@nuxt-customer-portal/planning 0.6.1 → 0.8.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,28 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.0
4
+
5
+ ### Patch Changes
6
+
7
+ - e558791: Support store-wide online and deferred invoice payment choices with immediate purchase fulfillment, linked unsent invoice drafts, inclusive tax review, and manual payment progress. Add unlisted products with server-generated unguessable slugs, excluded from catalog discovery while remaining available through individual product pages.
8
+ - Updated dependencies [175854d]
9
+ - Updated dependencies [e558791]
10
+ - Updated dependencies [28bb7af]
11
+ - @nuxt-customer-portal/core@0.8.0
12
+ - @nuxt-customer-portal/ui@0.8.0
13
+ - @nuxt-customer-portal/products@0.8.0
14
+
15
+ ## 0.7.0
16
+
17
+ ### Patch Changes
18
+
19
+ - 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.
20
+ - 1d0419c: Add scheduled product discounts and free offers, original catalog prices, and checkout price revalidation.
21
+ - Updated dependencies [1d0419c]
22
+ - @nuxt-customer-portal/products@0.7.0
23
+ - @nuxt-customer-portal/core@0.7.0
24
+ - @nuxt-customer-portal/ui@0.7.0
25
+
3
26
  ## 0.6.1
4
27
 
5
28
  ### 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" />
@@ -3,6 +3,8 @@ import type { HoldResult } from '../../composables/usePlanning'
3
3
  import { resolveCheckoutQuery, resolveCheckoutReturnUrl } from '@nuxt-customer-portal/products/shared/checkout-query'
4
4
  import { formatMoney } from '@nuxt-customer-portal/products/shared/money'
5
5
 
6
+ useHead({ meta: [{ name: 'robots', content: 'noindex, nofollow' }] })
7
+
6
8
  definePageMeta({ path: '/store/:slug/book', public: true, layout: 'store', key: (route) => `booking:${route.path}` })
7
9
  const route = useRoute(),
8
10
  { t, locale, setLocale } = useI18n(),
