@medalsocial/booking 0.0.0-placeholder.0 → 0.2.0
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/CHANGELOG.md +27 -0
- package/LICENSE +201 -0
- package/NOTICE +6 -0
- package/README.md +220 -1
- package/dist/core/age.d.mts +34 -0
- package/dist/core/age.mjs +95 -0
- package/dist/core/age.mjs.map +1 -0
- package/dist/core/attempt-store.d.mts +111 -0
- package/dist/core/attempt-store.mjs +159 -0
- package/dist/core/attempt-store.mjs.map +1 -0
- package/dist/core/categories.d.mts +27 -0
- package/dist/core/categories.mjs +42 -0
- package/dist/core/categories.mjs.map +1 -0
- package/dist/core/clock.d.mts +33 -0
- package/dist/core/clock.mjs +251 -0
- package/dist/core/clock.mjs.map +1 -0
- package/dist/core/config.d.mts +178 -0
- package/dist/core/config.mjs +394 -0
- package/dist/core/config.mjs.map +1 -0
- package/dist/core/consent.d.mts +14 -0
- package/dist/core/consent.mjs +17 -0
- package/dist/core/consent.mjs.map +1 -0
- package/dist/core/deep-link.d.mts +40 -0
- package/dist/core/deep-link.mjs +160 -0
- package/dist/core/deep-link.mjs.map +1 -0
- package/dist/core/display-name.d.mts +18 -0
- package/dist/core/display-name.mjs +30 -0
- package/dist/core/display-name.mjs.map +1 -0
- package/dist/core/draft-store.d.mts +89 -0
- package/dist/core/draft-store.mjs +131 -0
- package/dist/core/draft-store.mjs.map +1 -0
- package/dist/core/dto.d.mts +46 -0
- package/dist/core/dto.mjs +157 -0
- package/dist/core/dto.mjs.map +1 -0
- package/dist/core/ics.d.mts +63 -0
- package/dist/core/ics.mjs +130 -0
- package/dist/core/ics.mjs.map +1 -0
- package/dist/core/index.d.mts +35 -0
- package/dist/core/index.mjs +32 -0
- package/dist/core/labels.d.mts +88 -0
- package/dist/core/labels.mjs +93 -0
- package/dist/core/labels.mjs.map +1 -0
- package/dist/core/machine.d.mts +645 -0
- package/dist/core/machine.mjs +988 -0
- package/dist/core/machine.mjs.map +1 -0
- package/dist/core/money.d.mts +12 -0
- package/dist/core/money.mjs +51 -0
- package/dist/core/money.mjs.map +1 -0
- package/dist/core/next-available.d.mts +20 -0
- package/dist/core/next-available.mjs +36 -0
- package/dist/core/next-available.mjs.map +1 -0
- package/dist/core/next-free.d.mts +25 -0
- package/dist/core/next-free.mjs +44 -0
- package/dist/core/next-free.mjs.map +1 -0
- package/dist/core/party-slots.d.mts +79 -0
- package/dist/core/party-slots.mjs +229 -0
- package/dist/core/party-slots.mjs.map +1 -0
- package/dist/core/paths.d.mts +11 -0
- package/dist/core/paths.mjs +56 -0
- package/dist/core/paths.mjs.map +1 -0
- package/dist/core/phone.d.mts +17 -0
- package/dist/core/phone.mjs +58 -0
- package/dist/core/phone.mjs.map +1 -0
- package/dist/core/portal/cookies.d.mts +29 -0
- package/dist/core/portal/dto.d.mts +140 -0
- package/dist/core/portal/dto.mjs +142 -0
- package/dist/core/portal/dto.mjs.map +1 -0
- package/dist/core/portal/family.d.mts +39 -0
- package/dist/core/portal/family.mjs +39 -0
- package/dist/core/portal/family.mjs.map +1 -0
- package/dist/core/portal/login-input.d.mts +29 -0
- package/dist/core/portal/login-input.mjs +29 -0
- package/dist/core/portal/login-input.mjs.map +1 -0
- package/dist/core/portal/next-path.d.mts +12 -0
- package/dist/core/portal/next-path.mjs +98 -0
- package/dist/core/portal/next-path.mjs.map +1 -0
- package/dist/core/portal/return-path.d.mts +7 -0
- package/dist/core/portal/return-path.mjs +49 -0
- package/dist/core/portal/return-path.mjs.map +1 -0
- package/dist/core/portal/session-cookie.d.mts +27 -0
- package/dist/core/portal/session-cookie.mjs +30 -0
- package/dist/core/portal/session-cookie.mjs.map +1 -0
- package/dist/core/portal/vipps-link.d.mts +56 -0
- package/dist/core/portal/vipps-link.mjs +136 -0
- package/dist/core/portal/vipps-link.mjs.map +1 -0
- package/dist/core/portal/vipps-return.d.mts +39 -0
- package/dist/core/portal/vipps-return.mjs +69 -0
- package/dist/core/portal/vipps-return.mjs.map +1 -0
- package/dist/core/rebook-store.d.mts +28 -0
- package/dist/core/rebook-store.mjs +60 -0
- package/dist/core/rebook-store.mjs.map +1 -0
- package/dist/core/restore-gate.d.mts +40 -0
- package/dist/core/restore-gate.mjs +96 -0
- package/dist/core/restore-gate.mjs.map +1 -0
- package/dist/core/stores.d.mts +8 -0
- package/dist/core/stores.mjs +23 -0
- package/dist/core/stores.mjs.map +1 -0
- package/dist/core/types.d.mts +245 -0
- package/dist/core/visit.d.mts +26 -0
- package/dist/core/visit.mjs +40 -0
- package/dist/core/visit.mjs.map +1 -0
- package/dist/core/wire.d.mts +37 -0
- package/dist/next/cache/memory.d.mts +14 -0
- package/dist/next/cache/memory.mjs +51 -0
- package/dist/next/cache/memory.mjs.map +1 -0
- package/dist/next/cache/next-data.d.mts +6 -0
- package/dist/next/cache/next-data.mjs +35 -0
- package/dist/next/cache/next-data.mjs.map +1 -0
- package/dist/next/cache/noop.d.mts +6 -0
- package/dist/next/cache/noop.mjs +15 -0
- package/dist/next/cache/noop.mjs.map +1 -0
- package/dist/next/cache/workers.d.mts +29 -0
- package/dist/next/cache/workers.mjs +74 -0
- package/dist/next/cache/workers.mjs.map +1 -0
- package/dist/next/catalogue.d.mts +38 -0
- package/dist/next/catalogue.mjs +215 -0
- package/dist/next/catalogue.mjs.map +1 -0
- package/dist/next/handler.d.mts +11 -0
- package/dist/next/handler.mjs +150 -0
- package/dist/next/handler.mjs.map +1 -0
- package/dist/next/index.d.mts +17 -0
- package/dist/next/index.mjs +14 -0
- package/dist/next/loaders.d.mts +79 -0
- package/dist/next/loaders.mjs +195 -0
- package/dist/next/loaders.mjs.map +1 -0
- package/dist/next/manage-dto.d.mts +6 -0
- package/dist/next/manage-dto.mjs +28 -0
- package/dist/next/manage-dto.mjs.map +1 -0
- package/dist/next/medal.d.mts +105 -0
- package/dist/next/medal.mjs +155 -0
- package/dist/next/medal.mjs.map +1 -0
- package/dist/next/options.d.mts +158 -0
- package/dist/next/options.mjs +65 -0
- package/dist/next/options.mjs.map +1 -0
- package/dist/next/portal/guardian.d.mts +42 -0
- package/dist/next/portal/guardian.mjs +76 -0
- package/dist/next/portal/guardian.mjs.map +1 -0
- package/dist/next/portal/medal-portal.d.mts +161 -0
- package/dist/next/portal/medal-portal.mjs +825 -0
- package/dist/next/portal/medal-portal.mjs.map +1 -0
- package/dist/next/portal/session.d.mts +38 -0
- package/dist/next/portal/session.mjs +40 -0
- package/dist/next/portal/session.mjs.map +1 -0
- package/dist/next/portal/vipps-flash.d.mts +14 -0
- package/dist/next/portal/vipps-flash.mjs +41 -0
- package/dist/next/portal/vipps-flash.mjs.map +1 -0
- package/dist/next/portal.d.mts +205 -0
- package/dist/next/portal.mjs +469 -0
- package/dist/next/portal.mjs.map +1 -0
- package/dist/next/request.d.mts +51 -0
- package/dist/next/request.mjs +123 -0
- package/dist/next/request.mjs.map +1 -0
- package/dist/next/routes/catalogue-routes.mjs +344 -0
- package/dist/next/routes/catalogue-routes.mjs.map +1 -0
- package/dist/next/routes/create.mjs +460 -0
- package/dist/next/routes/create.mjs.map +1 -0
- package/dist/next/routes/manage.mjs +191 -0
- package/dist/next/routes/manage.mjs.map +1 -0
- package/dist/next/routes/portal-routes.mjs +312 -0
- package/dist/next/routes/portal-routes.mjs.map +1 -0
- package/dist/next/routes/shared.mjs +162 -0
- package/dist/next/routes/shared.mjs.map +1 -0
- package/dist/next/routes/vipps-return.mjs +166 -0
- package/dist/next/routes/vipps-return.mjs.map +1 -0
- package/dist/next/runtime.d.mts +49 -0
- package/dist/next/runtime.mjs +94 -0
- package/dist/next/runtime.mjs.map +1 -0
- package/dist/next/seed.d.mts +71 -0
- package/dist/next/seed.mjs +495 -0
- package/dist/next/seed.mjs.map +1 -0
- package/dist/next/server.d.mts +26 -0
- package/dist/next/server.mjs +63 -0
- package/dist/next/server.mjs.map +1 -0
- package/dist/react/BookingLink.d.mts +61 -0
- package/dist/react/BookingLink.mjs +102 -0
- package/dist/react/BookingLink.mjs.map +1 -0
- package/dist/react/BookingWizard.d.mts +49 -0
- package/dist/react/BookingWizard.mjs +598 -0
- package/dist/react/BookingWizard.mjs.map +1 -0
- package/dist/react/LoginPageQuery.d.mts +37 -0
- package/dist/react/LoginPageQuery.mjs +106 -0
- package/dist/react/LoginPageQuery.mjs.map +1 -0
- package/dist/react/LoginSheet.d.mts +49 -0
- package/dist/react/LoginSheet.mjs +98 -0
- package/dist/react/LoginSheet.mjs.map +1 -0
- package/dist/react/ManageBooking.d.mts +50 -0
- package/dist/react/ManageBooking.mjs +197 -0
- package/dist/react/ManageBooking.mjs.map +1 -0
- package/dist/react/PortalDashboard.d.mts +10 -0
- package/dist/react/Provider.d.mts +82 -0
- package/dist/react/Provider.mjs +132 -0
- package/dist/react/Provider.mjs.map +1 -0
- package/dist/react/actions.d.mts +133 -0
- package/dist/react/actions.mjs +90 -0
- package/dist/react/actions.mjs.map +1 -0
- package/dist/react/index.d.mts +28 -0
- package/dist/react/index.mjs +20 -0
- package/dist/react/kit.d.mts +54 -0
- package/dist/react/kit.mjs +127 -0
- package/dist/react/kit.mjs.map +1 -0
- package/dist/react/labels/index.d.mts +23 -0
- package/dist/react/labels/index.mjs +44 -0
- package/dist/react/labels/index.mjs.map +1 -0
- package/dist/react/labels/login.d.mts +10 -0
- package/dist/react/labels/login.mjs +13 -0
- package/dist/react/labels/login.mjs.map +1 -0
- package/dist/react/labels/manage.d.mts +15 -0
- package/dist/react/labels/manage.mjs +19 -0
- package/dist/react/labels/manage.mjs.map +1 -0
- package/dist/react/labels/portal.d.mts +25 -0
- package/dist/react/labels/portal.mjs +41 -0
- package/dist/react/labels/portal.mjs.map +1 -0
- package/dist/react/labels/screens.d.mts +11 -0
- package/dist/react/labels/screens.mjs +721 -0
- package/dist/react/labels/screens.mjs.map +1 -0
- package/dist/react/labels/wizard.d.mts +76 -0
- package/dist/react/labels/wizard.mjs +97 -0
- package/dist/react/labels/wizard.mjs.map +1 -0
- package/dist/react/link/index.d.mts +5 -0
- package/dist/react/link/index.mjs +5 -0
- package/dist/react/portal/SessionTouch.d.mts +19 -0
- package/dist/react/portal/SessionTouch.mjs +40 -0
- package/dist/react/portal/SessionTouch.mjs.map +1 -0
- package/dist/react/portal/age-prompt.d.mts +10 -0
- package/dist/react/portal/age-prompt.mjs +21 -0
- package/dist/react/portal/age-prompt.mjs.map +1 -0
- package/dist/react/portal/book-again.d.mts +18 -0
- package/dist/react/portal/book-again.mjs +21 -0
- package/dist/react/portal/book-again.mjs.map +1 -0
- package/dist/react/portal/child-summary.d.mts +17 -0
- package/dist/react/portal/child-summary.mjs +45 -0
- package/dist/react/portal/child-summary.mjs.map +1 -0
- package/dist/react/portal/dashboard.d.mts +74 -0
- package/dist/react/portal/dashboard.mjs +204 -0
- package/dist/react/portal/dashboard.mjs.map +1 -0
- package/dist/react/portal/leave.d.mts +17 -0
- package/dist/react/portal/leave.mjs +21 -0
- package/dist/react/portal/leave.mjs.map +1 -0
- package/dist/react/portal/sections.d.mts +12 -0
- package/dist/react/portal/sections.mjs +48 -0
- package/dist/react/portal/sections.mjs.map +1 -0
- package/dist/react/portal/tabs.d.mts +23 -0
- package/dist/react/portal/tabs.mjs +36 -0
- package/dist/react/portal/tabs.mjs.map +1 -0
- package/dist/react/portal/vipps-flash.d.mts +11 -0
- package/dist/react/portal/vipps-flash.mjs +18 -0
- package/dist/react/portal/vipps-flash.mjs.map +1 -0
- package/dist/react/portal/wired.mjs +202 -0
- package/dist/react/portal/wired.mjs.map +1 -0
- package/dist/react/screen-labels.mjs +14 -0
- package/dist/react/screen-labels.mjs.map +1 -0
- package/dist/react/shared.d.mts +13 -0
- package/dist/react/shared.mjs +7 -0
- package/dist/react/use-next-free.d.mts +19 -0
- package/dist/react/use-next-free.mjs +56 -0
- package/dist/react/use-next-free.mjs.map +1 -0
- package/dist/react/useBooking.d.mts +179 -0
- package/dist/react/useBooking.mjs +1331 -0
- package/dist/react/useBooking.mjs.map +1 -0
- package/dist/react/wizard/adapters.mjs +189 -0
- package/dist/react/wizard/adapters.mjs.map +1 -0
- package/dist/react/wizard/index.d.mts +11 -0
- package/dist/react/wizard/index.mjs +6 -0
- package/dist/react/wizard-error.mjs +29 -0
- package/dist/react/wizard-error.mjs.map +1 -0
- package/package.json +126 -5
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
import { marketingConsent } from "../../core/consent.mjs";
|
|
2
|
+
import { visitKeyOfIds } from "../../core/visit.mjs";
|
|
3
|
+
import { MedalConfigError } from "../medal.mjs";
|
|
4
|
+
import { PortalSessionExpiredError } from "../portal/medal-portal.mjs";
|
|
5
|
+
import { readBoundedText } from "../request.mjs";
|
|
6
|
+
import { bookingErrorResponse, isSlotTaken, parseRange } from "./shared.mjs";
|
|
7
|
+
import { MedalApiError } from "@medalsocial/sdk";
|
|
8
|
+
import { NextResponse, after } from "next/server";
|
|
9
|
+
//#region src/next/routes/create.ts
|
|
10
|
+
/**
|
|
11
|
+
* `POST <api>/create` — one submission, one booking, or one family,
|
|
12
|
+
* all-or-nothing.
|
|
13
|
+
*
|
|
14
|
+
* The logic is the source site's create route, moved unchanged: the
|
|
15
|
+
* idempotency key from the wizard's nonce and the canonical body, the
|
|
16
|
+
* marketing consent filed through `after()` once the booking exists, the live
|
|
17
|
+
* re-read of openings on a taken slot, and the slot-cache and seed expiry on
|
|
18
|
+
* every write.
|
|
19
|
+
*/
|
|
20
|
+
/** The window the wizard shows when it names none: seven local days from now. */
|
|
21
|
+
const DEFAULT_WINDOW_DAYS = 7;
|
|
22
|
+
/**
|
|
23
|
+
* The body's `window`, held to the same guards as the read routes' range
|
|
24
|
+
* (`parseRange`: readable, ordered, at most 62 days) — or the default window.
|
|
25
|
+
* Never an error: this only shapes what a refusal carries back.
|
|
26
|
+
*/
|
|
27
|
+
function slotWindow(rt, raw) {
|
|
28
|
+
const fromTs = raw?.fromTs;
|
|
29
|
+
const toTs = raw?.toTs;
|
|
30
|
+
if (typeof fromTs === "number" && typeof toTs === "number") {
|
|
31
|
+
const parsed = parseRange(new URLSearchParams({
|
|
32
|
+
from_ts: String(fromTs),
|
|
33
|
+
to_ts: String(toTs)
|
|
34
|
+
}));
|
|
35
|
+
if (!("error" in parsed)) return parsed;
|
|
36
|
+
}
|
|
37
|
+
const { fromTs: defaultFrom, toTs: defaultTo } = rt.clock.window(Date.now(), DEFAULT_WINDOW_DAYS);
|
|
38
|
+
return {
|
|
39
|
+
fromTs: defaultFrom,
|
|
40
|
+
toTs: defaultTo
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* What is free NOW for each VISIT in a refused booking, read live past the
|
|
45
|
+
* slot cache — the entry that offered the lost slot may still be there, since
|
|
46
|
+
* the tag expiry lands after this response. Keyed by visit key (`a+b` for a
|
|
47
|
+
* person having both, the bare id for one service), the key the wizard stores
|
|
48
|
+
* availability under: a visit's openings are not its first service's. Best
|
|
49
|
+
* effort per visit: one that fails is left out, and the wizard reads it the
|
|
50
|
+
* ordinary way.
|
|
51
|
+
*/
|
|
52
|
+
async function liveSlotsFor(rt, items, window) {
|
|
53
|
+
const visits = new Map(items.map((item) => {
|
|
54
|
+
const extraServiceIds = item.extra_service_ids ?? [];
|
|
55
|
+
return [visitKeyOfIds([item.service_id, ...extraServiceIds]), item];
|
|
56
|
+
}));
|
|
57
|
+
const read = await Promise.all([...visits].map(async ([visit, { service_id: serviceId, extra_service_ids }]) => {
|
|
58
|
+
try {
|
|
59
|
+
return [visit, (await rt.catalogue.cachedAvailability({
|
|
60
|
+
serviceId,
|
|
61
|
+
...extra_service_ids ? { extraServiceIds: extra_service_ids } : {},
|
|
62
|
+
...window
|
|
63
|
+
}, { fresh: true })).flatMap(rt.dto.toBookingSlotDto)];
|
|
64
|
+
} catch (error) {
|
|
65
|
+
rt.logger.warn({
|
|
66
|
+
err: error,
|
|
67
|
+
visit
|
|
68
|
+
}, "Could not re-read openings after a taken slot");
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}));
|
|
72
|
+
return Object.fromEntries(read.filter((entry) => entry !== null));
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* The fix for the trap `medal-client.ts` documents and deliberately refuses to
|
|
76
|
+
* apply on its own.
|
|
77
|
+
*
|
|
78
|
+
* Empty is not "absent" to this API, and the inconsistency is what hides it:
|
|
79
|
+
* the engine takes `contact.name` and `booked_for_name` as
|
|
80
|
+
* `z.string().trim().min(1).optional()` — `''` is a 400 — while `email`, right
|
|
81
|
+
* next to them, has no `min` and lets `''` straight through. The wizard seeds
|
|
82
|
+
* all three to `''` because an empty text input has no other value, so without
|
|
83
|
+
* this a parent who skips «Ditt navn», or leaves «Hvem skal klippes?» blank,
|
|
84
|
+
* cannot book at all — and the error names a field they left empty on purpose.
|
|
85
|
+
*/
|
|
86
|
+
function blankToUndefined(value) {
|
|
87
|
+
if (typeof value !== "string") return void 0;
|
|
88
|
+
const trimmed = value.trim();
|
|
89
|
+
return trimmed.length > 0 ? trimmed : void 0;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* The same rule for the one optional field that is a number.
|
|
93
|
+
*
|
|
94
|
+
* An emptied number input serialises as `null` (or `NaN`, which
|
|
95
|
+
* `JSON.stringify` also writes as `null`), and `z.number().optional()` refuses
|
|
96
|
+
* null exactly as `z.string().optional()` does.
|
|
97
|
+
*/
|
|
98
|
+
function numberOrUndefined(value) {
|
|
99
|
+
return typeof value === "number" && Number.isFinite(value) ? value : void 0;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* The same loose shape «Bekreft» checks, kept in step with `looksLikeEmail` there.
|
|
103
|
+
*
|
|
104
|
+
* Loose on purpose: the field is optional, so the only job is to refuse an
|
|
105
|
+
* address that cannot receive anything. A stricter pattern rejects real
|
|
106
|
+
* addresses, and refusing to book a haircut over an e-mail nobody had to give
|
|
107
|
+
* is the worse error.
|
|
108
|
+
*/
|
|
109
|
+
const EMAIL_SHAPE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
110
|
+
function invalid(message) {
|
|
111
|
+
return bookingErrorResponse("invalidInput", 400, message);
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* A line's `extraServiceIds` — the rest of this person's visit after
|
|
115
|
+
* `serviceId`, in order — or why not. Absent, `null` and `[]` are a
|
|
116
|
+
* one-service visit. Refused locally on everything the engine would refuse
|
|
117
|
+
* with a 422 (more than three, one twice, `serviceId` again), and on anything
|
|
118
|
+
* that is not a list of non-blank strings: a blank id is not «no extra», it is
|
|
119
|
+
* a wizard that lost track of what the person chose.
|
|
120
|
+
*/
|
|
121
|
+
function extraServiceIdsOf(raw, serviceId, index) {
|
|
122
|
+
if (raw === void 0 || raw === null) return { ids: [] };
|
|
123
|
+
const field = `items.${index}.extraServiceIds`;
|
|
124
|
+
if (!Array.isArray(raw)) return { error: invalid(`${field} must be a list of service ids`) };
|
|
125
|
+
const ids = [];
|
|
126
|
+
for (const entry of raw) {
|
|
127
|
+
const id = blankToUndefined(entry);
|
|
128
|
+
if (id === void 0) return { error: invalid(`${field} must be a list of service ids`) };
|
|
129
|
+
ids.push(id);
|
|
130
|
+
}
|
|
131
|
+
if (ids.length > 3) return { error: invalid(`${field} must name at most 3 services`) };
|
|
132
|
+
if (new Set(ids).size !== ids.length) return { error: invalid(`${field} names a service twice`) };
|
|
133
|
+
if (ids.includes(serviceId)) return { error: invalid(`${field} repeats serviceId`) };
|
|
134
|
+
return { ids };
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Turn the wizard's submission into Medal's create body, or say why not.
|
|
138
|
+
*
|
|
139
|
+
* Every rejection here is one the engine would also make. Answering locally is
|
|
140
|
+
* not just faster: the engine's messages are written for the integrator holding
|
|
141
|
+
* the API key and quote wire field names (`contact.phone`, `items.0.start_ts`)
|
|
142
|
+
* that mean nothing to the person filling in the form.
|
|
143
|
+
*/
|
|
144
|
+
function toMedalBody(body) {
|
|
145
|
+
if (!Array.isArray(body.items) || body.items.length === 0) return { error: invalid("items must contain at least one service") };
|
|
146
|
+
const phone = blankToUndefined(body.contact?.phone);
|
|
147
|
+
if (phone === void 0) return { error: invalid("contact.phone is required") };
|
|
148
|
+
const email = blankToUndefined(body.contact?.email);
|
|
149
|
+
if (email !== void 0 && !EMAIL_SHAPE.test(email)) return { error: invalid("contact.email is not a valid address") };
|
|
150
|
+
if (body.consentTerms !== true) return { error: invalid("consentTerms must be accepted") };
|
|
151
|
+
const items = [];
|
|
152
|
+
for (const [index, item] of body.items.entries()) {
|
|
153
|
+
const serviceId = blankToUndefined(item?.serviceId);
|
|
154
|
+
if (serviceId === void 0) return { error: invalid(`items.${index}.serviceId is required`) };
|
|
155
|
+
const startTs = item?.startTs;
|
|
156
|
+
if (!Number.isFinite(startTs)) return { error: invalid(`items.${index}.startTs must be a timestamp`) };
|
|
157
|
+
const extras = extraServiceIdsOf(item?.extraServiceIds, serviceId, index);
|
|
158
|
+
if ("error" in extras) return extras;
|
|
159
|
+
items.push({
|
|
160
|
+
service_id: serviceId,
|
|
161
|
+
...extras.ids.length > 0 ? { extra_service_ids: extras.ids } : {},
|
|
162
|
+
resource_id: blankToUndefined(item?.resourceId),
|
|
163
|
+
start_ts: startTs,
|
|
164
|
+
booked_for_name: blankToUndefined(item?.bookedForName),
|
|
165
|
+
booked_for_birth_year: numberOrUndefined(item?.bookedForBirthYear)
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
return { body: {
|
|
169
|
+
items,
|
|
170
|
+
contact: {
|
|
171
|
+
phone,
|
|
172
|
+
name: blankToUndefined(body.contact?.name),
|
|
173
|
+
email
|
|
174
|
+
},
|
|
175
|
+
notes: blankToUndefined(body.notes)
|
|
176
|
+
} };
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* The `Idempotency-Key` for one submission: the wizard's nonce and the body it
|
|
180
|
+
* is submitting, hashed together.
|
|
181
|
+
*
|
|
182
|
+
* Both halves are load-bearing, and each way of getting it wrong is its own
|
|
183
|
+
* failure. The one this route shipped with was to use neither: a fresh
|
|
184
|
+
* `crypto.randomUUID()` per POST is a fresh key per tap, which is two bookings.
|
|
185
|
+
* The two near misses:
|
|
186
|
+
*
|
|
187
|
+
* - **Nonce alone**, taken from the client as the key, would be a
|
|
188
|
+
* cross-customer leak. Medal's idempotency store is scoped to the workspace,
|
|
189
|
+
* not to the visitor, so if two browsers ever sent the same key the second
|
|
190
|
+
* would be handed the first one's cached response — manage tokens included,
|
|
191
|
+
* and a manage token is a live credential for somebody else's appointment.
|
|
192
|
+
* - **Body alone** would replay: two families booking the same service at the
|
|
193
|
+
* same time is a slot clash the engine should answer, and two identical
|
|
194
|
+
* submissions weeks apart are two haircuts.
|
|
195
|
+
*
|
|
196
|
+
* Together they give the property the wizard actually needs. A retry of the
|
|
197
|
+
* same submission — the double tap, the reload on a stalled request — carries
|
|
198
|
+
* the same nonce and the same body, so it hashes to the same key and books
|
|
199
|
+
* once. Two visitors cannot collide even if their nonces somehow matched,
|
|
200
|
+
* because two families differ in at least the phone number; and a visitor who
|
|
201
|
+
* changes their mind about the slot changes the body, which is a new key and
|
|
202
|
+
* correctly a new booking.
|
|
203
|
+
*
|
|
204
|
+
* `crypto.subtle` rather than `node:crypto`: this runs on Cloudflare Workers,
|
|
205
|
+
* where the Web Crypto global is the one that exists.
|
|
206
|
+
*
|
|
207
|
+
* The body is hashed *after* `toMedalBody`, which is what makes it canonical —
|
|
208
|
+
* trimmed, blanks already dropped, keys in the fixed order this file writes
|
|
209
|
+
* them. Two POSTs that differ only in whitespace would create the same booking,
|
|
210
|
+
* so they had better derive the same key.
|
|
211
|
+
*
|
|
212
|
+
* No nonce means an older wizard, or a caller that is not the wizard. Those
|
|
213
|
+
* still book — a per-request UUID, exactly as before — they simply do not get
|
|
214
|
+
* the guarantee. Refusing them would turn a missing nicety into an outage.
|
|
215
|
+
*/
|
|
216
|
+
async function idempotencyKeyFor(nonce, body) {
|
|
217
|
+
if (typeof nonce !== "string" || nonce.trim().length === 0) return crypto.randomUUID();
|
|
218
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(`${nonce}${JSON.stringify(body)}`));
|
|
219
|
+
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* The marketing box on «Bekreft», filed as its own record — and never at the
|
|
223
|
+
* booking's expense.
|
|
224
|
+
*
|
|
225
|
+
* Three rules, all of them load-bearing:
|
|
226
|
+
*
|
|
227
|
+
* - Only when the box was ticked. `granted: false` is a *withdrawal*, and
|
|
228
|
+
* writing one for every parent who left the box alone would overwrite the
|
|
229
|
+
* consent they gave last time they booked.
|
|
230
|
+
* - Only when there is an e-mail address. The record is keyed on it, and
|
|
231
|
+
* consent to be e-mailed without somewhere to e-mail is not a consent to
|
|
232
|
+
* anything.
|
|
233
|
+
* - Never fatal. The appointment is what the customer came for; a consent
|
|
234
|
+
* endpoint that is down, or an API key scoped to bookings alone, must cost
|
|
235
|
+
* the salon a mailing-list entry and not a haircut. Logged at `warn` so the
|
|
236
|
+
* silence is at least visible from the outside.
|
|
237
|
+
*
|
|
238
|
+
* Scheduled with `after` rather than awaited or floated, and both of the other
|
|
239
|
+
* two are worse in their own direction. AWAITED, this sat between the booking
|
|
240
|
+
* and the 201 with no timeout under it, so a consent endpoint that accepted the
|
|
241
|
+
* connection and never answered withheld a booking that had already been made —
|
|
242
|
+
* the browser gives up, the visitor reloads, `DetailsStep` remounts and mints a
|
|
243
|
+
* fresh submission nonce, and the retry derives a different idempotency key and
|
|
244
|
+
* books the child a second time. A try/catch does nothing about a stall.
|
|
245
|
+
* FLOATED, on Workers a promise nobody is holding when the response goes out is
|
|
246
|
+
* cancelled, so the consent would be written on a fast connection and lost on a
|
|
247
|
+
* slow one. `after` is the one that answers first and still finishes the work:
|
|
248
|
+
* on this runtime it lands on `ctx.waitUntil`, which the opennextjs-cloudflare
|
|
249
|
+
* wrapper holds the whole request with — so the isolate stays alive for the
|
|
250
|
+
* write while the visitor is already reading their confirmation.
|
|
251
|
+
*/
|
|
252
|
+
async function recordMarketingConsent(rt, email, granted) {
|
|
253
|
+
const consent = marketingConsent(rt.config);
|
|
254
|
+
if (granted !== true || email === void 0 || consent === null) return;
|
|
255
|
+
try {
|
|
256
|
+
await rt.medal.recordConsent({
|
|
257
|
+
email,
|
|
258
|
+
consent_type: "marketing_email",
|
|
259
|
+
granted: true,
|
|
260
|
+
source: consent.source,
|
|
261
|
+
consent_text: consent.text,
|
|
262
|
+
version: consent.version
|
|
263
|
+
});
|
|
264
|
+
} catch (error) {
|
|
265
|
+
rt.logger.warn({ err: error }, "Marketing consent was not recorded");
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Every way a submission can fail, in the vocabulary `WizardState.error`
|
|
270
|
+
* already speaks. The codes are consumed as-is, so the spelling is part of the
|
|
271
|
+
* contract rather than a description of it.
|
|
272
|
+
*
|
|
273
|
+
* `VALIDATION_ERROR` and not just `INVALID_INPUT`: the engine raises
|
|
274
|
+
* `ErrorCode.INVALID_INPUT` internally, but `withBookingApiErrors` routes it
|
|
275
|
+
* through `validationError`, so what actually reaches the wire is `422
|
|
276
|
+
* VALIDATION_ERROR`. Matching on the internal name alone would be a branch that
|
|
277
|
+
* never runs — and every rejected body would surface as a bare 502.
|
|
278
|
+
*
|
|
279
|
+
* The 400 is deliberate where the upstream said 422: to the browser this IS a
|
|
280
|
+
* bad request, and the wizard has one `invalidInput` state for it either way.
|
|
281
|
+
*/
|
|
282
|
+
function createErrorResponse(rt, error) {
|
|
283
|
+
if (error instanceof MedalConfigError) {
|
|
284
|
+
rt.logger.error({ err: error }, "Booking API is not configured");
|
|
285
|
+
return bookingErrorResponse("unconfigured", 503);
|
|
286
|
+
}
|
|
287
|
+
if (error instanceof MedalApiError) {
|
|
288
|
+
if (error.code === "IDEMPOTENCY_KEY_CONFLICT" || error.code === "IDEMPOTENCY_IN_PROGRESS") {
|
|
289
|
+
rt.logger.warn({ err: error }, "Booking create hit an idempotency key already in use");
|
|
290
|
+
return bookingErrorResponse("inProgress", 409);
|
|
291
|
+
}
|
|
292
|
+
if (error.code === "CONFLICT")
|
|
293
|
+
/* v8 ignore next -- the slotTaken side is kept as moved; createRoute answers it first */
|
|
294
|
+
return bookingErrorResponse(isSlotTaken(error) ? "slotTaken" : "conflict", 409);
|
|
295
|
+
if (error.code === "VALIDATION_ERROR" || error.code === "INVALID_INPUT") return bookingErrorResponse("invalidInput", 400);
|
|
296
|
+
}
|
|
297
|
+
rt.logger.error({ err: error }, "Booking create failed");
|
|
298
|
+
return bookingErrorResponse("upstreamError", 502);
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* The create body's ceiling, read in bytes before anything parses it (as the
|
|
302
|
+
* portal routes cap theirs). A family of three is about two kilobytes; this
|
|
303
|
+
* leaves room for the largest party a site may configure and a long note,
|
|
304
|
+
* and refuses the megabyte nobody's wizard sends.
|
|
305
|
+
*/
|
|
306
|
+
const CREATE_MAX_BODY_BYTES = 32768;
|
|
307
|
+
/**
|
|
308
|
+
* One submission, one booking — or one family, all-or-nothing, when `items`
|
|
309
|
+
* carries more than one child.
|
|
310
|
+
*
|
|
311
|
+
* `Idempotency-Key` is what makes a double-tapped «Bekreft» on a flaky mobile
|
|
312
|
+
* connection book once instead of twice, and it only does that if the second
|
|
313
|
+
* tap derives the *same* key as the first. It cannot be minted here for that
|
|
314
|
+
* reason: a fresh UUID per POST is a fresh key per tap, which is two bookings.
|
|
315
|
+
* So the wizard sends the nonce and this route mixes the body in —
|
|
316
|
+
* `idempotencyKeyFor` explains why it takes both.
|
|
317
|
+
*/
|
|
318
|
+
async function createRoute(rt, request) {
|
|
319
|
+
let parsed;
|
|
320
|
+
try {
|
|
321
|
+
const text = await readBoundedText(request, CREATE_MAX_BODY_BYTES);
|
|
322
|
+
if (text === null) return invalid("body is too large");
|
|
323
|
+
parsed = JSON.parse(text);
|
|
324
|
+
} catch {
|
|
325
|
+
return invalid("body must be valid JSON");
|
|
326
|
+
}
|
|
327
|
+
if (typeof parsed !== "object" || parsed === null) return invalid("body must be a JSON object");
|
|
328
|
+
const mapped = toMedalBody(parsed);
|
|
329
|
+
if ("error" in mapped) return mapped.error;
|
|
330
|
+
/* v8 ignore next */
|
|
331
|
+
const requestedIds = requestedPersonIds(parsed.items ?? []);
|
|
332
|
+
const keyBody = withIds(mapped.body, requestedIds, null);
|
|
333
|
+
const checked = await withPersons(rt, mapped.body, requestedIds);
|
|
334
|
+
if (checked === null) return NextResponse.json({
|
|
335
|
+
error: "upstreamError",
|
|
336
|
+
retryable: true
|
|
337
|
+
}, { status: 503 });
|
|
338
|
+
mapped.body = checked;
|
|
339
|
+
try {
|
|
340
|
+
const idempotencyKey = await idempotencyKeyFor(parsed.submissionNonce, keyBody);
|
|
341
|
+
const result = await rt.medal.createBooking(mapped.body, idempotencyKey);
|
|
342
|
+
const booked = Array.isArray(result.bookings) ? result.bookings : [];
|
|
343
|
+
const ids = booked.map((booking) => booking?.id);
|
|
344
|
+
const whole = booked.length === mapped.body.items.length && ids.every((id) => typeof id === "string" && id.length > 0) && new Set(ids).size === ids.length;
|
|
345
|
+
rt.catalogue.expireSlots(bookedServiceIds(mapped.body));
|
|
346
|
+
await rt.seed.expireBookingSeeds(bookedServiceIds(mapped.body));
|
|
347
|
+
if (!whole) {
|
|
348
|
+
rt.logger.error({
|
|
349
|
+
expected: mapped.body.items.length,
|
|
350
|
+
received: booked.length
|
|
351
|
+
}, "Booking create answered without one booking per line");
|
|
352
|
+
return bookingErrorResponse("upstreamError", 502);
|
|
353
|
+
}
|
|
354
|
+
after(() => recordMarketingConsent(rt, mapped.body.contact.email, parsed.consentMarketing));
|
|
355
|
+
return NextResponse.json({ bookings: booked.map((booking) => ({
|
|
356
|
+
id: booking.id,
|
|
357
|
+
manageToken: booking.manage_token
|
|
358
|
+
})) }, { status: 201 });
|
|
359
|
+
} catch (error) {
|
|
360
|
+
if (isSlotTaken(error)) {
|
|
361
|
+
const serviceIds = bookedServiceIds(mapped.body);
|
|
362
|
+
rt.catalogue.expireSlots(serviceIds);
|
|
363
|
+
await rt.seed.expireBookingSeeds(serviceIds);
|
|
364
|
+
const freshSlots = await liveSlotsFor(rt, mapped.body.items, slotWindow(rt, parsed.window));
|
|
365
|
+
return NextResponse.json({
|
|
366
|
+
error: "slotTaken",
|
|
367
|
+
freshSlots
|
|
368
|
+
}, { status: 409 });
|
|
369
|
+
}
|
|
370
|
+
return createErrorResponse(rt, error);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
/** Every service a submission books — each visit's extras too, since a
|
|
374
|
+
* visit's slot entry is tagged with all of its services and a write to any of
|
|
375
|
+
* them must retire it. */
|
|
376
|
+
function bookedServiceIds(body) {
|
|
377
|
+
return body.items.flatMap((item) => [item.service_id, ...item.extra_service_ids ?? []]);
|
|
378
|
+
}
|
|
379
|
+
/** Medal's person ids are Convex ids; anything else is not one worth asking about. */
|
|
380
|
+
const PERSON_ID_SHAPE = /^[A-Za-z0-9_-]{1,64}$/;
|
|
381
|
+
/**
|
|
382
|
+
* `booked_for_person_id` on each line — THE PHONE RULE (SP10).
|
|
383
|
+
*
|
|
384
|
+
* Medal resolves a booking's contact by the PHONE first, then the e-mail, and
|
|
385
|
+
* a person id is only valid on the contact who owns that child: an id sent
|
|
386
|
+
* under a number that resolves to somebody else is a 404 for the whole
|
|
387
|
+
* booking. So an id is forwarded only when all of this holds, and otherwise
|
|
388
|
+
* the line goes as it always did — by name and year:
|
|
389
|
+
*
|
|
390
|
+
* - the browser holds a portal session (the `portal.cookieName` cookie),
|
|
391
|
+
* - Medal still honours it and names the parent's phone,
|
|
392
|
+
* - that phone and the one submitted are the same number (national digits),
|
|
393
|
+
* - and the id is one of THAT parent's children.
|
|
394
|
+
*
|
|
395
|
+
* The browser already applies the phone rule before it sends an id; this is
|
|
396
|
+
* the server not trusting it, because the route is reachable without the
|
|
397
|
+
* wizard and a wrong id costs the whole family's booking.
|
|
398
|
+
*
|
|
399
|
+
* DETERMINISTIC, for the idempotency key's sake (see `POST`): ids are dropped
|
|
400
|
+
* only when the check SUCCEEDED and said no — no session, an expired one, a
|
|
401
|
+
* profile with another phone, a child who is not theirs — which a retry
|
|
402
|
+
* against the same profile says again. A check that could not be made is
|
|
403
|
+
* `null`, answered 503 without booking.
|
|
404
|
+
*
|
|
405
|
+
* THE COST: one extra round trip to Medal (`GET /portal/me`, with the
|
|
406
|
+
* session) before the booking — only for a submission that carries an id,
|
|
407
|
+
* which only a logged-in parent's wizard sends. A booking by names asks
|
|
408
|
+
* nothing extra.
|
|
409
|
+
*/
|
|
410
|
+
async function withPersons(rt, body, ids) {
|
|
411
|
+
if (ids.every((id) => id === null)) return body;
|
|
412
|
+
const owned = await ownedPersonIds(rt, body.contact.phone);
|
|
413
|
+
if (owned === null) return null;
|
|
414
|
+
if (owned.size === 0) return body;
|
|
415
|
+
return withIds(body, ids, owned);
|
|
416
|
+
}
|
|
417
|
+
/** The ids the client sent, one per line, `null` where none (or not an id). */
|
|
418
|
+
function requestedPersonIds(requested) {
|
|
419
|
+
return requested.map((item) => {
|
|
420
|
+
const id = item?.bookedForPersonId;
|
|
421
|
+
return typeof id === "string" && PERSON_ID_SHAPE.test(id) ? id : null;
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
/** `body` with each line's id set — all of them, or only those in `allowed`. */
|
|
425
|
+
function withIds(body, ids, allowed) {
|
|
426
|
+
if (ids.every((id) => id === null)) return body;
|
|
427
|
+
return {
|
|
428
|
+
...body,
|
|
429
|
+
items: body.items.map((item, index) => {
|
|
430
|
+
const id = ids[index] ?? null;
|
|
431
|
+
return id !== null && (allowed === null || allowed.has(id)) ? {
|
|
432
|
+
...item,
|
|
433
|
+
booked_for_person_id: id
|
|
434
|
+
} : item;
|
|
435
|
+
})
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* The logged-in parent's children's ids — empty unless their phone is `phone`,
|
|
440
|
+
* `null` when Medal could not be asked (an outage, a throttle): not an answer.
|
|
441
|
+
*/
|
|
442
|
+
async function ownedPersonIds(rt, phone) {
|
|
443
|
+
const { nationalDigits } = rt.phone;
|
|
444
|
+
try {
|
|
445
|
+
const session = await rt.session.readPortalSession();
|
|
446
|
+
if (session === null) return /* @__PURE__ */ new Set();
|
|
447
|
+
const profile = await rt.portal.getMe(session);
|
|
448
|
+
if (profile.phone === null) return /* @__PURE__ */ new Set();
|
|
449
|
+
if (nationalDigits(profile.phone) !== nationalDigits(phone)) return /* @__PURE__ */ new Set();
|
|
450
|
+
return new Set(profile.family.flatMap((member) => member.personId === null ? [] : [member.personId]));
|
|
451
|
+
} catch (error) {
|
|
452
|
+
if (error instanceof PortalSessionExpiredError) return /* @__PURE__ */ new Set();
|
|
453
|
+
rt.logger.warn({ err: error }, "Could not check the booking’s person ids; asking for a retry");
|
|
454
|
+
return null;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
//#endregion
|
|
458
|
+
export { createRoute };
|
|
459
|
+
|
|
460
|
+
//# sourceMappingURL=create.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"create.mjs","names":[],"sources":["../../../src/next/routes/create.ts"],"sourcesContent":["/**\n * `POST <api>/create` — one submission, one booking, or one family,\n * all-or-nothing.\n *\n * The logic is the source site's create route, moved unchanged: the\n * idempotency key from the wizard's nonce and the canonical body, the\n * marketing consent filed through `after()` once the booking exists, the live\n * re-read of openings on a taken slot, and the slot-cache and seed expiry on\n * every write.\n */\n\nimport { MedalApiError } from '@medalsocial/sdk';\nimport { after, NextResponse } from 'next/server';\nimport { marketingConsent } from '../../core/consent';\nimport type { BookingSlotDto } from '../../core/types';\nimport { visitKeyOfIds } from '../../core/visit';\nimport { type CreateBookingBody, MedalConfigError } from '../medal';\nimport { PortalSessionExpiredError } from '../portal/medal-portal';\nimport { readBoundedText } from '../request';\nimport type { BookingRuntime } from '../runtime';\nimport { bookingErrorResponse, isSlotTaken, MAX_EXTRA_SERVICES, parseRange } from './shared';\n\n/**\n * What the wizard submits. Every optional field here is one `WizardState`\n * seeds as `''` or `null`, which is the whole reason this module exists.\n *\n * `consentMarketing` does not travel on to Medal's create body — that schema\n * has nowhere to put it, and it should not: a consent is a dated record of a\n * sentence somebody agreed to, not a column on an appointment. It is written\n * separately, once the booking has succeeded, by `recordMarketingConsent`.\n */\ninterface CreateRequest {\n items?: Array<{\n serviceId?: unknown;\n /** The rest of this person's visit after `serviceId` — see `extraServiceIdsOf`. */\n extraServiceIds?: unknown;\n resourceId?: unknown;\n startTs?: unknown;\n bookedForName?: unknown;\n bookedForBirthYear?: unknown;\n /** A saved child's id (SP10) — forwarded only under the phone rule; see `withPersons`. */\n bookedForPersonId?: unknown;\n }>;\n contact?: { phone?: unknown; name?: unknown; email?: unknown };\n notes?: unknown;\n consentTerms?: unknown;\n consentMarketing?: unknown;\n /**\n * Half of the idempotency key — the wizard's half. Minted once when «Bekreft»\n * comes up and resent unchanged on every retry of the same submission; see\n * `idempotencyKeyFor` for why it is only half.\n *\n * Never forwarded to Medal. It identifies an *attempt*, and the engine has\n * nowhere to put one.\n */\n submissionNonce?: unknown;\n /**\n * The wizard's availability window, so a `slotTaken` can answer with the\n * openings over exactly the days the time step shows. Never forwarded to\n * Medal and not part of the idempotency key.\n */\n window?: { fromTs?: unknown; toTs?: unknown };\n}\n\n/** The window the wizard shows when it names none: seven local days from now. */\nconst DEFAULT_WINDOW_DAYS = 7;\n\n/**\n * The body's `window`, held to the same guards as the read routes' range\n * (`parseRange`: readable, ordered, at most 62 days) — or the default window.\n * Never an error: this only shapes what a refusal carries back.\n */\nfunction slotWindow(\n rt: BookingRuntime,\n raw: CreateRequest['window']\n): { fromTs: number; toTs: number } {\n const fromTs = raw?.fromTs;\n const toTs = raw?.toTs;\n if (typeof fromTs === 'number' && typeof toTs === 'number') {\n const parsed = parseRange(\n new URLSearchParams({ from_ts: String(fromTs), to_ts: String(toTs) })\n );\n if (!('error' in parsed)) return parsed;\n }\n const { fromTs: defaultFrom, toTs: defaultTo } = rt.clock.window(Date.now(), DEFAULT_WINDOW_DAYS);\n return { fromTs: defaultFrom, toTs: defaultTo };\n}\n\n/**\n * What is free NOW for each VISIT in a refused booking, read live past the\n * slot cache — the entry that offered the lost slot may still be there, since\n * the tag expiry lands after this response. Keyed by visit key (`a+b` for a\n * person having both, the bare id for one service), the key the wizard stores\n * availability under: a visit's openings are not its first service's. Best\n * effort per visit: one that fails is left out, and the wizard reads it the\n * ordinary way.\n */\nasync function liveSlotsFor(\n rt: BookingRuntime,\n items: CreateBookingBody['items'],\n window: { fromTs: number; toTs: number }\n): Promise<Record<string, BookingSlotDto[]>> {\n const visits = new Map(\n items.map((item) => {\n const extraServiceIds = item.extra_service_ids ?? [];\n return [visitKeyOfIds([item.service_id, ...extraServiceIds]), item] as const;\n })\n );\n const read = await Promise.all(\n [...visits].map(async ([visit, { service_id: serviceId, extra_service_ids }]) => {\n try {\n const slots = await rt.catalogue.cachedAvailability(\n {\n serviceId,\n ...(extra_service_ids ? { extraServiceIds: extra_service_ids } : {}),\n ...window,\n },\n { fresh: true }\n );\n return [visit, slots.flatMap(rt.dto.toBookingSlotDto)] as const;\n } catch (error) {\n rt.logger.warn({ err: error, visit }, 'Could not re-read openings after a taken slot');\n return null;\n }\n })\n );\n return Object.fromEntries(read.filter((entry) => entry !== null));\n}\n\n/**\n * The fix for the trap `medal-client.ts` documents and deliberately refuses to\n * apply on its own.\n *\n * Empty is not \"absent\" to this API, and the inconsistency is what hides it:\n * the engine takes `contact.name` and `booked_for_name` as\n * `z.string().trim().min(1).optional()` — `''` is a 400 — while `email`, right\n * next to them, has no `min` and lets `''` straight through. The wizard seeds\n * all three to `''` because an empty text input has no other value, so without\n * this a parent who skips «Ditt navn», or leaves «Hvem skal klippes?» blank,\n * cannot book at all — and the error names a field they left empty on purpose.\n */\nfunction blankToUndefined(value: unknown): string | undefined {\n if (typeof value !== 'string') return undefined;\n const trimmed = value.trim();\n return trimmed.length > 0 ? trimmed : undefined;\n}\n\n/**\n * The same rule for the one optional field that is a number.\n *\n * An emptied number input serialises as `null` (or `NaN`, which\n * `JSON.stringify` also writes as `null`), and `z.number().optional()` refuses\n * null exactly as `z.string().optional()` does.\n */\nfunction numberOrUndefined(value: unknown): number | undefined {\n return typeof value === 'number' && Number.isFinite(value) ? value : undefined;\n}\n\n/**\n * The same loose shape «Bekreft» checks, kept in step with `looksLikeEmail` there.\n *\n * Loose on purpose: the field is optional, so the only job is to refuse an\n * address that cannot receive anything. A stricter pattern rejects real\n * addresses, and refusing to book a haircut over an e-mail nobody had to give\n * is the worse error.\n */\nconst EMAIL_SHAPE = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;\n\nfunction invalid(message: string): NextResponse {\n return bookingErrorResponse('invalidInput', 400, message);\n}\n\n/**\n * A line's `extraServiceIds` — the rest of this person's visit after\n * `serviceId`, in order — or why not. Absent, `null` and `[]` are a\n * one-service visit. Refused locally on everything the engine would refuse\n * with a 422 (more than three, one twice, `serviceId` again), and on anything\n * that is not a list of non-blank strings: a blank id is not «no extra», it is\n * a wizard that lost track of what the person chose.\n */\nfunction extraServiceIdsOf(\n raw: unknown,\n serviceId: string,\n index: number\n): { ids: string[] } | { error: NextResponse } {\n if (raw === undefined || raw === null) return { ids: [] };\n const field = `items.${index}.extraServiceIds`;\n if (!Array.isArray(raw)) return { error: invalid(`${field} must be a list of service ids`) };\n const ids: string[] = [];\n for (const entry of raw) {\n const id = blankToUndefined(entry);\n if (id === undefined) return { error: invalid(`${field} must be a list of service ids`) };\n ids.push(id);\n }\n if (ids.length > MAX_EXTRA_SERVICES) {\n return { error: invalid(`${field} must name at most ${MAX_EXTRA_SERVICES} services`) };\n }\n if (new Set(ids).size !== ids.length) return { error: invalid(`${field} names a service twice`) };\n if (ids.includes(serviceId)) return { error: invalid(`${field} repeats serviceId`) };\n return { ids };\n}\n\n/**\n * Turn the wizard's submission into Medal's create body, or say why not.\n *\n * Every rejection here is one the engine would also make. Answering locally is\n * not just faster: the engine's messages are written for the integrator holding\n * the API key and quote wire field names (`contact.phone`, `items.0.start_ts`)\n * that mean nothing to the person filling in the form.\n */\nfunction toMedalBody(body: CreateRequest): { body: CreateBookingBody } | { error: NextResponse } {\n if (!Array.isArray(body.items) || body.items.length === 0) {\n return { error: invalid('items must contain at least one service') };\n }\n\n const phone = blankToUndefined(body.contact?.phone);\n if (phone === undefined) {\n return { error: invalid('contact.phone is required') };\n }\n\n // The engine takes `email` as `.trim().max(320)` with no shape check and no\n // `min`, so `kari@` is a 200 from its point of view — the appointment is made\n // and its confirmation and calendar invitation go nowhere. «Bekreft» checks the\n // same shape at the field; this is the boundary saying it again, because the\n // route is reachable without the wizard and «booked but unreachable» is not a\n // state worth being able to create.\n const email = blankToUndefined(body.contact?.email);\n if (email !== undefined && !EMAIL_SHAPE.test(email)) {\n return { error: invalid('contact.email is not a valid address') };\n }\n\n // Checked last so a malformed payload reports the malformation, but checked\n // before anything is sent: the terms box is the legal gate on the booking,\n // not a field the engine will catch for us — it has no idea it exists.\n if (body.consentTerms !== true) {\n return { error: invalid('consentTerms must be accepted') };\n }\n\n const items: CreateBookingBody['items'] = [];\n for (const [index, item] of body.items.entries()) {\n const serviceId = blankToUndefined(item?.serviceId);\n if (serviceId === undefined) {\n return { error: invalid(`items.${index}.serviceId is required`) };\n }\n // `Number.isFinite` and not `Number(...)`: `Number(null)` is 0, and a\n // cleared slot serialises as null — so a lenient parse would quietly book\n // the haircut at midnight on 1 January 1970.\n const startTs = item?.startTs;\n if (!Number.isFinite(startTs)) {\n return { error: invalid(`items.${index}.startTs must be a timestamp`) };\n }\n const extras = extraServiceIdsOf(item?.extraServiceIds, serviceId, index);\n if ('error' in extras) return extras;\n items.push({\n service_id: serviceId,\n // Only for a visit. Right after `service_id`, and absent rather than\n // `[]` otherwise: the idempotency key hashes this body, so a one-service\n // line must serialise exactly as it did before visits existed — and a\n // visit's extras are part of what makes two submissions different.\n ...(extras.ids.length > 0 ? { extra_service_ids: extras.ids } : {}),\n // `null` is the ORDINARY value here — «Første ledige» is the default\n // choice on step 2 and the one the salon prefers — and the engine's\n // `z.string().trim().min(1).optional()` refuses it.\n resource_id: blankToUndefined(item?.resourceId),\n start_ts: startTs as number,\n booked_for_name: blankToUndefined(item?.bookedForName),\n booked_for_birth_year: numberOrUndefined(item?.bookedForBirthYear),\n });\n }\n\n return {\n body: {\n items,\n contact: {\n phone,\n name: blankToUndefined(body.contact?.name),\n email,\n },\n notes: blankToUndefined(body.notes),\n },\n };\n}\n\n/**\n * The `Idempotency-Key` for one submission: the wizard's nonce and the body it\n * is submitting, hashed together.\n *\n * Both halves are load-bearing, and each way of getting it wrong is its own\n * failure. The one this route shipped with was to use neither: a fresh\n * `crypto.randomUUID()` per POST is a fresh key per tap, which is two bookings.\n * The two near misses:\n *\n * - **Nonce alone**, taken from the client as the key, would be a\n * cross-customer leak. Medal's idempotency store is scoped to the workspace,\n * not to the visitor, so if two browsers ever sent the same key the second\n * would be handed the first one's cached response — manage tokens included,\n * and a manage token is a live credential for somebody else's appointment.\n * - **Body alone** would replay: two families booking the same service at the\n * same time is a slot clash the engine should answer, and two identical\n * submissions weeks apart are two haircuts.\n *\n * Together they give the property the wizard actually needs. A retry of the\n * same submission — the double tap, the reload on a stalled request — carries\n * the same nonce and the same body, so it hashes to the same key and books\n * once. Two visitors cannot collide even if their nonces somehow matched,\n * because two families differ in at least the phone number; and a visitor who\n * changes their mind about the slot changes the body, which is a new key and\n * correctly a new booking.\n *\n * `crypto.subtle` rather than `node:crypto`: this runs on Cloudflare Workers,\n * where the Web Crypto global is the one that exists.\n *\n * The body is hashed *after* `toMedalBody`, which is what makes it canonical —\n * trimmed, blanks already dropped, keys in the fixed order this file writes\n * them. Two POSTs that differ only in whitespace would create the same booking,\n * so they had better derive the same key.\n *\n * No nonce means an older wizard, or a caller that is not the wizard. Those\n * still book — a per-request UUID, exactly as before — they simply do not get\n * the guarantee. Refusing them would turn a missing nicety into an outage.\n */\nasync function idempotencyKeyFor(nonce: unknown, body: CreateBookingBody): Promise<string> {\n if (typeof nonce !== 'string' || nonce.trim().length === 0) return crypto.randomUUID();\n const digest = await crypto.subtle.digest(\n 'SHA-256',\n new TextEncoder().encode(`${nonce}${JSON.stringify(body)}`)\n );\n return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('');\n}\n\n/**\n * The marketing box on «Bekreft», filed as its own record — and never at the\n * booking's expense.\n *\n * Three rules, all of them load-bearing:\n *\n * - Only when the box was ticked. `granted: false` is a *withdrawal*, and\n * writing one for every parent who left the box alone would overwrite the\n * consent they gave last time they booked.\n * - Only when there is an e-mail address. The record is keyed on it, and\n * consent to be e-mailed without somewhere to e-mail is not a consent to\n * anything.\n * - Never fatal. The appointment is what the customer came for; a consent\n * endpoint that is down, or an API key scoped to bookings alone, must cost\n * the salon a mailing-list entry and not a haircut. Logged at `warn` so the\n * silence is at least visible from the outside.\n *\n * Scheduled with `after` rather than awaited or floated, and both of the other\n * two are worse in their own direction. AWAITED, this sat between the booking\n * and the 201 with no timeout under it, so a consent endpoint that accepted the\n * connection and never answered withheld a booking that had already been made —\n * the browser gives up, the visitor reloads, `DetailsStep` remounts and mints a\n * fresh submission nonce, and the retry derives a different idempotency key and\n * books the child a second time. A try/catch does nothing about a stall.\n * FLOATED, on Workers a promise nobody is holding when the response goes out is\n * cancelled, so the consent would be written on a fast connection and lost on a\n * slow one. `after` is the one that answers first and still finishes the work:\n * on this runtime it lands on `ctx.waitUntil`, which the opennextjs-cloudflare\n * wrapper holds the whole request with — so the isolate stays alive for the\n * write while the visitor is already reading their confirmation.\n */\nasync function recordMarketingConsent(\n rt: BookingRuntime,\n email: string | undefined,\n granted: unknown\n): Promise<void> {\n const consent = marketingConsent(rt.config);\n // No marketing box on this site: nothing anybody ticked, nothing to file.\n if (granted !== true || email === undefined || consent === null) return;\n try {\n await rt.medal.recordConsent({\n email,\n consent_type: 'marketing_email',\n granted: true,\n source: consent.source,\n // The sentence that was actually on the screen, so what they agreed to is\n // recoverable years later rather than inferred from a boolean.\n consent_text: consent.text,\n version: consent.version,\n });\n } catch (error) {\n rt.logger.warn({ err: error }, 'Marketing consent was not recorded');\n }\n}\n\n/**\n * Every way a submission can fail, in the vocabulary `WizardState.error`\n * already speaks. The codes are consumed as-is, so the spelling is part of the\n * contract rather than a description of it.\n *\n * `VALIDATION_ERROR` and not just `INVALID_INPUT`: the engine raises\n * `ErrorCode.INVALID_INPUT` internally, but `withBookingApiErrors` routes it\n * through `validationError`, so what actually reaches the wire is `422\n * VALIDATION_ERROR`. Matching on the internal name alone would be a branch that\n * never runs — and every rejected body would surface as a bare 502.\n *\n * The 400 is deliberate where the upstream said 422: to the browser this IS a\n * bad request, and the wizard has one `invalidInput` state for it either way.\n */\nfunction createErrorResponse(rt: BookingRuntime, error: unknown): NextResponse {\n if (error instanceof MedalConfigError) {\n rt.logger.error({ err: error }, 'Booking API is not configured');\n return bookingErrorResponse('unconfigured', 503);\n }\n\n if (error instanceof MedalApiError) {\n // This submission's key already names a booking attempt — still running,\n // or made with a body this one does not match (an older wizard, a check\n // that answered differently). Either way something may be booked, and a\n // second try under the same key cannot book it again: «check your e-mail»,\n // not «try again». Logged, because a key conflict should not happen.\n if (error.code === 'IDEMPOTENCY_KEY_CONFLICT' || error.code === 'IDEMPOTENCY_IN_PROGRESS') {\n rt.logger.warn({ err: error }, 'Booking create hit an idempotency key already in use');\n return bookingErrorResponse('inProgress', 409);\n }\n if (error.code === 'CONFLICT') {\n // Two different answers on the time step: `slotTaken` sends the visitor back\n // with the neighbouring times highlighted, which a duplicate-key or\n // unseatable-party conflict has no slot to do. The engine distinguishes\n // them only in the message, so this is the seam. (`createRoute` answers a\n // taken slot itself, with the live openings, before it gets here.)\n /* v8 ignore next -- the slotTaken side is kept as moved; createRoute answers it first */\n return bookingErrorResponse(isSlotTaken(error) ? 'slotTaken' : 'conflict', 409);\n }\n if (error.code === 'VALIDATION_ERROR' || error.code === 'INVALID_INPUT') {\n return bookingErrorResponse('invalidInput', 400);\n }\n }\n\n rt.logger.error({ err: error }, 'Booking create failed');\n return bookingErrorResponse('upstreamError', 502);\n}\n\n/**\n * The create body's ceiling, read in bytes before anything parses it (as the\n * portal routes cap theirs). A family of three is about two kilobytes; this\n * leaves room for the largest party a site may configure and a long note,\n * and refuses the megabyte nobody's wizard sends.\n */\nconst CREATE_MAX_BODY_BYTES = 32 * 1024;\n\n/**\n * One submission, one booking — or one family, all-or-nothing, when `items`\n * carries more than one child.\n *\n * `Idempotency-Key` is what makes a double-tapped «Bekreft» on a flaky mobile\n * connection book once instead of twice, and it only does that if the second\n * tap derives the *same* key as the first. It cannot be minted here for that\n * reason: a fresh UUID per POST is a fresh key per tap, which is two bookings.\n * So the wizard sends the nonce and this route mixes the body in —\n * `idempotencyKeyFor` explains why it takes both.\n */\nexport async function createRoute(rt: BookingRuntime, request: Request): Promise<Response> {\n let parsed: unknown;\n try {\n const text = await readBoundedText(request, CREATE_MAX_BODY_BYTES);\n if (text === null) return invalid('body is too large');\n parsed = JSON.parse(text);\n } catch {\n // A body that will not parse is the browser's fault, not Medal's. Letting\n // it fall into the catch below would answer 502 and point the salon at an\n // outage that is not happening.\n return invalid('body must be valid JSON');\n }\n\n if (typeof parsed !== 'object' || parsed === null) {\n return invalid('body must be a JSON object');\n }\n\n const mapped = toMedalBody(parsed as CreateRequest);\n if ('error' in mapped) return mapped.error;\n // ONE SUBMISSION, ONE KEY, ONE BODY. The key is derived from what the\n // CLIENT asked for — ids included, before the server checks them — and the\n // body Medal is sent is a pure function of that request and the profile.\n // Medal's idempotency store compares the body's hash under a key: the same\n // key with a different body is 409 IDEMPOTENCY_KEY_CONFLICT, not a replay.\n // So a profile read that FAILS is not «send the names» — that would make\n // the retry, whose read succeeds, a different body under the same key — it\n // is a 503 before anything is booked, and the retry runs the same check.\n // `toMedalBody` has already refused a missing or empty `items`; the `?? []`\n // is kept as moved.\n /* v8 ignore next */\n const requestedIds = requestedPersonIds((parsed as CreateRequest).items ?? []);\n const keyBody = withIds(mapped.body, requestedIds, null);\n const checked = await withPersons(rt, mapped.body, requestedIds);\n if (checked === null) {\n return NextResponse.json({ error: 'upstreamError', retryable: true }, { status: 503 });\n }\n mapped.body = checked;\n\n try {\n // Inside the try, though nothing about a SHA-256 of a string this route\n // just built should throw: if the runtime ever disagrees, a logged 502 and\n // «timen ble ikke satt opp» is true — nothing was booked — where an\n // unhandled rejection would be a bare 500 with no line in the log saying\n // which booking it was.\n const idempotencyKey = await idempotencyKeyFor(\n (parsed as CreateRequest).submissionNonce,\n keyBody\n );\n const result = await rt.medal.createBooking(mapped.body, idempotencyKey);\n const booked = Array.isArray(result.bookings) ? result.bookings : [];\n // A null entry is a short answer, not a throw: the caches below must still go.\n const ids = booked.map((booking) => (booking as { id?: unknown } | null)?.id);\n const whole =\n booked.length === mapped.body.items.length &&\n ids.every((id) => typeof id === 'string' && id.length > 0) &&\n new Set(ids).size === ids.length;\n // The slots just taken are in the slot cache and in this colo's booking\n // seeds; expire both so neither this parent going back nor the next\n // visitor is offered them again.\n rt.catalogue.expireSlots(bookedServiceIds(mapped.body));\n await rt.seed.expireBookingSeeds(bookedServiceIds(mapped.body));\n // ONE BOOKING PER LINE, each with its own DISTINCT id, or no confirmation: a short\n // answer would hand the wizard lines with no booking behind them (and\n // calendar entries with no UID of their own). Something may be booked, so\n // the caches above still go; the answer is the generic create failure,\n // whose attempt the wizard keeps for the replay under the same key — and\n // no consent is filed for an appointment nobody can confirm.\n if (!whole) {\n rt.logger.error(\n { expected: mapped.body.items.length, received: booked.length },\n 'Booking create answered without one booking per line'\n );\n return bookingErrorResponse('upstreamError', 502);\n }\n // After the booking, never before: a consent recorded for an appointment\n // that then failed to exist is a mailing-list entry the customer never\n // agreed to give. `after` keeps that ordering — it is scheduled here, so it\n // cannot run for a `createBooking` that threw — while taking the write out\n // from between the appointment and the answer.\n after(() =>\n recordMarketingConsent(\n rt,\n mapped.body.contact.email,\n (parsed as CreateRequest).consentMarketing\n )\n );\n // Deliberately no start time: the endpoint does not send one back, and the\n // confirmation screen renders the slot the visitor picked. Echoing a time\n // from here would give it a second source to disagree with.\n return NextResponse.json(\n {\n bookings: booked.map((booking) => ({\n id: booking.id,\n manageToken: booking.manage_token,\n })),\n },\n { status: 201 }\n );\n } catch (error) {\n // SLOT_TAKEN means the cache offered a slot that was already gone. Expired\n // so the time step this visitor is sent back to is drawn from a live read.\n if (isSlotTaken(error)) {\n const serviceIds = bookedServiceIds(mapped.body);\n rt.catalogue.expireSlots(serviceIds);\n await rt.seed.expireBookingSeeds(serviceIds);\n // And the answer carries the live openings, so the wizard redraws the\n // time step from them rather than re-reading through the cache.\n const freshSlots = await liveSlotsFor(\n rt,\n mapped.body.items,\n slotWindow(rt, (parsed as CreateRequest).window)\n );\n return NextResponse.json({ error: 'slotTaken', freshSlots }, { status: 409 });\n }\n return createErrorResponse(rt, error);\n }\n}\n\n/** Every service a submission books — each visit's extras too, since a\n * visit's slot entry is tagged with all of its services and a write to any of\n * them must retire it. */\nfunction bookedServiceIds(body: CreateBookingBody): string[] {\n return body.items.flatMap((item) => [item.service_id, ...(item.extra_service_ids ?? [])]);\n}\n\n/** Medal's person ids are Convex ids; anything else is not one worth asking about. */\nconst PERSON_ID_SHAPE = /^[A-Za-z0-9_-]{1,64}$/;\n\n/**\n * `booked_for_person_id` on each line — THE PHONE RULE (SP10).\n *\n * Medal resolves a booking's contact by the PHONE first, then the e-mail, and\n * a person id is only valid on the contact who owns that child: an id sent\n * under a number that resolves to somebody else is a 404 for the whole\n * booking. So an id is forwarded only when all of this holds, and otherwise\n * the line goes as it always did — by name and year:\n *\n * - the browser holds a portal session (the `portal.cookieName` cookie),\n * - Medal still honours it and names the parent's phone,\n * - that phone and the one submitted are the same number (national digits),\n * - and the id is one of THAT parent's children.\n *\n * The browser already applies the phone rule before it sends an id; this is\n * the server not trusting it, because the route is reachable without the\n * wizard and a wrong id costs the whole family's booking.\n *\n * DETERMINISTIC, for the idempotency key's sake (see `POST`): ids are dropped\n * only when the check SUCCEEDED and said no — no session, an expired one, a\n * profile with another phone, a child who is not theirs — which a retry\n * against the same profile says again. A check that could not be made is\n * `null`, answered 503 without booking.\n *\n * THE COST: one extra round trip to Medal (`GET /portal/me`, with the\n * session) before the booking — only for a submission that carries an id,\n * which only a logged-in parent's wizard sends. A booking by names asks\n * nothing extra.\n */\nasync function withPersons(\n rt: BookingRuntime,\n body: CreateBookingBody,\n ids: ReadonlyArray<string | null>\n): Promise<CreateBookingBody | null> {\n if (ids.every((id) => id === null)) return body;\n const owned = await ownedPersonIds(rt, body.contact.phone);\n if (owned === null) return null;\n if (owned.size === 0) return body;\n return withIds(body, ids, owned);\n}\n\n/** The ids the client sent, one per line, `null` where none (or not an id). */\nfunction requestedPersonIds(requested: NonNullable<CreateRequest['items']>): Array<string | null> {\n return requested.map((item) => {\n const id = item?.bookedForPersonId;\n return typeof id === 'string' && PERSON_ID_SHAPE.test(id) ? id : null;\n });\n}\n\n/** `body` with each line's id set — all of them, or only those in `allowed`. */\nfunction withIds(\n body: CreateBookingBody,\n ids: ReadonlyArray<string | null>,\n allowed: ReadonlySet<string> | null\n): CreateBookingBody {\n if (ids.every((id) => id === null)) return body;\n return {\n ...body,\n items: body.items.map((item, index) => {\n const id = ids[index] ?? null;\n return id !== null && (allowed === null || allowed.has(id))\n ? { ...item, booked_for_person_id: id }\n : item;\n }),\n };\n}\n\n/**\n * The logged-in parent's children's ids — empty unless their phone is `phone`,\n * `null` when Medal could not be asked (an outage, a throttle): not an answer.\n */\nasync function ownedPersonIds(rt: BookingRuntime, phone: string): Promise<Set<string> | null> {\n const { nationalDigits } = rt.phone;\n try {\n const session = await rt.session.readPortalSession();\n if (session === null) return new Set();\n const profile = await rt.portal.getMe(session);\n if (profile.phone === null) return new Set();\n if (nationalDigits(profile.phone) !== nationalDigits(phone)) return new Set();\n return new Set(\n profile.family.flatMap((member) => (member.personId === null ? [] : [member.personId]))\n );\n } catch (error) {\n // A session Medal no longer honours is an answer, and the same one on a\n // retry: nobody is logged in, so the booking goes by names.\n if (error instanceof PortalSessionExpiredError) return new Set();\n // The seam scrubs the session.\n rt.logger.warn({ err: error }, 'Could not check the booking’s person ids; asking for a retry');\n return null;\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAiEA,MAAM,sBAAsB;;;;;;AAO5B,SAAS,WACP,IACA,KACkC;CAClC,MAAM,SAAS,KAAK;CACpB,MAAM,OAAO,KAAK;CAClB,IAAI,OAAO,WAAW,YAAY,OAAO,SAAS,UAAU;EAC1D,MAAM,SAAS,WACb,IAAI,gBAAgB;GAAE,SAAS,OAAO,MAAM;GAAG,OAAO,OAAO,IAAI;EAAE,CAAC,CACtE;EACA,IAAI,EAAE,WAAW,SAAS,OAAO;CACnC;CACA,MAAM,EAAE,QAAQ,aAAa,MAAM,cAAc,GAAG,MAAM,OAAO,KAAK,IAAI,GAAG,mBAAmB;CAChG,OAAO;EAAE,QAAQ;EAAa,MAAM;CAAU;AAChD;;;;;;;;;;AAWA,eAAe,aACb,IACA,OACA,QAC2C;CAC3C,MAAM,SAAS,IAAI,IACjB,MAAM,KAAK,SAAS;EAClB,MAAM,kBAAkB,KAAK,qBAAqB,CAAC;EACnD,OAAO,CAAC,cAAc,CAAC,KAAK,YAAY,GAAG,eAAe,CAAC,GAAG,IAAI;CACpE,CAAC,CACH;CACA,MAAM,OAAO,MAAM,QAAQ,IACzB,CAAC,GAAG,MAAM,CAAC,CAAC,IAAI,OAAO,CAAC,OAAO,EAAE,YAAY,WAAW,yBAAyB;EAC/E,IAAI;GASF,OAAO,CAAC,QAAO,MARK,GAAG,UAAU,mBAC/B;IACE;IACA,GAAI,oBAAoB,EAAE,iBAAiB,kBAAkB,IAAI,CAAC;IAClE,GAAG;GACL,GACA,EAAE,OAAO,KAAK,CAChB,EAAA,CACqB,QAAQ,GAAG,IAAI,gBAAgB,CAAC;EACvD,SAAS,OAAO;GACd,GAAG,OAAO,KAAK;IAAE,KAAK;IAAO;GAAM,GAAG,+CAA+C;GACrF,OAAO;EACT;CACF,CAAC,CACH;CACA,OAAO,OAAO,YAAY,KAAK,QAAQ,UAAU,UAAU,IAAI,CAAC;AAClE;;;;;;;;;;;;;AAcA,SAAS,iBAAiB,OAAoC;CAC5D,IAAI,OAAO,UAAU,UAAU,OAAO,KAAA;CACtC,MAAM,UAAU,MAAM,KAAK;CAC3B,OAAO,QAAQ,SAAS,IAAI,UAAU,KAAA;AACxC;;;;;;;;AASA,SAAS,kBAAkB,OAAoC;CAC7D,OAAO,OAAO,UAAU,YAAY,OAAO,SAAS,KAAK,IAAI,QAAQ,KAAA;AACvE;;;;;;;;;AAUA,MAAM,cAAc;AAEpB,SAAS,QAAQ,SAA+B;CAC9C,OAAO,qBAAqB,gBAAgB,KAAK,OAAO;AAC1D;;;;;;;;;AAUA,SAAS,kBACP,KACA,WACA,OAC6C;CAC7C,IAAI,QAAQ,KAAA,KAAa,QAAQ,MAAM,OAAO,EAAE,KAAK,CAAC,EAAE;CACxD,MAAM,QAAQ,SAAS,MAAM;CAC7B,IAAI,CAAC,MAAM,QAAQ,GAAG,GAAG,OAAO,EAAE,OAAO,QAAQ,GAAG,MAAM,+BAA+B,EAAE;CAC3F,MAAM,MAAgB,CAAC;CACvB,KAAK,MAAM,SAAS,KAAK;EACvB,MAAM,KAAK,iBAAiB,KAAK;EACjC,IAAI,OAAO,KAAA,GAAW,OAAO,EAAE,OAAO,QAAQ,GAAG,MAAM,+BAA+B,EAAE;EACxF,IAAI,KAAK,EAAE;CACb;CACA,IAAI,IAAI,SAAA,GACN,OAAO,EAAE,OAAO,QAAQ,GAAG,MAAM,8BAAkD,EAAE;CAEvF,IAAI,IAAI,IAAI,GAAG,CAAC,CAAC,SAAS,IAAI,QAAQ,OAAO,EAAE,OAAO,QAAQ,GAAG,MAAM,uBAAuB,EAAE;CAChG,IAAI,IAAI,SAAS,SAAS,GAAG,OAAO,EAAE,OAAO,QAAQ,GAAG,MAAM,mBAAmB,EAAE;CACnF,OAAO,EAAE,IAAI;AACf;;;;;;;;;AAUA,SAAS,YAAY,MAA4E;CAC/F,IAAI,CAAC,MAAM,QAAQ,KAAK,KAAK,KAAK,KAAK,MAAM,WAAW,GACtD,OAAO,EAAE,OAAO,QAAQ,yCAAyC,EAAE;CAGrE,MAAM,QAAQ,iBAAiB,KAAK,SAAS,KAAK;CAClD,IAAI,UAAU,KAAA,GACZ,OAAO,EAAE,OAAO,QAAQ,2BAA2B,EAAE;CASvD,MAAM,QAAQ,iBAAiB,KAAK,SAAS,KAAK;CAClD,IAAI,UAAU,KAAA,KAAa,CAAC,YAAY,KAAK,KAAK,GAChD,OAAO,EAAE,OAAO,QAAQ,sCAAsC,EAAE;CAMlE,IAAI,KAAK,iBAAiB,MACxB,OAAO,EAAE,OAAO,QAAQ,+BAA+B,EAAE;CAG3D,MAAM,QAAoC,CAAC;CAC3C,KAAK,MAAM,CAAC,OAAO,SAAS,KAAK,MAAM,QAAQ,GAAG;EAChD,MAAM,YAAY,iBAAiB,MAAM,SAAS;EAClD,IAAI,cAAc,KAAA,GAChB,OAAO,EAAE,OAAO,QAAQ,SAAS,MAAM,uBAAuB,EAAE;EAKlE,MAAM,UAAU,MAAM;EACtB,IAAI,CAAC,OAAO,SAAS,OAAO,GAC1B,OAAO,EAAE,OAAO,QAAQ,SAAS,MAAM,6BAA6B,EAAE;EAExE,MAAM,SAAS,kBAAkB,MAAM,iBAAiB,WAAW,KAAK;EACxE,IAAI,WAAW,QAAQ,OAAO;EAC9B,MAAM,KAAK;GACT,YAAY;GAKZ,GAAI,OAAO,IAAI,SAAS,IAAI,EAAE,mBAAmB,OAAO,IAAI,IAAI,CAAC;GAIjE,aAAa,iBAAiB,MAAM,UAAU;GAC9C,UAAU;GACV,iBAAiB,iBAAiB,MAAM,aAAa;GACrD,uBAAuB,kBAAkB,MAAM,kBAAkB;EACnE,CAAC;CACH;CAEA,OAAO,EACL,MAAM;EACJ;EACA,SAAS;GACP;GACA,MAAM,iBAAiB,KAAK,SAAS,IAAI;GACzC;EACF;EACA,OAAO,iBAAiB,KAAK,KAAK;CACpC,EACF;AACF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAwCA,eAAe,kBAAkB,OAAgB,MAA0C;CACzF,IAAI,OAAO,UAAU,YAAY,MAAM,KAAK,CAAC,CAAC,WAAW,GAAG,OAAO,OAAO,WAAW;CACrF,MAAM,SAAS,MAAM,OAAO,OAAO,OACjC,WACA,IAAI,YAAY,CAAC,CAAC,OAAO,GAAG,QAAQ,KAAK,UAAU,IAAI,GAAG,CAC5D;CACA,OAAO,MAAM,KAAK,IAAI,WAAW,MAAM,IAAI,SAAS,KAAK,SAAS,EAAE,CAAC,CAAC,SAAS,GAAG,GAAG,CAAC,CAAC,CAAC,KAAK,EAAE;AACjG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAiCA,eAAe,uBACb,IACA,OACA,SACe;CACf,MAAM,UAAU,iBAAiB,GAAG,MAAM;CAE1C,IAAI,YAAY,QAAQ,UAAU,KAAA,KAAa,YAAY,MAAM;CACjE,IAAI;EACF,MAAM,GAAG,MAAM,cAAc;GAC3B;GACA,cAAc;GACd,SAAS;GACT,QAAQ,QAAQ;GAGhB,cAAc,QAAQ;GACtB,SAAS,QAAQ;EACnB,CAAC;CACH,SAAS,OAAO;EACd,GAAG,OAAO,KAAK,EAAE,KAAK,MAAM,GAAG,oCAAoC;CACrE;AACF;;;;;;;;;;;;;;;AAgBA,SAAS,oBAAoB,IAAoB,OAA8B;CAC7E,IAAI,iBAAiB,kBAAkB;EACrC,GAAG,OAAO,MAAM,EAAE,KAAK,MAAM,GAAG,+BAA+B;EAC/D,OAAO,qBAAqB,gBAAgB,GAAG;CACjD;CAEA,IAAI,iBAAiB,eAAe;EAMlC,IAAI,MAAM,SAAS,8BAA8B,MAAM,SAAS,2BAA2B;GACzF,GAAG,OAAO,KAAK,EAAE,KAAK,MAAM,GAAG,sDAAsD;GACrF,OAAO,qBAAqB,cAAc,GAAG;EAC/C;EACA,IAAI,MAAM,SAAS;;EAOjB,OAAO,qBAAqB,YAAY,KAAK,IAAI,cAAc,YAAY,GAAG;EAEhF,IAAI,MAAM,SAAS,sBAAsB,MAAM,SAAS,iBACtD,OAAO,qBAAqB,gBAAgB,GAAG;CAEnD;CAEA,GAAG,OAAO,MAAM,EAAE,KAAK,MAAM,GAAG,uBAAuB;CACvD,OAAO,qBAAqB,iBAAiB,GAAG;AAClD;;;;;;;AAQA,MAAM,wBAAwB;;;;;;;;;;;;AAa9B,eAAsB,YAAY,IAAoB,SAAqC;CACzF,IAAI;CACJ,IAAI;EACF,MAAM,OAAO,MAAM,gBAAgB,SAAS,qBAAqB;EACjE,IAAI,SAAS,MAAM,OAAO,QAAQ,mBAAmB;EACrD,SAAS,KAAK,MAAM,IAAI;CAC1B,QAAQ;EAIN,OAAO,QAAQ,yBAAyB;CAC1C;CAEA,IAAI,OAAO,WAAW,YAAY,WAAW,MAC3C,OAAO,QAAQ,4BAA4B;CAG7C,MAAM,SAAS,YAAY,MAAuB;CAClD,IAAI,WAAW,QAAQ,OAAO,OAAO;;CAYrC,MAAM,eAAe,mBAAoB,OAAyB,SAAS,CAAC,CAAC;CAC7E,MAAM,UAAU,QAAQ,OAAO,MAAM,cAAc,IAAI;CACvD,MAAM,UAAU,MAAM,YAAY,IAAI,OAAO,MAAM,YAAY;CAC/D,IAAI,YAAY,MACd,OAAO,aAAa,KAAK;EAAE,OAAO;EAAiB,WAAW;CAAK,GAAG,EAAE,QAAQ,IAAI,CAAC;CAEvF,OAAO,OAAO;CAEd,IAAI;EAMF,MAAM,iBAAiB,MAAM,kBAC1B,OAAyB,iBAC1B,OACF;EACA,MAAM,SAAS,MAAM,GAAG,MAAM,cAAc,OAAO,MAAM,cAAc;EACvE,MAAM,SAAS,MAAM,QAAQ,OAAO,QAAQ,IAAI,OAAO,WAAW,CAAC;EAEnE,MAAM,MAAM,OAAO,KAAK,YAAa,SAAqC,EAAE;EAC5E,MAAM,QACJ,OAAO,WAAW,OAAO,KAAK,MAAM,UACpC,IAAI,OAAO,OAAO,OAAO,OAAO,YAAY,GAAG,SAAS,CAAC,KACzD,IAAI,IAAI,GAAG,CAAC,CAAC,SAAS,IAAI;EAI5B,GAAG,UAAU,YAAY,iBAAiB,OAAO,IAAI,CAAC;EACtD,MAAM,GAAG,KAAK,mBAAmB,iBAAiB,OAAO,IAAI,CAAC;EAO9D,IAAI,CAAC,OAAO;GACV,GAAG,OAAO,MACR;IAAE,UAAU,OAAO,KAAK,MAAM;IAAQ,UAAU,OAAO;GAAO,GAC9D,sDACF;GACA,OAAO,qBAAqB,iBAAiB,GAAG;EAClD;EAMA,YACE,uBACE,IACA,OAAO,KAAK,QAAQ,OACnB,OAAyB,gBAC5B,CACF;EAIA,OAAO,aAAa,KAClB,EACE,UAAU,OAAO,KAAK,aAAa;GACjC,IAAI,QAAQ;GACZ,aAAa,QAAQ;EACvB,EAAE,EACJ,GACA,EAAE,QAAQ,IAAI,CAChB;CACF,SAAS,OAAO;EAGd,IAAI,YAAY,KAAK,GAAG;GACtB,MAAM,aAAa,iBAAiB,OAAO,IAAI;GAC/C,GAAG,UAAU,YAAY,UAAU;GACnC,MAAM,GAAG,KAAK,mBAAmB,UAAU;GAG3C,MAAM,aAAa,MAAM,aACvB,IACA,OAAO,KAAK,OACZ,WAAW,IAAK,OAAyB,MAAM,CACjD;GACA,OAAO,aAAa,KAAK;IAAE,OAAO;IAAa;GAAW,GAAG,EAAE,QAAQ,IAAI,CAAC;EAC9E;EACA,OAAO,oBAAoB,IAAI,KAAK;CACtC;AACF;;;;AAKA,SAAS,iBAAiB,MAAmC;CAC3D,OAAO,KAAK,MAAM,SAAS,SAAS,CAAC,KAAK,YAAY,GAAI,KAAK,qBAAqB,CAAC,CAAE,CAAC;AAC1F;;AAGA,MAAM,kBAAkB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA+BxB,eAAe,YACb,IACA,MACA,KACmC;CACnC,IAAI,IAAI,OAAO,OAAO,OAAO,IAAI,GAAG,OAAO;CAC3C,MAAM,QAAQ,MAAM,eAAe,IAAI,KAAK,QAAQ,KAAK;CACzD,IAAI,UAAU,MAAM,OAAO;CAC3B,IAAI,MAAM,SAAS,GAAG,OAAO;CAC7B,OAAO,QAAQ,MAAM,KAAK,KAAK;AACjC;;AAGA,SAAS,mBAAmB,WAAsE;CAChG,OAAO,UAAU,KAAK,SAAS;EAC7B,MAAM,KAAK,MAAM;EACjB,OAAO,OAAO,OAAO,YAAY,gBAAgB,KAAK,EAAE,IAAI,KAAK;CACnE,CAAC;AACH;;AAGA,SAAS,QACP,MACA,KACA,SACmB;CACnB,IAAI,IAAI,OAAO,OAAO,OAAO,IAAI,GAAG,OAAO;CAC3C,OAAO;EACL,GAAG;EACH,OAAO,KAAK,MAAM,KAAK,MAAM,UAAU;GACrC,MAAM,KAAK,IAAI,UAAU;GACzB,OAAO,OAAO,SAAS,YAAY,QAAQ,QAAQ,IAAI,EAAE,KACrD;IAAE,GAAG;IAAM,sBAAsB;GAAG,IACpC;EACN,CAAC;CACH;AACF;;;;;AAMA,eAAe,eAAe,IAAoB,OAA4C;CAC5F,MAAM,EAAE,mBAAmB,GAAG;CAC9B,IAAI;EACF,MAAM,UAAU,MAAM,GAAG,QAAQ,kBAAkB;EACnD,IAAI,YAAY,MAAM,uBAAO,IAAI,IAAI;EACrC,MAAM,UAAU,MAAM,GAAG,OAAO,MAAM,OAAO;EAC7C,IAAI,QAAQ,UAAU,MAAM,uBAAO,IAAI,IAAI;EAC3C,IAAI,eAAe,QAAQ,KAAK,MAAM,eAAe,KAAK,GAAG,uBAAO,IAAI,IAAI;EAC5E,OAAO,IAAI,IACT,QAAQ,OAAO,SAAS,WAAY,OAAO,aAAa,OAAO,CAAC,IAAI,CAAC,OAAO,QAAQ,CAAE,CACxF;CACF,SAAS,OAAO;EAGd,IAAI,iBAAiB,2BAA2B,uBAAO,IAAI,IAAI;EAE/D,GAAG,OAAO,KAAK,EAAE,KAAK,MAAM,GAAG,8DAA8D;EAC7F,OAAO;CACT;AACF"}
|