extensions: numbering service, CRUD API, credentials, Users page tabs

One namespace for internal numbers (range, reserved codes, parking, every
object table) with a next-free lookup. Creating an extension generates its
SIP password and voicemail PIN and a runtime-state row; every change and
password regeneration triggers a config apply. Credentials are revealed
only on request and each reveal is audited. Users get an Ext. column and a
drawer with General (number + next free), Phone and Credentials tabs.
scripts/e2e/m2-t5.mjs checks the flow in a browser and that the generated
PJSIP file follows; the files it produced load cleanly in Asterisk.

Task: M2-T5
This commit is contained in:
iamdoubz committed 2026-10-17 16:35:07 -05:00
1 parent a4da81cf8f
commit 1cfc13ead3
12 files changed
+1167 -141

No files matched your search

+58
View File
@@ -0,0 +1,58 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Extension (the telephony identity of a user) as the API accepts and returns it (02, 04 §7.3).
// The patterns match the generator's sanitizers (03 §2), so valid input always renders.
import { z } from 'zod';
export const InternalNumber = z.string().regex(/^\d{3,6}$/, 'Use 3 to 6 digits');
const Slug = z.string().regex(/^[a-z0-9-]{1,32}$/, 'Lowercase letters, digits and dashes');
const Pin = z.string().regex(/^\d{4,10}$/, 'Use 4 to 10 digits');
const ExtensionFields = z.object({
number: InternalNumber,
userId: z.uuid().nullable(),
name: z.string().trim().min(1, 'Required').max(40),
webrtcEnabled: z.boolean(),
maxContacts: z.number().int().min(1).max(10),
ringSeconds: z.number().int().min(5).max(120),
voicemailEnabled: z.boolean(),
voicemailPin: Pin, // write-only
voicemailEmail: z.boolean(),
voicemailAttachAudio: z.boolean(),
voicemailDeleteAfterEmail: z.boolean(),
outboundClass: z.enum(['internal', 'local', 'national', 'international']),
outboundCallerIdDidId: z.uuid().nullable(),
emergencyCallerIdDidId: z.uuid().nullable(),
emergencyLocation: z.string().trim().max(200).nullable(),
recordMode: z.enum(['never', 'inbound', 'outbound', 'all']),
pickupGroup: Slug.nullable(),
language: z.string().regex(/^[a-z]{2,3}$/, 'A language code like en'),
mohClass: Slug,
blfNumbers: z.array(InternalNumber).max(32),
});
export const ExtensionCreateSchema = ExtensionFields.partial().required({
number: true,
name: true,
});
export const ExtensionUpdateSchema = ExtensionFields.partial();
export type ExtensionCreate = z.input<typeof ExtensionCreateSchema>;
export type ExtensionUpdate = z.input<typeof ExtensionUpdateSchema>;
export type ExtensionDto = Omit<z.output<typeof ExtensionFields>, 'voicemailPin'> & {
id: string;
createdAt: string;
};
export type SipCredentials = {
server: string;
port: number;
transport: 'udp' | 'tcp';
username: string;
password: string;
/** Browser softphone identity (when webrtc is on). */
webUsername: string | null;
};
export const NUMBER_KINDS = ['user', 'ringGroup', 'queue', 'ivr', 'conference', 'paging'] as const;
export type NumberKind = (typeof NUMBER_KINDS)[number];
+1
View File
@@ -5,3 +5,4 @@ export * from './destination.ts';
export * from './settings.ts';
export * from './auth.ts';
export * from './users.ts';
export * from './extensions.ts';
+1
View File
@@ -31,6 +31,7 @@ export type UserDto = {
mustChangePassword: boolean;
lastLoginAt: string | null;
createdAt: string;
extension: { id: string; number: string } | null;
};
export type ListResponse<T> = { items: T[]; nextCursor: string | null };