import { mutation, query } from "./_generated/server"; import type { MutationCtx, QueryCtx } from "./_generated/server"; import { ConvexError, v } from "convex/values"; import type { Id } from "./_generated/dataModel"; import { requireAdmin, requireStaff } from "./rbac"; type TicketStatusNormalized = "PENDING" | "AWAITING_ATTENDANCE" | "PAUSED" | "RESOLVED"; const STATUS_NORMALIZE_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 = STATUS_NORMALIZE_MAP[status.toUpperCase()]; return normalized ?? "PENDING"; } 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) { const direct = QUEUE_RENAME_LOOKUP[value]; if (direct) return direct; const normalizedKey = value.replace(/\s+/g, "-").toLowerCase(); return QUEUE_RENAME_LOOKUP[normalizedKey] ?? value; } function slugify(value: string) { return value .normalize("NFD") .replace(/[^\w\s-]/g, "") .trim() .replace(/\s+/g, "-") .replace(/-+/g, "-") .toLowerCase(); } type AnyCtx = QueryCtx | MutationCtx; async function ensureUniqueSlug(ctx: AnyCtx, tenantId: string, slug: string, excludeId?: Id<"queues">) { const existing = await ctx.db .query("queues") .withIndex("by_tenant_slug", (q) => q.eq("tenantId", tenantId).eq("slug", slug)) .first(); if (existing && (!excludeId || existing._id !== excludeId)) { throw new ConvexError("Já existe uma fila com este identificador"); } } async function ensureUniqueName(ctx: AnyCtx, tenantId: string, name: string, excludeId?: Id<"queues">) { const existing = await ctx.db .query("queues") .withIndex("by_tenant_name", (q) => q.eq("tenantId", tenantId).eq("name", name)) .first(); if (existing && (!excludeId || existing._id !== excludeId)) { throw new ConvexError("Já existe uma fila com este nome"); } } export const list = query({ args: { tenantId: v.string(), viewerId: v.id("users") }, handler: async (ctx, { tenantId, viewerId }) => { await requireAdmin(ctx, viewerId, tenantId); const queues = await ctx.db .query("queues") .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) .collect(); const teams = await ctx.db .query("teams") .withIndex("by_tenant_name", (q) => q.eq("tenantId", tenantId)) .collect(); return queues.map((queue) => { const team = queue.teamId ? teams.find((item) => item._id === queue.teamId) : null; return { id: queue._id, name: queue.name, slug: queue.slug, team: team ? { id: team._id, name: team.name, } : null, }; }); }, }); export const summary = query({ args: { tenantId: v.string(), viewerId: v.id("users") }, handler: async (ctx, { tenantId, viewerId }) => { await requireStaff(ctx, viewerId, tenantId); const queues = await ctx.db.query("queues").withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)).collect(); const result = await Promise.all( queues.map(async (qItem) => { const tickets = await ctx.db .query("tickets") .withIndex("by_tenant_queue", (q) => q.eq("tenantId", tenantId).eq("queueId", qItem._id)) .collect(); let pending = 0; let inProgress = 0; let paused = 0; let breached = 0; const now = Date.now(); for (const ticket of tickets) { const status = normalizeStatus(ticket.status); if (status === "PENDING") { pending += 1; } else if (status === "AWAITING_ATTENDANCE") { inProgress += 1; } else if (status === "PAUSED") { paused += 1; } if (status !== "RESOLVED") { const dueAt = typeof ticket.dueAt === "number" ? ticket.dueAt : null; if (dueAt && dueAt < now) { breached += 1; } } } return { id: qItem._id, name: renameQueueString(qItem.name), pending, inProgress, paused, breached, }; }) ); return result; }, }); export const create = mutation({ args: { tenantId: v.string(), actorId: v.id("users"), name: v.string(), teamId: v.optional(v.id("teams")), }, handler: async (ctx, { tenantId, actorId, name, teamId }) => { await requireAdmin(ctx, actorId, tenantId); const trimmed = name.trim(); if (trimmed.length < 2) { throw new ConvexError("Informe um nome válido para a fila"); } await ensureUniqueName(ctx, tenantId, trimmed); const slug = slugify(trimmed); await ensureUniqueSlug(ctx, tenantId, slug); if (teamId) { const team = await ctx.db.get(teamId); if (!team || team.tenantId !== tenantId) { throw new ConvexError("Time inválido"); } } const id = await ctx.db.insert("queues", { tenantId, name: trimmed, slug, teamId: teamId ?? undefined, }); return id; }, }); export const update = mutation({ args: { queueId: v.id("queues"), tenantId: v.string(), actorId: v.id("users"), name: v.string(), teamId: v.optional(v.id("teams")), }, handler: async (ctx, { queueId, tenantId, actorId, name, teamId }) => { await requireAdmin(ctx, actorId, tenantId); const queue = await ctx.db.get(queueId); if (!queue || queue.tenantId !== tenantId) { throw new ConvexError("Fila não encontrada"); } const trimmed = name.trim(); if (trimmed.length < 2) { throw new ConvexError("Informe um nome válido para a fila"); } await ensureUniqueName(ctx, tenantId, trimmed, queueId); let slug = queue.slug; if (queue.name !== trimmed) { slug = slugify(trimmed); await ensureUniqueSlug(ctx, tenantId, slug, queueId); } if (teamId) { const team = await ctx.db.get(teamId); if (!team || team.tenantId !== tenantId) { throw new ConvexError("Time inválido"); } } await ctx.db.patch(queueId, { name: trimmed, slug, teamId: teamId ?? undefined, }); }, }); export const remove = mutation({ args: { queueId: v.id("queues"), tenantId: v.string(), actorId: v.id("users"), }, handler: async (ctx, { queueId, tenantId, actorId }) => { await requireAdmin(ctx, actorId, tenantId); const queue = await ctx.db.get(queueId); if (!queue || queue.tenantId !== tenantId) { throw new ConvexError("Fila não encontrada"); } const ticketUsingQueue = await ctx.db .query("tickets") .withIndex("by_tenant_queue", (q) => q.eq("tenantId", tenantId).eq("queueId", queueId)) .first(); if (ticketUsingQueue) { throw new ConvexError("Não é possível remover uma fila vinculada a tickets"); } await ctx.db.delete(queueId); }, });