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