Files
picpeak/backend/src/services/expirationChecker.js
T
Luca 5893ecb27a fix(workflows): ship built-ins disabled for first beta + enabled-based mutex + admin sentinel
Per review: the four cutover built-ins (dunning, gallery_expiring,
gallery_expired, pre_event_email) now ship enabled:false. The mutual-exclusion
guards revert to ENABLED-based (isBuiltinFlowActive, not existence) so the
legacy paths keep running until the admin enables a built-in — enabling cuts
over, disabling reverts to legacy (fixes concern #4's "disable = silent dark"
foot-gun; no automation goes dark on upgrade).

admin_toggled_at sentinel (migration 148) marks admin ownership; the boot
re-seeder applies a shipped default-flip (enabled→disabled) only to
never-touched built-ins and never overwrites an admin's enable/disable/edit
(nit #1). SEED_VERSIONs bumped so the disabled default propagates.

Nit: applyReminder unlinks the just-rendered Mahnung PDF if queueEmail throws
(no orphan file).
2026-06-23 23:35:56 +02:00

257 lines
10 KiB
JavaScript

const cron = require('node-cron');
const { db } = require('../database/db');
const { archiveEvent } = require('./archiveService');
const { queueEmail, getSupportEmail } = require('./emailProcessor');
const { buildShareLinkVariants } = require('./shareLinkService');
const logger = require('../utils/logger');
const { formatBoolean } = require('../utils/dbCompat');
function startExpirationChecker() {
// Check every hour for expired events and warnings
cron.schedule('0 * * * *', async () => {
await checkExpirations();
});
logger.info('Expiration checker started');
}
async function checkExpirations() {
try {
const now = new Date();
const warningDate = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000); // 7 days from now
// Mutual exclusion with the workflow engine: when the matching built-in flow
// is enabled, the engine sends the email (via notify_gallery_* actions). We
// still EMIT the trigger every pass (for the built-in AND any custom flows),
// but skip the LEGACY email so the two never double-send. State transitions
// (is_active=false, archive) always run regardless — they're the expiry
// mechanic, not the notification.
// Enabled-based mutual exclusion: the legacy email stands down only when the
// matching built-in is ENABLED (then its action sends the identical mail). A
// disabled built-in leaves the legacy send running — so the flows can ship
// disabled without galleries going un-notified, and disabling a flow reverts
// to legacy. The trigger is still emitted regardless (for any custom flows).
const { isBuiltinFlowActive } = require('./workflows');
const warningFlowOwns = await isBuiltinFlowActive('gallery_expiring');
const expiredFlowOwns = await isBuiltinFlowActive('gallery_expired');
// Check for events needing warning emails
// Skip events with null expires_at (they never expire)
const eventsNeedingWarning = await db('events')
.where('is_active', formatBoolean(true))
.where('is_archived', formatBoolean(false))
.whereNotNull('expires_at')
.where('expires_at', '<=', warningDate)
.where('expires_at', '>', now);
for (const event of eventsNeedingWarning) {
await emitGalleryExpiring(event); // always — for the built-in + any custom flows
if (!warningFlowOwns) {
await queueExpirationWarning(event); // legacy email (self-dedupes)
}
}
// Check for expired events
// Skip events with null expires_at (they never expire)
const expiredEvents = await db('events')
.where('is_active', formatBoolean(true))
.where('is_archived', formatBoolean(false))
.whereNotNull('expires_at')
.where('expires_at', '<=', now);
for (const event of expiredEvents) {
await handleExpiredEvent(event, { sendLegacyEmails: !expiredFlowOwns });
}
} catch (error) {
logger.error('Error checking expirations:', error);
}
}
/**
* Emit gallery.expiring for the workflow engine. Best-effort / fail-closed;
* deduped per (workflow, event) by emitWorkflowEvent so the hourly sweep fires
* a flow at most once per gallery.
*/
async function emitGalleryExpiring(event) {
try {
const daysRemaining = Math.ceil((new Date(event.expires_at) - new Date()) / (1000 * 60 * 60 * 24));
const { shareUrl } = await buildShareLinkVariants({ slug: event.slug, shareToken: event.share_token });
await require('./workflows').emitWorkflowEvent('gallery.expiring', {
entityType: 'event',
entityId: event.id,
payload: {
eventId: event.id,
slug: event.slug,
eventName: event.event_name,
eventDate: event.event_date,
expiresAt: event.expires_at,
daysRemaining,
customerEmail: event.customer_email || event.host_email || null,
adminEmail: event.admin_email || null,
galleryLink: shareUrl,
},
});
} catch (err) {
logger.warn('Failed to emit gallery.expiring workflow event', { eventId: event.id, error: err.message });
}
}
/**
* Queue the customer expiration-warning email. Self-dedupes on the
* (event_id, 'expiration_warning') email_queue row so both the legacy hourly
* loop and the workflow `notify_gallery_expiring` action are safe to call it.
*/
async function queueExpirationWarning(event) {
const existingWarning = await db('email_queue')
.where('event_id', event.id)
.where('email_type', 'expiration_warning')
.first();
if (existingWarning) return;
const daysRemaining = Math.ceil((new Date(event.expires_at) - new Date()) / (1000 * 60 * 60 * 24));
const recipientEmail = event.customer_email || event.host_email;
const recipientName = event.customer_name || event.host_name || (recipientEmail ? recipientEmail.split('@')[0] : null);
// event.share_link is the path-only form; use the full URL so the
// recipient's mail client renders a clickable absolute link.
const { shareUrl } = await buildShareLinkVariants({ slug: event.slug, shareToken: event.share_token });
// Date formatting + language detection happen inside processTemplate using
// the recipient's resolved language — pass the raw ISO date and let the
// processor format it. Don't pre-format here with a hard-coded `.de`/`en`
// sniff (that helper got the wrong language for nl/pt/ru recipients).
//
// gallery_password is sent as the security-message sentinel because by the
// time the warning fires we no longer have the plaintext (only the bcrypt
// hash); the processor localises this to "(Not shown for security reasons)".
await queueEmail(event.id, recipientEmail, 'expiration_warning', {
customer_name: recipientName,
customer_email: recipientEmail,
host_name: recipientName,
event_name: event.event_name,
event_date: event.event_date,
days_remaining: daysRemaining.toString(),
expiry_date: event.expires_at,
gallery_link: shareUrl,
gallery_password: '{{password_security_message}}'
// Relationship mail — hold to business hours (no-op unless configured).
}, { respectBusinessHours: true });
logger.info(`Queued expiration warning for event ${event.slug}`);
}
/**
* Queue the gallery_expired emails (customer + optional admin). Self-dedupes on
* the (event_id, 'gallery_expired') email_queue row, so both the legacy expiry
* handler and the workflow `notify_gallery_expired` action are safe to call it.
*/
async function sendGalleryExpiredEmails(event) {
const existing = await db('email_queue')
.where('event_id', event.id)
.where('email_type', 'gallery_expired')
.first();
if (existing) return;
// The shipped templates (EN/DE in legacy 028, NL/PT/RU in core 075) reference
// {{host_name}}, {{event_date}}, {{expiry_date}} and {{support_email}} — fill
// them all here.
const recipientEmail = event.customer_email || event.host_email;
const recipientName = event.customer_name || event.host_name || (recipientEmail ? recipientEmail.split('@')[0] : null);
const supportEmail = await getSupportEmail();
const customerVars = {
customer_name: recipientName,
customer_email: recipientEmail,
host_name: recipientName,
event_name: event.event_name,
event_date: event.event_date,
expiry_date: event.expires_at,
admin_email: event.admin_email,
support_email: supportEmail
};
if (recipientEmail) {
await queueEmail(event.id, recipientEmail, 'gallery_expired', customerVars);
}
// Also notify admin (when configured).
if (event.admin_email && event.admin_email !== recipientEmail) {
await queueEmail(event.id, event.admin_email, 'gallery_expired', {
...customerVars,
host_name: 'Admin'
});
}
}
async function handleExpiredEvent(event, { sendLegacyEmails = true } = {}) {
try {
// Mark as inactive
await db('events').where('id', event.id).update({ is_active: formatBoolean(false) });
// Fire event.expired BEFORE the cascading archive call so receivers
// get the lifecycle in order (expired → archived). Canonical event
// subject (#341) so receivers see the same shape across all event.*
// types; expires_at retained as an event.expired-specific extra.
try {
const webhookService = require('./webhookService');
await webhookService.fire('event.expired', {
event: {
...webhookService.buildEventSubject({
id: event.id,
slug: event.slug,
event_name: event.event_name,
event_type: event.event_type,
event_date: event.event_date,
share_token: event.share_token,
customer_name: event.customer_name || event.host_name,
customer_email: event.customer_email || event.host_email,
customer_phone: event.customer_phone,
}),
expires_at: event.expires_at,
},
});
} catch (e) { /* non-fatal */ }
// Emit gallery.expired for the workflow engine (sibling to the event.expired
// webhook). Always emitted; deduped per (workflow, event).
try {
await require('./workflows').emitWorkflowEvent('gallery.expired', {
entityType: 'event',
entityId: event.id,
payload: {
eventId: event.id,
slug: event.slug,
eventName: event.event_name,
eventDate: event.event_date,
expiresAt: event.expires_at,
customerEmail: event.customer_email || event.host_email || null,
adminEmail: event.admin_email || null,
},
});
} catch (err) {
logger.warn('Failed to emit gallery.expired workflow event', { eventId: event.id, error: err.message });
}
// Legacy notification — skipped when the gallery_expired built-in flow drives
// it (the flow's notify_gallery_expired action sends the same emails).
if (sendLegacyEmails) {
await sendGalleryExpiredEmails(event);
}
// Start archiving process (always — the expiry mechanic, not the email).
await archiveEvent(event);
logger.info(`Handled expiration for event ${event.slug}`);
} catch (error) {
logger.error(`Error handling expired event ${event.slug}:`, error);
}
}
module.exports = {
startExpirationChecker,
// Reused by the workflow notify_gallery_* actions so the engine path sends the
// exact same emails as the legacy hourly checker.
queueExpirationWarning,
sendGalleryExpiredEmails,
};