@reqvet-sdk/sdk 2.2.1 → 2.2.3

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/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.2.1
4
+
5
+ - **Feat** : ajout de `getSignedUploadUrl(fileName, contentType)` — obtenir une URL presignée Supabase pour uploader l'audio directement, sans passer par `/api/v1/upload` (Vercel Serverless Function, limite ~4.5 MB). Recommandé pour les proxies serveur (Next.js, Express…) gérant des fichiers > 4 MB.
6
+ - **Types** : ajout de `SignedUploadResult` dans `index.d.ts`.
7
+ - **Docs** : `uploadAudio()` annotée avec l'avertissement Vercel dans `index.js`, `index.d.ts`, `SDK_REFERENCE.md` et `README.md`.
8
+ - **Examples** : `nextjs/route-generate.ts` et `nextjs/route-generate.mjs` mis à jour pour utiliser `getSignedUploadUrl()`.
9
+ - **Security** : exemple proxy dans `SECURITY.md` mis à jour.
10
+
3
11
  ## 2.2.0
4
12
 
5
13
  - **Feat** : ajout de `listJobs(options?)` — liste les jobs avec pagination (`limit`, `offset`) et filtres (`status`, `sort`, `order`). Aligne le SDK sur `GET /api/v1/jobs`.
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
 
@@ -51,6 +52,8 @@ const templateId = system[0].id;
51
52
 
52
53
  ### Flux webhook (recommandé)
53
54
 
55
+ Pour les intégrations serveur (Next.js, Express…), utilisez `getSignedUploadUrl()` qui uploade le fichier directement dans Supabase sans passer par une Vercel Serverless Function — **pas de limite de taille**.
56
+
54
57
  ```ts
55
58
  import ReqVet from '@reqvet-sdk/sdk';
56
59
 
@@ -58,20 +61,36 @@ const reqvet = new ReqVet(process.env.REQVET_API_KEY!, {
58
61
  baseUrl: process.env.REQVET_BASE_URL,
59
62
  });
60
63
 
61
- // 1. Uploader l'audio
62
- const { path } = await reqvet.uploadAudio(audioBuffer, 'consultation.webm');
64
+ // 1. Obtenir une URL signée Supabase (requête JSON légère, pas de fichier)
65
+ const { uploadUrl, path } = await reqvet.getSignedUploadUrl(
66
+ 'consultation.webm',
67
+ 'audio/webm',
68
+ );
69
+
70
+ // 2. Uploader directement vers Supabase (contourne Vercel, pas de limite de taille)
71
+ await fetch(uploadUrl, {
72
+ method: 'PUT',
73
+ headers: { 'Content-Type': 'audio/webm' },
74
+ body: audioBuffer, // Buffer | Blob | File
75
+ });
63
76
 
64
- // 2. Créer un job — ReqVet POSTera le résultat sur votre webhook quand il sera prêt
77
+ // 3. Créer un job — ReqVet POSTera le résultat sur votre webhook quand il sera prêt
65
78
  const job = await reqvet.createJob({
66
79
  audioFile: path,
67
80
  animalName: 'Rex',
68
81
  templateId: 'your-template-uuid',
69
82
  callbackUrl: 'https://your-app.com/api/reqvet/webhook',
70
- metadata: { consultationId: 'abc123' }, // transmis tel quel à votre webhook
83
+ metadata: { consultationId: 'abc123' },
71
84
  });
72
85
  // { job_id: '...', status: 'pending' }
73
86
  ```
74
87
 
88
+ > **`uploadAudio()` vs `getSignedUploadUrl()`**
89
+ >
90
+ > `uploadAudio()` est pratique pour des fichiers légers (< 4 MB) ou des contextes navigateur.
91
+ > Pour les proxies serveur, préférez `getSignedUploadUrl()` : le fichier va directement dans Supabase,
92
+ > sans passer par `/api/v1/upload` (Vercel Serverless Function, limite ~4.5 MB).
93
+
75
94
  Votre webhook reçoit un événement `job.completed` :
76
95
 
