@nuxt-customer-portal/planning 0.4.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.
Files changed (96) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/LICENSE +21 -0
  3. package/README.md +115 -0
  4. package/app/components/PlanningAppointmentCreateForm.vue +49 -0
  5. package/app/components/PlanningAppointmentDetails.vue +163 -0
  6. package/app/components/PlanningAppointmentDrawer.vue +109 -0
  7. package/app/components/PlanningAvailabilityCalendar.vue +525 -0
  8. package/app/components/PlanningDashboardOverview.vue +62 -0
  9. package/app/components/PlanningDashboardUpcomingAppointments.vue +81 -0
  10. package/app/components/PlanningPersonalCalendarSettings.vue +218 -0
  11. package/app/components/PlanningPersonalConnection.vue +68 -0
  12. package/app/components/PlanningPersonalMeetingSettings.vue +1 -0
  13. package/app/components/PlanningPolicyFields.vue +196 -0
  14. package/app/components/PlanningSettingsMembers.vue +50 -0
  15. package/app/components/PlanningSettingsPolicy.vue +46 -0
  16. package/app/components/PlanningSettingsSynchronization.vue +433 -0
  17. package/app/components/PlanningSlotPicker.vue +588 -0
  18. package/app/components/PlanningTimezoneReadOnlyHover.vue +172 -0
  19. package/app/components/PlanningTimezoneSelect.vue +101 -0
  20. package/app/composables/usePlanning.ts +180 -0
  21. package/app/composables/usePlanningDashboard.ts +4 -0
  22. package/app/composables/usePlanningTimeDisplay.ts +15 -0
  23. package/app/pages/admin/planning.vue +36 -0
  24. package/app/pages/appointments/[id].vue +13 -0
  25. package/app/pages/appointments/index.vue +384 -0
  26. package/app/pages/availability.vue +585 -0
  27. package/app/pages/booking/[slug].vue +298 -0
  28. package/app/pages/my-appointment-settings.vue +33 -0
  29. package/app/plugins/planning.ts +5 -0
  30. package/i18n/locales/en.json +260 -0
  31. package/i18n/locales/nl.json +260 -0
  32. package/migrations/0000_baseline.sql +58 -0
  33. package/migrations/0001_availability_calendar_title.sql +2 -0
  34. package/migrations/0002_job_activity.sql +11 -0
  35. package/migrations/0003_deduplicate_availability_jobs.sql +19 -0
  36. package/migrations/0004_connection_label.sql +2 -0
  37. package/nuxt.config.ts +15 -0
  38. package/package.json +55 -0
  39. package/portal.manifest.mjs +8 -0
  40. package/server/api/planning/admin/appointments/[id]/audit.get.ts +11 -0
  41. package/server/api/planning/admin/appointments.get.ts +11 -0
  42. package/server/api/planning/admin/jobs/[id]/retry.post.ts +26 -0
  43. package/server/api/planning/admin/jobs.get.ts +199 -0
  44. package/server/api/planning/admin/policy.get.ts +6 -0
  45. package/server/api/planning/admin/policy.put.ts +8 -0
  46. package/server/api/planning/admin/products/[id].put.ts +8 -0
  47. package/server/api/planning/admin/providers/[userId].patch.ts +8 -0
  48. package/server/api/planning/admin/providers.get.ts +6 -0
  49. package/server/api/planning/admin/retry.post.ts +31 -0
  50. package/server/api/planning/appointments/[id]/availability.get.ts +19 -0
  51. package/server/api/planning/appointments/[id]/cancel.post.ts +8 -0
  52. package/server/api/planning/appointments/[id]/files/[assetId].get.ts +17 -0
  53. package/server/api/planning/appointments/[id]/files.get.ts +28 -0
  54. package/server/api/planning/appointments/[id]/pending.delete.ts +8 -0
  55. package/server/api/planning/appointments/[id]/reschedule.put.ts +8 -0
  56. package/server/api/planning/appointments/[id].get.ts +6 -0
  57. package/server/api/planning/appointments.get.ts +4 -0
  58. package/server/api/planning/availability/[id].delete.ts +12 -0
  59. package/server/api/planning/availability/[id].put.ts +8 -0
  60. package/server/api/planning/availability.get.ts +6 -0
  61. package/server/api/planning/availability.post.ts +8 -0
  62. package/server/api/planning/calendar-busy.get.ts +29 -0
  63. package/server/api/planning/calendars.get.ts +8 -0
  64. package/server/api/planning/dashboard.get.ts +36 -0
  65. package/server/api/planning/google/notifications.post.ts +24 -0
  66. package/server/api/planning/oauth/[provider]/callback.get.ts +18 -0
  67. package/server/api/planning/oauth/[provider]/start.post.ts +8 -0
  68. package/server/api/planning/oauth/[provider].delete.ts +15 -0
  69. package/server/api/planning/products.get.ts +11 -0
  70. package/server/api/planning/provider-appointments.get.ts +11 -0
  71. package/server/api/planning/provider.get.ts +6 -0
  72. package/server/api/planning/provider.put.ts +8 -0
  73. package/server/api/store/planning/[productId]/availability.get.ts +8 -0
  74. package/server/api/store/planning/hold.delete.ts +11 -0
  75. package/server/api/store/planning/hold.get.ts +36 -0
  76. package/server/api/store/planning/holds.post.ts +10 -0
  77. package/server/db/schema/planning.ts +213 -0
  78. package/server/plugins/openapi.ts +36 -0
  79. package/server/plugins/planning.ts +6 -0
  80. package/server/tasks/planning/reconcile.ts +30 -0
  81. package/server/utils/access.ts +80 -0
  82. package/server/utils/adapters.ts +468 -0
  83. package/server/utils/appointments.ts +37 -0
  84. package/server/utils/booking.ts +619 -0
  85. package/server/utils/crypto.ts +23 -0
  86. package/server/utils/jobs.ts +671 -0
  87. package/server/utils/management.ts +427 -0
  88. package/server/utils/oauth.ts +104 -0
  89. package/server/utils/staff-appointments.ts +158 -0
  90. package/shared/appointment-calendar.ts +82 -0
  91. package/shared/availability.ts +140 -0
  92. package/shared/emails.ts +99 -0
  93. package/shared/feature.ts +78 -0
  94. package/shared/invitation.ts +58 -0
  95. package/shared/types.ts +110 -0
  96. package/shared/validation.ts +77 -0
