Subversion Repositories SmartDukaan

Rev

Blame | Last modification | View Log | RSS feed

package com.spice.profitmandi.service.lms;

import com.spice.profitmandi.dao.entity.user.LeadCall;
import com.spice.profitmandi.dao.repository.dtr.LeadCallRepository;
import com.spice.profitmandi.service.storage.ObjectStoreService;
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 org.springframework.transaction.annotation.Transactional;

import java.util.List;

/**
 * Finds call recordings the provider has announced but we have not stored yet, and hands each to
 * {@link RecordingArchiveWorker}. Owning the audio ourselves is what makes the 24-month retention in
 * SOP §17 our promise to keep rather than the vendor's, and what keeps playback working after a
 * provider switch.
 *
 * <p>Deliberately a sweep rather than work done straight off the recording webhook: the webhook fires
 * the moment the call ends, but the provider needs a moment before the file is actually fetchable,
 * and a webhook has to ACK immediately regardless. The sweep doubles as the retry — the work list is
 * simply "rows with a recording handle and no archived document", so nothing is lost across a restart.
 *
 * <p>This class holds no {@code @Scheduled} annotation on purpose. It lives in {@code profitmandi-dao},
 * which is loaded by the fofo WAR, profitmandi-web and profitmandi-cron alike; scheduling it here
 * would run the same sweep in every one of them. The trigger belongs to whichever app owns the job.
 */
@Service
public class RecordingArchiveService {

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

    @Autowired
    private LeadCallRepository leadCallRepository;

    @Autowired
    private ObjectStoreService objectStoreService;

    @Autowired
    private LmsDialerProvider dialerProvider;

    @Autowired
    private RecordingArchiveWorker recordingArchiveWorker;

    @Value("${lms.recording.archive.enabled:true}")
    private boolean enabled;

    @Value("${lms.recording.archive.batch.size:25}")
    private int batchSize;

    /**
     * How far back to look for recordings. MUST track the provider's own retention — Vonage keeps a
     * recording for 30 days, and a shorter window here silently makes recoverable audio
     * unrecoverable after any pause in the sweep.
     */
    @Value("${lms.recording.provider.retention.days:30}")
    private int providerRetentionDays;

    /** Give up after this many failed fetches so a dead row cannot occupy a batch slot forever. */
    @Value("${lms.recording.archive.max.attempts:5}")
    private int maxAttempts;

    /**
     * Log recordings the sweep will never pick up again.
     *
     * <p>The pending query excludes rows past the provider's retention window or over the attempt
     * ceiling — necessary, or one dead row starves every batch behind it. But an exclusion that
     * happens silently is how you discover during an audit that a month of audio is missing, so the
     * dropped rows are named here.
     */
    private void reportAbandoned() {
        try {
            List<LeadCall> abandoned =
                    leadCallRepository.selectAbandonedRecordings(50, providerRetentionDays, maxAttempts);
            if (abandoned == null || abandoned.isEmpty()) {
                return;
            }
            StringBuilder ids = new StringBuilder();
            for (LeadCall call : abandoned) {
                if (ids.length() > 0) {
                    ids.append(", ");
                }
                ids.append(call.getId());
            }
            LOGGER.error("{} call recording(s) will NOT be archived — past the {}-day provider window "
                            + "or over {} failed attempts. Call ids: {}",
                    abandoned.size(), providerRetentionDays, maxAttempts, ids);
        } catch (Exception e) {
            LOGGER.warn("Could not report abandoned recordings", e);
        }
    }

    /**
     * Ask the vendor for the recording of each recently answered call and stamp the handle on the row.
     *
     * <p>Runs before {@link #sweep()} and is what makes recordings exist at all for a server-placed
     * provider: those place the call over REST and receive no recording webhook, so without this step
     * {@code recording_uuid} stays null forever and the archival sweep — which selects on that column
     * — never sees a single call.
     *
     * <p>Deliberately not done at hang-up: the vendor needs a moment to finalise the file, and a call
     * that has just ended usually is not in the recording list yet. Being a sweep makes the retry free.
     *
     * @return how many recordings were newly linked
     */
    @Transactional
    public int linkSweep() {
        if (!enabled || !dialerProvider.isConfigured()) {
            return 0;
        }
        List<LeadCall> pending = leadCallRepository.selectPendingRecordingLink(batchSize, providerRetentionDays);
        if (pending == null || pending.isEmpty()) {
            return 0;
        }
        int linked = 0;
        for (LeadCall call : pending) {
            try {
                LmsDialerProvider.RecordingRef ref = dialerProvider.findRecording(call);
                if (ref == null || ref.id == null) {
                    // Normal: not recorded, or not finalised yet. The next sweep asks again.
                    continue;
                }
                call.setRecordingUuid(ref.id);
                if (ref.durationSeconds > 0) {
                    call.setRecordingDuration(ref.durationSeconds);
                }
                leadCallRepository.persist(call);
                linked++;
                LOGGER.info("Linked recording {} to call {} (vendor call {})",
                        ref.id, call.getId(), call.getProviderCallUuid());
            } catch (Exception e) {
                // One bad row must not stop the batch — the rest still get linked.
                LOGGER.warn("Could not link a recording for call {}", call.getId(), e);
            }
        }
        return linked;
    }

    /**
     * Queue every pending recording for archival.
     *
     * @return how many were queued; 0 when disabled, unconfigured, or nothing is waiting
     */
    @Transactional
    public int sweep() {
        if (!enabled) {
            return 0;
        }
        if (!objectStoreService.isEnabled() || !dialerProvider.isConfigured()) {
            LOGGER.debug("Recording archive skipped — object store or dialer not configured");
            return 0;
        }
        List<LeadCall> pending = leadCallRepository.selectPendingRecordingArchive(batchSize, providerRetentionDays, maxAttempts);
        if (pending == null || pending.isEmpty()) {
            return 0;
        }
        LOGGER.info("Queueing {} call recording(s) for archival", pending.size());
        reportAbandoned();
        for (LeadCall call : pending) {
            // Through the injected worker, never this.archiveOne — a self-call would bypass the
            // proxy and lose both @Async and @Transactional.
            recordingArchiveWorker.archiveOne(call.getId());
        }
        return pending.size();
    }
}