Symétrique #168 : GET/PUT /assistantes-maternelles/me/couples-garde avec parents[] pour le bandeau enfant|foyer. Colonne id_placement_garde_courant sur assistantes_maternelles. Co-authored-by: Cursor <cursoragent@cursor.com>
375 lines
13 KiB
TypeScript
375 lines
13 KiB
TypeScript
import {
|
||
BadRequestException,
|
||
ConflictException,
|
||
Injectable,
|
||
NotFoundException,
|
||
} from '@nestjs/common';
|
||
import { InjectRepository } from '@nestjs/typeorm';
|
||
import { In, IsNull, Repository } from 'typeorm';
|
||
import { RoleType, Users } from 'src/entities/users.entity';
|
||
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
|
||
import { AmChildren } from 'src/entities/am_children.entity';
|
||
import { Children, StatutEnfantType } from 'src/entities/children.entity';
|
||
import { ParentsChildren } from 'src/entities/parents_children.entity';
|
||
import { CreateAssistanteDto } from '../user/dto/create_assistante.dto';
|
||
import { UpdateAssistanteDto } from '../user/dto/update_assistante.dto';
|
||
import { UpdateAmFicheAdminDto } from './dto/update-am-fiche-admin.dto';
|
||
import { CouplesGardeAmResponseDto } from './dto/couples-garde-am.dto';
|
||
import { validateNir } from 'src/common/utils/nir.util';
|
||
|
||
const AM_CHILDREN_RELATIONS = ['user', 'amChildren', 'amChildren.child'] as const;
|
||
|
||
@Injectable()
|
||
export class AssistantesMaternellesService {
|
||
constructor(
|
||
@InjectRepository(AssistanteMaternelle)
|
||
private readonly assistantesMaternelleRepository: Repository<AssistanteMaternelle>,
|
||
@InjectRepository(Users)
|
||
private readonly usersRepository: Repository<Users>,
|
||
@InjectRepository(AmChildren)
|
||
private readonly amChildrenRepository: Repository<AmChildren>,
|
||
@InjectRepository(Children)
|
||
private readonly childrenRepository: Repository<Children>,
|
||
@InjectRepository(ParentsChildren)
|
||
private readonly parentsChildrenRepository: Repository<ParentsChildren>,
|
||
) {}
|
||
|
||
async create(dto: CreateAssistanteDto): Promise<AssistanteMaternelle> {
|
||
const user = await this.usersRepository.findOneBy({ id: dto.user_id });
|
||
if (!user) throw new NotFoundException('Utilisateur introuvable');
|
||
if (user.role !== RoleType.ASSISTANTE_MATERNELLE) {
|
||
throw new BadRequestException('Accès réservé aux assistantes maternelles');
|
||
}
|
||
|
||
const exist = await this.assistantesMaternelleRepository.findOneBy({ user_id: dto.user_id });
|
||
if (exist) throw new ConflictException('Assistante maternelle déjà existante');
|
||
|
||
const entity = this.assistantesMaternelleRepository.create({
|
||
user_id: dto.user_id,
|
||
user: { ...user, role: RoleType.ASSISTANTE_MATERNELLE },
|
||
approval_number: dto.approval_number,
|
||
nir: dto.nir,
|
||
max_children: dto.max_children,
|
||
biography: dto.biography,
|
||
available: dto.available ?? true,
|
||
residence_city: dto.residence_city,
|
||
agreement_date: dto.agreement_date ? new Date(dto.agreement_date) : undefined,
|
||
years_experience: dto.years_experience,
|
||
specialty: dto.specialty,
|
||
places_available: dto.places_available,
|
||
});
|
||
|
||
return this.assistantesMaternelleRepository.save(entity);
|
||
}
|
||
|
||
async findAll(): Promise<AssistanteMaternelle[]> {
|
||
return this.assistantesMaternelleRepository.find({
|
||
relations: [...AM_CHILDREN_RELATIONS],
|
||
});
|
||
}
|
||
|
||
async findOne(user_id: string): Promise<AssistanteMaternelle> {
|
||
const assistante = await this.assistantesMaternelleRepository.findOne({
|
||
where: { user_id },
|
||
relations: [...AM_CHILDREN_RELATIONS],
|
||
});
|
||
if (!assistante) throw new NotFoundException('Assistante maternelle introuvable');
|
||
return assistante;
|
||
}
|
||
|
||
async update(id: string, dto: UpdateAssistanteDto): Promise<AssistanteMaternelle> {
|
||
await this.assistantesMaternelleRepository.update(id, dto);
|
||
return this.findOne(id);
|
||
}
|
||
|
||
/**
|
||
* Mise à jour fiche AM (identité + champs pro) par admin/gestionnaire. Ticket #131.
|
||
*/
|
||
async updateFicheAdmin(amUserId: string, dto: UpdateAmFicheAdminDto): Promise<AssistanteMaternelle> {
|
||
const am = await this.findOne(amUserId);
|
||
const user = am.user;
|
||
|
||
if (dto.email && dto.email !== user.email) {
|
||
const existing = await this.usersRepository.findOne({ where: { email: dto.email } });
|
||
if (existing && existing.id !== user.id) {
|
||
throw new ConflictException('Cet email est déjà utilisé');
|
||
}
|
||
user.email = dto.email;
|
||
}
|
||
|
||
if (dto.nom !== undefined) user.nom = dto.nom;
|
||
if (dto.prenom !== undefined) user.prenom = dto.prenom;
|
||
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.statut !== undefined) user.statut = dto.statut;
|
||
if (dto.date_naissance !== undefined) {
|
||
user.date_naissance = dto.date_naissance ? new Date(dto.date_naissance) : undefined;
|
||
}
|
||
if (dto.lieu_naissance_ville !== undefined) {
|
||
user.lieu_naissance_ville = dto.lieu_naissance_ville || undefined;
|
||
}
|
||
if (dto.lieu_naissance_pays !== undefined) {
|
||
user.lieu_naissance_pays = dto.lieu_naissance_pays || undefined;
|
||
}
|
||
|
||
await this.usersRepository.save(user);
|
||
|
||
const amPatch: Partial<AssistanteMaternelle> = {};
|
||
if (dto.approval_number !== undefined) amPatch.approval_number = dto.approval_number;
|
||
if (dto.residence_city !== undefined) amPatch.residence_city = dto.residence_city;
|
||
if (dto.max_children !== undefined) amPatch.max_children = dto.max_children;
|
||
if (dto.places_available !== undefined) amPatch.places_available = dto.places_available;
|
||
if (dto.biography !== undefined) amPatch.biography = dto.biography;
|
||
if (dto.available !== undefined) amPatch.available = dto.available;
|
||
if (dto.agreement_date !== undefined) {
|
||
amPatch.agreement_date = dto.agreement_date ? new Date(dto.agreement_date) : undefined;
|
||
}
|
||
|
||
if (dto.nir !== undefined) {
|
||
const nirNormalized = dto.nir.replace(/\s/g, '').toUpperCase();
|
||
if (nirNormalized) {
|
||
const dateNaissanceForNir =
|
||
dto.date_naissance ??
|
||
(user.date_naissance instanceof Date
|
||
? user.date_naissance.toISOString().slice(0, 10)
|
||
: user.date_naissance
|
||
? String(user.date_naissance).slice(0, 10)
|
||
: undefined);
|
||
const nirValidation = validateNir(nirNormalized, {
|
||
dateNaissance: dateNaissanceForNir,
|
||
});
|
||
if (!nirValidation.valid) {
|
||
throw new BadRequestException(nirValidation.error || 'NIR invalide');
|
||
}
|
||
const nirDejaUtilise = await this.assistantesMaternelleRepository.findOne({
|
||
where: { nir: nirNormalized },
|
||
});
|
||
if (nirDejaUtilise && nirDejaUtilise.user_id !== amUserId) {
|
||
throw new ConflictException(
|
||
'Un compte assistante maternelle avec ce numéro NIR existe déjà.',
|
||
);
|
||
}
|
||
amPatch.nir = nirNormalized;
|
||
}
|
||
// NIR vide : ne pas effacer (colonne NOT NULL en BDD) — le front renvoie toujours la clé.
|
||
}
|
||
|
||
if (Object.keys(amPatch).length > 0) {
|
||
await this.assistantesMaternelleRepository.update(amUserId, amPatch);
|
||
}
|
||
|
||
return this.findOne(amUserId);
|
||
}
|
||
|
||
/**
|
||
* Rattacher un enfant à une AM (placement actif). Ticket #131.
|
||
* Passe le statut enfant à `garde` (sauf a_naitre / scolarise).
|
||
*/
|
||
async attachEnfant(amUserId: string, enfantId: string, createdBy?: Users): Promise<AssistanteMaternelle> {
|
||
const am = await this.findOne(amUserId);
|
||
|
||
const existingForAm = await this.amChildrenRepository.findOne({
|
||
where: { amId: amUserId, enfantId, date_fin: IsNull() },
|
||
});
|
||
if (existingForAm) {
|
||
throw new ConflictException('Cet enfant est déjà rattaché à cette assistante maternelle');
|
||
}
|
||
|
||
const child = await this.childrenRepository.findOne({ where: { id: enfantId } });
|
||
if (!child) {
|
||
throw new NotFoundException('Enfant introuvable');
|
||
}
|
||
|
||
const activeForChild = await this.amChildrenRepository.findOne({
|
||
where: { enfantId, date_fin: IsNull() },
|
||
});
|
||
if (activeForChild && activeForChild.amId !== amUserId) {
|
||
throw new ConflictException(
|
||
'Cet enfant est déjà en garde chez une autre assistante maternelle',
|
||
);
|
||
}
|
||
|
||
const activeCount = await this.amChildrenRepository.count({
|
||
where: { amId: amUserId, date_fin: IsNull() },
|
||
});
|
||
if (am.max_children != null && activeCount >= am.max_children) {
|
||
throw new BadRequestException(
|
||
`Capacité maximale atteinte (${am.max_children} enfant(s))`,
|
||
);
|
||
}
|
||
|
||
await this.amChildrenRepository.save(
|
||
this.amChildrenRepository.create({
|
||
amId: amUserId,
|
||
enfantId,
|
||
date_debut: new Date(),
|
||
cree_par: createdBy?.id,
|
||
}),
|
||
);
|
||
|
||
await this.applyGardeStatusOnAttach(child);
|
||
|
||
return this.findOne(amUserId);
|
||
}
|
||
|
||
/**
|
||
* Clôturer le placement AM ↔ enfant. Ticket #131.
|
||
* Repasse l'enfant en `sans_garde` s'il n'a plus de placement actif.
|
||
*/
|
||
async detachEnfant(amUserId: string, enfantId: string): Promise<AssistanteMaternelle> {
|
||
await this.findOne(amUserId);
|
||
|
||
const link = await this.amChildrenRepository.findOne({
|
||
where: { amId: amUserId, enfantId, date_fin: IsNull() },
|
||
relations: ['child'],
|
||
});
|
||
if (!link) {
|
||
throw new NotFoundException('Lien assistante maternelle-enfant introuvable');
|
||
}
|
||
|
||
link.date_fin = new Date();
|
||
await this.amChildrenRepository.save(link);
|
||
|
||
const remaining = await this.amChildrenRepository.count({
|
||
where: { enfantId, date_fin: IsNull() },
|
||
});
|
||
if (remaining === 0 && link.child) {
|
||
await this.applySansGardeStatusOnDetach(link.child);
|
||
}
|
||
|
||
return this.findOne(amUserId);
|
||
}
|
||
|
||
private async applyGardeStatusOnAttach(child: Children): Promise<void> {
|
||
if (
|
||
child.status === StatutEnfantType.A_NAITRE ||
|
||
child.status === StatutEnfantType.SCOLARISE
|
||
) {
|
||
return;
|
||
}
|
||
child.status = StatutEnfantType.GARDE;
|
||
await this.childrenRepository.save(child);
|
||
}
|
||
|
||
private async applySansGardeStatusOnDetach(child: Children): Promise<void> {
|
||
if (
|
||
child.status === StatutEnfantType.A_NAITRE ||
|
||
child.status === StatutEnfantType.SCOLARISE
|
||
) {
|
||
return;
|
||
}
|
||
child.status = StatutEnfantType.SANS_GARDE;
|
||
await this.childrenRepository.save(child);
|
||
}
|
||
|
||
async remove(id: string): Promise<{ message: string }> {
|
||
await this.assistantesMaternelleRepository.delete(id);
|
||
return { message: 'Assistante maternelle supprimée' };
|
||
}
|
||
|
||
/**
|
||
* Liste les couples de garde (enfant ↔ parents) de l’AM connectée — ticket #171.
|
||
* Un couple = un placement actif dans enfants_assistantes_maternelles pour cette AM.
|
||
*/
|
||
async listerCouplesGarde(amUserId: string): Promise<CouplesGardeAmResponseDto> {
|
||
const am = await this.assistantesMaternelleRepository.findOne({
|
||
where: { user_id: amUserId },
|
||
});
|
||
if (!am) {
|
||
throw new NotFoundException('Assistante maternelle introuvable');
|
||
}
|
||
|
||
const placements = await this.amChildrenRepository.find({
|
||
where: { amId: amUserId, date_fin: IsNull() },
|
||
relations: ['child'],
|
||
order: { date_debut: 'ASC' },
|
||
});
|
||
|
||
if (placements.length === 0) {
|
||
return { couples: [], couple_courant_id: null };
|
||
}
|
||
|
||
const enfantIds = placements.map((p) => p.enfantId);
|
||
const liens = await this.parentsChildrenRepository.find({
|
||
where: { enfantId: In(enfantIds) },
|
||
relations: ['parent', 'parent.user'],
|
||
});
|
||
const parentsByEnfant = new Map<
|
||
string,
|
||
{ id: string; prenom: string | null; nom: string | null; photo_url: string | null }[]
|
||
>();
|
||
for (const l of liens) {
|
||
const list = parentsByEnfant.get(l.enfantId) ?? [];
|
||
const u = l.parent?.user;
|
||
list.push({
|
||
id: l.parentId,
|
||
prenom: u?.prenom ?? null,
|
||
nom: u?.nom ?? null,
|
||
photo_url: u?.photo_url ?? null,
|
||
});
|
||
parentsByEnfant.set(l.enfantId, list);
|
||
}
|
||
|
||
let idCourant = am.id_placement_garde_courant ?? null;
|
||
const idsValides = new Set(placements.map((p) => p.id));
|
||
if (idCourant && !idsValides.has(idCourant)) {
|
||
idCourant = null;
|
||
await this.assistantesMaternelleRepository.update(
|
||
{ user_id: amUserId },
|
||
{ id_placement_garde_courant: () => 'NULL' },
|
||
);
|
||
}
|
||
if (!idCourant && placements.length === 1) {
|
||
idCourant = placements[0].id;
|
||
}
|
||
|
||
const couples = placements.map((p) => ({
|
||
id: p.id,
|
||
enfant: {
|
||
id: p.child.id,
|
||
prenom: p.child.first_name ?? null,
|
||
nom: p.child.last_name ?? null,
|
||
photo_url: p.child.photo_url ?? null,
|
||
},
|
||
parents: parentsByEnfant.get(p.enfantId) ?? [],
|
||
courant: idCourant != null && p.id === idCourant,
|
||
}));
|
||
|
||
return {
|
||
couples,
|
||
couple_courant_id: idCourant,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Persiste le couple de garde actif pour l’AM — ticket #171.
|
||
*/
|
||
async definirCoupleGardeCourant(
|
||
amUserId: string,
|
||
coupleId: string,
|
||
): Promise<CouplesGardeAmResponseDto> {
|
||
const am = await this.assistantesMaternelleRepository.findOne({
|
||
where: { user_id: amUserId },
|
||
});
|
||
if (!am) {
|
||
throw new NotFoundException('Assistante maternelle introuvable');
|
||
}
|
||
|
||
const placement = await this.amChildrenRepository.findOne({
|
||
where: { id: coupleId, amId: amUserId, date_fin: IsNull() },
|
||
});
|
||
if (!placement) {
|
||
throw new NotFoundException('Couple de garde introuvable ou inactif pour cette AM');
|
||
}
|
||
|
||
await this.assistantesMaternelleRepository.update(
|
||
{ user_id: amUserId },
|
||
{ id_placement_garde_courant: coupleId },
|
||
);
|
||
|
||
return this.listerCouplesGarde(amUserId);
|
||
}
|
||
}
|