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

@@ -48,5 +48,6 @@ pnpm + Turborepo. `apps/api` : NestJS, Prisma, PostgreSQL (pgvector + PostGIS),
- **R2.1 — socle backend exploitation** : migration `r2_exploitation` (10 tables), 15 opérations au contrat (41 total, transitions + champs requis du bilan dans `@siop/shared`), machine à états stricte avec garde de clôture (bilan 3 champs + checklist), demandeOT 1-1, rejet à motif, scoping « voir autre » (listes + accès directs), seed maquette (31 valeurs de bilan, 8 gabarits, OT/demandes/compteurs), 45 tests (95 %/79 %).
- **R2.2 — préventif + compteurs** : `underContract` + `periodKey` (unicité `[assetId, periodKey]` = idempotence EN BASE), génération mensuelle (ancrage mise en service, premier contrôle, gabarits administrables, statut du mois), compteurs strictement croissants ; +7 opérations (48), 50 tests (94,7 %/78 %). Automatisation cron/BullMQ notée pour le durcissement production.
- **R2.3 — écrans web exploitation** : liste/fiche OT (transitions via `allowedTransitions`, garde visible, bilan codé, checklist cliquable, activité), demandes+approbation/rejet motivé, nouvel OT (interrupteur urgence), préventif (tuiles+générer+gabarits), compteurs, tableau de bord réel, urgence traversante (chip topbar, badges, bandeau) ; `GET /assets/options` (trou Demandeur corrigé) ; retry sur collision de référence dans la génération ; 9 Playwright verts (recette R2 complète), 50 tests API.
- 🔄 **R2.4 — reprise ici** : portail public QR route publique `/q/{ref}` formulaire de signalement prérempli SANS compte (écran validé R0 : gros toggle personne bloquée, description, photo facultative R3 pour l'upload), suivi des signalements du gardien sans jargon (Reçu Intervention Résolu), rate-limiting/abus à considérer puis recette R2, déploiement, tag `release/r2`.
- **R2.4 — portail public QR** : `/q/{réf}` sans compte (écran validé R0, mobile d'abord), 3 routes publiques `/portal` throttlées, suivi par jeton opaque (`Request.publicToken`, jamais de liste exposée), étapes Reçu Intervention Résolu dérivées de l'OT ; boucle produit complète en e2e (11 Playwright verts, 52 opérations).
- 🔄 **R2 — reste pour clore** : recette R2 avec le référent (revue pixel maquettes R0+R2, sur `pnpm dev` ou l'instance en ligne après déploiement), tag `release/r2` ouverture R3 Gestion (stock, BC, tiers, main-d'œuvre, analytics, bibliothèque maquettes à produire d'abord).
- Détail quotidien : `docs/journal/journal.md`. Dépôt : `siop-spelev/siop2` (privé), jalons R0R5.

View File

@@ -23,6 +23,7 @@
"@nestjs/core": "^11.1.0",
"@nestjs/jwt": "^11.0.0",
"@nestjs/platform-express": "^11.1.0",
"@nestjs/throttler": "^6.5.0",
"@prisma/client": "^6.8.0",
"@siop/shared": "workspace:*",
"argon2": "^0.43.0",

View File

@@ -0,0 +1,6 @@
-- AlterTable
ALTER TABLE "Request" ADD COLUMN "publicToken" TEXT;
-- CreateIndex
CREATE UNIQUE INDEX "Request_publicToken_key" ON "Request"("publicToken");

View File

@@ -261,6 +261,9 @@ model Request {
requestedById String? @db.Uuid
requestedBy User? @relation(fields: [requestedById], references: [id])
requesterName String? // portail public via QR (R2.4)
// Suivi SANS COMPTE (portail) : le téléphone du gardien garde ce jeton,
// seul moyen de lire l'avancement — jamais listé, jamais devinable.
publicToken String? @unique
workOrderId String? @unique @db.Uuid // lien 1-1 — jamais de doublon
workOrder WorkOrder? @relation(fields: [workOrderId], references: [id])
createdAt DateTime @default(now())

View File

@@ -10,6 +10,7 @@ import { FilesModule } from './files/files.module';
import { HealthModule } from './health/health.module';
import { LocationsModule } from './locations/locations.module';
import { MetersModule } from './meters/meters.module';
import { PortalModule } from './portal/portal.module';
import { PreventiveModule } from './preventive/preventive.module';
import { ReferenceValuesModule } from './reference-values/reference-values.module';
import { RequestsModule } from './requests/requests.module';
@@ -48,6 +49,7 @@ export class AppModule {
ReferenceValuesModule,
PreventiveModule,
MetersModule,
PortalModule,
// ADR-002 : hors DEMO_MODE, le module n'est pas enregistré → 404
...(demoModeEnabled() ? [DemoAuthModule] : []),
],

View File

@@ -0,0 +1,40 @@
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
import { Throttle, ThrottlerGuard } from '@nestjs/throttler';
import {
PortalRequestCreateSchema,
type PortalRequestCreate,
} from '@siop/shared';
import { Public } from '../auth/public.decorator';
import { ZodValidationPipe } from '../common/zod-validation.pipe';
import { PortalService } from './portal.service';
/** Portail public (QR cabine) : seules routes @Public métier de l'API —
* protégées par throttling (surface exposée sans compte). */
@Controller('portal')
@UseGuards(ThrottlerGuard)
export class PortalController {
constructor(private readonly portal: PortalService) {}
@Public()
@Get('assets/:reference')
@Throttle({ default: { ttl: 60_000, limit: 60 } })
resolveAsset(@Param('reference') reference: string) {
return this.portal.resolveAsset(reference);
}
@Public()
@Post('requests')
@Throttle({ default: { ttl: 60_000, limit: 10 } }) // anti-spam signalement
createRequest(
@Body(new ZodValidationPipe(PortalRequestCreateSchema)) body: PortalRequestCreate,
) {
return this.portal.createRequest(body);
}
@Public()
@Get('requests/:reference/:token')
@Throttle({ default: { ttl: 60_000, limit: 60 } })
status(@Param('reference') reference: string, @Param('token') token: string) {
return this.portal.status(reference, token);
}
}

View File

@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { ThrottlerModule } from '@nestjs/throttler';
import { PortalController } from './portal.controller';
import { PortalService } from './portal.service';
@Module({
imports: [ThrottlerModule.forRoot([{ ttl: 60_000, limit: 60 }])],
controllers: [PortalController],
providers: [PortalService],
})
export class PortalModule {}

View File

@@ -0,0 +1,106 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import type {
PortalAsset,
PortalRequestCreate,
PortalRequestCreated,
PortalRequestStatus,
PortalStep,
} from '@siop/shared';
import { randomBytes } from 'node:crypto';
import { PrismaService } from '../prisma/prisma.service';
const requestInclude = { workOrder: true } satisfies Prisma.RequestInclude;
type Row = Prisma.RequestGetPayload<{ include: typeof requestInclude }>;
@Injectable()
export class PortalService {
constructor(private readonly prisma: PrismaService) {}
/** Le QR encode la référence (imprimée sur l'étiquette). */
async resolveAsset(reference: string): Promise<PortalAsset> {
const asset = await this.prisma.asset.findUnique({
where: { reference },
include: { location: { include: { parent: true } } },
});
if (!asset) throw new NotFoundException('Référence inconnue');
return {
reference: asset.reference,
siteName: asset.location.parent?.name ?? asset.location.name,
locationName: asset.location.name,
};
}
async createRequest(dto: PortalRequestCreate): Promise<PortalRequestCreated> {
const asset = await this.prisma.asset.findUnique({
where: { reference: dto.assetReference },
});
if (!asset) throw new NotFoundException('Référence inconnue');
for (let essai = 0; ; essai++) {
try {
const created = await this.prisma.request.create({
data: {
reference: await this.nextReference(),
description: dto.description,
isPersonTrapped: dto.isPersonTrapped ?? false,
assetId: asset.id,
requesterName: dto.requesterName || 'Portail (QR cabine)',
publicToken: randomBytes(24).toString('base64url'),
},
include: requestInclude,
});
return { ...this.toStatus(created), publicToken: created.publicToken! };
} catch (e) {
if (
e instanceof Prisma.PrismaClientKnownRequestError &&
e.code === 'P2002' &&
essai < 3
) {
continue; // collision de référence — on retente
}
throw e;
}
}
}
/** Suivi par jeton opaque — pas de jeton, pas de lecture (aucune fuite). */
async status(reference: string, token: string): Promise<PortalRequestStatus> {
const request = await this.prisma.request.findUnique({
where: { reference },
include: requestInclude,
});
if (!request || !request.publicToken || request.publicToken !== token) {
throw new NotFoundException('Signalement inconnu ou jeton invalide');
}
return this.toStatus(request);
}
private async nextReference(): Promise<string> {
const annee = new Date().getFullYear();
const dernier = await this.prisma.request.findFirst({
where: { reference: { startsWith: `DEM-${annee}-` } },
orderBy: { reference: 'desc' },
select: { reference: true },
});
const n = dernier ? Number(dernier.reference.split('-')[2]) + 1 : 1;
return `DEM-${annee}-${String(n).padStart(4, '0')}`;
}
private toStatus(row: Row): PortalRequestStatus {
const step: PortalStep =
row.workOrder?.status === 'DONE'
? 'RESOLVED'
: row.workOrder && row.workOrder.startedAt
? 'IN_PROGRESS'
: 'RECEIVED';
return {
reference: row.reference,
description: row.description,
isPersonTrapped: row.isPersonTrapped,
status: row.status,
step,
rejectionReason: row.rejectionReason,
createdAt: row.createdAt.toISOString(),
};
}
}

View File

@@ -0,0 +1,77 @@
import { expect, test } from '@playwright/test';
/**
* R2.4 — LA boucle produit : le gardien scanne le QR (aucun compte), signale,
* l'équipe traite (via API), le gardien voit « Résolu » sur son téléphone.
*/
const suffix = Date.now().toString(36);
const API = 'http://localhost:3000';
test('portail QR : signalement sans compte → traitement → suivi Résolu', async ({
page,
request,
}) => {
// 1 · Le gardien scanne le QR de l'ascenseur A1 → formulaire prérempli
await page.goto('/q/A1');
await expect(page.getByText(/Ascenseur A1/)).toBeVisible();
await expect(page.getByText('détecté par le QR')).toBeVisible();
await page.getByLabel('Décrivez le problème').fill(`E2E ${suffix} — voyant cabine éteint`);
await page.getByRole('button', { name: 'Envoyer le signalement' }).click();
await expect(page.getByText('l\'équipe SPELEV est prévenue')).toBeVisible();
// « Mes signalements » : Reçu, sans jargon
const ligne = page.locator('.item-liste', { hasText: suffix });
await expect(ligne).toBeVisible();
await expect(ligne.locator('.etape.fait')).toHaveText('✓ Reçu');
// 2 · Côté SPELEV (via API — le parcours UI est couvert par parcours-r2) :
// approbation → démarrage → bilan → clôture
const comptes = await (await request.get(`${API}/auth/demo-accounts`)).json();
const salmaId = comptes.accounts.find((a: { roleName: string }) => a.roleName === 'Dispatcher').id;
const jeton = (await (await request.post(`${API}/auth/demo-login`, { data: { userId: salmaId } })).json()).accessToken;
const auth = { Authorization: `Bearer ${jeton}` };
const demandes = await (await request.get(`${API}/requests`, { headers: auth })).json();
const demande = demandes.requests.find((r: { description: string }) => r.description.includes(suffix));
expect(demande).toBeTruthy();
const ot = await (
await request.post(`${API}/requests/${demande.id}/approve`, { headers: auth, data: {} })
).json();
await request.post(`${API}/work-orders/${ot.id}/transition`, {
headers: auth,
data: { to: 'IN_PROGRESS' },
});
// 3 · Le gardien recharge : « Intervention » franchie
await page.reload();
await expect(page.locator('.item-liste', { hasText: suffix }).getByText('✓ Intervention')).toBeVisible();
// 4 · Clôture avec bilan → « Résolu »
const refs = await (await request.get(`${API}/reference-values`, { headers: auth })).json();
const valeur = (field: string) =>
refs.referenceValues.find((v: { field: string; isActive: boolean }) => v.field === field && v.isActive).id;
await request.put(`${API}/work-orders/${ot.id}/report`, {
headers: auth,
data: {
doorStateId: valeur('DOOR_STATE'),
actionTakenId: valeur('ACTION_TAKEN'),
componentConcernedId: valeur('COMPONENT_CONCERNED'),
},
});
const cloture = await request.post(`${API}/work-orders/${ot.id}/transition`, {
headers: auth,
data: { to: 'DONE' },
});
expect(cloture.ok()).toBe(true);
await page.reload();
await expect(page.locator('.item-liste', { hasText: suffix }).getByText('✓ Résolu')).toBeVisible();
});
test('QR inconnu : message clair, pas décran technique', async ({ page }) => {
await page.goto('/q/INTROUVABLE-99');
await expect(page.getByText(/aucun appareil connu/)).toBeVisible();
});

View File

@@ -17,6 +17,7 @@ import PageNouvelAscenseur from '@/pages/nouvel-ascenseur';
import PageNouvelOT from '@/pages/nouvel-ot';
import PageOrdresTravail from '@/pages/ordres-travail';
import PagePersonnes from '@/pages/personnes';
import PagePortail from '@/pages/portail';
import PagePreventif from '@/pages/preventif';
import PageSites from '@/pages/sites';
import PageTableauDeBord from '@/pages/tableau-de-bord';
@@ -34,6 +35,8 @@ export default function App() {
<Routes>
<Route path="/connexion" element={<PageConnexion />} />
<Route path="/activation" element={<PageActivation />} />
{/* Portail public — cible du QR cabine, AUCUN compte */}
<Route path="/q/:reference" element={<PagePortail />} />
<Route path="/" element={dansCoquille(<PageTableauDeBord />)} />
<Route path="/design" element={dansCoquille(<PageDesign />)} />
<Route path="/sites" element={dansCoquille(<PageSites />)} />

View File

@@ -372,6 +372,57 @@ export interface paths {
patch: operations["updateUser"];
trace?: never;
};
"/portal/assets/{reference}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Résoudre le QR scanné (référence → appareil, préremplissage) */
get: operations["getPortalAsset"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/portal/requests": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Signaler sans compte — renvoie le jeton de suivi (à garder sur le téléphone) */
post: operations["createPortalRequest"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/portal/requests/{reference}/{token}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Suivi sans jargon (Reçu → Intervention → Résolu) — jeton requis */
get: operations["getPortalRequestStatus"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/preventive/templates": {
parameters: {
query?: never;
@@ -1069,6 +1120,42 @@ export interface components {
teamIds?: string[];
isActive?: boolean;
};
PortalAsset: {
reference: string;
siteName: string;
locationName: string;
};
PortalRequestCreated: {
reference: string;
description: string;
isPersonTrapped: boolean;
/** @enum {string} */
status: "RECEIVED" | "APPROVED" | "REJECTED";
/** @enum {string} */
step: "RECEIVED" | "IN_PROGRESS" | "RESOLVED";
rejectionReason: string | null;
/** Format: date-time */
createdAt: string;
publicToken: string;
};
PortalRequestCreate: {
assetReference: string;
description: string;
isPersonTrapped?: boolean;
requesterName?: string;
};
PortalRequestStatus: {
reference: string;
description: string;
isPersonTrapped: boolean;
/** @enum {string} */
status: "RECEIVED" | "APPROVED" | "REJECTED";
/** @enum {string} */
step: "RECEIVED" | "IN_PROGRESS" | "RESOLVED";
rejectionReason: string | null;
/** Format: date-time */
createdAt: string;
};
TaskTemplatesResponse: {
templates: {
/** Format: uuid */
@@ -2172,6 +2259,103 @@ export interface operations {
};
};
};
getPortalAsset: {
parameters: {
query?: never;
header?: never;
path: {
reference: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Appareil */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["PortalAsset"];
};
};
/** @description Référence inconnue */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
createPortalRequest: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["PortalRequestCreate"];
};
};
responses: {
/** @description Signalement enregistré */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["PortalRequestCreated"];
};
};
/** @description Référence inconnue */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Trop de signalements — réessayez dans une minute */
429: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
getPortalRequestStatus: {
parameters: {
query?: never;
header?: never;
path: {
reference: string;
token: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Avancement */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["PortalRequestStatus"];
};
};
/** @description Signalement inconnu ou jeton invalide */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
listTaskTemplates: {
parameters: {
query?: never;

View File

@@ -0,0 +1,203 @@
import { useQuery } from '@tanstack/react-query';
import { useState, type FormEvent } from 'react';
import { useParams } from 'react-router-dom';
import type { PortalRequestStatus } from '@siop/shared';
import { api } from '@/api/client';
import { Button } from '@/components/ui/button';
import { IcoAscenseurs } from '@/components/nav-icons';
/**
* Écran 6 validé R0 : Karim (gardien, aucune formation, aucun compte) arrive
* ici en scannant le QR. Équipement prérempli, UN choix critique, suivi sans
* jargon. Ses signalements vivent sur SON téléphone (référence + jeton).
*/
const CLE_SUIVIS = 'siop.portail.suivis';
interface Suivi { reference: string; token: string; }
const lireSuivis = (): Suivi[] => {
try {
return JSON.parse(localStorage.getItem(CLE_SUIVIS) ?? '[]') as Suivi[];
} catch {
return [];
}
};
const ajouterSuivi = (s: Suivi) =>
localStorage.setItem(CLE_SUIVIS, JSON.stringify([s, ...lireSuivis()].slice(0, 10)));
export default function PagePortail() {
const { reference } = useParams<{ reference: string }>();
const [suivis, setSuivis] = useState<Suivi[]>(lireSuivis);
const [personneBloquee, setPersonneBloquee] = useState(false);
const [envoye, setEnvoye] = useState(false);
const [erreur, setErreur] = useState<string | null>(null);
const { data: asset, isError: refInconnue } = useQuery({
queryKey: ['portal-asset', reference],
retry: false,
queryFn: async () => {
const { data, response } = await api.GET('/portal/assets/{reference}', {
params: { path: { reference: reference! } },
});
if (!data) throw new Error(String(response.status));
return data;
},
});
const surEnvoi = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
setErreur(null);
const form = e.currentTarget;
const description = String(new FormData(form).get('description') ?? '').trim();
const { data, response } = await api.POST('/portal/requests', {
body: {
assetReference: reference!,
description,
isPersonTrapped: personneBloquee,
},
});
if (!data) {
setErreur(
response.status === 429
? 'Trop de signalements envoyés — réessayez dans une minute.'
: 'Envoi impossible — réessayez ou appelez le numéro affiché en cabine.',
);
return;
}
ajouterSuivi({ reference: data.reference, token: data.publicToken });
setSuivis(lireSuivis());
setPersonneBloquee(false);
setEnvoye(true);
form.reset();
};
return (
<div style={{ background: 'var(--fond)', minHeight: '100vh' }}>
<div className="portail">
<div className="logo" style={{ color: 'var(--encre)', padding: 0 }}>
<span className="marque" aria-hidden="true"><br /></span>
<div>
<b style={{ fontSize: 19 }}>SIOP</b>
<small style={{ color: 'var(--encre-2)' }}>
Signalement{asset ? `${asset.siteName}` : ''}
</small>
</div>
</div>
{refInconnue ? (
<div className="carte">
<p className="erreur-form">
Ce QR ne correspond à aucun appareil connu. Vérifiez l'étiquette ou
appelez le numéro affiché en cabine.
</p>
</div>
) : (
<form className="carte flex flex-col gap-3" onSubmit={surEnvoi}>
<div className="bandeau-detecte">
<span style={{ color: 'var(--primaire)', width: 18, height: 18, flex: 'none' }}>
<IcoAscenseurs />
</span>
<span>
<b>Ascenseur {reference}{asset ? ` — ${asset.locationName}` : ''}</b>{' '}
<span style={{ color: 'var(--encre-2)' }}>(détecté par le QR)</span>
</span>
</div>
<div className="gros-toggle">
<b>Une personne est-elle bloquée dans la cabine ?</b>
<button
type="button"
className="interrupteur"
role="switch"
aria-checked={personneBloquee}
aria-label="Personne bloquée"
onClick={() => setPersonneBloquee((v) => !v)}
/>
</div>
<div className="champ">
<label htmlFor="po-desc">Décrivez le problème</label>
<input
id="po-desc"
name="description"
required
minLength={3}
placeholder="Ex. : la porte ne se ferme plus au 3ᵉ étage"
/>
</div>
<div className="zone-photo">📷 Ajouter une photo (bientôt disponible)</div>
{erreur ? <p className="erreur-form" role="alert">{erreur}</p> : null}
{envoye ? (
<p style={{ color: 'var(--succes)', fontWeight: 600, fontSize: 13 }} role="status">
✓ Signalement envoyé — l'équipe SPELEV est prévenue.
</p>
) : null}
<Button type="submit" variant="prim" className="justify-center" style={{ padding: 11 }}>
Envoyer le signalement
</Button>
</form>
)}
{suivis.length > 0 ? (
<div className="carte">
<h2>Mes signalements</h2>
{suivis.map((s) => (
<LigneSuivi key={s.reference} suivi={s} />
))}
</div>
) : null}
</div>
</div>
);
}
function LigneSuivi({ suivi }: { suivi: Suivi }) {
const { data } = useQuery({
queryKey: ['portal-status', suivi.reference],
refetchInterval: 30_000,
retry: false,
queryFn: async () => {
const { data } = await api.GET('/portal/requests/{reference}/{token}', {
params: { path: { reference: suivi.reference, token: suivi.token } },
});
return data ?? null;
},
});
if (!data) return null;
return (
<div className="item-liste" style={{ alignItems: 'flex-start' }}>
<div>
<b>{data.description}</b>
<span className="sous num">
{data.reference} ·{' '}
{new Intl.DateTimeFormat('fr-FR', { dateStyle: 'medium' }).format(new Date(data.createdAt))}
</span>
{data.status === 'REJECTED' ? (
<div style={{ fontSize: 12, color: 'var(--encre-2)', marginTop: 4 }}>
Sans suite : {data.rejectionReason ?? 'voir avec le gestionnaire'}
</div>
) : (
<Etapes status={data} />
)}
</div>
</div>
);
}
function Etapes({ status }: { status: PortalRequestStatus }) {
const franchi = (etape: 'RECEIVED' | 'IN_PROGRESS' | 'RESOLVED') => {
const ordre = ['RECEIVED', 'IN_PROGRESS', 'RESOLVED'];
return ordre.indexOf(status.step) >= ordre.indexOf(etape);
};
return (
<div className="suivi-simple">
<span className={franchi('RECEIVED') ? 'etape fait' : 'etape'}> Reçu</span>
<span className="lien-e" />
<span className={franchi('IN_PROGRESS') ? 'etape fait' : 'etape'}>
{franchi('IN_PROGRESS') ? '✓ ' : ''}Intervention
</span>
<span className="lien-e" />
<span className={franchi('RESOLVED') ? 'etape fait' : 'etape'}>
{franchi('RESOLVED') ? '✓ ' : ''}Résolu
</span>
</div>
);
}

View File

@@ -873,6 +873,45 @@ table {
color: var(--encre); text-align: right;
}
/* ═══ Portail demandeur (écran 6 validé R0 — aucun compte, aucun jargon) ═══ */
.portail {
max-width: 430px;
margin: 28px auto;
padding: 0 16px;
display: flex;
flex-direction: column;
gap: 14px;
min-height: 100vh;
}
.zone-photo {
border: 1.5px dashed var(--bordure-forte);
border-radius: 10px;
padding: 18px;
text-align: center;
color: var(--encre-2);
font-size: 13px;
}
.bandeau-detecte {
display: flex;
align-items: center;
gap: 10px;
background: var(--primaire-doux);
border-radius: var(--rayon-controle);
padding: 10px 12px;
font-size: 13px;
}
.suivi-simple {
display: flex;
align-items: center;
gap: 0;
font-size: 11.5px;
color: var(--encre-3);
margin-top: 6px;
}
.suivi-simple .etape { display: flex; align-items: center; gap: 6px; }
.suivi-simple .etape.fait { color: var(--succes); font-weight: 700; }
.suivi-simple .lien-e { width: 26px; height: 1px; background: var(--bordure-forte); margin: 0 6px; }
/* ═══ Page /design : nuanciers ═══ */
.nuancier {
display: flex;

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",

View File

@@ -44,7 +44,11 @@ for (const op of API_CONTRACT) {
name,
in: 'path',
required: true,
schema: { type: 'string', format: 'uuid' },
// Convention : « id » / «…Id » sont des UUID, le reste est libre
schema:
name === 'id' || name.endsWith('Id')
? { type: 'string', format: 'uuid' }
: { type: 'string' },
})),
}
: {}),

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>;

16
pnpm-lock.yaml generated
View File

@@ -38,6 +38,9 @@ importers:
'@nestjs/platform-express':
specifier: ^11.1.0
version: 11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/core@11.1.28)(supports-color@10.2.2)
'@nestjs/throttler':
specifier: ^6.5.0
version: 6.5.0(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/core@11.1.28)(reflect-metadata@0.2.2)
'@prisma/client':
specifier: ^6.8.0
version: 6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3)
@@ -1161,6 +1164,13 @@ packages:
'@nestjs/platform-express':
optional: true
'@nestjs/throttler@6.5.0':
resolution: {integrity: sha512-9j0ZRfH0QE1qyrj9JjIRDz5gQLPqq9yVC2nHsrosDVAfI5HHw08/aUAWx9DZLSdQf4HDkmhTTEGLrRFHENvchQ==}
peerDependencies:
'@nestjs/common': ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0
'@nestjs/core': ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0
reflect-metadata: ^0.1.13 || ^0.2.0
'@noble/hashes@1.8.0':
resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==}
engines: {node: ^14.21.3 || >=16}
@@ -5695,6 +5705,12 @@ snapshots:
optionalDependencies:
'@nestjs/platform-express': 11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/core@11.1.28)(supports-color@10.2.2)
'@nestjs/throttler@6.5.0(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/core@11.1.28)(reflect-metadata@0.2.2)':
dependencies:
'@nestjs/common': 11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2)
'@nestjs/core': 11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/platform-express@11.1.28)(reflect-metadata@0.2.2)(rxjs@7.8.2)
reflect-metadata: 0.2.2
'@noble/hashes@1.8.0': {}
'@nodable/entities@2.2.0': {}