@medalsocial/sdk 1.7.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.
@@ -26,6 +26,11 @@ tags:
26
26
  Appointment bookings — the service catalogue, free slots, staff actions
27
27
  on a booking, and the customer-facing manage-token routes. Money is
28
28
  always integer øre.
29
+ - name: Portal
30
+ description: >-
31
+ Customer self-service portal — e-mail one-time-code login, then
32
+ session-bound access to the signed-in contact's own profile, bookings,
33
+ data export and erasure. Session routes carry `X-Portal-Session`.
29
34
  - name: GDPR
30
35
  description: Manage exports, consent records, and cookie consent.
31
36
  - name: Workspaces
@@ -898,6 +903,184 @@ paths:
898
903
  $ref: "#/components/schemas/ApiResponse_BookingRescheduleResult"
899
904
  default:
900
905
  $ref: "#/components/responses/ApiError"
906
+ /api/v1/portal/login/start:
907
+ post:
908
+ tags: [Portal]
909
+ operationId: startPortalLogin
910
+ summary: E-mail a one-time login code
911
+ description: >-
912
+ Always answers 202 `{ status: "sent" }`, whether or not the address
913
+ belongs to a contact — enumeration-safe by design. Rate-limited per
914
+ address and per caller (`429 RATE_LIMITED`). Not idempotency-keyed.
915
+ Errors: `400 VALIDATION_ERROR`, `403 FORBIDDEN` (key lacks
916
+ `write:portal`), `429 RATE_LIMITED`.
917
+ requestBody:
918
+ required: true
919
+ content:
920
+ application/json:
921
+ schema:
922
+ $ref: "#/components/schemas/PortalLoginStartInput"
923
+ responses:
924
+ "202":
925
+ description: Code queued (or silently not, for an unknown address).
926
+ content:
927
+ application/json:
928
+ schema:
929
+ $ref: "#/components/schemas/ApiResponse_PortalLoginStartResult"
930
+ default:
931
+ $ref: "#/components/responses/ApiError"
932
+ /api/v1/portal/login/verify:
933
+ post:
934
+ tags: [Portal]
935
+ operationId: verifyPortalLogin
936
+ summary: Exchange the e-mailed code for a session
937
+ description: >-
938
+ A wrong, burned or expired code all answer `401 PORTAL_CODE_INVALID` —
939
+ the three are not distinguished, so the response is not an oracle for
940
+ which codes exist. The returned `session_token` is a bearer credential
941
+ for ONE contact: keep it in an HttpOnly cookie on the site's server and
942
+ never hand it to the browser. Not idempotency-keyed. Errors:
943
+ `400 VALIDATION_ERROR`, `401 PORTAL_CODE_INVALID`, `403 FORBIDDEN`,
944
+ `429 RATE_LIMITED`.
945
+ requestBody:
946
+ required: true
947
+ content:
948
+ application/json:
949
+ schema:
950
+ $ref: "#/components/schemas/PortalVerifyInput"
951
+ responses:
952
+ "200":
953
+ description: The new session.
954
+ content:
955
+ application/json:
956
+ schema:
957
+ $ref: "#/components/schemas/ApiResponse_PortalSession"
958
+ default:
959
+ $ref: "#/components/responses/ApiError"
960
+ /api/v1/portal/logout:
961
+ parameters:
962
+ - $ref: "#/components/parameters/PortalSession"
963
+ post:
964
+ tags: [Portal]
965
+ operationId: logoutPortal
966
+ summary: Revoke the session
967
+ description: >-
968
+ Revoking twice reaches the same state — the second call answers
969
+ `401 PORTAL_SESSION_INVALID`. Not idempotency-keyed. Errors:
970
+ `401 PORTAL_SESSION_REQUIRED` (header missing),
971
+ `401 PORTAL_SESSION_INVALID` (unknown, expired or revoked),
972
+ `403 FORBIDDEN`, `429 RATE_LIMITED`.
973
+ responses:
974
+ "204":
975
+ description: Session revoked.
976
+ default:
977
+ $ref: "#/components/responses/ApiError"
978
+ /api/v1/portal/me:
979
+ parameters:
980
+ - $ref: "#/components/parameters/PortalSession"
981
+ get:
982
+ tags: [Portal]
983
+ operationId: getPortalProfile
984
+ summary: The signed-in contact's profile
985
+ description: >-
986
+ Errors: `401 PORTAL_SESSION_REQUIRED`, `401 PORTAL_SESSION_INVALID`,
987
+ `403 FORBIDDEN` (key lacks `read:portal`), `429 RATE_LIMITED`.
988
+ responses:
989
+ "200":
990
+ description: Profile.
991
+ content:
992
+ application/json:
993
+ schema:
994
+ $ref: "#/components/schemas/ApiResponse_PortalProfile"
995
+ default:
996
+ $ref: "#/components/responses/ApiError"
997
+ patch:
998
+ tags: [Portal]
999
+ operationId: updatePortalProfile
1000
+ summary: Update the signed-in contact's profile
1001
+ description: >-
1002
+ Only the supplied fields change. `phone: null` clears the number,
1003
+ `family` replaces the whole list, and `marketing_consent` records a
1004
+ `marketing_email` consent decision with source `portal`. Answers the
1005
+ profile as it is after the change. Errors: `400 VALIDATION_ERROR`,
1006
+ `401 PORTAL_SESSION_REQUIRED`, `401 PORTAL_SESSION_INVALID`,
1007
+ `403 FORBIDDEN` (key lacks `write:portal`), `429 RATE_LIMITED`.
1008
+ requestBody:
1009
+ required: true
1010
+ content:
1011
+ application/json:
1012
+ schema:
1013
+ $ref: "#/components/schemas/PortalProfilePatch"
1014
+ responses:
1015
+ "200":
1016
+ description: Updated profile.
1017
+ content:
1018
+ application/json:
1019
+ schema:
1020
+ $ref: "#/components/schemas/ApiResponse_PortalProfile"
1021
+ default:
1022
+ $ref: "#/components/responses/ApiError"
1023
+ /api/v1/portal/me/bookings:
1024
+ parameters:
1025
+ - $ref: "#/components/parameters/PortalSession"
1026
+ get:
1027
+ tags: [Portal]
1028
+ operationId: listPortalBookings
1029
+ summary: The signed-in contact's bookings
1030
+ description: >-
1031
+ Split into `upcoming` and `past`. An upcoming booking still inside the
1032
+ workspace's policy windows carries `manage_token` and
1033
+ `can_manage: true`; the token opens the site's manage page. Errors:
1034
+ `401 PORTAL_SESSION_REQUIRED`, `401 PORTAL_SESSION_INVALID`,
1035
+ `403 FORBIDDEN`, `429 RATE_LIMITED`.
1036
+ responses:
1037
+ "200":
1038
+ description: Bookings.
1039
+ content:
1040
+ application/json:
1041
+ schema:
1042
+ $ref: "#/components/schemas/ApiResponse_PortalBookings"
1043
+ default:
1044
+ $ref: "#/components/responses/ApiError"
1045
+ /api/v1/portal/me/export:
1046
+ parameters:
1047
+ - $ref: "#/components/parameters/PortalSession"
1048
+ post:
1049
+ tags: [Portal]
1050
+ operationId: exportPortalData
1051
+ summary: Export everything held about the signed-in contact
1052
+ description: >-
1053
+ A synchronous GDPR Art. 15 export of the contact's profile, family,
1054
+ consents and bookings, as one JSON document. Read-only, so not
1055
+ idempotency-keyed. Errors: `401 PORTAL_SESSION_REQUIRED`,
1056
+ `401 PORTAL_SESSION_INVALID`, `403 FORBIDDEN`, `429 RATE_LIMITED`.
1057
+ responses:
1058
+ "200":
1059
+ description: The export.
1060
+ content:
1061
+ application/json:
1062
+ schema:
1063
+ $ref: "#/components/schemas/ApiResponse_PortalExport"
1064
+ default:
1065
+ $ref: "#/components/responses/ApiError"
1066
+ /api/v1/portal/me/delete:
1067
+ parameters:
1068
+ - $ref: "#/components/parameters/PortalSession"
1069
+ post:
1070
+ tags: [Portal]
1071
+ operationId: deletePortalAccount
1072
+ summary: Erase the signed-in contact
1073
+ description: >-
1074
+ GDPR Art. 17 erasure. The session is revoked as part of the deletion,
1075
+ so a retry answers `401 PORTAL_SESSION_INVALID` rather than deleting
1076
+ anything else. Not idempotency-keyed. Errors:
1077
+ `401 PORTAL_SESSION_REQUIRED`, `401 PORTAL_SESSION_INVALID`,
1078
+ `403 FORBIDDEN` (key lacks `write:portal`), `429 RATE_LIMITED`.
1079
+ responses:
1080
+ "204":
1081
+ description: Contact erased and session revoked.
1082
+ default:
1083
+ $ref: "#/components/responses/ApiError"
901
1084
  /api/v1/gdpr/export:
