SuppressionService + DELETE /dossiers/:numero, cascades DELETE /users et DELETE /enfants?deleteDossier, flag sans_enfant, specs front #160. Co-authored-by: Cursor <cursoragent@cursor.com>
421 lines
13 KiB
TypeScript
421 lines
13 KiB
TypeScript
import {
|
||
BadRequestException,
|
||
ForbiddenException,
|
||
Injectable,
|
||
NotFoundException,
|
||
} from '@nestjs/common';
|
||
import { InjectRepository } from '@nestjs/typeorm';
|
||
import { DataSource, In, IsNull, Repository } from 'typeorm';
|
||
import { RoleType, Users } from 'src/entities/users.entity';
|
||
import { Parents } from 'src/entities/parents.entity';
|
||
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
|
||
import { Children, StatutEnfantType } from 'src/entities/children.entity';
|
||
import { ParentsChildren } from 'src/entities/parents_children.entity';
|
||
import { AmChildren } from 'src/entities/am_children.entity';
|
||
|
||
export type SuppressionResult = {
|
||
type?: 'famille' | 'assistante_maternelle';
|
||
numero_dossier?: string;
|
||
deleted_user_ids: string[];
|
||
deleted_enfant_ids: string[];
|
||
dossier_supprime?: boolean;
|
||
message: string;
|
||
};
|
||
|
||
const STAFF_METIER: RoleType[] = [
|
||
RoleType.GESTIONNAIRE,
|
||
RoleType.ADMINISTRATEUR,
|
||
RoleType.SUPER_ADMIN,
|
||
];
|
||
|
||
/**
|
||
* Cascades de suppression métier — tickets #154 / #159.
|
||
*/
|
||
@Injectable()
|
||
export class SuppressionService {
|
||
constructor(
|
||
private readonly dataSource: DataSource,
|
||
@InjectRepository(Users)
|
||
private readonly usersRepository: Repository<Users>,
|
||
@InjectRepository(Parents)
|
||
private readonly parentsRepository: Repository<Parents>,
|
||
@InjectRepository(AssistanteMaternelle)
|
||
private readonly amRepository: Repository<AssistanteMaternelle>,
|
||
@InjectRepository(Children)
|
||
private readonly childrenRepository: Repository<Children>,
|
||
@InjectRepository(ParentsChildren)
|
||
private readonly parentsChildrenRepository: Repository<ParentsChildren>,
|
||
@InjectRepository(AmChildren)
|
||
private readonly amChildrenRepository: Repository<AmChildren>,
|
||
) {}
|
||
|
||
assertStaffMetier(currentUser: Users): void {
|
||
if (!STAFF_METIER.includes(currentUser.role)) {
|
||
throw new ForbiddenException('Accès refusé');
|
||
}
|
||
}
|
||
|
||
async deleteDossier(
|
||
numeroDossier: string,
|
||
currentUser: Users,
|
||
): Promise<SuppressionResult> {
|
||
this.assertStaffMetier(currentUser);
|
||
const num = numeroDossier?.trim();
|
||
if (!num) {
|
||
throw new BadRequestException('Numéro de dossier requis.');
|
||
}
|
||
|
||
const parentHit = await this.parentsRepository.findOne({
|
||
where: { numero_dossier: num },
|
||
});
|
||
if (parentHit) {
|
||
return this.deleteFamilleByNumero(num);
|
||
}
|
||
|
||
const amHit = await this.amRepository.findOne({
|
||
where: { numero_dossier: num },
|
||
relations: ['user'],
|
||
});
|
||
if (amHit?.user) {
|
||
return this.deleteAmUser(amHit.user.id);
|
||
}
|
||
|
||
throw new NotFoundException('Aucun dossier trouvé pour ce numéro.');
|
||
}
|
||
|
||
async deleteUser(
|
||
id: string,
|
||
currentUser: Users,
|
||
): Promise<SuppressionResult> {
|
||
const target = await this.usersRepository.findOne({ where: { id } });
|
||
if (!target) {
|
||
throw new NotFoundException('Utilisateur introuvable');
|
||
}
|
||
|
||
if (target.id === currentUser.id) {
|
||
throw new ForbiddenException('Vous ne pouvez pas supprimer votre propre compte.');
|
||
}
|
||
if (target.role === RoleType.SUPER_ADMIN) {
|
||
throw new ForbiddenException('Le super administrateur ne peut pas être supprimé.');
|
||
}
|
||
|
||
if (target.role === RoleType.PARENT) {
|
||
this.assertStaffMetier(currentUser);
|
||
return this.deleteParentUser(target.id);
|
||
}
|
||
if (target.role === RoleType.ASSISTANTE_MATERNELLE) {
|
||
this.assertStaffMetier(currentUser);
|
||
return this.deleteAmUser(target.id);
|
||
}
|
||
if (target.role === RoleType.GESTIONNAIRE) {
|
||
if (
|
||
currentUser.role !== RoleType.ADMINISTRATEUR &&
|
||
currentUser.role !== RoleType.SUPER_ADMIN
|
||
) {
|
||
throw new ForbiddenException(
|
||
'Seul un administrateur peut supprimer un gestionnaire.',
|
||
);
|
||
}
|
||
await this.usersRepository.delete(target.id);
|
||
return {
|
||
deleted_user_ids: [target.id],
|
||
deleted_enfant_ids: [],
|
||
message: 'Gestionnaire supprimé.',
|
||
};
|
||
}
|
||
if (target.role === RoleType.ADMINISTRATEUR) {
|
||
await this.assertCanDeleteAdministrateur(target, currentUser);
|
||
await this.usersRepository.delete(target.id);
|
||
return {
|
||
deleted_user_ids: [target.id],
|
||
deleted_enfant_ids: [],
|
||
message: 'Administrateur supprimé.',
|
||
};
|
||
}
|
||
|
||
throw new BadRequestException('Type d’utilisateur non supprimable via cet endpoint.');
|
||
}
|
||
|
||
async deleteEnfant(
|
||
enfantId: string,
|
||
deleteDossier: boolean,
|
||
currentUser: Users,
|
||
): Promise<SuppressionResult> {
|
||
this.assertStaffMetier(currentUser);
|
||
|
||
const child = await this.childrenRepository.findOne({
|
||
where: { id: enfantId },
|
||
relations: ['parentLinks', 'parentLinks.parent'],
|
||
});
|
||
if (!child) {
|
||
throw new NotFoundException('Enfant introuvable');
|
||
}
|
||
|
||
const parentIds = (child.parentLinks ?? [])
|
||
.map((l) => l.parentId ?? l.parent?.user_id)
|
||
.filter(Boolean) as string[];
|
||
|
||
let numero: string | undefined;
|
||
if (parentIds.length > 0) {
|
||
const parents = await this.parentsRepository.find({
|
||
where: { user_id: In(parentIds) },
|
||
});
|
||
numero = parents.map((p) => p.numero_dossier?.trim()).find((n) => !!n);
|
||
}
|
||
|
||
if (!numero) {
|
||
await this.closePlacementsForEnfants([enfantId]);
|
||
await this.childrenRepository.delete(enfantId);
|
||
return {
|
||
deleted_user_ids: [],
|
||
deleted_enfant_ids: [enfantId],
|
||
dossier_supprime: false,
|
||
message: 'Enfant supprimé.',
|
||
};
|
||
}
|
||
|
||
const siblingIds = await this.listEnfantIdsForNumero(numero);
|
||
const isLast = siblingIds.length <= 1;
|
||
|
||
if (isLast && deleteDossier) {
|
||
const result = await this.deleteFamilleByNumero(numero);
|
||
return {
|
||
...result,
|
||
dossier_supprime: true,
|
||
message: 'Dernier enfant et dossier famille supprimés.',
|
||
};
|
||
}
|
||
|
||
await this.closePlacementsForEnfants([enfantId]);
|
||
await this.childrenRepository.delete(enfantId);
|
||
return {
|
||
deleted_user_ids: [],
|
||
deleted_enfant_ids: [enfantId],
|
||
dossier_supprime: false,
|
||
numero_dossier: numero,
|
||
type: 'famille',
|
||
message: isLast
|
||
? 'Dernier enfant supprimé. Le dossier famille reste sans enfant.'
|
||
: 'Enfant supprimé du dossier famille.',
|
||
};
|
||
}
|
||
|
||
/** Compte enfants liés à un numero_dossier famille (pour flag sans_enfant). */
|
||
async countEnfantsForNumero(numeroDossier: string): Promise<number> {
|
||
const ids = await this.listEnfantIdsForNumero(numeroDossier);
|
||
return ids.length;
|
||
}
|
||
|
||
private async assertCanDeleteAdministrateur(
|
||
target: Users,
|
||
currentUser: Users,
|
||
): Promise<void> {
|
||
if (
|
||
currentUser.role !== RoleType.ADMINISTRATEUR &&
|
||
currentUser.role !== RoleType.SUPER_ADMIN
|
||
) {
|
||
throw new ForbiddenException(
|
||
'Seul un administrateur peut supprimer un administrateur.',
|
||
);
|
||
}
|
||
const adminCount = await this.usersRepository.count({
|
||
where: { role: RoleType.ADMINISTRATEUR },
|
||
});
|
||
if (adminCount <= 1) {
|
||
if (currentUser.role !== RoleType.SUPER_ADMIN) {
|
||
throw new ForbiddenException(
|
||
'Seul le super administrateur peut supprimer le dernier administrateur.',
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
private async deleteParentUser(userId: string): Promise<SuppressionResult> {
|
||
const parent = await this.parentsRepository.findOne({
|
||
where: { user_id: userId },
|
||
relations: ['co_parent'],
|
||
});
|
||
if (!parent) {
|
||
// Compte parent sans fiche — hard delete user
|
||
await this.usersRepository.delete(userId);
|
||
return {
|
||
deleted_user_ids: [userId],
|
||
deleted_enfant_ids: [],
|
||
message: 'Parent supprimé.',
|
||
};
|
||
}
|
||
|
||
const numero = parent.numero_dossier?.trim();
|
||
const foyerIds = numero
|
||
? await this.listParentUserIdsForNumero(numero)
|
||
: [userId];
|
||
|
||
const isLast = foyerIds.filter((id) => id !== userId).length === 0;
|
||
|
||
if (!isLast) {
|
||
// Co-parent : retirer liens enfants de ce parent, clear co_parent refs, delete user
|
||
await this.dataSource.transaction(async (manager) => {
|
||
await manager.delete(ParentsChildren, { parentId: userId });
|
||
await manager.query(
|
||
`UPDATE parents SET id_co_parent = NULL WHERE id_co_parent = $1 OR id_utilisateur = $1`,
|
||
[userId],
|
||
);
|
||
await manager.delete(Users, { id: userId });
|
||
});
|
||
return {
|
||
deleted_user_ids: [userId],
|
||
deleted_enfant_ids: [],
|
||
numero_dossier: numero,
|
||
type: 'famille',
|
||
message: 'Parent retiré du dossier (co-parent).',
|
||
};
|
||
}
|
||
|
||
// Dernier parent : + enfants
|
||
const enfantIds = numero
|
||
? await this.listEnfantIdsForNumero(numero)
|
||
: await this.listEnfantIdsForParent(userId);
|
||
await this.closePlacementsForEnfants(enfantIds);
|
||
await this.dataSource.transaction(async (manager) => {
|
||
if (enfantIds.length) {
|
||
await manager.delete(Children, { id: In(enfantIds) });
|
||
}
|
||
await manager.query(
|
||
`UPDATE parents SET id_co_parent = NULL WHERE id_utilisateur = $1 OR id_co_parent = $1`,
|
||
[userId],
|
||
);
|
||
await manager.delete(Users, { id: userId });
|
||
});
|
||
return {
|
||
deleted_user_ids: [userId],
|
||
deleted_enfant_ids: enfantIds,
|
||
numero_dossier: numero,
|
||
type: 'famille',
|
||
message: 'Dernier parent et enfants rattachés supprimés.',
|
||
};
|
||
}
|
||
|
||
private async deleteFamilleByNumero(numero: string): Promise<SuppressionResult> {
|
||
const parentIds = await this.listParentUserIdsForNumero(numero);
|
||
if (parentIds.length === 0) {
|
||
throw new NotFoundException('Aucun parent pour ce dossier.');
|
||
}
|
||
const enfantIds = await this.listEnfantIdsForNumero(numero);
|
||
await this.closePlacementsForEnfants(enfantIds);
|
||
|
||
await this.dataSource.transaction(async (manager) => {
|
||
if (enfantIds.length) {
|
||
await manager.delete(Children, { id: In(enfantIds) });
|
||
}
|
||
await manager.query(
|
||
`UPDATE parents SET id_co_parent = NULL WHERE id_utilisateur = ANY($1::uuid[]) OR id_co_parent = ANY($1::uuid[])`,
|
||
[parentIds],
|
||
);
|
||
await manager.delete(Users, { id: In(parentIds) });
|
||
});
|
||
|
||
return {
|
||
type: 'famille',
|
||
numero_dossier: numero,
|
||
deleted_user_ids: parentIds,
|
||
deleted_enfant_ids: enfantIds,
|
||
message: 'Dossier famille supprimé.',
|
||
};
|
||
}
|
||
|
||
private async deleteAmUser(userId: string): Promise<SuppressionResult> {
|
||
const am = await this.amRepository.findOne({ where: { user_id: userId } });
|
||
const numero = am?.numero_dossier?.trim();
|
||
|
||
const active = await this.amChildrenRepository.find({
|
||
where: { amId: userId, date_fin: IsNull() },
|
||
relations: ['child'],
|
||
});
|
||
const now = new Date();
|
||
for (const link of active) {
|
||
link.date_fin = now;
|
||
await this.amChildrenRepository.save(link);
|
||
if (link.child) {
|
||
await this.applySansGarde(link.child);
|
||
}
|
||
}
|
||
|
||
await this.usersRepository.delete(userId);
|
||
return {
|
||
type: 'assistante_maternelle',
|
||
numero_dossier: numero,
|
||
deleted_user_ids: [userId],
|
||
deleted_enfant_ids: [],
|
||
message: 'Dossier assistante maternelle supprimé.',
|
||
};
|
||
}
|
||
|
||
private async applySansGarde(child: Children): Promise<void> {
|
||
if (
|
||
child.status === StatutEnfantType.A_NAITRE ||
|
||
child.status === StatutEnfantType.SCOLARISE
|
||
) {
|
||
return;
|
||
}
|
||
const remaining = await this.amChildrenRepository.count({
|
||
where: { enfantId: child.id, date_fin: IsNull() },
|
||
});
|
||
if (remaining === 0) {
|
||
child.status = StatutEnfantType.SANS_GARDE;
|
||
await this.childrenRepository.save(child);
|
||
}
|
||
}
|
||
|
||
private async closePlacementsForEnfants(enfantIds: string[]): Promise<void> {
|
||
if (!enfantIds.length) return;
|
||
const links = await this.amChildrenRepository.find({
|
||
where: { enfantId: In(enfantIds), date_fin: IsNull() },
|
||
relations: ['child'],
|
||
});
|
||
const now = new Date();
|
||
for (const link of links) {
|
||
link.date_fin = now;
|
||
await this.amChildrenRepository.save(link);
|
||
if (link.child) {
|
||
await this.applySansGarde(link.child);
|
||
}
|
||
}
|
||
}
|
||
|
||
private async listParentUserIdsForNumero(numero: string): Promise<string[]> {
|
||
const rows: Array<{ id: string }> = await this.parentsRepository.query(
|
||
`
|
||
SELECT DISTINCT x.id::text AS id FROM (
|
||
SELECT id_utilisateur AS id FROM parents WHERE TRIM(numero_dossier) = $1
|
||
UNION
|
||
SELECT id_co_parent AS id FROM parents
|
||
WHERE TRIM(numero_dossier) = $1 AND id_co_parent IS NOT NULL
|
||
UNION
|
||
SELECT p2.id_utilisateur AS id FROM parents p1
|
||
JOIN parents p2 ON p2.id_utilisateur = p1.id_co_parent
|
||
WHERE TRIM(p1.numero_dossier) = $1
|
||
) x WHERE x.id IS NOT NULL
|
||
`,
|
||
[numero],
|
||
);
|
||
return rows.map((r) => r.id);
|
||
}
|
||
|
||
private async listEnfantIdsForNumero(numero: string): Promise<string[]> {
|
||
const parentIds = await this.listParentUserIdsForNumero(numero);
|
||
if (!parentIds.length) return [];
|
||
return this.listEnfantIdsForParents(parentIds);
|
||
}
|
||
|
||
private async listEnfantIdsForParent(parentId: string): Promise<string[]> {
|
||
return this.listEnfantIdsForParents([parentId]);
|
||
}
|
||
|
||
private async listEnfantIdsForParents(parentIds: string[]): Promise<string[]> {
|
||
const links = await this.parentsChildrenRepository.find({
|
||
where: { parentId: In(parentIds) },
|
||
});
|
||
return [...new Set(links.map((l) => l.enfantId))];
|
||
}
|
||
}
|