feat: enforce ticket ownership during work sessions

This commit is contained in:
Esdras Renan 2025-10-20 19:46:20 -03:00
parent 81657e52d8
commit 3972f66c92
6 changed files with 397 additions and 43 deletions

View file

@ -59,6 +59,83 @@ function normalizeStatus(status: string | null | undefined): TicketStatusNormali
return normalized ?? "PENDING";
}
type AgentWorkTotals = {
agentId: Id<"users">;
agentName: string | null;
agentEmail: string | null;
avatarUrl: string | null;
totalWorkedMs: number;
internalWorkedMs: number;
externalWorkedMs: number;
};
async function computeAgentWorkTotals(
ctx: MutationCtx | QueryCtx,
ticketId: Id<"tickets">,
referenceNow: number,
): Promise<AgentWorkTotals[]> {
const sessions = await ctx.db
.query("ticketWorkSessions")
.withIndex("by_ticket", (q) => q.eq("ticketId", ticketId))
.collect();
if (!sessions.length) {
return [];
}
const totals = new Map<
string,
{ totalWorkedMs: number; internalWorkedMs: number; externalWorkedMs: number }
>();
for (const session of sessions) {
const baseDuration = typeof session.durationMs === "number"
? session.durationMs
: typeof session.stoppedAt === "number"
? session.stoppedAt - session.startedAt
: referenceNow - session.startedAt;
const durationMs = Math.max(0, baseDuration);
if (durationMs <= 0) continue;
const key = session.agentId as string;
const bucket = totals.get(key) ?? {
totalWorkedMs: 0,
internalWorkedMs: 0,
externalWorkedMs: 0,
};
bucket.totalWorkedMs += durationMs;
const workType = (session.workType ?? "INTERNAL").toUpperCase();
if (workType === "EXTERNAL") {
bucket.externalWorkedMs += durationMs;
} else {
bucket.internalWorkedMs += durationMs;
}
totals.set(key, bucket);
}
if (totals.size === 0) {
return [];
}
const agentIds = Array.from(totals.keys());
const agents = await Promise.all(agentIds.map((agentId) => ctx.db.get(agentId as Id<"users">)));
return agentIds
.map((agentId, index) => {
const bucket = totals.get(agentId)!;
const agentDoc = agents[index] as Doc<"users"> | null;
return {
agentId: agentId as Id<"users">,
agentName: agentDoc?.name ?? null,
agentEmail: agentDoc?.email ?? null,
avatarUrl: agentDoc?.avatarUrl ?? null,
totalWorkedMs: bucket.totalWorkedMs,
internalWorkedMs: bucket.internalWorkedMs,
externalWorkedMs: bucket.externalWorkedMs,
};
})
.sort((a, b) => b.totalWorkedMs - a.totalWorkedMs);
}
async function ensureManagerTicketAccess(
ctx: MutationCtx | QueryCtx,
manager: Doc<"users">,
@ -744,6 +821,7 @@ export const getById = query({
);
const activeSession = t.activeSessionId ? await ctx.db.get(t.activeSessionId) : null;
const perAgentTotals = await computeAgentWorkTotals(ctx, id, serverNow);
return {
id: t._id,
@ -813,6 +891,15 @@ export const getById = query({
workType: activeSession.workType ?? "INTERNAL",
}
: null,
perAgentTotals: perAgentTotals.map((item) => ({
agentId: item.agentId,
agentName: item.agentName,
agentEmail: item.agentEmail,
avatarUrl: item.avatarUrl,
totalWorkedMs: item.totalWorkedMs,
internalWorkedMs: item.internalWorkedMs,
externalWorkedMs: item.externalWorkedMs,
})),
},
description: undefined,
customFields: customFieldsRecord,
@ -1285,6 +1372,10 @@ export const changeAssignee = mutation({
if (viewer.role === "MANAGER") {
throw new ConvexError("Gestores não podem reatribuir chamados")
}
const normalizedStatus = normalizeStatus(ticketDoc.status)
if (normalizedStatus === "AWAITING_ATTENDANCE" || ticketDoc.activeSessionId) {
throw new ConvexError("Pause o atendimento antes de reatribuir o chamado")
}
const currentAssigneeId = ticketDoc.assigneeId ?? null
if (currentAssigneeId && currentAssigneeId !== actorId && !isAdmin) {
throw new ConvexError("Somente o responsável atual pode reatribuir este chamado")
@ -1438,6 +1529,7 @@ export const workSummary = query({
const activeSession = ticket.activeSessionId ? await ctx.db.get(ticket.activeSessionId) : null
const serverNow = Date.now()
const perAgentTotals = await computeAgentWorkTotals(ctx, ticketId, serverNow)
return {
ticketId,
totalWorkedMs: ticket.totalWorkedMs ?? 0,
@ -1452,6 +1544,15 @@ export const workSummary = query({
workType: activeSession.workType ?? "INTERNAL",
}
: null,
perAgentTotals: perAgentTotals.map((item) => ({
agentId: item.agentId,
agentName: item.agentName,
agentEmail: item.agentEmail,
avatarUrl: item.avatarUrl,
totalWorkedMs: item.totalWorkedMs,
internalWorkedMs: item.internalWorkedMs,
externalWorkedMs: item.externalWorkedMs,
})),
}
},
})