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

+1
View File
@@ -9,3 +9,4 @@ export * from './extensions.ts';
export * from './live.ts';
export * from './trunks.ts';
export * from './dids.ts';
export * from './outbound.ts';
+90
View File
@@ -0,0 +1,90 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Outbound routes (02, 03 §6.12, 06 §4) and Asterisk dial-pattern matching for the test box.
import { z } from 'zod';
export const OUTBOUND_CLASSES = ['internal', 'local', 'national', 'international'] as const;
export type OutboundClass = (typeof OUTBOUND_CLASSES)[number];
/** SP_CLASS numbers in the dialplan: a caller may use a route when theirs is >= the route's. */
export const CLASS_RANK: Record<OutboundClass, number> = {
internal: 0,
local: 1,
national: 2,
international: 3,
};
export const RoutePatternSchema = z.object({
/** Asterisk pattern without the leading "_": X Z N [1-5] . ! and digits. */
match: z
.string()
.trim()
.regex(/^(?:[0-9XZN.!]|\[[0-9-]+\])+$/, 'Digits and X Z N [1-5] . ! only')
.max(32),
strip: z.number().int().min(0).max(20),
prepend: z
.string()
.regex(/^[0-9*#+]{0,10}$/, 'Digits only')
.max(10),
});
export type RoutePattern = z.infer<typeof RoutePatternSchema>;
const RouteFields = z.object({
name: z.string().trim().min(1, 'Required').max(60),
enabled: z.boolean(),
patterns: z.array(RoutePatternSchema).min(1, 'Add at least one pattern').max(50),
requiredClass: z.enum(OUTBOUND_CLASSES),
trunkIds: z.array(z.uuid()).min(1, 'Pick at least one trunk').max(10),
callerIdDidId: z.uuid().nullable(),
});
export const OutboundRouteCreateSchema = RouteFields.partial().required({
name: true,
patterns: true,
requiredClass: true,
trunkIds: true,
});
export const OutboundRouteUpdateSchema = RouteFields.partial();
export type OutboundRouteCreate = z.input<typeof OutboundRouteCreateSchema>;
export type OutboundRouteUpdate = z.input<typeof OutboundRouteUpdateSchema>;
export type OutboundRouteDto = z.output<typeof RouteFields> & {
id: string;
position: number;
createdAt: string;
};
export const RouteTestSchema = z.object({
extensionId: z.uuid(),
number: z.string().trim().min(1).max(32),
});
export type RouteTestResult =
| {
ok: true;
routeId: string;
routeName: string;
pattern: string;
dialed: string;
trunks: { id: string; name: string }[];
callerId: string | null;
}
| { ok: false; reason: string; routeId?: string; routeName?: string };
/** Asterisk extension pattern (without "_") → RegExp. Same rules Asterisk uses. */
export function patternRegex(match: string): RegExp {
let re = '';
for (let i = 0; i < match.length; i++) {
const c = match[i]!;
if (c === 'X') re += '[0-9]';
else if (c === 'Z') re += '[1-9]';
else if (c === 'N') re += '[2-9]';
else if (c === '.') re += '.+';
else if (c === '!') re += '.*';
else if (c === '[') {
const end = match.indexOf(']', i);
re += `[${match.slice(i + 1, end)}]`;
i = end;
} else re += c;
}
return new RegExp(`^${re}$`);
}
/** The dialed string as the dialplan sees it: what was typed, minus formatting. */
export const dialedDigits = (n: string) => n.replace(/[\s\-.()]/g, '');