Subversion Repositories SmartDukaan

Rev

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.
 */
@Service
public class LmsCallService {

    private static final Logger LOGGER = LogManager.getLogger(LmsCallService.class);

    @Autowired
    private LeadRepository leadRepository;

    @Autowired
    private LeadCallRepository leadCallRepository;

    @Autowired
    private LeadDndRepository leadDndRepository;

    @Autowired
    private 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;
    }
}