902
1085
  post:
903
1086
  tags: [GDPR]
@@ -1541,6 +1724,18 @@ components:
1541
1724
  Possession of it authorizes the customer's own cancel or reschedule.
1542
1725
  schema:
1543
1726
  type: string
1727
+ PortalSession:
1728
+ name: X-Portal-Session
1729
+ in: header
1730
+ required: true
1731
+ description: >-
1732
+ The `session_token` returned by `verifyPortalLogin`. A bearer
1733
+ credential for ONE contact — the site's server keeps it in an HttpOnly
1734
+ cookie and forwards it here. Missing answers
1735
+ `401 PORTAL_SESSION_REQUIRED`; unknown, expired or revoked answers
1736
+ `401 PORTAL_SESSION_INVALID`.
1737
+ schema:
1738
+ type: string
1544
1739
  Cursor:
1545
1740
  name: cursor
1546
1741
  in: query
@@ -3615,6 +3810,16 @@ components:
3615
3810
  $ref: "#/components/schemas/Envelope_BookingRescheduleResult"
3616
3811
  ApiResponse_ManageSummary:
3617
3812
  $ref: "#/components/schemas/Envelope_ManageSummary"
3813
+ ApiResponse_PortalLoginStartResult:
3814
+ $ref: "#/components/schemas/Envelope_PortalLoginStartResult"
3815
+ ApiResponse_PortalSession:
3816
+ $ref: "#/components/schemas/Envelope_PortalSession"
3817
+ ApiResponse_PortalProfile:
3818
+ $ref: "#/components/schemas/Envelope_PortalProfile"
3819
+ ApiResponse_PortalBookings:
3820
+ $ref: "#/components/schemas/Envelope_PortalBookings"
3821
+ ApiResponse_PortalExport:
3822
+ $ref: "#/components/schemas/Envelope_PortalExport"
3618
3823
  ApiResponse_GdprExportRequest:
