feat: implement invite onboarding and dynamic ticket fields
This commit is contained in:
parent
29a647f6c6
commit
f24a7f68ca
34 changed files with 2240 additions and 97 deletions
|
|
@ -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", {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue