@beezping/adapter-prisma 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs ADDED
@@ -0,0 +1,1241 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+
30
+ // src/index.ts
31
+ var index_exports = {};
32
+ __export(index_exports, {
33
+ PrismaStore: () => PrismaStore,
34
+ StoreDuplicateError: () => StoreDuplicateError,
35
+ StoreNotFoundError: () => StoreNotFoundError,
36
+ StorePersistenceError: () => StorePersistenceError,
37
+ createSitepingHandler: () => createSitepingHandler2,
38
+ dispatchWebhook: () => dispatchWebhook,
39
+ dispatchWebhooks: () => dispatchWebhooks,
40
+ flattenAnnotation: () => flattenAnnotation,
41
+ isStorePersistence: () => isStorePersistence
42
+ });
43
+ module.exports = __toCommonJS(index_exports);
44
+
45
+ // ../core/src/filters.ts
46
+ var DEFAULT_PAGE_LIMIT = 50;
47
+ var MAX_PAGE_LIMIT = 100;
48
+ function toPositiveInteger(value, fallback) {
49
+ return value !== void 0 && Number.isFinite(value) ? Math.max(1, Math.floor(value)) : fallback;
50
+ }
51
+ function clampPagination(query) {
52
+ const page = toPositiveInteger(query.page, 1);
53
+ const limit = Math.min(toPositiveInteger(query.limit, DEFAULT_PAGE_LIMIT), MAX_PAGE_LIMIT);
54
+ return { page, limit, skip: (page - 1) * limit };
55
+ }
56
+ function isUnreachableOffset(skip) {
57
+ return !Number.isSafeInteger(skip);
58
+ }
59
+
60
+ // ../core/src/type-utils.ts
61
+ function isRecord(value) {
62
+ return typeof value === "object" && value !== null;
63
+ }
64
+ function hasOwn(value, key) {
65
+ return isRecord(value) && key in value;
66
+ }
67
+
68
+ // ../core/src/types.ts
69
+ var StoreNotFoundError = class extends Error {
70
+ code = "STORE_NOT_FOUND";
71
+ constructor(message = "Record not found", options) {
72
+ super(message, options);
73
+ this.name = "StoreNotFoundError";
74
+ }
75
+ };
76
+ var StoreDuplicateError = class extends Error {
77
+ code = "STORE_DUPLICATE";
78
+ constructor(message = "Duplicate record", options) {
79
+ super(message, options);
80
+ this.name = "StoreDuplicateError";
81
+ }
82
+ };
83
+ var StorePersistenceError = class extends Error {
84
+ code = "STORE_PERSISTENCE";
85
+ constructor(message = "Failed to persist store mutation", options) {
86
+ super(message, options);
87
+ this.name = "StorePersistenceError";
88
+ }
89
+ };
90
+ function hasErrorCode(error, code) {
91
+ return hasOwn(error, "code") && error.code === code;
92
+ }
93
+ function isStoreNotFound(error) {
94
+ if (error instanceof StoreNotFoundError) return true;
95
+ return hasErrorCode(error, "STORE_NOT_FOUND") || hasErrorCode(error, "P2025");
96
+ }
97
+ function isStoreDuplicate(error) {
98
+ if (error instanceof StoreDuplicateError) return true;
99
+ return hasErrorCode(error, "STORE_DUPLICATE") || hasErrorCode(error, "P2002");
100
+ }
101
+ function isStorePersistence(error) {
102
+ if (error instanceof StorePersistenceError) return true;
103
+ return hasErrorCode(error, "STORE_PERSISTENCE");
104
+ }
105
+ function flattenAnnotation(ann) {
106
+ return {
107
+ cssSelector: ann.anchor.cssSelector,
108
+ xpath: ann.anchor.xpath,
109
+ textSnippet: ann.anchor.textSnippet,
110
+ elementTag: ann.anchor.elementTag,
111
+ elementId: ann.anchor.elementId,
112
+ textPrefix: ann.anchor.textPrefix,
113
+ textSuffix: ann.anchor.textSuffix,
114
+ fingerprint: ann.anchor.fingerprint,
115
+ neighborText: ann.anchor.neighborText,
116
+ anchorKey: ann.anchor.anchorKey ?? null,
117
+ xPct: ann.rect.xPct,
118
+ yPct: ann.rect.yPct,
119
+ wPct: ann.rect.wPct,
120
+ hPct: ann.rect.hPct,
121
+ scrollX: ann.scrollX,
122
+ scrollY: ann.scrollY,
123
+ viewportW: ann.viewportW,
124
+ viewportH: ann.viewportH,
125
+ devicePixelRatio: ann.devicePixelRatio
126
+ };
127
+ }
128
+
129
+ // ../server/dist/index.js
130
+ var zod = __toESM(require("zod"), 1);
131
+ var MAX_ANNOTATIONS_PER_FEEDBACK = 50;
132
+ var MAX_LOGGED_ORIGIN_LENGTH = 128;
133
+ var MAX_REPORTED_HEADER_NAME_LENGTH = 64;
134
+ var SITEPING_ERROR_MESSAGES = {
135
+ invalidJson: "Invalid JSON",
136
+ unauthorized: "Unauthorized",
137
+ apiKeyRequiredForDestructive: "apiKey required for destructive operations",
138
+ forbidden: "Forbidden",
139
+ unsupportedMediaType: "Content-Type must be application/json",
140
+ feedbackNotFound: "Feedback not found",
141
+ clientIdUsedByAnotherProject: "clientId already used by another project",
142
+ tooManyAnnotations: `Too many annotations (max ${MAX_ANNOTATIONS_PER_FEEDBACK})`,
143
+ deletionAborted: "Deletion aborted: a linked resource could not be cleaned up",
144
+ internalServerError: "Internal server error"
145
+ };
146
+ var SITEPING_CONFIGURATION_ERROR_MESSAGES = {
147
+ ownershipVerificationRequired: "[siteping] createSitepingHandler: `access.authorize` needs a store implementing `verifyProjectOwnership`. Without it, a caller authorized for one project could PATCH or DELETE another project's feedback by id. Implement `verifyProjectOwnership` on the store (createCollectionStore and PrismaStore already do).",
148
+ invalidAllowedHeader: "[siteping] allowedHeaders: every entry must be a valid HTTP header name (letters, digits and !#$%&'*+-.^_`|~, no spaces or commas). Invalid entry:"
149
+ };
150
+ function accessGateFromControl(access) {
151
+ return {
152
+ echoesAuthorEmailOnCreate: false,
153
+ async authenticate(request) {
154
+ const principal = await access.authenticate(request);
155
+ if (principal === null) return { ok: false, status: 401, error: SITEPING_ERROR_MESSAGES.unauthorized };
156
+ return { ok: true, principal, canReadAuthorEmail: access.canReadAuthorEmail?.(principal) ?? true };
157
+ },
158
+ async authorize(context) {
159
+ return access.authorize ? access.authorize(context) : true;
160
+ }
161
+ };
162
+ }
163
+ var textEncoder = new TextEncoder();
164
+ function constantTimeEqual(received, expected) {
165
+ const receivedBytes = textEncoder.encode(received);
166
+ const expectedBytes = textEncoder.encode(expected);
167
+ if (receivedBytes.length !== expectedBytes.length) return false;
168
+ let difference = 0;
169
+ for (let index = 0; index < receivedBytes.length; index++) {
170
+ difference |= (receivedBytes[index] ?? 0) ^ (expectedBytes[index] ?? 0);
171
+ }
172
+ return difference === 0;
173
+ }
174
+ function isProductionEnvironment() {
175
+ return typeof process !== "undefined" && process.env?.NODE_ENV === "production";
176
+ }
177
+ function createApiKeyGate({
178
+ apiKey,
179
+ publicEndpoints = apiKey ? ["POST", "OPTIONS"] : void 0,
180
+ requireAuthForDestructive = true,
181
+ redactUnauthenticatedEmails = true
182
+ }) {
183
+ if (!apiKey && requireAuthForDestructive && isProductionEnvironment()) {
184
+ throw new Error(
185
+ "[siteping] createSitepingHandler: apiKey is required in production. Set `apiKey` to enable destructive endpoints, pass `access` to plug your own auth, or pass `requireAuthForDestructive: false` if SitePing sits behind your own auth middleware."
186
+ );
187
+ }
188
+ const publicMethods = publicEndpoints ? new Set(publicEndpoints) : null;
189
+ const isBearerAuthenticated = (request) => {
190
+ if (!apiKey) return false;
191
+ const header = request.headers.get("Authorization");
192
+ return header !== null && constantTimeEqual(header, `Bearer ${apiKey}`);
193
+ };
194
+ return {
195
+ // Legacy contract: a successful POST echoes the email back to its author.
196
+ echoesAuthorEmailOnCreate: true,
197
+ async authenticate(request, method) {
198
+ const canReadAuthorEmail = !redactUnauthenticatedEmails || isBearerAuthenticated(request);
199
+ if (!apiKey) {
200
+ if (requireAuthForDestructive && (method === "DELETE" || method === "PATCH")) {
201
+ return { ok: false, status: 401, error: SITEPING_ERROR_MESSAGES.apiKeyRequiredForDestructive };
202
+ }
203
+ return { ok: true, principal: null, canReadAuthorEmail };
204
+ }
205
+ if (publicMethods?.has(method) || isBearerAuthenticated(request)) {
206
+ return { ok: true, principal: null, canReadAuthorEmail };
207
+ }
208
+ return { ok: false, status: 401, error: SITEPING_ERROR_MESSAGES.unauthorized };
209
+ },
210
+ async authorize() {
211
+ return true;
212
+ }
213
+ };
214
+ }
215
+ var LIST_CACHE_CONTROL = "no-store";
216
+ var CSRF_PROTECTED_METHODS = ["POST", "PATCH", "DELETE"];
217
+ var JSON_MEDIA_TYPE = "application/json";
218
+ var CORS_ALLOWED_METHODS = "GET, POST, PATCH, DELETE, OPTIONS";
219
+ var CORS_DEFAULT_ALLOWED_HEADERS = ["Content-Type", "Authorization"];
220
+ var HTTP_HEADER_NAME_PATTERN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
221
+ var CORS_MAX_AGE_SECONDS = 86400;
222
+ var LIST_QUERY_KEYS = [
223
+ "projectName",
224
+ "page",
225
+ "limit",
226
+ "type",
227
+ "status",
228
+ "statuses",
229
+ "search",
230
+ "url",
231
+ "urlPattern"
232
+ ];
233
+ function resolveAllowedHeaders(extraHeaders) {
234
+ const headersByLowercaseName = /* @__PURE__ */ new Map();
235
+ for (const header of [...CORS_DEFAULT_ALLOWED_HEADERS, ...extraHeaders ?? []]) {
236
+ if (typeof header !== "string" || !HTTP_HEADER_NAME_PATTERN.test(header)) {
237
+ const reported = String(header).slice(0, MAX_REPORTED_HEADER_NAME_LENGTH);
238
+ throw new Error(`${SITEPING_CONFIGURATION_ERROR_MESSAGES.invalidAllowedHeader} ${JSON.stringify(reported)}`);
239
+ }
240
+ const lowercaseName = header.toLowerCase();
241
+ if (!headersByLowercaseName.has(lowercaseName)) headersByLowercaseName.set(lowercaseName, header);
242
+ }
243
+ return [...headersByLowercaseName.values()].join(", ");
244
+ }
245
+ function createCorsPolicy({ allowedOrigins, allowedHeaders, allowedMethods }) {
246
+ return { allowedOrigins, allowedMethods, allowedHeaders: resolveAllowedHeaders(allowedHeaders) };
247
+ }
248
+ function buildCorsHeaders(request, policy) {
249
+ if (!policy.allowedOrigins) return {};
250
+ const origin = request.headers.get("Origin");
251
+ if (!origin || !policy.allowedOrigins.includes(origin)) return {};
252
+ return {
253
+ "Access-Control-Allow-Origin": origin,
254
+ "Access-Control-Allow-Methods": policy.allowedMethods,
255
+ "Access-Control-Allow-Headers": policy.allowedHeaders,
256
+ "Access-Control-Allow-Credentials": "true",
257
+ "Access-Control-Max-Age": String(CORS_MAX_AGE_SECONDS),
258
+ Vary: "Origin"
259
+ };
260
+ }
261
+ function preflightResponse(request, policy) {
262
+ return new Response(null, { status: 204, headers: buildCorsHeaders(request, policy) });
263
+ }
264
+ function withCors(response, corsHeaders) {
265
+ for (const [key, value] of Object.entries(corsHeaders)) {
266
+ response.headers.set(key, value);
267
+ }
268
+ return response;
269
+ }
270
+ var defaultLogger = {
271
+ error(message, context) {
272
+ console.error(message, context);
273
+ }
274
+ };
275
+ function internalErrorResponse({
276
+ logger,
277
+ describeError,
278
+ source,
279
+ operation,
280
+ request,
281
+ corsHeaders,
282
+ headers,
283
+ failure
284
+ }) {
285
+ logger.error(`[siteping] ${source}: ${operation} failed`, {
286
+ error: failure,
287
+ method: request.method,
288
+ path: new URL(request.url).pathname
289
+ });
290
+ const message = describeError?.(failure) ?? SITEPING_ERROR_MESSAGES.internalServerError;
291
+ return withCors(Response.json({ error: message }, { status: 500, ...headers ? { headers } : {} }), corsHeaders);
292
+ }
293
+ var EMAIL_PATTERN = /^(?!\.)(?!.*\.\.)[\p{L}\p{N}!#$%&'*+/=?^_`{|}~.-]{0,63}[\p{L}\p{N}!#$%&'*+/=?^_`{|}~-]@(?:[\p{L}\p{N}](?:[\p{L}\p{N}-]{0,61}[\p{L}\p{N}])?\.)+[\p{L}\p{N}-]{2,63}$/u;
294
+ function isRecord2(value) {
295
+ return typeof value === "object" && value !== null;
296
+ }
297
+ function hasOwn2(value, key) {
298
+ return isRecord2(value) && key in value;
299
+ }
300
+ var FEEDBACK_TYPES2 = ["question", "change", "bug", "other"];
301
+ var FEEDBACK_STATUSES2 = ["open", "in_progress", "resolved", "wont_fix"];
302
+ var CLOSED_FEEDBACK_STATUSES2 = ["resolved", "wont_fix"];
303
+ function isClosedStatus2(status) {
304
+ return CLOSED_FEEDBACK_STATUSES2.includes(status);
305
+ }
306
+ function toFeedbackUpdate2(status, closedAt = /* @__PURE__ */ new Date()) {
307
+ return isClosedStatus2(status) ? { status, resolvedAt: closedAt } : { status, resolvedAt: null };
308
+ }
309
+ var StoreNotFoundError2 = class extends Error {
310
+ code = "STORE_NOT_FOUND";
311
+ constructor(message = "Record not found", options) {
312
+ super(message, options);
313
+ this.name = "StoreNotFoundError";
314
+ }
315
+ };
316
+ var StoreDuplicateError2 = class extends Error {
317
+ code = "STORE_DUPLICATE";
318
+ constructor(message = "Duplicate record", options) {
319
+ super(message, options);
320
+ this.name = "StoreDuplicateError";
321
+ }
322
+ };
323
+ function hasErrorCode2(error, code) {
324
+ return hasOwn2(error, "code") && error.code === code;
325
+ }
326
+ function isStoreNotFound2(error) {
327
+ if (error instanceof StoreNotFoundError2) return true;
328
+ return hasErrorCode2(error, "STORE_NOT_FOUND") || hasErrorCode2(error, "P2025");
329
+ }
330
+ function isStoreDuplicate2(error) {
331
+ if (error instanceof StoreDuplicateError2) return true;
332
+ return hasErrorCode2(error, "STORE_DUPLICATE") || hasErrorCode2(error, "P2002");
333
+ }
334
+ function flattenAnnotation2(ann) {
335
+ return {
336
+ cssSelector: ann.anchor.cssSelector,
337
+ xpath: ann.anchor.xpath,
338
+ textSnippet: ann.anchor.textSnippet,
339
+ elementTag: ann.anchor.elementTag,
340
+ elementId: ann.anchor.elementId,
341
+ textPrefix: ann.anchor.textPrefix,
342
+ textSuffix: ann.anchor.textSuffix,
343
+ fingerprint: ann.anchor.fingerprint,
344
+ neighborText: ann.anchor.neighborText,
345
+ anchorKey: ann.anchor.anchorKey ?? null,
346
+ xPct: ann.rect.xPct,
347
+ yPct: ann.rect.yPct,
348
+ wPct: ann.rect.wPct,
349
+ hPct: ann.rect.hPct,
350
+ scrollX: ann.scrollX,
351
+ scrollY: ann.scrollY,
352
+ viewportW: ann.viewportW,
353
+ viewportH: ann.viewportH,
354
+ devicePixelRatio: ann.devicePixelRatio
355
+ };
356
+ }
357
+ var CONSOLE_DIAGNOSTIC_LEVELS2 = ["log", "info", "warn", "error"];
358
+ var z2 = "z" in zod ? zod.z : zod;
359
+ var anchorSchema = z2.object({
360
+ cssSelector: z2.string().min(1).max(2e3),
361
+ xpath: z2.string().min(1).max(2e3),
362
+ textSnippet: z2.string().max(500),
363
+ elementTag: z2.string().min(1),
364
+ elementId: z2.string().optional(),
365
+ textPrefix: z2.string().max(200),
366
+ textSuffix: z2.string().max(200),
367
+ fingerprint: z2.string().max(200),
368
+ neighborText: z2.string().max(500),
369
+ // Optional semantic anchor identifier from `data-feedback-anchor`.
370
+ // Null when no semantic ancestor exists; widget always sends this field.
371
+ anchorKey: z2.string().max(200).nullable().optional()
372
+ });
373
+ var rectSchema = z2.object({
374
+ xPct: z2.number().min(0).max(1),
375
+ yPct: z2.number().min(0).max(1),
376
+ wPct: z2.number().min(0).max(1),
377
+ hPct: z2.number().min(0).max(1)
378
+ });
379
+ var annotationSchema = z2.object({
380
+ anchor: anchorSchema,
381
+ rect: rectSchema,
382
+ scrollX: z2.number().min(0),
383
+ scrollY: z2.number().min(0),
384
+ viewportW: z2.number().int().positive(),
385
+ viewportH: z2.number().int().positive(),
386
+ devicePixelRatio: z2.number().positive().default(1)
387
+ });
388
+ var consoleEntrySchema = z2.object({
389
+ level: z2.enum(CONSOLE_DIAGNOSTIC_LEVELS2),
390
+ timestamp: z2.string().max(50),
391
+ message: z2.string().max(600)
392
+ });
393
+ var networkEntrySchema = z2.object({
394
+ url: z2.string().max(2e3),
395
+ method: z2.string().max(20),
396
+ status: z2.number().int().min(0).max(599),
397
+ durationMs: z2.number().min(0).max(6e5),
398
+ timestamp: z2.string().max(50)
399
+ });
400
+ var diagnosticsSchema = z2.object({
401
+ console: z2.array(consoleEntrySchema).max(50),
402
+ network: z2.array(networkEntrySchema).max(20)
403
+ });
404
+ var screenshotRegionSchema = z2.strictObject({
405
+ xPct: z2.number().min(0).max(1),
406
+ yPct: z2.number().min(0).max(1),
407
+ wPct: z2.number().min(0).max(1),
408
+ hPct: z2.number().min(0).max(1)
409
+ });
410
+ var feedbackCreateSchema = z2.object({
411
+ projectName: z2.string().min(1).max(200),
412
+ type: z2.enum(FEEDBACK_TYPES2),
413
+ message: z2.string().min(1).max(5e3),
414
+ // Page-scope identifier the widget uses to group feedbacks. Defaults to
415
+ // `window.location.pathname` ("/orders/42"), but hosts can override
416
+ // `getPageScope()` to return a full URL, an opaque slug, anything they
417
+ // want. We trim + require non-empty so whitespace-only payloads don't
418
+ // leak into the DB; otherwise the value is opaque to the server and only
419
+ // used as a literal Prisma equality filter, so the loose shape is safe.
420
+ url: z2.string().trim().min(1).max(2e3),
421
+ // Optional parameterized URL template (e.g. "/orders/:orderId") provided
422
+ // by the host via `getPageScope()`. Null when host omits it.
423
+ urlPattern: z2.string().max(2e3).nullable().optional(),
424
+ viewport: z2.string().min(1).max(50),
425
+ userAgent: z2.string().min(1).max(500),
426
+ authorName: z2.string().min(1).max(200),
427
+ // The widget's identity modal validates against the same core pattern, so
428
+ // an address the modal accepts (and persists) is never a 400 here.
429
+ authorEmail: z2.email({ pattern: EMAIL_PATTERN }).max(200),
430
+ annotations: z2.array(annotationSchema).max(50),
431
+ // Restrict to URL-safe identifiers. The widget generates UUIDs (or a
432
+ // Date+Math.random fallback), both of which match. Anything outside this
433
+ // alphabet — `..`, `/`, NUL, etc. — would be a path-traversal vector once
434
+ // the adapter forwards clientId to `screenshotStorage.upload({ feedbackId })`
435
+ // (e.g. an S3 key prefix or a local FS path).
436
+ clientId: z2.string().min(1).max(200).regex(/^[a-zA-Z0-9_-]+$/, "clientId must be alphanumeric (a-z, A-Z, 0-9, _, -)"),
437
+ // Optional base64 JPEG data URL captured by the widget when
438
+ // `enableScreenshot: true`. ~1.5 MB cap = roughly a 1.1 MB JPEG, well
439
+ // above typical sizes (the widget downscales to 1200px). Rejects abuse
440
+ // without truncating legitimate captures.
441
+ screenshotDataUrl: z2.string().max(15e5).regex(/^data:image\/(jpeg|png|webp);base64,/, "screenshotDataUrl must be a data:image/* base64 URL").nullable().optional(),
442
+ // Optional annotation-rect position within the screenshot. Sent by widgets
443
+ // that capture context around the drawn rect; null + omitted are both
444
+ // accepted so legacy clients (and captures without a screenshot) keep
445
+ // working unchanged.
446
+ screenshotRegion: screenshotRegionSchema.nullable().optional(),
447
+ // Optional console + failed-network snapshot. The widget only attaches
448
+ // this when `captureDiagnostics` is enabled; null + omitted are both
449
+ // accepted so existing clients keep working unchanged.
450
+ diagnostics: diagnosticsSchema.nullable().optional()
451
+ });
452
+ var feedbackPatchSchema = z2.object({
453
+ id: z2.string().min(1),
454
+ projectName: z2.string().min(1).max(200),
455
+ status: z2.enum(FEEDBACK_STATUSES2)
456
+ });
457
+ var feedbackDeleteSchema = z2.union([
458
+ z2.object({ id: z2.string().min(1), projectName: z2.string().min(1).max(200) }),
459
+ z2.object({ projectName: z2.string().min(1).max(200), deleteAll: z2.literal(true) })
460
+ ]);
461
+ var getQuerySchema = z2.object({
462
+ projectName: z2.string().min(1).max(200),
463
+ page: z2.coerce.number().int().min(1).default(1),
464
+ limit: z2.coerce.number().int().min(1).max(100).default(50),
465
+ type: z2.enum(FEEDBACK_TYPES2).optional(),
466
+ status: z2.enum(FEEDBACK_STATUSES2).optional(),
467
+ // Bucket status filter serialized as CSV over the wire (e.g.
468
+ // `statuses=open,in_progress`). Non-empty strings are split before each value
469
+ // is validated against the known statuses; `statuses` wins over the exact
470
+ // `status` filter downstream. Capped at 4 — the number of known statuses.
471
+ statuses: z2.preprocess(
472
+ (val) => typeof val === "string" && val.length > 0 ? val.split(",") : val,
473
+ z2.array(z2.enum(FEEDBACK_STATUSES2)).max(4)
474
+ ).optional(),
475
+ search: z2.string().max(200).optional(),
476
+ // Page scope filters — used by the panel's "this page / this type" controls
477
+ url: z2.string().max(2e3).optional(),
478
+ urlPattern: z2.string().max(2e3).optional()
479
+ });
480
+ function formatValidationErrors(error) {
481
+ return error.issues.map((issue) => ({
482
+ field: issue.path.map(String).join("."),
483
+ message: issue.message
484
+ }));
485
+ }
486
+ var DEFAULT_TIMEOUT_MS = 5e3;
487
+ var DISCORD_COLORS = {
488
+ bug: 15680580,
489
+ question: 3900150,
490
+ change: 16096779,
491
+ other: 7041664
492
+ };
493
+ var DEFAULT_DISCORD_COLOR = 7041664;
494
+ function truncate(text, max = 300) {
495
+ if (text.length <= max) return text;
496
+ return `${text.slice(0, max - 1)}\u2026`;
497
+ }
498
+ var SLACK_HEADER_MAX = 150;
499
+ function escapeSlackText(text) {
500
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
501
+ }
502
+ function buildSlackPayload(feedback) {
503
+ const preview = escapeSlackText(truncate(feedback.message));
504
+ const author = escapeSlackText(feedback.authorName);
505
+ const headline = `New ${feedback.type} feedback from ${feedback.authorName}`;
506
+ return {
507
+ text: `${escapeSlackText(headline)}: ${preview}`,
508
+ blocks: [
509
+ {
510
+ type: "header",
511
+ text: { type: "plain_text", text: truncate(headline, SLACK_HEADER_MAX), emoji: true }
512
+ },
513
+ {
514
+ type: "section",
515
+ text: { type: "mrkdwn", text: preview }
516
+ },
517
+ {
518
+ type: "context",
519
+ elements: [
520
+ { type: "mrkdwn", text: `*Project:* ${escapeSlackText(feedback.projectName)}` },
521
+ { type: "mrkdwn", text: `*Type:* ${feedback.type}` },
522
+ { type: "mrkdwn", text: `*URL:* ${escapeSlackText(feedback.url)}` },
523
+ { type: "mrkdwn", text: `*From:* ${author} (${escapeSlackText(feedback.authorEmail)})` }
524
+ ]
525
+ }
526
+ ]
527
+ };
528
+ }
529
+ function buildDiscordPayload(feedback) {
530
+ const preview = truncate(feedback.message);
531
+ return {
532
+ content: `New **${feedback.type}** feedback from **${feedback.authorName}**`,
533
+ embeds: [
534
+ {
535
+ title: `${feedback.type} \u2014 ${feedback.projectName}`,
536
+ description: preview,
537
+ color: DISCORD_COLORS[feedback.type] ?? DEFAULT_DISCORD_COLOR,
538
+ fields: [
539
+ { name: "URL", value: feedback.url, inline: false },
540
+ { name: "Author", value: `${feedback.authorName} (${feedback.authorEmail})`, inline: true },
541
+ { name: "Viewport", value: feedback.viewport, inline: true }
542
+ ],
543
+ timestamp: new Date(feedback.createdAt).toISOString()
544
+ }
545
+ ],
546
+ allowed_mentions: { parse: [] }
547
+ };
548
+ }
549
+ function buildGenericPayload(feedback) {
550
+ const { clientId: _clientId, ...payload } = feedback;
551
+ return payload;
552
+ }
553
+ function buildWebhookPayload(type, feedback) {
554
+ switch (type) {
555
+ case "slack":
556
+ return buildSlackPayload(feedback);
557
+ case "discord":
558
+ return buildDiscordPayload(feedback);
559
+ default:
560
+ return buildGenericPayload(feedback);
561
+ }
562
+ }
563
+ async function dispatchWebhook(config, feedback) {
564
+ const timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
565
+ const body = JSON.stringify(buildWebhookPayload(config.type ?? "generic", feedback));
566
+ const headers = { "Content-Type": "application/json", ...config.headers ?? {} };
567
+ const controller = new AbortController();
568
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
569
+ try {
570
+ const response = await fetch(config.url, {
571
+ method: "POST",
572
+ headers,
573
+ body,
574
+ signal: controller.signal
575
+ });
576
+ clearTimeout(timer);
577
+ if (!response.ok) {
578
+ const err = new Error(`Webhook responded with HTTP ${response.status}`);
579
+ reportError(config, err, feedback.id);
580
+ }
581
+ } catch (rawError) {
582
+ clearTimeout(timer);
583
+ const err = rawError instanceof Error ? rawError : new Error(String(rawError));
584
+ reportError(config, err, feedback.id);
585
+ }
586
+ }
587
+ function reportError(config, err, feedbackId) {
588
+ if (config.onError) {
589
+ try {
590
+ config.onError(err, feedbackId);
591
+ } catch (callbackErr) {
592
+ console.warn(
593
+ `[siteping] webhook onError() callback threw for feedback ${feedbackId}: ${String(callbackErr)} (original error: ${err.message})`
594
+ );
595
+ }
596
+ return;
597
+ }
598
+ console.warn(`[siteping] webhook to ${config.url} failed for feedback ${feedbackId}: ${err.message}`);
599
+ }
600
+ async function dispatchWebhooks(configs, feedback) {
601
+ if (configs.length === 0) return;
602
+ await Promise.all(configs.map((c) => dispatchWebhook(c, feedback)));
603
+ }
604
+ function toCreateInput(payload) {
605
+ return {
606
+ projectName: payload.projectName,
607
+ type: payload.type,
608
+ message: payload.message,
609
+ status: "open",
610
+ url: payload.url,
611
+ urlPattern: payload.urlPattern ?? null,
612
+ viewport: payload.viewport,
613
+ userAgent: payload.userAgent,
614
+ authorName: payload.authorName,
615
+ authorEmail: payload.authorEmail,
616
+ clientId: payload.clientId,
617
+ annotations: payload.annotations.map(flattenAnnotation2),
618
+ screenshotDataUrl: payload.screenshotDataUrl ?? null,
619
+ screenshotRegion: payload.screenshotRegion ?? null,
620
+ diagnostics: payload.diagnostics ?? null
621
+ };
622
+ }
623
+ function createFeedbackOperation({
624
+ store,
625
+ pipeline,
626
+ beforeCreate,
627
+ onCreated,
628
+ webhooks
629
+ }) {
630
+ const respondCreated = (scope, feedback, projectName) => feedback.projectName === projectName ? pipeline.json(scope, pipeline.presentCreated(scope, feedback), { status: 201 }) : pipeline.error(scope, 409, SITEPING_ERROR_MESSAGES.clientIdUsedByAnotherProject);
631
+ const insert = async (input) => {
632
+ try {
633
+ if (store.createFeedbackIfAbsent) {
634
+ const { feedback, created } = await store.createFeedbackIfAbsent(input);
635
+ return { feedback, isNew: created };
636
+ }
637
+ return { feedback: await store.createFeedback(input), isNew: true };
638
+ } catch (error) {
639
+ if (isStoreDuplicate2(error)) {
640
+ const existing = await store.findByClientId(input.clientId);
641
+ if (existing) return { feedback: existing, isNew: false };
642
+ }
643
+ throw error;
644
+ }
645
+ };
646
+ return async (request) => {
647
+ const authentication = await pipeline.authenticate(request, "POST");
648
+ if (!authentication.ok) return authentication.response;
649
+ const scope = authentication.value;
650
+ const payload = await pipeline.readBody(scope, feedbackCreateSchema);
651
+ if (!payload.ok) return payload.response;
652
+ if (payload.value.annotations.length > MAX_ANNOTATIONS_PER_FEEDBACK) {
653
+ return pipeline.error(scope, 400, SITEPING_ERROR_MESSAGES.tooManyAnnotations);
654
+ }
655
+ try {
656
+ const validatedInput = toCreateInput(payload.value);
657
+ const input = beforeCreate ? await beforeCreate(validatedInput, scope.context) : validatedInput;
658
+ const refusal = await pipeline.authorize(scope, { action: "create", projectName: input.projectName });
659
+ if (refusal) return refusal;
660
+ const replayed = await store.findByClientId(input.clientId);
661
+ if (replayed) return respondCreated(scope, replayed, input.projectName);
662
+ const { feedback, isNew } = await insert(input);
663
+ if (isNew && feedback.projectName === input.projectName) {
664
+ if (webhooks.length > 0) void dispatchWebhooks(webhooks, feedback);
665
+ if (onCreated) await pipeline.runHook("onCreated", () => onCreated(feedback, scope.context));
666
+ }
667
+ return respondCreated(scope, feedback, input.projectName);
668
+ } catch (error) {
669
+ return pipeline.internalError(scope, "create feedback", error);
670
+ }
671
+ };
672
+ }
673
+ function toDeletionTarget(deletion) {
674
+ return "deleteAll" in deletion ? { kind: "project", projectName: deletion.projectName } : { kind: "single", id: deletion.id, projectName: deletion.projectName };
675
+ }
676
+ function deleteFeedbackOperation({
677
+ store,
678
+ pipeline,
679
+ onDeleting,
680
+ onDeleted
681
+ }) {
682
+ const removeTarget = (target) => target.kind === "project" ? store.deleteAllFeedbacks(target.projectName) : store.deleteFeedback(target.id);
683
+ return async (request) => {
684
+ const authentication = await pipeline.authenticate(request, "DELETE");
685
+ if (!authentication.ok) return authentication.response;
686
+ const scope = authentication.value;
687
+ const payload = await pipeline.readBody(scope, feedbackDeleteSchema);
688
+ if (!payload.ok) return payload.response;
689
+ const target = toDeletionTarget(payload.value);
690
+ try {
691
+ const refusal = await pipeline.authorize(
692
+ scope,
693
+ target.kind === "project" ? { action: "deleteAll", projectName: target.projectName } : { action: "delete", projectName: target.projectName, feedbackId: target.id }
694
+ );
695
+ if (refusal) return refusal;
696
+ if (target.kind === "single" && store.verifyProjectOwnership && !await store.verifyProjectOwnership(target.id, target.projectName)) {
697
+ return pipeline.error(scope, 404, SITEPING_ERROR_MESSAGES.feedbackNotFound);
698
+ }
699
+ if (onDeleting) {
700
+ try {
701
+ await onDeleting(target, scope.context);
702
+ } catch (error) {
703
+ pipeline.logger.error("[siteping] createSitepingHandler: hook onDeleting aborted the deletion", {
704
+ error,
705
+ target
706
+ });
707
+ return pipeline.error(scope, 502, SITEPING_ERROR_MESSAGES.deletionAborted);
708
+ }
709
+ }
710
+ await removeTarget(target);
711
+ if (onDeleted) await pipeline.runHook("onDeleted", () => onDeleted(target, scope.context));
712
+ return pipeline.json(scope, { deleted: true });
713
+ } catch (error) {
714
+ if (isStoreNotFound2(error)) return pipeline.error(scope, 404, SITEPING_ERROR_MESSAGES.feedbackNotFound);
715
+ return pipeline.internalError(scope, "delete feedback", error);
716
+ }
717
+ };
718
+ }
719
+ function readListQuery(request) {
720
+ const searchParams = new URL(request.url).searchParams;
721
+ const rawQuery = {};
722
+ for (const key of LIST_QUERY_KEYS) {
723
+ const value = searchParams.get(key);
724
+ if (value !== null) rawQuery[key] = value;
725
+ }
726
+ return rawQuery;
727
+ }
728
+ function listFeedbacksOperation({ store, pipeline }) {
729
+ return async (request) => {
730
+ const authentication = await pipeline.authenticate(request, "GET");
731
+ if (!authentication.ok) return authentication.response;
732
+ const scope = authentication.value;
733
+ const query = pipeline.validate(scope, getQuerySchema, readListQuery(request));
734
+ if (!query.ok) return query.response;
735
+ try {
736
+ const refusal = await pipeline.authorize(scope, { action: "list", projectName: query.value.projectName });
737
+ if (refusal) return refusal;
738
+ const page = await store.getFeedbacks(query.value);
739
+ return pipeline.json(
740
+ scope,
741
+ { ...page, feedbacks: page.feedbacks.map((feedback) => pipeline.present(scope, feedback)) },
742
+ { headers: { "Cache-Control": LIST_CACHE_CONTROL } }
743
+ );
744
+ } catch (error) {
745
+ return pipeline.internalError(scope, "list feedbacks", error);
746
+ }
747
+ };
748
+ }
749
+ function updateFeedbackOperation({
750
+ store,
751
+ pipeline,
752
+ onUpdated
753
+ }) {
754
+ return async (request) => {
755
+ const authentication = await pipeline.authenticate(request, "PATCH");
756
+ if (!authentication.ok) return authentication.response;
757
+ const scope = authentication.value;
758
+ const payload = await pipeline.readBody(scope, feedbackPatchSchema);
759
+ if (!payload.ok) return payload.response;
760
+ const { id, projectName, status } = payload.value;
761
+ try {
762
+ const refusal = await pipeline.authorize(scope, { action: "update", projectName, feedbackId: id });
763
+ if (refusal) return refusal;
764
+ if (store.verifyProjectOwnership && !await store.verifyProjectOwnership(id, projectName)) {
765
+ return pipeline.error(scope, 404, SITEPING_ERROR_MESSAGES.feedbackNotFound);
766
+ }
767
+ const feedback = await store.updateFeedback(id, toFeedbackUpdate2(status));
768
+ if (onUpdated) await pipeline.runHook("onUpdated", () => onUpdated(feedback, scope.context));
769
+ return pipeline.json(scope, pipeline.present(scope, feedback));
770
+ } catch (error) {
771
+ if (isStoreNotFound2(error)) return pipeline.error(scope, 404, SITEPING_ERROR_MESSAGES.feedbackNotFound);
772
+ return pipeline.internalError(scope, "update feedback", error);
773
+ }
774
+ };
775
+ }
776
+ function isCsrfProtectedMethod(method) {
777
+ return CSRF_PROTECTED_METHODS.includes(method);
778
+ }
779
+ function isMutationOriginAllowed(request, policy) {
780
+ if (!policy.allowedOrigins) return true;
781
+ const origin = request.headers.get("Origin");
782
+ if (origin === null) return true;
783
+ if (policy.allowedOrigins.includes(origin)) return true;
784
+ return origin === new URL(request.url).origin;
785
+ }
786
+ function hasJsonContentType(request) {
787
+ const contentType = request.headers.get("Content-Type");
788
+ if (contentType === null) return false;
789
+ const [mediaType = ""] = contentType.split(";");
790
+ return mediaType.trim().toLowerCase() === JSON_MEDIA_TYPE;
791
+ }
792
+ function describeOriginForLog(request) {
793
+ const origin = request.headers.get("Origin") ?? "";
794
+ return origin.replace(/[\u0000-\u001f\u007f]/g, "").slice(0, MAX_LOGGED_ORIGIN_LENGTH);
795
+ }
796
+ function toWireFeedback(feedback, includeEmail) {
797
+ const { clientId: _clientId, ...wire } = feedback;
798
+ return includeEmail ? wire : { ...wire, authorEmail: "" };
799
+ }
800
+ function createRequestPipeline({
801
+ gate,
802
+ corsPolicy,
803
+ logger,
804
+ describeError,
805
+ presentFeedback
806
+ }) {
807
+ const json = (scope, body, init) => withCors(Response.json(body, init), scope.corsHeaders);
808
+ const error = (scope, status, message) => json(scope, { error: message }, { status });
809
+ const validate = (scope, schema, input) => {
810
+ const parsed = schema.safeParse(input);
811
+ if (parsed.success) return { ok: true, value: parsed.data };
812
+ return { ok: false, response: json(scope, { errors: formatValidationErrors(parsed.error) }, { status: 400 }) };
813
+ };
814
+ const internalError = (request, corsHeaders, operation, failure) => internalErrorResponse({
815
+ logger,
816
+ describeError,
817
+ source: "createSitepingHandler",
818
+ operation,
819
+ request,
820
+ corsHeaders,
821
+ failure
822
+ });
823
+ const present = (scope, feedback, includeEmail = scope.canReadAuthorEmail) => toWireFeedback(presentFeedback ? presentFeedback(feedback, scope.context) : feedback, includeEmail);
824
+ return {
825
+ json,
826
+ error,
827
+ /**
828
+ * Refuse forged mutations, then run the access gate. Mutating methods
829
+ * from an origin outside `allowedOrigins` answer 403 and non-JSON bodies
830
+ * 415, before the body is parsed, the caller authenticated or any hook
831
+ * run (see `request-guards.ts`). A throwing `authenticate` (session store
832
+ * down…) answers the logged 500 with CORS headers instead of rejecting
833
+ * the handler, which a browser would only see as an opaque network error.
834
+ */
835
+ async authenticate(request, method) {
836
+ const corsHeaders = buildCorsHeaders(request, corsPolicy);
837
+ if (isCsrfProtectedMethod(method)) {
838
+ if (!isMutationOriginAllowed(request, corsPolicy)) {
839
+ logger.error("[siteping] createSitepingHandler: refused a mutation from an origin outside allowedOrigins", {
840
+ method,
841
+ path: new URL(request.url).pathname,
842
+ origin: describeOriginForLog(request)
843
+ });
844
+ return { ok: false, response: error({ corsHeaders }, 403, SITEPING_ERROR_MESSAGES.forbidden) };
845
+ }
846
+ if (!hasJsonContentType(request)) {
847
+ return { ok: false, response: error({ corsHeaders }, 415, SITEPING_ERROR_MESSAGES.unsupportedMediaType) };
848
+ }
849
+ }
850
+ let outcome;
851
+ try {
852
+ outcome = await gate.authenticate(request, method);
853
+ } catch (failure) {
854
+ return { ok: false, response: internalError(request, corsHeaders, "authenticate request", failure) };
855
+ }
856
+ if (!outcome.ok) return { ok: false, response: error({ corsHeaders }, outcome.status, outcome.error) };
857
+ return {
858
+ ok: true,
859
+ value: {
860
+ context: { request, principal: outcome.principal },
861
+ canReadAuthorEmail: outcome.canReadAuthorEmail,
862
+ corsHeaders
863
+ }
864
+ };
865
+ },
866
+ validate,
867
+ /** Read and validate a JSON body. */
868
+ async readBody(scope, schema) {
869
+ const body = await scope.context.request.json().catch(() => null);
870
+ if (!body) return { ok: false, response: error(scope, 400, SITEPING_ERROR_MESSAGES.invalidJson) };
871
+ return validate(scope, schema, body);
872
+ },
873
+ /** `null` when allowed, the 403 response otherwise. */
874
+ async authorize(scope, authorization) {
875
+ const allowed = await gate.authorize({ ...scope.context, ...authorization });
876
+ return allowed ? null : error(scope, 403, SITEPING_ERROR_MESSAGES.forbidden);
877
+ },
878
+ present,
879
+ /**
880
+ * Wire shape of a record answering a `POST` (fresh or replayed): the
881
+ * requester's email policy, except under a gate that echoes the email to
882
+ * its submitter (legacy api-key policy).
883
+ */
884
+ presentCreated(scope, feedback) {
885
+ return present(scope, feedback, gate.echoesAuthorEmailOnCreate || scope.canReadAuthorEmail);
886
+ },
887
+ /** Log an unexpected failure and answer 500 (with `describeError`'s hint when it has one). */
888
+ internalError(scope, operation, failure) {
889
+ return internalError(scope.context.request, scope.corsHeaders, operation, failure);
890
+ },
891
+ /** Run a post-write hook; its failure is logged, never surfaced. */
892
+ async runHook(name, invoke) {
893
+ try {
894
+ await invoke();
895
+ } catch (failure) {
896
+ logger.error(`[siteping] createSitepingHandler: hook ${name} failed`, { error: failure });
897
+ }
898
+ },
899
+ logger
900
+ };
901
+ }
902
+ function toWebhookList(webhooks) {
903
+ if (!webhooks) return [];
904
+ return Array.isArray(webhooks) ? webhooks : [webhooks];
905
+ }
906
+ function createSitepingHandler(options) {
907
+ const {
908
+ store,
909
+ allowedOrigins,
910
+ allowedHeaders,
911
+ beforeCreate,
912
+ presentFeedback,
913
+ hooks = {},
914
+ logger = defaultLogger,
915
+ describeError
916
+ } = options;
917
+ if (!store) {
918
+ throw new Error("[siteping] createSitepingHandler requires a `store`.");
919
+ }
920
+ if (options.access?.authorize && !store.verifyProjectOwnership) {
921
+ throw new Error(SITEPING_CONFIGURATION_ERROR_MESSAGES.ownershipVerificationRequired);
922
+ }
923
+ const corsPolicy = createCorsPolicy({ allowedOrigins, allowedHeaders, allowedMethods: CORS_ALLOWED_METHODS });
924
+ const gate = options.access ? accessGateFromControl(options.access) : createApiKeyGate(options);
925
+ const pipeline = createRequestPipeline({ gate, corsPolicy, logger, describeError, presentFeedback });
926
+ return {
927
+ /** CORS preflight. Configure `allowedOrigins` (and `allowedHeaders`) for cross-origin widgets. */
928
+ OPTIONS: (request) => preflightResponse(request, corsPolicy),
929
+ POST: createFeedbackOperation({
930
+ store,
931
+ pipeline,
932
+ beforeCreate,
933
+ onCreated: hooks.onCreated?.bind(hooks),
934
+ webhooks: toWebhookList(options.webhooks)
935
+ }),
936
+ GET: listFeedbacksOperation({ store, pipeline }),
937
+ PATCH: updateFeedbackOperation({ store, pipeline, onUpdated: hooks.onUpdated?.bind(hooks) }),
938
+ DELETE: deleteFeedbackOperation({
939
+ store,
940
+ pipeline,
941
+ onDeleting: hooks.onDeleting?.bind(hooks),
942
+ onDeleted: hooks.onDeleted?.bind(hooks)
943
+ })
944
+ };
945
+ }
946
+
947
+ // src/index.ts
948
+ var INCLUDE_ANNOTATIONS = { annotations: true };
949
+ var PROVIDERS_SUPPORTING_INSENSITIVE_MODE = /* @__PURE__ */ new Set([
950
+ "postgresql",
951
+ "postgres",
952
+ "mongodb",
953
+ "cockroachdb"
954
+ ]);
955
+ function detectActiveProvider(prisma) {
956
+ try {
957
+ const candidate = prisma;
958
+ const fromActive = candidate?._activeProvider;
959
+ if (typeof fromActive === "string") return fromActive;
960
+ const fromEngineConfig = candidate?._engineConfig?.activeProvider;
961
+ if (typeof fromEngineConfig === "string") return fromEngineConfig;
962
+ const fromEngine = candidate?._engine?.config?.activeProvider;
963
+ if (typeof fromEngine === "string") return fromEngine;
964
+ return null;
965
+ } catch {
966
+ return null;
967
+ }
968
+ }
969
+ function toStoreError(error) {
970
+ if (error instanceof StoreNotFoundError || error instanceof StoreDuplicateError) return error;
971
+ if (isStoreNotFound(error)) return new StoreNotFoundError(void 0, { cause: error });
972
+ if (isStoreDuplicate(error)) return new StoreDuplicateError(void 0, { cause: error });
973
+ return error;
974
+ }
975
+ function isStoredScreenshotUrl(url) {
976
+ return typeof url === "string" && url.length > 0 && !url.startsWith("data:");
977
+ }
978
+ var PrismaStore = class {
979
+ /** @internal */
980
+ prisma;
981
+ screenshotStorage;
982
+ /** Module-level flag would leak across PrismaStore instances in tests; use per-instance. */
983
+ inlineFallbackWarned = false;
984
+ /** @internal */
985
+ caseInsensitiveSearch;
986
+ constructor(prisma, options = {}) {
987
+ this.prisma = prisma;
988
+ this.screenshotStorage = options.screenshotStorage;
989
+ if (typeof options.caseInsensitiveSearch === "boolean") {
990
+ this.caseInsensitiveSearch = options.caseInsensitiveSearch;
991
+ } else {
992
+ const provider = detectActiveProvider(prisma);
993
+ this.caseInsensitiveSearch = provider !== null && PROVIDERS_SUPPORTING_INSENSITIVE_MODE.has(provider);
994
+ }
995
+ }
996
+ async createFeedback(data) {
997
+ const screenshotUrl = await this.persistScreenshot(data.screenshotDataUrl, data.clientId);
998
+ try {
999
+ return await this.insertFeedback(data, screenshotUrl);
1000
+ } catch (error) {
1001
+ if (isStoreDuplicate(error)) await this.discardScreenshots([screenshotUrl]);
1002
+ throw toStoreError(error);
1003
+ }
1004
+ }
1005
+ async insertFeedback(data, screenshotUrl) {
1006
+ return await this.prisma.sitepingFeedback.create({
1007
+ data: {
1008
+ projectName: data.projectName,
1009
+ type: data.type,
1010
+ message: data.message,
1011
+ status: data.status,
1012
+ url: data.url,
1013
+ urlPattern: data.urlPattern ?? null,
1014
+ screenshotUrl,
1015
+ // Persisted as JSON when the model has a `screenshotRegion Json?`
1016
+ // column — same omit-when-null contract as `diagnostics` below, so
1017
+ // hosts that haven't run `npx siteping sync` keep working.
1018
+ ...data.screenshotRegion ? { screenshotRegion: data.screenshotRegion } : {},
1019
+ // Persisted as JSON when the model has a `diagnostics Json?` column.
1020
+ // Hosts that haven't run `npx siteping sync` keep their schema as-is
1021
+ // and Prisma will throw if we pass an unknown column, so omit the
1022
+ // key entirely when diagnostics is null.
1023
+ ...data.diagnostics ? { diagnostics: data.diagnostics } : {},
1024
+ viewport: data.viewport,
1025
+ userAgent: data.userAgent,
1026
+ authorName: data.authorName,
1027
+ authorEmail: data.authorEmail,
1028
+ clientId: data.clientId,
1029
+ annotations: {
1030
+ create: data.annotations.map((ann) => ({
1031
+ cssSelector: ann.cssSelector,
1032
+ xpath: ann.xpath,
1033
+ textSnippet: ann.textSnippet,
1034
+ elementTag: ann.elementTag,
1035
+ elementId: ann.elementId,
1036
+ textPrefix: ann.textPrefix,
1037
+ textSuffix: ann.textSuffix,
1038
+ fingerprint: ann.fingerprint,
1039
+ neighborText: ann.neighborText,
1040
+ anchorKey: ann.anchorKey ?? null,
1041
+ xPct: ann.xPct,
1042
+ yPct: ann.yPct,
1043
+ wPct: ann.wPct,
1044
+ hPct: ann.hPct,
1045
+ scrollX: ann.scrollX,
1046
+ scrollY: ann.scrollY,
1047
+ viewportW: ann.viewportW,
1048
+ viewportH: ann.viewportH,
1049
+ devicePixelRatio: ann.devicePixelRatio
1050
+ }))
1051
+ }
1052
+ },
1053
+ include: INCLUDE_ANNOTATIONS
1054
+ });
1055
+ }
1056
+ /**
1057
+ * Resolve the value to persist on `Feedback.screenshotUrl`.
1058
+ *
1059
+ * - No data URL → null
1060
+ * - Storage configured → upload, return remote URL. Upload failures
1061
+ * persist `null` (drop the screenshot) rather than silently inlining
1062
+ * the data URL — an inline fallback would bloat Postgres unnoticed
1063
+ * during a multi-minute storage outage. The feedback message itself is
1064
+ * preserved; only the screenshot is missing, and the warn surfaces it.
1065
+ * - No storage → inline base64, with a one-time warn so prod operators
1066
+ * notice the footgun.
1067
+ *
1068
+ * Operators who prefer the legacy inline-on-failure behaviour can wrap
1069
+ * their `ScreenshotStorage.upload` with their own catch + return the
1070
+ * data URL — the adapter treats whatever the storage returns as final.
1071
+ */
1072
+ async persistScreenshot(dataUrl, clientId) {
1073
+ if (!dataUrl) return null;
1074
+ if (this.screenshotStorage) {
1075
+ try {
1076
+ const { url } = await this.screenshotStorage.upload(dataUrl, {
1077
+ feedbackId: clientId,
1078
+ mimeType: "image/jpeg"
1079
+ });
1080
+ return url;
1081
+ } catch (err) {
1082
+ console.warn(
1083
+ "[siteping] screenshotStorage.upload failed \u2014 feedback will be saved without a screenshot. Wrap your storage's upload to handle this differently:",
1084
+ err
1085
+ );
1086
+ return null;
1087
+ }
1088
+ }
1089
+ if (!this.inlineFallbackWarned) {
1090
+ this.inlineFallbackWarned = true;
1091
+ console.warn(
1092
+ "[siteping] enableScreenshot is on but no `screenshotStorage` is configured \u2014 base64 data URLs will be persisted inline on Feedback.screenshotUrl. Configure a ScreenshotStorage (S3/R2/\u2026) for production."
1093
+ );
1094
+ }
1095
+ return dataUrl;
1096
+ }
1097
+ /**
1098
+ * Best-effort cleanup of stored screenshots through `ScreenshotStorage.delete`
1099
+ * — the hook the interface documents for feedback deletion. Failures are
1100
+ * logged and swallowed: an orphaned object is preferable to a delete that
1101
+ * reports failure after the row is already gone. Inline `data:` URLs and
1102
+ * stores without a `delete` hook are skipped.
1103
+ */
1104
+ async discardScreenshots(urls) {
1105
+ const remove = this.screenshotStorage?.delete?.bind(this.screenshotStorage);
1106
+ if (!remove) return;
1107
+ const stored = urls.filter(isStoredScreenshotUrl);
1108
+ if (stored.length === 0) return;
1109
+ const results = await Promise.allSettled(stored.map((url) => remove(url)));
1110
+ results.forEach((result, index) => {
1111
+ if (result.status === "rejected") {
1112
+ console.warn(
1113
+ `[siteping] screenshotStorage.delete failed for ${stored[index]} \u2014 object left in place:`,
1114
+ result.reason
1115
+ );
1116
+ }
1117
+ });
1118
+ }
1119
+ /** URLs of the stored screenshots in `projectName` — only fetched when a `delete` hook can use them. */
1120
+ async storedScreenshotUrls(projectName) {
1121
+ if (!this.screenshotStorage?.delete) return [];
1122
+ const rows = await this.prisma.sitepingFeedback.findMany({
1123
+ where: { projectName, screenshotUrl: { not: null } },
1124
+ select: { screenshotUrl: true }
1125
+ });
1126
+ return rows.map((row) => row.screenshotUrl).filter(isStoredScreenshotUrl);
1127
+ }
1128
+ async findByClientId(clientId) {
1129
+ return await this.prisma.sitepingFeedback.findUnique({
1130
+ where: { clientId },
1131
+ include: INCLUDE_ANNOTATIONS
1132
+ });
1133
+ }
1134
+ async getFeedbacks(query) {
1135
+ const { projectName, type, status, statuses, search, url, urlPattern } = query;
1136
+ const { limit, skip } = clampPagination(query);
1137
+ const where = { projectName };
1138
+ if (type) where.type = type;
1139
+ if (statuses && statuses.length > 0) {
1140
+ where.status = { in: [...statuses] };
1141
+ } else if (status) {
1142
+ where.status = status;
1143
+ }
1144
+ if (url) where.url = url;
1145
+ if (urlPattern) where.urlPattern = urlPattern;
1146
+ if (search) {
1147
+ where.message = this.caseInsensitiveSearch ? { contains: search, mode: "insensitive" } : { contains: search };
1148
+ }
1149
+ if (isUnreachableOffset(skip)) {
1150
+ return { feedbacks: [], total: await this.prisma.sitepingFeedback.count({ where }) };
1151
+ }
1152
+ const [feedbacks, total] = await Promise.all([
1153
+ this.prisma.sitepingFeedback.findMany({
1154
+ where,
1155
+ include: INCLUDE_ANNOTATIONS,
1156
+ orderBy: { createdAt: "desc" },
1157
+ skip,
1158
+ take: limit
1159
+ }),
1160
+ this.prisma.sitepingFeedback.count({ where })
1161
+ ]);
1162
+ return { feedbacks, total };
1163
+ }
1164
+ async updateFeedback(id, data) {
1165
+ try {
1166
+ return await this.prisma.sitepingFeedback.update({
1167
+ where: { id },
1168
+ data: {
1169
+ status: data.status,
1170
+ resolvedAt: data.resolvedAt
1171
+ },
1172
+ include: INCLUDE_ANNOTATIONS
1173
+ });
1174
+ } catch (error) {
1175
+ throw toStoreError(error);
1176
+ }
1177
+ }
1178
+ async deleteFeedback(id) {
1179
+ let deleted;
1180
+ try {
1181
+ deleted = await this.prisma.sitepingFeedback.delete({ where: { id } });
1182
+ } catch (error) {
1183
+ throw toStoreError(error);
1184
+ }
1185
+ await this.discardScreenshots([deleted?.screenshotUrl]);
1186
+ }
1187
+ async deleteAllFeedbacks(projectName) {
1188
+ const screenshotUrls = await this.storedScreenshotUrls(projectName);
1189
+ await this.prisma.sitepingFeedback.deleteMany({ where: { projectName } });
1190
+ await this.discardScreenshots(screenshotUrls);
1191
+ }
1192
+ /**
1193
+ * Verify that a feedback record with `id` belongs to `projectName`.
1194
+ * Returns `true` when the record exists and matches, `false` otherwise.
1195
+ */
1196
+ async verifyProjectOwnership(id, projectName) {
1197
+ const record = await this.prisma.sitepingFeedback.findUnique({
1198
+ where: { id }
1199
+ // Only need projectName for the check — skip annotations
1200
+ });
1201
+ return record !== null && record.projectName === projectName;
1202
+ }
1203
+ };
1204
+ function createSitepingHandler2({
1205
+ prisma,
1206
+ store: providedStore,
1207
+ screenshotStorage,
1208
+ caseInsensitiveSearch,
1209
+ ...handlerOptions
1210
+ }) {
1211
+ if (!providedStore && !prisma) {
1212
+ throw new Error("[siteping] createSitepingHandler requires either `store` or `prisma`.");
1213
+ }
1214
+ const store = providedStore ?? new PrismaStore(prisma, {
1215
+ screenshotStorage,
1216
+ ...typeof caseInsensitiveSearch === "boolean" ? { caseInsensitiveSearch } : {}
1217
+ });
1218
+ return createSitepingHandler({ ...handlerOptions, store, describeError: actionableErrorMessage });
1219
+ }
1220
+ function isTableNotFoundError(error) {
1221
+ return hasOwn(error, "code") && error.code === "P2021";
1222
+ }
1223
+ function actionableErrorMessage(error) {
1224
+ if (isTableNotFoundError(error)) {
1225
+ return "Table 'SitepingFeedback' not found. Run 'npx prisma db push' to create it.";
1226
+ }
1227
+ return void 0;
1228
+ }
1229
+ // Annotate the CommonJS export names for ESM import in node:
1230
+ 0 && (module.exports = {
1231
+ PrismaStore,
1232
+ StoreDuplicateError,
1233
+ StoreNotFoundError,
1234
+ StorePersistenceError,
1235
+ createSitepingHandler,
1236
+ dispatchWebhook,
1237
+ dispatchWebhooks,
1238
+ flattenAnnotation,
1239
+ isStorePersistence
1240
+ });
1241
+ //# sourceMappingURL=index.cjs.map