@nuxt-customer-portal/planning 0.6.1 → 0.7.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 CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.0
4
+
5
+ ### Patch Changes
6
+
7
+ - 9ccc48d: Keep Google Calendar connection recovery available after expired or incomplete authorization, validate Calendar permissions before saving a connection, and retain saved availability with a warning when external busy times cannot load.
8
+ - 1d0419c: Add scheduled product discounts and free offers, original catalog prices, and checkout price revalidation.
9
+ - Updated dependencies [1d0419c]
10
+ - @nuxt-customer-portal/products@0.7.0
11
+ - @nuxt-customer-portal/core@0.7.0
12
+ - @nuxt-customer-portal/ui@0.7.0
13
+
3
14
  ## 0.6.1
4
15
 
5
16
  ### Patch Changes
package/README.md CHANGED
@@ -115,3 +115,9 @@ Planning settings use separate components for the Team members, Booking policy,
115
115
  Personal connections and preferences are managed under My appointment settings (`/planning/settings`): Calendar settings contains Google connection, timezone, grace time, and calendar selection; Meeting settings contains the Zoom connection. Availability (`/availability`) contains only the availability editor and appointment blocks.
116
116
 
117
117
  Sandbox bookings use internal availability, holds, and appointments without requiring Google or Zoom connections or external calendar conflict checks. Live bookings still require healthy connections and configured calendars.
118
+
119
+ ### Google Calendar authorization recovery
120
+
121
+ Google OAuth must use the **In production** publishing status for long-lived Calendar authorizations. External OAuth apps in **Testing** receive refresh tokens that expire after seven days when Calendar scopes are requested. After changing publishing status, reconnect and grant all requested Calendar permissions. Revocation or other Google account policies can still require reauthorization. Keep the portal encryption key stable across deployments.
122
+
123
+ The portal validates Calendar permissions before replacing a connection. Calendar settings retain reconnect, disconnect and retry actions when Google is unavailable or authorization is incomplete. Availability continues showing saved windows and appointments with a warning when external busy times cannot be checked. This warning does not make external conflicts safe to ignore; booking checks still require working Calendar access.
@@ -2,12 +2,15 @@
2
2
  import { providerSettingsSchema } from '../../shared/validation'
3
3
 
4
4
  const api = usePlanning(),
5
- { t } = useI18n()
5
+ { t } = useI18n(),
6
+ route = useRoute()
6
7
  const calendars = ref<Array<{ id: string; summary: string; accessRole: string }>>([]),
7
8
  error = ref(''),
8
9
  busy = ref(false),
9
10
  ready = ref(false),
