From d746772b976ea6f07baf76cc496332aaba7a2fde Mon Sep 17 00:00:00 2001 From: iamdoubz Date: Sun, 18 Oct 2026 11:04:42 -0500 Subject: [PATCH] destinations: full picker everywhere, numbers in summaries, Ring everyone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/api/src/services/destinations.ts | 6 +- apps/api/test/ivrs.test.ts | 2 +- apps/api/test/ringgroups.test.ts | 2 +- apps/web/src/components/DestinationPicker.tsx | 2 +- apps/web/src/pages/SetupPage.tsx | 63 ++++++++++++++++--- docs/spec/05-web-ui.md | 8 ++- scripts/e2e/m3-t5.mjs | 11 ++-- 7 files changed, 72 insertions(+), 22 deletions(-) diff --git a/apps/api/src/services/destinations.ts b/apps/api/src/services/destinations.ts index 262c3bd..05209d8 100644 --- a/apps/api/src/services/destinations.ts +++ b/apps/api/src/services/destinations.ts @@ -40,8 +40,10 @@ async function targetName(db: Db, type: RefType, id: string): Promise { destination: { type: 'ivr', id: ivrId }, }) ).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); // The refused delete must not have dropped its options. expect((await req('GET', `/api/ivrs/${ivrId}`)).json().options).toHaveLength(1); diff --git a/apps/api/test/ringgroups.test.ts b/apps/api/test/ringgroups.test.ts index 9ec62fd..363b445 100644 --- a/apps/api/test/ringgroups.test.ts +++ b/apps/api/test/ringgroups.test.ts @@ -113,7 +113,7 @@ describe.skipIf(!hasTestDb)('/api/ring-groups', () => { trunkId, 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)`); const busy = await req('DELETE', `/api/ring-groups/${groupId}`); expect(busy.statusCode).toBe(409); diff --git a/apps/web/src/components/DestinationPicker.tsx b/apps/web/src/components/DestinationPicker.tsx index 21c0437..b805058 100644 --- a/apps/web/src/components/DestinationPicker.tsx +++ b/apps/web/src/components/DestinationPicker.tsx @@ -1,6 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later // "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 { useId, useState } from 'react'; import { normalizeNumber, type Destination } from '@selfphoneng/shared'; diff --git a/apps/web/src/pages/SetupPage.tsx b/apps/web/src/pages/SetupPage.tsx index 51096e4..c48db5f 100644 --- a/apps/web/src/pages/SetupPage.tsx +++ b/apps/web/src/pages/SetupPage.tsx @@ -1,7 +1,7 @@ // 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. // 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 { useEffect, useState, type ReactNode } from 'react'; import { useNavigate, useSearchParams } from 'react-router'; @@ -9,6 +9,7 @@ import { formatNumber, type Destination, type TrunkDto } from '@selfphoneng/shar import { didsApi, extensionsApi, + ringGroupsApi, settingsApi, systemApi, trunksApi, @@ -619,6 +620,26 @@ function RoutingStep({ next }: { next: () => void }) { const qc = useQueryClient(); const dids = useQuery({ queryKey: ['dids'], queryFn: didsApi.list }); const [dest, setDest] = useState>({}); + /** 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({ mutationFn: async () => { 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); return ( <> - {dids.data.map((d) => ( - setDest({ ...dest, [d.id]: v })} - /> - ))} +

+ A digital receptionist ("press 1 for sales…") needs a recorded greeting: build it under + Digital receptionist later and pick it here or on the Phone numbers page. +

+ {dids.data.map((d) => { + const value = dest[d.id] ?? d.destination; + return ( +
+ setDest({ ...dest, [d.id]: v })} + /> +
+ +
+
+ ); + })} + {everyone.isError && {everyone.error.message}} {save.isError && {save.error.message}}