@medalsocial/booking 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/README.md +2 -2
- package/dist/core/config.d.mts +7 -0
- package/dist/core/config.mjs +4 -2
- package/dist/core/config.mjs.map +1 -1
- package/dist/core/draft-store.d.mts +6 -0
- package/dist/core/draft-store.mjs +17 -1
- package/dist/core/draft-store.mjs.map +1 -1
- package/dist/core/index.d.mts +3 -2
- package/dist/core/index.mjs +3 -2
- package/dist/core/machine.d.mts +53 -6
- package/dist/core/machine.mjs +162 -52
- package/dist/core/machine.mjs.map +1 -1
- package/dist/core/party-slots.d.mts +9 -2
- package/dist/core/party-slots.mjs +18 -12
- package/dist/core/party-slots.mjs.map +1 -1
- package/dist/core/types.d.mts +4 -0
- package/dist/core/visit.d.mts +26 -0
- package/dist/core/visit.mjs +40 -0
- package/dist/core/visit.mjs.map +1 -0
- package/dist/next/catalogue.mjs +25 -7
- package/dist/next/catalogue.mjs.map +1 -1
- package/dist/next/medal.d.mts +7 -0
- package/dist/next/medal.mjs +2 -1
- package/dist/next/medal.mjs.map +1 -1
- package/dist/next/routes/catalogue-routes.mjs +18 -4
- package/dist/next/routes/catalogue-routes.mjs.map +1 -1
- package/dist/next/routes/create.mjs +47 -9
- package/dist/next/routes/create.mjs.map +1 -1
- package/dist/next/routes/manage.mjs.map +1 -1
- package/dist/next/routes/shared.mjs +29 -1
- package/dist/next/routes/shared.mjs.map +1 -1
- package/dist/react/BookingWizard.mjs +32 -11
- package/dist/react/BookingWizard.mjs.map +1 -1
- package/dist/react/labels/screens.d.mts +1 -1
- package/dist/react/labels/screens.mjs +12 -0
- package/dist/react/labels/screens.mjs.map +1 -1
- package/dist/react/labels/wizard.d.mts +7 -0
- package/dist/react/labels/wizard.mjs +2 -0
- package/dist/react/labels/wizard.mjs.map +1 -1
- package/dist/react/useBooking.d.mts +2 -0
- package/dist/react/useBooking.mjs +194 -53
- package/dist/react/useBooking.mjs.map +1 -1
- package/dist/react/wizard/adapters.mjs +7 -5
- package/dist/react/wizard/adapters.mjs.map +1 -1
- package/dist/react/wizard-error.mjs +29 -0
- package/dist/react/wizard-error.mjs.map +1 -0
- package/package.json +4 -4
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"catalogue-routes.mjs","names":[],"sources":["../../../src/next/routes/catalogue-routes.ts"],"sourcesContent":["/**\n * The read routes the wizard asks while it is on screen: the price list, the\n * stylists (with each one's next opening), free slots, opening hours, the\n * next free start and a stylist's photo.\n *\n * Every guard answers before Medal is called, and every read goes through the\n * data cache (`catalogue.ts`), which the site's own writes expire. There is\n * deliberately no way for a visitor to skip it.\n */\n\nimport { NextResponse } from 'next/server';\nimport { RESOURCE_ID_SHAPE } from '../../core/dto';\nimport { earliestOpening } from '../../core/next-available';\nimport type { MedalResource } from '../../core/wire';\nimport { DEFAULT_AVATAR_HOSTS } from '../options';\nimport type { BookingRuntime } from '../runtime';\nimport { bookingErrorResponse, catalogueErrorResponse, knownServiceId, parseRange } from './shared';\n\n/**\n * The full price list, in the order Medal returns it. Nothing is filtered: a\n * service with `bookable_online: false` is still a service the business sells,\n * and hiding it here would leave the printed price list and the website\n * disagreeing. Read through the catalogue cache (at most ~10 min old).\n */\nexport async function servicesRoute(rt: BookingRuntime): Promise<Response> {\n try {\n const services = await rt.catalogue.cachedServices();\n return NextResponse.json({ services: services.map(rt.dto.toBookingServiceDto) });\n } catch (error) {\n return catalogueErrorResponse(rt, error, 'services');\n }\n}\n\n/**\n * The window the caller asked about, never one this route invents: «next\n * free» has to agree with the day strip on the time step, and the wizard owns\n * that horizon.\n *\n * Best effort, and never fatal. A stylist list with no «next free» lines is a\n * working step; a 502 here would be a screen with nobody on it.\n */\nasync function firstOpeningPerResource(\n rt: BookingRuntime,\n serviceId: string,\n range: { fromTs: number; toTs: number }\n): Promise<Record<string, number>> {\n try {\n // Unfiltered by resource, deliberately: the whole point is one entry per\n // stylist, and `resource_id` would collapse the answer to that one.\n const slots = await rt.catalogue.cachedAvailability({ serviceId, ...range });\n const earliest: Record<string, number> = {};\n for (const slot of slots) {\n // A slot with no stylist cannot fill anybody's line, and an unparseable\n // start would render «Invalid Date».\n if (slot.resource_id === null || slot.start_ts === null) continue;\n const startTs = Date.parse(slot.start_ts);\n if (!Number.isFinite(startTs)) continue;\n const seen = earliest[slot.resource_id];\n if (seen === undefined || startTs < seen) earliest[slot.resource_id] = startTs;\n }\n return earliest;\n } catch (error) {\n rt.logger.warn({ err: error, serviceId }, 'Could not read next-available times for step 2');\n return {};\n }\n}\n\n/**\n * The stylists step 2 offers, and — when asked about a service — the first\n * minute each of them is free. Two answers from one round trip, because step 2\n * is one screen. The availability read is the SAME cached entry the\n * availability route and the booking page use for this service and window.\n */\nexport async function resourcesRoute(rt: BookingRuntime, request: Request): Promise<Response> {\n const { searchParams } = new URL(request.url);\n const rawServiceId = searchParams.get('service_id')?.trim();\n\n // The range is only meaningful alongside a service to ask about. Without one\n // this route is the catalogue alone, and a stray `from_ts` is not worth a 400.\n let range: { fromTs: number; toTs: number } | null = null;\n let serviceId: string | null = null;\n if (rawServiceId) {\n const parsed = parseRange(searchParams);\n if ('error' in parsed) return parsed.error;\n range = parsed;\n const known = await knownServiceId(rt, rawServiceId);\n if ('error' in known) return known.error;\n serviceId = known.serviceId;\n }\n\n // Started before the stylist list is awaited, so the two reads overlap.\n // `firstOpeningPerResource` never rejects.\n const nextAvailable =\n serviceId !== null && range !== null\n ? firstOpeningPerResource(rt, serviceId, range)\n : Promise.resolve({});\n\n let resources: MedalResource[];\n try {\n resources = await rt.catalogue.cachedResources();\n } catch (error) {\n return catalogueErrorResponse(rt, error, 'resources');\n }\n\n return NextResponse.json({\n // Unsorted, in the order Medal returned it: step 2 sorts by `sortOrder`\n // itself, and the party seating uses first-encounter order as its\n // tie-break.\n resources: resources.map(rt.dto.toBookingResourceDto),\n nextAvailableTs: await nextAvailable,\n });\n}\n\n/**\n * Free slots for one service over one range, optionally narrowed to the\n * stylist chosen on step 2. Read through the slot cache (at most ~60 s old),\n * which the site's own create / move / cancel routes expire on success.\n */\nexport async function availabilityRoute(rt: BookingRuntime, request: Request): Promise<Response> {\n const { searchParams } = new URL(request.url);\n\n const rawServiceId = searchParams.get('service_id')?.trim();\n if (!rawServiceId) {\n return bookingErrorResponse('invalidInput', 400, 'service_id is required');\n }\n\n const range = parseRange(searchParams);\n if ('error' in range) return range.error;\n\n const known = await knownServiceId(rt, rawServiceId);\n if ('error' in known) return known.error;\n const { serviceId } = known;\n\n const resourceId = searchParams.get('resource_id')?.trim();\n\n try {\n const slots = await rt.catalogue.cachedAvailability({\n serviceId,\n // Omitted rather than passed as an empty string: `?resource_id=` with\n // nothing after it means «first available».\n ...(resourceId ? { resourceId } : {}),\n fromTs: range.fromTs,\n toTs: range.toTs,\n });\n return NextResponse.json({ slots: slots.flatMap(rt.dto.toBookingSlotDto) });\n } catch (error) {\n return catalogueErrorResponse(rt, error, 'availability');\n }\n}\n\n/**\n * The open dates over one range, optionally narrowed to one stylist — the half\n * availability cannot answer: it returns free slots and nothing else, so a\n * closed Sunday, an evening after closing and a booked-out Thursday all arrive\n * as the same empty array. A date ABSENT from the response is one the business\n * keeps no hours on. Opening hours are per stylist, so the filter matters here\n * as much as on availability.\n */\nexport async function scheduleRoute(rt: BookingRuntime, request: Request): Promise<Response> {\n const { searchParams } = new URL(request.url);\n\n const rawServiceId = searchParams.get('service_id')?.trim();\n if (!rawServiceId) {\n return bookingErrorResponse('invalidInput', 400, 'service_id is required');\n }\n\n const range = parseRange(searchParams);\n if ('error' in range) return range.error;\n\n const known = await knownServiceId(rt, rawServiceId);\n if ('error' in known) return known.error;\n const { serviceId } = known;\n\n const resourceId = searchParams.get('resource_id')?.trim();\n\n try {\n const days = await rt.catalogue.cachedSchedule({\n ...(resourceId ? { resourceId } : {}),\n serviceId,\n fromTs: range.fromTs,\n toTs: range.toTs,\n });\n return NextResponse.json({ days: days.flatMap(rt.dto.toBookingDayDto) });\n } catch (error) {\n return catalogueErrorResponse(rt, error, 'schedule');\n }\n}\n\n/**\n * «Next free: today 11:00» — the next free start for the default prefetch\n * set, for a sticky booking bar or a hero. Read from the booking seed the\n * booking page itself renders from, so it costs no Medal call of its own and\n * cannot name a time the wizard will not show. `startTs: null` when nothing is\n * free or the seed cannot be read: the callers then simply say nothing.\n *\n * The label is formatted here, on the business's clock, so a visitor abroad\n * and the server agree on what «today» means.\n */\nexport async function nextFreeRoute(rt: BookingRuntime): Promise<Response> {\n const now = Date.now();\n try {\n const seed = await rt.seed.loadBookingSeed(undefined, now);\n const startTs = earliestOpening(seed.slots, now);\n return NextResponse.json(\n { startTs, label: startTs === null ? null : rt.clock.when(startTs, now) },\n // Short and private: the answer turns over with the seed's 30 s bucket.\n { headers: { 'Cache-Control': 'private, max-age=30' } }\n );\n } catch (error) {\n rt.logger.warn({ err: error }, 'next-free: booking seed unavailable');\n return NextResponse.json(\n { startTs: null, label: null },\n { headers: { 'Cache-Control': 'no-store' } }\n );\n }\n}\n\nconst AVATAR_CACHE_CONTROL = 'public, max-age=86400, stale-while-revalidate=604800';\n\nfunction uncached(status: number): NextResponse {\n return new NextResponse(null, { status, headers: { 'Cache-Control': 'no-store' } });\n}\n\n/** How long the photo host gets before the card falls back to initials: a hung\n * read must not hold a request open. */\nconst UPSTREAM_TIMEOUT_MS = 5000;\n\n/** FNV-1a, 32-bit, twice with different seeds — synchronous and plenty for a\n * cache validator; nothing here is a secret. */\nfunction etagFor(objectKey: string): string {\n const hash = (seed: number) => {\n let h = seed;\n for (let i = 0; i < objectKey.length; i += 1) {\n h ^= objectKey.charCodeAt(i);\n h = Math.imul(h, 0x01000193);\n }\n return (h >>> 0).toString(16).padStart(8, '0');\n };\n return `\"${hash(0x811c9dc5)}${hash(0x01000193)}\"`;\n}\n\n/**\n * Names that never leave the machine or the private network, whatever a site\n * lists: a photo fetched from one of them would be this server probing its own\n * neighbourhood on behalf of whoever can edit a stylist's photo.\n */\nconst INTERNAL_NAME = /(^|\\.)(localhost|local|internal|intranet|lan|home\\.arpa)$/;\n/** WHATWG URL normalises every IPv4 spelling (`2130706433`, `0x7f.1`) to dotted quads. */\nconst IPV4 = /^\\d{1,3}(\\.\\d{1,3}){3}$/;\n\n/** Whether `hostname` (already lower-cased by `URL`) is one the site allows. */\nfunction allowedPhotoHost(hostname: string, allowed: readonly string[]): boolean {\n // An IPv6 literal, an IPv4 one, a single label or an internal name: never.\n if (hostname.startsWith('[') || IPV4.test(hostname) || !hostname.includes('.')) return false;\n if (INTERNAL_NAME.test(hostname)) return false;\n return allowed.some((entry) => {\n const pattern = entry.trim().toLowerCase();\n if (pattern.startsWith('*.')) {\n const suffix = pattern.slice(1);\n // `*.` alone, or `*.com`, would be every host there is.\n return suffix.lastIndexOf('.') > 0 && hostname.endsWith(suffix);\n }\n return pattern.length > 0 && hostname === pattern;\n });\n}\n\n/**\n * The object, without the signature: origin and path. `null` for a URL this\n * server will not fetch — anything but `https:` on the default port, with no\n * credentials, on a host the site allows (`options.avatarHosts`).\n *\n * ASSUMPTION: the ETag is only a content validator because Medal stores each\n * uploaded avatar under a fresh key, so a new photo is a new path.\n */\nfunction objectKeyOf(photoUrl: string, allowed: readonly string[]): string | null {\n let url: URL;\n try {\n url = new URL(photoUrl);\n } catch {\n return null;\n }\n if (url.protocol !== 'https:' || url.port !== '' || url.username !== '' || url.password !== '') {\n return null;\n }\n return allowedPhotoHost(url.hostname, allowed) ? `${url.origin}${url.pathname}` : null;\n}\n\n/**\n * The raster formats a stylist photo may be. Anything else — SVG above all,\n * and HTML or a missing type — is refused rather than served from this\n * origin, where it would be the site's own active content.\n */\nconst RASTER_TYPES = new Set(['image/avif', 'image/gif', 'image/jpeg', 'image/png', 'image/webp']);\n\n/** `Image/PNG; charset=…` → `image/png`; `null` for a type that is not on the list. */\nfunction rasterType(header: string | null): string | null {\n const type = (header ?? '').split(';')[0].trim().toLowerCase();\n return RASTER_TYPES.has(type) ? type : null;\n}\n\nfunction etagMatches(header: string | null, etag: string): boolean {\n if (header === null) return false;\n return header\n .split(',')\n .map((value) => value.trim().replace(/^W\\//, ''))\n .some((value) => value === etag || value === '*');\n}\n\n/**\n * A stylist's photo, from a URL the browser can cache.\n *\n * Medal returns `photo_url` as a presigned URL that is signed afresh on every\n * read, so the wizard used to download every photo on every visit. This route\n * looks the stylist up in the cached catalogue, fetches the object server-side\n * and hands it back under one stable address with a day of browser cache and\n * an ETag keyed to the OBJECT (the URL without its signature).\n */\nexport async function avatarRoute(\n rt: BookingRuntime,\n request: Request,\n resourceId: string\n): Promise<Response> {\n if (!RESOURCE_ID_SHAPE.test(resourceId)) return uncached(400);\n\n let photoUrl: string | null | undefined;\n try {\n photoUrl = (await rt.catalogue.cachedResources()).find(\n (resource) => resource.id === resourceId\n )?.photo_url;\n } catch (error) {\n rt.logger.warn({ err: error, resourceId }, 'Could not read the stylists for an avatar');\n return uncached(502);\n }\n const objectKey = photoUrl\n ? objectKeyOf(photoUrl, rt.options.avatarHosts ?? DEFAULT_AVATAR_HOSTS)\n : null;\n if (!photoUrl || objectKey === null) {\n if (photoUrl)\n rt.logger.warn({ resourceId }, 'Stylist photo is on a host this site does not allow');\n return uncached(404);\n }\n\n const etag = etagFor(objectKey);\n if (etagMatches(request.headers.get('If-None-Match'), etag)) {\n return new NextResponse(null, {\n status: 304,\n headers: { ETag: etag, 'Cache-Control': AVATAR_CACHE_CONTROL },\n });\n }\n\n let upstream: Response;\n try {\n upstream = await fetch(photoUrl, {\n cache: 'no-store',\n // A redirect is answered, never followed: its target was not checked\n // against the allowed hosts. A 3xx is not `ok`, so it is a 502 below.\n redirect: 'manual',\n signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),\n });\n } catch (error) {\n rt.logger.warn({ err: error, resourceId }, 'Could not fetch a stylist photo');\n return uncached(502);\n }\n const contentType = rasterType(upstream.headers.get('Content-Type'));\n // Only raster images are relayed. Whatever the bucket answers is served from\n // THIS origin, and an HTML or SVG body here would be script on the site.\n if (!upstream.ok || contentType === null) {\n if (upstream.ok) {\n rt.logger.warn(\n { resourceId, contentType: upstream.headers.get('Content-Type') },\n 'Stylist photo is not a raster image'\n );\n }\n await upstream.body?.cancel();\n return uncached(502);\n }\n\n return new NextResponse(upstream.body, {\n status: 200,\n headers: {\n 'Content-Type': contentType,\n 'Cache-Control': AVATAR_CACHE_CONTROL,\n ETag: etag,\n 'X-Content-Type-Options': 'nosniff',\n // Belt and braces for a body opened as a document: no script, no\n // subresource, a unique origin.\n 'Content-Security-Policy': \"default-src 'none'; sandbox\",\n },\n });\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"catalogue-routes.mjs","names":[],"sources":["../../../src/next/routes/catalogue-routes.ts"],"sourcesContent":["/**\n * The read routes the wizard asks while it is on screen: the price list, the\n * stylists (with each one's next opening), free slots, opening hours, the\n * next free start and a stylist's photo.\n *\n * Every guard answers before Medal is called, and every read goes through the\n * data cache (`catalogue.ts`), which the site's own writes expire. There is\n * deliberately no way for a visitor to skip it.\n */\n\nimport { NextResponse } from 'next/server';\nimport { RESOURCE_ID_SHAPE } from '../../core/dto';\nimport { earliestOpening } from '../../core/next-available';\nimport type { MedalResource } from '../../core/wire';\nimport { DEFAULT_AVATAR_HOSTS } from '../options';\nimport type { BookingRuntime } from '../runtime';\nimport {\n bookingErrorResponse,\n catalogueErrorResponse,\n knownExtraServiceIds,\n knownServiceId,\n parseRange,\n} from './shared';\n\n/**\n * The full price list, in the order Medal returns it. Nothing is filtered: a\n * service with `bookable_online: false` is still a service the business sells,\n * and hiding it here would leave the printed price list and the website\n * disagreeing. Read through the catalogue cache (at most ~10 min old).\n */\nexport async function servicesRoute(rt: BookingRuntime): Promise<Response> {\n try {\n const services = await rt.catalogue.cachedServices();\n return NextResponse.json({ services: services.map(rt.dto.toBookingServiceDto) });\n } catch (error) {\n return catalogueErrorResponse(rt, error, 'services');\n }\n}\n\n/**\n * The window the caller asked about, never one this route invents: «next\n * free» has to agree with the day strip on the time step, and the wizard owns\n * that horizon.\n *\n * Best effort, and never fatal. A stylist list with no «next free» lines is a\n * working step; a 502 here would be a screen with nobody on it.\n */\nasync function firstOpeningPerResource(\n rt: BookingRuntime,\n serviceId: string,\n extraServiceIds: string[],\n range: { fromTs: number; toTs: number }\n): Promise<Record<string, number>> {\n try {\n // Unfiltered by resource, deliberately: the whole point is one entry per\n // stylist, and `resource_id` would collapse the answer to that one. The\n // whole visit, too: a stylist free for the cut but not the cut and the\n // wash is not free for this person.\n const slots = await rt.catalogue.cachedAvailability({\n serviceId,\n ...(extraServiceIds.length > 0 ? { extraServiceIds } : {}),\n ...range,\n });\n const earliest: Record<string, number> = {};\n for (const slot of slots) {\n // A slot with no stylist cannot fill anybody's line, and an unparseable\n // start would render «Invalid Date».\n if (slot.resource_id === null || slot.start_ts === null) continue;\n const startTs = Date.parse(slot.start_ts);\n if (!Number.isFinite(startTs)) continue;\n const seen = earliest[slot.resource_id];\n if (seen === undefined || startTs < seen) earliest[slot.resource_id] = startTs;\n }\n return earliest;\n } catch (error) {\n rt.logger.warn({ err: error, serviceId }, 'Could not read next-available times for step 2');\n return {};\n }\n}\n\n/**\n * The stylists step 2 offers, and — when asked about a service — the first\n * minute each of them is free. Two answers from one round trip, because step 2\n * is one screen. The availability read is the SAME cached entry the\n * availability route and the booking page use for this service and window.\n */\nexport async function resourcesRoute(rt: BookingRuntime, request: Request): Promise<Response> {\n const { searchParams } = new URL(request.url);\n const rawServiceId = searchParams.get('service_id')?.trim();\n\n // The range is only meaningful alongside a service to ask about. Without one\n // this route is the catalogue alone, and a stray `from_ts` is not worth a 400.\n let range: { fromTs: number; toTs: number } | null = null;\n let serviceId: string | null = null;\n let extraServiceIds: string[] = [];\n if (rawServiceId) {\n const parsed = parseRange(searchParams);\n if ('error' in parsed) return parsed.error;\n range = parsed;\n const known = await knownServiceId(rt, rawServiceId);\n if ('error' in known) return known.error;\n serviceId = known.serviceId;\n const extras = await knownExtraServiceIds(rt, searchParams.get('extra_service_ids'), serviceId);\n if ('error' in extras) return extras.error;\n extraServiceIds = extras.extraServiceIds;\n }\n\n // Started before the stylist list is awaited, so the two reads overlap.\n // `firstOpeningPerResource` never rejects.\n const nextAvailable =\n serviceId !== null && range !== null\n ? firstOpeningPerResource(rt, serviceId, extraServiceIds, range)\n : Promise.resolve({});\n\n let resources: MedalResource[];\n try {\n resources = await rt.catalogue.cachedResources();\n } catch (error) {\n return catalogueErrorResponse(rt, error, 'resources');\n }\n\n return NextResponse.json({\n // Unsorted, in the order Medal returned it: step 2 sorts by `sortOrder`\n // itself, and the party seating uses first-encounter order as its\n // tie-break.\n resources: resources.map(rt.dto.toBookingResourceDto),\n nextAvailableTs: await nextAvailable,\n });\n}\n\n/**\n * Free slots for one visit over one range — one service, or one person's\n * several (`extra_service_ids`) back to back — optionally narrowed to the\n * stylist chosen on step 2. Read through the slot cache (at most ~60 s old),\n * which the site's own create / move / cancel routes expire on success.\n */\nexport async function availabilityRoute(rt: BookingRuntime, request: Request): Promise<Response> {\n const { searchParams } = new URL(request.url);\n\n const rawServiceId = searchParams.get('service_id')?.trim();\n if (!rawServiceId) {\n return bookingErrorResponse('invalidInput', 400, 'service_id is required');\n }\n\n const range = parseRange(searchParams);\n if ('error' in range) return range.error;\n\n const known = await knownServiceId(rt, rawServiceId);\n if ('error' in known) return known.error;\n const { serviceId } = known;\n\n const extras = await knownExtraServiceIds(rt, searchParams.get('extra_service_ids'), serviceId);\n if ('error' in extras) return extras.error;\n const { extraServiceIds } = extras;\n\n const resourceId = searchParams.get('resource_id')?.trim();\n\n try {\n const slots = await rt.catalogue.cachedAvailability({\n serviceId,\n ...(extraServiceIds.length > 0 ? { extraServiceIds } : {}),\n // Omitted rather than passed as an empty string: `?resource_id=` with\n // nothing after it means «first available».\n ...(resourceId ? { resourceId } : {}),\n fromTs: range.fromTs,\n toTs: range.toTs,\n });\n return NextResponse.json({ slots: slots.flatMap(rt.dto.toBookingSlotDto) });\n } catch (error) {\n return catalogueErrorResponse(rt, error, 'availability');\n }\n}\n\n/**\n * The open dates over one range, optionally narrowed to one stylist — the half\n * availability cannot answer: it returns free slots and nothing else, so a\n * closed Sunday, an evening after closing and a booked-out Thursday all arrive\n * as the same empty array. A date ABSENT from the response is one the business\n * keeps no hours on. Opening hours are per stylist, so the filter matters here\n * as much as on availability.\n */\nexport async function scheduleRoute(rt: BookingRuntime, request: Request): Promise<Response> {\n const { searchParams } = new URL(request.url);\n\n const rawServiceId = searchParams.get('service_id')?.trim();\n if (!rawServiceId) {\n return bookingErrorResponse('invalidInput', 400, 'service_id is required');\n }\n\n const range = parseRange(searchParams);\n if ('error' in range) return range.error;\n\n const known = await knownServiceId(rt, rawServiceId);\n if ('error' in known) return known.error;\n const { serviceId } = known;\n\n const extras = await knownExtraServiceIds(rt, searchParams.get('extra_service_ids'), serviceId);\n if ('error' in extras) return extras.error;\n const { extraServiceIds } = extras;\n\n const resourceId = searchParams.get('resource_id')?.trim();\n\n try {\n const days = await rt.catalogue.cachedSchedule({\n ...(resourceId ? { resourceId } : {}),\n serviceId,\n // The whole visit's hours: its last start leaves room for every service.\n ...(extraServiceIds.length > 0 ? { extraServiceIds } : {}),\n fromTs: range.fromTs,\n toTs: range.toTs,\n });\n return NextResponse.json({ days: days.flatMap(rt.dto.toBookingDayDto) });\n } catch (error) {\n return catalogueErrorResponse(rt, error, 'schedule');\n }\n}\n\n/**\n * «Next free: today 11:00» — the next free start for the default prefetch\n * set, for a sticky booking bar or a hero. Read from the booking seed the\n * booking page itself renders from, so it costs no Medal call of its own and\n * cannot name a time the wizard will not show. `startTs: null` when nothing is\n * free or the seed cannot be read: the callers then simply say nothing.\n *\n * The label is formatted here, on the business's clock, so a visitor abroad\n * and the server agree on what «today» means.\n */\nexport async function nextFreeRoute(rt: BookingRuntime): Promise<Response> {\n const now = Date.now();\n try {\n const seed = await rt.seed.loadBookingSeed(undefined, now);\n const startTs = earliestOpening(seed.slots, now);\n return NextResponse.json(\n { startTs, label: startTs === null ? null : rt.clock.when(startTs, now) },\n // Short and private: the answer turns over with the seed's 30 s bucket.\n { headers: { 'Cache-Control': 'private, max-age=30' } }\n );\n } catch (error) {\n rt.logger.warn({ err: error }, 'next-free: booking seed unavailable');\n return NextResponse.json(\n { startTs: null, label: null },\n { headers: { 'Cache-Control': 'no-store' } }\n );\n }\n}\n\nconst AVATAR_CACHE_CONTROL = 'public, max-age=86400, stale-while-revalidate=604800';\n\nfunction uncached(status: number): NextResponse {\n return new NextResponse(null, { status, headers: { 'Cache-Control': 'no-store' } });\n}\n\n/** How long the photo host gets before the card falls back to initials: a hung\n * read must not hold a request open. */\nconst UPSTREAM_TIMEOUT_MS = 5000;\n\n/** FNV-1a, 32-bit, twice with different seeds — synchronous and plenty for a\n * cache validator; nothing here is a secret. */\nfunction etagFor(objectKey: string): string {\n const hash = (seed: number) => {\n let h = seed;\n for (let i = 0; i < objectKey.length; i += 1) {\n h ^= objectKey.charCodeAt(i);\n h = Math.imul(h, 0x01000193);\n }\n return (h >>> 0).toString(16).padStart(8, '0');\n };\n return `\"${hash(0x811c9dc5)}${hash(0x01000193)}\"`;\n}\n\n/**\n * Names that never leave the machine or the private network, whatever a site\n * lists: a photo fetched from one of them would be this server probing its own\n * neighbourhood on behalf of whoever can edit a stylist's photo.\n */\nconst INTERNAL_NAME = /(^|\\.)(localhost|local|internal|intranet|lan|home\\.arpa)$/;\n/** WHATWG URL normalises every IPv4 spelling (`2130706433`, `0x7f.1`) to dotted quads. */\nconst IPV4 = /^\\d{1,3}(\\.\\d{1,3}){3}$/;\n\n/** Whether `hostname` (already lower-cased by `URL`) is one the site allows. */\nfunction allowedPhotoHost(hostname: string, allowed: readonly string[]): boolean {\n // An IPv6 literal, an IPv4 one, a single label or an internal name: never.\n if (hostname.startsWith('[') || IPV4.test(hostname) || !hostname.includes('.')) return false;\n if (INTERNAL_NAME.test(hostname)) return false;\n return allowed.some((entry) => {\n const pattern = entry.trim().toLowerCase();\n if (pattern.startsWith('*.')) {\n const suffix = pattern.slice(1);\n // `*.` alone, or `*.com`, would be every host there is.\n return suffix.lastIndexOf('.') > 0 && hostname.endsWith(suffix);\n }\n return pattern.length > 0 && hostname === pattern;\n });\n}\n\n/**\n * The object, without the signature: origin and path. `null` for a URL this\n * server will not fetch — anything but `https:` on the default port, with no\n * credentials, on a host the site allows (`options.avatarHosts`).\n *\n * ASSUMPTION: the ETag is only a content validator because Medal stores each\n * uploaded avatar under a fresh key, so a new photo is a new path.\n */\nfunction objectKeyOf(photoUrl: string, allowed: readonly string[]): string | null {\n let url: URL;\n try {\n url = new URL(photoUrl);\n } catch {\n return null;\n }\n if (url.protocol !== 'https:' || url.port !== '' || url.username !== '' || url.password !== '') {\n return null;\n }\n return allowedPhotoHost(url.hostname, allowed) ? `${url.origin}${url.pathname}` : null;\n}\n\n/**\n * The raster formats a stylist photo may be. Anything else — SVG above all,\n * and HTML or a missing type — is refused rather than served from this\n * origin, where it would be the site's own active content.\n */\nconst RASTER_TYPES = new Set(['image/avif', 'image/gif', 'image/jpeg', 'image/png', 'image/webp']);\n\n/** `Image/PNG; charset=…` → `image/png`; `null` for a type that is not on the list. */\nfunction rasterType(header: string | null): string | null {\n const type = (header ?? '').split(';')[0].trim().toLowerCase();\n return RASTER_TYPES.has(type) ? type : null;\n}\n\nfunction etagMatches(header: string | null, etag: string): boolean {\n if (header === null) return false;\n return header\n .split(',')\n .map((value) => value.trim().replace(/^W\\//, ''))\n .some((value) => value === etag || value === '*');\n}\n\n/**\n * A stylist's photo, from a URL the browser can cache.\n *\n * Medal returns `photo_url` as a presigned URL that is signed afresh on every\n * read, so the wizard used to download every photo on every visit. This route\n * looks the stylist up in the cached catalogue, fetches the object server-side\n * and hands it back under one stable address with a day of browser cache and\n * an ETag keyed to the OBJECT (the URL without its signature).\n */\nexport async function avatarRoute(\n rt: BookingRuntime,\n request: Request,\n resourceId: string\n): Promise<Response> {\n if (!RESOURCE_ID_SHAPE.test(resourceId)) return uncached(400);\n\n let photoUrl: string | null | undefined;\n try {\n photoUrl = (await rt.catalogue.cachedResources()).find(\n (resource) => resource.id === resourceId\n )?.photo_url;\n } catch (error) {\n rt.logger.warn({ err: error, resourceId }, 'Could not read the stylists for an avatar');\n return uncached(502);\n }\n const objectKey = photoUrl\n ? objectKeyOf(photoUrl, rt.options.avatarHosts ?? DEFAULT_AVATAR_HOSTS)\n : null;\n if (!photoUrl || objectKey === null) {\n if (photoUrl)\n rt.logger.warn({ resourceId }, 'Stylist photo is on a host this site does not allow');\n return uncached(404);\n }\n\n const etag = etagFor(objectKey);\n if (etagMatches(request.headers.get('If-None-Match'), etag)) {\n return new NextResponse(null, {\n status: 304,\n headers: { ETag: etag, 'Cache-Control': AVATAR_CACHE_CONTROL },\n });\n }\n\n let upstream: Response;\n try {\n upstream = await fetch(photoUrl, {\n cache: 'no-store',\n // A redirect is answered, never followed: its target was not checked\n // against the allowed hosts. A 3xx is not `ok`, so it is a 502 below.\n redirect: 'manual',\n signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),\n });\n } catch (error) {\n rt.logger.warn({ err: error, resourceId }, 'Could not fetch a stylist photo');\n return uncached(502);\n }\n const contentType = rasterType(upstream.headers.get('Content-Type'));\n // Only raster images are relayed. Whatever the bucket answers is served from\n // THIS origin, and an HTML or SVG body here would be script on the site.\n if (!upstream.ok || contentType === null) {\n if (upstream.ok) {\n rt.logger.warn(\n { resourceId, contentType: upstream.headers.get('Content-Type') },\n 'Stylist photo is not a raster image'\n );\n }\n await upstream.body?.cancel();\n return uncached(502);\n }\n\n return new NextResponse(upstream.body, {\n status: 200,\n headers: {\n 'Content-Type': contentType,\n 'Cache-Control': AVATAR_CACHE_CONTROL,\n ETag: etag,\n 'X-Content-Type-Options': 'nosniff',\n // Belt and braces for a body opened as a document: no script, no\n // subresource, a unique origin.\n 'Content-Security-Policy': \"default-src 'none'; sandbox\",\n },\n });\n}\n"],"mappings":"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"}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { marketingConsent } from "../../core/consent.mjs";
|
|
2
|
+
import { visitKeyOfIds } from "../../core/visit.mjs";
|
|
2
3
|
import { MedalConfigError } from "../medal.mjs";
|
|
3
4
|
import { PortalSessionExpiredError } from "../portal/medal-portal.mjs";
|
|
4
5
|
import { readBoundedText } from "../request.mjs";
|
|
@@ -40,22 +41,30 @@ function slotWindow(rt, raw) {
|
|
|
40
41
|
};
|
|
41
42
|
}
|
|
42
43
|
/**
|
|
43
|
-
* What is free NOW for each
|
|
44
|
+
* What is free NOW for each VISIT in a refused booking, read live past the
|
|
44
45
|
* slot cache — the entry that offered the lost slot may still be there, since
|
|
45
|
-
* the tag expiry lands after this response.
|
|
46
|
-
*
|
|
46
|
+
* the tag expiry lands after this response. Keyed by visit key (`a+b` for a
|
|
47
|
+
* person having both, the bare id for one service), the key the wizard stores
|
|
48
|
+
* availability under: a visit's openings are not its first service's. Best
|
|
49
|
+
* effort per visit: one that fails is left out, and the wizard reads it the
|
|
50
|
+
* ordinary way.
|
|
47
51
|
*/
|
|
48
|
-
async function liveSlotsFor(rt,
|
|
49
|
-
const
|
|
52
|
+
async function liveSlotsFor(rt, items, window) {
|
|
53
|
+
const visits = new Map(items.map((item) => {
|
|
54
|
+
const extraServiceIds = item.extra_service_ids ?? [];
|
|
55
|
+
return [visitKeyOfIds([item.service_id, ...extraServiceIds]), item];
|
|
56
|
+
}));
|
|
57
|
+
const read = await Promise.all([...visits].map(async ([visit, { service_id: serviceId, extra_service_ids }]) => {
|
|
50
58
|
try {
|
|
51
|
-
return [
|
|
59
|
+
return [visit, (await rt.catalogue.cachedAvailability({
|
|
52
60
|
serviceId,
|
|
61
|
+
...extra_service_ids ? { extraServiceIds: extra_service_ids } : {},
|
|
53
62
|
...window
|
|
54
63
|
}, { fresh: true })).flatMap(rt.dto.toBookingSlotDto)];
|
|
55
64
|
} catch (error) {
|
|
56
65
|
rt.logger.warn({
|
|
57
66
|
err: error,
|
|
58
|
-
|
|
67
|
+
visit
|
|
59
68
|
}, "Could not re-read openings after a taken slot");
|
|
60
69
|
return null;
|
|
61
70
|
}
|
|
@@ -102,6 +111,29 @@ function invalid(message) {
|
|
|
102
111
|
return bookingErrorResponse("invalidInput", 400, message);
|
|
103
112
|
}
|
|
104
113
|
/**
|
|
114
|
+
* A line's `extraServiceIds` — the rest of this person's visit after
|
|
115
|
+
* `serviceId`, in order — or why not. Absent, `null` and `[]` are a
|
|
116
|
+
* one-service visit. Refused locally on everything the engine would refuse
|
|
117
|
+
* with a 422 (more than three, one twice, `serviceId` again), and on anything
|
|
118
|
+
* that is not a list of non-blank strings: a blank id is not «no extra», it is
|
|
119
|
+
* a wizard that lost track of what the person chose.
|
|
120
|
+
*/
|
|
121
|
+
function extraServiceIdsOf(raw, serviceId, index) {
|
|
122
|
+
if (raw === void 0 || raw === null) return { ids: [] };
|
|
123
|
+
const field = `items.${index}.extraServiceIds`;
|
|
124
|
+
if (!Array.isArray(raw)) return { error: invalid(`${field} must be a list of service ids`) };
|
|
125
|
+
const ids = [];
|
|
126
|
+
for (const entry of raw) {
|
|
127
|
+
const id = blankToUndefined(entry);
|
|
128
|
+
if (id === void 0) return { error: invalid(`${field} must be a list of service ids`) };
|
|
129
|
+
ids.push(id);
|
|
130
|
+
}
|
|
131
|
+
if (ids.length > 3) return { error: invalid(`${field} must name at most 3 services`) };
|
|
132
|
+
if (new Set(ids).size !== ids.length) return { error: invalid(`${field} names a service twice`) };
|
|
133
|
+
if (ids.includes(serviceId)) return { error: invalid(`${field} repeats serviceId`) };
|
|
134
|
+
return { ids };
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
105
137
|
* Turn the wizard's submission into Medal's create body, or say why not.
|
|
106
138
|
*
|
|
107
139
|
* Every rejection here is one the engine would also make. Answering locally is
|
|
@@ -122,8 +154,11 @@ function toMedalBody(body) {
|
|
|
122
154
|
if (serviceId === void 0) return { error: invalid(`items.${index}.serviceId is required`) };
|
|
123
155
|
const startTs = item?.startTs;
|
|
124
156
|
if (!Number.isFinite(startTs)) return { error: invalid(`items.${index}.startTs must be a timestamp`) };
|
|
157
|
+
const extras = extraServiceIdsOf(item?.extraServiceIds, serviceId, index);
|
|
158
|
+
if ("error" in extras) return extras;
|
|
125
159
|
items.push({
|
|
126
160
|
service_id: serviceId,
|
|
161
|
+
...extras.ids.length > 0 ? { extra_service_ids: extras.ids } : {},
|
|
127
162
|
resource_id: blankToUndefined(item?.resourceId),
|
|
128
163
|
start_ts: startTs,
|
|
129
164
|
booked_for_name: blankToUndefined(item?.bookedForName),
|
|
@@ -326,7 +361,7 @@ async function createRoute(rt, request) {
|
|
|
326
361
|
const serviceIds = bookedServiceIds(mapped.body);
|
|
327
362
|
rt.catalogue.expireSlots(serviceIds);
|
|
328
363
|
await rt.seed.expireBookingSeeds(serviceIds);
|
|
329
|
-
const freshSlots = await liveSlotsFor(rt,
|
|
364
|
+
const freshSlots = await liveSlotsFor(rt, mapped.body.items, slotWindow(rt, parsed.window));
|
|
330
365
|
return NextResponse.json({
|
|
331
366
|
error: "slotTaken",
|
|
332
367
|
freshSlots
|
|
@@ -335,8 +370,11 @@ async function createRoute(rt, request) {
|
|
|
335
370
|
return createErrorResponse(rt, error);
|
|
336
371
|
}
|
|
337
372
|
}
|
|
373
|
+
/** Every service a submission books — each visit's extras too, since a
|
|
374
|
+
* visit's slot entry is tagged with all of its services and a write to any of
|
|
375
|
+
* them must retire it. */
|
|
338
376
|
function bookedServiceIds(body) {
|
|
339
|
-
return body.items.
|
|
377
|
+
return body.items.flatMap((item) => [item.service_id, ...item.extra_service_ids ?? []]);
|
|
340
378
|
}
|
|
341
379
|
/** Medal's person ids are Convex ids; anything else is not one worth asking about. */
|
|
342
380
|
const PERSON_ID_SHAPE = /^[A-Za-z0-9_-]{1,64}$/;
|
|
@@ -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 { type CreateBookingBody, MedalConfigError } from '../medal';\nimport { PortalSessionExpiredError } from '../portal/medal-portal';\nimport { readBoundedText } from '../request';\nimport type { BookingRuntime } from '../runtime';\nimport { bookingErrorResponse, isSlotTaken, 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 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 service 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. Best effort per service: one that\n * fails is left out, and the wizard reads it the ordinary way.\n */\nasync function liveSlotsFor(\n rt: BookingRuntime,\n serviceIds: string[],\n window: { fromTs: number; toTs: number }\n): Promise<Record<string, BookingSlotDto[]>> {\n const read = await Promise.all(\n [...new Set(serviceIds)].map(async (serviceId) => {\n try {\n const slots = await rt.catalogue.cachedAvailability(\n { serviceId, ...window },\n { fresh: true }\n );\n return [serviceId, slots.flatMap(rt.dto.toBookingSlotDto)] as const;\n } catch (error) {\n rt.logger.warn({ err: error, serviceId }, '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 * 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 items.push({\n service_id: serviceId,\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 serviceIds,\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\nfunction bookedServiceIds(body: CreateBookingBody): string[] {\n return body.items.map((item) => item.service_id);\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(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 +1 @@
|
|
|
1
|
-
{"version":3,"file":"manage.mjs","names":[],"sources":["../../../src/next/routes/manage.ts"],"sourcesContent":["import { MedalApiError } from '@medalsocial/sdk';\nimport { NextResponse } from 'next/server';\nimport { toManageDto } from '../manage-dto';\nimport { MedalConfigError } from '../medal';\nimport { readBoundedText } from '../request';\nimport type { BookingRuntime } from '../runtime';\nimport { bookingErrorResponse, digestOf, isSlotTaken } from './shared';\n\n/**\n * The two things the manage page can DO: call off an appointment, or move it.\n *\n * One route rather than `…/cancel` and `…/reschedule`, because the token is the\n * expensive part of this URL and there is no reason to have it in two places.\n * The action is a field on the body.\n *\n * SECURITY — the path segment is a live bearer credential for one booking:\n * whoever holds it can cancel or move that appointment. Three rules follow, and\n * every one of them is a rule about *this file*:\n *\n * - The token is read from the path and from nowhere else. A query string would\n * be the same credential in the one place that is logged by every proxy\n * between here and the visitor, and would let a link with `?token=` be\n * crafted for a page that never offered one.\n * - Nothing here logs it, echoes it, or puts it in a response body. The upstream\n * client already redacts it out of its own error messages (`safePath`), and\n * the responses below carry a code and never the input that produced it.\n * - An unknown token gets the same 404 shape as a revoked one, which is what the\n * engine already answers with — the endpoint is not an oracle for whether a\n * booking exists.\n */\n\n/**\n * What the browser may send. `startTs` is epoch milliseconds, the same unit the\n * availability route emits and the same one `pickSlot` stores, so nothing has to\n * reformat a time between choosing it and committing to it.\n */\ninterface ManageRequest {\n action?: unknown;\n reason?: unknown;\n startTs?: unknown;\n}\n\nfunction invalid(message: string): NextResponse {\n return bookingErrorResponse('invalidInput', 400, message);\n}\n\n/**\n * The `Idempotency-Key` for one move: the booking's token and the instant it is\n * being moved to, hashed together.\n *\n * A reschedule is an insert plus a cancel, so a second identical request would\n * otherwise find the booking already cancelled and answer 409 — a double tap on\n * «Bekreft endring» reporting failure for a move that succeeded. Deriving the\n * key from the two things that identify the move makes the retry a replay.\n *\n * The digest, never the token. Medal stores the request identity against the\n * key, so a plaintext token here would write a live credential into a table that\n * outlives the appointment — the same reason the engine hashes the token out of\n * its own idempotency identity and API log. Two visitors cannot collide either,\n * since the token is unique per booking; and a visitor who picks a different\n * hour derives a different key, which is correctly a second move rather than a\n * replay of the first.\n */\nasync function moveKey(token: string, startTs: number): Promise<string> {\n return digestOf(`reschedule:${token}:${startTs}`);\n}\n\n/**\n * The `Idempotency-Key` for one cancellation — the booking's token, hashed.\n *\n * The same hazard the move key exists for, and it had been left off this half.\n * Cancelling twice is a `CONFLICT` from the engine, because the second call\n * finds a booking that is already cancelled — so a dropped RESPONSE (the write\n * landed, the answer did not) turns the visitor's perfectly reasonable retry\n * into «det gikk ikke» for an appointment that IS cancelled. A disabled button\n * only stops the concurrent double tap; it does nothing about the retry after\n * an ambiguous network result.\n *\n * No second component, unlike the move: a booking has exactly one cancellation,\n * so the token alone identifies it. Two attempts with different reasons are\n * still one cancellation, and the first reason is the one that sticks — which\n * is the right answer, since it is the one the visitor gave before anything\n * went wrong.\n *\n * The digest and never the token, for the reason `moveKey` gives.\n */\nasync function cancelKey(token: string): Promise<string> {\n return digestOf(`cancel:${token}`);\n}\n\n/**\n * Every way a manage action can fail, in codes the page has a Norwegian\n * sentence for.\n *\n * `windowPassed` is the one that matters. The engine enforces both windows on\n * the write and raises `CONFLICT` with `CANCEL_WINDOW_PASSED` /\n * `RESCHEDULE_WINDOW_PASSED` in the message — the code alone cannot tell it from\n * a taken slot, exactly as the create route has to read `SLOT_TAKEN` out of the\n * message. It is reachable without anyone doing anything wrong: `can_cancel` was\n * true when the page rendered, and the visitor sat on it until it was not.\n *\n * The upstream's own message never travels. Medal writes for the integrator\n * holding the API key, and its 404 for this route would be the same sentence\n * whether the token was revoked, GDPR-erased, or belongs to another salon.\n */\nfunction manageErrorResponse(rt: BookingRuntime, error: unknown, action: string): NextResponse {\n if (error instanceof MedalConfigError) {\n rt.logger.error({ err: error, action }, 'Booking API is not configured');\n return bookingErrorResponse('unconfigured', 503);\n }\n\n if (error instanceof MedalApiError) {\n if (error.status === 404) {\n return bookingErrorResponse('notFound', 404);\n }\n if (error.code === 'CONFLICT') {\n if (error.message.includes('WINDOW_PASSED')) {\n return bookingErrorResponse('windowPassed', 409);\n }\n if (isSlotTaken(error)) {\n return bookingErrorResponse('slotTaken', 409);\n }\n return bookingErrorResponse('conflict', 409);\n }\n if (error.code === 'VALIDATION_ERROR' || error.code === 'INVALID_INPUT') {\n return bookingErrorResponse('invalidInput', 400);\n }\n }\n\n // `action` and not the token: which of the two failed is the whole reason to\n // log a line here, and the token is the one thing that must never reach a log\n // stream that outlives the booking.\n rt.logger.error({ err: error, action }, 'Booking manage action failed');\n return bookingErrorResponse('upstreamError', 502);\n}\n\n/**\n * The booking's service id, for expiring its cached slots after a write.\n *\n * Asked for ALONGSIDE the write rather than after it, so it adds no round trip\n * (after a move the old token resolves to the cancelled row, which still names\n * the service — but there is no reason to wait for that). Best effort: `null`\n * leaves the cache to its ~60 s bound, never fails the action.\n */\nasync function serviceIdOf(rt: BookingRuntime, token: string): Promise<string | null> {\n try {\n return (await rt.medal.getManage(token)).service_id ?? null;\n } catch {\n return null;\n }\n}\n\n/** Expire the booking's slots — cached and in this colo's seeds — once the\n * write has landed. */\nasync function expireAfterWrite(\n rt: BookingRuntime,\n serviceId: Promise<string | null>\n): Promise<void> {\n const id = await serviceId;\n if (id === null) return;\n rt.catalogue.expireSlots([id]);\n await rt.seed.expireBookingSeeds([id]);\n}\n\n/**\n * The manage body's ceiling: an action, a reason chip and an instant fit in\n * a few hundred bytes. Read in bytes before anything parses it, as the portal\n * routes cap theirs.\n */\nconst MANAGE_MAX_BODY_BYTES = 4 * 1024;\n\nexport async function manageRoute(\n rt: BookingRuntime,\n request: Request,\n token: string\n): Promise<Response> {\n if (!token) {\n // Unreachable through the router, which only matches a non-empty segment —\n // but this is the one function in the app where an empty credential must\n // never be forwarded as if it were one.\n return invalid('token is required');\n }\n\n let parsed: unknown;\n try {\n const text = await readBoundedText(request, MANAGE_MAX_BODY_BYTES);\n if (text === null) return invalid('body is too large');\n parsed = JSON.parse(text);\n } catch {\n return invalid('body must be valid JSON');\n }\n if (typeof parsed !== 'object' || parsed === null) {\n return invalid('body must be a JSON object');\n }\n const body = parsed as ManageRequest;\n\n if (body.action === 'cancel') {\n try {\n // Trimmed to nothing means the visitor picked no chip, and the reason is\n // optional to the engine — `''` is a value it would store as a reason\n // that says nothing. Absent is the honest form of «they did not say».\n const reason = typeof body.reason === 'string' ? body.reason.trim() : '';\n const serviceId = serviceIdOf(rt, token);\n await rt.medal.cancelManage(\n token,\n reason.length > 0 ? reason : undefined,\n await cancelKey(token)\n );\n await expireAfterWrite(rt, serviceId);\n return NextResponse.json({ ok: true });\n } catch (error) {\n return manageErrorResponse(rt, error, 'cancel');\n }\n }\n\n if (body.action === 'reschedule') {\n const startTs = body.startTs;\n // `Number.isFinite` and not `Number(...)`, for the reason the create route\n // gives: `Number(null)` is 0, and a cleared slot serialises as null — a\n // lenient parse would ask the engine to move the appointment to 1970.\n if (!Number.isFinite(startTs)) {\n return invalid('startTs must be a timestamp');\n }\n const serviceId = serviceIdOf(rt, token);\n try {\n const result = await rt.medal.rescheduleManage(\n token,\n startTs as number,\n await moveKey(token, startTs as number)\n );\n await expireAfterWrite(rt, serviceId);\n // The NEW booking's token, relayed so the page can send the visitor to a\n // manage link that still works: the token they arrived with now belongs\n // to the cancelled row and from here on resolves to «avbestilt».\n //\n // Absent on an idempotent replay — the engine mints it exactly once — so\n // the field is optional and the page has a path for not getting one.\n return NextResponse.json({\n ok: true,\n ...(typeof result?.manage_token === 'string' ? { manageToken: result.manage_token } : {}),\n });\n } catch (error) {\n // The move lost its slot: the cache offered one that was already gone.\n if (isSlotTaken(error)) await expireAfterWrite(rt, serviceId);\n return manageErrorResponse(rt, error, 'reschedule');\n }\n }\n\n return invalid('action must be \"cancel\" or \"reschedule\"');\n}\n\n/**\n * `GET <api>/manage/<token>` — the booking the token names, as the manage page\n * renders it, for a client that loads it itself. The same 404 for an unknown\n * token as for a revoked one; the token is never echoed or logged, and the\n * answer is never stored by anyone (`no-store`, `no-referrer`).\n */\nexport async function manageSummaryRoute(\n rt: BookingRuntime,\n _request: Request,\n token: string\n): Promise<Response> {\n const headers = { 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer' };\n if (!token) return invalid('token is required');\n try {\n const booking = toManageDto(await rt.medal.getManage(token));\n if (booking === null) return NextResponse.json({ error: 'notFound' }, { status: 404, headers });\n return NextResponse.json({ booking }, { headers });\n } catch (error) {\n if (error instanceof MedalApiError && error.status === 404) {\n return NextResponse.json({ error: 'notFound' }, { status: 404, headers });\n }\n const response = manageErrorResponse(rt, error, 'read');\n for (const [name, value] of Object.entries(headers)) response.headers.set(name, value);\n return response;\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"manage.mjs","names":[],"sources":["../../../src/next/routes/manage.ts"],"sourcesContent":["import { MedalApiError } from '@medalsocial/sdk';\nimport { NextResponse } from 'next/server';\nimport { toManageDto } from '../manage-dto';\nimport { MedalConfigError } from '../medal';\nimport { readBoundedText } from '../request';\nimport type { BookingRuntime } from '../runtime';\nimport { bookingErrorResponse, digestOf, isSlotTaken } from './shared';\n\n/**\n * The two things the manage page can DO: call off an appointment, or move it.\n *\n * One route rather than `…/cancel` and `…/reschedule`, because the token is the\n * expensive part of this URL and there is no reason to have it in two places.\n * The action is a field on the body.\n *\n * SECURITY — the path segment is a live bearer credential for one booking:\n * whoever holds it can cancel or move that appointment. Three rules follow, and\n * every one of them is a rule about *this file*:\n *\n * - The token is read from the path and from nowhere else. A query string would\n * be the same credential in the one place that is logged by every proxy\n * between here and the visitor, and would let a link with `?token=` be\n * crafted for a page that never offered one.\n * - Nothing here logs it, echoes it, or puts it in a response body. The upstream\n * client already redacts it out of its own error messages (`safePath`), and\n * the responses below carry a code and never the input that produced it.\n * - An unknown token gets the same 404 shape as a revoked one, which is what the\n * engine already answers with — the endpoint is not an oracle for whether a\n * booking exists.\n */\n\n/**\n * What the browser may send. `startTs` is epoch milliseconds, the same unit the\n * availability route emits and the same one `pickSlot` stores, so nothing has to\n * reformat a time between choosing it and committing to it.\n */\ninterface ManageRequest {\n action?: unknown;\n reason?: unknown;\n startTs?: unknown;\n}\n\nfunction invalid(message: string): NextResponse {\n return bookingErrorResponse('invalidInput', 400, message);\n}\n\n/**\n * The `Idempotency-Key` for one move: the booking's token and the instant it is\n * being moved to, hashed together.\n *\n * A reschedule is an insert plus a cancel, so a second identical request would\n * otherwise find the booking already cancelled and answer 409 — a double tap on\n * «Bekreft endring» reporting failure for a move that succeeded. Deriving the\n * key from the two things that identify the move makes the retry a replay.\n *\n * The digest, never the token. Medal stores the request identity against the\n * key, so a plaintext token here would write a live credential into a table that\n * outlives the appointment — the same reason the engine hashes the token out of\n * its own idempotency identity and API log. Two visitors cannot collide either,\n * since the token is unique per booking; and a visitor who picks a different\n * hour derives a different key, which is correctly a second move rather than a\n * replay of the first.\n */\nasync function moveKey(token: string, startTs: number): Promise<string> {\n return digestOf(`reschedule:${token}:${startTs}`);\n}\n\n/**\n * The `Idempotency-Key` for one cancellation — the booking's token, hashed.\n *\n * The same hazard the move key exists for, and it had been left off this half.\n * Cancelling twice is a `CONFLICT` from the engine, because the second call\n * finds a booking that is already cancelled — so a dropped RESPONSE (the write\n * landed, the answer did not) turns the visitor's perfectly reasonable retry\n * into «det gikk ikke» for an appointment that IS cancelled. A disabled button\n * only stops the concurrent double tap; it does nothing about the retry after\n * an ambiguous network result.\n *\n * No second component, unlike the move: a booking has exactly one cancellation,\n * so the token alone identifies it. Two attempts with different reasons are\n * still one cancellation, and the first reason is the one that sticks — which\n * is the right answer, since it is the one the visitor gave before anything\n * went wrong.\n *\n * The digest and never the token, for the reason `moveKey` gives.\n */\nasync function cancelKey(token: string): Promise<string> {\n return digestOf(`cancel:${token}`);\n}\n\n/**\n * Every way a manage action can fail, in codes the page has a Norwegian\n * sentence for.\n *\n * `windowPassed` is the one that matters. The engine enforces both windows on\n * the write and raises `CONFLICT` with `CANCEL_WINDOW_PASSED` /\n * `RESCHEDULE_WINDOW_PASSED` in the message — the code alone cannot tell it from\n * a taken slot, exactly as the create route has to read `SLOT_TAKEN` out of the\n * message. It is reachable without anyone doing anything wrong: `can_cancel` was\n * true when the page rendered, and the visitor sat on it until it was not.\n *\n * The upstream's own message never travels. Medal writes for the integrator\n * holding the API key, and its 404 for this route would be the same sentence\n * whether the token was revoked, GDPR-erased, or belongs to another salon.\n */\nfunction manageErrorResponse(rt: BookingRuntime, error: unknown, action: string): NextResponse {\n if (error instanceof MedalConfigError) {\n rt.logger.error({ err: error, action }, 'Booking API is not configured');\n return bookingErrorResponse('unconfigured', 503);\n }\n\n if (error instanceof MedalApiError) {\n if (error.status === 404) {\n return bookingErrorResponse('notFound', 404);\n }\n if (error.code === 'CONFLICT') {\n if (error.message.includes('WINDOW_PASSED')) {\n return bookingErrorResponse('windowPassed', 409);\n }\n if (isSlotTaken(error)) {\n return bookingErrorResponse('slotTaken', 409);\n }\n return bookingErrorResponse('conflict', 409);\n }\n if (error.code === 'VALIDATION_ERROR' || error.code === 'INVALID_INPUT') {\n return bookingErrorResponse('invalidInput', 400);\n }\n }\n\n // `action` and not the token: which of the two failed is the whole reason to\n // log a line here, and the token is the one thing that must never reach a log\n // stream that outlives the booking.\n rt.logger.error({ err: error, action }, 'Booking manage action failed');\n return bookingErrorResponse('upstreamError', 502);\n}\n\n/**\n * The booking's service id, for expiring its cached slots after a write.\n *\n * Asked for ALONGSIDE the write rather than after it, so it adds no round trip\n * (after a move the old token resolves to the cancelled row, which still names\n * the service — but there is no reason to wait for that). Best effort: `null`\n * leaves the cache to its ~60 s bound, never fails the action.\n */\n// A visit's extra services are not expired here: the summary names only its\n// first one (the visit's own entry carries that tag too, so it does go); their\n// one-service entries turn over with their own ~60 s bound.\nasync function serviceIdOf(rt: BookingRuntime, token: string): Promise<string | null> {\n try {\n return (await rt.medal.getManage(token)).service_id ?? null;\n } catch {\n return null;\n }\n}\n\n/** Expire the booking's slots — cached and in this colo's seeds — once the\n * write has landed. */\nasync function expireAfterWrite(\n rt: BookingRuntime,\n serviceId: Promise<string | null>\n): Promise<void> {\n const id = await serviceId;\n if (id === null) return;\n rt.catalogue.expireSlots([id]);\n await rt.seed.expireBookingSeeds([id]);\n}\n\n/**\n * The manage body's ceiling: an action, a reason chip and an instant fit in\n * a few hundred bytes. Read in bytes before anything parses it, as the portal\n * routes cap theirs.\n */\nconst MANAGE_MAX_BODY_BYTES = 4 * 1024;\n\nexport async function manageRoute(\n rt: BookingRuntime,\n request: Request,\n token: string\n): Promise<Response> {\n if (!token) {\n // Unreachable through the router, which only matches a non-empty segment —\n // but this is the one function in the app where an empty credential must\n // never be forwarded as if it were one.\n return invalid('token is required');\n }\n\n let parsed: unknown;\n try {\n const text = await readBoundedText(request, MANAGE_MAX_BODY_BYTES);\n if (text === null) return invalid('body is too large');\n parsed = JSON.parse(text);\n } catch {\n return invalid('body must be valid JSON');\n }\n if (typeof parsed !== 'object' || parsed === null) {\n return invalid('body must be a JSON object');\n }\n const body = parsed as ManageRequest;\n\n if (body.action === 'cancel') {\n try {\n // Trimmed to nothing means the visitor picked no chip, and the reason is\n // optional to the engine — `''` is a value it would store as a reason\n // that says nothing. Absent is the honest form of «they did not say».\n const reason = typeof body.reason === 'string' ? body.reason.trim() : '';\n const serviceId = serviceIdOf(rt, token);\n await rt.medal.cancelManage(\n token,\n reason.length > 0 ? reason : undefined,\n await cancelKey(token)\n );\n await expireAfterWrite(rt, serviceId);\n return NextResponse.json({ ok: true });\n } catch (error) {\n return manageErrorResponse(rt, error, 'cancel');\n }\n }\n\n if (body.action === 'reschedule') {\n const startTs = body.startTs;\n // `Number.isFinite` and not `Number(...)`, for the reason the create route\n // gives: `Number(null)` is 0, and a cleared slot serialises as null — a\n // lenient parse would ask the engine to move the appointment to 1970.\n if (!Number.isFinite(startTs)) {\n return invalid('startTs must be a timestamp');\n }\n const serviceId = serviceIdOf(rt, token);\n try {\n const result = await rt.medal.rescheduleManage(\n token,\n startTs as number,\n await moveKey(token, startTs as number)\n );\n await expireAfterWrite(rt, serviceId);\n // The NEW booking's token, relayed so the page can send the visitor to a\n // manage link that still works: the token they arrived with now belongs\n // to the cancelled row and from here on resolves to «avbestilt».\n //\n // Absent on an idempotent replay — the engine mints it exactly once — so\n // the field is optional and the page has a path for not getting one.\n return NextResponse.json({\n ok: true,\n ...(typeof result?.manage_token === 'string' ? { manageToken: result.manage_token } : {}),\n });\n } catch (error) {\n // The move lost its slot: the cache offered one that was already gone.\n if (isSlotTaken(error)) await expireAfterWrite(rt, serviceId);\n return manageErrorResponse(rt, error, 'reschedule');\n }\n }\n\n return invalid('action must be \"cancel\" or \"reschedule\"');\n}\n\n/**\n * `GET <api>/manage/<token>` — the booking the token names, as the manage page\n * renders it, for a client that loads it itself. The same 404 for an unknown\n * token as for a revoked one; the token is never echoed or logged, and the\n * answer is never stored by anyone (`no-store`, `no-referrer`).\n */\nexport async function manageSummaryRoute(\n rt: BookingRuntime,\n _request: Request,\n token: string\n): Promise<Response> {\n const headers = { 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer' };\n if (!token) return invalid('token is required');\n try {\n const booking = toManageDto(await rt.medal.getManage(token));\n if (booking === null) return NextResponse.json({ error: 'notFound' }, { status: 404, headers });\n return NextResponse.json({ booking }, { headers });\n } catch (error) {\n if (error instanceof MedalApiError && error.status === 404) {\n return NextResponse.json({ error: 'notFound' }, { status: 404, headers });\n }\n const response = manageErrorResponse(rt, error, 'read');\n for (const [name, value] of Object.entries(headers)) response.headers.set(name, value);\n return response;\n }\n}\n"],"mappings":"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"}
|
|
@@ -77,6 +77,34 @@ async function knownServiceId(rt, raw) {
|
|
|
77
77
|
return { serviceId: raw };
|
|
78
78
|
}
|
|
79
79
|
/**
|
|
80
|
+
* `extra_service_ids` from the query string — the rest of ONE person's visit
|
|
81
|
+
* after `service_id`, comma-separated, in order. Blank entries are dropped, so
|
|
82
|
+
* an absent, empty or `,`-only value is a one-service visit (`[]`).
|
|
83
|
+
*
|
|
84
|
+
* Held to everything the engine would refuse with a 422 (more than three, one
|
|
85
|
+
* twice, `service_id` again) and to what `knownServiceId` holds `service_id` to
|
|
86
|
+
* (the shape, and a service the catalogue has), for the same reason: every id
|
|
87
|
+
* here lands in a cache key and a cache tag. Call it after `knownServiceId`.
|
|
88
|
+
*/
|
|
89
|
+
async function knownExtraServiceIds(rt, raw, serviceId) {
|
|
90
|
+
const ids = (raw ?? "").split(",").map((id) => id.trim()).filter((id) => id.length > 0);
|
|
91
|
+
if (ids.length === 0) return { extraServiceIds: [] };
|
|
92
|
+
const refuse = (message) => ({ error: bookingErrorResponse("invalidInput", 400, message) });
|
|
93
|
+
if (ids.length > 3) return refuse(`extra_service_ids must name at most 3 services`);
|
|
94
|
+
if (new Set(ids).size !== ids.length) return refuse("extra_service_ids names a service twice");
|
|
95
|
+
if (ids.includes(serviceId)) return refuse("extra_service_ids repeats service_id");
|
|
96
|
+
if (!ids.every((id) => SERVICE_ID_SHAPE.test(id))) return refuse("extra_service_ids is malformed");
|
|
97
|
+
let services;
|
|
98
|
+
try {
|
|
99
|
+
services = await rt.catalogue.cachedServices();
|
|
100
|
+
} catch (error) {
|
|
101
|
+
return { error: catalogueErrorResponse(rt, error, "services") };
|
|
102
|
+
}
|
|
103
|
+
const known = new Set(services.map((service) => service.id));
|
|
104
|
+
if (!ids.every((id) => known.has(id))) return refuse("Unknown service in extra_service_ids");
|
|
105
|
+
return { extraServiceIds: ids };
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
80
108
|
* The engine's «that slot is no longer free».
|
|
81
109
|
*
|
|
82
110
|
* It arrives as `CONFLICT` with `SLOT_TAKEN` in the message: the SDK exposes no
|
|
@@ -129,6 +157,6 @@ async function digestOf(input) {
|
|
|
129
157
|
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
130
158
|
}
|
|
131
159
|
//#endregion
|
|
132
|
-
export { bookingErrorResponse, catalogueErrorResponse, digestOf, isSlotTaken, knownServiceId, parseRange };
|
|
160
|
+
export { bookingErrorResponse, catalogueErrorResponse, digestOf, isSlotTaken, knownExtraServiceIds, knownServiceId, parseRange };
|
|
133
161
|
|
|
134
162
|
//# sourceMappingURL=shared.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"shared.mjs","names":[],"sources":["../../../src/next/routes/shared.ts"],"sourcesContent":["/**\n * What the booking routes are allowed to say to a browser.\n *\n * The wizard reads `error` straight into `WizardState.error`, which is a closed\n * union — `'maxParty' | 'slotTaken' | 'conflict' | 'invalidInput' |\n * 'unconfigured' | 'upstreamError'` — so the spelling here is load-bearing\n * rather than descriptive.\n *\n * `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 * 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":";;;;;;;;;;;;;;;;;;;;;;;;;AA2BA,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;;;;;;;;;;AAWA,SAAgB,YAAY,OAAyB;CACnD,OACE,iBAAiB,iBACjB,MAAM,SAAS,cACf,MAAM,QAAQ,SAAS,YAAY;AAEvC;;;;;;AAOA,MAAM,eAAe;;;;;;AAOrB,SAAS,eAAe,KAAmC;CACzD,MAAM,UAAU,KAAK,KAAK;CAC1B,IAAI,CAAC,SAAS,OAAO;CACrB,MAAM,UAAU,OAAO,OAAO;CAC9B,MAAM,YAAY,OAAO,SAAS,OAAO,IAAI,UAAU,KAAK,MAAM,OAAO;CACzE,OAAO,OAAO,SAAS,SAAS,IAAI,YAAY;AAClD;;;;;;;AAQA,SAAgB,WACd,cAC4D;CAC5D,MAAM,SAAS,eAAe,aAAa,IAAI,SAAS,CAAC;CACzD,MAAM,OAAO,eAAe,aAAa,IAAI,OAAO,CAAC;CACrD,IAAI,WAAW,QAAQ,SAAS,MAC9B,OAAO,EACL,OAAO,qBACL,gBACA,KACA,oEACF,EACF;CAEF,IAAI,QAAQ,QACV,OAAO,EAAE,OAAO,qBAAqB,gBAAgB,KAAK,6BAA6B,EAAE;CAE3F,IAAI,OAAO,SAAS,cAClB,OAAO,EAAE,OAAO,qBAAqB,gBAAgB,KAAK,+BAA+B,EAAE;CAE7F,OAAO;EAAE;EAAQ;CAAK;AACxB;;AAGA,eAAsB,SAAS,OAAgC;CAC7D,MAAM,SAAS,MAAM,OAAO,OAAO,OAAO,WAAW,IAAI,YAAY,CAAC,CAAC,OAAO,KAAK,CAAC;CACpF,OAAO,MAAM,KAAK,IAAI,WAAW,MAAM,IAAI,SAAS,KAAK,SAAS,EAAE,CAAC,CAAC,SAAS,GAAG,GAAG,CAAC,CAAC,CAAC,KAAK,EAAE;AACjG"}
|
|
1
|
+
{"version":3,"file":"shared.mjs","names":[],"sources":["../../../src/next/routes/shared.ts"],"sourcesContent":["/**\n * What the booking routes are allowed to say to a browser.\n *\n * The wizard reads `error` straight into `WizardState.error`, which is a closed\n * union — `'maxParty' | 'slotTaken' | 'conflict' | 'invalidInput' |\n * 'unconfigured' | 'upstreamError'` — so the spelling here is load-bearing\n * rather than descriptive.\n *\n * `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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"}
|