diff --git a/services/platform-api/package.json b/services/platform-api/package.json index 16c650c..85348ef 100644 --- a/services/platform-api/package.json +++ b/services/platform-api/package.json @@ -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", diff --git a/services/platform-api/pnpm-lock.yaml b/services/platform-api/pnpm-lock.yaml index 17fcb02..f3fd379 100644 --- a/services/platform-api/pnpm-lock.yaml +++ b/services/platform-api/pnpm-lock.yaml @@ -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: diff --git a/services/platform-api/src/scheduling/email/booking-templates.ts b/services/platform-api/src/scheduling/email/booking-templates.ts index ca394d6..a5f8147 100644 --- a/services/platform-api/src/scheduling/email/booking-templates.ts +++ b/services/platform-api/src/scheduling/email/booking-templates.ts @@ -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}`, ` +

This is a reminder of your upcoming booking ${escapeHtml(lead)}.

+

${escapeHtml(ctx.eventTitle)} with ${escapeHtml(ctx.hostName)}

+

${escapeHtml(when)}

+ ${ctx.location ? `

${escapeHtml(ctx.location)}

` : ''} +

Reschedule or cancel

+ `) + return { subject, text, html } +} + export function cancellationEmail(ctx: BookingEmailContext): RenderedEmail { const accent = ctx.brandColor || '#1a1a1a' const when = fmtRange(ctx.startUtc, ctx.endUtc, ctx.attendeeTimezone) diff --git a/services/platform-api/src/scheduling/reminders/booking-reminder.worker.ts b/services/platform-api/src/scheduling/reminders/booking-reminder.worker.ts new file mode 100644 index 0000000..dc847a3 --- /dev/null +++ b/services/platform-api/src/scheduling/reminders/booking-reminder.worker.ts @@ -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, + @InjectModel(Host.name) private readonly hostModel: Model, + @InjectModel(EventType.name) private readonly eventTypeModel: Model, + @InjectModel(Tenant.name) private readonly tenantModel: Model, + private readonly provisioner: CredentialProvisioner, + private readonly mailer: JmapMailer, + config: ConfigService, + ) { + this.offsets = parseOffsets(config.get('SCHEDULING_REMINDER_OFFSETS')) + this.bookingPublicUrl = (config.get('BOOKING_PUBLIC_URL') ?? 'https://booking.dezky.local').replace(/\/$/, '') + } + + @Cron(CronExpression.EVERY_5_MINUTES, { name: 'booking-reminders' }) + async run(): Promise { + 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 { + // 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() + 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 +} diff --git a/services/platform-api/src/scheduling/scheduling.module.ts b/services/platform-api/src/scheduling/scheduling.module.ts index e6e7a54..7734e1f 100644 --- a/services/platform-api/src/scheduling/scheduling.module.ts +++ b/services/platform-api/src/scheduling/scheduling.module.ts @@ -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 {} diff --git a/services/platform-api/src/schemas/booking.schema.ts b/services/platform-api/src/schemas/booking.schema.ts index dffc6aa..19063e8 100644 --- a/services/platform-api/src/schemas/booking.schema.ts +++ b/services/platform-api/src/schemas/booking.schema.ts @@ -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)