From 8d8627cf3844ae3310591e06eaf45d8ba0394a00 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Thu, 24 Sep 2026 09:09:57 +0000 Subject: [PATCH] feat(#193): table absences_garde + drop evenements Closes #193 Co-authored-by: Julien Martin --- backend/src/entities/absences_garde.entity.ts | 88 +++++++++++++++++++ backend/src/entities/evenements.entity.ts | 79 ----------------- database/BDD.sql | 51 ++++++----- database/docs/ENUMS.md | 20 ++--- database/docs/FK_POLICIES.md | 16 ++-- database/migrations/2026_absences_garde.sql | 85 ++++++++++++++++++ database/seed/02_seed.sql | 18 +--- database/tests/sql/verify.sql | 33 ++++--- 8 files changed, 238 insertions(+), 152 deletions(-) create mode 100644 backend/src/entities/absences_garde.entity.ts delete mode 100644 backend/src/entities/evenements.entity.ts create mode 100644 database/migrations/2026_absences_garde.sql diff --git a/backend/src/entities/absences_garde.entity.ts b/backend/src/entities/absences_garde.entity.ts new file mode 100644 index 0000000..4799a17 --- /dev/null +++ b/backend/src/entities/absences_garde.entity.ts @@ -0,0 +1,88 @@ +import { + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { AmChildren } from './am_children.entity'; +import { Users } from './users.entity'; + +export enum TypeAbsenceGardeType { + ABSENCE_ENFANT = 'absence_enfant', + CONGE_AM = 'conge_am', + ARRET_MALADIE_AM = 'arret_maladie_am', +} + +export enum StatutAbsenceGardeType { + EN_ATTENTE = 'en_attente', + ACCEPTE = 'accepte', + REFUSE = 'refuse', +} + +/** + * Vérité métier des absences / congés / arrêts par couple AM↔enfant. + * Les cartes (module ultérieur) collectent ; cette table stocke. + * Ticket #193. + */ +@Entity('absences_garde', { schema: 'public' }) +export class AbsencesGarde { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'id_placement', type: 'uuid' }) + id_placement: string; + + @ManyToOne(() => AmChildren, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'id_placement', referencedColumnName: 'id' }) + placement: AmChildren; + + @Column({ + type: 'enum', + enum: TypeAbsenceGardeType, + enumName: 'type_absence_garde_type', + name: 'type', + }) + type: TypeAbsenceGardeType; + + @Column({ name: 'date_debut', type: 'date' }) + date_debut: string; + + @Column({ name: 'date_fin', type: 'date' }) + date_fin: string; + + @Column({ + type: 'enum', + enum: StatutAbsenceGardeType, + enumName: 'statut_absence_garde_type', + name: 'statut', + default: StatutAbsenceGardeType.EN_ATTENTE, + }) + statut: StatutAbsenceGardeType; + + /** Purge auto pour en_attente/refuse ; accepte → infinity. */ + @Column({ name: 'expire_at', type: 'timestamptz' }) + expire_at: Date; + + @Column({ name: 'cree_par', type: 'uuid', nullable: true }) + cree_par?: string; + + @ManyToOne(() => Users, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'cree_par', referencedColumnName: 'id' }) + createdBy?: Users; + + /** Réf. carte de collecte (FK quand module Cartes existera). */ + @Column({ name: 'id_card_instance', type: 'uuid', nullable: true }) + id_card_instance?: string; + + @Column({ name: 'motif', type: 'text', nullable: true }) + motif?: string; + + @CreateDateColumn({ name: 'cree_le', type: 'timestamptz' }) + cree_le: Date; + + @UpdateDateColumn({ name: 'modifie_le', type: 'timestamptz' }) + modifie_le: Date; +} diff --git a/backend/src/entities/evenements.entity.ts b/backend/src/entities/evenements.entity.ts deleted file mode 100644 index 9d7a73c..0000000 --- a/backend/src/entities/evenements.entity.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { Column, CreateDateColumn, Entity, JoinColumn, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn } from "typeorm"; -import { Children } from "./children.entity"; -import { Users } from "./users.entity"; -import { Parents } from "./parents.entity"; - -export enum TypeEvenementType { - ABSENCE_ENFANT = 'absence_enfant', - CONGE_AM = 'conge_am', - CONGE_PARENT = 'conge_parent', - ARRET_MALADIE_AM = 'arret_maladie_am', - EVENEMENT_RPE = 'evenement_rpe', -} - -export enum StatutEvenementType { - PROPOSE = 'propose', - VALIDE = 'valide', - REFUSE = 'refuse', -} - -@Entity('evenements') -export class Evenement { - // Define your columns and relationships here - @PrimaryGeneratedColumn('uuid') - id: string; - - @Column({ - type: 'enum', - enum: TypeEvenementType, - enumName: 'type_evenement_type', - name: 'type' - }) - type: TypeEvenementType; - - @ManyToOne(() => Children, { onDelete: 'CASCADE', nullable: true }) - @JoinColumn({ name: 'id_enfant', referencedColumnName: 'id' }) - child?: Children; - - @ManyToOne(() => Users, { nullable: true }) - @JoinColumn({ name: 'id_am', referencedColumnName: 'id' }) - assistanteMaternelle?: Users; - - @ManyToOne(() => Parents, { nullable: true }) - @JoinColumn({ name: 'id_parent', referencedColumnName: 'user_id' }) - parent?: Parents; - - @ManyToOne(() => Users, { nullable: true }) - @JoinColumn({ name: 'cree_par', referencedColumnName: 'id' }) - created_by?: Users; - - @Column({ type: 'timestamptz', nullable: true, name: 'date_debut' }) - start_date?: Date; - - @Column({ type: 'timestamptz', nullable: true, name: 'date_fin' }) - end_date?: Date; - - @Column({ type: 'text', nullable: true, name: 'commentaires' }) - comments?: string; - - @Column({ - type: 'enum', - enum: StatutEvenementType, - enumName: 'statut_evenement_type', - name: 'statut', - default: StatutEvenementType.PROPOSE - }) - status: StatutEvenementType; - - @Column({type: 'timestamptz', nullable: true, name: 'delai_grace'}) - grace_deadline?: Date; - - @Column({type: 'boolean', default: false, name: 'urgent'}) - urgent: boolean; - - @CreateDateColumn({ name: 'cree_le', type: 'timestamptz' }) - created_at: Date; - - @UpdateDateColumn({ name: 'modifie_le', type: 'timestamptz' }) - updated_at: Date; -} diff --git a/database/BDD.sql b/database/BDD.sql index 11aff46..a23e4a2 100644 --- a/database/BDD.sql +++ b/database/BDD.sql @@ -35,13 +35,15 @@ DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_avenant_type') THEN CREATE TYPE statut_avenant_type AS ENUM ('propose', 'accepte', 'refuse'); END IF; - IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'type_evenement_type') THEN - CREATE TYPE type_evenement_type AS ENUM ( - 'absence_enfant', 'conge_am', 'conge_parent', 'arret_maladie_am', 'evenement_rpe' + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'type_absence_garde_type') THEN + CREATE TYPE type_absence_garde_type AS ENUM ( + 'absence_enfant', 'conge_am', 'arret_maladie_am' ); END IF; - IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_evenement_type') THEN - CREATE TYPE statut_evenement_type AS ENUM ('propose', 'valide', 'refuse'); + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_absence_garde_type') THEN + CREATE TYPE statut_absence_garde_type AS ENUM ( + 'en_attente', 'accepte', 'refuse' + ); END IF; IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_validation_type') THEN CREATE TYPE statut_validation_type AS ENUM ('en_attente', 'valide', 'refuse'); @@ -307,25 +309,34 @@ CREATE TABLE avenants_contrats ( ); -- ========================================================== --- Table : evenements +-- Table : absences_garde (vérité métier absences/congés/arrêt) +-- 1 ligne = 1 période ; rattachement = placement AM↔enfant (#193) -- ========================================================== -CREATE TABLE evenements ( +CREATE TABLE absences_garde ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - type type_evenement_type, - id_enfant UUID REFERENCES enfants(id) ON DELETE CASCADE, - id_am UUID REFERENCES utilisateurs(id), - id_parent UUID REFERENCES parents(id_utilisateur), - cree_par UUID REFERENCES utilisateurs(id), - date_debut TIMESTAMPTZ, - date_fin TIMESTAMPTZ, - commentaires TEXT, - statut statut_evenement_type DEFAULT 'propose', - delai_grace TIMESTAMPTZ, - urgent BOOLEAN DEFAULT false, - cree_le TIMESTAMPTZ DEFAULT now(), - modifie_le TIMESTAMPTZ DEFAULT now() + id_placement UUID NOT NULL + REFERENCES enfants_assistantes_maternelles(id) ON DELETE CASCADE, + type type_absence_garde_type NOT NULL, + date_debut DATE NOT NULL, + date_fin DATE NOT NULL, + statut statut_absence_garde_type NOT NULL DEFAULT 'en_attente', + expire_at TIMESTAMPTZ NOT NULL, + cree_par UUID REFERENCES utilisateurs(id) ON DELETE SET NULL, + id_card_instance UUID, + motif TEXT, + cree_le TIMESTAMPTZ NOT NULL DEFAULT now(), + modifie_le TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT chk_absences_garde_dates CHECK (date_fin >= date_debut) ); +CREATE INDEX idx_absences_garde_placement_dates + ON absences_garde (id_placement, date_debut, date_fin); +CREATE INDEX idx_absences_garde_placement_type_statut + ON absences_garde (id_placement, type, statut); +CREATE INDEX idx_absences_garde_expire_at + ON absences_garde (expire_at) + WHERE statut IN ('en_attente', 'refuse'); + -- ========================================================== -- Table : signalements_bugs -- ========================================================== diff --git a/database/docs/ENUMS.md b/database/docs/ENUMS.md index bda4d68..71162cb 100644 --- a/database/docs/ENUMS.md +++ b/database/docs/ENUMS.md @@ -104,31 +104,31 @@ Ce document recense **toutes les valeurs énumérées** utilisées dans la base --- -## 7) Type d’événement — `type` +## 7) Type d’absence de garde — `type` -**Tables/colonnes** : `evenements.type` +**Tables/colonnes** : `absences_garde.type` **Valeurs autorisées** : | Valeur | Description | |---|---| -| `absence_enfant` | Enfant absent | +| `absence_enfant` | Absence déclarée par un parent | | `conge_am` | Congé de l’assistante maternelle | -| `conge_parent` | Congé du parent | | `arret_maladie_am` | Arrêt maladie AM | -| `evenement_rpe` | Événement RPE | + +> Remplace l’ancien enum `type_evenement_type` / table `evenements` (supprimés ticket #193). --- -## 8) Statut d’événement — `statut` +## 8) Statut d’absence de garde — `statut` -**Tables/colonnes** : `evenements.statut` +**Tables/colonnes** : `absences_garde.statut` **Valeurs autorisées** : | Valeur | Description | |---|---| -| `propose` | Événement proposé | -| `valide` | Événement validé | -| `rejete` | Événement refusé | +| `en_attente` | En attente de validation / ack | +| `accepte` | Accepté / acté (conservé en historique métier) | +| `refuse` | Refusé (temporaire ; purge TTL) | --- diff --git a/database/docs/FK_POLICIES.md b/database/docs/FK_POLICIES.md index 2d861fa..9714b35 100644 --- a/database/docs/FK_POLICIES.md +++ b/database/docs/FK_POLICIES.md @@ -42,10 +42,8 @@ Documenter, de façon unique et partagée, les règles de suppression/mise à jo | **contrats(id_dossier)** → `dossiers(id)` | **CASCADE** | 1:1, contrat détruit si dossier supprimé | | **avenants_contrats(id_contrat)** → `contrats(id)` | **CASCADE** | Avenants détruits avec le contrat | | **avenants_contrats(initie_par)** → `utilisateurs(id)` | **SET NULL** | Historiser l’avenant sans bloquer | -| **evenements(id_enfant)** → `enfants(id)` | **CASCADE** | Événements n’ont plus de sens | -| **evenements(id_am)** → `utilisateurs(id)` | **SET NULL** | Garder la trace même si AM supprimée | -| **evenements(id_parent)** → `parents(id_utilisateur)` | **SET NULL** | Garder la trace si parent supprimé | -| **evenements(cree_par)** → `utilisateurs(id)` | **SET NULL** | Conserver l’historique de création | +| **absences_garde(id_placement)** → `enfants_assistantes_maternelles(id)` | **CASCADE** | Plus de couple = plus d’absences | +| **absences_garde(cree_par)** → `utilisateurs(id)` | **SET NULL** | Conserver la période si user supprimé | | **signalements_bugs(id_utilisateur)** → `utilisateurs(id)` | **SET NULL** | Conserver le ticket même si compte supprimé | | **uploads(id_utilisateur)** → `utilisateurs(id)` | **SET NULL** | Fichier reste référencé sans l’auteur | | **notifications(id_utilisateur)** → `utilisateurs(id)` | **CASCADE** | Notifications propres à l’utilisateur | @@ -79,10 +77,8 @@ Documenter, de façon unique et partagée, les règles de suppression/mise à jo - `avenants_contrats.initie_par` → **SET NULL** ### Événements -- `evenements.id_enfant` → **CASCADE** -- `evenements.id_am` → **SET NULL** -- `evenements.id_parent` → **SET NULL** -- `evenements.cree_par` → **SET NULL** +- `absences_garde.id_placement` → **CASCADE** +- `absences_garde.cree_par` → **SET NULL** ### Divers - `signalements_bugs.id_utilisateur` → **SET NULL** @@ -109,11 +105,11 @@ Documenter, de façon unique et partagée, les règles de suppression/mise à jo 4. **Suppression d’un enfant** - Supprimer `enfants(id=childA)` - - Attendu : `enfants_parents` (CASCADE), `dossiers` du childA (CASCADE), `evenements` du childA (CASCADE). + - Attendu : `enfants_parents` (CASCADE), `dossiers` du childA (CASCADE), `absences_garde` des placements du childA (CASCADE via EAM). 5. **Suppression d’un utilisateur AM** - Supprimer `utilisateurs(id=amB)` - - Attendu : `evenements.id_am` devient **NULL** (historique conservé). + - Attendu : absences liées via placement AM (CASCADE si le lien EAM est retiré). --- diff --git a/database/migrations/2026_absences_garde.sql b/database/migrations/2026_absences_garde.sql new file mode 100644 index 0000000..1587b6a --- /dev/null +++ b/database/migrations/2026_absences_garde.sql @@ -0,0 +1,85 @@ +-- Ticket #193 — Back métier absences / congés / arrêt (périodes par placement) +-- Idempotent : safe à rejouer. +-- Remplace la table legacy `evenements` (mal conçue, non utilisée en API). + +-- --------------------------------------------------------------------------- +-- Enums +-- --------------------------------------------------------------------------- +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'type_absence_garde_type') THEN + CREATE TYPE type_absence_garde_type AS ENUM ( + 'absence_enfant', + 'conge_am', + 'arret_maladie_am' + ); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_absence_garde_type') THEN + CREATE TYPE statut_absence_garde_type AS ENUM ( + 'en_attente', + 'accepte', + 'refuse' + ); + END IF; +END $$; + +-- --------------------------------------------------------------------------- +-- Table absences_garde (1 ligne = 1 période) +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS absences_garde ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + id_placement UUID NOT NULL + REFERENCES enfants_assistantes_maternelles(id) ON DELETE CASCADE, + type type_absence_garde_type NOT NULL, + date_debut DATE NOT NULL, + date_fin DATE NOT NULL, + statut statut_absence_garde_type NOT NULL DEFAULT 'en_attente', + -- TTL purge : obligatoire ; pour statut=accepte utiliser 'infinity' (pas de purge métier) + expire_at TIMESTAMPTZ NOT NULL, + cree_par UUID REFERENCES utilisateurs(id) ON DELETE SET NULL, + id_card_instance UUID NULL, + motif TEXT, + cree_le TIMESTAMPTZ NOT NULL DEFAULT now(), + modifie_le TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT chk_absences_garde_dates CHECK (date_fin >= date_debut) +); + +CREATE INDEX IF NOT EXISTS idx_absences_garde_placement_dates + ON absences_garde (id_placement, date_debut, date_fin); + +CREATE INDEX IF NOT EXISTS idx_absences_garde_placement_type_statut + ON absences_garde (id_placement, type, statut); + +CREATE INDEX IF NOT EXISTS idx_absences_garde_expire_at + ON absences_garde (expire_at) + WHERE statut IN ('en_attente', 'refuse'); + +COMMENT ON TABLE absences_garde IS + 'Vérité métier des absences/congés/arrêts par couple AM↔enfant (placement). Les cartes collectent ; cette table stocke.'; +COMMENT ON COLUMN absences_garde.expire_at IS + 'Purge auto (job ultérieur) pour en_attente/refuse. accepte → infinity.'; +COMMENT ON COLUMN absences_garde.id_card_instance IS + 'Réf. carte de collecte (FK cards quand le module Cartes existera).'; + +-- --------------------------------------------------------------------------- +-- Drop legacy evenements (+ enums si plus référencés) +-- --------------------------------------------------------------------------- +DROP TABLE IF EXISTS evenements CASCADE; + +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_type WHERE typname = 'type_evenement_type') + AND NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE udt_name = 'type_evenement_type' + ) THEN + DROP TYPE type_evenement_type; + END IF; + IF EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_evenement_type') + AND NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE udt_name = 'statut_evenement_type' + ) THEN + DROP TYPE statut_evenement_type; + END IF; +END $$; diff --git a/database/seed/02_seed.sql b/database/seed/02_seed.sql index 7fd21c7..c166098 100644 --- a/database/seed/02_seed.sql +++ b/database/seed/02_seed.sql @@ -161,23 +161,9 @@ VALUES ( ON CONFLICT (id) DO NOTHING; -- ------------------------------------------------------------ --- Événement (absence enfant) +-- Absences_garde : seeds quand placements EAM présents (API / ticket peuplement) +-- Legacy evenements supprimée (#193). -- ------------------------------------------------------------ -INSERT INTO evenements (id, type, id_enfant, id_am, id_parent, cree_par, date_debut, date_fin, commentaires, statut, urgence) -VALUES ( - 'e0000000-0000-0000-0000-000000000001', - 'absence_enfant', - 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', - '66666666-6666-6666-6666-666666666666', - '33333333-3333-3333-3333-333333333333', - '33333333-3333-3333-3333-333333333333', - '2025-09-12', - '2025-09-12', - 'Enfant malade (rhume).', - 'propose', - false -) -ON CONFLICT (id) DO NOTHING; -- ------------------------------------------------------------ -- Upload (justificatif lié au dossier) diff --git a/database/tests/sql/verify.sql b/database/tests/sql/verify.sql index 05593dd..4104ab0 100644 --- a/database/tests/sql/verify.sql +++ b/database/tests/sql/verify.sql @@ -79,12 +79,11 @@ LEFT JOIN avenants_contrats a ON a.id_contrat = c.id GROUP BY c.id, c.id_dossier, c.statut ORDER BY c.cree_le DESC; -\echo '=== 9) Evénements par enfant (30 derniers jours) ==============' -SELECT ev.id, ev.type, ev.id_enfant, e.prenom AS enfant, ev.date_debut, ev.date_fin, ev.statut -FROM evenements ev -JOIN enfants e ON e.id = ev.id_enfant -WHERE ev.date_debut >= (NOW()::date - INTERVAL '30 days') -ORDER BY ev.date_debut DESC; +\echo '=== 9) Absences_garde (30 derniers jours) =====================' +SELECT ag.id, ag.type, ag.id_placement, ag.date_debut, ag.date_fin, ag.statut, ag.expire_at +FROM absences_garde ag +WHERE ag.date_debut >= (NOW()::date - INTERVAL '30 days') +ORDER BY ag.date_debut DESC; \echo '=== 10) Uploads & notifications récentes =======================' SELECT u.courriel, up.fichier_url, up.type_fichier, up.cree_le @@ -135,11 +134,11 @@ FROM avenants_contrats a LEFT JOIN contrats c ON c.id = a.id_contrat WHERE c.id IS NULL; --- Evénements sans enfant -SELECT ev.* -FROM evenements ev -LEFT JOIN enfants e ON e.id = ev.id_enfant -WHERE e.id IS NULL; +-- Absences sans placement +SELECT ag.* +FROM absences_garde ag +LEFT JOIN enfants_assistantes_maternelles eam ON eam.id = ag.id_placement +WHERE eam.id IS NULL; \echo '=== 13) Performance : EXPLAIN sur requêtes clés ===============' @@ -151,12 +150,12 @@ WHERE m.id_dossier = 'dddddddd-dddd-dddd-dddd-dddddddddddd' ORDER BY m.cree_le DESC LIMIT 20; --- Evénements par enfant et période (idx_evenements_id_enfant_date_debut) +-- Absences par placement et période (idx_absences_garde_placement_dates) EXPLAIN ANALYZE -SELECT ev.* -FROM evenements ev -WHERE ev.id_enfant = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' - AND ev.date_debut >= '2025-01-01'; +SELECT ag.* +FROM absences_garde ag +WHERE ag.id_placement IS NOT NULL + AND ag.date_debut >= '2025-01-01'; -- Notifications non lues (idx_notifications_user_lu_cree_le) EXPLAIN ANALYZE @@ -199,7 +198,7 @@ SELECT (SELECT COUNT(*) FROM messages) AS nb_messages, (SELECT COUNT(*) FROM contrats) AS nb_contrats, (SELECT COUNT(*) FROM avenants_contrats) AS nb_avenants, - (SELECT COUNT(*) FROM evenements) AS nb_evenements, + (SELECT COUNT(*) FROM absences_garde) AS nb_absences_garde, (SELECT COUNT(*) FROM uploads) AS nb_uploads, (SELECT COUNT(*) FROM notifications) AS nb_notifications, (SELECT COUNT(*) FROM validations) AS nb_validations;