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

@@ -38,6 +38,12 @@ import {
UsersResponseSchema,
UserUpdateSchema,
} from './schemas/users-admin';
import {
PortalAssetSchema,
PortalRequestCreatedSchema,
PortalRequestCreateSchema,
PortalRequestStatusSchema,
} from './schemas/portail';
import {
MeterReadingCreateSchema,
MetersResponseSchema,
@@ -413,6 +419,52 @@ export const API_CONTRACT: ApiOperation[] = [
404: { description: 'Inconnue' },
},
},
// ————— R2 · Portail public (QR cabine — aucun compte) —————
{
operationId: 'getPortalAsset',
method: 'get',
path: '/portal/assets/{reference}',
summary: 'Résoudre le QR scanné (référence → appareil, préremplissage)',
tags: ['portal'],
isPublic: true,
pathParams: ['reference'],
responses: {
200: { description: 'Appareil', name: 'PortalAsset', schema: PortalAssetSchema },
404: { description: 'Référence inconnue' },
},
},
{
operationId: 'createPortalRequest',
method: 'post',
path: '/portal/requests',
summary: 'Signaler sans compte — renvoie le jeton de suivi (à garder sur le téléphone)',
tags: ['portal'],
isPublic: true,
request: { name: 'PortalRequestCreate', schema: PortalRequestCreateSchema },
responses: {
201: {
description: 'Signalement enregistré',
name: 'PortalRequestCreated',
schema: PortalRequestCreatedSchema,
},
404: { description: 'Référence inconnue' },
429: { description: 'Trop de signalements — réessayez dans une minute' },
},
},
{
operationId: 'getPortalRequestStatus',
method: 'get',
path: '/portal/requests/{reference}/{token}',
summary: 'Suivi sans jargon (Reçu → Intervention → Résolu) — jeton requis',
tags: ['portal'],
isPublic: true,
pathParams: ['reference', 'token'],
responses: {
200: { description: 'Avancement', name: 'PortalRequestStatus', schema: PortalRequestStatusSchema },
404: { description: 'Signalement inconnu ou jeton invalide' },
},
},
// ————— R2 · Préventif & compteurs —————
{
operationId: 'listTaskTemplates',

View File

@@ -3,6 +3,7 @@ export * from './referentiel';
export * from './exploitation';
export * from './schemas/exploitation';
export * from './schemas/preventif';
export * from './schemas/portail';
export * from './schemas/auth';
export * from './schemas/users';
export * from './schemas/users-admin';

View File

@@ -0,0 +1,42 @@
import { z } from 'zod';
import { REQUEST_STATUSES } from '../exploitation';
/**
* Portail public (QR cabine) — AUCUN compte, AUCUN jargon (charte §7).
* Le suivi se fait par jeton opaque gardé sur le téléphone du gardien.
*/
export const PortalAssetSchema = z.object({
reference: z.string(),
siteName: z.string(),
locationName: z.string(),
});
export type PortalAsset = z.infer<typeof PortalAssetSchema>;
export const PortalRequestCreateSchema = z.object({
assetReference: z.string().min(1).max(30),
description: z.string().min(3).max(1000),
isPersonTrapped: z.boolean().optional(),
requesterName: z.string().max(120).optional(),
});
export type PortalRequestCreate = z.infer<typeof PortalRequestCreateSchema>;
/** Étapes lisibles par tous : Reçu → Intervention → Résolu. */
export const PORTAL_STEPS = ['RECEIVED', 'IN_PROGRESS', 'RESOLVED'] as const;
export type PortalStep = (typeof PORTAL_STEPS)[number];
export const PortalRequestStatusSchema = z.object({
reference: z.string(),
description: z.string(),
isPersonTrapped: z.boolean(),
status: z.enum(REQUEST_STATUSES),
step: z.enum(PORTAL_STEPS),
rejectionReason: z.string().nullable(),
createdAt: z.iso.datetime(),
});
export type PortalRequestStatus = z.infer<typeof PortalRequestStatusSchema>;
export const PortalRequestCreatedSchema = PortalRequestStatusSchema.extend({
publicToken: z.string(), // à conserver côté téléphone — seul accès au suivi
});
export type PortalRequestCreated = z.infer<typeof PortalRequestCreatedSchema>;