import { ConvexError, v } from "convex/values" import type { Doc, Id } from "./_generated/dataModel" import { mutation, query } from "./_generated/server" import { requireAdmin, requireStaff } from "./rbac" import { normalizeChecklistText } from "./ticketChecklist" function normalizeTemplateName(input: string) { return input.trim() } function normalizeTemplateDescription(input: string | null | undefined) { const text = (input ?? "").trim() return text.length > 0 ? text : null } type ChecklistItemType = "checkbox" | "question" type RawTemplateItem = { id?: string text: string description?: string type?: string options?: string[] required?: boolean } type NormalizedTemplateItem = { id: string text: string description?: string type?: ChecklistItemType options?: string[] required?: boolean } function normalizeTemplateItems( raw: RawTemplateItem[], options: { generateId?: () => string } ): NormalizedTemplateItem[] { if (!Array.isArray(raw) || raw.length === 0) { throw new ConvexError("Adicione pelo menos um item no checklist.") } const generateId = options.generateId ?? (() => crypto.randomUUID()) const seen = new Set() const items: NormalizedTemplateItem[] = [] for (const entry of raw) { const id = String(entry.id ?? "").trim() || generateId() if (seen.has(id)) { throw new ConvexError("Itens do checklist com IDs duplicados.") } seen.add(id) const text = normalizeChecklistText(entry.text) if (!text) { throw new ConvexError("Todos os itens do checklist precisam ter um texto.") } if (text.length > 240) { throw new ConvexError("Item do checklist muito longo (max. 240 caracteres).") } const description = entry.description?.trim() || undefined const itemType: ChecklistItemType = entry.type === "question" ? "question" : "checkbox" const itemOptions = itemType === "question" && Array.isArray(entry.options) ? entry.options.map((o) => String(o).trim()).filter((o) => o.length > 0) : undefined if (itemType === "question" && (!itemOptions || itemOptions.length < 2)) { throw new ConvexError(`A pergunta "${text}" precisa ter pelo menos 2 opcoes.`) } const required = typeof entry.required === "boolean" ? entry.required : true items.push({ id, text, description, type: itemType, options: itemOptions, required, }) } return items } function mapTemplate(template: Doc<"ticketChecklistTemplates">, company: Doc<"companies"> | null) { return { id: template._id, name: template.name, description: template.description ?? "", company: company ? { id: company._id, name: company.name } : null, items: (template.items ?? []).map((item) => ({ id: item.id, text: item.text, description: item.description, type: item.type ?? "checkbox", options: item.options, required: typeof item.required === "boolean" ? item.required : true, })), isArchived: Boolean(template.isArchived), createdAt: template.createdAt, updatedAt: template.updatedAt, } } export const listActive = query({ args: { tenantId: v.string(), viewerId: v.id("users"), companyId: v.optional(v.id("companies")), }, handler: async (ctx, { tenantId, viewerId, companyId }) => { await requireStaff(ctx, viewerId, tenantId) const templates = await ctx.db .query("ticketChecklistTemplates") .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) .take(200) const filtered = templates.filter((tpl) => { if (tpl.isArchived === true) return false if (!companyId) return true return !tpl.companyId || String(tpl.companyId) === String(companyId) }) const companiesToHydrate = new Map>() for (const tpl of filtered) { if (tpl.companyId) { companiesToHydrate.set(String(tpl.companyId), tpl.companyId) } } const companyMap = new Map>() for (const id of companiesToHydrate.values()) { const company = await ctx.db.get(id) if (company && company.tenantId === tenantId) { companyMap.set(String(id), company as Doc<"companies">) } } return filtered .sort((a, b) => { const aSpecific = a.companyId ? 1 : 0 const bSpecific = b.companyId ? 1 : 0 if (aSpecific !== bSpecific) return bSpecific - aSpecific return (a.name ?? "").localeCompare(b.name ?? "", "pt-BR") }) .map((tpl) => mapTemplate(tpl, tpl.companyId ? (companyMap.get(String(tpl.companyId)) ?? null) : null)) }, }) export const list = query({ args: { tenantId: v.string(), viewerId: v.id("users"), includeArchived: v.optional(v.boolean()), }, handler: async (ctx, { tenantId, viewerId, includeArchived }) => { await requireAdmin(ctx, viewerId, tenantId) const templates = await ctx.db .query("ticketChecklistTemplates") .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) .take(500) const filtered = templates.filter((tpl) => includeArchived || tpl.isArchived !== true) const companiesToHydrate = new Map>() for (const tpl of filtered) { if (tpl.companyId) { companiesToHydrate.set(String(tpl.companyId), tpl.companyId) } } const companyMap = new Map>() for (const id of companiesToHydrate.values()) { const company = await ctx.db.get(id) if (company && company.tenantId === tenantId) { companyMap.set(String(id), company as Doc<"companies">) } } return filtered .sort((a, b) => { const aSpecific = a.companyId ? 1 : 0 const bSpecific = b.companyId ? 1 : 0 if (aSpecific !== bSpecific) return bSpecific - aSpecific return (a.name ?? "").localeCompare(b.name ?? "", "pt-BR") }) .map((tpl) => mapTemplate(tpl, tpl.companyId ? (companyMap.get(String(tpl.companyId)) ?? null) : null)) }, }) export const create = mutation({ args: { tenantId: v.string(), actorId: v.id("users"), name: v.string(), description: v.optional(v.string()), companyId: v.optional(v.id("companies")), items: v.array( v.object({ id: v.optional(v.string()), text: v.string(), description: v.optional(v.string()), type: v.optional(v.string()), options: v.optional(v.array(v.string())), required: v.optional(v.boolean()), }), ), isArchived: v.optional(v.boolean()), }, handler: async (ctx, { tenantId, actorId, name, description, companyId, items, isArchived }) => { await requireAdmin(ctx, actorId, tenantId) const normalizedName = normalizeTemplateName(name) if (normalizedName.length < 3) { throw new ConvexError("Informe um nome com pelo menos 3 caracteres.") } if (companyId) { const company = await ctx.db.get(companyId) if (!company || company.tenantId !== tenantId) { throw new ConvexError("Empresa inválida para o template.") } } const normalizedItems = normalizeTemplateItems(items, {}) const normalizedDescription = normalizeTemplateDescription(description) const archivedFlag = typeof isArchived === "boolean" ? isArchived : false const now = Date.now() return ctx.db.insert("ticketChecklistTemplates", { tenantId, name: normalizedName, description: normalizedDescription ?? undefined, companyId: companyId ?? undefined, items: normalizedItems, isArchived: archivedFlag, createdAt: now, updatedAt: now, createdBy: actorId, updatedBy: actorId, }) }, }) export const update = mutation({ args: { tenantId: v.string(), actorId: v.id("users"), templateId: v.id("ticketChecklistTemplates"), name: v.string(), description: v.optional(v.string()), companyId: v.optional(v.id("companies")), items: v.array( v.object({ id: v.optional(v.string()), text: v.string(), description: v.optional(v.string()), type: v.optional(v.string()), options: v.optional(v.array(v.string())), required: v.optional(v.boolean()), }), ), isArchived: v.optional(v.boolean()), }, handler: async (ctx, { tenantId, actorId, templateId, name, description, companyId, items, isArchived }) => { await requireAdmin(ctx, actorId, tenantId) const existing = await ctx.db.get(templateId) if (!existing || existing.tenantId !== tenantId) { throw new ConvexError("Template de checklist não encontrado.") } const normalizedName = normalizeTemplateName(name) if (normalizedName.length < 3) { throw new ConvexError("Informe um nome com pelo menos 3 caracteres.") } if (companyId) { const company = await ctx.db.get(companyId) if (!company || company.tenantId !== tenantId) { throw new ConvexError("Empresa inválida para o template.") } } const normalizedItems = normalizeTemplateItems(items, {}) const normalizedDescription = normalizeTemplateDescription(description) const nextArchived = typeof isArchived === "boolean" ? isArchived : Boolean(existing.isArchived) const now = Date.now() await ctx.db.patch(templateId, { name: normalizedName, description: normalizedDescription ?? undefined, companyId: companyId ?? undefined, items: normalizedItems, isArchived: nextArchived, updatedAt: now, updatedBy: actorId, }) return { ok: true } }, }) export const remove = mutation({ args: { tenantId: v.string(), actorId: v.id("users"), templateId: v.id("ticketChecklistTemplates"), }, handler: async (ctx, { tenantId, actorId, templateId }) => { await requireAdmin(ctx, actorId, tenantId) const existing = await ctx.db.get(templateId) if (!existing || existing.tenantId !== tenantId) { throw new ConvexError("Template de checklist não encontrado.") } await ctx.db.delete(templateId) return { ok: true } }, })