Subversion Repositories SmartDukaan

Rev

Rev 37651 | Show entire file | Ignore whitespace | Details | Blame | Last modification | View Log | RSS feed

Rev 37651 Rev 37749
Line 11... Line 11...
11
import com.spice.profitmandi.dao.repository.auth.AuthRepository;
11
import com.spice.profitmandi.dao.repository.auth.AuthRepository;
12
import com.spice.profitmandi.dao.repository.auth.VonageAgentRepository;
12
import com.spice.profitmandi.dao.repository.auth.VonageAgentRepository;
13
import com.spice.profitmandi.dao.repository.dtr.LeadCallRepository;
13
import com.spice.profitmandi.dao.repository.dtr.LeadCallRepository;
14
import com.spice.profitmandi.dao.repository.dtr.LeadDndRepository;
14
import com.spice.profitmandi.dao.repository.dtr.LeadDndRepository;
15
import com.spice.profitmandi.dao.repository.dtr.LeadRepository;
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;
16
import com.spice.profitmandi.service.integrations.vonage.VonageVoiceService;
19
import com.spice.profitmandi.service.integrations.vonage.vbc.VbcDialerProvider;
17
import com.spice.profitmandi.service.lms.LmsCallService;
20
import com.spice.profitmandi.service.integrations.vonage.vbc.VbcTelephonyService;
18
import com.spice.profitmandi.service.lms.LmsOutcome;
21
import com.spice.profitmandi.service.lms.LmsDialerProvider;
19
import com.spice.profitmandi.service.lms.LmsDialerProvider;
22
import com.spice.profitmandi.web.model.LoginDetails;
20
import com.spice.profitmandi.web.model.LoginDetails;
23
import com.spice.profitmandi.web.util.CookiesProcessor;
21
import com.spice.profitmandi.web.util.CookiesProcessor;
24
import org.apache.logging.log4j.LogManager;
22
import org.apache.logging.log4j.LogManager;
25
import org.apache.logging.log4j.Logger;
23
import org.apache.logging.log4j.Logger;
Line 80... Line 78...
80
 
78
 
81
    @Autowired
79
    @Autowired
82
    private LmsDialerProvider dialerProvider;
80
    private LmsDialerProvider dialerProvider;
83
 
81
 
84
    /**
82
    /**
85
     * Optional: only present when VBC is the active provider. Injected by type rather than through
83
     * Placing, tracking and ending a call — shared with the field app's endpoints in
86
     * the interface because placing a call needs the agent's extension, which is VBC-specific.
84
     * profitmandi-web, which differ from these only in how they identify the agent.
87
     */
85
     */
88
    @Autowired(required = false)
-
 
89
    private VbcDialerProvider vbcDialerProvider;
-
 
90
 
-
 
91
    @Autowired(required = false)
86
    @Autowired
92
    private VbcTelephonyService vbcTelephonyService;
87
    private LmsCallService lmsCallService;
93
 
88
 
94
    @Autowired
89
    @Autowired
95
    private AuthRepository authRepository;
90
    private AuthRepository authRepository;
96
 
91
 
97
    @Autowired
92
    @Autowired
Line 109... Line 104...
109
     * {@code PinelabsWebhookController}. Set it in every environment that faces the internet.
104
     * {@code PinelabsWebhookController}. Set it in every environment that faces the internet.
110
     */
105
     */
111
    @Value("${vonage.webhook.signature.secret:}")
106
    @Value("${vonage.webhook.signature.secret:}")
112
    private String webhookSignatureSecret;
107
    private String webhookSignatureSecret;
113
 
108
 
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 -----------------------------------------------------------------------
109
    // ---- Browser-facing -----------------------------------------------------------------------
125
 
110
 
126
    /**
111
    /**
127
     * Mint this agent's dialer credential. Short-lived by design — the page asks again rather than
112
     * Mint this agent's dialer credential. Short-lived by design — the page asks again rather than
128
     * holding a long-lived token in memory.
113
     * holding a long-lived token in memory.
129
     */
114
     */
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)
115
    @RequestMapping(value = "/lms/dialer/token", method = RequestMethod.GET)
139
    @ResponseBody
116
    @ResponseBody
140
    public ResponseEntity<?> token(HttpServletRequest request) {
117
    public ResponseEntity<?> token(HttpServletRequest request) {
141
        AuthUser me = currentUser(request);
118
        AuthUser me = currentUser(request);
142
        LmsDialerProvider.Handshake handshake = dialerProvider.prepare(me);
119
        LmsDialerProvider.Handshake handshake = dialerProvider.prepare(me);
Line 151... Line 128...
151
        out.put("applicationId", handshake.applicationId);
128
        out.put("applicationId", handshake.applicationId);
152
        return responseSender.ok(out);
129
        return responseSender.ok(out);
153
    }
130
    }
154
 
131
 
155
    /**
132
    /**
156
     * Gate a dial before it happens: DND and calling hours. Runs server-side because a check that
133
     * Gate a dial before it happens: DND and calling hours.
-
 
134
     *
-
 
135
     * <p>A refusal is a 200 carrying {@code {ok:false, code, reason}} — "this retailer is on the
-
 
136
     * DND register" is a normal answer to "may I dial?", not a client error, and the browser needs
157
     * only lives in the browser is not a check.
137
     * the code to decide what to show.
158
     */
138
     */
159
    @RequestMapping(value = "/lms/dialer/precheck", method = RequestMethod.POST)
139
    @RequestMapping(value = "/lms/dialer/precheck", method = RequestMethod.POST)
160
    @ResponseBody
140
    @ResponseBody
