From 11aa66feff138a693c07633900f398d3f4729aae Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Fri, 13 Feb 2026 15:26:06 +0100 Subject: [PATCH 1/3] Merge squash develop into master (incl. #89 log API requests debug) Co-authored-by: Cursor --- backend/.env.example | 3 + .../interceptors/log-request.interceptor.ts | 69 +++++++++++++++++++ backend/src/main.ts | 11 +-- 3 files changed, 78 insertions(+), 5 deletions(-) create mode 100644 backend/src/common/interceptors/log-request.interceptor.ts diff --git a/backend/.env.example b/backend/.env.example index 002d786..67081bd 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -21,3 +21,6 @@ JWT_EXPIRATION_TIME=7d # Environnement NODE_ENV=development + +# Log de chaque appel API (mode debug) — mettre à true pour tracer les requêtes front +# LOG_API_REQUESTS=true diff --git a/backend/src/common/interceptors/log-request.interceptor.ts b/backend/src/common/interceptors/log-request.interceptor.ts new file mode 100644 index 0000000..bfb87e0 --- /dev/null +++ b/backend/src/common/interceptors/log-request.interceptor.ts @@ -0,0 +1,69 @@ +import { + CallHandler, + ExecutionContext, + Injectable, + NestInterceptor, +} from '@nestjs/common'; +import { Observable } from 'rxjs'; +import { tap } from 'rxjs/operators'; +import { Request } from 'express'; + +/** Clés à masquer dans les logs (corps de requête) */ +const SENSITIVE_KEYS = [ + 'password', + 'smtp_password', + 'token', + 'accessToken', + 'refreshToken', + 'secret', +]; + +function maskBody(body: unknown): unknown { + if (body === null || body === undefined) return body; + if (typeof body !== 'object') return body; + const out: Record = {}; + for (const [key, value] of Object.entries(body)) { + const lower = key.toLowerCase(); + const isSensitive = SENSITIVE_KEYS.some((s) => lower.includes(s)); + out[key] = isSensitive ? '***' : value; + } + return out; +} + +@Injectable() +export class LogRequestInterceptor implements NestInterceptor { + private readonly enabled: boolean; + + constructor() { + this.enabled = + process.env.LOG_API_REQUESTS === 'true' || + process.env.LOG_API_REQUESTS === '1'; + } + + intercept(context: ExecutionContext, next: CallHandler): Observable { + if (!this.enabled) return next.handle(); + + const http = context.switchToHttp(); + const req = http.getRequest(); + const { method, url, body, query } = req; + const hasBody = body && Object.keys(body).length > 0; + + const logLine = [ + `[API] ${method} ${url}`, + Object.keys(query || {}).length ? `query=${JSON.stringify(query)}` : '', + hasBody ? `body=${JSON.stringify(maskBody(body))}` : '', + ] + .filter(Boolean) + .join(' '); + + console.log(logLine); + + return next.handle().pipe( + tap({ + next: () => { + // Optionnel: log du statut en fin de requête (si besoin plus tard) + }, + }), + ); + } +} diff --git a/backend/src/main.ts b/backend/src/main.ts index 3943463..6c62287 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -1,17 +1,18 @@ -import { NestFactory, Reflector } from '@nestjs/core'; +import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; import { ConfigService } from '@nestjs/config'; import { SwaggerModule } from '@nestjs/swagger/dist/swagger-module'; import { DocumentBuilder } from '@nestjs/swagger'; -import { AuthGuard } from './common/guards/auth.guard'; -import { JwtService } from '@nestjs/jwt'; -import { RolesGuard } from './common/guards/roles.guard'; import { ValidationPipe } from '@nestjs/common'; +import { LogRequestInterceptor } from './common/interceptors/log-request.interceptor'; async function bootstrap() { const app = await NestFactory.create(AppModule, { logger: ['error', 'warn', 'log', 'debug', 'verbose'] }); - + + // Log de chaque appel API si LOG_API_REQUESTS=true (mode debug) + app.useGlobalInterceptors(new LogRequestInterceptor()); + // Configuration CORS pour autoriser les requêtes depuis localhost (dev) et production app.enableCors({ origin: true, // Autorise toutes les origines (dev) - à restreindre en prod From dfe7daed14000bb488ac21fda76b4211935eb127 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Sun, 15 Feb 2026 23:20:15 +0100 Subject: [PATCH 2/3] =?UTF-8?q?Merge=20squash=20develop=20into=20master=20?= =?UTF-8?q?(incl.=20#14=20premi=C3=A8re=20config=20setup/complete)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../src/modules/config/config.controller.ts | 3 +- backend/src/modules/config/config.service.ts | 6 +-- docs/14_NOTE-BACKEND-CONFIG-SETUP.md | 37 ++++++++++++++ frontend/lib/config/env.dart | 2 +- .../admin_dashboardScreen.dart | 35 +++++++++++-- .../lib/services/configuration_service.dart | 45 ++++++++++------- .../lib/widgets/admin/dashboard_admin.dart | 50 +++++++++++-------- .../lib/widgets/admin/parametres_panel.dart | 23 +++++++-- 8 files changed, 150 insertions(+), 51 deletions(-) create mode 100644 docs/14_NOTE-BACKEND-CONFIG-SETUP.md diff --git a/backend/src/modules/config/config.controller.ts b/backend/src/modules/config/config.controller.ts index ee1c9ba..701bb48 100644 --- a/backend/src/modules/config/config.controller.ts +++ b/backend/src/modules/config/config.controller.ts @@ -53,8 +53,7 @@ export class ConfigController { // @Roles('super_admin') async completeSetup(@Request() req: any) { try { - // TODO: Récupérer l'ID utilisateur depuis le JWT - const userId = req.user?.id || 'system'; + const userId = req.user?.id ?? null; await this.configService.markSetupCompleted(userId); diff --git a/backend/src/modules/config/config.service.ts b/backend/src/modules/config/config.service.ts index 421ccae..973546b 100644 --- a/backend/src/modules/config/config.service.ts +++ b/backend/src/modules/config/config.service.ts @@ -259,10 +259,10 @@ export class AppConfigService implements OnModuleInit { /** * Marquer la configuration initiale comme terminée - * @param userId ID de l'utilisateur qui termine la configuration + * @param userId ID de l'utilisateur qui termine la configuration (null si non authentifié) */ - async markSetupCompleted(userId: string): Promise { - await this.set('setup_completed', 'true', userId); + async markSetupCompleted(userId: string | null): Promise { + await this.set('setup_completed', 'true', userId ?? undefined); this.logger.log('✅ Configuration initiale marquée comme terminée'); } diff --git a/docs/14_NOTE-BACKEND-CONFIG-SETUP.md b/docs/14_NOTE-BACKEND-CONFIG-SETUP.md new file mode 100644 index 0000000..f1716d9 --- /dev/null +++ b/docs/14_NOTE-BACKEND-CONFIG-SETUP.md @@ -0,0 +1,37 @@ +# Ticket #14 – Note pour modifications backend + +**Contexte :** Première connexion admin → panneau Paramètres, déblocage après clic sur « Sauvegarder ». Le front appelle `POST /api/v1/configuration/setup/complete` au clic sur Sauvegarder. + +## Problème + +Erreur renvoyée par le back : +`invalid input syntax for type uuid: "system"` + +- Le controller fait `const userId = req.user?.id || 'system'` puis `markSetupCompleted(userId)`. +- Le service `set()` fait `config.modifiePar = { id: userId }` ; la colonne `modifie_par` est une FK UUID vers `users`. +- La chaîne `"system"` n’est pas un UUID valide → erreur PostgreSQL. + +## Modifications à apporter au backend + +**Option A – Accepter l’absence d’utilisateur (recommandé si la route peut être appelée sans JWT)** + +1. **`config.controller.ts`** (route `completeSetup`) + - Remplacer : + `const userId = req.user?.id || 'system';` + - Par : + `const userId = req.user?.id ?? null;` + +2. **`config.service.ts`** (`markSetupCompleted`) + - Changer la signature : + `async markSetupCompleted(userId: string | null): Promise` + - Et appeler : + `await this.set('setup_completed', 'true', userId ?? undefined);` + - Dans `set()`, ne pas remplir `modifiePar` quand `userId` est absent (déjà le cas si `if (userId)`). + +**Option B – Imposer un utilisateur authentifié** + +- Activer le guard JWT (et éventuellement RolesGuard) sur `POST /configuration/setup/complete` pour que `req.user` soit toujours défini, et garder `userId = req.user.id` (plus de fallback `'system'`). + +--- + +Une fois le back modifié, le flux « Sauvegarder » → déblocage des panneaux fonctionne sans erreur. diff --git a/frontend/lib/config/env.dart b/frontend/lib/config/env.dart index 34e1cbd..fb9c0e1 100644 --- a/frontend/lib/config/env.dart +++ b/frontend/lib/config/env.dart @@ -6,7 +6,7 @@ class Env { ); // Construit une URL vers l'API v1 à partir d'un chemin (commençant par '/') - static String apiV1(String path) => "${apiBaseUrl}/api/v1$path"; + static String apiV1(String path) => '$apiBaseUrl/api/v1$path'; } diff --git a/frontend/lib/screens/administrateurs/admin_dashboardScreen.dart b/frontend/lib/screens/administrateurs/admin_dashboardScreen.dart index be288bd..1868c9d 100644 --- a/frontend/lib/screens/administrateurs/admin_dashboardScreen.dart +++ b/frontend/lib/screens/administrateurs/admin_dashboardScreen.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:p_tits_pas/services/configuration_service.dart'; import 'package:p_tits_pas/widgets/admin/assistante_maternelle_management_widget.dart'; import 'package:p_tits_pas/widgets/admin/gestionnaire_management_widget.dart'; import 'package:p_tits_pas/widgets/admin/parent_managmant_widget.dart'; @@ -14,12 +15,32 @@ class AdminDashboardScreen extends StatefulWidget { } class _AdminDashboardScreenState extends State { - /// 0 = Gestion des utilisateurs, 1 = Paramètres + bool? _setupCompleted; int mainTabIndex = 0; - - /// Sous-onglet quand mainTabIndex == 0 : 0=Gestionnaires, 1=Parents, 2=AM, 3=Administrateurs int subIndex = 0; + @override + void initState() { + super.initState(); + _loadSetupStatus(); + } + + Future _loadSetupStatus() async { + try { + final completed = await ConfigurationService.getSetupStatus(); + if (!mounted) return; + setState(() { + _setupCompleted = completed; + if (!completed) mainTabIndex = 1; + }); + } catch (e) { + if (mounted) setState(() { + _setupCompleted = false; + mainTabIndex = 1; + }); + } + } + void onMainTabChange(int index) { setState(() { mainTabIndex = index; @@ -34,6 +55,11 @@ class _AdminDashboardScreenState extends State { @override Widget build(BuildContext context) { + if (_setupCompleted == null) { + return const Scaffold( + body: Center(child: CircularProgressIndicator()), + ); + } return Scaffold( appBar: PreferredSize( preferredSize: const Size.fromHeight(60.0), @@ -46,6 +72,7 @@ class _AdminDashboardScreenState extends State { child: DashboardAppBarAdmin( selectedIndex: mainTabIndex, onTabChange: onMainTabChange, + setupCompleted: _setupCompleted!, ), ), ), @@ -67,7 +94,7 @@ class _AdminDashboardScreenState extends State { Widget _getBody() { if (mainTabIndex == 1) { - return const ParametresPanel(); + return ParametresPanel(redirectToLoginAfterSave: !_setupCompleted!); } switch (subIndex) { case 0: diff --git a/frontend/lib/services/configuration_service.dart b/frontend/lib/services/configuration_service.dart index 8f1c905..21e987e 100644 --- a/frontend/lib/services/configuration_service.dart +++ b/frontend/lib/services/configuration_service.dart @@ -55,6 +55,12 @@ class ConfigurationService { : Map.from(ApiConfig.headers); } + static String? _toStr(dynamic v) { + if (v == null) return null; + if (v is String) return v; + return v.toString(); + } + /// GET /api/v1/configuration/setup/status static Future getSetupStatus() async { final response = await http.get( @@ -63,7 +69,11 @@ class ConfigurationService { ); if (response.statusCode != 200) return true; final data = jsonDecode(response.body); - return data['data']?['setupCompleted'] as bool? ?? true; + final val = data['data']?['setupCompleted']; + if (val is bool) return val; + if (val is String) return val.toLowerCase() == 'true' || val == '1'; + if (val is int) return val == 1; + return true; // Par défaut on considère configuré pour ne pas bloquer } /// GET /api/v1/configuration (toutes les configs) @@ -73,9 +83,8 @@ class ConfigurationService { headers: await _headers(), ); if (response.statusCode != 200) { - throw Exception( - (jsonDecode(response.body) as Map)['message'] ?? 'Erreur chargement configuration', - ); + final err = jsonDecode(response.body) as Map?; + throw Exception(_toStr(err?['message']) ?? 'Erreur chargement configuration'); } final data = jsonDecode(response.body); final list = data['data'] as List? ?? []; @@ -89,9 +98,8 @@ class ConfigurationService { headers: await _headers(), ); if (response.statusCode != 200) { - throw Exception( - (jsonDecode(response.body) as Map)['message'] ?? 'Erreur chargement configuration', - ); + final err = jsonDecode(response.body) as Map?; + throw Exception(_toStr(err?['message']) ?? 'Erreur chargement configuration'); } final data = jsonDecode(response.body); final map = data['data'] as Map? ?? {}; @@ -105,9 +113,10 @@ class ConfigurationService { headers: await _headers(), body: jsonEncode(body), ); - if (response.statusCode != 200) { - final err = jsonDecode(response.body) as Map; - throw Exception(err['message'] ?? 'Erreur lors de la sauvegarde'); + if (response.statusCode != 200 && response.statusCode != 201) { + final err = jsonDecode(response.body) as Map?; + final msg = err != null ? (_toStr(err['error']) ?? _toStr(err['message'])) : null; + throw Exception(msg ?? 'Erreur lors de la sauvegarde'); } } @@ -118,11 +127,12 @@ class ConfigurationService { headers: await _headers(), body: jsonEncode({'testEmail': testEmail}), ); - final data = jsonDecode(response.body) as Map; - if (response.statusCode == 200 && data['success'] == true) { - return data['message'] as String? ?? 'Test SMTP réussi.'; + final data = jsonDecode(response.body) as Map?; + if ((response.statusCode == 200 || response.statusCode == 201) && (data?['success'] == true)) { + return _toStr(data?['message']) ?? 'Test SMTP réussi.'; } - throw Exception(data['error'] ?? data['message'] ?? 'Échec du test SMTP'); + final msg = data != null ? (_toStr(data['error']) ?? _toStr(data['message'])) : null; + throw Exception(msg ?? 'Échec du test SMTP'); } /// POST /api/v1/configuration/setup/complete (après première config) @@ -131,9 +141,10 @@ class ConfigurationService { Uri.parse('${ApiConfig.baseUrl}${ApiConfig.configurationSetupComplete}'), headers: await _headers(), ); - if (response.statusCode != 200) { - final err = jsonDecode(response.body) as Map; - throw Exception(err['message'] ?? 'Erreur finalisation configuration'); + if (response.statusCode != 200 && response.statusCode != 201) { + final err = jsonDecode(response.body) as Map?; + final msg = err != null ? (_toStr(err['error']) ?? _toStr(err['message'])) : null; + throw Exception(msg ?? 'Erreur finalisation configuration'); } } } diff --git a/frontend/lib/widgets/admin/dashboard_admin.dart b/frontend/lib/widgets/admin/dashboard_admin.dart index d19030a..12fbe8b 100644 --- a/frontend/lib/widgets/admin/dashboard_admin.dart +++ b/frontend/lib/widgets/admin/dashboard_admin.dart @@ -1,14 +1,18 @@ import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:p_tits_pas/services/auth_service.dart'; -/// Barre principale du dashboard admin : 2 onglets (Gestion des utilisateurs | Paramètres) + infos utilisateur. +/// Barre du dashboard admin : onglets Gestion des utilisateurs | Paramètres + déconnexion. class DashboardAppBarAdmin extends StatelessWidget implements PreferredSizeWidget { final int selectedIndex; final ValueChanged onTabChange; + final bool setupCompleted; const DashboardAppBarAdmin({ Key? key, required this.selectedIndex, required this.onTabChange, + this.setupCompleted = true, }) : super(key: key); @override @@ -32,9 +36,9 @@ class DashboardAppBarAdmin extends StatelessWidget implements PreferredSizeWidge child: Row( mainAxisSize: MainAxisSize.min, children: [ - _buildNavItem(context, 'Gestion des utilisateurs', 0), + _buildNavItem(context, 'Gestion des utilisateurs', 0, enabled: setupCompleted), const SizedBox(width: 24), - _buildNavItem(context, 'Paramètres', 1), + _buildNavItem(context, 'Paramètres', 1, enabled: true), ], ), ), @@ -74,23 +78,26 @@ class DashboardAppBarAdmin extends StatelessWidget implements PreferredSizeWidge ); } - Widget _buildNavItem(BuildContext context, String title, int index) { + Widget _buildNavItem(BuildContext context, String title, int index, {bool enabled = true}) { final bool isActive = index == selectedIndex; return InkWell( - onTap: () => onTabChange(index), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - decoration: BoxDecoration( - color: isActive ? const Color(0xFF9CC5C0) : Colors.transparent, - borderRadius: BorderRadius.circular(20), - border: isActive ? null : Border.all(color: Colors.black26), - ), - child: Text( - title, - style: TextStyle( - color: isActive ? Colors.white : Colors.black, - fontWeight: isActive ? FontWeight.w600 : FontWeight.normal, - fontSize: 14, + onTap: enabled ? () => onTabChange(index) : null, + child: Opacity( + opacity: enabled ? 1.0 : 0.5, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: isActive ? const Color(0xFF9CC5C0) : Colors.transparent, + borderRadius: BorderRadius.circular(20), + border: isActive ? null : Border.all(color: Colors.black26), + ), + child: Text( + title, + style: TextStyle( + color: isActive ? Colors.white : Colors.black, + fontWeight: isActive ? FontWeight.w600 : FontWeight.normal, + fontSize: 14, + ), ), ), ), @@ -109,9 +116,10 @@ class DashboardAppBarAdmin extends StatelessWidget implements PreferredSizeWidge child: const Text('Annuler'), ), ElevatedButton( - onPressed: () { + onPressed: () async { Navigator.pop(context); - // TODO: Implémenter la logique de déconnexion + await AuthService.logout(); + if (context.mounted) context.go('/login'); }, child: const Text('Déconnecter'), ), @@ -121,7 +129,7 @@ class DashboardAppBarAdmin extends StatelessWidget implements PreferredSizeWidge } } -/// Sous-barre affichée quand "Gestion des utilisateurs" est actif : 4 onglets sans infos utilisateur. +/// Sous-barre : Gestionnaires | Parents | Assistantes maternelles | Administrateurs. class DashboardUserManagementSubBar extends StatelessWidget { final int selectedSubIndex; final ValueChanged onSubTabChange; diff --git a/frontend/lib/widgets/admin/parametres_panel.dart b/frontend/lib/widgets/admin/parametres_panel.dart index c50ecdd..22e7b7c 100644 --- a/frontend/lib/widgets/admin/parametres_panel.dart +++ b/frontend/lib/widgets/admin/parametres_panel.dart @@ -1,9 +1,13 @@ import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import 'package:p_tits_pas/services/configuration_service.dart'; -/// Panneau Paramètres / Configuration (ticket #15) : 3 sections sur une page. +/// Panneau Paramètres admin : Email (SMTP), Personnalisation, Avancé. class ParametresPanel extends StatefulWidget { - const ParametresPanel({super.key}); + /// Si true, après sauvegarde on redirige vers le login (première config). Sinon on reste sur la page. + final bool redirectToLoginAfterSave; + + const ParametresPanel({super.key, this.redirectToLoginAfterSave = false}); @override State createState() => _ParametresPanelState(); @@ -104,7 +108,14 @@ class _ParametresPanelState extends State { return payload; } + /// Sauvegarde en base sans completeSetup (utilisé avant test SMTP). + Future _saveBulkOnly() async { + await ConfigurationService.updateBulk(_buildPayload()); + } + + /// Sauvegarde la config, marque le setup comme terminé. Si première config, redirige vers le login. Future _save() async { + final redirectAfter = widget.redirectToLoginAfterSave; setState(() { _message = null; _isSaving = true; @@ -112,10 +123,16 @@ class _ParametresPanelState extends State { try { await ConfigurationService.updateBulk(_buildPayload()); if (!mounted) return; + await ConfigurationService.completeSetup(); + if (!mounted) return; setState(() { _isSaving = false; _message = 'Configuration enregistrée.'; }); + if (!mounted) return; + if (redirectAfter) { + GoRouter.of(context).go('/login'); + } } catch (e) { if (mounted) { setState(() { @@ -160,7 +177,7 @@ class _ParametresPanelState extends State { if (email == null || !mounted) return; setState(() => _message = null); try { - await _save(); + await _saveBulkOnly(); if (!mounted) return; final msg = await ConfigurationService.testSmtp(email); if (!mounted) return; From 868242145309638f26302e0a82aeaf4311cc71fc Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Mon, 16 Feb 2026 16:19:23 +0100 Subject: [PATCH 3/3] Merge develop into master (squash) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - feat(#90): API Inscription AM - POST /auth/register/am - Suppression legacy register/parent/legacy - BDD assistantes_maternelles alignée entité - Script test register AM Co-authored-by: Cursor --- backend/scripts/test-register-am.sh | 27 +++ backend/src/routes/auth/auth.controller.ts | 16 +- backend/src/routes/auth/auth.module.ts | 3 +- backend/src/routes/auth/auth.service.ts | 200 +++++++----------- .../auth/dto/register-am-complet.dto.ts | 156 ++++++++++++++ .../routes/auth/dto/register-parent.dto.ts | 105 --------- database/BDD.sql | 9 +- frontend/lib/services/api/api_config.dart | 2 + 8 files changed, 282 insertions(+), 236 deletions(-) create mode 100755 backend/scripts/test-register-am.sh create mode 100644 backend/src/routes/auth/dto/register-am-complet.dto.ts delete mode 100644 backend/src/routes/auth/dto/register-parent.dto.ts 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';