/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
289 lines
8.8 KiB
TypeScript
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);
|