@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.
- package/CHANGELOG.md +27 -0
- package/LICENSE +201 -0
- package/NOTICE +6 -0
- package/README.md +220 -1
- package/dist/core/age.d.mts +34 -0
- package/dist/core/age.mjs +95 -0
- package/dist/core/age.mjs.map +1 -0
- package/dist/core/attempt-store.d.mts +111 -0
- package/dist/core/attempt-store.mjs +159 -0
- package/dist/core/attempt-store.mjs.map +1 -0
- package/dist/core/categories.d.mts +27 -0
- package/dist/core/categories.mjs +42 -0
- package/dist/core/categories.mjs.map +1 -0
- package/dist/core/clock.d.mts +33 -0
- package/dist/core/clock.mjs +251 -0
- package/dist/core/clock.mjs.map +1 -0
- package/dist/core/config.d.mts +178 -0
- package/dist/core/config.mjs +394 -0
- package/dist/core/config.mjs.map +1 -0
- package/dist/core/consent.d.mts +14 -0
- package/dist/core/consent.mjs +17 -0
- package/dist/core/consent.mjs.map +1 -0
- package/dist/core/deep-link.d.mts +40 -0
- package/dist/core/deep-link.mjs +160 -0
- package/dist/core/deep-link.mjs.map +1 -0
- package/dist/core/display-name.d.mts +18 -0
- package/dist/core/display-name.mjs +30 -0
- package/dist/core/display-name.mjs.map +1 -0
- package/dist/core/draft-store.d.mts +89 -0
- package/dist/core/draft-store.mjs +131 -0
- package/dist/core/draft-store.mjs.map +1 -0
- package/dist/core/dto.d.mts +46 -0
- package/dist/core/dto.mjs +157 -0
- package/dist/core/dto.mjs.map +1 -0
- package/dist/core/ics.d.mts +63 -0
- package/dist/core/ics.mjs +130 -0
- package/dist/core/ics.mjs.map +1 -0
- package/dist/core/index.d.mts +35 -0
- package/dist/core/index.mjs +32 -0
- package/dist/core/labels.d.mts +88 -0
- package/dist/core/labels.mjs +93 -0
- package/dist/core/labels.mjs.map +1 -0
- package/dist/core/machine.d.mts +645 -0
- package/dist/core/machine.mjs +988 -0
- package/dist/core/machine.mjs.map +1 -0
- package/dist/core/money.d.mts +12 -0
- package/dist/core/money.mjs +51 -0
- package/dist/core/money.mjs.map +1 -0
- package/dist/core/next-available.d.mts +20 -0
- package/dist/core/next-available.mjs +36 -0
- package/dist/core/next-available.mjs.map +1 -0
- package/dist/core/next-free.d.mts +25 -0
- package/dist/core/next-free.mjs +44 -0
- package/dist/core/next-free.mjs.map +1 -0
- package/dist/core/party-slots.d.mts +79 -0
- package/dist/core/party-slots.mjs +229 -0
- package/dist/core/party-slots.mjs.map +1 -0
- package/dist/core/paths.d.mts +11 -0
- package/dist/core/paths.mjs +56 -0
- package/dist/core/paths.mjs.map +1 -0
- package/dist/core/phone.d.mts +17 -0
- package/dist/core/phone.mjs +58 -0
- package/dist/core/phone.mjs.map +1 -0
- package/dist/core/portal/cookies.d.mts +29 -0
- package/dist/core/portal/dto.d.mts +140 -0
- package/dist/core/portal/dto.mjs +142 -0
- package/dist/core/portal/dto.mjs.map +1 -0
- package/dist/core/portal/family.d.mts +39 -0
- package/dist/core/portal/family.mjs +39 -0
- package/dist/core/portal/family.mjs.map +1 -0
- package/dist/core/portal/login-input.d.mts +29 -0
- package/dist/core/portal/login-input.mjs +29 -0
- package/dist/core/portal/login-input.mjs.map +1 -0
- package/dist/core/portal/next-path.d.mts +12 -0
- package/dist/core/portal/next-path.mjs +98 -0
- package/dist/core/portal/next-path.mjs.map +1 -0
- package/dist/core/portal/return-path.d.mts +7 -0
- package/dist/core/portal/return-path.mjs +49 -0
- package/dist/core/portal/return-path.mjs.map +1 -0
- package/dist/core/portal/session-cookie.d.mts +27 -0
- package/dist/core/portal/session-cookie.mjs +30 -0
- package/dist/core/portal/session-cookie.mjs.map +1 -0
- package/dist/core/portal/vipps-link.d.mts +56 -0
- package/dist/core/portal/vipps-link.mjs +136 -0
- package/dist/core/portal/vipps-link.mjs.map +1 -0
- package/dist/core/portal/vipps-return.d.mts +39 -0
- package/dist/core/portal/vipps-return.mjs +69 -0
- package/dist/core/portal/vipps-return.mjs.map +1 -0
- package/dist/core/rebook-store.d.mts +28 -0
- package/dist/core/rebook-store.mjs +60 -0
- package/dist/core/rebook-store.mjs.map +1 -0
- package/dist/core/restore-gate.d.mts +40 -0
- package/dist/core/restore-gate.mjs +96 -0
- package/dist/core/restore-gate.mjs.map +1 -0
- package/dist/core/stores.d.mts +8 -0
- package/dist/core/stores.mjs +23 -0
- package/dist/core/stores.mjs.map +1 -0
- package/dist/core/types.d.mts +245 -0
- package/dist/core/visit.d.mts +26 -0
- package/dist/core/visit.mjs +40 -0
- package/dist/core/visit.mjs.map +1 -0
- package/dist/core/wire.d.mts +37 -0
- package/dist/next/cache/memory.d.mts +14 -0
- package/dist/next/cache/memory.mjs +51 -0
- package/dist/next/cache/memory.mjs.map +1 -0
- package/dist/next/cache/next-data.d.mts +6 -0
- package/dist/next/cache/next-data.mjs +35 -0
- package/dist/next/cache/next-data.mjs.map +1 -0
- package/dist/next/cache/noop.d.mts +6 -0
- package/dist/next/cache/noop.mjs +15 -0
- package/dist/next/cache/noop.mjs.map +1 -0
- package/dist/next/cache/workers.d.mts +29 -0
- package/dist/next/cache/workers.mjs +74 -0
- package/dist/next/cache/workers.mjs.map +1 -0
- package/dist/next/catalogue.d.mts +38 -0
- package/dist/next/catalogue.mjs +215 -0
- package/dist/next/catalogue.mjs.map +1 -0
- package/dist/next/handler.d.mts +11 -0
- package/dist/next/handler.mjs +150 -0
- package/dist/next/handler.mjs.map +1 -0
- package/dist/next/index.d.mts +17 -0
- package/dist/next/index.mjs +14 -0
- package/dist/next/loaders.d.mts +79 -0
- package/dist/next/loaders.mjs +195 -0
- package/dist/next/loaders.mjs.map +1 -0
- package/dist/next/manage-dto.d.mts +6 -0
- package/dist/next/manage-dto.mjs +28 -0
- package/dist/next/manage-dto.mjs.map +1 -0
- package/dist/next/medal.d.mts +105 -0
- package/dist/next/medal.mjs +155 -0
- package/dist/next/medal.mjs.map +1 -0
- package/dist/next/options.d.mts +158 -0
- package/dist/next/options.mjs +65 -0
- package/dist/next/options.mjs.map +1 -0
- package/dist/next/portal/guardian.d.mts +42 -0
- package/dist/next/portal/guardian.mjs +76 -0
- package/dist/next/portal/guardian.mjs.map +1 -0
- package/dist/next/portal/medal-portal.d.mts +161 -0
- package/dist/next/portal/medal-portal.mjs +825 -0
- package/dist/next/portal/medal-portal.mjs.map +1 -0
- package/dist/next/portal/session.d.mts +38 -0
- package/dist/next/portal/session.mjs +40 -0
- package/dist/next/portal/session.mjs.map +1 -0
- package/dist/next/portal/vipps-flash.d.mts +14 -0
- package/dist/next/portal/vipps-flash.mjs +41 -0
- package/dist/next/portal/vipps-flash.mjs.map +1 -0
- package/dist/next/portal.d.mts +205 -0
- package/dist/next/portal.mjs +469 -0
- package/dist/next/portal.mjs.map +1 -0
- package/dist/next/request.d.mts +51 -0
- package/dist/next/request.mjs +123 -0
- package/dist/next/request.mjs.map +1 -0
- package/dist/next/routes/catalogue-routes.mjs +344 -0
- package/dist/next/routes/catalogue-routes.mjs.map +1 -0
- package/dist/next/routes/create.mjs +460 -0
- package/dist/next/routes/create.mjs.map +1 -0
- package/dist/next/routes/manage.mjs +191 -0
- package/dist/next/routes/manage.mjs.map +1 -0
- package/dist/next/routes/portal-routes.mjs +312 -0
- package/dist/next/routes/portal-routes.mjs.map +1 -0
- package/dist/next/routes/shared.mjs +162 -0
- package/dist/next/routes/shared.mjs.map +1 -0
- package/dist/next/routes/vipps-return.mjs +166 -0
- package/dist/next/routes/vipps-return.mjs.map +1 -0
- package/dist/next/runtime.d.mts +49 -0
- package/dist/next/runtime.mjs +94 -0
- package/dist/next/runtime.mjs.map +1 -0
- package/dist/next/seed.d.mts +71 -0
- package/dist/next/seed.mjs +495 -0
- package/dist/next/seed.mjs.map +1 -0
- package/dist/next/server.d.mts +26 -0
- package/dist/next/server.mjs +63 -0
- package/dist/next/server.mjs.map +1 -0
- package/dist/react/BookingLink.d.mts +61 -0
- package/dist/react/BookingLink.mjs +102 -0
- package/dist/react/BookingLink.mjs.map +1 -0
- package/dist/react/BookingWizard.d.mts +49 -0
- package/dist/react/BookingWizard.mjs +598 -0
- package/dist/react/BookingWizard.mjs.map +1 -0
- package/dist/react/LoginPageQuery.d.mts +37 -0
- package/dist/react/LoginPageQuery.mjs +106 -0
- package/dist/react/LoginPageQuery.mjs.map +1 -0
- package/dist/react/LoginSheet.d.mts +49 -0
- package/dist/react/LoginSheet.mjs +98 -0
- package/dist/react/LoginSheet.mjs.map +1 -0
- package/dist/react/ManageBooking.d.mts +50 -0
- package/dist/react/ManageBooking.mjs +197 -0
- package/dist/react/ManageBooking.mjs.map +1 -0
- package/dist/react/PortalDashboard.d.mts +10 -0
- package/dist/react/Provider.d.mts +82 -0
- package/dist/react/Provider.mjs +132 -0
- package/dist/react/Provider.mjs.map +1 -0
- package/dist/react/actions.d.mts +133 -0
- package/dist/react/actions.mjs +90 -0
- package/dist/react/actions.mjs.map +1 -0
- package/dist/react/index.d.mts +28 -0
- package/dist/react/index.mjs +20 -0
- package/dist/react/kit.d.mts +54 -0
- package/dist/react/kit.mjs +127 -0
- package/dist/react/kit.mjs.map +1 -0
- package/dist/react/labels/index.d.mts +23 -0
- package/dist/react/labels/index.mjs +44 -0
- package/dist/react/labels/index.mjs.map +1 -0
- package/dist/react/labels/login.d.mts +10 -0
- package/dist/react/labels/login.mjs +13 -0
- package/dist/react/labels/login.mjs.map +1 -0
- package/dist/react/labels/manage.d.mts +15 -0
- package/dist/react/labels/manage.mjs +19 -0
- package/dist/react/labels/manage.mjs.map +1 -0
- package/dist/react/labels/portal.d.mts +25 -0
- package/dist/react/labels/portal.mjs +41 -0
- package/dist/react/labels/portal.mjs.map +1 -0
- package/dist/react/labels/screens.d.mts +11 -0
- package/dist/react/labels/screens.mjs +721 -0
- package/dist/react/labels/screens.mjs.map +1 -0
- package/dist/react/labels/wizard.d.mts +76 -0
- package/dist/react/labels/wizard.mjs +97 -0
- package/dist/react/labels/wizard.mjs.map +1 -0
- package/dist/react/link/index.d.mts +5 -0
- package/dist/react/link/index.mjs +5 -0
- package/dist/react/portal/SessionTouch.d.mts +19 -0
- package/dist/react/portal/SessionTouch.mjs +40 -0
- package/dist/react/portal/SessionTouch.mjs.map +1 -0
- package/dist/react/portal/age-prompt.d.mts +10 -0
- package/dist/react/portal/age-prompt.mjs +21 -0
- package/dist/react/portal/age-prompt.mjs.map +1 -0
- package/dist/react/portal/book-again.d.mts +18 -0
- package/dist/react/portal/book-again.mjs +21 -0
- package/dist/react/portal/book-again.mjs.map +1 -0
- package/dist/react/portal/child-summary.d.mts +17 -0
- package/dist/react/portal/child-summary.mjs +45 -0
- package/dist/react/portal/child-summary.mjs.map +1 -0
- package/dist/react/portal/dashboard.d.mts +74 -0
- package/dist/react/portal/dashboard.mjs +204 -0
- package/dist/react/portal/dashboard.mjs.map +1 -0
- package/dist/react/portal/leave.d.mts +17 -0
- package/dist/react/portal/leave.mjs +21 -0
- package/dist/react/portal/leave.mjs.map +1 -0
- package/dist/react/portal/sections.d.mts +12 -0
- package/dist/react/portal/sections.mjs +48 -0
- package/dist/react/portal/sections.mjs.map +1 -0
- package/dist/react/portal/tabs.d.mts +23 -0
- package/dist/react/portal/tabs.mjs +36 -0
- package/dist/react/portal/tabs.mjs.map +1 -0
- package/dist/react/portal/vipps-flash.d.mts +11 -0
- package/dist/react/portal/vipps-flash.mjs +18 -0
- package/dist/react/portal/vipps-flash.mjs.map +1 -0
- package/dist/react/portal/wired.mjs +202 -0
- package/dist/react/portal/wired.mjs.map +1 -0
- package/dist/react/screen-labels.mjs +14 -0
- package/dist/react/screen-labels.mjs.map +1 -0
- package/dist/react/shared.d.mts +13 -0
- package/dist/react/shared.mjs +7 -0
- package/dist/react/use-next-free.d.mts +19 -0
- package/dist/react/use-next-free.mjs +56 -0
- package/dist/react/use-next-free.mjs.map +1 -0
- package/dist/react/useBooking.d.mts +179 -0
- package/dist/react/useBooking.mjs +1331 -0
- package/dist/react/useBooking.mjs.map +1 -0
- package/dist/react/wizard/adapters.mjs +189 -0
- package/dist/react/wizard/adapters.mjs.map +1 -0
- package/dist/react/wizard/index.d.mts +11 -0
- package/dist/react/wizard/index.mjs +6 -0
- package/dist/react/wizard-error.mjs +29 -0
- package/dist/react/wizard-error.mjs.map +1 -0
- 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
|