ivrs: digital receptionist with keypad grid, SoundPicker, golden case

/api/ivrs CRUD: greeting and wrong-key sounds must be prompts, every
option destination must exist (an option may point at its own menu),
keys unique; delete drops the menu's own options before checking
references, inside a transaction so a refused delete keeps them. The
generator renders sp-ivr-{id} per 03 §6.9 (Background greeting,
WaitExten, keys sorted, i/t retry then give up, include sp-local-numbers
for direct dial), adds the number to sp-local-numbers, and destApp sends
ivr destinations there. Golden case ivr (incl. # and self-reference).

Web: Digital receptionist page with a 3x4 keypad grid (pick a key, then
its destination or "unused"), SoundPicker (pick, play, upload new right
in the form), DestinationPicker gains "Digital receptionist" and a shared
target source; useDescribeDestination labels unsaved keys.

e2e m6-t3: menu built in the UI, the greeting plays, keys 1/2 ring the
right phones, a wrong key plays pbx-invalid and repeats the greeting.

Task: M6-T3
This commit is contained in:
iamdoubz committed 2026-10-18 10:39:13 -05:00
1 parent f87d4d47dd
commit 15624f7b77
35 files changed
+1730 -16

No files matched your search

+41
View File
@@ -6,9 +6,12 @@ import type { Db } from '../db/client.ts';
import {
dids,
extensions,
ivrOptions,
ivrs,
outboundRoutes,
ringGroupMembers,
ringGroups,
soundFiles,
trunks,
users,
} from '../db/schema.ts';
@@ -86,6 +89,21 @@ export type SnapRingGroup = {
noAnswerDestination: Destination;
};
export type SnapIvr = {
id: string;
number: string;
name: string;
/** Asterisk sound names, e.g. custom/<id>. */
greeting: string;
invalidSound: string | null;
timeoutSeconds: number;
maxRetries: number;
allowDirectDial: boolean;
options: { digit: string; destination: Destination }[];
timeoutDestination: Destination;
invalidDestination: Destination;
};
export type Snapshot = {
/** "YYYY-MM-DD" in general.timezone; render() never reads the clock. */
today: string;
@@ -95,6 +113,7 @@ export type Snapshot = {
trunks: SnapTrunk[];
routes: SnapRoute[];
ringGroups: SnapRingGroup[];
ivrs: SnapIvr[];
};
export function todayIn(timezone: string, now = new Date()): string {
@@ -131,6 +150,13 @@ export async function loadSnapshot(db: Db, now = new Date()): Promise<Snapshot>
const trunkRows = await db.select().from(trunks);
const routeRows = await db.select().from(outboundRoutes);
const groupRows = await db.select().from(ringGroups);
const ivrRows = await db.select().from(ivrs);
const optionRows = await db.select().from(ivrOptions);
const soundName = new Map(
(await db.select({ id: soundFiles.id, name: soundFiles.asteriskName }).from(soundFiles)).map(
(s) => [s.id, s.name],
),
);
const memberRows = await db
.select({ groupId: ringGroupMembers.ringGroupId, number: extensions.number })
.from(ringGroupMembers)
@@ -141,6 +167,21 @@ export async function loadSnapshot(db: Db, now = new Date()): Promise<Snapshot>
today: todayIn(settings['general.timezone'], now),
settings,
dids: didRows.map((d) => ({ ...d, destination: d.destination as Destination })),
ivrs: ivrRows.map((r) => ({
id: r.id,
number: r.number,
name: r.name,
greeting: soundName.get(r.greetingSoundId)!,
invalidSound: r.invalidSoundId ? (soundName.get(r.invalidSoundId) ?? null) : null,
timeoutSeconds: r.timeoutSeconds,
maxRetries: r.maxRetries,
allowDirectDial: r.allowDirectDial,
options: optionRows
.filter((o) => o.ivrId === r.id)
.map((o) => ({ digit: o.digit, destination: o.destination as Destination })),
timeoutDestination: r.timeoutDestination as Destination,
invalidDestination: r.invalidDestination as Destination,
})),
ringGroups: groupRows.map((g) => ({
id: g.id,
number: g.number,