@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 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
- calendars.value = googleConnected.value ? await api.calendars() : []
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')"
@@ -38,6 +38,7 @@ export interface ProviderConfiguration {
38
38
  enabled: boolean
39
39
  timezone: string
40
40
  graceMinutes: number
41
+ availabilitySyncEnabled: boolean
41
42
  availabilityCalendarTitle: string
42
43
  busyCalendarIds: string[]
43
44
  writeCalendarId: string | null
@@ -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",
@@ -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",
@@ -0,0 +1,2 @@
1
+ ALTER TABLE planning.provider
2
+ ADD COLUMN availability_sync_enabled boolean NOT NULL DEFAULT true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nuxt-customer-portal/planning",
3
- "version": "0.4.6",
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.6",
44
- "@nuxt-customer-portal/products": "^0.4.6",
45
- "@nuxt-customer-portal/ui": "^0.4.6"
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",
@@ -1,6 +1,6 @@
1
1
  export default {
2
2
  id: 'planning',
3
- version: '0.4.6',
3
+ version: '0.4.8',
4
4
  source: '@nuxt-customer-portal/planning',
5
5
  dependsOn: ['core', 'ui', 'products'],
6
6
  schema: './server/db/schema/planning.ts',
@@ -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()
@@ -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 Error(`Calendar/meeting authorization failed (${response.status})`)
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
- credentials = await exchange(provider, { grant_type: 'refresh_token', refresh_token: credentials.refresh_token })
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)],
@@ -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<{ write_calendar_id: string; availability_calendar_title: string }>(
285
- 'SELECT write_calendar_id,availability_calendar_title FROM planning.provider WHERE store_id=$1 AND user_id=$2',
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
- "UPDATE planning.availability SET data=jsonb_set(data,'{timezone}',to_jsonb($3::text)),revision=revision+1 WHERE store_id=$1 AND user_id=$2 RETURNING id,revision",
105
- [storeId, userId, input.timezone],
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
@@ -80,6 +80,7 @@ export interface ProviderSettings {
80
80
  enabled: boolean
81
81
  timezone: string
82
82
  graceMinutes: number
83
+ availabilitySyncEnabled: boolean
83
84
  availabilityCalendarTitle: string
84
85
  busyCalendarIds: string[]
85
86
  writeCalendarId: string | null
@@ -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)