merge: resolution conflits develop -> master (ticket #14)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
MARTIN Julien 2026-02-16 16:22:04 +01:00
commit b16dd4b55c
25 changed files with 671 additions and 292 deletions

18
.gitattributes vendored Normal file
View File

@ -0,0 +1,18 @@
# Fins de ligne : toujours LF dans le dépôt (évite les conflits Linux/Windows)
* text=auto eol=lf
# Fichiers binaires : pas de conversion
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.webp binary
*.pdf binary
*.woff binary
*.woff2 binary
*.ttf binary
*.eot binary
# Scripts shell : toujours LF
*.sh text eol=lf

View File

@ -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

View File

@ -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
}'

View File

@ -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<string, unknown> = {};
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<unknown> {
if (!this.enabled) return next.handle();
const http = context.switchToHttp();
const req = http.getRequest<Request>();
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)
},
}),
);
}
}

View File

@ -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

View File

@ -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);

View File

@ -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<void> {
await this.set('setup_completed', 'true', userId);
async markSetupCompleted(userId: string | null): Promise<void> {
await this.set('setup_completed', 'true', userId ?? undefined);
this.logger.log('✅ Configuration initiale marquée comme terminée');
}

View File

@ -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()

View File

@ -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({

View File

@ -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<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
* 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
*/

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

@ -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
);
-- ==========================================================

View File

@ -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"` nest pas un UUID valide → erreur PostgreSQL.
## Modifications à apporter au backend
**Option A Accepter labsence dutilisateur (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<void>`
- 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.

View File

@ -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';
}

View File

@ -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<AdminDashboardScreen> {
/// 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<void> _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<AdminDashboardScreen> {
@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<AdminDashboardScreen> {
child: DashboardAppBarAdmin(
selectedIndex: mainTabIndex,
onTabChange: onMainTabChange,
setupCompleted: _setupCompleted!,
),
),
),
@ -67,7 +94,7 @@ class _AdminDashboardScreenState extends State<AdminDashboardScreen> {
Widget _getBody() {
if (mainTabIndex == 1) {
return const ParametresPanel();
return ParametresPanel(redirectToLoginAfterSave: !_setupCompleted!);
}
switch (subIndex) {
case 0:

View File

@ -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';

View File

@ -55,6 +55,12 @@ class ConfigurationService {
: Map<String, String>.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<bool> 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<String, dynamic>?;
throw Exception(_toStr(err?['message']) ?? 'Erreur chargement configuration');
}
final data = jsonDecode(response.body);
final list = data['data'] as List<dynamic>? ?? [];
@ -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<String, dynamic>?;
throw Exception(_toStr(err?['message']) ?? 'Erreur chargement configuration');
}
final data = jsonDecode(response.body);
final map = data['data'] as Map<String, dynamic>? ?? {};
@ -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<String, dynamic>?;
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<String, dynamic>?;
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<String, dynamic>?;
final msg = err != null ? (_toStr(err['error']) ?? _toStr(err['message'])) : null;
throw Exception(msg ?? 'Erreur finalisation configuration');
}
}
}

View File

@ -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<int> 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,10 +78,12 @@ 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),
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(
@ -94,6 +100,7 @@ class DashboardAppBarAdmin extends StatelessWidget implements PreferredSizeWidge
),
),
),
),
);
}
@ -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<int> onSubTabChange;

View File

@ -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<ParametresPanel> createState() => _ParametresPanelState();
@ -104,7 +108,14 @@ class _ParametresPanelState extends State<ParametresPanel> {
return payload;
}
/// Sauvegarde en base sans completeSetup (utilisé avant test SMTP).
Future<void> _saveBulkOnly() async {
await ConfigurationService.updateBulk(_buildPayload());
}
/// Sauvegarde la config, marque le setup comme terminé. Si première config, redirige vers le login.
Future<void> _save() async {
final redirectAfter = widget.redirectToLoginAfterSave;
setState(() {
_message = null;
_isSaving = true;
@ -112,10 +123,16 @@ class _ParametresPanelState extends State<ParametresPanel> {
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<ParametresPanel> {
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;

View File

@ -0,0 +1,19 @@
# Créer lissue #84 (correctifs modale MDP) via lAPI Gitea
1. Définir un token valide :
`export GITEA_TOKEN="votre_token"`
ou créer `.gitea-token` à la racine du projet avec le token seul.
2. Créer lissue :
```bash
cd /chemin/vers/PetitsPas
curl -s -X POST \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d @scripts/issue-84-payload.json \
"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/issues"
```
3. En cas de succès (HTTP 201), la réponse JSON contient le numéro de lissue créée.
Payload utilisé : `scripts/issue-84-payload.json` (titre + corps depuis `scripts/issue-84-body.txt`).

View File

@ -0,0 +1,51 @@
#!/usr/bin/env bash
# Crée une issue Gitea via l'API.
# Usage: GITEA_TOKEN=xxx ./scripts/create-gitea-issue.sh
# Ou: mettre le token dans .gitea-token à la racine du projet.
set -e
BASE_URL="${GITEA_URL:-https://git.ptits-pas.fr/api/v1}"
REPO="jmartin/petitspas"
if [ -z "$GITEA_TOKEN" ]; then
if [ -f .gitea-token ]; then
GITEA_TOKEN=$(cat .gitea-token)
fi
fi
if [ -z "$GITEA_TOKEN" ]; then
echo "Définir GITEA_TOKEN ou créer .gitea-token avec votre token Gitea."
exit 1
fi
TITLE="$1"
BODY="$2"
if [ -z "$TITLE" ]; then
echo "Usage: $0 \"Titre de l'issue\" \"Corps (optionnel)\""
exit 1
fi
# Build JSON (escape body for JSON)
BODY_ESC=$(echo "$BODY" | jq -Rs . 2>/dev/null || echo "null")
if [ "$BODY_ESC" = "null" ] || [ -z "$BODY" ]; then
PAYLOAD=$(jq -n --arg t "$TITLE" '{title: $t}')
else
PAYLOAD=$(jq -n --arg t "$TITLE" --arg b "$BODY" '{title: $t, body: $b}')
fi
RESP=$(curl -s -w "\n%{http_code}" -X POST \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d "$PAYLOAD" \
"$BASE_URL/repos/$REPO/issues")
HTTP_CODE=$(echo "$RESP" | tail -1)
BODY_RESP=$(echo "$RESP" | sed '$d')
if [ "$HTTP_CODE" = "201" ]; then
ISSUE_NUM=$(echo "$BODY_RESP" | jq -r .number)
echo "Issue #$ISSUE_NUM créée."
echo "$BODY_RESP" | jq .
else
echo "Erreur HTTP $HTTP_CODE: $BODY_RESP"
exit 1
fi

View File

@ -0,0 +1,58 @@
#!/usr/bin/env bash
# Poste un commentaire sur une issue Gitea puis la ferme.
# Usage: GITEA_TOKEN=xxx ./scripts/gitea-close-issue-with-comment.sh <numéro> "Commentaire"
# Ou: mettre le token dans .gitea-token à la racine du projet.
# Exemple: ./scripts/gitea-close-issue-with-comment.sh 15 "Livré : panneau Paramètres opérationnel."
set -e
ISSUE="${1:?Usage: $0 <numéro_issue> \"Commentaire\"}"
COMMENT="${2:?Usage: $0 <numéro_issue> \"Commentaire\"}"
BASE_URL="${GITEA_URL:-https://git.ptits-pas.fr/api/v1}"
REPO="jmartin/petitspas"
if [ -z "$GITEA_TOKEN" ]; then
if [ -f .gitea-token ]; then
GITEA_TOKEN=$(cat .gitea-token)
fi
fi
if [ -z "$GITEA_TOKEN" ]; then
echo "Définir GITEA_TOKEN ou créer .gitea-token avec votre token Gitea."
exit 1
fi
# 1) Poster le commentaire
echo "Ajout du commentaire sur l'issue #$ISSUE..."
# Échapper pour JSON (guillemets et backslash)
COMMENT_ESC=$(printf '%s' "$COMMENT" | sed 's/\\/\\\\/g; s/"/\\"/g; s/\r//g')
PAYLOAD="{\"body\":\"$COMMENT_ESC\"}"
RESP=$(curl -s -w "\n%{http_code}" -X POST \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d "$PAYLOAD" \
"$BASE_URL/repos/$REPO/issues/$ISSUE/comments")
HTTP_CODE=$(echo "$RESP" | tail -1)
BODY=$(echo "$RESP" | sed '$d')
if [ "$HTTP_CODE" != "201" ]; then
echo "Erreur HTTP $HTTP_CODE lors du commentaire: $BODY"
exit 1
fi
echo "Commentaire ajouté."
# 2) Fermer l'issue
echo "Fermeture de l'issue #$ISSUE..."
RESP2=$(curl -s -w "\n%{http_code}" -X PATCH \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d '{"state":"closed"}' \
"$BASE_URL/repos/$REPO/issues/$ISSUE")
HTTP_CODE2=$(echo "$RESP2" | tail -1)
BODY2=$(echo "$RESP2" | sed '$d')
if [ "$HTTP_CODE2" = "200" ] || [ "$HTTP_CODE2" = "201" ]; then
echo "Issue #$ISSUE fermée."
else
echo "Erreur HTTP $HTTP_CODE2: $BODY2"
exit 1
fi

14
scripts/issue-84-body.txt Normal file
View File

@ -0,0 +1,14 @@
Correctifs et améliorations de la modale de changement de mot de passe obligatoire affichée à la première connexion admin.
**Périmètre :**
- Ajustements visuels / UX de la modale (ChangePasswordDialog)
- Cohérence charte graphique, espacements, lisibilité
- Comportement (validation, messages d'erreur, fermeture)
- Lien de test en debug sur l'écran login (« Test modale MDP ») pour faciliter les réglages
**Tâches :**
- [ ] Revoir le design de la modale (relief, bordures, couleurs)
- [ ] Vérifier les champs (MDP actuel, nouveau, confirmation) et validations
- [ ] Ajuster les textes et messages d'erreur
- [ ] Tester sur mobile et desktop
- [ ] Retirer ou conditionner le lien « Test modale MDP » en production si besoin

View File

@ -0,0 +1 @@
{"title": "[Frontend] Bug Correctifs modale Changement MDP (première connexion admin)", "body": "Correctifs et améliorations de la modale de changement de mot de passe obligatoire affichée à la première connexion admin.\n\n**Périmètre :**\n- Ajustements visuels / UX de la modale (ChangePasswordDialog)\n- Cohérence charte graphique, espacements, lisibilité\n- Comportement (validation, messages d'erreur, fermeture)\n- Lien de test en debug sur l'écran login (« Test modale MDP ») pour faciliter les réglages\n\n**Tâches :**\n- [ ] Revoir le design de la modale (relief, bordures, couleurs)\n- [ ] Vérifier les champs (MDP actuel, nouveau, confirmation) et validations\n- [ ] Ajuster les textes et messages d'erreur\n- [ ] Tester sur mobile et desktop\n- [ ] Retirer ou conditionner le lien « Test modale MDP » en production si besoin\n"}