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,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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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