@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/README.md +11 -11
- package/dist/beezping-core.d.cts +20 -0
- package/dist/beezping-core.d.ts +20 -0
- package/dist/concurrency.d.cts +14 -0
- package/dist/concurrency.d.ts +14 -0
- package/dist/constants/schema.d.cts +239 -0
- package/dist/constants/schema.d.ts +239 -0
- package/dist/deep-link.d.cts +14 -0
- package/dist/deep-link.d.ts +14 -0
- package/dist/email.d.cts +8 -5
- package/dist/email.d.ts +8 -5
- package/dist/errors.d.cts +28 -14
- package/dist/errors.d.ts +28 -14
- package/dist/filters.d.cts +15 -7
- package/dist/filters.d.ts +15 -7
- package/dist/i18n.d.cts +18 -0
- package/dist/i18n.d.ts +18 -0
- package/dist/index.cjs +178 -868
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +95 -213
- package/dist/index.d.ts +95 -213
- package/dist/index.js +175 -855
- package/dist/index.js.map +1 -1
- package/dist/schema.d.cts +12 -195
- package/dist/schema.d.ts +12 -195
- package/dist/screenshot-storage.d.cts +55 -30
- package/dist/screenshot-storage.d.ts +55 -30
- package/dist/store-helpers.d.cts +62 -34
- package/dist/store-helpers.d.ts +62 -34
- package/dist/type-utils.d.cts +8 -0
- package/dist/type-utils.d.ts +8 -0
- package/dist/types.d.cts +413 -154
- package/dist/types.d.ts +413 -154
- package/dist/wire.d.cts +42 -9
- package/dist/wire.d.ts +42 -9
- package/package.json +14 -10
- package/dist/siteping-core.d.cts +0 -17
- package/dist/siteping-core.d.ts +0 -17
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
|
-
// ../
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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:
|
|
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:
|
|
298
|
+
mimeType: screenshotMimeType(dataUrl)
|
|
1035
299
|
});
|
|
1036
300
|
return url;
|
|
1037
301
|
} catch (err) {
|
|
1038
302
|
console.warn(
|
|
1039
|
-
"[
|
|
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
|
-
"[
|
|
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
|
|
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
|
-
`[
|
|
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.
|
|
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.
|
|
354
|
+
return await this.prisma.beezpingFeedback.findUnique({
|
|
1086
355
|
where: { clientId },
|
|
1087
|
-
include:
|
|
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.
|
|
375
|
+
return { feedbacks: [], total: await this.prisma.beezpingFeedback.count({ where }) };
|
|
1107
376
|
}
|
|
1108
377
|
const [feedbacks, total] = await Promise.all([
|
|
1109
|
-
this.prisma.
|
|
378
|
+
this.prisma.beezpingFeedback.findMany({
|
|
1110
379
|
where,
|
|
1111
|
-
include:
|
|
1112
|
-
|
|
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.
|
|
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.
|
|
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:
|
|
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.
|
|
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.
|
|
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.
|
|
1154
|
-
where: { id }
|
|
1155
|
-
// Only need projectName for the check —
|
|
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
|
|
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
|
-
|
|
487
|
+
describeError,
|
|
488
|
+
...serverOptions
|
|
1166
489
|
}) {
|
|
1167
490
|
if (!providedStore && !prisma) {
|
|
1168
|
-
throw new Error("[
|
|
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
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
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
|
-
|
|
509
|
+
StoreValueTooLongError,
|
|
510
|
+
createBeezpingHandler,
|
|
1191
511
|
dispatchWebhook,
|
|
1192
512
|
dispatchWebhooks,
|
|
1193
513
|
flattenAnnotation,
|