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 +

+ +
+

+ 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} + + + +
  • + ))} +
+ )} + {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 ( + + + + + } + > +
+ + {(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';