77
96
  ```json
@@ -123,9 +142,12 @@ export async function POST(req: NextRequest) {
123
142
 
124
143
  ## API
125
144
 
145
+ ### Génération de comptes rendus
146
+
126
147
  | Méthode | Description |
127
148
  |---------|-------------|
128
- | `uploadAudio(audio, fileName?)` | Uploader un fichier audio |
149
+ | `getSignedUploadUrl(fileName, contentType)` | URL signée Supabase pour upload direct (recommandé serveur) |
150
+ | `uploadAudio(audio, fileName?)` | Uploader un fichier audio via ReqVet (limite Vercel ~4.5 MB) |
129
151
  | `generateReport(params)` | Upload + création de job (helper tout-en-un) |
130
152
  | `createJob(params)` | Créer un job de génération |
131
153
  | `listJobs(options?)` | Lister les jobs avec pagination et filtre par statut |
@@ -142,6 +164,18 @@ export async function POST(req: NextRequest) {
142
164
  | `deleteTemplate(templateId)` | Supprimer un template |
143
165
  | `health()` | Vérification de l'état de l'API |
144
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
+
145
179
  ## Événements webhook
146
180
 
147
181
  ReqVet déclenche 5 types d'événements : `job.completed`, `job.failed`, `job.amended`, `job.amend_failed`, `job.regenerated`.
@@ -150,6 +184,56 @@ Les livraisons échouées sont retentées 3 fois (0s, 2s, 5s). Implémentez l'id
150
184
 
151
185
  Voir [SDK_REFERENCE.md §6](./SDK_REFERENCE.md#6-webhook-events) pour la structure complète des payloads de chaque événement.
152
186
 
187
+ ## Intégration revendeur (multi-tenant)
188
+
189
+ Si vous êtes un éditeur logiciel intégrant ReqVet pour vos clients (cliniques), utilisez une clé API revendeur pour provisionner et gérer les organisations de manière programmatique.
190
+
191
+ ```ts
192
+ import ReqVet from '@reqvet-sdk/sdk';
193
+
194
+ // Instancier avec la clé revendeur (role='reseller')
195
+ const reseller = new ReqVet(process.env.REQVET_RESELLER_KEY!);
196
+
197
+ // Provisionner une clinique à l'onboarding
198
+ const result = await reseller.createOrganization({
199
+ name: 'Clinique du Parc',
200
+ contactEmail: 'contact@clinique-du-parc.fr',
201
+ externalId: 'votre_id_interne_4892', // votre ID — garantit l'idempotence
202
+ monthlyQuota: 500,
203
+ webhookUrl: 'https://votre-app.com/webhooks/reqvet',
204
+ });
205
+
206
+ // ⚠️ Stocker immédiatement ces valeurs — elles ne sont retournées qu'une seule fois
207
+ await db.saveClinicCredentials({
208
+ clinicId: result.organization.id,
209
+ apiKey: result.api_key, // rqv_live_...
210
+ webhookSecret: result.webhook_secret, // whsec_...
211
+ });
212
+
213
+ // La clinique utilise ensuite son propre client ReqVet avec sa clé
214
+ const clinic = new ReqVet(result.api_key);
215
+ const { system } = await clinic.listTemplates();
216
+ const job = await clinic.createJob({ audioFile, animalName, templateId: system[0].id });
217
+
218
+ // Lister toutes les cliniques avec leur usage du mois
219
+ const { organizations } = await reseller.listOrganizations();
220
+ // [{ id, name, is_active, monthly_quota, usage: { jobs_this_month, quota_remaining } }]
221
+
222
+ // Modifier le quota d'une clinique
223
+ await reseller.updateOrganization(orgId, { monthlyQuota: 1000 });
224
+
225
+ // Suspendre une clinique (révoque aussi ses clés API)
226
+ await reseller.updateOrganization(orgId, { isActive: false });
227
+
228
+ // Réactiver
229
+ await reseller.updateOrganization(orgId, { isActive: true });
230
+
231
+ // Désactiver définitivement (soft delete — données conservées pour le RGPD)
232
+ await reseller.deactivateOrganization(orgId);
233
+ ```
234
+
235
+ > **Idempotence** : si `createOrganization` est appelé plusieurs fois avec le même `externalId`, l'organisation existante est retournée sans créer de doublon. Utile pour rendre votre processus d'onboarding sûr en cas de retry.
236
+
153
237
  ## TypeScript
154
238
 
155
239
  Définitions TypeScript complètes incluses :
@@ -162,6 +246,12 @@ import type {
162
246
  Template,
163
247
  ReqVetReformulation,
164
248
  ExtractedFields,
249
+ // Partner / Reseller
250
+ PartnerOrganization,
251
+ OrganizationUsage,
252
+ CreateOrganizationParams,
253
+ UpdateOrganizationParams,
254
+ CreateOrganizationResult,
165
255
  } from '@reqvet-sdk/sdk';
166
256
  ```
167
257
 
package/SDK_REFERENCE.md CHANGED
@@ -11,8 +11,8 @@ import ReqVet from '@reqvet-sdk/sdk';
11
11
 
12
12
  const reqvet = new ReqVet(process.env.REQVET_API_KEY!, {
13
13
  baseUrl: process.env.REQVET_BASE_URL ?? 'https://api.reqvet.com',
14
- pollInterval: 5000, // intervalle de polling en ms (défaut : 5000)
15
- timeout: 5 * 60 * 1000, // attente maximale en polling en ms (défaut : 300 000 = 5 min)
14
+ pollInterval: 5000, // intervalle de polling en ms (défaut : 5000)
15
+ timeout: 5 * 60 * 1000, // attente maximale en polling en ms (défaut : 300 000 = 5 min)
16
16
  });
