@nuxt-customer-portal/planning 0.4.5 → 0.4.7

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,24 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.7
4
+
5
+ ### Patch Changes
6
+
7
+ - Keep Google Calendar settings recoverable when stored authorization expires, and offer reconnection after a rejected token refresh.
8
+ - @nuxt-customer-portal/core@0.4.7
9
+ - @nuxt-customer-portal/products@0.4.7
10
+ - @nuxt-customer-portal/ui@0.4.7
11
+
12
+ ## 0.4.6
13
+
14
+ ### Patch Changes
15
+
16
+ - Allow authenticated Google Calendar channel notifications through the public webhook boundary, retain provider-returned availability event IDs, expose those IDs in synchronization diagnostics, and remove the planning policy lint warning.
17
+ - Updated dependencies
18
+ - @nuxt-customer-portal/core@0.4.6
19
+ - @nuxt-customer-portal/products@0.4.6
20
+ - @nuxt-customer-portal/ui@0.4.6
21
+
3
22
  ## 0.4.5
4
23
 
5
24
  ### Patch Changes
@@ -46,7 +46,23 @@ async function load() {
46
46
  busyCalendarIds: settings.busyCalendarIds,
47
47
  writeCalendarId: settings.writeCalendarId || ''
48
48
  })
49
- calendars.value = googleConnected.value ? await api.calendars() : []
49
+ if (googleConnected.value) {
50
+ try {
51
+ calendars.value = await api.calendars()
52
+ } catch (calendarError) {
53
+ const refreshedSettings = await api.provider().catch(() => null)
54
+ const connectionStillHealthy = refreshedSettings?.connections.some(
55
+ (connection) => connection.provider === 'google' && connection.healthy
56
+ )
57
+ if (connectionStillHealthy !== false) {
58
+ throw calendarError
59
+ }
60
+ googleConnected.value = false
61
+ calendars.value = []
62
+ }
63
+ } else {
64
+ calendars.value = []
65
+ }
50
66
  ready.value = true
