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