mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 12:41:54 +00:00
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:
@@ -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.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.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.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.
|
- Détail quotidien : `docs/journal/journal.md`. Dépôt : `siop-spelev/siop2` (privé), jalons R0→R5.
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
"@nestjs/core": "^11.1.0",
|
"@nestjs/core": "^11.1.0",
|
||||||
"@nestjs/jwt": "^11.0.0",
|
"@nestjs/jwt": "^11.0.0",
|
||||||
"@nestjs/platform-express": "^11.1.0",
|
"@nestjs/platform-express": "^11.1.0",
|
||||||
|
"@nestjs/throttler": "^6.5.0",
|
||||||
"@prisma/client": "^6.8.0",
|
"@prisma/client": "^6.8.0",
|
||||||
"@siop/shared": "workspace:*",
|
"@siop/shared": "workspace:*",
|
||||||
"argon2": "^0.43.0",
|
"argon2": "^0.43.0",
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Request" ADD COLUMN "publicToken" TEXT;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Request_publicToken_key" ON "Request"("publicToken");
|
||||||
|
|
||||||
@@ -261,6 +261,9 @@ model Request {
|
|||||||
requestedById String? @db.Uuid
|
requestedById String? @db.Uuid
|
||||||
requestedBy User? @relation(fields: [requestedById], references: [id])
|
requestedBy User? @relation(fields: [requestedById], references: [id])
|
||||||
requesterName String? // portail public via QR (R2.4)
|
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
|
workOrderId String? @unique @db.Uuid // lien 1-1 — jamais de doublon
|
||||||
workOrder WorkOrder? @relation(fields: [workOrderId], references: [id])
|
workOrder WorkOrder? @relation(fields: [workOrderId], references: [id])
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { FilesModule } from './files/files.module';
|
|||||||
import { HealthModule } from './health/health.module';
|
import { HealthModule } from './health/health.module';
|
||||||
import { LocationsModule } from './locations/locations.module';
|
import { LocationsModule } from './locations/locations.module';
|
||||||
import { MetersModule } from './meters/meters.module';
|
import { MetersModule } from './meters/meters.module';
|
||||||
|
import { PortalModule } from './portal/portal.module';
|
||||||
import { PreventiveModule } from './preventive/preventive.module';
|
import { PreventiveModule } from './preventive/preventive.module';
|
||||||
import { ReferenceValuesModule } from './reference-values/reference-values.module';
|
import { ReferenceValuesModule } from './reference-values/reference-values.module';
|
||||||
import { RequestsModule } from './requests/requests.module';
|
import { RequestsModule } from './requests/requests.module';
|
||||||
@@ -48,6 +49,7 @@ export class AppModule {
|
|||||||
ReferenceValuesModule,
|
ReferenceValuesModule,
|
||||||
PreventiveModule,
|
PreventiveModule,
|
||||||
MetersModule,
|
MetersModule,
|
||||||
|
PortalModule,
|
||||||
// ADR-002 : hors DEMO_MODE, le module n'est pas enregistré → 404
|
// ADR-002 : hors DEMO_MODE, le module n'est pas enregistré → 404
|
||||||
...(demoModeEnabled() ? [DemoAuthModule] : []),
|
...(demoModeEnabled() ? [DemoAuthModule] : []),
|
||||||
],
|
],
|
||||||
|
|||||||
40
apps/api/src/portal/portal.controller.ts
Normal file
40
apps/api/src/portal/portal.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
11
apps/api/src/portal/portal.module.ts
Normal file
11
apps/api/src/portal/portal.module.ts
Normal 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 {}
|
||||||
106
apps/api/src/portal/portal.service.ts
Normal file
106
apps/api/src/portal/portal.service.ts
Normal 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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
77
apps/web/e2e/parcours-portail.spec.ts
Normal file
77
apps/web/e2e/parcours-portail.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
@@ -17,6 +17,7 @@ import PageNouvelAscenseur from '@/pages/nouvel-ascenseur';
|
|||||||
import PageNouvelOT from '@/pages/nouvel-ot';
|
import PageNouvelOT from '@/pages/nouvel-ot';
|
||||||
import PageOrdresTravail from '@/pages/ordres-travail';
|
import PageOrdresTravail from '@/pages/ordres-travail';
|
||||||
import PagePersonnes from '@/pages/personnes';
|
import PagePersonnes from '@/pages/personnes';
|
||||||
|
import PagePortail from '@/pages/portail';
|
||||||
import PagePreventif from '@/pages/preventif';
|
import PagePreventif from '@/pages/preventif';
|
||||||
import PageSites from '@/pages/sites';
|
import PageSites from '@/pages/sites';
|
||||||
import PageTableauDeBord from '@/pages/tableau-de-bord';
|
import PageTableauDeBord from '@/pages/tableau-de-bord';
|
||||||
@@ -34,6 +35,8 @@ export default function App() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/connexion" element={<PageConnexion />} />
|
<Route path="/connexion" element={<PageConnexion />} />
|
||||||
<Route path="/activation" element={<PageActivation />} />
|
<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="/" element={dansCoquille(<PageTableauDeBord />)} />
|
||||||
<Route path="/design" element={dansCoquille(<PageDesign />)} />
|
<Route path="/design" element={dansCoquille(<PageDesign />)} />
|
||||||
<Route path="/sites" element={dansCoquille(<PageSites />)} />
|
<Route path="/sites" element={dansCoquille(<PageSites />)} />
|
||||||
|
|||||||
184
apps/web/src/api/schema.d.ts
vendored
184
apps/web/src/api/schema.d.ts
vendored
@@ -372,6 +372,57 @@ export interface paths {
|
|||||||
patch: operations["updateUser"];
|
patch: operations["updateUser"];
|
||||||
trace?: never;
|
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": {
|
"/preventive/templates": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1069,6 +1120,42 @@ export interface components {
|
|||||||
teamIds?: string[];
|
teamIds?: string[];
|
||||||
isActive?: boolean;
|
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: {
|
TaskTemplatesResponse: {
|
||||||
templates: {
|
templates: {
|
||||||
/** Format: uuid */
|
/** 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: {
|
listTaskTemplates: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
203
apps/web/src/pages/portail.tsx
Normal file
203
apps/web/src/pages/portail.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -873,6 +873,45 @@ table {
|
|||||||
color: var(--encre); text-align: right;
|
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 ═══ */
|
/* ═══ Page /design : nuanciers ═══ */
|
||||||
.nuancier {
|
.nuancier {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -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
|
## 2026-07-16 — Pr. Daaif (+ Claude) — R2.3 : écrans web de l'exploitation
|
||||||
|
|
||||||
**Actions**
|
**Actions**
|
||||||
|
|||||||
@@ -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": {
|
"/preventive/templates": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "listTaskTemplates",
|
"operationId": "listTaskTemplates",
|
||||||
@@ -3634,6 +3747,171 @@
|
|||||||
},
|
},
|
||||||
"additionalProperties": false
|
"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": {
|
"TaskTemplatesResponse": {
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
|||||||
@@ -44,7 +44,11 @@ for (const op of API_CONTRACT) {
|
|||||||
name,
|
name,
|
||||||
in: 'path',
|
in: 'path',
|
||||||
required: true,
|
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' },
|
||||||
})),
|
})),
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
|
|||||||
@@ -38,6 +38,12 @@ import {
|
|||||||
UsersResponseSchema,
|
UsersResponseSchema,
|
||||||
UserUpdateSchema,
|
UserUpdateSchema,
|
||||||
} from './schemas/users-admin';
|
} from './schemas/users-admin';
|
||||||
|
import {
|
||||||
|
PortalAssetSchema,
|
||||||
|
PortalRequestCreatedSchema,
|
||||||
|
PortalRequestCreateSchema,
|
||||||
|
PortalRequestStatusSchema,
|
||||||
|
} from './schemas/portail';
|
||||||
import {
|
import {
|
||||||
MeterReadingCreateSchema,
|
MeterReadingCreateSchema,
|
||||||
MetersResponseSchema,
|
MetersResponseSchema,
|
||||||
@@ -413,6 +419,52 @@ export const API_CONTRACT: ApiOperation[] = [
|
|||||||
404: { description: 'Inconnue' },
|
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 —————
|
// ————— R2 · Préventif & compteurs —————
|
||||||
{
|
{
|
||||||
operationId: 'listTaskTemplates',
|
operationId: 'listTaskTemplates',
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export * from './referentiel';
|
|||||||
export * from './exploitation';
|
export * from './exploitation';
|
||||||
export * from './schemas/exploitation';
|
export * from './schemas/exploitation';
|
||||||
export * from './schemas/preventif';
|
export * from './schemas/preventif';
|
||||||
|
export * from './schemas/portail';
|
||||||
export * from './schemas/auth';
|
export * from './schemas/auth';
|
||||||
export * from './schemas/users';
|
export * from './schemas/users';
|
||||||
export * from './schemas/users-admin';
|
export * from './schemas/users-admin';
|
||||||
|
|||||||
42
packages/shared/src/schemas/portail.ts
Normal file
42
packages/shared/src/schemas/portail.ts
Normal 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
16
pnpm-lock.yaml
generated
@@ -38,6 +38,9 @@ importers:
|
|||||||
'@nestjs/platform-express':
|
'@nestjs/platform-express':
|
||||||
specifier: ^11.1.0
|
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)
|
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':
|
'@prisma/client':
|
||||||
specifier: ^6.8.0
|
specifier: ^6.8.0
|
||||||
version: 6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3)
|
version: 6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3)
|
||||||
@@ -1161,6 +1164,13 @@ packages:
|
|||||||
'@nestjs/platform-express':
|
'@nestjs/platform-express':
|
||||||
optional: true
|
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':
|
'@noble/hashes@1.8.0':
|
||||||
resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==}
|
resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==}
|
||||||
engines: {node: ^14.21.3 || >=16}
|
engines: {node: ^14.21.3 || >=16}
|
||||||
@@ -5695,6 +5705,12 @@ snapshots:
|
|||||||
optionalDependencies:
|
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/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': {}
|
'@noble/hashes@1.8.0': {}
|
||||||
|
|
||||||
'@nodable/entities@2.2.0': {}
|
'@nodable/entities@2.2.0': {}
|
||||||
|
|||||||
Reference in New Issue
Block a user