paging: paging groups, *80 intercom, auto-answer headers
ci / check (push) Successful in 1m7s
ci / images (push) Successful in 3m20s

/api/paging-groups CRUD (members as memberIds). The generator renders
sp-page-{id} per 03 §6.11 (desk phones only, auto-answer headers via
sp-sub-autoanswer) plus the number in sp-local-numbers. Added the `s`
option so phones already on a call aren't paged, which also keeps a
pager who is a member from being paged themselves. A group with no
members just hangs up.

*80<ext> intercom checks the target first: digits only and an endpoint
in sp-from-ext, else ss-noservice (the spec's one-liner passed
${EXTEN:3} straight into Page()). Both written back into 03.

Web: Paging page (list, drawer form with member checkboxes). Golden
case paging. e2e m8-t4 with baresip phones that honor the headers
(sip_autoanswer): an ordinary call rings, a page is answered by itself
and muted, the busy pager is skipped, *80 is two-way, *80999 refused.
The check on a real Yealink/Grandstream stays open (09).

Task: M8-T4
This commit is contained in:
iamdoubz committed 2026-10-19 09:40:22 -05:00
1 parent a6c423de97
commit e95271e694
47 files changed
+1466 -22

No files matched your search

+1
View File
@@ -67,6 +67,7 @@ function snapshot(): Snapshot {
ringGroups: [],
queues: [],
conferences: [],
pagingGroups: [],
ivrs: [],
timeConditions: [],
holidays: [],
+1
View File
@@ -156,6 +156,7 @@ function snapshot(): Snapshot {
: ({ type: 'voicemail', id: CDR_IDS.ext('102') } as const),
})),
conferences: [],
pagingGroups: [],
ivrs: [
{
id: CDR_IDS.ivr,
+1
View File
@@ -106,6 +106,7 @@ function snapshot(): Snapshot {
ringGroups: [],
queues: [],
conferences: [],
pagingGroups: [],
ivrs: [],
timeConditions: [],
holidays: [],
+1
View File
@@ -62,6 +62,7 @@ function snapshot(): Snapshot {
ringGroups: [],
queues: [],
conferences: [],
pagingGroups: [],
ivrs: [],
timeConditions: [],
holidays: [],
+76
View File
@@ -0,0 +1,76 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// M8-T4: /api/paging-groups (03 §6.11, 04 §7.3).
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 { hasTestDb } from './db.ts';
import { ADMIN, H, createTestApp } from './app.ts';
describe.skipIf(!hasTestDb)('/api/paging-groups', () => {
let t: Awaited<ReturnType<typeof createTestApp>>;
let req: (method: string, url: string, payload?: unknown) => Promise<LightMyRequestResponse>;
const ext: Record<string, string> = {};
let groupId: string;
const dialplan = async () => {
await t.app.generator.apply({ trigger: 'test' });
return readFile(join(t.genDir, 'extensions_selfphoneng.conf'), 'utf8');
};
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 },
});
for (const [number, name] of [
['102', 'Ann Lee'],
['101', 'Dan Doe'],
] as const)
ext[number] = (await req('POST', '/api/extensions', { number, name })).json().id;
});
afterAll(() => t?.close());
it('creates a group: desk phones only, auto-answer, busy phones skipped', async () => {
const r = await req('POST', '/api/paging-groups', {
number: '850',
name: 'Warehouse',
memberIds: [ext['102'], ext['101']],
});
expect(r.statusCode, r.body).toBe(201);
groupId = r.json().id;
expect(r.json()).toMatchObject({ duplex: false, memberIds: [ext['101'], ext['102']] });
const d = await dialplan();
expect(d).toContain(`exten => 850,1,Goto(sp-page-${groupId},s,1)`);
expect(d).toContain('exten => s,1,Page(PJSIP/101&PJSIP/102,iqsb(sp-sub-autoanswer^s^1),30)');
});
it('checks the number and the members', async () => {
const bad = (body: object) =>
req('POST', '/api/paging-groups', { number: '851', name: 'X', ...body });
expect((await bad({ number: '800' })).statusCode).toBe(400); // conference range
expect((await bad({ number: '850' })).statusCode).toBe(409);
expect((await bad({ memberIds: [ext['101'], ext['101']] })).statusCode).toBe(400);
expect((await bad({ memberIds: ['00000000-0000-4000-8000-000000000000'] })).statusCode).toBe(
400,
);
});
it('two-way and fewer members; delete removes it from the dialplan', async () => {
const r = await req('PATCH', `/api/paging-groups/${groupId}`, {
duplex: true,
memberIds: [ext['102']],
});
expect(r.statusCode, r.body).toBe(200);
expect(await dialplan()).toContain(
'exten => s,1,Page(PJSIP/102,diqsb(sp-sub-autoanswer^s^1),30)',
);
expect((await req('DELETE', `/api/paging-groups/${groupId}`)).statusCode).toBe(200);
expect(await dialplan()).not.toContain('sp-page-');
});
});