@beezping/adapter-prisma 0.7.0 → 0.8.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 CHANGED
@@ -1,9 +1,7 @@
1
1
  "use strict";
2
- var __create = Object.create;
3
2
  var __defProp = Object.defineProperty;
4
3
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
4
  var __getOwnPropNames = Object.getOwnPropertyNames;
6
- var __getProtoOf = Object.getPrototypeOf;
7
5
  var __hasOwnProp = Object.prototype.hasOwnProperty;
8
6
  var __export = (target, all) => {
9
7
  for (var name in all)
@@ -17,14 +15,6 @@ var __copyProps = (to, from, except, desc) => {
17
15
  }
18
16
  return to;
19
17
  };
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
18
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
19
 
30
20
  // src/index.ts
@@ -32,16 +22,38 @@ var index_exports = {};
32
22
  __export(index_exports, {
33
23
  PrismaStore: () => PrismaStore,
34
24
  StoreDuplicateError: () => StoreDuplicateError,
25
+ StoreLimitError: () => StoreLimitError,
35
26
  StoreNotFoundError: () => StoreNotFoundError,
36
27
  StorePersistenceError: () => StorePersistenceError,
37
- createSitepingHandler: () => createSitepingHandler2,
38
- dispatchWebhook: () => dispatchWebhook,
39
- dispatchWebhooks: () => dispatchWebhooks,
28
+ StoreValueTooLongError: () => StoreValueTooLongError,
29
+ createBeezpingHandler: () => createBeezpingHandler,
30
+ dispatchWebhook: () => import_server2.dispatchWebhook,
31
+ dispatchWebhooks: () => import_server2.dispatchWebhooks,
40
32
  flattenAnnotation: () => flattenAnnotation,
41
33
  isStorePersistence: () => isStorePersistence
42
34
  });
43
35
  module.exports = __toCommonJS(index_exports);
44
36
 
37
+ // ../core/src/concurrency.ts
38
+ async function settleWithConcurrencyLimit(items, concurrency, task) {
39
+ const results = new Array(items.length);
40
+ let nextIndex = 0;
41
+ const runWorker = async () => {
42
+ while (nextIndex < items.length) {
43
+ const index = nextIndex;
44
+ nextIndex += 1;
45
+ try {
46
+ results[index] = { status: "fulfilled", value: await task(items[index]) };
47
+ } catch (error) {
48
+ results[index] = { status: "rejected", reason: error };
49
+ }
50
+ }
51
+ };
52
+ const workerCount = Math.min(Math.max(1, concurrency), items.length);
53
+ await Promise.all(Array.from({ length: workerCount }, runWorker));
54
+ return results;
55
+ }
56
+
45
57
  // ../core/src/filters.ts
46
58
  var DEFAULT_PAGE_LIMIT = 50;
47
59
  var MAX_PAGE_LIMIT = 100;
@@ -66,6 +78,7 @@ function hasOwn(value, key) {
66
78
  }
67
79
 
68
80
  // ../core/src/types.ts
81
+ var MAX_COMMENTS_PER_FEEDBACK = 100;
69
82
  var StoreNotFoundError = class extends Error {
70
83
  code = "STORE_NOT_FOUND";
71
84
  constructor(message = "Record not found", options) {
@@ -87,6 +100,20 @@ var StorePersistenceError = class extends Error {
87
100
  this.name = "StorePersistenceError";
88
101
  }
89
102
  };
103
+ var StoreLimitError = class extends Error {
104
+ code = "STORE_LIMIT";
105
+ constructor(message = "Store limit reached", options) {
106
+ super(message, options);
107
+ this.name = "StoreLimitError";
108
+ }
109
+ };
110
+ var StoreValueTooLongError = class extends Error {
111
+ code = "STORE_VALUE_TOO_LONG";
112
+ constructor(message = "Value too long for the store", options) {
113
+ super(message, options);
114
+ this.name = "StoreValueTooLongError";
115
+ }
116
+ };
90
117
  function hasErrorCode(error, code) {
91
118
  return hasOwn(error, "code") && error.code === code;
92
119
  }
@@ -126,826 +153,20 @@ function flattenAnnotation(ann) {
126
153
  };
127
154
  }
