feat: checklists em tickets + automações

- Adiciona checklist no ticket (itens obrigatórios/opcionais) e bloqueia encerramento com pendências\n- Cria templates de checklist (globais/por empresa) + tela em /settings/checklists\n- Nova ação de automação: aplicar template de checklist\n- Corrige crash do Select (value vazio), warnings de Dialog e dimensionamento de charts\n- Ajusta SMTP (STARTTLS) e melhora teste de integração
This commit is contained in:
esdrasrenan 2025-12-13 20:51:47 -03:00
parent 4306b0504d
commit 88a9ef454e
27 changed files with 2685 additions and 226 deletions

View file

@ -19,6 +19,12 @@ import {
getTemplateByKey,
normalizeFormTemplateKey,
} from "./ticketFormTemplates";
import {
applyChecklistTemplateToItems,
checklistBlocksResolution,
normalizeChecklistText,
type TicketChecklistItem,
} from "./ticketChecklist";
const STAFF_ROLES = new Set(["ADMIN", "MANAGER", "AGENT"]);
const INTERNAL_STAFF_ROLES = new Set(["ADMIN", "AGENT"]);
@ -2088,6 +2094,20 @@ export const getById = query({
updatedAt: t.updatedAt,
createdAt: t.createdAt,
tags: t.tags ?? [],
checklist: Array.isArray(t.checklist)
? t.checklist.map((item) => ({
id: item.id,
text: item.text,
done: item.done,
required: typeof item.required === "boolean" ? item.required : true,
templateId: item.templateId ? String(item.templateId) : undefined,
templateItemId: item.templateItemId ?? undefined,
createdAt: item.createdAt ?? undefined,
createdBy: item.createdBy ? String(item.createdBy) : undefined,
doneAt: item.doneAt ?? undefined,
doneBy: item.doneBy ? String(item.doneBy) : undefined,
}))
: [],
lastTimelineEntry: null,
metrics: null,
csatScore: typeof t.csatScore === "number" ? t.csatScore : null,
@ -2177,6 +2197,15 @@ export const create = mutation({
categoryId: v.id("ticketCategories"),
subcategoryId: v.id("ticketSubcategories"),
machineId: v.optional(v.id("machines")),
checklist: v.optional(
v.array(
v.object({
text: v.string(),
required: v.optional(v.boolean()),
})
)
),
checklistTemplateIds: v.optional(v.array(v.id("ticketChecklistTemplates"))),
customFields: v.optional(
v.array(
v.object({
@ -2285,6 +2314,23 @@ export const create = mutation({
const nextRef = existing[0]?.reference ? existing[0].reference + 1 : 41000;
const now = Date.now();
const initialStatus: TicketStatusNormalized = "PENDING";
const manualChecklist: TicketChecklistItem[] = (args.checklist ?? []).map((entry) => {
const text = normalizeChecklistText(entry.text ?? "");
if (!text) {
throw new ConvexError("Item do checklist inválido.")
}
if (text.length > 240) {
throw new ConvexError("Item do checklist muito longo (máx. 240 caracteres).")
}
return {
id: crypto.randomUUID(),
text,
done: false,
required: typeof entry.required === "boolean" ? entry.required : true,
createdAt: now,
createdBy: args.actorId,
}
})
const requesterSnapshot = {
name: requester.name,
email: requester.email,
@ -2302,6 +2348,19 @@ export const create = mutation({
const companySnapshot = companyDoc
? { name: companyDoc.name, slug: companyDoc.slug, isAvulso: companyDoc.isAvulso ?? undefined }
: undefined
const resolvedCompanyId = companyDoc?._id ?? requester.companyId ?? undefined
let checklist = manualChecklist
for (const templateId of args.checklistTemplateIds ?? []) {
const template = await ctx.db.get(templateId)
if (!template || template.tenantId !== args.tenantId || template.isArchived === true) {
throw new ConvexError("Template de checklist inválido.")
}
if (template.companyId && (!resolvedCompanyId || String(template.companyId) !== String(resolvedCompanyId))) {
throw new ConvexError("Template de checklist não pertence à empresa do ticket.")
}
checklist = applyChecklistTemplateToItems(checklist, template, { now, actorId: args.actorId }).checklist
}
const assigneeSnapshot = initialAssignee
? {
@ -2358,7 +2417,7 @@ export const create = mutation({
requesterSnapshot,
assigneeId: initialAssigneeId,
assigneeSnapshot,
companyId: companyDoc?._id ?? requester.companyId ?? undefined,
companyId: resolvedCompanyId,
companySnapshot,
machineId: machineDoc?._id ?? undefined,
machineSnapshot: machineDoc
@ -2382,6 +2441,7 @@ export const create = mutation({
resolvedAt: undefined,
closedAt: undefined,
tags: [],
checklist: checklist.length > 0 ? checklist : undefined,
slaPolicyId: undefined,
dueAt: visitDueAt && isVisitQueue ? visitDueAt : undefined,
customFields: normalizedCustomFields.length ? normalizedCustomFields : undefined,
@ -2423,6 +2483,259 @@ export const create = mutation({
},
});
function ensureChecklistEditor(viewer: { role: string | null }) {
const normalizedRole = (viewer.role ?? "").toUpperCase();
if (!INTERNAL_STAFF_ROLES.has(normalizedRole)) {
throw new ConvexError("Apenas administradores e agentes podem alterar o checklist.");
}
}
function normalizeTicketChecklist(list: unknown): TicketChecklistItem[] {
if (!Array.isArray(list)) return [];
return list as TicketChecklistItem[];
}
export const addChecklistItem = mutation({
args: {
ticketId: v.id("tickets"),
actorId: v.id("users"),
text: v.string(),
required: v.optional(v.boolean()),
},
handler: async (ctx, { ticketId, actorId, text, required }) => {
const ticket = await ctx.db.get(ticketId);
if (!ticket) {
throw new ConvexError("Ticket não encontrado");
}
const ticketDoc = ticket as Doc<"tickets">;
const viewer = await requireTicketStaff(ctx, actorId, ticketDoc);
ensureChecklistEditor(viewer);
const normalizedText = normalizeChecklistText(text);
if (!normalizedText) {
throw new ConvexError("Informe o texto do item do checklist.");
}
if (normalizedText.length > 240) {
throw new ConvexError("Item do checklist muito longo (máx. 240 caracteres).");
}
const now = Date.now();
const item: TicketChecklistItem = {
id: crypto.randomUUID(),
text: normalizedText,
done: false,
required: typeof required === "boolean" ? required : true,
createdAt: now,
createdBy: actorId,
};
const checklist = normalizeTicketChecklist(ticketDoc.checklist).concat(item);
await ctx.db.patch(ticketId, {
checklist: checklist.length > 0 ? checklist : undefined,
updatedAt: now,
});
return { ok: true, item };
},
});
export const updateChecklistItemText = mutation({
args: {
ticketId: v.id("tickets"),
actorId: v.id("users"),
itemId: v.string(),
text: v.string(),
},
handler: async (ctx, { ticketId, actorId, itemId, text }) => {
const ticket = await ctx.db.get(ticketId);
if (!ticket) {
throw new ConvexError("Ticket não encontrado");
}
const ticketDoc = ticket as Doc<"tickets">;
const viewer = await requireTicketStaff(ctx, actorId, ticketDoc);
ensureChecklistEditor(viewer);
const normalizedText = normalizeChecklistText(text);
if (!normalizedText) {
throw new ConvexError("Informe o texto do item do checklist.");
}
if (normalizedText.length > 240) {
throw new ConvexError("Item do checklist muito longo (máx. 240 caracteres).");
}
const checklist = normalizeTicketChecklist(ticketDoc.checklist);
const index = checklist.findIndex((item) => item.id === itemId);
if (index < 0) {
throw new ConvexError("Item do checklist não encontrado.");
}
const nextChecklist = checklist.map((item) =>
item.id === itemId ? { ...item, text: normalizedText } : item
);
await ctx.db.patch(ticketId, { checklist: nextChecklist, updatedAt: Date.now() });
return { ok: true };
},
});
export const setChecklistItemDone = mutation({
args: {
ticketId: v.id("tickets"),
actorId: v.id("users"),
itemId: v.string(),
done: v.boolean(),
},
handler: async (ctx, { ticketId, actorId, itemId, done }) => {
const ticket = await ctx.db.get(ticketId);
if (!ticket) {
throw new ConvexError("Ticket não encontrado");
}
const ticketDoc = ticket as Doc<"tickets">;
await requireTicketStaff(ctx, actorId, ticketDoc);
const checklist = normalizeTicketChecklist(ticketDoc.checklist);
const index = checklist.findIndex((item) => item.id === itemId);
if (index < 0) {
throw new ConvexError("Item do checklist não encontrado.");
}
const previous = checklist[index]!;
if (previous.done === done) {
return { ok: true };
}
const now = Date.now();
const nextChecklist = checklist.map((item) => {
if (item.id !== itemId) return item;
if (done) {
return { ...item, done: true, doneAt: now, doneBy: actorId };
}
return { ...item, done: false, doneAt: undefined, doneBy: undefined };
});
await ctx.db.patch(ticketId, { checklist: nextChecklist, updatedAt: now });
return { ok: true };
},
});
export const setChecklistItemRequired = mutation({
args: {
ticketId: v.id("tickets"),
actorId: v.id("users"),
itemId: v.string(),
required: v.boolean(),
},
handler: async (ctx, { ticketId, actorId, itemId, required }) => {
const ticket = await ctx.db.get(ticketId);
if (!ticket) {
throw new ConvexError("Ticket não encontrado");
}
const ticketDoc = ticket as Doc<"tickets">;
const viewer = await requireTicketStaff(ctx, actorId, ticketDoc);
ensureChecklistEditor(viewer);
const checklist = normalizeTicketChecklist(ticketDoc.checklist);
const index = checklist.findIndex((item) => item.id === itemId);
if (index < 0) {
throw new ConvexError("Item do checklist não encontrado.");
}
const nextChecklist = checklist.map((item) => (item.id === itemId ? { ...item, required: Boolean(required) } : item));
await ctx.db.patch(ticketId, { checklist: nextChecklist, updatedAt: Date.now() });
return { ok: true };
},
});
export const removeChecklistItem = mutation({
args: {
ticketId: v.id("tickets"),
actorId: v.id("users"),
itemId: v.string(),
},
handler: async (ctx, { ticketId, actorId, itemId }) => {
const ticket = await ctx.db.get(ticketId);
if (!ticket) {
throw new ConvexError("Ticket não encontrado");
}
const ticketDoc = ticket as Doc<"tickets">;
const viewer = await requireTicketStaff(ctx, actorId, ticketDoc);
ensureChecklistEditor(viewer);
const checklist = normalizeTicketChecklist(ticketDoc.checklist);
const nextChecklist = checklist.filter((item) => item.id !== itemId);
await ctx.db.patch(ticketId, {
checklist: nextChecklist.length > 0 ? nextChecklist : undefined,
updatedAt: Date.now(),
});
return { ok: true };
},
});
export const completeAllChecklistItems = mutation({
args: {
ticketId: v.id("tickets"),
actorId: v.id("users"),
},
handler: async (ctx, { ticketId, actorId }) => {
const ticket = await ctx.db.get(ticketId);
if (!ticket) {
throw new ConvexError("Ticket não encontrado");
}
const ticketDoc = ticket as Doc<"tickets">;
const viewer = await requireTicketStaff(ctx, actorId, ticketDoc);
ensureChecklistEditor(viewer);
const checklist = normalizeTicketChecklist(ticketDoc.checklist);
if (checklist.length === 0) return { ok: true };
const now = Date.now();
const nextChecklist = checklist.map((item) => {
if (item.done === true) return item;
return { ...item, done: true, doneAt: now, doneBy: actorId };
});
await ctx.db.patch(ticketId, { checklist: nextChecklist, updatedAt: now });
return { ok: true };
},
});
export const applyChecklistTemplate = mutation({
args: {
ticketId: v.id("tickets"),
actorId: v.id("users"),
templateId: v.id("ticketChecklistTemplates"),
},
handler: async (ctx, { ticketId, actorId, templateId }) => {
const ticket = await ctx.db.get(ticketId);
if (!ticket) {
throw new ConvexError("Ticket não encontrado");
}
const ticketDoc = ticket as Doc<"tickets">;
const viewer = await requireTicketStaff(ctx, actorId, ticketDoc);
ensureChecklistEditor(viewer);
const template = await ctx.db.get(templateId);
if (!template || template.tenantId !== ticketDoc.tenantId || template.isArchived === true) {
throw new ConvexError("Template de checklist inválido.");
}
if (template.companyId && (!ticketDoc.companyId || String(template.companyId) !== String(ticketDoc.companyId))) {
throw new ConvexError("Template de checklist não pertence à empresa do ticket.");
}
const now = Date.now();
const current = normalizeTicketChecklist(ticketDoc.checklist);
const result = applyChecklistTemplateToItems(current, template, { now, actorId });
if (result.added === 0) {
return { ok: true, added: 0 };
}
await ctx.db.patch(ticketId, { checklist: result.checklist, updatedAt: now });
return { ok: true, added: result.added };
},
});
export const addComment = mutation({
args: {
ticketId: v.id("tickets"),
@ -2743,6 +3056,10 @@ export async function resolveTicketHandler(
const viewer = await requireTicketStaff(ctx, actorId, ticketDoc)
const now = Date.now()
if (checklistBlocksResolution((ticketDoc.checklist ?? []) as unknown as TicketChecklistItem[])) {
throw new ConvexError("Conclua todos os itens obrigatórios do checklist antes de encerrar o ticket.")
}
const baseRelated = new Set<string>()
for (const rel of relatedTicketIds ?? []) {
if (String(rel) === String(ticketId)) continue