@nuxt-customer-portal/planning 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/LICENSE +21 -0
  3. package/README.md +115 -0
  4. package/app/components/PlanningAppointmentCreateForm.vue +49 -0
  5. package/app/components/PlanningAppointmentDetails.vue +163 -0
  6. package/app/components/PlanningAppointmentDrawer.vue +109 -0
  7. package/app/components/PlanningAvailabilityCalendar.vue +525 -0
  8. package/app/components/PlanningDashboardOverview.vue +62 -0
  9. package/app/components/PlanningDashboardUpcomingAppointments.vue +81 -0
  10. package/app/components/PlanningPersonalCalendarSettings.vue +218 -0
  11. package/app/components/PlanningPersonalConnection.vue +68 -0
  12. package/app/components/PlanningPersonalMeetingSettings.vue +1 -0
  13. package/app/components/PlanningPolicyFields.vue +196 -0
  14. package/app/components/PlanningSettingsMembers.vue +50 -0
  15. package/app/components/PlanningSettingsPolicy.vue +46 -0
  16. package/app/components/PlanningSettingsSynchronization.vue +433 -0
  17. package/app/components/PlanningSlotPicker.vue +588 -0
  18. package/app/components/PlanningTimezoneReadOnlyHover.vue +172 -0
  19. package/app/components/PlanningTimezoneSelect.vue +101 -0
  20. package/app/composables/usePlanning.ts +180 -0
  21. package/app/composables/usePlanningDashboard.ts +4 -0
  22. package/app/composables/usePlanningTimeDisplay.ts +15 -0
  23. package/app/pages/admin/planning.vue +36 -0
  24. package/app/pages/appointments/[id].vue +13 -0
  25. package/app/pages/appointments/index.vue +384 -0
  26. package/app/pages/availability.vue +585 -0
  27. package/app/pages/booking/[slug].vue +298 -0
  28. package/app/pages/my-appointment-settings.vue +33 -0
  29. package/app/plugins/planning.ts +5 -0
  30. package/i18n/locales/en.json +260 -0
  31. package/i18n/locales/nl.json +260 -0
  32. package/migrations/0000_baseline.sql +58 -0
  33. package/migrations/0001_availability_calendar_title.sql +2 -0
  34. package/migrations/0002_job_activity.sql +11 -0
  35. package/migrations/0003_deduplicate_availability_jobs.sql +19 -0
  36. package/migrations/0004_connection_label.sql +2 -0
  37. package/nuxt.config.ts +15 -0
  38. package/package.json +55 -0
  39. package/portal.manifest.mjs +8 -0
  40. package/server/api/planning/admin/appointments/[id]/audit.get.ts +11 -0
  41. package/server/api/planning/admin/appointments.get.ts +11 -0
  42. package/server/api/planning/admin/jobs/[id]/retry.post.ts +26 -0
  43. package/server/api/planning/admin/jobs.get.ts +199 -0
  44. package/server/api/planning/admin/policy.get.ts +6 -0
  45. package/server/api/planning/admin/policy.put.ts +8 -0
  46. package/server/api/planning/admin/products/[id].put.ts +8 -0
  47. package/server/api/planning/admin/providers/[userId].patch.ts +8 -0
  48. package/server/api/planning/admin/providers.get.ts +6 -0
  49. package/server/api/planning/admin/retry.post.ts +31 -0
  50. package/server/api/planning/appointments/[id]/availability.get.ts +19 -0
  51. package/server/api/planning/appointments/[id]/cancel.post.ts +8 -0
  52. package/server/api/planning/appointments/[id]/files/[assetId].get.ts +17 -0
  53. package/server/api/planning/appointments/[id]/files.get.ts +28 -0
  54. package/server/api/planning/appointments/[id]/pending.delete.ts +8 -0
  55. package/server/api/planning/appointments/[id]/reschedule.put.ts +8 -0
  56. package/server/api/planning/appointments/[id].get.ts +6 -0
  57. package/server/api/planning/appointments.get.ts +4 -0
  58. package/server/api/planning/availability/[id].delete.ts +12 -0
  59. package/server/api/planning/availability/[id].put.ts +8 -0
  60. package/server/api/planning/availability.get.ts +6 -0
  61. package/server/api/planning/availability.post.ts +8 -0
  62. package/server/api/planning/calendar-busy.get.ts +29 -0
  63. package/server/api/planning/calendars.get.ts +8 -0
  64. package/server/api/planning/dashboard.get.ts +36 -0
  65. package/server/api/planning/google/notifications.post.ts +24 -0
  66. package/server/api/planning/oauth/[provider]/callback.get.ts +18 -0
  67. package/server/api/planning/oauth/[provider]/start.post.ts +8 -0
  68. package/server/api/planning/oauth/[provider].delete.ts +15 -0
  69. package/server/api/planning/products.get.ts +11 -0
  70. package/server/api/planning/provider-appointments.get.ts +11 -0
  71. package/server/api/planning/provider.get.ts +6 -0
  72. package/server/api/planning/provider.put.ts +8 -0
  73. package/server/api/store/planning/[productId]/availability.get.ts +8 -0
  74. package/server/api/store/planning/hold.delete.ts +11 -0
  75. package/server/api/store/planning/hold.get.ts +36 -0
  76. package/server/api/store/planning/holds.post.ts +10 -0
  77. package/server/db/schema/planning.ts +213 -0
  78. package/server/plugins/openapi.ts +36 -0
  79. package/server/plugins/planning.ts +6 -0
  80. package/server/tasks/planning/reconcile.ts +30 -0
  81. package/server/utils/access.ts +80 -0
  82. package/server/utils/adapters.ts +468 -0
  83. package/server/utils/appointments.ts +37 -0
  84. package/server/utils/booking.ts +619 -0
  85. package/server/utils/crypto.ts +23 -0
  86. package/server/utils/jobs.ts +671 -0
  87. package/server/utils/management.ts +427 -0
  88. package/server/utils/oauth.ts +104 -0
  89. package/server/utils/staff-appointments.ts +158 -0
  90. package/shared/appointment-calendar.ts +82 -0
  91. package/shared/availability.ts +140 -0
  92. package/shared/emails.ts +99 -0
  93. package/shared/feature.ts +78 -0
  94. package/shared/invitation.ts +58 -0
  95. package/shared/types.ts +110 -0
  96. package/shared/validation.ts +77 -0
