@medalsocial/booking 0.3.1 → 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.
Files changed (39) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +2 -1
  3. package/dist/core/config.d.mts +14 -0
  4. package/dist/core/config.mjs +9 -4
  5. package/dist/core/config.mjs.map +1 -1
  6. package/dist/core/draft-store.d.mts +5 -0
  7. package/dist/core/draft-store.mjs +1 -0
  8. package/dist/core/draft-store.mjs.map +1 -1
  9. package/dist/core/index.d.mts +2 -1
  10. package/dist/core/index.mjs +2 -1
  11. package/dist/core/machine.d.mts +6 -0
  12. package/dist/core/machine.mjs +22 -3
  13. package/dist/core/machine.mjs.map +1 -1
  14. package/dist/core/range.d.mts +30 -0
  15. package/dist/core/range.mjs +32 -0
  16. package/dist/core/range.mjs.map +1 -0
  17. package/dist/next/catalogue.mjs +4 -5
  18. package/dist/next/catalogue.mjs.map +1 -1
  19. package/dist/next/handler.mjs +63 -5
  20. package/dist/next/handler.mjs.map +1 -1
  21. package/dist/next/medal.mjs +47 -15
  22. package/dist/next/medal.mjs.map +1 -1
  23. package/dist/next/routes/create.mjs +3 -6
  24. package/dist/next/routes/create.mjs.map +1 -1
  25. package/dist/next/routes/manage.mjs +5 -8
  26. package/dist/next/routes/manage.mjs.map +1 -1
  27. package/dist/next/routes/shared.mjs +57 -14
  28. package/dist/next/routes/shared.mjs.map +1 -1
  29. package/dist/react/BookingWizard.d.mts +7 -1
  30. package/dist/react/BookingWizard.mjs +15 -0
  31. package/dist/react/BookingWizard.mjs.map +1 -1
  32. package/dist/react/index.d.mts +2 -2
  33. package/dist/react/labels/screens.mjs +4 -0
  34. package/dist/react/labels/screens.mjs.map +1 -1
  35. package/dist/react/useBooking.d.mts +11 -1
  36. package/dist/react/useBooking.mjs +105 -29
  37. package/dist/react/useBooking.mjs.map +1 -1
  38. package/dist/react/wizard/index.d.mts +2 -2
  39. package/package.json +4 -4
@@ -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":";;;;;;;;;;;;;;;;;;;;AAiEA,MAAM,sBAAsB;;;;;;AAO5B,SAAS,WACP,IACA,KACkC;CAClC,MAAM,SAAS,KAAK;CACpB,MAAM,OAAO,KAAK;CAClB,IAAI,OAAO,WAAW,YAAY,OAAO,SAAS,UAAU;EAC1D,MAAM,SAAS,WACb,IAAI,gBAAgB;GAAE,SAAS,OAAO,MAAM;GAAG,OAAO,OAAO,IAAI;EAAE,CAAC,CACtE;EACA,IAAI,EAAE,WAAW,SAAS,OAAO;CACnC;CACA,MAAM,EAAE,QAAQ,aAAa,MAAM,cAAc,GAAG,MAAM,OAAO,KAAK,IAAI,GAAG,mBAAmB;CAChG,OAAO;EAAE,QAAQ;EAAa,MAAM;CAAU;AAChD;;;;;;;;;;AAWA,eAAe,aACb,IACA,OACA,QAC2C;CAC3C,MAAM,SAAS,IAAI,IACjB,MAAM,KAAK,SAAS;EAClB,MAAM,kBAAkB,KAAK,qBAAqB,CAAC;EACnD,OAAO,CAAC,cAAc,CAAC,KAAK,YAAY,GAAG,eAAe,CAAC,GAAG,IAAI;CACpE,CAAC,CACH;CACA,MAAM,OAAO,MAAM,QAAQ,IACzB,CAAC,GAAG,MAAM,CAAC,CAAC,IAAI,OAAO,CAAC,OAAO,EAAE,YAAY,WAAW,yBAAyB;EAC/E,IAAI;GASF,OAAO,CAAC,QAAO,MARK,GAAG,UAAU,mBAC/B;IACE;IACA,GAAI,oBAAoB,EAAE,iBAAiB,kBAAkB,IAAI,CAAC;IAClE,GAAG;GACL,GACA,EAAE,OAAO,KAAK,CAChB,EAAA,CACqB,QAAQ,GAAG,IAAI,gBAAgB,CAAC;EACvD,SAAS,OAAO;GACd,GAAG,OAAO,KAAK;IAAE,KAAK;IAAO;GAAM,GAAG,+CAA+C;GACrF,OAAO;EACT;CACF,CAAC,CACH;CACA,OAAO,OAAO,YAAY,KAAK,QAAQ,UAAU,UAAU,IAAI,CAAC;AAClE;;;;;;;;;;;;;AAcA,SAAS,iBAAiB,OAAoC;CAC5D,IAAI,OAAO,UAAU,UAAU,OAAO,KAAA;CACtC,MAAM,UAAU,MAAM,KAAK;CAC3B,OAAO,QAAQ,SAAS,IAAI,UAAU,KAAA;AACxC;;;;;;;;AASA,SAAS,kBAAkB,OAAoC;CAC7D,OAAO,OAAO,UAAU,YAAY,OAAO,SAAS,KAAK,IAAI,QAAQ,KAAA;AACvE;;;;;;;;;AAUA,MAAM,cAAc;AAEpB,SAAS,QAAQ,SAA+B;CAC9C,OAAO,qBAAqB,gBAAgB,KAAK,OAAO;AAC1D;;;;;;;;;AAUA,SAAS,kBACP,KACA,WACA,OAC6C;CAC7C,IAAI,QAAQ,KAAA,KAAa,QAAQ,MAAM,OAAO,EAAE,KAAK,CAAC,EAAE;CACxD,MAAM,QAAQ,SAAS,MAAM;CAC7B,IAAI,CAAC,MAAM,QAAQ,GAAG,GAAG,OAAO,EAAE,OAAO,QAAQ,GAAG,MAAM,+BAA+B,EAAE;CAC3F,MAAM,MAAgB,CAAC;CACvB,KAAK,MAAM,SAAS,KAAK;EACvB,MAAM,KAAK,iBAAiB,KAAK;EACjC,IAAI,OAAO,KAAA,GAAW,OAAO,EAAE,OAAO,QAAQ,GAAG,MAAM,+BAA+B,EAAE;EACxF,IAAI,KAAK,EAAE;CACb;CACA,IAAI,IAAI,SAAA,GACN,OAAO,EAAE,OAAO,QAAQ,GAAG,MAAM,8BAAkD,EAAE;CAEvF,IAAI,IAAI,IAAI,GAAG,CAAC,CAAC,SAAS,IAAI,QAAQ,OAAO,EAAE,OAAO,QAAQ,GAAG,MAAM,uBAAuB,EAAE;CAChG,IAAI,IAAI,SAAS,SAAS,GAAG,OAAO,EAAE,OAAO,QAAQ,GAAG,MAAM,mBAAmB,EAAE;CACnF,OAAO,EAAE,IAAI;AACf;;;;;;;;;AAUA,SAAS,YAAY,MAA4E;CAC/F,IAAI,CAAC,MAAM,QAAQ,KAAK,KAAK,KAAK,KAAK,MAAM,WAAW,GACtD,OAAO,EAAE,OAAO,QAAQ,yCAAyC,EAAE;CAGrE,MAAM,QAAQ,iBAAiB,KAAK,SAAS,KAAK;CAClD,IAAI,UAAU,KAAA,GACZ,OAAO,EAAE,OAAO,QAAQ,2BAA2B,EAAE;CASvD,MAAM,QAAQ,iBAAiB,KAAK,SAAS,KAAK;CAClD,IAAI,UAAU,KAAA,KAAa,CAAC,YAAY,KAAK,KAAK,GAChD,OAAO,EAAE,OAAO,QAAQ,sCAAsC,EAAE;CAMlE,IAAI,KAAK,iBAAiB,MACxB,OAAO,EAAE,OAAO,QAAQ,+BAA+B,EAAE;CAG3D,MAAM,QAAoC,CAAC;CAC3C,KAAK,MAAM,CAAC,OAAO,SAAS,KAAK,MAAM,QAAQ,GAAG;EAChD,MAAM,YAAY,iBAAiB,MAAM,SAAS;EAClD,IAAI,cAAc,KAAA,GAChB,OAAO,EAAE,OAAO,QAAQ,SAAS,MAAM,uBAAuB,EAAE;EAKlE,MAAM,UAAU,MAAM;EACtB,IAAI,CAAC,OAAO,SAAS,OAAO,GAC1B,OAAO,EAAE,OAAO,QAAQ,SAAS,MAAM,6BAA6B,EAAE;EAExE,MAAM,SAAS,kBAAkB,MAAM,iBAAiB,WAAW,KAAK;EACxE,IAAI,WAAW,QAAQ,OAAO;EAC9B,MAAM,KAAK;GACT,YAAY;GAKZ,GAAI,OAAO,IAAI,SAAS,IAAI,EAAE,mBAAmB,OAAO,IAAI,IAAI,CAAC;GAIjE,aAAa,iBAAiB,MAAM,UAAU;GAC9C,UAAU;GACV,iBAAiB,iBAAiB,MAAM,aAAa;GACrD,uBAAuB,kBAAkB,MAAM,kBAAkB;EACnE,CAAC;CACH;CAEA,OAAO,EACL,MAAM;EACJ;EACA,SAAS;GACP;GACA,MAAM,iBAAiB,KAAK,SAAS,IAAI;GACzC;EACF;EACA,OAAO,iBAAiB,KAAK,KAAK;CACpC,EACF;AACF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAwCA,eAAe,kBACb,OACA,MACA,WACiB;CACjB,IAAI,OAAO,UAAU,YAAY,MAAM,KAAK,CAAC,CAAC,WAAW,GAAG,OAAO,OAAO,WAAW;CAcrF,MAAM,WAAW,GAAG,QAAQ,KAAK,UAAU,IAAI,IAAI,cAAc,OAAO,KAAK,iBAAiB;CAC9F,MAAM,SAAS,MAAM,OAAO,OAAO,OAAO,WAAW,IAAI,YAAY,CAAC,CAAC,OAAO,QAAQ,CAAC;CACvF,OAAO,MAAM,KAAK,IAAI,WAAW,MAAM,IAAI,SAAS,KAAK,SAAS,EAAE,CAAC,CAAC,SAAS,GAAG,GAAG,CAAC,CAAC,CAAC,KAAK,EAAE;AACjG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAiCA,eAAe,uBACb,IACA,OACA,SACe;CACf,MAAM,UAAU,iBAAiB,GAAG,MAAM;CAE1C,IAAI,YAAY,QAAQ,UAAU,KAAA,KAAa,YAAY,MAAM;CACjE,IAAI;EACF,MAAM,GAAG,MAAM,cAAc;GAC3B;GACA,cAAc;GACd,SAAS;GACT,QAAQ,QAAQ;GAGhB,cAAc,QAAQ;GACtB,SAAS,QAAQ;EACnB,CAAC;CACH,SAAS,OAAO;EACd,GAAG,OAAO,KAAK,EAAE,KAAK,MAAM,GAAG,oCAAoC;CACrE;AACF;;;;;;;;;;;;;;;AAgBA,SAAS,oBAAoB,IAAoB,OAA8B;CAC7E,IAAI,iBAAiB,kBAAkB;EACrC,GAAG,OAAO,MAAM,EAAE,KAAK,MAAM,GAAG,+BAA+B;EAC/D,OAAO,qBAAqB,gBAAgB,GAAG;CACjD;CAEA,IAAI,iBAAiB,eAAe;EAMlC,IAAI,MAAM,SAAS,8BAA8B,MAAM,SAAS,2BAA2B;GACzF,GAAG,OAAO,KAAK,EAAE,KAAK,MAAM,GAAG,sDAAsD;GACrF,OAAO,qBAAqB,cAAc,GAAG;EAC/C;EACA,IAAI,MAAM,SAAS;;EAOjB,OAAO,qBAAqB,YAAY,KAAK,IAAI,cAAc,YAAY,GAAG;EAEhF,IAAI,MAAM,SAAS,sBAAsB,MAAM,SAAS,iBACtD,OAAO,qBAAqB,gBAAgB,GAAG;CAEnD;CAEA,GAAG,OAAO,MAAM,EAAE,KAAK,MAAM,GAAG,uBAAuB;CACvD,OAAO,qBAAqB,iBAAiB,GAAG;AAClD;;;;;;;AAQA,MAAM,wBAAwB;;;;;;;;;;;;AAa9B,eAAsB,YAAY,IAAoB,SAAqC;CACzF,IAAI;CACJ,IAAI;EACF,MAAM,OAAO,MAAM,gBAAgB,SAAS,qBAAqB;EACjE,IAAI,SAAS,MAAM,OAAO,QAAQ,mBAAmB;EACrD,SAAS,KAAK,MAAM,IAAI;CAC1B,QAAQ;EAIN,OAAO,QAAQ,yBAAyB;CAC1C;CAEA,IAAI,OAAO,WAAW,YAAY,WAAW,MAC3C,OAAO,QAAQ,4BAA4B;CAG7C,MAAM,SAAS,YAAY,MAAuB;CAClD,IAAI,WAAW,QAAQ,OAAO,OAAO;;CAarC,MAAM,eAAe,mBAAoB,OAAyB,SAAS,CAAC,CAAC;CAC7E,MAAM,UAAU,QAAQ,OAAO,MAAM,cAAc,IAAI;CACvD,MAAM,QAAS,OAAyB;CAMxC,MAAM,UAAU,GAAG,OAAO,QAAQ,WAC9B,MAAM,eAAe,IAAI,OAAO,MAAM,YAAY,IAClD,MAAM,YAAY,IAAI,OAAO,MAAM,YAAY,CAAC,CAAC,MAAM,SACrD,SAAS,OAAO,OAAO;EAAE;EAAM,SAAS;EAAM,WAAW;CAAK,CAChE;CACJ,IAAI,YAAY,MACd,OAAO,aAAa,KAAK;EAAE,OAAO;EAAiB,WAAW;CAAK,GAAG,EAAE,QAAQ,IAAI,CAAC;CAEvF,IAAI,YAAY,mBAAmB,OAAO,gBAAgB;CAC1D,OAAO,OAAO,QAAQ;CAEtB,IAAI;EAMF,MAAM,iBAAiB,MAAM,kBAAkB,OAAO,SAAS,QAAQ,SAAS;EAChF,IAAI;EACJ,IAAI;GACF,SACE,QAAQ,YAAY,OAChB,MAAM,GAAG,MAAM,cAAc,OAAO,MAAM,cAAc,IACxD,MAAM,GAAG,MAAM,cAAc,OAAO,MAAM,gBAAgB,EACxD,eAAe,QAAQ,QACzB,CAAC;EACT,SAAS,OAAO;GAOd,IAAI,QAAQ,YAAY,QAAQ,oBAAoB,KAAK,GAAG,OAAO,gBAAgB;GACnF,MAAM;EACR;EACA,MAAM,SAAS,MAAM,QAAQ,OAAO,QAAQ,IAAI,OAAO,WAAW,CAAC;EAEnE,MAAM,MAAM,OAAO,KAAK,YAAa,SAAqC,EAAE;EAC5E,MAAM,QACJ,OAAO,WAAW,OAAO,KAAK,MAAM,UACpC,IAAI,OAAO,OAAO,OAAO,OAAO,YAAY,GAAG,SAAS,CAAC,KACzD,IAAI,IAAI,GAAG,CAAC,CAAC,SAAS,IAAI;EAI5B,GAAG,UAAU,YAAY,iBAAiB,OAAO,IAAI,CAAC;EACtD,MAAM,GAAG,KAAK,mBAAmB,iBAAiB,OAAO,IAAI,CAAC;EAO9D,IAAI,CAAC,OAAO;GACV,GAAG,OAAO,MACR;IAAE,UAAU,OAAO,KAAK,MAAM;IAAQ,UAAU,OAAO;GAAO,GAC9D,sDACF;GACA,OAAO,qBAAqB,iBAAiB,GAAG;EAClD;EAMA,YACE,uBACE,IACA,OAAO,KAAK,QAAQ,OACnB,OAAyB,gBAC5B,CACF;EAIA,OAAO,aAAa,KAClB,EACE,UAAU,OAAO,KAAK,aAAa;GACjC,IAAI,QAAQ;GACZ,aAAa,QAAQ;EACvB,EAAE,EACJ,GACA,EAAE,QAAQ,IAAI,CAChB;CACF,SAAS,OAAO;EAGd,IAAI,YAAY,KAAK,GAAG;GACtB,MAAM,aAAa,iBAAiB,OAAO,IAAI;GAC/C,GAAG,UAAU,YAAY,UAAU;GACnC,MAAM,GAAG,KAAK,mBAAmB,UAAU;GAG3C,MAAM,aAAa,MAAM,aACvB,IACA,OAAO,KAAK,OACZ,WAAW,IAAK,OAAyB,MAAM,CACjD;GACA,OAAO,aAAa,KAAK;IAAE,OAAO;IAAa;GAAW,GAAG,EAAE,QAAQ,IAAI,CAAC;EAC9E;EACA,OAAO,oBAAoB,IAAI,KAAK;CACtC;AACF;;AAGA,SAAS,kBAAgC;CACvC,OAAO,qBAAqB,mBAAmB,KAAK,gBAAgB;AACtE;;AAGA,SAAS,oBAAoB,OAAyB;CACpD,OACE,iBAAiB,iBACjB,MAAM,WAAW,QAChB,MAAM,SAAS,6BAA6B,MAAM,SAAS;AAEhE;;;;AAKA,SAAS,iBAAiB,MAAmC;CAC3D,OAAO,KAAK,MAAM,SAAS,SAAS,CAAC,KAAK,YAAY,GAAI,KAAK,qBAAqB,CAAC,CAAE,CAAC;AAC1F;;AAGA,MAAM,kBAAkB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAgCxB,eAAe,YACb,IACA,MACA,KACmC;CACnC,IAAI,IAAI,OAAO,OAAO,OAAO,IAAI,GAAG,OAAO;CAC3C,MAAM,QAAQ,MAAM,eAAe,IAAI,KAAK,QAAQ,KAAK;CACzD,IAAI,UAAU,MAAM,OAAO;CAC3B,IAAI,MAAM,SAAS,GAAG,OAAO;CAC7B,OAAO,QAAQ,MAAM,KAAK,KAAK;AACjC;;;;;;;;;;;;;;;;;AAkBA,eAAe,eACb,IACA,MACA,KAGA;CACA,IAAI;EACF,MAAM,UAAU,MAAM,GAAG,QAAQ,kBAAkB;EACnD,IAAI,YAAY,MAAM,OAAO;EAC7B,MAAM,UAAU,MAAM,GAAG,OAAO,MAAM,OAAO;EAC7C,OAAO;GACL,MAAM,QAAQ,MAAM,KAAK,UAAU,QAAQ,MAAM,CAAC;GAClD;GACA,WAAW,QAAQ;EACrB;CACF,SAAS,OAAO;EACd,IAAI,iBAAiB,2BAA2B,OAAO;EAEvD,GAAG,OAAO,KACR,EAAE,KAAK,MAAM,GACb,iEACF;EACA,OAAO;CACT;AACF;;AAGA,SAAS,UAAU,QAAiE;CAClF,OAAO,IAAI,IAAI,OAAO,SAAS,WAAY,OAAO,aAAa,OAAO,CAAC,IAAI,CAAC,OAAO,QAAQ,CAAE,CAAC;AAChG;;AAGA,SAAS,mBAAmB,WAAsE;CAChG,OAAO,UAAU,KAAK,SAAS;EAC7B,MAAM,KAAK,MAAM;EACjB,OAAO,OAAO,OAAO,YAAY,gBAAgB,KAAK,EAAE,IAAI,KAAK;CACnE,CAAC;AACH;;AAGA,SAAS,QACP,MACA,KACA,SACmB;CACnB,IAAI,IAAI,OAAO,OAAO,OAAO,IAAI,GAAG,OAAO;CAC3C,OAAO;EACL,GAAG;EACH,OAAO,KAAK,MAAM,KAAK,MAAM,UAAU;GACrC,MAAM,KAAK,IAAI,UAAU;GACzB,OAAO,OAAO,SAAS,YAAY,QAAQ,QAAQ,IAAI,EAAE,KACrD;IAAE,GAAG;IAAM,sBAAsB;GAAG,IACpC;EACN,CAAC;CACH;AACF;;;;;AAMA,eAAe,eAAe,IAAoB,OAA4C;CAC5F,MAAM,EAAE,mBAAmB,GAAG;CAC9B,IAAI;EACF,MAAM,UAAU,MAAM,GAAG,QAAQ,kBAAkB;EACnD,IAAI,YAAY,MAAM,uBAAO,IAAI,IAAI;EACrC,MAAM,UAAU,MAAM,GAAG,OAAO,MAAM,OAAO;EAC7C,IAAI,QAAQ,UAAU,MAAM,uBAAO,IAAI,IAAI;EAC3C,IAAI,eAAe,QAAQ,KAAK,MAAM,eAAe,KAAK,GAAG,uBAAO,IAAI,IAAI;EAC5E,OAAO,UAAU,QAAQ,MAAM;CACjC,SAAS,OAAO;EAGd,IAAI,iBAAiB,2BAA2B,uBAAO,IAAI,IAAI;EAE/D,GAAG,OAAO,KAAK,EAAE,KAAK,MAAM,GAAG,8DAA8D;EAC7F,OAAO;CACT;AACF"}
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
- if (error.message.includes("WINDOW_PASSED")) return bookingErrorResponse("windowPassed", 409);
80
- if (isSlotTaken(error)) return bookingErrorResponse("slotTaken", 409);
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.catalogue.expireSlots([id]);
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":"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"}
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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"}
@@ -1,3 +1,4 @@
1
+ import "../../core/range.mjs";
1
2
  import { MedalConfigError } from "../medal.mjs";
