import { query } from "./_generated/server"; import type { QueryCtx } from "./_generated/server"; import { ConvexError, v } from "convex/values"; import type { Doc, Id } from "./_generated/dataModel"; import { requireStaff } from "./rbac"; type TicketStatusNormalized = "PENDING" | "AWAITING_ATTENDANCE" | "PAUSED" | "RESOLVED"; const STATUS_NORMALIZE_MAP: Record = { NEW: "PENDING", PENDING: "PENDING", OPEN: "AWAITING_ATTENDANCE", AWAITING_ATTENDANCE: "AWAITING_ATTENDANCE", ON_HOLD: "PAUSED", PAUSED: "PAUSED", RESOLVED: "RESOLVED", CLOSED: "RESOLVED", }; function normalizeStatus(status: string | null | undefined): TicketStatusNormalized { if (!status) return "PENDING"; const normalized = STATUS_NORMALIZE_MAP[status.toUpperCase()]; return normalized ?? "PENDING"; } function average(values: number[]) { if (values.length === 0) return null; return values.reduce((sum, value) => sum + value, 0) / values.length; } const OPEN_STATUSES = new Set(["PENDING", "AWAITING_ATTENDANCE", "PAUSED"]); const ONE_DAY_MS = 24 * 60 * 60 * 1000; function percentageChange(current: number, previous: number) { if (previous === 0) { return current === 0 ? 0 : null; } return ((current - previous) / previous) * 100; } function extractScore(payload: unknown): number | null { if (typeof payload === "number") return payload; if (payload && typeof payload === "object" && "score" in payload) { const value = (payload as { score: unknown }).score; if (typeof value === "number") { return value; } } return null; } function isNotNull(value: T | null): value is T { return value !== null; } async function fetchTickets(ctx: QueryCtx, tenantId: string) { return ctx.db .query("tickets") .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) .collect(); } async function fetchScopedTickets( ctx: QueryCtx, tenantId: string, viewer: Awaited>, ) { if (viewer.role === "MANAGER") { if (!viewer.user.companyId) { throw new ConvexError("Gestor não possui empresa vinculada"); } return ctx.db .query("tickets") .withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId).eq("companyId", viewer.user.companyId!) ) .collect(); } return fetchTickets(ctx, tenantId); } async function fetchQueues(ctx: QueryCtx, tenantId: string) { return ctx.db .query("queues") .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) .collect(); } type CsatSurvey = { ticketId: Id<"tickets">; reference: number; score: number; receivedAt: number; }; async function collectCsatSurveys(ctx: QueryCtx, tickets: Doc<"tickets">[]): Promise { const perTicket = await Promise.all( tickets.map(async (ticket) => { const events = await ctx.db .query("ticketEvents") .withIndex("by_ticket", (q) => q.eq("ticketId", ticket._id)) .collect(); return events .filter((event) => event.type === "CSAT_RECEIVED" || event.type === "CSAT_RATED") .map((event) => { const score = extractScore(event.payload); if (score === null) return null; return { ticketId: ticket._id, reference: ticket.reference, score, receivedAt: event.createdAt, } as CsatSurvey; }) .filter(isNotNull); }) ); return perTicket.flat(); } function formatDateKey(timestamp: number) { const date = new Date(timestamp); const year = date.getUTCFullYear(); const month = `${date.getUTCMonth() + 1}`.padStart(2, "0"); const day = `${date.getUTCDate()}`.padStart(2, "0"); return `${year}-${month}-${day}`; } export const slaOverview = query({ args: { tenantId: v.string(), viewerId: v.id("users"), range: v.optional(v.string()) }, handler: async (ctx, { tenantId, viewerId }) => { const viewer = await requireStaff(ctx, viewerId, tenantId); const tickets = await fetchScopedTickets(ctx, tenantId, viewer); const queues = await fetchQueues(ctx, tenantId); const now = Date.now(); const openTickets = tickets.filter((ticket) => OPEN_STATUSES.has(normalizeStatus(ticket.status))); const resolvedTickets = tickets.filter((ticket) => { const status = normalizeStatus(ticket.status); return status === "RESOLVED"; }); const overdueTickets = openTickets.filter((ticket) => ticket.dueAt && ticket.dueAt < now); const firstResponseTimes = tickets .filter((ticket) => ticket.firstResponseAt) .map((ticket) => (ticket.firstResponseAt! - ticket.createdAt) / 60000); const resolutionTimes = resolvedTickets .filter((ticket) => ticket.resolvedAt) .map((ticket) => (ticket.resolvedAt! - ticket.createdAt) / 60000); const queueBreakdown = queues.map((queue) => { const count = openTickets.filter((ticket) => ticket.queueId === queue._id).length; return { id: queue._id, name: queue.name, open: count, }; }); return { totals: { total: tickets.length, open: openTickets.length, resolved: resolvedTickets.length, overdue: overdueTickets.length, }, response: { averageFirstResponseMinutes: average(firstResponseTimes), responsesRegistered: firstResponseTimes.length, }, resolution: { averageResolutionMinutes: average(resolutionTimes), resolvedCount: resolutionTimes.length, }, queueBreakdown, }; }, }); export const csatOverview = query({ args: { tenantId: v.string(), viewerId: v.id("users"), range: v.optional(v.string()) }, handler: async (ctx, { tenantId, viewerId, range }) => { const viewer = await requireStaff(ctx, viewerId, tenantId); const tickets = await fetchScopedTickets(ctx, tenantId, viewer); const surveysAll = await collectCsatSurveys(ctx, tickets); const days = range === "7d" ? 7 : range === "30d" ? 30 : 90; const end = new Date(); end.setUTCHours(0, 0, 0, 0); const endMs = end.getTime() + ONE_DAY_MS; const startMs = endMs - days * ONE_DAY_MS; const surveys = surveysAll.filter((s) => s.receivedAt >= startMs && s.receivedAt < endMs); const averageScore = average(surveys.map((item) => item.score)); const distribution = [1, 2, 3, 4, 5].map((score) => ({ score, total: surveys.filter((item) => item.score === score).length, })); return { totalSurveys: surveys.length, averageScore, distribution, recent: surveys .slice() .sort((a, b) => b.receivedAt - a.receivedAt) .slice(0, 10) .map((item) => ({ ticketId: item.ticketId, reference: item.reference, score: item.score, receivedAt: item.receivedAt, })), rangeDays: days, }; }, }); export const backlogOverview = query({ args: { tenantId: v.string(), viewerId: v.id("users"), range: v.optional(v.string()) }, handler: async (ctx, { tenantId, viewerId, range }) => { const viewer = await requireStaff(ctx, viewerId, tenantId); const tickets = await fetchScopedTickets(ctx, tenantId, viewer); // Optional range filter (createdAt) for reporting purposes const days = range === "7d" ? 7 : range === "30d" ? 30 : 90; const end = new Date(); end.setUTCHours(0, 0, 0, 0); const endMs = end.getTime() + ONE_DAY_MS; const startMs = endMs - days * ONE_DAY_MS; const inRange = tickets.filter((t) => t.createdAt >= startMs && t.createdAt < endMs); const statusCounts = inRange.reduce>((acc, ticket) => { const status = normalizeStatus(ticket.status); acc[status] = (acc[status] ?? 0) + 1; return acc; }, {} as Record); const priorityCounts = inRange.reduce>((acc, ticket) => { acc[ticket.priority] = (acc[ticket.priority] ?? 0) + 1; return acc; }, {}); const openTickets = inRange.filter((ticket) => OPEN_STATUSES.has(normalizeStatus(ticket.status))); const queueMap = new Map(); for (const ticket of openTickets) { const queueId = ticket.queueId ? ticket.queueId : "sem-fila"; const current = queueMap.get(queueId) ?? { name: queueId === "sem-fila" ? "Sem fila" : "", count: 0 }; current.count += 1; queueMap.set(queueId, current); } const queues = await fetchQueues(ctx, tenantId); for (const queue of queues) { const entry = queueMap.get(queue._id) ?? { name: queue.name, count: 0 }; entry.name = queue.name; queueMap.set(queue._id, entry); } return { rangeDays: days, statusCounts, priorityCounts, queueCounts: Array.from(queueMap.entries()).map(([id, data]) => ({ id, name: data.name, total: data.count, })), totalOpen: openTickets.length, }; }, }); export const dashboardOverview = query({ args: { tenantId: v.string(), viewerId: v.id("users") }, handler: async (ctx, { tenantId, viewerId }) => { const viewer = await requireStaff(ctx, viewerId, tenantId); const tickets = await fetchScopedTickets(ctx, tenantId, viewer); const now = Date.now(); const lastDayStart = now - ONE_DAY_MS; const previousDayStart = now - 2 * ONE_DAY_MS; const newTickets = tickets.filter((ticket) => ticket.createdAt >= lastDayStart); const previousTickets = tickets.filter( (ticket) => ticket.createdAt >= previousDayStart && ticket.createdAt < lastDayStart ); const trend = percentageChange(newTickets.length, previousTickets.length); const lastWindowStart = now - 7 * ONE_DAY_MS; const previousWindowStart = now - 14 * ONE_DAY_MS; const firstResponseWindow = tickets .filter( (ticket) => ticket.createdAt >= lastWindowStart && ticket.createdAt < now && ticket.firstResponseAt ) .map((ticket) => (ticket.firstResponseAt! - ticket.createdAt) / 60000); const firstResponsePrevious = tickets .filter( (ticket) => ticket.createdAt >= previousWindowStart && ticket.createdAt < lastWindowStart && ticket.firstResponseAt ) .map((ticket) => (ticket.firstResponseAt! - ticket.createdAt) / 60000); const averageWindow = average(firstResponseWindow); const averagePrevious = average(firstResponsePrevious); const deltaMinutes = averageWindow !== null && averagePrevious !== null ? averageWindow - averagePrevious : null; const awaitingTickets = tickets.filter((ticket) => OPEN_STATUSES.has(normalizeStatus(ticket.status))); const atRiskTickets = awaitingTickets.filter((ticket) => ticket.dueAt && ticket.dueAt < now); const surveys = await collectCsatSurveys(ctx, tickets); const averageScore = average(surveys.map((item) => item.score)); return { newTickets: { last24h: newTickets.length, previous24h: previousTickets.length, trendPercentage: trend, }, firstResponse: { averageMinutes: averageWindow, previousAverageMinutes: averagePrevious, deltaMinutes, responsesCount: firstResponseWindow.length, }, awaitingAction: { total: awaitingTickets.length, atRisk: atRiskTickets.length, }, csat: { averageScore, totalSurveys: surveys.length, }, }; }, }); export const ticketsByChannel = query({ args: { tenantId: v.string(), viewerId: v.id("users"), range: v.optional(v.string()), }, handler: async (ctx, { tenantId, viewerId, range }) => { const viewer = await requireStaff(ctx, viewerId, tenantId); const tickets = await fetchScopedTickets(ctx, tenantId, viewer); const days = range === "7d" ? 7 : range === "30d" ? 30 : 90; const end = new Date(); end.setUTCHours(0, 0, 0, 0); const endMs = end.getTime() + ONE_DAY_MS; const startMs = endMs - days * ONE_DAY_MS; const timeline = new Map>(); for (let ts = startMs; ts < endMs; ts += ONE_DAY_MS) { timeline.set(formatDateKey(ts), new Map()); } const channels = new Set(); for (const ticket of tickets) { if (ticket.createdAt < startMs || ticket.createdAt >= endMs) continue; const dateKey = formatDateKey(ticket.createdAt); const channelKey = ticket.channel ?? "OUTRO"; channels.add(channelKey); const dayMap = timeline.get(dateKey) ?? new Map(); dayMap.set(channelKey, (dayMap.get(channelKey) ?? 0) + 1); timeline.set(dateKey, dayMap); } const sortedChannels = Array.from(channels).sort(); const points = Array.from(timeline.entries()) .sort((a, b) => a[0].localeCompare(b[0])) .map(([date, map]) => { const values: Record = {}; for (const channel of sortedChannels) { values[channel] = map.get(channel) ?? 0; } return { date, values }; }); return { rangeDays: days, channels: sortedChannels, points, }; }, }); export const hoursByClient = query({ args: { tenantId: v.string(), viewerId: v.id("users"), range: v.optional(v.string()) }, handler: async (ctx, { tenantId, viewerId, range }) => { const viewer = await requireStaff(ctx, viewerId, tenantId) const tickets = await fetchScopedTickets(ctx, tenantId, viewer) const days = range === "7d" ? 7 : range === "30d" ? 30 : 90 const end = new Date() end.setUTCHours(0, 0, 0, 0) const endMs = end.getTime() + ONE_DAY_MS const startMs = endMs - days * ONE_DAY_MS // Accumulate by company type Acc = { companyId: string name: string isAvulso: boolean internalMs: number externalMs: number totalMs: number contractedHoursPerMonth?: number | null } const map = new Map() for (const t of tickets) { // only consider tickets updated in range as a proxy for recent work if (t.updatedAt < startMs || t.updatedAt >= endMs) continue const companyId = (t as any).companyId ?? null if (!companyId) continue let acc = map.get(companyId) if (!acc) { const company = await ctx.db.get(companyId) acc = { companyId, name: (company as any)?.name ?? "Sem empresa", isAvulso: Boolean((company as any)?.isAvulso ?? false), internalMs: 0, externalMs: 0, totalMs: 0, contractedHoursPerMonth: (company as any)?.contractedHoursPerMonth ?? null, } map.set(companyId, acc) } const internal = ((t as any).internalWorkedMs ?? 0) as number const external = ((t as any).externalWorkedMs ?? 0) as number acc.internalMs += internal acc.externalMs += external acc.totalMs += internal + external } const items = Array.from(map.values()).sort((a, b) => b.totalMs - a.totalMs) return { rangeDays: days, items: items.map((i) => ({ companyId: i.companyId, name: i.name, isAvulso: i.isAvulso, internalMs: i.internalMs, externalMs: i.externalMs, totalMs: i.totalMs, contractedHoursPerMonth: i.contractedHoursPerMonth ?? null, })), } }, })