Files
selfphoneng/apps/api/src/generator/load.ts
T
iamdoubz b05361e840
ci / check (push) Successful in 1m2s
ci / images (push) Successful in 22s
contacts: caller names on desk phones while they ring (C4)
Spec 11 §6.1: sp-sub-dial-user asks /api/internal/caller-name (func_curl, 2 s at most; this
machine only, with an APP_SECRET-derived key, not logged) for the ringing user's name for the
caller, and sets it on each ringing leg (pre-dial handler, CONNECTEDLINE), never on the call, so
forwards and other members don't see someone else's contacts. Ring group/queue/number prefixes
kept. Contrack lookups never wait (cache, contactsWarm every 5 min). Proven on real Asterisk
with baresip: apps/api/test/asterisk/cnam-test.ts.
2026-10-23 15:45:36 -05:00

440 lines
14 KiB
TypeScript

// SPDX-License-Identifier: AGPL-3.0-or-later
// Everything render() needs, loaded in one go (03 §1). render() itself does no I/O.
import { asc, eq } from 'drizzle-orm';
import { alias } from 'drizzle-orm/pg-core';
import type { Db } from '../db/client.ts';
import {
announcements,
conferences,
dids,
extensions,
holidays,
ivrOptions,
mohClasses,
pagingGroupMembers,
pagingGroups,
ivrs,
outboundRoutes,
queueMembers,
queues,
ringGroupMembers,
ringGroups,
soundFiles,
timeConditions,
trunks,
users,
} from '../db/schema.ts';
import { decrypt } from '../crypto/secrets.ts';
import { getAllSettings } from '../services/settings.ts';
import type { Destination, OpenRange, OutboundClass, RoutePattern } from '@selfphoneng/shared';
export type SnapExtension = {
id: string;
number: string;
name: string;
sipPassword: string; // decrypted
webrtcEnabled: boolean;
maxContacts: number;
ringSeconds: number;
voicemailEnabled: boolean;
voicemailPin: string; // decrypted
outboundClass: 'internal' | 'local' | 'national' | 'international';
outboundCallerId: string | null; // DID number
emergencyCallerId: string | null; // DID number
recordMode: 'never' | 'inbound' | 'outbound' | 'all';
pickupGroup: string | null;
language: string;
mohClass: string;
isAdmin: boolean; // the extension's user is an admin (time-condition override codes)
};
export type SnapDid = {
id: string;
number: string;
trunkId: string;
label: string | null;
destination: Destination;
callerIdNamePrefix: string | null;
recordInbound: boolean;
};
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;
enabled: boolean;
serverHost: string;
serverPort: number;
transport: 'udp' | 'tcp' | 'tls';
authMode: 'registration' | 'ip';
username: string | null;
password: string | null; // decrypted
fromUser: string | null;
fromDomain: string | null;
codecs: string[];
srtp: boolean;
maxChannels: number | null;
};
export type SnapRingGroup = {
id: string;
number: string;
name: string;
strategy: 'ringall' | 'hunt';
ringSeconds: number;
callerIdNamePrefix: string | null;
musicInsteadOfRing: boolean;
/** Extension numbers in ring order. */
members: string[];
noAnswerDestination: Destination;
};
export type SnapQueue = {
id: string;
number: string;
name: string;
strategy: 'ringall' | 'leastrecent' | 'fewestcalls' | 'random' | 'rrmemory' | 'linear';
memberRingSeconds: number;
retrySeconds: number;
wrapupSeconds: number;
maxWaitSeconds: number;
maxCallers: number;
mohClass: string;
announcePosition: boolean;
announceEverySeconds: number;
joinWhenNoAgents: boolean;
record: boolean;
callerIdNamePrefix: string | null;
members: { number: string; name: string; penalty: number }[];
timeoutDestination: Destination;
};
export type SnapConference = {
id: string;
number: string;
name: string;
pin: string | null; // decrypted
adminPin: string | null; // decrypted
maxMembers: number;
record: boolean;
};
export type SnapPagingGroup = {
id: string;
number: string;
name: string;
duplex: boolean;
/** Extension numbers. */
members: string[];
};
export type SnapIvr = {
id: string;
number: string;
name: string;
/** Asterisk sound names, e.g. custom/<id>. */
greeting: string;
invalidSound: string | null;
timeoutSeconds: number;
maxRetries: number;
allowDirectDial: boolean;
options: { digit: string; destination: Destination }[];
timeoutDestination: Destination;
invalidDestination: Destination;
};
export type SnapTimeCondition = {
id: string;
name: string;
timezone: string;
openRanges: OpenRange[];
useHolidays: boolean;
overrideDigit: number | null;
openDestination: Destination;
closedDestination: Destination;
holidayDestination: Destination | null;
};
export type SnapHoliday = {
name: string;
startDate: string; // YYYY-MM-DD
endDate: string;
recurringYearly: boolean;
};
export type SnapAnnouncement = {
id: string;
name: string;
/** Asterisk sound name, e.g. custom/<id>. */
sound: string;
destination: Destination;
};
export type Snapshot = {
/** "YYYY-MM-DD" in general.timezone; render() never reads the clock. */
today: string;
settings: Awaited<ReturnType<typeof getAllSettings>>;
extensions: SnapExtension[];
dids: SnapDid[];
trunks: SnapTrunk[];
routes: SnapRoute[];
ringGroups: SnapRingGroup[];
queues: SnapQueue[];
conferences: SnapConference[];
pagingGroups: SnapPagingGroup[];
ivrs: SnapIvr[];
timeConditions: SnapTimeCondition[];
holidays: SnapHoliday[];
announcements: SnapAnnouncement[];
/**
* Music-on-hold classes that have music ('default' is built in and not here). An empty class
* would play silence; left out, Asterisk falls back to default (seen in M8-T5).
*/
mohClasses: SnapMohClass[];
/** Played before recording a call that a phone number records (Settings → Recording). */
recordingAnnouncement: string | null;
/** Call audio ports from the environment (RTP_PORT_START/END), not the database. */
rtpPorts?: { start: number; end: number };
/** Caller names on ringing phones (11 §6 C4): the api's lookup URL and its key. */
callerNames?: { url: string; token: string };
};
export const DEFAULT_RTP_PORTS = { start: 10000, end: 20000 };
/** A music-on-hold class: its files (sound_files ids, <MOH_DIR>/<name>/<id>.wav) in play order. */
export type SnapMohClass = { name: string; shuffle: boolean; files: string[] };
export function todayIn(timezone: string, now = new Date()): string {
// en-CA formats as YYYY-MM-DD.
return new Intl.DateTimeFormat('en-CA', { timeZone: timezone }).format(now);
}
export async function loadSnapshot(db: Db, now = new Date()): Promise<Snapshot> {
const settings = await getAllSettings(db);
const outDid = alias(dids, 'out_did');
const e911Did = alias(dids, 'e911_did');
const rows = await db
.select({
ext: extensions,
role: users.role,
outCid: outDid.number,
e911Cid: e911Did.number,
})
.from(extensions)
.leftJoin(users, eq(users.id, extensions.userId))
.leftJoin(outDid, eq(outDid.id, extensions.outboundCallerIdDidId))
.leftJoin(e911Did, eq(e911Did.id, extensions.emergencyCallerIdDidId));
const didRows = await db
.select({
id: dids.id,
number: dids.number,
trunkId: dids.trunkId,
label: dids.label,
destination: dids.destination,
callerIdNamePrefix: dids.callerIdNamePrefix,
recordInbound: dids.recordInbound,
})
.from(dids);
const trunkRows = await db.select().from(trunks);
const routeRows = await db.select().from(outboundRoutes);
const groupRows = await db.select().from(ringGroups);
const queueRows = await db.select().from(queues);
const agentRows = await db
.select({
queueId: queueMembers.queueId,
number: extensions.number,
name: extensions.name,
penalty: queueMembers.penalty,
})
.from(queueMembers)
.innerJoin(extensions, eq(extensions.id, queueMembers.extensionId));
const confRows = await db.select().from(conferences);
const pageRows = await db.select().from(pagingGroups);
const pagedRows = await db
.select({ groupId: pagingGroupMembers.pagingGroupId, number: extensions.number })
.from(pagingGroupMembers)
.innerJoin(extensions, eq(extensions.id, pagingGroupMembers.extensionId));
const ivrRows = await db.select().from(ivrs);
const tcRows = await db.select().from(timeConditions);
const holidayRows = await db.select().from(holidays);
const annRows = await db.select().from(announcements);
const mohRows = await db
.select({ name: mohClasses.name, shuffle: mohClasses.shuffle, file: soundFiles.id })
.from(mohClasses)
.innerJoin(soundFiles, eq(soundFiles.mohClassId, mohClasses.id))
.orderBy(asc(mohClasses.name), asc(soundFiles.position), asc(soundFiles.createdAt));
const moh = new Map<string, SnapMohClass>();
for (const r of mohRows) {
const c = moh.get(r.name) ?? { name: r.name, shuffle: r.shuffle, files: [] };
c.files.push(r.file);
moh.set(r.name, c);
}
const optionRows = await db.select().from(ivrOptions);
const soundName = new Map(
(await db.select({ id: soundFiles.id, name: soundFiles.asteriskName }).from(soundFiles)).map(
(s) => [s.id, s.name],
),
);
const memberRows = await db
.select({ groupId: ringGroupMembers.ringGroupId, number: extensions.number })
.from(ringGroupMembers)
.innerJoin(extensions, eq(extensions.id, ringGroupMembers.extensionId))
.orderBy(asc(ringGroupMembers.position));
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 })),
timeConditions: tcRows.map((t) => ({
id: t.id,
name: t.name,
timezone: t.timezone,
openRanges: t.openRanges as OpenRange[],
useHolidays: t.useHolidays,
overrideDigit: t.overrideDigit,
openDestination: t.openDestination as Destination,
closedDestination: t.closedDestination as Destination,
holidayDestination: t.holidayDestination as Destination | null,
})),
recordingAnnouncement: settings['recordings.announcementSoundId']
? (soundName.get(settings['recordings.announcementSoundId']) ?? null)
: null,
announcements: annRows.map((a) => ({
id: a.id,
name: a.name,
sound: soundName.get(a.soundId)!,
destination: a.destination as Destination,
})),
holidays: holidayRows.map((h) => ({
name: h.name,
startDate: h.startDate,
endDate: h.endDate,
recurringYearly: h.recurringYearly,
})),
ivrs: ivrRows.map((r) => ({
id: r.id,
number: r.number,
name: r.name,
greeting: soundName.get(r.greetingSoundId)!,
invalidSound: r.invalidSoundId ? (soundName.get(r.invalidSoundId) ?? null) : null,
timeoutSeconds: r.timeoutSeconds,
maxRetries: r.maxRetries,
allowDirectDial: r.allowDirectDial,
options: optionRows
.filter((o) => o.ivrId === r.id)
.map((o) => ({ digit: o.digit, destination: o.destination as Destination })),
timeoutDestination: r.timeoutDestination as Destination,
invalidDestination: r.invalidDestination as Destination,
})),
ringGroups: groupRows.map((g) => ({
id: g.id,
number: g.number,
name: g.name,
strategy: g.strategy,
ringSeconds: g.ringSeconds,
callerIdNamePrefix: g.callerIdNamePrefix,
musicInsteadOfRing: g.musicInsteadOfRing,
members: memberRows.filter((m) => m.groupId === g.id).map((m) => m.number),
noAnswerDestination: g.noAnswerDestination as Destination,
})),
queues: queueRows.map((q) => ({
id: q.id,
number: q.number,
name: q.name,
strategy: q.strategy,
memberRingSeconds: q.memberRingSeconds,
retrySeconds: q.retrySeconds,
wrapupSeconds: q.wrapupSeconds,
maxWaitSeconds: q.maxWaitSeconds,
maxCallers: q.maxCallers,
mohClass: q.mohClass,
announcePosition: q.announcePosition,
announceEverySeconds: q.announceEverySeconds,
joinWhenNoAgents: q.joinWhenNoAgents,
record: q.record,
callerIdNamePrefix: q.callerIdNamePrefix,
members: agentRows
.filter((m) => m.queueId === q.id)
.map(({ number, name, penalty }) => ({ number, name, penalty })),
timeoutDestination: q.timeoutDestination as Destination,
})),
conferences: confRows.map((c) => ({
id: c.id,
number: c.number,
name: c.name,
pin: c.pinEnc ? decrypt(c.pinEnc) : null,
adminPin: c.adminPinEnc ? decrypt(c.adminPinEnc) : null,
maxMembers: c.maxMembers,
record: c.record,
})),
mohClasses: [...moh.values()],
pagingGroups: pageRows.map((g) => ({
id: g.id,
number: g.number,
name: g.name,
duplex: g.duplex,
members: pagedRows.filter((m) => m.groupId === g.id).map((m) => m.number),
})),
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,
enabled: t.enabled,
serverHost: t.serverHost,
serverPort: t.serverPort,
transport: t.transport,
authMode: t.authMode,
username: t.username,
password: t.passwordEnc ? decrypt(t.passwordEnc) : null,
fromUser: t.fromUser,
fromDomain: t.fromDomain,
codecs: t.codecs,
srtp: t.srtp,
maxChannels: t.maxChannels,
})),
extensions: rows.map(({ ext, role, outCid, e911Cid }) => ({
id: ext.id,
number: ext.number,
name: ext.name,
sipPassword: decrypt(ext.sipPasswordEnc),
webrtcEnabled: ext.webrtcEnabled,
maxContacts: ext.maxContacts,
ringSeconds: ext.ringSeconds,
voicemailEnabled: ext.voicemailEnabled,
voicemailPin: decrypt(ext.voicemailPinEnc),
outboundClass: ext.outboundClass,
outboundCallerId: outCid,
emergencyCallerId: e911Cid,
recordMode: ext.recordMode,
pickupGroup: ext.pickupGroup,
language: ext.language,
mohClass: ext.mohClass,
isAdmin: role === 'admin',
})),
};
}
/** Numeric order for internal numbers ("99" < "100"), then text as a tiebreak. */
export const byNumber = (a: { number: string }, b: { number: string }) =>
Number(a.number) - Number(b.number) || a.number.localeCompare(b.number);