10
- googleConnected = ref(false)
11
+ googleConnected = ref(false),
12
+ hasConnection = ref(false),
13
+ calendarsLoaded = ref(false)
11
14
  const providerState = reactive({
12
15
  timezone: 'Europe/Amsterdam',
13
16
  graceMinutes: 0,
@@ -35,8 +38,11 @@ const additionalCalendarIds = computed({
35
38
 
36
39
  async function load() {
37
40
  error.value = ''
41
+ ready.value = false
42
+ calendarsLoaded.value = false
38
43
  try {
39
44
  const settings = await api.provider()
45
+ hasConnection.value = settings.connections.some((connection) => connection.provider === 'google')
40
46
  googleConnected.value = settings.connections.some(
41
47
  (connection) => connection.provider === 'google' && connection.healthy
42
48
  )
@@ -51,20 +57,30 @@ async function load() {
51
57
  if (googleConnected.value) {
52
58
  try {
53
59
  calendars.value = await api.calendars()
54
- } catch (calendarError) {
60
+ calendarsLoaded.value = true
61
+ } catch {
55
62
  const refreshedSettings = await api.provider().catch(() => null)
56
63
  const connectionStillHealthy = refreshedSettings?.connections.some(
57
64
  (connection) => connection.provider === 'google' && connection.healthy
58
65
  )
59
- if (connectionStillHealthy !== false) {
60
- throw calendarError
61
- }
62
- googleConnected.value = false
66
+ error.value = t(
67
+ connectionStillHealthy === false ? 'planning.googleReconnectRequired' : 'planning.googleCalendarUnavailable'
68
+ )
69
+ googleConnected.value = connectionStillHealthy !== false
63
70
  calendars.value = []
64
71
  }
65
72
  } else {
66
73
  calendars.value = []
67
74
  }
75
+ if (!googleConnected.value && hasConnection.value) {
76
+ error.value = t('planning.googleReconnectRequired')
77
+ }
78
+ const connectionResult = route.query.connection
79
+ if (connectionResult === 'permissions') {
80
+ error.value = t('planning.googlePermissionsRequired')
81
+ } else if (connectionResult === 'error') {
82
+ error.value = t('planning.googleConnectionError')
83
+ }
68
84
  ready.value = true
69
85
  } catch {
70
86
  error.value = t('planning.loadError')
@@ -72,6 +88,19 @@ async function load() {
72
88
  }
73
89
  await load()
74
90
 
91
+ async function disconnectGoogle() {
92
+ busy.value = true
93
+ error.value = ''
94
+ try {
95
+ await api.disconnect('google')
96
+ await load()
97
+ } catch {
98
+ error.value = t('planning.actionError')
99
+ } finally {
100
+ busy.value = false
101
+ }
102
+ }
103
+
75
104
  async function connectMainCalendar() {
76
105
  busy.value = true
77
106
  error.value = ''
@@ -103,6 +132,16 @@ async function saveSettings() {
103
132
  <div class="space-y-6">
104
133
  <UAlert v-if="error" :title="error" color="error" variant="outline" />
105
134
 
135
+ <div class="flex flex-wrap gap-2">
136
+ <UButton :loading="busy" variant="outline" @click="connectMainCalendar">{{ t('planning.connect') }}</UButton>
137
+ <UButton v-if="hasConnection" :disabled="busy" color="neutral" variant="outline" @click="disconnectGoogle">{{
138
+ t('planning.disconnect')
139
+ }}</UButton>
140
+ <UButton :disabled="busy" color="neutral" variant="ghost" @click="load">{{
141
+ t('planning.retryConnection')
142
+ }}</UButton>
143
+ </div>
144
+
106
145
  <UCard v-if="ready && !googleConnected">
107
146
  <template #header>
108
147
  <div class="flex items-center gap-3">
@@ -124,7 +163,7 @@ async function saveSettings() {
124
163
  </UCard>
125
164
 
126
165
  <UForm
127
- v-else-if="ready"
166
+ v-else-if="ready && calendarsLoaded"
128
167
  :state="providerState"
129
168
  :schema="providerSettingsSchema"
130
169
  novalidate
@@ -15,6 +15,7 @@ const api = usePlanning(),
15
15
  windows = ref<AvailabilityWindow[]>([]),
16
16
  appointments = ref<AppointmentListItem[]>([]),
17
17
  externalBusy = ref<CalendarBusyPeriod[]>([]),
18
+ calendarWarning = ref(''),
18
19
  error = ref(''),
19
20
  busy = ref(false),
20
21
  calendarNavigationDirection = ref<'previous' | 'next'>(),
@@ -227,8 +228,14 @@ async function fetchExternalBusy(targetDays: string[], timezone = calendarTimezo
227
228
  '00:00',
228
229
  timezone
229
230
  )
230
- const periods = await api.calendarBusy({ from: from.toISOString(), to: to.toISOString() })
231
- return Array.isArray(periods) ? periods : []
231
+ calendarWarning.value = ''
232
+ try {
233
+ const periods = await api.calendarBusy({ from: from.toISOString(), to: to.toISOString() })
234
+ return Array.isArray(periods) ? periods : []
235
+ } catch {
236
+ calendarWarning.value = t('planning.googleBusyUnavailable')
237
+ return []
238
+ }
232
239
  }
233
240
  // Suspend the page setup until the calendar and its external events are ready.
234
241
  // This ensures the all-day row has its final layout on the first rendered frame.
@@ -379,6 +386,13 @@ async function removeWindow() {
379
386
  <template>
380
387
  <div class="h-full min-h-0 overflow-hidden">
381
388
  <UContainer class="flex h-full min-h-0 flex-col gap-6 py-8">
389
+ <UAlert
390
+ v-if="calendarWarning"
391
+ :title="calendarWarning"
392
+ color="warning"
393
+ variant="outline"
394
+ :actions="[{ label: t('planning.myAppointmentSettings'), to: '/planning/settings' }]"
395
+ />
382
396
  <div class="flex flex-wrap items-center justify-between gap-3">
383
397
  <div class="flex items-start gap-3">
384
398
  <UIcon name="i-lucide-calendar-days" class="mt-1 size-6 shrink-0 text-primary" />
@@ -88,13 +88,20 @@ useHead({ title: `${t('planning.bookNow')} — ${product.title}`, meta: [{ name:
88
88
  </p>
89
89
  <div class="order-price">
90
90
  <span>{{ t('products.total') }}</span
91
- ><strong>{{
92
- product.isFree
93
- ? t('products.freeProduct')
94
- : selectedPrice
95
- ? formatMoney(selectedPrice.amount, selectedPrice.currency, product.locale)
96
- : ''
97
- }}</strong>
91
+ ><span
92
+ ><del
93
+ v-if="selectedPrice?.offer && selectedPrice.originalAmount > selectedPrice.amount"
94
+ class="mr-2 text-sm text-muted"
95
+ :aria-label="t('products.offers.originalPrice')"
96
+ >{{ formatMoney(selectedPrice.originalAmount, selectedPrice.currency, product.locale) }}</del
97
+ ><strong>{{
98
+ product.isFree
99
+ ? t('products.freeProduct')
100
+ : selectedPrice
101
+ ? formatMoney(selectedPrice.amount, selectedPrice.currency, product.locale)
102
+ : ''
103
+ }}</strong></span
104
+ >
98
105
  </div>
99
106
  <UFormField v-if="product.prices.length > 1" :label="t('products.price')" class="mt-5">
100
107
  <USelect
@@ -165,7 +165,6 @@
165
165
  "status": "Status",
166
166
  "overridePolicySection": "Override this section",
167
167
  "time": "Time",
168
- "timezone": "Timezone",
169
168
  "meetingUrl": "Meeting link",
170
169
  "meetingDetails": "Meeting details",
171
170
  "instructions": "Instructions after purchase",
@@ -258,6 +257,11 @@
258
257
  "past": "Past date",
259
258
  "loading": "Loading slots"
260
259
  },
261
- "slotTimesTimezone": "Times shown in {timezone}"
260
+ "slotTimesTimezone": "Times shown in {timezone}",
261
+ "googleReconnectRequired": "Google Calendar access has expired or was revoked. Reconnect and allow all requested Calendar permissions.",
262
+ "googleCalendarUnavailable": "Google calendars could not be loaded. Retry, reconnect with all Calendar permissions, or disconnect the account.",
263
+ "googlePermissionsRequired": "Calendar permissions were not granted. Reconnect and allow all requested Calendar permissions. Your previous connection has not been replaced.",
264
+ "retryConnection": "Retry",
265
+ "googleBusyUnavailable": "Google Calendar busy times are unavailable. Saved availability is shown, but external conflicts cannot be checked. Open appointment settings to reconnect."
262
266
  }
263
267
  }
@@ -165,7 +165,6 @@
165
165
  "status": "Status",
166
166
  "overridePolicySection": "Deze sectie overschrijven",
167
167
  "time": "Tijd",
168
- "timezone": "Tijdzone",
169
168
  "meetingUrl": "Vergaderlink",
170
169
  "meetingDetails": "Vergadergegevens",
171
170
  "instructions": "Instructies na aankoop",
@@ -258,6 +257,11 @@
258
257
  "past": "Datum verstreken",
259
258
  "loading": "Tijden laden"
260
259
  },
261
- "slotTimesTimezone": "Tijden weergegeven in {timezone}"
260
+ "slotTimesTimezone": "Tijden weergegeven in {timezone}",
261
+ "googleReconnectRequired": "De toegang tot Google Agenda is verlopen of ingetrokken. Verbind opnieuw en geef alle gevraagde agendarechten.",
262
+ "googleCalendarUnavailable": "Google-agenda’s konden niet worden geladen. Probeer opnieuw, verbind opnieuw met alle agendarechten of verbreek de verbinding.",
263
+ "googlePermissionsRequired": "De agendarechten zijn niet verleend. Verbind opnieuw en geef alle gevraagde agendarechten. Je vorige verbinding is niet vervangen.",
264
+ "retryConnection": "Opnieuw proberen",
265
+ "googleBusyUnavailable": "Bezette tijden uit Google Agenda zijn niet beschikbaar. Je opgeslagen beschikbaarheid wordt getoond, maar externe conflicten kunnen niet worden gecontroleerd. Open je afspraakinstellingen om opnieuw te verbinden."
262
266
  }
263
267
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nuxt-customer-portal/planning",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
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.6.1",
44
- "@nuxt-customer-portal/products": "^0.6.1",
45
- "@nuxt-customer-portal/ui": "^0.6.1"
43
+ "@nuxt-customer-portal/core": "^0.7.0",
44
+ "@nuxt-customer-portal/ui": "^0.7.0",
45
+ "@nuxt-customer-portal/products": "^0.7.0"
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.6.1',
3
+ version: '0.7.0',
4
4
  source: '@nuxt-customer-portal/planning',
5
5
  dependsOn: ['core', 'ui', 'products'],
6
6
  schema: './server/db/schema/planning.ts',
@@ -3,16 +3,14 @@ import { finishOAuth, oauthProvider } from '../../../../utils/oauth'
3
3
 
4
4
  export default defineEventHandler(async (event) => {
5
5
  const query = getQuery(event)
6
+ const provider = oauthProvider(getRouterParam(event, 'provider'))
7
+ const tab = provider === 'google' ? 'calendar' : 'meeting'
6
8
  try {
7
- await finishOAuth(
8
- event,
9
- oauthProvider(getRouterParam(event, 'provider')),
10
- String(query.state || ''),
11
- String(query.code || '')
12
- )
13
- return sendRedirect(event, '/planning/settings?tab=meeting&connection=connected', 303)
9
+ await finishOAuth(event, provider, String(query.state || ''), String(query.code || ''))
10
+ return sendRedirect(event, `/planning/settings?tab=${tab}&connection=connected`, 303)
14
11
  } catch (error) {
15
12
  console.error('Planning OAuth callback failed', error)
16
- return sendRedirect(event, '/planning/settings?tab=meeting&connection=error', 303)
13
+ const reason = (error as { data?: { reason?: string } }).data?.reason === 'permissions' ? 'permissions' : 'error'
14
+ return sendRedirect(event, `/planning/settings?tab=${tab}&connection=${reason}`, 303)
17
15
  }
18
16
  })
@@ -9,6 +9,7 @@ export interface Credentials {
9
9
  access_token: string
10
10
  refresh_token: string
11
11
  expiresAt: number
12
+ scope?: string
12
13
  }
13
14
  export interface CalendarEvent {
14
15
  id: string
@@ -94,11 +95,17 @@ export async function exchange(provider: 'google' | 'zoom', params: Record<strin
94
95
  if (!response.ok) {
95
96
  throw new AuthorizationExchangeError(response.status)
96
97
  }
97
- const value = (await response.json()) as { access_token: string; refresh_token?: string; expires_in: number }
98
+ const value = (await response.json()) as {
99
+ access_token: string
100
+ refresh_token?: string
101
+ expires_in: number
102
+ scope?: string
103
+ }
98
104
  if (!value.access_token || !value.expires_in) {
99
105
  throw new Error('Invalid authorization response')
100
106
  }
101
107
  return {
108
+ scope: value.scope,
102
109
  access_token: value.access_token,
103
110
  refresh_token: value.refresh_token || params.refresh_token || '',
104
111
  expiresAt: Date.now() + value.expires_in * 1000
@@ -174,7 +181,21 @@ async function api<T>(
174
181
  }
175
182
  let detail = ''
176
183
  try {
177
- const payload = (await response.json()) as { error?: { message?: string }; message?: string }
184
+ const payload = (await response.json()) as {
185
+ error?: { message?: string; status?: string; errors?: Array<{ reason?: string }> }
186
+ message?: string
187
+ }
188
+ if (
189
+ provider === 'google' &&
190
+ response.status === 403 &&
191
+ ((payload.error?.status === 'PERMISSION_DENIED' && /insufficient.*scope/i.test(payload.error.message || '')) ||
192
+ payload.error?.errors?.some((item) => item.reason === 'insufficientPermissions'))
193
+ ) {
194
+ await rows(
195
+ 'UPDATE planning.connection SET healthy=false,error=$4 WHERE store_id=$1 AND user_id=$2 AND provider=$3',
196
+ [storeId, userId, provider, 'Calendar permissions missing; reconnect']
197
+ )
198
+ }
178
199
  detail = String(payload.error?.message || payload.message || '')
179
200
  .replace(/\s+/g, ' ')
180
201
  .trim()
@@ -6,7 +6,7 @@ import type { z } from 'zod'
6
6
  import { rows, transaction } from '@nuxt-customer-portal/products/server/utils/database'
7
7
  import { getStore } from '@nuxt-customer-portal/products/server/utils/access'
8
8
  import { developmentSandboxEffectsEnabled } from '@nuxt-customer-portal/products/server/utils/development'
9
- import { getProduct, selectCopy } from '@nuxt-customer-portal/products/server/utils/catalog'
9
+ import { getEffectiveProduct, selectCopy } from '@nuxt-customer-portal/products/server/utils/catalog'
10
10
  import { defaultPlanningPolicy, effectivePolicy } from '@nuxt-customer-portal/products/shared/planning'
11
11
  import type { Order } from '@nuxt-customer-portal/products/shared/types'
12
12
  import type { checkoutSchema } from '@nuxt-customer-portal/products/shared/validation'
@@ -59,7 +59,7 @@ export async function bookingBusy(
59
59
  return calendarAdapter().busy(storeId, userId, calendarIds, from, to)
60
60
  }
61
61
  export async function providers(storeId: string, productId: string) {
62
- const product = await getProduct(storeId, productId)
62
+ const product = await getEffectiveProduct(storeId, productId)
63
63
  if (!product.planning?.enabled || product.status !== 'published') {
64
64
  throw createError({ statusCode: 404, message: 'This product is not available for booking' })
65
65
  }
@@ -3,6 +3,7 @@ import { rows } from '@nuxt-customer-portal/products/server/utils/database'
3
3
  import { providerAccess } from './access'
4
4
  import { oauthConfig, exchange } from './adapters'
5
5
  import { digest, secret, encrypt } from './crypto'
6
+ import { googleCalendarScopes, hasGoogleCalendarPermissions } from '../../shared/google-permissions'
6
7
 
7
8
  export function oauthProvider(value: string | undefined): 'google' | 'zoom' {
8
9
  if (value !== 'google' && value !== 'zoom') {
@@ -35,8 +36,7 @@ export async function startOAuth(event: H3Event, provider: 'google' | 'zoom') {
35
36
  ? {
36
37
  access_type: 'offline',
37
38
  prompt: 'consent',
38
- scope:
39
- 'openid email https://www.googleapis.com/auth/calendar.events https://www.googleapis.com/auth/calendar.calendarlist.readonly https://www.googleapis.com/auth/calendar.freebusy'
39
+ scope: ['openid', 'email', ...googleCalendarScopes].join(' ')
40
40
  }
41
41
  : {})
42
42
  })
@@ -59,6 +59,9 @@ export async function finishOAuth(event: H3Event, provider: 'google' | 'zoom', s
59
59
  throw createError({ statusCode: 403, message: 'Authorization expired or belongs to another user' })
60
60
  }
61
61
  const credentials = await exchange(provider, { grant_type: 'authorization_code', code })
62
+ if (provider === 'google' && !hasGoogleCalendarPermissions(credentials.scope)) {
63
+ throw createError({ statusCode: 400, message: 'Calendar permissions required', data: { reason: 'permissions' } })
64
+ }
62
65
  if (!credentials.refresh_token) {
63
66
  throw createError({ statusCode: 400, message: 'Offline authorization required; reconnect with consent' })
64
67
  }
@@ -0,0 +1,16 @@
1
+ const calendar = 'https://www.googleapis.com/auth/calendar'
2
+ export const googleCalendarScopes = [`${calendar}.events`, `${calendar}.calendarlist.readonly`, `${calendar}.freebusy`]
3
+
4
+ export function hasGoogleCalendarPermissions(scope: string | undefined) {
5
+ const granted = new Set((scope || '').split(/\s+/))
6
+ return (
7
+ granted.has(calendar) ||
8
+ (granted.has(`${calendar}.events`) &&
9
+ (granted.has(`${calendar}.calendarlist.readonly`) ||
10
+ granted.has(`${calendar}.calendarlist`) ||
11
+ granted.has(`${calendar}.readonly`)) &&
12
+ (granted.has(`${calendar}.freebusy`) ||
13
+ granted.has(`${calendar}.events.freebusy`) ||
14
+ granted.has(`${calendar}.readonly`)))
15
+ )
16
+ }