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, requireUser } from "./rbac"; const FIELD_TYPES = ["text", "number", "select", "date", "boolean"] as const; type FieldType = (typeof FIELD_TYPES)[number]; function normalizeKey(label: string) { return label .trim() .toLowerCase() .normalize("NFD") .replace(/[^\w\s-]/g, "") .replace(/\s+/g, "_") .replace(/_+/g, "_"); } type AnyCtx = QueryCtx | MutationCtx; async function ensureUniqueKey(ctx: AnyCtx, tenantId: string, key: string, excludeId?: Id<"ticketFields">) { const existing = await ctx.db .query("ticketFields") .withIndex("by_tenant_key", (q) => q.eq("tenantId", tenantId).eq("key", key)) .first(); if (existing && (!excludeId || existing._id !== excludeId)) { throw new ConvexError("Já existe um campo com este identificador"); } } function validateOptions(type: FieldType, options: { value: string; label: string }[] | undefined) { if (type === "select" && (!options || options.length === 0)) { throw new ConvexError("Campos de seleção precisam de pelo menos uma opção"); } } async function validateCompanyScope(ctx: AnyCtx, tenantId: string, companyId?: Id<"companies"> | null) { if (!companyId) return undefined; const company = await ctx.db.get(companyId); if (!company || company.tenantId !== tenantId) { throw new ConvexError("Empresa inválida para o campo"); } return companyId; } export const list = query({ args: { tenantId: v.string(), viewerId: v.id("users"), scope: v.optional(v.string()) }, handler: async (ctx, { tenantId, viewerId, scope }) => { await requireAdmin(ctx, viewerId, tenantId); const fields = await ctx.db .query("ticketFields") .withIndex("by_tenant_order", (q) => q.eq("tenantId", tenantId)) .take(100); return fields .filter((field) => { if (!scope) return true; const fieldScope = (field.scope ?? "all").trim(); if (fieldScope === "all" || fieldScope.length === 0) return true; return fieldScope === scope; }) .sort((a, b) => a.order - b.order) .map((field) => ({ id: field._id, key: field.key, label: field.label, description: field.description ?? "", type: field.type as FieldType, required: field.required, options: field.options ?? [], order: field.order, scope: field.scope ?? "all", companyId: field.companyId ?? null, createdAt: field.createdAt, updatedAt: field.updatedAt, })); }, }); export const listForTenant = query({ args: { tenantId: v.string(), viewerId: v.id("users"), scope: v.optional(v.string()) }, handler: async (ctx, { tenantId, viewerId, scope }) => { await requireUser(ctx, viewerId, tenantId); const fields = await ctx.db .query("ticketFields") .withIndex("by_tenant_order", (q) => q.eq("tenantId", tenantId)) .take(100); return fields .filter((field) => { if (!scope) return true; const fieldScope = (field.scope ?? "all").trim(); if (fieldScope === "all" || fieldScope.length === 0) return true; return fieldScope === scope; }) .sort((a, b) => a.order - b.order) .map((field) => ({ id: field._id, key: field.key, label: field.label, description: field.description ?? "", type: field.type as FieldType, required: field.required, options: field.options ?? [], order: field.order, scope: field.scope ?? "all", companyId: field.companyId ?? null, })); }, }); export const create = mutation({ args: { tenantId: v.string(), actorId: v.id("users"), label: v.string(), description: v.optional(v.string()), type: v.string(), required: v.boolean(), options: v.optional( v.array( v.object({ value: v.string(), label: v.string(), }) ) ), scope: v.optional(v.string()), companyId: v.optional(v.id("companies")), }, handler: async (ctx, { tenantId, actorId, label, description, type, required, options, scope, companyId }) => { await requireAdmin(ctx, actorId, tenantId); const normalizedLabel = label.trim(); if (normalizedLabel.length < 2) { throw new ConvexError("Informe um rótulo para o campo"); } if (!FIELD_TYPES.includes(type as FieldType)) { throw new ConvexError("Tipo de campo inválido"); } validateOptions(type as FieldType, options ?? undefined); const key = normalizeKey(normalizedLabel); await ensureUniqueKey(ctx, tenantId, key); const normalizedScope = (() => { const raw = scope?.trim(); if (!raw || raw.length === 0) return "all"; const safe = raw.toLowerCase(); if (!/^[a-z0-9_\-]+$/i.test(safe)) { throw new ConvexError("Escopo inválido para o campo"); } return safe; })(); const companyRef = await validateCompanyScope(ctx, tenantId, companyId ?? undefined); const existing = await ctx.db .query("ticketFields") .withIndex("by_tenant_order", (q) => q.eq("tenantId", tenantId)) .take(100); const maxOrder = existing.reduce((acc: number, item: Doc<"ticketFields">) => Math.max(acc, item.order ?? 0), 0); const now = Date.now(); const id = await ctx.db.insert("ticketFields", { tenantId, key, label: normalizedLabel, description, type, required, options, order: maxOrder + 1, scope: normalizedScope, companyId: companyRef, createdAt: now, updatedAt: now, }); return id; }, }); export const update = mutation({ args: { tenantId: v.string(), fieldId: v.id("ticketFields"), actorId: v.id("users"), label: v.string(), description: v.optional(v.string()), type: v.string(), required: v.boolean(), options: v.optional( v.array( v.object({ value: v.string(), label: v.string(), }) ) ), scope: v.optional(v.string()), companyId: v.optional(v.id("companies")), }, handler: async (ctx, { tenantId, fieldId, actorId, label, description, type, required, options, scope, companyId }) => { await requireAdmin(ctx, actorId, tenantId); const field = await ctx.db.get(fieldId); if (!field || field.tenantId !== tenantId) { throw new ConvexError("Campo não encontrado"); } if (!FIELD_TYPES.includes(type as FieldType)) { throw new ConvexError("Tipo de campo inválido"); } const normalizedLabel = label.trim(); if (normalizedLabel.length < 2) { throw new ConvexError("Informe um rótulo para o campo"); } validateOptions(type as FieldType, options ?? undefined); const normalizedScope = (() => { const raw = scope?.trim(); if (!raw || raw.length === 0) return "all"; const safe = raw.toLowerCase(); if (!/^[a-z0-9_\-]+$/i.test(safe)) { throw new ConvexError("Escopo inválido para o campo"); } return safe; })(); const companyRef = await validateCompanyScope(ctx, tenantId, companyId ?? undefined); let key = field.key; if (field.label !== normalizedLabel) { key = normalizeKey(normalizedLabel); await ensureUniqueKey(ctx, tenantId, key, fieldId); } await ctx.db.patch(fieldId, { key, label: normalizedLabel, description, type, required, options, scope: normalizedScope, companyId: companyRef, updatedAt: Date.now(), }); }, }); export const remove = mutation({ args: { tenantId: v.string(), fieldId: v.id("ticketFields"), actorId: v.id("users"), }, handler: async (ctx, { tenantId, fieldId, actorId }) => { await requireAdmin(ctx, actorId, tenantId); const field = await ctx.db.get(fieldId); if (!field || field.tenantId !== tenantId) { throw new ConvexError("Campo não encontrado"); } await ctx.db.delete(fieldId); }, }); export const reorder = mutation({ args: { tenantId: v.string(), actorId: v.id("users"), orderedIds: v.array(v.id("ticketFields")), }, handler: async (ctx, { tenantId, actorId, orderedIds }) => { await requireAdmin(ctx, actorId, tenantId); const fields = await Promise.all(orderedIds.map((id) => ctx.db.get(id))); fields.forEach((field) => { if (!field || field.tenantId !== tenantId) { throw new ConvexError("Campo inválido para reordenação"); } }); const now = Date.now(); await Promise.all( orderedIds.map((fieldId, index) => ctx.db.patch(fieldId, { order: index + 1, updatedAt: now, }) ) ); }, });