@medalsocial/booking 0.4.0 → 0.5.0
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 +34 -0
- package/README.md +1 -0
- package/dist/core/config.d.mts +3 -0
- package/dist/core/config.mjs +5 -2
- package/dist/core/config.mjs.map +1 -1
- package/dist/core/draft-store.d.mts +2 -0
- package/dist/core/draft-store.mjs +1 -0
- package/dist/core/draft-store.mjs.map +1 -1
- package/dist/core/index.d.mts +2 -1
- package/dist/core/index.mjs +2 -1
- package/dist/core/machine.d.mts +16 -0
- package/dist/core/machine.mjs +59 -3
- package/dist/core/machine.mjs.map +1 -1
- package/dist/core/party-slots.d.mts +6 -1
- package/dist/core/party-slots.mjs +6 -5
- package/dist/core/party-slots.mjs.map +1 -1
- package/dist/core/range.d.mts +30 -0
- package/dist/core/range.mjs +32 -0
- package/dist/core/range.mjs.map +1 -0
- package/dist/next/catalogue.mjs +4 -5
- package/dist/next/catalogue.mjs.map +1 -1
- package/dist/next/handler.mjs +63 -5
- package/dist/next/handler.mjs.map +1 -1
- package/dist/next/medal.mjs +47 -15
- package/dist/next/medal.mjs.map +1 -1
- package/dist/next/routes/create.mjs +3 -6
- package/dist/next/routes/create.mjs.map +1 -1
- package/dist/next/routes/manage.mjs +5 -8
- package/dist/next/routes/manage.mjs.map +1 -1
- package/dist/next/routes/shared.mjs +57 -14
- package/dist/next/routes/shared.mjs.map +1 -1
- package/dist/react/BookingWizard.d.mts +7 -1
- package/dist/react/BookingWizard.mjs +20 -2
- package/dist/react/BookingWizard.mjs.map +1 -1
- package/dist/react/index.d.mts +2 -2
- package/dist/react/labels/screens.mjs +4 -0
- package/dist/react/labels/screens.mjs.map +1 -1
- package/dist/react/useBooking.d.mts +11 -1
- package/dist/react/useBooking.mjs +152 -31
- package/dist/react/useBooking.mjs.map +1 -1
- package/dist/react/wizard/adapters.mjs +33 -1
- package/dist/react/wizard/adapters.mjs.map +1 -1
- package/dist/react/wizard/index.d.mts +2 -2
- package/package.json +4 -4
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"party-slots.mjs","names":[],"sources":["../../src/core/party-slots.ts"],"sourcesContent":["/**\n * Seating a family: which instants a whole party can actually be taken at, and\n * with whom.\n *\n * The engine answers «when is this ONE service free?» — one row per stylist per\n * instant, because `listSlots` returns a `resourceId` with every slot. Nothing\n * upstream answers «when can these two children be seen back to back?», and\n * nothing should: it is a question about a basket that only exists in this\n * wizard.\n *\n * Pure, and a sibling of `wizard-machine.ts` rather than part of it, for the\n * reason the machine states about itself — the search is where a family booking\n * goes wrong (a stylist who can only cut one of the two, a pair of times that\n * are an hour apart, the same stylist promised to both children at once), and\n * every one of those is provable without rendering anything.\n *\n * It imports `itemStartTimes` rather than restating the seating arithmetic:\n * step 4 submits with that function and the confirmation card reads it back, so\n * a search that laid the children out any other way would offer a slot the\n * submission then asked for at different times.\n */\n\nimport { type ClockConfig, createClock } from './clock';\nimport { itemStartTimes, type WizardItem, type WizardState } from './machine';\nimport type { BookingSlotDto } from './types';\nimport { visitKey } from './visit';\n\n/** One child's place in the visit: when they sit down, and with whom. */\nexport interface PartySeat {\n startTs: number;\n /**\n * Never `null`, unlike `BookingSlotDto.resourceId`.\n *\n * «Første ledige» is an answer the engine can resolve for one booking, but a\n * party slot is a claim about *particular* people — that one stylist has a\n * clear hour, or that two different ones are free at the same minute. A seat\n * that could not name its stylist could not make either claim.\n */\n resourceId: string;\n}\n\n/** A whole family, seated. */\nexport interface PartySlot {\n /** When the visit starts — the first child sits down. */\n startTs: number;\n /** Which search produced it, so accepting the parallel alternative can also\n * switch the mode the submission is seated with. */\n mode: WizardState['partyMode'];\n /** One per line item, in basket order. */\n seats: PartySeat[];\n}\n\n/** Availability as the wizard collects it: one query per VISIT in the basket,\n * keyed by `visitKey` — a service id for a one-service visit, `a+b` for a\n * person having both. The server answers a visit as one list (spans as long as\n * the whole visit, on stylists who do every part of it), so it has to be stored\n * and read under the visit's key: the first service's own list would offer a\n * stylist who can cut but not wash. Two children sharing a visit share the\n * entry — which is exactly why the parallel search has to insist on distinct\n * stylists rather than trusting the lists to differ. */\nexport type SlotsByService = Readonly<Record<string, readonly BookingSlotDto[]>>;\n\n/** The keys a basket's availability is fetched and stored under: each distinct\n * visit once, in basket order — the list the wizard asks the route for. */\nexport function slotKeysFor(items: readonly WizardItem[]): string[] {\n return [...new Set(items.map((item) => visitKey(item)))];\n}\n\n/** `resource@instant`, the unit both searches ask about. */\nfunction seatKey(resourceId: string, startTs: number): string {\n return `${resourceId}@${startTs}`;\n}\n\n/**\n * The catalogue, indexed for the only question either search asks: is this\n * stylist free for this visit at this exact minute?\n *\n * Slots with no `resourceId` are dropped rather than treated as «anybody». The\n * wire type is nullable because `medal-client.ts` types every field the way the\n * serialiser emits it; a slot that arrived without one cannot be paired with a\n * second slot, since there would be no way to tell whether the pair is two\n * stylists or one stylist promised twice.\n */\nfunction indexAvailability(slotsByService: SlotsByService): {\n free: (key: string, resourceId: string, startTs: number) => boolean;\n resourceOrder: string[];\n} {\n const byVisit = new Map<string, Set<string>>();\n // First-encounter order across the whole catalogue: the tie-break when two\n // stylists could both take the same slot. It follows whatever order the\n // availability route returned, which is the engine's own resource order, so\n // the wizard offers the same stylist twice in a row rather than shuffling\n // under a re-render.\n const resourceOrder: string[] = [];\n const seen = new Set<string>();\n\n for (const [key, slots] of Object.entries(slotsByService)) {\n const keys = byVisit.get(key) ?? new Set<string>();\n for (const slot of slots) {\n if (slot.resourceId === null) continue;\n keys.add(seatKey(slot.resourceId, slot.startTs));\n if (!seen.has(slot.resourceId)) {\n seen.add(slot.resourceId);\n resourceOrder.push(slot.resourceId);\n }\n }\n byVisit.set(key, keys);\n }\n\n return {\n free: (key, resourceId, startTs) =>\n byVisit.get(key)?.has(seatKey(resourceId, startTs)) ?? false,\n resourceOrder,\n };\n}\n\n/**\n * The instants the party could begin at: every time the FIRST child's visit is\n * free, with anybody, in ascending order and without repeats.\n *\n * The first child is the right anchor for both modes — sequential seats the\n * rest after them and parallel seats the rest alongside them — and starting\n * anywhere else would offer a visit whose opening line was never free.\n *\n * Deliberately does NOT re-filter the stylist-less slots that `indexAvailability`\n * drops. An instant only survives if some real stylist can be seated at it, and\n * that is the index's answer to give: a second copy of the rule here would be a\n * place for the two to disagree, and it would pass every test the index's own\n * guard already passes.\n *\n * Deduplicated, because two stylists free at the same minute are one offer to a\n * parent. Without the `Set` the same visit would be built once per stylist and\n * step 3 would draw the identical chip twice.\n */\nfunction candidateStarts(items: WizardItem[], slotsByService: SlotsByService): number[] {\n const first = slotsByService[visitKey(items[0])] ?? [];\n return [...new Set(first.map((slot) => slot.startTs))].sort((a, b) => a - b);\n}\n\n/**\n * One stylist for the whole visit.\n *\n * `every`, over every line item and not just the first: the report's example is\n * Sara doing Gutteklipp *and* Jenteklipp, and a search that only checked the\n * service the visitor happened to tap first would offer a stylist who cannot\n * cut the second child — a refusal the engine issues at submit, dressed up here\n * as a slot.\n *\n * The times come from `itemStartTimes`, so «contiguous» is enforced rather than\n * hoped for: the second child's slot has to exist at exactly the minute the\n * first one's service ends. 15:00 and 16:30 are two openings, not an hour.\n */\nfunction seatSequentially(\n items: WizardItem[],\n starts: number[],\n index: ReturnType<typeof indexAvailability>\n): string[] | null {\n for (const resourceId of index.resourceOrder) {\n const fits = items.every((item, position) =>\n index.free(visitKey(item), resourceId, starts[position])\n );\n if (fits) return items.map(() => resourceId);\n }\n return null;\n}\n\n/**\n * A different stylist per child, all at the same minute.\n *\n * Backtracking rather than a greedy pass, because greedy is wrong on the case\n * the salon actually has: three children where Sara is the only one who can\n * take the third, and Sara is also the first name on the list for the first\n * two. Handing her to child one and giving up leaves a seating that exists\n * unfound. A party is small — the salon's kids' services cap it at three, and\n * `maxPerBooking` caps it at whatever the salon seeds — so the search space is\n * trivial and correctness is free.\n *\n * `taken` is what makes the stylists distinct — the rule that one person cannot\n * cut two children at once, which no amount of availability data states on its\n * own, since one free slot is genuinely free for either child.\n */\nfunction seatInParallel(\n items: WizardItem[],\n starts: number[],\n index: ReturnType<typeof indexAvailability>\n): string[] | null {\n const taken = new Set<string>();\n\n function assign(position: number): string[] | null {\n if (position === items.length) return [];\n for (const resourceId of index.resourceOrder) {\n if (taken.has(resourceId)) continue;\n if (!index.free(visitKey(items[position]), resourceId, starts[position])) continue;\n taken.add(resourceId);\n const rest = assign(position + 1);\n taken.delete(resourceId);\n if (rest !== null) return [resourceId, ...rest];\n }\n return null;\n }\n\n return assign(0);\n}\n\n/**\n * Every instant the whole party can be taken at, in the mode the visitor chose.\n *\n * At most one slot per instant. Two stylists who could both take the same hour\n * back to back are one offer to a parent, not two identical chips — step 3 asks\n * «når passer det?», and the stylist question was step 2's.\n */\nexport function findPartySlots(\n items: WizardItem[],\n slotsByService: SlotsByService,\n mode: WizardState['partyMode']\n): PartySlot[] {\n if (items.length === 0) return [];\n\n const index = indexAvailability(slotsByService);\n const seat = mode === 'parallel' ? seatInParallel : seatSequentially;\n const found: PartySlot[] = [];\n\n for (const startTs of candidateStarts(items, slotsByService)) {\n const starts = itemStartTimes(items, startTs, mode);\n const resourceIds = seat(items, starts, index);\n if (resourceIds === null) continue;\n found.push({\n startTs,\n mode,\n seats: resourceIds.map((resourceId, position) => ({\n startTs: starts[position],\n resourceId,\n })),\n });\n }\n\n return found;\n}\n\n/**\n * The report's rescue: «Ingen ledige timer rett etter hverandre torsdag – men\n * begge kan tas samtidig kl. 15:00.»\n *\n * Returns the earliest parallel slot on the day being looked at, and only when\n * the sequential search has genuinely found nothing on that same day. Offering\n * it alongside slots that do exist would be answering a question nobody asked;\n * withholding it when there are none is the empty day the report is trying to\n * prevent.\n *\n * The day is the salon's, not the viewer's — `clock.dayKey` rather than a date\n * string — for the reason every other clock face in this flow is: a 00:30 slot\n * belongs to the day the salon is open on, whatever UTC calls it.\n */\nfunction partyAlternative(\n dayKey: (ts: number) => string,\n args: {\n sequential: readonly PartySlot[];\n parallel: readonly PartySlot[];\n dayTs: number;\n }\n): PartySlot | null {\n const day = dayKey(args.dayTs);\n if (args.sequential.some((slot) => dayKey(slot.startTs) === day)) return null;\n return args.parallel.reduce<PartySlot | null>(\n (earliest, slot) =>\n dayKey(slot.startTs) === day && (earliest === null || slot.startTs < earliest.startTs)\n ? slot\n : earliest,\n null\n );\n}\n\n/**\n * «Neste ledige» for a family: the earliest minute each stylist can take the\n * WHOLE party back to back.\n *\n * The single-service answer (`firstOpeningPerResource`, and the resources\n * route) is the first minute a stylist can take ONE child. Printed on a party's\n * stylist card it would quote a time the search on the time step then fails to\n * find for that stylist — the second child's slot need not be free straight\n * after. So this runs the very search `findPartySlots` runs once the visitor\n * picks the stylist (sequential, narrowed to that one person) and keeps the\n * first hit: the card and the day strip under it cannot disagree.\n *\n * Sequential only, deliberately. The parallel mode draws no stylist list — two\n * children at once are two people — so there is no card to hang an answer on.\n *\n * A stylist with no seating in the window gets no entry, the same «promise\n * nothing we do not know» rule the single-service line follows.\n */\nexport function firstPartyStartPerResource(\n items: WizardItem[],\n slotsByService: SlotsByService\n): Record<string, number> {\n if (items.length === 0) return {};\n\n const index = indexAvailability(slotsByService);\n const earliest: Record<string, number> = {};\n let left = index.resourceOrder.length;\n\n for (const startTs of candidateStarts(items, slotsByService)) {\n if (left === 0) break;\n const starts = itemStartTimes(items, startTs, 'sequential');\n for (const resourceId of index.resourceOrder) {\n if (resourceId in earliest) continue;\n const fits = items.every((item, position) =>\n index.free(visitKey(item), resourceId, starts[position])\n );\n if (!fits) continue;\n earliest[resourceId] = startTs;\n left -= 1;\n }\n }\n\n return earliest;\n}\n\nexport interface PartySlots {\n findPartySlots: typeof findPartySlots;\n firstPartyStartPerResource: typeof firstPartyStartPerResource;\n partyAlternative(args: {\n sequential: readonly PartySlot[];\n parallel: readonly PartySlot[];\n dayTs: number;\n }): PartySlot | null;\n}\n\n/** The party search on this business's calendar. Only `partyAlternative` needs the clock. */\nexport function createPartySlots(config: ClockConfig): PartySlots {\n const clock = createClock(config);\n return {\n findPartySlots,\n firstPartyStartPerResource,\n partyAlternative: (args) => partyAlternative(clock.dayKey, args),\n };\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"party-slots.mjs","names":[],"sources":["../../src/core/party-slots.ts"],"sourcesContent":["/**\n * Seating a family: which instants a whole party can actually be taken at, and\n * with whom.\n *\n * The engine answers «when is this ONE service free?» — one row per stylist per\n * instant, because `listSlots` returns a `resourceId` with every slot. Nothing\n * upstream answers «when can these two children be seen back to back?», and\n * nothing should: it is a question about a basket that only exists in this\n * wizard.\n *\n * Pure, and a sibling of `wizard-machine.ts` rather than part of it, for the\n * reason the machine states about itself — the search is where a family booking\n * goes wrong (a stylist who can only cut one of the two, a pair of times that\n * are an hour apart, the same stylist promised to both children at once), and\n * every one of those is provable without rendering anything.\n *\n * It imports `itemStartTimes` rather than restating the seating arithmetic:\n * step 4 submits with that function and the confirmation card reads it back, so\n * a search that laid the children out any other way would offer a slot the\n * submission then asked for at different times.\n */\n\nimport { type ClockConfig, createClock } from './clock';\nimport { itemStartTimes, type WizardItem, type WizardState } from './machine';\nimport type { BookingSlotDto } from './types';\nimport { visitKey } from './visit';\n\n/** One child's place in the visit: when they sit down, and with whom. */\nexport interface PartySeat {\n startTs: number;\n /**\n * Never `null`, unlike `BookingSlotDto.resourceId`.\n *\n * «Første ledige» is an answer the engine can resolve for one booking, but a\n * party slot is a claim about *particular* people — that one stylist has a\n * clear hour, or that two different ones are free at the same minute. A seat\n * that could not name its stylist could not make either claim.\n */\n resourceId: string;\n}\n\n/** A whole family, seated. */\nexport interface PartySlot {\n /** When the visit starts — the first child sits down. */\n startTs: number;\n /** Which search produced it, so accepting the parallel alternative can also\n * switch the mode the submission is seated with. */\n mode: WizardState['partyMode'];\n /** One per line item, in basket order. */\n seats: PartySeat[];\n}\n\n/** Availability as the wizard collects it: one query per VISIT in the basket,\n * keyed by `visitKey` — a service id for a one-service visit, `a+b` for a\n * person having both. The server answers a visit as one list (spans as long as\n * the whole visit, on stylists who do every part of it), so it has to be stored\n * and read under the visit's key: the first service's own list would offer a\n * stylist who can cut but not wash. Two children sharing a visit share the\n * entry — which is exactly why the parallel search has to insist on distinct\n * stylists rather than trusting the lists to differ. */\nexport type SlotsByService = Readonly<Record<string, readonly BookingSlotDto[]>>;\n\n/** The keys a basket's availability is fetched and stored under: each distinct\n * visit once, in basket order — the list the wizard asks the route for. */\nexport function slotKeysFor(items: readonly WizardItem[]): string[] {\n return [...new Set(items.map((item) => visitKey(item)))];\n}\n\n/** `resource@instant`, the unit both searches ask about. */\nfunction seatKey(resourceId: string, startTs: number): string {\n return `${resourceId}@${startTs}`;\n}\n\n/**\n * The catalogue, indexed for the only question either search asks: is this\n * stylist free for this visit at this exact minute?\n *\n * Slots with no `resourceId` are dropped rather than treated as «anybody». The\n * wire type is nullable because `medal-client.ts` types every field the way the\n * serialiser emits it; a slot that arrived without one cannot be paired with a\n * second slot, since there would be no way to tell whether the pair is two\n * stylists or one stylist promised twice.\n */\nfunction indexAvailability(slotsByService: SlotsByService): {\n free: (key: string, resourceId: string, startTs: number) => boolean;\n resourceOrder: string[];\n} {\n const byVisit = new Map<string, Set<string>>();\n // First-encounter order across the whole catalogue: the tie-break when two\n // stylists could both take the same slot. It follows whatever order the\n // availability route returned, which is the engine's own resource order, so\n // the wizard offers the same stylist twice in a row rather than shuffling\n // under a re-render.\n const resourceOrder: string[] = [];\n const seen = new Set<string>();\n\n for (const [key, slots] of Object.entries(slotsByService)) {\n const keys = byVisit.get(key) ?? new Set<string>();\n for (const slot of slots) {\n if (slot.resourceId === null) continue;\n keys.add(seatKey(slot.resourceId, slot.startTs));\n if (!seen.has(slot.resourceId)) {\n seen.add(slot.resourceId);\n resourceOrder.push(slot.resourceId);\n }\n }\n byVisit.set(key, keys);\n }\n\n return {\n free: (key, resourceId, startTs) =>\n byVisit.get(key)?.has(seatKey(resourceId, startTs)) ?? false,\n resourceOrder,\n };\n}\n\n/**\n * The instants the party could begin at: every time the FIRST child's visit is\n * free, with anybody, in ascending order and without repeats.\n *\n * The first child is the right anchor for both modes — sequential seats the\n * rest after them and parallel seats the rest alongside them — and starting\n * anywhere else would offer a visit whose opening line was never free.\n *\n * Deliberately does NOT re-filter the stylist-less slots that `indexAvailability`\n * drops. An instant only survives if some real stylist can be seated at it, and\n * that is the index's answer to give: a second copy of the rule here would be a\n * place for the two to disagree, and it would pass every test the index's own\n * guard already passes.\n *\n * Deduplicated, because two stylists free at the same minute are one offer to a\n * parent. Without the `Set` the same visit would be built once per stylist and\n * step 3 would draw the identical chip twice.\n */\nfunction candidateStarts(items: WizardItem[], slotsByService: SlotsByService): number[] {\n const first = slotsByService[visitKey(items[0])] ?? [];\n return [...new Set(first.map((slot) => slot.startTs))].sort((a, b) => a - b);\n}\n\n/**\n * One stylist for the whole visit.\n *\n * `every`, over every line item and not just the first: the report's example is\n * Sara doing Gutteklipp *and* Jenteklipp, and a search that only checked the\n * service the visitor happened to tap first would offer a stylist who cannot\n * cut the second child — a refusal the engine issues at submit, dressed up here\n * as a slot.\n *\n * The times come from `itemStartTimes`, so «contiguous» is enforced rather than\n * hoped for: the second child's slot has to exist at exactly the minute the\n * first one's service ends. 15:00 and 16:30 are two openings, not an hour.\n */\nfunction seatSequentially(\n items: WizardItem[],\n starts: number[],\n index: ReturnType<typeof indexAvailability>\n): string[] | null {\n for (const resourceId of index.resourceOrder) {\n const fits = items.every((item, position) =>\n index.free(visitKey(item), resourceId, starts[position])\n );\n if (fits) return items.map(() => resourceId);\n }\n return null;\n}\n\n/**\n * A different stylist per child, all at the same minute.\n *\n * Backtracking rather than a greedy pass, because greedy is wrong on the case\n * the salon actually has: three children where Sara is the only one who can\n * take the third, and Sara is also the first name on the list for the first\n * two. Handing her to child one and giving up leaves a seating that exists\n * unfound. A party is small — the salon's kids' services cap it at three, and\n * `maxPerBooking` caps it at whatever the salon seeds — so the search space is\n * trivial and correctness is free.\n *\n * `taken` is what makes the stylists distinct — the rule that one person cannot\n * cut two children at once, which no amount of availability data states on its\n * own, since one free slot is genuinely free for either child.\n */\nfunction seatInParallel(\n items: WizardItem[],\n starts: number[],\n index: ReturnType<typeof indexAvailability>,\n preferred: readonly (string | null)[] = []\n): string[] | null {\n const taken = new Set<string>();\n\n function assign(position: number): string[] | null {\n if (position === items.length) return [];\n // A person with a stylist of their own is seated with that one or not at\n // all; everyone else with anyone free at the minute.\n const wanted = preferred[position] ?? null;\n const candidates = wanted === null ? index.resourceOrder : [wanted];\n for (const resourceId of candidates) {\n if (taken.has(resourceId)) continue;\n if (!index.free(visitKey(items[position]), resourceId, starts[position])) continue;\n taken.add(resourceId);\n const rest = assign(position + 1);\n taken.delete(resourceId);\n if (rest !== null) return [resourceId, ...rest];\n }\n return null;\n }\n\n return assign(0);\n}\n\n/**\n * Every instant the whole party can be taken at, in the mode the visitor chose.\n *\n * At most one slot per instant. Two stylists who could both take the same hour\n * back to back are one offer to a parent, not two identical chips — step 3 asks\n * «når passer det?», and the stylist question was step 2's.\n */\nexport function findPartySlots(\n items: WizardItem[],\n slotsByService: SlotsByService,\n mode: WizardState['partyMode'],\n /**\n * Seen at once: each line's own stylist, in basket order, `null` for\n * anyone. Ignored one after another, where the visit has one stylist.\n */\n seatResourceIds: readonly (string | null)[] = []\n): PartySlot[] {\n if (items.length === 0) return [];\n\n const index = indexAvailability(slotsByService);\n const found: PartySlot[] = [];\n\n for (const startTs of candidateStarts(items, slotsByService)) {\n const starts = itemStartTimes(items, startTs, mode);\n const resourceIds =\n mode === 'parallel'\n ? seatInParallel(items, starts, index, seatResourceIds)\n : seatSequentially(items, starts, index);\n if (resourceIds === null) continue;\n found.push({\n startTs,\n mode,\n seats: resourceIds.map((resourceId, position) => ({\n startTs: starts[position],\n resourceId,\n })),\n });\n }\n\n return found;\n}\n\n/**\n * The report's rescue: «Ingen ledige timer rett etter hverandre torsdag – men\n * begge kan tas samtidig kl. 15:00.»\n *\n * Returns the earliest parallel slot on the day being looked at, and only when\n * the sequential search has genuinely found nothing on that same day. Offering\n * it alongside slots that do exist would be answering a question nobody asked;\n * withholding it when there are none is the empty day the report is trying to\n * prevent.\n *\n * The day is the salon's, not the viewer's — `clock.dayKey` rather than a date\n * string — for the reason every other clock face in this flow is: a 00:30 slot\n * belongs to the day the salon is open on, whatever UTC calls it.\n */\nfunction partyAlternative(\n dayKey: (ts: number) => string,\n args: {\n sequential: readonly PartySlot[];\n parallel: readonly PartySlot[];\n dayTs: number;\n }\n): PartySlot | null {\n const day = dayKey(args.dayTs);\n if (args.sequential.some((slot) => dayKey(slot.startTs) === day)) return null;\n return args.parallel.reduce<PartySlot | null>(\n (earliest, slot) =>\n dayKey(slot.startTs) === day && (earliest === null || slot.startTs < earliest.startTs)\n ? slot\n : earliest,\n null\n );\n}\n\n/**\n * «Neste ledige» for a family: the earliest minute each stylist can take the\n * WHOLE party back to back.\n *\n * The single-service answer (`firstOpeningPerResource`, and the resources\n * route) is the first minute a stylist can take ONE child. Printed on a party's\n * stylist card it would quote a time the search on the time step then fails to\n * find for that stylist — the second child's slot need not be free straight\n * after. So this runs the very search `findPartySlots` runs once the visitor\n * picks the stylist (sequential, narrowed to that one person) and keeps the\n * first hit: the card and the day strip under it cannot disagree.\n *\n * Sequential only, deliberately. The parallel mode draws no stylist list — two\n * children at once are two people — so there is no card to hang an answer on.\n *\n * A stylist with no seating in the window gets no entry, the same «promise\n * nothing we do not know» rule the single-service line follows.\n */\nexport function firstPartyStartPerResource(\n items: WizardItem[],\n slotsByService: SlotsByService\n): Record<string, number> {\n if (items.length === 0) return {};\n\n const index = indexAvailability(slotsByService);\n const earliest: Record<string, number> = {};\n let left = index.resourceOrder.length;\n\n for (const startTs of candidateStarts(items, slotsByService)) {\n if (left === 0) break;\n const starts = itemStartTimes(items, startTs, 'sequential');\n for (const resourceId of index.resourceOrder) {\n if (resourceId in earliest) continue;\n const fits = items.every((item, position) =>\n index.free(visitKey(item), resourceId, starts[position])\n );\n if (!fits) continue;\n earliest[resourceId] = startTs;\n left -= 1;\n }\n }\n\n return earliest;\n}\n\nexport interface PartySlots {\n findPartySlots: typeof findPartySlots;\n firstPartyStartPerResource: typeof firstPartyStartPerResource;\n partyAlternative(args: {\n sequential: readonly PartySlot[];\n parallel: readonly PartySlot[];\n dayTs: number;\n }): PartySlot | null;\n}\n\n/** The party search on this business's calendar. Only `partyAlternative` needs the clock. */\nexport function createPartySlots(config: ClockConfig): PartySlots {\n const clock = createClock(config);\n return {\n findPartySlots,\n firstPartyStartPerResource,\n partyAlternative: (args) => partyAlternative(clock.dayKey, args),\n };\n}\n"],"mappings":"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"}
|
|
@@ -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"}
|
package/dist/next/catalogue.mjs
CHANGED
|
@@ -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
|
|
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 +
|
|
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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"}
|
package/dist/next/handler.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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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"}
|
package/dist/next/medal.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { redactSession } from "./redact.mjs";
|
|
2
|
-
import { Medal, MedalApiError as MedalApiError$1 } from "@medalsocial/sdk";
|
|
2
|
+
import { Medal, MedalApiError as MedalApiError$1, MedalTimeoutError } from "@medalsocial/sdk";
|
|
3
3
|
//#region src/next/medal.ts
|
|
4
4
|
var MedalConfigError = class extends Error {
|
|
5
5
|
constructor(message) {
|
|
@@ -51,9 +51,38 @@ function rangeParams(args) {
|
|
|
51
51
|
...args.extraServiceIds?.length ? { extra_service_ids: [...args.extraServiceIds] } : {}
|
|
52
52
|
};
|
|
53
53
|
}
|
|
54
|
+
/**
|
|
55
|
+
* One attempt at a catalogue or calendar read. The SDK's default is 30 s with
|
|
56
|
+
* two retries — over a minute and a half of a parent looking at a skeleton
|
|
57
|
+
* before the page can say «ring oss». A Medal that has not answered a read in
|
|
58
|
+
* a few seconds is not going to answer this visitor.
|
|
59
|
+
*/
|
|
60
|
+
const READ_ATTEMPT_TIMEOUT_MS = 4e3;
|
|
61
|
+
/**
|
|
62
|
+
* The whole read, retries and any `Retry-After` wait included. The SDK honours
|
|
63
|
+
* `Retry-After` uncapped, so a 429 could otherwise park the read for as long
|
|
64
|
+
* as the server asked.
|
|
65
|
+
*/
|
|
66
|
+
const READ_DEADLINE_MS = 8e3;
|
|
67
|
+
/**
|
|
68
|
+
* A read bounded by `READ_DEADLINE_MS`. The deadline aborts the SDK call
|
|
69
|
+
* itself — its attempt, its retries and any `Retry-After` wait — so a read the
|
|
70
|
+
* visitor has stopped waiting for sends nothing more upstream. It ends with
|
|
71
|
+
* the SDK's own `MedalTimeoutError`.
|
|
72
|
+
*/
|
|
73
|
+
async function withinDeadline(read) {
|
|
74
|
+
const controller = new AbortController();
|
|
75
|
+
const timer = setTimeout(() => controller.abort(new MedalTimeoutError(READ_DEADLINE_MS)), READ_DEADLINE_MS);
|
|
76
|
+
try {
|
|
77
|
+
return await read({ signal: controller.signal });
|
|
78
|
+
} finally {
|
|
79
|
+
clearTimeout(timer);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
54
82
|
function createMedalSeam(options) {
|
|
55
83
|
let cached = null;
|
|
56
|
-
|
|
84
|
+
/** One client per timeout: the reads and the Vipps calls use different ones. */
|
|
85
|
+
const quick = /* @__PURE__ */ new Map();
|
|
57
86
|
function readConfig() {
|
|
58
87
|
const key = read(options.apiKey);
|
|
59
88
|
if (!key || looksLikePlaceholderKey(key)) return null;
|
|
@@ -80,42 +109,45 @@ function createMedalSeam(options) {
|
|
|
80
109
|
function requireMedalWithTimeout(timeout) {
|
|
81
110
|
const config = readConfig();
|
|
82
111
|
if (!config) throw new MedalConfigError("The Medal API key is not configured");
|
|
83
|
-
|
|
84
|
-
|
|
112
|
+
const known = quick.get(timeout);
|
|
113
|
+
if (known && known.key === config.key && known.base === config.base) return known.medal;
|
|
114
|
+
const medal = new Medal(config.key, {
|
|
115
|
+
baseUrl: config.base,
|
|
116
|
+
timeout
|
|
117
|
+
});
|
|
118
|
+
quick.set(timeout, {
|
|
85
119
|
...config,
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
timeout
|
|
90
|
-
})
|
|
91
|
-
};
|
|
92
|
-
return cachedQuick.medal;
|
|
120
|
+
medal
|
|
121
|
+
});
|
|
122
|
+
return medal;
|
|
93
123
|
}
|
|
94
124
|
function requireMedalConfig() {
|
|
95
125
|
const config = readConfig();
|
|
96
126
|
if (!config) throw new MedalConfigError("The Medal API key is not configured");
|
|
97
127
|
return config;
|
|
98
128
|
}
|
|
129
|
+
/** The client for a read a visitor is waiting on. */
|
|
130
|
+
const reader = () => requireMedalWithTimeout(READ_ATTEMPT_TIMEOUT_MS);
|
|
99
131
|
return {
|
|
100
132
|
getMedal,
|
|
101
133
|
requireMedal,
|
|
102
134
|
requireMedalWithTimeout,
|
|
103
135
|
requireMedalConfig,
|
|
104
136
|
async listServices() {
|
|
105
|
-
return unwrap(await
|
|
137
|
+
return unwrap(await withinDeadline((request) => reader().bookings.listServices(void 0, request)), "/api/v1/bookings/services");
|
|
106
138
|
},
|
|
107
139
|
async listResources() {
|
|
108
|
-
return unwrap(await
|
|
140
|
+
return unwrap(await withinDeadline((request) => reader().bookings.listResources(request)), "/api/v1/bookings/resources");
|
|
109
141
|
},
|
|
110
142
|
async listAvailability(args) {
|
|
111
|
-
return unwrap(await
|
|
143
|
+
return unwrap(await withinDeadline((request) => reader().bookings.availability(rangeParams(args), request)), "/api/v1/bookings/availability");
|
|
112
144
|
},
|
|
113
145
|
/**
|
|
114
146
|
* The open dates over a range — the half availability cannot answer. A
|
|
115
147
|
* date ABSENT from this response is one the business keeps no hours on.
|
|
116
148
|
*/
|
|
117
149
|
async listSchedule(args) {
|
|
118
|
-
return unwrap(await
|
|
150
|
+
return unwrap(await withinDeadline((request) => reader().bookings.schedule(rangeParams(args), request)), "/api/v1/bookings/schedule");
|
|
119
151
|
},
|
|
120
152
|
async createBooking(body, idempotencyKey, opts) {
|
|
121
153
|
const portalSession = opts?.portalSession;
|
package/dist/next/medal.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"medal.mjs","names":["MedalApiError"],"sources":["../../src/next/medal.ts"],"sourcesContent":["/**\n * The booking seam: everything a booking site asks Medal, in the site's own\n * terms.\n *\n * Backed by `@medalsocial/sdk`, which owns the wire format, auth, timeouts and\n * 429/5xx retries. What stays here is the handful of decisions that are the\n * deployment's to make and must not become a per-call option for whoever\n * calls in: `created_via: 'web'` forced last, a 200 that is not JSON treated\n * as a failure, the manage summary narrowed, and a missing or placeholder key\n * failing loudly as `MedalConfigError` instead of a confusing 401.\n *\n * THE KEY IS THE APP'S. The package reads no environment variable. A site\n * passes its key — or, where the platform only fills `process.env` per\n * request (OpenNext on Cloudflare Workers), a function that reads it — and\n * the seam reads it again on every call, re-keying its client when it\n * changes rather than pinning the first one an isolate saw.\n */\n\nimport type {\n BookingClaimableCreatedVia,\n CreateBookingInput,\n RecordConsentInput,\n} from '@medalsocial/sdk';\nimport { Medal, MedalApiError } from '@medalsocial/sdk';\nimport type {\n MedalManageSummary,\n MedalResource,\n MedalScheduleDay,\n MedalService,\n MedalSlot,\n} from '../core/wire';\nimport { redactSession } from './redact';\n\nexport { MedalApiError };\n\nexport class MedalConfigError extends Error {\n constructor(message: string) {\n super(message);\n // `extends Error` leaves `name` as \"Error\"; an uncaught one in a Workers\n // log would then say nothing about where to look.\n this.name = 'MedalConfigError';\n }\n}\n\n/**\n * Medal's HTTP-actions origin. `app.medalsocial.com` is the dashboard and\n * answers `/api/v1/...` with its own 200 HTML \"Page not found\", so a wrong\n * value fails as a JSON parse error several layers from the cause. A\n * deployment should pass its origin explicitly; this is only ever reached by\n * a run started without one — exactly when a wrong default costs most.\n */\nexport const DEFAULT_MEDAL_ENDPOINT = 'https://io.medalsocial.com';\n\n/** A value, or a function that reads it when it is needed. */\ntype Lazy<T> = T | (() => T);\n\nexport interface MedalSeamOptions {\n apiKey: Lazy<string | undefined>;\n baseUrl?: Lazy<string | undefined>;\n}\n\n/**\n * Whether a key is a secret manager's placeholder rather than a key. Secret\n * stores seed new folders with values like `pending…`, so a deployment can\n * hold a syntactically present key Medal will reject; treated as absent, it\n * fails as an obvious config error rather than a 401.\n */\nexport function looksLikePlaceholderKey(key: string): boolean {\n return /^(?:pending|placeholder|changeme|change-me|todo|replace-me|<)/i.test(key.trim());\n}\n\n/**\n * A 200 whose body is not JSON is not a success. The SDK hands a non-JSON body\n * back as-is, so `data` is `undefined` — and, undetected, that travels into\n * whichever route tried to `.map` over it and surfaces there, pointing at a\n * file with nothing wrong in it. The reachable trigger is an endpoint aimed at\n * a host that answers with an HTML page (the dashboard does).\n */\nexport function unwrap<T>(response: { data: T } | undefined, path: string): T {\n const data = response?.data;\n if (data === undefined) {\n throw new MedalApiError(200, 'NON_JSON_BODY', `Medal returned a non-JSON body from ${path}`);\n }\n return data;\n}\n\nexport interface RangeArgs {\n serviceId: string;\n /**\n * The rest of ONE person's visit after `serviceId`, in order — absent (or\n * empty) for a one-service visit. With it the engine answers for the whole\n * visit: slots as long as every service together, on stylists who perform\n * all of them, and a schedule whose last start leaves room for the lot.\n */\n extraServiceIds?: readonly string[];\n resourceId?: string;\n fromTs: number;\n toTs: number;\n}\n\n/**\n * No `created_via` here: the site is unambiguously `'web'`, so `createBooking`\n * sets it rather than taking it. Provenance is a fact about the deployment,\n * not a per-call option.\n *\n * `booked_for_name` and `contact.name` are validated `.min(1)` by the engine\n * (`''` is a 400) while `contact.email` is not, so whatever maps wizard state\n * onto this body must coalesce blanks to `undefined` before calling in.\n */\nexport type CreateBookingBody = Omit<CreateBookingInput, 'created_via'>;\n\n/**\n * Deliberately no `start_ts`: the create response carries only the new id and\n * the one-time manage token per booking. `manage_token` is OPTIONAL: it is\n * minted exactly once, and an idempotent replay returns the same ids WITHOUT\n * it.\n */\nexport interface CreateBookingResult {\n bookings: Array<{ id: string; manage_token?: string }>;\n}\n\n/**\n * The marketing box, filed where a consent belongs. This endpoint is scoped\n * `write:contacts` where the booking endpoints are scoped to bookings: a key\n * minted for bookings alone will 403 here.\n */\nexport type RecordConsentBody = RecordConsentInput & { consent_type: 'marketing_email' };\n\n/**\n * What a completed move hands back. A reschedule is an insert plus a cancel,\n * so `manage_token` is the only way to reach the new booking; it is minted\n * once and absent from an idempotent replay.\n */\nexport interface RescheduleResult {\n booking_id?: string;\n manage_token?: string;\n}\n\nexport interface MedalSeam {\n /** The client, or `null` when the deployment has no usable key. */\n getMedal(): Medal | null;\n /** The client, or a loud `MedalConfigError`. */\n requireMedal(): Medal;\n /** `requireMedal()` with a shorter per-attempt deadline than the SDK's 30 s. */\n requireMedalWithTimeout(timeout: number): Medal;\n /** The key and origin, for the few raw routes the SDK lacks. */\n requireMedalConfig(): { key: string; base: string };\n listServices(): Promise<MedalService[]>;\n listResources(): Promise<MedalResource[]>;\n listAvailability(args: RangeArgs): Promise<MedalSlot[]>;\n listSchedule(args: RangeArgs): Promise<MedalScheduleDay[]>;\n /**\n * `opts.portalSession` — the logged-in parent's portal session, sent as\n * `X-Portal-Session` so Medal books on THAT contact. Never logged: a throw\n * is scrubbed of it before it leaves.\n */\n createBooking(\n body: CreateBookingBody,\n idempotencyKey: string,\n opts?: { portalSession?: string }\n ): Promise<CreateBookingResult>;\n recordConsent(body: RecordConsentBody): Promise<unknown>;\n getManage(token: string): Promise<MedalManageSummary>;\n cancelManage(token: string, reason?: string, idempotencyKey?: string): Promise<unknown>;\n rescheduleManage(\n token: string,\n startTs: number,\n idempotencyKey: string\n ): Promise<RescheduleResult>;\n}\n\nconst SITE_PROVENANCE: BookingClaimableCreatedVia = 'web';\n\nfunction read<T>(value: Lazy<T>): T {\n return typeof value === 'function' ? (value as () => T)() : value;\n}\n\n/** ISO on the wire — the engine parses either, but a millisecond epoch reads\n * as nothing in a request log. */\nfunction rangeParams(args: RangeArgs) {\n return {\n service_id: args.serviceId,\n from_ts: new Date(args.fromTs).toISOString(),\n to_ts: new Date(args.toTs).toISOString(),\n ...(args.resourceId ? { resource_id: args.resourceId } : {}),\n // Only when there is a visit to describe: a one-service read asks exactly\n // what it asked before, so nothing about it changes on the wire.\n ...(args.extraServiceIds?.length ? { extra_service_ids: [...args.extraServiceIds] } : {}),\n };\n}\n\nexport function createMedalSeam(options: MedalSeamOptions): MedalSeam {\n let cached: { key: string; base: string; medal: Medal } | null = null;\n let cachedQuick: { key: string; base: string; timeout: number; medal: Medal } | null = null;\n\n function readConfig(): { key: string; base: string } | null {\n const key = read(options.apiKey);\n if (!key || looksLikePlaceholderKey(key)) return null;\n return { key, base: read(options.baseUrl) || DEFAULT_MEDAL_ENDPOINT };\n }\n\n function getMedal(): Medal | null {\n const config = readConfig();\n if (!config) return null;\n if (cached && cached.key === config.key && cached.base === config.base) {\n return cached.medal;\n }\n cached = { ...config, medal: new Medal(config.key, { baseUrl: config.base }) };\n return cached.medal;\n }\n\n function requireMedal(): Medal {\n const medal = getMedal();\n if (!medal) throw new MedalConfigError('The Medal API key is not configured');\n return medal;\n }\n\n function requireMedalWithTimeout(timeout: number): Medal {\n const config = readConfig();\n if (!config) throw new MedalConfigError('The Medal API key is not configured');\n if (\n cachedQuick &&\n cachedQuick.key === config.key &&\n cachedQuick.base === config.base &&\n cachedQuick.timeout === timeout\n ) {\n return cachedQuick.medal;\n }\n cachedQuick = {\n ...config,\n timeout,\n medal: new Medal(config.key, { baseUrl: config.base, timeout }),\n };\n return cachedQuick.medal;\n }\n\n function requireMedalConfig(): { key: string; base: string } {\n const config = readConfig();\n if (!config) throw new MedalConfigError('The Medal API key is not configured');\n return config;\n }\n\n return {\n getMedal,\n requireMedal,\n requireMedalWithTimeout,\n requireMedalConfig,\n\n async listServices() {\n return unwrap(await requireMedal().bookings.listServices(), '/api/v1/bookings/services');\n },\n\n async listResources() {\n return unwrap(await requireMedal().bookings.listResources(), '/api/v1/bookings/resources');\n },\n\n async listAvailability(args) {\n return unwrap(\n await requireMedal().bookings.availability(rangeParams(args)),\n '/api/v1/bookings/availability'\n );\n },\n\n /**\n * The open dates over a range — the half availability cannot answer. A\n * date ABSENT from this response is one the business keeps no hours on.\n */\n async listSchedule(args) {\n return unwrap(\n await requireMedal().bookings.schedule(rangeParams(args)),\n '/api/v1/bookings/schedule'\n );\n },\n\n async createBooking(body, idempotencyKey, opts) {\n const portalSession = opts?.portalSession;\n let response: Awaited<ReturnType<Medal['bookings']['create']>>;\n try {\n response = await requireMedal().bookings.create(\n // `created_via` LAST, so it wins over anything a caller smuggled in.\n { ...body, created_via: SITE_PROVENANCE },\n portalSession ? { idempotencyKey, portalSession } : { idempotencyKey }\n );\n } catch (error) {\n // The session is never logged, and the create route logs what Medal threw.\n throw portalSession ? redactSession(error, portalSession) : error;\n }\n const result = unwrap(response, '/api/v1/bookings');\n return { bookings: result.bookings };\n },\n\n async recordConsent(body) {\n return unwrap(await requireMedal().gdpr.recordConsent(body), '/api/v1/gdpr/consent');\n },\n\n async getManage(token) {\n const path = '/api/v1/bookings/manage/<token>';\n const summary = unwrap(await requireMedal().bookings.manage.get(token), path);\n if (summary.status === null) {\n // Not a case the engine produces; if it ever does, a page that shows a\n // booking with no state is worse than the «cannot find» card this becomes.\n throw new MedalApiError(\n 200,\n 'MALFORMED_SUMMARY',\n `Medal returned a booking without a status from ${path}`\n );\n }\n return {\n ...summary,\n status: summary.status,\n service_name: summary.service_name ?? 'Unknown service',\n resource_name: summary.resource_name ?? 'Unknown resource',\n };\n },\n\n /**\n * `idempotencyKey` is optional only in the type — the caller derives one\n * from the token so a retry after a dropped response replays rather than\n * answering `CONFLICT` about a booking that is already cancelled.\n */\n async cancelManage(token, reason, idempotencyKey) {\n return unwrap(\n await requireMedal().bookings.manage.cancel(\n token,\n reason === undefined ? undefined : { reason },\n idempotencyKey ? { idempotencyKey } : undefined\n ),\n '/api/v1/bookings/manage/<token>/cancel'\n );\n },\n\n async rescheduleManage(token, startTs, idempotencyKey) {\n return unwrap(\n await requireMedal().bookings.manage.reschedule(\n token,\n { new_start_ts: new Date(startTs).toISOString() },\n { idempotencyKey }\n ),\n '/api/v1/bookings/manage/<token>/reschedule'\n );\n },\n };\n}\n"],"mappings":";;;AAmCA,IAAa,mBAAb,cAAsC,MAAM;CAC1C,YAAY,SAAiB;EAC3B,MAAM,OAAO;EAGb,KAAK,OAAO;CACd;AACF;;;;;;;;AASA,MAAa,yBAAyB;;;;;;;AAgBtC,SAAgB,wBAAwB,KAAsB;CAC5D,OAAO,iEAAiE,KAAK,IAAI,KAAK,CAAC;AACzF;;;;;;;;AASA,SAAgB,OAAU,UAAmC,MAAiB;CAC5E,MAAM,OAAO,UAAU;CACvB,IAAI,SAAS,KAAA,GACX,MAAM,IAAIA,gBAAc,KAAK,iBAAiB,uCAAuC,MAAM;CAE7F,OAAO;AACT;AAuFA,MAAM,kBAA8C;AAEpD,SAAS,KAAQ,OAAmB;CAClC,OAAO,OAAO,UAAU,aAAc,MAAkB,IAAI;AAC9D;;;AAIA,SAAS,YAAY,MAAiB;CACpC,OAAO;EACL,YAAY,KAAK;EACjB,SAAS,IAAI,KAAK,KAAK,MAAM,CAAC,CAAC,YAAY;EAC3C,OAAO,IAAI,KAAK,KAAK,IAAI,CAAC,CAAC,YAAY;EACvC,GAAI,KAAK,aAAa,EAAE,aAAa,KAAK,WAAW,IAAI,CAAC;EAG1D,GAAI,KAAK,iBAAiB,SAAS,EAAE,mBAAmB,CAAC,GAAG,KAAK,eAAe,EAAE,IAAI,CAAC;CACzF;AACF;AAEA,SAAgB,gBAAgB,SAAsC;CACpE,IAAI,SAA6D;CACjE,IAAI,cAAmF;CAEvF,SAAS,aAAmD;EAC1D,MAAM,MAAM,KAAK,QAAQ,MAAM;EAC/B,IAAI,CAAC,OAAO,wBAAwB,GAAG,GAAG,OAAO;EACjD,OAAO;GAAE;GAAK,MAAM,KAAK,QAAQ,OAAO,KAAA;EAA4B;CACtE;CAEA,SAAS,WAAyB;EAChC,MAAM,SAAS,WAAW;EAC1B,IAAI,CAAC,QAAQ,OAAO;EACpB,IAAI,UAAU,OAAO,QAAQ,OAAO,OAAO,OAAO,SAAS,OAAO,MAChE,OAAO,OAAO;EAEhB,SAAS;GAAE,GAAG;GAAQ,OAAO,IAAI,MAAM,OAAO,KAAK,EAAE,SAAS,OAAO,KAAK,CAAC;EAAE;EAC7E,OAAO,OAAO;CAChB;CAEA,SAAS,eAAsB;EAC7B,MAAM,QAAQ,SAAS;EACvB,IAAI,CAAC,OAAO,MAAM,IAAI,iBAAiB,qCAAqC;EAC5E,OAAO;CACT;CAEA,SAAS,wBAAwB,SAAwB;EACvD,MAAM,SAAS,WAAW;EAC1B,IAAI,CAAC,QAAQ,MAAM,IAAI,iBAAiB,qCAAqC;EAC7E,IACE,eACA,YAAY,QAAQ,OAAO,OAC3B,YAAY,SAAS,OAAO,QAC5B,YAAY,YAAY,SAExB,OAAO,YAAY;EAErB,cAAc;GACZ,GAAG;GACH;GACA,OAAO,IAAI,MAAM,OAAO,KAAK;IAAE,SAAS,OAAO;IAAM;GAAQ,CAAC;EAChE;EACA,OAAO,YAAY;CACrB;CAEA,SAAS,qBAAoD;EAC3D,MAAM,SAAS,WAAW;EAC1B,IAAI,CAAC,QAAQ,MAAM,IAAI,iBAAiB,qCAAqC;EAC7E,OAAO;CACT;CAEA,OAAO;EACL;EACA;EACA;EACA;EAEA,MAAM,eAAe;GACnB,OAAO,OAAO,MAAM,aAAa,CAAC,CAAC,SAAS,aAAa,GAAG,2BAA2B;EACzF;EAEA,MAAM,gBAAgB;GACpB,OAAO,OAAO,MAAM,aAAa,CAAC,CAAC,SAAS,cAAc,GAAG,4BAA4B;EAC3F;EAEA,MAAM,iBAAiB,MAAM;GAC3B,OAAO,OACL,MAAM,aAAa,CAAC,CAAC,SAAS,aAAa,YAAY,IAAI,CAAC,GAC5D,+BACF;EACF;;;;;EAMA,MAAM,aAAa,MAAM;GACvB,OAAO,OACL,MAAM,aAAa,CAAC,CAAC,SAAS,SAAS,YAAY,IAAI,CAAC,GACxD,2BACF;EACF;EAEA,MAAM,cAAc,MAAM,gBAAgB,MAAM;GAC9C,MAAM,gBAAgB,MAAM;GAC5B,IAAI;GACJ,IAAI;IACF,WAAW,MAAM,aAAa,CAAC,CAAC,SAAS,OAEvC;KAAE,GAAG;KAAM,aAAa;IAAgB,GACxC,gBAAgB;KAAE;KAAgB;IAAc,IAAI,EAAE,eAAe,CACvE;GACF,SAAS,OAAO;IAEd,MAAM,gBAAgB,cAAc,OAAO,aAAa,IAAI;GAC9D;GAEA,OAAO,EAAE,UADM,OAAO,UAAU,kBACR,CAAC,CAAC,SAAS;EACrC;EAEA,MAAM,cAAc,MAAM;GACxB,OAAO,OAAO,MAAM,aAAa,CAAC,CAAC,KAAK,cAAc,IAAI,GAAG,sBAAsB;EACrF;EAEA,MAAM,UAAU,OAAO;GACrB,MAAM,OAAO;GACb,MAAM,UAAU,OAAO,MAAM,aAAa,CAAC,CAAC,SAAS,OAAO,IAAI,KAAK,GAAG,IAAI;GAC5E,IAAI,QAAQ,WAAW,MAGrB,MAAM,IAAIA,gBACR,KACA,qBACA,kDAAkD,MACpD;GAEF,OAAO;IACL,GAAG;IACH,QAAQ,QAAQ;IAChB,cAAc,QAAQ,gBAAgB;IACtC,eAAe,QAAQ,iBAAiB;GAC1C;EACF;;;;;;EAOA,MAAM,aAAa,OAAO,QAAQ,gBAAgB;GAChD,OAAO,OACL,MAAM,aAAa,CAAC,CAAC,SAAS,OAAO,OACnC,OACA,WAAW,KAAA,IAAY,KAAA,IAAY,EAAE,OAAO,GAC5C,iBAAiB,EAAE,eAAe,IAAI,KAAA,CACxC,GACA,wCACF;EACF;EAEA,MAAM,iBAAiB,OAAO,SAAS,gBAAgB;GACrD,OAAO,OACL,MAAM,aAAa,CAAC,CAAC,SAAS,OAAO,WACnC,OACA,EAAE,cAAc,IAAI,KAAK,OAAO,CAAC,CAAC,YAAY,EAAE,GAChD,EAAE,eAAe,CACnB,GACA,4CACF;EACF;CACF;AACF"}
|
|
1
|
+
{"version":3,"file":"medal.mjs","names":["MedalApiError"],"sources":["../../src/next/medal.ts"],"sourcesContent":["/**\n * The booking seam: everything a booking site asks Medal, in the site's own\n * terms.\n *\n * Backed by `@medalsocial/sdk`, which owns the wire format, auth, timeouts and\n * 429/5xx retries. What stays here is the handful of decisions that are the\n * deployment's to make and must not become a per-call option for whoever\n * calls in: `created_via: 'web'` forced last, a 200 that is not JSON treated\n * as a failure, the manage summary narrowed, and a missing or placeholder key\n * failing loudly as `MedalConfigError` instead of a confusing 401.\n *\n * THE KEY IS THE APP'S. The package reads no environment variable. A site\n * passes its key — or, where the platform only fills `process.env` per\n * request (OpenNext on Cloudflare Workers), a function that reads it — and\n * the seam reads it again on every call, re-keying its client when it\n * changes rather than pinning the first one an isolate saw.\n */\n\nimport type {\n BookingClaimableCreatedVia,\n CreateBookingInput,\n RecordConsentInput,\n} from '@medalsocial/sdk';\nimport { Medal, MedalApiError, MedalTimeoutError } from '@medalsocial/sdk';\nimport type {\n MedalManageSummary,\n MedalResource,\n MedalScheduleDay,\n MedalService,\n MedalSlot,\n} from '../core/wire';\nimport { redactSession } from './redact';\n\nexport { MedalApiError };\n\nexport class MedalConfigError extends Error {\n constructor(message: string) {\n super(message);\n // `extends Error` leaves `name` as \"Error\"; an uncaught one in a Workers\n // log would then say nothing about where to look.\n this.name = 'MedalConfigError';\n }\n}\n\n/**\n * Medal's HTTP-actions origin. `app.medalsocial.com` is the dashboard and\n * answers `/api/v1/...` with its own 200 HTML \"Page not found\", so a wrong\n * value fails as a JSON parse error several layers from the cause. A\n * deployment should pass its origin explicitly; this is only ever reached by\n * a run started without one — exactly when a wrong default costs most.\n */\nexport const DEFAULT_MEDAL_ENDPOINT = 'https://io.medalsocial.com';\n\n/** A value, or a function that reads it when it is needed. */\ntype Lazy<T> = T | (() => T);\n\nexport interface MedalSeamOptions {\n apiKey: Lazy<string | undefined>;\n baseUrl?: Lazy<string | undefined>;\n}\n\n/**\n * Whether a key is a secret manager's placeholder rather than a key. Secret\n * stores seed new folders with values like `pending…`, so a deployment can\n * hold a syntactically present key Medal will reject; treated as absent, it\n * fails as an obvious config error rather than a 401.\n */\nexport function looksLikePlaceholderKey(key: string): boolean {\n return /^(?:pending|placeholder|changeme|change-me|todo|replace-me|<)/i.test(key.trim());\n}\n\n/**\n * A 200 whose body is not JSON is not a success. The SDK hands a non-JSON body\n * back as-is, so `data` is `undefined` — and, undetected, that travels into\n * whichever route tried to `.map` over it and surfaces there, pointing at a\n * file with nothing wrong in it. The reachable trigger is an endpoint aimed at\n * a host that answers with an HTML page (the dashboard does).\n */\nexport function unwrap<T>(response: { data: T } | undefined, path: string): T {\n const data = response?.data;\n if (data === undefined) {\n throw new MedalApiError(200, 'NON_JSON_BODY', `Medal returned a non-JSON body from ${path}`);\n }\n return data;\n}\n\nexport interface RangeArgs {\n serviceId: string;\n /**\n * The rest of ONE person's visit after `serviceId`, in order — absent (or\n * empty) for a one-service visit. With it the engine answers for the whole\n * visit: slots as long as every service together, on stylists who perform\n * all of them, and a schedule whose last start leaves room for the lot.\n */\n extraServiceIds?: readonly string[];\n resourceId?: string;\n fromTs: number;\n toTs: number;\n}\n\n/**\n * No `created_via` here: the site is unambiguously `'web'`, so `createBooking`\n * sets it rather than taking it. Provenance is a fact about the deployment,\n * not a per-call option.\n *\n * `booked_for_name` and `contact.name` are validated `.min(1)` by the engine\n * (`''` is a 400) while `contact.email` is not, so whatever maps wizard state\n * onto this body must coalesce blanks to `undefined` before calling in.\n */\nexport type CreateBookingBody = Omit<CreateBookingInput, 'created_via'>;\n\n/**\n * Deliberately no `start_ts`: the create response carries only the new id and\n * the one-time manage token per booking. `manage_token` is OPTIONAL: it is\n * minted exactly once, and an idempotent replay returns the same ids WITHOUT\n * it.\n */\nexport interface CreateBookingResult {\n bookings: Array<{ id: string; manage_token?: string }>;\n}\n\n/**\n * The marketing box, filed where a consent belongs. This endpoint is scoped\n * `write:contacts` where the booking endpoints are scoped to bookings: a key\n * minted for bookings alone will 403 here.\n */\nexport type RecordConsentBody = RecordConsentInput & { consent_type: 'marketing_email' };\n\n/**\n * What a completed move hands back. A reschedule is an insert plus a cancel,\n * so `manage_token` is the only way to reach the new booking; it is minted\n * once and absent from an idempotent replay.\n */\nexport interface RescheduleResult {\n booking_id?: string;\n manage_token?: string;\n}\n\nexport interface MedalSeam {\n /** The client, or `null` when the deployment has no usable key. */\n getMedal(): Medal | null;\n /** The client, or a loud `MedalConfigError`. */\n requireMedal(): Medal;\n /** `requireMedal()` with a shorter per-attempt deadline than the SDK's 30 s. */\n requireMedalWithTimeout(timeout: number): Medal;\n /** The key and origin, for the few raw routes the SDK lacks. */\n requireMedalConfig(): { key: string; base: string };\n listServices(): Promise<MedalService[]>;\n listResources(): Promise<MedalResource[]>;\n listAvailability(args: RangeArgs): Promise<MedalSlot[]>;\n listSchedule(args: RangeArgs): Promise<MedalScheduleDay[]>;\n /**\n * `opts.portalSession` — the logged-in parent's portal session, sent as\n * `X-Portal-Session` so Medal books on THAT contact. Never logged: a throw\n * is scrubbed of it before it leaves.\n */\n createBooking(\n body: CreateBookingBody,\n idempotencyKey: string,\n opts?: { portalSession?: string }\n ): Promise<CreateBookingResult>;\n recordConsent(body: RecordConsentBody): Promise<unknown>;\n getManage(token: string): Promise<MedalManageSummary>;\n cancelManage(token: string, reason?: string, idempotencyKey?: string): Promise<unknown>;\n rescheduleManage(\n token: string,\n startTs: number,\n idempotencyKey: string\n ): Promise<RescheduleResult>;\n}\n\nconst SITE_PROVENANCE: BookingClaimableCreatedVia = 'web';\n\nfunction read<T>(value: Lazy<T>): T {\n return typeof value === 'function' ? (value as () => T)() : value;\n}\n\n/** ISO on the wire — the engine parses either, but a millisecond epoch reads\n * as nothing in a request log. */\nfunction rangeParams(args: RangeArgs) {\n return {\n service_id: args.serviceId,\n from_ts: new Date(args.fromTs).toISOString(),\n to_ts: new Date(args.toTs).toISOString(),\n ...(args.resourceId ? { resource_id: args.resourceId } : {}),\n // Only when there is a visit to describe: a one-service read asks exactly\n // what it asked before, so nothing about it changes on the wire.\n ...(args.extraServiceIds?.length ? { extra_service_ids: [...args.extraServiceIds] } : {}),\n };\n}\n\n/**\n * One attempt at a catalogue or calendar read. The SDK's default is 30 s with\n * two retries — over a minute and a half of a parent looking at a skeleton\n * before the page can say «ring oss». A Medal that has not answered a read in\n * a few seconds is not going to answer this visitor.\n */\nconst READ_ATTEMPT_TIMEOUT_MS = 4_000;\n\n/**\n * The whole read, retries and any `Retry-After` wait included. The SDK honours\n * `Retry-After` uncapped, so a 429 could otherwise park the read for as long\n * as the server asked.\n */\nconst READ_DEADLINE_MS = 8_000;\n\n/**\n * A read bounded by `READ_DEADLINE_MS`. The deadline aborts the SDK call\n * itself — its attempt, its retries and any `Retry-After` wait — so a read the\n * visitor has stopped waiting for sends nothing more upstream. It ends with\n * the SDK's own `MedalTimeoutError`.\n */\nasync function withinDeadline<T>(\n read: (request: { signal: AbortSignal }) => Promise<T>\n): Promise<T> {\n const controller = new AbortController();\n const timer = setTimeout(\n () => controller.abort(new MedalTimeoutError(READ_DEADLINE_MS)),\n READ_DEADLINE_MS\n );\n try {\n return await read({ signal: controller.signal });\n } finally {\n clearTimeout(timer);\n }\n}\n\nexport function createMedalSeam(options: MedalSeamOptions): MedalSeam {\n let cached: { key: string; base: string; medal: Medal } | null = null;\n /** One client per timeout: the reads and the Vipps calls use different ones. */\n const quick = new Map<number, { key: string; base: string; medal: Medal }>();\n\n function readConfig(): { key: string; base: string } | null {\n const key = read(options.apiKey);\n if (!key || looksLikePlaceholderKey(key)) return null;\n return { key, base: read(options.baseUrl) || DEFAULT_MEDAL_ENDPOINT };\n }\n\n function getMedal(): Medal | null {\n const config = readConfig();\n if (!config) return null;\n if (cached && cached.key === config.key && cached.base === config.base) {\n return cached.medal;\n }\n cached = { ...config, medal: new Medal(config.key, { baseUrl: config.base }) };\n return cached.medal;\n }\n\n function requireMedal(): Medal {\n const medal = getMedal();\n if (!medal) throw new MedalConfigError('The Medal API key is not configured');\n return medal;\n }\n\n function requireMedalWithTimeout(timeout: number): Medal {\n const config = readConfig();\n if (!config) throw new MedalConfigError('The Medal API key is not configured');\n const known = quick.get(timeout);\n if (known && known.key === config.key && known.base === config.base) return known.medal;\n const medal = new Medal(config.key, { baseUrl: config.base, timeout });\n quick.set(timeout, { ...config, medal });\n return medal;\n }\n\n function requireMedalConfig(): { key: string; base: string } {\n const config = readConfig();\n if (!config) throw new MedalConfigError('The Medal API key is not configured');\n return config;\n }\n\n /** The client for a read a visitor is waiting on. */\n const reader = () => requireMedalWithTimeout(READ_ATTEMPT_TIMEOUT_MS);\n\n return {\n getMedal,\n requireMedal,\n requireMedalWithTimeout,\n requireMedalConfig,\n\n async listServices() {\n return unwrap(\n await withinDeadline((request) => reader().bookings.listServices(undefined, request)),\n '/api/v1/bookings/services'\n );\n },\n\n async listResources() {\n return unwrap(\n await withinDeadline((request) => reader().bookings.listResources(request)),\n '/api/v1/bookings/resources'\n );\n },\n\n async listAvailability(args) {\n return unwrap(\n await withinDeadline((request) =>\n reader().bookings.availability(rangeParams(args), request)\n ),\n '/api/v1/bookings/availability'\n );\n },\n\n /**\n * The open dates over a range — the half availability cannot answer. A\n * date ABSENT from this response is one the business keeps no hours on.\n */\n async listSchedule(args) {\n return unwrap(\n await withinDeadline((request) => reader().bookings.schedule(rangeParams(args), request)),\n '/api/v1/bookings/schedule'\n );\n },\n\n async createBooking(body, idempotencyKey, opts) {\n const portalSession = opts?.portalSession;\n let response: Awaited<ReturnType<Medal['bookings']['create']>>;\n try {\n response = await requireMedal().bookings.create(\n // `created_via` LAST, so it wins over anything a caller smuggled in.\n { ...body, created_via: SITE_PROVENANCE },\n portalSession ? { idempotencyKey, portalSession } : { idempotencyKey }\n );\n } catch (error) {\n // The session is never logged, and the create route logs what Medal threw.\n throw portalSession ? redactSession(error, portalSession) : error;\n }\n const result = unwrap(response, '/api/v1/bookings');\n return { bookings: result.bookings };\n },\n\n async recordConsent(body) {\n return unwrap(await requireMedal().gdpr.recordConsent(body), '/api/v1/gdpr/consent');\n },\n\n async getManage(token) {\n const path = '/api/v1/bookings/manage/<token>';\n const summary = unwrap(await requireMedal().bookings.manage.get(token), path);\n if (summary.status === null) {\n // Not a case the engine produces; if it ever does, a page that shows a\n // booking with no state is worse than the «cannot find» card this becomes.\n throw new MedalApiError(\n 200,\n 'MALFORMED_SUMMARY',\n `Medal returned a booking without a status from ${path}`\n );\n }\n return {\n ...summary,\n status: summary.status,\n service_name: summary.service_name ?? 'Unknown service',\n resource_name: summary.resource_name ?? 'Unknown resource',\n };\n },\n\n /**\n * `idempotencyKey` is optional only in the type — the caller derives one\n * from the token so a retry after a dropped response replays rather than\n * answering `CONFLICT` about a booking that is already cancelled.\n */\n async cancelManage(token, reason, idempotencyKey) {\n return unwrap(\n await requireMedal().bookings.manage.cancel(\n token,\n reason === undefined ? undefined : { reason },\n idempotencyKey ? { idempotencyKey } : undefined\n ),\n '/api/v1/bookings/manage/<token>/cancel'\n );\n },\n\n async rescheduleManage(token, startTs, idempotencyKey) {\n return unwrap(\n await requireMedal().bookings.manage.reschedule(\n token,\n { new_start_ts: new Date(startTs).toISOString() },\n { idempotencyKey }\n ),\n '/api/v1/bookings/manage/<token>/reschedule'\n );\n },\n };\n}\n"],"mappings":"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"}
|