Subversion Repositories SmartDukaan

Rev

Blame | Last modification | View Log | RSS feed

package com.spice.profitmandi.web.controller;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.spice.profitmandi.common.web.util.ResponseSender;
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.entity.user.LeadDnd;
import com.spice.profitmandi.dao.entity.auth.VonageAgent;
import com.spice.profitmandi.dao.repository.auth.AuthRepository;
import com.spice.profitmandi.dao.repository.auth.VonageAgentRepository;
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.dao.enumuration.dtr.LeadCallProvider;
import com.spice.profitmandi.dao.enumuration.dtr.LeadCallStatus;
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 com.spice.profitmandi.service.lms.LmsDialerProvider;
import com.spice.profitmandi.web.model.LoginDetails;
import com.spice.profitmandi.web.util.CookiesProcessor;
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.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import javax.servlet.http.HttpServletRequest;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;

/**
 * The LMS web dialer: the endpoints the browser softphone talks to, plus the three Vonage callbacks.
 *
 * <p><b>Two audiences, two security models.</b> {@code /lms/dialer/*} is normal authenticated app
 * traffic behind the session cookie and the role gate. {@code /vonage/voice/*} is Vonage calling us,
 * so those paths are excluded from all three interceptors in {@code WebConfig} and are protected
 * instead by the signed-webhook JWT — which means they must never trust anything in the payload that
 * they can look up themselves. In particular the number we dial comes from the lead record, never
 * from the webhook.
 */
@Controller
@Transactional(rollbackFor = Throwable.class)
public class LmsDialerController {

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

    private final ObjectMapper objectMapper = new ObjectMapper();

    @Autowired
    private LeadRepository leadRepository;

    @Autowired
    private LeadCallRepository leadCallRepository;

    @Autowired
    private LeadDndRepository leadDndRepository;

    @Autowired
    private VonageVoiceService vonageVoiceService;

    @Autowired
    private LmsDialerProvider dialerProvider;

    /**
     * Optional: only present when VBC is the active provider. Injected by type rather than through
     * the interface because placing a call needs the agent's extension, which is VBC-specific.
     */
    @Autowired(required = false)
    private VbcDialerProvider vbcDialerProvider;

    @Autowired(required = false)
    private VbcTelephonyService vbcTelephonyService;

    @Autowired
    private AuthRepository authRepository;

    @Autowired
    private VonageAgentRepository vonageAgentRepository;

    @Autowired
    private CookiesProcessor cookiesProcessor;

    @Autowired
    private ResponseSender<?> responseSender;

    /**
     * Vonage account signature secret. Verification is skipped while this is blank so a new
     * environment can be brought up before the secret is in place — same opt-in shape as
     * {@code PinelabsWebhookController}. Set it in every environment that faces the internet.
     */
    @Value("${vonage.webhook.signature.secret:}")
    private String webhookSignatureSecret;

    /** 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;

    // ---- Browser-facing -----------------------------------------------------------------------

    /**
     * Mint this agent's dialer credential. Short-lived by design — the page asks again rather than
     * holding a long-lived token in memory.
     */
    /**
     * 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;

    @RequestMapping(value = "/lms/dialer/token", method = RequestMethod.GET)
    @ResponseBody
    public ResponseEntity<?> token(HttpServletRequest request) {
        AuthUser me = currentUser(request);
        LmsDialerProvider.Handshake handshake = dialerProvider.prepare(me);
        Map<String, Object> out = new HashMap<>();
        out.put("ok", handshake.ok);
        if (!handshake.ok) {
            out.put("reason", handshake.reason);
            return responseSender.ok(out);
        }
        out.put("mode", handshake.mode.name());
        out.put("token", handshake.token);
        out.put("applicationId", handshake.applicationId);
        return responseSender.ok(out);
    }

    /**
     * Gate a dial before it happens: DND and calling hours. Runs server-side because a check that
     * only lives in the browser is not a check.
     */
    @RequestMapping(value = "/lms/dialer/precheck", method = RequestMethod.POST)
    @ResponseBody
    public ResponseEntity<?> precheck(HttpServletRequest request,
                                      @RequestParam(name = "leadId") int leadId) {
        Map<String, Object> out = new HashMap<>();
        Lead lead = leadRepository.selectById(leadId);
        if (lead == null) {
            return responseSender.notFound("Lead not found");
        }

        LeadDnd blocked = leadDndRepository.selectByMobile(lead.getLeadMobile());
        if (blocked != null) {
            out.put("ok", false);
            out.put("code", "DND");
            out.put("reason", "This retailer is on the do-not-call register");
            return responseSender.ok(out);
        }
        if (enforceCallingHours && !withinCallingHours()) {
            out.put("ok", false);
            out.put("code", "OUTSIDE_HOURS");
            out.put("reason", "Commercial calling is only permitted between "
                    + callingHoursStart + ":00 and " + callingHoursEnd + ":00");
            return responseSender.ok(out);
        }
        if (vonageVoiceService.toE164(lead.getLeadMobile()) == null) {
            out.put("ok", false);
            out.put("code", "NO_NUMBER");
            out.put("reason", "This lead has no usable contact number");
            return responseSender.ok(out);
        }

        out.put("ok", true);
        out.put("leadId", leadId);
        return responseSender.ok(out);
    }