17
17
  ```
18
18
 
@@ -25,6 +25,7 @@ La clé API doit commencer par `rqv_`. Une `Error` est levée immédiatement dan
25
25
  ### Obtenir vos identifiants
26
26
 
27
27
  Votre responsable de compte ReqVet vous fournira :
28
+
28
29
  - `REQVET_API_KEY` — votre clé API d'organisation (`rqv_live_...`)
29
30
  - `REQVET_BASE_URL` — l'URL de base de l'API
30
31
  - `REQVET_WEBHOOK_SECRET` — votre secret de signature webhook (si vous utilisez les webhooks)
@@ -68,9 +69,53 @@ const report = await reqvet.generateReport({ audio, animalName, templateId, wait
68
69
 
69
70
  ## 4) Méthodes
70
71
 
72
+ ### `getSignedUploadUrl(fileName, contentType)` ⭐ recommandé serveur
73
+
74
+ Obtenir une URL signée Supabase pour uploader le fichier audio directement, sans passer par une Vercel Serverless Function.
75
+
76
+ **Quand l'utiliser :** intégrations serveur (Next.js proxy, Express, etc.), fichiers > 4 MB.
77
+
78
+ **Flow :**
79
+ ```
80
+ getSignedUploadUrl() → PUT uploadUrl (Supabase direct) → createJob({ audioFile: path })
81
+ ```
82
+
83
+ **Paramètres :**
84
+ | Nom | Type | Requis | Description |
85
+ |-----|------|--------|-------------|
86
+ | `fileName` | `string` | ✅ | Nom du fichier (ex. `consultation.webm`) |
87
+ | `contentType` | `string` | ✅ | Type MIME (ex. `audio/webm`) |
88
+
89
+ **Réponse :**
90
+ ```ts
91
+ {
92
+ uploadUrl: string; // URL presignée Supabase — à utiliser avec PUT
93
+ path: string; // chemin de stockage — à passer à createJob()
94
+ }
95
+ ```
96
+
97
+ **Exemple :**
98
+ ```ts
99
+ const { uploadUrl, path } = await reqvet.getSignedUploadUrl('consultation.webm', 'audio/webm');
100
+
101
+ await fetch(uploadUrl, {
102
+ method: 'PUT',
103
+ headers: { 'Content-Type': 'audio/webm' },
104
+ body: audioBuffer,
105
+ });
106
+
107
+ const job = await reqvet.createJob({ audioFile: path, animalName, templateId });
108
+ ```
109
+
110
+ ---
111
+
71
112
  ### `uploadAudio(audio, fileName?)`
72
113
 
73
- Uploader un fichier audio vers le stockage ReqVet.
114
+ Uploader un fichier audio vers le stockage ReqVet via `/api/v1/upload`.
115
+
116
+ > ⚠️ **Limite serveur** : `/api/v1/upload` est une Vercel Serverless Function avec une limite de payload de ~4.5 MB. Pour les proxies serveur gérant des fichiers > 4 MB, utilisez [`getSignedUploadUrl()`](#getsigneduploadurlfilename-contenttype--recommandé-serveur) à la place.
117
+ >
118
+ > `uploadAudio()` reste adapté pour les contextes navigateur (Blob/File natif, pas de limite Vercel) ou les fichiers légers.
74
119
 
75
120
  **Paramètres :**
76
121
  | Nom | Type | Requis | Description |
@@ -79,10 +124,11 @@ Uploader un fichier audio vers le stockage ReqVet.
79
124
  | `fileName` | `string` | — | Nom du fichier, utilisé pour inférer le type MIME (défaut : `audio.webm`) |
80
125
 
81
126
  **Réponse :**
127
+
82
128
  ```ts
83
129
  {
84
- audio_file: string; // chemin de stockage canonique — à passer à createJob()
85
- path: string; // alias de audio_file
130
+ audio_file: string; // chemin de stockage canonique — à passer à createJob()
131
+ path: string; // alias de audio_file
86
132
  size_bytes: number;
87
133
  content_type: string;
88
134
  }
@@ -110,6 +156,7 @@ Wrapper pratique : `uploadAudio → createJob`. Attend optionnellement la fin du
110
156
  | `onStatus` | `(status: string) => void` | — | Appelé à chaque poll (uniquement si `waitForResult: true`) |
111
157
 
112
158
  **Réponse :**
159
+
113
160
  - `waitForResult: false` (défaut) : `{ job_id: string, status: 'pending' }`
114
161
  - `waitForResult: true` : `ReqVetReport` (voir `waitForJob`)
115
162
 
@@ -130,8 +177,12 @@ Démarrer un pipeline de transcription + génération de compte rendu.
130
177
  | `extraInstructions` | `string` | — | Instructions de génération supplémentaires (max 5 000 caractères) |
131
178
 
132
179
  **Réponse :**
