@medalsocial/booking 0.0.0-placeholder.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 (266) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/LICENSE +201 -0
  3. package/NOTICE +6 -0
  4. package/README.md +220 -1
  5. package/dist/core/age.d.mts +34 -0
  6. package/dist/core/age.mjs +95 -0
  7. package/dist/core/age.mjs.map +1 -0
  8. package/dist/core/attempt-store.d.mts +111 -0
  9. package/dist/core/attempt-store.mjs +159 -0
  10. package/dist/core/attempt-store.mjs.map +1 -0
  11. package/dist/core/categories.d.mts +27 -0
  12. package/dist/core/categories.mjs +42 -0
  13. package/dist/core/categories.mjs.map +1 -0
  14. package/dist/core/clock.d.mts +33 -0
  15. package/dist/core/clock.mjs +251 -0
  16. package/dist/core/clock.mjs.map +1 -0
  17. package/dist/core/config.d.mts +178 -0
  18. package/dist/core/config.mjs +394 -0
  19. package/dist/core/config.mjs.map +1 -0
  20. package/dist/core/consent.d.mts +14 -0
  21. package/dist/core/consent.mjs +17 -0
  22. package/dist/core/consent.mjs.map +1 -0
  23. package/dist/core/deep-link.d.mts +40 -0
  24. package/dist/core/deep-link.mjs +160 -0
  25. package/dist/core/deep-link.mjs.map +1 -0
  26. package/dist/core/display-name.d.mts +18 -0
  27. package/dist/core/display-name.mjs +30 -0
  28. package/dist/core/display-name.mjs.map +1 -0
  29. package/dist/core/draft-store.d.mts +89 -0
  30. package/dist/core/draft-store.mjs +131 -0
  31. package/dist/core/draft-store.mjs.map +1 -0
  32. package/dist/core/dto.d.mts +46 -0
  33. package/dist/core/dto.mjs +157 -0
  34. package/dist/core/dto.mjs.map +1 -0
  35. package/dist/core/ics.d.mts +63 -0
  36. package/dist/core/ics.mjs +130 -0
  37. package/dist/core/ics.mjs.map +1 -0
  38. package/dist/core/index.d.mts +35 -0
  39. package/dist/core/index.mjs +32 -0
  40. package/dist/core/labels.d.mts +88 -0
  41. package/dist/core/labels.mjs +93 -0
  42. package/dist/core/labels.mjs.map +1 -0
  43. package/dist/core/machine.d.mts +645 -0
  44. package/dist/core/machine.mjs +988 -0
  45. package/dist/core/machine.mjs.map +1 -0
  46. package/dist/core/money.d.mts +12 -0
  47. package/dist/core/money.mjs +51 -0
  48. package/dist/core/money.mjs.map +1 -0
  49. package/dist/core/next-available.d.mts +20 -0
  50. package/dist/core/next-available.mjs +36 -0
  51. package/dist/core/next-available.mjs.map +1 -0
  52. package/dist/core/next-free.d.mts +25 -0
  53. package/dist/core/next-free.mjs +44 -0
  54. package/dist/core/next-free.mjs.map +1 -0
  55. package/dist/core/party-slots.d.mts +79 -0
  56. package/dist/core/party-slots.mjs +229 -0
  57. package/dist/core/party-slots.mjs.map +1 -0
  58. package/dist/core/paths.d.mts +11 -0
  59. package/dist/core/paths.mjs +56 -0
  60. package/dist/core/paths.mjs.map +1 -0
  61. package/dist/core/phone.d.mts +17 -0
  62. package/dist/core/phone.mjs +58 -0
  63. package/dist/core/phone.mjs.map +1 -0
  64. package/dist/core/portal/cookies.d.mts +29 -0
  65. package/dist/core/portal/dto.d.mts +140 -0
  66. package/dist/core/portal/dto.mjs +142 -0
  67. package/dist/core/portal/dto.mjs.map +1 -0
  68. package/dist/core/portal/family.d.mts +39 -0
  69. package/dist/core/portal/family.mjs +39 -0
  70. package/dist/core/portal/family.mjs.map +1 -0
  71. package/dist/core/portal/login-input.d.mts +29 -0
  72. package/dist/core/portal/login-input.mjs +29 -0
  73. package/dist/core/portal/login-input.mjs.map +1 -0
  74. package/dist/core/portal/next-path.d.mts +12 -0
  75. package/dist/core/portal/next-path.mjs +98 -0
  76. package/dist/core/portal/next-path.mjs.map +1 -0
  77. package/dist/core/portal/return-path.d.mts +7 -0
  78. package/dist/core/portal/return-path.mjs +49 -0
  79. package/dist/core/portal/return-path.mjs.map +1 -0
  80. package/dist/core/portal/session-cookie.d.mts +27 -0
  81. package/dist/core/portal/session-cookie.mjs +30 -0
  82. package/dist/core/portal/session-cookie.mjs.map +1 -0
  83. package/dist/core/portal/vipps-link.d.mts +56 -0
  84. package/dist/core/portal/vipps-link.mjs +136 -0
  85. package/dist/core/portal/vipps-link.mjs.map +1 -0
  86. package/dist/core/portal/vipps-return.d.mts +39 -0
  87. package/dist/core/portal/vipps-return.mjs +69 -0
  88. package/dist/core/portal/vipps-return.mjs.map +1 -0
  89. package/dist/core/rebook-store.d.mts +28 -0
  90. package/dist/core/rebook-store.mjs +60 -0
  91. package/dist/core/rebook-store.mjs.map +1 -0
  92. package/dist/core/restore-gate.d.mts +40 -0
  93. package/dist/core/restore-gate.mjs +96 -0
  94. package/dist/core/restore-gate.mjs.map +1 -0
  95. package/dist/core/stores.d.mts +8 -0
  96. package/dist/core/stores.mjs +23 -0
  97. package/dist/core/stores.mjs.map +1 -0
  98. package/dist/core/types.d.mts +245 -0
  99. package/dist/core/visit.d.mts +26 -0
  100. package/dist/core/visit.mjs +40 -0
  101. package/dist/core/visit.mjs.map +1 -0
  102. package/dist/core/wire.d.mts +37 -0
  103. package/dist/next/cache/memory.d.mts +14 -0
  104. package/dist/next/cache/memory.mjs +51 -0
  105. package/dist/next/cache/memory.mjs.map +1 -0
  106. package/dist/next/cache/next-data.d.mts +6 -0
  107. package/dist/next/cache/next-data.mjs +35 -0
  108. package/dist/next/cache/next-data.mjs.map +1 -0
  109. package/dist/next/cache/noop.d.mts +6 -0
  110. package/dist/next/cache/noop.mjs +15 -0
  111. package/dist/next/cache/noop.mjs.map +1 -0
  112. package/dist/next/cache/workers.d.mts +29 -0
  113. package/dist/next/cache/workers.mjs +74 -0
  114. package/dist/next/cache/workers.mjs.map +1 -0
  115. package/dist/next/catalogue.d.mts +38 -0
  116. package/dist/next/catalogue.mjs +215 -0
  117. package/dist/next/catalogue.mjs.map +1 -0
  118. package/dist/next/handler.d.mts +11 -0
  119. package/dist/next/handler.mjs +150 -0
  120. package/dist/next/handler.mjs.map +1 -0
  121. package/dist/next/index.d.mts +17 -0
  122. package/dist/next/index.mjs +14 -0
  123. package/dist/next/loaders.d.mts +79 -0
  124. package/dist/next/loaders.mjs +195 -0
  125. package/dist/next/loaders.mjs.map +1 -0
  126. package/dist/next/manage-dto.d.mts +6 -0
  127. package/dist/next/manage-dto.mjs +28 -0
  128. package/dist/next/manage-dto.mjs.map +1 -0
  129. package/dist/next/medal.d.mts +105 -0
  130. package/dist/next/medal.mjs +155 -0
  131. package/dist/next/medal.mjs.map +1 -0
  132. package/dist/next/options.d.mts +158 -0
  133. package/dist/next/options.mjs +65 -0
  134. package/dist/next/options.mjs.map +1 -0
  135. package/dist/next/portal/guardian.d.mts +42 -0
  136. package/dist/next/portal/guardian.mjs +76 -0
  137. package/dist/next/portal/guardian.mjs.map +1 -0
  138. package/dist/next/portal/medal-portal.d.mts +161 -0
  139. package/dist/next/portal/medal-portal.mjs +825 -0
  140. package/dist/next/portal/medal-portal.mjs.map +1 -0
  141. package/dist/next/portal/session.d.mts +38 -0
  142. package/dist/next/portal/session.mjs +40 -0
  143. package/dist/next/portal/session.mjs.map +1 -0
  144. package/dist/next/portal/vipps-flash.d.mts +14 -0
  145. package/dist/next/portal/vipps-flash.mjs +41 -0
  146. package/dist/next/portal/vipps-flash.mjs.map +1 -0
  147. package/dist/next/portal.d.mts +205 -0
  148. package/dist/next/portal.mjs +469 -0
  149. package/dist/next/portal.mjs.map +1 -0
  150. package/dist/next/request.d.mts +51 -0
  151. package/dist/next/request.mjs +123 -0
  152. package/dist/next/request.mjs.map +1 -0
  153. package/dist/next/routes/catalogue-routes.mjs +344 -0
  154. package/dist/next/routes/catalogue-routes.mjs.map +1 -0
  155. package/dist/next/routes/create.mjs +460 -0
  156. package/dist/next/routes/create.mjs.map +1 -0
  157. package/dist/next/routes/manage.mjs +191 -0
  158. package/dist/next/routes/manage.mjs.map +1 -0
  159. package/dist/next/routes/portal-routes.mjs +312 -0
  160. package/dist/next/routes/portal-routes.mjs.map +1 -0
  161. package/dist/next/routes/shared.mjs +162 -0
  162. package/dist/next/routes/shared.mjs.map +1 -0
  163. package/dist/next/routes/vipps-return.mjs +166 -0
  164. package/dist/next/routes/vipps-return.mjs.map +1 -0
  165. package/dist/next/runtime.d.mts +49 -0
  166. package/dist/next/runtime.mjs +94 -0
  167. package/dist/next/runtime.mjs.map +1 -0
  168. package/dist/next/seed.d.mts +71 -0
  169. package/dist/next/seed.mjs +495 -0
  170. package/dist/next/seed.mjs.map +1 -0
  171. package/dist/next/server.d.mts +26 -0
  172. package/dist/next/server.mjs +63 -0
  173. package/dist/next/server.mjs.map +1 -0
  174. package/dist/react/BookingLink.d.mts +61 -0
  175. package/dist/react/BookingLink.mjs +102 -0
  176. package/dist/react/BookingLink.mjs.map +1 -0
  177. package/dist/react/BookingWizard.d.mts +49 -0
  178. package/dist/react/BookingWizard.mjs +598 -0
  179. package/dist/react/BookingWizard.mjs.map +1 -0
  180. package/dist/react/LoginPageQuery.d.mts +37 -0
  181. package/dist/react/LoginPageQuery.mjs +106 -0
  182. package/dist/react/LoginPageQuery.mjs.map +1 -0
  183. package/dist/react/LoginSheet.d.mts +49 -0
  184. package/dist/react/LoginSheet.mjs +98 -0
  185. package/dist/react/LoginSheet.mjs.map +1 -0
  186. package/dist/react/ManageBooking.d.mts +50 -0
  187. package/dist/react/ManageBooking.mjs +197 -0
  188. package/dist/react/ManageBooking.mjs.map +1 -0
  189. package/dist/react/PortalDashboard.d.mts +10 -0
  190. package/dist/react/Provider.d.mts +82 -0
  191. package/dist/react/Provider.mjs +132 -0
  192. package/dist/react/Provider.mjs.map +1 -0
  193. package/dist/react/actions.d.mts +133 -0
  194. package/dist/react/actions.mjs +90 -0
  195. package/dist/react/actions.mjs.map +1 -0
  196. package/dist/react/index.d.mts +28 -0
  197. package/dist/react/index.mjs +20 -0
  198. package/dist/react/kit.d.mts +54 -0
  199. package/dist/react/kit.mjs +127 -0
  200. package/dist/react/kit.mjs.map +1 -0
  201. package/dist/react/labels/index.d.mts +23 -0
  202. package/dist/react/labels/index.mjs +44 -0
  203. package/dist/react/labels/index.mjs.map +1 -0
  204. package/dist/react/labels/login.d.mts +10 -0
  205. package/dist/react/labels/login.mjs +13 -0
  206. package/dist/react/labels/login.mjs.map +1 -0
  207. package/dist/react/labels/manage.d.mts +15 -0
  208. package/dist/react/labels/manage.mjs +19 -0
  209. package/dist/react/labels/manage.mjs.map +1 -0
  210. package/dist/react/labels/portal.d.mts +25 -0
  211. package/dist/react/labels/portal.mjs +41 -0
  212. package/dist/react/labels/portal.mjs.map +1 -0
  213. package/dist/react/labels/screens.d.mts +11 -0
  214. package/dist/react/labels/screens.mjs +721 -0
  215. package/dist/react/labels/screens.mjs.map +1 -0
  216. package/dist/react/labels/wizard.d.mts +76 -0
  217. package/dist/react/labels/wizard.mjs +97 -0
  218. package/dist/react/labels/wizard.mjs.map +1 -0
  219. package/dist/react/link/index.d.mts +5 -0
  220. package/dist/react/link/index.mjs +5 -0
  221. package/dist/react/portal/SessionTouch.d.mts +19 -0
  222. package/dist/react/portal/SessionTouch.mjs +40 -0
  223. package/dist/react/portal/SessionTouch.mjs.map +1 -0
  224. package/dist/react/portal/age-prompt.d.mts +10 -0
  225. package/dist/react/portal/age-prompt.mjs +21 -0
  226. package/dist/react/portal/age-prompt.mjs.map +1 -0
  227. package/dist/react/portal/book-again.d.mts +18 -0
  228. package/dist/react/portal/book-again.mjs +21 -0
  229. package/dist/react/portal/book-again.mjs.map +1 -0
  230. package/dist/react/portal/child-summary.d.mts +17 -0
  231. package/dist/react/portal/child-summary.mjs +45 -0
  232. package/dist/react/portal/child-summary.mjs.map +1 -0
  233. package/dist/react/portal/dashboard.d.mts +74 -0
  234. package/dist/react/portal/dashboard.mjs +204 -0
  235. package/dist/react/portal/dashboard.mjs.map +1 -0
  236. package/dist/react/portal/leave.d.mts +17 -0
  237. package/dist/react/portal/leave.mjs +21 -0
  238. package/dist/react/portal/leave.mjs.map +1 -0
  239. package/dist/react/portal/sections.d.mts +12 -0
  240. package/dist/react/portal/sections.mjs +48 -0
  241. package/dist/react/portal/sections.mjs.map +1 -0
  242. package/dist/react/portal/tabs.d.mts +23 -0
  243. package/dist/react/portal/tabs.mjs +36 -0
  244. package/dist/react/portal/tabs.mjs.map +1 -0
  245. package/dist/react/portal/vipps-flash.d.mts +11 -0
  246. package/dist/react/portal/vipps-flash.mjs +18 -0
  247. package/dist/react/portal/vipps-flash.mjs.map +1 -0
  248. package/dist/react/portal/wired.mjs +202 -0
  249. package/dist/react/portal/wired.mjs.map +1 -0
  250. package/dist/react/screen-labels.mjs +14 -0
  251. package/dist/react/screen-labels.mjs.map +1 -0
  252. package/dist/react/shared.d.mts +13 -0
  253. package/dist/react/shared.mjs +7 -0
  254. package/dist/react/use-next-free.d.mts +19 -0
  255. package/dist/react/use-next-free.mjs +56 -0
  256. package/dist/react/use-next-free.mjs.map +1 -0
  257. package/dist/react/useBooking.d.mts +179 -0
  258. package/dist/react/useBooking.mjs +1331 -0
  259. package/dist/react/useBooking.mjs.map +1 -0
  260. package/dist/react/wizard/adapters.mjs +189 -0
  261. package/dist/react/wizard/adapters.mjs.map +1 -0
  262. package/dist/react/wizard/index.d.mts +11 -0
  263. package/dist/react/wizard/index.mjs +6 -0
  264. package/dist/react/wizard-error.mjs +29 -0
  265. package/dist/react/wizard-error.mjs.map +1 -0
  266. package/package.json +126 -5
