Subversion Repositories SmartDukaan

Rev

Details | Last modification | View Log | RSS feed

Rev Author Line No. Line
37651 vikas 1
package com.spice.profitmandi.web.controller;
2
 
3
import com.fasterxml.jackson.databind.JsonNode;
4
import com.fasterxml.jackson.databind.ObjectMapper;
5
import com.spice.profitmandi.common.web.util.ResponseSender;
6
import com.spice.profitmandi.dao.entity.auth.AuthUser;
7
import com.spice.profitmandi.dao.entity.user.Lead;
8
import com.spice.profitmandi.dao.entity.user.LeadCall;
9
import com.spice.profitmandi.dao.entity.user.LeadDnd;
10
import com.spice.profitmandi.dao.entity.auth.VonageAgent;
11
import com.spice.profitmandi.dao.repository.auth.AuthRepository;
12
import com.spice.profitmandi.dao.repository.auth.VonageAgentRepository;
13
import com.spice.profitmandi.dao.repository.dtr.LeadCallRepository;
14
import com.spice.profitmandi.dao.repository.dtr.LeadDndRepository;
15
import com.spice.profitmandi.dao.repository.dtr.LeadRepository;
16
import com.spice.profitmandi.dao.enumuration.dtr.LeadCallProvider;
17
import com.spice.profitmandi.dao.enumuration.dtr.LeadCallStatus;
18
import com.spice.profitmandi.service.integrations.vonage.VonageVoiceService;
19
import com.spice.profitmandi.service.integrations.vonage.vbc.VbcDialerProvider;
20
import com.spice.profitmandi.service.integrations.vonage.vbc.VbcTelephonyService;
21
import com.spice.profitmandi.service.lms.LmsDialerProvider;
22
import com.spice.profitmandi.web.model.LoginDetails;
23
import com.spice.profitmandi.web.util.CookiesProcessor;
24
import org.apache.logging.log4j.LogManager;
25
import org.apache.logging.log4j.Logger;
26
import org.springframework.beans.factory.annotation.Autowired;
27
import org.springframework.beans.factory.annotation.Value;
28
import org.springframework.http.HttpStatus;
29
import org.springframework.http.MediaType;
30
import org.springframework.http.ResponseEntity;
31
import org.springframework.stereotype.Controller;
32
import org.springframework.transaction.annotation.Transactional;
33
import org.springframework.web.bind.annotation.RequestBody;
34
import org.springframework.web.bind.annotation.RequestHeader;
35
import org.springframework.web.bind.annotation.RequestMapping;
36
import org.springframework.web.bind.annotation.RequestMethod;
37
import org.springframework.web.bind.annotation.RequestParam;
38
import org.springframework.web.bind.annotation.ResponseBody;
39
 
40
import javax.crypto.Mac;
41
import javax.crypto.spec.SecretKeySpec;
42
import javax.servlet.http.HttpServletRequest;
43
import java.nio.charset.StandardCharsets;
44
import java.security.MessageDigest;
45
import java.time.LocalDateTime;
46
import java.time.LocalTime;
47
import java.util.Base64;
48
import java.util.HashMap;
49
import java.util.Map;
50
 
51
/**
52
 * The LMS web dialer: the endpoints the browser softphone talks to, plus the three Vonage callbacks.
53
 *
54
 * <p><b>Two audiences, two security models.</b> {@code /lms/dialer/*} is normal authenticated app
55
 * traffic behind the session cookie and the role gate. {@code /vonage/voice/*} is Vonage calling us,
56
 * so those paths are excluded from all three interceptors in {@code WebConfig} and are protected
57
 * instead by the signed-webhook JWT — which means they must never trust anything in the payload that
58
 * they can look up themselves. In particular the number we dial comes from the lead record, never
59
 * from the webhook.
60
 */
