@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,191 @@
|
|
|
1
|
+
import { toManageDto } from "../manage-dto.mjs";
|
|
2
|
+
import { MedalConfigError } from "../medal.mjs";
|
|
3
|
+
import { readBoundedText } from "../request.mjs";
|
|
4
|
+
import { bookingErrorResponse, digestOf, isSlotTaken } from "./shared.mjs";
|
|
5
|
+
import { MedalApiError } from "@medalsocial/sdk";
|
|
6
|
+
import { NextResponse } from "next/server";
|
|
7
|
+
//#region src/next/routes/manage.ts
|
|
8
|
+
function invalid(message) {
|
|
9
|
+
return bookingErrorResponse("invalidInput", 400, message);
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* The `Idempotency-Key` for one move: the booking's token and the instant it is
|
|
13
|
+
* being moved to, hashed together.
|
|
14
|
+
*
|
|
15
|
+
* A reschedule is an insert plus a cancel, so a second identical request would
|
|
16
|
+
* otherwise find the booking already cancelled and answer 409 — a double tap on
|
|
17
|
+
* «Bekreft endring» reporting failure for a move that succeeded. Deriving the
|
|
18
|
+
* key from the two things that identify the move makes the retry a replay.
|
|
19
|
+
*
|
|
20
|
+
* The digest, never the token. Medal stores the request identity against the
|
|
21
|
+
* key, so a plaintext token here would write a live credential into a table that
|
|
22
|
+
* outlives the appointment — the same reason the engine hashes the token out of
|
|
23
|
+
* its own idempotency identity and API log. Two visitors cannot collide either,
|
|
24
|
+
* since the token is unique per booking; and a visitor who picks a different
|
|
25
|
+
* hour derives a different key, which is correctly a second move rather than a
|
|
26
|
+
* replay of the first.
|
|
27
|
+
*/
|
|
28
|
+
async function moveKey(token, startTs) {
|
|
29
|
+
return digestOf(`reschedule:${token}:${startTs}`);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The `Idempotency-Key` for one cancellation — the booking's token, hashed.
|
|
33
|
+
*
|
|
34
|
+
* The same hazard the move key exists for, and it had been left off this half.
|
|
35
|
+
* Cancelling twice is a `CONFLICT` from the engine, because the second call
|
|
36
|
+
* finds a booking that is already cancelled — so a dropped RESPONSE (the write
|
|
37
|
+
* landed, the answer did not) turns the visitor's perfectly reasonable retry
|
|
38
|
+
* into «det gikk ikke» for an appointment that IS cancelled. A disabled button
|
|
39
|
+
* only stops the concurrent double tap; it does nothing about the retry after
|
|
40
|
+
* an ambiguous network result.
|
|
41
|
+
*
|
|
42
|
+
* No second component, unlike the move: a booking has exactly one cancellation,
|
|
43
|
+
* so the token alone identifies it. Two attempts with different reasons are
|
|
44
|
+
* still one cancellation, and the first reason is the one that sticks — which
|
|
45
|
+
* is the right answer, since it is the one the visitor gave before anything
|
|
46
|
+
* went wrong.
|
|
47
|
+
*
|
|
48
|
+
* The digest and never the token, for the reason `moveKey` gives.
|
|
49
|
+
*/
|
|
50
|
+
async function cancelKey(token) {
|
|
51
|
+
return digestOf(`cancel:${token}`);
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Every way a manage action can fail, in codes the page has a Norwegian
|
|
55
|
+
* sentence for.
|
|
56
|
+
*
|
|
57
|
+
* `windowPassed` is the one that matters. The engine enforces both windows on
|
|
58
|
+
* the write and raises `CONFLICT` with `CANCEL_WINDOW_PASSED` /
|
|
59
|
+
* `RESCHEDULE_WINDOW_PASSED` in the message — the code alone cannot tell it from
|
|
60
|
+
* a taken slot, exactly as the create route has to read `SLOT_TAKEN` out of the
|
|
61
|
+
* message. It is reachable without anyone doing anything wrong: `can_cancel` was
|
|
62
|
+
* true when the page rendered, and the visitor sat on it until it was not.
|
|
63
|
+
*
|
|
64
|
+
* The upstream's own message never travels. Medal writes for the integrator
|
|
65
|
+
* holding the API key, and its 404 for this route would be the same sentence
|
|
66
|
+
* whether the token was revoked, GDPR-erased, or belongs to another salon.
|
|
67
|
+
*/
|
|
68
|
+
function manageErrorResponse(rt, error, action) {
|
|
69
|
+
if (error instanceof MedalConfigError) {
|
|
70
|
+
rt.logger.error({
|
|
71
|
+
err: error,
|
|
72
|
+
action
|
|
73
|
+
}, "Booking API is not configured");
|
|
74
|
+
return bookingErrorResponse("unconfigured", 503);
|
|
75
|
+
}
|
|
76
|
+
if (error instanceof MedalApiError) {
|
|
77
|
+
if (error.status === 404) return bookingErrorResponse("notFound", 404);
|
|
78
|
+
if (error.code === "CONFLICT") {
|
|
79
|
+
if (error.message.includes("WINDOW_PASSED")) return bookingErrorResponse("windowPassed", 409);
|
|
80
|
+
if (isSlotTaken(error)) return bookingErrorResponse("slotTaken", 409);
|
|
81
|
+
return bookingErrorResponse("conflict", 409);
|
|
82
|
+
}
|
|
83
|
+
if (error.code === "VALIDATION_ERROR" || error.code === "INVALID_INPUT") return bookingErrorResponse("invalidInput", 400);
|
|
84
|
+
}
|
|
85
|
+
rt.logger.error({
|
|
86
|
+
err: error,
|
|
87
|
+
action
|
|
88
|
+
}, "Booking manage action failed");
|
|
89
|
+
return bookingErrorResponse("upstreamError", 502);
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* The booking's service id, for expiring its cached slots after a write.
|
|
93
|
+
*
|
|
94
|
+
* Asked for ALONGSIDE the write rather than after it, so it adds no round trip
|
|
95
|
+
* (after a move the old token resolves to the cancelled row, which still names
|
|
96
|
+
* the service — but there is no reason to wait for that). Best effort: `null`
|
|
97
|
+
* leaves the cache to its ~60 s bound, never fails the action.
|
|
98
|
+
*/
|
|
99
|
+
async function serviceIdOf(rt, token) {
|
|
100
|
+
try {
|
|
101
|
+
return (await rt.medal.getManage(token)).service_id ?? null;
|
|
102
|
+
} catch {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/** Expire the booking's slots — cached and in this colo's seeds — once the
|
|
107
|
+
* write has landed. */
|
|
108
|
+
async function expireAfterWrite(rt, serviceId) {
|
|
109
|
+
const id = await serviceId;
|
|
110
|
+
if (id === null) return;
|
|
111
|
+
rt.catalogue.expireSlots([id]);
|
|
112
|
+
await rt.seed.expireBookingSeeds([id]);
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* The manage body's ceiling: an action, a reason chip and an instant fit in
|
|
116
|
+
* a few hundred bytes. Read in bytes before anything parses it, as the portal
|
|
117
|
+
* routes cap theirs.
|
|
118
|
+
*/
|
|
119
|
+
const MANAGE_MAX_BODY_BYTES = 4096;
|
|
120
|
+
async function manageRoute(rt, request, token) {
|
|
121
|
+
if (!token) return invalid("token is required");
|
|
122
|
+
let parsed;
|
|
123
|
+
try {
|
|
124
|
+
const text = await readBoundedText(request, MANAGE_MAX_BODY_BYTES);
|
|
125
|
+
if (text === null) return invalid("body is too large");
|
|
126
|
+
parsed = JSON.parse(text);
|
|
127
|
+
} catch {
|
|
128
|
+
return invalid("body must be valid JSON");
|
|
129
|
+
}
|
|
130
|
+
if (typeof parsed !== "object" || parsed === null) return invalid("body must be a JSON object");
|
|
131
|
+
const body = parsed;
|
|
132
|
+
if (body.action === "cancel") try {
|
|
133
|
+
const reason = typeof body.reason === "string" ? body.reason.trim() : "";
|
|
134
|
+
const serviceId = serviceIdOf(rt, token);
|
|
135
|
+
await rt.medal.cancelManage(token, reason.length > 0 ? reason : void 0, await cancelKey(token));
|
|
136
|
+
await expireAfterWrite(rt, serviceId);
|
|
137
|
+
return NextResponse.json({ ok: true });
|
|
138
|
+
} catch (error) {
|
|
139
|
+
return manageErrorResponse(rt, error, "cancel");
|
|
140
|
+
}
|
|
141
|
+
if (body.action === "reschedule") {
|
|
142
|
+
const startTs = body.startTs;
|
|
143
|
+
if (!Number.isFinite(startTs)) return invalid("startTs must be a timestamp");
|
|
144
|
+
const serviceId = serviceIdOf(rt, token);
|
|
145
|
+
try {
|
|
146
|
+
const result = await rt.medal.rescheduleManage(token, startTs, await moveKey(token, startTs));
|
|
147
|
+
await expireAfterWrite(rt, serviceId);
|
|
148
|
+
return NextResponse.json({
|
|
149
|
+
ok: true,
|
|
150
|
+
...typeof result?.manage_token === "string" ? { manageToken: result.manage_token } : {}
|
|
151
|
+
});
|
|
152
|
+
} catch (error) {
|
|
153
|
+
if (isSlotTaken(error)) await expireAfterWrite(rt, serviceId);
|
|
154
|
+
return manageErrorResponse(rt, error, "reschedule");
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return invalid("action must be \"cancel\" or \"reschedule\"");
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* `GET <api>/manage/<token>` — the booking the token names, as the manage page
|
|
161
|
+
* renders it, for a client that loads it itself. The same 404 for an unknown
|
|
162
|
+
* token as for a revoked one; the token is never echoed or logged, and the
|
|
163
|
+
* answer is never stored by anyone (`no-store`, `no-referrer`).
|
|
164
|
+
*/
|
|
165
|
+
async function manageSummaryRoute(rt, _request, token) {
|
|
166
|
+
const headers = {
|
|
167
|
+
"Cache-Control": "no-store",
|
|
168
|
+
"Referrer-Policy": "no-referrer"
|
|
169
|
+
};
|
|
170
|
+
if (!token) return invalid("token is required");
|
|
171
|
+
try {
|
|
172
|
+
const booking = toManageDto(await rt.medal.getManage(token));
|
|
173
|
+
if (booking === null) return NextResponse.json({ error: "notFound" }, {
|
|
174
|
+
status: 404,
|
|
175
|
+
headers
|
|
176
|
+
});
|
|
177
|
+
return NextResponse.json({ booking }, { headers });
|
|
178
|
+
} catch (error) {
|
|
179
|
+
if (error instanceof MedalApiError && error.status === 404) return NextResponse.json({ error: "notFound" }, {
|
|
180
|
+
status: 404,
|
|
181
|
+
headers
|
|
182
|
+
});
|
|
183
|
+
const response = manageErrorResponse(rt, error, "read");
|
|
184
|
+
for (const [name, value] of Object.entries(headers)) response.headers.set(name, value);
|
|
185
|
+
return response;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
//#endregion
|
|
189
|
+
export { manageRoute, manageSummaryRoute };
|
|
190
|
+
|
|
191
|
+
//# sourceMappingURL=manage.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"manage.mjs","names":[],"sources":["../../../src/next/routes/manage.ts"],"sourcesContent":["import { MedalApiError } from '@medalsocial/sdk';\nimport { NextResponse } from 'next/server';\nimport { toManageDto } from '../manage-dto';\nimport { MedalConfigError } from '../medal';\nimport { readBoundedText } from '../request';\nimport type { BookingRuntime } from '../runtime';\nimport { bookingErrorResponse, digestOf, isSlotTaken } from './shared';\n\n/**\n * The two things the manage page can DO: call off an appointment, or move it.\n *\n * One route rather than `…/cancel` and `…/reschedule`, because the token is the\n * expensive part of this URL and there is no reason to have it in two places.\n * The action is a field on the body.\n *\n * SECURITY — the path segment is a live bearer credential for one booking:\n * whoever holds it can cancel or move that appointment. Three rules follow, and\n * every one of them is a rule about *this file*:\n *\n * - The token is read from the path and from nowhere else. A query string would\n * be the same credential in the one place that is logged by every proxy\n * between here and the visitor, and would let a link with `?token=` be\n * crafted for a page that never offered one.\n * - Nothing here logs it, echoes it, or puts it in a response body. The upstream\n * client already redacts it out of its own error messages (`safePath`), and\n * the responses below carry a code and never the input that produced it.\n * - An unknown token gets the same 404 shape as a revoked one, which is what the\n * engine already answers with — the endpoint is not an oracle for whether a\n * booking exists.\n */\n\n/**\n * What the browser may send. `startTs` is epoch milliseconds, the same unit the\n * availability route emits and the same one `pickSlot` stores, so nothing has to\n * reformat a time between choosing it and committing to it.\n */\ninterface ManageRequest {\n action?: unknown;\n reason?: unknown;\n startTs?: unknown;\n}\n\nfunction invalid(message: string): NextResponse {\n return bookingErrorResponse('invalidInput', 400, message);\n}\n\n/**\n * The `Idempotency-Key` for one move: the booking's token and the instant it is\n * being moved to, hashed together.\n *\n * A reschedule is an insert plus a cancel, so a second identical request would\n * otherwise find the booking already cancelled and answer 409 — a double tap on\n * «Bekreft endring» reporting failure for a move that succeeded. Deriving the\n * key from the two things that identify the move makes the retry a replay.\n *\n * The digest, never the token. Medal stores the request identity against the\n * key, so a plaintext token here would write a live credential into a table that\n * outlives the appointment — the same reason the engine hashes the token out of\n * its own idempotency identity and API log. Two visitors cannot collide either,\n * since the token is unique per booking; and a visitor who picks a different\n * hour derives a different key, which is correctly a second move rather than a\n * replay of the first.\n */\nasync function moveKey(token: string, startTs: number): Promise<string> {\n return digestOf(`reschedule:${token}:${startTs}`);\n}\n\n/**\n * The `Idempotency-Key` for one cancellation — the booking's token, hashed.\n *\n * The same hazard the move key exists for, and it had been left off this half.\n * Cancelling twice is a `CONFLICT` from the engine, because the second call\n * finds a booking that is already cancelled — so a dropped RESPONSE (the write\n * landed, the answer did not) turns the visitor's perfectly reasonable retry\n * into «det gikk ikke» for an appointment that IS cancelled. A disabled button\n * only stops the concurrent double tap; it does nothing about the retry after\n * an ambiguous network result.\n *\n * No second component, unlike the move: a booking has exactly one cancellation,\n * so the token alone identifies it. Two attempts with different reasons are\n * still one cancellation, and the first reason is the one that sticks — which\n * is the right answer, since it is the one the visitor gave before anything\n * went wrong.\n *\n * The digest and never the token, for the reason `moveKey` gives.\n */\nasync function cancelKey(token: string): Promise<string> {\n return digestOf(`cancel:${token}`);\n}\n\n/**\n * Every way a manage action can fail, in codes the page has a Norwegian\n * sentence for.\n *\n * `windowPassed` is the one that matters. The engine enforces both windows on\n * the write and raises `CONFLICT` with `CANCEL_WINDOW_PASSED` /\n * `RESCHEDULE_WINDOW_PASSED` in the message — the code alone cannot tell it from\n * a taken slot, exactly as the create route has to read `SLOT_TAKEN` out of the\n * message. It is reachable without anyone doing anything wrong: `can_cancel` was\n * true when the page rendered, and the visitor sat on it until it was not.\n *\n * The upstream's own message never travels. Medal writes for the integrator\n * holding the API key, and its 404 for this route would be the same sentence\n * whether the token was revoked, GDPR-erased, or belongs to another salon.\n */\nfunction manageErrorResponse(rt: BookingRuntime, error: unknown, action: string): NextResponse {\n if (error instanceof MedalConfigError) {\n rt.logger.error({ err: error, action }, 'Booking API is not configured');\n return bookingErrorResponse('unconfigured', 503);\n }\n\n if (error instanceof MedalApiError) {\n if (error.status === 404) {\n return bookingErrorResponse('notFound', 404);\n }\n if (error.code === 'CONFLICT') {\n if (error.message.includes('WINDOW_PASSED')) {\n return bookingErrorResponse('windowPassed', 409);\n }\n if (isSlotTaken(error)) {\n return bookingErrorResponse('slotTaken', 409);\n }\n return bookingErrorResponse('conflict', 409);\n }\n if (error.code === 'VALIDATION_ERROR' || error.code === 'INVALID_INPUT') {\n return bookingErrorResponse('invalidInput', 400);\n }\n }\n\n // `action` and not the token: which of the two failed is the whole reason to\n // log a line here, and the token is the one thing that must never reach a log\n // stream that outlives the booking.\n rt.logger.error({ err: error, action }, 'Booking manage action failed');\n return bookingErrorResponse('upstreamError', 502);\n}\n\n/**\n * The booking's service id, for expiring its cached slots after a write.\n *\n * Asked for ALONGSIDE the write rather than after it, so it adds no round trip\n * (after a move the old token resolves to the cancelled row, which still names\n * the service — but there is no reason to wait for that). Best effort: `null`\n * leaves the cache to its ~60 s bound, never fails the action.\n */\n// A visit's extra services are not expired here: the summary names only its\n// first one (the visit's own entry carries that tag too, so it does go); their\n// one-service entries turn over with their own ~60 s bound.\nasync function serviceIdOf(rt: BookingRuntime, token: string): Promise<string | null> {\n try {\n return (await rt.medal.getManage(token)).service_id ?? null;\n } catch {\n return null;\n }\n}\n\n/** Expire the booking's slots — cached and in this colo's seeds — once the\n * write has landed. */\nasync function expireAfterWrite(\n rt: BookingRuntime,\n serviceId: Promise<string | null>\n): Promise<void> {\n const id = await serviceId;\n if (id === null) return;\n rt.catalogue.expireSlots([id]);\n await rt.seed.expireBookingSeeds([id]);\n}\n\n/**\n * The manage body's ceiling: an action, a reason chip and an instant fit in\n * a few hundred bytes. Read in bytes before anything parses it, as the portal\n * routes cap theirs.\n */\nconst MANAGE_MAX_BODY_BYTES = 4 * 1024;\n\nexport async function manageRoute(\n rt: BookingRuntime,\n request: Request,\n token: string\n): Promise<Response> {\n if (!token) {\n // Unreachable through the router, which only matches a non-empty segment —\n // but this is the one function in the app where an empty credential must\n // never be forwarded as if it were one.\n return invalid('token is required');\n }\n\n let parsed: unknown;\n try {\n const text = await readBoundedText(request, MANAGE_MAX_BODY_BYTES);\n if (text === null) return invalid('body is too large');\n parsed = JSON.parse(text);\n } catch {\n return invalid('body must be valid JSON');\n }\n if (typeof parsed !== 'object' || parsed === null) {\n return invalid('body must be a JSON object');\n }\n const body = parsed as ManageRequest;\n\n if (body.action === 'cancel') {\n try {\n // Trimmed to nothing means the visitor picked no chip, and the reason is\n // optional to the engine — `''` is a value it would store as a reason\n // that says nothing. Absent is the honest form of «they did not say».\n const reason = typeof body.reason === 'string' ? body.reason.trim() : '';\n const serviceId = serviceIdOf(rt, token);\n await rt.medal.cancelManage(\n token,\n reason.length > 0 ? reason : undefined,\n await cancelKey(token)\n );\n await expireAfterWrite(rt, serviceId);\n return NextResponse.json({ ok: true });\n } catch (error) {\n return manageErrorResponse(rt, error, 'cancel');\n }\n }\n\n if (body.action === 'reschedule') {\n const startTs = body.startTs;\n // `Number.isFinite` and not `Number(...)`, for the reason the create route\n // gives: `Number(null)` is 0, and a cleared slot serialises as null — a\n // lenient parse would ask the engine to move the appointment to 1970.\n if (!Number.isFinite(startTs)) {\n return invalid('startTs must be a timestamp');\n }\n const serviceId = serviceIdOf(rt, token);\n try {\n const result = await rt.medal.rescheduleManage(\n token,\n startTs as number,\n await moveKey(token, startTs as number)\n );\n await expireAfterWrite(rt, serviceId);\n // The NEW booking's token, relayed so the page can send the visitor to a\n // manage link that still works: the token they arrived with now belongs\n // to the cancelled row and from here on resolves to «avbestilt».\n //\n // Absent on an idempotent replay — the engine mints it exactly once — so\n // the field is optional and the page has a path for not getting one.\n return NextResponse.json({\n ok: true,\n ...(typeof result?.manage_token === 'string' ? { manageToken: result.manage_token } : {}),\n });\n } catch (error) {\n // The move lost its slot: the cache offered one that was already gone.\n if (isSlotTaken(error)) await expireAfterWrite(rt, serviceId);\n return manageErrorResponse(rt, error, 'reschedule');\n }\n }\n\n return invalid('action must be \"cancel\" or \"reschedule\"');\n}\n\n/**\n * `GET <api>/manage/<token>` — the booking the token names, as the manage page\n * renders it, for a client that loads it itself. The same 404 for an unknown\n * token as for a revoked one; the token is never echoed or logged, and the\n * answer is never stored by anyone (`no-store`, `no-referrer`).\n */\nexport async function manageSummaryRoute(\n rt: BookingRuntime,\n _request: Request,\n token: string\n): Promise<Response> {\n const headers = { 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer' };\n if (!token) return invalid('token is required');\n try {\n const booking = toManageDto(await rt.medal.getManage(token));\n if (booking === null) return NextResponse.json({ error: 'notFound' }, { status: 404, headers });\n return NextResponse.json({ booking }, { headers });\n } catch (error) {\n if (error instanceof MedalApiError && error.status === 404) {\n return NextResponse.json({ error: 'notFound' }, { status: 404, headers });\n }\n const response = manageErrorResponse(rt, error, 'read');\n for (const [name, value] of Object.entries(headers)) response.headers.set(name, value);\n return response;\n }\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import { verifyLoginInput } from "../../core/portal/login-input.mjs";
|
|
2
|
+
import { toBookingFamilyMember } from "../portal/guardian.mjs";
|
|
3
|
+
import { PortalSessionExpiredError, PortalThrottledError, PortalValidationError, PortalVippsConflictError } from "../portal/medal-portal.mjs";
|
|
4
|
+
import { declaresNoOrigin, isCrossOriginRequest, readBoundedText } from "../request.mjs";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { NextResponse } from "next/server";
|
|
7
|
+
//#region src/next/routes/portal-routes.ts
|
|
8
|
+
/**
|
|
9
|
+
* The portal's route handlers: the e-mail code and Vipps-link code that mint
|
|
10
|
+
* a session, «add a child» from the booking wizard, the hourly session touch,
|
|
11
|
+
* the dead-session clear and the Vipps flash spend.
|
|
12
|
+
*
|
|
13
|
+
* ROUTE HANDLERS, NOT SERVER ACTIONS, and that is the whole reason they
|
|
14
|
+
* exist: in Next 16 a server action that sets a cookie makes the client
|
|
15
|
+
* re-fetch the route it was called from, which on the booking page would
|
|
16
|
+
* re-render the whole force-dynamic wizard behind a login sheet whose point
|
|
17
|
+
* was not to. A `fetch` to a route handler sets the cookie and triggers
|
|
18
|
+
* nothing.
|
|
19
|
+
*
|
|
20
|
+
* THE SESSION TOKEN IS NEVER IN A RESPONSE: it goes from the seam straight
|
|
21
|
+
* into `writePortalSession`, whose cookie is HttpOnly. The seam scrubs it (and
|
|
22
|
+
* every other secret it holds) out of anything it throws.
|
|
23
|
+
*
|
|
24
|
+
* SAME-ORIGIN, STRICTLY, for everything that mints a session or writes to the
|
|
25
|
+
* account: a page elsewhere must not be able to log a visitor in to an
|
|
26
|
+
* account of its choosing (login CSRF), and a request that declares neither
|
|
27
|
+
* `Origin` nor `Referer` is refused too — every browser sends `Origin` on a
|
|
28
|
+
* `fetch` POST.
|
|
29
|
+
*/
|
|
30
|
+
function answer(status, body) {
|
|
31
|
+
return Response.json(body, {
|
|
32
|
+
status,
|
|
33
|
+
headers: { "Cache-Control": "no-store" }
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
function empty(status) {
|
|
37
|
+
return new Response(null, {
|
|
38
|
+
status,
|
|
39
|
+
headers: { "Cache-Control": "no-store" }
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
async function jsonFrom(request, maxBytes) {
|
|
43
|
+
const text = await readBoundedText(request, maxBytes);
|
|
44
|
+
try {
|
|
45
|
+
return text === null ? null : JSON.parse(text);
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/** An address, six digits and JSON punctuation fit in far less. */
|
|
51
|
+
const LOGIN_MAX_BODY_BYTES = 2048;
|
|
52
|
+
/**
|
|
53
|
+
* `POST <portalApi>/login/verify` — exchange an e-mail code for the session
|
|
54
|
+
* cookie. Body `{ email, code }`. Answers, all `no-store`:
|
|
55
|
+
*
|
|
56
|
+
* - 200 `{ ok: true, guardian }` — the cookie is written. `guardian` is the
|
|
57
|
+
* parent narrowed exactly as the booking page narrows them, or `null` when
|
|
58
|
+
* the profile read after a good code failed.
|
|
59
|
+
* - 400 / 401 `{ ok: false, reason: 'invalid' }` — a malformed body, or a
|
|
60
|
+
* wrong or expired code.
|
|
61
|
+
* - 429 `throttled` · 503 `unreachable` · 403 for another origin.
|
|
62
|
+
*/
|
|
63
|
+
async function loginVerifyRoute(rt, request) {
|
|
64
|
+
const reply = (status, body) => answer(status, body);
|
|
65
|
+
if (declaresNoOrigin(request) || isCrossOriginRequest(request)) return reply(403, {
|
|
66
|
+
ok: false,
|
|
67
|
+
reason: "forbidden"
|
|
68
|
+
});
|
|
69
|
+
const parsed = verifyLoginInput.safeParse(await jsonFrom(request, LOGIN_MAX_BODY_BYTES));
|
|
70
|
+
if (!parsed.success) return reply(400, {
|
|
71
|
+
ok: false,
|
|
72
|
+
reason: "invalid"
|
|
73
|
+
});
|
|
74
|
+
let session;
|
|
75
|
+
try {
|
|
76
|
+
session = await rt.portal.verifyLogin(parsed.data.email, parsed.data.code);
|
|
77
|
+
} catch (error) {
|
|
78
|
+
if (error instanceof PortalThrottledError) return reply(429, {
|
|
79
|
+
ok: false,
|
|
80
|
+
reason: "throttled"
|
|
81
|
+
});
|
|
82
|
+
rt.logger.error(error, "Portal login code could not be verified");
|
|
83
|
+
return reply(503, {
|
|
84
|
+
ok: false,
|
|
85
|
+
reason: "unreachable"
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
if (session === null) return reply(401, {
|
|
89
|
+
ok: false,
|
|
90
|
+
reason: "invalid"
|
|
91
|
+
});
|
|
92
|
+
await rt.session.writePortalSession(session.sessionToken, session.expiresAt);
|
|
93
|
+
return reply(200, {
|
|
94
|
+
ok: true,
|
|
95
|
+
guardian: await rt.guardianFromSession(session.sessionToken)
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
const PERSONS_MAX_BODY_BYTES = 2048;
|
|
99
|
+
const personInput = z.object({
|
|
100
|
+
name: z.string().trim().min(1).max(60),
|
|
101
|
+
birthYear: z.number().int().refine((year) => year >= (/* @__PURE__ */ new Date()).getFullYear() - 18 && year <= (/* @__PURE__ */ new Date()).getFullYear()),
|
|
102
|
+
birthMonth: z.number().int().min(1).max(12).optional(),
|
|
103
|
+
notes: z.string().trim().max(500).optional()
|
|
104
|
+
});
|
|
105
|
+
/**
|
|
106
|
+
* `POST <portalApi>/persons` — the wizard's «add a child» for a logged-in
|
|
107
|
+
* parent: create the child in Medal, answer with them, narrowed exactly as the
|
|
108
|
+
* booking page narrows the family (no stylist note, no contact id).
|
|
109
|
+
*
|
|
110
|
+
* Body `{ name, birthYear, birthMonth?, notes? }`. Answers, all `no-store`:
|
|
111
|
+
* 201 `{ ok: true, child }` · 400 `invalid` (with the engine's `message`
|
|
112
|
+
* where there is one) · 401 `session` · 429 `throttled` · 503 `unreachable`
|
|
113
|
+
* · 403 for another origin.
|
|
114
|
+
*/
|
|
115
|
+
async function personsRoute(rt, request) {
|
|
116
|
+
const reply = (status, body) => answer(status, body);
|
|
117
|
+
if (declaresNoOrigin(request) || isCrossOriginRequest(request)) return reply(403, {
|
|
118
|
+
ok: false,
|
|
119
|
+
reason: "forbidden"
|
|
120
|
+
});
|
|
121
|
+
const session = await rt.session.readPortalSession();
|
|
122
|
+
if (session === null) return reply(401, {
|
|
123
|
+
ok: false,
|
|
124
|
+
reason: "session"
|
|
125
|
+
});
|
|
126
|
+
const parsed = personInput.safeParse(await jsonFrom(request, PERSONS_MAX_BODY_BYTES));
|
|
127
|
+
if (!parsed.success) return reply(400, {
|
|
128
|
+
ok: false,
|
|
129
|
+
reason: "invalid"
|
|
130
|
+
});
|
|
131
|
+
const { name, birthYear, birthMonth, notes } = parsed.data;
|
|
132
|
+
try {
|
|
133
|
+
return reply(201, {
|
|
134
|
+
ok: true,
|
|
135
|
+
child: createdChild(await rt.portal.createPerson(session, {
|
|
136
|
+
name,
|
|
137
|
+
birthYear,
|
|
138
|
+
...birthMonth === void 0 ? {} : { birthMonth },
|
|
139
|
+
...notes === void 0 || notes === "" ? {} : { notes }
|
|
140
|
+
}), parsed.data)
|
|
141
|
+
});
|
|
142
|
+
} catch (error) {
|
|
143
|
+
if (error instanceof PortalSessionExpiredError) return reply(401, {
|
|
144
|
+
ok: false,
|
|
145
|
+
reason: "session"
|
|
146
|
+
});
|
|
147
|
+
if (error instanceof PortalValidationError) return reply(400, {
|
|
148
|
+
ok: false,
|
|
149
|
+
reason: "invalid",
|
|
150
|
+
message: error.message
|
|
151
|
+
});
|
|
152
|
+
if (error instanceof PortalThrottledError) return reply(429, {
|
|
153
|
+
ok: false,
|
|
154
|
+
reason: "throttled"
|
|
155
|
+
});
|
|
156
|
+
rt.logger.error(error, "A child could not be added from the booking wizard");
|
|
157
|
+
return reply(503, {
|
|
158
|
+
ok: false,
|
|
159
|
+
reason: "unreachable"
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
/** The child just created, as the wizard sees them — by id, else by name and year. */
|
|
164
|
+
function createdChild(result, wanted) {
|
|
165
|
+
const family = result.profile.family;
|
|
166
|
+
const created = (result.personId === null ? void 0 : family.find((member) => member.personId === result.personId)) ?? family.find((member) => member.name === wanted.name && member.birthYear === wanted.birthYear);
|
|
167
|
+
if (created) return toBookingFamilyMember(created);
|
|
168
|
+
return {
|
|
169
|
+
name: wanted.name,
|
|
170
|
+
birthYear: wanted.birthYear,
|
|
171
|
+
...wanted.birthMonth === void 0 ? {} : { birthMonth: wanted.birthMonth }
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* `POST <portalApi>/session/touch` — keep the session cookie alive as long as
|
|
176
|
+
* the session behind it. Medal slides a portal session every time it is used,
|
|
177
|
+
* but the cookie was written once, at login; Server Components cannot set
|
|
178
|
+
* cookies, so the portal calls this from the browser (once an hour at most).
|
|
179
|
+
*
|
|
180
|
+
* 204: Medal answered, the cookie is renewed. 401: no session, or one Medal no
|
|
181
|
+
* longer honours — nothing is renewed or cleared here. 503: Medal could not be
|
|
182
|
+
* asked. POST so no link, prefetch or crawler triggers it.
|
|
183
|
+
*/
|
|
184
|
+
async function sessionTouchRoute(rt) {
|
|
185
|
+
const session = await rt.session.readPortalSession();
|
|
186
|
+
if (session === null) return empty(401);
|
|
187
|
+
try {
|
|
188
|
+
await rt.portal.getMe(session);
|
|
189
|
+
} catch (error) {
|
|
190
|
+
if (error instanceof PortalSessionExpiredError) return empty(401);
|
|
191
|
+
rt.logger.warn({ err: error }, "Portal session touch could not reach Medal");
|
|
192
|
+
return empty(503);
|
|
193
|
+
}
|
|
194
|
+
await rt.session.renewPortalSession(session);
|
|
195
|
+
return empty(204);
|
|
196
|
+
}
|
|
197
|
+
function seeOther(path, request, headers = {}) {
|
|
198
|
+
const location = new URL(path, request.url);
|
|
199
|
+
return new NextResponse(null, {
|
|
200
|
+
status: 303,
|
|
201
|
+
headers: {
|
|
202
|
+
Location: location.toString(),
|
|
203
|
+
"Cache-Control": "no-store",
|
|
204
|
+
...headers
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* `GET <portalApi>/session/expired` — drop a session cookie Medal has stopped
|
|
210
|
+
* honouring, then send the parent to the login.
|
|
211
|
+
*
|
|
212
|
+
* A Server Component cannot clear a cookie, and the login page sends anyone
|
|
213
|
+
* WITH a cookie back to the portal, so a revoked cookie would bounce the
|
|
214
|
+
* parent between the two. The portal page redirects HERE instead.
|
|
215
|
+
*
|
|
216
|
+
* It clears ONLY a cookie Medal itself refuses: a predictable GET that always
|
|
217
|
+
* logged the parent out would be a logout-CSRF. If Medal cannot be reached the
|
|
218
|
+
* cookie is kept too, and the portal shows its own «unreachable» card.
|
|
219
|
+
*/
|
|
220
|
+
async function sessionExpiredRoute(rt, request) {
|
|
221
|
+
const login = rt.paths.portalLogin;
|
|
222
|
+
const session = await rt.session.readPortalSession();
|
|
223
|
+
if (session === null) return seeOther(login, request);
|
|
224
|
+
let dead;
|
|
225
|
+
try {
|
|
226
|
+
await rt.portal.getMe(session);
|
|
227
|
+
dead = false;
|
|
228
|
+
} catch (error) {
|
|
229
|
+
dead = error instanceof PortalSessionExpiredError;
|
|
230
|
+
}
|
|
231
|
+
if (!dead) return seeOther(rt.paths.portal ?? login, request);
|
|
232
|
+
await rt.session.clearPortalSession();
|
|
233
|
+
return seeOther(login, request);
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* `DELETE <portalApi>/vipps/flash` — spend the «Vipps linked» flash once the
|
|
237
|
+
* profile has shown it. Same-origin only; 204 whether or not there was one.
|
|
238
|
+
*/
|
|
239
|
+
async function vippsFlashRoute(rt, request) {
|
|
240
|
+
if (isCrossOriginRequest(request)) return empty(403);
|
|
241
|
+
await rt.flash.clearVippsLinkFlash();
|
|
242
|
+
return empty(204);
|
|
243
|
+
}
|
|
244
|
+
const LINK_MAX_BODY_BYTES = 1024;
|
|
245
|
+
const linkInput = z.object({ code: z.string().regex(/^\d{6}$/) }).strict();
|
|
246
|
+
/**
|
|
247
|
+
* `POST <portalApi>/vipps/link/verify` — the e-mail code that confirms a
|
|
248
|
+
* conflicted Vipps login, exchanged for the session cookie. The body is
|
|
249
|
+
* `{ code }` and nothing else; the pending `link` and this browser's binding
|
|
250
|
+
* come from their httpOnly cookies and go to Medal in the request body.
|
|
251
|
+
*
|
|
252
|
+
* 200 `{ ok: true, guardian }` (both Vipps cookies cleared) · 400 / 401
|
|
253
|
+
* `invalid` (the cookies stay on a wrong code) · 409 `conflict` (the link is
|
|
254
|
+
* spent, so the cookies go) · 429 · 503 · 403.
|
|
255
|
+
*/
|
|
256
|
+
async function vippsLinkVerifyRoute(rt, request) {
|
|
257
|
+
const reply = (status, body) => answer(status, body);
|
|
258
|
+
if (declaresNoOrigin(request) || isCrossOriginRequest(request)) return reply(403, {
|
|
259
|
+
ok: false,
|
|
260
|
+
reason: "forbidden"
|
|
261
|
+
});
|
|
262
|
+
const parsed = linkInput.safeParse(await jsonFrom(request, LINK_MAX_BODY_BYTES));
|
|
263
|
+
const code = parsed.success ? parsed.data.code : null;
|
|
264
|
+
if (code === null) return reply(400, {
|
|
265
|
+
ok: false,
|
|
266
|
+
reason: "invalid"
|
|
267
|
+
});
|
|
268
|
+
const pair = await rt.vippsLink.readVippsLinkPair();
|
|
269
|
+
if (pair === null) return reply(401, {
|
|
270
|
+
ok: false,
|
|
271
|
+
reason: "invalid"
|
|
272
|
+
});
|
|
273
|
+
let session;
|
|
274
|
+
try {
|
|
275
|
+
session = await rt.portal.verifyVippsLink({
|
|
276
|
+
link: pair.link,
|
|
277
|
+
code,
|
|
278
|
+
browserBinding: pair.binding
|
|
279
|
+
});
|
|
280
|
+
} catch (error) {
|
|
281
|
+
if (error instanceof PortalVippsConflictError) {
|
|
282
|
+
await rt.vippsLink.clearVippsLinkPair();
|
|
283
|
+
return reply(409, {
|
|
284
|
+
ok: false,
|
|
285
|
+
reason: "conflict"
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
if (error instanceof PortalThrottledError) return reply(429, {
|
|
289
|
+
ok: false,
|
|
290
|
+
reason: "throttled"
|
|
291
|
+
});
|
|
292
|
+
rt.logger.error(error, "A Vipps link code could not be verified");
|
|
293
|
+
return reply(503, {
|
|
294
|
+
ok: false,
|
|
295
|
+
reason: "unreachable"
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
if (session === null) return reply(401, {
|
|
299
|
+
ok: false,
|
|
300
|
+
reason: "invalid"
|
|
301
|
+
});
|
|
302
|
+
await rt.session.writePortalSession(session.sessionToken, session.expiresAt);
|
|
303
|
+
await rt.vippsLink.clearVippsLinkPair();
|
|
304
|
+
return reply(200, {
|
|
305
|
+
ok: true,
|
|
306
|
+
guardian: await rt.guardianFromSession(session.sessionToken)
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
//#endregion
|
|
310
|
+
export { loginVerifyRoute, personsRoute, sessionExpiredRoute, sessionTouchRoute, vippsFlashRoute, vippsLinkVerifyRoute };
|
|
311
|
+
|
|
312
|
+
//# sourceMappingURL=portal-routes.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"portal-routes.mjs","names":[],"sources":["../../../src/next/routes/portal-routes.ts"],"sourcesContent":["/**\n * The portal's route handlers: the e-mail code and Vipps-link code that mint\n * a session, «add a child» from the booking wizard, the hourly session touch,\n * the dead-session clear and the Vipps flash spend.\n *\n * ROUTE HANDLERS, NOT SERVER ACTIONS, and that is the whole reason they\n * exist: in Next 16 a server action that sets a cookie makes the client\n * re-fetch the route it was called from, which on the booking page would\n * re-render the whole force-dynamic wizard behind a login sheet whose point\n * was not to. A `fetch` to a route handler sets the cookie and triggers\n * nothing.\n *\n * THE SESSION TOKEN IS NEVER IN A RESPONSE: it goes from the seam straight\n * into `writePortalSession`, whose cookie is HttpOnly. The seam scrubs it (and\n * every other secret it holds) out of anything it throws.\n *\n * SAME-ORIGIN, STRICTLY, for everything that mints a session or writes to the\n * account: a page elsewhere must not be able to log a visitor in to an\n * account of its choosing (login CSRF), and a request that declares neither\n * `Origin` nor `Referer` is refused too — every browser sends `Origin` on a\n * `fetch` POST.\n */\n\nimport { NextResponse } from 'next/server';\nimport { z } from 'zod';\nimport { type VerifyLoginFailure, verifyLoginInput } from '../../core/portal/login-input';\nimport type { BookingFamilyMember, BookingGuardian } from '../../core/types';\nimport { toBookingFamilyMember } from '../portal/guardian';\nimport {\n type PortalPersonResult,\n PortalSessionExpiredError,\n PortalThrottledError,\n PortalValidationError,\n PortalVippsConflictError,\n} from '../portal/medal-portal';\nimport { declaresNoOrigin, isCrossOriginRequest, readBoundedText } from '../request';\nimport type { BookingRuntime } from '../runtime';\n\nfunction answer(status: number, body: unknown): Response {\n return Response.json(body, { status, headers: { 'Cache-Control': 'no-store' } });\n}\n\nfunction empty(status: number): Response {\n return new Response(null, { status, headers: { 'Cache-Control': 'no-store' } });\n}\n\nasync function jsonFrom(request: Request, maxBytes: number): Promise<unknown> {\n const text = await readBoundedText(request, maxBytes);\n try {\n return text === null ? null : JSON.parse(text);\n } catch {\n return null;\n }\n}\n\n/** An address, six digits and JSON punctuation fit in far less. */\nconst LOGIN_MAX_BODY_BYTES = 2 * 1024;\n\ntype VerifyAnswer =\n | { ok: true; guardian: BookingGuardian | null }\n | { ok: false; reason: VerifyLoginFailure | 'forbidden' };\n\n/**\n * `POST <portalApi>/login/verify` — exchange an e-mail code for the session\n * cookie. Body `{ email, code }`. Answers, all `no-store`:\n *\n * - 200 `{ ok: true, guardian }` — the cookie is written. `guardian` is the\n * parent narrowed exactly as the booking page narrows them, or `null` when\n * the profile read after a good code failed.\n * - 400 / 401 `{ ok: false, reason: 'invalid' }` — a malformed body, or a\n * wrong or expired code.\n * - 429 `throttled` · 503 `unreachable` · 403 for another origin.\n */\nexport async function loginVerifyRoute(rt: BookingRuntime, request: Request): Promise<Response> {\n const reply = (status: number, body: VerifyAnswer) => answer(status, body);\n if (declaresNoOrigin(request) || isCrossOriginRequest(request)) {\n return reply(403, { ok: false, reason: 'forbidden' });\n }\n\n const parsed = verifyLoginInput.safeParse(await jsonFrom(request, LOGIN_MAX_BODY_BYTES));\n if (!parsed.success) return reply(400, { ok: false, reason: 'invalid' });\n\n let session: Awaited<ReturnType<BookingRuntime['portal']['verifyLogin']>>;\n try {\n session = await rt.portal.verifyLogin(parsed.data.email, parsed.data.code);\n } catch (error) {\n if (error instanceof PortalThrottledError) {\n return reply(429, { ok: false, reason: 'throttled' });\n }\n // The seam never puts the code or a token in an error it throws.\n rt.logger.error(error, 'Portal login code could not be verified');\n return reply(503, { ok: false, reason: 'unreachable' });\n }\n if (session === null) return reply(401, { ok: false, reason: 'invalid' });\n\n await rt.session.writePortalSession(session.sessionToken, session.expiresAt);\n return reply(200, { ok: true, guardian: await rt.guardianFromSession(session.sessionToken) });\n}\n\nconst PERSONS_MAX_BODY_BYTES = 2 * 1024;\n\nconst personInput = z.object({\n name: z.string().trim().min(1).max(60),\n birthYear: z\n .number()\n .int()\n .refine((year) => year >= new Date().getFullYear() - 18 && year <= new Date().getFullYear()),\n birthMonth: z.number().int().min(1).max(12).optional(),\n notes: z.string().trim().max(500).optional(),\n});\n\ntype PersonsAnswer =\n | { ok: true; child: BookingFamilyMember }\n | {\n ok: false;\n reason: 'invalid' | 'session' | 'throttled' | 'unreachable' | 'forbidden';\n message?: string;\n };\n\n/**\n * `POST <portalApi>/persons` — the wizard's «add a child» for a logged-in\n * parent: create the child in Medal, answer with them, narrowed exactly as the\n * booking page narrows the family (no stylist note, no contact id).\n *\n * Body `{ name, birthYear, birthMonth?, notes? }`. Answers, all `no-store`:\n * 201 `{ ok: true, child }` · 400 `invalid` (with the engine's `message`\n * where there is one) · 401 `session` · 429 `throttled` · 503 `unreachable`\n * · 403 for another origin.\n */\nexport async function personsRoute(rt: BookingRuntime, request: Request): Promise<Response> {\n const reply = (status: number, body: PersonsAnswer) => answer(status, body);\n if (declaresNoOrigin(request) || isCrossOriginRequest(request)) {\n return reply(403, { ok: false, reason: 'forbidden' });\n }\n const session = await rt.session.readPortalSession();\n if (session === null) return reply(401, { ok: false, reason: 'session' });\n\n const parsed = personInput.safeParse(await jsonFrom(request, PERSONS_MAX_BODY_BYTES));\n if (!parsed.success) return reply(400, { ok: false, reason: 'invalid' });\n const { name, birthYear, birthMonth, notes } = parsed.data;\n\n try {\n const result = await rt.portal.createPerson(session, {\n name,\n birthYear,\n ...(birthMonth === undefined ? {} : { birthMonth }),\n ...(notes === undefined || notes === '' ? {} : { notes }),\n });\n return reply(201, { ok: true, child: createdChild(result, parsed.data) });\n } catch (error) {\n if (error instanceof PortalSessionExpiredError) {\n return reply(401, { ok: false, reason: 'session' });\n }\n if (error instanceof PortalValidationError) {\n return reply(400, { ok: false, reason: 'invalid', message: error.message });\n }\n if (error instanceof PortalThrottledError) {\n return reply(429, { ok: false, reason: 'throttled' });\n }\n rt.logger.error(error, 'A child could not be added from the booking wizard');\n return reply(503, { ok: false, reason: 'unreachable' });\n }\n}\n\n/** The child just created, as the wizard sees them — by id, else by name and year. */\nfunction createdChild(\n result: PortalPersonResult,\n wanted: { name: string; birthYear: number; birthMonth?: number }\n): BookingFamilyMember {\n const family = result.profile.family;\n const created =\n (result.personId === null\n ? undefined\n : family.find((member) => member.personId === result.personId)) ??\n family.find((member) => member.name === wanted.name && member.birthYear === wanted.birthYear);\n if (created) return toBookingFamilyMember(created);\n return {\n name: wanted.name,\n birthYear: wanted.birthYear,\n ...(wanted.birthMonth === undefined ? {} : { birthMonth: wanted.birthMonth }),\n };\n}\n\n/**\n * `POST <portalApi>/session/touch` — keep the session cookie alive as long as\n * the session behind it. Medal slides a portal session every time it is used,\n * but the cookie was written once, at login; Server Components cannot set\n * cookies, so the portal calls this from the browser (once an hour at most).\n *\n * 204: Medal answered, the cookie is renewed. 401: no session, or one Medal no\n * longer honours — nothing is renewed or cleared here. 503: Medal could not be\n * asked. POST so no link, prefetch or crawler triggers it.\n */\nexport async function sessionTouchRoute(rt: BookingRuntime): Promise<Response> {\n const session = await rt.session.readPortalSession();\n if (session === null) return empty(401);\n\n try {\n await rt.portal.getMe(session);\n } catch (error) {\n if (error instanceof PortalSessionExpiredError) return empty(401);\n rt.logger.warn({ err: error }, 'Portal session touch could not reach Medal');\n return empty(503);\n }\n\n await rt.session.renewPortalSession(session);\n return empty(204);\n}\n\nfunction seeOther(path: string, request: Request, headers: Record<string, string> = {}): Response {\n const location = new URL(path, request.url);\n return new NextResponse(null, {\n status: 303,\n headers: { Location: location.toString(), 'Cache-Control': 'no-store', ...headers },\n });\n}\n\n/**\n * `GET <portalApi>/session/expired` — drop a session cookie Medal has stopped\n * honouring, then send the parent to the login.\n *\n * A Server Component cannot clear a cookie, and the login page sends anyone\n * WITH a cookie back to the portal, so a revoked cookie would bounce the\n * parent between the two. The portal page redirects HERE instead.\n *\n * It clears ONLY a cookie Medal itself refuses: a predictable GET that always\n * logged the parent out would be a logout-CSRF. If Medal cannot be reached the\n * cookie is kept too, and the portal shows its own «unreachable» card.\n */\nexport async function sessionExpiredRoute(rt: BookingRuntime, request: Request): Promise<Response> {\n const login = rt.paths.portalLogin;\n const session = await rt.session.readPortalSession();\n if (session === null) {\n return seeOther(login, request);\n }\n let dead: boolean;\n try {\n await rt.portal.getMe(session);\n dead = false;\n } catch (error) {\n dead = error instanceof PortalSessionExpiredError;\n }\n if (!dead) {\n return seeOther(rt.paths.portal ?? login, request);\n }\n await rt.session.clearPortalSession();\n return seeOther(login, request);\n}\n\n/**\n * `DELETE <portalApi>/vipps/flash` — spend the «Vipps linked» flash once the\n * profile has shown it. Same-origin only; 204 whether or not there was one.\n */\nexport async function vippsFlashRoute(rt: BookingRuntime, request: Request): Promise<Response> {\n if (isCrossOriginRequest(request)) return empty(403);\n await rt.flash.clearVippsLinkFlash();\n return empty(204);\n}\n\nconst LINK_MAX_BODY_BYTES = 1024;\n\nconst linkInput = z.object({ code: z.string().regex(/^\\d{6}$/) }).strict();\n\ntype LinkAnswer =\n | { ok: true; guardian: BookingGuardian | null }\n | { ok: false; reason: 'invalid' | 'conflict' | 'throttled' | 'unreachable' | 'forbidden' };\n\n/**\n * `POST <portalApi>/vipps/link/verify` — the e-mail code that confirms a\n * conflicted Vipps login, exchanged for the session cookie. The body is\n * `{ code }` and nothing else; the pending `link` and this browser's binding\n * come from their httpOnly cookies and go to Medal in the request body.\n *\n * 200 `{ ok: true, guardian }` (both Vipps cookies cleared) · 400 / 401\n * `invalid` (the cookies stay on a wrong code) · 409 `conflict` (the link is\n * spent, so the cookies go) · 429 · 503 · 403.\n */\nexport async function vippsLinkVerifyRoute(\n rt: BookingRuntime,\n request: Request\n): Promise<Response> {\n const reply = (status: number, body: LinkAnswer) => answer(status, body);\n if (declaresNoOrigin(request) || isCrossOriginRequest(request)) {\n return reply(403, { ok: false, reason: 'forbidden' });\n }\n const parsed = linkInput.safeParse(await jsonFrom(request, LINK_MAX_BODY_BYTES));\n const code = parsed.success ? parsed.data.code : null;\n if (code === null) return reply(400, { ok: false, reason: 'invalid' });\n // No link or no binding in the jar — expired, or another browser — is the\n // same «wrong or expired code» Medal would give, without asking Medal.\n const pair = await rt.vippsLink.readVippsLinkPair();\n if (pair === null) return reply(401, { ok: false, reason: 'invalid' });\n\n let session: Awaited<ReturnType<BookingRuntime['portal']['verifyVippsLink']>>;\n try {\n session = await rt.portal.verifyVippsLink({\n link: pair.link,\n code,\n browserBinding: pair.binding,\n });\n } catch (error) {\n if (error instanceof PortalVippsConflictError) {\n await rt.vippsLink.clearVippsLinkPair();\n return reply(409, { ok: false, reason: 'conflict' });\n }\n if (error instanceof PortalThrottledError) {\n return reply(429, { ok: false, reason: 'throttled' });\n }\n rt.logger.error(error, 'A Vipps link code could not be verified');\n return reply(503, { ok: false, reason: 'unreachable' });\n }\n if (session === null) return reply(401, { ok: false, reason: 'invalid' });\n\n await rt.session.writePortalSession(session.sessionToken, session.expiresAt);\n await rt.vippsLink.clearVippsLinkPair();\n return reply(200, { ok: true, guardian: await rt.guardianFromSession(session.sessionToken) });\n}\n"],"mappings":"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"}
|