@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 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) Checklist d'intégration
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.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
  /**