Optimize Convex queries and stack config
This commit is contained in:
parent
004f345d92
commit
3e4943f79c
4 changed files with 469 additions and 408 deletions
|
|
@ -50,6 +50,79 @@ function resolveCategoryName(
|
|||
export const OPEN_STATUSES = new Set<TicketStatusNormalized>(["PENDING", "AWAITING_ATTENDANCE", "PAUSED"]);
|
||||
export const ONE_DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
const REPORTS_PAGE_SIZE = 200;
|
||||
|
||||
type PaginatedResult<T> = {
|
||||
page: T[];
|
||||
continueCursor?: string | null;
|
||||
done?: boolean;
|
||||
isDone?: boolean;
|
||||
};
|
||||
|
||||
async function paginateTickets<T>(
|
||||
buildQuery: () => {
|
||||
paginate: (options: { cursor: string | null; numItems: number }) => Promise<PaginatedResult<T>>;
|
||||
},
|
||||
handler: (doc: T) => void | Promise<void>,
|
||||
pageSize = REPORTS_PAGE_SIZE,
|
||||
) {
|
||||
const query = buildQuery();
|
||||
let cursor: string | null = null;
|
||||
while (true) {
|
||||
const page = await query.paginate({ cursor, numItems: pageSize });
|
||||
for (const doc of page.page) {
|
||||
await handler(doc);
|
||||
}
|
||||
const done = page.done ?? page.isDone ?? !page.continueCursor;
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
cursor = page.continueCursor ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveScopedCompanyId(
|
||||
viewer: Awaited<ReturnType<typeof requireStaff>>,
|
||||
companyId?: Id<"companies">,
|
||||
): Id<"companies"> | undefined {
|
||||
if (viewer.role === "MANAGER") {
|
||||
if (!viewer.user.companyId) {
|
||||
throw new ConvexError("Gestor não possui empresa vinculada");
|
||||
}
|
||||
return viewer.user.companyId;
|
||||
}
|
||||
return companyId;
|
||||
}
|
||||
|
||||
export async function fetchOpenScopedTickets(
|
||||
ctx: QueryCtx,
|
||||
tenantId: string,
|
||||
viewer: Awaited<ReturnType<typeof requireStaff>>,
|
||||
companyId?: Id<"companies">,
|
||||
): Promise<Doc<"tickets">[]> {
|
||||
const scopedCompanyId = resolveScopedCompanyId(viewer, companyId);
|
||||
const statuses: TicketStatusNormalized[] = ["PENDING", "AWAITING_ATTENDANCE", "PAUSED"];
|
||||
const results: Doc<"tickets">[] = [];
|
||||
|
||||
for (const status of statuses) {
|
||||
await paginateTickets(
|
||||
() =>
|
||||
ctx.db
|
||||
.query("tickets")
|
||||
.withIndex("by_tenant_status", (q) => q.eq("tenantId", tenantId).eq("status", status))
|
||||
.order("desc"),
|
||||
(ticket) => {
|
||||
if (scopedCompanyId && ticket.companyId !== scopedCompanyId) {
|
||||
return;
|
||||
}
|
||||
results.push(ticket);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function percentageChange(current: number, previous: number) {
|
||||
if (previous === 0) {
|
||||
return current === 0 ? 0 : null;
|
||||
|
|
@ -106,10 +179,14 @@ function isNotNull<T>(value: T | null): value is T {
|
|||
}
|
||||
|
||||
export async function fetchTickets(ctx: QueryCtx, tenantId: string) {
|
||||
return ctx.db
|
||||
.query("tickets")
|
||||
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
|
||||
.collect();
|
||||
const results: Doc<"tickets">[] = [];
|
||||
await paginateTickets(
|
||||
() => ctx.db.query("tickets").withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)).order("desc"),
|
||||
(ticket) => {
|
||||
results.push(ticket);
|
||||
},
|
||||
);
|
||||
return results;
|
||||
}
|
||||
|
||||
async function fetchCategoryMap(ctx: QueryCtx, tenantId: string) {
|
||||
|
|
@ -129,18 +206,25 @@ export async function fetchScopedTickets(
|
|||
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);
|
||||
const scopedCompanyId = resolveScopedCompanyId(viewer);
|
||||
const results: Doc<"tickets">[] = [];
|
||||
|
||||
await paginateTickets(
|
||||
() => {
|
||||
if (scopedCompanyId) {
|
||||
return ctx.db
|
||||
.query("tickets")
|
||||
.withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId).eq("companyId", scopedCompanyId))
|
||||
.order("desc");
|
||||
}
|
||||
return ctx.db.query("tickets").withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)).order("desc");
|
||||
},
|
||||
(ticket) => {
|
||||
results.push(ticket);
|
||||
},
|
||||
);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
export async function fetchScopedTicketsByCreatedRange(
|
||||
|
|
@ -151,61 +235,31 @@ export async function fetchScopedTicketsByCreatedRange(
|
|||
endMs: number,
|
||||
companyId?: Id<"companies">,
|
||||
) {
|
||||
const collectRange = async (buildQuery: (chunkStart: number) => unknown) => {
|
||||
const results: Doc<"tickets">[] = [];
|
||||
const chunkSize = 7 * ONE_DAY_MS;
|
||||
for (let chunkStart = startMs; chunkStart < endMs; chunkStart += chunkSize) {
|
||||
const chunkEnd = Math.min(chunkStart + chunkSize, endMs);
|
||||
const baseQuery = buildQuery(chunkStart);
|
||||
const filterFn = (baseQuery as { filter?: (fn: (q: QueryFilterBuilder) => unknown) => unknown }).filter;
|
||||
const queryForChunk =
|
||||
typeof filterFn === "function"
|
||||
? filterFn.call(baseQuery, (q: QueryFilterBuilder) => q.lt(q.field("createdAt"), chunkEnd))
|
||||
: baseQuery;
|
||||
const collectFn = (queryForChunk as { collect?: () => Promise<Doc<"tickets">[]> }).collect;
|
||||
if (typeof collectFn !== "function") {
|
||||
throw new ConvexError("Indexed query does not support collect (createdAt)");
|
||||
}
|
||||
const page = await collectFn.call(queryForChunk);
|
||||
if (!page || page.length === 0) continue;
|
||||
for (const ticket of page) {
|
||||
const createdAt = typeof ticket.createdAt === "number" ? ticket.createdAt : null;
|
||||
if (createdAt === null) continue;
|
||||
if (createdAt < chunkStart || createdAt >= endMs) continue;
|
||||
results.push(ticket);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
};
|
||||
const scopedCompanyId = resolveScopedCompanyId(viewer, companyId);
|
||||
const results: Doc<"tickets">[] = [];
|
||||
|
||||
if (viewer.role === "MANAGER") {
|
||||
if (!viewer.user.companyId) {
|
||||
throw new ConvexError("Gestor não possui empresa vinculada");
|
||||
}
|
||||
return collectRange((chunkStart) =>
|
||||
ctx.db
|
||||
.query("tickets")
|
||||
.withIndex("by_tenant_company_created", (q) =>
|
||||
q.eq("tenantId", tenantId).eq("companyId", viewer.user.companyId!).gte("createdAt", chunkStart)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (companyId) {
|
||||
return collectRange((chunkStart) =>
|
||||
ctx.db
|
||||
.query("tickets")
|
||||
.withIndex("by_tenant_company_created", (q) =>
|
||||
q.eq("tenantId", tenantId).eq("companyId", companyId).gte("createdAt", chunkStart)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return collectRange((chunkStart) =>
|
||||
ctx.db
|
||||
.query("tickets")
|
||||
.withIndex("by_tenant_created", (q) => q.eq("tenantId", tenantId).gte("createdAt", chunkStart))
|
||||
await paginateTickets(
|
||||
() => {
|
||||
const query = scopedCompanyId
|
||||
? ctx.db
|
||||
.query("tickets")
|
||||
.withIndex("by_tenant_company_created", (q) =>
|
||||
q.eq("tenantId", tenantId).eq("companyId", scopedCompanyId).gte("createdAt", startMs),
|
||||
)
|
||||
: ctx.db
|
||||
.query("tickets")
|
||||
.withIndex("by_tenant_created", (q) => q.eq("tenantId", tenantId).gte("createdAt", startMs));
|
||||
return query.filter((q) => q.lt(q.field("createdAt"), endMs)).order("desc");
|
||||
},
|
||||
(ticket) => {
|
||||
const createdAt = typeof ticket.createdAt === "number" ? ticket.createdAt : null;
|
||||
if (createdAt === null) return;
|
||||
if (createdAt < startMs || createdAt >= endMs) return;
|
||||
results.push(ticket);
|
||||
},
|
||||
);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
export async function fetchScopedTicketsByResolvedRange(
|
||||
|
|
@ -216,61 +270,31 @@ export async function fetchScopedTicketsByResolvedRange(
|
|||
endMs: number,
|
||||
companyId?: Id<"companies">,
|
||||
) {
|
||||
const collectRange = async (buildQuery: (chunkStart: number) => unknown) => {
|
||||
const results: Doc<"tickets">[] = [];
|
||||
const chunkSize = 7 * ONE_DAY_MS;
|
||||
for (let chunkStart = startMs; chunkStart < endMs; chunkStart += chunkSize) {
|
||||
const chunkEnd = Math.min(chunkStart + chunkSize, endMs);
|
||||
const baseQuery = buildQuery(chunkStart);
|
||||
const filterFn = (baseQuery as { filter?: (fn: (q: QueryFilterBuilder) => unknown) => unknown }).filter;
|
||||
const queryForChunk =
|
||||
typeof filterFn === "function"
|
||||
? filterFn.call(baseQuery, (q: QueryFilterBuilder) => q.lt(q.field("resolvedAt"), chunkEnd))
|
||||
: baseQuery;
|
||||
const collectFn = (queryForChunk as { collect?: () => Promise<Doc<"tickets">[]> }).collect;
|
||||
if (typeof collectFn !== "function") {
|
||||
throw new ConvexError("Indexed query does not support collect (resolvedAt)");
|
||||
}
|
||||
const page = await collectFn.call(queryForChunk);
|
||||
if (!page || page.length === 0) continue;
|
||||
for (const ticket of page) {
|
||||
const resolvedAt = typeof ticket.resolvedAt === "number" ? ticket.resolvedAt : null;
|
||||
if (resolvedAt === null) continue;
|
||||
if (resolvedAt < chunkStart || resolvedAt >= endMs) continue;
|
||||
results.push(ticket);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
};
|
||||
const scopedCompanyId = resolveScopedCompanyId(viewer, companyId);
|
||||
const results: Doc<"tickets">[] = [];
|
||||
|
||||
if (viewer.role === "MANAGER") {
|
||||
if (!viewer.user.companyId) {
|
||||
throw new ConvexError("Gestor não possui empresa vinculada");
|
||||
}
|
||||
return collectRange((chunkStart) =>
|
||||
ctx.db
|
||||
.query("tickets")
|
||||
.withIndex("by_tenant_company_resolved", (q) =>
|
||||
q.eq("tenantId", tenantId).eq("companyId", viewer.user.companyId!).gte("resolvedAt", chunkStart)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (companyId) {
|
||||
return collectRange((chunkStart) =>
|
||||
ctx.db
|
||||
.query("tickets")
|
||||
.withIndex("by_tenant_company_resolved", (q) =>
|
||||
q.eq("tenantId", tenantId).eq("companyId", companyId).gte("resolvedAt", chunkStart)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return collectRange((chunkStart) =>
|
||||
ctx.db
|
||||
.query("tickets")
|
||||
.withIndex("by_tenant_resolved", (q) => q.eq("tenantId", tenantId).gte("resolvedAt", chunkStart))
|
||||
await paginateTickets(
|
||||
() => {
|
||||
const query = scopedCompanyId
|
||||
? ctx.db
|
||||
.query("tickets")
|
||||
.withIndex("by_tenant_company_resolved", (q) =>
|
||||
q.eq("tenantId", tenantId).eq("companyId", scopedCompanyId).gte("resolvedAt", startMs),
|
||||
)
|
||||
: ctx.db
|
||||
.query("tickets")
|
||||
.withIndex("by_tenant_resolved", (q) => q.eq("tenantId", tenantId).gte("resolvedAt", startMs));
|
||||
return query.filter((q) => q.lt(q.field("resolvedAt"), endMs)).order("desc");
|
||||
},
|
||||
(ticket) => {
|
||||
const resolvedAt = typeof ticket.resolvedAt === "number" ? ticket.resolvedAt : null;
|
||||
if (resolvedAt === null) return;
|
||||
if (resolvedAt < startMs || resolvedAt >= endMs) return;
|
||||
results.push(ticket);
|
||||
},
|
||||
);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
async function fetchQueues(ctx: QueryCtx, tenantId: string) {
|
||||
|
|
@ -320,65 +344,53 @@ type CsatSurvey = {
|
|||
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,
|
||||
];
|
||||
function collectCsatSurveys(tickets: Doc<"tickets">[]): CsatSurvey[] {
|
||||
return tickets
|
||||
.map((ticket) => {
|
||||
if (typeof ticket.csatScore !== "number") {
|
||||
return null;
|
||||
}
|
||||
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;
|
||||
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);
|
||||
const snapshot = (ticket.csatAssigneeSnapshot ?? null) as {
|
||||
name?: string | null
|
||||
email?: string | null
|
||||
} | null
|
||||
const assigneeId =
|
||||
ticket.csatAssigneeId && typeof ticket.csatAssigneeId === "string"
|
||||
? ticket.csatAssigneeId
|
||||
: ticket.csatAssigneeId
|
||||
? String(ticket.csatAssigneeId)
|
||||
: ticket.assigneeId
|
||||
? String(ticket.assigneeId)
|
||||
: null
|
||||
const assigneeName = snapshot?.name?.trim?.() || snapshot?.email?.trim?.() || null
|
||||
const maxScore =
|
||||
typeof ticket.csatMaxScore === "number" && Number.isFinite(ticket.csatMaxScore)
|
||||
? (ticket.csatMaxScore as number)
|
||||
: 5
|
||||
const receivedAtRaw =
|
||||
typeof ticket.csatRatedAt === "number"
|
||||
? ticket.csatRatedAt
|
||||
: typeof ticket.resolvedAt === "number"
|
||||
? ticket.resolvedAt
|
||||
: ticket.updatedAt ?? ticket.createdAt
|
||||
if (typeof receivedAtRaw !== "number") {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
ticketId: ticket._id,
|
||||
reference: ticket.reference,
|
||||
score: ticket.csatScore,
|
||||
maxScore,
|
||||
comment:
|
||||
typeof ticket.csatComment === "string" && ticket.csatComment.trim().length > 0
|
||||
? ticket.csatComment.trim()
|
||||
: null,
|
||||
receivedAt: receivedAtRaw,
|
||||
assigneeId,
|
||||
assigneeName,
|
||||
} satisfies CsatSurvey;
|
||||
})
|
||||
);
|
||||
return perTicket.flat();
|
||||
.filter(isNotNull);
|
||||
}
|
||||
|
||||
function formatDateKey(timestamp: number) {
|
||||
|
|
@ -394,15 +406,13 @@ export async function slaOverviewHandler(
|
|||
{ 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)
|
||||
// Optional range filter (createdAt) for reporting purposes, similar ao backlog/csat
|
||||
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 inRange = await fetchScopedTicketsByCreatedRange(ctx, tenantId, viewer, startMs, endMs, companyId);
|
||||
const queues = await fetchQueues(ctx, tenantId);
|
||||
const categoriesMap = await fetchCategoryMap(ctx, tenantId);
|
||||
|
||||
|
|
@ -543,15 +553,13 @@ export async function csatOverviewHandler(
|
|||
{ 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 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 tickets = await fetchScopedTicketsByResolvedRange(ctx, tenantId, viewer, startMs, endMs, companyId);
|
||||
const surveys = collectCsatSurveys(tickets).filter((s) => s.receivedAt >= startMs && s.receivedAt < endMs);
|
||||
|
||||
const normalizeToFive = (value: CsatSurvey) => {
|
||||
if (!value.maxScore || value.maxScore <= 0) return value.score;
|
||||
|
|
@ -775,7 +783,8 @@ export async function queueLoadTrendHandler(
|
|||
end.setUTCHours(0, 0, 0, 0)
|
||||
const endMs = end.getTime() + ONE_DAY_MS
|
||||
const startMs = endMs - days * ONE_DAY_MS
|
||||
const tickets = await fetchScopedTickets(ctx, tenantId, viewer)
|
||||
const openedTickets = await fetchScopedTicketsByCreatedRange(ctx, tenantId, viewer, startMs, endMs)
|
||||
const resolvedTickets = await fetchScopedTicketsByResolvedRange(ctx, tenantId, viewer, startMs, endMs)
|
||||
const queues = await fetchQueues(ctx, tenantId)
|
||||
|
||||
const queueNames = new Map<string, string>()
|
||||
|
|
@ -806,24 +815,26 @@ export async function queueLoadTrendHandler(
|
|||
return stats.get(queueId)!
|
||||
}
|
||||
|
||||
for (const ticket of tickets) {
|
||||
for (const ticket of openedTickets) {
|
||||
const queueId = ticket.queueId ? String(ticket.queueId) : "unassigned"
|
||||
if (ticket.createdAt >= startMs && ticket.createdAt < endMs) {
|
||||
const entry = ensureEntry(queueId)
|
||||
const bucket = entry.series.get(formatDateKey(ticket.createdAt))
|
||||
if (bucket) {
|
||||
bucket.opened += 1
|
||||
}
|
||||
entry.openedTotal += 1
|
||||
const entry = ensureEntry(queueId)
|
||||
const bucket = entry.series.get(formatDateKey(ticket.createdAt))
|
||||
if (bucket) {
|
||||
bucket.opened += 1
|
||||
}
|
||||
if (typeof ticket.resolvedAt === "number" && ticket.resolvedAt >= startMs && ticket.resolvedAt < endMs) {
|
||||
const entry = ensureEntry(queueId)
|
||||
const bucket = entry.series.get(formatDateKey(ticket.resolvedAt))
|
||||
if (bucket) {
|
||||
bucket.resolved += 1
|
||||
}
|
||||
entry.resolvedTotal += 1
|
||||
entry.openedTotal += 1
|
||||
}
|
||||
|
||||
for (const ticket of resolvedTickets) {
|
||||
const queueId = ticket.queueId ? String(ticket.queueId) : "unassigned"
|
||||
const resolvedAt = typeof ticket.resolvedAt === "number" ? ticket.resolvedAt : null
|
||||
if (resolvedAt === null) continue
|
||||
const entry = ensureEntry(queueId)
|
||||
const bucket = entry.series.get(formatDateKey(resolvedAt))
|
||||
if (bucket) {
|
||||
bucket.resolved += 1
|
||||
}
|
||||
entry.resolvedTotal += 1
|
||||
}
|
||||
|
||||
const maxEntries = Math.max(1, Math.min(limit ?? 3, 6))
|
||||
|
|
@ -852,8 +863,6 @@ export async function agentProductivityHandler(
|
|||
{ 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()
|
||||
|
|
@ -861,7 +870,7 @@ export async function agentProductivityHandler(
|
|||
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 inRange = await fetchScopedTicketsByCreatedRange(ctx, tenantId, viewer, startMs, endMs, companyId)
|
||||
type Acc = {
|
||||
agentId: Id<"users">
|
||||
name: string | null
|
||||
|
|
@ -1075,52 +1084,55 @@ export async function dashboardOverviewHandler(
|
|||
{ tenantId, viewerId }: { tenantId: string; viewerId: Id<"users"> }
|
||||
) {
|
||||
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 inProgressCurrent = tickets.filter((ticket) => {
|
||||
if (!ticket.firstResponseAt) return false;
|
||||
const status = normalizeStatus(ticket.status);
|
||||
if (status === "RESOLVED") return false;
|
||||
return !ticket.resolvedAt;
|
||||
});
|
||||
|
||||
const inProgressPrevious = tickets.filter((ticket) => {
|
||||
if (!ticket.firstResponseAt || ticket.firstResponseAt >= lastDayStart) return false;
|
||||
if (ticket.resolvedAt && ticket.resolvedAt < lastDayStart) return false;
|
||||
const status = normalizeStatus(ticket.status);
|
||||
return status !== "RESOLVED" || !ticket.resolvedAt;
|
||||
});
|
||||
|
||||
const inProgressTrend = percentageChange(inProgressCurrent.length, inProgressPrevious.length);
|
||||
|
||||
const lastWindowStart = now - 7 * ONE_DAY_MS;
|
||||
const previousWindowStart = now - 14 * ONE_DAY_MS;
|
||||
|
||||
const firstResponseWindow = tickets
|
||||
const openTickets = await fetchOpenScopedTickets(ctx, tenantId, viewer);
|
||||
const recentCreatedTickets = await fetchScopedTicketsByCreatedRange(
|
||||
ctx,
|
||||
tenantId,
|
||||
viewer,
|
||||
previousWindowStart,
|
||||
now,
|
||||
);
|
||||
const recentResolvedTickets = await fetchScopedTicketsByResolvedRange(
|
||||
ctx,
|
||||
tenantId,
|
||||
viewer,
|
||||
previousWindowStart,
|
||||
now,
|
||||
);
|
||||
|
||||
const newTickets = recentCreatedTickets.filter((ticket) => ticket.createdAt >= lastDayStart);
|
||||
const previousTickets = recentCreatedTickets.filter(
|
||||
(ticket) => ticket.createdAt >= previousDayStart && ticket.createdAt < lastDayStart,
|
||||
);
|
||||
const trend = percentageChange(newTickets.length, previousTickets.length);
|
||||
|
||||
const inProgressCurrent = openTickets.filter((ticket) => Boolean(ticket.firstResponseAt));
|
||||
const inProgressPrevious = openTickets.filter(
|
||||
(ticket) => Boolean(ticket.firstResponseAt && ticket.firstResponseAt < lastDayStart),
|
||||
);
|
||||
const inProgressTrend = percentageChange(inProgressCurrent.length, inProgressPrevious.length);
|
||||
|
||||
const firstResponseWindow = recentCreatedTickets
|
||||
.filter(
|
||||
(ticket) =>
|
||||
ticket.createdAt >= lastWindowStart &&
|
||||
ticket.createdAt < now &&
|
||||
ticket.firstResponseAt
|
||||
ticket.firstResponseAt,
|
||||
)
|
||||
.map((ticket) => (ticket.firstResponseAt! - ticket.createdAt) / 60000);
|
||||
const firstResponsePrevious = tickets
|
||||
const firstResponsePrevious = recentCreatedTickets
|
||||
.filter(
|
||||
(ticket) =>
|
||||
ticket.createdAt >= previousWindowStart &&
|
||||
ticket.createdAt < lastWindowStart &&
|
||||
ticket.firstResponseAt
|
||||
ticket.firstResponseAt,
|
||||
)
|
||||
.map((ticket) => (ticket.firstResponseAt! - ticket.createdAt) / 60000);
|
||||
|
||||
|
|
@ -1129,23 +1141,21 @@ export async function dashboardOverviewHandler(
|
|||
const deltaMinutes =
|
||||
averageWindow !== null && averagePrevious !== null ? averageWindow - averagePrevious : null;
|
||||
|
||||
const awaitingTickets = tickets.filter((ticket) => OPEN_STATUSES.has(normalizeStatus(ticket.status)));
|
||||
const awaitingTickets = openTickets;
|
||||
const atRiskTickets = awaitingTickets.filter((ticket) => ticket.dueAt && ticket.dueAt < now);
|
||||
|
||||
const resolvedLastWindow = tickets.filter(
|
||||
(ticket) => ticket.resolvedAt && ticket.resolvedAt >= lastWindowStart && ticket.resolvedAt < now
|
||||
const resolvedLastWindow = recentResolvedTickets.filter(
|
||||
(ticket) => ticket.resolvedAt && ticket.resolvedAt >= lastWindowStart && ticket.resolvedAt < now,
|
||||
);
|
||||
const resolvedPreviousWindow = tickets.filter(
|
||||
const resolvedPreviousWindow = recentResolvedTickets.filter(
|
||||
(ticket) =>
|
||||
ticket.resolvedAt &&
|
||||
ticket.resolvedAt >= previousWindowStart &&
|
||||
ticket.resolvedAt < lastWindowStart
|
||||
ticket.resolvedAt < lastWindowStart,
|
||||
);
|
||||
const resolutionRate = tickets.length > 0 ? (resolvedLastWindow.length / tickets.length) * 100 : null;
|
||||
const resolutionDelta =
|
||||
resolvedPreviousWindow.length > 0
|
||||
? ((resolvedLastWindow.length - resolvedPreviousWindow.length) / resolvedPreviousWindow.length) * 100
|
||||
: null;
|
||||
const resolutionBase = Math.max(recentCreatedTickets.length, awaitingTickets.length);
|
||||
const resolutionRate = resolutionBase > 0 ? (resolvedLastWindow.length / resolutionBase) * 100 : null;
|
||||
const resolutionDelta = percentageChange(resolvedLastWindow.length, resolvedPreviousWindow.length);
|
||||
|
||||
return {
|
||||
newTickets: {
|
||||
|
|
@ -1187,14 +1197,13 @@ export async function ticketsByChannelHandler(
|
|||
{ 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 tickets = await fetchScopedTicketsByCreatedRange(ctx, tenantId, viewer, startMs, endMs, companyId);
|
||||
|
||||
const timeline = new Map<string, Map<string, number>>();
|
||||
for (let ts = startMs; ts < endMs; ts += ONE_DAY_MS) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue