diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts
index 1c5196d..de3055a 100644
--- a/apps/api/src/app.ts
+++ b/apps/api/src/app.ts
@@ -25,6 +25,7 @@ import { soundRoutes } from './routes/sounds.ts';
import { ringGroupRoutes } from './routes/ringgroups.ts';
import { ivrRoutes } from './routes/ivrs.ts';
import { timeConditionRoutes } from './routes/timeconditions.ts';
+import { announcementRoutes } from './routes/announcements.ts';
declare module 'fastify' {
interface FastifyInstance {
@@ -101,6 +102,7 @@ export function buildApp(config: Config, db: Db, publicDir = resolve('public'))
app.register(ringGroupRoutes);
app.register(ivrRoutes);
app.register(timeConditionRoutes);
+ app.register(announcementRoutes);
// Always registered: reply.sendFile also streams voicemail audio (with Range) from the spool.
app.register(fastifyStatic, {
diff --git a/apps/api/src/generator/__golden__/ivr/expected/extensions_selfphoneng.conf b/apps/api/src/generator/__golden__/ivr/expected/extensions_selfphoneng.conf
index f576c0e..f2e6fd6 100644
--- a/apps/api/src/generator/__golden__/ivr/expected/extensions_selfphoneng.conf
+++ b/apps/api/src/generator/__golden__/ivr/expected/extensions_selfphoneng.conf
@@ -163,6 +163,7 @@ exten => #,1,Goto(sp-vm-direct,101,1)
exten => 0,1,Goto(sp-ext-forward,13125550000,1)
exten => 1,1,Goto(sp-rg-22222222-2222-4222-8222-222222222500,s,1)
exten => 2,1,Goto(sp-local-numbers,102,1)
+exten => 3,1,Goto(sp-ann-66666666-6666-4666-8666-666666666001,s,1)
exten => 9,1,Goto(sp-ivr-33333333-3333-4333-8333-333333333600,s,1)
exten => i,1,Set(SP_IVR_TRIES=$[${SP_IVR_TRIES} + 1])
same => n,GotoIf($[${SP_IVR_TRIES} >= 2]?giveup)
@@ -196,6 +197,12 @@ exten => t,1,Set(SP_IVR_TRIES=$[${SP_IVR_TRIES} + 1])
exten => _*3[123]X,1,Playback(ss-noservice)
same => n,Hangup()
+; ─── Announcement "Our hours"
+[sp-ann-66666666-6666-4666-8666-666666666001]
+exten => s,1,Answer()
+ same => n,Playback(custom/44444444-4444-4444-8444-444444444447)
+ same => n,Goto(sp-ivr-33333333-3333-4333-8333-333333333600,s,1)
+
; ─── Calls from trunk "voip.ms"
[sp-from-trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37]
exten => s,1,Goto(sp-from-trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37,unknown,1)
diff --git a/apps/api/src/generator/__golden__/ivr/snapshot.json b/apps/api/src/generator/__golden__/ivr/snapshot.json
index 55eadcb..bbfad6c 100644
--- a/apps/api/src/generator/__golden__/ivr/snapshot.json
+++ b/apps/api/src/generator/__golden__/ivr/snapshot.json
@@ -32,24 +32,54 @@
"options": [
{
"digit": "2",
- "destination": { "type": "user", "id": "00000000-0000-4000-8000-000000000102" }
+ "destination": {
+ "type": "user",
+ "id": "00000000-0000-4000-8000-000000000102"
+ }
},
{
"digit": "1",
- "destination": { "type": "ring_group", "id": "22222222-2222-4222-8222-222222222500" }
+ "destination": {
+ "type": "ring_group",
+ "id": "22222222-2222-4222-8222-222222222500"
+ }
},
{
"digit": "9",
- "destination": { "type": "ivr", "id": "33333333-3333-4333-8333-333333333600" }
+ "destination": {
+ "type": "ivr",
+ "id": "33333333-3333-4333-8333-333333333600"
+ }
},
{
"digit": "#",
- "destination": { "type": "voicemail", "id": "00000000-0000-4000-8000-000000000101" }
+ "destination": {
+ "type": "voicemail",
+ "id": "00000000-0000-4000-8000-000000000101"
+ }
},
- { "digit": "0", "destination": { "type": "external", "number": "13125550000" } }
+ {
+ "digit": "0",
+ "destination": {
+ "type": "external",
+ "number": "13125550000"
+ }
+ },
+ {
+ "digit": "3",
+ "destination": {
+ "type": "announcement",
+ "id": "66666666-6666-4666-8666-666666666001"
+ }
+ }
],
- "timeoutDestination": { "type": "voicemail", "id": "00000000-0000-4000-8000-000000000101" },
- "invalidDestination": { "type": "hangup" }
+ "timeoutDestination": {
+ "type": "voicemail",
+ "id": "00000000-0000-4000-8000-000000000101"
+ },
+ "invalidDestination": {
+ "type": "hangup"
+ }
},
{
"id": "33333333-3333-4333-8333-333333333601",
@@ -58,16 +88,40 @@
"greeting": "custom/44444444-4444-4444-8444-444444444445",
"invalidSound": "custom/44444444-4444-4444-8444-444444444446",
"allowDirectDial": false,
- "timeoutDestination": { "type": "ivr", "id": "33333333-3333-4333-8333-333333333600" },
- "invalidDestination": { "type": "ivr", "id": "33333333-3333-4333-8333-333333333600" }
+ "timeoutDestination": {
+ "type": "ivr",
+ "id": "33333333-3333-4333-8333-333333333600"
+ },
+ "invalidDestination": {
+ "type": "ivr",
+ "id": "33333333-3333-4333-8333-333333333600"
+ }
}
],
"dids": [
{
"id": "11111111-1111-4111-8111-111111111111",
"number": "13125551234",
- "destination": { "type": "ivr", "id": "33333333-3333-4333-8333-333333333600" }
+ "destination": {
+ "type": "ivr",
+ "id": "33333333-3333-4333-8333-333333333600"
+ }
}
],
- "trunks": [{ "id": "7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37" }]
+ "trunks": [
+ {
+ "id": "7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37"
+ }
+ ],
+ "announcements": [
+ {
+ "id": "66666666-6666-4666-8666-666666666001",
+ "name": "Our hours",
+ "sound": "custom/44444444-4444-4444-8444-444444444447",
+ "destination": {
+ "type": "ivr",
+ "id": "33333333-3333-4333-8333-333333333600"
+ }
+ }
+ ]
}
diff --git a/apps/api/src/generator/dialplan/announcements.ts b/apps/api/src/generator/dialplan/announcements.ts
new file mode 100644
index 0000000..d49b2a1
--- /dev/null
+++ b/apps/api/src/generator/dialplan/announcements.ts
@@ -0,0 +1,21 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+// Announcements (03 §6.11): play a sound, then continue.
+import type { Snapshot } from '../load.ts';
+import * as s from '../sanitize.ts';
+import { destApp } from './inbound.ts';
+
+export function renderAnnouncements(snap: Snapshot): string {
+ return [...snap.announcements]
+ .sort((a, b) => a.id.localeCompare(b.id))
+ .map((a) =>
+ [
+ `; ─── Announcement "${s.name(a.name)}"`,
+ `[sp-ann-${s.ident(a.id)}]`,
+ 'exten => s,1,Answer()',
+ ` same => n,Playback(${s.sound(a.sound)})`,
+ ` same => n,${destApp(a.destination, snap)}`,
+ '',
+ ].join('\n'),
+ )
+ .join('\n');
+}
diff --git a/apps/api/src/generator/dialplan/inbound.ts b/apps/api/src/generator/dialplan/inbound.ts
index f245852..a9092d0 100644
--- a/apps/api/src/generator/dialplan/inbound.ts
+++ b/apps/api/src/generator/dialplan/inbound.ts
@@ -29,8 +29,12 @@ export function destApp(d: Destination, snap: Snapshot): string {
return snap.timeConditions.some((t) => t.id === d.id)
? `Goto(sp-tc-${s.ident(d.id)},s,1)`
: 'Hangup()';
+ case 'announcement':
+ return snap.announcements.some((a) => a.id === d.id)
+ ? `Goto(sp-ann-${s.ident(d.id)},s,1)`
+ : 'Hangup()';
default:
- // TODO(M6, M8): queues, conferences, announcements.
+ // TODO(M8): queues, conferences.
return 'Hangup()';
}
}
diff --git a/apps/api/src/generator/golden-case.ts b/apps/api/src/generator/golden-case.ts
index b010532..dd6fe87 100644
--- a/apps/api/src/generator/golden-case.ts
+++ b/apps/api/src/generator/golden-case.ts
@@ -21,6 +21,7 @@ export function loadCase(name: string): Snapshot {
ivrs?: Partial[];
timeConditions?: Partial[];
holidays?: Snapshot['holidays'];
+ announcements?: Snapshot['announcements'];
};
const settings = Object.fromEntries(
(Object.keys(SettingsSchemas) as SettingKey[])
@@ -52,6 +53,7 @@ export function loadCase(name: string): Snapshot {
id: t.id!,
})),
holidays: raw.holidays ?? [],
+ announcements: raw.announcements ?? [],
ivrs: (raw.ivrs ?? []).map((r, i) => ({
name: `Menu ${i + 1}`,
greeting: 'custom/greeting',
diff --git a/apps/api/src/generator/index.ts b/apps/api/src/generator/index.ts
index d84a3d7..992733c 100644
--- a/apps/api/src/generator/index.ts
+++ b/apps/api/src/generator/index.ts
@@ -19,6 +19,7 @@ import { renderEmergency } from './dialplan/emergency.ts';
import { renderRingGroups } from './dialplan/ringgroups.ts';
import { renderIvrs } from './dialplan/ivrs.ts';
import { renderTimeConditions } from './dialplan/timeconditions.ts';
+import { renderAnnouncements } from './dialplan/announcements.ts';
import { renderVoicemail } from './voicemail.ts';
export { renderTransports };
@@ -54,6 +55,7 @@ const BODIES: Partial string>> = {
renderRingGroups(snap),
renderIvrs(snap),
renderTimeConditions(snap),
+ renderAnnouncements(snap),
renderTrunkEntries(snap),
renderDids(snap),
renderOutbound(snap),
diff --git a/apps/api/src/generator/load.ts b/apps/api/src/generator/load.ts
index 2304955..96e316d 100644
--- a/apps/api/src/generator/load.ts
+++ b/apps/api/src/generator/load.ts
@@ -4,6 +4,7 @@ import { asc, eq } from 'drizzle-orm';
import { alias } from 'drizzle-orm/pg-core';
import type { Db } from '../db/client.ts';
import {
+ announcements,
dids,
extensions,
holidays,
@@ -125,6 +126,14 @@ export type SnapHoliday = {
recurringYearly: boolean;
};
+export type SnapAnnouncement = {
+ id: string;
+ name: string;
+ /** Asterisk sound name, e.g. custom/. */
+ sound: string;
+ destination: Destination;
+};
+
export type Snapshot = {
/** "YYYY-MM-DD" in general.timezone; render() never reads the clock. */
today: string;
@@ -137,6 +146,7 @@ export type Snapshot = {
ivrs: SnapIvr[];
timeConditions: SnapTimeCondition[];
holidays: SnapHoliday[];
+ announcements: SnapAnnouncement[];
};
export function todayIn(timezone: string, now = new Date()): string {
@@ -176,6 +186,7 @@ export async function loadSnapshot(db: Db, now = new Date()): Promise
const ivrRows = await db.select().from(ivrs);
const tcRows = await db.select().from(timeConditions);
const holidayRows = await db.select().from(holidays);
+ const annRows = await db.select().from(announcements);
const optionRows = await db.select().from(ivrOptions);
const soundName = new Map(
(await db.select({ id: soundFiles.id, name: soundFiles.asteriskName }).from(soundFiles)).map(
@@ -203,6 +214,12 @@ export async function loadSnapshot(db: Db, now = new Date()): Promise
closedDestination: t.closedDestination as Destination,
holidayDestination: t.holidayDestination as Destination | null,
})),
+ announcements: annRows.map((a) => ({
+ id: a.id,
+ name: a.name,
+ sound: soundName.get(a.soundId)!,
+ destination: a.destination as Destination,
+ })),
holidays: holidayRows.map((h) => ({
name: h.name,
startDate: h.startDate,
diff --git a/apps/api/src/routes/announcements.ts b/apps/api/src/routes/announcements.ts
new file mode 100644
index 0000000..7d3ef4e
--- /dev/null
+++ b/apps/api/src/routes/announcements.ts
@@ -0,0 +1,137 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+// /api/announcements (04 §7.3): play a sound, then continue to a destination. Admin only.
+import { asc, eq } from 'drizzle-orm';
+import type { FastifyInstance } from 'fastify';
+import { z } from 'zod';
+import {
+ AnnouncementCreateSchema,
+ AnnouncementUpdateSchema,
+ type AnnouncementDto,
+ type Destination,
+ type ListResponse,
+} from '@selfphoneng/shared';
+import { requireAdmin } from '../auth/guards.ts';
+import type { Db } from '../db/client.ts';
+import { announcements, soundFiles } from '../db/schema.ts';
+import { badRequest, conflict, notFound } from '../errors.ts';
+import { audit } from '../services/audit.ts';
+import {
+ assertDestinationExists,
+ describeDestination,
+ findReferences,
+} from '../services/destinations.ts';
+
+type Row = typeof announcements.$inferSelect;
+
+async function toDto(db: Db, a: Row): Promise {
+ const destination = a.destination as Destination;
+ const [s] = await db
+ .select({ name: soundFiles.name })
+ .from(soundFiles)
+ .where(eq(soundFiles.id, a.soundId));
+ return {
+ id: a.id,
+ name: a.name,
+ soundId: a.soundId,
+ soundName: s?.name ?? '?',
+ destination,
+ destinationSummary: await describeDestination(db, destination),
+ };
+}
+
+const IdParams = z.object({ id: z.uuid() });
+
+async function assertPrompt(db: Db, id: string | undefined) {
+ if (!id) return;
+ const [s] = await db
+ .select({ kind: soundFiles.kind })
+ .from(soundFiles)
+ .where(eq(soundFiles.id, id));
+ if (s?.kind !== 'prompt') throw badRequest('That sound no longer exists');
+}
+
+export async function announcementRoutes(app: FastifyInstance) {
+ const { db } = app;
+ app.addHook('preHandler', requireAdmin);
+
+ async function getAnn(id: string) {
+ const [a] = await db.select().from(announcements).where(eq(announcements.id, id));
+ if (!a) throw notFound('Announcement not found');
+ return a;
+ }
+
+ app.get('/api/announcements', async (): Promise> => {
+ const rows = await db.select().from(announcements).orderBy(asc(announcements.name));
+ return { items: await Promise.all(rows.map((a) => toDto(db, a))), nextCursor: null };
+ });
+
+ app.get('/api/announcements/:id', async (req) =>
+ toDto(db, await getAnn(IdParams.parse(req.params).id)),
+ );
+
+ app.post('/api/announcements', async (req, reply) => {
+ const body = AnnouncementCreateSchema.parse(req.body);
+ await assertPrompt(db, body.soundId);
+ await assertDestinationExists(db, body.destination);
+ const [a] = (await db.insert(announcements).values(body).returning()) as [Row];
+ await audit(db, {
+ actorUserId: req.user!.id,
+ actorIp: req.ip,
+ action: 'create',
+ entityType: 'announcement',
+ entityId: a.id,
+ summary: `Created announcement ${a.name}`,
+ diff: { after: body },
+ });
+ app.generator.requestApply('api-change');
+ return reply.code(201).send(await toDto(db, a));
+ });
+
+ app.patch('/api/announcements/:id', async (req) => {
+ const { id } = IdParams.parse(req.params);
+ const body = AnnouncementUpdateSchema.parse(req.body);
+ await getAnn(id);
+ await assertPrompt(db, body.soundId);
+ if (body.destination) {
+ if (body.destination.type === 'announcement' && body.destination.id === id)
+ throw badRequest("An announcement can't continue to itself");
+ await assertDestinationExists(db, body.destination);
+ }
+ const [a] = (await db
+ .update(announcements)
+ .set(body)
+ .where(eq(announcements.id, id))
+ .returning()) as [Row];
+ await audit(db, {
+ actorUserId: req.user!.id,
+ actorIp: req.ip,
+ action: 'update',
+ entityType: 'announcement',
+ entityId: id,
+ summary: `Updated announcement ${a.name}`,
+ diff: { after: body },
+ });
+ app.generator.requestApply('api-change');
+ return toDto(db, a);
+ });
+
+ app.delete('/api/announcements/:id', async (req) => {
+ const { id } = IdParams.parse(req.params);
+ const a = await getAnn(id);
+ const usedBy = await findReferences(db, 'announcement', id);
+ if (usedBy.length)
+ throw conflict(`${a.name} is still used by ${usedBy.join(', ')}`, { usedBy });
+ await db.delete(announcements).where(eq(announcements.id, id));
+ await audit(db, {
+ actorUserId: req.user!.id,
+ actorIp: req.ip,
+ action: 'delete',
+ entityType: 'announcement',
+ entityId: id,
+ summary: `Deleted announcement ${a.name}`,
+ diff: { before: a },
+ });
+ app.generator.requestApply('api-change');
+ return { ok: true };
+ });
+}
diff --git a/apps/api/test/announcements.test.ts b/apps/api/test/announcements.test.ts
new file mode 100644
index 0000000..a9423ae
--- /dev/null
+++ b/apps/api/test/announcements.test.ts
@@ -0,0 +1,91 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+// M6-T5: /api/announcements (03 §6.11).
+import { readFile } from 'node:fs/promises';
+import { join } from 'node:path';
+import type { LightMyRequestResponse } from 'fastify';
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+import { soundFiles } from '../src/db/schema.ts';
+import { hasTestDb } from './db.ts';
+import { ADMIN, H, createTestApp } from './app.ts';
+
+describe.skipIf(!hasTestDb)('/api/announcements', () => {
+ let t: Awaited>;
+ let req: (method: string, url: string, payload?: unknown) => Promise;
+ let dan: string;
+ const sound = '44444444-4444-4444-8444-444444444447';
+ let id: string;
+
+ beforeAll(async () => {
+ t = await createTestApp();
+ await t.clearMustChange(ADMIN.email);
+ const cookie = await t.login(ADMIN.email, ADMIN.password);
+ req = (method, url, payload) =>
+ t.app.inject({
+ method: method as 'GET',
+ url,
+ payload: payload as object,
+ headers: { ...H, cookie },
+ });
+ dan = (await req('POST', '/api/extensions', { number: '101', name: 'Dan Doe' })).json().id;
+ await t.db.insert(soundFiles).values({
+ id: sound,
+ name: 'Our hours',
+ kind: 'prompt',
+ source: 'upload',
+ asteriskName: `custom/${sound}`,
+ durationMs: 4000,
+ });
+ });
+ afterAll(() => t?.close());
+
+ it('plays the sound, then continues; shows up in the dialplan', async () => {
+ const r = await req('POST', '/api/announcements', {
+ name: 'Hours',
+ soundId: sound,
+ destination: { type: 'voicemail', id: dan },
+ });
+ expect(r.statusCode, r.body).toBe(201);
+ id = r.json().id;
+ expect(r.json()).toMatchObject({
+ soundName: 'Our hours',
+ destinationSummary: 'Voicemail of Dan Doe (101)',
+ });
+ await t.app.generator.apply({ trigger: 'test' });
+ const conf = await readFile(join(t.genDir, 'extensions_selfphoneng.conf'), 'utf8');
+ expect(conf).toContain(
+ `[sp-ann-${id}]\nexten => s,1,Answer()\n same => n,Playback(custom/${sound})\n same => n,Goto(sp-vm-direct,101,1)`,
+ );
+ });
+
+ it('refuses a missing sound, itself as destination; sound and announcement are protected', async () => {
+ expect(
+ (
+ await req('POST', '/api/announcements', {
+ name: 'X',
+ soundId: '00000000-0000-4000-8000-000000000999',
+ destination: { type: 'hangup' },
+ })
+ ).statusCode,
+ ).toBe(400);
+ expect(
+ (
+ await req('PATCH', `/api/announcements/${id}`, {
+ destination: { type: 'announcement', id },
+ })
+ ).statusCode,
+ ).toBe(400);
+ const s = await req('DELETE', `/api/sounds/${sound}`);
+ expect(s.json().error.details.usedBy).toEqual(['announcement Hours']);
+ const ann2 = (
+ await req('POST', '/api/announcements', {
+ name: 'Before hours',
+ soundId: sound,
+ destination: { type: 'announcement', id },
+ })
+ ).json();
+ expect(ann2.destinationSummary).toBe('Announcement Hours');
+ expect((await req('DELETE', `/api/announcements/${id}`)).statusCode).toBe(409);
+ expect((await req('DELETE', `/api/announcements/${ann2.id}`)).statusCode).toBe(200);
+ expect((await req('DELETE', `/api/announcements/${id}`)).statusCode).toBe(200);
+ });
+});
diff --git a/apps/api/test/asterisk/call-test.ts b/apps/api/test/asterisk/call-test.ts
index ec556f2..a464170 100644
--- a/apps/api/test/asterisk/call-test.ts
+++ b/apps/api/test/asterisk/call-test.ts
@@ -68,6 +68,7 @@ function snapshot(): Snapshot {
ivrs: [],
timeConditions: [],
holidays: [],
+ announcements: [],
extensions: [ext('101', 'Alice'), ext('102', 'Bob')],
};
}
diff --git a/apps/api/test/asterisk/trunk-test.ts b/apps/api/test/asterisk/trunk-test.ts
index 2469885..717b2b8 100644
--- a/apps/api/test/asterisk/trunk-test.ts
+++ b/apps/api/test/asterisk/trunk-test.ts
@@ -104,6 +104,7 @@ function snapshot(): Snapshot {
ivrs: [],
timeConditions: [],
holidays: [],
+ announcements: [],
routes: [
{
id: 'aaaaaaaa-0000-4000-8000-000000000001',
diff --git a/apps/api/test/asterisk/voicemail-test.ts b/apps/api/test/asterisk/voicemail-test.ts
index 80ecaef..5708172 100644
--- a/apps/api/test/asterisk/voicemail-test.ts
+++ b/apps/api/test/asterisk/voicemail-test.ts
@@ -63,6 +63,7 @@ function snapshot(): Snapshot {
ivrs: [],
timeConditions: [],
holidays: [],
+ announcements: [],
extensions: [ext('101', 'Alice'), ext('102', 'Bob')],
};
}
diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts
index a3e1bbc..9121f40 100644
--- a/apps/web/src/api/client.ts
+++ b/apps/web/src/api/client.ts
@@ -1,6 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Typed fetch wrappers (05 §1). Every request sends X-Requested-With (CSRF, 04 §2.2).
import type {
+ AnnouncementCreate,
+ AnnouncementDto,
+ AnnouncementUpdate,
AuthUser,
DidCreate,
DidDto,
@@ -240,3 +243,11 @@ export const holidaysApi = {
importUsFederal: (year: number) =>
api<{ added: number }>('POST', '/api/holidays/import-us-federal', { year }),
};
+
+export const announcementsApi = {
+ list: () => api>('GET', '/api/announcements').then((r) => r.items),
+ create: (a: AnnouncementCreate) => api('POST', '/api/announcements', a),
+ update: (id: string, a: AnnouncementUpdate) =>
+ api('PATCH', `/api/announcements/${id}`, a),
+ remove: (id: string) => api<{ ok: true }>('DELETE', `/api/announcements/${id}`),
+};
diff --git a/apps/web/src/components/DestinationPicker.tsx b/apps/web/src/components/DestinationPicker.tsx
index 102ef06..21c0437 100644
--- a/apps/web/src/components/DestinationPicker.tsx
+++ b/apps/web/src/components/DestinationPicker.tsx
@@ -4,10 +4,16 @@
import { useQuery } from '@tanstack/react-query';
import { useId, useState } from 'react';
import { normalizeNumber, type Destination } from '@selfphoneng/shared';
-import { extensionsApi, ivrsApi, ringGroupsApi, timeConditionsApi } from '../api/client.ts';
+import {
+ announcementsApi,
+ extensionsApi,
+ ivrsApi,
+ ringGroupsApi,
+ timeConditionsApi,
+} from '../api/client.ts';
import { inputClass } from './ui.tsx';
-type IdKind = 'user' | 'voicemail' | 'ring_group' | 'ivr' | 'time_condition';
+type IdKind = 'user' | 'voicemail' | 'ring_group' | 'ivr' | 'time_condition' | 'announcement';
type Kind = IdKind | 'external' | 'hangup';
const KINDS: { kind: Kind; label: string }[] = [
@@ -16,6 +22,7 @@ const KINDS: { kind: Kind; label: string }[] = [
{ kind: 'ring_group', label: 'Ring group' },
{ kind: 'ivr', label: 'Digital receptionist' },
{ kind: 'time_condition', label: 'Office hours' },
+ { kind: 'announcement', label: 'Announcement' },
{ kind: 'external', label: 'Forward to an outside number' },
{ kind: 'hangup', label: 'Hang up' },
];
@@ -26,6 +33,7 @@ const TARGET_NOUN: Record = {
ring_group: 'ring group',
ivr: 'digital receptionist',
time_condition: 'office hours',
+ announcement: 'announcement',
};
const isIdKind = (k: Kind): k is IdKind => k !== 'external' && k !== 'hangup';
/** Kinds whose targets are the same rows: switching between them keeps the pick. */
@@ -133,6 +141,7 @@ function useTargets() {
const groups = useQuery({ queryKey: ['ring-groups'], queryFn: ringGroupsApi.list });
const ivrs = useQuery({ queryKey: ['ivrs'], queryFn: ivrsApi.list });
const tcs = useQuery({ queryKey: ['time-conditions'], queryFn: timeConditionsApi.list });
+ const anns = useQuery({ queryKey: ['announcements'], queryFn: announcementsApi.list });
const rows = (q: { data?: { id: string; name: string; number?: string }[] }) =>
q.data?.map((x) => ({ id: x.id, label: x.number ? `${x.number} ${x.name}` : x.name }));
return {
@@ -141,6 +150,7 @@ function useTargets() {
ring_group: rows(groups),
ivr: rows(ivrs),
time_condition: rows(tcs),
+ announcement: rows(anns),
} satisfies Record;
}
diff --git a/apps/web/src/pages/admin/SoundsPage.tsx b/apps/web/src/pages/admin/SoundsPage.tsx
index cac9278..67e3f15 100644
--- a/apps/web/src/pages/admin/SoundsPage.tsx
+++ b/apps/web/src/pages/admin/SoundsPage.tsx
@@ -3,8 +3,17 @@
// Upload by picking or dropping a file; play, rename, delete. Music on hold arrives with M8.
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
-import { SOUND_MAX_BYTES, type SoundDto } from '@selfphoneng/shared';
-import { soundsApi } from '../../api/client.ts';
+import {
+ AnnouncementCreateSchema,
+ SOUND_MAX_BYTES,
+ type AnnouncementCreate,
+ type AnnouncementDto,
+ type Destination,
+ type SoundDto,
+} from '@selfphoneng/shared';
+import { announcementsApi, soundsApi } from '../../api/client.ts';
+import { DestinationPicker } from '../../components/DestinationPicker.tsx';
+import { SoundPicker } from '../../components/SoundPicker.tsx';
import { ConfirmDialog, Dialog } from '../../components/Dialog.tsx';
import { Alert, Button, Field, inputClass } from '../../components/ui.tsx';
@@ -163,10 +172,153 @@ export function SoundsPage() {
}}
onConfirm={() => deleting && remove.mutate(deleting.id)}
/>
+
);
}
+/** Announcements (03 §6.11): play one of these sounds, then send the caller on. */
+function Announcements() {
+ const qc = useQueryClient();
+ const list = useQuery({ queryKey: ['announcements'], queryFn: announcementsApi.list });
+ const [editing, setEditing] = useState(null);
+ const [deleting, setDeleting] = useState(null);
+ const refresh = () => qc.invalidateQueries({ queryKey: ['announcements'] });
+ const remove = useMutation({
+ mutationFn: (id: string) => announcementsApi.remove(id),
+ onSuccess: async () => {
+ setDeleting(null);
+ await refresh();
+ },
+ });
+ return (
+
+
+
+ Announcements
+
+ setEditing('new')}>Add announcement
+
+
+ An announcement plays a sound ("Our hours are…") and then sends the caller on, e.g. back to
+ a menu. Use them from phone numbers, menus and office hours.
+
+ {list.data && list.data.length > 0 && (
+
+ {list.data.map((a) => (
+
+ {a.name}
+
+ plays "{a.soundName}", then {a.destinationSummary}
+
+ setEditing(a)}>Edit
+ setDeleting(a)}>
+ Delete
+
+
+ ))}
+
+ )}
+ {editing && (
+ setEditing(null)}
+ onSaved={refresh}
+ />
+ )}
+ Delete {deleting?.name}? }
+ confirmLabel="Delete"
+ busy={remove.isPending}
+ error={remove.error?.message}
+ onCancel={() => {
+ setDeleting(null);
+ remove.reset();
+ }}
+ onConfirm={() => deleting && remove.mutate(deleting.id)}
+ />
+
+ );
+}
+
+function AnnouncementForm({
+ ann,
+ onClose,
+ onSaved,
+}: {
+ ann: AnnouncementDto | null;
+ onClose: () => void;
+ onSaved: () => Promise;
+}) {
+ const [name, setName] = useState(ann?.name ?? '');
+ const [soundId, setSoundId] = useState(ann?.soundId ?? null);
+ const [dest, setDest] = useState(ann?.destination ?? { type: 'hangup' });
+ const [errors, setErrors] = useState>({});
+ const save = useMutation({
+ mutationFn: (body: AnnouncementCreate) =>
+ ann ? announcementsApi.update(ann.id, body) : announcementsApi.create(body),
+ onSuccess: async () => {
+ await onSaved();
+ onClose();
+ },
+ });
+ function submit() {
+ const parsed = AnnouncementCreateSchema.safeParse({
+ name,
+ soundId: soundId ?? undefined,
+ destination: dest ?? undefined,
+ });
+ if (!parsed.success) {
+ setErrors(Object.fromEntries(parsed.error.issues.map((i) => [String(i.path[0]), i.message])));
+ return;
+ }
+ setErrors({});
+ save.mutate(parsed.data);
+ }
+ return (
+
+ Cancel
+
+ Save
+
+ >
+ }
+ >
+
+
+ {(p) => (
+ setName(e.target.value)}
+ />
+ )}
+
+
+
+ {save.error &&
{save.error.message} }
+
+
+ );
+}
+
function RenameDialog({
sound,
onClose,
diff --git a/docs/spec/05-web-ui.md b/docs/spec/05-web-ui.md
index e932665..7cd4457 100644
--- a/docs/spec/05-web-ui.md
+++ b/docs/spec/05-web-ui.md
@@ -38,7 +38,7 @@ Left sidebar navigation, in this order:
| Office hours | `/admin/hours` | Time conditions: weekly grid editor (click-drag hours per day) that saves to `open_ranges`; timezone; holidays toggle; open/closed/holiday destinations; override digit; big status chip "Open now / Closed now" with Force open / Force closed / Automatic buttons. Holidays sub-tab: list + "Add US federal holidays for {year}" |
| Conferences | `/admin/conferences` | number, name, PIN, admin PIN, max members, record |
| Paging | `/admin/paging` | number, name, members, duplex |
-| Sounds & music | `/admin/sounds` | Prompts list with play buttons, upload (drag-drop), TTS (M12). MOH classes with their files |
+| Sounds & music | `/admin/sounds` | Prompts list with play buttons, upload (drag-drop), TTS (M12). MOH classes with their files (M8). An **Announcements** section (play a sound, then a destination) lives here too: no page of its own (M6-T5) |
| Phones (devices) | `/admin/devices` | Table: MAC, vendor, model, user, last provisioned. Form: MAC (any format → normalized), vendor, model, extension. Shows the provisioning URL to type into the phone, and "Preview config" |
| Contacts | `/admin/contacts` | Company phonebook, CSV import |
| Call history | `/admin/calls` | Filters + table + CSV export; a row expands to show the CDR legs and plays the recording |
diff --git a/packages/shared/src/announcements.ts b/packages/shared/src/announcements.ts
new file mode 100644
index 0000000..25d3925
--- /dev/null
+++ b/packages/shared/src/announcements.ts
@@ -0,0 +1,22 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+// Announcements (02 announcements, 03 §6.11): play a sound, then send the caller on.
+import { z } from 'zod';
+import { DestinationSchema, type Destination } from './destination.ts';
+
+const AnnouncementFields = z.object({
+ name: z.string().trim().min(1, 'Required').max(40),
+ soundId: z.uuid('Pick a sound'),
+ destination: DestinationSchema,
+});
+
+export const AnnouncementCreateSchema = AnnouncementFields;
+export const AnnouncementUpdateSchema = AnnouncementFields.partial();
+export type AnnouncementCreate = z.input;
+export type AnnouncementUpdate = z.input;
+
+export type AnnouncementDto = z.output & {
+ id: string;
+ destination: Destination;
+ destinationSummary: string;
+ soundName: string;
+};
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
index 8b5496d..d5b303d 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/src/index.ts
@@ -15,3 +15,4 @@ export * from './sounds.ts';
export * from './ringgroups.ts';
export * from './ivrs.ts';
export * from './timeconditions.ts';
+export * from './announcements.ts';