announcements: play a sound, then continue

/api/announcements CRUD (sound must be a prompt, destination must exist
and not be itself, delete refused while referenced); the generator
renders sp-ann-{id} (Answer, Playback, destApp) and destApp sends
announcement destinations there. The ivr golden case gains "press 3:
hours announcement, then back to the menu". Announcements are managed in
a section of Sounds & music (05 updated); the DestinationPicker gains
"Announcement".

Task: M6-T5
This commit is contained in:
iamdoubz committed 2026-10-18 11:00:33 -05:00
1 parent 108547046c
commit dd640b368b
19 files changed
+553 -17

No files matched your search

+2
View File
@@ -25,6 +25,7 @@ import { soundRoutes } from './routes/sounds.ts';
import { ringGroupRoutes } from './routes/ringgroups.ts'; import { ringGroupRoutes } from './routes/ringgroups.ts';
import { ivrRoutes } from './routes/ivrs.ts'; import { ivrRoutes } from './routes/ivrs.ts';
import { timeConditionRoutes } from './routes/timeconditions.ts'; import { timeConditionRoutes } from './routes/timeconditions.ts';
import { announcementRoutes } from './routes/announcements.ts';
declare module 'fastify' { declare module 'fastify' {
interface FastifyInstance { interface FastifyInstance {
@@ -101,6 +102,7 @@ export function buildApp(config: Config, db: Db, publicDir = resolve('public'))
app.register(ringGroupRoutes); app.register(ringGroupRoutes);
app.register(ivrRoutes); app.register(ivrRoutes);
app.register(timeConditionRoutes); app.register(timeConditionRoutes);
app.register(announcementRoutes);
// Always registered: reply.sendFile also streams voicemail audio (with Range) from the spool. // Always registered: reply.sendFile also streams voicemail audio (with Range) from the spool.
app.register(fastifyStatic, { app.register(fastifyStatic, {
@@ -163,6 +163,7 @@ exten => #,1,Goto(sp-vm-direct,101,1)
exten => 0,1,Goto(sp-ext-forward,13125550000,1) exten => 0,1,Goto(sp-ext-forward,13125550000,1)
exten => 1,1,Goto(sp-rg-22222222-2222-4222-8222-222222222500,s,1) exten => 1,1,Goto(sp-rg-22222222-2222-4222-8222-222222222500,s,1)
exten => 2,1,Goto(sp-local-numbers,102,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 => 9,1,Goto(sp-ivr-33333333-3333-4333-8333-333333333600,s,1)
exten => i,1,Set(SP_IVR_TRIES=$[${SP_IVR_TRIES} + 1]) exten => i,1,Set(SP_IVR_TRIES=$[${SP_IVR_TRIES} + 1])
same => n,GotoIf($[${SP_IVR_TRIES} >= 2]?giveup) 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) exten => _*3[123]X,1,Playback(ss-noservice)
same => n,Hangup() 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" ; ─── Calls from trunk "voip.ms"
[sp-from-trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37] [sp-from-trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37]
exten => s,1,Goto(sp-from-trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37,unknown,1) exten => s,1,Goto(sp-from-trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37,unknown,1)
@@ -32,24 +32,54 @@
"options": [ "options": [
{ {
"digit": "2", "digit": "2",
"destination": { "type": "user", "id": "00000000-0000-4000-8000-000000000102" } "destination": {
"type": "user",
"id": "00000000-0000-4000-8000-000000000102"
}
}, },
{ {
"digit": "1", "digit": "1",
"destination": { "type": "ring_group", "id": "22222222-2222-4222-8222-222222222500" } "destination": {
"type": "ring_group",
"id": "22222222-2222-4222-8222-222222222500"
}
}, },
{ {
"digit": "9", "digit": "9",
"destination": { "type": "ivr", "id": "33333333-3333-4333-8333-333333333600" } "destination": {
"type": "ivr",
"id": "33333333-3333-4333-8333-333333333600"
}
}, },
{ {
"digit": "#", "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" }, "timeoutDestination": {
"invalidDestination": { "type": "hangup" } "type": "voicemail",
"id": "00000000-0000-4000-8000-000000000101"
},
"invalidDestination": {
"type": "hangup"
}
}, },
{ {
"id": "33333333-3333-4333-8333-333333333601", "id": "33333333-3333-4333-8333-333333333601",
@@ -58,16 +88,40 @@
"greeting": "custom/44444444-4444-4444-8444-444444444445", "greeting": "custom/44444444-4444-4444-8444-444444444445",
"invalidSound": "custom/44444444-4444-4444-8444-444444444446", "invalidSound": "custom/44444444-4444-4444-8444-444444444446",
"allowDirectDial": false, "allowDirectDial": false,
"timeoutDestination": { "type": "ivr", "id": "33333333-3333-4333-8333-333333333600" }, "timeoutDestination": {
"invalidDestination": { "type": "ivr", "id": "33333333-3333-4333-8333-333333333600" } "type": "ivr",
"id": "33333333-3333-4333-8333-333333333600"
},
"invalidDestination": {
"type": "ivr",
"id": "33333333-3333-4333-8333-333333333600"
}
} }
], ],
"dids": [ "dids": [
{ {
"id": "11111111-1111-4111-8111-111111111111", "id": "11111111-1111-4111-8111-111111111111",
"number": "13125551234", "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"
}
}
]
} }
@@ -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');
}
+5 -1
View File
@@ -29,8 +29,12 @@ export function destApp(d: Destination, snap: Snapshot): string {
return snap.timeConditions.some((t) => t.id === d.id) return snap.timeConditions.some((t) => t.id === d.id)
? `Goto(sp-tc-${s.ident(d.id)},s,1)` ? `Goto(sp-tc-${s.ident(d.id)},s,1)`
: 'Hangup()'; : 'Hangup()';
case 'announcement':
return snap.announcements.some((a) => a.id === d.id)
? `Goto(sp-ann-${s.ident(d.id)},s,1)`
: 'Hangup()';
default: default:
// TODO(M6, M8): queues, conferences, announcements. // TODO(M8): queues, conferences.
return 'Hangup()'; return 'Hangup()';
} }
} }
+2
View File
@@ -21,6 +21,7 @@ export function loadCase(name: string): Snapshot {
ivrs?: Partial<Snapshot['ivrs'][number]>[]; ivrs?: Partial<Snapshot['ivrs'][number]>[];
timeConditions?: Partial<Snapshot['timeConditions'][number]>[]; timeConditions?: Partial<Snapshot['timeConditions'][number]>[];
holidays?: Snapshot['holidays']; holidays?: Snapshot['holidays'];
announcements?: Snapshot['announcements'];
}; };
const settings = Object.fromEntries( const settings = Object.fromEntries(
(Object.keys(SettingsSchemas) as SettingKey[]) (Object.keys(SettingsSchemas) as SettingKey[])
@@ -52,6 +53,7 @@ export function loadCase(name: string): Snapshot {
id: t.id!, id: t.id!,
})), })),
holidays: raw.holidays ?? [], holidays: raw.holidays ?? [],
announcements: raw.announcements ?? [],
ivrs: (raw.ivrs ?? []).map((r, i) => ({ ivrs: (raw.ivrs ?? []).map((r, i) => ({
name: `Menu ${i + 1}`, name: `Menu ${i + 1}`,
greeting: 'custom/greeting', greeting: 'custom/greeting',
+2
View File
@@ -19,6 +19,7 @@ import { renderEmergency } from './dialplan/emergency.ts';
import { renderRingGroups } from './dialplan/ringgroups.ts'; import { renderRingGroups } from './dialplan/ringgroups.ts';
import { renderIvrs } from './dialplan/ivrs.ts'; import { renderIvrs } from './dialplan/ivrs.ts';
import { renderTimeConditions } from './dialplan/timeconditions.ts'; import { renderTimeConditions } from './dialplan/timeconditions.ts';
import { renderAnnouncements } from './dialplan/announcements.ts';
import { renderVoicemail } from './voicemail.ts'; import { renderVoicemail } from './voicemail.ts';
export { renderTransports }; export { renderTransports };
@@ -54,6 +55,7 @@ const BODIES: Partial<Record<GeneratedFileName, (snap: Snapshot) => string>> = {
renderRingGroups(snap), renderRingGroups(snap),
renderIvrs(snap), renderIvrs(snap),
renderTimeConditions(snap), renderTimeConditions(snap),
renderAnnouncements(snap),
renderTrunkEntries(snap), renderTrunkEntries(snap),
renderDids(snap), renderDids(snap),
renderOutbound(snap), renderOutbound(snap),
+17
View File
@@ -4,6 +4,7 @@ import { asc, eq } from 'drizzle-orm';
import { alias } from 'drizzle-orm/pg-core'; import { alias } from 'drizzle-orm/pg-core';
import type { Db } from '../db/client.ts'; import type { Db } from '../db/client.ts';
import { import {
announcements,
dids, dids,
extensions, extensions,
holidays, holidays,
@@ -125,6 +126,14 @@ export type SnapHoliday = {
recurringYearly: boolean; recurringYearly: boolean;
}; };
export type SnapAnnouncement = {
id: string;
name: string;
/** Asterisk sound name, e.g. custom/<id>. */
sound: string;
destination: Destination;
};
export type Snapshot = { export type Snapshot = {
/** "YYYY-MM-DD" in general.timezone; render() never reads the clock. */ /** "YYYY-MM-DD" in general.timezone; render() never reads the clock. */
today: string; today: string;
@@ -137,6 +146,7 @@ export type Snapshot = {
ivrs: SnapIvr[]; ivrs: SnapIvr[];
timeConditions: SnapTimeCondition[]; timeConditions: SnapTimeCondition[];
holidays: SnapHoliday[]; holidays: SnapHoliday[];
announcements: SnapAnnouncement[];
}; };
export function todayIn(timezone: string, now = new Date()): string { export function todayIn(timezone: string, now = new Date()): string {
@@ -176,6 +186,7 @@ export async function loadSnapshot(db: Db, now = new Date()): Promise<Snapshot>
const ivrRows = await db.select().from(ivrs); const ivrRows = await db.select().from(ivrs);
const tcRows = await db.select().from(timeConditions); const tcRows = await db.select().from(timeConditions);
const holidayRows = await db.select().from(holidays); const holidayRows = await db.select().from(holidays);
const annRows = await db.select().from(announcements);
const optionRows = await db.select().from(ivrOptions); const optionRows = await db.select().from(ivrOptions);
const soundName = new Map( const soundName = new Map(
(await db.select({ id: soundFiles.id, name: soundFiles.asteriskName }).from(soundFiles)).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<Snapshot>
closedDestination: t.closedDestination as Destination, closedDestination: t.closedDestination as Destination,
holidayDestination: t.holidayDestination as Destination | null, 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) => ({ holidays: holidayRows.map((h) => ({
name: h.name, name: h.name,
startDate: h.startDate, startDate: h.startDate,
+137
View File
@@ -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<AnnouncementDto> {
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<ListResponse<AnnouncementDto>> => {
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 };
});
}
+91
View File
@@ -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<ReturnType<typeof createTestApp>>;
let req: (method: string, url: string, payload?: unknown) => Promise<LightMyRequestResponse>;
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);
});
});
+1
View File
@@ -68,6 +68,7 @@ function snapshot(): Snapshot {
ivrs: [], ivrs: [],
timeConditions: [], timeConditions: [],
holidays: [], holidays: [],
announcements: [],
extensions: [ext('101', 'Alice'), ext('102', 'Bob')], extensions: [ext('101', 'Alice'), ext('102', 'Bob')],
}; };
} }
+1
View File
@@ -104,6 +104,7 @@ function snapshot(): Snapshot {
ivrs: [], ivrs: [],
timeConditions: [], timeConditions: [],
holidays: [], holidays: [],
announcements: [],
routes: [ routes: [
{ {
id: 'aaaaaaaa-0000-4000-8000-000000000001', id: 'aaaaaaaa-0000-4000-8000-000000000001',
+1
View File
@@ -63,6 +63,7 @@ function snapshot(): Snapshot {
ivrs: [], ivrs: [],
timeConditions: [], timeConditions: [],
holidays: [], holidays: [],
announcements: [],
extensions: [ext('101', 'Alice'), ext('102', 'Bob')], extensions: [ext('101', 'Alice'), ext('102', 'Bob')],
}; };
} }
+11
View File
@@ -1,6 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
// Typed fetch wrappers (05 §1). Every request sends X-Requested-With (CSRF, 04 §2.2). // Typed fetch wrappers (05 §1). Every request sends X-Requested-With (CSRF, 04 §2.2).
import type { import type {
AnnouncementCreate,
AnnouncementDto,
AnnouncementUpdate,
AuthUser, AuthUser,
DidCreate, DidCreate,
DidDto, DidDto,
@@ -240,3 +243,11 @@ export const holidaysApi = {
importUsFederal: (year: number) => importUsFederal: (year: number) =>
api<{ added: number }>('POST', '/api/holidays/import-us-federal', { year }), api<{ added: number }>('POST', '/api/holidays/import-us-federal', { year }),
}; };
export const announcementsApi = {
list: () => api<ListResponse<AnnouncementDto>>('GET', '/api/announcements').then((r) => r.items),
create: (a: AnnouncementCreate) => api<AnnouncementDto>('POST', '/api/announcements', a),
update: (id: string, a: AnnouncementUpdate) =>
api<AnnouncementDto>('PATCH', `/api/announcements/${id}`, a),
remove: (id: string) => api<{ ok: true }>('DELETE', `/api/announcements/${id}`),
};
+12 -2
View File
@@ -4,10 +4,16 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useId, useState } from 'react'; import { useId, useState } from 'react';
import { normalizeNumber, type Destination } from '@selfphoneng/shared'; 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'; 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'; type Kind = IdKind | 'external' | 'hangup';
const KINDS: { kind: Kind; label: string }[] = [ const KINDS: { kind: Kind; label: string }[] = [
@@ -16,6 +22,7 @@ const KINDS: { kind: Kind; label: string }[] = [
{ kind: 'ring_group', label: 'Ring group' }, { kind: 'ring_group', label: 'Ring group' },
{ kind: 'ivr', label: 'Digital receptionist' }, { kind: 'ivr', label: 'Digital receptionist' },
{ kind: 'time_condition', label: 'Office hours' }, { kind: 'time_condition', label: 'Office hours' },
{ kind: 'announcement', label: 'Announcement' },
{ kind: 'external', label: 'Forward to an outside number' }, { kind: 'external', label: 'Forward to an outside number' },
{ kind: 'hangup', label: 'Hang up' }, { kind: 'hangup', label: 'Hang up' },
]; ];
@@ -26,6 +33,7 @@ const TARGET_NOUN: Record<IdKind, string> = {
ring_group: 'ring group', ring_group: 'ring group',
ivr: 'digital receptionist', ivr: 'digital receptionist',
time_condition: 'office hours', time_condition: 'office hours',
announcement: 'announcement',
}; };
const isIdKind = (k: Kind): k is IdKind => k !== 'external' && k !== 'hangup'; const isIdKind = (k: Kind): k is IdKind => k !== 'external' && k !== 'hangup';
/** Kinds whose targets are the same rows: switching between them keeps the pick. */ /** 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 groups = useQuery({ queryKey: ['ring-groups'], queryFn: ringGroupsApi.list });
const ivrs = useQuery({ queryKey: ['ivrs'], queryFn: ivrsApi.list }); const ivrs = useQuery({ queryKey: ['ivrs'], queryFn: ivrsApi.list });
const tcs = useQuery({ queryKey: ['time-conditions'], queryFn: timeConditionsApi.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 }[] }) => 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 })); q.data?.map((x) => ({ id: x.id, label: x.number ? `${x.number} ${x.name}` : x.name }));
return { return {
@@ -141,6 +150,7 @@ function useTargets() {
ring_group: rows(groups), ring_group: rows(groups),
ivr: rows(ivrs), ivr: rows(ivrs),
time_condition: rows(tcs), time_condition: rows(tcs),
announcement: rows(anns),
} satisfies Record<IdKind, unknown>; } satisfies Record<IdKind, unknown>;
} }
+154 -2
View File
@@ -3,8 +3,17 @@
// Upload by picking or dropping a file; play, rename, delete. Music on hold arrives with M8. // 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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react'; import { useState } from 'react';
import { SOUND_MAX_BYTES, type SoundDto } from '@selfphoneng/shared'; import {
import { soundsApi } from '../../api/client.ts'; 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 { ConfirmDialog, Dialog } from '../../components/Dialog.tsx';
import { Alert, Button, Field, inputClass } from '../../components/ui.tsx'; import { Alert, Button, Field, inputClass } from '../../components/ui.tsx';
@@ -163,10 +172,153 @@ export function SoundsPage() {
}} }}
onConfirm={() => deleting && remove.mutate(deleting.id)} onConfirm={() => deleting && remove.mutate(deleting.id)}
/> />
<Announcements />
</section> </section>
); );
} }
/** 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<AnnouncementDto | 'new' | null>(null);
const [deleting, setDeleting] = useState<AnnouncementDto | null>(null);
const refresh = () => qc.invalidateQueries({ queryKey: ['announcements'] });
const remove = useMutation({
mutationFn: (id: string) => announcementsApi.remove(id),
onSuccess: async () => {
setDeleting(null);
await refresh();
},
});
return (
<section aria-labelledby="ann-h" className="mt-4 flex flex-col gap-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<h2 id="ann-h" className="text-lg font-semibold">
Announcements
</h2>
<Button onClick={() => setEditing('new')}>Add announcement</Button>
</div>
<p className="text-sm text-zinc-500">
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.
</p>
{list.data && list.data.length > 0 && (
<ul className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
{list.data.map((a) => (
<li
key={a.id}
className="flex flex-wrap items-center gap-x-3 gap-y-1 px-3 py-2 text-sm"
>
<span className="font-medium">{a.name}</span>
<span className="min-w-0 flex-1 truncate text-zinc-500">
plays "{a.soundName}", then {a.destinationSummary}
</span>
<Button onClick={() => setEditing(a)}>Edit</Button>
<Button variant="ghost" onClick={() => setDeleting(a)}>
Delete
</Button>
</li>
))}
</ul>
)}
{editing && (
<AnnouncementForm
ann={editing === 'new' ? null : editing}
onClose={() => setEditing(null)}
onSaved={refresh}
/>
)}
<ConfirmDialog
open={deleting !== null}
title="Delete announcement"
message={<p>Delete {deleting?.name}?</p>}
confirmLabel="Delete"
busy={remove.isPending}
error={remove.error?.message}
onCancel={() => {
setDeleting(null);
remove.reset();
}}
onConfirm={() => deleting && remove.mutate(deleting.id)}
/>
</section>
);
}
function AnnouncementForm({
ann,
onClose,
onSaved,
}: {
ann: AnnouncementDto | null;
onClose: () => void;
onSaved: () => Promise<unknown>;
}) {
const [name, setName] = useState(ann?.name ?? '');
const [soundId, setSoundId] = useState<string | null>(ann?.soundId ?? null);
const [dest, setDest] = useState<Destination | null>(ann?.destination ?? { type: 'hangup' });
const [errors, setErrors] = useState<Record<string, string>>({});
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 (
<Dialog
open
onClose={onClose}
title={ann ? ann.name : 'New announcement'}
footer={
<>
<Button onClick={onClose}>Cancel</Button>
<Button variant="primary" onClick={submit} disabled={save.isPending}>
Save
</Button>
</>
}
>
<div className="flex flex-col gap-4">
<Field label="Name" error={errors.name}>
{(p) => (
<input
{...p}
className={inputClass}
value={name}
onChange={(e) => setName(e.target.value)}
/>
)}
</Field>
<SoundPicker label="Sound" value={soundId} onChange={setSoundId} error={errors.soundId} />
<DestinationPicker
label="Then"
value={dest ?? { type: 'hangup' }}
onChange={setDest}
error={errors.destination}
exclude={ann?.id}
/>
{save.error && <Alert>{save.error.message}</Alert>}
</div>
</Dialog>
);
}
function RenameDialog({ function RenameDialog({
sound, sound,
onClose, onClose,
+1 -1
View File
@@ -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}" | | 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 | | Conferences | `/admin/conferences` | number, name, PIN, admin PIN, max members, record |
| Paging | `/admin/paging` | number, name, members, duplex | | 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" | | 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 | | 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 | | Call history | `/admin/calls` | Filters + table + CSV export; a row expands to show the CDR legs and plays the recording |
+22
View File
@@ -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<typeof AnnouncementCreateSchema>;
export type AnnouncementUpdate = z.input<typeof AnnouncementUpdateSchema>;
export type AnnouncementDto = z.output<typeof AnnouncementFields> & {
id: string;
destination: Destination;
destinationSummary: string;
soundName: string;
};
+1
View File
@@ -15,3 +15,4 @@ export * from './sounds.ts';
export * from './ringgroups.ts'; export * from './ringgroups.ts';
export * from './ivrs.ts'; export * from './ivrs.ts';
export * from './timeconditions.ts'; export * from './timeconditions.ts';
export * from './announcements.ts';