VetPulse
Logiciel de gestion vétérinaire fictif · démo d'intégration du SDK et de reqvet-engine
VetPulse n'est pas un vrai produit commercialisé — c'est une reference implementation conçue pour montrer, code à l'appui, comment intégrer le SDK @reqvet-sdk/sdk et le moteur reqvet-engine dans votre propre logiciel de gestion vétérinaire (ERP). Stack : Next.js 15 · TypeScript strict · Turso (LibSQL) · SDK v2.3.0.
Vue d'ensemble
@reqvet-sdk/sdk et le moteur reqvet-engine dans leur propre ERP. Aucun vétérinaire réel n'utilise VetPulse. Chaque route, chaque snippet, chaque pattern présenté ici est fait pour être copié et adapté dans votre stack.VetPulse illustre un flux métier vétérinaire complet — enregistrement audio d'une consultation → génération de compte-rendu → reformulation → amendement audio — avec en plus un dashboard reseller qui permet à un partenaire (fictif, ex. « DrVeto ») de créer et piloter ses cliniques clientes depuis une interface centrale.
lib/reqvet.ts). Chaque route Next.js (app/api/reqvet/*) est un thin wrapper qui délègue à une méthode du SDK. Vous pouvez reproduire cette structure dans votre ERP (Next.js, Express, NestJS, autre) — seule la couche route change, le SDK et le moteur restent identiques.Modèle de déploiement — 1 admin + N cliniques
VetPulse a deux modes de fonctionnement distincts, différenciés uniquement par les variables d'environnement. Le même code source sert les deux — c'est la variable présente à l'exécution qui détermine si l'instance est un admin ou une clinique.
DrVeto (reseller ReqVet)
└── VetPulse Admin [REQVET_RESELLER_API_KEY] → /admin
├── Clinique du Parc → VetPulse Clinique A [REQVET_API_KEY=rqv_live_AAA]
├── Cabinet Laval Animaux → VetPulse Clinique B [REQVET_API_KEY=rqv_live_BBB]
└── Clinique Saint-Ex... → VetPulse Clinique C [REQVET_API_KEY=rqv_live_CCC]| Mode | Variable présente | Accès | Rôle |
|---|---|---|---|
| Admin reseller | REQVET_RESELLER_API_KEY | /admin | Créer et gérer les cliniques du réseau |
| Clinique | REQVET_API_KEY | /consultation | Flux vétérinaire complet (enregistrer, générer, éditer) |
Chaque clinique a son propre déploiement VetPulse avec sa propre base Turso — les données patients ne se mélangent jamais. La clé API clinique est remise une seule foisvia le dashboard admin (jamais stockée en base), puis configurée dans le .env.localde l'instance clinique.
Architecture — 3 couches
Le pattern proxy backend en action : le navigateur ne parle qu'à VetPulse, VetPulse parle à ReqVet (via le SDK) et à Turso. La clé API ReqVet ne quitte jamais le serveur.
lib/reqvet.ts, lib/reqvet-admin.ts) · délégation aux méthodes SDK · mapping local ↔ ReqVet dans Turso · vérification HMAC des webhooks entrants · idempotence via webhook_events./api/reqvet/webhook. Turso : consultations, jobs, reformulations, webhook_events, clinics (admin).Le singleton SDK
// lib/reqvet.ts — Singleton côté serveur (jamais importé d'un composant client)
// Pattern proxy : la clé API reste ici, jamais exposée au navigateur
import ReqVet from "@reqvet-sdk/sdk";
if (!process.env.REQVET_API_KEY) {
throw new Error("REQVET_API_KEY manquante dans .env.local");
}
export const reqvet = new ReqVet(process.env.REQVET_API_KEY, {
baseUrl: process.env.REQVET_BASE_URL ?? "https://api.reqvet.com",
pollInterval: 5000,
timeout: 5 * 60 * 1000,
});Deux règles : (1) importé uniquement depuis des routes serveur, jamais depuis un composant client ; (2) instanciation dès le chargement du module — le check REQVET_API_KEY jette au boot si la variable manque, plutôt que de laisser une route planter à la première requête.
Le singleton admin (lazy)
// lib/reqvet-admin.ts — Client SDK avec clé reseller
// Utilisé uniquement dans les routes /api/admin/*
import ReqVet from "@reqvet-sdk/sdk";
let _client: ReqVet | null = null;
export function getResellerClient(): ReqVet {
if (!process.env.REQVET_RESELLER_API_KEY) {
throw new Error("REQVET_RESELLER_API_KEY manquante — pas configuré en mode admin.");
}
if (!_client) {
_client = new ReqVet(process.env.REQVET_RESELLER_API_KEY, {
baseUrl: process.env.REQVET_BASE_URL ?? "https://api.reqvet.com",
});
}
return _client;
}Différence avec lib/reqvet.ts : ici l'initialisation est lazy(dans getResellerClient()) car les instances clinique n'ont pas de REQVET_RESELLER_API_KEY et ne doivent pas planter au boot. L'erreur ne se produit que si une route /api/admin/* tente d'appeler le client.
Flux clinique — de l'audio au CR
De l'enregistrement audio à l'affichage du compte-rendu dans l'éditeur, en six étapes. Le vétérinaire n'attend jamais activement — le polling se fait en arrière-plan, le webhook alimente Turso, la UI se met à jour dès que le CR est prêt.
- Frontend enregistre — MediaRecorder Opus 32 kbps dans
ConsultationView.tsx. Le blob est envoyé en multipart àPOST /api/reqvet/generateavecanimalName,animalBreed,animalAge(issus du profil patientconsultations). - Proxy demande une URL signée —
reqvet.getSignedUploadUrl()retourne{ uploadUrl, path }. Requête JSON légère, aucun fichier transféré à ce stade. - Upload PUT direct — le proxy fait un
PUTdu blob vers l'uploadUrlSupabase. Contourne la limite Vercel de 4,5 Mo. - createJob avec callbackUrl —
reqvet.createJob({ audioFile, ..., callbackUrl })oùcallbackUrl = ${NEXT_PUBLIC_APP_URL}/api/reqvet/webhook. Retour immédiat 201 avecjob_id. Un enregistrement est créé dans Turso (jobs) pour mapperlocal_job_id↔reqvetJobId↔consultationId. - Webhook ReqVet → VetPulse — quand la transcription + génération sont prêtes, ReqVet POSTe sur
/api/reqvet/webhookavec HMAC. Le handler vérifie la signature, déduplique viawebhook_events, met à jourjobsavechtml,transcription,fields. - Frontend poll —
GET /api/reqvet/job?jobId=…toutes les 3-5 s. Dès questatus = "completed", la UI affiche le CR et pré-remplit les champs éditables (motif, conclusion). Le vétérinaire édite, sauvegarde avecPATCH /api/consultations/[id].
Le proxy /generate — le plus important
// app/api/reqvet/generate/route.ts — le proxy le plus important
export async function POST(req: NextRequest) {
const form = await req.formData();
const audio = form.get("audio") as File;
const animalName = form.get("animalName") as string;
const animalBreed = form.get("animalBreed") as string | undefined;
const animalAge = form.get("animalAge") as string | undefined;
const templateId = form.get("templateId") as string;
const consultationId = form.get("consultationId") as string;
// 1a. URL signée Supabase (requête JSON légère)
const { uploadUrl, path: audioPath } = await reqvet.getSignedUploadUrl(
audio.name || "consultation.webm",
audio.type || "audio/webm",
);
// 1b. PUT direct vers Supabase (bypass Vercel 4,5 Mo)
await fetch(uploadUrl, {
method: "PUT",
headers: { "Content-Type": audio.type || "audio/webm" },
body: Buffer.from(await audio.arrayBuffer()),
});
// 2. Créer le job avec callbackUrl vers notre webhook
const callbackUrl = `${process.env.NEXT_PUBLIC_APP_URL}/api/reqvet/webhook`;
const job = await reqvet.createJob({
audioFile: audioPath,
animalName, animalBreed, animalAge,
templateId, callbackUrl,
metadata: { consultationId, source: "vetpulse" },
});
// 3. Persister le mapping local ↔ ReqVet en base Turso
const localJobId = randomUUID();
await createJob({
id: localJobId,
consultationId,
reqvetJobId: job.job_id,
templateId,
status: job.status,
metadata: { consultationId },
});
return NextResponse.json({ job_id: job.job_id, local_job_id: localJobId }, { status: 201 });
}Le handler webhook
// app/api/reqvet/webhook/route.ts — reçoit les résultats ReqVet
import { verifyWebhookSignature } from "@reqvet-sdk/sdk/webhooks";
export async function POST(req: NextRequest) {
// 1. Raw body AVANT JSON.parse (obligatoire pour HMAC)
const rawBody = await req.text();
// 2. Vérifier signature HMAC + anti-replay 5 min
const result = verifyWebhookSignature({
secret: process.env.REQVET_WEBHOOK_SECRET,
rawBody,
signature: req.headers.get("x-reqvet-signature") ?? "",
timestamp: req.headers.get("x-reqvet-timestamp") ?? "",
maxSkewMs: 5 * 60 * 1000,
});
if (!result.ok) return new NextResponse("Unauthorized", { status: 401 });
const event = JSON.parse(rawBody);
// 3. Idempotence — dedup sur (job_id, event_type)
const existing = await db.execute({
sql: "SELECT id FROM webhook_events WHERE job_id = ? AND event_type = ?",
args: [event.job_id, event.event],
});
if (existing.rows.length > 0) return NextResponse.json({ ok: true, deduplicated: true });
await db.execute({
sql: "INSERT INTO webhook_events (id, job_id, event_type) VALUES (?, ?, ?)",
args: [`${event.job_id}:${event.event}`, event.job_id, event.event],
});
// 4. Router selon l'event
switch (event.event) {
case "job.completed":
case "job.amended":
case "job.regenerated":
await updateJobFromWebhook({
reqvetJobId: event.job_id,
status: "completed",
html: event.html,
transcription: event.transcription,
fields: event.fields,
amendmentNumber: event.amendment_number,
});
break;
case "job.failed":
case "job.amend_failed":
await updateJobFromWebhook({
reqvetJobId: event.job_id,
status: event.event === "job.failed" ? "failed" : "completed",
error: event.error,
});
break;
}
return NextResponse.json({ ok: true });
}Le polling — Turso d'abord, ReqVet en fallback
// app/api/reqvet/job/route.ts — polling côté frontend
// Deux sources : (1) Turso (mise à jour par le webhook), (2) API ReqVet (fallback)
export async function GET(req: NextRequest) {
const jobId = req.nextUrl.searchParams.get("jobId")!;
// 1. D'abord la base (webhook déjà passé ?)
const localJob = await getJob(jobId);
if (localJob?.status === "completed") {
return NextResponse.json({
status: "completed",
html: localJob.html,
transcription: localJob.transcription,
fields: localJob.fields ? JSON.parse(localJob.fields) : null,
});
}
if (localJob?.status === "failed") {
return NextResponse.json({ status: "failed", error: localJob.error });
}
// 2. Fallback API ReqVet (webhook en retard)
const remoteJob = await reqvet.getJob(jobId);
return NextResponse.json({
status: remoteJob.status,
html: remoteJob.result?.html ?? null,
transcription: remoteJob.transcription ?? null,
fields: remoteJob.result?.fields ?? null,
});
}Deux sources de vérité gérées gracieusement : la base Turso est la source principale (alimentée par le webhook), mais si le webhook tarde ou échoue, on interroge directement l'API ReqVet en fallback. Si l'API indique completed avant le webhook, on met à jour Turso au passage.
Flux admin reseller
Le dashboard /admin est réservé aux instances configurées avec REQVET_RESELLER_API_KEY. Il permet à DrVeto de provisionner ses cliniques, gérer leurs quotas et récupérer leurs credentials une seule fois à la création.
- DrVeto ouvre
/admin— le server component vérifieREQVET_RESELLER_API_KEY. Absent → 403. - Liste enrichie —
GET /api/admin/clinicsappellereqvetAdmin.listOrganizations()et croise avec les notes locales stockées en Turso (clinics.notes). - Créer une clinique — modal avec nom, email, quota, notes. Génération d'un UUID local qui servira d'
externalIdpour l'idempotence côté ReqVet. - createOrganization() — le proxy appelle le SDK. Réponse :
{ organization, api_key, webhook_secret, warning }. - Modal credentials one-time — l'
api_keyet lewebhook_secretsont affichés une seule fois. DrVeto les copie dans un gestionnaire de mots de passe et les transmet à la clinique. - Turso — mapping local — VetPulse stocke
{ id (=externalId), reqvet_org_id, name, notes }mais jamais l'api_key.
Le proxy /admin/clinics — création + idempotence
// app/api/admin/clinics/route.ts — provisionner une clinique
export async function POST(req: NextRequest) {
const { name, contactEmail, monthlyQuota, notes } = await req.json();
const reqvetAdmin = getResellerClient();
// UUID local généré en avance → sert d'externalId pour l'idempotence
const localId = randomUUID();
const result = await reqvetAdmin.createOrganization({
name: name.trim(),
contactEmail,
monthlyQuota: monthlyQuota ? Number(monthlyQuota) : undefined,
externalId: localId, // ← clé d'idempotence : re-appel = pas de doublon
});
// L'org existait déjà (idempotence) — pas de credentials retournés
if (result.message) {
const existing = await getClinicByReqvetOrgId(result.organization.id);
return NextResponse.json({
organization: result.organization,
already_existed: true,
local_id: existing?.id ?? null,
});
}
// Nouvelle org — sauvegarder en local SANS la clé (elle reste chez la clinique)
await createClinicRecord({
id: localId,
reqvetOrgId: result.organization.id,
name: result.organization.name,
notes: notes?.trim() || null,
});
// Retourner api_key + webhook_secret UNE SEULE FOIS
return NextResponse.json({
organization: result.organization,
local_id: localId,
api_key: result.api_key,
webhook_secret: result.webhook_secret,
}, { status: 201 });
}api_key est perdue côté DrVeto — ReqVet ne stocke que son hash SHA-256. En cas de perte, il faudra passer par la rotation de clé côté ReqVet (fonctionnalité admin, hors périmètre VetPulse actuel).Routes proxy ↔ méthode SDK
Le mapping exhaustif entre les routes VetPulse et les méthodes SDK qu'elles appellent sous le capot. Toutes les routes vérifient l'auth utilisateur avant de déléguer au SDK.
Routes cliniques (nécessitent REQVET_API_KEY)
| Méthode | Route VetPulse | Appel SDK sous-jacent |
|---|---|---|
| POST | /api/reqvet/generate | getSignedUploadUrl() + createJob() |
| POST | /api/reqvet/webhook | verifyWebhookSignature() |
| GET | /api/reqvet/job | getJob() (fallback si Turso pas à jour) |
| GET | /api/reqvet/templates | listTemplates() |
| POST | /api/reqvet/reformulate | reformulateReport() + sauvegarde Turso |
| POST | /api/reqvet/amend | getSignedUploadUrl() + amendJob() |
| PATCH | /api/consultations/[id] | — (Turso only) |
Routes admin (nécessitent REQVET_RESELLER_API_KEY)
| Méthode | Route VetPulse | Appel SDK sous-jacent |
|---|---|---|
| GET | /api/admin/clinics | listOrganizations() + enrichit avec notes Turso |
| POST | /api/admin/clinics | createOrganization() + createClinicRecord (Turso) |
| GET | /api/admin/clinics/[id] | getOrganization() + usage mensuel |
| PATCH | /api/admin/clinics/[id] | updateOrganization() |
| DELETE | /api/admin/clinics/[id] | deactivateOrganization() (soft delete) |
Chaque route côté VetPulse est un thin wrapper : ~40-100 lignes de TypeScript qui parsent l'entrée, appellent une méthode SDK, persistent le mapping local en Turso, et retournent la réponse au frontend. La complexité est mutualisée dans le SDK et le moteur ReqVet.
Structure des fichiers
Arborescence commentée avec le rôle de chaque module. Le cœur est dans app/api/reqvet/* (proxy clinique), app/api/admin/* (proxy admin) et lib/reqvet*.ts (singletons SDK).
vetpulse/
├── app/
│ ├── admin/ # UI reseller (mode DrVeto)
│ │ ├── page.tsx # server component : garde REQVET_RESELLER_API_KEY
│ │ ├── ClinicDashboard.tsx # client component : liste + création
│ │ ├── clinics/, costs/, overview/, templates/
│ │ └── AdminSidebar.tsx
│ ├── api/
│ │ ├── admin/clinics/ # GET list · POST create · [id] PATCH/DELETE
│ │ ├── consultations/ # PATCH motif/CR/conclusion en Turso
│ │ └── reqvet/ # ← Le cœur du proxy backend
│ │ ├── generate/route.ts # getSignedUploadUrl + createJob
│ │ ├── webhook/route.ts # verifyWebhookSignature + updateJob
│ │ ├── job/route.ts # getJob (poll)
│ │ ├── templates/route.ts # listTemplates
│ │ ├── reformulate/route.ts # reformulateReport
│ │ └── amend/route.ts # getSignedUploadUrl + amendJob
│ ├── consultation/page.tsx # UI vétérinaire
│ ├── layout.tsx
│ └── globals.css
├── components/
│ └── ConsultationView.tsx # MediaRecorder + polling + éditeur riche
├── lib/
│ ├── reqvet.ts # SDK clinique (REQVET_API_KEY)
│ ├── reqvet-admin.ts # SDK reseller (REQVET_RESELLER_API_KEY)
│ ├── db.ts # Turso client + helpers + types
│ └── db-setup.mjs # script d'initialisation du schéma
├── .env.example
└── package.jsonSetup
Trois étapes : mise en place commune (base Turso), puis config spécifique selon le mode (admin ou clinique). Prérequis : Node ≥ 18, compte Turso (gratuit), clé API ReqVet, tunnel HTTPS public pour recevoir les webhooks en dev (ngrok).
1. Setup commun
# Installer les dépendances
npm install
# Créer la base Turso
turso db create vetpulse
turso db show vetpulse --url # → TURSO_DATABASE_URL
turso db tokens create vetpulse # → TURSO_AUTH_TOKEN
# Initialiser le schéma (idempotent)
npm run db:setup
# → crée consultations, jobs, reformulations, webhook_events, clinics2. Instance admin (DrVeto)
# .env.local — Instance ADMIN (DrVeto)
REQVET_RESELLER_API_KEY=rqv_live_reseller_... # clé reseller fournie par ReqVet
REQVET_BASE_URL=https://api.reqvet.com
# Turso — base centrale DrVeto (liste des cliniques)
TURSO_DATABASE_URL=libsql://vetpulse-drveto.turso.io
TURSO_AUTH_TOKEN=eyJ...
NEXT_PUBLIC_APP_URL=https://admin.drveto.frnpm run dev
# → http://localhost:3000/admin
# → cliquer "Ajouter une clinique" → remplir → modal credentials one-time3. Instance clinique
# .env.local — Instance CLINIQUE
REQVET_API_KEY=rqv_live_clinic_... # clé clinique (obtenue via /admin DrVeto)
REQVET_WEBHOOK_SECRET=whsec_... # secret webhook (obtenu en même temps)
REQVET_BASE_URL=https://api.reqvet.com
# Turso — base propre à cette clinique
TURSO_DATABASE_URL=libsql://vetpulse-clinique-du-parc.turso.io
TURSO_AUTH_TOKEN=eyJ...
# URL publique (tunnel ngrok en dev, domaine en prod)
NEXT_PUBLIC_APP_URL=https://xxxx.ngrok-free.app# Exposer le webhook en dev (ngrok)
ngrok http 3000
# → copier l'URL https://xxxx.ngrok-free.app dans NEXT_PUBLIC_APP_URL
# Initialiser la base Turso propre à cette clinique
npm run db:setup
# Lancer
npm run dev
# → http://localhost:3000/consultationPoints techniques clés
Les 5 choix d'implémentation qui font marcher VetPulse en production — le pattern proxy, l'upload sans limite, le contexte patient injecté, l'idempotence des créations d'orgs, la sécurité des webhooks.
Signed upload — contourner la limite Vercel 4,5 Mo
Les audios de consultation font 5-30 Mo — largement au-dessus de la limite ~4,5 Mo des Vercel Serverless Functions. reqvet.uploadAudio() tomberait en 413 FUNCTION_PAYLOAD_TOO_LARGE. VetPulse utilise systématiquement getSignedUploadUrl() + PUT direct vers Supabase :
// 1. Requête JSON légère → URL signée
const { uploadUrl, path } = await reqvet.getSignedUploadUrl(fileName, contentType);
// 2. PUT direct vers Supabase — aucune limite de taille
await fetch(uploadUrl, { method: "PUT", body: audioBuffer });
// 3. path (identifiant canonique côté Supabase) passé à createJob
await reqvet.createJob({ audioFile: path, ... });Contexte patient — race + âge injectés dans le prompt
La table consultations contient déjà patient_breed et patient_age. VetPulse les transmet automatiquement à ReqVet sur chaque createJob — sans config supplémentaire. Côté ReqVet, ces données sont injectées dans le signalement patient du prompt LLM et améliorent significativement les hypothèses en mode diagnostic_hypothesis (prédispositions raciales, pathologies liées à l'âge).
ConsultationRow.patient_breed → form "animalBreed" → reqvet.createJob({ animalBreed })
ConsultationRow.patient_age → form "animalAge" → reqvet.createJob({ animalAge })
// Côté ReqVet, injecté dans le prompt LLM :
SIGNALEMENT DU PATIENT :
- Nom : Rex
- Race : Labrador Retriever
- Âge : 5 ansIdempotence des créations d'org — externalId
createOrganization() accepte un externalId (UUID local Turso). Si une org avec ce même externalId existe déjà côté ReqVet, l'API retourne l'existante sans créer de doublon et sans renvoyer api_key/webhook_secret. Cela protège contre les double-clics utilisateur et les erreurs réseau au moment du provisionnement.
const localId = randomUUID(); // généré AVANT l'appel API
const result = await reqvetAdmin.createOrganization({
name, contactEmail, monthlyQuota,
externalId: localId, // ← clé d'idempotence
});
if (result.message) {
// L'org existait déjà — pas de nouvelles credentials, on remonte l'existante
return { organization: result.organization, already_existed: true };
}
// Nouvelle org — result.api_key + result.webhook_secret retournés une seule foisSécurité webhook — HMAC + anti-replay + idempotence
Trois défenses empilées sur le handler webhook :
- Signature HMAC vérifiée via
verifyWebhookSignaturedu SDK, sur le raw body (avantJSON.parse). - Anti-replay :
maxSkewMs = 5 minsur le timestamp — rejette tout webhook trop vieux ou daté dans le futur. - Idempotence : dédoublonnage sur
(job_id, event_type)via la table Tursowebhook_events. Un webhook rejoué (retry ReqVet) est reconnu et ignoré silencieusement.
Isolation reseller ↔ clinique — variables d'env
Le même code source sert les deux modes. C'est la présence de la variable qui active le mode :
REQVET_RESELLER_API_KEYprésente → routes/api/admin/*activéesREQVET_RESELLER_API_KEYabsente → routes/api/admin/*retournent403REQVET_API_KEYprésente → routes/api/reqvet/*et/consultationfonctionnent- Un déploiement peut avoir les deux (pour dev local), un déploiement de production a une seule des deux
Aller plus loin
Cette page couvre la vision architecturale. Le README VetPulse et le code source restent la référence pour les détails d'implémentation.
- README complet —
vetpulse/README.md: commandes shell exactes, tables Turso, variables d'environnement détaillées, workflow DrVeto pas à pas. - Code source des proxies —
app/api/reqvet/*etapp/api/admin/*: chaque route est intentionnellement courte (~40-100 lignes) pour être lisible. - Types SDK —
node_modules/@reqvet-sdk/sdk/src/index.d.ts: signatures typées des 22 méthodes du SDK. - Documentation ReqVet Engine — la vue complète du moteur (architecture, RGPD, sécurité, providers) est sur le site ReqVet.