61
@Controller
62
@Transactional(rollbackFor = Throwable.class)
63
public class LmsDialerController {
64
 
65
    private static final Logger LOGGER = LogManager.getLogger(LmsDialerController.class);
66
 
67
    private final ObjectMapper objectMapper = new ObjectMapper();
68
 
69
    @Autowired
70
    private LeadRepository leadRepository;
71
 
72
    @Autowired
73
    private LeadCallRepository leadCallRepository;
74
 
75
    @Autowired
76
    private LeadDndRepository leadDndRepository;
77
 
78
    @Autowired
79
    private VonageVoiceService vonageVoiceService;
80
 
81
    @Autowired
82
    private LmsDialerProvider dialerProvider;
83
 
84
    /**
85
     * Optional: only present when VBC is the active provider. Injected by type rather than through
86
     * the interface because placing a call needs the agent's extension, which is VBC-specific.
87
     */
88
    @Autowired(required = false)
89
    private VbcDialerProvider vbcDialerProvider;
90
 
91
    @Autowired(required = false)
92
    private VbcTelephonyService vbcTelephonyService;
93
 
94
    @Autowired
95
    private AuthRepository authRepository;
96
 
97
    @Autowired
98
    private VonageAgentRepository vonageAgentRepository;
99
 
100
    @Autowired
101
    private CookiesProcessor cookiesProcessor;
102
 
103
    @Autowired
104
    private ResponseSender<?> responseSender;
105
 
106
    /**
107
     * Vonage account signature secret. Verification is skipped while this is blank so a new
108
     * environment can be brought up before the secret is in place — same opt-in shape as
109
     * {@code PinelabsWebhookController}. Set it in every environment that faces the internet.
110
     */
111
    @Value("${vonage.webhook.signature.secret:}")
112
    private String webhookSignatureSecret;
113
 
114
    /** TRAI restricts commercial calling to 09:00–21:00. Configurable, but not by accident. */
115
    @Value("${lms.dialer.calling.hours.start:9}")
116
    private int callingHoursStart;
117
 
118
    @Value("${lms.dialer.calling.hours.end:21}")
119
    private int callingHoursEnd;
120
 
121
    @Value("${lms.dialer.enforce.calling.hours:true}")
122
    private boolean enforceCallingHours;
123
 
124
    // ---- Browser-facing -----------------------------------------------------------------------
125
 
126
    /**
127
     * Mint this agent's dialer credential. Short-lived by design — the page asks again rather than
128
     * holding a long-lived token in memory.
129
     */
130
    /**
131
     * How long a placed call may stay unseen in VBC's active-call list before it is written off.
132
     * Generous enough to cover the gap between "VBC returned an id" and "the extension starts
133
     * ringing", short enough that an agent is not left watching a dead progress bar.
134
     */
135
    @Value("${vonage.vbc.initiate.grace.seconds:25}")
136
    private int initiateGraceSeconds;
137
 
138
    @RequestMapping(value = "/lms/dialer/token", method = RequestMethod.GET)
139
    @ResponseBody
140
    public ResponseEntity<?> token(HttpServletRequest request) {
141
        AuthUser me = currentUser(request);
142
        LmsDialerProvider.Handshake handshake = dialerProvider.prepare(me);
143
        Map<String, Object> out = new HashMap<>();
144
        out.put("ok", handshake.ok);
145
        if (!handshake.ok) {
146
            out.put("reason", handshake.reason);
147
            return responseSender.ok(out);
148
        }
149
        out.put("mode", handshake.mode.name());
150
        out.put("token", handshake.token);
151
        out.put("applicationId", handshake.applicationId);
152
        return responseSender.ok(out);
153
    }
154
 
155
    /**
156
     * Gate a dial before it happens: DND and calling hours. Runs server-side because a check that
157
     * only lives in the browser is not a check.
158
     */
159
    @RequestMapping(value = "/lms/dialer/precheck", method = RequestMethod.POST)
160
    @ResponseBody
161
    public ResponseEntity<?> precheck(HttpServletRequest request,
162
                                      @RequestParam(name = "leadId") int leadId) {
163
        Map<String, Object> out = new HashMap<>();
164
        Lead lead = leadRepository.selectById(leadId);
165
        if (lead == null) {
166
            return responseSender.notFound("Lead not found");
167
        }
168
 
169
        LeadDnd blocked = leadDndRepository.selectByMobile(lead.getLeadMobile());
170
        if (blocked != null) {
171
            out.put("ok", false);
172
            out.put("code", "DND");
173
            out.put("reason", "This retailer is on the do-not-call register");
174
            return responseSender.ok(out);
175
        }
176
        if (enforceCallingHours && !withinCallingHours()) {
177
            out.put("ok", false);
178
            out.put("code", "OUTSIDE_HOURS");
179
            out.put("reason", "Commercial calling is only permitted between "
180
                    + callingHoursStart + ":00 and " + callingHoursEnd + ":00");
181
            return responseSender.ok(out);
182
        }
183
        if (vonageVoiceService.toE164(lead.getLeadMobile()) == null) {
184
            out.put("ok", false);
185
            out.put("code", "NO_NUMBER");
186
            out.put("reason", "This lead has no usable contact number");
187
            return responseSender.ok(out);
188
        }
189
 
190
        out.put("ok", true);
191
        out.put("leadId", leadId);
192
        return responseSender.ok(out);
193
    }
194
 
195
    /**
196
     * The call row for a lead, so the browser can learn its {@code leadCallId} after the answer
197
     * webhook has created it. The browser cannot know this id itself — see {@code VonageVoiceService}.
198
     */
199
    @RequestMapping(value = "/lms/dialer/call", method = RequestMethod.GET)
200
    @ResponseBody
201
    public ResponseEntity<?> currentCall(HttpServletRequest request,
202
                                         @RequestParam(name = "leadId") int leadId) {
203
        AuthUser me = currentUser(request);
204
        if (me == null) {
205
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
206
        }
207
        LeadCall call = leadCallRepository.selectLatestByLeadIdAndAuthId(leadId, me.getId());
208
        Map<String, Object> out = new HashMap<>();
209
        if (call == null) {
210
            out.put("found", false);
211
            return responseSender.ok(out);
212
        }
213
        out.put("found", true);
214
        out.put("leadCallId", call.getId());
215
        out.put("status", call.getStatus() != null ? call.getStatus().name() : null);
216
        out.put("durationSeconds", call.getDurationSeconds());
217
        out.put("hasRecording", call.getRecordingDocumentId() != null);
218
        return responseSender.ok(out);
219
    }
220
 
221
    /**
222
     * Place a call server-side (click2dial). The agent's own VBC device rings first, then VBC dials
223
     * the retailer.
224
     *
225
     * <p>Unlike the WebRTC path there is no answer webhook, so the {@code lead_call} row is created
226
     * here — the server knows the lead, the agent and the call id in one place, which makes
227
     * correlation trivial rather than something to reconstruct afterwards.
228
     */
229
    @RequestMapping(value = "/lms/dialer/dial", method = RequestMethod.POST)
230
    @ResponseBody
231
    public ResponseEntity<?> dial(HttpServletRequest request,
232
                                  @RequestParam(name = "leadId") int leadId) {
233
        AuthUser me = currentUser(request);
234
        if (me == null) {
235
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
236
        }
237
        if (vbcDialerProvider == null || vbcTelephonyService == null) {
238
            return responseSender.badRequest("Server-placed calling is not available on this environment.");
239
        }
240
        Lead lead = leadRepository.selectById(leadId);
241
        if (lead == null) {
242
            return responseSender.notFound("Lead not found");
243
        }
244
        // Re-checked here rather than trusting that the pre-check ran, or that nothing changed since.
245
        if (leadDndRepository.selectByMobile(lead.getLeadMobile()) != null) {
246
            return responseSender.badRequest("This retailer is on the do-not-call register.");
247
        }
248
        if (enforceCallingHours && !withinCallingHours()) {
249
            return responseSender.badRequest("Commercial calling is only permitted between "
250
                    + callingHoursStart + ":00 and " + callingHoursEnd + ":00.");
251
        }
252
        String extension = vbcDialerProvider.endpointFor(me);
253
        if (extension == null || extension.trim().isEmpty()) {
254
            return responseSender.badRequest(
255
                    "No VBC " + vbcTelephonyService.fromType() + " is set for your account.");
256
        }
257
 
258
        try {
259
            String callId = vbcDialerProvider.placeCall(me, extension, lead.getLeadMobile());
260
 
261
            LeadCall call = new LeadCall();
262
            call.setLeadId(leadId);
263
            call.setAuthId(me.getId());
264
            call.setProvider(LeadCallProvider.VONAGE);
265
            call.setProviderCallUuid(callId);
266
            call.setDirection("OUTBOUND");
267
            call.setFromNumber(extension.trim());
268
            call.setToNumber(vbcTelephonyService.toE164(lead.getLeadMobile()));
269
            call.setStatus(LeadCallStatus.INITIATED);
270
            call.setStartedAt(LocalDateTime.now());
271
            call.setCreatedTimestamp(LocalDateTime.now());
272
            leadCallRepository.persist(call);
273
 
274
            Map<String, Object> out = new HashMap<>();
275
            out.put("ok", true);
276
            out.put("leadCallId", call.getId());
277
            out.put("extension", extension.trim());
278
            LOGGER.info("VBC click2dial for lead {} by auth {} -> call row {} (vendor {})",
279
                    leadId, me.getId(), call.getId(), callId);
280
            return responseSender.ok(out);
281
        } catch (Exception e) {
282
            LOGGER.error("Could not place a VBC call for lead {}", leadId, e);
283
            return responseSender.badRequest(e.getMessage() == null
284
                    ? "Could not place the call." : e.getMessage());
285
        }
286
    }
287
 
288
    /**
289
     * Poll one call's live state. Needed because click2dial has no event webhook — the browser has
290
     * to ask. Also advances the stored row so the trail and reports see the outcome.
291
     */
292
    @RequestMapping(value = "/lms/dialer/state", method = RequestMethod.GET)
293
    @ResponseBody
294
    public ResponseEntity<?> state(HttpServletRequest request,
295
                                   @RequestParam(name = "leadCallId") int leadCallId) {
296
        LeadCall call = leadCallRepository.selectById(leadCallId);
297
        if (call == null) {
298
            return responseSender.notFound("Call not found");
299
        }
300
        if (!ownsCall(request, call)) {
301
            return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
302
        }
303
        Map<String, Object> out = new HashMap<>();
304
        if (vbcTelephonyService != null && call.getProviderCallUuid() != null
305
                && call.getStatus() != null && !call.getStatus().isTerminal()) {
306
            try {
307
                com.fasterxml.jackson.databind.JsonNode live =
308
                        vbcTelephonyService.fetchCallState(call.getProviderCallUuid());
309
                if (live != null) {
310
                    // Derived from the call's legs — a click2dial call has no top-level status.
311
                    LeadCallStatus mapped = vbcTelephonyService.deriveStatus(live);
312
                    if (mapped != null && mapped.advancesFrom(call.getStatus())) {
313
                        if (mapped == LeadCallStatus.ANSWERED && call.getAnsweredAt() == null) {
314
                            // Prefer VBC's own answer time: polling runs every 3s, so "now" could
315
                            // overstate the answer by almost that much on every call.
316
                            LocalDateTime answeredAt = vbcTelephonyService.retailerAnsweredAt(live);
317
                            call.setAnsweredAt(answeredAt == null ? LocalDateTime.now() : answeredAt);
318
                        }
319
                        call.setStatus(mapped);
320
                        leadCallRepository.persist(call);
321
                    }
322
                } else if (call.getStatus() != LeadCallStatus.INITIATED) {
323
                    // We have seen this call live before, so VBC dropping it means it ended.
324
                    closeCall(call, LeadCallStatus.COMPLETED);
325
                } else if (call.getStartedAt() != null
326
                        && call.getStartedAt().plusSeconds(initiateGraceSeconds).isBefore(LocalDateTime.now())) {
327
                    // Never seen live, and too old to still be appearing: the call was accepted by
328
                    // VBC (it returned an id) but never reached the agent's extension.
329
                    //
330
                    // This branch is load-bearing. /calls only lists calls that are IN PROGRESS —
331
                    // it is not history — so a call that never connects is absent from the moment
332
                    // it is placed. Without a deadline it would sit at INITIATED forever and the
333
                    // browser would poll every 3s for the rest of the session.
334
                    LOGGER.warn("VBC call {} (vendor {}) never appeared as live within {}s — "
335
                                    + "marking failed; usually the extension has no reachable device",
336
                            leadCallId, call.getProviderCallUuid(), initiateGraceSeconds);
337
                    closeCall(call, LeadCallStatus.FAILED);
338
                }
339
            } catch (Exception e) {
340
                LOGGER.warn("Could not refresh VBC state for call {}", leadCallId, e);
341
            }
342
        }
343
        out.put("leadCallId", call.getId());
344
        out.put("status", call.getStatus() == null ? null : call.getStatus().name());
345
        out.put("terminal", call.getStatus() != null && call.getStatus().isTerminal());
346
        out.put("answered", call.getAnsweredAt() != null);
347
        return responseSender.ok(out);
348
    }
349
 
350
    /** Hang up a server-placed call. */
351
    @RequestMapping(value = "/lms/dialer/hangup", method = RequestMethod.POST)
352
    @ResponseBody
353
    public ResponseEntity<?> hangup(HttpServletRequest request,
354
                                    @RequestParam(name = "leadCallId") int leadCallId) {
355
        LeadCall call = leadCallRepository.selectById(leadCallId);
356
        if (call == null) {
357
            return responseSender.notFound("Call not found");
358
        }
359
        if (!ownsCall(request, call)) {
360
            return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
361
        }
362
        if (vbcTelephonyService != null) {
363
            vbcTelephonyService.endCall(call.getProviderCallUuid());
364
        }
365
        if (call.getStatus() != null && !call.getStatus().isTerminal()) {
366
            closeCall(call, LeadCallStatus.COMPLETED);
367
        }
368
        Map<String, Object> out = new HashMap<>();
369
        out.put("ok", true);
370
        return responseSender.ok(out);
371
    }
372
 
373
    private boolean withinCallingHours() {
374
        int hour = LocalTime.now().getHour();
375
        return hour >= callingHoursStart && hour < callingHoursEnd;
376
    }
377
 
378
    // ---- Vonage callbacks ---------------------------------------------------------------------
379
 
380
    /**
381
     * Answer webhook — returns the NCCO. Accepts GET and POST because the method is an Application
382
     * setting rather than something we control per call.
383
     *
384
     * <p>Always returns valid NCCO JSON, never an error status: a 5xx here makes Vonage retry a call
385
     * that is already ringing. An unidentifiable lead yields an empty NCCO, which hangs up cleanly.
386
     */
387
    @RequestMapping(value = "/vonage/voice/answer", method = {RequestMethod.GET, RequestMethod.POST},
388
            produces = MediaType.APPLICATION_JSON_VALUE)
389
    @ResponseBody
390
    public String answer(HttpServletRequest request,
391
                         @RequestBody(required = false) String rawBody) {
392
        try {
393
            JsonNode body = (rawBody != null && !rawBody.trim().isEmpty())
394
                    ? objectMapper.readTree(rawBody) : null;
395
 
396
            String conversationUuid = firstNonNull(
397
                    body != null ? asText(body.get("conversation_uuid")) : null,
398
                    request.getParameter("conversation_uuid"));
399
            String callUuid = firstNonNull(
400
                    body != null ? asText(body.get("uuid")) : null,
401
                    request.getParameter("uuid"));
402
 
403
            // custom_data is what serverCall({leadId}) put on the wire.
404
            int leadId = 0;
405
            if (body != null && body.get("custom_data") != null) {
406
                leadId = asInt(body.get("custom_data").get("leadId"));
407
            }
408
            if (leadId <= 0) {
409
                leadId = parseInt(request.getParameter("leadId"));
410
            }
411
 
412
            // The agent is resolved from `from_user` — the JWT `sub` Vonage authenticated, echoed back
413
            // to us. Deliberately NOT taken from custom_data: this webhook is unauthenticated as far
414
            // as the app is concerned, and an auth id supplied by the caller would let anyone
415
            // attribute a call, and its recording, to another agent. The same lookup yields the
416
            // agent's own caller ID, which is why it must be trustworthy.
417
            VonageAgent agent = resolveAgent(firstNonNull(
418
                    body != null ? asText(body.get("from_user")) : null,
419
                    request.getParameter("from_user")));
420
            int authId = agent != null ? agent.getAuthId() : 0;
421
            String agentFromNumber = agent != null ? agent.getFromNumber() : null;
422
 
423
            Lead lead = leadId > 0 ? leadRepository.selectById(leadId) : null;
424
            if (lead == null) {
425
                LOGGER.error("Vonage answer webhook could not resolve a lead (conversation {})", conversationUuid);
426
                return "[]";
427
            }
428
            // Defence in depth: the number is taken from the lead, and DND is re-checked here rather
429
            // than trusting that the pre-check ran or that nothing changed since.
430
            if (leadDndRepository.selectByMobile(lead.getLeadMobile()) != null) {
431
                LOGGER.warn("Refusing to connect lead {} — number is on the DND register", leadId);
432
                return "[]";
433
            }
434
 
435
            return vonageVoiceService.onAnswer(leadId, authId, agentFromNumber, lead.getLeadMobile(),
436
                    conversationUuid, callUuid);
437
        } catch (Exception e) {
438
            LOGGER.error("Vonage answer webhook failed", e);
439
            return "[]";
440
        }
441
    }
442
 
443
    /** Event webhook — call state transitions. Always ACKs; Vonage retries anything non-2xx. */
444
    @RequestMapping(value = "/vonage/voice/event", method = RequestMethod.POST)
445
    @ResponseBody
446
    public ResponseEntity<?> event(HttpServletRequest request,
447
                                   @RequestHeader(name = "Authorization", required = false) String authorization,
448
                                   @RequestBody(required = false) String rawBody) {
449
        if (!verifySignature(authorization, rawBody)) {
450
            LOGGER.warn("Rejected a Vonage event webhook with a bad signature");
451
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
452
        }
453
        try {
454
            vonageVoiceService.onEvent(objectMapper.readTree(rawBody));
455
        } catch (Exception e) {
456
            LOGGER.error("Vonage event webhook failed for body {}", rawBody, e);
457
        }
458
        return ResponseEntity.ok().build();
459
    }
460
 
461
    /** Recording webhook — attaches the vendor handle; archival happens on its own schedule. */
462
    @RequestMapping(value = "/vonage/voice/recording", method = RequestMethod.POST)
463
    @ResponseBody
464
    public ResponseEntity<?> recording(HttpServletRequest request,
465
                                       @RequestHeader(name = "Authorization", required = false) String authorization,
466
                                       @RequestBody(required = false) String rawBody) {
467
        if (!verifySignature(authorization, rawBody)) {
468
            LOGGER.warn("Rejected a Vonage recording webhook with a bad signature");
469
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
470
        }
471
        try {
472
            vonageVoiceService.onRecording(objectMapper.readTree(rawBody));
473
        } catch (Exception e) {
474
            LOGGER.error("Vonage recording webhook failed for body {}", rawBody, e);
475
        }
476
        return ResponseEntity.ok().build();
477
    }
478
 
479
    /**
480
     * Vonage signed webhooks carry an HS256 JWT in {@code Authorization: Bearer}, signed with the
481
     * account signature secret, whose {@code payload_hash} claim is the SHA-256 of the request body.
482
     * Verifying both the signature and the hash is what stops a replayed body.
483
     *
484
     * <p>Returns true when no secret is configured — deliberate, so a new environment is not bricked
485
     * before the secret lands, and the same opt-in shape the Pinelabs webhook already uses.
486
     */
487
    private boolean verifySignature(String authorization, String rawBody) {
488
        if (webhookSignatureSecret == null || webhookSignatureSecret.trim().isEmpty()) {
489
            return true;
490
        }
491
        if (authorization == null || !authorization.trim().toLowerCase().startsWith("bearer ")) {
492
            return false;
493
        }
494
        try {
495
            String jwt = authorization.trim().substring(7).trim();
496
            String[] parts = jwt.split("\\.");
497
            if (parts.length != 3) {
498
                return false;
499
            }
500
            String signingInput = parts[0] + "." + parts[1];
501
            Mac mac = Mac.getInstance("HmacSHA256");
502
            mac.init(new SecretKeySpec(webhookSignatureSecret.trim().getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
503
            byte[] expected = mac.doFinal(signingInput.getBytes(StandardCharsets.UTF_8));
504
            byte[] actual = Base64.getUrlDecoder().decode(parts[2]);
505
            if (!MessageDigest.isEqual(expected, actual)) {
506
                return false;
507
            }
508
 
509
            JsonNode claims = objectMapper.readTree(Base64.getUrlDecoder().decode(parts[1]));
510
            String payloadHash = asText(claims.get("payload_hash"));
511
            if (payloadHash == null) {
512
                // Signature alone is valid; a hash-less token cannot bind to this body.
513
                return true;
514
            }
515
            byte[] digest = MessageDigest.getInstance("SHA-256")
516
                    .digest((rawBody == null ? "" : rawBody).getBytes(StandardCharsets.UTF_8));
517
            return MessageDigest.isEqual(hex(digest).getBytes(StandardCharsets.UTF_8),
518
                    payloadHash.toLowerCase().getBytes(StandardCharsets.UTF_8));
519
        } catch (Exception e) {
520
            LOGGER.warn("Could not verify a Vonage webhook signature", e);
521
            return false;
522
        }
523
    }
524
 
525
    private String hex(byte[] bytes) {
526
        StringBuilder sb = new StringBuilder(bytes.length * 2);
527
        for (byte b : bytes) {
528
            sb.append(Character.forDigit((b >> 4) & 0xF, 16));
529
            sb.append(Character.forDigit(b & 0xF, 16));
530
        }
531
        return sb.toString();
532
    }
533
 
534
    // ---- helpers ------------------------------------------------------------------------------
535
 
536
    private String asText(JsonNode node) {
537
        if (node == null || node.isNull()) {
538
            return null;
539
        }
540
        String s = node.asText();
541
        return (s == null || s.trim().isEmpty()) ? null : s.trim();
542
    }
543
 
544
    private int asInt(JsonNode node) {
545
        return (node == null || node.isNull()) ? 0 : node.asInt(0);
546
    }
547
 
548
    private int parseInt(String s) {
549
        try {
550
            return (s == null || s.trim().isEmpty()) ? 0 : Integer.parseInt(s.trim());
551
        } catch (NumberFormatException e) {
552
            return 0;
553
        }
554
    }
555
 
556
    private String firstNonNull(String a, String b) {
557
        return a != null ? a : b;
558
    }
559
 
560
    /**
561
     * Vonage user name -> the agent mapping, which carries both our auth id and that agent's own
562
     * caller ID. Returns null when unmapped, leaving the call unattributed and falling back to the
563
     * default caller ID rather than attributing it to the wrong person — an orphan call is
564
     * recoverable, a misattributed recording is not.
565
     */
566
    private VonageAgent resolveAgent(String vonageUserName) {
567
        if (vonageUserName == null || vonageUserName.trim().isEmpty()) {
568
            LOGGER.warn("Vonage answer webhook carried no from_user — call will be unattributed");
569
            return null;
570
        }
571
        VonageAgent agent = vonageAgentRepository.selectByVonageUserName(vonageUserName);
572
        if (agent == null) {
573
            LOGGER.warn("No vonage_agent mapping for Vonage user '{}'", vonageUserName);
574
        }
575
        return agent;
576
    }
577
 
578
    /**
579
     * Put a call into a terminal state: status, end time and duration together.
580
     *
581
     * <p>Duration is measured from {@code answered_at}, not {@code started_at} — it is meant to be
582
     * how long the two people spoke, and a click2dial call spends several seconds ringing the
583
     * agent's own device before the retailer is even dialled. A call that was never answered has a
584
     * duration of zero rather than null, so reporting can distinguish "no conversation" from "not
585
     * recorded yet".
586
     *
587
     * <p>Exists because the three places a call can end were each stamping status and end time by
588
     * hand and none of them set duration at all — {@code setDurationSeconds} was only ever called on
589
     * the WebRTC webhook path, so every click2dial call had a null duration.
590
     */
591
    private void closeCall(LeadCall call, LeadCallStatus status) {
592
        LocalDateTime endedAt = LocalDateTime.now();
593
        call.setStatus(status);
594
        call.setEndedAt(endedAt);
595
        long seconds = call.getAnsweredAt() == null ? 0L
596
                : java.time.Duration.between(call.getAnsweredAt(), endedAt).getSeconds();
597
        call.setDurationSeconds((int) Math.max(0L, seconds));
598
        leadCallRepository.persist(call);
599
    }
600
 
601
    /**
602
     * Whether the caller placed this call.
603
     *
604
     * <p>{@code leadCallId} arrives from the browser, so without this any signed-in user could poll
605
     * another agent's live call or hang it up mid-conversation by guessing a sequential id. Deliberately
606
     * an exact owner check rather than a role check: a supervisor has no reason to drive someone
607
     * else's call from this endpoint, and reporting reads the call rows directly.
608
     */
609
    private boolean ownsCall(HttpServletRequest request, LeadCall call) {
610
        AuthUser me = currentUser(request);
611
        if (me == null || call.getAuthId() != me.getId()) {
612
            LOGGER.warn("Auth {} tried to act on call {} owned by auth {}",
613
                    me == null ? null : me.getId(), call.getId(), call.getAuthId());
614
            return false;
615
        }
616
        return true;
617
    }
618
 
619
    private AuthUser currentUser(HttpServletRequest request) {
620
        try {
621
            LoginDetails loginDetails = cookiesProcessor.getCookiesObject(request);
622
            if (loginDetails != null && loginDetails.getEmailId() != null) {
623
                return authRepository.selectByEmailOrMobile(loginDetails.getEmailId());
624
            }
625
        } catch (Exception e) {
626
            LOGGER.warn("Could not resolve the user for a dialer request", e);
627
        }
628
        return null;
629
    }
630
}