release: squash merge develop → master

Quotidien / Epic C : feed bulles parent (#173), module Cartes (#194/#195),
evenements_agenda (#205), couples garde AM (#169–#171), API absences (#172).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-05 15:44:10 +02:00
co-authored by Cursor
parent ba6078feab
commit da024d997a
94 changed files with 6263 additions and 236 deletions
+534
View File
@@ -0,0 +1,534 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { IsNull, Repository } from 'typeorm';
import { EvenementsAgendaService } from '../evenements-agenda/evenements-agenda.service';
import {
StatutEvenementAgendaType,
TypeEvenementAgendaType,
} from 'src/entities/evenements_agenda.entity';
import { AmChildren } from 'src/entities/am_children.entity';
import { ParentsChildren } from 'src/entities/parents_children.entity';
import { RoleType } from 'src/entities/users.entity';
import { CardType, CardResponseModeType } from 'src/entities/card_types.entity';
import {
CardInstance,
CardInstanceStatutType,
CardOperationType,
} from 'src/entities/card_instances.entity';
import { CardAudienceMember } from 'src/entities/card_audience_members.entity';
import {
CardResponse,
CardResponseActionType,
} from 'src/entities/card_responses.entity';
import {
CarteDto,
CreerCarteDto,
ListeCartesDto,
MajCarteDto,
RepondreCarteDto,
} from './dto/cards.dto';
import { CardsRealtimeService } from './cards-realtime.service';
@Injectable()
export class CardsService {
constructor(
@InjectRepository(CardType)
private readonly typesRepo: Repository<CardType>,
@InjectRepository(CardInstance)
private readonly cardsRepo: Repository<CardInstance>,
@InjectRepository(CardAudienceMember)
private readonly audienceRepo: Repository<CardAudienceMember>,
@InjectRepository(CardResponse)
private readonly responsesRepo: Repository<CardResponse>,
@InjectRepository(AmChildren)
private readonly amChildrenRepo: Repository<AmChildren>,
@InjectRepository(ParentsChildren)
private readonly parentsChildrenRepo: Repository<ParentsChildren>,
private readonly evenementsService: EvenementsAgendaService,
private readonly realtime: CardsRealtimeService,
) {}
async listerTypes(role: RoleType): Promise<
Pick<
CardType,
| 'code'
| 'system'
| 'titre'
| 'emitter_roles'
| 'recipient_roles'
| 'audience_resolver'
| 'response_mode'
| 'retention_days'
>[]
> {
const all = await this.typesRepo.find({ where: { system: true } });
return all
.filter((t) => t.emitter_roles.includes(role))
.map((t) => ({
code: t.code,
system: t.system,
titre: t.titre,
emitter_roles: t.emitter_roles,
recipient_roles: t.recipient_roles,
audience_resolver: t.audience_resolver,
response_mode: t.response_mode,
retention_days: t.retention_days,
}));
}
async lister(
userId: string,
role: RoleType,
placementId?: string,
): Promise<ListeCartesDto> {
const qb = this.cardsRepo
.createQueryBuilder('c')
.innerJoin('c.audience', 'aud', 'aud.id_utilisateur = :userId', { userId })
.leftJoinAndSelect('c.type', 'type')
.leftJoinAndSelect('c.responses', 'responses')
.where('c.purge_at > now()')
.orderBy(
`CASE c.statut WHEN 'refusee' THEN 0 WHEN 'ouverte' THEN 1 ELSE 2 END`,
'ASC',
)
.addOrderBy('c.modifie_le', 'DESC');
if (placementId) {
await this.assertPlacementAccess(userId, role, placementId);
qb.andWhere('c.id_placement = :placementId', { placementId });
}
const rows = await qb.getMany();
return {
items: rows.map((c) => this.toDto(c, userId)),
};
}
async creer(
userId: string,
role: RoleType,
dto: CreerCarteDto,
): Promise<CarteDto> {
if (dto.date_fin < dto.date_debut) {
throw new BadRequestException('date_fin < date_debut');
}
const type = await this.typesRepo.findOne({ where: { code: dto.type_code } });
if (!type || !type.system) {
throw new NotFoundException('Type de carte inconnu');
}
if (!type.emitter_roles.includes(role)) {
throw new ForbiddenException('Vous ne pouvez pas émettre ce type de carte');
}
const placement = await this.assertPlacementAccess(
userId,
role,
dto.id_placement,
);
const operation = dto.operation ?? CardOperationType.CREATE;
let absenceId = dto.id_evenement;
const absenceType = this.mapAbsenceType(dto.type_code);
if (operation === CardOperationType.CREATE) {
const absence = await this.evenementsService.creer(userId, role, {
id_placement: dto.id_placement,
type: absenceType,
date_debut: dto.date_debut,
date_fin: dto.date_fin,
motif: dto.motif,
});
absenceId = absence.id;
} else {
if (!absenceId) {
throw new BadRequestException('id_evenement requis pour operation=update');
}
await this.evenementsService.maj(userId, role, absenceId, {
date_debut: dto.date_debut,
date_fin: dto.date_fin,
motif: dto.motif,
// Congé accepté modifié → repasse en attente via cartes respond flow;
// pour absence enfant update immédiat déjà fait.
...(dto.type_code === 'conge_am'
? { statut: StatutEvenementAgendaType.EN_ATTENTE }
: {}),
});
}
const statutInitial =
type.response_mode === CardResponseModeType.NONE
? CardInstanceStatutType.TRAITEE
: CardInstanceStatutType.OUVERTE;
const card = this.cardsRepo.create({
type_code: type.code,
id_placement: dto.id_placement,
id_evenement: absenceId,
cree_par: userId,
operation,
statut: statutInitial,
payload: {
date_debut: dto.date_debut,
date_fin: dto.date_fin,
motif: dto.motif ?? null,
},
purge_at: this.purgeAt(type.retention_days, statutInitial),
});
const saved = await this.cardsRepo.save(card);
if (absenceId) {
await this.linkAbsenceCard(absenceId, saved.id);
}
await this.buildAudience(saved, type, placement, userId, role);
const full = await this.loadCard(saved.id);
const dtoOut = this.toDto(full, userId);
this.emitAudience(full, 'card.created', dtoOut);
return dtoOut;
}
async repondre(
userId: string,
role: RoleType,
cardId: string,
dto: RepondreCarteDto,
): Promise<CarteDto> {
const card = await this.loadCard(cardId);
await this.assertInAudience(card, userId);
if (card.statut !== CardInstanceStatutType.OUVERTE) {
throw new BadRequestException('Cette carte n’est plus ouverte');
}
if (card.cree_par === userId) {
throw new ForbiddenException('Le créateur ne répond pas à sa propre carte');
}
const mode = card.type.response_mode;
if (mode === CardResponseModeType.NONE) {
throw new BadRequestException('Ce type de carte ne demande pas de réponse');
}
if (mode === CardResponseModeType.ACK && dto.action !== CardResponseActionType.ACK) {
throw new BadRequestException('Action attendue : ack');
}
if (
mode === CardResponseModeType.ACCEPT_REFUSE &&
dto.action !== CardResponseActionType.ACCEPT &&
dto.action !== CardResponseActionType.REFUSE
) {
throw new BadRequestException('Action attendue : accept ou refuse');
}
if (dto.action === CardResponseActionType.REFUSE && !dto.comment?.trim()) {
throw new BadRequestException('Motivation obligatoire en cas de refus');
}
await this.responsesRepo.save(
this.responsesRepo.create({
card: { id: card.id },
user: { id: userId },
action: dto.action,
comment: dto.comment?.trim() || undefined,
}),
);
if (card.id_evenement) {
if (dto.action === CardResponseActionType.ACCEPT || dto.action === CardResponseActionType.ACK) {
await this.evenementsService.maj(userId, role, card.id_evenement, {
statut: StatutEvenementAgendaType.ACCEPTE,
});
card.statut = CardInstanceStatutType.TRAITEE;
} else if (dto.action === CardResponseActionType.REFUSE) {
await this.evenementsService.maj(userId, role, card.id_evenement, {
statut: StatutEvenementAgendaType.REFUSE,
motif: dto.comment!.trim(),
});
card.statut = CardInstanceStatutType.REFUSEE;
}
} else if (dto.action === CardResponseActionType.ACK) {
card.statut = CardInstanceStatutType.TRAITEE;
}
card.purge_at = this.purgeAt(card.type.retention_days, card.statut);
// Ne pas cardsRepo.save(card) avec relations chargées : TypeORM peut
// resynchroniser les FK enfants (UPDATE … SET id_card=null).
await this.cardsRepo.update(
{ id: card.id },
{ statut: card.statut, purge_at: card.purge_at },
);
const full = await this.loadCard(cardId);
const dtoOut = this.toDto(full, userId);
this.emitAudience(full, 'response.added', {
action: dto.action,
card: dtoOut,
});
this.emitAudience(full, 'card.updated', dtoOut);
return dtoOut;
}
async majEnAttente(
userId: string,
role: RoleType,
cardId: string,
dto: MajCarteDto,
): Promise<CarteDto> {
const card = await this.loadCard(cardId);
if (card.cree_par !== userId) {
throw new ForbiddenException('Seul le créateur peut modifier cette carte');
}
if (
card.statut !== CardInstanceStatutType.OUVERTE &&
card.statut !== CardInstanceStatutType.REFUSEE
) {
throw new BadRequestException('Carte non modifiable dans cet état');
}
const debut =
dto.date_debut ?? String(card.payload?.['date_debut'] ?? '');
const fin = dto.date_fin ?? String(card.payload?.['date_fin'] ?? '');
if (!debut || !fin || fin < debut) {
throw new BadRequestException('Dates invalides');
}
if (card.id_evenement) {
await this.evenementsService.maj(userId, role, card.id_evenement, {
date_debut: debut,
date_fin: fin,
motif: dto.motif,
statut: StatutEvenementAgendaType.EN_ATTENTE,
});
}
card.payload = {
...card.payload,
date_debut: debut,
date_fin: fin,
motif: dto.motif ?? card.payload?.['motif'] ?? null,
};
card.statut = CardInstanceStatutType.OUVERTE;
card.purge_at = this.purgeAt(card.type.retention_days, card.statut);
await this.cardsRepo.manager.query(
`UPDATE card_instances
SET statut = $1, purge_at = $2, payload = $3::jsonb, modifie_le = now()
WHERE id = $4`,
[card.statut, card.purge_at, JSON.stringify(card.payload), card.id],
);
const full = await this.loadCard(cardId);
const dtoOut = this.toDto(full, userId);
this.emitAudience(full, 'card.updated', dtoOut);
return dtoOut;
}
async supprimer(
userId: string,
role: RoleType,
cardId: string,
): Promise<void> {
const card = await this.loadCard(cardId);
if (card.cree_par !== userId) {
throw new ForbiddenException('Seul le créateur peut supprimer cette carte');
}
const audienceIds = await this.audienceUserIds(card);
if (card.id_evenement) {
const absStatut =
card.statut === CardInstanceStatutType.TRAITEE
? null
: card.id_evenement;
// Supprime l’absence liée si pas encore acceptée définitivement
if (
card.statut === CardInstanceStatutType.OUVERTE ||
card.statut === CardInstanceStatutType.REFUSEE
) {
await this.evenementsService.supprimer(userId, role, card.id_evenement);
}
void absStatut;
}
await this.cardsRepo.delete({ id: cardId });
this.realtime.emitToUsers(audienceIds, 'card.deleted', cardId, {
id: cardId,
});
}
private emitAudience(
card: CardInstance,
event: 'card.created' | 'card.updated' | 'response.added',
data: unknown,
): void {
const ids = (card.audience ?? []).map((a) => a.id_utilisateur);
if (ids.length === 0) return;
this.realtime.emitToUsers(ids, event, card.id, data);
}
private async audienceUserIds(card: CardInstance): Promise<string[]> {
if (card.audience?.length) {
return card.audience.map((a) => a.id_utilisateur);
}
const rows = await this.audienceRepo.find({ where: { id_card: card.id } });
return rows.map((r) => r.id_utilisateur);
}
private mapAbsenceType(typeCode: string): TypeEvenementAgendaType {
if (typeCode === 'absence_enfant' || typeCode === 'absence_enfant_modif') {
return TypeEvenementAgendaType.ABSENCE_ENFANT;
}
if (typeCode === 'conge_am') return TypeEvenementAgendaType.CONGE_AM;
if (typeCode === 'arret_maladie_am') {
return TypeEvenementAgendaType.ARRET_MALADIE_AM;
}
throw new BadRequestException(`Type non mappé à une absence: ${typeCode}`);
}
private async linkAbsenceCard(absenceId: string, cardId: string): Promise<void> {
// Update direct pour éviter droits maj vides
await this.cardsRepo.manager.query(
`UPDATE evenements_agenda SET id_card_instance = $1, modifie_le = now() WHERE id = $2`,
[cardId, absenceId],
);
}
private async buildAudience(
card: CardInstance,
type: CardType,
placement: AmChildren,
creatorId: string,
creatorRole: RoleType,
): Promise<void> {
const members: Partial<CardAudienceMember>[] = [
{
id_card: card.id,
id_utilisateur: creatorId,
role_snapshot: creatorRole,
is_creator: true,
},
];
if (type.audience_resolver === 'couple_am') {
// Destinataire métier = AM + miroir foyer : tous les co-parents de l’enfant
members.push({
id_card: card.id,
id_utilisateur: placement.amId,
role_snapshot: RoleType.ASSISTANTE_MATERNELLE,
is_creator: false,
});
const liensAm = await this.parentsChildrenRepo.find({
where: { enfantId: placement.enfantId },
});
for (const l of liensAm) {
if (l.parentId === creatorId) continue;
members.push({
id_card: card.id,
id_utilisateur: l.parentId,
role_snapshot: RoleType.PARENT,
is_creator: false,
});
}
} else if (type.audience_resolver === 'couple_parents') {
const liens = await this.parentsChildrenRepo.find({
where: { enfantId: placement.enfantId },
});
for (const l of liens) {
if (l.parentId === creatorId) continue;
members.push({
id_card: card.id,
id_utilisateur: l.parentId,
role_snapshot: RoleType.PARENT,
is_creator: false,
});
}
}
// dédup
const seen = new Set<string>();
const unique = members.filter((m) => {
const k = m.id_utilisateur!;
if (seen.has(k)) return false;
seen.add(k);
return true;
});
await this.audienceRepo.save(this.audienceRepo.create(unique));
}
private purgeAt(
retentionDays: number,
statut: CardInstanceStatutType,
): Date {
const days =
statut === CardInstanceStatutType.OUVERTE
? Math.max(retentionDays, 15)
: retentionDays;
return new Date(Date.now() + days * 24 * 60 * 60 * 1000);
}
private async loadCard(id: string): Promise<CardInstance> {
const card = await this.cardsRepo.findOne({
where: { id },
relations: ['type', 'responses', 'audience'],
});
if (!card) throw new NotFoundException('Carte introuvable');
return card;
}
private async assertInAudience(card: CardInstance, userId: string): Promise<void> {
const ok = (card.audience ?? []).some((a) => a.id_utilisateur === userId);
if (!ok) {
const row = await this.audienceRepo.findOne({
where: { id_card: card.id, id_utilisateur: userId },
});
if (!row) throw new ForbiddenException('Carte hors de votre audience');
}
}
private async assertPlacementAccess(
userId: string,
role: RoleType,
placementId: string,
): Promise<AmChildren> {
const placement = await this.amChildrenRepo.findOne({
where: { id: placementId, date_fin: IsNull() },
});
if (!placement) {
throw new NotFoundException('Placement introuvable ou inactif');
}
if (role === RoleType.ASSISTANTE_MATERNELLE) {
if (placement.amId !== userId) {
throw new ForbiddenException('Placement non autorisé');
}
return placement;
}
if (role === RoleType.PARENT) {
const lien = await this.parentsChildrenRepo.findOne({
where: { parentId: userId, enfantId: placement.enfantId },
});
if (!lien) throw new ForbiddenException('Placement non autorisé');
return placement;
}
throw new ForbiddenException('Rôle non autorisé');
}
private toDto(card: CardInstance, userId: string): CarteDto {
const lastRefuse = [...(card.responses ?? [])]
.reverse()
.find((r) => r.action === CardResponseActionType.REFUSE);
return {
id: card.id,
type_code: card.type_code,
titre: card.type?.titre ?? card.type_code,
id_placement: card.id_placement,
id_evenement: card.id_evenement ?? null,
operation: card.operation,
statut: card.statut,
payload: card.payload ?? {},
purge_at: card.purge_at?.toISOString?.() ?? String(card.purge_at),
cree_par: card.cree_par ?? null,
is_creator: card.cree_par === userId,
response_mode: card.type?.response_mode,
last_refuse_comment: lastRefuse?.comment ?? null,
cree_le: card.cree_le?.toISOString?.() ?? String(card.cree_le),
modifie_le: card.modifie_le?.toISOString?.() ?? String(card.modifie_le),
};
}
}