@medalsocial/sdk 1.6.0 → 1.8.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@medalsocial/sdk",
3
- "version": "1.6.0",
3
+ "version": "1.8.0",
4
4
  "description": "TypeScript SDK for Medal Social API — posts, emails, contacts, deals, helpdesk, webhooks, and GDPR compliance",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Medal Social / Ali Aljumaili",
@@ -76,27 +76,27 @@
76
76
  "zod": "^4.4.3"
77
77
  },
78
78
  "devDependencies": {
79
- "@biomejs/biome": "^2.5.6",
80
- "@changesets/changelog-github": "^0.7.0",
81
- "@changesets/cli": "^2.31.1",
82
- "@commitlint/cli": "^21.2.1",
83
- "@commitlint/config-conventional": "^21.2.0",
84
- "@redocly/cli": "^2.42.0",
79
+ "@biomejs/biome": "^2.5.10",
80
+ "@changesets/changelog-github": "^1.0.0",
81
+ "@changesets/cli": "^3.0.1",
82
+ "@commitlint/cli": "^21.2.2",
83
+ "@commitlint/config-conventional": "^21.2.2",
84
+ "@redocly/cli": "^2.47.0",
85
85
  "@secretlint/secretlint-rule-preset-recommend": "^13.0.4",
86
86
  "@tanstack/intent": "0.3.6",
87
- "@types/node": "^24.13.3",
88
- "@vitest/coverage-v8": "^4.1.10",
89
- "husky": "^9.1.6",
87
+ "@types/node": "^26.3.0",
88
+ "@vitest/coverage-v8": "^4.1.11",
89
+ "husky": "^9.1.7",
90
90
  "jsr": "^0.14.3",
91
- "knip": "^6.29.0",
92
- "lint-staged": "^17.2.0",
93
- "only-allow": "^1.2.1",
91
+ "knip": "^6.32.2",
92
+ "lint-staged": "^17.3.0",
93
+ "only-allow": "^1.2.2",
94
94
  "openapi-typescript": "^7.13.0",
95
95
  "secretlint": "^13.0.4",
96
- "tsup": "^8.3.0",
96
+ "tsup": "^8.5.1",
97
97
  "typedoc": "^0.28.20",
98
98
  "typescript": "^6.0.3",
99
- "vitest": "^4.1.10"
99
+ "vitest": "^4.1.11"
100
100
  },
