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:
Luca
2026-06-02 13:00:13 +02:00
parent 93956db0ca
commit 621ce942b5
13 changed files with 988 additions and 19 deletions
@@ -0,0 +1,238 @@
/**
* Unit tests for the per-weekday business-hours floor (migration 114).
* Exercises the pure snap logic against a fixed IANA zone so the results
* don't drift with the CI box's local timezone.
*
* All scenarios use Europe/Zurich (the regulatory-scope default).
*/
const {
snapToBusinessHours,
parseHHMM,
minutesToHHMM,
normaliseSchedule,
hasAnyBlocks,
_internal,
} = require('../../src/utils/businessHours');
const TZ = 'Europe/Zurich';
// MonFri 09:0018:00, weekend closed. Plain string-block storage shape.
const STANDARD = {
'1': [{ start: '09:00', end: '18:00' }],
'2': [{ start: '09:00', end: '18:00' }],
'3': [{ start: '09:00', end: '18:00' }],
'4': [{ start: '09:00', end: '18:00' }],
'5': [{ start: '09:00', end: '18:00' }],
'6': [],
'7': [],
};
// MonFri with a lunch break (09:0012:00, 13:0018:00).
const LUNCH = {
'1': [{ start: '09:00', end: '12:00' }, { start: '13:00', end: '18:00' }],
'2': [{ start: '09:00', end: '12:00' }, { start: '13:00', end: '18:00' }],
'3': [{ start: '09:00', end: '12:00' }, { start: '13:00', end: '18:00' }],
'4': [{ start: '09:00', end: '12:00' }, { start: '13:00', end: '18:00' }],
'5': [{ start: '09:00', end: '12:00' }, { start: '13:00', end: '18:00' }],
'6': [],
'7': [],
};
const cfg = (schedule, overrides = {}) => ({
enabled: true,
timezone: TZ,
schedule,
...overrides,
});
// Build a UTC instant from a Zurich wall-clock so the assertions read in
// local terms. Reuses the module's own converter (covered separately).
function zurich(y, mo, d, hh, mi) {
return _internal.zonedWallClockToUtc(y, mo, d, hh, mi, TZ);
}
function partsOf(date) {
const p = _internal.getZonedParts(date, TZ);
return [p.y, p.mo, p.d, p.hh, p.mi];
}
describe('parseHHMM / minutesToHHMM', () => {
it('parses valid times to minutes', () => {
expect(parseHHMM('09:00')).toBe(540);
expect(parseHHMM('00:00')).toBe(0);
expect(parseHHMM('23:59')).toBe(1439);
});
it('rejects malformed input', () => {
expect(parseHHMM('9:00')).toBeNull();
expect(parseHHMM('24:00')).toBeNull();
expect(parseHHMM('12:60')).toBeNull();
expect(parseHHMM('')).toBeNull();
expect(parseHHMM(null)).toBeNull();
});
it('round-trips minutesToHHMM', () => {
expect(minutesToHHMM(540)).toBe('09:00');
expect(minutesToHHMM(0)).toBe('00:00');
expect(minutesToHHMM(1439)).toBe('23:59');
});
});
describe('normaliseSchedule', () => {
it('parses, sorts, and drops invalid blocks', () => {
const out = normaliseSchedule({
'1': [{ start: '13:00', end: '18:00' }, { start: '09:00', end: '12:00' }],
'2': [{ start: '18:00', end: '09:00' }], // end<=start → dropped
'3': [{ start: 'bad', end: '18:00' }], // malformed → dropped
});
expect(out['1']).toEqual([
{ start: '09:00', end: '12:00' },
{ start: '13:00', end: '18:00' },
]);
expect(out['2']).toEqual([]);
expect(out['3']).toEqual([]);
expect(out['7']).toEqual([]);
});
it('accepts [start,end] pair blocks and a JSON string', () => {
const out = normaliseSchedule(JSON.stringify({ '4': [['09:00', '17:00']] }));
expect(out['4']).toEqual([{ start: '09:00', end: '17:00' }]);
});
it('garbage input → all-empty week', () => {
expect(hasAnyBlocks(normaliseSchedule('not json'))).toBe(false);
expect(hasAnyBlocks(normaliseSchedule(null))).toBe(false);
});
});
describe('snapToBusinessHours — single window (MonFri 09:0018:00)', () => {
it('weekday before open (Tue 02:11) → SAME day 09:00', () => {
// 2026-06-02 is a Tuesday.
const out = snapToBusinessHours(zurich(2026, 6, 2, 2, 11), cfg(STANDARD));
expect(partsOf(out)).toEqual([2026, 6, 2, 9, 0]);
});
it('weekday inside window (Tue 10:30) → unchanged', () => {
const input = zurich(2026, 6, 2, 10, 30);
expect(snapToBusinessHours(input, cfg(STANDARD)).getTime()).toBe(input.getTime());
});
it('weekday after close (Tue 20:00) → next business day 09:00 (Wed)', () => {
const out = snapToBusinessHours(zurich(2026, 6, 2, 20, 0), cfg(STANDARD));
expect(partsOf(out)).toEqual([2026, 6, 3, 9, 0]);
});
it('Sunday 14:00 → Monday 09:00', () => {
// 2026-06-07 is a Sunday; 2026-06-08 is the Monday.
const out = snapToBusinessHours(zurich(2026, 6, 7, 14, 0), cfg(STANDARD));
expect(partsOf(out)).toEqual([2026, 6, 8, 9, 0]);
});
it('Saturday before open (Sat 02:11) → Monday 09:00 (closed day, not same-day)', () => {
const out = snapToBusinessHours(zurich(2026, 6, 6, 2, 11), cfg(STANDARD));
expect(partsOf(out)).toEqual([2026, 6, 8, 9, 0]);
});
it('Friday after close (Fri 19:30) → Monday 09:00 (skips weekend)', () => {
// 2026-06-05 is a Friday.
const out = snapToBusinessHours(zurich(2026, 6, 5, 19, 30), cfg(STANDARD));
expect(partsOf(out)).toEqual([2026, 6, 8, 9, 0]);
});
it('exactly at open (Tue 09:00) → unchanged (inclusive lower bound)', () => {
const input = zurich(2026, 6, 2, 9, 0);
expect(snapToBusinessHours(input, cfg(STANDARD)).getTime()).toBe(input.getTime());
});
it('exactly at close (Tue 18:00) → next business day 09:00 (exclusive upper bound)', () => {
const out = snapToBusinessHours(zurich(2026, 6, 2, 18, 0), cfg(STANDARD));
expect(partsOf(out)).toEqual([2026, 6, 3, 9, 0]);
});
});
describe('snapToBusinessHours — lunch break (09:0012:00, 13:0018:00)', () => {
it('morning block (Tue 10:30) → unchanged', () => {
const input = zurich(2026, 6, 2, 10, 30);
expect(snapToBusinessHours(input, cfg(LUNCH)).getTime()).toBe(input.getTime());
});
it('during lunch (Tue 12:30) → SAME day 13:00 (next block open)', () => {
const out = snapToBusinessHours(zurich(2026, 6, 2, 12, 30), cfg(LUNCH));
expect(partsOf(out)).toEqual([2026, 6, 2, 13, 0]);
});
it('exactly at lunch start (Tue 12:00) → 13:00 (block end is exclusive)', () => {
const out = snapToBusinessHours(zurich(2026, 6, 2, 12, 0), cfg(LUNCH));
expect(partsOf(out)).toEqual([2026, 6, 2, 13, 0]);
});
it('afternoon block (Tue 17:59) → unchanged', () => {
const input = zurich(2026, 6, 2, 17, 59);
expect(snapToBusinessHours(input, cfg(LUNCH)).getTime()).toBe(input.getTime());
});
it('before open (Tue 07:00) → SAME day 09:00 (first block)', () => {
const out = snapToBusinessHours(zurich(2026, 6, 2, 7, 0), cfg(LUNCH));
expect(partsOf(out)).toEqual([2026, 6, 2, 9, 0]);
});
it('after close (Tue 19:00) → next day 09:00', () => {
const out = snapToBusinessHours(zurich(2026, 6, 2, 19, 0), cfg(LUNCH));
expect(partsOf(out)).toEqual([2026, 6, 3, 9, 0]);
});
});
describe('snapToBusinessHours — per-day differing hours', () => {
const PERDAY = {
'1': [{ start: '08:00', end: '12:00' }], // Mon morning only
'2': [], // Tue closed
'3': [{ start: '14:00', end: '20:00' }], // Wed afternoon/evening
'4': [], '5': [], '6': [], '7': [],
};
it('Mon after its noon close (Mon 13:00) → skips closed Tue → Wed 14:00', () => {
// 2026-06-01 is a Monday; 2026-06-03 is the Wednesday.
const out = snapToBusinessHours(zurich(2026, 6, 1, 13, 0), cfg(PERDAY));
expect(partsOf(out)).toEqual([2026, 6, 3, 14, 0]);
});
it('closed Tuesday (Tue 10:00) → Wed 14:00', () => {
const out = snapToBusinessHours(zurich(2026, 6, 2, 10, 0), cfg(PERDAY));
expect(partsOf(out)).toEqual([2026, 6, 3, 14, 0]);
});
it('Wed before its 14:00 open (Wed 09:00) → SAME day 14:00', () => {
const out = snapToBusinessHours(zurich(2026, 6, 3, 9, 0), cfg(PERDAY));
expect(partsOf(out)).toEqual([2026, 6, 3, 14, 0]);
});
});
describe('snapToBusinessHours — passthrough cases', () => {
it('floor disabled → unchanged even when outside hours', () => {
const input = zurich(2026, 6, 2, 2, 11);
expect(snapToBusinessHours(input, cfg(STANDARD, { enabled: false })).getTime())
.toBe(input.getTime());
});
it('empty schedule → unchanged (nothing to snap to)', () => {
const empty = normaliseSchedule(null);
const input = zurich(2026, 6, 7, 14, 0);
expect(snapToBusinessHours(input, cfg(empty)).getTime()).toBe(input.getTime());
});
it('non-Date / invalid input is passed through untouched', () => {
expect(snapToBusinessHours(null, cfg(STANDARD))).toBeNull();
const bad = new Date('not-a-date');
expect(Number.isNaN(snapToBusinessHours(bad, cfg(STANDARD)).getTime())).toBe(true);
});
});
describe('_internal round-trips', () => {
it('zonedWallClockToUtc → getZonedParts reconstructs the wall-clock', () => {
const d = _internal.zonedWallClockToUtc(2026, 6, 2, 9, 0, TZ);
const p = _internal.getZonedParts(d, TZ);
expect([p.y, p.mo, p.d, p.hh, p.mi]).toEqual([2026, 6, 2, 9, 0]);
});
it('isoWeekday: 2026-06-07 is Sunday (7), 2026-06-08 is Monday (1)', () => {
expect(_internal.isoWeekday(2026, 6, 7)).toBe(7);
expect(_internal.isoWeekday(2026, 6, 8)).toBe(1);
});
});
@@ -0,0 +1,61 @@
/**
* Migration: configurable business hours on the business profile.
*
* Adds two columns to the singleton business_profile row (id=1):
*
* business_hours TEXT — JSON, per-ISO-weekday opening
* blocks. Shape:
* {"1":[{"start":"09:00","end":"12:00"},
* {"start":"13:00","end":"18:00"}],
* ...,"7":[]}
* Keys are ISO weekdays 1=Mon … 7=Sun;
* a day with no blocks is closed.
* Multiple blocks per day model lunch
* breaks (Google-style).
* scheduled_email_floor_enabled BOOLEAN default TRUE — master switch for
* holding scheduled emails until the
* next open block.
*
* business_hours defaults to NULL (no hours configured). A null / empty
* schedule makes the scheduled-email floor a no-op, so existing installs
* keep today's behaviour — emails send at their requested instant until
* the admin actually defines opening hours (migration-preserve-state).
*
* The timezone the blocks are interpreted in is the EXISTING
* business_profile.timezone column (added earlier for the admin calendar)
* — no new tz column. The whole business-hours definition lives on the
* business profile, which is where the admin edits it.
*
* Idempotent: each column guarded by hasColumn so a re-run is a no-op.
*/
exports.up = async function(knex) {
if (!(await knex.schema.hasTable('business_profile'))) return;
if (!(await knex.schema.hasColumn('business_profile', 'business_hours'))) {
await knex.schema.alterTable('business_profile', (table) => {
table.text('business_hours');
});
}
if (!(await knex.schema.hasColumn('business_profile', 'scheduled_email_floor_enabled'))) {
await knex.schema.alterTable('business_profile', (table) => {
table.boolean('scheduled_email_floor_enabled').notNullable().defaultTo(true);
});
}
};
exports.down = async function(knex) {
if (!(await knex.schema.hasTable('business_profile'))) return;
if (await knex.schema.hasColumn('business_profile', 'scheduled_email_floor_enabled')) {
await knex.schema.alterTable('business_profile', (table) => {
table.dropColumn('scheduled_email_floor_enabled');
});
}
if (await knex.schema.hasColumn('business_profile', 'business_hours')) {
await knex.schema.alterTable('business_profile', (table) => {
table.dropColumn('business_hours');
});
}
};
@@ -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)) {
+21 -1
View File
@@ -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;
}
+96 -17
View File
@@ -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);
+237
View File
@@ -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:0012:00 + 13:0018: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,
},
};