@@ -0,0 +1,158 @@
1
+ import { BookingConfig } from "../core/config.mjs";
2
+ import { CookieSource } from "../core/portal/cookies.mjs";
3
+ import { MedalSeamOptions } from "./medal.mjs";
4
+ //#region src/next/options.d.ts
5
+ /**
6
+ * One cache the package reads through. Two of them back a booking site: the
7
+ * per-location EDGE cache (the booking seed and catalogue, a few ms to read)
8
+ * and the cross-isolate DATA cache (services, stylists, hours and free slots).
9
+ *
10
+ * `get`/`put`/`delete` are the contract every adapter keeps. The optional
11
+ * members are what a platform adds on top: tag expiry for a data cache, a
12
+ * background hand-off, a deploy version and a storage-key mapping for an
13
+ * edge cache, and a read-through `load` for a cache (Next's) that is a
14
+ * function wrapper rather than a key-value store.
15
+ */
16
+ export interface BookingCacheAdapter {
17
+ /** The value under `key`, or `undefined` for a miss. May throw; callers treat a throw as a miss. */
18
+ get<T>(key: string): Promise<T | undefined>;
19
+ /** Store `value` for at most `ttlSeconds`. `tags` group entries for `expireTag`. */
20
+ put<T>(key: string, value: T, ttlSeconds: number, options?: {
21
+ tags?: readonly string[];
22
+ }): Promise<void>;
23
+ /** Drop `keys` — in this location only, for an edge cache. */
24
+ delete(keys: readonly string[]): Promise<void>;
25
+ /** Expire every entry stored under `tag`. */
26
+ expireTag?(tag: string): Promise<void>;
27
+ /**
28
+ * Hand background work to the platform (`ctx.waitUntil`, `after()`), so it
29
+ * outlives the response without holding it. Answers `false` when there is no
30
+ * hook for THIS request; the caller then awaits the work itself.
31
+ */
32
+ defer?(work: Promise<unknown>): boolean;
33
+ /**
34
+ * The storage key for a package key, or `null` when this adapter cannot
35
+ * store anything right now (off its platform, no origin configured). The
36
+ * default is the key itself.
37
+ */
38
+ key?(key: string): string | null;
39
+ /** The deployed version, mixed into edge keys so a deploy starts cold. */
40
+ version?(): string | undefined;
41
+ /**
42
+ * Read-through: the cached answer for `keyParts` + `args`, or `fill()`'s,
43
+ * stored for `ttlSeconds` under `tags`. Adapters without it get the
44
+ * `get`-then-`put` default (`cacheLoad`).
45
+ */
46
+ load?<T>(keyParts: readonly string[], args: readonly unknown[], fill: () => Promise<T>, options: {
47
+ ttlSeconds: number;
48
+ tags: readonly string[];
49
+ }): Promise<T>;
50
+ }
51
+ /**
52
+ * Pino's call shape — `(meta, message)` — which is what every log line in the
53
+ * package writes. `console` accepts it too.
54
+ */
55
+ export interface BookingLogger {
56
+ info(meta: unknown, message?: string): void;
57
+ warn(meta: unknown, message?: string): void;
58
+ error(meta: unknown, message?: string): void;
59
+ }
60
+ /** The phases the package measures. Fixed strings: the header is public. */
61
+ export type BookingTimingPhase = 'catalogue' | 'seed' | 'guardian';
62
+ /**
63
+ * A `Server-Timing` sink. `meta.start` / `meta.end` are the span in
64
+ * `Date.now()` milliseconds; the seed phase also carries `meta.state`
65
+ * (`hit`, `stale`, `miss` or `bypass`).
66
+ */
67
+ export type BookingTiming = (phase: BookingTimingPhase, ms: number, meta?: Record<string, string>) => void;
68
+ /**
69
+ * The app's rate limiter, asked once per handler request before anything is
70
+ * read. `true` answers `429 { error: 'rateLimited' }`. `scope` is the route
71
+ * (`booking/create`, `portal/login/verify`, …).
72
+ */
73
+ export type BookingRateLimit = (scope: string, request: Request) => boolean | Promise<boolean>;
74
+ export interface BookingServerOptions {
75
+ /** The resolved, client-safe config (`resolveBookingConfig`). */
76
+ config: Readonly<BookingConfig>;
77
+ /**
78
+ * The Medal API key and origin. The app passes its own secrets in — as
79
+ * values, or as functions read on every call where the platform fills
80
+ * `process.env` per request (OpenNext on Workers). A missing or placeholder
81
+ * key fails as `MedalConfigError` when Medal is first asked, never at
82
+ * import.
83
+ */
84
+ medal: MedalSeamOptions;
85
+ cache: {
86
+ /** L1: per-location, few-ms reads (the booking seed and catalogue). */
87
+ edge: BookingCacheAdapter;
88
+ /** L2: cross-isolate data cache (services, stylists, hours, slots). */
89
+ data: BookingCacheAdapter;
90
+ /** Data-cache key prefix (the first key part of every L2 entry). */
91
+ prefix: string;
92
+ /** Edge-key path prefix. Default `/__medal-edge/booking-seed/v1`. */
93
+ edgePrefix?: string;
94
+ /** Added to every edge key, so staging and production on one zone never share. */
95
+ environment: string;
96
+ };
97
+ /** The cookie jar for this request. Default: `cookies()` from `next/headers`. */
98
+ cookies?: CookieSource;
99
+ timing?: BookingTiming;
100
+ logger?: BookingLogger;
101
+ rateLimit?: BookingRateLimit;
102
+ /**
103
+ * The origin to fall back on when a request names none (a server action
104
+ * with no host header): the site's canonical URL.
105
+ */
106
+ baseUrl?: string | (() => string | undefined);
107
+ /** The portal's server-side switches. Every field has a default. */
108
+ portal?: BookingPortalServerOptions;
109
+ /**
110
+ * The hosts the avatar route may fetch a stylist photo from: an exact
111
+ * hostname (`images.example.com`) or a `*.` suffix (`*.example.com`, which
112
+ * matches every subdomain but not the apex). Default
113
+ * `DEFAULT_AVATAR_HOSTS` — Medal's photo storage and Google profile
114
+ * pictures. Whatever is listed, the route only fetches `https:` on the
115
+ * default port, never an IP address, `localhost` or an internal name, and
116
+ * never follows a redirect.
117
+ */
118
+ avatarHosts?: readonly string[];
119
+ }
120
+ /**
121
+ * Where Medal's `photo_url` points: presigned Cloudflare R2 objects (Medal's
122
+ * own photo storage) and, for a stylist linked to a member with no photo of
123
+ * their own, that member's Google profile picture.
124
+ */
125
+ export declare const DEFAULT_AVATAR_HOSTS: readonly string[];
126
+ export interface BookingPortalServerOptions {
127
+ /**
128
+ * Whether the portal is on for THIS request, for a site whose switch lives
129
+ * in a CMS. Default `config.portal.enabled`.
130
+ */
131
+ enabled?: () => boolean | Promise<boolean>;
132
+ /** The «Vipps link» flash cookie. Default `<portal.cookieName>_vipps_flash`. */
133
+ vippsFlashCookieName?: string;
134
+ /** Where Vipps returns a login (the `vippsReturn` route). Default `<paths.portal>/vipps`. */
135
+ vippsReturnPath?: string;
136
+ /** Where Vipps returns a «link Vipps» (the `vippsLinkReturn` route). Default `<paths.portal>/vipps/link`. */
137
+ vippsLinkReturnPath?: string;
138
+ /** The word the delete-account form must carry. Default `'SLETT'`. */
139
+ deleteConfirmWord?: string;
140
+ /** The data export's file name, before `-<date>.json`. Default `'min-side-eksport'`. */
141
+ exportFilePrefix?: string;
142
+ /** The language of Medal's login e-mails. Default `'no'`. */
143
+ locale?: 'no' | 'en';
144
+ /** The validation sentences the portal's actions answer with. */
145
+ messages?: Partial<PortalMessages>;
146
+ }
147
+ /** The sentences the portal answers a form with. Norwegian by default. */
148
+ export interface PortalMessages {
149
+ duplicateChild: string;
150
+ invalidChild: string;
151
+ childNotFound: string;
152
+ invalidPhone: string;
153
+ invalidBirthYear: string;
154
+ }
155
+ export declare const DEFAULT_PORTAL_MESSAGES: PortalMessages;
156
+ export declare const DEFAULT_EDGE_PREFIX = "/__medal-edge/booking-seed/v1";
157
+ //#endregion
158
+ //# sourceMappingURL=options.d.mts.map
@@ -0,0 +1,65 @@
1
+ //#region src/next/options.ts
2
+ /**
3
+ * Where Medal's `photo_url` points: presigned Cloudflare R2 objects (Medal's
4
+ * own photo storage) and, for a stylist linked to a member with no photo of
5
+ * their own, that member's Google profile picture.
6
+ */
7
+ const DEFAULT_AVATAR_HOSTS = Object.freeze(["*.r2.cloudflarestorage.com", "*.googleusercontent.com"]);
8
+ const DEFAULT_PORTAL_MESSAGES = {
9
+ duplicateChild: "Dette barnet er allerede lagt inn.",
10
+ invalidChild: "Sjekk navn, fødselsår og måned.",
11
+ childNotFound: "Fant ikke barnet. Last siden på nytt og prøv igjen.",
12
+ invalidPhone: "Telefonnummeret må ha åtte siffer.",
13
+ invalidBirthYear: "Fødselsåret ser ikke riktig ut."
14
+ };
15
+ const DEFAULT_EDGE_PREFIX = "/__medal-edge/booking-seed/v1";
16
+ const quiet = () => {};
17
+ /** The default logger: silent. A site that wants the lines passes its own. */
18
+ const SILENT_LOGGER = {
19
+ info: quiet,
20
+ warn: quiet,
21
+ error: quiet
22
+ };
23
+ /**
24
+ * `work` measured into `timing` under `phase`, or just `work` when there is no
25
+ * sink. `meta` is read after the work, so a phase can report how it ended.
26
+ */
27
+ async function timed(timing, phase, work, meta) {
28
+ if (!timing) return work();
29
+ const start = Date.now();
30
+ try {
31
+ return await work();
32
+ } finally {
33
+ const end = Date.now();
34
+ timing(phase, end - start, {
35
+ start: String(start),
36
+ end: String(end),
37
+ ...meta?.()
38
+ });
39
+ }
40
+ }
41
+ /**
42
+ * Hand `work` to the adapter's `defer`, or await it where there is none.
43
+ * Never throws: everything scheduled through here is an optimisation.
44
+ */
45
+ async function inBackground(adapter, work) {
46
+ const settled = work.then(() => void 0, () => void 0);
47
+ if (adapter.defer?.(settled) === true) return;
48
+ await settled;
49
+ }
50
+ /** The read-through every adapter without its own `load` gets. */
51
+ async function cacheLoad(adapter, keyParts, args, fill, options) {
52
+ if (adapter.load) return adapter.load(keyParts, args, fill, options);
53
+ const path = `/${[...keyParts, JSON.stringify(args)].map(encodeURIComponent).join("/")}`;
54
+ const key = adapter.key ? adapter.key(path) : path;
55
+ if (key === null) return fill();
56
+ const hit = await adapter.get(key).catch(() => void 0);
57
+ if (hit !== void 0) return hit;
58
+ const value = await fill();
59
+ await adapter.put(key, value, options.ttlSeconds, { tags: options.tags }).catch(() => void 0);
60
+ return value;
61
+ }
62
+ //#endregion
63
+ export { DEFAULT_AVATAR_HOSTS, DEFAULT_EDGE_PREFIX, DEFAULT_PORTAL_MESSAGES, SILENT_LOGGER, cacheLoad, inBackground, timed };
64
+
65
+ //# sourceMappingURL=options.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"options.mjs","names":[],"sources":["../../src/next/options.ts"],"sourcesContent":["/**\n * What a site hands the server half of the booking package.\n *\n * Split from `BookingConfig` by TRUST. The config is client-safe and goes into\n * the page as a prop; everything here — the Medal key, the cache handles, the\n * cookie jar, the logger — stays on the server and is never serialised.\n *\n * The package reads NO environment variable. A site passes its own key in\n * (`process.env.MEDAL_API_KEY` in a Next app), so the name of the variable,\n * the secret manager behind it and the moment it is read are all the app's.\n */\n\nimport type { BookingConfig } from '../core/config';\nimport type { CookieSource } from '../core/portal/cookies';\nimport type { MedalSeamOptions } from './medal';\n\n/**\n * One cache the package reads through. Two of them back a booking site: the\n * per-location EDGE cache (the booking seed and catalogue, a few ms to read)\n * and the cross-isolate DATA cache (services, stylists, hours and free slots).\n *\n * `get`/`put`/`delete` are the contract every adapter keeps. The optional\n * members are what a platform adds on top: tag expiry for a data cache, a\n * background hand-off, a deploy version and a storage-key mapping for an\n * edge cache, and a read-through `load` for a cache (Next's) that is a\n * function wrapper rather than a key-value store.\n */\nexport interface BookingCacheAdapter {\n /** The value under `key`, or `undefined` for a miss. May throw; callers treat a throw as a miss. */\n get<T>(key: string): Promise<T | undefined>;\n /** Store `value` for at most `ttlSeconds`. `tags` group entries for `expireTag`. */\n put<T>(\n key: string,\n value: T,\n ttlSeconds: number,\n options?: { tags?: readonly string[] }\n ): Promise<void>;\n /** Drop `keys` — in this location only, for an edge cache. */\n delete(keys: readonly string[]): Promise<void>;\n /** Expire every entry stored under `tag`. */\n expireTag?(tag: string): Promise<void>;\n /**\n * Hand background work to the platform (`ctx.waitUntil`, `after()`), so it\n * outlives the response without holding it. Answers `false` when there is no\n * hook for THIS request; the caller then awaits the work itself.\n */\n defer?(work: Promise<unknown>): boolean;\n /**\n * The storage key for a package key, or `null` when this adapter cannot\n * store anything right now (off its platform, no origin configured). The\n * default is the key itself.\n */\n key?(key: string): string | null;\n /** The deployed version, mixed into edge keys so a deploy starts cold. */\n version?(): string | undefined;\n /**\n * Read-through: the cached answer for `keyParts` + `args`, or `fill()`'s,\n * stored for `ttlSeconds` under `tags`. Adapters without it get the\n * `get`-then-`put` default (`cacheLoad`).\n */\n load?<T>(\n keyParts: readonly string[],\n args: readonly unknown[],\n fill: () => Promise<T>,\n options: { ttlSeconds: number; tags: readonly string[] }\n ): Promise<T>;\n}\n\n/**\n * Pino's call shape — `(meta, message)` — which is what every log line in the\n * package writes. `console` accepts it too.\n */\nexport interface BookingLogger {\n info(meta: unknown, message?: string): void;\n warn(meta: unknown, message?: string): void;\n error(meta: unknown, message?: string): void;\n}\n\n/** The phases the package measures. Fixed strings: the header is public. */\nexport type BookingTimingPhase = 'catalogue' | 'seed' | 'guardian';\n\n/**\n * A `Server-Timing` sink. `meta.start` / `meta.end` are the span in\n * `Date.now()` milliseconds; the seed phase also carries `meta.state`\n * (`hit`, `stale`, `miss` or `bypass`).\n */\nexport type BookingTiming = (\n phase: BookingTimingPhase,\n ms: number,\n meta?: Record<string, string>\n) => void;\n\n/**\n * The app's rate limiter, asked once per handler request before anything is\n * read. `true` answers `429 { error: 'rateLimited' }`. `scope` is the route\n * (`booking/create`, `portal/login/verify`, …).\n */\nexport type BookingRateLimit = (scope: string, request: Request) => boolean | Promise<boolean>;\n\nexport interface BookingServerOptions {\n /** The resolved, client-safe config (`resolveBookingConfig`). */\n config: Readonly<BookingConfig>;\n /**\n * The Medal API key and origin. The app passes its own secrets in — as\n * values, or as functions read on every call where the platform fills\n * `process.env` per request (OpenNext on Workers). A missing or placeholder\n * key fails as `MedalConfigError` when Medal is first asked, never at\n * import.\n */\n medal: MedalSeamOptions;\n cache: {\n /** L1: per-location, few-ms reads (the booking seed and catalogue). */\n edge: BookingCacheAdapter;\n /** L2: cross-isolate data cache (services, stylists, hours, slots). */\n data: BookingCacheAdapter;\n /** Data-cache key prefix (the first key part of every L2 entry). */\n prefix: string;\n /** Edge-key path prefix. Default `/__medal-edge/booking-seed/v1`. */\n edgePrefix?: string;\n /** Added to every edge key, so staging and production on one zone never share. */\n environment: string;\n };\n /** The cookie jar for this request. Default: `cookies()` from `next/headers`. */\n cookies?: CookieSource;\n timing?: BookingTiming;\n logger?: BookingLogger;\n rateLimit?: BookingRateLimit;\n /**\n * The origin to fall back on when a request names none (a server action\n * with no host header): the site's canonical URL.\n */\n baseUrl?: string | (() => string | undefined);\n /** The portal's server-side switches. Every field has a default. */\n portal?: BookingPortalServerOptions;\n /**\n * The hosts the avatar route may fetch a stylist photo from: an exact\n * hostname (`images.example.com`) or a `*.` suffix (`*.example.com`, which\n * matches every subdomain but not the apex). Default\n * `DEFAULT_AVATAR_HOSTS` — Medal's photo storage and Google profile\n * pictures. Whatever is listed, the route only fetches `https:` on the\n * default port, never an IP address, `localhost` or an internal name, and\n * never follows a redirect.\n */\n avatarHosts?: readonly string[];\n}\n\n/**\n * Where Medal's `photo_url` points: presigned Cloudflare R2 objects (Medal's\n * own photo storage) and, for a stylist linked to a member with no photo of\n * their own, that member's Google profile picture.\n */\nexport const DEFAULT_AVATAR_HOSTS: readonly string[] = Object.freeze([\n '*.r2.cloudflarestorage.com',\n '*.googleusercontent.com',\n]);\n\nexport interface BookingPortalServerOptions {\n /**\n * Whether the portal is on for THIS request, for a site whose switch lives\n * in a CMS. Default `config.portal.enabled`.\n */\n enabled?: () => boolean | Promise<boolean>;\n /** The «Vipps link» flash cookie. Default `<portal.cookieName>_vipps_flash`. */\n vippsFlashCookieName?: string;\n /** Where Vipps returns a login (the `vippsReturn` route). Default `<paths.portal>/vipps`. */\n vippsReturnPath?: string;\n /** Where Vipps returns a «link Vipps» (the `vippsLinkReturn` route). Default `<paths.portal>/vipps/link`. */\n vippsLinkReturnPath?: string;\n /** The word the delete-account form must carry. Default `'SLETT'`. */\n deleteConfirmWord?: string;\n /** The data export's file name, before `-<date>.json`. Default `'min-side-eksport'`. */\n exportFilePrefix?: string;\n /** The language of Medal's login e-mails. Default `'no'`. */\n locale?: 'no' | 'en';\n /** The validation sentences the portal's actions answer with. */\n messages?: Partial<PortalMessages>;\n}\n\n/** The sentences the portal answers a form with. Norwegian by default. */\nexport interface PortalMessages {\n duplicateChild: string;\n invalidChild: string;\n childNotFound: string;\n invalidPhone: string;\n invalidBirthYear: string;\n}\n\nexport const DEFAULT_PORTAL_MESSAGES: PortalMessages = {\n duplicateChild: 'Dette barnet er allerede lagt inn.',\n invalidChild: 'Sjekk navn, fødselsår og måned.',\n childNotFound: 'Fant ikke barnet. Last siden på nytt og prøv igjen.',\n invalidPhone: 'Telefonnummeret må ha åtte siffer.',\n invalidBirthYear: 'Fødselsåret ser ikke riktig ut.',\n};\n\nexport const DEFAULT_EDGE_PREFIX = '/__medal-edge/booking-seed/v1';\n\nconst quiet = () => {};\n\n/** The default logger: silent. A site that wants the lines passes its own. */\nexport const SILENT_LOGGER: BookingLogger = { info: quiet, warn: quiet, error: quiet };\n\n/**\n * `work` measured into `timing` under `phase`, or just `work` when there is no\n * sink. `meta` is read after the work, so a phase can report how it ended.\n */\nexport async function timed<T>(\n timing: BookingTiming | undefined,\n phase: BookingTimingPhase,\n work: () => Promise<T>,\n meta?: () => Record<string, string> | undefined\n): Promise<T> {\n if (!timing) return work();\n const start = Date.now();\n try {\n return await work();\n } finally {\n const end = Date.now();\n timing(phase, end - start, { start: String(start), end: String(end), ...meta?.() });\n }\n}\n\n/**\n * Hand `work` to the adapter's `defer`, or await it where there is none.\n * Never throws: everything scheduled through here is an optimisation.\n */\nexport async function inBackground(\n adapter: Pick<BookingCacheAdapter, 'defer'>,\n work: Promise<unknown>\n): Promise<void> {\n const settled = work.then(\n () => undefined,\n () => undefined\n );\n if (adapter.defer?.(settled) === true) return;\n await settled;\n}\n\n/** The read-through every adapter without its own `load` gets. */\nexport async function cacheLoad<T>(\n adapter: BookingCacheAdapter,\n keyParts: readonly string[],\n args: readonly unknown[],\n fill: () => Promise<T>,\n options: { ttlSeconds: number; tags: readonly string[] }\n): Promise<T> {\n if (adapter.load) return adapter.load(keyParts, args, fill, options);\n // A path, so an adapter that keys by URL (the Workers cache) can store it.\n const path = `/${[...keyParts, JSON.stringify(args)].map(encodeURIComponent).join('/')}`;\n const key = adapter.key ? adapter.key(path) : path;\n if (key === null) return fill();\n const hit = await adapter.get<T>(key).catch(() => undefined);\n if (hit !== undefined) return hit;\n const value = await fill();\n await adapter.put(key, value, options.ttlSeconds, { tags: options.tags }).catch(() => undefined);\n return value;\n}\n"],"mappings":";;;;;;AAuJA,MAAa,uBAA0C,OAAO,OAAO,CACnE,8BACA,yBACF,CAAC;AAiCD,MAAa,0BAA0C;CACrD,gBAAgB;CAChB,cAAc;CACd,eAAe;CACf,cAAc;CACd,kBAAkB;AACpB;AAEA,MAAa,sBAAsB;AAEnC,MAAM,cAAc,CAAC;;AAGrB,MAAa,gBAA+B;CAAE,MAAM;CAAO,MAAM;CAAO,OAAO;AAAM;;;;;AAMrF,eAAsB,MACpB,QACA,OACA,MACA,MACY;CACZ,IAAI,CAAC,QAAQ,OAAO,KAAK;CACzB,MAAM,QAAQ,KAAK,IAAI;CACvB,IAAI;EACF,OAAO,MAAM,KAAK;CACpB,UAAU;EACR,MAAM,MAAM,KAAK,IAAI;EACrB,OAAO,OAAO,MAAM,OAAO;GAAE,OAAO,OAAO,KAAK;GAAG,KAAK,OAAO,GAAG;GAAG,GAAG,OAAO;EAAE,CAAC;CACpF;AACF;;;;;AAMA,eAAsB,aACpB,SACA,MACe;CACf,MAAM,UAAU,KAAK,WACb,KAAA,SACA,KAAA,CACR;CACA,IAAI,QAAQ,QAAQ,OAAO,MAAM,MAAM;CACvC,MAAM;AACR;;AAGA,eAAsB,UACpB,SACA,UACA,MACA,MACA,SACY;CACZ,IAAI,QAAQ,MAAM,OAAO,QAAQ,KAAK,UAAU,MAAM,MAAM,OAAO;CAEnE,MAAM,OAAO,IAAI,CAAC,GAAG,UAAU,KAAK,UAAU,IAAI,CAAC,CAAC,CAAC,IAAI,kBAAkB,CAAC,CAAC,KAAK,GAAG;CACrF,MAAM,MAAM,QAAQ,MAAM,QAAQ,IAAI,IAAI,IAAI;CAC9C,IAAI,QAAQ,MAAM,OAAO,KAAK;CAC9B,MAAM,MAAM,MAAM,QAAQ,IAAO,GAAG,CAAC,CAAC,YAAY,KAAA,CAAS;CAC3D,IAAI,QAAQ,KAAA,GAAW,OAAO;CAC9B,MAAM,QAAQ,MAAM,KAAK;CACzB,MAAM,QAAQ,IAAI,KAAK,OAAO,QAAQ,YAAY,EAAE,MAAM,QAAQ,KAAK,CAAC,CAAC,CAAC,YAAY,KAAA,CAAS;CAC/F,OAAO;AACT"}
@@ -0,0 +1,42 @@
1
+ import { BookingFamilyMember, BookingGuardian } from "../../core/types.mjs";
2
+ import { PortalBookingDto, PortalFamilyMemberDto } from "../../core/portal/dto.mjs";
3
+ import { BookingLogger } from "../options.mjs";
4
+ import { PortalSeam } from "./medal-portal.mjs";
5
+ //#region src/next/portal/guardian.d.ts
6
+ /**
7
+ * One child, narrowed for the browser: the id, the name, the year and month
8
+ * the wizard computes an age from, and the stylist a card can preselect.
9
+ * `notes` (the note for the stylist) stays behind — it is for the salon, and the
10
+ * wizard renders none of it. Nulls become absent keys, the wizard's rule.
11
+ */
12
+ export declare function toBookingFamilyMember(member: PortalFamilyMemberDto, lastVisit?: PortalBookingDto | null): BookingFamilyMember;
13
+ /**
14
+ * The logged-in parent, as the booking wizard is allowed to see them.
15
+ *
16
+ * Two callers, one answer, and the reason this is a module rather than a
17
+ * function in the booking page: the page asks it on the way in (a parent
18
+ * who arrived holding a Min side cookie), and `POST /api/portal/login/verify`
19
+ * asks it the moment an e-mail code has been accepted, so the wizard can fill
20
+ * its fields in place instead of refreshing a whole server page to find out
21
+ * who just logged in. A second copy of the narrowing in the route would be a
22
+ * second place for the `contactId` or the marketing flag to leak into the
23
+ * browser from.
24
+ *
25
+ * Takes the SESSION rather than reading the jar, because the route has the
26
+ * token in hand before any cookie exists to read — and a handler reading back
27
+ * its own `Set-Cookie` in the same request is not something to lean on.
28
+ *
29
+ * BEST EFFORT IN EVERY DIRECTION. A session Medal no longer honours, or Medal
30
+ * being unreachable, is `null` and a warning — never a throw and never a
31
+ * redirect. On the booking page that is a parent the page could not identify, who
32
+ * books like everybody else; after a login it is a parent who is logged in but
33
+ * whose fields the wizard cannot prefill, which is the same form they would
34
+ * have typed into anyway.
35
+ *
36
+ * The narrowing to `BookingGuardian` is deliberate and happens HERE, on the
37
+ * server: `contactId`, the marketing flag and the bransjemal labels stay behind
38
+ * because the wizard renders none of them.
39
+ */
40
+ export declare function createGuardian(portal: Pick<PortalSeam, 'getMe' | 'getMyBookings'>, logger: BookingLogger): (session: string) => Promise<BookingGuardian | null>;
41
+ //#endregion
42
+ //# sourceMappingURL=guardian.d.mts.map
@@ -0,0 +1,76 @@
1
+ import { completedNewestFirst, firstBookingFor } from "../../core/portal/family.mjs";
2
+ //#region src/next/portal/guardian.ts
3
+ /**
4
+ * One child, narrowed for the browser: the id, the name, the year and month
5
+ * the wizard computes an age from, and the stylist a card can preselect.
6
+ * `notes` (the note for the stylist) stays behind — it is for the salon, and the
7
+ * wizard renders none of it. Nulls become absent keys, the wizard's rule.
8
+ */
9
+ function toBookingFamilyMember(member, lastVisit = null) {
10
+ return {
11
+ name: member.name,
12
+ birthYear: member.birthYear,
13
+ ...member.personId === null ? {} : { personId: member.personId },
14
+ ...member.birthMonth === null ? {} : { birthMonth: member.birthMonth },
15
+ ...member.preferredResourceId === null ? {} : { preferredResourceId: member.preferredResourceId },
16
+ ...lastVisit?.serviceId ? { lastVisit: {
17
+ serviceId: lastVisit.serviceId,
18
+ serviceName: lastVisit.serviceName,
19
+ resourceId: lastVisit.resourceId,
20
+ startTs: lastVisit.startTs
21
+ } } : {}
22
+ };
23
+ }
24
+ /**
25
+ * The logged-in parent, as the booking wizard is allowed to see them.
26
+ *
27
+ * Two callers, one answer, and the reason this is a module rather than a
28
+ * function in the booking page: the page asks it on the way in (a parent
29
+ * who arrived holding a Min side cookie), and `POST /api/portal/login/verify`
30
+ * asks it the moment an e-mail code has been accepted, so the wizard can fill
31
+ * its fields in place instead of refreshing a whole server page to find out
32
+ * who just logged in. A second copy of the narrowing in the route would be a
33
+ * second place for the `contactId` or the marketing flag to leak into the
34
+ * browser from.
35
+ *
36
+ * Takes the SESSION rather than reading the jar, because the route has the
37
+ * token in hand before any cookie exists to read — and a handler reading back
38
+ * its own `Set-Cookie` in the same request is not something to lean on.
39
+ *
40
+ * BEST EFFORT IN EVERY DIRECTION. A session Medal no longer honours, or Medal
41
+ * being unreachable, is `null` and a warning — never a throw and never a
42
+ * redirect. On the booking page that is a parent the page could not identify, who
43
+ * books like everybody else; after a login it is a parent who is logged in but
44
+ * whose fields the wizard cannot prefill, which is the same form they would
45
+ * have typed into anyway.
46
+ *
47
+ * The narrowing to `BookingGuardian` is deliberate and happens HERE, on the
48
+ * server: `contactId`, the marketing flag and the bransjemal labels stay behind
49
+ * because the wizard renders none of them.
50
+ */
51
+ function createGuardian(portal, logger) {
52
+ const { getMe, getMyBookings } = portal;
53
+ return async function guardianFromSession(session) {
54
+ try {
55
+ const [profile, bookings] = await Promise.all([getMe(session), getMyBookings(session).catch((error) => {
56
+ logger.warn({ err: error }, "Could not read past bookings for the booking wizard");
57
+ return null;
58
+ })]);
59
+ const last = firstBookingFor(profile.family, completedNewestFirst(bookings?.past ?? []));
60
+ return {
61
+ firstName: profile.firstName,
62
+ lastName: profile.lastName,
63
+ email: profile.email,
64
+ phone: profile.phone,
65
+ family: profile.family.map((member, index) => toBookingFamilyMember(member, last[index]))
66
+ };
67
+ } catch (error) {
68
+ logger.warn({ err: error }, "Could not read the Min side profile for the booking wizard");
69
+ return null;
70
+ }
71
+ };
72
+ }
73
+ //#endregion
74
+ export { createGuardian, toBookingFamilyMember };
75
+
76
+ //# sourceMappingURL=guardian.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"guardian.mjs","names":[],"sources":["../../../src/next/portal/guardian.ts"],"sourcesContent":["import type { PortalBookingDto, PortalFamilyMemberDto } from '../../core/portal/dto';\nimport { completedNewestFirst, firstBookingFor } from '../../core/portal/family';\nimport type { BookingFamilyMember, BookingGuardian } from '../../core/types';\nimport type { BookingLogger } from '../options';\nimport type { PortalSeam } from './medal-portal';\n\n/**\n * One child, narrowed for the browser: the id, the name, the year and month\n * the wizard computes an age from, and the stylist a card can preselect.\n * `notes` (the note for the stylist) stays behind — it is for the salon, and the\n * wizard renders none of it. Nulls become absent keys, the wizard's rule.\n */\nexport function toBookingFamilyMember(\n member: PortalFamilyMemberDto,\n lastVisit: PortalBookingDto | null = null\n): BookingFamilyMember {\n return {\n name: member.name,\n birthYear: member.birthYear,\n ...(member.personId === null ? {} : { personId: member.personId }),\n ...(member.birthMonth === null ? {} : { birthMonth: member.birthMonth }),\n ...(member.preferredResourceId === null\n ? {}\n : { preferredResourceId: member.preferredResourceId }),\n ...(lastVisit?.serviceId\n ? {\n lastVisit: {\n serviceId: lastVisit.serviceId,\n serviceName: lastVisit.serviceName,\n resourceId: lastVisit.resourceId,\n startTs: lastVisit.startTs,\n },\n }\n : {}),\n };\n}\n\n/**\n * The logged-in parent, as the booking wizard is allowed to see them.\n *\n * Two callers, one answer, and the reason this is a module rather than a\n * function in the booking page: the page asks it on the way in (a parent\n * who arrived holding a Min side cookie), and `POST /api/portal/login/verify`\n * asks it the moment an e-mail code has been accepted, so the wizard can fill\n * its fields in place instead of refreshing a whole server page to find out\n * who just logged in. A second copy of the narrowing in the route would be a\n * second place for the `contactId` or the marketing flag to leak into the\n * browser from.\n *\n * Takes the SESSION rather than reading the jar, because the route has the\n * token in hand before any cookie exists to read — and a handler reading back\n * its own `Set-Cookie` in the same request is not something to lean on.\n *\n * BEST EFFORT IN EVERY DIRECTION. A session Medal no longer honours, or Medal\n * being unreachable, is `null` and a warning — never a throw and never a\n * redirect. On the booking page that is a parent the page could not identify, who\n * books like everybody else; after a login it is a parent who is logged in but\n * whose fields the wizard cannot prefill, which is the same form they would\n * have typed into anyway.\n *\n * The narrowing to `BookingGuardian` is deliberate and happens HERE, on the\n * server: `contactId`, the marketing flag and the bransjemal labels stay behind\n * because the wizard renders none of them.\n */\nexport function createGuardian(\n portal: Pick<PortalSeam, 'getMe' | 'getMyBookings'>,\n logger: BookingLogger\n): (session: string) => Promise<BookingGuardian | null> {\n const { getMe, getMyBookings } = portal;\n return async function guardianFromSession(session: string): Promise<BookingGuardian | null> {\n try {\n // The bookings only feed each child's «Sist: …» and step 2's «Samme som\n // sist», so they are best effort within best effort: a failed read is a\n // family without last visits, not a parent the wizard cannot recognise.\n const [profile, bookings] = await Promise.all([\n getMe(session),\n getMyBookings(session).catch((error: unknown) => {\n logger.warn({ err: error }, 'Could not read past bookings for the booking wizard');\n return null;\n }),\n ]);\n const last = firstBookingFor(profile.family, completedNewestFirst(bookings?.past ?? []));\n return {\n firstName: profile.firstName,\n lastName: profile.lastName,\n email: profile.email,\n phone: profile.phone,\n family: profile.family.map((member, index) => toBookingFamilyMember(member, last[index])),\n };\n } catch (error) {\n // The seam has already scrubbed the session out of this.\n logger.warn({ err: error }, 'Could not read the Min side profile for the booking wizard');\n return null;\n }\n };\n}\n"],"mappings":";;;;;;;;AAYA,SAAgB,sBACd,QACA,YAAqC,MAChB;CACrB,OAAO;EACL,MAAM,OAAO;EACb,WAAW,OAAO;EAClB,GAAI,OAAO,aAAa,OAAO,CAAC,IAAI,EAAE,UAAU,OAAO,SAAS;EAChE,GAAI,OAAO,eAAe,OAAO,CAAC,IAAI,EAAE,YAAY,OAAO,WAAW;EACtE,GAAI,OAAO,wBAAwB,OAC/B,CAAC,IACD,EAAE,qBAAqB,OAAO,oBAAoB;EACtD,GAAI,WAAW,YACX,EACE,WAAW;GACT,WAAW,UAAU;GACrB,aAAa,UAAU;GACvB,YAAY,UAAU;GACtB,SAAS,UAAU;EACrB,EACF,IACA,CAAC;CACP;AACF;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA6BA,SAAgB,eACd,QACA,QACsD;CACtD,MAAM,EAAE,OAAO,kBAAkB;CACjC,OAAO,eAAe,oBAAoB,SAAkD;EAC1F,IAAI;GAIF,MAAM,CAAC,SAAS,YAAY,MAAM,QAAQ,IAAI,CAC5C,MAAM,OAAO,GACb,cAAc,OAAO,CAAC,CAAC,OAAO,UAAmB;IAC/C,OAAO,KAAK,EAAE,KAAK,MAAM,GAAG,qDAAqD;IACjF,OAAO;GACT,CAAC,CACH,CAAC;GACD,MAAM,OAAO,gBAAgB,QAAQ,QAAQ,qBAAqB,UAAU,QAAQ,CAAC,CAAC,CAAC;GACvF,OAAO;IACL,WAAW,QAAQ;IACnB,UAAU,QAAQ;IAClB,OAAO,QAAQ;IACf,OAAO,QAAQ;IACf,QAAQ,QAAQ,OAAO,KAAK,QAAQ,UAAU,sBAAsB,QAAQ,KAAK,MAAM,CAAC;GAC1F;EACF,SAAS,OAAO;GAEd,OAAO,KAAK,EAAE,KAAK,MAAM,GAAG,4DAA4D;GACxF,OAAO;EACT;CACF;AACF"}
@@ -0,0 +1,161 @@
1
+ import { BookingConfig } from "../../core/config.mjs";
2
+ import { PortalBookingDto, PortalProfileDto } from "../../core/portal/dto.mjs";
3
+ import { MedalSeam } from "../medal.mjs";
4
+ import { PortalMessages } from "../options.mjs";
5
+ import { PortalExport, PortalLocale, PortalProfilePatch } from "@medalsocial/sdk";
6
+ //#region src/next/portal/medal-portal.d.ts
7
+ /**
8
+ * The portal seam: everything `/min-side` asks Medal, in this site's own terms.
9
+ *
10
+ * The sibling of `lib/booking/medal-client.ts`, and built on the same SDK. The
11
+ * difference is the credential. The booking seam carries a per-appointment
12
+ * manage token on a handful of calls; every call here except the two login
13
+ * steps carries the parent's SESSION — a bearer credential for their whole
14
+ * account, read from the session cookie by `portal/session.ts` and
15
+ * handed in as a plain string. This module is the last place that string is
16
+ * known by name before it goes on the wire, which makes it the last place two
17
+ * things can be guaranteed:
18
+ *
19
+ * 1. **It is not in any error that leaves here.** The SDK formats the failing
20
+ * request into its messages, and the session travels in a header of that
21
+ * request; whatever an action lets escape is logged verbatim by
22
+ * `next-safe-action`'s error handler and a log is copied to places a cookie
23
+ * is not. `withSession` scrubs the value out of the message and stack of
24
+ * anything it rethrows — the same instance, so `instanceof`, `status` and
25
+ * `code` still hold for whoever catches it.
26
+ * 2. **It is never logged here.** There is no `logger` import in this file on
27
+ * purpose. The one thing a log line about a portal failure must not contain
28
+ * is the thing every function in this file holds.
29
+ *
30
+ * Two errors are this module's own vocabulary, because two upstream answers
31
+ * are decisions for the page rather than failures: `PortalSessionExpiredError`
32
+ * (Medal no longer honours the cookie — clear it and send the parent back to
33
+ * the login) and `PortalThrottledError` (Medal is rate-limiting this address —
34
+ * the login copy already says «if we have your address, a code is on its way»,
35
+ * and a throttle must not become an oracle for which addresses exist).
36
+ * `PortalValidationError` carries the engine's message for a rejected profile
37
+ * edit; it is written for integrators and quotes wire field names, so the form
38
+ * should prefer its own copy and fall back to this.
39
+ */
40
+ /** Medal answered 401 with a `PORTAL_SESSION_*` code: the cookie is dead. */
41
+ export declare class PortalSessionExpiredError extends Error {
42
+ constructor();
43
+ }
44
+ /** Medal answered 429: this address has asked for too many codes. */
45
+ export declare class PortalThrottledError extends Error {
46
+ constructor();
47
+ }
48
+ /** Medal rejected a profile edit; `message` is the engine's own sentence. */
49
+ export declare class PortalValidationError extends Error {
50
+ constructor(message: string);
51
+ }
52
+ /**
53
+ * Medal answered 503 `VIPPS_NOT_CONFIGURED`: this workspace has no Vipps
54
+ * credentials (yet). A decision for the login page — show the e-mail form
55
+ * and say so — not a failure.
56
+ */
57
+ export declare class PortalVippsUnavailableError extends Error {
58
+ constructor();
59
+ }
60
+ /** One child as the editor writes it, in this site's own terms. */
61
+ export interface PortalPersonInput {
62
+ name: string;
63
+ birthYear: number;
64
+ /** `null` clears; absent leaves it alone. */
65
+ birthMonth?: number | null;
66
+ notes?: string | null;
67
+ preferredResourceId?: string | null;
68
+ }
69
+ /** What a person edit reports: the profile as Medal now holds it, and whether
70
+ * the SP10 route was there to take the details. */
71
+ export interface PortalPersonResult {
72
+ profile: PortalProfileDto;
73
+ /** The id of the child just created or edited, when the profile can name it. */
74
+ personId: string | null;
75
+ fallback: boolean;
76
+ }
77
+ /**
78
+ * Which child an edit is about. `personId` wherever the profile named one;
79
+ * `index` into the profile's family only for the rare row it could not (two
80
+ * same-named, same-year children against a Medal that sends no ids), which
81
+ * only the list-replacing fallback can address.
82
+ */
83
+ export interface PersonTarget {
84
+ personId?: string | null;
85
+ index?: number;
86
+ }
87
+ /**
88
+ * Medal answered 409 `VIPPS_IDENTITY_CONFLICT` to a pending-link code: the code
89
+ * was right, but this Vipps account has since been bound to another customer.
90
+ * An answer for the login sheet — «log in with e-mail, or ring the salon».
91
+ */
92
+ export declare class PortalVippsConflictError extends Error {
93
+ constructor();
94
+ }
95
+ /** How a link grant ended: linked, bound to another profile, or not honoured. */
96
+ export type VippsLinkCompletion = 'linked' | 'conflict' | 'invalid';
97
+ type Seam = Pick<MedalSeam, 'requireMedal' | 'requireMedalWithTimeout' | 'requireMedalConfig'>;
98
+ export interface PortalSeamOptions {
99
+ config: Pick<BookingConfig, 'paths'>;
100
+ messages?: Partial<PortalMessages>;
101
+ }
102
+ /** The portal seam over one Medal seam. */
103
+ export declare function createPortalSeam(medal: Seam, options: PortalSeamOptions): {
104
+ startLogin: (email: string, locale?: PortalLocale) => Promise<void>;
105
+ verifyLogin: (email: string, code: string) => Promise<{
106
+ sessionToken: string;
107
+ expiresAt: number;
108
+ } | null>;
109
+ logout: (session: string) => Promise<void>;
110
+ getMe: (session: string) => Promise<PortalProfileDto>;
111
+ updateMe: (session: string, patch: PortalProfilePatch) => Promise<PortalProfileDto>;
112
+ getMyBookings: (session: string) => Promise<{
113
+ upcoming: PortalBookingDto[];
114
+ past: PortalBookingDto[];
115
+ }>;
116
+ exportMyData: (session: string) => Promise<PortalExport>;
117
+ deleteMe: (session: string) => Promise<void>;
118
+ startVippsLogin: ({ returnUrl, browserBinding, locale }: {
119
+ returnUrl: string;
120
+ /**
121
+ * SP10: the value this site keeps in the browser-binding cookie, so a pending link
122
+ * Medal offers can only be confirmed from this browser (`vipps-link.ts`).
123
+ * A Medal without it ignores the key.
124
+ */
125
+ browserBinding?: string;
126
+ /** The language of the code e-mail a conflicted login sends. */
127
+ locale?: 'no' | 'en';
128
+ }) => Promise<{
129
+ authorizeUrl: string;
130
+ }>;
131
+ exchangeVippsGrant: (grant: string) => Promise<{
132
+ sessionToken: string;
133
+ expiresAt: number;
134
+ } | null>;
135
+ createPerson: (session: string, input: PortalPersonInput) => Promise<PortalPersonResult>;
136
+ updatePerson: (session: string, target: PersonTarget, patch: Partial<PortalPersonInput>) => Promise<PortalPersonResult>;
137
+ removePerson: (session: string, target: PersonTarget) => Promise<PortalPersonResult>;
138
+ verifyVippsLink: ({ link, code, browserBinding }: {
139
+ link: string;
140
+ code: string;
141
+ /** Mandatory: Medal refuses a verify without the binding the login started with. */
142
+ browserBinding: string;
143
+ }) => Promise<{
144
+ sessionToken: string;
145
+ expiresAt: number;
146
+ } | null>;
147
+ startVippsLink: (session: string, { returnUrl, browserBinding, locale }: {
148
+ returnUrl: string;
149
+ browserBinding: string;
150
+ locale?: 'no' | 'en';
151
+ }) => Promise<{
152
+ authorizeUrl: string;
153
+ } | null>;
154
+ completeVippsLink: (session: string, { grant, browserBinding }: {
155
+ grant: string;
156
+ browserBinding: string;
157
+ }) => Promise<VippsLinkCompletion>;
158
+ };
159
+ export type PortalSeam = ReturnType<typeof createPortalSeam>;
160
+ //#endregion
161
+ //# sourceMappingURL=medal-portal.d.mts.map