// services/voiceReport/businessWindow.js // Store-local business-hour windows for /voicereport (default 9am–9pm). import { DateTime } from 'luxon'; import { DISPLAY_TIMEZONE } from '../../utils/time.js'; const TWELVE_HOURS_MS = 12 * 60 * 60 * 1000; function envInt(name, fallback) { const v = Number(process.env[name]); return Number.isFinite(v) ? v : fallback; } export function getBusinessHourConfig() { return { startHour: envInt('VOICEREPORT_BUSINESS_START_HOUR', 9), endHour: envInt('VOICEREPORT_BUSINESS_END_HOUR', 21), apiLagMs: envInt('VOICEREPORT_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; }