diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts
index 1cd0d1b..b98424a 100644
--- a/apps/api/src/app.ts
+++ b/apps/api/src/app.ts
@@ -16,6 +16,7 @@ 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 { outboundRouteRoutes } from './routes/outbound.ts';
import { liveRoutes } from './live/hub.ts';
declare module 'fastify' {
@@ -83,6 +84,7 @@ export function buildApp(config: Config, db: Db, publicDir = resolve('public'))
app.register(extensionRoutes);
app.register(trunkRoutes);
app.register(didRoutes);
+ app.register(outboundRouteRoutes);
app.register(liveRoutes);
if (existsSync(publicDir)) {
diff --git a/apps/api/src/generator/__golden__/empty/expected/extensions_selfphoneng.conf b/apps/api/src/generator/__golden__/empty/expected/extensions_selfphoneng.conf
index 94d8bce..80af058 100644
--- a/apps/api/src/generator/__golden__/empty/expected/extensions_selfphoneng.conf
+++ b/apps/api/src/generator/__golden__/empty/expected/extensions_selfphoneng.conf
@@ -106,12 +106,12 @@ include => sp-parkedcalls
; anything else
exten => _[0-9a-z].,1,Hangup()
+[sp-outbound-routes]
+exten => pending,1,Hangup() ; no routes yet
+
; ─── Filled in by later milestones
[sp-emergency-dial] ; M3-T4 (03 §6.14)
exten => pending,1,Hangup()
[sp-featurecodes] ; M6-T7 (03 §6.13)
exten => pending,1,Hangup()
-
-[sp-outbound-routes] ; M3-T3 (03 §6.12)
-exten => pending,1,Hangup()
diff --git a/apps/api/src/generator/__golden__/hostile-names/expected/extensions_selfphoneng.conf b/apps/api/src/generator/__golden__/hostile-names/expected/extensions_selfphoneng.conf
index ba6ca79..5d2029b 100644
--- a/apps/api/src/generator/__golden__/hostile-names/expected/extensions_selfphoneng.conf
+++ b/apps/api/src/generator/__golden__/hostile-names/expected/extensions_selfphoneng.conf
@@ -115,12 +115,12 @@ exten => 105,hint,PJSIP/105&PJSIP/105-web
; anything else
exten => _[0-9a-z].,1,Hangup()
+[sp-outbound-routes]
+exten => pending,1,Hangup() ; no routes yet
+
; ─── Filled in by later milestones
[sp-emergency-dial] ; M3-T4 (03 §6.14)
exten => pending,1,Hangup()
[sp-featurecodes] ; M6-T7 (03 §6.13)
exten => pending,1,Hangup()
-
-[sp-outbound-routes] ; M3-T3 (03 §6.12)
-exten => pending,1,Hangup()
diff --git a/apps/api/src/generator/__golden__/one-user/expected/extensions_selfphoneng.conf b/apps/api/src/generator/__golden__/one-user/expected/extensions_selfphoneng.conf
index 21322ff..5d78dec 100644
--- a/apps/api/src/generator/__golden__/one-user/expected/extensions_selfphoneng.conf
+++ b/apps/api/src/generator/__golden__/one-user/expected/extensions_selfphoneng.conf
@@ -115,12 +115,12 @@ exten => 13125551234,1,Set(__SP_DID=13125551234)
; anything else
exten => _[0-9a-z].,1,Hangup()
+[sp-outbound-routes]
+exten => pending,1,Hangup() ; no routes yet
+
; ─── Filled in by later milestones
[sp-emergency-dial] ; M3-T4 (03 §6.14)
exten => pending,1,Hangup()
[sp-featurecodes] ; M6-T7 (03 §6.13)
exten => pending,1,Hangup()
-
-[sp-outbound-routes] ; M3-T3 (03 §6.12)
-exten => pending,1,Hangup()
diff --git a/apps/api/src/generator/__golden__/trunk-variants/expected/extensions_selfphoneng.conf b/apps/api/src/generator/__golden__/trunk-variants/expected/extensions_selfphoneng.conf
index 1ced10a..7519b1b 100644
--- a/apps/api/src/generator/__golden__/trunk-variants/expected/extensions_selfphoneng.conf
+++ b/apps/api/src/generator/__golden__/trunk-variants/expected/extensions_selfphoneng.conf
@@ -140,12 +140,56 @@ exten => _[+0-9a-zA-Z].,1,Set(__SP_DIRECTION=inbound)
; anything else
exten => _[0-9a-z].,1,Hangup()
+[sp-outbound-routes]
+include => sp-or-bbbbbbbb-0000-4000-8000-000000000001 ; First
+include => sp-or-bbbbbbbb-0000-4000-8000-000000000002 ; Second evil
+
+[sp-or-bbbbbbbb-0000-4000-8000-000000000001]
+exten => _1[2-9]XXNXXXXXX,1,Goto(sp-route-bbbbbbbb-0000-4000-8000-000000000001,${EXTEN:0},1)
+
+[sp-route-bbbbbbbb-0000-4000-8000-000000000001]
+exten => _X.,1,Set(__SP_DIRECTION=outbound)
+ same => n,GotoIf($[${IF($["${SP_CLASS}" = ""]?0:${SP_CLASS})} < 2]?denied)
+ same => n,ExecIf($["${SP_RECMODE}" = "outbound" | "${SP_RECMODE}" = "all"]?Gosub(sp-sub-record,s,1))
+ ; caller ID: route override > user > the DID a forwarded call came in on > default
+ same => n,Set(CID=)
+ same => n,ExecIf($["${CID}" = ""]?Set(CID=${SP_OUTCID}))
+ same => n,ExecIf($["${CID}" = ""]?Set(CID=${SP_DID}))
+ same => n,ExecIf($["${CID}" = ""]?Set(CID=${SP_DEFAULT_CID}))
+ same => n,ExecIf($["${CID}" != ""]?Set(CALLERID(num)=${CID}))
+ same => n(busy),Playback(all-circuits-busy-now)
+ same => n(done),Hangup()
+ same => n(denied),Playback(ss-noservice)
+ same => n,Hangup()
+
+[sp-or-bbbbbbbb-0000-4000-8000-000000000002]
+exten => _9NXXNXXXXXX,1,Goto(sp-route-bbbbbbbb-0000-4000-8000-000000000002,1${EXTEN:1},1)
+
+[sp-route-bbbbbbbb-0000-4000-8000-000000000002]
+exten => _X.,1,Set(__SP_DIRECTION=outbound)
+ same => n,GotoIf($[${IF($["${SP_CLASS}" = ""]?0:${SP_CLASS})} < 1]?denied)
+ same => n,ExecIf($["${SP_RECMODE}" = "outbound" | "${SP_RECMODE}" = "all"]?Gosub(sp-sub-record,s,1))
+ ; caller ID: route override > user > the DID a forwarded call came in on > default
+ same => n,Set(CID=15551230000)
+ same => n,ExecIf($["${CID}" = ""]?Set(CID=${SP_OUTCID}))
+ same => n,ExecIf($["${CID}" = ""]?Set(CID=${SP_DID}))
+ same => n,ExecIf($["${CID}" = ""]?Set(CID=${SP_DEFAULT_CID}))
+ same => n,ExecIf($["${CID}" != ""]?Set(CALLERID(num)=${CID}))
+ same => n,Set(GROUP(sptrunk)=a1b2c3d4-0000-4000-8000-000000000002)
+ same => n,GotoIf($[${GROUP_COUNT(a1b2c3d4-0000-4000-8000-000000000002@sptrunk)} > 4]?t1)
+ same => n,Dial(PJSIP/${EXTEN}@trunk-a1b2c3d4-0000-4000-8000-000000000002,120)
+ same => n,GotoIf($["${DIALSTATUS}" != "CHANUNAVAIL" & "${DIALSTATUS}" != "CONGESTION"]?done)
+ same => n(t1),Set(GROUP(sptrunk)=a1b2c3d4-0000-4000-8000-000000000001)
+ same => n,Dial(PJSIP/${EXTEN}@trunk-a1b2c3d4-0000-4000-8000-000000000001,120)
+ same => n,GotoIf($["${DIALSTATUS}" != "CHANUNAVAIL" & "${DIALSTATUS}" != "CONGESTION"]?done)
+ same => n(busy),Playback(all-circuits-busy-now)
+ same => n(done),Hangup()
+ same => n(denied),Playback(ss-noservice)
+ same => n,Hangup()
+
; ─── Filled in by later milestones
[sp-emergency-dial] ; M3-T4 (03 §6.14)
exten => pending,1,Hangup()
[sp-featurecodes] ; M6-T7 (03 §6.13)
exten => pending,1,Hangup()
-
-[sp-outbound-routes] ; M3-T3 (03 §6.12)
-exten => pending,1,Hangup()
diff --git a/apps/api/src/generator/__golden__/trunk-variants/snapshot.json b/apps/api/src/generator/__golden__/trunk-variants/snapshot.json
index 05bfec4..1fe3bd3 100644
--- a/apps/api/src/generator/__golden__/trunk-variants/snapshot.json
+++ b/apps/api/src/generator/__golden__/trunk-variants/snapshot.json
@@ -1,5 +1,7 @@
{
- "settings": { "sip.tcpEnabled": true },
+ "settings": {
+ "sip.tcpEnabled": true
+ },
"trunks": [
{
"id": "a1b2c3d4-0000-4000-8000-000000000001",
@@ -21,12 +23,53 @@
"password": "p@ss w0rd!#",
"fromUser": "15551230000",
"fromDomain": "example.net",
- "codecs": ["g722", "ulaw"]
+ "codecs": ["g722", "ulaw"],
+ "maxChannels": 4
},
{
"id": "a1b2c3d4-0000-4000-8000-000000000003",
"name": "Old provider",
"enabled": false
}
+ ],
+ "routes": [
+ {
+ "id": "bbbbbbbb-0000-4000-8000-000000000002",
+ "name": "Second; [evil]",
+ "position": 5,
+ "patterns": [
+ {
+ "match": "9NXXNXXXXXX",
+ "strip": 1,
+ "prepend": "1"
+ },
+ {
+ "match": "9NXXNXXXXXX",
+ "strip": 1,
+ "prepend": "1"
+ }
+ ],
+ "requiredClass": "local",
+ "trunkIds": [
+ "a1b2c3d4-0000-4000-8000-000000000002",
+ "a1b2c3d4-0000-4000-8000-000000000003",
+ "a1b2c3d4-0000-4000-8000-000000000001"
+ ],
+ "callerId": "15551230000"
+ },
+ {
+ "id": "bbbbbbbb-0000-4000-8000-000000000001",
+ "name": "First",
+ "position": 1,
+ "patterns": [
+ {
+ "match": "1[2-9]XXNXXXXXX",
+ "strip": 0,
+ "prepend": ""
+ }
+ ],
+ "requiredClass": "national",
+ "trunkIds": ["a1b2c3d4-0000-4000-8000-000000000003"]
+ }
]
}
diff --git a/apps/api/src/generator/__golden__/voipms-basic/expected/extensions_selfphoneng.conf b/apps/api/src/generator/__golden__/voipms-basic/expected/extensions_selfphoneng.conf
index d13a418..7b0b206 100644
--- a/apps/api/src/generator/__golden__/voipms-basic/expected/extensions_selfphoneng.conf
+++ b/apps/api/src/generator/__golden__/voipms-basic/expected/extensions_selfphoneng.conf
@@ -137,12 +137,64 @@ exten => 13125559876,1,Set(__SP_DID=13125559876)
; anything else
exten => _[0-9a-z].,1,Hangup()
+[sp-outbound-routes]
+include => sp-or-aaaaaaaa-0000-4000-8000-000000000002 ; Toll-free
+include => sp-or-aaaaaaaa-0000-4000-8000-000000000001 ; Local/National NANP
+
+[sp-or-aaaaaaaa-0000-4000-8000-000000000002]
+exten => _1800NXXXXXX,1,Goto(sp-route-aaaaaaaa-0000-4000-8000-000000000002,${EXTEN:0},1)
+exten => _1888NXXXXXX,1,Goto(sp-route-aaaaaaaa-0000-4000-8000-000000000002,${EXTEN:0},1)
+exten => _1877NXXXXXX,1,Goto(sp-route-aaaaaaaa-0000-4000-8000-000000000002,${EXTEN:0},1)
+exten => _1866NXXXXXX,1,Goto(sp-route-aaaaaaaa-0000-4000-8000-000000000002,${EXTEN:0},1)
+exten => _1855NXXXXXX,1,Goto(sp-route-aaaaaaaa-0000-4000-8000-000000000002,${EXTEN:0},1)
+exten => _1844NXXXXXX,1,Goto(sp-route-aaaaaaaa-0000-4000-8000-000000000002,${EXTEN:0},1)
+exten => _1833NXXXXXX,1,Goto(sp-route-aaaaaaaa-0000-4000-8000-000000000002,${EXTEN:0},1)
+
+[sp-route-aaaaaaaa-0000-4000-8000-000000000002]
+exten => _X.,1,Set(__SP_DIRECTION=outbound)
+ same => n,GotoIf($[${IF($["${SP_CLASS}" = ""]?0:${SP_CLASS})} < 1]?denied)
+ same => n,ExecIf($["${SP_RECMODE}" = "outbound" | "${SP_RECMODE}" = "all"]?Gosub(sp-sub-record,s,1))
+ ; caller ID: route override > user > the DID a forwarded call came in on > default
+ same => n,Set(CID=)
+ same => n,ExecIf($["${CID}" = ""]?Set(CID=${SP_OUTCID}))
+ same => n,ExecIf($["${CID}" = ""]?Set(CID=${SP_DID}))
+ same => n,ExecIf($["${CID}" = ""]?Set(CID=${SP_DEFAULT_CID}))
+ same => n,ExecIf($["${CID}" != ""]?Set(CALLERID(num)=${CID}))
+ same => n,Set(GROUP(sptrunk)=7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37)
+ same => n,GotoIf($[${GROUP_COUNT(7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37@sptrunk)} > 2]?busy)
+ same => n,Dial(PJSIP/${EXTEN}@trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37,120)
+ same => n,GotoIf($["${DIALSTATUS}" != "CHANUNAVAIL" & "${DIALSTATUS}" != "CONGESTION"]?done)
+ same => n(busy),Playback(all-circuits-busy-now)
+ same => n(done),Hangup()
+ same => n(denied),Playback(ss-noservice)
+ same => n,Hangup()
+
+[sp-or-aaaaaaaa-0000-4000-8000-000000000001]
+exten => _NXXNXXXXXX,1,Goto(sp-route-aaaaaaaa-0000-4000-8000-000000000001,1${EXTEN:0},1)
+exten => _1NXXNXXXXXX,1,Goto(sp-route-aaaaaaaa-0000-4000-8000-000000000001,${EXTEN:0},1)
+
+[sp-route-aaaaaaaa-0000-4000-8000-000000000001]
+exten => _X.,1,Set(__SP_DIRECTION=outbound)
+ same => n,GotoIf($[${IF($["${SP_CLASS}" = ""]?0:${SP_CLASS})} < 2]?denied)
+ same => n,ExecIf($["${SP_RECMODE}" = "outbound" | "${SP_RECMODE}" = "all"]?Gosub(sp-sub-record,s,1))
+ ; caller ID: route override > user > the DID a forwarded call came in on > default
+ same => n,Set(CID=)
+ same => n,ExecIf($["${CID}" = ""]?Set(CID=${SP_OUTCID}))
+ same => n,ExecIf($["${CID}" = ""]?Set(CID=${SP_DID}))
+ same => n,ExecIf($["${CID}" = ""]?Set(CID=${SP_DEFAULT_CID}))
+ same => n,ExecIf($["${CID}" != ""]?Set(CALLERID(num)=${CID}))
+ same => n,Set(GROUP(sptrunk)=7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37)
+ same => n,GotoIf($[${GROUP_COUNT(7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37@sptrunk)} > 2]?busy)
+ same => n,Dial(PJSIP/${EXTEN}@trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37,120)
+ same => n,GotoIf($["${DIALSTATUS}" != "CHANUNAVAIL" & "${DIALSTATUS}" != "CONGESTION"]?done)
+ same => n(busy),Playback(all-circuits-busy-now)
+ same => n(done),Hangup()
+ same => n(denied),Playback(ss-noservice)
+ same => n,Hangup()
+
; ─── Filled in by later milestones
[sp-emergency-dial] ; M3-T4 (03 §6.14)
exten => pending,1,Hangup()
[sp-featurecodes] ; M6-T7 (03 §6.13)
exten => pending,1,Hangup()
-
-[sp-outbound-routes] ; M3-T3 (03 §6.12)
-exten => pending,1,Hangup()
diff --git a/apps/api/src/generator/__golden__/voipms-basic/snapshot.json b/apps/api/src/generator/__golden__/voipms-basic/snapshot.json
index a8d6caa..467b557 100644
--- a/apps/api/src/generator/__golden__/voipms-basic/snapshot.json
+++ b/apps/api/src/generator/__golden__/voipms-basic/snapshot.json
@@ -3,21 +3,117 @@
"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" }],
+ "extensions": [
+ {
+ "number": "101",
+ "name": "Dan Doe"
+ }
+ ],
+ "trunks": [
+ {
+ "id": "7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37",
+ "maxChannels": 2
+ }
+ ],
"dids": [
{
"id": "11111111-1111-4111-8111-111111111111",
"number": "13125551234",
"label": "Main line",
- "destination": { "type": "user", "id": "00000000-0000-4000-8000-000000000000" }
+ "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" }
+ "destination": {
+ "type": "external",
+ "number": "13125550000"
+ }
+ }
+ ],
+ "routes": [
+ {
+ "id": "aaaaaaaa-0000-4000-8000-000000000001",
+ "name": "Local/National (NANP)",
+ "position": 1,
+ "patterns": [
+ {
+ "match": "NXXNXXXXXX",
+ "strip": 0,
+ "prepend": "1"
+ },
+ {
+ "match": "1NXXNXXXXXX",
+ "strip": 0,
+ "prepend": ""
+ }
+ ],
+ "requiredClass": "national",
+ "trunkIds": ["7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37"]
+ },
+ {
+ "id": "aaaaaaaa-0000-4000-8000-000000000002",
+ "name": "Toll-free",
+ "position": 0,
+ "patterns": [
+ {
+ "match": "1800NXXXXXX",
+ "strip": 0,
+ "prepend": ""
+ },
+ {
+ "match": "1888NXXXXXX",
+ "strip": 0,
+ "prepend": ""
+ },
+ {
+ "match": "1877NXXXXXX",
+ "strip": 0,
+ "prepend": ""
+ },
+ {
+ "match": "1866NXXXXXX",
+ "strip": 0,
+ "prepend": ""
+ },
+ {
+ "match": "1855NXXXXXX",
+ "strip": 0,
+ "prepend": ""
+ },
+ {
+ "match": "1844NXXXXXX",
+ "strip": 0,
+ "prepend": ""
+ },
+ {
+ "match": "1833NXXXXXX",
+ "strip": 0,
+ "prepend": ""
+ }
+ ],
+ "requiredClass": "local",
+ "trunkIds": ["7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37"]
+ },
+ {
+ "id": "aaaaaaaa-0000-4000-8000-000000000003",
+ "name": "International",
+ "position": 2,
+ "enabled": false,
+ "patterns": [
+ {
+ "match": "011.",
+ "strip": 0,
+ "prepend": ""
+ }
+ ],
+ "requiredClass": "international",
+ "trunkIds": ["7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37"]
}
]
}
diff --git a/apps/api/src/generator/__golden__/webrtc-disabled/expected/extensions_selfphoneng.conf b/apps/api/src/generator/__golden__/webrtc-disabled/expected/extensions_selfphoneng.conf
index 9309192..2fad828 100644
--- a/apps/api/src/generator/__golden__/webrtc-disabled/expected/extensions_selfphoneng.conf
+++ b/apps/api/src/generator/__golden__/webrtc-disabled/expected/extensions_selfphoneng.conf
@@ -112,12 +112,12 @@ exten => 102,hint,PJSIP/102
; anything else
exten => _[0-9a-z].,1,Hangup()
+[sp-outbound-routes]
+exten => pending,1,Hangup() ; no routes yet
+
; ─── Filled in by later milestones
[sp-emergency-dial] ; M3-T4 (03 §6.14)
exten => pending,1,Hangup()
[sp-featurecodes] ; M6-T7 (03 §6.13)
exten => pending,1,Hangup()
-
-[sp-outbound-routes] ; M3-T3 (03 §6.12)
-exten => pending,1,Hangup()
diff --git a/apps/api/src/generator/dialplan/local.ts b/apps/api/src/generator/dialplan/local.ts
index c60c10f..2aa53ad 100644
--- a/apps/api/src/generator/dialplan/local.ts
+++ b/apps/api/src/generator/dialplan/local.ts
@@ -1,10 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// sp-local-numbers (03 §6.6) and the globals file (03 §6.3).
+import { CLASS_RANK } from '@selfphoneng/shared';
import { byNumber, type Snapshot } from '../load.ts';
import * as s from '../sanitize.ts';
-const CLASS_NUMBER = { internal: 0, local: 1, national: 2, international: 3 } as const;
-
// TODO(M4): voicemail on. Until then every user gets ARG3 = 0 (09 M2-T4).
const VOICEMAIL_READY = false;
@@ -42,8 +41,7 @@ export function renderGlobals(snap: Snapshot): string {
'SP_REC_ANNOUNCE=', // M7: recordings.announcementSoundId → sound name
];
const exts = [...snap.extensions].sort(byNumber);
- for (const e of exts)
- lines.push(`SP_CLASS_${s.digits(e.number)}=${CLASS_NUMBER[e.outboundClass]}`);
+ for (const e of exts) lines.push(`SP_CLASS_${s.digits(e.number)}=${CLASS_RANK[e.outboundClass]}`);
for (const e of exts) if (e.isAdmin) lines.push(`SP_TCADMIN_${s.digits(e.number)}=1`);
return lines.join('\n') + '\n';
}
diff --git a/apps/api/src/generator/dialplan/outbound.ts b/apps/api/src/generator/dialplan/outbound.ts
new file mode 100644
index 0000000..ee20f12
--- /dev/null
+++ b/apps/api/src/generator/dialplan/outbound.ts
@@ -0,0 +1,69 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+// Outbound routes (03 §6.12). Asterisk orders patterns inside one context by how specific they
+// are, not by file order, so each route gets its own context and sp-outbound-routes includes
+// them in position order: an include is only searched after the ones before it.
+import { CLASS_RANK } from '@selfphoneng/shared';
+import type { Snapshot } from '../load.ts';
+import * as s from '../sanitize.ts';
+
+export function renderOutbound(snap: Snapshot): string {
+ const trunks = new Map(snap.trunks.filter((t) => t.enabled).map((t) => [t.id, t]));
+ const routes = snap.routes
+ .filter((r) => r.enabled && r.patterns.length)
+ .sort((a, b) => a.position - b.position || a.id.localeCompare(b.id));
+
+ const top = ['[sp-outbound-routes]'];
+ if (!routes.length) top.push('exten => pending,1,Hangup() ; no routes yet');
+ const out: string[] = [];
+ for (const r of routes) {
+ const id = s.ident(r.id);
+ top.push(`include => sp-or-${id} ; ${s.name(r.name)}`);
+
+ const seen = new Set();
+ const pat = [`[sp-or-${id}]`];
+ for (const p of r.patterns) {
+ const match = s.pattern(p.match);
+ if (seen.has(match)) continue; // a duplicate would be a load error
+ seen.add(match);
+ const strip = s.digits(String(p.strip));
+ const prepend = p.prepend ? s.dialable(p.prepend) : '';
+ pat.push(`exten => _${match},1,Goto(sp-route-${id},${prepend}\${EXTEN:${strip}},1)`);
+ }
+
+ const route = [
+ `[sp-route-${id}]`,
+ 'exten => _X.,1,Set(__SP_DIRECTION=outbound)',
+ ` same => n,GotoIf($[\${IF($["\${SP_CLASS}" = ""]?0:\${SP_CLASS})} < ${CLASS_RANK[r.requiredClass]}]?denied)`,
+ ' same => n,ExecIf($["${SP_RECMODE}" = "outbound" | "${SP_RECMODE}" = "all"]?Gosub(sp-sub-record,s,1))',
+ ' ; caller ID: route override > user > the DID a forwarded call came in on > default',
+ ` same => n,Set(CID=${r.callerId ? s.digits(r.callerId) : ''})`,
+ ' same => n,ExecIf($["${CID}" = ""]?Set(CID=${SP_OUTCID}))',
+ ' same => n,ExecIf($["${CID}" = ""]?Set(CID=${SP_DID}))',
+ ' same => n,ExecIf($["${CID}" = ""]?Set(CID=${SP_DEFAULT_CID}))',
+ ' same => n,ExecIf($["${CID}" != ""]?Set(CALLERID(num)=${CID}))',
+ ];
+ const tries = r.trunkIds.map((tid) => trunks.get(tid)).filter((t) => t !== undefined);
+ tries.forEach((t, i) => {
+ const tid = s.ident(t.id);
+ const next = i + 1 < tries.length ? `t${i + 1}` : 'busy';
+ const label = i === 0 ? ' same => n' : ` same => n(t${i})`;
+ route.push(`${label},Set(GROUP(sptrunk)=${tid})`);
+ if (t.maxChannels)
+ route.push(
+ ` same => n,GotoIf($[\${GROUP_COUNT(${tid}@sptrunk)} > ${s.digits(String(t.maxChannels))}]?${next})`,
+ );
+ route.push(
+ ` same => n,Dial(PJSIP/\${EXTEN}@trunk-${tid},120)`,
+ ` same => n,GotoIf($["\${DIALSTATUS}" != "CHANUNAVAIL" & "\${DIALSTATUS}" != "CONGESTION"]?done)`,
+ );
+ });
+ route.push(
+ ' same => n(busy),Playback(all-circuits-busy-now)', // every trunk full, down or off
+ ' same => n(done),Hangup()',
+ ' same => n(denied),Playback(ss-noservice)',
+ ' same => n,Hangup()',
+ );
+ out.push(pat.join('\n'), route.join('\n'));
+ }
+ return [top.join('\n'), ...out].join('\n\n') + '\n';
+}
diff --git a/apps/api/src/generator/dialplan/pending.ts b/apps/api/src/generator/dialplan/pending.ts
index 492a79d..1c6b016 100644
--- a/apps/api/src/generator/dialplan/pending.ts
+++ b/apps/api/src/generator/dialplan/pending.ts
@@ -8,7 +8,4 @@ exten => pending,1,Hangup()
[sp-featurecodes] ; M6-T7 (03 §6.13)
exten => pending,1,Hangup()
-
-[sp-outbound-routes] ; M3-T3 (03 §6.12)
-exten => pending,1,Hangup()
`;
diff --git a/apps/api/src/generator/golden.test.ts b/apps/api/src/generator/golden.test.ts
index 9f35fee..2b5c932 100644
--- a/apps/api/src/generator/golden.test.ts
+++ b/apps/api/src/generator/golden.test.ts
@@ -20,6 +20,7 @@ export function loadCase(name: string): Snapshot {
extensions?: Partial[];
dids?: Partial[];
trunks?: Partial[];
+ routes?: Partial[];
};
const settings = Object.fromEntries(
(Object.keys(SettingsSchemas) as SettingKey[])
@@ -38,6 +39,17 @@ export function loadCase(name: string): Snapshot {
id: d.id!,
number: d.number!,
})),
+ routes: (raw.routes ?? []).map((r, i) => ({
+ name: `Route ${i + 1}`,
+ position: i,
+ enabled: true,
+ patterns: [],
+ requiredClass: 'national',
+ trunkIds: [],
+ callerId: null,
+ ...r,
+ id: r.id!,
+ })),
trunks: (raw.trunks ?? []).map((t) => ({
name: 'voip.ms',
enabled: true,
diff --git a/apps/api/src/generator/index.ts b/apps/api/src/generator/index.ts
index 73f11a6..3246b7d 100644
--- a/apps/api/src/generator/index.ts
+++ b/apps/api/src/generator/index.ts
@@ -14,6 +14,7 @@ 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';
+import { renderOutbound } from './dialplan/outbound.ts';
export { renderTransports };
@@ -46,6 +47,7 @@ const BODIES: Partial string>> = {
renderLocalNumbers(snap),
renderTrunkEntries(snap),
renderDids(snap),
+ renderOutbound(snap),
PENDING_CONTEXTS,
]
.filter(Boolean)
diff --git a/apps/api/src/generator/load.ts b/apps/api/src/generator/load.ts
index 1dd02ac..0dff83f 100644
--- a/apps/api/src/generator/load.ts
+++ b/apps/api/src/generator/load.ts
@@ -3,10 +3,10 @@
import { eq } from 'drizzle-orm';
import { alias } from 'drizzle-orm/pg-core';
import type { Db } from '../db/client.ts';
-import { dids, extensions, trunks, users } from '../db/schema.ts';
+import { dids, extensions, outboundRoutes, trunks, users } from '../db/schema.ts';
import { decrypt } from '../crypto/secrets.ts';
import { getAllSettings } from '../services/settings.ts';
-import type { Destination } from '@selfphoneng/shared';
+import type { Destination, OutboundClass, RoutePattern } from '@selfphoneng/shared';
export type SnapExtension = {
id: string;
@@ -36,6 +36,17 @@ export type SnapDid = {
callerIdNamePrefix: string | null;
};
+export type SnapRoute = {
+ id: string;
+ name: string;
+ position: number;
+ enabled: boolean;
+ patterns: RoutePattern[];
+ requiredClass: OutboundClass;
+ trunkIds: string[];
+ callerId: string | null; // the override DID's number
+};
+
export type SnapTrunk = {
id: string;
name: string;
@@ -60,6 +71,7 @@ export type Snapshot = {
extensions: SnapExtension[];
dids: SnapDid[];
trunks: SnapTrunk[];
+ routes: SnapRoute[];
};
export function todayIn(timezone: string, now = new Date()): string {
@@ -94,10 +106,22 @@ export async function loadSnapshot(db: Db, now = new Date()): Promise
})
.from(dids);
const trunkRows = await db.select().from(trunks);
+ const routeRows = await db.select().from(outboundRoutes);
+ const didNumber = new Map(didRows.map((d) => [d.id, d.number]));
return {
today: todayIn(settings['general.timezone'], now),
settings,
dids: didRows.map((d) => ({ ...d, destination: d.destination as Destination })),
+ routes: routeRows.map((r) => ({
+ id: r.id,
+ name: r.name,
+ position: r.position,
+ enabled: r.enabled,
+ patterns: r.patterns as RoutePattern[],
+ requiredClass: r.requiredClass,
+ trunkIds: r.trunkIds,
+ callerId: r.callerIdDidId ? (didNumber.get(r.callerIdDidId) ?? null) : null,
+ })),
trunks: trunkRows.map((t) => ({
id: t.id,
name: t.name,
diff --git a/apps/api/src/generator/pjsip.ts b/apps/api/src/generator/pjsip.ts
index db09796..f0dc3b3 100644
--- a/apps/api/src/generator/pjsip.ts
+++ b/apps/api/src/generator/pjsip.ts
@@ -1,10 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// pjsip_selfphoneng.conf (03 §5) and rtp_selfphoneng.conf (03 §4).
+import { CLASS_RANK } from '@selfphoneng/shared';
import { byNumber, type SnapExtension, type SnapTrunk, type Snapshot } from './load.ts';
import * as s from './sanitize.ts';
-const CLASS_NUMBER = { internal: 0, local: 1, national: 2, international: 3 } as const;
-
const section = (title: string, lines: (string | false | null | undefined)[]) =>
[`[${title}]`, ...lines.filter((l): l is string => typeof l === 'string')].join('\n');
@@ -76,7 +75,7 @@ function extensionSections(e: SnapExtension): string {
const mailbox = e.voicemailEnabled ? `mailboxes = ${num}@default` : null;
const vars = [
`set_var = SP_EXT=${num}`,
- `set_var = SP_CLASS=${CLASS_NUMBER[e.outboundClass]}`,
+ `set_var = SP_CLASS=${CLASS_RANK[e.outboundClass]}`,
e.outboundCallerId && `set_var = SP_OUTCID=${s.digits(e.outboundCallerId)}`,
e.emergencyCallerId && `set_var = SP_E911CID=${s.digits(e.emergencyCallerId)}`,
`set_var = SP_RECMODE=${e.recordMode}`,
diff --git a/apps/api/src/routes/outbound.ts b/apps/api/src/routes/outbound.ts
new file mode 100644
index 0000000..88d7ec4
--- /dev/null
+++ b/apps/api/src/routes/outbound.ts
@@ -0,0 +1,175 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+// /api/outbound-routes (04 §7.3, 03 §6.12). Admin only. Position = match order.
+import { asc, eq, inArray, max } from 'drizzle-orm';
+import type { FastifyInstance } from 'fastify';
+import { z } from 'zod';
+import {
+ OutboundRouteCreateSchema,
+ OutboundRouteUpdateSchema,
+ RouteTestSchema,
+ type ListResponse,
+ type OutboundRouteDto,
+ type RoutePattern,
+} from '@selfphoneng/shared';
+import type { Db } from '../db/client.ts';
+import { dids, outboundRoutes, trunks } from '../db/schema.ts';
+import { badRequest, notFound } from '../errors.ts';
+import { requireAdmin } from '../auth/guards.ts';
+import { audit } from '../services/audit.ts';
+import { testRoute } from '../services/outbound.ts';
+
+type RouteRow = typeof outboundRoutes.$inferSelect;
+
+const toDto = (r: RouteRow): OutboundRouteDto => ({
+ id: r.id,
+ name: r.name,
+ position: r.position,
+ enabled: r.enabled,
+ patterns: r.patterns as RoutePattern[],
+ requiredClass: r.requiredClass,
+ trunkIds: r.trunkIds,
+ callerIdDidId: r.callerIdDidId,
+ createdAt: r.createdAt.toISOString(),
+});
+
+const IdParams = z.object({ id: z.uuid() });
+
+async function getRoute(db: Db, id: string): Promise {
+ const [r] = await db.select().from(outboundRoutes).where(eq(outboundRoutes.id, id));
+ if (!r) throw notFound('Outbound rule not found');
+ return r;
+}
+
+/** trunk_ids is a plain uuid[] (no FK), so check it here. */
+async function checkRefs(db: Db, b: { trunkIds?: string[]; callerIdDidId?: string | null }) {
+ if (b.trunkIds) {
+ if (new Set(b.trunkIds).size !== b.trunkIds.length) throw badRequest('A trunk is listed twice');
+ const found = await db
+ .select({ id: trunks.id })
+ .from(trunks)
+ .where(inArray(trunks.id, b.trunkIds));
+ if (found.length !== b.trunkIds.length) throw badRequest('One of those trunks does not exist');
+ }
+ if (b.callerIdDidId) {
+ const [d] = await db.select({ id: dids.id }).from(dids).where(eq(dids.id, b.callerIdDidId));
+ if (!d) throw badRequest('That caller ID number does not exist');
+ }
+}
+
+export async function outboundRouteRoutes(app: FastifyInstance) {
+ const { db } = app;
+ app.addHook('preHandler', requireAdmin);
+
+ app.get('/api/outbound-routes', async (): Promise> => {
+ const rows = await db
+ .select()
+ .from(outboundRoutes)
+ .orderBy(asc(outboundRoutes.position), asc(outboundRoutes.id));
+ return { items: rows.map(toDto), nextCursor: null };
+ });
+
+ app.get('/api/outbound-routes/:id', async (req) => {
+ const { id } = IdParams.parse(req.params);
+ return toDto(await getRoute(db, id));
+ });
+
+ app.post('/api/outbound-routes', async (req, reply) => {
+ const body = OutboundRouteCreateSchema.parse(req.body);
+ await checkRefs(db, body);
+ const [{ last }] = (await db
+ .select({ last: max(outboundRoutes.position) })
+ .from(outboundRoutes)) as [{ last: number | null }];
+ const [created] = (await db
+ .insert(outboundRoutes)
+ .values({ ...body, position: (last ?? -1) + 1 })
+ .returning()) as [RouteRow];
+ await audit(db, {
+ actorUserId: req.user!.id,
+ actorIp: req.ip,
+ action: 'create',
+ entityType: 'outbound_route',
+ entityId: created.id,
+ summary: `Created outbound rule ${created.name}`,
+ diff: { after: body },
+ });
+ app.generator.requestApply('api-change');
+ return reply.code(201).send(toDto(created));
+ });
+
+ app.patch('/api/outbound-routes/:id', async (req) => {
+ const { id } = IdParams.parse(req.params);
+ const body = OutboundRouteUpdateSchema.parse(req.body);
+ const before = await getRoute(db, id);
+ await checkRefs(db, body);
+ const [after] = (await db
+ .update(outboundRoutes)
+ .set(body)
+ .where(eq(outboundRoutes.id, id))
+ .returning()) as [RouteRow];
+ const changed = (Object.keys(body) as (keyof typeof body)[]).filter(
+ (k) => JSON.stringify(before[k]) !== JSON.stringify(after[k]),
+ );
+ await audit(db, {
+ actorUserId: req.user!.id,
+ actorIp: req.ip,
+ action: 'update',
+ entityType: 'outbound_route',
+ entityId: id,
+ summary: `Updated outbound rule ${after.name}${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 toDto(after);
+ });
+
+ app.delete('/api/outbound-routes/:id', async (req) => {
+ const { id } = IdParams.parse(req.params);
+ const r = await getRoute(db, id);
+ await db.delete(outboundRoutes).where(eq(outboundRoutes.id, id));
+ await audit(db, {
+ actorUserId: req.user!.id,
+ actorIp: req.ip,
+ action: 'delete',
+ entityType: 'outbound_route',
+ entityId: id,
+ summary: `Deleted outbound rule ${r.name}`,
+ diff: { before: toDto(r) },
+ });
+ app.generator.requestApply('api-change');
+ return { ok: true };
+ });
+
+ /** The full new order: every route id exactly once. */
+ app.post('/api/outbound-routes/reorder', async (req) => {
+ const { ids } = z.object({ ids: z.array(z.uuid()) }).parse(req.body);
+ const all = await db.select({ id: outboundRoutes.id }).from(outboundRoutes);
+ if (
+ ids.length !== all.length ||
+ new Set(ids).size !== ids.length ||
+ all.some((r) => !ids.includes(r.id))
+ )
+ throw badRequest('Send every outbound rule exactly once');
+ await db.transaction(async (tx) => {
+ for (const [position, id] of ids.entries())
+ await tx.update(outboundRoutes).set({ position }).where(eq(outboundRoutes.id, id));
+ });
+ await audit(db, {
+ actorUserId: req.user!.id,
+ actorIp: req.ip,
+ action: 'reorder',
+ entityType: 'outbound_route',
+ summary: 'Reordered outbound rules',
+ diff: { after: ids },
+ });
+ app.generator.requestApply('api-change');
+ return { ok: true };
+ });
+
+ app.post('/api/outbound-routes/test', async (req) => {
+ const { extensionId, number } = RouteTestSchema.parse(req.body);
+ return testRoute(db, extensionId, number);
+ });
+}
diff --git a/apps/api/src/routes/trunks.ts b/apps/api/src/routes/trunks.ts
index a936284..df32b76 100644
--- a/apps/api/src/routes/trunks.ts
+++ b/apps/api/src/routes/trunks.ts
@@ -7,6 +7,7 @@ import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import {
TrunkCreateSchema,
+ formatNumber,
TrunkUpdateSchema,
type ListResponse,
type TrunkDto,
@@ -19,6 +20,7 @@ import { requireAdmin } from '../auth/guards.ts';
import { encrypt } from '../crypto/secrets.ts';
import { audit } from '../services/audit.ts';
import { getSetting } from '../services/settings.ts';
+import { createDefaultRoutes } from '../services/outbound.ts';
type TrunkRow = typeof trunks.$inferSelect;
@@ -106,7 +108,7 @@ export async function trunkRoutes(app: FastifyInstance) {
summary: `Created trunk ${created.name}`,
diff: { after: toDto(created) },
});
- // TODO(M3-T3): create the default outbound routes with the first trunk (06 §4).
+ await createDefaultRoutes(db, created.id); // only when there are no routes yet (06 §4)
app.generator.requestApply('api-change');
return reply.code(201).send(toDto(created));
});
@@ -167,7 +169,7 @@ export async function trunkRoutes(app: FastifyInstance) {
const t = await getTrunk(db, id);
const usedBy = [
...(await db.select({ n: dids.number }).from(dids).where(eq(dids.trunkId, id))).map(
- (r) => `number ${r.n}`,
+ (r) => `number ${formatNumber(r.n)}`,
),
...(
await db
diff --git a/apps/api/src/services/outbound.ts b/apps/api/src/services/outbound.ts
new file mode 100644
index 0000000..173f01d
--- /dev/null
+++ b/apps/api/src/services/outbound.ts
@@ -0,0 +1,120 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+// Outbound routes: the defaults (06 §4) and "which route would this call use?" (04 §7.3).
+import { asc, count, eq, inArray } from 'drizzle-orm';
+import {
+ CLASS_RANK,
+ dialedDigits,
+ formatNumber,
+ patternRegex,
+ type RoutePattern,
+ type RouteTestResult,
+} from '@selfphoneng/shared';
+import type { Db } from '../db/client.ts';
+import { dids, extensions, outboundRoutes, trunks } from '../db/schema.ts';
+import { notFound } from '../errors.ts';
+import { getSetting } from './settings.ts';
+
+const TOLL_FREE = ['800', '888', '877', '866', '855', '844', '833'];
+
+/**
+ * Created with the first trunk so calls work straight away; international starts disabled.
+ * Toll-free comes first: 1NXXNXXXXXX also matches 1800…, and the first matching rule wins.
+ */
+export async function createDefaultRoutes(db: Db, trunkId: string): Promise {
+ const [{ n }] = (await db.select({ n: count() }).from(outboundRoutes)) as [{ n: number }];
+ if (n > 0) return;
+ await db.insert(outboundRoutes).values([
+ {
+ name: 'Toll-free',
+ position: 0,
+ patterns: TOLL_FREE.map((p) => ({ match: `1${p}NXXXXXX`, strip: 0, prepend: '' })),
+ requiredClass: 'local',
+ trunkIds: [trunkId],
+ },
+ {
+ name: 'Local and national (US/Canada)',
+ position: 1,
+ patterns: [
+ { match: 'NXXNXXXXXX', strip: 0, prepend: '1' },
+ { match: '1NXXNXXXXXX', strip: 0, prepend: '' },
+ ],
+ requiredClass: 'national',
+ trunkIds: [trunkId],
+ },
+ {
+ name: 'International',
+ position: 2,
+ enabled: false, // toll-fraud protection: an admin turns it on deliberately (06 §4)
+ patterns: [{ match: '011.', strip: 0, prepend: '' }],
+ requiredClass: 'international',
+ trunkIds: [trunkId],
+ },
+ ]);
+}
+
+/** Mirrors the generated dialplan: sp-from-ext tries emergency, internal numbers, then routes. */
+export async function testRoute(
+ db: Db,
+ extensionId: string,
+ input: string,
+): Promise {
+ const [ext] = await db.select().from(extensions).where(eq(extensions.id, extensionId));
+ if (!ext) throw notFound('Extension not found');
+ const n = dialedDigits(input);
+ if (n === '911' || n === '9911')
+ return { ok: false, reason: 'Emergency call: always allowed, handled by the 911 rules' };
+ const [internal] = await db
+ .select({ id: extensions.id })
+ .from(extensions)
+ .where(eq(extensions.number, n));
+ if (internal) return { ok: false, reason: `${n} is an internal number, not an outside call` };
+
+ const routes = await db
+ .select()
+ .from(outboundRoutes)
+ .where(eq(outboundRoutes.enabled, true))
+ .orderBy(asc(outboundRoutes.position), asc(outboundRoutes.id));
+ for (const r of routes) {
+ const p = (r.patterns as RoutePattern[]).find((x) => patternRegex(x.match).test(n));
+ if (!p) continue;
+ if (CLASS_RANK[ext.outboundClass] < CLASS_RANK[r.requiredClass])
+ return {
+ ok: false,
+ routeId: r.id,
+ routeName: r.name,
+ reason: `Denied: "${r.name}" needs calling permission "${r.requiredClass}", ${ext.number} has "${ext.outboundClass}". The caller hears "no service".`,
+ };
+ const rows = r.trunkIds.length
+ ? await db
+ .select({ id: trunks.id, name: trunks.name, enabled: trunks.enabled })
+ .from(trunks)
+ .where(inArray(trunks.id, r.trunkIds))
+ : [];
+ const usable = r.trunkIds
+ .map((id) => rows.find((t) => t.id === id))
+ .filter((t) => t?.enabled)
+ .map((t) => ({ id: t!.id, name: t!.name }));
+ if (!usable.length)
+ return {
+ ok: false,
+ routeId: r.id,
+ routeName: r.name,
+ reason: `"${r.name}" matches but none of its trunks is enabled`,
+ };
+ const cidDid = r.callerIdDidId ?? ext.outboundCallerIdDidId;
+ const cidId = cidDid ?? (await getSetting(db, 'routing.defaultCallerIdDidId'));
+ const [did] = cidId
+ ? await db.select({ number: dids.number }).from(dids).where(eq(dids.id, cidId))
+ : [];
+ return {
+ ok: true,
+ routeId: r.id,
+ routeName: r.name,
+ pattern: p.match,
+ dialed: p.prepend + n.slice(p.strip),
+ trunks: usable,
+ callerId: did ? formatNumber(did.number) : null,
+ };
+ }
+ return { ok: false, reason: 'No enabled route matches this number. The call fails.' };
+}
diff --git a/apps/api/test/asterisk/call-test.ts b/apps/api/test/asterisk/call-test.ts
index d4f6d8b..6e7fca9 100644
--- a/apps/api/test/asterisk/call-test.ts
+++ b/apps/api/test/asterisk/call-test.ts
@@ -62,6 +62,7 @@ function snapshot(): Snapshot {
settings,
dids: [],
trunks: [],
+ routes: [],
extensions: [ext('101', 'Alice'), ext('102', 'Bob')],
};
}
diff --git a/apps/api/test/asterisk/trunk-test.ts b/apps/api/test/asterisk/trunk-test.ts
index b2b02d7..4a98861 100644
--- a/apps/api/test/asterisk/trunk-test.ts
+++ b/apps/api/test/asterisk/trunk-test.ts
@@ -32,6 +32,7 @@ const PHONES = 'sp-trunk-phones';
const EXT_ID = '00000000-0000-4000-8000-000000000101';
const PHONE_PASS = 'TrunkTest101xxxxxxxxxxxx';
const DID = '13125551234';
+const DID_ID = '11111111-1111-4111-8111-111111111111';
const MOBILE = '3125559999'; // the outside caller, 10 digits as voip.ms sends them
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
@@ -47,6 +48,7 @@ function snapshot(): Snapshot {
.filter((k) => k !== 'sms.webhookToken')
.map((k) => [k, SettingsSchemas[k].parse(undefined)]),
) as Snapshot['settings'];
+ settings['routing.defaultCallerIdDidId'] = DID_ID;
const trunk = (id: string, name: string, password: string) => ({
id,
name,
@@ -68,7 +70,7 @@ function snapshot(): Snapshot {
settings,
dids: [
{
- id: '11111111-1111-4111-8111-111111111111',
+ id: DID_ID,
number: DID,
trunkId: GOOD,
label: 'Main line',
@@ -97,6 +99,21 @@ function snapshot(): Snapshot {
},
],
trunks: [trunk(GOOD, 'voip.ms', SUB.pass), trunk(BAD, 'wrong password', 'NotThePassword1')],
+ routes: [
+ {
+ id: 'aaaaaaaa-0000-4000-8000-000000000001',
+ name: 'Local and national',
+ position: 0,
+ enabled: true,
+ patterns: [
+ { match: 'NXXNXXXXXX', strip: 0, prepend: '1' },
+ { match: '1NXXNXXXXXX', strip: 0, prepend: '' },
+ ],
+ requiredClass: 'national',
+ trunkIds: [GOOD],
+ callerId: null,
+ },
+ ],
};
}
@@ -167,8 +184,11 @@ exten => _X.,1,Set(CALLERID(all)="Mobile" <${MOBILE}>)
exten => s,1,Answer()
same => n,Echo()
+; Our outbound calls arrive here: remember what the provider saw, then echo the audio.
[provider-in]
-exten => _X.,1,Hangup()
+exten => _X.,1,Set(GLOBAL(LAST_IN)=to=\${EXTEN} from=\${CALLERID(num)} rpid=\${PJSIP_HEADER(read,Remote-Party-ID)})
+ same => n,Answer()
+ same => n,Echo()
`;
async function waitFor(what: string, fn: () => Promise, seconds = 60) {
@@ -230,6 +250,26 @@ async function main() {
`${conf}:/etc/asterisk/selfphoneng`,
IMAGE,
);
+ /** Holds the call 10 s, then both legs (101 and the trunk) must have RTP both ways. */
+ async function checkAudio(what: string) {
+ 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(`${what}: 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(`${what} ${cols[1]}: rx ${rx}, tx ${tx}`);
+ if (!(rx > 250) || !(tx > 250))
+ problems.push(`${what}: one-way or no audio on ${cols[1]}: rx ${rx}, tx ${tx}`);
+ }
+ await ami.command('channel request hangup all');
+ for (let i = 0; i < 10 && /PJSIP\//.test(await ami.command('core show channels concise')); i++)
+ await sleep(500);
+ }
const booted = async (name: string) =>
waitFor(
`${name} to boot`,
@@ -346,20 +386,36 @@ async function main() {
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 checkAudio('inbound');
+
+ // M3-T3: 101 dials a 10-digit number; it leaves as 11 digits with the DID as caller ID.
+ const bothUp = async () => {
+ const up = (await ami.command('core show channels concise'))
+ .split('\n')
+ .filter((l) => l.includes('!Up!'));
+ return (
+ up.some((l) => l.startsWith('PJSIP/101-')) && up.some((l) => l.startsWith('PJSIP/trunk-'))
+ );
+ };
+ await ami.command(`channel originate PJSIP/101 extension 3125550000@sp-from-ext`);
+ await waitFor('the outbound call to connect', bothUp, 30);
+ const seen = docker('exec', PROVIDER, 'asterisk', '-rx', 'dialplan show globals').out;
+ const lastIn = /LAST_IN=(.*)/.exec(seen)?.[1] ?? '';
+ log(`provider received: ${lastIn}`);
+ if (!lastIn.startsWith('to=13125550000 '))
+ problems.push(`provider saw ${lastIn}, expected to=13125550000`);
+ if (!new RegExp(`rpid=.*sip:${DID}@`).test(lastIn))
+ problems.push(`caller ID ${DID} not in Remote-Party-ID: ${lastIn}`);
+ await checkAudio('outbound');
+
+ // A caller whose class is too low (a Local channel has no SP_CLASS) hears "no service".
+ await ami.command('channel originate Local/13125550000@sp-from-ext application Wait 10');
+ await waitFor(
+ 'ss-noservice',
+ async () => /Playback!ss-noservice/.test(await ami.command('core show channels concise')),
+ 10,
+ );
+ log('class check: ss-noservice');
await ami.command('channel request hangup all');
// eslint-disable-next-line no-control-regex -- strips Asterisk's ANSI colors
@@ -384,7 +440,9 @@ async function main() {
for (const p of problems) console.log(` ${p}`);
process.exit(1);
}
- console.log('ok trunks (registration, rejection, qualify, inbound DID call to 101 with audio)');
+ console.log(
+ 'ok trunks (registration, rejection, qualify, inbound DID → 101, outbound with DID caller ID, class check)',
+ );
}
await main();
diff --git a/apps/api/test/outbound.test.ts b/apps/api/test/outbound.test.ts
new file mode 100644
index 0000000..e789c69
--- /dev/null
+++ b/apps/api/test/outbound.test.ts
@@ -0,0 +1,170 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+// M3-T3: outbound routes, their order, the test box, and caller ID priority.
+import { readFileSync } from 'node:fs';
+import { join } from 'node:path';
+import type { LightMyRequestResponse } from 'fastify';
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+import { patternRegex } from '@selfphoneng/shared';
+import { settings } from '../src/db/schema.ts';
+import { hasTestDb } from './db.ts';
+import { ADMIN, H, createTestApp } from './app.ts';
+
+describe('patternRegex (Asterisk pattern rules)', () => {
+ it.each([
+ ['NXXNXXXXXX', '3125550000', true],
+ ['NXXNXXXXXX', '1125550000', false], // N is 2-9
+ ['1NXXNXXXXXX', '13125550000', true],
+ ['1800NXXXXXX', '18005551234', true],
+ ['011.', '011442079460958', true],
+ ['011.', '011', false], // "." needs at least one more character
+ ['011!', '011', true],
+ ['9[2-9]XX', '9234', true],
+ ['9[2-9]XX', '9134', false],
+ ['ZXX', '011', false],
+ ])('%s vs %s → %s', (p, n, ok) => expect(patternRegex(p).test(n)).toBe(ok));
+});
+
+describe.skipIf(!hasTestDb)('outbound routes API', () => {
+ let t: Awaited>;
+ let req: (method: string, url: string, payload?: unknown) => Promise;
+ 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 ext: { id: string };
+ let local: { id: string };
+ let mainDid: string;
+ let salesDid: string;
+ let routes: { id: string; name: string }[];
+ const test = async (extensionId: string, number: string) =>
+ (await req('POST', '/api/outbound-routes/test', { extensionId, number })).json();
+
+ 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;
+ ext = (await req('POST', '/api/extensions', { number: '101', name: 'Dan' })).json();
+ local = (
+ await req('POST', '/api/extensions', { number: '102', name: 'Lo', outboundClass: 'local' })
+ ).json();
+ mainDid = (
+ await req('POST', '/api/dids', {
+ number: '3125551234',
+ trunkId,
+ destination: { type: 'hangup' },
+ })
+ ).json().id;
+ salesDid = (
+ await req('POST', '/api/dids', {
+ number: '3125559876',
+ trunkId,
+ destination: { type: 'hangup' },
+ })
+ ).json().id;
+ await t.db.insert(settings).values({ key: 'routing.defaultCallerIdDidId', value: mainDid });
+ routes = (await req('GET', '/api/outbound-routes')).json().items;
+ });
+ afterAll(() => t?.close());
+
+ it('a 10-digit number goes out as 11 digits with the default caller ID', async () => {
+ expect(await test(ext.id, '(312) 555-0000')).toEqual({
+ ok: true,
+ routeId: routes[1]!.id,
+ routeName: 'Local and national (US/Canada)',
+ pattern: 'NXXNXXXXXX',
+ dialed: '13125550000',
+ trunks: [{ id: trunkId, name: 'voip.ms' }],
+ callerId: '(312) 555-1234',
+ });
+ });
+
+ it('class local may call toll-free but not national; international is off', async () => {
+ expect((await test(local.id, '18005551234')).ok).toBe(true);
+ const denied = await test(local.id, '13125550000');
+ expect(denied).toMatchObject({ ok: false, routeName: 'Local and national (US/Canada)' });
+ expect(denied.reason).toMatch(/needs calling permission "national"/);
+ expect(await test(ext.id, '011442079460958')).toEqual({
+ ok: false,
+ reason: 'No enabled route matches this number. The call fails.',
+ });
+ expect((await test(ext.id, '911')).reason).toMatch(/Emergency/);
+ expect((await test(ext.id, '102')).reason).toMatch(/internal number/);
+ });
+
+ it('caller ID: the user setting beats the default, a route override beats both', async () => {
+ await req('PATCH', `/api/extensions/${ext.id}`, { outboundCallerIdDidId: salesDid });
+ expect((await test(ext.id, '3125550000')).callerId).toBe('(312) 555-9876');
+ await req('PATCH', `/api/outbound-routes/${routes[1]!.id}`, { callerIdDidId: mainDid });
+ expect((await test(ext.id, '3125550000')).callerId).toBe('(312) 555-1234');
+ await req('PATCH', `/api/outbound-routes/${routes[1]!.id}`, { callerIdDidId: null });
+ });
+
+ it('turning international on, then reordering, changes what matches first', async () => {
+ const intl = routes[2]!;
+ await req('PATCH', `/api/outbound-routes/${intl.id}`, { enabled: true });
+ expect((await test(ext.id, '011442079460958')).ok).toBe(false); // class national < international
+ const catchAll = (
+ await req('POST', '/api/outbound-routes', {
+ name: 'Everything',
+ patterns: [{ match: 'X.', strip: 0, prepend: '' }],
+ requiredClass: 'local',
+ trunkIds: [trunkId],
+ })
+ ).json();
+ expect(catchAll.position).toBe(3);
+ expect((await test(local.id, '13125550000')).routeName).toBe('Local and national (US/Canada)');
+ const ids = [catchAll.id, ...routes.map((r) => r.id)];
+ expect((await req('POST', '/api/outbound-routes/reorder', { ids })).statusCode).toBe(200);
+ expect((await test(local.id, '13125550000')).routeName).toBe('Everything');
+
+ await settle();
+ const conf = dialplan();
+ const order = [...conf.matchAll(/^include => sp-or-(\S+)/gm)].map((m) => m[1]);
+ expect(order).toEqual(ids); // the include order is the position order
+ expect(conf).toContain(`[sp-route-${catchAll.id}]`);
+ expect(
+ (await req('POST', '/api/outbound-routes/reorder', { ids: ids.slice(1) })).statusCode,
+ ).toBe(400);
+ });
+
+ it('refuses unknown or repeated trunks and bad patterns', async () => {
+ const base = {
+ name: 'x',
+ requiredClass: 'local',
+ patterns: [{ match: 'X.', strip: 0, prepend: '' }],
+ };
+ const unknown = await req('POST', '/api/outbound-routes', {
+ ...base,
+ trunkIds: ['00000000-0000-4000-8000-000000000000'],
+ });
+ expect(unknown.statusCode).toBe(400);
+ const twice = await req('POST', '/api/outbound-routes', {
+ ...base,
+ trunkIds: [trunkId, trunkId],
+ });
+ expect(twice.statusCode).toBe(400);
+ const bad = await req('POST', '/api/outbound-routes', {
+ ...base,
+ trunkIds: [trunkId],
+ patterns: [{ match: '9,Dial(x)', strip: 0, prepend: '' }],
+ });
+ expect(bad.statusCode).toBe(400);
+ });
+});
diff --git a/apps/api/test/trunks.test.ts b/apps/api/test/trunks.test.ts
index 398523a..1da6424 100644
--- a/apps/api/test/trunks.test.ts
+++ b/apps/api/test/trunks.test.ts
@@ -9,7 +9,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { TrunkCreateSchema, type LiveMessage } from '@selfphoneng/shared';
import { AmiParser, field } from '../src/ami/events.ts';
import { decrypt } from '../src/crypto/secrets.ts';
-import { auditLog, dids, trunks } from '../src/db/schema.ts';
+import { auditLog, dids, outboundRoutes, trunks } from '../src/db/schema.ts';
import { trunkOfObject } from '../src/live/state.ts';
import { hasTestDb } from './db.ts';
import { ADMIN, H, createTestApp } from './app.ts';
@@ -156,15 +156,31 @@ describe.skipIf(!hasTestDb)('trunks API', () => {
await req('PATCH', `/api/trunks/${id}`, { enabled: true });
});
- it('refuses to delete a trunk that a phone number uses', async () => {
+ it('the first trunk gets the default outbound rules (international off)', async () => {
+ const routes = (await req('GET', '/api/outbound-routes')).json().items;
+ expect(routes.map((r: { name: string; enabled: boolean }) => [r.name, r.enabled])).toEqual([
+ ['Toll-free', true],
+ ['Local and national (US/Canada)', true],
+ ['International', false],
+ ]);
+ expect(routes.every((r: { trunkIds: string[] }) => r.trunkIds[0] === id)).toBe(true);
+ });
+
+ it('refuses to delete a trunk that a phone number or an outbound rule uses', async () => {
const [d] = await t.db
.insert(dids)
.values({ number: '13125551234', trunkId: id, destination: { type: 'hangup' } })
.returning();
const r = await req('DELETE', `/api/trunks/${id}`);
expect(r.statusCode).toBe(409);
- expect(r.json().error.message).toContain('number 13125551234');
+ expect(r.json().error.details.usedBy).toEqual([
+ 'number (312) 555-1234',
+ 'outbound rule "Toll-free"',
+ 'outbound rule "Local and national (US/Canada)"',
+ 'outbound rule "International"',
+ ]);
await t.db.delete(dids).where(eq(dids.id, d!.id));
+ await t.db.delete(outboundRoutes);
expect((await req('DELETE', `/api/trunks/${id}`)).statusCode).toBe(200);
await settle();
expect(pjsip()).not.toContain('trunk-');
diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts
index 9ec6b6f..a9b62a4 100644
--- a/apps/web/src/api/client.ts
+++ b/apps/web/src/api/client.ts
@@ -10,6 +10,10 @@ import type {
ExtensionUpdate,
ListResponse,
NumberKind,
+ OutboundRouteCreate,
+ OutboundRouteDto,
+ OutboundRouteUpdate,
+ RouteTestResult,
SipCredentials,
TrunkCreate,
TrunkDto,
@@ -97,3 +101,15 @@ export const didsApi = {
update: (id: string, d: DidUpdate) => api('PATCH', `/api/dids/${id}`, d),
remove: (id: string) => api<{ ok: true }>('DELETE', `/api/dids/${id}`),
};
+
+export const routesApi = {
+ list: () =>
+ api>('GET', '/api/outbound-routes').then((r) => r.items),
+ create: (r: OutboundRouteCreate) => api('POST', '/api/outbound-routes', r),
+ update: (id: string, r: OutboundRouteUpdate) =>
+ api('PATCH', `/api/outbound-routes/${id}`, r),
+ remove: (id: string) => api<{ ok: true }>('DELETE', `/api/outbound-routes/${id}`),
+ reorder: (ids: string[]) => api<{ ok: true }>('POST', '/api/outbound-routes/reorder', { ids }),
+ test: (extensionId: string, number: string) =>
+ api('POST', '/api/outbound-routes/test', { extensionId, number }),
+};
diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx
index c9df52e..ad682fd 100644
--- a/apps/web/src/main.tsx
+++ b/apps/web/src/main.tsx
@@ -12,6 +12,7 @@ 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 { OutboundPage } from './pages/admin/OutboundPage.tsx';
import { MePage } from './pages/user/MePage.tsx';
import './index.css';
@@ -28,6 +29,8 @@ const router = createBrowserRouter([
) : p.path === 'users' ? (
{(user) => }
+ ) : p.path === 'outbound' ? (
+
) : p.path === 'numbers' ? (
) : p.path === 'trunks' ? (
diff --git a/apps/web/src/pages/admin/AdminLayout.tsx b/apps/web/src/pages/admin/AdminLayout.tsx
index 971c45b..c62cbf9 100644
--- a/apps/web/src/pages/admin/AdminLayout.tsx
+++ b/apps/web/src/pages/admin/AdminLayout.tsx
@@ -14,7 +14,7 @@ export const ADMIN_PAGES = [
{ path: 'users', label: 'Users & Extensions', milestone: null },
{ path: 'trunks', label: 'Trunks', milestone: null },
{ path: 'numbers', label: 'Phone numbers', milestone: null },
- { path: 'outbound', label: 'Outbound rules', milestone: 'M3' },
+ { path: 'outbound', label: 'Outbound rules', milestone: null },
{ path: 'ring-groups', label: 'Ring groups', milestone: 'M6' },
{ path: 'queues', label: 'Queues', milestone: 'M8' },
{ path: 'ivrs', label: 'Digital receptionist', milestone: 'M6' },
diff --git a/apps/web/src/pages/admin/OutboundPage.tsx b/apps/web/src/pages/admin/OutboundPage.tsx
new file mode 100644
index 0000000..00b1039
--- /dev/null
+++ b/apps/web/src/pages/admin/OutboundPage.tsx
@@ -0,0 +1,443 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+// Outbound rules (05 §2, 03 §6.12): ordered list (first match wins), form, and a test box.
+// ponytail: ↑/↓ buttons instead of drag-to-reorder; keyboard-friendly, no drag library.
+import { zodResolver } from '@hookform/resolvers/zod';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { useState } from 'react';
+import { useFieldArray, useForm } from 'react-hook-form';
+import { z } from 'zod';
+import {
+ OUTBOUND_CLASSES,
+ RoutePatternSchema,
+ formatNumber,
+ type OutboundRouteDto,
+ type RouteTestResult,
+} from '@selfphoneng/shared';
+import { didsApi, extensionsApi, routesApi, trunksApi } from '../../api/client.ts';
+import { ConfirmDialog, Dialog } from '../../components/Dialog.tsx';
+import { Alert, Button, Field, inputClass } from '../../components/ui.tsx';
+
+export const CLASS_LABEL: Record<(typeof OUTBOUND_CLASSES)[number], string> = {
+ internal: 'Internal only',
+ local: 'Local and toll-free',
+ national: 'US/Canada',
+ international: 'International',
+};
+
+export function OutboundPage() {
+ const qc = useQueryClient();
+ const routes = useQuery({ queryKey: ['routes'], queryFn: routesApi.list });
+ const trunks = useQuery({ queryKey: ['trunks'], queryFn: trunksApi.list });
+ const [editing, setEditing] = useState(null);
+ const [deleting, setDeleting] = useState(null);
+ const refresh = () => qc.invalidateQueries({ queryKey: ['routes'] });
+ const remove = useMutation({
+ mutationFn: (id: string) => routesApi.remove(id),
+ onSuccess: () => {
+ setDeleting(null);
+ void refresh();
+ },
+ });
+ const move = useMutation({
+ mutationFn: ({ from, to }: { from: number; to: number }) => {
+ const ids = routes.data!.map((r) => r.id);
+ [ids[from], ids[to]] = [ids[to]!, ids[from]!];
+ return routesApi.reorder(ids);
+ },
+ onSettled: () => void refresh(),
+ });
+ const trunkName = (id: string) => trunks.data?.find((t) => t.id === id)?.name ?? '?';
+
+ return (
+
+
+
Outbound rules
+
+
+
+ When someone dials an outside number, the first rule whose pattern matches decides how the
+ call goes out. Put more specific rules (like toll-free) above general ones.
+