petitspas/backend/src/routes/user/user.service.ts

456 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { RoleType, StatutUtilisateurType, Users } from "src/entities/users.entity";
import { In, MoreThan, Repository } from "typeorm";
import { CreateUserDto } from "./dto/create_user.dto";
import { CreateAdminDto } from "./dto/create_admin.dto";
import { UpdateUserDto } from "./dto/update_user.dto";
import * as bcrypt from 'bcrypt';
import { StatutValidationType, Validation } from "src/entities/validations.entity";
import { Parents } from "src/entities/parents.entity";
import { AssistanteMaternelle } from "src/entities/assistantes_maternelles.entity";
import { MailService } from "src/modules/mail/mail.service";
import * as crypto from 'crypto';
@Injectable()
export class UserService {
private readonly logger = new Logger(UserService.name);
constructor(
@InjectRepository(Users)
private readonly usersRepository: Repository<Users>,
@InjectRepository(Validation)
private readonly validationRepository: Repository<Validation>,
@InjectRepository(Parents)
private readonly parentsRepository: Repository<Parents>,
@InjectRepository(AssistanteMaternelle)
private readonly assistantesRepository: Repository<AssistanteMaternelle>,
private readonly mailService: MailService,
) { }
async createUser(dto: CreateUserDto, currentUser?: Users): Promise<Users> {
if (!dto.cguAccepted) {
throw new BadRequestException(
'Vous devez accepter les CGU et la Politique de confidentialité pour créer un compte.',
);
}
const exist = await this.usersRepository.findOneBy({ email: dto.email });
if (exist) throw new BadRequestException('Email déjà utilisé');
const isSuperAdmin = currentUser?.role === RoleType.SUPER_ADMIN;
const isAdmin = currentUser?.role === RoleType.ADMINISTRATEUR;
let role: RoleType;
if (dto.role === RoleType.GESTIONNAIRE) {
if (!isAdmin && !isSuperAdmin) {
throw new ForbiddenException('Seuls les administrateurs peuvent créer un gestionnaire');
}
role = RoleType.GESTIONNAIRE;
} else if (dto.role === RoleType.ADMINISTRATEUR) {
if (!isAdmin && !isSuperAdmin) {
throw new ForbiddenException('Seuls les administrateurs peuvent créer un administrateur');
}
role = RoleType.ADMINISTRATEUR;
} else if (dto.role === RoleType.ASSISTANTE_MATERNELLE) {
role = RoleType.ASSISTANTE_MATERNELLE;
if (!dto.photo_url) {
throw new BadRequestException(
'La photo de profil est obligatoire pour les assistantes maternelles.',
);
}
} else {
role = RoleType.PARENT;
}
const statut = isSuperAdmin
? dto.statut ?? StatutUtilisateurType.EN_ATTENTE
: StatutUtilisateurType.EN_ATTENTE;
if (!dto.nom?.trim()) throw new BadRequestException('Nom est obligatoire.');
if (!dto.prenom?.trim()) throw new BadRequestException('Prénom est obligatoire.');
if (!dto.adresse?.trim()) throw new BadRequestException('Adresse est obligatoire.');
if (!dto.telephone?.trim()) throw new BadRequestException('Téléphone est obligatoire.');
let consentDate: Date | undefined;
if (dto.consentement_photo && dto.date_consentement_photo) {
const parsed = new Date(dto.date_consentement_photo);
if (!isNaN(parsed.getTime())) {
consentDate = parsed;
}
}
const salt = await bcrypt.genSalt();
const hashedPassword = await bcrypt.hash(dto.password, salt);
const entity = this.usersRepository.create({
email: dto.email,
password: hashedPassword,
prenom: dto.prenom,
nom: dto.nom,
role,
statut,
genre: dto.genre,
telephone: dto.telephone,
ville: dto.ville,
code_postal: dto.code_postal,
adresse: dto.adresse,
photo_url: dto.photo_url,
consentement_photo: dto.consentement_photo ?? false,
date_consentement_photo: consentDate,
changement_mdp_obligatoire:
role === RoleType.ADMINISTRATEUR || role === RoleType.GESTIONNAIRE
? true
: dto.changement_mdp_obligatoire ?? false,
});
const saved = await this.usersRepository.save(entity);
return this.findOne(saved.id);
}
async createAdmin(dto: CreateAdminDto, currentUser: Users): Promise<Users> {
if (currentUser.role !== RoleType.SUPER_ADMIN) {
throw new ForbiddenException('Seuls les super administrateurs peuvent créer un administrateur');
}
const exist = await this.usersRepository.findOneBy({ email: dto.email });
if (exist) throw new BadRequestException('Email déjà utilisé');
const salt = await bcrypt.genSalt();
const hashedPassword = await bcrypt.hash(dto.password, salt);
const entity = this.usersRepository.create({
email: dto.email,
password: hashedPassword,
prenom: dto.prenom,
nom: dto.nom,
role: RoleType.ADMINISTRATEUR,
statut: StatutUtilisateurType.ACTIF,
telephone: dto.telephone,
changement_mdp_obligatoire: true,
});
return this.usersRepository.save(entity);
}
async findPendingUsers(role?: RoleType): Promise<Users[]> {
const where: any = { statut: StatutUtilisateurType.EN_ATTENTE };
if (role) {
where.role = role;
}
return this.usersRepository.find({ where });
}
/** Comptes refusés (à corriger) : liste pour reprise par le gestionnaire */
async findRefusedUsers(role?: RoleType): Promise<Users[]> {
const where: any = { statut: StatutUtilisateurType.REFUSE };
if (role) {
where.role = role;
}
return this.usersRepository.find({ where });
}
async findAll(): Promise<Users[]> {
return this.usersRepository.find();
}
async findOneBy(where: Partial<Users>): Promise<Users | null> {
return this.usersRepository.findOne({ where });
}
async findOne(id: string): Promise<Users> {
const user = await this.usersRepository.findOne({ where: { id } });
if (!user) {
throw new NotFoundException('Utilisateur introuvable');
}
return user;
}
async findByEmailOrNull(email: string): Promise<Users | null> {
return this.usersRepository.findOne({ where: { email } });
}
async updateUser(id: string, dto: UpdateUserDto, currentUser: Users): Promise<Users> {
const user = await this.findOne(id);
// Le super administrateur conserve une identité figée.
if (
user.role === RoleType.SUPER_ADMIN &&
(dto.nom !== undefined || dto.prenom !== undefined)
) {
throw new ForbiddenException(
'Le nom et le prénom du super administrateur ne peuvent pas être modifiés',
);
}
// Interdire changement de rôle si pas super admin
if (dto.role && currentUser.role !== RoleType.SUPER_ADMIN) {
throw new ForbiddenException('Accès réservé aux super admins');
}
// Un admin ne peut pas modifier un super admin
if (currentUser.role === RoleType.ADMINISTRATEUR && user.role === RoleType.SUPER_ADMIN) {
throw new ForbiddenException('Vous ne pouvez pas modifier un super administrateur');
}
// Empêcher de modifier le flag changement_mdp_obligatoire pour admin/gestionnaire
if (
(user.role === RoleType.ADMINISTRATEUR || user.role === RoleType.GESTIONNAIRE) &&
dto.changement_mdp_obligatoire === false
) {
throw new ForbiddenException(
'Impossible de désactiver lobligation de changement de mot de passe pour ce rôle',
);
}
// Gestion du mot de passe
if (dto.password) {
const salt = await bcrypt.genSalt();
user.password = await bcrypt.hash(dto.password, salt);
delete (dto as any).password;
// Une fois le mot de passe changé, on peut lever lobligation
user.changement_mdp_obligatoire = false;
}
// Conversion de la date de consentement
if (dto.date_consentement_photo !== undefined) {
user.date_consentement_photo = dto.date_consentement_photo
? new Date(dto.date_consentement_photo)
: undefined;
delete (dto as any).date_consentement_photo;
}
Object.assign(user, dto);
return this.usersRepository.save(user);
}
// Valider un compte utilisateur (en_attente ou refuse -> actif)
async validateUser(user_id: string, currentUser: Users, comment?: string): Promise<Users> {
if (![RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE].includes(currentUser.role)) {
throw new ForbiddenException('Accès réservé aux super admins, administrateurs et gestionnaires');
}
const user = await this.usersRepository.findOne({ where: { id: user_id } });
if (!user) throw new NotFoundException('Utilisateur introuvable');
if (user.statut !== StatutUtilisateurType.EN_ATTENTE && user.statut !== StatutUtilisateurType.REFUSE) {
throw new BadRequestException('Seuls les comptes en attente ou refusés (à corriger) peuvent être validés.');
}
user.statut = StatutUtilisateurType.ACTIF;
const savedUser = await this.usersRepository.save(user);
if (user.role === RoleType.PARENT) {
const existParent = await this.parentsRepository.findOneBy({ user_id: user.id });
if (!existParent) {
const parentEntity = this.parentsRepository.create({ user_id: user.id, user });
await this.parentsRepository.save(parentEntity);
}
} else if (user.role === RoleType.ASSISTANTE_MATERNELLE) {
const existAssistante = await this.assistantesRepository.findOneBy({ user_id: user.id });
if (!existAssistante) {
const assistanteEntity = this.assistantesRepository.create({ user_id: user.id, user });
await this.assistantesRepository.save(assistanteEntity);
}
}
const validation = this.validationRepository.create({
user: savedUser,
type: 'validation_compte',
status: StatutValidationType.VALIDE,
validated_by: currentUser,
comment,
});
await this.validationRepository.save(validation);
return savedUser;
}
// Mettre un compte en statut suspendu
async suspendUser(user_id: string, currentUser: Users, comment?: string): Promise<Users> {
if (![RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE].includes(currentUser.role)) {
throw new ForbiddenException('Accès réservé aux super admins, administrateurs et gestionnaires');
}
const user = await this.usersRepository.findOne({ where: { id: user_id } });
if (!user) throw new NotFoundException('Utilisateur introuvable');
user.statut = StatutUtilisateurType.SUSPENDU;
const savedUser = await this.usersRepository.save(user);
const suspend = this.validationRepository.create({
user: savedUser,
type: 'suspension_compte',
status: StatutValidationType.VALIDE,
validated_by: currentUser,
comment,
})
await this.validationRepository.save(suspend);
return savedUser;
}
/** Refuser un compte (en_attente -> refuse) ; tracé validations, token reprise, email. Ticket #110 */
async refuseUser(user_id: string, currentUser: Users, comment?: string): Promise<Users> {
if (![RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE].includes(currentUser.role)) {
throw new ForbiddenException('Accès réservé aux super admins, administrateurs et gestionnaires');
}
const user = await this.usersRepository.findOne({ where: { id: user_id } });
if (!user) throw new NotFoundException('Utilisateur introuvable');
if (user.statut !== StatutUtilisateurType.EN_ATTENTE) {
throw new BadRequestException('Seul un compte en attente peut être refusé.');
}
const tokenReprise = crypto.randomUUID();
const expireLe = new Date();
expireLe.setDate(expireLe.getDate() + 7);
user.statut = StatutUtilisateurType.REFUSE;
user.token_reprise = tokenReprise;
user.token_reprise_expire_le = expireLe;
const savedUser = await this.usersRepository.save(user);
const validation = this.validationRepository.create({
user: savedUser,
type: 'refus_compte',
status: StatutValidationType.REFUSE,
validated_by: currentUser,
comment,
});
await this.validationRepository.save(validation);
try {
await this.mailService.sendRefusEmail(
savedUser.email,
savedUser.prenom ?? '',
savedUser.nom ?? '',
comment,
tokenReprise,
);
} catch (err) {
this.logger.warn(`Envoi email refus échoué pour ${savedUser.email}`, err);
}
return savedUser;
}
/**
* Affecter ou modifier le numéro de dossier d'un utilisateur (parent ou AM).
* Permet au gestionnaire/admin de rapprocher deux dossiers (même numéro pour plusieurs personnes).
* Garde-fou : au plus 2 parents par numéro de dossier (couple / co-parents).
*/
async affecterNumeroDossier(userId: string, numeroDossier: string): Promise<Users> {
const user = await this.usersRepository.findOne({ where: { id: userId } });
if (!user) throw new NotFoundException('Utilisateur introuvable');
if (user.role !== RoleType.PARENT && user.role !== RoleType.ASSISTANTE_MATERNELLE) {
throw new BadRequestException(
'Le numéro de dossier ne peut être affecté qu\'à un parent ou une assistante maternelle',
);
}
if (user.role === RoleType.PARENT) {
const uneAMALe = await this.assistantesRepository.count({
where: { numero_dossier: numeroDossier },
});
if (uneAMALe > 0) {
throw new BadRequestException(
'Ce numéro de dossier est celui d\'une assistante maternelle. Un numéro AM ne peut pas être affecté à un parent.',
);
}
const parentsAvecCeNumero = await this.parentsRepository.count({
where: { numero_dossier: numeroDossier },
});
const userADejaCeNumero = user.numero_dossier === numeroDossier;
if (!userADejaCeNumero && parentsAvecCeNumero >= 2) {
throw new BadRequestException(
'Un numéro de dossier ne peut être associé qu\'à 2 parents au maximum (couple / co-parents). Ce dossier a déjà 2 parents.',
);
}
}
if (user.role === RoleType.ASSISTANTE_MATERNELLE) {
const unParentLA = await this.parentsRepository.count({
where: { numero_dossier: numeroDossier },
});
if (unParentLA > 0) {
throw new BadRequestException(
'Ce numéro de dossier est celui d\'une famille (parent). Un numéro famille ne peut pas être affecté à une assistante maternelle.',
);
}
}
user.numero_dossier = numeroDossier;
const savedUser = await this.usersRepository.save(user);
if (user.role === RoleType.PARENT) {
await this.parentsRepository.update({ user_id: userId }, { numero_dossier: numeroDossier });
} else {
await this.assistantesRepository.update({ user_id: userId }, { numero_dossier: numeroDossier });
}
return savedUser;
}
/** Trouve un user par token reprise valide (non expiré). Ticket #111 */
async findByTokenReprise(token: string): Promise<Users | null> {
return this.usersRepository.findOne({
where: {
token_reprise: token,
statut: StatutUtilisateurType.REFUSE,
token_reprise_expire_le: MoreThan(new Date()),
},
});
}
/** Resoumission reprise : met à jour les champs autorisés, passe en en_attente, invalide le token. Ticket #111 */
async resoumettreReprise(
token: string,
dto: { prenom?: string; nom?: string; telephone?: string; adresse?: string; ville?: string; code_postal?: string; photo_url?: string },
): Promise<Users> {
const user = await this.findByTokenReprise(token);
if (!user) {
throw new NotFoundException('Token reprise invalide ou expiré.');
}
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;
if (dto.photo_url !== undefined) user.photo_url = dto.photo_url;
user.statut = StatutUtilisateurType.EN_ATTENTE;
user.token_reprise = undefined;
user.token_reprise_expire_le = undefined;
return this.usersRepository.save(user);
}
/** Pour modale reprise : numero_dossier + email → user en REFUSE avec token valide. Ticket #111 */
async findByNumeroDossierAndEmailForReprise(numero_dossier: string, email: string): Promise<Users | null> {
const user = await this.usersRepository.findOne({
where: {
email: email.trim().toLowerCase(),
numero_dossier: numero_dossier.trim(),
statut: StatutUtilisateurType.REFUSE,
token_reprise_expire_le: MoreThan(new Date()),
},
});
return user ?? null;
}
async remove(id: string, currentUser: Users): Promise<void> {
if (currentUser.role !== RoleType.SUPER_ADMIN) {
throw new ForbiddenException('Accès réservé aux super admins');
}
const user = await this.findOne(id);
if (user.role === RoleType.SUPER_ADMIN) {
throw new ForbiddenException(
'Le super administrateur ne peut pas être supprimé',
);
}
const result = await this.usersRepository.delete(id);
if (result.affected === 0) {
throw new NotFoundException('Utilisateur introuvable');
}
}
}