@@ -88,13 +90,20 @@ useHead({ title: `${t('planning.bookNow')} — ${product.title}`, meta: [{ name:
88
90
  </p>
89
91
  <div class="order-price">
90
92
  <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>
93
+ ><span
94
+ ><del
95
+ v-if="selectedPrice?.offer && selectedPrice.originalAmount > selectedPrice.amount"
96
+ class="mr-2 text-sm text-muted"
97
+ :aria-label="t('products.offers.originalPrice')"
98
+ >{{ formatMoney(selectedPrice.originalAmount, selectedPrice.currency, product.locale) }}</del
99
+ ><strong>{{
100
+ product.isFree
101
+ ? t('products.freeProduct')
102
+ : selectedPrice
103
+ ? formatMoney(selectedPrice.amount, selectedPrice.currency, product.locale)
104
+ : ''
105
+ }}</strong></span
106
+ >
98
107
  </div>
99
108
  <UFormField v-if="product.prices.length > 1" :label="t('products.price')" class="mt-5">
100
109
  <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.8.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.8.0",
44
+ "@nuxt-customer-portal/ui": "^0.8.0",
45
+ "@nuxt-customer-portal/products": "^0.8.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.8.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
  })
@@ -5,7 +5,7 @@ import { rows } from '@nuxt-customer-portal/products/server/utils/database'
5
5
  export default defineEventHandler(async (event) => {
6
6
  const scope = await appointmentScope(event)
7
7
  return rows(
8
- `SELECT slug,COALESCE(NULLIF(data->'content'->'en'->>'title',''),data->'content'->'nl'->>'title') AS title FROM products.product WHERE store_id=$1 AND data->>'status'='published' AND data->>'type'='service' AND data->'planning'->>'enabled'='true' ORDER BY title,id`,
8
+ `SELECT slug,COALESCE(NULLIF(data->'content'->'en'->>'title',''),data->'content'->'nl'->>'title') AS title FROM products.product WHERE store_id=$1 AND data->>'status'='published' AND COALESCE(data->>'visibility','listed')='listed' AND data->>'type'='service' AND data->'planning'->>'enabled'='true' ORDER BY title,id`,
9
9
  [scope.storeId]
10
10
  )
11
11
  })
@@ -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
  }
@@ -485,8 +485,8 @@ export async function prepareOrder(order: Order) {
485
485
  let failure: string | undefined
486
486
  if (
487
487
  hold.status !== 'reserved' ||
488
- !order.snapshot.paymentCompletedAt ||
489
- Date.parse(order.snapshot.paymentCompletedAt) > hold.expires_at.getTime()
488
+ !(order.snapshot.acceptedAt || order.snapshot.paymentCompletedAt) ||
489
+ Date.parse((order.snapshot.acceptedAt || order.snapshot.paymentCompletedAt)!) > hold.expires_at.getTime()
490
490
  ) {
491
491
  failure = 'Reservation expired before payment completed'
492
492
  } else {
@@ -544,8 +544,8 @@ export async function confirm(tx: PoolClient, order: Order) {
544
544
  if (
545
545
  order.snapshot.planningFailure ||
546
546
  hold.status !== 'reserved' ||
547
- !order.snapshot.paymentCompletedAt ||
548
- Date.parse(order.snapshot.paymentCompletedAt) > hold.expires_at.getTime()
547
+ !(order.snapshot.acceptedAt || order.snapshot.paymentCompletedAt) ||
548
+ Date.parse((order.snapshot.acceptedAt || order.snapshot.paymentCompletedAt)!) > hold.expires_at.getTime()
549
549
  ) {
550
550
  await tx.query(
551
551
  "UPDATE products.orders SET snapshot=jsonb_set(snapshot,'{planningFailure}',to_jsonb($2::text)) WHERE id=$1",
@@ -1,3 +1,5 @@
1
+ import { orderDownloadToken, purchaseDownloads } from '@nuxt-customer-portal/products/server/utils/downloads'
2
+ import { downloadEmailMarkdown } from '@nuxt-customer-portal/products/shared/downloads'
1
3
  import { randomUUID } from 'node:crypto'
2
4
  import { pool } from '@nuxt-customer-portal/core/server/utils/db'
3
5
  import { sendPortalEmail } from '@nuxt-customer-portal/core/server/utils/portal-email'
@@ -181,7 +183,19 @@ async function appointmentEffects(
181
183
  timezone: a.snapshot.customerTimezone,
182
184
  meetingUrl: a.meeting_url || appointmentUrl,
183
185
  meetingDetails,
184
- instructions: order.lines[0]!.snapshot.product.nextSteps[locale] || '',
186
+ instructions: [
187
+ order.lines[0]!.snapshot.product.nextSteps[locale] || '',
188
+ order.snapshot.paymentMethod === 'invoice' && a.status !== 'cancelled'
189
+ ? locale === 'nl'
190
+ ? 'Je ontvangt de factuur later apart.'
191
+ : 'Your invoice will follow separately.'
192
+ : '',
193
+ a.status === 'cancelled'
194
+ ? ''
195
+ : downloadEmailMarkdown(await purchaseDownloads(order, await orderDownloadToken(order.id)), locale)
196
+ ]
197
+ .filter(Boolean)
198
+ .join('\n\n'),
185
199
  url: appointmentUrl
186
200
  }
187
201
  const invitation = calendarInvitation({
@@ -584,7 +598,7 @@ export async function expireReservations() {
584
598
  await reconcileCheckout(order.checkout_id)
585
599
  }
586
600
  const current = await getOrder(hold.order_id)
587
- if (current?.status === 'paid') {
601
+ if (current && ['paid', 'accepted'].includes(current.status)) {
588
602
  await processOrder(current.id)
589
603
  continue
590
604
  }
@@ -383,7 +383,7 @@ export async function abandonChange(event: H3Event, id: string) {
383
383
  [hold.order_id],
384
384
  tx
385
385
  )
386
- if (order?.status === 'paid') {
386
+ if (order && ['paid', 'accepted'].includes(order.status)) {
387
387
  return
388
388
  }
389
389
  await tx.query("UPDATE products.orders SET status='expired' WHERE id=$1 AND status='pending'", [hold.order_id])
@@ -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
+ }