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)}
` : ''} + + `) + 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