feat(r2.4): portail public QR — signalement sans compte, suivi par jeton

- 3 routes publiques /portal (les seules @Public métier), throttlées
  (@nestjs/throttler : 10 signalements/min, 60 lectures/min) : résolution
  du QR, signalement (retourne un jeton de suivi opaque), suivi par
  référence + jeton — pas de jeton, pas de lecture, jamais de liste
- migration r2_portail : Request.publicToken (unique)
- page /q/{réf} fidèle à l'écran 6 validé R0 (mobile d'abord) : équipement
  prérempli « détecté par le QR », interrupteur personne bloquée,
  suivi sans jargon Reçu → Intervention → Résolu (dérivé de l'OT lié),
  rejet affiché « Sans suite : {motif} », photo annoncée (upload R3) ;
  signalements gardés sur le téléphone (localStorage, 10 max)
- e2e : LA boucle produit — gardien sans compte → traitement → ✓ Résolu ;
  11 tests Playwright verts, 50 tests API, 52 opérations au contrat
- générateur OpenAPI : paramètres non-« id » plus typés uuid

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pr-daaif
2026-07-16 17:33:10 +01:00
parent 6a2dae1710
commit 50254daea4
20 changed files with 1089 additions and 2 deletions

View File

@@ -4,6 +4,24 @@ Trace chronologique des sessions (la plus récente en premier). Le **playbook**
---
## 2026-07-16 — Pr. Daaif (+ Claude) — R2.4 : portail public QR — l'étiquette prend vie
**Actions**
- **Trois routes publiques `/portal`** (les seules routes @Public métier de l'API), **throttlées** (`@nestjs/throttler` : 10 signalements/min, 60 lectures/min — première surface sans compte) : résolution du QR (`référence → appareil`), signalement (retourne un **jeton de suivi opaque**), suivi par `référence + jeton` (pas de jeton, pas de lecture — aucune énumération possible).
- **Suivi sans compte** : `Request.publicToken` (migration `r2_portail`) ; le téléphone du gardien garde `[référence, jeton]` en localStorage (10 max) ; étapes sans jargon **Reçu → Intervention → Résolu** dérivées de l'OT lié ; un rejet s'affiche « Sans suite : {motif} ».
- **Page `/q/{réf}`** fidèle à l'écran 6 validé R0 (mobile d'abord) : équipement prérempli « détecté par le QR », gros interrupteur personne bloquée, description, photo annoncée (upload → R3), messages d'erreur en français métier (QR inconnu, throttling).
- **e2e Playwright** : LA boucle produit — le gardien signale sans compte sur `/q/A1`, l'équipe traite (approbation → démarrage → bilan → clôture via API), le gardien recharge et voit ✓ Résolu. 11 tests Playwright verts, 50 tests API, 52 opérations au contrat. Générateur OpenAPI : les paramètres non-`id` ne sont plus typés uuid.
**Décisions**
- Le portail n'expose JAMAIS de liste : lecture uniquement par jeton individuel.
- Throttling au niveau du contrôleur portail seulement (le reste de l'API est derrière JWT).
**R2 est fonctionnellement complète** (OT, bilan codé, demandes, portail QR, préventif, compteurs). Prochaine étape : recette R2 avec le référent (revue pixel ↔ maquettes R0+R2), déploiement, tag `release/r2`.
---
## 2026-07-16 — Pr. Daaif (+ Claude) — R2.3 : écrans web de l'exploitation
**Actions**

View File

@@ -956,6 +956,119 @@
}
}
},
"/portal/assets/{reference}": {
"get": {
"operationId": "getPortalAsset",
"summary": "Résoudre le QR scanné (référence → appareil, préremplissage)",
"tags": [
"portal"
],
"parameters": [
{
"name": "reference",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Appareil",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PortalAsset"
}
}
}
},
"404": {
"description": "Référence inconnue"
}
}
}
},
"/portal/requests": {
"post": {
"operationId": "createPortalRequest",
"summary": "Signaler sans compte — renvoie le jeton de suivi (à garder sur le téléphone)",
"tags": [
"portal"
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PortalRequestCreate"
}
}
}
},
"responses": {
"201": {
"description": "Signalement enregistré",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PortalRequestCreated"
}
}
}
},
"404": {
"description": "Référence inconnue"
},
"429": {
"description": "Trop de signalements — réessayez dans une minute"
}
}
}
},
"/portal/requests/{reference}/{token}": {
"get": {
"operationId": "getPortalRequestStatus",
"summary": "Suivi sans jargon (Reçu → Intervention → Résolu) — jeton requis",
"tags": [
"portal"
],
"parameters": [
{
"name": "reference",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "token",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Avancement",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PortalRequestStatus"
}
}
}
},
"404": {
"description": "Signalement inconnu ou jeton invalide"
}
}
}
},
"/preventive/templates": {
"get": {
"operationId": "listTaskTemplates",
@@ -3634,6 +3747,171 @@
},
"additionalProperties": false
},
"PortalAsset": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"reference": {
"type": "string"
},
"siteName": {
"type": "string"
},
"locationName": {
"type": "string"
}
},
"required": [
"reference",
"siteName",
"locationName"
],
"additionalProperties": false
},
"PortalRequestCreated": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"reference": {
"type": "string"
},
"description": {
"type": "string"
},
"isPersonTrapped": {
"type": "boolean"
},
"status": {
"type": "string",
"enum": [
"RECEIVED",
"APPROVED",
"REJECTED"
]
},
"step": {
"type": "string",
"enum": [
"RECEIVED",
"IN_PROGRESS",
"RESOLVED"
]
},
"rejectionReason": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"createdAt": {
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
},
"publicToken": {
"type": "string"
}
},
"required": [
"reference",
"description",
"isPersonTrapped",
"status",
"step",
"rejectionReason",
"createdAt",
"publicToken"
],
"additionalProperties": false
},
"PortalRequestCreate": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"assetReference": {
"type": "string",
"minLength": 1,
"maxLength": 30
},
"description": {
"type": "string",
"minLength": 3,
"maxLength": 1000
},
"isPersonTrapped": {
"type": "boolean"
},
"requesterName": {
"type": "string",
"maxLength": 120
}
},
"required": [
"assetReference",
"description"
],
"additionalProperties": false
},
"PortalRequestStatus": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"reference": {
"type": "string"
},
"description": {
"type": "string"
},
"isPersonTrapped": {
"type": "boolean"
},
"status": {
"type": "string",
"enum": [
"RECEIVED",
"APPROVED",
"REJECTED"
]
},
"step": {
"type": "string",
"enum": [
"RECEIVED",
"IN_PROGRESS",
"RESOLVED"
]
},
"rejectionReason": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"createdAt": {
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
}
},
"required": [
"reference",
"description",
"isPersonTrapped",
"status",
"step",
"rejectionReason",
"createdAt"
],
"additionalProperties": false
},
"TaskTemplatesResponse": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",