import { internalMutation, mutation, query } from "./_generated/server"; import { v } from "convex/values"; import { Id, type Doc } from "./_generated/dataModel"; const PRIORITY_ORDER = ["URGENT", "HIGH", "MEDIUM", "LOW"] as const; 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; // 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; 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: 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: t.dueAt ?? null, firstResponseAt: t.firstResponseAt ?? null, resolvedAt: t.resolvedAt ?? null, updatedAt: t.updatedAt, createdAt: t.createdAt, tags: t.tags ?? [], lastTimelineEntry: null, metrics: 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 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, 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, }; }) ); 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: 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: t.dueAt ?? null, firstResponseAt: t.firstResponseAt ?? null, resolvedAt: t.resolvedAt ?? null, updatedAt: t.updatedAt, createdAt: t.createdAt, tags: t.tags ?? [], lastTimelineEntry: null, metrics: null, description: undefined, customFields: {}, timeline: timeline.map((ev) => ({ id: ev._id, type: ev.type, payload: ev.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"), }, handler: async (ctx, args) => { // 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: args.subject, summary: args.summary, status: "NEW", priority: args.priority, channel: args.channel, queueId: args.queueId, requesterId: args.requesterId, assigneeId: undefined, working: false, 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 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; await ctx.db.insert("ticketEvents", { ticketId, type: "QUEUE_CHANGED", payload: { queueId, queueName: queue?.name, actorId }, createdAt: now, }); }, }); 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 toggleWork = mutation({ args: { ticketId: v.id("tickets"), actorId: v.id("users") }, handler: async (ctx, { ticketId, actorId }) => { const t = await ctx.db.get(ticketId) if (!t) return const now = Date.now() const next = !(t.working ?? false) await ctx.db.patch(ticketId, { working: next, updatedAt: now }) const actor = (await ctx.db.get(actorId)) as Doc<"users"> | null await ctx.db.insert("ticketEvents", { ticketId, type: next ? "WORK_STARTED" : "WORK_PAUSED", payload: { actorId, actorName: actor?.name, actorAvatar: actor?.avatarUrl }, createdAt: now, }) return next }, }) 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; await ctx.db.patch(ticketId, { subject, updatedAt: now }); const actor = await ctx.db.get(actorId); await ctx.db.insert("ticketEvents", { ticketId, type: "SUBJECT_CHANGED", payload: { from: t.subject, to: subject, 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; }, });