@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.
- package/CHANGELOG.md +61 -0
- package/LICENSE +21 -0
- package/README.md +115 -0
- package/app/components/PlanningAppointmentCreateForm.vue +49 -0
- package/app/components/PlanningAppointmentDetails.vue +163 -0
- package/app/components/PlanningAppointmentDrawer.vue +109 -0
- package/app/components/PlanningAvailabilityCalendar.vue +525 -0
- package/app/components/PlanningDashboardOverview.vue +62 -0
- package/app/components/PlanningDashboardUpcomingAppointments.vue +81 -0
- package/app/components/PlanningPersonalCalendarSettings.vue +218 -0
- package/app/components/PlanningPersonalConnection.vue +68 -0
- package/app/components/PlanningPersonalMeetingSettings.vue +1 -0
- package/app/components/PlanningPolicyFields.vue +196 -0
- package/app/components/PlanningSettingsMembers.vue +50 -0
- package/app/components/PlanningSettingsPolicy.vue +46 -0
- package/app/components/PlanningSettingsSynchronization.vue +433 -0
- package/app/components/PlanningSlotPicker.vue +588 -0
- package/app/components/PlanningTimezoneReadOnlyHover.vue +172 -0
- package/app/components/PlanningTimezoneSelect.vue +101 -0
- package/app/composables/usePlanning.ts +180 -0
- package/app/composables/usePlanningDashboard.ts +4 -0
- package/app/composables/usePlanningTimeDisplay.ts +15 -0
- package/app/pages/admin/planning.vue +36 -0
- package/app/pages/appointments/[id].vue +13 -0
- package/app/pages/appointments/index.vue +384 -0
- package/app/pages/availability.vue +585 -0
- package/app/pages/booking/[slug].vue +298 -0
- package/app/pages/my-appointment-settings.vue +33 -0
- package/app/plugins/planning.ts +5 -0
- package/i18n/locales/en.json +260 -0
- package/i18n/locales/nl.json +260 -0
- package/migrations/0000_baseline.sql +58 -0
- package/migrations/0001_availability_calendar_title.sql +2 -0
- package/migrations/0002_job_activity.sql +11 -0
- package/migrations/0003_deduplicate_availability_jobs.sql +19 -0
- package/migrations/0004_connection_label.sql +2 -0
- package/nuxt.config.ts +15 -0
- package/package.json +55 -0
- package/portal.manifest.mjs +8 -0
- package/server/api/planning/admin/appointments/[id]/audit.get.ts +11 -0
- package/server/api/planning/admin/appointments.get.ts +11 -0
- package/server/api/planning/admin/jobs/[id]/retry.post.ts +26 -0
- package/server/api/planning/admin/jobs.get.ts +199 -0
- package/server/api/planning/admin/policy.get.ts +6 -0
- package/server/api/planning/admin/policy.put.ts +8 -0
- package/server/api/planning/admin/products/[id].put.ts +8 -0
- package/server/api/planning/admin/providers/[userId].patch.ts +8 -0
- package/server/api/planning/admin/providers.get.ts +6 -0
- package/server/api/planning/admin/retry.post.ts +31 -0
- package/server/api/planning/appointments/[id]/availability.get.ts +19 -0
- package/server/api/planning/appointments/[id]/cancel.post.ts +8 -0
- package/server/api/planning/appointments/[id]/files/[assetId].get.ts +17 -0
- package/server/api/planning/appointments/[id]/files.get.ts +28 -0
- package/server/api/planning/appointments/[id]/pending.delete.ts +8 -0
- package/server/api/planning/appointments/[id]/reschedule.put.ts +8 -0
- package/server/api/planning/appointments/[id].get.ts +6 -0
- package/server/api/planning/appointments.get.ts +4 -0
- package/server/api/planning/availability/[id].delete.ts +12 -0
- package/server/api/planning/availability/[id].put.ts +8 -0
- package/server/api/planning/availability.get.ts +6 -0
- package/server/api/planning/availability.post.ts +8 -0
- package/server/api/planning/calendar-busy.get.ts +29 -0
- package/server/api/planning/calendars.get.ts +8 -0
- package/server/api/planning/dashboard.get.ts +36 -0
- package/server/api/planning/google/notifications.post.ts +24 -0
- package/server/api/planning/oauth/[provider]/callback.get.ts +18 -0
- package/server/api/planning/oauth/[provider]/start.post.ts +8 -0
- package/server/api/planning/oauth/[provider].delete.ts +15 -0
- package/server/api/planning/products.get.ts +11 -0
- package/server/api/planning/provider-appointments.get.ts +11 -0
- package/server/api/planning/provider.get.ts +6 -0
- package/server/api/planning/provider.put.ts +8 -0
- package/server/api/store/planning/[productId]/availability.get.ts +8 -0
- package/server/api/store/planning/hold.delete.ts +11 -0
- package/server/api/store/planning/hold.get.ts +36 -0
- package/server/api/store/planning/holds.post.ts +10 -0
- package/server/db/schema/planning.ts +213 -0
- package/server/plugins/openapi.ts +36 -0
- package/server/plugins/planning.ts +6 -0
- package/server/tasks/planning/reconcile.ts +30 -0
- package/server/utils/access.ts +80 -0
- package/server/utils/adapters.ts +468 -0
- package/server/utils/appointments.ts +37 -0
- package/server/utils/booking.ts +619 -0
- package/server/utils/crypto.ts +23 -0
- package/server/utils/jobs.ts +671 -0
- package/server/utils/management.ts +427 -0
- package/server/utils/oauth.ts +104 -0
- package/server/utils/staff-appointments.ts +158 -0
- package/shared/appointment-calendar.ts +82 -0
- package/shared/availability.ts +140 -0
- package/shared/emails.ts +99 -0
- package/shared/feature.ts +78 -0
- package/shared/invitation.ts +58 -0
- package/shared/types.ts +110 -0
- 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
|
+
}
|
package/shared/emails.ts
ADDED
|
@@ -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
|
+
}
|