diff --git a/backend/scripts/test-register-am.sh b/backend/scripts/test-register-am.sh new file mode 100755 index 0000000..0ae987e --- /dev/null +++ b/backend/scripts/test-register-am.sh @@ -0,0 +1,27 @@ +#!/bin/bash +# Test POST /auth/register/am (ticket #90) +# Usage: ./scripts/test-register-am.sh [BASE_URL] +# Exemple: ./scripts/test-register-am.sh https://app.ptits-pas.fr/api/v1 +# ./scripts/test-register-am.sh http://localhost:3000/api/v1 + +BASE_URL="${1:-http://localhost:3000/api/v1}" +echo "Testing POST $BASE_URL/auth/register/am" +echo "---" + +curl -s -w "\n\nHTTP %{http_code}\n" -X POST "$BASE_URL/auth/register/am" \ + -H "Content-Type: application/json" \ + -d '{ + "email": "marie.dupont.test@ptits-pas.fr", + "prenom": "Marie", + "nom": "DUPONT", + "telephone": "0612345678", + "adresse": "1 rue Test", + "code_postal": "75001", + "ville": "Paris", + "consentement_photo": true, + "nir": "123456789012345", + "numero_agrement": "AGR-2024-001", + "capacite_accueil": 4, + "acceptation_cgu": true, + "acceptation_privacy": true + }' diff --git a/backend/src/routes/auth/auth.controller.ts b/backend/src/routes/auth/auth.controller.ts index 3de3fc1..2eeaf98 100644 --- a/backend/src/routes/auth/auth.controller.ts +++ b/backend/src/routes/auth/auth.controller.ts @@ -3,8 +3,8 @@ import { LoginDto } from './dto/login.dto'; import { AuthService } from './auth.service'; import { Public } from 'src/common/decorators/public.decorator'; import { RegisterDto } from './dto/register.dto'; -import { RegisterParentDto } from './dto/register-parent.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 { AuthGuard } from 'src/common/guards/auth.guard'; @@ -53,12 +53,16 @@ export class AuthController { } @Public() - @Post('register/parent/legacy') - @ApiOperation({ summary: '[OBSOLÈTE] Inscription Parent (étape 1/6 uniquement)' }) - @ApiResponse({ status: 201, description: 'Inscription réussie' }) + @Post('register/am') + @ApiOperation({ + 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é' }) - async registerParentLegacy(@Body() dto: RegisterParentDto) { - return this.authService.registerParent(dto); + async inscrireAMComplet(@Body() dto: RegisterAMCompletDto) { + return this.authService.inscrireAMComplet(dto); } @Public() diff --git a/backend/src/routes/auth/auth.module.ts b/backend/src/routes/auth/auth.module.ts index 6554be7..3d15615 100644 --- a/backend/src/routes/auth/auth.module.ts +++ b/backend/src/routes/auth/auth.module.ts @@ -8,11 +8,12 @@ import { ConfigModule, ConfigService } from '@nestjs/config'; import { Users } from 'src/entities/users.entity'; import { Parents } from 'src/entities/parents.entity'; import { Children } from 'src/entities/children.entity'; +import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity'; import { AppConfigModule } from 'src/modules/config'; @Module({ imports: [ - TypeOrmModule.forFeature([Users, Parents, Children]), + TypeOrmModule.forFeature([Users, Parents, Children, AssistanteMaternelle]), forwardRef(() => UserModule), AppConfigModule, JwtModule.registerAsync({ diff --git a/backend/src/routes/auth/auth.service.ts b/backend/src/routes/auth/auth.service.ts index 1c9985e..1fdb8cd 100644 --- a/backend/src/routes/auth/auth.service.ts +++ b/backend/src/routes/auth/auth.service.ts @@ -13,13 +13,14 @@ import * as crypto from 'crypto'; import * as fs from 'fs/promises'; import * as path from 'path'; import { RegisterDto } from './dto/register.dto'; -import { RegisterParentDto } from './dto/register-parent.dto'; import { RegisterParentCompletDto } from './dto/register-parent-complet.dto'; +import { RegisterAMCompletDto } from './dto/register-am-complet.dto'; import { ConfigService } from '@nestjs/config'; import { RoleType, StatutUtilisateurType, Users } from 'src/entities/users.entity'; import { Parents } from 'src/entities/parents.entity'; 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 { 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 */ 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( - '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 * 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( + '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 */ diff --git a/backend/src/routes/auth/dto/register-am-complet.dto.ts b/backend/src/routes/auth/dto/register-am-complet.dto.ts new file mode 100644 index 0000000..72728ca --- /dev/null +++ b/backend/src/routes/auth/dto/register-am-complet.dto.ts @@ -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; +} diff --git a/backend/src/routes/auth/dto/register-parent.dto.ts b/backend/src/routes/auth/dto/register-parent.dto.ts deleted file mode 100644 index a022724..0000000 --- a/backend/src/routes/auth/dto/register-parent.dto.ts +++ /dev/null @@ -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; -} - diff --git a/database/BDD.sql b/database/BDD.sql index 1e7bc0b..991ce3a 100644 --- a/database/BDD.sql +++ b/database/BDD.sql @@ -80,12 +80,15 @@ CREATE INDEX idx_utilisateurs_token_creation_mdp CREATE TABLE assistantes_maternelles ( id_utilisateur UUID PRIMARY KEY REFERENCES utilisateurs(id) ON DELETE CASCADE, numero_agrement VARCHAR(50), - date_agrement DATE NOT NULL, -- Obligatoire selon CDC v1.3 nir_chiffre CHAR(15), nb_max_enfants INT, - place_disponible INT, biographie TEXT, - disponible BOOLEAN DEFAULT true + disponible BOOLEAN DEFAULT true, + ville_residence VARCHAR(100), + date_agrement DATE, + annee_experience SMALLINT, + specialite VARCHAR(100), + place_disponible INT ); -- ========================================================== diff --git a/frontend/lib/services/api/api_config.dart b/frontend/lib/services/api/api_config.dart index 831e911..774b1d2 100644 --- a/frontend/lib/services/api/api_config.dart +++ b/frontend/lib/services/api/api_config.dart @@ -5,6 +5,8 @@ class ApiConfig { // Auth endpoints static const String login = '/auth/login'; 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 authMe = '/auth/me'; static const String changePasswordRequired = '/auth/change-password-required';