@@ -0,0 +1,158 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { createError, type H3Event } from 'h3'
3
+ import { rows, transaction } from '@nuxt-customer-portal/products/server/utils/database'
4
+ import { parseInput } from '@nuxt-customer-portal/products/server/utils/validation'
5
+ import { staffRescheduleSchema } from '../../shared/validation'
6
+ import { generateSlots } from '../../shared/availability'
7
+ import type { Appointment, AvailabilityWindow } from '../../shared/types'
8
+ import { appointmentAccess, planningAdmin } from './access'
9
+ import { providers, lockProvider, enqueue, bookingBusy } from './booking'
10
+ import { getStore } from '@nuxt-customer-portal/products/server/utils/access'
11
+ import { auditLock } from './jobs'
12
+ import { digest } from './crypto'
13
+
14
+ export async function staffReschedule(event: H3Event, id: string, body: unknown) {
15
+ await planningAdmin(event)
16
+ const input = parseInput(staffRescheduleSchema, body),
17
+ { appointment: initial, userId } = await appointmentAccess(event, id, true)
18
+ const { users } = await providers(initial.store_id, initial.product_id),
19
+ provider = users.find((u) => u.user_id === input.providerUserId)
20
+ if (!provider) {
21
+ throw createError({ statusCode: 409, message: 'Choose an eligible team member' })
22
+ }
23
+ if (initial.snapshot.meetingProvider === 'zoom' && (await getStore()).mode !== 'sandbox') {
24
+ const [connection] = await rows(
25
+ "SELECT user_id FROM planning.connection WHERE store_id=$1 AND user_id=$2 AND provider='zoom' AND healthy",
26
+ [initial.store_id, provider.user_id]
27
+ )
28
+ if (!connection) {
29
+ throw createError({ statusCode: 409, message: 'A healthy Zoom connection is required' })
30
+ }
31
+ }
32
+ const start = new Date(input.start),
33
+ end = new Date(start.getTime() + initial.snapshot.durationMinutes * 60000),
34
+ blocked = new Date(end.getTime() + provider.grace_minutes * 60000)
35
+ const busy = await bookingBusy(initial.store_id, provider.user_id, provider.busy_calendar_ids, start, blocked)
36
+ try {
37
+ await transaction(async (tx) => {
38
+ await auditLock(tx, id)
39
+ for (const memberId of [...new Set([initial.user_id, provider.user_id])].sort()) {
40
+ await lockProvider(tx, memberId)
41
+ }
42
+ const [a] = await rows<Appointment>(
43
+ 'SELECT * FROM planning.appointment WHERE id=$1 AND store_id=$2 FOR UPDATE',
44
+ [id, initial.store_id],
45
+ tx
46
+ )
47
+ if (!a || a.status !== 'confirmed' || a.revision !== input.revision) {
48
+ throw createError({ statusCode: 409, message: 'Appointment changed; reload before editing' })
49
+ }
50
+ const [pending] = await rows(
51
+ "SELECT id FROM planning.reservation WHERE replaces_id=$1 AND status='reserved'",
52
+ [id],
53
+ tx
54
+ )
55
+ if (pending) {
56
+ throw createError({ statusCode: 409, message: 'Abandon or complete the pending customer change first' })
57
+ }
58
+ const [eligible] = await rows(
59
+ `SELECT p.user_id FROM planning.provider p JOIN public.member m ON m.user_id=p.user_id AND m.organization_id=p.store_id JOIN planning.connection c ON c.store_id=p.store_id AND c.user_id=p.user_id AND c.provider='google' AND c.healthy WHERE p.store_id=$1 AND p.user_id=$2 AND p.enabled AND p.write_calendar_id=$3 AND p.grace_minutes=$4 AND p.busy_calendar_ids=$5`,
60
+ [a.store_id, provider.user_id, provider.write_calendar_id, provider.grace_minutes, provider.busy_calendar_ids],
61
+ tx
62
+ )
63
+ const [product] = await rows(
64
+ `SELECT id FROM products.product WHERE id=$1 AND store_id=$2 AND data->'planning'->>'enabled'='true' AND data->'planning'->'providerUserIds' ? $3 FOR SHARE`,
65
+ [a.product_id, a.store_id, provider.user_id],
66
+ tx
67
+ )
68
+ if (!eligible || !product) {
69
+ throw createError({ statusCode: 409, message: 'Team member planning settings changed' })
70
+ }
71
+ const windows = await rows<{ data: AvailabilityWindow }>(
72
+ 'SELECT data FROM planning.availability WHERE store_id=$1 AND user_id=$2 AND NOT deleted',
73
+ [a.store_id, provider.user_id],
74
+ tx
75
+ )
76
+ const internal = await rows<{ start_at: Date; blocked_until: Date }>(
77
+ "SELECT start_at,blocked_until FROM planning.reservation WHERE user_id=$1 AND status IN ('reserved','confirmed') AND start_at<$3 AND blocked_until>$2 AND id<>$4",
78
+ [provider.user_id, start, blocked, a.reservation_id],
79
+ tx
80
+ )
81
+ const slots = generateSlots({
82
+ from: start,
83
+ to: end,
84
+ now: new Date(),
85
+ durationMinutes: a.snapshot.durationMinutes,
86
+ graceMinutes: provider.grace_minutes,
87
+ intervalMinutes: a.snapshot.policy.slotIntervalMinutes,
88
+ displayIntervalMinutes: a.snapshot.policy.displayIntervalMinutes ?? 30,
89
+ noticeMinutes: 0,
90
+ horizonDays: a.snapshot.policy.bookingHorizonDays,
91
+ productId: a.product_id,
92
+ providerUserId: provider.user_id,
93
+ providerName: provider.name,
94
+ windows: windows.map((w) => w.data),
95
+ busy: [
96
+ ...busy,
97
+ ...internal.map((h) => ({ start: h.start_at.toISOString(), end: h.blocked_until.toISOString() }))
98
+ ]
99
+ })
100
+ if (!slots.some((slot) => slot.start === start.toISOString())) {
101
+ throw createError({ statusCode: 409, message: 'This slot is no longer available' })
102
+ }
103
+ const reservationId = randomUUID(),
104
+ snapshot = {
105
+ ...a.snapshot,
106
+ timezone: provider.timezone,
107
+ customerTimezone: input.customerTimezone,
108
+ graceMinutes: provider.grace_minutes
109
+ }
110
+ await tx.query("UPDATE planning.reservation SET status='superseded' WHERE id=$1", [a.reservation_id])
111
+ await tx.query(
112
+ "INSERT INTO planning.reservation(id,store_id,user_id,product_id,token_hash,start_at,end_at,blocked_until,expires_at,status,confirmed_at,replaces_id,snapshot) VALUES($1,$2,$3,$4,$5,$6,$7,$8,now(),'confirmed',now(),$9,$10)",
113
+ [
114
+ reservationId,
115
+ a.store_id,
116
+ provider.user_id,
117
+ a.product_id,
118
+ digest(randomUUID()),
119
+ start,
120
+ end,
121
+ blocked,
122
+ id,
123
+ snapshot
124
+ ]
125
+ )
126
+ if (a.user_id !== provider.user_id) {
127
+ await enqueue(tx, `cleanup:${id}:${a.revision}`, 'cleanup', {
128
+ storeId: a.store_id,
129
+ userId: a.calendar_user_id || a.user_id,
130
+ calendarId: a.calendar_id,
131
+ eventId: a.calendar_event_id,
132
+ meetingId: a.meeting_id
133
+ })
134
+ }
135
+ await tx.query(
136
+ `UPDATE planning.appointment SET reservation_id=$2,user_id=$3,start_at=$4,end_at=$5,snapshot=$6,revision=revision+1,conflict=false,effects_error=NULL,meeting_id=CASE WHEN user_id=$3 THEN meeting_id ELSE NULL END,meeting_url=CASE WHEN user_id=$3 THEN meeting_url ELSE NULL END,calendar_event_id=CASE WHEN user_id=$3 THEN calendar_event_id ELSE NULL END,calendar_id=CASE WHEN user_id=$3 THEN calendar_id ELSE NULL END,calendar_user_id=CASE WHEN user_id=$3 THEN calendar_user_id ELSE NULL END WHERE id=$1`,
137
+ [id, reservationId, provider.user_id, start, end, snapshot]
138
+ )
139
+ await tx.query('INSERT INTO planning.audit(id,appointment_id,actor_id,action,data) VALUES($1,$2,$3,$4,$5)', [
140
+ randomUUID(),
141
+ id,
142
+ userId,
143
+ 'staff_rescheduled',
144
+ { previousStart: a.start_at, start, providerUserId: provider.user_id }
145
+ ])
146
+ await enqueue(tx, `appointment:${id}:${a.revision + 1}`, 'appointment', {
147
+ appointmentId: id,
148
+ revision: a.revision + 1
149
+ })
150
+ })
151
+ } catch (error) {
152
+ if ((error as { code?: string }).code === '23P01') {
153
+ throw createError({ statusCode: 409, message: 'This slot is no longer available' })
154
+ }
155
+ throw error
156
+ }
157
+ return { success: true }
158
+ }
@@ -0,0 +1,82 @@
1
+ import type { Billing, Locale } from '@nuxt-customer-portal/products/shared/types'
2
+
3
+ type AppointmentCalendarDescriptionInput = {
4
+ start: Date
5
+ end: Date
6
+ providerTimezone: string
7
+ customerTimezone: string
8
+ locale: Locale
9
+ billing: Billing
10
+ email: string
11
+ meetingUrl?: string | null
12
+ graceMinutes: number
13
+ }
14
+
15
+ const labels = {
16
+ en: {
17
+ appointmentTime: 'Appointment time',
18
+ client: 'Client',
19
+ clientTime: 'Client time',
20
+ grace: (minutes: number) => `${minutes} minutes grace time after this appointment. Manage in the portal.`
21
+ },
22
+ nl: {
23
+ appointmentTime: 'Afspraaktijd',
24
+ client: 'Klant',
25
+ clientTime: 'Tijd van de klant',
26
+ grace: (minutes: number) => `${minutes} minuten uitlooptijd na deze afspraak. Beheer dit in het portaal.`
27
+ }
28
+ } as const
29
+
30
+ function timezoneRange(start: Date, end: Date, timezone: string, locale: Locale) {
31
+ const formatter = new Intl.DateTimeFormat(locale, {
32
+ dateStyle: 'long',
33
+ timeStyle: 'short',
34
+ timeZone: timezone
35
+ })
36
+ return formatter.formatRange(start, end)
37
+ }
38
+
39
+ function countryName(country: string, locale: Locale) {
40
+ if (!country) {
41
+ return ''
42
+ }
43
+ try {
44
+ return new Intl.DisplayNames([locale], { type: 'region' }).of(country.toUpperCase()) || country
45
+ } catch {
46
+ return country
47
+ }
48
+ }
49
+
50
+ export function appointmentCalendarTitle(title: string, billing: Billing) {
51
+ const clientName = billing.name || [billing.firstName, billing.lastName].filter(Boolean).join(' ')
52
+ return clientName ? `${clientName} - ${title}` : title
53
+ }
54
+
55
+ export function appointmentCalendarDescription(input: AppointmentCalendarDescriptionInput) {
56
+ const text = labels[input.locale]
57
+ const clientName = input.billing.name || [input.billing.firstName, input.billing.lastName].filter(Boolean).join(' ')
58
+ const client = [clientName, input.email, countryName(input.billing.country, input.locale)].filter(Boolean)
59
+ const sections = [
60
+ [
61
+ `${text.appointmentTime}:`,
62
+ input.providerTimezone.replaceAll('_', ' '),
63
+ timezoneRange(input.start, input.end, input.providerTimezone, input.locale)
64
+ ].join('\n'),
65
+ [`${text.client}:`, ...client].join('\n')
66
+ ]
67
+
68
+ if (input.customerTimezone && input.customerTimezone !== input.providerTimezone) {
69
+ sections.push(
70
+ [
71
+ `${text.clientTime}:`,
72
+ input.customerTimezone.replaceAll('_', ' '),
73
+ timezoneRange(input.start, input.end, input.customerTimezone, input.locale)
74
+ ].join('\n')
75
+ )
76
+ }
77
+ if (input.meetingUrl) {
78
+ sections.push(input.meetingUrl)
79
+ }
80
+ sections.push(text.grace(input.graceMinutes))
81
+ return sections.join('\n\n')
82
+ }
@@ -0,0 +1,140 @@
1
+ import type { AvailabilityWindow, Interval, Slot } from './types'
2
+
3
+ const minute = 60000
4
+ const formatters = new Map<string, Intl.DateTimeFormat>()
5
+ /** Keep display spacing anchored to local midnight, not the first remaining available slot. */
6
+ export function isDisplaySlot(slot: Slot, timezone: string, maximumInterval = 30): boolean {
7
+ const interval = Math.min((Date.parse(slot.end) - Date.parse(slot.start)) / minute, maximumInterval)
8
+ const { time } = localParts(new Date(slot.start), timezone)
9
+ const wallMinutes = Number(time.slice(0, 2)) * 60 + Number(time.slice(3))
10
+ return interval > 0 && wallMinutes % interval === 0
11
+ }
12
+ export function localParts(instant: Date, timezone: string) {
13
+ let formatter = formatters.get(timezone)
14
+ if (!formatter) {
15
+ formatter = new Intl.DateTimeFormat('en-CA', {
16
+ timeZone: timezone,
17
+ year: 'numeric',
18
+ month: '2-digit',
19
+ day: '2-digit',
20
+ hour: '2-digit',
21
+ minute: '2-digit',
22
+ hourCycle: 'h23'
23
+ })
24
+ formatters.set(timezone, formatter)
25
+ }
26
+ const parts = formatter.formatToParts(instant)
27
+ const get = (key: string) => parts.find((p) => p.type === key)!.value
28
+ return { date: `${get('year')}-${get('month')}-${get('day')}`, time: `${get('hour')}:${get('minute')}` }
29
+ }
30
+ /** UTC iteration handles skipped/repeated wall-clock times without fixed offsets. */
31
+ export function generateSlots(input: {
32
+ from: Date
33
+ to: Date
34
+ now: Date
35
+ durationMinutes: number
36
+ graceMinutes: number
37
+ intervalMinutes: number
38
+ displayIntervalMinutes?: number
39
+ noticeMinutes: number
40
+ horizonDays: number
41
+ productId: string
42
+ providerUserId: string
43
+ providerName: string
44
+ windows: AvailabilityWindow[]
45
+ busy: Interval[]
46
+ }): Slot[] {
47
+ const slots: Slot[] = []
48
+ const busy = input.busy.map((b) => ({ start: Date.parse(b.start), end: Date.parse(b.end) }))
49
+ const lower = Math.max(input.from.getTime(), input.now.getTime() + input.noticeMinutes * minute)
50
+ const upper = Math.min(input.to.getTime(), input.now.getTime() + input.horizonDays * 86400000)
51
+ for (let start = Math.ceil(lower / minute) * minute; start < upper; start += minute) {
52
+ const end = start + input.durationMinutes * minute
53
+ if (end > upper) {
54
+ break
55
+ }
56
+ let slotTimezone = 'UTC'
57
+ const fits = input.windows.some((w) => {
58
+ if (w.productIds && !w.productIds.includes(input.productId)) {
59
+ return false
60
+ }
61
+ const first = localParts(new Date(start), w.timezone),
62
+ last = localParts(new Date(end), w.timezone)
63
+ const endsAtMidnight =
64
+ w.endTime === '24:00' && last.time === '00:00' && Date.parse(last.date) === Date.parse(first.date) + 86400000
65
+ if (
66
+ (first.date !== last.date && !endsAtMidnight) ||
67
+ first.date < w.date ||
68
+ (w.endDate && first.date > w.endDate) ||
69
+ w.exceptions.includes(first.date)
70
+ ) {
71
+ return false
72
+ }
73
+ if (w.recurring ? new Date(first.date).getUTCDay() !== new Date(w.date).getUTCDay() : first.date !== w.date) {
74
+ return false
75
+ }
76
+ const wallMinute = (s: string) => Number(s.slice(0, 2)) * 60 + Number(s.slice(3))
77
+ const matches =
78
+ first.time >= w.startTime &&
79
+ (endsAtMidnight || last.time <= w.endTime) &&
80
+ (wallMinute(first.time) - wallMinute(w.startTime)) % input.intervalMinutes === 0
81
+ if (matches) {
82
+ slotTimezone = w.timezone
83
+ }
84
+ return matches
85
+ })
86
+ if (!fits || busy.some((b) => start < b.end && end + input.graceMinutes * minute > b.start)) {
87
+ continue
88
+ }
89
+ const slot = {
90
+ start: new Date(start).toISOString(),
91
+ end: new Date(end).toISOString(),
92
+ providerUserId: input.providerUserId,
93
+ providerName: input.providerName
94
+ }
95
+ if (input.displayIntervalMinutes && !isDisplaySlot(slot, slotTimezone, input.displayIntervalMinutes)) {
96
+ continue
97
+ }
98
+ slots.push(slot)
99
+ }
100
+ return slots
101
+ }
102
+ export const canChange = (start: Date, now: Date, cutoffMinutes: number) =>
103
+ start.getTime() - now.getTime() >= cutoffMinutes * minute && start > now
104
+ export function changeFee(changes: number, freeChanges: number, fees: Record<string, number>, currency: string) {
105
+ if (changes < freeChanges) {
106
+ return 0
107
+ }
108
+ const fee = fees[currency]
109
+ if (!fee) {
110
+ throw new Error('No change fee configured for this currency')
111
+ }
112
+ return fee
113
+ }
114
+ export const refundAmount = (paid: number, alreadyRefunded: number, percentage: number) =>
115
+ Math.max(0, Math.min(paid - alreadyRefunded, Math.round((paid * percentage) / 100)))
116
+
117
+ /** Format a local wall time for calendar APIs, which require midnight as the following day's 00:00. */
118
+ export function calendarWallDateTime(date: string, time: string) {
119
+ if (time !== '24:00') {
120
+ return `${date}T${time}:00`
121
+ }
122
+ const nextDate = new Date(`${date}T00:00:00Z`)
123
+ nextDate.setUTCDate(nextDate.getUTCDate() + 1)
124
+ return `${nextDate.toISOString().slice(0, 10)}T00:00:00`
125
+ }
126
+
127
+ /** Resolve explicit local wall time; reject nonexistent times instead of silently changing them. */
128
+ export function wallInstant(date: string, time: string, timezone: string): Date {
129
+ const target = Date.parse(`${date}T${time}:00Z`)
130
+ let guess = target
131
+ for (let i = 0; i < 6; i++) {
132
+ const parts = localParts(new Date(guess), timezone)
133
+ const delta = target - Date.parse(`${parts.date}T${parts.time}:00Z`)
134
+ if (!delta) {
135
+ return new Date(guess)
136
+ }
137
+ guess += delta
138
+ }
139
+ throw new Error('This local time does not exist in the selected timezone')
140
+ }
@@ -0,0 +1,99 @@
1
+ const appointmentPlaceholders = [
2
+ 'recipient_name',
3
+ 'product',
4
+ 'time',
5
+ 'timezone',
6
+ 'meetingUrl',
7
+ 'meetingDetails',
8
+ 'instructions',
9
+ 'url'
10
+ ].map((key) => ({
11
+ key,
12
+ labelKey: `planning.${key}`,
13
+ example: key === 'recipient_name' ? 'Alex' : key
14
+ }))
15
+
16
+ const appointmentStatusPlaceholders = [
17
+ ...appointmentPlaceholders,
18
+ { key: 'status', labelKey: 'planning.status', example: 'Appointment confirmed' }
19
+ ]
20
+
21
+ export const newAppointmentEmail = {
22
+ id: 'appointment-new',
23
+ labelKey: 'planning.emailNewAppointment',
24
+ defaults: {
25
+ en: {
26
+ subject: 'Your appointment is confirmed: {{product}}',
27
+ body: 'Dear {{recipient_name}},\n\nYour new appointment has been confirmed. You can find the details below.\n\n- **Appointment:** {{product}}\n- **Date and time:** {{time}} ({{timezone}})\n\n{{meetingDetails}}\n\n{{instructions}}\n\nYou can review or manage your appointment at any time from [your appointments]({{url}}).',
28
+ footer:
29
+ 'We look forward to seeing you. If anything changes, you can always find the most recent information through the appointment link above.'
30
+ },
31
+ nl: {
32
+ subject: 'Je afspraak is bevestigd: {{product}}',
33
+ body: 'Beste {{recipient_name}},\n\nJe nieuwe afspraak is bevestigd. Hieronder vind je de gegevens.\n\n- **Afspraak:** {{product}}\n- **Datum en tijd:** {{time}} ({{timezone}})\n\n{{meetingDetails}}\n\n{{instructions}}\n\nJe kunt je afspraak op elk moment bekijken of beheren via [je afspraken]({{url}}).',
34
+ footer:
35
+ 'We kijken ernaar uit je te zien. Als er iets verandert, vind je via de bovenstaande link altijd de meest recente informatie.'
36
+ }
37
+ },
38
+ placeholders: appointmentPlaceholders
39
+ }
40
+
41
+ export const zoomLinkReadyEmail = {
42
+ id: 'appointment-zoom-ready',
43
+ labelKey: 'planning.emailZoomLinkReady',
44
+ defaults: {
45
+ en: {
46
+ subject: 'Your Zoom link is ready: {{product}}',
47
+ body: 'Dear {{recipient_name}},\n\nThe Zoom link for your appointment is now ready.\n\n- **Appointment:** {{product}}\n- **Date and time:** {{time}} ({{timezone}})\n\n{{meetingDetails}}\n\nYou can always find the current meeting details under [your appointments]({{url}}).',
48
+ footer: 'We look forward to seeing you.'
49
+ },
50
+ nl: {
51
+ subject: 'Je Zoom-link is gereed: {{product}}',
52
+ body: 'Beste {{recipient_name}},\n\nDe Zoom-link voor je afspraak is nu gereed.\n\n- **Afspraak:** {{product}}\n- **Datum en tijd:** {{time}} ({{timezone}})\n\n{{meetingDetails}}\n\nJe vindt de actuele vergadergegevens altijd bij [je afspraken]({{url}}).',
53
+ footer: 'We kijken ernaar uit je te zien.'
54
+ }
55
+ },
56
+ placeholders: appointmentPlaceholders
57
+ }
58
+
59
+ export const updatedAppointmentEmail = {
60
+ // Keep the original id so existing organization-specific customizations remain attached.
61
+ id: 'appointment',
62
+ labelKey: 'planning.emailUpdatedAppointment',
63
+ defaults: {
64
+ en: {
65
+ subject: 'Your appointment has been updated: {{product}}',
66
+ body: 'Dear {{recipient_name}},\n\nYour appointment has been updated. You can find the latest details below.\n\n**{{status}}**\n\n- **Appointment:** {{product}}\n- **Date and time:** {{time}} ({{timezone}})\n\n{{meetingDetails}}\n\n{{instructions}}\n\nYou can review or manage your appointment at any time from [your appointments]({{url}}).',
67
+ footer:
68
+ 'We look forward to seeing you. If anything changes, you can always find the most recent information through the appointment link above.'
69
+ },
70
+ nl: {
71
+ subject: 'Je afspraak is bijgewerkt: {{product}}',
72
+ body: 'Beste {{recipient_name}},\n\nJe afspraak is bijgewerkt. Hieronder vind je de meest recente gegevens.\n\n**{{status}}**\n\n- **Afspraak:** {{product}}\n- **Datum en tijd:** {{time}} ({{timezone}})\n\n{{meetingDetails}}\n\n{{instructions}}\n\nJe kunt je afspraak op elk moment bekijken of beheren via [je afspraken]({{url}}).',
73
+ footer:
74
+ 'We kijken ernaar uit je te zien. Als er iets verandert, vind je via de bovenstaande link altijd de meest recente informatie.'
75
+ }
76
+ },
77
+ placeholders: appointmentStatusPlaceholders
78
+ }
79
+
80
+ export const canceledAppointmentEmail = {
81
+ id: 'appointment-canceled',
82
+ labelKey: 'planning.emailCanceledAppointment',
83
+ defaults: {
84
+ en: {
85
+ subject: 'Your appointment has been canceled: {{product}}',
86
+ body: 'Dear {{recipient_name}},\n\nYour appointment has been canceled. The canceled appointment was scheduled as follows:\n\n- **Appointment:** {{product}}\n- **Date and time:** {{time}} ({{timezone}})\n\nYou can view your appointments or arrange another time from [your appointments]({{url}}).',
87
+ footer: 'If you have any questions about this cancellation, please contact us and we will be happy to help.'
88
+ },
89
+ nl: {
90
+ subject: 'Je afspraak is geannuleerd: {{product}}',
91
+ body: 'Beste {{recipient_name}},\n\nJe afspraak is geannuleerd. De geannuleerde afspraak stond als volgt gepland:\n\n- **Afspraak:** {{product}}\n- **Datum en tijd:** {{time}} ({{timezone}})\n\nJe kunt je afspraken bekijken of een ander tijdstip regelen via [je afspraken]({{url}}).',
92
+ footer: 'Heb je vragen over deze annulering? Neem dan gerust contact met ons op; we helpen je graag verder.'
93
+ }
94
+ },
95
+ placeholders: appointmentPlaceholders
96
+ }
97
+
98
+ // Operational notifications still use the general appointment-update template.
99
+ export const appointmentEmail = updatedAppointmentEmail
@@ -0,0 +1,78 @@
1
+ import type { PortalFeatureDefinition } from '@nuxt-customer-portal/core/shared/types/feature'
2
+
3
+ import { canceledAppointmentEmail, newAppointmentEmail, updatedAppointmentEmail, zoomLinkReadyEmail } from './emails'
4
+
5
+ export const planningFeature: PortalFeatureDefinition<'manage' | 'read'> = {
6
+ id: 'planning',
7
+ emails: [newAppointmentEmail, updatedAppointmentEmail, canceledAppointmentEmail, zoomLinkReadyEmail],
8
+ dashboardWidgets: [
9
+ {
10
+ id: 'planning-upcoming',
11
+ component: 'PlanningDashboardUpcomingAppointments',
12
+ area: 'main',
13
+ size: 'half',
14
+ order: 5
15
+ },
16
+ {
17
+ id: 'planning-overview',
18
+ component: 'PlanningDashboardOverview',
19
+ area: 'main',
20
+ size: 'half',
21
+ order: 6
22
+ }
23
+ ],
24
+ modules: [
25
+ {
26
+ id: 'planning',
27
+ labelKey: 'planning.appointments',
28
+ icon: 'i-lucide-calendar-days',
29
+ to: '/availability',
30
+ routePrefixes: ['/appointments', '/availability', '/planning/settings', '/admin/planning'],
31
+ audiences: ['providerAuthenticated'],
32
+ menuItems: [
33
+ {
34
+ id: 'availability',
35
+ labelKey: 'planning.availability',
36
+ icon: 'i-lucide-calendar-days',
37
+ to: '/availability',
38
+ audiences: ['providerAuthenticated'],
39
+ exact: true
40
+ },
41
+ {
42
+ id: 'planning-appointments',
43
+ labelKey: 'planning.appointments',
44
+ icon: 'i-lucide-calendar-check',
45
+ to: '/appointments',
46
+ audiences: ['providerAuthenticated']
47
+ },
48
+ {
49
+ id: 'planning-personal-settings',
50
+ labelKey: 'planning.myAppointmentSettings',
51
+ icon: 'i-lucide-calendar-cog',
52
+ to: '/planning/settings',
53
+ audiences: ['providerAuthenticated'],
54
+ exact: true
55
+ },
56
+ {
57
+ id: 'planning-admin',
58
+ labelKey: 'planning.settings',
59
+ icon: 'i-lucide-settings',
60
+ to: '/admin/planning',
61
+ audiences: ['providerAdmin']
62
+ }
63
+ ]
64
+ },
65
+ {
66
+ id: 'appointments',
67
+ labelKey: 'planning.appointments',
68
+ icon: 'i-lucide-calendar-check',
69
+ to: '/appointments',
70
+ routePrefixes: ['/appointments'],
71
+ audiences: ['clientAuthenticated']
72
+ }
73
+ ],
74
+ policy: {
75
+ PROVIDER: { owner: ['manage', 'read'], admin: ['manage', 'read'], member: ['read'] },
76
+ CLIENT: { owner: ['read'], admin: ['read'], member: ['read'] }
77
+ }
78
+ }
@@ -0,0 +1,58 @@
1
+ const escape = (value: string) =>
2
+ value.replace(/\\/g, '\\\\').replace(/\r?\n/g, '\\n').replace(/,/g, '\\,').replace(/;/g, '\\;')
3
+ const stamp = (date: Date) =>
4
+ date
5
+ .toISOString()
6
+ .replace(/[-:]/g, '')
7
+ .replace(/\.\d{3}/, '')
8
+ export function calendarInvitation(input: {
9
+ id: string
10
+ revision: number
11
+ title: string
12
+ start: Date
13
+ end: Date
14
+ organizer: string
15
+ attendee: string
16
+ url?: string | null
17
+ cancelled: boolean
18
+ now?: Date
19
+ }) {
20
+ const lines = [
21
+ 'BEGIN:VCALENDAR',
22
+ 'VERSION:2.0',
23
+ 'PRODID:-//Nuxt Customer Portal//Planning//EN',
24
+ `METHOD:${input.cancelled ? 'CANCEL' : 'REQUEST'}`,
25
+ 'BEGIN:VEVENT',
26
+ `UID:${input.id}@portal-planning`,
27
+ `SEQUENCE:${input.revision}`,
28
+ `DTSTAMP:${stamp(input.now || new Date())}`,
29
+ `DTSTART:${stamp(input.start)}`,
30
+ `DTEND:${stamp(input.end)}`,
31
+ `SUMMARY:${escape(input.title)}`,
32
+ `ORGANIZER:mailto:${input.organizer}`,
33
+ `ATTENDEE;RSVP=TRUE:mailto:${input.attendee}`,
34
+ `STATUS:${input.cancelled ? 'CANCELLED' : 'CONFIRMED'}`,
35
+ ...(input.url ? [`LOCATION:${escape(input.url)}`, `DESCRIPTION:${escape(input.url)}`] : []),
36
+ 'END:VEVENT',
37
+ 'END:VCALENDAR'
38
+ ]
39
+ // Fold by UTF-8 bytes, including the continuation space.
40
+ return (
41
+ lines
42
+ .flatMap((line) => {
43
+ const parts: string[] = []
44
+ let part = ''
45
+ for (const char of line) {
46
+ if (Buffer.byteLength(part + char) > 74) {
47
+ parts.push(part)
48
+ part = ' ' + char
49
+ } else {
50
+ part += char
51
+ }
52
+ }
53
+ parts.push(part)
54
+ return parts
55
+ })
56
+ .join('\r\n') + '\r\n'
57
+ )
58
+ }