@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,199 @@
1
+ import { defineEventHandler, getQuery } from 'h3'
2
+ import { z } from 'zod'
3
+ import { planningAdmin } from '@nuxt-customer-portal/planning/server/utils/access'
4
+ import { getStore } from '@nuxt-customer-portal/products/server/utils/access'
5
+ import { rows } from '@nuxt-customer-portal/products/server/utils/database'
6
+
7
+ const querySchema = z.object({
8
+ page: z.coerce.number().int().min(1).default(1),
9
+ search: z.string().trim().max(200).default(''),
10
+ status: z.enum(['all', 'failed', 'queued', 'succeeded']).default('failed'),
11
+ kind: z.string().trim().max(50).default('all'),
12
+ sort: z.enum(['lastEvent', 'attempts', 'kind']).default('lastEvent'),
13
+ direction: z.enum(['asc', 'desc']).default('desc')
14
+ })
15
+
16
+ const scopedJobs = `WITH scoped AS (
17
+ SELECT j.id,j.kind,j.attempts,j.error,j.available_at,j.completed_at,j.payload->>'trigger' AS trigger,
18
+ j.payload->>'appointmentId' AS appointment_id,
19
+ COALESCE(j.completed_at,j.last_attempt_at,j.created_at) AS last_event_at,
20
+ COALESCE(
21
+ j.payload->>'storeId',
22
+ (SELECT a.store_id FROM planning.appointment a WHERE a.id::text=j.payload->>'appointmentId'),
23
+ (SELECT w.store_id FROM planning.availability w WHERE w.id::text=j.payload->>'id'),
24
+ (SELECT o.store_id FROM products.orders o WHERE o.id::text=j.payload->>'orderId')
25
+ ) AS store_id,
26
+ COALESCE(
27
+ (SELECT a.snapshot->>'title' FROM planning.appointment a WHERE a.id::text=j.payload->>'appointmentId'),
28
+ (SELECT u.name FROM public."user" u WHERE u.id=j.payload->>'userId'),
29
+ j.payload->>'orderId',j.payload->>'id'
30
+ ) AS subject
31
+ FROM planning.job j
32
+ )`
33
+
34
+ export default defineEventHandler(async (event) => {
35
+ await planningAdmin(event)
36
+ const store = await getStore()
37
+ const query = querySchema.parse(getQuery(event))
38
+ const pageSize = 20
39
+ const filters: string[] = ['store_id=$1']
40
+ const params: unknown[] = [store.organization_id]
41
+ if (query.search) {
42
+ params.push(`%${query.search}%`)
43
+ filters.push(
44
+ `(id ILIKE $${params.length} OR kind ILIKE $${params.length} OR COALESCE(error,'') ILIKE $${params.length} OR COALESCE(subject,'') ILIKE $${params.length})`
45
+ )
46
+ }
47
+ if (query.status !== 'all') {
48
+ filters.push(
49
+ {
50
+ failed: 'completed_at IS NULL AND error IS NOT NULL',
51
+ queued: 'completed_at IS NULL AND error IS NULL',
52
+ succeeded: 'completed_at IS NOT NULL'
53
+ }[query.status]
54
+ )
55
+ }
56
+ if (query.kind !== 'all') {
57
+ params.push(query.kind)
58
+ filters.push(`kind=$${params.length}`)
59
+ }
60
+ const order = { lastEvent: 'last_event_at', attempts: 'attempts', kind: 'kind' }[query.sort]
61
+ const direction = query.direction === 'asc' ? 'ASC' : 'DESC'
62
+ const where = filters.join(' AND ')
63
+ const [count] = await rows<{ total: number }>(
64
+ `${scopedJobs} SELECT count(*)::int AS total FROM scoped WHERE ${where}`,
65
+ params
66
+ )
67
+ params.push(pageSize, (query.page - 1) * pageSize)
68
+ const items = await rows<{
69
+ id: string
70
+ kind: string
71
+ attempts: number
72
+ error: string | null
73
+ subject: string | null
74
+ availableAt: Date
75
+ lastEventAt: Date
76
+ completedAt: Date | null
77
+ trigger: string | null
78
+ appointmentId: string | null
79
+ entity?: unknown
80
+ }>(
81
+ `${scopedJobs} SELECT id,kind,attempts,error,subject,trigger,appointment_id AS "appointmentId",available_at AS "availableAt",last_event_at AS "lastEventAt",completed_at AS "completedAt" FROM scoped WHERE ${where} ORDER BY ${order} ${direction},id ${direction} LIMIT $${params.length - 1} OFFSET $${params.length}`,
82
+ params
83
+ )
84
+ const appointmentIds = [...new Set(items.map((item) => item.appointmentId).filter(Boolean))] as string[]
85
+ if (appointmentIds.length) {
86
+ const entities = await rows<{
87
+ id: string
88
+ status: string
89
+ title: string
90
+ startAt: Date
91
+ endAt: Date
92
+ timezone: string
93
+ hostName: string
94
+ hostEmail: string
95
+ customerName: string
96
+ customerEmail: string
97
+ bookingReference: string
98
+ meetingId: string | null
99
+ meetingUrl: string | null
100
+ }>(
101
+ `SELECT a.id::text AS id,a.status,a.snapshot->>'title' AS title,a.start_at AS "startAt",a.end_at AS "endAt",
102
+ COALESCE(a.snapshot->>'timezone','UTC') AS timezone,u.name AS "hostName",u.email AS "hostEmail",
103
+ COALESCE(NULLIF(o.snapshot->'billing'->>'name',''),NULLIF(o.snapshot->'billing'->>'firstName',''),o.email) AS "customerName",
104
+ o.email AS "customerEmail",o.booking_reference AS "bookingReference",a.meeting_id AS "meetingId",a.meeting_url AS "meetingUrl"
105
+ FROM planning.appointment a
106
+ JOIN public."user" u ON u.id=a.user_id
107
+ JOIN products.orders o ON o.id=a.order_id
108
+ WHERE a.store_id=$1 AND a.id=ANY($2::uuid[])`,
109
+ [store.organization_id, appointmentIds]
110
+ )
111
+ const byId = new Map(entities.map((entity) => [entity.id, entity]))
112
+ for (const item of items) {
113
+ if (!item.appointmentId) {
114
+ continue
115
+ }
116
+ const entity = byId.get(item.appointmentId)
117
+ if (!entity) {
118
+ continue
119
+ }
120
+ item.subject = entity.title
121
+ item.entity = { type: 'appointment', ...entity }
122
+ }
123
+ }
124
+ const availabilityIds = items
125
+ .filter((item) => item.kind === 'availability' && item.subject)
126
+ .map((item) => item.subject as string)
127
+ if (availabilityIds.length) {
128
+ const entities = await rows<{
129
+ id: string
130
+ providerName: string
131
+ providerEmail: string
132
+ calendarId: string | null
133
+ data: {
134
+ date: string
135
+ endDate: string | null
136
+ startTime: string
137
+ endTime: string
138
+ timezone: string
139
+ recurring: boolean
140
+ productIds: string[] | null
141
+ }
142
+ productTitles: string[]
143
+ }>(
144
+ `SELECT a.id::text AS id,u.name AS "providerName",u.email AS "providerEmail",
145
+ p.write_calendar_id AS "calendarId",a.data,
146
+ COALESCE((
147
+ SELECT array_agg(COALESCE(NULLIF(product.data->'content'->'en'->>'title',''),product.data->'content'->'nl'->>'title',product.id) ORDER BY product.id)
148
+ FROM products.product product
149
+ WHERE product.store_id=a.store_id
150
+ AND product.id=ANY(
151
+ SELECT jsonb_array_elements_text(
152
+ CASE WHEN jsonb_typeof(a.data->'productIds')='array' THEN a.data->'productIds' ELSE '[]'::jsonb END
153
+ )
154
+ )
155
+ ),'{}') AS "productTitles"
156
+ FROM planning.availability a
157
+ JOIN public."user" u ON u.id=a.user_id
158
+ LEFT JOIN planning.provider p ON p.store_id=a.store_id AND p.user_id=a.user_id
159
+ WHERE a.store_id=$1 AND a.id=ANY($2::uuid[])`,
160
+ [store.organization_id, availabilityIds]
161
+ )
162
+ const byId = new Map(entities.map((entity) => [entity.id, entity]))
163
+ for (const item of items) {
164
+ if (item.kind !== 'availability' || !item.subject) {
165
+ continue
166
+ }
167
+ const entity = byId.get(item.subject)
168
+ if (!entity) {
169
+ continue
170
+ }
171
+ item.subject = entity.providerName
172
+ item.entity = {
173
+ type: 'availability',
174
+ id: entity.id,
175
+ providerName: entity.providerName,
176
+ providerEmail: entity.providerEmail,
177
+ calendarId: entity.calendarId,
178
+ date: entity.data.date,
179
+ endDate: entity.data.endDate,
180
+ startTime: entity.data.startTime,
181
+ endTime: entity.data.endTime,
182
+ timezone: entity.data.timezone,
183
+ recurring: entity.data.recurring,
184
+ allProducts: entity.data.productIds === null,
185
+ productTitles: entity.productTitles
186
+ }
187
+ }
188
+ }
189
+ const total = count?.total || 0
190
+ return {
191
+ items,
192
+ kinds: (
193
+ await rows<{ kind: string }>(`${scopedJobs} SELECT DISTINCT kind FROM scoped WHERE store_id=$1 ORDER BY kind`, [
194
+ store.organization_id
195
+ ])
196
+ ).map((row) => row.kind),
197
+ pagination: { total, page: query.page, pageSize, pageCount: Math.max(1, Math.ceil(total / pageSize)) }
198
+ }
199
+ })
@@ -0,0 +1,6 @@
1
+ import { defineEventHandler } from 'h3'
2
+ import { getPolicy } from '@nuxt-customer-portal/planning/server/utils/management'
3
+
4
+ export default defineEventHandler(async (event) => {
5
+ return getPolicy(event)
6
+ })
@@ -0,0 +1,8 @@
1
+ import { defineEventHandler, readBody } from 'h3'
2
+ import { savePolicy } from '@nuxt-customer-portal/planning/server/utils/management'
3
+ import { sameOrigin } from '@nuxt-customer-portal/planning/server/utils/access'
4
+
5
+ export default defineEventHandler(async (event) => {
6
+ sameOrigin(event)
7
+ return savePolicy(event, await readBody(event))
8
+ })
@@ -0,0 +1,8 @@
1
+ import { defineEventHandler, getRouterParam, readBody } from 'h3'
2
+ import { sameOrigin } from '@nuxt-customer-portal/planning/server/utils/access'
3
+ import { saveProductPlanning } from '@nuxt-customer-portal/planning/server/utils/management'
4
+
5
+ export default defineEventHandler(async (event) => {
6
+ sameOrigin(event)
7
+ return saveProductPlanning(event, getRouterParam(event, 'id')!, await readBody(event))
8
+ })
@@ -0,0 +1,8 @@
1
+ import { defineEventHandler, readBody, getRouterParam } from 'h3'
2
+ import { setEnabled } from '@nuxt-customer-portal/planning/server/utils/management'
3
+ import { sameOrigin } from '@nuxt-customer-portal/planning/server/utils/access'
4
+
5
+ export default defineEventHandler(async (event) => {
6
+ sameOrigin(event)
7
+ return setEnabled(event, getRouterParam(event, 'userId')!, await readBody(event))
8
+ })
@@ -0,0 +1,6 @@
1
+ import { defineEventHandler } from 'h3'
2
+ import { listProviders } from '@nuxt-customer-portal/planning/server/utils/management'
3
+
4
+ export default defineEventHandler(async (event) => {
5
+ return listProviders(event)
6
+ })
@@ -0,0 +1,31 @@
1
+ import { defineEventHandler } from 'h3'
2
+ import { planningAdmin, sameOrigin } from '@nuxt-customer-portal/planning/server/utils/access'
3
+ import { rows } from '@nuxt-customer-portal/products/server/utils/database'
4
+ import { runJobs } from '@nuxt-customer-portal/planning/server/utils/jobs'
5
+ import { getStore } from '@nuxt-customer-portal/products/server/utils/access'
6
+
7
+ export default defineEventHandler(async (event) => {
8
+ sameOrigin(event)
9
+ await planningAdmin(event)
10
+ const store = await getStore()
11
+ await rows(
12
+ `UPDATE planning.job j SET available_at=now() WHERE completed_at IS NULL AND COALESCE(
13
+ j.payload->>'storeId',
14
+ (SELECT a.store_id FROM planning.appointment a WHERE a.id::text=j.payload->>'appointmentId'),
15
+ (SELECT w.store_id FROM planning.availability w WHERE w.id::text=j.payload->>'id'),
16
+ (SELECT o.store_id FROM products.orders o WHERE o.id::text=j.payload->>'orderId')
17
+ )=$1`,
18
+ [store.organization_id]
19
+ )
20
+ let completed = 0,
21
+ failed = 0
22
+ for (let batch = 0; batch < 10; batch++) {
23
+ const result = await runJobs(20, store.organization_id)
24
+ completed += result.completed
25
+ failed += result.failed
26
+ if (result.completed + result.failed < 20) {
27
+ break
28
+ }
29
+ }
30
+ return { completed, failed }
31
+ })
@@ -0,0 +1,19 @@
1
+ import { defineEventHandler, getRouterParam, getQuery } from 'h3'
2
+ import { appointmentAccess } from '../../../../utils/access'
3
+ import { available } from '../../../../utils/booking'
4
+ import { canChange } from '../../../../../shared/availability'
5
+
6
+ export default defineEventHandler(async (event) => {
7
+ const { appointment: a, staff, canManage } = await appointmentAccess(event, getRouterParam(event, 'id')!)
8
+ if (
9
+ !canManage ||
10
+ a.status !== 'confirmed' ||
11
+ (!staff && !canChange(a.start_at, new Date(), a.snapshot.policy.rescheduleCutoffMinutes))
12
+ ) {
13
+ return []
14
+ }
15
+ return available(a.product_id, getQuery(event), {
16
+ policy: staff ? { ...a.snapshot.policy, minimumNoticeMinutes: 0 } : a.snapshot.policy,
17
+ duration: a.snapshot.durationMinutes
18
+ })
19
+ })
@@ -0,0 +1,8 @@
1
+ import { defineEventHandler, getRouterParam } from 'h3'
2
+ import { cancel } from '@nuxt-customer-portal/planning/server/utils/management'
3
+ import { sameOrigin } from '@nuxt-customer-portal/planning/server/utils/access'
4
+
5
+ export default defineEventHandler(async (event) => {
6
+ sameOrigin(event)
7
+ return cancel(event, getRouterParam(event, 'id')!)
8
+ })
@@ -0,0 +1,17 @@
1
+ import { getQuery, getRouterParam, setHeader } from 'h3'
2
+ import { appointmentAccess } from '@nuxt-customer-portal/planning/server/utils/access'
3
+ import { getOrder, hasAccess } from '@nuxt-customer-portal/products/server/utils/orders'
4
+ import { sendAsset } from '@nuxt-customer-portal/products/server/utils/storage'
5
+
6
+ export default defineEventHandler(async (event) => {
7
+ const { appointment } = await appointmentAccess(event, getRouterParam(event, 'id')!)
8
+ const assetId = getRouterParam(event, 'assetId')!
9
+ const order = await getOrder(appointment.order_id)
10
+ const line = order?.lines.find((item) => hasAccess(order, item) && item.snapshot.product.fileIds.includes(assetId))
11
+ if (!order || !line) {
12
+ throw createError({ statusCode: 404 })
13
+ }
14
+ const fileName = line.snapshot.product.fileNames?.[assetId]?.[order.snapshot.locale]
15
+ setHeader(event, 'Cache-Control', 'private, no-store')
16
+ return sendAsset(event, assetId, getQuery(event).download === '1', fileName)
17
+ })
@@ -0,0 +1,28 @@
1
+ import { getRouterParam } from 'h3'
2
+ import { appointmentAccess } from '@nuxt-customer-portal/planning/server/utils/access'
3
+ import { rows } from '@nuxt-customer-portal/products/server/utils/database'
4
+ import { getOrder, hasAccess } from '@nuxt-customer-portal/products/server/utils/orders'
5
+
6
+ export default defineEventHandler(async (event) => {
7
+ const { appointment } = await appointmentAccess(event, getRouterParam(event, 'id')!)
8
+ const order = await getOrder(appointment.order_id)
9
+ if (!order) {
10
+ throw createError({ statusCode: 404 })
11
+ }
12
+ const accessibleLines = order.lines.filter((line) => hasAccess(order, line))
13
+ const fileIds = [...new Set(accessibleLines.flatMap((line) => line.snapshot.product.fileIds))]
14
+ if (!fileIds.length) {
15
+ return []
16
+ }
17
+ const assets = await rows<{ id: string; name: string; content_type: string; size: number }>(
18
+ 'SELECT id,name,content_type,size FROM products.asset WHERE ready AND id=ANY($1::text[])',
19
+ [fileIds]
20
+ )
21
+ return assets.map((asset) => ({
22
+ ...asset,
23
+ name:
24
+ accessibleLines.find((line) => line.snapshot.product.fileIds.includes(asset.id))?.snapshot.product.fileNames?.[
25
+ asset.id
26
+ ]?.[order.snapshot.locale] || asset.name
27
+ }))
28
+ })
@@ -0,0 +1,8 @@
1
+ import { defineEventHandler, getRouterParam } from 'h3'
2
+ import { abandonChange } from '@nuxt-customer-portal/planning/server/utils/management'
3
+ import { sameOrigin } from '@nuxt-customer-portal/planning/server/utils/access'
4
+
5
+ export default defineEventHandler(async (event) => {
6
+ sameOrigin(event)
7
+ return abandonChange(event, getRouterParam(event, 'id')!)
8
+ })
@@ -0,0 +1,8 @@
1
+ import { defineEventHandler, getRouterParam, readBody } from 'h3'
2
+ import { sameOrigin } from '@nuxt-customer-portal/planning/server/utils/access'
3
+ import { staffReschedule } from '@nuxt-customer-portal/planning/server/utils/staff-appointments'
4
+
5
+ export default defineEventHandler(async (event) => {
6
+ sameOrigin(event)
7
+ return staffReschedule(event, getRouterParam(event, 'id')!, await readBody(event))
8
+ })
@@ -0,0 +1,6 @@
1
+ import { defineEventHandler, getRouterParam } from 'h3'
2
+ import { appointmentDetails } from '@nuxt-customer-portal/planning/server/utils/management'
3
+
4
+ export default defineEventHandler(async (event) => {
5
+ return appointmentDetails(event, getRouterParam(event, 'id')!)
6
+ })
@@ -0,0 +1,4 @@
1
+ import { defineEventHandler, getQuery } from 'h3'
2
+ import { listAppointments } from '@nuxt-customer-portal/planning/server/utils/appointments'
3
+
4
+ export default defineEventHandler((event) => listAppointments(event, getQuery(event)))
@@ -0,0 +1,12 @@
1
+ import { defineEventHandler, getQuery, getRouterParam } from 'h3'
2
+ import { deleteWindow } from '@nuxt-customer-portal/planning/server/utils/management'
3
+ import { sameOrigin } from '@nuxt-customer-portal/planning/server/utils/access'
4
+
5
+ export default defineEventHandler(async (event) => {
6
+ sameOrigin(event)
7
+ return deleteWindow(
8
+ event,
9
+ getRouterParam(event, 'id')!,
10
+ typeof getQuery(event).occurrence === 'string' ? String(getQuery(event).occurrence) : undefined
11
+ )
12
+ })
@@ -0,0 +1,8 @@
1
+ import { defineEventHandler, readBody, getRouterParam } from 'h3'
2
+ import { saveWindow } from '@nuxt-customer-portal/planning/server/utils/management'
3
+ import { sameOrigin } from '@nuxt-customer-portal/planning/server/utils/access'
4
+
5
+ export default defineEventHandler(async (event) => {
6
+ sameOrigin(event)
7
+ return saveWindow(event, await readBody(event), getRouterParam(event, 'id')!)
8
+ })
@@ -0,0 +1,6 @@
1
+ import { defineEventHandler } from 'h3'
2
+ import { windows } from '@nuxt-customer-portal/planning/server/utils/management'
3
+
4
+ export default defineEventHandler(async (event) => {
5
+ return windows(event)
6
+ })
@@ -0,0 +1,8 @@
1
+ import { defineEventHandler, readBody } from 'h3'
2
+ import { saveWindow } from '@nuxt-customer-portal/planning/server/utils/management'
3
+ import { sameOrigin } from '@nuxt-customer-portal/planning/server/utils/access'
4
+
5
+ export default defineEventHandler(async (event) => {
6
+ sameOrigin(event)
7
+ return saveWindow(event, await readBody(event))
8
+ })
@@ -0,0 +1,29 @@
1
+ import { defineEventHandler, getQuery } from 'h3'
2
+ import { z } from 'zod'
3
+ import { parseInput } from '@nuxt-customer-portal/products/server/utils/validation'
4
+ import { rows } from '@nuxt-customer-portal/products/server/utils/database'
5
+ import { providerAccess } from '@nuxt-customer-portal/planning/server/utils/access'
6
+ import { externalBusyDetails } from '@nuxt-customer-portal/planning/server/utils/adapters'
7
+
8
+ const querySchema = z
9
+ .object({ from: z.iso.datetime({ offset: true }), to: z.iso.datetime({ offset: true }) })
10
+ .refine(
11
+ ({ from, to }) => Date.parse(to) > Date.parse(from) && Date.parse(to) - Date.parse(from) <= 14 * 86400000,
12
+ 'Choose a range of at most 14 days'
13
+ )
14
+
15
+ export default defineEventHandler(async (event) => {
16
+ const { storeId, userId } = await providerAccess(event)
17
+ const query = parseInput(querySchema, getQuery(event))
18
+ const [provider] = await rows<{ busy_calendar_ids: string[]; write_calendar_id: string | null }>(
19
+ 'SELECT busy_calendar_ids,write_calendar_id FROM planning.provider WHERE store_id=$1 AND user_id=$2',
20
+ [storeId, userId]
21
+ )
22
+ const calendarIds = [
23
+ ...new Set([...(provider?.busy_calendar_ids || []), provider?.write_calendar_id].filter(Boolean))
24
+ ] as string[]
25
+ if (!calendarIds.length) {
26
+ return []
27
+ }
28
+ return externalBusyDetails(storeId, userId, calendarIds, new Date(query.from), new Date(query.to))
29
+ })
@@ -0,0 +1,8 @@
1
+ import { defineEventHandler } from 'h3'
2
+ import { providerAccess } from '@nuxt-customer-portal/planning/server/utils/access'
3
+ import { calendarAdapter } from '@nuxt-customer-portal/planning/server/utils/adapters'
4
+
5
+ export default defineEventHandler(async (event) => {
6
+ const { storeId, userId } = await providerAccess(event)
7
+ return calendarAdapter().calendars(storeId, userId)
8
+ })
@@ -0,0 +1,36 @@
1
+ import { defineEventHandler } from 'h3'
2
+ import { appointmentScope } from '@nuxt-customer-portal/planning/server/utils/access'
3
+ import { rows } from '@nuxt-customer-portal/products/server/utils/database'
4
+ import type { PlanningDashboardAppointment, PlanningDashboardDto } from '@nuxt-customer-portal/planning/shared/types'
5
+
6
+ export default defineEventHandler(async (event): Promise<PlanningDashboardDto> => {
7
+ const scope = await appointmentScope(event)
8
+ const values = [scope.storeId, scope.staff, scope.userId]
9
+ const audience = 'a.store_id=$1 AND ($2::boolean AND a.user_id=$3 OR NOT $2::boolean AND o.buyer_id=$3)'
10
+ const from =
11
+ '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'
12
+ const [summary] = await rows<{ upcomingCount: number; todayCount: number; conflictCount: number }>(
13
+ `SELECT
14
+ count(*) FILTER (WHERE a.status='confirmed' AND a.end_at>=now())::int AS "upcomingCount",
15
+ count(*) FILTER (WHERE a.status='confirmed' AND a.start_at>=date_trunc('day',now()) AND a.start_at<date_trunc('day',now())+interval '1 day')::int AS "todayCount",
16
+ count(*) FILTER (WHERE a.status='confirmed' AND a.end_at>=now() AND a.conflict)::int AS "conflictCount"
17
+ ${from} WHERE ${audience}`,
18
+ values
19
+ )
20
+ const appointments = await rows<PlanningDashboardAppointment>(
21
+ `SELECT a.id,a.start_at AS start,a.end_at AS end,a.snapshot->>'title' AS title,
22
+ a.snapshot->>'meetingProvider' AS "meetingProvider",a.meeting_url AS "meetingUrl",a.conflict,
23
+ u.name AS "providerName",COALESCE(a.snapshot->>'timezone',p.timezone,u.timezone,'Europe/Amsterdam') AS "providerTimezone",
24
+ o.snapshot->'billing'->>'name' AS "customerName",a.snapshot->>'customerTimezone' AS "customerTimezone"
25
+ ${from} WHERE ${audience} AND a.status='confirmed' AND a.end_at>=now()
26
+ ORDER BY a.start_at,a.id LIMIT 3`,
27
+ values
28
+ )
29
+ return {
30
+ access: { staff: scope.staff, canManage: scope.canManage },
31
+ upcomingCount: summary?.upcomingCount ?? 0,
32
+ todayCount: summary?.todayCount ?? 0,
33
+ conflictCount: scope.staff ? (summary?.conflictCount ?? 0) : 0,
34
+ appointments
35
+ }
36
+ })
@@ -0,0 +1,24 @@
1
+ import { defineEventHandler, getHeader, setResponseStatus } from 'h3'
2
+ import { rows } from '@nuxt-customer-portal/products/server/utils/database'
3
+ import { pool } from '@nuxt-customer-portal/core/server/utils/db'
4
+ import { digest } from '../../../utils/crypto'
5
+ import { enqueue } from '../../../utils/booking'
6
+
7
+ export default defineEventHandler(async (event) => {
8
+ const [watch] = await rows<{ store_id: string; user_id: string }>(
9
+ 'SELECT store_id,user_id FROM planning.watch WHERE id::text=$1 AND token_hash=$2 AND (resource_id IS NULL OR resource_id=$3) AND expires_at>now()',
10
+ [
11
+ getHeader(event, 'x-goog-channel-id') || '',
12
+ digest(getHeader(event, 'x-goog-channel-token') || ''),
13
+ getHeader(event, 'x-goog-resource-id') || ''
14
+ ]
15
+ )
16
+ if (watch) {
17
+ await enqueue(pool, `sync:${watch.store_id}:${watch.user_id}:${Math.floor(Date.now() / 1000)}`, 'sync', {
18
+ storeId: watch.store_id,
19
+ userId: watch.user_id,
20
+ trigger: 'notification'
21
+ })
22
+ }
23
+ setResponseStatus(event, 204)
24
+ })
@@ -0,0 +1,18 @@
1
+ import { defineEventHandler, getRouterParam, getQuery, sendRedirect } from 'h3'
2
+ import { finishOAuth, oauthProvider } from '../../../../utils/oauth'
3
+
4
+ export default defineEventHandler(async (event) => {
5
+ const query = getQuery(event)
6
+ try {
7
+ await finishOAuth(
8
+ event,
9
+ oauthProvider(getRouterParam(event, 'provider')),
10
+ String(query.state || ''),
11
+ String(query.code || '')
12
+ )
13
+ return sendRedirect(event, '/planning/settings?tab=meeting&connection=connected', 303)
14
+ } catch (error) {
15
+ console.error('Planning OAuth callback failed', error)
16
+ return sendRedirect(event, '/planning/settings?tab=meeting&connection=error', 303)
17
+ }
18
+ })
@@ -0,0 +1,8 @@
1
+ import { defineEventHandler, getRouterParam } from 'h3'
2
+ import { startOAuth, oauthProvider } from '../../../../utils/oauth'
3
+ import { sameOrigin } from '../../../../utils/access'
4
+
5
+ export default defineEventHandler(async (event) => {
6
+ sameOrigin(event)
7
+ return startOAuth(event, oauthProvider(getRouterParam(event, 'provider')))
8
+ })
@@ -0,0 +1,15 @@
1
+ import { defineEventHandler, getRouterParam } from 'h3'
2
+ import { providerAccess, sameOrigin } from '../../../utils/access'
3
+ import { oauthProvider } from '../../../utils/oauth'
4
+ import { rows } from '@nuxt-customer-portal/products/server/utils/database'
5
+
6
+ export default defineEventHandler(async (event) => {
7
+ sameOrigin(event)
8
+ const { storeId, userId } = await providerAccess(event)
9
+ await rows('DELETE FROM planning.connection WHERE store_id=$1 AND user_id=$2 AND provider=$3', [
10
+ storeId,
11
+ userId,
12
+ oauthProvider(getRouterParam(event, 'provider'))
13
+ ])
14
+ return { success: true }
15
+ })
@@ -0,0 +1,11 @@
1
+ import { defineEventHandler } from 'h3'
2
+ import { appointmentScope } from '@nuxt-customer-portal/planning/server/utils/access'
3
+ import { rows } from '@nuxt-customer-portal/products/server/utils/database'
4
+
5
+ export default defineEventHandler(async (event) => {
6
+ const scope = await appointmentScope(event)
7
+ return rows(
8
+ `SELECT slug,COALESCE(NULLIF(data->'content'->'en'->>'title',''),data->'content'->'nl'->>'title') AS title FROM products.product WHERE store_id=$1 AND data->>'status'='published' AND data->>'type'='service' AND data->'planning'->>'enabled'='true' ORDER BY title,id`,
9
+ [scope.storeId]
10
+ )
11
+ })
@@ -0,0 +1,11 @@
1
+ import { defineEventHandler } from 'h3'
2
+ import { providerAccess } from '@nuxt-customer-portal/planning/server/utils/access'
3
+ import { rows } from '@nuxt-customer-portal/products/server/utils/database'
4
+
5
+ export default defineEventHandler(async (event) => {
6
+ const { storeId, userId } = await providerAccess(event)
7
+ return rows(
8
+ `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.user_id AS "providerUserId",u.name AS "providerName",u.email AS "providerEmail",a.snapshot->>'timezone' AS "providerTimezone",a.snapshot->>'customerTimezone' AS "customerTimezone",a.conflict,a.effects_error AS "effectsError",o.email,o.snapshot->'billing'->>'name' AS "customerName",o.snapshot->'billing'->>'country' AS country FROM planning.appointment a JOIN products.orders o ON o.id=a.order_id JOIN public."user" u ON u.id=a.user_id WHERE a.store_id=$1 AND a.user_id=$2 AND a.end_at>now()-interval '7 days' ORDER BY start_at LIMIT 200`,
9
+ [storeId, userId]
10
+ )
11
+ })
@@ -0,0 +1,6 @@
1
+ import { defineEventHandler } from 'h3'
2
+ import { ownSettings } from '@nuxt-customer-portal/planning/server/utils/management'
3
+
4
+ export default defineEventHandler(async (event) => {
5
+ return ownSettings(event)
6
+ })
@@ -0,0 +1,8 @@
1
+ import { defineEventHandler, readBody } from 'h3'
2
+ import { saveOwnSettings } from '@nuxt-customer-portal/planning/server/utils/management'
3
+ import { sameOrigin } from '@nuxt-customer-portal/planning/server/utils/access'
4
+
5
+ export default defineEventHandler(async (event) => {
6
+ sameOrigin(event)
7
+ return saveOwnSettings(event, await readBody(event))
8
+ })
@@ -0,0 +1,8 @@
1
+ import { defineEventHandler, getQuery, getRouterParam } from 'h3'
2
+ import { available } from '@nuxt-customer-portal/planning/server/utils/booking'
3
+ import { publicLimit } from '@nuxt-customer-portal/products/server/utils/access'
4
+
5
+ export default defineEventHandler(async (event) => {
6
+ await publicLimit(event)
7
+ return available(getRouterParam(event, 'productId')!, getQuery(event))
8
+ })
@@ -0,0 +1,11 @@
1
+ import { defineEventHandler, readBody } from 'h3'
2
+ import { releaseHold } from '../../../utils/booking'
3
+ import { sameOrigin } from '../../../utils/access'
4
+ import { publicLimit } from '@nuxt-customer-portal/products/server/utils/access'
5
+
6
+ export default defineEventHandler(async (event) => {
7
+ await publicLimit(event)
8
+ sameOrigin(event)
9
+ await releaseHold(event, await readBody(event))
10
+ return { released: true }
11
+ })