feat: enhance tickets portal and admin flows

This commit is contained in:
Esdras Renan 2025-10-07 02:26:09 -03:00
parent 9cdd8763b4
commit c15f0a5b09
67 changed files with 1101 additions and 338 deletions

View file

@ -7,6 +7,38 @@ import { requireCustomer, requireStaff, requireUser } from "./rbac";
const STAFF_ROLES = new Set(["ADMIN", "MANAGER", "AGENT", "COLLABORATOR"]);
const INTERNAL_STAFF_ROLES = new Set(["ADMIN", "AGENT", "COLLABORATOR"]);
const PAUSE_REASON_LABELS: Record<string, string> = {
NO_CONTACT: "Falta de contato",
WAITING_THIRD_PARTY: "Aguardando terceiro",
IN_PROCEDURE: "Em procedimento",
};
type TicketStatusNormalized = "PENDING" | "AWAITING_ATTENDANCE" | "PAUSED" | "RESOLVED" | "CLOSED";
const STATUS_LABELS: Record<TicketStatusNormalized, string> = {
PENDING: "Pendente",
AWAITING_ATTENDANCE: "Aguardando atendimento",
PAUSED: "Pausado",
RESOLVED: "Resolvido",
CLOSED: "Fechado",
};
const LEGACY_STATUS_MAP: Record<string, TicketStatusNormalized> = {
NEW: "PENDING",
PENDING: "PENDING",
OPEN: "AWAITING_ATTENDANCE",
AWAITING_ATTENDANCE: "AWAITING_ATTENDANCE",
ON_HOLD: "PAUSED",
PAUSED: "PAUSED",
RESOLVED: "RESOLVED",
CLOSED: "CLOSED",
};
function normalizeStatus(status: string | null | undefined): TicketStatusNormalized {
if (!status) return "PENDING";
const normalized = LEGACY_STATUS_MAP[status.toUpperCase()];
return normalized ?? "PENDING";
}
async function ensureManagerTicketAccess(
ctx: MutationCtx | QueryCtx,
@ -232,11 +264,6 @@ export const list = query({
.query("tickets")
.withIndex("by_tenant_company", (q) => q.eq("tenantId", args.tenantId).eq("companyId", user.companyId!))
.collect();
} else if (args.status) {
base = await ctx.db
.query("tickets")
.withIndex("by_tenant_status", (q) => q.eq("tenantId", args.tenantId).eq("status", args.status!))
.collect();
} else if (args.queueId) {
base = await ctx.db
.query("tickets")
@ -258,9 +285,13 @@ export const list = query({
}
filtered = filtered.filter((t) => t.companyId === user.companyId)
}
const normalizedStatusFilter = args.status ? normalizeStatus(args.status) : null;
if (args.priority) filtered = filtered.filter((t) => t.priority === args.priority);
if (args.channel) filtered = filtered.filter((t) => t.channel === args.channel);
if (normalizedStatusFilter) {
filtered = filtered.filter((t) => normalizeStatus(t.status) === normalizedStatusFilter);
}
if (args.search) {
const term = args.search.toLowerCase();
filtered = filtered.filter(
@ -307,7 +338,7 @@ export const list = query({
tenantId: t.tenantId,
subject: t.subject,
summary: t.summary,
status: t.status,
status: normalizeStatus(t.status),
priority: t.priority,
channel: t.channel,
queue: queueName,
@ -428,7 +459,7 @@ export const getById = query({
tenantId: t.tenantId,
subject: t.subject,
summary: t.summary,
status: t.status,
status: normalizeStatus(t.status),
priority: t.priority,
channel: t.channel,
queue: queueName,
@ -588,12 +619,13 @@ export const create = mutation({
.take(1);
const nextRef = existing[0]?.reference ? existing[0].reference + 1 : 41000;
const now = Date.now();
const initialStatus: TicketStatusNormalized = initialAssigneeId ? "AWAITING_ATTENDANCE" : "PENDING";
const id = await ctx.db.insert("tickets", {
tenantId: args.tenantId,
reference: nextRef,
subject,
summary: args.summary?.trim() || undefined,
status: "NEW",
status: initialStatus,
priority: args.priority,
channel: args.channel,
queueId: args.queueId,
@ -847,20 +879,13 @@ export const updateStatus = mutation({
}
const ticketDoc = ticket as Doc<"tickets">
await requireTicketStaff(ctx, actorId, ticketDoc)
const normalizedStatus = normalizeStatus(status)
const now = Date.now();
await ctx.db.patch(ticketId, { status, updatedAt: now });
const statusPt: Record<string, string> = {
NEW: "Novo",
OPEN: "Aberto",
PENDING: "Pendente",
ON_HOLD: "Em espera",
RESOLVED: "Resolvido",
CLOSED: "Fechado",
} as const;
await ctx.db.patch(ticketId, { status: normalizedStatus, updatedAt: now });
await ctx.db.insert("ticketEvents", {
ticketId,
type: "STATUS_CHANGED",
payload: { to: status, toLabel: statusPt[status] ?? status, actorId },
payload: { to: normalizedStatus, toLabel: STATUS_LABELS[normalizedStatus], actorId },
createdAt: now,
});
},
@ -1095,8 +1120,13 @@ export const startWork = mutation({
})
export const pauseWork = mutation({
args: { ticketId: v.id("tickets"), actorId: v.id("users") },
handler: async (ctx, { ticketId, actorId }) => {
args: {
ticketId: v.id("tickets"),
actorId: v.id("users"),
reason: v.string(),
note: v.optional(v.string()),
},
handler: async (ctx, { ticketId, actorId, reason, note }) => {
const ticket = await ctx.db.get(ticketId)
if (!ticket) {
throw new ConvexError("Ticket não encontrado")
@ -1106,6 +1136,10 @@ export const pauseWork = mutation({
return { status: "already_paused" }
}
if (!PAUSE_REASON_LABELS[reason]) {
throw new ConvexError("Motivo de pausa inválido")
}
const session = await ctx.db.get(ticket.activeSessionId)
if (!session) {
await ctx.db.patch(ticketId, { activeSessionId: undefined, working: false })
@ -1118,6 +1152,8 @@ export const pauseWork = mutation({
await ctx.db.patch(ticket.activeSessionId, {
stoppedAt: now,
durationMs,
pauseReason: reason,
pauseNote: note ?? "",
})
await ctx.db.patch(ticketId, {
@ -1137,11 +1173,19 @@ export const pauseWork = mutation({
actorAvatar: actor?.avatarUrl,
sessionId: session._id,
sessionDurationMs: durationMs,
pauseReason: reason,
pauseReasonLabel: PAUSE_REASON_LABELS[reason],
pauseNote: note ?? "",
},
createdAt: now,
})
return { status: "paused", durationMs }
return {
status: "paused",
durationMs,
pauseReason: reason,
pauseNote: note ?? "",
}
},
})
@ -1228,7 +1272,10 @@ export const playNext = mutation({
const chosen = candidates[0];
const now = Date.now();
await ctx.db.patch(chosen._id, { assigneeId: agentId, status: chosen.status === "NEW" ? "OPEN" : chosen.status, updatedAt: now });
const currentStatus = normalizeStatus(chosen.status);
const nextStatus: TicketStatusNormalized =
currentStatus === "PENDING" ? "AWAITING_ATTENDANCE" : currentStatus;
await ctx.db.patch(chosen._id, { assigneeId: agentId, status: nextStatus, updatedAt: now });
await ctx.db.insert("ticketEvents", {
ticketId: chosen._id,
type: "ASSIGNEE_CHANGED",
@ -1247,7 +1294,7 @@ export const playNext = mutation({
tenantId: chosen.tenantId,
subject: chosen.subject,
summary: chosen.summary,
status: chosen.status,
status: nextStatus,
priority: chosen.priority,
channel: chosen.channel,
queue: queueName,