@@ -0,0 +1,671 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { pool } from '@nuxt-customer-portal/core/server/utils/db'
3
+ import { sendPortalEmail } from '@nuxt-customer-portal/core/server/utils/portal-email'
4
+ import { emailRecipientName } from '@nuxt-customer-portal/core/shared/email-recipient'
5
+ import { rows, transaction } from '@nuxt-customer-portal/products/server/utils/database'
6
+ import {
7
+ getOrder,
8
+ reconcileCheckout,
9
+ processOrder,
10
+ notifyOrder
11
+ } from '@nuxt-customer-portal/products/server/utils/orders'
12
+ import { stripeProvider } from '@nuxt-customer-portal/products/server/utils/payments'
13
+ import { baseUrl } from '@nuxt-customer-portal/products/server/utils/access'
14
+ import { developmentSandboxEffectsEnabled } from '@nuxt-customer-portal/products/server/utils/development'
15
+ import type { Appointment, AvailabilityWindow, Reservation } from '../../shared/types'
16
+ import { calendarInvitation } from '../../shared/invitation'
17
+ import { calendarWallDateTime, wallInstant } from '../../shared/availability'
18
+ import { appointmentCalendarDescription, appointmentCalendarTitle } from '../../shared/appointment-calendar'
19
+ import { calendarAdapter, meetingAdapter, externalBusy } from './adapters'
20
+ import { digest, secret } from './crypto'
21
+ import { enqueue, lockProvider } from './booking'
22
+
23
+ import {
24
+ appointmentEmail,
25
+ canceledAppointmentEmail,
26
+ newAppointmentEmail,
27
+ updatedAppointmentEmail,
28
+ zoomLinkReadyEmail
29
+ } from '../../shared/emails'
30
+
31
+ export async function auditLock(tx: Pick<import('pg').PoolClient, 'query'>, id: string) {
32
+ await tx.query('SELECT pg_advisory_xact_lock(hashtext($1))', [`planning-effects:${id}`])
33
+ }
34
+ async function appointmentEffects(
35
+ id: string,
36
+ revision: number,
37
+ notify = true,
38
+ notificationKey?: string,
39
+ ensureMeeting = true
40
+ ) {
41
+ const [a] = await rows<Appointment>('SELECT * FROM planning.appointment WHERE id=$1', [id])
42
+ if (!a || a.revision !== revision) {
43
+ return
44
+ }
45
+ const order = await getOrder(a.order_id)
46
+ if (!order) {
47
+ throw new Error('Appointment order missing')
48
+ }
49
+ // Repair invoice delivery for older paid appointments whose invoice was
50
+ // created before invoice email jobs were scheduled transactionally.
51
+ await notifyOrder(order.id)
52
+ if (order.snapshot.storeMode === 'sandbox' && !developmentSandboxEffectsEnabled()) {
53
+ return
54
+ }
55
+ const [provider] = await rows<{ write_calendar_id: string; email: string }>(
56
+ `SELECT p.write_calendar_id,u.email FROM planning.provider p JOIN public."user" u ON u.id=p.user_id WHERE p.store_id=$1 AND p.user_id=$2`,
57
+ [a.store_id, a.user_id]
58
+ )
59
+ const eventId = a.calendar_event_id || `p${id.replace(/-/g, '')}`
60
+ let effectsFailure: unknown
61
+ try {
62
+ if (a.status === 'cancelled') {
63
+ if (a.calendar_event_id && a.calendar_id) {
64
+ await calendarAdapter().remove(a.store_id, a.calendar_user_id || a.user_id, a.calendar_id, a.calendar_event_id)
65
+ }
66
+ if (a.meeting_id) {
67
+ await meetingAdapter().remove(a.store_id, a.user_id, a.meeting_id)
68
+ }
69
+ } else {
70
+ if (a.snapshot.meetingProvider === 'zoom' && (ensureMeeting || !a.meeting_id || !a.meeting_url)) {
71
+ const meeting = await meetingAdapter().ensure(
72
+ a.store_id,
73
+ a.user_id,
74
+ `portal:${id}`,
75
+ appointmentCalendarTitle(a.snapshot.title, order.snapshot.billing),
76
+ a.start_at,
77
+ a.snapshot.durationMinutes,
78
+ {
79
+ agenda: appointmentCalendarDescription({
80
+ start: a.start_at,
81
+ end: a.end_at,
82
+ providerTimezone: a.snapshot.timezone,
83
+ customerTimezone: a.snapshot.customerTimezone,
84
+ locale: a.snapshot.locale,
85
+ billing: order.snapshot.billing,
86
+ email: order.email,
87
+ graceMinutes: a.snapshot.graceMinutes
88
+ }),
89
+ inviteeEmail: order.email
90
+ },
91
+ a.meeting_id || undefined
92
+ )
93
+ a.meeting_id = meeting.id
94
+ a.meeting_url = meeting.url
95
+ await rows('UPDATE planning.appointment SET meeting_id=$2,meeting_url=$3 WHERE id=$1', [
96
+ id,
97
+ meeting.id,
98
+ meeting.url
99
+ ])
100
+ }
101
+ if (!provider?.write_calendar_id) {
102
+ throw new Error('Select a writable calendar')
103
+ }
104
+ if (a.calendar_id && a.calendar_id !== provider.write_calendar_id && a.calendar_event_id) {
105
+ await calendarAdapter().remove(a.store_id, a.calendar_user_id || a.user_id, a.calendar_id, a.calendar_event_id)
106
+ }
107
+ const written = await calendarAdapter().put(a.store_id, a.user_id, provider.write_calendar_id, {
108
+ id: eventId,
109
+ summary: appointmentCalendarTitle(a.snapshot.title, order.snapshot.billing),
110
+ start: { dateTime: a.start_at.toISOString(), timeZone: a.snapshot.timezone },
111
+ end: { dateTime: a.end_at.toISOString(), timeZone: a.snapshot.timezone },
112
+ transparency: 'opaque',
113
+ description: appointmentCalendarDescription({
114
+ start: a.start_at,
115
+ end: a.end_at,
116
+ providerTimezone: a.snapshot.timezone,
117
+ customerTimezone: a.snapshot.customerTimezone,
118
+ locale: a.snapshot.locale,
119
+ billing: order.snapshot.billing,
120
+ email: order.email,
121
+ meetingUrl: a.meeting_url,
122
+ graceMinutes: a.snapshot.graceMinutes
123
+ }),
124
+ extendedProperties: { private: { portalPlanning: id, portalRevision: String(revision) } }
125
+ })
126
+ if (
127
+ written?.externalChangeKey ||
128
+ (written?.wasMissing && a.calendar_event_id && a.calendar_id === provider.write_calendar_id)
129
+ ) {
130
+ await enqueue(
131
+ pool,
132
+ `mirror-notice:${id}:${revision}:${written.externalChangeKey || 'missing'}`,
133
+ 'mirror-notice',
134
+ { userId: a.user_id }
135
+ )
136
+ }
137
+ await rows(
138
+ 'UPDATE planning.appointment SET calendar_event_id=$2,calendar_id=$3,calendar_user_id=user_id WHERE id=$1',
139
+ [id, written?.id || eventId, provider.write_calendar_id]
140
+ )
141
+ }
142
+ } catch (error) {
143
+ effectsFailure = error
144
+ }
145
+ if (!notify) {
146
+ if (effectsFailure) {
147
+ throw effectsFailure
148
+ }
149
+ return
150
+ }
151
+ const locale = a.snapshot.locale
152
+ const appointmentUrl = order.invitation_id
153
+ ? `${baseUrl()}/signup?invitationId=${encodeURIComponent(order.invitation_id)}`
154
+ : `${baseUrl()}/appointments`
155
+ const meetingDetails = a.meeting_url
156
+ ? locale === 'nl'
157
+ ? `**Duur:** ${a.snapshot.durationMinutes} minuten\n\n[Deelnemen aan de afspraak](${a.meeting_url})`
158
+ : `**Duration:** ${a.snapshot.durationMinutes} minutes\n\n[Join the meeting](${a.meeting_url})`
159
+ : locale === 'nl'
160
+ ? `**Duur:** ${a.snapshot.durationMinutes} minuten\n\nDe vergaderlink wordt later toegevoegd. Je vindt de actuele link altijd bij je afspraken.`
161
+ : `**Duration:** ${a.snapshot.durationMinutes} minutes\n\nThe meeting link will be added later. You can always find the current link under your appointments.`
162
+ const emailValues = {
163
+ recipient_name: emailRecipientName({
164
+ firstName: order.snapshot.billing.firstName,
165
+ displayName: order.snapshot.billing.name,
166
+ email: order.email
167
+ }),
168
+ product: a.snapshot.title,
169
+ status:
170
+ a.status === 'cancelled'
171
+ ? locale === 'nl'
172
+ ? 'Afspraak geannuleerd'
173
+ : 'Appointment cancelled'
174
+ : locale === 'nl'
175
+ ? 'Afspraak bevestigd'
176
+ : 'Appointment confirmed',
177
+ time: new Intl.DateTimeFormat(locale, {
178
+ dateStyle: 'full',
179
+ timeStyle: 'short',
180
+ timeZone: a.snapshot.customerTimezone
181
+ }).format(a.start_at),
182
+ timezone: a.snapshot.customerTimezone,
183
+ meetingUrl: a.meeting_url || appointmentUrl,
184
+ meetingDetails,
185
+ instructions: order.lines[0]!.snapshot.product.nextSteps[locale] || '',
186
+ url: appointmentUrl
187
+ }
188
+ const invitation = calendarInvitation({
189
+ id,
190
+ revision,
191
+ title: a.snapshot.title,
192
+ start: a.start_at,
193
+ end: a.end_at,
194
+ organizer: a.snapshot.organizerEmail || provider?.email || '',
195
+ attendee: order.email,
196
+ url: a.meeting_url,
197
+ cancelled: a.status === 'cancelled'
198
+ })
199
+ if (notificationKey || a.snapshot.customerNotificationRevision !== revision) {
200
+ try {
201
+ // Stable organizer, attendee and UID prevent duplicate customer appointments.
202
+ await sendPortalEmail({
203
+ moduleId: 'planning',
204
+ definition:
205
+ a.status === 'cancelled'
206
+ ? canceledAppointmentEmail
207
+ : revision === 1
208
+ ? newAppointmentEmail
209
+ : updatedAppointmentEmail,
210
+ locale,
211
+ to: order.email,
212
+ values: emailValues,
213
+ attachments: [
214
+ {
215
+ filename: 'appointment.ics',
216
+ content: Buffer.from(invitation),
217
+ contentType: `text/calendar; method=${a.status === 'cancelled' ? 'CANCEL' : 'REQUEST'}; charset=UTF-8`
218
+ }
219
+ ],
220
+ idempotencyKey: notificationKey || `appointment:${id}:${revision}`,
221
+ subjectPrefix: order.snapshot.storeMode === 'sandbox' ? '[TEST] ' : undefined
222
+ })
223
+ } catch (error) {
224
+ const message = error instanceof Error ? error.message.toLowerCase() : ''
225
+ if (!message.includes('idempotency key has been used') || !message.includes('request body was modified')) {
226
+ throw error
227
+ }
228
+ }
229
+ a.snapshot.customerNotificationRevision = revision
230
+ await rows(
231
+ `UPDATE planning.appointment
232
+ SET snapshot=jsonb_set(snapshot,'{customerNotificationRevision}',to_jsonb($2::int))
233
+ WHERE id=$1`,
234
+ [id, revision]
235
+ )
236
+ }
237
+ if (a.status === 'confirmed' && a.snapshot.meetingProvider === 'zoom') {
238
+ if (!a.meeting_url) {
239
+ a.snapshot.zoomLinkNotificationPending = true
240
+ await rows(
241
+ `UPDATE planning.appointment
242
+ SET snapshot=jsonb_set(snapshot,'{zoomLinkNotificationPending}','true'::jsonb)
243
+ WHERE id=$1`,
244
+ [id]
245
+ )
246
+ } else if (a.snapshot.zoomLinkNotificationPending) {
247
+ await sendPortalEmail({
248
+ moduleId: 'planning',
249
+ definition: zoomLinkReadyEmail,
250
+ locale,
251
+ to: order.email,
252
+ values: emailValues,
253
+ idempotencyKey: `appointment-zoom-ready:${id}:${revision}`,
254
+ subjectPrefix: order.snapshot.storeMode === 'sandbox' ? '[TEST] ' : undefined
255
+ })
256
+ await rows("UPDATE planning.appointment SET snapshot=snapshot-'zoomLinkNotificationPending' WHERE id=$1", [id])
257
+ }
258
+ }
259
+ if (effectsFailure) {
260
+ throw effectsFailure
261
+ }
262
+ await rows('UPDATE planning.appointment SET effects_error=NULL WHERE id=$1', [id])
263
+ }
264
+ async function availabilityEffects(id: string) {
265
+ const [window] = await rows<{
266
+ store_id: string
267
+ user_id: string
268
+ data: AvailabilityWindow
269
+ revision: number
270
+ deleted: boolean
271
+ calendar_event_id: string | null
272
+ calendar_id: string | null
273
+ }>('SELECT * FROM planning.availability WHERE id=$1', [id])
274
+ if (!window) {
275
+ return
276
+ }
277
+ const [store] = await rows<{ mode: string }>('SELECT mode FROM products.store WHERE organization_id=$1', [
278
+ window.store_id
279
+ ])
280
+ if (store?.mode === 'sandbox' && !developmentSandboxEffectsEnabled()) {
281
+ return
282
+ }
283
+ const eventId = window.calendar_event_id || `a${id.replace(/-/g, '')}`
284
+ const [provider] = await rows<{ write_calendar_id: string; availability_calendar_title: string }>(
285
+ 'SELECT write_calendar_id,availability_calendar_title FROM planning.provider WHERE store_id=$1 AND user_id=$2',
286
+ [window.store_id, window.user_id]
287
+ )
288
+ if (window.deleted) {
289
+ if (window.calendar_id) {
290
+ await calendarAdapter().remove(window.store_id, window.user_id, window.calendar_id, eventId)
291
+ }
292
+ return
293
+ }
294
+ if (!provider?.write_calendar_id) {
295
+ throw new Error('Select a writable calendar')
296
+ }
297
+ if (window.calendar_id && window.calendar_id !== provider.write_calendar_id) {
298
+ await calendarAdapter().remove(window.store_id, window.user_id, window.calendar_id, eventId)
299
+ }
300
+ const w = window.data,
301
+ recurrence: string[] = []
302
+ const products = await rows<{ title: string }>(
303
+ `SELECT COALESCE(NULLIF(data->'content'->'en'->>'title',''),data->'content'->'nl'->>'title',id) AS title
304
+ FROM products.product
305
+ WHERE store_id=$1
306
+ AND data->>'status'<>'archived'
307
+ AND ($3::boolean OR id=ANY($4::text[]))
308
+ AND ($3::boolean IS FALSE OR (data->'planning'->>'enabled'='true' AND data->'planning'->'providerUserIds' ? $2))
309
+ ORDER BY title`,
310
+ [window.store_id, window.user_id, w.productIds === null, w.productIds || []]
311
+ )
312
+ const productDescription = products.length
313
+ ? `Products:\n${products.map((product) => `• ${product.title}`).join('\n')}`
314
+ : 'Products: none'
315
+ if (w.recurring) {
316
+ // UNTIL uses the final occurrence’s actual instant in the series timezone.
317
+ recurrence.push(
318
+ `RRULE:FREQ=WEEKLY${
319
+ w.endDate
320
+ ? `;UNTIL=${wallInstant(w.endDate, w.startTime, w.timezone)
321
+ .toISOString()
322
+ .replace(/[-:]/g, '')
323
+ .replace(/\.\d{3}/, '')}`
324
+ : ''
325
+ }`
326
+ )
327
+ if (w.exceptions.length) {
328
+ recurrence.push(
329
+ `EXDATE;TZID=${w.timezone}:${w.exceptions.map((d) => `${d.replace(/-/g, '')}T${w.startTime.replace(':', '')}00`).join(',')}`
330
+ )
331
+ }
332
+ }
333
+ const written = await calendarAdapter().put(window.store_id, window.user_id, provider.write_calendar_id, {
334
+ id: eventId,
335
+ summary: provider.availability_calendar_title,
336
+ description: productDescription,
337
+ start: { dateTime: calendarWallDateTime(w.date, w.startTime), timeZone: w.timezone },
338
+ end: { dateTime: calendarWallDateTime(w.date, w.endTime), timeZone: w.timezone },
339
+ transparency: 'transparent',
340
+ extendedProperties: { private: { portalPlanning: id, portalRevision: String(window.revision) } },
341
+ ...(recurrence.length ? { recurrence } : {})
342
+ })
343
+ if (
344
+ written?.externalChangeKey ||
345
+ (written?.wasMissing && window.calendar_event_id && window.calendar_id === provider.write_calendar_id)
346
+ ) {
347
+ await enqueue(
348
+ pool,
349
+ `mirror-notice:${id}:${window.revision}:${written.externalChangeKey || 'missing'}`,
350
+ 'mirror-notice',
351
+ { userId: window.user_id }
352
+ )
353
+ }
354
+ await rows('UPDATE planning.availability SET calendar_event_id=$2,calendar_id=$3 WHERE id=$1', [
355
+ id,
356
+ eventId,
357
+ provider.write_calendar_id
358
+ ])
359
+ }
360
+ async function refund(orderId: string, amount: number, key: string) {
361
+ const order = await getOrder(orderId)
362
+ if (!order || !order.payment_id || !amount) {
363
+ return
364
+ }
365
+ if (order.snapshot.storeMode === 'sandbox') {
366
+ await rows('UPDATE products.orders SET refunded=GREATEST(refunded,$2) WHERE id=$1', [orderId, amount])
367
+ await rows('UPDATE products.order_line SET refunded=GREATEST(refunded,$2) WHERE order_id=$1', [orderId, amount])
368
+ } else {
369
+ // Stripe's idempotency key prevents repeat refunds after task retries.
370
+ await stripeProvider.refund(order.payment_id, amount, key)
371
+ const state = await stripeProvider.lookupPayment(order.payment_id)
372
+ await rows('UPDATE products.orders SET refunded=$2 WHERE id=$1', [orderId, state.refunded])
373
+ await rows('UPDATE products.order_line SET refunded=$2 WHERE order_id=$1', [orderId, state.refunded])
374
+ }
375
+ await processOrder(orderId)
376
+ }
377
+ export async function runJobs(limit = 20, storeId?: string, jobId?: string) {
378
+ const pending = await rows<{ id: string; kind: string; payload: Record<string, unknown>; attempts: number }>(
379
+ `WITH scoped AS (
380
+ SELECT j.*,
381
+ COALESCE(
382
+ j.payload->>'storeId',
383
+ (SELECT a.store_id FROM planning.appointment a WHERE a.id::text=j.payload->>'appointmentId'),
384
+ (SELECT w.store_id FROM planning.availability w WHERE w.id::text=j.payload->>'id'),
385
+ (SELECT o.store_id FROM products.orders o WHERE o.id::text=j.payload->>'orderId')
386
+ ) AS store_id
387
+ FROM planning.job j
388
+ WHERE j.completed_at IS NULL AND j.available_at<=now()
389
+ )
390
+ SELECT * FROM scoped WHERE ($2::text IS NULL OR store_id=$2) AND ($3::text IS NULL OR id=$3) ORDER BY available_at LIMIT $1`,
391
+ [limit, storeId, jobId]
392
+ )
393
+ let completed = 0,
394
+ failed = 0
395
+ for (const job of pending) {
396
+ const client = await pool.connect(),
397
+ key = `planning-job:${job.id}`
398
+ let acquired = false,
399
+ effectsKey: string | undefined
400
+ try {
401
+ acquired = (await client.query<{ locked: boolean }>('SELECT pg_try_advisory_lock(hashtext($1)) AS locked', [key]))
402
+ .rows[0]!.locked
403
+ if (!acquired) {
404
+ continue
405
+ }
406
+ const [current] = await rows<{ completed_at: Date | null }>(
407
+ 'SELECT completed_at FROM planning.job WHERE id=$1',
408
+ [job.id],
409
+ client
410
+ )
411
+ if (current?.completed_at) {
412
+ continue
413
+ }
414
+ const p = job.payload
415
+ if (job.kind === 'appointment' || job.kind === 'repair') {
416
+ effectsKey = `planning-effects:${p.appointmentId}`
417
+ await client.query('SELECT pg_advisory_lock(hashtext($1))', [effectsKey])
418
+ const repairNotification = job.kind === 'repair' && p.notifyCustomer === true
419
+ const [pendingAppointment] =
420
+ job.kind === 'repair'
421
+ ? await rows<{ id: string }>(
422
+ `SELECT id FROM planning.job WHERE kind='appointment' AND completed_at IS NULL AND payload->>'appointmentId'=$1 AND payload->>'revision'=$2 LIMIT 1`,
423
+ [String(p.appointmentId), String(p.revision)],
424
+ client
425
+ )
426
+ : []
427
+ if (!pendingAppointment) {
428
+ await appointmentEffects(
429
+ String(p.appointmentId),
430
+ Number(p.revision),
431
+ job.kind === 'appointment' || repairNotification,
432
+ repairNotification ? job.id : undefined,
433
+ job.kind === 'appointment'
434
+ )
435
+ }
436
+ } else if (job.kind === 'availability') {
437
+ await availabilityEffects(String(p.id))
438
+ } else if (job.kind === 'refund') {
439
+ await refund(String(p.orderId), Number(p.amount), job.id)
440
+ } else if (job.kind === 'cleanup') {
441
+ if (p.calendarId && p.eventId) {
442
+ const [adopted] = await rows<{ id: string }>(
443
+ "SELECT id FROM planning.appointment WHERE calendar_id=$1 AND calendar_event_id=$2 AND status='confirmed'",
444
+ [p.calendarId, p.eventId]
445
+ )
446
+ if (!adopted) {
447
+ await calendarAdapter().remove(String(p.storeId), String(p.userId), String(p.calendarId), String(p.eventId))
448
+ }
449
+ }
450
+ if (p.meetingId) {
451
+ await meetingAdapter().remove(String(p.storeId), String(p.userId), String(p.meetingId))
452
+ }
453
+ } else if (job.kind === 'close-checkout') {
454
+ const order = await getOrder(String(p.orderId))
455
+ if (order?.checkout_id && !order.checkout_id.startsWith('sandbox:')) {
456
+ await stripeProvider.expireCheckout(order.checkout_id)
457
+ await reconcileCheckout(order.checkout_id)
458
+ }
459
+ } else if (job.kind === 'sync') {
460
+ await syncProvider(String(p.storeId), String(p.userId), String(p.trigger || 'reconciliation'))
461
+ } else if (job.kind === 'failed-booking') {
462
+ const order = await getOrder(String(p.orderId))
463
+ if (order && (order.snapshot.storeMode !== 'sandbox' || developmentSandboxEffectsEnabled())) {
464
+ await sendPortalEmail({
465
+ moduleId: 'planning',
466
+ definition: appointmentEmail,
467
+ locale: order.snapshot.locale,
468
+ to: order.email,
469
+ values: {
470
+ recipient_name: emailRecipientName({
471
+ firstName: order.snapshot.billing.firstName,
472
+ displayName: order.snapshot.billing.name,
473
+ email: order.email
474
+ }),
475
+ product: order.lines[0]!.snapshot.title,
476
+ status:
477
+ order.snapshot.locale === 'nl'
478
+ ? 'Afspraak kon niet worden bevestigd. Een eventuele betaling wordt terugbetaald.'
479
+ : 'The appointment could not be confirmed. Any payment will be refunded.',
480
+ time: '',
481
+ timezone: '',
482
+ meetingUrl: `${baseUrl()}/appointments`,
483
+ meetingDetails: '',
484
+ instructions: '',
485
+ url: `${baseUrl()}/appointments`
486
+ },
487
+ idempotencyKey: job.id
488
+ })
489
+ }
490
+ } else if (job.kind === 'conflict' || job.kind === 'mirror-notice') {
491
+ const [provider] = await rows<{ email: string; first_name: string | null; name: string | null }>(
492
+ 'SELECT email,first_name,name FROM public."user" WHERE id=$1',
493
+ [p.userId]
494
+ )
495
+ if (provider) {
496
+ await sendPortalEmail({
497
+ moduleId: 'planning',
498
+ definition: appointmentEmail,
499
+ locale: 'en',
500
+ to: provider.email,
501
+ values: {
502
+ recipient_name: emailRecipientName({
503
+ firstName: provider.first_name,
504
+ displayName: provider.name,
505
+ email: provider.email
506
+ }),
507
+ product: job.kind === 'conflict' ? 'Calendar conflict' : 'Calendar synchronization',
508
+ status:
509
+ job.kind === 'conflict'
510
+ ? 'An external appointment overlaps a confirmed portal appointment. Resolve the conflict in your calendar or contact the customer.'
511
+ : 'An externally changed or removed portal event was restored. Manage portal availability and appointments in the portal.',
512
+ time: '',
513
+ timezone: '',
514
+ meetingUrl: `${baseUrl()}/planning`,
515
+ meetingDetails: '',
516
+ instructions: '',
517
+ url: `${baseUrl()}/planning`
518
+ },
519
+ idempotencyKey: job.id
520
+ })
521
+ }
522
+ } else {
523
+ throw new Error('Unknown planning job')
524
+ }
525
+ await rows(
526
+ 'UPDATE planning.job SET completed_at=now(),last_attempt_at=now(),error=NULL WHERE id=$1',
527
+ [job.id],
528
+ client
529
+ )
530
+ completed++
531
+ } catch (error) {
532
+ failed++
533
+ const message = error instanceof Error ? error.message : 'Planning task failed'
534
+ const now = new Date()
535
+ const retrySeconds = message.includes('zoom API failed (429)')
536
+ ? Math.max(
537
+ 60,
538
+ Math.ceil(
539
+ (Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1) - now.getTime()) / 1000
540
+ ) + 60
541
+ )
542
+ : Math.min(3600, 30 * 2 ** Math.min(job.attempts, 7))
543
+ await rows(
544
+ "UPDATE planning.job SET attempts=attempts+1,last_attempt_at=now(),error=$2,available_at=now()+($3::int*interval '1 second') WHERE id=$1",
545
+ [job.id, message, retrySeconds],
546
+ client
547
+ )
548
+ if (job.kind === 'appointment') {
549
+ await rows(
550
+ 'UPDATE planning.appointment SET effects_error=$2 WHERE id=$1',
551
+ [job.payload.appointmentId, message],
552
+ client
553
+ )
554
+ }
555
+ } finally {
556
+ if (effectsKey) {
557
+ await client.query('SELECT pg_advisory_unlock(hashtext($1))', [effectsKey]).catch(() => undefined)
558
+ }
559
+ if (acquired) {
560
+ await client.query('SELECT pg_advisory_unlock(hashtext($1))', [key]).catch(() => undefined)
561
+ }
562
+ client.release()
563
+ }
564
+ }
565
+ return { completed, failed }
566
+ }
567
+ export async function expireReservations() {
568
+ const overdue = await rows<Reservation>(
569
+ "SELECT * FROM planning.reservation WHERE status='reserved' AND expires_at<=now() ORDER BY expires_at LIMIT 50"
570
+ )
571
+ for (const hold of overdue) {
572
+ if (hold.order_id) {
573
+ const order = await getOrder(hold.order_id)
574
+ if (order?.checkout_id && !order.checkout_id.startsWith('sandbox:')) {
575
+ // Close first, then reconcile again: payment may complete while closing checkout.
576
+ await stripeProvider.expireCheckout(order.checkout_id)
577
+ await reconcileCheckout(order.checkout_id)
578
+ }
579
+ const current = await getOrder(hold.order_id)
580
+ if (current?.status === 'paid') {
581
+ await processOrder(current.id)
582
+ continue
583
+ }
584
+ }
585
+ await transaction(async (tx) => {
586
+ await lockProvider(tx, hold.user_id)
587
+ await tx.query("UPDATE planning.reservation SET status='expired' WHERE id=$1 AND status='reserved'", [hold.id])
588
+ if (hold.order_id) {
589
+ await tx.query("UPDATE products.orders SET status='expired' WHERE id=$1 AND status='pending'", [hold.order_id])
590
+ }
591
+ })
592
+ }
593
+ await rows('DELETE FROM planning.oauth_state WHERE expires_at<now()')
594
+ }
595
+ export async function syncProvider(storeId: string, userId: string, trigger = 'reconciliation') {
596
+ const [p] = await rows<{ busy_calendar_ids: string[] }>(
597
+ 'SELECT busy_calendar_ids FROM planning.provider WHERE store_id=$1 AND user_id=$2',
598
+ [storeId, userId]
599
+ )
600
+ if (!p?.busy_calendar_ids.length) {
601
+ return
602
+ }
603
+ const from = new Date(),
604
+ to = new Date(Date.now() + 365 * 86400000)
605
+ const busy = await externalBusy(storeId, userId, p.busy_calendar_ids, from, to)
606
+ const appointments = await rows<Appointment>(
607
+ "SELECT * FROM planning.appointment WHERE store_id=$1 AND user_id=$2 AND status='confirmed' AND end_at>now()",
608
+ [storeId, userId]
609
+ )
610
+ for (const a of appointments) {
611
+ const conflict = busy.some(
612
+ (b) =>
613
+ a.start_at.getTime() < Date.parse(b.end) &&
614
+ a.end_at.getTime() + a.snapshot.graceMinutes * 60000 > Date.parse(b.start)
615
+ )
616
+ if (conflict !== a.conflict) {
617
+ await transaction(async (tx) => {
618
+ await tx.query('UPDATE planning.appointment SET conflict=$2 WHERE id=$1', [a.id, conflict])
619
+ if (conflict) {
620
+ await enqueue(tx, `conflict:${a.id}:${a.revision}:${randomUUID()}`, 'conflict', {
621
+ userId,
622
+ appointmentId: a.id
623
+ })
624
+ }
625
+ })
626
+ }
627
+ }
628
+ const repairBucket = Math.floor(Date.now() / (trigger === 'notification' ? 300000 : 86400000))
629
+ for (const a of appointments) {
630
+ await enqueue(pool, `repair:${a.id}:${a.revision}:${repairBucket}`, 'repair', {
631
+ appointmentId: a.id,
632
+ revision: a.revision,
633
+ trigger
634
+ })
635
+ }
636
+ const windows = await rows<{ id: string }>(
637
+ 'SELECT id FROM planning.availability WHERE store_id=$1 AND user_id=$2 AND NOT deleted',
638
+ [storeId, userId]
639
+ )
640
+ for (const w of windows) {
641
+ await enqueue(pool, `availability-repair:${w.id}:${repairBucket}`, 'availability', {
642
+ id: w.id,
643
+ trigger
644
+ })
645
+ }
646
+ if (!baseUrl().startsWith('https://')) {
647
+ return
648
+ }
649
+ for (const calendarId of p.busy_calendar_ids) {
650
+ const [live] = await rows<{ id: string }>(
651
+ "SELECT id FROM planning.watch WHERE store_id=$1 AND user_id=$2 AND calendar_id=$3 AND expires_at>now()+interval '1 hour'",
652
+ [storeId, userId, calendarId]
653
+ )
654
+ if (live) {
655
+ continue
656
+ }
657
+ const id = randomUUID(),
658
+ token = secret()
659
+ // Persist before creating the channel because Google can send its initial notification immediately.
660
+ await rows(
661
+ "INSERT INTO planning.watch(id,store_id,user_id,calendar_id,token_hash,expires_at) VALUES($1,$2,$3,$4,$5,now()+interval '1 hour')",
662
+ [id, storeId, userId, calendarId, digest(token)]
663
+ )
664
+ const watch = await calendarAdapter().watch(storeId, userId, calendarId, id, token)
665
+ await rows('UPDATE planning.watch SET resource_id=$2,expires_at=$3 WHERE id=$1', [
666
+ id,
667
+ watch.resourceId,
668
+ new Date(Number(watch.expiration))
669
+ ])
670
+ }
671
+ }