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