// 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 = { 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 = { PENDING: "Pendente", AWAITING_ATTENDANCE: "Em andamento", PAUSED: "Pausado", RESOLVED: "Resolvido", }; const LEGACY_STATUS_MAP: Record = { NEW: "PENDING", PENDING: "PENDING", OPEN: "AWAITING_ATTENDANCE", AWAITING_ATTENDANCE: "AWAITING_ATTENDANCE", ON_HOLD: "PAUSED", PAUSED: "PAUSED", RESOLVED: "RESOLVED", CLOSED: "RESOLVED", }; const missingRequesterLogCache = new Set(); const missingCommentAuthorLogCache = new Set(); // 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 escapeHtml(input: string): string { return input .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } export function buildAssigneeChangeComment( reason: string, context: { previousName: string; nextName: string }, ): string { const normalized = reason.replace(/\r\n/g, "\n").trim(); const lines = normalized .split("\n") .map((line) => line.trim()) .filter((line) => line.length > 0); const previous = escapeHtml(context.previousName || "Não atribuído"); const next = escapeHtml(context.nextName || "Não atribuído"); const reasonHtml = lines.length ? lines.map((line) => `

${escapeHtml(line)}

`).join("") : `

`; return `

Responsável atualizado: ${previous} → ${next}

Motivo da troca:

