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:
1 parent
108547046c
commit
dd640b368b
19 files changed
+553
-17
No files matched your search
@@ -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, {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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()';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ export function loadCase(name: string): Snapshot {
|
||||
ivrs?: Partial<Snapshot['ivrs'][number]>[];
|
||||
timeConditions?: Partial<Snapshot['timeConditions'][number]>[];
|
||||
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',
|
||||
|
||||
@@ -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<Record<GeneratedFileName, (snap: Snapshot) => string>> = {
|
||||
renderRingGroups(snap),
|
||||
renderIvrs(snap),
|
||||
renderTimeConditions(snap),
|
||||
renderAnnouncements(snap),
|
||||
renderTrunkEntries(snap),
|
||||
renderDids(snap),
|
||||
renderOutbound(snap),
|
||||
|
||||
@@ -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/<id>. */
|
||||
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<Snapshot>
|
||||
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<Snapshot>
|
||||
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,
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -68,6 +68,7 @@ function snapshot(): Snapshot {
|
||||
ivrs: [],
|
||||
timeConditions: [],
|
||||
holidays: [],
|
||||
announcements: [],
|
||||
extensions: [ext('101', 'Alice'), ext('102', 'Bob')],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -104,6 +104,7 @@ function snapshot(): Snapshot {
|
||||
ivrs: [],
|
||||
timeConditions: [],
|
||||
holidays: [],
|
||||
announcements: [],
|
||||
routes: [
|
||||
{
|
||||
id: 'aaaaaaaa-0000-4000-8000-000000000001',
|
||||
|
||||
@@ -63,6 +63,7 @@ function snapshot(): Snapshot {
|
||||
ivrs: [],
|
||||
timeConditions: [],
|
||||
holidays: [],
|
||||
announcements: [],
|
||||
extensions: [ext('101', 'Alice'), ext('102', 'Bob')],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<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}`),
|
||||
};
|
||||
@@ -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<IdKind, string> = {
|
||||
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<IdKind, unknown>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 />
|
||||
</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({
|
||||
sound,
|
||||
onClose,
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -15,3 +15,4 @@ export * from './sounds.ts';
|
||||
export * from './ringgroups.ts';
|
||||
export * from './ivrs.ts';
|
||||
export * from './timeconditions.ts';
|
||||
export * from './announcements.ts';
|
||||
Reference in new issue
Block a user