@ossy/booking 1.13.0 → 1.13.2
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/package.json +9 -2
- package/src/availability-engine.js +118 -0
- package/src/availability-engine.spec.js +291 -0
- package/src/availability.resource.js +6 -0
- package/src/booking-cancellation.email.jsx +47 -0
- package/src/booking-confirmation.email.jsx +107 -0
- package/src/booking-reminder.email.jsx +69 -0
- package/src/booking-request.email.jsx +96 -0
- package/src/cancel-booking.action.js +61 -0
- package/src/confirm-booking.action.js +82 -0
- package/src/create-booking.action.js +156 -0
- package/src/decline-booking.action.js +68 -0
- package/src/get-available-slots.action.js +73 -0
- package/src/index.js +2 -0
- package/src/public-booking.page.jsx +739 -0
- package/src/send-booking-reminder.task.js +76 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { EmailLayout, EmailText, EmailButton } from '@ossy/email'
|
|
3
|
+
|
|
4
|
+
export const id = 'booking/request'
|
|
5
|
+
export const subject = 'Ny bokningsförfrågan'
|
|
6
|
+
|
|
7
|
+
const SWEDISH_MONTHS = [
|
|
8
|
+
'januari', 'februari', 'mars', 'april', 'maj', 'juni',
|
|
9
|
+
'juli', 'augusti', 'september', 'oktober', 'november', 'december',
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
const SWEDISH_WEEKDAYS = [
|
|
13
|
+
'söndag', 'måndag', 'tisdag', 'onsdag', 'torsdag', 'fredag', 'lördag',
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
function formatSwedishDateTime(isoString) {
|
|
17
|
+
const d = new Date(isoString)
|
|
18
|
+
const weekday = SWEDISH_WEEKDAYS[d.getDay()]
|
|
19
|
+
const day = d.getDate()
|
|
20
|
+
const month = SWEDISH_MONTHS[d.getMonth()]
|
|
21
|
+
const year = d.getFullYear()
|
|
22
|
+
const hours = String(d.getHours()).padStart(2, '0')
|
|
23
|
+
const minutes = String(d.getMinutes()).padStart(2, '0')
|
|
24
|
+
return `${weekday} ${day} ${month} ${year} kl. ${hours}:${minutes}`
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export default function BookingRequestEmail({
|
|
28
|
+
consultantName,
|
|
29
|
+
clientName,
|
|
30
|
+
clientEmail,
|
|
31
|
+
startAt,
|
|
32
|
+
duration,
|
|
33
|
+
message,
|
|
34
|
+
confirmUrl,
|
|
35
|
+
declineUrl,
|
|
36
|
+
}) {
|
|
37
|
+
const formattedDate = formatSwedishDateTime(startAt)
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<EmailLayout>
|
|
41
|
+
<h1 style={{ color: '#111111', fontSize: 24, fontWeight: 700, margin: '0 0 24px' }}>
|
|
42
|
+
Ny bokningsförfrågan
|
|
43
|
+
</h1>
|
|
44
|
+
|
|
45
|
+
<EmailText>
|
|
46
|
+
Hej {consultantName},
|
|
47
|
+
</EmailText>
|
|
48
|
+
|
|
49
|
+
<EmailText>
|
|
50
|
+
<strong>{clientName}</strong> ({clientEmail}) vill boka en tid hos dig.
|
|
51
|
+
</EmailText>
|
|
52
|
+
|
|
53
|
+
<table cellPadding={0} cellSpacing={0} style={{ marginBottom: 32, width: '100%' }}>
|
|
54
|
+
<tbody>
|
|
55
|
+
<tr>
|
|
56
|
+
<td style={{ paddingBottom: 12, paddingRight: 24, whiteSpace: 'nowrap' }}>
|
|
57
|
+
<strong style={{ color: '#111111', fontSize: 14 }}>Datum & tid</strong>
|
|
58
|
+
</td>
|
|
59
|
+
<td style={{ paddingBottom: 12, color: '#444444', fontSize: 14 }}>
|
|
60
|
+
{formattedDate}
|
|
61
|
+
</td>
|
|
62
|
+
</tr>
|
|
63
|
+
<tr>
|
|
64
|
+
<td style={{ paddingBottom: 12, paddingRight: 24, whiteSpace: 'nowrap' }}>
|
|
65
|
+
<strong style={{ color: '#111111', fontSize: 14 }}>Längd</strong>
|
|
66
|
+
</td>
|
|
67
|
+
<td style={{ paddingBottom: 12, color: '#444444', fontSize: 14 }}>
|
|
68
|
+
{duration} minuter
|
|
69
|
+
</td>
|
|
70
|
+
</tr>
|
|
71
|
+
{message && (
|
|
72
|
+
<tr>
|
|
73
|
+
<td style={{ paddingBottom: 12, paddingRight: 24, whiteSpace: 'nowrap', verticalAlign: 'top' }}>
|
|
74
|
+
<strong style={{ color: '#111111', fontSize: 14 }}>Meddelande</strong>
|
|
75
|
+
</td>
|
|
76
|
+
<td style={{ paddingBottom: 12, color: '#444444', fontSize: 14 }}>
|
|
77
|
+
{message}
|
|
78
|
+
</td>
|
|
79
|
+
</tr>
|
|
80
|
+
)}
|
|
81
|
+
</tbody>
|
|
82
|
+
</table>
|
|
83
|
+
|
|
84
|
+
<EmailButton href={confirmUrl}>
|
|
85
|
+
Bekräfta bokning
|
|
86
|
+
</EmailButton>
|
|
87
|
+
|
|
88
|
+
<EmailText style={{ marginTop: 16, fontSize: 13, color: '#888888' }}>
|
|
89
|
+
Vill du inte bekräfta?{' '}
|
|
90
|
+
<a href={declineUrl} style={{ color: '#111111', textDecoration: 'underline' }}>
|
|
91
|
+
Avböj förfrågan
|
|
92
|
+
</a>
|
|
93
|
+
</EmailText>
|
|
94
|
+
</EmailLayout>
|
|
95
|
+
)
|
|
96
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { Aggregate } from '@ossy/event-store'
|
|
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
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { Aggregate } from '@ossy/event-store'
|
|
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
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { nanoid } from 'nanoid'
|
|
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
|
+
const weeklyWindows = availabilityResources.map(r => ({
|
|
41
|
+
dayOfWeek: r.content?.dayOfWeek,
|
|
42
|
+
startTime: r.content?.startTime ?? '09:00',
|
|
43
|
+
endTime: r.content?.endTime ?? '17:00',
|
|
44
|
+
})).filter(w => w.dayOfWeek != null)
|
|
45
|
+
|
|
46
|
+
const firstResource = availabilityResources[0]
|
|
47
|
+
const availability = {
|
|
48
|
+
weeklyWindows,
|
|
49
|
+
bufferMinutes: firstResource?.content?.bufferMinutes ?? 0,
|
|
50
|
+
blackoutDates: firstResource?.content?.blackoutDates ?? [],
|
|
51
|
+
timezone: firstResource?.content?.timezone ?? 'Europe/Stockholm',
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const allBookingResources = await ResourcesQueries.GetResources({
|
|
55
|
+
type: '@ossy/booking/booking',
|
|
56
|
+
belongsTo: consultantId,
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
const confirmedBookings = allBookingResources
|
|
60
|
+
.filter(b => b.content?.status === 'confirmed')
|
|
61
|
+
.map(b => ({
|
|
62
|
+
startAt: b.content.startsAt,
|
|
63
|
+
endAt: b.content.endsAt,
|
|
64
|
+
status: 'confirmed',
|
|
65
|
+
}))
|
|
66
|
+
|
|
67
|
+
const startDate = new Date(startAt)
|
|
68
|
+
const endAt = new Date(startDate.getTime() + duration * 60 * 1000).toISOString()
|
|
69
|
+
|
|
70
|
+
// Validate the requested slot is still open
|
|
71
|
+
const openSlots = getAvailableSlots({
|
|
72
|
+
availability,
|
|
73
|
+
bookings: confirmedBookings,
|
|
74
|
+
fromDate: startDate,
|
|
75
|
+
toDate: startDate,
|
|
76
|
+
duration,
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
const isAvailable = openSlots.some(s => s.startAt === startDate.toISOString())
|
|
80
|
+
if (!isAvailable) {
|
|
81
|
+
throw Object.assign(new Error('The requested slot is no longer available'), { status: 409 })
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Create the booking resource
|
|
85
|
+
const bookingId = nanoid()
|
|
86
|
+
const event = ResourcesEvents.Created({
|
|
87
|
+
aggregateId: bookingId,
|
|
88
|
+
type: '@ossy/booking/booking',
|
|
89
|
+
createdBy: 'public',
|
|
90
|
+
belongsTo: consultantId,
|
|
91
|
+
location: '/bookings/',
|
|
92
|
+
name: `booking-${bookingId}.json`,
|
|
93
|
+
content: {
|
|
94
|
+
clientName,
|
|
95
|
+
clientEmail,
|
|
96
|
+
clientMessage: clientMessage ?? '',
|
|
97
|
+
consultantId,
|
|
98
|
+
startsAt: startDate.toISOString(),
|
|
99
|
+
endsAt: endAt,
|
|
100
|
+
duration,
|
|
101
|
+
status: 'pending',
|
|
102
|
+
paymentStatus: 'unpaid',
|
|
103
|
+
},
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
const booking = await Aggregate.Of(Resource, event).then(Aggregate.View())
|
|
107
|
+
|
|
108
|
+
log?.info(`[booking/create] Booking ${bookingId} created`)
|
|
109
|
+
|
|
110
|
+
const emailClient = integrations?.get?.('email')
|
|
111
|
+
if (emailClient) {
|
|
112
|
+
// Notify the consultant of the new booking request
|
|
113
|
+
if (consultantEmail) {
|
|
114
|
+
try {
|
|
115
|
+
await emailClient.sendTemplate(
|
|
116
|
+
BookingRequestEmail,
|
|
117
|
+
{
|
|
118
|
+
consultantName: consultantName ?? 'Hej',
|
|
119
|
+
clientName,
|
|
120
|
+
clientEmail,
|
|
121
|
+
startAt: startDate.toISOString(),
|
|
122
|
+
duration,
|
|
123
|
+
message: clientMessage ?? '',
|
|
124
|
+
confirmUrl: confirmUrl ?? `${baseUrl ?? ''}/actions/booking/confirm?bookingId=${bookingId}`,
|
|
125
|
+
declineUrl: declineUrl ?? `${baseUrl ?? ''}/actions/booking/decline?bookingId=${bookingId}`,
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
to: consultantEmail,
|
|
129
|
+
from: 'noreply@ossy.se',
|
|
130
|
+
subject: 'Ny bokningsförfrågan',
|
|
131
|
+
},
|
|
132
|
+
)
|
|
133
|
+
log?.info(`[booking/create] Booking request email sent to consultant ${consultantEmail}`)
|
|
134
|
+
} catch (err) {
|
|
135
|
+
log?.error('[booking/create] Failed to send booking request email to consultant', err)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Send a simple receipt to the client — no .ics yet (sent after consultant confirms)
|
|
140
|
+
try {
|
|
141
|
+
await emailClient.send(
|
|
142
|
+
{
|
|
143
|
+
to: clientEmail,
|
|
144
|
+
from: 'noreply@ossy.se',
|
|
145
|
+
subject: 'Vi har tagit emot din förfrågan',
|
|
146
|
+
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`,
|
|
147
|
+
},
|
|
148
|
+
)
|
|
149
|
+
log?.info(`[booking/create] Receipt email sent to client ${clientEmail}`)
|
|
150
|
+
} catch (err) {
|
|
151
|
+
log?.error('[booking/create] Failed to send receipt email to client', err)
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return booking
|
|
156
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { Aggregate } from '@ossy/event-store'
|
|
2
|
+
import { Resource, ResourcesEvents } from '@ossy/resources'
|
|
3
|
+
import BookingCancellationEmail from './booking-cancellation.email.jsx'
|
|
4
|
+
|
|
5
|
+
export const id = 'booking/decline'
|
|
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 reason = payload?.reason ?? null
|
|
12
|
+
|
|
13
|
+
if (!bookingId) throw Object.assign(new Error('bookingId is required'), { status: 400 })
|
|
14
|
+
|
|
15
|
+
log?.info(`[booking/decline] Declining 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 decline this booking'), { status: 403 })
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (booking.content?.status === 'cancelled') {
|
|
25
|
+
throw Object.assign(new Error('Booking is already cancelled'), { status: 409 })
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (booking.content?.status === 'confirmed') {
|
|
29
|
+
throw Object.assign(new Error('Use cancel-booking to cancel an already confirmed booking'), { status: 409 })
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
await Aggregate.Of(Resource, bookingId)
|
|
33
|
+
.then(Aggregate.Add(
|
|
34
|
+
ResourcesEvents.ContentUpdated({
|
|
35
|
+
createdBy: req?.userId ?? 'system',
|
|
36
|
+
content: { ...booking.content, status: 'cancelled', declineReason: reason ?? '' },
|
|
37
|
+
}),
|
|
38
|
+
))
|
|
39
|
+
.then(Aggregate.Save())
|
|
40
|
+
|
|
41
|
+
log?.info(`[booking/decline] Booking ${bookingId} declined`)
|
|
42
|
+
|
|
43
|
+
// Send decline email to client
|
|
44
|
+
const emailClient = integrations?.get?.('email')
|
|
45
|
+
if (emailClient && booking.content?.clientEmail) {
|
|
46
|
+
try {
|
|
47
|
+
await emailClient.sendTemplate(
|
|
48
|
+
BookingCancellationEmail,
|
|
49
|
+
{
|
|
50
|
+
clientName: booking.content.clientName,
|
|
51
|
+
startAt: booking.content.startsAt,
|
|
52
|
+
duration: booking.content.duration,
|
|
53
|
+
reason,
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
to: booking.content.clientEmail,
|
|
57
|
+
from: 'noreply@ossy.se',
|
|
58
|
+
subject: 'Din bokningsförfrågan avböjdes',
|
|
59
|
+
},
|
|
60
|
+
)
|
|
61
|
+
log?.info(`[booking/decline] Decline email sent to ${booking.content.clientEmail}`)
|
|
62
|
+
} catch (err) {
|
|
63
|
+
log?.error('[booking/decline] Failed to send decline email to client', err)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return { id: bookingId, status: 'cancelled' }
|
|
68
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { ResourcesQueries } from '@ossy/resources'
|
|
2
|
+
import { getAvailableSlots } from './availability-engine.js'
|
|
3
|
+
|
|
4
|
+
export const id = 'booking/get-available-slots'
|
|
5
|
+
export const access = 'public'
|
|
6
|
+
|
|
7
|
+
const DEFAULT_DURATION = 60 // minutes
|
|
8
|
+
const DEFAULT_RANGE_DAYS = 30
|
|
9
|
+
|
|
10
|
+
export async function run({ payload, log }) {
|
|
11
|
+
const { consultantId, duration, from, to } = payload ?? {}
|
|
12
|
+
|
|
13
|
+
if (!consultantId) {
|
|
14
|
+
throw Object.assign(new Error('consultantId is required'), { status: 400 })
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
log?.info(`[booking/get-available-slots] Loading slots for consultant ${consultantId}`)
|
|
18
|
+
|
|
19
|
+
const availabilityResources = await ResourcesQueries.GetResources({
|
|
20
|
+
type: '@ossy/booking/availability',
|
|
21
|
+
belongsTo: consultantId,
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
if (!availabilityResources.length) {
|
|
25
|
+
log?.info('[booking/get-available-slots] No availability resources found, returning []')
|
|
26
|
+
return []
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const weeklyWindows = availabilityResources.map(r => ({
|
|
30
|
+
dayOfWeek: r.content?.dayOfWeek,
|
|
31
|
+
startTime: r.content?.startTime ?? '09:00',
|
|
32
|
+
endTime: r.content?.endTime ?? '17:00',
|
|
33
|
+
})).filter(w => w.dayOfWeek != null)
|
|
34
|
+
|
|
35
|
+
const firstResource = availabilityResources[0]
|
|
36
|
+
const availability = {
|
|
37
|
+
weeklyWindows,
|
|
38
|
+
bufferMinutes: firstResource?.content?.bufferMinutes ?? 0,
|
|
39
|
+
blackoutDates: firstResource?.content?.blackoutDates ?? [],
|
|
40
|
+
timezone: firstResource?.content?.timezone ?? 'Europe/Stockholm',
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const fromDate = from ? new Date(from) : new Date()
|
|
44
|
+
const toDate = to
|
|
45
|
+
? new Date(to)
|
|
46
|
+
: new Date(Date.now() + DEFAULT_RANGE_DAYS * 24 * 60 * 60 * 1000)
|
|
47
|
+
|
|
48
|
+
const allBookingResources = await ResourcesQueries.GetResources({
|
|
49
|
+
type: '@ossy/booking/booking',
|
|
50
|
+
belongsTo: consultantId,
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
const confirmedBookings = allBookingResources
|
|
54
|
+
.filter(b => b.content?.status === 'confirmed')
|
|
55
|
+
.map(b => ({
|
|
56
|
+
startAt: b.content.startsAt,
|
|
57
|
+
endAt: b.content.endsAt,
|
|
58
|
+
status: 'confirmed',
|
|
59
|
+
}))
|
|
60
|
+
|
|
61
|
+
const slotDuration = duration ?? firstResource?.content?.sessionDuration ?? DEFAULT_DURATION
|
|
62
|
+
|
|
63
|
+
const slots = getAvailableSlots({
|
|
64
|
+
availability,
|
|
65
|
+
bookings: confirmedBookings,
|
|
66
|
+
fromDate,
|
|
67
|
+
toDate,
|
|
68
|
+
duration: slotDuration,
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
log?.info(`[booking/get-available-slots] Returning ${slots.length} slots`)
|
|
72
|
+
return slots
|
|
73
|
+
}
|
package/src/index.js
CHANGED
|
@@ -4,3 +4,5 @@ export { ServiceCard } from './ServiceCard.jsx'
|
|
|
4
4
|
export { BookingForm } from './BookingForm.jsx'
|
|
5
5
|
export { BookingList } from './BookingList.jsx'
|
|
6
6
|
export { AvailabilityEditor } from './AvailabilityEditor.jsx'
|
|
7
|
+
|
|
8
|
+
export { getAvailableSlots } from './availability-engine.js'
|