Files
selfphoneng/apps/api/src/generator/load.ts
T
iamdoubz 108547046c timeconditions: office hours, holidays, override codes, weekly grid
/api/time-conditions (company time zone by default, ranges validated,
unique override digit, never pointing at itself) with a live status
("open/closed/holiday now", same logic as the dialplan, shared), and
POST /:id/override, which writes PostgreSQL then AstDB SP/TCOVR.
/api/holidays CRUD plus import-us-federal (observed dates, computed in
code, skips days already listed). services/astdb.ts resyncs AstDB on
every AMI connect (PostgreSQL wins); services/toggles.ts applies
SpToggle TCOVR from *31N/*32N/*33N.

Generator: sp-tc-{id} per 03 §6.10 (override, holidays within the next
year, ranges rendered a minute short because GotoIfTime's end is
inclusive), sp-tc-override with a no-service fallback, admin-only
*3[123]X in sp-featurecodes, destApp time_condition. The date changing
in the company time zone triggers a forced apply so holidays roll over.
Golden cases holidays and ivr-business-hours.

Office hours page: click-and-drag half-hour grid plus an exact range
list, destinations, holiday destination, override key, status chip with
Force open / Force closed / Automatic; Holidays tab with the US federal
import. e2e m6-t4 found two bugs (pointer capture release throwing,
drawer overflow) and checks routing, AstDB, *321/*331 from an admin
phone, refusal for others, and the import reaching the dialplan.

Task: M6-T4
2026-10-18 10:56:28 -05:00

289 lines
8.8 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 {
dids,
extensions,
holidays,
ivrOptions,
ivrs,
outboundRoutes,
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;
};
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 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 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[];
ivrs: SnapIvr[];
timeConditions: SnapTimeCondition[];
holidays: SnapHoliday[];
};
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,
})
.from(dids);
const trunkRows = await db.select().from(trunks);
const routeRows = await db.select().from(outboundRoutes);
const groupRows = await db.select().from(ringGroups);
const ivrRows = await db.select().from(ivrs);
const tcRows = await db.select().from(timeConditions);
const holidayRows = await db.select().from(holidays);
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,
})),
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,
})),
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);