| 37759 |
vikas |
1 |
package com.spice.profitmandi.service.lms;
|
|
|
2 |
|
|
|
3 |
import com.fasterxml.jackson.databind.JsonNode;
|
|
|
4 |
import com.spice.profitmandi.dao.entity.auth.AuthUser;
|
|
|
5 |
import com.spice.profitmandi.dao.entity.user.Lead;
|
|
|
6 |
import com.spice.profitmandi.dao.entity.user.LeadCall;
|
|
|
7 |
import com.spice.profitmandi.dao.enumuration.dtr.LeadCallProvider;
|
|
|
8 |
import com.spice.profitmandi.dao.enumuration.dtr.LeadCallStatus;
|
|
|
9 |
import com.spice.profitmandi.dao.repository.dtr.LeadCallRepository;
|
|
|
10 |
import com.spice.profitmandi.dao.repository.dtr.LeadDndRepository;
|
|
|
11 |
import com.spice.profitmandi.dao.repository.dtr.LeadRepository;
|
|
|
12 |
import com.spice.profitmandi.service.integrations.vonage.VonageVoiceService;
|
|
|
13 |
import com.spice.profitmandi.service.integrations.vonage.vbc.VbcDialerProvider;
|
|
|
14 |
import com.spice.profitmandi.service.integrations.vonage.vbc.VbcTelephonyService;
|
|
|
15 |
import org.apache.logging.log4j.LogManager;
|
|
|
16 |
import org.apache.logging.log4j.Logger;
|
|
|
17 |
import org.springframework.beans.factory.annotation.Autowired;
|
|
|
18 |
import org.springframework.beans.factory.annotation.Value;
|
|
|
19 |
import org.springframework.stereotype.Service;
|
|
|
20 |
|
|
|
21 |
import java.time.Duration;
|
|
|
22 |
import java.time.LocalDateTime;
|
|
|
23 |
import java.time.LocalTime;
|
|
|
24 |
|
|
|
25 |
/**
|
|
|
26 |
* Placing, tracking and ending an LMS click2dial call — everything about a call except who is
|
|
|
27 |
* asking.
|
|
|
28 |
*
|
|
|
29 |
* <p><b>Why this is a service and not a controller.</b> Two front ends now place LMS calls: the
|
|
|
30 |
* desk console in {@code profitmandi-fofo}, which identifies its agent from a session cookie, and
|
|
|
31 |
* the field app through {@code profitmandi-web}, which identifies its agent from an {@code
|
|
|
32 |
* Auth-Token} JWT. Only that identity step differs. Everything else — the DND register, the TRAI
|
|
|
33 |
* calling-hours window, endpoint resolution, the {@code user.lead_call} row and the state machine
|
|
|
34 |
* that advances it — must behave identically on both, because both feed the same reports and the
|
|
|
35 |
* same compliance obligations. Two copies of this logic is how a DND check ends up enforced on one
|
|
|
36 |
* path and quietly missing from the other.
|
|
|
37 |
*
|
|
|
38 |
* <p>Callers pass an already-resolved {@link AuthUser} and render {@link LmsOutcome} into whatever
|
|
|
39 |
* response envelope their module uses.
|
|
|
40 |
*/
|
|
|
41 |
@Service
|
|
|
42 |
public class LmsCallService {
|
|
|
43 |
|
|
|
44 |
private static final Logger LOGGER = LogManager.getLogger(LmsCallService.class);
|
|
|
45 |
|
|
|
46 |
@Autowired
|
|
|
47 |
private LeadRepository leadRepository;
|
|
|
48 |
|
|
|
49 |
@Autowired
|
|
|
50 |
private LeadCallRepository leadCallRepository;
|
|
|
51 |
|
|
|
52 |
@Autowired
|
|
|
53 |
private LeadDndRepository leadDndRepository;
|
|
|
54 |
|
|
|
55 |
@Autowired
|
|
|
56 |
private VonageVoiceService vonageVoiceService;
|
|
|
57 |
|
|
|
58 |
/** Optional: only present when VBC is the active provider. */
|
|
|
59 |
@Autowired(required = false)
|
|
|
60 |
private VbcDialerProvider vbcDialerProvider;
|
|
|
61 |
|
|
|
62 |
@Autowired(required = false)
|
|
|
63 |
private VbcTelephonyService vbcTelephonyService;
|
|
|
64 |
|
|
|
65 |
/** TRAI restricts commercial calling to 09:00–21:00. Configurable, but not by accident. */
|
|
|
66 |
@Value("${lms.dialer.calling.hours.start:9}")
|
|
|
67 |
private int callingHoursStart;
|
|
|
68 |
|
|
|
69 |
@Value("${lms.dialer.calling.hours.end:21}")
|
|
|
70 |
private int callingHoursEnd;
|
|
|
71 |
|
|
|
72 |
@Value("${lms.dialer.enforce.calling.hours:true}")
|
|
|
73 |
private boolean enforceCallingHours;
|
|
|
74 |
|
|
|
75 |
/**
|
|
|
76 |
* How long a placed call may stay unseen in VBC's active-call list before it is written off.
|
|
|
77 |
* Generous enough to cover the gap between "VBC returned an id" and "the extension starts
|
|
|
78 |
* ringing", short enough that an agent is not left watching a dead progress bar.
|
|
|
79 |
*/
|
|
|
80 |
@Value("${vonage.vbc.initiate.grace.seconds:25}")
|
|
|
81 |
private int initiateGraceSeconds;
|
|
|
82 |
|
|
|
83 |
// ---- Pre-flight ----------------------------------------------------------------------------
|
|
|
84 |
|
|
|
85 |
/**
|
|
|
86 |
* Gate a dial before it happens: DND and calling hours. Runs server-side because a check that
|
|
|
87 |
* only lives in the client is not a check.
|
|
|
88 |
*/
|
|
|
89 |
public LmsOutcome precheck(int leadId) {
|
|
|
90 |
Lead lead = leadRepository.selectById(leadId);
|
|
|
91 |
if (lead == null) {
|
|
|
92 |
return LmsOutcome.notFound("Lead not found");
|
|
|
93 |
}
|
|
|
94 |
LmsOutcome refusal = refusalFor(lead);
|
|
|
95 |
if (refusal != null) {
|
|
|
96 |
return refusal;
|
|
|
97 |
}
|
|
|
98 |
return LmsOutcome.ok().with("ok", true).with("leadId", leadId);
|
|
|
99 |
}
|
|
|
100 |
|
|
|
101 |
/**
|
|
|
102 |
* The reason this lead may not be dialled right now, or null when it may.
|
|
|
103 |
*
|
|
|
104 |
* <p>Shared by {@link #precheck} and {@link #dial} on purpose: the dial re-runs it rather than
|
|
|
105 |
* trusting that the pre-check ran, or that nothing changed in between.
|
|
|
106 |
*/
|
|
|
107 |
private LmsOutcome refusalFor(Lead lead) {
|
|
|
108 |
if (leadDndRepository.selectByMobile(lead.getLeadMobile()) != null) {
|
|
|
109 |
return LmsOutcome.refused("DND", "This retailer is on the do-not-call register");
|
|
|
110 |
}
|
|
|
111 |
if (enforceCallingHours && !withinCallingHours()) {
|
|
|
112 |
return LmsOutcome.refused("OUTSIDE_HOURS", "Commercial calling is only permitted between "
|
|
|
113 |
+ callingHoursStart + ":00 and " + callingHoursEnd + ":00");
|
|
|
114 |
}
|
|
|
115 |
if (vonageVoiceService.toE164(lead.getLeadMobile()) == null) {
|
|
|
116 |
return LmsOutcome.refused("NO_NUMBER", "This lead has no usable contact number");
|
|
|
117 |
}
|
|
|
118 |
return null;
|
|
|
119 |
}
|
|
|
120 |
|
|
|
121 |
private boolean withinCallingHours() {
|
|
|
122 |
int hour = LocalTime.now().getHour();
|
|
|
123 |
return hour >= callingHoursStart && hour < callingHoursEnd;
|
|
|
124 |
}
|
|
|
125 |
|
|
|
126 |
// ---- Placing -------------------------------------------------------------------------------
|
|
|
127 |
|
|
|
128 |
/**
|
|
|
129 |
* Place a call server-side (click2dial). The agent's own VBC endpoint rings first, then VBC
|
|
|
130 |
* dials the retailer.
|
|
|
131 |
*
|
|
|
132 |
* <p>Unlike the WebRTC path there is no answer webhook, so the {@code lead_call} row is created
|
|
|
133 |
* here — the server knows the lead, the agent and the call id in one place, which makes
|
|
|
134 |
* correlation trivial rather than something to reconstruct afterwards.
|
|
|
135 |
*/
|
|
|
136 |
public LmsOutcome dial(AuthUser agent, int leadId) {
|
|
|
137 |
if (agent == null) {
|
|
|
138 |
return LmsOutcome.unauthorized();
|
|
|
139 |
}
|
|
|
140 |
if (vbcDialerProvider == null || vbcTelephonyService == null) {
|
|
|
141 |
return LmsOutcome.unavailable("Server-placed calling is not available on this environment.");
|
|
|
142 |
}
|
|
|
143 |
Lead lead = leadRepository.selectById(leadId);
|
|
|
144 |
if (lead == null) {
|
|
|
145 |
return LmsOutcome.notFound("Lead not found");
|
|
|
146 |
}
|
|
|
147 |
LmsOutcome refusal = refusalFor(lead);
|
|
|
148 |
if (refusal != null) {
|
|
|
149 |
return refusal;
|
|
|
150 |
}
|
|
|
151 |
|
|
|
152 |
VbcDialerProvider.AgentEndpoint endpoint = vbcDialerProvider.resolveEndpoint(agent);
|
|
|
153 |
if (endpoint == null) {
|
|
|
154 |
return LmsOutcome.refused("NO_ENDPOINT",
|
|
|
155 |
"No VBC extension or device is set for your account.");
|
|
|
156 |
}
|
|
|
157 |
|
|
|
158 |
try {
|
|
|
159 |
String callId = vbcTelephonyService.placeCall(endpoint.type, endpoint.destination,
|
|
|
160 |
lead.getLeadMobile());
|
|
|
161 |
|
|
|
162 |
LeadCall call = new LeadCall();
|
|
|
163 |
call.setLeadId(leadId);
|
|
|
164 |
call.setAuthId(agent.getId());
|
|
|
165 |
call.setProvider(LeadCallProvider.VONAGE);
|
|
|
166 |
call.setProviderCallUuid(callId);
|
|
|
167 |
call.setDirection("OUTBOUND");
|
|
|
168 |
call.setFromNumber(endpoint.destination);
|
|
|
169 |
call.setToNumber(vbcTelephonyService.toE164(lead.getLeadMobile()));
|
|
|
170 |
call.setStatus(LeadCallStatus.INITIATED);
|
|
|
171 |
call.setStartedAt(LocalDateTime.now());
|
|
|
172 |
call.setCreatedTimestamp(LocalDateTime.now());
|
|
|
173 |
leadCallRepository.persist(call);
|
|
|
174 |
|
|
|
175 |
LOGGER.info("VBC click2dial for lead {} by auth {} -> call row {} (vendor {}, {} {})",
|
|
|
176 |
leadId, agent.getId(), call.getId(), callId, endpoint.type, endpoint.destination);
|
|
|
177 |
return LmsOutcome.ok()
|
|
|
178 |
.with("ok", true)
|
|
|
179 |
.with("leadCallId", call.getId())
|
|
|
180 |
.with("extension", endpoint.destination);
|
|
|
181 |
} catch (Exception e) {
|
|
|
182 |
LOGGER.error("Could not place a VBC call for lead {}", leadId, e);
|
|
|
183 |
return LmsOutcome.refused("PLACE_FAILED", e.getMessage() == null
|
|
|
184 |
? "Could not place the call." : e.getMessage());
|
|
|
185 |
}
|
|
|
186 |
}
|
|
|
187 |
|
|
|
188 |
// ---- Tracking ------------------------------------------------------------------------------
|
|
|
189 |
|
|
|
190 |
/**
|
|
|
191 |
* Poll one call's live state. Needed because click2dial has no event webhook — the client has
|
|
|
192 |
* to ask. Also advances the stored row so the trail and reports see the outcome.
|
|
|
193 |
*/
|
|
|
194 |
public LmsOutcome state(AuthUser agent, int leadCallId) {
|
|
|
195 |
LeadCall call = leadCallRepository.selectById(leadCallId);
|
|
|
196 |
if (call == null) {
|
|
|
197 |
return LmsOutcome.notFound("Call not found");
|
|
|
198 |
}
|
|
|
199 |
if (!owns(agent, call)) {
|
|
|
200 |
return LmsOutcome.forbidden();
|
|
|
201 |
}
|
|
|
202 |
if (vbcTelephonyService != null && call.getProviderCallUuid() != null
|
|
|
203 |
&& call.getStatus() != null && !call.getStatus().isTerminal()) {
|
|
|
204 |
try {
|
|
|
205 |
JsonNode live = vbcTelephonyService.fetchCallState(call.getProviderCallUuid());
|
|
|
206 |
if (live != null) {
|
|
|
207 |
// Derived from the call's legs — a click2dial call has no top-level status.
|
|
|
208 |
LeadCallStatus mapped = vbcTelephonyService.deriveStatus(live);
|
|
|
209 |
if (mapped != null && mapped.advancesFrom(call.getStatus())) {
|
|
|
210 |
if (mapped == LeadCallStatus.ANSWERED && call.getAnsweredAt() == null) {
|
|
|
211 |
// Prefer VBC's own answer time: polling runs every 3s, so "now" could
|
|
|
212 |
// overstate the answer by almost that much on every call.
|
|
|
213 |
LocalDateTime answeredAt = vbcTelephonyService.retailerAnsweredAt(live);
|
|
|
214 |
call.setAnsweredAt(answeredAt == null ? LocalDateTime.now() : answeredAt);
|
|
|
215 |
}
|
|
|
216 |
call.setStatus(mapped);
|
|
|
217 |
leadCallRepository.persist(call);
|
|
|
218 |
}
|
|
|
219 |
} else if (call.getStatus() != LeadCallStatus.INITIATED) {
|
|
|
220 |
// We have seen this call live before, so VBC dropping it means it ended.
|
|
|
221 |
closeCall(call, LeadCallStatus.COMPLETED);
|
|
|
222 |
} else if (call.getStartedAt() != null
|
|
|
223 |
&& call.getStartedAt().plusSeconds(initiateGraceSeconds).isBefore(LocalDateTime.now())) {
|
|
|
224 |
// Never seen live, and too old to still be appearing: the call was accepted by
|
|
|
225 |
// VBC (it returned an id) but never reached the agent's endpoint.
|
|
|
226 |
//
|
|
|
227 |
// This branch is load-bearing. /calls only lists calls that are IN PROGRESS —
|
|
|
228 |
// it is not history — so a call that never connects is absent from the moment
|
|
|
229 |
// it is placed. Without a deadline it would sit at INITIATED forever and the
|
|
|
230 |
// client would poll every 3s for the rest of the session.
|
|
|
231 |
LOGGER.warn("VBC call {} (vendor {}) never appeared as live within {}s — "
|
|
|
232 |
+ "marking failed; usually the endpoint has no reachable device",
|
|
|
233 |
leadCallId, call.getProviderCallUuid(), initiateGraceSeconds);
|
|
|
234 |
closeCall(call, LeadCallStatus.FAILED);
|
|
|
235 |
}
|
|
|
236 |
} catch (Exception e) {
|
|
|
237 |
LOGGER.warn("Could not refresh VBC state for call {}", leadCallId, e);
|
|
|
238 |
}
|
|
|
239 |
}
|
|
|
240 |
return LmsOutcome.ok()
|
|
|
241 |
.with("leadCallId", call.getId())
|
|
|
242 |
.with("status", call.getStatus() == null ? null : call.getStatus().name())
|
|
|
243 |
.with("terminal", call.getStatus() != null && call.getStatus().isTerminal())
|
|
|
244 |
.with("answered", call.getAnsweredAt() != null)
|
|
|
245 |
.with("durationSeconds", call.getDurationSeconds());
|
|
|
246 |
}
|
|
|
247 |
|
|
|
248 |
/** Hang up a server-placed call. */
|
|
|
249 |
public LmsOutcome hangup(AuthUser agent, int leadCallId) {
|
|
|
250 |
LeadCall call = leadCallRepository.selectById(leadCallId);
|
|
|
251 |
if (call == null) {
|
|
|
252 |
return LmsOutcome.notFound("Call not found");
|
|
|
253 |
}
|
|
|
254 |
if (!owns(agent, call)) {
|
|
|
255 |
return LmsOutcome.forbidden();
|
|
|
256 |
}
|
|
|
257 |
if (vbcTelephonyService != null) {
|
|
|
258 |
vbcTelephonyService.endCall(call.getProviderCallUuid());
|
|
|
259 |
}
|
|
|
260 |
if (call.getStatus() != null && !call.getStatus().isTerminal()) {
|
|
|
261 |
closeCall(call, LeadCallStatus.COMPLETED);
|
|
|
262 |
}
|
|
|
263 |
return LmsOutcome.ok().with("ok", true);
|
|
|
264 |
}
|
|
|
265 |
|
|
|
266 |
/**
|
|
|
267 |
* Put a call into a terminal state: status, end time and duration together.
|
|
|
268 |
*
|
|
|
269 |
* <p>Duration is measured from {@code answered_at}, not {@code started_at} — it is meant to be
|
|
|
270 |
* how long the two people spoke, and a click2dial call spends several seconds ringing the
|
|
|
271 |
* agent's own device before the retailer is even dialled. A call that was never answered has a
|
|
|
272 |
* duration of zero rather than null, so reporting can distinguish "no conversation" from "not
|
|
|
273 |
* recorded yet".
|
|
|
274 |
*/
|
|
|
275 |
private void closeCall(LeadCall call, LeadCallStatus status) {
|
|
|
276 |
LocalDateTime endedAt = LocalDateTime.now();
|
|
|
277 |
call.setStatus(status);
|
|
|
278 |
call.setEndedAt(endedAt);
|
|
|
279 |
long seconds = call.getAnsweredAt() == null ? 0L
|
|
|
280 |
: Duration.between(call.getAnsweredAt(), endedAt).getSeconds();
|
|
|
281 |
call.setDurationSeconds((int) Math.max(0L, seconds));
|
|
|
282 |
leadCallRepository.persist(call);
|
|
|
283 |
}
|
|
|
284 |
|
|
|
285 |
/**
|
|
|
286 |
* Whether the caller placed this call.
|
|
|
287 |
*
|
|
|
288 |
* <p>{@code leadCallId} arrives from the client, so without this any signed-in user could poll
|
|
|
289 |
* another agent's live call or hang it up mid-conversation by guessing a sequential id.
|
|
|
290 |
* Deliberately an exact owner check rather than a role check: a supervisor has no reason to
|
|
|
291 |
* drive someone else's call from these endpoints, and reporting reads the call rows directly.
|
|
|
292 |
*/
|
|
|
293 |
private boolean owns(AuthUser agent, LeadCall call) {
|
|
|
294 |
if (agent == null || call.getAuthId() != agent.getId()) {
|
|
|
295 |
LOGGER.warn("Auth {} tried to act on call {} owned by auth {}",
|
|
|
296 |
agent == null ? null : agent.getId(), call.getId(), call.getAuthId());
|
|
|
297 |
return false;
|
|
|
298 |
}
|
|
|
299 |
return true;
|
|
|
300 |
}
|
|
|
301 |
}
|