@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/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
+ }