import { internalMutation, mutation, query } from "./_generated/server"; import { ConvexError, v } from "convex/values"; import { Id, type Doc } from "./_generated/dataModel"; const PRIORITY_ORDER = ["URGENT", "HIGH", "MEDIUM", "LOW"] as const; const QUEUE_RENAME_LOOKUP: Record = { "Suporte N1": "Chamados", "suporte-n1": "Chamados", "Suporte N2": "Laboratório", "suporte-n2": "Laboratório", }; 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 && normalized !== queue.name) { return normalized; } if (queue.slug) { const fromSlug = renameQueueString(queue.slug); if (fromSlug) return fromSlug; } return normalized ?? queue.name; } function normalizeTeams(teams?: string[] | null): string[] { if (!teams) return []; return teams.map((team) => renameQueueString(team) ?? team); } export const list = query({ args: { 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) => { // Choose best index based on provided args for efficiency let base: Doc<"tickets">[] = []; if (args.status) { base = await ctx.db .query("tickets") .withIndex("by_tenant_status", (q) => q.eq("tenantId", args.tenantId).eq("status", args.status!)) .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 (args.priority) filtered = filtered.filter((t) => t.priority === args.priority); if (args.channel) filtered = filtered.filter((t) => t.channel === args.channel); 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 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: t.status, priority: t.priority, channel: t.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: 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, activeSession: activeSession ? { id: activeSession._id, agentId: activeSession.agentId, startedAt: activeSession.startedAt, } : 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") }, handler: async (ctx, { tenantId, id }) => { const t = await ctx.db.get(id); if (!t || t.tenantId !== tenantId) return null; 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 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 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: t.status, priority: t.priority, channel: t.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: 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, activeSession: activeSession ? { id: activeSession._id, agentId: activeSession.agentId, startedAt: activeSession.startedAt, } : null, }, description: undefined, customFields: {}, 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: { 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"), categoryId: v.id("ticketCategories"), subcategoryId: v.id("ticketSubcategories"), }, handler: async (ctx, args) => { 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"); } // 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 id = await ctx.db.insert("tickets", { tenantId: args.tenantId, reference: nextRef, subject, summary: args.summary?.trim() || undefined, status: "NEW", priority: args.priority, channel: args.channel, queueId: args.queueId, categoryId: args.categoryId, subcategoryId: args.subcategoryId, requesterId: args.requesterId, assigneeId: undefined, working: false, activeSessionId: undefined, totalWorkedMs: 0, createdAt: now, updatedAt: now, firstResponseAt: undefined, resolvedAt: undefined, closedAt: undefined, tags: [], slaPolicyId: undefined, dueAt: undefined, }); const requester = await ctx.db.get(args.requesterId); await ctx.db.insert("ticketEvents", { ticketId: id, type: "CREATED", payload: { requesterId: args.requesterId, requesterName: requester?.name, requesterAvatar: requester?.avatarUrl }, 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 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, }); const author = await ctx.db.get(args.authorId); 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 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 comment = await ctx.db.get(commentId); if (!comment || comment.ticketId !== ticketId) { throw new ConvexError("Comentário não encontrado"); } 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, }); const actor = (await ctx.db.get(actorId)) as Doc<"users"> | null; 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 now = Date.now(); await ctx.db.patch(ticketId, { status, updatedAt: now }); const statusPt: Record = { NEW: "Novo", OPEN: "Aberto", PENDING: "Pendente", ON_HOLD: "Em espera", RESOLVED: "Resolvido", CLOSED: "Fechado", } as const; await ctx.db.insert("ticketEvents", { ticketId, type: "STATUS_CHANGED", payload: { to: status, toLabel: statusPt[status] ?? status, 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 now = Date.now(); await ctx.db.patch(ticketId, { assigneeId, updatedAt: now }); const user = (await ctx.db.get(assigneeId)) as Doc<"users"> | null; await ctx.db.insert("ticketEvents", { ticketId, type: "ASSIGNEE_CHANGED", payload: { assigneeId, assigneeName: user?.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 now = Date.now(); await ctx.db.patch(ticketId, { queueId, updatedAt: now }); const queue = (await ctx.db.get(queueId)) as Doc<"queues"> | null; 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.id("ticketCategories"), subcategoryId: v.id("ticketSubcategories"), 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 category = await ctx.db.get(categoryId) if (!category || category.tenantId !== ticket.tenantId) { throw new ConvexError("Categoria inválida") } const subcategory = await ctx.db.get(subcategoryId) if (!subcategory || subcategory.categoryId !== categoryId || subcategory.tenantId !== ticket.tenantId) { throw new ConvexError("Subcategoria inválida") } if (ticket.categoryId === categoryId && ticket.subcategoryId === subcategoryId) { return { status: "unchanged" } } const now = Date.now() await ctx.db.patch(ticketId, { categoryId, subcategoryId, 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: subcategory.name, actorId, actorName: actor?.name, actorAvatar: actor?.avatarUrl, }, createdAt: now, }) return { status: "updated" } }, }) export const workSummary = query({ args: { ticketId: v.id("tickets") }, handler: async (ctx, { ticketId }) => { const ticket = await ctx.db.get(ticketId) if (!ticket) return null const activeSession = ticket.activeSessionId ? await ctx.db.get(ticket.activeSessionId) : null return { ticketId, totalWorkedMs: ticket.totalWorkedMs ?? 0, activeSession: activeSession ? { id: activeSession._id, agentId: activeSession.agentId, startedAt: activeSession.startedAt, } : null, } }, }) export const updatePriority = mutation({ args: { ticketId: v.id("tickets"), priority: v.string(), actorId: v.id("users") }, handler: async (ctx, { ticketId, priority, actorId }) => { 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") }, handler: async (ctx, { ticketId, actorId }) => { const ticket = await ctx.db.get(ticketId) if (!ticket) { throw new ConvexError("Ticket não encontrado") } if (ticket.activeSessionId) { return { status: "already_started", sessionId: ticket.activeSessionId } } const now = Date.now() const sessionId = await ctx.db.insert("ticketWorkSessions", { ticketId, agentId: actorId, 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 }, createdAt: now, }) return { status: "started", sessionId, startedAt: now } }, }) export const pauseWork = 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") } if (!ticket.activeSessionId) { return { status: "already_paused" } } 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, }) await ctx.db.patch(ticketId, { working: false, activeSessionId: undefined, totalWorkedMs: (ticket.totalWorkedMs ?? 0) + durationMs, 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, }, createdAt: now, }) return { status: "paused", durationMs } }, }) 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) return; 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) return; 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 }) => { // 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.status !== "CLOSED" && !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(); await ctx.db.patch(chosen._id, { assigneeId: agentId, status: chosen.status === "NEW" ? "OPEN" : chosen.status, updatedAt: now }); const agent = (await ctx.db.get(agentId)) as Doc<"users"> | null; 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 return { id: chosen._id, reference: chosen.reference, tenantId: chosen.tenantId, subject: chosen.subject, summary: chosen.summary, status: chosen.status, priority: chosen.priority, channel: chosen.channel, queue: queue?.name ?? null, requester: requester && { id: requester._id, name: requester.name, email: requester.email, avatarUrl: requester.avatarUrl, teams: requester.teams ?? [], }, assignee: assignee ? { id: assignee._id, name: assignee.name, email: assignee.email, avatarUrl: assignee.avatarUrl, teams: 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"), actorId: v.id("users") }, handler: async (ctx, { ticketId, actorId }) => { // 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; }, });