feat(scheduling): booking reminder emails
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user