chore: reorganize project structure and ensure default queues

This commit is contained in:
Esdras Renan 2025-10-06 22:59:35 -03:00
parent 854887f499
commit 1cccb852a5
201 changed files with 417 additions and 838 deletions

360
convex/reports.ts Normal file
View file

@ -0,0 +1,360 @@
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";
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(["NEW", "OPEN", "PENDING", "ON_HOLD"]);
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<T>(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<ReturnType<typeof requireStaff>>,
) {
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<CsatSurvey[]> {
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") },
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(ticket.status));
const resolvedTickets = tickets.filter((ticket) => ticket.status === "RESOLVED" || ticket.status === "CLOSED");
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") },
handler: async (ctx, { tenantId, viewerId }) => {
const viewer = await requireStaff(ctx, viewerId, tenantId);
const tickets = await fetchScopedTickets(ctx, tenantId, viewer);
const surveys = await collectCsatSurveys(ctx, tickets);
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,
})),
};
},
});
export const backlogOverview = 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 statusCounts = tickets.reduce<Record<string, number>>((acc, ticket) => {
acc[ticket.status] = (acc[ticket.status] ?? 0) + 1;
return acc;
}, {});
const priorityCounts = tickets.reduce<Record<string, number>>((acc, ticket) => {
acc[ticket.priority] = (acc[ticket.priority] ?? 0) + 1;
return acc;
}, {});
const openTickets = tickets.filter((ticket) => OPEN_STATUSES.has(ticket.status));
const queueMap = new Map<string, { name: string; count: number }>();
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 {
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(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<string, Map<string, number>>();
for (let ts = startMs; ts < endMs; ts += ONE_DAY_MS) {
timeline.set(formatDateKey(ts), new Map());
}
const channels = new Set<string>();
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<string, number>();
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<string, number> = {};
for (const channel of sortedChannels) {
values[channel] = map.get(channel) ?? 0;
}
return { date, values };
});
return {
rangeDays: days,
channels: sortedChannels,
points,
};
},
});