@zevra/support 0.2.0
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 +65 -0
- package/package.json +19 -0
- package/src/client.ts +354 -0
- package/src/configuration.ts +106 -0
- package/src/erreurs.ts +199 -0
- package/src/garde-serveur.ts +75 -0
- package/src/index.ts +117 -0
- package/src/reessai.ts +134 -0
- package/src/relais/chemins.ts +91 -0
- package/src/relais/corps.ts +292 -0
- package/src/relais/routes.ts +380 -0
- package/src/requete.ts +146 -0
- package/src/types.ts +221 -0
package/src/requete.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// La construction d'une requête. PUR — aucune I/O, aucune horloge.
|
|
3
|
+
// ============================================================================
|
|
4
|
+
//
|
|
5
|
+
// Tout ce que le client fait de discutable est ICI, pour que ce soit rejouable
|
|
6
|
+
// dans un test : quel en-tête porte la clé, comment la base est normalisée,
|
|
7
|
+
// comment une valeur multiple devient une query string, et à partir de quelle
|
|
8
|
+
// taille on refuse d'envoyer.
|
|
9
|
+
|
|
10
|
+
import { ErreurSupport } from './erreurs'
|
|
11
|
+
|
|
12
|
+
/** L'en-tête d'authentification de l'API v1 (PLAN.md §4). */
|
|
13
|
+
export const ENTETE_CLE = 'X-Support-Key'
|
|
14
|
+
|
|
15
|
+
/** Idempotence des POST : même clé + même app dans les 24 h ⇒ même réponse. */
|
|
16
|
+
export const ENTETE_IDEMPOTENCE = 'Idempotency-Key'
|
|
17
|
+
|
|
18
|
+
/** Le chemin de base de l'API v1, sous l'URL de Support. */
|
|
19
|
+
export const SEGMENT_API = '/api/v1'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 256 Ko (PLAN.md §4), comptés en OCTETS UTF-8.
|
|
23
|
+
*
|
|
24
|
+
* ⚠️ Pas en caractères : « é » pèse 2 octets, un emoji 4, et un contexte de
|
|
25
|
+
* console plein d'accents passerait la vérification pour être refusé par le
|
|
26
|
+
* serveur après avoir traversé le réseau. C'est exactement le genre d'échec
|
|
27
|
+
* qu'on ne reproduit jamais en test avec des chaînes ASCII.
|
|
28
|
+
*/
|
|
29
|
+
export const TAILLE_MAX_CORPS_OCTETS = 256 * 1024
|
|
30
|
+
|
|
31
|
+
export type ValeurQuery = string | number | boolean | readonly string[] | null | undefined
|
|
32
|
+
|
|
33
|
+
export interface EntreeRequete {
|
|
34
|
+
/** L'URL de Support (`SUPPORT_URL`), avec ou sans `/api/v1`, avec ou sans `/`. */
|
|
35
|
+
base: string
|
|
36
|
+
cle: string
|
|
37
|
+
methode: 'GET' | 'POST'
|
|
38
|
+
/** Le chemin SOUS `/api/v1`, sans barre de tête : `demandes/<id>/messages`. */
|
|
39
|
+
chemin: string
|
|
40
|
+
requete?: Record<string, ValeurQuery>
|
|
41
|
+
corps?: unknown
|
|
42
|
+
cleIdempotence?: string | null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface RequetePreparee {
|
|
46
|
+
url: string
|
|
47
|
+
methode: 'GET' | 'POST'
|
|
48
|
+
entetes: Record<string, string>
|
|
49
|
+
corps?: string
|
|
50
|
+
/** La taille réelle du corps, en octets. Utile au journal d'incident. */
|
|
51
|
+
octets: number
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Range l'URL de Support en base d'API. PUR.
|
|
56
|
+
*
|
|
57
|
+
* `SUPPORT_URL` est posée par l'admin Zevra et vaut l'URL de l'app
|
|
58
|
+
* (`https://support.zevra.tech`). Mais tout le monde ne la lit pas pareil :
|
|
59
|
+
* certains y collent déjà `/api/v1`, d'autres laissent une barre finale, un
|
|
60
|
+
* autre encore recopie `https://support.zevra.tech/api/v1/`. Les trois doivent
|
|
61
|
+
* marcher — sinon l'intégration échoue en 404 sur une faute d'URL, et le
|
|
62
|
+
* message d'erreur parle de « demande introuvable ».
|
|
63
|
+
*/
|
|
64
|
+
export function normaliserBase(brut: string): string {
|
|
65
|
+
const detoure = String(brut ?? '').trim()
|
|
66
|
+
if (detoure === '') return ''
|
|
67
|
+
const sansBarre = detoure.replace(/\/+$/, '')
|
|
68
|
+
if (sansBarre.endsWith(SEGMENT_API)) return sansBarre
|
|
69
|
+
return `${sansBarre}${SEGMENT_API}`
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Sérialise la query string. PUR.
|
|
74
|
+
*
|
|
75
|
+
* ⚠️ Une valeur VIDE est omise, jamais envoyée. `?statut=` (un select au
|
|
76
|
+
* repos, une variable non remplie) est lu côté Support comme « pas de
|
|
77
|
+
* filtre » — mais `?email=` serait lu comme « les demandes de ⟨rien⟩ », que le
|
|
78
|
+
* socle traite en page vide. Ne rien envoyer est la seule écriture qui ait le
|
|
79
|
+
* même sens des deux côtés.
|
|
80
|
+
*/
|
|
81
|
+
export function serialiserQuery(requete: Record<string, ValeurQuery> | undefined): string {
|
|
82
|
+
if (!requete) return ''
|
|
83
|
+
const parametres = new URLSearchParams()
|
|
84
|
+
for (const [cle, valeur] of Object.entries(requete)) {
|
|
85
|
+
if (valeur === null || valeur === undefined) continue
|
|
86
|
+
const valeurs = Array.isArray(valeur) ? valeur : [valeur as string | number | boolean]
|
|
87
|
+
for (const une of valeurs) {
|
|
88
|
+
const texte = String(une).trim()
|
|
89
|
+
if (texte === '') continue
|
|
90
|
+
parametres.append(cle, texte)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const rendu = parametres.toString()
|
|
94
|
+
return rendu === '' ? '' : `?${rendu}`
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Compte les octets UTF-8 d'une chaîne, sans dépendre de `Buffer`. */
|
|
98
|
+
export function octetsUtf8(texte: string): number {
|
|
99
|
+
return new TextEncoder().encode(texte).length
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Assemble tout. PUR — jette `validation` si le corps dépasse la borne.
|
|
104
|
+
*
|
|
105
|
+
* ⚠️ On refuse AVANT d'envoyer. Laisser partir 400 Ko pour récolter un 422
|
|
106
|
+
* coûte un aller-retour, du temps d'attente pour la personne qui a cliqué, et
|
|
107
|
+
* un message serveur qui ne dit pas quel champ est trop gros. Ici, on peut le
|
|
108
|
+
* dire : c'est presque toujours le contexte de console ou une capture collée
|
|
109
|
+
* en base64 dans la description.
|
|
110
|
+
*/
|
|
111
|
+
export function preparerRequete(entree: EntreeRequete): RequetePreparee {
|
|
112
|
+
const base = normaliserBase(entree.base)
|
|
113
|
+
const chemin = String(entree.chemin ?? '').replace(/^\/+/, '')
|
|
114
|
+
const url = `${base}/${chemin}${serialiserQuery(entree.requete)}`
|
|
115
|
+
|
|
116
|
+
const entetes: Record<string, string> = {
|
|
117
|
+
// ⚠️ JAMAIS `Authorization: Bearer` : sur support.dev.zevra.tech, la garde
|
|
118
|
+
// basicAuth de l'environnement de dev consomme cet en-tête (AGENTS.md), et
|
|
119
|
+
// les appels échouent sans la moindre explication utile.
|
|
120
|
+
[ENTETE_CLE]: entree.cle,
|
|
121
|
+
Accept: 'application/json',
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
let corps: string | undefined
|
|
125
|
+
let octets = 0
|
|
126
|
+
if (entree.corps !== undefined) {
|
|
127
|
+
corps = JSON.stringify(entree.corps)
|
|
128
|
+
octets = octetsUtf8(corps)
|
|
129
|
+
if (octets > TAILLE_MAX_CORPS_OCTETS) {
|
|
130
|
+
throw new ErreurSupport({
|
|
131
|
+
code: 'validation',
|
|
132
|
+
message: `Le corps de la requête pèse ${octets} octets ; la limite est de ${TAILLE_MAX_CORPS_OCTETS}. Le contexte technique (erreurs de console) ou une pièce collée dans la description en sont presque toujours la cause — une pièce se dépose par POST /pieces.`,
|
|
133
|
+
champs: { corps: 'trop volumineux' },
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
entetes['Content-Type'] = 'application/json'
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// L'idempotence n'a de sens que sur une écriture : posée sur un GET, elle
|
|
140
|
+
// laisserait croire à une garantie que l'API ne donne pas.
|
|
141
|
+
if (entree.methode === 'POST' && entree.cleIdempotence) {
|
|
142
|
+
entetes[ENTETE_IDEMPOTENCE] = entree.cleIdempotence
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return { url, methode: entree.methode, entetes, corps, octets }
|
|
146
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// Les types du FIL — le contrat §4 de PLAN.md, recopié tel qu'il voyage.
|
|
3
|
+
// ============================================================================
|
|
4
|
+
//
|
|
5
|
+
// ⚠️ Deux décisions de vocabulaire, qui expliquent tout ce fichier.
|
|
6
|
+
//
|
|
7
|
+
// 1. **On garde le `snake_case` du contrat** (`id_externe`, `curseur_suivant`,
|
|
8
|
+
// `type_mime`, `url_put`). Traduire en camelCase donnerait une deuxième
|
|
9
|
+
// nomenclature à tenir : le jour où l'API ajoute un champ, il arriverait
|
|
10
|
+
// dans le SDK sous un nom que personne n'a écrit, et la table de
|
|
11
|
+
// correspondance oubliée rendrait `undefined` sans la moindre erreur. Le
|
|
12
|
+
// fil est la source de vérité ; le SDK en est le calque typé.
|
|
13
|
+
//
|
|
14
|
+
// 2. **Les unions fermées sont RECOPIÉES ici**, alors qu'elles existent déjà
|
|
15
|
+
// dans `src/lib/domaine/types.ts` de l'app. C'est délibéré : ce paquet est
|
|
16
|
+
// publié et installé chez des apps qui n'ont pas le dépôt Support. Importer
|
|
17
|
+
// l'app depuis le SDK ferait dépendre un client de son serveur. Le prix de
|
|
18
|
+
// la copie est qu'une valeur ajoutée au vocabulaire doit être ajoutée ici —
|
|
19
|
+
// d'où le renvoi explicite, ci-dessous, vers le fichier à suivre.
|
|
20
|
+
|
|
21
|
+
/** Source : `src/lib/domaine/types.ts` de Zevra Support (PLAN.md §2). */
|
|
22
|
+
export type TypeDemande = 'bug' | 'question' | 'idee' | 'beta'
|
|
23
|
+
|
|
24
|
+
/** Source : `src/lib/domaine/types.ts` de Zevra Support (PLAN.md §2). */
|
|
25
|
+
export type StatutDemande =
|
|
26
|
+
| 'nouvelle'
|
|
27
|
+
| 'ouverte'
|
|
28
|
+
| 'en_attente_requerant'
|
|
29
|
+
| 'resolue'
|
|
30
|
+
| 'fermee'
|
|
31
|
+
|
|
32
|
+
/** Source : `src/lib/domaine/types.ts` de Zevra Support (PLAN.md §2). */
|
|
33
|
+
export type PrioriteDemande = 'basse' | 'normale' | 'haute' | 'critique'
|
|
34
|
+
|
|
35
|
+
/** Source : `src/lib/domaine/types.ts` de Zevra Support (PLAN.md §2). */
|
|
36
|
+
export type AuteurMessage = 'requerant' | 'equipe' | 'ia' | 'systeme'
|
|
37
|
+
|
|
38
|
+
export const TYPES_DEMANDE: readonly TypeDemande[] = ['bug', 'question', 'idee', 'beta']
|
|
39
|
+
|
|
40
|
+
/* ─── Le requérant ──────────────────────────────────────────────────── */
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Qui demande. C'est l'app hôte qui le sait — jamais le navigateur : voir
|
|
44
|
+
* `relais/corps.ts`, où cette identité est IMPOSÉE au corps reçu.
|
|
45
|
+
*/
|
|
46
|
+
export interface Requerant {
|
|
47
|
+
email: string
|
|
48
|
+
nom?: string | null
|
|
49
|
+
/** L'identifiant de la personne DANS l'app cliente, pour recoller les fils. */
|
|
50
|
+
id_externe?: string | null
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/* ─── Le contexte technique ─────────────────────────────────────────── */
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Ce que le widget relève tout seul (PLAN.md §3, colonne `contexte` jsonb).
|
|
57
|
+
*
|
|
58
|
+
* Tous les champs sont optionnels : un contexte partiel vaut mieux qu'un
|
|
59
|
+
* contexte inventé. `console` porte au plus les 20 dernières erreurs.
|
|
60
|
+
*/
|
|
61
|
+
export interface ContexteDemande {
|
|
62
|
+
url?: string
|
|
63
|
+
user_agent?: string
|
|
64
|
+
version?: string
|
|
65
|
+
os?: string
|
|
66
|
+
viewport?: string
|
|
67
|
+
langue?: string
|
|
68
|
+
console?: string[]
|
|
69
|
+
extra?: Record<string, unknown>
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/* ─── POST /demandes ────────────────────────────────────────────────── */
|
|
73
|
+
|
|
74
|
+
export interface EntreeCreerDemande {
|
|
75
|
+
/** Facultatif : absent, le triage IA de Support le détermine. */
|
|
76
|
+
type?: TypeDemande | null
|
|
77
|
+
/** Facultatif : absent, le triage IA de Support le rédige d'après la description. */
|
|
78
|
+
titre?: string | null
|
|
79
|
+
description: string
|
|
80
|
+
requerant: Requerant
|
|
81
|
+
contexte?: ContexteDemande | null
|
|
82
|
+
/** Identifiants rendus par `POST /pieces`, jamais des noms de fichiers. */
|
|
83
|
+
pieces?: readonly string[]
|
|
84
|
+
campagne_id?: string | null
|
|
85
|
+
scenario?: string | null
|
|
86
|
+
/** Retour de bêta, 1 à 5. Hors bornes : 422. */
|
|
87
|
+
score?: number | null
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface DemandeCreee {
|
|
91
|
+
id: string
|
|
92
|
+
/** La référence publique, déjà formatée : « SUP-412 ». */
|
|
93
|
+
reference: string
|
|
94
|
+
statut: StatutDemande
|
|
95
|
+
/** Le lien magique du portail requérant — à mettre dans VOTRE accusé. */
|
|
96
|
+
url_portail: string
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/* ─── GET /demandes ─────────────────────────────────────────────────── */
|
|
100
|
+
|
|
101
|
+
export interface EntreeListerDemandes {
|
|
102
|
+
email: string
|
|
103
|
+
statut?: StatutDemande | readonly StatutDemande[]
|
|
104
|
+
limite?: number
|
|
105
|
+
curseur?: string | null
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Le résumé d'une demande en liste.
|
|
110
|
+
*
|
|
111
|
+
* ⚠️ Il n'y a NI description, NI contexte, NI extrait du dernier message, et
|
|
112
|
+
* ce n'est pas un oubli : le dernier message d'un fil peut être une note
|
|
113
|
+
* interne, et un extrait est la fuite la plus discrète qu'on puisse écrire.
|
|
114
|
+
* Pour le corps d'une demande, il faut la lire (`lireDemande`).
|
|
115
|
+
*/
|
|
116
|
+
export interface ResumeDemande {
|
|
117
|
+
id: string
|
|
118
|
+
reference: string
|
|
119
|
+
type: TypeDemande
|
|
120
|
+
statut: StatutDemande
|
|
121
|
+
priorite: PrioriteDemande
|
|
122
|
+
titre: string
|
|
123
|
+
created_at: string
|
|
124
|
+
dernier_message_at: string
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export interface PageDemandes {
|
|
128
|
+
demandes: ResumeDemande[]
|
|
129
|
+
/** Absent ou `null` : c'était la dernière page. */
|
|
130
|
+
curseur_suivant?: string | null
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/* ─── GET /demandes/:id ─────────────────────────────────────────────── */
|
|
134
|
+
|
|
135
|
+
export interface MessageLu {
|
|
136
|
+
id: string
|
|
137
|
+
auteur: AuteurMessage
|
|
138
|
+
corps: string
|
|
139
|
+
created_at: string
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export interface PieceLue {
|
|
143
|
+
id: string
|
|
144
|
+
nom: string
|
|
145
|
+
type_mime: string
|
|
146
|
+
octets: number
|
|
147
|
+
/** URL de LECTURE présignée, de courte durée. Jamais la clé S3 brute. */
|
|
148
|
+
url?: string | null
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export interface DemandeLue {
|
|
152
|
+
demande: ResumeDemande & {
|
|
153
|
+
description: string
|
|
154
|
+
contexte?: ContexteDemande | null
|
|
155
|
+
resolue_at?: string | null
|
|
156
|
+
/**
|
|
157
|
+
* ⚠️ CE CHAMP EST LA GARDE D'APPARTENANCE DU RELAIS, pas un ornement.
|
|
158
|
+
*
|
|
159
|
+
* `GET /api/v1/demandes/:id` rend n'importe quelle demande DE L'APP : la
|
|
160
|
+
* clé suffit, l'adresse n'y entre pas. C'est donc `relais/routes.ts` qui
|
|
161
|
+
* compare cette adresse à celle de la session avant de rendre le fil.
|
|
162
|
+
*
|
|
163
|
+
* S'il manque de la réponse de l'API v1, `verifierAppartenance` rend
|
|
164
|
+
* `false` — fermé par défaut, donc sûr, mais TOUTE lecture et TOUTE
|
|
165
|
+
* réponse sont alors refusées en 404 et l'onglet « Mes demandes » devient
|
|
166
|
+
* inutilisable. C'est pour ça qu'il est typé ici plutôt que lu par un
|
|
167
|
+
* transtypage : le jour où le lot 1 change la forme de la réponse, la
|
|
168
|
+
* rupture se voit à la compilation et pas sur un écran vide.
|
|
169
|
+
*/
|
|
170
|
+
requerant_email?: string | null
|
|
171
|
+
}
|
|
172
|
+
/** Les notes internes de l'équipe ne sont JAMAIS dans cette liste. */
|
|
173
|
+
messages: MessageLu[]
|
|
174
|
+
pieces: PieceLue[]
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/* ─── POST /demandes/:id/messages ───────────────────────────────────── */
|
|
178
|
+
|
|
179
|
+
export interface EntreeAjouterMessage {
|
|
180
|
+
corps: string
|
|
181
|
+
/** L'adresse du requérant : c'est elle qui prouve que le fil lui appartient. */
|
|
182
|
+
requerant_email: string
|
|
183
|
+
pieces?: readonly string[]
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export interface MessageAjoute {
|
|
187
|
+
message: MessageLu
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/* ─── POST /pieces ──────────────────────────────────────────────────── */
|
|
191
|
+
|
|
192
|
+
export interface EntreeDeposerPiece {
|
|
193
|
+
nom: string
|
|
194
|
+
type_mime: string
|
|
195
|
+
octets: number
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export interface DepotPiece {
|
|
199
|
+
id: string
|
|
200
|
+
/** URL présignée : le fichier part en PUT DIRECTEMENT vers S3, pas par ici. */
|
|
201
|
+
url_put: string
|
|
202
|
+
expire_at: string
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/* ─── GET /programmes/actif ─────────────────────────────────────────── */
|
|
206
|
+
|
|
207
|
+
export interface ScenarioCampagne {
|
|
208
|
+
cle: string
|
|
209
|
+
titre: string
|
|
210
|
+
etapes: string[]
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export interface ProgrammeActif {
|
|
214
|
+
programme?: { id: string; nom: string; description?: string | null } | null
|
|
215
|
+
campagne?: {
|
|
216
|
+
id: string
|
|
217
|
+
titre: string
|
|
218
|
+
consignes?: string | null
|
|
219
|
+
scenarios: ScenarioCampagne[]
|
|
220
|
+
} | null
|
|
221
|
+
}
|