feat(scheduling): booking reminder emails
This commit is contained in:
@@ -19,6 +19,7 @@
|
|||||||
"@nestjs/platform-fastify": "^10.4.0",
|
"@nestjs/platform-fastify": "^10.4.0",
|
||||||
"@nestjs/config": "^3.3.0",
|
"@nestjs/config": "^3.3.0",
|
||||||
"@nestjs/mongoose": "^10.1.0",
|
"@nestjs/mongoose": "^10.1.0",
|
||||||
|
"@nestjs/schedule": "^4.1.0",
|
||||||
"@nestjs/throttler": "^6.2.1",
|
"@nestjs/throttler": "^6.2.1",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.1",
|
"class-validator": "^0.14.1",
|
||||||
|
|||||||
Generated
+41
@@ -26,6 +26,9 @@ importers:
|
|||||||
'@nestjs/platform-fastify':
|
'@nestjs/platform-fastify':
|
||||||
specifier: ^10.4.0
|
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))
|
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':
|
'@nestjs/throttler':
|
||||||
specifier: ^6.2.1
|
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)
|
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':
|
'@fastify/view':
|
||||||
optional: true
|
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':
|
'@nestjs/schematics@10.2.3':
|
||||||
resolution: {integrity: sha512-4e8gxaCk7DhBxVUly2PjYL4xC2ifDFexCqq1/u4TtivLGXotVk0wHdYuPYe1tHTHuR1lsOkRbfOCpkdTnigLVg==}
|
resolution: {integrity: sha512-4e8gxaCk7DhBxVUly2PjYL4xC2ifDFexCqq1/u4TtivLGXotVk0wHdYuPYe1tHTHuR1lsOkRbfOCpkdTnigLVg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -753,6 +762,9 @@ packages:
|
|||||||
'@types/json-schema@7.0.15':
|
'@types/json-schema@7.0.15':
|
||||||
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
||||||
|
|
||||||
|
'@types/luxon@3.4.2':
|
||||||
|
resolution: {integrity: sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA==}
|
||||||
|
|
||||||
'@types/luxon@3.7.1':
|
'@types/luxon@3.7.1':
|
||||||
resolution: {integrity: sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==}
|
resolution: {integrity: sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==}
|
||||||
|
|
||||||
@@ -1157,6 +1169,9 @@ packages:
|
|||||||
create-require@1.1.1:
|
create-require@1.1.1:
|
||||||
resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==}
|
resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==}
|
||||||
|
|
||||||
|
cron@3.2.1:
|
||||||
|
resolution: {integrity: sha512-w2n5l49GMmmkBFEsH9FIDhjZ1n1QgTMOCMGuQtOXs5veNiosZmso6bQGuqOJSYAXXrG84WQFVneNk+Yt0Ua9iw==}
|
||||||
|
|
||||||
cross-spawn@7.0.6:
|
cross-spawn@7.0.6:
|
||||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
@@ -1862,6 +1877,10 @@ packages:
|
|||||||
lru-cache@5.1.1:
|
lru-cache@5.1.1:
|
||||||
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
|
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:
|
luxon@3.7.2:
|
||||||
resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==}
|
resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -2628,6 +2647,10 @@ packages:
|
|||||||
uuid-random@1.3.2:
|
uuid-random@1.3.2:
|
||||||
resolution: {integrity: sha512-UOzej0Le/UgkbWEO8flm+0y+G+ljUon1QWTEZOq1rnMAsxo2+SckbiZdKzAHHlVh6gJqI1TjC/xwgR50MuCrBQ==}
|
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:
|
v8-compile-cache-lib@3.0.1:
|
||||||
resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==}
|
resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==}
|
||||||
|
|
||||||
@@ -3583,6 +3606,13 @@ snapshots:
|
|||||||
path-to-regexp: 3.3.0
|
path-to-regexp: 3.3.0
|
||||||
tslib: 2.8.1
|
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)':
|
'@nestjs/schematics@10.2.3(chokidar@3.6.0)(typescript@5.7.2)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@angular-devkit/core': 17.3.11(chokidar@3.6.0)
|
'@angular-devkit/core': 17.3.11(chokidar@3.6.0)
|
||||||
@@ -3751,6 +3781,8 @@ snapshots:
|
|||||||
|
|
||||||
'@types/json-schema@7.0.15': {}
|
'@types/json-schema@7.0.15': {}
|
||||||
|
|
||||||
|
'@types/luxon@3.4.2': {}
|
||||||
|
|
||||||
'@types/luxon@3.7.1': {}
|
'@types/luxon@3.7.1': {}
|
||||||
|
|
||||||
'@types/node@20.19.41':
|
'@types/node@20.19.41':
|
||||||
@@ -4198,6 +4230,11 @@ snapshots:
|
|||||||
|
|
||||||
create-require@1.1.1: {}
|
create-require@1.1.1: {}
|
||||||
|
|
||||||
|
cron@3.2.1:
|
||||||
|
dependencies:
|
||||||
|
'@types/luxon': 3.4.2
|
||||||
|
luxon: 3.5.0
|
||||||
|
|
||||||
cross-spawn@7.0.6:
|
cross-spawn@7.0.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
path-key: 3.1.1
|
path-key: 3.1.1
|
||||||
@@ -5084,6 +5121,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
yallist: 3.1.1
|
yallist: 3.1.1
|
||||||
|
|
||||||
|
luxon@3.5.0: {}
|
||||||
|
|
||||||
luxon@3.7.2: {}
|
luxon@3.7.2: {}
|
||||||
|
|
||||||
magic-string@0.30.8:
|
magic-string@0.30.8:
|
||||||
@@ -5730,6 +5769,8 @@ snapshots:
|
|||||||
|
|
||||||
uuid-random@1.3.2: {}
|
uuid-random@1.3.2: {}
|
||||||
|
|
||||||
|
uuid@11.0.3: {}
|
||||||
|
|
||||||
v8-compile-cache-lib@3.0.1: {}
|
v8-compile-cache-lib@3.0.1: {}
|
||||||
|
|
||||||
v8-to-istanbul@9.3.0:
|
v8-to-istanbul@9.3.0:
|
||||||
|
|||||||
@@ -75,6 +75,45 @@ export function confirmationEmail(ctx: BookingEmailContext): RenderedEmail {
|
|||||||
return { subject, text, html }
|
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 {
|
export function cancellationEmail(ctx: BookingEmailContext): RenderedEmail {
|
||||||
const accent = ctx.brandColor || '#1a1a1a'
|
const accent = ctx.brandColor || '#1a1a1a'
|
||||||
const when = fmtRange(ctx.startUtc, ctx.endUtc, ctx.attendeeTimezone)
|
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 { Module } from '@nestjs/common'
|
||||||
import { MongooseModule } from '@nestjs/mongoose'
|
import { MongooseModule } from '@nestjs/mongoose'
|
||||||
|
import { ScheduleModule } from '@nestjs/schedule'
|
||||||
import { ThrottlerModule } from '@nestjs/throttler'
|
import { ThrottlerModule } from '@nestjs/throttler'
|
||||||
import { AuthModule } from '../auth/auth.module.js'
|
import { AuthModule } from '../auth/auth.module.js'
|
||||||
import { IntegrationsModule } from '../integrations/integrations.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 { EventType, EventTypeSchema } from '../schemas/event-type.schema.js'
|
||||||
import { Host, HostSchema } from '../schemas/scheduling-host.schema.js'
|
import { Host, HostSchema } from '../schemas/scheduling-host.schema.js'
|
||||||
import { SlotLock, SlotLockSchema } from '../schemas/slot-lock.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 { User, UserSchema } from '../schemas/user.schema.js'
|
||||||
import { TenantsModule } from '../tenants/tenants.module.js'
|
import { TenantsModule } from '../tenants/tenants.module.js'
|
||||||
import { AvailabilityService } from './availability/availability.service.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 { HostsService } from './hosts/hosts.service.js'
|
||||||
import { PublicSchedulingController } from './public/public-scheduling.controller.js'
|
import { PublicSchedulingController } from './public/public-scheduling.controller.js'
|
||||||
import { PublicSchedulingService } from './public/public-scheduling.service.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 { SchedulingAdminController } from './scheduling-admin.controller.js'
|
||||||
import { SlotService } from './slots/slot.service.js'
|
import { SlotService } from './slots/slot.service.js'
|
||||||
import { StalwartCalendarModule } from './stalwart-calendar/stalwart-calendar.module.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: EventType.name, schema: EventTypeSchema },
|
||||||
{ name: Booking.name, schema: BookingSchema },
|
{ name: Booking.name, schema: BookingSchema },
|
||||||
{ name: SlotLock.name, schema: SlotLockSchema },
|
{ name: SlotLock.name, schema: SlotLockSchema },
|
||||||
|
{ name: Tenant.name, schema: TenantSchema },
|
||||||
{ name: User.name, schema: UserSchema },
|
{ 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;
|
// Per-IP rate limiting for the public booking endpoints (default read limit;
|
||||||
// write endpoints tighten it via @Throttle).
|
// write endpoints tighten it via @Throttle).
|
||||||
ThrottlerModule.forRoot({ throttlers: [{ name: 'default', ttl: 60_000, limit: 60 }] }),
|
ThrottlerModule.forRoot({ throttlers: [{ name: 'default', ttl: 60_000, limit: 60 }] }),
|
||||||
@@ -52,6 +58,7 @@ import { StalwartCalendarModule } from './stalwart-calendar/stalwart-calendar.mo
|
|||||||
BookingsService,
|
BookingsService,
|
||||||
PublicSchedulingService,
|
PublicSchedulingService,
|
||||||
JmapMailer,
|
JmapMailer,
|
||||||
|
BookingReminderWorker,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class SchedulingModule {}
|
export class SchedulingModule {}
|
||||||
|
|||||||
@@ -73,9 +73,17 @@ export class Booking {
|
|||||||
@Prop({ trim: true })
|
@Prop({ trim: true })
|
||||||
cancellationReason?: string
|
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' })
|
@Prop({ default: 'none' })
|
||||||
reminderState!: string
|
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)
|
export const BookingSchema = SchemaFactory.createForClass(Booking)
|
||||||
|
|||||||
Reference in New Issue
Block a user