feat(email): per-weekday business hours + manual queue flush
Move the scheduled-email business-hours floor onto the business profile
as Google-style per-weekday opening blocks (multiple blocks/day for lunch
breaks). Migration 114 adds business_profile.business_hours (JSON) +
scheduled_email_floor_enabled; emailProcessor snaps a queued email to the
next open block, read in the profile timezone. Editor lives under
Settings → Business profile.
Add an admin "Send queued emails now" flush (POST /admin/email/flush-queue)
that drains the queue immediately, ignoring the business-hours floor — the
escape hatch before maintenance/updates. processEmailQueue now takes
{ignoreSchedule, limit} and returns send counts; the scheduled interval
run is unchanged.
This commit is contained in:
@@ -167,11 +167,34 @@ function transformProfile(p) {
|
||||
// Migration 137 — IANA timezone for the admin calendar. Null when
|
||||
// the admin hasn't picked one; frontend falls back to the browser.
|
||||
timezone: p.timezone || null,
|
||||
// Migration 114 — per-ISO-weekday opening hours (object keyed
|
||||
// "1".."7"). Stored as JSON TEXT; parse to an object for the API.
|
||||
// null/blank = no hours configured.
|
||||
businessHours: parseBusinessHours(p.business_hours),
|
||||
// Migration 114 — master switch for the scheduled-email floor.
|
||||
// Defaults true (column is NOT NULL default true).
|
||||
scheduledEmailFloorEnabled: p.scheduled_email_floor_enabled == null
|
||||
? true
|
||||
: (p.scheduled_email_floor_enabled === true
|
||||
|| p.scheduled_email_floor_enabled === 1
|
||||
|| p.scheduled_email_floor_enabled === '1'),
|
||||
createdAt: p.created_at,
|
||||
updatedAt: p.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
/** Parse the stored business_hours JSON to an object, or null. */
|
||||
function parseBusinessHours(raw) {
|
||||
if (raw == null || raw === '') return null;
|
||||
if (typeof raw === 'object') return raw; // pg jsonb path (column is text today)
|
||||
try {
|
||||
const obj = JSON.parse(raw);
|
||||
return obj && typeof obj === 'object' ? obj : null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function transformBank(b) {
|
||||
if (!b) return null;
|
||||
return {
|
||||
@@ -380,6 +403,15 @@ router.put(
|
||||
// "Europe/Zurich"). Free-text; backend stores up to 64 chars.
|
||||
// Frontend falls back to browser Intl when this is blank.
|
||||
body('timezone').optional({ values: 'falsy', nullable: true }).isString().isLength({ max: 64 }),
|
||||
// Migration 114 — per-weekday opening hours. Object keyed "1".."7" or
|
||||
// null to clear. Shape is validated + sanitised in the service layer
|
||||
// (normaliseSchedule); here we only reject obviously-wrong types.
|
||||
body('businessHours').optional({ nullable: true }).custom((v) => {
|
||||
if (v === null || typeof v === 'object') return true;
|
||||
throw new Error('businessHours must be an object or null');
|
||||
}),
|
||||
// Migration 114 — scheduled-email floor master switch.
|
||||
body('scheduledEmailFloorEnabled').optional().isBoolean(),
|
||||
],
|
||||
handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
@@ -418,6 +450,9 @@ router.put(
|
||||
pdfQuoteShowSkonto: 'pdf_quote_show_skonto',
|
||||
// Migration 137 — admin calendar timezone.
|
||||
timezone: 'timezone',
|
||||
// Migration 114 — business hours + scheduled-email floor switch.
|
||||
businessHours: 'business_hours',
|
||||
scheduledEmailFloorEnabled: 'scheduled_email_floor_enabled',
|
||||
};
|
||||
for (const [api, db] of Object.entries(map)) {
|
||||
if (Object.prototype.hasOwnProperty.call(req.body, api)) {
|
||||
|
||||
@@ -4,7 +4,7 @@ const { body, validationResult } = require('express-validator');
|
||||
const { db, logActivity } = require('../database/db');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const { wrapEmailHtml } = require('../services/emailProcessor');
|
||||
const { wrapEmailHtml, processEmailQueue } = require('../services/emailProcessor');
|
||||
const router = express.Router();
|
||||
|
||||
// Get email configuration
|
||||
@@ -248,6 +248,26 @@ router.post('/test', adminAuth, requirePermission('email.send'), async (req, res
|
||||
}
|
||||
});
|
||||
|
||||
// Flush the email queue now. Sends every pending email immediately,
|
||||
// bypassing the business-hours floor (`scheduled_at`) — the escape hatch
|
||||
// for "drain the queue before I take the server down for an update".
|
||||
router.post('/flush-queue', adminAuth, requirePermission('email.send'), async (req, res) => {
|
||||
try {
|
||||
const summary = await processEmailQueue({ ignoreSchedule: true, limit: 1000 });
|
||||
try {
|
||||
await logActivity('email_queue_flushed',
|
||||
{ processed: summary.processed, sent: summary.sent, failed: summary.failed },
|
||||
null,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
} catch (_) { /* activity logging is best-effort */ }
|
||||
res.json({ message: 'Email queue flushed', ...summary });
|
||||
} catch (error) {
|
||||
console.error('Flush email queue error:', error);
|
||||
res.status(500).json({ error: 'Failed to flush email queue', details: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Helper: parse variables JSON safely
|
||||
function parseVariables(template) {
|
||||
try {
|
||||
|
||||
@@ -17,6 +17,7 @@ const { db, withRetry } = require('../database/db');
|
||||
const logger = require('../utils/logger');
|
||||
const { AppError } = require('../utils/errors');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { normaliseSchedule } = require('../utils/businessHours');
|
||||
|
||||
const ALLOWED_PROFILE_FIELDS = [
|
||||
'company_name',
|
||||
@@ -79,6 +80,11 @@ const ALLOWED_PROFILE_FIELDS = [
|
||||
// by the calendar UI to render timed blocks in the operator's
|
||||
// working tz. Admin-only; never exposed via publicSettings.
|
||||
'timezone',
|
||||
// Per-ISO-weekday opening hours (migration 114). JSON TEXT; drives the
|
||||
// scheduled-email floor and is interpreted in `timezone`.
|
||||
'business_hours',
|
||||
// Master switch for the scheduled-email business-hours floor (mig 114).
|
||||
'scheduled_email_floor_enabled',
|
||||
];
|
||||
|
||||
const ALLOWED_BANK_FIELDS = [
|
||||
@@ -177,6 +183,21 @@ function sanitiseProfilePayload(payload) {
|
||||
}
|
||||
}
|
||||
|
||||
// Per-weekday opening hours. Accept the API object (or a JSON string),
|
||||
// run it through the shared validator (drops bad blocks, sorts, fills
|
||||
// all 7 days), and persist the canonical JSON string. An explicit
|
||||
// null / '' clears the schedule back to "no hours configured".
|
||||
if (updates.business_hours !== undefined) {
|
||||
if (updates.business_hours === null || updates.business_hours === '') {
|
||||
updates.business_hours = null;
|
||||
} else {
|
||||
updates.business_hours = JSON.stringify(normaliseSchedule(updates.business_hours));
|
||||
}
|
||||
}
|
||||
if (updates.scheduled_email_floor_enabled !== undefined) {
|
||||
updates.scheduled_email_floor_enabled = formatBoolean(Boolean(updates.scheduled_email_floor_enabled));
|
||||
}
|
||||
|
||||
return updates;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,11 @@ const nodemailer = require('nodemailer');
|
||||
const { db } = require('../database/db');
|
||||
const logger = require('../utils/logger');
|
||||
const { getFrontendBaseUrl } = require('../utils/frontendUrl');
|
||||
const {
|
||||
snapToBusinessHours,
|
||||
normaliseSchedule,
|
||||
} = require('../utils/businessHours');
|
||||
const { hasColumnCached } = require('../utils/schemaCache');
|
||||
|
||||
let transporter = null;
|
||||
let lastConfigHash = null;
|
||||
@@ -735,10 +740,22 @@ async function sendTemplateEmail(to, templateKey, variables) {
|
||||
}
|
||||
}
|
||||
|
||||
// Process email queue
|
||||
async function processEmailQueue() {
|
||||
// Process email queue.
|
||||
//
|
||||
// Options:
|
||||
// ignoreSchedule when true, send every pending email regardless of its
|
||||
// `scheduled_at` floor (used by the admin "send now" flush
|
||||
// before maintenance/updates). The scheduled interval run
|
||||
// leaves it false so future-dated emails keep waiting.
|
||||
// limit max emails per pass. The flush raises this to drain the
|
||||
// whole queue in a single pass (no re-query, so a failing
|
||||
// email isn't retried in a tight loop within one flush).
|
||||
//
|
||||
// Returns { processed, sent, failed }.
|
||||
async function processEmailQueue({ ignoreSchedule = false, limit = 10 } = {}) {
|
||||
logger.info('Email queue processor: Checking for pending emails...');
|
||||
|
||||
const result = { processed: 0, sent: 0, failed: 0 };
|
||||
|
||||
try {
|
||||
// Try to initialize transporter if it's null (in case it failed at startup)
|
||||
if (!transporter) {
|
||||
@@ -746,37 +763,41 @@ async function processEmailQueue() {
|
||||
transporter = await initializeTransporter();
|
||||
if (!transporter) {
|
||||
logger.warn('Email transporter could not be initialized, skipping queue processing');
|
||||
return;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
let pendingEmails = [];
|
||||
try {
|
||||
// Pick up emails that are pending AND either have no `scheduled_at`
|
||||
// or whose scheduled_at is in the past. Used by CRM invoices to
|
||||
// queue split-payment emails relative to the event date.
|
||||
const now = new Date();
|
||||
pendingEmails = await db('email_queue')
|
||||
const query = db('email_queue')
|
||||
.where('status', 'pending')
|
||||
.where('retry_count', '<', 3)
|
||||
.andWhere(function() {
|
||||
.where('retry_count', '<', 3);
|
||||
if (!ignoreSchedule) {
|
||||
query.andWhere(function() {
|
||||
this.whereNull('scheduled_at').orWhere('scheduled_at', '<=', now);
|
||||
})
|
||||
});
|
||||
}
|
||||
pendingEmails = await query
|
||||
.orderBy('scheduled_at', 'asc')
|
||||
.orderBy('created_at', 'asc')
|
||||
.limit(10);
|
||||
.limit(limit);
|
||||
} catch (dbError) {
|
||||
logger.error('Failed to query email queue:', dbError);
|
||||
return;
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
if (pendingEmails.length === 0) {
|
||||
logger.info('Email queue processor: No pending emails found');
|
||||
return;
|
||||
return result;
|
||||
}
|
||||
|
||||
logger.info(`Processing ${pendingEmails.length} emails from queue`);
|
||||
|
||||
result.processed = pendingEmails.length;
|
||||
|
||||
for (const email of pendingEmails) {
|
||||
try {
|
||||
const emailData = typeof email.email_data === 'string'
|
||||
@@ -796,9 +817,11 @@ async function processEmailQueue() {
|
||||
status: 'sent',
|
||||
sent_at: new Date()
|
||||
});
|
||||
|
||||
|
||||
result.sent += 1;
|
||||
logger.info(`Email ${email.id} sent successfully`);
|
||||
} catch (error) {
|
||||
result.failed += 1;
|
||||
// Increment retry count
|
||||
try {
|
||||
await db('email_queue')
|
||||
@@ -825,6 +848,52 @@ async function processEmailQueue() {
|
||||
} catch (error) {
|
||||
logger.error('Error processing email queue:', error);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Load + normalise the business-hours config used by queueEmail. The
|
||||
// definition lives on the singleton business_profile row (migration 114):
|
||||
// business_hours JSON, per-ISO-weekday opening blocks
|
||||
// scheduled_email_floor_enabled master on/off switch
|
||||
// timezone IANA zone the blocks are read in
|
||||
// Any failure (column missing on a half-migrated install, bad data) or an
|
||||
// unconfigured schedule degrades to `enabled: false` so a queued email is
|
||||
// never lost — it just sends at its original time.
|
||||
async function getScheduledEmailConfig() {
|
||||
try {
|
||||
if (!(await hasColumnCached('business_profile', 'business_hours'))) {
|
||||
return { enabled: false };
|
||||
}
|
||||
const hasToggle = await hasColumnCached('business_profile', 'scheduled_email_floor_enabled');
|
||||
const cols = ['business_hours', 'timezone'];
|
||||
if (hasToggle) cols.push('scheduled_email_floor_enabled');
|
||||
const profile = await db('business_profile').where({ id: 1 }).first(cols);
|
||||
if (!profile) return { enabled: false };
|
||||
|
||||
const enabled = hasToggle
|
||||
? (profile.scheduled_email_floor_enabled === true
|
||||
|| profile.scheduled_email_floor_enabled === 1
|
||||
|| profile.scheduled_email_floor_enabled === '1')
|
||||
: true;
|
||||
if (!enabled) return { enabled: false };
|
||||
|
||||
const schedule = normaliseSchedule(profile.business_hours);
|
||||
|
||||
let timezone = (profile.timezone || '').trim();
|
||||
if (!timezone) timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
||||
// Reject a bogus tz before it reaches Intl in the snap helper.
|
||||
try {
|
||||
new Intl.DateTimeFormat('en-US', { timeZone: timezone });
|
||||
} catch (_) {
|
||||
timezone = 'UTC';
|
||||
}
|
||||
|
||||
return { enabled: true, timezone, schedule };
|
||||
} catch (err) {
|
||||
logger.warn(`Business-hours config unavailable, skipping email floor: ${err.message}`);
|
||||
return { enabled: false };
|
||||
}
|
||||
}
|
||||
|
||||
// Queue an email for sending. Optionally takes a 5th `options` arg:
|
||||
@@ -846,15 +915,25 @@ async function queueEmail(eventId, recipientEmail, emailType, emailData, options
|
||||
retry_count: 0,
|
||||
created_at: new Date(),
|
||||
};
|
||||
let snappedFrom = null;
|
||||
if (options.scheduledAt) {
|
||||
row.scheduled_at = options.scheduledAt instanceof Date
|
||||
const requested = options.scheduledAt instanceof Date
|
||||
? options.scheduledAt
|
||||
: new Date(options.scheduledAt);
|
||||
// Floor to the configured business-hours window so a "send in N
|
||||
// days" click at 02:11 doesn't deliver at 02:11. No-op when the
|
||||
// floor is disabled or the instant already lands inside the window.
|
||||
const cfg = await getScheduledEmailConfig();
|
||||
const snapped = snapToBusinessHours(requested, cfg);
|
||||
if (snapped.getTime() !== requested.getTime()) snappedFrom = requested;
|
||||
row.scheduled_at = snapped;
|
||||
}
|
||||
await db('email_queue').insert(row);
|
||||
|
||||
logger.info(`Email queued: ${emailType} to ${recipientEmail}${
|
||||
options.scheduledAt ? ` (scheduled ${row.scheduled_at.toISOString()})` : ''
|
||||
options.scheduledAt ? ` (scheduled ${row.scheduled_at.toISOString()}${
|
||||
snappedFrom ? `, floored from ${snappedFrom.toISOString()}` : ''
|
||||
})` : ''
|
||||
}`);
|
||||
} catch (error) {
|
||||
logger.error('Error queueing email:', error);
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* Business hours + scheduled-email floor (migration 114).
|
||||
*
|
||||
* Pure, dependency-free time math. picpeak doesn't pull in a date library,
|
||||
* so timezone handling leans on Intl.DateTimeFormat, which every supported
|
||||
* Node build ships with full IANA data for.
|
||||
*
|
||||
* The schedule is per-ISO-weekday with any number of opening blocks, so a
|
||||
* day can carry a lunch break (e.g. 09:00–12:00 + 13:00–18:00) or differ
|
||||
* from its neighbours — the Google-business-hours model. Shape:
|
||||
*
|
||||
* { "1": [{ start: "09:00", end: "12:00" }, { start: "13:00", end: "18:00" }],
|
||||
* "2": [...], ..., "6": [], "7": [] }
|
||||
*
|
||||
* ISO weekday numbering throughout: 1=Mon … 7=Sun. A weekday with no
|
||||
* blocks is closed.
|
||||
*
|
||||
* Snap rule (nearest upcoming block-open; confirmed with the maintainer):
|
||||
* - floor disabled / empty schedule → unchanged
|
||||
* - instant falls inside any block → unchanged
|
||||
* - instant before a later block same day → that block's open
|
||||
* (covers before-first-open AND lunch gaps)
|
||||
* - otherwise → first block of the next
|
||||
* open day, at its open
|
||||
*/
|
||||
|
||||
/**
|
||||
* Wall-clock components of `date` as observed in IANA zone `tz`.
|
||||
* Returns { y, mo, d, hh, mi, ss } with mo 1-12, hh 0-23.
|
||||
*/
|
||||
function getZonedParts(date, tz) {
|
||||
const dtf = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: tz,
|
||||
hour12: false,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
const map = {};
|
||||
for (const part of dtf.formatToParts(date)) {
|
||||
if (part.type !== 'literal') map[part.type] = part.value;
|
||||
}
|
||||
let hh = parseInt(map.hour, 10);
|
||||
// Some engines render midnight as "24" under hour12:false; normalise.
|
||||
if (hh === 24) hh = 0;
|
||||
return {
|
||||
y: parseInt(map.year, 10),
|
||||
mo: parseInt(map.month, 10),
|
||||
d: parseInt(map.day, 10),
|
||||
hh,
|
||||
mi: parseInt(map.minute, 10),
|
||||
ss: parseInt(map.second, 10) || 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** ISO weekday (1=Mon … 7=Sun) for a plain calendar date. */
|
||||
function isoWeekday(y, mo, d) {
|
||||
const dow = new Date(Date.UTC(y, mo - 1, d)).getUTCDay(); // 0=Sun … 6=Sat
|
||||
return dow === 0 ? 7 : dow;
|
||||
}
|
||||
|
||||
/** Offset (ms) between zone `tz` wall-clock and UTC at instant `utcMs`. */
|
||||
function tzOffsetMs(utcMs, tz) {
|
||||
const p = getZonedParts(new Date(utcMs), tz);
|
||||
const asUtc = Date.UTC(p.y, p.mo - 1, p.d, p.hh, p.mi, p.ss);
|
||||
return asUtc - utcMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a wall-clock time (interpreted in zone `tz`) to a UTC instant.
|
||||
* Two-pass offset resolution so it stays correct across DST boundaries.
|
||||
*/
|
||||
function zonedWallClockToUtc(y, mo, d, hh, mi, tz) {
|
||||
const naiveUtc = Date.UTC(y, mo - 1, d, hh, mi, 0);
|
||||
let result = naiveUtc - tzOffsetMs(naiveUtc, tz);
|
||||
result = naiveUtc - tzOffsetMs(result, tz);
|
||||
return new Date(result);
|
||||
}
|
||||
|
||||
/** "HH:MM" → minutes from midnight, or null when malformed. */
|
||||
function parseHHMM(value) {
|
||||
const m = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(String(value || ''));
|
||||
if (!m) return null;
|
||||
return parseInt(m[1], 10) * 60 + parseInt(m[2], 10);
|
||||
}
|
||||
|
||||
/** minutes from midnight → "HH:MM" (24h, zero-padded). */
|
||||
function minutesToHHMM(minutes) {
|
||||
const hh = Math.floor(minutes / 60);
|
||||
const mi = minutes % 60;
|
||||
return `${String(hh).padStart(2, '0')}:${String(mi).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/** Add `n` calendar days to a {y,mo,d}, returning the same shape. */
|
||||
function addDays(y, mo, d, n) {
|
||||
const dt = new Date(Date.UTC(y, mo - 1, d + n));
|
||||
return { y: dt.getUTCFullYear(), mo: dt.getUTCMonth() + 1, d: dt.getUTCDate() };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate + clean a raw weekly schedule into the canonical storage shape.
|
||||
*
|
||||
* Accepts the JSON object (or a JSON string), keyed by ISO weekday. Each
|
||||
* day's value is an array of blocks; a block may be {start,end} or a
|
||||
* [start,end] pair. Invalid blocks (bad HH:MM, end<=start) are dropped;
|
||||
* blocks are sorted by start. Days are emitted as string keys "1".."7"
|
||||
* with an array value (possibly empty = closed).
|
||||
*
|
||||
* Returns { "1": [{start,end}], ..., "7": [] } — never throws.
|
||||
*/
|
||||
function normaliseSchedule(raw) {
|
||||
let obj = raw;
|
||||
if (typeof raw === 'string') {
|
||||
try { obj = JSON.parse(raw); } catch (_) { obj = null; }
|
||||
}
|
||||
const out = {};
|
||||
for (let iso = 1; iso <= 7; iso += 1) out[String(iso)] = [];
|
||||
if (!obj || typeof obj !== 'object') return out;
|
||||
|
||||
for (let iso = 1; iso <= 7; iso += 1) {
|
||||
const dayRaw = obj[String(iso)] !== undefined ? obj[String(iso)] : obj[iso];
|
||||
if (!Array.isArray(dayRaw)) continue;
|
||||
const blocks = [];
|
||||
for (const b of dayRaw) {
|
||||
let startStr;
|
||||
let endStr;
|
||||
if (Array.isArray(b)) {
|
||||
[startStr, endStr] = b;
|
||||
} else if (b && typeof b === 'object') {
|
||||
startStr = b.start;
|
||||
endStr = b.end;
|
||||
}
|
||||
const startMin = parseHHMM(startStr);
|
||||
const endMin = parseHHMM(endStr);
|
||||
if (startMin == null || endMin == null || endMin <= startMin) continue;
|
||||
blocks.push({ start: minutesToHHMM(startMin), end: minutesToHHMM(endMin), startMin, endMin });
|
||||
}
|
||||
blocks.sort((a, b) => a.startMin - b.startMin);
|
||||
out[String(iso)] = blocks.map((b) => ({ start: b.start, end: b.end }));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** True when at least one weekday carries at least one opening block. */
|
||||
function hasAnyBlocks(schedule) {
|
||||
if (!schedule || typeof schedule !== 'object') return false;
|
||||
for (let iso = 1; iso <= 7; iso += 1) {
|
||||
const day = schedule[String(iso)];
|
||||
if (Array.isArray(day) && day.length > 0) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Blocks for an ISO weekday as sorted {startMin,endMin}, parsed fresh. */
|
||||
function blocksForDay(schedule, iso) {
|
||||
const day = schedule[String(iso)];
|
||||
if (!Array.isArray(day)) return [];
|
||||
const out = [];
|
||||
for (const b of day) {
|
||||
const startMin = parseHHMM(b && b.start);
|
||||
const endMin = parseHHMM(b && b.end);
|
||||
if (startMin == null || endMin == null || endMin <= startMin) continue;
|
||||
out.push({ startMin, endMin });
|
||||
}
|
||||
out.sort((a, b) => a.startMin - b.startMin);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Snap a Date to the configured business-hours window.
|
||||
*
|
||||
* @param {Date} date the requested send instant
|
||||
* @param {Object} cfg
|
||||
* @param {boolean} cfg.enabled
|
||||
* @param {string} cfg.timezone IANA zone (resolved by caller; never "")
|
||||
* @param {Object} cfg.schedule per-ISO-weekday blocks (see module docs)
|
||||
* @returns {Date} the (possibly unchanged) send instant
|
||||
*/
|
||||
function snapToBusinessHours(date, cfg) {
|
||||
if (!cfg || !cfg.enabled) return date;
|
||||
if (!(date instanceof Date) || Number.isNaN(date.getTime())) return date;
|
||||
const { timezone, schedule } = cfg;
|
||||
if (!hasAnyBlocks(schedule)) return date; // nothing to snap to
|
||||
|
||||
const p = getZonedParts(date, timezone);
|
||||
const iso = isoWeekday(p.y, p.mo, p.d);
|
||||
const tMin = p.hh * 60 + p.mi;
|
||||
|
||||
const today = blocksForDay(schedule, iso);
|
||||
for (const block of today) {
|
||||
// Inside an open block → leave the instant untouched.
|
||||
if (tMin >= block.startMin && tMin < block.endMin) return date;
|
||||
}
|
||||
// Before a later block today (covers before-first-open AND lunch gaps):
|
||||
// snap up to the nearest block whose open is still ahead.
|
||||
for (const block of today) {
|
||||
if (tMin < block.startMin) {
|
||||
return zonedWallClockToUtc(
|
||||
p.y, p.mo, p.d, Math.floor(block.startMin / 60), block.startMin % 60, timezone
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// After the last block today, or a closed day: walk forward to the first
|
||||
// open block of the next open day. Bounded at 14 days as a safety stop;
|
||||
// hasAnyBlocks above guarantees the loop terminates well within that.
|
||||
let cur = { y: p.y, mo: p.mo, d: p.d };
|
||||
for (let i = 1; i <= 14; i += 1) {
|
||||
cur = addDays(cur.y, cur.mo, cur.d, 1);
|
||||
const dayBlocks = blocksForDay(schedule, isoWeekday(cur.y, cur.mo, cur.d));
|
||||
if (dayBlocks.length > 0) {
|
||||
const open = dayBlocks[0].startMin;
|
||||
return zonedWallClockToUtc(
|
||||
cur.y, cur.mo, cur.d, Math.floor(open / 60), open % 60, timezone
|
||||
);
|
||||
}
|
||||
}
|
||||
return date;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
snapToBusinessHours,
|
||||
parseHHMM,
|
||||
minutesToHHMM,
|
||||
normaliseSchedule,
|
||||
hasAnyBlocks,
|
||||
_internal: {
|
||||
getZonedParts,
|
||||
isoWeekday,
|
||||
zonedWallClockToUtc,
|
||||
addDays,
|
||||
blocksForDay,
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user