@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,468 @@
1
+ import { pool } from '@nuxt-customer-portal/core/server/utils/db'
2
+ import { rows } from '@nuxt-customer-portal/products/server/utils/database'
3
+ import { baseUrl } from '@nuxt-customer-portal/products/server/utils/access'
4
+ import { encrypt, decrypt, digest } from './crypto'
5
+ import type { Interval } from '../../shared/types'
6
+ import { wallInstant } from '../../shared/availability'
7
+
8
+ export interface Credentials {
9
+ access_token: string
10
+ refresh_token: string
11
+ expiresAt: number
12
+ }
13
+ export interface CalendarEvent {
14
+ id: string
15
+ summary: string
16
+ start: { dateTime: string; timeZone: string }
17
+ end: { dateTime: string; timeZone: string }
18
+ transparency: 'transparent' | 'opaque'
19
+ extendedProperties: { private: Record<string, string> }
20
+ recurrence?: string[]
21
+ description?: string
22
+ }
23
+ export interface CalendarAdapter {
24
+ calendars(
25
+ storeId: string,
26
+ userId: string
27
+ ): Promise<Array<{ id: string; summary: string; accessRole: string; timeZone?: string }>>
28
+ busy(storeId: string, userId: string, calendarIds: string[], from: Date, to: Date): Promise<Interval[]>
29
+ put(
30
+ storeId: string,
31
+ userId: string,
32
+ calendarId: string,
33
+ event: CalendarEvent
34
+ ): Promise<undefined | { id: string; externalChangeKey?: string; wasMissing: boolean }>
35
+ remove(storeId: string, userId: string, calendarId: string, eventId: string): Promise<void>
36
+ watch(
37
+ storeId: string,
38
+ userId: string,
39
+ calendarId: string,
40
+ id: string,
41
+ token: string
42
+ ): Promise<{ resourceId: string; expiration: string }>
43
+ }
44
+ export interface MeetingAdapter {
45
+ ensure(
46
+ storeId: string,
47
+ userId: string,
48
+ identity: string,
49
+ title: string,
50
+ start: Date,
51
+ duration: number,
52
+ details: { agenda: string; inviteeEmail: string },
53
+ existingId?: string
54
+ ): Promise<{ id: string; url: string }>
55
+ remove(storeId: string, userId: string, id: string): Promise<void>
56
+ }
57
+ export function oauthConfig(provider: 'google' | 'zoom') {
58
+ const prefix = `PLANNING_${provider.toUpperCase()}`
59
+ const clientId = process.env[`${prefix}_CLIENT_ID`],
60
+ clientSecret = process.env[`${prefix}_CLIENT_SECRET`]
61
+ if (!clientId || !clientSecret) {
62
+ throw new Error(`Configure ${prefix}_CLIENT_ID and ${prefix}_CLIENT_SECRET`)
63
+ }
64
+ return { clientId, clientSecret, redirectUri: `${baseUrl()}/api/planning/oauth/${provider}/callback` }
65
+ }
66
+ export async function exchange(provider: 'google' | 'zoom', params: Record<string, string>): Promise<Credentials> {
67
+ const config = oauthConfig(provider)
68
+ const response = await fetch(
69
+ provider === 'google' ? 'https://oauth2.googleapis.com/token' : 'https://zoom.us/oauth/token',
70
+ {
71
+ method: 'POST',
72
+ signal: AbortSignal.timeout(15000),
73
+ headers: {
74
+ 'Content-Type': 'application/x-www-form-urlencoded',
75
+ ...(provider === 'zoom'
76
+ ? { Authorization: `Basic ${Buffer.from(`${config.clientId}:${config.clientSecret}`).toString('base64')}` }
77
+ : {})
78
+ },
79
+ body: new URLSearchParams({
80
+ ...params,
81
+ redirect_uri: config.redirectUri,
82
+ ...(provider === 'google' ? { client_id: config.clientId, client_secret: config.clientSecret } : {})
83
+ })
84
+ }
85
+ )
86
+ if (!response.ok) {
87
+ throw new Error(`Calendar/meeting authorization failed (${response.status})`)
88
+ }
89
+ const value = (await response.json()) as { access_token: string; refresh_token?: string; expires_in: number }
90
+ if (!value.access_token || !value.expires_in) {
91
+ throw new Error('Invalid authorization response')
92
+ }
93
+ return {
94
+ access_token: value.access_token,
95
+ refresh_token: value.refresh_token || params.refresh_token || '',
96
+ expiresAt: Date.now() + value.expires_in * 1000
97
+ }
98
+ }
99
+ async function accessToken(storeId: string, userId: string, provider: 'google' | 'zoom') {
100
+ const client = await pool.connect(),
101
+ lock = `planning-token:${storeId}:${userId}:${provider}`
102
+ try {
103
+ await client.query('SELECT pg_advisory_lock(hashtext($1))', [lock])
104
+ const [connection] = await rows<{ credentials: string; healthy: boolean }>(
105
+ 'SELECT credentials,healthy FROM planning.connection WHERE store_id=$1 AND user_id=$2 AND provider=$3',
106
+ [storeId, userId, provider],
107
+ client
108
+ )
109
+ if (!connection?.healthy) {
110
+ throw new Error(`Reconnect ${provider} before planning`)
111
+ }
112
+ let credentials = decrypt<Credentials>(connection.credentials)
113
+ if (credentials.expiresAt < Date.now() + 60000) {
114
+ credentials = await exchange(provider, { grant_type: 'refresh_token', refresh_token: credentials.refresh_token })
115
+ await rows(
116
+ 'UPDATE planning.connection SET credentials=$4,error=NULL WHERE store_id=$1 AND user_id=$2 AND provider=$3',
117
+ [storeId, userId, provider, encrypt(credentials)],
118
+ client
119
+ )
120
+ }
121
+ return credentials.access_token
122
+ } finally {
123
+ await client.query('SELECT pg_advisory_unlock(hashtext($1))', [lock]).catch(() => undefined)
124
+ client.release()
125
+ }
126
+ }
127
+ async function api<T>(
128
+ provider: 'google' | 'zoom',
129
+ storeId: string,
130
+ userId: string,
131
+ path: string,
132
+ method = 'GET',
133
+ body?: unknown,
134
+ allowed: number[] = []
135
+ ): Promise<T> {
136
+ const token = await accessToken(storeId, userId, provider)
137
+ const response = await fetch(
138
+ `${provider === 'google' ? 'https://www.googleapis.com/calendar/v3' : 'https://api.zoom.us/v2'}${path}`,
139
+ {
140
+ method,
141
+ signal: AbortSignal.timeout(15000),
142
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
143
+ body: body === undefined ? undefined : JSON.stringify(body)
144
+ }
145
+ )
146
+ if (!response.ok && !allowed.includes(response.status)) {
147
+ if (response.status === 401) {
148
+ await rows(
149
+ 'UPDATE planning.connection SET healthy=false,error=$4 WHERE store_id=$1 AND user_id=$2 AND provider=$3',
150
+ [storeId, userId, provider, 'Authorization expired; reconnect']
151
+ )
152
+ }
153
+ let detail = ''
154
+ try {
155
+ const payload = (await response.json()) as { error?: { message?: string }; message?: string }
156
+ detail = String(payload.error?.message || payload.message || '')
157
+ .replace(/\s+/g, ' ')
158
+ .trim()
159
+ .slice(0, 500)
160
+ } catch {
161
+ // Some provider error responses have no JSON body.
162
+ }
163
+ throw new Error(`${provider} API failed (${response.status})${detail ? `: ${detail}` : ''}`)
164
+ }
165
+ if (response.status === 204 || allowed.includes(response.status)) {
166
+ return undefined as T
167
+ }
168
+ return (await response.json()) as T
169
+ }
170
+ const calendarPath = (id: string) => `/calendars/${encodeURIComponent(id)}/events`
171
+ export const googleCalendar: CalendarAdapter = {
172
+ async calendars(s, u) {
173
+ const items: Awaited<ReturnType<CalendarAdapter['calendars']>> = []
174
+ let pageToken: string | undefined
175
+ do {
176
+ const result: { items?: typeof items; nextPageToken?: string } = await api(
177
+ 'google',
178
+ s,
179
+ u,
180
+ `/users/me/calendarList?${new URLSearchParams({ maxResults: '250', ...(pageToken ? { pageToken } : {}) })}`
181
+ )
182
+ items.push(...(result.items || []))
183
+ pageToken = result.nextPageToken
184
+ } while (pageToken)
185
+ return items
186
+ },
187
+ async busy(s, u, ids, from, to) {
188
+ if (!ids.length) {
189
+ throw new Error('Select calendars for conflict checks')
190
+ }
191
+ const response = await api<{ calendars: Record<string, { busy: Interval[]; errors?: unknown[] }> }>(
192
+ 'google',
193
+ s,
194
+ u,
195
+ '/freeBusy',
196
+ 'POST',
197
+ { timeMin: from.toISOString(), timeMax: to.toISOString(), items: ids.map((id) => ({ id })) }
198
+ )
199
+ if (ids.some((id) => !response.calendars[id] || response.calendars[id]!.errors?.length)) {
200
+ throw new Error('Calendar conflict check unavailable')
201
+ }
202
+ return ids.flatMap((id) => response.calendars[id]!.busy)
203
+ },
204
+ async put(s, u, id, event) {
205
+ const existing = await api<(CalendarEvent & { updated?: string; status?: string }) | undefined>(
206
+ 'google',
207
+ s,
208
+ u,
209
+ `${calendarPath(id)}/${event.id}`,
210
+ 'GET',
211
+ undefined,
212
+ [404, 410]
213
+ )
214
+ const instant = (value: string, zone: string) =>
215
+ /(?:Z|[+-]\d{2}:\d{2})$/.test(value)
216
+ ? Date.parse(value)
217
+ : wallInstant(value.slice(0, 10), value.slice(11, 16), zone).getTime()
218
+ const same =
219
+ !!existing &&
220
+ existing.status !== 'cancelled' &&
221
+ existing.summary === event.summary &&
222
+ existing.start?.dateTime &&
223
+ existing.end?.dateTime &&
224
+ instant(existing.start.dateTime, existing.start.timeZone || event.start.timeZone) ===
225
+ instant(event.start.dateTime, event.start.timeZone) &&
226
+ instant(existing.end.dateTime, existing.end.timeZone || event.end.timeZone) ===
227
+ instant(event.end.dateTime, event.end.timeZone) &&
228
+ (existing.transparency || 'opaque') === event.transparency &&
229
+ JSON.stringify(existing.recurrence || []) === JSON.stringify(event.recurrence || []) &&
230
+ (existing.description || '') === (event.description || '')
231
+ const sameRevision =
232
+ existing?.extendedProperties?.private?.portalRevision === event.extendedProperties.private.portalRevision
233
+ const externalChangeKey =
234
+ existing && sameRevision && !same ? digest(existing.updated || JSON.stringify(existing)).slice(0, 16) : undefined
235
+ if (same && sameRevision) {
236
+ return { id: event.id, wasMissing: false }
237
+ }
238
+ if (existing && existing.status !== 'cancelled') {
239
+ await api('google', s, u, `${calendarPath(id)}/${event.id}`, 'PUT', event)
240
+ return { id: event.id, externalChangeKey, wasMissing: false }
241
+ }
242
+ // Deleted Google event IDs cannot always be reused. Recover with a deterministic replacement ID.
243
+ for (let attempt = 0; attempt < 4; attempt++) {
244
+ const eventId =
245
+ attempt === 0
246
+ ? event.id
247
+ : `${event.id}r${digest(event.extendedProperties.private.portalRevision + ':' + attempt).slice(0, 8)}`
248
+ const created = await api<{ id: string } | undefined>(
249
+ 'google',
250
+ s,
251
+ u,
252
+ calendarPath(id),
253
+ 'POST',
254
+ { ...event, id: eventId },
255
+ [409]
256
+ )
257
+ if (created) {
258
+ return { id: eventId, wasMissing: true }
259
+ }
260
+ const recovered = await api<{ id: string; status?: string } | undefined>(
261
+ 'google',
262
+ s,
263
+ u,
264
+ `${calendarPath(id)}/${eventId}`,
265
+ 'GET',
266
+ undefined,
267
+ [404, 410]
268
+ )
269
+ if (recovered && recovered.status !== 'cancelled') {
270
+ await api('google', s, u, `${calendarPath(id)}/${eventId}`, 'PUT', { ...event, id: eventId })
271
+ return { id: eventId, wasMissing: false }
272
+ }
273
+ }
274
+ throw new Error('Could not recreate the calendar event')
275
+ },
276
+ async remove(s, u, id, eventId) {
277
+ await api('google', s, u, `${calendarPath(id)}/${eventId}`, 'DELETE', undefined, [404, 410])
278
+ },
279
+ watch: (s, u, id, channelId, token) =>
280
+ api('google', s, u, `${calendarPath(id)}/watch`, 'POST', {
281
+ id: channelId,
282
+ type: 'web_hook',
283
+ address: `${baseUrl()}/api/planning/google/notifications`,
284
+ token
285
+ })
286
+ }
287
+ /** Bounded event reads used for conflict alerts; no external event titles are persisted. */
288
+ export async function externalBusy(
289
+ storeId: string,
290
+ userId: string,
291
+ calendarIds: string[],
292
+ from: Date,
293
+ to: Date
294
+ ): Promise<Interval[]> {
295
+ const busy: Interval[] = []
296
+ for (const calendarId of calendarIds) {
297
+ let pageToken: string | undefined
298
+ do {
299
+ const result: {
300
+ items?: Array<{
301
+ status?: string
302
+ transparency?: string
303
+ start: { dateTime?: string; date?: string }
304
+ end: { dateTime?: string; date?: string }
305
+ extendedProperties?: { private?: Record<string, string> }
306
+ }>
307
+ timeZone?: string
308
+ nextPageToken?: string
309
+ } = await api(
310
+ 'google',
311
+ storeId,
312
+ userId,
313
+ `${calendarPath(calendarId)}?${new URLSearchParams({ timeMin: from.toISOString(), timeMax: to.toISOString(), singleEvents: 'true', maxResults: '2500', ...(pageToken ? { pageToken } : {}) })}`
314
+ )
315
+ for (const e of result.items || []) {
316
+ if (
317
+ e.status === 'cancelled' ||
318
+ e.transparency === 'transparent' ||
319
+ e.extendedProperties?.private?.portalPlanning
320
+ ) {
321
+ continue
322
+ }
323
+ // All-day boundaries follow the connected calendar’s timezone, including DST.
324
+ busy.push({
325
+ start: e.start.dateTime || wallInstant(e.start.date!, '00:00', result.timeZone || 'UTC').toISOString(),
326
+ end: e.end.dateTime || wallInstant(e.end.date!, '00:00', result.timeZone || 'UTC').toISOString()
327
+ })
328
+ }
329
+ pageToken = result.nextPageToken
330
+ } while (pageToken)
331
+ }
332
+ return busy
333
+ }
334
+ export async function externalBusyDetails(
335
+ storeId: string,
336
+ userId: string,
337
+ calendarIds: string[],
338
+ from: Date,
339
+ to: Date
340
+ ): Promise<Array<Interval & { title?: string; allDay?: boolean; startDate?: string; endDate?: string }>> {
341
+ const events: Array<Interval & { title?: string; allDay?: boolean; startDate?: string; endDate?: string }> = []
342
+ for (const calendarId of calendarIds) {
343
+ try {
344
+ let pageToken: string | undefined
345
+ do {
346
+ const result: {
347
+ items?: Array<{
348
+ summary?: string
349
+ status?: string
350
+ transparency?: string
351
+ start: { dateTime?: string; date?: string }
352
+ end: { dateTime?: string; date?: string }
353
+ extendedProperties?: { private?: Record<string, string> }
354
+ }>
355
+ timeZone?: string
356
+ nextPageToken?: string
357
+ } = await api(
358
+ 'google',
359
+ storeId,
360
+ userId,
361
+ `${calendarPath(calendarId)}?${new URLSearchParams({ timeMin: from.toISOString(), timeMax: to.toISOString(), singleEvents: 'true', maxResults: '2500', fields: 'items(summary,status,transparency,start,end,extendedProperties),nextPageToken,timeZone', ...(pageToken ? { pageToken } : {}) })}`
362
+ )
363
+ for (const event of result.items || []) {
364
+ if (
365
+ event.status === 'cancelled' ||
366
+ event.transparency === 'transparent' ||
367
+ event.extendedProperties?.private?.portalPlanning
368
+ ) {
369
+ continue
370
+ }
371
+ events.push({
372
+ start:
373
+ event.start.dateTime || wallInstant(event.start.date!, '00:00', result.timeZone || 'UTC').toISOString(),
374
+ end: event.end.dateTime || wallInstant(event.end.date!, '00:00', result.timeZone || 'UTC').toISOString(),
375
+ title: event.summary?.trim() || undefined,
376
+ allDay: Boolean(event.start.date && event.end.date),
377
+ startDate: event.start.date,
378
+ endDate: event.end.date
379
+ })
380
+ }
381
+ pageToken = result.nextPageToken
382
+ } while (pageToken)
383
+ } catch {
384
+ // Calendars shared as free/busy-only cannot expose titles, but still block time.
385
+ events.push(...(await googleCalendar.busy(storeId, userId, [calendarId], from, to)))
386
+ }
387
+ }
388
+ return events
389
+ }
390
+ export const zoomMeeting: MeetingAdapter = {
391
+ async ensure(s, u, identity, title, start, duration, details, existingId) {
392
+ const agenda = `${details.agenda}\n\nPortal reference: ${identity}`,
393
+ settings = {
394
+ waiting_room: true,
395
+ join_before_host: false,
396
+ meeting_invitees: [{ email: details.inviteeEmail }]
397
+ },
398
+ update = { topic: title, start_time: start.toISOString(), duration, timezone: 'UTC', agenda, settings }
399
+ if (existingId) {
400
+ const meeting = await api<
401
+ | {
402
+ id: number
403
+ join_url: string
404
+ topic: string
405
+ start_time: string
406
+ duration: number
407
+ agenda: string
408
+ settings?: {
409
+ waiting_room?: boolean
410
+ join_before_host?: boolean
411
+ meeting_invitees?: Array<{ email?: string }>
412
+ }
413
+ }
414
+ | undefined
415
+ >('zoom', s, u, `/meetings/${encodeURIComponent(existingId)}`, 'GET', undefined, [404])
416
+ if (meeting) {
417
+ const invitee = details.inviteeEmail.toLowerCase()
418
+ const unchanged =
419
+ meeting.topic === title &&
420
+ Date.parse(meeting.start_time) === start.getTime() &&
421
+ meeting.duration === duration &&
422
+ meeting.agenda === agenda &&
423
+ meeting.settings?.waiting_room === true &&
424
+ meeting.settings?.join_before_host === false &&
425
+ meeting.settings.meeting_invitees?.some((item) => item.email?.toLowerCase() === invitee)
426
+ if (!unchanged) {
427
+ await api('zoom', s, u, `/meetings/${encodeURIComponent(existingId)}`, 'PATCH', update)
428
+ }
429
+ return { id: String(meeting.id), url: meeting.join_url }
430
+ }
431
+ }
432
+ // Recover a successful create whose response was lost, before creating another meeting.
433
+ let page: string | undefined
434
+ do {
435
+ const result: { meetings: Array<{ id: number; agenda?: string; join_url: string }>; next_page_token?: string } =
436
+ await api(
437
+ 'zoom',
438
+ s,
439
+ u,
440
+ `/users/me/meetings?${new URLSearchParams({ type: 'scheduled', page_size: '300', ...(page ? { next_page_token: page } : {}) })}`
441
+ )
442
+ const existing = result.meetings.find(
443
+ (meeting) => meeting.agenda === identity || meeting.agenda?.endsWith(`Portal reference: ${identity}`)
444
+ )
445
+ if (existing) {
446
+ await api('zoom', s, u, `/meetings/${existing.id}`, 'PATCH', update)
447
+ return { id: String(existing.id), url: existing.join_url }
448
+ }
449
+ page = result.next_page_token
450
+ } while (page)
451
+ const value = await api<{ id: number; join_url: string }>('zoom', s, u, '/users/me/meetings', 'POST', {
452
+ ...update,
453
+ type: 2
454
+ })
455
+ return { id: String(value.id), url: value.join_url }
456
+ },
457
+ async remove(s, u, id) {
458
+ await api('zoom', s, u, `/meetings/${encodeURIComponent(id)}`, 'DELETE', undefined, [404])
459
+ }
460
+ }
461
+ let calendar: CalendarAdapter = googleCalendar,
462
+ meeting: MeetingAdapter = zoomMeeting
463
+ export const calendarAdapter = () => calendar
464
+ export const meetingAdapter = () => meeting
465
+ export function registerPlanningAdapters(value: { calendar?: CalendarAdapter; meeting?: MeetingAdapter }) {
466
+ calendar = value.calendar || calendar
467
+ meeting = value.meeting || meeting
468
+ }
@@ -0,0 +1,37 @@
1
+ import type { H3Event } from 'h3'
2
+ import { rows } from '@nuxt-customer-portal/products/server/utils/database'
3
+ import { parseInput } from '@nuxt-customer-portal/products/server/utils/validation'
4
+ import { appointmentListSchema } from '../../shared/validation'
5
+ import { appointmentScope } from './access'
6
+
7
+ export async function listAppointments(event: H3Event, input: unknown) {
8
+ const scope = await appointmentScope(event),
9
+ q = parseInput(appointmentListSchema, input)
10
+ const values = [scope.storeId, scope.staff, scope.userId, `%${q.search}%`, q.status, q.conflicts, q.host]
11
+ const where = `a.store_id=$1 AND ($2::boolean OR o.buyer_id=$3) AND ($4='%%' OR a.snapshot->>'title' ILIKE $4 OR u.name ILIKE $4 OR o.email ILIKE $4 OR o.snapshot->'billing'->>'name' ILIKE $4) AND ($5='all' OR a.status=$5) AND ($6='all' OR a.conflict) AND ($7='all' OR a.user_id=$7)`
12
+ const from =
13
+ 'FROM planning.appointment a JOIN products.orders o ON o.id=a.order_id JOIN public."user" u ON u.id=a.user_id LEFT JOIN planning.provider p ON p.store_id=a.store_id AND p.user_id=a.user_id'
14
+ const [count] = await rows<{ total: number }>(`SELECT count(*)::int AS total ${from} WHERE ${where}`, values)
15
+ const total = count!.total,
16
+ pageSize = 20,
17
+ pageCount = Math.ceil(total / pageSize),
18
+ page = Math.min(q.page, Math.max(1, pageCount))
19
+ const sort = q.sortBy === 'title' ? "a.snapshot->>'title'" : 'a.start_at',
20
+ direction = q.sortOrder === 'asc' ? 'ASC' : 'DESC'
21
+ const items = await rows(
22
+ `SELECT a.id,a.start_at AS start,a.end_at AS end,a.status,a.snapshot->>'title' AS title,a.snapshot->>'meetingProvider' AS "meetingProvider",a.meeting_url AS "meetingUrl",a.conflict,a.effects_error AS "effectsError",a.user_id AS "providerUserId",u.name AS "providerName",u.email AS "providerEmail",COALESCE(a.snapshot->>'timezone',p.timezone,u.timezone,'Europe/Amsterdam') AS "providerTimezone",a.snapshot->>'customerTimezone' AS "customerTimezone",o.email,o.snapshot->'billing'->>'name' AS "customerName",o.snapshot->'billing'->>'country' AS country ${from} WHERE ${where} ORDER BY ${sort} ${direction},a.id ${direction} LIMIT $8 OFFSET $9`,
23
+ [...values, pageSize, (page - 1) * pageSize]
24
+ )
25
+ const hosts = scope.staff
26
+ ? await rows<{ id: string; name: string }>(
27
+ `SELECT DISTINCT u.id,u.name FROM planning.appointment a JOIN public."user" u ON u.id=a.user_id WHERE a.store_id=$1 ORDER BY u.name,u.id`,
28
+ [scope.storeId]
29
+ )
30
+ : []
31
+ return {
32
+ items,
33
+ pagination: { total, page, pageSize, pageCount },
34
+ access: { staff: scope.staff, canManage: scope.canManage },
35
+ hosts
36
+ }
37
+ }