2
3
  import { MedalApiError } from "@medalsocial/sdk";
3
4
  import { NextResponse } from "next/server";
@@ -108,23 +109,47 @@ async function knownExtraServiceIds(rt, raw, serviceId) {
108
109
  return { extraServiceIds: ids };
109
110
  }
110
111
  /**
111
- * The engine's «that slot is no longer free».
112
+ * The engine's refusals that all mean «that time cannot be booked — pick
113
+ * another»: taken, already passed, beyond the booking horizon, off the slot
114
+ * grid. Each is answered the same way — fresh openings, back to the time step —
115
+ * because the visitor's only move is a different time. Read as anything else,
116
+ * the wizard said «Prøv en gang til» and kept the hour, and every retry was
117
+ * refused again: a tab left open overnight offers hours that have passed.
112
118
  *
113
- * It arrives as `CONFLICT` with `SLOT_TAKEN` in the message: the SDK exposes no
114
- * finer code (`MedalApiError.code` is the generic `CONFLICT`, shared with a
115
- * duplicate key and an unseatable party). The message is read HERE and nowhere
116
- * else, so the day the engine grows a structured code there is one line to
117
- * change.
119
+ * The API answers all four with HTTP 409 and code `CONFLICT`, the reason in
120
+ * `error.details.reason` (lower case) and the same word upper-cased as the
121
+ * message prefix (`IN_PAST: …`). Read defensively anyway — any 409 whatever
122
+ * its code, the reason in any case, the message prefix when there is no
123
+ * reason — so a change of spelling upstream degrades to «pick another time»,
124
+ * never to a retry loop. Only a 409 is ever one: a 400 or 422 is a request
125
+ * the visitor's next tap cannot fix by picking a time.
118
126
  */
