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.*/@Servicepublic class RecordingArchiveService {private static final Logger LOGGER = LogManager.getLogger(RecordingArchiveService.class);@Autowiredprivate LeadCallRepository leadCallRepository;@Autowiredprivate ObjectStoreService objectStoreService;@Autowiredprivate LmsDialerProvider dialerProvider;@Autowiredprivate 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*/@Transactionalpublic 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*/@Transactionalpublic 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();}}