moh: music-on-hold classes, uploads, pickers for users and queues
ci / check (push) Successful in 51s
ci / images (push) Successful in 18s

/api/moh-classes: create (slug, not "default"), delete (409 while an
extension or queue uses it), add/play/remove files. Files are 8 kHz
wav in MOH_DIR/<class>/ and sound_files rows of kind moh. Extensions
and queues may only pick default or an existing class.

Two things found with real calls, written back into 03 and 04:
- An empty class plays silence on Asterisk 24. Only classes with
  music are rendered, so a channel asking for an empty one gets
  default.
- The generator reloads only changed files, and a file upload changes
  no config (except a class's first/last). The route sends moh reload
  after every file change.

Web: a Music on hold section on Sounds & music, and a select of sets
in the user drawer's Phone tab and the queue form. load-golden makes
the class folders like the api does. Golden case queue gains two
classes. e2e m8-t5: set made in the UI, upload listed by Asterisk, a
queue caller hears it, a user's moh_suggest, delete refused while
used, an emptied set falls back to default.

Task: M8-T5
This commit is contained in:
iamdoubz committed 2026-10-19 10:10:01 -05:00
1 parent e95271e694
commit b8ff6e3386
34 files changed
+840 -21

No files matched your search

+1 -1
View File
@@ -281,7 +281,7 @@ create table sound_files (
);
create table moh_classes (
id uuid primary key default gen_random_uuid(),
name text not null unique -- slug [a-z0-9_-]; 'default' is built in and not stored here
name text not null unique -- slug [a-z0-9-] (same as other slugs); 'default' is built in and not stored here
);
create table ivrs (
+5
View File
@@ -979,6 +979,11 @@ sort = random
```
One per `moh_classes` row. The api keeps the folder contents in sync with `sound_files` (04 §6).
As built (M8-T5): one per `moh_classes` row **that has at least one file**. An empty class plays
silence in Asterisk 24; left out, a channel asking for it gets `default` instead (checked with a
real queue caller). Because only the first and last file change this file, the api also sends
`moh reload` after every file it adds or removes. Golden case `queue`; `scripts/e2e/m8-t5.mjs`.
## 9. Snapshot → endpoint variables summary
| Variable | Where set | Read by |
+3 -1
View File
@@ -316,6 +316,8 @@ forced apply.
- Duration: `ffprobe -v error -show_entries format=duration -of csv=p=0 <out>.wav`.
- Prompts: `/var/lib/asterisk/sounds/custom/<id>.{wav,g722}` → `asterisk_name = custom/<id>`.
- MOH: `/var/lib/asterisk/moh/<className>/<id>.wav` (wav only). After any MOH change, call `generator.requestApply`.
As built (M8-T5): the folder is `MOH_DIR` (default `/var/lib/asterisk/moh`). Adding or removing a
file changes no config unless it's the class's first or last, so the route also sends `moh reload`.
- Deleting a sound referenced by an IVR/announcement/settings → 409 with references.
- Optional TTS (M12): `POST /api/sounds/tts {name, text, voice}` runs Piper (binary
and voice models in the api image under `/opt/piper`), then the same conversion.
@@ -383,7 +385,7 @@ Admin resources (all `requireAdmin`; standard CRUD = `GET /x`, `POST /x`, `GET /
| `/api/conferences` | |
| `/api/paging-groups` | members are set via `memberIds: [uuid]` in POST/PATCH (no order). Not a destination, so delete never returns 409 |
| `/api/sounds` | upload is `POST /api/sounds?name=&filename=` with the raw file as the body (like greetings, no multipart), `GET /:id/audio`, `PATCH /:id {name}`, `DELETE /:id` (409 with `usedBy`: IVR greeting/invalid sound, announcements, the recording notice), `POST /tts` (M12). M6 lists prompts only; MOH classes and files come with M8 |
| `/api/moh-classes` | |
| `/api/moh-classes` | `POST {name}` (slug, not `default`), `DELETE /:id` (409 with `usedBy` while an extension or queue uses it), `POST /:id/files?name=&filename=` (raw file body, like sounds), `GET /:id/files/:fileId/audio`, `DELETE /:id/files/:fileId`. No rename: the name is the folder and is stored on extensions and queues. Extensions and queues may only pick `default` or an existing class (400) |
| `/api/devices` | `GET /:id/config-preview` (rendered provisioning file, passwords masked) |
| `/api/blocked-numbers` | |
| `/api/contacts` | company phonebook; `POST /import` CSV (`name,company,email,label1,number1,...`) |
+2 -2
View File
@@ -34,12 +34,12 @@ Left sidebar navigation, in this order:
| Outbound rules | `/admin/outbound` | Sortable list (drag to reorder). Form: name, patterns (rows of match/strip/prepend with a pattern help popover), required class, trunk order (multi-select with order), caller ID override. **Test box**: pick an extension + type a number → shows the matching route, final dialed number, trunk, caller ID, or the reason it's denied |
| Ring groups | `/admin/ring-groups` | Form: number, name, strategy, ring seconds, members (ordered picker), music instead of ring, caller-name prefix, no-answer DestinationPicker |
| (Dashboard, Home) | | As built (M8-T2): a "Parked calls" list (slot, caller, parked by, waiting time) on the Dashboard and on Home; Home's has **Pick up 701**, which calls the slot like Call back (softphone, or the desk phone rings first) |
| Queues | `/admin/queues` | Form: every column from 02. Detail page shows live waiting callers + agent list with pause/unpause buttons + today's stats. As built (M8-T1): detail at `/admin/queues/:id`; music on hold offers only the current class until M8-T5 |
| Queues | `/admin/queues` | Form: every column from 02. Detail page shows live waiting callers + agent list with pause/unpause buttons + today's stats. As built (M8-T1): detail at `/admin/queues/:id`; music on hold is a select of `default` and the classes from Sounds & music (M8-T5) |
| IVRs (Digital receptionist) | `/admin/ivrs` | Form: number, name, greeting (SoundPicker), keypad grid 0–9 * # where each key gets a DestinationPicker or "unused", timeout, retries, invalid sound, direct dial toggle, timeout and invalid destinations |
| Office hours | `/admin/hours` | Time conditions: weekly grid editor (click-drag hours per day) that saves to `open_ranges`; timezone; holidays toggle; open/closed/holiday destinations; override digit; big status chip "Open now / Closed now" with Force open / Force closed / Automatic buttons. Holidays sub-tab: list + "Add US federal holidays for {year}" |
| Conferences | `/admin/conferences` | number, name, PIN, admin PIN, max members, record. As built (M8-T3): admins see the PINs in the list (they hand them out); an admin PIN needs a different room PIN |
| Paging | `/admin/paging` | number, name, members, duplex. As built (M8-T4): members are checkboxes (no order); the empty state explains `*80<ext>` |
| Sounds & music | `/admin/sounds` | Prompts list with play buttons, upload (drag-drop), TTS (M12). MOH classes with their files (M8). An **Announcements** section (play a sound, then a destination) lives here too: no page of its own (M6-T5) |
| Sounds & music | `/admin/sounds` | Prompts list with play buttons, upload (drag-drop), TTS (M12). MOH classes with their files (M8). As built (M8-T5): a **Music on hold** section: make a set (slug name), upload, play and remove its files, delete it. The user drawer's Phone tab and the queue form pick a set from a select. An **Announcements** section (play a sound, then a destination) lives here too: no page of its own (M6-T5) |
| Phones (devices) | `/admin/devices` | Table: MAC, vendor, model, user, last provisioned. Form: MAC (any format → normalized), vendor, model, extension. Shows the provisioning URL to type into the phone, and "Preview config" |
| Contacts | `/admin/contacts` | Company phonebook, CSV import |
| Call history | `/admin/calls` | Filters + table + CSV export; a row expands to show the CDR legs and plays the recording |