collabSupport/services/cdrFeedQueue.js
jmcqueen a25fc08fe2 Rename /voicereport to /callreport with scoped user and phone targets.
Add store/email/phone filtering, richer call-line formatting, CDR feed pagination and queueing, and split Jira poller enrichment into testable modules.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 10:10:03 -04:00

92 lines
2.5 KiB
JavaScript

// services/cdrFeedQueue.js
// Serialize cdr_feed HTTP calls — Webex allows ~1 request/minute per token.
import { logger } from '../utils/logger.js';
const LOG_SCOPE = 'cdr:queue';
let testCooldownOverride = null;
function cooldownMs() {
if (testCooldownOverride != null) return testCooldownOverride;
const v = Number(process.env.CDR_FEED_COOLDOWN_MS);
return Number.isFinite(v) && v > 0 ? v : 65_000;
}
export const CDR_FEED_COOLDOWN_MS = 65_000;
let chain = Promise.resolve();
let lastStartMs = 0;
let pendingCount = 0;
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function estimateRunAtMs(position) {
const now = Date.now();
const firstSlot = lastStartMs > 0
? Math.max(now, lastStartMs + cooldownMs())
: now;
if (position <= 0) return firstSlot;
return firstSlot + position * cooldownMs();
}
/**
* Run a cdr_feed fetch when the global cooldown allows. Queues concurrent callers.
*
* @template T
* @param {() => Promise<T>} fn
* @param {{ onQueued?: (info: { runAt: Date, waitMs: number, position: number }) => void|Promise<void>, label?: string }} [opts]
* @returns {Promise<T>}
*/
export function enqueueCdrFeedFetch(fn, opts = {}) {
const { onQueued, label } = opts;
const now = Date.now();
const position = pendingCount;
pendingCount += 1;
const inCooldown = lastStartMs > 0 && now < lastStartMs + cooldownMs();
if ((position > 0 || inCooldown) && onQueued) {
const runAtMs = estimateRunAtMs(position);
Promise.resolve(onQueued({
runAt: new Date(runAtMs),
waitMs: Math.max(0, runAtMs - now),
position: position + 1,
})).catch((err) => {
logger(LOG_SCOPE, `onQueued callback failed${label ? ` (${label})` : ''}: ${err.message}`, 'warn');
});
}
const job = chain.then(async () => {
const waitMs = Math.max(0, (lastStartMs > 0 ? lastStartMs + cooldownMs() : 0) - Date.now());
if (waitMs > 0) {
logger(
LOG_SCOPE,
`Waiting ${Math.ceil(waitMs / 1000)}s for cdr_feed cooldown${label ? ` (${label})` : ''}`,
'info',
);
await sleep(waitMs);
}
lastStartMs = Date.now();
return fn();
});
chain = job.catch(() => {});
return job.finally(() => {
pendingCount = Math.max(0, pendingCount - 1);
});
}
/** @internal test helper */
export function _resetCdrFeedQueueForTests() {
chain = Promise.resolve();
lastStartMs = 0;
pendingCount = 0;
testCooldownOverride = null;
}
/** @internal test helper */
export function _setCdrFeedCooldownForTests(ms) {
testCooldownOverride = ms;
}