@nuxt-customer-portal/planning 0.4.6 → 0.4.8
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 +18 -0
- package/app/components/PlanningPersonalCalendarSettings.vue +27 -1
- package/app/composables/usePlanning.ts +1 -0
- package/i18n/locales/en.json +2 -0
- package/i18n/locales/nl.json +2 -0
- package/migrations/0005_availability_sync_enabled.sql +2 -0
- package/package.json +4 -4
- package/portal.manifest.mjs +1 -1
- package/server/db/schema/planning.ts +1 -0
- package/server/utils/adapters.ts +24 -2
- package/server/utils/jobs.ts +8 -10
- package/server/utils/management.ts +17 -6
- package/shared/types.ts +1 -0
- package/shared/validation.ts +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.8
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Control Google Calendar availability synchronization with a provider appointment preference instead of the store environment.
|
|
8
|
+
- @nuxt-customer-portal/core@0.4.8
|
|
9
|
+
- @nuxt-customer-portal/products@0.4.8
|
|
10
|
+
- @nuxt-customer-portal/ui@0.4.8
|
|
11
|
+
|
|
12
|
+
## 0.4.7
|
|
13
|
+
|
|
14
|
+
### Patch Changes
|
|
15
|
+
|
|
16
|
+
- Keep Google Calendar settings recoverable when stored authorization expires, and offer reconnection after a rejected token refresh.
|
|
17
|
+
- @nuxt-customer-portal/core@0.4.7
|
|
18
|
+
- @nuxt-customer-portal/products@0.4.7
|
|
19
|
+
- @nuxt-customer-portal/ui@0.4.7
|
|
20
|
+
|
|
3
21
|
## 0.4.6
|
|
4
22
|
|
|
5
23
|
### Patch Changes
|
|
@@ -11,6 +11,7 @@ const calendars = ref<Array<{ id: string; summary: string; accessRole: string }>
|
|
|
11
11
|
const providerState = reactive({
|
|
12
12
|
timezone: 'Europe/Amsterdam',
|
|
13
13
|
graceMinutes: 0,
|
|
14
|
+
availabilitySyncEnabled: true,
|
|
14
15
|
availabilityCalendarTitle: 'Portal availability',
|
|
15
16
|
busyCalendarIds: [] as string[],
|
|
16
17
|
writeCalendarId: ''
|
|
@@ -42,11 +43,28 @@ async function load() {
|
|
|
42
43
|
Object.assign(providerState, {
|
|
43
44
|
timezone: settings.timezone,
|
|
44
45
|
graceMinutes: settings.graceMinutes,
|
|
46
|
+
availabilitySyncEnabled: settings.availabilitySyncEnabled,
|
|
45
47
|
availabilityCalendarTitle: settings.availabilityCalendarTitle,
|
|
46
48
|
busyCalendarIds: settings.busyCalendarIds,
|
|
47
49
|
writeCalendarId: settings.writeCalendarId || ''
|
|
48
50
|
})
|
|
49
|
-
|
|
51
|
+
if (googleConnected.value) {
|
|
52
|
+
try {
|
|
53
|
+
calendars.value = await api.calendars()
|
|
54
|
+
} catch (calendarError) {
|
|
55
|
+
const refreshedSettings = await api.provider().catch(() => null)
|
|
56
|
+
const connectionStillHealthy = refreshedSettings?.connections.some(
|
|
57
|
+
(connection) => connection.provider === 'google' && connection.healthy
|
|
58
|
+
)
|
|
59
|
+
if (connectionStillHealthy !== false) {
|
|
60
|
+
throw calendarError
|
|
61
|
+
}
|
|
62
|
+
googleConnected.value = false
|
|
63
|
+
calendars.value = []
|
|
64
|
+
}
|
|
65
|
+
} else {
|
|
66
|
+
calendars.value = []
|
|
67
|
+
}
|
|
50
68
|
ready.value = true
|
|
51
69
|
} catch {
|
|
52
70
|
error.value = t('planning.loadError')
|
|
@@ -182,6 +200,14 @@ async function saveSettings() {
|
|
|
182
200
|
><h2 class="font-semibold">{{ t('planning.schedulingPreferences') }}</h2></template
|
|
183
201
|
>
|
|
184
202
|
<div class="grid gap-4 sm:grid-cols-2">
|
|
203
|
+
<UFormField
|
|
204
|
+
name="availabilitySyncEnabled"
|
|
205
|
+
:label="t('planning.availabilitySyncEnabled')"
|
|
206
|
+
:description="t('planning.availabilitySyncEnabledDescription')"
|
|
207
|
+
class="sm:col-span-2"
|
|
208
|
+
>
|
|
209
|
+
<USwitch v-model="providerState.availabilitySyncEnabled" />
|
|
210
|
+
</UFormField>
|
|
185
211
|
<UFormField
|
|
186
212
|
name="availabilityCalendarTitle"
|
|
187
213
|
:label="t('planning.availabilityCalendarTitle')"
|
package/i18n/locales/en.json
CHANGED
|
@@ -95,6 +95,8 @@
|
|
|
95
95
|
"setMainCalendar": "Set main calendar",
|
|
96
96
|
"calendarSettings": "Calendar settings",
|
|
97
97
|
"graceMinutes": "Grace time after portal appointments (minutes)",
|
|
98
|
+
"availabilitySyncEnabled": "Sync availability to Google Calendar",
|
|
99
|
+
"availabilitySyncEnabledDescription": "Create and maintain calendar events for your portal availability. Turning this off removes existing availability events; appointments remain synchronized.",
|
|
98
100
|
"availabilityCalendarTitle": "Availability calendar event title",
|
|
99
101
|
"availabilityCalendarTitleDescription": "Shown as the title of availability events in your main calendar.",
|
|
100
102
|
"busyCalendars": "Calendars that block availability",
|
package/i18n/locales/nl.json
CHANGED
|
@@ -95,6 +95,8 @@
|
|
|
95
95
|
"setMainCalendar": "Hoofdagenda instellen",
|
|
96
96
|
"calendarSettings": "Agenda-instellingen",
|
|
97
97
|
"graceMinutes": "Rusttijd na portaalafspraken (minuten)",
|
|
98
|
+
"availabilitySyncEnabled": "Beschikbaarheid synchroniseren met Google Agenda",
|
|
99
|
+
"availabilitySyncEnabledDescription": "Maak en onderhoud agendagebeurtenissen voor je beschikbaarheid in het portaal. Als je dit uitschakelt, worden bestaande beschikbaarheidsgebeurtenissen verwijderd; afspraken blijven gesynchroniseerd.",
|
|
98
100
|
"availabilityCalendarTitle": "Titel van beschikbaarheid in agenda",
|
|
99
101
|
"availabilityCalendarTitleDescription": "Wordt als titel getoond bij beschikbaarheid in je hoofdagenda.",
|
|
100
102
|
"busyCalendars": "Agenda’s die beschikbaarheid blokkeren",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nuxt-customer-portal/planning",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.8",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"homepage": "https://nuxt-customer-portal.com",
|
|
6
6
|
"repository": {
|
|
@@ -40,9 +40,9 @@
|
|
|
40
40
|
"drizzle-orm": "^0.45.2",
|
|
41
41
|
"pg": "^8.22.0",
|
|
42
42
|
"zod": "^4.4.3",
|
|
43
|
-
"@nuxt-customer-portal/core": "^0.4.
|
|
44
|
-
"@nuxt-customer-portal/
|
|
45
|
-
"@nuxt-customer-portal/
|
|
43
|
+
"@nuxt-customer-portal/core": "^0.4.8",
|
|
44
|
+
"@nuxt-customer-portal/ui": "^0.4.8",
|
|
45
|
+
"@nuxt-customer-portal/products": "^0.4.8"
|
|
46
46
|
},
|
|
47
47
|
"peerDependencies": {
|
|
48
48
|
"nuxt": "^4.5.1",
|
package/portal.manifest.mjs
CHANGED
|
@@ -38,6 +38,7 @@ export const provider = schema.table(
|
|
|
38
38
|
enabled: boolean('enabled').notNull().default(false),
|
|
39
39
|
timezone: text('timezone').notNull().default('Europe/Amsterdam'),
|
|
40
40
|
graceMinutes: integer('grace_minutes').notNull().default(0),
|
|
41
|
+
availabilitySyncEnabled: boolean('availability_sync_enabled').notNull().default(true),
|
|
41
42
|
availabilityCalendarTitle: text('availability_calendar_title').notNull().default('Portal availability'),
|
|
42
43
|
busyCalendarIds: text('busy_calendar_ids')
|
|
43
44
|
.array()
|
package/server/utils/adapters.ts
CHANGED
|
@@ -54,6 +54,14 @@ export interface MeetingAdapter {
|
|
|
54
54
|
): Promise<{ id: string; url: string }>
|
|
55
55
|
remove(storeId: string, userId: string, id: string): Promise<void>
|
|
56
56
|
}
|
|
57
|
+
class AuthorizationExchangeError extends Error {
|
|
58
|
+
constructor(
|
|
59
|
+
public readonly status: number,
|
|
60
|
+
message = `Calendar/meeting authorization failed (${status})`
|
|
61
|
+
) {
|
|
62
|
+
super(message)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
57
65
|
export function oauthConfig(provider: 'google' | 'zoom') {
|
|
58
66
|
const prefix = `PLANNING_${provider.toUpperCase()}`
|
|
59
67
|
const clientId = process.env[`${prefix}_CLIENT_ID`],
|
|
@@ -84,7 +92,7 @@ export async function exchange(provider: 'google' | 'zoom', params: Record<strin
|
|
|
84
92
|
}
|
|
85
93
|
)
|
|
86
94
|
if (!response.ok) {
|
|
87
|
-
throw new
|
|
95
|
+
throw new AuthorizationExchangeError(response.status)
|
|
88
96
|
}
|
|
89
97
|
const value = (await response.json()) as { access_token: string; refresh_token?: string; expires_in: number }
|
|
90
98
|
if (!value.access_token || !value.expires_in) {
|
|
@@ -111,7 +119,21 @@ async function accessToken(storeId: string, userId: string, provider: 'google' |
|
|
|
111
119
|
}
|
|
112
120
|
let credentials = decrypt<Credentials>(connection.credentials)
|
|
113
121
|
if (credentials.expiresAt < Date.now() + 60000) {
|
|
114
|
-
|
|
122
|
+
try {
|
|
123
|
+
credentials = await exchange(provider, {
|
|
124
|
+
grant_type: 'refresh_token',
|
|
125
|
+
refresh_token: credentials.refresh_token
|
|
126
|
+
})
|
|
127
|
+
} catch (error) {
|
|
128
|
+
if (error instanceof AuthorizationExchangeError && [400, 401].includes(error.status)) {
|
|
129
|
+
await rows(
|
|
130
|
+
'UPDATE planning.connection SET healthy=false,error=$4 WHERE store_id=$1 AND user_id=$2 AND provider=$3',
|
|
131
|
+
[storeId, userId, provider, 'Authorization expired; reconnect'],
|
|
132
|
+
client
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
throw error
|
|
136
|
+
}
|
|
115
137
|
await rows(
|
|
116
138
|
'UPDATE planning.connection SET credentials=$4,error=NULL WHERE store_id=$1 AND user_id=$2 AND provider=$3',
|
|
117
139
|
[storeId, userId, provider, encrypt(credentials)],
|
package/server/utils/jobs.ts
CHANGED
|
@@ -11,7 +11,6 @@ import {
|
|
|
11
11
|
} from '@nuxt-customer-portal/products/server/utils/orders'
|
|
12
12
|
import { stripeProvider } from '@nuxt-customer-portal/products/server/utils/payments'
|
|
13
13
|
import { baseUrl } from '@nuxt-customer-portal/products/server/utils/access'
|
|
14
|
-
import { developmentSandboxEffectsEnabled } from '@nuxt-customer-portal/products/server/utils/development'
|
|
15
14
|
import type { Appointment, AvailabilityWindow, Reservation } from '../../shared/types'
|
|
16
15
|
import { calendarInvitation } from '../../shared/invitation'
|
|
17
16
|
import { calendarWallDateTime, wallInstant } from '../../shared/availability'
|
|
@@ -274,20 +273,19 @@ async function availabilityEffects(id: string) {
|
|
|
274
273
|
if (!window) {
|
|
275
274
|
return
|
|
276
275
|
}
|
|
277
|
-
const [store] = await rows<{ mode: string }>('SELECT mode FROM products.store WHERE organization_id=$1', [
|
|
278
|
-
window.store_id
|
|
279
|
-
])
|
|
280
|
-
if (store?.mode === 'sandbox' && !developmentSandboxEffectsEnabled()) {
|
|
281
|
-
return
|
|
282
|
-
}
|
|
283
276
|
const eventId = window.calendar_event_id || `a${id.replace(/-/g, '')}`
|
|
284
|
-
const [provider] = await rows<{
|
|
285
|
-
|
|
277
|
+
const [provider] = await rows<{
|
|
278
|
+
write_calendar_id: string
|
|
279
|
+
availability_calendar_title: string
|
|
280
|
+
availability_sync_enabled: boolean
|
|
281
|
+
}>(
|
|
282
|
+
'SELECT write_calendar_id,availability_calendar_title,availability_sync_enabled FROM planning.provider WHERE store_id=$1 AND user_id=$2',
|
|
286
283
|
[window.store_id, window.user_id]
|
|
287
284
|
)
|
|
288
|
-
if (window.deleted) {
|
|
285
|
+
if (window.deleted || !provider?.availability_sync_enabled) {
|
|
289
286
|
if (window.calendar_id) {
|
|
290
287
|
await calendarAdapter().remove(window.store_id, window.user_id, window.calendar_id, eventId)
|
|
288
|
+
await rows('UPDATE planning.availability SET calendar_event_id=NULL,calendar_id=NULL WHERE id=$1', [id])
|
|
291
289
|
}
|
|
292
290
|
return
|
|
293
291
|
}
|
|
@@ -7,7 +7,7 @@ import { stripeProvider } from '@nuxt-customer-portal/products/server/utils/paym
|
|
|
7
7
|
import { reconcileCheckout, getOrder } from '@nuxt-customer-portal/products/server/utils/orders'
|
|
8
8
|
import { getProduct } from '@nuxt-customer-portal/products/server/utils/catalog'
|
|
9
9
|
import { productPlanningSchema, planningPolicySchema } from '@nuxt-customer-portal/products/shared/planning'
|
|
10
|
-
import { canChange, changeFee, refundAmount } from '../../shared/availability'
|
|
10
|
+
import { canChange, changeFee, refundAmount, localParts } from '../../shared/availability'
|
|
11
11
|
import { availabilityEditSchema, providerSettingsSchema } from '../../shared/validation'
|
|
12
12
|
import type { Appointment, ProviderSettings, AvailabilityWindow } from '../../shared/types'
|
|
13
13
|
import { planningAdmin, providerAccess, appointmentAccess } from './access'
|
|
@@ -18,7 +18,7 @@ import { auditLock } from './jobs'
|
|
|
18
18
|
export async function listProviders(event: H3Event): Promise<ProviderSettings[]> {
|
|
19
19
|
const context = await planningAdmin(event)
|
|
20
20
|
return rows<ProviderSettings>(
|
|
21
|
-
`SELECT u.id AS "userId",u.name,u.image,COALESCE(p.enabled,false) AS enabled,COALESCE(p.timezone,u.timezone,os.timezone,'Europe/Amsterdam') AS timezone,COALESCE(p.grace_minutes,0) AS "graceMinutes",COALESCE(p.busy_calendar_ids,'{}') AS "busyCalendarIds",p.write_calendar_id AS "writeCalendarId",EXISTS(SELECT 1 FROM planning.connection c WHERE c.store_id=m.organization_id AND c.user_id=u.id AND c.provider='google' AND c.healthy) AS "googleConnected",EXISTS(SELECT 1 FROM planning.connection c WHERE c.store_id=m.organization_id AND c.user_id=u.id AND c.provider='zoom' AND c.healthy) AS "zoomConnected" FROM public.member m JOIN public."user" u ON u.id=m.user_id LEFT JOIN planning.provider p ON p.store_id=m.organization_id AND p.user_id=u.id LEFT JOIN public.organization_settings os ON os.organization_id=m.organization_id WHERE m.organization_id=$1 ORDER BY u.name`,
|
|
21
|
+
`SELECT u.id AS "userId",u.name,u.image,COALESCE(p.enabled,false) AS enabled,COALESCE(p.timezone,u.timezone,os.timezone,'Europe/Amsterdam') AS timezone,COALESCE(p.grace_minutes,0) AS "graceMinutes",COALESCE(p.availability_sync_enabled,true) AS "availabilitySyncEnabled",COALESCE(p.busy_calendar_ids,'{}') AS "busyCalendarIds",p.write_calendar_id AS "writeCalendarId",EXISTS(SELECT 1 FROM planning.connection c WHERE c.store_id=m.organization_id AND c.user_id=u.id AND c.provider='google' AND c.healthy) AS "googleConnected",EXISTS(SELECT 1 FROM planning.connection c WHERE c.store_id=m.organization_id AND c.user_id=u.id AND c.provider='zoom' AND c.healthy) AS "zoomConnected" FROM public.member m JOIN public."user" u ON u.id=m.user_id LEFT JOIN planning.provider p ON p.store_id=m.organization_id AND p.user_id=u.id LEFT JOIN public.organization_settings os ON os.organization_id=m.organization_id WHERE m.organization_id=$1 ORDER BY u.name`,
|
|
22
22
|
[context.organizationId]
|
|
23
23
|
)
|
|
24
24
|
}
|
|
@@ -48,6 +48,7 @@ export async function ownSettings(event: H3Event) {
|
|
|
48
48
|
enabled: boolean
|
|
49
49
|
timezone: string
|
|
50
50
|
grace_minutes: number
|
|
51
|
+
availability_sync_enabled: boolean
|
|
51
52
|
availability_calendar_title: string
|
|
52
53
|
busy_calendar_ids: string[]
|
|
53
54
|
write_calendar_id: string | null
|
|
@@ -69,6 +70,7 @@ export async function ownSettings(event: H3Event) {
|
|
|
69
70
|
enabled: p!.enabled,
|
|
70
71
|
timezone: p!.timezone,
|
|
71
72
|
graceMinutes: p!.grace_minutes,
|
|
73
|
+
availabilitySyncEnabled: p!.availability_sync_enabled,
|
|
72
74
|
availabilityCalendarTitle: p!.availability_calendar_title,
|
|
73
75
|
busyCalendarIds: p!.busy_calendar_ids,
|
|
74
76
|
writeCalendarId: p!.write_calendar_id,
|
|
@@ -89,7 +91,7 @@ export async function saveOwnSettings(event: H3Event, body: unknown) {
|
|
|
89
91
|
await transaction(async (tx) => {
|
|
90
92
|
await lockProvider(tx, userId)
|
|
91
93
|
await tx.query(
|
|
92
|
-
'UPDATE planning.provider SET timezone=$3,grace_minutes=$4,busy_calendar_ids=$5,write_calendar_id=$6,availability_calendar_title=$7 WHERE store_id=$1 AND user_id=$2',
|
|
94
|
+
'UPDATE planning.provider SET timezone=$3,grace_minutes=$4,busy_calendar_ids=$5,write_calendar_id=$6,availability_calendar_title=$7,availability_sync_enabled=$8 WHERE store_id=$1 AND user_id=$2',
|
|
93
95
|
[
|
|
94
96
|
storeId,
|
|
95
97
|
userId,
|
|
@@ -97,12 +99,21 @@ export async function saveOwnSettings(event: H3Event, body: unknown) {
|
|
|
97
99
|
input.graceMinutes,
|
|
98
100
|
[...new Set([...input.busyCalendarIds, input.writeCalendarId])],
|
|
99
101
|
input.writeCalendarId,
|
|
100
|
-
input.availabilityCalendarTitle
|
|
102
|
+
input.availabilityCalendarTitle,
|
|
103
|
+
input.availabilitySyncEnabled
|
|
101
104
|
]
|
|
102
105
|
)
|
|
106
|
+
const today = localParts(new Date(), input.timezone).date
|
|
103
107
|
const windows = await rows<{ id: string; revision: number }>(
|
|
104
|
-
|
|
105
|
-
|
|
108
|
+
`UPDATE planning.availability
|
|
109
|
+
SET data=jsonb_set(data,'{timezone}',to_jsonb($3::text)),revision=revision+1
|
|
110
|
+
WHERE store_id=$1 AND user_id=$2
|
|
111
|
+
AND ($4::boolean IS FALSE OR (NOT deleted AND (
|
|
112
|
+
data->>'date'>=$5 OR
|
|
113
|
+
(data->>'recurring'='true' AND (data->>'endDate' IS NULL OR data->>'endDate'>=$5))
|
|
114
|
+
)))
|
|
115
|
+
RETURNING id,revision`,
|
|
116
|
+
[storeId, userId, input.timezone, input.availabilitySyncEnabled, today],
|
|
106
117
|
tx
|
|
107
118
|
)
|
|
108
119
|
for (const w of windows) {
|
package/shared/types.ts
CHANGED
package/shared/validation.ts
CHANGED
|
@@ -35,6 +35,7 @@ export const availabilitySchema = z.object(availabilityShape).superRefine(valida
|
|
|
35
35
|
export const providerSettingsSchema = z.object({
|
|
36
36
|
timezone: timezoneSchema,
|
|
37
37
|
graceMinutes: z.number().int().min(0).max(1440),
|
|
38
|
+
availabilitySyncEnabled: z.boolean().default(true),
|
|
38
39
|
availabilityCalendarTitle: z.string().trim().min(1).max(100),
|
|
39
40
|
busyCalendarIds: z.array(z.string().min(1).max(500)).max(20),
|
|
40
41
|
writeCalendarId: z.string().min(1).max(500)
|