feat(#90): API Inscription AM - POST /auth/register/am

- DTO RegisterAMCompletDto (identité, photo, infos pro, CGU)
- Endpoint POST /auth/register/am + inscrireAMComplet() (transaction User + AssistanteMaternelle)
- Photo base64, token création MDP, consentement photo
- Suppression legacy: route register/parent/legacy, registerParent(), RegisterParentDto
- Frontend: ApiConfig.registerAM pour ticket #91

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
MARTIN Julien 2026-02-16 00:05:23 +01:00
parent 111935e451
commit c94f2cf0d5
6 changed files with 249 additions and 233 deletions

View File

@ -3,8 +3,8 @@ import { LoginDto } from './dto/login.dto';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { Public } from 'src/common/decorators/public.decorator'; import { Public } from 'src/common/decorators/public.decorator';
import { RegisterDto } from './dto/register.dto'; import { RegisterDto } from './dto/register.dto';
import { RegisterParentDto } from './dto/register-parent.dto';
import { RegisterParentCompletDto } from './dto/register-parent-complet.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 { ChangePasswordRequiredDto } from './dto/change-password.dto';
import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { AuthGuard } from 'src/common/guards/auth.guard'; import { AuthGuard } from 'src/common/guards/auth.guard';
@ -53,12 +53,16 @@ export class AuthController {
} }
@Public() @Public()
@Post('register/parent/legacy') @Post('register/am')
@ApiOperation({ summary: '[OBSOLÈTE] Inscription Parent (étape 1/6 uniquement)' }) @ApiOperation({
@ApiResponse({ status: 201, description: 'Inscription réussie' }) summary: 'Inscription Assistante Maternelle COMPLÈTE',
description: 'Crée User AM + entrée assistantes_maternelles (identité + infos pro + photo + CGU) en une transaction',
})
@ApiResponse({ status: 201, description: 'Inscription réussie - Dossier en attente de validation' })
@ApiResponse({ status: 400, description: 'Données invalides ou CGU non acceptées' })
@ApiResponse({ status: 409, description: 'Email déjà utilisé' }) @ApiResponse({ status: 409, description: 'Email déjà utilisé' })
async registerParentLegacy(@Body() dto: RegisterParentDto) { async inscrireAMComplet(@Body() dto: RegisterAMCompletDto) {
return this.authService.registerParent(dto); return this.authService.inscrireAMComplet(dto);
} }
@Public() @Public()

View File

