From 86b28abe513d4a55bf7ca2f5245c6aeeaa118627 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Thu, 12 Mar 2026 23:04:45 +0100 Subject: [PATCH] =?UTF-8?q?feat(#111):=20Reprise=20apr=C3=A8s=20refus=20?= =?UTF-8?q?=E2=80=93=20backend?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /auth/reprise-dossier?token= : dossier pour préremplir (token seul) - PATCH /auth/reprise-resoumettre : token + champs modifiables → en_attente, token invalidé - POST /auth/reprise-identify : numero_dossier + email → type + token - UserService: findByTokenReprise, resoumettreReprise, findByNumeroDossierAndEmailForReprise Made-with: Cursor --- backend/src/routes/auth/auth.controller.ts | 36 +++++++++++++- backend/src/routes/auth/auth.service.ts | 46 +++++++++++++++++ .../routes/auth/dto/reprise-dossier.dto.ts | 44 +++++++++++++++++ .../routes/auth/dto/reprise-identify.dto.ts | 23 +++++++++ .../auth/dto/resoumettre-reprise.dto.ts | 49 +++++++++++++++++++ backend/src/routes/user/user.service.ts | 48 +++++++++++++++++- 6 files changed, 243 insertions(+), 3 deletions(-) create mode 100644 backend/src/routes/auth/dto/reprise-dossier.dto.ts create mode 100644 backend/src/routes/auth/dto/reprise-identify.dto.ts create mode 100644 backend/src/routes/auth/dto/resoumettre-reprise.dto.ts diff --git a/backend/src/routes/auth/auth.controller.ts b/backend/src/routes/auth/auth.controller.ts index 2eeaf98..258d853 100644 --- a/backend/src/routes/auth/auth.controller.ts +++ b/backend/src/routes/auth/auth.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Post, Req, UnauthorizedException, BadRequestException, UseGuards } from '@nestjs/common'; +import { Body, Controller, Get, Patch, Post, Query, Req, UnauthorizedException, BadRequestException, UseGuards } from '@nestjs/common'; import { LoginDto } from './dto/login.dto'; import { AuthService } from './auth.service'; import { Public } from 'src/common/decorators/public.decorator'; @@ -6,14 +6,17 @@ import { RegisterDto } from './dto/register.dto'; import { RegisterParentCompletDto } from './dto/register-parent-complet.dto'; import { RegisterAMCompletDto } from './dto/register-am-complet.dto'; import { ChangePasswordRequiredDto } from './dto/change-password.dto'; -import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { ApiBearerAuth, ApiOperation, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger'; import { AuthGuard } from 'src/common/guards/auth.guard'; import type { Request } from 'express'; import { UserService } from '../user/user.service'; import { ProfileResponseDto } from './dto/profile_response.dto'; import { RefreshTokenDto } from './dto/refresh_token.dto'; +import { ResoumettreRepriseDto } from './dto/resoumettre-reprise.dto'; +import { RepriseIdentifyBodyDto } from './dto/reprise-identify.dto'; import { User } from 'src/common/decorators/user.decorator'; import { Users } from 'src/entities/users.entity'; +import { RepriseDossierDto } from './dto/reprise-dossier.dto'; @ApiTags('Authentification') @Controller('auth') @@ -65,6 +68,35 @@ export class AuthController { return this.authService.inscrireAMComplet(dto); } + @Public() + @Get('reprise-dossier') + @ApiOperation({ summary: 'Dossier pour reprise (token seul)' }) + @ApiQuery({ name: 'token', required: true, description: 'Token reprise (lien email)' }) + @ApiResponse({ status: 200, description: 'Données dossier pour préremplir', type: RepriseDossierDto }) + @ApiResponse({ status: 404, description: 'Token invalide ou expiré' }) + async getRepriseDossier(@Query('token') token: string): Promise { + return this.authService.getRepriseDossier(token); + } + + @Public() + @Patch('reprise-resoumettre') + @ApiOperation({ summary: 'Resoumettre le dossier (mise à jour + statut en_attente, invalide le token)' }) + @ApiResponse({ status: 200, description: 'Dossier resoumis' }) + @ApiResponse({ status: 404, description: 'Token invalide ou expiré' }) + async resoumettreReprise(@Body() dto: ResoumettreRepriseDto) { + const { token, ...fields } = dto; + return this.authService.resoumettreReprise(token, fields); + } + + @Public() + @Post('reprise-identify') + @ApiOperation({ summary: 'Modale reprise : numéro + email → type + token' }) + @ApiResponse({ status: 201, description: 'type (parent/AM) + token pour GET reprise-dossier / PUT reprise-resoumettre' }) + @ApiResponse({ status: 404, description: 'Aucun dossier en reprise pour ce numéro et email' }) + async repriseIdentify(@Body() dto: RepriseIdentifyBodyDto) { + return this.authService.identifyReprise(dto.numero_dossier, dto.email); + } + @Public() @Post('refresh') @ApiBearerAuth('refresh_token') diff --git a/backend/src/routes/auth/auth.service.ts b/backend/src/routes/auth/auth.service.ts index edaba73..c6fe021 100644 --- a/backend/src/routes/auth/auth.service.ts +++ b/backend/src/routes/auth/auth.service.ts @@ -1,6 +1,7 @@ import { ConflictException, Injectable, + NotFoundException, UnauthorizedException, BadRequestException, } from '@nestjs/common'; @@ -22,6 +23,8 @@ import { Children, StatutEnfantType } from 'src/entities/children.entity'; import { ParentsChildren } from 'src/entities/parents_children.entity'; import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity'; import { LoginDto } from './dto/login.dto'; +import { RepriseDossierDto } from './dto/reprise-dossier.dto'; +import { RepriseIdentifyResponseDto } from './dto/reprise-identify.dto'; import { AppConfigService } from 'src/modules/config/config.service'; import { validateNir } from 'src/common/utils/nir.util'; import { NumeroDossierService } from 'src/modules/numero-dossier/numero-dossier.service'; @@ -501,4 +504,47 @@ export class AuthService { async logout(userId: string) { return { success: true, message: 'Deconnexion'} } + + /** GET dossier reprise – token seul. Ticket #111 */ + async getRepriseDossier(token: string): Promise { + const user = await this.usersService.findByTokenReprise(token); + if (!user) { + throw new NotFoundException('Token reprise invalide ou expiré.'); + } + return { + id: user.id, + email: user.email, + prenom: user.prenom, + nom: user.nom, + telephone: user.telephone, + adresse: user.adresse, + ville: user.ville, + code_postal: user.code_postal, + numero_dossier: user.numero_dossier, + role: user.role, + photo_url: user.photo_url, + genre: user.genre, + situation_familiale: user.situation_familiale, + }; + } + + /** PUT resoumission reprise. Ticket #111 */ + async resoumettreReprise( + token: string, + dto: { prenom?: string; nom?: string; telephone?: string; adresse?: string; ville?: string; code_postal?: string; photo_url?: string }, + ): Promise { + return this.usersService.resoumettreReprise(token, dto); + } + + /** POST reprise-identify : numero_dossier + email → type + token. Ticket #111 */ + async identifyReprise(numero_dossier: string, email: string): Promise { + const user = await this.usersService.findByNumeroDossierAndEmailForReprise(numero_dossier, email); + if (!user || !user.token_reprise) { + throw new NotFoundException('Aucun dossier en reprise trouvé pour ce numéro et cet email.'); + } + return { + type: user.role === RoleType.PARENT ? 'parent' : 'assistante_maternelle', + token: user.token_reprise, + }; + } } diff --git a/backend/src/routes/auth/dto/reprise-dossier.dto.ts b/backend/src/routes/auth/dto/reprise-dossier.dto.ts new file mode 100644 index 0000000..e81c6e4 --- /dev/null +++ b/backend/src/routes/auth/dto/reprise-dossier.dto.ts @@ -0,0 +1,44 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { RoleType } from 'src/entities/users.entity'; + +/** Réponse GET /auth/reprise-dossier – données dossier pour préremplir le formulaire reprise. Ticket #111 */ +export class RepriseDossierDto { + @ApiProperty() + id: string; + + @ApiProperty() + email: string; + + @ApiProperty({ required: false }) + prenom?: string; + + @ApiProperty({ required: false }) + nom?: string; + + @ApiProperty({ required: false }) + telephone?: string; + + @ApiProperty({ required: false }) + adresse?: string; + + @ApiProperty({ required: false }) + ville?: string; + + @ApiProperty({ required: false }) + code_postal?: string; + + @ApiProperty({ required: false }) + numero_dossier?: string; + + @ApiProperty({ enum: RoleType }) + role: RoleType; + + @ApiProperty({ required: false, description: 'Pour AM' }) + photo_url?: string; + + @ApiProperty({ required: false }) + genre?: string; + + @ApiProperty({ required: false }) + situation_familiale?: string; +} diff --git a/backend/src/routes/auth/dto/reprise-identify.dto.ts b/backend/src/routes/auth/dto/reprise-identify.dto.ts new file mode 100644 index 0000000..f37c483 --- /dev/null +++ b/backend/src/routes/auth/dto/reprise-identify.dto.ts @@ -0,0 +1,23 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsEmail, IsString, MaxLength } from 'class-validator'; + +/** Body POST /auth/reprise-identify – numéro + email pour obtenir token reprise. Ticket #111 */ +export class RepriseIdentifyBodyDto { + @ApiProperty({ example: '2026-000001' }) + @IsString() + @MaxLength(20) + numero_dossier: string; + + @ApiProperty({ example: 'parent@example.com' }) + @IsEmail() + email: string; +} + +/** Réponse POST /auth/reprise-identify */ +export class RepriseIdentifyResponseDto { + @ApiProperty({ enum: ['parent', 'assistante_maternelle'] }) + type: 'parent' | 'assistante_maternelle'; + + @ApiProperty({ description: 'Token à utiliser pour GET reprise-dossier et PUT reprise-resoumettre' }) + token: string; +} diff --git a/backend/src/routes/auth/dto/resoumettre-reprise.dto.ts b/backend/src/routes/auth/dto/resoumettre-reprise.dto.ts new file mode 100644 index 0000000..efec456 --- /dev/null +++ b/backend/src/routes/auth/dto/resoumettre-reprise.dto.ts @@ -0,0 +1,49 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsOptional, IsString, MaxLength, IsUUID } from 'class-validator'; + +/** Body PUT /auth/reprise-resoumettre – token + champs modifiables. Ticket #111 */ +export class ResoumettreRepriseDto { + @ApiProperty({ description: 'Token reprise (reçu par email)' }) + @IsUUID() + token: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + @MaxLength(100) + prenom?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + @MaxLength(100) + nom?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + @MaxLength(20) + telephone?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + adresse?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + @MaxLength(150) + ville?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + @MaxLength(10) + code_postal?: string; + + @ApiProperty({ required: false, description: 'Pour AM' }) + @IsOptional() + @IsString() + photo_url?: string; +} diff --git a/backend/src/routes/user/user.service.ts b/backend/src/routes/user/user.service.ts index 83b6abb..7e44081 100644 --- a/backend/src/routes/user/user.service.ts +++ b/backend/src/routes/user/user.service.ts @@ -1,7 +1,7 @@ import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; import { RoleType, StatutUtilisateurType, Users } from "src/entities/users.entity"; -import { In, Repository } from "typeorm"; +import { In, MoreThan, Repository } from "typeorm"; import { CreateUserDto } from "./dto/create_user.dto"; import { CreateAdminDto } from "./dto/create_admin.dto"; import { UpdateUserDto } from "./dto/update_user.dto"; @@ -392,6 +392,52 @@ export class UserService { return savedUser; } + /** Trouve un user par token reprise valide (non expiré). Ticket #111 */ + async findByTokenReprise(token: string): Promise { + return this.usersRepository.findOne({ + where: { + token_reprise: token, + statut: StatutUtilisateurType.REFUSE, + token_reprise_expire_le: MoreThan(new Date()), + }, + }); + } + + /** Resoumission reprise : met à jour les champs autorisés, passe en en_attente, invalide le token. Ticket #111 */ + async resoumettreReprise( + token: string, + dto: { prenom?: string; nom?: string; telephone?: string; adresse?: string; ville?: string; code_postal?: string; photo_url?: string }, + ): Promise { + const user = await this.findByTokenReprise(token); + if (!user) { + throw new NotFoundException('Token reprise invalide ou expiré.'); + } + if (dto.prenom !== undefined) user.prenom = dto.prenom; + if (dto.nom !== undefined) user.nom = dto.nom; + if (dto.telephone !== undefined) user.telephone = dto.telephone; + if (dto.adresse !== undefined) user.adresse = dto.adresse; + if (dto.ville !== undefined) user.ville = dto.ville; + if (dto.code_postal !== undefined) user.code_postal = dto.code_postal; + if (dto.photo_url !== undefined) user.photo_url = dto.photo_url; + user.statut = StatutUtilisateurType.EN_ATTENTE; + user.token_reprise = undefined; + user.token_reprise_expire_le = undefined; + return this.usersRepository.save(user); + } + + /** Pour modale reprise : numero_dossier + email → user en REFUSE avec token valide. Ticket #111 */ + async findByNumeroDossierAndEmailForReprise(numero_dossier: string, email: string): Promise { + const user = await this.usersRepository.findOne({ + where: { + email: email.trim().toLowerCase(), + numero_dossier: numero_dossier.trim(), + statut: StatutUtilisateurType.REFUSE, + token_reprise_expire_le: MoreThan(new Date()), + }, + }); + return user ?? null; + } + async remove(id: string, currentUser: Users): Promise { if (currentUser.role !== RoleType.SUPER_ADMIN) { throw new ForbiddenException('Accès réservé aux super admins');