Adapter Prisma
L'adapter de production — des handlers HTTP adossés à votre base, avec auth, CORS, redaction et webhooks.
@siteping/adapter-prisma transforme une URL de votre app en API de feedback complète. Il gère Prisma Client v5, v6 et v7, et exige Node 20+.
npm i @siteping/adapter-prismaLe montage
La factory renvoie un handler standard du Web (Request → Response) par méthode HTTP — les monter est le travail de votre framework :
// app/api/siteping/route.ts — Next.js App Router
import { createSitepingHandler } from "@siteping/adapter-prisma";
import { prisma } from "@/lib/prisma";
export const { GET, POST, PATCH, DELETE, OPTIONS } = createSitepingHandler({ prisma });Tout framework qui parle Request/Response du Web (Hono, Remix, SvelteKit, …) peut monter les mêmes handlers sur un unique endpoint et répartir par méthode.
Options
| Option | Type | Défaut | Ce que ça fait |
|---|---|---|---|
prisma | SitepingPrismaClient | — | Votre client Prisma. Obligatoire sauf si vous passez store |
store | SitepingStore | — | Utiliser n'importe quel store au lieu de Prisma (prioritaire) |
apiKey | string | — | Active l'auth Bearer. Les requêtes envoient Authorization: Bearer <clé> |
publicEndpoints | SitepingHttpMethod[] | ["POST", "OPTIONS"] quand apiKey est défini | Méthodes qui contournent l'auth. Passer une valeur remplace le défaut — incluez "POST" vous-même, sinon le widget ne peut plus envoyer |
requireAuthForDestructive | boolean | true | Sans apiKey, PATCH et DELETE répondent 401. En NODE_ENV=production, la factory refuse de démarrer sans apiKey |
redactUnauthenticatedEmails | boolean | true | Voir ce que les réponses exposent |
allowedOrigins | string[] | — | Liste blanche CORS en correspondance exacte. Aucun joker — ["*"] ne correspond à rien ; non défini signifie aucun en-tête CORS du tout |
screenshotStorage | ScreenshotStorage | — | Envoyer les captures quelque part de réel au lieu de les inliner en URL data:. Ignoré quand vous passez un store personnalisé |
caseInsensitiveSearch | boolean | détecté automatiquement | Détecté depuis le provider Prisma (actif pour PostgreSQL, MongoDB, CockroachDB). Ne s'applique que si prisma est fourni |
webhooks | WebhookConfig | WebhookConfig[] | — | Déclenchés à chaque nouveau feedback — voir webhooks |
Le modèle de sécurité, franchement
- Les lectures sont publiques par défaut. Sans
apiKey, quiconque connaît l'URL peut lister les feedbacks (avec les e-mails masqués — voir plus bas). - Les appels destructifs, non. PATCH et DELETE répondent
401sauf si vous définissez unapiKeyou vous en désengagez explicitement avecrequireAuthForDestructive: false. En production, la factory lève au démarrage plutôt que de tourner sans clé. OPTIONSest toujours public — les préflights doivent fonctionner.
Ce que les réponses exposent
clientIdest retiré de toutes les réponses.authorEmailest vidé sauf si la requête porte un en-têteAuthorization: Bearervalide. Seule exception : unPOSTréussi renvoie l'e-mail à son auteur.
Référence HTTP
Un endpoint, cinq méthodes. Tous les corps sont en JSON.
| Méthode | Rôle | Succès |
|---|---|---|
POST | Créer un feedback (envois du widget) | 201 + l'enregistrement. Un clientId rejoué renvoie l'enregistrement existant au lieu d'échouer (et ne notifie jamais les webhooks deux fois) ; un clientId déjà utilisé par un autre projet est refusé avec 409 |
GET | Lister les feedbacks | 200 + { feedbacks, total }, avec Cache-Control: private, max-age=5 |
PATCH | Changer un statut | 200 + l'enregistrement mis à jour |
DELETE | Supprimer un ou tous | 200 + { deleted: true } |
OPTIONS | Préflight CORS | 204 |
Paramètres de requête du GET : projectName (obligatoire), page (défaut 1), limit (défaut 50, max 100), type, status, statuses (liste séparée par des virgules, max 4 — ex. statuses=open,in_progress), search, url, urlPattern.
Corps du PATCH : { id, projectName, status } — les trois sont obligatoires. status vaut open, in_progress, resolved ou wont_fix. Le serveur déduit resolvedAt automatiquement : renseigné quand le statut est clos (resolved/wont_fix), effacé sinon.
Corps du DELETE : { id, projectName } pour un enregistrement, ou { projectName, deleteAll: true } pour tout le projet.
Erreurs : JSON invalide → 400 { error } ; échec de validation → 400 { errors: [{ field, message }] } ; id inconnu → 404 ; clientId appartenant à un autre projet → 409 { error } ; table manquante → 500 avec une indication d'exécuter npx prisma db push ; tout le reste → 500 { error: "Internal server error" }.
Les limites de validation à connaître
message ≤ 5000 caractères · annotations ≤ 50 par feedback · screenshotDataUrl ≤ 1,5 Mo, JPEG/PNG/WebP uniquement · diagnostics ≤ 50 entrées console + 20 réseau · clientId doit correspondre à [a-zA-Z0-9_-]+ · authorEmail suit le même motif Unicode que la fenêtre d'identité du widget (françois@exemple.fr passe), donc une adresse acceptée par la fenêtre n'est jamais refusée ici.
Schéma de base de données
Voici le schéma exact dont l'adapter a besoin — c'est ce que génère npx @siteping/cli sync. Préférez le CLI ; si vous l'écrivez à la main, ne retirez pas de colonnes : l'adapter écrit urlPattern, screenshotUrl et anchorKey à chaque insertion, donc un schéma partiel échoue dès le premier envoi.
model SitepingFeedback {
id String @id @default(cuid())
projectName String
type String
message String @db.Text
status String @default("open")
url String
urlPattern String?
screenshotUrl String? @db.Text
screenshotRegion Json?
diagnostics Json?
viewport String
userAgent String
authorName String
authorEmail String
clientId String @unique
resolvedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
annotations SitepingAnnotation[]
@@index([projectName])
@@index([projectName, status, createdAt])
@@index([projectName, url])
}
model SitepingAnnotation {
id String @id @default(cuid())
feedbackId String
feedback SitepingFeedback @relation(fields: [feedbackId], references: [id], onDelete: Cascade)
cssSelector String @db.Text
xpath String @db.Text
textSnippet String @db.Text
elementTag String
elementId String?
textPrefix String @db.Text
textSuffix String @db.Text
fingerprint String
neighborText String @db.Text
anchorKey String?
xPct Float
yPct Float
wPct Float
hPct Float
scrollX Float
scrollY Float
viewportW Int
viewportH Int
devicePixelRatio Float @default(1)
createdAt DateTime @default(now())
@@index([feedbackId])
}
@db.Textcible PostgreSQL et MySQL. Sur SQLite, retirez ces attributs — unStringsimple y est déjà sans limite.
Stockage des captures
Par défaut, les captures sont stockées en ligne sous forme d'URL data: dans la colonne screenshotUrl — parfait pour essayer, lourd pour une vraie base. En production, branchez un ScreenshotStorage :
createSitepingHandler({
prisma,
screenshotStorage: {
async upload(dataUrl, { feedbackId, mimeType }) {
const url = await uploadToS3(dataUrl, `siteping/${feedbackId}.jpg`, mimeType);
return { url };
},
},
});Si un envoi échoue, le feedback est quand même enregistré (avec screenshotUrl: null) et un avertissement est journalisé — un bucket cassé ne fait jamais perdre le commentaire d'un client.
Ajoutez un delete(url) optionnel et l'adapter l'appelle à la suppression d'un feedback — DELETE unitaire comme deleteAll — pour ne rien laisser traîner dans votre bucket. C'est du meilleur effort : un delete qui échoue est journalisé et ne fait jamais échouer la suppression (un objet orphelin vaut mieux qu'une ligne qui refuse de partir). Les URL data: en ligne ne lui sont jamais passées.
Webhooks
Soyez notifié à chaque nouveau feedback :
createSitepingHandler({
prisma,
webhooks: [
{ url: process.env.SLACK_WEBHOOK_URL!, type: "slack" },
{ url: "https://mon-api.dev/hooks/siteping", type: "generic", headers: { "x-secret": "…" } },
],
});type vaut "slack", "discord" ou "generic" (le défaut — il poste l'enregistrement en JSON avec l'e-mail de l'auteur non masqué : traitez donc les cibles de webhook générique comme de confiance ; le clientId est retiré comme partout ailleurs). Chaque appel a un timeout de 5 secondes, les échecs ne bloquent jamais l'envoi, et un onError(err, feedbackId) optionnel permet de les journaliser.
Le texte des feedbacks est saisi par des visiteurs anonymes, les charges utiles de chat sont donc durcies : le texte Slack est échappé (un commentaire contenant <!channel> s'affiche tel quel, jamais comme une notification) et les messages Discord partent avec l'analyse des mentions désactivée (allowed_mentions: { parse: [] }) — un formulaire public ne peut pas servir à notifier tout votre serveur.
Utiliser un store personnalisé
createSitepingHandler({ store }) monte n'importe quel SitepingStore (l'adapter memory, ou le vôtre) derrière la même surface HTTP — mêmes validations, même auth, même redaction.
Deux réserves, directement issues du code source :
- La vérification d'appartenance inter-projets sur PATCH/DELETE tourne pour tout store qui implémente la méthode optionnelle
verifyProjectOwnership— les adapters memory et localStorage et tout ce qui repose surcreateCollectionStorele font. Un store écrit à la main qui l'omet saute la vérification : quiconque connaît l'idd'un feedback peut alors le modifier quel que soit leprojectName, donc implémentez la méthode (voir écrire un adapter) ou placez l'endpoint derrière unapiKey. screenshotStorageetcaseInsensitiveSearchne s'appliquent qu'au chemin Prisma intégré ; avec un store personnalisé, gérez les captures à l'intérieur de votre store.