diff --git a/CLAUDE.md b/CLAUDE.md index 23164bf..9b94900 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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), demande→OT 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 R0→R5. diff --git a/apps/api/package.json b/apps/api/package.json index 0415612..dc8ba11 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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", diff --git a/apps/api/prisma/migrations/20260716172705_r2_portail/migration.sql b/apps/api/prisma/migrations/20260716172705_r2_portail/migration.sql new file mode 100644 index 0000000..27e161f --- /dev/null +++ b/apps/api/prisma/migrations/20260716172705_r2_portail/migration.sql @@ -0,0 +1,6 @@ +-- AlterTable +ALTER TABLE "Request" ADD COLUMN "publicToken" TEXT; + +-- CreateIndex +CREATE UNIQUE INDEX "Request_publicToken_key" ON "Request"("publicToken"); + diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 80be5ad..06cee26 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -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()) diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 6d813f6..1ce7467 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -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] : []), ], diff --git a/apps/api/src/portal/portal.controller.ts b/apps/api/src/portal/portal.controller.ts new file mode 100644 index 0000000..b7b5ef8 --- /dev/null +++ b/apps/api/src/portal/portal.controller.ts @@ -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); + } +} diff --git a/apps/api/src/portal/portal.module.ts b/apps/api/src/portal/portal.module.ts new file mode 100644 index 0000000..8bb703a --- /dev/null +++ b/apps/api/src/portal/portal.module.ts @@ -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 {} diff --git a/apps/api/src/portal/portal.service.ts b/apps/api/src/portal/portal.service.ts new file mode 100644 index 0000000..e340a8d --- /dev/null +++ b/apps/api/src/portal/portal.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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(), + }; + } +} diff --git a/apps/web/e2e/parcours-portail.spec.ts b/apps/web/e2e/parcours-portail.spec.ts new file mode 100644 index 0000000..521ce47 --- /dev/null +++ b/apps/web/e2e/parcours-portail.spec.ts @@ -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(); +}); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 08841a6..b83dfe2 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -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() { } /> } /> + {/* Portail public — cible du QR cabine, AUCUN compte */} + } /> )} /> )} /> )} /> diff --git a/apps/web/src/api/schema.d.ts b/apps/web/src/api/schema.d.ts index e6cb608..133990d 100644 --- a/apps/web/src/api/schema.d.ts +++ b/apps/web/src/api/schema.d.ts @@ -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; diff --git a/apps/web/src/pages/portail.tsx b/apps/web/src/pages/portail.tsx new file mode 100644 index 0000000..43a30d9 --- /dev/null +++ b/apps/web/src/pages/portail.tsx @@ -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(lireSuivis); + const [personneBloquee, setPersonneBloquee] = useState(false); + const [envoye, setEnvoye] = useState(false); + const [erreur, setErreur] = useState(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) => { + 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 ( +
+
+
+ +
+ SIOP + + Signalement{asset ? ` — ${asset.siteName}` : ''} + +
+
+ + {refInconnue ? ( +
+

+ Ce QR ne correspond à aucun appareil connu. Vérifiez l'étiquette ou + appelez le numéro affiché en cabine. +

+
+ ) : ( +
+
+ + + + + Ascenseur {reference}{asset ? ` — ${asset.locationName}` : ''}{' '} + (détecté par le QR) + +
+
+ Une personne est-elle bloquée dans la cabine ? +
+
+ + +
+
📷 Ajouter une photo (bientôt disponible)
+ {erreur ?

{erreur}

: null} + {envoye ? ( +

+ ✓ Signalement envoyé — l'équipe SPELEV est prévenue. +

+ ) : null} + +
+ )} + + {suivis.length > 0 ? ( +
+

Mes signalements

+ {suivis.map((s) => ( + + ))} +
+ ) : null} +
+
+ ); +} + +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 ( +
+
+ {data.description} + + {data.reference} ·{' '} + {new Intl.DateTimeFormat('fr-FR', { dateStyle: 'medium' }).format(new Date(data.createdAt))} + + {data.status === 'REJECTED' ? ( +
+ Sans suite : {data.rejectionReason ?? 'voir avec le gestionnaire'} +
+ ) : ( + + )} +
+
+ ); +} + +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 ( +
+ ✓ Reçu + + + {franchi('IN_PROGRESS') ? '✓ ' : ''}Intervention + + + + {franchi('RESOLVED') ? '✓ ' : ''}Résolu + +
+ ); +} diff --git a/apps/web/src/styles/app.css b/apps/web/src/styles/app.css index c69dc8f..934a55c 100644 --- a/apps/web/src/styles/app.css +++ b/apps/web/src/styles/app.css @@ -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; diff --git a/docs/journal/journal.md b/docs/journal/journal.md index af8f2b7..81ad2d1 100644 --- a/docs/journal/journal.md +++ b/docs/journal/journal.md @@ -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** diff --git a/docs/openapi.json b/docs/openapi.json index beb5894..57cbe39 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -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", diff --git a/packages/shared/scripts/generate-openapi.ts b/packages/shared/scripts/generate-openapi.ts index ad1d3cb..3a99421 100644 --- a/packages/shared/scripts/generate-openapi.ts +++ b/packages/shared/scripts/generate-openapi.ts @@ -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' }, })), } : {}), diff --git a/packages/shared/src/contract.ts b/packages/shared/src/contract.ts index 7ccf0b9..8c3fd1f 100644 --- a/packages/shared/src/contract.ts +++ b/packages/shared/src/contract.ts @@ -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', diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 191546d..3b6a51f 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -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'; diff --git a/packages/shared/src/schemas/portail.ts b/packages/shared/src/schemas/portail.ts new file mode 100644 index 0000000..b7a1d6e --- /dev/null +++ b/packages/shared/src/schemas/portail.ts @@ -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; + +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; + +/** É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; + +export const PortalRequestCreatedSchema = PortalRequestStatusSchema.extend({ + publicToken: z.string(), // à conserver côté téléphone — seul accès au suivi +}); +export type PortalRequestCreated = z.infer; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2b450db..5701da5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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': {}