@medalsocial/booking 0.4.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # @medalsocial/booking
2
2
 
3
+ ## 0.4.1
4
+
5
+ ### Patch Changes
6
+
7
+ - [#214](https://github.com/Medal-Social/MedalSocial-SDK/pull/214) [`6afacff`](https://github.com/Medal-Social/MedalSocial-SDK/commit/6afacffc461213b95a02951f15638d3d42c1975e) Thanks [@alioftech](https://github.com/alioftech)! - Outage-hardening follow-ups: every failed visit in a party's batch reaches `onEvent` as its own `read_error` (the controller's `readError` is now `readErrors`, a short queue). An overnight «Prøv igjen» shows the stylists loading until the new window answers. Portal refusals are logged with their `reason` code.
8
+
9
+ - [#214](https://github.com/Medal-Social/MedalSocial-SDK/pull/214) [`6afacff`](https://github.com/Medal-Social/MedalSocial-SDK/commit/6afacffc461213b95a02951f15638d3d42c1975e) Thanks [@alioftech](https://github.com/alioftech)! - Outage hardening after a production incident where the DST hour pushed the widest booking window past the routes' 62 × 24 h range guard, so every unseeded service answered 400 between 00:00 and 01:00 for eight weeks of nights.
10
+
11
+ - The read routes admit the widest window a valid config can build, DST hour included. One module (`core/range.ts`, exporting `MAX_RANGE_DAYS` and `MAX_RANGE_MS`) owns the bound for the config schema, `parseRange` and the catalogue cache horizon.
12
+ - «Prøv igjen» re-reads the openings, the stylists and the hours, seeded ones included. If the business day has moved on, it starts a fresh window from now. A party keeps the visits that answered and asks only for the ones that failed.
13
+ - Medal's `in_past`, `outside_horizon` and `off_grid` refusals take the slot-taken path (fresh openings, back to the time step) rather than an endless «Prøv en gang til».
14
+ - Every answer ≥ 400 is logged through the site's logger with the route (a fixed label for malformed or unmatched paths), method, status and error code. The message is never logged, and neither are the rate limiter's own 429s. `onEvent` gains `{ type: 'read_error', endpoint, status }`, once per failure.
15
+ - Catalogue and calendar reads give up after 4 s per attempt and 8 s in total, instead of the SDK's 30 s × 3 with an uncapped `Retry-After`. The deadline aborts the SDK call itself, so nothing more is sent upstream.
16
+ - A booking that happened is never answered as a failure because a cache could not be expired.
17
+ - A pending booking resumes on browsers without `AbortSignal.timeout` (Safari < 16).
18
+
19
+ - [#214](https://github.com/Medal-Social/MedalSocial-SDK/pull/214) [`6afacff`](https://github.com/Medal-Social/MedalSocial-SDK/commit/6afacffc461213b95a02951f15638d3d42c1975e) Thanks [@alioftech](https://github.com/alioftech)! - `isSlotTaken` reads Medal's «pick another time» refusals defensively: any 409 (not only code `CONFLICT`), `details.reason` in any case, and the message prefix when there is no reason. A change of spelling on the engine side now degrades to fresh openings, never to a retry loop.
20
+ - Updated dependencies [[`6afacff`](https://github.com/Medal-Social/MedalSocial-SDK/commit/6afacffc461213b95a02951f15638d3d42c1975e)]:
21
+ - @medalsocial/sdk@1.15.0
22
+
3
23
  ## 0.4.0
4
24
 
5
25
  ### Minor Changes
@@ -1,3 +1,4 @@
1
+ import "./range.mjs";
1
2
  import { z } from "zod";
2
3
  //#region src/core/config.ts
3
4
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"config.mjs","names":[],"sources":["../../src/core/config.ts"],"sourcesContent":["/**\n * What a site tells the booking package about itself.\n *\n * `BookingConfig` is CLIENT-SAFE by construction: it is serialised into the\n * page as a prop, so it never holds a key, a session secret or a cache handle.\n * Those live in `BookingServerOptions`, which only `/next` knows (P4).\n *\n * The package reads no environment variable and no CMS. A site resolves its\n * own switches (a handoff URL, whether accounts are on, the contact block) and\n * passes the answers in.\n *\n * Every field but `timeZone` has a default. `resolveBookingConfig` fills them,\n * validates the whole object with zod and freezes it, so a config that reached\n * a factory is one that passed.\n */\n\nimport { z } from 'zod';\nimport type { BookingLabels } from './labels';\n\n/** A service group, in display order. Medal's free-string `category` maps onto `key`. */\nexport interface BookingCategory {\n key: string;\n /** Who the group is for. `child` is the kids' path: guest children, `?antall=`, the held link service. */\n audience: 'child' | 'adult' | 'any';\n /**\n * For a child's group: which adult group a service «grows up» into once the\n * child has outgrown the kids' menu. A plain key maps every service in the\n * group; a list maps by a word in the service name, first match wins\n * (`{ nameIncludes: 'gutt', category: 'herre' }`). Case-insensitive.\n */\n adultEquivalent?: string | ReadonlyArray<{ nameIncludes: string; category: string }>;\n}\n\n/** A named part of the day on the business's clock, in local hours `[from, to)`. */\nexport interface BookingDaypart {\n key: string;\n from: number;\n to: number;\n}\n\nexport interface BookingConfig {\n /** IANA zone the business keeps its hours in. Required. */\n timeZone: string;\n /** BCP-47, for every `Intl` formatter and the label pack. */\n locale: string;\n /** ISO 4217. Amounts stay in minor units (øre, pence) on the wire. */\n currency: string;\n /** `NO` is the only strict rule set in 0.x; every other country is `loose`. */\n phone: { country: string; validate: 'strict' | 'loose' };\n /** URLs this site serves. */\n paths: {\n booking: string;\n /** Prefix of the manage page; the token is the next segment. */\n manage: string;\n /** `null` = no customer portal. */\n portal: string | null;\n portalLogin: string;\n api: string;\n portalApi: string;\n avatar: string;\n };\n /**\n * The query keys a link may carry into the booking page.\n *\n * `category`, `service`, `stylist`, `party` and `who` are the marketing\n * deep-link keys; `rebookService` / `rebookStylist` are the portal's «book\n * again» link, read by the wizard's own prefill; `resume` marks a return\n * from an external login; `time` is reserved for a pre-picked start.\n */\n query: {\n category: string;\n service: string;\n stylist: string;\n party: string;\n who: string;\n time: string;\n resume: string;\n rebookService: string;\n rebookStylist: string;\n };\n /** The values `query.who` accepts. */\n whoValues: { child: string; adult: string };\n /** Service groups, in display order. */\n categories: BookingCategory[];\n /** Where an unknown Medal category lands. Must be one of `categories`. */\n fallbackCategory: string;\n /** Step 1's rules. */\n party: {\n maxPeople: number;\n allowParallel: boolean;\n askWhoFirst: boolean;\n /**\n * How many services one person may book as a single visit (1–4; Medal's\n * cap is 4). `1` — the default — is the one-tap service step: a tap\n * answers it. Above `1`, `<BookingWizard>` draws the multi-select step:\n * each person ticks up to this many, done back to back as one visit.\n */\n maxServicesPerPerson: number;\n /**\n * A party with a grown-up in it starts «Samtidig» (`partyMode:\n * 'parallel'`): the child in one chair, the parent in the next, done\n * together. Only until the parent picks a mode, or a stylist link names\n * one stylist (one stylist cannot cut two people at once). Needs\n * `allowParallel`. Off by default: everyone else keeps «Rett etter\n * hverandre».\n */\n adultTogether: boolean;\n };\n /** The bookable window and the page's prefetch. */\n window: { rangeDays: number; prefetchLimit: number; prefetchCategory: string | null };\n /** Named day parts for the time step, contiguous from 0 to 24. */\n dayparts: BookingDaypart[];\n /** Set = the booking path hands off (307) to this URL instead of rendering. */\n handoffUrl: string | null;\n /** The business's own contact, for «call us» and the unavailable screen. */\n contact: { phone: string | null; address: string | null; name: string };\n portal: {\n enabled: boolean;\n methods: Array<'email_code' | 'vipps'>;\n /** The session cookie. */\n cookieName: string;\n /** Where a login that left the site (Vipps) returns to; read once. */\n nextCookieName: string;\n /** Must be `__Host-`-prefixed: Secure, `Path=/`, no `Domain`. */\n vippsBindingCookieName: string;\n vippsLinkCookieName: string;\n /**\n * Where a finished login may send the visitor. `exact` matches the\n * pathname only (the booking page, so a deeper manage URL never\n * qualifies); `prefixes` matches anything below.\n */\n returnPaths: { exact: string[]; prefixes: string[] };\n };\n /**\n * Booking only for a logged-in parent: the details step becomes the login\n * until there is a session, and `/create` refuses without one.\n */\n account: { required: boolean };\n /**\n * Opt-in screen features, all off by default so a site's wizard only\n * changes when it asks:\n * - `recap` — the details step opens with what is being booked (day, hours,\n * who, stylist, total, «edit», and a swap to another stylist free at the\n * same minute when «first available» picked one).\n * - `soonest` — the time step leads with the earliest free starts.\n * - `dayFullness` — each day chip shows how much room the day has.\n * - `summaryDetail` — the sticky bar's time and price on a second line.\n * - `hideDisabledNext` — the bar's «next» only while it does something.\n * - `firstAvailableFaces` — «first available» drawn as the qualified\n * stylists' faces.\n * - `stylistEdgeFade` — the phone stylist row fades at its edge.\n * - `guestParty` — a guest picks a number of children and «me too» instead\n * of the chips, so a parent and child book together without an account.\n * - `childMenuFirst` — a child's service list leads with the children's and\n * anyone's groups; the grown-ups' groups move below the «usually not for\n * this age» divider, still one tap away.\n * - `wideLayouts` — the stylist step keeps its row of faces at every width\n * (the chosen stylist's bio under it), and the time step goes\n * `layout=\"adaptive\"`: days beside the times on an iPad, the whole week as\n * a grid of times on a desktop. Needs the `time.week.*` labels.\n */\n screens: {\n recap: boolean;\n soonest: boolean;\n dayFullness: boolean;\n summaryDetail: boolean;\n hideDisabledNext: boolean;\n firstAvailableFaces: boolean;\n stylistEdgeFade: boolean;\n guestParty: boolean;\n childMenuFirst: boolean;\n wideLayouts: boolean;\n };\n consent: {\n termsUrl: string | null;\n /** `null` = no marketing box. */\n marketing: { text: string; version: string } | null;\n };\n /** Namespaces browser storage keys: `<ns>:booking:draft`, … */\n storageNamespace: string;\n /**\n * `prodId` stamps every calendar file. `uidDomain` is for UIDs the package\n * mints itself (`/react`, 0.1.0); a booking id passed in as `uid` is used as\n * is, so a calendar entry made before a site moved onto the package is still\n * the same entry after it.\n */\n ics: { prodId: string; uidDomain: string };\n monitoring: { enabled: boolean; sampleRate: number };\n /** Copy, merged over the built-in pack for `locale`. */\n labels?: Partial<BookingLabels>;\n}\n\ntype DeepPartial<T> = T extends readonly unknown[]\n ? T\n : T extends object\n ? { [K in keyof T]?: DeepPartial<T[K]> }\n : T;\n\n/** What a site passes: `timeZone`, and whatever it wants to change. */\nexport type BookingConfigInput = { timeZone: string } & DeepPartial<\n Omit<BookingConfig, 'timeZone'>\n>;\n\nconst DEFAULT_PATHS: BookingConfig['paths'] = {\n booking: '/bestill',\n manage: '/bestill/administrer',\n portal: '/min-side',\n portalLogin: '/min-side/logg-inn',\n api: '/api/booking',\n portalApi: '/api/portal',\n avatar: '/api/booking/avatar',\n};\n\n/** Everything but `timeZone` (required) and the portal cookie names (derived). */\nfunction defaults(): Omit<BookingConfig, 'timeZone' | 'portal'> & {\n portal: Omit<\n BookingConfig['portal'],\n 'nextCookieName' | 'vippsBindingCookieName' | 'vippsLinkCookieName' | 'returnPaths'\n >;\n} {\n return {\n locale: 'nb-NO',\n currency: 'NOK',\n phone: { country: 'NO', validate: 'strict' },\n paths: { ...DEFAULT_PATHS },\n query: {\n category: 'kategori',\n service: 'tjeneste',\n stylist: 'frisor',\n party: 'antall',\n who: 'hvem',\n time: 'tid',\n resume: 'resume',\n rebookService: 'service',\n rebookStylist: 'stylist',\n },\n whoValues: { child: 'barn', adult: 'voksen' },\n categories: [\n { key: 'barn', audience: 'child' },\n { key: 'annet', audience: 'any' },\n ],\n fallbackCategory: 'annet',\n party: {\n maxPeople: 3,\n allowParallel: true,\n askWhoFirst: true,\n maxServicesPerPerson: 1,\n adultTogether: false,\n },\n window: { rangeDays: 7, prefetchLimit: 4, prefetchCategory: null },\n dayparts: [\n { key: 'formiddag', from: 0, to: 12 },\n { key: 'ettermiddag', from: 12, to: 17 },\n { key: 'kveld', from: 17, to: 24 },\n ],\n handoffUrl: null,\n contact: { phone: null, address: null, name: '' },\n portal: { enabled: false, methods: ['email_code'], cookieName: 'booking_portal' },\n account: { required: false },\n screens: {\n recap: false,\n soonest: false,\n dayFullness: false,\n summaryDetail: false,\n hideDisabledNext: false,\n firstAvailableFaces: false,\n stylistEdgeFade: false,\n guestParty: false,\n childMenuFirst: false,\n wideLayouts: false,\n },\n consent: { termsUrl: null, marketing: null },\n storageNamespace: 'medal',\n ics: { prodId: '-//Medal Social//Booking//EN', uidDomain: 'booking.invalid' },\n monitoring: { enabled: true, sampleRate: 1 },\n };\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value);\n}\n\n/**\n * Objects merge key by key; arrays, scalars and `null` replace. Only\n * `undefined` keeps the default — `paths.portal: null` means «no portal».\n */\nfunction merge(base: unknown, patch: unknown): unknown {\n if (patch === undefined) return base;\n if (!isRecord(base) || !isRecord(patch)) return patch;\n const out: Record<string, unknown> = { ...base };\n for (const [key, value] of Object.entries(patch)) out[key] = merge(base[key], value);\n return out;\n}\n\nfunction validTimeZone(value: string): boolean {\n try {\n new Intl.DateTimeFormat('en-US', { timeZone: value });\n return true;\n } catch {\n return false;\n }\n}\n\nfunction validLocale(value: string): boolean {\n try {\n return Intl.getCanonicalLocales(value).length === 1;\n } catch {\n return false;\n }\n}\n\nconst path = z.string().regex(/^\\/[^\\s?#]*$/, 'must be a root-relative path');\nconst key = z.string().regex(/^[\\w-]{1,64}$/, 'must be 1–64 word characters');\n/** Group keys and `?hvem=` values are matched against a lower-cased query value. */\nconst lowerKey = z.string().regex(/^[a-z0-9_-]{1,64}$/, 'must be 1–64 lower-case word characters');\n/** A prefix must end at a segment boundary, or `/flow` would also admit `/flow-evil`. */\nconst prefix = path.refine((value) => value.endsWith('/'), 'a prefix must end with /');\n/** The longest cookie name the config takes. */\nconst MAX_COOKIE_NAME = 128;\n/** RFC 6265 token characters, which is every name a browser keeps intact. */\nconst cookieName = z.string().regex(/^[!#$%&'*+\\-.^_`|~0-9A-Za-z]{1,128}$/);\n\n/**\n * How each secondary portal cookie name derives from `portal.cookieName` when\n * the site sets none — the prefix and suffix, so a too-long base can be\n * reported against the name the site actually wrote.\n */\nconst DERIVED_COOKIE_NAMES = {\n nextCookieName: { prefix: '', suffix: '_next' },\n vippsBindingCookieName: { prefix: '__Host-', suffix: '_vipps_bind' },\n vippsLinkCookieName: { prefix: '', suffix: '_vipps_link' },\n} as const;\n\ntype DerivedCookieName = keyof typeof DERIVED_COOKIE_NAMES;\n\n/**\n * A derived name that would be too long, said about `portal.cookieName`\n * (which the site wrote) rather than a name it never set. `null` when every\n * derived name fits — the schema then judges the characters as usual — and\n * when the base is not a string at all (a runtime input the types did not\n * catch): the schema's own issue on `portal.cookieName` says so.\n */\nfunction derivedCookieNameIssue(\n session: unknown,\n derived: readonly DerivedCookieName[]\n): { path: string; message: string } | null {\n if (typeof session !== 'string') return null;\n const tooLong = derived.filter((name) => {\n const { prefix, suffix } = DERIVED_COOKIE_NAMES[name];\n return prefix.length + session.length + suffix.length > MAX_COOKIE_NAME;\n });\n if (tooLong.length === 0) return null;\n const room = Math.min(\n ...tooLong.map((name) => {\n const { prefix, suffix } = DERIVED_COOKIE_NAMES[name];\n return MAX_COOKIE_NAME - prefix.length - suffix.length;\n })\n );\n return {\n path: 'portal.cookieName',\n message: `must be at most ${room} characters, or the derived ${tooLong\n .map((name) => `portal.${name}`)\n .join(', ')} would exceed ${MAX_COOKIE_NAME}; shorten it or set ${\n tooLong.length === 1 ? 'that name' : 'those names'\n } explicitly`,\n };\n}\n\nconst schema = z\n .object({\n timeZone: z.string().refine(validTimeZone, 'must be an IANA time zone'),\n locale: z.string().refine(validLocale, 'must be a BCP-47 locale'),\n currency: z.string().regex(/^[A-Z]{3}$/, 'must be an ISO 4217 code'),\n phone: z.object({\n country: z.string().regex(/^[A-Z]{2}$/),\n validate: z.enum(['strict', 'loose']),\n }),\n paths: z.object({\n booking: path,\n manage: path,\n portal: path.nullable(),\n portalLogin: path,\n api: path,\n portalApi: path,\n avatar: path,\n }),\n query: z.object({\n category: key,\n service: key,\n stylist: key,\n party: key,\n who: key,\n time: key,\n resume: key,\n rebookService: key,\n rebookStylist: key,\n }),\n whoValues: z.object({ child: lowerKey, adult: lowerKey }),\n categories: z\n .array(\n z.object({\n key: lowerKey,\n audience: z.enum(['child', 'adult', 'any']),\n adultEquivalent: z\n .union([\n lowerKey,\n z.array(z.object({ nameIncludes: z.string().min(1), category: lowerKey })),\n ])\n .optional(),\n })\n )\n .min(1),\n fallbackCategory: lowerKey,\n party: z.object({\n maxPeople: z.number().int().min(1).max(20),\n allowParallel: z.boolean(),\n askWhoFirst: z.boolean(),\n maxServicesPerPerson: z.number().int().min(1).max(4),\n adultTogether: z.boolean(),\n }),\n window: z.object({\n rangeDays: z.number().int().min(1).max(62),\n prefetchLimit: z.number().int().min(0).max(20),\n prefetchCategory: lowerKey.nullable(),\n }),\n dayparts: z\n .array(\n z.object({\n key,\n from: z.number().int().min(0).max(23),\n to: z.number().int().min(1).max(24),\n })\n )\n .min(1),\n handoffUrl: z.url({ protocol: /^https?$/ }).nullable(),\n contact: z.object({\n phone: z.string().nullable(),\n address: z.string().nullable(),\n name: z.string(),\n }),\n portal: z.object({\n enabled: z.boolean(),\n methods: z.array(z.enum(['email_code', 'vipps'])),\n cookieName,\n nextCookieName: cookieName,\n vippsBindingCookieName: cookieName.refine(\n (name) => name.startsWith('__Host-'),\n 'must carry the __Host- prefix'\n ),\n vippsLinkCookieName: cookieName,\n returnPaths: z.object({ exact: z.array(path), prefixes: z.array(prefix) }),\n }),\n account: z.object({ required: z.boolean() }),\n screens: z.object({\n recap: z.boolean(),\n soonest: z.boolean(),\n dayFullness: z.boolean(),\n summaryDetail: z.boolean(),\n hideDisabledNext: z.boolean(),\n firstAvailableFaces: z.boolean(),\n stylistEdgeFade: z.boolean(),\n guestParty: z.boolean(),\n childMenuFirst: z.boolean(),\n wideLayouts: z.boolean(),\n }),\n consent: z.object({\n termsUrl: z.string().min(1).nullable(),\n marketing: z.object({ text: z.string().min(1), version: z.string().min(1) }).nullable(),\n }),\n storageNamespace: key,\n ics: z.object({ prodId: z.string().regex(/^-\\/\\/.+\\/\\/.+$/), uidDomain: z.string().min(1) }),\n monitoring: z.object({ enabled: z.boolean(), sampleRate: z.number().min(0).max(1) }),\n labels: z.record(z.string(), z.union([z.string(), z.array(z.string())])).optional(),\n })\n .superRefine((config, ctx) => {\n const keys = config.categories.map((category) => category.key);\n const known = new Set(keys);\n if (known.size !== keys.length) {\n ctx.addIssue({\n code: 'custom',\n path: ['categories'],\n message: 'category keys must be unique',\n });\n }\n if (!known.has(config.fallbackCategory)) {\n ctx.addIssue({\n code: 'custom',\n path: ['fallbackCategory'],\n message: 'must be one of the categories',\n });\n }\n if (config.window.prefetchCategory !== null && !known.has(config.window.prefetchCategory)) {\n ctx.addIssue({\n code: 'custom',\n path: ['window', 'prefetchCategory'],\n message: 'must be one of the categories',\n });\n }\n config.categories.forEach((category, index) => {\n const targets =\n category.adultEquivalent === undefined\n ? []\n : typeof category.adultEquivalent === 'string'\n ? [category.adultEquivalent]\n : category.adultEquivalent.map((rule) => rule.category);\n for (const target of targets) {\n if (!known.has(target)) {\n ctx.addIssue({\n code: 'custom',\n path: ['categories', index, 'adultEquivalent'],\n message: `«${target}» is not one of the categories`,\n });\n }\n }\n });\n // Contiguous and covering the whole day, so every hour has exactly one part.\n let hour = 0;\n config.dayparts.forEach((part, index) => {\n if (part.from !== hour || part.to <= part.from) {\n ctx.addIssue({\n code: 'custom',\n path: ['dayparts', index],\n message: 'dayparts must run contiguously from 0 to 24',\n });\n }\n hour = part.to;\n });\n if (hour !== 24) {\n ctx.addIssue({ code: 'custom', path: ['dayparts'], message: 'dayparts must end at 24' });\n }\n if (config.portal.methods.length === 0 && config.portal.enabled) {\n ctx.addIssue({\n code: 'custom',\n path: ['portal', 'methods'],\n message: 'an enabled portal needs a login method',\n });\n }\n if (config.account.required && !config.portal.enabled) {\n ctx.addIssue({\n code: 'custom',\n path: ['account', 'required'],\n message: 'account.required needs portal.enabled',\n });\n }\n });\n\nfunction deepFreeze<T>(value: T): T {\n if (typeof value === 'object' && value !== null && !Object.isFrozen(value)) {\n Object.freeze(value);\n for (const child of Object.values(value)) deepFreeze(child);\n }\n return value;\n}\n\n/** Thrown with zod's issue list when a config does not validate. */\nexport class BookingConfigError extends Error {\n readonly issues: ReadonlyArray<{ path: string; message: string }>;\n\n constructor(issues: ReadonlyArray<{ path: string; message: string }>) {\n super(\n `Invalid booking config:\\n${issues.map((issue) => ` ${issue.path}: ${issue.message}`).join('\\n')}`\n );\n this.name = 'BookingConfigError';\n this.issues = issues;\n }\n}\n\n/**\n * The site's partial config with every default filled, validated and frozen.\n *\n * The portal's three secondary cookie names derive from `cookieName` unless\n * set, and a login may return to the booking page unless `returnPaths` says\n * otherwise. Idempotent: a resolved config resolves to an equal one.\n */\nexport function resolveBookingConfig(input: BookingConfigInput): Readonly<BookingConfig> {\n const merged = merge(defaults(), input) as Omit<BookingConfig, 'portal'> & {\n portal: Partial<BookingConfig['portal']> & Pick<BookingConfig['portal'], 'cookieName'>;\n };\n const session = merged.portal.cookieName;\n const candidate = {\n ...merged,\n portal: {\n ...merged.portal,\n nextCookieName: merged.portal.nextCookieName ?? `${session}_next`,\n vippsBindingCookieName:\n merged.portal.vippsBindingCookieName ?? `__Host-${session}_vipps_bind`,\n vippsLinkCookieName: merged.portal.vippsLinkCookieName ?? `${session}_vipps_link`,\n returnPaths: {\n exact: merged.portal.returnPaths?.exact ?? [merged.paths.booking],\n prefixes: merged.portal.returnPaths?.prefixes ?? [],\n },\n },\n };\n const derived = (Object.keys(DERIVED_COOKIE_NAMES) as DerivedCookieName[]).filter(\n (name) => merged.portal[name] === undefined\n );\n const result = schema.safeParse(candidate);\n if (!result.success) {\n const lengthIssue = derivedCookieNameIssue(session, derived);\n const issues = result.error.issues\n .map((issue) => ({ path: issue.path.join('.'), message: issue.message }))\n // The derived names' own complaints are the base name's, said once.\n .filter(\n (issue) => lengthIssue === null || !derived.some((name) => issue.path === `portal.${name}`)\n );\n throw new BookingConfigError(lengthIssue === null ? issues : [lengthIssue, ...issues]);\n }\n return deepFreeze(result.data as BookingConfig);\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"config.mjs","names":[],"sources":["../../src/core/config.ts"],"sourcesContent":["/**\n * What a site tells the booking package about itself.\n *\n * `BookingConfig` is CLIENT-SAFE by construction: it is serialised into the\n * page as a prop, so it never holds a key, a session secret or a cache handle.\n * Those live in `BookingServerOptions`, which only `/next` knows (P4).\n *\n * The package reads no environment variable and no CMS. A site resolves its\n * own switches (a handoff URL, whether accounts are on, the contact block) and\n * passes the answers in.\n *\n * Every field but `timeZone` has a default. `resolveBookingConfig` fills them,\n * validates the whole object with zod and freezes it, so a config that reached\n * a factory is one that passed.\n */\n\nimport { z } from 'zod';\nimport type { BookingLabels } from './labels';\nimport { MAX_RANGE_DAYS } from './range';\n\n/** A service group, in display order. Medal's free-string `category` maps onto `key`. */\nexport interface BookingCategory {\n key: string;\n /** Who the group is for. `child` is the kids' path: guest children, `?antall=`, the held link service. */\n audience: 'child' | 'adult' | 'any';\n /**\n * For a child's group: which adult group a service «grows up» into once the\n * child has outgrown the kids' menu. A plain key maps every service in the\n * group; a list maps by a word in the service name, first match wins\n * (`{ nameIncludes: 'gutt', category: 'herre' }`). Case-insensitive.\n */\n adultEquivalent?: string | ReadonlyArray<{ nameIncludes: string; category: string }>;\n}\n\n/** A named part of the day on the business's clock, in local hours `[from, to)`. */\nexport interface BookingDaypart {\n key: string;\n from: number;\n to: number;\n}\n\nexport interface BookingConfig {\n /** IANA zone the business keeps its hours in. Required. */\n timeZone: string;\n /** BCP-47, for every `Intl` formatter and the label pack. */\n locale: string;\n /** ISO 4217. Amounts stay in minor units (øre, pence) on the wire. */\n currency: string;\n /** `NO` is the only strict rule set in 0.x; every other country is `loose`. */\n phone: { country: string; validate: 'strict' | 'loose' };\n /** URLs this site serves. */\n paths: {\n booking: string;\n /** Prefix of the manage page; the token is the next segment. */\n manage: string;\n /** `null` = no customer portal. */\n portal: string | null;\n portalLogin: string;\n api: string;\n portalApi: string;\n avatar: string;\n };\n /**\n * The query keys a link may carry into the booking page.\n *\n * `category`, `service`, `stylist`, `party` and `who` are the marketing\n * deep-link keys; `rebookService` / `rebookStylist` are the portal's «book\n * again» link, read by the wizard's own prefill; `resume` marks a return\n * from an external login; `time` is reserved for a pre-picked start.\n */\n query: {\n category: string;\n service: string;\n stylist: string;\n party: string;\n who: string;\n time: string;\n resume: string;\n rebookService: string;\n rebookStylist: string;\n };\n /** The values `query.who` accepts. */\n whoValues: { child: string; adult: string };\n /** Service groups, in display order. */\n categories: BookingCategory[];\n /** Where an unknown Medal category lands. Must be one of `categories`. */\n fallbackCategory: string;\n /** Step 1's rules. */\n party: {\n maxPeople: number;\n allowParallel: boolean;\n askWhoFirst: boolean;\n /**\n * How many services one person may book as a single visit (1–4; Medal's\n * cap is 4). `1` — the default — is the one-tap service step: a tap\n * answers it. Above `1`, `<BookingWizard>` draws the multi-select step:\n * each person ticks up to this many, done back to back as one visit.\n */\n maxServicesPerPerson: number;\n /**\n * A party with a grown-up in it starts «Samtidig» (`partyMode:\n * 'parallel'`): the child in one chair, the parent in the next, done\n * together. Only until the parent picks a mode, or a stylist link names\n * one stylist (one stylist cannot cut two people at once). Needs\n * `allowParallel`. Off by default: everyone else keeps «Rett etter\n * hverandre».\n */\n adultTogether: boolean;\n };\n /** The bookable window and the page's prefetch. */\n window: { rangeDays: number; prefetchLimit: number; prefetchCategory: string | null };\n /** Named day parts for the time step, contiguous from 0 to 24. */\n dayparts: BookingDaypart[];\n /** Set = the booking path hands off (307) to this URL instead of rendering. */\n handoffUrl: string | null;\n /** The business's own contact, for «call us» and the unavailable screen. */\n contact: { phone: string | null; address: string | null; name: string };\n portal: {\n enabled: boolean;\n methods: Array<'email_code' | 'vipps'>;\n /** The session cookie. */\n cookieName: string;\n /** Where a login that left the site (Vipps) returns to; read once. */\n nextCookieName: string;\n /** Must be `__Host-`-prefixed: Secure, `Path=/`, no `Domain`. */\n vippsBindingCookieName: string;\n vippsLinkCookieName: string;\n /**\n * Where a finished login may send the visitor. `exact` matches the\n * pathname only (the booking page, so a deeper manage URL never\n * qualifies); `prefixes` matches anything below.\n */\n returnPaths: { exact: string[]; prefixes: string[] };\n };\n /**\n * Booking only for a logged-in parent: the details step becomes the login\n * until there is a session, and `/create` refuses without one.\n */\n account: { required: boolean };\n /**\n * Opt-in screen features, all off by default so a site's wizard only\n * changes when it asks:\n * - `recap` — the details step opens with what is being booked (day, hours,\n * who, stylist, total, «edit», and a swap to another stylist free at the\n * same minute when «first available» picked one).\n * - `soonest` — the time step leads with the earliest free starts.\n * - `dayFullness` — each day chip shows how much room the day has.\n * - `summaryDetail` — the sticky bar's time and price on a second line.\n * - `hideDisabledNext` — the bar's «next» only while it does something.\n * - `firstAvailableFaces` — «first available» drawn as the qualified\n * stylists' faces.\n * - `stylistEdgeFade` — the phone stylist row fades at its edge.\n * - `guestParty` — a guest picks a number of children and «me too» instead\n * of the chips, so a parent and child book together without an account.\n * - `childMenuFirst` — a child's service list leads with the children's and\n * anyone's groups; the grown-ups' groups move below the «usually not for\n * this age» divider, still one tap away.\n * - `wideLayouts` — the stylist step keeps its row of faces at every width\n * (the chosen stylist's bio under it), and the time step goes\n * `layout=\"adaptive\"`: days beside the times on an iPad, the whole week as\n * a grid of times on a desktop. Needs the `time.week.*` labels.\n */\n screens: {\n recap: boolean;\n soonest: boolean;\n dayFullness: boolean;\n summaryDetail: boolean;\n hideDisabledNext: boolean;\n firstAvailableFaces: boolean;\n stylistEdgeFade: boolean;\n guestParty: boolean;\n childMenuFirst: boolean;\n wideLayouts: boolean;\n };\n consent: {\n termsUrl: string | null;\n /** `null` = no marketing box. */\n marketing: { text: string; version: string } | null;\n };\n /** Namespaces browser storage keys: `<ns>:booking:draft`, … */\n storageNamespace: string;\n /**\n * `prodId` stamps every calendar file. `uidDomain` is for UIDs the package\n * mints itself (`/react`, 0.1.0); a booking id passed in as `uid` is used as\n * is, so a calendar entry made before a site moved onto the package is still\n * the same entry after it.\n */\n ics: { prodId: string; uidDomain: string };\n monitoring: { enabled: boolean; sampleRate: number };\n /** Copy, merged over the built-in pack for `locale`. */\n labels?: Partial<BookingLabels>;\n}\n\ntype DeepPartial<T> = T extends readonly unknown[]\n ? T\n : T extends object\n ? { [K in keyof T]?: DeepPartial<T[K]> }\n : T;\n\n/** What a site passes: `timeZone`, and whatever it wants to change. */\nexport type BookingConfigInput = { timeZone: string } & DeepPartial<\n Omit<BookingConfig, 'timeZone'>\n>;\n\nconst DEFAULT_PATHS: BookingConfig['paths'] = {\n booking: '/bestill',\n manage: '/bestill/administrer',\n portal: '/min-side',\n portalLogin: '/min-side/logg-inn',\n api: '/api/booking',\n portalApi: '/api/portal',\n avatar: '/api/booking/avatar',\n};\n\n/** Everything but `timeZone` (required) and the portal cookie names (derived). */\nfunction defaults(): Omit<BookingConfig, 'timeZone' | 'portal'> & {\n portal: Omit<\n BookingConfig['portal'],\n 'nextCookieName' | 'vippsBindingCookieName' | 'vippsLinkCookieName' | 'returnPaths'\n >;\n} {\n return {\n locale: 'nb-NO',\n currency: 'NOK',\n phone: { country: 'NO', validate: 'strict' },\n paths: { ...DEFAULT_PATHS },\n query: {\n category: 'kategori',\n service: 'tjeneste',\n stylist: 'frisor',\n party: 'antall',\n who: 'hvem',\n time: 'tid',\n resume: 'resume',\n rebookService: 'service',\n rebookStylist: 'stylist',\n },\n whoValues: { child: 'barn', adult: 'voksen' },\n categories: [\n { key: 'barn', audience: 'child' },\n { key: 'annet', audience: 'any' },\n ],\n fallbackCategory: 'annet',\n party: {\n maxPeople: 3,\n allowParallel: true,\n askWhoFirst: true,\n maxServicesPerPerson: 1,\n adultTogether: false,\n },\n window: { rangeDays: 7, prefetchLimit: 4, prefetchCategory: null },\n dayparts: [\n { key: 'formiddag', from: 0, to: 12 },\n { key: 'ettermiddag', from: 12, to: 17 },\n { key: 'kveld', from: 17, to: 24 },\n ],\n handoffUrl: null,\n contact: { phone: null, address: null, name: '' },\n portal: { enabled: false, methods: ['email_code'], cookieName: 'booking_portal' },\n account: { required: false },\n screens: {\n recap: false,\n soonest: false,\n dayFullness: false,\n summaryDetail: false,\n hideDisabledNext: false,\n firstAvailableFaces: false,\n stylistEdgeFade: false,\n guestParty: false,\n childMenuFirst: false,\n wideLayouts: false,\n },\n consent: { termsUrl: null, marketing: null },\n storageNamespace: 'medal',\n ics: { prodId: '-//Medal Social//Booking//EN', uidDomain: 'booking.invalid' },\n monitoring: { enabled: true, sampleRate: 1 },\n };\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value);\n}\n\n/**\n * Objects merge key by key; arrays, scalars and `null` replace. Only\n * `undefined` keeps the default — `paths.portal: null` means «no portal».\n */\nfunction merge(base: unknown, patch: unknown): unknown {\n if (patch === undefined) return base;\n if (!isRecord(base) || !isRecord(patch)) return patch;\n const out: Record<string, unknown> = { ...base };\n for (const [key, value] of Object.entries(patch)) out[key] = merge(base[key], value);\n return out;\n}\n\nfunction validTimeZone(value: string): boolean {\n try {\n new Intl.DateTimeFormat('en-US', { timeZone: value });\n return true;\n } catch {\n return false;\n }\n}\n\nfunction validLocale(value: string): boolean {\n try {\n return Intl.getCanonicalLocales(value).length === 1;\n } catch {\n return false;\n }\n}\n\nconst path = z.string().regex(/^\\/[^\\s?#]*$/, 'must be a root-relative path');\nconst key = z.string().regex(/^[\\w-]{1,64}$/, 'must be 1–64 word characters');\n/** Group keys and `?hvem=` values are matched against a lower-cased query value. */\nconst lowerKey = z.string().regex(/^[a-z0-9_-]{1,64}$/, 'must be 1–64 lower-case word characters');\n/** A prefix must end at a segment boundary, or `/flow` would also admit `/flow-evil`. */\nconst prefix = path.refine((value) => value.endsWith('/'), 'a prefix must end with /');\n/** The longest cookie name the config takes. */\nconst MAX_COOKIE_NAME = 128;\n/** RFC 6265 token characters, which is every name a browser keeps intact. */\nconst cookieName = z.string().regex(/^[!#$%&'*+\\-.^_`|~0-9A-Za-z]{1,128}$/);\n\n/**\n * How each secondary portal cookie name derives from `portal.cookieName` when\n * the site sets none — the prefix and suffix, so a too-long base can be\n * reported against the name the site actually wrote.\n */\nconst DERIVED_COOKIE_NAMES = {\n nextCookieName: { prefix: '', suffix: '_next' },\n vippsBindingCookieName: { prefix: '__Host-', suffix: '_vipps_bind' },\n vippsLinkCookieName: { prefix: '', suffix: '_vipps_link' },\n} as const;\n\ntype DerivedCookieName = keyof typeof DERIVED_COOKIE_NAMES;\n\n/**\n * A derived name that would be too long, said about `portal.cookieName`\n * (which the site wrote) rather than a name it never set. `null` when every\n * derived name fits — the schema then judges the characters as usual — and\n * when the base is not a string at all (a runtime input the types did not\n * catch): the schema's own issue on `portal.cookieName` says so.\n */\nfunction derivedCookieNameIssue(\n session: unknown,\n derived: readonly DerivedCookieName[]\n): { path: string; message: string } | null {\n if (typeof session !== 'string') return null;\n const tooLong = derived.filter((name) => {\n const { prefix, suffix } = DERIVED_COOKIE_NAMES[name];\n return prefix.length + session.length + suffix.length > MAX_COOKIE_NAME;\n });\n if (tooLong.length === 0) return null;\n const room = Math.min(\n ...tooLong.map((name) => {\n const { prefix, suffix } = DERIVED_COOKIE_NAMES[name];\n return MAX_COOKIE_NAME - prefix.length - suffix.length;\n })\n );\n return {\n path: 'portal.cookieName',\n message: `must be at most ${room} characters, or the derived ${tooLong\n .map((name) => `portal.${name}`)\n .join(', ')} would exceed ${MAX_COOKIE_NAME}; shorten it or set ${\n tooLong.length === 1 ? 'that name' : 'those names'\n } explicitly`,\n };\n}\n\nconst schema = z\n .object({\n timeZone: z.string().refine(validTimeZone, 'must be an IANA time zone'),\n locale: z.string().refine(validLocale, 'must be a BCP-47 locale'),\n currency: z.string().regex(/^[A-Z]{3}$/, 'must be an ISO 4217 code'),\n phone: z.object({\n country: z.string().regex(/^[A-Z]{2}$/),\n validate: z.enum(['strict', 'loose']),\n }),\n paths: z.object({\n booking: path,\n manage: path,\n portal: path.nullable(),\n portalLogin: path,\n api: path,\n portalApi: path,\n avatar: path,\n }),\n query: z.object({\n category: key,\n service: key,\n stylist: key,\n party: key,\n who: key,\n time: key,\n resume: key,\n rebookService: key,\n rebookStylist: key,\n }),\n whoValues: z.object({ child: lowerKey, adult: lowerKey }),\n categories: z\n .array(\n z.object({\n key: lowerKey,\n audience: z.enum(['child', 'adult', 'any']),\n adultEquivalent: z\n .union([\n lowerKey,\n z.array(z.object({ nameIncludes: z.string().min(1), category: lowerKey })),\n ])\n .optional(),\n })\n )\n .min(1),\n fallbackCategory: lowerKey,\n party: z.object({\n maxPeople: z.number().int().min(1).max(20),\n allowParallel: z.boolean(),\n askWhoFirst: z.boolean(),\n maxServicesPerPerson: z.number().int().min(1).max(4),\n adultTogether: z.boolean(),\n }),\n window: z.object({\n rangeDays: z.number().int().min(1).max(MAX_RANGE_DAYS),\n prefetchLimit: z.number().int().min(0).max(20),\n prefetchCategory: lowerKey.nullable(),\n }),\n dayparts: z\n .array(\n z.object({\n key,\n from: z.number().int().min(0).max(23),\n to: z.number().int().min(1).max(24),\n })\n )\n .min(1),\n handoffUrl: z.url({ protocol: /^https?$/ }).nullable(),\n contact: z.object({\n phone: z.string().nullable(),\n address: z.string().nullable(),\n name: z.string(),\n }),\n portal: z.object({\n enabled: z.boolean(),\n methods: z.array(z.enum(['email_code', 'vipps'])),\n cookieName,\n nextCookieName: cookieName,\n vippsBindingCookieName: cookieName.refine(\n (name) => name.startsWith('__Host-'),\n 'must carry the __Host- prefix'\n ),\n vippsLinkCookieName: cookieName,\n returnPaths: z.object({ exact: z.array(path), prefixes: z.array(prefix) }),\n }),\n account: z.object({ required: z.boolean() }),\n screens: z.object({\n recap: z.boolean(),\n soonest: z.boolean(),\n dayFullness: z.boolean(),\n summaryDetail: z.boolean(),\n hideDisabledNext: z.boolean(),\n firstAvailableFaces: z.boolean(),\n stylistEdgeFade: z.boolean(),\n guestParty: z.boolean(),\n childMenuFirst: z.boolean(),\n wideLayouts: z.boolean(),\n }),\n consent: z.object({\n termsUrl: z.string().min(1).nullable(),\n marketing: z.object({ text: z.string().min(1), version: z.string().min(1) }).nullable(),\n }),\n storageNamespace: key,\n ics: z.object({ prodId: z.string().regex(/^-\\/\\/.+\\/\\/.+$/), uidDomain: z.string().min(1) }),\n monitoring: z.object({ enabled: z.boolean(), sampleRate: z.number().min(0).max(1) }),\n labels: z.record(z.string(), z.union([z.string(), z.array(z.string())])).optional(),\n })\n .superRefine((config, ctx) => {\n const keys = config.categories.map((category) => category.key);\n const known = new Set(keys);\n if (known.size !== keys.length) {\n ctx.addIssue({\n code: 'custom',\n path: ['categories'],\n message: 'category keys must be unique',\n });\n }\n if (!known.has(config.fallbackCategory)) {\n ctx.addIssue({\n code: 'custom',\n path: ['fallbackCategory'],\n message: 'must be one of the categories',\n });\n }\n if (config.window.prefetchCategory !== null && !known.has(config.window.prefetchCategory)) {\n ctx.addIssue({\n code: 'custom',\n path: ['window', 'prefetchCategory'],\n message: 'must be one of the categories',\n });\n }\n config.categories.forEach((category, index) => {\n const targets =\n category.adultEquivalent === undefined\n ? []\n : typeof category.adultEquivalent === 'string'\n ? [category.adultEquivalent]\n : category.adultEquivalent.map((rule) => rule.category);\n for (const target of targets) {\n if (!known.has(target)) {\n ctx.addIssue({\n code: 'custom',\n path: ['categories', index, 'adultEquivalent'],\n message: `«${target}» is not one of the categories`,\n });\n }\n }\n });\n // Contiguous and covering the whole day, so every hour has exactly one part.\n let hour = 0;\n config.dayparts.forEach((part, index) => {\n if (part.from !== hour || part.to <= part.from) {\n ctx.addIssue({\n code: 'custom',\n path: ['dayparts', index],\n message: 'dayparts must run contiguously from 0 to 24',\n });\n }\n hour = part.to;\n });\n if (hour !== 24) {\n ctx.addIssue({ code: 'custom', path: ['dayparts'], message: 'dayparts must end at 24' });\n }\n if (config.portal.methods.length === 0 && config.portal.enabled) {\n ctx.addIssue({\n code: 'custom',\n path: ['portal', 'methods'],\n message: 'an enabled portal needs a login method',\n });\n }\n if (config.account.required && !config.portal.enabled) {\n ctx.addIssue({\n code: 'custom',\n path: ['account', 'required'],\n message: 'account.required needs portal.enabled',\n });\n }\n });\n\nfunction deepFreeze<T>(value: T): T {\n if (typeof value === 'object' && value !== null && !Object.isFrozen(value)) {\n Object.freeze(value);\n for (const child of Object.values(value)) deepFreeze(child);\n }\n return value;\n}\n\n/** Thrown with zod's issue list when a config does not validate. */\nexport class BookingConfigError extends Error {\n readonly issues: ReadonlyArray<{ path: string; message: string }>;\n\n constructor(issues: ReadonlyArray<{ path: string; message: string }>) {\n super(\n `Invalid booking config:\\n${issues.map((issue) => ` ${issue.path}: ${issue.message}`).join('\\n')}`\n );\n this.name = 'BookingConfigError';\n this.issues = issues;\n }\n}\n\n/**\n * The site's partial config with every default filled, validated and frozen.\n *\n * The portal's three secondary cookie names derive from `cookieName` unless\n * set, and a login may return to the booking page unless `returnPaths` says\n * otherwise. Idempotent: a resolved config resolves to an equal one.\n */\nexport function resolveBookingConfig(input: BookingConfigInput): Readonly<BookingConfig> {\n const merged = merge(defaults(), input) as Omit<BookingConfig, 'portal'> & {\n portal: Partial<BookingConfig['portal']> & Pick<BookingConfig['portal'], 'cookieName'>;\n };\n const session = merged.portal.cookieName;\n const candidate = {\n ...merged,\n portal: {\n ...merged.portal,\n nextCookieName: merged.portal.nextCookieName ?? `${session}_next`,\n vippsBindingCookieName:\n merged.portal.vippsBindingCookieName ?? `__Host-${session}_vipps_bind`,\n vippsLinkCookieName: merged.portal.vippsLinkCookieName ?? `${session}_vipps_link`,\n returnPaths: {\n exact: merged.portal.returnPaths?.exact ?? [merged.paths.booking],\n prefixes: merged.portal.returnPaths?.prefixes ?? [],\n },\n },\n };\n const derived = (Object.keys(DERIVED_COOKIE_NAMES) as DerivedCookieName[]).filter(\n (name) => merged.portal[name] === undefined\n );\n const result = schema.safeParse(candidate);\n if (!result.success) {\n const lengthIssue = derivedCookieNameIssue(session, derived);\n const issues = result.error.issues\n .map((issue) => ({ path: issue.path.join('.'), message: issue.message }))\n // The derived names' own complaints are the base name's, said once.\n .filter(\n (issue) => lengthIssue === null || !derived.some((name) => issue.path === `portal.${name}`)\n );\n throw new BookingConfigError(lengthIssue === null ? issues : [lengthIssue, ...issues]);\n }\n return deepFreeze(result.data as BookingConfig);\n}\n"],"mappings":"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"}
@@ -28,8 +28,9 @@ import { ReturnPathGuard, createReturnPath } from "./portal/return-path.mjs";
28
28
  import { SessionCookie, createSessionCookie, isPortalSessionToken } from "./portal/session-cookie.mjs";
29
29
  import { VippsLinkCookies, createVippsLinkCookies, isVippsToken, mintBrowserBinding, vippsLinkVerifyPath } from "./portal/vipps-link.mjs";
30
30
  import { VIPPS_CONFIRM_QUERY, maskedAddress, stripVippsReturn, vippsConfirmFrom } from "./portal/vipps-return.mjs";
31
+ import { MAX_RANGE_DAYS, MAX_RANGE_MS } from "./range.mjs";
31
32
  import { RebookStore, createRebookStore, rebookStorageKey } from "./rebook-store.mjs";
32
33
  import { RESTORE_FALLBACK_MS, RestoreGate, createRestoreGate, restorePendingInStorage } from "./restore-gate.mjs";
33
34
  import { Stores, createStores } from "./stores.mjs";
34
35
  import { VisitTiming, visitKey, visitKeyOfIds, visitOf, visitOfServices, visitPriceOre, visitServicesOf } from "./visit.mjs";
35
- export { ATTEMPT_TTL_MS, Age, AgeBounds, AgeRange, AttemptStore, BOOKED_FOR_NAME_MAX_LENGTH, BookingAttempt, BookingCategory, BookingConfig, BookingConfigError, BookingConfigInput, BookingDayDto, BookingDaypart, BookingFamilyMember, BookingGuardian, BookingLabel, BookingLabels, BookingLastVisit, BookingManageDto, BookingResourceDto, BookingServiceDto, BookingSlotDto, BookingSubmission, BookingSubmissionItem, CONSENT_SOURCE, Clock, ClockConfig, ConfirmedBooking, CookieAttributes, CookieJar, CookieSource, CoreLabels, DRAFT_MAX_AGE_MS, DeepLinks, DraftStore, Dto, FamilyKey, Ics, IcsEvent, KeyedLabels, LABEL_PACKS, MarketingConsent, MedalManageSummary, MedalResource, MedalScheduleDay, MedalService, MedalSlot, Money, NEXT_FREE_TTL_MS, NextFree, NextFreeClient, PartySeat, PartySlot, PartySlots, Paths, Phone, PortalBookingDto, PortalDto, PortalFamilyMemberDto, PortalLabels, PortalNextPath, PortalProfileDto, RESOURCE_ID_SHAPE, RESTORE_FALLBACK_MS, RebookStore, RebookSuggestion, RestoreGate, ReturnPathGuard, SELF_KEY, SessionCookie, SlotsByService, Stores, SubmittedVisit, VIPPS_CONFIRM_QUERY, VerifyLoginFailure, VippsLinkCookies, VisitTiming, Wizard, WizardAction, WizardCatalogue, WizardConfig, WizardDraft, WizardDraftItem, WizardItem, WizardPerson, WizardPrefill, WizardService, WizardState, WizardStep, adultCategoryFor, adultEquivalent, ageOnDay, attemptStorageKey, canAdvance, canGoToStep, categoryOrder, childCategory, codeField, completedNewestFirst, createAge, createAttemptStore, createClock, createDeepLinks, createDraftStore, createDto, createIcs, createMoney, createNextFree, createPartySlots, createPaths, createPhone, createPortalDto, createPortalNextPath, createRebookStore, createRestoreGate, createReturnPath, createSessionCookie, createStores, createVippsLinkCookies, createWizard, draftStorageKey, earliestOpening, emailField, fill, fillParts, findPartySlots, firstBookingFor, firstOpeningPerResource, firstPartyStartPerResource, fitsAge, guestChild, icsDataUrl, initialState, initialsOf, isBookingLabel, isChildCategory, isGuestSeat, isPortalSessionToken, isVippsToken, itemResourceIds, itemStartTimes, labelPackFor, labelText, marketingConsent, maskedAddress, mintBrowserBinding, monthOrNull, normaliseCategory, parseParty, rebookStorageKey, rebookSuggestions, resolveBookingConfig, resolveLabels, resourceMatches, restorePendingInStorage, serviceMatches, showsPartyMode, slotKeysFor, slugify, stripVippsReturn, stylistDisplayName, stylistSlug, telHref, toBookingDayDto, toBookingSlotDto, toPortalProfileDto, upcomingSoonestFirst, verifyLoginInput, vippsConfirmFrom, vippsLinkVerifyPath, visitEndTs, visitKey, visitKeyOfIds, visitMinutes, visitOf, visitOfServices, visitPriceOre, visitServicesOf, visitTailMinutes, withBookingQuery };
36
+ export { ATTEMPT_TTL_MS, Age, AgeBounds, AgeRange, AttemptStore, BOOKED_FOR_NAME_MAX_LENGTH, BookingAttempt, BookingCategory, BookingConfig, BookingConfigError, BookingConfigInput, BookingDayDto, BookingDaypart, BookingFamilyMember, BookingGuardian, BookingLabel, BookingLabels, BookingLastVisit, BookingManageDto, BookingResourceDto, BookingServiceDto, BookingSlotDto, BookingSubmission, BookingSubmissionItem, CONSENT_SOURCE, Clock, ClockConfig, ConfirmedBooking, CookieAttributes, CookieJar, CookieSource, CoreLabels, DRAFT_MAX_AGE_MS, DeepLinks, DraftStore, Dto, FamilyKey, Ics, IcsEvent, KeyedLabels, LABEL_PACKS, MAX_RANGE_DAYS, MAX_RANGE_MS, MarketingConsent, MedalManageSummary, MedalResource, MedalScheduleDay, MedalService, MedalSlot, Money, NEXT_FREE_TTL_MS, NextFree, NextFreeClient, PartySeat, PartySlot, PartySlots, Paths, Phone, PortalBookingDto, PortalDto, PortalFamilyMemberDto, PortalLabels, PortalNextPath, PortalProfileDto, RESOURCE_ID_SHAPE, RESTORE_FALLBACK_MS, RebookStore, RebookSuggestion, RestoreGate, ReturnPathGuard, SELF_KEY, SessionCookie, SlotsByService, Stores, SubmittedVisit, VIPPS_CONFIRM_QUERY, VerifyLoginFailure, VippsLinkCookies, VisitTiming, Wizard, WizardAction, WizardCatalogue, WizardConfig, WizardDraft, WizardDraftItem, WizardItem, WizardPerson, WizardPrefill, WizardService, WizardState, WizardStep, adultCategoryFor, adultEquivalent, ageOnDay, attemptStorageKey, canAdvance, canGoToStep, categoryOrder, childCategory, codeField, completedNewestFirst, createAge, createAttemptStore, createClock, createDeepLinks, createDraftStore, createDto, createIcs, createMoney, createNextFree, createPartySlots, createPaths, createPhone, createPortalDto, createPortalNextPath, createRebookStore, createRestoreGate, createReturnPath, createSessionCookie, createStores, createVippsLinkCookies, createWizard, draftStorageKey, earliestOpening, emailField, fill, fillParts, findPartySlots, firstBookingFor, firstOpeningPerResource, firstPartyStartPerResource, fitsAge, guestChild, icsDataUrl, initialState, initialsOf, isBookingLabel, isChildCategory, isGuestSeat, isPortalSessionToken, isVippsToken, itemResourceIds, itemStartTimes, labelPackFor, labelText, marketingConsent, maskedAddress, mintBrowserBinding, monthOrNull, normaliseCategory, parseParty, rebookStorageKey, rebookSuggestions, resolveBookingConfig, resolveLabels, resourceMatches, restorePendingInStorage, serviceMatches, showsPartyMode, slotKeysFor, slugify, stripVippsReturn, stylistDisplayName, stylistSlug, telHref, toBookingDayDto, toBookingSlotDto, toPortalProfileDto, upcomingSoonestFirst, verifyLoginInput, vippsConfirmFrom, vippsLinkVerifyPath, visitEndTs, visitKey, visitKeyOfIds, visitMinutes, visitOf, visitOfServices, visitPriceOre, visitServicesOf, visitTailMinutes, withBookingQuery };
@@ -3,6 +3,7 @@ import { LABEL_PACKS, fill, fillParts, isBookingLabel, labelPackFor, labelText,
3
3
  import { ageOnDay, createAge, fitsAge } from "./age.mjs";
4
4
  import { ATTEMPT_TTL_MS, attemptStorageKey, createAttemptStore } from "./attempt-store.mjs";
5
5
  import { createClock } from "./clock.mjs";
6
+ import { MAX_RANGE_DAYS, MAX_RANGE_MS } from "./range.mjs";
6
7
  import { BookingConfigError, resolveBookingConfig } from "./config.mjs";
7
8
  import { CONSENT_SOURCE, marketingConsent } from "./consent.mjs";
8
9
  import { initialsOf, stylistDisplayName } from "./display-name.mjs";
@@ -29,4 +30,4 @@ import { VIPPS_CONFIRM_QUERY, maskedAddress, stripVippsReturn, vippsConfirmFrom
29
30
  import { createRebookStore, rebookStorageKey } from "./rebook-store.mjs";
30
31
  import { RESTORE_FALLBACK_MS, createRestoreGate, restorePendingInStorage } from "./restore-gate.mjs";
31
32
  import { createStores } from "./stores.mjs";
32
- export { ATTEMPT_TTL_MS, BOOKED_FOR_NAME_MAX_LENGTH, BookingConfigError, CONSENT_SOURCE, DRAFT_MAX_AGE_MS, LABEL_PACKS, NEXT_FREE_TTL_MS, RESOURCE_ID_SHAPE, RESTORE_FALLBACK_MS, SELF_KEY, VIPPS_CONFIRM_QUERY, adultCategoryFor, adultEquivalent, ageOnDay, attemptStorageKey, canAdvance, canGoToStep, categoryOrder, childCategory, codeField, completedNewestFirst, createAge, createAttemptStore, createClock, createDeepLinks, createDraftStore, createDto, createIcs, createMoney, createNextFree, createPartySlots, createPaths, createPhone, createPortalDto, createPortalNextPath, createRebookStore, createRestoreGate, createReturnPath, createSessionCookie, createStores, createVippsLinkCookies, createWizard, draftStorageKey, earliestOpening, emailField, fill, fillParts, findPartySlots, firstBookingFor, firstOpeningPerResource, firstPartyStartPerResource, fitsAge, guestChild, icsDataUrl, initialState, initialsOf, isBookingLabel, isChildCategory, isGuestSeat, isPortalSessionToken, isVippsToken, itemResourceIds, itemStartTimes, labelPackFor, labelText, marketingConsent, maskedAddress, mintBrowserBinding, monthOrNull, normaliseCategory, parseParty, rebookStorageKey, rebookSuggestions, resolveBookingConfig, resolveLabels, resourceMatches, restorePendingInStorage, serviceMatches, showsPartyMode, slotKeysFor, slugify, stripVippsReturn, stylistDisplayName, stylistSlug, telHref, toBookingDayDto, toBookingSlotDto, toPortalProfileDto, upcomingSoonestFirst, verifyLoginInput, vippsConfirmFrom, vippsLinkVerifyPath, visitEndTs, visitKey, visitKeyOfIds, visitMinutes, visitOf, visitOfServices, visitPriceOre, visitServicesOf, visitTailMinutes, withBookingQuery };
33
+ export { ATTEMPT_TTL_MS, BOOKED_FOR_NAME_MAX_LENGTH, BookingConfigError, CONSENT_SOURCE, DRAFT_MAX_AGE_MS, LABEL_PACKS, MAX_RANGE_DAYS, MAX_RANGE_MS, NEXT_FREE_TTL_MS, RESOURCE_ID_SHAPE, RESTORE_FALLBACK_MS, SELF_KEY, VIPPS_CONFIRM_QUERY, adultCategoryFor, adultEquivalent, ageOnDay, attemptStorageKey, canAdvance, canGoToStep, categoryOrder, childCategory, codeField, completedNewestFirst, createAge, createAttemptStore, createClock, createDeepLinks, createDraftStore, createDto, createIcs, createMoney, createNextFree, createPartySlots, createPaths, createPhone, createPortalDto, createPortalNextPath, createRebookStore, createRestoreGate, createReturnPath, createSessionCookie, createStores, createVippsLinkCookies, createWizard, draftStorageKey, earliestOpening, emailField, fill, fillParts, findPartySlots, firstBookingFor, firstOpeningPerResource, firstPartyStartPerResource, fitsAge, guestChild, icsDataUrl, initialState, initialsOf, isBookingLabel, isChildCategory, isGuestSeat, isPortalSessionToken, isVippsToken, itemResourceIds, itemStartTimes, labelPackFor, labelText, marketingConsent, maskedAddress, mintBrowserBinding, monthOrNull, normaliseCategory, parseParty, rebookStorageKey, rebookSuggestions, resolveBookingConfig, resolveLabels, resourceMatches, restorePendingInStorage, serviceMatches, showsPartyMode, slotKeysFor, slugify, stripVippsReturn, stylistDisplayName, stylistSlug, telHref, toBookingDayDto, toBookingSlotDto, toPortalProfileDto, upcomingSoonestFirst, verifyLoginInput, vippsConfirmFrom, vippsLinkVerifyPath, visitEndTs, visitKey, visitKeyOfIds, visitMinutes, visitOf, visitOfServices, visitPriceOre, visitServicesOf, visitTailMinutes, withBookingQuery };
@@ -0,0 +1,30 @@
1
+ //#region src/core/range.d.ts
2
+ /**
3
+ * How far one booking read may reach — shared by the config schema
4
+ * (`window.rangeDays`), the read routes' range guard (`parseRange`) and the
5
+ * catalogue's cache horizon, so the three cannot disagree.
6
+ */
7
+ /**
8
+ * The longest calendar window a booking page may ask for, in days — the
9
+ * `window.rangeDays` ceiling.
10
+ */
11
+ export declare const MAX_RANGE_DAYS = 62;
12
+ /**
13
+ * The longest stretch, in milliseconds, a booking read may cover: the widest
14
+ * `window()` there is, at its worst.
15
+ *
16
+ * NOT `MAX_RANGE_DAYS × 24 h`. `window()` ends at a business midnight, and a
17
+ * stretch of calendar days holding the autumn clock change is an hour longer
18
+ * than its days × 24 h. A page opened just after midnight with the widest
19
+ * window would ask for 62 days and up to an hour more — which a bare
20
+ * `62 × 24 h` cap refused, every night for the eight weeks before the change,
21
+ * for every service the page had not been seeded with. The one DST hour is
22
+ * the most any zone's clock change adds (Lord Howe's is half that).
23
+ *
24
+ * The config schema, the read routes' range guard and the catalogue's cache
25
+ * horizon all read these two constants, so they cannot drift apart again;
26
+ * `tests/next/range-bound.test.ts` pins the window to the guard.
27
+ */
28
+ export declare const MAX_RANGE_MS: number;
29
+ //#endregion
30
+ //# sourceMappingURL=range.d.mts.map
@@ -0,0 +1,32 @@
1
+ //#region src/core/range.ts
2
+ /**
3
+ * How far one booking read may reach — shared by the config schema
4
+ * (`window.rangeDays`), the read routes' range guard (`parseRange`) and the
5
+ * catalogue's cache horizon, so the three cannot disagree.
6
+ */
7
+ /**
8
+ * The longest calendar window a booking page may ask for, in days — the
9
+ * `window.rangeDays` ceiling.
10
+ */
11
+ const MAX_RANGE_DAYS = 62;
12
+ /**
13
+ * The longest stretch, in milliseconds, a booking read may cover: the widest
14
+ * `window()` there is, at its worst.
15
+ *
16
+ * NOT `MAX_RANGE_DAYS × 24 h`. `window()` ends at a business midnight, and a
17
+ * stretch of calendar days holding the autumn clock change is an hour longer
18
+ * than its days × 24 h. A page opened just after midnight with the widest
19
+ * window would ask for 62 days and up to an hour more — which a bare
20
+ * `62 × 24 h` cap refused, every night for the eight weeks before the change,
21
+ * for every service the page had not been seeded with. The one DST hour is
22
+ * the most any zone's clock change adds (Lord Howe's is half that).
23
+ *
24
+ * The config schema, the read routes' range guard and the catalogue's cache
25
+ * horizon all read these two constants, so they cannot drift apart again;
26
+ * `tests/next/range-bound.test.ts` pins the window to the guard.
27
+ */
28
+ const MAX_RANGE_MS = 53604e5;
29
+ //#endregion
30
+ export { MAX_RANGE_DAYS, MAX_RANGE_MS };
31
+
32
+ //# sourceMappingURL=range.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"range.mjs","names":[],"sources":["../../src/core/range.ts"],"sourcesContent":["/**\n * How far one booking read may reach — shared by the config schema\n * (`window.rangeDays`), the read routes' range guard (`parseRange`) and the\n * catalogue's cache horizon, so the three cannot disagree.\n */\n\n/**\n * The longest calendar window a booking page may ask for, in days — the\n * `window.rangeDays` ceiling.\n */\nexport const MAX_RANGE_DAYS = 62;\n\n/**\n * The longest stretch, in milliseconds, a booking read may cover: the widest\n * `window()` there is, at its worst.\n *\n * NOT `MAX_RANGE_DAYS × 24 h`. `window()` ends at a business midnight, and a\n * stretch of calendar days holding the autumn clock change is an hour longer\n * than its days × 24 h. A page opened just after midnight with the widest\n * window would ask for 62 days and up to an hour more — which a bare\n * `62 × 24 h` cap refused, every night for the eight weeks before the change,\n * for every service the page had not been seeded with. The one DST hour is\n * the most any zone's clock change adds (Lord Howe's is half that).\n *\n * The config schema, the read routes' range guard and the catalogue's cache\n * horizon all read these two constants, so they cannot drift apart again;\n * `tests/next/range-bound.test.ts` pins the window to the guard.\n */\nexport const MAX_RANGE_MS = MAX_RANGE_DAYS * 86_400_000 + 3_600_000;\n"],"mappings":";;;;;;;;;;AAUA,MAAa,iBAAiB;;;;;;;;;;;;;;;;;AAkB9B,MAAa,eAAe"}
@@ -1,4 +1,5 @@
1
1
  import { createClock } from "../core/clock.mjs";
2
+ import { MAX_RANGE_MS } from "../core/range.mjs";
2
3
  import { visitKeyOfIds } from "../core/visit.mjs";
3
4
  import { cacheLoad } from "./options.mjs";
4
5
  //#region src/next/catalogue.ts
@@ -39,9 +40,6 @@ const SLOTS_TTL_S = 30;
39
40
  /** The slot key step, and the catalogue's time bucket. */
40
41
  const SLOT_KEY_STEP_MS = 3e4;
41
42
  const CATALOGUE_BUCKET_MS = 3e5;
42
- /** How far ahead a cached read may reach; beyond it the read is live. Matches
43
- * the 62-day range bound the routes put on a query string. */
44
- const MAX_KEY_AHEAD_MS = 53568e5;
45
43
  function slotsTag(serviceId) {
46
44
  return `booking-slots:${serviceId}`;
47
45
  }
@@ -83,7 +81,8 @@ function createCatalogue(options, medal) {
83
81
  * - `from` is `slotKeyStart` (the later of it and now, floored to 30 s);
84
82
  * - `to` rounded UP to a local midnight — the booking page's window already
85
83
  * ends on one, so it is unchanged there;
86
- * - a key reaching more than 62 days ahead is not cached at all;
84
+ * - a key reaching further ahead than the routes' range bound
85
+ * (`MAX_RANGE_MS`) is not cached at all;
87
86
  * - a stylist-narrowed read (the manage page's) is not cached at all.
88
87
  *
89
88
  * The caller's real bounds are applied to the answer afterwards.
@@ -92,7 +91,7 @@ function createCatalogue(options, medal) {
92
91
  if (args.resourceId) return null;
93
92
  const fromTs = slotKeyStart(args.fromTs, now);
94
93
  const toTs = roundUpToDay(args.toTs);
95
- const limit = now + MAX_KEY_AHEAD_MS;
94
+ const limit = now + MAX_RANGE_MS;
96
95
  if (fromTs > limit || toTs > limit || toTs <= fromTs) return null;
97
96
  return {
98
97
  fromTs,
@@ -1 +1 @@
1
- {"version":3,"file":"catalogue.mjs","names":[],"sources":["../../src/next/catalogue.ts"],"sourcesContent":["/**\n * A short-lived data cache in front of Medal's booking READS (the L2 cache).\n *\n * The booking page and the wizard's route handlers would otherwise ask Medal\n * live on every request, which is most of a one-second TTFB and most of the\n * wait after a service tap. It reads through `options.cache.data`: with the\n * `next-data` adapter that is `unstable_cache` (on OpenNext, the incremental\n * cache — R2 plus the Durable Object tag cache), shared across isolates and\n * visitors.\n *\n * `unstable_cache` is stale-while-revalidate: an expired entry is served once\n * more while it refills. So every key also carries TIME — the slot key a 30 s\n * bucket, the catalogue keys a five-minute one — and a key that has moved on\n * is a miss, not a stale hit. The resulting bounds:\n *\n * - free slots (tag `booking-slots:<serviceId>`, one per service in the\n * visit): at most ~60 s old. The\n * site's own create / move / cancel expire the tag at once (`expireSlots`);\n * the bound is what covers a booking made anywhere else;\n * - the catalogue (services, stylists, opening hours; tag `booking-catalogue`):\n * at most ~10 min old.\n *\n * KEYS. Every entry is keyed `[prefix, '<read>']` plus its arguments, the\n * scheme the first site on this package already uses, so its key parts stay\n * the same when `prefix` does. A slot or schedule key names the VISIT\n * (`visitKeyOfIds`: `a+b` for a person having both, the bare id for one\n * service), so a one-service key is byte-identical to what it was before\n * visits existed and a deploy does not empty the cache.\n *\n * A failed read throws and is not cached, so a Medal blip is not pinned.\n */\n\nimport { createClock } from '../core/clock';\nimport type { BookingConfig } from '../core/config';\nimport { visitKeyOfIds } from '../core/visit';\nimport type { MedalResource, MedalScheduleDay, MedalService, MedalSlot } from '../core/wire';\nimport type { MedalSeam, RangeArgs } from './medal';\nimport { type BookingCacheAdapter, type BookingLogger, cacheLoad } from './options';\n\nexport const CATALOGUE_TAG = 'booking-catalogue';\nconst CATALOGUE_TTL_S = 300;\nconst SLOTS_TTL_S = 30;\n\n/** The slot key step, and the catalogue's time bucket. */\nconst SLOT_KEY_STEP_MS = 30 * 1000;\nconst CATALOGUE_BUCKET_MS = 5 * 60 * 1000;\n\n/** How far ahead a cached read may reach; beyond it the read is live. Matches\n * the 62-day range bound the routes put on a query string. */\nconst MAX_KEY_AHEAD_MS = 62 * 24 * 60 * 60 * 1000;\n\n/** `{ fresh: true }` skips the cache for one read and writes no entry. Server\n * callers only — the create route's live re-read after a `slotTaken`; no\n * route lets a visitor ask for it. */\nexport interface ReadOptions {\n fresh?: boolean;\n}\n\nexport function slotsTag(serviceId: string): string {\n return `booking-slots:${serviceId}`;\n}\n\n/**\n * Where a slot key starts: the later of the caller's `from` and now, floored to\n * 30 s. Unrounded, no two visitors would share an entry; not clamped to now,\n * any past instant would be a key of its own.\n */\nexport function slotKeyStart(fromTs: number, now: number = Date.now()): number {\n return Math.floor(Math.max(fromTs, now) / SLOT_KEY_STEP_MS) * SLOT_KEY_STEP_MS;\n}\n\n/** Every service in the read's visit, the first one first. */\nfunction visitIdsOf(args: RangeArgs): string[] {\n return [args.serviceId, ...(args.extraServiceIds ?? [])];\n}\n\n/** `extraServiceIds` as a spread: absent for a one-service visit, so a\n * one-service read reaches the seam with exactly the arguments it always had. */\nfunction extrasOf(args: RangeArgs): Pick<RangeArgs, 'extraServiceIds'> {\n return args.extraServiceIds?.length ? { extraServiceIds: args.extraServiceIds } : {};\n}\n\nfunction catalogueBucket(now: number): number {\n return Math.floor(now / CATALOGUE_BUCKET_MS);\n}\n\nexport interface Catalogue {\n cachedServices(): Promise<MedalService[]>;\n cachedResources(): Promise<MedalResource[]>;\n cachedSchedule(args: RangeArgs): Promise<MedalScheduleDay[]>;\n cachedAvailability(args: RangeArgs, options?: ReadOptions): Promise<MedalSlot[]>;\n expireSlots(serviceIds: Iterable<string>): void;\n}\n\nexport interface CatalogueOptions {\n config: Pick<BookingConfig, 'timeZone' | 'locale' | 'dayparts'>;\n cache: { data: BookingCacheAdapter; prefix: string };\n logger: BookingLogger;\n}\n\ntype Seam = Pick<MedalSeam, 'listServices' | 'listResources' | 'listAvailability' | 'listSchedule'>;\n\nexport function createCatalogue(options: CatalogueOptions, medal: Seam): Catalogue {\n const { data } = options.cache;\n const KEY_PREFIX = options.cache.prefix;\n const clock = createClock(options.config);\n\n /** `ts` if it is a local midnight, otherwise the next one. */\n function roundUpToDay(ts: number): number {\n const start = clock.dayStart(ts);\n return start === ts ? ts : clock.dayStart(ts, 1);\n }\n\n /**\n * The range a read is CACHED under, or `null` for a read that must go live.\n *\n * The range comes from a visitor's query string, so the key is normalised to\n * a small set of values — otherwise every distinct `from_ts`/`to_ts` would be\n * a Medal call and a cache write:\n *\n * - `from` is `slotKeyStart` (the later of it and now, floored to 30 s);\n * - `to` rounded UP to a local midnight — the booking page's window already\n * ends on one, so it is unchanged there;\n * - a key reaching more than 62 days ahead is not cached at all;\n * - a stylist-narrowed read (the manage page's) is not cached at all.\n *\n * The caller's real bounds are applied to the answer afterwards.\n */\n function keyRange(args: RangeArgs, now: number): { fromTs: number; toTs: number } | null {\n if (args.resourceId) return null;\n const fromTs = slotKeyStart(args.fromTs, now);\n const toTs = roundUpToDay(args.toTs);\n const limit = now + MAX_KEY_AHEAD_MS;\n if (fromTs > limit || toTs > limit || toTs <= fromTs) return null;\n return { fromTs, toTs };\n }\n\n function cachedServices(): Promise<MedalService[]> {\n return cacheLoad(\n data,\n [KEY_PREFIX, 'services'],\n [catalogueBucket(Date.now())],\n () => medal.listServices(),\n {\n ttlSeconds: CATALOGUE_TTL_S,\n tags: [CATALOGUE_TAG],\n }\n );\n }\n\n function cachedResources(): Promise<MedalResource[]> {\n return cacheLoad(\n data,\n [KEY_PREFIX, 'resources'],\n [catalogueBucket(Date.now())],\n () => medal.listResources(),\n { ttlSeconds: CATALOGUE_TTL_S, tags: [CATALOGUE_TAG] }\n );\n }\n\n /**\n * Opening hours per service (the cutoffs are per service) over a range.\n *\n * The key range can end after the one asked about, so dates outside the\n * caller's real range are dropped.\n */\n async function cachedSchedule(args: RangeArgs): Promise<MedalScheduleDay[]> {\n const now = Date.now();\n const key = keyRange(args, now);\n if (key === null) return medal.listSchedule(args);\n const { serviceId } = args;\n const days = await cacheLoad(\n data,\n [KEY_PREFIX, 'schedule'],\n // The visit, not the first service: a visit's last start is earlier than\n // its first service's alone, so the two answers are different entries.\n [visitKeyOfIds(visitIdsOf(args)), key.fromTs, key.toTs, catalogueBucket(now)],\n () =>\n medal.listSchedule({ serviceId, ...extrasOf(args), fromTs: key.fromTs, toTs: key.toTs }),\n { ttlSeconds: CATALOGUE_TTL_S, tags: [CATALOGUE_TAG] }\n );\n const firstDay = clock.dayKey(args.fromTs);\n // The local day holding the last instant before `toTs` is the last one\n // asked about; a day starting at or after `toTs` was not.\n const lastDay = clock.dayKey(args.toTs - 1);\n // `date` is ISO `YYYY-MM-DD`, so string order is date order. A null date is\n // left for `toBookingDayDto`, which already drops it.\n return days.filter((day) => day.date === null || (day.date >= firstDay && day.date <= lastDay));\n }\n\n /**\n * Free slots for one visit — one service, or one person's several back to\n * back. The entry carries the tag of EVERY service in the visit, so a write\n * to any one of them (`expireSlots`) retires it; the key is the same for\n * every call with the same arguments.\n *\n * Only slots starting inside the caller's real `[fromTs, toTs)` AND not\n * before now are returned: the key reaches forward to the next local\n * midnight, and an entry up to a minute old may still hold a slot that has\n * since started.\n */\n async function cachedAvailability(\n args: RangeArgs,\n readOptions: ReadOptions = {}\n ): Promise<MedalSlot[]> {\n const now = Date.now();\n const key = readOptions.fresh ? null : keyRange(args, now);\n let slots: MedalSlot[];\n if (key === null) {\n slots = await medal.listAvailability(args);\n } else {\n const { serviceId } = args;\n // The time bucket as well as the start: a FUTURE `from` (the manage\n // page's) keys a start that never moves, and the bucket still retires it.\n slots = await cacheLoad(\n data,\n [KEY_PREFIX, 'availability'],\n [visitKeyOfIds(visitIdsOf(args)), key.fromTs, key.toTs, Math.floor(now / SLOT_KEY_STEP_MS)],\n () =>\n medal.listAvailability({\n serviceId,\n ...extrasOf(args),\n fromTs: key.fromTs,\n toTs: key.toTs,\n }),\n { ttlSeconds: SLOTS_TTL_S, tags: [...new Set(visitIdsOf(args))].map(slotsTag) }\n );\n }\n const earliest = Math.max(args.fromTs, now);\n return slots.filter((slot) => {\n // Unreadable starts are kept for `toBookingSlotDto`, which drops them.\n if (slot.start_ts === null) return true;\n const startTs = Date.parse(slot.start_ts);\n return !Number.isFinite(startTs) || (startTs >= earliest && startTs < args.toTs);\n });\n }\n\n /**\n * Expire the cached slots of every service a write just touched, so the next\n * read — this visitor's confirmation, the next visitor's time step — is live.\n *\n * Best effort: the write has already happened, and a cache that could not be\n * expired must not turn a made booking into an error. The ~60 s bound above\n * is the backstop.\n */\n function expireSlots(serviceIds: Iterable<string>): void {\n for (const serviceId of new Set(serviceIds)) {\n const warn = (error: unknown) =>\n options.logger.warn({ err: error, serviceId }, 'Could not expire cached booking slots');\n try {\n data.expireTag?.(slotsTag(serviceId))?.catch(warn);\n } catch (error) {\n warn(error);\n }\n }\n }\n\n return { cachedServices, cachedResources, cachedSchedule, cachedAvailability, expireSlots };\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"catalogue.mjs","names":[],"sources":["../../src/next/catalogue.ts"],"sourcesContent":["/**\n * A short-lived data cache in front of Medal's booking READS (the L2 cache).\n *\n * The booking page and the wizard's route handlers would otherwise ask Medal\n * live on every request, which is most of a one-second TTFB and most of the\n * wait after a service tap. It reads through `options.cache.data`: with the\n * `next-data` adapter that is `unstable_cache` (on OpenNext, the incremental\n * cache — R2 plus the Durable Object tag cache), shared across isolates and\n * visitors.\n *\n * `unstable_cache` is stale-while-revalidate: an expired entry is served once\n * more while it refills. So every key also carries TIME — the slot key a 30 s\n * bucket, the catalogue keys a five-minute one — and a key that has moved on\n * is a miss, not a stale hit. The resulting bounds:\n *\n * - free slots (tag `booking-slots:<serviceId>`, one per service in the\n * visit): at most ~60 s old. The\n * site's own create / move / cancel expire the tag at once (`expireSlots`);\n * the bound is what covers a booking made anywhere else;\n * - the catalogue (services, stylists, opening hours; tag `booking-catalogue`):\n * at most ~10 min old.\n *\n * KEYS. Every entry is keyed `[prefix, '<read>']` plus its arguments, the\n * scheme the first site on this package already uses, so its key parts stay\n * the same when `prefix` does. A slot or schedule key names the VISIT\n * (`visitKeyOfIds`: `a+b` for a person having both, the bare id for one\n * service), so a one-service key is byte-identical to what it was before\n * visits existed and a deploy does not empty the cache.\n *\n * A failed read throws and is not cached, so a Medal blip is not pinned.\n */\n\nimport { createClock } from '../core/clock';\nimport type { BookingConfig } from '../core/config';\nimport { MAX_RANGE_MS } from '../core/range';\nimport { visitKeyOfIds } from '../core/visit';\nimport type { MedalResource, MedalScheduleDay, MedalService, MedalSlot } from '../core/wire';\nimport type { MedalSeam, RangeArgs } from './medal';\nimport { type BookingCacheAdapter, type BookingLogger, cacheLoad } from './options';\n\nexport const CATALOGUE_TAG = 'booking-catalogue';\nconst CATALOGUE_TTL_S = 300;\nconst SLOTS_TTL_S = 30;\n\n/** The slot key step, and the catalogue's time bucket. */\nconst SLOT_KEY_STEP_MS = 30 * 1000;\nconst CATALOGUE_BUCKET_MS = 5 * 60 * 1000;\n\n/** `{ fresh: true }` skips the cache for one read and writes no entry. Server\n * callers only — the create route's live re-read after a `slotTaken`; no\n * route lets a visitor ask for it. */\nexport interface ReadOptions {\n fresh?: boolean;\n}\n\nexport function slotsTag(serviceId: string): string {\n return `booking-slots:${serviceId}`;\n}\n\n/**\n * Where a slot key starts: the later of the caller's `from` and now, floored to\n * 30 s. Unrounded, no two visitors would share an entry; not clamped to now,\n * any past instant would be a key of its own.\n */\nexport function slotKeyStart(fromTs: number, now: number = Date.now()): number {\n return Math.floor(Math.max(fromTs, now) / SLOT_KEY_STEP_MS) * SLOT_KEY_STEP_MS;\n}\n\n/** Every service in the read's visit, the first one first. */\nfunction visitIdsOf(args: RangeArgs): string[] {\n return [args.serviceId, ...(args.extraServiceIds ?? [])];\n}\n\n/** `extraServiceIds` as a spread: absent for a one-service visit, so a\n * one-service read reaches the seam with exactly the arguments it always had. */\nfunction extrasOf(args: RangeArgs): Pick<RangeArgs, 'extraServiceIds'> {\n return args.extraServiceIds?.length ? { extraServiceIds: args.extraServiceIds } : {};\n}\n\nfunction catalogueBucket(now: number): number {\n return Math.floor(now / CATALOGUE_BUCKET_MS);\n}\n\nexport interface Catalogue {\n cachedServices(): Promise<MedalService[]>;\n cachedResources(): Promise<MedalResource[]>;\n cachedSchedule(args: RangeArgs): Promise<MedalScheduleDay[]>;\n cachedAvailability(args: RangeArgs, options?: ReadOptions): Promise<MedalSlot[]>;\n expireSlots(serviceIds: Iterable<string>): void;\n}\n\nexport interface CatalogueOptions {\n config: Pick<BookingConfig, 'timeZone' | 'locale' | 'dayparts'>;\n cache: { data: BookingCacheAdapter; prefix: string };\n logger: BookingLogger;\n}\n\ntype Seam = Pick<MedalSeam, 'listServices' | 'listResources' | 'listAvailability' | 'listSchedule'>;\n\nexport function createCatalogue(options: CatalogueOptions, medal: Seam): Catalogue {\n const { data } = options.cache;\n const KEY_PREFIX = options.cache.prefix;\n const clock = createClock(options.config);\n\n /** `ts` if it is a local midnight, otherwise the next one. */\n function roundUpToDay(ts: number): number {\n const start = clock.dayStart(ts);\n return start === ts ? ts : clock.dayStart(ts, 1);\n }\n\n /**\n * The range a read is CACHED under, or `null` for a read that must go live.\n *\n * The range comes from a visitor's query string, so the key is normalised to\n * a small set of values — otherwise every distinct `from_ts`/`to_ts` would be\n * a Medal call and a cache write:\n *\n * - `from` is `slotKeyStart` (the later of it and now, floored to 30 s);\n * - `to` rounded UP to a local midnight — the booking page's window already\n * ends on one, so it is unchanged there;\n * - a key reaching further ahead than the routes' range bound\n * (`MAX_RANGE_MS`) is not cached at all;\n * - a stylist-narrowed read (the manage page's) is not cached at all.\n *\n * The caller's real bounds are applied to the answer afterwards.\n */\n function keyRange(args: RangeArgs, now: number): { fromTs: number; toTs: number } | null {\n if (args.resourceId) return null;\n const fromTs = slotKeyStart(args.fromTs, now);\n const toTs = roundUpToDay(args.toTs);\n const limit = now + MAX_RANGE_MS;\n if (fromTs > limit || toTs > limit || toTs <= fromTs) return null;\n return { fromTs, toTs };\n }\n\n function cachedServices(): Promise<MedalService[]> {\n return cacheLoad(\n data,\n [KEY_PREFIX, 'services'],\n [catalogueBucket(Date.now())],\n () => medal.listServices(),\n {\n ttlSeconds: CATALOGUE_TTL_S,\n tags: [CATALOGUE_TAG],\n }\n );\n }\n\n function cachedResources(): Promise<MedalResource[]> {\n return cacheLoad(\n data,\n [KEY_PREFIX, 'resources'],\n [catalogueBucket(Date.now())],\n () => medal.listResources(),\n { ttlSeconds: CATALOGUE_TTL_S, tags: [CATALOGUE_TAG] }\n );\n }\n\n /**\n * Opening hours per service (the cutoffs are per service) over a range.\n *\n * The key range can end after the one asked about, so dates outside the\n * caller's real range are dropped.\n */\n async function cachedSchedule(args: RangeArgs): Promise<MedalScheduleDay[]> {\n const now = Date.now();\n const key = keyRange(args, now);\n if (key === null) return medal.listSchedule(args);\n const { serviceId } = args;\n const days = await cacheLoad(\n data,\n [KEY_PREFIX, 'schedule'],\n // The visit, not the first service: a visit's last start is earlier than\n // its first service's alone, so the two answers are different entries.\n [visitKeyOfIds(visitIdsOf(args)), key.fromTs, key.toTs, catalogueBucket(now)],\n () =>\n medal.listSchedule({ serviceId, ...extrasOf(args), fromTs: key.fromTs, toTs: key.toTs }),\n { ttlSeconds: CATALOGUE_TTL_S, tags: [CATALOGUE_TAG] }\n );\n const firstDay = clock.dayKey(args.fromTs);\n // The local day holding the last instant before `toTs` is the last one\n // asked about; a day starting at or after `toTs` was not.\n const lastDay = clock.dayKey(args.toTs - 1);\n // `date` is ISO `YYYY-MM-DD`, so string order is date order. A null date is\n // left for `toBookingDayDto`, which already drops it.\n return days.filter((day) => day.date === null || (day.date >= firstDay && day.date <= lastDay));\n }\n\n /**\n * Free slots for one visit — one service, or one person's several back to\n * back. The entry carries the tag of EVERY service in the visit, so a write\n * to any one of them (`expireSlots`) retires it; the key is the same for\n * every call with the same arguments.\n *\n * Only slots starting inside the caller's real `[fromTs, toTs)` AND not\n * before now are returned: the key reaches forward to the next local\n * midnight, and an entry up to a minute old may still hold a slot that has\n * since started.\n */\n async function cachedAvailability(\n args: RangeArgs,\n readOptions: ReadOptions = {}\n ): Promise<MedalSlot[]> {\n const now = Date.now();\n const key = readOptions.fresh ? null : keyRange(args, now);\n let slots: MedalSlot[];\n if (key === null) {\n slots = await medal.listAvailability(args);\n } else {\n const { serviceId } = args;\n // The time bucket as well as the start: a FUTURE `from` (the manage\n // page's) keys a start that never moves, and the bucket still retires it.\n slots = await cacheLoad(\n data,\n [KEY_PREFIX, 'availability'],\n [visitKeyOfIds(visitIdsOf(args)), key.fromTs, key.toTs, Math.floor(now / SLOT_KEY_STEP_MS)],\n () =>\n medal.listAvailability({\n serviceId,\n ...extrasOf(args),\n fromTs: key.fromTs,\n toTs: key.toTs,\n }),\n { ttlSeconds: SLOTS_TTL_S, tags: [...new Set(visitIdsOf(args))].map(slotsTag) }\n );\n }\n const earliest = Math.max(args.fromTs, now);\n return slots.filter((slot) => {\n // Unreadable starts are kept for `toBookingSlotDto`, which drops them.\n if (slot.start_ts === null) return true;\n const startTs = Date.parse(slot.start_ts);\n return !Number.isFinite(startTs) || (startTs >= earliest && startTs < args.toTs);\n });\n }\n\n /**\n * Expire the cached slots of every service a write just touched, so the next\n * read — this visitor's confirmation, the next visitor's time step — is live.\n *\n * Best effort: the write has already happened, and a cache that could not be\n * expired must not turn a made booking into an error. The ~60 s bound above\n * is the backstop.\n */\n function expireSlots(serviceIds: Iterable<string>): void {\n for (const serviceId of new Set(serviceIds)) {\n const warn = (error: unknown) =>\n options.logger.warn({ err: error, serviceId }, 'Could not expire cached booking slots');\n try {\n data.expireTag?.(slotsTag(serviceId))?.catch(warn);\n } catch (error) {\n warn(error);\n }\n }\n }\n\n return { cachedServices, cachedResources, cachedSchedule, cachedAvailability, expireSlots };\n}\n"],"mappings":"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"}
@@ -120,10 +120,61 @@ function match(rt, pathname) {
120
120
  }
121
121
  return null;
122
122
  }
123
+ /**
124
+ * Answers that are part of the flow rather than a fault: a taken slot or a
125
+ * replayed submission (409), an expired portal session (401), a manage link or
126
+ * path that no longer resolves (404). Counted at `info`; everything else is
127
+ * `warn`, and a 5xx is `error`.
128
+ */
129
+ const EXPECTED_STATUSES = /* @__PURE__ */ new Set([
130
+ 401,
131
+ 404,
132
+ 409
133
+ ]);
134
+ /**
135
+ * The refusal's code from a response body: `error` on the booking routes,
136
+ * `reason` on the portal routes — both fixed codes. Nothing else in the body
137
+ * is read.
138
+ */
139
+ function failureCode(body) {
140
+ if (typeof body !== "object" || body === null) return void 0;
141
+ const { error, reason } = body;
142
+ if (typeof error === "string") return error;
143
+ return typeof reason === "string" ? reason : void 0;
144
+ }
145
+ /**
146
+ * One line per refused or failed answer, so a fault shows in the site's logs
147
+ * the night it starts. The route had every 400 to itself: when the range guard
148
+ * refused every unseeded service for eight weeks of nights, nothing anywhere
149
+ * said so.
150
+ *
151
+ * Only the route, the method, the status and the response's error CODE go out.
152
+ * Not its `message`: most are the package's fixed strings, but the portal's
153
+ * person route relays the engine's validation text about what the visitor
154
+ * typed. The rate limiter's own 429 is not logged at all — the requests it
155
+ * refuses are the flood, and a line each would bury the faults.
156
+ */
157
+ async function logFailure(rt, route, method, response) {
158
+ const { status } = response;
159
+ if (status < 400) return;
160
+ let code;
161
+ try {
162
+ code = failureCode(await response.clone().json());
163
+ } catch {}
164
+ const meta = {
165
+ route,
166
+ method,
167
+ status,
168
+ code
169
+ };
170
+ const line = `booking ${method} ${route} answered ${status}`;
171
+ if (status >= 500) rt.logger.error(meta, line);
172
+ else if (EXPECTED_STATUSES.has(status)) rt.logger.info(meta, line);
173
+ else rt.logger.warn(meta, line);
174
+ }
123
175
  /** The handler over an existing runtime (what `createBookingServer` shares). */
124
176
  function createBookingHandlerFor(rt) {
125
- async function handle(method, request) {
126
- const found = match(rt, new URL(request.url).pathname);
177
+ async function dispatch(method, request, found) {
127
178
  if (found === "malformed") return bookingErrorResponse("invalidInput", 400, "path is malformed");
128
179
  if (found === null) return bookingErrorResponse("notFound", 404);
129
180
  const route = found.routes[method];
@@ -132,11 +183,18 @@ function createBookingHandlerFor(rt) {
132
183
  response.headers.set("Allow", Object.keys(found.routes).join(", "));
133
184
  return response;
134
185
  }
135
- if (rt.options.rateLimit && await rt.options.rateLimit(found.scope, request)) return NextResponse.json({ error: "rateLimited" }, {
186
+ if (rt.options.rateLimit && await rt.options.rateLimit(found.scope, request)) return null;
187
+ return route(rt, request, found.segment);
188
+ }
189
+ async function handle(method, request) {
190
+ const found = match(rt, new URL(request.url).pathname);
191
+ const response = await dispatch(method, request, found);
192
+ if (response === null) return NextResponse.json({ error: "rateLimited" }, {
136
193
  status: 429,
137
194
  headers: { "Cache-Control": "no-store" }
138
195
  });
139
- return route(rt, request, found.segment);
196
+ await logFailure(rt, found === "malformed" ? "malformed" : found === null ? "unmatched" : found.scope, method, response);
197
+ return response;
140
198
  }
141
199
  return {
142
200
  GET: (request) => handle("GET", request),
@@ -145,6 +203,6 @@ function createBookingHandlerFor(rt) {
145
203
  };
146
204
  }
147
205
  //#endregion
148
- export { createBookingHandlerFor };
206
+ export { createBookingHandlerFor, failureCode };
149
207
 
150
208
  //# sourceMappingURL=handler.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"handler.mjs","names":[],"sources":["../../src/next/handler.ts"],"sourcesContent":["/**\n * One drop-in handler for every booking and portal API route.\n *\n * An app mounts it on two catch-alls and is done:\n *\n * ```ts\n * // app/api/booking/[...path]/route.ts and app/api/portal/[...path]/route.ts\n * export const { GET, POST, DELETE } = booking.handler;\n * ```\n *\n * It dispatches on the path after `config.paths.api` / `config.paths.portalApi`:\n *\n * | Path (after the prefix) | Methods |\n * |---|---|\n * | `services`, `resources`, `availability`, `schedule`, `next-free` | GET |\n * | `avatar/<resourceId>` | GET |\n * | `create` | POST |\n * | `manage/<token>` | GET (the booking), POST (`cancel` / `reschedule`) |\n * | portal `login/verify`, `persons`, `session/touch`, `vipps/link/verify` | POST |\n * | portal `session/expired` | GET |\n * | portal `vipps/flash` | DELETE |\n *\n * Static routes in the app still win over the catch-all in Next, so a site\n * keeps any route of its own beside these.\n */\n\nimport { NextResponse } from 'next/server';\nimport {\n availabilityRoute,\n avatarRoute,\n nextFreeRoute,\n resourcesRoute,\n scheduleRoute,\n servicesRoute,\n} from './routes/catalogue-routes';\nimport { createRoute } from './routes/create';\nimport { manageRoute, manageSummaryRoute } from './routes/manage';\nimport {\n loginVerifyRoute,\n personsRoute,\n sessionExpiredRoute,\n sessionTouchRoute,\n vippsFlashRoute,\n vippsLinkVerifyRoute,\n} from './routes/portal-routes';\nimport { bookingErrorResponse } from './routes/shared';\nimport type { BookingRuntime } from './runtime';\n\ntype Method = 'GET' | 'POST' | 'DELETE';\n\ntype Route = (rt: BookingRuntime, request: Request, segment: string) => Promise<Response>;\n\ninterface Match {\n scope: string;\n routes: Partial<Record<Method, Route>>;\n /** The decoded trailing segment, for `avatar/<id>` and `manage/<token>`. */\n segment: string;\n}\n\nconst BOOKING_ROUTES: Record<string, Partial<Record<Method, Route>>> = {\n services: { GET: (rt) => servicesRoute(rt) },\n resources: { GET: (rt, request) => resourcesRoute(rt, request) },\n availability: { GET: (rt, request) => availabilityRoute(rt, request) },\n schedule: { GET: (rt, request) => scheduleRoute(rt, request) },\n 'next-free': { GET: (rt) => nextFreeRoute(rt) },\n create: { POST: (rt, request) => createRoute(rt, request) },\n};\n\nconst BOOKING_PARAM_ROUTES: Record<string, Partial<Record<Method, Route>>> = {\n avatar: { GET: (rt, request, segment) => avatarRoute(rt, request, segment) },\n manage: {\n GET: (rt, request, segment) => manageSummaryRoute(rt, request, segment),\n POST: (rt, request, segment) => manageRoute(rt, request, segment),\n },\n};\n\nconst PORTAL_ROUTES: Record<string, Partial<Record<Method, Route>>> = {\n 'login/verify': { POST: (rt, request) => loginVerifyRoute(rt, request) },\n persons: { POST: (rt, request) => personsRoute(rt, request) },\n 'session/touch': { POST: (rt) => sessionTouchRoute(rt) },\n 'session/expired': { GET: (rt, request) => sessionExpiredRoute(rt, request) },\n 'vipps/flash': { DELETE: (rt, request) => vippsFlashRoute(rt, request) },\n 'vipps/link/verify': { POST: (rt, request) => vippsLinkVerifyRoute(rt, request) },\n};\n\n/** The rest of `pathname` after `prefix`, or `null` when it is not under it. */\nfunction under(pathname: string, prefix: string): string | null {\n const base = prefix.replace(/\\/+$/, '');\n if (!pathname.startsWith(`${base}/`)) return null;\n return pathname.slice(base.length + 1).replace(/\\/+$/, '');\n}\n\nfunction decoded(segment: string): string | null {\n try {\n return decodeURIComponent(segment);\n } catch {\n return null;\n }\n}\n\nfunction matchBooking(rest: string): Match | 'malformed' | null {\n const fixed = BOOKING_ROUTES[rest];\n if (fixed) return { scope: `booking/${rest}`, routes: fixed, segment: '' };\n const slash = rest.indexOf('/');\n if (slash > 0) {\n const head = rest.slice(0, slash);\n const tail = rest.slice(slash + 1);\n const routes = BOOKING_PARAM_ROUTES[head];\n if (routes && tail !== '' && !tail.includes('/')) {\n const segment = decoded(tail);\n if (segment === null) return 'malformed';\n return { scope: `booking/${head}`, routes, segment };\n }\n }\n return null;\n}\n\nfunction matchPortal(rest: string): Match | null {\n const routes = PORTAL_ROUTES[rest];\n return routes ? { scope: `portal/${rest}`, routes, segment: '' } : null;\n}\n\n/**\n * The route a path names. The LONGER prefix is tried first, so a portal API\n * nested under the booking API (`/api` and `/api/portal`) still reaches the\n * portal routes, and the other way round.\n */\nfunction match(rt: BookingRuntime, pathname: string): Match | 'malformed' | null {\n const { api, portalApi } = rt.config.paths;\n const prefixes = [\n { prefix: api, find: matchBooking },\n { prefix: portalApi, find: matchPortal },\n ].sort((a, b) => b.prefix.replace(/\\/+$/, '').length - a.prefix.replace(/\\/+$/, '').length);\n for (const { prefix, find } of prefixes) {\n const rest = under(pathname, prefix);\n if (rest === null) continue;\n const found = find(rest);\n if (found !== null) return found;\n }\n return null;\n}\n\nexport interface BookingHandler {\n GET(request: Request): Promise<Response>;\n POST(request: Request): Promise<Response>;\n DELETE(request: Request): Promise<Response>;\n}\n\n/** The handler over an existing runtime (what `createBookingServer` shares). */\nexport function createBookingHandlerFor(rt: BookingRuntime): BookingHandler {\n async function handle(method: Method, request: Request): Promise<Response> {\n const found = match(rt, new URL(request.url).pathname);\n if (found === 'malformed')\n return bookingErrorResponse('invalidInput', 400, 'path is malformed');\n if (found === null) return bookingErrorResponse('notFound', 404);\n const route = found.routes[method];\n if (!route) {\n const response = bookingErrorResponse('methodNotAllowed', 405);\n response.headers.set('Allow', Object.keys(found.routes).join(', '));\n return response;\n }\n if (rt.options.rateLimit && (await rt.options.rateLimit(found.scope, request))) {\n return NextResponse.json(\n { error: 'rateLimited' },\n { status: 429, headers: { 'Cache-Control': 'no-store' } }\n );\n }\n return route(rt, request, found.segment);\n }\n\n return {\n GET: (request) => handle('GET', request),\n POST: (request) => handle('POST', request),\n DELETE: (request) => handle('DELETE', request),\n };\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"handler.mjs","names":[],"sources":["../../src/next/handler.ts"],"sourcesContent":["/**\n * One drop-in handler for every booking and portal API route.\n *\n * An app mounts it on two catch-alls and is done:\n *\n * ```ts\n * // app/api/booking/[...path]/route.ts and app/api/portal/[...path]/route.ts\n * export const { GET, POST, DELETE } = booking.handler;\n * ```\n *\n * It dispatches on the path after `config.paths.api` / `config.paths.portalApi`:\n *\n * | Path (after the prefix) | Methods |\n * |---|---|\n * | `services`, `resources`, `availability`, `schedule`, `next-free` | GET |\n * | `avatar/<resourceId>` | GET |\n * | `create` | POST |\n * | `manage/<token>` | GET (the booking), POST (`cancel` / `reschedule`) |\n * | portal `login/verify`, `persons`, `session/touch`, `vipps/link/verify` | POST |\n * | portal `session/expired` | GET |\n * | portal `vipps/flash` | DELETE |\n *\n * Static routes in the app still win over the catch-all in Next, so a site\n * keeps any route of its own beside these.\n */\n\nimport { NextResponse } from 'next/server';\nimport {\n availabilityRoute,\n avatarRoute,\n nextFreeRoute,\n resourcesRoute,\n scheduleRoute,\n servicesRoute,\n} from './routes/catalogue-routes';\nimport { createRoute } from './routes/create';\nimport { manageRoute, manageSummaryRoute } from './routes/manage';\nimport {\n loginVerifyRoute,\n personsRoute,\n sessionExpiredRoute,\n sessionTouchRoute,\n vippsFlashRoute,\n vippsLinkVerifyRoute,\n} from './routes/portal-routes';\nimport { bookingErrorResponse } from './routes/shared';\nimport type { BookingRuntime } from './runtime';\n\ntype Method = 'GET' | 'POST' | 'DELETE';\n\ntype Route = (rt: BookingRuntime, request: Request, segment: string) => Promise<Response>;\n\ninterface Match {\n scope: string;\n routes: Partial<Record<Method, Route>>;\n /** The decoded trailing segment, for `avatar/<id>` and `manage/<token>`. */\n segment: string;\n}\n\nconst BOOKING_ROUTES: Record<string, Partial<Record<Method, Route>>> = {\n services: { GET: (rt) => servicesRoute(rt) },\n resources: { GET: (rt, request) => resourcesRoute(rt, request) },\n availability: { GET: (rt, request) => availabilityRoute(rt, request) },\n schedule: { GET: (rt, request) => scheduleRoute(rt, request) },\n 'next-free': { GET: (rt) => nextFreeRoute(rt) },\n create: { POST: (rt, request) => createRoute(rt, request) },\n};\n\nconst BOOKING_PARAM_ROUTES: Record<string, Partial<Record<Method, Route>>> = {\n avatar: { GET: (rt, request, segment) => avatarRoute(rt, request, segment) },\n manage: {\n GET: (rt, request, segment) => manageSummaryRoute(rt, request, segment),\n POST: (rt, request, segment) => manageRoute(rt, request, segment),\n },\n};\n\nconst PORTAL_ROUTES: Record<string, Partial<Record<Method, Route>>> = {\n 'login/verify': { POST: (rt, request) => loginVerifyRoute(rt, request) },\n persons: { POST: (rt, request) => personsRoute(rt, request) },\n 'session/touch': { POST: (rt) => sessionTouchRoute(rt) },\n 'session/expired': { GET: (rt, request) => sessionExpiredRoute(rt, request) },\n 'vipps/flash': { DELETE: (rt, request) => vippsFlashRoute(rt, request) },\n 'vipps/link/verify': { POST: (rt, request) => vippsLinkVerifyRoute(rt, request) },\n};\n\n/** The rest of `pathname` after `prefix`, or `null` when it is not under it. */\nfunction under(pathname: string, prefix: string): string | null {\n const base = prefix.replace(/\\/+$/, '');\n if (!pathname.startsWith(`${base}/`)) return null;\n return pathname.slice(base.length + 1).replace(/\\/+$/, '');\n}\n\nfunction decoded(segment: string): string | null {\n try {\n return decodeURIComponent(segment);\n } catch {\n return null;\n }\n}\n\nfunction matchBooking(rest: string): Match | 'malformed' | null {\n const fixed = BOOKING_ROUTES[rest];\n if (fixed) return { scope: `booking/${rest}`, routes: fixed, segment: '' };\n const slash = rest.indexOf('/');\n if (slash > 0) {\n const head = rest.slice(0, slash);\n const tail = rest.slice(slash + 1);\n const routes = BOOKING_PARAM_ROUTES[head];\n if (routes && tail !== '' && !tail.includes('/')) {\n const segment = decoded(tail);\n if (segment === null) return 'malformed';\n return { scope: `booking/${head}`, routes, segment };\n }\n }\n return null;\n}\n\nfunction matchPortal(rest: string): Match | null {\n const routes = PORTAL_ROUTES[rest];\n return routes ? { scope: `portal/${rest}`, routes, segment: '' } : null;\n}\n\n/**\n * The route a path names. The LONGER prefix is tried first, so a portal API\n * nested under the booking API (`/api` and `/api/portal`) still reaches the\n * portal routes, and the other way round.\n */\nfunction match(rt: BookingRuntime, pathname: string): Match | 'malformed' | null {\n const { api, portalApi } = rt.config.paths;\n const prefixes = [\n { prefix: api, find: matchBooking },\n { prefix: portalApi, find: matchPortal },\n ].sort((a, b) => b.prefix.replace(/\\/+$/, '').length - a.prefix.replace(/\\/+$/, '').length);\n for (const { prefix, find } of prefixes) {\n const rest = under(pathname, prefix);\n if (rest === null) continue;\n const found = find(rest);\n if (found !== null) return found;\n }\n return null;\n}\n\nexport interface BookingHandler {\n GET(request: Request): Promise<Response>;\n POST(request: Request): Promise<Response>;\n DELETE(request: Request): Promise<Response>;\n}\n\n/**\n * Answers that are part of the flow rather than a fault: a taken slot or a\n * replayed submission (409), an expired portal session (401), a manage link or\n * path that no longer resolves (404). Counted at `info`; everything else is\n * `warn`, and a 5xx is `error`.\n */\nconst EXPECTED_STATUSES = new Set([401, 404, 409]);\n\n/**\n * The refusal's code from a response body: `error` on the booking routes,\n * `reason` on the portal routes — both fixed codes. Nothing else in the body\n * is read.\n */\nexport function failureCode(body: unknown): string | undefined {\n if (typeof body !== 'object' || body === null) return undefined;\n const { error, reason } = body as { error?: unknown; reason?: unknown };\n if (typeof error === 'string') return error;\n return typeof reason === 'string' ? reason : undefined;\n}\n\n/**\n * One line per refused or failed answer, so a fault shows in the site's logs\n * the night it starts. The route had every 400 to itself: when the range guard\n * refused every unseeded service for eight weeks of nights, nothing anywhere\n * said so.\n *\n * Only the route, the method, the status and the response's error CODE go out.\n * Not its `message`: most are the package's fixed strings, but the portal's\n * person route relays the engine's validation text about what the visitor\n * typed. The rate limiter's own 429 is not logged at all — the requests it\n * refuses are the flood, and a line each would bury the faults.\n */\nasync function logFailure(\n rt: BookingRuntime,\n route: string,\n method: Method,\n response: Response\n): Promise<void> {\n const { status } = response;\n if (status < 400) return;\n let code: string | undefined;\n try {\n code = failureCode(await response.clone().json());\n } catch {\n // A body that is not JSON still gets its line, without the code.\n }\n const meta = { route, method, status, code };\n const line = `booking ${method} ${route} answered ${status}`;\n if (status >= 500) rt.logger.error(meta, line);\n else if (EXPECTED_STATUSES.has(status)) rt.logger.info(meta, line);\n else rt.logger.warn(meta, line);\n}\n\n/** The handler over an existing runtime (what `createBookingServer` shares). */\nexport function createBookingHandlerFor(rt: BookingRuntime): BookingHandler {\n async function dispatch(\n method: Method,\n request: Request,\n found: Match | 'malformed' | null\n ): Promise<Response | null> {\n if (found === 'malformed')\n return bookingErrorResponse('invalidInput', 400, 'path is malformed');\n if (found === null) return bookingErrorResponse('notFound', 404);\n const route = found.routes[method];\n if (!route) {\n const response = bookingErrorResponse('methodNotAllowed', 405);\n response.headers.set('Allow', Object.keys(found.routes).join(', '));\n return response;\n }\n if (rt.options.rateLimit && (await rt.options.rateLimit(found.scope, request))) return null;\n return route(rt, request, found.segment);\n }\n\n async function handle(method: Method, request: Request): Promise<Response> {\n const found = match(rt, new URL(request.url).pathname);\n const response = await dispatch(method, request, found);\n if (response === null) {\n // Refused by the site's rate limiter: answered, never logged (`logFailure`).\n return NextResponse.json(\n { error: 'rateLimited' },\n { status: 429, headers: { 'Cache-Control': 'no-store' } }\n );\n }\n // A path the package does not know is logged under a fixed label, never by\n // its path: an unmatched path can still carry a manage token\n // (`manage/<token>/x`). A client and a server on different package versions\n // look exactly like this, which is why it is logged at all.\n const route = found === 'malformed' ? 'malformed' : found === null ? 'unmatched' : found.scope;\n await logFailure(rt, route, method, response);\n return response;\n }\n\n return {\n GET: (request) => handle('GET', request),\n POST: (request) => handle('POST', request),\n DELETE: (request) => handle('DELETE', request),\n };\n}\n"],"mappings":"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"}