Optimize Convex queries and stack config
This commit is contained in:
parent
004f345d92
commit
3e4943f79c
4 changed files with 469 additions and 408 deletions
|
|
@ -6,7 +6,7 @@ import type { QueryCtx } from "./_generated/server"
|
||||||
import {
|
import {
|
||||||
OPEN_STATUSES,
|
OPEN_STATUSES,
|
||||||
ONE_DAY_MS,
|
ONE_DAY_MS,
|
||||||
fetchScopedTickets,
|
fetchOpenScopedTickets,
|
||||||
fetchScopedTicketsByCreatedRange,
|
fetchScopedTicketsByCreatedRange,
|
||||||
fetchScopedTicketsByResolvedRange,
|
fetchScopedTicketsByResolvedRange,
|
||||||
normalizeStatus,
|
normalizeStatus,
|
||||||
|
|
@ -189,13 +189,14 @@ async function computeAgentStats(
|
||||||
rangeDays: number,
|
rangeDays: number,
|
||||||
agentFilter?: Id<"users">,
|
agentFilter?: Id<"users">,
|
||||||
) {
|
) {
|
||||||
const scopedTickets = await fetchScopedTickets(ctx, tenantId, viewer)
|
|
||||||
const end = new Date()
|
const end = new Date()
|
||||||
end.setUTCHours(0, 0, 0, 0)
|
end.setUTCHours(0, 0, 0, 0)
|
||||||
const endMs = end.getTime() + ONE_DAY_MS
|
const endMs = end.getTime() + ONE_DAY_MS
|
||||||
const startMs = endMs - rangeDays * ONE_DAY_MS
|
const startMs = endMs - rangeDays * ONE_DAY_MS
|
||||||
|
|
||||||
const statsMap = new Map<string, AgentStatsRaw>()
|
const statsMap = new Map<string, AgentStatsRaw>()
|
||||||
|
const openTickets = await fetchOpenScopedTickets(ctx, tenantId, viewer)
|
||||||
|
const scopedTickets = await fetchScopedTicketsByCreatedRange(ctx, tenantId, viewer, startMs, endMs)
|
||||||
|
|
||||||
const matchesFilter = (ticket: Doc<"tickets">) => {
|
const matchesFilter = (ticket: Doc<"tickets">) => {
|
||||||
if (!ticket.assigneeId) return false
|
if (!ticket.assigneeId) return false
|
||||||
|
|
@ -203,7 +204,7 @@ async function computeAgentStats(
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const ticket of scopedTickets) {
|
for (const ticket of openTickets) {
|
||||||
if (!matchesFilter(ticket)) continue
|
if (!matchesFilter(ticket)) continue
|
||||||
const stats = ensureAgentStats(statsMap, ticket)
|
const stats = ensureAgentStats(statsMap, ticket)
|
||||||
if (!stats) continue
|
if (!stats) continue
|
||||||
|
|
@ -215,9 +216,7 @@ async function computeAgentStats(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const inRange = scopedTickets.filter(
|
const inRange = scopedTickets.filter((ticket) => matchesFilter(ticket))
|
||||||
(ticket) => matchesFilter(ticket) && ticket.createdAt >= startMs && ticket.createdAt < endMs,
|
|
||||||
)
|
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
for (const ticket of inRange) {
|
for (const ticket of inRange) {
|
||||||
const stats = ensureAgentStats(statsMap, ticket)
|
const stats = ensureAgentStats(statsMap, ticket)
|
||||||
|
|
@ -336,7 +335,10 @@ const metricResolvers: Record<string, MetricResolver> = {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tickets.waiting_action_now": async (ctx, { tenantId, viewer, params }) => {
|
"tickets.waiting_action_now": async (ctx, { tenantId, viewer, params }) => {
|
||||||
const tickets = filterTicketsByQueue(await fetchScopedTickets(ctx, tenantId, viewer), parseQueueIds(params))
|
const tickets = filterTicketsByQueue(
|
||||||
|
await fetchOpenScopedTickets(ctx, tenantId, viewer),
|
||||||
|
parseQueueIds(params),
|
||||||
|
)
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
let total = 0
|
let total = 0
|
||||||
let atRisk = 0
|
let atRisk = 0
|
||||||
|
|
@ -361,7 +363,10 @@ const metricResolvers: Record<string, MetricResolver> = {
|
||||||
end.setUTCHours(0, 0, 0, 0)
|
end.setUTCHours(0, 0, 0, 0)
|
||||||
const endMs = end.getTime() + ONE_DAY_MS
|
const endMs = end.getTime() + ONE_DAY_MS
|
||||||
const startMs = endMs - rangeDays * ONE_DAY_MS
|
const startMs = endMs - rangeDays * ONE_DAY_MS
|
||||||
const tickets = filterTicketsByQueue(await fetchScopedTickets(ctx, tenantId, viewer), parseQueueIds(params))
|
const tickets = filterTicketsByQueue(
|
||||||
|
await fetchScopedTicketsByCreatedRange(ctx, tenantId, viewer, startMs, endMs),
|
||||||
|
parseQueueIds(params),
|
||||||
|
)
|
||||||
|
|
||||||
const daily: Record<string, { total: number; atRisk: number }> = {}
|
const daily: Record<string, { total: number; atRisk: number }> = {}
|
||||||
for (let offset = rangeDays - 1; offset >= 0; offset -= 1) {
|
for (let offset = rangeDays - 1; offset >= 0; offset -= 1) {
|
||||||
|
|
@ -500,7 +505,7 @@ const metricResolvers: Record<string, MetricResolver> = {
|
||||||
ensureEntry(key)
|
ensureEntry(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
const scopedTickets = await fetchScopedTickets(ctx, tenantId, viewer)
|
const scopedTickets = await fetchOpenScopedTickets(ctx, tenantId, viewer)
|
||||||
for (const ticket of scopedTickets) {
|
for (const ticket of scopedTickets) {
|
||||||
const key = normalizeKey(ticket.queueId ?? null)
|
const key = normalizeKey(ticket.queueId ?? null)
|
||||||
if (filterHas && queueFilter && !queueFilter.includes(key)) continue
|
if (filterHas && queueFilter && !queueFilter.includes(key)) continue
|
||||||
|
|
@ -626,7 +631,10 @@ const metricResolvers: Record<string, MetricResolver> = {
|
||||||
},
|
},
|
||||||
"tickets.awaiting_table": async (ctx, { tenantId, viewer, params }) => {
|
"tickets.awaiting_table": async (ctx, { tenantId, viewer, params }) => {
|
||||||
const limit = parseLimit(params, 20)
|
const limit = parseLimit(params, 20)
|
||||||
const tickets = filterTicketsByQueue(await fetchScopedTickets(ctx, tenantId, viewer), parseQueueIds(params))
|
const tickets = filterTicketsByQueue(
|
||||||
|
await fetchOpenScopedTickets(ctx, tenantId, viewer),
|
||||||
|
parseQueueIds(params),
|
||||||
|
)
|
||||||
const awaiting = tickets
|
const awaiting = tickets
|
||||||
.filter((ticket) => OPEN_STATUSES.has(normalizeStatus(ticket.status)))
|
.filter((ticket) => OPEN_STATUSES.has(normalizeStatus(ticket.status)))
|
||||||
.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0))
|
.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0))
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,79 @@ function resolveCategoryName(
|
||||||
export const OPEN_STATUSES = new Set<TicketStatusNormalized>(["PENDING", "AWAITING_ATTENDANCE", "PAUSED"]);
|
export const OPEN_STATUSES = new Set<TicketStatusNormalized>(["PENDING", "AWAITING_ATTENDANCE", "PAUSED"]);
|
||||||
export const ONE_DAY_MS = 24 * 60 * 60 * 1000;
|
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) {
|
function percentageChange(current: number, previous: number) {
|
||||||
if (previous === 0) {
|
if (previous === 0) {
|
||||||
return current === 0 ? 0 : null;
|
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) {
|
export async function fetchTickets(ctx: QueryCtx, tenantId: string) {
|
||||||
return ctx.db
|
const results: Doc<"tickets">[] = [];
|
||||||
.query("tickets")
|
await paginateTickets(
|
||||||
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
|
() => ctx.db.query("tickets").withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)).order("desc"),
|
||||||
.collect();
|
(ticket) => {
|
||||||
|
results.push(ticket);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchCategoryMap(ctx: QueryCtx, tenantId: string) {
|
async function fetchCategoryMap(ctx: QueryCtx, tenantId: string) {
|
||||||
|
|
@ -129,18 +206,25 @@ export async function fetchScopedTickets(
|
||||||
tenantId: string,
|
tenantId: string,
|
||||||
viewer: Awaited<ReturnType<typeof requireStaff>>,
|
viewer: Awaited<ReturnType<typeof requireStaff>>,
|
||||||
) {
|
) {
|
||||||
if (viewer.role === "MANAGER") {
|
const scopedCompanyId = resolveScopedCompanyId(viewer);
|
||||||
if (!viewer.user.companyId) {
|
const results: Doc<"tickets">[] = [];
|
||||||
throw new ConvexError("Gestor não possui empresa vinculada");
|
|
||||||
}
|
await paginateTickets(
|
||||||
|
() => {
|
||||||
|
if (scopedCompanyId) {
|
||||||
return ctx.db
|
return ctx.db
|
||||||
.query("tickets")
|
.query("tickets")
|
||||||
.withIndex("by_tenant_company", (q) =>
|
.withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId).eq("companyId", scopedCompanyId))
|
||||||
q.eq("tenantId", tenantId).eq("companyId", viewer.user.companyId!)
|
.order("desc");
|
||||||
)
|
|
||||||
.collect();
|
|
||||||
}
|
}
|
||||||
return fetchTickets(ctx, tenantId);
|
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(
|
export async function fetchScopedTicketsByCreatedRange(
|
||||||
|
|
@ -151,61 +235,31 @@ export async function fetchScopedTicketsByCreatedRange(
|
||||||
endMs: number,
|
endMs: number,
|
||||||
companyId?: Id<"companies">,
|
companyId?: Id<"companies">,
|
||||||
) {
|
) {
|
||||||
const collectRange = async (buildQuery: (chunkStart: number) => unknown) => {
|
const scopedCompanyId = resolveScopedCompanyId(viewer, companyId);
|
||||||
const results: Doc<"tickets">[] = [];
|
const results: Doc<"tickets">[] = [];
|
||||||
const chunkSize = 7 * ONE_DAY_MS;
|
|
||||||
for (let chunkStart = startMs; chunkStart < endMs; chunkStart += chunkSize) {
|
await paginateTickets(
|
||||||
const chunkEnd = Math.min(chunkStart + chunkSize, endMs);
|
() => {
|
||||||
const baseQuery = buildQuery(chunkStart);
|
const query = scopedCompanyId
|
||||||
const filterFn = (baseQuery as { filter?: (fn: (q: QueryFilterBuilder) => unknown) => unknown }).filter;
|
? ctx.db
|
||||||
const queryForChunk =
|
.query("tickets")
|
||||||
typeof filterFn === "function"
|
.withIndex("by_tenant_company_created", (q) =>
|
||||||
? filterFn.call(baseQuery, (q: QueryFilterBuilder) => q.lt(q.field("createdAt"), chunkEnd))
|
q.eq("tenantId", tenantId).eq("companyId", scopedCompanyId).gte("createdAt", startMs),
|
||||||
: baseQuery;
|
)
|
||||||
const collectFn = (queryForChunk as { collect?: () => Promise<Doc<"tickets">[]> }).collect;
|
: ctx.db
|
||||||
if (typeof collectFn !== "function") {
|
.query("tickets")
|
||||||
throw new ConvexError("Indexed query does not support collect (createdAt)");
|
.withIndex("by_tenant_created", (q) => q.eq("tenantId", tenantId).gte("createdAt", startMs));
|
||||||
}
|
return query.filter((q) => q.lt(q.field("createdAt"), endMs)).order("desc");
|
||||||
const page = await collectFn.call(queryForChunk);
|
},
|
||||||
if (!page || page.length === 0) continue;
|
(ticket) => {
|
||||||
for (const ticket of page) {
|
|
||||||
const createdAt = typeof ticket.createdAt === "number" ? ticket.createdAt : null;
|
const createdAt = typeof ticket.createdAt === "number" ? ticket.createdAt : null;
|
||||||
if (createdAt === null) continue;
|
if (createdAt === null) return;
|
||||||
if (createdAt < chunkStart || createdAt >= endMs) continue;
|
if (createdAt < startMs || createdAt >= endMs) return;
|
||||||
results.push(ticket);
|
results.push(ticket);
|
||||||
}
|
},
|
||||||
}
|
);
|
||||||
|
|
||||||
return results;
|
return results;
|
||||||
};
|
|
||||||
|
|
||||||
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))
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchScopedTicketsByResolvedRange(
|
export async function fetchScopedTicketsByResolvedRange(
|
||||||
|
|
@ -216,61 +270,31 @@ export async function fetchScopedTicketsByResolvedRange(
|
||||||
endMs: number,
|
endMs: number,
|
||||||
companyId?: Id<"companies">,
|
companyId?: Id<"companies">,
|
||||||
) {
|
) {
|
||||||
const collectRange = async (buildQuery: (chunkStart: number) => unknown) => {
|
const scopedCompanyId = resolveScopedCompanyId(viewer, companyId);
|
||||||
const results: Doc<"tickets">[] = [];
|
const results: Doc<"tickets">[] = [];
|
||||||
const chunkSize = 7 * ONE_DAY_MS;
|
|
||||||
for (let chunkStart = startMs; chunkStart < endMs; chunkStart += chunkSize) {
|
await paginateTickets(
|
||||||
const chunkEnd = Math.min(chunkStart + chunkSize, endMs);
|
() => {
|
||||||
const baseQuery = buildQuery(chunkStart);
|
const query = scopedCompanyId
|
||||||
const filterFn = (baseQuery as { filter?: (fn: (q: QueryFilterBuilder) => unknown) => unknown }).filter;
|
? ctx.db
|
||||||
const queryForChunk =
|
.query("tickets")
|
||||||
typeof filterFn === "function"
|
.withIndex("by_tenant_company_resolved", (q) =>
|
||||||
? filterFn.call(baseQuery, (q: QueryFilterBuilder) => q.lt(q.field("resolvedAt"), chunkEnd))
|
q.eq("tenantId", tenantId).eq("companyId", scopedCompanyId).gte("resolvedAt", startMs),
|
||||||
: baseQuery;
|
)
|
||||||
const collectFn = (queryForChunk as { collect?: () => Promise<Doc<"tickets">[]> }).collect;
|
: ctx.db
|
||||||
if (typeof collectFn !== "function") {
|
.query("tickets")
|
||||||
throw new ConvexError("Indexed query does not support collect (resolvedAt)");
|
.withIndex("by_tenant_resolved", (q) => q.eq("tenantId", tenantId).gte("resolvedAt", startMs));
|
||||||
}
|
return query.filter((q) => q.lt(q.field("resolvedAt"), endMs)).order("desc");
|
||||||
const page = await collectFn.call(queryForChunk);
|
},
|
||||||
if (!page || page.length === 0) continue;
|
(ticket) => {
|
||||||
for (const ticket of page) {
|
|
||||||
const resolvedAt = typeof ticket.resolvedAt === "number" ? ticket.resolvedAt : null;
|
const resolvedAt = typeof ticket.resolvedAt === "number" ? ticket.resolvedAt : null;
|
||||||
if (resolvedAt === null) continue;
|
if (resolvedAt === null) return;
|
||||||
if (resolvedAt < chunkStart || resolvedAt >= endMs) continue;
|
if (resolvedAt < startMs || resolvedAt >= endMs) return;
|
||||||
results.push(ticket);
|
results.push(ticket);
|
||||||
}
|
},
|
||||||
}
|
);
|
||||||
|
|
||||||
return results;
|
return results;
|
||||||
};
|
|
||||||
|
|
||||||
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))
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchQueues(ctx: QueryCtx, tenantId: string) {
|
async function fetchQueues(ctx: QueryCtx, tenantId: string) {
|
||||||
|
|
@ -320,65 +344,53 @@ type CsatSurvey = {
|
||||||
assigneeName: string | null;
|
assigneeName: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
async function collectCsatSurveys(ctx: QueryCtx, tickets: Doc<"tickets">[]): Promise<CsatSurvey[]> {
|
function collectCsatSurveys(tickets: Doc<"tickets">[]): CsatSurvey[] {
|
||||||
const perTicket = await Promise.all(
|
return tickets
|
||||||
tickets.map(async (ticket) => {
|
.map((ticket) => {
|
||||||
if (typeof ticket.csatScore === "number") {
|
if (typeof ticket.csatScore !== "number") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const snapshot = (ticket.csatAssigneeSnapshot ?? null) as {
|
const snapshot = (ticket.csatAssigneeSnapshot ?? null) as {
|
||||||
name?: string
|
name?: string | null
|
||||||
email?: string
|
email?: string | null
|
||||||
} | null
|
} | null
|
||||||
const assigneeId =
|
const assigneeId =
|
||||||
ticket.csatAssigneeId && typeof ticket.csatAssigneeId === "string"
|
ticket.csatAssigneeId && typeof ticket.csatAssigneeId === "string"
|
||||||
? ticket.csatAssigneeId
|
? ticket.csatAssigneeId
|
||||||
: ticket.csatAssigneeId
|
: ticket.csatAssigneeId
|
||||||
? String(ticket.csatAssigneeId)
|
? String(ticket.csatAssigneeId)
|
||||||
|
: ticket.assigneeId
|
||||||
|
? String(ticket.assigneeId)
|
||||||
: null
|
: null
|
||||||
const assigneeName =
|
const assigneeName = snapshot?.name?.trim?.() || snapshot?.email?.trim?.() || null
|
||||||
snapshot && typeof snapshot.name === "string" && snapshot.name.trim().length > 0
|
const maxScore =
|
||||||
? snapshot.name.trim()
|
typeof ticket.csatMaxScore === "number" && Number.isFinite(ticket.csatMaxScore)
|
||||||
: null
|
? (ticket.csatMaxScore as number)
|
||||||
return [
|
: 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,
|
ticketId: ticket._id,
|
||||||
reference: ticket.reference,
|
reference: ticket.reference,
|
||||||
score: ticket.csatScore,
|
score: ticket.csatScore,
|
||||||
maxScore: ticket.csatMaxScore && Number.isFinite(ticket.csatMaxScore) ? (ticket.csatMaxScore as number) : 5,
|
maxScore,
|
||||||
comment:
|
comment:
|
||||||
typeof ticket.csatComment === "string" && ticket.csatComment.trim().length > 0
|
typeof ticket.csatComment === "string" && ticket.csatComment.trim().length > 0
|
||||||
? ticket.csatComment.trim()
|
? ticket.csatComment.trim()
|
||||||
: null,
|
: null,
|
||||||
receivedAt: ticket.csatRatedAt ?? ticket.updatedAt ?? ticket.createdAt,
|
receivedAt: receivedAtRaw,
|
||||||
assigneeId,
|
assigneeId,
|
||||||
assigneeName,
|
assigneeName,
|
||||||
} satisfies CsatSurvey,
|
} satisfies CsatSurvey;
|
||||||
];
|
|
||||||
}
|
|
||||||
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);
|
.filter(isNotNull);
|
||||||
})
|
|
||||||
);
|
|
||||||
return perTicket.flat();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDateKey(timestamp: number) {
|
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"> }
|
{ tenantId, viewerId, range, companyId }: { tenantId: string; viewerId: Id<"users">; range?: string; companyId?: Id<"companies"> }
|
||||||
) {
|
) {
|
||||||
const viewer = await requireStaff(ctx, viewerId, tenantId);
|
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
|
// Optional range filter (createdAt) for reporting purposes, similar ao backlog/csat
|
||||||
const days = range === "7d" ? 7 : range === "30d" ? 30 : 90;
|
const days = range === "7d" ? 7 : range === "30d" ? 30 : 90;
|
||||||
const end = new Date();
|
const end = new Date();
|
||||||
end.setUTCHours(0, 0, 0, 0);
|
end.setUTCHours(0, 0, 0, 0);
|
||||||
const endMs = end.getTime() + ONE_DAY_MS;
|
const endMs = end.getTime() + ONE_DAY_MS;
|
||||||
const startMs = endMs - days * 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 queues = await fetchQueues(ctx, tenantId);
|
||||||
const categoriesMap = await fetchCategoryMap(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"> }
|
{ tenantId, viewerId, range, companyId }: { tenantId: string; viewerId: Id<"users">; range?: string; companyId?: Id<"companies"> }
|
||||||
) {
|
) {
|
||||||
const viewer = await requireStaff(ctx, viewerId, tenantId);
|
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 days = range === "7d" ? 7 : range === "30d" ? 30 : 90;
|
||||||
const end = new Date();
|
const end = new Date();
|
||||||
end.setUTCHours(0, 0, 0, 0);
|
end.setUTCHours(0, 0, 0, 0);
|
||||||
const endMs = end.getTime() + ONE_DAY_MS;
|
const endMs = end.getTime() + ONE_DAY_MS;
|
||||||
const startMs = endMs - days * 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) => {
|
const normalizeToFive = (value: CsatSurvey) => {
|
||||||
if (!value.maxScore || value.maxScore <= 0) return value.score;
|
if (!value.maxScore || value.maxScore <= 0) return value.score;
|
||||||
|
|
@ -775,7 +783,8 @@ export async function queueLoadTrendHandler(
|
||||||
end.setUTCHours(0, 0, 0, 0)
|
end.setUTCHours(0, 0, 0, 0)
|
||||||
const endMs = end.getTime() + ONE_DAY_MS
|
const endMs = end.getTime() + ONE_DAY_MS
|
||||||
const startMs = endMs - days * 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 queues = await fetchQueues(ctx, tenantId)
|
||||||
|
|
||||||
const queueNames = new Map<string, string>()
|
const queueNames = new Map<string, string>()
|
||||||
|
|
@ -806,9 +815,8 @@ export async function queueLoadTrendHandler(
|
||||||
return stats.get(queueId)!
|
return stats.get(queueId)!
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const ticket of tickets) {
|
for (const ticket of openedTickets) {
|
||||||
const queueId = ticket.queueId ? String(ticket.queueId) : "unassigned"
|
const queueId = ticket.queueId ? String(ticket.queueId) : "unassigned"
|
||||||
if (ticket.createdAt >= startMs && ticket.createdAt < endMs) {
|
|
||||||
const entry = ensureEntry(queueId)
|
const entry = ensureEntry(queueId)
|
||||||
const bucket = entry.series.get(formatDateKey(ticket.createdAt))
|
const bucket = entry.series.get(formatDateKey(ticket.createdAt))
|
||||||
if (bucket) {
|
if (bucket) {
|
||||||
|
|
@ -816,15 +824,18 @@ export async function queueLoadTrendHandler(
|
||||||
}
|
}
|
||||||
entry.openedTotal += 1
|
entry.openedTotal += 1
|
||||||
}
|
}
|
||||||
if (typeof ticket.resolvedAt === "number" && ticket.resolvedAt >= startMs && ticket.resolvedAt < endMs) {
|
|
||||||
|
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 entry = ensureEntry(queueId)
|
||||||
const bucket = entry.series.get(formatDateKey(ticket.resolvedAt))
|
const bucket = entry.series.get(formatDateKey(resolvedAt))
|
||||||
if (bucket) {
|
if (bucket) {
|
||||||
bucket.resolved += 1
|
bucket.resolved += 1
|
||||||
}
|
}
|
||||||
entry.resolvedTotal += 1
|
entry.resolvedTotal += 1
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const maxEntries = Math.max(1, Math.min(limit ?? 3, 6))
|
const maxEntries = Math.max(1, Math.min(limit ?? 3, 6))
|
||||||
const queuesTrend = Array.from(stats.values())
|
const queuesTrend = Array.from(stats.values())
|
||||||
|
|
@ -852,8 +863,6 @@ export async function agentProductivityHandler(
|
||||||
{ tenantId, viewerId, range, companyId }: { tenantId: string; viewerId: Id<"users">; range?: string; companyId?: Id<"companies"> }
|
{ tenantId, viewerId, range, companyId }: { tenantId: string; viewerId: Id<"users">; range?: string; companyId?: Id<"companies"> }
|
||||||
) {
|
) {
|
||||||
const viewer = await requireStaff(ctx, viewerId, tenantId)
|
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 days = range === "7d" ? 7 : range === "30d" ? 30 : 90
|
||||||
const end = new Date()
|
const end = new Date()
|
||||||
|
|
@ -861,7 +870,7 @@ export async function agentProductivityHandler(
|
||||||
const endMs = end.getTime() + ONE_DAY_MS
|
const endMs = end.getTime() + ONE_DAY_MS
|
||||||
const startMs = endMs - days * 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 = {
|
type Acc = {
|
||||||
agentId: Id<"users">
|
agentId: Id<"users">
|
||||||
name: string | null
|
name: string | null
|
||||||
|
|
@ -1075,52 +1084,55 @@ export async function dashboardOverviewHandler(
|
||||||
{ tenantId, viewerId }: { tenantId: string; viewerId: Id<"users"> }
|
{ tenantId, viewerId }: { tenantId: string; viewerId: Id<"users"> }
|
||||||
) {
|
) {
|
||||||
const viewer = await requireStaff(ctx, viewerId, tenantId);
|
const viewer = await requireStaff(ctx, viewerId, tenantId);
|
||||||
const tickets = await fetchScopedTickets(ctx, tenantId, viewer);
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
const lastDayStart = now - ONE_DAY_MS;
|
const lastDayStart = now - ONE_DAY_MS;
|
||||||
const previousDayStart = now - 2 * 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 lastWindowStart = now - 7 * ONE_DAY_MS;
|
||||||
const previousWindowStart = now - 14 * 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(
|
.filter(
|
||||||
(ticket) =>
|
(ticket) =>
|
||||||
ticket.createdAt >= lastWindowStart &&
|
ticket.createdAt >= lastWindowStart &&
|
||||||
ticket.createdAt < now &&
|
ticket.createdAt < now &&
|
||||||
ticket.firstResponseAt
|
ticket.firstResponseAt,
|
||||||
)
|
)
|
||||||
.map((ticket) => (ticket.firstResponseAt! - ticket.createdAt) / 60000);
|
.map((ticket) => (ticket.firstResponseAt! - ticket.createdAt) / 60000);
|
||||||
const firstResponsePrevious = tickets
|
const firstResponsePrevious = recentCreatedTickets
|
||||||
.filter(
|
.filter(
|
||||||
(ticket) =>
|
(ticket) =>
|
||||||
ticket.createdAt >= previousWindowStart &&
|
ticket.createdAt >= previousWindowStart &&
|
||||||
ticket.createdAt < lastWindowStart &&
|
ticket.createdAt < lastWindowStart &&
|
||||||
ticket.firstResponseAt
|
ticket.firstResponseAt,
|
||||||
)
|
)
|
||||||
.map((ticket) => (ticket.firstResponseAt! - ticket.createdAt) / 60000);
|
.map((ticket) => (ticket.firstResponseAt! - ticket.createdAt) / 60000);
|
||||||
|
|
||||||
|
|
@ -1129,23 +1141,21 @@ export async function dashboardOverviewHandler(
|
||||||
const deltaMinutes =
|
const deltaMinutes =
|
||||||
averageWindow !== null && averagePrevious !== null ? averageWindow - averagePrevious : null;
|
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 atRiskTickets = awaitingTickets.filter((ticket) => ticket.dueAt && ticket.dueAt < now);
|
||||||
|
|
||||||
const resolvedLastWindow = tickets.filter(
|
const resolvedLastWindow = recentResolvedTickets.filter(
|
||||||
(ticket) => ticket.resolvedAt && ticket.resolvedAt >= lastWindowStart && ticket.resolvedAt < now
|
(ticket) => ticket.resolvedAt && ticket.resolvedAt >= lastWindowStart && ticket.resolvedAt < now,
|
||||||
);
|
);
|
||||||
const resolvedPreviousWindow = tickets.filter(
|
const resolvedPreviousWindow = recentResolvedTickets.filter(
|
||||||
(ticket) =>
|
(ticket) =>
|
||||||
ticket.resolvedAt &&
|
ticket.resolvedAt &&
|
||||||
ticket.resolvedAt >= previousWindowStart &&
|
ticket.resolvedAt >= previousWindowStart &&
|
||||||
ticket.resolvedAt < lastWindowStart
|
ticket.resolvedAt < lastWindowStart,
|
||||||
);
|
);
|
||||||
const resolutionRate = tickets.length > 0 ? (resolvedLastWindow.length / tickets.length) * 100 : null;
|
const resolutionBase = Math.max(recentCreatedTickets.length, awaitingTickets.length);
|
||||||
const resolutionDelta =
|
const resolutionRate = resolutionBase > 0 ? (resolvedLastWindow.length / resolutionBase) * 100 : null;
|
||||||
resolvedPreviousWindow.length > 0
|
const resolutionDelta = percentageChange(resolvedLastWindow.length, resolvedPreviousWindow.length);
|
||||||
? ((resolvedLastWindow.length - resolvedPreviousWindow.length) / resolvedPreviousWindow.length) * 100
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
newTickets: {
|
newTickets: {
|
||||||
|
|
@ -1187,14 +1197,13 @@ export async function ticketsByChannelHandler(
|
||||||
{ tenantId, viewerId, range, companyId }: { tenantId: string; viewerId: Id<"users">; range?: string; companyId?: Id<"companies"> }
|
{ tenantId, viewerId, range, companyId }: { tenantId: string; viewerId: Id<"users">; range?: string; companyId?: Id<"companies"> }
|
||||||
) {
|
) {
|
||||||
const viewer = await requireStaff(ctx, viewerId, tenantId);
|
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 days = range === "7d" ? 7 : range === "30d" ? 30 : 90;
|
||||||
|
|
||||||
const end = new Date();
|
const end = new Date();
|
||||||
end.setUTCHours(0, 0, 0, 0);
|
end.setUTCHours(0, 0, 0, 0);
|
||||||
const endMs = end.getTime() + ONE_DAY_MS;
|
const endMs = end.getTime() + ONE_DAY_MS;
|
||||||
const startMs = endMs - days * 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>>();
|
const timeline = new Map<string, Map<string, number>>();
|
||||||
for (let ts = startMs; ts < endMs; ts += ONE_DAY_MS) {
|
for (let ts = startMs; ts < endMs; ts += ONE_DAY_MS) {
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { mutation, query } from "./_generated/server";
|
||||||
import { api } from "./_generated/api";
|
import { api } from "./_generated/api";
|
||||||
import type { MutationCtx, QueryCtx } from "./_generated/server";
|
import type { MutationCtx, QueryCtx } from "./_generated/server";
|
||||||
import { ConvexError, v } from "convex/values";
|
import { ConvexError, v } from "convex/values";
|
||||||
import { Id, type Doc } from "./_generated/dataModel";
|
import { Id, type Doc, type TableNames } from "./_generated/dataModel";
|
||||||
|
|
||||||
import { requireAdmin, requireStaff, requireUser } from "./rbac";
|
import { requireAdmin, requireStaff, requireUser } from "./rbac";
|
||||||
import {
|
import {
|
||||||
|
|
@ -1379,12 +1379,12 @@ function getCustomFieldRecordEntry(
|
||||||
return Object.prototype.hasOwnProperty.call(record, key) ? record[key] : undefined;
|
return Object.prototype.hasOwnProperty.call(record, key) ? record[key] : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_TICKETS_LIST_LIMIT = 250;
|
const DEFAULT_TICKETS_LIST_LIMIT = 120;
|
||||||
const MIN_TICKETS_LIST_LIMIT = 25;
|
const MIN_TICKETS_LIST_LIMIT = 25;
|
||||||
const MAX_TICKETS_LIST_LIMIT = 600;
|
const MAX_TICKETS_LIST_LIMIT = 400;
|
||||||
const MAX_FETCH_LIMIT = 1000;
|
const MAX_FETCH_LIMIT = 400;
|
||||||
const FETCH_MULTIPLIER_NO_SEARCH = 3;
|
const BASE_FETCH_PADDING = 50;
|
||||||
const FETCH_MULTIPLIER_WITH_SEARCH = 5;
|
const SEARCH_FETCH_PADDING = 200;
|
||||||
|
|
||||||
function clampTicketLimit(limit: number) {
|
function clampTicketLimit(limit: number) {
|
||||||
if (!Number.isFinite(limit)) return DEFAULT_TICKETS_LIST_LIMIT;
|
if (!Number.isFinite(limit)) return DEFAULT_TICKETS_LIST_LIMIT;
|
||||||
|
|
@ -1392,11 +1392,31 @@ function clampTicketLimit(limit: number) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function computeFetchLimit(limit: number, hasSearch: boolean) {
|
function computeFetchLimit(limit: number, hasSearch: boolean) {
|
||||||
const multiplier = hasSearch ? FETCH_MULTIPLIER_WITH_SEARCH : FETCH_MULTIPLIER_NO_SEARCH;
|
const padding = hasSearch ? SEARCH_FETCH_PADDING : BASE_FETCH_PADDING;
|
||||||
const target = limit * multiplier;
|
const target = limit + padding;
|
||||||
return Math.max(limit, Math.min(MAX_FETCH_LIMIT, target));
|
return Math.max(limit, Math.min(MAX_FETCH_LIMIT, target));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadDocs<TableName extends TableNames>(
|
||||||
|
ctx: QueryCtx,
|
||||||
|
ids: (Id<TableName> | null | undefined)[],
|
||||||
|
): Promise<Map<string, Doc<TableName>>> {
|
||||||
|
const uniqueIds = Array.from(
|
||||||
|
new Set(ids.filter((value): value is Id<TableName> => Boolean(value))),
|
||||||
|
);
|
||||||
|
if (uniqueIds.length === 0) {
|
||||||
|
return new Map();
|
||||||
|
}
|
||||||
|
const docs = await Promise.all(uniqueIds.map((id) => ctx.db.get(id)));
|
||||||
|
const map = new Map<string, Doc<TableName>>();
|
||||||
|
docs.forEach((doc, index) => {
|
||||||
|
if (doc) {
|
||||||
|
map.set(String(uniqueIds[index]), doc);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
function dedupeTicketsById(tickets: Doc<"tickets">[]) {
|
function dedupeTicketsById(tickets: Doc<"tickets">[]) {
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const result: Doc<"tickets">[] = [];
|
const result: Doc<"tickets">[] = [];
|
||||||
|
|
@ -1515,63 +1535,84 @@ export const list = query({
|
||||||
}
|
}
|
||||||
|
|
||||||
const limited = filtered.slice(0, requestedLimit);
|
const limited = filtered.slice(0, requestedLimit);
|
||||||
const categoryCache = new Map<string, Doc<"ticketCategories"> | null>();
|
if (limited.length === 0) {
|
||||||
const subcategoryCache = new Map<string, Doc<"ticketSubcategories"> | null>();
|
return [];
|
||||||
const machineCache = new Map<string, Doc<"machines"> | null>();
|
|
||||||
// hydrate requester and assignee
|
|
||||||
const result = await Promise.all(
|
|
||||||
limited.map(async (t) => {
|
|
||||||
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 company = t.companyId ? ((await ctx.db.get(t.companyId)) as Doc<"companies"> | null) : null;
|
|
||||||
const queueName = normalizeQueueName(queue);
|
|
||||||
const activeSession = t.activeSessionId ? await ctx.db.get(t.activeSessionId) : null;
|
|
||||||
let categorySummary: { id: Id<"ticketCategories">; name: string } | null = null;
|
|
||||||
let subcategorySummary: { id: Id<"ticketSubcategories">; name: string } | null = null;
|
|
||||||
if (t.categoryId) {
|
|
||||||
if (!categoryCache.has(t.categoryId)) {
|
|
||||||
categoryCache.set(t.categoryId, await ctx.db.get(t.categoryId));
|
|
||||||
}
|
|
||||||
const category = categoryCache.get(t.categoryId);
|
|
||||||
if (category) {
|
|
||||||
categorySummary = { id: category._id, name: category.name };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (t.subcategoryId) {
|
|
||||||
if (!subcategoryCache.has(t.subcategoryId)) {
|
|
||||||
subcategoryCache.set(t.subcategoryId, await ctx.db.get(t.subcategoryId));
|
|
||||||
}
|
|
||||||
const subcategory = subcategoryCache.get(t.subcategoryId);
|
|
||||||
if (subcategory) {
|
|
||||||
subcategorySummary = { id: subcategory._id, name: subcategory.name };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const [
|
||||||
|
requesterDocs,
|
||||||
|
assigneeDocs,
|
||||||
|
queueDocs,
|
||||||
|
companyDocs,
|
||||||
|
machineDocs,
|
||||||
|
activeSessionDocs,
|
||||||
|
categoryDocs,
|
||||||
|
subcategoryDocs,
|
||||||
|
] = await Promise.all([
|
||||||
|
loadDocs(ctx, limited.map((t) => t.requesterId)),
|
||||||
|
loadDocs(ctx, limited.map((t) => (t.assigneeId as Id<"users"> | null) ?? null)),
|
||||||
|
loadDocs(ctx, limited.map((t) => (t.queueId as Id<"queues"> | null) ?? null)),
|
||||||
|
loadDocs(ctx, limited.map((t) => (t.companyId as Id<"companies"> | null) ?? null)),
|
||||||
|
loadDocs(ctx, limited.map((t) => (t.machineId as Id<"machines"> | null) ?? null)),
|
||||||
|
loadDocs(ctx, limited.map((t) => (t.activeSessionId as Id<"ticketWorkSessions"> | null) ?? null)),
|
||||||
|
loadDocs(ctx, limited.map((t) => (t.categoryId as Id<"ticketCategories"> | null) ?? null)),
|
||||||
|
loadDocs(ctx, limited.map((t) => (t.subcategoryId as Id<"ticketSubcategories"> | null) ?? null)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const serverNow = Date.now();
|
||||||
|
const result = limited.map((t) => {
|
||||||
|
const requesterSnapshot = t.requesterSnapshot as UserSnapshot | undefined;
|
||||||
|
const requesterDoc = requesterDocs.get(String(t.requesterId)) ?? null;
|
||||||
|
const requesterSummary = requesterDoc
|
||||||
|
? buildRequesterSummary(requesterDoc, t.requesterId, { ticketId: t._id })
|
||||||
|
: buildRequesterFromSnapshot(t.requesterId, requesterSnapshot, { ticketId: t._id });
|
||||||
|
|
||||||
|
const assigneeDoc = t.assigneeId
|
||||||
|
? assigneeDocs.get(String(t.assigneeId)) ?? null
|
||||||
|
: null;
|
||||||
|
const assigneeSummary = t.assigneeId
|
||||||
|
? assigneeDoc
|
||||||
|
? {
|
||||||
|
id: assigneeDoc._id,
|
||||||
|
name: assigneeDoc.name,
|
||||||
|
email: assigneeDoc.email,
|
||||||
|
avatarUrl: assigneeDoc.avatarUrl,
|
||||||
|
teams: normalizeTeams(assigneeDoc.teams),
|
||||||
}
|
}
|
||||||
|
: buildAssigneeFromSnapshot(t.assigneeId, t.assigneeSnapshot ?? undefined)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const queueDoc = t.queueId ? queueDocs.get(String(t.queueId)) ?? null : null;
|
||||||
|
const queueName = normalizeQueueName(queueDoc);
|
||||||
|
|
||||||
|
const companyDoc = t.companyId ? companyDocs.get(String(t.companyId)) ?? null : null;
|
||||||
|
const companySummary = companyDoc
|
||||||
|
? { id: companyDoc._id, name: companyDoc.name, isAvulso: companyDoc.isAvulso ?? false }
|
||||||
|
: t.companyId || t.companySnapshot
|
||||||
|
? buildCompanyFromSnapshot(t.companyId as Id<"companies"> | undefined, t.companySnapshot ?? undefined)
|
||||||
|
: null;
|
||||||
|
|
||||||
const machineSnapshot = t.machineSnapshot as
|
const machineSnapshot = t.machineSnapshot as
|
||||||
| {
|
| {
|
||||||
hostname?: string
|
hostname?: string;
|
||||||
persona?: string
|
persona?: string;
|
||||||
assignedUserName?: string
|
assignedUserName?: string;
|
||||||
assignedUserEmail?: string
|
assignedUserEmail?: string;
|
||||||
status?: string
|
status?: string;
|
||||||
}
|
}
|
||||||
| undefined;
|
| undefined;
|
||||||
|
const machineDoc = t.machineId ? machineDocs.get(String(t.machineId)) ?? null : null;
|
||||||
let machineSummary:
|
let machineSummary:
|
||||||
| {
|
| {
|
||||||
id: Id<"machines"> | null
|
id: Id<"machines"> | null;
|
||||||
hostname: string | null
|
hostname: string | null;
|
||||||
persona: string | null
|
persona: string | null;
|
||||||
assignedUserName: string | null
|
assignedUserName: string | null;
|
||||||
assignedUserEmail: string | null
|
assignedUserEmail: string | null;
|
||||||
status: string | null
|
status: string | null;
|
||||||
}
|
}
|
||||||
| null = null;
|
| null = null;
|
||||||
if (t.machineId) {
|
if (t.machineId) {
|
||||||
const cacheKey = String(t.machineId);
|
|
||||||
if (!machineCache.has(cacheKey)) {
|
|
||||||
machineCache.set(cacheKey, (await ctx.db.get(t.machineId)) as Doc<"machines"> | null);
|
|
||||||
}
|
|
||||||
const machineDoc = machineCache.get(cacheKey);
|
|
||||||
machineSummary = {
|
machineSummary = {
|
||||||
id: t.machineId,
|
id: t.machineId,
|
||||||
hostname: machineDoc?.hostname ?? machineSnapshot?.hostname ?? null,
|
hostname: machineDoc?.hostname ?? machineSnapshot?.hostname ?? null,
|
||||||
|
|
@ -1590,7 +1631,31 @@ export const list = query({
|
||||||
status: machineSnapshot.status ?? null,
|
status: machineSnapshot.status ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const serverNow = Date.now()
|
|
||||||
|
const categoryDoc = t.categoryId ? categoryDocs.get(String(t.categoryId)) ?? null : null;
|
||||||
|
const categorySummary = categoryDoc
|
||||||
|
? { id: categoryDoc._id, name: categoryDoc.name }
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const subcategoryDoc = t.subcategoryId
|
||||||
|
? subcategoryDocs.get(String(t.subcategoryId)) ?? null
|
||||||
|
: null;
|
||||||
|
const subcategorySummary = subcategoryDoc
|
||||||
|
? { id: subcategoryDoc._id, name: subcategoryDoc.name, categoryId: subcategoryDoc.categoryId }
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const activeSessionDoc = t.activeSessionId
|
||||||
|
? activeSessionDocs.get(String(t.activeSessionId)) ?? null
|
||||||
|
: null;
|
||||||
|
const activeSession = activeSessionDoc
|
||||||
|
? {
|
||||||
|
id: activeSessionDoc._id,
|
||||||
|
agentId: activeSessionDoc.agentId,
|
||||||
|
startedAt: activeSessionDoc.startedAt,
|
||||||
|
workType: activeSessionDoc.workType ?? "INTERNAL",
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: t._id,
|
id: t._id,
|
||||||
reference: t.reference,
|
reference: t.reference,
|
||||||
|
|
@ -1603,34 +1668,20 @@ export const list = query({
|
||||||
queue: queueName,
|
queue: queueName,
|
||||||
csatScore: typeof t.csatScore === "number" ? t.csatScore : null,
|
csatScore: typeof t.csatScore === "number" ? t.csatScore : null,
|
||||||
csatMaxScore: typeof t.csatMaxScore === "number" ? t.csatMaxScore : null,
|
csatMaxScore: typeof t.csatMaxScore === "number" ? t.csatMaxScore : null,
|
||||||
csatComment: typeof t.csatComment === "string" && t.csatComment.trim().length > 0 ? t.csatComment.trim() : null,
|
csatComment:
|
||||||
|
typeof t.csatComment === "string" && t.csatComment.trim().length > 0
|
||||||
|
? t.csatComment.trim()
|
||||||
|
: null,
|
||||||
csatRatedAt: t.csatRatedAt ?? null,
|
csatRatedAt: t.csatRatedAt ?? null,
|
||||||
csatRatedBy: t.csatRatedBy ? String(t.csatRatedBy) : null,
|
csatRatedBy: t.csatRatedBy ? String(t.csatRatedBy) : null,
|
||||||
formTemplate: t.formTemplate ?? null,
|
formTemplate: t.formTemplate ?? null,
|
||||||
formTemplateLabel: resolveFormTemplateLabel(t.formTemplate ?? null, t.formTemplateLabel ?? null),
|
formTemplateLabel: resolveFormTemplateLabel(
|
||||||
company: company
|
t.formTemplate ?? null,
|
||||||
? { id: company._id, name: company.name, isAvulso: company.isAvulso ?? false }
|
t.formTemplateLabel ?? null,
|
||||||
: t.companyId || t.companySnapshot
|
|
||||||
? buildCompanyFromSnapshot(t.companyId as Id<"companies"> | undefined, t.companySnapshot ?? undefined)
|
|
||||||
: null,
|
|
||||||
requester: requester
|
|
||||||
? buildRequesterSummary(requester, t.requesterId, { ticketId: t._id })
|
|
||||||
: buildRequesterFromSnapshot(
|
|
||||||
t.requesterId,
|
|
||||||
t.requesterSnapshot ?? undefined,
|
|
||||||
{ ticketId: t._id }
|
|
||||||
),
|
),
|
||||||
assignee: t.assigneeId
|
company: companySummary,
|
||||||
? assignee
|
requester: requesterSummary,
|
||||||
? {
|
assignee: assigneeSummary,
|
||||||
id: assignee._id,
|
|
||||||
name: assignee.name,
|
|
||||||
email: assignee.email,
|
|
||||||
avatarUrl: assignee.avatarUrl,
|
|
||||||
teams: normalizeTeams(assignee.teams),
|
|
||||||
}
|
|
||||||
: buildAssigneeFromSnapshot(t.assigneeId, t.assigneeSnapshot ?? undefined)
|
|
||||||
: null,
|
|
||||||
slaPolicy: null,
|
slaPolicy: null,
|
||||||
dueAt: t.dueAt ?? null,
|
dueAt: t.dueAt ?? null,
|
||||||
firstResponseAt: t.firstResponseAt ?? null,
|
firstResponseAt: t.firstResponseAt ?? null,
|
||||||
|
|
@ -1648,18 +1699,11 @@ export const list = query({
|
||||||
internalWorkedMs: t.internalWorkedMs ?? 0,
|
internalWorkedMs: t.internalWorkedMs ?? 0,
|
||||||
externalWorkedMs: t.externalWorkedMs ?? 0,
|
externalWorkedMs: t.externalWorkedMs ?? 0,
|
||||||
serverNow,
|
serverNow,
|
||||||
activeSession: activeSession
|
activeSession,
|
||||||
? {
|
|
||||||
id: activeSession._id,
|
|
||||||
agentId: activeSession.agentId,
|
|
||||||
startedAt: activeSession.startedAt,
|
|
||||||
workType: activeSession.workType ?? "INTERNAL",
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
})
|
});
|
||||||
);
|
|
||||||
// sort by updatedAt desc
|
// sort by updatedAt desc
|
||||||
return result.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
|
return result.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -85,7 +85,7 @@ services:
|
||||||
failure_action: rollback
|
failure_action: rollback
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
memory: "4G"
|
memory: "5G"
|
||||||
restart_policy:
|
restart_policy:
|
||||||
condition: any
|
condition: any
|
||||||
placement:
|
placement:
|
||||||
|
|
@ -114,7 +114,7 @@ services:
|
||||||
- NEXT_PUBLIC_DEPLOYMENT_URL=https://convex.esdrasrenan.com.br
|
- NEXT_PUBLIC_DEPLOYMENT_URL=https://convex.esdrasrenan.com.br
|
||||||
deploy:
|
deploy:
|
||||||
mode: replicated
|
mode: replicated
|
||||||
replicas: 1
|
replicas: 0
|
||||||
placement:
|
placement:
|
||||||
constraints:
|
constraints:
|
||||||
- node.role == manager
|
- node.role == manager
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue