Users get a Reports tab: the admin summary limited to calls that rang or were answered by them. Answered and talk time are the calls they picked up.
202 lines
7.9 KiB
TypeScript
202 lines
7.9 KiB
TypeScript
// 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;
|
|
// /api/me/reports/summary, the calls made to me.
|
|
import type { LightMyRequestResponse } from 'fastify';
|
|
import { inArray } from 'drizzle-orm';
|
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
import { callExtensions, calls, extensions, queueCalls, queues } from '../src/db/schema.ts';
|
|
import { encrypt } from '../src/crypto/secrets.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,
|
|
lineSeconds = talkSeconds, // arrival to hang-up
|
|
) => ({
|
|
linkedid,
|
|
direction,
|
|
status,
|
|
startedAt: new Date(at),
|
|
endedAt: new Date(Date.parse(at) + lineSeconds * 1000),
|
|
talkSeconds,
|
|
});
|
|
await t.db.insert(calls).values([
|
|
call('a', '2026-05-04T14:00:00Z', 'inbound', 'answered', 60, 75), // Mon 09:00 local
|
|
call('b', '2026-05-04T14:30:00Z', 'inbound', 'missed', 0, 20), // Mon 09:30
|
|
call('c', '2026-05-04T15:00:00Z', 'outbound', 'answered', 120, 130), // Mon 10:00
|
|
call('d', '2026-05-05T03:00:00Z', 'inbound', 'voicemail', 0, 40), // still Mon, 22:00
|
|
call('e', '2026-05-06T13:00:00Z', 'internal', 'busy', 0, 5), // 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([
|
|
// outside: inbound 75 + 20 + 40 s from arrival, outbound 120 s talk = 255 s, 4 minutes
|
|
{ date: '2026-05-04', inbound: 3, outbound: 1, internal: 0, missed: 2, outsideMinutes: 4 },
|
|
{ date: '2026-05-05', inbound: 0, outbound: 0, internal: 0, missed: 0, outsideMinutes: 0 },
|
|
{ date: '2026-05-06', inbound: 0, outbound: 0, internal: 1, missed: 0, outsideMinutes: 0 },
|
|
]);
|
|
expect(s.totals).toEqual({
|
|
calls: 5,
|
|
answered: 2,
|
|
missed: 2,
|
|
avgTalkSeconds: 90,
|
|
talkSeconds: 180,
|
|
outsideSeconds: { inbound: 135, outbound: 120 },
|
|
});
|
|
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);
|
|
expect((await get('/api/me/reports/summary', pat)).statusCode).toBe(404); // no extension
|
|
});
|
|
|
|
it('mine: only calls made to me; answered and talk time are the ones I picked up', async () => {
|
|
const sam = await t.addUser('sam@example.com', 'sam-password-123');
|
|
const [mine, other] = await t.db
|
|
.insert(extensions)
|
|
.values(
|
|
(['103', '104'] as const).map((number) => ({
|
|
number,
|
|
name: number,
|
|
sipPasswordEnc: encrypt('x'),
|
|
voicemailPinEnc: encrypt('4826'),
|
|
userId: number === '103' ? sam.id : null,
|
|
})),
|
|
)
|
|
.returning();
|
|
await t.db.insert(calls).values({
|
|
linkedid: 'g',
|
|
direction: 'internal',
|
|
status: 'answered',
|
|
startedAt: new Date('2026-05-05T15:00:00Z'), // Tue 10:00
|
|
endedAt: new Date('2026-05-05T15:00:45Z'),
|
|
talkSeconds: 40,
|
|
answeredByExtensionId: mine!.id,
|
|
});
|
|
const ids = Object.fromEntries(
|
|
(
|
|
await t.db
|
|
.select({ id: calls.id, linkedid: calls.linkedid })
|
|
.from(calls)
|
|
.where(inArray(calls.linkedid, ['a', 'b', 'c', 'g']))
|
|
).map((c) => [c.linkedid, c.id]),
|
|
);
|
|
// a: rang me and a coworker, the coworker answered. b: rang me, missed. c: I made it. g: mine.
|
|
await t.db
|
|
.update(calls)
|
|
.set({ answeredByExtensionId: other!.id })
|
|
.where(inArray(calls.id, [ids.a!]));
|
|
await t.db.insert(callExtensions).values([
|
|
{ callId: ids.a!, extensionId: mine!.id, role: 'rang' },
|
|
{ callId: ids.a!, extensionId: other!.id, role: 'answered' },
|
|
{ callId: ids.b!, extensionId: mine!.id, role: 'rang' },
|
|
{ callId: ids.c!, extensionId: mine!.id, role: 'caller' },
|
|
{ callId: ids.g!, extensionId: mine!.id, role: 'rang' },
|
|
{ callId: ids.g!, extensionId: mine!.id, role: 'answered' },
|
|
]);
|
|
const cookie = await t.login('sam@example.com', 'sam-password-123');
|
|
const r = await get('/api/me/reports/summary?from=2026-05-04&to=2026-05-06', cookie);
|
|
expect(r.statusCode, r.body).toBe(200);
|
|
const s = r.json();
|
|
expect(s.days).toEqual([
|
|
{ date: '2026-05-04', inbound: 2, outbound: 0, internal: 0, missed: 1, outsideMinutes: 2 },
|
|
{ date: '2026-05-05', inbound: 0, outbound: 0, internal: 1, missed: 0, outsideMinutes: 0 },
|
|
{ date: '2026-05-06', inbound: 0, outbound: 0, internal: 0, missed: 0, outsideMinutes: 0 },
|
|
]);
|
|
expect(s.totals).toEqual({
|
|
calls: 3,
|
|
answered: 1,
|
|
missed: 1,
|
|
avgTalkSeconds: 40,
|
|
talkSeconds: 40,
|
|
outsideSeconds: { inbound: 95, outbound: 0 },
|
|
});
|
|
expect(s.hours.flat().reduce((a: number, b: number) => a + b)).toBe(3);
|
|
// The company summary is unchanged by who rang.
|
|
expect(
|
|
(await get('/api/reports/summary?from=2026-05-04&to=2026-05-04')).json().totals.answered,
|
|
).toBe(2);
|
|
});
|
|
});
|