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
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user