@ -8,11 +8,12 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
import { Users } from 'src/entities/users.entity'; import { Users } from 'src/entities/users.entity';
import { Parents } from 'src/entities/parents.entity'; import { Parents } from 'src/entities/parents.entity';
import { Children } from 'src/entities/children.entity'; import { Children } from 'src/entities/children.entity';
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
import { AppConfigModule } from 'src/modules/config'; import { AppConfigModule } from 'src/modules/config';
@Module({ @Module({
imports: [ imports: [
TypeOrmModule.forFeature([Users, Parents, Children]), TypeOrmModule.forFeature([Users, Parents, Children, AssistanteMaternelle]),
forwardRef(() => UserModule), forwardRef(() => UserModule),
AppConfigModule, AppConfigModule,
JwtModule.registerAsync({ JwtModule.registerAsync({

View File

@ -13,13 +13,14 @@ import * as crypto from 'crypto';
import * as fs from 'fs/promises'; import * as fs from 'fs/promises';
import * as path from 'path'; import * as path from 'path';
import { RegisterDto } from './dto/register.dto'; import { RegisterDto } from './dto/register.dto';
import { RegisterParentDto } from './dto/register-parent.dto';
import { RegisterParentCompletDto } from './dto/register-parent-complet.dto'; import { RegisterParentCompletDto } from './dto/register-parent-complet.dto';
import { RegisterAMCompletDto } from './dto/register-am-complet.dto';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { RoleType, StatutUtilisateurType, Users } from 'src/entities/users.entity'; import { RoleType, StatutUtilisateurType, Users } from 'src/entities/users.entity';
import { Parents } from 'src/entities/parents.entity'; import { Parents } from 'src/entities/parents.entity';
import { Children, StatutEnfantType } from 'src/entities/children.entity'; import { Children, StatutEnfantType } from 'src/entities/children.entity';
import { ParentsChildren } from 'src/entities/parents_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 { LoginDto } from './dto/login.dto';
import { AppConfigService } from 'src/modules/config/config.service'; import { AppConfigService } from 'src/modules/config/config.service';
@ -116,7 +117,7 @@ export class AuthService {
} }
/** /**
* Inscription utilisateur OBSOLÈTE - Utiliser registerParent() ou registerAM() * Inscription utilisateur OBSOLÈTE - Utiliser inscrireParentComplet() ou registerAM()
* @deprecated * @deprecated
*/ */
async register(registerDto: RegisterDto) { async register(registerDto: RegisterDto) {
@ -157,125 +158,6 @@ export class AuthService {
}; };
} }
/**
* Inscription Parent (étape 1/6 du workflow CDC)
* SANS mot de passe - Token de création MDP généré
*/
async registerParent(dto: RegisterParentDto) {
// 1. Vérifier que l'email n'existe pas
const exists = await this.usersService.findByEmailOrNull(dto.email);
if (exists) {
throw new ConflictException('Un compte avec cet email existe déjà');
}
// 2. Vérifier l'email du co-parent s'il existe
if (dto.co_parent_email) {
const coParentExists = await this.usersService.findByEmailOrNull(dto.co_parent_email);
if (coParentExists) {
throw new ConflictException('L\'email du co-parent est déjà utilisé');
}
}
// 3. Récupérer la durée d'expiration du token depuis la config
const tokenExpiryDays = await this.appConfigService.get<number>(
'password_reset_token_expiry_days',
7,
);
// 4. Générer les tokens de création de mot de passe
const tokenCreationMdp = crypto.randomUUID();
const tokenExpiration = new Date();
tokenExpiration.setDate(tokenExpiration.getDate() + tokenExpiryDays);
// 5. Transaction : Créer Parent 1 + Parent 2 (si existe) + entités parents
const result = await this.usersRepo.manager.transaction(async (manager) => {
// Créer Parent 1
const parent1 = manager.create(Users, {
email: dto.email,
prenom: dto.prenom,
nom: dto.nom,
role: RoleType.PARENT,
statut: StatutUtilisateurType.EN_ATTENTE,
telephone: dto.telephone,
adresse: dto.adresse,
code_postal: dto.code_postal,
ville: dto.ville,
token_creation_mdp: tokenCreationMdp,
token_creation_mdp_expire_le: tokenExpiration,
});
const savedParent1 = await manager.save(Users, parent1);
// Créer Parent 2 si renseigné
let savedParent2: Users | null = null;
let tokenCoParent: string | null = null;
if (dto.co_parent_email && dto.co_parent_prenom && dto.co_parent_nom) {
tokenCoParent = crypto.randomUUID();
const tokenExpirationCoParent = new Date();
tokenExpirationCoParent.setDate(tokenExpirationCoParent.getDate() + tokenExpiryDays);
const parent2 = manager.create(Users, {
email: dto.co_parent_email,
prenom: dto.co_parent_prenom,
nom: dto.co_parent_nom,
role: RoleType.PARENT,
statut: StatutUtilisateurType.EN_ATTENTE,
telephone: dto.co_parent_telephone,
adresse: dto.co_parent_meme_adresse ? dto.adresse : dto.co_parent_adresse,
code_postal: dto.co_parent_meme_adresse ? dto.code_postal : dto.co_parent_code_postal,
ville: dto.co_parent_meme_adresse ? dto.ville : dto.co_parent_ville,
token_creation_mdp: tokenCoParent,
token_creation_mdp_expire_le: tokenExpirationCoParent,
});
savedParent2 = await manager.save(Users, parent2);
}
// Créer l'entité métier Parents pour Parent 1
const parentEntity = manager.create(Parents, {
user_id: savedParent1.id,
});
parentEntity.user = savedParent1;
if (savedParent2) {
parentEntity.co_parent = savedParent2;
}
await manager.save(Parents, parentEntity);
// Créer l'entité métier Parents pour Parent 2 (si existe)
if (savedParent2) {
const coParentEntity = manager.create(Parents, {
user_id: savedParent2.id,
});
coParentEntity.user = savedParent2;
coParentEntity.co_parent = savedParent1;
await manager.save(Parents, coParentEntity);
}
return {
parent1: savedParent1,
parent2: savedParent2,
tokenCreationMdp,
tokenCoParent,
};
});
// 6. TODO: Envoyer email avec lien de création de MDP
// await this.mailService.sendPasswordCreationEmail(result.parent1, result.tokenCreationMdp);
// if (result.parent2 && result.tokenCoParent) {
// await this.mailService.sendPasswordCreationEmail(result.parent2, result.tokenCoParent);
// }
return {
message: 'Inscription réussie. Un email de validation vous a été envoyé.',
parent_id: result.parent1.id,
co_parent_id: result.parent2?.id,
statut: StatutUtilisateurType.EN_ATTENTE,
};
}
/** /**
* Inscription Parent COMPLÈTE - Workflow CDC 6 étapes en 1 transaction * Inscription Parent COMPLÈTE - Workflow CDC 6 étapes en 1 transaction
* Gère : Parent 1 + Parent 2 (opt) + Enfants + Présentation + CGU * Gère : Parent 1 + Parent 2 (opt) + Enfants + Présentation + CGU
@ -432,6 +314,82 @@ export class AuthService {
}; };
} }
/**
* Inscription Assistante Maternelle COMPLÈTE - Un seul endpoint (identité + pro + photo + CGU)
* Crée User (role AM) + entrée assistantes_maternelles, token création MDP
*/
async inscrireAMComplet(dto: RegisterAMCompletDto) {
if (!dto.acceptation_cgu || !dto.acceptation_privacy) {
throw new BadRequestException(
"L'acceptation des CGU et de la politique de confidentialité est obligatoire",
);
}
const existe = await this.usersService.findByEmailOrNull(dto.email);
if (existe) {
throw new ConflictException('Un compte avec cet email existe déjà');
}
const joursExpirationToken = await this.appConfigService.get<number>(
'password_reset_token_expiry_days',
7,
);
const tokenCreationMdp = crypto.randomUUID();
const dateExpiration = new Date();
dateExpiration.setDate(dateExpiration.getDate() + joursExpirationToken);
let urlPhoto: string | null = null;
if (dto.photo_base64 && dto.photo_filename) {
urlPhoto = await this.sauvegarderPhotoDepuisBase64(dto.photo_base64, dto.photo_filename);
}
const dateConsentementPhoto =
dto.consentement_photo ? new Date() : undefined;
const resultat = await this.usersRepo.manager.transaction(async (manager) => {
const user = manager.create(Users, {
email: dto.email,
prenom: dto.prenom,
nom: dto.nom,
role: RoleType.ASSISTANTE_MATERNELLE,
statut: StatutUtilisateurType.EN_ATTENTE,
telephone: dto.telephone,
adresse: dto.adresse,
code_postal: dto.code_postal,
ville: dto.ville,
token_creation_mdp: tokenCreationMdp,
token_creation_mdp_expire_le: dateExpiration,
photo_url: urlPhoto ?? undefined,
consentement_photo: dto.consentement_photo,
date_consentement_photo: dateConsentementPhoto,
date_naissance: dto.date_naissance ? new Date(dto.date_naissance) : undefined,
});
const userEnregistre = await manager.save(Users, user);
const amRepo = manager.getRepository(AssistanteMaternelle);
const am = amRepo.create({
user_id: userEnregistre.id,
approval_number: dto.numero_agrement,
nir: dto.nir,
max_children: dto.capacite_accueil,
biography: dto.biographie,
residence_city: dto.ville ?? undefined,
agreement_date: dto.date_agrement ? new Date(dto.date_agrement) : undefined,
available: true,
});
await amRepo.save(am);
return { user: userEnregistre };
});
return {
message:
'Inscription réussie. Votre dossier est en attente de validation par un gestionnaire.',
user_id: resultat.user.id,
statut: StatutUtilisateurType.EN_ATTENTE,
};
}
/** /**
* Sauvegarde une photo depuis base64 vers le système de fichiers * Sauvegarde une photo depuis base64 vers le système de fichiers
*/ */

View File

@ -0,0 +1,156 @@
import { ApiProperty } from '@nestjs/swagger';
import {
IsEmail,
IsNotEmpty,
IsOptional,
IsString,
IsBoolean,
IsInt,
Min,
Max,
MinLength,
MaxLength,
Matches,
IsDateString,
} from 'class-validator';
export class RegisterAMCompletDto {
// ============================================
// ÉTAPE 1 : IDENTITÉ (Obligatoire)
// ============================================
@ApiProperty({ example: 'marie.dupont@ptits-pas.fr' })
@IsEmail({}, { message: 'Email invalide' })
@IsNotEmpty({ message: "L'email est requis" })
email: string;
@ApiProperty({ example: 'Marie' })
@IsString()
@IsNotEmpty({ message: 'Le prénom est requis' })
@MinLength(2, { message: 'Le prénom doit contenir au moins 2 caractères' })
@MaxLength(100)
prenom: string;
@ApiProperty({ example: 'DUPONT' })
@IsString()
@IsNotEmpty({ message: 'Le nom est requis' })
@MinLength(2, { message: 'Le nom doit contenir au moins 2 caractères' })
@MaxLength(100)
nom: string;
@ApiProperty({ example: '0689567890' })
@IsString()
@IsNotEmpty({ message: 'Le téléphone est requis' })
@Matches(/^(\+33|0)[1-9](\d{2}){4}$/, {
message: 'Le numéro de téléphone doit être valide (ex: 0689567890 ou +33689567890)',
})
telephone: string;
@ApiProperty({ example: '5 Avenue du Général de Gaulle', required: false })
@IsOptional()
@IsString()
adresse?: string;
@ApiProperty({ example: '95870', required: false })
@IsOptional()
@IsString()
@MaxLength(10)
code_postal?: string;
@ApiProperty({ example: 'Bezons', required: false })
@IsOptional()
@IsString()
@MaxLength(150)
ville?: string;
// ============================================
// ÉTAPE 2 : PHOTO + INFOS PRO
// ============================================
@ApiProperty({
example: 'data:image/jpeg;base64,/9j/4AAQ...',
required: false,
description: 'Photo de profil en base64',
})
@IsOptional()
@IsString()
photo_base64?: string;
@ApiProperty({ example: 'photo_profil.jpg', required: false })
@IsOptional()
@IsString()
photo_filename?: string;
@ApiProperty({ example: true, description: 'Consentement utilisation photo' })
@IsBoolean()
@IsNotEmpty({ message: 'Le consentement photo est requis' })
consentement_photo: boolean;
@ApiProperty({ example: '2024-01-15', required: false, description: 'Date de naissance' })
@IsOptional()
@IsDateString()
date_naissance?: string;
@ApiProperty({ example: 'Paris', required: false, description: 'Ville de naissance' })
@IsOptional()
@IsString()
@MaxLength(100)
lieu_naissance_ville?: string;
@ApiProperty({ example: 'France', required: false, description: 'Pays de naissance' })
@IsOptional()
@IsString()
@MaxLength(100)
lieu_naissance_pays?: string;
@ApiProperty({ example: '123456789012345', description: 'NIR 15 chiffres' })
@IsString()
@IsNotEmpty({ message: 'Le NIR est requis' })
@Matches(/^\d{15}$/, { message: 'Le NIR doit contenir exactement 15 chiffres' })
nir: string;
@ApiProperty({ example: 'AGR-2024-12345', description: "Numéro d'agrément" })
@IsString()
@IsNotEmpty({ message: "Le numéro d'agrément est requis" })
@MaxLength(50)
numero_agrement: string;
@ApiProperty({ example: '2024-06-01', required: false, description: "Date d'obtention de l'agrément" })
@IsOptional()
@IsDateString()
date_agrement?: string;
@ApiProperty({ example: 4, description: 'Capacité d\'accueil (nombre d\'enfants)', minimum: 1, maximum: 10 })
@IsInt()
@Min(1, { message: 'La capacité doit être au moins 1' })
@Max(10, { message: 'La capacité ne peut pas dépasser 10' })
capacite_accueil: number;
// ============================================
// ÉTAPE 3 : PRÉSENTATION (Optionnel)
// ============================================
@ApiProperty({
example: 'Assistante maternelle expérimentée, accueil bienveillant...',
required: false,
description: 'Présentation / biographie (max 2000 caractères)',
})
@IsOptional()
@IsString()
@MaxLength(2000, { message: 'La présentation ne peut pas dépasser 2000 caractères' })
biographie?: string;
// ============================================
// ÉTAPE 4 : ACCEPTATION CGU (Obligatoire)
// ============================================
@ApiProperty({ example: true, description: "Acceptation des CGU" })
@IsBoolean()
@IsNotEmpty({ message: "L'acceptation des CGU est requise" })
acceptation_cgu: boolean;
@ApiProperty({ example: true, description: 'Acceptation de la Politique de confidentialité' })
@IsBoolean()
@IsNotEmpty({ message: "L'acceptation de la politique de confidentialité est requise" })
acceptation_privacy: boolean;
}

View File

@ -1,105 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import {
IsEmail,
IsNotEmpty,
IsOptional,
IsString,
IsDateString,
IsEnum,
MinLength,
MaxLength,
Matches,
} from 'class-validator';
import { SituationFamilialeType } from 'src/entities/users.entity';
export class RegisterParentDto {
// === Informations obligatoires ===
@ApiProperty({ example: 'claire.martin@ptits-pas.fr' })
@IsEmail({}, { message: 'Email invalide' })
@IsNotEmpty({ message: 'L\'email est requis' })
email: string;
@ApiProperty({ example: 'Claire' })
@IsString()
@IsNotEmpty({ message: 'Le prénom est requis' })
@MinLength(2, { message: 'Le prénom doit contenir au moins 2 caractères' })
@MaxLength(100, { message: 'Le prénom ne peut pas dépasser 100 caractères' })
prenom: string;
@ApiProperty({ example: 'MARTIN' })
@IsString()
@IsNotEmpty({ message: 'Le nom est requis' })
@MinLength(2, { message: 'Le nom doit contenir au moins 2 caractères' })
@MaxLength(100, { message: 'Le nom ne peut pas dépasser 100 caractères' })
nom: string;
@ApiProperty({ example: '0689567890' })
@IsString()
@IsNotEmpty({ message: 'Le téléphone est requis' })
@Matches(/^(\+33|0)[1-9](\d{2}){4}$/, {
message: 'Le numéro de téléphone doit être valide (ex: 0689567890 ou +33689567890)',
})
telephone: string;
// === Informations optionnelles ===
@ApiProperty({ example: '5 Avenue du Général de Gaulle', required: false })
@IsOptional()
@IsString()
adresse?: string;
@ApiProperty({ example: '95870', required: false })
@IsOptional()
@IsString()
@MaxLength(10)
code_postal?: string;
@ApiProperty({ example: 'Bezons', required: false })
@IsOptional()
@IsString()
@MaxLength(150)
ville?: string;
// === Informations co-parent (optionnel) ===
@ApiProperty({ example: 'thomas.martin@ptits-pas.fr', required: false })
@IsOptional()
@IsEmail({}, { message: 'Email du co-parent invalide' })
co_parent_email?: string;
@ApiProperty({ example: 'Thomas', required: false })
@IsOptional()
@IsString()
co_parent_prenom?: string;
@ApiProperty({ example: 'MARTIN', required: false })
@IsOptional()
@IsString()
co_parent_nom?: string;
@ApiProperty({ example: '0612345678', required: false })
@IsOptional()
@IsString()
@Matches(/^(\+33|0)[1-9](\d{2}){4}$/, {
message: 'Le numéro de téléphone du co-parent doit être valide',
})
co_parent_telephone?: string;
@ApiProperty({ example: 'true', description: 'Le co-parent habite à la même adresse', required: false })
@IsOptional()
co_parent_meme_adresse?: boolean;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
co_parent_adresse?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
co_parent_code_postal?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
co_parent_ville?: string;
}

View File

@ -5,6 +5,8 @@ class ApiConfig {
// Auth endpoints // Auth endpoints
static const String login = '/auth/login'; static const String login = '/auth/login';
static const String register = '/auth/register'; static const String register = '/auth/register';
static const String registerParent = '/auth/register/parent';
static const String registerAM = '/auth/register/am';
static const String refreshToken = '/auth/refresh'; static const String refreshToken = '/auth/refresh';
static const String authMe = '/auth/me'; static const String authMe = '/auth/me';
static const String changePasswordRequired = '/auth/change-password-required'; static const String changePasswordRequired = '/auth/change-password-required';