Subversion Repositories SmartDukaan

Rev

Details | Last modification | View Log | RSS feed

Rev Author Line No. Line
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
}