@reqvet-sdk/sdk 2.2.2 → 2.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +135 -0
- package/SDK_REFERENCE.md +350 -1
- package/SECURITY.md +40 -0
- package/package.json +2 -8
- package/src/index.d.ts +70 -0
- package/src/index.js +81 -0
package/README.md
CHANGED
|
@@ -16,6 +16,7 @@ SDK JavaScript/TypeScript officiel pour l'API [ReqVet](https://reqvet.com) — g
|
|
|
16
16
|
- **Reformuler** pour une audience spécifique — propriétaire, référé, spécialiste (`reformulateReport`)
|
|
17
17
|
- **Gérer les templates** (`listTemplates`, `createTemplate`, `updateTemplate`, `deleteTemplate`)
|
|
18
18
|
- **Vérifier les webhooks** avec HMAC (`@reqvet-sdk/sdk/webhooks`)
|
|
19
|
+
- **Provisionner et gérer des cliniques** en mode revendeur multi-tenant (`createOrganization`, `listOrganizations`, `updateOrganization`, `deactivateOrganization`)
|
|
19
20
|
|
|
20
21
|
> **Note** : ce SDK n'inclut pas d'enregistreur audio. Votre application gère l'enregistrement et passe un `File`, `Blob` ou `Buffer` au SDK.
|
|
21
22
|
|
|
@@ -141,6 +142,8 @@ export async function POST(req: NextRequest) {
|
|
|
141
142
|
|
|
142
143
|
## API
|
|
143
144
|
|
|
145
|
+
### Génération de comptes rendus
|
|
146
|
+
|
|
144
147
|
| Méthode | Description |
|
|
145
148
|
|---------|-------------|
|
|
146
149
|
| `getSignedUploadUrl(fileName, contentType)` | URL signée Supabase pour upload direct (recommandé serveur) |
|
|
@@ -161,6 +164,18 @@ export async function POST(req: NextRequest) {
|
|
|
161
164
|
| `deleteTemplate(templateId)` | Supprimer un template |
|
|
162
165
|
| `health()` | Vérification de l'état de l'API |
|
|
163
166
|
|
|
167
|
+
### API Partenaire / Revendeur
|
|
168
|
+
|
|
169
|
+
> Ces méthodes nécessitent une clé API revendeur (`rqv_live_...` avec `role='reseller'`), distincte de la clé d'une clinique standard. Contactez votre responsable de compte ReqVet pour obtenir une clé revendeur.
|
|
170
|
+
|
|
171
|
+
| Méthode | Description |
|
|
172
|
+
|---------|-------------|
|
|
173
|
+
| `listOrganizations()` | Lister toutes les cliniques provisionnées par le revendeur |
|
|
174
|
+
| `createOrganization(params)` | Provisionner une nouvelle clinique (génère sa clé API et son webhook secret) |
|
|
175
|
+
| `getOrganization(orgId)` | Obtenir le détail et l'usage du mois d'une clinique |
|
|
176
|
+
| `updateOrganization(orgId, updates)` | Modifier le quota, le statut ou le webhook d'une clinique |
|
|
177
|
+
| `deactivateOrganization(orgId)` | Suspendre une clinique et révoquer ses clés API (soft delete) |
|
|
178
|
+
|
|
164
179
|
## Événements webhook
|
|
165
180
|
|
|
166
181
|
ReqVet déclenche 5 types d'événements : `job.completed`, `job.failed`, `job.amended`, `job.amend_failed`, `job.regenerated`.
|
|
@@ -169,6 +184,120 @@ Les livraisons échouées sont retentées 3 fois (0s, 2s, 5s). Implémentez l'id
|
|
|
169
184
|
|
|
170
185
|
Voir [SDK_REFERENCE.md §6](./SDK_REFERENCE.md#6-webhook-events) pour la structure complète des payloads de chaque événement.
|
|
171
186
|
|
|
187
|
+
## Intégration revendeur (multi-tenant)
|
|
188
|
+
|
|
189
|
+
Si vous êtes un éditeur logiciel intégrant ReqVet pour vos clients (cliniques), vous disposez de **deux types de clés** :
|
|
190
|
+
|
|
191
|
+
| Clé | Variable | Usage |
|
|
192
|
+
|-----|----------|-------|
|
|
193
|
+
| Clé revendeur | `REQVET_RESELLER_KEY` | Provisionner et administrer les cliniques — **jamais** pour générer des jobs |
|
|
194
|
+
| Clé clinique | stockée dans votre DB | Faire les appels de génération pour cette clinique spécifique |
|
|
195
|
+
|
|
196
|
+
### 1 — Onboarding : provisionner une clinique
|
|
197
|
+
|
|
198
|
+
Appelez `createOrganization` à chaque fois qu'une nouvelle clinique souscrit à votre service. Utilisez votre identifiant interne comme `externalId` — c'est le pont entre vos deux systèmes.
|
|
199
|
+
|
|
200
|
+
```ts
|
|
201
|
+
import ReqVet from '@reqvet-sdk/sdk';
|
|
202
|
+
|
|
203
|
+
const reseller = new ReqVet(process.env.REQVET_RESELLER_KEY!);
|
|
204
|
+
|
|
205
|
+
const result = await reseller.createOrganization({
|
|
206
|
+
name: 'Clinique du Parc',
|
|
207
|
+
contactEmail: 'contact@clinique-du-parc.fr',
|
|
208
|
+
externalId: String(clinic.id), // votre ID interne → idempotence garantie
|
|
209
|
+
monthlyQuota: 500,
|
|
210
|
+
webhookUrl: 'https://votre-app.com/webhooks/reqvet',
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
if (result.api_key) {
|
|
214
|
+
// Première création — stocker immédiatement, chiffré au repos
|
|
215
|
+
// api_key et webhook_secret ne sont retournés qu'une seule fois
|
|
216
|
+
await db.clinics.update(clinic.id, {
|
|
217
|
+
reqvet_org_id: result.organization.id,
|
|
218
|
+
reqvet_api_key: encrypt(result.api_key), // rqv_live_...
|
|
219
|
+
reqvet_wh_secret: encrypt(result.webhook_secret), // whsec_...
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
// Si !result.api_key → clinique déjà provisionnée (idempotent), clé déjà en base
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
### 2 — Runtime : appeler ReqVet pour le bon utilisateur
|
|
226
|
+
|
|
227
|
+
À chaque consultation, récupérez la clé de **la clinique de l'utilisateur connecté** et faites l'appel côté serveur. La clé ne sort jamais du backend.
|
|
228
|
+
|
|
229
|
+
```ts
|
|
230
|
+
// Handler server-side — appelé quand un vétérinaire lance une transcription
|
|
231
|
+
async function transcribe(userId: string, audioBuffer: Buffer) {
|
|
232
|
+
// Identifier la clinique de l'utilisateur dans VOTRE système
|
|
233
|
+
const clinic = await db.getClinicByUser(userId);
|
|
234
|
+
|
|
235
|
+
// Déchiffrer la clé de cette clinique — côté serveur uniquement
|
|
236
|
+
const reqvet = new ReqVet(decrypt(clinic.reqvet_api_key));
|
|
237
|
+
|
|
238
|
+
// Upload direct Supabase (pas de limite de taille)
|
|
239
|
+
const { uploadUrl, path } = await reqvet.getSignedUploadUrl('consultation.webm', 'audio/webm');
|
|
240
|
+
await fetch(uploadUrl, { method: 'PUT', body: audioBuffer });
|
|
241
|
+
|
|
242
|
+
// Injecter clinicId dans metadata → permet de router les webhooks entrants
|
|
243
|
+
return reqvet.createJob({
|
|
244
|
+
audioFile: path,
|
|
245
|
+
animalName: consultation.animalName,
|
|
246
|
+
templateId: clinic.reqvet_template_id,
|
|
247
|
+
callbackUrl: 'https://votre-app.com/webhooks/reqvet',
|
|
248
|
+
metadata: { clinicId: clinic.id, userId },
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
### 3 — Webhooks entrants : router vers la bonne clinique
|
|
254
|
+
|
|
255
|
+
Vos webhooks arrivent sur un seul endpoint. Utilisez `metadata.clinicId` (injecté au `createJob`) pour identifier la clinique et vérifier la signature avec son propre secret.
|
|
256
|
+
|
|
257
|
+
```ts
|
|
258
|
+
export async function POST(req: NextRequest) {
|
|
259
|
+
const rawBody = await req.text();
|
|
260
|
+
const event = JSON.parse(rawBody);
|
|
261
|
+
|
|
262
|
+
const clinic = await db.getClinic(event.metadata?.clinicId);
|
|
263
|
+
|
|
264
|
+
const { ok } = verifyWebhookSignature({
|
|
265
|
+
secret: decrypt(clinic.reqvet_wh_secret), // secret de CETTE clinique
|
|
266
|
+
rawBody,
|
|
267
|
+
signature: req.headers.get('x-reqvet-signature') ?? '',
|
|
268
|
+
timestamp: req.headers.get('x-reqvet-timestamp') ?? '',
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
if (!ok) return new Response('Unauthorized', { status: 401 });
|
|
272
|
+
|
|
273
|
+
if (event.event === 'job.completed') {
|
|
274
|
+
await db.saveReport(clinic.id, event.job_id, event.html, event.fields);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
### 4 — Administration : quotas, suspension, monitoring
|
|
280
|
+
|
|
281
|
+
```ts
|
|
282
|
+
// Lister toutes les cliniques avec leur usage du mois
|
|
283
|
+
const { organizations } = await reseller.listOrganizations();
|
|
284
|
+
// [{ id, name, is_active, monthly_quota, usage: { jobs_this_month, quota_remaining } }]
|
|
285
|
+
|
|
286
|
+
// Modifier le quota (ex. changement de plan)
|
|
287
|
+
await reseller.updateOrganization(orgId, { monthlyQuota: 1000 });
|
|
288
|
+
|
|
289
|
+
// Suspendre une clinique — révoque ses clés API en cascade
|
|
290
|
+
await reseller.updateOrganization(orgId, { isActive: false });
|
|
291
|
+
|
|
292
|
+
// Réactiver
|
|
293
|
+
await reseller.updateOrganization(orgId, { isActive: true });
|
|
294
|
+
|
|
295
|
+
// Désactiver définitivement (soft delete — données conservées pour le RGPD)
|
|
296
|
+
await reseller.deactivateOrganization(orgId);
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
> Voir [SDK_REFERENCE.md §9](./SDK_REFERENCE.md#pattern--gestion-des-clés-par-clinique) pour le schéma DB complet, les règles impératives et les cas limites (rotation de clé, etc.).
|
|
300
|
+
|
|
172
301
|
## TypeScript
|
|
173
302
|
|
|
174
303
|
Définitions TypeScript complètes incluses :
|
|
@@ -181,6 +310,12 @@ import type {
|
|
|
181
310
|
Template,
|
|
182
311
|
ReqVetReformulation,
|
|
183
312
|
ExtractedFields,
|
|
313
|
+
// Partner / Reseller
|
|
314
|
+
PartnerOrganization,
|
|
315
|
+
OrganizationUsage,
|
|
316
|
+
CreateOrganizationParams,
|
|
317
|
+
UpdateOrganizationParams,
|
|
318
|
+
CreateOrganizationResult,
|
|
184
319
|
} from '@reqvet-sdk/sdk';
|
|
185
320
|
```
|
|
186
321
|
|
package/SDK_REFERENCE.md
CHANGED
|
@@ -570,7 +570,344 @@ try {
|
|
|
570
570
|
|
|
571
571
|
---
|
|
572
572
|
|
|
573
|
-
## 9)
|
|
573
|
+
## 9) API Partenaire / Revendeur
|
|
574
|
+
|
|
575
|
+
Ces méthodes sont réservées aux revendeurs (éditeurs logiciels) qui provisionnent et administrent des cliniques clientes. Elles nécessitent une clé API avec `role='reseller'`, distincte des clés d'organisation standard.
|
|
576
|
+
|
|
577
|
+
> **Isolation garantie** : un revendeur ne peut accéder qu'aux organisations qu'il a lui-même créées. La contrainte est appliquée en base de données (`parent_org_id = reseller.orgId`) — il n'est pas possible d'accéder aux données d'un autre revendeur, même avec un `orgId` connu.
|
|
578
|
+
|
|
579
|
+
---
|
|
580
|
+
|
|
581
|
+
### Pattern : gestion des clés par clinique
|
|
582
|
+
|
|
583
|
+
Chaque clinique provisionnée reçoit sa propre clé API `rqv_live_...`. Avec 100 cliniques, il faut un mécanisme pour associer la bonne clé au bon utilisateur authentifié au moment de l'appel.
|
|
584
|
+
|
|
585
|
+
**Le principe** : vous stockez les clés dans votre propre base de données, liées à vos enregistrements cliniques. Vos appels vers ReqVet se font **server-side** — la clé ne passe jamais dans le navigateur.
|
|
586
|
+
|
|
587
|
+
#### Schéma recommandé (côté revendeur)
|
|
588
|
+
|
|
589
|
+
```sql
|
|
590
|
+
-- Dans votre base de données
|
|
591
|
+
ALTER TABLE clinics ADD COLUMN reqvet_org_id TEXT; -- UUID ReqVet
|
|
592
|
+
ALTER TABLE clinics ADD COLUMN reqvet_api_key TEXT; -- rqv_live_... chiffré
|
|
593
|
+
ALTER TABLE clinics ADD COLUMN reqvet_wh_secret TEXT; -- whsec_... chiffré
|
|
594
|
+
```
|
|
595
|
+
|
|
596
|
+
> Chiffrez `reqvet_api_key` et `reqvet_wh_secret` au repos (AES-256 ou équivalent). Ces valeurs sont des secrets d'accès — traitez-les comme des mots de passe.
|
|
597
|
+
|
|
598
|
+
#### Flux d'onboarding d'une clinique
|
|
599
|
+
|
|
600
|
+
```ts
|
|
601
|
+
// Appelé quand vous créez une nouvelle clinique dans votre système
|
|
602
|
+
async function onboardClinic(clinicId: string, clinicData: ClinicInput) {
|
|
603
|
+
const reseller = new ReqVet(process.env.REQVET_RESELLER_KEY!);
|
|
604
|
+
|
|
605
|
+
// externalId = votre ID interne → garantit l'idempotence en cas de retry
|
|
606
|
+
const result = await reseller.createOrganization({
|
|
607
|
+
name: clinicData.name,
|
|
608
|
+
contactEmail: clinicData.email,
|
|
609
|
+
externalId: clinicId, // ← votre identifiant interne comme pont
|
|
610
|
+
monthlyQuota: 200,
|
|
611
|
+
webhookUrl: `https://votre-app.com/webhooks/reqvet`,
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
if (result.api_key) {
|
|
615
|
+
// Première création : stocker la clé chiffrée
|
|
616
|
+
await db.clinics.update(clinicId, {
|
|
617
|
+
reqvet_org_id: result.organization.id,
|
|
618
|
+
reqvet_api_key: encrypt(result.api_key),
|
|
619
|
+
reqvet_wh_secret: encrypt(result.webhook_secret),
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
// Si !result.api_key → organisation déjà existante (idempotent), clé déjà en base
|
|
623
|
+
}
|
|
624
|
+
```
|
|
625
|
+
|
|
626
|
+
#### Flux d'appel au moment de la consultation
|
|
627
|
+
|
|
628
|
+
```ts
|
|
629
|
+
// Appelé quand un vétérinaire authentifié lance une transcription
|
|
630
|
+
async function transcribe(userId: string, audioBuffer: Buffer) {
|
|
631
|
+
// 1. Identifier la clinique de l'utilisateur dans VOTRE système
|
|
632
|
+
const clinic = await db.getClinicByUser(userId);
|
|
633
|
+
|
|
634
|
+
// 2. Récupérer et déchiffrer la clé ReqVet — côté serveur uniquement
|
|
635
|
+
const reqvetKey = decrypt(clinic.reqvet_api_key);
|
|
636
|
+
|
|
637
|
+
// 3. Instancier le client avec la clé de CETTE clinique
|
|
638
|
+
const reqvet = new ReqVet(reqvetKey);
|
|
639
|
+
|
|
640
|
+
// 4. Appel ReqVet — la clé ne sort jamais du serveur
|
|
641
|
+
const { uploadUrl, path } = await reqvet.getSignedUploadUrl('consultation.webm', 'audio/webm');
|
|
642
|
+
await fetch(uploadUrl, { method: 'PUT', body: audioBuffer });
|
|
643
|
+
|
|
644
|
+
const job = await reqvet.createJob({
|
|
645
|
+
audioFile: path,
|
|
646
|
+
animalName: consultation.animalName,
|
|
647
|
+
templateId: clinic.reqvet_template_id,
|
|
648
|
+
callbackUrl: `https://votre-app.com/webhooks/reqvet`,
|
|
649
|
+
metadata: { clinicId: clinic.id, userId },
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
return job;
|
|
653
|
+
}
|
|
654
|
+
```
|
|
655
|
+
|
|
656
|
+
#### Réception du webhook
|
|
657
|
+
|
|
658
|
+
Le webhook ReqVet arrive sur votre endpoint. Pour l'associer à la bonne clinique, utilisez le `metadata.clinicId` que vous avez injecté lors du `createJob` :
|
|
659
|
+
|
|
660
|
+
```ts
|
|
661
|
+
export async function POST(req: NextRequest) {
|
|
662
|
+
const rawBody = await req.text();
|
|
663
|
+
const event = JSON.parse(rawBody);
|
|
664
|
+
|
|
665
|
+
// Retrouver la clinique via les metadata passthrough
|
|
666
|
+
const clinicId = event.metadata?.clinicId;
|
|
667
|
+
const clinic = await db.getClinic(clinicId);
|
|
668
|
+
|
|
669
|
+
// Vérifier la signature avec le secret de CETTE clinique
|
|
670
|
+
const { ok } = verifyWebhookSignature({
|
|
671
|
+
secret: decrypt(clinic.reqvet_wh_secret),
|
|
672
|
+
rawBody,
|
|
673
|
+
signature: req.headers.get('x-reqvet-signature') ?? '',
|
|
674
|
+
timestamp: req.headers.get('x-reqvet-timestamp') ?? '',
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
if (!ok) return new Response('Unauthorized', { status: 401 });
|
|
678
|
+
|
|
679
|
+
// Traiter l'événement
|
|
680
|
+
if (event.event === 'job.completed') {
|
|
681
|
+
await db.saveReport(clinicId, event.job_id, event.html, event.fields);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
```
|
|
685
|
+
|
|
686
|
+
#### Règles impératives
|
|
687
|
+
|
|
688
|
+
| Règle | Détail |
|
|
689
|
+
|-------|--------|
|
|
690
|
+
| **Server-side uniquement** | La clé clinique ne doit jamais être envoyée au navigateur, ni apparaître dans les réponses API de votre frontend |
|
|
691
|
+
| **Chiffrement au repos** | `reqvet_api_key` et `reqvet_wh_secret` chiffrés en base — pas en clair |
|
|
692
|
+
| **`externalId` = votre ID** | Utilisez systématiquement votre identifiant interne comme `externalId` — c'est le pont entre vos deux systèmes et le garde-fou anti-doublon |
|
|
693
|
+
| **Une clé par clinique** | Ne réutilisez jamais la clé d'une clinique pour une autre — l'isolation des données ReqVet est par `org_id` |
|
|
694
|
+
| **Rotation** | Si une clé est compromise, désactivez la clinique (`isActive: false`), puis réactivez — les nouvelles clés devront être provisionnées manuellement via `createOrganization` |
|
|
695
|
+
|
|
696
|
+
---
|
|
697
|
+
|
|
698
|
+
### `listOrganizations()`
|
|
699
|
+
|
|
700
|
+
Lister toutes les organisations (cliniques) provisionnées par le revendeur, enrichies de l'usage du mois courant.
|
|
701
|
+
|
|
702
|
+
**Réponse :**
|
|
703
|
+
|
|
704
|
+
```ts
|
|
705
|
+
{
|
|
706
|
+
organizations: Array<{
|
|
707
|
+
id: string;
|
|
708
|
+
name: string;
|
|
709
|
+
contact_email: string | null;
|
|
710
|
+
is_active: boolean;
|
|
711
|
+
monthly_quota: number | null;
|
|
712
|
+
external_id: string | null;
|
|
713
|
+
created_at: string;
|
|
714
|
+
usage: {
|
|
715
|
+
jobs_this_month: number;
|
|
716
|
+
quota_remaining: number | 'unlimited';
|
|
717
|
+
};
|
|
718
|
+
}>;
|
|
719
|
+
}
|
|
720
|
+
```
|
|
721
|
+
|
|
722
|
+
**Exemple :**
|
|
723
|
+
|
|
724
|
+
```ts
|
|
725
|
+
const reseller = new ReqVet(process.env.REQVET_RESELLER_KEY!);
|
|
726
|
+
|
|
727
|
+
const { organizations } = await reseller.listOrganizations();
|
|
728
|
+
|
|
729
|
+
for (const org of organizations) {
|
|
730
|
+
console.log(`${org.name} — ${org.usage.jobs_this_month} jobs ce mois / quota ${org.monthly_quota}`);
|
|
731
|
+
}
|
|
732
|
+
```
|
|
733
|
+
|
|
734
|
+
---
|
|
735
|
+
|
|
736
|
+
### `createOrganization(params)`
|
|
737
|
+
|
|
738
|
+
Provisionner une nouvelle organisation (clinique) sous le compte revendeur.
|
|
739
|
+
|
|
740
|
+
Cette méthode crée automatiquement :
|
|
741
|
+
- l'enregistrement de l'organisation dans ReqVet
|
|
742
|
+
- une clé API `rqv_live_...` pour la clinique (stockée hashée côté serveur)
|
|
743
|
+
- un secret de signature webhook `whsec_...`
|
|
744
|
+
|
|
745
|
+
**Paramètres :**
|
|
746
|
+
|
|
747
|
+
| Nom | Type | Requis | Description |
|
|
748
|
+
|-----|------|--------|-------------|
|
|
749
|
+
| `name` | `string` | ✅ | Nom de la clinique |
|
|
750
|
+
| `contactEmail` | `string` | — | Email de contact |
|
|
751
|
+
| `externalId` | `string` | — | Votre identifiant interne (active l'idempotence — voir ci-dessous) |
|
|
752
|
+
| `monthlyQuota` | `number` | — | Nombre max de jobs par mois (défaut : 100, max : 10 000) |
|
|
753
|
+
| `webhookUrl` | `string` | — | URL de webhook pour les événements de jobs de cette clinique |
|
|
754
|
+
|
|
755
|
+
**Idempotence via `externalId`**
|
|
756
|
+
|
|
757
|
+
Si `externalId` est fourni et qu'une organisation avec ce même identifiant existe déjà sous ce revendeur, la méthode retourne l'organisation existante sans créer de doublon. Le champ `message` est alors présent dans la réponse (`'Organization already exists (idempotent)'`), et `api_key` / `webhook_secret` sont absents (ils ne peuvent pas être récupérés après la création initiale).
|
|
758
|
+
|
|
759
|
+
**Réponse (statut 201 — première création) :**
|
|
760
|
+
|
|
761
|
+
```ts
|
|
762
|
+
{
|
|
763
|
+
organization: {
|
|
764
|
+
id: string;
|
|
765
|
+
name: string;
|
|
766
|
+
monthly_quota: number;
|
|
767
|
+
external_id: string | null;
|
|
768
|
+
};
|
|
769
|
+
api_key: string; // rqv_live_... — à stocker immédiatement, non récupérable ensuite
|
|
770
|
+
webhook_secret: string; // whsec_... — à stocker immédiatement, non récupérable ensuite
|
|
771
|
+
warning: string; // "Save api_key and webhook_secret now — they cannot be retrieved later!"
|
|
772
|
+
}
|
|
773
|
+
```
|
|
774
|
+
|
|
775
|
+
**Réponse (statut 200 — idempotent, organisation déjà existante) :**
|
|
776
|
+
|
|
777
|
+
```ts
|
|
778
|
+
{
|
|
779
|
+
message: 'Organization already exists (idempotent)';
|
|
780
|
+
organization: { id, name, monthly_quota, external_id, is_active };
|
|
781
|
+
// api_key et webhook_secret absents
|
|
782
|
+
}
|
|
783
|
+
```
|
|
784
|
+
|
|
785
|
+
**Exemple :**
|
|
786
|
+
|
|
787
|
+
```ts
|
|
788
|
+
const result = await reseller.createOrganization({
|
|
789
|
+
name: 'Clinique du Parc',
|
|
790
|
+
contactEmail: 'contact@clinique-du-parc.fr',
|
|
791
|
+
externalId: 'votre_id_interne_4892',
|
|
792
|
+
monthlyQuota: 500,
|
|
793
|
+
webhookUrl: 'https://votre-app.com/webhooks/reqvet',
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
if (!result.api_key) {
|
|
797
|
+
// Organisation déjà existante (idempotent) — récupérer la clé depuis votre propre stockage
|
|
798
|
+
const storedKey = await db.getApiKey(result.organization.id);
|
|
799
|
+
} else {
|
|
800
|
+
// Première création — stocker la clé et le secret immédiatement
|
|
801
|
+
await db.saveClinicCredentials({
|
|
802
|
+
orgId: result.organization.id,
|
|
803
|
+
apiKey: result.api_key,
|
|
804
|
+
webhookSecret: result.webhook_secret,
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
```
|
|
808
|
+
|
|
809
|
+
---
|
|
810
|
+
|
|
811
|
+
### `getOrganization(orgId)`
|
|
812
|
+
|
|
813
|
+
Obtenir les détails et l'usage du mois courant d'une organisation spécifique.
|
|
814
|
+
|
|
815
|
+
**Paramètres :**
|
|
816
|
+
|
|
817
|
+
| Nom | Type | Requis | Description |
|
|
818
|
+
|-----|------|--------|-------------|
|
|
819
|
+
| `orgId` | `string` | ✅ | UUID de l'organisation |
|
|
820
|
+
|
|
821
|
+
**Réponse :** `PartnerOrganization` (même structure que les éléments de `listOrganizations`, avec `usage`)
|
|
822
|
+
|
|
823
|
+
**Exemple :**
|
|
824
|
+
|
|
825
|
+
```ts
|
|
826
|
+
const org = await reseller.getOrganization('uuid-de-la-clinique');
|
|
827
|
+
console.log(`Quota restant : ${org.usage.quota_remaining}`);
|
|
828
|
+
```
|
|
829
|
+
|
|
830
|
+
---
|
|
831
|
+
|
|
832
|
+
### `updateOrganization(orgId, updates)`
|
|
833
|
+
|
|
834
|
+
Modifier le quota mensuel, l'état d'activation, ou l'URL de webhook d'une organisation.
|
|
835
|
+
|
|
836
|
+
Tous les champs sont optionnels — seuls les champs fournis sont mis à jour.
|
|
837
|
+
|
|
838
|
+
**Paramètres :**
|
|
839
|
+
|
|
840
|
+
| Nom | Type | Description |
|
|
841
|
+
|-----|------|-------------|
|
|
842
|
+
| `orgId` | `string` | UUID de l'organisation |
|
|
843
|
+
| `updates.monthlyQuota` | `number` | Nouveau quota mensuel (1–10 000) |
|
|
844
|
+
| `updates.isActive` | `boolean` | `false` pour suspendre la clinique (révoque aussi ses clés API) |
|
|
845
|
+
| `updates.webhookUrl` | `string` | Nouvelle URL de webhook (`null` pour supprimer) |
|
|
846
|
+
|
|
847
|
+
> **Suspension** : passer `isActive: false` désactive l'organisation **et** révoque toutes ses clés API en cascade. Les jobs déjà en cours ne sont pas interrompus. Pour réactiver, passer `isActive: true` — les clés API restent révoquées et doivent être régénérées manuellement si nécessaire.
|
|
848
|
+
|
|
849
|
+
**Réponse :**
|
|
850
|
+
|
|
851
|
+
```ts
|
|
852
|
+
{
|
|
853
|
+
id: string;
|
|
854
|
+
name: string;
|
|
855
|
+
is_active: boolean;
|
|
856
|
+
monthly_quota: number;
|
|
857
|
+
external_id: string | null;
|
|
858
|
+
}
|
|
859
|
+
```
|
|
860
|
+
|
|
861
|
+
**Exemples :**
|
|
862
|
+
|
|
863
|
+
```ts
|
|
864
|
+
// Augmenter le quota
|
|
865
|
+
await reseller.updateOrganization(orgId, { monthlyQuota: 1000 });
|
|
866
|
+
|
|
867
|
+
// Suspendre une clinique (non-paiement, etc.)
|
|
868
|
+
await reseller.updateOrganization(orgId, { isActive: false });
|
|
869
|
+
|
|
870
|
+
// Réactiver
|
|
871
|
+
await reseller.updateOrganization(orgId, { isActive: true });
|
|
872
|
+
|
|
873
|
+
// Mettre à jour le webhook
|
|
874
|
+
await reseller.updateOrganization(orgId, {
|
|
875
|
+
webhookUrl: 'https://votre-app.com/webhooks/reqvet/v2',
|
|
876
|
+
});
|
|
877
|
+
```
|
|
878
|
+
|
|
879
|
+
---
|
|
880
|
+
|
|
881
|
+
### `deactivateOrganization(orgId)`
|
|
882
|
+
|
|
883
|
+
Désactiver définitivement une organisation et révoquer toutes ses clés API.
|
|
884
|
+
|
|
885
|
+
Il s'agit d'un **soft delete** : les données (jobs, transcriptions, comptes rendus) sont conservées en base pour respecter les obligations RGPD et permettre un audit trail. L'organisation ne peut plus créer de nouveaux jobs.
|
|
886
|
+
|
|
887
|
+
**Paramètres :**
|
|
888
|
+
|
|
889
|
+
| Nom | Type | Requis | Description |
|
|
890
|
+
|-----|------|--------|-------------|
|
|
891
|
+
| `orgId` | `string` | ✅ | UUID de l'organisation |
|
|
892
|
+
|
|
893
|
+
**Réponse :**
|
|
894
|
+
|
|
895
|
+
```ts
|
|
896
|
+
{ success: true; message: 'Organization and API keys deactivated' }
|
|
897
|
+
```
|
|
898
|
+
|
|
899
|
+
**Exemple :**
|
|
900
|
+
|
|
901
|
+
```ts
|
|
902
|
+
await reseller.deactivateOrganization(orgId);
|
|
903
|
+
// L'organisation est désormais inactive, ses clés API sont révoquées
|
|
904
|
+
```
|
|
905
|
+
|
|
906
|
+
---
|
|
907
|
+
|
|
908
|
+
## 10) Checklist d'intégration
|
|
909
|
+
|
|
910
|
+
**Intégration standard (clinique)**
|
|
574
911
|
|
|
575
912
|
- [ ] SDK utilisé **côté serveur uniquement** — clé API jamais dans les bundles navigateur
|
|
576
913
|
- [ ] `listTemplates()` appelé au démarrage pour découvrir les `templateId` disponibles
|
|
@@ -580,3 +917,15 @@ try {
|
|
|
580
917
|
- [ ] Vérification anti-replay du timestamp activée (`maxSkewMs`)
|
|
581
918
|
- [ ] Idempotence implémentée — dédoublonnage sur `job_id + event`
|
|
582
919
|
- [ ] `REQVET_API_KEY` et `REQVET_WEBHOOK_SECRET` stockés dans des variables d'environnement, jamais en dur dans le code
|
|
920
|
+
|
|
921
|
+
**Intégration revendeur (multi-tenant)**
|
|
922
|
+
|
|
923
|
+
- [ ] Clé revendeur (`REQVET_RESELLER_KEY`) distincte et stockée séparément des clés cliniques
|
|
924
|
+
- [ ] `externalId` systématiquement fourni à `createOrganization` — valeur = votre ID interne de clinique
|
|
925
|
+
- [ ] `api_key` et `webhook_secret` retournés par `createOrganization` stockés immédiatement, **chiffrés au repos** — ils ne sont affichés qu'une seule fois
|
|
926
|
+
- [ ] Clés cliniques appelées **server-side uniquement** — jamais exposées au navigateur ni aux réponses frontend
|
|
927
|
+
- [ ] Un client `ReqVet` instancié avec la clé de **la clinique concernée** à chaque appel — jamais avec la clé revendeur pour les jobs
|
|
928
|
+
- [ ] `metadata` enrichi de `clinicId` (et `userId` si pertinent) sur chaque `createJob` — permet de router les webhooks entrants vers la bonne clinique
|
|
929
|
+
- [ ] Signature webhook vérifiée avec le secret de **la clinique destinataire** (et non le secret revendeur)
|
|
930
|
+
- [ ] Suspension de clinique gérée via `updateOrganization(orgId, { isActive: false })` (et non `deactivateOrganization` qui est irréversible)
|
|
931
|
+
- [ ] Usage mensuel (`usage.jobs_this_month`, `usage.quota_remaining`) consulté via `listOrganizations()` ou `getOrganization()` pour le monitoring et la facturation
|
package/SECURITY.md
CHANGED
|
@@ -117,6 +117,46 @@ await cache.set(key, true, { ttl: 86400 });
|
|
|
117
117
|
// process...
|
|
118
118
|
```
|
|
119
119
|
|
|
120
|
+
## Clés revendeur et credentials cliniques
|
|
121
|
+
|
|
122
|
+
Les revendeurs manipulent deux types de secrets supplémentaires qui exigent une attention particulière.
|
|
123
|
+
|
|
124
|
+
### Clé API revendeur
|
|
125
|
+
|
|
126
|
+
La clé revendeur (`rqv_live_...` avec `role='reseller'`) donne accès à l'ensemble des cliniques que vous administrez. Elle est plus sensible qu'une clé clinique standard.
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
# Variable d'environnement dédiée, distincte de la clé clinique
|
|
130
|
+
REQVET_RESELLER_KEY=rqv_live_...
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
**Ne jamais** utiliser la clé revendeur côté client ni la partager avec les cliniques.
|
|
134
|
+
|
|
135
|
+
### `api_key` et `webhook_secret` retournés par `createOrganization`
|
|
136
|
+
|
|
137
|
+
Ces deux valeurs sont **affichées une seule fois** au moment de la création. Après la réponse HTTP initiale, elles ne peuvent pas être récupérées (seul le hash SHA-256 de la clé est stocké côté serveur).
|
|
138
|
+
|
|
139
|
+
```ts
|
|
140
|
+
const result = await reseller.createOrganization({ name: 'Clinique du Parc', externalId: 'id_4892' });
|
|
141
|
+
|
|
142
|
+
// ✅ Stocker immédiatement dans votre vault ou base chiffrée
|
|
143
|
+
await secretsVault.store({
|
|
144
|
+
orgId: result.organization.id,
|
|
145
|
+
apiKey: result.api_key, // non récupérable après cette réponse
|
|
146
|
+
webhookSecret: result.webhook_secret, // non récupérable après cette réponse
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// ✅ Transmettre la clé à la clinique de manière sécurisée (canal chiffré, jamais par email)
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Si une clé clinique est perdue ou compromise, le seul recours est de désactiver l'organisation via `deactivateOrganization()` et d'en créer une nouvelle.
|
|
153
|
+
|
|
154
|
+
### Isolation entre revendeurs
|
|
155
|
+
|
|
156
|
+
L'API Partner filtre toutes les requêtes sur `parent_org_id = reseller.orgId` côté base de données. Il n'est pas possible d'accéder aux cliniques d'un autre revendeur, même en connaissant un `orgId` valide. Ne pas tenter de contourner cette isolation.
|
|
157
|
+
|
|
158
|
+
---
|
|
159
|
+
|
|
120
160
|
## API key rotation
|
|
121
161
|
|
|
122
162
|
If a key is compromised:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reqvet-sdk/sdk",
|
|
3
|
-
"version": "2.2.
|
|
3
|
+
"version": "2.2.4",
|
|
4
4
|
"description": "Official JavaScript SDK for the ReqVet veterinary report generation API.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
@@ -18,13 +18,7 @@
|
|
|
18
18
|
},
|
|
19
19
|
"./package.json": "./package.json"
|
|
20
20
|
},
|
|
21
|
-
"files": [
|
|
22
|
-
"src/",
|
|
23
|
-
"README.md",
|
|
24
|
-
"SDK_REFERENCE.md",
|
|
25
|
-
"CHANGELOG.md",
|
|
26
|
-
"SECURITY.md"
|
|
27
|
-
],
|
|
21
|
+
"files": ["src/", "README.md", "SDK_REFERENCE.md", "CHANGELOG.md", "SECURITY.md"],
|
|
28
22
|
"keywords": [
|
|
29
23
|
"reqvet",
|
|
30
24
|
"veterinary",
|
package/src/index.d.ts
CHANGED
|
@@ -188,6 +188,52 @@ export interface ReqVetReformulation {
|
|
|
188
188
|
created_at: string;
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
+
// ─── Partner / Reseller Types ────────────────────────────────
|
|
192
|
+
|
|
193
|
+
export interface OrganizationUsage {
|
|
194
|
+
jobs_this_month: number;
|
|
195
|
+
quota_remaining: number | 'unlimited';
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export interface PartnerOrganization {
|
|
199
|
+
id: string;
|
|
200
|
+
name: string;
|
|
201
|
+
contact_email: string | null;
|
|
202
|
+
is_active: boolean;
|
|
203
|
+
monthly_quota: number | null;
|
|
204
|
+
external_id: string | null;
|
|
205
|
+
created_at: string;
|
|
206
|
+
usage?: OrganizationUsage;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export interface CreateOrganizationParams {
|
|
210
|
+
name: string;
|
|
211
|
+
contactEmail?: string;
|
|
212
|
+
/** Your internal ID — enables idempotency (same externalId returns the existing org) */
|
|
213
|
+
externalId?: string;
|
|
214
|
+
/** Max jobs per month (default: 100, max: 10 000) */
|
|
215
|
+
monthlyQuota?: number;
|
|
216
|
+
webhookUrl?: string;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export interface UpdateOrganizationParams {
|
|
220
|
+
monthlyQuota?: number;
|
|
221
|
+
/** Set to false to suspend the clinic and revoke its API keys */
|
|
222
|
+
isActive?: boolean;
|
|
223
|
+
webhookUrl?: string;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export interface CreateOrganizationResult {
|
|
227
|
+
organization: Pick<PartnerOrganization, 'id' | 'name' | 'monthly_quota' | 'external_id'>;
|
|
228
|
+
/** The clinic's API key — returned only once, store it securely! */
|
|
229
|
+
api_key: string;
|
|
230
|
+
/** Webhook signing secret — returned only once, store it securely! */
|
|
231
|
+
webhook_secret: string;
|
|
232
|
+
warning: string;
|
|
233
|
+
/** Returned instead of api_key/webhook_secret when the org already exists (idempotent) */
|
|
234
|
+
message?: string;
|
|
235
|
+
}
|
|
236
|
+
|
|
191
237
|
// ─── Client ──────────────────────────────────────────────────
|
|
192
238
|
|
|
193
239
|
export declare class ReqVetError extends Error {
|
|
@@ -272,6 +318,30 @@ export declare class ReqVet {
|
|
|
272
318
|
/** Delete a template */
|
|
273
319
|
deleteTemplate(templateId: string): Promise<{ success: boolean }>;
|
|
274
320
|
|
|
321
|
+
// ─── Partner / Reseller (requires role='reseller' API key) ──
|
|
322
|
+
|
|
323
|
+
/** List all organizations provisioned by the reseller */
|
|
324
|
+
listOrganizations(): Promise<{ organizations: PartnerOrganization[] }>;
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Provision a new organization/clinic.
|
|
328
|
+
* Idempotent via externalId — returns existing org if already provisioned.
|
|
329
|
+
* ⚠️ api_key and webhook_secret are returned only once.
|
|
330
|
+
*/
|
|
331
|
+
createOrganization(params: CreateOrganizationParams): Promise<CreateOrganizationResult>;
|
|
332
|
+
|
|
333
|
+
/** Get details and current month usage of a specific organization */
|
|
334
|
+
getOrganization(orgId: string): Promise<PartnerOrganization>;
|
|
335
|
+
|
|
336
|
+
/** Update an organization's quota, status, or webhook URL */
|
|
337
|
+
updateOrganization(
|
|
338
|
+
orgId: string,
|
|
339
|
+
updates: UpdateOrganizationParams,
|
|
340
|
+
): Promise<Pick<PartnerOrganization, 'id' | 'name' | 'is_active' | 'monthly_quota' | 'external_id'>>;
|
|
341
|
+
|
|
342
|
+
/** Deactivate an organization and revoke all its API keys (soft delete) */
|
|
343
|
+
deactivateOrganization(orgId: string): Promise<{ success: boolean; message: string }>;
|
|
344
|
+
|
|
275
345
|
/** Health check */
|
|
276
346
|
health(): Promise<{ status: string; services: Record<string, string> }>;
|
|
277
347
|
}
|
package/src/index.js
CHANGED
|
@@ -416,6 +416,87 @@ class ReqVet {
|
|
|
416
416
|
return this._fetch('DELETE', `/api/v1/templates/${templateId}`);
|
|
417
417
|
}
|
|
418
418
|
|
|
419
|
+
// ─── Partner / Reseller ────────────────────────────────────
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* List all organizations provisioned by the reseller.
|
|
423
|
+
* Requires a reseller API key (role='reseller').
|
|
424
|
+
*
|
|
425
|
+
* @returns {Promise<{organizations: Object[]}>}
|
|
426
|
+
*/
|
|
427
|
+
async listOrganizations() {
|
|
428
|
+
return this._fetch('GET', '/api/v1/partner/orgs');
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Provision a new organization (clinic) under the reseller account.
|
|
433
|
+
* Requires a reseller API key (role='reseller').
|
|
434
|
+
*
|
|
435
|
+
* Idempotent via externalId: if an org with the same externalId already
|
|
436
|
+
* exists under this reseller, the existing one is returned (no duplicate).
|
|
437
|
+
*
|
|
438
|
+
* ⚠️ The returned api_key and webhook_secret are shown only once — store them securely.
|
|
439
|
+
*
|
|
440
|
+
* @param {Object} params
|
|
441
|
+
* @param {string} params.name - Clinic name
|
|
442
|
+
* @param {string} [params.contactEmail]
|
|
443
|
+
* @param {string} [params.externalId] - Your internal ID (enables idempotency)
|
|
444
|
+
* @param {number} [params.monthlyQuota] - Job quota per month (default: 100, max: 10 000)
|
|
445
|
+
* @param {string} [params.webhookUrl] - Webhook URL for job results
|
|
446
|
+
* @returns {Promise<Object>}
|
|
447
|
+
*/
|
|
448
|
+
async createOrganization({ name, contactEmail, externalId, monthlyQuota, webhookUrl }) {
|
|
449
|
+
return this._fetch('POST', '/api/v1/partner/orgs', {
|
|
450
|
+
name,
|
|
451
|
+
contact_email: contactEmail,
|
|
452
|
+
external_id: externalId,
|
|
453
|
+
monthly_quota: monthlyQuota,
|
|
454
|
+
webhook_url: webhookUrl,
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Get details and current month usage of a specific organization.
|
|
460
|
+
* Requires a reseller API key (role='reseller').
|
|
461
|
+
*
|
|
462
|
+
* @param {string} orgId
|
|
463
|
+
* @returns {Promise<Object>}
|
|
464
|
+
*/
|
|
465
|
+
async getOrganization(orgId) {
|
|
466
|
+
return this._fetch('GET', `/api/v1/partner/orgs/${orgId}`);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Update an organization's quota, status, or webhook URL.
|
|
471
|
+
* Requires a reseller API key (role='reseller').
|
|
472
|
+
*
|
|
473
|
+
* @param {string} orgId
|
|
474
|
+
* @param {Object} updates
|
|
475
|
+
* @param {number} [updates.monthlyQuota]
|
|
476
|
+
* @param {boolean} [updates.isActive] - Set to false to suspend the clinic
|
|
477
|
+
* @param {string} [updates.webhookUrl]
|
|
478
|
+
* @returns {Promise<Object>}
|
|
479
|
+
*/
|
|
480
|
+
async updateOrganization(orgId, { monthlyQuota, isActive, webhookUrl } = {}) {
|
|
481
|
+
return this._fetch('PATCH', `/api/v1/partner/orgs/${orgId}`, {
|
|
482
|
+
monthly_quota: monthlyQuota,
|
|
483
|
+
is_active: isActive,
|
|
484
|
+
webhook_url: webhookUrl,
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Deactivate an organization and revoke all its API keys.
|
|
490
|
+
* Soft delete — data is preserved for audit/GDPR purposes.
|
|
491
|
+
* Requires a reseller API key (role='reseller').
|
|
492
|
+
*
|
|
493
|
+
* @param {string} orgId
|
|
494
|
+
* @returns {Promise<{success: boolean, message: string}>}
|
|
495
|
+
*/
|
|
496
|
+
async deactivateOrganization(orgId) {
|
|
497
|
+
return this._fetch('DELETE', `/api/v1/partner/orgs/${orgId}`);
|
|
498
|
+
}
|
|
499
|
+
|
|
419
500
|
// ─── Health ────────────────────────────────────────────────
|
|
420
501
|
|
|
421
502
|
/**
|