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.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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
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())
|
||||
|
||||
@@ -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] : []),
|
||||
],
|
||||
|
||||
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 PageOrdresTravail from '@/pages/ordres-travail';
|
||||
import PagePersonnes from '@/pages/personnes';
|
||||
import PagePortail from '@/pages/portail';
|
||||
import PagePreventif from '@/pages/preventif';
|
||||
import PageSites from '@/pages/sites';
|
||||
import PageTableauDeBord from '@/pages/tableau-de-bord';
|
||||
@@ -34,6 +35,8 @@ export default function App() {
|
||||
<Routes>
|
||||
<Route path="/connexion" element={<PageConnexion />} />
|
||||
<Route path="/activation" element={<PageActivation />} />
|
||||
{/* Portail public — cible du QR cabine, AUCUN compte */}
|
||||
<Route path="/q/:reference" element={<PagePortail />} />
|
||||
<Route path="/" element={dansCoquille(<PageTableauDeBord />)} />
|
||||
<Route path="/design" element={dansCoquille(<PageDesign />)} />
|
||||
<Route path="/sites" element={dansCoquille(<PageSites />)} />
|
||||
|
||||
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"];
|
||||
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;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/* ═══ 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;
|
||||
|
||||
@@ -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**
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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' },
|
||||
})),
|
||||
}
|
||||
: {}),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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';
|
||||
|
||||
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':
|
||||
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': {}
|
||||
|
||||
Reference in New Issue
Block a user