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:
parent
4306b0504d
commit
88a9ef454e
27 changed files with 2685 additions and 226 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue