Skip to content

Commit 1b54493

Browse files
committed
fix: IVR path tree rendering, goto_flow digit tracking, and several calling bugs
- Include digit and label in goto_flow IVR path marker so the tree shows which key was pressed to trigger a flow transition - Add IVRPathTree component with proper nested tree rendering (border-l indentation, recursive depth) replacing the flat badge list - Reuse IVR AudioPlayer across goto_flow transitions to maintain RTP sequence continuity (prevents packet drops from seq reset) - Fix toggle active/call-start wiping IVR menu by making UpdateIVRFlow only include non-zero fields in the update map - Validate TTS configuration and surface TTS errors to UI instead of silently logging them - Set agent_id on CallLog during transfer acceptance so ended webhook doesn't incorrectly mark transferred calls as missed - Re-read CallLog before deciding final status to catch late agent_id writes
1 parent e1989bc commit 1b54493

File tree

8 files changed

+275
-45
lines changed

8 files changed

+275
-45
lines changed
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
<script setup lang="ts">
2+
import { computed } from 'vue'
3+
import { Phone, ArrowRight } from 'lucide-vue-next'
4+
5+
interface Step {
6+
digit?: string
7+
label?: string
8+
action?: string
9+
flow?: string
10+
}
11+
12+
interface TreeNode {
13+
step: Step
14+
children: TreeNode[]
15+
}
16+
17+
const props = defineProps<{
18+
steps: Step[]
19+
}>()
20+
21+
function isFlowStep(step: Step) {
22+
return step.action === 'flow_start' || (step.action === 'goto_flow' && step.flow)
23+
}
24+
25+
// Build a tree from the flat step list.
26+
// flow_start / goto_flow (with flow field) → creates a group, children nested under it.
27+
// submenu → creates a node, children nested under it.
28+
// parent → leaf, pops one level.
29+
// everything else → leaf node.
30+
const tree = computed(() => {
31+
const root: TreeNode[] = []
32+
const stack: TreeNode[][] = [root]
33+
34+
for (const step of props.steps) {
35+
const current = stack[stack.length - 1]
36+
37+
if (isFlowStep(step)) {
38+
// Flow nodes nest all subsequent steps as children
39+
const node: TreeNode = { step, children: [] }
40+
current.push(node)
41+
stack.push(node.children)
42+
} else if (step.action === 'submenu') {
43+
const node: TreeNode = { step, children: [] }
44+
current.push(node)
45+
stack.push(node.children)
46+
} else if (step.action === 'parent') {
47+
current.push({ step, children: [] })
48+
if (stack.length > 1) stack.pop()
49+
} else {
50+
current.push({ step, children: [] })
51+
}
52+
}
53+
54+
return root
55+
})
56+
</script>
57+
58+
<template>
59+
<div class="ivr-path-tree">
60+
<TreeNodes :nodes="tree" :depth="0" />
61+
</div>
62+
</template>
63+
64+
<script lang="ts">
65+
import { defineComponent, h, type PropType } from 'vue'
66+
67+
interface TNode {
68+
step: { digit?: string; label?: string; action?: string; flow?: string }
69+
children: TNode[]
70+
}
71+
72+
const TreeNodes = defineComponent({
73+
name: 'TreeNodes',
74+
props: {
75+
nodes: { type: Array as PropType<TNode[]>, required: true },
76+
depth: { type: Number, default: 0 }
77+
},
78+
setup(props) {
79+
return () => {
80+
if (!props.nodes.length) return null
81+
82+
return h('div', { class: 'flex flex-col gap-1' },
83+
props.nodes.map((node, idx) => {
84+
const isFlow = node.step.action === 'flow_start' ||
85+
(node.step.action === 'goto_flow' && node.step.flow)
86+
const isGotoFlow = node.step.action === 'goto_flow' && node.step.flow
87+
const hasChildren = node.children.length > 0
88+
89+
// The row content
90+
let rowContent
91+
if (isFlow) {
92+
const flowParts = []
93+
94+
// For goto_flow that has a digit, show the digit badge first
95+
if (isGotoFlow && node.step.digit) {
96+
flowParts.push(
97+
h('div', {
98+
class: 'flex items-center justify-center h-6 w-6 rounded border bg-muted text-xs font-mono font-bold shrink-0'
99+
}, node.step.digit)
100+
)
101+
flowParts.push(
102+
h(ArrowRight, { class: 'h-3.5 w-3.5 text-muted-foreground shrink-0' })
103+
)
104+
}
105+
106+
flowParts.push(
107+
h('div', {
108+
class: 'flex items-center justify-center h-6 w-6 rounded-full bg-primary shrink-0'
109+
}, [h(Phone, { class: 'h-3 w-3 text-primary-foreground' })])
110+
)
111+
flowParts.push(
112+
h('span', { class: 'font-semibold text-sm' }, node.step.flow || '?')
113+
)
114+
115+
rowContent = h('div', { class: 'flex items-center gap-2 py-1' }, flowParts)
116+
} else if (node.step.action === 'submenu') {
117+
rowContent = h('div', { class: 'flex items-center gap-2 py-1' }, [
118+
h('div', {
119+
class: 'flex items-center justify-center h-6 w-6 rounded border bg-muted text-xs font-mono font-bold shrink-0'
120+
}, node.step.digit || '?'),
121+
h('span', { class: 'text-sm font-medium' }, node.step.label || '-'),
122+
])
123+
} else {
124+
rowContent = h('div', { class: 'flex items-center gap-2 py-1' }, [
125+
h('div', {
126+
class: 'flex items-center justify-center h-6 w-6 rounded border bg-muted text-xs font-mono font-bold shrink-0'
127+
}, node.step.digit || '?'),
128+
h('span', { class: 'text-sm' }, node.step.label || '-'),
129+
])
130+
}
131+
132+
// Build the node with optional children
133+
const elements = [rowContent]
134+
135+
if (hasChildren) {
136+
elements.push(
137+
h('div', {
138+
class: 'ml-3 pl-4 border-l-2 border-muted-foreground/30'
139+
}, [
140+
h(TreeNodes, { nodes: node.children, depth: props.depth + 1 })
141+
])
142+
)
143+
}
144+
145+
return h('div', { key: idx }, elements)
146+
})
147+
)
148+
}
149+
}
150+
})
151+
</script>

frontend/src/views/calling/CallLogsView.vue

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } f
1111
import { Phone, PhoneIncoming, PhoneOutgoing, PhoneOff, PhoneMissed, Clock, RefreshCw, Mic } from 'lucide-vue-next'
1212
import DataTable, { type Column } from '@/components/shared/DataTable.vue'
1313
import SearchInput from '@/components/shared/SearchInput.vue'
14+
import IVRPathTree from '@/components/calling/IVRPathTree.vue'
1415
1516
const { t } = useI18n()
1617
const store = useCallingStore()
@@ -337,17 +338,8 @@ watch(phoneSearch, () => {
337338
</div>
338339

339340
<div v-if="selectedLog.ivr_path?.steps?.length">
340-
<p class="text-sm text-muted-foreground mb-2">{{ t('calling.ivrPath') }}</p>
341-
<div class="space-y-1">
342-
<div
343-
v-for="(step, idx) in selectedLog.ivr_path.steps"
344-
:key="idx"
345-
class="flex items-center gap-2 text-sm"
346-
>
347-
<Badge variant="outline" class="font-mono">{{ step.digit }}</Badge>
348-
<span>{{ step.label || '-' }}</span>
349-
</div>
350-
</div>
341+
<p class="text-sm text-muted-foreground mb-3">{{ t('calling.ivrPath') }}</p>
342+
<IVRPathTree :steps="selectedLog.ivr_path.steps" />
351343
</div>
352344

353345
<div v-if="selectedLog.recording_s3_key" class="space-y-2">

internal/calling/audio.go

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,13 @@ import (
1111
)
1212

1313
// AudioPlayer handles playing pre-recorded OGG/Opus audio into a WebRTC track.
14+
// It maintains cumulative RTP sequence numbers and timestamps across multiple
15+
// PlayFile calls so that receivers don't drop packets as duplicates.
1416
type AudioPlayer struct {
15-
track *webrtc.TrackLocalStaticRTP
16-
stop chan struct{}
17+
track *webrtc.TrackLocalStaticRTP
18+
stop chan struct{}
19+
sequenceNumber uint16
20+
timestamp uint32
1721
}
1822

1923
// NewAudioPlayer creates a new audio player for a WebRTC track.
@@ -43,8 +47,6 @@ func (p *AudioPlayer) PlayFile(filePath string) (int, error) {
4347
// Opus at 48kHz, 20ms frames = 960 samples per frame
4448
const samplesPerFrame = 960
4549

46-
var sequenceNumber uint16
47-
var timestamp uint32
4850
packetCount := 0
4951

5052
ticker := time.NewTicker(20 * time.Millisecond)
@@ -59,8 +61,8 @@ func (p *AudioPlayer) PlayFile(filePath string) (int, error) {
5961
Header: rtp.Header{
6062
Version: 2,
6163
PayloadType: 111, // Opus
62-
SequenceNumber: sequenceNumber,
63-
Timestamp: timestamp,
64+
SequenceNumber: p.sequenceNumber,
65+
Timestamp: p.timestamp,
6466
SSRC: 1,
6567
},
6668
Payload: opusData,
@@ -70,8 +72,8 @@ func (p *AudioPlayer) PlayFile(filePath string) (int, error) {
7072
return packetCount, fmt.Errorf("failed to write RTP packet: %w", err)
7173
}
7274

73-
sequenceNumber++
74-
timestamp += samplesPerFrame
75+
p.sequenceNumber++
76+
p.timestamp += samplesPerFrame
7577
packetCount++
7678
}
7779
}
@@ -121,8 +123,6 @@ func (p *AudioPlayer) PlaySilence(duration time.Duration) {
121123
silence := []byte{0xF8, 0xFF, 0xFE}
122124

123125
const samplesPerFrame = 960
124-
var sequenceNumber uint16
125-
var timestamp uint32
126126

127127
ticker := time.NewTicker(20 * time.Millisecond)
128128
defer ticker.Stop()
@@ -139,17 +139,17 @@ func (p *AudioPlayer) PlaySilence(duration time.Duration) {
139139
Header: rtp.Header{
140140
Version: 2,
141141
PayloadType: 111,
142-
SequenceNumber: sequenceNumber,
143-
Timestamp: timestamp,
142+
SequenceNumber: p.sequenceNumber,
143+
Timestamp: p.timestamp,
144144
SSRC: 1,
145145
},
146146
Payload: silence,
147147
}
148148
if err := p.track.WriteRTP(packet); err != nil {
149149
return
150150
}
151-
sequenceNumber++
152-
timestamp += samplesPerFrame
151+
p.sequenceNumber++
152+
p.timestamp += samplesPerFrame
153153
}
154154
}
155155
}

internal/calling/ivr.go

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -62,13 +62,27 @@ func (m *Manager) runIVRFlow(session *CallSession, waAccount *whatsapp.Account)
6262
}
6363
}
6464

65+
// Record which flow we're entering (first call gets flow_start,
66+
// subsequent goto_flow calls already have a goto_flow marker)
67+
if len(ivrPath) == 0 {
68+
ivrPath = append(ivrPath, map[string]string{"action": "flow_start", "flow": session.IVRFlow.Name})
69+
}
70+
6571
// Start at the root menu
6672
currentMenu := &rootMenu
6773
session.mu.Lock()
6874
session.CurrentMenu = currentMenu
6975
session.mu.Unlock()
7076

71-
player := NewAudioPlayer(session.AudioTrack)
77+
// Reuse the session's IVR player to maintain RTP sequence continuity
78+
// across goto_flow transitions (new player would reset seq to 0,
79+
// causing the receiver to drop packets as duplicates).
80+
session.mu.Lock()
81+
if session.IVRPlayer == nil {
82+
session.IVRPlayer = NewAudioPlayer(session.AudioTrack)
83+
}
84+
player := session.IVRPlayer
85+
session.mu.Unlock()
7286

7387
for {
7488
// Check if session is still active
@@ -113,15 +127,18 @@ func (m *Manager) runIVRFlow(session *CallSession, waAccount *whatsapp.Account)
113127
continue
114128
}
115129

116-
ivrPath = append(ivrPath, map[string]string{"digit": digitStr, "label": option.Label})
117-
118130
m.log.Info("IVR option selected",
119131
"call_id", session.ID,
120132
"digit", digitStr,
121133
"action", option.Action,
122134
"label", option.Label,
123135
)
124136

137+
// Record the digit step (goto_flow merges digit into its flow marker below)
138+
if option.Action != "goto_flow" {
139+
ivrPath = append(ivrPath, map[string]string{"digit": digitStr, "label": option.Label, "action": option.Action})
140+
}
141+
125142
switch option.Action {
126143
case "submenu":
127144
if option.Menu != nil {
@@ -152,7 +169,6 @@ func (m *Manager) runIVRFlow(session *CallSession, waAccount *whatsapp.Account)
152169
case "goto_flow":
153170
if option.Target != "" {
154171
m.log.Info("IVR goto_flow", "call_id", session.ID, "target_flow", option.Target)
155-
m.saveIVRPath(session, ivrPath)
156172

157173
targetFlowID, err := uuid.Parse(option.Target)
158174
if err != nil {
@@ -165,11 +181,16 @@ func (m *Manager) runIVRFlow(session *CallSession, waAccount *whatsapp.Account)
165181
m.log.Error("Failed to load goto_flow target", "error", err, "call_id", session.ID, "flow_id", option.Target)
166182
continue
167183
}
184+
168185
if !targetFlow.IsActive {
169186
m.log.Warn("goto_flow target is disabled, skipping", "call_id", session.ID, "flow_id", option.Target)
170187
continue
171188
}
172189

190+
// Record the flow transition in the IVR path (include digit so the tree shows which key was pressed)
191+
ivrPath = append(ivrPath, map[string]string{"action": "goto_flow", "flow": targetFlow.Name, "digit": digitStr, "label": option.Label})
192+
m.saveIVRPath(session, ivrPath)
193+
173194
// Switch to the new flow and restart the IVR loop
174195
session.mu.Lock()
175196
session.IVRFlow = &targetFlow

internal/calling/session.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ type CallSession struct {
3131
AudioTrack *webrtc.TrackLocalStaticRTP
3232
CurrentMenu *IVRMenuNode
3333
IVRFlow *models.IVRFlow
34+
IVRPlayer *AudioPlayer // persists across goto_flow for RTP continuity
3435
DTMFBuffer chan byte
3536
StartedAt time.Time
3637

@@ -252,6 +253,9 @@ func (m *Manager) cleanupSession(callID string) {
252253
if session.HoldPlayer != nil {
253254
session.HoldPlayer.Stop()
254255
}
256+
if session.IVRPlayer != nil {
257+
session.IVRPlayer.Stop()
258+
}
255259
if session.TransferCancel != nil {
256260
session.TransferCancel()
257261
}

internal/calling/transfer.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,12 @@ func (m *Manager) completeTransferConnection(session *CallSession, transferID, a
259259
"connected_at": now,
260260
})
261261

262+
// Also set agent_id on the CallLog so the webhook "ended" handler
263+
// knows an agent was connected and doesn't mark the call as "missed".
264+
m.db.Model(&models.CallLog{}).
265+
Where("id = ?", session.CallLogID).
266+
Update("agent_id", agentID)
267+
262268
session.mu.Lock()
263269
session.TransferStatus = models.CallTransferStatusConnected
264270
callerRemote := session.CallerRemoteTrack

internal/handlers/call_webhook.go

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -154,15 +154,20 @@ func (a *App) processCallWebhook(phoneNumberID string, call interface{}) {
154154

155155
case "ended", "terminate":
156156
// Calculate duration and determine final status.
157-
// For incoming calls that were pre-accepted for WebRTC but never reached
158-
// an agent (no transfer connected), mark as missed instead of completed.
157+
// Re-read the call log to get the latest agent_id (may have been set
158+
// by transfer acceptance after our initial read).
159+
a.DB.First(callLog, callLog.ID)
160+
159161
duration := 0
160162
if callLog.AnsweredAt != nil {
161163
duration = int(now.Sub(*callLog.AnsweredAt).Seconds())
162164
}
163165

166+
// For incoming calls that were pre-accepted for WebRTC but never reached
167+
// an agent (no transfer connected), mark as missed instead of completed.
164168
finalStatus := models.CallStatusCompleted
165-
if callLog.Direction == models.CallDirectionIncoming && callLog.AgentID == nil {
169+
if callLog.Direction == models.CallDirectionIncoming && callLog.AgentID == nil &&
170+
callLog.Status != models.CallStatusTransferring {
166171
finalStatus = models.CallStatusMissed
167172
}
168173

0 commit comments

Comments
 (0)