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, requireUser } from "./rbac" const FIELD_TYPES = ["text", "number", "select", "date", "boolean"] as const type FieldType = (typeof FIELD_TYPES)[number] type AnyCtx = MutationCtx | QueryCtx function normalizeKey(label: string) { return label .trim() .toLowerCase() .normalize("NFD") .replace(/[^\w\s-]/g, "") .replace(/\s+/g, "_") .replace(/_+/g, "_") } async function ensureUniqueKey(ctx: AnyCtx, tenantId: string, key: string, excludeId?: Id<"deviceFields">) { const existing = await ctx.db .query("deviceFields") .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") } } function matchesScope(fieldScope: string | undefined, scope: string | undefined) { if (!scope || scope === "all") return true if (!fieldScope || fieldScope === "all") return true return fieldScope === scope } function matchesCompany(fieldCompanyId: Id<"companies"> | undefined, companyId: Id<"companies"> | undefined, includeScoped?: boolean) { if (!companyId) { if (includeScoped) return true return fieldCompanyId ? false : true } return !fieldCompanyId || fieldCompanyId === companyId } export const list = query({ args: { tenantId: v.string(), viewerId: v.id("users"), companyId: v.optional(v.id("companies")), scope: v.optional(v.string()), }, handler: async (ctx, { tenantId, viewerId, companyId, scope }) => { await requireAdmin(ctx, viewerId, tenantId) const fieldsQuery = ctx.db .query("deviceFields") .withIndex("by_tenant_order", (q) => q.eq("tenantId", tenantId)) const fields = await fieldsQuery.collect() return fields .filter((field) => matchesCompany(field.companyId, companyId, true)) .filter((field) => matchesScope(field.scope, 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: Boolean(field.required), options: field.options ?? [], order: field.order, scope: field.scope ?? "all", companyId: field.companyId ?? null, })) }, }) export const listForTenant = query({ args: { tenantId: v.string(), viewerId: v.id("users"), companyId: v.optional(v.id("companies")), scope: v.optional(v.string()), }, handler: async (ctx, { tenantId, viewerId, companyId, scope }) => { await requireUser(ctx, viewerId, tenantId) const fields = await ctx.db .query("deviceFields") .withIndex("by_tenant_order", (q) => q.eq("tenantId", tenantId)) .collect() return fields .filter((field) => matchesCompany(field.companyId, companyId, false)) .filter((field) => matchesScope(field.scope, 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: Boolean(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.optional(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, args) => { await requireAdmin(ctx, args.actorId, args.tenantId) const normalizedLabel = args.label.trim() if (normalizedLabel.length < 2) { throw new ConvexError("Informe um rótulo para o campo") } if (!FIELD_TYPES.includes(args.type as FieldType)) { throw new ConvexError("Tipo de campo inválido") } validateOptions(args.type as FieldType, args.options ?? undefined) const key = normalizeKey(normalizedLabel) await ensureUniqueKey(ctx, args.tenantId, key) const existing = await ctx.db .query("deviceFields") .withIndex("by_tenant_order", (q) => q.eq("tenantId", args.tenantId)) .collect() const maxOrder = existing.reduce((acc, item) => Math.max(acc, item.order ?? 0), 0) const now = Date.now() const id = await ctx.db.insert("deviceFields", { tenantId: args.tenantId, key, label: normalizedLabel, description: args.description ?? undefined, type: args.type, required: Boolean(args.required), options: args.options ?? undefined, scope: args.scope ?? "all", companyId: args.companyId ?? undefined, order: maxOrder + 1, createdAt: now, updatedAt: now, createdBy: args.actorId, updatedBy: args.actorId, }) return id }, }) export const update = mutation({ args: { tenantId: v.string(), actorId: v.id("users"), fieldId: v.id("deviceFields"), label: v.string(), description: v.optional(v.string()), type: v.string(), required: v.optional(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, args) => { await requireAdmin(ctx, args.actorId, args.tenantId) const field = await ctx.db.get(args.fieldId) if (!field || field.tenantId !== args.tenantId) { throw new ConvexError("Campo não encontrado") } if (!FIELD_TYPES.includes(args.type as FieldType)) { throw new ConvexError("Tipo de campo inválido") } const normalizedLabel = args.label.trim() if (normalizedLabel.length < 2) { throw new ConvexError("Informe um rótulo para o campo") } validateOptions(args.type as FieldType, args.options ?? undefined) let key = field.key if (field.label !== normalizedLabel) { key = normalizeKey(normalizedLabel) await ensureUniqueKey(ctx, args.tenantId, key, args.fieldId) } await ctx.db.patch(args.fieldId, { key, label: normalizedLabel, description: args.description ?? undefined, type: args.type, required: Boolean(args.required), options: args.options ?? undefined, scope: args.scope ?? "all", companyId: args.companyId ?? undefined, updatedAt: Date.now(), updatedBy: args.actorId, }) }, }) export const remove = mutation({ args: { tenantId: v.string(), actorId: v.id("users"), fieldId: v.id("deviceFields"), }, handler: async (ctx, args) => { await requireAdmin(ctx, args.actorId, args.tenantId) const field = await ctx.db.get(args.fieldId) if (!field || field.tenantId !== args.tenantId) { throw new ConvexError("Campo não encontrado") } await ctx.db.delete(args.fieldId) }, }) export const reorder = mutation({ args: { tenantId: v.string(), actorId: v.id("users"), orderedIds: v.array(v.id("deviceFields")), }, handler: async (ctx, args) => { await requireAdmin(ctx, args.actorId, args.tenantId) const now = Date.now() await Promise.all( args.orderedIds.map((fieldId, index) => ctx.db.patch(fieldId, { order: index + 1, updatedAt: now, updatedBy: args.actorId, }) ) ) }, })