119
- function isSlotTaken(error) {
120
- return error instanceof MedalApiError && error.code === "CONFLICT" && error.message.includes("SLOT_TAKEN");
127
+ const PICK_ANOTHER_TIME = /* @__PURE__ */ new Set([
128
+ "slot_taken",
129
+ "in_past",
130
+ "outside_horizon",
131
+ "off_grid"
132
+ ]);
133
+ /** The same refusals by their message prefix, for an engine that sends no `details.reason`. */
134
+ const PICK_ANOTHER_TIME_MESSAGE = /\b(?:SLOT_TAKEN|IN_PAST|OUTSIDE_HORIZON|OFF_GRID)\b/;
135
+ /** `error.details.reason` in lower case — the engine's structured refusal, when it sent one. */
136
+ function refusalReason(error) {
137
+ const details = error.details;
138
+ if (typeof details !== "object" || details === null || !("reason" in details)) return null;
139
+ return typeof details.reason === "string" ? details.reason.toLowerCase() : null;
121
140
  }
122
141
  /**
123
- * 62 days. A bound on what a visitor-controlled query string may ask the site
124
- * to fetch on their behalf — not the engine's own limit, which clamps rather
125
- * than refuses. The wizard asks for a week at a time.
142
+ * The engine's «that time is no longer bookable» (see `PICK_ANOTHER_TIME`).
143
+ *
144
+ * It arrives as a 409, shared with a duplicate key and an unseatable party. The reason is read HERE and nowhere else: structured when
145
+ * the engine sends one, else the message prefix it has always carried.
126
146
  */
127
- const MAX_RANGE_MS = 53568e5;
147
+ function isSlotTaken(error) {
148
+ if (!(error instanceof MedalApiError) || error.status !== 409) return false;
149
+ const reason = refusalReason(error);
150
+ if (reason !== null) return PICK_ANOTHER_TIME.has(reason);
151
+ return PICK_ANOTHER_TIME_MESSAGE.test(error.message);
152
+ }
128
153
  /**
129
154
  * Epoch milliseconds or an ISO 8601 string — the same two forms the engine's
130
155
  * own parser accepts, so a bound this site admits is one Medal would have
@@ -148,18 +173,36 @@ function parseRange(searchParams) {
148
173
  const toTs = parseTimestamp(searchParams.get("to_ts"));
149
174
  if (fromTs === null || toTs === null) return { error: bookingErrorResponse("invalidInput", 400, "from_ts and to_ts must be Unix milliseconds or ISO 8601 date-times") };
150
175
  if (toTs <= fromTs) return { error: bookingErrorResponse("invalidInput", 400, "to_ts must be after from_ts") };
151
- if (toTs - fromTs > MAX_RANGE_MS) return { error: bookingErrorResponse("invalidInput", 400, "Range must not exceed 62 days") };
176
+ if (toTs - fromTs > 53604e5) return { error: bookingErrorResponse("invalidInput", 400, `Range must not exceed 62 days`) };
152
177
  return {
153
178
  fromTs,
154
179
  toTs
155
180
  };
156
181
  }
182
+ /**
183
+ * Expire the slot cache and this colo's booking seeds for services just
184
+ * booked, moved or cancelled.
185
+ *
186
+ * Never throws. The write has already happened when this runs, and a cache
187
+ * that could not be told must not turn it into an error: the visitor would be
188
+ * told a booking failed that exists, and might book it again. The worst a
189
+ * failure here costs is a stale opening for a few minutes, which Medal's own
190
+ * check at the next submit refuses.
191
+ */
192
+ async function forgetBookedSlots(rt, serviceIds) {
193
+ try {
194
+ rt.catalogue.expireSlots([...serviceIds]);
195
+ await rt.seed.expireBookingSeeds([...serviceIds]);
196
+ } catch (error) {
197
+ rt.logger.error({ err: error }, "Could not expire the booking caches after a write");
198
+ }
199
+ }
157
200
  /** SHA-256, hex — for idempotency keys that must never carry what they hash. */
158
201
  async function digestOf(input) {
159
202
  const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(input));
160
203
  return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
161
204
  }
162
205
  //#endregion
163
- export { bookingErrorResponse, catalogueErrorResponse, digestOf, isSlotTaken, knownExtraServiceIds, knownServiceId, parseRange };
206
+ export { bookingErrorResponse, catalogueErrorResponse, digestOf, forgetBookedSlots, isSlotTaken, knownExtraServiceIds, knownServiceId, parseRange };
164
207
 
