@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,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
|
+
}
|