1930 lines
65 KiB
TypeScript
1930 lines
65 KiB
TypeScript
// CI touch: enable server-side assignee filtering and trigger redeploy
|
|
import { mutation, query } from "./_generated/server";
|
|
import type { MutationCtx, QueryCtx } from "./_generated/server";
|
|
import { ConvexError, v } from "convex/values";
|
|
import { Id, type Doc } from "./_generated/dataModel";
|
|
|
|
import { requireAdmin, requireStaff, requireUser } from "./rbac";
|
|
|
|
const STAFF_ROLES = new Set(["ADMIN", "MANAGER", "AGENT"]);
|
|
const INTERNAL_STAFF_ROLES = new Set(["ADMIN", "AGENT"]);
|
|
const PAUSE_REASON_LABELS: Record<string, string> = {
|
|
NO_CONTACT: "Falta de contato",
|
|
WAITING_THIRD_PARTY: "Aguardando terceiro",
|
|
IN_PROCEDURE: "Em procedimento",
|
|
};
|
|
|
|
type TicketStatusNormalized = "PENDING" | "AWAITING_ATTENDANCE" | "PAUSED" | "RESOLVED";
|
|
|
|
const STATUS_LABELS: Record<TicketStatusNormalized, string> = {
|
|
PENDING: "Pendente",
|
|
AWAITING_ATTENDANCE: "Em andamento",
|
|
PAUSED: "Pausado",
|
|
RESOLVED: "Resolvido",
|
|
};
|
|
|
|
const LEGACY_STATUS_MAP: Record<string, TicketStatusNormalized> = {
|
|
NEW: "PENDING",
|
|
PENDING: "PENDING",
|
|
OPEN: "AWAITING_ATTENDANCE",
|
|
AWAITING_ATTENDANCE: "AWAITING_ATTENDANCE",
|
|
ON_HOLD: "PAUSED",
|
|
PAUSED: "PAUSED",
|
|
RESOLVED: "RESOLVED",
|
|
CLOSED: "RESOLVED",
|
|
};
|
|
|
|
const missingRequesterLogCache = new Set<string>();
|
|
const missingCommentAuthorLogCache = new Set<string>();
|
|
|
|
// Character limits (generous but bounded)
|
|
const MAX_SUMMARY_CHARS = 600;
|
|
const MAX_COMMENT_CHARS = 20000;
|
|
|
|
function plainTextLength(html: string): number {
|
|
try {
|
|
const text = String(html)
|
|
.replace(/<[^>]*>/g, "") // strip tags
|
|
.replace(/ /g, " ")
|
|
.trim();
|
|
return text.length;
|
|
} catch {
|
|
return String(html ?? "").length;
|
|
}
|
|
}
|
|
|
|
function normalizeStatus(status: string | null | undefined): TicketStatusNormalized {
|
|
if (!status) return "PENDING";
|
|
const normalized = LEGACY_STATUS_MAP[status.toUpperCase()];
|
|
return normalized ?? "PENDING";
|
|
}
|
|
|
|
async function ensureManagerTicketAccess(
|
|
ctx: MutationCtx | QueryCtx,
|
|
manager: Doc<"users">,
|
|
ticket: Doc<"tickets">,
|
|
): Promise<Doc<"users"> | null> {
|
|
if (!manager.companyId) {
|
|
throw new ConvexError("Gestor não possui empresa vinculada")
|
|
}
|
|
if (ticket.companyId && ticket.companyId === manager.companyId) {
|
|
return null
|
|
}
|
|
const requester = await ctx.db.get(ticket.requesterId)
|
|
if (!requester || requester.companyId !== manager.companyId) {
|
|
throw new ConvexError("Acesso restrito à empresa")
|
|
}
|
|
return requester as Doc<"users">
|
|
}
|
|
|
|
async function requireTicketStaff(
|
|
ctx: MutationCtx | QueryCtx,
|
|
actorId: Id<"users">,
|
|
ticket: Doc<"tickets">
|
|
) {
|
|
const viewer = await requireStaff(ctx, actorId, ticket.tenantId)
|
|
if (viewer.role === "MANAGER") {
|
|
await ensureManagerTicketAccess(ctx, viewer.user, ticket)
|
|
}
|
|
return viewer
|
|
}
|
|
|
|
const QUEUE_RENAME_LOOKUP: Record<string, string> = {
|
|
"Suporte N1": "Chamados",
|
|
"suporte-n1": "Chamados",
|
|
chamados: "Chamados",
|
|
"Suporte N2": "Laboratório",
|
|
"suporte-n2": "Laboratório",
|
|
laboratorio: "Laboratório",
|
|
Laboratorio: "Laboratório",
|
|
visitas: "Visitas",
|
|
};
|
|
|
|
function renameQueueString(value?: string | null): string | null {
|
|
if (!value) return value ?? null;
|
|
const direct = QUEUE_RENAME_LOOKUP[value];
|
|
if (direct) return direct;
|
|
const normalizedKey = value.replace(/\s+/g, "-").toLowerCase();
|
|
return QUEUE_RENAME_LOOKUP[normalizedKey] ?? value;
|
|
}
|
|
|
|
function normalizeQueueName(queue?: Doc<"queues"> | null): string | null {
|
|
if (!queue) return null;
|
|
const normalized = renameQueueString(queue.name);
|
|
if (normalized) {
|
|
return normalized;
|
|
}
|
|
if (queue.slug) {
|
|
const fromSlug = renameQueueString(queue.slug);
|
|
if (fromSlug) return fromSlug;
|
|
}
|
|
return queue.name;
|
|
}
|
|
|
|
function normalizeTeams(teams?: string[] | null): string[] {
|
|
if (!teams) return [];
|
|
return teams.map((team) => renameQueueString(team) ?? team);
|
|
}
|
|
|
|
type RequesterFallbackContext = {
|
|
ticketId?: Id<"tickets">;
|
|
fallbackName?: string | null;
|
|
fallbackEmail?: string | null;
|
|
};
|
|
|
|
function buildRequesterSummary(
|
|
requester: Doc<"users"> | null,
|
|
requesterId: Id<"users">,
|
|
context?: RequesterFallbackContext,
|
|
) {
|
|
if (requester) {
|
|
return {
|
|
id: requester._id,
|
|
name: requester.name,
|
|
email: requester.email,
|
|
avatarUrl: requester.avatarUrl,
|
|
teams: normalizeTeams(requester.teams),
|
|
};
|
|
}
|
|
|
|
const idString = String(requesterId);
|
|
const fallbackName =
|
|
typeof context?.fallbackName === "string" && context.fallbackName.trim().length > 0
|
|
? context.fallbackName.trim()
|
|
: "Solicitante não encontrado";
|
|
const fallbackEmailCandidate =
|
|
typeof context?.fallbackEmail === "string" && context.fallbackEmail.includes("@")
|
|
? context.fallbackEmail
|
|
: null;
|
|
const fallbackEmail = fallbackEmailCandidate ?? `requester-${idString}@example.invalid`;
|
|
|
|
if (process.env.NODE_ENV !== "test") {
|
|
const ticketInfo = context?.ticketId ? ` (ticket ${String(context.ticketId)})` : "";
|
|
const cacheKey = `${idString}:${context?.ticketId ? String(context.ticketId) : "unknown"}`;
|
|
if (!missingRequesterLogCache.has(cacheKey)) {
|
|
missingRequesterLogCache.add(cacheKey);
|
|
console.warn(
|
|
`[tickets] requester ${idString} ausente ao hidratar resposta${ticketInfo}; usando placeholders.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
return {
|
|
id: requesterId,
|
|
name: fallbackName,
|
|
email: fallbackEmail,
|
|
teams: [],
|
|
};
|
|
}
|
|
|
|
type UserSnapshot = { name: string; email?: string; avatarUrl?: string; teams?: string[] };
|
|
type CompanySnapshot = { name: string; slug?: string; isAvulso?: boolean };
|
|
|
|
function buildRequesterFromSnapshot(
|
|
requesterId: Id<"users">,
|
|
snapshot: UserSnapshot | null | undefined,
|
|
fallback?: RequesterFallbackContext
|
|
) {
|
|
if (snapshot) {
|
|
const name = typeof snapshot.name === "string" && snapshot.name.trim().length > 0 ? snapshot.name.trim() : (fallback?.fallbackName ?? "Solicitante não encontrado")
|
|
const emailCandidate = typeof snapshot.email === "string" && snapshot.email.includes("@") ? snapshot.email : null
|
|
const email = emailCandidate ?? (fallback?.fallbackEmail ?? `requester-${String(requesterId)}@example.invalid`)
|
|
return {
|
|
id: requesterId,
|
|
name,
|
|
email,
|
|
avatarUrl: snapshot.avatarUrl ?? undefined,
|
|
teams: normalizeTeams(snapshot.teams ?? []),
|
|
}
|
|
}
|
|
return buildRequesterSummary(null, requesterId, fallback)
|
|
}
|
|
|
|
function buildAssigneeFromSnapshot(
|
|
assigneeId: Id<"users">,
|
|
snapshot: UserSnapshot | null | undefined
|
|
) {
|
|
const name = snapshot?.name?.trim?.() || "Usuário removido"
|
|
const emailCandidate = typeof snapshot?.email === "string" && snapshot.email.includes("@") ? snapshot.email : null
|
|
const email = emailCandidate ?? `user-${String(assigneeId)}@example.invalid`
|
|
return {
|
|
id: assigneeId,
|
|
name,
|
|
email,
|
|
avatarUrl: snapshot?.avatarUrl ?? undefined,
|
|
teams: normalizeTeams(snapshot?.teams ?? []),
|
|
}
|
|
}
|
|
|
|
function buildCompanyFromSnapshot(
|
|
companyId: Id<"companies"> | undefined,
|
|
snapshot: CompanySnapshot | null | undefined
|
|
) {
|
|
if (!snapshot) return null
|
|
return {
|
|
id: (companyId ? companyId : ("snapshot" as unknown as Id<"companies">)) as Id<"companies">,
|
|
name: snapshot.name,
|
|
isAvulso: Boolean(snapshot.isAvulso ?? false),
|
|
}
|
|
}
|
|
|
|
type CommentAuthorFallbackContext = {
|
|
ticketId?: Id<"tickets">;
|
|
commentId?: Id<"ticketComments">;
|
|
};
|
|
|
|
type CommentAuthorSnapshot = {
|
|
name: string;
|
|
email?: string;
|
|
avatarUrl?: string;
|
|
teams?: string[];
|
|
};
|
|
|
|
export function buildCommentAuthorSummary(
|
|
comment: Doc<"ticketComments">,
|
|
author: Doc<"users"> | null,
|
|
context?: CommentAuthorFallbackContext,
|
|
) {
|
|
if (author) {
|
|
return {
|
|
id: author._id,
|
|
name: author.name,
|
|
email: author.email,
|
|
avatarUrl: author.avatarUrl,
|
|
teams: normalizeTeams(author.teams),
|
|
};
|
|
}
|
|
|
|
if (process.env.NODE_ENV !== "test") {
|
|
const ticketInfo = context?.ticketId ? ` (ticket ${String(context.ticketId)})` : "";
|
|
const commentInfo = context?.commentId ? ` (comentário ${String(context.commentId)})` : "";
|
|
const cacheKeyParts = [String(comment.authorId), context?.ticketId ? String(context.ticketId) : "unknown"];
|
|
if (context?.commentId) cacheKeyParts.push(String(context.commentId));
|
|
const cacheKey = cacheKeyParts.join(":");
|
|
if (!missingCommentAuthorLogCache.has(cacheKey)) {
|
|
missingCommentAuthorLogCache.add(cacheKey);
|
|
console.warn(
|
|
`[tickets] autor ${String(comment.authorId)} ausente ao hidratar comentário${ticketInfo}${commentInfo}; usando placeholders.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
const idString = String(comment.authorId);
|
|
const fallbackName = "Usuário removido";
|
|
const fallbackEmail = `author-${idString}@example.invalid`;
|
|
const snapshot = comment.authorSnapshot as CommentAuthorSnapshot | undefined;
|
|
if (snapshot) {
|
|
const name =
|
|
typeof snapshot.name === "string" && snapshot.name.trim().length > 0
|
|
? snapshot.name.trim()
|
|
: fallbackName;
|
|
const emailCandidate =
|
|
typeof snapshot.email === "string" && snapshot.email.includes("@") ? snapshot.email : null;
|
|
const email = emailCandidate ?? fallbackEmail;
|
|
return {
|
|
id: comment.authorId,
|
|
name,
|
|
email,
|
|
avatarUrl: snapshot.avatarUrl ?? undefined,
|
|
teams: normalizeTeams(snapshot.teams ?? []),
|
|
};
|
|
}
|
|
|
|
return {
|
|
id: comment.authorId,
|
|
name: fallbackName,
|
|
email: fallbackEmail,
|
|
teams: [],
|
|
};
|
|
}
|
|
|
|
type CustomFieldInput = {
|
|
fieldId: Id<"ticketFields">;
|
|
value: unknown;
|
|
};
|
|
|
|
type NormalizedCustomField = {
|
|
fieldId: Id<"ticketFields">;
|
|
fieldKey: string;
|
|
label: string;
|
|
type: string;
|
|
value: unknown;
|
|
displayValue?: string;
|
|
};
|
|
|
|
function coerceCustomFieldValue(field: Doc<"ticketFields">, raw: unknown): { value: unknown; displayValue?: string } {
|
|
switch (field.type) {
|
|
case "text":
|
|
return { value: String(raw).trim() };
|
|
case "number": {
|
|
const value = typeof raw === "number" ? raw : Number(String(raw).replace(",", "."));
|
|
if (!Number.isFinite(value)) {
|
|
throw new ConvexError(`Valor numérico inválido para o campo ${field.label}`);
|
|
}
|
|
return { value };
|
|
}
|
|
case "date": {
|
|
if (typeof raw === "number") {
|
|
if (!Number.isFinite(raw)) {
|
|
throw new ConvexError(`Data inválida para o campo ${field.label}`);
|
|
}
|
|
return { value: raw };
|
|
}
|
|
const parsed = Date.parse(String(raw));
|
|
if (!Number.isFinite(parsed)) {
|
|
throw new ConvexError(`Data inválida para o campo ${field.label}`);
|
|
}
|
|
return { value: parsed };
|
|
}
|
|
case "boolean": {
|
|
if (typeof raw === "boolean") {
|
|
return { value: raw };
|
|
}
|
|
if (typeof raw === "string") {
|
|
const normalized = raw.toLowerCase();
|
|
if (normalized === "true" || normalized === "1") return { value: true };
|
|
if (normalized === "false" || normalized === "0") return { value: false };
|
|
}
|
|
throw new ConvexError(`Valor inválido para o campo ${field.label}`);
|
|
}
|
|
case "select": {
|
|
if (!field.options || field.options.length === 0) {
|
|
throw new ConvexError(`Campo ${field.label} sem opções configuradas`);
|
|
}
|
|
const value = String(raw);
|
|
const option = field.options.find((opt) => opt.value === value);
|
|
if (!option) {
|
|
throw new ConvexError(`Seleção inválida para o campo ${field.label}`);
|
|
}
|
|
return { value: option.value, displayValue: option.label ?? option.value };
|
|
}
|
|
default:
|
|
return { value: raw };
|
|
}
|
|
}
|
|
|
|
async function normalizeCustomFieldValues(
|
|
ctx: Pick<MutationCtx, "db">,
|
|
tenantId: string,
|
|
inputs: CustomFieldInput[] | undefined
|
|
): Promise<NormalizedCustomField[]> {
|
|
const definitions = await ctx.db
|
|
.query("ticketFields")
|
|
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
|
|
.collect();
|
|
|
|
if (!definitions.length) {
|
|
if (inputs && inputs.length > 0) {
|
|
throw new ConvexError("Nenhum campo personalizado configurado para este tenant");
|
|
}
|
|
return [];
|
|
}
|
|
|
|
const provided = new Map<Id<"ticketFields">, unknown>();
|
|
for (const entry of inputs ?? []) {
|
|
provided.set(entry.fieldId, entry.value);
|
|
}
|
|
|
|
const normalized: NormalizedCustomField[] = [];
|
|
|
|
for (const definition of definitions.sort((a, b) => a.order - b.order)) {
|
|
const raw = provided.has(definition._id) ? provided.get(definition._id) : undefined;
|
|
const isMissing =
|
|
raw === undefined ||
|
|
raw === null ||
|
|
(typeof raw === "string" && raw.trim().length === 0);
|
|
|
|
if (isMissing) {
|
|
if (definition.required) {
|
|
throw new ConvexError(`Preencha o campo obrigatório: ${definition.label}`);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
const { value, displayValue } = coerceCustomFieldValue(definition, raw);
|
|
normalized.push({
|
|
fieldId: definition._id,
|
|
fieldKey: definition.key,
|
|
label: definition.label,
|
|
type: definition.type,
|
|
value,
|
|
displayValue,
|
|
});
|
|
}
|
|
|
|
return normalized;
|
|
}
|
|
|
|
function mapCustomFieldsToRecord(entries: NormalizedCustomField[] | undefined) {
|
|
if (!entries || entries.length === 0) return {};
|
|
return entries.reduce<Record<string, { label: string; type: string; value: unknown; displayValue?: string }>>((acc, entry) => {
|
|
acc[entry.fieldKey] = {
|
|
label: entry.label,
|
|
type: entry.type,
|
|
value: entry.value,
|
|
displayValue: entry.displayValue,
|
|
};
|
|
return acc;
|
|
}, {});
|
|
}
|
|
|
|
export const list = query({
|
|
args: {
|
|
viewerId: v.optional(v.id("users")),
|
|
tenantId: v.string(),
|
|
status: v.optional(v.string()),
|
|
priority: v.optional(v.string()),
|
|
channel: v.optional(v.string()),
|
|
queueId: v.optional(v.id("queues")),
|
|
assigneeId: v.optional(v.id("users")),
|
|
requesterId: v.optional(v.id("users")),
|
|
search: v.optional(v.string()),
|
|
limit: v.optional(v.number()),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
if (!args.viewerId) {
|
|
return []
|
|
}
|
|
const { user, role } = await requireUser(ctx, args.viewerId, args.tenantId)
|
|
|
|
// Choose best index based on provided args for efficiency
|
|
let base: Doc<"tickets">[] = [];
|
|
if (role === "MANAGER") {
|
|
if (!user.companyId) {
|
|
throw new ConvexError("Gestor não possui empresa vinculada")
|
|
}
|
|
// Managers are scoped to company; allow secondary narrowing by requester/assignee
|
|
base = await ctx.db
|
|
.query("tickets")
|
|
.withIndex("by_tenant_company", (q) => q.eq("tenantId", args.tenantId).eq("companyId", user.companyId!))
|
|
.collect();
|
|
} else if (args.assigneeId) {
|
|
base = await ctx.db
|
|
.query("tickets")
|
|
.withIndex("by_tenant_assignee", (q) => q.eq("tenantId", args.tenantId).eq("assigneeId", args.assigneeId!))
|
|
.collect();
|
|
} else if (args.requesterId) {
|
|
base = await ctx.db
|
|
.query("tickets")
|
|
.withIndex("by_tenant_requester", (q) => q.eq("tenantId", args.tenantId).eq("requesterId", args.requesterId!))
|
|
.collect();
|
|
} else if (args.queueId) {
|
|
base = await ctx.db
|
|
.query("tickets")
|
|
.withIndex("by_tenant_queue", (q) => q.eq("tenantId", args.tenantId).eq("queueId", args.queueId!))
|
|
.collect();
|
|
} else if (role === "COLLABORATOR") {
|
|
// Colaborador: exibir apenas tickets onde ele é o solicitante
|
|
// Compatibilidade por e-mail: inclui tickets com requesterSnapshot.email == e-mail do viewer
|
|
const all = await ctx.db
|
|
.query("tickets")
|
|
.withIndex("by_tenant", (q) => q.eq("tenantId", args.tenantId))
|
|
.collect()
|
|
const viewerEmail = user.email.trim().toLowerCase()
|
|
base = all.filter((t) => {
|
|
if (t.requesterId === args.viewerId) return true
|
|
const rs = t.requesterSnapshot as { email?: string } | undefined
|
|
const email = typeof rs?.email === "string" ? rs.email.trim().toLowerCase() : null
|
|
return Boolean(email && email === viewerEmail)
|
|
})
|
|
} else {
|
|
base = await ctx.db
|
|
.query("tickets")
|
|
.withIndex("by_tenant", (q) => q.eq("tenantId", args.tenantId))
|
|
.collect();
|
|
}
|
|
let filtered = base;
|
|
|
|
if (role === "MANAGER") {
|
|
if (!user.companyId) {
|
|
throw new ConvexError("Gestor não possui empresa vinculada")
|
|
}
|
|
filtered = filtered.filter((t) => t.companyId === user.companyId)
|
|
}
|
|
const normalizedStatusFilter = args.status ? normalizeStatus(args.status) : null;
|
|
|
|
if (args.priority) filtered = filtered.filter((t) => t.priority === args.priority);
|
|
if (args.channel) filtered = filtered.filter((t) => t.channel === args.channel);
|
|
if (args.assigneeId) filtered = filtered.filter((t) => String(t.assigneeId ?? "") === String(args.assigneeId));
|
|
if (args.requesterId) filtered = filtered.filter((t) => String(t.requesterId) === String(args.requesterId));
|
|
if (normalizedStatusFilter) {
|
|
filtered = filtered.filter((t) => normalizeStatus(t.status) === normalizedStatusFilter);
|
|
}
|
|
if (args.search) {
|
|
const term = args.search.toLowerCase();
|
|
filtered = filtered.filter(
|
|
(t) =>
|
|
t.subject.toLowerCase().includes(term) ||
|
|
t.summary?.toLowerCase().includes(term) ||
|
|
`#${t.reference}`.toLowerCase().includes(term)
|
|
);
|
|
}
|
|
const limited = args.limit ? filtered.slice(0, args.limit) : filtered;
|
|
const categoryCache = new Map<string, Doc<"ticketCategories"> | null>();
|
|
const subcategoryCache = new Map<string, Doc<"ticketSubcategories"> | null>();
|
|
// hydrate requester and assignee
|
|
const result = await Promise.all(
|
|
limited.map(async (t) => {
|
|
const requester = (await ctx.db.get(t.requesterId)) as Doc<"users"> | null;
|
|
const assignee = t.assigneeId ? ((await ctx.db.get(t.assigneeId)) as Doc<"users"> | null) : null;
|
|
const queue = t.queueId ? ((await ctx.db.get(t.queueId)) as Doc<"queues"> | null) : null;
|
|
const company = t.companyId ? ((await ctx.db.get(t.companyId)) as Doc<"companies"> | null) : null;
|
|
const queueName = normalizeQueueName(queue);
|
|
const activeSession = t.activeSessionId ? await ctx.db.get(t.activeSessionId) : null;
|
|
let categorySummary: { id: Id<"ticketCategories">; name: string } | null = null;
|
|
let subcategorySummary: { id: Id<"ticketSubcategories">; name: string } | null = null;
|
|
if (t.categoryId) {
|
|
if (!categoryCache.has(t.categoryId)) {
|
|
categoryCache.set(t.categoryId, await ctx.db.get(t.categoryId));
|
|
}
|
|
const category = categoryCache.get(t.categoryId);
|
|
if (category) {
|
|
categorySummary = { id: category._id, name: category.name };
|
|
}
|
|
}
|
|
if (t.subcategoryId) {
|
|
if (!subcategoryCache.has(t.subcategoryId)) {
|
|
subcategoryCache.set(t.subcategoryId, await ctx.db.get(t.subcategoryId));
|
|
}
|
|
const subcategory = subcategoryCache.get(t.subcategoryId);
|
|
if (subcategory) {
|
|
subcategorySummary = { id: subcategory._id, name: subcategory.name };
|
|
}
|
|
}
|
|
const serverNow = Date.now()
|
|
return {
|
|
id: t._id,
|
|
reference: t.reference,
|
|
tenantId: t.tenantId,
|
|
subject: t.subject,
|
|
summary: t.summary,
|
|
status: normalizeStatus(t.status),
|
|
priority: t.priority,
|
|
channel: t.channel,
|
|
queue: queueName,
|
|
company: company
|
|
? { id: company._id, name: company.name, isAvulso: company.isAvulso ?? false }
|
|
: t.companyId || t.companySnapshot
|
|
? buildCompanyFromSnapshot(t.companyId as Id<"companies"> | undefined, t.companySnapshot ?? undefined)
|
|
: null,
|
|
requester: requester
|
|
? buildRequesterSummary(requester, t.requesterId, { ticketId: t._id })
|
|
: buildRequesterFromSnapshot(
|
|
t.requesterId,
|
|
t.requesterSnapshot ?? undefined,
|
|
{ ticketId: t._id }
|
|
),
|
|
assignee: t.assigneeId
|
|
? assignee
|
|
? {
|
|
id: assignee._id,
|
|
name: assignee.name,
|
|
email: assignee.email,
|
|
avatarUrl: assignee.avatarUrl,
|
|
teams: normalizeTeams(assignee.teams),
|
|
}
|
|
: buildAssigneeFromSnapshot(t.assigneeId, t.assigneeSnapshot ?? undefined)
|
|
: null,
|
|
slaPolicy: null,
|
|
dueAt: t.dueAt ?? null,
|
|
firstResponseAt: t.firstResponseAt ?? null,
|
|
resolvedAt: t.resolvedAt ?? null,
|
|
updatedAt: t.updatedAt,
|
|
createdAt: t.createdAt,
|
|
tags: t.tags ?? [],
|
|
lastTimelineEntry: null,
|
|
metrics: null,
|
|
category: categorySummary,
|
|
subcategory: subcategorySummary,
|
|
workSummary: {
|
|
totalWorkedMs: t.totalWorkedMs ?? 0,
|
|
internalWorkedMs: t.internalWorkedMs ?? 0,
|
|
externalWorkedMs: t.externalWorkedMs ?? 0,
|
|
serverNow,
|
|
activeSession: activeSession
|
|
? {
|
|
id: activeSession._id,
|
|
agentId: activeSession.agentId,
|
|
startedAt: activeSession.startedAt,
|
|
workType: activeSession.workType ?? "INTERNAL",
|
|
}
|
|
: null,
|
|
},
|
|
};
|
|
})
|
|
);
|
|
// sort by updatedAt desc
|
|
return result.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
|
|
},
|
|
});
|
|
|
|
export const getById = query({
|
|
args: { tenantId: v.string(), id: v.id("tickets"), viewerId: v.id("users") },
|
|
handler: async (ctx, { tenantId, id, viewerId }) => {
|
|
const { user, role } = await requireUser(ctx, viewerId, tenantId)
|
|
const t = await ctx.db.get(id);
|
|
if (!t || t.tenantId !== tenantId) return null;
|
|
if (role === "COLLABORATOR") {
|
|
const isOwnerById = String(t.requesterId) === String(viewerId)
|
|
const snapshotEmail = (t.requesterSnapshot as { email?: string } | undefined)?.email?.trim().toLowerCase?.() ?? null
|
|
const viewerEmail = user.email.trim().toLowerCase()
|
|
const isOwnerByEmail = Boolean(snapshotEmail && snapshotEmail === viewerEmail)
|
|
if (!isOwnerById && !isOwnerByEmail) {
|
|
return null
|
|
}
|
|
}
|
|
// no customer role; managers are constrained to company via ensureManagerTicketAccess
|
|
let requester: Doc<"users"> | null = null
|
|
if (role === "MANAGER") {
|
|
requester = (await ensureManagerTicketAccess(ctx, user, t)) ?? null
|
|
}
|
|
if (!requester) {
|
|
requester = (await ctx.db.get(t.requesterId)) as Doc<"users"> | null
|
|
}
|
|
const assignee = t.assigneeId ? ((await ctx.db.get(t.assigneeId)) as Doc<"users"> | null) : null;
|
|
const queue = t.queueId ? ((await ctx.db.get(t.queueId)) as Doc<"queues"> | null) : null;
|
|
const company = t.companyId ? ((await ctx.db.get(t.companyId)) as Doc<"companies"> | null) : null;
|
|
const queueName = normalizeQueueName(queue);
|
|
const category = t.categoryId ? await ctx.db.get(t.categoryId) : null;
|
|
const subcategory = t.subcategoryId ? await ctx.db.get(t.subcategoryId) : null;
|
|
const comments = await ctx.db
|
|
.query("ticketComments")
|
|
.withIndex("by_ticket", (q) => q.eq("ticketId", id))
|
|
.collect();
|
|
const canViewInternalComments = role === "ADMIN" || role === "AGENT";
|
|
const visibleComments = canViewInternalComments
|
|
? comments
|
|
: comments.filter((comment) => comment.visibility !== "INTERNAL");
|
|
const visibleCommentKeys = new Set(
|
|
visibleComments.map((comment) => `${comment.createdAt}:${comment.authorId}`)
|
|
)
|
|
const visibleCommentTimestamps = new Set(visibleComments.map((comment) => comment.createdAt))
|
|
const serverNow = Date.now()
|
|
|
|
let timelineRecords = await ctx.db
|
|
.query("ticketEvents")
|
|
.withIndex("by_ticket", (q) => q.eq("ticketId", id))
|
|
.collect();
|
|
|
|
if (!(role === "ADMIN" || role === "AGENT")) {
|
|
timelineRecords = timelineRecords.filter((event) => {
|
|
const payload = (event.payload ?? {}) as Record<string, unknown>
|
|
switch (event.type) {
|
|
case "CREATED":
|
|
return true
|
|
case "QUEUE_CHANGED":
|
|
return true
|
|
case "ASSIGNEE_CHANGED":
|
|
return true
|
|
case "CATEGORY_CHANGED":
|
|
return true
|
|
case "COMMENT_ADDED": {
|
|
const authorIdRaw = (payload as { authorId?: string }).authorId
|
|
if (typeof authorIdRaw === "string" && authorIdRaw.trim().length > 0) {
|
|
const key = `${event.createdAt}:${authorIdRaw}`
|
|
if (visibleCommentKeys.has(key)) {
|
|
return true
|
|
}
|
|
}
|
|
return visibleCommentTimestamps.has(event.createdAt)
|
|
}
|
|
case "STATUS_CHANGED": {
|
|
const toLabelRaw = (payload as { toLabel?: string }).toLabel
|
|
const toRaw = (payload as { to?: string }).to
|
|
const normalized = (typeof toLabelRaw === "string" && toLabelRaw.trim().length > 0
|
|
? toLabelRaw.trim()
|
|
: typeof toRaw === "string"
|
|
? toRaw.trim()
|
|
: "").toUpperCase()
|
|
if (!normalized) return false
|
|
return (
|
|
normalized === "RESOLVED" ||
|
|
normalized === "RESOLVIDO" ||
|
|
normalized === "CLOSED" ||
|
|
normalized === "FINALIZADO" ||
|
|
normalized === "FINALIZED"
|
|
)
|
|
}
|
|
default:
|
|
return false
|
|
}
|
|
})
|
|
}
|
|
|
|
const customFieldsRecord = mapCustomFieldsToRecord(
|
|
(t.customFields as NormalizedCustomField[] | undefined) ?? undefined
|
|
);
|
|
|
|
const commentsHydrated = await Promise.all(
|
|
visibleComments.map(async (c) => {
|
|
const author = (await ctx.db.get(c.authorId)) as Doc<"users"> | null;
|
|
const attachments = await Promise.all(
|
|
(c.attachments ?? []).map(async (att) => ({
|
|
id: att.storageId,
|
|
name: att.name,
|
|
size: att.size,
|
|
type: att.type,
|
|
url: await ctx.storage.getUrl(att.storageId),
|
|
}))
|
|
);
|
|
const authorSummary = buildCommentAuthorSummary(c, author, {
|
|
ticketId: t._id,
|
|
commentId: c._id,
|
|
});
|
|
return {
|
|
id: c._id,
|
|
author: authorSummary,
|
|
visibility: c.visibility,
|
|
body: c.body,
|
|
attachments,
|
|
createdAt: c.createdAt,
|
|
updatedAt: c.updatedAt,
|
|
};
|
|
})
|
|
);
|
|
|
|
const activeSession = t.activeSessionId ? await ctx.db.get(t.activeSessionId) : null;
|
|
|
|
return {
|
|
id: t._id,
|
|
reference: t.reference,
|
|
tenantId: t.tenantId,
|
|
subject: t.subject,
|
|
summary: t.summary,
|
|
status: normalizeStatus(t.status),
|
|
priority: t.priority,
|
|
channel: t.channel,
|
|
queue: queueName,
|
|
company: company
|
|
? { id: company._id, name: company.name, isAvulso: company.isAvulso ?? false }
|
|
: t.companyId || t.companySnapshot
|
|
? buildCompanyFromSnapshot(t.companyId as Id<"companies"> | undefined, t.companySnapshot ?? undefined)
|
|
: null,
|
|
requester: requester
|
|
? buildRequesterSummary(requester, t.requesterId, { ticketId: t._id })
|
|
: buildRequesterFromSnapshot(
|
|
t.requesterId,
|
|
t.requesterSnapshot ?? undefined,
|
|
{ ticketId: t._id }
|
|
),
|
|
assignee: t.assigneeId
|
|
? assignee
|
|
? {
|
|
id: assignee._id,
|
|
name: assignee.name,
|
|
email: assignee.email,
|
|
avatarUrl: assignee.avatarUrl,
|
|
teams: normalizeTeams(assignee.teams),
|
|
}
|
|
: buildAssigneeFromSnapshot(t.assigneeId, t.assigneeSnapshot ?? undefined)
|
|
: null,
|
|
slaPolicy: null,
|
|
dueAt: t.dueAt ?? null,
|
|
firstResponseAt: t.firstResponseAt ?? null,
|
|
resolvedAt: t.resolvedAt ?? null,
|
|
updatedAt: t.updatedAt,
|
|
createdAt: t.createdAt,
|
|
tags: t.tags ?? [],
|
|
lastTimelineEntry: null,
|
|
metrics: null,
|
|
category: category
|
|
? {
|
|
id: category._id,
|
|
name: category.name,
|
|
}
|
|
: null,
|
|
subcategory: subcategory
|
|
? {
|
|
id: subcategory._id,
|
|
name: subcategory.name,
|
|
categoryId: subcategory.categoryId,
|
|
}
|
|
: null,
|
|
workSummary: {
|
|
totalWorkedMs: t.totalWorkedMs ?? 0,
|
|
internalWorkedMs: t.internalWorkedMs ?? 0,
|
|
externalWorkedMs: t.externalWorkedMs ?? 0,
|
|
serverNow,
|
|
activeSession: activeSession
|
|
? {
|
|
id: activeSession._id,
|
|
agentId: activeSession.agentId,
|
|
startedAt: activeSession.startedAt,
|
|
workType: activeSession.workType ?? "INTERNAL",
|
|
}
|
|
: null,
|
|
},
|
|
description: undefined,
|
|
customFields: customFieldsRecord,
|
|
timeline: timelineRecords.map((ev) => {
|
|
let payload = ev.payload;
|
|
if (ev.type === "QUEUE_CHANGED" && payload && typeof payload === "object" && "queueName" in payload) {
|
|
const normalized = renameQueueString((payload as { queueName?: string }).queueName ?? null);
|
|
if (normalized && normalized !== (payload as { queueName?: string }).queueName) {
|
|
payload = { ...payload, queueName: normalized };
|
|
}
|
|
}
|
|
return {
|
|
id: ev._id,
|
|
type: ev.type,
|
|
payload,
|
|
createdAt: ev.createdAt,
|
|
};
|
|
}),
|
|
comments: commentsHydrated,
|
|
};
|
|
},
|
|
});
|
|
|
|
export const create = mutation({
|
|
args: {
|
|
actorId: v.id("users"),
|
|
tenantId: v.string(),
|
|
subject: v.string(),
|
|
summary: v.optional(v.string()),
|
|
priority: v.string(),
|
|
channel: v.string(),
|
|
queueId: v.optional(v.id("queues")),
|
|
requesterId: v.id("users"),
|
|
assigneeId: v.optional(v.id("users")),
|
|
categoryId: v.id("ticketCategories"),
|
|
subcategoryId: v.id("ticketSubcategories"),
|
|
customFields: v.optional(
|
|
v.array(
|
|
v.object({
|
|
fieldId: v.id("ticketFields"),
|
|
value: v.any(),
|
|
})
|
|
)
|
|
),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const { user: actorUser, role } = await requireUser(ctx, args.actorId, args.tenantId)
|
|
// no customer role; managers validated below
|
|
|
|
if (args.assigneeId && (!role || !INTERNAL_STAFF_ROLES.has(role))) {
|
|
throw new ConvexError("Somente a equipe interna pode definir o responsável")
|
|
}
|
|
|
|
let initialAssigneeId: Id<"users"> | undefined
|
|
let initialAssignee: Doc<"users"> | null = null
|
|
|
|
if (args.assigneeId) {
|
|
const assignee = (await ctx.db.get(args.assigneeId)) as Doc<"users"> | null
|
|
if (!assignee || assignee.tenantId !== args.tenantId) {
|
|
throw new ConvexError("Responsável inválido")
|
|
}
|
|
const normalizedAssigneeRole = (assignee.role ?? "AGENT").toUpperCase()
|
|
if (!STAFF_ROLES.has(normalizedAssigneeRole)) {
|
|
throw new ConvexError("Responsável inválido")
|
|
}
|
|
initialAssigneeId = assignee._id
|
|
initialAssignee = assignee
|
|
} else if (role && INTERNAL_STAFF_ROLES.has(role)) {
|
|
initialAssigneeId = actorUser._id
|
|
initialAssignee = actorUser
|
|
}
|
|
|
|
const subject = args.subject.trim();
|
|
if (subject.length < 3) {
|
|
throw new ConvexError("Informe um assunto com pelo menos 3 caracteres");
|
|
}
|
|
if (args.summary && args.summary.trim().length > MAX_SUMMARY_CHARS) {
|
|
throw new ConvexError(`Resumo muito longo (máx. ${MAX_SUMMARY_CHARS} caracteres)`);
|
|
}
|
|
const category = await ctx.db.get(args.categoryId);
|
|
if (!category || category.tenantId !== args.tenantId) {
|
|
throw new ConvexError("Categoria inválida");
|
|
}
|
|
const subcategory = await ctx.db.get(args.subcategoryId);
|
|
if (!subcategory || subcategory.categoryId !== args.categoryId || subcategory.tenantId !== args.tenantId) {
|
|
throw new ConvexError("Subcategoria inválida");
|
|
}
|
|
|
|
const requester = (await ctx.db.get(args.requesterId)) as Doc<"users"> | null
|
|
if (!requester || requester.tenantId !== args.tenantId) {
|
|
throw new ConvexError("Solicitante inválido")
|
|
}
|
|
if (role === "MANAGER") {
|
|
if (!actorUser.companyId) {
|
|
throw new ConvexError("Gestor não possui empresa vinculada")
|
|
}
|
|
if (requester.companyId !== actorUser.companyId) {
|
|
throw new ConvexError("Gestores só podem abrir chamados para sua própria empresa")
|
|
}
|
|
}
|
|
|
|
const normalizedCustomFields = await normalizeCustomFieldValues(ctx, args.tenantId, args.customFields ?? undefined);
|
|
// compute next reference (simple monotonic counter per tenant)
|
|
const existing = await ctx.db
|
|
.query("tickets")
|
|
.withIndex("by_tenant_reference", (q) => q.eq("tenantId", args.tenantId))
|
|
.order("desc")
|
|
.take(1);
|
|
const nextRef = existing[0]?.reference ? existing[0].reference + 1 : 41000;
|
|
const now = Date.now();
|
|
const initialStatus: TicketStatusNormalized = initialAssigneeId ? "AWAITING_ATTENDANCE" : "PENDING";
|
|
const requesterSnapshot = {
|
|
name: requester.name,
|
|
email: requester.email,
|
|
avatarUrl: requester.avatarUrl ?? undefined,
|
|
teams: requester.teams ?? undefined,
|
|
}
|
|
const companyDoc = requester.companyId ? (await ctx.db.get(requester.companyId)) : null
|
|
const companySnapshot = companyDoc
|
|
? { name: companyDoc.name, slug: companyDoc.slug, isAvulso: companyDoc.isAvulso ?? undefined }
|
|
: undefined
|
|
|
|
const assigneeSnapshot = initialAssignee
|
|
? {
|
|
name: initialAssignee.name,
|
|
email: initialAssignee.email,
|
|
avatarUrl: initialAssignee.avatarUrl ?? undefined,
|
|
teams: initialAssignee.teams ?? undefined,
|
|
}
|
|
: undefined
|
|
|
|
// default queue: if none provided, prefer "Chamados"
|
|
let resolvedQueueId = args.queueId as Id<"queues"> | undefined
|
|
if (!resolvedQueueId) {
|
|
const queues = await ctx.db
|
|
.query("queues")
|
|
.withIndex("by_tenant", (q) => q.eq("tenantId", args.tenantId))
|
|
.collect()
|
|
const preferred = queues.find((q) => q.slug === "chamados") || queues.find((q) => q.name === "Chamados") || null
|
|
if (preferred) {
|
|
resolvedQueueId = preferred._id as Id<"queues">
|
|
}
|
|
}
|
|
|
|
const id = await ctx.db.insert("tickets", {
|
|
tenantId: args.tenantId,
|
|
reference: nextRef,
|
|
subject,
|
|
summary: args.summary?.trim() || undefined,
|
|
status: initialStatus,
|
|
priority: args.priority,
|
|
channel: args.channel,
|
|
queueId: resolvedQueueId,
|
|
categoryId: args.categoryId,
|
|
subcategoryId: args.subcategoryId,
|
|
requesterId: args.requesterId,
|
|
requesterSnapshot,
|
|
assigneeId: initialAssigneeId,
|
|
assigneeSnapshot,
|
|
companyId: requester.companyId ?? undefined,
|
|
companySnapshot,
|
|
working: false,
|
|
activeSessionId: undefined,
|
|
totalWorkedMs: 0,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
firstResponseAt: undefined,
|
|
resolvedAt: undefined,
|
|
closedAt: undefined,
|
|
tags: [],
|
|
slaPolicyId: undefined,
|
|
dueAt: undefined,
|
|
customFields: normalizedCustomFields.length ? normalizedCustomFields : undefined,
|
|
});
|
|
await ctx.db.insert("ticketEvents", {
|
|
ticketId: id,
|
|
type: "CREATED",
|
|
payload: { requesterId: args.requesterId, requesterName: requester?.name, requesterAvatar: requester?.avatarUrl },
|
|
createdAt: now,
|
|
});
|
|
|
|
if (initialAssigneeId && initialAssignee) {
|
|
await ctx.db.insert("ticketEvents", {
|
|
ticketId: id,
|
|
type: "ASSIGNEE_CHANGED",
|
|
payload: { assigneeId: initialAssigneeId, assigneeName: initialAssignee.name, actorId: args.actorId },
|
|
createdAt: now,
|
|
})
|
|
}
|
|
|
|
return id;
|
|
},
|
|
});
|
|
|
|
export const addComment = mutation({
|
|
args: {
|
|
ticketId: v.id("tickets"),
|
|
authorId: v.id("users"),
|
|
visibility: v.string(),
|
|
body: v.string(),
|
|
attachments: v.optional(
|
|
v.array(
|
|
v.object({
|
|
storageId: v.id("_storage"),
|
|
name: v.string(),
|
|
size: v.optional(v.number()),
|
|
type: v.optional(v.string()),
|
|
})
|
|
)
|
|
),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const ticket = await ctx.db.get(args.ticketId);
|
|
if (!ticket) {
|
|
throw new ConvexError("Ticket não encontrado")
|
|
}
|
|
const ticketDoc = ticket as Doc<"tickets">
|
|
|
|
const author = (await ctx.db.get(args.authorId)) as Doc<"users"> | null
|
|
if (!author || author.tenantId !== ticketDoc.tenantId) {
|
|
throw new ConvexError("Autor do comentário inválido")
|
|
}
|
|
|
|
const normalizedRole = (author.role ?? "AGENT").toUpperCase()
|
|
|
|
const requestedVisibility = (args.visibility ?? "").toUpperCase()
|
|
if (requestedVisibility !== "PUBLIC" && requestedVisibility !== "INTERNAL") {
|
|
throw new ConvexError("Visibilidade inválida")
|
|
}
|
|
|
|
if (normalizedRole === "MANAGER") {
|
|
await ensureManagerTicketAccess(ctx, author, ticketDoc)
|
|
if (requestedVisibility !== "PUBLIC") {
|
|
throw new ConvexError("Gestores só podem registrar comentários públicos")
|
|
}
|
|
}
|
|
const canUseInternalComments = normalizedRole === "ADMIN" || normalizedRole === "AGENT"
|
|
if (requestedVisibility === "INTERNAL" && !canUseInternalComments) {
|
|
throw new ConvexError("Apenas administradores e agentes podem registrar comentários internos")
|
|
}
|
|
|
|
// Regra: a equipe (ADMIN/AGENT/MANAGER) só pode comentar se o ticket tiver responsável.
|
|
// O solicitante (colaborador) pode comentar sempre.
|
|
const isRequester = String(ticketDoc.requesterId) === String(author._id)
|
|
const isAdminOrAgent = normalizedRole === "ADMIN" || normalizedRole === "AGENT"
|
|
const hasAssignee = Boolean(ticketDoc.assigneeId)
|
|
// Gestores podem comentar mesmo sem responsável; admin/agent só com responsável
|
|
if (!isRequester && isAdminOrAgent && !hasAssignee) {
|
|
throw new ConvexError("Somente é possível comentar quando o chamado possui um responsável.")
|
|
}
|
|
|
|
if (ticketDoc.requesterId === args.authorId) {
|
|
// O próprio solicitante pode comentar seu ticket.
|
|
// Comentários internos já são bloqueados acima para quem não é STAFF.
|
|
// Portanto, nada a fazer aqui.
|
|
} else {
|
|
await requireTicketStaff(ctx, args.authorId, ticketDoc)
|
|
}
|
|
|
|
const attachments = args.attachments ?? []
|
|
if (attachments.length > 5) {
|
|
throw new ConvexError("É permitido anexar no máximo 5 arquivos por comentário")
|
|
}
|
|
const maxAttachmentSize = 5 * 1024 * 1024
|
|
for (const attachment of attachments) {
|
|
if (typeof attachment.size === "number" && attachment.size > maxAttachmentSize) {
|
|
throw new ConvexError("Cada anexo pode ter até 5MB")
|
|
}
|
|
}
|
|
|
|
const bodyPlainLen = plainTextLength(args.body)
|
|
if (bodyPlainLen > MAX_COMMENT_CHARS) {
|
|
throw new ConvexError(`Comentário muito longo (máx. ${MAX_COMMENT_CHARS} caracteres)`)
|
|
}
|
|
|
|
const authorSnapshot: CommentAuthorSnapshot = {
|
|
name: author.name,
|
|
email: author.email,
|
|
avatarUrl: author.avatarUrl ?? undefined,
|
|
teams: author.teams ?? undefined,
|
|
};
|
|
|
|
const now = Date.now();
|
|
const id = await ctx.db.insert("ticketComments", {
|
|
ticketId: args.ticketId,
|
|
authorId: args.authorId,
|
|
visibility: requestedVisibility,
|
|
body: args.body,
|
|
authorSnapshot,
|
|
attachments,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
});
|
|
await ctx.db.insert("ticketEvents", {
|
|
ticketId: args.ticketId,
|
|
type: "COMMENT_ADDED",
|
|
payload: { authorId: args.authorId, authorName: author.name, authorAvatar: author.avatarUrl },
|
|
createdAt: now,
|
|
});
|
|
// bump ticket updatedAt
|
|
await ctx.db.patch(args.ticketId, { updatedAt: now });
|
|
return id;
|
|
},
|
|
});
|
|
|
|
export const updateComment = mutation({
|
|
args: {
|
|
ticketId: v.id("tickets"),
|
|
commentId: v.id("ticketComments"),
|
|
actorId: v.id("users"),
|
|
body: v.string(),
|
|
},
|
|
handler: async (ctx, { ticketId, commentId, actorId, body }) => {
|
|
const ticket = await ctx.db.get(ticketId)
|
|
if (!ticket) {
|
|
throw new ConvexError("Ticket não encontrado")
|
|
}
|
|
const ticketDoc = ticket as Doc<"tickets">
|
|
const actor = (await ctx.db.get(actorId)) as Doc<"users"> | null
|
|
if (!actor || actor.tenantId !== ticketDoc.tenantId) {
|
|
throw new ConvexError("Autor do comentário inválido")
|
|
}
|
|
const comment = await ctx.db.get(commentId);
|
|
if (!comment || comment.ticketId !== ticketId) {
|
|
throw new ConvexError("Comentário não encontrado");
|
|
}
|
|
if (comment.authorId !== actorId) {
|
|
throw new ConvexError("Você não tem permissão para editar este comentário");
|
|
}
|
|
const normalizedRole = (actor.role ?? "AGENT").toUpperCase()
|
|
if (ticketDoc.requesterId === actorId) {
|
|
if (STAFF_ROLES.has(normalizedRole)) {
|
|
await requireTicketStaff(ctx, actorId, ticketDoc)
|
|
} else {
|
|
throw new ConvexError("Autor não possui permissão para editar")
|
|
}
|
|
} else {
|
|
await requireTicketStaff(ctx, actorId, ticketDoc)
|
|
}
|
|
|
|
const bodyPlainLen = plainTextLength(body)
|
|
if (bodyPlainLen > MAX_COMMENT_CHARS) {
|
|
throw new ConvexError(`Comentário muito longo (máx. ${MAX_COMMENT_CHARS} caracteres)`)
|
|
}
|
|
|
|
const now = Date.now();
|
|
await ctx.db.patch(commentId, {
|
|
body,
|
|
updatedAt: now,
|
|
});
|
|
|
|
await ctx.db.insert("ticketEvents", {
|
|
ticketId,
|
|
type: "COMMENT_EDITED",
|
|
payload: {
|
|
commentId,
|
|
actorId,
|
|
actorName: actor?.name,
|
|
actorAvatar: actor?.avatarUrl,
|
|
},
|
|
createdAt: now,
|
|
});
|
|
|
|
await ctx.db.patch(ticketId, { updatedAt: now });
|
|
},
|
|
});
|
|
|
|
export const removeCommentAttachment = mutation({
|
|
args: {
|
|
ticketId: v.id("tickets"),
|
|
commentId: v.id("ticketComments"),
|
|
attachmentId: v.id("_storage"),
|
|
actorId: v.id("users"),
|
|
},
|
|
handler: async (ctx, { ticketId, commentId, attachmentId, actorId }) => {
|
|
const ticket = await ctx.db.get(ticketId)
|
|
if (!ticket) {
|
|
throw new ConvexError("Ticket não encontrado")
|
|
}
|
|
const ticketDoc = ticket as Doc<"tickets">
|
|
const actor = (await ctx.db.get(actorId)) as Doc<"users"> | null
|
|
if (!actor || actor.tenantId !== ticketDoc.tenantId) {
|
|
throw new ConvexError("Autor do comentário inválido")
|
|
}
|
|
const comment = await ctx.db.get(commentId);
|
|
if (!comment || comment.ticketId !== ticketId) {
|
|
throw new ConvexError("Comentário não encontrado");
|
|
}
|
|
if (comment.authorId !== actorId) {
|
|
throw new ConvexError("Você não pode alterar anexos de outro usuário")
|
|
}
|
|
|
|
const normalizedRole = (actor.role ?? "AGENT").toUpperCase()
|
|
if (ticketDoc.requesterId === actorId) {
|
|
if (STAFF_ROLES.has(normalizedRole)) {
|
|
await requireTicketStaff(ctx, actorId, ticketDoc)
|
|
} else {
|
|
throw new ConvexError("Autor não possui permissão para alterar anexos")
|
|
}
|
|
} else {
|
|
await requireTicketStaff(ctx, actorId, ticketDoc)
|
|
}
|
|
|
|
const attachments = comment.attachments ?? [];
|
|
const target = attachments.find((att) => att.storageId === attachmentId);
|
|
if (!target) {
|
|
throw new ConvexError("Anexo não encontrado");
|
|
}
|
|
|
|
await ctx.storage.delete(attachmentId);
|
|
|
|
const now = Date.now();
|
|
await ctx.db.patch(commentId, {
|
|
attachments: attachments.filter((att) => att.storageId !== attachmentId),
|
|
updatedAt: now,
|
|
});
|
|
|
|
await ctx.db.insert("ticketEvents", {
|
|
ticketId,
|
|
type: "ATTACHMENT_REMOVED",
|
|
payload: {
|
|
attachmentId,
|
|
attachmentName: target.name,
|
|
actorId,
|
|
actorName: actor?.name,
|
|
actorAvatar: actor?.avatarUrl,
|
|
},
|
|
createdAt: now,
|
|
});
|
|
|
|
await ctx.db.patch(ticketId, { updatedAt: now });
|
|
},
|
|
});
|
|
|
|
export const updateStatus = mutation({
|
|
args: { ticketId: v.id("tickets"), status: v.string(), actorId: v.id("users") },
|
|
handler: async (ctx, { ticketId, status, actorId }) => {
|
|
const ticket = await ctx.db.get(ticketId)
|
|
if (!ticket) {
|
|
throw new ConvexError("Ticket não encontrado")
|
|
}
|
|
const ticketDoc = ticket as Doc<"tickets">
|
|
await requireTicketStaff(ctx, actorId, ticketDoc)
|
|
const normalizedStatus = normalizeStatus(status)
|
|
const now = Date.now();
|
|
await ctx.db.patch(ticketId, { status: normalizedStatus, updatedAt: now });
|
|
await ctx.db.insert("ticketEvents", {
|
|
ticketId,
|
|
type: "STATUS_CHANGED",
|
|
payload: { to: normalizedStatus, toLabel: STATUS_LABELS[normalizedStatus], actorId },
|
|
createdAt: now,
|
|
});
|
|
},
|
|
});
|
|
|
|
export const changeAssignee = mutation({
|
|
args: { ticketId: v.id("tickets"), assigneeId: v.id("users"), actorId: v.id("users") },
|
|
handler: async (ctx, { ticketId, assigneeId, actorId }) => {
|
|
const ticket = await ctx.db.get(ticketId)
|
|
if (!ticket) {
|
|
throw new ConvexError("Ticket não encontrado")
|
|
}
|
|
const ticketDoc = ticket as Doc<"tickets">
|
|
const viewer = await requireTicketStaff(ctx, actorId, ticketDoc)
|
|
const isAdmin = viewer.role === "ADMIN"
|
|
const assignee = (await ctx.db.get(assigneeId)) as Doc<"users"> | null
|
|
if (!assignee || assignee.tenantId !== ticketDoc.tenantId) {
|
|
throw new ConvexError("Responsável inválido")
|
|
}
|
|
if (viewer.role === "MANAGER") {
|
|
throw new ConvexError("Gestores não podem reatribuir chamados")
|
|
}
|
|
const currentAssigneeId = ticketDoc.assigneeId ?? null
|
|
if (currentAssigneeId && currentAssigneeId !== actorId && !isAdmin) {
|
|
throw new ConvexError("Somente o responsável atual pode reatribuir este chamado")
|
|
}
|
|
|
|
const now = Date.now();
|
|
const assigneeSnapshot = {
|
|
name: assignee.name,
|
|
email: assignee.email,
|
|
avatarUrl: assignee.avatarUrl ?? undefined,
|
|
teams: assignee.teams ?? undefined,
|
|
}
|
|
await ctx.db.patch(ticketId, { assigneeId, assigneeSnapshot, updatedAt: now });
|
|
await ctx.db.insert("ticketEvents", {
|
|
ticketId,
|
|
type: "ASSIGNEE_CHANGED",
|
|
payload: { assigneeId, assigneeName: assignee.name, actorId },
|
|
createdAt: now,
|
|
});
|
|
},
|
|
});
|
|
|
|
export const changeQueue = mutation({
|
|
args: { ticketId: v.id("tickets"), queueId: v.id("queues"), actorId: v.id("users") },
|
|
handler: async (ctx, { ticketId, queueId, actorId }) => {
|
|
const ticket = await ctx.db.get(ticketId)
|
|
if (!ticket) {
|
|
throw new ConvexError("Ticket não encontrado")
|
|
}
|
|
const ticketDoc = ticket as Doc<"tickets">
|
|
const viewer = await requireTicketStaff(ctx, actorId, ticketDoc)
|
|
if (viewer.role === "MANAGER") {
|
|
throw new ConvexError("Gestores não podem alterar a fila do chamado")
|
|
}
|
|
const queue = (await ctx.db.get(queueId)) as Doc<"queues"> | null
|
|
if (!queue || queue.tenantId !== ticketDoc.tenantId) {
|
|
throw new ConvexError("Fila inválida")
|
|
}
|
|
const now = Date.now();
|
|
await ctx.db.patch(ticketId, { queueId, updatedAt: now });
|
|
const queueName = normalizeQueueName(queue);
|
|
await ctx.db.insert("ticketEvents", {
|
|
ticketId,
|
|
type: "QUEUE_CHANGED",
|
|
payload: { queueId, queueName, actorId },
|
|
createdAt: now,
|
|
});
|
|
},
|
|
});
|
|
|
|
export const updateCategories = mutation({
|
|
args: {
|
|
ticketId: v.id("tickets"),
|
|
categoryId: v.union(v.id("ticketCategories"), v.null()),
|
|
subcategoryId: v.union(v.id("ticketSubcategories"), v.null()),
|
|
actorId: v.id("users"),
|
|
},
|
|
handler: async (ctx, { ticketId, categoryId, subcategoryId, actorId }) => {
|
|
const ticket = await ctx.db.get(ticketId)
|
|
if (!ticket) {
|
|
throw new ConvexError("Ticket não encontrado")
|
|
}
|
|
const ticketDoc = ticket as Doc<"tickets">
|
|
const viewer = await requireTicketStaff(ctx, actorId, ticketDoc)
|
|
if (viewer.role === "MANAGER") {
|
|
throw new ConvexError("Gestores não podem alterar a categorização do chamado")
|
|
}
|
|
|
|
if (categoryId === null) {
|
|
if (subcategoryId !== null) {
|
|
throw new ConvexError("Subcategoria inválida")
|
|
}
|
|
if (!ticketDoc.categoryId && !ticketDoc.subcategoryId) {
|
|
return { status: "unchanged" }
|
|
}
|
|
const now = Date.now()
|
|
await ctx.db.patch(ticketId, {
|
|
categoryId: undefined,
|
|
subcategoryId: undefined,
|
|
updatedAt: now,
|
|
})
|
|
const actor = (await ctx.db.get(actorId)) as Doc<"users"> | null
|
|
await ctx.db.insert("ticketEvents", {
|
|
ticketId,
|
|
type: "CATEGORY_CHANGED",
|
|
payload: {
|
|
categoryId: null,
|
|
categoryName: null,
|
|
subcategoryId: null,
|
|
subcategoryName: null,
|
|
actorId,
|
|
actorName: actor?.name,
|
|
actorAvatar: actor?.avatarUrl,
|
|
},
|
|
createdAt: now,
|
|
})
|
|
return { status: "cleared" }
|
|
}
|
|
|
|
const category = await ctx.db.get(categoryId)
|
|
if (!category || category.tenantId !== ticketDoc.tenantId) {
|
|
throw new ConvexError("Categoria inválida")
|
|
}
|
|
|
|
let subcategoryName: string | null = null
|
|
if (subcategoryId !== null) {
|
|
const subcategory = await ctx.db.get(subcategoryId)
|
|
if (!subcategory || subcategory.categoryId !== categoryId || subcategory.tenantId !== ticketDoc.tenantId) {
|
|
throw new ConvexError("Subcategoria inválida")
|
|
}
|
|
subcategoryName = subcategory.name
|
|
}
|
|
|
|
if (ticketDoc.categoryId === categoryId && (ticketDoc.subcategoryId ?? null) === subcategoryId) {
|
|
return { status: "unchanged" }
|
|
}
|
|
|
|
const now = Date.now()
|
|
await ctx.db.patch(ticketId, {
|
|
categoryId,
|
|
subcategoryId: subcategoryId ?? undefined,
|
|
updatedAt: now,
|
|
})
|
|
|
|
const actor = (await ctx.db.get(actorId)) as Doc<"users"> | null
|
|
await ctx.db.insert("ticketEvents", {
|
|
ticketId,
|
|
type: "CATEGORY_CHANGED",
|
|
payload: {
|
|
categoryId,
|
|
categoryName: category.name,
|
|
subcategoryId,
|
|
subcategoryName,
|
|
actorId,
|
|
actorName: actor?.name,
|
|
actorAvatar: actor?.avatarUrl,
|
|
},
|
|
createdAt: now,
|
|
})
|
|
|
|
return { status: "updated" }
|
|
},
|
|
})
|
|
|
|
export const workSummary = query({
|
|
args: { ticketId: v.id("tickets"), viewerId: v.id("users") },
|
|
handler: async (ctx, { ticketId, viewerId }) => {
|
|
const ticket = await ctx.db.get(ticketId)
|
|
if (!ticket) return null
|
|
await requireStaff(ctx, viewerId, ticket.tenantId)
|
|
|
|
const activeSession = ticket.activeSessionId ? await ctx.db.get(ticket.activeSessionId) : null
|
|
const serverNow = Date.now()
|
|
return {
|
|
ticketId,
|
|
totalWorkedMs: ticket.totalWorkedMs ?? 0,
|
|
internalWorkedMs: ticket.internalWorkedMs ?? 0,
|
|
externalWorkedMs: ticket.externalWorkedMs ?? 0,
|
|
serverNow,
|
|
activeSession: activeSession
|
|
? {
|
|
id: activeSession._id,
|
|
agentId: activeSession.agentId,
|
|
startedAt: activeSession.startedAt,
|
|
workType: activeSession.workType ?? "INTERNAL",
|
|
}
|
|
: null,
|
|
}
|
|
},
|
|
})
|
|
|
|
export const updatePriority = mutation({
|
|
args: { ticketId: v.id("tickets"), priority: v.string(), actorId: v.id("users") },
|
|
handler: async (ctx, { ticketId, priority, actorId }) => {
|
|
const ticket = await ctx.db.get(ticketId)
|
|
if (!ticket) {
|
|
throw new ConvexError("Ticket não encontrado")
|
|
}
|
|
await requireStaff(ctx, actorId, ticket.tenantId)
|
|
const now = Date.now();
|
|
await ctx.db.patch(ticketId, { priority, updatedAt: now });
|
|
const pt: Record<string, string> = { LOW: "Baixa", MEDIUM: "Média", HIGH: "Alta", URGENT: "Urgente" };
|
|
await ctx.db.insert("ticketEvents", {
|
|
ticketId,
|
|
type: "PRIORITY_CHANGED",
|
|
payload: { to: priority, toLabel: pt[priority] ?? priority, actorId },
|
|
createdAt: now,
|
|
});
|
|
},
|
|
});
|
|
|
|
export const startWork = mutation({
|
|
args: { ticketId: v.id("tickets"), actorId: v.id("users"), workType: v.optional(v.string()) },
|
|
handler: async (ctx, { ticketId, actorId, workType }) => {
|
|
const ticket = await ctx.db.get(ticketId)
|
|
if (!ticket) {
|
|
throw new ConvexError("Ticket não encontrado")
|
|
}
|
|
const ticketDoc = ticket as Doc<"tickets">
|
|
const viewer = await requireTicketStaff(ctx, actorId, ticketDoc)
|
|
const isAdmin = viewer.role === "ADMIN"
|
|
const currentAssigneeId = ticketDoc.assigneeId ?? null
|
|
const now = Date.now()
|
|
|
|
if (currentAssigneeId && currentAssigneeId !== actorId && !isAdmin) {
|
|
throw new ConvexError("Somente o responsável atual pode iniciar este chamado")
|
|
}
|
|
|
|
if (ticketDoc.activeSessionId) {
|
|
const session = await ctx.db.get(ticketDoc.activeSessionId)
|
|
return {
|
|
status: "already_started",
|
|
sessionId: ticketDoc.activeSessionId,
|
|
startedAt: session?.startedAt ?? now,
|
|
serverNow: now,
|
|
}
|
|
}
|
|
|
|
let assigneePatched = false
|
|
|
|
if (!currentAssigneeId) {
|
|
const assigneeSnapshot = {
|
|
name: viewer.user.name,
|
|
email: viewer.user.email,
|
|
avatarUrl: viewer.user.avatarUrl ?? undefined,
|
|
teams: viewer.user.teams ?? undefined,
|
|
}
|
|
await ctx.db.patch(ticketId, { assigneeId: actorId, assigneeSnapshot, updatedAt: now })
|
|
ticketDoc.assigneeId = actorId
|
|
assigneePatched = true
|
|
}
|
|
|
|
const sessionId = await ctx.db.insert("ticketWorkSessions", {
|
|
ticketId,
|
|
agentId: actorId,
|
|
workType: (workType ?? "INTERNAL").toUpperCase(),
|
|
startedAt: now,
|
|
})
|
|
|
|
await ctx.db.patch(ticketId, {
|
|
working: true,
|
|
activeSessionId: sessionId,
|
|
status: "AWAITING_ATTENDANCE",
|
|
updatedAt: now,
|
|
})
|
|
|
|
if (assigneePatched) {
|
|
await ctx.db.insert("ticketEvents", {
|
|
ticketId,
|
|
type: "ASSIGNEE_CHANGED",
|
|
payload: { assigneeId: actorId, assigneeName: viewer.user.name, actorId },
|
|
createdAt: now,
|
|
})
|
|
}
|
|
|
|
await ctx.db.insert("ticketEvents", {
|
|
ticketId,
|
|
type: "WORK_STARTED",
|
|
payload: {
|
|
actorId,
|
|
actorName: viewer.user.name,
|
|
actorAvatar: viewer.user.avatarUrl,
|
|
sessionId,
|
|
workType: (workType ?? "INTERNAL").toUpperCase(),
|
|
},
|
|
createdAt: now,
|
|
})
|
|
|
|
return { status: "started", sessionId, startedAt: now, serverNow: now }
|
|
},
|
|
})
|
|
|
|
export const pauseWork = mutation({
|
|
args: {
|
|
ticketId: v.id("tickets"),
|
|
actorId: v.id("users"),
|
|
reason: v.string(),
|
|
note: v.optional(v.string()),
|
|
},
|
|
handler: async (ctx, { ticketId, actorId, reason, note }) => {
|
|
const ticket = await ctx.db.get(ticketId)
|
|
if (!ticket) {
|
|
throw new ConvexError("Ticket não encontrado")
|
|
}
|
|
const ticketDoc = ticket as Doc<"tickets">
|
|
const viewer = await requireTicketStaff(ctx, actorId, ticketDoc)
|
|
const isAdmin = viewer.role === "ADMIN"
|
|
if (ticketDoc.assigneeId && ticketDoc.assigneeId !== actorId && !isAdmin) {
|
|
throw new ConvexError("Somente o responsável atual pode pausar este chamado")
|
|
}
|
|
|
|
if (!ticketDoc.activeSessionId) {
|
|
return { status: "already_paused" }
|
|
}
|
|
|
|
if (!PAUSE_REASON_LABELS[reason]) {
|
|
throw new ConvexError("Motivo de pausa inválido")
|
|
}
|
|
|
|
const session = await ctx.db.get(ticketDoc.activeSessionId)
|
|
if (!session) {
|
|
await ctx.db.patch(ticketId, { activeSessionId: undefined, working: false })
|
|
return { status: "session_missing" }
|
|
}
|
|
|
|
const now = Date.now()
|
|
const durationMs = now - session.startedAt
|
|
|
|
await ctx.db.patch(ticketDoc.activeSessionId, {
|
|
stoppedAt: now,
|
|
durationMs,
|
|
pauseReason: reason,
|
|
pauseNote: note ?? "",
|
|
})
|
|
|
|
const sessionType = (session.workType ?? "INTERNAL").toUpperCase()
|
|
const deltaInternal = sessionType === "INTERNAL" ? durationMs : 0
|
|
const deltaExternal = sessionType === "EXTERNAL" ? durationMs : 0
|
|
|
|
await ctx.db.patch(ticketId, {
|
|
working: false,
|
|
activeSessionId: undefined,
|
|
status: "PAUSED",
|
|
totalWorkedMs: (ticket.totalWorkedMs ?? 0) + durationMs,
|
|
internalWorkedMs: (ticket.internalWorkedMs ?? 0) + deltaInternal,
|
|
externalWorkedMs: (ticket.externalWorkedMs ?? 0) + deltaExternal,
|
|
updatedAt: now,
|
|
})
|
|
|
|
const actor = (await ctx.db.get(actorId)) as Doc<"users"> | null
|
|
await ctx.db.insert("ticketEvents", {
|
|
ticketId,
|
|
type: "WORK_PAUSED",
|
|
payload: {
|
|
actorId,
|
|
actorName: actor?.name,
|
|
actorAvatar: actor?.avatarUrl,
|
|
sessionId: session._id,
|
|
sessionDurationMs: durationMs,
|
|
workType: sessionType,
|
|
pauseReason: reason,
|
|
pauseReasonLabel: PAUSE_REASON_LABELS[reason],
|
|
pauseNote: note ?? "",
|
|
},
|
|
createdAt: now,
|
|
})
|
|
|
|
return {
|
|
status: "paused",
|
|
durationMs,
|
|
pauseReason: reason,
|
|
pauseNote: note ?? "",
|
|
serverNow: now,
|
|
}
|
|
},
|
|
})
|
|
|
|
export const updateSubject = mutation({
|
|
args: { ticketId: v.id("tickets"), subject: v.string(), actorId: v.id("users") },
|
|
handler: async (ctx, { ticketId, subject, actorId }) => {
|
|
const now = Date.now();
|
|
const t = await ctx.db.get(ticketId);
|
|
if (!t) {
|
|
throw new ConvexError("Ticket não encontrado")
|
|
}
|
|
await requireStaff(ctx, actorId, t.tenantId)
|
|
const trimmed = subject.trim();
|
|
if (trimmed.length < 3) {
|
|
throw new ConvexError("Informe um assunto com pelo menos 3 caracteres");
|
|
}
|
|
await ctx.db.patch(ticketId, { subject: trimmed, updatedAt: now });
|
|
const actor = await ctx.db.get(actorId);
|
|
await ctx.db.insert("ticketEvents", {
|
|
ticketId,
|
|
type: "SUBJECT_CHANGED",
|
|
payload: { from: t.subject, to: trimmed, actorId, actorName: (actor as Doc<"users"> | null)?.name, actorAvatar: (actor as Doc<"users"> | null)?.avatarUrl },
|
|
createdAt: now,
|
|
});
|
|
},
|
|
});
|
|
|
|
export const updateSummary = mutation({
|
|
args: { ticketId: v.id("tickets"), summary: v.optional(v.string()), actorId: v.id("users") },
|
|
handler: async (ctx, { ticketId, summary, actorId }) => {
|
|
const now = Date.now();
|
|
const t = await ctx.db.get(ticketId);
|
|
if (!t) {
|
|
throw new ConvexError("Ticket não encontrado")
|
|
}
|
|
await requireStaff(ctx, actorId, t.tenantId)
|
|
if (summary && summary.trim().length > MAX_SUMMARY_CHARS) {
|
|
throw new ConvexError(`Resumo muito longo (máx. ${MAX_SUMMARY_CHARS} caracteres)`)
|
|
}
|
|
await ctx.db.patch(ticketId, { summary, updatedAt: now });
|
|
const actor = await ctx.db.get(actorId);
|
|
await ctx.db.insert("ticketEvents", {
|
|
ticketId,
|
|
type: "SUMMARY_CHANGED",
|
|
payload: { actorId, actorName: (actor as Doc<"users"> | null)?.name, actorAvatar: (actor as Doc<"users"> | null)?.avatarUrl },
|
|
createdAt: now,
|
|
});
|
|
},
|
|
});
|
|
|
|
export const playNext = mutation({
|
|
args: {
|
|
tenantId: v.string(),
|
|
queueId: v.optional(v.id("queues")),
|
|
agentId: v.id("users"),
|
|
},
|
|
handler: async (ctx, { tenantId, queueId, agentId }) => {
|
|
const { user: agent } = await requireStaff(ctx, agentId, tenantId)
|
|
// Find eligible tickets: not resolved/closed and not assigned
|
|
let candidates: Doc<"tickets">[] = []
|
|
if (queueId) {
|
|
candidates = await ctx.db
|
|
.query("tickets")
|
|
.withIndex("by_tenant_queue", (q) => q.eq("tenantId", tenantId).eq("queueId", queueId))
|
|
.collect()
|
|
} else {
|
|
candidates = await ctx.db
|
|
.query("tickets")
|
|
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
|
|
.collect()
|
|
}
|
|
|
|
candidates = candidates.filter(
|
|
(t) => t.status !== "RESOLVED" && !t.assigneeId
|
|
);
|
|
|
|
if (candidates.length === 0) return null;
|
|
|
|
// prioritize by priority then createdAt
|
|
const rank: Record<string, number> = { URGENT: 0, HIGH: 1, MEDIUM: 2, LOW: 3 }
|
|
candidates.sort((a, b) => {
|
|
const pa = rank[a.priority] ?? 999
|
|
const pb = rank[b.priority] ?? 999
|
|
if (pa !== pb) return pa - pb
|
|
return a.createdAt - b.createdAt
|
|
})
|
|
|
|
const chosen = candidates[0];
|
|
const now = Date.now();
|
|
const currentStatus = normalizeStatus(chosen.status);
|
|
const nextStatus: TicketStatusNormalized =
|
|
currentStatus === "PENDING" ? "AWAITING_ATTENDANCE" : currentStatus;
|
|
const assigneeSnapshot = {
|
|
name: agent.name,
|
|
email: agent.email,
|
|
avatarUrl: agent.avatarUrl ?? undefined,
|
|
teams: agent.teams ?? undefined,
|
|
}
|
|
await ctx.db.patch(chosen._id, { assigneeId: agentId, assigneeSnapshot, status: nextStatus, updatedAt: now });
|
|
await ctx.db.insert("ticketEvents", {
|
|
ticketId: chosen._id,
|
|
type: "ASSIGNEE_CHANGED",
|
|
payload: { assigneeId: agentId, assigneeName: agent.name },
|
|
createdAt: now,
|
|
});
|
|
|
|
// hydrate minimal public ticket like in list
|
|
const requester = (await ctx.db.get(chosen.requesterId)) as Doc<"users"> | null
|
|
const assignee = chosen.assigneeId ? ((await ctx.db.get(chosen.assigneeId)) as Doc<"users"> | null) : null
|
|
const queue = chosen.queueId ? ((await ctx.db.get(chosen.queueId)) as Doc<"queues"> | null) : null
|
|
const queueName = normalizeQueueName(queue)
|
|
return {
|
|
id: chosen._id,
|
|
reference: chosen.reference,
|
|
tenantId: chosen.tenantId,
|
|
subject: chosen.subject,
|
|
summary: chosen.summary,
|
|
status: nextStatus,
|
|
priority: chosen.priority,
|
|
channel: chosen.channel,
|
|
queue: queueName,
|
|
requester: requester
|
|
? buildRequesterSummary(requester, chosen.requesterId, { ticketId: chosen._id })
|
|
: buildRequesterFromSnapshot(
|
|
chosen.requesterId,
|
|
chosen.requesterSnapshot ?? undefined,
|
|
{ ticketId: chosen._id }
|
|
),
|
|
assignee: chosen.assigneeId
|
|
? assignee
|
|
? {
|
|
id: assignee._id,
|
|
name: assignee.name,
|
|
email: assignee.email,
|
|
avatarUrl: assignee.avatarUrl,
|
|
teams: normalizeTeams(assignee.teams),
|
|
}
|
|
: buildAssigneeFromSnapshot(chosen.assigneeId, chosen.assigneeSnapshot ?? undefined)
|
|
: null,
|
|
slaPolicy: null,
|
|
dueAt: chosen.dueAt ?? null,
|
|
firstResponseAt: chosen.firstResponseAt ?? null,
|
|
resolvedAt: chosen.resolvedAt ?? null,
|
|
updatedAt: chosen.updatedAt,
|
|
createdAt: chosen.createdAt,
|
|
tags: chosen.tags ?? [],
|
|
lastTimelineEntry: null,
|
|
metrics: null,
|
|
}
|
|
},
|
|
});
|
|
|
|
export const remove = mutation({
|
|
args: { ticketId: v.id("tickets"), actorId: v.id("users") },
|
|
handler: async (ctx, { ticketId, actorId }) => {
|
|
const ticket = await ctx.db.get(ticketId)
|
|
if (!ticket) {
|
|
throw new ConvexError("Ticket não encontrado")
|
|
}
|
|
await requireAdmin(ctx, actorId, ticket.tenantId)
|
|
// delete comments (and attachments)
|
|
const comments = await ctx.db
|
|
.query("ticketComments")
|
|
.withIndex("by_ticket", (q) => q.eq("ticketId", ticketId))
|
|
.collect();
|
|
for (const c of comments) {
|
|
for (const att of c.attachments ?? []) {
|
|
try { await ctx.storage.delete(att.storageId); } catch {}
|
|
}
|
|
await ctx.db.delete(c._id);
|
|
}
|
|
// delete events
|
|
const events = await ctx.db
|
|
.query("ticketEvents")
|
|
.withIndex("by_ticket", (q) => q.eq("ticketId", ticketId))
|
|
.collect();
|
|
for (const ev of events) await ctx.db.delete(ev._id);
|
|
// delete ticket
|
|
await ctx.db.delete(ticketId);
|
|
// (optional) event is moot after deletion
|
|
return true;
|
|
},
|
|
});
|
|
|
|
export const reassignTicketsByEmail = mutation({
|
|
args: {
|
|
tenantId: v.string(),
|
|
actorId: v.id("users"),
|
|
fromEmail: v.string(),
|
|
toUserId: v.id("users"),
|
|
dryRun: v.optional(v.boolean()),
|
|
limit: v.optional(v.number()),
|
|
updateSnapshot: v.optional(v.boolean()),
|
|
},
|
|
handler: async (ctx, { tenantId, actorId, fromEmail, toUserId, dryRun, limit, updateSnapshot }) => {
|
|
await requireAdmin(ctx, actorId, tenantId)
|
|
|
|
const normalizedFrom = fromEmail.trim().toLowerCase()
|
|
if (!normalizedFrom || !normalizedFrom.includes("@")) {
|
|
throw new ConvexError("E-mail de origem inválido")
|
|
}
|
|
|
|
const toUser = await ctx.db.get(toUserId)
|
|
if (!toUser || toUser.tenantId !== tenantId) {
|
|
throw new ConvexError("Usuário de destino inválido para o tenant")
|
|
}
|
|
|
|
// Coletar tickets por requesterId (quando possível via usuário antigo)
|
|
const fromUser = await ctx.db
|
|
.query("users")
|
|
.withIndex("by_tenant_email", (q) => q.eq("tenantId", tenantId).eq("email", normalizedFrom))
|
|
.first()
|
|
|
|
const byRequesterId: Doc<"tickets">[] = fromUser
|
|
? await ctx.db
|
|
.query("tickets")
|
|
.withIndex("by_tenant_requester", (q) => q.eq("tenantId", tenantId).eq("requesterId", fromUser._id))
|
|
.collect()
|
|
: []
|
|
|
|
// Coletar tickets por e-mail no snapshot para cobrir casos sem user antigo
|
|
const allTenant = await ctx.db
|
|
.query("tickets")
|
|
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
|
|
.collect()
|
|
|
|
const bySnapshotEmail = allTenant.filter((t) => {
|
|
const rs = t.requesterSnapshot as { email?: string } | undefined
|
|
const email = typeof rs?.email === "string" ? rs.email.trim().toLowerCase() : null
|
|
if (!email || email !== normalizedFrom) return false
|
|
// Evita duplicar os já coletados por requesterId
|
|
if (fromUser && t.requesterId === fromUser._id) return false
|
|
return true
|
|
})
|
|
|
|
const candidatesMap = new Map<string, Doc<"tickets">>()
|
|
for (const t of byRequesterId) candidatesMap.set(String(t._id), t)
|
|
for (const t of bySnapshotEmail) candidatesMap.set(String(t._id), t)
|
|
const candidates = Array.from(candidatesMap.values())
|
|
|
|
const maxToProcess = Math.max(0, Math.min(limit && limit > 0 ? limit : candidates.length, candidates.length))
|
|
const toProcess = candidates.slice(0, maxToProcess)
|
|
|
|
if (dryRun) {
|
|
return {
|
|
dryRun: true as const,
|
|
fromEmail: normalizedFrom,
|
|
toUserId,
|
|
candidates: candidates.length,
|
|
willUpdate: toProcess.length,
|
|
}
|
|
}
|
|
|
|
const now = Date.now()
|
|
let updated = 0
|
|
for (const t of toProcess) {
|
|
const patch: Record<string, unknown> = { requesterId: toUserId, updatedAt: now }
|
|
if (updateSnapshot) {
|
|
patch.requesterSnapshot = {
|
|
name: toUser.name,
|
|
email: toUser.email,
|
|
avatarUrl: toUser.avatarUrl ?? undefined,
|
|
teams: toUser.teams ?? undefined,
|
|
}
|
|
}
|
|
await ctx.db.patch(t._id, patch)
|
|
await ctx.db.insert("ticketEvents", {
|
|
ticketId: t._id,
|
|
type: "REQUESTER_CHANGED",
|
|
payload: {
|
|
fromUserId: fromUser?._id ?? null,
|
|
fromEmail: normalizedFrom,
|
|
toUserId,
|
|
toUserName: toUser.name,
|
|
},
|
|
createdAt: now,
|
|
})
|
|
updated += 1
|
|
}
|
|
|
|
return {
|
|
dryRun: false as const,
|
|
fromEmail: normalizedFrom,
|
|
toUserId,
|
|
candidates: candidates.length,
|
|
updated,
|
|
}
|
|
},
|
|
})
|