165
208
  //# sourceMappingURL=shared.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"shared.mjs","names":[],"sources":["../../../src/next/routes/shared.ts"],"sourcesContent":["/**\n * What the booking routes are allowed to say to a browser.\n *\n * The wizard reads `error` straight into `WizardState.error`, which is a closed\n * union — `'maxParty' | 'slotTaken' | 'conflict' | 'invalidInput' |\n * 'unconfigured' | 'upstreamError'` — so the spelling here is load-bearing\n * rather than descriptive.\n *\n * The create route also answers `'inProgress'` (409, a key Medal already holds)\n * and `'accountRequired'` (401, `account.required` with nobody logged in).\n *\n * `upstreamError` is the catch-all. Something has to be in the field either\n * way, because `NextResponse.json({ error: undefined })` serialises to `{}`\n * and reads as a failure with no reason at all.\n *\n * Only the create route is held to the union. `catalogueErrorResponse` also\n * relays the upstream's own code where there is one, so the read routes can\n * say words the wizard has never heard of — which is safe precisely because\n * nothing is reading them into `WizardState`.\n *\n * `message` appears only when THIS file wrote it. Medal's own error bodies are\n * written for the integrator holding the API key — they name workspaces, ids\n * and internal field paths — and none of that belongs in front of a visitor.\n */\n\nimport { MedalApiError } from '@medalsocial/sdk';\nimport { NextResponse } from 'next/server';\nimport { MedalConfigError } from '../medal';\nimport type { BookingRuntime } from '../runtime';\n\nexport function bookingErrorResponse(code: string, status: number, message?: string): NextResponse {\n return NextResponse.json(message === undefined ? { error: code } : { error: code, message }, {\n status,\n });\n}\n\n/**\n * The catch for the read routes, in one place because it is one behaviour.\n *\n * The upstream *status* is relayed rather than flattened, because it is the\n * part that says what to do — a 404 means this service no longer exists and\n * the wizard should reload the catalogue, a 429 means back off, a 403 means\n * the plan lost the bookings module. The upstream *message* is not.\n *\n * A `MedalApiError` already carries its own explanation on both sides of the\n * wire; a missing API key and an unrecognised throw do not, and those two are\n * logged.\n */\nexport function catalogueErrorResponse(\n rt: Pick<BookingRuntime, 'logger'>,\n error: unknown,\n endpoint: string\n): NextResponse {\n if (error instanceof MedalConfigError) {\n rt.logger.error({ err: error, endpoint }, 'Booking API is not configured');\n return bookingErrorResponse('unconfigured', 503);\n }\n if (error instanceof MedalApiError) {\n return bookingErrorResponse(error.code ?? 'upstreamError', error.status);\n }\n rt.logger.error({ err: error, endpoint }, 'Unexpected booking API failure');\n return bookingErrorResponse('upstreamError', 502);\n}\n\n/** What a service id looks like: Medal's ids are word characters and dashes. */\nconst SERVICE_ID_SHAPE = /^[\\w-]{1,64}$/;\n\n/**\n * A `service_id` from the query string, checked before it can reach a cache\n * key or a cache tag: the right shape, and a service the catalogue actually\n * has. Otherwise every invented id would be a Medal call and a cache entry.\n *\n * The catalogue read is the cached one, so a real id costs nothing extra.\n */\nexport async function knownServiceId(\n rt: Pick<BookingRuntime, 'logger' | 'catalogue'>,\n raw: string\n): Promise<{ serviceId: string } | { error: NextResponse }> {\n if (!SERVICE_ID_SHAPE.test(raw)) {\n return { error: bookingErrorResponse('invalidInput', 400, 'service_id is malformed') };\n }\n let services: Array<{ id: string }>;\n try {\n services = await rt.catalogue.cachedServices();\n } catch (error) {\n return { error: catalogueErrorResponse(rt, error, 'services') };\n }\n if (!services.some((service) => service.id === raw)) {\n return { error: bookingErrorResponse('invalidInput', 400, 'Unknown service_id') };\n }\n return { serviceId: raw };\n}\n\n/**\n * How many services one person's visit may add after the first — the engine's\n * own bound (four services in all), refused here so a visitor's query string\n * cannot make it a Medal call.\n */\nexport const MAX_EXTRA_SERVICES = 3;\n\n/**\n * `extra_service_ids` from the query string — the rest of ONE person's visit\n * after `service_id`, comma-separated, in order. Blank entries are dropped, so\n * an absent, empty or `,`-only value is a one-service visit (`[]`).\n *\n * Held to everything the engine would refuse with a 422 (more than three, one\n * twice, `service_id` again) and to what `knownServiceId` holds `service_id` to\n * (the shape, and a service the catalogue has), for the same reason: every id\n * here lands in a cache key and a cache tag. Call it after `knownServiceId`.\n */\nexport async function knownExtraServiceIds(\n rt: Pick<BookingRuntime, 'logger' | 'catalogue'>,\n raw: string | null,\n serviceId: string\n): Promise<{ extraServiceIds: string[] } | { error: NextResponse }> {\n const ids = (raw ?? '')\n .split(',')\n .map((id) => id.trim())\n .filter((id) => id.length > 0);\n if (ids.length === 0) return { extraServiceIds: [] };\n const refuse = (message: string) => ({\n error: bookingErrorResponse('invalidInput', 400, message),\n });\n if (ids.length > MAX_EXTRA_SERVICES) {\n return refuse(`extra_service_ids must name at most ${MAX_EXTRA_SERVICES} services`);\n }\n if (new Set(ids).size !== ids.length) return refuse('extra_service_ids names a service twice');\n if (ids.includes(serviceId)) return refuse('extra_service_ids repeats service_id');\n if (!ids.every((id) => SERVICE_ID_SHAPE.test(id))) {\n return refuse('extra_service_ids is malformed');\n }\n let services: Array<{ id: string }>;\n try {\n services = await rt.catalogue.cachedServices();\n } catch (error) {\n return { error: catalogueErrorResponse(rt, error, 'services') };\n }\n const known = new Set(services.map((service) => service.id));\n if (!ids.every((id) => known.has(id))) return refuse('Unknown service in extra_service_ids');\n return { extraServiceIds: ids };\n}\n\n/**\n * The engine's «that slot is no longer free».\n *\n * It arrives as `CONFLICT` with `SLOT_TAKEN` in the message: the SDK exposes no\n * finer code (`MedalApiError.code` is the generic `CONFLICT`, shared with a\n * duplicate key and an unseatable party). The message is read HERE and nowhere\n * else, so the day the engine grows a structured code there is one line to\n * change.\n */\nexport function isSlotTaken(error: unknown): boolean {\n return (\n error instanceof MedalApiError &&\n error.code === 'CONFLICT' &&\n error.message.includes('SLOT_TAKEN')\n );\n}\n\n/**\n * 62 days. A bound on what a visitor-controlled query string may ask the site\n * to fetch on their behalf — not the engine's own limit, which clamps rather\n * than refuses. The wizard asks for a week at a time.\n */\nconst MAX_RANGE_MS = 62 * 24 * 60 * 60 * 1000;\n\n/**\n * Epoch milliseconds or an ISO 8601 string — the same two forms the engine's\n * own parser accepts, so a bound this site admits is one Medal would have\n * admitted too. `null` for anything it cannot read as a time.\n */\nfunction parseTimestamp(raw: string | null): number | null {\n const trimmed = raw?.trim();\n if (!trimmed) return null;\n const numeric = Number(trimmed);\n const timestamp = Number.isFinite(numeric) ? numeric : Date.parse(trimmed);\n return Number.isFinite(timestamp) ? timestamp : null;\n}\n\n/**\n * The `from_ts`/`to_ts` pair the read routes take, validated before Medal is\n * called. Every guard answers a request the engine would also refuse, and an\n * unreadable bound would otherwise throw inside the client and surface as an\n * opaque 502 pointing at Medal for a typo in our own query string.\n */\nexport function parseRange(\n searchParams: URLSearchParams\n): { fromTs: number; toTs: number } | { error: NextResponse } {\n const fromTs = parseTimestamp(searchParams.get('from_ts'));\n const toTs = parseTimestamp(searchParams.get('to_ts'));\n if (fromTs === null || toTs === null) {\n return {\n error: bookingErrorResponse(\n 'invalidInput',\n 400,\n 'from_ts and to_ts must be Unix milliseconds or ISO 8601 date-times'\n ),\n };\n }\n if (toTs <= fromTs) {\n return { error: bookingErrorResponse('invalidInput', 400, 'to_ts must be after from_ts') };\n }\n if (toTs - fromTs > MAX_RANGE_MS) {\n return { error: bookingErrorResponse('invalidInput', 400, 'Range must not exceed 62 days') };\n }\n return { fromTs, toTs };\n}\n\n/** SHA-256, hex — for idempotency keys that must never carry what they hash. */\nexport async function digestOf(input: string): Promise<string> {\n const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input));\n return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('');\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;AA8BA,SAAgB,qBAAqB,MAAc,QAAgB,SAAgC;CACjG,OAAO,aAAa,KAAK,YAAY,KAAA,IAAY,EAAE,OAAO,KAAK,IAAI;EAAE,OAAO;EAAM;CAAQ,GAAG,EAC3F,OACF,CAAC;AACH;;;;;;;;;;;;;AAcA,SAAgB,uBACd,IACA,OACA,UACc;CACd,IAAI,iBAAiB,kBAAkB;EACrC,GAAG,OAAO,MAAM;GAAE,KAAK;GAAO;EAAS,GAAG,+BAA+B;EACzE,OAAO,qBAAqB,gBAAgB,GAAG;CACjD;CACA,IAAI,iBAAiB,eACnB,OAAO,qBAAqB,MAAM,QAAQ,iBAAiB,MAAM,MAAM;CAEzE,GAAG,OAAO,MAAM;EAAE,KAAK;EAAO;CAAS,GAAG,gCAAgC;CAC1E,OAAO,qBAAqB,iBAAiB,GAAG;AAClD;;AAGA,MAAM,mBAAmB;;;;;;;;AASzB,eAAsB,eACpB,IACA,KAC0D;CAC1D,IAAI,CAAC,iBAAiB,KAAK,GAAG,GAC5B,OAAO,EAAE,OAAO,qBAAqB,gBAAgB,KAAK,yBAAyB,EAAE;CAEvF,IAAI;CACJ,IAAI;EACF,WAAW,MAAM,GAAG,UAAU,eAAe;CAC/C,SAAS,OAAO;EACd,OAAO,EAAE,OAAO,uBAAuB,IAAI,OAAO,UAAU,EAAE;CAChE;CACA,IAAI,CAAC,SAAS,MAAM,YAAY,QAAQ,OAAO,GAAG,GAChD,OAAO,EAAE,OAAO,qBAAqB,gBAAgB,KAAK,oBAAoB,EAAE;CAElF,OAAO,EAAE,WAAW,IAAI;AAC1B;;;;;;;;;;;AAmBA,eAAsB,qBACpB,IACA,KACA,WACkE;CAClE,MAAM,OAAO,OAAO,GAAA,CACjB,MAAM,GAAG,CAAC,CACV,KAAK,OAAO,GAAG,KAAK,CAAC,CAAC,CACtB,QAAQ,OAAO,GAAG,SAAS,CAAC;CAC/B,IAAI,IAAI,WAAW,GAAG,OAAO,EAAE,iBAAiB,CAAC,EAAE;CACnD,MAAM,UAAU,aAAqB,EACnC,OAAO,qBAAqB,gBAAgB,KAAK,OAAO,EAC1D;CACA,IAAI,IAAI,SAAA,GACN,OAAO,OAAO,gDAAoE;CAEpF,IAAI,IAAI,IAAI,GAAG,CAAC,CAAC,SAAS,IAAI,QAAQ,OAAO,OAAO,yCAAyC;CAC7F,IAAI,IAAI,SAAS,SAAS,GAAG,OAAO,OAAO,sCAAsC;CACjF,IAAI,CAAC,IAAI,OAAO,OAAO,iBAAiB,KAAK,EAAE,CAAC,GAC9C,OAAO,OAAO,gCAAgC;CAEhD,IAAI;CACJ,IAAI;EACF,WAAW,MAAM,GAAG,UAAU,eAAe;CAC/C,SAAS,OAAO;EACd,OAAO,EAAE,OAAO,uBAAuB,IAAI,OAAO,UAAU,EAAE;CAChE;CACA,MAAM,QAAQ,IAAI,IAAI,SAAS,KAAK,YAAY,QAAQ,EAAE,CAAC;CAC3D,IAAI,CAAC,IAAI,OAAO,OAAO,MAAM,IAAI,EAAE,CAAC,GAAG,OAAO,OAAO,sCAAsC;CAC3F,OAAO,EAAE,iBAAiB,IAAI;AAChC;;;;;;;;;;AAWA,SAAgB,YAAY,OAAyB;CACnD,OACE,iBAAiB,iBACjB,MAAM,SAAS,cACf,MAAM,QAAQ,SAAS,YAAY;AAEvC;;;;;;AAOA,MAAM,eAAe;;;;;;AAOrB,SAAS,eAAe,KAAmC;CACzD,MAAM,UAAU,KAAK,KAAK;CAC1B,IAAI,CAAC,SAAS,OAAO;CACrB,MAAM,UAAU,OAAO,OAAO;CAC9B,MAAM,YAAY,OAAO,SAAS,OAAO,IAAI,UAAU,KAAK,MAAM,OAAO;CACzE,OAAO,OAAO,SAAS,SAAS,IAAI,YAAY;AAClD;;;;;;;AAQA,SAAgB,WACd,cAC4D;CAC5D,MAAM,SAAS,eAAe,aAAa,IAAI,SAAS,CAAC;CACzD,MAAM,OAAO,eAAe,aAAa,IAAI,OAAO,CAAC;CACrD,IAAI,WAAW,QAAQ,SAAS,MAC9B,OAAO,EACL,OAAO,qBACL,gBACA,KACA,oEACF,EACF;CAEF,IAAI,QAAQ,QACV,OAAO,EAAE,OAAO,qBAAqB,gBAAgB,KAAK,6BAA6B,EAAE;CAE3F,IAAI,OAAO,SAAS,cAClB,OAAO,EAAE,OAAO,qBAAqB,gBAAgB,KAAK,+BAA+B,EAAE;CAE7F,OAAO;EAAE;EAAQ;CAAK;AACxB;;AAGA,eAAsB,SAAS,OAAgC;CAC7D,MAAM,SAAS,MAAM,OAAO,OAAO,OAAO,WAAW,IAAI,YAAY,CAAC,CAAC,OAAO,KAAK,CAAC;CACpF,OAAO,MAAM,KAAK,IAAI,WAAW,MAAM,IAAI,SAAS,KAAK,SAAS,EAAE,CAAC,CAAC,SAAS,GAAG,GAAG,CAAC,CAAC,CAAC,KAAK,EAAE;AACjG"}
