@solumflow-app/crm-client 0.1.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/LICENSE +21 -0
- package/README.md +359 -0
- package/dist/client.d.ts +102 -0
- package/dist/errors.d.ts +50 -0
- package/dist/generated/api-types.d.ts +265 -0
- package/dist/index.cjs +381 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +32 -0
- package/dist/index.js +345 -0
- package/dist/index.js.map +1 -0
- package/dist/mirror.cjs +127 -0
- package/dist/mirror.cjs.map +1 -0
- package/dist/mirror.d.ts +95 -0
- package/dist/mirror.js +102 -0
- package/dist/mirror.js.map +1 -0
- package/dist/refusal.d.ts +18 -0
- package/dist/tags.d.ts +18 -0
- package/dist/types.d.ts +172 -0
- package/dist/webhooks.cjs +180 -0
- package/dist/webhooks.cjs.map +1 -0
- package/dist/webhooks.d.ts +122 -0
- package/dist/webhooks.js +141 -0
- package/dist/webhooks.js.map +1 -0
- package/package.json +61 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ascensie
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
# @solumflow-app/crm-client
|
|
2
|
+
|
|
3
|
+
Read a CRM catalogue and send orders back to it, from your own website.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
// lib/crm.ts
|
|
7
|
+
import { createClient } from '@solumflow-app/crm-client';
|
|
8
|
+
|
|
9
|
+
export const crm = createClient({
|
|
10
|
+
apiKey: process.env.CRM_API_KEY!,
|
|
11
|
+
baseUrl: process.env.CRM_BASE_URL!, // https://app.example.com — origin only
|
|
12
|
+
});
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
```tsx
|
|
16
|
+
// app/shop/page.tsx
|
|
17
|
+
const { data } = await crm.getProducts({ limit: 24 });
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
That is the whole of it for a shop that lists products. The rest of this file is
|
|
21
|
+
the parts that bite.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## The two kinds of reading function, and why the names differ
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
const { data } = await crm.getProducts(); // cached, may be a minute old
|
|
29
|
+
const product = await crm.getProduct('eiken-tafel'); // cached
|
|
30
|
+
const stock = await crm.getAvailability(product.id); // live, never cached
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`getProducts` and `getProduct` are cached at the edge and filed under tags the
|
|
34
|
+
revalidation route below knows how to clear. `getAvailability` is not cached
|
|
35
|
+
anywhere and never will be.
|
|
36
|
+
|
|
37
|
+
Reach for the live one for anything a visitor would notice was wrong. Stock is
|
|
38
|
+
the example: a cached "in stock" is the first thing to go stale and the most
|
|
39
|
+
expensive when it does. Nothing in the type system will warn you — the split is
|
|
40
|
+
in the name because that is the only place it could be.
|
|
41
|
+
|
|
42
|
+
Detail lookups answer `null` when there is no such thing, so a slug somebody
|
|
43
|
+
typed wrong is an ordinary outcome:
|
|
44
|
+
|
|
45
|
+
```tsx
|
|
46
|
+
const product = await crm.getProduct(params.slug);
|
|
47
|
+
|
|
48
|
+
if (!product) notFound();
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Everything else — a rejected key, a missing permission, a rate limit — throws a
|
|
52
|
+
`CrmApiError`. That difference is deliberate: if a bad key also produced `null`,
|
|
53
|
+
a misconfigured site would render "not found" on every page for ever.
|
|
54
|
+
|
|
55
|
+
## Two kinds of key
|
|
56
|
+
|
|
57
|
+
| Prefix | Where it may live | What it may do |
|
|
58
|
+
| --- | --- | --- |
|
|
59
|
+
| `crmp_` | anywhere, including a browser bundle | read the catalogue |
|
|
60
|
+
| `crms_` | a server, and nowhere else | read and write |
|
|
61
|
+
|
|
62
|
+
A `crms_` key can create orders and resolve contacts. This client throws on
|
|
63
|
+
construction if it finds one in a browser, so that ends up as an error on the
|
|
64
|
+
first render rather than as a key in a JavaScript bundle anyone can open.
|
|
65
|
+
|
|
66
|
+
Issue keys in the CRM under **Settings → Integrations → API keys**. The value is
|
|
67
|
+
shown once.
|
|
68
|
+
|
|
69
|
+
## Keeping a cached shop from showing yesterday's prices
|
|
70
|
+
|
|
71
|
+
Two steps.
|
|
72
|
+
|
|
73
|
+
**1. Add the route.** One line, and the signature check comes with it:
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
// app/api/crm/revalidate/route.ts
|
|
77
|
+
import { createRevalidateRoute } from '@solumflow-app/crm-client/webhooks';
|
|
78
|
+
|
|
79
|
+
export const POST = createRevalidateRoute({
|
|
80
|
+
signingKey: process.env.CRM_WEBHOOK_KEY!,
|
|
81
|
+
});
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
**2. Register the endpoint** in the CRM under **Settings → Integrations →
|
|
85
|
+
Webhooks**: its URL, the events it wants, and the signing key it shows you once.
|
|
86
|
+
|
|
87
|
+
That is it. A change to a product clears `crm:products` and `crm:product:<id>`,
|
|
88
|
+
and the next request to a page built from either fetches fresh.
|
|
89
|
+
|
|
90
|
+
Some things worth knowing about what arrives:
|
|
91
|
+
|
|
92
|
+
- **A delivery carries ids and nothing else.** No product data. That is on
|
|
93
|
+
purpose — a message that arrives late still leads to the right state, because
|
|
94
|
+
your site fetches the current row rather than trusting a snapshot.
|
|
95
|
+
- **The same delivery can arrive twice.** Retries reuse the `webhook-id` header,
|
|
96
|
+
so that is the thing to remember if you do something in `onEvent` that is not
|
|
97
|
+
safe to repeat.
|
|
98
|
+
- **Deliveries are not ordered.** Always write current state, never a difference.
|
|
99
|
+
|
|
100
|
+
If you want to do something beyond clearing tags — an order changing status is
|
|
101
|
+
the usual one — `onEvent` is handed every verified delivery:
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
export const POST = createRevalidateRoute({
|
|
105
|
+
signingKey: process.env.CRM_WEBHOOK_KEY!,
|
|
106
|
+
onEvent: async ({ payload, messageId }) => {
|
|
107
|
+
if (payload.type === 'order.status_changed') {
|
|
108
|
+
await noteOrderChanged(messageId, payload.ids);
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### Checking a signature yourself
|
|
115
|
+
|
|
116
|
+
If you would rather write the route, at least do not write the check:
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
import { verifyWebhook } from '@solumflow-app/crm-client/webhooks';
|
|
120
|
+
|
|
121
|
+
const body = await request.text(); // the raw bytes, before any parsing
|
|
122
|
+
|
|
123
|
+
const verdict = verifyWebhook({
|
|
124
|
+
signingKey: process.env.CRM_WEBHOOK_KEY!,
|
|
125
|
+
headers: {
|
|
126
|
+
'webhook-id': request.headers.get('webhook-id') ?? undefined,
|
|
127
|
+
'webhook-timestamp': request.headers.get('webhook-timestamp') ?? undefined,
|
|
128
|
+
'webhook-signature': request.headers.get('webhook-signature') ?? undefined,
|
|
129
|
+
},
|
|
130
|
+
payload: body,
|
|
131
|
+
});
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
**Read the body as text first.** `JSON.parse` followed by `JSON.stringify`
|
|
135
|
+
changes the bytes, and the signature is over the bytes. Parsing first gives you
|
|
136
|
+
`bad_signature` on every delivery and sends you looking at your key.
|
|
137
|
+
|
|
138
|
+
### Two answers a receiver should get right
|
|
139
|
+
|
|
140
|
+
| Verdict | Answer | Why |
|
|
141
|
+
| --- | --- | --- |
|
|
142
|
+
| `bad_signature`, `missing_headers`, `malformed_key` | **401** | The sender never retries a 401 and parks the delivery immediately. Correct: a rotated key will be just as wrong in thirty seconds. |
|
|
143
|
+
| `stale_timestamp` | **400** | Every attempt is signed afresh, so a delivery that queued behind a slow one gets through next time. A 401 here would bury it and make a slow queue look like a key problem. |
|
|
144
|
+
|
|
145
|
+
## Writing back
|
|
146
|
+
|
|
147
|
+
```ts
|
|
148
|
+
const order = await crm.submitOrder({
|
|
149
|
+
buyer: { email: 'koper@example.com', firstName: 'Ada' },
|
|
150
|
+
lines: [{ productId, quantity: 2 }],
|
|
151
|
+
address: { line1: 'Dorpsstraat 1', postalCode: '1234 AB', city: 'Utrecht', countryCode: 'NL' },
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// hand order.payment.clientSecret to Stripe, on order.payment.stripeAccount
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
There is no amount in that call and none is accepted. The total is worked out on
|
|
158
|
+
the server from the seller's own price rows — a shop where the browser may state
|
|
159
|
+
the price is a shop where the customer types it.
|
|
160
|
+
|
|
161
|
+
Also available: `upsertContact` (a newsletter sign-up, a back-in-stock notice),
|
|
162
|
+
`submitRequest` (a question or a quote request, no amount) and `submitForm`.
|
|
163
|
+
|
|
164
|
+
### Retrying safely
|
|
165
|
+
|
|
166
|
+
Every write carries an `Idempotency-Key`, generated for you. **If you retry, send
|
|
167
|
+
the key you used the first time:**
|
|
168
|
+
|
|
169
|
+
```ts
|
|
170
|
+
const key = crypto.randomUUID();
|
|
171
|
+
|
|
172
|
+
try {
|
|
173
|
+
return await crm.submitOrder(input, { idempotencyKey: key });
|
|
174
|
+
} catch (error) {
|
|
175
|
+
if (error instanceof CrmApiError && error.retryable) {
|
|
176
|
+
return await crm.submitOrder(input, { idempotencyKey: key }); // same key
|
|
177
|
+
}
|
|
178
|
+
throw error;
|
|
179
|
+
}
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
A site that timed out does not know whether the order arrived. Sending it again
|
|
183
|
+
under a *new* key is how one customer gets charged twice.
|
|
184
|
+
|
|
185
|
+
## When something is refused
|
|
186
|
+
|
|
187
|
+
```ts
|
|
188
|
+
import { CrmApiError } from '@solumflow-app/crm-client';
|
|
189
|
+
|
|
190
|
+
try {
|
|
191
|
+
await crm.submitOrder(input);
|
|
192
|
+
} catch (error) {
|
|
193
|
+
if (error instanceof CrmApiError) {
|
|
194
|
+
error.code; // 'invalid_request' | 'unauthorized' | …
|
|
195
|
+
error.fields; // [{ path: 'buyer.email', message: 'Required' }]
|
|
196
|
+
error.retryable; // true for a rate limit or a server error
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
Match on `code`, never on `message` — the message is written for somebody
|
|
202
|
+
reading a log and gets reworded.
|
|
203
|
+
|
|
204
|
+
Three codes say less than you might want, on purpose:
|
|
205
|
+
|
|
206
|
+
- **`unauthorized`** means the key was not accepted and will not say whether it
|
|
207
|
+
is missing, mistyped, revoked or expired. The alternative is an endpoint that
|
|
208
|
+
tells a stranger whether a key they guessed exists. If you are sure the key is
|
|
209
|
+
right, check that you pasted all 48 characters.
|
|
210
|
+
- **`forbidden`** covers both "this key does not carry that permission" and "this
|
|
211
|
+
is a browser-safe key and you asked it to write".
|
|
212
|
+
- **`not_found`** is also the answer for something that exists but belongs to
|
|
213
|
+
somebody else.
|
|
214
|
+
|
|
215
|
+
## Mirroring the catalogue into your own CMS
|
|
216
|
+
|
|
217
|
+
If your site keeps its own copy of the products — for editorial fields, for
|
|
218
|
+
search, for a page builder — `@solumflow-app/crm-client/mirror` owns the awkward half
|
|
219
|
+
and leaves you the write:
|
|
220
|
+
|
|
221
|
+
```ts
|
|
222
|
+
import { createCatalogueMirror } from '@solumflow-app/crm-client/mirror';
|
|
223
|
+
import { getPayload } from 'payload';
|
|
224
|
+
import config from '@payload-config';
|
|
225
|
+
|
|
226
|
+
const payload = await getPayload({ config });
|
|
227
|
+
|
|
228
|
+
export const mirror = createCatalogueMirror({
|
|
229
|
+
client: crm,
|
|
230
|
+
target: {
|
|
231
|
+
upsert: async ({ id, product }) => {
|
|
232
|
+
const existing = await payload.find({
|
|
233
|
+
collection: 'products',
|
|
234
|
+
where: { crmId: { equals: id } },
|
|
235
|
+
limit: 1,
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
const data = {
|
|
239
|
+
crmId: id,
|
|
240
|
+
title: product.name,
|
|
241
|
+
slug: product.slug,
|
|
242
|
+
priceInEUR: product.priceFromCents,
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
if (existing.docs[0]) {
|
|
246
|
+
await payload.update({ collection: 'products', id: existing.docs[0].id, data });
|
|
247
|
+
} else {
|
|
248
|
+
await payload.create({ collection: 'products', data });
|
|
249
|
+
}
|
|
250
|
+
},
|
|
251
|
+
remove: async ({ id }) => {
|
|
252
|
+
await payload.delete({
|
|
253
|
+
collection: 'products',
|
|
254
|
+
where: { crmId: { equals: id } },
|
|
255
|
+
});
|
|
256
|
+
},
|
|
257
|
+
},
|
|
258
|
+
});
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
Then hand it your deliveries:
|
|
262
|
+
|
|
263
|
+
```ts
|
|
264
|
+
export const POST = createRevalidateRoute({
|
|
265
|
+
signingKey: process.env.CRM_WEBHOOK_KEY!,
|
|
266
|
+
onEvent: ({ payload }) => mirror.apply(payload),
|
|
267
|
+
});
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
Why the mapping is yours to write: the local collection's shape is not something
|
|
271
|
+
a package could guess. A Payload shop built on `@payloadcms/plugin-ecommerce`
|
|
272
|
+
stores `title`, `priceInEUR` and a `gallery` of uploads; this catalogue answers
|
|
273
|
+
`name`, `priceFromCents` and `images` of URLs. The next shop names them
|
|
274
|
+
differently again. What *is* the same everywhere is everything around it, and
|
|
275
|
+
that is what the mirror does:
|
|
276
|
+
|
|
277
|
+
- A product that has stopped answering is **removed**, not skipped. Moving
|
|
278
|
+
something back to draft deletes nothing, so it arrives as `product.changed`
|
|
279
|
+
for an id that then refuses to answer.
|
|
280
|
+
- A **truncated** delivery means the id list is a fragment, and the mirror walks
|
|
281
|
+
the whole catalogue instead of acting on part of it.
|
|
282
|
+
- `mirror.syncAll()` is there for a first import and for a scheduled repair.
|
|
283
|
+
Supply `listMirroredIds` if you also want it to take out what the catalogue no
|
|
284
|
+
longer has; without it, a resync adds and updates but never removes.
|
|
285
|
+
- **Check `report.failed`.** An id that could not be fetched — a rate limit, a
|
|
286
|
+
five-hundred — is reported rather than thrown, so the rest of the batch still
|
|
287
|
+
lands. An empty `failed` is the only outcome that means the mirror is now
|
|
288
|
+
correct; anything else wants a retry, and the ids to retry are in it.
|
|
289
|
+
A `syncAll` that could not read everything **removes nothing at all**, because
|
|
290
|
+
"what the catalogue no longer has" is a subtraction and a hole in it takes out
|
|
291
|
+
live products.
|
|
292
|
+
|
|
293
|
+
One Payload-specific note: `payload.update`/`payload.delete` with a `where`
|
|
294
|
+
answer `{ docs, errors }` rather than throwing, so a row that fails is in
|
|
295
|
+
`errors` and easy to miss. And if you do a bulk import, set
|
|
296
|
+
`req.context.disableRevalidate` or every row will fire your collection's own
|
|
297
|
+
revalidation hooks.
|
|
298
|
+
|
|
299
|
+
## Cache tags
|
|
300
|
+
|
|
301
|
+
| Tag | Carried by |
|
|
302
|
+
| --- | --- |
|
|
303
|
+
| `crm:products` | every product read, listing and detail alike |
|
|
304
|
+
| `crm:product:<id or slug>` | `getProduct`, under the key you asked with |
|
|
305
|
+
| `crm:events` | every event read |
|
|
306
|
+
| `crm:event:<id or slug>` | `getEvent` |
|
|
307
|
+
|
|
308
|
+
Detail reads carry the collection tag as well as their own, and that is not belt
|
|
309
|
+
and braces. A delivery names ids; a product page is usually fetched by *slug*;
|
|
310
|
+
nothing on this side can turn one into the other. Without the collection tag a
|
|
311
|
+
slug-fetched page would never update — and would keep working while showing the
|
|
312
|
+
old price, which is exactly the failure this package exists to prevent.
|
|
313
|
+
|
|
314
|
+
Exported as functions, so you can clear them yourself:
|
|
315
|
+
|
|
316
|
+
```ts
|
|
317
|
+
import { productTag, productsTag } from '@solumflow-app/crm-client';
|
|
318
|
+
|
|
319
|
+
revalidateTag(productTag(id), { expire: 0 });
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
## Options
|
|
323
|
+
|
|
324
|
+
```ts
|
|
325
|
+
createClient({
|
|
326
|
+
apiKey,
|
|
327
|
+
baseUrl,
|
|
328
|
+
revalidate: 60, // seconds a cached answer may be served; `false` = only on a webhook
|
|
329
|
+
fetch, // your own, for instrumentation or tests
|
|
330
|
+
});
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
`revalidate: false` is right for a shop whose revalidation route is wired up and
|
|
334
|
+
reachable, and wrong for one where it is not — then nothing ever expires at all.
|
|
335
|
+
|
|
336
|
+
`createRevalidateRoute` takes a `profile` too. It defaults to `{ expire: 0 }`,
|
|
337
|
+
which expires the entry outright so the next visitor waits and never sees the
|
|
338
|
+
old answer. Pass `'max'` for stale-while-revalidate if you have a large
|
|
339
|
+
catalogue and have decided that showing one stale price per page is acceptable.
|
|
340
|
+
|
|
341
|
+
## What is deliberately not here
|
|
342
|
+
|
|
343
|
+
- **No product URL.** An event is sold on a page the CRM hosts and carries a
|
|
344
|
+
link; a product is sold on *your* site, and the CRM does not know what you
|
|
345
|
+
called that page. A guessed link is worse than none.
|
|
346
|
+
- **No `sku` and no stock count.** The first says who supplies a business and
|
|
347
|
+
often what it paid; the second is a figure a competitor would like. `gtin` is
|
|
348
|
+
offered instead, on the detail, because that one is printed on the box.
|
|
349
|
+
- **No non-public custom fields.** `fields` carries only attributes somebody
|
|
350
|
+
marked public in the CRM, one at a time, and that switch is off by default.
|
|
351
|
+
|
|
352
|
+
## Requirements
|
|
353
|
+
|
|
354
|
+
Node 20 or newer. `next` is an optional peer dependency, needed only by
|
|
355
|
+
`createRevalidateRoute` when you let it reach for `revalidateTag` itself.
|
|
356
|
+
|
|
357
|
+
## Licence
|
|
358
|
+
|
|
359
|
+
MIT. The licence covers this package only, not the CRM it talks to.
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { ContactInput, ContactResult, EventAvailability, EventDetail, EventList, FormSubmissionInput, FormSubmissionResult, OrderInput, OrderResult, ProductDetail, ProductList, RequestInput, RequestResult, StockStatus } from './types';
|
|
2
|
+
/**
|
|
3
|
+
* The fetch options this package sets that are not in the web standard.
|
|
4
|
+
*
|
|
5
|
+
* `next` is read by Next.js and ignored by every other runtime, which is
|
|
6
|
+
* exactly the behaviour wanted: the tags do nothing outside a framework that
|
|
7
|
+
* caches, and nothing breaks in one that does not.
|
|
8
|
+
*/
|
|
9
|
+
type CachedInit = RequestInit & {
|
|
10
|
+
next?: {
|
|
11
|
+
tags?: string[];
|
|
12
|
+
revalidate?: number | false;
|
|
13
|
+
};
|
|
14
|
+
};
|
|
15
|
+
export type FetchLike = (input: string, init?: CachedInit) => Promise<Response>;
|
|
16
|
+
export interface CrmClientOptions {
|
|
17
|
+
/**
|
|
18
|
+
* The key, whole.
|
|
19
|
+
*
|
|
20
|
+
* A `crmp_` key may be read by anyone who views source and may only read; a
|
|
21
|
+
* `crms_` key may write and must never leave a server. This client refuses a
|
|
22
|
+
* `crms_` key outright when it finds itself in a browser — see the note on
|
|
23
|
+
* `createClient`.
|
|
24
|
+
*/
|
|
25
|
+
apiKey: string;
|
|
26
|
+
/**
|
|
27
|
+
* Where the CRM lives: an origin, with no path on it.
|
|
28
|
+
*
|
|
29
|
+
* `https://app.example.com`, not `https://app.example.com/api/public/v1`.
|
|
30
|
+
* The version lives in this package so that a shop upgrading the package is
|
|
31
|
+
* the thing that moves it, rather than a string in someone's environment
|
|
32
|
+
* file that nobody remembers to change.
|
|
33
|
+
*/
|
|
34
|
+
baseUrl: string;
|
|
35
|
+
/**
|
|
36
|
+
* How long a cached answer may be served before it is refetched, in seconds.
|
|
37
|
+
*
|
|
38
|
+
* Defaults to a minute. `false` means never on a timer — only when a webhook
|
|
39
|
+
* clears the tag. That is the right setting for a shop whose revalidation
|
|
40
|
+
* route is wired up and reachable, and the wrong one for a shop where it is
|
|
41
|
+
* not, because then nothing ever expires at all.
|
|
42
|
+
*/
|
|
43
|
+
revalidate?: number | false;
|
|
44
|
+
/** For tests and for runtimes with their own instrumented fetch. */
|
|
45
|
+
fetch?: FetchLike;
|
|
46
|
+
}
|
|
47
|
+
export interface ListOptions {
|
|
48
|
+
limit?: number;
|
|
49
|
+
cursor?: string | null;
|
|
50
|
+
}
|
|
51
|
+
export interface ProductListOptions extends ListOptions {
|
|
52
|
+
/** A category id. Passing something that is not a uuid is refused, loudly. */
|
|
53
|
+
category?: string;
|
|
54
|
+
}
|
|
55
|
+
export interface EventListOptions extends ListOptions {
|
|
56
|
+
/** ISO timestamps. Both ends are optional and both are inclusive. */
|
|
57
|
+
from?: string;
|
|
58
|
+
to?: string;
|
|
59
|
+
}
|
|
60
|
+
export interface WriteOptions {
|
|
61
|
+
/**
|
|
62
|
+
* The key that makes a retry safe.
|
|
63
|
+
*
|
|
64
|
+
* Generated per call when you leave it out, which is right for a first
|
|
65
|
+
* attempt and wrong for a retry: a website that timed out does not know
|
|
66
|
+
* whether the order arrived, and sending it again under a *new* key is how
|
|
67
|
+
* one customer gets charged twice. Keep the key you used and resend with it.
|
|
68
|
+
*/
|
|
69
|
+
idempotencyKey?: string;
|
|
70
|
+
}
|
|
71
|
+
export interface CrmClient {
|
|
72
|
+
getProducts(options?: ProductListOptions): Promise<ProductList>;
|
|
73
|
+
getProduct(slugOrId: string): Promise<ProductDetail | null>;
|
|
74
|
+
/** Live. Never cached, at any layer. */
|
|
75
|
+
getAvailability(slugOrId: string): Promise<StockStatus | null>;
|
|
76
|
+
getEvents(options?: EventListOptions): Promise<EventList>;
|
|
77
|
+
getEvent(idOrSlug: string): Promise<EventDetail | null>;
|
|
78
|
+
/** Live. Never cached, at any layer. */
|
|
79
|
+
getEventAvailability(eventId: string): Promise<EventAvailability | null>;
|
|
80
|
+
submitOrder(input: OrderInput, options?: WriteOptions): Promise<OrderResult>;
|
|
81
|
+
upsertContact(input: ContactInput, options?: WriteOptions): Promise<ContactResult>;
|
|
82
|
+
submitRequest(input: RequestInput, options?: WriteOptions): Promise<RequestResult>;
|
|
83
|
+
submitForm(formId: string, input: FormSubmissionInput, options?: WriteOptions): Promise<FormSubmissionResult>;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* A client for one account's public API.
|
|
87
|
+
*
|
|
88
|
+
* Two layers, and the names are the whole warning. `getProducts` and
|
|
89
|
+
* `getProduct` are cached at the edge and filed under tags the shipped
|
|
90
|
+
* revalidation route knows how to clear. `getAvailability` is not cached
|
|
91
|
+
* anywhere and never will be — a stock figure in a cached answer is the number
|
|
92
|
+
* that lies first and lies worst, and a developer who reaches for `getProduct`
|
|
93
|
+
* to show "in stock" gets no warning from the type system, so the split has to
|
|
94
|
+
* be in the name.
|
|
95
|
+
*
|
|
96
|
+
* The browser check is not decoration either. A `crms_` key can create orders
|
|
97
|
+
* and read contacts; pasted into a client component it ends up in a JavaScript
|
|
98
|
+
* bundle that anybody can open. Throwing at construction turns that into a
|
|
99
|
+
* build-time failure on the first render instead of a quiet leak.
|
|
100
|
+
*/
|
|
101
|
+
export declare function createClient(options: CrmClientOptions): CrmClient;
|
|
102
|
+
export {};
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { ApiErrorCode } from './generated/api-types';
|
|
2
|
+
/**
|
|
3
|
+
* A refusal from the API, with the machine-readable reason kept.
|
|
4
|
+
*
|
|
5
|
+
* Match on `code`, never on `message`. The code is a closed set that this
|
|
6
|
+
* package generates from the server's own list; the message is written for
|
|
7
|
+
* somebody reading a log and gets reworded.
|
|
8
|
+
*
|
|
9
|
+
* Three of the codes are deliberately vaguer than they could be, and knowing
|
|
10
|
+
* that saves an afternoon:
|
|
11
|
+
*
|
|
12
|
+
* - `unauthorized` means the key was not accepted, and says nothing about
|
|
13
|
+
* which of "missing", "mistyped", "revoked" or "expired" applies. That is on
|
|
14
|
+
* purpose — the alternative is an endpoint that tells a stranger whether a
|
|
15
|
+
* key they guessed exists.
|
|
16
|
+
* - `forbidden` covers both "this key does not carry that permission" and "this
|
|
17
|
+
* is a browser-safe key and you asked it to write", for the same reason.
|
|
18
|
+
* - `not_found` is also the answer for something that exists but belongs to
|
|
19
|
+
* somebody else. A shop cannot tell the two apart, and should not be able to.
|
|
20
|
+
*/
|
|
21
|
+
export declare class CrmApiError extends Error {
|
|
22
|
+
readonly code: ApiErrorCode;
|
|
23
|
+
readonly status: number;
|
|
24
|
+
readonly fields: {
|
|
25
|
+
path: string;
|
|
26
|
+
message: string;
|
|
27
|
+
}[];
|
|
28
|
+
/** Present when the API refused before the request reached a handler. */
|
|
29
|
+
readonly requestUrl: string;
|
|
30
|
+
constructor(input: {
|
|
31
|
+
code: ApiErrorCode;
|
|
32
|
+
message: string;
|
|
33
|
+
status: number;
|
|
34
|
+
fields?: {
|
|
35
|
+
path: string;
|
|
36
|
+
message: string;
|
|
37
|
+
}[];
|
|
38
|
+
requestUrl: string;
|
|
39
|
+
});
|
|
40
|
+
/**
|
|
41
|
+
* Whether trying the same request again could plausibly work.
|
|
42
|
+
*
|
|
43
|
+
* A rate limit clears and a server error may be a blip; a rejected key and a
|
|
44
|
+
* malformed body will be refused just as firmly the second time. Write
|
|
45
|
+
* requests should be retried with the *same* idempotency key, which this
|
|
46
|
+
* client fills in for you, so a retry after a timeout cannot become a second
|
|
47
|
+
* order.
|
|
48
|
+
*/
|
|
49
|
+
get retryable(): boolean;
|
|
50
|
+
}
|