destinations: full picker everywhere, numbers in summaries, Ring everyone
The DestinationPicker now offers every type that exists (user, voicemail,
ring group, digital receptionist, office hours, announcement, outside
number, hang up) on every page that routes calls; queues and
conferences join in M8. API summaries show the number of dialable
targets ("Ring group Sales (500)", 05 §3). Wizard step 8 gains "Ring
everyone": it creates or refreshes a ring group "Everyone" of all
extensions (unanswered → the first user's voicemail); an IVR isn't
created there because it needs a recorded greeting. m3-t5 now routes
the main number through "Ring everyone" and still passes.
Task: M6-T6
This commit is contained in:
1 parent
dd640b368b
commit
d746772b97
7 files changed
+72
-22
No files matched your search
@@ -40,8 +40,10 @@ async function targetName(db: Db, type: RefType, id: string): Promise<string | n
|
|||||||
return e ? `${e.name} (${e.number})` : null;
|
return e ? `${e.name} (${e.number})` : null;
|
||||||
}
|
}
|
||||||
const t = TABLES[type];
|
const t = TABLES[type];
|
||||||
const [r] = await db.select({ name: t.name }).from(t).where(eq(t.id, id));
|
const [r] = await db.select().from(t).where(eq(t.id, id));
|
||||||
return r?.name ?? null;
|
if (!r) return null;
|
||||||
|
// Things you can dial show their number too: "Ring group Sales (500)" (05 §3).
|
||||||
|
return 'number' in r ? `${r.name} (${r.number})` : r.name;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Throws 400 when a destination points at something that doesn't exist. */
|
/** Throws 400 when a destination points at something that doesn't exist. */
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ describe.skipIf(!hasTestDb)('/api/ivrs', () => {
|
|||||||
destination: { type: 'ivr', id: ivrId },
|
destination: { type: 'ivr', id: ivrId },
|
||||||
})
|
})
|
||||||
).json();
|
).json();
|
||||||
expect(did.destinationSummary).toBe('Digital receptionist Main menu');
|
expect(did.destinationSummary).toBe('Digital receptionist Main menu (600)');
|
||||||
expect((await req('DELETE', `/api/ivrs/${ivrId}`)).statusCode).toBe(409);
|
expect((await req('DELETE', `/api/ivrs/${ivrId}`)).statusCode).toBe(409);
|
||||||
// The refused delete must not have dropped its options.
|
// The refused delete must not have dropped its options.
|
||||||
expect((await req('GET', `/api/ivrs/${ivrId}`)).json().options).toHaveLength(1);
|
expect((await req('GET', `/api/ivrs/${ivrId}`)).json().options).toHaveLength(1);
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ describe.skipIf(!hasTestDb)('/api/ring-groups', () => {
|
|||||||
trunkId,
|
trunkId,
|
||||||
destination: { type: 'ring_group', id: groupId },
|
destination: { type: 'ring_group', id: groupId },
|
||||||
});
|
});
|
||||||
expect(did.json().destinationSummary).toBe('Ring group Sales');
|
expect(did.json().destinationSummary).toBe('Ring group Sales (500)');
|
||||||
expect(await dialplan()).toContain(`Goto(sp-rg-${groupId},s,1)`);
|
expect(await dialplan()).toContain(`Goto(sp-rg-${groupId},s,1)`);
|
||||||
const busy = await req('DELETE', `/api/ring-groups/${groupId}`);
|
const busy = await req('DELETE', `/api/ring-groups/${groupId}`);
|
||||||
expect(busy.statusCode).toBe(409);
|
expect(busy.statusCode).toBe(409);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
// "Where should the call go?" (05 §3): a type, then the target (or a number).
|
// "Where should the call go?" (05 §3): a type, then the target (or a number).
|
||||||
// Types join as their features land; M6-T6 finishes the list.
|
// Every type that exists (M6-T6); queues and conferences join with M8.
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useId, useState } from 'react';
|
import { useId, useState } from 'react';
|
||||||
import { normalizeNumber, type Destination } from '@selfphoneng/shared';
|
import { normalizeNumber, type Destination } from '@selfphoneng/shared';
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
// First-run setup wizard (05 §2.1). Every step saves before moving on; some can be skipped.
|
// First-run setup wizard (05 §2.1). Every step saves before moving on; some can be skipped.
|
||||||
// M3 also brings steps 7 (users) and 8 (main number routing) because "calls work using only the
|
// M3 also brings steps 7 (users) and 8 (main number routing) because "calls work using only the
|
||||||
// wizard" needs them; step 8's ring group and IVR choices arrive with M6.
|
// wizard" needs them; M6 adds "Ring everyone" to step 8 (an IVR needs a recorded greeting first).
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useEffect, useState, type ReactNode } from 'react';
|
import { useEffect, useState, type ReactNode } from 'react';
|
||||||
import { useNavigate, useSearchParams } from 'react-router';
|
import { useNavigate, useSearchParams } from 'react-router';
|
||||||
@@ -9,6 +9,7 @@ import { formatNumber, type Destination, type TrunkDto } from '@selfphoneng/shar
|
|||||||
import {
|
import {
|
||||||
didsApi,
|
didsApi,
|
||||||
extensionsApi,
|
extensionsApi,
|
||||||
|
ringGroupsApi,
|
||||||
settingsApi,
|
settingsApi,
|
||||||
systemApi,
|
systemApi,
|
||||||
trunksApi,
|
trunksApi,
|
||||||
@@ -619,6 +620,26 @@ function RoutingStep({ next }: { next: () => void }) {
|
|||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const dids = useQuery({ queryKey: ['dids'], queryFn: didsApi.list });
|
const dids = useQuery({ queryKey: ['dids'], queryFn: didsApi.list });
|
||||||
const [dest, setDest] = useState<Record<string, Destination | null>>({});
|
const [dest, setDest] = useState<Record<string, Destination | null>>({});
|
||||||
|
/** A ring group "Everyone" with every extension (made once, kept up to date). */
|
||||||
|
const everyone = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const exts = (await extensionsApi.list()).sort((a, b) => Number(a.number) - Number(b.number));
|
||||||
|
if (!exts.length) throw new Error('Add users with extensions first (step 7)');
|
||||||
|
const memberIds = exts.map((e) => e.id);
|
||||||
|
const existing = (await ringGroupsApi.list()).find((g) => g.name === 'Everyone');
|
||||||
|
const g = existing
|
||||||
|
? await ringGroupsApi.update(existing.id, { memberIds })
|
||||||
|
: await ringGroupsApi.create({
|
||||||
|
number: (await extensionsApi.nextNumber('ringGroup')) ?? '500',
|
||||||
|
name: 'Everyone',
|
||||||
|
memberIds,
|
||||||
|
// Nobody picks up: the first person's voicemail rather than a dead end.
|
||||||
|
noAnswerDestination: { type: 'voicemail', id: exts[0]!.id },
|
||||||
|
});
|
||||||
|
await qc.invalidateQueries({ queryKey: ['ring-groups'] });
|
||||||
|
return g;
|
||||||
|
},
|
||||||
|
});
|
||||||
const save = useMutation({
|
const save = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
for (const [id, d] of Object.entries(dest))
|
for (const [id, d] of Object.entries(dest))
|
||||||
@@ -640,14 +661,38 @@ function RoutingStep({ next }: { next: () => void }) {
|
|||||||
const incomplete = Object.values(dest).some((d) => d === null);
|
const incomplete = Object.values(dest).some((d) => d === null);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{dids.data.map((d) => (
|
<p className="text-xs text-zinc-500">
|
||||||
<DestinationPicker
|
A digital receptionist ("press 1 for sales…") needs a recorded greeting: build it under
|
||||||
key={d.id}
|
Digital receptionist later and pick it here or on the Phone numbers page.
|
||||||
label={`When someone calls ${formatNumber(d.number)}${d.label ? ` (${d.label})` : ''}, ring:`}
|
</p>
|
||||||
value={dest[d.id] ?? d.destination}
|
{dids.data.map((d) => {
|
||||||
onChange={(v) => setDest({ ...dest, [d.id]: v })}
|
const value = dest[d.id] ?? d.destination;
|
||||||
/>
|
return (
|
||||||
))}
|
<div key={d.id} className="flex flex-col gap-2">
|
||||||
|
<DestinationPicker
|
||||||
|
// Re-mount when "Ring everyone" sets the value from outside.
|
||||||
|
key={JSON.stringify(value)}
|
||||||
|
label={`When someone calls ${formatNumber(d.number)}${d.label ? ` (${d.label})` : ''}, ring:`}
|
||||||
|
value={value}
|
||||||
|
onChange={(v) => setDest({ ...dest, [d.id]: v })}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
disabled={everyone.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
everyone.mutate(undefined, {
|
||||||
|
onSuccess: (g) =>
|
||||||
|
setDest({ ...dest, [d.id]: { type: 'ring_group', id: g.id } }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Ring everyone
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{everyone.isError && <Alert>{everyone.error.message}</Alert>}
|
||||||
{save.isError && <Alert>{save.error.message}</Alert>}
|
{save.isError && <Alert>{save.error.message}</Alert>}
|
||||||
<div className="border-t border-zinc-200 pt-4 dark:border-zinc-800">
|
<div className="border-t border-zinc-200 pt-4 dark:border-zinc-800">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -73,7 +73,10 @@ Implementation notes (M3):
|
|||||||
(`POST /api/system/asterisk/restart?now=true`). `restartRequired` clears on the next AMI
|
(`POST /api/system/asterisk/restart?now=true`). `restartRequired` clears on the next AMI
|
||||||
connect once Asterisk's uptime shows it started after the change.
|
connect once Asterisk's uptime shows it started after the change.
|
||||||
- M3 also ships steps 7 and 8 because "calls work using only the wizard" needs a user and a
|
- M3 also ships steps 7 and 8 because "calls work using only the wizard" needs a user and a
|
||||||
routed number. Step 8 offers a user or an outside number; "everyone" and "an IVR" arrive in M6.
|
routed number. Step 8 has the full DestinationPicker plus a **Ring everyone** button (M6-T6), which
|
||||||
|
creates or refreshes a ring group "Everyone" with every extension (unanswered → the
|
||||||
|
lowest-numbered user's voicemail). It does not create an IVR: that needs a recorded greeting,
|
||||||
|
so the step points to Digital receptionist instead.
|
||||||
Step 10 does not mention *43 or the softphone until M6/M5 build them.
|
Step 10 does not mention *43 or the softphone until M6/M5 build them.
|
||||||
- The first phone number added becomes `routing.defaultCallerIdDidId` when none is set.
|
- The first phone number added becomes `routing.defaultCallerIdDidId` when none is set.
|
||||||
|
|
||||||
@@ -82,6 +85,9 @@ Implementation notes (M3):
|
|||||||
- **DestinationPicker**: a type select (User, Voicemail of user, Ring group, Queue,
|
- **DestinationPicker**: a type select (User, Voicemail of user, Ring group, Queue,
|
||||||
IVR, Office hours, Conference, Announcement, External number, Hang up), then a
|
IVR, Office hours, Conference, Announcement, External number, Hang up), then a
|
||||||
searchable target select or a number input. The value is a `Destination` object (02 §1).
|
searchable target select or a number input. The value is a `Destination` object (02 §1).
|
||||||
|
As built (M6-T6): every type that exists so far (queues and conferences join in M8); the target
|
||||||
|
is a native select, which is searchable by typing. API summaries include the number where there
|
||||||
|
is one: "Ring group Sales (500)".
|
||||||
The read-only display renders "→ Ring group Sales (500)".
|
The read-only display renders "→ Ring group Sales (500)".
|
||||||
- **SoundPicker**: select + play button + "upload new" shortcut.
|
- **SoundPicker**: select + play button + "upload new" shortcut.
|
||||||
- **ExtensionPicker** (single/multi, orderable via drag handle).
|
- **ExtensionPicker** (single/multi, orderable via drag handle).
|
||||||
|
|||||||
@@ -195,17 +195,14 @@ try {
|
|||||||
await p.waitFor(`document.body.innerText.includes('✓ ${ext} Dan Doe')`);
|
await p.waitFor(`document.body.innerText.includes('✓ ${ext} Dan Doe')`);
|
||||||
await clickNext();
|
await clickNext();
|
||||||
|
|
||||||
// 8. Main number → Dan
|
// 8. Main number → "Ring everyone" (M6: a ring group of all users; Dan is the only one)
|
||||||
await step(8, 'Main number');
|
await step(8, 'Main number');
|
||||||
const label = 'When someone calls (312) 555-1234 (Main line), ring:';
|
const label = 'When someone calls (312) 555-1234 (Main line), ring:';
|
||||||
await choose(`${label}: type`, 'user');
|
await p.click('Ring everyone');
|
||||||
await p.waitFor(
|
await p.waitFor(
|
||||||
`[...document.querySelectorAll('select[aria-label="${label}: user"] option')].some(o => o.textContent.includes('Dan Doe'))`,
|
`document.querySelector('select[aria-label="${label}: type"]')?.value === 'ring_group' && document.querySelector('select[aria-label="${label}: ring group"]')?.selectedOptions[0]?.textContent.includes('Everyone')`,
|
||||||
|
'ring group Everyone picked',
|
||||||
);
|
);
|
||||||
const danId = await p.evaluate(
|
|
||||||
`[...document.querySelectorAll('select[aria-label="${label}: user"] option')].find(o => o.textContent.includes('Dan Doe')).value`,
|
|
||||||
);
|
|
||||||
await choose(`${label}: user`, danId);
|
|
||||||
await p.click('Save and continue');
|
await p.click('Save and continue');
|
||||||
|
|
||||||
// 9. Emergency: Next stays locked until a default E911 caller ID is saved.
|
// 9. Emergency: Next stays locked until a default E911 caller ID is saved.
|
||||||
|
|||||||
Reference in new issue
Block a user