1
+ {"version":3,"file":"shared.mjs","names":[],"sources":["../../../src/next/routes/shared.ts"],"sourcesContent":["/**\n * What the booking routes are allowed to say to a browser.\n *\n * The wizard reads `error` straight into `WizardState.error`, which is a closed\n * union — `'maxParty' | 'slotTaken' | 'conflict' | 'invalidInput' |\n * 'unconfigured' | 'upstreamError'` — so the spelling here is load-bearing\n * rather than descriptive.\n *\n * The create route also answers `'inProgress'` (409, a key Medal already holds)\n * and `'accountRequired'` (401, `account.required` with nobody logged in).\n *\n * `upstreamError` is the catch-all. Something has to be in the field either\n * way, because `NextResponse.json({ error: undefined })` serialises to `{}`\n * and reads as a failure with no reason at all.\n *\n * Only the create route is held to the union. `catalogueErrorResponse` also\n * relays the upstream's own code where there is one, so the read routes can\n * say words the wizard has never heard of — which is safe precisely because\n * nothing is reading them into `WizardState`.\n *\n * `message` appears only when THIS file wrote it. Medal's own error bodies are\n * written for the integrator holding the API key — they name workspaces, ids\n * and internal field paths — and none of that belongs in front of a visitor.\n */\n\nimport { MedalApiError } from '@medalsocial/sdk';\nimport { NextResponse } from 'next/server';\nimport { MAX_RANGE_DAYS, MAX_RANGE_MS } from '../../core/range';\nimport { MedalConfigError } from '../medal';\nimport type { BookingRuntime } from '../runtime';\n\nexport function bookingErrorResponse(code: string, status: number, message?: string): NextResponse {\n return NextResponse.json(message === undefined ? { error: code } : { error: code, message }, {\n status,\n });\n}\n\n/**\n * The catch for the read routes, in one place because it is one behaviour.\n *\n * The upstream *status* is relayed rather than flattened, because it is the\n * part that says what to do — a 404 means this service no longer exists and\n * the wizard should reload the catalogue, a 429 means back off, a 403 means\n * the plan lost the bookings module. The upstream *message* is not.\n *\n * A `MedalApiError` already carries its own explanation on both sides of the\n * wire; a missing API key and an unrecognised throw do not, and those two are\n * logged.\n */\nexport function catalogueErrorResponse(\n rt: Pick<BookingRuntime, 'logger'>,\n error: unknown,\n endpoint: string\n): NextResponse {\n if (error instanceof MedalConfigError) {\n rt.logger.error({ err: error, endpoint }, 'Booking API is not configured');\n return bookingErrorResponse('unconfigured', 503);\n }\n if (error instanceof MedalApiError) {\n return bookingErrorResponse(error.code ?? 'upstreamError', error.status);\n }\n rt.logger.error({ err: error, endpoint }, 'Unexpected booking API failure');\n return bookingErrorResponse('upstreamError', 502);\n}\n\n/** What a service id looks like: Medal's ids are word characters and dashes. */\nconst SERVICE_ID_SHAPE = /^[\\w-]{1,64}$/;\n\n/**\n * A `service_id` from the query string, checked before it can reach a cache\n * key or a cache tag: the right shape, and a service the catalogue actually\n * has. Otherwise every invented id would be a Medal call and a cache entry.\n *\n * The catalogue read is the cached one, so a real id costs nothing extra.\n */\nexport async function knownServiceId(\n rt: Pick<BookingRuntime, 'logger' | 'catalogue'>,\n raw: string\n): Promise<{ serviceId: string } | { error: NextResponse }> {\n if (!SERVICE_ID_SHAPE.test(raw)) {\n return { error: bookingErrorResponse('invalidInput', 400, 'service_id is malformed') };\n }\n let services: Array<{ id: string }>;\n try {\n services = await rt.catalogue.cachedServices();\n } catch (error) {\n return { error: catalogueErrorResponse(rt, error, 'services') };\n }\n if (!services.some((service) => service.id === raw)) {\n return { error: bookingErrorResponse('invalidInput', 400, 'Unknown service_id') };\n }\n return { serviceId: raw };\n}\n\n/**\n * How many services one person's visit may add after the first — the engine's\n * own bound (four services in all), refused here so a visitor's query string\n * cannot make it a Medal call.\n */\nexport const MAX_EXTRA_SERVICES = 3;\n\n/**\n * `extra_service_ids` from the query string — the rest of ONE person's visit\n * after `service_id`, comma-separated, in order. Blank entries are dropped, so\n * an absent, empty or `,`-only value is a one-service visit (`[]`).\n *\n * Held to everything the engine would refuse with a 422 (more than three, one\n * twice, `service_id` again) and to what `knownServiceId` holds `service_id` to\n * (the shape, and a service the catalogue has), for the same reason: every id\n * here lands in a cache key and a cache tag. Call it after `knownServiceId`.\n */\nexport async function knownExtraServiceIds(\n rt: Pick<BookingRuntime, 'logger' | 'catalogue'>,\n raw: string | null,\n serviceId: string\n): Promise<{ extraServiceIds: string[] } | { error: NextResponse }> {\n const ids = (raw ?? '')\n .split(',')\n .map((id) => id.trim())\n .filter((id) => id.length > 0);\n if (ids.length === 0) return { extraServiceIds: [] };\n const refuse = (message: string) => ({\n error: bookingErrorResponse('invalidInput', 400, message),\n });\n if (ids.length > MAX_EXTRA_SERVICES) {\n return refuse(`extra_service_ids must name at most ${MAX_EXTRA_SERVICES} services`);\n }\n if (new Set(ids).size !== ids.length) return refuse('extra_service_ids names a service twice');\n if (ids.includes(serviceId)) return refuse('extra_service_ids repeats service_id');\n if (!ids.every((id) => SERVICE_ID_SHAPE.test(id))) {\n return refuse('extra_service_ids is malformed');\n }\n let services: Array<{ id: string }>;\n try {\n services = await rt.catalogue.cachedServices();\n } catch (error) {\n return { error: catalogueErrorResponse(rt, error, 'services') };\n }\n const known = new Set(services.map((service) => service.id));\n if (!ids.every((id) => known.has(id))) return refuse('Unknown service in extra_service_ids');\n return { extraServiceIds: ids };\n}\n\n/**\n * The engine's refusals that all mean «that time cannot be booked — pick\n * another»: taken, already passed, beyond the booking horizon, off the slot\n * grid. Each is answered the same way — fresh openings, back to the time step —\n * because the visitor's only move is a different time. Read as anything else,\n * the wizard said «Prøv en gang til» and kept the hour, and every retry was\n * refused again: a tab left open overnight offers hours that have passed.\n *\n * The API answers all four with HTTP 409 and code `CONFLICT`, the reason in\n * `error.details.reason` (lower case) and the same word upper-cased as the\n * message prefix (`IN_PAST: …`). Read defensively anyway — any 409 whatever\n * its code, the reason in any case, the message prefix when there is no\n * reason — so a change of spelling upstream degrades to «pick another time»,\n * never to a retry loop. Only a 409 is ever one: a 400 or 422 is a request\n * the visitor's next tap cannot fix by picking a time.\n */\nconst PICK_ANOTHER_TIME = new Set(['slot_taken', 'in_past', 'outside_horizon', 'off_grid']);\n\n/** The same refusals by their message prefix, for an engine that sends no `details.reason`. */\nconst PICK_ANOTHER_TIME_MESSAGE = /\\b(?:SLOT_TAKEN|IN_PAST|OUTSIDE_HORIZON|OFF_GRID)\\b/;\n\n/** `error.details.reason` in lower case — the engine's structured refusal, when it sent one. */\nfunction refusalReason(error: MedalApiError): string | null {\n const details = error.details;\n if (typeof details !== 'object' || details === null || !('reason' in details)) return null;\n return typeof details.reason === 'string' ? details.reason.toLowerCase() : null;\n}\n\n/**\n * The engine's «that time is no longer bookable» (see `PICK_ANOTHER_TIME`).\n *\n * It arrives as a 409, shared with a duplicate key and an unseatable party. The reason is read HERE and nowhere else: structured when\n * the engine sends one, else the message prefix it has always carried.\n */\nexport function isSlotTaken(error: unknown): boolean {\n if (!(error instanceof MedalApiError) || error.status !== 409) return false;\n const reason = refusalReason(error);\n if (reason !== null) return PICK_ANOTHER_TIME.has(reason);\n return PICK_ANOTHER_TIME_MESSAGE.test(error.message);\n}\n\n/**\n * Epoch milliseconds or an ISO 8601 string — the same two forms the engine's\n * own parser accepts, so a bound this site admits is one Medal would have\n * admitted too. `null` for anything it cannot read as a time.\n */\nfunction parseTimestamp(raw: string | null): number | null {\n const trimmed = raw?.trim();\n if (!trimmed) return null;\n const numeric = Number(trimmed);\n const timestamp = Number.isFinite(numeric) ? numeric : Date.parse(trimmed);\n return Number.isFinite(timestamp) ? timestamp : null;\n}\n\n/**\n * The `from_ts`/`to_ts` pair the read routes take, validated before Medal is\n * called. Every guard answers a request the engine would also refuse, and an\n * unreadable bound would otherwise throw inside the client and surface as an\n * opaque 502 pointing at Medal for a typo in our own query string.\n */\nexport function parseRange(\n searchParams: URLSearchParams\n): { fromTs: number; toTs: number } | { error: NextResponse } {\n const fromTs = parseTimestamp(searchParams.get('from_ts'));\n const toTs = parseTimestamp(searchParams.get('to_ts'));\n if (fromTs === null || toTs === null) {\n return {\n error: bookingErrorResponse(\n 'invalidInput',\n 400,\n 'from_ts and to_ts must be Unix milliseconds or ISO 8601 date-times'\n ),\n };\n }\n if (toTs <= fromTs) {\n return { error: bookingErrorResponse('invalidInput', 400, 'to_ts must be after from_ts') };\n }\n // A bound on what a visitor-controlled query string may ask the site to\n // fetch on their behalf — not the engine's own limit, which clamps rather\n // than refuses. It admits the widest window a valid config can produce,\n // DST hour included (`core/range.ts`).\n if (toTs - fromTs > MAX_RANGE_MS) {\n return {\n error: bookingErrorResponse(\n 'invalidInput',\n 400,\n `Range must not exceed ${MAX_RANGE_DAYS} days`\n ),\n };\n }\n return { fromTs, toTs };\n}\n\n/**\n * Expire the slot cache and this colo's booking seeds for services just\n * booked, moved or cancelled.\n *\n * Never throws. The write has already happened when this runs, and a cache\n * that could not be told must not turn it into an error: the visitor would be\n * told a booking failed that exists, and might book it again. The worst a\n * failure here costs is a stale opening for a few minutes, which Medal's own\n * check at the next submit refuses.\n */\nexport async function forgetBookedSlots(\n rt: BookingRuntime,\n serviceIds: readonly string[]\n): Promise<void> {\n try {\n rt.catalogue.expireSlots([...serviceIds]);\n await rt.seed.expireBookingSeeds([...serviceIds]);\n } catch (error) {\n rt.logger.error({ err: error }, 'Could not expire the booking caches after a write');\n }\n}\n\n/** SHA-256, hex — for idempotency keys that must never carry what they hash. */\nexport async function digestOf(input: string): Promise<string> {\n const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input));\n return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('');\n}\n"],"mappings":"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"}
@@ -1,7 +1,7 @@
1
1
  import { WizardState, WizardStep } from "../core/machine.mjs";
