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,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');
});
}
};