180
+
133
181
  ```ts
134
- { job_id: string; status: 'pending' }
182
+ {
183
+ job_id: string;
184
+ status: 'pending';
185
+ }
135
186
  ```
136
187
 
137
188
  > **Limite de débit** : 10 000 requêtes/minute par organisation.
@@ -152,6 +203,7 @@ Lister les jobs de l'organisation authentifiée, avec pagination et filtrage.
152
203
  | `order` | `string` | `desc` | Direction : `asc` ou `desc` |
153
204
 
154
205
  **Réponse :**
206
+
155
207
  ```ts
156
208
  {
157
209
  jobs: JobSummary[];
@@ -167,21 +219,22 @@ Obtenir l'état actuel et le résultat d'un job.
167
219
 
168
220
  **Champs de réponse par statut :**
169
221
 
170
- | Champ | `pending` | `transcribing` | `generating` | `completed` | `failed` |
171
- |-------|:---------:|:--------------:|:------------:|:-----------:|:--------:|
172
- | `job_id` | | | | | |
173
- | `status` | | | | | |
174
- | `animal_name` | | | | | |
175
- | `metadata` | | | | | |
176
- | `transcription` | | | | | |
177
- | `result.html` | | | | | |
178
- | `result.fields` | | | | ✅* | |
179
- | `cost` | | | | | |
180
- | `error` | | | | | |
222
+ | Champ | `pending` | `transcribing` | `generating` | `completed` | `failed` |
223
+ | --------------- | :-------: | :------------: | :----------: | :---------: | :------: |
224
+ | `job_id` | | | | | |
225
+ | `status` | | | | | |
226
+ | `animal_name` | | | | | |
227
+ | `metadata` | | | | | |
228
+ | `transcription` | | | | | |
229
+ | `result.html` | | | | | |
230
+ | `result.fields` | | | | ✅\* | |
231
+ | `cost` | | | | | |
232
+ | `error` | | | | | |
181
233
 
182
- *`result.fields` n'est présent que si votre organisation a un `field_schema` configuré (extraction de données structurées). Vaut `null` sinon. Voir [Schéma de champs](#5-schéma-de-champs) ci-dessous.
234
+ \*`result.fields` n'est présent que si votre organisation a un `field_schema` configuré (extraction de données structurées). Vaut `null` sinon. Voir [Schéma de champs](#5-schéma-de-champs) ci-dessous.
183
235
 
184
236
  **Structure du coût (jobs terminés) :**
237
+
185
238
  ```ts
