feat(#206): backend cartes rose soin_enfant (3 sous-types, instant T, correction 24h)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-08 16:46:54 +02:00
co-authored by Cursor
parent a36e9ef176
commit 8af579be53
8 changed files with 262 additions and 26 deletions
@@ -14,6 +14,7 @@ export enum TypeEvenementAgendaType {
ABSENCE_ENFANT = 'absence_enfant', ABSENCE_ENFANT = 'absence_enfant',
CONGE_AM = 'conge_am', CONGE_AM = 'conge_am',
ARRET_MALADIE_AM = 'arret_maladie_am', ARRET_MALADIE_AM = 'arret_maladie_am',
SOIN_ENFANT = 'soin_enfant',
} }
export enum StatutEvenementAgendaType { export enum StatutEvenementAgendaType {
@@ -281,4 +281,96 @@ describe('CardsService (#194)', () => {
); );
expect(res.statut).toBe(CardInstanceStatutType.TRAITEE); expect(res.statut).toBe(CardInstanceStatutType.TRAITEE);
}); });
it('AM crée soin_enfant (fievre) → motif dérivé + payload structuré', async () => {
typesRepo.findOne.mockResolvedValue({
code: 'soin_enfant',
system: true,
emitter_roles: [RoleType.ASSISTANTE_MATERNELLE],
response_mode: CardResponseModeType.ACK,
retention_days: 7,
audience_resolver: 'couple_parents',
});
evenementsService.creer.mockResolvedValue({ id: 'ev-soin' });
cardsRepo.create.mockImplementation((c) => ({ ...c, id: 'card-soin' }));
cardsRepo.save.mockResolvedValue({ id: 'card-soin' });
cardsRepo.findOne.mockResolvedValue({
id: 'card-soin',
type_code: 'soin_enfant',
statut: CardInstanceStatutType.OUVERTE,
payload: {
date_debut: '2026-10-28',
date_fin: '2026-10-28',
motif: '38,5 °C à 14:30 (Médicament : Doliprane)',
sous_type: 'fievre',
heure_releve: '14:30',
temperature_c: 38.5,
medicament_donne: 'Doliprane',
},
type: { response_mode: CardResponseModeType.ACK },
});
const res = await service.creer('am-1', RoleType.ASSISTANTE_MATERNELLE, {
type_code: 'soin_enfant',
id_placement: 'pl-1',
date_debut: '2026-10-28',
date_fin: '2026-10-28',
payload: {
sous_type: 'fievre',
heure_releve: '14:30',
temperature_c: 38.5,
medicament_donne: 'Doliprane',
},
});
expect(evenementsService.creer).toHaveBeenCalledWith(
'am-1',
RoleType.ASSISTANTE_MATERNELLE,
expect.objectContaining({
type: TypeEvenementAgendaType.SOIN_ENFANT,
motif: '38,5 °C à 14:30 (Médicament : Doliprane)',
}),
);
expect(res.payload.motif).toBe('38,5 °C à 14:30 (Médicament : Doliprane)');
});
it('AM corrige soin_enfant dans les 24h → historique + re-ack', async () => {
const creeLe = new Date();
cardsRepo.findOne.mockResolvedValue({
id: 'card-soin',
type_code: 'soin_enfant',
id_placement: 'pl-1',
id_evenement: 'ev-soin',
cree_par: 'am-1',
cree_le: creeLe,
statut: CardInstanceStatutType.TRAITEE, // déjà acquitté
payload: {
date_debut: '2026-10-28',
date_fin: '2026-10-28',
sous_type: 'fievre',
heure_releve: '14:30',
temperature_c: 38.5,
},
type: { retention_days: 7 },
});
evenementsService.maj.mockResolvedValue({});
await service.majEnAttente('am-1', RoleType.ASSISTANTE_MATERNELLE, 'card-soin', {
date_debut: '2026-10-28',
date_fin: '2026-10-28',
payload: {
sous_type: 'fievre',
heure_releve: '14:30',
temperature_c: 37.5, // correction
},
});
expect(cardsRepo.manager.query).toHaveBeenCalledWith(
expect.stringContaining('UPDATE card_instances'),
expect.arrayContaining([
CardInstanceStatutType.OUVERTE, // repasse ouverte
expect.stringContaining('37,5'), // nouveau payload stringifié
]),
);
});
}); });
+149 -22
View File
@@ -136,23 +136,76 @@ export class CardsService {
let absenceId = dto.id_evenement; let absenceId = dto.id_evenement;
const absenceType = this.mapAbsenceType(dto.type_code); const absenceType = this.mapAbsenceType(dto.type_code);
let motif = dto.motif ?? null;
let finalPayload = {
date_debut: dto.date_debut,
date_fin: dto.date_fin,
motif,
...(dto.payload ?? {}),
};
if (dto.type_code === 'soin_enfant') {
const p = dto.payload;
if (!p || !p.sous_type || !p.heure_releve) {
throw new BadRequestException('sous_type et heure_releve requis pour soin_enfant');
}
if (!/^([01]\d|2[0-3]):([0-5]\d)$/.test(p.heure_releve)) {
throw new BadRequestException('Format heure_releve invalide (HH:mm)');
}
if (p.sous_type === 'fievre') {
if (typeof p.temperature_c !== 'number' || p.temperature_c < 34.0 || p.temperature_c > 43.0) {
throw new BadRequestException('temperature_c invalide (34.0 - 43.0)');
}
motif = `${p.temperature_c.toFixed(1).replace('.', ',')} °C à ${p.heure_releve}`;
if (p.medicament_donne && typeof p.medicament_donne === 'string' && p.medicament_donne.trim()) {
motif += ` (Médicament : ${p.medicament_donne.trim()})`;
}
} else if (p.sous_type === 'bobo') {
const natures = ['chute', 'coup', 'egratignure', 'piqure', 'brulure', 'autre'];
if (!natures.includes(p.bobo_nature)) {
throw new BadRequestException('bobo_nature invalide');
}
motif = `Bobo (${p.bobo_nature}) à ${p.heure_releve}`;
if (p.description && typeof p.description === 'string' && p.description.trim()) {
const desc = p.description.trim();
if (desc.length > 120) throw new BadRequestException('description trop longue (max 120)');
motif += ` : ${desc.length > 30 ? desc.substring(0, 30) + '...' : desc}`;
}
} else if (p.sous_type === 'medicament') {
if (!p.medicament_nom || typeof p.medicament_nom !== 'string' || !p.medicament_nom.trim()) {
throw new BadRequestException('medicament_nom requis');
}
motif = `Médicament (${p.medicament_nom.trim()}) à ${p.heure_releve}`;
if (p.contexte && typeof p.contexte === 'string' && p.contexte.trim()) {
if (p.contexte.trim().length > 120) throw new BadRequestException('contexte trop long (max 120)');
}
} else {
throw new BadRequestException('sous_type invalide pour soin_enfant');
}
finalPayload.motif = motif;
}
if (operation === CardOperationType.CREATE) { if (operation === CardOperationType.CREATE) {
const absence = await this.evenementsService.creer(userId, role, { const absence = await this.evenementsService.creer(userId, role, {
id_placement: dto.id_placement, id_placement: dto.id_placement,
type: absenceType, type: absenceType,
date_debut: dto.date_debut, date_debut: dto.date_debut,
date_fin: dto.date_fin, date_fin: dto.date_fin,
motif: dto.motif, motif: motif ?? undefined,
}); });
absenceId = absence.id; absenceId = absence.id;
} else { } else {
if (dto.type_code === 'soin_enfant') {
throw new BadRequestException('operation=update interdite pour soin_enfant');
}
if (!absenceId) { if (!absenceId) {
throw new BadRequestException('id_evenement requis pour operation=update'); throw new BadRequestException('id_evenement requis pour operation=update');
} }
await this.evenementsService.maj(userId, role, absenceId, { await this.evenementsService.maj(userId, role, absenceId, {
date_debut: dto.date_debut, date_debut: dto.date_debut,
date_fin: dto.date_fin, date_fin: dto.date_fin,
motif: dto.motif, motif: motif ?? undefined,
// Congé accepté modifié → repasse en attente via cartes respond flow; // Congé accepté modifié → repasse en attente via cartes respond flow;
// pour absence enfant update immédiat déjà fait. // pour absence enfant update immédiat déjà fait.
...(dto.type_code === 'conge_am' ...(dto.type_code === 'conge_am'
@@ -173,11 +226,7 @@ export class CardsService {
cree_par: userId, cree_par: userId,
operation, operation,
statut: statutInitial, statut: statutInitial,
payload: { payload: finalPayload,
date_debut: dto.date_debut,
date_fin: dto.date_fin,
motif: dto.motif ?? null,
},
purge_at: this.purgeAt(type.retention_days, statutInitial), purge_at: this.purgeAt(type.retention_days, statutInitial),
}); });
const saved = await this.cardsRepo.save(card); const saved = await this.cardsRepo.save(card);
@@ -280,11 +329,19 @@ export class CardsService {
if (card.cree_par !== userId) { if (card.cree_par !== userId) {
throw new ForbiddenException('Seul le créateur peut modifier cette carte'); throw new ForbiddenException('Seul le créateur peut modifier cette carte');
} }
if (
card.statut !== CardInstanceStatutType.OUVERTE && if (card.type_code === 'soin_enfant') {
card.statut !== CardInstanceStatutType.REFUSEE const msSinceCreation = Date.now() - new Date(card.cree_le).getTime();
) { if (msSinceCreation > 24 * 60 * 60 * 1000) {
throw new BadRequestException('Carte non modifiable dans cet état'); throw new BadRequestException('Délai de correction de 24h dépassé');
}
} else {
if (
card.statut !== CardInstanceStatutType.OUVERTE &&
card.statut !== CardInstanceStatutType.REFUSEE
) {
throw new BadRequestException('Carte non modifiable dans cet état');
}
} }
const debut = const debut =
@@ -294,21 +351,79 @@ export class CardsService {
throw new BadRequestException('Dates invalides'); throw new BadRequestException('Dates invalides');
} }
let motif = dto.motif ?? card.payload?.['motif'] ?? null;
let finalPayload = {
...card.payload,
date_debut: debut,
date_fin: fin,
...(dto.payload ?? {}),
};
if (card.type_code === 'soin_enfant') {
const p = finalPayload;
if (!p || !p.sous_type || !p.heure_releve) {
throw new BadRequestException('sous_type et heure_releve requis pour soin_enfant');
}
if (!/^([01]\d|2[0-3]):([0-5]\d)$/.test(p.heure_releve)) {
throw new BadRequestException('Format heure_releve invalide (HH:mm)');
}
if (p.sous_type === 'fievre') {
if (typeof p.temperature_c !== 'number' || p.temperature_c < 34.0 || p.temperature_c > 43.0) {
throw new BadRequestException('temperature_c invalide (34.0 - 43.0)');
}
motif = `${p.temperature_c.toFixed(1).replace('.', ',')} °C à ${p.heure_releve}`;
if (p.medicament_donne && typeof p.medicament_donne === 'string' && p.medicament_donne.trim()) {
motif += ` (Médicament : ${p.medicament_donne.trim()})`;
}
} else if (p.sous_type === 'bobo') {
const natures = ['chute', 'coup', 'egratignure', 'piqure', 'brulure', 'autre'];
if (!natures.includes(p.bobo_nature)) {
throw new BadRequestException('bobo_nature invalide');
}
motif = `Bobo (${p.bobo_nature}) à ${p.heure_releve}`;
if (p.description && typeof p.description === 'string' && p.description.trim()) {
const desc = p.description.trim();
if (desc.length > 120) throw new BadRequestException('description trop longue (max 120)');
motif += ` : ${desc.length > 30 ? desc.substring(0, 30) + '...' : desc}`;
}
} else if (p.sous_type === 'medicament') {
if (!p.medicament_nom || typeof p.medicament_nom !== 'string' || !p.medicament_nom.trim()) {
throw new BadRequestException('medicament_nom requis');
}
motif = `Médicament (${p.medicament_nom.trim()}) à ${p.heure_releve}`;
if (p.contexte && typeof p.contexte === 'string' && p.contexte.trim()) {
if (p.contexte.trim().length > 120) throw new BadRequestException('contexte trop long (max 120)');
}
} else {
throw new BadRequestException('sous_type invalide pour soin_enfant');
}
finalPayload.motif = motif;
const hist = Array.isArray(card.payload?.['historique']) ? card.payload['historique'] : [];
const oldPayload = { ...card.payload };
delete oldPayload.historique;
hist.push({
...oldPayload,
corrige_le: new Date().toISOString(),
corrige_par: userId,
});
finalPayload.historique = hist;
finalPayload.corrige_le = new Date().toISOString();
} else {
finalPayload.motif = motif;
}
if (card.id_evenement) { if (card.id_evenement) {
await this.evenementsService.maj(userId, role, card.id_evenement, { await this.evenementsService.maj(userId, role, card.id_evenement, {
date_debut: debut, date_debut: debut,
date_fin: fin, date_fin: fin,
motif: dto.motif, motif: motif ?? undefined,
statut: StatutEvenementAgendaType.EN_ATTENTE, ...(card.type_code !== 'soin_enfant' ? { statut: StatutEvenementAgendaType.EN_ATTENTE } : {}),
}); });
} }
card.payload = { card.payload = finalPayload;
...card.payload,
date_debut: debut,
date_fin: fin,
motif: dto.motif ?? card.payload?.['motif'] ?? null,
};
card.statut = CardInstanceStatutType.OUVERTE; card.statut = CardInstanceStatutType.OUVERTE;
card.purge_at = this.purgeAt(card.type.retention_days, card.statut); card.purge_at = this.purgeAt(card.type.retention_days, card.statut);
await this.cardsRepo.manager.query( await this.cardsRepo.manager.query(
@@ -332,16 +447,25 @@ export class CardsService {
if (card.cree_par !== userId) { if (card.cree_par !== userId) {
throw new ForbiddenException('Seul le créateur peut supprimer cette carte'); throw new ForbiddenException('Seul le créateur peut supprimer cette carte');
} }
if (card.type_code === 'soin_enfant') {
const msSinceCreation = Date.now() - new Date(card.cree_le).getTime();
if (msSinceCreation > 24 * 60 * 60 * 1000) {
throw new BadRequestException('Délai de correction de 24h dépassé, suppression impossible');
}
}
const audienceIds = await this.audienceUserIds(card); const audienceIds = await this.audienceUserIds(card);
if (card.id_evenement) { if (card.id_evenement) {
const absStatut = const absStatut =
card.statut === CardInstanceStatutType.TRAITEE card.statut === CardInstanceStatutType.TRAITEE
? null ? null
: card.id_evenement; : card.id_evenement;
// Supprime l’absence liée si pas encore acceptée définitivement // Supprime l’absence liée si pas encore acceptée définitivement (ou si soin_enfant dans les 24h)
if ( if (
card.statut === CardInstanceStatutType.OUVERTE || card.statut === CardInstanceStatutType.OUVERTE ||
card.statut === CardInstanceStatutType.REFUSEE card.statut === CardInstanceStatutType.REFUSEE ||
card.type_code === 'soin_enfant'
) { ) {
await this.evenementsService.supprimer(userId, role, card.id_evenement); await this.evenementsService.supprimer(userId, role, card.id_evenement);
} }
@@ -379,6 +503,9 @@ export class CardsService {
if (typeCode === 'arret_maladie_am') { if (typeCode === 'arret_maladie_am') {
return TypeEvenementAgendaType.ARRET_MALADIE_AM; return TypeEvenementAgendaType.ARRET_MALADIE_AM;
} }
if (typeCode === 'soin_enfant') {
return TypeEvenementAgendaType.SOIN_ENFANT;
}
throw new BadRequestException(`Type non mappé à une absence: ${typeCode}`); throw new BadRequestException(`Type non mappé à une absence: ${typeCode}`);
} }
@@ -48,6 +48,10 @@ export class CreerCarteDto {
@IsOptional() @IsOptional()
@IsEnum(CardOperationType) @IsEnum(CardOperationType)
operation?: CardOperationType; operation?: CardOperationType;
@ApiPropertyOptional()
@IsOptional()
payload?: Record<string, any>;
} }
export class RepondreCarteDto { export class RepondreCarteDto {
@@ -79,6 +83,10 @@ export class MajCarteDto {
@IsString() @IsString()
@MaxLength(2000) @MaxLength(2000)
motif?: string; motif?: string;
@ApiPropertyOptional()
@IsOptional()
payload?: Record<string, any>;
} }
export class CarteDto { export class CarteDto {
@@ -196,7 +196,7 @@ export class EvenementsAgendaService {
} }
private statutInitial(type: TypeEvenementAgendaType): StatutEvenementAgendaType { private statutInitial(type: TypeEvenementAgendaType): StatutEvenementAgendaType {
if (type === TypeEvenementAgendaType.ABSENCE_ENFANT) { if (type === TypeEvenementAgendaType.ABSENCE_ENFANT || type === TypeEvenementAgendaType.SOIN_ENFANT) {
return StatutEvenementAgendaType.ACCEPTE; return StatutEvenementAgendaType.ACCEPTE;
} }
return StatutEvenementAgendaType.EN_ATTENTE; return StatutEvenementAgendaType.EN_ATTENTE;
@@ -233,10 +233,11 @@ export class EvenementsAgendaService {
if (role === RoleType.ASSISTANTE_MATERNELLE) { if (role === RoleType.ASSISTANTE_MATERNELLE) {
if ( if (
type !== TypeEvenementAgendaType.CONGE_AM && type !== TypeEvenementAgendaType.CONGE_AM &&
type !== TypeEvenementAgendaType.ARRET_MALADIE_AM type !== TypeEvenementAgendaType.ARRET_MALADIE_AM &&
type !== TypeEvenementAgendaType.SOIN_ENFANT
) { ) {
throw new ForbiddenException( throw new ForbiddenException(
'Une AM ne peut créer que congé ou arrêt maladie', 'Une AM ne peut créer que congé, arrêt maladie ou soin enfant',
); );
} }
return; return;
+1 -1
View File
@@ -37,7 +37,7 @@ DO $$ BEGIN
END IF; END IF;
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'type_evenement_agenda_type') THEN IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'type_evenement_agenda_type') THEN
CREATE TYPE type_evenement_agenda_type AS ENUM ( CREATE TYPE type_evenement_agenda_type AS ENUM (
'absence_enfant', 'conge_am', 'arret_maladie_am' 'absence_enfant', 'conge_am', 'arret_maladie_am', 'soin_enfant'
); );
END IF; END IF;
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_evenement_agenda_type') THEN IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_evenement_agenda_type') THEN
@@ -108,6 +108,11 @@ INSERT INTO card_types (
'arret_maladie_am', true, 'Arrêt maladie AM', 'arret_maladie_am', true, 'Arrêt maladie AM',
ARRAY['assistante_maternelle'], ARRAY['parent'], ARRAY['assistante_maternelle'], ARRAY['parent'],
'couple_parents', 'ack', 14, 'pink' 'couple_parents', 'ack', 14, 'pink'
),
(
'soin_enfant', true, 'Soins enfant',
ARRAY['assistante_maternelle'], ARRAY['parent'],
'couple_parents', 'ack', 7, 'pink'
) )
ON CONFLICT (code) DO UPDATE SET ON CONFLICT (code) DO UPDATE SET
titre = EXCLUDED.titre, titre = EXCLUDED.titre,
@@ -0,0 +1,2 @@
-- Migration incrémentale : ajout du type soin_enfant à l'agenda
ALTER TYPE type_evenement_agenda_type ADD VALUE IF NOT EXISTS 'soin_enfant';