feat: dispositivos e ajustes de csat e relatórios

This commit is contained in:
codex-bot 2025-11-03 19:29:50 -03:00
parent 25d2a9b062
commit e0ef66555d
86 changed files with 5811 additions and 992 deletions

View file

@ -51,6 +51,39 @@ function extractScore(payload: unknown): number | null {
return null;
}
function extractMaxScore(payload: unknown): number | null {
if (payload && typeof payload === "object" && "maxScore" in payload) {
const value = (payload as { maxScore: unknown }).maxScore;
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
return value;
}
}
return null;
}
function extractComment(payload: unknown): string | null {
if (payload && typeof payload === "object" && "comment" in payload) {
const value = (payload as { comment: unknown }).comment;
if (typeof value === "string") {
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
}
return null;
}
function extractAssignee(payload: unknown): { id: string | null; name: string | null } {
if (!payload || typeof payload !== "object") {
return { id: null, name: null }
}
const record = payload as Record<string, unknown>
const rawId = record["assigneeId"]
const rawName = record["assigneeName"]
const id = typeof rawId === "string" && rawId.trim().length > 0 ? rawId.trim() : null
const name = typeof rawName === "string" && rawName.trim().length > 0 ? rawName.trim() : null
return { id, name }
}
function isNotNull<T>(value: T | null): value is T {
return value !== null;
}
@ -119,6 +152,44 @@ async function fetchScopedTicketsByCreatedRange(
.collect();
}
async function fetchScopedTicketsByResolvedRange(
ctx: QueryCtx,
tenantId: string,
viewer: Awaited<ReturnType<typeof requireStaff>>,
startMs: number,
endMs: number,
companyId?: Id<"companies">,
) {
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_resolved", (q) =>
q.eq("tenantId", tenantId).eq("companyId", viewer.user.companyId!).gte("resolvedAt", startMs),
)
.filter((q) => q.lt(q.field("resolvedAt"), endMs))
.collect();
}
if (companyId) {
return ctx.db
.query("tickets")
.withIndex("by_tenant_company_resolved", (q) =>
q.eq("tenantId", tenantId).eq("companyId", companyId).gte("resolvedAt", startMs),
)
.filter((q) => q.lt(q.field("resolvedAt"), endMs))
.collect();
}
return ctx.db
.query("tickets")
.withIndex("by_tenant_resolved", (q) => q.eq("tenantId", tenantId).gte("resolvedAt", startMs))
.filter((q) => q.lt(q.field("resolvedAt"), endMs))
.collect();
}
async function fetchQueues(ctx: QueryCtx, tenantId: string) {
return ctx.db
.query("queues")
@ -159,12 +230,47 @@ type CsatSurvey = {
ticketId: Id<"tickets">;
reference: number;
score: number;
maxScore: number;
comment: string | null;
receivedAt: number;
assigneeId: string | null;
assigneeName: string | null;
};
async function collectCsatSurveys(ctx: QueryCtx, tickets: Doc<"tickets">[]): Promise<CsatSurvey[]> {
const perTicket = await Promise.all(
tickets.map(async (ticket) => {
if (typeof ticket.csatScore === "number") {
const snapshot = (ticket.csatAssigneeSnapshot ?? null) as {
name?: string
email?: string
} | null
const assigneeId =
ticket.csatAssigneeId && typeof ticket.csatAssigneeId === "string"
? ticket.csatAssigneeId
: ticket.csatAssigneeId
? String(ticket.csatAssigneeId)
: null
const assigneeName =
snapshot && typeof snapshot.name === "string" && snapshot.name.trim().length > 0
? snapshot.name.trim()
: null
return [
{
ticketId: ticket._id,
reference: ticket.reference,
score: ticket.csatScore,
maxScore: ticket.csatMaxScore && Number.isFinite(ticket.csatMaxScore) ? (ticket.csatMaxScore as number) : 5,
comment:
typeof ticket.csatComment === "string" && ticket.csatComment.trim().length > 0
? ticket.csatComment.trim()
: null,
receivedAt: ticket.csatRatedAt ?? ticket.updatedAt ?? ticket.createdAt,
assigneeId,
assigneeName,
} satisfies CsatSurvey,
];
}
const events = await ctx.db
.query("ticketEvents")
.withIndex("by_ticket", (q) => q.eq("ticketId", ticket._id))
@ -174,11 +280,16 @@ async function collectCsatSurveys(ctx: QueryCtx, tickets: Doc<"tickets">[]): Pro
.map((event) => {
const score = extractScore(event.payload);
if (score === null) return null;
const assignee = extractAssignee(event.payload)
return {
ticketId: ticket._id,
reference: ticket.reference,
score,
maxScore: extractMaxScore(event.payload) ?? 5,
comment: extractComment(event.payload),
receivedAt: event.createdAt,
assigneeId: assignee.id,
assigneeName: assignee.name,
} as CsatSurvey;
})
.filter(isNotNull);
@ -275,12 +386,61 @@ export async function csatOverviewHandler(
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 normalizeToFive = (value: CsatSurvey) => {
if (!value.maxScore || value.maxScore <= 0) return value.score;
return Math.min(5, Math.max(1, (value.score / value.maxScore) * 5));
};
const averageScore = average(surveys.map((item) => normalizeToFive(item)));
const distribution = [1, 2, 3, 4, 5].map((score) => ({
score,
total: surveys.filter((item) => item.score === score).length,
total: surveys.filter((item) => Math.round(normalizeToFive(item)) === score).length,
}));
const positiveThreshold = 4;
const positiveCount = surveys.filter((item) => normalizeToFive(item) >= positiveThreshold).length;
const positiveRate = surveys.length > 0 ? positiveCount / surveys.length : null;
const agentStats = new Map<
string,
{ id: string; name: string; total: number; sum: number; positive: number }
>();
for (const survey of surveys) {
const normalizedScore = normalizeToFive(survey);
const key = survey.assigneeId ?? "unassigned";
const existing = agentStats.get(key) ?? {
id: key,
name: survey.assigneeName ?? "Sem responsável",
total: 0,
sum: 0,
positive: 0,
};
existing.total += 1;
existing.sum += normalizedScore;
if (normalizedScore >= positiveThreshold) {
existing.positive += 1;
}
if (survey.assigneeName && survey.assigneeName.trim().length > 0) {
existing.name = survey.assigneeName.trim();
}
agentStats.set(key, existing);
}
const byAgent = Array.from(agentStats.values())
.map((entry) => ({
agentId: entry.id === "unassigned" ? null : entry.id,
agentName: entry.id === "unassigned" ? "Sem responsável" : entry.name,
totalResponses: entry.total,
averageScore: entry.total > 0 ? entry.sum / entry.total : null,
positiveRate: entry.total > 0 ? entry.positive / entry.total : null,
}))
.sort((a, b) => {
const diff = (b.averageScore ?? 0) - (a.averageScore ?? 0);
if (Math.abs(diff) > 0.0001) return diff;
return (b.totalResponses ?? 0) - (a.totalResponses ?? 0);
});
return {
totalSurveys: surveys.length,
averageScore,
@ -293,9 +453,15 @@ export async function csatOverviewHandler(
ticketId: item.ticketId,
reference: item.reference,
score: item.score,
maxScore: item.maxScore,
comment: item.comment,
receivedAt: item.receivedAt,
assigneeId: item.assigneeId,
assigneeName: item.assigneeName,
})),
rangeDays: days,
positiveRate,
byAgent,
};
}
@ -309,15 +475,15 @@ export async function openedResolvedByDayHandler(
{ tenantId, viewerId, range, companyId }: { tenantId: string; viewerId: Id<"users">; range?: string; companyId?: Id<"companies"> }
) {
const viewer = await requireStaff(ctx, viewerId, tenantId);
let tickets = await fetchScopedTickets(ctx, tenantId, viewer);
if (companyId) tickets = tickets.filter((t) => t.companyId === companyId)
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 openedTickets = await fetchScopedTicketsByCreatedRange(ctx, tenantId, viewer, startMs, endMs, companyId);
const resolvedTickets = await fetchScopedTicketsByResolvedRange(ctx, tenantId, viewer, startMs, endMs, companyId);
const opened: Record<string, number> = {}
const resolved: Record<string, number> = {}
@ -328,15 +494,19 @@ export async function openedResolvedByDayHandler(
resolved[key] = 0
}
for (const t of tickets) {
if (t.createdAt >= startMs && t.createdAt < endMs) {
const key = formatDateKey(t.createdAt)
for (const ticket of openedTickets) {
if (ticket.createdAt >= startMs && ticket.createdAt < endMs) {
const key = formatDateKey(ticket.createdAt)
opened[key] = (opened[key] ?? 0) + 1
}
if (t.resolvedAt && t.resolvedAt >= startMs && t.resolvedAt < endMs) {
const key = formatDateKey(t.resolvedAt)
resolved[key] = (resolved[key] ?? 0) + 1
}
for (const ticket of resolvedTickets) {
if (typeof ticket.resolvedAt !== "number") {
continue
}
const key = formatDateKey(ticket.resolvedAt)
resolved[key] = (resolved[key] ?? 0) + 1
}
const series: Array<{ date: string; opened: number; resolved: number }> = []