    /**
     * The call row for a lead, so the browser can learn its {@code leadCallId} after the answer
     * webhook has created it. The browser cannot know this id itself — see {@code VonageVoiceService}.
     */
    @RequestMapping(value = "/lms/dialer/call", method = RequestMethod.GET)
    @ResponseBody
    public ResponseEntity<?> currentCall(HttpServletRequest request,
                                         @RequestParam(name = "leadId") int leadId) {
        AuthUser me = currentUser(request);
        if (me == null) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
        }
        LeadCall call = leadCallRepository.selectLatestByLeadIdAndAuthId(leadId, me.getId());
        Map<String, Object> out = new HashMap<>();
        if (call == null) {
            out.put("found", false);
            return responseSender.ok(out);
        }
        out.put("found", true);
        out.put("leadCallId", call.getId());
        out.put("status", call.getStatus() != null ? call.getStatus().name() : null);
        out.put("durationSeconds", call.getDurationSeconds());
        out.put("hasRecording", call.getRecordingDocumentId() != null);
        return responseSender.ok(out);
    }

    /**
     * Place a call server-side (click2dial). The agent's own VBC device 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.
     */
    @RequestMapping(value = "/lms/dialer/dial", method = RequestMethod.POST)
    @ResponseBody
    public ResponseEntity<?> dial(HttpServletRequest request,
                                  @RequestParam(name = "leadId") int leadId) {
        AuthUser me = currentUser(request);
        if (me == null) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
        }
        if (vbcDialerProvider == null || vbcTelephonyService == null) {
            return responseSender.badRequest("Server-placed calling is not available on this environment.");
        }
        Lead lead = leadRepository.selectById(leadId);
        if (lead == null) {
            return responseSender.notFound("Lead not found");
        }
        // Re-checked here rather than trusting that the pre-check ran, or that nothing changed since.
        if (leadDndRepository.selectByMobile(lead.getLeadMobile()) != null) {
            return responseSender.badRequest("This retailer is on the do-not-call register.");
        }
        if (enforceCallingHours && !withinCallingHours()) {
            return responseSender.badRequest("Commercial calling is only permitted between "
                    + callingHoursStart + ":00 and " + callingHoursEnd + ":00.");
        }
        String extension = vbcDialerProvider.endpointFor(me);
        if (extension == null || extension.trim().isEmpty()) {
            return responseSender.badRequest(
                    "No VBC " + vbcTelephonyService.fromType() + " is set for your account.");
        }

        try {
            String callId = vbcDialerProvider.placeCall(me, extension, lead.getLeadMobile());

            LeadCall call = new LeadCall();
            call.setLeadId(leadId);
            call.setAuthId(me.getId());
            call.setProvider(LeadCallProvider.VONAGE);
            call.setProviderCallUuid(callId);
            call.setDirection("OUTBOUND");
            call.setFromNumber(extension.trim());
            call.setToNumber(vbcTelephonyService.toE164(lead.getLeadMobile()));
            call.setStatus(LeadCallStatus.INITIATED);
            call.setStartedAt(LocalDateTime.now());
            call.setCreatedTimestamp(LocalDateTime.now());
            leadCallRepository.persist(call);

            Map<String, Object> out = new HashMap<>();
            out.put("ok", true);
            out.put("leadCallId", call.getId());
            out.put("extension", extension.trim());
            LOGGER.info("VBC click2dial for lead {} by auth {} -> call row {} (vendor {})",
                    leadId, me.getId(), call.getId(), callId);
            return responseSender.ok(out);
        } catch (Exception e) {
            LOGGER.error("Could not place a VBC call for lead {}", leadId, e);
            return responseSender.badRequest(e.getMessage() == null
                    ? "Could not place the call." : e.getMessage());
        }
    }

    /**
     * Poll one call's live state. Needed because click2dial has no event webhook — the browser has
     * to ask. Also advances the stored row so the trail and reports see the outcome.
     */
    @RequestMapping(value = "/lms/dialer/state", method = RequestMethod.GET)
    @ResponseBody
    public ResponseEntity<?> state(HttpServletRequest request,
                                   @RequestParam(name = "leadCallId") int leadCallId) {
        LeadCall call = leadCallRepository.selectById(leadCallId);
        if (call == null) {
            return responseSender.notFound("Call not found");
        }
        if (!ownsCall(request, call)) {
            return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
        }
        Map<String, Object> out = new HashMap<>();
        if (vbcTelephonyService != null && call.getProviderCallUuid() != null
                && call.getStatus() != null && !call.getStatus().isTerminal()) {
            try {
                com.fasterxml.jackson.databind.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 extension.
                    //
                    // 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
                    // browser 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 extension 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);
            }
        }
        out.put("leadCallId", call.getId());
        out.put("status", call.getStatus() == null ? null : call.getStatus().name());
        out.put("terminal", call.getStatus() != null && call.getStatus().isTerminal());
        out.put("answered", call.getAnsweredAt() != null);
        return responseSender.ok(out);
    }

    /** Hang up a server-placed call. */
    @RequestMapping(value = "/lms/dialer/hangup", method = RequestMethod.POST)
    @ResponseBody
    public ResponseEntity<?> hangup(HttpServletRequest request,
                                    @RequestParam(name = "leadCallId") int leadCallId) {
        LeadCall call = leadCallRepository.selectById(leadCallId);
        if (call == null) {
            return responseSender.notFound("Call not found");
        }
        if (!ownsCall(request, call)) {
            return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
        }
        if (vbcTelephonyService != null) {
            vbcTelephonyService.endCall(call.getProviderCallUuid());
        }
        if (call.getStatus() != null && !call.getStatus().isTerminal()) {
            closeCall(call, LeadCallStatus.COMPLETED);
        }
        Map<String, Object> out = new HashMap<>();
        out.put("ok", true);
        return responseSender.ok(out);
    }

    private boolean withinCallingHours() {
        int hour = LocalTime.now().getHour();
        return hour >= callingHoursStart && hour < callingHoursEnd;
    }

    // ---- Vonage callbacks ---------------------------------------------------------------------

    /**
     * Answer webhook — returns the NCCO. Accepts GET and POST because the method is an Application
     * setting rather than something we control per call.
     *
     * <p>Always returns valid NCCO JSON, never an error status: a 5xx here makes Vonage retry a call
     * that is already ringing. An unidentifiable lead yields an empty NCCO, which hangs up cleanly.
     */
    @RequestMapping(value = "/vonage/voice/answer", method = {RequestMethod.GET, RequestMethod.POST},
            produces = MediaType.APPLICATION_JSON_VALUE)
    @ResponseBody
    public String answer(HttpServletRequest request,
                         @RequestBody(required = false) String rawBody) {
        try {
            JsonNode body = (rawBody != null && !rawBody.trim().isEmpty())
                    ? objectMapper.readTree(rawBody) : null;

            String conversationUuid = firstNonNull(
                    body != null ? asText(body.get("conversation_uuid")) : null,
                    request.getParameter("conversation_uuid"));
            String callUuid = firstNonNull(
                    body != null ? asText(body.get("uuid")) : null,
                    request.getParameter("uuid"));

            // custom_data is what serverCall({leadId}) put on the wire.
            int leadId = 0;
            if (body != null && body.get("custom_data") != null) {
                leadId = asInt(body.get("custom_data").get("leadId"));
            }
            if (leadId <= 0) {
                leadId = parseInt(request.getParameter("leadId"));
            }

            // The agent is resolved from `from_user` — the JWT `sub` Vonage authenticated, echoed back
            // to us. Deliberately NOT taken from custom_data: this webhook is unauthenticated as far
            // as the app is concerned, and an auth id supplied by the caller would let anyone
            // attribute a call, and its recording, to another agent. The same lookup yields the
            // agent's own caller ID, which is why it must be trustworthy.
            VonageAgent agent = resolveAgent(firstNonNull(
                    body != null ? asText(body.get("from_user")) : null,
                    request.getParameter("from_user")));
            int authId = agent != null ? agent.getAuthId() : 0;
            String agentFromNumber = agent != null ? agent.getFromNumber() : null;

            Lead lead = leadId > 0 ? leadRepository.selectById(leadId) : null;
            if (lead == null) {
                LOGGER.error("Vonage answer webhook could not resolve a lead (conversation {})", conversationUuid);
                return "[]";
            }
            // Defence in depth: the number is taken from the lead, and DND is re-checked here rather
            // than trusting that the pre-check ran or that nothing changed since.
            if (leadDndRepository.selectByMobile(lead.getLeadMobile()) != null) {
                LOGGER.warn("Refusing to connect lead {} — number is on the DND register", leadId);
                return "[]";
            }

            return vonageVoiceService.onAnswer(leadId, authId, agentFromNumber, lead.getLeadMobile(),
                    conversationUuid, callUuid);
        } catch (Exception e) {
            LOGGER.error("Vonage answer webhook failed", e);
            return "[]";
        }
    }

    /** Event webhook — call state transitions. Always ACKs; Vonage retries anything non-2xx. */
    @RequestMapping(value = "/vonage/voice/event", method = RequestMethod.POST)
    @ResponseBody
    public ResponseEntity<?> event(HttpServletRequest request,
                                   @RequestHeader(name = "Authorization", required = false) String authorization,
                                   @RequestBody(required = false) String rawBody) {
        if (!verifySignature(authorization, rawBody)) {
            LOGGER.warn("Rejected a Vonage event webhook with a bad signature");
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
        }
        try {
            vonageVoiceService.onEvent(objectMapper.readTree(rawBody));
        } catch (Exception e) {
            LOGGER.error("Vonage event webhook failed for body {}", rawBody, e);
        }
        return ResponseEntity.ok().build();
    }

    /** Recording webhook — attaches the vendor handle; archival happens on its own schedule. */
    @RequestMapping(value = "/vonage/voice/recording", method = RequestMethod.POST)
    @ResponseBody
    public ResponseEntity<?> recording(HttpServletRequest request,
                                       @RequestHeader(name = "Authorization", required = false) String authorization,
                                       @RequestBody(required = false) String rawBody) {
        if (!verifySignature(authorization, rawBody)) {
            LOGGER.warn("Rejected a Vonage recording webhook with a bad signature");
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
        }
        try {
            vonageVoiceService.onRecording(objectMapper.readTree(rawBody));
        } catch (Exception e) {
            LOGGER.error("Vonage recording webhook failed for body {}", rawBody, e);
        }
        return ResponseEntity.ok().build();
    }

    /**
     * Vonage signed webhooks carry an HS256 JWT in {@code Authorization: Bearer}, signed with the
     * account signature secret, whose {@code payload_hash} claim is the SHA-256 of the request body.
     * Verifying both the signature and the hash is what stops a replayed body.
     *
     * <p>Returns true when no secret is configured — deliberate, so a new environment is not bricked
     * before the secret lands, and the same opt-in shape the Pinelabs webhook already uses.
     */
    private boolean verifySignature(String authorization, String rawBody) {
        if (webhookSignatureSecret == null || webhookSignatureSecret.trim().isEmpty()) {
            return true;
        }
        if (authorization == null || !authorization.trim().toLowerCase().startsWith("bearer ")) {
            return false;
        }
        try {
            String jwt = authorization.trim().substring(7).trim();
            String[] parts = jwt.split("\\.");
            if (parts.length != 3) {
                return false;
            }
            String signingInput = parts[0] + "." + parts[1];
            Mac mac = Mac.getInstance("HmacSHA256");
            mac.init(new SecretKeySpec(webhookSignatureSecret.trim().getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
            byte[] expected = mac.doFinal(signingInput.getBytes(StandardCharsets.UTF_8));
            byte[] actual = Base64.getUrlDecoder().decode(parts[2]);
            if (!MessageDigest.isEqual(expected, actual)) {
                return false;
            }

            JsonNode claims = objectMapper.readTree(Base64.getUrlDecoder().decode(parts[1]));
            String payloadHash = asText(claims.get("payload_hash"));
            if (payloadHash == null) {
                // Signature alone is valid; a hash-less token cannot bind to this body.
                return true;
            }
            byte[] digest = MessageDigest.getInstance("SHA-256")
                    .digest((rawBody == null ? "" : rawBody).getBytes(StandardCharsets.UTF_8));
            return MessageDigest.isEqual(hex(digest).getBytes(StandardCharsets.UTF_8),
                    payloadHash.toLowerCase().getBytes(StandardCharsets.UTF_8));
        } catch (Exception e) {
            LOGGER.warn("Could not verify a Vonage webhook signature", e);
            return false;
        }
    }

    private String hex(byte[] bytes) {
        StringBuilder sb = new StringBuilder(bytes.length * 2);
        for (byte b : bytes) {
            sb.append(Character.forDigit((b >> 4) & 0xF, 16));
            sb.append(Character.forDigit(b & 0xF, 16));
        }
        return sb.toString();
    }

    // ---- helpers ------------------------------------------------------------------------------

    private String asText(JsonNode node) {
        if (node == null || node.isNull()) {
            return null;
        }
        String s = node.asText();
        return (s == null || s.trim().isEmpty()) ? null : s.trim();
    }

    private int asInt(JsonNode node) {
        return (node == null || node.isNull()) ? 0 : node.asInt(0);
    }

    private int parseInt(String s) {
        try {
            return (s == null || s.trim().isEmpty()) ? 0 : Integer.parseInt(s.trim());
        } catch (NumberFormatException e) {
            return 0;
        }
    }

    private String firstNonNull(String a, String b) {
        return a != null ? a : b;
    }

    /**
     * Vonage user name -> the agent mapping, which carries both our auth id and that agent's own
     * caller ID. Returns null when unmapped, leaving the call unattributed and falling back to the
     * default caller ID rather than attributing it to the wrong person — an orphan call is
     * recoverable, a misattributed recording is not.
     */
    private VonageAgent resolveAgent(String vonageUserName) {
        if (vonageUserName == null || vonageUserName.trim().isEmpty()) {
            LOGGER.warn("Vonage answer webhook carried no from_user — call will be unattributed");
            return null;
        }
        VonageAgent agent = vonageAgentRepository.selectByVonageUserName(vonageUserName);
        if (agent == null) {
            LOGGER.warn("No vonage_agent mapping for Vonage user '{}'", vonageUserName);
        }
        return agent;
    }

    /**
     * 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".
     *
     * <p>Exists because the three places a call can end were each stamping status and end time by
     * hand and none of them set duration at all — {@code setDurationSeconds} was only ever called on
     * the WebRTC webhook path, so every click2dial call had a null duration.
     */
    private void closeCall(LeadCall call, LeadCallStatus status) {
        LocalDateTime endedAt = LocalDateTime.now();
        call.setStatus(status);
        call.setEndedAt(endedAt);
        long seconds = call.getAnsweredAt() == null ? 0L
                : java.time.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 browser, 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 this endpoint, and reporting reads the call rows directly.
     */
    private boolean ownsCall(HttpServletRequest request, LeadCall call) {
        AuthUser me = currentUser(request);
        if (me == null || call.getAuthId() != me.getId()) {
            LOGGER.warn("Auth {} tried to act on call {} owned by auth {}",
                    me == null ? null : me.getId(), call.getId(), call.getAuthId());
            return false;
        }
        return true;
    }

    private AuthUser currentUser(HttpServletRequest request) {
        try {
            LoginDetails loginDetails = cookiesProcessor.getCookiesObject(request);
            if (loginDetails != null && loginDetails.getEmailId() != null) {
                return authRepository.selectByEmailOrMobile(loginDetails.getEmailId());
            }
        } catch (Exception e) {
            LOGGER.warn("Could not resolve the user for a dialer request", e);
        }
        return null;
    }
}