@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,142 @@
|
|
|
1
|
+
import { stylistDisplayName } from "../display-name.mjs";
|
|
2
|
+
import { createPaths } from "../paths.mjs";
|
|
3
|
+
//#region src/core/portal/dto.ts
|
|
4
|
+
function stringOrNull(value) {
|
|
5
|
+
return typeof value === "string" && value !== "" ? value : null;
|
|
6
|
+
}
|
|
7
|
+
/** 1–12, or `null` for anything else — absent, `null`, or nonsense. */
|
|
8
|
+
function monthOrNull(value) {
|
|
9
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 12 ? value : null;
|
|
10
|
+
}
|
|
11
|
+
function yearOrNull(value) {
|
|
12
|
+
return typeof value === "number" && Number.isInteger(value) ? value : null;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* The person behind one `family` entry.
|
|
16
|
+
*
|
|
17
|
+
* By id where Medal sends one (SP10 puts `person_id` on every entry). Without
|
|
18
|
+
* it — today's Medal — by name AND year among the active guardian-type
|
|
19
|
+
* persons, and only when exactly ONE matches: that is the same pair Medal's
|
|
20
|
+
* own `PATCH /me {family}` matches on, so an id found this way is the row that
|
|
21
|
+
* patch would edit. Two same-named, same-year rows are nobody in particular.
|
|
22
|
+
*/
|
|
23
|
+
function personFor(member, persons) {
|
|
24
|
+
const id = stringOrNull(member.person_id);
|
|
25
|
+
if (id !== null) return persons.find((person) => person.person_id === id);
|
|
26
|
+
const twins = persons.filter((person) => person.active !== false && person.relation_type === "guardian" && person.name === member.name && person.birth_year === member.birth_year);
|
|
27
|
+
return twins.length === 1 ? twins[0] : void 0;
|
|
28
|
+
}
|
|
29
|
+
function toFamilyMemberDto(member, persons) {
|
|
30
|
+
const person = personFor(member, persons);
|
|
31
|
+
return {
|
|
32
|
+
personId: stringOrNull(member.person_id) ?? person?.person_id ?? null,
|
|
33
|
+
name: member.name,
|
|
34
|
+
birthYear: member.birth_year,
|
|
35
|
+
birthMonth: monthOrNull(member.birth_month) ?? monthOrNull(person?.birth_month),
|
|
36
|
+
notes: stringOrNull(person?.notes),
|
|
37
|
+
preferredResourceId: stringOrNull(person?.preferred_resource_id)
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/** Whether the wire carries SP10's person fields at all; see `personDetails`. */
|
|
41
|
+
function speaksPersonDetails(family, persons) {
|
|
42
|
+
return family.some((member) => "person_id" in member || "birth_month" in member) || persons.some((person) => "birth_month" in person || "preferred_resource_id" in person);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Nulls stay nulls. The page decides what a missing surname or phone number
|
|
46
|
+
* reads as, and a `''` here would make «not given» and «given as blank»
|
|
47
|
+
* indistinguishable to the form that edits them.
|
|
48
|
+
*/
|
|
49
|
+
function toPortalProfileDto(profile) {
|
|
50
|
+
const family = profile.family;
|
|
51
|
+
const persons = Array.isArray(profile.persons) ? profile.persons : [];
|
|
52
|
+
return {
|
|
53
|
+
contactId: profile.contact_id,
|
|
54
|
+
email: profile.email,
|
|
55
|
+
firstName: profile.first_name,
|
|
56
|
+
lastName: profile.last_name,
|
|
57
|
+
phone: profile.phone,
|
|
58
|
+
family: family.map((member) => toFamilyMemberDto(member, persons)),
|
|
59
|
+
personDetails: speaksPersonDetails(family, persons),
|
|
60
|
+
marketingConsent: profile.marketing_consent,
|
|
61
|
+
labels: profile.labels,
|
|
62
|
+
...vippsLinkedFrom(profile)
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
/** `{ vippsLinked }` when the wire says, `{}` otherwise. TODO(sdk): typed once the SDK has it. */
|
|
66
|
+
function vippsLinkedFrom(profile) {
|
|
67
|
+
const linked = profile.vipps_linked;
|
|
68
|
+
return typeof linked === "boolean" ? { vippsLinked: linked } : {};
|
|
69
|
+
}
|
|
70
|
+
function toPortalBookingDto(managePath, booking) {
|
|
71
|
+
return {
|
|
72
|
+
bookingId: booking.booking_id,
|
|
73
|
+
status: booking.status,
|
|
74
|
+
startTs: booking.start_ts,
|
|
75
|
+
endTs: booking.end_ts,
|
|
76
|
+
serviceId: booking.service_id,
|
|
77
|
+
serviceName: booking.service_name,
|
|
78
|
+
resourceId: booking.resource_id,
|
|
79
|
+
resourceName: booking.resource_name === null ? null : stylistDisplayName(booking.resource_name) || null,
|
|
80
|
+
bookedForName: booking.booked_for_name,
|
|
81
|
+
bookedForPersonId: stringOrNull(booking.booked_for_person_id),
|
|
82
|
+
bookedForBirthYear: yearOrNull(booking.booked_for_birth_year),
|
|
83
|
+
bookedForBirthMonth: monthOrNull(booking.booked_for_birth_month),
|
|
84
|
+
amountOre: booking.amount_ore,
|
|
85
|
+
notes: booking.notes,
|
|
86
|
+
managePath: booking.manage_token ? managePath(booking.manage_token) : null
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* The haircuts worth offering again, most recent first, at most `max` of them.
|
|
91
|
+
*
|
|
92
|
+
* Distinct by (service, stylist, child): a family that books the same cut for
|
|
93
|
+
* the same child with the same stylist every six weeks has ONE habit, not ten,
|
|
94
|
+
* and ten identical cards would push the second child's cut off the list.
|
|
95
|
+
* «Any stylist» (`resourceId: null`) is its own key — a parent who never
|
|
96
|
+
* chose is offered the same non-choice, not the stylist they happened to get.
|
|
97
|
+
*
|
|
98
|
+
* Only `completed` rows: a cancelled or no-show booking is not a haircut that
|
|
99
|
+
* happened, and a pending or confirmed one is upcoming, not history — offering
|
|
100
|
+
* to book it again would be offering a duplicate. A row whose `serviceId` is
|
|
101
|
+
* null has nothing to link to and is skipped rather than rendered as a card
|
|
102
|
+
* that opens the wizard on step 1.
|
|
103
|
+
*
|
|
104
|
+
* Sorts a copy: the caller's `past` array is rendered in its own order.
|
|
105
|
+
*/
|
|
106
|
+
function rebookSuggestions(past, max = 4) {
|
|
107
|
+
const seen = /* @__PURE__ */ new Set();
|
|
108
|
+
const suggestions = [];
|
|
109
|
+
const newestFirst = [...past].sort((a, b) => b.startTs - a.startTs);
|
|
110
|
+
for (const booking of newestFirst) {
|
|
111
|
+
if (suggestions.length >= max) break;
|
|
112
|
+
if (booking.status !== "completed" || booking.serviceId === null) continue;
|
|
113
|
+
const key = JSON.stringify([
|
|
114
|
+
booking.serviceId,
|
|
115
|
+
booking.resourceId,
|
|
116
|
+
booking.bookedForName
|
|
117
|
+
]);
|
|
118
|
+
if (seen.has(key)) continue;
|
|
119
|
+
seen.add(key);
|
|
120
|
+
suggestions.push({
|
|
121
|
+
serviceId: booking.serviceId,
|
|
122
|
+
serviceName: booking.serviceName,
|
|
123
|
+
resourceId: booking.resourceId,
|
|
124
|
+
resourceName: booking.resourceName,
|
|
125
|
+
bookedForName: booking.bookedForName
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
return suggestions;
|
|
129
|
+
}
|
|
130
|
+
function createPortalDto(config) {
|
|
131
|
+
const { managePath } = createPaths(config);
|
|
132
|
+
return {
|
|
133
|
+
toPortalProfileDto,
|
|
134
|
+
toPortalBookingDto: (booking) => toPortalBookingDto(managePath, booking),
|
|
135
|
+
rebookSuggestions,
|
|
136
|
+
monthOrNull
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
//#endregion
|
|
140
|
+
export { createPortalDto, monthOrNull, rebookSuggestions, toPortalProfileDto };
|
|
141
|
+
|
|
142
|
+
//# sourceMappingURL=dto.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"dto.mjs","names":[],"sources":["../../../src/core/portal/dto.ts"],"sourcesContent":["/**\n * Where the portal's wire shape becomes this site's — one definition per shape.\n *\n * The same split `lib/booking/dto.ts` makes for the wizard: `medal-portal.ts`\n * types the wire honestly and does nothing to it, and the pages under\n * `/min-side` read only the camelCase projections defined here. Two consumers\n * need the same answers — the overview page and the server actions that hand\n * a refreshed profile back to the browser — so the projection is a module and\n * not a private function in either.\n *\n * One projection here is a security decision, not a rename. A booking on the\n * wire carries its `manage_token`, which is a live bearer credential for that\n * one appointment: whoever holds it can move or cancel it, with no session and\n * no login. The DTO does not keep it. It keeps the PATH the token is spent at —\n * the same `/bestill/administrer/<token>` the confirmation e-mail links to —\n * built by the one function in the app that knows how to build it. A page that\n * renders a link has everything it needs; a serialiser that dumps the DTO into\n * a prop or a log has no field named «token» to leak, and the credential\n * travels exactly as far as an `href` and no further.\n *\n * Free of `server-only` on purpose: a client component may import a DTO type,\n * and `rebookSuggestions` is pure. The wire types come from `@medalsocial/sdk`\n * as `import type`, so nothing here pulls the SDK into a browser bundle.\n */\n\nimport type {\n PortalBooking,\n PortalBookingStatus,\n PortalFamilyEntry,\n PortalPerson,\n PortalProfile,\n} from '@medalsocial/sdk';\nimport type { BookingConfig } from '../config';\nimport { stylistDisplayName } from '../display-name';\nimport { createPaths } from '../paths';\n\n/**\n * One child on the profile — a PERSON now, not a name (SP10).\n *\n * `personId` is what makes a child something this site can point at: the id\n * of the `contactPersons` row Medal keeps, stable across a rename through\n * `/me/persons/{id}`, and the key a past booking carries as\n * `booked_for_person_id`. It is `null` only against a Medal that sent neither\n * a `person_id` on the family entry nor one unambiguous person of that name\n * and year — the site then falls back to what it did before persons existed.\n *\n * `birthMonth`, `notes` («Notat til frisøren») and `preferredResourceId`\n * («Fast frisør») are SP10's portal-editable details; each is `null` when\n * unset AND when Medal predates them — see `personDetails` on the profile for\n * telling the two apart.\n */\nexport interface PortalFamilyMemberDto {\n personId: string | null;\n name: string;\n birthYear: number;\n birthMonth: number | null;\n notes: string | null;\n preferredResourceId: string | null;\n}\n\n/**\n * The bransjemal preset's words for who a booking is for — «Barn» for a\n * children's salon, «Familie» wherever the fallback still applies. `person` is the\n * singular a future per-row label would use; only `persons` is read today.\n */\nexport interface PortalLabels {\n person: string;\n persons: string;\n}\n\nexport interface PortalProfileDto {\n contactId: string;\n email: string;\n firstName: string | null;\n lastName: string | null;\n phone: string | null;\n family: PortalFamilyMemberDto[];\n /**\n * Whether this Medal speaks SP10 persons — birth month, notes and preferred\n * stylist on each child, and the `/me/persons` endpoints that edit them.\n * Read off the wire (the fields are present, `null` or not), so the editor\n * offers the extra fields only where they can be saved. `false` also for a\n * profile with no persons at all, where there is nothing to read it off;\n * the person actions still try the endpoints and fall back on a 404.\n */\n personDetails: boolean;\n marketingConsent: boolean;\n labels?: PortalLabels;\n /**\n * Whether the parent's Vipps account is linked to this profile — present\n * only when Medal says (`vipps_linked`, with the «Koble til Vipps» routes).\n * ABSENT against a Medal that predates them, and then Min side → Profil\n * draws no Vipps row: a button whose route does not exist is a dead end.\n */\n vippsLinked?: boolean;\n}\n\nexport interface PortalBookingDto {\n bookingId: string;\n status: PortalBookingStatus;\n startTs: number;\n endTs: number;\n serviceId: string | null;\n serviceName: string | null;\n resourceId: string | null;\n resourceName: string | null;\n bookedForName: string | null;\n /**\n * Who the visit was for, by id — the `person_id` of one of the parent's\n * children, or `null` for a visit for the parent themselves, one made before\n * persons existed, or a Medal that does not send it yet (SP10). This is the\n * key «Samme som sist» joins on; the name is only the fallback.\n */\n bookedForPersonId: string | null;\n /** The birth year and month frozen onto the booking, when Medal sends them. */\n bookedForBirthYear: number | null;\n bookedForBirthMonth: number | null;\n amountOre: number | null;\n notes: string | null;\n /**\n * Where this booking is managed, or `null` when Medal issued no token for it\n * — a cancelled or completed row, or one made over the counter. The token\n * itself is deliberately not on this object; see the module comment.\n */\n managePath: string | null;\n}\n\n/*\n * The SP10 wire fields (`person_id` / `birth_month` on a family entry,\n * `birth_month` / `preferred_resource_id` on a person, `booked_for_*` on a\n * booking) are typed by the SDK since 1.12, but still READ defensively: a\n * Medal that predates SP10 omits every one of them, and `speaksPersonDetails`\n * keys on exactly that absence. The helpers below take `unknown` for that\n * reason, not because the types are missing.\n */\n\nfunction stringOrNull(value: unknown): string | null {\n return typeof value === 'string' && value !== '' ? value : null;\n}\n\n/** 1–12, or `null` for anything else — absent, `null`, or nonsense. */\nexport function monthOrNull(value: unknown): number | null {\n return typeof value === 'number' && Number.isInteger(value) && value >= 1 && value <= 12\n ? value\n : null;\n}\n\nfunction yearOrNull(value: unknown): number | null {\n return typeof value === 'number' && Number.isInteger(value) ? value : null;\n}\n\n/**\n * The person behind one `family` entry.\n *\n * By id where Medal sends one (SP10 puts `person_id` on every entry). Without\n * it — today's Medal — by name AND year among the active guardian-type\n * persons, and only when exactly ONE matches: that is the same pair Medal's\n * own `PATCH /me {family}` matches on, so an id found this way is the row that\n * patch would edit. Two same-named, same-year rows are nobody in particular.\n */\nfunction personFor(member: PortalFamilyEntry, persons: PortalPerson[]): PortalPerson | undefined {\n const id = stringOrNull(member.person_id);\n if (id !== null) return persons.find((person) => person.person_id === id);\n const twins = persons.filter(\n (person) =>\n person.active !== false &&\n person.relation_type === 'guardian' &&\n person.name === member.name &&\n person.birth_year === member.birth_year\n );\n return twins.length === 1 ? twins[0] : undefined;\n}\n\nfunction toFamilyMemberDto(\n member: PortalFamilyEntry,\n persons: PortalPerson[]\n): PortalFamilyMemberDto {\n const person = personFor(member, persons);\n return {\n personId: stringOrNull(member.person_id) ?? person?.person_id ?? null,\n name: member.name,\n birthYear: member.birth_year,\n birthMonth: monthOrNull(member.birth_month) ?? monthOrNull(person?.birth_month),\n notes: stringOrNull(person?.notes),\n preferredResourceId: stringOrNull(person?.preferred_resource_id),\n };\n}\n\n/** Whether the wire carries SP10's person fields at all; see `personDetails`. */\nfunction speaksPersonDetails(family: PortalFamilyEntry[], persons: PortalPerson[]): boolean {\n return (\n family.some((member) => 'person_id' in member || 'birth_month' in member) ||\n persons.some((person) => 'birth_month' in person || 'preferred_resource_id' in person)\n );\n}\n\n/**\n * Nulls stay nulls. The page decides what a missing surname or phone number\n * reads as, and a `''` here would make «not given» and «given as blank»\n * indistinguishable to the form that edits them.\n */\nexport function toPortalProfileDto(profile: PortalProfile): PortalProfileDto {\n const family = profile.family;\n // `persons` is SP11's; a Medal (or a fixture) without it is a family of names.\n const persons = Array.isArray(profile.persons) ? profile.persons : [];\n return {\n contactId: profile.contact_id,\n email: profile.email,\n firstName: profile.first_name,\n lastName: profile.last_name,\n phone: profile.phone,\n family: family.map((member) => toFamilyMemberDto(member, persons)),\n personDetails: speaksPersonDetails(family, persons),\n marketingConsent: profile.marketing_consent,\n labels: profile.labels,\n ...vippsLinkedFrom(profile),\n };\n}\n\n/** `{ vippsLinked }` when the wire says, `{}` otherwise. TODO(sdk): typed once the SDK has it. */\nfunction vippsLinkedFrom(profile: PortalProfile): { vippsLinked?: boolean } {\n const linked = (profile as PortalProfile & { vipps_linked?: unknown }).vipps_linked;\n return typeof linked === 'boolean' ? { vippsLinked: linked } : {};\n}\n\nfunction toPortalBookingDto(\n managePath: (token: string) => string,\n booking: PortalBooking\n): PortalBookingDto {\n return {\n bookingId: booking.booking_id,\n status: booking.status,\n startTs: booking.start_ts,\n endTs: booking.end_ts,\n serviceId: booking.service_id,\n serviceName: booking.service_name,\n resourceId: booking.resource_id,\n // Cleaned once here for every portal card (upcoming, history, rebook).\n resourceName:\n booking.resource_name === null ? null : stylistDisplayName(booking.resource_name) || null,\n bookedForName: booking.booked_for_name,\n bookedForPersonId: stringOrNull(booking.booked_for_person_id),\n bookedForBirthYear: yearOrNull(booking.booked_for_birth_year),\n bookedForBirthMonth: monthOrNull(booking.booked_for_birth_month),\n amountOre: booking.amount_ore,\n notes: booking.notes,\n managePath: booking.manage_token ? managePath(booking.manage_token) : null,\n };\n}\n\n/**\n * What a «Bestill igjen» card carries: enough to prefill the wizard's three\n * questions (`?service&stylist&who`) and the two names it shows on the card.\n * No ids the wizard cannot vouch for are trusted downstream — `applyPrefill`\n * ignores a service or stylist the catalogue no longer lists — so this does\n * not need to check them either.\n */\nexport interface RebookSuggestion {\n serviceId: string;\n serviceName: string | null;\n resourceId: string | null;\n resourceName: string | null;\n bookedForName: string | null;\n}\n\n/**\n * The haircuts worth offering again, most recent first, at most `max` of them.\n *\n * Distinct by (service, stylist, child): a family that books the same cut for\n * the same child with the same stylist every six weeks has ONE habit, not ten,\n * and ten identical cards would push the second child's cut off the list.\n * «Any stylist» (`resourceId: null`) is its own key — a parent who never\n * chose is offered the same non-choice, not the stylist they happened to get.\n *\n * Only `completed` rows: a cancelled or no-show booking is not a haircut that\n * happened, and a pending or confirmed one is upcoming, not history — offering\n * to book it again would be offering a duplicate. A row whose `serviceId` is\n * null has nothing to link to and is skipped rather than rendered as a card\n * that opens the wizard on step 1.\n *\n * Sorts a copy: the caller's `past` array is rendered in its own order.\n */\nexport function rebookSuggestions(past: PortalBookingDto[], max = 4): RebookSuggestion[] {\n const seen = new Set<string>();\n const suggestions: RebookSuggestion[] = [];\n const newestFirst = [...past].sort((a, b) => b.startTs - a.startTs);\n\n for (const booking of newestFirst) {\n if (suggestions.length >= max) break;\n if (booking.status !== 'completed' || booking.serviceId === null) continue;\n\n // JSON rather than a joined string: a child's name can contain whatever\n // separator a `join` would pick, and `null` and `''` must stay distinct.\n const key = JSON.stringify([booking.serviceId, booking.resourceId, booking.bookedForName]);\n if (seen.has(key)) continue;\n seen.add(key);\n\n suggestions.push({\n serviceId: booking.serviceId,\n serviceName: booking.serviceName,\n resourceId: booking.resourceId,\n resourceName: booking.resourceName,\n bookedForName: booking.bookedForName,\n });\n }\n\n return suggestions;\n}\n\nexport interface PortalDto {\n toPortalProfileDto: typeof toPortalProfileDto;\n /** The booking, with its manage token turned into this site's manage PATH (`paths.manage`). */\n toPortalBookingDto(booking: PortalBooking): PortalBookingDto;\n rebookSuggestions: typeof rebookSuggestions;\n monthOrNull: typeof monthOrNull;\n}\n\nexport function createPortalDto(config: Pick<BookingConfig, 'paths'>): PortalDto {\n const { managePath } = createPaths(config);\n return {\n toPortalProfileDto,\n toPortalBookingDto: (booking) => toPortalBookingDto(managePath, booking),\n rebookSuggestions,\n monthOrNull,\n };\n}\n"],"mappings":";;;AAwIA,SAAS,aAAa,OAA+B;CACnD,OAAO,OAAO,UAAU,YAAY,UAAU,KAAK,QAAQ;AAC7D;;AAGA,SAAgB,YAAY,OAA+B;CACzD,OAAO,OAAO,UAAU,YAAY,OAAO,UAAU,KAAK,KAAK,SAAS,KAAK,SAAS,KAClF,QACA;AACN;AAEA,SAAS,WAAW,OAA+B;CACjD,OAAO,OAAO,UAAU,YAAY,OAAO,UAAU,KAAK,IAAI,QAAQ;AACxE;;;;;;;;;;AAWA,SAAS,UAAU,QAA2B,SAAmD;CAC/F,MAAM,KAAK,aAAa,OAAO,SAAS;CACxC,IAAI,OAAO,MAAM,OAAO,QAAQ,MAAM,WAAW,OAAO,cAAc,EAAE;CACxE,MAAM,QAAQ,QAAQ,QACnB,WACC,OAAO,WAAW,SAClB,OAAO,kBAAkB,cACzB,OAAO,SAAS,OAAO,QACvB,OAAO,eAAe,OAAO,UACjC;CACA,OAAO,MAAM,WAAW,IAAI,MAAM,KAAK,KAAA;AACzC;AAEA,SAAS,kBACP,QACA,SACuB;CACvB,MAAM,SAAS,UAAU,QAAQ,OAAO;CACxC,OAAO;EACL,UAAU,aAAa,OAAO,SAAS,KAAK,QAAQ,aAAa;EACjE,MAAM,OAAO;EACb,WAAW,OAAO;EAClB,YAAY,YAAY,OAAO,WAAW,KAAK,YAAY,QAAQ,WAAW;EAC9E,OAAO,aAAa,QAAQ,KAAK;EACjC,qBAAqB,aAAa,QAAQ,qBAAqB;CACjE;AACF;;AAGA,SAAS,oBAAoB,QAA6B,SAAkC;CAC1F,OACE,OAAO,MAAM,WAAW,eAAe,UAAU,iBAAiB,MAAM,KACxE,QAAQ,MAAM,WAAW,iBAAiB,UAAU,2BAA2B,MAAM;AAEzF;;;;;;AAOA,SAAgB,mBAAmB,SAA0C;CAC3E,MAAM,SAAS,QAAQ;CAEvB,MAAM,UAAU,MAAM,QAAQ,QAAQ,OAAO,IAAI,QAAQ,UAAU,CAAC;CACpE,OAAO;EACL,WAAW,QAAQ;EACnB,OAAO,QAAQ;EACf,WAAW,QAAQ;EACnB,UAAU,QAAQ;EAClB,OAAO,QAAQ;EACf,QAAQ,OAAO,KAAK,WAAW,kBAAkB,QAAQ,OAAO,CAAC;EACjE,eAAe,oBAAoB,QAAQ,OAAO;EAClD,kBAAkB,QAAQ;EAC1B,QAAQ,QAAQ;EAChB,GAAG,gBAAgB,OAAO;CAC5B;AACF;;AAGA,SAAS,gBAAgB,SAAmD;CAC1E,MAAM,SAAU,QAAuD;CACvE,OAAO,OAAO,WAAW,YAAY,EAAE,aAAa,OAAO,IAAI,CAAC;AAClE;AAEA,SAAS,mBACP,YACA,SACkB;CAClB,OAAO;EACL,WAAW,QAAQ;EACnB,QAAQ,QAAQ;EAChB,SAAS,QAAQ;EACjB,OAAO,QAAQ;EACf,WAAW,QAAQ;EACnB,aAAa,QAAQ;EACrB,YAAY,QAAQ;EAEpB,cACE,QAAQ,kBAAkB,OAAO,OAAO,mBAAmB,QAAQ,aAAa,KAAK;EACvF,eAAe,QAAQ;EACvB,mBAAmB,aAAa,QAAQ,oBAAoB;EAC5D,oBAAoB,WAAW,QAAQ,qBAAqB;EAC5D,qBAAqB,YAAY,QAAQ,sBAAsB;EAC/D,WAAW,QAAQ;EACnB,OAAO,QAAQ;EACf,YAAY,QAAQ,eAAe,WAAW,QAAQ,YAAY,IAAI;CACxE;AACF;;;;;;;;;;;;;;;;;;AAkCA,SAAgB,kBAAkB,MAA0B,MAAM,GAAuB;CACvF,MAAM,uBAAO,IAAI,IAAY;CAC7B,MAAM,cAAkC,CAAC;CACzC,MAAM,cAAc,CAAC,GAAG,IAAI,CAAC,CAAC,MAAM,GAAG,MAAM,EAAE,UAAU,EAAE,OAAO;CAElE,KAAK,MAAM,WAAW,aAAa;EACjC,IAAI,YAAY,UAAU,KAAK;EAC/B,IAAI,QAAQ,WAAW,eAAe,QAAQ,cAAc,MAAM;EAIlE,MAAM,MAAM,KAAK,UAAU;GAAC,QAAQ;GAAW,QAAQ;GAAY,QAAQ;EAAa,CAAC;EACzF,IAAI,KAAK,IAAI,GAAG,GAAG;EACnB,KAAK,IAAI,GAAG;EAEZ,YAAY,KAAK;GACf,WAAW,QAAQ;GACnB,aAAa,QAAQ;GACrB,YAAY,QAAQ;GACpB,cAAc,QAAQ;GACtB,eAAe,QAAQ;EACzB,CAAC;CACH;CAEA,OAAO;AACT;AAUA,SAAgB,gBAAgB,QAAiD;CAC/E,MAAM,EAAE,eAAe,YAAY,MAAM;CACzC,OAAO;EACL;EACA,qBAAqB,YAAY,mBAAmB,YAAY,OAAO;EACvE;EACA;CACF;AACF"}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { PortalBookingDto } from "./dto.mjs";
|
|
2
|
+
//#region src/core/portal/family.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* Which past (or upcoming) booking belongs to which child — BY ID (SP10).
|
|
5
|
+
*
|
|
6
|
+
* A booking carries `bookedForPersonId`, the id of the child it was made for,
|
|
7
|
+
* and a child on the profile carries the same id; that pair is the join, and
|
|
8
|
+
* it survives a rename, a nickname, and two siblings sharing a first name. It
|
|
9
|
+
* replaces what `child-summary.ts` did before persons existed — comparing the
|
|
10
|
+
* free text a parent typed on «Bekreft» with the free text on Min side.
|
|
11
|
+
*
|
|
12
|
+
* NAMES ARE THE FALLBACK, and only for a booking that carries no id: one made
|
|
13
|
+
* before persons existed, one for somebody typed by hand, or any booking from
|
|
14
|
+
* a Medal that does not send the id yet. For those the old rule stands —
|
|
15
|
+
* trimmed, case-folded, and a name two children share names nobody, because a
|
|
16
|
+
* missing «sist klipt» under-claims where a loose match would put a sister's
|
|
17
|
+
* haircut on the wrong child. A booking that DOES carry an id is never matched
|
|
18
|
+
* by name: its id is the answer, and a different child with the same name is a
|
|
19
|
+
* different child.
|
|
20
|
+
*
|
|
21
|
+
* Pure and free of `server-only`: the dashboard renders it on the server and
|
|
22
|
+
* the booking page does the same on the way to the wizard.
|
|
23
|
+
*/
|
|
24
|
+
export interface FamilyKey {
|
|
25
|
+
personId: string | null;
|
|
26
|
+
name: string;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* For each child, the first booking in `bookings` that is theirs — so pass
|
|
30
|
+
* them in the order «first» should mean (newest first for a last visit,
|
|
31
|
+
* soonest first for the next one). `null` for a child with none.
|
|
32
|
+
*/
|
|
33
|
+
export declare function firstBookingFor(family: readonly FamilyKey[], bookings: readonly PortalBookingDto[]): Array<PortalBookingDto | null>;
|
|
34
|
+
/** Completed bookings, newest first — the order a «last visit» is read in. */
|
|
35
|
+
export declare function completedNewestFirst(past: readonly PortalBookingDto[]): PortalBookingDto[];
|
|
36
|
+
/** Bookings still ahead, soonest first — the order a «next time» is read in. */
|
|
37
|
+
export declare function upcomingSoonestFirst(upcoming: readonly PortalBookingDto[]): PortalBookingDto[];
|
|
38
|
+
//#endregion
|
|
39
|
+
//# sourceMappingURL=family.d.mts.map
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
//#region src/core/portal/family.ts
|
|
2
|
+
/** Trimmed and case-folded — the two sides were typed on different screens. */
|
|
3
|
+
function nameKey(name) {
|
|
4
|
+
const trimmed = name?.trim().toLocaleLowerCase("nb-NO");
|
|
5
|
+
return trimmed ? trimmed : null;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* For each child, the first booking in `bookings` that is theirs — so pass
|
|
9
|
+
* them in the order «first» should mean (newest first for a last visit,
|
|
10
|
+
* soonest first for the next one). `null` for a child with none.
|
|
11
|
+
*/
|
|
12
|
+
function firstBookingFor(family, bookings) {
|
|
13
|
+
const seen = /* @__PURE__ */ new Map();
|
|
14
|
+
for (const member of family) {
|
|
15
|
+
const key = nameKey(member.name);
|
|
16
|
+
if (key !== null) seen.set(key, (seen.get(key) ?? 0) + 1);
|
|
17
|
+
}
|
|
18
|
+
return family.map((member) => {
|
|
19
|
+
const wanted = nameKey(member.name);
|
|
20
|
+
/* v8 ignore next -- every usable name was counted into `seen` above */
|
|
21
|
+
const nameUsable = wanted !== null && (seen.get(wanted) ?? 0) === 1;
|
|
22
|
+
return bookings.find((booking) => {
|
|
23
|
+
if (booking.bookedForPersonId !== null) return member.personId !== null && booking.bookedForPersonId === member.personId;
|
|
24
|
+
return nameUsable && nameKey(booking.bookedForName) === wanted;
|
|
25
|
+
}) ?? null;
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
/** Completed bookings, newest first — the order a «last visit» is read in. */
|
|
29
|
+
function completedNewestFirst(past) {
|
|
30
|
+
return past.filter((booking) => booking.status === "completed").sort((a, b) => b.startTs - a.startTs);
|
|
31
|
+
}
|
|
32
|
+
/** Bookings still ahead, soonest first — the order a «next time» is read in. */
|
|
33
|
+
function upcomingSoonestFirst(upcoming) {
|
|
34
|
+
return upcoming.filter((booking) => booking.status === "pending" || booking.status === "confirmed").sort((a, b) => a.startTs - b.startTs);
|
|
35
|
+
}
|
|
36
|
+
//#endregion
|
|
37
|
+
export { completedNewestFirst, firstBookingFor, upcomingSoonestFirst };
|
|
38
|
+
|
|
39
|
+
//# sourceMappingURL=family.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"family.mjs","names":[],"sources":["../../../src/core/portal/family.ts"],"sourcesContent":["import type { PortalBookingDto } from './dto';\n\n/**\n * Which past (or upcoming) booking belongs to which child — BY ID (SP10).\n *\n * A booking carries `bookedForPersonId`, the id of the child it was made for,\n * and a child on the profile carries the same id; that pair is the join, and\n * it survives a rename, a nickname, and two siblings sharing a first name. It\n * replaces what `child-summary.ts` did before persons existed — comparing the\n * free text a parent typed on «Bekreft» with the free text on Min side.\n *\n * NAMES ARE THE FALLBACK, and only for a booking that carries no id: one made\n * before persons existed, one for somebody typed by hand, or any booking from\n * a Medal that does not send the id yet. For those the old rule stands —\n * trimmed, case-folded, and a name two children share names nobody, because a\n * missing «sist klipt» under-claims where a loose match would put a sister's\n * haircut on the wrong child. A booking that DOES carry an id is never matched\n * by name: its id is the answer, and a different child with the same name is a\n * different child.\n *\n * Pure and free of `server-only`: the dashboard renders it on the server and\n * the booking page does the same on the way to the wizard.\n */\n\nexport interface FamilyKey {\n personId: string | null;\n name: string;\n}\n\n/** Trimmed and case-folded — the two sides were typed on different screens. */\nfunction nameKey(name: string | null): string | null {\n const trimmed = name?.trim().toLocaleLowerCase('nb-NO');\n return trimmed ? trimmed : null;\n}\n\n/**\n * For each child, the first booking in `bookings` that is theirs — so pass\n * them in the order «first» should mean (newest first for a last visit,\n * soonest first for the next one). `null` for a child with none.\n */\nexport function firstBookingFor(\n family: readonly FamilyKey[],\n bookings: readonly PortalBookingDto[]\n): Array<PortalBookingDto | null> {\n // Names that appear twice in the family list name nobody in particular.\n const seen = new Map<string, number>();\n for (const member of family) {\n const key = nameKey(member.name);\n if (key !== null) seen.set(key, (seen.get(key) ?? 0) + 1);\n }\n\n return family.map((member) => {\n const wanted = nameKey(member.name);\n /* v8 ignore next -- every usable name was counted into `seen` above */\n const nameUsable = wanted !== null && (seen.get(wanted) ?? 0) === 1;\n const match = bookings.find((booking) => {\n if (booking.bookedForPersonId !== null) {\n return member.personId !== null && booking.bookedForPersonId === member.personId;\n }\n return nameUsable && nameKey(booking.bookedForName) === wanted;\n });\n return match ?? null;\n });\n}\n\n/** Completed bookings, newest first — the order a «last visit» is read in. */\nexport function completedNewestFirst(past: readonly PortalBookingDto[]): PortalBookingDto[] {\n return past\n .filter((booking) => booking.status === 'completed')\n .sort((a, b) => b.startTs - a.startTs);\n}\n\n/** Bookings still ahead, soonest first — the order a «next time» is read in. */\nexport function upcomingSoonestFirst(upcoming: readonly PortalBookingDto[]): PortalBookingDto[] {\n return upcoming\n .filter((booking) => booking.status === 'pending' || booking.status === 'confirmed')\n .sort((a, b) => a.startTs - b.startTs);\n}\n"],"mappings":";;AA8BA,SAAS,QAAQ,MAAoC;CACnD,MAAM,UAAU,MAAM,KAAK,CAAC,CAAC,kBAAkB,OAAO;CACtD,OAAO,UAAU,UAAU;AAC7B;;;;;;AAOA,SAAgB,gBACd,QACA,UACgC;CAEhC,MAAM,uBAAO,IAAI,IAAoB;CACrC,KAAK,MAAM,UAAU,QAAQ;EAC3B,MAAM,MAAM,QAAQ,OAAO,IAAI;EAC/B,IAAI,QAAQ,MAAM,KAAK,IAAI,MAAM,KAAK,IAAI,GAAG,KAAK,KAAK,CAAC;CAC1D;CAEA,OAAO,OAAO,KAAK,WAAW;EAC5B,MAAM,SAAS,QAAQ,OAAO,IAAI;;EAElC,MAAM,aAAa,WAAW,SAAS,KAAK,IAAI,MAAM,KAAK,OAAO;EAOlE,OANc,SAAS,MAAM,YAAY;GACvC,IAAI,QAAQ,sBAAsB,MAChC,OAAO,OAAO,aAAa,QAAQ,QAAQ,sBAAsB,OAAO;GAE1E,OAAO,cAAc,QAAQ,QAAQ,aAAa,MAAM;EAC1D,CACW,KAAK;CAClB,CAAC;AACH;;AAGA,SAAgB,qBAAqB,MAAuD;CAC1F,OAAO,KACJ,QAAQ,YAAY,QAAQ,WAAW,WAAW,CAAC,CACnD,MAAM,GAAG,MAAM,EAAE,UAAU,EAAE,OAAO;AACzC;;AAGA,SAAgB,qBAAqB,UAA2D;CAC9F,OAAO,SACJ,QAAQ,YAAY,QAAQ,WAAW,aAAa,QAAQ,WAAW,WAAW,CAAC,CACnF,MAAM,GAAG,MAAM,EAAE,UAAU,EAAE,OAAO;AACzC"}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
//#region src/core/portal/login-input.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* The login's two inputs, validated the same way wherever they arrive — the
|
|
5
|
+
* `startLoginAction` server action and `POST /api/portal/login/verify`.
|
|
6
|
+
*
|
|
7
|
+
* A module of its own because a `'use server'` file may export only async
|
|
8
|
+
* functions, and the route handler needs the same schema the action uses: two
|
|
9
|
+
* spellings of «what is an e-mail address» would let an address a code was
|
|
10
|
+
* sent to be refused when the code comes back.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* Trimmed and lowercased BEFORE the format check, and piped rather than
|
|
14
|
+
* chained: `z.email()` validates the raw value, so a pasted address with a
|
|
15
|
+
* trailing space would be refused for a reason the parent cannot see.
|
|
16
|
+
* Lowercased because the code is looked up by address and «Kari@» and «kari@»
|
|
17
|
+
* are one inbox.
|
|
18
|
+
*/
|
|
19
|
+
export declare const emailField: z.ZodPipe<z.ZodString, z.ZodEmail>;
|
|
20
|
+
/** Six digits, nothing else — Medal's own code shape. */
|
|
21
|
+
export declare const codeField: z.ZodString;
|
|
22
|
+
export declare const verifyLoginInput: z.ZodObject<{
|
|
23
|
+
email: z.ZodPipe<z.ZodString, z.ZodEmail>;
|
|
24
|
+
code: z.ZodString;
|
|
25
|
+
}, z.core.$strip>;
|
|
26
|
+
/** Why a code did not end in a session, in the words the login form keys its copy on. */
|
|
27
|
+
export type VerifyLoginFailure = 'invalid' | 'throttled' | 'unreachable';
|
|
28
|
+
//#endregion
|
|
29
|
+
//# sourceMappingURL=login-input.d.mts.map
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
//#region src/core/portal/login-input.ts
|
|
3
|
+
/**
|
|
4
|
+
* The login's two inputs, validated the same way wherever they arrive — the
|
|
5
|
+
* `startLoginAction` server action and `POST /api/portal/login/verify`.
|
|
6
|
+
*
|
|
7
|
+
* A module of its own because a `'use server'` file may export only async
|
|
8
|
+
* functions, and the route handler needs the same schema the action uses: two
|
|
9
|
+
* spellings of «what is an e-mail address» would let an address a code was
|
|
10
|
+
* sent to be refused when the code comes back.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* Trimmed and lowercased BEFORE the format check, and piped rather than
|
|
14
|
+
* chained: `z.email()` validates the raw value, so a pasted address with a
|
|
15
|
+
* trailing space would be refused for a reason the parent cannot see.
|
|
16
|
+
* Lowercased because the code is looked up by address and «Kari@» and «kari@»
|
|
17
|
+
* are one inbox.
|
|
18
|
+
*/
|
|
19
|
+
const emailField = z.string().trim().toLowerCase().pipe(z.email().max(254));
|
|
20
|
+
/** Six digits, nothing else — Medal's own code shape. */
|
|
21
|
+
const codeField = z.string().regex(/^\d{6}$/, "Koden er seks siffer.");
|
|
22
|
+
const verifyLoginInput = z.object({
|
|
23
|
+
email: emailField,
|
|
24
|
+
code: codeField
|
|
25
|
+
});
|
|
26
|
+
//#endregion
|
|
27
|
+
export { codeField, emailField, verifyLoginInput };
|
|
28
|
+
|
|
29
|
+
//# sourceMappingURL=login-input.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"login-input.mjs","names":[],"sources":["../../../src/core/portal/login-input.ts"],"sourcesContent":["import { z } from 'zod';\n\n/**\n * The login's two inputs, validated the same way wherever they arrive — the\n * `startLoginAction` server action and `POST /api/portal/login/verify`.\n *\n * A module of its own because a `'use server'` file may export only async\n * functions, and the route handler needs the same schema the action uses: two\n * spellings of «what is an e-mail address» would let an address a code was\n * sent to be refused when the code comes back.\n */\n\n/**\n * Trimmed and lowercased BEFORE the format check, and piped rather than\n * chained: `z.email()` validates the raw value, so a pasted address with a\n * trailing space would be refused for a reason the parent cannot see.\n * Lowercased because the code is looked up by address and «Kari@» and «kari@»\n * are one inbox.\n */\nexport const emailField = z.string().trim().toLowerCase().pipe(z.email().max(254));\n\n/** Six digits, nothing else — Medal's own code shape. */\nexport const codeField = z.string().regex(/^\\d{6}$/, 'Koden er seks siffer.');\n\nexport const verifyLoginInput = z.object({ email: emailField, code: codeField });\n\n/** Why a code did not end in a session, in the words the login form keys its copy on. */\nexport type VerifyLoginFailure = 'invalid' | 'throttled' | 'unreachable';\n"],"mappings":";;;;;;;;;;;;;;;;;;AAmBA,MAAa,aAAa,EAAE,OAAO,CAAC,CAAC,KAAK,CAAC,CAAC,YAAY,CAAC,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC,IAAI,GAAG,CAAC;;AAGjF,MAAa,YAAY,EAAE,OAAO,CAAC,CAAC,MAAM,WAAW,uBAAuB;AAE5E,MAAa,mBAAmB,EAAE,OAAO;CAAE,OAAO;CAAY,MAAM;AAAU,CAAC"}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { BookingConfig } from "../config.mjs";
|
|
2
|
+
import { CookieSource } from "./cookies.mjs";
|
|
3
|
+
//#region src/core/portal/next-path.d.ts
|
|
4
|
+
export interface PortalNextPath {
|
|
5
|
+
readonly PORTAL_NEXT_COOKIE: string;
|
|
6
|
+
writePortalNextPath(path: string | null): Promise<void>;
|
|
7
|
+
takePortalNextPath(): Promise<string | null>;
|
|
8
|
+
}
|
|
9
|
+
/** The read-once «where this login ends» cookie (`portal.nextCookieName`). */
|
|
10
|
+
export declare function createPortalNextPath(config: Pick<BookingConfig, 'portal'>, cookies: CookieSource): PortalNextPath;
|
|
11
|
+
//#endregion
|
|
12
|
+
//# sourceMappingURL=next-path.d.mts.map
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { createReturnPath } from "./return-path.mjs";
|
|
2
|
+
//#region src/core/portal/next-path.ts
|
|
3
|
+
/**
|
|
4
|
+
* Where to send the parent after a Vipps login that did not start on Min side.
|
|
5
|
+
*
|
|
6
|
+
* The e-mail login can carry its destination in the URL — the parent stays on
|
|
7
|
+
* this site for the whole handshake, and `/min-side/logg-inn?return=…` is read
|
|
8
|
+
* by the page that renders the form. The Vipps login cannot: the browser goes
|
|
9
|
+
* to Medal, then to Vipps, then back to `/min-side/vipps`, and the only thing
|
|
10
|
+
* this site controls along that path is the `return_url` it handed Medal at
|
|
11
|
+
* the start. Two reasons not to put the destination in THAT:
|
|
12
|
+
*
|
|
13
|
+
* 1. Medal validates the `return_url` against the workspace's site and answers
|
|
14
|
+
* `400 INVALID_RETURN_URL` for one it does not recognise. A query string
|
|
15
|
+
* this site invents is a second thing that has to survive a check written
|
|
16
|
+
* on the other side of the integration — and the barnehage payment leg has
|
|
17
|
+
* already been bitten once by an engine that rewrote a `return_url`'s query
|
|
18
|
+
* (D31).
|
|
19
|
+
* 2. It would put a redirect target in a URL a third party hands back to the
|
|
20
|
+
* browser, which is the shape an open redirect is usually built out of.
|
|
21
|
+
*
|
|
22
|
+
* So the destination never leaves this site: it goes into a short-lived
|
|
23
|
+
* httpOnly cookie before the redirect to Vipps, and `/min-side/vipps` takes it
|
|
24
|
+
* out again when the grant comes back. `sameSite: 'lax'` is what makes that
|
|
25
|
+
* work — the return from Vipps is a top-level GET navigation, which is exactly
|
|
26
|
+
* the case `lax` still sends a cookie on.
|
|
27
|
+
*
|
|
28
|
+
* VALIDATED ON BOTH SIDES. `safeReturnPath` runs before the value is written
|
|
29
|
+
* AND after it is read, so a cookie edited in the jar between the two — this
|
|
30
|
+
* one is httpOnly, but a cookie is not a promise — is a `null` rather than a
|
|
31
|
+
* redirect of the holder's choosing.
|
|
32
|
+
*/
|
|
33
|
+
/**
|
|
34
|
+
* Ten minutes: a Vipps login is a phone unlock and a confirmation, and a value
|
|
35
|
+
* that outlived the attempt would send a parent who came back to the login on
|
|
36
|
+
* their own into the middle of a flow they had left.
|
|
37
|
+
*/
|
|
38
|
+
const MAX_AGE_SECONDS = 600;
|
|
39
|
+
/** The read-once «where this login ends» cookie (`portal.nextCookieName`). */
|
|
40
|
+
function createPortalNextPath(config, cookies) {
|
|
41
|
+
const PORTAL_NEXT_COOKIE = config.portal.nextCookieName;
|
|
42
|
+
const safeReturnPath = createReturnPath(config);
|
|
43
|
+
/**
|
|
44
|
+
* Remember where this login should end, or forget any earlier answer.
|
|
45
|
+
*
|
|
46
|
+
* A `null` path CLEARS rather than leaves the previous value in place: a
|
|
47
|
+
* parent who starts a barnehage login, abandons it and then logs in normally
|
|
48
|
+
* from Min side must not be thrown into the kindergarten flow by a cookie the
|
|
49
|
+
* first attempt left behind.
|
|
50
|
+
*
|
|
51
|
+
* `secure` unconditionally, matching `PORTAL_SESSION_COOKIE` — see
|
|
52
|
+
* `lib/portal/session.ts` for why localhost still works in Chrome and Firefox.
|
|
53
|
+
*/
|
|
54
|
+
async function writePortalNextPath(path) {
|
|
55
|
+
const safe = path === null ? null : safeReturnPath(path);
|
|
56
|
+
const jar = await cookies();
|
|
57
|
+
if (safe === null) {
|
|
58
|
+
jar.delete({
|
|
59
|
+
name: PORTAL_NEXT_COOKIE,
|
|
60
|
+
path: "/"
|
|
61
|
+
});
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
jar.set(PORTAL_NEXT_COOKIE, safe, {
|
|
65
|
+
httpOnly: true,
|
|
66
|
+
secure: true,
|
|
67
|
+
sameSite: "lax",
|
|
68
|
+
path: "/",
|
|
69
|
+
maxAge: MAX_AGE_SECONDS
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* The remembered destination, and it is gone afterwards.
|
|
74
|
+
*
|
|
75
|
+
* Read-once on purpose: the value describes ONE login attempt, and leaving it
|
|
76
|
+
* in the jar would redirect the next one too. The delete runs whether or not
|
|
77
|
+
* the value survived validation, so a malformed cookie is not re-read on every
|
|
78
|
+
* subsequent login either.
|
|
79
|
+
*/
|
|
80
|
+
async function takePortalNextPath() {
|
|
81
|
+
const jar = await cookies();
|
|
82
|
+
const raw = jar.get(PORTAL_NEXT_COOKIE)?.value;
|
|
83
|
+
jar.delete({
|
|
84
|
+
name: PORTAL_NEXT_COOKIE,
|
|
85
|
+
path: "/"
|
|
86
|
+
});
|
|
87
|
+
return safeReturnPath(raw);
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
PORTAL_NEXT_COOKIE,
|
|
91
|
+
writePortalNextPath,
|
|
92
|
+
takePortalNextPath
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
//#endregion
|
|
96
|
+
export { createPortalNextPath };
|
|
97
|
+
|
|
98
|
+
//# sourceMappingURL=next-path.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"next-path.mjs","names":[],"sources":["../../../src/core/portal/next-path.ts"],"sourcesContent":["import type { BookingConfig } from '../config';\nimport type { CookieSource } from './cookies';\nimport { createReturnPath } from './return-path';\n\n/**\n * Where to send the parent after a Vipps login that did not start on Min side.\n *\n * The e-mail login can carry its destination in the URL — the parent stays on\n * this site for the whole handshake, and `/min-side/logg-inn?return=…` is read\n * by the page that renders the form. The Vipps login cannot: the browser goes\n * to Medal, then to Vipps, then back to `/min-side/vipps`, and the only thing\n * this site controls along that path is the `return_url` it handed Medal at\n * the start. Two reasons not to put the destination in THAT:\n *\n * 1. Medal validates the `return_url` against the workspace's site and answers\n * `400 INVALID_RETURN_URL` for one it does not recognise. A query string\n * this site invents is a second thing that has to survive a check written\n * on the other side of the integration — and the barnehage payment leg has\n * already been bitten once by an engine that rewrote a `return_url`'s query\n * (D31).\n * 2. It would put a redirect target in a URL a third party hands back to the\n * browser, which is the shape an open redirect is usually built out of.\n *\n * So the destination never leaves this site: it goes into a short-lived\n * httpOnly cookie before the redirect to Vipps, and `/min-side/vipps` takes it\n * out again when the grant comes back. `sameSite: 'lax'` is what makes that\n * work — the return from Vipps is a top-level GET navigation, which is exactly\n * the case `lax` still sends a cookie on.\n *\n * VALIDATED ON BOTH SIDES. `safeReturnPath` runs before the value is written\n * AND after it is read, so a cookie edited in the jar between the two — this\n * one is httpOnly, but a cookie is not a promise — is a `null` rather than a\n * redirect of the holder's choosing.\n */\n\n/**\n * Ten minutes: a Vipps login is a phone unlock and a confirmation, and a value\n * that outlived the attempt would send a parent who came back to the login on\n * their own into the middle of a flow they had left.\n */\nconst MAX_AGE_SECONDS = 10 * 60;\n\nexport interface PortalNextPath {\n readonly PORTAL_NEXT_COOKIE: string;\n writePortalNextPath(path: string | null): Promise<void>;\n takePortalNextPath(): Promise<string | null>;\n}\n\n/** The read-once «where this login ends» cookie (`portal.nextCookieName`). */\nexport function createPortalNextPath(\n config: Pick<BookingConfig, 'portal'>,\n cookies: CookieSource\n): PortalNextPath {\n const PORTAL_NEXT_COOKIE = config.portal.nextCookieName;\n const safeReturnPath = createReturnPath(config);\n\n /**\n * Remember where this login should end, or forget any earlier answer.\n *\n * A `null` path CLEARS rather than leaves the previous value in place: a\n * parent who starts a barnehage login, abandons it and then logs in normally\n * from Min side must not be thrown into the kindergarten flow by a cookie the\n * first attempt left behind.\n *\n * `secure` unconditionally, matching `PORTAL_SESSION_COOKIE` — see\n * `lib/portal/session.ts` for why localhost still works in Chrome and Firefox.\n */\n async function writePortalNextPath(path: string | null): Promise<void> {\n const safe = path === null ? null : safeReturnPath(path);\n const jar = await cookies();\n if (safe === null) {\n jar.delete({ name: PORTAL_NEXT_COOKIE, path: '/' });\n return;\n }\n jar.set(PORTAL_NEXT_COOKIE, safe, {\n httpOnly: true,\n secure: true,\n sameSite: 'lax',\n path: '/',\n maxAge: MAX_AGE_SECONDS,\n });\n }\n\n /**\n * The remembered destination, and it is gone afterwards.\n *\n * Read-once on purpose: the value describes ONE login attempt, and leaving it\n * in the jar would redirect the next one too. The delete runs whether or not\n * the value survived validation, so a malformed cookie is not re-read on every\n * subsequent login either.\n */\n async function takePortalNextPath(): Promise<string | null> {\n const jar = await cookies();\n const raw = jar.get(PORTAL_NEXT_COOKIE)?.value;\n jar.delete({ name: PORTAL_NEXT_COOKIE, path: '/' });\n return safeReturnPath(raw);\n }\n\n return { PORTAL_NEXT_COOKIE, writePortalNextPath, takePortalNextPath };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAwCA,MAAM,kBAAkB;;AASxB,SAAgB,qBACd,QACA,SACgB;CAChB,MAAM,qBAAqB,OAAO,OAAO;CACzC,MAAM,iBAAiB,iBAAiB,MAAM;;;;;;;;;;;;CAa9C,eAAe,oBAAoB,MAAoC;EACrE,MAAM,OAAO,SAAS,OAAO,OAAO,eAAe,IAAI;EACvD,MAAM,MAAM,MAAM,QAAQ;EAC1B,IAAI,SAAS,MAAM;GACjB,IAAI,OAAO;IAAE,MAAM;IAAoB,MAAM;GAAI,CAAC;GAClD;EACF;EACA,IAAI,IAAI,oBAAoB,MAAM;GAChC,UAAU;GACV,QAAQ;GACR,UAAU;GACV,MAAM;GACN,QAAQ;EACV,CAAC;CACH;;;;;;;;;CAUA,eAAe,qBAA6C;EAC1D,MAAM,MAAM,MAAM,QAAQ;EAC1B,MAAM,MAAM,IAAI,IAAI,kBAAkB,CAAC,EAAE;EACzC,IAAI,OAAO;GAAE,MAAM;GAAoB,MAAM;EAAI,CAAC;EAClD,OAAO,eAAe,GAAG;CAC3B;CAEA,OAAO;EAAE;EAAoB;EAAqB;CAAmB;AACvE"}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { BookingConfig } from "../config.mjs";
|
|
2
|
+
//#region src/core/portal/return-path.d.ts
|
|
3
|
+
export type ReturnPathGuard = (value: unknown) => string | null;
|
|
4
|
+
/** `safeReturnPath` for this site's `portal.returnPaths`. */
|
|
5
|
+
export declare function createReturnPath(config: Pick<BookingConfig, 'portal'>): ReturnPathGuard;
|
|
6
|
+
//#endregion
|
|
7
|
+
//# sourceMappingURL=return-path.d.mts.map
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
//#region src/core/portal/return-path.ts
|
|
2
|
+
/**
|
|
3
|
+
* Longer than any address this site builds (a kindergarten slug plus one
|
|
4
|
+
* query flag) and short enough that a crafted value cannot be used to push a
|
|
5
|
+
* kilobyte through a `Set-Cookie`.
|
|
6
|
+
*/
|
|
7
|
+
const MAX_LENGTH = 512;
|
|
8
|
+
/**
|
|
9
|
+
* A base that cannot be reached and cannot be confused with a real origin.
|
|
10
|
+
* `URL` needs one to resolve a relative reference; anything that resolves to a
|
|
11
|
+
* DIFFERENT origin than this was not relative, whatever it looked like.
|
|
12
|
+
*/
|
|
13
|
+
const RELATIVE_BASE = "https://return-path.invalid";
|
|
14
|
+
/**
|
|
15
|
+
* The path `value` names, or `null`.
|
|
16
|
+
*
|
|
17
|
+
* The answer is rebuilt from the parsed URL rather than handed back as it
|
|
18
|
+
* arrived — pathname and query only — so a caller receives a normalised value
|
|
19
|
+
* with no fragment and no room for a second reading of the same bytes.
|
|
20
|
+
*/
|
|
21
|
+
function safeReturnPath(allowed, value) {
|
|
22
|
+
if (typeof value !== "string") return null;
|
|
23
|
+
if (value.length === 0 || value.length > MAX_LENGTH) return null;
|
|
24
|
+
if (value.includes("\\")) return null;
|
|
25
|
+
for (const char of value) {
|
|
26
|
+
/* v8 ignore next -- a character of a string always has a code point */
|
|
27
|
+
const code = char.codePointAt(0) ?? 0;
|
|
28
|
+
if (code <= 32 || code === 127) return null;
|
|
29
|
+
}
|
|
30
|
+
if (!value.startsWith("/") || value.startsWith("//")) return null;
|
|
31
|
+
let url;
|
|
32
|
+
try {
|
|
33
|
+
url = new URL(value, RELATIVE_BASE);
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
if (url.origin !== RELATIVE_BASE) return null;
|
|
38
|
+
if (!(allowed.prefixes.some((prefix) => url.pathname.startsWith(prefix)) || allowed.exact.some((path) => url.pathname === path))) return null;
|
|
39
|
+
return `${url.pathname}${url.search}`;
|
|
40
|
+
}
|
|
41
|
+
/** `safeReturnPath` for this site's `portal.returnPaths`. */
|
|
42
|
+
function createReturnPath(config) {
|
|
43
|
+
const allowed = config.portal.returnPaths;
|
|
44
|
+
return (value) => safeReturnPath(allowed, value);
|
|
45
|
+
}
|
|
46
|
+
//#endregion
|
|
47
|
+
export { createReturnPath };
|
|
48
|
+
|
|
49
|
+
//# sourceMappingURL=return-path.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"return-path.mjs","names":[],"sources":["../../../src/core/portal/return-path.ts"],"sourcesContent":["/**\n * Where a login is allowed to send a parent afterwards.\n *\n * Two places on this site now start a Min side login on behalf of a page that\n * is NOT Min side — a sign-up flow's «fetch from Vipps» and its «log in\n * with e-mail» — and both have to name where the parent should land when the\n * login is done. That destination arrives from somewhere a visitor can write:\n * a query string on `/min-side/logg-inn`, or a form field posted to the Vipps\n * start action. Anything that arrives that way and is later handed to a\n * `redirect()` is an open redirect unless something narrows it first, and this\n * module is that something.\n *\n * The rule is deliberately not «is this the same origin»: a value is refused\n * unless it is a ROOT-RELATIVE PATH, so there is no origin to compare and no\n * parser disagreement to exploit. The three shapes that historically get\n * through a hand-rolled `startsWith('/')` check are all refused explicitly:\n *\n * - `//evil.example` — a protocol-relative URL. It starts with `/`, and a\n * browser sends it to `evil.example`.\n * - `/\\evil.example` and `\\\\evil.example` — the same trick with backslashes,\n * which Chrome and Safari normalise to `//`.\n * - `https://evil.example/barnehage/x` — an absolute URL that happens to\n * contain an allowed path.\n *\n * On top of that the path must be one this site is willing to send a\n * freshly-logged-in parent to. That list is short on purpose: the login exists\n * to hand a parent back to a flow they were already standing in, not to be a\n * general-purpose navigation primitive. `/min-side` needs no entry — it is the\n * default when there is no return path at all.\n *\n * Pure, and not `server-only`: the login page validates a query parameter with\n * it on the server, and the tests exercise it directly.\n */\n\nimport type { BookingConfig } from '../config';\n\n/*\n * The paths a login may return a parent to come from\n * `config.portal.returnPaths`: `prefixes` match on the pathname (a flow whose\n * pages all live below one path), `exact` match the pathname alone and no\n * descendants.\n *\n * The booking page is the default `exact` entry: its login sheet offers Vipps\n * mid-flow and needs the parent back on the page they left (`?resume=1`\n * rebuilds the half-built booking out of the draft store). It is an exact\n * match rather than a prefix because the manage page lives underneath it:\n * that page is reached with a one-time credential in the path, and it has no\n * business being a destination a login can be pointed at.\n *\n * The query string is not part of the comparison — `safeReturnPath` rebuilds\n * the answer from `pathname` and `search`, so `?resume=1` survives while the\n * decision is made on the path alone. The portal itself needs no entry — it is\n * the default when there is no return path at all.\n */\n\n/**\n * Longer than any address this site builds (a kindergarten slug plus one\n * query flag) and short enough that a crafted value cannot be used to push a\n * kilobyte through a `Set-Cookie`.\n */\nconst MAX_LENGTH = 512;\n\n/**\n * A base that cannot be reached and cannot be confused with a real origin.\n * `URL` needs one to resolve a relative reference; anything that resolves to a\n * DIFFERENT origin than this was not relative, whatever it looked like.\n */\nconst RELATIVE_BASE = 'https://return-path.invalid';\n\n/**\n * The path `value` names, or `null`.\n *\n * The answer is rebuilt from the parsed URL rather than handed back as it\n * arrived — pathname and query only — so a caller receives a normalised value\n * with no fragment and no room for a second reading of the same bytes.\n */\nfunction safeReturnPath(\n allowed: { exact: readonly string[]; prefixes: readonly string[] },\n value: unknown\n): string | null {\n if (typeof value !== 'string') return null;\n if (value.length === 0 || value.length > MAX_LENGTH) return null;\n\n // Before any parsing: a path this site issues contains no backslash, no\n // whitespace and no control character, and every known normalisation trick\n // needs one of the three. Character by character rather than by regex — a\n // regex that spells out the control range is itself a lint error, and this\n // says the same thing without one.\n if (value.includes('\\\\')) return null;\n for (const char of value) {\n /* v8 ignore next -- a character of a string always has a code point */\n const code = char.codePointAt(0) ?? 0;\n if (code <= 0x20 || code === 0x7f) return null;\n }\n\n // One leading slash, never two — `//host` is protocol-relative.\n if (!value.startsWith('/') || value.startsWith('//')) return null;\n\n // Defensive from here: the checks above leave only single-slash paths with no\n // backslash or whitespace, which neither throw nor leave the base origin. The\n // two guards stay as the second line of defence they were written as.\n let url: URL;\n try {\n url = new URL(value, RELATIVE_BASE);\n /* v8 ignore start */\n } catch {\n return null;\n }\n // An absolute URL resolves to its own origin and is refused here even when\n // its path would have been allowed.\n if (url.origin !== RELATIVE_BASE) return null;\n /* v8 ignore stop */\n\n const ok =\n allowed.prefixes.some((prefix) => url.pathname.startsWith(prefix)) ||\n allowed.exact.some((path) => url.pathname === path);\n if (!ok) return null;\n\n return `${url.pathname}${url.search}`;\n}\n\nexport type ReturnPathGuard = (value: unknown) => string | null;\n\n/** `safeReturnPath` for this site's `portal.returnPaths`. */\nexport function createReturnPath(config: Pick<BookingConfig, 'portal'>): ReturnPathGuard {\n const allowed = config.portal.returnPaths;\n return (value) => safeReturnPath(allowed, value);\n}\n"],"mappings":";;;;;;AA4DA,MAAM,aAAa;;;;;;AAOnB,MAAM,gBAAgB;;;;;;;;AAStB,SAAS,eACP,SACA,OACe;CACf,IAAI,OAAO,UAAU,UAAU,OAAO;CACtC,IAAI,MAAM,WAAW,KAAK,MAAM,SAAS,YAAY,OAAO;CAO5D,IAAI,MAAM,SAAS,IAAI,GAAG,OAAO;CACjC,KAAK,MAAM,QAAQ,OAAO;;EAExB,MAAM,OAAO,KAAK,YAAY,CAAC,KAAK;EACpC,IAAI,QAAQ,MAAQ,SAAS,KAAM,OAAO;CAC5C;CAGA,IAAI,CAAC,MAAM,WAAW,GAAG,KAAK,MAAM,WAAW,IAAI,GAAG,OAAO;CAK7D,IAAI;CACJ,IAAI;EACF,MAAM,IAAI,IAAI,OAAO,aAAa;CAEpC,QAAQ;EACN,OAAO;CACT;CAGA,IAAI,IAAI,WAAW,eAAe,OAAO;CAMzC,IAAI,EAFF,QAAQ,SAAS,MAAM,WAAW,IAAI,SAAS,WAAW,MAAM,CAAC,KACjE,QAAQ,MAAM,MAAM,SAAS,IAAI,aAAa,IAAI,IAC3C,OAAO;CAEhB,OAAO,GAAG,IAAI,WAAW,IAAI;AAC/B;;AAKA,SAAgB,iBAAiB,QAAwD;CACvF,MAAM,UAAU,OAAO,OAAO;CAC9B,QAAQ,UAAU,eAAe,SAAS,KAAK;AACjD"}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { BookingConfig } from "../config.mjs";
|
|
2
|
+
//#region src/core/portal/session-cookie.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* Whether a value is the shape of a Medal-issued portal session token.
|
|
5
|
+
*
|
|
6
|
+
* Shared by the cookie reader, the cookie writer, and middleware so the three
|
|
7
|
+
* cannot disagree — a token the writer accepted but the reader (or the
|
|
8
|
+
* bounce) refused would log the parent in and then out again on the next
|
|
9
|
+
* request, with nothing on screen to say why.
|
|
10
|
+
*
|
|
11
|
+
* `unknown` rather than `string`, because the value on the way in is whatever
|
|
12
|
+
* a JSON body or a cookie header contained, and narrowing it is this
|
|
13
|
+
* function's whole job.
|
|
14
|
+
*/
|
|
15
|
+
export declare function isPortalSessionToken(value: unknown): value is string;
|
|
16
|
+
export interface SessionCookie {
|
|
17
|
+
/** The session cookie's name (`portal.cookieName`). */
|
|
18
|
+
readonly PORTAL_SESSION_COOKIE: string;
|
|
19
|
+
/** Exact dashboard URL the middleware bounce matches (`paths.portal`). */
|
|
20
|
+
readonly PORTAL_DASHBOARD_PATH: string | null;
|
|
21
|
+
/** Where a logged-out dashboard visit is sent (`paths.portalLogin`). */
|
|
22
|
+
readonly PORTAL_LOGIN_PATH: string;
|
|
23
|
+
isPortalSessionToken: typeof isPortalSessionToken;
|
|
24
|
+
}
|
|
25
|
+
export declare function createSessionCookie(config: Pick<BookingConfig, 'portal' | 'paths'>): SessionCookie;
|
|
26
|
+
//#endregion
|
|
27
|
+
//# sourceMappingURL=session-cookie.d.mts.map
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
//#region src/core/portal/session-cookie.ts
|
|
2
|
+
/** 32 bytes, base64url, no padding: `ceil(32 × 8 / 6)` = 43 characters. */
|
|
3
|
+
const PORTAL_SESSION_TOKEN = /^[A-Za-z0-9_-]{43}$/;
|
|
4
|
+
/**
|
|
5
|
+
* Whether a value is the shape of a Medal-issued portal session token.
|
|
6
|
+
*
|
|
7
|
+
* Shared by the cookie reader, the cookie writer, and middleware so the three
|
|
8
|
+
* cannot disagree — a token the writer accepted but the reader (or the
|
|
9
|
+
* bounce) refused would log the parent in and then out again on the next
|
|
10
|
+
* request, with nothing on screen to say why.
|
|
11
|
+
*
|
|
12
|
+
* `unknown` rather than `string`, because the value on the way in is whatever
|
|
13
|
+
* a JSON body or a cookie header contained, and narrowing it is this
|
|
14
|
+
* function's whole job.
|
|
15
|
+
*/
|
|
16
|
+
function isPortalSessionToken(value) {
|
|
17
|
+
return typeof value === "string" && PORTAL_SESSION_TOKEN.test(value);
|
|
18
|
+
}
|
|
19
|
+
function createSessionCookie(config) {
|
|
20
|
+
return {
|
|
21
|
+
PORTAL_SESSION_COOKIE: config.portal.cookieName,
|
|
22
|
+
PORTAL_DASHBOARD_PATH: config.paths.portal,
|
|
23
|
+
PORTAL_LOGIN_PATH: config.paths.portalLogin,
|
|
24
|
+
isPortalSessionToken
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
//#endregion
|
|
28
|
+
export { createSessionCookie, isPortalSessionToken };
|
|
29
|
+
|
|
30
|
+
//# sourceMappingURL=session-cookie.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"session-cookie.mjs","names":[],"sources":["../../../src/core/portal/session-cookie.ts"],"sourcesContent":["/**\n * The portal session cookie's *shape* — name and token alphabet — with no\n * Next.js `cookies()` import, so Edge middleware can share the check the\n * server reader uses without pulling `server-only` into the Worker.\n *\n * The token is a 43-character base64url string: 32 random bytes, encoded\n * without padding, which is what Medal's issuer produces and the one shape\n * this site accepts. The alphabet is `A–Z a–z 0–9 - _` — a `+`, `/` or `=`\n * is standard base64 and is refused, because a value that came from a\n * different encoder did not come from Medal.\n */\n\nimport type { BookingConfig } from '../config';\n\n/** 32 bytes, base64url, no padding: `ceil(32 × 8 / 6)` = 43 characters. */\nconst PORTAL_SESSION_TOKEN = /^[A-Za-z0-9_-]{43}$/;\n\n/**\n * Whether a value is the shape of a Medal-issued portal session token.\n *\n * Shared by the cookie reader, the cookie writer, and middleware so the three\n * cannot disagree — a token the writer accepted but the reader (or the\n * bounce) refused would log the parent in and then out again on the next\n * request, with nothing on screen to say why.\n *\n * `unknown` rather than `string`, because the value on the way in is whatever\n * a JSON body or a cookie header contained, and narrowing it is this\n * function's whole job.\n */\nexport function isPortalSessionToken(value: unknown): value is string {\n return typeof value === 'string' && PORTAL_SESSION_TOKEN.test(value);\n}\n\nexport interface SessionCookie {\n /** The session cookie's name (`portal.cookieName`). */\n readonly PORTAL_SESSION_COOKIE: string;\n /** Exact dashboard URL the middleware bounce matches (`paths.portal`). */\n readonly PORTAL_DASHBOARD_PATH: string | null;\n /** Where a logged-out dashboard visit is sent (`paths.portalLogin`). */\n readonly PORTAL_LOGIN_PATH: string;\n isPortalSessionToken: typeof isPortalSessionToken;\n}\n\nexport function createSessionCookie(\n config: Pick<BookingConfig, 'portal' | 'paths'>\n): SessionCookie {\n return {\n PORTAL_SESSION_COOKIE: config.portal.cookieName,\n PORTAL_DASHBOARD_PATH: config.paths.portal,\n PORTAL_LOGIN_PATH: config.paths.portalLogin,\n isPortalSessionToken,\n };\n}\n"],"mappings":";;AAeA,MAAM,uBAAuB;;;;;;;;;;;;;AAc7B,SAAgB,qBAAqB,OAAiC;CACpE,OAAO,OAAO,UAAU,YAAY,qBAAqB,KAAK,KAAK;AACrE;AAYA,SAAgB,oBACd,QACe;CACf,OAAO;EACL,uBAAuB,OAAO,OAAO;EACrC,uBAAuB,OAAO,MAAM;EACpC,mBAAmB,OAAO,MAAM;EAChC;CACF;AACF"}
|