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:
1 parent
c117d8bb1d
commit
4496f6b89a
33 files changed
+1686
-70
No files matched your search
@@ -9,3 +9,4 @@ export * from './extensions.ts';
|
||||
export * from './live.ts';
|
||||
export * from './trunks.ts';
|
||||
export * from './dids.ts';
|
||||
export * from './outbound.ts';
|
||||
@@ -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, '');
|
||||
Reference in new issue
Block a user