@ossy/booking 1.16.2 → 3.0.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 (92) hide show
  1. package/README.md +48 -21
  2. package/package.json +11 -8
  3. package/src/AvailabilityEditor.jsx +2 -13
  4. package/src/BookingList.jsx +1 -1
  5. package/src/BookingProductHome.jsx +38 -0
  6. package/src/BookingSalesPage.jsx +55 -0
  7. package/src/Definition.js +2 -2
  8. package/src/HeroCover.jsx +5 -5
  9. package/src/SalesSection.jsx +7 -15
  10. package/src/ServiceCard.jsx +2 -2
  11. package/src/availability-action-maps.js +39 -0
  12. package/src/availability-engine.js +18 -14
  13. package/src/availability-engine.spec.js +36 -4
  14. package/src/availability-setup.page.jsx +45 -46
  15. package/src/{availability.resource.js → availability.schema.js} +1 -5
  16. package/src/booking-cancellation.email.jsx +1 -1
  17. package/src/booking-card.component.jsx +3 -3
  18. package/src/booking-confirmation.email.jsx +5 -3
  19. package/src/booking-detail.page.jsx +15 -10
  20. package/src/booking-email-token.js +45 -0
  21. package/src/booking-home-content.js +50 -0
  22. package/src/booking-list.aggregate.js +65 -0
  23. package/src/booking-reminder.email.jsx +4 -3
  24. package/src/booking-request.email.jsx +5 -2
  25. package/src/booking-resources.js +3 -3
  26. package/src/{booking.resource.js → booking.schema.js} +12 -3
  27. package/src/bookings.component.jsx +26 -25
  28. package/src/bookings.page.jsx +29 -16
  29. package/src/cancel-booking.action.js +1 -1
  30. package/src/cancel-booking.task.js +14 -17
  31. package/src/confirm-booking.action.js +1 -1
  32. package/src/confirm-booking.api.js +35 -0
  33. package/src/confirm-booking.task.js +26 -24
  34. package/src/create-booking.action.js +1 -1
  35. package/src/create-booking.task.js +73 -37
  36. package/src/create-service.action.js +5 -1
  37. package/src/create-service.task.js +7 -7
  38. package/src/decline-booking.action.js +1 -1
  39. package/src/decline-booking.api.js +35 -0
  40. package/src/decline-booking.task.js +15 -18
  41. package/src/delete-service.action.js +1 -1
  42. package/src/delete-service.task.js +12 -15
  43. package/src/en.translations.json +83 -37
  44. package/src/get-availability.action.js +1 -1
  45. package/src/get-availability.task.js +5 -4
  46. package/src/get-available-slots.action.js +1 -1
  47. package/src/get-available-slots.task.js +16 -13
  48. package/src/get-services.action.js +1 -1
  49. package/src/get-services.task.js +13 -6
  50. package/src/home.page.jsx +14 -46
  51. package/src/index.js +17 -0
  52. package/src/list-bookings.action.js +1 -1
  53. package/src/list-bookings.task.js +33 -16
  54. package/src/locations.js +6 -4
  55. package/src/open-availability-setup.action.js +5 -0
  56. package/src/open-bookings.action.js +5 -0
  57. package/src/open-create-service.action.js +5 -0
  58. package/src/open-services.action.js +5 -0
  59. package/src/provider-availability.flow.js +39 -0
  60. package/src/provider-fields.js +34 -0
  61. package/src/provider-onboard.flow.js +25 -0
  62. package/src/public-booking-form.form.js +7 -0
  63. package/src/public-booking-form.schema.js +12 -0
  64. package/src/public-booking.page.jsx +60 -57
  65. package/src/resolve-workspace-contact.js +55 -0
  66. package/src/save-availability.action.js +5 -1
  67. package/src/save-availability.task.js +26 -28
  68. package/src/schema-ids.js +7 -0
  69. package/src/select-duration-30.action.js +4 -0
  70. package/src/select-duration-60.action.js +4 -0
  71. package/src/select-duration-90.action.js +4 -0
  72. package/src/send-booking-reminder.task.js +39 -14
  73. package/src/service-card-slot.component.jsx +4 -4
  74. package/src/service-detail.component.jsx +4 -4
  75. package/src/service.form.js +7 -0
  76. package/src/service.schema.js +14 -0
  77. package/src/services.page.jsx +62 -58
  78. package/src/stories/fixtures.js +149 -0
  79. package/src/sv.translations.json +83 -37
  80. package/src/time.js +27 -0
  81. package/src/toggle-availability-day-0.action.js +4 -0
  82. package/src/toggle-availability-day-1.action.js +4 -0
  83. package/src/toggle-availability-day-2.action.js +4 -0
  84. package/src/toggle-availability-day-3.action.js +4 -0
  85. package/src/toggle-availability-day-4.action.js +4 -0
  86. package/src/toggle-availability-day-5.action.js +4 -0
  87. package/src/toggle-availability-day-6.action.js +4 -0
  88. package/src/update-service.action.js +1 -1
  89. package/src/update-service.task.js +13 -16
  90. package/src/workspace-services.js +5 -0
  91. package/src/moduleStatus.js +0 -2
  92. package/src/service.resource.js +0 -32