128
155
 
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
- };
156
+ // ../core/src/screenshot-storage.ts
157
+ var SCREENSHOT_DELETE_CONCURRENCY = 32;
158
+ function screenshotMimeType(dataUrl) {
159
+ return /^data:(image\/(?:jpeg|png|webp))[;,]/i.exec(dataUrl)?.[1]?.toLowerCase() ?? "image/jpeg";
945
160
  }
946
161
 
947
162
  // src/index.ts
163
+ var import_server = require("@beezping/server");
164
+ var import_server2 = require("@beezping/server");
948
165
  var INCLUDE_ANNOTATIONS = { annotations: true };
166
+ var INCLUDE_ANNOTATIONS_AND_COMMENTS = {
167
+ annotations: true,
168
+ comments: { orderBy: [{ createdAt: "asc" }, { id: "asc" }] }
169
+ };
949
170
  var PROVIDERS_SUPPORTING_INSENSITIVE_MODE = /* @__PURE__ */ new Set([
950
171
  "postgresql",
951
172
  "postgres",
@@ -970,6 +191,7 @@ function toStoreError(error) {
970
191
  if (error instanceof StoreNotFoundError || error instanceof StoreDuplicateError) return error;
971
192
  if (isStoreNotFound(error)) return new StoreNotFoundError(void 0, { cause: error });
972
193
  if (isStoreDuplicate(error)) return new StoreDuplicateError(void 0, { cause: error });
194
+ if (hasOwn(error, "code") && error.code === "P2000") return new StoreValueTooLongError(void 0, { cause: error });
973
195
  return error;
974
196
  }
975
197
  function isStoredScreenshotUrl(url) {
@@ -983,9 +205,17 @@ var PrismaStore = class {
983
205
  inlineFallbackWarned = false;
984
206
  /** @internal */
985
207
  caseInsensitiveSearch;
208
+ /** Read shape of every feedback query — with the thread once the client has the comment model. */
209
+ include;
986
210
  constructor(prisma, options = {}) {
987
211
  this.prisma = prisma;
988
212
  this.screenshotStorage = options.screenshotStorage;
213
+ const comments = prisma.beezpingComment;
214
+ this.include = comments ? INCLUDE_ANNOTATIONS_AND_COMMENTS : INCLUDE_ANNOTATIONS;
215
+ if (comments) {
216
+ this.addComment ??= (feedbackId, data) => this.insertComment(comments, feedbackId, data);
217
+ this.deleteComment ??= (feedbackId, commentId) => this.removeComment(comments, feedbackId, commentId);
218
+ }
989
219
  if (typeof options.caseInsensitiveSearch === "boolean") {
990
220
  this.caseInsensitiveSearch = options.caseInsensitiveSearch;
991
221
  } else {
@@ -998,12 +228,36 @@ var PrismaStore = class {
998
228
  try {
999
229
  return await this.insertFeedback(data, screenshotUrl);
1000
230
  } catch (error) {
1001
- if (isStoreDuplicate(error)) await this.discardScreenshots([screenshotUrl]);
231
+ if (isStoreDuplicate(error)) await this.discardUnreferencedUpload(screenshotUrl, data.clientId);
1002
232
  throw toStoreError(error);
1003
233
  }
1004
234
  }
235
+ /**
236
+ * Drop the screenshot uploaded by a replay of a stored clientId — unless the
237
+ * stored row references it. Uploads are keyed on clientId, so with a
238
+ * deterministic key (`feedback/${feedbackId}.jpg`) the replay wrote to the
239
+ * very object the existing row points at: deleting it would strip the
240
+ * surviving feedback of its screenshot. If the lookup itself fails the
241
+ * object is kept — an orphan beats data loss. Other insert failures never
242
+ * get here: with such a key, a retry on another instance may have rewritten
243
+ * the object and not yet inserted the row that will point at it.
244
+ */
245
+ async discardUnreferencedUpload(url, clientId) {
246
+ if (!isStoredScreenshotUrl(url) || !this.screenshotStorage?.delete) return;
247
+ let existing;
248
+ try {
249
+ existing = await this.prisma.beezpingFeedback.findUnique({
250
+ where: { clientId },
251
+ select: { screenshotUrl: true }
252
+ });
253
+ } catch {
254
+ return;
255
+ }
256
+ if (existing?.screenshotUrl === url) return;
257
+ await this.discardScreenshots([url]);
258
+ }
1005
259
  async insertFeedback(data, screenshotUrl) {
1006
- return await this.prisma.sitepingFeedback.create({
260
+ return await this.prisma.beezpingFeedback.create({
1007
261
  data: {
1008
262
  projectName: data.projectName,
1009
263
  type: data.type,
@@ -1014,10 +268,10 @@ var PrismaStore = class {
1014
268
  screenshotUrl,
1015
269
  // Persisted as JSON when the model has a `screenshotRegion Json?`
1016
270
  // column — same omit-when-null contract as `diagnostics` below, so
1017
- // hosts that haven't run `npx siteping sync` keep working.
271
+ // hosts that haven't run `npx beezping sync` keep working.
1018
272
  ...data.screenshotRegion ? { screenshotRegion: data.screenshotRegion } : {},
1019
273
  // 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
274
+ // Hosts that haven't run `npx beezping sync` keep their schema as-is
1021
275
  // and Prisma will throw if we pass an unknown column, so omit the
1022
276
  // key entirely when diagnostics is null.
1023
277
  ...data.diagnostics ? { diagnostics: data.diagnostics } : {},
@@ -1050,7 +304,7 @@ var PrismaStore = class {
1050
304
  }))
1051
305
  }
1052
306
  },
1053
- include: INCLUDE_ANNOTATIONS
307
+ include: this.include
1054
308
  });
1055
309
  }
1056
310
  /**
@@ -1075,12 +329,12 @@ var PrismaStore = class {
1075
329
  try {
1076
330
  const { url } = await this.screenshotStorage.upload(dataUrl, {
1077
331
  feedbackId: clientId,
1078
- mimeType: "image/jpeg"
332
+ mimeType: screenshotMimeType(dataUrl)
1079
333
  });
1080
334
  return url;
1081
335
  } catch (err) {
1082
336
  console.warn(
1083
- "[siteping] screenshotStorage.upload failed \u2014 feedback will be saved without a screenshot. Wrap your storage's upload to handle this differently:",
337
+ "[beezping] screenshotStorage.upload failed \u2014 feedback will be saved without a screenshot. Wrap your storage's upload to handle this differently:",
1084
338
  err
1085
339
  );
1086
340
  return null;
@@ -1089,7 +343,7 @@ var PrismaStore = class {
1089
343
  if (!this.inlineFallbackWarned) {
1090
344
  this.inlineFallbackWarned = true;
1091
345
  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."
346
+ "[beezping] 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
347
  );
1094
348
  }
1095
349
  return dataUrl;
@@ -1100,17 +354,22 @@ var PrismaStore = class {
1100
354
  * logged and swallowed: an orphaned object is preferable to a delete that
1101
355
  * reports failure after the row is already gone. Inline `data:` URLs and
1102
356
  * stores without a `delete` hook are skipped.
357
+ *
358
+ * Deletes run through a pool of at most {@link SCREENSHOT_DELETE_CONCURRENCY}
359
+ * concurrent calls: a project delete may free thousands of objects, and one
360
+ * socket each at once would exhaust the file descriptors of a serverless
361
+ * function, orphaning most of them.
1103
362
  */
1104
363
  async discardScreenshots(urls) {
1105
364
  const remove = this.screenshotStorage?.delete?.bind(this.screenshotStorage);
1106
365
  if (!remove) return;
1107
366
  const stored = urls.filter(isStoredScreenshotUrl);
1108
367
  if (stored.length === 0) return;
1109
- const results = await Promise.allSettled(stored.map((url) => remove(url)));
368
+ const results = await settleWithConcurrencyLimit(stored, SCREENSHOT_DELETE_CONCURRENCY, async (url) => remove(url));
1110
369
  results.forEach((result, index) => {
1111
370
  if (result.status === "rejected") {
1112
371
  console.warn(
1113
- `[siteping] screenshotStorage.delete failed for ${stored[index]} \u2014 object left in place:`,
372
+ `[beezping] screenshotStorage.delete failed for ${stored[index]} \u2014 object left in place:`,
1114
373
  result.reason
1115
374
  );
1116
375
  }
@@ -1119,16 +378,16 @@ var PrismaStore = class {
1119
378
  /** URLs of the stored screenshots in `projectName` — only fetched when a `delete` hook can use them. */
1120
379
  async storedScreenshotUrls(projectName) {
1121
380
  if (!this.screenshotStorage?.delete) return [];
1122
- const rows = await this.prisma.sitepingFeedback.findMany({
381
+ const rows = await this.prisma.beezpingFeedback.findMany({
1123
382
  where: { projectName, screenshotUrl: { not: null } },
1124
383
  select: { screenshotUrl: true }
1125
384
  });
1126
385
  return rows.map((row) => row.screenshotUrl).filter(isStoredScreenshotUrl);
1127
386
  }
1128
387
  async findByClientId(clientId) {
1129
- return await this.prisma.sitepingFeedback.findUnique({
388
+ return await this.prisma.beezpingFeedback.findUnique({
1130
389
  where: { clientId },
1131
- include: INCLUDE_ANNOTATIONS
390
+ include: this.include
1132
391
  });
1133
392
  }
1134
393
  async getFeedbacks(query) {
@@ -1147,38 +406,83 @@ var PrismaStore = class {
1147
406
  where.message = this.caseInsensitiveSearch ? { contains: search, mode: "insensitive" } : { contains: search };
1148
407
  }
1149
408
  if (isUnreachableOffset(skip)) {
1150
- return { feedbacks: [], total: await this.prisma.sitepingFeedback.count({ where }) };
409
+ return { feedbacks: [], total: await this.prisma.beezpingFeedback.count({ where }) };
1151
410
  }
1152
411
  const [feedbacks, total] = await Promise.all([
1153
- this.prisma.sitepingFeedback.findMany({
412
+ this.prisma.beezpingFeedback.findMany({
1154
413
  where,
1155
- include: INCLUDE_ANNOTATIONS,
1156
- orderBy: { createdAt: "desc" },
414
+ include: this.include,
415
+ // `id` breaks createdAt ties: SQL leaves equal rows unordered, so
416
+ // OFFSET pages could otherwise repeat one row and skip another.
417
+ orderBy: [{ createdAt: "desc" }, { id: "desc" }],
1157
418
  skip,
1158
419
  take: limit
1159
420
  }),
1160
- this.prisma.sitepingFeedback.count({ where })
421
+ this.prisma.beezpingFeedback.count({ where })
1161
422
  ]);
1162
423
  return { feedbacks, total };
1163
424
  }
1164
425
  async updateFeedback(id, data) {
1165
426
  try {
1166
- return await this.prisma.sitepingFeedback.update({
427
+ return await this.prisma.beezpingFeedback.update({
1167
428
  where: { id },
1168
429
  data: {
1169
430
  status: data.status,
1170
431
  resolvedAt: data.resolvedAt
1171
432
  },
1172
- include: INCLUDE_ANNOTATIONS
433
+ include: this.include
434
+ });
435
+ } catch (error) {
436
+ throw toStoreError(error);
437
+ }
438
+ }
439
+ /**
440
+ * Insert a comment. A replayed `clientId` returns the stored comment, looked
441
+ * up first so a replay never runs into the thread cap; a concurrent replay
442
+ * that wins the insert race surfaces as P2002 and is read back the same
443
+ * way. The `connect` turns a missing feedback into Prisma's P2025 on every
444
+ * provider, rather than each database's own foreign-key error. The cap on
445
+ * `client` comments is a count before the insert, so posts racing for the
446
+ * last free slot may overshoot it; a `team` comment skips it.
447
+ */
448
+ async insertComment(comments, feedbackId, data) {
449
+ const replayed = await this.findComment(comments, data.clientId);
450
+ if (replayed) return replayed;
451
+ if (data.authorRole === "client" && await comments.count({ where: { feedbackId, authorRole: "client" } }) >= MAX_COMMENTS_PER_FEEDBACK) {
452
+ throw new StoreLimitError(`A thread holds at most ${MAX_COMMENTS_PER_FEEDBACK} client comments`);
453
+ }
454
+ try {
455
+ return await comments.create({
456
+ data: {
457
+ feedback: { connect: { id: feedbackId } },
458
+ body: data.body,
459
+ authorName: data.authorName,
460
+ authorEmail: data.authorEmail,
461
+ authorRole: data.authorRole,
462
+ clientId: data.clientId
463
+ }
1173
464
  });
1174
465
  } catch (error) {
466
+ const winner = isStoreDuplicate(error) ? await this.findComment(comments, data.clientId) : null;
467
+ if (winner) return winner;
1175
468
  throw toStoreError(error);
1176
469
  }
1177
470
  }
471
+ async findComment(comments, clientId) {
472
+ return await comments.findUnique({ where: { clientId } });
473
+ }
474
+ /**
475
+ * Delete one comment. Both ids go in one `deleteMany`, so a comment of
476
+ * another thread and an unknown one are the same zero-row miss.
477
+ */
478
+ async removeComment(comments, feedbackId, commentId) {
479
+ const { count } = await comments.deleteMany({ where: { id: commentId, feedbackId } });
480
+ if (count === 0) throw new StoreNotFoundError();
481
+ }
1178
482
  async deleteFeedback(id) {
1179
483
  let deleted;
1180
484
  try {
1181
- deleted = await this.prisma.sitepingFeedback.delete({ where: { id } });
485
+ deleted = await this.prisma.beezpingFeedback.delete({ where: { id } });
1182
486
  } catch (error) {
1183
487
  throw toStoreError(error);
1184
488
  }
@@ -1186,7 +490,7 @@ var PrismaStore = class {
1186
490
  }
1187
491
  async deleteAllFeedbacks(projectName) {
1188
492
  const screenshotUrls = await this.storedScreenshotUrls(projectName);
1189
- await this.prisma.sitepingFeedback.deleteMany({ where: { projectName } });
493
+ await this.prisma.beezpingFeedback.deleteMany({ where: { projectName } });
1190
494
  await this.discardScreenshots(screenshotUrls);
1191
495
  }
1192
496
  /**
@@ -1194,45 +498,51 @@ var PrismaStore = class {
1194
498
  * Returns `true` when the record exists and matches, `false` otherwise.
1195
499
  */
1196
500
  async verifyProjectOwnership(id, projectName) {
1197
- const record = await this.prisma.sitepingFeedback.findUnique({
1198
- where: { id }
1199
- // Only need projectName for the check — skip annotations
501
+ const record = await this.prisma.beezpingFeedback.findUnique({
502
+ where: { id },
503
+ // Only need projectName for the check — not the annotations, nor an
504
+ // inline screenshot data URL or the diagnostics JSON
505
+ select: { projectName: true }
1200
506
  });
1201
507
  return record !== null && record.projectName === projectName;
1202
508
  }
1203
509
  };
1204
- function createSitepingHandler2({
510
+ function describePrismaError(error) {
511
+ if (hasOwn(error, "code") && error.code === "P2021") {
512
+ return "A Beezping table is missing. Run 'npx prisma db push' (or apply your migrations) to create it.";
513
+ }
514
+ return void 0;
515
+ }
516
+ function createBeezpingHandler({
1205
517
  prisma,
1206
518
  store: providedStore,
1207
519
  screenshotStorage,
1208
520
  caseInsensitiveSearch,
1209
- ...handlerOptions
521
+ describeError,
522
+ ...serverOptions
1210
523
  }) {
1211
524
  if (!providedStore && !prisma) {
1212
- throw new Error("[siteping] createSitepingHandler requires either `store` or `prisma`.");
525
+ throw new Error("[beezping] createBeezpingHandler requires either `store` or `prisma`.");
1213
526
  }
1214
527
  const store = providedStore ?? new PrismaStore(prisma, {
1215
528
  screenshotStorage,
1216
529
  ...typeof caseInsensitiveSearch === "boolean" ? { caseInsensitiveSearch } : {}
1217
530
  });
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;
531
+ return (0, import_server.createBeezpingHandler)({
532
+ ...serverOptions,
533
+ store,
534
+ describeError: (error) => describeError?.(error) ?? describePrismaError(error)
535
+ });
1228
536
  }
1229
537
  // Annotate the CommonJS export names for ESM import in node:
1230
538
  0 && (module.exports = {
1231
539
  PrismaStore,
1232
540
  StoreDuplicateError,
541
+ StoreLimitError,
1233
542
  StoreNotFoundError,
1234
543
  StorePersistenceError,
1235
- createSitepingHandler,
544
+ StoreValueTooLongError,
545
+ createBeezpingHandler,
1236
546
  dispatchWebhook,
1237
547
  dispatchWebhooks,
1238
548
  flattenAnnotation,