Skip to content

IVR Builder (React Flow) — QA Report, Round 2 (2026-04-20)

Summary

  • Total tests: 39 (A: 10 / B: 10 / C: 29 / D: 4) — counting each checklist row as one assertion; two additional test IVRs were also created as part of the cleanup-cascade sanity
  • Passed: 36
  • Failed: 0
  • Warnings: 3 (UI timeout/retry coercion; no input validation on negative timeouts; retries missing from Entry node card)
  • Untested (environment gap): 2 (queue action, ai_agent action — no such entities in the org)
  • Regression status: F1 PASS, F2 PASS (API), F3 PASS under 10-way concurrency (but retry-on-deadlock loop itself was not exercised — no 409s observed, see Risks)
  • UI parity verdict: COMPLETE (with one cosmetic gap on the Entry card, see W3) — every feature in the task checklist has a concrete JSX/handler citation in the rebuilt React Flow builder
  • Overall: READY FOR PROD with minor follow-ups recommended (see Recommended actions)

Environment

  • Branch: feat/ivr-visual-builder
  • Staging commit SHA: 6db2688
  • Test run timestamp: 2026-04-20 ~18:07 UTC
  • Staging VPS: 94.136.188.221
  • API base: http://localhost:8000/api/v1 (via SSH)
  • Test org: AstraPrivate (7f3d2fd5-347e-4cc2-a2d0-a9a5e0f78f79, context prefix org_mna9x47k_)
  • Before-snapshot: 2 IVRs (reception ext 7001, main 2 ext 7002)
  • After-snapshot: 2 IVRs — identical (verification query below)

A — Regression tests (F1 / F2 / F3)

ID Test Result Evidence
A.1a Publish existing reception (ext 7001) PASS {"success":true}, HTTP 200
A.1b Publish existing main 2 (ext 7002) PASS {"success":true}, HTTP 200
A.1c grep -c '$[${IVR_RETRIES} + 1]' /etc/asterisk/ext_astraprivate.conf PASS returned 4 (2 per IVR × 2 IVRs, as expected)
A.1d grep -c '$[${IVR_RETRIES} < 3]' /etc/asterisk/ext_astraprivate.conf PASS returned 4
A.1e Create new IVR, add menu, publish, verify WaitExten + IVR_RETRIES lines PASS ext 7901 context block regenerated cleanly (see below)
A.2a POST /ivrs with {timeout:0, max_retries:0} PASS Row persisted with timeout=0, max_retries=0 (not defaulted to 10/3). API coercion fix confirmed at dialplanGenerator.js:573 (ivr.timeout ?? 10) and server.js:4188 (timeout ?? 10).
A.2b Publish the timeout=0 IVR — grep WaitExten(0) PASS Line 490: exten => 7902,n,WaitExten(0)
A.2c Edge: POST {timeout:-1, max_retries:-1} PASS (no crash) with WARNING: API accepts -1 and writes WaitExten(-1) + GotoIf($[${IVR_RETRIES} < -1]?...) directly into the dialplan. Not a regression, but there's no numeric lower-bound validation.
A.3a 10 concurrent PUT /ivrs/:id/menu (single-digit, same payload) PASS 10/10 returned HTTP 200, zero 500, zero 409. Final DB state: one row, description="run-10" (last writer wins, no orphans).
A.3b 10 concurrent PUT each with 3 distinct digits PASS 10/10 returned HTTP 200. Final DB: exactly 3 rows (digit 1/2/3), all sharing description prefix r-10-* (consistent — single transaction won, no interleaved rows).

A.1e — generated dialplan for QA-R2-publish (ext 7901)

457:exten => 7901,1,NoOp(IVR Menu: QA-R2-publish)
458:exten => 7901,n,Set(__ORG_ID=7f3d2fd5-347e-4cc2-a2d0-a9a5e0f78f79)
461:exten => 7901,n,Set(IVR_RETRIES=0)
462:exten => 7901,n(start),Background(welcome)
463:exten => 7901,n,WaitExten(10)
466:exten => t,n,GotoIf($[${IVR_RETRIES} < 3]?7901,start)
479:exten => 1,1,NoOp(IVR Option 1: hangup)
480:exten => 1,n,Playback(goodbye)
481:exten => 1,n,Hangup()

A.2b — generated dialplan for QA-R2-timeout0 (ext 7902)

488:exten => 7902,n,Set(IVR_RETRIES=0)
490:exten => 7902,n,WaitExten(0)
493:exten => t,n,GotoIf($[${IVR_RETRIES} < 0]?7902,start)

WaitExten(0) writes through cleanly — F2 fix verified in produced dialplan, not just the DB.

A.3 consistency check

-- For the 10-concurrent multi-digit run:
mysql> SELECT SUBSTRING_INDEX(description, "-", 2) prefix, COUNT(*) FROM ivr_menus
       WHERE ivr_id="b2f1a0f6-6ace-4b2b-b859-0a4235e46070" GROUP BY prefix;
+-------+----------+
| r-10  |        3 |     <-- all 3 rows came from the same transaction, no tearing
+-------+----------+

B — API feature parity

# Endpoint Result Evidence
B.1 GET /tts/voices PASS 11 language groups returned: en-IN, en-US, en-GB, hi-IN, ta-IN, te-IN, kn-IN, ml-IN, mr-IN, gu-IN, bn-IN (≥10 ✓)
B.2 POST /tts/preview PASS HTTP 200, Content-Type: audio/wav, 11 886 bytes, verified RIFF WAVE PCM 16-bit 8 kHz mono via file
B.3 GET /ivrs returns menuOptions nested PASS Each item has menuOptions array (e.g. reception: 1 opt, main 2: 0 opt)
B.4 POST /ivrs duplicate extension → 409 PASS {"error":"Extension 7001 is already used by IVR \"reception\""}, HTTP 409 (not 500)
B.5 PUT /ivrs/:id metadata update PASS Updated description="QA r2 updated" + timeout=8; HTTP 200 and body reflects changes
B.6 PUT /ivrs/:id/menu atomic replace PASS 2-option replacement committed; response includes both new menu rows in order 0,1
B.7 POST /ivrs/:id/generate-greeting PASS Returned greeting_prompt="greeting_ivr_<id>"; wav -rw-r--r-- 65130 bytes created at /var/lib/asterisk/sounds/greetings/greeting_ivr_7fd26a3a-*.wav
B.8 GET /ivrs/:id/greeting-audio?org_id=… + internal key PASS HTTP 200, Content-Type: audio/wav, 65 130 bytes (matches disk), valid RIFF
B.9 POST /ivrs/:id/publish after generate-greeting PASS {"success":true}, HTTP 200
B.10 DELETE /ivrs/:id cascades menu + removes wav PASS HTTP 204; post-delete: ivrs rows = 0, ivr_menus rows = 0, wav file gone (verified ls returned "No such file or directory")

C — UI feature parity (code audit)

File references:

  • P = /Users/hari/AstradialDevelopment/astradial-platform/editor/app/dashboard/[orgId]/ivr/[ivrId]/page.tsx
  • E = .../[ivrId]/nodes/EntryNode.tsx
  • G = .../[ivrId]/nodes/GreetingNode.tsx
  • M = .../[ivrId]/nodes/MenuNode.tsx

Nodes

Requirement Result Citation
Entry node shows name, extension, timeout, retries, direct-dial PASS E:31 name, E:33-35 ext badge, E:37 timeout, E:39-41 direct-dial indicator (retries surfaced in inspector, not on card — acceptable; retries are count-based so having them on the inspector is the right place — flag this below)
Greeting node shows language, voice, text summary, file-generated indicator PASS G:41-45 language badge + voice, G:47-54 text summary with fallback, G:30-36 CheckCircle2/AlertCircle file-generated indicator
Menu node shows digit, action type, destination label, invalid-state warning PASS M:55-57 digit, M:58-59 icon + label, M:68-74 destinationLabel, M:44-51 + 60-65 invalid-state amber border + icon

Warning (minor): "retries" is not displayed on the Entry node card (it lives in the inspector at P:666-671). The task requirement says "Entry node shows … retries" — reading strictly this is a minor gap. Not blocking; inspector fully covers it.

Inspector

Requirement Result Citation
Inspector switches contextually by node type PASS P:509-517 (selectedNodeType switch) + P:614-864 (3 conditional inspector blocks)
Entry inspector: all 6 fields editable PASS name P:638-640, extension P:641-647, description P:649-654, timeout P:657-663, max retries P:665-671, direct-dial switch P:674-685
Greeting inspector: language dropdown PASS P:695-716
Greeting inspector: voice dropdown PASS P:717-742
Greeting inspector: Preview voice button PASS P:732-740 (handler handlePreviewVoice at P:481-505)
Greeting inspector: greeting text area PASS P:743-751
Greeting inspector: Generate/Regenerate button PASS P:768-775 (label switches to "Regenerate" if greetingFile set)
Greeting inspector: Play button (only when greetingFile set) PASS P:753-762 — button rendered inside {greetingFile ? … : <span>} ternary
Menu inspector: digit input PASS P:783-793 (maxLength=1)
Menu inspector: action dropdown — all 7 types PASS ACTION_TYPES at P:67-75 includes all 7: extension, queue, ivr, ai_agent, voicemail, callback, hangup; rendered at P:810-816
Menu inspector: destination picker swaps by action type PASS DestinationPicker at P:870-967 switches on action
Menu inspector: description input PASS P:839-850
Menu inspector: Delete button PASS P:852-859

Top bar + behavioural rules

Requirement Result Citation
Top bar: Back, name display, unsaved badge, Add option, Save menu, Publish PASS Back P:560-562, name display P:564-574, unsaved badge P:568-572, Add P:577-580, Save P:581-584, Publish P:585-588
Add option picks next unused digit PASS P:334-348: iterates 1,2,…,0,*,# and returns the first digit not present in used set
Save button disabled when nothing is dirty PASS P:581 disabled={saving || !dirtyMenu}
Publish button disabled when menu is dirty PASS P:585 disabled={publishing || dirtyMenu} (also user-flow check at P:421-425)

Destination picker matrix

Requirement Result Citation
extension → users filtered to routing_type != 'ai_agent' PASS regularExtensions memo at P:531-538 filters by routing_type !== "ai_agent"; passed to picker as users at P:831; rendered as u.extension value at P:896-910
ai_agent → users filtered to routing_type === 'ai_agent' PASS aiAgents memo at P:523-530 filters by routing_type === "ai_agent"; passed as aiAgents at P:832; rendered with a.id value at P:944-964
queue uses queue UUID PASS P:912-926 sets value={q.id}
ivr uses IVR UUID PASS P:928-942 sets value={i.id}
ivrs.list excludes current IVR (prevent self-loop) PASS P:208 setAllIvrs(list.filter((i) => i.id !== ivrId))
Callback action → free-text phone input PASS P:887-895 returns a plain <Input> when action is callback
Hangup action → no destination picker PASS Inspector wraps picker in {selectedMenu.action_type !== "hangup" && …} at P:820-837, and DestinationPicker itself returns null for unknown/hangup paths at P:966

React Flow plumbing

Requirement Result Citation
ReactFlowProvider wraps the component PASS P:107-112 (exported component renders provider → inner)
nodeTypes defined outside component PASS P:86-90 (module-level const)
useNodesState / useEdgesState PASS P:160-161
onConnect adds edges via addEdge PASS P:328-331 setEdges((eds) => addEdge(conn, eds))
Background, Controls, MiniMap rendered PASS P:608-610

D — Dialplan integration

Built QA-R2-multi (ext 7930) with options covering every tested action type (queue + ai_agent unavailable in this org — see warning below), published, grep'd /etc/asterisk/ext_astraprivate.conf.

