@medalsocial/booking 0.2.1 → 0.3.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 (55) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +21 -3
  3. package/dist/core/attempt-store.d.mts +1 -0
  4. package/dist/core/attempt-store.mjs +13 -0
  5. package/dist/core/attempt-store.mjs.map +1 -1
  6. package/dist/core/config.d.mts +37 -0
  7. package/dist/core/config.mjs +29 -0
  8. package/dist/core/config.mjs.map +1 -1
  9. package/dist/core/machine.d.mts +27 -1
  10. package/dist/core/machine.mjs +52 -4
  11. package/dist/core/machine.mjs.map +1 -1
  12. package/dist/next/medal.d.mts +8 -1
  13. package/dist/next/medal.mjs +16 -5
  14. package/dist/next/medal.mjs.map +1 -1
  15. package/dist/next/portal/medal-portal.mjs +1 -62
  16. package/dist/next/portal/medal-portal.mjs.map +1 -1
  17. package/dist/next/redact.mjs +76 -0
  18. package/dist/next/redact.mjs.map +1 -0
  19. package/dist/next/routes/create.mjs +67 -9
  20. package/dist/next/routes/create.mjs.map +1 -1
  21. package/dist/next/routes/shared.mjs +3 -0
  22. package/dist/next/routes/shared.mjs.map +1 -1
  23. package/dist/react/BookingWizard.mjs +143 -11
  24. package/dist/react/BookingWizard.mjs.map +1 -1
  25. package/dist/react/LoginSheet.d.mts +19 -1
  26. package/dist/react/LoginSheet.mjs +15 -1
  27. package/dist/react/LoginSheet.mjs.map +1 -1
  28. package/dist/react/Provider.d.mts +2 -1
  29. package/dist/react/Provider.mjs.map +1 -1
  30. package/dist/react/actions.d.mts +16 -5
  31. package/dist/react/actions.mjs +5 -1
  32. package/dist/react/actions.mjs.map +1 -1
  33. package/dist/react/index.d.mts +2 -2
  34. package/dist/react/kit.d.mts +6 -1
  35. package/dist/react/kit.mjs +4 -3
  36. package/dist/react/kit.mjs.map +1 -1
  37. package/dist/react/labels/login.d.mts +2 -0
  38. package/dist/react/labels/login.mjs +4 -2
  39. package/dist/react/labels/login.mjs.map +1 -1
  40. package/dist/react/labels/screens.mjs +58 -2
  41. package/dist/react/labels/screens.mjs.map +1 -1
  42. package/dist/react/labels/wizard.d.mts +8 -0
  43. package/dist/react/labels/wizard.mjs +6 -0
  44. package/dist/react/labels/wizard.mjs.map +1 -1
  45. package/dist/react/screen-labels.mjs +5 -0
  46. package/dist/react/screen-labels.mjs.map +1 -1
  47. package/dist/react/useBooking.d.mts +19 -1
  48. package/dist/react/useBooking.mjs +269 -42
  49. package/dist/react/useBooking.mjs.map +1 -1
  50. package/dist/react/wizard/adapters.mjs +66 -3
  51. package/dist/react/wizard/adapters.mjs.map +1 -1
  52. package/dist/react/wizard/index.d.mts +2 -2
  53. package/dist/react/wizard/wall-clock.mjs +24 -0
  54. package/dist/react/wizard/wall-clock.mjs.map +1 -0
  55. package/package.json +4 -4
@@ -213,9 +213,10 @@ function toMedalBody(body) {
213
213
  * still book — a per-request UUID, exactly as before — they simply do not get
214
214
  * the guarantee. Refusing them would turn a missing nicety into an outage.
215
215
  */
216
- async function idempotencyKeyFor(nonce, body) {
216
+ async function idempotencyKeyFor(nonce, body, contactId) {
217
217
  if (typeof nonce !== "string" || nonce.trim().length === 0) return crypto.randomUUID();
218
- const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(`${nonce}${JSON.stringify(body)}`));
218
+ const material = `${nonce}${JSON.stringify(body)}${contactId === null ? "" : `\u0000contact:${contactId}`}`;
219
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(material));
219
220
  return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
220
221
  }
