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:
parent
744d5933d4
commit
55511f3a0e
20 changed files with 1102 additions and 357 deletions
|
|
@ -57,6 +57,8 @@ export default defineSchema({
|
|||
updatedAt: v.number(),
|
||||
createdAt: v.number(),
|
||||
tags: v.optional(v.array(v.string())),
|
||||
totalWorkedMs: v.optional(v.number()),
|
||||
activeSessionId: v.optional(v.id("ticketWorkSessions")),
|
||||
})
|
||||
.index("by_tenant_status", ["tenantId", "status"])
|
||||
.index("by_tenant_queue", ["tenantId", "queueId"])
|
||||
|
|
@ -89,4 +91,14 @@ export default defineSchema({
|
|||
payload: v.optional(v.any()),
|
||||
createdAt: v.number(),
|
||||
}).index("by_ticket", ["ticketId"]),
|
||||
|
||||
ticketWorkSessions: defineTable({
|
||||
ticketId: v.id("tickets"),
|
||||
agentId: v.id("users"),
|
||||
startedAt: v.number(),
|
||||
stoppedAt: v.optional(v.number()),
|
||||
durationMs: v.optional(v.number()),
|
||||
})
|
||||
.index("by_ticket", ["ticketId"])
|
||||
.index("by_ticket_agent", ["ticketId", "agentId"]),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue