280 lines
9 KiB
TypeScript
280 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,
|
|
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,
|
|
});
|
|
},
|
|
});
|