${reasonHtml}`; } function truncateSubject(subject: string) { if (subject.length <= 60) return subject return `${subject.slice(0, 57)}…` } function buildTicketMentionAnchor(ticket: Doc<"tickets">): string { const reference = ticket.reference const subject = escapeHtml(ticket.subject ?? "") const truncated = truncateSubject(subject) const status = (ticket.status ?? "PENDING").toString().toUpperCase() const priority = (ticket.priority ?? "MEDIUM").toString().toUpperCase() return `#${reference}${truncated}` } function canMentionTicket(viewerRole: string, viewerId: Id<"users">, ticket: Doc<"tickets">) { if (viewerRole === "ADMIN" || viewerRole === "AGENT") return true if (viewerRole === "COLLABORATOR") { return String(ticket.requesterId) === String(viewerId) } if (viewerRole === "MANAGER") { // Gestores compartilham contexto interno; permitem apenas tickets da mesma empresa do solicitante return String(ticket.requesterId) === String(viewerId) } return false } async function normalizeTicketMentions( ctx: MutationCtx, html: string, viewer: { user: Doc<"users">; role: string }, tenantId: string, ): Promise { if (!html || html.indexOf("data-ticket-mention") === -1) { return html } const mentionPattern = /]*data-ticket-mention="true"[^>]*>[\s\S]*?<\/a>/gi const matches = Array.from(html.matchAll(mentionPattern)) if (!matches.length) { return html } let output = html for (const match of matches) { const full = match[0] const idMatch = /data-ticket-id="([^"]+)"/i.exec(full) const ticketIdRaw = idMatch?.[1] let replacement = "" if (ticketIdRaw) { const ticket = await ctx.db.get(ticketIdRaw as Id<"tickets">) if (ticket && ticket.tenantId === tenantId && canMentionTicket(viewer.role, viewer.user._id, ticket)) { replacement = buildTicketMentionAnchor(ticket) } else { const inner = match[0].replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim() replacement = escapeHtml(inner || `#${ticketIdRaw}`) } } else { replacement = escapeHtml(full.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim()) } output = output.replace(full, replacement) } return output } function normalizeStatus(status: string | null | undefined): TicketStatusNormalized { if (!status) return "PENDING"; const normalized = LEGACY_STATUS_MAP[status.toUpperCase()]; return normalized ?? "PENDING"; } type AgentWorkTotals = { agentId: Id<"users">; agentName: string | null; agentEmail: string | null; avatarUrl: string | null; totalWorkedMs: number; internalWorkedMs: number; externalWorkedMs: number; }; async function computeAgentWorkTotals( ctx: MutationCtx | QueryCtx, ticketId: Id<"tickets">, referenceNow: number, ): Promise { const sessions = await ctx.db .query("ticketWorkSessions") .withIndex("by_ticket", (q) => q.eq("ticketId", ticketId)) .collect(); if (!sessions.length) { return []; } const totals = new Map< string, { totalWorkedMs: number; internalWorkedMs: number; externalWorkedMs: number } >(); for (const session of sessions) { const baseDuration = typeof session.durationMs === "number" ? session.durationMs : typeof session.stoppedAt === "number" ? session.stoppedAt - session.startedAt : referenceNow - session.startedAt; const durationMs = Math.max(0, baseDuration); if (durationMs <= 0) continue; const key = session.agentId as string; const bucket = totals.get(key) ?? { totalWorkedMs: 0, internalWorkedMs: 0, externalWorkedMs: 0, }; bucket.totalWorkedMs += durationMs; const workType = (session.workType ?? "INTERNAL").toUpperCase(); if (workType === "EXTERNAL") { bucket.externalWorkedMs += durationMs; } else { bucket.internalWorkedMs += durationMs; } totals.set(key, bucket); } if (totals.size === 0) { return []; } const agentIds = Array.from(totals.keys()); const agents = await Promise.all(agentIds.map((agentId) => ctx.db.get(agentId as Id<"users">))); return agentIds .map((agentId, index) => { const bucket = totals.get(agentId)!; const agentDoc = agents[index] as Doc<"users"> | null; return { agentId: agentId as Id<"users">, agentName: agentDoc?.name ?? null, agentEmail: agentDoc?.email ?? null, avatarUrl: agentDoc?.avatarUrl ?? null, totalWorkedMs: bucket.totalWorkedMs, internalWorkedMs: bucket.internalWorkedMs, externalWorkedMs: bucket.externalWorkedMs, }; }) .sort((a, b) => b.totalWorkedMs - a.totalWorkedMs); } async function ensureManagerTicketAccess( ctx: MutationCtx | QueryCtx, manager: Doc<"users">, ticket: Doc<"tickets">, ): Promise | 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 = { "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, tenantId: string, inputs: CustomFieldInput[] | undefined ): Promise { 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, 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>((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 | null>(); const subcategoryCache = new Map | 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 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; const perAgentTotals = await computeAgentWorkTotals(ctx, id, serverNow); 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, perAgentTotals: perAgentTotals.map((item) => ({ agentId: item.agentId, agentName: item.agentName, agentEmail: item.agentEmail, avatarUrl: item.avatarUrl, totalWorkedMs: item.totalWorkedMs, internalWorkedMs: item.internalWorkedMs, externalWorkedMs: item.externalWorkedMs, })), }, 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 authorSnapshot: CommentAuthorSnapshot = { name: author.name, email: author.email, avatarUrl: author.avatarUrl ?? undefined, teams: author.teams ?? undefined, }; const normalizedBody = await normalizeTicketMentions(ctx, args.body, { user: author, role: normalizedRole }, ticketDoc.tenantId) const bodyPlainLen = plainTextLength(normalizedBody) if (bodyPlainLen > MAX_COMMENT_CHARS) { throw new ConvexError(`Comentário muito longo (máx. ${MAX_COMMENT_CHARS} caracteres)`) } const now = Date.now(); const id = await ctx.db.insert("ticketComments", { ticketId: args.ticketId, authorId: args.authorId, visibility: requestedVisibility, body: normalizedBody, 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 normalizedBody = await normalizeTicketMentions(ctx, body, { user: actor, role: normalizedRole }, ticketDoc.tenantId) const bodyPlainLen = plainTextLength(normalizedBody) 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: normalizedBody, 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"), reason: v.string(), }, handler: async (ctx, { ticketId, assigneeId, actorId, reason }) => { 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 viewerUser = viewer.user 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 normalizedStatus = normalizeStatus(ticketDoc.status) if (normalizedStatus === "AWAITING_ATTENDANCE" || ticketDoc.activeSessionId) { throw new ConvexError("Pause o atendimento antes de reatribuir o chamado") } const currentAssigneeId = ticketDoc.assigneeId ?? null if (currentAssigneeId && currentAssigneeId !== actorId && !isAdmin) { throw new ConvexError("Somente o responsável atual pode reatribuir este chamado") } const normalizedReason = reason.replace(/\r\n/g, "\n").trim() if (normalizedReason.length < 5) { throw new ConvexError("Informe um motivo para registrar a troca de responsável") } if (normalizedReason.length > 1000) { throw new ConvexError("Motivo muito longo (máx. 1000 caracteres)") } const previousAssigneeName = ((ticketDoc.assigneeSnapshot as { name?: string } | null)?.name as string | undefined) ?? "Não atribuído" const nextAssigneeName = assignee.name ?? assignee.email ?? "Responsável" const commentBody = buildAssigneeChangeComment(normalizedReason, { previousName: previousAssigneeName, nextName: nextAssigneeName, }) const commentPlainLength = plainTextLength(commentBody) if (commentPlainLength > MAX_COMMENT_CHARS) { throw new ConvexError(`Comentário muito longo (máx. ${MAX_COMMENT_CHARS} caracteres)`) } 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, previousAssigneeId: currentAssigneeId, previousAssigneeName, reason: normalizedReason, }, createdAt: now, }); const authorSnapshot: CommentAuthorSnapshot = { name: viewerUser.name, email: viewerUser.email, avatarUrl: viewerUser.avatarUrl ?? undefined, teams: viewerUser.teams ?? undefined, } await ctx.db.insert("ticketComments", { ticketId, authorId: actorId, visibility: "INTERNAL", body: commentBody, authorSnapshot, attachments: [], createdAt: now, updatedAt: now, }) await ctx.db.insert("ticketEvents", { ticketId, type: "COMMENT_ADDED", payload: { authorId: actorId, authorName: viewerUser.name, authorAvatar: viewerUser.avatarUrl }, createdAt: now, }) }, }); export const changeRequester = mutation({ args: { ticketId: v.id("tickets"), requesterId: v.id("users"), actorId: v.id("users") }, handler: async (ctx, { ticketId, requesterId, 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 viewerRole = (viewer.role ?? "AGENT").toUpperCase() const actor = viewer.user if (String(ticketDoc.requesterId) === String(requesterId)) { return { status: "unchanged" } } const requester = (await ctx.db.get(requesterId)) as Doc<"users"> | null if (!requester || requester.tenantId !== ticketDoc.tenantId) { throw new ConvexError("Solicitante inválido") } if (viewerRole === "MANAGER") { if (!actor.companyId) { throw new ConvexError("Gestor não possui empresa vinculada") } if (requester.companyId !== actor.companyId) { throw new ConvexError("Gestores só podem alterar para usuários da própria empresa") } } const now = Date.now() const requesterSnapshot = { name: requester.name, email: requester.email, avatarUrl: requester.avatarUrl ?? undefined, teams: requester.teams ?? undefined, } let companyId: Id<"companies"> | undefined let companySnapshot: { name: string; slug?: string; isAvulso?: boolean } | undefined if (requester.companyId) { const company = await ctx.db.get(requester.companyId) if (company) { companyId = company._id as Id<"companies"> companySnapshot = { name: company.name, slug: company.slug ?? undefined, isAvulso: company.isAvulso ?? undefined, } } } const patch: Record = { requesterId, requesterSnapshot, updatedAt: now, } if (companyId) { patch["companyId"] = companyId patch["companySnapshot"] = companySnapshot } else { patch["companyId"] = undefined patch["companySnapshot"] = undefined } await ctx.db.patch(ticketId, patch) await ctx.db.insert("ticketEvents", { ticketId, type: "REQUESTER_CHANGED", payload: { requesterId, requesterName: requester.name, requesterEmail: requester.email, companyId: companyId ?? null, companyName: companySnapshot?.name ?? null, actorId, actorName: actor.name, actorAvatar: actor.avatarUrl, }, createdAt: now, }) return { status: "updated" } }, }) 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() const perAgentTotals = await computeAgentWorkTotals(ctx, ticketId, serverNow) 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, perAgentTotals: perAgentTotals.map((item) => ({ agentId: item.agentId, agentName: item.agentName, agentEmail: item.agentEmail, avatarUrl: item.avatarUrl, totalWorkedMs: item.totalWorkedMs, internalWorkedMs: item.internalWorkedMs, externalWorkedMs: item.externalWorkedMs, })), } }, }) 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 = { 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 = { 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>() 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 = { 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, } }, })