sistema-de-chamados/convex/ticketFormTemplates.ts
2025-11-09 21:09:38 -03:00

281 lines
9 KiB
TypeScript

"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))
.collect();
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<Doc<"ticketFormTemplates">> = {};
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<Doc<"ticketFormTemplates"> | 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))
.collect();
if (sourceFields.length === 0) return;
const ordered = await ctx.db
.query("ticketFields")
.withIndex("by_tenant_order", (q) => q.eq("tenantId", tenantId))
.collect();
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))
.collect();
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))
.collect();
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))
.collect();
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,
});
},
});