@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,36 @@
|
|
|
1
|
+
import { defineEventHandler, getQuery, setHeader, createError } from 'h3'
|
|
2
|
+
import { rows } from '@nuxt-customer-portal/products/server/utils/database'
|
|
3
|
+
import { publicLimit } from '@nuxt-customer-portal/products/server/utils/access'
|
|
4
|
+
import { parseInput } from '@nuxt-customer-portal/products/server/utils/validation'
|
|
5
|
+
import { holdHash } from '../../../utils/booking'
|
|
6
|
+
import { holdCredentialSchema } from '../../../../shared/validation'
|
|
7
|
+
|
|
8
|
+
export default defineEventHandler(async (event) => {
|
|
9
|
+
await publicLimit(event)
|
|
10
|
+
setHeader(event, 'Cache-Control', 'no-store')
|
|
11
|
+
const input = parseInput(holdCredentialSchema, getQuery(event))
|
|
12
|
+
const [hold] = await rows<{
|
|
13
|
+
product_id: string
|
|
14
|
+
start_at: Date
|
|
15
|
+
end_at: Date
|
|
16
|
+
expires_at: Date
|
|
17
|
+
name: string
|
|
18
|
+
snapshot: { unitAmount: number; currency: string; customerTimezone: string }
|
|
19
|
+
}>(
|
|
20
|
+
`SELECT r.*,u.name FROM planning.reservation r JOIN public."user" u ON u.id=r.user_id WHERE r.token_hash=$1 AND r.status='reserved' AND r.expires_at>now()`,
|
|
21
|
+
[holdHash(event, input.holdToken)]
|
|
22
|
+
)
|
|
23
|
+
if (!hold) {
|
|
24
|
+
throw createError({ statusCode: 409, message: 'Reservation expired' })
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
productId: hold.product_id,
|
|
28
|
+
start: hold.start_at,
|
|
29
|
+
end: hold.end_at,
|
|
30
|
+
expiresAt: hold.expires_at,
|
|
31
|
+
providerName: hold.name,
|
|
32
|
+
amount: hold.snapshot.unitAmount,
|
|
33
|
+
currency: hold.snapshot.currency,
|
|
34
|
+
customerTimezone: hold.snapshot.customerTimezone
|
|
35
|
+
}
|
|
36
|
+
})
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { defineEventHandler, readBody } from 'h3'
|
|
2
|
+
import { reserve } from '@nuxt-customer-portal/planning/server/utils/booking'
|
|
3
|
+
import { sameOrigin } from '@nuxt-customer-portal/planning/server/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
|
+
return reserve(event, await readBody(event))
|
|
10
|
+
})
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import {
|
|
2
|
+
pgSchema,
|
|
3
|
+
text,
|
|
4
|
+
uuid,
|
|
5
|
+
boolean,
|
|
6
|
+
integer,
|
|
7
|
+
timestamp,
|
|
8
|
+
jsonb,
|
|
9
|
+
primaryKey,
|
|
10
|
+
foreignKey,
|
|
11
|
+
index,
|
|
12
|
+
uniqueIndex,
|
|
13
|
+
check,
|
|
14
|
+
type AnyPgColumn
|
|
15
|
+
} from 'drizzle-orm/pg-core'
|
|
16
|
+
import { sql } from 'drizzle-orm'
|
|
17
|
+
import { organization, user } from '@nuxt-customer-portal/core/schema'
|
|
18
|
+
import { product, orders, orderLine } from '@nuxt-customer-portal/products/schema'
|
|
19
|
+
import type { AppointmentSnapshot, AvailabilityWindow, PlanningPolicy } from '../../../shared/types'
|
|
20
|
+
|
|
21
|
+
const schema = pgSchema('planning')
|
|
22
|
+
const instant = (name: string) => timestamp(name, { withTimezone: true })
|
|
23
|
+
export const settings = schema.table('settings', {
|
|
24
|
+
storeId: text('store_id')
|
|
25
|
+
.primaryKey()
|
|
26
|
+
.references(() => organization.id),
|
|
27
|
+
policy: jsonb('policy').$type<PlanningPolicy>().notNull()
|
|
28
|
+
})
|
|
29
|
+
export const provider = schema.table(
|
|
30
|
+
'provider',
|
|
31
|
+
{
|
|
32
|
+
storeId: text('store_id')
|
|
33
|
+
.notNull()
|
|
34
|
+
.references(() => organization.id),
|
|
35
|
+
userId: text('user_id')
|
|
36
|
+
.notNull()
|
|
37
|
+
.references(() => user.id),
|
|
38
|
+
enabled: boolean('enabled').notNull().default(false),
|
|
39
|
+
timezone: text('timezone').notNull().default('Europe/Amsterdam'),
|
|
40
|
+
graceMinutes: integer('grace_minutes').notNull().default(0),
|
|
41
|
+
availabilityCalendarTitle: text('availability_calendar_title').notNull().default('Portal availability'),
|
|
42
|
+
busyCalendarIds: text('busy_calendar_ids')
|
|
43
|
+
.array()
|
|
44
|
+
.notNull()
|
|
45
|
+
.default(sql`'{}'`),
|
|
46
|
+
writeCalendarId: text('write_calendar_id')
|
|
47
|
+
},
|
|
48
|
+
(t) => [
|
|
49
|
+
primaryKey({ columns: [t.storeId, t.userId] }),
|
|
50
|
+
check('provider_grace_minutes_check', sql`${t.graceMinutes} BETWEEN 0 AND 1440`)
|
|
51
|
+
]
|
|
52
|
+
)
|
|
53
|
+
const providerFk = (t: { storeId: AnyPgColumn; userId: AnyPgColumn }) =>
|
|
54
|
+
foreignKey({ columns: [t.storeId, t.userId], foreignColumns: [provider.storeId, provider.userId] })
|
|
55
|
+
export const connection = schema.table(
|
|
56
|
+
'connection',
|
|
57
|
+
{
|
|
58
|
+
storeId: text('store_id').notNull(),
|
|
59
|
+
userId: text('user_id').notNull(),
|
|
60
|
+
provider: text('provider').notNull(),
|
|
61
|
+
credentials: text('credentials').notNull(),
|
|
62
|
+
externalUserId: text('external_user_id').notNull(),
|
|
63
|
+
externalUserLabel: text('external_user_label'),
|
|
64
|
+
healthy: boolean('healthy').notNull().default(true),
|
|
65
|
+
error: text('error')
|
|
66
|
+
},
|
|
67
|
+
(t) => [
|
|
68
|
+
primaryKey({ columns: [t.storeId, t.userId, t.provider] }),
|
|
69
|
+
providerFk(t),
|
|
70
|
+
check('connection_provider_check', sql`${t.provider} IN ('google','zoom')`)
|
|
71
|
+
]
|
|
72
|
+
)
|
|
73
|
+
export const oauthState = schema.table(
|
|
74
|
+
'oauth_state',
|
|
75
|
+
{
|
|
76
|
+
id: text('id').primaryKey(),
|
|
77
|
+
storeId: text('store_id').notNull(),
|
|
78
|
+
userId: text('user_id').notNull(),
|
|
79
|
+
provider: text('provider').notNull(),
|
|
80
|
+
expiresAt: instant('expires_at').notNull()
|
|
81
|
+
},
|
|
82
|
+
(t) => [providerFk(t)]
|
|
83
|
+
)
|
|
84
|
+
export const availability = schema.table(
|
|
85
|
+
'availability',
|
|
86
|
+
{
|
|
87
|
+
id: uuid('id').primaryKey(),
|
|
88
|
+
storeId: text('store_id').notNull(),
|
|
89
|
+
userId: text('user_id').notNull(),
|
|
90
|
+
data: jsonb('data').$type<AvailabilityWindow>().notNull(),
|
|
91
|
+
revision: integer('revision').notNull().default(1),
|
|
92
|
+
deleted: boolean('deleted').notNull().default(false),
|
|
93
|
+
calendarEventId: text('calendar_event_id'),
|
|
94
|
+
calendarId: text('calendar_id')
|
|
95
|
+
},
|
|
96
|
+
(t) => [providerFk(t)]
|
|
97
|
+
)
|
|
98
|
+
export const reservation = schema.table(
|
|
99
|
+
'reservation',
|
|
100
|
+
{
|
|
101
|
+
id: uuid('id').primaryKey(),
|
|
102
|
+
storeId: text('store_id').notNull(),
|
|
103
|
+
userId: text('user_id').notNull(),
|
|
104
|
+
productId: text('product_id')
|
|
105
|
+
.notNull()
|
|
106
|
+
.references(() => product.id),
|
|
107
|
+
tokenHash: text('token_hash').notNull().unique(),
|
|
108
|
+
startAt: instant('start_at').notNull(),
|
|
109
|
+
endAt: instant('end_at').notNull(),
|
|
110
|
+
blockedUntil: instant('blocked_until').notNull(),
|
|
111
|
+
expiresAt: instant('expires_at').notNull(),
|
|
112
|
+
confirmedAt: instant('confirmed_at'),
|
|
113
|
+
status: text('status').notNull(),
|
|
114
|
+
orderId: text('order_id')
|
|
115
|
+
.unique()
|
|
116
|
+
.references(() => orders.id),
|
|
117
|
+
replacesId: uuid('replaces_id'),
|
|
118
|
+
snapshot: jsonb('snapshot').$type<AppointmentSnapshot>().notNull()
|
|
119
|
+
},
|
|
120
|
+
(t) => [
|
|
121
|
+
providerFk(t),
|
|
122
|
+
foreignKey({ columns: [t.replacesId], foreignColumns: [appointment.id as AnyPgColumn] }),
|
|
123
|
+
check('reservation_status_check', sql`${t.status} IN ('reserved','confirmed','expired','cancelled','superseded')`),
|
|
124
|
+
check('reservation_check', sql`${t.startAt} < ${t.endAt} AND ${t.endAt} <= ${t.blockedUntil}`),
|
|
125
|
+
index('reservation_expiry')
|
|
126
|
+
.on(t.expiresAt)
|
|
127
|
+
.where(sql`${t.status}='reserved'`)
|
|
128
|
+
]
|
|
129
|
+
)
|
|
130
|
+
// The provider/time exclusion constraint is installed by SQL migration (Drizzle has no exclusion builder).
|
|
131
|
+
export const appointment = schema.table(
|
|
132
|
+
'appointment',
|
|
133
|
+
{
|
|
134
|
+
id: uuid('id').primaryKey(),
|
|
135
|
+
storeId: text('store_id').notNull(),
|
|
136
|
+
reservationId: uuid('reservation_id')
|
|
137
|
+
.notNull()
|
|
138
|
+
.unique()
|
|
139
|
+
.references((): AnyPgColumn => reservation.id),
|
|
140
|
+
orderLineId: text('order_line_id')
|
|
141
|
+
.notNull()
|
|
142
|
+
.unique()
|
|
143
|
+
.references(() => orderLine.id),
|
|
144
|
+
orderId: text('order_id')
|
|
145
|
+
.notNull()
|
|
146
|
+
.references(() => orders.id),
|
|
147
|
+
userId: text('user_id')
|
|
148
|
+
.notNull()
|
|
149
|
+
.references(() => user.id),
|
|
150
|
+
productId: text('product_id')
|
|
151
|
+
.notNull()
|
|
152
|
+
.references(() => product.id),
|
|
153
|
+
startAt: instant('start_at').notNull(),
|
|
154
|
+
endAt: instant('end_at').notNull(),
|
|
155
|
+
status: text('status').notNull(),
|
|
156
|
+
changes: integer('changes').notNull().default(0),
|
|
157
|
+
revision: integer('revision').notNull().default(1),
|
|
158
|
+
snapshot: jsonb('snapshot').$type<AppointmentSnapshot>().notNull(),
|
|
159
|
+
meetingId: text('meeting_id'),
|
|
160
|
+
meetingUrl: text('meeting_url'),
|
|
161
|
+
calendarEventId: text('calendar_event_id'),
|
|
162
|
+
calendarId: text('calendar_id'),
|
|
163
|
+
calendarUserId: text('calendar_user_id'),
|
|
164
|
+
conflict: boolean('conflict').notNull().default(false),
|
|
165
|
+
effectsError: text('effects_error')
|
|
166
|
+
},
|
|
167
|
+
(t) => [check('appointment_status_check', sql`${t.status} IN ('confirmed','cancelled')`)]
|
|
168
|
+
)
|
|
169
|
+
export const audit = schema.table('audit', {
|
|
170
|
+
id: uuid('id').primaryKey(),
|
|
171
|
+
appointmentId: uuid('appointment_id')
|
|
172
|
+
.notNull()
|
|
173
|
+
.references(() => appointment.id),
|
|
174
|
+
actorId: text('actor_id').references(() => user.id),
|
|
175
|
+
action: text('action').notNull(),
|
|
176
|
+
data: jsonb('data').notNull().default({}),
|
|
177
|
+
createdAt: instant('created_at').notNull().defaultNow()
|
|
178
|
+
})
|
|
179
|
+
export const job = schema.table(
|
|
180
|
+
'job',
|
|
181
|
+
{
|
|
182
|
+
id: text('id').primaryKey(),
|
|
183
|
+
kind: text('kind').notNull(),
|
|
184
|
+
payload: jsonb('payload').notNull(),
|
|
185
|
+
attempts: integer('attempts').notNull().default(0),
|
|
186
|
+
availableAt: instant('available_at').notNull().defaultNow(),
|
|
187
|
+
createdAt: instant('created_at').notNull().defaultNow(),
|
|
188
|
+
lastAttemptAt: instant('last_attempt_at'),
|
|
189
|
+
completedAt: instant('completed_at'),
|
|
190
|
+
error: text('error')
|
|
191
|
+
},
|
|
192
|
+
(t) => [
|
|
193
|
+
index('job_pending')
|
|
194
|
+
.on(t.availableAt)
|
|
195
|
+
.where(sql`${t.completedAt} IS NULL`),
|
|
196
|
+
uniqueIndex('job_pending_availability_target')
|
|
197
|
+
.on(sql`(${t.payload}->>'id')`)
|
|
198
|
+
.where(sql`${t.completedAt} IS NULL AND ${t.kind} = 'availability'`)
|
|
199
|
+
]
|
|
200
|
+
)
|
|
201
|
+
export const watch = schema.table(
|
|
202
|
+
'watch',
|
|
203
|
+
{
|
|
204
|
+
id: uuid('id').primaryKey(),
|
|
205
|
+
storeId: text('store_id').notNull(),
|
|
206
|
+
userId: text('user_id').notNull(),
|
|
207
|
+
calendarId: text('calendar_id').notNull(),
|
|
208
|
+
tokenHash: text('token_hash').notNull(),
|
|
209
|
+
resourceId: text('resource_id'),
|
|
210
|
+
expiresAt: instant('expires_at').notNull()
|
|
211
|
+
},
|
|
212
|
+
(t) => [providerFk(t)]
|
|
213
|
+
)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { productPlanningSchema } from '@nuxt-customer-portal/products/shared/planning'
|
|
2
|
+
import { registerPortalOpenApiContracts } from '@nuxt-customer-portal/core/server/utils/openapi-contracts'
|
|
3
|
+
import { z } from 'zod'
|
|
4
|
+
import {
|
|
5
|
+
appointmentListSchema,
|
|
6
|
+
staffRescheduleSchema,
|
|
7
|
+
availabilityQuerySchema,
|
|
8
|
+
availabilitySchema,
|
|
9
|
+
availabilityEditSchema,
|
|
10
|
+
providerSettingsSchema,
|
|
11
|
+
holdSchema,
|
|
12
|
+
holdCredentialSchema,
|
|
13
|
+
planningPolicySchema
|
|
14
|
+
} from '../../shared/validation'
|
|
15
|
+
|
|
16
|
+
export default defineNitroPlugin(() =>
|
|
17
|
+
registerPortalOpenApiContracts({
|
|
18
|
+
owner: 'planning',
|
|
19
|
+
query: {
|
|
20
|
+
planningAppointmentsGet: appointmentListSchema,
|
|
21
|
+
storePlanningByProductIdAvailabilityGet: availabilityQuerySchema,
|
|
22
|
+
storePlanningHoldGet: holdCredentialSchema,
|
|
23
|
+
planningAppointmentsByIdAvailabilityGet: availabilityQuerySchema
|
|
24
|
+
},
|
|
25
|
+
body: {
|
|
26
|
+
planningAppointmentsByIdReschedulePut: staffRescheduleSchema,
|
|
27
|
+
storePlanningHoldsPost: holdSchema,
|
|
28
|
+
planningAdminProductsByIdPut: productPlanningSchema,
|
|
29
|
+
planningProviderPut: providerSettingsSchema,
|
|
30
|
+
planningAvailabilityPost: availabilitySchema,
|
|
31
|
+
planningAvailabilityByIdPut: availabilityEditSchema,
|
|
32
|
+
planningAdminPolicyPut: planningPolicySchema,
|
|
33
|
+
planningAdminProvidersByUserIdPatch: z.object({ enabled: z.boolean() })
|
|
34
|
+
}
|
|
35
|
+
})
|
|
36
|
+
)
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { registerPlanningOrderIntegration } from '@nuxt-customer-portal/products/server/utils/contracts'
|
|
2
|
+
import { prepareCheckout, bindPrepared, prepareOrder, confirm } from '../utils/booking'
|
|
3
|
+
|
|
4
|
+
export default defineNitroPlugin(() =>
|
|
5
|
+
registerPlanningOrderIntegration({ prepareCheckout, bind: bindPrepared, prepareOrder, confirm })
|
|
6
|
+
)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { pool } from '@nuxt-customer-portal/core/server/utils/db'
|
|
2
|
+
import { rows } from '@nuxt-customer-portal/products/server/utils/database'
|
|
3
|
+
import { expireReservations, runJobs } from '../../utils/jobs'
|
|
4
|
+
import { enqueue } from '../../utils/booking'
|
|
5
|
+
|
|
6
|
+
export default defineTask({
|
|
7
|
+
meta: {
|
|
8
|
+
name: 'planning:reconcile',
|
|
9
|
+
description: 'Expire booking holds, synchronize calendars and retry appointment effects'
|
|
10
|
+
},
|
|
11
|
+
async run() {
|
|
12
|
+
let expiryError = false
|
|
13
|
+
try {
|
|
14
|
+
await expireReservations()
|
|
15
|
+
} catch {
|
|
16
|
+
expiryError = true
|
|
17
|
+
}
|
|
18
|
+
const users = await rows<{ store_id: string; user_id: string }>(
|
|
19
|
+
`SELECT p.store_id,p.user_id FROM planning.provider p JOIN planning.connection c ON c.store_id=p.store_id AND c.user_id=p.user_id AND c.provider='google' AND c.healthy WHERE cardinality(p.busy_calendar_ids)>0`
|
|
20
|
+
)
|
|
21
|
+
for (const u of users) {
|
|
22
|
+
await enqueue(pool, `sync:${u.store_id}:${u.user_id}:${Math.floor(Date.now() / 900000)}`, 'sync', {
|
|
23
|
+
storeId: u.store_id,
|
|
24
|
+
userId: u.user_id,
|
|
25
|
+
trigger: 'reconciliation'
|
|
26
|
+
})
|
|
27
|
+
}
|
|
28
|
+
return { result: { ...(await runJobs()), expiryError } }
|
|
29
|
+
}
|
|
30
|
+
})
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { createError, getHeader, type H3Event } from 'h3'
|
|
2
|
+
import { requireActiveOrganizationRole, requireSession } from '@nuxt-customer-portal/core/server/portal'
|
|
3
|
+
import { admin, getStore, baseUrl } from '@nuxt-customer-portal/products/server/utils/access'
|
|
4
|
+
import { rows } from '@nuxt-customer-portal/products/server/utils/database'
|
|
5
|
+
import { claimPurchases } from '@nuxt-customer-portal/products/server/utils/orders'
|
|
6
|
+
import type { Appointment } from '../../shared/types'
|
|
7
|
+
|
|
8
|
+
export const planningAdmin = admin
|
|
9
|
+
export function sameOrigin(event: H3Event) {
|
|
10
|
+
if (getHeader(event, 'origin') !== baseUrl()) {
|
|
11
|
+
throw createError({ statusCode: 403, message: 'This action must originate from the portal' })
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export async function providerAccess(event: H3Event) {
|
|
15
|
+
const context = await requireActiveOrganizationRole(event),
|
|
16
|
+
store = await getStore()
|
|
17
|
+
if (context.organizationType !== 'PROVIDER' || context.organizationId !== store.organization_id) {
|
|
18
|
+
throw createError({ statusCode: 403, message: 'Provider organization membership required' })
|
|
19
|
+
}
|
|
20
|
+
await rows(
|
|
21
|
+
`INSERT INTO planning.provider(store_id,user_id,timezone) SELECT $1,u.id,COALESCE(u.timezone,os.timezone,'Europe/Amsterdam') FROM public."user" u LEFT JOIN public.organization_settings os ON os.organization_id=$1 WHERE u.id=$2 ON CONFLICT DO NOTHING`,
|
|
22
|
+
[context.organizationId, context.session.user.id]
|
|
23
|
+
)
|
|
24
|
+
return { ...context, storeId: store.organization_id, userId: context.session.user.id }
|
|
25
|
+
}
|
|
26
|
+
export async function customerAppointment(event: H3Event, id: string) {
|
|
27
|
+
const userId = await claimPurchases(event)
|
|
28
|
+
const [a] = await rows<Appointment>(
|
|
29
|
+
`SELECT a.* FROM planning.appointment a JOIN products.orders o ON o.id=a.order_id WHERE a.id=$1 AND o.buyer_id=$2`,
|
|
30
|
+
[id, userId]
|
|
31
|
+
)
|
|
32
|
+
if (!a) {
|
|
33
|
+
throw createError({ statusCode: 404, message: 'Appointment not found' })
|
|
34
|
+
}
|
|
35
|
+
return { appointment: a, userId }
|
|
36
|
+
}
|
|
37
|
+
export async function verifiedCustomer(event: H3Event) {
|
|
38
|
+
const session = await requireSession(event)
|
|
39
|
+
if (!session.user.emailVerified) {
|
|
40
|
+
throw createError({ statusCode: 403, message: 'Verify your email' })
|
|
41
|
+
}
|
|
42
|
+
return session
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function appointmentScope(event: H3Event) {
|
|
46
|
+
const session = await requireSession(event)
|
|
47
|
+
let context
|
|
48
|
+
try {
|
|
49
|
+
context = await requireActiveOrganizationRole(event)
|
|
50
|
+
} catch (error) {
|
|
51
|
+
if (![400, 403].includes((error as { statusCode?: number }).statusCode ?? 500)) {
|
|
52
|
+
throw error
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const store = await getStore()
|
|
56
|
+
if (context?.organizationType === 'PROVIDER' && context.organizationId === store.organization_id) {
|
|
57
|
+
return {
|
|
58
|
+
staff: true,
|
|
59
|
+
canManage: ['owner', 'admin'].includes(context.role),
|
|
60
|
+
storeId: store.organization_id,
|
|
61
|
+
userId: session.user.id
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const userId = await claimPurchases(event)
|
|
65
|
+
return { staff: false, canManage: true, storeId: store.organization_id, userId }
|
|
66
|
+
}
|
|
67
|
+
export async function appointmentAccess(event: H3Event, id: string, manage = false) {
|
|
68
|
+
const scope = await appointmentScope(event)
|
|
69
|
+
const [appointment] = await rows<Appointment>(
|
|
70
|
+
`SELECT a.* FROM planning.appointment a JOIN products.orders o ON o.id=a.order_id WHERE a.id=$1 AND a.store_id=$2 AND ($3::boolean OR o.buyer_id=$4)`,
|
|
71
|
+
[id, scope.storeId, scope.staff, scope.userId]
|
|
72
|
+
)
|
|
73
|
+
if (!appointment) {
|
|
74
|
+
throw createError({ statusCode: 404, message: 'Appointment not found' })
|
|
75
|
+
}
|
|
76
|
+
if (manage && !scope.canManage) {
|
|
77
|
+
throw createError({ statusCode: 403, message: 'Organization administrator access required' })
|
|
78
|
+
}
|
|
79
|
+
return { ...scope, appointment }
|
|
80
|
+
}
|