101
101
  "scripts": {
102
102
  "preinstall": "npx only-allow pnpm",
@@ -104,12 +104,13 @@
104
104
  "dev": "tsup src/index.ts --watch",
105
105
  "clean": "rm -rf dist",
106
106
  "test": "vitest run",
107
+ "test:coverage": "vitest run --coverage",
107
108
  "test:watch": "vitest",
108
109
  "docs": "typedoc",
109
110
  "lint": "biome check .",
110
111
  "lint:fix": "biome check --fix .",
111
- "quality": "pnpm lint && pnpm test",
112
- "typecheck": "tsc --noEmit",
112
+ "quality": "pnpm lint && pnpm typecheck && pnpm test",
113
+ "typecheck": "tsc --noEmit && tsc -p tsconfig.test.json --noEmit",
113
114
  "verify:paths": "node scripts/verify-package-paths.mjs",
114
115
  "openapi:lint": "redocly lint openapi/medal-social.openapi.yaml",
115
116
  "openapi:types": "openapi-typescript && biome format --write src/openapi.generated.ts",
@@ -57,6 +57,8 @@ Every request gets:
57
57
  - `x-workspace-id: <workspaceId>` (only if `workspaceId` was set on the constructor)
58
58
  - `User-Agent: medalsocial-sdk/<version>` (best-effort — browsers reject custom User-Agent; the SDK swallows that error silently)
59
59
 
60
+ Per-call extras go in `RequestOptions.headers` (accepted by `get`, `post`, `postOnce`, `patch`, `delete` on `BaseClient`). The named options win over a same-named bag entry: `{ headers: { "idempotency-key": "a" }, idempotencyKey: "b" }` sends `b`. The SDK uses this itself for the customer portal's `X-Portal-Session` header — you never set that one by hand; pass the session token to `medal.portal.*` instead. Bag keys are lower-cased before the protected names (`content-type`, the named options) are applied, so capitalisation cannot smuggle a duplicate past them. `retry: false` sends a request exactly once (no 429/5xx retry) on every verb (`post`, `patch`, `delete`) — used by `portal.login.verify`, `portal.logout` and `portal.deleteMe`, where the first attempt may have consumed the code or revoked the session and a retry would misreport success as failure, and by `portal.updateMe`, where a `marketing_consent` change records a consent event a retry would repeat.
61
+
60
62
  ## Retry behavior
61
63
 
62
64
  `BaseClient.request` retries on **429 and 5xx** for up to **3 attempts total**:
@@ -65,9 +67,9 @@ Every request gets:
65
67
  - Otherwise it waits `250 * attempt` ms (so 250, 500 between the first three attempts).
66
68
  - Other 4xx errors are NOT retried — they throw `MedalApiError` immediately.
67
69
  - Network errors (fetch throws) are NOT retried — they bubble up.
68
- - The request is aborted via `AbortController` after `timeout` ms.
70
+ - The request is aborted via `AbortController` after `timeout` ms. That budget covers the **whole exchange** — headers and body — per attempt. It is fixed wall-clock time: progress on the body does not extend it, so raise `timeout` if you pull responses large enough to take longer than it to arrive. Retry backoff is not charged against it.
69
71
 
70
- The SDK does **not** drain the response body between retries if you observe a connection leak in long-running processes, that's worth investigating.
72
+ The SDK **drains** the response body of any attempt it abandons to a retry, so the connection returns to the pool instead of being held open. The body is streamed to a sink rather than buffered, so a large error page costs no memory. A drain that fails is ignored — the retry proceeds on the status.
71
73
 
72
74
  ## Errors
73
75
 
@@ -1,17 +1,20 @@
1
1
  ---
2
2
  name: resources
3
- description: Use when calling any of the SDK resources (contacts, deals, emails, gdpr, posts, scan, workspaces) — listing with pagination, sending transactional or batch emails, scheduling and publishing posts, recording GDPR consent or running an export workflow, fetching a contact's activity timeline — or when needing OpenAPI-derived TypeScript types or the raw OpenAPI document from `@medalsocial/sdk`.
3
+ description: Use when calling any of the SDK resources (bookings, contacts, deals, emails, gdpr, portal, posts, scan, workspaces) — listing with pagination, sending transactional or batch emails, scheduling and publishing posts, booking appointments or querying free slots, cancelling or rescheduling a booking as staff or on a customer's behalf, signing a customer into the self-service portal and reading their own profile/bookings/export, recording GDPR consent or running an export workflow, fetching a contact's activity timeline — or when needing OpenAPI-derived TypeScript types or the raw OpenAPI document from `@medalsocial/sdk`.
4
4
  ---
5
5
 
6
6
  # Medal Social SDK — Resources
7
7
 
8
8
  ## When to load this skill
9
9
 
10
- - Calling `medal.contacts.*`, `medal.deals.*`, `medal.emails.*`, `medal.gdpr.*`, `medal.posts.*`, `medal.scan.*`, or `medal.workspaces.*`.
10
+ - Calling `medal.bookings.*`, `medal.contacts.*`, `medal.deals.*`, `medal.emails.*`, `medal.gdpr.*`, `medal.portal.*`, `medal.posts.*`, `medal.scan.*`, or `medal.workspaces.*`.
11
11
  - Looking up an exact method signature or response shape.
12
12
  - Building a list view that needs pagination.
13
13
  - Sending a single transactional email or a bulk batch.
14
+ - Booking an appointment: reading the catalogue, querying free slots, creating a booking or party.
15
+ - Cancelling or rescheduling a booking — and deciding between the staff route and the customer manage-token route.
14
16
  - Running a GDPR data-export workflow (request → poll → fetch).
17
+ - Building a customer self-service portal: e-mail code login, then the signed-in contact's own profile, bookings, export and erasure.
15
18
  - Importing contacts from a CSV-like source.
16
19
  - Needing OpenAPI-derived types for a custom fetch wrapper, generated mocks, or contract tests.
17
20
 
@@ -22,7 +25,10 @@ Most methods return one of:
22
25
  - **`ApiResponse<T>`** — single-result envelope: `{ data: T }` plus optional metadata.
23
26
  - **`PaginatedResponse<T>`** — list envelope: `{ data: T[], pagination: { has_more: boolean, next_cursor: string | null } }`.
24
27
 
25
- **One documented exception:** `medal.gdpr.cookieConsent(input)` returns a plain `{ success: boolean, logId?: string }` directly — no `data` envelope. Don't destructure `{ data }` from it. See the GDPR section below.
28
+ **Two documented deviations:**
29
+
30
+ - `medal.gdpr.cookieConsent(input)` returns a plain `{ success: boolean, logId?: string }` directly — no `data` envelope. Don't destructure `{ data }` from it. See the GDPR section below.
31
+ - `medal.bookings.list(opts?)` returns `BookingsPage`, not `PaginatedResponse<Booking>` — its `pagination` carries an extra `truncated: boolean`. See the Bookings section below.
26
32
 
27
33
  Errors throw `MedalApiError` (see the `client` skill for details).
28
34
 
@@ -30,11 +36,15 @@ Errors throw `MedalApiError` (see the `client` skill for details).
30
36
 
31
37
  | Namespace | Source | Methods |
32
38
  |---|---|---|
39
+ | `medal.bookings` | `src/resources/bookings.ts` | `listServices(opts?)`, `listResources()`, `availability(opts)`, `schedule(opts)`, `list(opts?)`, `create(input, opts?)`, `get(id)`, `update(id, input, opts?)`, `cancel(id, input?, opts?)`, `reschedule(id, input, opts?)`, `markNoShow(id, opts?)` — all **staff** semantics (policy windows bypassed) |
40
+ | `medal.bookings.manage` | `src/resources/bookings.ts` (`BookingsManage`) | `get(token)`, `cancel(token, input?, opts?)`, `reschedule(token, input, opts?)` — **customer** semantics (policy windows enforced) |
33
41
  | `medal.contacts` | `src/resources/contacts.ts` | `list(opts?)`, `create(input)`, `get(id)`, `update(id, input)`, `remove(id)`, `activities(id, opts?)`, `addNote(id, { content })`, `import(contacts[])` |
34
42
  | `medal.deals` | `src/resources/deals.ts` | `list(opts?)`, `create(input)`, `get(id)`, `update(id, input)`, `remove(id)` |
35
43
  | `medal.emails.templates` | `src/resources/emails.ts` (`EmailTemplates`) | `list()`, `get(slug, opts?)` |
36
44
  | `medal.emails` | `src/resources/emails.ts` (`Emails`) | `send(input)`, `get(id)`, `batch(input)` |
37
45
  | `medal.gdpr` | `src/resources/gdpr.ts` | `requestExport()`, `listExports()`, `getExport(id)`, `recordConsent(input)`, `getConsent(email)`, `cookieConsent(input)` |
46
+ | `medal.portal.login` | `src/resources/portal.ts` (`PortalLogin`) | `start({ email, locale? })` (always 202 `{ status: 'sent' }`), `verify({ email, code })` → `PortalSession` |
47
+ | `medal.portal` | `src/resources/portal.ts` | `me(session)`, `updateMe(session, patch)`, `myBookings(session)`, `exportMyData(session)`, `deleteMe(session)`, `logout(session)` — every one takes the `session_token` first and sends it as `X-Portal-Session`; `deleteMe`/`logout` resolve to `undefined` (204) |
38
48
  | `medal.scan` | `src/resources/scan.ts` | `create(input)` (exactly one of `url`/`orgnr`/`name`; 202 async job), `get(id)`, `companies(q)` (Norwegian registry typeahead), `waitForResult(id, opts?)` (polls until done/failed; returns the job either way, throws only on deadline) |
39
49
  | `medal.posts` | `src/resources/posts.ts` | `list(opts?)`, `create(input)`, `get(id)`, `update(id, input)`, `remove(id)`, `schedule(id, input)`, `publish(id)`, `channels()` |
40
50
  | `medal.workspaces` | `src/resources/workspaces.ts` | `list()` |
@@ -90,6 +100,92 @@ await medal.posts.publish(post.id);
90
100
 
91
101
  `channels()` is the canonical way to discover what publishing destinations a workspace has connected — don't hard-code channel IDs.
92
102
 
103
+ ## Bookings — catalogue, slots, and the two cancel/reschedule semantics
104
+
105
+ **Money is integer øre.** `amount_ore` and `price_ore` are whole øre — never divide into a float for storage or comparison, and never invent a "kroner" field. `499.90` is not representable and a rounding error in a price is a wrong invoice.
106
+
107
+ **Timestamps are asymmetric.** Responses render every timestamp as an ISO 8601 string. Requests accept *either* Unix milliseconds or an ISO string (`BookingTimestampInput = number | string`), so echoing a slot's `start_ts` straight back into `create()` is supported and is the intended flow. Don't normalise on the client.
108
+
109
+ ```ts
110
+ const { data: services } = await medal.bookings.listServices(); // active-only
111
+ const { data: all } = await medal.bookings.listServices({ include_inactive: true });
112
+ const { data: resources } = await medal.bookings.listResources(); // staff / rooms / equipment
113
+
114
+ // Free slots — service_id, from_ts and to_ts are all REQUIRED; to_ts must be after from_ts
115
+ const { data: slots } = await medal.bookings.availability({
116
+ service_id: services[0].id,
117
+ from_ts: Date.now(),
118
+ to_ts: Date.now() + 7 * 86_400_000,
119
+ resource_id: resources[0].id, // optional
120
+ });
121
+ ```
122
+
123
+ Slots are computed at call time and are **not held** — a slot can be taken between `availability()` and `create()`. Handle the conflict error; don't assume a fetched slot is reserved.
124
+
125
+ **An empty `availability()` does not say why.** A closed day, an evening past closing and a fully booked day all come back as `[]`. `schedule()` is the other half — one entry per date the workspace keeps hours on, same parameters as `availability()`:
126
+
127
+ ```ts
128
+ const { data: days } = await medal.bookings.schedule({
129
+ service_id: services[0].id,
130
+ from_ts: Date.now(),
131
+ to_ts: Date.now() + 7 * 86_400_000,
132
+ });
133
+ // A date ABSENT from `days` is closed. On a listed date, `last_start_ts` is the last
134
+ // start THIS service could occupy (duration + buffers, not the closing time) — compare
135
+ // it against the clock to tell "too late today" from "fully booked"; it is `null` on a
136
+ // date with posted hours that is shut outright (a public holiday).
137
+ ```
138
+
139
+ **Creating is a party operation.** `items` is an array because one request books a whole family in one all-or-nothing transaction (max 50). A single appointment is just `items` of length 1.
140
+
141
+ ```ts
142
+ const { data } = await medal.bookings.create(
143
+ {
144
+ items: [{ service_id: services[0].id, start_ts: slots[0].start_ts! }],
145
+ contact: { phone: '+4790000000', name: 'Ida' }, // phone is the CRM dedupe key and is required
146
+ created_via: 'web', // only from the workspace's OWN site; omit (=> 'api') from integrations
147
+ },
148
+ { idempotencyKey: crypto.randomUUID() },
149
+ );
150
+ data.bookings[0].manage_token; // SHOW-ONCE
151
+ data.contact_id;
152
+ ```
153
+
154
+ `created_via` is optional and defaults to `api`. Send `web` **only** from the workspace's own website, so its bookings can be told apart from integrations'. `dashboard` and `walk_in` are staff-only and the API rejects them with 400 — an API key proves which workspace is calling, not that a member typed the booking in.
155
+
156
+ `manage_token` is a capability: whoever holds it can cancel or move that booking. Only its SHA-256 hash is stored, so the create response is the **only** place the plaintext token ever appears — persist it there if you need to build the customer's manage link. It is **absent** (the key is dropped, not nulled) when the response is replayed from an `Idempotency-Key`, which is why the type is `manage_token?: string`.
157
+
158
+ **A lost token cannot be recovered.** `bookings.get(id)` returns a `Booking`, which has no token field — there is nothing to re-read, and the stored hash is one-way. The only ways forward are to reschedule the booking (`reschedule` mints a fresh token) or to have staff act on it by id.
159
+
160
+ **The two semantics are picked by which namespace you call, not by an argument:**
161
+
162
+ | | `medal.bookings.cancel(id)` / `.reschedule(id, …)` | `medal.bookings.manage.cancel(token)` / `.reschedule(token, …)` |
163
+ |---|---|---|
164
+ | Who is acting | the business (your API key **is** the salon) | the customer, relayed by you |
165
+ | Policy windows | **bypassed** | **enforced** |
166
+ | Cancel attributed to | `staff` | `customer` |
167
+ | Addressed by | booking id | manage token |
168
+
169
+ Use the manage routes when you are relaying a customer's own click on the link in their confirmation email. Use the id routes for anything staff do. Reaching for the id route because "it always works" silently records a customer's cancellation as a staff one and skips the window the salon configured.
170
+
171
+ ```ts
172
+ const { data: summary } = await medal.bookings.manage.get(manageToken);
173
+ if (summary.can_cancel) await medal.bookings.manage.cancel(manageToken, { reason: 'Endret plan' });
174
+ ```
175
+
176
+ `can_cancel` / `can_reschedule` already apply the windows — honour them instead of re-deriving from `cancel_window_hours` and `start_ts`.
177
+
178
+ **A reschedule returns a NEW booking.** Both reschedule methods cancel the old row and insert a new one, so the result's `booking_id` is a new id and `manage_token` a newly minted token. The id and token you passed in are dead afterwards — re-store both, or the next manage link you send will 404.
179
+
180
+ **Listing carries a third pagination field.** `pagination.truncated` is separate from `has_more`: the underlying read is capped, and when the cap binds there are matching bookings that **no cursor from this call reaches**. Walking `has_more` to the end will not find them — narrow `from_ts`/`to_ts` and page again.
181
+
182
+ ```ts
183
+ const page = await medal.bookings.list({ status: 'confirmed', from_ts: Date.now(), limit: 50 });
184
+ if (page.pagination.truncated) { /* window too wide — split the range */ }
185
+ ```
186
+
187
+ `update(id, input)` is annotation only — `notes` (customer-visible) and `internal_notes` (staff-only); at least one is required, and `""` clears a field. It cannot move a booking or change its status.
188
+
93
189
  ## Emails — transactional + batch
94
190
 
95
191
  **Single send (HTTP 202 — queued, not delivered):**
@@ -130,6 +226,45 @@ const { data: summary } = await medal.emails.batch({
130
226
 
131
227
  For more than 100 recipients, chunk into multiple `batch()` calls. There is no built-in chunker.
132
228
 
229
+ ## Customer portal — e-mail code login, then session-bound self-service
230
+
231
+ `medal.portal` is for the workspace's **own customers**, not staff. A customer proves they own an e-mail address, gets a session, and can then see and change what the workspace holds about *them* — profile, family members, bookings, consents — export it, or erase it. The API key needs `read:portal` + `write:portal` (`403 FORBIDDEN` otherwise).
232
+
233
+ **The session token is a bearer credential for ONE contact.** `verify()` returns it once; your site's *server* keeps it in an HttpOnly, Secure cookie on the site's own domain and forwards it on every call. Never send it to the browser as JSON, never put it in a URL, and never let the browser call Medal directly — the API key would leak with it.
234
+
235
+ ```ts
236
+ // Step 1 — send the code. ALWAYS { status: 'sent' }, whether or not the address is a
237
+ // contact: enumeration-safe, so do not treat "sent" as "this customer exists".
238
+ await medal.portal.login.start({ email, locale: 'nb' });
239
+
240
+ // Step 2 — exchange the code. Wrong, burned and expired codes ALL answer
241
+ // 401 PORTAL_CODE_INVALID; there is no way to tell them apart, by design.
242
+ const { data: session } = await medal.portal.login.verify({ email, code });
243
+ cookies.set('portal_session', session.session_token, {
244
+ httpOnly: true, secure: true, sameSite: 'lax', expires: new Date(session.expires_at),
245
+ });
246
+
247
+ // Step 3 — session-bound calls, token read back from the cookie
248
+ const token = cookies.get('portal_session');
249
+ const { data: me } = await medal.portal.me(token);
250
+ const { data: mine } = await medal.portal.myBookings(token); // { upcoming, past }
251
+ await medal.portal.updateMe(token, { family: [{ name: 'Ola', birth_year: 2018 }] });
252
+ const { data: exported } = await medal.portal.exportMyData(token); // GDPR Art. 15 — synchronous JSON
253
+ await medal.portal.logout(token); // 204 — revokes this session only
254
+ // …or, terminal (a later logout() on the same token answers 401 PORTAL_SESSION_INVALID):
255
+ await medal.portal.deleteMe(token); // GDPR Art. 17 — 204
256
+ ```
257
+
258
+ **`myBookings` hands you manage tokens.** An `upcoming` booking still inside the workspace's policy windows carries `manage_token` (string) and `can_manage: true`; everything else has `manage_token: null`. Use it with `medal.bookings.manage.*` — the customer routes, where the windows are enforced — never with the id-addressed staff routes.
259
+
260
+ **`updateMe` is a partial patch.** Only supplied fields change; `phone: null` clears the number; `family` replaces the whole list (send the full new list, not the delta); `marketing_consent` records a `marketing_email` consent decision with source `portal`, so it shows up in `medal.gdpr.getConsent(email)`.
261
+
262
+ **Two 401s, one meaning.** `PORTAL_SESSION_REQUIRED` (header missing) and `PORTAL_SESSION_INVALID` (unknown, expired, revoked — including after `deleteMe` or `logout`) both mean "sign in again": clear the cookie and send the customer back to step 1. Do not retry them.
263
+
264
+ **Nothing here is idempotency-keyed.** The login routes cannot duplicate anything (a retried `start` sends at most one more code; a retried `verify` meets a burned code), `me`/`myBookings`/`exportMyData` are reads, and `logout`/`deleteMe` are terminal — a retry meets a revoked session. Passing `idempotencyKey` is not possible on these methods and would change nothing if it were.
265
+
266
+ **Portal export vs. GDPR export.** `medal.portal.exportMyData(token)` is one contact's data, synchronous, returned inline. `medal.gdpr.requestExport()` is the whole *workspace*, asynchronous, polled via `getExport`. They are not interchangeable.
267
+
133
268
  ## GDPR — consent + export workflow
134
269
 
135
270
  **Consent (per-contact):**
@@ -239,4 +374,18 @@ The `with { type: "json" }` import-attribute syntax requires Node 24+ or a bundl
239
374
  | `contacts.import` with > 500 contacts | API rejects | Chunk into 500-contact batches yourself |
240
375
  | Hard-coded channel IDs in `posts.create` | Channels are workspace-specific | Call `posts.channels()` to discover them |
241
376
  | Looping on `next_cursor` alone for pagination | Can be non-null when `has_more: false` | Loop on `pagination.has_more` |
377
+ | `medal.bookings.cancel(id)` to relay a customer's cancel | Bypasses the policy window and records `cancelled_by: 'staff'` | `medal.bookings.manage.cancel(token)` |
378
+ | Dividing `amount_ore` / `price_ore` into kroner for storage | Integer øre; a float rounds and the invoice is wrong | Keep the integer; format only at the point of display |
379
+ | Reusing the old id or manage token after a reschedule | Reschedule inserts a NEW booking and mints a NEW token | Store `result.booking_id` and `result.manage_token` |
380
+ | Expecting `manage_token` on an idempotent replay | Tokens are redacted from replayed responses | Persist it from the first response — a replay cannot give it back |
381
+ | Re-reading a booking to recover a lost `manage_token` | `Booking` has no token field and only the hash is stored — it is unrecoverable | Reschedule to mint a fresh token, or act by booking id as staff |
382
+ | `medal.bookings.update(id, {})` | Rejected by the API; now also a compile error | Pass at least one of `notes` / `internal_notes` |
383
+ | Ignoring `pagination.truncated` on `bookings.list` | Matching bookings exist that no cursor reaches | Narrow `from_ts`/`to_ts` and page again |
384
+ | Converting `start_ts` to a fixed format before sending | The API takes Unix ms **or** ISO 8601 | Pass a slot's `start_ts` straight through |
385
+ | Sending `session_token` to the browser (JSON, URL, non-HttpOnly cookie) | It is a bearer credential for that contact — whoever holds it is them | HttpOnly, Secure cookie on the site's server; the server calls `medal.portal.*` |
386
+ | Treating `login.start` → `{ status: 'sent' }` as "this customer exists" | Enumeration-safe: unknown addresses answer `sent` too | Show "check your e-mail" unconditionally |
387
+ | Branching on why `verify` failed | Wrong, burned and expired codes all answer `PORTAL_CODE_INVALID` | One message: "that code did not work — request a new one" |
388
+ | Retrying a `PORTAL_SESSION_INVALID` | The session is gone (expired, revoked, or the contact was deleted) | Clear the cookie and restart the login |
389
+ | Passing a portal `manage_token` to `medal.bookings.cancel(id)` | Wrong route: staff semantics, and it takes an id not a token | `medal.bookings.manage.cancel(manage_token)` |
390
+ | `updateMe(token, { family: [newMember] })` to add one member | `family` REPLACES the list — the others are dropped | Send the full list: `[...me.family, newMember]` |
242
391
  | Building a custom client when only types are needed | Reinventing the wheel | Import from `@medalsocial/sdk/openapi-types` |