dids: phone numbers, destinations, inbound dialplan, Phone numbers page
DIDs CRUD (any input format, normalized; 409 on duplicates; a number
that is an emergency or default caller ID can't be removed). Shared
destination service: assertDestinationExists, describeDestination
("Ring Dan Doe (101)") and findReferences over every destination
column in 02 §1 plus the unmatched-number setting; deleting an
extension a destination points at is now a 409 naming the places.
Generator: destApp (user, external, hangup for now), sp-from-trunk-*
per enabled trunk, sp-did-exists (left out when empty) and sp-inbound
with caller-name prefix. Golden case voipms-basic. Phone numbers page
with a minimal DestinationPicker (user, outside number, hang up).
trunk-test.ts: the fake provider calls the DID with a 10-digit
Request-URI like voip.ms; baresip 101 answers, caller ID arrives
normalized, 500 packets each way on both legs. scripts/e2e/m3-t2.mjs
checks the UI → Asterisk path. Still to do with real voip.ms: the
mobile-to-DID call and recording which header carries the DID (06 §3).
Task: M3-T2
This commit is contained in:
1 parent
1b7f60b5f2
commit
c117d8bb1d
28 files changed
+1371
-21
No files matched your search
@@ -13,7 +13,7 @@ export const ADMIN_PAGES = [
|
||||
{ path: '', label: 'Dashboard', milestone: null },
|
||||
{ path: 'users', label: 'Users & Extensions', milestone: null },
|
||||
{ path: 'trunks', label: 'Trunks', milestone: null },
|
||||
{ path: 'numbers', label: 'Phone numbers', milestone: 'M3' },
|
||||
{ path: 'numbers', label: 'Phone numbers', milestone: null },
|
||||
{ path: 'outbound', label: 'Outbound rules', milestone: 'M3' },
|
||||
{ path: 'ring-groups', label: 'Ring groups', milestone: 'M6' },
|
||||
{ path: 'queues', label: 'Queues', milestone: 'M8' },
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Phone numbers (DIDs) and where their calls go (05 §2, 06 §3).
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
import { DestinationSchema, formatNumber, type DidDto } from '@selfphoneng/shared';
|
||||
import { didsApi, trunksApi } from '../../api/client.ts';
|
||||
import { ConfirmDialog, Dialog } from '../../components/Dialog.tsx';
|
||||
import { DestinationPicker } from '../../components/DestinationPicker.tsx';
|
||||
import { Alert, Button, Field, inputClass } from '../../components/ui.tsx';
|
||||
|
||||
export function NumbersPage() {
|
||||
const qc = useQueryClient();
|
||||
const dids = useQuery({ queryKey: ['dids'], queryFn: didsApi.list });
|
||||
const trunks = useQuery({ queryKey: ['trunks'], queryFn: trunksApi.list });
|
||||
const [editing, setEditing] = useState<DidDto | 'new' | null>(null);
|
||||
const [deleting, setDeleting] = useState<DidDto | null>(null);
|
||||
const refresh = () => qc.invalidateQueries({ queryKey: ['dids'] });
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: string) => didsApi.remove(id),
|
||||
onSuccess: () => {
|
||||
setDeleting(null);
|
||||
void refresh();
|
||||
},
|
||||
});
|
||||
const trunkName = (id: string) => trunks.data?.find((t) => t.id === id)?.name ?? '–';
|
||||
const noTrunk = trunks.data?.length === 0;
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h1 className="text-xl font-semibold">Phone numbers</h1>
|
||||
<Button variant="primary" onClick={() => setEditing('new')} disabled={noTrunk}>
|
||||
Add number
|
||||
</Button>
|
||||
</div>
|
||||
{noTrunk && (
|
||||
<p className="text-sm text-zinc-500">Add a trunk first: numbers arrive through one.</p>
|
||||
)}
|
||||
{dids.isError && <Alert>{dids.error.message}</Alert>}
|
||||
{dids.isPending && <p className="text-sm text-zinc-500">Loading…</p>}
|
||||
{dids.data?.length === 0 && !noTrunk && (
|
||||
<p className="text-sm text-zinc-500">
|
||||
No numbers yet. Add the numbers (DIDs) you bought at voip.ms.
|
||||
</p>
|
||||
)}
|
||||
{dids.data && dids.data.length > 0 && (
|
||||
<ul className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
|
||||
{dids.data.map((d) => (
|
||||
<li
|
||||
key={d.id}
|
||||
className="flex flex-wrap items-center gap-x-3 gap-y-1 px-3 py-2 text-sm"
|
||||
>
|
||||
<span className="font-mono">{formatNumber(d.number)}</span>
|
||||
{d.label && <span className="text-zinc-500">{d.label}</span>}
|
||||
<span className="flex-1 truncate">→ {d.destinationSummary}</span>
|
||||
<span className="hidden text-zinc-500 sm:inline">{trunkName(d.trunkId)}</span>
|
||||
<Button onClick={() => setEditing(d)}>Edit</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
remove.reset();
|
||||
setDeleting(d);
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{editing && trunks.data && (
|
||||
<DidDrawer
|
||||
did={editing === 'new' ? null : editing}
|
||||
trunks={trunks.data.map((t) => ({ id: t.id, name: t.name }))}
|
||||
onClose={() => setEditing(null)}
|
||||
onSaved={() => {
|
||||
setEditing(null);
|
||||
void refresh();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<ConfirmDialog
|
||||
open={deleting !== null}
|
||||
title="Remove number"
|
||||
message={
|
||||
<p>
|
||||
Remove <strong>{deleting && formatNumber(deleting.number)}</strong>? Calls to it will no
|
||||
longer be answered here. It stays yours at voip.ms.
|
||||
</p>
|
||||
}
|
||||
confirmLabel="Remove"
|
||||
busy={remove.isPending}
|
||||
error={remove.error?.message}
|
||||
onCancel={() => setDeleting(null)}
|
||||
onConfirm={() => deleting && remove.mutate(deleting.id)}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const FormSchema = z.object({
|
||||
number: z.string().trim().min(1, 'Required'),
|
||||
label: z.string().trim().max(60),
|
||||
trunkId: z.string().min(1, 'Pick a trunk'),
|
||||
destination: DestinationSchema.nullable().refine((d) => d !== null, 'Finish choosing'),
|
||||
callerIdNamePrefix: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(12)
|
||||
.regex(/^[A-Za-z0-9 :_.#-]*$/, 'Letters, digits, spaces and : _ . # - only'),
|
||||
e911Address: z.string().trim().max(200),
|
||||
});
|
||||
type FormValues = z.input<typeof FormSchema>;
|
||||
|
||||
function DidDrawer({
|
||||
did,
|
||||
trunks,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
did: DidDto | null;
|
||||
trunks: { id: string; name: string }[];
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const [error, setError] = useState<string>();
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(FormSchema),
|
||||
defaultValues: {
|
||||
number: did ? formatNumber(did.number) : '',
|
||||
label: did?.label ?? '',
|
||||
trunkId: did?.trunkId ?? trunks[0]?.id ?? '',
|
||||
destination: did?.destination ?? { type: 'hangup' },
|
||||
callerIdNamePrefix: did?.callerIdNamePrefix ?? '',
|
||||
e911Address: did?.e911Address ?? '',
|
||||
},
|
||||
});
|
||||
const { errors } = form.formState;
|
||||
|
||||
const onSubmit = form.handleSubmit(async (v) => {
|
||||
setError(undefined);
|
||||
const body = {
|
||||
number: v.number,
|
||||
trunkId: v.trunkId,
|
||||
label: v.label || null,
|
||||
destination: v.destination!,
|
||||
callerIdNamePrefix: v.callerIdNamePrefix || null,
|
||||
e911Address: v.e911Address || null,
|
||||
};
|
||||
try {
|
||||
if (did) await didsApi.update(did.id, body);
|
||||
else await didsApi.create(body);
|
||||
onSaved();
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
drawer
|
||||
onClose={onClose}
|
||||
title={did ? `Edit ${formatNumber(did.number)}` : 'Add number'}
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<Button variant="primary" type="submit" form="did-form">
|
||||
{did ? 'Save' : 'Add number'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="did-form" onSubmit={onSubmit} className="flex flex-col gap-4" noValidate>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<Field label="Phone number" error={errors.number?.message}>
|
||||
{(a) => (
|
||||
<input
|
||||
{...a}
|
||||
{...form.register('number')}
|
||||
inputMode="tel"
|
||||
placeholder="(312) 555-1234"
|
||||
className={inputClass}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Label" error={errors.label?.message} hint="For example: Main line.">
|
||||
{(a) => <input {...a} {...form.register('label')} className={inputClass} />}
|
||||
</Field>
|
||||
<Field label="Trunk" error={errors.trunkId?.message}>
|
||||
{(a) => (
|
||||
<select {...a} {...form.register('trunkId')} className={inputClass}>
|
||||
{trunks.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</Field>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="destination"
|
||||
render={({ field, fieldState }) => (
|
||||
<DestinationPicker
|
||||
label="When someone calls this number"
|
||||
value={field.value ?? { type: 'hangup' }}
|
||||
onChange={field.onChange}
|
||||
error={fieldState.error?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Field
|
||||
label="Caller name prefix"
|
||||
error={errors.callerIdNamePrefix?.message}
|
||||
hint="Shown before the caller's name, e.g. SALES: so people know which number was called."
|
||||
>
|
||||
{(a) => <input {...a} {...form.register('callerIdNamePrefix')} className={inputClass} />}
|
||||
</Field>
|
||||
<Field
|
||||
label="E911 address (for your notes)"
|
||||
error={errors.e911Address?.message}
|
||||
hint="The address registered for this number at voip.ms (DID → e911). Emergency services get the address stored there, not this one."
|
||||
>
|
||||
{(a) => <input {...a} {...form.register('e911Address')} className={inputClass} />}
|
||||
</Field>
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user