import { internalMutation, mutation, query } from "./_generated/server"; import { v } from "convex/values"; import { Id } from "./_generated/dataModel"; const STATUS_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) => { let q = ctx.db .query("tickets") .withIndex("by_tenant_status", (q) => q.eq("tenantId", args.tenantId)); const all = await q.collect(); let filtered = all; if (args.status) filtered = filtered.filter((t) => t.status === args.status); if (args.priority) filtered = filtered.filter((t) => t.priority === args.priority); if (args.channel) filtered = filtered.filter((t) => t.channel === args.channel); if (args.queueId) filtered = filtered.filter((t) => t.queueId === args.queueId); 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); const assignee = t.assigneeId ? await ctx.db.get(t.assigneeId) : null; const queue = t.queueId ? await ctx.db.get(t.queueId) : 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 as any) - (a.updatedAt as any)); }, }); 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); const assignee = t.assigneeId ? await ctx.db.get(t.assigneeId) : null; const queue = t.queueId ? await ctx.db.get(t.queueId) : 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); 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, createdAt: now, updatedAt: now, firstResponseAt: undefined, resolvedAt: undefined, closedAt: undefined, tags: [], slaPolicyId: undefined, dueAt: undefined, }); await ctx.db.insert("ticketEvents", { ticketId: id, type: "CREATED", payload: { requesterId: args.requesterId }, 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, }); await ctx.db.insert("ticketEvents", { ticketId: args.ticketId, type: "COMMENT_ADDED", payload: { authorId: args.authorId }, 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 }); await ctx.db.insert("ticketEvents", { ticketId, type: "STATUS_CHANGED", payload: { to: status, actorId }, 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 = await ctx.db .query("tickets") .withIndex("by_tenant_queue", (q) => q.eq("tenantId", tenantId).eq("queueId", queueId ?? undefined as any)) .collect(); candidates = candidates.filter( (t) => t.status !== "RESOLVED" && t.status !== "CLOSED" && !t.assigneeId ); if (candidates.length === 0) return null; // prioritize by priority then createdAt candidates.sort((a, b) => { const pa = STATUS_ORDER.indexOf(a.priority as any); const pb = STATUS_ORDER.indexOf(b.priority as any); 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 }); await ctx.db.insert("ticketEvents", { ticketId: chosen._id, type: "ASSIGNEE_CHANGED", payload: { assigneeId: agentId }, createdAt: now, }); return await getPublicById(ctx, chosen._id); }, }); // internal helper to hydrate a ticket in the same shape as list/getById const getPublicById = async (ctx: any, id: Id<"tickets">) => { const t = await ctx.db.get(id); if (!t) return null; const requester = await ctx.db.get(t.requesterId); const assignee = t.assigneeId ? await ctx.db.get(t.assigneeId) : null; const queue = t.queueId ? await ctx.db.get(t.queueId) : 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, }; };