@medalsocial/booking 0.1.0 → 0.2.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.
Files changed (48) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +2 -2
  3. package/dist/core/config.d.mts +7 -0
  4. package/dist/core/config.mjs +4 -2
  5. package/dist/core/config.mjs.map +1 -1
  6. package/dist/core/draft-store.d.mts +6 -0
  7. package/dist/core/draft-store.mjs +17 -1
  8. package/dist/core/draft-store.mjs.map +1 -1
  9. package/dist/core/index.d.mts +3 -2
  10. package/dist/core/index.mjs +3 -2
  11. package/dist/core/machine.d.mts +53 -6
  12. package/dist/core/machine.mjs +162 -52
  13. package/dist/core/machine.mjs.map +1 -1
  14. package/dist/core/party-slots.d.mts +9 -2
  15. package/dist/core/party-slots.mjs +18 -12
  16. package/dist/core/party-slots.mjs.map +1 -1
  17. package/dist/core/types.d.mts +4 -0
  18. package/dist/core/visit.d.mts +26 -0
  19. package/dist/core/visit.mjs +40 -0
  20. package/dist/core/visit.mjs.map +1 -0
  21. package/dist/next/catalogue.mjs +25 -7
  22. package/dist/next/catalogue.mjs.map +1 -1
  23. package/dist/next/medal.d.mts +7 -0
  24. package/dist/next/medal.mjs +2 -1
  25. package/dist/next/medal.mjs.map +1 -1
  26. package/dist/next/routes/catalogue-routes.mjs +18 -4
  27. package/dist/next/routes/catalogue-routes.mjs.map +1 -1
  28. package/dist/next/routes/create.mjs +47 -9
  29. package/dist/next/routes/create.mjs.map +1 -1
  30. package/dist/next/routes/manage.mjs.map +1 -1
  31. package/dist/next/routes/shared.mjs +29 -1
  32. package/dist/next/routes/shared.mjs.map +1 -1
  33. package/dist/react/BookingWizard.mjs +32 -11
  34. package/dist/react/BookingWizard.mjs.map +1 -1
  35. package/dist/react/labels/screens.d.mts +1 -1
  36. package/dist/react/labels/screens.mjs +12 -0
  37. package/dist/react/labels/screens.mjs.map +1 -1
  38. package/dist/react/labels/wizard.d.mts +7 -0
  39. package/dist/react/labels/wizard.mjs +2 -0
  40. package/dist/react/labels/wizard.mjs.map +1 -1
  41. package/dist/react/useBooking.d.mts +2 -0
  42. package/dist/react/useBooking.mjs +176 -50
  43. package/dist/react/useBooking.mjs.map +1 -1
  44. package/dist/react/wizard/adapters.mjs +7 -5
  45. package/dist/react/wizard/adapters.mjs.map +1 -1
  46. package/dist/react/wizard-error.mjs +29 -0
  47. package/dist/react/wizard-error.mjs.map +1 -0
  48. 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';\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 service in the\n * basket, keyed by `service.id`. Two children sharing a service 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/** `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 service 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: (serviceId: string, resourceId: string, startTs: number) => boolean;\n resourceOrder: string[];\n} {\n const byService = 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 [serviceId, slots] of Object.entries(slotsByService)) {\n const keys = byService.get(serviceId) ?? 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 byService.set(serviceId, keys);\n }\n\n return {\n free: (serviceId, resourceId, startTs) =>\n byService.get(serviceId)?.has(seatKey(resourceId, startTs)) ?? false,\n resourceOrder,\n };\n}\n\n/**\n * The instants a visit could begin at: every time the FIRST child's service 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[items[0].service.id] ?? [];\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(item.service.id, 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(items[position].service.id, 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(item.service.id, 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): 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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"}
@@ -140,6 +140,10 @@ export interface BookingManageDto {
140
140
  */
141
141
  export interface BookingSubmissionItem {
142
142
  serviceId: string;
143
+ /** The rest of this person's visit after `serviceId`, in order — booked as
144
+ * ONE appointment with one stylist. Absent for a one-service visit, never
145
+ * `[]`. */
146
+ extraServiceIds?: string[];
143
147
  /** The stylist the slot RESOLVED to, not the visitor's «Første ledige»
144
148
  * preference — absent when the engine is still to choose. */
145
149
  resourceId?: string;
@@ -0,0 +1,26 @@
1
+ import { WizardItem, WizardService } from "./machine.mjs";
2
+ //#region src/core/visit.d.ts
3
+ /** The timing of a whole visit. */
4
+ export interface VisitTiming {
5
+ durationMinutes: number;
6
+ bufferBeforeMinutes: number;
7
+ bufferAfterMinutes: number;
8
+ }
9
+ /** A person's services in order: the first, then the extras. */
10
+ export declare function visitServicesOf(item: Pick<WizardItem, 'service' | 'extraServices'>): WizardService[];
11
+ /** The timing of an ordered, non-empty list of services. Throws on an empty list. */
12
+ export declare function visitOfServices(services: readonly WizardService[]): VisitTiming;
13
+ /** The timing of a person's visit, from a line item or a list of services. */
14
+ export declare function visitOf(itemOrServices: Pick<WizardItem, 'service' | 'extraServices'> | readonly WizardService[]): VisitTiming;
15
+ /**
16
+ * A visit's ids joined with `+`, in order; a single service is its id
17
+ * unchanged. The server keys its slot cache and its `freshSlots` answer with
18
+ * this too, so the wizard and the server can never disagree on a visit's name.
19
+ */
20
+ export declare function visitKeyOfIds(serviceIds: readonly string[]): string;
21
+ /** The service ids joined with `+`, in order; a single service is its id unchanged. */
22
+ export declare function visitKey(itemOrServices: Pick<WizardItem, 'service' | 'extraServices'> | readonly WizardService[]): string;
23
+ /** The visit's price: each service priced on its own, then summed. */
24
+ export declare function visitPriceOre(services: readonly WizardService[], priceOf: (service: WizardService) => number): number;
25
+ //#endregion
26
+ //# sourceMappingURL=visit.d.mts.map
@@ -0,0 +1,40 @@
1
+ //#region src/core/visit.ts
2
+ /** A person's services in order: the first, then the extras. */
3
+ function visitServicesOf(item) {
4
+ return [item.service, ...item.extraServices ?? []];
5
+ }
6
+ /** The timing of an ordered, non-empty list of services. Throws on an empty list. */
7
+ function visitOfServices(services) {
8
+ const first = services[0];
9
+ const last = services[services.length - 1];
10
+ if (!first || !last) throw new Error("a visit needs at least one service");
11
+ return {
12
+ durationMinutes: services.reduce((sum, s) => sum + s.durationMinutes, 0),
13
+ bufferBeforeMinutes: first.bufferBeforeMinutes,
14
+ bufferAfterMinutes: last.bufferAfterMinutes
15
+ };
16
+ }
17
+ /** The timing of a person's visit, from a line item or a list of services. */
18
+ function visitOf(itemOrServices) {
19
+ return visitOfServices(Array.isArray(itemOrServices) ? itemOrServices : visitServicesOf(itemOrServices));
20
+ }
21
+ /**
22
+ * A visit's ids joined with `+`, in order; a single service is its id
23
+ * unchanged. The server keys its slot cache and its `freshSlots` answer with
24
+ * this too, so the wizard and the server can never disagree on a visit's name.
25
+ */
26
+ function visitKeyOfIds(serviceIds) {
27
+ return serviceIds.join("+");
28
+ }
29
+ /** The service ids joined with `+`, in order; a single service is its id unchanged. */
30
+ function visitKey(itemOrServices) {
31
+ return visitKeyOfIds((Array.isArray(itemOrServices) ? itemOrServices : visitServicesOf(itemOrServices)).map((s) => s.id));
32
+ }
33
+ /** The visit's price: each service priced on its own, then summed. */
34
+ function visitPriceOre(services, priceOf) {
35
+ return services.reduce((sum, s) => sum + priceOf(s), 0);
36
+ }
37
+ //#endregion
38
+ export { visitKey, visitKeyOfIds, visitOf, visitOfServices, visitPriceOre, visitServicesOf };
39
+
40
+ //# sourceMappingURL=visit.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"visit.mjs","names":[],"sources":["../../src/core/visit.ts"],"sourcesContent":["/**\n * One person, several services, ONE visit — performed back to back by one\n * stylist. The engine's model: the duration is the SUM of the services', the\n * buffer before is the FIRST service's, the buffer after is the LAST's, there\n * is no buffer between a person's own services, and each service is priced on\n * its own. Everything that times or prices a line goes through these helpers so\n * a one-service line and a three-service line share one path.\n */\n\nimport type { WizardItem, WizardService } from './machine';\n\n/** The timing of a whole visit. */\nexport interface VisitTiming {\n durationMinutes: number;\n bufferBeforeMinutes: number;\n bufferAfterMinutes: number;\n}\n\n/** A person's services in order: the first, then the extras. */\nexport function visitServicesOf(\n item: Pick<WizardItem, 'service' | 'extraServices'>\n): WizardService[] {\n return [item.service, ...(item.extraServices ?? [])];\n}\n\n/** The timing of an ordered, non-empty list of services. Throws on an empty list. */\nexport function visitOfServices(services: readonly WizardService[]): VisitTiming {\n const first = services[0];\n const last = services[services.length - 1];\n if (!first || !last) throw new Error('a visit needs at least one service');\n return {\n durationMinutes: services.reduce((sum, s) => sum + s.durationMinutes, 0),\n bufferBeforeMinutes: first.bufferBeforeMinutes,\n bufferAfterMinutes: last.bufferAfterMinutes,\n };\n}\n\n/** The timing of a person's visit, from a line item or a list of services. */\nexport function visitOf(\n itemOrServices: Pick<WizardItem, 'service' | 'extraServices'> | readonly WizardService[]\n): VisitTiming {\n return visitOfServices(\n Array.isArray(itemOrServices)\n ? itemOrServices\n : visitServicesOf(itemOrServices as Pick<WizardItem, 'service' | 'extraServices'>)\n );\n}\n\n/**\n * A visit's ids joined with `+`, in order; a single service is its id\n * unchanged. The server keys its slot cache and its `freshSlots` answer with\n * this too, so the wizard and the server can never disagree on a visit's name.\n */\nexport function visitKeyOfIds(serviceIds: readonly string[]): string {\n return serviceIds.join('+');\n}\n\n/** The service ids joined with `+`, in order; a single service is its id unchanged. */\nexport function visitKey(\n itemOrServices: Pick<WizardItem, 'service' | 'extraServices'> | readonly WizardService[]\n): string {\n const services = Array.isArray(itemOrServices)\n ? (itemOrServices as readonly WizardService[])\n : visitServicesOf(itemOrServices as Pick<WizardItem, 'service' | 'extraServices'>);\n return visitKeyOfIds(services.map((s) => s.id));\n}\n\n/** The visit's price: each service priced on its own, then summed. */\nexport function visitPriceOre(\n services: readonly WizardService[],\n priceOf: (service: WizardService) => number\n): number {\n return services.reduce((sum, s) => sum + priceOf(s), 0);\n}\n"],"mappings":";;AAmBA,SAAgB,gBACd,MACiB;CACjB,OAAO,CAAC,KAAK,SAAS,GAAI,KAAK,iBAAiB,CAAC,CAAE;AACrD;;AAGA,SAAgB,gBAAgB,UAAiD;CAC/E,MAAM,QAAQ,SAAS;CACvB,MAAM,OAAO,SAAS,SAAS,SAAS;CACxC,IAAI,CAAC,SAAS,CAAC,MAAM,MAAM,IAAI,MAAM,oCAAoC;CACzE,OAAO;EACL,iBAAiB,SAAS,QAAQ,KAAK,MAAM,MAAM,EAAE,iBAAiB,CAAC;EACvE,qBAAqB,MAAM;EAC3B,oBAAoB,KAAK;CAC3B;AACF;;AAGA,SAAgB,QACd,gBACa;CACb,OAAO,gBACL,MAAM,QAAQ,cAAc,IACxB,iBACA,gBAAgB,cAA+D,CACrF;AACF;;;;;;AAOA,SAAgB,cAAc,YAAuC;CACnE,OAAO,WAAW,KAAK,GAAG;AAC5B;;AAGA,SAAgB,SACd,gBACQ;CAIR,OAAO,eAHU,MAAM,QAAQ,cAAc,IACxC,iBACD,gBAAgB,cAA+D,EAAA,CACrD,KAAK,MAAM,EAAE,EAAE,CAAC;AAChD;;AAGA,SAAgB,cACd,UACA,SACQ;CACR,OAAO,SAAS,QAAQ,KAAK,MAAM,MAAM,QAAQ,CAAC,GAAG,CAAC;AACxD"}
@@ -1,4 +1,5 @@
1
1
  import { createClock } from "../core/clock.mjs";
2
+ import { visitKeyOfIds } from "../core/visit.mjs";
2
3
  import { cacheLoad } from "./options.mjs";
3
4
  //#region src/next/catalogue.ts
4
5
  /**
@@ -16,7 +17,8 @@ import { cacheLoad } from "./options.mjs";
16
17
  * bucket, the catalogue keys a five-minute one — and a key that has moved on
17
18
  * is a miss, not a stale hit. The resulting bounds:
18
19
  *
19
- * - free slots (tag `booking-slots:<serviceId>`): at most ~60 s old. The
20
+ * - free slots (tag `booking-slots:<serviceId>`, one per service in the
21
+ * visit): at most ~60 s old. The
20
22
  * site's own create / move / cancel expire the tag at once (`expireSlots`);
21
23
  * the bound is what covers a booking made anywhere else;
22
24
  * - the catalogue (services, stylists, opening hours; tag `booking-catalogue`):
@@ -24,7 +26,10 @@ import { cacheLoad } from "./options.mjs";
24
26
  *
25
27
  * KEYS. Every entry is keyed `[prefix, '<read>']` plus its arguments, the
26
28
  * scheme the first site on this package already uses, so its key parts stay
27
- * the same when `prefix` does.
29
+ * the same when `prefix` does. A slot or schedule key names the VISIT
30
+ * (`visitKeyOfIds`: `a+b` for a person having both, the bare id for one
31
+ * service), so a one-service key is byte-identical to what it was before
32
+ * visits existed and a deploy does not empty the cache.
28
33
  *
29
34
  * A failed read throws and is not cached, so a Medal blip is not pinned.
30
35
  */
@@ -48,6 +53,15 @@ function slotsTag(serviceId) {
48
53
  function slotKeyStart(fromTs, now = Date.now()) {
49
54
  return Math.floor(Math.max(fromTs, now) / SLOT_KEY_STEP_MS) * SLOT_KEY_STEP_MS;
50
55
  }
56
+ /** Every service in the read's visit, the first one first. */
57
+ function visitIdsOf(args) {
58
+ return [args.serviceId, ...args.extraServiceIds ?? []];
59
+ }
60
+ /** `extraServiceIds` as a spread: absent for a one-service visit, so a
61
+ * one-service read reaches the seam with exactly the arguments it always had. */
62
+ function extrasOf(args) {
63
+ return args.extraServiceIds?.length ? { extraServiceIds: args.extraServiceIds } : {};
64
+ }
51
65
  function catalogueBucket(now) {
52
66
  return Math.floor(now / CATALOGUE_BUCKET_MS);
53
67
  }
@@ -109,12 +123,13 @@ function createCatalogue(options, medal) {
109
123
  if (key === null) return medal.listSchedule(args);
110
124
  const { serviceId } = args;
111
125
  const days = await cacheLoad(data, [KEY_PREFIX, "schedule"], [
112
- serviceId,
126
+ visitKeyOfIds(visitIdsOf(args)),
113
127
  key.fromTs,
114
128
  key.toTs,
115
129
  catalogueBucket(now)
116
130
  ], () => medal.listSchedule({
117
131
  serviceId,
132
+ ...extrasOf(args),
118
133
  fromTs: key.fromTs,
119
134
  toTs: key.toTs
120
135
  }), {
@@ -126,8 +141,10 @@ function createCatalogue(options, medal) {
126
141
  return days.filter((day) => day.date === null || day.date >= firstDay && day.date <= lastDay);
127
142
  }
128
143
  /**
129
- * Free slots for one service. The tag names the service; the key is the
130
- * same for every call with the same arguments.
144
+ * Free slots for one visit — one service, or one person's several back to
145
+ * back. The entry carries the tag of EVERY service in the visit, so a write
146
+ * to any one of them (`expireSlots`) retires it; the key is the same for
147
+ * every call with the same arguments.
131
148
  *
132
149
  * Only slots starting inside the caller's real `[fromTs, toTs)` AND not
133
150
  * before now are returned: the key reaches forward to the next local
@@ -142,17 +159,18 @@ function createCatalogue(options, medal) {
142
159
  else {
143
160
  const { serviceId } = args;
144
161
  slots = await cacheLoad(data, [KEY_PREFIX, "availability"], [
145
- serviceId,
162
+ visitKeyOfIds(visitIdsOf(args)),
146
163
  key.fromTs,
147
164
  key.toTs,
148
165
  Math.floor(now / SLOT_KEY_STEP_MS)
149
166
  ], () => medal.listAvailability({
150
167
  serviceId,
168
+ ...extrasOf(args),
151
169
  fromTs: key.fromTs,
152
170
  toTs: key.toTs
153
171
  }), {
154
172
  ttlSeconds: SLOTS_TTL_S,
155
- tags: [slotsTag(serviceId)]
173
+ tags: [...new Set(visitIdsOf(args))].map(slotsTag)
156
174
  });
157
175
  }
158
176
  const earliest = Math.max(args.fromTs, now);
@@ -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>`): 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.\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 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\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 [serviceId, key.fromTs, key.toTs, catalogueBucket(now)],\n () => medal.listSchedule({ serviceId, 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 service. The tag names the service; the key is the\n * same for 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 [serviceId, key.fromTs, key.toTs, Math.floor(now / SLOT_KEY_STEP_MS)],\n () => medal.listAvailability({ serviceId, fromTs: key.fromTs, toTs: key.toTs }),\n { ttlSeconds: SLOTS_TTL_S, tags: [slotsTag(serviceId)] }\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 { 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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"}
@@ -27,6 +27,13 @@ export interface MedalSeamOptions {
27
27
  export declare function looksLikePlaceholderKey(key: string): boolean;
28
28
  export interface RangeArgs {
29
29
  serviceId: string;
30
+ /**
31
+ * The rest of ONE person's visit after `serviceId`, in order — absent (or
32
+ * empty) for a one-service visit. With it the engine answers for the whole
33
+ * visit: slots as long as every service together, on stylists who perform
34
+ * all of them, and a schedule whose last start leaves room for the lot.
35
+ */
36
+ extraServiceIds?: readonly string[];
30
37
  resourceId?: string;
31
38
  fromTs: number;
32
39
  toTs: number;
@@ -46,7 +46,8 @@ function rangeParams(args) {
46
46
  service_id: args.serviceId,
47
47
  from_ts: new Date(args.fromTs).toISOString(),
48
48
  to_ts: new Date(args.toTs).toISOString(),
49
- ...args.resourceId ? { resource_id: args.resourceId } : {}
49
+ ...args.resourceId ? { resource_id: args.resourceId } : {},
50
+ ...args.extraServiceIds?.length ? { extra_service_ids: [...args.extraServiceIds] } : {}
50
51
  };
51
52
  }
52
53
  function createMedalSeam(options) {
@@ -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';\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 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 createBooking(body: CreateBookingBody, idempotencyKey: string): 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 };\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) {\n const result = unwrap(\n // `created_via` LAST, so it wins over anything a caller smuggled in.\n await requireMedal().bookings.create(\n { ...body, created_via: SITE_PROVENANCE },\n { idempotencyKey }\n ),\n '/api/v1/bookings'\n );\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":";;AAkCA,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;AAuEA,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;CAC5D;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;GASxC,OAAO,EAAE,UARM,OAEb,MAAM,aAAa,CAAC,CAAC,SAAS,OAC5B;IAAE,GAAG;IAAM,aAAa;GAAgB,GACxC,EAAE,eAAe,CACnB,GACA,kBAEsB,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 } from '@medalsocial/sdk';\nimport type {\n MedalManageSummary,\n MedalResource,\n MedalScheduleDay,\n MedalService,\n MedalSlot,\n} from '../core/wire';\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 createBooking(body: CreateBookingBody, idempotencyKey: string): 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) {\n const result = unwrap(\n // `created_via` LAST, so it wins over anything a caller smuggled in.\n await requireMedal().bookings.create(\n { ...body, created_via: SITE_PROVENANCE },\n { idempotencyKey }\n ),\n '/api/v1/bookings'\n );\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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"}
@@ -1,7 +1,7 @@
1
1
  import { RESOURCE_ID_SHAPE } from "../../core/dto.mjs";
2
2
  import { earliestOpening } from "../../core/next-available.mjs";
3
3
  import { DEFAULT_AVATAR_HOSTS } from "../options.mjs";
4
- import { bookingErrorResponse, catalogueErrorResponse, knownServiceId, parseRange } from "./shared.mjs";
4
+ import { bookingErrorResponse, catalogueErrorResponse, knownExtraServiceIds, knownServiceId, parseRange } from "./shared.mjs";
5
5
  import { NextResponse } from "next/server";
6
6
  //#region src/next/routes/catalogue-routes.ts
7
7
  /**
@@ -35,10 +35,11 @@ async function servicesRoute(rt) {
35
35
  * Best effort, and never fatal. A stylist list with no «next free» lines is a
36
36
  * working step; a 502 here would be a screen with nobody on it.
37
37
  */
38
- async function firstOpeningPerResource(rt, serviceId, range) {
38
+ async function firstOpeningPerResource(rt, serviceId, extraServiceIds, range) {
39
39
  try {
40
40
  const slots = await rt.catalogue.cachedAvailability({
41
41
  serviceId,
42
+ ...extraServiceIds.length > 0 ? { extraServiceIds } : {},
42
43
  ...range
43
44
  });
44
45
  const earliest = {};
@@ -69,6 +70,7 @@ async function resourcesRoute(rt, request) {
69
70
  const rawServiceId = searchParams.get("service_id")?.trim();
70
71
  let range = null;
71
72
  let serviceId = null;
73
+ let extraServiceIds = [];
72
74
  if (rawServiceId) {
73
75
  const parsed = parseRange(searchParams);
74
76
  if ("error" in parsed) return parsed.error;
@@ -76,8 +78,11 @@ async function resourcesRoute(rt, request) {
76
78
  const known = await knownServiceId(rt, rawServiceId);
77
79
  if ("error" in known) return known.error;
78
80
  serviceId = known.serviceId;
81
+ const extras = await knownExtraServiceIds(rt, searchParams.get("extra_service_ids"), serviceId);
82
+ if ("error" in extras) return extras.error;
83
+ extraServiceIds = extras.extraServiceIds;
79
84
  }
80
- const nextAvailable = serviceId !== null && range !== null ? firstOpeningPerResource(rt, serviceId, range) : Promise.resolve({});
85
+ const nextAvailable = serviceId !== null && range !== null ? firstOpeningPerResource(rt, serviceId, extraServiceIds, range) : Promise.resolve({});
81
86
  let resources;
82
87
  try {
83
88
  resources = await rt.catalogue.cachedResources();
@@ -90,7 +95,8 @@ async function resourcesRoute(rt, request) {
90
95
  });
91
96
  }
92
97
  /**
93
- * Free slots for one service over one range, optionally narrowed to the
98
+ * Free slots for one visit over one range — one service, or one person's
99
+ * several (`extra_service_ids`) back to back — optionally narrowed to the
94
100
  * stylist chosen on step 2. Read through the slot cache (at most ~60 s old),
95
101
  * which the site's own create / move / cancel routes expire on success.
96
102
  */
@@ -103,10 +109,14 @@ async function availabilityRoute(rt, request) {
103
109
  const known = await knownServiceId(rt, rawServiceId);
104
110
  if ("error" in known) return known.error;
105
111
  const { serviceId } = known;
112
+ const extras = await knownExtraServiceIds(rt, searchParams.get("extra_service_ids"), serviceId);
113
+ if ("error" in extras) return extras.error;
114
+ const { extraServiceIds } = extras;
106
115
  const resourceId = searchParams.get("resource_id")?.trim();
107
116
  try {
108
117
  const slots = await rt.catalogue.cachedAvailability({
109
118
  serviceId,
119
+ ...extraServiceIds.length > 0 ? { extraServiceIds } : {},
110
120
  ...resourceId ? { resourceId } : {},
111
121
  fromTs: range.fromTs,
112
122
  toTs: range.toTs
@@ -133,11 +143,15 @@ async function scheduleRoute(rt, request) {
133
143
  const known = await knownServiceId(rt, rawServiceId);
134
144
  if ("error" in known) return known.error;
135
145
  const { serviceId } = known;
146
+ const extras = await knownExtraServiceIds(rt, searchParams.get("extra_service_ids"), serviceId);
147
+ if ("error" in extras) return extras.error;
148
+ const { extraServiceIds } = extras;
136
149
  const resourceId = searchParams.get("resource_id")?.trim();
137
150
  try {
138
151
  const days = await rt.catalogue.cachedSchedule({
139
152
  ...resourceId ? { resourceId } : {},
140
153
  serviceId,
154
+ ...extraServiceIds.length > 0 ? { extraServiceIds } : {},
141
155
  fromTs: range.fromTs,
142
156
  toTs: range.toTs
143
157
  });