me: call report email (hourly, daily, weekly), with call summaries
My settings → Call report: each user's calls of the last hour, day (at a time) or week (on a day at a time), company time zone: numbers, status, talk time, recorded, and optionally each recorded call's summary (10 §3). No calls, no email. Job callReports every minute, 5 min after the period ends; periods computed in PostgreSQL (DST-safe). Migration 0008_call_reports.
This commit is contained in:
1 parent
46cc8c2002
commit
4c006f2388
16 files changed
+4691
-9
No files matched your search
@@ -0,0 +1,184 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// The call report email (05 §4): its periods, and who gets what.
|
||||
import type { LightMyRequestResponse } from 'fastify';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { callExtensions, calls, extensions, recordings } from '../src/db/schema.ts';
|
||||
import { lastPeriod, sendCallReports } from '../src/services/callreport.ts';
|
||||
import { setSetting } from '../src/services/settings.ts';
|
||||
import { hasTestDb } from './db.ts';
|
||||
import { ADMIN, H, createTestApp } from './app.ts';
|
||||
|
||||
describe.skipIf(!hasTestDb)('call report email', () => {
|
||||
let t: Awaited<ReturnType<typeof createTestApp>>;
|
||||
let dan: (method: string, url: string, payload?: unknown) => Promise<LightMyRequestResponse>;
|
||||
let extId: string;
|
||||
const tz = 'America/Chicago';
|
||||
const period = (callReport: 'hourly' | 'daily' | 'weekly', now: string, at = '08:00', wd = 1) =>
|
||||
lastPeriod(
|
||||
t.db,
|
||||
tz,
|
||||
{ callReport, callReportTime: at, callReportWeekday: wd },
|
||||
new Date(now),
|
||||
).then((p) => [p.from.toISOString(), p.to.toISOString()]);
|
||||
const run = (now: string) =>
|
||||
sendCallReports({ db: t.db, send: t.app.sendMail, log: t.app.log, now: new Date(now) });
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await createTestApp();
|
||||
await t.clearMustChange(ADMIN.email);
|
||||
await setSetting(t.db, 'general.timezone', tz);
|
||||
const as = (cookie: string) => (method: string, url: string, payload?: unknown) =>
|
||||
t.app.inject({
|
||||
method: method as 'GET',
|
||||
url,
|
||||
payload: payload as object,
|
||||
headers: { ...H, cookie },
|
||||
});
|
||||
const admin = as(await t.login(ADMIN.email, ADMIN.password));
|
||||
const danId = (await t.addUser('dan@example.com', 'dan-password-123')).id;
|
||||
extId = (
|
||||
await admin('POST', '/api/extensions', { number: '101', name: 'Dan Doe', userId: danId })
|
||||
).json().id;
|
||||
dan = as(await t.login('dan@example.com', 'dan-password-123'));
|
||||
});
|
||||
afterAll(() => t?.close());
|
||||
|
||||
it('periods end at the last scheduled local time, DST included', async () => {
|
||||
// CDT is UTC-5. 2026-10-23 is a Friday.
|
||||
expect(await period('hourly', '2026-10-23T14:25:00Z')).toEqual([
|
||||
'2026-10-23T13:00:00.000Z',
|
||||
'2026-10-23T14:00:00.000Z',
|
||||
]);
|
||||
expect(await period('daily', '2026-10-23T14:25:00Z')).toEqual([
|
||||
'2026-10-22T13:00:00.000Z',
|
||||
'2026-10-23T13:00:00.000Z',
|
||||
]);
|
||||
// 07:59 local: still yesterday's period.
|
||||
expect(await period('daily', '2026-10-23T12:59:00Z')).toEqual([
|
||||
'2026-10-21T13:00:00.000Z',
|
||||
'2026-10-22T13:00:00.000Z',
|
||||
]);
|
||||
// Weekly on Monday 08:00, asked on Friday: the week up to this Monday.
|
||||
expect(await period('weekly', '2026-10-23T14:25:00Z')).toEqual([
|
||||
'2026-10-12T13:00:00.000Z',
|
||||
'2026-10-19T13:00:00.000Z',
|
||||
]);
|
||||
// DST ends 02:00 Sunday 2026-11-01 (CDT to CST, UTC-6): Saturday 08:00 to Sunday 08:00 is 25 h.
|
||||
expect(await period('daily', '2026-11-01T15:00:00Z')).toEqual([
|
||||
'2026-10-31T13:00:00.000Z',
|
||||
'2026-11-01T14:00:00.000Z',
|
||||
]);
|
||||
});
|
||||
|
||||
it('PATCH /api/me saves the schedule, starting with the next period', async () => {
|
||||
const r = await dan('PATCH', '/api/me', {
|
||||
callReport: 'daily',
|
||||
callReportTime: '17:30',
|
||||
callReportSummaries: true,
|
||||
});
|
||||
expect(r.statusCode, r.body).toBe(200);
|
||||
expect(r.json()).toMatchObject({
|
||||
callReport: 'daily',
|
||||
callReportTime: '17:30',
|
||||
callReportWeekday: 1,
|
||||
callReportSummaries: true,
|
||||
});
|
||||
const [e] = await t.db.select().from(extensions).where(eq(extensions.id, extId));
|
||||
expect(e!.callReportSentAt).not.toBeNull();
|
||||
expect((await dan('PATCH', '/api/me', { callReportTime: '25:00' })).statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('emails the calls of the period once, and nothing for a period without calls', async () => {
|
||||
await dan('PATCH', '/api/me', { callReport: 'daily', callReportTime: '08:00' });
|
||||
await t.db.update(extensions).set({ callReportSentAt: null }).where(eq(extensions.id, extId));
|
||||
await setSetting(t.db, 'ai.callTranscripts', true);
|
||||
await setSetting(t.db, 'ai.callSummaries', true);
|
||||
const [rec] = await t.db
|
||||
.insert(recordings)
|
||||
.values({
|
||||
linkedid: 'r1',
|
||||
filePath: '2026/10/22/r1.wav',
|
||||
startedAt: new Date('2026-10-22T15:00:00Z'),
|
||||
summary: 'Acme wants a quote.\nAction items:\n- Dan sends the quote',
|
||||
aiDoneAt: new Date(),
|
||||
})
|
||||
.returning();
|
||||
const [inCall, outCall, before] = await t.db
|
||||
.insert(calls)
|
||||
.values([
|
||||
{
|
||||
linkedid: 'c1',
|
||||
direction: 'inbound',
|
||||
status: 'answered',
|
||||
startedAt: new Date('2026-10-22T15:00:00Z'),
|
||||
endedAt: new Date('2026-10-22T15:03:12Z'),
|
||||
fromNumber: '13125551234',
|
||||
fromName: 'Acme',
|
||||
toNumber: '13125550000',
|
||||
talkSeconds: 192,
|
||||
answeredByExtensionId: extId,
|
||||
recordingId: rec!.id,
|
||||
},
|
||||
{
|
||||
linkedid: 'c2',
|
||||
direction: 'outbound',
|
||||
status: 'answered',
|
||||
startedAt: new Date('2026-10-22T20:00:00Z'),
|
||||
endedAt: new Date('2026-10-22T20:00:45Z'),
|
||||
fromNumber: '101',
|
||||
toNumber: '18005550100',
|
||||
talkSeconds: 40,
|
||||
},
|
||||
{
|
||||
linkedid: 'c3', // the period before
|
||||
direction: 'inbound',
|
||||
status: 'missed',
|
||||
startedAt: new Date('2026-10-21T15:00:00Z'),
|
||||
endedAt: new Date('2026-10-21T15:00:20Z'),
|
||||
fromNumber: '13125559999',
|
||||
},
|
||||
])
|
||||
.returning();
|
||||
await t.db.insert(callExtensions).values([
|
||||
{ callId: inCall!.id, extensionId: extId, role: 'answered' },
|
||||
{ callId: outCall!.id, extensionId: extId, role: 'caller' },
|
||||
{ callId: before!.id, extensionId: extId, role: 'rang' },
|
||||
]);
|
||||
|
||||
t.mails.length = 0;
|
||||
// Today 08:00 CDT is 13:00Z; the report waits a few minutes for the last calls and summaries.
|
||||
expect(await run('2026-10-23T13:02:00Z')).toBe(1); // yesterday's period, never reported
|
||||
t.mails.length = 0;
|
||||
await t.db
|
||||
.update(extensions)
|
||||
.set({ callReportSentAt: new Date('2026-10-22T13:00:00Z') })
|
||||
.where(eq(extensions.id, extId));
|
||||
expect(await run('2026-10-23T13:06:00Z')).toBe(1);
|
||||
expect(await run('2026-10-23T13:07:00Z')).toBe(0); // once
|
||||
expect(t.mails).toHaveLength(1);
|
||||
const m = t.mails[0]!;
|
||||
expect(m.to).toEqual(['dan@example.com']);
|
||||
expect(m.subject).toBe('Your calls (101): 2 calls');
|
||||
expect(m.text).toContain('Incoming from (312) 555-1234 (Acme) to (312) 555-0000');
|
||||
expect(m.text).toContain('answered · 3:12 · recorded');
|
||||
expect(m.text).toContain(' - Dan sends the quote');
|
||||
expect(m.text).toContain('Outgoing to (800) 555-0100');
|
||||
expect(m.text).toContain('answered · 0:40\n');
|
||||
expect(m.text).not.toContain('555-9999');
|
||||
expect(m.text.indexOf('Incoming')).toBeLessThan(m.text.indexOf('Outgoing'));
|
||||
|
||||
// The next day had no calls: no email.
|
||||
expect(await run('2026-10-24T13:06:00Z')).toBe(0);
|
||||
expect(t.mails).toHaveLength(1);
|
||||
|
||||
// Summaries off: the calls, not what was said.
|
||||
await dan('PATCH', '/api/me', { callReportSummaries: false });
|
||||
await t.db
|
||||
.update(extensions)
|
||||
.set({ callReportSentAt: new Date('2026-10-22T13:00:00Z') })
|
||||
.where(eq(extensions.id, extId));
|
||||
await run('2026-10-23T13:06:00Z');
|
||||
expect(t.mails[1]!.text).not.toContain('quote');
|
||||
});
|
||||
});
|
||||
@@ -62,6 +62,10 @@ describe.skipIf(!hasTestDb)('/api/me settings and phone toggles', () => {
|
||||
voicemailEmail: true,
|
||||
voicemailAttachAudio: true,
|
||||
voicemailDeleteAfterEmail: false,
|
||||
callReport: 'off',
|
||||
callReportTime: '08:00',
|
||||
callReportWeekday: 1,
|
||||
callReportSummaries: false,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -48,6 +48,11 @@ const EXPECTED: Record<string, Record<string, string>> = {
|
||||
language: "text|NO|'en'::text",
|
||||
moh_class: "text|NO|'default'::text",
|
||||
blf_numbers: "ARRAY:_text|NO|'{}'::text[]",
|
||||
call_report: "USER-DEFINED:call_report|NO|'off'::call_report",
|
||||
call_report_time: "text|NO|'08:00'::text",
|
||||
call_report_weekday: 'integer|NO|1',
|
||||
call_report_summaries: 'boolean|NO|false',
|
||||
call_report_sent_at: 'timestamp with time zone|YES|',
|
||||
...ts,
|
||||
},
|
||||
trunks: {
|
||||
|
||||
Reference in new issue
Block a user