@medalsocial/sdk 1.6.0 → 1.7.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/README.md +68 -0
- package/dist/openapi/medal-social.openapi.json +3097 -1489
- package/dist/pilot/index.d.mts +2 -2
- package/dist/pilot/index.d.ts +2 -2
- package/dist/src/index.d.mts +593 -23
- package/dist/src/index.d.ts +593 -23
- package/dist/src/index.js +472 -146
- package/dist/src/index.js.map +1 -1
- package/dist/src/index.mjs +471 -146
- package/dist/src/index.mjs.map +1 -1
- package/dist/src/openapi.generated.d.mts +905 -45
- package/dist/src/openapi.generated.d.ts +905 -45
- package/dist/src/openapi.generated.js.map +1 -1
- package/openapi/medal-social.openapi.yaml +944 -0
- package/package.json +18 -17
- package/skills/client/SKILL.md +2 -2
- package/skills/resources/SKILL.md +104 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@medalsocial/sdk",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.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.
|
|
80
|
-
"@changesets/changelog-github": "^0.
|
|
81
|
-
"@changesets/cli": "^
|
|
82
|
-
"@commitlint/cli": "^21.2.
|
|
83
|
-
"@commitlint/config-conventional": "^21.2.
|
|
84
|
-
"@redocly/cli": "^2.
|
|
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": "^
|
|
88
|
-
"@vitest/coverage-v8": "^4.1.
|
|
89
|
-
"husky": "^9.1.
|
|
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.
|
|
92
|
-
"lint-staged": "^17.
|
|
93
|
-
"only-allow": "^1.2.
|
|
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.
|
|
96
|
+
"tsup": "^8.5.1",
|
|
97
97
|
"typedoc": "^0.28.20",
|
|
98
98
|
"typescript": "^6.0.3",
|
|
99
|
-
"vitest": "^4.1.
|
|
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",
|
package/skills/client/SKILL.md
CHANGED
|
@@ -65,9 +65,9 @@ Every request gets:
|
|
|
65
65
|
- Otherwise it waits `250 * attempt` ms (so 250, 500 between the first three attempts).
|
|
66
66
|
- Other 4xx errors are NOT retried — they throw `MedalApiError` immediately.
|
|
67
67
|
- Network errors (fetch throws) are NOT retried — they bubble up.
|
|
68
|
-
- The request is aborted via `AbortController` after `timeout` ms.
|
|
68
|
+
- 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
69
|
|
|
70
|
-
The SDK
|
|
70
|
+
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
71
|
|
|
72
72
|
## Errors
|
|
73
73
|
|
|
@@ -1,16 +1,18 @@
|
|
|
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, 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`.
|
|
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.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).
|
|
15
17
|
- Importing contacts from a CSV-like source.
|
|
16
18
|
- Needing OpenAPI-derived types for a custom fetch wrapper, generated mocks, or contract tests.
|
|
@@ -22,7 +24,10 @@ Most methods return one of:
|
|
|
22
24
|
- **`ApiResponse<T>`** — single-result envelope: `{ data: T }` plus optional metadata.
|
|
23
25
|
- **`PaginatedResponse<T>`** — list envelope: `{ data: T[], pagination: { has_more: boolean, next_cursor: string | null } }`.
|
|
24
26
|
|
|
25
|
-
**
|
|
27
|
+
**Two documented deviations:**
|
|
28
|
+
|
|
29
|
+
- `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.
|
|
30
|
+
- `medal.bookings.list(opts?)` returns `BookingsPage`, not `PaginatedResponse<Booking>` — its `pagination` carries an extra `truncated: boolean`. See the Bookings section below.
|
|
26
31
|
|
|
27
32
|
Errors throw `MedalApiError` (see the `client` skill for details).
|
|
28
33
|
|
|
@@ -30,6 +35,8 @@ Errors throw `MedalApiError` (see the `client` skill for details).
|
|
|
30
35
|
|
|
31
36
|
| Namespace | Source | Methods |
|
|
32
37
|
|---|---|---|
|
|
38
|
+
| `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
|
+
| `medal.bookings.manage` | `src/resources/bookings.ts` (`BookingsManage`) | `get(token)`, `cancel(token, input?, opts?)`, `reschedule(token, input, opts?)` — **customer** semantics (policy windows enforced) |
|
|
33
40
|
| `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
41
|
| `medal.deals` | `src/resources/deals.ts` | `list(opts?)`, `create(input)`, `get(id)`, `update(id, input)`, `remove(id)` |
|
|
35
42
|
| `medal.emails.templates` | `src/resources/emails.ts` (`EmailTemplates`) | `list()`, `get(slug, opts?)` |
|
|
@@ -90,6 +97,92 @@ await medal.posts.publish(post.id);
|
|
|
90
97
|
|
|
91
98
|
`channels()` is the canonical way to discover what publishing destinations a workspace has connected — don't hard-code channel IDs.
|
|
92
99
|
|
|
100
|
+
## Bookings — catalogue, slots, and the two cancel/reschedule semantics
|
|
101
|
+
|
|
102
|
+
**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.
|
|
103
|
+
|
|
104
|
+
**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.
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
const { data: services } = await medal.bookings.listServices(); // active-only
|
|
108
|
+
const { data: all } = await medal.bookings.listServices({ include_inactive: true });
|
|
109
|
+
const { data: resources } = await medal.bookings.listResources(); // staff / rooms / equipment
|
|
110
|
+
|
|
111
|
+
// Free slots — service_id, from_ts and to_ts are all REQUIRED; to_ts must be after from_ts
|
|
112
|
+
const { data: slots } = await medal.bookings.availability({
|
|
113
|
+
service_id: services[0].id,
|
|
114
|
+
from_ts: Date.now(),
|
|
115
|
+
to_ts: Date.now() + 7 * 86_400_000,
|
|
116
|
+
resource_id: resources[0].id, // optional
|
|
117
|
+
});
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
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.
|
|
121
|
+
|
|
122
|
+
**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()`:
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
const { data: days } = await medal.bookings.schedule({
|
|
126
|
+
service_id: services[0].id,
|
|
127
|
+
from_ts: Date.now(),
|
|
128
|
+
to_ts: Date.now() + 7 * 86_400_000,
|
|
129
|
+
});
|
|
130
|
+
// A date ABSENT from `days` is closed. On a listed date, `last_start_ts` is the last
|
|
131
|
+
// start THIS service could occupy (duration + buffers, not the closing time) — compare
|
|
132
|
+
// it against the clock to tell "too late today" from "fully booked"; it is `null` on a
|
|
133
|
+
// date with posted hours that is shut outright (a public holiday).
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
**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.
|
|
137
|
+
|
|
138
|
+
```ts
|
|
139
|
+
const { data } = await medal.bookings.create(
|
|
140
|
+
{
|
|
141
|
+
items: [{ service_id: services[0].id, start_ts: slots[0].start_ts! }],
|
|
142
|
+
contact: { phone: '+4790000000', name: 'Ida' }, // phone is the CRM dedupe key and is required
|
|
143
|
+
created_via: 'web', // only from the workspace's OWN site; omit (=> 'api') from integrations
|
|
144
|
+
},
|
|
145
|
+
{ idempotencyKey: crypto.randomUUID() },
|
|
146
|
+
);
|
|
147
|
+
data.bookings[0].manage_token; // SHOW-ONCE
|
|
148
|
+
data.contact_id;
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
`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.
|
|
152
|
+
|
|
153
|
+
`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`.
|
|
154
|
+
|
|
155
|
+
**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.
|
|
156
|
+
|
|
157
|
+
**The two semantics are picked by which namespace you call, not by an argument:**
|
|
158
|
+
|
|
159
|
+
| | `medal.bookings.cancel(id)` / `.reschedule(id, …)` | `medal.bookings.manage.cancel(token)` / `.reschedule(token, …)` |
|
|
160
|
+
|---|---|---|
|
|
161
|
+
| Who is acting | the business (your API key **is** the salon) | the customer, relayed by you |
|
|
162
|
+
| Policy windows | **bypassed** | **enforced** |
|
|
163
|
+
| Cancel attributed to | `staff` | `customer` |
|
|
164
|
+
| Addressed by | booking id | manage token |
|
|
165
|
+
|
|
166
|
+
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.
|
|
167
|
+
|
|
168
|
+
```ts
|
|
169
|
+
const { data: summary } = await medal.bookings.manage.get(manageToken);
|
|
170
|
+
if (summary.can_cancel) await medal.bookings.manage.cancel(manageToken, { reason: 'Endret plan' });
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
`can_cancel` / `can_reschedule` already apply the windows — honour them instead of re-deriving from `cancel_window_hours` and `start_ts`.
|
|
174
|
+
|
|
175
|
+
**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.
|
|
176
|
+
|
|
177
|
+
**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.
|
|
178
|
+
|
|
179
|
+
```ts
|
|
180
|
+
const page = await medal.bookings.list({ status: 'confirmed', from_ts: Date.now(), limit: 50 });
|
|
181
|
+
if (page.pagination.truncated) { /* window too wide — split the range */ }
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
`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.
|
|
185
|
+
|
|
93
186
|
## Emails — transactional + batch
|
|
94
187
|
|
|
95
188
|
**Single send (HTTP 202 — queued, not delivered):**
|
|
@@ -239,4 +332,12 @@ The `with { type: "json" }` import-attribute syntax requires Node 24+ or a bundl
|
|
|
239
332
|
| `contacts.import` with > 500 contacts | API rejects | Chunk into 500-contact batches yourself |
|
|
240
333
|
| Hard-coded channel IDs in `posts.create` | Channels are workspace-specific | Call `posts.channels()` to discover them |
|
|
241
334
|
| Looping on `next_cursor` alone for pagination | Can be non-null when `has_more: false` | Loop on `pagination.has_more` |
|
|
335
|
+
| `medal.bookings.cancel(id)` to relay a customer's cancel | Bypasses the policy window and records `cancelled_by: 'staff'` | `medal.bookings.manage.cancel(token)` |
|
|
336
|
+
| 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 |
|
|
337
|
+
| 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` |
|
|
338
|
+
| 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 |
|
|
339
|
+
| 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 |
|
|
340
|
+
| `medal.bookings.update(id, {})` | Rejected by the API; now also a compile error | Pass at least one of `notes` / `internal_notes` |
|
|
341
|
+
| Ignoring `pagination.truncated` on `bookings.list` | Matching bookings exist that no cursor reaches | Narrow `from_ts`/`to_ts` and page again |
|
|
342
|
+
| 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 |
|
|
242
343
|
| Building a custom client when only types are needed | Reinventing the wheel | Import from `@medalsocial/sdk/openapi-types` |
|