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;
}
+91 -12
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,9 +740,21 @@ 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)
@@ -746,7 +763,7 @@ async function processEmailQueue() {
transporter = await initializeTransporter();
if (!transporter) {
logger.warn('Email transporter could not be initialized, skipping queue processing');
return;
return result;
}
}
@@ -756,26 +773,30 @@ async function processEmailQueue() {
// 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 {
@@ -797,8 +818,10 @@ async function processEmailQueue() {
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,
},
};
+26
View File
@@ -2305,6 +2305,13 @@
"gmailAppPassword": "Für Gmail verwenden Sie ein App-spezifisches Passwort",
"testEmailAddressLabel": "Test-E-Mail-Adresse",
"sendTestEmailButton": "Test-E-Mail senden",
"flushQueue": {
"title": "Wartende E-Mails jetzt senden",
"help": "Sendet sofort alle ausstehenden E-Mails, unabhängig von den Geschäftszeiten. Nützlich, um die Warteschlange vor Wartungsarbeiten oder Updates zu leeren.",
"button": "Wartende E-Mails jetzt senden",
"success": "Warteschlange geleert {{sent}} gesendet, {{failed}} fehlgeschlagen",
"empty": "Keine ausstehenden E-Mails zum Senden"
},
"commonSmtpSettings": "Häufige SMTP-Einstellungen:",
"editTemplate": "Vorlage bearbeiten",
"templateName": "Vorlagenname",
@@ -3686,6 +3693,25 @@
"savedToast": "Geschäftsprofil gespeichert.",
"title": "Geschäftsprofil",
"subtitle": "Briefkopf, Kontaktdaten und Standardwerte für Angebote und Rechnungen.",
"businessHours": {
"title": "Geschäftszeiten",
"subtitle": "Öffnungszeiten je Wochentag festlegen — für eine Mittagspause einfach einen zweiten Block hinzufügen. Werden in der oben gewählten Zeitzone interpretiert ({{tz}}).",
"closed": "Geschlossen",
"addHours": "Zeiten hinzufügen",
"addBlock": "Weiteren Block hinzufügen",
"copyToAll": "Auf alle Tage übertragen",
"floorToggle": "Geplante E-Mails bis zu den Geschäftszeiten zurückhalten",
"floorToggleHelp": "Wenn aktiv, wird eine automatische E-Mail, die außerhalb der obigen Zeiten geplant ist, erst zur nächsten Öffnungszeit zugestellt statt zu einer ungünstigen Uhrzeit. Wenn aus, werden geplante E-Mails exakt zum geplanten Zeitpunkt versendet.",
"weekday": {
"1": "Montag",
"2": "Dienstag",
"3": "Mittwoch",
"4": "Donnerstag",
"5": "Freitag",
"6": "Samstag",
"7": "Sonntag"
}
},
"section": {
"company": "Firma",
"contact": "Kontakt",
+26
View File
@@ -1959,6 +1959,13 @@
"gmailAppPassword": "For Gmail, use an app-specific password",
"testEmailAddressLabel": "Test Email Address",
"sendTestEmailButton": "Send Test Email",
"flushQueue": {
"title": "Send queued emails now",
"help": "Immediately send every pending email, ignoring the business-hours schedule. Useful for draining the queue before maintenance or updates.",
"button": "Send queued emails now",
"success": "Email queue flushed — {{sent}} sent, {{failed}} failed",
"empty": "No pending emails to send"
},
"commonSmtpSettings": "Common SMTP Settings:",
"editTemplate": "Edit Template",
"templateName": "Template Name",
@@ -3683,6 +3690,25 @@
"savedToast": "Business profile saved.",
"title": "Business profile",
"subtitle": "Issuer block shown on every quote and invoice PDF.",
"businessHours": {
"title": "Business hours",
"subtitle": "Set opening hours per weekday — add a second block for a lunch break. Interpreted in the timezone above ({{tz}}).",
"closed": "Closed",
"addHours": "Add hours",
"addBlock": "Add another block",
"copyToAll": "Copy to all days",
"floorToggle": "Hold scheduled emails until business hours",
"floorToggleHelp": "When on, an automated email scheduled outside the hours above is delivered at the next opening instead of at an odd hour. When off, scheduled emails send at their exact time.",
"weekday": {
"1": "Monday",
"2": "Tuesday",
"3": "Wednesday",
"4": "Thursday",
"5": "Friday",
"6": "Saturday",
"7": "Sunday"
}
},
"section": {
"company": "Company",
"contact": "Contact",
@@ -248,6 +248,20 @@ export const EmailConfigPage: React.FC = () => {
}
});
const flushQueueMutation = useMutation({
mutationFn: () => emailService.flushQueue(),
onSuccess: (summary) => {
if (summary.processed === 0) {
toast.info(t('email.flushQueue.empty'));
} else {
toast.success(t('email.flushQueue.success', { sent: summary.sent, failed: summary.failed }));
}
},
onError: () => {
toast.error(t('toast.saveError'));
}
});
const saveTemplateMutation = useMutation({
mutationFn: ({ key, translations }: { key: string; translations: Record<string, EmailTemplateTranslation> }) =>
emailService.updateTemplate(key, { translations }),
@@ -665,6 +679,20 @@ export const EmailConfigPage: React.FC = () => {
</div>
</div>
</Card>
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-2">{t('email.flushQueue.title')}</h2>
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">{t('email.flushQueue.help')}</p>
<Button
variant="outline"
onClick={() => flushQueueMutation.mutate()}
isLoading={flushQueueMutation.isPending}
leftIcon={<Send className="w-5 h-5" />}
className="w-full"
>
{t('email.flushQueue.button')}
</Button>
</Card>
</div>
)}
@@ -8,11 +8,13 @@
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Plus, Trash2, Star, Pencil, Save } from 'lucide-react';
import { Plus, Trash2, Star, Pencil, Save, Clock, Copy } from 'lucide-react';
import {
businessProfileService,
type BusinessProfile,
type BankAccount,
type BusinessHours,
type BusinessHoursBlock,
type QrFormat,
} from '../../../services/businessProfile.service';
import { Button, Card, Loading, Input, CountrySelect } from '../../../components/common';
@@ -251,6 +253,37 @@ export const SettingsBusinessProfilePage: React.FC = () => {
</div>
</Card>
{/* Business hours (migration 114). Per-weekday opening blocks with
lunch-break support, interpreted in the profile timezone above.
Drives the scheduled-email floor: an email scheduled outside the
open blocks is held until the next opening. */}
<Card>
<div className="flex items-center gap-2 mb-1">
<Clock className="w-5 h-5 text-neutral-500" />
<h3 className="font-semibold">{t('businessProfile.businessHours.title', 'Business hours')}</h3>
</div>
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
{t('businessProfile.businessHours.subtitle',
'Set opening hours per weekday — add a second block for a lunch break. Interpreted in the timezone above ({{tz}}).',
{ tz: profile.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone })}
</p>
<BusinessHoursEditor
value={profile.businessHours}
onChange={(next) => setProfile({ ...profile, businessHours: next })}
/>
<div className="mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-700">
<PdfToggleRow
label={t('businessProfile.businessHours.floorToggle', 'Hold scheduled emails until business hours') as string}
description={t('businessProfile.businessHours.floorToggleHelp',
'When on, an automated email scheduled outside the hours above is delivered at the next opening instead of at an odd hour. When off, scheduled emails send at their exact time.') as string}
enabled={profile.scheduledEmailFloorEnabled}
onChange={(v) => setProfile({ ...profile, scheduledEmailFloorEnabled: v })}
/>
</div>
</Card>
{/* Disclaimer banner for QR-bill / IBAN data. picpeak renders
what the operator types — it cannot validate IBAN/BIC, QR-IID
or scan-compatibility with any specific bank's e-banking app.
@@ -307,6 +340,143 @@ const PdfToggleRow: React.FC<PdfToggleRowProps> = ({ label, description, enabled
</label>
);
/**
* Per-weekday business-hours editor (migration 114). Google-style: each
* weekday holds zero or more {start,end} blocks, so a day can be closed
* (no blocks), open all day (one block), or have a lunch break (two).
* Edits the parent's `businessHours` object directly; the page-level Save
* persists it. ISO weekday keys "1".."7" (1=Mon … 7=Sun).
*/
const WEEKDAYS = [1, 2, 3, 4, 5, 6, 7];
const BusinessHoursEditor: React.FC<{
value: BusinessHours | null;
onChange: (next: BusinessHours) => void;
}> = ({ value, onChange }) => {
const { t } = useTranslation();
// Always work with a fully-populated 7-day object so toggling a day on
// and off doesn't drop sibling keys.
const full: BusinessHours = {};
for (const iso of WEEKDAYS) {
const blocks = value?.[String(iso)];
full[String(iso)] = Array.isArray(blocks) ? blocks : [];
}
const setDay = (iso: number, blocks: BusinessHoursBlock[]) => {
onChange({ ...full, [String(iso)]: blocks });
};
const addBlock = (iso: number) => {
const blocks = full[String(iso)];
// First block defaults to a full workday; a second one defaults to a
// post-lunch afternoon so the common 0912 / 1318 split is one click.
const next: BusinessHoursBlock = blocks.length === 0
? { start: '09:00', end: '17:00' }
: { start: '13:00', end: '18:00' };
setDay(iso, [...blocks, next]);
};
const updateBlock = (iso: number, idx: number, patch: Partial<BusinessHoursBlock>) => {
setDay(iso, full[String(iso)].map((b, i) => (i === idx ? { ...b, ...patch } : b)));
};
const removeBlock = (iso: number, idx: number) => {
setDay(iso, full[String(iso)].filter((_, i) => i !== idx));
};
const copyToAll = (iso: number) => {
const src = full[String(iso)];
const next: BusinessHours = {};
for (const d of WEEKDAYS) next[String(d)] = src.map((b) => ({ ...b }));
onChange(next);
};
return (
<div className="space-y-2">
{WEEKDAYS.map((iso) => {
const blocks = full[String(iso)];
const isOpen = blocks.length > 0;
return (
<div
key={iso}
className="flex flex-col sm:flex-row sm:items-start gap-2 sm:gap-3 py-2 border-b border-neutral-100 dark:border-neutral-800 last:border-0"
>
<div className="w-28 shrink-0 pt-2 text-sm font-medium text-neutral-900 dark:text-neutral-100">
{t(`businessProfile.businessHours.weekday.${iso}`)}
</div>
<div className="flex-1 space-y-2">
{!isOpen && (
<div className="flex items-center gap-3">
<span className="text-sm text-neutral-500 dark:text-neutral-400">
{t('businessProfile.businessHours.closed', 'Closed')}
</span>
<button
type="button"
onClick={() => addBlock(iso)}
className="inline-flex items-center gap-1 text-sm text-primary-600 hover:text-primary-700"
>
<Plus className="w-4 h-4" />
{t('businessProfile.businessHours.addHours', 'Add hours')}
</button>
</div>
)}
{blocks.map((block, idx) => (
<div key={idx} className="flex items-center gap-2">
<Input
type="time"
value={block.start}
onChange={(e) => updateBlock(iso, idx, { start: e.target.value })}
className="w-32"
/>
<span className="text-neutral-400"></span>
<Input
type="time"
value={block.end}
onChange={(e) => updateBlock(iso, idx, { end: e.target.value })}
className="w-32"
/>
<button
type="button"
onClick={() => removeBlock(iso, idx)}
aria-label={t('common.remove', 'Remove') as string}
className="p-1.5 text-neutral-400 hover:text-red-600"
>
<Trash2 className="w-4 h-4" />
</button>
{idx === blocks.length - 1 && (
<button
type="button"
onClick={() => addBlock(iso)}
aria-label={t('businessProfile.businessHours.addBlock', 'Add another block') as string}
className="p-1.5 text-primary-600 hover:text-primary-700"
>
<Plus className="w-4 h-4" />
</button>
)}
</div>
))}
</div>
{isOpen && (
<button
type="button"
onClick={() => copyToAll(iso)}
className="shrink-0 inline-flex items-center gap-1 pt-2 text-xs text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300"
>
<Copy className="w-3.5 h-3.5" />
{t('businessProfile.businessHours.copyToAll', 'Copy to all days')}
</button>
)}
</div>
);
})}
</div>
);
};
/**
* Dedicated PDF letterhead logo uploader. Accepts PNG / JPEG / SVG;
* the backend rasterises SVG to PNG via sharp so vector uploads work
@@ -84,10 +84,28 @@ export interface BusinessProfile {
* via publicSettings. When null/empty, the calendar UI falls back
* to the browser's `Intl.DateTimeFormat().resolvedOptions().timeZone`. */
timezone: string | null;
/** Per-ISO-weekday opening hours (migration 114). Keyed "1".."7"
* (1=Mon … 7=Sun); each value is a list of {start,end} "HH:MM" blocks,
* so a day can carry a lunch break or differ from its neighbours. A day
* with no blocks is closed. null = no hours configured. Interpreted in
* `timezone`. Drives the scheduled-email business-hours floor. */
businessHours: BusinessHours | null;
/** Master switch for the scheduled-email business-hours floor
* (migration 114). Defaults true. When off, scheduled emails send at
* their requested instant regardless of `businessHours`. */
scheduledEmailFloorEnabled: boolean;
createdAt: string;
updatedAt: string;
}
export interface BusinessHoursBlock {
start: string; // "HH:MM"
end: string; // "HH:MM"
}
/** ISO-weekday-keyed ("1".."7") opening blocks. */
export type BusinessHours = Record<string, BusinessHoursBlock[]>;
export interface BankAccount {
id: number;
label: string;
+10
View File
@@ -74,6 +74,16 @@ export const emailService = {
await api.post('/admin/email/test', { test_email: testEmail });
},
/** Flush the email queue immediately. Sends every pending email now,
* bypassing the business-hours floor — the escape hatch for draining
* the queue before maintenance/updates. */
async flushQueue(): Promise<{ processed: number; sent: number; failed: number }> {
const response = await api.post<{ processed: number; sent: number; failed: number }>(
'/admin/email/flush-queue'
);
return response.data;
},
// Get all email templates
async getTemplates(): Promise<EmailTemplate[]> {
const response = await api.get<EmailTemplate[]>('/admin/email/templates');