feat(scheduling): booking reminder emails

This commit is contained in:
Ronni Baslund
2026-06-07 00:31:33 +02:00
parent 3831c85285
commit 9e1defa946
6 changed files with 248 additions and 1 deletions
+1
View File
@@ -19,6 +19,7 @@
"@nestjs/platform-fastify": "^10.4.0",
"@nestjs/config": "^3.3.0",
"@nestjs/mongoose": "^10.1.0",
"@nestjs/schedule": "^4.1.0",
"@nestjs/throttler": "^6.2.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
+41
View File
@@ -26,6 +26,9 @@ importers:
'@nestjs/platform-fastify':
specifier: ^10.4.0
version: 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))
'@nestjs/schedule':
specifier: ^4.1.0
version: 4.1.2(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))
'@nestjs/throttler':
specifier: ^6.2.1
version: 6.5.0(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)
@@ -610,6 +613,12 @@ packages:
'@fastify/view':
optional: true
'@nestjs/schedule@4.1.2':
resolution: {integrity: sha512-hCTQ1lNjIA5EHxeu8VvQu2Ed2DBLS1GSC6uKPYlBiQe6LL9a7zfE9iVSK+zuK8E2odsApteEBmfAQchc8Hx0Gg==}
peerDependencies:
'@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0
'@nestjs/core': ^8.0.0 || ^9.0.0 || ^10.0.0
'@nestjs/schematics@10.2.3':
resolution: {integrity: sha512-4e8gxaCk7DhBxVUly2PjYL4xC2ifDFexCqq1/u4TtivLGXotVk0wHdYuPYe1tHTHuR1lsOkRbfOCpkdTnigLVg==}
peerDependencies:
@@ -753,6 +762,9 @@ packages:
'@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
'@types/luxon@3.4.2':
resolution: {integrity: sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA==}
'@types/luxon@3.7.1':
resolution: {integrity: sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==}
@@ -1157,6 +1169,9 @@ packages:
create-require@1.1.1:
resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==}
cron@3.2.1:
resolution: {integrity: sha512-w2n5l49GMmmkBFEsH9FIDhjZ1n1QgTMOCMGuQtOXs5veNiosZmso6bQGuqOJSYAXXrG84WQFVneNk+Yt0Ua9iw==}
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
@@ -1862,6 +1877,10 @@ packages:
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
luxon@3.5.0:
resolution: {integrity: sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==}
engines: {node: '>=12'}
luxon@3.7.2:
resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==}
engines: {node: '>=12'}
@@ -2628,6 +2647,10 @@ packages:
uuid-random@1.3.2:
resolution: {integrity: sha512-UOzej0Le/UgkbWEO8flm+0y+G+ljUon1QWTEZOq1rnMAsxo2+SckbiZdKzAHHlVh6gJqI1TjC/xwgR50MuCrBQ==}
uuid@11.0.3:
resolution: {integrity: sha512-d0z310fCWv5dJwnX1Y/MncBAqGMKEzlBb1AOf7z9K8ALnd0utBX/msg/fA0+sbyN1ihbMsLhrBlnl1ak7Wa0rg==}
hasBin: true
v8-compile-cache-lib@3.0.1:
resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==}
@@ -3583,6 +3606,13 @@ snapshots:
path-to-regexp: 3.3.0
tslib: 2.8.1
'@nestjs/schedule@4.1.2(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))':
dependencies:
'@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/core': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2)
cron: 3.2.1
uuid: 11.0.3
'@nestjs/schematics@10.2.3(chokidar@3.6.0)(typescript@5.7.2)':
dependencies:
'@angular-devkit/core': 17.3.11(chokidar@3.6.0)
@@ -3751,6 +3781,8 @@ snapshots:
'@types/json-schema@7.0.15': {}
'@types/luxon@3.4.2': {}
'@types/luxon@3.7.1': {}
'@types/node@20.19.41':
@@ -4198,6 +4230,11 @@ snapshots:
create-require@1.1.1: {}
cron@3.2.1:
dependencies:
'@types/luxon': 3.4.2
luxon: 3.5.0
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
@@ -5084,6 +5121,8 @@ snapshots:
dependencies:
yallist: 3.1.1
luxon@3.5.0: {}
luxon@3.7.2: {}
magic-string@0.30.8:
@@ -5730,6 +5769,8 @@ snapshots:
uuid-random@1.3.2: {}
uuid@11.0.3: {}
v8-compile-cache-lib@3.0.1: {}
v8-to-istanbul@9.3.0:
@@ -75,6 +75,45 @@ export function confirmationEmail(ctx: BookingEmailContext): RenderedEmail {
return { subject, text, html }
}
// Human-friendly lead time, e.g. 1440 -> "tomorrow", 60 -> "in 1 hour".
function fmtLeadTime(minutes: number): string {
if (minutes % 1440 === 0) {
const days = minutes / 1440
return days === 1 ? 'tomorrow' : `in ${days} days`
}
if (minutes % 60 === 0) {
const hours = minutes / 60
return `in ${hours} hour${hours === 1 ? '' : 's'}`
}
return `in ${minutes} minutes`
}
export function reminderEmail(ctx: BookingEmailContext & { offsetMinutes: number }): RenderedEmail {
const accent = ctx.brandColor || '#1a1a1a'
const when = fmtRange(ctx.startUtc, ctx.endUtc, ctx.attendeeTimezone)
const lead = fmtLeadTime(ctx.offsetMinutes)
const subject = `Reminder: ${ctx.eventTitle} with ${ctx.hostName} ${lead}`
const text = [
`Hi ${ctx.attendeeName},`,
``,
`This is a reminder of your upcoming booking ${lead}.`,
``,
`${ctx.eventTitle} with ${ctx.hostName}`,
when,
ctx.location ? `Location: ${ctx.location}` : '',
``,
`Need to change it? ${ctx.manageUrl}`,
].filter(Boolean).join('\n')
const html = shell(accent, ctx.brandName, `Your booking is ${lead}`, `
<p style="margin:0 0 12px;color:#444">This is a reminder of your upcoming booking ${escapeHtml(lead)}.</p>
<p style="margin:0 0 6px"><strong>${escapeHtml(ctx.eventTitle)}</strong> with ${escapeHtml(ctx.hostName)}</p>
<p style="margin:0 0 4px;color:#444">${escapeHtml(when)}</p>
${ctx.location ? `<p style="margin:0 0 4px;color:#444">${escapeHtml(ctx.location)}</p>` : ''}
<p style="margin:18px 0 0"><a href="${ctx.manageUrl}" style="display:inline-block;background:${accent};color:#fff;text-decoration:none;padding:10px 18px;border-radius:8px;font-size:14px">Reschedule or cancel</a></p>
`)
return { subject, text, html }
}
export function cancellationEmail(ctx: BookingEmailContext): RenderedEmail {
const accent = ctx.brandColor || '#1a1a1a'
const when = fmtRange(ctx.startUtc, ctx.endUtc, ctx.attendeeTimezone)
@@ -0,0 +1,151 @@
import { Injectable, Logger } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import { InjectModel } from '@nestjs/mongoose'
import { Cron, CronExpression } from '@nestjs/schedule'
import { Model } from 'mongoose'
import { Booking, BookingDocument } from '../../schemas/booking.schema.js'
import { EventType, EventTypeDocument } from '../../schemas/event-type.schema.js'
import { Host, HostDocument } from '../../schemas/scheduling-host.schema.js'
import { Tenant, TenantDocument } from '../../schemas/tenant.schema.js'
import { reminderEmail } from '../email/booking-templates.js'
import { buildBookingIcs } from '../email/ics.js'
import { JmapMailer } from '../email/jmap-mailer.service.js'
import { CredentialProvisioner } from '../stalwart-calendar/credential-provisioner.service.js'
// Periodic worker that emails attendees a branded reminder ahead of confirmed
// bookings. Offsets (minutes before start) are configurable via
// SCHEDULING_REMINDER_OFFSETS (comma-separated, default "1440,60"). Idempotency
// is enforced per-offset on the Booking via `sentReminderOffsets`: the worker
// only emails an offset that is absent, then atomically appends it so concurrent
// or repeated runs never double-send. Email is best-effort — a send failure
// leaves the offset un-appended so the next run retries.
@Injectable()
export class BookingReminderWorker {
private readonly logger = new Logger(BookingReminderWorker.name)
private readonly offsets: number[]
private readonly bookingPublicUrl: string
constructor(
@InjectModel(Booking.name) private readonly bookingModel: Model<BookingDocument>,
@InjectModel(Host.name) private readonly hostModel: Model<HostDocument>,
@InjectModel(EventType.name) private readonly eventTypeModel: Model<EventTypeDocument>,
@InjectModel(Tenant.name) private readonly tenantModel: Model<TenantDocument>,
private readonly provisioner: CredentialProvisioner,
private readonly mailer: JmapMailer,
config: ConfigService,
) {
this.offsets = parseOffsets(config.get<string>('SCHEDULING_REMINDER_OFFSETS'))
this.bookingPublicUrl = (config.get<string>('BOOKING_PUBLIC_URL') ?? 'https://booking.dezky.local').replace(/\/$/, '')
}
@Cron(CronExpression.EVERY_5_MINUTES, { name: 'booking-reminders' })
async run(): Promise<void> {
if (this.offsets.length === 0) return
const now = new Date()
// For each offset, a reminder is "due" once we are within the offset window
// of the start (start - offset <= now < start). We also clamp at `now` on the
// lower bound so a worker that was down does not fire stale reminders for
// bookings whose offset window opened long ago — but bookings still in the
// future get caught up. The lower bound is the previous run window (5 min).
for (const offset of this.offsets) {
const offsetMs = offset * 60_000
const windowEnd = new Date(now.getTime() + offsetMs)
const candidates = await this.bookingModel
.find({
status: 'confirmed',
startUtc: { $gt: now, $lte: windowEnd },
sentReminderOffsets: { $ne: offset },
})
.limit(200)
.exec()
for (const booking of candidates) {
await this.sendReminder(booking, offset).catch((e) =>
this.logger.warn(`Reminder (offset ${offset}m) failed for booking ${booking._id}: ${e.message}`),
)
}
}
}
private async sendReminder(booking: BookingDocument, offset: number): Promise<void> {
// Atomically claim this offset first so a concurrent run cannot also send it.
const claim = await this.bookingModel
.findOneAndUpdate(
{ _id: booking._id, sentReminderOffsets: { $ne: offset } },
{ $addToSet: { sentReminderOffsets: offset } },
)
.exec()
if (!claim) return // another run already claimed this offset
try {
const [host, eventType, tenant] = await Promise.all([
this.hostModel.findById(booking.hostId).exec(),
this.eventTypeModel.findById(booking.eventTypeId).exec(),
this.tenantModel.findById(booking.tenantId).exec(),
])
if (!host || !eventType || !tenant) {
throw new Error('missing host/eventType/tenant for booking')
}
const access = await this.provisioner.resolveAccess(host)
const rendered = reminderEmail({
brandName: tenant.name,
brandColor: tenant.brandColor,
eventTitle: eventType.title,
hostName: host.displayName,
attendeeName: booking.attendeeName,
startUtc: booking.startUtc,
endUtc: booking.endUtc,
attendeeTimezone: booking.attendeeTimezone,
location: booking.locationUrl,
manageUrl: `${this.bookingPublicUrl}/manage/${booking.manageToken}`,
offsetMinutes: offset,
})
const ics = buildBookingIcs({
uid: booking.calendarEventUid,
start: booking.startUtc,
end: booking.endUtc,
summary: `${eventType.title} with ${host.displayName}`,
description: booking.attendeeNotes,
location: booking.locationUrl,
organizerName: host.displayName,
organizerEmail: host.email,
attendeeName: booking.attendeeName,
attendeeEmail: booking.attendeeEmail,
})
await this.mailer.send(access, {
to: booking.attendeeEmail,
toName: booking.attendeeName,
subject: rendered.subject,
text: rendered.text,
html: rendered.html,
ics,
icsFilename: 'invite.ics',
})
this.logger.log(`Sent ${offset}m reminder for booking ${booking._id}`)
} catch (err) {
// Roll back the claim so the next run retries this offset.
await this.bookingModel
.updateOne({ _id: booking._id }, { $pull: { sentReminderOffsets: offset } })
.exec()
.catch(() => undefined)
throw err
}
}
}
// "1440,60" -> [1440, 60]; ignores blanks and non-positive/NaN entries.
function parseOffsets(raw: string | undefined): number[] {
const source = (raw ?? '1440,60').split(',')
const seen = new Set<number>()
const out: number[] = []
for (const part of source) {
const n = Number(part.trim())
if (Number.isFinite(n) && n > 0 && !seen.has(n)) {
seen.add(n)
out.push(n)
}
}
return out
}
@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common'
import { MongooseModule } from '@nestjs/mongoose'
import { ScheduleModule } from '@nestjs/schedule'
import { ThrottlerModule } from '@nestjs/throttler'
import { AuthModule } from '../auth/auth.module.js'
import { IntegrationsModule } from '../integrations/integrations.module.js'
@@ -8,6 +9,7 @@ import { Booking, BookingSchema } from '../schemas/booking.schema.js'
import { EventType, EventTypeSchema } from '../schemas/event-type.schema.js'
import { Host, HostSchema } from '../schemas/scheduling-host.schema.js'
import { SlotLock, SlotLockSchema } from '../schemas/slot-lock.schema.js'
import { Tenant, TenantSchema } from '../schemas/tenant.schema.js'
import { User, UserSchema } from '../schemas/user.schema.js'
import { TenantsModule } from '../tenants/tenants.module.js'
import { AvailabilityService } from './availability/availability.service.js'
@@ -17,6 +19,7 @@ import { EventTypesService } from './event-types/event-types.service.js'
import { HostsService } from './hosts/hosts.service.js'
import { PublicSchedulingController } from './public/public-scheduling.controller.js'
import { PublicSchedulingService } from './public/public-scheduling.service.js'
import { BookingReminderWorker } from './reminders/booking-reminder.worker.js'
import { SchedulingAdminController } from './scheduling-admin.controller.js'
import { SlotService } from './slots/slot.service.js'
import { StalwartCalendarModule } from './stalwart-calendar/stalwart-calendar.module.js'
@@ -33,8 +36,11 @@ import { StalwartCalendarModule } from './stalwart-calendar/stalwart-calendar.mo
{ name: EventType.name, schema: EventTypeSchema },
{ name: Booking.name, schema: BookingSchema },
{ name: SlotLock.name, schema: SlotLockSchema },
{ name: Tenant.name, schema: TenantSchema },
{ name: User.name, schema: UserSchema },
]),
// Drives the @Cron booking reminder worker.
ScheduleModule.forRoot(),
// Per-IP rate limiting for the public booking endpoints (default read limit;
// write endpoints tighten it via @Throttle).
ThrottlerModule.forRoot({ throttlers: [{ name: 'default', ttl: 60_000, limit: 60 }] }),
@@ -52,6 +58,7 @@ import { StalwartCalendarModule } from './stalwart-calendar/stalwart-calendar.mo
BookingsService,
PublicSchedulingService,
JmapMailer,
BookingReminderWorker,
],
})
export class SchedulingModule {}
@@ -73,9 +73,17 @@ export class Booking {
@Prop({ trim: true })
cancellationReason?: string
// Reminder bookkeeping for Phase 2 (e.g. 'none' | 'sent_24h').
// Reminder bookkeeping for Phase 2 (e.g. 'none' | 'sent_24h'). Kept for
// backwards compatibility; the reminder worker now tracks idempotency via
// `sentReminderOffsets` instead.
@Prop({ default: 'none' })
reminderState!: string
// Reminder offsets (in minutes-before-start) that have already been emailed for
// this booking. The reminder cron only sends an offset absent from this list,
// then atomically appends it — making reminders idempotent across runs.
@Prop({ type: [Number], default: [] })
sentReminderOffsets!: number[]
}
export const BookingSchema = SchemaFactory.createForClass(Booking)