me: call report email (hourly, daily, weekly), with call summaries
ci / check (push) Successful in 1m14s
ci / images (push) Successful in 40s
ci / check (pull_request) Successful in 59s
ci / images (pull_request) Successful in 4s

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:
iamdoubz committed 2026-10-23 13:55:47 -05:00
1 parent 46cc8c2002
commit 4c006f2388
16 files changed
+4691 -9

No files matched your search

@@ -0,0 +1,6 @@
CREATE TYPE "public"."call_report" AS ENUM('off', 'hourly', 'daily', 'weekly');--> statement-breakpoint
ALTER TABLE "extensions" ADD COLUMN "call_report" "call_report" DEFAULT 'off' NOT NULL;--> statement-breakpoint
ALTER TABLE "extensions" ADD COLUMN "call_report_time" text DEFAULT '08:00' NOT NULL;--> statement-breakpoint
ALTER TABLE "extensions" ADD COLUMN "call_report_weekday" integer DEFAULT 1 NOT NULL;--> statement-breakpoint
ALTER TABLE "extensions" ADD COLUMN "call_report_summaries" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "extensions" ADD COLUMN "call_report_sent_at" timestamp with time zone;
File diff suppressed because it is too large. Load diff
@@ -57,6 +57,13 @@
"when": 1792626762332,
"tag": "0007_recording_transcripts",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1792781321577,
"tag": "0008_call_reports",
"breakpoints": true
}
]
}
+8
View File
@@ -102,6 +102,7 @@ export const outboundClass = pgEnum('outbound_class', [
'international',
]);
export const recordMode = pgEnum('record_mode', ['never', 'inbound', 'outbound', 'all']);
export const callReport = pgEnum('call_report', ['off', 'hourly', 'daily', 'weekly']);
export const extensions = pgTable('extensions', {
id: id(),
@@ -128,6 +129,13 @@ export const extensions = pgTable('extensions', {
language: text().notNull().default('en'),
mohClass: text().notNull().default('default'),
blfNumbers: emptyTextArray(),
/** The call report email (05 §4): how often, at what local time (HH:MM) and weekday (0 = Sunday). */
callReport: callReport().notNull().default('off'),
callReportTime: text().notNull().default('08:00'),
callReportWeekday: integer().notNull().default(1),
callReportSummaries: boolean().notNull().default(false),
/** The end of the last period reported on (or when the schedule last changed). */
callReportSentAt: tstz(),
...timestamps,
});
+7 -1
View File
@@ -23,6 +23,7 @@ import { cleanupRecordings } from './routes/recordings.ts';
import { transcribeCalls } from './ai/calls.ts';
import { audit } from './services/audit.ts';
import { backupDue, runBackup } from './services/backup.ts';
import { sendCallReports } from './services/callreport.ts';
import { logEvent } from './services/events.ts';
import { deliverDue } from './services/webhooks.ts';
import type { SendMail } from './services/mail.ts';
@@ -237,7 +238,11 @@ export function createJobs(d: JobDeps) {
);
};
return { daily, backup, publicIpWatch, trunkWatch, diskWatch };
const callReports = async () => {
await sendCallReports({ db: d.db, send: d.send, log: d.log, now: now() });
};
return { daily, backup, publicIpWatch, trunkWatch, diskWatch, callReports };
}
/** Starts the jobs; returns a stop function. */
@@ -259,6 +264,7 @@ export function startJobs(d: JobDeps): () => void {
run('trunkWatch', j.trunkWatch);
if (minute % 5 === 0) run('publicIpWatch', j.publicIpWatch);
if (minute % 60 === 0) run('diskWatch', j.diskWatch);
run('callReports', j.callReports);
run('callTranscripts', async () => {
await transcribeCalls({ db: d.db, dir: resolve(d.config.RECORDINGS_DIR), log: d.log });
});
+15 -2
View File
@@ -1,5 +1,5 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// /api/me/voicemail and /api/me/greetings (04 §5.6, §7.3): the signed-in user's own mailbox.
// /api/me (settings, call report), /api/me/voicemail and /api/me/greetings (04 §5.6, §7.3).
import { existsSync } from 'node:fs';
import { mkdir, rm } from 'node:fs/promises';
import { join } from 'node:path';
@@ -111,6 +111,10 @@ export async function meRoutes(app: FastifyInstance) {
voicemailEmail: e.voicemailEmail,
voicemailAttachAudio: e.voicemailAttachAudio,
voicemailDeleteAfterEmail: e.voicemailDeleteAfterEmail,
callReport: e.callReport,
callReportTime: e.callReportTime,
callReportWeekday: e.callReportWeekday,
callReportSummaries: e.callReportSummaries,
};
}
@@ -144,6 +148,11 @@ export async function meRoutes(app: FastifyInstance) {
if (body.voicemailPin && isTrivialPin(body.voicemailPin, ext.number))
throw badRequest('That PIN is too easy to guess (repeated, sequential or your extension)');
const { dnd: _d, cfu: _u, cfb: _b, cfna: _n, voicemailPin, ...vm } = body;
// A new call report schedule starts with its next period, not one that just ended.
const rescheduled =
body.callReport !== undefined ||
body.callReportTime !== undefined ||
body.callReportWeekday !== undefined;
await db.transaction(async (tx) => {
if (Object.keys(runtime).length)
await tx
@@ -153,7 +162,11 @@ export async function meRoutes(app: FastifyInstance) {
if (Object.keys(vm).length || voicemailPin)
await tx
.update(extensions)
.set({ ...vm, ...(voicemailPin ? { voicemailPinEnc: encrypt(voicemailPin) } : {}) })
.set({
...vm,
...(voicemailPin ? { voicemailPinEnc: encrypt(voicemailPin) } : {}),
...(rescheduled ? { callReportSentAt: new Date() } : {}),
})
.where(eq(extensions.id, ext.id));
});
// Offline Asterisk catches up from PostgreSQL on its next connect.
+190
View File
@@ -0,0 +1,190 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// The call report email (05 §4): each user's calls of the last hour, day or week, with each
// recorded call's summary if they asked for it. No calls, no email.
import { and, asc, eq, gte, inArray, lt, ne, sql } from 'drizzle-orm';
import { alias } from 'drizzle-orm/pg-core';
import { formatNumber, type CallReport } from '@selfphoneng/shared';
import type { Db } from '../db/client.ts';
import { callExtensions, calls, extensions, recordings, users } from '../db/schema.ts';
import type { SendMail } from './mail.ts';
import { getSetting } from './settings.ts';
/** Calls are written a few seconds after hang-up and summarized a minute or two after. */
const LAG_MS = 5 * 60_000;
/**
* The last whole period that ended by `now`, in the time zone `tz`: e.g. daily at 08:00 is
* yesterday 08:00 to today 08:00. Local-time arithmetic in PostgreSQL, so DST days are 23 or 25 h.
*/
export async function lastPeriod(
db: Db,
tz: string,
s: { callReport: Exclude<CallReport, 'off'>; callReportTime: string; callReportWeekday: number },
now: Date,
): Promise<{ from: Date; to: Date }> {
const r = await db.execute<{ from: Date; to: Date }>(sql`
with l as (select (${now.toISOString()}::timestamptz at time zone ${tz}::text) as t),
s as (
select t, case ${s.callReport}::text
when 'hourly' then date_trunc('hour', t)
when 'daily' then t::date + ${s.callReportTime}::time
else t::date - ((extract(dow from t)::int - ${s.callReportWeekday}::int + 7) % 7)
+ ${s.callReportTime}::time
end as at,
case ${s.callReport}::text
when 'hourly' then interval '1 hour'
when 'daily' then interval '1 day'
else interval '7 days'
end as p
from l),
e as (select case when at > t then at - p else at end as at, p from s)
select (at - p) at time zone ${tz}::text as "from", at at time zone ${tz}::text as "to" from e`);
const row = r.rows[0]!;
return { from: new Date(row.from), to: new Date(row.to) };
}
const duration = (s: number) =>
s >= 3600
? `${Math.floor(s / 3600)}:${String(Math.floor(s / 60) % 60).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`
: `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
/** "102" stays as is; outside numbers get their usual format. */
const num = (n: string | null) => (n ? (n.length > 6 ? formatNumber(n) : n) : 'unknown');
export type ReportCall = {
startedAt: Date;
out: boolean;
status: string;
fromNumber: string | null;
fromName: string | null;
toNumber: string | null;
talkSeconds: number;
answeredBy: string | null;
recorded: boolean;
summary: string | null;
summaryPending: boolean;
};
/** The email's text: one block per call, oldest first. */
export function reportText(
tz: string,
period: { from: Date; to: Date },
list: ReportCall[],
withSummaries: boolean,
): string {
const when = new Intl.DateTimeFormat('en-US', {
timeZone: tz,
dateStyle: 'medium',
timeStyle: 'short',
});
const lines = [
`${list.length} call${list.length === 1 ? '' : 's'} from ${when.format(period.from)} to ${when.format(period.to)}:`,
];
for (const c of list) {
const who = c.out
? `Outgoing to ${num(c.toNumber)}`
: `Incoming from ${num(c.fromNumber)}${c.fromName ? ` (${c.fromName})` : ''} to ${num(c.toNumber)}`;
const facts = [
c.status === 'answered' && c.answeredBy ? `answered by ${c.answeredBy}` : c.status,
duration(c.talkSeconds),
...(c.recorded ? ['recorded'] : []),
];
lines.push('', `${when.format(c.startedAt)} ${who}`, ` ${facts.join(' · ')}`);
if (withSummaries && c.summary)
lines.push(
...c.summary
.trim()
.split('\n')
.map((l) => ` ${l}`),
);
else if (withSummaries && c.summaryPending)
lines.push(' Summary not ready yet: see Recordings in the web portal.');
}
return lines.join('\n') + '\n';
}
const answerer = alias(extensions, 'answerer');
/** One run: every user whose period ended and wasn't reported on yet. */
export async function sendCallReports(d: {
db: Db;
send: SendMail;
log: { error(o: object, m: string): void };
now: Date;
}): Promise<number> {
const tz = await getSetting(d.db, 'general.timezone');
const asOf = new Date(d.now.getTime() - LAG_MS);
const due = await d.db
.select({ e: extensions, email: users.email })
.from(extensions)
.innerJoin(users, eq(users.id, extensions.userId))
.where(and(ne(extensions.callReport, 'off'), eq(users.isActive, true)));
let sent = 0;
for (const { e, email } of due) {
if (e.callReport === 'off') continue;
const period = await lastPeriod(d.db, tz, { ...e, callReport: e.callReport }, asOf);
if (e.callReportSentAt && e.callReportSentAt >= period.to) continue;
// ponytail: by hang-up time, and only the last period: one that passed while the server
// was down isn't caught up.
const rows = await d.db
.select({
c: calls,
answeredBy: answerer.name,
answeredById: answerer.id,
summary: recordings.summary,
aiDoneAt: recordings.aiDoneAt,
out: sql<boolean>`exists (select 1 from ${callExtensions} where ${callExtensions.callId} = ${calls.id}
and ${callExtensions.extensionId} = ${e.id} and ${callExtensions.role} = 'caller')`,
})
.from(calls)
.leftJoin(answerer, eq(answerer.id, calls.answeredByExtensionId))
.leftJoin(recordings, eq(recordings.id, calls.recordingId))
.where(
and(
gte(calls.endedAt, period.from),
lt(calls.endedAt, period.to),
inArray(
calls.id,
d.db
.select({ id: callExtensions.callId })
.from(callExtensions)
.where(eq(callExtensions.extensionId, e.id)),
),
),
)
.orderBy(asc(calls.startedAt));
// Marked first: a failing mail server is logged once, not retried every minute.
await d.db
.update(extensions)
.set({ callReportSentAt: period.to })
.where(eq(extensions.id, e.id));
if (!rows.length) continue;
const list = rows.map((r): ReportCall => ({
startedAt: r.c.startedAt,
out: r.out,
status: r.c.status,
fromNumber: r.c.fromNumber,
fromName: r.c.fromName,
toNumber: r.c.toNumber,
talkSeconds: r.c.talkSeconds,
answeredBy: r.answeredById && r.answeredById !== e.id ? r.answeredBy : null,
recorded: r.c.recordingId !== null,
summary: r.summary,
summaryPending: r.c.recordingId !== null && r.aiDoneAt === null,
}));
const withSummaries =
e.callReportSummaries &&
(await getSetting(d.db, 'ai.callTranscripts')) &&
(await getSetting(d.db, 'ai.callSummaries'));
try {
await d.send({
to: [email],
subject: `Your calls (${e.number}): ${list.length} call${list.length === 1 ? '' : 's'}`,
text: reportText(tz, period, list, withSummaries),
});
sent++;
} catch (err) {
d.log.error({ err, extension: e.number }, 'call report not sent');
}
}
return sent;
}
+184
View File
@@ -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');
});
});
+4
View File
@@ -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,
});
});
+5
View File
@@ -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: {