@ossy/booking 1.15.7 → 1.16.1
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/README.md +26 -19
- package/package.json +7 -7
- package/src/AvailabilityEditor.jsx +22 -15
- package/src/Definition.js +1 -23
- package/src/SalesSection.jsx +9 -4
- package/src/availability-setup.page.jsx +94 -126
- package/src/booking-card.component.jsx +30 -33
- package/src/booking-detail.page.jsx +65 -204
- package/src/booking-resources.js +10 -0
- package/src/bookings.page.jsx +54 -56
- package/src/cancel-booking.action.js +1 -61
- package/src/cancel-booking.task.js +60 -0
- package/src/confirm-booking.action.js +1 -82
- package/src/confirm-booking.task.js +81 -0
- package/src/create-booking.action.js +1 -168
- package/src/create-booking.task.js +170 -0
- package/src/create-service.action.js +1 -0
- package/src/create-service.task.js +46 -0
- package/src/decline-booking.action.js +1 -68
- package/src/decline-booking.task.js +67 -0
- package/src/delete-service.action.js +1 -0
- package/src/delete-service.task.js +41 -0
- package/src/en.translations.json +246 -0
- package/src/get-availability.action.js +1 -52
- package/src/get-availability.task.js +51 -0
- package/src/get-available-slots.action.js +1 -89
- package/src/get-available-slots.task.js +99 -0
- package/src/get-services.action.js +1 -0
- package/src/get-services.task.js +37 -0
- package/src/home.page.jsx +48 -5
- package/src/index.js +20 -1
- package/src/invoke-error-message.js +10 -0
- package/src/list-bookings.action.js +1 -36
- package/src/list-bookings.task.js +35 -0
- package/src/locations.js +14 -0
- package/src/public-booking.page.jsx +200 -245
- package/src/save-availability.action.js +1 -73
- package/src/save-availability.task.js +74 -0
- package/src/send-booking-reminder.task.js +2 -2
- package/src/services.page.jsx +167 -46
- package/src/sv.translations.json +246 -0
- package/src/update-service.action.js +1 -0
- package/src/update-service.task.js +52 -0
- package/src/BookingForm.jsx +0 -82
- package/src/availability.page.jsx +0 -41
- package/src/booking-page.page.jsx +0 -42
package/src/bookings.page.jsx
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
|
-
import React, { useState, useEffect, useCallback } from 'react'
|
|
2
|
-
import {
|
|
1
|
+
import React, { useState, useEffect, useCallback, useMemo } from 'react'
|
|
2
|
+
import { useRouter } from '@ossy/router-react'
|
|
3
|
+
import { View, Title, Text, Tabs, Alert, useLocale } from '@ossy/design-system'
|
|
4
|
+
import { useSdk } from '@ossy/sdk-react'
|
|
3
5
|
import { Definition } from './Definition.js'
|
|
4
6
|
import { BookingCard } from './booking-card.component.jsx'
|
|
7
|
+
import { metadata as ConfirmBooking } from './confirm-booking.action.js'
|
|
8
|
+
import { metadata as DeclineBooking } from './decline-booking.action.js'
|
|
9
|
+
import { metadata as CancelBooking } from './cancel-booking.action.js'
|
|
10
|
+
import { metadata as ListBookings } from './list-bookings.action.js'
|
|
11
|
+
import { invokeErrorMessage } from './invoke-error-message.js'
|
|
5
12
|
|
|
6
13
|
export const metadata = {
|
|
7
14
|
id: 'booking/bookings',
|
|
@@ -11,45 +18,48 @@ export const metadata = {
|
|
|
11
18
|
},
|
|
12
19
|
}
|
|
13
20
|
|
|
14
|
-
const TABS = [
|
|
15
|
-
{ id: 'pending', label: 'Förfrågningar' },
|
|
16
|
-
{ id: 'confirmed', label: 'Bekräftade' },
|
|
17
|
-
{ id: 'all', label: 'Alla' },
|
|
18
|
-
]
|
|
19
|
-
|
|
20
|
-
const EMPTY_MESSAGES = {
|
|
21
|
-
pending: { heading: 'Inga förfrågningar', body: 'Du har inga väntande bokningsförfrågningar just nu.' },
|
|
22
|
-
confirmed: { heading: 'Inga bekräftade bokningar', body: 'Bekräftade bokningar visas här.' },
|
|
23
|
-
all: { heading: 'Inga bokningar', body: 'Bokningar från dina klienter visas här.' },
|
|
24
|
-
}
|
|
25
|
-
|
|
26
21
|
export default function BookingsPage () {
|
|
22
|
+
const { t } = useLocale()
|
|
23
|
+
const sdk = useSdk()
|
|
24
|
+
const router = useRouter()
|
|
27
25
|
const [activeTab, setActiveTab] = useState('pending')
|
|
28
26
|
const [bookings, setBookings] = useState([])
|
|
29
27
|
const [loading, setLoading] = useState(true)
|
|
30
28
|
const [error, setError] = useState(null)
|
|
31
29
|
|
|
30
|
+
const TABS = useMemo(() => [
|
|
31
|
+
{ id: 'pending', label: t('booking.bookings.tab.pending') },
|
|
32
|
+
{ id: 'confirmed', label: t('booking.bookings.tab.confirmed') },
|
|
33
|
+
{ id: 'all', label: t('booking.bookings.tab.all') },
|
|
34
|
+
], [t])
|
|
35
|
+
|
|
36
|
+
const EMPTY_MESSAGES = useMemo(() => ({
|
|
37
|
+
pending: {
|
|
38
|
+
heading: t('booking.bookings.empty.pending.heading'),
|
|
39
|
+
body: t('booking.bookings.empty.pending.body'),
|
|
40
|
+
},
|
|
41
|
+
confirmed: {
|
|
42
|
+
heading: t('booking.bookings.empty.confirmed.heading'),
|
|
43
|
+
body: t('booking.bookings.empty.confirmed.body'),
|
|
44
|
+
},
|
|
45
|
+
all: {
|
|
46
|
+
heading: t('booking.bookings.empty.all.heading'),
|
|
47
|
+
body: t('booking.bookings.empty.all.body'),
|
|
48
|
+
},
|
|
49
|
+
}), [t])
|
|
50
|
+
|
|
32
51
|
const loadBookings = useCallback(async () => {
|
|
33
52
|
setLoading(true)
|
|
34
53
|
setError(null)
|
|
35
54
|
try {
|
|
36
|
-
const
|
|
37
|
-
method: 'POST',
|
|
38
|
-
headers: { 'Content-Type': 'application/json' },
|
|
39
|
-
body: JSON.stringify({}),
|
|
40
|
-
})
|
|
41
|
-
if (!res.ok) {
|
|
42
|
-
const data = await res.json().catch(() => ({}))
|
|
43
|
-
throw new Error(data?.message ?? `Fel ${res.status}`)
|
|
44
|
-
}
|
|
45
|
-
const data = await res.json()
|
|
55
|
+
const data = await sdk.invoke(ListBookings, {})
|
|
46
56
|
setBookings(data)
|
|
47
57
|
} catch (err) {
|
|
48
58
|
setError(err.message)
|
|
49
59
|
} finally {
|
|
50
60
|
setLoading(false)
|
|
51
61
|
}
|
|
52
|
-
}, [])
|
|
62
|
+
}, [sdk, t])
|
|
53
63
|
|
|
54
64
|
useEffect(() => {
|
|
55
65
|
loadBookings()
|
|
@@ -60,46 +70,34 @@ export default function BookingsPage () {
|
|
|
60
70
|
const visible = activeTab === 'pending' ? pending : activeTab === 'confirmed' ? confirmed : bookings
|
|
61
71
|
|
|
62
72
|
const handleConfirm = async (booking) => {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
if (!res.ok) {
|
|
69
|
-
const data = await res.json().catch(() => ({}))
|
|
70
|
-
throw new Error(data?.message ?? 'Bekräftning misslyckades')
|
|
73
|
+
try {
|
|
74
|
+
await sdk.invoke(ConfirmBooking, { bookingId: booking.id })
|
|
75
|
+
await loadBookings()
|
|
76
|
+
} catch (err) {
|
|
77
|
+
throw new Error(await invokeErrorMessage(err, t('booking.bookings.errorConfirm')))
|
|
71
78
|
}
|
|
72
|
-
await loadBookings()
|
|
73
79
|
}
|
|
74
80
|
|
|
75
81
|
const handleDecline = async (booking) => {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
if (!res.ok) {
|
|
82
|
-
const data = await res.json().catch(() => ({}))
|
|
83
|
-
throw new Error(data?.message ?? 'Avböjning misslyckades')
|
|
82
|
+
try {
|
|
83
|
+
await sdk.invoke(DeclineBooking, { bookingId: booking.id })
|
|
84
|
+
await loadBookings()
|
|
85
|
+
} catch (err) {
|
|
86
|
+
throw new Error(await invokeErrorMessage(err, t('booking.bookings.errorDecline')))
|
|
84
87
|
}
|
|
85
|
-
await loadBookings()
|
|
86
88
|
}
|
|
87
89
|
|
|
88
90
|
const handleCancel = async (booking) => {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
if (!res.ok) {
|
|
95
|
-
const data = await res.json().catch(() => ({}))
|
|
96
|
-
throw new Error(data?.message ?? 'Avbokning misslyckades')
|
|
91
|
+
try {
|
|
92
|
+
await sdk.invoke(CancelBooking, { bookingId: booking.id })
|
|
93
|
+
await loadBookings()
|
|
94
|
+
} catch (err) {
|
|
95
|
+
throw new Error(await invokeErrorMessage(err, t('booking.bookings.errorCancel')))
|
|
97
96
|
}
|
|
98
|
-
await loadBookings()
|
|
99
97
|
}
|
|
100
98
|
|
|
101
99
|
const handleView = (booking) => {
|
|
102
|
-
window.location.href =
|
|
100
|
+
window.location.href = router.getHref({ id: 'booking/booking-detail', params: { bookingId: booking.id } })
|
|
103
101
|
}
|
|
104
102
|
|
|
105
103
|
const empty = EMPTY_MESSAGES[activeTab] ?? EMPTY_MESSAGES.all
|
|
@@ -111,9 +109,9 @@ export default function BookingsPage () {
|
|
|
111
109
|
style={{ padding: 'var(--space-m) var(--space-l)', height: '100%', overflowY: 'auto' }}
|
|
112
110
|
>
|
|
113
111
|
<View inset="s" gap="s">
|
|
114
|
-
<Title>{Definition.title} —
|
|
112
|
+
<Title>{Definition.title} — {t('booking.bookings.titleSuffix')}</Title>
|
|
115
113
|
<Text style={{ maxWidth: '400px' }}>
|
|
116
|
-
|
|
114
|
+
{t('booking.bookings.description')}
|
|
117
115
|
</Text>
|
|
118
116
|
</View>
|
|
119
117
|
|
|
@@ -126,7 +124,7 @@ export default function BookingsPage () {
|
|
|
126
124
|
|
|
127
125
|
{loading ? (
|
|
128
126
|
<Text color="secondary" style={{ textAlign: 'center', padding: 'var(--space-l)' }}>
|
|
129
|
-
|
|
127
|
+
{t('booking.bookings.loading')}
|
|
130
128
|
</Text>
|
|
131
129
|
) : error ? (
|
|
132
130
|
<Alert variant="danger">{error}</Alert>
|
|
@@ -1,61 +1 @@
|
|
|
1
|
-
|
|
2
|
-
import { Resource, ResourcesEvents } from '@ossy/resources'
|
|
3
|
-
import BookingCancellationEmail from './booking-cancellation.email.jsx'
|
|
4
|
-
|
|
5
|
-
export const id = 'booking/cancel'
|
|
6
|
-
export const access = 'authenticated'
|
|
7
|
-
|
|
8
|
-
export async function run({ payload, req, log, integrations }) {
|
|
9
|
-
const bookingId = payload?.bookingId ?? req?.params?.bookingId
|
|
10
|
-
const workspaceId = payload?.workspaceId ?? req?.workspaceId
|
|
11
|
-
|
|
12
|
-
if (!bookingId) throw Object.assign(new Error('bookingId is required'), { status: 400 })
|
|
13
|
-
|
|
14
|
-
log?.info(`[booking/cancel] Cancelling booking ${bookingId}`)
|
|
15
|
-
|
|
16
|
-
const booking = await Aggregate.Of(Resource, bookingId).then(Aggregate.View())
|
|
17
|
-
if (!booking?.id) throw Object.assign(new Error('Booking not found'), { status: 404 })
|
|
18
|
-
|
|
19
|
-
if (booking.belongsTo !== workspaceId) {
|
|
20
|
-
throw Object.assign(new Error('You do not have permission to cancel this booking'), { status: 403 })
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
if (booking.content?.status === 'cancelled') {
|
|
24
|
-
throw Object.assign(new Error('Booking is already cancelled'), { status: 409 })
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
const updated = await Aggregate.Of(Resource, bookingId)
|
|
28
|
-
.then(Aggregate.Add(
|
|
29
|
-
ResourcesEvents.ContentUpdated({
|
|
30
|
-
createdBy: req?.userId ?? 'system',
|
|
31
|
-
content: { ...booking.content, status: 'cancelled' },
|
|
32
|
-
}),
|
|
33
|
-
))
|
|
34
|
-
.then(Aggregate.Save())
|
|
35
|
-
|
|
36
|
-
log?.info(`[booking/cancel] Booking ${bookingId} cancelled`)
|
|
37
|
-
|
|
38
|
-
// Send cancellation email to client
|
|
39
|
-
const emailClient = integrations?.get?.('email')
|
|
40
|
-
if (emailClient && booking.content?.clientEmail) {
|
|
41
|
-
try {
|
|
42
|
-
await emailClient.sendTemplate(
|
|
43
|
-
BookingCancellationEmail,
|
|
44
|
-
{
|
|
45
|
-
clientName: booking.content.clientName,
|
|
46
|
-
startAt: booking.content.startsAt,
|
|
47
|
-
duration: booking.content.duration,
|
|
48
|
-
},
|
|
49
|
-
{
|
|
50
|
-
to: booking.content.clientEmail,
|
|
51
|
-
from: 'noreply@ossy.se',
|
|
52
|
-
subject: 'Din bokning har avbokats',
|
|
53
|
-
},
|
|
54
|
-
)
|
|
55
|
-
} catch (err) {
|
|
56
|
-
log?.error('[booking/cancel] Failed to send cancellation email', err)
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
return { id: bookingId, status: 'cancelled' }
|
|
61
|
-
}
|
|
1
|
+
export const metadata = { id: 'booking/cancel', access: 'authenticated' }
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { Aggregate } from '@ossy/event-store'
|
|
2
|
+
import { Resource, ResourcesEvents } from '@ossy/resources/server'
|
|
3
|
+
import BookingCancellationEmail from './booking-cancellation.email.jsx'
|
|
4
|
+
|
|
5
|
+
export const metadata = { id: 'booking/cancel' }
|
|
6
|
+
|
|
7
|
+
export async function run({ payload, req, log, integrations }) {
|
|
8
|
+
const bookingId = payload?.bookingId ?? req?.params?.bookingId
|
|
9
|
+
const workspaceId = payload?.workspaceId ?? req?.workspaceId
|
|
10
|
+
|
|
11
|
+
if (!bookingId) throw Object.assign(new Error('bookingId is required'), { status: 400 })
|
|
12
|
+
|
|
13
|
+
log?.info(`[booking/cancel] Cancelling booking ${bookingId}`)
|
|
14
|
+
|
|
15
|
+
const booking = await Aggregate.Of(Resource, bookingId).then(Aggregate.View())
|
|
16
|
+
if (!booking?.id) throw Object.assign(new Error('Booking not found'), { status: 404 })
|
|
17
|
+
|
|
18
|
+
if (booking.belongsTo !== workspaceId) {
|
|
19
|
+
throw Object.assign(new Error('You do not have permission to cancel this booking'), { status: 403 })
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (booking.content?.status === 'cancelled') {
|
|
23
|
+
throw Object.assign(new Error('Booking is already cancelled'), { status: 409 })
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const updated = await Aggregate.Of(Resource, bookingId)
|
|
27
|
+
.then(Aggregate.Add(
|
|
28
|
+
ResourcesEvents.ContentUpdated({
|
|
29
|
+
createdBy: req?.userId ?? 'system',
|
|
30
|
+
content: { ...booking.content, status: 'cancelled' },
|
|
31
|
+
}),
|
|
32
|
+
))
|
|
33
|
+
.then(Aggregate.Save())
|
|
34
|
+
|
|
35
|
+
log?.info(`[booking/cancel] Booking ${bookingId} cancelled`)
|
|
36
|
+
|
|
37
|
+
// Send cancellation email to client
|
|
38
|
+
const emailClient = integrations?.get?.('email')
|
|
39
|
+
if (emailClient && booking.content?.clientEmail) {
|
|
40
|
+
try {
|
|
41
|
+
await emailClient.sendTemplate(
|
|
42
|
+
BookingCancellationEmail,
|
|
43
|
+
{
|
|
44
|
+
clientName: booking.content.clientName,
|
|
45
|
+
startAt: booking.content.startsAt,
|
|
46
|
+
duration: booking.content.duration,
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
to: booking.content.clientEmail,
|
|
50
|
+
from: 'noreply@ossy.se',
|
|
51
|
+
subject: 'Din bokning har avbokats',
|
|
52
|
+
},
|
|
53
|
+
)
|
|
54
|
+
} catch (err) {
|
|
55
|
+
log?.error('[booking/cancel] Failed to send cancellation email', err)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return { id: bookingId, status: 'cancelled' }
|
|
60
|
+
}
|
|
@@ -1,82 +1 @@
|
|
|
1
|
-
|
|
2
|
-
import { Resource, ResourcesEvents } from '@ossy/resources'
|
|
3
|
-
import BookingConfirmationEmail from './booking-confirmation.email.jsx'
|
|
4
|
-
|
|
5
|
-
export const id = 'booking/confirm'
|
|
6
|
-
export const access = 'workspace'
|
|
7
|
-
|
|
8
|
-
export async function run({ payload, req, log, integrations }) {
|
|
9
|
-
const bookingId = payload?.bookingId ?? req?.params?.bookingId
|
|
10
|
-
const workspaceId = payload?.workspaceId ?? req?.workspaceId
|
|
11
|
-
const { consultantName, baseUrl } = payload ?? {}
|
|
12
|
-
|
|
13
|
-
if (!bookingId) throw Object.assign(new Error('bookingId is required'), { status: 400 })
|
|
14
|
-
|
|
15
|
-
log?.info(`[booking/confirm] Confirming booking ${bookingId}`)
|
|
16
|
-
|
|
17
|
-
const booking = await Aggregate.Of(Resource, bookingId).then(Aggregate.View())
|
|
18
|
-
if (!booking?.id) throw Object.assign(new Error('Booking not found'), { status: 404 })
|
|
19
|
-
|
|
20
|
-
if (booking.belongsTo !== workspaceId) {
|
|
21
|
-
throw Object.assign(new Error('You do not have permission to confirm this booking'), { status: 403 })
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
if (booking.content?.status !== 'pending') {
|
|
25
|
-
throw Object.assign(
|
|
26
|
-
new Error(`Booking cannot be confirmed from status '${booking.content?.status}'`),
|
|
27
|
-
{ status: 409 },
|
|
28
|
-
)
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
await Aggregate.Of(Resource, bookingId)
|
|
32
|
-
.then(Aggregate.Add(
|
|
33
|
-
ResourcesEvents.ContentUpdated({
|
|
34
|
-
createdBy: req?.userId ?? 'system',
|
|
35
|
-
content: { ...booking.content, status: 'confirmed' },
|
|
36
|
-
}),
|
|
37
|
-
))
|
|
38
|
-
.then(Aggregate.Save())
|
|
39
|
-
|
|
40
|
-
log?.info(`[booking/confirm] Booking ${bookingId} confirmed`)
|
|
41
|
-
|
|
42
|
-
// Send confirmation email with .ics to the client
|
|
43
|
-
const emailClient = integrations?.get?.('email')
|
|
44
|
-
if (emailClient && booking.content?.clientEmail) {
|
|
45
|
-
try {
|
|
46
|
-
await emailClient.sendTemplate(
|
|
47
|
-
BookingConfirmationEmail,
|
|
48
|
-
{
|
|
49
|
-
clientName: booking.content.clientName,
|
|
50
|
-
consultantName: consultantName ?? 'Din konsult',
|
|
51
|
-
startAt: booking.content.startsAt,
|
|
52
|
-
duration: booking.content.duration,
|
|
53
|
-
baseUrl: baseUrl ?? '',
|
|
54
|
-
},
|
|
55
|
-
{
|
|
56
|
-
to: booking.content.clientEmail,
|
|
57
|
-
from: 'noreply@ossy.se',
|
|
58
|
-
subject: 'Bokningsbekräftelse',
|
|
59
|
-
},
|
|
60
|
-
)
|
|
61
|
-
log?.info(`[booking/confirm] Confirmation email sent to ${booking.content.clientEmail}`)
|
|
62
|
-
} catch (err) {
|
|
63
|
-
log?.error('[booking/confirm] Failed to send confirmation email to client', err)
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
// Brief notification to the consultant that the booking is now locked in
|
|
67
|
-
if (booking.content?.consultantId) {
|
|
68
|
-
try {
|
|
69
|
-
await emailClient.send({
|
|
70
|
-
to: booking.content.consultantId,
|
|
71
|
-
from: 'noreply@ossy.se',
|
|
72
|
-
subject: 'Bokning bekräftad',
|
|
73
|
-
text: `Du bekräftade bokningen med ${booking.content.clientName} (${booking.content.clientEmail}). En kalenderinbjudan har skickats till klienten.`,
|
|
74
|
-
})
|
|
75
|
-
} catch (err) {
|
|
76
|
-
log?.error('[booking/confirm] Failed to send confirmation notification to consultant', err)
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
return { id: bookingId, status: 'confirmed' }
|
|
82
|
-
}
|
|
1
|
+
export const metadata = { id: 'booking/confirm', access: 'workspace' }
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { Aggregate } from '@ossy/event-store'
|
|
2
|
+
import { Resource, ResourcesEvents } from '@ossy/resources/server'
|
|
3
|
+
import BookingConfirmationEmail from './booking-confirmation.email.jsx'
|
|
4
|
+
|
|
5
|
+
export const metadata = { id: 'booking/confirm' }
|
|
6
|
+
|
|
7
|
+
export async function run({ payload, req, log, integrations }) {
|
|
8
|
+
const bookingId = payload?.bookingId ?? req?.params?.bookingId
|
|
9
|
+
const workspaceId = payload?.workspaceId ?? req?.workspaceId
|
|
10
|
+
const { consultantName, baseUrl } = payload ?? {}
|
|
11
|
+
|
|
12
|
+
if (!bookingId) throw Object.assign(new Error('bookingId is required'), { status: 400 })
|
|
13
|
+
|
|
14
|
+
log?.info(`[booking/confirm] Confirming booking ${bookingId}`)
|
|
15
|
+
|
|
16
|
+
const booking = await Aggregate.Of(Resource, bookingId).then(Aggregate.View())
|
|
17
|
+
if (!booking?.id) throw Object.assign(new Error('Booking not found'), { status: 404 })
|
|
18
|
+
|
|
19
|
+
if (booking.belongsTo !== workspaceId) {
|
|
20
|
+
throw Object.assign(new Error('You do not have permission to confirm this booking'), { status: 403 })
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (booking.content?.status !== 'pending') {
|
|
24
|
+
throw Object.assign(
|
|
25
|
+
new Error(`Booking cannot be confirmed from status '${booking.content?.status}'`),
|
|
26
|
+
{ status: 409 },
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
await Aggregate.Of(Resource, bookingId)
|
|
31
|
+
.then(Aggregate.Add(
|
|
32
|
+
ResourcesEvents.ContentUpdated({
|
|
33
|
+
createdBy: req?.userId ?? 'system',
|
|
34
|
+
content: { ...booking.content, status: 'confirmed' },
|
|
35
|
+
}),
|
|
36
|
+
))
|
|
37
|
+
.then(Aggregate.Save())
|
|
38
|
+
|
|
39
|
+
log?.info(`[booking/confirm] Booking ${bookingId} confirmed`)
|
|
40
|
+
|
|
41
|
+
// Send confirmation email with .ics to the client
|
|
42
|
+
const emailClient = integrations?.get?.('email')
|
|
43
|
+
if (emailClient && booking.content?.clientEmail) {
|
|
44
|
+
try {
|
|
45
|
+
await emailClient.sendTemplate(
|
|
46
|
+
BookingConfirmationEmail,
|
|
47
|
+
{
|
|
48
|
+
clientName: booking.content.clientName,
|
|
49
|
+
consultantName: consultantName ?? 'Din konsult',
|
|
50
|
+
startAt: booking.content.startsAt,
|
|
51
|
+
duration: booking.content.duration,
|
|
52
|
+
baseUrl: baseUrl ?? '',
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
to: booking.content.clientEmail,
|
|
56
|
+
from: 'noreply@ossy.se',
|
|
57
|
+
subject: 'Bokningsbekräftelse',
|
|
58
|
+
},
|
|
59
|
+
)
|
|
60
|
+
log?.info(`[booking/confirm] Confirmation email sent to ${booking.content.clientEmail}`)
|
|
61
|
+
} catch (err) {
|
|
62
|
+
log?.error('[booking/confirm] Failed to send confirmation email to client', err)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Brief notification to the consultant that the booking is now locked in
|
|
66
|
+
if (booking.content?.consultantId) {
|
|
67
|
+
try {
|
|
68
|
+
await emailClient.send({
|
|
69
|
+
to: booking.content.consultantId,
|
|
70
|
+
from: 'noreply@ossy.se',
|
|
71
|
+
subject: 'Bokning bekräftad',
|
|
72
|
+
text: `Du bekräftade bokningen med ${booking.content.clientName} (${booking.content.clientEmail}). En kalenderinbjudan har skickats till klienten.`,
|
|
73
|
+
})
|
|
74
|
+
} catch (err) {
|
|
75
|
+
log?.error('[booking/confirm] Failed to send confirmation notification to consultant', err)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return { id: bookingId, status: 'confirmed' }
|
|
81
|
+
}
|
|
@@ -1,168 +1 @@
|
|
|
1
|
-
|
|
2
|
-
import { Aggregate } from '@ossy/event-store'
|
|
3
|
-
import { Resource, ResourcesEvents } from '@ossy/resources'
|
|
4
|
-
import { ResourcesQueries } from '@ossy/resources'
|
|
5
|
-
import { getAvailableSlots } from './availability-engine.js'
|
|
6
|
-
import BookingRequestEmail from './booking-request.email.jsx'
|
|
7
|
-
|
|
8
|
-
export const id = 'booking/create'
|
|
9
|
-
export const access = 'public'
|
|
10
|
-
|
|
11
|
-
export async function run({ payload, log, integrations }) {
|
|
12
|
-
const {
|
|
13
|
-
consultantId,
|
|
14
|
-
startAt,
|
|
15
|
-
duration,
|
|
16
|
-
clientName,
|
|
17
|
-
clientEmail,
|
|
18
|
-
clientMessage,
|
|
19
|
-
consultantName,
|
|
20
|
-
consultantEmail,
|
|
21
|
-
baseUrl,
|
|
22
|
-
confirmUrl,
|
|
23
|
-
declineUrl,
|
|
24
|
-
} = payload ?? {}
|
|
25
|
-
|
|
26
|
-
if (!consultantId) throw Object.assign(new Error('consultantId is required'), { status: 400 })
|
|
27
|
-
if (!startAt) throw Object.assign(new Error('startAt is required'), { status: 400 })
|
|
28
|
-
if (!duration) throw Object.assign(new Error('duration is required'), { status: 400 })
|
|
29
|
-
if (!clientName) throw Object.assign(new Error('clientName is required'), { status: 400 })
|
|
30
|
-
if (!clientEmail) throw Object.assign(new Error('clientEmail is required'), { status: 400 })
|
|
31
|
-
|
|
32
|
-
log?.info(`[booking/create] Creating booking for ${clientEmail} with ${consultantId}`)
|
|
33
|
-
|
|
34
|
-
// Re-validate slot availability
|
|
35
|
-
const availabilityResources = await ResourcesQueries.GetResources({
|
|
36
|
-
type: '@ossy/booking/availability',
|
|
37
|
-
belongsTo: consultantId,
|
|
38
|
-
})
|
|
39
|
-
|
|
40
|
-
// New format: single resource with weeklyWindows array in content
|
|
41
|
-
const configResource = availabilityResources.find(r => Array.isArray(r.content?.weeklyWindows))
|
|
42
|
-
let weeklyWindows, firstResource
|
|
43
|
-
|
|
44
|
-
if (configResource) {
|
|
45
|
-
weeklyWindows = configResource.content.weeklyWindows ?? []
|
|
46
|
-
firstResource = configResource
|
|
47
|
-
} else {
|
|
48
|
-
// Legacy format: one resource per day
|
|
49
|
-
firstResource = availabilityResources[0]
|
|
50
|
-
weeklyWindows = availabilityResources
|
|
51
|
-
.map(r => ({
|
|
52
|
-
dayOfWeek: r.content?.dayOfWeek,
|
|
53
|
-
startTime: r.content?.startTime ?? '09:00',
|
|
54
|
-
endTime: r.content?.endTime ?? '17:00',
|
|
55
|
-
}))
|
|
56
|
-
.filter(w => w.dayOfWeek != null)
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
const availability = {
|
|
60
|
-
weeklyWindows,
|
|
61
|
-
bufferMinutes: firstResource?.content?.bufferMinutes ?? 0,
|
|
62
|
-
blackoutDates: firstResource?.content?.blackoutDates ?? [],
|
|
63
|
-
timezone: firstResource?.content?.timezone ?? 'Europe/Stockholm',
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
const allBookingResources = await ResourcesQueries.GetResources({
|
|
67
|
-
type: '@ossy/booking/booking',
|
|
68
|
-
belongsTo: consultantId,
|
|
69
|
-
})
|
|
70
|
-
|
|
71
|
-
const confirmedBookings = allBookingResources
|
|
72
|
-
.filter(b => b.content?.status === 'confirmed')
|
|
73
|
-
.map(b => ({
|
|
74
|
-
startAt: b.content.startsAt,
|
|
75
|
-
endAt: b.content.endsAt,
|
|
76
|
-
status: 'confirmed',
|
|
77
|
-
}))
|
|
78
|
-
|
|
79
|
-
const startDate = new Date(startAt)
|
|
80
|
-
const endAt = new Date(startDate.getTime() + duration * 60 * 1000).toISOString()
|
|
81
|
-
|
|
82
|
-
// Validate the requested slot is still open
|
|
83
|
-
const openSlots = getAvailableSlots({
|
|
84
|
-
availability,
|
|
85
|
-
bookings: confirmedBookings,
|
|
86
|
-
fromDate: startDate,
|
|
87
|
-
toDate: startDate,
|
|
88
|
-
duration,
|
|
89
|
-
})
|
|
90
|
-
|
|
91
|
-
const isAvailable = openSlots.some(s => s.startAt === startDate.toISOString())
|
|
92
|
-
if (!isAvailable) {
|
|
93
|
-
throw Object.assign(new Error('The requested slot is no longer available'), { status: 409 })
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
// Create the booking resource
|
|
97
|
-
const bookingId = nanoid()
|
|
98
|
-
const event = ResourcesEvents.Created({
|
|
99
|
-
aggregateId: bookingId,
|
|
100
|
-
type: '@ossy/booking/booking',
|
|
101
|
-
createdBy: 'public',
|
|
102
|
-
belongsTo: consultantId,
|
|
103
|
-
location: '/bookings/',
|
|
104
|
-
name: `booking-${bookingId}.json`,
|
|
105
|
-
content: {
|
|
106
|
-
clientName,
|
|
107
|
-
clientEmail,
|
|
108
|
-
clientMessage: clientMessage ?? '',
|
|
109
|
-
consultantId,
|
|
110
|
-
startsAt: startDate.toISOString(),
|
|
111
|
-
endsAt: endAt,
|
|
112
|
-
duration,
|
|
113
|
-
status: 'pending',
|
|
114
|
-
paymentStatus: 'unpaid',
|
|
115
|
-
},
|
|
116
|
-
})
|
|
117
|
-
|
|
118
|
-
const booking = await Aggregate.Of(Resource, event).then(Aggregate.View())
|
|
119
|
-
|
|
120
|
-
log?.info(`[booking/create] Booking ${bookingId} created`)
|
|
121
|
-
|
|
122
|
-
const emailClient = integrations?.get?.('email')
|
|
123
|
-
if (emailClient) {
|
|
124
|
-
// Notify the consultant of the new booking request
|
|
125
|
-
if (consultantEmail) {
|
|
126
|
-
try {
|
|
127
|
-
await emailClient.sendTemplate(
|
|
128
|
-
BookingRequestEmail,
|
|
129
|
-
{
|
|
130
|
-
consultantName: consultantName ?? 'Hej',
|
|
131
|
-
clientName,
|
|
132
|
-
clientEmail,
|
|
133
|
-
startAt: startDate.toISOString(),
|
|
134
|
-
duration,
|
|
135
|
-
message: clientMessage ?? '',
|
|
136
|
-
confirmUrl: confirmUrl ?? `${baseUrl ?? ''}/actions/booking/confirm?bookingId=${bookingId}`,
|
|
137
|
-
declineUrl: declineUrl ?? `${baseUrl ?? ''}/actions/booking/decline?bookingId=${bookingId}`,
|
|
138
|
-
},
|
|
139
|
-
{
|
|
140
|
-
to: consultantEmail,
|
|
141
|
-
from: 'noreply@ossy.se',
|
|
142
|
-
subject: 'Ny bokningsförfrågan',
|
|
143
|
-
},
|
|
144
|
-
)
|
|
145
|
-
log?.info(`[booking/create] Booking request email sent to consultant ${consultantEmail}`)
|
|
146
|
-
} catch (err) {
|
|
147
|
-
log?.error('[booking/create] Failed to send booking request email to consultant', err)
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
// Send a simple receipt to the client — no .ics yet (sent after consultant confirms)
|
|
152
|
-
try {
|
|
153
|
-
await emailClient.send(
|
|
154
|
-
{
|
|
155
|
-
to: clientEmail,
|
|
156
|
-
from: 'noreply@ossy.se',
|
|
157
|
-
subject: 'Vi har tagit emot din förfrågan',
|
|
158
|
-
text: `Hej ${clientName},\n\nVi har tagit emot din bokningsförfrågan. Konsulten bekräftar inom kort och du får ett nytt mejl med kalenderinbjudan när bokningen är bekräftad.\n\nMvh\nOssy`,
|
|
159
|
-
},
|
|
160
|
-
)
|
|
161
|
-
log?.info(`[booking/create] Receipt email sent to client ${clientEmail}`)
|
|
162
|
-
} catch (err) {
|
|
163
|
-
log?.error('[booking/create] Failed to send receipt email to client', err)
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
return booking
|
|
168
|
-
}
|
|
1
|
+
export const metadata = { id: 'booking/create', access: 'public' }
|