@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,427 @@
1
+ import { parseInput } from '@nuxt-customer-portal/products/server/utils/validation'
2
+ import { randomUUID } from 'node:crypto'
3
+ import { createError, type H3Event } from 'h3'
4
+ import { z } from 'zod'
5
+ import { rows, transaction } from '@nuxt-customer-portal/products/server/utils/database'
6
+ import { stripeProvider } from '@nuxt-customer-portal/products/server/utils/payments'
7
+ import { reconcileCheckout, getOrder } from '@nuxt-customer-portal/products/server/utils/orders'
8
+ import { getProduct } from '@nuxt-customer-portal/products/server/utils/catalog'
9
+ import { productPlanningSchema, planningPolicySchema } from '@nuxt-customer-portal/products/shared/planning'
10
+ import { canChange, changeFee, refundAmount } from '../../shared/availability'
11
+ import { availabilityEditSchema, providerSettingsSchema } from '../../shared/validation'
12
+ import type { Appointment, ProviderSettings, AvailabilityWindow } from '../../shared/types'
13
+ import { planningAdmin, providerAccess, appointmentAccess } from './access'
14
+ import { calendarAdapter } from './adapters'
15
+ import { enqueue, lockProvider, policy } from './booking'
16
+ import { auditLock } from './jobs'
17
+
18
+ export async function listProviders(event: H3Event): Promise<ProviderSettings[]> {
19
+ const context = await planningAdmin(event)
20
+ return rows<ProviderSettings>(
21
+ `SELECT u.id AS "userId",u.name,u.image,COALESCE(p.enabled,false) AS enabled,COALESCE(p.timezone,u.timezone,os.timezone,'Europe/Amsterdam') AS timezone,COALESCE(p.grace_minutes,0) AS "graceMinutes",COALESCE(p.busy_calendar_ids,'{}') AS "busyCalendarIds",p.write_calendar_id AS "writeCalendarId",EXISTS(SELECT 1 FROM planning.connection c WHERE c.store_id=m.organization_id AND c.user_id=u.id AND c.provider='google' AND c.healthy) AS "googleConnected",EXISTS(SELECT 1 FROM planning.connection c WHERE c.store_id=m.organization_id AND c.user_id=u.id AND c.provider='zoom' AND c.healthy) AS "zoomConnected" FROM public.member m JOIN public."user" u ON u.id=m.user_id LEFT JOIN planning.provider p ON p.store_id=m.organization_id AND p.user_id=u.id LEFT JOIN public.organization_settings os ON os.organization_id=m.organization_id WHERE m.organization_id=$1 ORDER BY u.name`,
22
+ [context.organizationId]
23
+ )
24
+ }
25
+ export async function setEnabled(event: H3Event, userId: string, body: unknown) {
26
+ const context = await planningAdmin(event),
27
+ input = parseInput(z.object({ enabled: z.boolean() }), body)
28
+ await transaction(async (tx) => {
29
+ await lockProvider(tx, userId)
30
+ const [member] = await rows<{ id: string }>(
31
+ 'SELECT id FROM public.member WHERE organization_id=$1 AND user_id=$2',
32
+ [context.organizationId, userId],
33
+ tx
34
+ )
35
+ if (!member) {
36
+ throw createError({ statusCode: 404, message: 'Provider member not found' })
37
+ }
38
+ await tx.query(
39
+ `INSERT INTO planning.provider(store_id,user_id,enabled,timezone) SELECT $1,u.id,$3,COALESCE(u.timezone,os.timezone,'Europe/Amsterdam') FROM public."user" u LEFT JOIN public.organization_settings os ON os.organization_id=$1 WHERE u.id=$2 ON CONFLICT(store_id,user_id) DO UPDATE SET enabled=$3`,
40
+ [context.organizationId, userId, input.enabled]
41
+ )
42
+ })
43
+ return { success: true }
44
+ }
45
+ export async function ownSettings(event: H3Event) {
46
+ const { storeId, userId } = await providerAccess(event)
47
+ const [p] = await rows<{
48
+ enabled: boolean
49
+ timezone: string
50
+ grace_minutes: number
51
+ availability_calendar_title: string
52
+ busy_calendar_ids: string[]
53
+ write_calendar_id: string | null
54
+ }>('SELECT * FROM planning.provider WHERE store_id=$1 AND user_id=$2', [storeId, userId])
55
+ const connections = await rows<{
56
+ provider: string
57
+ healthy: boolean
58
+ error: string | null
59
+ externalUserLabel: string | null
60
+ }>(
61
+ 'SELECT provider,healthy,error,external_user_label AS "externalUserLabel" FROM planning.connection WHERE store_id=$1 AND user_id=$2',
62
+ [storeId, userId]
63
+ )
64
+ const products = await rows<{ id: string; title: string; thumbnailImageId: string | null }>(
65
+ `SELECT id,COALESCE(NULLIF(data->'content'->'en'->>'title',''),data->'content'->'nl'->>'title') AS title, COALESCE(data->>'thumbnailImageId', data->'imageIds'->>0) AS "thumbnailImageId" FROM products.product WHERE store_id=$1 AND data->'planning'->>'enabled'='true' AND data->'planning'->'providerUserIds' ? $2 AND data->>'status'<>'archived' ORDER BY title`,
66
+ [storeId, userId]
67
+ )
68
+ return {
69
+ enabled: p!.enabled,
70
+ timezone: p!.timezone,
71
+ graceMinutes: p!.grace_minutes,
72
+ availabilityCalendarTitle: p!.availability_calendar_title,
73
+ busyCalendarIds: p!.busy_calendar_ids,
74
+ writeCalendarId: p!.write_calendar_id,
75
+ connections,
76
+ products
77
+ }
78
+ }
79
+ export async function saveOwnSettings(event: H3Event, body: unknown) {
80
+ const { storeId, userId } = await providerAccess(event),
81
+ input = parseInput(providerSettingsSchema, body),
82
+ calendars = await calendarAdapter().calendars(storeId, userId)
83
+ if (
84
+ input.busyCalendarIds.some((id) => !calendars.some((c) => c.id === id)) ||
85
+ !calendars.some((c) => c.id === input.writeCalendarId && ['owner', 'writer'].includes(c.accessRole))
86
+ ) {
87
+ throw createError({ statusCode: 400, message: 'Choose accessible calendars and a writable destination' })
88
+ }
89
+ await transaction(async (tx) => {
90
+ await lockProvider(tx, userId)
91
+ await tx.query(
92
+ 'UPDATE planning.provider SET timezone=$3,grace_minutes=$4,busy_calendar_ids=$5,write_calendar_id=$6,availability_calendar_title=$7 WHERE store_id=$1 AND user_id=$2',
93
+ [
94
+ storeId,
95
+ userId,
96
+ input.timezone,
97
+ input.graceMinutes,
98
+ [...new Set([...input.busyCalendarIds, input.writeCalendarId])],
99
+ input.writeCalendarId,
100
+ input.availabilityCalendarTitle
101
+ ]
102
+ )
103
+ const windows = await rows<{ id: string; revision: number }>(
104
+ "UPDATE planning.availability SET data=jsonb_set(data,'{timezone}',to_jsonb($3::text)),revision=revision+1 WHERE store_id=$1 AND user_id=$2 RETURNING id,revision",
105
+ [storeId, userId, input.timezone],
106
+ tx
107
+ )
108
+ for (const w of windows) {
109
+ await enqueue(tx, `availability:${w.id}:${w.revision}`, 'availability', { id: w.id })
110
+ }
111
+ const appointments = await rows<Appointment>(
112
+ "UPDATE planning.appointment SET revision=revision+1 WHERE store_id=$1 AND user_id=$2 AND status='confirmed' AND end_at>now() RETURNING *",
113
+ [storeId, userId],
114
+ tx
115
+ )
116
+ for (const a of appointments) {
117
+ await enqueue(tx, `appointment:${a.id}:${a.revision}`, 'appointment', {
118
+ appointmentId: a.id,
119
+ revision: a.revision
120
+ })
121
+ }
122
+ await enqueue(tx, `sync:${storeId}:${userId}:${randomUUID()}`, 'sync', { storeId, userId })
123
+ })
124
+ return ownSettings(event)
125
+ }
126
+ export async function windows(event: H3Event) {
127
+ const { storeId, userId } = await providerAccess(event)
128
+ const found = await rows<{ id: string; data: AvailabilityWindow }>(
129
+ 'SELECT id,data FROM planning.availability WHERE store_id=$1 AND user_id=$2 AND NOT deleted',
130
+ [storeId, userId]
131
+ )
132
+ return found.map((w) => ({ ...w.data, id: w.id }))
133
+ }
134
+ export async function saveWindow(event: H3Event, body: unknown, id?: string) {
135
+ const { storeId, userId } = await providerAccess(event),
136
+ input = parseInput(availabilityEditSchema, body)
137
+ const settings = await ownSettings(event)
138
+ if (input.productIds?.some((productId) => !settings.products.some((p) => p.id === productId))) {
139
+ throw createError({ statusCode: 400, message: 'Select your assigned plannable products' })
140
+ }
141
+ return transaction(async (tx) => {
142
+ await lockProvider(tx, userId)
143
+ let existing: { data: AvailabilityWindow; revision: number } | undefined
144
+ if (id) {
145
+ ;[existing] = await rows<{ data: AvailabilityWindow; revision: number }>(
146
+ 'SELECT data,revision FROM planning.availability WHERE id=$1 AND store_id=$2 AND user_id=$3 AND NOT deleted FOR UPDATE',
147
+ [id, storeId, userId],
148
+ tx
149
+ )
150
+ if (!existing) {
151
+ throw createError({ statusCode: 404, message: 'Availability not found' })
152
+ }
153
+ }
154
+ if (input.occurrenceDate && existing) {
155
+ if (
156
+ !existing.data.recurring ||
157
+ new Date(input.occurrenceDate).getUTCDay() !== new Date(existing.data.date).getUTCDay() ||
158
+ input.occurrenceDate < existing.data.date ||
159
+ (existing.data.endDate && input.occurrenceDate > existing.data.endDate)
160
+ ) {
161
+ throw createError({ statusCode: 400, message: 'Choose an occurrence in this series' })
162
+ }
163
+ const exception = {
164
+ ...existing.data,
165
+ exceptions: [...new Set([...existing.data.exceptions, input.occurrenceDate])]
166
+ }
167
+ await tx.query('UPDATE planning.availability SET data=$2,revision=revision+1 WHERE id=$1', [id, exception])
168
+ await enqueue(tx, `availability:${id}:${existing.revision + 1}`, 'availability', { id })
169
+ id = undefined
170
+ input.recurring = false
171
+ input.endDate = null
172
+ input.exceptions = []
173
+ }
174
+ const { occurrenceDate: _occurrence, ...data } = input,
175
+ newId = id || randomUUID()
176
+ const window: AvailabilityWindow = {
177
+ ...data,
178
+ id: newId,
179
+ userId,
180
+ timezone: existing && id ? existing.data.timezone : settings.timezone
181
+ }
182
+ const [saved] = await rows<{ revision: number }>(
183
+ 'INSERT INTO planning.availability(id,store_id,user_id,data) VALUES($1,$2,$3,$4) ON CONFLICT(id) DO UPDATE SET data=$4,revision=planning.availability.revision+1 RETURNING revision',
184
+ [newId, storeId, userId, window],
185
+ tx
186
+ )
187
+ await enqueue(tx, `availability:${newId}:${saved!.revision}`, 'availability', { id: newId })
188
+ return window
189
+ })
190
+ }
191
+ export async function deleteWindow(event: H3Event, id: string, occurrence?: string) {
192
+ const { storeId, userId } = await providerAccess(event)
193
+ return transaction(async (tx) => {
194
+ await lockProvider(tx, userId)
195
+ const [w] = await rows<{ data: AvailabilityWindow; revision: number }>(
196
+ 'SELECT data,revision FROM planning.availability WHERE id=$1 AND store_id=$2 AND user_id=$3 AND NOT deleted FOR UPDATE',
197
+ [id, storeId, userId],
198
+ tx
199
+ )
200
+ if (!w) {
201
+ throw createError({ statusCode: 404, message: 'Availability not found' })
202
+ }
203
+ if (occurrence) {
204
+ z.string()
205
+ .regex(/^\d{4}-\d{2}-\d{2}$/)
206
+ .parse(occurrence)
207
+ if (
208
+ !w.data.recurring ||
209
+ occurrence < w.data.date ||
210
+ (w.data.endDate && occurrence > w.data.endDate) ||
211
+ new Date(occurrence).getUTCDay() !== new Date(w.data.date).getUTCDay()
212
+ ) {
213
+ throw createError({ statusCode: 400, message: 'Invalid occurrence' })
214
+ }
215
+ await tx.query('UPDATE planning.availability SET data=$2,revision=revision+1 WHERE id=$1', [
216
+ id,
217
+ { ...w.data, exceptions: [...new Set([...w.data.exceptions, occurrence])] }
218
+ ])
219
+ } else {
220
+ await tx.query('UPDATE planning.availability SET deleted=true,revision=revision+1 WHERE id=$1', [id])
221
+ }
222
+ await enqueue(tx, `availability:${id}:${w.revision + 1}`, 'availability', { id })
223
+ return { success: true }
224
+ })
225
+ }
226
+ export async function appointmentDetails(event: H3Event, id: string) {
227
+ const { appointment: a, staff, canManage } = await appointmentAccess(event, id),
228
+ order = await getOrder(a.order_id),
229
+ now = new Date()
230
+ let fee: number | null = null
231
+ try {
232
+ fee = changeFee(a.changes, a.snapshot.policy.freeChanges, a.snapshot.policy.changeFees, a.snapshot.currency)
233
+ } catch {
234
+ /* A missing currency fee disables paid self-service changes. */
235
+ }
236
+ const [provider] = await rows<{ name: string }>('SELECT name FROM public."user" WHERE id=$1', [a.user_id])
237
+ const [pending] = await rows<{ expires_at: Date }>(
238
+ "SELECT expires_at FROM planning.reservation WHERE replaces_id=$1 AND status='reserved' ORDER BY expires_at DESC LIMIT 1",
239
+ [id]
240
+ )
241
+ return {
242
+ staff,
243
+ canManage,
244
+ revision: a.revision,
245
+ pendingChangeExpiresAt: pending?.expires_at || null,
246
+ id: a.id,
247
+ productId: a.product_id,
248
+ productSlug: order!.lines[0]!.snapshot.product.slug,
249
+ title: a.snapshot.title,
250
+ providerName: provider!.name,
251
+ start: a.start_at,
252
+ end: a.end_at,
253
+ status: a.status,
254
+ customerTimezone: a.snapshot.customerTimezone,
255
+ currency: a.snapshot.currency,
256
+ meetingProvider: a.snapshot.meetingProvider,
257
+ meetingUrl: a.meeting_url,
258
+ changes: a.changes,
259
+ freeChanges: a.snapshot.policy.freeChanges,
260
+ changeFee: fee,
261
+ canReschedule:
262
+ a.status === 'confirmed' &&
263
+ canManage &&
264
+ !pending &&
265
+ (staff || (fee !== null && canChange(a.start_at, now, a.snapshot.policy.rescheduleCutoffMinutes))),
266
+ canCancel:
267
+ a.status === 'confirmed' &&
268
+ canManage &&
269
+ (staff ||
270
+ (a.snapshot.policy.cancellationEnabled &&
271
+ canChange(a.start_at, now, a.snapshot.policy.cancellationCutoffMinutes))),
272
+ refundAmount:
273
+ a.snapshot.policy.cancellationEnabled && canChange(a.start_at, now, a.snapshot.policy.cancellationCutoffMinutes)
274
+ ? refundAmount(order!.total || 0, order!.refunded, a.snapshot.policy.refundPercentage)
275
+ : 0,
276
+ conflict: a.conflict
277
+ }
278
+ }
279
+ export async function cancel(event: H3Event, id: string) {
280
+ const { appointment: initial, userId, staff } = await appointmentAccess(event, id, true)
281
+ return transaction(async (tx) => {
282
+ await auditLock(tx, id)
283
+ await lockProvider(tx, initial.user_id)
284
+ const [a] = await rows<Appointment>('SELECT * FROM planning.appointment WHERE id=$1 FOR UPDATE', [id], tx)
285
+ if (a!.status === 'cancelled') {
286
+ return { success: true }
287
+ }
288
+ if (
289
+ !staff &&
290
+ (!a!.snapshot.policy.cancellationEnabled ||
291
+ !canChange(a!.start_at, new Date(), a!.snapshot.policy.cancellationCutoffMinutes))
292
+ ) {
293
+ throw createError({ statusCode: 409, message: 'Cancellation is not permitted by this appointment policy' })
294
+ }
295
+ const [order] = await rows<{ total: number; refunded: number }>(
296
+ 'SELECT total,refunded FROM products.orders WHERE id=$1 FOR UPDATE',
297
+ [a!.order_id],
298
+ tx
299
+ )
300
+ const amount =
301
+ a!.snapshot.policy.cancellationEnabled &&
302
+ canChange(a!.start_at, new Date(), a!.snapshot.policy.cancellationCutoffMinutes)
303
+ ? refundAmount(order!.total || 0, order!.refunded, a!.snapshot.policy.refundPercentage)
304
+ : 0
305
+ await tx.query(
306
+ "UPDATE planning.appointment SET status='cancelled',revision=revision+1,conflict=false WHERE id=$1",
307
+ [id]
308
+ )
309
+ await tx.query("UPDATE planning.reservation SET status='cancelled' WHERE id=$1", [a!.reservation_id])
310
+ const replacements = await rows<{ id: string; order_id: string | null }>(
311
+ "UPDATE planning.reservation SET status='cancelled' WHERE replaces_id=$1 AND status='reserved' RETURNING id,order_id",
312
+ [id],
313
+ tx
314
+ )
315
+ for (const h of replacements) {
316
+ if (h.order_id) {
317
+ await enqueue(tx, `close:${h.order_id}`, 'close-checkout', { orderId: h.order_id })
318
+ }
319
+ }
320
+ await tx.query('INSERT INTO planning.audit(id,appointment_id,actor_id,action,data) VALUES($1,$2,$3,$4,$5)', [
321
+ randomUUID(),
322
+ id,
323
+ userId,
324
+ 'cancelled',
325
+ { refundAmount: amount }
326
+ ])
327
+ await enqueue(tx, `appointment:${id}:${a!.revision + 1}`, 'appointment', {
328
+ appointmentId: id,
329
+ revision: a!.revision + 1
330
+ })
331
+ if (amount) {
332
+ await enqueue(tx, `cancel-refund:${id}`, 'refund', { orderId: a!.order_id, amount })
333
+ }
334
+ return { success: true, refundAmount: amount }
335
+ })
336
+ }
337
+ export async function getPolicy(event: H3Event) {
338
+ const context = await planningAdmin(event)
339
+ return policy(context.organizationId)
340
+ }
341
+ export async function savePolicy(event: H3Event, body: unknown) {
342
+ const context = await planningAdmin(event),
343
+ input = parseInput(planningPolicySchema, body)
344
+ await rows(
345
+ 'INSERT INTO planning.settings(store_id,policy) VALUES($1,$2) ON CONFLICT(store_id) DO UPDATE SET policy=$2',
346
+ [context.organizationId, input]
347
+ )
348
+ return input
349
+ }
350
+
351
+ export async function abandonChange(event: H3Event, id: string) {
352
+ await appointmentAccess(event, id, true)
353
+ const pending = await rows<{ id: string; user_id: string; order_id: string | null }>(
354
+ "SELECT id,user_id,order_id FROM planning.reservation WHERE replaces_id=$1 AND status='reserved'",
355
+ [id]
356
+ )
357
+ for (const hold of pending) {
358
+ if (hold.order_id) {
359
+ const order = await getOrder(hold.order_id)
360
+ if (order?.checkout_id && !order.checkout_id.startsWith('sandbox:')) {
361
+ await stripeProvider.expireCheckout(order.checkout_id)
362
+ await reconcileCheckout(order.checkout_id)
363
+ }
364
+ }
365
+ await transaction(async (tx) => {
366
+ await auditLock(tx, id)
367
+ await lockProvider(tx, hold.user_id)
368
+ await rows('SELECT id FROM planning.reservation WHERE id=$1 FOR UPDATE', [hold.id], tx)
369
+ if (hold.order_id) {
370
+ const [order] = await rows<{ status: string }>(
371
+ 'SELECT status FROM products.orders WHERE id=$1 FOR UPDATE',
372
+ [hold.order_id],
373
+ tx
374
+ )
375
+ if (order?.status === 'paid') {
376
+ return
377
+ }
378
+ await tx.query("UPDATE products.orders SET status='expired' WHERE id=$1 AND status='pending'", [hold.order_id])
379
+ }
380
+ await tx.query("UPDATE planning.reservation SET status='cancelled' WHERE id=$1 AND status='reserved'", [hold.id])
381
+ })
382
+ }
383
+ return appointmentDetails(event, id)
384
+ }
385
+
386
+ export async function saveProductPlanning(event: H3Event, id: string, body: unknown) {
387
+ const context = await planningAdmin(event)
388
+ const input = parseInput(productPlanningSchema, body)
389
+ await transaction(async (tx) => {
390
+ await tx.query('SELECT pg_advisory_xact_lock(hashtext($1))', [`product:${id}`])
391
+ const [product] = await rows<{ data: { type: string; planning?: { providerUserIds: string[] } } }>(
392
+ 'SELECT data FROM products.product WHERE id=$1 AND store_id=$2 FOR UPDATE',
393
+ [id, context.organizationId],
394
+ tx
395
+ )
396
+ if (!product) {
397
+ throw createError({ statusCode: 404, message: 'Product not found' })
398
+ }
399
+ if (product.data.type !== 'service') {
400
+ throw createError({ statusCode: 400, message: 'Only service products support planning' })
401
+ }
402
+ if (input.enabled) {
403
+ const members = await rows<{ user_id: string; enabled: boolean }>(
404
+ 'SELECT m.user_id,COALESCE(p.enabled,false) AS enabled FROM public.member m LEFT JOIN planning.provider p ON p.store_id=m.organization_id AND p.user_id=m.user_id WHERE m.organization_id=$1 AND m.user_id=ANY($2::text[])',
405
+ [context.organizationId, input.providerUserIds],
406
+ tx
407
+ )
408
+ const previous = product.data.planning?.providerUserIds || []
409
+ if (
410
+ input.providerUserIds.some(
411
+ (userId) => !members.some((m) => m.user_id === userId && (m.enabled || previous.includes(userId)))
412
+ )
413
+ ) {
414
+ throw createError({
415
+ statusCode: 400,
416
+ message: 'Select organization team members with planning enabled',
417
+ data: { field: 'planning.providerUserIds' }
418
+ })
419
+ }
420
+ }
421
+ await tx.query(
422
+ "UPDATE products.product SET data=jsonb_set(data,'{planning}',$3::jsonb),updated_at=now() WHERE id=$1 AND store_id=$2",
423
+ [id, context.organizationId, JSON.stringify(input)]
424
+ )
425
+ })
426
+ return getProduct(context.organizationId, id)
427
+ }
@@ -0,0 +1,104 @@
1
+ import { getCookie, setCookie, deleteCookie, createError, type H3Event } from 'h3'
2
+ import { rows } from '@nuxt-customer-portal/products/server/utils/database'
3
+ import { providerAccess } from './access'
4
+ import { oauthConfig, exchange } from './adapters'
5
+ import { digest, secret, encrypt } from './crypto'
6
+
7
+ export function oauthProvider(value: string | undefined): 'google' | 'zoom' {
8
+ if (value !== 'google' && value !== 'zoom') {
9
+ throw createError({ statusCode: 404 })
10
+ }
11
+ return value
12
+ }
13
+ export async function startOAuth(event: H3Event, provider: 'google' | 'zoom') {
14
+ const { storeId, userId } = await providerAccess(event),
15
+ config = oauthConfig(provider),
16
+ state = secret(),
17
+ binding = secret()
18
+ await rows(
19
+ `INSERT INTO planning.oauth_state(id,store_id,user_id,provider,expires_at) VALUES($1,$2,$3,$4,now()+interval '10 minutes')`,
20
+ [digest(`${state}:${binding}`), storeId, userId, provider]
21
+ )
22
+ setCookie(event, 'planning-oauth', binding, {
23
+ httpOnly: true,
24
+ secure: config.redirectUri.startsWith('https://'),
25
+ sameSite: 'lax',
26
+ path: '/api/planning/oauth',
27
+ maxAge: 600
28
+ })
29
+ const params = new URLSearchParams({
30
+ client_id: config.clientId,
31
+ redirect_uri: config.redirectUri,
32
+ response_type: 'code',
33
+ state,
34
+ ...(provider === 'google'
35
+ ? {
36
+ access_type: 'offline',
37
+ prompt: 'consent',
38
+ scope:
39
+ 'openid email https://www.googleapis.com/auth/calendar.events https://www.googleapis.com/auth/calendar.calendarlist.readonly https://www.googleapis.com/auth/calendar.freebusy'
40
+ }
41
+ : {})
42
+ })
43
+ return {
44
+ url: `${provider === 'google' ? 'https://accounts.google.com/o/oauth2/v2/auth' : 'https://zoom.us/oauth/authorize'}?${params}`
45
+ }
46
+ }
47
+ export async function finishOAuth(event: H3Event, provider: 'google' | 'zoom', state: string, code: string) {
48
+ const { storeId, userId } = await providerAccess(event),
49
+ binding = getCookie(event, 'planning-oauth')
50
+ if (!binding || !state || !code) {
51
+ throw createError({ statusCode: 400, message: 'Authorization state missing' })
52
+ }
53
+ const [request] = await rows<{ id: string }>(
54
+ 'DELETE FROM planning.oauth_state WHERE id=$1 AND store_id=$2 AND user_id=$3 AND provider=$4 AND expires_at>now() RETURNING id',
55
+ [digest(`${state}:${binding}`), storeId, userId, provider]
56
+ )
57
+ deleteCookie(event, 'planning-oauth', { path: '/api/planning/oauth' })
58
+ if (!request) {
59
+ throw createError({ statusCode: 403, message: 'Authorization expired or belongs to another user' })
60
+ }
61
+ const credentials = await exchange(provider, { grant_type: 'authorization_code', code })
62
+ if (!credentials.refresh_token) {
63
+ throw createError({ statusCode: 400, message: 'Offline authorization required; reconnect with consent' })
64
+ }
65
+ let externalId: string,
66
+ externalLabel: string | null = null
67
+ if (provider === 'google') {
68
+ const response = await fetch('https://openidconnect.googleapis.com/v1/userinfo', {
69
+ headers: { Authorization: `Bearer ${credentials.access_token}` },
70
+ signal: AbortSignal.timeout(15000)
71
+ })
72
+ if (!response.ok) {
73
+ throw new Error('Could not identify connected account')
74
+ }
75
+ const profile = (await response.json()) as { sub?: string }
76
+ if (!profile.sub) {
77
+ throw new Error('Connected account identity missing')
78
+ }
79
+ externalId = profile.sub
80
+ } else {
81
+ const response = await fetch('https://api.zoom.us/v2/users/me', {
82
+ headers: { Authorization: `Bearer ${credentials.access_token}` },
83
+ signal: AbortSignal.timeout(15000)
84
+ })
85
+ if (!response.ok) {
86
+ throw new Error('Could not identify connected Zoom account')
87
+ }
88
+ const profile = (await response.json()) as {
89
+ id?: string
90
+ email?: string
91
+ first_name?: string
92
+ last_name?: string
93
+ }
94
+ if (!profile.id) {
95
+ throw new Error('Connected Zoom account identity missing')
96
+ }
97
+ externalId = profile.id
98
+ externalLabel = profile.email || [profile.first_name, profile.last_name].filter(Boolean).join(' ') || profile.id
99
+ }
100
+ await rows(
101
+ 'INSERT INTO planning.connection(store_id,user_id,provider,credentials,external_user_id,external_user_label) VALUES($1,$2,$3,$4,$5,$6) ON CONFLICT(store_id,user_id,provider) DO UPDATE SET credentials=$4,external_user_id=$5,external_user_label=$6,healthy=true,error=NULL',
102
+ [storeId, userId, provider, encrypt(credentials), externalId, externalLabel]
103
+ )
104
+ }