Feat/call report #1

Merged
iamdoubz merged 2 commits from feat/call-report into main 2026-10-23 13:59:42 -05:00
17 changed files with 4716 additions and 12 deletions

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, "when": 1792626762332,
"tag": "0007_recording_transcripts", "tag": "0007_recording_transcripts",
"breakpoints": true "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', 'international',
]); ]);
export const recordMode = pgEnum('record_mode', ['never', 'inbound', 'outbound', 'all']); 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', { export const extensions = pgTable('extensions', {
id: id(), id: id(),
@@ -128,6 +129,13 @@ export const extensions = pgTable('extensions', {
language: text().notNull().default('en'), language: text().notNull().default('en'),
mohClass: text().notNull().default('default'), mohClass: text().notNull().default('default'),
blfNumbers: emptyTextArray(), 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, ...timestamps,
}); });
+7 -1
View File
@@ -23,6 +23,7 @@ import { cleanupRecordings } from './routes/recordings.ts';
import { transcribeCalls } from './ai/calls.ts'; import { transcribeCalls } from './ai/calls.ts';
import { audit } from './services/audit.ts'; import { audit } from './services/audit.ts';
import { backupDue, runBackup } from './services/backup.ts'; import { backupDue, runBackup } from './services/backup.ts';
import { sendCallReports } from './services/callreport.ts';
import { logEvent } from './services/events.ts'; import { logEvent } from './services/events.ts';
import { deliverDue } from './services/webhooks.ts'; import { deliverDue } from './services/webhooks.ts';
import type { SendMail } from './services/mail.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. */ /** Starts the jobs; returns a stop function. */
@@ -259,6 +264,7 @@ export function startJobs(d: JobDeps): () => void {
run('trunkWatch', j.trunkWatch); run('trunkWatch', j.trunkWatch);
if (minute % 5 === 0) run('publicIpWatch', j.publicIpWatch); if (minute % 5 === 0) run('publicIpWatch', j.publicIpWatch);
if (minute % 60 === 0) run('diskWatch', j.diskWatch); if (minute % 60 === 0) run('diskWatch', j.diskWatch);
run('callReports', j.callReports);
run('callTranscripts', async () => { run('callTranscripts', async () => {
await transcribeCalls({ db: d.db, dir: resolve(d.config.RECORDINGS_DIR), log: d.log }); 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 // 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 { existsSync } from 'node:fs';
import { mkdir, rm } from 'node:fs/promises'; import { mkdir, rm } from 'node:fs/promises';
import { join } from 'node:path'; import { join } from 'node:path';
@@ -111,6 +111,10 @@ export async function meRoutes(app: FastifyInstance) {
voicemailEmail: e.voicemailEmail, voicemailEmail: e.voicemailEmail,
voicemailAttachAudio: e.voicemailAttachAudio, voicemailAttachAudio: e.voicemailAttachAudio,
voicemailDeleteAfterEmail: e.voicemailDeleteAfterEmail, 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)) if (body.voicemailPin && isTrivialPin(body.voicemailPin, ext.number))
throw badRequest('That PIN is too easy to guess (repeated, sequential or your extension)'); 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; 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) => { await db.transaction(async (tx) => {
if (Object.keys(runtime).length) if (Object.keys(runtime).length)
await tx await tx
@@ -153,7 +162,11 @@ export async function meRoutes(app: FastifyInstance) {
if (Object.keys(vm).length || voicemailPin) if (Object.keys(vm).length || voicemailPin)
await tx await tx
.update(extensions) .update(extensions)
.set({ ...vm, ...(voicemailPin ? { voicemailPinEnc: encrypt(voicemailPin) } : {}) }) .set({
...vm,
...(voicemailPin ? { voicemailPinEnc: encrypt(voicemailPin) } : {}),
...(rescheduled ? { callReportSentAt: new Date() } : {}),
})
.where(eq(extensions.id, ext.id)); .where(eq(extensions.id, ext.id));
}); });
// Offline Asterisk catches up from PostgreSQL on its next connect. // 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, voicemailEmail: true,
voicemailAttachAudio: true, voicemailAttachAudio: true,
voicemailDeleteAfterEmail: false, 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", language: "text|NO|'en'::text",
moh_class: "text|NO|'default'::text", moh_class: "text|NO|'default'::text",
blf_numbers: "ARRAY:_text|NO|'{}'::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, ...ts,
}, },
trunks: { trunks: {
+108 -2
View File
@@ -1,10 +1,11 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
// My settings (05 §4): call forwarding, do not disturb, voicemail PIN and email options. // My settings (05 §4): call forwarding, do not disturb, voicemail PIN and email options, the call
// report email.
// Softphone devices and notifications live in the softphone panel. "Set up a phone app" (07 §5). // Softphone devices and notifications live in the softphone panel. "Set up a phone app" (07 §5).
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react'; import { useState } from 'react';
import { Link } from 'react-router'; import { Link } from 'react-router';
import { formatNumber, type MeDto, type MeUpdate } from '@selfphoneng/shared'; import { formatNumber, type CallReport, type MeDto, type MeUpdate } from '@selfphoneng/shared';
import { meApi } from '../../api/client.ts'; import { meApi } from '../../api/client.ts';
import { PhoneAppSetup } from '../../components/PhoneAppSetup.tsx'; import { PhoneAppSetup } from '../../components/PhoneAppSetup.tsx';
import { Alert, Button, Field, inputClass } from '../../components/ui.tsx'; import { Alert, Button, Field, inputClass } from '../../components/ui.tsx';
@@ -61,6 +62,7 @@ function SettingsForms({ me }: { me: MeDto }) {
<Forwarding me={me} /> <Forwarding me={me} />
</section> </section>
{me.voicemailEnabled && <Voicemail me={me} />} {me.voicemailEnabled && <Voicemail me={me} />}
<CallReportForm me={me} />
<section aria-labelledby="app-h" className="flex flex-col gap-3"> <section aria-labelledby="app-h" className="flex flex-col gap-3">
<h2 id="app-h" className="text-lg font-semibold"> <h2 id="app-h" className="text-lg font-semibold">
Set up a phone app Set up a phone app
@@ -196,3 +198,107 @@ function Voicemail({ me }: { me: MeDto }) {
</section> </section>
); );
} }
const REPORTS: [CallReport, string][] = [
['off', 'Off'],
['hourly', 'Every hour'],
['daily', 'Every day'],
['weekly', 'Every week'],
];
const WEEKDAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
function CallReportForm({ me }: { me: MeDto }) {
const save = useUpdateMe();
const [form, setForm] = useState({
callReport: me.callReport,
callReportTime: me.callReportTime,
callReportWeekday: me.callReportWeekday,
callReportSummaries: me.callReportSummaries,
});
const on = form.callReport !== 'off';
return (
<section aria-labelledby="report-h" className="flex flex-col gap-3">
<h2 id="report-h" className="text-lg font-semibold">
Call report
</h2>
<p className="text-sm text-zinc-500">
An email listing your calls: who, which numbers, how long, and if they were recorded. No
calls, no email. It goes out a few minutes after each period ends, in the company time zone.
</p>
<form
className="flex flex-col gap-3"
onSubmit={(e) => {
e.preventDefault();
save.mutate(form);
}}
>
<Field label="Email me">
{(p) => (
<select
{...p}
className={`${inputClass} max-w-48`}
value={form.callReport}
onChange={(e) => setForm({ ...form, callReport: e.target.value as CallReport })}
>
{REPORTS.map(([v, label]) => (
<option key={v} value={v}>
{label}
</option>
))}
</select>
)}
</Field>
{form.callReport === 'weekly' && (
<Field label="On">
{(p) => (
<select
{...p}
className={`${inputClass} max-w-48`}
value={form.callReportWeekday}
onChange={(e) => setForm({ ...form, callReportWeekday: Number(e.target.value) })}
>
{WEEKDAYS.map((d, i) => (
<option key={d} value={i}>
{d}
</option>
))}
</select>
)}
</Field>
)}
{(form.callReport === 'daily' || form.callReport === 'weekly') && (
<Field label="At" hint="Covers the day (or week) up to this time">
{(p) => (
<input
{...p}
type="time"
required
className={`${inputClass} max-w-48`}
value={form.callReportTime}
onChange={(e) => setForm({ ...form, callReportTime: e.target.value })}
/>
)}
</Field>
)}
{on && (
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={form.callReportSummaries}
onChange={(e) => setForm({ ...form, callReportSummaries: e.target.checked })}
/>
Add a summary of each recorded call
<span className="text-xs text-zinc-500">(when your admin has call summaries on)</span>
</label>
)}
<div className="flex items-center gap-3">
<Button type="submit" variant="primary" disabled={save.isPending}>
Save call report
</Button>
{save.isSuccess && <span className="text-sm text-zinc-500">Saved.</span>}
</div>
{save.error && <Alert>{save.error.message}</Alert>}
</form>
</section>
);
}
+25 -3
View File
@@ -1,16 +1,21 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
// My voicemail (05 §4): messages (play marks read, download, delete) and greetings. // My voicemail (05 §4): messages (play marks read, download, delete, search) and greetings.
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useRef, useState } from 'react'; import { useRef, useState } from 'react';
import { formatNumber, type GreetingKind, type VoicemailDto } from '@selfphoneng/shared'; import { formatNumber, type GreetingKind, type VoicemailDto } from '@selfphoneng/shared';
import { meApi } from '../../api/client.ts'; import { meApi } from '../../api/client.ts';
import { ConfirmDialog } from '../../components/Dialog.tsx'; import { ConfirmDialog } from '../../components/Dialog.tsx';
import { Alert, Button } from '../../components/ui.tsx'; import { Alert, Button, inputClass } from '../../components/ui.tsx';
export const mmss = (s: number) => `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`; export const mmss = (s: number) => `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
export const callerOf = (m: VoicemailDto) => export const callerOf = (m: VoicemailDto) =>
m.callerName ?? (m.callerNumber ? formatNumber(m.callerNumber) : 'Unknown caller'); m.callerName ?? (m.callerNumber ? formatNumber(m.callerNumber) : 'Unknown caller');
export const isNew = (m: VoicemailDto) => m.folder !== 'Old'; export const isNew = (m: VoicemailDto) => m.folder !== 'Old';
/** Words in the transcript, caller name or number, ignoring case. */
export const matches = (m: VoicemailDto, q: string) =>
[m.transcript, m.callerName, m.callerNumber].some((f) =>
f?.toLowerCase().includes(q.trim().toLowerCase()),
);
export function VoicemailPage() { export function VoicemailPage() {
return ( return (
@@ -25,6 +30,10 @@ function Messages() {
const qc = useQueryClient(); const qc = useQueryClient();
const list = useQuery({ queryKey: ['me', 'voicemail'], queryFn: meApi.voicemail }); const list = useQuery({ queryKey: ['me', 'voicemail'], queryFn: meApi.voicemail });
const [deleting, setDeleting] = useState<VoicemailDto | null>(null); const [deleting, setDeleting] = useState<VoicemailDto | null>(null);
const [q, setQ] = useState('');
// ponytail: the whole mailbox is already loaded, so search filters it here; move to the API if
// mailboxes ever get paged.
const shown = q.trim() ? list.data?.filter((m) => matches(m, q)) : list.data;
const refresh = () => qc.invalidateQueries({ queryKey: ['me', 'voicemail'] }); const refresh = () => qc.invalidateQueries({ queryKey: ['me', 'voicemail'] });
const markRead = useMutation({ const markRead = useMutation({
mutationFn: (id: string) => meApi.moveVoicemail(id, 'Old'), mutationFn: (id: string) => meApi.moveVoicemail(id, 'Old'),
@@ -44,9 +53,22 @@ function Messages() {
Voicemail Voicemail
</h2> </h2>
{list.error && <Alert>{list.error.message}</Alert>} {list.error && <Alert>{list.error.message}</Alert>}
{!!list.data?.length && (
<input
type="search"
aria-label="Search voicemail"
placeholder="Search what was said, or the caller"
className={`${inputClass} max-w-72`}
value={q}
onChange={(e) => setQ(e.target.value)}
/>
)}
{list.data?.length === 0 && <p className="text-sm text-zinc-500">No messages.</p>} {list.data?.length === 0 && <p className="text-sm text-zinc-500">No messages.</p>}
{!!list.data?.length && shown?.length === 0 && (
<p className="text-sm text-zinc-500">No message mentions that.</p>
)}
<ul className="flex flex-col gap-2"> <ul className="flex flex-col gap-2">
{list.data?.map((m) => ( {shown?.map((m) => (
<li <li
key={m.id} key={m.id}
className="flex flex-col gap-2 rounded-lg border border-zinc-200 p-3 sm:flex-row sm:items-center dark:border-zinc-800" className="flex flex-col gap-2 rounded-lg border border-zinc-200 p-3 sm:flex-row sm:items-center dark:border-zinc-800"
+1 -1
View File
@@ -73,7 +73,7 @@ on, each recorded call is also recorded one side at a time, and a minute or two
On the **Recordings** page each call shows its summary, **Show transcript**, and the search box On the **Recordings** page each call shows its summary, **Show transcript**, and the search box
finds calls by what was said. People who were on a call can read its transcript too, like they can finds calls by what was said. People who were on a call can read its transcript too, like they can
play its recording. play its recording, and get its summary in their call report email (My settings → Call report).
- Only calls that are recorded are written out (user record modes, phone numbers, queues and - Only calls that are recorded are written out (user record modes, phone numbers, queues and
conference rooms set to record), and only those recorded while it's on. conference rooms set to record), and only those recorded while it's on.
+7 -1
View File
@@ -101,6 +101,7 @@ create table api_tokens (
-- ───────────── extensions (telephony identity of a user) ───────────── -- ───────────── extensions (telephony identity of a user) ─────────────
create type outbound_class as enum ('internal', 'local', 'national', 'international'); create type outbound_class as enum ('internal', 'local', 'national', 'international');
create type record_mode as enum ('never', 'inbound', 'outbound', 'all'); create type record_mode as enum ('never', 'inbound', 'outbound', 'all');
create type call_report as enum ('off', 'hourly', 'daily', 'weekly');
create table extensions ( create table extensions (
id uuid primary key default gen_random_uuid(), id uuid primary key default gen_random_uuid(),
@@ -124,7 +125,12 @@ create table extensions (
pickup_group text, -- e.g. 'sales'; same group can pick each other up pickup_group text, -- e.g. 'sales'; same group can pick each other up
language text not null default 'en', language text not null default 'en',
moh_class text not null default 'default', moh_class text not null default 'default',
blf_numbers text[] not null default '{}' -- internal numbers shown as BLF keys on their phones/web blf_numbers text[] not null default '{}', -- internal numbers shown as BLF keys on their phones/web
call_report call_report not null default 'off', -- the call report email (05 §4)
call_report_time text not null default '08:00', -- HH:MM company time, daily and weekly
call_report_weekday int not null default 1, -- 0 = Sunday, weekly
call_report_summaries boolean not null default false, -- each recorded call's summary too (10 §3)
call_report_sent_at timestamptz -- end of the last period reported on
); );
-- * dids is created below; in the migration create dids first or add these FKs after. -- * dids is created below; in the migration create dids first or add these FKs after.
+1
View File
@@ -457,6 +457,7 @@ Provisioning and inbound hooks (outside `/api`, no session):
| `webhookDelivery` | every 10 s | send pending deliveries (§9) | | `webhookDelivery` | every 10 s | send pending deliveries (§9) |
| `trunkWatch` | every 60 s | if a trunk is unregistered for more than 5 min → email admins once per outage | | `trunkWatch` | every 60 s | if a trunk is unregistered for more than 5 min → email admins once per outage |
| `diskWatch` | hourly | if free disk < 10% → email admins once a day | | `diskWatch` | hourly | if free disk < 10% → email admins once a day |
| `callReports` | every 60 s | each user whose call report period (05 §4) ended over 5 min ago and isn't reported yet: email their calls of that period (by hang-up time) if there were any; `extensions.call_report_sent_at` = the period's end. Changing the schedule sets it to now, so the first report is the next period's |
Jobs must never overlap with themselves (use a running flag). Jobs must never overlap with themselves (use a running flag).
+2 -2
View File
@@ -103,10 +103,10 @@ Implementation notes (M3):
|---|---|---| |---|---|---|
| Home | `/me` | Status card (DND toggle, forwarding summary, queue login toggles), missed calls, new voicemails, colleagues presence list. M4 ships only new voicemails; the rest arrives with DND/forwarding (M6), call history (M7) and presence (M5-T3) | | Home | `/me` | Status card (DND toggle, forwarding summary, queue login toggles), missed calls, new voicemails, colleagues presence list. M4 ships only new voicemails; the rest arrives with DND/forwarding (M6), call history (M7) and presence (M5-T3) |
| Calls | `/me/calls` | Own call history, click a number to call back, add to contacts | | Calls | `/me/calls` | Own call history, click a number to call back, add to contacts |
| Voicemail | `/me/voicemail` | List (new bold), play (marks read), download, delete. Greetings section: record in the browser (MediaRecorder → upload), upload, or delete for unavail/busy/name | | Voicemail | `/me/voicemail` | List (new bold), play (marks read), download, delete, search (transcript, caller). Greetings section: record in the browser (MediaRecorder → upload), upload, or delete for unavail/busy/name |
| Contacts | `/me/contacts` | Personal + company (read-only) with search, click to call | | Contacts | `/me/contacts` | Personal + company (read-only) with search, click to call |
| Messages | `/me/sms` | Threads per DID + remote number, chat bubbles, composer with 160-char segment counter, unread badges (06 §5) | | Messages | `/me/sms` | Threads per DID + remote number, chat bubbles, composer with 160-char segment counter, unread badges (06 §5) |
| Settings | `/me/settings` | Forwarding rules (always/busy/no answer with number fields), voicemail PIN, email options, softphone audio devices, notification permission, change password, "Set up a phone app" (QR, 07 §5) | | Settings | `/me/settings` | Forwarding rules (always/busy/no answer with number fields), voicemail PIN, email options, call report email (off / hourly / daily at a time / weekly on a day at a time, company time zone; optional summaries of recorded calls, 10 §3; nothing sent for a period without calls), softphone audio devices, notification permission, change password, "Set up a phone app" (QR, 07 §5) |
### 4.1 Presence panel ### 4.1 Presence panel
Right column on wide screens, a tab on mobile. Lists every extension with its Right column on wide screens, a tab on mobile. Lists every extension with its
+18
View File
@@ -10,6 +10,10 @@ const ForwardInput = z
.regex(/^[0-9*#+()\s.-]*[0-9][0-9*#+()\s.-]*$/, 'A number like 101 or (312) 555-1234') .regex(/^[0-9*#+()\s.-]*[0-9][0-9*#+()\s.-]*$/, 'A number like 101 or (312) 555-1234')
.nullable(); .nullable();
/** How often the call report email goes out (05 §4), in the company time zone. */
export const CALL_REPORTS = ['off', 'hourly', 'daily', 'weekly'] as const;
export type CallReport = (typeof CALL_REPORTS)[number];
export const MeUpdateSchema = z.object({ export const MeUpdateSchema = z.object({
dnd: z.boolean().optional(), dnd: z.boolean().optional(),
/** Forward always / when busy / when not answered. */ /** Forward always / when busy / when not answered. */
@@ -23,6 +27,16 @@ export const MeUpdateSchema = z.object({
.string() .string()
.regex(/^\d{4,10}$/, 'Use 4 to 10 digits') .regex(/^\d{4,10}$/, 'Use 4 to 10 digits')
.optional(), .optional(),
callReport: z.enum(CALL_REPORTS).optional(),
/** HH:MM, for daily and weekly. */
callReportTime: z
.string()
.regex(/^([01]\d|2[0-3]):[0-5]\d$/, 'A time like 08:00')
.optional(),
/** 0 = Sunday, for weekly. */
callReportWeekday: z.number().int().min(0).max(6).optional(),
/** Each recorded call's summary too (spec 10 §3). */
callReportSummaries: z.boolean().optional(),
}); });
export type MeUpdate = z.input<typeof MeUpdateSchema>; export type MeUpdate = z.input<typeof MeUpdateSchema>;
@@ -37,4 +51,8 @@ export type MeDto = {
voicemailEmail: boolean; voicemailEmail: boolean;
voicemailAttachAudio: boolean; voicemailAttachAudio: boolean;
voicemailDeleteAfterEmail: boolean; voicemailDeleteAfterEmail: boolean;
callReport: CallReport;
callReportTime: string;
callReportWeekday: number;
callReportSummaries: boolean;
}; };