dids: phone numbers, destinations, inbound dialplan, Phone numbers page
DIDs CRUD (any input format, normalized; 409 on duplicates; a number
that is an emergency or default caller ID can't be removed). Shared
destination service: assertDestinationExists, describeDestination
("Ring Dan Doe (101)") and findReferences over every destination
column in 02 §1 plus the unmatched-number setting; deleting an
extension a destination points at is now a 409 naming the places.
Generator: destApp (user, external, hangup for now), sp-from-trunk-*
per enabled trunk, sp-did-exists (left out when empty) and sp-inbound
with caller-name prefix. Golden case voipms-basic. Phone numbers page
with a minimal DestinationPicker (user, outside number, hang up).
trunk-test.ts: the fake provider calls the DID with a 10-digit
Request-URI like voip.ms; baresip 101 answers, caller ID arrives
normalized, 500 packets each way on both legs. scripts/e2e/m3-t2.mjs
checks the UI → Asterisk path. Still to do with real voip.ms: the
mobile-to-DID call and recording which header carries the DID (06 §3).
Task: M3-T2
This commit is contained in:
1 parent
1b7f60b5f2
commit
c117d8bb1d
28 files changed
+1371
-21
No files matched your search
@@ -15,6 +15,7 @@ import { userRoutes } from './routes/users.ts';
|
||||
import { systemRoutes } from './routes/system.ts';
|
||||
import { extensionRoutes } from './routes/extensions.ts';
|
||||
import { trunkRoutes } from './routes/trunks.ts';
|
||||
import { didRoutes } from './routes/dids.ts';
|
||||
import { liveRoutes } from './live/hub.ts';
|
||||
|
||||
declare module 'fastify' {
|
||||
@@ -81,6 +82,7 @@ export function buildApp(config: Config, db: Db, publicDir = resolve('public'))
|
||||
app.register(systemRoutes);
|
||||
app.register(extensionRoutes);
|
||||
app.register(trunkRoutes);
|
||||
app.register(didRoutes);
|
||||
app.register(liveRoutes);
|
||||
|
||||
if (existsSync(publicDir)) {
|
||||
|
||||
@@ -102,6 +102,10 @@ include => sp-parkedcalls
|
||||
|
||||
; users (sorted by number)
|
||||
|
||||
[sp-inbound]
|
||||
; anything else
|
||||
exten => _[0-9a-z].,1,Hangup()
|
||||
|
||||
; ─── Filled in by later milestones
|
||||
[sp-emergency-dial] ; M3-T4 (03 §6.14)
|
||||
exten => pending,1,Hangup()
|
||||
|
||||
@@ -111,6 +111,10 @@ exten => 105,1,Gosub(sp-sub-user,s,1(105,25,0,never))
|
||||
same => n,Hangup()
|
||||
exten => 105,hint,PJSIP/105&PJSIP/105-web
|
||||
|
||||
[sp-inbound]
|
||||
; anything else
|
||||
exten => _[0-9a-z].,1,Hangup()
|
||||
|
||||
; ─── Filled in by later milestones
|
||||
[sp-emergency-dial] ; M3-T4 (03 §6.14)
|
||||
exten => pending,1,Hangup()
|
||||
|
||||
@@ -105,6 +105,16 @@ exten => 101,1,Gosub(sp-sub-user,s,1(101,25,0,never))
|
||||
same => n,Hangup()
|
||||
exten => 101,hint,PJSIP/101&PJSIP/101-web
|
||||
|
||||
[sp-did-exists]
|
||||
exten => 13125551234,1,NoOp()
|
||||
|
||||
[sp-inbound]
|
||||
; DID 13125551234
|
||||
exten => 13125551234,1,Set(__SP_DID=13125551234)
|
||||
same => n,Hangup()
|
||||
; anything else
|
||||
exten => _[0-9a-z].,1,Hangup()
|
||||
|
||||
; ─── Filled in by later milestones
|
||||
[sp-emergency-dial] ; M3-T4 (03 §6.14)
|
||||
exten => pending,1,Hangup()
|
||||
|
||||
@@ -102,6 +102,44 @@ include => sp-parkedcalls
|
||||
|
||||
; users (sorted by number)
|
||||
|
||||
; ─── Calls from trunk "Carrier B IP auth"
|
||||
[sp-from-trunk-a1b2c3d4-0000-4000-8000-000000000001]
|
||||
exten => s,1,Goto(sp-from-trunk-a1b2c3d4-0000-4000-8000-000000000001,unknown,1)
|
||||
exten => _[+0-9a-zA-Z].,1,Set(__SP_DIRECTION=inbound)
|
||||
same => n,Gosub(sp-sub-normalize,s,1(${EXTEN}))
|
||||
same => n,Set(SP_IN=${GOSUB_RETVAL})
|
||||
same => n,GotoIf($["${SP_IN}" != "" & ${DIALPLAN_EXISTS(sp-did-exists,${SP_IN},1)}]?caller)
|
||||
; Request-URI didn't hold one of our DIDs: try the To header (06 §3)
|
||||
same => n,Set(TOU=${CUT(CUT(PJSIP_HEADER(read,To),@,1),:,2)})
|
||||
same => n,Gosub(sp-sub-normalize,s,1(${TOU}))
|
||||
same => n,Set(SP_IN=${GOSUB_RETVAL})
|
||||
same => n(caller),Gosub(sp-sub-normalize,s,1(${CALLERID(num)}))
|
||||
same => n,Set(CALLERID(num)=${GOSUB_RETVAL})
|
||||
same => n,GotoIf($["${DB(SP/BLOCK/${CALLERID(num)})}" = "1"]?blocked)
|
||||
same => n,Goto(sp-inbound,${IF($["${SP_IN}" = ""]?unknown:${SP_IN})},1)
|
||||
same => n(blocked),Hangup(21)
|
||||
|
||||
; ─── Calls from trunk "Carrier C evilinjected = yes"
|
||||
[sp-from-trunk-a1b2c3d4-0000-4000-8000-000000000002]
|
||||
exten => s,1,Goto(sp-from-trunk-a1b2c3d4-0000-4000-8000-000000000002,unknown,1)
|
||||
exten => _[+0-9a-zA-Z].,1,Set(__SP_DIRECTION=inbound)
|
||||
same => n,Gosub(sp-sub-normalize,s,1(${EXTEN}))
|
||||
same => n,Set(SP_IN=${GOSUB_RETVAL})
|
||||
same => n,GotoIf($["${SP_IN}" != "" & ${DIALPLAN_EXISTS(sp-did-exists,${SP_IN},1)}]?caller)
|
||||
; Request-URI didn't hold one of our DIDs: try the To header (06 §3)
|
||||
same => n,Set(TOU=${CUT(CUT(PJSIP_HEADER(read,To),@,1),:,2)})
|
||||
same => n,Gosub(sp-sub-normalize,s,1(${TOU}))
|
||||
same => n,Set(SP_IN=${GOSUB_RETVAL})
|
||||
same => n(caller),Gosub(sp-sub-normalize,s,1(${CALLERID(num)}))
|
||||
same => n,Set(CALLERID(num)=${GOSUB_RETVAL})
|
||||
same => n,GotoIf($["${DB(SP/BLOCK/${CALLERID(num)})}" = "1"]?blocked)
|
||||
same => n,Goto(sp-inbound,${IF($["${SP_IN}" = ""]?unknown:${SP_IN})},1)
|
||||
same => n(blocked),Hangup(21)
|
||||
|
||||
[sp-inbound]
|
||||
; anything else
|
||||
exten => _[0-9a-z].,1,Hangup()
|
||||
|
||||
; ─── Filled in by later milestones
|
||||
[sp-emergency-dial] ; M3-T4 (03 §6.14)
|
||||
exten => pending,1,Hangup()
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||
SP_DEFAULT_CID=
|
||||
SP_DEFAULT_E911_CID=
|
||||
SP_DEFAULT_CID=13125551234
|
||||
SP_DEFAULT_E911_CID=13125551234
|
||||
SP_REC_ANNOUNCE=
|
||||
SP_CLASS_101=2
|
||||
@@ -105,6 +105,38 @@ exten => 101,1,Gosub(sp-sub-user,s,1(101,25,0,never))
|
||||
same => n,Hangup()
|
||||
exten => 101,hint,PJSIP/101&PJSIP/101-web
|
||||
|
||||
; ─── 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)
|
||||
exten => _[+0-9a-zA-Z].,1,Set(__SP_DIRECTION=inbound)
|
||||
same => n,Gosub(sp-sub-normalize,s,1(${EXTEN}))
|
||||
same => n,Set(SP_IN=${GOSUB_RETVAL})
|
||||
same => n,GotoIf($["${SP_IN}" != "" & ${DIALPLAN_EXISTS(sp-did-exists,${SP_IN},1)}]?caller)
|
||||
; Request-URI didn't hold one of our DIDs: try the To header (06 §3)
|
||||
same => n,Set(TOU=${CUT(CUT(PJSIP_HEADER(read,To),@,1),:,2)})
|
||||
same => n,Gosub(sp-sub-normalize,s,1(${TOU}))
|
||||
same => n,Set(SP_IN=${GOSUB_RETVAL})
|
||||
same => n(caller),Gosub(sp-sub-normalize,s,1(${CALLERID(num)}))
|
||||
same => n,Set(CALLERID(num)=${GOSUB_RETVAL})
|
||||
same => n,GotoIf($["${DB(SP/BLOCK/${CALLERID(num)})}" = "1"]?blocked)
|
||||
same => n,Goto(sp-inbound,${IF($["${SP_IN}" = ""]?unknown:${SP_IN})},1)
|
||||
same => n(blocked),Hangup(21)
|
||||
|
||||
[sp-did-exists]
|
||||
exten => 13125551234,1,NoOp()
|
||||
exten => 13125559876,1,NoOp()
|
||||
|
||||
[sp-inbound]
|
||||
; DID 13125551234 "Main line"
|
||||
exten => 13125551234,1,Set(__SP_DID=13125551234)
|
||||
same => n,Goto(sp-local-numbers,101,1)
|
||||
; DID 13125559876 "Sales line"
|
||||
exten => 13125559876,1,Set(__SP_DID=13125559876)
|
||||
same => n,Set(CALLERID(name)=SALES:${CALLERID(name)})
|
||||
same => n,Goto(sp-ext-forward,13125550000,1)
|
||||
; anything else
|
||||
exten => _[0-9a-z].,1,Hangup()
|
||||
|
||||
; ─── Filled in by later milestones
|
||||
[sp-emergency-dial] ; M3-T4 (03 §6.14)
|
||||
exten => pending,1,Hangup()
|
||||
|
||||
@@ -1,4 +1,23 @@
|
||||
{
|
||||
"settings": {
|
||||
"routing.defaultCallerIdDidId": "11111111-1111-4111-8111-111111111111",
|
||||
"emergency.defaultCallerIdDidId": "11111111-1111-4111-8111-111111111111"
|
||||
},
|
||||
"extensions": [{ "number": "101", "name": "Dan Doe" }],
|
||||
"trunks": [{ "id": "7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37" }]
|
||||
"trunks": [{ "id": "7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37" }],
|
||||
"dids": [
|
||||
{
|
||||
"id": "11111111-1111-4111-8111-111111111111",
|
||||
"number": "13125551234",
|
||||
"label": "Main line",
|
||||
"destination": { "type": "user", "id": "00000000-0000-4000-8000-000000000000" }
|
||||
},
|
||||
{
|
||||
"id": "22222222-2222-4222-8222-222222222222",
|
||||
"number": "13125559876",
|
||||
"label": "Sales; line",
|
||||
"callerIdNamePrefix": "SALES:",
|
||||
"destination": { "type": "external", "number": "13125550000" }
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -108,6 +108,10 @@ exten => 102,1,Gosub(sp-sub-user,s,1(102,25,0,all))
|
||||
same => n,Hangup()
|
||||
exten => 102,hint,PJSIP/102
|
||||
|
||||
[sp-inbound]
|
||||
; anything else
|
||||
exten => _[0-9a-z].,1,Hangup()
|
||||
|
||||
; ─── Filled in by later milestones
|
||||
[sp-emergency-dial] ; M3-T4 (03 §6.14)
|
||||
exten => pending,1,Hangup()
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Calls from trunks (03 §6.4, §6.5) and where destinations go (03 §6.0).
|
||||
import type { Destination } from '@selfphoneng/shared';
|
||||
import type { Snapshot } from '../load.ts';
|
||||
import * as s from '../sanitize.ts';
|
||||
|
||||
/** ONE dialplan application that sends the call to `d` (03 §6.0). */
|
||||
export function destApp(d: Destination, snap: Snapshot): string {
|
||||
switch (d.type) {
|
||||
case 'user':
|
||||
case 'voicemail': {
|
||||
const e = snap.extensions.find((x) => x.id === d.id);
|
||||
if (!e) return 'Hangup()'; // can't happen: the API refuses dangling destinations (02 §1)
|
||||
return `Goto(${d.type === 'user' ? 'sp-local-numbers' : 'sp-vm-direct'},${s.digits(e.number)},1)`;
|
||||
}
|
||||
case 'external':
|
||||
return `Goto(sp-ext-forward,${s.dialable(d.number)},1)`;
|
||||
case 'hangup':
|
||||
return 'Hangup()';
|
||||
default:
|
||||
// TODO(M6, M8): ring groups, queues, IVRs, time conditions, conferences, announcements.
|
||||
return 'Hangup()';
|
||||
}
|
||||
}
|
||||
|
||||
/** One entry context per enabled trunk. */
|
||||
export function renderTrunkEntries(snap: Snapshot): string {
|
||||
return snap.trunks
|
||||
.filter((t) => t.enabled)
|
||||
.sort((a, b) => a.id.localeCompare(b.id))
|
||||
.map((t) => {
|
||||
const ctx = `sp-from-trunk-${s.ident(t.id)}`;
|
||||
return (
|
||||
[
|
||||
`; ─── Calls from trunk "${s.name(t.name)}"`,
|
||||
`[${ctx}]`,
|
||||
`exten => s,1,Goto(${ctx},unknown,1)`,
|
||||
'exten => _[+0-9a-zA-Z].,1,Set(__SP_DIRECTION=inbound)',
|
||||
' same => n,Gosub(sp-sub-normalize,s,1(${EXTEN}))',
|
||||
' same => n,Set(SP_IN=${GOSUB_RETVAL})',
|
||||
' same => n,GotoIf($["${SP_IN}" != "" & ${DIALPLAN_EXISTS(sp-did-exists,${SP_IN},1)}]?caller)',
|
||||
" ; Request-URI didn't hold one of our DIDs: try the To header (06 §3)",
|
||||
' same => n,Set(TOU=${CUT(CUT(PJSIP_HEADER(read,To),@,1),:,2)})',
|
||||
' same => n,Gosub(sp-sub-normalize,s,1(${TOU}))',
|
||||
' same => n,Set(SP_IN=${GOSUB_RETVAL})',
|
||||
' same => n(caller),Gosub(sp-sub-normalize,s,1(${CALLERID(num)}))',
|
||||
' same => n,Set(CALLERID(num)=${GOSUB_RETVAL})',
|
||||
' same => n,GotoIf($["${DB(SP/BLOCK/${CALLERID(num)})}" = "1"]?blocked)',
|
||||
' same => n,Goto(sp-inbound,${IF($["${SP_IN}" = ""]?unknown:${SP_IN})},1)',
|
||||
' same => n(blocked),Hangup(21)',
|
||||
].join('\n') + '\n'
|
||||
);
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
/** sp-did-exists (left out when there are no DIDs: Asterisk won't create an empty context). */
|
||||
export function renderDids(snap: Snapshot): string {
|
||||
const dids = [...snap.dids].sort((a, b) => a.number.localeCompare(b.number));
|
||||
const out: string[] = [];
|
||||
if (dids.length)
|
||||
out.push(
|
||||
['[sp-did-exists]', ...dids.map((d) => `exten => ${s.digits(d.number)},1,NoOp()`)].join('\n'),
|
||||
);
|
||||
const inbound = ['[sp-inbound]'];
|
||||
for (const d of dids) {
|
||||
const num = s.digits(d.number);
|
||||
const prefix = d.callerIdNamePrefix ? s.name(d.callerIdNamePrefix) : '';
|
||||
inbound.push(
|
||||
`; DID ${num}${d.label ? ` "${s.name(d.label)}"` : ''}`,
|
||||
`exten => ${num},1,Set(__SP_DID=${num})`,
|
||||
...(prefix ? [` same => n,Set(CALLERID(name)=${prefix}\${CALLERID(name)})`] : []),
|
||||
// TODO(M7): record_inbound (announcement + sp-sub-record).
|
||||
` same => n,${destApp(d.destination, snap)}`,
|
||||
);
|
||||
}
|
||||
inbound.push(
|
||||
'; anything else',
|
||||
`exten => _[0-9a-z].,1,${destApp(snap.settings['routing.unmatchedDidDestination'], snap)}`,
|
||||
);
|
||||
out.push(inbound.join('\n'));
|
||||
return out.join('\n\n') + '\n';
|
||||
}
|
||||
@@ -18,7 +18,7 @@ export function loadCase(name: string): Snapshot {
|
||||
today?: string;
|
||||
settings?: Record<string, unknown>;
|
||||
extensions?: Partial<Snapshot['extensions'][number]>[];
|
||||
dids?: Snapshot['dids'];
|
||||
dids?: Partial<Snapshot['dids'][number]>[];
|
||||
trunks?: Partial<Snapshot['trunks'][number]>[];
|
||||
};
|
||||
const settings = Object.fromEntries(
|
||||
@@ -29,7 +29,15 @@ export function loadCase(name: string): Snapshot {
|
||||
return {
|
||||
today: raw.today ?? '2026-10-17',
|
||||
settings,
|
||||
dids: raw.dids ?? [],
|
||||
dids: (raw.dids ?? []).map((d) => ({
|
||||
trunkId: '7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37',
|
||||
label: null,
|
||||
destination: { type: 'hangup' as const },
|
||||
callerIdNamePrefix: null,
|
||||
...d,
|
||||
id: d.id!,
|
||||
number: d.number!,
|
||||
})),
|
||||
trunks: (raw.trunks ?? []).map((t) => ({
|
||||
name: 'voip.ms',
|
||||
enabled: true,
|
||||
@@ -73,9 +81,15 @@ const cases = readdirSync(ROOT).filter((d) => existsSync(join(ROOT, d, 'snapshot
|
||||
|
||||
describe('golden', () => {
|
||||
it('has the required cases', () => {
|
||||
// 03 §12.1; the rest arrive with their features (M3, M6, M8).
|
||||
// 03 §12.1; the rest arrive with their features (M6, M8).
|
||||
expect(cases).toEqual(
|
||||
expect.arrayContaining(['empty', 'one-user', 'webrtc-disabled', 'hostile-names']),
|
||||
expect.arrayContaining([
|
||||
'empty',
|
||||
'one-user',
|
||||
'webrtc-disabled',
|
||||
'hostile-names',
|
||||
'voipms-basic',
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import { renderPjsip, renderRtp, renderTransports } from './pjsip.ts';
|
||||
import { STATIC_DIALPLAN } from './dialplan/static.ts';
|
||||
import { renderGlobals, renderLocalNumbers } from './dialplan/local.ts';
|
||||
import { PENDING_CONTEXTS } from './dialplan/pending.ts';
|
||||
import { renderDids, renderTrunkEntries } from './dialplan/inbound.ts';
|
||||
|
||||
export { renderTransports };
|
||||
|
||||
@@ -40,7 +41,15 @@ const BODIES: Partial<Record<GeneratedFileName, (snap: Snapshot) => string>> = {
|
||||
'rtp_selfphoneng.conf': renderRtp,
|
||||
'extensions_globals_selfphoneng.conf': renderGlobals,
|
||||
'extensions_selfphoneng.conf': (snap) =>
|
||||
`${STATIC_DIALPLAN}\n${renderLocalNumbers(snap)}\n${PENDING_CONTEXTS}`,
|
||||
[
|
||||
STATIC_DIALPLAN,
|
||||
renderLocalNumbers(snap),
|
||||
renderTrunkEntries(snap),
|
||||
renderDids(snap),
|
||||
PENDING_CONTEXTS,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n'),
|
||||
};
|
||||
|
||||
/** PURE: no I/O, no clock, no randomness. Same snapshot → byte-identical files. */
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { Db } from '../db/client.ts';
|
||||
import { dids, extensions, trunks, users } from '../db/schema.ts';
|
||||
import { decrypt } from '../crypto/secrets.ts';
|
||||
import { getAllSettings } from '../services/settings.ts';
|
||||
import type { Destination } from '@selfphoneng/shared';
|
||||
|
||||
export type SnapExtension = {
|
||||
id: string;
|
||||
@@ -26,7 +27,14 @@ export type SnapExtension = {
|
||||
isAdmin: boolean; // the extension's user is an admin (time-condition override codes)
|
||||
};
|
||||
|
||||
export type SnapDid = { id: string; number: string };
|
||||
export type SnapDid = {
|
||||
id: string;
|
||||
number: string;
|
||||
trunkId: string;
|
||||
label: string | null;
|
||||
destination: Destination;
|
||||
callerIdNamePrefix: string | null;
|
||||
};
|
||||
|
||||
export type SnapTrunk = {
|
||||
id: string;
|
||||
@@ -50,7 +58,7 @@ export type Snapshot = {
|
||||
today: string;
|
||||
settings: Awaited<ReturnType<typeof getAllSettings>>;
|
||||
extensions: SnapExtension[];
|
||||
dids: SnapDid[]; // M3 adds routing fields
|
||||
dids: SnapDid[];
|
||||
trunks: SnapTrunk[];
|
||||
};
|
||||
|
||||
@@ -75,12 +83,21 @@ export async function loadSnapshot(db: Db, now = new Date()): Promise<Snapshot>
|
||||
.leftJoin(outDid, eq(outDid.id, extensions.outboundCallerIdDidId))
|
||||
.leftJoin(e911Did, eq(e911Did.id, extensions.emergencyCallerIdDidId));
|
||||
|
||||
const didRows = await db.select({ id: dids.id, number: dids.number }).from(dids);
|
||||
const didRows = await db
|
||||
.select({
|
||||
id: dids.id,
|
||||
number: dids.number,
|
||||
trunkId: dids.trunkId,
|
||||
label: dids.label,
|
||||
destination: dids.destination,
|
||||
callerIdNamePrefix: dids.callerIdNamePrefix,
|
||||
})
|
||||
.from(dids);
|
||||
const trunkRows = await db.select().from(trunks);
|
||||
return {
|
||||
today: todayIn(settings['general.timezone'], now),
|
||||
settings,
|
||||
dids: didRows,
|
||||
dids: didRows.map((d) => ({ ...d, destination: d.destination as Destination })),
|
||||
trunks: trunkRows.map((t) => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// /api/dids: phone numbers and where their calls go (04 §7.3, 06 §3). Admin only.
|
||||
import { asc, eq } from 'drizzle-orm';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
DidCreateSchema,
|
||||
DidUpdateSchema,
|
||||
formatNumber,
|
||||
type Destination,
|
||||
type DidDto,
|
||||
type ListResponse,
|
||||
} from '@selfphoneng/shared';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { dids, extensions, trunks } from '../db/schema.ts';
|
||||
import { badRequest, conflict, notFound } from '../errors.ts';
|
||||
import { requireAdmin } from '../auth/guards.ts';
|
||||
import { audit } from '../services/audit.ts';
|
||||
import { assertDestinationExists, describeDestination } from '../services/destinations.ts';
|
||||
import { getSetting } from '../services/settings.ts';
|
||||
|
||||
type DidRow = typeof dids.$inferSelect;
|
||||
|
||||
async function toDto(db: Db, d: DidRow): Promise<DidDto> {
|
||||
const destination = d.destination as Destination;
|
||||
return {
|
||||
id: d.id,
|
||||
number: d.number,
|
||||
trunkId: d.trunkId,
|
||||
label: d.label,
|
||||
destination,
|
||||
destinationSummary: await describeDestination(db, destination),
|
||||
callerIdNamePrefix: d.callerIdNamePrefix,
|
||||
e911Address: d.e911Address,
|
||||
createdAt: d.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
const IdParams = z.object({ id: z.uuid() });
|
||||
|
||||
async function getDid(db: Db, id: string): Promise<DidRow> {
|
||||
const [d] = await db.select().from(dids).where(eq(dids.id, id));
|
||||
if (!d) throw notFound('Phone number not found');
|
||||
return d;
|
||||
}
|
||||
|
||||
async function assertTrunk(db: Db, id: string) {
|
||||
const [t] = await db.select({ id: trunks.id }).from(trunks).where(eq(trunks.id, id));
|
||||
if (!t) throw badRequest('That trunk does not exist');
|
||||
}
|
||||
|
||||
const isUniqueViolation = (e: unknown) =>
|
||||
((e as { cause?: { code?: string } }).cause?.code ?? (e as { code?: string }).code) === '23505';
|
||||
|
||||
export async function didRoutes(app: FastifyInstance) {
|
||||
const { db } = app;
|
||||
app.addHook('preHandler', requireAdmin);
|
||||
|
||||
app.get('/api/dids', async (): Promise<ListResponse<DidDto>> => {
|
||||
const rows = await db.select().from(dids).orderBy(asc(dids.number));
|
||||
return { items: await Promise.all(rows.map((d) => toDto(db, d))), nextCursor: null };
|
||||
});
|
||||
|
||||
app.get('/api/dids/:id', async (req) => {
|
||||
const { id } = IdParams.parse(req.params);
|
||||
return toDto(db, await getDid(db, id));
|
||||
});
|
||||
|
||||
app.post('/api/dids', async (req, reply) => {
|
||||
const body = DidCreateSchema.parse(req.body);
|
||||
await assertTrunk(db, body.trunkId);
|
||||
await assertDestinationExists(db, body.destination);
|
||||
let created: DidRow;
|
||||
try {
|
||||
[created] = (await db.insert(dids).values(body).returning()) as [DidRow];
|
||||
} catch (e) {
|
||||
if (isUniqueViolation(e)) throw conflict(`${formatNumber(body.number)} is already added`);
|
||||
throw e;
|
||||
}
|
||||
const dto = await toDto(db, created);
|
||||
await audit(db, {
|
||||
actorUserId: req.user!.id,
|
||||
actorIp: req.ip,
|
||||
action: 'create',
|
||||
entityType: 'did',
|
||||
entityId: created.id,
|
||||
summary: `Added ${formatNumber(created.number)} → ${dto.destinationSummary}`,
|
||||
diff: { after: body },
|
||||
});
|
||||
app.generator.requestApply('api-change');
|
||||
return reply.code(201).send(dto);
|
||||
});
|
||||
|
||||
app.patch('/api/dids/:id', async (req) => {
|
||||
const { id } = IdParams.parse(req.params);
|
||||
const body = DidUpdateSchema.parse(req.body);
|
||||
const before = await getDid(db, id);
|
||||
if (body.trunkId) await assertTrunk(db, body.trunkId);
|
||||
if (body.destination) await assertDestinationExists(db, body.destination);
|
||||
let after: DidRow;
|
||||
try {
|
||||
[after] = (await db.update(dids).set(body).where(eq(dids.id, id)).returning()) as [DidRow];
|
||||
} catch (e) {
|
||||
if (isUniqueViolation(e)) throw conflict(`${formatNumber(body.number!)} is already added`);
|
||||
throw e;
|
||||
}
|
||||
const changed = (Object.keys(body) as (keyof typeof body)[]).filter(
|
||||
(k) => JSON.stringify(before[k]) !== JSON.stringify(after[k]),
|
||||
);
|
||||
const dto = await toDto(db, after);
|
||||
await audit(db, {
|
||||
actorUserId: req.user!.id,
|
||||
actorIp: req.ip,
|
||||
action: 'update',
|
||||
entityType: 'did',
|
||||
entityId: id,
|
||||
summary: `Updated ${formatNumber(after.number)}${changed.length ? ` (${changed.join(', ')})` : ''}`,
|
||||
diff: {
|
||||
before: Object.fromEntries(changed.map((k) => [k, before[k]])),
|
||||
after: Object.fromEntries(changed.map((k) => [k, after[k]])),
|
||||
},
|
||||
});
|
||||
app.generator.requestApply('api-change');
|
||||
return dto;
|
||||
});
|
||||
|
||||
app.delete('/api/dids/:id', async (req) => {
|
||||
const { id } = IdParams.parse(req.params);
|
||||
const d = await getDid(db, id);
|
||||
// Caller ID uses fall back quietly (FK set null), but emergency caller IDs must not vanish.
|
||||
const usedBy = [
|
||||
...((await getSetting(db, 'emergency.defaultCallerIdDidId')) === id
|
||||
? ['the default emergency caller ID']
|
||||
: []),
|
||||
...((await getSetting(db, 'routing.defaultCallerIdDidId')) === id
|
||||
? ['the default outbound caller ID']
|
||||
: []),
|
||||
...(
|
||||
await db
|
||||
.select({ n: extensions.number })
|
||||
.from(extensions)
|
||||
.where(eq(extensions.emergencyCallerIdDidId, id))
|
||||
).map((r) => `the emergency caller ID of extension ${r.n}`),
|
||||
];
|
||||
if (usedBy.length)
|
||||
throw conflict(`${formatNumber(d.number)} is still ${usedBy.join(', ')}`, { usedBy });
|
||||
await db.delete(dids).where(eq(dids.id, id));
|
||||
await audit(db, {
|
||||
actorUserId: req.user!.id,
|
||||
actorIp: req.ip,
|
||||
action: 'delete',
|
||||
entityType: 'did',
|
||||
entityId: id,
|
||||
summary: `Removed ${formatNumber(d.number)}`,
|
||||
diff: { before: d },
|
||||
});
|
||||
app.generator.requestApply('api-change');
|
||||
return { ok: true };
|
||||
});
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
} from '../crypto/secrets.ts';
|
||||
import { audit } from '../services/audit.ts';
|
||||
import { assertNumberAvailable, nextFreeNumber } from '../services/numbering.ts';
|
||||
import { findReferences } from '../services/destinations.ts';
|
||||
import { getSetting } from '../services/settings.ts';
|
||||
|
||||
type ExtRow = typeof extensions.$inferSelect;
|
||||
@@ -182,7 +183,9 @@ export async function extensionRoutes(app: FastifyInstance) {
|
||||
app.delete('/api/extensions/:id', async (req) => {
|
||||
const { id } = IdParams.parse(req.params);
|
||||
const e = await getExt(db, id);
|
||||
// TODO(M3-T2): refuse with 409 when a destination points here (findReferences, 02 §1).
|
||||
const usedBy = await findReferences(db, 'user', id);
|
||||
if (usedBy.length)
|
||||
throw conflict(`Extension ${e.number} is still used by ${usedBy.join(', ')}`, { usedBy });
|
||||
await db.delete(extensions).where(eq(extensions.id, id)); // members, runtime state, devices cascade
|
||||
await audit(db, {
|
||||
actorUserId: req.user!.id,
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Destinations live in jsonb, so there are no foreign keys: these keep them honest (02 §1).
|
||||
import { eq, sql, type SQL } from 'drizzle-orm';
|
||||
import type { AnyPgColumn } from 'drizzle-orm/pg-core';
|
||||
import { formatNumber, type Destination } from '@selfphoneng/shared';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import {
|
||||
announcements,
|
||||
conferences,
|
||||
dids,
|
||||
extensions,
|
||||
ivrOptions,
|
||||
ivrs,
|
||||
queues,
|
||||
ringGroups,
|
||||
settings,
|
||||
timeConditions,
|
||||
} from '../db/schema.ts';
|
||||
import { badRequest } from '../errors.ts';
|
||||
|
||||
type RefType = Exclude<Destination['type'], 'external' | 'hangup'>;
|
||||
|
||||
const TABLES = {
|
||||
ring_group: ringGroups,
|
||||
queue: queues,
|
||||
ivr: ivrs,
|
||||
time_condition: timeConditions,
|
||||
conference: conferences,
|
||||
announcement: announcements,
|
||||
} as const;
|
||||
|
||||
/** One row's name, or null when it doesn't exist. */
|
||||
async function targetName(db: Db, type: RefType, id: string): Promise<string | null> {
|
||||
if (type === 'user' || type === 'voicemail') {
|
||||
const [e] = await db
|
||||
.select({ number: extensions.number, name: extensions.name })
|
||||
.from(extensions)
|
||||
.where(eq(extensions.id, id));
|
||||
return e ? `${e.name} (${e.number})` : null;
|
||||
}
|
||||
const t = TABLES[type];
|
||||
const [r] = await db.select({ name: t.name }).from(t).where(eq(t.id, id));
|
||||
return r?.name ?? null;
|
||||
}
|
||||
|
||||
/** Throws 400 when a destination points at something that doesn't exist. */
|
||||
export async function assertDestinationExists(db: Db, d: Destination): Promise<void> {
|
||||
if (d.type === 'hangup' || d.type === 'external') return;
|
||||
if ((await targetName(db, d.type, d.id)) === null)
|
||||
throw badRequest(`That ${d.type.replace('_', ' ')} doesn't exist any more`);
|
||||
}
|
||||
|
||||
const LABEL: Record<RefType, string> = {
|
||||
user: 'Ring',
|
||||
voicemail: 'Voicemail of',
|
||||
ring_group: 'Ring group',
|
||||
queue: 'Queue',
|
||||
ivr: 'Digital receptionist',
|
||||
time_condition: 'Office hours',
|
||||
conference: 'Conference',
|
||||
announcement: 'Announcement',
|
||||
};
|
||||
|
||||
/** "Ring Dan Doe (101)", "Call (312) 555-1234", "Hang up". */
|
||||
export async function describeDestination(db: Db, d: Destination): Promise<string> {
|
||||
if (d.type === 'hangup') return 'Hang up';
|
||||
if (d.type === 'external') return `Call ${formatNumber(d.number)}`;
|
||||
return `${LABEL[d.type]} ${(await targetName(db, d.type, d.id)) ?? '(missing)'}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every place a destination points at (type, id). Keep in sync with the column list in 02 §1.
|
||||
* `user` and `voicemail` both point at an extension, so asking for either finds both.
|
||||
*/
|
||||
export async function findReferences(db: Db, type: RefType, id: string): Promise<string[]> {
|
||||
const types = type === 'user' || type === 'voicemail' ? ['user', 'voicemail'] : [type];
|
||||
const matches = (col: AnyPgColumn): SQL =>
|
||||
sql.join(
|
||||
types.map((t) => sql`${col} @> ${JSON.stringify({ type: t, id })}::jsonb`),
|
||||
sql` or `,
|
||||
);
|
||||
const or = (...cols: AnyPgColumn[]) => sql.join(cols.map(matches), sql` or `);
|
||||
const named = (what: string) => (rows: { n: string }[]) => rows.map((r) => `${what} ${r.n}`);
|
||||
const found = [
|
||||
...(await db.select({ n: dids.number }).from(dids).where(matches(dids.destination))).map(
|
||||
(r) => `number ${formatNumber(r.n)}`,
|
||||
),
|
||||
...named('ring group')(
|
||||
await db
|
||||
.select({ n: ringGroups.name })
|
||||
.from(ringGroups)
|
||||
.where(matches(ringGroups.noAnswerDestination)),
|
||||
),
|
||||
...named('queue')(
|
||||
await db.select({ n: queues.name }).from(queues).where(matches(queues.timeoutDestination)),
|
||||
),
|
||||
...named('digital receptionist')(
|
||||
await db
|
||||
.select({ n: ivrs.name })
|
||||
.from(ivrs)
|
||||
.where(or(ivrs.timeoutDestination, ivrs.invalidDestination)),
|
||||
),
|
||||
...named('digital receptionist')(
|
||||
await db
|
||||
.select({ n: ivrs.name })
|
||||
.from(ivrOptions)
|
||||
.innerJoin(ivrs, eq(ivrs.id, ivrOptions.ivrId))
|
||||
.where(matches(ivrOptions.destination)),
|
||||
),
|
||||
...named('office hours')(
|
||||
await db
|
||||
.select({ n: timeConditions.name })
|
||||
.from(timeConditions)
|
||||
.where(
|
||||
or(
|
||||
timeConditions.openDestination,
|
||||
timeConditions.closedDestination,
|
||||
timeConditions.holidayDestination,
|
||||
),
|
||||
),
|
||||
),
|
||||
...named('announcement')(
|
||||
await db
|
||||
.select({ n: announcements.name })
|
||||
.from(announcements)
|
||||
.where(matches(announcements.destination)),
|
||||
),
|
||||
...(
|
||||
await db
|
||||
.select({ n: settings.key })
|
||||
.from(settings)
|
||||
.where(
|
||||
sql`${settings.key} = 'routing.unmatchedDidDestination' and (${matches(settings.value)})`,
|
||||
)
|
||||
).map(() => 'the setting for calls to unknown numbers'),
|
||||
];
|
||||
return [...new Set(found)];
|
||||
}
|
||||
@@ -3,6 +3,8 @@
|
||||
// registers), our generated config is the PBX. Everything runs on a private Docker bridge network.
|
||||
// M3-T1: the good trunk registers, the one with a wrong password is rejected, and the AMI
|
||||
// session can be recorded for the live-state tests.
|
||||
// M3-T2: the provider calls our DID the way voip.ms does (10-digit Request-URI); a baresip phone
|
||||
// registered as 101 answers, caller ID arrives normalized, and audio flows both ways.
|
||||
// Usage: node --conditions=source apps/api/test/asterisk/trunk-test.ts [--record <file>]
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
@@ -26,6 +28,11 @@ const AMI_SECRET = 'trunk-test-secret';
|
||||
const GOOD = '7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37';
|
||||
const BAD = '0b9f6c21-8e4d-4a7b-b3c5-d1e2f3a4b5c6';
|
||||
const SUB = { user: '123456_pbx', pass: 'ProviderSecret42' };
|
||||
const PHONES = 'sp-trunk-phones';
|
||||
const EXT_ID = '00000000-0000-4000-8000-000000000101';
|
||||
const PHONE_PASS = 'TrunkTest101xxxxxxxxxxxx';
|
||||
const DID = '13125551234';
|
||||
const MOBILE = '3125559999'; // the outside caller, 10 digits as voip.ms sends them
|
||||
|
||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
const docker = (...args: string[]) => {
|
||||
@@ -59,12 +66,69 @@ function snapshot(): Snapshot {
|
||||
return {
|
||||
today: '2026-10-17',
|
||||
settings,
|
||||
dids: [],
|
||||
extensions: [],
|
||||
dids: [
|
||||
{
|
||||
id: '11111111-1111-4111-8111-111111111111',
|
||||
number: DID,
|
||||
trunkId: GOOD,
|
||||
label: 'Main line',
|
||||
destination: { type: 'user', id: EXT_ID },
|
||||
callerIdNamePrefix: null,
|
||||
},
|
||||
],
|
||||
extensions: [
|
||||
{
|
||||
id: EXT_ID,
|
||||
number: '101',
|
||||
name: 'Alice',
|
||||
sipPassword: PHONE_PASS,
|
||||
webrtcEnabled: false,
|
||||
maxContacts: 5,
|
||||
ringSeconds: 25,
|
||||
voicemailEnabled: false,
|
||||
outboundClass: 'national',
|
||||
outboundCallerId: null,
|
||||
emergencyCallerId: null,
|
||||
recordMode: 'never',
|
||||
pickupGroup: null,
|
||||
language: 'en',
|
||||
mohClass: 'default',
|
||||
isAdmin: false,
|
||||
},
|
||||
],
|
||||
trunks: [trunk(GOOD, 'voip.ms', SUB.pass), trunk(BAD, 'wrong password', 'NotThePassword1')],
|
||||
};
|
||||
}
|
||||
|
||||
/** A baresip softphone registered as 101: auto-answers, sends a tone. */
|
||||
function phoneFiles(dir: string) {
|
||||
const d = join(dir, 'phone-101');
|
||||
mkdirSync(d, { recursive: true });
|
||||
writeFileSync(
|
||||
join(d, 'config'),
|
||||
[
|
||||
'poll_method epoll',
|
||||
'sip_listen 0.0.0.0:5070',
|
||||
'module_path /usr/lib/baresip/modules',
|
||||
'module g711.so',
|
||||
'module ausine.so',
|
||||
'module aufile.so',
|
||||
'module menu.so',
|
||||
'module account.so',
|
||||
'audio_source ausine,440',
|
||||
'ausrc_srate 48000', // ausine only runs at 48 kHz (see call-test.ts)
|
||||
'auplay_srate 48000',
|
||||
'audio_player aufile,/phones/phone-101/heard.wav',
|
||||
'audio_alert aufile,/dev/null',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
writeFileSync(
|
||||
join(d, 'accounts'),
|
||||
`<sip:101@${PBX};transport=udp>;auth_pass=${PHONE_PASS};answermode=auto;audio_codecs=PCMU;regint=60\n`,
|
||||
);
|
||||
}
|
||||
|
||||
/** The fake voip.ms: one sub-account that may register. */
|
||||
const PROVIDER_PJSIP = `[transport-udp]
|
||||
type = transport
|
||||
@@ -91,6 +155,22 @@ max_contacts = 1
|
||||
remove_existing = yes
|
||||
`;
|
||||
|
||||
/**
|
||||
* provider-out: a mobile phone calls one of our DIDs. Like voip.ms, the Request-URI user is the
|
||||
* 10-digit DID (06 §3), sent to the sub-account's registered contact. Echo() returns the audio.
|
||||
*/
|
||||
const PROVIDER_DIALPLAN = `[provider-out]
|
||||
exten => _X.,1,Set(CALLERID(all)="Mobile" <${MOBILE}>)
|
||||
same => n,Dial(PJSIP/\${EXTEN}@${SUB.user},30)
|
||||
|
||||
[provider-echo]
|
||||
exten => s,1,Answer()
|
||||
same => n,Echo()
|
||||
|
||||
[provider-in]
|
||||
exten => _X.,1,Hangup()
|
||||
`;
|
||||
|
||||
async function waitFor(what: string, fn: () => Promise<boolean>, seconds = 60) {
|
||||
for (let i = 0; i < seconds; i++) {
|
||||
if (await fn()) return;
|
||||
@@ -108,8 +188,16 @@ async function main() {
|
||||
const files = render(snapshot());
|
||||
for (const f of GENERATED_FILES) {
|
||||
writeFileSync(join(pbxConf, f), files[f]);
|
||||
writeFileSync(join(provConf, f), f === 'pjsip_selfphoneng.conf' ? PROVIDER_PJSIP : '');
|
||||
writeFileSync(
|
||||
join(provConf, f),
|
||||
f === 'pjsip_selfphoneng.conf'
|
||||
? PROVIDER_PJSIP
|
||||
: f === 'extensions_selfphoneng.conf'
|
||||
? PROVIDER_DIALPLAN
|
||||
: '',
|
||||
);
|
||||
}
|
||||
phoneFiles(dir);
|
||||
writeFileSync(
|
||||
join(dir, 'manager.conf.template'),
|
||||
'[general]\nenabled = yes\nport = 5038\nbindaddr = 0.0.0.0\n\n[selfphoneng]\nsecret = ${AMI_SECRET}\npermit = 0.0.0.0/0.0.0.0\nread = all\nwrite = all\n',
|
||||
@@ -149,7 +237,7 @@ async function main() {
|
||||
);
|
||||
|
||||
try {
|
||||
docker('rm', '-f', PBX, PROVIDER);
|
||||
docker('rm', '-f', PBX, PROVIDER, PHONES);
|
||||
docker('network', 'create', NET);
|
||||
const prov = asterisk(PROVIDER, provConf, []);
|
||||
if (!prov.ok) throw new Error(`provider: ${prov.out}`);
|
||||
@@ -204,6 +292,75 @@ async function main() {
|
||||
`Action: PJSIPShowEndpoint\r\nEndpoint: trunk-${GOOD}\r\nActionID: rec-4\r\n\r\n`,
|
||||
);
|
||||
await sleep(1000);
|
||||
recorder?.destroy(); // the fixture covers registration only
|
||||
recorder = undefined;
|
||||
|
||||
// M3-T2: a call to the DID from outside rings 101.
|
||||
log('starting a baresip phone as 101 (installs baresip on first run)…');
|
||||
const phones = docker(
|
||||
'run',
|
||||
'-d',
|
||||
'--rm',
|
||||
'--name',
|
||||
PHONES,
|
||||
'--network',
|
||||
NET,
|
||||
'-v',
|
||||
`${dir}:/phones`,
|
||||
'debian:trixie-slim',
|
||||
'sh',
|
||||
'-c',
|
||||
'apt-get update -qq >/dev/null && apt-get install -y -qq --no-install-recommends baresip-core >/dev/null 2>&1 && ' +
|
||||
'(baresip -f /phones/phone-101 > /phones/phone-101/log 2>&1 &) && sleep 600',
|
||||
);
|
||||
if (!phones.ok) throw new Error(`phones: ${phones.out}`);
|
||||
await waitFor(
|
||||
'101 to register',
|
||||
async () => /101\/sip:.*Avail/.test(await ami.command('pjsip show contacts')),
|
||||
180,
|
||||
);
|
||||
const dials: Record<string, string | string[]>[] = [];
|
||||
ami.on('event:DialBegin', (m) => dials.push(m));
|
||||
const orig = docker(
|
||||
'exec',
|
||||
PROVIDER,
|
||||
'asterisk',
|
||||
'-rx',
|
||||
`channel originate Local/${DID.slice(1)}@provider-out extension s@provider-echo`,
|
||||
);
|
||||
if (!orig.ok) throw new Error(`originate on the provider: ${orig.out}`);
|
||||
let channels = '';
|
||||
await waitFor(
|
||||
'the inbound call to be answered by 101',
|
||||
async () => {
|
||||
channels = await ami.command('core show channels concise');
|
||||
const up = channels.split('\n').filter((l) => l.includes('!Up!'));
|
||||
return (
|
||||
up.some((l) => l.startsWith('PJSIP/101-')) && up.some((l) => l.startsWith('PJSIP/trunk-'))
|
||||
);
|
||||
},
|
||||
30,
|
||||
);
|
||||
log('inbound call answered by 101');
|
||||
const toPhone = dials.find((m) => String(m.DestChannel).startsWith('PJSIP/101-'));
|
||||
if (!toPhone) problems.push('no DialBegin to PJSIP/101');
|
||||
else if (toPhone.CallerIDNum !== `1${MOBILE}`)
|
||||
problems.push(`101 saw caller ID ${toPhone.CallerIDNum}, expected 1${MOBILE} (normalized)`);
|
||||
await sleep(10_000);
|
||||
const stats = await ami.command('pjsip show channelstats');
|
||||
// The ChannelId column is cut at 18 characters: "trunk-7d3e9a52-1c4".
|
||||
const rows = stats.split('\n').filter((l) => /^\s*\S+ (101-[0-9a-f]{8}|trunk-\S+) /.test(l));
|
||||
if (rows.length !== 2) problems.push(`expected the 101 and trunk legs in:\n${stats}`);
|
||||
for (const r of rows) {
|
||||
const cols = r.trim().split(/\s+/);
|
||||
const ulaw = cols.indexOf('ulaw');
|
||||
const rx = Number(cols[ulaw + 1]);
|
||||
const tx = Number(cols[ulaw + 5]);
|
||||
log(`${cols[1]}: rx ${rx}, tx ${tx}`);
|
||||
if (!(rx > 250) || !(tx > 250))
|
||||
problems.push(`one-way or no audio on ${cols[1]}: rx ${rx}, tx ${tx}`);
|
||||
}
|
||||
await ami.command('channel request hangup all');
|
||||
|
||||
// eslint-disable-next-line no-control-regex -- strips Asterisk's ANSI colors
|
||||
const plainLog = docker('logs', PBX).out.replace(/\x1b\[[0-9;]*m/g, '');
|
||||
@@ -218,7 +375,7 @@ async function main() {
|
||||
}
|
||||
recorder?.destroy();
|
||||
ami.stop();
|
||||
docker('rm', '-f', PBX, PROVIDER);
|
||||
docker('rm', '-f', PBX, PROVIDER, PHONES);
|
||||
docker('network', 'rm', NET);
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
@@ -227,7 +384,7 @@ async function main() {
|
||||
for (const p of problems) console.log(` ${p}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('ok trunks (registration, rejection, qualify)');
|
||||
console.log('ok trunks (registration, rejection, qualify, inbound DID call to 101 with audio)');
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -0,0 +1,156 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// M3-T2: phone numbers, destinations (exist checks, references, summaries) and the inbound dialplan.
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import type { LightMyRequestResponse } from 'fastify';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { DidCreateSchema } from '@selfphoneng/shared';
|
||||
import { settings } from '../src/db/schema.ts';
|
||||
import { hasTestDb } from './db.ts';
|
||||
import { ADMIN, H, createTestApp } from './app.ts';
|
||||
|
||||
describe('DID number input', () => {
|
||||
it.each([
|
||||
['(312) 555-1234', '13125551234'],
|
||||
['312.555.1234', '13125551234'],
|
||||
['+1 312 555 1234', '13125551234'],
|
||||
['13125551234', '13125551234'],
|
||||
['+44 20 7946 0958', '011442079460958'],
|
||||
])('%s → %s', (input, out) => {
|
||||
const r = DidCreateSchema.safeParse({
|
||||
number: input,
|
||||
trunkId: '7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37',
|
||||
destination: { type: 'hangup' },
|
||||
});
|
||||
expect(r.success && r.data.number).toBe(out);
|
||||
});
|
||||
|
||||
it.each(['555-1234x', 'abc', '12'])('rejects %s', (input) => {
|
||||
expect(
|
||||
DidCreateSchema.safeParse({
|
||||
number: input,
|
||||
trunkId: '7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37',
|
||||
destination: { type: 'hangup' },
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!hasTestDb)('DIDs API', () => {
|
||||
let t: Awaited<ReturnType<typeof createTestApp>>;
|
||||
let req: (method: string, url: string, payload?: unknown) => Promise<LightMyRequestResponse>;
|
||||
const dialplan = () => readFileSync(join(t.genDir, 'extensions_selfphoneng.conf'), 'utf8');
|
||||
const settle = async () => {
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
await t.app.generator.apply({ trigger: 'test' });
|
||||
};
|
||||
let trunkId: string;
|
||||
let extId: string;
|
||||
let didId: 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 },
|
||||
});
|
||||
trunkId = (
|
||||
await req('POST', '/api/trunks', {
|
||||
name: 'voip.ms',
|
||||
serverHost: '127.0.0.1',
|
||||
username: '123456_pbx',
|
||||
password: 'pw',
|
||||
})
|
||||
).json().id;
|
||||
extId = (await req('POST', '/api/extensions', { number: '101', name: 'Dan Doe' })).json().id;
|
||||
});
|
||||
afterAll(() => t?.close());
|
||||
|
||||
it('adds a number routed to a user, normalized, with a readable summary', async () => {
|
||||
const r = await req('POST', '/api/dids', {
|
||||
number: '(312) 555-1234',
|
||||
trunkId,
|
||||
label: 'Main line',
|
||||
destination: { type: 'user', id: extId },
|
||||
});
|
||||
expect(r.statusCode).toBe(201);
|
||||
didId = r.json().id;
|
||||
expect(r.json()).toMatchObject({
|
||||
number: '13125551234',
|
||||
destinationSummary: 'Ring Dan Doe (101)',
|
||||
});
|
||||
await settle();
|
||||
const conf = dialplan();
|
||||
expect(conf).toContain('[sp-did-exists]\nexten => 13125551234,1,NoOp()');
|
||||
expect(conf).toContain(
|
||||
'exten => 13125551234,1,Set(__SP_DID=13125551234)\n same => n,Goto(sp-local-numbers,101,1)',
|
||||
);
|
||||
expect(conf).toContain(`[sp-from-trunk-${trunkId}]`);
|
||||
});
|
||||
|
||||
it('refuses duplicates, unknown trunks and destinations that do not exist', async () => {
|
||||
const dup = await req('POST', '/api/dids', {
|
||||
number: '3125551234',
|
||||
trunkId,
|
||||
destination: { type: 'hangup' },
|
||||
});
|
||||
expect(dup.statusCode).toBe(409);
|
||||
expect(dup.json().error.message).toBe('(312) 555-1234 is already added');
|
||||
const badTrunk = await req('POST', '/api/dids', {
|
||||
number: '3125550001',
|
||||
trunkId: '00000000-0000-4000-8000-000000000000',
|
||||
destination: { type: 'hangup' },
|
||||
});
|
||||
expect(badTrunk.statusCode).toBe(400);
|
||||
const badDest = await req('POST', '/api/dids', {
|
||||
number: '3125550001',
|
||||
trunkId,
|
||||
destination: { type: 'user', id: '00000000-0000-4000-8000-000000000000' },
|
||||
});
|
||||
expect(badDest.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('an extension a number points at cannot be deleted (409 names the number)', async () => {
|
||||
const r = await req('DELETE', `/api/extensions/${extId}`);
|
||||
expect(r.statusCode).toBe(409);
|
||||
expect(r.json().error.details.usedBy).toEqual(['number (312) 555-1234']);
|
||||
// A voicemail destination is a reference to the same extension.
|
||||
await req('PATCH', `/api/dids/${didId}`, { destination: { type: 'voicemail', id: extId } });
|
||||
expect((await req('DELETE', `/api/extensions/${extId}`)).statusCode).toBe(409);
|
||||
});
|
||||
|
||||
it('the unmatched-number setting counts as a reference too', async () => {
|
||||
await req('PATCH', `/api/dids/${didId}`, {
|
||||
destination: { type: 'external', number: '13125550000' },
|
||||
});
|
||||
await t.db.insert(settings).values({
|
||||
key: 'routing.unmatchedDidDestination',
|
||||
value: { type: 'user', id: extId },
|
||||
});
|
||||
const r = await req('DELETE', `/api/extensions/${extId}`);
|
||||
expect(r.json().error.details.usedBy).toEqual(['the setting for calls to unknown numbers']);
|
||||
await settle();
|
||||
expect(dialplan()).toContain('exten => _[0-9a-z].,1,Goto(sp-local-numbers,101,1)');
|
||||
expect(dialplan()).toContain(' same => n,Goto(sp-ext-forward,13125550000,1)');
|
||||
});
|
||||
|
||||
it('a caller-name prefix lands in the dialplan, sanitized by the schema', async () => {
|
||||
const bad = await req('PATCH', `/api/dids/${didId}`, { callerIdNamePrefix: 'X;Y' });
|
||||
expect(bad.statusCode).toBe(400);
|
||||
await req('PATCH', `/api/dids/${didId}`, { callerIdNamePrefix: 'SALES:' });
|
||||
await settle();
|
||||
expect(dialplan()).toContain(' same => n,Set(CALLERID(name)=SALES:${CALLERID(name)})');
|
||||
});
|
||||
|
||||
it('a number that is the emergency caller ID cannot be removed', async () => {
|
||||
await t.db.insert(settings).values({ key: 'emergency.defaultCallerIdDidId', value: didId });
|
||||
const r = await req('DELETE', `/api/dids/${didId}`);
|
||||
expect(r.statusCode).toBe(409);
|
||||
expect(r.json().error.message).toContain('the default emergency caller ID');
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,9 @@
|
||||
// Typed fetch wrappers (05 §1). Every request sends X-Requested-With (CSRF, 04 §2.2).
|
||||
import type {
|
||||
AuthUser,
|
||||
DidCreate,
|
||||
DidDto,
|
||||
DidUpdate,
|
||||
ExtensionCreate,
|
||||
ExtensionDto,
|
||||
ExtensionUpdate,
|
||||
@@ -67,6 +70,7 @@ export const usersApi = {
|
||||
};
|
||||
|
||||
export const extensionsApi = {
|
||||
list: () => api<ListResponse<ExtensionDto>>('GET', '/api/extensions').then((r) => r.items),
|
||||
get: (id: string) => api<ExtensionDto>('GET', `/api/extensions/${id}`),
|
||||
create: (e: ExtensionCreate) => api<ExtensionDto>('POST', '/api/extensions', e),
|
||||
update: (id: string, e: ExtensionUpdate) =>
|
||||
@@ -86,3 +90,10 @@ export const trunksApi = {
|
||||
remove: (id: string) => api<{ ok: true }>('DELETE', `/api/trunks/${id}`),
|
||||
test: (id: string) => api<TrunkTestResult>('POST', `/api/trunks/${id}/test`),
|
||||
};
|
||||
|
||||
export const didsApi = {
|
||||
list: () => api<ListResponse<DidDto>>('GET', '/api/dids').then((r) => r.items),
|
||||
create: (d: DidCreate) => api<DidDto>('POST', '/api/dids', d),
|
||||
update: (id: string, d: DidUpdate) => api<DidDto>('PATCH', `/api/dids/${id}`, d),
|
||||
remove: (id: string) => api<{ ok: true }>('DELETE', `/api/dids/${id}`),
|
||||
};
|
||||
@@ -0,0 +1,98 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// "Where should the call go?" (05 §3). M3: a user, an outside number, or hang up. Voicemail (M4),
|
||||
// ring groups, queues, IVRs, office hours, conferences and announcements join later (M6-T6).
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useId, useState } from 'react';
|
||||
import { normalizeNumber, type Destination } from '@selfphoneng/shared';
|
||||
import { extensionsApi } from '../api/client.ts';
|
||||
import { inputClass } from './ui.tsx';
|
||||
|
||||
type Kind = 'user' | 'voicemail' | 'external' | 'hangup';
|
||||
|
||||
export function DestinationPicker({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
error,
|
||||
}: {
|
||||
label: string;
|
||||
value: Destination;
|
||||
onChange: (d: Destination | null) => void; // null = incomplete (e.g. half-typed number)
|
||||
error?: string;
|
||||
}) {
|
||||
const id = useId();
|
||||
const exts = useQuery({ queryKey: ['extensions'], queryFn: extensionsApi.list });
|
||||
const [kind, setKind] = useState<Kind>(value.type as Kind);
|
||||
const [number, setNumber] = useState(value.type === 'external' ? value.number : '');
|
||||
const [numberError, setNumberError] = useState<string>();
|
||||
const extId = value.type === 'user' || value.type === 'voicemail' ? value.id : '';
|
||||
|
||||
function pickKind(k: Kind) {
|
||||
setKind(k);
|
||||
if (k === 'hangup') onChange({ type: 'hangup' });
|
||||
else if (k === 'external') typed(number);
|
||||
else if (extId) onChange({ type: k, id: extId });
|
||||
else onChange(exts.data?.[0] ? { type: k, id: exts.data[0].id } : null);
|
||||
}
|
||||
|
||||
function typed(v: string) {
|
||||
setNumber(v);
|
||||
try {
|
||||
onChange({ type: 'external', number: normalizeNumber(v) });
|
||||
setNumberError(undefined);
|
||||
} catch {
|
||||
onChange(null);
|
||||
setNumberError(v ? 'A phone number like (312) 555-1234' : undefined);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<fieldset className="flex flex-col gap-2" aria-describedby={error ? `${id}-err` : undefined}>
|
||||
<legend className="mb-1 text-sm font-medium">{label}</legend>
|
||||
<select
|
||||
aria-label={`${label}: type`}
|
||||
value={kind}
|
||||
onChange={(e) => pickKind(e.target.value as Kind)}
|
||||
className={inputClass}
|
||||
>
|
||||
<option value="user">Ring a user</option>
|
||||
<option value="external">Forward to an outside number</option>
|
||||
<option value="hangup">Hang up</option>
|
||||
</select>
|
||||
{(kind === 'user' || kind === 'voicemail') && (
|
||||
<select
|
||||
aria-label={`${label}: user`}
|
||||
value={extId}
|
||||
onChange={(e) => onChange({ type: kind, id: e.target.value })}
|
||||
className={inputClass}
|
||||
>
|
||||
{!extId && <option value="">Pick a user…</option>}
|
||||
{exts.data?.map((x) => (
|
||||
<option key={x.id} value={x.id}>
|
||||
{x.number} {x.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{kind === 'external' && (
|
||||
<input
|
||||
aria-label={`${label}: number`}
|
||||
value={number}
|
||||
onChange={(e) => typed(e.target.value)}
|
||||
inputMode="tel"
|
||||
placeholder="(312) 555-1234"
|
||||
aria-invalid={Boolean(numberError)}
|
||||
className={inputClass}
|
||||
/>
|
||||
)}
|
||||
{(numberError || error) && (
|
||||
<p id={`${id}-err`} className="text-xs text-red-600 dark:text-red-400">
|
||||
{numberError ?? error}
|
||||
</p>
|
||||
)}
|
||||
{exts.data?.length === 0 && (kind === 'user' || kind === 'voicemail') && (
|
||||
<p className="text-xs text-zinc-500">No users with an extension yet.</p>
|
||||
)}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { ADMIN_PAGES, AdminLayout, ComingSoon } from './pages/admin/AdminLayout.
|
||||
import { UsersPage } from './pages/admin/UsersPage.tsx';
|
||||
import { DashboardPage } from './pages/admin/DashboardPage.tsx';
|
||||
import { TrunksPage } from './pages/admin/TrunksPage.tsx';
|
||||
import { NumbersPage } from './pages/admin/NumbersPage.tsx';
|
||||
import { MePage } from './pages/user/MePage.tsx';
|
||||
import './index.css';
|
||||
|
||||
@@ -27,6 +28,8 @@ const router = createBrowserRouter([
|
||||
<DashboardPage />
|
||||
) : p.path === 'users' ? (
|
||||
<RequireAuth>{(user) => <UsersPage currentUserId={user.id} />}</RequireAuth>
|
||||
) : p.path === 'numbers' ? (
|
||||
<NumbersPage />
|
||||
) : p.path === 'trunks' ? (
|
||||
<TrunksPage />
|
||||
) : (
|
||||
|
||||
@@ -13,7 +13,7 @@ export const ADMIN_PAGES = [
|
||||
{ path: '', label: 'Dashboard', milestone: null },
|
||||
{ path: 'users', label: 'Users & Extensions', milestone: null },
|
||||
{ path: 'trunks', label: 'Trunks', milestone: null },
|
||||
{ path: 'numbers', label: 'Phone numbers', milestone: 'M3' },
|
||||
{ path: 'numbers', label: 'Phone numbers', milestone: null },
|
||||
{ path: 'outbound', label: 'Outbound rules', milestone: 'M3' },
|
||||
{ path: 'ring-groups', label: 'Ring groups', milestone: 'M6' },
|
||||
{ path: 'queues', label: 'Queues', milestone: 'M8' },
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Phone numbers (DIDs) and where their calls go (05 §2, 06 §3).
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
import { DestinationSchema, formatNumber, type DidDto } from '@selfphoneng/shared';
|
||||
import { didsApi, trunksApi } from '../../api/client.ts';
|
||||
import { ConfirmDialog, Dialog } from '../../components/Dialog.tsx';
|
||||
import { DestinationPicker } from '../../components/DestinationPicker.tsx';
|
||||
import { Alert, Button, Field, inputClass } from '../../components/ui.tsx';
|
||||
|
||||
export function NumbersPage() {
|
||||
const qc = useQueryClient();
|
||||
const dids = useQuery({ queryKey: ['dids'], queryFn: didsApi.list });
|
||||
const trunks = useQuery({ queryKey: ['trunks'], queryFn: trunksApi.list });
|
||||
const [editing, setEditing] = useState<DidDto | 'new' | null>(null);
|
||||
const [deleting, setDeleting] = useState<DidDto | null>(null);
|
||||
const refresh = () => qc.invalidateQueries({ queryKey: ['dids'] });
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: string) => didsApi.remove(id),
|
||||
onSuccess: () => {
|
||||
setDeleting(null);
|
||||
void refresh();
|
||||
},
|
||||
});
|
||||
const trunkName = (id: string) => trunks.data?.find((t) => t.id === id)?.name ?? '–';
|
||||
const noTrunk = trunks.data?.length === 0;
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h1 className="text-xl font-semibold">Phone numbers</h1>
|
||||
<Button variant="primary" onClick={() => setEditing('new')} disabled={noTrunk}>
|
||||
Add number
|
||||
</Button>
|
||||
</div>
|
||||
{noTrunk && (
|
||||
<p className="text-sm text-zinc-500">Add a trunk first: numbers arrive through one.</p>
|
||||
)}
|
||||
{dids.isError && <Alert>{dids.error.message}</Alert>}
|
||||
{dids.isPending && <p className="text-sm text-zinc-500">Loading…</p>}
|
||||
{dids.data?.length === 0 && !noTrunk && (
|
||||
<p className="text-sm text-zinc-500">
|
||||
No numbers yet. Add the numbers (DIDs) you bought at voip.ms.
|
||||
</p>
|
||||
)}
|
||||
{dids.data && dids.data.length > 0 && (
|
||||
<ul className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
|
||||
{dids.data.map((d) => (
|
||||
<li
|
||||
key={d.id}
|
||||
className="flex flex-wrap items-center gap-x-3 gap-y-1 px-3 py-2 text-sm"
|
||||
>
|
||||
<span className="font-mono">{formatNumber(d.number)}</span>
|
||||
{d.label && <span className="text-zinc-500">{d.label}</span>}
|
||||
<span className="flex-1 truncate">→ {d.destinationSummary}</span>
|
||||
<span className="hidden text-zinc-500 sm:inline">{trunkName(d.trunkId)}</span>
|
||||
<Button onClick={() => setEditing(d)}>Edit</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
remove.reset();
|
||||
setDeleting(d);
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{editing && trunks.data && (
|
||||
<DidDrawer
|
||||
did={editing === 'new' ? null : editing}
|
||||
trunks={trunks.data.map((t) => ({ id: t.id, name: t.name }))}
|
||||
onClose={() => setEditing(null)}
|
||||
onSaved={() => {
|
||||
setEditing(null);
|
||||
void refresh();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<ConfirmDialog
|
||||
open={deleting !== null}
|
||||
title="Remove number"
|
||||
message={
|
||||
<p>
|
||||
Remove <strong>{deleting && formatNumber(deleting.number)}</strong>? Calls to it will no
|
||||
longer be answered here. It stays yours at voip.ms.
|
||||
</p>
|
||||
}
|
||||
confirmLabel="Remove"
|
||||
busy={remove.isPending}
|
||||
error={remove.error?.message}
|
||||
onCancel={() => setDeleting(null)}
|
||||
onConfirm={() => deleting && remove.mutate(deleting.id)}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const FormSchema = z.object({
|
||||
number: z.string().trim().min(1, 'Required'),
|
||||
label: z.string().trim().max(60),
|
||||
trunkId: z.string().min(1, 'Pick a trunk'),
|
||||
destination: DestinationSchema.nullable().refine((d) => d !== null, 'Finish choosing'),
|
||||
callerIdNamePrefix: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(12)
|
||||
.regex(/^[A-Za-z0-9 :_.#-]*$/, 'Letters, digits, spaces and : _ . # - only'),
|
||||
e911Address: z.string().trim().max(200),
|
||||
});
|
||||
type FormValues = z.input<typeof FormSchema>;
|
||||
|
||||
function DidDrawer({
|
||||
did,
|
||||
trunks,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
did: DidDto | null;
|
||||
trunks: { id: string; name: string }[];
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const [error, setError] = useState<string>();
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(FormSchema),
|
||||
defaultValues: {
|
||||
number: did ? formatNumber(did.number) : '',
|
||||
label: did?.label ?? '',
|
||||
trunkId: did?.trunkId ?? trunks[0]?.id ?? '',
|
||||
destination: did?.destination ?? { type: 'hangup' },
|
||||
callerIdNamePrefix: did?.callerIdNamePrefix ?? '',
|
||||
e911Address: did?.e911Address ?? '',
|
||||
},
|
||||
});
|
||||
const { errors } = form.formState;
|
||||
|
||||
const onSubmit = form.handleSubmit(async (v) => {
|
||||
setError(undefined);
|
||||
const body = {
|
||||
number: v.number,
|
||||
trunkId: v.trunkId,
|
||||
label: v.label || null,
|
||||
destination: v.destination!,
|
||||
callerIdNamePrefix: v.callerIdNamePrefix || null,
|
||||
e911Address: v.e911Address || null,
|
||||
};
|
||||
try {
|
||||
if (did) await didsApi.update(did.id, body);
|
||||
else await didsApi.create(body);
|
||||
onSaved();
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
drawer
|
||||
onClose={onClose}
|
||||
title={did ? `Edit ${formatNumber(did.number)}` : 'Add number'}
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<Button variant="primary" type="submit" form="did-form">
|
||||
{did ? 'Save' : 'Add number'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="did-form" onSubmit={onSubmit} className="flex flex-col gap-4" noValidate>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<Field label="Phone number" error={errors.number?.message}>
|
||||
{(a) => (
|
||||
<input
|
||||
{...a}
|
||||
{...form.register('number')}
|
||||
inputMode="tel"
|
||||
placeholder="(312) 555-1234"
|
||||
className={inputClass}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Label" error={errors.label?.message} hint="For example: Main line.">
|
||||
{(a) => <input {...a} {...form.register('label')} className={inputClass} />}
|
||||
</Field>
|
||||
<Field label="Trunk" error={errors.trunkId?.message}>
|
||||
{(a) => (
|
||||
<select {...a} {...form.register('trunkId')} className={inputClass}>
|
||||
{trunks.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</Field>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="destination"
|
||||
render={({ field, fieldState }) => (
|
||||
<DestinationPicker
|
||||
label="When someone calls this number"
|
||||
value={field.value ?? { type: 'hangup' }}
|
||||
onChange={field.onChange}
|
||||
error={fieldState.error?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Field
|
||||
label="Caller name prefix"
|
||||
error={errors.callerIdNamePrefix?.message}
|
||||
hint="Shown before the caller's name, e.g. SALES: so people know which number was called."
|
||||
>
|
||||
{(a) => <input {...a} {...form.register('callerIdNamePrefix')} className={inputClass} />}
|
||||
</Field>
|
||||
<Field
|
||||
label="E911 address (for your notes)"
|
||||
error={errors.e911Address?.message}
|
||||
hint="The address registered for this number at voip.ms (DID → e911). Emergency services get the address stored there, not this one."
|
||||
>
|
||||
{(a) => <input {...a} {...form.register('e911Address')} className={inputClass} />}
|
||||
</Field>
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -514,6 +514,8 @@ exten => 13125551234,1,Set(__SP_DID=13125551234)
|
||||
; anything else
|
||||
exten => _[0-9a-z].,1,{destApp(routing.unmatchedDidDestination)}
|
||||
```
|
||||
Leave `[sp-did-exists]` out when there are no DIDs: Asterisk doesn't create an empty context, and
|
||||
`DIALPLAN_EXISTS` on a missing context is simply false. `record_inbound` arrives with recording (M7).
|
||||
|
||||
### 6.6 `sp-local-numbers`
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Phone numbers (DIDs) as the API accepts and returns them (02, 04 §7.3, 05 §2).
|
||||
import { z } from 'zod';
|
||||
import { DestinationSchema } from './destination.ts';
|
||||
import { normalizeNumber } from './phone.ts';
|
||||
|
||||
/** Any way of writing a number → its normalized digits (00 §8). */
|
||||
export const DidNumber = z
|
||||
.string()
|
||||
.trim()
|
||||
.transform((v, ctx) => {
|
||||
try {
|
||||
const n = normalizeNumber(v);
|
||||
if (/^\d{7,15}$/.test(n)) return n;
|
||||
} catch {
|
||||
// reported below
|
||||
}
|
||||
ctx.addIssue({ code: 'custom', message: 'A phone number like (312) 555-1234' });
|
||||
return z.NEVER;
|
||||
});
|
||||
|
||||
const DidFields = z.object({
|
||||
number: DidNumber,
|
||||
trunkId: z.uuid('Pick a trunk'),
|
||||
label: z.string().trim().max(60).nullable(),
|
||||
destination: DestinationSchema,
|
||||
callerIdNamePrefix: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(12)
|
||||
.regex(/^[A-Za-z0-9 :_.#-]*$/, 'Letters, digits, spaces and : _ . # - only')
|
||||
.nullable(),
|
||||
e911Address: z.string().trim().max(200).nullable(),
|
||||
});
|
||||
|
||||
export const DidCreateSchema = DidFields.partial().required({
|
||||
number: true,
|
||||
trunkId: true,
|
||||
destination: true,
|
||||
});
|
||||
export const DidUpdateSchema = DidFields.partial();
|
||||
|
||||
export type DidCreate = z.input<typeof DidCreateSchema>;
|
||||
export type DidUpdate = z.input<typeof DidUpdateSchema>;
|
||||
|
||||
export type DidDto = z.output<typeof DidFields> & {
|
||||
id: string;
|
||||
/** "Ring Dan Doe (101)", for lists. */
|
||||
destinationSummary: string;
|
||||
createdAt: string;
|
||||
};
|
||||
@@ -8,3 +8,4 @@ export * from './users.ts';
|
||||
export * from './extensions.ts';
|
||||
export * from './live.ts';
|
||||
export * from './trunks.ts';
|
||||
export * from './dids.ts';
|
||||
@@ -31,7 +31,8 @@ node scripts/e2e/m2-live.mjs down
|
||||
|
||||
`m3-t1.mjs` runs on the same setup (after `m1-t4.mjs`): it starts a fake voip.ms (a second Asterisk
|
||||
that accepts one sub-account) on the m2-live network, creates the trunk in the UI and waits for
|
||||
**Registered**, then presses Test.
|
||||
**Registered**, then presses Test. `m3-t2.mjs` (after `m3-t1.mjs`) adds a phone number routed to a user in the UI and
|
||||
checks `sp-inbound` in Asterisk, and that the extension can no longer be deleted.
|
||||
|
||||
`pnpm test:asterisk` (golden-file load test, `call-test.ts` with 30 s two-way audio between two
|
||||
baresip phones, and `trunk-test.ts` with registration against a fake provider) needs no api at all.
|
||||
@@ -0,0 +1,99 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// M3-T2 in a browser: add a phone number routed to a user and see the inbound dialplan reach the
|
||||
// live Asterisk. Run after m3-t1.mjs (needs its trunk) on the m2-live setup.
|
||||
// Usage: node scripts/e2e/m3-t2.mjs <baseUrl> <adminEmail> <adminPassword> <shotsDir>
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { launch } from './cdp.mjs';
|
||||
|
||||
const AST = 'sp-live-ast';
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const cli = (cmd) =>
|
||||
spawnSync('docker', ['exec', AST, 'asterisk', '-rx', cmd], { encoding: 'utf8' }).stdout ?? '';
|
||||
const [base, email, pw, shotsDir] = process.argv.slice(2);
|
||||
|
||||
const login = await fetch(`${base}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', 'x-requested-with': 'selfphoneng' },
|
||||
body: JSON.stringify({ email, password: pw }),
|
||||
});
|
||||
const cookie = login.headers.get('set-cookie').split(';')[0];
|
||||
const api = async (method, path, body) => {
|
||||
const r = await fetch(`${base}${path}`, {
|
||||
method,
|
||||
headers: { cookie, 'x-requested-with': 'selfphoneng', 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
assert.ok(r.ok, `${method} ${path}: ${r.status} ${await r.clone().text()}`);
|
||||
return r.json();
|
||||
};
|
||||
const { user } = await api('POST', '/api/users', {
|
||||
email: 'dan@example.com',
|
||||
displayName: 'Dan Doe',
|
||||
});
|
||||
await api('POST', '/api/extensions', { number: '101', name: 'Dan Doe', userId: user.id });
|
||||
|
||||
const p = await launch({ shotsDir });
|
||||
/** Sets a <select> found by its aria-label, the way a user would. */
|
||||
const choose = (ariaLabel, value) =>
|
||||
p.evaluate(`(() => {
|
||||
const el = document.querySelector('select[aria-label="${ariaLabel}"]');
|
||||
if (!el) throw new Error('no select ${ariaLabel}');
|
||||
Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value').set.call(el, ${JSON.stringify(value)});
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
return true;
|
||||
})()`);
|
||||
try {
|
||||
await p.goto(`${base}/login`);
|
||||
await p.waitFor(`!!document.querySelector('input[type=email]')`);
|
||||
await p.fill('Email', email);
|
||||
await p.fill('Password', pw);
|
||||
await p.click('Sign in');
|
||||
await p.waitFor(`location.pathname === '/admin'`);
|
||||
await p.click('Phone numbers');
|
||||
await p.waitFor(`document.body.innerText.includes('No numbers yet')`, 'empty numbers page');
|
||||
await p.click('Add number');
|
||||
await p.waitFor(`!!document.querySelector('#did-form')`);
|
||||
await p.fill('Phone number', '312.555.1234');
|
||||
await p.fill('Label', 'Main line');
|
||||
await choose('When someone calls this number: type', 'user');
|
||||
await p.waitFor(
|
||||
`[...document.querySelectorAll('select[aria-label="When someone calls this number: user"] option')].some(o => o.textContent.includes('Dan Doe'))`,
|
||||
'user list',
|
||||
);
|
||||
await p.shot('m3-t2-01-form');
|
||||
await p.evaluate(`document.querySelector('dialog button[form=did-form]').click(), true`);
|
||||
await p.waitFor(
|
||||
`document.body.innerText.includes('(312) 555-1234') && document.body.innerText.includes('→ Ring Dan Doe (101)')`,
|
||||
'number listed with its destination',
|
||||
);
|
||||
await p.shot('m3-t2-02-list');
|
||||
console.log('UI: (312) 555-1234 → Ring Dan Doe (101)');
|
||||
|
||||
let shown = '';
|
||||
for (let i = 0; i < 40 && !shown.includes('Goto(sp-local-numbers,101,1)'); i++) {
|
||||
await sleep(100);
|
||||
shown = cli('dialplan show 13125551234@sp-inbound');
|
||||
}
|
||||
assert.match(shown, /Goto\(sp-local-numbers,101,1\)/, 'sp-inbound in Asterisk');
|
||||
assert.match(cli('dialplan show sp-did-exists'), /'13125551234'/, 'sp-did-exists in Asterisk');
|
||||
console.log('Asterisk: 13125551234@sp-inbound → sp-local-numbers,101');
|
||||
|
||||
// The extension is now in use: the API refuses to delete it and says where.
|
||||
const r = await fetch(`${base}/api/extensions`, { headers: { cookie } });
|
||||
const ext = (await r.json()).items.find((e) => e.number === '101');
|
||||
const del = await fetch(`${base}/api/extensions/${ext.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { cookie, 'x-requested-with': 'selfphoneng' },
|
||||
});
|
||||
assert.equal(del.status, 409);
|
||||
assert.deepEqual((await del.json()).error.details.usedBy, ['number (312) 555-1234']);
|
||||
assert.deepEqual(p.consoleErrors, [], 'no console errors');
|
||||
console.log('M3-T2 (UI → Asterisk): PASS');
|
||||
} catch (e) {
|
||||
await p.shot('m3-t2-FAILED').catch(() => {});
|
||||
console.error('FAIL:', e.message, '\nconsole:', p.consoleErrors);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
await p.close();
|
||||
}
|
||||
Reference in new issue
Block a user