@@ -1,37 +1,38 @@
1
1
  import React from 'react'
2
- import { View, Title, Text, Tags } from '@ossy/design-system'
2
+ import { View, Text, Tags, Page } from '@ossy/design-system'
3
3
  import { Definition } from './Definition.js'
4
- import { moduleStatusTags } from './moduleStatus.js'
5
4
  import { BookingList } from './BookingList.jsx'
6
5
 
7
6
  export const metadata = {
8
7
  id: 'booking/bookings',
9
8
  }
10
9
 
11
- export const BookingsContent = () => (
12
- <View
13
- gap="m"
14
- surface="primary"
15
- style={{ padding: 'var(--space-m) var(--space-l)', height: '100%', overflowY: 'auto' }}
16
- >
17
- <View inset="s" gap="s">
18
- <View layout="row" gap="s" alignItems="center">
19
- <Title>{Definition.title} Bookings</Title>
20
- {moduleStatusTags(Definition).length > 0 && (
21
- <Tags tags={moduleStatusTags(Definition)} size="s" />
22
- )}
23
- </View>
10
+ export const BookingsContent = () => {
11
+ const statusTags = (Definition.status ?? [])
12
+ .map((s) => ({ beta: 'Beta', 'coming-soon': 'Coming Soon' }[s]))
13
+ .filter(Boolean)
14
+
15
+ return (
16
+ <Page>
17
+ <View gap="s">
18
+ <View layout="row" gap="s" alignItems="center">
19
+ <Text variant="heading-default" as="h1">{Definition.title} — Bookings</Text>
20
+ {statusTags.length > 0 && (
21
+ <Tags tags={statusTags} size="s" />
22
+ )}
23
+ </View>
24
24
 
25
- <Text style={{ maxWidth: '400px' }}>
26
- Incoming appointments from your clients.
27
- </Text>
28
- </View>
25
+ <Text style={{ maxWidth: '400px' }}>
26
+ Incoming appointments from your clients.
27
+ </Text>
28
+ </View>
29
29
 
30
- <View gap="s" inset="s">
31
- <Title variant="secondary">Upcoming</Title>
32
- <BookingList bookings={[]} />
33
- </View>
34
- </View>
35
- )
30
+ <View gap="s">
31
+ <Text variant="heading-secondary" as="h2">Upcoming</Text>
32
+ <BookingList bookings={[]} />
33
+ </View>
34
+ </Page>
35
+ )
36
+ }
36
37
 
37
38
  export default BookingsContent
@@ -1,6 +1,6 @@
1
1
  import React, { useState, useEffect, useCallback, useMemo } from 'react'
2
2
  import { useRouter } from '@ossy/router-react'
3
- import { View, Title, Text, Tabs, Alert, useLocale } from '@ossy/design-system'
3
+ import { View, Text, Tabs, Guide, ContentHeader, Page, useLocale } from '@ossy/design-system'
4
4
  import { useSdk } from '@ossy/sdk-react'
5
5
  import { Definition } from './Definition.js'
6
6
  import { BookingCard } from './booking-card.component.jsx'
