@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.
Files changed (46) hide show
  1. package/README.md +26 -19
  2. package/package.json +7 -7
  3. package/src/AvailabilityEditor.jsx +22 -15
  4. package/src/Definition.js +1 -23
  5. package/src/SalesSection.jsx +9 -4
  6. package/src/availability-setup.page.jsx +94 -126
  7. package/src/booking-card.component.jsx +30 -33
  8. package/src/booking-detail.page.jsx +65 -204
  9. package/src/booking-resources.js +10 -0
  10. package/src/bookings.page.jsx +54 -56
  11. package/src/cancel-booking.action.js +1 -61
  12. package/src/cancel-booking.task.js +60 -0
  13. package/src/confirm-booking.action.js +1 -82
  14. package/src/confirm-booking.task.js +81 -0
  15. package/src/create-booking.action.js +1 -168
  16. package/src/create-booking.task.js +170 -0
  17. package/src/create-service.action.js +1 -0
  18. package/src/create-service.task.js +46 -0
  19. package/src/decline-booking.action.js +1 -68
  20. package/src/decline-booking.task.js +67 -0
  21. package/src/delete-service.action.js +1 -0
  22. package/src/delete-service.task.js +41 -0
  23. package/src/en.translations.json +246 -0
  24. package/src/get-availability.action.js +1 -52
  25. package/src/get-availability.task.js +51 -0
  26. package/src/get-available-slots.action.js +1 -89
  27. package/src/get-available-slots.task.js +99 -0
  28. package/src/get-services.action.js +1 -0
  29. package/src/get-services.task.js +37 -0
  30. package/src/home.page.jsx +48 -5
  31. package/src/index.js +20 -1
  32. package/src/invoke-error-message.js +10 -0
  33. package/src/list-bookings.action.js +1 -36
  34. package/src/list-bookings.task.js +35 -0
  35. package/src/locations.js +14 -0
  36. package/src/public-booking.page.jsx +200 -245
  37. package/src/save-availability.action.js +1 -73
  38. package/src/save-availability.task.js +74 -0
  39. package/src/send-booking-reminder.task.js +2 -2
  40. package/src/services.page.jsx +167 -46
  41. package/src/sv.translations.json +246 -0
  42. package/src/update-service.action.js +1 -0
  43. package/src/update-service.task.js +52 -0
  44. package/src/BookingForm.jsx +0 -82
  45. package/src/availability.page.jsx +0 -41
  46. package/src/booking-page.page.jsx +0 -42
@@ -1,7 +1,14 @@
1
- import React, { useState, useEffect, useCallback } from 'react'
2
- import { View, Title, Text, Tabs, Alert } from '@ossy/design-system'
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 res = await fetch('/actions/booking/list', {
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
- const res = await fetch('/actions/booking/confirm', {
64
- method: 'POST',
65
- headers: { 'Content-Type': 'application/json' },
66
- body: JSON.stringify({ bookingId: booking.id }),
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
- const res = await fetch('/actions/booking/decline', {
77
- method: 'POST',
78
- headers: { 'Content-Type': 'application/json' },
79
- body: JSON.stringify({ bookingId: booking.id }),
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
- const res = await fetch('/actions/booking/cancel', {
90
- method: 'POST',
91
- headers: { 'Content-Type': 'application/json' },
92
- body: JSON.stringify({ bookingId: booking.id }),
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 = `/bokningar/${booking.id}`
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} — Bokningar</Title>
112
+ <Title>{Definition.title} — {t('booking.bookings.titleSuffix')}</Title>
115
113
  <Text style={{ maxWidth: '400px' }}>
116
- Hantera bokningsförfrågningar och bekräftade möten.
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
- Laddar…
127
+ {t('booking.bookings.loading')}
130
128
  </Text>
131
129
  ) : error ? (
132
130
  <Alert variant="danger">{error}</Alert>
@@ -1,61 +1 @@
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
- }
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
- 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
- }
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
- 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
- // 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' }