@medalsocial/sdk 1.7.0 → 1.9.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.7.0",
3
+ "version": "1.9.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",
@@ -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**:
@@ -1,19 +1,20 @@
1
1
  ---
2
2
  name: resources
3
- description: Use when calling any of the SDK resources (bookings, contacts, deals, emails, gdpr, 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, 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.bookings.*`, `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
14
  - Booking an appointment: reading the catalogue, querying free slots, creating a booking or party.
15
15
  - Cancelling or rescheduling a booking — and deciding between the staff route and the customer manage-token route.
16
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.
17
18
  - Importing contacts from a CSV-like source.
18
19
  - Needing OpenAPI-derived types for a custom fetch wrapper, generated mocks, or contract tests.
19
20
 
@@ -37,11 +38,16 @@ Errors throw `MedalApiError` (see the `client` skill for details).
37
38
  |---|---|---|
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) |
39
40
  | `medal.bookings.manage` | `src/resources/bookings.ts` (`BookingsManage`) | `get(token)`, `cancel(token, input?, opts?)`, `reschedule(token, input, opts?)` — **customer** semantics (policy windows enforced) |
41
+ | `medal.bookings.persons` | `src/resources/bookings.ts` (`BookingsPersons`) | `list(contactId, { include_inactive? })`, `create(input)` — persons a contact books for (children, pets, employees) |
42
+ | `medal.bookings.relations` | `src/resources/bookings.ts` (`BookingsRelations`) | `list(contactId)` → `{ outgoing, incoming }`, `create(input)` — directional relations between contacts |
43
+ | `medal.bookings.events` | `src/resources/bookings.ts` (`BookingsEvents`) | `list({ from, to, status? })`, `get(id)`, `create(input)` — arrangementer (scheduled group sessions); registering a booking to an event ships in a later release |
40
44
  | `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[])` |
41
45
  | `medal.deals` | `src/resources/deals.ts` | `list(opts?)`, `create(input)`, `get(id)`, `update(id, input)`, `remove(id)` |
42
46
  | `medal.emails.templates` | `src/resources/emails.ts` (`EmailTemplates`) | `list()`, `get(slug, opts?)` |
43
47
  | `medal.emails` | `src/resources/emails.ts` (`Emails`) | `send(input)`, `get(id)`, `batch(input)` |
44
48
  | `medal.gdpr` | `src/resources/gdpr.ts` | `requestExport()`, `listExports()`, `getExport(id)`, `recordConsent(input)`, `getConsent(email)`, `cookieConsent(input)` |
49
+ | `medal.portal.login` | `src/resources/portal.ts` (`PortalLogin`) | `start({ email, locale? })` (always 202 `{ status: 'sent' }`), `verify({ email, code })` → `PortalSession` |
50
+ | `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) |
45
51
  | `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) |
46
52
  | `medal.posts` | `src/resources/posts.ts` | `list(opts?)`, `create(input)`, `get(id)`, `update(id, input)`, `remove(id)`, `schedule(id, input)`, `publish(id)`, `channels()` |
47
53
  | `medal.workspaces` | `src/resources/workspaces.ts` | `list()` |
@@ -223,6 +229,45 @@ const { data: summary } = await medal.emails.batch({
223
229
 
224
230
  For more than 100 recipients, chunk into multiple `batch()` calls. There is no built-in chunker.
225
231
 
232
+ ## Customer portal — e-mail code login, then session-bound self-service
233
+
234
+ `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).
235
+
236
+ **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.
237
+
238
+ ```ts
239
+ // Step 1 — send the code. ALWAYS { status: 'sent' }, whether or not the address is a
240
+ // contact: enumeration-safe, so do not treat "sent" as "this customer exists".
241
+ await medal.portal.login.start({ email, locale: 'nb' });
242
+
243
+ // Step 2 — exchange the code. Wrong, burned and expired codes ALL answer
244
+ // 401 PORTAL_CODE_INVALID; there is no way to tell them apart, by design.
245
+ const { data: session } = await medal.portal.login.verify({ email, code });
246
+ cookies.set('portal_session', session.session_token, {
247
+ httpOnly: true, secure: true, sameSite: 'lax', expires: new Date(session.expires_at),
248
+ });
249
+
250
+ // Step 3 — session-bound calls, token read back from the cookie
251
+ const token = cookies.get('portal_session');
252
+ const { data: me } = await medal.portal.me(token);
253
+ const { data: mine } = await medal.portal.myBookings(token); // { upcoming, past }
254
+ await medal.portal.updateMe(token, { family: [{ name: 'Ola', birth_year: 2018 }] });
255
+ const { data: exported } = await medal.portal.exportMyData(token); // GDPR Art. 15 — synchronous JSON
256
+ await medal.portal.logout(token); // 204 — revokes this session only
257
+ // …or, terminal (a later logout() on the same token answers 401 PORTAL_SESSION_INVALID):
258
+ await medal.portal.deleteMe(token); // GDPR Art. 17 — 204
259
+ ```
260
+
261
+ **`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.
262
+
263
+ **`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)`.
264
+
265
+ **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.
266
+
267
+ **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.
268
+
269
+ **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.
270
+
226
271
  ## GDPR — consent + export workflow
227
272
 
228
273
  **Consent (per-contact):**
@@ -340,4 +385,10 @@ The `with { type: "json" }` import-attribute syntax requires Node 24+ or a bundl
340
385
  | `medal.bookings.update(id, {})` | Rejected by the API; now also a compile error | Pass at least one of `notes` / `internal_notes` |
341
386
  | Ignoring `pagination.truncated` on `bookings.list` | Matching bookings exist that no cursor reaches | Narrow `from_ts`/`to_ts` and page again |
342
387
  | 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 |
388
+ | 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.*` |
389
+ | Treating `login.start` → `{ status: 'sent' }` as "this customer exists" | Enumeration-safe: unknown addresses answer `sent` too | Show "check your e-mail" unconditionally |
390
+ | 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" |
391
+ | Retrying a `PORTAL_SESSION_INVALID` | The session is gone (expired, revoked, or the contact was deleted) | Clear the cookie and restart the login |
392
+ | 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)` |
393
+ | `updateMe(token, { family: [newMember] })` to add one member | `family` REPLACES the list — the others are dropped | Send the full list: `[...me.family, newMember]` |
343
394
  | Building a custom client when only types are needed | Reinventing the wheel | Import from `@medalsocial/sdk/openapi-types` |