3619
3824
  $ref: "#/components/schemas/Envelope_GdprExportRequest"
3620
3825
  ApiResponse_GdprExportArray:
@@ -3946,6 +4151,247 @@ components:
3946
4151
  properties:
3947
4152
  data:
3948
4153
  $ref: "#/components/schemas/ManageSummary"
4154
+ PortalLoginStartInput:
4155
+ type: object
4156
+ required: [email]
4157
+ properties:
4158
+ email:
4159
+ type: string
4160
+ format: email
4161
+ description: The address the code is sent to.
4162
+ locale:
4163
+ type: string
4164
+ description: Locale for the e-mail (e.g. `nb`, `en`); the workspace default when omitted.
4165
+ PortalLoginStartResult:
4166
+ description: >-
4167
+ Always `sent`, whether or not the address is a known contact — the
4168
+ route is enumeration-safe by design.
4169
+ type: object
4170
+ required: [status]
4171
+ properties:
4172
+ status:
4173
+ type: string
4174
+ const: sent
4175
+ PortalVerifyInput:
4176
+ type: object
4177
+ required: [email, code]
4178
+ properties:
4179
+ email:
4180
+ type: string
4181
+ format: email
4182
+ code:
4183
+ type: string
4184
+ description: The one-time code from the e-mail.
4185
+ PortalContactSummary:
4186
+ type: object
4187
+ required: [contact_id, first_name]
4188
+ properties:
4189
+ contact_id:
4190
+ type: string
4191
+ first_name:
4192
+ type: [string, "null"]
4193
+ PortalSession:
4194
+ description: >-
4195
+ A portal session. `session_token` is a bearer credential for ONE
4196
+ contact — keep it in an HttpOnly cookie on the site's server and never
4197
+ hand it to the browser.
4198
+ type: object
4199
+ required: [session_token, expires_at, contact]
4200
+ properties:
4201
+ session_token:
4202
+ type: string
4203
+ expires_at:
4204
+ type: integer
4205
+ description: Unix timestamp in milliseconds.
4206
+ contact:
4207
+ $ref: "#/components/schemas/PortalContactSummary"
4208
+ PortalFamilyMember:
4209
+ type: object
4210
+ required: [name, birth_year]
4211
+ properties:
4212
+ name:
4213
+ type: string
4214
+ birth_year:
4215
+ type: integer
4216
+ PortalProfile:
4217
+ type: object
4218
+ required:
4219
+ - contact_id
4220
+ - email
4221
+ - first_name
4222
+ - last_name
4223
+ - phone
4224
+ - family
4225
+ - marketing_consent
4226
+ - created_at
4227
+ properties:
4228
+ contact_id:
4229
+ type: string
4230
+ email:
4231
+ type: string
4232
+ first_name:
4233
+ type: [string, "null"]
4234
+ last_name:
4235
+ type: [string, "null"]
4236
+ phone:
4237
+ type: [string, "null"]
4238
+ family:
4239
+ type: array
4240
+ items:
4241
+ $ref: "#/components/schemas/PortalFamilyMember"
4242
+ marketing_consent:
4243
+ type: boolean
4244
+ created_at:
4245
+ type: integer
4246
+ description: Unix timestamp in milliseconds.
4247
+ PortalProfilePatch:
4248
+ description: Only the supplied fields change.
4249
+ type: object
4250
+ properties:
4251
+ first_name:
4252
+ type: string
4253
+ last_name:
4254
+ type: string
4255
+ phone:
4256
+ type: [string, "null"]
4257
+ description: "`null` clears the number."
4258
+ family:
4259
+ type: array
4260
+ description: Replaces the whole list.
4261
+ items:
4262
+ $ref: "#/components/schemas/PortalFamilyMember"
4263
+ marketing_consent:
4264
+ type: boolean
4265
+ description: Records a `marketing_email` consent change with source `portal`.
4266
+ PortalBooking:
4267
+ type: object
4268
+ required:
4269
+ - booking_id
4270
+ - status
4271
+ - start_ts
4272
+ - end_ts
4273
+ - service_id
4274
+ - service_name
4275
+ - resource_id
4276
+ - resource_name
4277
+ - booked_for_name
4278
+ - amount_ore
4279
+ - notes
4280
+ - manage_token
4281
+ - can_manage
4282
+ properties:
4283
+ booking_id:
4284
+ type: string
4285
+ status:
4286
+ $ref: "#/components/schemas/BookingStatus"
4287
+ start_ts:
4288
+ type: integer
4289
+ description: Unix timestamp in milliseconds.
4290
+ end_ts:
4291
+ type: integer
4292
+ description: Unix timestamp in milliseconds.
4293
+ service_id:
4294
+ type: [string, "null"]
4295
+ service_name:
4296
+ type: [string, "null"]
4297
+ resource_id:
4298
+ type: [string, "null"]
4299
+ resource_name:
4300
+ type: [string, "null"]
4301
+ booked_for_name:
4302
+ type: [string, "null"]
4303
+ amount_ore:
4304
+ type: [integer, "null"]
4305
+ description: Integer øre, or `null` when the service has no price.
4306
+ notes:
4307
+ type: [string, "null"]
4308
+ manage_token:
4309
+ type: [string, "null"]
4310
+ description: >-
4311
+ Present only while the booking is upcoming and manageable; opens
4312
+ the site's manage page.
4313
+ can_manage:
4314
+ type: boolean
4315
+ PortalBookings:
4316
+ type: object
4317
+ required: [upcoming, past]
4318
+ properties:
4319
+ upcoming:
4320
+ type: array
4321
+ items:
4322
+ $ref: "#/components/schemas/PortalBooking"
4323
+ past:
4324
+ type: array
4325
+ items:
4326
+ $ref: "#/components/schemas/PortalBooking"
4327
+ PortalConsentRecord:
4328
+ type: object
4329
+ required: [consent_type, granted, granted_at, revoked_at, source]
4330
+ properties:
4331
+ consent_type:
4332
+ type: string
4333
+ granted:
4334
+ type: boolean
4335
+ granted_at:
4336
+ type: [integer, "null"]
4337
+ description: Unix timestamp in milliseconds, or `null`.
4338
+ revoked_at:
4339
+ type: [integer, "null"]
4340
+ description: Unix timestamp in milliseconds, or `null`.
4341
+ source:
4342
+ type: string
4343
+ PortalExport:
4344
+ description: Everything the workspace holds about the signed-in contact (GDPR Art. 15).
4345
+ type: object
4346
+ required: [exported_at, contact, family, consents, bookings]
4347
+ properties:
4348
+ exported_at:
4349
+ type: integer
4350
+ description: Unix timestamp in milliseconds.
4351
+ contact:
4352
+ $ref: "#/components/schemas/PortalProfile"
4353
+ family:
4354
+ type: array
4355
+ items:
4356
+ $ref: "#/components/schemas/PortalFamilyMember"
4357
+ consents:
4358
+ type: array
4359
+ items:
4360
+ $ref: "#/components/schemas/PortalConsentRecord"
4361
+ bookings:
4362
+ type: array
4363
+ items:
4364
+ $ref: "#/components/schemas/PortalBooking"
4365
+ Envelope_PortalLoginStartResult:
4366
+ type: object
4367
+ required: [data]
4368
+ properties:
4369
+ data:
4370
+ $ref: "#/components/schemas/PortalLoginStartResult"
4371
+ Envelope_PortalSession:
4372
+ type: object
4373
+ required: [data]
4374
+ properties:
4375
+ data:
4376
+ $ref: "#/components/schemas/PortalSession"
4377
+ Envelope_PortalProfile:
4378
+ type: object
4379
+ required: [data]
4380
+ properties:
4381
+ data:
4382
+ $ref: "#/components/schemas/PortalProfile"
4383
+ Envelope_PortalBookings:
4384
+ type: object
4385
+ required: [data]
4386
+ properties:
4387
+ data:
4388
+ $ref: "#/components/schemas/PortalBookings"
4389
+ Envelope_PortalExport:
4390
+ type: object
4391
+ required: [data]
4392
+ properties:
4393
+ data:
4394
+ $ref: "#/components/schemas/PortalExport"
3949
4395
  ScanCreateInput:
3950
4396
  type: object
3951
4397
  description: Provide exactly one of `url`, `orgnr`, or `name`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@medalsocial/sdk",
3
- "version": "1.7.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",
@@ -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
 
@@ -42,6 +43,8 @@ Errors throw `MedalApiError` (see the `client` skill for details).
42
43
  | `medal.emails.templates` | `src/resources/emails.ts` (`EmailTemplates`) | `list()`, `get(slug, opts?)` |
43
44
  | `medal.emails` | `src/resources/emails.ts` (`Emails`) | `send(input)`, `get(id)`, `batch(input)` |
44
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) |
45
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) |
46
49
  | `medal.posts` | `src/resources/posts.ts` | `list(opts?)`, `create(input)`, `get(id)`, `update(id, input)`, `remove(id)`, `schedule(id, input)`, `publish(id)`, `channels()` |
47
50
  | `medal.workspaces` | `src/resources/workspaces.ts` | `list()` |
@@ -223,6 +226,45 @@ const { data: summary } = await medal.emails.batch({
223
226
 
224
227
  For more than 100 recipients, chunk into multiple `batch()` calls. There is no built-in chunker.
225
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
+
226
268
  ## GDPR — consent + export workflow
227
269
 
228
270
  **Consent (per-contact):**
@@ -340,4 +382,10 @@ The `with { type: "json" }` import-attribute syntax requires Node 24+ or a bundl
340
382
  | `medal.bookings.update(id, {})` | Rejected by the API; now also a compile error | Pass at least one of `notes` / `internal_notes` |
341
383
  | Ignoring `pagination.truncated` on `bookings.list` | Matching bookings exist that no cursor reaches | Narrow `from_ts`/`to_ts` and page again |
342
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]` |
343
391
  | Building a custom client when only types are needed | Reinventing the wheel | Import from `@medalsocial/sdk/openapi-types` |