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
@@ -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)