51
67
  } catch {
52
68
  error.value = t('planning.loadError')
@@ -10,17 +10,16 @@ const props = withDefaults(defineProps<{ prefix?: string; currencies?: string[];
10
10
  compact: false
11
11
  })
12
12
  const { t } = useI18n()
13
- const numeric = [
14
- 'reservationMinutes',
15
- 'slotIntervalMinutes',
16
- 'displayIntervalMinutes',
17
- 'bookingHorizonDays',
18
- 'minimumNoticeMinutes',
19
- 'freeChanges',
20
- 'rescheduleCutoffMinutes',
21
- 'cancellationCutoffMinutes',
22
- 'refundPercentage'
23
- ] as const
13
+ type NumericPolicyKey =
14
+ | 'reservationMinutes'
15
+ | 'slotIntervalMinutes'
16
+ | 'displayIntervalMinutes'
17
+ | 'bookingHorizonDays'
18
+ | 'minimumNoticeMinutes'
19
+ | 'freeChanges'
20
+ | 'rescheduleCutoffMinutes'
21
+ | 'cancellationCutoffMinutes'
22
+ | 'refundPercentage'
24
23
  const groups = [
25
24
  {
26
25
  key: 'schedule',
@@ -70,7 +69,7 @@ if (overrides.value !== undefined) {
70
69
  }
71
70
  overrides.value = next
72
71
  }
73
- function setNumber(key: (typeof numeric)[number], value: number | null | undefined) {
72
+ function setNumber(key: NumericPolicyKey, value: number | null | undefined) {
74
73
  state.value = { ...state.value, [key]: value ?? 0 }
75
74
  }
76
75
  function setFee(currency: string, value: number | null | undefined) {
@@ -375,6 +375,11 @@ useAutoPagination({
375
375
  <UIcon name="i-lucide-calendar" class="size-4 shrink-0" />
376
376
  <span class="truncate">{{ job.entity.calendarId || t('planning.noDestinationCalendar') }}</span>
377
377
  </p>
378
+ <p v-if="job.entity.calendarEventId" class="flex min-w-0 items-center gap-2 sm:col-span-2">
379
+ <UIcon name="i-lucide-fingerprint" class="size-4 shrink-0" />
380
+ <span class="shrink-0">{{ t('planning.calendarEventId') }}:</span>
381
+ <code class="truncate text-xs" :title="job.entity.calendarEventId">{{ job.entity.calendarEventId }}</code>
382
+ </p>
378
383
  </div>
379
384
  <div
380
385
  v-else-if="job.entity?.type === 'appointment'"
@@ -99,6 +99,7 @@ export interface PlanningJobListItem {
99
99
  providerName: string
100
100
  providerEmail: string
101
101
  calendarId: string | null
102
+ calendarEventId: string | null
102
103
  date: string
103
104
  endDate: string | null
104
105
  startTime: string
@@ -146,6 +146,7 @@
146
146
  "jobAllProducts": "All products",
147
147
  "noProducts": "No products",
148
148
  "noDestinationCalendar": "No destination calendar selected",
149
+ "calendarEventId": "Google event ID",
149
150
  "synchronizationPages": "Synchronization task pages",
150
151
  "jobKinds": {
151
152
  "appointment": "Appointment synchronization",
@@ -146,6 +146,7 @@
146
146
  "jobAllProducts": "Alle producten",
147
147
  "noProducts": "Geen producten",
148
148
  "noDestinationCalendar": "Geen doelagenda geselecteerd",
149
+ "calendarEventId": "Google-afspraak-ID",
149
150
  "synchronizationPages": "Pagina's met synchronisatietaken",
150
151
  "jobKinds": {
151
152
  "appointment": "Afspraaksynchronisatie",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nuxt-customer-portal/planning",
3
- "version": "0.4.5",
3
+ "version": "0.4.7",
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.5",
44
- "@nuxt-customer-portal/products": "^0.4.5",
45
- "@nuxt-customer-portal/ui": "^0.4.5"
43
+ "@nuxt-customer-portal/core": "^0.4.7",
44
+ "@nuxt-customer-portal/ui": "^0.4.7",
45
+ "@nuxt-customer-portal/products": "^0.4.7"
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.5',
3
+ version: '0.4.7',
4
4
  source: '@nuxt-customer-portal/planning',
5
5
  dependsOn: ['core', 'ui', 'products'],
6
6
  schema: './server/db/schema/planning.ts',
@@ -130,6 +130,7 @@ export default defineEventHandler(async (event) => {
130
130
  providerName: string
131
131
  providerEmail: string
132
132
  calendarId: string | null
133
+ calendarEventId: string | null
133
134
  data: {
134
135
  date: string
135
136
  endDate: string | null
@@ -142,7 +143,7 @@ export default defineEventHandler(async (event) => {
142
143
  productTitles: string[]
143
144
  }>(
144
145
  `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(a.calendar_id,p.write_calendar_id) AS "calendarId",a.calendar_event_id AS "calendarEventId",a.data,
146
147
  COALESCE((
147
148
  SELECT array_agg(COALESCE(NULLIF(product.data->'content'->'en'->>'title',''),product.data->'content'->'nl'->>'title',product.id) ORDER BY product.id)
148
149
  FROM products.product product
@@ -175,6 +176,7 @@ export default defineEventHandler(async (event) => {
175
176
  providerName: entity.providerName,
176
177
  providerEmail: entity.providerEmail,
177
178
  calendarId: entity.calendarId,
179
+ calendarEventId: entity.calendarEventId,
178
180
  date: entity.data.date,
179
181
  endDate: entity.data.endDate,
180
182
  startTime: entity.data.startTime,
@@ -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)],
@@ -353,9 +353,18 @@ async function availabilityEffects(id: string) {
353
353
  }
354
354
  await rows('UPDATE planning.availability SET calendar_event_id=$2,calendar_id=$3 WHERE id=$1', [
355
355
  id,
356
- eventId,
356
+ written?.id || eventId,
357
357
  provider.write_calendar_id
358
358
  ])
359
+ if (written?.wasMissing || written?.externalChangeKey) {
360
+ console.info('Planning availability synchronized', {
361
+ availabilityId: id,
362
+ calendarId: provider.write_calendar_id,
363
+ calendarEventId: written.id,
364
+ revision: window.revision,
365
+ result: written.externalChangeKey ? 'external-change-repaired' : 'event-created'
366
+ })
367
+ }
359
368
  }
360
369
  async function refund(orderId: string, amount: number, key: string) {
361
370
  const order = await getOrder(orderId)