186
239
  cost: {
187
240
  transcription_usd: number;
@@ -199,14 +252,19 @@ cost: {
199
252
  Poller jusqu'à ce qu'un job atteigne `completed` ou `failed`. Respecte `pollInterval` et `timeout`.
200
253
 
201
254
  **Réponse (`ReqVetReport`) :**
255
+
202
256
  ```ts
203
257
  {
204
258
  jobId: string;
205
- html: string; // HTML du compte rendu généré
206
- fields: ExtractedFields | null; // null si aucun field_schema configuré
259
+ html: string; // HTML du compte rendu généré
260
+ fields: ExtractedFields | null; // null si aucun field_schema configuré
207
261
  transcription: string;
208
262
  animalName: string;
209
- cost: { transcription_usd: number; generation_usd: number; total_usd: number };
263
+ cost: {
264
+ transcription_usd: number;
265
+ generation_usd: number;
266
+ total_usd: number;
267
+ }
210
268
  metadata: Record<string, unknown>;
211
269
  }
212
270
  ```
@@ -226,6 +284,7 @@ Régénérer le compte rendu d'un job terminé — par exemple avec des instruct
226
284
  | `templateId` | `string` | Basculer vers un autre template |
227
285
 
228
286
  **Réponse :**
287
+
229
288
  ```ts
230
289
  { job_id: string; status: 'completed'; result: { html: string; fields?: ExtractedFields } }
231
290
  ```
@@ -247,8 +306,14 @@ Ajouter un audio complémentaire à un job terminé. Le nouvel audio est transcr
247
306
  | `templateId` | `string` | — | Basculer vers un autre template |
248
307
 
249
308
  **Réponse :**
309
+
250
310
  ```ts
251
- { job_id: string; status: 'amending'; amendment_number: number; message: string }
311
+ {
312
+ job_id: string;
313
+ status: 'amending';
314
+ amendment_number: number;
315
+ message: string;
316
+ }
252
317
  ```
253
318
 
254
319
  Le job repasse à `completed` quand l'amendement est terminé. Utilisez `waitForJob()` ou écoutez l'événement webhook `job.amended`. Plusieurs amendements sont supportés — chacun est ajouté à la transcription complète.
@@ -275,6 +340,7 @@ Générer une version alternative d'un compte rendu terminé pour une audience s
275
340
  | `custom` | Défini par `customInstructions` |
276
341
 
277
342
  **Réponse (`ReqVetReformulation`) :**
343
+
278
344
  ```ts
279
345
  {
280
346
  id: string;
@@ -308,12 +374,12 @@ Générer une version alternative d'un compte rendu terminé pour une audience s
308
374
 
309
375
  #### `createTemplate(params)` → `Template`
310
376
 
311
- | Nom | Type | Requis |
312
- |-----|------|--------|
313
- | `name` | `string` | ✅ |
314
- | `content` | `string` | ✅ |
315
- | `description` | `string` | — |
316
- | `is_default` | `boolean` | — |
377
+ | Nom | Type | Requis |
378
+ | ------------- | --------- | ------ |
379
+ | `name` | `string` | ✅ |
380
+ | `content` | `string` | ✅ |
381
+ | `description` | `string` | — |
382
+ | `is_default` | `boolean` | — |
317
383
 
318
384
  #### `updateTemplate(templateId, updates)` → `Template`
319
385
 
@@ -462,10 +528,10 @@ import { verifyWebhookSignature } from '@reqvet-sdk/sdk/webhooks';
462
528
 
463
529
  const { ok, reason } = verifyWebhookSignature({
464
530
  secret: process.env.REQVET_WEBHOOK_SECRET!,
465
- rawBody, // corps brut de la requête — à lire AVANT JSON.parse
466
- signature, // valeur de l'en-tête X-ReqVet-Signature
467
- timestamp, // valeur de l'en-tête X-ReqVet-Timestamp
468
- maxSkewMs: 5 * 60 * 1000, // rejeter les événements de plus de 5 min (défaut)
531
+ rawBody, // corps brut de la requête — à lire AVANT JSON.parse
532
+ signature, // valeur de l'en-tête X-ReqVet-Signature
533
+ timestamp, // valeur de l'en-tête X-ReqVet-Timestamp
534
+ maxSkewMs: 5 * 60 * 1000, // rejeter les événements de plus de 5 min (défaut)
469
535
  });
470
536
  ```
471
537
 
@@ -486,25 +552,245 @@ try {
486
552
  const report = await reqvet.waitForJob(jobId);
487
553
  } catch (err) {
488
554
  if (err instanceof ReqVetError) {
489
- console.error(err.message); // message lisible par un humain
490
- console.error(err.status); // statut HTTP (0 pour les erreurs réseau/timeout)
491
- console.error(err.body); // corps brut de la réponse
555
+ console.error(err.message); // message lisible par un humain
556
+ console.error(err.status); // statut HTTP (0 pour les erreurs réseau/timeout)
557
+ console.error(err.body); // corps brut de la réponse
492
558
  }
493
559
  }
494
560
  ```
495
561
 
496
- | Statut | Signification |
497
- |--------|---------------|
498
- | `400` | Erreur de validation — vérifiez `err.body.issues` |
499
- | `401` | Clé API invalide ou manquante |
500
- | `403` | Quota mensuel dépassé |
501
- | `404` | Job ou template introuvable |
502
- | `429` | Limite de débit dépassée — attendez et réessayez |
503
- | `500` | Erreur interne ReqVet |
562
+ | Statut | Signification |
563
+ | ------ | ------------------------------------------------- |
564
+ | `400` | Erreur de validation — vérifiez `err.body.issues` |
565
+ | `401` | Clé API invalide ou manquante |
566
+ | `403` | Quota mensuel dépassé |
567
+ | `404` | Job ou template introuvable |
568
+ | `429` | Limite de débit dépassée — attendez et réessayez |
569
+ | `500` | Erreur interne ReqVet |
570
+
571
+ ---
572
+
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
+ ### `listOrganizations()`
582
+
583
+ Lister toutes les organisations (cliniques) provisionnées par le revendeur, enrichies de l'usage du mois courant.
584
+
585
+ **Réponse :**
586
+
587
+ ```ts
588
+ {
589
+ organizations: Array<{
590
+ id: string;
591
+ name: string;
592
+ contact_email: string | null;
593
+ is_active: boolean;
594
+ monthly_quota: number | null;
595
+ external_id: string | null;
596
+ created_at: string;
597
+ usage: {
598
+ jobs_this_month: number;
599
+ quota_remaining: number | 'unlimited';
600
+ };
601
+ }>;
602
+ }
603
+ ```
604
+
605
+ **Exemple :**
606
+
607
+ ```ts
608
+ const reseller = new ReqVet(process.env.REQVET_RESELLER_KEY!);
609
+
610
+ const { organizations } = await reseller.listOrganizations();
611
+
612
+ for (const org of organizations) {
613
+ console.log(`${org.name} — ${org.usage.jobs_this_month} jobs ce mois / quota ${org.monthly_quota}`);
614
+ }
615
+ ```
504
616
 
505
617
  ---
506
618
 
507
- ## 9) Checklist d'intégration
619
+ ### `createOrganization(params)`
620
+
621
+ Provisionner une nouvelle organisation (clinique) sous le compte revendeur.
622
+
623
+ Cette méthode crée automatiquement :
624
+ - l'enregistrement de l'organisation dans ReqVet
625
+ - une clé API `rqv_live_...` pour la clinique (stockée hashée côté serveur)
626
+ - un secret de signature webhook `whsec_...`
627
+
628
+ **Paramètres :**
629
+
630
+ | Nom | Type | Requis | Description |
631
+ |-----|------|--------|-------------|
632
+ | `name` | `string` | ✅ | Nom de la clinique |
633
+ | `contactEmail` | `string` | — | Email de contact |
634
+ | `externalId` | `string` | — | Votre identifiant interne (active l'idempotence — voir ci-dessous) |
635
+ | `monthlyQuota` | `number` | — | Nombre max de jobs par mois (défaut : 100, max : 10 000) |
636
+ | `webhookUrl` | `string` | — | URL de webhook pour les événements de jobs de cette clinique |
637
+
638
+ **Idempotence via `externalId`**
639
+
640
+ 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).
641
+
642
+ **Réponse (statut 201 — première création) :**
643
+
644
+ ```ts
645
+ {
646
+ organization: {
647
+ id: string;
648
+ name: string;
649
+ monthly_quota: number;
650
+ external_id: string | null;
651
+ };
652
+ api_key: string; // rqv_live_... — à stocker immédiatement, non récupérable ensuite
653
+ webhook_secret: string; // whsec_... — à stocker immédiatement, non récupérable ensuite
654
+ warning: string; // "Save api_key and webhook_secret now — they cannot be retrieved later!"
655
+ }
656
+ ```
657
+
658
+ **Réponse (statut 200 — idempotent, organisation déjà existante) :**
659
+
660
+ ```ts
661
+ {
662
+ message: 'Organization already exists (idempotent)';
663
+ organization: { id, name, monthly_quota, external_id, is_active };
664
+ // api_key et webhook_secret absents
665
+ }
666
+ ```
667
+
668
+ **Exemple :**
669
+
670
+ ```ts
671
+ const result = await reseller.createOrganization({
672
+ name: 'Clinique du Parc',
673
+ contactEmail: 'contact@clinique-du-parc.fr',
674
+ externalId: 'votre_id_interne_4892',
675
+ monthlyQuota: 500,
676
+ webhookUrl: 'https://votre-app.com/webhooks/reqvet',
677
+ });
678
+
679
+ if (!result.api_key) {
680
+ // Organisation déjà existante (idempotent) — récupérer la clé depuis votre propre stockage
681
+ const storedKey = await db.getApiKey(result.organization.id);
682
+ } else {
683
+ // Première création — stocker la clé et le secret immédiatement
684
+ await db.saveClinicCredentials({
685
+ orgId: result.organization.id,
686
+ apiKey: result.api_key,
687
+ webhookSecret: result.webhook_secret,
688
+ });
689
+ }
690
+ ```
691
+
692
+ ---
693
+
694
+ ### `getOrganization(orgId)`
695
+
696
+ Obtenir les détails et l'usage du mois courant d'une organisation spécifique.
697
+
698
+ **Paramètres :**
699
+
700
+ | Nom | Type | Requis | Description |
701
+ |-----|------|--------|-------------|
702
+ | `orgId` | `string` | ✅ | UUID de l'organisation |
703
+
704
+ **Réponse :** `PartnerOrganization` (même structure que les éléments de `listOrganizations`, avec `usage`)
705
+
706
+ **Exemple :**
707
+
708
+ ```ts
709
+ const org = await reseller.getOrganization('uuid-de-la-clinique');
710
+ console.log(`Quota restant : ${org.usage.quota_remaining}`);
711
+ ```
712
+
713
+ ---
714
+
715
+ ### `updateOrganization(orgId, updates)`
716
+
717
+ Modifier le quota mensuel, l'état d'activation, ou l'URL de webhook d'une organisation.
718
+
719
+ Tous les champs sont optionnels — seuls les champs fournis sont mis à jour.
720
+
721
+ **Paramètres :**
722
+
723
+ | Nom | Type | Description |
724
+ |-----|------|-------------|
725
+ | `orgId` | `string` | UUID de l'organisation |
726
+ | `updates.monthlyQuota` | `number` | Nouveau quota mensuel (1–10 000) |
727
+ | `updates.isActive` | `boolean` | `false` pour suspendre la clinique (révoque aussi ses clés API) |
728
+ | `updates.webhookUrl` | `string` | Nouvelle URL de webhook (`null` pour supprimer) |
729
+
730
+ > **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.
731
+
732
+ **Réponse :**
733
+
734
+ ```ts
735
+ {
736
+ id: string;
737
+ name: string;
738
+ is_active: boolean;
739
+ monthly_quota: number;
740
+ external_id: string | null;
741
+ }
742
+ ```
743
+
744
+ **Exemples :**
745
+
746
+ ```ts
747
+ // Augmenter le quota
748
+ await reseller.updateOrganization(orgId, { monthlyQuota: 1000 });
749
+
750
+ // Suspendre une clinique (non-paiement, etc.)
751
+ await reseller.updateOrganization(orgId, { isActive: false });
752
+
753
+ // Réactiver
754
+ await reseller.updateOrganization(orgId, { isActive: true });
755
+
756
+ // Mettre à jour le webhook
757
+ await reseller.updateOrganization(orgId, {
758
+ webhookUrl: 'https://votre-app.com/webhooks/reqvet/v2',
759
+ });
760
+ ```
761
+
762
+ ---
763
+
764
+ ### `deactivateOrganization(orgId)`
765
+
766
+ Désactiver définitivement une organisation et révoquer toutes ses clés API.
767
+
768
+ 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.
769
+
770
+ **Paramètres :**
771
+
772
+ | Nom | Type | Requis | Description |
773
+ |-----|------|--------|-------------|
774
+ | `orgId` | `string` | ✅ | UUID de l'organisation |
775
+
776
+ **Réponse :**
777
+
778
+ ```ts
779
+ { success: true; message: 'Organization and API keys deactivated' }
780
+ ```
781
+
782
+ **Exemple :**
783
+
784
+ ```ts
785
+ await reseller.deactivateOrganization(orgId);
786
+ // L'organisation est désormais inactive, ses clés API sont révoquées
787
+ ```
788
+
789
+ ---
790
+
791
+ ## 10) Checklist d'intégration
792
+
793
+ **Intégration standard (clinique)**
508
794
 
509
795
  - [ ] SDK utilisé **côté serveur uniquement** — clé API jamais dans les bundles navigateur
510
796
  - [ ] `listTemplates()` appelé au démarrage pour découvrir les `templateId` disponibles
@@ -514,3 +800,11 @@ try {
514
800
  - [ ] Vérification anti-replay du timestamp activée (`maxSkewMs`)
515
801
  - [ ] Idempotence implémentée — dédoublonnage sur `job_id + event`
516
802
  - [ ] `REQVET_API_KEY` et `REQVET_WEBHOOK_SECRET` stockés dans des variables d'environnement, jamais en dur dans le code
803
+
804
+ **Intégration revendeur (multi-tenant)**
805
+
806
+ - [ ] Clé revendeur (`REQVET_RESELLER_KEY`) distincte et stockée séparément des clés cliniques
807
+ - [ ] `externalId` systématiquement fourni à `createOrganization` pour garantir l'idempotence des onboardings
808
+ - [ ] `api_key` et `webhook_secret` retournés par `createOrganization` stockés immédiatement et de façon sécurisée — ils ne sont affichés qu'une seule fois
809
+ - [ ] Suspension de clinique gérée via `updateOrganization(orgId, { isActive: false })` (et non `deactivateOrganization` qui est irréversible)
810
+ - [ ] Usage mensuel (`usage.jobs_this_month`, `usage.quota_remaining`) consulté via `listOrganizations()` ou `getOrganization()` pour le monitoring et la facturation
package/SECURITY.md CHANGED
@@ -29,7 +29,7 @@ Example proxy route (Next.js App Router):
29
29
  ```ts
30
30
  // app/api/reqvet/generate/route.ts
31
31
  import { NextRequest, NextResponse } from 'next/server';
32
- import ReqVet from '@reqvet/sdk';
32
+ import ReqVet from '@reqvet-sdk/sdk';
33
33
 
34
34
  const reqvet = new ReqVet(process.env.REQVET_API_KEY!);
35
35
 
@@ -37,7 +37,21 @@ export async function POST(req: NextRequest) {
37
37
  const form = await req.formData();
38
38
  const audio = form.get('audio') as File;
39
39
 
40
- const { path } = await reqvet.uploadAudio(audio, audio.name);
40
+ // Use getSignedUploadUrl() instead of uploadAudio() for server-side proxies.
41
+ // uploadAudio() posts to /api/v1/upload (Vercel Serverless Function, ~4.5 MB limit).
42
+ // getSignedUploadUrl() uploads directly to Supabase — no size limit.
43
+ const { uploadUrl, path } = await reqvet.getSignedUploadUrl(
44
+ audio.name || 'consultation.webm',
45
+ audio.type || 'audio/webm',
46
+ );
47
+
48
+ const audioBuffer = Buffer.from(await audio.arrayBuffer());
49
+ await fetch(uploadUrl, {
50
+ method: 'PUT',
51
+ headers: { 'Content-Type': audio.type || 'audio/webm' },
52
+ body: audioBuffer,
53
+ });
54
+
41
55
  const job = await reqvet.createJob({
42
56
  audioFile: path,
43
57
  animalName: form.get('animalName') as string,
@@ -103,6 +117,46 @@ await cache.set(key, true, { ttl: 86400 });
103
117
  // process...
104
118
  ```
105
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
+
106
160
  ## API key rotation
107
161
 
108
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.1",
3
+ "version": "2.2.3",
4
4
  "description": "Official JavaScript SDK for the ReqVet veterinary report generation API.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
package/src/index.d.ts CHANGED
@@ -15,6 +15,13 @@ export interface UploadResult {
15
15
  content_type: string;
16
16
  }
17
17
 
18
+ export interface SignedUploadResult {
19
+ /** Presigned URL for direct PUT upload to Supabase storage */
20
+ uploadUrl: string;
21
+ /** Storage path to pass to createJob({ audioFile: path }) */
22
+ path: string;
23
+ }
24
+
18
25
  export interface JobResult {
19
26
  job_id: string;
20
27
  status: 'pending' | 'transcribing' | 'generating' | 'completed' | 'failed' | 'amending';
@@ -181,6 +188,52 @@ export interface ReqVetReformulation {
181
188
  created_at: string;
182
189
  }
183
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
+
184
237
  // ─── Client ──────────────────────────────────────────────────
185
238
 
186
239
  export declare class ReqVetError extends Error {
@@ -201,7 +254,18 @@ export declare class ReqVet {
201
254
  generateReport(params: GenerateReportParams & { waitForResult?: false }): Promise<JobResult>;
202
255
  generateReport(params: GenerateReportParams): Promise<JobResult | ReqVetReport>;
203
256
 
204
- /** Upload an audio file */
257
+ /**
258
+ * Get a presigned URL for direct upload to Supabase storage.
259
+ * Recommended for server-side proxies — bypasses Vercel's ~4.5 MB payload limit.
260
+ * PUT the audio buffer to uploadUrl, then pass path to createJob().
261
+ */
262
+ getSignedUploadUrl(fileName: string, contentType: string): Promise<SignedUploadResult>;
263
+
264
+ /**
265
+ * Upload an audio file via ReqVet's /api/v1/upload endpoint.
266
+ * ⚠️ Subject to Vercel Serverless Function payload limit (~4.5 MB).
267
+ * For files > 4 MB in server-side contexts, use getSignedUploadUrl() instead.
268
+ */
205
269
  uploadAudio(audio: Blob | Buffer | File, fileName?: string): Promise<UploadResult>;
206
270
 
207
271
  /** Create a generation job */
@@ -254,6 +318,30 @@ export declare class ReqVet {
254
318
  /** Delete a template */
255
319
  deleteTemplate(templateId: string): Promise<{ success: boolean }>;
256
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
+
257
345
  /** Health check */
258
346
  health(): Promise<{ status: string; services: Record<string, string> }>;
259
347
  }
package/src/index.js CHANGED
@@ -108,9 +108,37 @@ class ReqVet {
108
108
 
109
109
  // ─── Upload ────────────────────────────────────────────────
110
110
 
111
+ /**
112
+ * Get a presigned upload URL for direct upload to ReqVet storage (Supabase).
113
+ *
114
+ * Recommended for server-side integrations (e.g. Next.js proxy routes).
115
+ * The file goes directly to Supabase — it never passes through a Vercel
116
+ * Serverless Function, so there is no ~4.5 MB payload limit.
117
+ *
118
+ * Flow:
119
+ * 1. getSignedUploadUrl(fileName, contentType) — tiny JSON request, no file.
120
+ * 2. PUT the audio buffer to uploadUrl (direct to Supabase).
121
+ * 3. Pass path to createJob({ audioFile: path }).
122
+ *
123
+ * @param {string} fileName - File name (e.g. 'consultation.webm')
124
+ * @param {string} contentType - MIME type (e.g. 'audio/webm')
125
+ * @returns {Promise<{uploadUrl: string, path: string}>}
126
+ */
127
+ async getSignedUploadUrl(fileName, contentType) {
128
+ return this._fetch('POST', '/api/v1/storage/signed-upload', {
129
+ file_name: fileName,
130
+ content_type: contentType,
131
+ });
132
+ }
133
+
111
134
  /**
112
135
  * Upload an audio file to ReqVet storage.
113
136
  *
137
+ * ⚠️ This method POSTs the file to /api/v1/upload, which runs as a
138
+ * Vercel Serverless Function (~4.5 MB request limit). For server-side
139
+ * proxies (Next.js, Express…) handling files > 4 MB, prefer
140
+ * getSignedUploadUrl() + a direct PUT to avoid this limit.
141
+ *
114
142
  * @param {Blob|Buffer|File} audio - The audio file
115
143
  * @param {string} [fileName] - File name
116
144
  * @returns {Promise<{audio_file: string, size_bytes: number}>}
@@ -388,6 +416,87 @@ class ReqVet {
388
416
  return this._fetch('DELETE', `/api/v1/templates/${templateId}`);
389
417
  }
390
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
+
391
500
  // ─── Health ────────────────────────────────────────────────
392
501
 
393
502
  /**