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
@@ -2,6 +2,9 @@
|
||||
// Typed fetch wrappers (05 §1). Every request sends X-Requested-With (CSRF, 04 §2.2).
|
||||
import type {
|
||||
AuthUser,
|
||||
DidCreate,
|
||||
DidDto,
|
||||
DidUpdate,
|
||||
ExtensionCreate,
|
||||
ExtensionDto,
|
||||
ExtensionUpdate,
|
||||
@@ -67,6 +70,7 @@ export const usersApi = {
|
||||
};
|
||||
|
||||
export const extensionsApi = {
|
||||
list: () => api<ListResponse<ExtensionDto>>('GET', '/api/extensions').then((r) => r.items),
|
||||
get: (id: string) => api<ExtensionDto>('GET', `/api/extensions/${id}`),
|
||||
create: (e: ExtensionCreate) => api<ExtensionDto>('POST', '/api/extensions', e),
|
||||
update: (id: string, e: ExtensionUpdate) =>
|
||||
@@ -86,3 +90,10 @@ export const trunksApi = {
|
||||
remove: (id: string) => api<{ ok: true }>('DELETE', `/api/trunks/${id}`),
|
||||
test: (id: string) => api<TrunkTestResult>('POST', `/api/trunks/${id}/test`),
|
||||
};
|
||||
|
||||
export const didsApi = {
|
||||
list: () => api<ListResponse<DidDto>>('GET', '/api/dids').then((r) => r.items),
|
||||
create: (d: DidCreate) => api<DidDto>('POST', '/api/dids', d),
|
||||
update: (id: string, d: DidUpdate) => api<DidDto>('PATCH', `/api/dids/${id}`, d),
|
||||
remove: (id: string) => api<{ ok: true }>('DELETE', `/api/dids/${id}`),
|
||||
};
|
||||
@@ -0,0 +1,98 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// "Where should the call go?" (05 §3). M3: a user, an outside number, or hang up. Voicemail (M4),
|
||||
// ring groups, queues, IVRs, office hours, conferences and announcements join later (M6-T6).
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useId, useState } from 'react';
|
||||
import { normalizeNumber, type Destination } from '@selfphoneng/shared';
|
||||
import { extensionsApi } from '../api/client.ts';
|
||||
import { inputClass } from './ui.tsx';
|
||||
|
||||
type Kind = 'user' | 'voicemail' | 'external' | 'hangup';
|
||||
|
||||
export function DestinationPicker({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
error,
|
||||
}: {
|
||||
label: string;
|
||||
value: Destination;
|
||||
onChange: (d: Destination | null) => void; // null = incomplete (e.g. half-typed number)
|
||||
error?: string;
|
||||
}) {
|
||||
const id = useId();
|
||||
const exts = useQuery({ queryKey: ['extensions'], queryFn: extensionsApi.list });
|
||||
const [kind, setKind] = useState<Kind>(value.type as Kind);
|
||||
const [number, setNumber] = useState(value.type === 'external' ? value.number : '');
|
||||
const [numberError, setNumberError] = useState<string>();
|
||||
const extId = value.type === 'user' || value.type === 'voicemail' ? value.id : '';
|
||||
|
||||
function pickKind(k: Kind) {
|
||||
setKind(k);
|
||||
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);
|
||||
}
|
||||
|
||||
function typed(v: string) {
|
||||
setNumber(v);
|
||||
try {
|
||||
onChange({ type: 'external', number: normalizeNumber(v) });
|
||||
setNumberError(undefined);
|
||||
} catch {
|
||||
onChange(null);
|
||||
setNumberError(v ? 'A phone number like (312) 555-1234' : undefined);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<fieldset className="flex flex-col gap-2" aria-describedby={error ? `${id}-err` : undefined}>
|
||||
<legend className="mb-1 text-sm font-medium">{label}</legend>
|
||||
<select
|
||||
aria-label={`${label}: type`}
|
||||
value={kind}
|
||||
onChange={(e) => pickKind(e.target.value as Kind)}
|
||||
className={inputClass}
|
||||
>
|
||||
<option value="user">Ring a user</option>
|
||||
<option value="external">Forward to an outside number</option>
|
||||
<option value="hangup">Hang up</option>
|
||||
</select>
|
||||
{(kind === 'user' || kind === 'voicemail') && (
|
||||
<select
|
||||
aria-label={`${label}: user`}
|
||||
value={extId}
|
||||
onChange={(e) => onChange({ type: kind, id: e.target.value })}
|
||||
className={inputClass}
|
||||
>
|
||||
{!extId && <option value="">Pick a user…</option>}
|
||||
{exts.data?.map((x) => (
|
||||
<option key={x.id} value={x.id}>
|
||||
{x.number} {x.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{kind === 'external' && (
|
||||
<input
|
||||
aria-label={`${label}: number`}
|
||||
value={number}
|
||||
onChange={(e) => typed(e.target.value)}
|
||||
inputMode="tel"
|
||||
placeholder="(312) 555-1234"
|
||||
aria-invalid={Boolean(numberError)}
|
||||
className={inputClass}
|
||||
/>
|
||||
)}
|
||||
{(numberError || error) && (
|
||||
<p id={`${id}-err`} className="text-xs text-red-600 dark:text-red-400">
|
||||
{numberError ?? error}
|
||||
</p>
|
||||
)}
|
||||
{exts.data?.length === 0 && (kind === 'user' || kind === 'voicemail') && (
|
||||
<p className="text-xs text-zinc-500">No users with an extension yet.</p>
|
||||
)}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { ADMIN_PAGES, AdminLayout, ComingSoon } from './pages/admin/AdminLayout.
|
||||
import { UsersPage } from './pages/admin/UsersPage.tsx';
|
||||
import { DashboardPage } from './pages/admin/DashboardPage.tsx';
|
||||
import { TrunksPage } from './pages/admin/TrunksPage.tsx';
|
||||
import { NumbersPage } from './pages/admin/NumbersPage.tsx';
|
||||
import { MePage } from './pages/user/MePage.tsx';
|
||||
import './index.css';
|
||||
|
||||
@@ -27,6 +28,8 @@ const router = createBrowserRouter([
|
||||
<DashboardPage />
|
||||
) : p.path === 'users' ? (
|
||||
<RequireAuth>{(user) => <UsersPage currentUserId={user.id} />}</RequireAuth>
|
||||
) : p.path === 'numbers' ? (
|
||||
<NumbersPage />
|
||||
) : p.path === 'trunks' ? (
|
||||
<TrunksPage />
|
||||
) : (
|
||||
|
||||
@@ -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