@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
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/** What a key is allowed to reach. A key carries one or more of these. */
|
|
2
|
+
export type ApiScope = 'catalog:read' | 'events:read' | 'orders:write' | 'contacts:write' | 'forms:write';
|
|
3
|
+
export declare const ALL_API_SCOPES: readonly ApiScope[];
|
|
4
|
+
/** Everything an endpoint can be told about. A delivery names one. */
|
|
5
|
+
export type ApiWebhookEvent = 'product.changed' | 'product.deleted' | 'event.changed' | 'order.status_changed';
|
|
6
|
+
export declare const ALL_API_WEBHOOK_EVENTS: readonly ApiWebhookEvent[];
|
|
7
|
+
/** The `error.code` of a refusal. Match on this, never on the message -- the message is written for a person reading a log and may be reworded. */
|
|
8
|
+
export type ApiErrorCode = 'unauthorized' | 'forbidden' | 'feature_unavailable' | 'not_found' | 'invalid_request' | 'idempotency_key_reused' | 'request_in_progress' | 'rate_limited' | 'internal_error';
|
|
9
|
+
export declare const ALL_API_ERROR_CODES: readonly ApiErrorCode[];
|
|
10
|
+
/**
|
|
11
|
+
* One product as a stranger's website sees it.
|
|
12
|
+
*
|
|
13
|
+
* These interfaces are the public API's contract, not internal convenience
|
|
14
|
+
* types. They are served cross-origin to pages nobody here controls, and a
|
|
15
|
+
* customer's shop reads these exact keys — so a field removed or renamed breaks
|
|
16
|
+
* a site that will not be redeployed. Add fields; do not repurpose them.
|
|
17
|
+
*
|
|
18
|
+
* What is deliberately absent is as much the contract as what is here.
|
|
19
|
+
*
|
|
20
|
+
* - **`sku`** is the number this business uses to find the thing in its own
|
|
21
|
+
* stockroom. It says how many suppliers there are, which one a line came
|
|
22
|
+
* from, and often what was paid. `gtin` is the number printed on the box and
|
|
23
|
+
* is offered instead, in the detail: that one is meant to be public, and a
|
|
24
|
+
* shop needs it for a product feed.
|
|
25
|
+
* - **The stock count** never leaves. `inStock` answers the only question a
|
|
26
|
+
* visitor has, and the number itself is a business fact a competitor would
|
|
27
|
+
* like and a cached answer would get wrong within the minute.
|
|
28
|
+
* - **`unit_price_cents` raw** is not it either; see `priceFromCents`.
|
|
29
|
+
* - **Non-public custom fields.** `fields` carries only attributes a member
|
|
30
|
+
* marked `is_public`, which defaults to off. A field called "purchase price"
|
|
31
|
+
* stays home unless somebody says otherwise, once, per field.
|
|
32
|
+
*
|
|
33
|
+
* And one field that an event has and a product does not: **`url`**. An event
|
|
34
|
+
* is sold on a page this system hosts, so its listing can hand out a link. A
|
|
35
|
+
* product is sold on the customer's own site — that is the entire reason this
|
|
36
|
+
* API exists — and this system does not know what they called their product
|
|
37
|
+
* page. A guessed link is worse than none.
|
|
38
|
+
*/
|
|
39
|
+
export interface PublicProductListItem {
|
|
40
|
+
id: string;
|
|
41
|
+
/** Always present: a product without an address cannot be published. */
|
|
42
|
+
slug: string;
|
|
43
|
+
name: string;
|
|
44
|
+
productType: 'digital' | 'physical';
|
|
45
|
+
/**
|
|
46
|
+
* The storage path, not a URL. The route turns it into a public URL, because
|
|
47
|
+
* only the route knows the origin its own storage is served from.
|
|
48
|
+
*/
|
|
49
|
+
imagePath: string | null;
|
|
50
|
+
/**
|
|
51
|
+
* The cheapest way in, over the active price options — not the default one.
|
|
52
|
+
* A card that says "from €12" next to a product whose entry price is €12 and
|
|
53
|
+
* whose default is the €40 yearly plan is answering the question the visitor
|
|
54
|
+
* actually asked.
|
|
55
|
+
*
|
|
56
|
+
* Never null, unlike the same field on an event. An event without a ticket
|
|
57
|
+
* tier on sale genuinely has no price; a product always has `unit_price_cents`
|
|
58
|
+
* on its own row, so when no separate price option is active that column is
|
|
59
|
+
* the answer — including when it is `0`, which is a real price for something
|
|
60
|
+
* given away.
|
|
61
|
+
*/
|
|
62
|
+
priceFromCents: number;
|
|
63
|
+
/** The struck-through price beside it, when the seller set one. */
|
|
64
|
+
compareAtCents: number | null;
|
|
65
|
+
currency: string;
|
|
66
|
+
/**
|
|
67
|
+
* Whether a visitor can buy it right now — never how many are left.
|
|
68
|
+
*
|
|
69
|
+
* True whenever the seller does not track stock at all, which is the common
|
|
70
|
+
* case and the honest answer for anything made to order. When they do track
|
|
71
|
+
* it, this counts what is on the shelf minus what other people already hold
|
|
72
|
+
* in a checkout, and it stays true for a seller who accepts backorders.
|
|
73
|
+
*/
|
|
74
|
+
inStock: boolean;
|
|
75
|
+
/**
|
|
76
|
+
* The custom fields this account invented, keyed by the slug they chose, and
|
|
77
|
+
* only the ones marked public. An empty object when none are.
|
|
78
|
+
*/
|
|
79
|
+
fields: Record<string, unknown>;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* The same product on its own page, where more may be shown.
|
|
83
|
+
*
|
|
84
|
+
* The detail answers for things the listing hides — an unlisted product, one
|
|
85
|
+
* reachable by anyone holding the link — for the same reason the events detail
|
|
86
|
+
* answers for a cancelled evening: somebody was sent here on purpose, and a 404
|
|
87
|
+
* would be a lie. What it still refuses is anything unpublished.
|
|
88
|
+
*/
|
|
89
|
+
export interface PublicProductDetail extends PublicProductListItem {
|
|
90
|
+
/** The one-line text that lands on a quote or an invoice row. */
|
|
91
|
+
description: string | null;
|
|
92
|
+
/** The long story, as sanitised HTML. Written by the seller, for this page. */
|
|
93
|
+
longDescription: string | null;
|
|
94
|
+
/** The barcode number: printed on the box, wanted by every product feed. */
|
|
95
|
+
gtin: string | null;
|
|
96
|
+
metaTitle: string | null;
|
|
97
|
+
metaDescription: string | null;
|
|
98
|
+
images: PublicProductImage[];
|
|
99
|
+
prices: PublicProductPrice[];
|
|
100
|
+
categories: PublicProductCategory[];
|
|
101
|
+
/** What the seller linked this to — a companion, a refill, a bigger model. */
|
|
102
|
+
related: PublicProductSummary[];
|
|
103
|
+
}
|
|
104
|
+
export interface PublicProductImage {
|
|
105
|
+
/** A path, turned into a URL by the route. Same reason as `imagePath`. */
|
|
106
|
+
path: string;
|
|
107
|
+
alt: string | null;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* One way to buy the thing.
|
|
111
|
+
*
|
|
112
|
+
* `stripe_price_id` and `stripe_account_id` are absent and must stay absent:
|
|
113
|
+
* they name objects in the seller's payment account, and a checkout is started
|
|
114
|
+
* through this system's own order route, never by a stranger quoting an id.
|
|
115
|
+
*/
|
|
116
|
+
export interface PublicProductPrice {
|
|
117
|
+
id: string;
|
|
118
|
+
kind: 'one_off' | 'recurring' | 'installment';
|
|
119
|
+
label: string | null;
|
|
120
|
+
amountCents: number;
|
|
121
|
+
/** What the whole thing costs when paid in parts; null for the other kinds. */
|
|
122
|
+
totalCents: number | null;
|
|
123
|
+
installmentCount: number | null;
|
|
124
|
+
recurringInterval: 'day' | 'week' | 'month' | 'year' | null;
|
|
125
|
+
recurringIntervalCount: number | null;
|
|
126
|
+
compareAtCents: number | null;
|
|
127
|
+
trialPeriodDays: number | null;
|
|
128
|
+
isDefault: boolean;
|
|
129
|
+
}
|
|
130
|
+
export interface PublicProductCategory {
|
|
131
|
+
id: string;
|
|
132
|
+
name: string;
|
|
133
|
+
}
|
|
134
|
+
export interface PublicProductSummary {
|
|
135
|
+
id: string;
|
|
136
|
+
slug: string;
|
|
137
|
+
name: string;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* One event as a stranger's website sees it.
|
|
141
|
+
*
|
|
142
|
+
* This interface is the public API's contract, not an internal convenience
|
|
143
|
+
* type. It is served cross-origin to pages nobody here controls, and a widget
|
|
144
|
+
* on a customer's homepage reads these exact keys — so a field removed or
|
|
145
|
+
* renamed breaks a site that will not be redeployed. Add fields; do not
|
|
146
|
+
* repurpose them.
|
|
147
|
+
*
|
|
148
|
+
* What is deliberately absent is as much the contract as what is here: no
|
|
149
|
+
* description (a paragraph no card renders), no joining link (that is
|
|
150
|
+
* admission), no counts of who bought what, and nothing an organiser uses to
|
|
151
|
+
* manage the event.
|
|
152
|
+
*/
|
|
153
|
+
export interface PublicEventListItem {
|
|
154
|
+
id: string;
|
|
155
|
+
slug: string;
|
|
156
|
+
title: string;
|
|
157
|
+
subtitle: string | null;
|
|
158
|
+
startsAt: string;
|
|
159
|
+
endsAt: string | null;
|
|
160
|
+
timezone: string;
|
|
161
|
+
locationName: string | null;
|
|
162
|
+
/**
|
|
163
|
+
* The storage path, not a URL. The route turns it into a public URL, because
|
|
164
|
+
* only the route knows the origin its own storage is served from.
|
|
165
|
+
*/
|
|
166
|
+
coverImagePath: string | null;
|
|
167
|
+
/** Null when nothing is on sale — never 0, which is a real price. */
|
|
168
|
+
priceFromCents: number | null;
|
|
169
|
+
currency: string | null;
|
|
170
|
+
soldOut: boolean;
|
|
171
|
+
/** Null when the event has no ceiling to count down from. */
|
|
172
|
+
seatsLeft: number | null;
|
|
173
|
+
/** Path on the hosted site, so a widget can link straight to checkout. */
|
|
174
|
+
url: string;
|
|
175
|
+
/**
|
|
176
|
+
* The custom fields this account invented, keyed by the slug they chose, and
|
|
177
|
+
* only the ones marked public. An empty object when none are, which is every
|
|
178
|
+
* account until somebody turns one on.
|
|
179
|
+
*
|
|
180
|
+
* Added rather than repurposed, which is the rule this interface states at
|
|
181
|
+
* the top: a widget written before this field existed keeps working, because
|
|
182
|
+
* it simply never reads the key.
|
|
183
|
+
*/
|
|
184
|
+
fields: Record<string, unknown>;
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* One event as the public API hands it out, on its own page.
|
|
188
|
+
*
|
|
189
|
+
* Not the same thing as `PublicEventView`, which feeds the detail page this
|
|
190
|
+
* system hosts — and the difference is the reason this type exists rather than
|
|
191
|
+
* the other one being reused. That view carries `online_url`: the link you join
|
|
192
|
+
* the evening by. On a page we host, behind a ticket, that is correct. Through
|
|
193
|
+
* an API that answers any holder of a read key, it is a free seat.
|
|
194
|
+
*
|
|
195
|
+
* It also carries `account_id`, which is ours and not the caller's business.
|
|
196
|
+
*
|
|
197
|
+
* So this is a separate, narrower projection, and everything in
|
|
198
|
+
* `PublicEventListItem` about adding rather than repurposing fields applies
|
|
199
|
+
* here too.
|
|
200
|
+
*/
|
|
201
|
+
export interface PublicEventDetailItem extends PublicEventListItem {
|
|
202
|
+
/** The organiser's own text for the evening. */
|
|
203
|
+
description: string | null;
|
|
204
|
+
/**
|
|
205
|
+
* `on_sale`, `closed` or `cancelled` — never `draft`, which does not answer
|
|
206
|
+
* at all. A ticket holder has to be able to read that the evening is off, so
|
|
207
|
+
* this route replies for the last two where the listing withholds them.
|
|
208
|
+
*/
|
|
209
|
+
status: 'on_sale' | 'closed' | 'cancelled';
|
|
210
|
+
doorsOpenAt: string | null;
|
|
211
|
+
salesStartAt: string | null;
|
|
212
|
+
salesEndAt: string | null;
|
|
213
|
+
/** The postal address, as the organiser entered it. */
|
|
214
|
+
address: unknown;
|
|
215
|
+
metaTitle: string | null;
|
|
216
|
+
metaDescription: string | null;
|
|
217
|
+
ticketTypes: PublicEventTicketType[];
|
|
218
|
+
/** Custom fields the account marked public, keyed by their own slug. */
|
|
219
|
+
fields: Record<string, unknown>;
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* One way in, as a stranger's page may show it.
|
|
223
|
+
*
|
|
224
|
+
* `reserved_count` and `sold_count` are absent: how many were sold is the
|
|
225
|
+
* organiser's figure, and `seatsLeft` already answers the only question a
|
|
226
|
+
* visitor has. The capacity behind it stays home for the same reason a stock
|
|
227
|
+
* count does.
|
|
228
|
+
*/
|
|
229
|
+
export interface PublicEventTicketType {
|
|
230
|
+
id: string;
|
|
231
|
+
name: string;
|
|
232
|
+
description: string | null;
|
|
233
|
+
priceCents: number;
|
|
234
|
+
currency: string;
|
|
235
|
+
taxPercentage: number | null;
|
|
236
|
+
minPerOrder: number | null;
|
|
237
|
+
maxPerOrder: number | null;
|
|
238
|
+
salesStartAt: string | null;
|
|
239
|
+
salesEndAt: string | null;
|
|
240
|
+
/** Null when this tier has no ceiling to count down from. */
|
|
241
|
+
seatsLeft: number | null;
|
|
242
|
+
soldOut: boolean;
|
|
243
|
+
onSale: boolean;
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* The body of one delivery.
|
|
247
|
+
*
|
|
248
|
+
* IDS AND NOTHING ELSE, and the three reasons are worth keeping together. A
|
|
249
|
+
* message that arrives late still leads to the right state, because the
|
|
250
|
+
* receiver fetches the current row rather than trusting a snapshot from twenty
|
|
251
|
+
* minutes ago. A message can never hand out something the receiving key was
|
|
252
|
+
* not allowed to read, because it hands out nothing. And five hundred changes
|
|
253
|
+
* fit in a body of a few kilobytes instead of a few megabytes.
|
|
254
|
+
*
|
|
255
|
+
* `truncated` means the batch stopped collecting at its ceiling and `ids` is
|
|
256
|
+
* therefore incomplete: the receiver should refetch the whole collection. It
|
|
257
|
+
* is always present rather than only when true, so a receiver that reads it
|
|
258
|
+
* cannot mistake "absent" for "false" in the one direction that matters.
|
|
259
|
+
*/
|
|
260
|
+
export interface ApiWebhookPayload {
|
|
261
|
+
type: ApiWebhookEvent;
|
|
262
|
+
occurredAt: string;
|
|
263
|
+
ids: string[];
|
|
264
|
+
truncated: boolean;
|
|
265
|
+
}
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
ALL_API_ERROR_CODES: () => ALL_API_ERROR_CODES,
|
|
24
|
+
ALL_API_SCOPES: () => ALL_API_SCOPES,
|
|
25
|
+
ALL_API_WEBHOOK_EVENTS: () => ALL_API_WEBHOOK_EVENTS,
|
|
26
|
+
CrmApiError: () => CrmApiError,
|
|
27
|
+
createClient: () => createClient,
|
|
28
|
+
eventTag: () => eventTag,
|
|
29
|
+
eventsTag: () => eventsTag,
|
|
30
|
+
productTag: () => productTag,
|
|
31
|
+
productsTag: () => productsTag,
|
|
32
|
+
tagsForDelivery: () => tagsForDelivery
|
|
33
|
+
});
|
|
34
|
+
module.exports = __toCommonJS(index_exports);
|
|
35
|
+
|
|
36
|
+
// src/errors.ts
|
|
37
|
+
var CrmApiError = class extends Error {
|
|
38
|
+
constructor(input) {
|
|
39
|
+
super(input.message);
|
|
40
|
+
this.name = "CrmApiError";
|
|
41
|
+
this.code = input.code;
|
|
42
|
+
this.status = input.status;
|
|
43
|
+
this.fields = input.fields ?? [];
|
|
44
|
+
this.requestUrl = input.requestUrl;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Whether trying the same request again could plausibly work.
|
|
48
|
+
*
|
|
49
|
+
* A rate limit clears and a server error may be a blip; a rejected key and a
|
|
50
|
+
* malformed body will be refused just as firmly the second time. Write
|
|
51
|
+
* requests should be retried with the *same* idempotency key, which this
|
|
52
|
+
* client fills in for you, so a retry after a timeout cannot become a second
|
|
53
|
+
* order.
|
|
54
|
+
*/
|
|
55
|
+
get retryable() {
|
|
56
|
+
return this.code === "rate_limited" || this.code === "internal_error";
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// src/refusal.ts
|
|
61
|
+
async function readJson(response) {
|
|
62
|
+
try {
|
|
63
|
+
return await response.json();
|
|
64
|
+
} catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function refusalOf(status, body, requestUrl) {
|
|
69
|
+
const structured = body?.error;
|
|
70
|
+
if (structured && typeof structured === "object" && structured.code) {
|
|
71
|
+
return new CrmApiError({
|
|
72
|
+
code: structured.code,
|
|
73
|
+
message: structured.message ?? structured.code,
|
|
74
|
+
status,
|
|
75
|
+
fields: structured.fields ?? [],
|
|
76
|
+
requestUrl
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
const plain = body?.error;
|
|
80
|
+
return new CrmApiError({
|
|
81
|
+
code: codeForStatus(status),
|
|
82
|
+
message: typeof plain === "string" ? plain : `The request failed with ${status}.`,
|
|
83
|
+
status,
|
|
84
|
+
requestUrl
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
function codeForStatus(status) {
|
|
88
|
+
switch (status) {
|
|
89
|
+
case 400:
|
|
90
|
+
return "invalid_request";
|
|
91
|
+
case 401:
|
|
92
|
+
return "unauthorized";
|
|
93
|
+
case 403:
|
|
94
|
+
return "forbidden";
|
|
95
|
+
case 404:
|
|
96
|
+
return "not_found";
|
|
97
|
+
case 409:
|
|
98
|
+
return "request_in_progress";
|
|
99
|
+
case 429:
|
|
100
|
+
return "rate_limited";
|
|
101
|
+
default:
|
|
102
|
+
return "internal_error";
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// src/tags.ts
|
|
107
|
+
var PREFIX = "crm";
|
|
108
|
+
function productsTag() {
|
|
109
|
+
return `${PREFIX}:products`;
|
|
110
|
+
}
|
|
111
|
+
function productTag(slugOrId) {
|
|
112
|
+
return `${PREFIX}:product:${slugOrId}`;
|
|
113
|
+
}
|
|
114
|
+
function eventsTag() {
|
|
115
|
+
return `${PREFIX}:events`;
|
|
116
|
+
}
|
|
117
|
+
function eventTag(idOrSlug) {
|
|
118
|
+
return `${PREFIX}:event:${idOrSlug}`;
|
|
119
|
+
}
|
|
120
|
+
function tagsForDelivery(input) {
|
|
121
|
+
const collection = input.type.startsWith("product.") ? productsTag() : input.type.startsWith("event.") ? eventsTag() : null;
|
|
122
|
+
if (collection === null) {
|
|
123
|
+
return [];
|
|
124
|
+
}
|
|
125
|
+
if (input.truncated) {
|
|
126
|
+
return [collection];
|
|
127
|
+
}
|
|
128
|
+
const item = input.type.startsWith("product.") ? productTag : eventTag;
|
|
129
|
+
return [collection, ...input.ids.map((id) => item(id))];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// src/client.ts
|
|
133
|
+
var KEY_FORM = /^crm[ps]_[A-Za-z0-9_-]{43}$/;
|
|
134
|
+
var SECRET_PREFIX = "crms_";
|
|
135
|
+
var BASE_PATH = "/api/public/v1";
|
|
136
|
+
var DEFAULT_REVALIDATE = 60;
|
|
137
|
+
function createClient(options) {
|
|
138
|
+
const apiKey = options.apiKey?.trim() ?? "";
|
|
139
|
+
if (!KEY_FORM.test(apiKey)) {
|
|
140
|
+
throw new Error(
|
|
141
|
+
"crm-client: the API key is not in the expected form. It starts with `crmp_` or `crms_` and is 48 characters long \u2014 a truncated paste looks exactly like a revoked key once it reaches the server."
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
if (apiKey.startsWith(SECRET_PREFIX) && inSomethingTheUserCanRead()) {
|
|
145
|
+
throw new Error(
|
|
146
|
+
"crm-client: a `crms_` key may not be used in a browser. It can write orders and read contacts, and anything a browser holds is public \u2014 including a service worker or a web worker, which is why this is refused there too. Read from a server component or a route handler, or issue a `crmp_` key for the parts of the catalogue a page reads directly."
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
const base = options.baseUrl?.replace(/\/+$/, "") ?? "";
|
|
150
|
+
if (!/^https?:\/\/[^/]+$/.test(base)) {
|
|
151
|
+
throw new Error(
|
|
152
|
+
`crm-client: baseUrl should be an origin and nothing more, such as "https://app.example.com". Received "${options.baseUrl}".`
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
const doFetch = options.fetch ?? ((input, init) => fetch(input, init));
|
|
156
|
+
const revalidate = options.revalidate ?? DEFAULT_REVALIDATE;
|
|
157
|
+
async function send(path, init, unwrap, notFoundIsNull) {
|
|
158
|
+
const url = `${base}${BASE_PATH}${path}`;
|
|
159
|
+
const response = await doFetch(url, {
|
|
160
|
+
...init,
|
|
161
|
+
headers: {
|
|
162
|
+
accept: "application/json",
|
|
163
|
+
authorization: `Bearer ${apiKey}`,
|
|
164
|
+
...init.headers
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
const body = await readJson(response);
|
|
168
|
+
if (!response.ok) {
|
|
169
|
+
const refusal = refusalOf(response.status, body, url);
|
|
170
|
+
if (notFoundIsNull && refusal.code === "not_found") {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
throw refusal;
|
|
174
|
+
}
|
|
175
|
+
const answer = unwrap(body);
|
|
176
|
+
if (answer === void 0 || answer === null) {
|
|
177
|
+
throw new CrmApiError({
|
|
178
|
+
code: "internal_error",
|
|
179
|
+
message: "The API answered successfully with a body this client did not recognise. Either something between here and it replaced the answer, or this package is older than the route it called.",
|
|
180
|
+
status: response.status,
|
|
181
|
+
requestUrl: url
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
return answer;
|
|
185
|
+
}
|
|
186
|
+
function cached(tags) {
|
|
187
|
+
return { method: "GET", next: { tags, revalidate } };
|
|
188
|
+
}
|
|
189
|
+
const live = { method: "GET", cache: "no-store" };
|
|
190
|
+
function write(body, options2) {
|
|
191
|
+
return {
|
|
192
|
+
method: "POST",
|
|
193
|
+
cache: "no-store",
|
|
194
|
+
headers: {
|
|
195
|
+
"content-type": "application/json",
|
|
196
|
+
"idempotency-key": options2?.idempotencyKey ?? newIdempotencyKey()
|
|
197
|
+
},
|
|
198
|
+
body: JSON.stringify(body)
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
return {
|
|
202
|
+
async getProducts(listOptions) {
|
|
203
|
+
const query = new URLSearchParams();
|
|
204
|
+
if (listOptions?.limit !== void 0) {
|
|
205
|
+
query.set("limit", `${listOptions.limit}`);
|
|
206
|
+
}
|
|
207
|
+
if (listOptions?.cursor) {
|
|
208
|
+
query.set("cursor", listOptions.cursor);
|
|
209
|
+
}
|
|
210
|
+
if (listOptions?.category) {
|
|
211
|
+
query.set("category", listOptions.category);
|
|
212
|
+
}
|
|
213
|
+
const result = await send(
|
|
214
|
+
`/products${suffix(query)}`,
|
|
215
|
+
cached([productsTag()]),
|
|
216
|
+
(body) => listOrNothing(body, "data"),
|
|
217
|
+
false
|
|
218
|
+
);
|
|
219
|
+
return result;
|
|
220
|
+
},
|
|
221
|
+
getProduct(slugOrId) {
|
|
222
|
+
return send(
|
|
223
|
+
`/products/${encodeURIComponent(slugOrId)}`,
|
|
224
|
+
cached([productsTag(), productTag(slugOrId)]),
|
|
225
|
+
(body) => dataOf(body),
|
|
226
|
+
true
|
|
227
|
+
);
|
|
228
|
+
},
|
|
229
|
+
getAvailability(slugOrId) {
|
|
230
|
+
return send(
|
|
231
|
+
`/products/${encodeURIComponent(slugOrId)}/availability`,
|
|
232
|
+
live,
|
|
233
|
+
(body) => dataOf(body),
|
|
234
|
+
true
|
|
235
|
+
);
|
|
236
|
+
},
|
|
237
|
+
async getEvents(listOptions) {
|
|
238
|
+
const query = new URLSearchParams();
|
|
239
|
+
if (listOptions?.limit !== void 0) {
|
|
240
|
+
query.set("limit", `${listOptions.limit}`);
|
|
241
|
+
}
|
|
242
|
+
if (listOptions?.cursor) {
|
|
243
|
+
query.set("cursor", listOptions.cursor);
|
|
244
|
+
}
|
|
245
|
+
if (listOptions?.from) {
|
|
246
|
+
query.set("from", listOptions.from);
|
|
247
|
+
}
|
|
248
|
+
if (listOptions?.to) {
|
|
249
|
+
query.set("to", listOptions.to);
|
|
250
|
+
}
|
|
251
|
+
const result = await send(
|
|
252
|
+
`/events${suffix(query)}`,
|
|
253
|
+
cached([eventsTag()]),
|
|
254
|
+
(body) => listOrNothing(body, "events"),
|
|
255
|
+
false
|
|
256
|
+
);
|
|
257
|
+
return result;
|
|
258
|
+
},
|
|
259
|
+
getEvent(idOrSlug) {
|
|
260
|
+
return send(
|
|
261
|
+
`/events/${encodeURIComponent(idOrSlug)}`,
|
|
262
|
+
cached([eventsTag(), eventTag(idOrSlug)]),
|
|
263
|
+
(body) => dataOf(body),
|
|
264
|
+
true
|
|
265
|
+
);
|
|
266
|
+
},
|
|
267
|
+
getEventAvailability(eventId) {
|
|
268
|
+
return send(
|
|
269
|
+
`/events/${encodeURIComponent(eventId)}/availability`,
|
|
270
|
+
live,
|
|
271
|
+
(body) => body,
|
|
272
|
+
true
|
|
273
|
+
);
|
|
274
|
+
},
|
|
275
|
+
async submitOrder(input, writeOptions) {
|
|
276
|
+
const result = await send(
|
|
277
|
+
"/orders",
|
|
278
|
+
write(input, writeOptions),
|
|
279
|
+
(body) => dataOf(body),
|
|
280
|
+
false
|
|
281
|
+
);
|
|
282
|
+
return result;
|
|
283
|
+
},
|
|
284
|
+
async upsertContact(input, writeOptions) {
|
|
285
|
+
const result = await send(
|
|
286
|
+
"/contacts",
|
|
287
|
+
write(input, writeOptions),
|
|
288
|
+
(body) => dataOf(body),
|
|
289
|
+
false
|
|
290
|
+
);
|
|
291
|
+
return result;
|
|
292
|
+
},
|
|
293
|
+
async submitRequest(input, writeOptions) {
|
|
294
|
+
const result = await send(
|
|
295
|
+
"/requests",
|
|
296
|
+
write(input, writeOptions),
|
|
297
|
+
(body) => dataOf(body),
|
|
298
|
+
false
|
|
299
|
+
);
|
|
300
|
+
return result;
|
|
301
|
+
},
|
|
302
|
+
async submitForm(formId, input, writeOptions) {
|
|
303
|
+
const result = await send(
|
|
304
|
+
`/forms/${encodeURIComponent(formId)}/submissions`,
|
|
305
|
+
write(input, writeOptions),
|
|
306
|
+
(body) => dataOf(body),
|
|
307
|
+
false
|
|
308
|
+
);
|
|
309
|
+
return result;
|
|
310
|
+
}
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
function suffix(query) {
|
|
314
|
+
const rendered = query.toString();
|
|
315
|
+
return rendered ? `?${rendered}` : "";
|
|
316
|
+
}
|
|
317
|
+
function dataOf(body) {
|
|
318
|
+
return body?.data;
|
|
319
|
+
}
|
|
320
|
+
function listOrNothing(body, key) {
|
|
321
|
+
const listing = body;
|
|
322
|
+
return Array.isArray(listing?.[key]) ? listing : void 0;
|
|
323
|
+
}
|
|
324
|
+
function newIdempotencyKey() {
|
|
325
|
+
const source = globalThis.crypto;
|
|
326
|
+
if (typeof source?.randomUUID === "function") {
|
|
327
|
+
return source.randomUUID();
|
|
328
|
+
}
|
|
329
|
+
if (typeof source?.getRandomValues === "function") {
|
|
330
|
+
const bytes = source.getRandomValues(new Uint8Array(16));
|
|
331
|
+
return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
332
|
+
}
|
|
333
|
+
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}`;
|
|
334
|
+
}
|
|
335
|
+
function inSomethingTheUserCanRead() {
|
|
336
|
+
if (typeof window !== "undefined") {
|
|
337
|
+
return true;
|
|
338
|
+
}
|
|
339
|
+
const scope = globalThis;
|
|
340
|
+
return typeof scope.importScripts === "function";
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// src/generated/api-types.ts
|
|
344
|
+
var ALL_API_SCOPES = [
|
|
345
|
+
"catalog:read",
|
|
346
|
+
"events:read",
|
|
347
|
+
"orders:write",
|
|
348
|
+
"contacts:write",
|
|
349
|
+
"forms:write"
|
|
350
|
+
];
|
|
351
|
+
var ALL_API_WEBHOOK_EVENTS = [
|
|
352
|
+
"product.changed",
|
|
353
|
+
"product.deleted",
|
|
354
|
+
"event.changed",
|
|
355
|
+
"order.status_changed"
|
|
356
|
+
];
|
|
357
|
+
var ALL_API_ERROR_CODES = [
|
|
358
|
+
"unauthorized",
|
|
359
|
+
"forbidden",
|
|
360
|
+
"feature_unavailable",
|
|
361
|
+
"not_found",
|
|
362
|
+
"invalid_request",
|
|
363
|
+
"idempotency_key_reused",
|
|
364
|
+
"request_in_progress",
|
|
365
|
+
"rate_limited",
|
|
366
|
+
"internal_error"
|
|
367
|
+
];
|
|
368
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
369
|
+
0 && (module.exports = {
|
|
370
|
+
ALL_API_ERROR_CODES,
|
|
371
|
+
ALL_API_SCOPES,
|
|
372
|
+
ALL_API_WEBHOOK_EVENTS,
|
|
373
|
+
CrmApiError,
|
|
374
|
+
createClient,
|
|
375
|
+
eventTag,
|
|
376
|
+
eventsTag,
|
|
377
|
+
productTag,
|
|
378
|
+
productsTag,
|
|
379
|
+
tagsForDelivery
|
|
380
|
+
});
|
|
381
|
+
//# sourceMappingURL=index.cjs.map
|