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 { requireStaff, requireUser } from "./rbac"; const STAFF_ROLES = new Set(["ADMIN", "MANAGER", "AGENT", "COLLABORATOR"]); const INTERNAL_STAFF_ROLES = new Set(["ADMIN", "AGENT", "COLLABORATOR"]); 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: "Aguardando atendimento", 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", }; 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 | 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 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")), 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") } base = await ctx.db .query("tickets") .withIndex("by_tenant_company", (q) => q.eq("tenantId", args.tenantId).eq("companyId", user.companyId!)) .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 { 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 (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 }; } } 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 as any).isAvulso ?? false } : null, requester: requester && { id: requester._id, name: requester.name, email: requester.email, avatarUrl: requester.avatarUrl, teams: normalizeTeams(requester.teams), }, assignee: assignee ? { id: assignee._id, name: assignee.name, email: assignee.email, avatarUrl: assignee.avatarUrl, teams: normalizeTeams(assignee.teams), } : 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 as any).internalWorkedMs ?? 0, externalWorkedMs: (t as any).externalWorkedMs ?? 0, activeSession: activeSession ? { id: activeSession._id, agentId: activeSession.agentId, startedAt: activeSession.startedAt, workType: (activeSession as any).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; // 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 timeline = await ctx.db .query("ticketEvents") .withIndex("by_ticket", (q) => q.eq("ticketId", id)) .collect(); const customFieldsRecord = mapCustomFieldsToRecord( (t.customFields as NormalizedCustomField[] | undefined) ?? undefined ); const commentsHydrated = await Promise.all( comments.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), })) ); return { id: c._id, author: { id: author!._id, name: author!.name, email: author!.email, avatarUrl: author!.avatarUrl, teams: author!.teams ?? [], }, 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 as any).isAvulso ?? false } : null, requester: requester && { id: requester._id, name: requester.name, email: requester.email, avatarUrl: requester.avatarUrl, teams: normalizeTeams(requester.teams), }, assignee: assignee ? { id: assignee._id, name: assignee.name, email: assignee.email, avatarUrl: assignee.avatarUrl, teams: normalizeTeams(assignee.teams), } : 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 as any).internalWorkedMs ?? 0, externalWorkedMs: (t as any).externalWorkedMs ?? 0, activeSession: activeSession ? { id: activeSession._id, agentId: activeSession.agentId, startedAt: activeSession.startedAt, workType: (activeSession as any).workType ?? "INTERNAL", } : null, }, description: undefined, customFields: customFieldsRecord, timeline: timeline.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"); } 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 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: args.queueId, categoryId: args.categoryId, subcategoryId: args.subcategoryId, requesterId: args.requesterId, assigneeId: initialAssigneeId, companyId: requester.companyId ?? undefined, 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() if (normalizedRole === "MANAGER") { await ensureManagerTicketAccess(ctx, author, ticketDoc) if (args.visibility !== "PUBLIC") { throw new ConvexError("Gestores só podem registrar comentários públicos") } } if (ticketDoc.requesterId === args.authorId) { // requester commenting: managers restricted to PUBLIC (handled above); // internal staff require staff permission if (STAFF_ROLES.has(normalizedRole)) { await requireTicketStaff(ctx, args.authorId, ticketDoc) } else { throw new ConvexError("Autor não possui permissão para comentar") } } else { await requireTicketStaff(ctx, args.authorId, ticketDoc) } const now = Date.now(); const id = await ctx.db.insert("ticketComments", { ticketId: args.ticketId, authorId: args.authorId, visibility: args.visibility, body: args.body, attachments: args.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 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 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 now = Date.now(); await ctx.db.patch(ticketId, { assigneeId, 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 return { ticketId, totalWorkedMs: ticket.totalWorkedMs ?? 0, internalWorkedMs: (ticket as any).internalWorkedMs ?? 0, externalWorkedMs: (ticket as any).externalWorkedMs ?? 0, activeSession: activeSession ? { id: activeSession._id, agentId: activeSession.agentId, startedAt: activeSession.startedAt, workType: (activeSession as any).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 = { 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") } await requireStaff(ctx, actorId, ticket.tenantId) if (ticket.activeSessionId) { return { status: "already_started", sessionId: ticket.activeSessionId } } const now = Date.now() 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, updatedAt: now, }) const actor = (await ctx.db.get(actorId)) as Doc<"users"> | null await ctx.db.insert("ticketEvents", { ticketId, type: "WORK_STARTED", payload: { actorId, actorName: actor?.name, actorAvatar: actor?.avatarUrl, sessionId, workType: (workType ?? "INTERNAL").toUpperCase() }, createdAt: now, }) return { status: "started", sessionId, startedAt: 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") } await requireStaff(ctx, actorId, ticket.tenantId) if (!ticket.activeSessionId) { return { status: "already_paused" } } if (!PAUSE_REASON_LABELS[reason]) { throw new ConvexError("Motivo de pausa inválido") } const session = await ctx.db.get(ticket.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(ticket.activeSessionId, { stoppedAt: now, durationMs, pauseReason: reason, pauseNote: note ?? "", }) const sessionType = ((session as any).workType ?? "INTERNAL").toUpperCase() const deltaInternal = sessionType === "INTERNAL" ? durationMs : 0 const deltaExternal = sessionType === "EXTERNAL" ? durationMs : 0 await ctx.db.patch(ticketId, { working: false, activeSessionId: undefined, totalWorkedMs: (ticket.totalWorkedMs ?? 0) + durationMs, internalWorkedMs: ((ticket as any).internalWorkedMs ?? 0) + deltaInternal, externalWorkedMs: ((ticket as any).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 ?? "", } }, }) 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) 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; await ctx.db.patch(chosen._id, { assigneeId: agentId, 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 && { id: requester._id, name: requester.name, email: requester.email, avatarUrl: requester.avatarUrl, teams: normalizeTeams(requester.teams), }, assignee: assignee ? { id: assignee._id, name: assignee.name, email: assignee.email, avatarUrl: assignee.avatarUrl, teams: normalizeTeams(assignee.teams), } : 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") }, handler: async (ctx, { ticketId }) => { // 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; }, });