@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.
@@ -0,0 +1,363 @@
1
+ import { FeedbackPayload, SitepingStore, ScreenshotStorage, FeedbackCreateInput, FeedbackRecord as FeedbackRecord$1, FeedbackQuery, FeedbackPage, FeedbackUpdateInput } from './siteping-core.js';
2
+ export { ScreenshotStorage, SitepingStore, StoreDuplicateError, StoreNotFoundError, StorePersistenceError, flattenAnnotation, isStorePersistence } from './siteping-core.js';
3
+ import { FeedbackStatus, FeedbackRecord, FeedbackType } from './siteping-core.js';
4
+
5
+ /** HTTP methods served by `createSitepingHandler`. */
6
+ type SitepingHttpMethod = "GET" | "POST" | "PATCH" | "DELETE" | "OPTIONS";
7
+
8
+ /** Options of the built-in shared-secret policy (the historical `adapter-prisma` behavior). */
9
+ interface ApiKeyAccessOptions {
10
+ /**
11
+ * Shared secret expected as `Authorization: Bearer {apiKey}`.
12
+ *
13
+ * - **When set:** every request not listed in `publicEndpoints` must carry it (401 otherwise).
14
+ * - **When not set:** the API is public, except DELETE/PATCH while
15
+ * `requireAuthForDestructive` is on.
16
+ */
17
+ apiKey?: string | undefined;
18
+ /** Methods that skip the key. Defaults to `['POST', 'OPTIONS']` when `apiKey` is set. */
19
+ publicEndpoints?: ReadonlyArray<SitepingHttpMethod>;
20
+ /**
21
+ * Whether DELETE/PATCH require `apiKey`. Defaults to `true`: without `apiKey`
22
+ * the handler refuses to start in production and answers 401 elsewhere.
23
+ * Set `false` only behind your own auth layer — or pass `access` instead.
24
+ */
25
+ requireAuthForDestructive?: boolean;
26
+ /**
27
+ * Blank `authorEmail` in responses to requests without a valid key.
28
+ * Defaults to `true` — reviewer emails are PII and GET must stay reachable
29
+ * by the widget.
30
+ */
31
+ redactUnauthenticatedEmails?: boolean;
32
+ }
33
+
34
+ /**
35
+ * Outgoing webhook notifications for newly-created feedbacks.
36
+ *
37
+ * Plug a Slack, Discord, or generic HTTP endpoint into `createSitepingHandler`
38
+ * to receive a payload whenever a feedback is successfully persisted. Webhooks
39
+ * are dispatched as fire-and-forget (`void Promise.all(...)`) so a slow or
40
+ * down receiver never blocks the client response — the feedback is already in
41
+ * the DB by the time we dial out.
42
+ *
43
+ * - **Type-specific formatting**: Slack uses `{ text, blocks }`, Discord uses
44
+ * `{ content, embeds }`, generic posts the record as JSON (minus `clientId`).
45
+ * - **Untrusted input**: `message` and `authorName` come from anonymous
46
+ * visitors. Slack text is escaped and Discord mention parsing is disabled,
47
+ * so a public feedback form can never be turned into a channel-wide ping.
48
+ * - **Timeout**: 5s by default (overridable per webhook).
49
+ * - **Error handling**: `config.onError(err, feedback.id)` is invoked when
50
+ * present; otherwise we log a one-liner to `console.warn` so the issue is
51
+ * surfaced without crashing the request.
52
+ */
53
+
54
+ /** Supported webhook integrations — drives the JSON body shape. */
55
+ type WebhookType = "slack" | "discord" | "generic";
56
+ /**
57
+ * Outgoing webhook configuration.
58
+ *
59
+ * - `url` — required, the HTTPS endpoint to POST to.
60
+ * - `type` — payload format. Defaults to `"generic"` (raw JSON).
61
+ * - `headers` — extra headers merged on top of `Content-Type: application/json`.
62
+ * Useful for signed-payload schemes (`X-Signature`, bearer tokens, …).
63
+ * - `timeoutMs` — abort the fetch after this many ms. Defaults to 5000.
64
+ * - `onError` — invoked with the underlying error and the feedback id when
65
+ * the dispatch fails (network error, non-2xx, timeout). The webhook is
66
+ * fire-and-forget, so this is your only chance to observe failures.
67
+ */
68
+ interface WebhookConfig {
69
+ url: string;
70
+ type?: WebhookType;
71
+ headers?: Record<string, string>;
72
+ timeoutMs?: number;
73
+ onError?: (err: Error, feedbackId: string) => void;
74
+ }
75
+ /** Block Kit envelope used by Slack incoming webhooks. */
76
+ interface SlackWebhookPayload {
77
+ text: string;
78
+ blocks: ReadonlyArray<SlackHeaderBlock | SlackSectionBlock | SlackContextBlock>;
79
+ }
80
+ interface SlackHeaderBlock {
81
+ type: "header";
82
+ text: {
83
+ type: "plain_text";
84
+ text: string;
85
+ emoji: true;
86
+ };
87
+ }
88
+ interface SlackSectionBlock {
89
+ type: "section";
90
+ text: {
91
+ type: "mrkdwn";
92
+ text: string;
93
+ };
94
+ }
95
+ interface SlackContextBlock {
96
+ type: "context";
97
+ elements: ReadonlyArray<{
98
+ type: "mrkdwn";
99
+ text: string;
100
+ }>;
101
+ }
102
+ /** Embed envelope used by Discord incoming webhooks. */
103
+ interface DiscordWebhookPayload {
104
+ content: string;
105
+ embeds: ReadonlyArray<{
106
+ title: string;
107
+ description: string;
108
+ color: number;
109
+ fields: ReadonlyArray<{
110
+ name: string;
111
+ value: string;
112
+ inline: boolean;
113
+ }>;
114
+ timestamp: string;
115
+ }>;
116
+ /**
117
+ * Mention parsing is switched off: `content` carries end-user text, so an
118
+ * author called `@everyone` must render as text, never as a notification.
119
+ */
120
+ allowed_mentions: {
121
+ parse: ReadonlyArray<"roles" | "users" | "everyone">;
122
+ };
123
+ }
124
+ /**
125
+ * Generic webhook body — the stored record as JSON. `clientId` is stripped like
126
+ * on every other output: it is the browser-local dedup secret and the POST
127
+ * replay path hands the full record to whoever presents it.
128
+ */
129
+ type GenericWebhookPayload = Omit<FeedbackRecord, "clientId">;
130
+ /** Mapping from webhook type to its concrete body shape. */
131
+ interface WebhookPayloadMap {
132
+ slack: SlackWebhookPayload;
133
+ discord: DiscordWebhookPayload;
134
+ generic: GenericWebhookPayload;
135
+ }
136
+ /**
137
+ * Dispatch a single webhook. Fire-and-forget: never throws, never rejects.
138
+ *
139
+ * - Builds the type-specific payload.
140
+ * - POSTs with an `AbortSignal` timeout.
141
+ * - On any error (network, non-2xx, timeout, exception), invokes
142
+ * `config.onError(err, feedbackId)` if provided; otherwise logs a one-liner.
143
+ */
144
+ declare function dispatchWebhook(config: WebhookConfig, feedback: FeedbackRecord): Promise<void>;
145
+ /**
146
+ * Dispatch every configured webhook in parallel. Awaiting the returned promise
147
+ * lets tests synchronize on completion, but production callers should drop the
148
+ * promise on the floor (`void dispatchWebhooks(...)`) so the HTTP response
149
+ * isn't held back on slow receivers.
150
+ */
151
+ declare function dispatchWebhooks(configs: readonly WebhookConfig[], feedback: FeedbackRecord): Promise<void>;
152
+ /** One handler per HTTP method — mount them on any Fetch-API router. */
153
+ interface SitepingHandler {
154
+ OPTIONS: (request: Request) => Response;
155
+ POST: (request: Request) => Promise<Response>;
156
+ GET: (request: Request) => Promise<Response>;
157
+ PATCH: (request: Request) => Promise<Response>;
158
+ DELETE: (request: Request) => Promise<Response>;
159
+ }
160
+ interface FeedbackPatchInput {
161
+ id: string;
162
+ projectName: string;
163
+ status: FeedbackStatus;
164
+ }
165
+ interface FeedbackDeleteSingle {
166
+ id: string;
167
+ projectName: string;
168
+ }
169
+ interface FeedbackDeleteAll {
170
+ projectName: string;
171
+ deleteAll: true;
172
+ }
173
+ type FeedbackDeleteInput = FeedbackDeleteSingle | FeedbackDeleteAll;
174
+ interface GetQueryInput {
175
+ projectName: string;
176
+ /** Set to 1 by schema default when omitted from raw input. */
177
+ page: number;
178
+ /** Set to 50 by schema default when omitted from raw input. */
179
+ limit: number;
180
+ type?: FeedbackType | undefined;
181
+ status?: FeedbackStatus | undefined;
182
+ statuses?: FeedbackStatus[] | undefined;
183
+ search?: string | undefined;
184
+ url?: string | undefined;
185
+ urlPattern?: string | undefined;
186
+ }
187
+
188
+ /**
189
+ * @deprecated The create wire shape is core's `FeedbackPayload` — import
190
+ * that instead. This alias is kept for one release cycle.
191
+ */
192
+ type FeedbackCreateSchemaInput = FeedbackPayload;
193
+
194
+ /**
195
+ * Structural type for a Prisma model delegate (`prisma.sitepingFeedback`).
196
+ *
197
+ * Arguments are kept `unknown` so any Prisma version's generated client
198
+ * satisfies the constraint; the adapter assembles type-safe payloads
199
+ * internally before forwarding them.
200
+ *
201
+ * Members use **method syntax** (`create(args)`) rather than function-property
202
+ * syntax (`create: (args) => ...`) on purpose: under `strictFunctionTypes`,
203
+ * function-property parameters are checked *contravariantly*, so a real
204
+ * generated delegate — whose `create(args: SpecificArgs)` takes a type narrower
205
+ * than `unknown` — would fail to assign to `PrismaModelDelegate`. Method
206
+ * signatures are checked *bivariantly* on parameters, which is exactly what we
207
+ * want for structurally matching a third-party generated client (#99).
208
+ */
209
+ interface PrismaModelDelegate {
210
+ create(args: unknown): Promise<unknown>;
211
+ findMany(args: unknown): Promise<unknown[]>;
212
+ findUnique(args: unknown): Promise<unknown>;
213
+ update(args: unknown): Promise<unknown>;
214
+ delete(args: unknown): Promise<unknown>;
215
+ deleteMany(args: unknown): Promise<unknown>;
216
+ count(args: unknown): Promise<number>;
217
+ }
218
+ /**
219
+ * Minimal Prisma client shape expected by this adapter.
220
+ * Consumers pass their own `PrismaClient` instance at runtime — this interface
221
+ * defines the subset of methods the adapter actually uses, so it can be
222
+ * referenced in handler option types without importing `@prisma/client`.
223
+ */
224
+ interface SitepingPrismaClient {
225
+ sitepingFeedback: PrismaModelDelegate;
226
+ }
227
+ /**
228
+ * Options accepted by `PrismaStore`.
229
+ */
230
+ interface PrismaStoreOptions {
231
+ /**
232
+ * When `true`, the `?search=` filter is built with `mode: "insensitive"`
233
+ * (case-insensitive across all letters, including non-ASCII).
234
+ *
235
+ * When `false`, the filter is built without `mode` — uses each database's
236
+ * default `LIKE` semantics (case-insensitive ASCII on SQLite by default;
237
+ * case-sensitive on PostgreSQL with the standard `LIKE` operator;
238
+ * collation-driven on MySQL and SQL Server).
239
+ *
240
+ * When omitted, the value is auto-detected from the Prisma client's active
241
+ * provider: providers whose generated client exposes `mode?: QueryMode`
242
+ * (`postgresql`, `mongodb`, `cockroachdb`) get `true`; others (`mysql`,
243
+ * `sqlite`, `sqlserver`) get `false`. Unknown / undetectable providers
244
+ * default to `false` — `contains` without `mode` works on every provider;
245
+ * `mode: "insensitive"` throws on MySQL/SQLite/SQL Server, so the safer
246
+ * default is to omit it.
247
+ */
248
+ caseInsensitiveSearch?: boolean;
249
+ /**
250
+ * Optional storage backend for screenshots. Without it, the data URL is
251
+ * persisted inline on `Feedback.screenshotUrl` with a one-time warn.
252
+ */
253
+ screenshotStorage?: ScreenshotStorage | undefined;
254
+ }
255
+ /**
256
+ * Prisma-backed implementation of `SitepingStore`.
257
+ *
258
+ * Wraps a PrismaClient to satisfy the abstract store interface.
259
+ *
260
+ * Pass `screenshotStorage` to externalise screenshots (S3, R2, B2, …) — the
261
+ * widget's data URL is uploaded and only the returned URL is persisted, so
262
+ * the database stays small. Without `screenshotStorage`, the data URL is
263
+ * persisted inline (logged once on first use as a heads-up).
264
+ */
265
+ declare class PrismaStore implements SitepingStore {
266
+ /** @internal */
267
+ private prisma;
268
+ private readonly screenshotStorage;
269
+ /** Module-level flag would leak across PrismaStore instances in tests; use per-instance. */
270
+ private inlineFallbackWarned;
271
+ /** @internal */
272
+ private caseInsensitiveSearch;
273
+ constructor(prisma: SitepingPrismaClient, options?: PrismaStoreOptions);
274
+ createFeedback(data: FeedbackCreateInput): Promise<FeedbackRecord$1>;
275
+ private insertFeedback;
276
+ /**
277
+ * Resolve the value to persist on `Feedback.screenshotUrl`.
278
+ *
279
+ * - No data URL → null
280
+ * - Storage configured → upload, return remote URL. Upload failures
281
+ * persist `null` (drop the screenshot) rather than silently inlining
282
+ * the data URL — an inline fallback would bloat Postgres unnoticed
283
+ * during a multi-minute storage outage. The feedback message itself is
284
+ * preserved; only the screenshot is missing, and the warn surfaces it.
285
+ * - No storage → inline base64, with a one-time warn so prod operators
286
+ * notice the footgun.
287
+ *
288
+ * Operators who prefer the legacy inline-on-failure behaviour can wrap
289
+ * their `ScreenshotStorage.upload` with their own catch + return the
290
+ * data URL — the adapter treats whatever the storage returns as final.
291
+ */
292
+ private persistScreenshot;
293
+ /**
294
+ * Best-effort cleanup of stored screenshots through `ScreenshotStorage.delete`
295
+ * — the hook the interface documents for feedback deletion. Failures are
296
+ * logged and swallowed: an orphaned object is preferable to a delete that
297
+ * reports failure after the row is already gone. Inline `data:` URLs and
298
+ * stores without a `delete` hook are skipped.
299
+ */
300
+ private discardScreenshots;
301
+ /** URLs of the stored screenshots in `projectName` — only fetched when a `delete` hook can use them. */
302
+ private storedScreenshotUrls;
303
+ findByClientId(clientId: string): Promise<FeedbackRecord$1 | null>;
304
+ getFeedbacks(query: FeedbackQuery): Promise<FeedbackPage>;
305
+ updateFeedback(id: string, data: FeedbackUpdateInput): Promise<FeedbackRecord$1>;
306
+ deleteFeedback(id: string): Promise<void>;
307
+ deleteAllFeedbacks(projectName: string): Promise<void>;
308
+ /**
309
+ * Verify that a feedback record with `id` belongs to `projectName`.
310
+ * Returns `true` when the record exists and matches, `false` otherwise.
311
+ */
312
+ verifyProjectOwnership(id: string, projectName: string): Promise<boolean>;
313
+ }
314
+ interface HandlerOptions extends ApiKeyAccessOptions {
315
+ /** Prisma client — used when `store` is not provided. Wrapped in a `PrismaStore` internally. */
316
+ prisma?: SitepingPrismaClient;
317
+ /** Abstract store — when provided, takes precedence over `prisma`. */
318
+ store?: SitepingStore;
319
+ /**
320
+ * Optional storage backend for screenshots. Used only with `prisma`
321
+ * (ignored when a custom `store` is passed — that store is responsible
322
+ * for its own screenshot strategy). Without a storage, the data URL is
323
+ * persisted inline on `Feedback.screenshotUrl` with a one-time warn.
324
+ */
325
+ screenshotStorage?: ScreenshotStorage;
326
+ /** Allowed CORS origins — when set, validates the Origin header */
327
+ allowedOrigins?: ReadonlyArray<string> | undefined;
328
+ /**
329
+ * Override case-insensitive search behaviour for the built-in `PrismaStore`.
330
+ *
331
+ * Only applied when `prisma` is provided (not when a custom `store` is
332
+ * passed). See `PrismaStoreOptions.caseInsensitiveSearch` for details on
333
+ * auto-detection and per-provider semantics.
334
+ */
335
+ caseInsensitiveSearch?: boolean;
336
+ /**
337
+ * Outgoing webhooks fired after a feedback is successfully persisted.
338
+ * Fire-and-forget; provide `onError` on each config to observe failures.
339
+ */
340
+ webhooks?: WebhookConfig | ReadonlyArray<WebhookConfig>;
341
+ }
342
+ /**
343
+ * Create request handlers for the Siteping API endpoint.
344
+ *
345
+ * Accepts either a `store` (abstract) or a `prisma` client (backwards compatible).
346
+ * When `prisma` is provided without `store`, it is wrapped in a `PrismaStore`.
347
+ * For custom auth (sessions, roles), lifecycle hooks or input transforms use
348
+ * `createSitepingHandler` from `@beezping/server` with a `PrismaStore`.
349
+ *
350
+ * **Rate limiting** is not handled by this library. Apply rate limiting at the
351
+ * framework or reverse-proxy level (e.g. Next.js middleware, Nginx, Cloudflare).
352
+ *
353
+ * @example Next.js App Router — `app/api/siteping/route.ts`
354
+ * ```ts
355
+ * import { createSitepingHandler } from '@beezping/adapter-prisma'
356
+ * import { prisma } from '@/lib/prisma'
357
+ *
358
+ * export const { GET, POST, PATCH, DELETE, OPTIONS } = createSitepingHandler({ prisma })
359
+ * ```
360
+ */
361
+ declare function createSitepingHandler({ prisma, store: providedStore, screenshotStorage, caseInsensitiveSearch, ...handlerOptions }: HandlerOptions): SitepingHandler;
362
+
363
+ export { type DiscordWebhookPayload, type FeedbackCreateSchemaInput, type FeedbackDeleteInput, type FeedbackPatchInput, type GenericWebhookPayload, type GetQueryInput, type HandlerOptions, type PrismaModelDelegate, PrismaStore, type PrismaStoreOptions, type SitepingHandler, type SitepingHttpMethod, type SitepingPrismaClient, type SlackWebhookPayload, type WebhookConfig, type WebhookPayloadMap, type WebhookType, createSitepingHandler, dispatchWebhook, dispatchWebhooks };