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:
@@ -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';
|
||||
|
||||
// Mon–Fri 09:00–18: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': [],
|
||||
};
|
||||
|
||||
// Mon–Fri with a lunch break (09:00–12:00, 13:00–18: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 (Mon–Fri 09:00–18: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:00–12:00, 13:00–18: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)) {
|
||||
|
||||
@@ -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