@@ -103,19 +103,20 @@ export default function BookingsPage () {
103
103
  const empty = EMPTY_MESSAGES[activeTab] ?? EMPTY_MESSAGES.all
104
104
 
105
105
  return (
106
- <View
107
- gap="m"
108
- surface="primary"
109
- style={{ padding: 'var(--space-m) var(--space-l)', height: '100%', overflowY: 'auto' }}
106
+ <Page
107
+ header={(
108
+ <ContentHeader
109
+ inset="none"
110
+ title={(
111
+ <Text variant="heading-default" as="h1">
112
+ {Definition.title} — {t('booking.bookings.titleSuffix')}
113
+ </Text>
114
+ )}
115
+ description="booking.bookings.description"
116
+ />
117
+ )}
110
118
  >
111
- <View inset="s" gap="s">
112
- <Title>{Definition.title} — {t('booking.bookings.titleSuffix')}</Title>
113
- <Text style={{ maxWidth: '400px' }}>
114
- {t('booking.bookings.description')}
115
- </Text>
116
- </View>
117
-
118
- <View gap="m" inset="s" surface="secondary" roundness="m">
119
+ <View gap="m" surface="secondary" roundness="m">
119
120
  <Tabs
120
121
  tabs={TABS}
121
122
  activeTabId={activeTab}
@@ -127,10 +128,22 @@ export default function BookingsPage () {
127
128
  {t('booking.bookings.loading')}
128
129
  </Text>
129
130
  ) : error ? (
130
- <Alert variant="danger">{error}</Alert>
131
+ <Guide
132
+ title="booking.bookings.errorLoad.title"
133
+ far={[
134
+ {
135
+ key: 'retry',
136
+ label: 'design-system.tryAgain',
137
+ variant: 'neutral',
138
+ onClick: loadBookings,
139
+ },
140
+ ]}
141
+ >
142
+ <Text color="secondary" style={{ marginBottom: 'var(--space-m)' }}>{error}</Text>
143
+ </Guide>
131
144
  ) : visible.length === 0 ? (
132
145
  <View gap="xs" alignItems="center" inset="l">
133
- <Title variant="tertiary">{empty.heading}</Title>
146
+ <Text variant="heading-tertiary" as="h3">{empty.heading}</Text>
134
147
  <Text color="secondary">{empty.body}</Text>
135
148
  </View>
136
149
  ) : (
@@ -148,6 +161,6 @@ export default function BookingsPage () {
148
161
  </View>
149
162
  )}
150
163
  </View>
151
- </View>
164
+ </Page>
152
165
  )
153
166
  }
@@ -1 +1 @@
1
- export const metadata = { id: 'booking/cancel', access: 'authenticated' }
1
+ export const metadata = { id: '@ossy/booking/actions/cancel', access: 'authenticated' }
@@ -1,18 +1,17 @@
1
- import { Aggregate } from '@ossy/event-store'
2
- import { Resource, ResourcesEvents } from '@ossy/resources/server'
1
+ import { ResourcesEvents, mutateResource, viewResource } from '@ossy/resources/server'
3
2
  import BookingCancellationEmail from './booking-cancellation.email.jsx'
4
3
 
5
- export const metadata = { id: 'booking/cancel' }
4
+ export const metadata = { id: '@ossy/booking/tasks/cancel' }
6
5
 
