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>
140 lines
4.1 KiB
JavaScript
140 lines
4.1 KiB
JavaScript
// services/callReport/businessWindow.js
|
||
// Store-local business-hour windows for /callreport (default 9am–9pm).
|
||
|
||
import { DateTime } from 'luxon';
|
||
import { DISPLAY_TIMEZONE } from '../../utils/time.js';
|
||
import { callReportEnvInt } from './env.js';
|
||
|
||
const TWELVE_HOURS_MS = 12 * 60 * 60 * 1000;
|
||
|
||
export function getBusinessHourConfig() {
|
||
return {
|
||
startHour: callReportEnvInt('BUSINESS_START_HOUR', 9),
|
||
endHour: callReportEnvInt('BUSINESS_END_HOUR', 21),
|
||
apiLagMs: callReportEnvInt('API_LAG_MS', 5 * 60 * 1000),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Parse user date arg into a mode + calendar date in store TZ.
|
||
* @param {string|null|undefined} raw
|
||
* @param {string} timeZone IANA zone
|
||
* @param {Date} [now] injectable for tests
|
||
*/
|
||
export function parseReportDateArg(raw, timeZone, now = new Date()) {
|
||
const zone = timeZone || DISPLAY_TIMEZONE;
|
||
const nowLocal = DateTime.fromJSDate(now, { zone });
|
||
const token = String(raw || 'yesterday').trim().toLowerCase();
|
||
|
||
if (!token || token === 'yesterday') {
|
||
const day = nowLocal.minus({ days: 1 }).startOf('day');
|
||
return { mode: 'fullDay', day, label: day.toISODate() };
|
||
}
|
||
if (token === 'today') {
|
||
return { mode: 'today', day: nowLocal.startOf('day'), label: nowLocal.toISODate() };
|
||
}
|
||
const parsed = DateTime.fromISO(token, { zone });
|
||
if (parsed.isValid) {
|
||
return { mode: 'fullDay', day: parsed.startOf('day'), label: parsed.toISODate() };
|
||
}
|
||
return { error: `Unrecognised date "${raw}". Use yesterday, today, or YYYY-MM-DD.` };
|
||
}
|
||
|
||
/**
|
||
* Compute UTC ISO window for cdr_feed / Prisma queries.
|
||
* @returns {{ startTime: string, endTime: string, timeZone: string, label: string, mode: string, ready: boolean, reason?: string }}
|
||
*/
|
||
export function computeBusinessWindow({ timeZone, dateArg, now = new Date() }) {
|
||
const zone = timeZone || DISPLAY_TIMEZONE;
|
||
const cfg = getBusinessHourConfig();
|
||
const parsed = parseReportDateArg(dateArg, zone, now);
|
||
if (parsed.error) {
|
||
return { ready: false, reason: parsed.error, timeZone: zone };
|
||
}
|
||
|
||
const startLocal = parsed.day.set({
|
||
hour: cfg.startHour,
|
||
minute: 0,
|
||
second: 0,
|
||
millisecond: 0,
|
||
});
|
||
|
||
let endLocal;
|
||
const nowLocal = DateTime.fromJSDate(now, { zone });
|
||
|
||
if (parsed.mode === 'today') {
|
||
const businessEnd = parsed.day.set({
|
||
hour: cfg.endHour,
|
||
minute: 0,
|
||
second: 0,
|
||
millisecond: 0,
|
||
});
|
||
const lagEnd = nowLocal.minus({ milliseconds: cfg.apiLagMs });
|
||
if (lagEnd < startLocal.plus({ minutes: 1 })) {
|
||
return {
|
||
ready: false,
|
||
reason: 'Business window has not started yet, or insufficient data after the 5-minute API lag.',
|
||
timeZone: zone,
|
||
label: parsed.label,
|
||
mode: parsed.mode,
|
||
};
|
||
}
|
||
endLocal = DateTime.min(lagEnd, businessEnd);
|
||
} else {
|
||
endLocal = parsed.day.set({
|
||
hour: cfg.endHour,
|
||
minute: 0,
|
||
second: 0,
|
||
millisecond: 0,
|
||
});
|
||
}
|
||
|
||
if (endLocal <= startLocal) {
|
||
return {
|
||
ready: false,
|
||
reason: 'Computed window is empty (end is not after start).',
|
||
timeZone: zone,
|
||
label: parsed.label,
|
||
mode: parsed.mode,
|
||
};
|
||
}
|
||
|
||
let startMs = startLocal.toUTC().toMillis();
|
||
let endMs = endLocal.toUTC().toMillis();
|
||
|
||
const latestAllowedEnd = now.getTime() - cfg.apiLagMs;
|
||
if (endMs > latestAllowedEnd) endMs = latestAllowedEnd;
|
||
if (endMs - startMs > TWELVE_HOURS_MS) startMs = endMs - TWELVE_HOURS_MS;
|
||
|
||
if (startMs >= endMs) {
|
||
return {
|
||
ready: false,
|
||
reason: 'Window invalid after API lag clamp.',
|
||
timeZone: zone,
|
||
label: parsed.label,
|
||
mode: parsed.mode,
|
||
};
|
||
}
|
||
|
||
return {
|
||
ready: true,
|
||
timeZone: zone,
|
||
label: parsed.label,
|
||
mode: parsed.mode,
|
||
startTime: new Date(startMs).toISOString(),
|
||
endTime: new Date(endMs).toISOString(),
|
||
startLocal: startLocal.toISO(),
|
||
endLocal: endLocal.toISO(),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* True when a timestamp (ISO or ms) falls inside the business window.
|
||
*/
|
||
export function isWithinWindow(isoOrMs, window) {
|
||
if (!window?.startTime || !window?.endTime) return false;
|
||
const t = new Date(isoOrMs).getTime();
|
||
const s = new Date(window.startTime).getTime();
|
||
const e = new Date(window.endTime).getTime();
|
||
return t >= s && t <= e;
|
||
}
|