@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 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.
@@ -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 {};
@@ -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
+ }