| 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) {
|