@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.
- package/README.md +48 -21
- package/package.json +11 -8
- package/src/AvailabilityEditor.jsx +2 -13
- package/src/BookingList.jsx +1 -1
- package/src/BookingProductHome.jsx +38 -0
- package/src/BookingSalesPage.jsx +55 -0
- package/src/Definition.js +2 -2
- package/src/HeroCover.jsx +5 -5
- package/src/SalesSection.jsx +7 -15
- package/src/ServiceCard.jsx +2 -2
- package/src/availability-action-maps.js +39 -0
- package/src/availability-engine.js +18 -14
- package/src/availability-engine.spec.js +36 -4
- package/src/availability-setup.page.jsx +45 -46
- package/src/{availability.resource.js → availability.schema.js} +1 -5
- package/src/booking-cancellation.email.jsx +1 -1
- package/src/booking-card.component.jsx +3 -3
- package/src/booking-confirmation.email.jsx +5 -3
- package/src/booking-detail.page.jsx +15 -10
- package/src/booking-email-token.js +45 -0
- package/src/booking-home-content.js +50 -0
- package/src/booking-list.aggregate.js +65 -0
- package/src/booking-reminder.email.jsx +4 -3
- package/src/booking-request.email.jsx +5 -2
- package/src/booking-resources.js +3 -3
- package/src/{booking.resource.js → booking.schema.js} +12 -3
- package/src/bookings.component.jsx +26 -25
- package/src/bookings.page.jsx +29 -16
- package/src/cancel-booking.action.js +1 -1
- package/src/cancel-booking.task.js +14 -17
- package/src/confirm-booking.action.js +1 -1
- package/src/confirm-booking.api.js +35 -0
- package/src/confirm-booking.task.js +26 -24
- package/src/create-booking.action.js +1 -1
- package/src/create-booking.task.js +73 -37
- package/src/create-service.action.js +5 -1
- package/src/create-service.task.js +7 -7
- package/src/decline-booking.action.js +1 -1
- package/src/decline-booking.api.js +35 -0
- package/src/decline-booking.task.js +15 -18
- package/src/delete-service.action.js +1 -1
- package/src/delete-service.task.js +12 -15
- package/src/en.translations.json +83 -37
- package/src/get-availability.action.js +1 -1
- package/src/get-availability.task.js +5 -4
- package/src/get-available-slots.action.js +1 -1
- package/src/get-available-slots.task.js +16 -13
- package/src/get-services.action.js +1 -1
- package/src/get-services.task.js +13 -6
- package/src/home.page.jsx +14 -46
- package/src/index.js +17 -0
- package/src/list-bookings.action.js +1 -1
- package/src/list-bookings.task.js +33 -16
- package/src/locations.js +6 -4
- package/src/open-availability-setup.action.js +5 -0
- package/src/open-bookings.action.js +5 -0
- package/src/open-create-service.action.js +5 -0
- package/src/open-services.action.js +5 -0
- package/src/provider-availability.flow.js +39 -0
- package/src/provider-fields.js +34 -0
- package/src/provider-onboard.flow.js +25 -0
- package/src/public-booking-form.form.js +7 -0
- package/src/public-booking-form.schema.js +12 -0
- package/src/public-booking.page.jsx +60 -57
- package/src/resolve-workspace-contact.js +55 -0
- package/src/save-availability.action.js +5 -1
- package/src/save-availability.task.js +26 -28
- package/src/schema-ids.js +7 -0
- package/src/select-duration-30.action.js +4 -0
- package/src/select-duration-60.action.js +4 -0
- package/src/select-duration-90.action.js +4 -0
- package/src/send-booking-reminder.task.js +39 -14
- package/src/service-card-slot.component.jsx +4 -4
- package/src/service-detail.component.jsx +4 -4
- package/src/service.form.js +7 -0
- package/src/service.schema.js +14 -0
- package/src/services.page.jsx +62 -58
- package/src/stories/fixtures.js +149 -0
- package/src/sv.translations.json +83 -37
- package/src/time.js +27 -0
- package/src/toggle-availability-day-0.action.js +4 -0
- package/src/toggle-availability-day-1.action.js +4 -0
- package/src/toggle-availability-day-2.action.js +4 -0
- package/src/toggle-availability-day-3.action.js +4 -0
- package/src/toggle-availability-day-4.action.js +4 -0
- package/src/toggle-availability-day-5.action.js +4 -0
- package/src/toggle-availability-day-6.action.js +4 -0
- package/src/update-service.action.js +1 -1
- package/src/update-service.task.js +13 -16
- package/src/workspace-services.js +5 -0
- package/src/moduleStatus.js +0 -2
- package/src/service.resource.js +0 -32
|
@@ -1,37 +1,38 @@
|
|
|
1
1
|
import React from 'react'
|
|
2
|
-
import { View,
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
<
|
|
18
|
-
<View
|
|
19
|
-
<
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
25
|
+
<Text style={{ maxWidth: '400px' }}>
|
|
26
|
+
Incoming appointments from your clients.
|
|
27
|
+
</Text>
|
|
28
|
+
</View>
|
|
29
29
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
package/src/bookings.page.jsx
CHANGED
|
@@ -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,
|
|
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
|
-
<
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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 {
|
|
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
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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 {
|
|
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
|
|
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
|
|
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
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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
|
-
|
|
66
|
-
|
|
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:
|
|
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
|
|
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 {
|
|
3
|
-
import {
|
|
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
|
-
|
|
21
|
-
consultantEmail,
|
|
27
|
+
attachments,
|
|
22
28
|
baseUrl,
|
|
23
29
|
confirmUrl,
|
|
24
30
|
declineUrl,
|
|
25
31
|
} = payload ?? {}
|
|
26
32
|
|
|
27
|
-
|
|
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 ${
|
|
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
|
-
|
|
38
|
-
belongsTo:
|
|
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
|
-
|
|
69
|
-
belongsTo:
|
|
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
|
|
81
|
-
|
|
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 ===
|
|
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
|
-
|
|
101
|
-
|
|
120
|
+
resourceId: bookingId,
|
|
121
|
+
schemaId: BookingSchema.booking,
|
|
102
122
|
createdBy: 'public',
|
|
103
|
-
belongsTo:
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
|
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
|
-
|
|
127
|
-
|
|
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
|
-
|
|
168
|
+
providerName: providerName ?? 'Hej',
|
|
133
169
|
clientName,
|
|
134
170
|
clientEmail,
|
|
135
|
-
startAt:
|
|
171
|
+
startAt: startMs,
|
|
136
172
|
duration,
|
|
137
173
|
message: clientMessage ?? '',
|
|
138
|
-
confirmUrl: confirmUrl ??
|
|
139
|
-
declineUrl: declineUrl ??
|
|
174
|
+
confirmUrl: confirmUrl ?? defaultConfirmUrl,
|
|
175
|
+
declineUrl: declineUrl ?? defaultDeclineUrl,
|
|
140
176
|
},
|
|
141
177
|
{
|
|
142
|
-
to:
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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,9 +1,9 @@
|
|
|
1
1
|
import { nanoid } from 'nanoid'
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
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
|
-
|
|
35
|
-
|
|
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
|
|
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
|
+
}
|