Blame | Last modification | View Log | RSS feed
package com.spice.profitmandi.service.lms;import com.fasterxml.jackson.databind.JsonNode;import com.spice.profitmandi.dao.entity.auth.AuthUser;import com.spice.profitmandi.dao.entity.user.Lead;import com.spice.profitmandi.dao.entity.user.LeadCall;import com.spice.profitmandi.dao.enumuration.dtr.LeadCallProvider;import com.spice.profitmandi.dao.enumuration.dtr.LeadCallStatus;import com.spice.profitmandi.dao.repository.dtr.LeadCallRepository;import com.spice.profitmandi.dao.repository.dtr.LeadDndRepository;import com.spice.profitmandi.dao.repository.dtr.LeadRepository;import com.spice.profitmandi.service.integrations.vonage.VonageVoiceService;import com.spice.profitmandi.service.integrations.vonage.vbc.VbcDialerProvider;import com.spice.profitmandi.service.integrations.vonage.vbc.VbcTelephonyService;import org.apache.logging.log4j.LogManager;import org.apache.logging.log4j.Logger;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.beans.factory.annotation.Value;import org.springframework.stereotype.Service;import java.time.Duration;import java.time.LocalDateTime;import java.time.LocalTime;/*** Placing, tracking and ending an LMS click2dial call — everything about a call except who is* asking.** <p><b>Why this is a service and not a controller.</b> Two front ends now place LMS calls: the* desk console in {@code profitmandi-fofo}, which identifies its agent from a session cookie, and* the field app through {@code profitmandi-web}, which identifies its agent from an {@code* Auth-Token} JWT. Only that identity step differs. Everything else — the DND register, the TRAI* calling-hours window, endpoint resolution, the {@code user.lead_call} row and the state machine* that advances it — must behave identically on both, because both feed the same reports and the* same compliance obligations. Two copies of this logic is how a DND check ends up enforced on one* path and quietly missing from the other.** <p>Callers pass an already-resolved {@link AuthUser} and render {@link LmsOutcome} into whatever* response envelope their module uses.*/@Servicepublic class LmsCallService {private static final Logger LOGGER = LogManager.getLogger(LmsCallService.class);@Autowiredprivate LeadRepository leadRepository;@Autowiredprivate LeadCallRepository leadCallRepository;@Autowiredprivate LeadDndRepository leadDndRepository;@Autowiredprivate VonageVoiceService vonageVoiceService;/** Optional: only present when VBC is the active provider. */@Autowired(required = false)private VbcDialerProvider vbcDialerProvider;@Autowired(required = false)private VbcTelephonyService vbcTelephonyService;/** TRAI restricts commercial calling to 09:00–21:00. Configurable, but not by accident. */@Value("${lms.dialer.calling.hours.start:9}")private int callingHoursStart;@Value("${lms.dialer.calling.hours.end:21}")private int callingHoursEnd;@Value("${lms.dialer.enforce.calling.hours:true}")private boolean enforceCallingHours;/*** How long a placed call may stay unseen in VBC's active-call list before it is written off.* Generous enough to cover the gap between "VBC returned an id" and "the extension starts* ringing", short enough that an agent is not left watching a dead progress bar.*/@Value("${vonage.vbc.initiate.grace.seconds:25}")private int initiateGraceSeconds;// ---- Pre-flight ----------------------------------------------------------------------------/*** Gate a dial before it happens: DND and calling hours. Runs server-side because a check that* only lives in the client is not a check.*/public LmsOutcome precheck(int leadId) {Lead lead = leadRepository.selectById(leadId);if (lead == null) {return LmsOutcome.notFound("Lead not found");}LmsOutcome refusal = refusalFor(lead);if (refusal != null) {return refusal;}return LmsOutcome.ok().with("ok", true).with("leadId", leadId);}/*** The reason this lead may not be dialled right now, or null when it may.** <p>Shared by {@link #precheck} and {@link #dial} on purpose: the dial re-runs it rather than* trusting that the pre-check ran, or that nothing changed in between.*/private LmsOutcome refusalFor(Lead lead) {if (leadDndRepository.selectByMobile(lead.getLeadMobile()) != null) {return LmsOutcome.refused("DND", "This retailer is on the do-not-call register");}if (enforceCallingHours && !withinCallingHours()) {return LmsOutcome.refused("OUTSIDE_HOURS", "Commercial calling is only permitted between "+ callingHoursStart + ":00 and " + callingHoursEnd + ":00");}if (vonageVoiceService.toE164(lead.getLeadMobile()) == null) {return LmsOutcome.refused("NO_NUMBER", "This lead has no usable contact number");}return null;}private boolean withinCallingHours() {int hour = LocalTime.now().getHour();return hour >= callingHoursStart && hour < callingHoursEnd;}// ---- Placing -------------------------------------------------------------------------------/*** Place a call server-side (click2dial). The agent's own VBC endpoint rings first, then VBC* dials the retailer.** <p>Unlike the WebRTC path there is no answer webhook, so the {@code lead_call} row is created* here — the server knows the lead, the agent and the call id in one place, which makes* correlation trivial rather than something to reconstruct afterwards.*/public LmsOutcome dial(AuthUser agent, int leadId) {if (agent == null) {return LmsOutcome.unauthorized();}if (vbcDialerProvider == null || vbcTelephonyService == null) {return LmsOutcome.unavailable("Server-placed calling is not available on this environment.");}Lead lead = leadRepository.selectById(leadId);if (lead == null) {return LmsOutcome.notFound("Lead not found");}LmsOutcome refusal = refusalFor(lead);if (refusal != null) {return refusal;}VbcDialerProvider.AgentEndpoint endpoint = vbcDialerProvider.resolveEndpoint(agent);if (endpoint == null) {return LmsOutcome.refused("NO_ENDPOINT","No VBC extension or device is set for your account.");}try {String callId = vbcTelephonyService.placeCall(endpoint.type, endpoint.destination,lead.getLeadMobile());LeadCall call = new LeadCall();call.setLeadId(leadId);call.setAuthId(agent.getId());call.setProvider(LeadCallProvider.VONAGE);call.setProviderCallUuid(callId);call.setDirection("OUTBOUND");call.setFromNumber(endpoint.destination);call.setToNumber(vbcTelephonyService.toE164(lead.getLeadMobile()));call.setStatus(LeadCallStatus.INITIATED);call.setStartedAt(LocalDateTime.now());call.setCreatedTimestamp(LocalDateTime.now());leadCallRepository.persist(call);LOGGER.info("VBC click2dial for lead {} by auth {} -> call row {} (vendor {}, {} {})",leadId, agent.getId(), call.getId(), callId, endpoint.type, endpoint.destination);return LmsOutcome.ok().with("ok", true).with("leadCallId", call.getId()).with("extension", endpoint.destination);} catch (Exception e) {LOGGER.error("Could not place a VBC call for lead {}", leadId, e);return LmsOutcome.refused("PLACE_FAILED", e.getMessage() == null? "Could not place the call." : e.getMessage());}}// ---- Tracking ------------------------------------------------------------------------------/*** Poll one call's live state. Needed because click2dial has no event webhook — the client has* to ask. Also advances the stored row so the trail and reports see the outcome.*/public LmsOutcome state(AuthUser agent, int leadCallId) {LeadCall call = leadCallRepository.selectById(leadCallId);if (call == null) {return LmsOutcome.notFound("Call not found");}if (!owns(agent, call)) {return LmsOutcome.forbidden();}if (vbcTelephonyService != null && call.getProviderCallUuid() != null&& call.getStatus() != null && !call.getStatus().isTerminal()) {try {JsonNode live = vbcTelephonyService.fetchCallState(call.getProviderCallUuid());if (live != null) {// Derived from the call's legs — a click2dial call has no top-level status.LeadCallStatus mapped = vbcTelephonyService.deriveStatus(live);if (mapped != null && mapped.advancesFrom(call.getStatus())) {if (mapped == LeadCallStatus.ANSWERED && call.getAnsweredAt() == null) {// Prefer VBC's own answer time: polling runs every 3s, so "now" could// overstate the answer by almost that much on every call.LocalDateTime answeredAt = vbcTelephonyService.retailerAnsweredAt(live);call.setAnsweredAt(answeredAt == null ? LocalDateTime.now() : answeredAt);}call.setStatus(mapped);leadCallRepository.persist(call);}} else if (call.getStatus() != LeadCallStatus.INITIATED) {// We have seen this call live before, so VBC dropping it means it ended.closeCall(call, LeadCallStatus.COMPLETED);} else if (call.getStartedAt() != null&& call.getStartedAt().plusSeconds(initiateGraceSeconds).isBefore(LocalDateTime.now())) {// Never seen live, and too old to still be appearing: the call was accepted by// VBC (it returned an id) but never reached the agent's endpoint.//// This branch is load-bearing. /calls only lists calls that are IN PROGRESS —// it is not history — so a call that never connects is absent from the moment// it is placed. Without a deadline it would sit at INITIATED forever and the// client would poll every 3s for the rest of the session.LOGGER.warn("VBC call {} (vendor {}) never appeared as live within {}s — "+ "marking failed; usually the endpoint has no reachable device",leadCallId, call.getProviderCallUuid(), initiateGraceSeconds);closeCall(call, LeadCallStatus.FAILED);}} catch (Exception e) {LOGGER.warn("Could not refresh VBC state for call {}", leadCallId, e);}}return LmsOutcome.ok().with("leadCallId", call.getId()).with("status", call.getStatus() == null ? null : call.getStatus().name()).with("terminal", call.getStatus() != null && call.getStatus().isTerminal()).with("answered", call.getAnsweredAt() != null).with("durationSeconds", call.getDurationSeconds());}/** Hang up a server-placed call. */public LmsOutcome hangup(AuthUser agent, int leadCallId) {LeadCall call = leadCallRepository.selectById(leadCallId);if (call == null) {return LmsOutcome.notFound("Call not found");}if (!owns(agent, call)) {return LmsOutcome.forbidden();}if (vbcTelephonyService != null) {vbcTelephonyService.endCall(call.getProviderCallUuid());}if (call.getStatus() != null && !call.getStatus().isTerminal()) {closeCall(call, LeadCallStatus.COMPLETED);}return LmsOutcome.ok().with("ok", true);}/*** Put a call into a terminal state: status, end time and duration together.** <p>Duration is measured from {@code answered_at}, not {@code started_at} — it is meant to be* how long the two people spoke, and a click2dial call spends several seconds ringing the* agent's own device before the retailer is even dialled. A call that was never answered has a* duration of zero rather than null, so reporting can distinguish "no conversation" from "not* recorded yet".*/private void closeCall(LeadCall call, LeadCallStatus status) {LocalDateTime endedAt = LocalDateTime.now();call.setStatus(status);call.setEndedAt(endedAt);long seconds = call.getAnsweredAt() == null ? 0L: Duration.between(call.getAnsweredAt(), endedAt).getSeconds();call.setDurationSeconds((int) Math.max(0L, seconds));leadCallRepository.persist(call);}/*** Whether the caller placed this call.** <p>{@code leadCallId} arrives from the client, so without this any signed-in user could poll* another agent's live call or hang it up mid-conversation by guessing a sequential id.* Deliberately an exact owner check rather than a role check: a supervisor has no reason to* drive someone else's call from these endpoints, and reporting reads the call rows directly.*/private boolean owns(AuthUser agent, LeadCall call) {if (agent == null || call.getAuthId() != agent.getId()) {LOGGER.warn("Auth {} tried to act on call {} owned by auth {}",agent == null ? null : agent.getId(), call.getId(), call.getAuthId());return false;}return true;}}