emergency: 911 dialplan, notifications, test button, settings, warnings
Dialplan: sp-emergency-dial (911, 9911) first in sp-from-ext; sp-emergency has no class, channel-limit or DND check, dials every enabled trunk in name order with the E911 caller ID, and raises UserEvent SpEmergency. Static sp-emergency-alert (_X! so "0" matches) for the ring-out. api: SpEmergency -> emergency_events row, live emergency.call (admins and notify users only), email via nodemailer (SMTP from settings) and an Originate to every notify extension, each outcome recorded. Test button runs the same with TEST in front, records and dials nothing. GET/PATCH /api/settings (smtp password write-only, references checked, read-only system keys), SMTP test, /api/system/warnings. Fix: clearing a nullable setting stored SQL NULL (now JSON null). UI: Settings page (Email, Emergency with the 06 §6 notice), user Emergency tab, dashboard warnings banner, red 911 banner. Verified: generator/emergency.test.ts; trunk-test.ts dials 911 from a caller with no class to the fake provider (E911 caller ID in RPID, SpEmergency on AMI, alert plays on baresip 101); scripts/e2e/m3-t4.mjs (SMTP sink receives the test email, 101 rings, banner shows). nodemailer pinned to 10.0.11 (newest release over two weeks old). Task: M3-T4
This commit is contained in:
1 parent
4496f6b89a
commit
409724bc55
45 files changed
+1781
-139
No files matched your search
@@ -18,6 +18,7 @@
|
||||
"@selfphoneng/shared": "workspace:*",
|
||||
"drizzle-orm": "^0.45.4",
|
||||
"fastify": "^5.12.7",
|
||||
"nodemailer": "10.0.11",
|
||||
"pg": "^8.23.1",
|
||||
"zod": "^4.6.7"
|
||||
},
|
||||
|
||||
@@ -9,6 +9,8 @@ import { sql } from 'drizzle-orm';
|
||||
import type { Config } from './config.ts';
|
||||
import type { Db } from './db/client.ts';
|
||||
import type { Generator } from './generator/index.ts';
|
||||
import type { AmiClient } from './ami/client.ts';
|
||||
import { settingsRoutes } from './routes/settings.ts';
|
||||
import { errorHandler, forbidden, notFound, sendError } from './errors.ts';
|
||||
import { authRoutes } from './routes/auth.ts';
|
||||
import { userRoutes } from './routes/users.ts';
|
||||
@@ -25,6 +27,8 @@ declare module 'fastify' {
|
||||
config: Config;
|
||||
/** Decorated by the caller (index.ts, tests) before ready(): it needs app.log. */
|
||||
generator: Generator;
|
||||
/** Decorated by the caller too (tests pass a fake). */
|
||||
ami: Pick<AmiClient, 'action' | 'isConnected'>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,6 +89,7 @@ export function buildApp(config: Config, db: Db, publicDir = resolve('public'))
|
||||
app.register(trunkRoutes);
|
||||
app.register(didRoutes);
|
||||
app.register(outboundRouteRoutes);
|
||||
app.register(settingsRoutes);
|
||||
app.register(liveRoutes);
|
||||
|
||||
if (existsSync(publicDir)) {
|
||||
|
||||
@@ -72,6 +72,19 @@ exten => s,1,Set(PJSIP_HEADER(add,Call-Info)=<sip:${SP_EXT}>\;answer-after=0)
|
||||
same => n,Set(PJSIP_HEADER(add,Alert-Info)=info=alert-autoanswer)
|
||||
same => n,Return()
|
||||
|
||||
; ─── 911 alert ring-out. Originated by the api (04 §5.3): Channel PJSIP/<notify ext>,
|
||||
; Exten = the extension that dialed 911.
|
||||
[sp-emergency-alert]
|
||||
exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
|
||||
same => n,Wait(1)
|
||||
same => n,Playback(beep&beep&beep)
|
||||
same => n,Playback(extension)
|
||||
same => n,SayDigits(${EXTEN})
|
||||
same => n,Playback(beep&beep&beep)
|
||||
same => n,Playback(extension)
|
||||
same => n,SayDigits(${EXTEN})
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-vm-direct]
|
||||
exten => _X.,1,Answer()
|
||||
same => n,VoiceMail(${EXTEN}@default,u)
|
||||
@@ -109,9 +122,17 @@ exten => _[0-9a-z].,1,Hangup()
|
||||
[sp-outbound-routes]
|
||||
exten => pending,1,Hangup() ; no routes yet
|
||||
|
||||
; ─── Filled in by later milestones
|
||||
[sp-emergency-dial] ; M3-T4 (03 §6.14)
|
||||
exten => pending,1,Hangup()
|
||||
[sp-emergency-dial]
|
||||
exten => 911,1,Goto(sp-emergency,s,1)
|
||||
exten => 9911,1,Goto(sp-emergency,s,1) ; people who dial 9 first out of habit
|
||||
|
||||
[sp-emergency]
|
||||
exten => s,1,Set(CID=${IF($["${SP_E911CID}" != ""]?${SP_E911CID}:${SP_DEFAULT_E911_CID})})
|
||||
same => n,UserEvent(SpEmergency,Ext: ${SP_EXT},CallerId: ${CID},Dialed: 911)
|
||||
same => n,ExecIf($["${CID}" != ""]?Set(CALLERID(num)=${CID}))
|
||||
same => n,Set(__SP_DIRECTION=outbound)
|
||||
same => n,Hangup()
|
||||
|
||||
; ─── Filled in by later milestones
|
||||
[sp-featurecodes] ; M6-T7 (03 §6.13)
|
||||
exten => pending,1,Hangup()
|
||||
+24
-3
@@ -72,6 +72,19 @@ exten => s,1,Set(PJSIP_HEADER(add,Call-Info)=<sip:${SP_EXT}>\;answer-after=0)
|
||||
same => n,Set(PJSIP_HEADER(add,Alert-Info)=info=alert-autoanswer)
|
||||
same => n,Return()
|
||||
|
||||
; ─── 911 alert ring-out. Originated by the api (04 §5.3): Channel PJSIP/<notify ext>,
|
||||
; Exten = the extension that dialed 911.
|
||||
[sp-emergency-alert]
|
||||
exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
|
||||
same => n,Wait(1)
|
||||
same => n,Playback(beep&beep&beep)
|
||||
same => n,Playback(extension)
|
||||
same => n,SayDigits(${EXTEN})
|
||||
same => n,Playback(beep&beep&beep)
|
||||
same => n,Playback(extension)
|
||||
same => n,SayDigits(${EXTEN})
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-vm-direct]
|
||||
exten => _X.,1,Answer()
|
||||
same => n,VoiceMail(${EXTEN}@default,u)
|
||||
@@ -118,9 +131,17 @@ exten => _[0-9a-z].,1,Hangup()
|
||||
[sp-outbound-routes]
|
||||
exten => pending,1,Hangup() ; no routes yet
|
||||
|
||||
; ─── Filled in by later milestones
|
||||
[sp-emergency-dial] ; M3-T4 (03 §6.14)
|
||||
exten => pending,1,Hangup()
|
||||
[sp-emergency-dial]
|
||||
exten => 911,1,Goto(sp-emergency,s,1)
|
||||
exten => 9911,1,Goto(sp-emergency,s,1) ; people who dial 9 first out of habit
|
||||
|
||||
[sp-emergency]
|
||||
exten => s,1,Set(CID=${IF($["${SP_E911CID}" != ""]?${SP_E911CID}:${SP_DEFAULT_E911_CID})})
|
||||
same => n,UserEvent(SpEmergency,Ext: ${SP_EXT},CallerId: ${CID},Dialed: 911)
|
||||
same => n,ExecIf($["${CID}" != ""]?Set(CALLERID(num)=${CID}))
|
||||
same => n,Set(__SP_DIRECTION=outbound)
|
||||
same => n,Hangup()
|
||||
|
||||
; ─── Filled in by later milestones
|
||||
[sp-featurecodes] ; M6-T7 (03 §6.13)
|
||||
exten => pending,1,Hangup()
|
||||
@@ -72,6 +72,19 @@ exten => s,1,Set(PJSIP_HEADER(add,Call-Info)=<sip:${SP_EXT}>\;answer-after=0)
|
||||
same => n,Set(PJSIP_HEADER(add,Alert-Info)=info=alert-autoanswer)
|
||||
same => n,Return()
|
||||
|
||||
; ─── 911 alert ring-out. Originated by the api (04 §5.3): Channel PJSIP/<notify ext>,
|
||||
; Exten = the extension that dialed 911.
|
||||
[sp-emergency-alert]
|
||||
exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
|
||||
same => n,Wait(1)
|
||||
same => n,Playback(beep&beep&beep)
|
||||
same => n,Playback(extension)
|
||||
same => n,SayDigits(${EXTEN})
|
||||
same => n,Playback(beep&beep&beep)
|
||||
same => n,Playback(extension)
|
||||
same => n,SayDigits(${EXTEN})
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-vm-direct]
|
||||
exten => _X.,1,Answer()
|
||||
same => n,VoiceMail(${EXTEN}@default,u)
|
||||
@@ -118,9 +131,17 @@ exten => _[0-9a-z].,1,Hangup()
|
||||
[sp-outbound-routes]
|
||||
exten => pending,1,Hangup() ; no routes yet
|
||||
|
||||
; ─── Filled in by later milestones
|
||||
[sp-emergency-dial] ; M3-T4 (03 §6.14)
|
||||
exten => pending,1,Hangup()
|
||||
[sp-emergency-dial]
|
||||
exten => 911,1,Goto(sp-emergency,s,1)
|
||||
exten => 9911,1,Goto(sp-emergency,s,1) ; people who dial 9 first out of habit
|
||||
|
||||
[sp-emergency]
|
||||
exten => s,1,Set(CID=${IF($["${SP_E911CID}" != ""]?${SP_E911CID}:${SP_DEFAULT_E911_CID})})
|
||||
same => n,UserEvent(SpEmergency,Ext: ${SP_EXT},CallerId: ${CID},Dialed: 911)
|
||||
same => n,ExecIf($["${CID}" != ""]?Set(CALLERID(num)=${CID}))
|
||||
same => n,Set(__SP_DIRECTION=outbound)
|
||||
same => n,Hangup()
|
||||
|
||||
; ─── Filled in by later milestones
|
||||
[sp-featurecodes] ; M6-T7 (03 §6.13)
|
||||
exten => pending,1,Hangup()
|
||||
+26
-3
@@ -72,6 +72,19 @@ exten => s,1,Set(PJSIP_HEADER(add,Call-Info)=<sip:${SP_EXT}>\;answer-after=0)
|
||||
same => n,Set(PJSIP_HEADER(add,Alert-Info)=info=alert-autoanswer)
|
||||
same => n,Return()
|
||||
|
||||
; ─── 911 alert ring-out. Originated by the api (04 §5.3): Channel PJSIP/<notify ext>,
|
||||
; Exten = the extension that dialed 911.
|
||||
[sp-emergency-alert]
|
||||
exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
|
||||
same => n,Wait(1)
|
||||
same => n,Playback(beep&beep&beep)
|
||||
same => n,Playback(extension)
|
||||
same => n,SayDigits(${EXTEN})
|
||||
same => n,Playback(beep&beep&beep)
|
||||
same => n,Playback(extension)
|
||||
same => n,SayDigits(${EXTEN})
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-vm-direct]
|
||||
exten => _X.,1,Answer()
|
||||
same => n,VoiceMail(${EXTEN}@default,u)
|
||||
@@ -187,9 +200,19 @@ exten => _X.,1,Set(__SP_DIRECTION=outbound)
|
||||
same => n(denied),Playback(ss-noservice)
|
||||
same => n,Hangup()
|
||||
|
||||
; ─── Filled in by later milestones
|
||||
[sp-emergency-dial] ; M3-T4 (03 §6.14)
|
||||
exten => pending,1,Hangup()
|
||||
[sp-emergency-dial]
|
||||
exten => 911,1,Goto(sp-emergency,s,1)
|
||||
exten => 9911,1,Goto(sp-emergency,s,1) ; people who dial 9 first out of habit
|
||||
|
||||
[sp-emergency]
|
||||
exten => s,1,Set(CID=${IF($["${SP_E911CID}" != ""]?${SP_E911CID}:${SP_DEFAULT_E911_CID})})
|
||||
same => n,UserEvent(SpEmergency,Ext: ${SP_EXT},CallerId: ${CID},Dialed: 911)
|
||||
same => n,ExecIf($["${CID}" != ""]?Set(CALLERID(num)=${CID}))
|
||||
same => n,Set(__SP_DIRECTION=outbound)
|
||||
same => n,Dial(PJSIP/911@trunk-a1b2c3d4-0000-4000-8000-000000000001)
|
||||
same => n,Dial(PJSIP/911@trunk-a1b2c3d4-0000-4000-8000-000000000002)
|
||||
same => n,Hangup()
|
||||
|
||||
; ─── Filled in by later milestones
|
||||
[sp-featurecodes] ; M6-T7 (03 §6.13)
|
||||
exten => pending,1,Hangup()
|
||||
+25
-3
@@ -72,6 +72,19 @@ exten => s,1,Set(PJSIP_HEADER(add,Call-Info)=<sip:${SP_EXT}>\;answer-after=0)
|
||||
same => n,Set(PJSIP_HEADER(add,Alert-Info)=info=alert-autoanswer)
|
||||
same => n,Return()
|
||||
|
||||
; ─── 911 alert ring-out. Originated by the api (04 §5.3): Channel PJSIP/<notify ext>,
|
||||
; Exten = the extension that dialed 911.
|
||||
[sp-emergency-alert]
|
||||
exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
|
||||
same => n,Wait(1)
|
||||
same => n,Playback(beep&beep&beep)
|
||||
same => n,Playback(extension)
|
||||
same => n,SayDigits(${EXTEN})
|
||||
same => n,Playback(beep&beep&beep)
|
||||
same => n,Playback(extension)
|
||||
same => n,SayDigits(${EXTEN})
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-vm-direct]
|
||||
exten => _X.,1,Answer()
|
||||
same => n,VoiceMail(${EXTEN}@default,u)
|
||||
@@ -192,9 +205,18 @@ exten => _X.,1,Set(__SP_DIRECTION=outbound)
|
||||
same => n(denied),Playback(ss-noservice)
|
||||
same => n,Hangup()
|
||||
|
||||
; ─── Filled in by later milestones
|
||||
[sp-emergency-dial] ; M3-T4 (03 §6.14)
|
||||
exten => pending,1,Hangup()
|
||||
[sp-emergency-dial]
|
||||
exten => 911,1,Goto(sp-emergency,s,1)
|
||||
exten => 9911,1,Goto(sp-emergency,s,1) ; people who dial 9 first out of habit
|
||||
|
||||
[sp-emergency]
|
||||
exten => s,1,Set(CID=${IF($["${SP_E911CID}" != ""]?${SP_E911CID}:${SP_DEFAULT_E911_CID})})
|
||||
same => n,UserEvent(SpEmergency,Ext: ${SP_EXT},CallerId: ${CID},Dialed: 911)
|
||||
same => n,ExecIf($["${CID}" != ""]?Set(CALLERID(num)=${CID}))
|
||||
same => n,Set(__SP_DIRECTION=outbound)
|
||||
same => n,Dial(PJSIP/911@trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37)
|
||||
same => n,Hangup()
|
||||
|
||||
; ─── Filled in by later milestones
|
||||
[sp-featurecodes] ; M6-T7 (03 §6.13)
|
||||
exten => pending,1,Hangup()
|
||||
+24
-3
@@ -72,6 +72,19 @@ exten => s,1,Set(PJSIP_HEADER(add,Call-Info)=<sip:${SP_EXT}>\;answer-after=0)
|
||||
same => n,Set(PJSIP_HEADER(add,Alert-Info)=info=alert-autoanswer)
|
||||
same => n,Return()
|
||||
|
||||
; ─── 911 alert ring-out. Originated by the api (04 §5.3): Channel PJSIP/<notify ext>,
|
||||
; Exten = the extension that dialed 911.
|
||||
[sp-emergency-alert]
|
||||
exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
|
||||
same => n,Wait(1)
|
||||
same => n,Playback(beep&beep&beep)
|
||||
same => n,Playback(extension)
|
||||
same => n,SayDigits(${EXTEN})
|
||||
same => n,Playback(beep&beep&beep)
|
||||
same => n,Playback(extension)
|
||||
same => n,SayDigits(${EXTEN})
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-vm-direct]
|
||||
exten => _X.,1,Answer()
|
||||
same => n,VoiceMail(${EXTEN}@default,u)
|
||||
@@ -115,9 +128,17 @@ exten => _[0-9a-z].,1,Hangup()
|
||||
[sp-outbound-routes]
|
||||
exten => pending,1,Hangup() ; no routes yet
|
||||
|
||||
; ─── Filled in by later milestones
|
||||
[sp-emergency-dial] ; M3-T4 (03 §6.14)
|
||||
exten => pending,1,Hangup()
|
||||
[sp-emergency-dial]
|
||||
exten => 911,1,Goto(sp-emergency,s,1)
|
||||
exten => 9911,1,Goto(sp-emergency,s,1) ; people who dial 9 first out of habit
|
||||
|
||||
[sp-emergency]
|
||||
exten => s,1,Set(CID=${IF($["${SP_E911CID}" != ""]?${SP_E911CID}:${SP_DEFAULT_E911_CID})})
|
||||
same => n,UserEvent(SpEmergency,Ext: ${SP_EXT},CallerId: ${CID},Dialed: 911)
|
||||
same => n,ExecIf($["${CID}" != ""]?Set(CALLERID(num)=${CID}))
|
||||
same => n,Set(__SP_DIRECTION=outbound)
|
||||
same => n,Hangup()
|
||||
|
||||
; ─── Filled in by later milestones
|
||||
[sp-featurecodes] ; M6-T7 (03 §6.13)
|
||||
exten => pending,1,Hangup()
|
||||
@@ -0,0 +1,26 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// 911 (03 §6.14). Never blocked for any reason: no class check, no channel limits, no DND, and
|
||||
// every enabled trunk is tried. The UserEvent makes the api notify people (04 §5.3).
|
||||
import type { Snapshot } from '../load.ts';
|
||||
import * as s from '../sanitize.ts';
|
||||
|
||||
export function renderEmergency(snap: Snapshot): string {
|
||||
const trunks = snap.trunks
|
||||
.filter((t) => t.enabled)
|
||||
.sort((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id));
|
||||
return (
|
||||
[
|
||||
'[sp-emergency-dial]',
|
||||
'exten => 911,1,Goto(sp-emergency,s,1)',
|
||||
'exten => 9911,1,Goto(sp-emergency,s,1) ; people who dial 9 first out of habit',
|
||||
'',
|
||||
'[sp-emergency]',
|
||||
'exten => s,1,Set(CID=${IF($["${SP_E911CID}" != ""]?${SP_E911CID}:${SP_DEFAULT_E911_CID})})',
|
||||
' same => n,UserEvent(SpEmergency,Ext: ${SP_EXT},CallerId: ${CID},Dialed: 911)',
|
||||
' same => n,ExecIf($["${CID}" != ""]?Set(CALLERID(num)=${CID}))',
|
||||
' same => n,Set(__SP_DIRECTION=outbound)',
|
||||
...trunks.map((t) => ` same => n,Dial(PJSIP/911@trunk-${s.ident(t.id)})`),
|
||||
' same => n,Hangup()',
|
||||
].join('\n') + '\n'
|
||||
);
|
||||
}
|
||||
@@ -3,9 +3,6 @@
|
||||
// extension, because Asterisk doesn't create an empty context and then warns "tries to include
|
||||
// nonexistent context". Each one moves to its feature's renderer.
|
||||
export const PENDING_CONTEXTS = `; ─── Filled in by later milestones
|
||||
[sp-emergency-dial] ; M3-T4 (03 §6.14)
|
||||
exten => pending,1,Hangup()
|
||||
|
||||
[sp-featurecodes] ; M6-T7 (03 §6.13)
|
||||
exten => pending,1,Hangup()
|
||||
`;
|
||||
@@ -75,6 +75,19 @@ exten => s,1,Set(PJSIP_HEADER(add,Call-Info)=<sip:\${SP_EXT}>\\;answer-after=0)
|
||||
same => n,Set(PJSIP_HEADER(add,Alert-Info)=info=alert-autoanswer)
|
||||
same => n,Return()
|
||||
|
||||
; ─── 911 alert ring-out. Originated by the api (04 §5.3): Channel PJSIP/<notify ext>,
|
||||
; Exten = the extension that dialed 911.
|
||||
[sp-emergency-alert]
|
||||
exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
|
||||
same => n,Wait(1)
|
||||
same => n,Playback(beep&beep&beep)
|
||||
same => n,Playback(extension)
|
||||
same => n,SayDigits(\${EXTEN})
|
||||
same => n,Playback(beep&beep&beep)
|
||||
same => n,Playback(extension)
|
||||
same => n,SayDigits(\${EXTEN})
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-vm-direct]
|
||||
exten => _X.,1,Answer()
|
||||
same => n,VoiceMail(\${EXTEN}@default,u)
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// M3-T4 (06 §6.1): 911 can't be blocked. An extension with class internal, DND on and every trunk
|
||||
// at its channel limit still reaches the 911 Dial lines.
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { loadCase } from './golden-case.ts';
|
||||
import { render } from './index.ts';
|
||||
|
||||
const context = (conf: string, name: string) => {
|
||||
const start = conf.indexOf(`[${name}]\n`);
|
||||
const end = conf.indexOf('\n[', start + 1);
|
||||
return conf.slice(start, end === -1 ? undefined : end);
|
||||
};
|
||||
|
||||
describe('emergency dialplan', () => {
|
||||
const snap = loadCase('voipms-basic');
|
||||
snap.extensions[0]!.outboundClass = 'internal';
|
||||
snap.trunks.push({
|
||||
...snap.trunks[0]!,
|
||||
id: 'b0b0b0b0-0000-4000-8000-000000000000',
|
||||
name: 'b',
|
||||
maxChannels: 1,
|
||||
});
|
||||
snap.trunks.push({
|
||||
...snap.trunks[0]!,
|
||||
id: 'c0c0c0c0-0000-4000-8000-000000000000',
|
||||
name: 'c',
|
||||
enabled: false,
|
||||
});
|
||||
const conf = render(snap)['extensions_selfphoneng.conf'];
|
||||
|
||||
it('911 and 9911 are matched before anything else a phone can dial', () => {
|
||||
expect(context(conf, 'sp-from-ext').split('\n')[1]).toBe('include => sp-emergency-dial');
|
||||
expect(context(conf, 'sp-emergency-dial')).toContain('exten => 911,1,Goto(sp-emergency,s,1)');
|
||||
expect(context(conf, 'sp-emergency-dial')).toContain('exten => 9911,1,Goto(sp-emergency,s,1)');
|
||||
});
|
||||
|
||||
it('has no class, DND or channel-limit check, and dials every enabled trunk in name order', () => {
|
||||
const e = context(conf, 'sp-emergency');
|
||||
expect(e).not.toMatch(/SP_CLASS|DND|GROUP|denied|ss-noservice/);
|
||||
expect([...e.matchAll(/Dial\(PJSIP\/911@trunk-([0-9a-f-]+)\)/g)].map((m) => m[1])).toEqual([
|
||||
'b0b0b0b0-0000-4000-8000-000000000000', // "b"
|
||||
'7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37', // "voip.ms"; the disabled "c" is left out
|
||||
]);
|
||||
expect(e).toContain('UserEvent(SpEmergency,Ext: ${SP_EXT},CallerId: ${CID},Dialed: 911)');
|
||||
});
|
||||
|
||||
it('sends the E911 caller ID: the extension one, else the system default', () => {
|
||||
expect(context(conf, 'sp-emergency')).toContain(
|
||||
'Set(CID=${IF($["${SP_E911CID}" != ""]?${SP_E911CID}:${SP_DEFAULT_E911_CID})})',
|
||||
);
|
||||
expect(render(snap)['extensions_globals_selfphoneng.conf']).toContain(
|
||||
'SP_DEFAULT_E911_CID=13125551234',
|
||||
);
|
||||
});
|
||||
|
||||
it('still notifies when there is no trunk at all', () => {
|
||||
const none = { ...snap, trunks: [], routes: [] };
|
||||
const e = context(render(none)['extensions_selfphoneng.conf'], 'sp-emergency');
|
||||
expect(e).toContain('UserEvent(SpEmergency');
|
||||
expect(e).not.toContain('Dial(');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Golden case loader (03 §12.1), shared by the golden tests and other generator tests.
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { SettingsSchemas, type SettingKey } from '@selfphoneng/shared';
|
||||
import type { Snapshot } from './load.ts';
|
||||
|
||||
export const GOLDEN_ROOT = fileURLToPath(new URL('./__golden__', import.meta.url));
|
||||
|
||||
/** snapshot.json holds only what differs from the defaults, to keep cases readable. */
|
||||
export function loadCase(name: string): Snapshot {
|
||||
const raw = JSON.parse(readFileSync(join(GOLDEN_ROOT, name, 'snapshot.json'), 'utf8')) as {
|
||||
today?: string;
|
||||
settings?: Record<string, unknown>;
|
||||
extensions?: Partial<Snapshot['extensions'][number]>[];
|
||||
dids?: Partial<Snapshot['dids'][number]>[];
|
||||
trunks?: Partial<Snapshot['trunks'][number]>[];
|
||||
routes?: Partial<Snapshot['routes'][number]>[];
|
||||
};
|
||||
const settings = Object.fromEntries(
|
||||
(Object.keys(SettingsSchemas) as SettingKey[])
|
||||
.filter((k) => k !== 'sms.webhookToken')
|
||||
.map((k) => [k, SettingsSchemas[k].parse(raw.settings?.[k])]),
|
||||
) as Snapshot['settings'];
|
||||
return {
|
||||
today: raw.today ?? '2026-10-17',
|
||||
settings,
|
||||
dids: (raw.dids ?? []).map((d) => ({
|
||||
trunkId: '7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37',
|
||||
label: null,
|
||||
destination: { type: 'hangup' as const },
|
||||
callerIdNamePrefix: null,
|
||||
...d,
|
||||
id: d.id!,
|
||||
number: d.number!,
|
||||
})),
|
||||
routes: (raw.routes ?? []).map((r, i) => ({
|
||||
name: `Route ${i + 1}`,
|
||||
position: i,
|
||||
enabled: true,
|
||||
patterns: [],
|
||||
requiredClass: 'national',
|
||||
trunkIds: [],
|
||||
callerId: null,
|
||||
...r,
|
||||
id: r.id!,
|
||||
})),
|
||||
trunks: (raw.trunks ?? []).map((t) => ({
|
||||
name: 'voip.ms',
|
||||
enabled: true,
|
||||
serverHost: 'chicago1.voip.ms',
|
||||
serverPort: 5060,
|
||||
transport: 'udp',
|
||||
authMode: 'registration',
|
||||
username: '123456_pbx',
|
||||
password: 'TrunkPassw0rd',
|
||||
fromUser: null,
|
||||
fromDomain: null,
|
||||
codecs: ['ulaw'],
|
||||
srtp: false,
|
||||
maxChannels: null,
|
||||
...t,
|
||||
id: t.id!,
|
||||
})),
|
||||
extensions: (raw.extensions ?? []).map((e, i) => ({
|
||||
id: `00000000-0000-4000-8000-${String(i).padStart(12, '0')}`,
|
||||
number: '101',
|
||||
name: 'User',
|
||||
sipPassword: 'TestPassword0000000000AB',
|
||||
webrtcEnabled: true,
|
||||
maxContacts: 5,
|
||||
ringSeconds: 25,
|
||||
voicemailEnabled: true,
|
||||
outboundClass: 'national',
|
||||
outboundCallerId: null,
|
||||
emergencyCallerId: null,
|
||||
recordMode: 'never',
|
||||
pickupGroup: null,
|
||||
language: 'en',
|
||||
mohClass: 'default',
|
||||
isAdmin: false,
|
||||
...e,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -3,92 +3,12 @@
|
||||
// `UPDATE_GOLDEN=1 pnpm test` rewrites the expected files; a human reads the diff before committing.
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { SettingsSchemas, type SettingKey } from '@selfphoneng/shared';
|
||||
import { GOLDEN_ROOT as ROOT, loadCase } from './golden-case.ts';
|
||||
import { GENERATED_FILES, render } from './index.ts';
|
||||
import type { Snapshot } from './load.ts';
|
||||
|
||||
const ROOT = fileURLToPath(new URL('./__golden__', import.meta.url));
|
||||
const UPDATE = process.env.UPDATE_GOLDEN === '1';
|
||||
|
||||
/** snapshot.json holds only what differs from the defaults, to keep cases readable. */
|
||||
export function loadCase(name: string): Snapshot {
|
||||
const raw = JSON.parse(readFileSync(join(ROOT, name, 'snapshot.json'), 'utf8')) as {
|
||||
today?: string;
|
||||
settings?: Record<string, unknown>;
|
||||
extensions?: Partial<Snapshot['extensions'][number]>[];
|
||||
dids?: Partial<Snapshot['dids'][number]>[];
|
||||
trunks?: Partial<Snapshot['trunks'][number]>[];
|
||||
routes?: Partial<Snapshot['routes'][number]>[];
|
||||
};
|
||||
const settings = Object.fromEntries(
|
||||
(Object.keys(SettingsSchemas) as SettingKey[])
|
||||
.filter((k) => k !== 'sms.webhookToken')
|
||||
.map((k) => [k, SettingsSchemas[k].parse(raw.settings?.[k])]),
|
||||
) as Snapshot['settings'];
|
||||
return {
|
||||
today: raw.today ?? '2026-10-17',
|
||||
settings,
|
||||
dids: (raw.dids ?? []).map((d) => ({
|
||||
trunkId: '7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37',
|
||||
label: null,
|
||||
destination: { type: 'hangup' as const },
|
||||
callerIdNamePrefix: null,
|
||||
...d,
|
||||
id: d.id!,
|
||||
number: d.number!,
|
||||
})),
|
||||
routes: (raw.routes ?? []).map((r, i) => ({
|
||||
name: `Route ${i + 1}`,
|
||||
position: i,
|
||||
enabled: true,
|
||||
patterns: [],
|
||||
requiredClass: 'national',
|
||||
trunkIds: [],
|
||||
callerId: null,
|
||||
...r,
|
||||
id: r.id!,
|
||||
})),
|
||||
trunks: (raw.trunks ?? []).map((t) => ({
|
||||
name: 'voip.ms',
|
||||
enabled: true,
|
||||
serverHost: 'chicago1.voip.ms',
|
||||
serverPort: 5060,
|
||||
transport: 'udp',
|
||||
authMode: 'registration',
|
||||
username: '123456_pbx',
|
||||
password: 'TrunkPassw0rd',
|
||||
fromUser: null,
|
||||
fromDomain: null,
|
||||
codecs: ['ulaw'],
|
||||
srtp: false,
|
||||
maxChannels: null,
|
||||
...t,
|
||||
id: t.id!,
|
||||
})),
|
||||
extensions: (raw.extensions ?? []).map((e, i) => ({
|
||||
id: `00000000-0000-4000-8000-${String(i).padStart(12, '0')}`,
|
||||
number: '101',
|
||||
name: 'User',
|
||||
sipPassword: 'TestPassword0000000000AB',
|
||||
webrtcEnabled: true,
|
||||
maxContacts: 5,
|
||||
ringSeconds: 25,
|
||||
voicemailEnabled: true,
|
||||
outboundClass: 'national',
|
||||
outboundCallerId: null,
|
||||
emergencyCallerId: null,
|
||||
recordMode: 'never',
|
||||
pickupGroup: null,
|
||||
language: 'en',
|
||||
mohClass: 'default',
|
||||
isAdmin: false,
|
||||
...e,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const cases = readdirSync(ROOT).filter((d) => existsSync(join(ROOT, d, 'snapshot.json')));
|
||||
|
||||
describe('golden', () => {
|
||||
|
||||
@@ -15,6 +15,7 @@ import { renderGlobals, renderLocalNumbers } from './dialplan/local.ts';
|
||||
import { PENDING_CONTEXTS } from './dialplan/pending.ts';
|
||||
import { renderDids, renderTrunkEntries } from './dialplan/inbound.ts';
|
||||
import { renderOutbound } from './dialplan/outbound.ts';
|
||||
import { renderEmergency } from './dialplan/emergency.ts';
|
||||
|
||||
export { renderTransports };
|
||||
|
||||
@@ -48,6 +49,7 @@ const BODIES: Partial<Record<GeneratedFileName, (snap: Snapshot) => string>> = {
|
||||
renderTrunkEntries(snap),
|
||||
renderDids(snap),
|
||||
renderOutbound(snap),
|
||||
renderEmergency(snap),
|
||||
PENDING_CONTEXTS,
|
||||
]
|
||||
.filter(Boolean)
|
||||
|
||||
@@ -9,6 +9,8 @@ import { createDb, runMigrations } from './db/client.ts';
|
||||
import { createGenerator } from './generator/index.ts';
|
||||
import { ensureInitialAdmin } from './services/bootstrap.ts';
|
||||
import { LiveState } from './live/state.ts';
|
||||
import { smtpMailer } from './services/mail.ts';
|
||||
import { watchEmergencies } from './services/emergency.ts';
|
||||
|
||||
let config;
|
||||
try {
|
||||
@@ -44,6 +46,10 @@ const generator = createGenerator({
|
||||
},
|
||||
});
|
||||
app.decorate('generator', generator);
|
||||
app.decorate('ami', ami);
|
||||
const sendMail = smtpMailer(db);
|
||||
app.decorate('sendMail', sendMail);
|
||||
watchEmergencies({ db, ami, live, send: sendMail, log: app.log }, ami);
|
||||
app.decorate('live', live);
|
||||
|
||||
await runMigrations(db);
|
||||
|
||||
@@ -12,6 +12,7 @@ const PING_MS = 30_000;
|
||||
type Client = {
|
||||
ws: WebSocket;
|
||||
admin: boolean;
|
||||
userId: string;
|
||||
/** `${type}:${key}` → latest message, so many updates of one thing collapse into one. */
|
||||
pending: Map<string, LiveMessage>;
|
||||
alive: boolean;
|
||||
@@ -31,11 +32,18 @@ export async function liveRoutes(app: FastifyInstance) {
|
||||
? `extension:${m.data.number}` // a removal replaces a pending update and vice versa
|
||||
: m.type === 'trunk.updated' || m.type === 'trunk.removed'
|
||||
? `trunk:${m.data.trunkId}`
|
||||
: m.type;
|
||||
: m.type === 'emergency.call'
|
||||
? `emergency:${m.data.at}:${m.data.ext}` // never merge two 911 calls
|
||||
: m.type;
|
||||
|
||||
const maySee = (c: Client, m: LiveMessage) =>
|
||||
c.admin ||
|
||||
(m.type === 'emergency.call'
|
||||
? m.data.audienceUserIds.includes(c.userId)
|
||||
: !m.type.startsWith('trunk.'));
|
||||
|
||||
app.live.on('message', (m) => {
|
||||
const adminOnly = m.type.startsWith('trunk.');
|
||||
for (const c of clients) if (c.admin || !adminOnly) c.pending.set(keyOf(m), m);
|
||||
for (const c of clients) if (maySee(c, m)) c.pending.set(keyOf(m), m);
|
||||
});
|
||||
|
||||
const viewFor = (c: Client, m: LiveMessage): LiveMessage =>
|
||||
@@ -76,7 +84,13 @@ export async function liveRoutes(app: FastifyInstance) {
|
||||
const found = token ? await getSessionUser(app.db, token) : null;
|
||||
// Browsers can't read an HTTP 401 on an upgrade, so open and close with 4401 (04 §5.2).
|
||||
if (!found || found.user.mustChangePassword) return ws.close(4401, 'unauthorized');
|
||||
const c: Client = { ws, admin: found.user.role === 'admin', pending: new Map(), alive: true };
|
||||
const c: Client = {
|
||||
ws,
|
||||
admin: found.user.role === 'admin',
|
||||
userId: found.user.id,
|
||||
pending: new Map(),
|
||||
alive: true,
|
||||
};
|
||||
clients.add(c);
|
||||
ws.on('pong', () => (c.alive = true));
|
||||
ws.on('close', () => clients.delete(c));
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// /api/settings (04 §7.3) and the dashboard warnings. Admin only.
|
||||
import { eq, inArray } from 'drizzle-orm';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
SettingsSchemas,
|
||||
SmtpInputSchema,
|
||||
type Destination,
|
||||
type SettingKey,
|
||||
type SettingsDto,
|
||||
type Warning,
|
||||
} from '@selfphoneng/shared';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { dids, extensions, trunks } from '../db/schema.ts';
|
||||
import { badRequest } from '../errors.ts';
|
||||
import { requireAdmin } from '../auth/guards.ts';
|
||||
import { encrypt } from '../crypto/secrets.ts';
|
||||
import { audit } from '../services/audit.ts';
|
||||
import { assertDestinationExists } from '../services/destinations.ts';
|
||||
import { getAllSettings, getSetting, setSetting } from '../services/settings.ts';
|
||||
import { notifyEmergency } from '../services/emergency.ts';
|
||||
import type { SendMail } from '../services/mail.ts';
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
/** Decorated by the caller (index.ts: SMTP; tests: a fake). */
|
||||
sendMail: SendMail;
|
||||
}
|
||||
}
|
||||
|
||||
/** Set by the system itself, never through PATCH. */
|
||||
const READ_ONLY = new Set<string>([
|
||||
'system.restartRequired',
|
||||
'system.transportHash',
|
||||
'sms.webhookToken',
|
||||
'setup.completed',
|
||||
]);
|
||||
|
||||
async function toDto(db: Db): Promise<SettingsDto> {
|
||||
const { smtp, ...rest } = await getAllSettings(db);
|
||||
return {
|
||||
...rest,
|
||||
smtp: smtp && {
|
||||
host: smtp.host,
|
||||
port: smtp.port,
|
||||
secure: smtp.secure,
|
||||
username: smtp.username,
|
||||
from: smtp.from,
|
||||
hasPassword: smtp.passwordEnc !== '',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** References inside settings must point at rows that exist; transports in use stay on. */
|
||||
async function checkRefs(db: Db, v: Record<string, unknown>) {
|
||||
for (const key of ['routing.defaultCallerIdDidId', 'emergency.defaultCallerIdDidId'] as const) {
|
||||
const id = v[key] as string | null | undefined;
|
||||
if (!id) continue;
|
||||
const [d] = await db.select({ id: dids.id }).from(dids).where(eq(dids.id, id));
|
||||
if (!d) throw badRequest(`${key}: that phone number does not exist`);
|
||||
}
|
||||
const ids = v['emergency.notifyExtensionIds'] as string[] | undefined;
|
||||
if (ids?.length) {
|
||||
const found = await db
|
||||
.select({ id: extensions.id })
|
||||
.from(extensions)
|
||||
.where(inArray(extensions.id, ids));
|
||||
if (found.length !== new Set(ids).size)
|
||||
throw badRequest('emergency.notifyExtensionIds: one of those extensions does not exist');
|
||||
}
|
||||
if (v['routing.unmatchedDidDestination'])
|
||||
await assertDestinationExists(db, v['routing.unmatchedDidDestination'] as Destination);
|
||||
for (const [key, transport] of [
|
||||
['sip.tcpEnabled', 'tcp'],
|
||||
['sip.tlsEnabled', 'tls'],
|
||||
] as const)
|
||||
if (v[key] === false) {
|
||||
const [t] = await db
|
||||
.select({ name: trunks.name })
|
||||
.from(trunks)
|
||||
.where(eq(trunks.transport, transport));
|
||||
if (t) throw badRequest(`Trunk ${t.name} uses ${transport.toUpperCase()}: change it first`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function settingsRoutes(app: FastifyInstance) {
|
||||
const { db } = app;
|
||||
app.addHook('preHandler', requireAdmin);
|
||||
|
||||
app.get('/api/settings', () => toDto(db));
|
||||
|
||||
app.patch('/api/settings', async (req) => {
|
||||
const body = z.record(z.string(), z.unknown()).parse(req.body);
|
||||
const values: Record<string, unknown> = {};
|
||||
for (const [key, raw] of Object.entries(body)) {
|
||||
if (!(key in SettingsSchemas) || READ_ONLY.has(key))
|
||||
throw badRequest(`${key} can't be changed here`);
|
||||
if (key === 'smtp') {
|
||||
// The password is write-only: empty keeps the stored one.
|
||||
const input = SmtpInputSchema.nullable().parse(raw);
|
||||
const old = await getSetting(db, 'smtp');
|
||||
values.smtp = input && {
|
||||
host: input.host,
|
||||
port: input.port,
|
||||
secure: input.secure,
|
||||
username: input.username,
|
||||
from: input.from,
|
||||
passwordEnc: input.password ? encrypt(input.password) : (old?.passwordEnc ?? ''),
|
||||
};
|
||||
} else values[key] = SettingsSchemas[key as SettingKey].parse(raw);
|
||||
}
|
||||
await checkRefs(db, values);
|
||||
const before = await getAllSettings(db);
|
||||
for (const [key, value] of Object.entries(values))
|
||||
await setSetting(db, key as SettingKey, value as never);
|
||||
const changed = Object.keys(values).filter(
|
||||
(k) => JSON.stringify(before[k as keyof typeof before]) !== JSON.stringify(values[k]),
|
||||
);
|
||||
if (changed.length) {
|
||||
await audit(db, {
|
||||
actorUserId: req.user!.id,
|
||||
actorIp: req.ip,
|
||||
action: 'update',
|
||||
entityType: 'settings',
|
||||
summary: `Changed settings: ${changed.join(', ')}`,
|
||||
diff: {
|
||||
before: Object.fromEntries(changed.map((k) => [k, before[k as keyof typeof before]])),
|
||||
after: Object.fromEntries(changed.map((k) => [k, values[k]])),
|
||||
},
|
||||
});
|
||||
app.generator.requestApply('api-change');
|
||||
}
|
||||
return toDto(db);
|
||||
});
|
||||
|
||||
app.post('/api/settings/smtp/test', async (req) => {
|
||||
const { to } = z.object({ to: z.email() }).parse(req.body);
|
||||
try {
|
||||
await app.sendMail({
|
||||
to: [to],
|
||||
subject: 'SelfPhoneNG test email',
|
||||
text: 'Email from your phone system works.',
|
||||
});
|
||||
} catch (e) {
|
||||
throw badRequest(`Sending failed: ${(e as Error).message}`);
|
||||
}
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
/** Settings → Emergency → Test notifications (06 §6.5): email + ring-out, never dials 911. */
|
||||
app.post('/api/settings/emergency/test', async (req) => {
|
||||
const [ext] = await db.select().from(extensions).where(eq(extensions.userId, req.user!.id));
|
||||
const notified = await notifyEmergency(
|
||||
{ db, ami: app.ami, live: app.live, send: app.sendMail, log: req.log },
|
||||
{
|
||||
ext: ext?.number ?? '',
|
||||
name: req.user!.displayName,
|
||||
callerId: 'TEST',
|
||||
dialed: '911',
|
||||
test: true,
|
||||
},
|
||||
);
|
||||
await audit(db, {
|
||||
actorUserId: req.user!.id,
|
||||
actorIp: req.ip,
|
||||
action: 'emergency_test',
|
||||
summary: 'Tested the emergency notifications (nothing was dialed)',
|
||||
diff: { after: notified },
|
||||
});
|
||||
return { notified };
|
||||
});
|
||||
|
||||
/** Things an admin should fix, for the dashboard banner (05 §2). */
|
||||
app.get('/api/system/warnings', async (): Promise<{ items: Warning[] }> => {
|
||||
const items: Warning[] = [];
|
||||
const s = await getAllSettings(db);
|
||||
if (s['system.restartRequired'])
|
||||
items.push({
|
||||
code: 'restart_required',
|
||||
message: 'SIP port or transport changes need a telephony restart.',
|
||||
link: '/admin/system',
|
||||
});
|
||||
const [anyTrunk] = await db.select({ id: trunks.id }).from(trunks).limit(1);
|
||||
const e911 = s['emergency.defaultCallerIdDidId'];
|
||||
if (anyTrunk && !e911)
|
||||
items.push({
|
||||
code: 'e911_cid_missing',
|
||||
message: 'No emergency (E911) caller ID is set. 911 calls may not carry your address.',
|
||||
link: '/admin/settings/emergency',
|
||||
});
|
||||
if (e911) {
|
||||
const [d] = await db.select().from(dids).where(eq(dids.id, e911));
|
||||
if (d && !d.e911Address)
|
||||
items.push({
|
||||
code: 'e911_address_unconfirmed',
|
||||
message: 'Confirm the emergency caller ID number has an E911 address at voip.ms.',
|
||||
link: '/admin/numbers',
|
||||
});
|
||||
}
|
||||
if (!s.smtp)
|
||||
items.push({
|
||||
code: 'smtp_missing',
|
||||
message: 'Email is not set up, so emergency and voicemail emails can’t be sent.',
|
||||
link: '/admin/settings/email',
|
||||
});
|
||||
return { items };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Someone dialed 911 (04 §5.3, 06 §6): record it, email, ring the notify phones, tell the
|
||||
// dashboards. The test button runs the same notifications with TEST in front and dials nothing.
|
||||
import type { EventEmitter } from 'node:events';
|
||||
import { eq, inArray } from 'drizzle-orm';
|
||||
import type { EmergencyCall } from '@selfphoneng/shared';
|
||||
import { field, type AmiMessage } from '../ami/events.ts';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { emergencyEvents, extensions } from '../db/schema.ts';
|
||||
import type { SendMail } from './mail.ts';
|
||||
import { getSetting } from './settings.ts';
|
||||
|
||||
export type Notified = { kind: 'email' | 'ring'; to: string; ok: boolean; error?: string };
|
||||
|
||||
export type EmergencyDeps = {
|
||||
db: Db;
|
||||
ami: { action(fields: Record<string, string>): Promise<unknown> };
|
||||
live: { emit(event: 'message', m: { type: 'emergency.call'; data: EmergencyCall }): unknown };
|
||||
send: SendMail;
|
||||
log: { info(o: object, msg: string): void; error(o: object, msg: string): void };
|
||||
};
|
||||
|
||||
export async function notifyEmergency(
|
||||
d: EmergencyDeps,
|
||||
call: { ext: string; callerId: string; dialed: string; test?: boolean; name?: string },
|
||||
): Promise<Notified[]> {
|
||||
const at = new Date();
|
||||
const [ext] = call.ext
|
||||
? await d.db.select().from(extensions).where(eq(extensions.number, call.ext))
|
||||
: [];
|
||||
const name = ext?.name ?? call.name ?? 'unknown';
|
||||
const location = ext?.emergencyLocation ?? null;
|
||||
const [row] = call.test
|
||||
? []
|
||||
: await d.db
|
||||
.insert(emergencyEvents)
|
||||
.values({
|
||||
extensionNumber: call.ext || 'unknown',
|
||||
dialed: call.dialed,
|
||||
callerIdUsed: call.callerId,
|
||||
location,
|
||||
})
|
||||
.returning({ id: emergencyEvents.id });
|
||||
|
||||
const notifyIds = await getSetting(d.db, 'emergency.notifyExtensionIds');
|
||||
const notifyExts = notifyIds.length
|
||||
? await d.db
|
||||
.select({ number: extensions.number, userId: extensions.userId })
|
||||
.from(extensions)
|
||||
.where(inArray(extensions.id, notifyIds))
|
||||
: [];
|
||||
// Dashboards first: it's the fastest channel.
|
||||
d.live.emit('message', {
|
||||
type: 'emergency.call',
|
||||
data: {
|
||||
ext: call.ext,
|
||||
name,
|
||||
location,
|
||||
callerId: call.callerId,
|
||||
at: at.toISOString(),
|
||||
test: Boolean(call.test),
|
||||
audienceUserIds: notifyExts.flatMap((e) => (e.userId ? [e.userId] : [])),
|
||||
},
|
||||
});
|
||||
|
||||
const tz = await getSetting(d.db, 'general.timezone');
|
||||
const prefix = call.test ? 'TEST ' : '';
|
||||
const results: Notified[] = [];
|
||||
const emails = await getSetting(d.db, 'emergency.notifyEmails');
|
||||
const mail = emails.length
|
||||
? d
|
||||
.send({
|
||||
to: emails,
|
||||
subject: `${prefix}EMERGENCY: 911 dialed from ext ${call.ext || 'unknown'} (${name})`,
|
||||
text: [
|
||||
call.test
|
||||
? 'This is a TEST of the emergency notifications. No emergency call was made.\n'
|
||||
: '',
|
||||
`Time: ${at.toLocaleString('en-US', { timeZone: tz })}`,
|
||||
`Extension: ${call.ext || 'unknown'} (${name})`,
|
||||
`Location: ${location ?? 'not set'}`,
|
||||
`Caller ID sent to 911: ${call.callerId || 'none'}`,
|
||||
'',
|
||||
'Emergency services get the address registered for that caller ID at voip.ms.',
|
||||
].join('\n'),
|
||||
})
|
||||
.then(
|
||||
() => results.push({ kind: 'email', to: emails.join(', '), ok: true }),
|
||||
(e: Error) =>
|
||||
results.push({ kind: 'email', to: emails.join(', '), ok: false, error: e.message }),
|
||||
)
|
||||
: Promise.resolve();
|
||||
const rings = notifyExts.map((e) =>
|
||||
d.ami
|
||||
.action({
|
||||
Action: 'Originate',
|
||||
Channel: `PJSIP/${e.number}`,
|
||||
Context: 'sp-emergency-alert',
|
||||
Exten: call.ext || '0',
|
||||
Priority: '1',
|
||||
CallerID: `"${prefix}EMERGENCY 911" <${call.ext || '0'}>`,
|
||||
Async: 'true',
|
||||
})
|
||||
.then(
|
||||
() => results.push({ kind: 'ring', to: e.number, ok: true }),
|
||||
(err: Error) => results.push({ kind: 'ring', to: e.number, ok: false, error: err.message }),
|
||||
),
|
||||
);
|
||||
await Promise.all([mail, ...rings]);
|
||||
|
||||
if (row)
|
||||
await d.db
|
||||
.update(emergencyEvents)
|
||||
.set({ notified: results })
|
||||
.where(eq(emergencyEvents.id, row.id));
|
||||
d.log[results.every((r) => r.ok) ? 'info' : 'error'](
|
||||
{ ext: call.ext, test: Boolean(call.test), results },
|
||||
'emergency notifications',
|
||||
);
|
||||
return results;
|
||||
}
|
||||
|
||||
/** Listens for the dialplan's UserEvent(SpEmergency,...) (03 §6.14). */
|
||||
export function watchEmergencies(d: EmergencyDeps, ami: Pick<EventEmitter, 'on'>) {
|
||||
ami.on('event', (m: AmiMessage) => {
|
||||
if (field(m, 'Event') !== 'UserEvent' || field(m, 'UserEvent') !== 'SpEmergency') return;
|
||||
void notifyEmergency(d, {
|
||||
ext: field(m, 'Ext') ?? '',
|
||||
callerId: field(m, 'CallerId') ?? '',
|
||||
dialed: field(m, 'Dialed') ?? '911',
|
||||
}).catch((err: unknown) => d.log.error({ err }, 'emergency notification failed'));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Outgoing email (04 §10) through the SMTP server in settings.smtp.
|
||||
import nodemailer from 'nodemailer';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { decrypt } from '../crypto/secrets.ts';
|
||||
import { getSetting } from './settings.ts';
|
||||
|
||||
export type Mail = { to: string[]; subject: string; text: string };
|
||||
export type SendMail = (m: Mail) => Promise<void>;
|
||||
|
||||
export class MailNotConfigured extends Error {
|
||||
constructor() {
|
||||
super('Email is not set up (Settings → Email)');
|
||||
}
|
||||
}
|
||||
|
||||
/** Reads the SMTP settings on every send, so changes apply without a restart. */
|
||||
export function smtpMailer(db: Db): SendMail {
|
||||
return async ({ to, subject, text }) => {
|
||||
const smtp = await getSetting(db, 'smtp');
|
||||
if (!smtp) throw new MailNotConfigured();
|
||||
const transport = nodemailer.createTransport({
|
||||
host: smtp.host,
|
||||
port: smtp.port,
|
||||
secure: smtp.secure,
|
||||
auth: smtp.username ? { user: smtp.username, pass: decrypt(smtp.passwordEnc) } : undefined,
|
||||
connectionTimeout: 15_000,
|
||||
greetingTimeout: 15_000,
|
||||
});
|
||||
await transport.sendMail({ from: smtp.from, to, subject, text });
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Typed key/value settings (spec 02 §3). Missing rows return the key's default.
|
||||
import { eq, inArray } from 'drizzle-orm';
|
||||
import { eq, inArray, sql } from 'drizzle-orm';
|
||||
import { SettingsSchemas, type SettingKey, type SettingValue } from '@selfphoneng/shared';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { settings } from '../db/schema.ts';
|
||||
@@ -44,7 +44,9 @@ export async function setSetting<K extends SettingKey>(
|
||||
key: K,
|
||||
value: SettingValue<K>,
|
||||
): Promise<void> {
|
||||
const v = SettingsSchemas[key].parse(value);
|
||||
// A plain null would bind as SQL NULL (the column is NOT NULL); store the JSON value null.
|
||||
const parsed = SettingsSchemas[key].parse(value);
|
||||
const v = parsed === null ? sql`'null'::jsonb` : parsed;
|
||||
await db
|
||||
.insert(settings)
|
||||
.values({ key, value: v })
|
||||
|
||||
@@ -16,6 +16,7 @@ import { EventEmitter } from 'node:events';
|
||||
import { createGenerator } from '../src/generator/index.ts';
|
||||
import { LiveState } from '../src/live/state.ts';
|
||||
import type { AmiMessage } from '../src/ami/events.ts';
|
||||
import type { Mail } from '../src/services/mail.ts';
|
||||
import { createTestDb } from './db.ts';
|
||||
|
||||
/** Stand-in for AmiClient: records commands; flip `connected` to simulate Asterisk going away. */
|
||||
@@ -70,6 +71,14 @@ export async function createTestApp(extend?: (app: ReturnType<typeof buildApp>)
|
||||
const genDir = mkdtempSync(join(tmpdir(), 'sp-gen-'));
|
||||
const live = new LiveState(t.db, ami);
|
||||
app.decorate('live', live);
|
||||
app.decorate('ami', ami);
|
||||
/** Every email the app sent; set mail.fail to make sending throw. */
|
||||
const mails: Mail[] = [];
|
||||
const mail = { fail: null as string | null };
|
||||
app.decorate('sendMail', async (m: Mail) => {
|
||||
if (mail.fail) throw new Error(mail.fail);
|
||||
mails.push(m);
|
||||
});
|
||||
app.decorate(
|
||||
'generator',
|
||||
createGenerator({
|
||||
@@ -116,6 +125,8 @@ export async function createTestApp(extend?: (app: ReturnType<typeof buildApp>)
|
||||
app,
|
||||
db: t.db,
|
||||
ami,
|
||||
mails,
|
||||
mail,
|
||||
live,
|
||||
genDir,
|
||||
login,
|
||||
|
||||
@@ -418,6 +418,56 @@ async function main() {
|
||||
log('class check: ss-noservice');
|
||||
await ami.command('channel request hangup all');
|
||||
|
||||
// M3-T4: 911 from a caller with no class at all still goes out, with the E911 caller ID, and
|
||||
// the dialplan tells the api. (The fake provider answers it; nothing real is dialed.)
|
||||
const userEvents: Record<string, string | string[]>[] = [];
|
||||
ami.on('event:UserEvent', (m) => userEvents.push(m));
|
||||
await ami.command('channel originate Local/911@sp-from-ext application Wait 5');
|
||||
await waitFor(
|
||||
'the provider to receive 911',
|
||||
async () =>
|
||||
/LAST_IN=to=911 /.test(
|
||||
docker('exec', PROVIDER, 'asterisk', '-rx', 'dialplan show globals').out,
|
||||
),
|
||||
15,
|
||||
);
|
||||
const e911 = /LAST_IN=(.*)/.exec(
|
||||
docker('exec', PROVIDER, 'asterisk', '-rx', 'dialplan show globals').out,
|
||||
)?.[1];
|
||||
log(`provider received: ${e911}`);
|
||||
if (!new RegExp(`rpid=.*sip:${DID}@`).test(e911 ?? ''))
|
||||
problems.push(`911 without the E911 caller ID ${DID}: ${e911}`);
|
||||
// AMI delivers the UserEvent asynchronously: it can arrive after the trunk call has started.
|
||||
await waitFor(
|
||||
'SpEmergency on AMI',
|
||||
async () => userEvents.some((m) => m.UserEvent === 'SpEmergency'),
|
||||
10,
|
||||
).catch(() => {});
|
||||
const ev = userEvents.find((m) => m.UserEvent === 'SpEmergency');
|
||||
if (!ev || ev.CallerId !== DID || ev.Dialed !== '911')
|
||||
problems.push(`no SpEmergency UserEvent with CallerId ${DID}: ${JSON.stringify(userEvents)}`);
|
||||
await ami.command('channel request hangup all');
|
||||
// The alert ring-out the api originates: 101 answers and hears beeps and the extension.
|
||||
await ami.action({
|
||||
Action: 'Originate',
|
||||
Channel: 'PJSIP/101',
|
||||
Context: 'sp-emergency-alert',
|
||||
Exten: '102',
|
||||
Priority: '1',
|
||||
CallerID: '"EMERGENCY 911" <102>',
|
||||
Async: 'true',
|
||||
});
|
||||
await waitFor(
|
||||
'the alert to play on 101',
|
||||
async () =>
|
||||
/sp-emergency-alert!102!.*!Up!(Playback|SayDigits|Wait)/.test(
|
||||
await ami.command('core show channels concise'),
|
||||
),
|
||||
15,
|
||||
);
|
||||
log('911: out with the E911 caller ID, SpEmergency raised, alert plays on 101');
|
||||
await ami.command('channel request hangup all');
|
||||
|
||||
// eslint-disable-next-line no-control-regex -- strips Asterisk's ANSI colors
|
||||
const plainLog = docker('logs', PBX).out.replace(/\x1b\[[0-9;]*m/g, '');
|
||||
const errors = plainLog.split('\n').filter((l) => /ERROR\[/.test(l));
|
||||
@@ -441,7 +491,7 @@ async function main() {
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(
|
||||
'ok trunks (registration, rejection, qualify, inbound DID → 101, outbound with DID caller ID, class check)',
|
||||
'ok trunks (registration, rejection, qualify, inbound DID → 101, outbound with DID caller ID, class check, 911)',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// M3-T4: 911 notifications (04 §5.3), the test button, settings and dashboard warnings.
|
||||
import { eq } from 'drizzle-orm';
|
||||
import type { LightMyRequestResponse } from 'fastify';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import type { LiveMessage } from '@selfphoneng/shared';
|
||||
import { emergencyEvents, settings } from '../src/db/schema.ts';
|
||||
import { decrypt } from '../src/crypto/secrets.ts';
|
||||
import { watchEmergencies } from '../src/services/emergency.ts';
|
||||
import { hasTestDb } from './db.ts';
|
||||
import { ADMIN, H, createTestApp } from './app.ts';
|
||||
|
||||
const tick = () => new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
describe.skipIf(!hasTestDb)('emergency notifications', () => {
|
||||
let t: Awaited<ReturnType<typeof createTestApp>>;
|
||||
let req: (method: string, url: string, payload?: unknown) => Promise<LightMyRequestResponse>;
|
||||
let dan: { id: string; number: string };
|
||||
let desk: { id: string; number: string };
|
||||
let didId: string;
|
||||
const live: LiveMessage[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await createTestApp();
|
||||
await t.clearMustChange(ADMIN.email);
|
||||
const cookie = await t.login(ADMIN.email, ADMIN.password);
|
||||
req = (method, url, payload) =>
|
||||
t.app.inject({
|
||||
method: method as 'GET',
|
||||
url,
|
||||
payload: payload as object,
|
||||
headers: { ...H, cookie },
|
||||
});
|
||||
dan = (await req('POST', '/api/extensions', { number: '101', name: 'Dan Doe' })).json();
|
||||
await req('PATCH', `/api/extensions/${dan.id}`, { emergencyLocation: '2nd floor, room 210' });
|
||||
desk = (await req('POST', '/api/extensions', { number: '100', name: 'Front desk' })).json();
|
||||
const trunkId = (
|
||||
await req('POST', '/api/trunks', {
|
||||
name: 'voip.ms',
|
||||
serverHost: '127.0.0.1',
|
||||
username: '123456_pbx',
|
||||
password: 'pw',
|
||||
})
|
||||
).json().id;
|
||||
didId = (
|
||||
await req('POST', '/api/dids', {
|
||||
number: '3125551234',
|
||||
trunkId,
|
||||
destination: { type: 'hangup' },
|
||||
})
|
||||
).json().id;
|
||||
t.live.on('message', (m) => live.push(m));
|
||||
watchEmergencies(
|
||||
{ db: t.db, ami: t.ami, live: t.live, send: t.app.sendMail, log: t.app.log },
|
||||
t.ami.events,
|
||||
);
|
||||
});
|
||||
afterAll(() => t?.close());
|
||||
|
||||
it('dashboard warnings: E911 caller ID missing, email missing', async () => {
|
||||
const codes = (await req('GET', '/api/system/warnings'))
|
||||
.json()
|
||||
.items.map((w: { code: string }) => w.code);
|
||||
expect(codes).toEqual(['e911_cid_missing', 'smtp_missing']);
|
||||
});
|
||||
|
||||
it('settings: SMTP password is write-only; references are checked', async () => {
|
||||
const r = await req('PATCH', '/api/settings', {
|
||||
smtp: {
|
||||
host: 'smtp.example.com',
|
||||
port: 587,
|
||||
secure: false,
|
||||
username: 'pbx',
|
||||
password: 'mail-secret',
|
||||
from: 'PBX <pbx@example.com>',
|
||||
},
|
||||
'emergency.defaultCallerIdDidId': didId,
|
||||
'emergency.notifyEmails': ['security@example.com'],
|
||||
'emergency.notifyExtensionIds': [desk.id],
|
||||
});
|
||||
expect(r.statusCode).toBe(200);
|
||||
expect(r.json().smtp).toEqual({
|
||||
host: 'smtp.example.com',
|
||||
port: 587,
|
||||
secure: false,
|
||||
username: 'pbx',
|
||||
from: 'PBX <pbx@example.com>',
|
||||
hasPassword: true,
|
||||
});
|
||||
expect(JSON.stringify(r.json())).not.toContain('mail-secret');
|
||||
const [row] = await t.db.select().from(settings).where(eq(settings.key, 'smtp'));
|
||||
expect(decrypt((row!.value as { passwordEnc: string }).passwordEnc)).toBe('mail-secret');
|
||||
// Saving again without a password keeps it.
|
||||
await req('PATCH', '/api/settings', {
|
||||
smtp: { ...r.json().smtp, hasPassword: undefined, port: 465, secure: true },
|
||||
});
|
||||
const [again] = await t.db.select().from(settings).where(eq(settings.key, 'smtp'));
|
||||
expect(decrypt((again!.value as { passwordEnc: string }).passwordEnc)).toBe('mail-secret');
|
||||
|
||||
for (const bad of [
|
||||
{ 'system.restartRequired': false },
|
||||
{ 'no.such.key': 1 },
|
||||
{ 'emergency.defaultCallerIdDidId': '00000000-0000-4000-8000-000000000000' },
|
||||
{ 'emergency.notifyExtensionIds': ['00000000-0000-4000-8000-000000000000'] },
|
||||
{ 'emergency.notifyEmails': ['not-an-email'] },
|
||||
])
|
||||
expect((await req('PATCH', '/api/settings', bad)).statusCode).toBe(400);
|
||||
|
||||
const codes = (await req('GET', '/api/system/warnings'))
|
||||
.json()
|
||||
.items.map((w: { code: string }) => w.code);
|
||||
expect(codes).toEqual(['e911_address_unconfirmed']);
|
||||
await req('PATCH', `/api/dids/${didId}`, { e911Address: '1 Main St, Chicago IL' });
|
||||
expect((await req('GET', '/api/system/warnings')).json().items).toEqual([]);
|
||||
});
|
||||
|
||||
it('a 911 call records the event, emails, rings the notify phone and tells the dashboards', async () => {
|
||||
t.ami.events.emit('event', {
|
||||
Event: 'UserEvent',
|
||||
UserEvent: 'SpEmergency',
|
||||
Ext: '101',
|
||||
CallerId: '13125551234',
|
||||
Dialed: '911',
|
||||
});
|
||||
for (let i = 0; i < 40 && !(await t.db.select().from(emergencyEvents))[0]?.notified; i++)
|
||||
await tick();
|
||||
await tick();
|
||||
const [ev] = await t.db.select().from(emergencyEvents);
|
||||
expect(ev).toMatchObject({
|
||||
extensionNumber: '101',
|
||||
dialed: '911',
|
||||
callerIdUsed: '13125551234',
|
||||
location: '2nd floor, room 210',
|
||||
});
|
||||
expect(ev!.notified).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ kind: 'email', to: 'security@example.com', ok: true },
|
||||
{ kind: 'ring', to: '100', ok: true },
|
||||
]),
|
||||
);
|
||||
const mail = t.mails.at(-1)!;
|
||||
expect(mail.subject).toBe('EMERGENCY: 911 dialed from ext 101 (Dan Doe)');
|
||||
expect(mail.text).toContain('Location: 2nd floor, room 210');
|
||||
expect(t.ami.actions).toContainEqual(
|
||||
expect.objectContaining({
|
||||
Action: 'Originate',
|
||||
Channel: 'PJSIP/100',
|
||||
Context: 'sp-emergency-alert',
|
||||
Exten: '101',
|
||||
}),
|
||||
);
|
||||
expect(live).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: 'emergency.call',
|
||||
data: expect.objectContaining({ ext: '101', test: false, location: '2nd floor, room 210' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('a failing email is recorded and the phone still rings', async () => {
|
||||
t.mail.fail = 'connection refused';
|
||||
t.ami.actions.length = 0;
|
||||
t.ami.events.emit('event', {
|
||||
Event: 'UserEvent',
|
||||
UserEvent: 'SpEmergency',
|
||||
Ext: '101',
|
||||
CallerId: '13125551234',
|
||||
Dialed: '911',
|
||||
});
|
||||
for (let i = 0; i < 40 && (await t.db.select().from(emergencyEvents)).length < 2; i++)
|
||||
await tick();
|
||||
await tick();
|
||||
const rows = await t.db.select().from(emergencyEvents);
|
||||
const last = rows.find((r) =>
|
||||
(r.notified as unknown[]).some((n) => (n as { ok: boolean }).ok === false),
|
||||
);
|
||||
expect(last?.notified).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ kind: 'email', to: 'security@example.com', ok: false, error: 'connection refused' },
|
||||
{ kind: 'ring', to: '100', ok: true },
|
||||
]),
|
||||
);
|
||||
t.mail.fail = null;
|
||||
});
|
||||
|
||||
it('the test button notifies with TEST in front, records nothing and dials nothing', async () => {
|
||||
const before = (await t.db.select().from(emergencyEvents)).length;
|
||||
t.ami.actions.length = 0;
|
||||
const r = await req('POST', '/api/settings/emergency/test');
|
||||
expect(r.statusCode).toBe(200);
|
||||
expect(t.mails.at(-1)!.subject).toMatch(/^TEST EMERGENCY: 911 dialed/);
|
||||
expect(t.mails.at(-1)!.text).toContain('No emergency call was made');
|
||||
expect(t.ami.actions).toEqual([
|
||||
expect.objectContaining({ Channel: 'PJSIP/100', Context: 'sp-emergency-alert' }),
|
||||
]);
|
||||
expect((await t.db.select().from(emergencyEvents)).length).toBe(before);
|
||||
});
|
||||
});
|
||||
@@ -184,4 +184,32 @@ describe.skipIf(!hasTestDb)('/api/live WebSocket', () => {
|
||||
admin.ws.terminate();
|
||||
user.ws.terminate();
|
||||
});
|
||||
|
||||
it('a 911 alert reaches admins and notify users only; trunk status reaches admins only', async () => {
|
||||
const admin = await open(await t.login(ADMIN.email, ADMIN.password));
|
||||
const notifyUser = await t.addUser('desk@example.com', 'desk-password-123');
|
||||
const desk = await open(await t.login('desk@example.com', 'desk-password-123'));
|
||||
const other = await open(await t.login('u@example.com', 'user-password-123'));
|
||||
await until(() => [admin, desk, other].every((c) => c.msgs.length === 1));
|
||||
const call = {
|
||||
ext: '101',
|
||||
name: 'Dan',
|
||||
location: null,
|
||||
callerId: '13125551234',
|
||||
at: new Date().toISOString(),
|
||||
test: false,
|
||||
audienceUserIds: [notifyUser.id],
|
||||
};
|
||||
t.live.emit('message', { type: 'emergency.call', data: call });
|
||||
t.live.emit('message', {
|
||||
type: 'trunk.updated',
|
||||
data: { trunkId: 'x', registration: 'registered', reachable: true, lastChange: call.at },
|
||||
});
|
||||
await until(() => admin.msgs.length === 3 && desk.msgs.length === 2);
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
expect(desk.msgs[1]).toMatchObject({ type: 'emergency.call', data: { ext: '101' } });
|
||||
expect(desk.msgs).toHaveLength(2); // no trunk.updated
|
||||
expect(other.msgs).toHaveLength(1); // just the snapshot
|
||||
for (const c of [admin, desk, other]) c.ws.terminate();
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,14 @@ describe.skipIf(!hasTestDb)('settings service', () => {
|
||||
await expect(setSetting(db, 'backup.time', '25:00')).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('a nullable setting can be set and cleared again (JSON null, not SQL NULL)', async () => {
|
||||
const id = '11111111-1111-4111-8111-111111111111';
|
||||
await setSetting(db, 'emergency.defaultCallerIdDidId', id);
|
||||
expect(await getSetting(db, 'emergency.defaultCallerIdDidId')).toBe(id);
|
||||
await setSetting(db, 'emergency.defaultCallerIdDidId', null);
|
||||
expect(await getSetting(db, 'emergency.defaultCallerIdDidId')).toBeNull();
|
||||
});
|
||||
|
||||
it('makes sms.webhookToken once and keeps it', async () => {
|
||||
const a = await getSetting(db, 'sms.webhookToken');
|
||||
expect(a).toMatch(/^[A-Za-z0-9]{32}$/);
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
OutboundRouteDto,
|
||||
OutboundRouteUpdate,
|
||||
RouteTestResult,
|
||||
SettingsDto,
|
||||
SipCredentials,
|
||||
TrunkCreate,
|
||||
TrunkDto,
|
||||
@@ -22,6 +23,7 @@ import type {
|
||||
UserCreate,
|
||||
UserDto,
|
||||
UserUpdate,
|
||||
Warning,
|
||||
} from '@selfphoneng/shared';
|
||||
|
||||
export class ApiError extends Error {
|
||||
@@ -113,3 +115,15 @@ export const routesApi = {
|
||||
test: (extensionId: string, number: string) =>
|
||||
api<RouteTestResult>('POST', '/api/outbound-routes/test', { extensionId, number }),
|
||||
};
|
||||
|
||||
export const settingsApi = {
|
||||
get: () => api<SettingsDto>('GET', '/api/settings'),
|
||||
patch: (s: Record<string, unknown>) => api<SettingsDto>('PATCH', '/api/settings', s),
|
||||
testEmail: (to: string) => api<{ ok: true }>('POST', '/api/settings/smtp/test', { to }),
|
||||
testEmergency: () =>
|
||||
api<{ notified: { kind: string; to: string; ok: boolean; error?: string }[] }>(
|
||||
'POST',
|
||||
'/api/settings/emergency/test',
|
||||
),
|
||||
warnings: () => api<{ items: Warning[] }>('GET', '/api/system/warnings').then((r) => r.items),
|
||||
};
|
||||
@@ -32,7 +32,7 @@ export function DestinationPicker({
|
||||
if (k === 'hangup') onChange({ type: 'hangup' });
|
||||
else if (k === 'external') typed(number);
|
||||
else if (extId) onChange({ type: k, id: extId });
|
||||
else onChange(exts.data?.[0] ? { type: k, id: exts.data[0].id } : null);
|
||||
else onChange(null); // the admin picks the user: never guess
|
||||
}
|
||||
|
||||
function typed(v: string) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Live data from /api/live (05 §1): one WebSocket per tab, reconnecting with backoff.
|
||||
import { create } from 'zustand';
|
||||
import type { ExtensionLive, LiveMessage, TrunkLive } from '@selfphoneng/shared';
|
||||
import type { EmergencyCall, ExtensionLive, LiveMessage, TrunkLive } from '@selfphoneng/shared';
|
||||
|
||||
type LiveStore = {
|
||||
/** WebSocket state: 'connecting' before the first snapshot. */
|
||||
@@ -10,6 +10,8 @@ type LiveStore = {
|
||||
extensions: Record<string, ExtensionLive>;
|
||||
/** Admins only (empty for users). */
|
||||
trunks: Record<string, TrunkLive>;
|
||||
/** 911 alerts received while this tab was open, newest last; dismissed ones are removed. */
|
||||
emergencies: EmergencyCall[];
|
||||
};
|
||||
|
||||
export const useLive = create<LiveStore>(() => ({
|
||||
@@ -17,6 +19,7 @@ export const useLive = create<LiveStore>(() => ({
|
||||
amiConnected: false,
|
||||
extensions: {},
|
||||
trunks: {},
|
||||
emergencies: [],
|
||||
}));
|
||||
|
||||
function apply(m: LiveMessage) {
|
||||
@@ -41,6 +44,8 @@ function apply(m: LiveMessage) {
|
||||
const { [m.data.trunkId]: _gone, ...rest } = s.trunks;
|
||||
return { trunks: rest };
|
||||
}
|
||||
case 'emergency.call':
|
||||
return { emergencies: [...s.emergencies, m.data] };
|
||||
case 'ami.status':
|
||||
return { amiConnected: m.data.connected };
|
||||
default:
|
||||
|
||||
+23
-17
@@ -13,6 +13,7 @@ import { DashboardPage } from './pages/admin/DashboardPage.tsx';
|
||||
import { TrunksPage } from './pages/admin/TrunksPage.tsx';
|
||||
import { NumbersPage } from './pages/admin/NumbersPage.tsx';
|
||||
import { OutboundPage } from './pages/admin/OutboundPage.tsx';
|
||||
import { SettingsPage } from './pages/admin/SettingsPage.tsx';
|
||||
import { MePage } from './pages/user/MePage.tsx';
|
||||
import './index.css';
|
||||
|
||||
@@ -22,23 +23,28 @@ const router = createBrowserRouter([
|
||||
{
|
||||
path: '/admin',
|
||||
element: <RequireAuth role="admin">{(user) => <AdminLayout user={user} />}</RequireAuth>,
|
||||
children: ADMIN_PAGES.map((p) => ({
|
||||
...(p.path ? { path: p.path } : { index: true }),
|
||||
element:
|
||||
p.path === '' ? (
|
||||
<DashboardPage />
|
||||
) : p.path === 'users' ? (
|
||||
<RequireAuth>{(user) => <UsersPage currentUserId={user.id} />}</RequireAuth>
|
||||
) : p.path === 'outbound' ? (
|
||||
<OutboundPage />
|
||||
) : p.path === 'numbers' ? (
|
||||
<NumbersPage />
|
||||
) : p.path === 'trunks' ? (
|
||||
<TrunksPage />
|
||||
) : (
|
||||
<ComingSoon label={p.label} milestone={p.milestone ?? ''} />
|
||||
),
|
||||
})),
|
||||
children: [
|
||||
...ADMIN_PAGES.map((p) => ({
|
||||
...(p.path ? { path: p.path } : { index: true }),
|
||||
element:
|
||||
p.path === '' ? (
|
||||
<DashboardPage />
|
||||
) : p.path === 'users' ? (
|
||||
<RequireAuth>{(user) => <UsersPage currentUserId={user.id} />}</RequireAuth>
|
||||
) : p.path === 'settings' ? (
|
||||
<SettingsPage />
|
||||
) : p.path === 'outbound' ? (
|
||||
<OutboundPage />
|
||||
) : p.path === 'numbers' ? (
|
||||
<NumbersPage />
|
||||
) : p.path === 'trunks' ? (
|
||||
<TrunksPage />
|
||||
) : (
|
||||
<ComingSoon label={p.label} milestone={p.milestone ?? ''} />
|
||||
),
|
||||
})),
|
||||
{ path: 'settings/:tab', element: <SettingsPage /> },
|
||||
],
|
||||
},
|
||||
{ path: '/me/*', element: <RequireAuth>{(user) => <MePage user={user} />}</RequireAuth> },
|
||||
{
|
||||
|
||||
@@ -29,7 +29,7 @@ export const ADMIN_PAGES = [
|
||||
{ path: 'reports', label: 'Reports', milestone: 'M11' },
|
||||
{ path: 'sms', label: 'SMS', milestone: 'M9' },
|
||||
{ path: 'blocked', label: 'Blocked numbers', milestone: 'M6' },
|
||||
{ path: 'settings', label: 'Settings', milestone: 'M3' },
|
||||
{ path: 'settings', label: 'Settings', milestone: null },
|
||||
{ path: 'system', label: 'System', milestone: 'M11' },
|
||||
] as const;
|
||||
|
||||
@@ -129,6 +129,7 @@ export function AdminLayout({ user }: { user: AuthUser }) {
|
||||
Reconnecting…
|
||||
</p>
|
||||
)}
|
||||
<EmergencyBanner />
|
||||
<main className="flex-1 p-4 md:p-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
@@ -145,3 +146,36 @@ export function ComingSoon({ label, milestone }: { label: string; milestone: str
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** 911 was dialed: impossible to miss, stays until dismissed (04 §5.3). */
|
||||
function EmergencyBanner() {
|
||||
const calls = useLive((s) => s.emergencies);
|
||||
if (!calls.length) return null;
|
||||
const dismiss = (at: string) =>
|
||||
useLive.setState((s) => ({ emergencies: s.emergencies.filter((c) => c.at !== at) }));
|
||||
return (
|
||||
<div role="alert" className="flex flex-col gap-1 bg-red-600 px-4 py-2 text-sm text-white">
|
||||
{calls.map((c) => (
|
||||
<div key={c.at + c.ext} className="flex flex-wrap items-center gap-x-3">
|
||||
<strong>
|
||||
{c.test
|
||||
? `TEST of the 911 alerts (${c.name})`
|
||||
: `911 dialed from extension ${c.ext || '?'} (${c.name})`}
|
||||
</strong>
|
||||
<span>
|
||||
{new Date(c.at).toLocaleTimeString()}
|
||||
{c.location ? ` · ${c.location}` : ''}
|
||||
</span>
|
||||
<span className="flex-1" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dismiss(c.at)}
|
||||
className="rounded border border-white/60 px-2 py-0.5 text-xs hover:bg-white/10"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Dashboard (05 §2). Telephony link, trunks and extensions online. Calls, queues and the
|
||||
// warnings banner arrive with their features (M3-T4, M7, M8).
|
||||
// Dashboard (05 §2). Warnings, telephony link, trunks and extensions online. Calls and queues
|
||||
// arrive with their features (M7, M8).
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import { settingsApi } from '../../api/client.ts';
|
||||
import { StatusDot, statusLabel } from '../../components/StatusDot.tsx';
|
||||
import { useLive } from '../../live/store.ts';
|
||||
|
||||
@@ -27,6 +30,11 @@ export function DashboardPage() {
|
||||
return (
|
||||
<section className="flex flex-col gap-6">
|
||||
<h1 className="text-xl font-semibold">Dashboard</h1>
|
||||
<Warnings
|
||||
trunkDown={trunkList.some(
|
||||
(t) => t.registration === 'rejected' || (!t.reachable && t.registration !== 'registered'),
|
||||
)}
|
||||
/>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Card title="Extensions online">
|
||||
{status === 'connecting' ? '…' : `${online} / ${list.length}`}
|
||||
@@ -77,3 +85,39 @@ export function DashboardPage() {
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Warnings({ trunkDown }: { trunkDown: boolean }) {
|
||||
const warnings = useQuery({
|
||||
queryKey: ['warnings'],
|
||||
queryFn: settingsApi.warnings,
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
const items = [
|
||||
...(trunkDown
|
||||
? [
|
||||
{
|
||||
code: 'trunk_down',
|
||||
message: 'A trunk is not connected to the provider.',
|
||||
link: '/admin/trunks',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(warnings.data ?? []),
|
||||
];
|
||||
if (!items.length) return null;
|
||||
return (
|
||||
<ul
|
||||
aria-label="Warnings"
|
||||
className="flex flex-col gap-1 rounded-lg border border-red-300 bg-red-50 p-3 text-sm text-red-900 dark:border-red-900 dark:bg-red-950/50 dark:text-red-100"
|
||||
>
|
||||
{items.map((w) => (
|
||||
<li key={w.code}>
|
||||
{w.message}{' '}
|
||||
<Link to={w.link} className="font-medium underline">
|
||||
Fix
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Settings (05 §2). M3: Email and Emergency. The other tabs arrive with their features.
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import { NavLink, useParams } from 'react-router';
|
||||
import { formatNumber, type SettingsDto } from '@selfphoneng/shared';
|
||||
import { didsApi, extensionsApi, settingsApi } from '../../api/client.ts';
|
||||
import { Alert, Button, Field, inputClass } from '../../components/ui.tsx';
|
||||
|
||||
const TABS = [
|
||||
{ id: 'email', label: 'Email' },
|
||||
{ id: 'emergency', label: 'Emergency' },
|
||||
] as const;
|
||||
|
||||
export function SettingsPage() {
|
||||
const { tab = 'email' } = useParams();
|
||||
const settings = useQuery({ queryKey: ['settings'], queryFn: settingsApi.get });
|
||||
return (
|
||||
<section className="flex max-w-2xl flex-col gap-4">
|
||||
<h1 className="text-xl font-semibold">Settings</h1>
|
||||
<nav
|
||||
aria-label="Settings"
|
||||
className="flex gap-1 border-b border-zinc-200 dark:border-zinc-800"
|
||||
>
|
||||
{TABS.map((t) => (
|
||||
<NavLink
|
||||
key={t.id}
|
||||
to={`/admin/settings/${t.id}`}
|
||||
className={({ isActive }) =>
|
||||
`-mb-px border-b-2 px-3 py-1.5 text-sm font-medium ${isActive || (t.id === 'email' && tab === 'email') ? 'border-sky-600' : 'border-transparent hover:border-zinc-300'}`
|
||||
}
|
||||
>
|
||||
{t.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
{settings.isError && <Alert>{settings.error.message}</Alert>}
|
||||
{settings.data && tab === 'email' && <EmailTab s={settings.data} />}
|
||||
{settings.data && tab === 'emergency' && <EmergencyTab s={settings.data} />}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function useSave() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: settingsApi.patch,
|
||||
onSuccess: (s) => {
|
||||
qc.setQueryData(['settings'], s);
|
||||
void qc.invalidateQueries({ queryKey: ['warnings'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function EmailTab({ s }: { s: SettingsDto }) {
|
||||
const save = useSave();
|
||||
const [v, setV] = useState({
|
||||
host: s.smtp?.host ?? '',
|
||||
port: String(s.smtp?.port ?? 587),
|
||||
secure: s.smtp?.secure ?? false,
|
||||
username: s.smtp?.username ?? '',
|
||||
password: '',
|
||||
from: s.smtp?.from ?? '',
|
||||
});
|
||||
const [to, setTo] = useState('');
|
||||
const test = useMutation({ mutationFn: () => settingsApi.testEmail(to) });
|
||||
const set = (k: keyof typeof v) => (e: { target: { value: string } }) =>
|
||||
setV({ ...v, [k]: e.target.value });
|
||||
|
||||
return (
|
||||
<form
|
||||
className="flex flex-col gap-4"
|
||||
noValidate
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
save.mutate({
|
||||
smtp: v.host ? { ...v, port: Number(v.port), password: v.password || undefined } : null,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<p className="text-sm text-zinc-500">
|
||||
The mail server used for emergency alerts and voicemail emails. Leave the server empty to
|
||||
turn email off.
|
||||
</p>
|
||||
{save.isError && <Alert>{save.error.message}</Alert>}
|
||||
{save.isSuccess && (
|
||||
<p role="status" className="text-sm text-green-700 dark:text-green-400">
|
||||
Saved.
|
||||
</p>
|
||||
)}
|
||||
<div className="grid grid-cols-[1fr_6rem] gap-3">
|
||||
<Field label="SMTP server">
|
||||
{(a) => (
|
||||
<input
|
||||
{...a}
|
||||
value={v.host}
|
||||
onChange={set('host')}
|
||||
placeholder="smtp.example.com"
|
||||
className={inputClass}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Port">
|
||||
{(a) => (
|
||||
<input
|
||||
{...a}
|
||||
value={v.port}
|
||||
onChange={set('port')}
|
||||
inputMode="numeric"
|
||||
className={inputClass}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={v.secure}
|
||||
onChange={(e) =>
|
||||
setV({ ...v, secure: e.target.checked, port: e.target.checked ? '465' : '587' })
|
||||
}
|
||||
className="size-4"
|
||||
/>
|
||||
TLS from the start (port 465). Off: STARTTLS (port 587).
|
||||
</label>
|
||||
<Field label="Username">
|
||||
{(a) => (
|
||||
<input
|
||||
{...a}
|
||||
value={v.username}
|
||||
onChange={set('username')}
|
||||
autoComplete="off"
|
||||
className={inputClass}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field
|
||||
label="Password"
|
||||
hint={s.smtp?.hasPassword ? 'Leave empty to keep the current password.' : undefined}
|
||||
>
|
||||
{(a) => (
|
||||
<input
|
||||
{...a}
|
||||
type="password"
|
||||
value={v.password}
|
||||
onChange={set('password')}
|
||||
autoComplete="new-password"
|
||||
className={inputClass}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="From" hint="For example: Phone system <pbx@example.com>">
|
||||
{(a) => <input {...a} value={v.from} onChange={set('from')} className={inputClass} />}
|
||||
</Field>
|
||||
<div>
|
||||
<Button variant="primary" type="submit" disabled={save.isPending}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{s.smtp && (
|
||||
<div className="flex flex-col gap-2 border-t border-zinc-200 pt-4 dark:border-zinc-800">
|
||||
<div className="flex items-end gap-2">
|
||||
<Field label="Send a test email to">
|
||||
{(a) => (
|
||||
<input
|
||||
{...a}
|
||||
type="email"
|
||||
value={to}
|
||||
onChange={(e) => setTo(e.target.value)}
|
||||
className={inputClass}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Button onClick={() => test.mutate()} disabled={!to || test.isPending}>
|
||||
{test.isPending ? 'Sending…' : 'Send test'}
|
||||
</Button>
|
||||
</div>
|
||||
{test.isError && <Alert>{test.error.message}</Alert>}
|
||||
{test.isSuccess && (
|
||||
<p role="status" className="text-sm text-green-700 dark:text-green-400">
|
||||
Sent. Check the inbox (and the spam folder).
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function EmergencyTab({ s }: { s: SettingsDto }) {
|
||||
const save = useSave();
|
||||
const dids = useQuery({ queryKey: ['dids'], queryFn: didsApi.list });
|
||||
const exts = useQuery({ queryKey: ['extensions'], queryFn: extensionsApi.list });
|
||||
const [cid, setCid] = useState(s['emergency.defaultCallerIdDidId'] ?? '');
|
||||
const [emails, setEmails] = useState(s['emergency.notifyEmails'].join(', '));
|
||||
const [notify, setNotify] = useState<string[]>(s['emergency.notifyExtensionIds']);
|
||||
const test = useMutation({ mutationFn: settingsApi.testEmergency });
|
||||
|
||||
return (
|
||||
<form
|
||||
className="flex flex-col gap-4"
|
||||
noValidate
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
save.mutate({
|
||||
'emergency.defaultCallerIdDidId': cid || null,
|
||||
'emergency.notifyEmails': emails
|
||||
.split(/[,\s]+/)
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean),
|
||||
'emergency.notifyExtensionIds': notify,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div className="rounded-md border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-100">
|
||||
<p>
|
||||
Every phone can always dial <strong>911</strong> (and 9911), whatever its calling
|
||||
permission. Emergency services get the street address registered for the caller ID number{' '}
|
||||
<strong>at voip.ms</strong> (DID → e911), not anything typed here. Register an address for
|
||||
every number used as an emergency caller ID. People working elsewhere need their own
|
||||
number with their own address, or should call 911 from a mobile phone.
|
||||
</p>
|
||||
<p className="mt-2">
|
||||
US law (Kari's Law, RAY BAUM'S Act) also asks that someone is notified when 911
|
||||
is dialed. This is a summary, not legal advice: check with counsel.
|
||||
</p>
|
||||
</div>
|
||||
{save.isError && <Alert>{save.error.message}</Alert>}
|
||||
{save.isSuccess && (
|
||||
<p role="status" className="text-sm text-green-700 dark:text-green-400">
|
||||
Saved.
|
||||
</p>
|
||||
)}
|
||||
<Field
|
||||
label="Default emergency caller ID"
|
||||
hint="Used when an extension has no emergency caller ID of its own."
|
||||
>
|
||||
{(a) => (
|
||||
<select
|
||||
{...a}
|
||||
value={cid}
|
||||
onChange={(e) => setCid(e.target.value)}
|
||||
className={inputClass}
|
||||
>
|
||||
<option value="">Not set</option>
|
||||
{dids.data?.map((d) => (
|
||||
<option key={d.id} value={d.id}>
|
||||
{formatNumber(d.number)}
|
||||
{d.label ? ` (${d.label})` : ''}
|
||||
{d.e911Address ? '' : ' — E911 address not confirmed'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Email these people" hint="Separate addresses with commas.">
|
||||
{(a) => (
|
||||
<input
|
||||
{...a}
|
||||
value={emails}
|
||||
onChange={(e) => setEmails(e.target.value)}
|
||||
placeholder="frontdesk@example.com"
|
||||
className={inputClass}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<fieldset className="flex flex-col gap-1">
|
||||
<legend className="mb-1 text-sm font-medium">Ring these phones</legend>
|
||||
{exts.data?.length === 0 && <p className="text-sm text-zinc-500">No extensions yet.</p>}
|
||||
{exts.data?.map((x) => (
|
||||
<label key={x.id} className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="size-4"
|
||||
checked={notify.includes(x.id)}
|
||||
onChange={(e) =>
|
||||
setNotify(e.target.checked ? [...notify, x.id] : notify.filter((n) => n !== x.id))
|
||||
}
|
||||
/>
|
||||
{x.number} {x.name}
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="primary" type="submit" disabled={save.isPending}>
|
||||
Save
|
||||
</Button>
|
||||
<Button onClick={() => test.mutate()} disabled={test.isPending}>
|
||||
{test.isPending ? 'Testing…' : 'Test notifications'}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-zinc-500">
|
||||
The test sends the email and rings the phones above with "TEST" in front. It never
|
||||
dials 911. Never test by calling 911.
|
||||
</p>
|
||||
{test.isError && <Alert>{test.error.message}</Alert>}
|
||||
{test.data && (
|
||||
<ul role="status" className="text-sm">
|
||||
{test.data.notified.length === 0 && (
|
||||
<li>Nobody to notify yet: add an email or a phone.</li>
|
||||
)}
|
||||
{test.data.notified.map((n) => (
|
||||
<li key={n.kind + n.to} className={n.ok ? '' : 'text-red-600 dark:text-red-400'}>
|
||||
{n.kind === 'email' ? 'Email to' : 'Rang'} {n.to}: {n.ok ? 'ok' : n.error}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// User + extension editor (05 §2). Tabs so far: General, Phone, Calling, Credentials.
|
||||
// Voicemail (M4), Emergency (M3-T4) and Forwarding (M6) arrive with their features.
|
||||
// User + extension editor (05 §2). Tabs so far: General, Phone, Calling, Emergency, Credentials.
|
||||
// Emergency too. Voicemail (M4) and Forwarding (M6) arrive with their features.
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useState, type ReactNode } from 'react';
|
||||
@@ -21,7 +21,7 @@ import { CLASS_LABEL } from './OutboundPage.tsx';
|
||||
import { Dialog } from '../../components/Dialog.tsx';
|
||||
import { Alert, Button, Field, inputClass } from '../../components/ui.tsx';
|
||||
|
||||
type Tab = 'general' | 'phone' | 'calling' | 'credentials';
|
||||
type Tab = 'general' | 'phone' | 'calling' | 'emergency' | 'credentials';
|
||||
|
||||
const GeneralSchema = z.object({
|
||||
displayName: z.string().trim().min(1, 'Required').max(100),
|
||||
@@ -55,6 +55,7 @@ export function UserDrawer({
|
||||
{ id: 'general', label: 'General', enabled: true },
|
||||
{ id: 'phone', label: 'Phone', enabled: Boolean(ext.data) },
|
||||
{ id: 'calling', label: 'Calling', enabled: Boolean(ext.data) },
|
||||
{ id: 'emergency', label: 'Emergency', enabled: Boolean(ext.data) },
|
||||
{ id: 'credentials', label: 'Credentials', enabled: Boolean(ext.data) },
|
||||
];
|
||||
|
||||
@@ -106,6 +107,7 @@ export function UserDrawer({
|
||||
)}
|
||||
{tab === 'phone' && ext.data && <PhoneTab ext={ext.data} onSaved={() => onSaved()} />}
|
||||
{tab === 'calling' && ext.data && <CallingTab ext={ext.data} onSaved={() => onSaved()} />}
|
||||
{tab === 'emergency' && ext.data && <EmergencyTab ext={ext.data} onSaved={() => onSaved()} />}
|
||||
{tab === 'credentials' && ext.data && <CredentialsTab ext={ext.data} />}
|
||||
</Dialog>
|
||||
);
|
||||
@@ -496,3 +498,55 @@ function CallingTab({ ext, onSaved }: { ext: ExtensionDto; onSaved: () => void }
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function EmergencyTab({ ext, onSaved }: { ext: ExtensionDto; onSaved: () => void }) {
|
||||
const qc = useQueryClient();
|
||||
const dids = useQuery({ queryKey: ['dids'], queryFn: didsApi.list });
|
||||
const [error, setError] = useState<string>();
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
emergencyCallerIdDidId: ext.emergencyCallerIdDidId ?? '',
|
||||
emergencyLocation: ext.emergencyLocation ?? '',
|
||||
},
|
||||
});
|
||||
const onSubmit = form.handleSubmit(async (v) => {
|
||||
setError(undefined);
|
||||
try {
|
||||
await extensionsApi.update(ext.id, {
|
||||
emergencyCallerIdDidId: v.emergencyCallerIdDidId || null,
|
||||
emergencyLocation: v.emergencyLocation.trim() || null,
|
||||
});
|
||||
await qc.invalidateQueries({ queryKey: ['extension', ext.id] });
|
||||
onSaved();
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
}
|
||||
});
|
||||
return (
|
||||
<form id="user-emergency-form" onSubmit={onSubmit} className="flex flex-col gap-4" noValidate>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<Field
|
||||
label="Emergency caller ID"
|
||||
hint="A number whose E911 address at voip.ms is where this person works. Default: the company emergency caller ID."
|
||||
>
|
||||
{(a) => (
|
||||
<select {...a} {...form.register('emergencyCallerIdDidId')} className={inputClass}>
|
||||
<option value="">Company default</option>
|
||||
{dids.data?.map((d) => (
|
||||
<option key={d.id} value={d.id}>
|
||||
{formatNumber(d.number)}
|
||||
{d.label ? ` (${d.label})` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</Field>
|
||||
<Field
|
||||
label="Location"
|
||||
hint="For example: 2nd floor, room 210. Goes into the alert email; 911 can't receive it."
|
||||
>
|
||||
{(a) => <input {...a} {...form.register('emergencyLocation')} className={inputClass} />}
|
||||
</Field>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -860,7 +860,7 @@ The `SpEmergency` event triggers notifications (04 §5.3, 06 §6). For the
|
||||
```ini
|
||||
[sp-emergency-alert]
|
||||
; Originated by the api: Channel PJSIP/<notify ext>, Context sp-emergency-alert, Exten <caller ext>
|
||||
exten => _X.,1,Answer()
|
||||
exten => _X!,1,Answer() ; X! also matches a one-digit "0" (caller unknown, or a test)
|
||||
same => n,Wait(1)
|
||||
same => n,Playback(beep&beep&beep)
|
||||
same => n,Playback(extension)
|
||||
|
||||
@@ -344,8 +344,10 @@ Reports, history, system:
|
||||
| GET | `/api/reports/summary?from&to` | totals per day, per direction, missed count, average talk time |
|
||||
| GET | `/api/reports/queues?from&to` | per queue: offered, answered, abandoned, avg wait, % within SLA |
|
||||
| GET | `/api/audit-log` | |
|
||||
| GET/PATCH | `/api/settings` | whole settings object; PATCH merges known keys only |
|
||||
| GET/PATCH | `/api/settings` | whole settings object; PATCH merges known keys only. `smtp` comes back as `{host, port, secure, username, from, hasPassword}` and is written with an optional `password` (empty keeps the stored one). `system.*`, `sms.webhookToken` and `setup.completed` are read-only. References (DIDs, extensions, destinations) are checked, and a transport a trunk uses can't be turned off |
|
||||
| POST | `/api/settings/smtp/test` | `{to}` sends a test email |
|
||||
| POST | `/api/settings/emergency/test` | runs 04 §5.3 steps 2–4 with `TEST` in front (email, ring-out, banner); records nothing and dials nothing (06 §6.5). Returns `{notified}` |
|
||||
| GET | `/api/system/warnings` | `{items: [{code, message, link}]}` for the dashboard banner: `restart_required`, `e911_cid_missing` (a trunk exists, no default E911 caller ID), `e911_address_unconfirmed` (that DID has no `e911_address`), `smtp_missing`. "Trunk down" comes from live state in the browser |
|
||||
| POST | `/api/settings/network/detect-public-ip` | |
|
||||
| GET | `/api/system/status` | `{ db: ok, ami: connected, asteriskVersion, uptime, trunks: [...], disk: {free, total}, restartRequired, lastGeneratorRun }` |
|
||||
| GET | `/api/system/generated-files` | names + contents of generated files, **passwords masked** (debug view) |
|
||||
|
||||
@@ -32,6 +32,18 @@ export type TrunkLive = {
|
||||
lastChange: string;
|
||||
};
|
||||
|
||||
/** 911 was dialed (or the notifications were tested). */
|
||||
export type EmergencyCall = {
|
||||
ext: string;
|
||||
name: string;
|
||||
location: string | null;
|
||||
callerId: string;
|
||||
at: string;
|
||||
test: boolean;
|
||||
/** Non-admin users who get it too (their extension is a notify extension). */
|
||||
audienceUserIds: string[];
|
||||
};
|
||||
|
||||
export type LiveSnapshot = {
|
||||
extensions: ExtensionLive[];
|
||||
/** Admins only. */
|
||||
@@ -46,6 +58,7 @@ export type LiveMessage =
|
||||
| { type: 'extension.removed'; data: { number: string } }
|
||||
| { type: 'trunk.updated'; data: TrunkLive }
|
||||
| { type: 'trunk.removed'; data: { trunkId: string } }
|
||||
| { type: 'emergency.call'; data: EmergencyCall }
|
||||
| { type: 'ami.status'; data: { connected: boolean } }
|
||||
| {
|
||||
type: 'system.configApplied';
|
||||
|
||||
@@ -82,3 +82,24 @@ export const SettingsSchemas = {
|
||||
|
||||
export type SettingKey = keyof typeof SettingsSchemas;
|
||||
export type SettingValue<K extends SettingKey> = z.output<(typeof SettingsSchemas)[K]>;
|
||||
|
||||
/** PATCH /api/settings `smtp`: the password is write-only (empty keeps the stored one). */
|
||||
export const SmtpInputSchema = z.object({
|
||||
host: z.string().trim().min(1, 'Required'),
|
||||
port: z.number().int().min(1).max(65535),
|
||||
secure: z.boolean(),
|
||||
username: z.string().trim(),
|
||||
password: z.string().max(256).optional(),
|
||||
from: z.string().trim().min(3, 'Like "Phone system <pbx@example.com>"'),
|
||||
});
|
||||
export type SmtpInput = z.input<typeof SmtpInputSchema>;
|
||||
|
||||
/** GET /api/settings: every key, secrets replaced by hasPassword. */
|
||||
export type SettingsDto = {
|
||||
[K in Exclude<SettingKey, 'smtp' | 'sms.webhookToken'>]: SettingValue<K>;
|
||||
} & {
|
||||
smtp: (Omit<SmtpInput, 'password'> & { hasPassword: boolean }) | null;
|
||||
};
|
||||
|
||||
/** GET /api/system/warnings: shown as the dashboard banner (05 §2). */
|
||||
export type Warning = { code: string; message: string; link: string };
|
||||
Generated
+9
@@ -62,6 +62,9 @@ importers:
|
||||
fastify:
|
||||
specifier: ^5.12.7
|
||||
version: 5.12.7
|
||||
nodemailer:
|
||||
specifier: 10.0.11
|
||||
version: 10.0.11
|
||||
pg:
|
||||
specifier: ^8.23.1
|
||||
version: 8.23.1
|
||||
@@ -2091,6 +2094,10 @@ packages:
|
||||
resolution: {integrity: sha512-5qr2okC4ItAI0hG5SaV0HoYMpnM/Y6+Oqr7AmPYy/2uEoGvS+GbSKLeRxSS1uAdu59yz0ouzcuXievSgg///WA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
nodemailer@10.0.11:
|
||||
resolution: {integrity: sha512-/c4P7U7aGpiNu2Rl08q525q2zECkUpSaf2zQPgIStCcAjHAkkQQKwD4sTyi3l91mGcLQrKzL1syXPHymo+Ogtw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
obug@3.0.0:
|
||||
resolution: {integrity: sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==}
|
||||
engines: {node: '>=12.20.0'}
|
||||
@@ -4074,6 +4081,8 @@ snapshots:
|
||||
|
||||
node-releases@2.0.59: {}
|
||||
|
||||
nodemailer@10.0.11: {}
|
||||
|
||||
obug@3.0.0: {}
|
||||
|
||||
on-exit-leak-free@2.1.2: {}
|
||||
|
||||
@@ -34,7 +34,10 @@ that accepts one sub-account) on the m2-live network, creates the trunk in the U
|
||||
**Registered**, then presses Test. `m3-t2.mjs` (after `m3-t1.mjs`) adds a phone number routed to a user in the UI and
|
||||
checks `sp-inbound` in Asterisk, and that the extension can no longer be deleted. `m3-t3.mjs` (after
|
||||
both) checks the default outbound rules, the test box, a user's calling permission, and that
|
||||
reordering changes the include order in Asterisk.
|
||||
reordering changes the include order in Asterisk. `m3-t4.mjs` (after all three) runs a tiny SMTP sink on
|
||||
127.0.0.1:2525 and a baresip phone as 101, then checks the dashboard warnings, Settings → Email and
|
||||
Emergency, and the Test notifications button (email arrives, 101 rings, red banner). Nothing dials 911. The fake provider from `m3-t1.mjs` stays up until `m2-live.mjs down`: a container that took
|
||||
over its IP would be identified as the trunk.
|
||||
|
||||
`pnpm test:asterisk` (golden-file load test, `call-test.ts` with 30 s two-way audio between two
|
||||
baresip phones, and `trunk-test.ts` with registration against a fake provider) needs no api at all.
|
||||
@@ -60,7 +60,7 @@ if (cmd === 'up') {
|
||||
await sleep(500);
|
||||
console.log('asterisk up; AMI on 127.0.0.1:15038, secret m2-live-secret');
|
||||
} else if (cmd === 'down') {
|
||||
docker('rm', '-f', PHONES, AST);
|
||||
docker('rm', '-f', PHONES, AST, 'sp-live-provider', 'sp-live-phone101');
|
||||
docker('network', 'rm', NET);
|
||||
} else if (cmd === 'check') {
|
||||
const [base, email, pw, shotsDir] = args;
|
||||
|
||||
@@ -116,5 +116,6 @@ try {
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
await p.close();
|
||||
docker('rm', '-f', PROVIDER);
|
||||
// The provider keeps running (until m2-live.mjs down): if it went away, the next container
|
||||
// could get its IP and be taken for the trunk by the IP identify.
|
||||
}
|
||||
@@ -61,6 +61,10 @@ try {
|
||||
`[...document.querySelectorAll('select[aria-label="When someone calls this number: user"] option')].some(o => o.textContent.includes('Dan Doe'))`,
|
||||
'user list',
|
||||
);
|
||||
const danId = await p.evaluate(
|
||||
`[...document.querySelectorAll('select[aria-label="When someone calls this number: user"] option')].find(o => o.textContent.includes('Dan Doe')).value`,
|
||||
);
|
||||
await choose('When someone calls this number: user', danId);
|
||||
await p.shot('m3-t2-01-form');
|
||||
await p.evaluate(`document.querySelector('dialog button[form=did-form]').click(), true`);
|
||||
await p.waitFor(
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// M3-T4 in a browser: dashboard warnings, Settings → Email and Emergency, and the test button
|
||||
// (email arrives, the notify phone rings, the red banner shows). Nothing dials 911.
|
||||
// Run after m3-t1..t3 on the m2-live setup. Needs the api started with SMTP reachable at
|
||||
// 127.0.0.1:2525, which this script provides (a tiny SMTP sink).
|
||||
// Usage: node scripts/e2e/m3-t4.mjs <baseUrl> <adminEmail> <adminPassword> <shotsDir>
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
|
||||
import { createServer } from 'node:net';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { launch } from './cdp.mjs';
|
||||
|
||||
const NET = 'sp-live';
|
||||
const AST = 'sp-live-ast';
|
||||
const PHONE = 'sp-live-phone101';
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const docker = (...a) => {
|
||||
const r = spawnSync('docker', a, { encoding: 'utf8' });
|
||||
return { ok: r.status === 0, out: `${r.stdout ?? ''}${r.stderr ?? ''}` };
|
||||
};
|
||||
const cli = (cmd) => docker('exec', AST, 'asterisk', '-rx', cmd).out;
|
||||
const [base, email, pw, shotsDir] = process.argv.slice(2);
|
||||
|
||||
/** Just enough SMTP for nodemailer: no auth, no TLS, keeps every message. */
|
||||
const mails = [];
|
||||
const smtp = createServer((sock) => {
|
||||
let data = false;
|
||||
let buf = '';
|
||||
let msg = '';
|
||||
sock.write('220 sink ESMTP\r\n');
|
||||
sock.on('data', (d) => {
|
||||
buf += d;
|
||||
let i;
|
||||
while ((i = buf.indexOf('\r\n')) >= 0) {
|
||||
const line = buf.slice(0, i);
|
||||
buf = buf.slice(i + 2);
|
||||
if (data) {
|
||||
if (line === '.') {
|
||||
data = false;
|
||||
mails.push(msg);
|
||||
msg = '';
|
||||
sock.write('250 ok\r\n');
|
||||
} else msg += line + '\n';
|
||||
} else if (/^EHLO/i.test(line)) sock.write('250-sink\r\n250 OK\r\n');
|
||||
else if (/^DATA/i.test(line)) {
|
||||
data = true;
|
||||
sock.write('354 go\r\n');
|
||||
} else if (/^QUIT/i.test(line)) sock.end('221 bye\r\n');
|
||||
else sock.write('250 ok\r\n');
|
||||
}
|
||||
});
|
||||
});
|
||||
await new Promise((r) => smtp.listen(2525, '127.0.0.1', r));
|
||||
|
||||
const login = await fetch(`${base}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', 'x-requested-with': 'selfphoneng' },
|
||||
body: JSON.stringify({ email, password: pw }),
|
||||
});
|
||||
const cookie = login.headers.get('set-cookie').split(';')[0];
|
||||
const get = async (path) => (await fetch(`${base}${path}`, { headers: { cookie } })).json();
|
||||
|
||||
// A softphone registered as 101 (Dan, from m3-t2), to be the "ring these phones" target.
|
||||
const dan = (await get('/api/extensions')).items.find((e) => e.number === '101');
|
||||
const creds = await get(`/api/extensions/${dan.id}/sip-credentials`);
|
||||
const dir = mkdtempSync(join(tmpdir(), 'sp-phone101-'));
|
||||
mkdirSync(join(dir, 'p'));
|
||||
writeFileSync(
|
||||
join(dir, 'p', 'config'),
|
||||
'sip_listen 0.0.0.0:5070\nmodule_path /usr/lib/baresip/modules\nmodule g711.so\nmodule ausine.so\nmodule aufile.so\nmodule menu.so\nmodule account.so\naudio_source ausine,440\nausrc_srate 48000\nauplay_srate 48000\naudio_player aufile,/phone/p/heard.wav\naudio_alert aufile,/dev/null\n',
|
||||
);
|
||||
writeFileSync(
|
||||
join(dir, 'p', 'accounts'),
|
||||
`<sip:101@${AST};transport=udp>;auth_pass=${creds.password};answermode=auto;audio_codecs=PCMU\n`,
|
||||
);
|
||||
docker('rm', '-f', PHONE);
|
||||
assert.ok(
|
||||
docker(
|
||||
'run',
|
||||
'-d',
|
||||
'--rm',
|
||||
'--name',
|
||||
PHONE,
|
||||
'--network',
|
||||
NET,
|
||||
'-v',
|
||||
`${dir}:/phone`,
|
||||
'debian:trixie-slim',
|
||||
'sh',
|
||||
'-c',
|
||||
'apt-get update -qq >/dev/null && apt-get install -y -qq --no-install-recommends baresip-core >/dev/null 2>&1 && baresip -f /phone/p > /phone/p/log 2>&1',
|
||||
).ok,
|
||||
);
|
||||
|
||||
const p = await launch({ shotsDir });
|
||||
try {
|
||||
await p.goto(`${base}/login`);
|
||||
await p.waitFor(`!!document.querySelector('input[type=email]')`);
|
||||
await p.fill('Email', email);
|
||||
await p.fill('Password', pw);
|
||||
await p.click('Sign in');
|
||||
await p.waitFor(`location.pathname === '/admin'`);
|
||||
await p.waitFor(
|
||||
`document.body.innerText.includes('No emergency (E911) caller ID is set') && document.body.innerText.includes('Email is not set up')`,
|
||||
'dashboard warnings',
|
||||
);
|
||||
await p.shot('m3-t4-01-warnings');
|
||||
console.log('dashboard: E911 caller ID and email warnings');
|
||||
|
||||
// Email
|
||||
await p.click('Settings');
|
||||
await p.waitFor(`document.body.innerText.includes('SMTP server')`);
|
||||
await p.fill('SMTP server', '127.0.0.1');
|
||||
await p.fill('Port', '2525');
|
||||
await p.fill('From', 'Phone system <pbx@example.com>');
|
||||
await p.click('Save');
|
||||
await p.waitFor(`document.body.innerText.includes('Saved.')`, 'smtp saved');
|
||||
await p.fill('Send a test email to', 'admin@example.com');
|
||||
await p.click('Send test');
|
||||
await p.waitFor(`document.body.innerText.includes('Sent. Check the inbox')`, 'test email', 15000);
|
||||
assert.ok(
|
||||
mails.some((m) => m.includes('Subject: SelfPhoneNG test email')),
|
||||
'test email arrived',
|
||||
);
|
||||
console.log('email: test message received by the SMTP sink');
|
||||
|
||||
// Emergency
|
||||
await p.click('Emergency');
|
||||
await p.waitFor(`document.body.innerText.includes('Default emergency caller ID')`);
|
||||
const did = (await get('/api/dids')).items[0];
|
||||
await p.waitFor(`!!document.querySelector('option[value="${did.id}"]')`, 'phone numbers loaded');
|
||||
await p.fill('Default emergency caller ID', did.id);
|
||||
await p.fill('Email these people', 'security@example.com');
|
||||
await p.evaluate(
|
||||
`[...document.querySelectorAll('label')].find(l => l.textContent.includes('101 Dan Doe')).querySelector('input').click(), true`,
|
||||
);
|
||||
await p.click('Save');
|
||||
await p.waitFor(`document.body.innerText.includes('Saved.')`, 'emergency saved');
|
||||
|
||||
for (let i = 0; i < 120 && !/101\/sip:.*Avail/.test(cli('pjsip show contacts')); i++)
|
||||
await sleep(1000);
|
||||
assert.match(cli('pjsip show contacts'), /101\/sip:.*Avail/, 'phone 101 registered');
|
||||
const before = mails.length;
|
||||
await p.click('Test notifications');
|
||||
await p.waitFor(`document.body.innerText.includes('Rang 101: ok')`, 'test result', 20000);
|
||||
await p.waitFor(
|
||||
`document.body.innerText.includes('TEST of the 911 alerts (Administrator)')`,
|
||||
'red banner',
|
||||
);
|
||||
let rang = '';
|
||||
for (let i = 0; i < 30 && !/sp-emergency-alert/.test(rang); i++) {
|
||||
await sleep(300);
|
||||
rang = cli('core show channels concise');
|
||||
}
|
||||
assert.match(rang, /^PJSIP\/101-.*sp-emergency-alert/m, '101 answered the alert ring-out');
|
||||
for (let i = 0; i < 30 && mails.length === before; i++) await sleep(200);
|
||||
const alert = mails.slice(before).find((m) => /Subject: TEST EMERGENCY: 911 dialed/.test(m));
|
||||
assert.ok(alert, 'alert email arrived');
|
||||
assert.ok(
|
||||
!/to=911|Dial\(PJSIP\/911/.test(cli('core show channels concise')),
|
||||
'nothing dialed 911',
|
||||
);
|
||||
await p.shot('m3-t4-02-test-notifications');
|
||||
console.log('test button: email + ring-out on 101 + red banner, nothing dialed');
|
||||
|
||||
// The dashboard now asks to confirm the E911 address of that number.
|
||||
await p.click('Dismiss');
|
||||
await p.click('Dashboard');
|
||||
await p.waitFor(
|
||||
`document.body.innerText.includes('Confirm the emergency caller ID number has an E911 address')`,
|
||||
'address warning',
|
||||
);
|
||||
assert.ok(!(await p.evaluate('document.body.innerText')).includes('Email is not set up'));
|
||||
await p.shot('m3-t4-03-dashboard');
|
||||
assert.deepEqual(p.consoleErrors, [], 'no console errors');
|
||||
console.log('M3-T4 (UI): PASS');
|
||||
} catch (e) {
|
||||
await p.shot('m3-t4-FAILED').catch(() => {});
|
||||
console.error('FAIL:', e.message, '\nconsole:', p.consoleErrors);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
await p.close();
|
||||
docker('rm', '-f', PHONE);
|
||||
smtp.close();
|
||||
}
|
||||
Reference in new issue
Block a user