@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,619 @@
|
|
|
1
|
+
import { parseInput } from '@nuxt-customer-portal/products/server/utils/validation'
|
|
2
|
+
import { randomUUID } from 'node:crypto'
|
|
3
|
+
import { createError, getCookie, setCookie, type H3Event } from 'h3'
|
|
4
|
+
import type { PoolClient } from 'pg'
|
|
5
|
+
import type { z } from 'zod'
|
|
6
|
+
import { rows, transaction } from '@nuxt-customer-portal/products/server/utils/database'
|
|
7
|
+
import { getStore } from '@nuxt-customer-portal/products/server/utils/access'
|
|
8
|
+
import { developmentSandboxEffectsEnabled } from '@nuxt-customer-portal/products/server/utils/development'
|
|
9
|
+
import { getProduct, selectCopy } from '@nuxt-customer-portal/products/server/utils/catalog'
|
|
10
|
+
import { defaultPlanningPolicy, effectivePolicy } from '@nuxt-customer-portal/products/shared/planning'
|
|
11
|
+
import type { Order } from '@nuxt-customer-portal/products/shared/types'
|
|
12
|
+
import type { checkoutSchema } from '@nuxt-customer-portal/products/shared/validation'
|
|
13
|
+
import { availabilityQuerySchema, holdSchema, holdCredentialSchema } from '../../shared/validation'
|
|
14
|
+
import { generateSlots, canChange, changeFee } from '../../shared/availability'
|
|
15
|
+
import type {
|
|
16
|
+
Appointment,
|
|
17
|
+
Interval,
|
|
18
|
+
AvailabilityWindow,
|
|
19
|
+
Reservation,
|
|
20
|
+
PlanningPolicy,
|
|
21
|
+
AppointmentSnapshot
|
|
22
|
+
} from '../../shared/types'
|
|
23
|
+
import { calendarAdapter } from './adapters'
|
|
24
|
+
import { digest, secret } from './crypto'
|
|
25
|
+
import { customerAppointment } from './access'
|
|
26
|
+
|
|
27
|
+
const minute = 60000
|
|
28
|
+
export const lockProvider = async (tx: PoolClient, userId: string) => {
|
|
29
|
+
await tx.query('SELECT pg_advisory_xact_lock(hashtext($1))', [`planning:${userId}`])
|
|
30
|
+
}
|
|
31
|
+
export async function enqueue(
|
|
32
|
+
tx: Pick<PoolClient, 'query'>,
|
|
33
|
+
id: string,
|
|
34
|
+
kind: string,
|
|
35
|
+
payload: Record<string, unknown>
|
|
36
|
+
) {
|
|
37
|
+
await tx.query('INSERT INTO planning.job(id,kind,payload) VALUES($1,$2,$3) ON CONFLICT DO NOTHING', [
|
|
38
|
+
id,
|
|
39
|
+
kind,
|
|
40
|
+
payload
|
|
41
|
+
])
|
|
42
|
+
}
|
|
43
|
+
export async function policy(storeId: string) {
|
|
44
|
+
const [row] = await rows<{ policy: PlanningPolicy }>('SELECT policy FROM planning.settings WHERE store_id=$1', [
|
|
45
|
+
storeId
|
|
46
|
+
])
|
|
47
|
+
return effectivePolicy(defaultPlanningPolicy(), row?.policy || {})
|
|
48
|
+
}
|
|
49
|
+
export async function bookingBusy(
|
|
50
|
+
storeId: string,
|
|
51
|
+
userId: string,
|
|
52
|
+
calendarIds: string[],
|
|
53
|
+
from: Date,
|
|
54
|
+
to: Date
|
|
55
|
+
): Promise<Interval[]> {
|
|
56
|
+
if (!calendarIds.length) {
|
|
57
|
+
return []
|
|
58
|
+
}
|
|
59
|
+
return calendarAdapter().busy(storeId, userId, calendarIds, from, to)
|
|
60
|
+
}
|
|
61
|
+
export async function providers(storeId: string, productId: string) {
|
|
62
|
+
const product = await getProduct(storeId, productId)
|
|
63
|
+
if (!product.planning?.enabled || product.status !== 'published') {
|
|
64
|
+
throw createError({ statusCode: 404, message: 'This product is not available for booking' })
|
|
65
|
+
}
|
|
66
|
+
const sandbox = (await getStore()).mode === 'sandbox' && !developmentSandboxEffectsEnabled()
|
|
67
|
+
const users = await rows<{
|
|
68
|
+
user_id: string
|
|
69
|
+
name: string
|
|
70
|
+
email: string
|
|
71
|
+
timezone: string
|
|
72
|
+
grace_minutes: number
|
|
73
|
+
busy_calendar_ids: string[]
|
|
74
|
+
write_calendar_id: string
|
|
75
|
+
}>(
|
|
76
|
+
`SELECT DISTINCT p.*,u.name,u.email FROM planning.provider p JOIN public.member m ON m.organization_id=p.store_id AND m.user_id=p.user_id JOIN public."user" u ON u.id=p.user_id WHERE p.store_id=$1 AND p.enabled AND p.user_id=ANY($2::text[]) AND ($4::boolean OR (p.write_calendar_id IS NOT NULL AND cardinality(p.busy_calendar_ids)>0 AND EXISTS(SELECT 1 FROM planning.connection c WHERE c.store_id=p.store_id AND c.user_id=p.user_id AND c.provider='google' AND c.healthy) AND ($3='none' OR EXISTS(SELECT 1 FROM planning.connection z WHERE z.store_id=p.store_id AND z.user_id=p.user_id AND z.provider='zoom' AND z.healthy))))`,
|
|
77
|
+
[storeId, product.planning.providerUserIds, product.planning.meetingProvider, sandbox]
|
|
78
|
+
)
|
|
79
|
+
return { product, users }
|
|
80
|
+
}
|
|
81
|
+
export async function available(
|
|
82
|
+
productId: string,
|
|
83
|
+
input: unknown,
|
|
84
|
+
options: { tx?: PoolClient; policy?: PlanningPolicy; duration?: number; grace?: number } = {}
|
|
85
|
+
) {
|
|
86
|
+
const q = parseInput(availabilityQuerySchema, input),
|
|
87
|
+
store = await getStore(true),
|
|
88
|
+
{ product, users } = await providers(store.organization_id, productId)
|
|
89
|
+
const rules =
|
|
90
|
+
options.policy || effectivePolicy(await policy(store.organization_id), product.planning!.policyOverrides)
|
|
91
|
+
const slots = []
|
|
92
|
+
for (const u of users.filter((u) => !q.providerUserId || q.providerUserId === u.user_id)) {
|
|
93
|
+
const windowRows = await rows<{ id: string; data: AvailabilityWindow }>(
|
|
94
|
+
'SELECT id,data FROM planning.availability WHERE store_id=$1 AND user_id=$2 AND NOT deleted',
|
|
95
|
+
[store.organization_id, u.user_id],
|
|
96
|
+
options.tx
|
|
97
|
+
)
|
|
98
|
+
// Each provider failure closes their availability; do not offer stale slots.
|
|
99
|
+
let busy
|
|
100
|
+
try {
|
|
101
|
+
busy = await bookingBusy(
|
|
102
|
+
store.organization_id,
|
|
103
|
+
u.user_id,
|
|
104
|
+
u.busy_calendar_ids,
|
|
105
|
+
new Date(q.from),
|
|
106
|
+
new Date(Date.parse(q.to) + u.grace_minutes * minute)
|
|
107
|
+
)
|
|
108
|
+
} catch {
|
|
109
|
+
continue
|
|
110
|
+
}
|
|
111
|
+
const internal = await rows<{ start_at: Date; blocked_until: Date }>(
|
|
112
|
+
`SELECT start_at,blocked_until FROM planning.reservation WHERE user_id=$1 AND status IN ('reserved','confirmed') AND start_at<$3 AND blocked_until>$2`,
|
|
113
|
+
[u.user_id, q.from, q.to],
|
|
114
|
+
options.tx
|
|
115
|
+
)
|
|
116
|
+
slots.push(
|
|
117
|
+
...generateSlots({
|
|
118
|
+
from: new Date(q.from),
|
|
119
|
+
to: new Date(q.to),
|
|
120
|
+
now: new Date(),
|
|
121
|
+
durationMinutes: options.duration || product.planning!.durationMinutes!,
|
|
122
|
+
graceMinutes: options.grace ?? u.grace_minutes,
|
|
123
|
+
intervalMinutes: rules.slotIntervalMinutes,
|
|
124
|
+
displayIntervalMinutes: rules.displayIntervalMinutes ?? 30,
|
|
125
|
+
noticeMinutes: rules.minimumNoticeMinutes,
|
|
126
|
+
horizonDays: rules.bookingHorizonDays,
|
|
127
|
+
productId,
|
|
128
|
+
providerUserId: u.user_id,
|
|
129
|
+
providerName: u.name,
|
|
130
|
+
windows: windowRows.map((w) => ({ ...w.data, id: w.id })),
|
|
131
|
+
busy: [
|
|
132
|
+
...busy,
|
|
133
|
+
...internal.map((b) => ({ start: b.start_at.toISOString(), end: b.blocked_until.toISOString() }))
|
|
134
|
+
]
|
|
135
|
+
})
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
return slots.sort((a, b) => a.start.localeCompare(b.start) || a.providerUserId.localeCompare(b.providerUserId))
|
|
139
|
+
}
|
|
140
|
+
const sessions = new WeakMap<H3Event, string>()
|
|
141
|
+
function sessionKey(event: H3Event, create = false) {
|
|
142
|
+
let value = getCookie(event, 'planning-session') || sessions.get(event)
|
|
143
|
+
if (!value && create) {
|
|
144
|
+
value = secret()
|
|
145
|
+
sessions.set(event, value)
|
|
146
|
+
setCookie(event, 'planning-session', value, {
|
|
147
|
+
httpOnly: true,
|
|
148
|
+
secure: new URL(process.env.BETTER_AUTH_URL || process.env.PUBLIC_URL!).protocol === 'https:',
|
|
149
|
+
sameSite: 'lax',
|
|
150
|
+
path: '/',
|
|
151
|
+
maxAge: 86400
|
|
152
|
+
})
|
|
153
|
+
}
|
|
154
|
+
if (!value) {
|
|
155
|
+
throw createError({ statusCode: 403, message: 'Reservation session missing; choose a slot again' })
|
|
156
|
+
}
|
|
157
|
+
return value
|
|
158
|
+
}
|
|
159
|
+
export const holdHash = (event: H3Event, token: string) => digest(`${sessionKey(event)}:${token}`)
|
|
160
|
+
export async function releaseHold(event: H3Event, body: unknown) {
|
|
161
|
+
const input = parseInput(holdCredentialSchema, body)
|
|
162
|
+
await transaction(async (tx) => {
|
|
163
|
+
const [hold] = await rows<Reservation>(
|
|
164
|
+
'SELECT * FROM planning.reservation WHERE token_hash=$1 FOR UPDATE',
|
|
165
|
+
[holdHash(event, input.holdToken)],
|
|
166
|
+
tx
|
|
167
|
+
)
|
|
168
|
+
if (!hold || hold.status !== 'reserved') {
|
|
169
|
+
return
|
|
170
|
+
}
|
|
171
|
+
if (hold.order_id) {
|
|
172
|
+
throw createError({
|
|
173
|
+
statusCode: 409,
|
|
174
|
+
message: 'Checkout already started; finish or wait for this reservation to expire'
|
|
175
|
+
})
|
|
176
|
+
}
|
|
177
|
+
await tx.query("UPDATE planning.reservation SET status='expired' WHERE id=$1 AND status='reserved'", [hold.id])
|
|
178
|
+
})
|
|
179
|
+
}
|
|
180
|
+
export async function reserve(event: H3Event, body: unknown) {
|
|
181
|
+
const input = parseInput(holdSchema, body),
|
|
182
|
+
store = await getStore(true),
|
|
183
|
+
{ product, users } = await providers(store.organization_id, input.productId)
|
|
184
|
+
const provider = users.find((u) => u.user_id === input.providerUserId)
|
|
185
|
+
if (!provider) {
|
|
186
|
+
throw createError({ statusCode: 409, message: 'Provider is unavailable' })
|
|
187
|
+
}
|
|
188
|
+
const price = product.prices.find((p) => p.currency === input.currency)
|
|
189
|
+
if (!price) {
|
|
190
|
+
throw createError({ statusCode: 409, message: 'Product price unavailable' })
|
|
191
|
+
}
|
|
192
|
+
let rules = effectivePolicy(await policy(store.organization_id), product.planning!.policyOverrides),
|
|
193
|
+
original: Appointment | undefined
|
|
194
|
+
if (input.replacesId) {
|
|
195
|
+
original = (await customerAppointment(event, input.replacesId)).appointment
|
|
196
|
+
if (
|
|
197
|
+
original.status !== 'confirmed' ||
|
|
198
|
+
original.product_id !== product.id ||
|
|
199
|
+
original.snapshot.currency !== input.currency ||
|
|
200
|
+
!canChange(original.start_at, new Date(), original.snapshot.policy.rescheduleCutoffMinutes)
|
|
201
|
+
) {
|
|
202
|
+
throw createError({ statusCode: 409, message: 'This appointment cannot be changed' })
|
|
203
|
+
}
|
|
204
|
+
rules = original.snapshot.policy
|
|
205
|
+
}
|
|
206
|
+
const duration = original?.snapshot.durationMinutes || product.planning!.durationMinutes!,
|
|
207
|
+
grace = provider.grace_minutes
|
|
208
|
+
const snapshot: AppointmentSnapshot = {
|
|
209
|
+
organizerEmail: original?.snapshot.organizerEmail || provider.email,
|
|
210
|
+
title: original?.snapshot.title || selectCopy(product, input.locale, store.default_locale).title,
|
|
211
|
+
durationMinutes: duration,
|
|
212
|
+
graceMinutes: grace,
|
|
213
|
+
timezone: provider.timezone,
|
|
214
|
+
customerTimezone: input.customerTimezone,
|
|
215
|
+
policy: rules,
|
|
216
|
+
currency: input.currency,
|
|
217
|
+
unitAmount: price.amount,
|
|
218
|
+
meetingProvider: product.planning!.meetingProvider,
|
|
219
|
+
locale: input.locale
|
|
220
|
+
}
|
|
221
|
+
if (original) {
|
|
222
|
+
snapshot.unitAmount = changeFee(original.changes, rules.freeChanges, rules.changeFees, input.currency)
|
|
223
|
+
}
|
|
224
|
+
// External check precedes the short transaction; internal slots are validated again under lock.
|
|
225
|
+
const end = new Date(Date.parse(input.start) + duration * minute),
|
|
226
|
+
blockedUntil = new Date(end.getTime() + grace * minute)
|
|
227
|
+
const external = await bookingBusy(
|
|
228
|
+
store.organization_id,
|
|
229
|
+
provider.user_id,
|
|
230
|
+
provider.busy_calendar_ids,
|
|
231
|
+
new Date(input.start),
|
|
232
|
+
blockedUntil
|
|
233
|
+
)
|
|
234
|
+
if (
|
|
235
|
+
external.some((b) => Date.parse(input.start) < Date.parse(b.end) && blockedUntil.getTime() > Date.parse(b.start))
|
|
236
|
+
) {
|
|
237
|
+
throw createError({ statusCode: 409, message: 'This slot is no longer available' })
|
|
238
|
+
}
|
|
239
|
+
sessionKey(event, true)
|
|
240
|
+
const token = secret(),
|
|
241
|
+
id = randomUUID(),
|
|
242
|
+
expiresAt = new Date(Date.now() + rules.reservationMinutes * minute)
|
|
243
|
+
await transaction(async (tx) => {
|
|
244
|
+
if (input.previousHoldToken) {
|
|
245
|
+
const [previous] = await rows<Reservation>(
|
|
246
|
+
'SELECT * FROM planning.reservation WHERE token_hash=$1 FOR UPDATE',
|
|
247
|
+
[holdHash(event, input.previousHoldToken)],
|
|
248
|
+
tx
|
|
249
|
+
)
|
|
250
|
+
if (previous?.status === 'reserved') {
|
|
251
|
+
if (previous.product_id !== product.id || previous.order_id) {
|
|
252
|
+
throw createError({ statusCode: 409, message: 'Previous checkout cannot be replaced' })
|
|
253
|
+
}
|
|
254
|
+
await tx.query("UPDATE planning.reservation SET status='expired' WHERE id=$1", [previous.id])
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
await lockProvider(tx, provider.user_id)
|
|
258
|
+
const windows = await rows<{ id: string; data: AvailabilityWindow }>(
|
|
259
|
+
'SELECT id,data FROM planning.availability WHERE store_id=$1 AND user_id=$2 AND NOT deleted',
|
|
260
|
+
[store.organization_id, provider.user_id],
|
|
261
|
+
tx
|
|
262
|
+
)
|
|
263
|
+
const internal = await rows<{ start_at: Date; blocked_until: Date }>(
|
|
264
|
+
"SELECT start_at,blocked_until FROM planning.reservation WHERE user_id=$1 AND status IN ('reserved','confirmed') AND start_at<$3 AND blocked_until>$2",
|
|
265
|
+
[provider.user_id, input.start, blockedUntil],
|
|
266
|
+
tx
|
|
267
|
+
)
|
|
268
|
+
const valid = generateSlots({
|
|
269
|
+
from: new Date(input.start),
|
|
270
|
+
to: end,
|
|
271
|
+
now: new Date(),
|
|
272
|
+
durationMinutes: duration,
|
|
273
|
+
graceMinutes: grace,
|
|
274
|
+
intervalMinutes: rules.slotIntervalMinutes,
|
|
275
|
+
displayIntervalMinutes: rules.displayIntervalMinutes ?? 30,
|
|
276
|
+
noticeMinutes: rules.minimumNoticeMinutes,
|
|
277
|
+
horizonDays: rules.bookingHorizonDays,
|
|
278
|
+
productId: product.id,
|
|
279
|
+
providerUserId: provider.user_id,
|
|
280
|
+
providerName: provider.name,
|
|
281
|
+
windows: windows.map((w) => ({ ...w.data, id: w.id })),
|
|
282
|
+
busy: [
|
|
283
|
+
...external,
|
|
284
|
+
...internal.map((b) => ({ start: b.start_at.toISOString(), end: b.blocked_until.toISOString() }))
|
|
285
|
+
]
|
|
286
|
+
})
|
|
287
|
+
if (!valid.some((s) => Date.parse(s.start) === Date.parse(input.start))) {
|
|
288
|
+
throw createError({ statusCode: 409, message: 'This slot is no longer available' })
|
|
289
|
+
}
|
|
290
|
+
const [currentProduct] = await rows<{
|
|
291
|
+
data: { planning: { enabled: boolean; providerUserIds: string[]; durationMinutes: number } }
|
|
292
|
+
}>('SELECT data FROM products.product WHERE id=$1 FOR SHARE', [product.id], tx)
|
|
293
|
+
const [membership] = await rows<{ id: string }>(
|
|
294
|
+
'SELECT id FROM public.member WHERE organization_id=$1 AND user_id=$2 FOR SHARE',
|
|
295
|
+
[store.organization_id, provider.user_id],
|
|
296
|
+
tx
|
|
297
|
+
)
|
|
298
|
+
if (
|
|
299
|
+
!membership ||
|
|
300
|
+
!currentProduct?.data.planning?.enabled ||
|
|
301
|
+
!currentProduct.data.planning.providerUserIds.includes(provider.user_id)
|
|
302
|
+
) {
|
|
303
|
+
throw createError({ statusCode: 409, message: 'Provider assignment changed' })
|
|
304
|
+
}
|
|
305
|
+
const active = await providers(store.organization_id, product.id)
|
|
306
|
+
if (
|
|
307
|
+
!active.users.some((u) => u.user_id === provider.user_id) ||
|
|
308
|
+
active.product.planning!.durationMinutes !== product.planning!.durationMinutes
|
|
309
|
+
) {
|
|
310
|
+
throw createError({ statusCode: 409, message: 'Planning settings changed; refresh availability' })
|
|
311
|
+
}
|
|
312
|
+
if (original) {
|
|
313
|
+
await tx.query('SELECT id FROM planning.appointment WHERE id=$1 FOR UPDATE', [original.id])
|
|
314
|
+
const [current] = await rows<Appointment>('SELECT * FROM planning.appointment WHERE id=$1', [original.id], tx)
|
|
315
|
+
if (current!.status !== 'confirmed' || current!.revision !== original.revision) {
|
|
316
|
+
throw createError({ statusCode: 409, message: 'Appointment changed; refresh' })
|
|
317
|
+
}
|
|
318
|
+
const [pending] = await rows<{ id: string }>(
|
|
319
|
+
"SELECT id FROM planning.reservation WHERE replaces_id=$1 AND status='reserved'",
|
|
320
|
+
[original.id],
|
|
321
|
+
tx
|
|
322
|
+
)
|
|
323
|
+
if (pending) {
|
|
324
|
+
throw createError({ statusCode: 409, message: 'Finish or wait for your pending appointment change' })
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
await tx.query(
|
|
328
|
+
`INSERT INTO planning.reservation(id,store_id,user_id,product_id,token_hash,start_at,end_at,blocked_until,expires_at,status,replaces_id,snapshot) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,'reserved',$10,$11)`,
|
|
329
|
+
[
|
|
330
|
+
id,
|
|
331
|
+
store.organization_id,
|
|
332
|
+
provider.user_id,
|
|
333
|
+
product.id,
|
|
334
|
+
holdHash(event, token),
|
|
335
|
+
new Date(input.start),
|
|
336
|
+
end,
|
|
337
|
+
blockedUntil,
|
|
338
|
+
expiresAt,
|
|
339
|
+
original?.id || null,
|
|
340
|
+
snapshot
|
|
341
|
+
]
|
|
342
|
+
)
|
|
343
|
+
})
|
|
344
|
+
return {
|
|
345
|
+
holdToken: token,
|
|
346
|
+
expiresAt: expiresAt.toISOString(),
|
|
347
|
+
start: input.start,
|
|
348
|
+
end: end.toISOString(),
|
|
349
|
+
providerName: provider.name,
|
|
350
|
+
amount: snapshot.unitAmount,
|
|
351
|
+
currency: snapshot.currency
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
export async function prepareCheckout(event: H3Event, input: z.infer<typeof checkoutSchema>) {
|
|
355
|
+
if (!input.holdToken) {
|
|
356
|
+
throw createError({ statusCode: 409, message: 'Choose an appointment before checkout' })
|
|
357
|
+
}
|
|
358
|
+
const [hold] = await rows<Reservation>('SELECT * FROM planning.reservation WHERE token_hash=$1', [
|
|
359
|
+
holdHash(event, input.holdToken)
|
|
360
|
+
])
|
|
361
|
+
if (!hold || hold.product_id !== input.productId || hold.status !== 'reserved' || hold.expires_at <= new Date()) {
|
|
362
|
+
throw createError({ statusCode: 409, message: 'Reservation expired; choose another slot' })
|
|
363
|
+
}
|
|
364
|
+
if (hold.replaces_id) {
|
|
365
|
+
await customerAppointment(event, hold.replaces_id)
|
|
366
|
+
}
|
|
367
|
+
const { users } = await providers(hold.store_id, hold.product_id),
|
|
368
|
+
provider = users.find((u) => u.user_id === hold.user_id)
|
|
369
|
+
if (!provider) {
|
|
370
|
+
throw createError({ statusCode: 409, message: 'Provider unavailable' })
|
|
371
|
+
}
|
|
372
|
+
const busy = await bookingBusy(
|
|
373
|
+
hold.store_id,
|
|
374
|
+
hold.user_id,
|
|
375
|
+
provider.busy_calendar_ids,
|
|
376
|
+
hold.start_at,
|
|
377
|
+
hold.blocked_until
|
|
378
|
+
)
|
|
379
|
+
if (
|
|
380
|
+
busy.some((b) => hold.start_at.getTime() < Date.parse(b.end) && hold.blocked_until.getTime() > Date.parse(b.start))
|
|
381
|
+
) {
|
|
382
|
+
throw createError({ statusCode: 409, message: 'Calendar conflict; choose another slot' })
|
|
383
|
+
}
|
|
384
|
+
// Hash only travels inside this request and is never accepted from the public API.
|
|
385
|
+
prepared.set(event, { token: input.holdToken, hash: holdHash(event, input.holdToken) })
|
|
386
|
+
}
|
|
387
|
+
const prepared = new WeakMap<H3Event, { token: string; hash: string }>()
|
|
388
|
+
export async function bind(tx: PoolClient, order: Order, hash: string) {
|
|
389
|
+
const [hold] = await rows<Reservation>('SELECT * FROM planning.reservation WHERE token_hash=$1', [hash], tx)
|
|
390
|
+
if (
|
|
391
|
+
!hold ||
|
|
392
|
+
hold.status !== 'reserved' ||
|
|
393
|
+
hold.expires_at <= new Date() ||
|
|
394
|
+
hold.product_id !== order.lines[0]!.product_id ||
|
|
395
|
+
hold.snapshot.currency !== order.lines[0]!.snapshot.price.currency ||
|
|
396
|
+
(hold.order_id && hold.order_id !== order.id)
|
|
397
|
+
) {
|
|
398
|
+
throw createError({ statusCode: 409, message: 'Reservation is not valid for this checkout' })
|
|
399
|
+
}
|
|
400
|
+
await lockProvider(tx, hold.user_id)
|
|
401
|
+
const [current] = await rows<Reservation>('SELECT * FROM planning.reservation WHERE id=$1 FOR UPDATE', [hold.id], tx)
|
|
402
|
+
if (current!.status !== 'reserved' || current!.expires_at <= new Date()) {
|
|
403
|
+
throw createError({ statusCode: 409, message: 'Reservation expired' })
|
|
404
|
+
}
|
|
405
|
+
const [member] = await rows<{ id: string }>(
|
|
406
|
+
'SELECT m.id FROM public.member m JOIN planning.provider p ON p.store_id=m.organization_id AND p.user_id=m.user_id WHERE p.enabled AND m.organization_id=$1 AND m.user_id=$2',
|
|
407
|
+
[hold.store_id, hold.user_id],
|
|
408
|
+
tx
|
|
409
|
+
)
|
|
410
|
+
if (!member || !order.lines[0]!.snapshot.product.planning?.providerUserIds.includes(hold.user_id)) {
|
|
411
|
+
throw createError({ statusCode: 409, message: 'Provider assignment changed' })
|
|
412
|
+
}
|
|
413
|
+
const line = order.lines[0]!
|
|
414
|
+
if (
|
|
415
|
+
!hold.replaces_id &&
|
|
416
|
+
(hold.snapshot.durationMinutes !== line.snapshot.product.planning?.durationMinutes ||
|
|
417
|
+
hold.snapshot.unitAmount !== line.unit_amount)
|
|
418
|
+
) {
|
|
419
|
+
throw createError({ statusCode: 409, message: 'Product changed; choose a slot again' })
|
|
420
|
+
}
|
|
421
|
+
if (hold.replaces_id) {
|
|
422
|
+
const [original] = await rows<Appointment>(
|
|
423
|
+
'SELECT * FROM planning.appointment WHERE id=$1 FOR UPDATE',
|
|
424
|
+
[hold.replaces_id],
|
|
425
|
+
tx
|
|
426
|
+
)
|
|
427
|
+
if (
|
|
428
|
+
!original ||
|
|
429
|
+
original.status !== 'confirmed' ||
|
|
430
|
+
!canChange(original.start_at, new Date(), original.snapshot.policy.rescheduleCutoffMinutes) ||
|
|
431
|
+
changeFee(
|
|
432
|
+
original.changes,
|
|
433
|
+
original.snapshot.policy.freeChanges,
|
|
434
|
+
original.snapshot.policy.changeFees,
|
|
435
|
+
original.snapshot.currency
|
|
436
|
+
) !== hold.snapshot.unitAmount
|
|
437
|
+
) {
|
|
438
|
+
throw createError({ statusCode: 409, message: 'Appointment change is no longer eligible' })
|
|
439
|
+
}
|
|
440
|
+
const [purchase] = await rows<{ buyer_id: string }>(
|
|
441
|
+
'SELECT buyer_id FROM products.orders WHERE id=$1',
|
|
442
|
+
[original.order_id],
|
|
443
|
+
tx
|
|
444
|
+
)
|
|
445
|
+
if (order.buyer_id !== purchase!.buyer_id) {
|
|
446
|
+
throw createError({ statusCode: 403, message: 'Use your verified email when changing an appointment' })
|
|
447
|
+
}
|
|
448
|
+
line.unit_amount = hold.snapshot.unitAmount
|
|
449
|
+
line.snapshot = {
|
|
450
|
+
...line.snapshot,
|
|
451
|
+
title: `${original.snapshot.title} — ${hold.snapshot.locale === 'nl' ? 'Afspraak wijzigen' : 'Appointment change'}`,
|
|
452
|
+
price: { ...line.snapshot.price, amount: hold.snapshot.unitAmount },
|
|
453
|
+
product: { ...line.snapshot.product, isFree: hold.snapshot.unitAmount === 0 }
|
|
454
|
+
}
|
|
455
|
+
await tx.query('UPDATE products.order_line SET snapshot=$2,unit_amount=$3 WHERE id=$1', [
|
|
456
|
+
line.id,
|
|
457
|
+
line.snapshot,
|
|
458
|
+
line.unit_amount
|
|
459
|
+
])
|
|
460
|
+
}
|
|
461
|
+
await tx.query('UPDATE planning.reservation SET order_id=$2 WHERE id=$1', [hold.id, order.id])
|
|
462
|
+
order.snapshot.planningChangeAppointmentId = hold.replaces_id || undefined
|
|
463
|
+
order.snapshot.planningReservationId = hold.id
|
|
464
|
+
order.snapshot.planningExpiresAt = hold.expires_at.toISOString()
|
|
465
|
+
await tx.query('UPDATE products.orders SET snapshot=$2 WHERE id=$1', [order.id, order.snapshot])
|
|
466
|
+
}
|
|
467
|
+
export async function bindPrepared(event: H3Event, tx: PoolClient, order: Order, token: string) {
|
|
468
|
+
const value = prepared.get(event)
|
|
469
|
+
if (!value || value.token !== token) {
|
|
470
|
+
throw new Error('Reservation preflight missing')
|
|
471
|
+
}
|
|
472
|
+
await bind(tx, order, value.hash)
|
|
473
|
+
}
|
|
474
|
+
export async function prepareOrder(order: Order) {
|
|
475
|
+
const [hold] = await rows<Reservation>('SELECT * FROM planning.reservation WHERE id=$1 AND order_id=$2', [
|
|
476
|
+
order.snapshot.planningReservationId,
|
|
477
|
+
order.id
|
|
478
|
+
])
|
|
479
|
+
if (!hold) {
|
|
480
|
+
throw new Error('Order reservation missing')
|
|
481
|
+
}
|
|
482
|
+
if (hold.confirmed_at || order.snapshot.planningFailure) {
|
|
483
|
+
return
|
|
484
|
+
}
|
|
485
|
+
let failure: string | undefined
|
|
486
|
+
if (
|
|
487
|
+
hold.status !== 'reserved' ||
|
|
488
|
+
!order.snapshot.paymentCompletedAt ||
|
|
489
|
+
Date.parse(order.snapshot.paymentCompletedAt) > hold.expires_at.getTime()
|
|
490
|
+
) {
|
|
491
|
+
failure = 'Reservation expired before payment completed'
|
|
492
|
+
} else {
|
|
493
|
+
const [provider] = await rows<{ busy_calendar_ids: string[] }>(
|
|
494
|
+
'SELECT busy_calendar_ids FROM planning.provider WHERE store_id=$1 AND user_id=$2',
|
|
495
|
+
[hold.store_id, hold.user_id]
|
|
496
|
+
)
|
|
497
|
+
const [member] = await rows<{ id: string }>(
|
|
498
|
+
'SELECT id FROM public.member WHERE organization_id=$1 AND user_id=$2',
|
|
499
|
+
[hold.store_id, hold.user_id]
|
|
500
|
+
)
|
|
501
|
+
if (!member) {
|
|
502
|
+
failure = 'Provider is no longer in the organization'
|
|
503
|
+
}
|
|
504
|
+
const busy = await bookingBusy(
|
|
505
|
+
hold.store_id,
|
|
506
|
+
hold.user_id,
|
|
507
|
+
provider!.busy_calendar_ids,
|
|
508
|
+
hold.start_at,
|
|
509
|
+
hold.blocked_until
|
|
510
|
+
)
|
|
511
|
+
if (
|
|
512
|
+
busy.some(
|
|
513
|
+
(b) => hold.start_at.getTime() < Date.parse(b.end) && hold.blocked_until.getTime() > Date.parse(b.start)
|
|
514
|
+
)
|
|
515
|
+
) {
|
|
516
|
+
failure = 'Calendar conflict before appointment confirmation'
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
if (failure) {
|
|
520
|
+
order.snapshot.planningFailure = failure
|
|
521
|
+
await rows(
|
|
522
|
+
"UPDATE products.orders SET snapshot=jsonb_set(snapshot,'{planningFailure}',to_jsonb($2::text)) WHERE id=$1",
|
|
523
|
+
[order.id, failure]
|
|
524
|
+
)
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
export async function confirm(tx: PoolClient, order: Order) {
|
|
528
|
+
const [hold] = await rows<Reservation>(
|
|
529
|
+
'SELECT * FROM planning.reservation WHERE id=$1 AND order_id=$2',
|
|
530
|
+
[order.snapshot.planningReservationId, order.id],
|
|
531
|
+
tx
|
|
532
|
+
)
|
|
533
|
+
if (!hold) {
|
|
534
|
+
throw new Error('Order reservation missing')
|
|
535
|
+
}
|
|
536
|
+
const id = hold.replaces_id || randomUUID()
|
|
537
|
+
await tx.query('SELECT pg_advisory_xact_lock(hashtext($1))', [`planning-effects:${id}`])
|
|
538
|
+
await lockProvider(tx, hold.user_id)
|
|
539
|
+
const [current] = await rows<Reservation>('SELECT * FROM planning.reservation WHERE id=$1 FOR UPDATE', [hold.id], tx)
|
|
540
|
+
Object.assign(hold, current)
|
|
541
|
+
if (hold.confirmed_at) {
|
|
542
|
+
return
|
|
543
|
+
}
|
|
544
|
+
if (
|
|
545
|
+
order.snapshot.planningFailure ||
|
|
546
|
+
hold.status !== 'reserved' ||
|
|
547
|
+
!order.snapshot.paymentCompletedAt ||
|
|
548
|
+
Date.parse(order.snapshot.paymentCompletedAt) > hold.expires_at.getTime()
|
|
549
|
+
) {
|
|
550
|
+
await tx.query(
|
|
551
|
+
"UPDATE products.orders SET snapshot=jsonb_set(snapshot,'{planningFailure}',to_jsonb($2::text)) WHERE id=$1",
|
|
552
|
+
[order.id, order.snapshot.planningFailure || 'Reservation no longer valid']
|
|
553
|
+
)
|
|
554
|
+
await tx.query("UPDATE planning.reservation SET status='expired' WHERE id=$1 AND status='reserved'", [hold.id])
|
|
555
|
+
await enqueue(tx, `failed-booking:${order.id}`, 'failed-booking', { orderId: order.id })
|
|
556
|
+
if (order.total) {
|
|
557
|
+
await enqueue(tx, `late-refund:${order.id}`, 'refund', { orderId: order.id, amount: order.total })
|
|
558
|
+
}
|
|
559
|
+
return
|
|
560
|
+
}
|
|
561
|
+
const line = order.lines[0]!
|
|
562
|
+
if (hold.replaces_id) {
|
|
563
|
+
const [original] = await rows<Appointment>('SELECT * FROM planning.appointment WHERE id=$1 FOR UPDATE', [id], tx)
|
|
564
|
+
if (!original || original.status !== 'confirmed') {
|
|
565
|
+
await tx.query("UPDATE planning.reservation SET status='expired' WHERE id=$1", [hold.id])
|
|
566
|
+
await enqueue(tx, `failed-booking:${order.id}`, 'failed-booking', { orderId: order.id })
|
|
567
|
+
if (order.total) {
|
|
568
|
+
await enqueue(tx, `late-refund:${order.id}`, 'refund', { orderId: order.id, amount: order.total })
|
|
569
|
+
}
|
|
570
|
+
return
|
|
571
|
+
}
|
|
572
|
+
await tx.query("UPDATE planning.reservation SET status='superseded' WHERE id=$1", [original.reservation_id])
|
|
573
|
+
if (original.user_id !== hold.user_id) {
|
|
574
|
+
await enqueue(tx, `cleanup:${id}:${original.revision}`, 'cleanup', {
|
|
575
|
+
storeId: original.store_id,
|
|
576
|
+
userId: original.calendar_user_id || original.user_id,
|
|
577
|
+
calendarId: original.calendar_id,
|
|
578
|
+
eventId: original.calendar_event_id,
|
|
579
|
+
meetingId: original.meeting_id
|
|
580
|
+
})
|
|
581
|
+
}
|
|
582
|
+
const snapshot = {
|
|
583
|
+
...original.snapshot,
|
|
584
|
+
timezone: hold.snapshot.timezone,
|
|
585
|
+
customerTimezone: hold.snapshot.customerTimezone,
|
|
586
|
+
graceMinutes: hold.snapshot.graceMinutes
|
|
587
|
+
}
|
|
588
|
+
await tx.query(
|
|
589
|
+
`UPDATE planning.appointment SET reservation_id=$2,user_id=$3,start_at=$4,end_at=$5,snapshot=$6,changes=changes+1,revision=revision+1,conflict=false,effects_error=NULL,meeting_id=CASE WHEN user_id=$3 THEN meeting_id ELSE NULL END,meeting_url=CASE WHEN user_id=$3 THEN meeting_url ELSE NULL END,calendar_event_id=CASE WHEN user_id=$3 THEN calendar_event_id ELSE NULL END,calendar_id=CASE WHEN user_id=$3 THEN calendar_id ELSE NULL END,calendar_user_id=CASE WHEN user_id=$3 THEN calendar_user_id ELSE NULL END WHERE id=$1`,
|
|
590
|
+
[id, hold.id, hold.user_id, hold.start_at, hold.end_at, snapshot]
|
|
591
|
+
)
|
|
592
|
+
} else {
|
|
593
|
+
await tx.query(
|
|
594
|
+
`INSERT INTO planning.appointment(id,store_id,reservation_id,order_line_id,order_id,user_id,product_id,start_at,end_at,status,snapshot) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,'confirmed',$10)`,
|
|
595
|
+
[
|
|
596
|
+
id,
|
|
597
|
+
hold.store_id,
|
|
598
|
+
hold.id,
|
|
599
|
+
line.id,
|
|
600
|
+
order.id,
|
|
601
|
+
hold.user_id,
|
|
602
|
+
hold.product_id,
|
|
603
|
+
hold.start_at,
|
|
604
|
+
hold.end_at,
|
|
605
|
+
hold.snapshot
|
|
606
|
+
]
|
|
607
|
+
)
|
|
608
|
+
}
|
|
609
|
+
await tx.query("UPDATE planning.reservation SET status='confirmed',confirmed_at=now() WHERE id=$1", [hold.id])
|
|
610
|
+
const [a] = await rows<Appointment>('SELECT * FROM planning.appointment WHERE id=$1', [id], tx)
|
|
611
|
+
await tx.query('INSERT INTO planning.audit(id,appointment_id,actor_id,action,data) VALUES($1,$2,$3,$4,$5)', [
|
|
612
|
+
randomUUID(),
|
|
613
|
+
id,
|
|
614
|
+
order.buyer_id,
|
|
615
|
+
hold.replaces_id ? 'rescheduled' : 'booked',
|
|
616
|
+
{ reservationId: hold.id, changeOrderId: hold.replaces_id ? order.id : null }
|
|
617
|
+
])
|
|
618
|
+
await enqueue(tx, `appointment:${id}:${a!.revision}`, 'appointment', { appointmentId: id, revision: a!.revision })
|
|
619
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, randomBytes, createHash } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
export const digest = (value: string) => createHash('sha256').update(value).digest('hex')
|
|
4
|
+
export const secret = () => randomBytes(32).toString('base64url')
|
|
5
|
+
function key() {
|
|
6
|
+
const value = Buffer.from(process.env.PLANNING_ENCRYPTION_KEY || '', 'base64')
|
|
7
|
+
if (value.length !== 32) {
|
|
8
|
+
throw new Error('Configure PLANNING_ENCRYPTION_KEY with a base64-encoded 32-byte key')
|
|
9
|
+
}
|
|
10
|
+
return value
|
|
11
|
+
}
|
|
12
|
+
export function encrypt(value: unknown) {
|
|
13
|
+
const iv = randomBytes(12),
|
|
14
|
+
cipher = createCipheriv('aes-256-gcm', key(), iv)
|
|
15
|
+
const ciphertext = Buffer.concat([cipher.update(JSON.stringify(value)), cipher.final()])
|
|
16
|
+
return Buffer.concat([iv, cipher.getAuthTag(), ciphertext]).toString('base64')
|
|
17
|
+
}
|
|
18
|
+
export function decrypt<T>(value: string): T {
|
|
19
|
+
const bytes = Buffer.from(value, 'base64'),
|
|
20
|
+
cipher = createDecipheriv('aes-256-gcm', key(), bytes.subarray(0, 12))
|
|
21
|
+
cipher.setAuthTag(bytes.subarray(12, 28))
|
|
22
|
+
return JSON.parse(Buffer.concat([cipher.update(bytes.subarray(28)), cipher.final()]).toString()) as T
|
|
23
|
+
}
|