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:
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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user