161
    public ResponseEntity<?> precheck(HttpServletRequest request,
141
    public ResponseEntity<?> precheck(HttpServletRequest request,
162
                                      @RequestParam(name = "leadId") int leadId) {
142
                                      @RequestParam(name = "leadId") int leadId) {
163
        Map<String, Object> out = new HashMap<>();
-
 
164
        Lead lead = leadRepository.selectById(leadId);
143
        LmsOutcome outcome = lmsCallService.precheck(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()) {
144
        if (outcome.status == LmsOutcome.Status.REFUSED) {
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);
145
            return responseSender.ok(outcome.asRefusalBody());
188
        }
146
        }
189
 
-
 
190
        out.put("ok", true);
-
 
191
        out.put("leadId", leadId);
-
 
192
        return responseSender.ok(out);
147
        return render(outcome);
193
    }
148
    }
194
 
149
 
195
    /**
150
    /**
196
     * The call row for a lead, so the browser can learn its {@code leadCallId} after the answer
151
     * 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}.
152
     * webhook has created it. The browser cannot know this id itself — see {@code VonageVoiceService}.
Line 216... Line 171...
216
        out.put("durationSeconds", call.getDurationSeconds());
171
        out.put("durationSeconds", call.getDurationSeconds());
217
        out.put("hasRecording", call.getRecordingDocumentId() != null);
172
        out.put("hasRecording", call.getRecordingDocumentId() != null);
218
        return responseSender.ok(out);
173
        return responseSender.ok(out);
219
    }
174
    }
220
 
175
 
221
    /**
-
 
222
     * Place a call server-side (click2dial). The agent's own VBC device rings first, then VBC dials
176
    /** Place a call server-side (click2dial). */
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)
177
    @RequestMapping(value = "/lms/dialer/dial", method = RequestMethod.POST)
230
    @ResponseBody
178
    @ResponseBody
231
    public ResponseEntity<?> dial(HttpServletRequest request,
179
    public ResponseEntity<?> dial(HttpServletRequest request,
232
                                  @RequestParam(name = "leadId") int leadId) {
180
                                  @RequestParam(name = "leadId") int leadId) {
233
        AuthUser me = currentUser(request);
181
        return render(lmsCallService.dial(currentUser(request), leadId));
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
    }
182
    }
287
 
183
 
288
    /**
-
 
289
     * Poll one call's live state. Needed because click2dial has no event webhook — the browser has
184
    /** Poll one call's live state — click2dial has no event webhook, so the browser has to ask. */
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)
185
    @RequestMapping(value = "/lms/dialer/state", method = RequestMethod.GET)
293
    @ResponseBody
186
    @ResponseBody
294
    public ResponseEntity<?> state(HttpServletRequest request,
187
    public ResponseEntity<?> state(HttpServletRequest request,
295
                                   @RequestParam(name = "leadCallId") int leadCallId) {
188
                                   @RequestParam(name = "leadCallId") int leadCallId) {
296
        LeadCall call = leadCallRepository.selectById(leadCallId);
189
        return render(lmsCallService.state(currentUser(request), 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
    }
190
    }
349
 
191
 
350
    /** Hang up a server-placed call. */
192
    /** Hang up a server-placed call. */
351
    @RequestMapping(value = "/lms/dialer/hangup", method = RequestMethod.POST)
193
    @RequestMapping(value = "/lms/dialer/hangup", method = RequestMethod.POST)
352
    @ResponseBody
194
    @ResponseBody
353
    public ResponseEntity<?> hangup(HttpServletRequest request,
195
    public ResponseEntity<?> hangup(HttpServletRequest request,
354
                                    @RequestParam(name = "leadCallId") int leadCallId) {
196
                                    @RequestParam(name = "leadCallId") int leadCallId) {
355
        LeadCall call = leadCallRepository.selectById(leadCallId);
197
        return render(lmsCallService.hangup(currentUser(request), 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
    }
198
    }
372
 
199
 
-
 
200
    /** Map a service outcome onto this module's response envelope. */
373
    private boolean withinCallingHours() {
201
    private ResponseEntity<?> render(LmsOutcome outcome) {
374
        int hour = LocalTime.now().getHour();
202
        switch (outcome.status) {
-
 
203
            case OK:
-
 
204
                return responseSender.ok(outcome.data);
-
 
205
            case NOT_FOUND:
-
 
206
                return responseSender.notFound(outcome.message);
-
 
207
            case UNAUTHORIZED:
-
 
208
                return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
-
 
209
            case FORBIDDEN:
375
        return hour >= callingHoursStart && hour < callingHoursEnd;
210
                return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
-
 
211
            default:
-
 
212
                return responseSender.badRequest(outcome.message);
-
 
213
        }
376
    }
214
    }
377
 
215
 
378
    // ---- Vonage callbacks ---------------------------------------------------------------------
216
    // ---- Vonage callbacks ---------------------------------------------------------------------
379
 
217
 
380
    /**
218
    /**
Line 573... Line 411...
573
            LOGGER.warn("No vonage_agent mapping for Vonage user '{}'", vonageUserName);
411
            LOGGER.warn("No vonage_agent mapping for Vonage user '{}'", vonageUserName);
574
        }
412
        }
575
        return agent;
413
        return agent;
576
    }
414
    }
577
 
415
 
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
 
416
 
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
 
417
 
619
    private AuthUser currentUser(HttpServletRequest request) {
418
    private AuthUser currentUser(HttpServletRequest request) {
620
        try {
419
        try {
621
            LoginDetails loginDetails = cookiesProcessor.getCookiesObject(request);
420
            LoginDetails loginDetails = cookiesProcessor.getCookiesObject(request);
622
            if (loginDetails != null && loginDetails.getEmailId() != null) {
421
            if (loginDetails != null && loginDetails.getEmailId() != null) {