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