Retire le champ partout : BDD (migration + seed), entity/DTO/services Nest, modèles et payloads Flutter, scripts de test et docs. Back et front alignés (forbidNonWhitelisted). Aucun fantôme de compat. Co-authored-by: Cursor <cursoragent@cursor.com>
1591 lines
63 KiB
TypeScript
1591 lines
63 KiB
TypeScript
import {
|
||
ConflictException,
|
||
Injectable,
|
||
Logger,
|
||
NotFoundException,
|
||
UnauthorizedException,
|
||
BadRequestException,
|
||
} from '@nestjs/common';
|
||
import { InjectRepository } from '@nestjs/typeorm';
|
||
import { EntityManager, In, QueryFailedError, Repository } from 'typeorm';
|
||
import { UserService } from '../user/user.service';
|
||
import { JwtService } from '@nestjs/jwt';
|
||
import * as bcrypt from 'bcrypt';
|
||
import * as crypto from 'crypto';
|
||
import * as fs from 'fs/promises';
|
||
import * as path from 'path';
|
||
import { RegisterDto } from './dto/register.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 { DossierFamille, DossierFamilleEnfant } from 'src/entities/dossier_famille.entity';
|
||
import { StatutDossierType } from 'src/entities/dossiers.entity';
|
||
import { LoginDto } from './dto/login.dto';
|
||
import { RepriseDossierDto } from './dto/reprise-dossier.dto';
|
||
import { ResoumettreRepriseDto } from './dto/resoumettre-reprise.dto';
|
||
import { RepriseIdentifyResponseDto } from './dto/reprise-identify.dto';
|
||
import { AppConfigService } from 'src/modules/config/config.service';
|
||
import { validateNir } from 'src/common/utils/nir.util';
|
||
import { NumeroDossierService } from 'src/modules/numero-dossier/numero-dossier.service';
|
||
import { MailService } from 'src/modules/mail/mail.service';
|
||
import { ParentsService } from '../parents/parents.service';
|
||
import { DossiersService } from '../dossiers/dossiers.service';
|
||
import { DossierAmCompletDto } from '../dossiers/dto/dossier-am-complet.dto';
|
||
import { StaffAddCoParentDto } from '../parents/dto/staff-add-co-parent.dto';
|
||
|
||
@Injectable()
|
||
export class AuthService {
|
||
private readonly logger = new Logger(AuthService.name);
|
||
|
||
constructor(
|
||
private readonly usersService: UserService,
|
||
private readonly jwtService: JwtService,
|
||
private readonly configService: ConfigService,
|
||
private readonly appConfigService: AppConfigService,
|
||
private readonly mailService: MailService,
|
||
private readonly numeroDossierService: NumeroDossierService,
|
||
private readonly parentsService: ParentsService,
|
||
private readonly dossiersService: DossiersService,
|
||
@InjectRepository(Parents)
|
||
private readonly parentsRepo: Repository<Parents>,
|
||
@InjectRepository(Users)
|
||
private readonly usersRepo: Repository<Users>,
|
||
@InjectRepository(Children)
|
||
private readonly childrenRepo: Repository<Children>,
|
||
@InjectRepository(AssistanteMaternelle)
|
||
private readonly assistantesMaternellesRepo: Repository<AssistanteMaternelle>,
|
||
) { }
|
||
|
||
/**
|
||
* Génère un access_token et un refresh_token
|
||
*/
|
||
async generateTokens(userId: string, email: string, role: RoleType) {
|
||
const accessSecret = this.configService.get<string>('jwt.accessSecret');
|
||
const accessExpiresIn = this.configService.get<string>('jwt.accessExpiresIn');
|
||
const refreshSecret = this.configService.get<string>('jwt.refreshSecret');
|
||
const refreshExpiresIn = this.configService.get<string>('jwt.refreshExpiresIn');
|
||
|
||
const [accessToken, refreshToken] = await Promise.all([
|
||
this.jwtService.signAsync({ sub: userId, email, role }, { secret: accessSecret, expiresIn: accessExpiresIn }),
|
||
this.jwtService.signAsync({ sub: userId }, { secret: refreshSecret, expiresIn: refreshExpiresIn }),
|
||
]);
|
||
|
||
return {
|
||
access_token: accessToken,
|
||
refresh_token: refreshToken,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Connexion utilisateur
|
||
*/
|
||
async login(dto: LoginDto) {
|
||
const user = await this.usersService.findByEmailOrNull(dto.email);
|
||
|
||
if (!user) {
|
||
throw new UnauthorizedException('Identifiants invalides');
|
||
}
|
||
|
||
// Vérifier que le mot de passe existe (compte activé)
|
||
if (!user.password) {
|
||
throw new UnauthorizedException(
|
||
'Compte non activé. Veuillez créer votre mot de passe via le lien reçu par email.',
|
||
);
|
||
}
|
||
|
||
// Vérifier le mot de passe
|
||
const isMatch = await bcrypt.compare(dto.password, user.password);
|
||
if (!isMatch) {
|
||
throw new UnauthorizedException('Identifiants invalides');
|
||
}
|
||
|
||
// Vérifier le statut du compte
|
||
if (user.statut === StatutUtilisateurType.EN_ATTENTE) {
|
||
throw new UnauthorizedException(
|
||
'Votre compte est en attente de validation par un gestionnaire.',
|
||
);
|
||
}
|
||
|
||
if (user.statut === StatutUtilisateurType.SUSPENDU) {
|
||
throw new UnauthorizedException('Votre compte a été suspendu. Contactez un administrateur.');
|
||
}
|
||
|
||
if (user.statut === StatutUtilisateurType.REFUSE) {
|
||
throw new UnauthorizedException(
|
||
'Votre compte a été refusé. Vous pouvez corriger votre dossier et le soumettre à nouveau ; un gestionnaire pourra le réexaminer.',
|
||
);
|
||
}
|
||
|
||
return this.generateTokens(user.id, user.email, user.role);
|
||
}
|
||
|
||
/**
|
||
* Rafraîchir les tokens
|
||
*/
|
||
async refreshTokens(refreshToken: string) {
|
||
try {
|
||
const payload = await this.jwtService.verifyAsync(refreshToken, {
|
||
secret: this.configService.get<string>('jwt.refreshSecret'),
|
||
});
|
||
|
||
const user = await this.usersService.findOne(payload.sub);
|
||
if (!user) {
|
||
throw new UnauthorizedException('Utilisateur introuvable');
|
||
}
|
||
|
||
return this.generateTokens(user.id, user.email, user.role);
|
||
} catch {
|
||
throw new UnauthorizedException('Refresh token invalide');
|
||
}
|
||
}
|
||
|
||
async verifyCreatePasswordToken(token: string) {
|
||
const cleanedToken = token?.trim();
|
||
if (!cleanedToken) {
|
||
// #118: côté front, un token absent doit être traité comme lien invalide/expiré.
|
||
// On renvoie donc la même réponse neutre 404 que pour les autres cas invalides.
|
||
throw new NotFoundException('Token invalide, expiré, ou déjà utilisé.');
|
||
}
|
||
|
||
const user = await this.usersRepo.findOne({
|
||
where: { token_creation_mdp: cleanedToken },
|
||
select: ['id', 'token_creation_mdp_expire_le', 'password'],
|
||
});
|
||
|
||
if (!user) {
|
||
throw new NotFoundException('Token invalide, expiré, ou déjà utilisé.');
|
||
}
|
||
|
||
if (user.password) {
|
||
throw new NotFoundException('Token invalide, expiré, ou déjà utilisé.');
|
||
}
|
||
|
||
if (!user.token_creation_mdp_expire_le || user.token_creation_mdp_expire_le <= new Date()) {
|
||
// Invalidation explicite d'un token expiré pour éviter toute réutilisation ultérieure.
|
||
await this.usersRepo
|
||
.createQueryBuilder()
|
||
.update(Users)
|
||
.set({
|
||
token_creation_mdp: () => 'NULL',
|
||
token_creation_mdp_expire_le: () => 'NULL',
|
||
})
|
||
.where('id = :id', { id: user.id })
|
||
.execute();
|
||
throw new NotFoundException('Token invalide, expiré, ou déjà utilisé.');
|
||
}
|
||
|
||
return {
|
||
valid: true,
|
||
message: 'Token valide',
|
||
};
|
||
}
|
||
|
||
async createPasswordWithToken(token: string, plainPassword: string) {
|
||
const cleanedToken = token?.trim();
|
||
if (!cleanedToken) {
|
||
throw new BadRequestException('Token manquant');
|
||
}
|
||
|
||
const existingUser = await this.usersRepo.findOne({
|
||
where: {
|
||
token_creation_mdp: cleanedToken,
|
||
},
|
||
select: ['id', 'password', 'token_creation_mdp_expire_le'],
|
||
});
|
||
|
||
if (!existingUser) {
|
||
throw new NotFoundException('Token invalide, expiré, ou déjà utilisé.');
|
||
}
|
||
|
||
if (existingUser.password) {
|
||
throw new NotFoundException('Token invalide, expiré, ou déjà utilisé.');
|
||
}
|
||
|
||
if (
|
||
!existingUser.token_creation_mdp_expire_le ||
|
||
existingUser.token_creation_mdp_expire_le <= new Date()
|
||
) {
|
||
await this.usersRepo
|
||
.createQueryBuilder()
|
||
.update(Users)
|
||
.set({
|
||
token_creation_mdp: () => 'NULL',
|
||
token_creation_mdp_expire_le: () => 'NULL',
|
||
})
|
||
.where('id = :id', { id: existingUser.id })
|
||
.execute();
|
||
throw new NotFoundException('Token invalide, expiré, ou déjà utilisé.');
|
||
}
|
||
|
||
const sel = await bcrypt.genSalt(12);
|
||
const hashedPassword = await bcrypt.hash(plainPassword, sel);
|
||
|
||
// Usage unique atomique : une seule requête UPDATE avec garde-fous.
|
||
const updateResult = await this.usersRepo
|
||
.createQueryBuilder()
|
||
.update(Users)
|
||
.set({
|
||
password: hashedPassword,
|
||
token_creation_mdp: () => 'NULL',
|
||
token_creation_mdp_expire_le: () => 'NULL',
|
||
statut: StatutUtilisateurType.ACTIF,
|
||
changement_mdp_obligatoire: false,
|
||
})
|
||
.where('token_creation_mdp = :token', { token: cleanedToken })
|
||
.andWhere('token_creation_mdp_expire_le > NOW()')
|
||
.andWhere('password IS NULL')
|
||
.execute();
|
||
|
||
if (!updateResult.affected) {
|
||
this.logger.warn(
|
||
'[createPasswordWithToken] Token non consommé (course concurrente ou token invalidé)',
|
||
);
|
||
throw new NotFoundException('Token invalide, expiré, ou déjà utilisé.');
|
||
}
|
||
|
||
this.logger.log('[createPasswordWithToken] Mot de passe initial créé avec succès');
|
||
|
||
return {
|
||
message: 'Mot de passe créé avec succès. Vous pouvez maintenant vous connecter.',
|
||
userId: existingUser.id,
|
||
};
|
||
}
|
||
|
||
/** Réponse unique pour POST forgot-password (anti-énumération), ticket #127 */
|
||
private static readonly FORGOT_PASSWORD_GENERIC_MESSAGE =
|
||
'Si cette adresse est associée à un compte pour lequel un mot de passe existe, vous recevrez un e-mail avec les instructions.';
|
||
|
||
/**
|
||
* Ticket #127 — demande de réinitialisation (e-mail avec lien /reset-password).
|
||
* Toujours la même réponse 200 ; n’utilise pas token_creation_mdp (#118 inscription).
|
||
*/
|
||
async requestPasswordReset(rawEmail: string): Promise<{ message: string }> {
|
||
const generic = { message: AuthService.FORGOT_PASSWORD_GENERIC_MESSAGE };
|
||
const email = (rawEmail ?? '').trim().toLowerCase();
|
||
if (!email) {
|
||
return generic;
|
||
}
|
||
|
||
const user = await this.usersRepo
|
||
.createQueryBuilder('u')
|
||
.where('LOWER(TRIM(u.email)) = :email', { email })
|
||
.select(['u.id', 'u.email', 'u.prenom', 'u.nom', 'u.password'])
|
||
.getOne();
|
||
|
||
if (!user?.password) {
|
||
return generic;
|
||
}
|
||
|
||
const expiryDaysRaw = this.appConfigService.get<number>('password_reset_token_expiry_days', 7);
|
||
const expiryDays = Number(expiryDaysRaw) > 0 ? Number(expiryDaysRaw) : 7;
|
||
const expires = new Date();
|
||
expires.setDate(expires.getDate() + expiryDays);
|
||
|
||
const token = crypto.randomUUID();
|
||
|
||
await this.usersRepo.update(
|
||
{ id: user.id },
|
||
{
|
||
password_reset_token: token,
|
||
password_reset_expires: expires,
|
||
},
|
||
);
|
||
|
||
try {
|
||
await this.mailService.sendPasswordResetEmail({
|
||
email: user.email,
|
||
prenom: user.prenom ?? '',
|
||
nom: user.nom ?? '',
|
||
token,
|
||
});
|
||
} catch (err) {
|
||
this.logger.error(
|
||
'[requestPasswordReset] Échec envoi e-mail (réponse inchangée pour anti-énumération)',
|
||
err,
|
||
);
|
||
}
|
||
|
||
return generic;
|
||
}
|
||
|
||
/**
|
||
* Ticket #127 — consomme password_reset_token (distinct de create-password / token_creation_mdp).
|
||
*/
|
||
async resetPasswordWithResetToken(token: string, plainPassword: string): Promise<{ message: string }> {
|
||
const cleanedToken = token?.trim();
|
||
if (!cleanedToken) {
|
||
throw new NotFoundException('Token invalide, expiré, ou déjà utilisé.');
|
||
}
|
||
|
||
const existingUser = await this.usersRepo.findOne({
|
||
where: { password_reset_token: cleanedToken },
|
||
select: ['id', 'password', 'password_reset_expires'],
|
||
});
|
||
|
||
if (!existingUser?.password) {
|
||
throw new NotFoundException('Token invalide, expiré, ou déjà utilisé.');
|
||
}
|
||
|
||
if (!existingUser.password_reset_expires || existingUser.password_reset_expires <= new Date()) {
|
||
await this.usersRepo
|
||
.createQueryBuilder()
|
||
.update(Users)
|
||
.set({
|
||
password_reset_token: () => 'NULL',
|
||
password_reset_expires: () => 'NULL',
|
||
})
|
||
.where('id = :id', { id: existingUser.id })
|
||
.execute();
|
||
throw new NotFoundException('Token invalide, expiré, ou déjà utilisé.');
|
||
}
|
||
|
||
const sel = await bcrypt.genSalt(12);
|
||
const hashedPassword = await bcrypt.hash(plainPassword, sel);
|
||
|
||
const updateResult = await this.usersRepo
|
||
.createQueryBuilder()
|
||
.update(Users)
|
||
.set({
|
||
password: hashedPassword,
|
||
password_reset_token: () => 'NULL',
|
||
password_reset_expires: () => 'NULL',
|
||
changement_mdp_obligatoire: false,
|
||
})
|
||
.where('password_reset_token = :token', { token: cleanedToken })
|
||
.andWhere('password_reset_expires > NOW()')
|
||
.execute();
|
||
|
||
if (!updateResult.affected) {
|
||
this.logger.warn(
|
||
'[resetPasswordWithResetToken] Token non consommé (course concurrente ou token invalidé)',
|
||
);
|
||
throw new NotFoundException('Token invalide, expiré, ou déjà utilisé.');
|
||
}
|
||
|
||
this.logger.log('[resetPasswordWithResetToken] Mot de passe réinitialisé avec succès');
|
||
|
||
return {
|
||
message: 'Mot de passe mis à jour. Vous pouvez vous connecter.',
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Inscription utilisateur OBSOLÈTE - Utiliser inscrireParentComplet() ou registerAM()
|
||
* @deprecated
|
||
*/
|
||
async register(registerDto: RegisterDto) {
|
||
const exists = await this.usersService.findByEmailOrNull(registerDto.email);
|
||
if (exists) {
|
||
throw new ConflictException('Email déjà utilisé');
|
||
}
|
||
|
||
const allowedRoles = new Set<RoleType>([RoleType.PARENT, RoleType.ASSISTANTE_MATERNELLE]);
|
||
if (!allowedRoles.has(registerDto.role)) {
|
||
registerDto.role = RoleType.PARENT;
|
||
}
|
||
|
||
registerDto.statut = StatutUtilisateurType.EN_ATTENTE;
|
||
|
||
if (!registerDto.consentement_photo) {
|
||
registerDto.date_consentement_photo = null;
|
||
} else if (registerDto.date_consentement_photo) {
|
||
const date = new Date(registerDto.date_consentement_photo);
|
||
if (isNaN(date.getTime())) {
|
||
registerDto.date_consentement_photo = null;
|
||
}
|
||
}
|
||
|
||
const user = await this.usersService.createUser(registerDto);
|
||
const tokens = await this.generateTokens(user.id, user.email, user.role);
|
||
|
||
return {
|
||
...tokens,
|
||
user: {
|
||
id: user.id,
|
||
email: user.email,
|
||
role: user.role,
|
||
prenom: user.prenom,
|
||
nom: user.nom,
|
||
statut: user.statut,
|
||
},
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Cœur partagé création dossier parent (#129).
|
||
* - public : statut en_attente + mails pending
|
||
* - staff : statut actif + mails création MDP (pas de mail « dossier en attente »)
|
||
*/
|
||
async createParentDossier(
|
||
dto: RegisterParentCompletDto,
|
||
options: {
|
||
statut: StatutUtilisateurType;
|
||
sendPendingEmail: boolean;
|
||
sendPasswordSetupEmail: boolean;
|
||
requireCgu: boolean;
|
||
},
|
||
) {
|
||
if (options.requireCgu && (!dto.acceptation_cgu || !dto.acceptation_privacy)) {
|
||
throw new BadRequestException('L\'acceptation des CGU et de la politique de confidentialité est obligatoire');
|
||
}
|
||
|
||
if (!dto.enfants || dto.enfants.length === 0) {
|
||
throw new BadRequestException('Au moins un enfant est requis');
|
||
}
|
||
|
||
const existe = await this.usersService.findByEmailOrNull(dto.email);
|
||
if (existe) {
|
||
throw new ConflictException('Un compte avec cet email existe déjà');
|
||
}
|
||
|
||
if (dto.co_parent_email) {
|
||
if (dto.email.trim().toLowerCase() === dto.co_parent_email.trim().toLowerCase()) {
|
||
throw new BadRequestException(
|
||
'L\'email du parent et du co-parent doivent être différents.',
|
||
);
|
||
}
|
||
const coParentExiste = await this.usersService.findByEmailOrNull(dto.co_parent_email);
|
||
if (coParentExiste) {
|
||
throw new ConflictException('L\'email du co-parent est déjà utilisé');
|
||
}
|
||
}
|
||
|
||
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 resultat: {
|
||
parent1: Users;
|
||
parent2: Users | null;
|
||
enfants: Children[];
|
||
tokenCreationMdp: string;
|
||
tokenCoParent: string | null;
|
||
};
|
||
|
||
try {
|
||
resultat = await this.usersRepo.manager.transaction(async (manager) => {
|
||
const { numero: numeroDossier } = await this.numeroDossierService.getNextNumeroDossier(manager);
|
||
|
||
const parent1 = manager.create(Users, {
|
||
email: dto.email,
|
||
prenom: dto.prenom,
|
||
nom: dto.nom,
|
||
role: RoleType.PARENT,
|
||
statut: options.statut,
|
||
telephone: dto.telephone,
|
||
adresse: dto.adresse,
|
||
code_postal: dto.code_postal,
|
||
ville: dto.ville,
|
||
token_creation_mdp: tokenCreationMdp,
|
||
token_creation_mdp_expire_le: dateExpiration,
|
||
numero_dossier: numeroDossier,
|
||
});
|
||
|
||
const parent1Enregistre = await manager.save(Users, parent1);
|
||
|
||
let parent2Enregistre: 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 dateExpirationCoParent = new Date();
|
||
dateExpirationCoParent.setDate(dateExpirationCoParent.getDate() + joursExpirationToken);
|
||
|
||
const parent2 = manager.create(Users, {
|
||
email: dto.co_parent_email,
|
||
prenom: dto.co_parent_prenom,
|
||
nom: dto.co_parent_nom,
|
||
role: RoleType.PARENT,
|
||
statut: options.statut,
|
||
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: dateExpirationCoParent,
|
||
numero_dossier: numeroDossier,
|
||
});
|
||
|
||
parent2Enregistre = await manager.save(Users, parent2);
|
||
}
|
||
|
||
const entiteParent = manager.create(Parents, {
|
||
user_id: parent1Enregistre.id,
|
||
numero_dossier: numeroDossier,
|
||
});
|
||
entiteParent.user = parent1Enregistre;
|
||
if (parent2Enregistre) {
|
||
entiteParent.co_parent = parent2Enregistre;
|
||
}
|
||
|
||
await manager.save(Parents, entiteParent);
|
||
|
||
if (parent2Enregistre) {
|
||
const entiteCoParent = manager.create(Parents, {
|
||
user_id: parent2Enregistre.id,
|
||
numero_dossier: numeroDossier,
|
||
});
|
||
entiteCoParent.user = parent2Enregistre;
|
||
entiteCoParent.co_parent = parent1Enregistre;
|
||
|
||
await manager.save(Parents, entiteCoParent);
|
||
}
|
||
|
||
const enfantsEnregistres: Children[] = [];
|
||
for (const enfantDto of dto.enfants) {
|
||
let urlPhoto: string | null = null;
|
||
|
||
if (enfantDto.photo_base64 && enfantDto.photo_filename) {
|
||
urlPhoto = await this.sauvegarderPhotoDepuisBase64(
|
||
enfantDto.photo_base64,
|
||
enfantDto.photo_filename,
|
||
);
|
||
}
|
||
|
||
const enfant = new Children();
|
||
enfant.first_name = enfantDto.prenom;
|
||
enfant.last_name = enfantDto.nom || dto.nom;
|
||
enfant.gender = enfantDto.genre;
|
||
enfant.birth_date = enfantDto.date_naissance ? new Date(enfantDto.date_naissance) : undefined;
|
||
enfant.due_date = enfantDto.date_previsionnelle_naissance
|
||
? new Date(enfantDto.date_previsionnelle_naissance)
|
||
: undefined;
|
||
enfant.photo_url = urlPhoto || undefined;
|
||
enfant.status = enfantDto.date_naissance ? StatutEnfantType.SANS_GARDE : StatutEnfantType.A_NAITRE;
|
||
enfant.consent_photo = !!enfantDto.consent_photo;
|
||
enfant.consent_photo_at = enfant.consent_photo ? new Date() : null!;
|
||
|
||
const enfantEnregistre = await manager.save(Children, enfant);
|
||
enfantsEnregistres.push(enfantEnregistre);
|
||
|
||
const lienParentEnfant1 = manager.create(ParentsChildren, {
|
||
parentId: parent1Enregistre.id,
|
||
enfantId: enfantEnregistre.id,
|
||
});
|
||
await manager.save(ParentsChildren, lienParentEnfant1);
|
||
|
||
if (parent2Enregistre) {
|
||
const lienParentEnfant2 = manager.create(ParentsChildren, {
|
||
parentId: parent2Enregistre.id,
|
||
enfantId: enfantEnregistre.id,
|
||
});
|
||
await manager.save(ParentsChildren, lienParentEnfant2);
|
||
}
|
||
}
|
||
|
||
// Dossier famille : motivation (texte_motivation côté GET) + liaisons enfants (ticket #119)
|
||
const presentationTrim = dto.presentation_dossier?.trim();
|
||
const dossierFamilleEnt = manager.create(DossierFamille, {
|
||
numero_dossier: numeroDossier,
|
||
presentation: presentationTrim || undefined,
|
||
statut: StatutDossierType.ENVOYE,
|
||
parent: entiteParent,
|
||
});
|
||
const dossierFamilleSaved = await manager.save(DossierFamille, dossierFamilleEnt);
|
||
for (const enfantEnregistre of enfantsEnregistres) {
|
||
await manager.save(
|
||
DossierFamilleEnfant,
|
||
manager.create(DossierFamilleEnfant, {
|
||
id_dossier_famille: dossierFamilleSaved.id,
|
||
id_enfant: enfantEnregistre.id,
|
||
}),
|
||
);
|
||
}
|
||
|
||
return {
|
||
parent1: parent1Enregistre,
|
||
parent2: parent2Enregistre,
|
||
enfants: enfantsEnregistres,
|
||
tokenCreationMdp,
|
||
tokenCoParent,
|
||
};
|
||
});
|
||
} catch (err) {
|
||
if (this.isPostgresUniqueViolation(err)) {
|
||
throw new ConflictException(
|
||
'Un compte avec cet email existe déjà (contrainte unique en base).',
|
||
);
|
||
}
|
||
throw err;
|
||
}
|
||
|
||
const numeroDossier = resultat.parent1.numero_dossier ?? '';
|
||
|
||
if (options.sendPendingEmail) {
|
||
try {
|
||
await this.mailService.sendRegistrationPendingEmail(
|
||
resultat.parent1.email,
|
||
resultat.parent1.prenom ?? '',
|
||
resultat.parent1.nom ?? '',
|
||
numeroDossier,
|
||
);
|
||
if (resultat.parent2) {
|
||
await this.mailService.sendRegistrationPendingEmail(
|
||
resultat.parent2.email,
|
||
resultat.parent2.prenom ?? '',
|
||
resultat.parent2.nom ?? '',
|
||
numeroDossier,
|
||
);
|
||
}
|
||
} catch (err) {
|
||
this.logger.error(
|
||
"[createParentDossier] Échec envoi email d'accusé de réception (inscription conservée)",
|
||
err instanceof Error ? err.stack : String(err),
|
||
);
|
||
}
|
||
}
|
||
|
||
if (options.sendPasswordSetupEmail) {
|
||
try {
|
||
await this.mailService.sendValidatedAccountPasswordSetupEmail(
|
||
{
|
||
email: resultat.parent1.email,
|
||
prenom: resultat.parent1.prenom ?? '',
|
||
nom: resultat.parent1.nom ?? '',
|
||
token: resultat.tokenCreationMdp,
|
||
numeroDossier,
|
||
},
|
||
'parent',
|
||
);
|
||
if (resultat.parent2 && resultat.tokenCoParent) {
|
||
await this.mailService.sendValidatedAccountPasswordSetupEmail(
|
||
{
|
||
email: resultat.parent2.email,
|
||
prenom: resultat.parent2.prenom ?? '',
|
||
nom: resultat.parent2.nom ?? '',
|
||
token: resultat.tokenCoParent,
|
||
numeroDossier,
|
||
},
|
||
'parent',
|
||
);
|
||
}
|
||
} catch (err) {
|
||
this.logger.error(
|
||
'[createParentDossier] Échec envoi email création MDP (dossier conservé)',
|
||
err instanceof Error ? err.stack : String(err),
|
||
);
|
||
}
|
||
}
|
||
|
||
const message = options.sendPasswordSetupEmail
|
||
? 'Dossier famille créé et validé. Un e-mail de création de mot de passe a été envoyé.'
|
||
: 'Inscription réussie. Votre dossier est en attente de validation par un gestionnaire.';
|
||
|
||
return {
|
||
message,
|
||
parent_id: resultat.parent1.id,
|
||
parent_user_id: resultat.parent1.id,
|
||
co_parent_id: resultat.parent2?.id ?? null,
|
||
co_parent_user_id: resultat.parent2?.id ?? null,
|
||
enfants_ids: resultat.enfants.map(e => e.id),
|
||
enfant_ids: resultat.enfants.map(e => e.id),
|
||
statut: options.statut,
|
||
numero_dossier: numeroDossier,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Inscription Parent publique — CDC (statut en_attente + mail pending).
|
||
*/
|
||
async inscrireParentComplet(dto: RegisterParentCompletDto) {
|
||
return this.createParentDossier(dto, {
|
||
statut: StatutUtilisateurType.EN_ATTENTE,
|
||
sendPendingEmail: true,
|
||
sendPasswordSetupEmail: false,
|
||
requireCgu: true,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Création dossier parent par le staff (#129) — actif + mail création MDP.
|
||
*/
|
||
async createParentDossierStaff(dto: RegisterParentCompletDto) {
|
||
return this.createParentDossier(dto, {
|
||
statut: StatutUtilisateurType.ACTIF,
|
||
sendPendingEmail: false,
|
||
sendPasswordSetupEmail: true,
|
||
requireCgu: false,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Ajoute un co-parent à un foyer existant (mono-parent) — ticket #135.
|
||
* Compte actif + mail création MDP + liens Parents bidirectionnels + enfants du foyer.
|
||
*/
|
||
async addCoParentStaff(pivotUserId: string, dto: StaffAddCoParentDto) {
|
||
const pivotParent = await this.parentsRepo.findOne({
|
||
where: { user_id: pivotUserId },
|
||
relations: ['user', 'co_parent', 'parentChildren'],
|
||
});
|
||
if (!pivotParent?.user) {
|
||
throw new NotFoundException('Parent introuvable');
|
||
}
|
||
|
||
if (pivotParent.co_parent) {
|
||
throw new BadRequestException('Ce foyer a déjà un co-parent.');
|
||
}
|
||
|
||
const numeroDossier = pivotParent.numero_dossier?.trim() || pivotParent.user.numero_dossier?.trim();
|
||
if (!numeroDossier) {
|
||
throw new BadRequestException("Ce parent n'a pas de numéro de dossier.");
|
||
}
|
||
|
||
const sameDossierCount = await this.parentsRepo.count({
|
||
where: { numero_dossier: numeroDossier },
|
||
});
|
||
if (sameDossierCount >= 2) {
|
||
throw new BadRequestException('Ce dossier a déjà deux responsables.');
|
||
}
|
||
|
||
const email = dto.email.trim().toLowerCase();
|
||
if (pivotParent.user.email.trim().toLowerCase() === email) {
|
||
throw new BadRequestException(
|
||
"L'email du co-parent doit être différent de celui du parent principal.",
|
||
);
|
||
}
|
||
|
||
const emailExiste = await this.usersService.findByEmailOrNull(dto.email);
|
||
if (emailExiste) {
|
||
throw new ConflictException("L'email du co-parent est déjà utilisé");
|
||
}
|
||
|
||
const memeAdresse = dto.meme_adresse ?? true;
|
||
if (!memeAdresse) {
|
||
if (!dto.adresse?.trim() || !dto.ville?.trim() || !dto.code_postal?.trim()) {
|
||
throw new BadRequestException(
|
||
"Adresse, code postal et ville du co-parent sont requis si meme_adresse est faux.",
|
||
);
|
||
}
|
||
}
|
||
|
||
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 coParent: Users;
|
||
|
||
try {
|
||
coParent = await this.usersRepo.manager.transaction(async (manager) => {
|
||
const pivotUser = await manager.findOne(Users, {
|
||
where: { id: pivotUserId },
|
||
});
|
||
if (!pivotUser) {
|
||
throw new NotFoundException('Parent introuvable');
|
||
}
|
||
|
||
const pivotEntite = await manager.findOne(Parents, {
|
||
where: { user_id: pivotUserId },
|
||
relations: ['parentChildren'],
|
||
});
|
||
if (!pivotEntite) {
|
||
throw new NotFoundException('Parent introuvable');
|
||
}
|
||
|
||
const coUser = manager.create(Users, {
|
||
email: dto.email.trim(),
|
||
prenom: dto.prenom,
|
||
nom: dto.nom,
|
||
role: RoleType.PARENT,
|
||
statut: StatutUtilisateurType.ACTIF,
|
||
telephone: dto.telephone,
|
||
adresse: memeAdresse ? pivotUser.adresse : dto.adresse,
|
||
code_postal: memeAdresse ? pivotUser.code_postal : dto.code_postal,
|
||
ville: memeAdresse ? pivotUser.ville : dto.ville,
|
||
token_creation_mdp: tokenCreationMdp,
|
||
token_creation_mdp_expire_le: dateExpiration,
|
||
numero_dossier: numeroDossier,
|
||
});
|
||
const coUserSaved = await manager.save(Users, coUser);
|
||
|
||
pivotEntite.co_parent = coUserSaved;
|
||
pivotEntite.numero_dossier = numeroDossier;
|
||
await manager.save(Parents, pivotEntite);
|
||
|
||
const coEntite = manager.create(Parents, {
|
||
user_id: coUserSaved.id,
|
||
numero_dossier: numeroDossier,
|
||
});
|
||
coEntite.user = coUserSaved;
|
||
coEntite.co_parent = pivotUser;
|
||
await manager.save(Parents, coEntite);
|
||
|
||
const enfantIds = (pivotEntite.parentChildren ?? [])
|
||
.map((pc) => pc.enfantId)
|
||
.filter(Boolean);
|
||
for (const enfantId of enfantIds) {
|
||
const existing = await manager.findOne(ParentsChildren, {
|
||
where: { parentId: coUserSaved.id, enfantId },
|
||
});
|
||
if (existing) continue;
|
||
await manager.save(
|
||
ParentsChildren,
|
||
manager.create(ParentsChildren, {
|
||
parentId: coUserSaved.id,
|
||
enfantId,
|
||
}),
|
||
);
|
||
}
|
||
|
||
return coUserSaved;
|
||
});
|
||
} catch (err) {
|
||
if (this.isPostgresUniqueViolation(err)) {
|
||
throw new ConflictException(
|
||
'Un compte avec cet email existe déjà (contrainte unique en base).',
|
||
);
|
||
}
|
||
throw err;
|
||
}
|
||
|
||
try {
|
||
await this.mailService.sendValidatedAccountPasswordSetupEmail(
|
||
{
|
||
email: coParent.email,
|
||
prenom: coParent.prenom ?? '',
|
||
nom: coParent.nom ?? '',
|
||
token: tokenCreationMdp,
|
||
numeroDossier,
|
||
},
|
||
'parent',
|
||
);
|
||
} catch (err) {
|
||
this.logger.error(
|
||
'[addCoParentStaff] Échec envoi email création MDP (co-parent conservé)',
|
||
err instanceof Error ? err.stack : String(err),
|
||
);
|
||
}
|
||
|
||
return {
|
||
message:
|
||
'Co-parent ajouté au foyer. Un e-mail de création de mot de passe a été envoyé.',
|
||
numero_dossier: numeroDossier,
|
||
parent_user_id: pivotUserId,
|
||
co_parent_user_id: coParent.id,
|
||
statut: StatutUtilisateurType.ACTIF,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Cœur partagé création dossier AM (#156).
|
||
* - public : statut en_attente + mail pending
|
||
* - staff : statut actif + mail création MDP
|
||
*/
|
||
async createAmDossier(
|
||
dto: RegisterAMCompletDto,
|
||
options: {
|
||
statut: StatutUtilisateurType;
|
||
sendPendingEmail: boolean;
|
||
sendPasswordSetupEmail: boolean;
|
||
requireCgu: boolean;
|
||
logContext?: string;
|
||
},
|
||
): Promise<{
|
||
message: string;
|
||
user_id: string;
|
||
statut: StatutUtilisateurType;
|
||
numero_dossier: string;
|
||
}> {
|
||
const logCtx = options.logContext ?? 'createAmDossier';
|
||
|
||
if (options.requireCgu && (!dto.acceptation_cgu || !dto.acceptation_privacy)) {
|
||
throw new BadRequestException(
|
||
"L'acceptation des CGU et de la politique de confidentialité est obligatoire",
|
||
);
|
||
}
|
||
|
||
if (dto.places_disponibles > dto.capacite_accueil) {
|
||
throw new BadRequestException(
|
||
'Le nombre de places disponibles ne peut pas dépasser la capacité d\'accueil.',
|
||
);
|
||
}
|
||
|
||
const nirNormalized = (dto.nir || '').replace(/\s/g, '').toUpperCase();
|
||
const nirValidation = validateNir(nirNormalized, {
|
||
dateNaissance: dto.date_naissance,
|
||
});
|
||
if (!nirValidation.valid) {
|
||
throw new BadRequestException(nirValidation.error || 'NIR invalide');
|
||
}
|
||
if (nirValidation.warning) {
|
||
console.warn(`[${logCtx}] NIR warning:`, nirValidation.warning, 'email=', dto.email);
|
||
}
|
||
|
||
const existe = await this.usersService.findByEmailOrNull(dto.email);
|
||
if (existe) {
|
||
throw new ConflictException('Un compte avec cet email existe déjà');
|
||
}
|
||
|
||
const nirDejaUtilise = await this.assistantesMaternellesRepo.findOne({
|
||
where: { nir: nirNormalized },
|
||
});
|
||
if (nirDejaUtilise) {
|
||
throw new ConflictException(
|
||
'Un compte assistante maternelle avec ce numéro NIR existe déjà.',
|
||
);
|
||
}
|
||
|
||
const numeroAgrement = (dto.numero_agrement || '').trim();
|
||
if (numeroAgrement) {
|
||
const agrementDejaUtilise = await this.assistantesMaternellesRepo.findOne({
|
||
where: { approval_number: numeroAgrement },
|
||
});
|
||
if (agrementDejaUtilise) {
|
||
throw new ConflictException(
|
||
'Un compte assistante maternelle avec ce numéro d\'agrément 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;
|
||
const photoB64 = (dto.photo_base64 ?? '').trim();
|
||
if (photoB64) {
|
||
const nomFichier =
|
||
(dto.photo_filename ?? '').trim() || 'photo_am.jpg';
|
||
urlPhoto = await this.sauvegarderPhotoDepuisBase64(photoB64, nomFichier);
|
||
}
|
||
|
||
const dateConsentementPhoto =
|
||
dto.consentement_photo ? new Date() : undefined;
|
||
|
||
let resultat: { user: Users };
|
||
try {
|
||
resultat = await this.usersRepo.manager.transaction(async (manager) => {
|
||
const { numero: numeroDossier } =
|
||
await this.numeroDossierService.getNextNumeroDossier(manager);
|
||
|
||
const user = manager.create(Users, {
|
||
email: dto.email,
|
||
prenom: dto.prenom,
|
||
nom: dto.nom,
|
||
role: RoleType.ASSISTANTE_MATERNELLE,
|
||
statut: options.statut,
|
||
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,
|
||
lieu_naissance_ville: dto.lieu_naissance_ville,
|
||
lieu_naissance_pays: dto.lieu_naissance_pays,
|
||
numero_dossier: numeroDossier,
|
||
});
|
||
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: nirNormalized,
|
||
max_children: dto.capacite_accueil,
|
||
places_available: dto.places_disponibles,
|
||
biography: dto.biographie,
|
||
residence_city: dto.ville ?? undefined,
|
||
agreement_date: dto.date_agrement
|
||
? new Date(dto.date_agrement)
|
||
: undefined,
|
||
available: true,
|
||
numero_dossier: numeroDossier,
|
||
});
|
||
await amRepo.save(am);
|
||
|
||
return { user: userEnregistre };
|
||
});
|
||
} catch (err) {
|
||
if (this.isPostgresUniqueViolation(err)) {
|
||
throw new ConflictException(
|
||
'Un compte avec cet email existe déjà (contrainte unique en base).',
|
||
);
|
||
}
|
||
throw err;
|
||
}
|
||
|
||
const numeroDossier = resultat.user.numero_dossier ?? '';
|
||
|
||
if (options.sendPendingEmail) {
|
||
try {
|
||
await this.mailService.sendRegistrationPendingEmail(
|
||
resultat.user.email,
|
||
resultat.user.prenom ?? '',
|
||
resultat.user.nom ?? '',
|
||
numeroDossier,
|
||
);
|
||
} catch (err) {
|
||
this.logger.error(
|
||
`[${logCtx}] Échec envoi email d'accusé de réception (inscription conservée)`,
|
||
err instanceof Error ? err.stack : String(err),
|
||
);
|
||
}
|
||
}
|
||
|
||
if (options.sendPasswordSetupEmail) {
|
||
try {
|
||
await this.mailService.sendValidatedAccountPasswordSetupEmail(
|
||
{
|
||
email: resultat.user.email,
|
||
prenom: resultat.user.prenom ?? '',
|
||
nom: resultat.user.nom ?? '',
|
||
token: tokenCreationMdp,
|
||
numeroDossier,
|
||
},
|
||
'am',
|
||
);
|
||
} catch (err) {
|
||
this.logger.error(
|
||
`[${logCtx}] Échec envoi email création MDP (dossier conservé)`,
|
||
err instanceof Error ? err.stack : String(err),
|
||
);
|
||
}
|
||
}
|
||
|
||
const message =
|
||
options.statut === StatutUtilisateurType.ACTIF
|
||
? 'Dossier AM créé et validé. Un e-mail de création de mot de passe a été envoyé.'
|
||
: 'Inscription réussie. Votre dossier est en attente de validation par un gestionnaire.';
|
||
|
||
return {
|
||
message,
|
||
user_id: resultat.user.id,
|
||
statut: options.statut,
|
||
numero_dossier: numeroDossier,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 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) {
|
||
return this.createAmDossier(dto, {
|
||
statut: StatutUtilisateurType.EN_ATTENTE,
|
||
sendPendingEmail: true,
|
||
sendPasswordSetupEmail: false,
|
||
requireCgu: true,
|
||
logContext: 'inscrireAMComplet',
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Création dossier AM par staff (#156) — statut actif + e-mail création MDP.
|
||
*/
|
||
async createAmDossierStaff(dto: RegisterAMCompletDto) {
|
||
return this.createAmDossier(dto, {
|
||
statut: StatutUtilisateurType.ACTIF,
|
||
sendPendingEmail: false,
|
||
sendPasswordSetupEmail: true,
|
||
requireCgu: false,
|
||
logContext: 'createAmDossierStaff',
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Sauvegarde une photo depuis base64 vers le système de fichiers
|
||
*/
|
||
/**
|
||
* Sauvegarde une photo depuis base64 vers le système de fichiers
|
||
*/
|
||
private async sauvegarderPhotoDepuisBase64(donneesBase64: string, nomFichier: string): Promise<string> {
|
||
const correspondances = donneesBase64.match(/^data:image\/(\w+);base64,(.+)$/);
|
||
if (!correspondances) {
|
||
throw new BadRequestException('Format de photo invalide (doit être base64)');
|
||
}
|
||
|
||
const extension = correspondances[1];
|
||
const tamponImage = Buffer.from(correspondances[2], 'base64');
|
||
|
||
const rawDir = process.env.UPLOAD_PHOTOS_DIR?.trim();
|
||
const dossierUpload = rawDir
|
||
? path.isAbsolute(rawDir)
|
||
? rawDir
|
||
: path.resolve(process.cwd(), rawDir)
|
||
: path.join(process.cwd(), 'uploads', 'photos');
|
||
await fs.mkdir(dossierUpload, { recursive: true });
|
||
|
||
const nomFichierUnique = `${Date.now()}-${crypto.randomUUID()}.${extension}`;
|
||
const cheminFichier = path.join(dossierUpload, nomFichierUnique);
|
||
|
||
await fs.writeFile(cheminFichier, tamponImage);
|
||
|
||
return `/uploads/photos/${nomFichierUnique}`;
|
||
}
|
||
|
||
/**
|
||
* Changement de mot de passe obligatoire (première connexion)
|
||
*/
|
||
async changePasswordRequired(
|
||
userId: string,
|
||
motDePasseActuel: string,
|
||
nouveauMotDePasse: string,
|
||
) {
|
||
const user = await this.usersRepo.findOne({ where: { id: userId } });
|
||
|
||
if (!user) {
|
||
throw new UnauthorizedException('Utilisateur introuvable');
|
||
}
|
||
|
||
// Vérifier que le changement est bien obligatoire
|
||
if (!user.changement_mdp_obligatoire) {
|
||
throw new BadRequestException(
|
||
'Le changement de mot de passe n\'est pas requis pour cet utilisateur',
|
||
);
|
||
}
|
||
|
||
// Vérifier que l'utilisateur a un mot de passe
|
||
if (!user.password) {
|
||
throw new BadRequestException('Compte non activé');
|
||
}
|
||
|
||
// Vérifier le mot de passe actuel
|
||
const motDePasseValide = await bcrypt.compare(motDePasseActuel, user.password);
|
||
if (!motDePasseValide) {
|
||
throw new BadRequestException('Mot de passe actuel incorrect');
|
||
}
|
||
|
||
// Vérifier que le nouveau mot de passe est différent de l'ancien
|
||
const memeMotDePasse = await bcrypt.compare(nouveauMotDePasse, user.password);
|
||
if (memeMotDePasse) {
|
||
throw new BadRequestException(
|
||
'Le nouveau mot de passe doit être différent de l\'ancien',
|
||
);
|
||
}
|
||
|
||
// Hasher et sauvegarder le nouveau mot de passe
|
||
const sel = await bcrypt.genSalt(12);
|
||
user.password = await bcrypt.hash(nouveauMotDePasse, sel);
|
||
user.changement_mdp_obligatoire = false;
|
||
user.modifie_le = new Date();
|
||
|
||
await this.usersRepo.save(user);
|
||
|
||
return {
|
||
success: true,
|
||
message: 'Mot de passe changé avec succès',
|
||
};
|
||
}
|
||
|
||
async logout(userId: string) {
|
||
return { success: true, message: 'Deconnexion'}
|
||
}
|
||
|
||
/** GET dossier reprise – token seul. Ticket #111, enrichi #112 */
|
||
async getRepriseDossier(token: string): Promise<RepriseDossierDto> {
|
||
const user = await this.usersService.findByTokenReprise(token);
|
||
if (!user) {
|
||
throw new NotFoundException('Token reprise invalide ou expiré.');
|
||
}
|
||
|
||
const base = this.userToRepriseBase(user);
|
||
|
||
if (user.role === RoleType.PARENT && user.numero_dossier) {
|
||
try {
|
||
const famille = await this.parentsService.getDossierFamilleByNumero(user.numero_dossier);
|
||
return {
|
||
...base,
|
||
parents: famille.parents,
|
||
enfants: famille.enfants,
|
||
texte_motivation: famille.texte_motivation,
|
||
};
|
||
} catch (err) {
|
||
if (err instanceof NotFoundException) {
|
||
return base;
|
||
}
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
if (user.role === RoleType.ASSISTANTE_MATERNELLE && user.numero_dossier) {
|
||
try {
|
||
const unifie = await this.dossiersService.getDossierByNumero(user.numero_dossier);
|
||
if (unifie.type === 'am') {
|
||
return this.mergeAmRepriseDossier(base, unifie.dossier as DossierAmCompletDto);
|
||
}
|
||
} catch (err) {
|
||
if (err instanceof NotFoundException) {
|
||
return base;
|
||
}
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
return base;
|
||
}
|
||
|
||
/** PATCH resoumission reprise – dossier complet. Ticket #111, enrichi #112 */
|
||
async resoumettreReprise(dto: ResoumettreRepriseDto): Promise<{
|
||
message: string;
|
||
statut: StatutUtilisateurType;
|
||
user_id: string;
|
||
numero_dossier?: string;
|
||
}> {
|
||
const user = await this.usersService.findByTokenReprise(dto.token);
|
||
if (!user) {
|
||
throw new NotFoundException('Token reprise invalide ou expiré.');
|
||
}
|
||
|
||
if (user.role === RoleType.PARENT) {
|
||
return this.resoumettreRepriseParent(user, dto);
|
||
}
|
||
if (user.role === RoleType.ASSISTANTE_MATERNELLE) {
|
||
return this.resoumettreRepriseAM(user, dto);
|
||
}
|
||
|
||
throw new BadRequestException('Rôle non pris en charge pour la reprise.');
|
||
}
|
||
|
||
private userToRepriseBase(user: Users): RepriseDossierDto {
|
||
return {
|
||
id: user.id,
|
||
email: user.email,
|
||
prenom: user.prenom,
|
||
nom: user.nom,
|
||
telephone: user.telephone,
|
||
adresse: user.adresse,
|
||
ville: user.ville,
|
||
code_postal: user.code_postal,
|
||
numero_dossier: user.numero_dossier,
|
||
role: user.role,
|
||
photo_url: user.photo_url,
|
||
genre: user.genre,
|
||
situation_familiale: user.situation_familiale,
|
||
};
|
||
}
|
||
|
||
private mergeAmRepriseDossier(base: RepriseDossierDto, dossier: DossierAmCompletDto): RepriseDossierDto {
|
||
const u = dossier.user;
|
||
return {
|
||
...base,
|
||
email: u.email,
|
||
prenom: u.prenom,
|
||
nom: u.nom,
|
||
telephone: u.telephone,
|
||
adresse: u.adresse,
|
||
ville: u.ville,
|
||
code_postal: u.code_postal,
|
||
photo_url: u.photo_url,
|
||
consentement_photo: u.consentement_photo,
|
||
date_naissance: u.date_naissance,
|
||
lieu_naissance_ville: u.lieu_naissance_ville,
|
||
lieu_naissance_pays: u.lieu_naissance_pays,
|
||
numero_agrement: dossier.numero_agrement,
|
||
nir: dossier.nir,
|
||
date_agrement: dossier.date_agrement,
|
||
nb_max_enfants: dossier.nb_max_enfants,
|
||
place_disponible: dossier.place_disponible,
|
||
biographie: dossier.biographie,
|
||
};
|
||
}
|
||
|
||
private applyTitulaireUserFields(user: Users, dto: ResoumettreRepriseDto): void {
|
||
if (dto.prenom !== undefined) user.prenom = dto.prenom;
|
||
if (dto.nom !== undefined) user.nom = dto.nom;
|
||
if (dto.telephone !== undefined) user.telephone = dto.telephone;
|
||
if (dto.adresse !== undefined) user.adresse = dto.adresse;
|
||
if (dto.ville !== undefined) user.ville = dto.ville;
|
||
if (dto.code_postal !== undefined) user.code_postal = dto.code_postal;
|
||
}
|
||
|
||
private async resoumettreRepriseParent(
|
||
titulaire: Users,
|
||
dto: ResoumettreRepriseDto,
|
||
): Promise<{ message: string; statut: StatutUtilisateurType; user_id: string; numero_dossier?: string }> {
|
||
const motivation =
|
||
dto.texte_motivation?.trim() ??
|
||
dto.presentation_dossier?.trim();
|
||
|
||
const result = await this.usersRepo.manager.transaction(async (manager) => {
|
||
const familyUsers = titulaire.numero_dossier
|
||
? await manager.find(Users, {
|
||
where: {
|
||
role: RoleType.PARENT,
|
||
numero_dossier: titulaire.numero_dossier,
|
||
},
|
||
})
|
||
: [titulaire];
|
||
|
||
const titulaireLive = familyUsers.find((u) => u.id === titulaire.id) ?? titulaire;
|
||
this.applyTitulaireUserFields(titulaireLive, dto);
|
||
|
||
const coParentUser = familyUsers.find((u) => u.id !== titulaire.id);
|
||
if (coParentUser) {
|
||
if (dto.co_parent_prenom !== undefined) coParentUser.prenom = dto.co_parent_prenom;
|
||
if (dto.co_parent_nom !== undefined) coParentUser.nom = dto.co_parent_nom;
|
||
if (dto.co_parent_telephone !== undefined) coParentUser.telephone = dto.co_parent_telephone;
|
||
|
||
if (dto.co_parent_meme_adresse) {
|
||
coParentUser.adresse = titulaireLive.adresse;
|
||
coParentUser.code_postal = titulaireLive.code_postal;
|
||
coParentUser.ville = titulaireLive.ville;
|
||
} else {
|
||
if (dto.co_parent_adresse !== undefined) coParentUser.adresse = dto.co_parent_adresse;
|
||
if (dto.co_parent_code_postal !== undefined) coParentUser.code_postal = dto.co_parent_code_postal;
|
||
if (dto.co_parent_ville !== undefined) coParentUser.ville = dto.co_parent_ville;
|
||
}
|
||
}
|
||
|
||
if (titulaire.numero_dossier && motivation !== undefined) {
|
||
const dossierFamille = await manager.findOne(DossierFamille, {
|
||
where: { numero_dossier: titulaire.numero_dossier },
|
||
});
|
||
if (dossierFamille) {
|
||
dossierFamille.presentation = motivation || undefined;
|
||
await manager.save(DossierFamille, dossierFamille);
|
||
}
|
||
}
|
||
|
||
if (dto.enfants?.length) {
|
||
const familyChildIds = await this.getFamilyChildrenIds(manager, titulaireLive.id);
|
||
for (const enfantDto of dto.enfants) {
|
||
if (!familyChildIds.has(enfantDto.id)) {
|
||
throw new BadRequestException(
|
||
`Enfant inconnu pour ce dossier : ${enfantDto.id}`,
|
||
);
|
||
}
|
||
|
||
const enfant = await manager.findOne(Children, { where: { id: enfantDto.id } });
|
||
if (!enfant) {
|
||
throw new BadRequestException(`Enfant introuvable : ${enfantDto.id}`);
|
||
}
|
||
|
||
if (enfantDto.prenom !== undefined) enfant.first_name = enfantDto.prenom;
|
||
if (enfantDto.nom !== undefined) enfant.last_name = enfantDto.nom;
|
||
if (enfantDto.genre !== undefined) enfant.gender = enfantDto.genre;
|
||
if (enfantDto.date_naissance !== undefined) {
|
||
enfant.birth_date = new Date(enfantDto.date_naissance);
|
||
enfant.status = StatutEnfantType.SANS_GARDE;
|
||
}
|
||
if (enfantDto.date_previsionnelle_naissance !== undefined) {
|
||
enfant.due_date = new Date(enfantDto.date_previsionnelle_naissance);
|
||
if (!enfantDto.date_naissance) {
|
||
enfant.status = StatutEnfantType.A_NAITRE;
|
||
}
|
||
}
|
||
if (enfantDto.consent_photo !== undefined) {
|
||
enfant.consent_photo = !!enfantDto.consent_photo;
|
||
enfant.consent_photo_at = enfant.consent_photo
|
||
? new Date()
|
||
: null!;
|
||
}
|
||
|
||
if (enfantDto.photo_base64 && enfantDto.photo_filename) {
|
||
enfant.photo_url = await this.sauvegarderPhotoDepuisBase64(
|
||
enfantDto.photo_base64,
|
||
enfantDto.photo_filename,
|
||
);
|
||
}
|
||
|
||
await manager.save(Children, enfant);
|
||
}
|
||
}
|
||
|
||
for (const familyUser of familyUsers) {
|
||
familyUser.statut = StatutUtilisateurType.EN_ATTENTE;
|
||
familyUser.token_reprise = undefined;
|
||
familyUser.token_reprise_expire_le = undefined;
|
||
await manager.save(Users, familyUser);
|
||
}
|
||
|
||
return {
|
||
message: 'Dossier resoumis avec succès. Il est de nouveau en attente de validation.',
|
||
statut: StatutUtilisateurType.EN_ATTENTE,
|
||
user_id: titulaireLive.id,
|
||
numero_dossier: titulaireLive.numero_dossier,
|
||
parentsPourEmail: familyUsers,
|
||
};
|
||
});
|
||
|
||
const numeroDossier = result.numero_dossier ?? '';
|
||
try {
|
||
for (const parent of result.parentsPourEmail) {
|
||
await this.mailService.sendResoumissionPendingEmail(
|
||
parent.email,
|
||
parent.prenom ?? '',
|
||
parent.nom ?? '',
|
||
numeroDossier,
|
||
);
|
||
}
|
||
} catch (err) {
|
||
this.logger.error(
|
||
"[resoumettreRepriseParent] Échec envoi email accusé resoumission (dossier conservé)",
|
||
err instanceof Error ? err.stack : String(err),
|
||
);
|
||
}
|
||
|
||
const { parentsPourEmail: _parentsPourEmail, ...response } = result;
|
||
return response;
|
||
}
|
||
|
||
private async resoumettreRepriseAM(
|
||
user: Users,
|
||
dto: ResoumettreRepriseDto,
|
||
): Promise<{ message: string; statut: StatutUtilisateurType; user_id: string; numero_dossier?: string }> {
|
||
if (
|
||
dto.capacite_accueil !== undefined &&
|
||
dto.places_disponibles !== undefined &&
|
||
dto.places_disponibles > dto.capacite_accueil
|
||
) {
|
||
throw new BadRequestException(
|
||
'Le nombre de places disponibles ne peut pas dépasser la capacité d\'accueil.',
|
||
);
|
||
}
|
||
|
||
let urlPhoto: string | undefined;
|
||
const photoB64 = (dto.photo_base64 ?? '').trim();
|
||
if (photoB64) {
|
||
const nomFichier = (dto.photo_filename ?? '').trim() || 'photo_am.jpg';
|
||
urlPhoto = await this.sauvegarderPhotoDepuisBase64(photoB64, nomFichier);
|
||
} else if (dto.photo_url !== undefined) {
|
||
urlPhoto = dto.photo_url;
|
||
}
|
||
|
||
if (dto.nir !== undefined) {
|
||
const nirNormalized = dto.nir.replace(/\s/g, '').toUpperCase();
|
||
const nirValidation = validateNir(nirNormalized, {
|
||
dateNaissance: dto.date_naissance ?? user.date_naissance?.toISOString().slice(0, 10),
|
||
});
|
||
if (!nirValidation.valid) {
|
||
throw new BadRequestException(nirValidation.error || 'NIR invalide');
|
||
}
|
||
}
|
||
|
||
return this.usersRepo.manager.transaction(async (manager) => {
|
||
const userLive = await manager.findOne(Users, { where: { id: user.id } });
|
||
if (!userLive) {
|
||
throw new NotFoundException('Utilisateur introuvable.');
|
||
}
|
||
|
||
this.applyTitulaireUserFields(userLive, dto);
|
||
if (urlPhoto !== undefined) userLive.photo_url = urlPhoto;
|
||
if (dto.consentement_photo !== undefined) {
|
||
userLive.consentement_photo = dto.consentement_photo;
|
||
if (dto.consentement_photo) {
|
||
userLive.date_consentement_photo = new Date();
|
||
}
|
||
}
|
||
if (dto.date_naissance !== undefined) {
|
||
userLive.date_naissance = new Date(dto.date_naissance);
|
||
}
|
||
if (dto.lieu_naissance_ville !== undefined) {
|
||
userLive.lieu_naissance_ville = dto.lieu_naissance_ville;
|
||
}
|
||
if (dto.lieu_naissance_pays !== undefined) {
|
||
userLive.lieu_naissance_pays = dto.lieu_naissance_pays;
|
||
}
|
||
|
||
const am = await manager.findOne(AssistanteMaternelle, { where: { user_id: user.id } });
|
||
if (!am) {
|
||
throw new NotFoundException('Fiche assistante maternelle introuvable.');
|
||
}
|
||
|
||
if (dto.nir !== undefined) {
|
||
const nirNormalized = dto.nir.replace(/\s/g, '').toUpperCase();
|
||
const nirDejaUtilise = await manager.findOne(AssistanteMaternelle, {
|
||
where: { nir: nirNormalized },
|
||
});
|
||
if (nirDejaUtilise && nirDejaUtilise.user_id !== user.id) {
|
||
throw new ConflictException(
|
||
'Un compte assistante maternelle avec ce numéro NIR existe déjà.',
|
||
);
|
||
}
|
||
am.nir = nirNormalized;
|
||
}
|
||
if (dto.numero_agrement !== undefined) {
|
||
const agrement = dto.numero_agrement.trim();
|
||
const agrementDejaUtilise = await manager.findOne(AssistanteMaternelle, {
|
||
where: { approval_number: agrement },
|
||
});
|
||
if (agrementDejaUtilise && agrementDejaUtilise.user_id !== user.id) {
|
||
throw new ConflictException(
|
||
'Un compte assistante maternelle avec ce numéro d\'agrément existe déjà.',
|
||
);
|
||
}
|
||
am.approval_number = agrement;
|
||
}
|
||
if (dto.date_agrement !== undefined) {
|
||
am.agreement_date = new Date(dto.date_agrement);
|
||
}
|
||
if (dto.capacite_accueil !== undefined) {
|
||
am.max_children = dto.capacite_accueil;
|
||
}
|
||
if (dto.places_disponibles !== undefined) {
|
||
am.places_available = dto.places_disponibles;
|
||
}
|
||
if (dto.biographie !== undefined) {
|
||
am.biography = dto.biographie;
|
||
}
|
||
|
||
userLive.statut = StatutUtilisateurType.EN_ATTENTE;
|
||
userLive.token_reprise = undefined;
|
||
userLive.token_reprise_expire_le = undefined;
|
||
|
||
await manager.save(AssistanteMaternelle, am);
|
||
await manager.save(Users, userLive);
|
||
|
||
return {
|
||
message: 'Dossier resoumis avec succès. Il est de nouveau en attente de validation.',
|
||
statut: StatutUtilisateurType.EN_ATTENTE,
|
||
user_id: userLive.id,
|
||
numero_dossier: userLive.numero_dossier,
|
||
};
|
||
});
|
||
}
|
||
|
||
private async getFamilyChildrenIds(
|
||
manager: EntityManager,
|
||
parentUserId: string,
|
||
): Promise<Set<string>> {
|
||
const familyUserIds = await this.parentsService.getFamilyUserIds(parentUserId);
|
||
const links = await manager.find(ParentsChildren, {
|
||
where: { parentId: In(familyUserIds) },
|
||
});
|
||
return new Set(links.map((l) => l.enfantId));
|
||
}
|
||
|
||
/** POST reprise-identify : numero_dossier + email → type + token. Ticket #111 */
|
||
async identifyReprise(numero_dossier: string, email: string): Promise<RepriseIdentifyResponseDto> {
|
||
const user = await this.usersService.findByNumeroDossierAndEmailForReprise(numero_dossier, email);
|
||
if (!user || !user.token_reprise) {
|
||
throw new NotFoundException('Aucun dossier en reprise trouvé pour ce numéro et cet email.');
|
||
}
|
||
return {
|
||
type: user.role === RoleType.PARENT ? 'parent' : 'assistante_maternelle',
|
||
token: user.token_reprise,
|
||
};
|
||
}
|
||
|
||
/** Violation unique PostgreSQL (ex. email déjà présent malgré course entre requêtes). */
|
||
private isPostgresUniqueViolation(err: unknown): boolean {
|
||
if (!(err instanceof QueryFailedError)) {
|
||
return false;
|
||
}
|
||
const code = (err.driverError as { code?: string } | undefined)?.code;
|
||
return code === '23505';
|
||
}
|
||
}
|