"use server"; import { mutation, query } from "./_generated/server"; import type { MutationCtx, QueryCtx } from "./_generated/server"; import { ConvexError, v } from "convex/values"; import type { Doc, Id } from "./_generated/dataModel"; import { requireAdmin, requireStaff } from "./rbac"; import { TICKET_FORM_CONFIG } from "./ticketForms.config"; type AnyCtx = MutationCtx | QueryCtx; function slugify(input: string) { return input .trim() .toLowerCase() .normalize("NFD") .replace(/[^\w\s-]/g, "") .replace(/\s+/g, "-") .replace(/-+/g, "-") .replace(/^-|-$/g, ""); } export function normalizeFormTemplateKey(input: string | null | undefined): string | null { if (!input) return null; const normalized = slugify(input); return normalized || null; } async function templateKeyExists(ctx: AnyCtx, tenantId: string, key: string) { const existing = await ctx.db .query("ticketFormTemplates") .withIndex("by_tenant_key", (q) => q.eq("tenantId", tenantId).eq("key", key)) .first(); return Boolean(existing); } export async function ensureTicketFormTemplatesForTenant(ctx: MutationCtx, tenantId: string) { const existing = await ctx.db .query("ticketFormTemplates") .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) .take(50); let order = existing.reduce((max, tpl) => Math.max(max, tpl.order ?? 0), 0); const now = Date.now(); for (const template of TICKET_FORM_CONFIG) { const match = existing.find((tpl) => tpl.key === template.key); if (match) { const updates: Partial> = {}; if (!match.baseTemplateKey) { updates.baseTemplateKey = template.key; } if (match.isSystem !== true) { updates.isSystem = true; } if (typeof match.defaultEnabled === "undefined") { updates.defaultEnabled = template.defaultEnabled; } if (Object.keys(updates).length) { await ctx.db.patch(match._id, { ...updates, updatedAt: now, }); } continue; } order += 1; await ctx.db.insert("ticketFormTemplates", { tenantId, key: template.key, label: template.label, description: template.description ?? undefined, defaultEnabled: template.defaultEnabled, baseTemplateKey: template.key, isSystem: true, isArchived: false, order, createdAt: now, updatedAt: now, }); } } export async function getTemplateByKey(ctx: AnyCtx, tenantId: string, key: string): Promise | null> { return ctx.db .query("ticketFormTemplates") .withIndex("by_tenant_key", (q) => q.eq("tenantId", tenantId).eq("key", key)) .first(); } async function generateTemplateKey(ctx: MutationCtx, tenantId: string, label: string) { const base = slugify(label) || `template-${Date.now()}`; let candidate = base; let suffix = 1; while (await templateKeyExists(ctx, tenantId, candidate)) { candidate = `${base}-${suffix}`; suffix += 1; } return candidate; } async function cloneFieldsFromTemplate(ctx: MutationCtx, tenantId: string, sourceKey: string, targetKey: string) { const sourceFields = await ctx.db .query("ticketFields") .withIndex("by_tenant_scope", (q) => q.eq("tenantId", tenantId).eq("scope", sourceKey)) .take(50); if (sourceFields.length === 0) return; const ordered = await ctx.db .query("ticketFields") .withIndex("by_tenant_order", (q) => q.eq("tenantId", tenantId)) .take(50); let order = ordered.reduce((max, field) => Math.max(max, field.order ?? 0), 0); const now = Date.now(); for (const field of sourceFields.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))) { order += 1; await ctx.db.insert("ticketFields", { tenantId, key: field.key, label: field.label, description: field.description ?? undefined, type: field.type, required: field.required, options: field.options ?? undefined, scope: targetKey, companyId: field.companyId ?? undefined, order, createdAt: now, updatedAt: now, }); } } function mapTemplate(template: Doc<"ticketFormTemplates">) { return { id: template._id, key: template.key, label: template.label, description: template.description ?? "", defaultEnabled: template.defaultEnabled ?? true, baseTemplateKey: template.baseTemplateKey ?? null, isSystem: Boolean(template.isSystem), isArchived: Boolean(template.isArchived), order: template.order ?? 0, createdAt: template.createdAt, updatedAt: template.updatedAt, }; } 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("ticketFormTemplates") .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) .take(50); return templates .filter((tpl) => includeArchived || tpl.isArchived !== true) .sort((a, b) => (a.order ?? 0) - (b.order ?? 0) || a.label.localeCompare(b.label, "pt-BR")) .map(mapTemplate); }, }); export const listActive = query({ args: { tenantId: v.string(), viewerId: v.id("users"), }, handler: async (ctx, { tenantId, viewerId }) => { await requireStaff(ctx, viewerId, tenantId); const templates = await ctx.db .query("ticketFormTemplates") .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) .take(50); return templates .filter((tpl) => tpl.isArchived !== true) .sort((a, b) => (a.order ?? 0) - (b.order ?? 0) || a.label.localeCompare(b.label, "pt-BR")) .map(mapTemplate); }, }); export const create = mutation({ args: { tenantId: v.string(), actorId: v.id("users"), label: v.string(), description: v.optional(v.string()), baseTemplateKey: v.optional(v.string()), cloneFields: v.optional(v.boolean()), }, handler: async (ctx, { tenantId, actorId, label, description, baseTemplateKey, cloneFields }) => { await requireAdmin(ctx, actorId, tenantId); const trimmedLabel = label.trim(); if (trimmedLabel.length < 3) { throw new ConvexError("Informe um nome com pelo menos 3 caracteres"); } const key = await generateTemplateKey(ctx, tenantId, trimmedLabel); const templates = await ctx.db .query("ticketFormTemplates") .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) .take(50); const order = (templates.reduce((max, tpl) => Math.max(max, tpl.order ?? 0), 0) ?? 0) + 1; const now = Date.now(); const templateId = await ctx.db.insert("ticketFormTemplates", { tenantId, key, label: trimmedLabel, description: description?.trim() || undefined, defaultEnabled: true, baseTemplateKey: baseTemplateKey ?? undefined, isSystem: false, isArchived: false, order, createdAt: now, updatedAt: now, createdBy: actorId, updatedBy: actorId, }); if (baseTemplateKey && cloneFields) { await cloneFieldsFromTemplate(ctx, tenantId, baseTemplateKey, key); } return templateId; }, }); export const update = mutation({ args: { tenantId: v.string(), actorId: v.id("users"), templateId: v.id("ticketFormTemplates"), label: v.string(), description: v.optional(v.string()), isArchived: v.optional(v.boolean()), defaultEnabled: v.optional(v.boolean()), order: v.optional(v.number()), }, handler: async (ctx, { tenantId, actorId, templateId, label, description, isArchived, defaultEnabled, order }) => { await requireAdmin(ctx, actorId, tenantId); const template = await ctx.db.get(templateId); if (!template || template.tenantId !== tenantId) { throw new ConvexError("Template não encontrado"); } const trimmedLabel = label.trim(); if (trimmedLabel.length < 3) { throw new ConvexError("Informe um nome com pelo menos 3 caracteres"); } await ctx.db.patch(templateId, { label: trimmedLabel, description: description?.trim() || undefined, isArchived: typeof isArchived === "boolean" ? isArchived : template.isArchived ?? false, defaultEnabled: typeof defaultEnabled === "boolean" ? defaultEnabled : template.defaultEnabled ?? true, order: typeof order === "number" ? order : template.order ?? 0, updatedAt: Date.now(), updatedBy: actorId, }); }, }); export const archive = mutation({ args: { tenantId: v.string(), actorId: v.id("users"), templateId: v.id("ticketFormTemplates"), archived: v.boolean(), }, handler: async (ctx, { tenantId, actorId, templateId, archived }) => { await requireAdmin(ctx, actorId, tenantId); const template = await ctx.db.get(templateId); if (!template || template.tenantId !== tenantId) { throw new ConvexError("Template não encontrado"); } await ctx.db.patch(templateId, { isArchived: archived, updatedAt: Date.now(), updatedBy: actorId, }); }, });