outbound: routes with failover and caller ID, test box, Outbound rules page

Outbound routes CRUD, reorder and POST /test (mirrors the dialplan:
911 and internal numbers first, then the first matching enabled route,
class check, usable trunks, caller ID priority route > user > default).
The first trunk creates the default rules: toll-free first (because
1NXXNXXXXXX also matches 1800...), US/Canada, international disabled.

Generator: Asterisk orders patterns in one context by specificity, not
by file order, so each route gets its own sp-or-{id} context and
sp-outbound-routes includes them in position order (spec 03 §6.12
updated). sp-route-{id}: class check -> ss-noservice, caller ID
priority, per-trunk GROUP channel limit, failover on CHANUNAVAIL or
CONGESTION, all-circuits-busy-now when every trunk is full or off.

UI: Outbound rules page (↑/↓ reorder, pattern rows with help, ordered
trunk list, caller ID override, test box) and a Calling tab on users
(calling permission, outbound caller ID).

trunk-test.ts: 101 dials 3125550000 -> the fake provider receives
13125550000 with the DID in Remote-Party-ID (From user is the
sub-account), two-way audio; a caller without a class hears
ss-noservice. scripts/e2e/m3-t3.mjs covers the UI. With real voip.ms
still to do: whether per-call caller ID is honored (06 §4).

Task: M3-T3
This commit is contained in:
iamdoubz committed 2026-10-17 20:04:35 -05:00
1 parent c117d8bb1d
commit 4496f6b89a
33 files changed
+1686 -70

No files matched your search

+26 -2
View File
@@ -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<Snapshot>
})
.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,