@medalsocial/booking 0.4.0 → 0.4.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/CHANGELOG.md +20 -0
- package/dist/core/config.mjs +1 -0
- package/dist/core/config.mjs.map +1 -1
- package/dist/core/index.d.mts +2 -1
- package/dist/core/index.mjs +2 -1
- package/dist/core/range.d.mts +30 -0
- package/dist/core/range.mjs +32 -0
- package/dist/core/range.mjs.map +1 -0
- package/dist/next/catalogue.mjs +4 -5
- package/dist/next/catalogue.mjs.map +1 -1
- package/dist/next/handler.mjs +63 -5
- package/dist/next/handler.mjs.map +1 -1
- package/dist/next/medal.mjs +47 -15
- package/dist/next/medal.mjs.map +1 -1
- package/dist/next/routes/create.mjs +3 -6
- package/dist/next/routes/create.mjs.map +1 -1
- package/dist/next/routes/manage.mjs +5 -8
- package/dist/next/routes/manage.mjs.map +1 -1
- package/dist/next/routes/shared.mjs +57 -14
- package/dist/next/routes/shared.mjs.map +1 -1
- package/dist/react/BookingWizard.d.mts +7 -1
- package/dist/react/BookingWizard.mjs +13 -0
- package/dist/react/BookingWizard.mjs.map +1 -1
- package/dist/react/index.d.mts +2 -2
- package/dist/react/useBooking.d.mts +11 -1
- package/dist/react/useBooking.mjs +101 -27
- package/dist/react/useBooking.mjs.map +1 -1
- package/dist/react/wizard/index.d.mts +2 -2
- package/package.json +2 -2
package/dist/next/medal.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { redactSession } from "./redact.mjs";
|
|
2
|
-
import { Medal, MedalApiError as MedalApiError$1 } from "@medalsocial/sdk";
|
|
2
|
+
import { Medal, MedalApiError as MedalApiError$1, MedalTimeoutError } from "@medalsocial/sdk";
|
|
3
3
|
//#region src/next/medal.ts
|
|
4
4
|
var MedalConfigError = class extends Error {
|
|
5
5
|
constructor(message) {
|
|
@@ -51,9 +51,38 @@ function rangeParams(args) {
|
|
|
51
51
|
...args.extraServiceIds?.length ? { extra_service_ids: [...args.extraServiceIds] } : {}
|
|
52
52
|
};
|
|
53
53
|
}
|
|
54
|
+
/**
|
|
55
|
+
* One attempt at a catalogue or calendar read. The SDK's default is 30 s with
|
|
56
|
+
* two retries — over a minute and a half of a parent looking at a skeleton
|
|
57
|
+
* before the page can say «ring oss». A Medal that has not answered a read in
|
|
58
|
+
* a few seconds is not going to answer this visitor.
|
|
59
|
+
*/
|
|
60
|
+
const READ_ATTEMPT_TIMEOUT_MS = 4e3;
|
|
61
|
+
/**
|
|
62
|
+
* The whole read, retries and any `Retry-After` wait included. The SDK honours
|
|
63
|
+
* `Retry-After` uncapped, so a 429 could otherwise park the read for as long
|
|
64
|
+
* as the server asked.
|
|
65
|
+
*/
|
|
66
|
+
const READ_DEADLINE_MS = 8e3;
|
|
67
|
+
/**
|
|
68
|
+
* A read bounded by `READ_DEADLINE_MS`. The deadline aborts the SDK call
|
|
69
|
+
* itself — its attempt, its retries and any `Retry-After` wait — so a read the
|
|
70
|
+
* visitor has stopped waiting for sends nothing more upstream. It ends with
|
|
71
|
+
* the SDK's own `MedalTimeoutError`.
|
|
72
|
+
*/
|
|
73
|
+
async function withinDeadline(read) {
|
|
74
|
+
const controller = new AbortController();
|
|
75
|
+
const timer = setTimeout(() => controller.abort(new MedalTimeoutError(READ_DEADLINE_MS)), READ_DEADLINE_MS);
|
|
76
|
+
try {
|
|
77
|
+
return await read({ signal: controller.signal });
|
|
78
|
+
} finally {
|
|
79
|
+
clearTimeout(timer);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
54
82
|
function createMedalSeam(options) {
|
|
55
83
|
let cached = null;
|
|
56
|
-
|
|
84
|
+
/** One client per timeout: the reads and the Vipps calls use different ones. */
|
|
85
|
+
const quick = /* @__PURE__ */ new Map();
|
|
57
86
|
function readConfig() {
|
|
58
87
|
const key = read(options.apiKey);
|
|
59
88
|
if (!key || looksLikePlaceholderKey(key)) return null;
|
|
@@ -80,42 +109,45 @@ function createMedalSeam(options) {
|
|
|
80
109
|
function requireMedalWithTimeout(timeout) {
|
|
81
110
|
const config = readConfig();
|
|
82
111
|
if (!config) throw new MedalConfigError("The Medal API key is not configured");
|
|
83
|
-
|
|
84
|
-
|
|
112
|
+
const known = quick.get(timeout);
|
|
113
|
+
if (known && known.key === config.key && known.base === config.base) return known.medal;
|
|
114
|
+
const medal = new Medal(config.key, {
|
|
115
|
+
baseUrl: config.base,
|
|
116
|
+
timeout
|
|
117
|
+
});
|
|
118
|
+
quick.set(timeout, {
|
|
85
119
|
...config,
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
timeout
|
|
90
|
-
})
|
|
91
|
-
};
|
|
92
|
-
return cachedQuick.medal;
|
|
120
|
+
medal
|
|
121
|
+
});
|
|
122
|
+
return medal;
|
|
93
123
|
}
|
|
94
124
|
function requireMedalConfig() {
|
|
95
125
|
const config = readConfig();
|
|
96
126
|
if (!config) throw new MedalConfigError("The Medal API key is not configured");
|
|
97
127
|
return config;
|
|
98
128
|
}
|
|
129
|
+
/** The client for a read a visitor is waiting on. */
|
|
130
|
+
const reader = () => requireMedalWithTimeout(READ_ATTEMPT_TIMEOUT_MS);
|
|
99
131
|
return {
|
|
100
132
|
getMedal,
|
|
101
133
|
requireMedal,
|
|
102
134
|
requireMedalWithTimeout,
|
|
103
135
|
requireMedalConfig,
|
|
104
136
|
async listServices() {
|
|
105
|
-
return unwrap(await
|
|
137
|
+
return unwrap(await withinDeadline((request) => reader().bookings.listServices(void 0, request)), "/api/v1/bookings/services");
|
|
106
138
|
},
|
|
107
139
|
async listResources() {
|
|
108
|
-
return unwrap(await
|
|
140
|
+
return unwrap(await withinDeadline((request) => reader().bookings.listResources(request)), "/api/v1/bookings/resources");
|
|
109
141
|
},
|
|
110
142
|
async listAvailability(args) {
|
|
111
|
-
return unwrap(await
|
|
143
|
+
return unwrap(await withinDeadline((request) => reader().bookings.availability(rangeParams(args), request)), "/api/v1/bookings/availability");
|
|
112
144
|
},
|
|
113
145
|
/**
|
|
114
146
|
* The open dates over a range — the half availability cannot answer. A
|
|
115
147
|
* date ABSENT from this response is one the business keeps no hours on.
|
|
116
148
|
*/
|
|
117
149
|
async listSchedule(args) {
|
|
118
|
-
return unwrap(await
|
|
150
|
+
return unwrap(await withinDeadline((request) => reader().bookings.schedule(rangeParams(args), request)), "/api/v1/bookings/schedule");
|
|
119
151
|
},
|
|
120
152
|
async createBooking(body, idempotencyKey, opts) {
|
|
121
153
|
const portalSession = opts?.portalSession;
|
package/dist/next/medal.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"medal.mjs","names":["MedalApiError"],"sources":["../../src/next/medal.ts"],"sourcesContent":["/**\n * The booking seam: everything a booking site asks Medal, in the site's own\n * terms.\n *\n * Backed by `@medalsocial/sdk`, which owns the wire format, auth, timeouts and\n * 429/5xx retries. What stays here is the handful of decisions that are the\n * deployment's to make and must not become a per-call option for whoever\n * calls in: `created_via: 'web'` forced last, a 200 that is not JSON treated\n * as a failure, the manage summary narrowed, and a missing or placeholder key\n * failing loudly as `MedalConfigError` instead of a confusing 401.\n *\n * THE KEY IS THE APP'S. The package reads no environment variable. A site\n * passes its key — or, where the platform only fills `process.env` per\n * request (OpenNext on Cloudflare Workers), a function that reads it — and\n * the seam reads it again on every call, re-keying its client when it\n * changes rather than pinning the first one an isolate saw.\n */\n\nimport type {\n BookingClaimableCreatedVia,\n CreateBookingInput,\n RecordConsentInput,\n} from '@medalsocial/sdk';\nimport { Medal, MedalApiError } from '@medalsocial/sdk';\nimport type {\n MedalManageSummary,\n MedalResource,\n MedalScheduleDay,\n MedalService,\n MedalSlot,\n} from '../core/wire';\nimport { redactSession } from './redact';\n\nexport { MedalApiError };\n\nexport class MedalConfigError extends Error {\n constructor(message: string) {\n super(message);\n // `extends Error` leaves `name` as \"Error\"; an uncaught one in a Workers\n // log would then say nothing about where to look.\n this.name = 'MedalConfigError';\n }\n}\n\n/**\n * Medal's HTTP-actions origin. `app.medalsocial.com` is the dashboard and\n * answers `/api/v1/...` with its own 200 HTML \"Page not found\", so a wrong\n * value fails as a JSON parse error several layers from the cause. A\n * deployment should pass its origin explicitly; this is only ever reached by\n * a run started without one — exactly when a wrong default costs most.\n */\nexport const DEFAULT_MEDAL_ENDPOINT = 'https://io.medalsocial.com';\n\n/** A value, or a function that reads it when it is needed. */\ntype Lazy<T> = T | (() => T);\n\nexport interface MedalSeamOptions {\n apiKey: Lazy<string | undefined>;\n baseUrl?: Lazy<string | undefined>;\n}\n\n/**\n * Whether a key is a secret manager's placeholder rather than a key. Secret\n * stores seed new folders with values like `pending…`, so a deployment can\n * hold a syntactically present key Medal will reject; treated as absent, it\n * fails as an obvious config error rather than a 401.\n */\nexport function looksLikePlaceholderKey(key: string): boolean {\n return /^(?:pending|placeholder|changeme|change-me|todo|replace-me|<)/i.test(key.trim());\n}\n\n/**\n * A 200 whose body is not JSON is not a success. The SDK hands a non-JSON body\n * back as-is, so `data` is `undefined` — and, undetected, that travels into\n * whichever route tried to `.map` over it and surfaces there, pointing at a\n * file with nothing wrong in it. The reachable trigger is an endpoint aimed at\n * a host that answers with an HTML page (the dashboard does).\n */\nexport function unwrap<T>(response: { data: T } | undefined, path: string): T {\n const data = response?.data;\n if (data === undefined) {\n throw new MedalApiError(200, 'NON_JSON_BODY', `Medal returned a non-JSON body from ${path}`);\n }\n return data;\n}\n\nexport interface RangeArgs {\n serviceId: string;\n /**\n * The rest of ONE person's visit after `serviceId`, in order — absent (or\n * empty) for a one-service visit. With it the engine answers for the whole\n * visit: slots as long as every service together, on stylists who perform\n * all of them, and a schedule whose last start leaves room for the lot.\n */\n extraServiceIds?: readonly string[];\n resourceId?: string;\n fromTs: number;\n toTs: number;\n}\n\n/**\n * No `created_via` here: the site is unambiguously `'web'`, so `createBooking`\n * sets it rather than taking it. Provenance is a fact about the deployment,\n * not a per-call option.\n *\n * `booked_for_name` and `contact.name` are validated `.min(1)` by the engine\n * (`''` is a 400) while `contact.email` is not, so whatever maps wizard state\n * onto this body must coalesce blanks to `undefined` before calling in.\n */\nexport type CreateBookingBody = Omit<CreateBookingInput, 'created_via'>;\n\n/**\n * Deliberately no `start_ts`: the create response carries only the new id and\n * the one-time manage token per booking. `manage_token` is OPTIONAL: it is\n * minted exactly once, and an idempotent replay returns the same ids WITHOUT\n * it.\n */\nexport interface CreateBookingResult {\n bookings: Array<{ id: string; manage_token?: string }>;\n}\n\n/**\n * The marketing box, filed where a consent belongs. This endpoint is scoped\n * `write:contacts` where the booking endpoints are scoped to bookings: a key\n * minted for bookings alone will 403 here.\n */\nexport type RecordConsentBody = RecordConsentInput & { consent_type: 'marketing_email' };\n\n/**\n * What a completed move hands back. A reschedule is an insert plus a cancel,\n * so `manage_token` is the only way to reach the new booking; it is minted\n * once and absent from an idempotent replay.\n */\nexport interface RescheduleResult {\n booking_id?: string;\n manage_token?: string;\n}\n\nexport interface MedalSeam {\n /** The client, or `null` when the deployment has no usable key. */\n getMedal(): Medal | null;\n /** The client, or a loud `MedalConfigError`. */\n requireMedal(): Medal;\n /** `requireMedal()` with a shorter per-attempt deadline than the SDK's 30 s. */\n requireMedalWithTimeout(timeout: number): Medal;\n /** The key and origin, for the few raw routes the SDK lacks. */\n requireMedalConfig(): { key: string; base: string };\n listServices(): Promise<MedalService[]>;\n listResources(): Promise<MedalResource[]>;\n listAvailability(args: RangeArgs): Promise<MedalSlot[]>;\n listSchedule(args: RangeArgs): Promise<MedalScheduleDay[]>;\n /**\n * `opts.portalSession` — the logged-in parent's portal session, sent as\n * `X-Portal-Session` so Medal books on THAT contact. Never logged: a throw\n * is scrubbed of it before it leaves.\n */\n createBooking(\n body: CreateBookingBody,\n idempotencyKey: string,\n opts?: { portalSession?: string }\n ): Promise<CreateBookingResult>;\n recordConsent(body: RecordConsentBody): Promise<unknown>;\n getManage(token: string): Promise<MedalManageSummary>;\n cancelManage(token: string, reason?: string, idempotencyKey?: string): Promise<unknown>;\n rescheduleManage(\n token: string,\n startTs: number,\n idempotencyKey: string\n ): Promise<RescheduleResult>;\n}\n\nconst SITE_PROVENANCE: BookingClaimableCreatedVia = 'web';\n\nfunction read<T>(value: Lazy<T>): T {\n return typeof value === 'function' ? (value as () => T)() : value;\n}\n\n/** ISO on the wire — the engine parses either, but a millisecond epoch reads\n * as nothing in a request log. */\nfunction rangeParams(args: RangeArgs) {\n return {\n service_id: args.serviceId,\n from_ts: new Date(args.fromTs).toISOString(),\n to_ts: new Date(args.toTs).toISOString(),\n ...(args.resourceId ? { resource_id: args.resourceId } : {}),\n // Only when there is a visit to describe: a one-service read asks exactly\n // what it asked before, so nothing about it changes on the wire.\n ...(args.extraServiceIds?.length ? { extra_service_ids: [...args.extraServiceIds] } : {}),\n };\n}\n\nexport function createMedalSeam(options: MedalSeamOptions): MedalSeam {\n let cached: { key: string; base: string; medal: Medal } | null = null;\n let cachedQuick: { key: string; base: string; timeout: number; medal: Medal } | null = null;\n\n function readConfig(): { key: string; base: string } | null {\n const key = read(options.apiKey);\n if (!key || looksLikePlaceholderKey(key)) return null;\n return { key, base: read(options.baseUrl) || DEFAULT_MEDAL_ENDPOINT };\n }\n\n function getMedal(): Medal | null {\n const config = readConfig();\n if (!config) return null;\n if (cached && cached.key === config.key && cached.base === config.base) {\n return cached.medal;\n }\n cached = { ...config, medal: new Medal(config.key, { baseUrl: config.base }) };\n return cached.medal;\n }\n\n function requireMedal(): Medal {\n const medal = getMedal();\n if (!medal) throw new MedalConfigError('The Medal API key is not configured');\n return medal;\n }\n\n function requireMedalWithTimeout(timeout: number): Medal {\n const config = readConfig();\n if (!config) throw new MedalConfigError('The Medal API key is not configured');\n if (\n cachedQuick &&\n cachedQuick.key === config.key &&\n cachedQuick.base === config.base &&\n cachedQuick.timeout === timeout\n ) {\n return cachedQuick.medal;\n }\n cachedQuick = {\n ...config,\n timeout,\n medal: new Medal(config.key, { baseUrl: config.base, timeout }),\n };\n return cachedQuick.medal;\n }\n\n function requireMedalConfig(): { key: string; base: string } {\n const config = readConfig();\n if (!config) throw new MedalConfigError('The Medal API key is not configured');\n return config;\n }\n\n return {\n getMedal,\n requireMedal,\n requireMedalWithTimeout,\n requireMedalConfig,\n\n async listServices() {\n return unwrap(await requireMedal().bookings.listServices(), '/api/v1/bookings/services');\n },\n\n async listResources() {\n return unwrap(await requireMedal().bookings.listResources(), '/api/v1/bookings/resources');\n },\n\n async listAvailability(args) {\n return unwrap(\n await requireMedal().bookings.availability(rangeParams(args)),\n '/api/v1/bookings/availability'\n );\n },\n\n /**\n * The open dates over a range — the half availability cannot answer. A\n * date ABSENT from this response is one the business keeps no hours on.\n */\n async listSchedule(args) {\n return unwrap(\n await requireMedal().bookings.schedule(rangeParams(args)),\n '/api/v1/bookings/schedule'\n );\n },\n\n async createBooking(body, idempotencyKey, opts) {\n const portalSession = opts?.portalSession;\n let response: Awaited<ReturnType<Medal['bookings']['create']>>;\n try {\n response = await requireMedal().bookings.create(\n // `created_via` LAST, so it wins over anything a caller smuggled in.\n { ...body, created_via: SITE_PROVENANCE },\n portalSession ? { idempotencyKey, portalSession } : { idempotencyKey }\n );\n } catch (error) {\n // The session is never logged, and the create route logs what Medal threw.\n throw portalSession ? redactSession(error, portalSession) : error;\n }\n const result = unwrap(response, '/api/v1/bookings');\n return { bookings: result.bookings };\n },\n\n async recordConsent(body) {\n return unwrap(await requireMedal().gdpr.recordConsent(body), '/api/v1/gdpr/consent');\n },\n\n async getManage(token) {\n const path = '/api/v1/bookings/manage/<token>';\n const summary = unwrap(await requireMedal().bookings.manage.get(token), path);\n if (summary.status === null) {\n // Not a case the engine produces; if it ever does, a page that shows a\n // booking with no state is worse than the «cannot find» card this becomes.\n throw new MedalApiError(\n 200,\n 'MALFORMED_SUMMARY',\n `Medal returned a booking without a status from ${path}`\n );\n }\n return {\n ...summary,\n status: summary.status,\n service_name: summary.service_name ?? 'Unknown service',\n resource_name: summary.resource_name ?? 'Unknown resource',\n };\n },\n\n /**\n * `idempotencyKey` is optional only in the type — the caller derives one\n * from the token so a retry after a dropped response replays rather than\n * answering `CONFLICT` about a booking that is already cancelled.\n */\n async cancelManage(token, reason, idempotencyKey) {\n return unwrap(\n await requireMedal().bookings.manage.cancel(\n token,\n reason === undefined ? undefined : { reason },\n idempotencyKey ? { idempotencyKey } : undefined\n ),\n '/api/v1/bookings/manage/<token>/cancel'\n );\n },\n\n async rescheduleManage(token, startTs, idempotencyKey) {\n return unwrap(\n await requireMedal().bookings.manage.reschedule(\n token,\n { new_start_ts: new Date(startTs).toISOString() },\n { idempotencyKey }\n ),\n '/api/v1/bookings/manage/<token>/reschedule'\n );\n },\n };\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"medal.mjs","names":["MedalApiError"],"sources":["../../src/next/medal.ts"],"sourcesContent":["/**\n * The booking seam: everything a booking site asks Medal, in the site's own\n * terms.\n *\n * Backed by `@medalsocial/sdk`, which owns the wire format, auth, timeouts and\n * 429/5xx retries. What stays here is the handful of decisions that are the\n * deployment's to make and must not become a per-call option for whoever\n * calls in: `created_via: 'web'` forced last, a 200 that is not JSON treated\n * as a failure, the manage summary narrowed, and a missing or placeholder key\n * failing loudly as `MedalConfigError` instead of a confusing 401.\n *\n * THE KEY IS THE APP'S. The package reads no environment variable. A site\n * passes its key — or, where the platform only fills `process.env` per\n * request (OpenNext on Cloudflare Workers), a function that reads it — and\n * the seam reads it again on every call, re-keying its client when it\n * changes rather than pinning the first one an isolate saw.\n */\n\nimport type {\n BookingClaimableCreatedVia,\n CreateBookingInput,\n RecordConsentInput,\n} from '@medalsocial/sdk';\nimport { Medal, MedalApiError, MedalTimeoutError } from '@medalsocial/sdk';\nimport type {\n MedalManageSummary,\n MedalResource,\n MedalScheduleDay,\n MedalService,\n MedalSlot,\n} from '../core/wire';\nimport { redactSession } from './redact';\n\nexport { MedalApiError };\n\nexport class MedalConfigError extends Error {\n constructor(message: string) {\n super(message);\n // `extends Error` leaves `name` as \"Error\"; an uncaught one in a Workers\n // log would then say nothing about where to look.\n this.name = 'MedalConfigError';\n }\n}\n\n/**\n * Medal's HTTP-actions origin. `app.medalsocial.com` is the dashboard and\n * answers `/api/v1/...` with its own 200 HTML \"Page not found\", so a wrong\n * value fails as a JSON parse error several layers from the cause. A\n * deployment should pass its origin explicitly; this is only ever reached by\n * a run started without one — exactly when a wrong default costs most.\n */\nexport const DEFAULT_MEDAL_ENDPOINT = 'https://io.medalsocial.com';\n\n/** A value, or a function that reads it when it is needed. */\ntype Lazy<T> = T | (() => T);\n\nexport interface MedalSeamOptions {\n apiKey: Lazy<string | undefined>;\n baseUrl?: Lazy<string | undefined>;\n}\n\n/**\n * Whether a key is a secret manager's placeholder rather than a key. Secret\n * stores seed new folders with values like `pending…`, so a deployment can\n * hold a syntactically present key Medal will reject; treated as absent, it\n * fails as an obvious config error rather than a 401.\n */\nexport function looksLikePlaceholderKey(key: string): boolean {\n return /^(?:pending|placeholder|changeme|change-me|todo|replace-me|<)/i.test(key.trim());\n}\n\n/**\n * A 200 whose body is not JSON is not a success. The SDK hands a non-JSON body\n * back as-is, so `data` is `undefined` — and, undetected, that travels into\n * whichever route tried to `.map` over it and surfaces there, pointing at a\n * file with nothing wrong in it. The reachable trigger is an endpoint aimed at\n * a host that answers with an HTML page (the dashboard does).\n */\nexport function unwrap<T>(response: { data: T } | undefined, path: string): T {\n const data = response?.data;\n if (data === undefined) {\n throw new MedalApiError(200, 'NON_JSON_BODY', `Medal returned a non-JSON body from ${path}`);\n }\n return data;\n}\n\nexport interface RangeArgs {\n serviceId: string;\n /**\n * The rest of ONE person's visit after `serviceId`, in order — absent (or\n * empty) for a one-service visit. With it the engine answers for the whole\n * visit: slots as long as every service together, on stylists who perform\n * all of them, and a schedule whose last start leaves room for the lot.\n */\n extraServiceIds?: readonly string[];\n resourceId?: string;\n fromTs: number;\n toTs: number;\n}\n\n/**\n * No `created_via` here: the site is unambiguously `'web'`, so `createBooking`\n * sets it rather than taking it. Provenance is a fact about the deployment,\n * not a per-call option.\n *\n * `booked_for_name` and `contact.name` are validated `.min(1)` by the engine\n * (`''` is a 400) while `contact.email` is not, so whatever maps wizard state\n * onto this body must coalesce blanks to `undefined` before calling in.\n */\nexport type CreateBookingBody = Omit<CreateBookingInput, 'created_via'>;\n\n/**\n * Deliberately no `start_ts`: the create response carries only the new id and\n * the one-time manage token per booking. `manage_token` is OPTIONAL: it is\n * minted exactly once, and an idempotent replay returns the same ids WITHOUT\n * it.\n */\nexport interface CreateBookingResult {\n bookings: Array<{ id: string; manage_token?: string }>;\n}\n\n/**\n * The marketing box, filed where a consent belongs. This endpoint is scoped\n * `write:contacts` where the booking endpoints are scoped to bookings: a key\n * minted for bookings alone will 403 here.\n */\nexport type RecordConsentBody = RecordConsentInput & { consent_type: 'marketing_email' };\n\n/**\n * What a completed move hands back. A reschedule is an insert plus a cancel,\n * so `manage_token` is the only way to reach the new booking; it is minted\n * once and absent from an idempotent replay.\n */\nexport interface RescheduleResult {\n booking_id?: string;\n manage_token?: string;\n}\n\nexport interface MedalSeam {\n /** The client, or `null` when the deployment has no usable key. */\n getMedal(): Medal | null;\n /** The client, or a loud `MedalConfigError`. */\n requireMedal(): Medal;\n /** `requireMedal()` with a shorter per-attempt deadline than the SDK's 30 s. */\n requireMedalWithTimeout(timeout: number): Medal;\n /** The key and origin, for the few raw routes the SDK lacks. */\n requireMedalConfig(): { key: string; base: string };\n listServices(): Promise<MedalService[]>;\n listResources(): Promise<MedalResource[]>;\n listAvailability(args: RangeArgs): Promise<MedalSlot[]>;\n listSchedule(args: RangeArgs): Promise<MedalScheduleDay[]>;\n /**\n * `opts.portalSession` — the logged-in parent's portal session, sent as\n * `X-Portal-Session` so Medal books on THAT contact. Never logged: a throw\n * is scrubbed of it before it leaves.\n */\n createBooking(\n body: CreateBookingBody,\n idempotencyKey: string,\n opts?: { portalSession?: string }\n ): Promise<CreateBookingResult>;\n recordConsent(body: RecordConsentBody): Promise<unknown>;\n getManage(token: string): Promise<MedalManageSummary>;\n cancelManage(token: string, reason?: string, idempotencyKey?: string): Promise<unknown>;\n rescheduleManage(\n token: string,\n startTs: number,\n idempotencyKey: string\n ): Promise<RescheduleResult>;\n}\n\nconst SITE_PROVENANCE: BookingClaimableCreatedVia = 'web';\n\nfunction read<T>(value: Lazy<T>): T {\n return typeof value === 'function' ? (value as () => T)() : value;\n}\n\n/** ISO on the wire — the engine parses either, but a millisecond epoch reads\n * as nothing in a request log. */\nfunction rangeParams(args: RangeArgs) {\n return {\n service_id: args.serviceId,\n from_ts: new Date(args.fromTs).toISOString(),\n to_ts: new Date(args.toTs).toISOString(),\n ...(args.resourceId ? { resource_id: args.resourceId } : {}),\n // Only when there is a visit to describe: a one-service read asks exactly\n // what it asked before, so nothing about it changes on the wire.\n ...(args.extraServiceIds?.length ? { extra_service_ids: [...args.extraServiceIds] } : {}),\n };\n}\n\n/**\n * One attempt at a catalogue or calendar read. The SDK's default is 30 s with\n * two retries — over a minute and a half of a parent looking at a skeleton\n * before the page can say «ring oss». A Medal that has not answered a read in\n * a few seconds is not going to answer this visitor.\n */\nconst READ_ATTEMPT_TIMEOUT_MS = 4_000;\n\n/**\n * The whole read, retries and any `Retry-After` wait included. The SDK honours\n * `Retry-After` uncapped, so a 429 could otherwise park the read for as long\n * as the server asked.\n */\nconst READ_DEADLINE_MS = 8_000;\n\n/**\n * A read bounded by `READ_DEADLINE_MS`. The deadline aborts the SDK call\n * itself — its attempt, its retries and any `Retry-After` wait — so a read the\n * visitor has stopped waiting for sends nothing more upstream. It ends with\n * the SDK's own `MedalTimeoutError`.\n */\nasync function withinDeadline<T>(\n read: (request: { signal: AbortSignal }) => Promise<T>\n): Promise<T> {\n const controller = new AbortController();\n const timer = setTimeout(\n () => controller.abort(new MedalTimeoutError(READ_DEADLINE_MS)),\n READ_DEADLINE_MS\n );\n try {\n return await read({ signal: controller.signal });\n } finally {\n clearTimeout(timer);\n }\n}\n\nexport function createMedalSeam(options: MedalSeamOptions): MedalSeam {\n let cached: { key: string; base: string; medal: Medal } | null = null;\n /** One client per timeout: the reads and the Vipps calls use different ones. */\n const quick = new Map<number, { key: string; base: string; medal: Medal }>();\n\n function readConfig(): { key: string; base: string } | null {\n const key = read(options.apiKey);\n if (!key || looksLikePlaceholderKey(key)) return null;\n return { key, base: read(options.baseUrl) || DEFAULT_MEDAL_ENDPOINT };\n }\n\n function getMedal(): Medal | null {\n const config = readConfig();\n if (!config) return null;\n if (cached && cached.key === config.key && cached.base === config.base) {\n return cached.medal;\n }\n cached = { ...config, medal: new Medal(config.key, { baseUrl: config.base }) };\n return cached.medal;\n }\n\n function requireMedal(): Medal {\n const medal = getMedal();\n if (!medal) throw new MedalConfigError('The Medal API key is not configured');\n return medal;\n }\n\n function requireMedalWithTimeout(timeout: number): Medal {\n const config = readConfig();\n if (!config) throw new MedalConfigError('The Medal API key is not configured');\n const known = quick.get(timeout);\n if (known && known.key === config.key && known.base === config.base) return known.medal;\n const medal = new Medal(config.key, { baseUrl: config.base, timeout });\n quick.set(timeout, { ...config, medal });\n return medal;\n }\n\n function requireMedalConfig(): { key: string; base: string } {\n const config = readConfig();\n if (!config) throw new MedalConfigError('The Medal API key is not configured');\n return config;\n }\n\n /** The client for a read a visitor is waiting on. */\n const reader = () => requireMedalWithTimeout(READ_ATTEMPT_TIMEOUT_MS);\n\n return {\n getMedal,\n requireMedal,\n requireMedalWithTimeout,\n requireMedalConfig,\n\n async listServices() {\n return unwrap(\n await withinDeadline((request) => reader().bookings.listServices(undefined, request)),\n '/api/v1/bookings/services'\n );\n },\n\n async listResources() {\n return unwrap(\n await withinDeadline((request) => reader().bookings.listResources(request)),\n '/api/v1/bookings/resources'\n );\n },\n\n async listAvailability(args) {\n return unwrap(\n await withinDeadline((request) =>\n reader().bookings.availability(rangeParams(args), request)\n ),\n '/api/v1/bookings/availability'\n );\n },\n\n /**\n * The open dates over a range — the half availability cannot answer. A\n * date ABSENT from this response is one the business keeps no hours on.\n */\n async listSchedule(args) {\n return unwrap(\n await withinDeadline((request) => reader().bookings.schedule(rangeParams(args), request)),\n '/api/v1/bookings/schedule'\n );\n },\n\n async createBooking(body, idempotencyKey, opts) {\n const portalSession = opts?.portalSession;\n let response: Awaited<ReturnType<Medal['bookings']['create']>>;\n try {\n response = await requireMedal().bookings.create(\n // `created_via` LAST, so it wins over anything a caller smuggled in.\n { ...body, created_via: SITE_PROVENANCE },\n portalSession ? { idempotencyKey, portalSession } : { idempotencyKey }\n );\n } catch (error) {\n // The session is never logged, and the create route logs what Medal threw.\n throw portalSession ? redactSession(error, portalSession) : error;\n }\n const result = unwrap(response, '/api/v1/bookings');\n return { bookings: result.bookings };\n },\n\n async recordConsent(body) {\n return unwrap(await requireMedal().gdpr.recordConsent(body), '/api/v1/gdpr/consent');\n },\n\n async getManage(token) {\n const path = '/api/v1/bookings/manage/<token>';\n const summary = unwrap(await requireMedal().bookings.manage.get(token), path);\n if (summary.status === null) {\n // Not a case the engine produces; if it ever does, a page that shows a\n // booking with no state is worse than the «cannot find» card this becomes.\n throw new MedalApiError(\n 200,\n 'MALFORMED_SUMMARY',\n `Medal returned a booking without a status from ${path}`\n );\n }\n return {\n ...summary,\n status: summary.status,\n service_name: summary.service_name ?? 'Unknown service',\n resource_name: summary.resource_name ?? 'Unknown resource',\n };\n },\n\n /**\n * `idempotencyKey` is optional only in the type — the caller derives one\n * from the token so a retry after a dropped response replays rather than\n * answering `CONFLICT` about a booking that is already cancelled.\n */\n async cancelManage(token, reason, idempotencyKey) {\n return unwrap(\n await requireMedal().bookings.manage.cancel(\n token,\n reason === undefined ? undefined : { reason },\n idempotencyKey ? { idempotencyKey } : undefined\n ),\n '/api/v1/bookings/manage/<token>/cancel'\n );\n },\n\n async rescheduleManage(token, startTs, idempotencyKey) {\n return unwrap(\n await requireMedal().bookings.manage.reschedule(\n token,\n { new_start_ts: new Date(startTs).toISOString() },\n { idempotencyKey }\n ),\n '/api/v1/bookings/manage/<token>/reschedule'\n );\n },\n };\n}\n"],"mappings":"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"}
|
|
@@ -3,7 +3,7 @@ import { visitKeyOfIds } from "../../core/visit.mjs";
|
|
|
3
3
|
import { MedalConfigError } from "../medal.mjs";
|
|
4
4
|
import { PortalSessionExpiredError } from "../portal/medal-portal.mjs";
|
|
5
5
|
import { readBoundedText } from "../request.mjs";
|
|
6
|
-
import { bookingErrorResponse, isSlotTaken, parseRange } from "./shared.mjs";
|
|
6
|
+
import { bookingErrorResponse, forgetBookedSlots, isSlotTaken, parseRange } from "./shared.mjs";
|
|
7
7
|
import { MedalApiError } from "@medalsocial/sdk";
|
|
8
8
|
import { NextResponse, after } from "next/server";
|
|
9
9
|
//#region src/next/routes/create.ts
|
|
@@ -355,8 +355,7 @@ async function createRoute(rt, request) {
|
|
|
355
355
|
const booked = Array.isArray(result.bookings) ? result.bookings : [];
|
|
356
356
|
const ids = booked.map((booking) => booking?.id);
|
|
357
357
|
const whole = booked.length === mapped.body.items.length && ids.every((id) => typeof id === "string" && id.length > 0) && new Set(ids).size === ids.length;
|
|
358
|
-
rt
|
|
359
|
-
await rt.seed.expireBookingSeeds(bookedServiceIds(mapped.body));
|
|
358
|
+
await forgetBookedSlots(rt, bookedServiceIds(mapped.body));
|
|
360
359
|
if (!whole) {
|
|
361
360
|
rt.logger.error({
|
|
362
361
|
expected: mapped.body.items.length,
|
|
@@ -371,9 +370,7 @@ async function createRoute(rt, request) {
|
|
|
371
370
|
})) }, { status: 201 });
|
|
372
371
|
} catch (error) {
|
|
373
372
|
if (isSlotTaken(error)) {
|
|
374
|
-
|
|
375
|
-
rt.catalogue.expireSlots(serviceIds);
|
|
376
|
-
await rt.seed.expireBookingSeeds(serviceIds);
|
|
373
|
+
await forgetBookedSlots(rt, bookedServiceIds(mapped.body));
|
|
377
374
|
const freshSlots = await liveSlotsFor(rt, mapped.body.items, slotWindow(rt, parsed.window));
|
|
378
375
|
return NextResponse.json({
|
|
379
376
|
error: "slotTaken",
|
|
@@ -1 +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(\n nonce: unknown,\n body: CreateBookingBody,\n contactId: string | null\n): Promise<string> {\n if (typeof nonce !== 'string' || nonce.trim().length === 0) return crypto.randomUUID();\n // Under `account.required`, the CONTACT the session resolved to — never the\n // session itself. The contact is whose booking this is: two parents never\n // share a key (the same submission from two accounts is two bookings), and\n // the same parent keeps theirs across a fresh login. A key that hashed the\n // token would change when the session is renewed, so a resend after a lost\n // answer would be a new booking under a new key — the double booking, or a\n // `slotTaken` for an hour that is in fact theirs. Medal binds a replay to the\n // session as well (the header's hash is part of the request identity), so\n // a resend under a renewed session meets the first attempt as a 409\n // IDEMPOTENCY_KEY_CONFLICT — «check your e-mail» — rather than as a second\n // booking, and nobody else's session is ever handed this one's answer.\n // Without `account.required` the key is unchanged: the phone in the body\n // already says whose booking it is.\n const material = `${nonce}${JSON.stringify(body)}${contactId === null ? '' : `\\u0000contact:${contactId}`}`;\n const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(material));\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\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 nonce = (parsed as CreateRequest).submissionNonce;\n // Two models, never mixed. Without `account.required` the booking is the\n // logged-out one it always was — Medal finds the contact by the phone, and a\n // session only ever vouches for person ids under the phone rule. With it,\n // the parent's session goes to Medal and decides the contact, whatever phone\n // was typed: see `sessionBooking`.\n const booking = rt.config.account.required\n ? await sessionBooking(rt, mapped.body, requestedIds)\n : await withPersons(rt, mapped.body, requestedIds).then((body) =>\n body === null ? null : { body, session: null, contactId: null }\n );\n if (booking === null) {\n return NextResponse.json({ error: 'upstreamError', retryable: true }, { status: 503 });\n }\n if (booking === 'accountRequired') return accountRequired();\n mapped.body = booking.body;\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(nonce, keyBody, booking.contactId);\n let result: Awaited<ReturnType<BookingRuntime['medal']['createBooking']>>;\n try {\n result =\n booking.session === null\n ? await rt.medal.createBooking(mapped.body, idempotencyKey)\n : await rt.medal.createBooking(mapped.body, idempotencyKey, {\n portalSession: booking.session,\n });\n } catch (error) {\n // The session died between the profile read and the create: the login\n // again. Either Medal refused inside the create, which releases the\n // key — nothing was booked — or it refused to REPLAY an earlier attempt\n // of this submission that did book. The parent's resend after the login\n // is the same contact, so the same key, and meets whichever it was: a\n // booking, or the earlier attempt's 409 «check your e-mail».\n if (booking.session !== null && isDeadPortalSession(error)) return accountRequired();\n throw error;\n }\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/** Under `account.required`, the answer to a submission nobody is logged in for. */\nfunction accountRequired(): NextResponse {\n return bookingErrorResponse('accountRequired', 401, 'Log in to book');\n}\n\n/** Medal's 401 for a portal session it no longer honours (or never issued). */\nfunction isDeadPortalSession(error: unknown): boolean {\n return (\n error instanceof MedalApiError &&\n error.status === 401 &&\n (error.code === 'PORTAL_SESSION_REQUIRED' || error.code === 'PORTAL_SESSION_INVALID')\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), for a site\n * without `account.required` (with it, see `sessionBooking`).\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/**\n * Under `account.required`: the logged-in parent's booking, on THEIR contact.\n *\n * The session goes to Medal (`X-Portal-Session`), and Medal books on the\n * session's contact whatever phone was typed — so the phone rule has nothing\n * to say here, and a person id is checked against the session's own family\n * alone. The profile is read for every submission, ids or none: it is what\n * proves the session is still alive before anything is booked, and its\n * `contactId` is the half of the idempotency key that says WHOSE attempt this\n * is (see `idempotencyKeyFor`).\n *\n * `'accountRequired'` when there is no session or Medal no longer honours it —\n * an answer, and the same one on a retry. `null` when the session or the\n * profile could not be read at all (an outage, a throttle): a 503 before\n * anything is booked, as for the phone rule's read.\n */\nasync function sessionBooking(\n rt: BookingRuntime,\n body: CreateBookingBody,\n ids: ReadonlyArray<string | null>\n): Promise<\n { body: CreateBookingBody; session: string; contactId: string } | 'accountRequired' | null\n> {\n try {\n const session = await rt.session.readPortalSession();\n if (session === null) return 'accountRequired';\n const profile = await rt.portal.getMe(session);\n return {\n body: withIds(body, ids, familyIds(profile.family)),\n session,\n contactId: profile.contactId,\n };\n } catch (error) {\n if (error instanceof PortalSessionExpiredError) return 'accountRequired';\n // The seam scrubs the session.\n rt.logger.warn(\n { err: error },\n 'Could not read the booking’s portal session; asking for a retry'\n );\n return null;\n }\n}\n\n/** The person ids a profile's family has. */\nfunction familyIds(family: ReadonlyArray<{ personId: string | null }>): Set<string> {\n return new Set(family.flatMap((member) => (member.personId === null ? [] : [member.personId])));\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 familyIds(profile.family);\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":"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"}
|
|
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 {\n bookingErrorResponse,\n forgetBookedSlots,\n isSlotTaken,\n MAX_EXTRA_SERVICES,\n parseRange,\n} 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(\n nonce: unknown,\n body: CreateBookingBody,\n contactId: string | null\n): Promise<string> {\n if (typeof nonce !== 'string' || nonce.trim().length === 0) return crypto.randomUUID();\n // Under `account.required`, the CONTACT the session resolved to — never the\n // session itself. The contact is whose booking this is: two parents never\n // share a key (the same submission from two accounts is two bookings), and\n // the same parent keeps theirs across a fresh login. A key that hashed the\n // token would change when the session is renewed, so a resend after a lost\n // answer would be a new booking under a new key — the double booking, or a\n // `slotTaken` for an hour that is in fact theirs. Medal binds a replay to the\n // session as well (the header's hash is part of the request identity), so\n // a resend under a renewed session meets the first attempt as a 409\n // IDEMPOTENCY_KEY_CONFLICT — «check your e-mail» — rather than as a second\n // booking, and nobody else's session is ever handed this one's answer.\n // Without `account.required` the key is unchanged: the phone in the body\n // already says whose booking it is.\n const material = `${nonce}${JSON.stringify(body)}${contactId === null ? '' : `\\u0000contact:${contactId}`}`;\n const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(material));\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\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 nonce = (parsed as CreateRequest).submissionNonce;\n // Two models, never mixed. Without `account.required` the booking is the\n // logged-out one it always was — Medal finds the contact by the phone, and a\n // session only ever vouches for person ids under the phone rule. With it,\n // the parent's session goes to Medal and decides the contact, whatever phone\n // was typed: see `sessionBooking`.\n const booking = rt.config.account.required\n ? await sessionBooking(rt, mapped.body, requestedIds)\n : await withPersons(rt, mapped.body, requestedIds).then((body) =>\n body === null ? null : { body, session: null, contactId: null }\n );\n if (booking === null) {\n return NextResponse.json({ error: 'upstreamError', retryable: true }, { status: 503 });\n }\n if (booking === 'accountRequired') return accountRequired();\n mapped.body = booking.body;\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(nonce, keyBody, booking.contactId);\n let result: Awaited<ReturnType<BookingRuntime['medal']['createBooking']>>;\n try {\n result =\n booking.session === null\n ? await rt.medal.createBooking(mapped.body, idempotencyKey)\n : await rt.medal.createBooking(mapped.body, idempotencyKey, {\n portalSession: booking.session,\n });\n } catch (error) {\n // The session died between the profile read and the create: the login\n // again. Either Medal refused inside the create, which releases the\n // key — nothing was booked — or it refused to REPLAY an earlier attempt\n // of this submission that did book. The parent's resend after the login\n // is the same contact, so the same key, and meets whichever it was: a\n // booking, or the earlier attempt's 409 «check your e-mail».\n if (booking.session !== null && isDeadPortalSession(error)) return accountRequired();\n throw error;\n }\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 await forgetBookedSlots(rt, 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 await forgetBookedSlots(rt, bookedServiceIds(mapped.body));\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/** Under `account.required`, the answer to a submission nobody is logged in for. */\nfunction accountRequired(): NextResponse {\n return bookingErrorResponse('accountRequired', 401, 'Log in to book');\n}\n\n/** Medal's 401 for a portal session it no longer honours (or never issued). */\nfunction isDeadPortalSession(error: unknown): boolean {\n return (\n error instanceof MedalApiError &&\n error.status === 401 &&\n (error.code === 'PORTAL_SESSION_REQUIRED' || error.code === 'PORTAL_SESSION_INVALID')\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), for a site\n * without `account.required` (with it, see `sessionBooking`).\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/**\n * Under `account.required`: the logged-in parent's booking, on THEIR contact.\n *\n * The session goes to Medal (`X-Portal-Session`), and Medal books on the\n * session's contact whatever phone was typed — so the phone rule has nothing\n * to say here, and a person id is checked against the session's own family\n * alone. The profile is read for every submission, ids or none: it is what\n * proves the session is still alive before anything is booked, and its\n * `contactId` is the half of the idempotency key that says WHOSE attempt this\n * is (see `idempotencyKeyFor`).\n *\n * `'accountRequired'` when there is no session or Medal no longer honours it —\n * an answer, and the same one on a retry. `null` when the session or the\n * profile could not be read at all (an outage, a throttle): a 503 before\n * anything is booked, as for the phone rule's read.\n */\nasync function sessionBooking(\n rt: BookingRuntime,\n body: CreateBookingBody,\n ids: ReadonlyArray<string | null>\n): Promise<\n { body: CreateBookingBody; session: string; contactId: string } | 'accountRequired' | null\n> {\n try {\n const session = await rt.session.readPortalSession();\n if (session === null) return 'accountRequired';\n const profile = await rt.portal.getMe(session);\n return {\n body: withIds(body, ids, familyIds(profile.family)),\n session,\n contactId: profile.contactId,\n };\n } catch (error) {\n if (error instanceof PortalSessionExpiredError) return 'accountRequired';\n // The seam scrubs the session.\n rt.logger.warn(\n { err: error },\n 'Could not read the booking’s portal session; asking for a retry'\n );\n return null;\n }\n}\n\n/** The person ids a profile's family has. */\nfunction familyIds(family: ReadonlyArray<{ personId: string | null }>): Set<string> {\n return new Set(family.flatMap((member) => (member.personId === null ? [] : [member.personId])));\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 familyIds(profile.family);\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":"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"}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { toManageDto } from "../manage-dto.mjs";
|
|
2
2
|
import { MedalConfigError } from "../medal.mjs";
|
|
3
3
|
import { readBoundedText } from "../request.mjs";
|
|
4
|
-
import { bookingErrorResponse, digestOf, isSlotTaken } from "./shared.mjs";
|
|
4
|
+
import { bookingErrorResponse, digestOf, forgetBookedSlots, isSlotTaken } from "./shared.mjs";
|
|
5
5
|
import { MedalApiError } from "@medalsocial/sdk";
|
|
6
6
|
import { NextResponse } from "next/server";
|
|
7
7
|
//#region src/next/routes/manage.ts
|
|
@@ -75,11 +75,9 @@ function manageErrorResponse(rt, error, action) {
|
|
|
75
75
|
}
|
|
76
76
|
if (error instanceof MedalApiError) {
|
|
77
77
|
if (error.status === 404) return bookingErrorResponse("notFound", 404);
|
|
78
|
-
if (error.code === "CONFLICT")
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
return bookingErrorResponse("conflict", 409);
|
|
82
|
-
}
|
|
78
|
+
if (error.code === "CONFLICT" && error.message.includes("WINDOW_PASSED")) return bookingErrorResponse("windowPassed", 409);
|
|
79
|
+
if (isSlotTaken(error)) return bookingErrorResponse("slotTaken", 409);
|
|
80
|
+
if (error.code === "CONFLICT") return bookingErrorResponse("conflict", 409);
|
|
83
81
|
if (error.code === "VALIDATION_ERROR" || error.code === "INVALID_INPUT") return bookingErrorResponse("invalidInput", 400);
|
|
84
82
|
}
|
|
85
83
|
rt.logger.error({
|
|
@@ -108,8 +106,7 @@ async function serviceIdOf(rt, token) {
|
|
|
108
106
|
async function expireAfterWrite(rt, serviceId) {
|
|
109
107
|
const id = await serviceId;
|
|
110
108
|
if (id === null) return;
|
|
111
|
-
rt
|
|
112
|
-
await rt.seed.expireBookingSeeds([id]);
|
|
109
|
+
await forgetBookedSlots(rt, [id]);
|
|
113
110
|
}
|
|
114
111
|
/**
|
|
115
112
|
* The manage body's ceiling: an action, a reason chip and an instant fit in
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"manage.mjs","names":[],"sources":["../../../src/next/routes/manage.ts"],"sourcesContent":["import { MedalApiError } from '@medalsocial/sdk';\nimport { NextResponse } from 'next/server';\nimport { toManageDto } from '../manage-dto';\nimport { MedalConfigError } from '../medal';\nimport { readBoundedText } from '../request';\nimport type { BookingRuntime } from '../runtime';\nimport { bookingErrorResponse, digestOf, isSlotTaken } from './shared';\n\n/**\n * The two things the manage page can DO: call off an appointment, or move it.\n *\n * One route rather than `…/cancel` and `…/reschedule`, because the token is the\n * expensive part of this URL and there is no reason to have it in two places.\n * The action is a field on the body.\n *\n * SECURITY — the path segment is a live bearer credential for one booking:\n * whoever holds it can cancel or move that appointment. Three rules follow, and\n * every one of them is a rule about *this file*:\n *\n * - The token is read from the path and from nowhere else. A query string would\n * be the same credential in the one place that is logged by every proxy\n * between here and the visitor, and would let a link with `?token=` be\n * crafted for a page that never offered one.\n * - Nothing here logs it, echoes it, or puts it in a response body. The upstream\n * client already redacts it out of its own error messages (`safePath`), and\n * the responses below carry a code and never the input that produced it.\n * - An unknown token gets the same 404 shape as a revoked one, which is what the\n * engine already answers with — the endpoint is not an oracle for whether a\n * booking exists.\n */\n\n/**\n * What the browser may send. `startTs` is epoch milliseconds, the same unit the\n * availability route emits and the same one `pickSlot` stores, so nothing has to\n * reformat a time between choosing it and committing to it.\n */\ninterface ManageRequest {\n action?: unknown;\n reason?: unknown;\n startTs?: unknown;\n}\n\nfunction invalid(message: string): NextResponse {\n return bookingErrorResponse('invalidInput', 400, message);\n}\n\n/**\n * The `Idempotency-Key` for one move: the booking's token and the instant it is\n * being moved to, hashed together.\n *\n * A reschedule is an insert plus a cancel, so a second identical request would\n * otherwise find the booking already cancelled and answer 409 — a double tap on\n * «Bekreft endring» reporting failure for a move that succeeded. Deriving the\n * key from the two things that identify the move makes the retry a replay.\n *\n * The digest, never the token. Medal stores the request identity against the\n * key, so a plaintext token here would write a live credential into a table that\n * outlives the appointment — the same reason the engine hashes the token out of\n * its own idempotency identity and API log. Two visitors cannot collide either,\n * since the token is unique per booking; and a visitor who picks a different\n * hour derives a different key, which is correctly a second move rather than a\n * replay of the first.\n */\nasync function moveKey(token: string, startTs: number): Promise<string> {\n return digestOf(`reschedule:${token}:${startTs}`);\n}\n\n/**\n * The `Idempotency-Key` for one cancellation — the booking's token, hashed.\n *\n * The same hazard the move key exists for, and it had been left off this half.\n * Cancelling twice is a `CONFLICT` from the engine, because the second call\n * finds a booking that is already cancelled — so a dropped RESPONSE (the write\n * landed, the answer did not) turns the visitor's perfectly reasonable retry\n * into «det gikk ikke» for an appointment that IS cancelled. A disabled button\n * only stops the concurrent double tap; it does nothing about the retry after\n * an ambiguous network result.\n *\n * No second component, unlike the move: a booking has exactly one cancellation,\n * so the token alone identifies it. Two attempts with different reasons are\n * still one cancellation, and the first reason is the one that sticks — which\n * is the right answer, since it is the one the visitor gave before anything\n * went wrong.\n *\n * The digest and never the token, for the reason `moveKey` gives.\n */\nasync function cancelKey(token: string): Promise<string> {\n return digestOf(`cancel:${token}`);\n}\n\n/**\n * Every way a manage action can fail, in codes the page has a Norwegian\n * sentence for.\n *\n * `windowPassed` is the one that matters. The engine enforces both windows on\n * the write and raises `CONFLICT` with `CANCEL_WINDOW_PASSED` /\n * `RESCHEDULE_WINDOW_PASSED` in the message — the code alone cannot tell it from\n * a taken slot, exactly as the create route has to read `SLOT_TAKEN` out of the\n * message. It is reachable without anyone doing anything wrong: `can_cancel` was\n * true when the page rendered, and the visitor sat on it until it was not.\n *\n * The upstream's own message never travels. Medal writes for the integrator\n * holding the API key, and its 404 for this route would be the same sentence\n * whether the token was revoked, GDPR-erased, or belongs to another salon.\n */\nfunction manageErrorResponse(rt: BookingRuntime, error: unknown, action: string): NextResponse {\n if (error instanceof MedalConfigError) {\n rt.logger.error({ err: error, action }, 'Booking API is not configured');\n return bookingErrorResponse('unconfigured', 503);\n }\n\n if (error instanceof MedalApiError) {\n if (error.status === 404) {\n return bookingErrorResponse('notFound', 404);\n }\n if (error.code === 'CONFLICT') {\n if (error.message.includes('WINDOW_PASSED')) {\n return bookingErrorResponse('windowPassed', 409);\n }\n if (isSlotTaken(error)) {\n return bookingErrorResponse('slotTaken', 409);\n }\n return bookingErrorResponse('conflict', 409);\n }\n if (error.code === 'VALIDATION_ERROR' || error.code === 'INVALID_INPUT') {\n return bookingErrorResponse('invalidInput', 400);\n }\n }\n\n // `action` and not the token: which of the two failed is the whole reason to\n // log a line here, and the token is the one thing that must never reach a log\n // stream that outlives the booking.\n rt.logger.error({ err: error, action }, 'Booking manage action failed');\n return bookingErrorResponse('upstreamError', 502);\n}\n\n/**\n * The booking's service id, for expiring its cached slots after a write.\n *\n * Asked for ALONGSIDE the write rather than after it, so it adds no round trip\n * (after a move the old token resolves to the cancelled row, which still names\n * the service — but there is no reason to wait for that). Best effort: `null`\n * leaves the cache to its ~60 s bound, never fails the action.\n */\n// A visit's extra services are not expired here: the summary names only its\n// first one (the visit's own entry carries that tag too, so it does go); their\n// one-service entries turn over with their own ~60 s bound.\nasync function serviceIdOf(rt: BookingRuntime, token: string): Promise<string | null> {\n try {\n return (await rt.medal.getManage(token)).service_id ?? null;\n } catch {\n return null;\n }\n}\n\n/** Expire the booking's slots — cached and in this colo's seeds — once the\n * write has landed. */\nasync function expireAfterWrite(\n rt: BookingRuntime,\n serviceId: Promise<string | null>\n): Promise<void> {\n const id = await serviceId;\n if (id === null) return;\n rt.catalogue.expireSlots([id]);\n await rt.seed.expireBookingSeeds([id]);\n}\n\n/**\n * The manage body's ceiling: an action, a reason chip and an instant fit in\n * a few hundred bytes. Read in bytes before anything parses it, as the portal\n * routes cap theirs.\n */\nconst MANAGE_MAX_BODY_BYTES = 4 * 1024;\n\nexport async function manageRoute(\n rt: BookingRuntime,\n request: Request,\n token: string\n): Promise<Response> {\n if (!token) {\n // Unreachable through the router, which only matches a non-empty segment —\n // but this is the one function in the app where an empty credential must\n // never be forwarded as if it were one.\n return invalid('token is required');\n }\n\n let parsed: unknown;\n try {\n const text = await readBoundedText(request, MANAGE_MAX_BODY_BYTES);\n if (text === null) return invalid('body is too large');\n parsed = JSON.parse(text);\n } catch {\n return invalid('body must be valid JSON');\n }\n if (typeof parsed !== 'object' || parsed === null) {\n return invalid('body must be a JSON object');\n }\n const body = parsed as ManageRequest;\n\n if (body.action === 'cancel') {\n try {\n // Trimmed to nothing means the visitor picked no chip, and the reason is\n // optional to the engine — `''` is a value it would store as a reason\n // that says nothing. Absent is the honest form of «they did not say».\n const reason = typeof body.reason === 'string' ? body.reason.trim() : '';\n const serviceId = serviceIdOf(rt, token);\n await rt.medal.cancelManage(\n token,\n reason.length > 0 ? reason : undefined,\n await cancelKey(token)\n );\n await expireAfterWrite(rt, serviceId);\n return NextResponse.json({ ok: true });\n } catch (error) {\n return manageErrorResponse(rt, error, 'cancel');\n }\n }\n\n if (body.action === 'reschedule') {\n const startTs = body.startTs;\n // `Number.isFinite` and not `Number(...)`, for the reason the create route\n // gives: `Number(null)` is 0, and a cleared slot serialises as null — a\n // lenient parse would ask the engine to move the appointment to 1970.\n if (!Number.isFinite(startTs)) {\n return invalid('startTs must be a timestamp');\n }\n const serviceId = serviceIdOf(rt, token);\n try {\n const result = await rt.medal.rescheduleManage(\n token,\n startTs as number,\n await moveKey(token, startTs as number)\n );\n await expireAfterWrite(rt, serviceId);\n // The NEW booking's token, relayed so the page can send the visitor to a\n // manage link that still works: the token they arrived with now belongs\n // to the cancelled row and from here on resolves to «avbestilt».\n //\n // Absent on an idempotent replay — the engine mints it exactly once — so\n // the field is optional and the page has a path for not getting one.\n return NextResponse.json({\n ok: true,\n ...(typeof result?.manage_token === 'string' ? { manageToken: result.manage_token } : {}),\n });\n } catch (error) {\n // The move lost its slot: the cache offered one that was already gone.\n if (isSlotTaken(error)) await expireAfterWrite(rt, serviceId);\n return manageErrorResponse(rt, error, 'reschedule');\n }\n }\n\n return invalid('action must be \"cancel\" or \"reschedule\"');\n}\n\n/**\n * `GET <api>/manage/<token>` — the booking the token names, as the manage page\n * renders it, for a client that loads it itself. The same 404 for an unknown\n * token as for a revoked one; the token is never echoed or logged, and the\n * answer is never stored by anyone (`no-store`, `no-referrer`).\n */\nexport async function manageSummaryRoute(\n rt: BookingRuntime,\n _request: Request,\n token: string\n): Promise<Response> {\n const headers = { 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer' };\n if (!token) return invalid('token is required');\n try {\n const booking = toManageDto(await rt.medal.getManage(token));\n if (booking === null) return NextResponse.json({ error: 'notFound' }, { status: 404, headers });\n return NextResponse.json({ booking }, { headers });\n } catch (error) {\n if (error instanceof MedalApiError && error.status === 404) {\n return NextResponse.json({ error: 'notFound' }, { status: 404, headers });\n }\n const response = manageErrorResponse(rt, error, 'read');\n for (const [name, value] of Object.entries(headers)) response.headers.set(name, value);\n return response;\n }\n}\n"],"mappings":";;;;;;;AA0CA,SAAS,QAAQ,SAA+B;CAC9C,OAAO,qBAAqB,gBAAgB,KAAK,OAAO;AAC1D;;;;;;;;;;;;;;;;;;AAmBA,eAAe,QAAQ,OAAe,SAAkC;CACtE,OAAO,SAAS,cAAc,MAAM,GAAG,SAAS;AAClD;;;;;;;;;;;;;;;;;;;;AAqBA,eAAe,UAAU,OAAgC;CACvD,OAAO,SAAS,UAAU,OAAO;AACnC;;;;;;;;;;;;;;;;AAiBA,SAAS,oBAAoB,IAAoB,OAAgB,QAA8B;CAC7F,IAAI,iBAAiB,kBAAkB;EACrC,GAAG,OAAO,MAAM;GAAE,KAAK;GAAO;EAAO,GAAG,+BAA+B;EACvE,OAAO,qBAAqB,gBAAgB,GAAG;CACjD;CAEA,IAAI,iBAAiB,eAAe;EAClC,IAAI,MAAM,WAAW,KACnB,OAAO,qBAAqB,YAAY,GAAG;EAE7C,IAAI,MAAM,SAAS,YAAY;GAC7B,IAAI,MAAM,QAAQ,SAAS,eAAe,GACxC,OAAO,qBAAqB,gBAAgB,GAAG;GAEjD,IAAI,YAAY,KAAK,GACnB,OAAO,qBAAqB,aAAa,GAAG;GAE9C,OAAO,qBAAqB,YAAY,GAAG;EAC7C;EACA,IAAI,MAAM,SAAS,sBAAsB,MAAM,SAAS,iBACtD,OAAO,qBAAqB,gBAAgB,GAAG;CAEnD;CAKA,GAAG,OAAO,MAAM;EAAE,KAAK;EAAO;CAAO,GAAG,8BAA8B;CACtE,OAAO,qBAAqB,iBAAiB,GAAG;AAClD;;;;;;;;;AAaA,eAAe,YAAY,IAAoB,OAAuC;CACpF,IAAI;EACF,QAAQ,MAAM,GAAG,MAAM,UAAU,KAAK,EAAA,CAAG,cAAc;CACzD,QAAQ;EACN,OAAO;CACT;AACF;;;AAIA,eAAe,iBACb,IACA,WACe;CACf,MAAM,KAAK,MAAM;CACjB,IAAI,OAAO,MAAM;CACjB,GAAG,UAAU,YAAY,CAAC,EAAE,CAAC;CAC7B,MAAM,GAAG,KAAK,mBAAmB,CAAC,EAAE,CAAC;AACvC;;;;;;AAOA,MAAM,wBAAwB;AAE9B,eAAsB,YACpB,IACA,SACA,OACmB;CACnB,IAAI,CAAC,OAIH,OAAO,QAAQ,mBAAmB;CAGpC,IAAI;CACJ,IAAI;EACF,MAAM,OAAO,MAAM,gBAAgB,SAAS,qBAAqB;EACjE,IAAI,SAAS,MAAM,OAAO,QAAQ,mBAAmB;EACrD,SAAS,KAAK,MAAM,IAAI;CAC1B,QAAQ;EACN,OAAO,QAAQ,yBAAyB;CAC1C;CACA,IAAI,OAAO,WAAW,YAAY,WAAW,MAC3C,OAAO,QAAQ,4BAA4B;CAE7C,MAAM,OAAO;CAEb,IAAI,KAAK,WAAW,UAClB,IAAI;EAIF,MAAM,SAAS,OAAO,KAAK,WAAW,WAAW,KAAK,OAAO,KAAK,IAAI;EACtE,MAAM,YAAY,YAAY,IAAI,KAAK;EACvC,MAAM,GAAG,MAAM,aACb,OACA,OAAO,SAAS,IAAI,SAAS,KAAA,GAC7B,MAAM,UAAU,KAAK,CACvB;EACA,MAAM,iBAAiB,IAAI,SAAS;EACpC,OAAO,aAAa,KAAK,EAAE,IAAI,KAAK,CAAC;CACvC,SAAS,OAAO;EACd,OAAO,oBAAoB,IAAI,OAAO,QAAQ;CAChD;CAGF,IAAI,KAAK,WAAW,cAAc;EAChC,MAAM,UAAU,KAAK;EAIrB,IAAI,CAAC,OAAO,SAAS,OAAO,GAC1B,OAAO,QAAQ,6BAA6B;EAE9C,MAAM,YAAY,YAAY,IAAI,KAAK;EACvC,IAAI;GACF,MAAM,SAAS,MAAM,GAAG,MAAM,iBAC5B,OACA,SACA,MAAM,QAAQ,OAAO,OAAiB,CACxC;GACA,MAAM,iBAAiB,IAAI,SAAS;GAOpC,OAAO,aAAa,KAAK;IACvB,IAAI;IACJ,GAAI,OAAO,QAAQ,iBAAiB,WAAW,EAAE,aAAa,OAAO,aAAa,IAAI,CAAC;GACzF,CAAC;EACH,SAAS,OAAO;GAEd,IAAI,YAAY,KAAK,GAAG,MAAM,iBAAiB,IAAI,SAAS;GAC5D,OAAO,oBAAoB,IAAI,OAAO,YAAY;EACpD;CACF;CAEA,OAAO,QAAQ,6CAAyC;AAC1D;;;;;;;AAQA,eAAsB,mBACpB,IACA,UACA,OACmB;CACnB,MAAM,UAAU;EAAE,iBAAiB;EAAY,mBAAmB;CAAc;CAChF,IAAI,CAAC,OAAO,OAAO,QAAQ,mBAAmB;CAC9C,IAAI;EACF,MAAM,UAAU,YAAY,MAAM,GAAG,MAAM,UAAU,KAAK,CAAC;EAC3D,IAAI,YAAY,MAAM,OAAO,aAAa,KAAK,EAAE,OAAO,WAAW,GAAG;GAAE,QAAQ;GAAK;EAAQ,CAAC;EAC9F,OAAO,aAAa,KAAK,EAAE,QAAQ,GAAG,EAAE,QAAQ,CAAC;CACnD,SAAS,OAAO;EACd,IAAI,iBAAiB,iBAAiB,MAAM,WAAW,KACrD,OAAO,aAAa,KAAK,EAAE,OAAO,WAAW,GAAG;GAAE,QAAQ;GAAK;EAAQ,CAAC;EAE1E,MAAM,WAAW,oBAAoB,IAAI,OAAO,MAAM;EACtD,KAAK,MAAM,CAAC,MAAM,UAAU,OAAO,QAAQ,OAAO,GAAG,SAAS,QAAQ,IAAI,MAAM,KAAK;EACrF,OAAO;CACT;AACF"}
|
|
1
|
+
{"version":3,"file":"manage.mjs","names":[],"sources":["../../../src/next/routes/manage.ts"],"sourcesContent":["import { MedalApiError } from '@medalsocial/sdk';\nimport { NextResponse } from 'next/server';\nimport { toManageDto } from '../manage-dto';\nimport { MedalConfigError } from '../medal';\nimport { readBoundedText } from '../request';\nimport type { BookingRuntime } from '../runtime';\nimport { bookingErrorResponse, digestOf, forgetBookedSlots, isSlotTaken } from './shared';\n\n/**\n * The two things the manage page can DO: call off an appointment, or move it.\n *\n * One route rather than `…/cancel` and `…/reschedule`, because the token is the\n * expensive part of this URL and there is no reason to have it in two places.\n * The action is a field on the body.\n *\n * SECURITY — the path segment is a live bearer credential for one booking:\n * whoever holds it can cancel or move that appointment. Three rules follow, and\n * every one of them is a rule about *this file*:\n *\n * - The token is read from the path and from nowhere else. A query string would\n * be the same credential in the one place that is logged by every proxy\n * between here and the visitor, and would let a link with `?token=` be\n * crafted for a page that never offered one.\n * - Nothing here logs it, echoes it, or puts it in a response body. The upstream\n * client already redacts it out of its own error messages (`safePath`), and\n * the responses below carry a code and never the input that produced it.\n * - An unknown token gets the same 404 shape as a revoked one, which is what the\n * engine already answers with — the endpoint is not an oracle for whether a\n * booking exists.\n */\n\n/**\n * What the browser may send. `startTs` is epoch milliseconds, the same unit the\n * availability route emits and the same one `pickSlot` stores, so nothing has to\n * reformat a time between choosing it and committing to it.\n */\ninterface ManageRequest {\n action?: unknown;\n reason?: unknown;\n startTs?: unknown;\n}\n\nfunction invalid(message: string): NextResponse {\n return bookingErrorResponse('invalidInput', 400, message);\n}\n\n/**\n * The `Idempotency-Key` for one move: the booking's token and the instant it is\n * being moved to, hashed together.\n *\n * A reschedule is an insert plus a cancel, so a second identical request would\n * otherwise find the booking already cancelled and answer 409 — a double tap on\n * «Bekreft endring» reporting failure for a move that succeeded. Deriving the\n * key from the two things that identify the move makes the retry a replay.\n *\n * The digest, never the token. Medal stores the request identity against the\n * key, so a plaintext token here would write a live credential into a table that\n * outlives the appointment — the same reason the engine hashes the token out of\n * its own idempotency identity and API log. Two visitors cannot collide either,\n * since the token is unique per booking; and a visitor who picks a different\n * hour derives a different key, which is correctly a second move rather than a\n * replay of the first.\n */\nasync function moveKey(token: string, startTs: number): Promise<string> {\n return digestOf(`reschedule:${token}:${startTs}`);\n}\n\n/**\n * The `Idempotency-Key` for one cancellation — the booking's token, hashed.\n *\n * The same hazard the move key exists for, and it had been left off this half.\n * Cancelling twice is a `CONFLICT` from the engine, because the second call\n * finds a booking that is already cancelled — so a dropped RESPONSE (the write\n * landed, the answer did not) turns the visitor's perfectly reasonable retry\n * into «det gikk ikke» for an appointment that IS cancelled. A disabled button\n * only stops the concurrent double tap; it does nothing about the retry after\n * an ambiguous network result.\n *\n * No second component, unlike the move: a booking has exactly one cancellation,\n * so the token alone identifies it. Two attempts with different reasons are\n * still one cancellation, and the first reason is the one that sticks — which\n * is the right answer, since it is the one the visitor gave before anything\n * went wrong.\n *\n * The digest and never the token, for the reason `moveKey` gives.\n */\nasync function cancelKey(token: string): Promise<string> {\n return digestOf(`cancel:${token}`);\n}\n\n/**\n * Every way a manage action can fail, in codes the page has a Norwegian\n * sentence for.\n *\n * `windowPassed` is the one that matters. The engine enforces both windows on\n * the write and raises `CONFLICT` with `CANCEL_WINDOW_PASSED` /\n * `RESCHEDULE_WINDOW_PASSED` in the message — the code alone cannot tell it from\n * a taken slot, exactly as the create route has to read `SLOT_TAKEN` out of the\n * message. It is reachable without anyone doing anything wrong: `can_cancel` was\n * true when the page rendered, and the visitor sat on it until it was not.\n *\n * The upstream's own message never travels. Medal writes for the integrator\n * holding the API key, and its 404 for this route would be the same sentence\n * whether the token was revoked, GDPR-erased, or belongs to another salon.\n */\nfunction manageErrorResponse(rt: BookingRuntime, error: unknown, action: string): NextResponse {\n if (error instanceof MedalConfigError) {\n rt.logger.error({ err: error, action }, 'Booking API is not configured');\n return bookingErrorResponse('unconfigured', 503);\n }\n\n if (error instanceof MedalApiError) {\n if (error.status === 404) {\n return bookingErrorResponse('notFound', 404);\n }\n if (error.code === 'CONFLICT' && error.message.includes('WINDOW_PASSED')) {\n return bookingErrorResponse('windowPassed', 409);\n }\n // Before the code check: a «pick another time» refusal is any 409.\n if (isSlotTaken(error)) {\n return bookingErrorResponse('slotTaken', 409);\n }\n if (error.code === 'CONFLICT') {\n return bookingErrorResponse('conflict', 409);\n }\n if (error.code === 'VALIDATION_ERROR' || error.code === 'INVALID_INPUT') {\n return bookingErrorResponse('invalidInput', 400);\n }\n }\n\n // `action` and not the token: which of the two failed is the whole reason to\n // log a line here, and the token is the one thing that must never reach a log\n // stream that outlives the booking.\n rt.logger.error({ err: error, action }, 'Booking manage action failed');\n return bookingErrorResponse('upstreamError', 502);\n}\n\n/**\n * The booking's service id, for expiring its cached slots after a write.\n *\n * Asked for ALONGSIDE the write rather than after it, so it adds no round trip\n * (after a move the old token resolves to the cancelled row, which still names\n * the service — but there is no reason to wait for that). Best effort: `null`\n * leaves the cache to its ~60 s bound, never fails the action.\n */\n// A visit's extra services are not expired here: the summary names only its\n// first one (the visit's own entry carries that tag too, so it does go); their\n// one-service entries turn over with their own ~60 s bound.\nasync function serviceIdOf(rt: BookingRuntime, token: string): Promise<string | null> {\n try {\n return (await rt.medal.getManage(token)).service_id ?? null;\n } catch {\n return null;\n }\n}\n\n/** Expire the booking's slots — cached and in this colo's seeds — once the\n * write has landed. */\nasync function expireAfterWrite(\n rt: BookingRuntime,\n serviceId: Promise<string | null>\n): Promise<void> {\n const id = await serviceId;\n if (id === null) return;\n await forgetBookedSlots(rt, [id]);\n}\n\n/**\n * The manage body's ceiling: an action, a reason chip and an instant fit in\n * a few hundred bytes. Read in bytes before anything parses it, as the portal\n * routes cap theirs.\n */\nconst MANAGE_MAX_BODY_BYTES = 4 * 1024;\n\nexport async function manageRoute(\n rt: BookingRuntime,\n request: Request,\n token: string\n): Promise<Response> {\n if (!token) {\n // Unreachable through the router, which only matches a non-empty segment —\n // but this is the one function in the app where an empty credential must\n // never be forwarded as if it were one.\n return invalid('token is required');\n }\n\n let parsed: unknown;\n try {\n const text = await readBoundedText(request, MANAGE_MAX_BODY_BYTES);\n if (text === null) return invalid('body is too large');\n parsed = JSON.parse(text);\n } catch {\n return invalid('body must be valid JSON');\n }\n if (typeof parsed !== 'object' || parsed === null) {\n return invalid('body must be a JSON object');\n }\n const body = parsed as ManageRequest;\n\n if (body.action === 'cancel') {\n try {\n // Trimmed to nothing means the visitor picked no chip, and the reason is\n // optional to the engine — `''` is a value it would store as a reason\n // that says nothing. Absent is the honest form of «they did not say».\n const reason = typeof body.reason === 'string' ? body.reason.trim() : '';\n const serviceId = serviceIdOf(rt, token);\n await rt.medal.cancelManage(\n token,\n reason.length > 0 ? reason : undefined,\n await cancelKey(token)\n );\n await expireAfterWrite(rt, serviceId);\n return NextResponse.json({ ok: true });\n } catch (error) {\n return manageErrorResponse(rt, error, 'cancel');\n }\n }\n\n if (body.action === 'reschedule') {\n const startTs = body.startTs;\n // `Number.isFinite` and not `Number(...)`, for the reason the create route\n // gives: `Number(null)` is 0, and a cleared slot serialises as null — a\n // lenient parse would ask the engine to move the appointment to 1970.\n if (!Number.isFinite(startTs)) {\n return invalid('startTs must be a timestamp');\n }\n const serviceId = serviceIdOf(rt, token);\n try {\n const result = await rt.medal.rescheduleManage(\n token,\n startTs as number,\n await moveKey(token, startTs as number)\n );\n await expireAfterWrite(rt, serviceId);\n // The NEW booking's token, relayed so the page can send the visitor to a\n // manage link that still works: the token they arrived with now belongs\n // to the cancelled row and from here on resolves to «avbestilt».\n //\n // Absent on an idempotent replay — the engine mints it exactly once — so\n // the field is optional and the page has a path for not getting one.\n return NextResponse.json({\n ok: true,\n ...(typeof result?.manage_token === 'string' ? { manageToken: result.manage_token } : {}),\n });\n } catch (error) {\n // The move lost its slot: the cache offered one that was already gone.\n if (isSlotTaken(error)) await expireAfterWrite(rt, serviceId);\n return manageErrorResponse(rt, error, 'reschedule');\n }\n }\n\n return invalid('action must be \"cancel\" or \"reschedule\"');\n}\n\n/**\n * `GET <api>/manage/<token>` — the booking the token names, as the manage page\n * renders it, for a client that loads it itself. The same 404 for an unknown\n * token as for a revoked one; the token is never echoed or logged, and the\n * answer is never stored by anyone (`no-store`, `no-referrer`).\n */\nexport async function manageSummaryRoute(\n rt: BookingRuntime,\n _request: Request,\n token: string\n): Promise<Response> {\n const headers = { 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer' };\n if (!token) return invalid('token is required');\n try {\n const booking = toManageDto(await rt.medal.getManage(token));\n if (booking === null) return NextResponse.json({ error: 'notFound' }, { status: 404, headers });\n return NextResponse.json({ booking }, { headers });\n } catch (error) {\n if (error instanceof MedalApiError && error.status === 404) {\n return NextResponse.json({ error: 'notFound' }, { status: 404, headers });\n }\n const response = manageErrorResponse(rt, error, 'read');\n for (const [name, value] of Object.entries(headers)) response.headers.set(name, value);\n return response;\n }\n}\n"],"mappings":"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"}
|