feat: implement invite onboarding and dynamic ticket fields

This commit is contained in:
esdrasrenan 2025-10-05 21:47:28 -03:00
parent 29a647f6c6
commit f24a7f68ca
34 changed files with 2240 additions and 97 deletions

View file

@ -1,4 +1,5 @@
import { mutation, query } from "./_generated/server";
import type { MutationCtx } from "./_generated/server";
import { ConvexError, v } from "convex/values";
import { Id, type Doc } from "./_generated/dataModel";
@ -37,6 +38,136 @@ function normalizeTeams(teams?: string[] | null): string[] {
return teams.map((team) => renameQueueString(team) ?? team);
}
type CustomFieldInput = {
fieldId: Id<"ticketFields">;
value: unknown;
};
type NormalizedCustomField = {
fieldId: Id<"ticketFields">;
fieldKey: string;
label: string;
type: string;
value: unknown;
displayValue?: string;
};
function coerceCustomFieldValue(field: Doc<"ticketFields">, raw: unknown): { value: unknown; displayValue?: string } {
switch (field.type) {
case "text":
return { value: String(raw).trim() };
case "number": {
const value = typeof raw === "number" ? raw : Number(String(raw).replace(",", "."));
if (!Number.isFinite(value)) {
throw new ConvexError(`Valor numérico inválido para o campo ${field.label}`);
}
return { value };
}
case "date": {
if (typeof raw === "number") {
if (!Number.isFinite(raw)) {
throw new ConvexError(`Data inválida para o campo ${field.label}`);
}
return { value: raw };
}
const parsed = Date.parse(String(raw));
if (!Number.isFinite(parsed)) {
throw new ConvexError(`Data inválida para o campo ${field.label}`);
}
return { value: parsed };
}
case "boolean": {
if (typeof raw === "boolean") {
return { value: raw };
}
if (typeof raw === "string") {
const normalized = raw.toLowerCase();
if (normalized === "true" || normalized === "1") return { value: true };
if (normalized === "false" || normalized === "0") return { value: false };
}
throw new ConvexError(`Valor inválido para o campo ${field.label}`);
}
case "select": {
if (!field.options || field.options.length === 0) {
throw new ConvexError(`Campo ${field.label} sem opções configuradas`);
}
const value = String(raw);
const option = field.options.find((opt) => opt.value === value);
if (!option) {
throw new ConvexError(`Seleção inválida para o campo ${field.label}`);
}
return { value: option.value, displayValue: option.label ?? option.value };
}
default:
return { value: raw };
}
}
async function normalizeCustomFieldValues(
ctx: Pick<MutationCtx, "db">,
tenantId: string,
inputs: CustomFieldInput[] | undefined
): Promise<NormalizedCustomField[]> {
const definitions = await ctx.db
.query("ticketFields")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.collect();
if (!definitions.length) {
if (inputs && inputs.length > 0) {
throw new ConvexError("Nenhum campo personalizado configurado para este tenant");
}
return [];
}
const provided = new Map<Id<"ticketFields">, unknown>();
for (const entry of inputs ?? []) {
provided.set(entry.fieldId, entry.value);
}
const normalized: NormalizedCustomField[] = [];
for (const definition of definitions.sort((a, b) => a.order - b.order)) {
const raw = provided.has(definition._id) ? provided.get(definition._id) : undefined;
const isMissing =
raw === undefined ||
raw === null ||
(typeof raw === "string" && raw.trim().length === 0);
if (isMissing) {
if (definition.required) {
throw new ConvexError(`Preencha o campo obrigatório: ${definition.label}`);
}
continue;
}
const { value, displayValue } = coerceCustomFieldValue(definition, raw);
normalized.push({
fieldId: definition._id,
fieldKey: definition.key,
label: definition.label,
type: definition.type,
value,
displayValue,
});
}
return normalized;
}
function mapCustomFieldsToRecord(entries: NormalizedCustomField[] | undefined) {
if (!entries || entries.length === 0) return {};
return entries.reduce<Record<string, { label: string; type: string; value: unknown; displayValue?: string }>>((acc, entry) => {
acc[entry.fieldKey] = {
label: entry.label,
type: entry.type,
value: entry.value,
displayValue: entry.displayValue,
};
return acc;
}, {});
}
export const list = query({
args: {
viewerId: v.optional(v.id("users")),
@ -199,6 +330,10 @@ export const getById = query({
.withIndex("by_ticket", (q) => q.eq("ticketId", id))
.collect();
const customFieldsRecord = mapCustomFieldsToRecord(
(t.customFields as NormalizedCustomField[] | undefined) ?? undefined
);
const commentsHydrated = await Promise.all(
comments.map(async (c) => {
const author = (await ctx.db.get(c.authorId)) as Doc<"users"> | null;
@ -290,7 +425,7 @@ export const getById = query({
: null,
},
description: undefined,
customFields: {},
customFields: customFieldsRecord,
timeline: timeline.map((ev) => {
let payload = ev.payload;
if (ev.type === "QUEUE_CHANGED" && payload && typeof payload === "object" && "queueName" in payload) {
@ -323,6 +458,14 @@ export const create = mutation({
requesterId: v.id("users"),
categoryId: v.id("ticketCategories"),
subcategoryId: v.id("ticketSubcategories"),
customFields: v.optional(
v.array(
v.object({
fieldId: v.id("ticketFields"),
value: v.any(),
})
)
),
},
handler: async (ctx, args) => {
const { role } = await requireUser(ctx, args.actorId, args.tenantId)
@ -342,6 +485,8 @@ export const create = mutation({
if (!subcategory || subcategory.categoryId !== args.categoryId || subcategory.tenantId !== args.tenantId) {
throw new ConvexError("Subcategoria inválida");
}
const normalizedCustomFields = await normalizeCustomFieldValues(ctx, args.tenantId, args.customFields ?? undefined);
// compute next reference (simple monotonic counter per tenant)
const existing = await ctx.db
.query("tickets")
@ -374,6 +519,7 @@ export const create = mutation({
tags: [],
slaPolicyId: undefined,
dueAt: undefined,
customFields: normalizedCustomFields.length ? normalizedCustomFields : undefined,
});
const requester = await ctx.db.get(args.requesterId);
await ctx.db.insert("ticketEvents", {