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

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