feat: surface ticket work metrics and refresh list layout

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
esdrasrenan 2025-10-04 22:22:02 -03:00
parent 744d5933d4
commit 55511f3a0e
20 changed files with 1102 additions and 357 deletions

View file

@ -1,5 +1,5 @@
import { internalMutation, mutation, query } from "./_generated/server";
import { v } from "convex/values";
import { ConvexError, v } from "convex/values";
import { Id, type Doc } from "./_generated/dataModel";
const PRIORITY_ORDER = ["URGENT", "HIGH", "MEDIUM", "LOW"] as const;
@ -53,6 +53,7 @@ export const list = query({
const requester = (await ctx.db.get(t.requesterId)) as Doc<"users"> | null;
const assignee = t.assigneeId ? ((await ctx.db.get(t.assigneeId)) as Doc<"users"> | null) : null;
const queue = t.queueId ? ((await ctx.db.get(t.queueId)) as Doc<"queues"> | null) : null;
const activeSession = t.activeSessionId ? await ctx.db.get(t.activeSessionId) : null;
return {
id: t._id,
reference: t.reference,
@ -88,6 +89,16 @@ export const list = query({
tags: t.tags ?? [],
lastTimelineEntry: null,
metrics: null,
workSummary: {
totalWorkedMs: t.totalWorkedMs ?? 0,
activeSession: activeSession
? {
id: activeSession._id,
agentId: activeSession.agentId,
startedAt: activeSession.startedAt,
}
: null,
},
};
})
);
@ -121,6 +132,7 @@ export const getById = query({
id: att.storageId,
name: att.name,
size: att.size,
type: att.type,
url: await ctx.storage.getUrl(att.storageId),
}))
);
@ -142,6 +154,8 @@ export const getById = query({
})
);
const activeSession = t.activeSessionId ? await ctx.db.get(t.activeSessionId) : null;
return {
id: t._id,
reference: t.reference,
@ -177,6 +191,16 @@ export const getById = query({
tags: t.tags ?? [],
lastTimelineEntry: null,
metrics: null,
workSummary: {
totalWorkedMs: t.totalWorkedMs ?? 0,
activeSession: activeSession
? {
id: activeSession._id,
agentId: activeSession.agentId,
startedAt: activeSession.startedAt,
}
: null,
},
description: undefined,
customFields: {},
timeline: timeline.map((ev) => ({
@ -201,6 +225,10 @@ export const create = mutation({
requesterId: v.id("users"),
},
handler: async (ctx, args) => {
const subject = args.subject.trim();
if (subject.length < 3) {
throw new ConvexError("Informe um assunto com pelo menos 3 caracteres");
}
// compute next reference (simple monotonic counter per tenant)
const existing = await ctx.db
.query("tickets")
@ -212,8 +240,8 @@ export const create = mutation({
const id = await ctx.db.insert("tickets", {
tenantId: args.tenantId,
reference: nextRef,
subject: args.subject,
summary: args.summary,
subject,
summary: args.summary?.trim() || undefined,
status: "NEW",
priority: args.priority,
channel: args.channel,
@ -221,6 +249,8 @@ export const create = mutation({
requesterId: args.requesterId,
assigneeId: undefined,
working: false,
activeSessionId: undefined,
totalWorkedMs: 0,
createdAt: now,
updatedAt: now,
firstResponseAt: undefined,
@ -282,6 +312,51 @@ export const addComment = mutation({
},
});
export const removeCommentAttachment = mutation({
args: {
ticketId: v.id("tickets"),
commentId: v.id("ticketComments"),
attachmentId: v.id("_storage"),
actorId: v.id("users"),
},
handler: async (ctx, { ticketId, commentId, attachmentId, actorId }) => {
const comment = await ctx.db.get(commentId);
if (!comment || comment.ticketId !== ticketId) {
throw new ConvexError("Comentário não encontrado");
}
const attachments = comment.attachments ?? [];
const target = attachments.find((att) => att.storageId === attachmentId);
if (!target) {
throw new ConvexError("Anexo não encontrado");
}
await ctx.storage.delete(attachmentId);
const now = Date.now();
await ctx.db.patch(commentId, {
attachments: attachments.filter((att) => att.storageId !== attachmentId),
updatedAt: now,
});
const actor = (await ctx.db.get(actorId)) as Doc<"users"> | null;
await ctx.db.insert("ticketEvents", {
ticketId,
type: "ATTACHMENT_REMOVED",
payload: {
attachmentId,
attachmentName: target.name,
actorId,
actorName: actor?.name,
actorAvatar: actor?.avatarUrl,
},
createdAt: now,
});
await ctx.db.patch(ticketId, { updatedAt: now });
},
});
export const updateStatus = mutation({
args: { ticketId: v.id("tickets"), status: v.string(), actorId: v.id("users") },
handler: async (ctx, { ticketId, status, actorId }) => {
@ -334,6 +409,27 @@ export const changeQueue = mutation({
},
});
export const workSummary = query({
args: { ticketId: v.id("tickets") },
handler: async (ctx, { ticketId }) => {
const ticket = await ctx.db.get(ticketId)
if (!ticket) return null
const activeSession = ticket.activeSessionId ? await ctx.db.get(ticket.activeSessionId) : null
return {
ticketId,
totalWorkedMs: ticket.totalWorkedMs ?? 0,
activeSession: activeSession
? {
id: activeSession._id,
agentId: activeSession.agentId,
startedAt: activeSession.startedAt,
}
: null,
}
},
})
export const updatePriority = mutation({
args: { ticketId: v.id("tickets"), priority: v.string(), actorId: v.id("users") },
handler: async (ctx, { ticketId, priority, actorId }) => {
@ -349,22 +445,89 @@ export const updatePriority = mutation({
},
});
export const toggleWork = mutation({
export const startWork = mutation({
args: { ticketId: v.id("tickets"), actorId: v.id("users") },
handler: async (ctx, { ticketId, actorId }) => {
const t = await ctx.db.get(ticketId)
if (!t) return
const ticket = await ctx.db.get(ticketId)
if (!ticket) {
throw new ConvexError("Ticket não encontrado")
}
if (ticket.activeSessionId) {
return { status: "already_started", sessionId: ticket.activeSessionId }
}
const now = Date.now()
const next = !(t.working ?? false)
await ctx.db.patch(ticketId, { working: next, updatedAt: now })
const sessionId = await ctx.db.insert("ticketWorkSessions", {
ticketId,
agentId: actorId,
startedAt: now,
})
await ctx.db.patch(ticketId, {
working: true,
activeSessionId: sessionId,
updatedAt: now,
})
const actor = (await ctx.db.get(actorId)) as Doc<"users"> | null
await ctx.db.insert("ticketEvents", {
ticketId,
type: next ? "WORK_STARTED" : "WORK_PAUSED",
payload: { actorId, actorName: actor?.name, actorAvatar: actor?.avatarUrl },
type: "WORK_STARTED",
payload: { actorId, actorName: actor?.name, actorAvatar: actor?.avatarUrl, sessionId },
createdAt: now,
})
return next
return { status: "started", sessionId, startedAt: now }
},
})
export const pauseWork = 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")
}
if (!ticket.activeSessionId) {
return { status: "already_paused" }
}
const session = await ctx.db.get(ticket.activeSessionId)
if (!session) {
await ctx.db.patch(ticketId, { activeSessionId: undefined, working: false })
return { status: "session_missing" }
}
const now = Date.now()
const durationMs = now - session.startedAt
await ctx.db.patch(ticket.activeSessionId, {
stoppedAt: now,
durationMs,
})
await ctx.db.patch(ticketId, {
working: false,
activeSessionId: undefined,
totalWorkedMs: (ticket.totalWorkedMs ?? 0) + durationMs,
updatedAt: now,
})
const actor = (await ctx.db.get(actorId)) as Doc<"users"> | null
await ctx.db.insert("ticketEvents", {
ticketId,
type: "WORK_PAUSED",
payload: {
actorId,
actorName: actor?.name,
actorAvatar: actor?.avatarUrl,
sessionId: session._id,
sessionDurationMs: durationMs,
},
createdAt: now,
})
return { status: "paused", durationMs }
},
})
@ -374,12 +537,16 @@ export const updateSubject = mutation({
const now = Date.now();
const t = await ctx.db.get(ticketId);
if (!t) return;
await ctx.db.patch(ticketId, { subject, updatedAt: now });
const trimmed = subject.trim();
if (trimmed.length < 3) {
throw new ConvexError("Informe um assunto com pelo menos 3 caracteres");
}
await ctx.db.patch(ticketId, { subject: trimmed, updatedAt: now });
const actor = await ctx.db.get(actorId);
await ctx.db.insert("ticketEvents", {
ticketId,
type: "SUBJECT_CHANGED",
payload: { from: t.subject, to: subject, actorId, actorName: (actor as Doc<"users"> | null)?.name, actorAvatar: (actor as Doc<"users"> | null)?.avatarUrl },
payload: { from: t.subject, to: trimmed, actorId, actorName: (actor as Doc<"users"> | null)?.name, actorAvatar: (actor as Doc<"users"> | null)?.avatarUrl },
createdAt: now,
});
},