@blamejs/blamejs-shop 0.0.60 → 0.0.62
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/CHANGELOG.md +4 -0
- package/lib/carrier-rates.js +683 -0
- package/lib/cart-bulk-ops.js +711 -0
- package/lib/cms-blocks.js +651 -0
- package/lib/code-minter.js +535 -0
- package/lib/customer-import.js +590 -0
- package/lib/discount-analytics.js +548 -0
- package/lib/dunning.js +700 -0
- package/lib/email-campaigns.js +844 -0
- package/lib/geolocation.js +651 -0
- package/lib/gift-card-ledger.js +483 -0
- package/lib/gift-registry.js +820 -0
- package/lib/index.js +21 -0
- package/lib/loyalty-redemption.js +673 -0
- package/lib/operator-audit-log.js +621 -0
- package/lib/plan-changes.js +508 -0
- package/lib/refund-policy.js +965 -0
- package/lib/search-facets.js +825 -0
- package/lib/sms-dispatcher.js +951 -0
- package/lib/stock-transfers.js +777 -0
- package/lib/storefront-dashboards.js +863 -0
- package/lib/storefront-forms.js +884 -0
- package/lib/vendors.js +797 -0
- package/package.json +1 -1
|
@@ -0,0 +1,884 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module shop.storefrontForms
|
|
4
|
+
* @title Storefront forms — operator-defined contact / lead-capture
|
|
5
|
+
*
|
|
6
|
+
* @intro
|
|
7
|
+
* Operator-defined contact / lead-capture / "request a quote" /
|
|
8
|
+
* "wholesale application" forms. The operator declares the field
|
|
9
|
+
* shape + a submit destination; customers submit values; the
|
|
10
|
+
* primitive validates each field against its declared kind,
|
|
11
|
+
* refuses missing required fields, and dispatches the captured
|
|
12
|
+
* values through the injected `notifications` (email) or
|
|
13
|
+
* `webhooks` (webhook) dep.
|
|
14
|
+
*
|
|
15
|
+
* Each form carries:
|
|
16
|
+
* - a stable URL-friendly `slug` (the PK + the storefront route
|
|
17
|
+
* fragment under `/forms/<slug>`),
|
|
18
|
+
* - a `title` shown in the page header,
|
|
19
|
+
* - an optional `description` (one paragraph of operator copy),
|
|
20
|
+
* - a `fields` array — each `{ name, kind, required, label,
|
|
21
|
+
* options? }` — drawing `kind` from a closed enum
|
|
22
|
+
* (`text / email / phone / textarea / select / checkbox /
|
|
23
|
+
* number`),
|
|
24
|
+
* - a `submit_to` target `{ kind, value }` — `kind` is `email`
|
|
25
|
+
* or `webhook`, `value` is the recipient address or the
|
|
26
|
+
* webhook event name,
|
|
27
|
+
* - a `success_message` rendered after a successful submit,
|
|
28
|
+
* - an optional `throttle_per_minute_per_session` rate limit
|
|
29
|
+
* (defaults to 5; 0 disables the throttle).
|
|
30
|
+
*
|
|
31
|
+
* Field-validation surface (per-kind):
|
|
32
|
+
* text — non-empty string ≤ 1024 chars, no control bytes
|
|
33
|
+
* email — `b.guardEmail.validate(..., profile: "strict")`
|
|
34
|
+
* phone — digits / spaces / dashes / `+` / `(` / `)`,
|
|
35
|
+
* ≤ 32 chars
|
|
36
|
+
* textarea — non-empty string ≤ 8192 chars, LF allowed,
|
|
37
|
+
* no NUL / DEL
|
|
38
|
+
* select — string that appears verbatim in the field's
|
|
39
|
+
* declared `options` list
|
|
40
|
+
* checkbox — strict boolean (`true` / `false`)
|
|
41
|
+
* number — finite number (integer or decimal); rejects
|
|
42
|
+
* NaN / Infinity / non-numeric strings
|
|
43
|
+
*
|
|
44
|
+
* Throttle behavior — `throttleCheck(...)` counts rows in
|
|
45
|
+
* `storefront_form_submissions` over the last 60s for the same
|
|
46
|
+
* `(form_slug, session_id_hash)` tuple. `submit(...)` calls
|
|
47
|
+
* `throttleCheck(...)` before persisting; over-limit submits are
|
|
48
|
+
* refused with `{ ok: false, error: "throttled" }` and no row is
|
|
49
|
+
* written.
|
|
50
|
+
*
|
|
51
|
+
* Session-id privacy — the raw session id never lands in the
|
|
52
|
+
* table. The primitive hashes it through
|
|
53
|
+
* `b.crypto.namespaceHash("storefront-form-session", ...)` before
|
|
54
|
+
* it reaches `session_id_hash`, so a DB compromise can't unmask
|
|
55
|
+
* visitor session ids. Callers that already pre-hashed the
|
|
56
|
+
* session id can pass `session_id_hash` directly instead of
|
|
57
|
+
* `session_id` (the throttle window still works either way).
|
|
58
|
+
*
|
|
59
|
+
* Dispatch — `submit(...)` validates first, persists the
|
|
60
|
+
* submission row, THEN dispatches. A dispatch failure is recorded
|
|
61
|
+
* in `dispatch_error` on the submission row (drop-silent — the
|
|
62
|
+
* submission still landed; the operator reviews failed-dispatch
|
|
63
|
+
* rows offline). If no dispatch dep is injected, the row is
|
|
64
|
+
* written with `dispatched_at = NULL` and the call returns
|
|
65
|
+
* `{ ok: true, id, dispatched: false }`.
|
|
66
|
+
*
|
|
67
|
+
* Composes:
|
|
68
|
+
* - `b.guardEmail.validate` — email field validation
|
|
69
|
+
* - `b.crypto.namespaceHash` — session-id hashing
|
|
70
|
+
* - `b.uuid.v7` — submission row ids
|
|
71
|
+
*
|
|
72
|
+
* @primitive storefrontForms
|
|
73
|
+
* @related notifications, webhooks, b.guardEmail, b.crypto
|
|
74
|
+
*/
|
|
75
|
+
|
|
76
|
+
var MAX_SLUG_LEN = 80;
|
|
77
|
+
var MAX_TITLE_LEN = 200;
|
|
78
|
+
var MAX_DESCRIPTION_LEN = 2000;
|
|
79
|
+
var MAX_FIELD_NAME_LEN = 64;
|
|
80
|
+
var MAX_FIELD_LABEL_LEN = 200;
|
|
81
|
+
var MAX_FIELDS = 32;
|
|
82
|
+
var MAX_OPTIONS_PER_FIELD = 64;
|
|
83
|
+
var MAX_OPTION_LEN = 200;
|
|
84
|
+
var MAX_SUBMIT_TO_VALUE_LEN = 320;
|
|
85
|
+
var MAX_SUCCESS_MESSAGE_LEN = 1000;
|
|
86
|
+
var MAX_TEXT_VALUE_LEN = 1024;
|
|
87
|
+
var MAX_TEXTAREA_VALUE_LEN = 8192;
|
|
88
|
+
var MAX_PHONE_VALUE_LEN = 32;
|
|
89
|
+
var MAX_LIST_LIMIT = 200;
|
|
90
|
+
var DEFAULT_LIST_LIMIT = 50;
|
|
91
|
+
var DEFAULT_THROTTLE_LIMIT = 5;
|
|
92
|
+
var MAX_THROTTLE_LIMIT = 1000;
|
|
93
|
+
var THROTTLE_WINDOW_MS = 60 * 1000;
|
|
94
|
+
|
|
95
|
+
var SESSION_NAMESPACE = "storefront-form-session";
|
|
96
|
+
|
|
97
|
+
var FIELD_KINDS = Object.freeze([
|
|
98
|
+
"text",
|
|
99
|
+
"email",
|
|
100
|
+
"phone",
|
|
101
|
+
"textarea",
|
|
102
|
+
"select",
|
|
103
|
+
"checkbox",
|
|
104
|
+
"number",
|
|
105
|
+
]);
|
|
106
|
+
|
|
107
|
+
var SUBMIT_TO_KINDS = Object.freeze([
|
|
108
|
+
"email",
|
|
109
|
+
"webhook",
|
|
110
|
+
]);
|
|
111
|
+
|
|
112
|
+
var ALLOWED_UPDATE_COLUMNS = Object.freeze([
|
|
113
|
+
"title",
|
|
114
|
+
"description",
|
|
115
|
+
"fields",
|
|
116
|
+
"submit_to",
|
|
117
|
+
"success_message",
|
|
118
|
+
"throttle_per_minute_per_session",
|
|
119
|
+
]);
|
|
120
|
+
|
|
121
|
+
// Slug shape mirrors the rest of the storefront primitives — alnum
|
|
122
|
+
// leading char, alnum + dot + hyphen + underscore tail.
|
|
123
|
+
var SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/;
|
|
124
|
+
|
|
125
|
+
// Field name shape — operator-authored identifier reused as the key
|
|
126
|
+
// in the submitted `values` object. Tight ASCII shape so a
|
|
127
|
+
// surprising character can't smuggle a JSON-pointer / SQL-fragment
|
|
128
|
+
// shape through the submission row.
|
|
129
|
+
var FIELD_NAME_RE = /^[a-z][a-z0-9_]{0,63}$/;
|
|
130
|
+
|
|
131
|
+
// Phone validator — digits / spaces / dashes / plus / parens. Loose
|
|
132
|
+
// on purpose; the operator's downstream CRM normalizes to E.164,
|
|
133
|
+
// the primitive just refuses obvious garbage.
|
|
134
|
+
var PHONE_VALUE_RE = /^[0-9+()\-\s]{1,32}$/;
|
|
135
|
+
|
|
136
|
+
// Webhook event name shape — `domain.action[.qualifier]` segments.
|
|
137
|
+
// Mirrors the webhook-subscriptions event-name shape so an operator
|
|
138
|
+
// can route a form submission through the same dispatch pipeline as
|
|
139
|
+
// any other event.
|
|
140
|
+
var WEBHOOK_EVENT_RE = /^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)*$/;
|
|
141
|
+
|
|
142
|
+
// Refuse C0 control bytes + DEL in single-line strings. Textarea
|
|
143
|
+
// values permit LF (the customer's free-text response is line-
|
|
144
|
+
// oriented).
|
|
145
|
+
var CONTROL_BYTE_LINE_RE = /[\x00-\x1f\x7f]/;
|
|
146
|
+
var CONTROL_BYTE_BLOCK_RE = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/;
|
|
147
|
+
|
|
148
|
+
// Zero-width / direction-override family. Spelled with \u-escapes
|
|
149
|
+
// so ESLint's no-irregular-whitespace stays happy.
|
|
150
|
+
var ZERO_WIDTH_RE = new RegExp(
|
|
151
|
+
"[\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\u2066-\\u2069\\uFEFF\\u061C]"
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
var bShop;
|
|
155
|
+
function _b() {
|
|
156
|
+
if (!bShop) bShop = require("./index");
|
|
157
|
+
return bShop.framework;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ---- validators ---------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
function _slug(s) {
|
|
163
|
+
if (typeof s !== "string" || !SLUG_RE.test(s)) {
|
|
164
|
+
throw new TypeError("storefrontForms: slug must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (≤ " + MAX_SLUG_LEN + " chars)");
|
|
165
|
+
}
|
|
166
|
+
return s;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function _line(s, label, maxLen, optional) {
|
|
170
|
+
if (s == null) {
|
|
171
|
+
if (optional) return null;
|
|
172
|
+
throw new TypeError("storefrontForms: " + label + " is required");
|
|
173
|
+
}
|
|
174
|
+
if (typeof s !== "string" || !s.length || s.length > maxLen) {
|
|
175
|
+
throw new TypeError("storefrontForms: " + label + " must be a non-empty string ≤ " + maxLen + " chars");
|
|
176
|
+
}
|
|
177
|
+
if (CONTROL_BYTE_LINE_RE.test(s)) {
|
|
178
|
+
throw new TypeError("storefrontForms: " + label + " contains control bytes (incl. CR/LF)");
|
|
179
|
+
}
|
|
180
|
+
if (ZERO_WIDTH_RE.test(s)) {
|
|
181
|
+
throw new TypeError("storefrontForms: " + label + " contains zero-width / direction-override characters");
|
|
182
|
+
}
|
|
183
|
+
return s;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function _description(s) {
|
|
187
|
+
if (s == null) return null;
|
|
188
|
+
if (typeof s !== "string" || s.length > MAX_DESCRIPTION_LEN) {
|
|
189
|
+
throw new TypeError("storefrontForms: description must be a string ≤ " + MAX_DESCRIPTION_LEN + " chars");
|
|
190
|
+
}
|
|
191
|
+
if (CONTROL_BYTE_BLOCK_RE.test(s)) {
|
|
192
|
+
throw new TypeError("storefrontForms: description contains control bytes");
|
|
193
|
+
}
|
|
194
|
+
if (ZERO_WIDTH_RE.test(s)) {
|
|
195
|
+
throw new TypeError("storefrontForms: description contains zero-width / direction-override characters");
|
|
196
|
+
}
|
|
197
|
+
return s;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function _fieldName(s) {
|
|
201
|
+
if (typeof s !== "string" || !FIELD_NAME_RE.test(s)) {
|
|
202
|
+
throw new TypeError("storefrontForms: field name must match /^[a-z][a-z0-9_]*$/ (≤ " + MAX_FIELD_NAME_LEN + " chars)");
|
|
203
|
+
}
|
|
204
|
+
return s;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function _fieldKind(s) {
|
|
208
|
+
if (typeof s !== "string" || FIELD_KINDS.indexOf(s) === -1) {
|
|
209
|
+
throw new TypeError("storefrontForms: field kind must be one of " + JSON.stringify(FIELD_KINDS));
|
|
210
|
+
}
|
|
211
|
+
return s;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function _fields(input) {
|
|
215
|
+
if (!Array.isArray(input) || !input.length) {
|
|
216
|
+
throw new TypeError("storefrontForms: fields must be a non-empty array");
|
|
217
|
+
}
|
|
218
|
+
if (input.length > MAX_FIELDS) {
|
|
219
|
+
throw new TypeError("storefrontForms: fields must contain ≤ " + MAX_FIELDS + " entries");
|
|
220
|
+
}
|
|
221
|
+
var seenNames = {};
|
|
222
|
+
var out = [];
|
|
223
|
+
for (var i = 0; i < input.length; i += 1) {
|
|
224
|
+
var f = input[i];
|
|
225
|
+
if (!f || typeof f !== "object") {
|
|
226
|
+
throw new TypeError("storefrontForms: fields[" + i + "] must be an object");
|
|
227
|
+
}
|
|
228
|
+
var name = _fieldName(f.name);
|
|
229
|
+
if (Object.prototype.hasOwnProperty.call(seenNames, name)) {
|
|
230
|
+
throw new TypeError("storefrontForms: duplicate field name " + JSON.stringify(name));
|
|
231
|
+
}
|
|
232
|
+
seenNames[name] = true;
|
|
233
|
+
var kind = _fieldKind(f.kind);
|
|
234
|
+
if (typeof f.required !== "boolean") {
|
|
235
|
+
throw new TypeError("storefrontForms: fields[" + i + "].required must be a boolean");
|
|
236
|
+
}
|
|
237
|
+
var label = _line(f.label, "fields[" + i + "].label", MAX_FIELD_LABEL_LEN);
|
|
238
|
+
var options = null;
|
|
239
|
+
if (kind === "select") {
|
|
240
|
+
if (!Array.isArray(f.options) || !f.options.length) {
|
|
241
|
+
throw new TypeError("storefrontForms: fields[" + i + "] (select) must declare a non-empty options array");
|
|
242
|
+
}
|
|
243
|
+
if (f.options.length > MAX_OPTIONS_PER_FIELD) {
|
|
244
|
+
throw new TypeError("storefrontForms: fields[" + i + "] (select) options must contain ≤ " + MAX_OPTIONS_PER_FIELD + " entries");
|
|
245
|
+
}
|
|
246
|
+
var seenOpts = {};
|
|
247
|
+
options = [];
|
|
248
|
+
for (var j = 0; j < f.options.length; j += 1) {
|
|
249
|
+
var opt = f.options[j];
|
|
250
|
+
if (typeof opt !== "string" || !opt.length || opt.length > MAX_OPTION_LEN) {
|
|
251
|
+
throw new TypeError("storefrontForms: fields[" + i + "].options[" + j + "] must be a non-empty string ≤ " + MAX_OPTION_LEN + " chars");
|
|
252
|
+
}
|
|
253
|
+
if (CONTROL_BYTE_LINE_RE.test(opt) || ZERO_WIDTH_RE.test(opt)) {
|
|
254
|
+
throw new TypeError("storefrontForms: fields[" + i + "].options[" + j + "] contains control / zero-width characters");
|
|
255
|
+
}
|
|
256
|
+
if (Object.prototype.hasOwnProperty.call(seenOpts, opt)) {
|
|
257
|
+
throw new TypeError("storefrontForms: fields[" + i + "].options has duplicate " + JSON.stringify(opt));
|
|
258
|
+
}
|
|
259
|
+
seenOpts[opt] = true;
|
|
260
|
+
options.push(opt);
|
|
261
|
+
}
|
|
262
|
+
} else if (f.options != null) {
|
|
263
|
+
throw new TypeError("storefrontForms: fields[" + i + "].options only valid on kind=select");
|
|
264
|
+
}
|
|
265
|
+
var entry = { name: name, kind: kind, required: f.required, label: label };
|
|
266
|
+
if (options) entry.options = options;
|
|
267
|
+
out.push(entry);
|
|
268
|
+
}
|
|
269
|
+
return out;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function _submitTo(input) {
|
|
273
|
+
if (!input || typeof input !== "object") {
|
|
274
|
+
throw new TypeError("storefrontForms: submit_to must be an object { kind, value }");
|
|
275
|
+
}
|
|
276
|
+
if (typeof input.kind !== "string" || SUBMIT_TO_KINDS.indexOf(input.kind) === -1) {
|
|
277
|
+
throw new TypeError("storefrontForms: submit_to.kind must be one of " + JSON.stringify(SUBMIT_TO_KINDS));
|
|
278
|
+
}
|
|
279
|
+
if (typeof input.value !== "string" || !input.value.length || input.value.length > MAX_SUBMIT_TO_VALUE_LEN) {
|
|
280
|
+
throw new TypeError("storefrontForms: submit_to.value must be a non-empty string ≤ " + MAX_SUBMIT_TO_VALUE_LEN + " chars");
|
|
281
|
+
}
|
|
282
|
+
if (CONTROL_BYTE_LINE_RE.test(input.value) || ZERO_WIDTH_RE.test(input.value)) {
|
|
283
|
+
throw new TypeError("storefrontForms: submit_to.value contains control / zero-width characters");
|
|
284
|
+
}
|
|
285
|
+
if (input.kind === "email") {
|
|
286
|
+
var v = _b().guardEmail.validate(input.value, { profile: "strict" });
|
|
287
|
+
if (!v.ok) {
|
|
288
|
+
var ruleId = v.issues && v.issues[0] && v.issues[0].ruleId || "shape";
|
|
289
|
+
throw new TypeError("storefrontForms: submit_to.value (email) rejected (" + ruleId + ")");
|
|
290
|
+
}
|
|
291
|
+
return { kind: "email", value: _b().guardEmail.sanitize(input.value, { profile: "strict" }) };
|
|
292
|
+
}
|
|
293
|
+
// webhook
|
|
294
|
+
if (!WEBHOOK_EVENT_RE.test(input.value)) {
|
|
295
|
+
throw new TypeError("storefrontForms: submit_to.value (webhook) must match /^[a-z][a-z0-9_]*(\\.[a-z][a-z0-9_]*)*$/");
|
|
296
|
+
}
|
|
297
|
+
return { kind: "webhook", value: input.value };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function _successMessage(s) {
|
|
301
|
+
return _line(s, "success_message", MAX_SUCCESS_MESSAGE_LEN);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function _throttle(n) {
|
|
305
|
+
if (n == null) return DEFAULT_THROTTLE_LIMIT;
|
|
306
|
+
if (!Number.isInteger(n) || n < 0 || n > MAX_THROTTLE_LIMIT) {
|
|
307
|
+
throw new TypeError("storefrontForms: throttle_per_minute_per_session must be a non-negative integer ≤ " + MAX_THROTTLE_LIMIT);
|
|
308
|
+
}
|
|
309
|
+
return n;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function _listLimit(n) {
|
|
313
|
+
if (n == null) return DEFAULT_LIST_LIMIT;
|
|
314
|
+
if (!Number.isInteger(n) || n <= 0 || n > MAX_LIST_LIMIT) {
|
|
315
|
+
throw new TypeError("storefrontForms: limit must be an integer 1.." + MAX_LIST_LIMIT);
|
|
316
|
+
}
|
|
317
|
+
return n;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function _uaClass(s) {
|
|
321
|
+
if (s == null) return null;
|
|
322
|
+
if (typeof s !== "string" || !s.length || s.length > 64) {
|
|
323
|
+
throw new TypeError("storefrontForms: ua_class must be a non-empty string ≤ 64 chars");
|
|
324
|
+
}
|
|
325
|
+
if (!/^[a-z0-9_-]+$/.test(s)) {
|
|
326
|
+
throw new TypeError("storefrontForms: ua_class must match /^[a-z0-9_-]+$/");
|
|
327
|
+
}
|
|
328
|
+
return s;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function _ipHash(s) {
|
|
332
|
+
if (s == null) return null;
|
|
333
|
+
if (typeof s !== "string" || !s.length || s.length > 256) {
|
|
334
|
+
throw new TypeError("storefrontForms: ip_hash must be a non-empty string ≤ 256 chars");
|
|
335
|
+
}
|
|
336
|
+
if (CONTROL_BYTE_LINE_RE.test(s)) {
|
|
337
|
+
throw new TypeError("storefrontForms: ip_hash contains control bytes");
|
|
338
|
+
}
|
|
339
|
+
return s;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function _now() { return Date.now(); }
|
|
343
|
+
|
|
344
|
+
// ---- per-field value validator -----------------------------------------
|
|
345
|
+
//
|
|
346
|
+
// Returns `{ ok: true, value }` on success or `{ ok: false, error }`
|
|
347
|
+
// on failure. The caller aggregates the per-field results into a
|
|
348
|
+
// single submission outcome so the customer sees every failed field
|
|
349
|
+
// at once, not one-at-a-time.
|
|
350
|
+
|
|
351
|
+
function _validateValue(field, raw) {
|
|
352
|
+
// Missing / empty handling. `undefined`, `null`, and `""` are
|
|
353
|
+
// all "missing"; the required gate fires when missing AND
|
|
354
|
+
// required. Checkbox unchecked maps to `false`, not "missing".
|
|
355
|
+
var isMissing = raw == null || raw === "";
|
|
356
|
+
if (isMissing && field.kind !== "checkbox") {
|
|
357
|
+
if (field.required) return { ok: false, error: "required" };
|
|
358
|
+
return { ok: true, value: null };
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (field.kind === "text") {
|
|
362
|
+
if (typeof raw !== "string" || raw.length > MAX_TEXT_VALUE_LEN) {
|
|
363
|
+
return { ok: false, error: "text value must be a string ≤ " + MAX_TEXT_VALUE_LEN + " chars" };
|
|
364
|
+
}
|
|
365
|
+
if (CONTROL_BYTE_LINE_RE.test(raw) || ZERO_WIDTH_RE.test(raw)) {
|
|
366
|
+
return { ok: false, error: "text value contains control / zero-width characters" };
|
|
367
|
+
}
|
|
368
|
+
return { ok: true, value: raw };
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if (field.kind === "email") {
|
|
372
|
+
if (typeof raw !== "string" || !raw.length || raw.length > 320) {
|
|
373
|
+
return { ok: false, error: "email value must be a string 1..320 chars" };
|
|
374
|
+
}
|
|
375
|
+
var v = _b().guardEmail.validate(raw, { profile: "strict" });
|
|
376
|
+
if (!v.ok) {
|
|
377
|
+
var ruleId = v.issues && v.issues[0] && v.issues[0].ruleId || "shape";
|
|
378
|
+
return { ok: false, error: "email rejected (" + ruleId + ")" };
|
|
379
|
+
}
|
|
380
|
+
return { ok: true, value: _b().guardEmail.sanitize(raw, { profile: "strict" }) };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (field.kind === "phone") {
|
|
384
|
+
if (typeof raw !== "string" || !PHONE_VALUE_RE.test(raw)) {
|
|
385
|
+
return { ok: false, error: "phone value must match /^[0-9+()\\-\\s]{1," + MAX_PHONE_VALUE_LEN + "}$/" };
|
|
386
|
+
}
|
|
387
|
+
return { ok: true, value: raw };
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (field.kind === "textarea") {
|
|
391
|
+
if (typeof raw !== "string" || !raw.length || raw.length > MAX_TEXTAREA_VALUE_LEN) {
|
|
392
|
+
return { ok: false, error: "textarea value must be a non-empty string ≤ " + MAX_TEXTAREA_VALUE_LEN + " chars" };
|
|
393
|
+
}
|
|
394
|
+
if (CONTROL_BYTE_BLOCK_RE.test(raw) || ZERO_WIDTH_RE.test(raw)) {
|
|
395
|
+
return { ok: false, error: "textarea value contains control / zero-width characters" };
|
|
396
|
+
}
|
|
397
|
+
return { ok: true, value: raw };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
if (field.kind === "select") {
|
|
401
|
+
if (typeof raw !== "string") {
|
|
402
|
+
return { ok: false, error: "select value must be a string drawn from the declared options" };
|
|
403
|
+
}
|
|
404
|
+
if (!field.options || field.options.indexOf(raw) === -1) {
|
|
405
|
+
return { ok: false, error: "select value must be one of " + JSON.stringify(field.options || []) };
|
|
406
|
+
}
|
|
407
|
+
return { ok: true, value: raw };
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
if (field.kind === "checkbox") {
|
|
411
|
+
if (raw === true || raw === false) {
|
|
412
|
+
if (field.required && raw === false) return { ok: false, error: "required" };
|
|
413
|
+
return { ok: true, value: raw };
|
|
414
|
+
}
|
|
415
|
+
return { ok: false, error: "checkbox value must be a strict boolean" };
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
if (field.kind === "number") {
|
|
419
|
+
var n;
|
|
420
|
+
if (typeof raw === "number") {
|
|
421
|
+
n = raw;
|
|
422
|
+
} else if (typeof raw === "string" && raw.length && raw.length <= 64) {
|
|
423
|
+
// Strict numeric-string shape so something like "1e9999" or
|
|
424
|
+
// " 3" or "" or "0x1f" doesn't slip through. Accept a
|
|
425
|
+
// signed integer or decimal.
|
|
426
|
+
if (!/^-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?$/.test(raw)) {
|
|
427
|
+
return { ok: false, error: "number value must be a finite numeric string" };
|
|
428
|
+
}
|
|
429
|
+
n = Number(raw);
|
|
430
|
+
} else {
|
|
431
|
+
return { ok: false, error: "number value must be a finite number or numeric string" };
|
|
432
|
+
}
|
|
433
|
+
if (!Number.isFinite(n)) {
|
|
434
|
+
return { ok: false, error: "number value must be finite (no NaN / Infinity)" };
|
|
435
|
+
}
|
|
436
|
+
return { ok: true, value: n };
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// Unreachable — _fields() refuses unknown kinds at define time.
|
|
440
|
+
return { ok: false, error: "unsupported field kind " + JSON.stringify(field.kind) };
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// ---- row hydration ------------------------------------------------------
|
|
444
|
+
|
|
445
|
+
function _hydrateFormRow(r) {
|
|
446
|
+
if (!r) return null;
|
|
447
|
+
var fields, submitTo;
|
|
448
|
+
try { fields = JSON.parse(r.fields_json); }
|
|
449
|
+
catch (_e) { fields = []; }
|
|
450
|
+
try { submitTo = JSON.parse(r.submit_to_json); }
|
|
451
|
+
catch (_e2) { submitTo = null; }
|
|
452
|
+
return {
|
|
453
|
+
slug: r.slug,
|
|
454
|
+
title: r.title,
|
|
455
|
+
description: r.description,
|
|
456
|
+
fields: fields,
|
|
457
|
+
submit_to: submitTo,
|
|
458
|
+
success_message: r.success_message,
|
|
459
|
+
throttle_per_minute_per_session: Number(r.throttle_per_minute_per_session),
|
|
460
|
+
archived_at: r.archived_at == null ? null : Number(r.archived_at),
|
|
461
|
+
created_at: Number(r.created_at),
|
|
462
|
+
updated_at: Number(r.updated_at),
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function _hydrateSubmissionRow(r) {
|
|
467
|
+
if (!r) return null;
|
|
468
|
+
var values;
|
|
469
|
+
try { values = JSON.parse(r.values_json); }
|
|
470
|
+
catch (_e) { values = {}; }
|
|
471
|
+
return {
|
|
472
|
+
id: r.id,
|
|
473
|
+
form_slug: r.form_slug,
|
|
474
|
+
values: values,
|
|
475
|
+
session_id_hash: r.session_id_hash == null ? null : r.session_id_hash,
|
|
476
|
+
ip_hash: r.ip_hash == null ? null : r.ip_hash,
|
|
477
|
+
ua_class: r.ua_class == null ? null : r.ua_class,
|
|
478
|
+
dispatched_at: r.dispatched_at == null ? null : Number(r.dispatched_at),
|
|
479
|
+
dispatch_error: r.dispatch_error == null ? null : r.dispatch_error,
|
|
480
|
+
created_at: Number(r.created_at),
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// ---- factory ------------------------------------------------------------
|
|
485
|
+
|
|
486
|
+
function create(opts) {
|
|
487
|
+
opts = opts || {};
|
|
488
|
+
var query = opts.query;
|
|
489
|
+
if (!query) {
|
|
490
|
+
query = function (sql, params) { return _b().externalDb.query(sql, params); };
|
|
491
|
+
}
|
|
492
|
+
// Optional dispatch deps. Both are duck-typed (we only check for
|
|
493
|
+
// the call we make) so a test collector can stand in for the real
|
|
494
|
+
// primitive without implementing the whole surface.
|
|
495
|
+
var notifications = opts.notifications || null;
|
|
496
|
+
var webhooks = opts.webhooks || null;
|
|
497
|
+
|
|
498
|
+
function _hashSession(sessionId) {
|
|
499
|
+
return _b().crypto.namespaceHash(SESSION_NAMESPACE, sessionId);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// -- defineForm -------------------------------------------------------
|
|
503
|
+
|
|
504
|
+
async function defineForm(input) {
|
|
505
|
+
if (!input || typeof input !== "object") {
|
|
506
|
+
throw new TypeError("storefrontForms.defineForm: input object required");
|
|
507
|
+
}
|
|
508
|
+
var slug = _slug(input.slug);
|
|
509
|
+
var title = _line(input.title, "title", MAX_TITLE_LEN);
|
|
510
|
+
var description = _description(input.description);
|
|
511
|
+
var fields = _fields(input.fields);
|
|
512
|
+
var submitTo = _submitTo(input.submit_to);
|
|
513
|
+
var successMessage = _successMessage(input.success_message);
|
|
514
|
+
var throttle = _throttle(input.throttle_per_minute_per_session);
|
|
515
|
+
|
|
516
|
+
var ts = _now();
|
|
517
|
+
await query(
|
|
518
|
+
"INSERT INTO storefront_forms (slug, title, description, fields_json, submit_to_json, " +
|
|
519
|
+
"success_message, throttle_per_minute_per_session, archived_at, created_at, updated_at) " +
|
|
520
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL, ?8, ?8)",
|
|
521
|
+
[slug, title, description, JSON.stringify(fields), JSON.stringify(submitTo), successMessage, throttle, ts],
|
|
522
|
+
);
|
|
523
|
+
return await getForm(slug);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// -- getForm / listForms ---------------------------------------------
|
|
527
|
+
|
|
528
|
+
async function getForm(slug) {
|
|
529
|
+
_slug(slug);
|
|
530
|
+
var r = (await query(
|
|
531
|
+
"SELECT * FROM storefront_forms WHERE slug = ?1 LIMIT 1",
|
|
532
|
+
[slug],
|
|
533
|
+
)).rows[0];
|
|
534
|
+
return _hydrateFormRow(r);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
async function listForms() {
|
|
538
|
+
var rows = (await query(
|
|
539
|
+
"SELECT * FROM storefront_forms WHERE archived_at IS NULL " +
|
|
540
|
+
"ORDER BY created_at ASC, slug ASC",
|
|
541
|
+
[],
|
|
542
|
+
)).rows;
|
|
543
|
+
return rows.map(_hydrateFormRow);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// -- updateForm ------------------------------------------------------
|
|
547
|
+
|
|
548
|
+
async function updateForm(slug, patch) {
|
|
549
|
+
_slug(slug);
|
|
550
|
+
if (!patch || typeof patch !== "object") {
|
|
551
|
+
throw new TypeError("storefrontForms.updateForm: patch object required");
|
|
552
|
+
}
|
|
553
|
+
var keys = Object.keys(patch);
|
|
554
|
+
if (!keys.length) {
|
|
555
|
+
throw new TypeError("storefrontForms.updateForm: patch must include at least one column");
|
|
556
|
+
}
|
|
557
|
+
var current = await getForm(slug);
|
|
558
|
+
if (!current) {
|
|
559
|
+
throw new TypeError("storefrontForms.updateForm: slug " + JSON.stringify(slug) + " not found");
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
var sets = [];
|
|
563
|
+
var params = [];
|
|
564
|
+
var idx = 1;
|
|
565
|
+
|
|
566
|
+
for (var i = 0; i < keys.length; i += 1) {
|
|
567
|
+
var col = keys[i];
|
|
568
|
+
if (ALLOWED_UPDATE_COLUMNS.indexOf(col) === -1) {
|
|
569
|
+
throw new TypeError("storefrontForms.updateForm: unsupported column " + JSON.stringify(col));
|
|
570
|
+
}
|
|
571
|
+
var v;
|
|
572
|
+
if (col === "title") { v = _line(patch[col], "title", MAX_TITLE_LEN); sets.push("title = ?" + idx); }
|
|
573
|
+
else if (col === "description") { v = _description(patch[col]); sets.push("description = ?" + idx); }
|
|
574
|
+
else if (col === "fields") { v = JSON.stringify(_fields(patch[col])); sets.push("fields_json = ?" + idx); }
|
|
575
|
+
else if (col === "submit_to") { v = JSON.stringify(_submitTo(patch[col])); sets.push("submit_to_json = ?" + idx); }
|
|
576
|
+
else if (col === "success_message"){ v = _successMessage(patch[col]); sets.push("success_message = ?" + idx); }
|
|
577
|
+
else /* throttle */ { v = _throttle(patch[col]); sets.push("throttle_per_minute_per_session = ?" + idx); }
|
|
578
|
+
params.push(v);
|
|
579
|
+
idx += 1;
|
|
580
|
+
}
|
|
581
|
+
sets.push("updated_at = ?" + idx);
|
|
582
|
+
params.push(_now());
|
|
583
|
+
idx += 1;
|
|
584
|
+
params.push(slug);
|
|
585
|
+
|
|
586
|
+
var r = await query(
|
|
587
|
+
"UPDATE storefront_forms SET " + sets.join(", ") + " WHERE slug = ?" + idx,
|
|
588
|
+
params,
|
|
589
|
+
);
|
|
590
|
+
if (Number(r.rowCount || 0) === 0) {
|
|
591
|
+
throw new TypeError("storefrontForms.updateForm: slug " + JSON.stringify(slug) + " not found");
|
|
592
|
+
}
|
|
593
|
+
return await getForm(slug);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// -- archiveForm -----------------------------------------------------
|
|
597
|
+
|
|
598
|
+
async function archiveForm(slug) {
|
|
599
|
+
_slug(slug);
|
|
600
|
+
var current = await getForm(slug);
|
|
601
|
+
if (!current) {
|
|
602
|
+
throw new TypeError("storefrontForms.archiveForm: slug " + JSON.stringify(slug) + " not found");
|
|
603
|
+
}
|
|
604
|
+
if (current.archived_at != null) {
|
|
605
|
+
// Already archived — idempotent. Return the current row.
|
|
606
|
+
return current;
|
|
607
|
+
}
|
|
608
|
+
var ts = _now();
|
|
609
|
+
await query(
|
|
610
|
+
"UPDATE storefront_forms SET archived_at = ?1, updated_at = ?1 WHERE slug = ?2",
|
|
611
|
+
[ts, slug],
|
|
612
|
+
);
|
|
613
|
+
return await getForm(slug);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// -- throttleCheck ---------------------------------------------------
|
|
617
|
+
//
|
|
618
|
+
// Counts recent submissions for a (form_slug, session_id_hash) tuple
|
|
619
|
+
// over the last `THROTTLE_WINDOW_MS`. Returns `{ ok: true }` when
|
|
620
|
+
// under the limit, `{ ok: false, error: "throttled", count }` when
|
|
621
|
+
// over. Forms with `throttle_per_minute_per_session = 0` skip the
|
|
622
|
+
// check entirely. A missing session id falls back to "no throttle"
|
|
623
|
+
// because there's no key to bucket the submissions against.
|
|
624
|
+
|
|
625
|
+
async function throttleCheck(input) {
|
|
626
|
+
if (!input || typeof input !== "object") {
|
|
627
|
+
throw new TypeError("storefrontForms.throttleCheck: input object required");
|
|
628
|
+
}
|
|
629
|
+
var slug = _slug(input.form_slug);
|
|
630
|
+
var form = await getForm(slug);
|
|
631
|
+
if (!form) {
|
|
632
|
+
throw new TypeError("storefrontForms.throttleCheck: form " + JSON.stringify(slug) + " not found");
|
|
633
|
+
}
|
|
634
|
+
if (form.throttle_per_minute_per_session === 0) {
|
|
635
|
+
return { ok: true, limit: 0, count: 0 };
|
|
636
|
+
}
|
|
637
|
+
var sessionHash;
|
|
638
|
+
if (input.session_id_hash != null) {
|
|
639
|
+
sessionHash = String(input.session_id_hash);
|
|
640
|
+
} else if (input.session_id != null) {
|
|
641
|
+
sessionHash = _hashSession(String(input.session_id));
|
|
642
|
+
} else {
|
|
643
|
+
// No session key — operator-side caller chose not to bucket.
|
|
644
|
+
// Throttle is effectively disabled for this submission.
|
|
645
|
+
return { ok: true, limit: form.throttle_per_minute_per_session, count: 0 };
|
|
646
|
+
}
|
|
647
|
+
var windowStart = _now() - THROTTLE_WINDOW_MS;
|
|
648
|
+
var r = await query(
|
|
649
|
+
"SELECT COUNT(*) AS n FROM storefront_form_submissions " +
|
|
650
|
+
"WHERE form_slug = ?1 AND session_id_hash = ?2 AND created_at >= ?3",
|
|
651
|
+
[slug, sessionHash, windowStart],
|
|
652
|
+
);
|
|
653
|
+
var count = Number(r.rows[0] && r.rows[0].n || 0);
|
|
654
|
+
if (count >= form.throttle_per_minute_per_session) {
|
|
655
|
+
return { ok: false, error: "throttled", limit: form.throttle_per_minute_per_session, count: count };
|
|
656
|
+
}
|
|
657
|
+
return { ok: true, limit: form.throttle_per_minute_per_session, count: count };
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// -- submit ----------------------------------------------------------
|
|
661
|
+
|
|
662
|
+
async function submit(input) {
|
|
663
|
+
if (!input || typeof input !== "object") {
|
|
664
|
+
throw new TypeError("storefrontForms.submit: input object required");
|
|
665
|
+
}
|
|
666
|
+
var slug = _slug(input.form_slug);
|
|
667
|
+
var form = await getForm(slug);
|
|
668
|
+
if (!form) {
|
|
669
|
+
throw new TypeError("storefrontForms.submit: form " + JSON.stringify(slug) + " not found");
|
|
670
|
+
}
|
|
671
|
+
if (form.archived_at != null) {
|
|
672
|
+
return { ok: false, error: "archived" };
|
|
673
|
+
}
|
|
674
|
+
if (!input.values || typeof input.values !== "object" || Array.isArray(input.values)) {
|
|
675
|
+
throw new TypeError("storefrontForms.submit: values must be a plain object");
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// Refuse unknown field names so a hostile submitter can't smuggle
|
|
679
|
+
// an arbitrary key into the stored JSON. The set of allowed
|
|
680
|
+
// names is the form's declared fields list.
|
|
681
|
+
var allowed = {};
|
|
682
|
+
for (var ai = 0; ai < form.fields.length; ai += 1) allowed[form.fields[ai].name] = true;
|
|
683
|
+
var inputKeys = Object.keys(input.values);
|
|
684
|
+
for (var ki = 0; ki < inputKeys.length; ki += 1) {
|
|
685
|
+
if (!Object.prototype.hasOwnProperty.call(allowed, inputKeys[ki])) {
|
|
686
|
+
return { ok: false, error: "unknown field " + JSON.stringify(inputKeys[ki]) };
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// Per-field validation. Aggregate every failure so the caller
|
|
691
|
+
// can render all of them at once.
|
|
692
|
+
var cleanedValues = {};
|
|
693
|
+
var fieldErrors = {};
|
|
694
|
+
for (var i = 0; i < form.fields.length; i += 1) {
|
|
695
|
+
var f = form.fields[i];
|
|
696
|
+
var raw = Object.prototype.hasOwnProperty.call(input.values, f.name) ? input.values[f.name] : null;
|
|
697
|
+
var res = _validateValue(f, raw);
|
|
698
|
+
if (!res.ok) {
|
|
699
|
+
fieldErrors[f.name] = res.error;
|
|
700
|
+
continue;
|
|
701
|
+
}
|
|
702
|
+
if (res.value !== null) cleanedValues[f.name] = res.value;
|
|
703
|
+
}
|
|
704
|
+
var errorNames = Object.keys(fieldErrors);
|
|
705
|
+
if (errorNames.length) {
|
|
706
|
+
return { ok: false, error: "field_validation", field_errors: fieldErrors };
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// Resolve session id hash for throttle bucketing + persistence.
|
|
710
|
+
var sessionHash = null;
|
|
711
|
+
if (input.session_id_hash != null) {
|
|
712
|
+
sessionHash = String(input.session_id_hash);
|
|
713
|
+
} else if (input.session_id != null) {
|
|
714
|
+
sessionHash = _hashSession(String(input.session_id));
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// Throttle check — only enforced when both a session key + a
|
|
718
|
+
// non-zero per-form limit exist.
|
|
719
|
+
if (sessionHash && form.throttle_per_minute_per_session > 0) {
|
|
720
|
+
var windowStart = _now() - THROTTLE_WINDOW_MS;
|
|
721
|
+
var rt = await query(
|
|
722
|
+
"SELECT COUNT(*) AS n FROM storefront_form_submissions " +
|
|
723
|
+
"WHERE form_slug = ?1 AND session_id_hash = ?2 AND created_at >= ?3",
|
|
724
|
+
[slug, sessionHash, windowStart],
|
|
725
|
+
);
|
|
726
|
+
var count = Number(rt.rows[0] && rt.rows[0].n || 0);
|
|
727
|
+
if (count >= form.throttle_per_minute_per_session) {
|
|
728
|
+
return { ok: false, error: "throttled", limit: form.throttle_per_minute_per_session, count: count };
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
var ipHash = _ipHash(input.ip_hash == null ? null : input.ip_hash);
|
|
733
|
+
var uaClass = _uaClass(input.ua_class == null ? null : input.ua_class);
|
|
734
|
+
|
|
735
|
+
// Persist FIRST so a dispatch failure can't lose the submission.
|
|
736
|
+
// The dispatch outcome (success/failure) is stamped via UPDATE
|
|
737
|
+
// after the dep call returns.
|
|
738
|
+
var id = _b().uuid.v7();
|
|
739
|
+
var now = _now();
|
|
740
|
+
await query(
|
|
741
|
+
"INSERT INTO storefront_form_submissions " +
|
|
742
|
+
"(id, form_slug, values_json, session_id_hash, ip_hash, ua_class, dispatched_at, dispatch_error, created_at) " +
|
|
743
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL, NULL, ?7)",
|
|
744
|
+
[id, slug, JSON.stringify(cleanedValues), sessionHash, ipHash, uaClass, now],
|
|
745
|
+
);
|
|
746
|
+
|
|
747
|
+
// Dispatch via the matching injected dep. Both branches stamp
|
|
748
|
+
// `dispatched_at` on success or `dispatch_error` on failure. A
|
|
749
|
+
// missing dep means the submission lands but stays
|
|
750
|
+
// un-dispatched — operator review surfaces the row with
|
|
751
|
+
// `dispatched_at IS NULL`.
|
|
752
|
+
var dispatched = false;
|
|
753
|
+
var dispatchError = null;
|
|
754
|
+
var target = form.submit_to;
|
|
755
|
+
try {
|
|
756
|
+
if (target.kind === "email" && notifications && typeof notifications.enqueue === "function") {
|
|
757
|
+
var enq = await notifications.enqueue({
|
|
758
|
+
recipient_id: target.value,
|
|
759
|
+
channel: "email",
|
|
760
|
+
event_type: "storefront.form_submission",
|
|
761
|
+
title: form.title,
|
|
762
|
+
body: "Form " + form.slug + " received a new submission.",
|
|
763
|
+
payload: { form_slug: form.slug, submission_id: id, values: cleanedValues },
|
|
764
|
+
});
|
|
765
|
+
if (enq && enq.ok === false) {
|
|
766
|
+
dispatchError = "notifications: " + (enq.error || "rejected");
|
|
767
|
+
} else {
|
|
768
|
+
dispatched = true;
|
|
769
|
+
}
|
|
770
|
+
} else if (target.kind === "webhook" && webhooks && typeof webhooks.send === "function") {
|
|
771
|
+
await webhooks.send(target.value, {
|
|
772
|
+
form_slug: form.slug,
|
|
773
|
+
submission_id: id,
|
|
774
|
+
values: cleanedValues,
|
|
775
|
+
occurred_at: now,
|
|
776
|
+
});
|
|
777
|
+
dispatched = true;
|
|
778
|
+
}
|
|
779
|
+
} catch (e) {
|
|
780
|
+
dispatchError = String((e && e.message) || e).slice(0, 1024);
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
if (dispatched) {
|
|
784
|
+
await query(
|
|
785
|
+
"UPDATE storefront_form_submissions SET dispatched_at = ?1 WHERE id = ?2",
|
|
786
|
+
[_now(), id],
|
|
787
|
+
);
|
|
788
|
+
} else if (dispatchError) {
|
|
789
|
+
await query(
|
|
790
|
+
"UPDATE storefront_form_submissions SET dispatch_error = ?1 WHERE id = ?2",
|
|
791
|
+
[dispatchError, id],
|
|
792
|
+
);
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
return {
|
|
796
|
+
ok: true,
|
|
797
|
+
id: id,
|
|
798
|
+
dispatched: dispatched,
|
|
799
|
+
dispatch_error: dispatchError,
|
|
800
|
+
success_message: form.success_message,
|
|
801
|
+
};
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
// -- submissionsForForm ---------------------------------------------
|
|
805
|
+
|
|
806
|
+
async function submissionsForForm(input) {
|
|
807
|
+
if (!input || typeof input !== "object") {
|
|
808
|
+
throw new TypeError("storefrontForms.submissionsForForm: input object required");
|
|
809
|
+
}
|
|
810
|
+
var slug = _slug(input.form_slug);
|
|
811
|
+
var limit = _listLimit(input.limit);
|
|
812
|
+
var rows;
|
|
813
|
+
if (input.cursor != null) {
|
|
814
|
+
if (typeof input.cursor !== "string" || !input.cursor.length || input.cursor.length > 256) {
|
|
815
|
+
throw new TypeError("storefrontForms.submissionsForForm: cursor must be a non-empty string ≤ 256 chars");
|
|
816
|
+
}
|
|
817
|
+
// The cursor is a `<created_at>:<id>` pair encoded by the
|
|
818
|
+
// previous call. The encoding is the simplest stable form
|
|
819
|
+
// that lets the next page resume at the strict (<,<=)
|
|
820
|
+
// boundary without an HMAC step — the cursor surface is
|
|
821
|
+
// operator-internal and the tuple-comparison is total
|
|
822
|
+
// (created_at DESC, id DESC).
|
|
823
|
+
var sep = input.cursor.indexOf(":");
|
|
824
|
+
if (sep <= 0) {
|
|
825
|
+
throw new TypeError("storefrontForms.submissionsForForm: cursor malformed");
|
|
826
|
+
}
|
|
827
|
+
var cAt = Number(input.cursor.slice(0, sep));
|
|
828
|
+
var cId = input.cursor.slice(sep + 1);
|
|
829
|
+
if (!Number.isFinite(cAt)) {
|
|
830
|
+
throw new TypeError("storefrontForms.submissionsForForm: cursor malformed");
|
|
831
|
+
}
|
|
832
|
+
rows = (await query(
|
|
833
|
+
"SELECT * FROM storefront_form_submissions " +
|
|
834
|
+
"WHERE form_slug = ?1 AND (created_at < ?2 OR (created_at = ?2 AND id < ?3)) " +
|
|
835
|
+
"ORDER BY created_at DESC, id DESC LIMIT ?4",
|
|
836
|
+
[slug, cAt, cId, limit],
|
|
837
|
+
)).rows;
|
|
838
|
+
} else {
|
|
839
|
+
rows = (await query(
|
|
840
|
+
"SELECT * FROM storefront_form_submissions WHERE form_slug = ?1 " +
|
|
841
|
+
"ORDER BY created_at DESC, id DESC LIMIT ?2",
|
|
842
|
+
[slug, limit],
|
|
843
|
+
)).rows;
|
|
844
|
+
}
|
|
845
|
+
var items = rows.map(_hydrateSubmissionRow);
|
|
846
|
+
var next = null;
|
|
847
|
+
if (items.length === limit) {
|
|
848
|
+
var last = items[items.length - 1];
|
|
849
|
+
next = last.created_at + ":" + last.id;
|
|
850
|
+
}
|
|
851
|
+
return { items: items, next_cursor: next };
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
return {
|
|
855
|
+
SESSION_NAMESPACE: SESSION_NAMESPACE,
|
|
856
|
+
FIELD_KINDS: FIELD_KINDS,
|
|
857
|
+
SUBMIT_TO_KINDS: SUBMIT_TO_KINDS,
|
|
858
|
+
THROTTLE_WINDOW_MS: THROTTLE_WINDOW_MS,
|
|
859
|
+
|
|
860
|
+
hashSession: function (sessionId) {
|
|
861
|
+
if (typeof sessionId !== "string" || !sessionId.length) {
|
|
862
|
+
throw new TypeError("storefrontForms.hashSession: sessionId must be a non-empty string");
|
|
863
|
+
}
|
|
864
|
+
return _hashSession(sessionId);
|
|
865
|
+
},
|
|
866
|
+
|
|
867
|
+
defineForm: defineForm,
|
|
868
|
+
getForm: getForm,
|
|
869
|
+
listForms: listForms,
|
|
870
|
+
updateForm: updateForm,
|
|
871
|
+
archiveForm: archiveForm,
|
|
872
|
+
submit: submit,
|
|
873
|
+
submissionsForForm: submissionsForForm,
|
|
874
|
+
throttleCheck: throttleCheck,
|
|
875
|
+
};
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
module.exports = {
|
|
879
|
+
create: create,
|
|
880
|
+
SESSION_NAMESPACE: SESSION_NAMESPACE,
|
|
881
|
+
FIELD_KINDS: FIELD_KINDS,
|
|
882
|
+
SUBMIT_TO_KINDS: SUBMIT_TO_KINDS,
|
|
883
|
+
THROTTLE_WINDOW_MS: THROTTLE_WINDOW_MS,
|
|
884
|
+
};
|