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:
@@ -14,6 +14,7 @@ export enum TypeEvenementAgendaType {
|
||||
ABSENCE_ENFANT = 'absence_enfant',
|
||||
CONGE_AM = 'conge_am',
|
||||
ARRET_MALADIE_AM = 'arret_maladie_am',
|
||||
SOIN_ENFANT = 'soin_enfant',
|
||||
}
|
||||
|
||||
export enum StatutEvenementAgendaType {
|
||||
|
||||
@@ -281,4 +281,96 @@ describe('CardsService (#194)', () => {
|
||||
);
|
||||
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é
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -136,23 +136,76 @@ export class CardsService {
|
||||
let absenceId = dto.id_evenement;
|
||||
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) {
|
||||
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,
|
||||
motif: motif ?? undefined,
|
||||
});
|
||||
absenceId = absence.id;
|
||||
} else {
|
||||
if (dto.type_code === 'soin_enfant') {
|
||||
throw new BadRequestException('operation=update interdite pour soin_enfant');
|
||||
}
|
||||
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,
|
||||
motif: motif ?? undefined,
|
||||
// Congé accepté modifié → repasse en attente via cartes respond flow;
|
||||
// pour absence enfant update immédiat déjà fait.
|
||||
...(dto.type_code === 'conge_am'
|
||||
@@ -173,11 +226,7 @@ export class CardsService {
|
||||
cree_par: userId,
|
||||
operation,
|
||||
statut: statutInitial,
|
||||
payload: {
|
||||
date_debut: dto.date_debut,
|
||||
date_fin: dto.date_fin,
|
||||
motif: dto.motif ?? null,
|
||||
},
|
||||
payload: finalPayload,
|
||||
purge_at: this.purgeAt(type.retention_days, statutInitial),
|
||||
});
|
||||
const saved = await this.cardsRepo.save(card);
|
||||
@@ -280,12 +329,20 @@ export class CardsService {
|
||||
if (card.cree_par !== userId) {
|
||||
throw new ForbiddenException('Seul le créateur peut modifier 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é');
|
||||
}
|
||||
} else {
|
||||
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'] ?? '');
|
||||
@@ -294,21 +351,79 @@ export class CardsService {
|
||||
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) {
|
||||
await this.evenementsService.maj(userId, role, card.id_evenement, {
|
||||
date_debut: debut,
|
||||
date_fin: fin,
|
||||
motif: dto.motif,
|
||||
statut: StatutEvenementAgendaType.EN_ATTENTE,
|
||||
motif: motif ?? undefined,
|
||||
...(card.type_code !== 'soin_enfant' ? { statut: StatutEvenementAgendaType.EN_ATTENTE } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
card.payload = {
|
||||
...card.payload,
|
||||
date_debut: debut,
|
||||
date_fin: fin,
|
||||
motif: dto.motif ?? card.payload?.['motif'] ?? null,
|
||||
};
|
||||
card.payload = finalPayload;
|
||||
card.statut = CardInstanceStatutType.OUVERTE;
|
||||
card.purge_at = this.purgeAt(card.type.retention_days, card.statut);
|
||||
await this.cardsRepo.manager.query(
|
||||
@@ -332,16 +447,25 @@ export class CardsService {
|
||||
if (card.cree_par !== userId) {
|
||||
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);
|
||||
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
|
||||
// Supprime l’absence liée si pas encore acceptée définitivement (ou si soin_enfant dans les 24h)
|
||||
if (
|
||||
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);
|
||||
}
|
||||
@@ -379,6 +503,9 @@ export class CardsService {
|
||||
if (typeCode === '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}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,10 @@ export class CreerCarteDto {
|
||||
@IsOptional()
|
||||
@IsEnum(CardOperationType)
|
||||
operation?: CardOperationType;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
payload?: Record<string, any>;
|
||||
}
|
||||
|
||||
export class RepondreCarteDto {
|
||||
@@ -79,6 +83,10 @@ export class MajCarteDto {
|
||||
@IsString()
|
||||
@MaxLength(2000)
|
||||
motif?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
payload?: Record<string, any>;
|
||||
}
|
||||
|
||||
export class CarteDto {
|
||||
|
||||
@@ -196,7 +196,7 @@ export class EvenementsAgendaService {
|
||||
}
|
||||
|
||||
private statutInitial(type: TypeEvenementAgendaType): StatutEvenementAgendaType {
|
||||
if (type === TypeEvenementAgendaType.ABSENCE_ENFANT) {
|
||||
if (type === TypeEvenementAgendaType.ABSENCE_ENFANT || type === TypeEvenementAgendaType.SOIN_ENFANT) {
|
||||
return StatutEvenementAgendaType.ACCEPTE;
|
||||
}
|
||||
return StatutEvenementAgendaType.EN_ATTENTE;
|
||||
@@ -233,10 +233,11 @@ export class EvenementsAgendaService {
|
||||
if (role === RoleType.ASSISTANTE_MATERNELLE) {
|
||||
if (
|
||||
type !== TypeEvenementAgendaType.CONGE_AM &&
|
||||
type !== TypeEvenementAgendaType.ARRET_MALADIE_AM
|
||||
type !== TypeEvenementAgendaType.ARRET_MALADIE_AM &&
|
||||
type !== TypeEvenementAgendaType.SOIN_ENFANT
|
||||
) {
|
||||
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;
|
||||
|
||||
+1
-1
@@ -37,7 +37,7 @@ DO $$ BEGIN
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'type_evenement_agenda_type') THEN
|
||||
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;
|
||||
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',
|
||||
ARRAY['assistante_maternelle'], ARRAY['parent'],
|
||||
'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
|
||||
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';
|
||||
Reference in New Issue
Block a user