@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.
- package/CHANGELOG.md +61 -0
- package/LICENSE +21 -0
- package/README.md +115 -0
- package/app/components/PlanningAppointmentCreateForm.vue +49 -0
- package/app/components/PlanningAppointmentDetails.vue +163 -0
- package/app/components/PlanningAppointmentDrawer.vue +109 -0
- package/app/components/PlanningAvailabilityCalendar.vue +525 -0
- package/app/components/PlanningDashboardOverview.vue +62 -0
- package/app/components/PlanningDashboardUpcomingAppointments.vue +81 -0
- package/app/components/PlanningPersonalCalendarSettings.vue +218 -0
- package/app/components/PlanningPersonalConnection.vue +68 -0
- package/app/components/PlanningPersonalMeetingSettings.vue +1 -0
- package/app/components/PlanningPolicyFields.vue +196 -0
- package/app/components/PlanningSettingsMembers.vue +50 -0
- package/app/components/PlanningSettingsPolicy.vue +46 -0
- package/app/components/PlanningSettingsSynchronization.vue +433 -0
- package/app/components/PlanningSlotPicker.vue +588 -0
- package/app/components/PlanningTimezoneReadOnlyHover.vue +172 -0
- package/app/components/PlanningTimezoneSelect.vue +101 -0
- package/app/composables/usePlanning.ts +180 -0
- package/app/composables/usePlanningDashboard.ts +4 -0
- package/app/composables/usePlanningTimeDisplay.ts +15 -0
- package/app/pages/admin/planning.vue +36 -0
- package/app/pages/appointments/[id].vue +13 -0
- package/app/pages/appointments/index.vue +384 -0
- package/app/pages/availability.vue +585 -0
- package/app/pages/booking/[slug].vue +298 -0
- package/app/pages/my-appointment-settings.vue +33 -0
- package/app/plugins/planning.ts +5 -0
- package/i18n/locales/en.json +260 -0
- package/i18n/locales/nl.json +260 -0
- package/migrations/0000_baseline.sql +58 -0
- package/migrations/0001_availability_calendar_title.sql +2 -0
- package/migrations/0002_job_activity.sql +11 -0
- package/migrations/0003_deduplicate_availability_jobs.sql +19 -0
- package/migrations/0004_connection_label.sql +2 -0
- package/nuxt.config.ts +15 -0
- package/package.json +55 -0
- package/portal.manifest.mjs +8 -0
- package/server/api/planning/admin/appointments/[id]/audit.get.ts +11 -0
- package/server/api/planning/admin/appointments.get.ts +11 -0
- package/server/api/planning/admin/jobs/[id]/retry.post.ts +26 -0
- package/server/api/planning/admin/jobs.get.ts +199 -0
- package/server/api/planning/admin/policy.get.ts +6 -0
- package/server/api/planning/admin/policy.put.ts +8 -0
- package/server/api/planning/admin/products/[id].put.ts +8 -0
- package/server/api/planning/admin/providers/[userId].patch.ts +8 -0
- package/server/api/planning/admin/providers.get.ts +6 -0
- package/server/api/planning/admin/retry.post.ts +31 -0
- package/server/api/planning/appointments/[id]/availability.get.ts +19 -0
- package/server/api/planning/appointments/[id]/cancel.post.ts +8 -0
- package/server/api/planning/appointments/[id]/files/[assetId].get.ts +17 -0
- package/server/api/planning/appointments/[id]/files.get.ts +28 -0
- package/server/api/planning/appointments/[id]/pending.delete.ts +8 -0
- package/server/api/planning/appointments/[id]/reschedule.put.ts +8 -0
- package/server/api/planning/appointments/[id].get.ts +6 -0
- package/server/api/planning/appointments.get.ts +4 -0
- package/server/api/planning/availability/[id].delete.ts +12 -0
- package/server/api/planning/availability/[id].put.ts +8 -0
- package/server/api/planning/availability.get.ts +6 -0
- package/server/api/planning/availability.post.ts +8 -0
- package/server/api/planning/calendar-busy.get.ts +29 -0
- package/server/api/planning/calendars.get.ts +8 -0
- package/server/api/planning/dashboard.get.ts +36 -0
- package/server/api/planning/google/notifications.post.ts +24 -0
- package/server/api/planning/oauth/[provider]/callback.get.ts +18 -0
- package/server/api/planning/oauth/[provider]/start.post.ts +8 -0
- package/server/api/planning/oauth/[provider].delete.ts +15 -0
- package/server/api/planning/products.get.ts +11 -0
- package/server/api/planning/provider-appointments.get.ts +11 -0
- package/server/api/planning/provider.get.ts +6 -0
- package/server/api/planning/provider.put.ts +8 -0
- package/server/api/store/planning/[productId]/availability.get.ts +8 -0
- package/server/api/store/planning/hold.delete.ts +11 -0
- package/server/api/store/planning/hold.get.ts +36 -0
- package/server/api/store/planning/holds.post.ts +10 -0
- package/server/db/schema/planning.ts +213 -0
- package/server/plugins/openapi.ts +36 -0
- package/server/plugins/planning.ts +6 -0
- package/server/tasks/planning/reconcile.ts +30 -0
- package/server/utils/access.ts +80 -0
- package/server/utils/adapters.ts +468 -0
- package/server/utils/appointments.ts +37 -0
- package/server/utils/booking.ts +619 -0
- package/server/utils/crypto.ts +23 -0
- package/server/utils/jobs.ts +671 -0
- package/server/utils/management.ts +427 -0
- package/server/utils/oauth.ts +104 -0
- package/server/utils/staff-appointments.ts +158 -0
- package/shared/appointment-calendar.ts +82 -0
- package/shared/availability.ts +140 -0
- package/shared/emails.ts +99 -0
- package/shared/feature.ts +78 -0
- package/shared/invitation.ts +58 -0
- package/shared/types.ts +110 -0
- 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
|
+
}
|