7
- export async function run({ payload, req, log, integrations }) {
6
+ export async function run ({ payload, req, log, integrations }) {
8
7
  const bookingId = payload?.bookingId ?? req?.params?.bookingId
9
8
  const workspaceId = payload?.workspaceId ?? req?.workspaceId
10
9
 
11
10
  if (!bookingId) throw Object.assign(new Error('bookingId is required'), { status: 400 })
12
11
 
13
- log?.info(`[booking/cancel] Cancelling booking ${bookingId}`)
12
+ log?.info(`[booking/tasks/cancel] Cancelling booking ${bookingId}`)
14
13
 
15
- const booking = await Aggregate.Of(Resource, bookingId).then(Aggregate.View())
14
+ const booking = await viewResource(bookingId)
16
15
  if (!booking?.id) throw Object.assign(new Error('Booking not found'), { status: 404 })
17
16
 
18
17
  if (booking.belongsTo !== workspaceId) {
@@ -23,18 +22,16 @@ export async function run({ payload, req, log, integrations }) {
23
22
  throw Object.assign(new Error('Booking is already cancelled'), { status: 409 })
24
23
  }
25
24
 
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())
25
+ await mutateResource(
26
+ bookingId,
27
+ ResourcesEvents.Patched({
28
+ createdBy: req?.userId ?? 'system',
29
+ content: { ...booking.content, status: 'cancelled' },
30
+ }),
31
+ )
34
32
 
35
- log?.info(`[booking/cancel] Booking ${bookingId} cancelled`)
33
+ log?.info(`[booking/tasks/cancel] Booking ${bookingId} cancelled`)
36
34
 
37
- // Send cancellation email to client
38
35
  const emailClient = integrations?.get?.('email')
39
36
  if (emailClient && booking.content?.clientEmail) {
40
37
  try {
@@ -52,7 +49,7 @@ export async function run({ payload, req, log, integrations }) {
52
49
  },
53
50
  )
54
51
  } catch (err) {
55
- log?.error('[booking/cancel] Failed to send cancellation email', err)
52
+ log?.error('[booking/tasks/cancel] Failed to send cancellation email', err)
56
53
  }
57
54
  }
58
55
 
@@ -1 +1 @@
1
- export const metadata = { id: 'booking/confirm', access: 'workspace' }
1
+ export const metadata = { id: '@ossy/booking/actions/confirm', access: 'workspace' }
@@ -0,0 +1,35 @@
1
+ import { ActionService } from '@ossy/platform'
2
+ import { verifyBookingEmailToken } from './booking-email-token.js'
3
+
4
+ export const metadata = {
5
+ id: 'booking.confirm',
6
+ path: '/api/v0/booking/confirm',
7
+ }
8
+
9
+ function htmlPage (title, message) {
10
+ return `<!DOCTYPE html><html lang="sv"><head><meta charset="utf-8"><title>${title}</title></head><body><p>${message}</p></body></html>`
11
+ }
12
+
13
+ export default async function handle (req, res) {
14
+ if (req.method !== 'GET') {
15
+ res.setHeader('Allow', 'GET')
16
+ res.status(405).json({ error: 'Method Not Allowed' })
17
+ return
18
+ }
19
+
20
+ try {
21
+ const { bookingId, workspaceId } = await verifyBookingEmailToken(req.query.token, 'confirm')
22
+ await ActionService.invoke('@ossy/booking/actions/confirm', {
23
+ payload: { bookingId, workspaceId },
24
+ req: { ...req, workspaceId },
25
+ })
26
+ res.status(200).send(htmlPage('Bokning bekräftad', 'Bokningen är bekräftad. Klienten har fått ett bekräftelsemejl.'))
27
+ } catch (err) {
28
+ const status = err?.status ?? 500
29
+ if (status >= 500) {
30
+ res.status(status).json({ error: err?.message ?? 'Internal error' })
31
+ return
32
+ }
33
+ res.status(status).send(htmlPage('Kunde inte bekräfta', err?.message ?? 'Ogiltig eller utgången länk.'))
34
+ }
35
+ }
@@ -1,19 +1,21 @@
1
- import { Aggregate } from '@ossy/event-store'
2
- import { Resource, ResourcesEvents } from '@ossy/resources/server'
1
+ import { ResourcesEvents, mutateResource, viewResource } from '@ossy/resources/server'
3
2
  import BookingConfirmationEmail from './booking-confirmation.email.jsx'
3
+ import { resolveProviderName, workspaceIdFromContent } from './provider-fields.js'
4
+ import { resolveWorkspaceContact } from './resolve-workspace-contact.js'
4
5
 
5
- export const metadata = { id: 'booking/confirm' }
6
+ export const metadata = { id: '@ossy/booking/tasks/confirm' }
6
7
 
7
- export async function run({ payload, req, log, integrations }) {
8
+ export async function run ({ payload, req, log, integrations }) {
8
9
  const bookingId = payload?.bookingId ?? req?.params?.bookingId
9
10
  const workspaceId = payload?.workspaceId ?? req?.workspaceId
10
- const { consultantName, baseUrl } = payload ?? {}
11
+ const providerName = resolveProviderName(payload ?? {})
12
+ const { baseUrl } = payload ?? {}
11
13
 
12
14
  if (!bookingId) throw Object.assign(new Error('bookingId is required'), { status: 400 })
13
15
 
14
- log?.info(`[booking/confirm] Confirming booking ${bookingId}`)
16
+ log?.info(`[booking/tasks/confirm] Confirming booking ${bookingId}`)
15
17
 
16
- const booking = await Aggregate.Of(Resource, bookingId).then(Aggregate.View())
18
+ const booking = await viewResource(bookingId)
17
19
  if (!booking?.id) throw Object.assign(new Error('Booking not found'), { status: 404 })
18
20
 
19
21
  if (booking.belongsTo !== workspaceId) {
@@ -27,18 +29,16 @@ export async function run({ payload, req, log, integrations }) {
27
29
  )
28
30
  }
29
31
 
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())
32
+ await mutateResource(
33
+ bookingId,
34
+ ResourcesEvents.Patched({
35
+ createdBy: req?.userId ?? 'system',
36
+ content: { ...booking.content, status: 'confirmed' },
37
+ }),
38
+ )
38
39
 
39
- log?.info(`[booking/confirm] Booking ${bookingId} confirmed`)
40
+ log?.info(`[booking/tasks/confirm] Booking ${bookingId} confirmed`)
40
41
 
41
- // Send confirmation email with .ics to the client
42
42
  const emailClient = integrations?.get?.('email')
43
43
  if (emailClient && booking.content?.clientEmail) {
44
44
  try {
@@ -46,7 +46,7 @@ export async function run({ payload, req, log, integrations }) {
46
46
  BookingConfirmationEmail,
47
47
  {
48
48
  clientName: booking.content.clientName,
49
- consultantName: consultantName ?? 'Din konsult',
49
+ providerName: providerName ?? 'Din utförare',
50
50
  startAt: booking.content.startsAt,
51
51
  duration: booking.content.duration,
52
52
  baseUrl: baseUrl ?? '',
@@ -57,22 +57,24 @@ export async function run({ payload, req, log, integrations }) {
57
57
  subject: 'Bokningsbekräftelse',
58
58
  },
59
59
  )
60
- log?.info(`[booking/confirm] Confirmation email sent to ${booking.content.clientEmail}`)
60
+ log?.info(`[booking/tasks/confirm] Confirmation email sent to ${booking.content.clientEmail}`)
61
61
  } catch (err) {
62
- log?.error('[booking/confirm] Failed to send confirmation email to client', err)
62
+ log?.error('[booking/tasks/confirm] Failed to send confirmation email to client', err)
63
63
  }
64
64
 
65
- // Brief notification to the consultant that the booking is now locked in
66
- if (booking.content?.consultantId) {
65
+ const bookingWorkspaceId = booking.belongsTo ?? workspaceIdFromContent(booking.content)
66
+ const contact = await resolveWorkspaceContact(bookingWorkspaceId)
67
+ const providerEmail = contact?.providerEmail
68
+ if (providerEmail) {
67
69
  try {
68
70
  await emailClient.send({
69
- to: booking.content.consultantId,
71
+ to: providerEmail,
70
72
  from: 'noreply@ossy.se',
71
73
  subject: 'Bokning bekräftad',
72
74
  text: `Du bekräftade bokningen med ${booking.content.clientName} (${booking.content.clientEmail}). En kalenderinbjudan har skickats till klienten.`,
73
75
  })
74
76
  } catch (err) {
75
- log?.error('[booking/confirm] Failed to send confirmation notification to consultant', err)
77
+ log?.error('[booking/tasks/confirm] Failed to send confirmation notification to provider', err)
76
78
  }
77
79
  }
78
80
  }
@@ -1 +1 @@
1
- export const metadata = { id: 'booking/create', access: 'public' }
1
+ export const metadata = { id: '@ossy/booking/actions/create', access: 'public' }
@@ -1,41 +1,57 @@
1
1
  import { nanoid } from 'nanoid'
2
- import { Aggregate } from '@ossy/event-store'
3
- import { Resource, ResourcesEvents } from '@ossy/resources/server'
2
+ import { ConfigService } from '@ossy/platform'
3
+ import { ResourcesEvents, commitResource } from '@ossy/resources/server'
4
+ import { BookingSchema } from './schema-ids.js'
4
5
  import { getAvailableSlots } from './availability-engine.js'
6
+ import { toMs } from './time.js'
5
7
  import { getBookingResources } from './booking-resources.js'
8
+ import { createBookingEmailToken } from './booking-email-token.js'
6
9
  import { bookings } from './locations.js'
7
10
  import BookingRequestEmail from './booking-request.email.jsx'
11
+ import {
12
+ resolveProviderEmail,
13
+ resolveProviderName,
14
+ } from './provider-fields.js'
15
+ import { resolveWorkspaceContact, resolveWorkspaceId } from './resolve-workspace-contact.js'
8
16
 
9
- export const metadata = { id: 'booking/create' }
17
+ export const metadata = { id: '@ossy/booking/tasks/create' }
10
18
 
11
- export async function run({ payload, log, integrations }) {
19
+ export async function run({ payload, req, log, integrations }) {
12
20
  const {
13
- consultantId,
14
21
  startAt,
15
22
  duration,
16
23
  serviceId,
17
24
  clientName,
18
25
  clientEmail,
19
26
  clientMessage,
20
- consultantName,
21
- consultantEmail,
27
+ attachments,
22
28
  baseUrl,
23
29
  confirmUrl,
24
30
  declineUrl,
25
31
  } = payload ?? {}
26
32
 
27
- if (!consultantId) throw Object.assign(new Error('consultantId is required'), { status: 400 })
33
+ const workspaceId = await resolveWorkspaceId(payload)
34
+ let providerName = resolveProviderName(payload)
35
+ let providerEmail = resolveProviderEmail(payload)
36
+
37
+ if (!workspaceId) throw Object.assign(new Error('workspaceId or providerSlug is required'), { status: 400 })
38
+
39
+ if (!providerEmail || !providerName) {
40
+ const contact = await resolveWorkspaceContact(workspaceId)
41
+ providerEmail = providerEmail ?? contact?.providerEmail
42
+ providerName = providerName ?? contact?.providerName
43
+ }
28
44
  if (!startAt) throw Object.assign(new Error('startAt is required'), { status: 400 })
29
45
  if (!duration) throw Object.assign(new Error('duration is required'), { status: 400 })
30
46
  if (!clientName) throw Object.assign(new Error('clientName is required'), { status: 400 })
31
47
  if (!clientEmail) throw Object.assign(new Error('clientEmail is required'), { status: 400 })
32
48
 
33
- log?.info(`[booking/create] Creating booking for ${clientEmail} with ${consultantId}`)
49
+ log?.info(`[booking/tasks/create] Creating booking for ${clientEmail} with workspace ${workspaceId}`)
34
50
 
35
51
  // Re-validate slot availability
36
52
  const availabilityResources = await getBookingResources({
37
- type: '@ossy/booking/availability',
38
- belongsTo: consultantId,
53
+ schemaId: BookingSchema.availability,
54
+ belongsTo: workspaceId,
39
55
  })
40
56
 
41
57
  // New format: single resource with weeklyWindows array in content
@@ -65,8 +81,8 @@ export async function run({ payload, log, integrations }) {
65
81
  }
66
82
 
67
83
  const allBookingResources = await getBookingResources({
68
- type: '@ossy/booking/booking',
69
- belongsTo: consultantId,
84
+ schemaId: BookingSchema.booking,
85
+ belongsTo: workspaceId,
70
86
  })
71
87
 
72
88
  const confirmedBookings = allBookingResources
@@ -77,8 +93,12 @@ export async function run({ payload, log, integrations }) {
77
93
  status: 'confirmed',
78
94
  }))
79
95
 
80
- const startDate = new Date(startAt)
81
- const endAt = new Date(startDate.getTime() + duration * 60 * 1000).toISOString()
96
+ const startMs = toMs(startAt)
97
+ if (startMs == null) {
98
+ throw Object.assign(new Error('startAt must be a valid time'), { status: 400 })
99
+ }
100
+ const startDate = new Date(startMs)
101
+ const endsAt = startMs + duration * 60 * 1000
82
102
 
83
103
  // Validate the requested slot is still open
84
104
  const openSlots = getAvailableSlots({
@@ -89,7 +109,7 @@ export async function run({ payload, log, integrations }) {
89
109
  duration,
90
110
  })
91
111
 
92
- const isAvailable = openSlots.some(s => s.startAt === startDate.toISOString())
112
+ const isAvailable = openSlots.some(s => s.startAt === startMs)
93
113
  if (!isAvailable) {
94
114
  throw Object.assign(new Error('The requested slot is no longer available'), { status: 409 })
95
115
  }
@@ -97,10 +117,10 @@ export async function run({ payload, log, integrations }) {
97
117
  // Create the booking resource
98
118
  const bookingId = nanoid()
99
119
  const event = ResourcesEvents.Created({
100
- aggregateId: bookingId,
101
- type: '@ossy/booking/booking',
120
+ resourceId: bookingId,
121
+ schemaId: BookingSchema.booking,
102
122
  createdBy: 'public',
103
- belongsTo: consultantId,
123
+ belongsTo: workspaceId,
104
124
  location: bookings,
105
125
  name: `booking-${bookingId}.json`,
106
126
  content: {
@@ -108,61 +128,77 @@ export async function run({ payload, log, integrations }) {
108
128
  clientName,
109
129
  clientEmail,
110
130
  clientMessage: clientMessage ?? '',
111
- consultantId,
112
- startsAt: startDate.toISOString(),
113
- endsAt: endAt,
131
+ attachments: Array.isArray(attachments) ? attachments : [],
132
+ workspaceId,
133
+ providerId: workspaceId,
134
+ startsAt: startMs,
135
+ endsAt,
114
136
  duration,
115
137
  status: 'pending',
116
138
  paymentStatus: 'unpaid',
117
139
  },
118
140
  })
119
141
 
120
- const booking = await Aggregate.Of(Resource, event).then(Aggregate.View())
142
+ const booking = await commitResource(event)
121
143
 
122
- log?.info(`[booking/create] Booking ${bookingId} created`)
144
+ log?.info(`[booking/tasks/create] Booking ${bookingId} created`)
123
145
 
124
146
  const emailClient = integrations?.get?.('email')
125
147
  if (emailClient) {
126
- // Notify the consultant of the new booking request
127
- if (consultantEmail) {
148
+ const webBaseUrl = baseUrl ?? ConfigService.getWebClientBaseUrl(req)
149
+ const confirmToken = createBookingEmailToken({
150
+ bookingId,
151
+ workspaceId,
152
+ purpose: 'confirm',
153
+ })
154
+ const declineToken = createBookingEmailToken({
155
+ bookingId,
156
+ workspaceId,
157
+ purpose: 'decline',
158
+ })
159
+ const defaultConfirmUrl = `${webBaseUrl}/api/v0/booking/confirm?token=${encodeURIComponent(confirmToken)}`
160
+ const defaultDeclineUrl = `${webBaseUrl}/api/v0/booking/decline?token=${encodeURIComponent(declineToken)}`
161
+
162
+ // Notify the provider of the new booking request
163
+ if (providerEmail) {
128
164
  try {
129
165
  await emailClient.sendTemplate(
130
166
  BookingRequestEmail,
131
167
  {
132
- consultantName: consultantName ?? 'Hej',
168
+ providerName: providerName ?? 'Hej',
133
169
  clientName,
134
170
  clientEmail,
135
- startAt: startDate.toISOString(),
171
+ startAt: startMs,
136
172
  duration,
137
173
  message: clientMessage ?? '',
138
- confirmUrl: confirmUrl ?? `${baseUrl ?? ''}/actions/booking/confirm?bookingId=${bookingId}`,
139
- declineUrl: declineUrl ?? `${baseUrl ?? ''}/actions/booking/decline?bookingId=${bookingId}`,
174
+ confirmUrl: confirmUrl ?? defaultConfirmUrl,
175
+ declineUrl: declineUrl ?? defaultDeclineUrl,
140
176
  },
141
177
  {
142
- to: consultantEmail,
178
+ to: providerEmail,
143
179
  from: 'noreply@ossy.se',
144
180
  subject: 'Ny bokningsförfrågan',
145
181
  },
146
182
  )
147
- log?.info(`[booking/create] Booking request email sent to consultant ${consultantEmail}`)
183
+ log?.info(`[booking/tasks/create] Booking request email sent to provider ${providerEmail}`)
148
184
  } catch (err) {
149
- log?.error('[booking/create] Failed to send booking request email to consultant', err)
185
+ log?.error('[booking/tasks/create] Failed to send booking request email to provider', err)
150
186
  }
151
187
  }
152
188
 
153
- // Send a simple receipt to the client — no .ics yet (sent after consultant confirms)
189
+ // Send a simple receipt to the client — no .ics yet (sent after provider confirms)
154
190
  try {
155
191
  await emailClient.send(
156
192
  {
157
193
  to: clientEmail,
158
194
  from: 'noreply@ossy.se',
159
195
  subject: 'Vi har tagit emot din förfrågan',
160
- 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`,
196
+ text: `Hej ${clientName},\n\nVi har tagit emot din bokningsförfrågan. Bokningsansvarig bekräftar inom kort och du får ett nytt mejl med kalenderinbjudan när bokningen är bekräftad.\n\nMvh\nOssy`,
161
197
  },
162
198
  )
163
- log?.info(`[booking/create] Receipt email sent to client ${clientEmail}`)
199
+ log?.info(`[booking/tasks/create] Receipt email sent to client ${clientEmail}`)
164
200
  } catch (err) {
165
- log?.error('[booking/create] Failed to send receipt email to client', err)
201
+ log?.error('[booking/tasks/create] Failed to send receipt email to client', err)
166
202
  }
167
203
  }
168
204
 
@@ -1 +1,5 @@
1
- export const metadata = { id: 'booking/create-service', access: 'workspace' }
1
+ export const metadata = {
2
+ id: '@ossy/booking/actions/create-service',
3
+ access: 'workspace',
4
+ label: 'booking.services.save',
5
+ }
@@ -1,9 +1,9 @@
1
1
  import { nanoid } from 'nanoid'
2
- import { Aggregate } from '@ossy/event-store'
3
- import { Resource, ResourcesEvents } from '@ossy/resources/server'
2
+ import { ResourcesEvents, commitResource } from '@ossy/resources/server'
3
+ import { BookingSchema } from './schema-ids.js'
4
4
  import { services } from './locations.js'
5
5
 
6
- export const metadata = { id: 'booking/create-service' }
6
+ export const metadata = { id: '@ossy/booking/tasks/create-service' }
7
7
 
8
8
  export async function run ({ payload, req, log }) {
9
9
  const workspaceId = req?.workspaceId
@@ -28,11 +28,11 @@ export async function run ({ payload, req, log }) {
28
28
  active: active !== false,
29
29
  }
30
30
 
31
- log?.info(`[booking/create-service] Creating service ${resourceId} for workspace ${workspaceId}`)
31
+ log?.info(`[booking/tasks/create-service] Creating service ${resourceId} for workspace ${workspaceId}`)
32
32
 
33
33
  const event = ResourcesEvents.Created({
34
- aggregateId: resourceId,
35
- type: '@ossy/booking/service',
34
+ resourceId,
35
+ schemaId: BookingSchema.service,
36
36
  createdBy: req?.userId ?? 'system',
37
37
  belongsTo: workspaceId,
38
38
  location: services,
@@ -40,7 +40,7 @@ export async function run ({ payload, req, log }) {
40
40
  content,
41
41
  })
42
42
 
43
- const service = await Aggregate.Of(Resource, event).then(Aggregate.View())
43
+ const service = await commitResource(event)
44
44
 
45
45
  return { id: resourceId, ...content, ...service }
46
46
  }
@@ -1 +1 @@
1
- export const metadata = { id: 'booking/decline', access: 'workspace' }
1
+ export const metadata = { id: '@ossy/booking/actions/decline', access: 'workspace' }
@@ -0,0 +1,35 @@
1
+ import { ActionService } from '@ossy/platform'
2
+ import { verifyBookingEmailToken } from './booking-email-token.js'
3
+
4
+ export const metadata = {
5
+ id: 'booking.decline',
6
+ path: '/api/v0/booking/decline',
7
+ }
8
+
9
+ function htmlPage (title, message) {
10
+ return `<!DOCTYPE html><html lang="sv"><head><meta charset="utf-8"><title>${title}</title></head><body><p>${message}</p></body></html>`
11
+ }
12
+
13
+ export default async function handle (req, res) {
14
+ if (req.method !== 'GET') {
15
+ res.setHeader('Allow', 'GET')
16
+ res.status(405).json({ error: 'Method Not Allowed' })
17
+ return
18
+ }
19
+
20
+ try {
21
+ const { bookingId, workspaceId } = await verifyBookingEmailToken(req.query.token, 'decline')
22
+ await ActionService.invoke('@ossy/booking/actions/decline', {
23
+ payload: { bookingId, workspaceId },
24
+ req: { ...req, workspaceId },
25
+ })
26
+ res.status(200).send(htmlPage('Bokning avböjd', 'Bokningen har avböjts. Klienten har fått ett meddelande.'))
27
+ } catch (err) {
28
+ const status = err?.status ?? 500
29
+ if (status >= 500) {
30
+ res.status(status).json({ error: err?.message ?? 'Internal error' })
31
+ return
32
+ }
33
+ res.status(status).send(htmlPage('Kunde inte avböja', err?.message ?? 'Ogiltig eller utgången länk.'))
34
+ }
35
+ }