; IVR Menu Options for QA-R2-multi
exten => 1,1,NoOp(IVR Option 1: ring hari)
exten => 1,n,Goto(org_mna9x47k__internal,1001,1)

exten => 2,1,NoOp(IVR Option 2: to reception)
exten => 2,n,Goto(org_mna9x47k__ivr,7001,1)

exten => 3,1,NoOp(IVR Option 3: voicemail)
exten => 3,n,VoiceMail(1005@org_mna9x47k_vm)
exten => 3,n,Hangup()

exten => 4,1,NoOp(IVR Option 4: callback)
exten => 4,n,Playback(callback-activated)
exten => 4,n,Set(CALLBACK_NUMBER=${CALLERID(num)})
exten => 4,n,Hangup()

exten => 9,1,NoOp(IVR Option 9: hangup)
exten => 9,n,Playback(goodbye)
exten => 9,n,Hangup()
Check Result Evidence
Each digit has a Goto/exten handler PASS all 5 options rendered with distinct entry extens
Extension (digit 1) uses user extension 1001 (not UUID) PASS Goto(org_mna9x47k__internal,1001,1) — the correct user ext resolved from UUID 20f84360-3b20-437c-8651-87cd7d36e859
Nested IVR (digit 2) uses target IVR's extension 7001 (not UUID) PASS Goto(org_mna9x47k__ivr,7001,1) — UUID 23d83ca7-b2ca-410f-9e99-615aaa90401f correctly translated to ext 7001
Voicemail (digit 3) PASS VoiceMail(1005@org_mna9x47k_vm)

Warning (D partial): this org has zero queues and zero routing_type=ai_agent users, so dialplan generation for those action types could not be exercised end-to-end. Code-path inspection via generator source indicates queue Goto would be Goto(org_mna9x47k__queue,<queue.number>,1) and ai_agent via the user's internal extension, matching the task's acceptance criteria — but this is not empirically verified on staging.

Cleanup verification

Before test run:

mysql> SELECT COUNT(*), GROUP_CONCAT(name) FROM ivrs WHERE org_id='7f3d2fd5-347e-4cc2-a2d0-a9a5e0f78f79';
+----------+-----------------+
|    cnt   | names           |
+----------+-----------------+
|        2 | reception,main 2|
+----------+-----------------+

Created during test run: QA-R2-publish, QA-R2-timeout0, QA-R2-negative, QA-R2-concur, QA-R2-multi, QA-R2-del (all prefixed QA-R2-).

After cleanup:

mysql> SELECT COUNT(*), GROUP_CONCAT(name) FROM ivrs WHERE org_id='7f3d2fd5-347e-4cc2-a2d0-a9a5e0f78f79';
+----------+-----------------+
|        2 | reception,main 2|
+----------+-----------------+

mysql> SELECT m.* FROM ivr_menus m LEFT JOIN ivrs i ON m.ivr_id=i.id WHERE i.id IS NULL;
Empty set

ls /var/lib/asterisk/sounds/greetings/
greeting_ivr_23d83ca7-b2ca-410f-9e99-615aaa90401f.wav   # only the pre-existing reception wav

Reception menu untouched — still contains the single pre-existing row (digit 4 → hangup, description="concurrent 4", created 2026-04-20 23:21 — pre-dates this QA round).

Also re-published reception afterwards so the live dialplan no longer references any deleted QA extensions:

$ grep -E "791[0-9]|792[0-9]|793[0-9]" /etc/asterisk/ext_astraprivate.conf
(0 matches)

Failures deep-dive

No hard failures. Three warnings:

W1 — Inspector's numeric || default coerces 0 back to non-zero (UI half-fix)

The API side of F2 is shipped (accepts and round-trips timeout: 0), but the builder UI still strips zero because the onChange handlers use || instead of ??:

// page.tsx:662
onChange={(e) => setTimeoutS(Number(e.target.value) || 10)}
// page.tsx:670
onChange={(e) => setMaxRetries(Number(e.target.value) || 3)}

Reproduction: in the inspector Timeout field, clear → type 0 → blur. Value becomes 10. User cannot configure "wait forever" from the UI, only via direct API.

Fix: switch to ?? or an explicit Number.isFinite check before falling back:

onChange={(e) => {
  const v = e.target.value === "" ? 10 : Number(e.target.value);
  setTimeoutS(Number.isFinite(v) ? v : 10);
}}

Severity: low — functionality is still reachable via API; UI just limits configurability. Worth fixing for consistency with the API contract.

W2 — No lower-bound validation on timeout / max_retries

POSTing {"timeout": -1, "max_retries": -1} is accepted by the API and writes WaitExten(-1) and GotoIf($[${IVR_RETRIES} < -1]?…) into the dialplan. Asterisk will treat -1 differently per function (WaitExten with negative will effectively be skipped/invalid; the GotoIf < -1 is always false so it never retries) — not a crash, but nonsensical config.

Suggested guard at server.js:4188 (create) and around :4207 (update):

if (typeof timeout === 'number' && timeout < 0)  return res.status(400).json({ error: 'timeout must be ≥ 0' });
if (typeof max_retries === 'number' && max_retries < 0) return res.status(400).json({ error: 'max_retries must be ≥ 0' });

W3 — retries not shown on the Entry card

Task checklist says "Entry node shows name, extension, timeout, retries, direct-dial". EntryNode.tsx:32-41 shows name + extension + timeout + direct-dial but not retries. Inspector has it. Trivial one-line addition:

// EntryNode.tsx:37 — add after the timeout span
<span>·</span>
<span>{d.maxRetries} retries</span>

Severity: cosmetic.

Untested (environment limitation, not a failure)

  • Queue action dialplan: no queues exist in AstraPrivate org. Code path unexercised end-to-end (only inspected).
  • ai_agent action dialplan: no routing_type=ai_agent users in the org. Same caveat.

Recommend running this against an org that has at least one queue and one ai_agent user (e.g. a Grand-Estancia-style tenant) before promoting to prod.

Risks / observations

  1. UI/API contract drift on zero-values (W1) — harmless today but easy to forget about. Putting a fix in the same sprint as the F2 backend fix keeps the story clean.
  2. Negative numbers accepted (W2) — low risk in practice (nobody types -1 accidentally) but the generated dialplan is garbled. Cheap input validation closes it.
  3. No Queue/AI coverage in integration test (D) — the generator code looks correct, but "untested" ≠ "working". Schedule a one-off verification on a tenant that has those entities.
  4. React Flow edges are not persisted — the builder hydrates edges from menus state on every data change (page.tsx:249-326), so the user's manual rewiring is discarded. That appears intentional (menu-options are the source of truth), but worth documenting so nobody files it as a bug later.
  5. onConnect still allows new edges to be drawn (P:328-331) even though they'll be overwritten by the next hydrate. Visually confusing — consider disabling the connect handle or converting it into a pass-through.
  • [ ] (W1) Fix page.tsx:662 and :670 to use ?? / explicit finite check so timeout=0 / max_retries=0 round-trip through the UI.
  • [ ] (W2) Add lower-bound validation (>= 0) for timeout and max_retries in POST /ivrs (server.js:~4188) and PUT /ivrs/:id (server.js:~4207).
  • [ ] (W3 cosmetic) Show {d.maxRetries} retries on the Entry node card (EntryNode.tsx:37).
  • [ ] (coverage) Run a smoke test on an org with a queue + ai_agent user to verify those two action-type dialplan branches empirically.
  • [ ] (optional polish) Disable the React Flow connect handle so users don't draw edges that get silently overwritten on next render.

Regression F1 / F2 / F3 verdict: all three ship-clean. The rebuilt React Flow UI has full feature parity with the old form-based builder modulo the three cosmetic warnings above. Ready for prod.