reports: calls per day, missed, busiest hours, queue table
GET /api/reports/summary and /api/reports/queues (company-time-zone days, last 30 by default, a year at most) and the Reports page: totals, stacked bars per day, missed per day, a weekday x hour heatmap and the queue table. Plain divs, no chart library. Task: M11-T6
This commit is contained in:
1 parent
60846afddd
commit
29ee4ba105
13 files changed
+714
-3
No files matched your search
@@ -37,6 +37,7 @@ import { callRoutes } from './routes/calls.ts';
|
||||
import { recordingRoutes } from './routes/recordings.ts';
|
||||
import { liveCallRoutes } from './routes/livecalls.ts';
|
||||
import { integrationRoutes } from './routes/integrations.ts';
|
||||
import { reportRoutes } from './routes/reports.ts';
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
@@ -147,6 +148,7 @@ export function buildApp(config: Config, db: Db, publicDir = resolve('public'))
|
||||
app.register(recordingRoutes);
|
||||
app.register(liveCallRoutes);
|
||||
app.register(integrationRoutes);
|
||||
app.register(reportRoutes);
|
||||
|
||||
// Always registered: reply.sendFile also streams voicemail audio (with Range) from the spool.
|
||||
app.register(fastifyStatic, {
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// /api/reports (04 §7.3, 05 §2 Reports): calls per day, busiest hours, per-queue stats. Admin only.
|
||||
import { and, asc, sql } from 'drizzle-orm';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import {
|
||||
CALL_DIRECTIONS,
|
||||
ReportRangeSchema,
|
||||
type ListResponse,
|
||||
type QueueReportRow,
|
||||
type ReportSummary,
|
||||
} from '@selfphoneng/shared';
|
||||
import { requireAdmin } from '../auth/guards.ts';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { calls, queues } from '../db/schema.ts';
|
||||
import { badRequest } from '../errors.ts';
|
||||
import { todayIn } from '../generator/load.ts';
|
||||
import { queueStats } from '../services/queuestats.ts';
|
||||
import { getSetting } from '../services/settings.ts';
|
||||
|
||||
const addDays = (d: string, n: number) =>
|
||||
new Date(Date.parse(`${d}T00:00:00Z`) + n * 86_400_000).toISOString().slice(0, 10);
|
||||
|
||||
/** Whole days in the company time zone; the last 30 days by default, a year at most. */
|
||||
async function range(db: Db, query: unknown) {
|
||||
const r = ReportRangeSchema.parse(query);
|
||||
const tz = await getSetting(db, 'general.timezone');
|
||||
const to = r.to ?? (r.from ? addDays(r.from, 29) : todayIn(tz));
|
||||
const from = r.from ?? addDays(to, -29);
|
||||
if (addDays(from, 366) <= to) throw badRequest('Pick at most a year');
|
||||
return { tz, from, to };
|
||||
}
|
||||
|
||||
export async function reportRoutes(app: FastifyInstance) {
|
||||
const { db } = app;
|
||||
app.addHook('preHandler', requireAdmin);
|
||||
|
||||
app.get('/api/reports/summary', async (req): Promise<ReportSummary> => {
|
||||
const { tz, from, to } = await range(db, req.query);
|
||||
const where = and(
|
||||
sql`${calls.startedAt} >= (${from}::date)::timestamp at time zone ${tz}`,
|
||||
sql`${calls.startedAt} < ((${to}::date + 1)::timestamp at time zone ${tz})`,
|
||||
);
|
||||
const local = sql`(${calls.startedAt} at time zone ${tz})`;
|
||||
const missed = sql`${calls.status} in ('missed', 'voicemail', 'abandoned')`;
|
||||
const [perDay, perHour, [t]] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
date: sql<string>`to_char(${local}, 'YYYY-MM-DD')`,
|
||||
direction: calls.direction,
|
||||
n: sql<number>`count(*)::int`,
|
||||
missed: sql<number>`count(*) filter (where ${missed})::int`,
|
||||
})
|
||||
.from(calls)
|
||||
.where(where)
|
||||
.groupBy(sql`1`, calls.direction),
|
||||
db
|
||||
.select({
|
||||
dow: sql<number>`extract(dow from ${local})::int`,
|
||||
hour: sql<number>`extract(hour from ${local})::int`,
|
||||
n: sql<number>`count(*)::int`,
|
||||
})
|
||||
.from(calls)
|
||||
.where(where)
|
||||
.groupBy(sql`1`, sql`2`),
|
||||
db
|
||||
.select({
|
||||
calls: sql<number>`count(*)::int`,
|
||||
missed: sql<number>`count(*) filter (where ${missed})::int`,
|
||||
avgTalk: sql<
|
||||
number | null
|
||||
>`round(avg(${calls.talkSeconds}) filter (where ${calls.status} = 'answered'))::int`,
|
||||
})
|
||||
.from(calls)
|
||||
.where(where),
|
||||
]);
|
||||
const days: ReportSummary['days'] = [];
|
||||
for (let d = from; d <= to; d = addDays(d, 1)) {
|
||||
const rows = perDay.filter((r) => r.date === d);
|
||||
days.push({
|
||||
date: d,
|
||||
...(Object.fromEntries(
|
||||
CALL_DIRECTIONS.map((dir) => [dir, rows.find((r) => r.direction === dir)?.n ?? 0]),
|
||||
) as Record<(typeof CALL_DIRECTIONS)[number], number>),
|
||||
missed: rows.reduce((s, r) => s + r.missed, 0),
|
||||
});
|
||||
}
|
||||
const hours = Array.from({ length: 7 }, () => Array<number>(24).fill(0));
|
||||
for (const h of perHour) hours[h.dow]![h.hour] = h.n;
|
||||
return {
|
||||
from,
|
||||
to,
|
||||
days,
|
||||
totals: { calls: t!.calls, missed: t!.missed, avgTalkSeconds: t!.avgTalk },
|
||||
hours,
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/api/reports/queues', async (req): Promise<ListResponse<QueueReportRow>> => {
|
||||
const { from, to } = await range(db, req.query);
|
||||
const qs = await db.select().from(queues).orderBy(asc(queues.number));
|
||||
return {
|
||||
nextCursor: null,
|
||||
items: await Promise.all(
|
||||
qs.map(async (q) => ({
|
||||
id: q.id,
|
||||
number: q.number,
|
||||
name: q.name,
|
||||
slaSeconds: q.slaSeconds,
|
||||
...(await queueStats(db, q, { from, to })),
|
||||
})),
|
||||
),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// M11-T6: /api/reports/summary and /api/reports/queues (04 §7.3), days in the company time zone.
|
||||
import type { LightMyRequestResponse } from 'fastify';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { calls, queueCalls, queues } from '../src/db/schema.ts';
|
||||
import { setSetting } from '../src/services/settings.ts';
|
||||
import { hasTestDb } from './db.ts';
|
||||
import { ADMIN, H, createTestApp } from './app.ts';
|
||||
|
||||
describe.skipIf(!hasTestDb)('/api/reports', () => {
|
||||
let t: Awaited<ReturnType<typeof createTestApp>>;
|
||||
let get: (url: string, cookie?: string) => Promise<LightMyRequestResponse>;
|
||||
let admin: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await createTestApp();
|
||||
await t.clearMustChange(ADMIN.email);
|
||||
admin = await t.login(ADMIN.email, ADMIN.password);
|
||||
get = (url, cookie = admin) => t.app.inject({ method: 'GET', url, headers: { ...H, cookie } });
|
||||
await setSetting(t.db, 'general.timezone', 'America/Chicago'); // UTC-5 in May
|
||||
const call = (
|
||||
linkedid: string,
|
||||
at: string,
|
||||
direction: 'inbound' | 'outbound' | 'internal',
|
||||
status: 'answered' | 'missed' | 'voicemail' | 'busy',
|
||||
talkSeconds = 0,
|
||||
) => ({
|
||||
linkedid,
|
||||
direction,
|
||||
status,
|
||||
startedAt: new Date(at),
|
||||
endedAt: new Date(at),
|
||||
talkSeconds,
|
||||
});
|
||||
await t.db.insert(calls).values([
|
||||
call('a', '2026-05-04T14:00:00Z', 'inbound', 'answered', 60), // Mon 09:00 local
|
||||
call('b', '2026-05-04T14:30:00Z', 'inbound', 'missed'), // Mon 09:30
|
||||
call('c', '2026-05-04T15:00:00Z', 'outbound', 'answered', 120), // Mon 10:00
|
||||
call('d', '2026-05-05T03:00:00Z', 'inbound', 'voicemail'), // still Mon, 22:00
|
||||
call('e', '2026-05-06T13:00:00Z', 'internal', 'busy'), // Wed 08:00
|
||||
call('f', '2026-05-09T12:00:00Z', 'inbound', 'answered', 30), // outside the range
|
||||
]);
|
||||
const [q] = await t.db
|
||||
.insert(queues)
|
||||
.values({ number: '550', name: 'Sales', timeoutDestination: { type: 'hangup' } })
|
||||
.returning();
|
||||
await t.db.insert(queueCalls).values([
|
||||
{
|
||||
queueId: q!.id,
|
||||
uniqueid: '1',
|
||||
linkedid: '1',
|
||||
joinedAt: new Date('2026-05-04T14:00:00Z'),
|
||||
outcome: 'answered',
|
||||
waitSeconds: 5,
|
||||
},
|
||||
{
|
||||
queueId: q!.id,
|
||||
uniqueid: '2',
|
||||
linkedid: '2',
|
||||
joinedAt: new Date('2026-05-04T14:05:00Z'),
|
||||
outcome: 'abandoned',
|
||||
waitSeconds: 40,
|
||||
},
|
||||
{
|
||||
queueId: q!.id,
|
||||
uniqueid: '3',
|
||||
linkedid: '3',
|
||||
joinedAt: new Date('2026-05-09T14:05:00Z'),
|
||||
outcome: 'answered',
|
||||
waitSeconds: 1,
|
||||
},
|
||||
]);
|
||||
});
|
||||
afterAll(() => t?.close());
|
||||
|
||||
it('summary: per day by direction, missed, average talk, busiest hours', async () => {
|
||||
const r = await get('/api/reports/summary?from=2026-05-04&to=2026-05-06');
|
||||
expect(r.statusCode, r.body).toBe(200);
|
||||
const s = r.json();
|
||||
expect(s.days).toEqual([
|
||||
{ date: '2026-05-04', inbound: 3, outbound: 1, internal: 0, missed: 2 },
|
||||
{ date: '2026-05-05', inbound: 0, outbound: 0, internal: 0, missed: 0 },
|
||||
{ date: '2026-05-06', inbound: 0, outbound: 0, internal: 1, missed: 0 },
|
||||
]);
|
||||
expect(s.totals).toEqual({ calls: 5, missed: 2, avgTalkSeconds: 90 });
|
||||
expect(s.hours).toHaveLength(7);
|
||||
expect(s.hours[1][9]).toBe(2);
|
||||
expect(s.hours[1][10]).toBe(1);
|
||||
expect(s.hours[1][22]).toBe(1);
|
||||
expect(s.hours[3][8]).toBe(1);
|
||||
expect(s.hours.flat().reduce((a: number, b: number) => a + b)).toBe(5);
|
||||
});
|
||||
|
||||
it('queues: offered, answered, abandoned, wait and service level', async () => {
|
||||
const r = await get('/api/reports/queues?from=2026-05-04&to=2026-05-06');
|
||||
expect(r.json().items).toEqual([
|
||||
expect.objectContaining({
|
||||
number: '550',
|
||||
name: 'Sales',
|
||||
offered: 2,
|
||||
answered: 1,
|
||||
abandoned: 1,
|
||||
avgWaitSeconds: 5,
|
||||
withinSlaPercent: 50,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('defaults to the last 30 days; bad ranges and non-admins are refused', async () => {
|
||||
const s = (await get('/api/reports/summary')).json();
|
||||
expect(s.days).toHaveLength(30);
|
||||
expect(s.days.at(-1).date).toBe(s.to);
|
||||
expect((await get('/api/reports/summary?from=2026-05-06&to=2026-05-04')).statusCode).toBe(400);
|
||||
expect((await get('/api/reports/summary?from=2025-01-01&to=2026-05-04')).statusCode).toBe(400);
|
||||
expect((await get('/api/reports/summary?from=nope')).statusCode).toBe(400);
|
||||
await t.addUser('pat@example.com', 'pat-password-123');
|
||||
const pat = await t.login('pat@example.com', 'pat-password-123');
|
||||
expect((await get('/api/reports/queues', pat)).statusCode).toBe(403);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user