221
222
  /**
@@ -330,15 +331,27 @@ async function createRoute(rt, request) {
330
331
  /* v8 ignore next */
331
332
  const requestedIds = requestedPersonIds(parsed.items ?? []);
332
333
  const keyBody = withIds(mapped.body, requestedIds, null);
333
- const checked = await withPersons(rt, mapped.body, requestedIds);
334
- if (checked === null) return NextResponse.json({
334
+ const nonce = parsed.submissionNonce;
335
+ const booking = rt.config.account.required ? await sessionBooking(rt, mapped.body, requestedIds) : await withPersons(rt, mapped.body, requestedIds).then((body) => body === null ? null : {
336
+ body,
337
+ session: null,
338
+ contactId: null
339
+ });
340
+ if (booking === null) return NextResponse.json({
335
341
  error: "upstreamError",
336
342
  retryable: true
337
343
  }, { status: 503 });
338
- mapped.body = checked;
344
+ if (booking === "accountRequired") return accountRequired();
345
+ mapped.body = booking.body;
339
346
  try {
340
- const idempotencyKey = await idempotencyKeyFor(parsed.submissionNonce, keyBody);
341
- const result = await rt.medal.createBooking(mapped.body, idempotencyKey);
347
+ const idempotencyKey = await idempotencyKeyFor(nonce, keyBody, booking.contactId);
348
+ let result;
349
+ try {
350
+ result = booking.session === null ? await rt.medal.createBooking(mapped.body, idempotencyKey) : await rt.medal.createBooking(mapped.body, idempotencyKey, { portalSession: booking.session });
351
+ } catch (error) {
352
+ if (booking.session !== null && isDeadPortalSession(error)) return accountRequired();
353
+ throw error;
354
+ }
342
355
  const booked = Array.isArray(result.bookings) ? result.bookings : [];
343
356
  const ids = booked.map((booking) => booking?.id);
344
357
  const whole = booked.length === mapped.body.items.length && ids.every((id) => typeof id === "string" && id.length > 0) && new Set(ids).size === ids.length;
@@ -370,6 +383,14 @@ async function createRoute(rt, request) {
370
383
  return createErrorResponse(rt, error);
371
384
  }
372
385
  }
386
+ /** Under `account.required`, the answer to a submission nobody is logged in for. */
387
+ function accountRequired() {
388
+ return bookingErrorResponse("accountRequired", 401, "Log in to book");
389
+ }
390
+ /** Medal's 401 for a portal session it no longer honours (or never issued). */
391
+ function isDeadPortalSession(error) {
392
+ return error instanceof MedalApiError && error.status === 401 && (error.code === "PORTAL_SESSION_REQUIRED" || error.code === "PORTAL_SESSION_INVALID");
393
+ }
373
394
  /** Every service a submission books — each visit's extras too, since a
374
395
  * visit's slot entry is tagged with all of its services and a write to any of
375
396
  * them must retire it. */
@@ -379,7 +400,8 @@ function bookedServiceIds(body) {
379
400
  /** Medal's person ids are Convex ids; anything else is not one worth asking about. */
380
401
  const PERSON_ID_SHAPE = /^[A-Za-z0-9_-]{1,64}$/;
381
402
  /**
382
- * `booked_for_person_id` on each line — THE PHONE RULE (SP10).
403
+ * `booked_for_person_id` on each line — THE PHONE RULE (SP10), for a site
404
+ * without `account.required` (with it, see `sessionBooking`).
383
405
  *
384
406
  * Medal resolves a booking's contact by the PHONE first, then the e-mail, and
385
407
  * a person id is only valid on the contact who owns that child: an id sent
@@ -414,6 +436,42 @@ async function withPersons(rt, body, ids) {
414
436
  if (owned.size === 0) return body;
415
437
  return withIds(body, ids, owned);
416
438
  }
439
+ /**
440
+ * Under `account.required`: the logged-in parent's booking, on THEIR contact.
441
+ *
442
+ * The session goes to Medal (`X-Portal-Session`), and Medal books on the
443
+ * session's contact whatever phone was typed — so the phone rule has nothing
444
+ * to say here, and a person id is checked against the session's own family
445
+ * alone. The profile is read for every submission, ids or none: it is what
446
+ * proves the session is still alive before anything is booked, and its
447
+ * `contactId` is the half of the idempotency key that says WHOSE attempt this
448
+ * is (see `idempotencyKeyFor`).
449
+ *
450
+ * `'accountRequired'` when there is no session or Medal no longer honours it —
451
+ * an answer, and the same one on a retry. `null` when the session or the
452
+ * profile could not be read at all (an outage, a throttle): a 503 before
453
+ * anything is booked, as for the phone rule's read.
454
+ */
455
+ async function sessionBooking(rt, body, ids) {
456
+ try {
457
+ const session = await rt.session.readPortalSession();
458
+ if (session === null) return "accountRequired";
459
+ const profile = await rt.portal.getMe(session);
460
+ return {
461
+ body: withIds(body, ids, familyIds(profile.family)),
462
+ session,
463
+ contactId: profile.contactId
464
+ };
465
+ } catch (error) {
466
+ if (error instanceof PortalSessionExpiredError) return "accountRequired";
467
+ rt.logger.warn({ err: error }, "Could not read the booking’s portal session; asking for a retry");
468
+ return null;
469
+ }
470
+ }
471
+ /** The person ids a profile's family has. */
472
+ function familyIds(family) {
473
+ return new Set(family.flatMap((member) => member.personId === null ? [] : [member.personId]));
474
+ }
417
475
  /** The ids the client sent, one per line, `null` where none (or not an id). */
418
476
  function requestedPersonIds(requested) {
419
477
  return requested.map((item) => {
@@ -447,7 +505,7 @@ async function ownedPersonIds(rt, phone) {
447
505
  const profile = await rt.portal.getMe(session);
448
506
  if (profile.phone === null) return /* @__PURE__ */ new Set();
449
507
  if (nationalDigits(profile.phone) !== nationalDigits(phone)) return /* @__PURE__ */ new Set();
450
- return new Set(profile.family.flatMap((member) => member.personId === null ? [] : [member.personId]));
508
+ return familyIds(profile.family);
451
509
  } catch (error) {
452
510
  if (error instanceof PortalSessionExpiredError) return /* @__PURE__ */ new Set();
453
511
  rt.logger.warn({ err: error }, "Could not check the booking’s person ids; asking for a retry");
@@ -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(nonce: unknown, body: CreateBookingBody): Promise<string> {\n if (typeof nonce !== 'string' || nonce.trim().length === 0) return crypto.randomUUID();\n const digest = await crypto.subtle.digest(\n 'SHA-256',\n new TextEncoder().encode(`${nonce}${JSON.stringify(body)}`)\n );\n return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('');\n}\n\n/**\n * The marketing box on «Bekreft», filed as its own record — and never at the\n * booking's expense.\n *\n * Three rules, all of them load-bearing:\n *\n * - Only when the box was ticked. `granted: false` is a *withdrawal*, and\n * writing one for every parent who left the box alone would overwrite the\n * consent they gave last time they booked.\n * - Only when there is an e-mail address. The record is keyed on it, and\n * consent to be e-mailed without somewhere to e-mail is not a consent to\n * anything.\n * - Never fatal. The appointment is what the customer came for; a consent\n * endpoint that is down, or an API key scoped to bookings alone, must cost\n * the salon a mailing-list entry and not a haircut. Logged at `warn` so the\n * silence is at least visible from the outside.\n *\n * Scheduled with `after` rather than awaited or floated, and both of the other\n * two are worse in their own direction. AWAITED, this sat between the booking\n * and the 201 with no timeout under it, so a consent endpoint that accepted the\n * connection and never answered withheld a booking that had already been made —\n * the browser gives up, the visitor reloads, `DetailsStep` remounts and mints a\n * fresh submission nonce, and the retry derives a different idempotency key and\n * books the child a second time. A try/catch does nothing about a stall.\n * FLOATED, on Workers a promise nobody is holding when the response goes out is\n * cancelled, so the consent would be written on a fast connection and lost on a\n * slow one. `after` is the one that answers first and still finishes the work:\n * on this runtime it lands on `ctx.waitUntil`, which the opennextjs-cloudflare\n * wrapper holds the whole request with — so the isolate stays alive for the\n * write while the visitor is already reading their confirmation.\n */\nasync function recordMarketingConsent(\n rt: BookingRuntime,\n email: string | undefined,\n granted: unknown\n): Promise<void> {\n const consent = marketingConsent(rt.config);\n // No marketing box on this site: nothing anybody ticked, nothing to file.\n if (granted !== true || email === undefined || consent === null) return;\n try {\n await rt.medal.recordConsent({\n email,\n consent_type: 'marketing_email',\n granted: true,\n source: consent.source,\n // The sentence that was actually on the screen, so what they agreed to is\n // recoverable years later rather than inferred from a boolean.\n consent_text: consent.text,\n version: consent.version,\n });\n } catch (error) {\n rt.logger.warn({ err: error }, 'Marketing consent was not recorded');\n }\n}\n\n/**\n * Every way a submission can fail, in the vocabulary `WizardState.error`\n * already speaks. The codes are consumed as-is, so the spelling is part of the\n * contract rather than a description of it.\n *\n * `VALIDATION_ERROR` and not just `INVALID_INPUT`: the engine raises\n * `ErrorCode.INVALID_INPUT` internally, but `withBookingApiErrors` routes it\n * through `validationError`, so what actually reaches the wire is `422\n * VALIDATION_ERROR`. Matching on the internal name alone would be a branch that\n * never runs — and every rejected body would surface as a bare 502.\n *\n * The 400 is deliberate where the upstream said 422: to the browser this IS a\n * bad request, and the wizard has one `invalidInput` state for it either way.\n */\nfunction createErrorResponse(rt: BookingRuntime, error: unknown): NextResponse {\n if (error instanceof MedalConfigError) {\n rt.logger.error({ err: error }, 'Booking API is not configured');\n return bookingErrorResponse('unconfigured', 503);\n }\n\n if (error instanceof MedalApiError) {\n // This submission's key already names a booking attempt — still running,\n // or made with a body this one does not match (an older wizard, a check\n // that answered differently). Either way something may be booked, and a\n // second try under the same key cannot book it again: «check your e-mail»,\n // not «try again». Logged, because a key conflict should not happen.\n if (error.code === 'IDEMPOTENCY_KEY_CONFLICT' || error.code === 'IDEMPOTENCY_IN_PROGRESS') {\n rt.logger.warn({ err: error }, 'Booking create hit an idempotency key already in use');\n return bookingErrorResponse('inProgress', 409);\n }\n if (error.code === 'CONFLICT') {\n // Two different answers on the time step: `slotTaken` sends the visitor back\n // with the neighbouring times highlighted, which a duplicate-key or\n // unseatable-party conflict has no slot to do. The engine distinguishes\n // them only in the message, so this is the seam. (`createRoute` answers a\n // taken slot itself, with the live openings, before it gets here.)\n /* v8 ignore next -- the slotTaken side is kept as moved; createRoute answers it first */\n return bookingErrorResponse(isSlotTaken(error) ? 'slotTaken' : 'conflict', 409);\n }\n if (error.code === 'VALIDATION_ERROR' || error.code === 'INVALID_INPUT') {\n return bookingErrorResponse('invalidInput', 400);\n }\n }\n\n rt.logger.error({ err: error }, 'Booking create failed');\n return bookingErrorResponse('upstreamError', 502);\n}\n\n/**\n * The create body's ceiling, read in bytes before anything parses it (as the\n * portal routes cap theirs). A family of three is about two kilobytes; this\n * leaves room for the largest party a site may configure and a long note,\n * and refuses the megabyte nobody's wizard sends.\n */\nconst CREATE_MAX_BODY_BYTES = 32 * 1024;\n\n/**\n * One submission, one booking — or one family, all-or-nothing, when `items`\n * carries more than one child.\n *\n * `Idempotency-Key` is what makes a double-tapped «Bekreft» on a flaky mobile\n * connection book once instead of twice, and it only does that if the second\n * tap derives the *same* key as the first. It cannot be minted here for that\n * reason: a fresh UUID per POST is a fresh key per tap, which is two bookings.\n * So the wizard sends the nonce and this route mixes the body in —\n * `idempotencyKeyFor` explains why it takes both.\n */\nexport async function createRoute(rt: BookingRuntime, request: Request): Promise<Response> {\n let parsed: unknown;\n try {\n const text = await readBoundedText(request, CREATE_MAX_BODY_BYTES);\n if (text === null) return invalid('body is too large');\n parsed = JSON.parse(text);\n } catch {\n // A body that will not parse is the browser's fault, not Medal's. Letting\n // it fall into the catch below would answer 502 and point the salon at an\n // outage that is not happening.\n return invalid('body must be valid JSON');\n }\n\n if (typeof parsed !== 'object' || parsed === null) {\n return invalid('body must be a JSON object');\n }\n\n const mapped = toMedalBody(parsed as CreateRequest);\n if ('error' in mapped) return mapped.error;\n // ONE SUBMISSION, ONE KEY, ONE BODY. The key is derived from what the\n // CLIENT asked for — ids included, before the server checks them — and the\n // body Medal is sent is a pure function of that request and the profile.\n // Medal's idempotency store compares the body's hash under a key: the same\n // key with a different body is 409 IDEMPOTENCY_KEY_CONFLICT, not a replay.\n // So a profile read that FAILS is not «send the names» — that would make\n // the retry, whose read succeeds, a different body under the same key — it\n // is a 503 before anything is booked, and the retry runs the same check.\n // `toMedalBody` has already refused a missing or empty `items`; the `?? []`\n // is kept as moved.\n /* v8 ignore next */\n const requestedIds = requestedPersonIds((parsed as CreateRequest).items ?? []);\n const keyBody = withIds(mapped.body, requestedIds, null);\n const checked = await withPersons(rt, mapped.body, requestedIds);\n if (checked === null) {\n return NextResponse.json({ error: 'upstreamError', retryable: true }, { status: 503 });\n }\n mapped.body = checked;\n\n try {\n // Inside the try, though nothing about a SHA-256 of a string this route\n // just built should throw: if the runtime ever disagrees, a logged 502 and\n // «timen ble ikke satt opp» is true — nothing was booked — where an\n // unhandled rejection would be a bare 500 with no line in the log saying\n // which booking it was.\n const idempotencyKey = await idempotencyKeyFor(\n (parsed as CreateRequest).submissionNonce,\n keyBody\n );\n const result = await rt.medal.createBooking(mapped.body, idempotencyKey);\n const booked = Array.isArray(result.bookings) ? result.bookings : [];\n // A null entry is a short answer, not a throw: the caches below must still go.\n const ids = booked.map((booking) => (booking as { id?: unknown } | null)?.id);\n const whole =\n booked.length === mapped.body.items.length &&\n ids.every((id) => typeof id === 'string' && id.length > 0) &&\n new Set(ids).size === ids.length;\n // The slots just taken are in the slot cache and in this colo's booking\n // seeds; expire both so neither this parent going back nor the next\n // visitor is offered them again.\n rt.catalogue.expireSlots(bookedServiceIds(mapped.body));\n await rt.seed.expireBookingSeeds(bookedServiceIds(mapped.body));\n // ONE BOOKING PER LINE, each with its own DISTINCT id, or no confirmation: a short\n // answer would hand the wizard lines with no booking behind them (and\n // calendar entries with no UID of their own). Something may be booked, so\n // the caches above still go; the answer is the generic create failure,\n // whose attempt the wizard keeps for the replay under the same key — and\n // no consent is filed for an appointment nobody can confirm.\n if (!whole) {\n rt.logger.error(\n { expected: mapped.body.items.length, received: booked.length },\n 'Booking create answered without one booking per line'\n );\n return bookingErrorResponse('upstreamError', 502);\n }\n // After the booking, never before: a consent recorded for an appointment\n // that then failed to exist is a mailing-list entry the customer never\n // agreed to give. `after` keeps that ordering — it is scheduled here, so it\n // cannot run for a `createBooking` that threw — while taking the write out\n // from between the appointment and the answer.\n after(() =>\n recordMarketingConsent(\n rt,\n mapped.body.contact.email,\n (parsed as CreateRequest).consentMarketing\n )\n );\n // Deliberately no start time: the endpoint does not send one back, and the\n // confirmation screen renders the slot the visitor picked. Echoing a time\n // from here would give it a second source to disagree with.\n return NextResponse.json(\n {\n bookings: booked.map((booking) => ({\n id: booking.id,\n manageToken: booking.manage_token,\n })),\n },\n { status: 201 }\n );\n } catch (error) {\n // SLOT_TAKEN means the cache offered a slot that was already gone. Expired\n // so the time step this visitor is sent back to is drawn from a live read.\n if (isSlotTaken(error)) {\n const serviceIds = bookedServiceIds(mapped.body);\n rt.catalogue.expireSlots(serviceIds);\n await rt.seed.expireBookingSeeds(serviceIds);\n // And the answer carries the live openings, so the wizard redraws the\n // time step from them rather than re-reading through the cache.\n const freshSlots = await liveSlotsFor(\n rt,\n mapped.body.items,\n slotWindow(rt, (parsed as CreateRequest).window)\n );\n return NextResponse.json({ error: 'slotTaken', freshSlots }, { status: 409 });\n }\n return createErrorResponse(rt, error);\n }\n}\n\n/** Every service a submission books — each visit's extras too, since a\n * visit's slot entry is tagged with all of its services and a write to any of\n * them must retire it. */\nfunction bookedServiceIds(body: CreateBookingBody): string[] {\n return body.items.flatMap((item) => [item.service_id, ...(item.extra_service_ids ?? [])]);\n}\n\n/** Medal's person ids are Convex ids; anything else is not one worth asking about. */\nconst PERSON_ID_SHAPE = /^[A-Za-z0-9_-]{1,64}$/;\n\n/**\n * `booked_for_person_id` on each line — THE PHONE RULE (SP10).\n *\n * Medal resolves a booking's contact by the PHONE first, then the e-mail, and\n * a person id is only valid on the contact who owns that child: an id sent\n * under a number that resolves to somebody else is a 404 for the whole\n * booking. So an id is forwarded only when all of this holds, and otherwise\n * the line goes as it always did — by name and year:\n *\n * - the browser holds a portal session (the `portal.cookieName` cookie),\n * - Medal still honours it and names the parent's phone,\n * - that phone and the one submitted are the same number (national digits),\n * - and the id is one of THAT parent's children.\n *\n * The browser already applies the phone rule before it sends an id; this is\n * the server not trusting it, because the route is reachable without the\n * wizard and a wrong id costs the whole family's booking.\n *\n * DETERMINISTIC, for the idempotency key's sake (see `POST`): ids are dropped\n * only when the check SUCCEEDED and said no — no session, an expired one, a\n * profile with another phone, a child who is not theirs — which a retry\n * against the same profile says again. A check that could not be made is\n * `null`, answered 503 without booking.\n *\n * THE COST: one extra round trip to Medal (`GET /portal/me`, with the\n * session) before the booking — only for a submission that carries an id,\n * which only a logged-in parent's wizard sends. A booking by names asks\n * nothing extra.\n */\nasync function withPersons(\n rt: BookingRuntime,\n body: CreateBookingBody,\n ids: ReadonlyArray<string | null>\n): Promise<CreateBookingBody | null> {\n if (ids.every((id) => id === null)) return body;\n const owned = await ownedPersonIds(rt, body.contact.phone);\n if (owned === null) return null;\n if (owned.size === 0) return body;\n return withIds(body, ids, owned);\n}\n\n/** The ids the client sent, one per line, `null` where none (or not an id). */\nfunction requestedPersonIds(requested: NonNullable<CreateRequest['items']>): Array<string | null> {\n return requested.map((item) => {\n const id = item?.bookedForPersonId;\n return typeof id === 'string' && PERSON_ID_SHAPE.test(id) ? id : null;\n });\n}\n\n/** `body` with each line's id set — all of them, or only those in `allowed`. */\nfunction withIds(\n body: CreateBookingBody,\n ids: ReadonlyArray<string | null>,\n allowed: ReadonlySet<string> | null\n): CreateBookingBody {\n if (ids.every((id) => id === null)) return body;\n return {\n ...body,\n items: body.items.map((item, index) => {\n const id = ids[index] ?? null;\n return id !== null && (allowed === null || allowed.has(id))\n ? { ...item, booked_for_person_id: id }\n : item;\n }),\n };\n}\n\n/**\n * The logged-in parent's children's ids — empty unless their phone is `phone`,\n * `null` when Medal could not be asked (an outage, a throttle): not an answer.\n */\nasync function ownedPersonIds(rt: BookingRuntime, phone: string): Promise<Set<string> | null> {\n const { nationalDigits } = rt.phone;\n try {\n const session = await rt.session.readPortalSession();\n if (session === null) return new Set();\n const profile = await rt.portal.getMe(session);\n if (profile.phone === null) return new Set();\n if (nationalDigits(profile.phone) !== nationalDigits(phone)) return new Set();\n return new Set(\n profile.family.flatMap((member) => (member.personId === null ? [] : [member.personId]))\n );\n } catch (error) {\n // A session Medal no longer honours is an answer, and the same one on a\n // retry: nobody is logged in, so the booking goes by names.\n if (error instanceof PortalSessionExpiredError) return new Set();\n // The seam scrubs the session.\n rt.logger.warn({ err: error }, 'Could not check the booking’s person ids; asking for a retry');\n return null;\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"create.mjs","names":[],"sources":["../../../src/next/routes/create.ts"],"sourcesContent":["/**\n * `POST <api>/create` — one submission, one booking, or one family,\n * all-or-nothing.\n *\n * The logic is the source site's create route, moved unchanged: the\n * idempotency key from the wizard's nonce and the canonical body, the\n * marketing consent filed through `after()` once the booking exists, the live\n * re-read of openings on a taken slot, and the slot-cache and seed expiry on\n * every write.\n */\n\nimport { MedalApiError } from '@medalsocial/sdk';\nimport { after, NextResponse } from 'next/server';\nimport { marketingConsent } from '../../core/consent';\nimport type { BookingSlotDto } from '../../core/types';\nimport { visitKeyOfIds } from '../../core/visit';\nimport { type CreateBookingBody, MedalConfigError } from '../medal';\nimport { PortalSessionExpiredError } from '../portal/medal-portal';\nimport { readBoundedText } from '../request';\nimport type { BookingRuntime } from '../runtime';\nimport { 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"}
@@ -10,6 +10,9 @@ import { NextResponse } from "next/server";
10
10
  * 'unconfigured' | 'upstreamError'` — so the spelling here is load-bearing
11
11
  * rather than descriptive.
12
12
  *
13
+ * The create route also answers `'inProgress'` (409, a key Medal already holds)
14
+ * and `'accountRequired'` (401, `account.required` with nobody logged in).
15
+ *
13
16
  * `upstreamError` is the catch-all. Something has to be in the field either
14
17
  * way, because `NextResponse.json({ error: undefined })` serialises to `{}`
15
18
  * and reads as a failure with no reason at all.
@@ -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 * `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":"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"}
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"}