2
2
  import { PortalActions } from "./actions.mjs";
3
3
  import { BookingOverrides } from "./Provider.mjs";
4
- import { BookingSeed, useBooking } from "./useBooking.mjs";
4
+ import { BookingReadEndpoint, BookingSeed, useBooking } from "./useBooking.mjs";
5
5
  //#region src/react/BookingWizard.d.ts
6
6
  export interface BookingWizardProps extends BookingOverrides {
7
7
  /** What the page prefetched (the `/next` loader's seed). */
@@ -38,6 +38,12 @@ export type BookingWizardEvent = {
38
38
  } | {
39
39
  type: 'submit_error';
40
40
  code: NonNullable<WizardState['error']>;
41
+ } |
42
+ /** A read the time step waits on failed: `status` is `null` when nothing answered. */
43
+ {
44
+ type: 'read_error';
45
+ endpoint: BookingReadEndpoint;
46
+ status: number | null;
41
47
  };
42
48
  /**
43
49
  * The wizard, behind its own Suspense boundary: `useSearchParams` throws to
@@ -159,6 +159,19 @@ function BookingWizardShell(props) {
159
159
  });
160
160
  lastError.current = state.error;
161
161
  }, [state.error, onEvent]);
162
+ const { readErrors } = booking;
163
+ const lastReadSeq = useRef(0);
164
+ useEffect(() => {
165
+ for (const failure of readErrors) {
166
+ if (failure.seq <= lastReadSeq.current) continue;
167
+ lastReadSeq.current = failure.seq;
168
+ onEvent?.({
169
+ type: "read_error",
170
+ endpoint: failure.endpoint,
171
+ status: failure.status
172
+ });
173
+ }
174
+ }, [readErrors, onEvent]);
162
175
  const confirmedCount = confirmed?.bookings.length ?? 0;
163
176
  useEffect(() => {
164
177
  if (confirmedCount > 0) onEvent?.({
@@ -394,6 +407,7 @@ function WhenStep({ booking, resolved }) {
394
407
  onPick: booking.pickResource,
395
408
  firstAvailableFaces: config.screens.firstAvailableFaces,
396
409
  edgeFade: config.screens.stylistEdgeFade,
410
+ row: config.screens.wideLayouts,
397
411
  party: party ? {
398
412
  mode: state.partyMode,
399
413
  size: items.length,
@@ -426,6 +440,7 @@ function WhenStep({ booking, resolved }) {
426
440
  onPick: booking.pickSlot,
427
441
  soonest: config.screens.soonest ? { resolveStylist: (resourceId) => stylistFace(catalogue.resources, resourceId) } : void 0,
428
442
  dayFullness: config.screens.dayFullness,
443
+ layout: config.screens.wideLayouts ? "adaptive" : "stack",
429
444
  party: party ? {
430
445
  items,
431
446
  mode: state.partyMode,