Feat/call report #1
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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
voicemailEmail: true,
|
||||||
voicemailAttachAudio: true,
|
voicemailAttachAudio: true,
|
||||||
voicemailDeleteAfterEmail: false,
|
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",
|
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: {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
Reference in new issue
Block a user