From c784de8198555a3400219a162e0e8f8cd40fd2fe Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Thu, 24 Sep 2026 11:27:42 +0200 Subject: [PATCH] =?UTF-8?q?feat(#194):=20sch=C3=A9ma=20cartes=20SYSTEM=20(?= =?UTF-8?q?types,=20instances,=20audience,=20responses).?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migration + seeds absence/congé/arrêt + entités TypeORM. Co-authored-by: Cursor --- .../entities/card_audience_members.entity.ts | 35 +++++ backend/src/entities/card_instances.entity.ts | 97 ++++++++++++++ backend/src/entities/card_responses.entity.ts | 50 +++++++ backend/src/entities/card_types.entity.ts | 54 ++++++++ database/migrations/2026_cards_system.sql | 123 ++++++++++++++++++ 5 files changed, 359 insertions(+) create mode 100644 backend/src/entities/card_audience_members.entity.ts create mode 100644 backend/src/entities/card_instances.entity.ts create mode 100644 backend/src/entities/card_responses.entity.ts create mode 100644 backend/src/entities/card_types.entity.ts create mode 100644 database/migrations/2026_cards_system.sql diff --git a/backend/src/entities/card_audience_members.entity.ts b/backend/src/entities/card_audience_members.entity.ts new file mode 100644 index 0000000..a608301 --- /dev/null +++ b/backend/src/entities/card_audience_members.entity.ts @@ -0,0 +1,35 @@ +import { + Column, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { CardInstance } from './card_instances.entity'; +import { Users } from './users.entity'; + +@Entity('card_audience_members', { schema: 'public' }) +export class CardAudienceMember { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'id_card', type: 'uuid' }) + id_card: string; + + @ManyToOne(() => CardInstance, (c) => c.audience, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'id_card', referencedColumnName: 'id' }) + card: CardInstance; + + @Column({ name: 'id_utilisateur', type: 'uuid' }) + id_utilisateur: string; + + @ManyToOne(() => Users, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'id_utilisateur', referencedColumnName: 'id' }) + user: Users; + + @Column({ name: 'role_snapshot', type: 'varchar', length: 64 }) + role_snapshot: string; + + @Column({ name: 'is_creator', type: 'boolean', default: false }) + is_creator: boolean; +} diff --git a/backend/src/entities/card_instances.entity.ts b/backend/src/entities/card_instances.entity.ts new file mode 100644 index 0000000..3fa92da --- /dev/null +++ b/backend/src/entities/card_instances.entity.ts @@ -0,0 +1,97 @@ +import { + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + OneToMany, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { AmChildren } from './am_children.entity'; +import { AbsencesGarde } from './absences_garde.entity'; +import { Users } from './users.entity'; +import { CardType } from './card_types.entity'; +import { CardAudienceMember } from './card_audience_members.entity'; +import { CardResponse } from './card_responses.entity'; + +export enum CardInstanceStatutType { + OUVERTE = 'ouverte', + REFUSEE = 'refusee', + TRAITEE = 'traitee', +} + +export enum CardOperationType { + CREATE = 'create', + UPDATE = 'update', +} + +@Entity('card_instances', { schema: 'public' }) +export class CardInstance { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'type_code', type: 'varchar', length: 64 }) + type_code: string; + + @ManyToOne(() => CardType, { onDelete: 'RESTRICT' }) + @JoinColumn({ name: 'type_code', referencedColumnName: 'code' }) + type: CardType; + + @Column({ name: 'id_placement', type: 'uuid' }) + id_placement: string; + + @ManyToOne(() => AmChildren, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'id_placement', referencedColumnName: 'id' }) + placement: AmChildren; + + @Column({ name: 'id_absence', type: 'uuid', nullable: true }) + id_absence?: string; + + @ManyToOne(() => AbsencesGarde, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'id_absence', referencedColumnName: 'id' }) + absence?: AbsencesGarde; + + @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; + + @Column({ + type: 'enum', + enum: CardOperationType, + enumName: 'card_operation_type', + name: 'operation', + default: CardOperationType.CREATE, + }) + operation: CardOperationType; + + @Column({ + type: 'enum', + enum: CardInstanceStatutType, + enumName: 'card_instance_statut_type', + name: 'statut', + default: CardInstanceStatutType.OUVERTE, + }) + statut: CardInstanceStatutType; + + @Column({ name: 'payload', type: 'jsonb', default: {} }) + payload: Record; + + @Column({ name: 'purge_at', type: 'timestamptz' }) + purge_at: Date; + + @OneToMany(() => CardAudienceMember, (m) => m.card) + audience: CardAudienceMember[]; + + @OneToMany(() => CardResponse, (r) => r.card) + responses: CardResponse[]; + + @CreateDateColumn({ name: 'cree_le', type: 'timestamptz' }) + cree_le: Date; + + @UpdateDateColumn({ name: 'modifie_le', type: 'timestamptz' }) + modifie_le: Date; +} diff --git a/backend/src/entities/card_responses.entity.ts b/backend/src/entities/card_responses.entity.ts new file mode 100644 index 0000000..f278502 --- /dev/null +++ b/backend/src/entities/card_responses.entity.ts @@ -0,0 +1,50 @@ +import { + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { CardInstance } from './card_instances.entity'; +import { Users } from './users.entity'; + +export enum CardResponseActionType { + ACCEPT = 'accept', + REFUSE = 'refuse', + ACK = 'ack', +} + +@Entity('card_responses', { schema: 'public' }) +export class CardResponse { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'id_card', type: 'uuid' }) + id_card: string; + + @ManyToOne(() => CardInstance, (c) => c.responses, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'id_card', referencedColumnName: 'id' }) + card: CardInstance; + + @Column({ name: 'id_utilisateur', type: 'uuid' }) + id_utilisateur: string; + + @ManyToOne(() => Users, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'id_utilisateur', referencedColumnName: 'id' }) + user: Users; + + @Column({ + type: 'enum', + enum: CardResponseActionType, + enumName: 'card_response_action_type', + name: 'action', + }) + action: CardResponseActionType; + + @Column({ name: 'comment', type: 'text', nullable: true }) + comment?: string; + + @CreateDateColumn({ name: 'cree_le', type: 'timestamptz' }) + cree_le: Date; +} diff --git a/backend/src/entities/card_types.entity.ts b/backend/src/entities/card_types.entity.ts new file mode 100644 index 0000000..d57a800 --- /dev/null +++ b/backend/src/entities/card_types.entity.ts @@ -0,0 +1,54 @@ +import { + Column, + CreateDateColumn, + Entity, + PrimaryColumn, + UpdateDateColumn, +} from 'typeorm'; + +export enum CardResponseModeType { + NONE = 'none', + ACK = 'ack', + ACCEPT_REFUSE = 'accept_refuse', +} + +@Entity('card_types', { schema: 'public' }) +export class CardType { + @PrimaryColumn({ name: 'code', type: 'varchar', length: 64 }) + code: string; + + @Column({ name: 'system', type: 'boolean', default: true }) + system: boolean; + + @Column({ name: 'titre', type: 'varchar', length: 120 }) + titre: string; + + @Column({ name: 'emitter_roles', type: 'text', array: true }) + emitter_roles: string[]; + + @Column({ name: 'recipient_roles', type: 'text', array: true }) + recipient_roles: string[]; + + @Column({ name: 'audience_resolver', type: 'varchar', length: 64 }) + audience_resolver: string; + + @Column({ + type: 'enum', + enum: CardResponseModeType, + enumName: 'card_response_mode_type', + name: 'response_mode', + }) + response_mode: CardResponseModeType; + + @Column({ name: 'retention_days', type: 'int', default: 14 }) + retention_days: number; + + @Column({ name: 'couleur', type: 'varchar', length: 32, nullable: true }) + couleur?: string; + + @CreateDateColumn({ name: 'cree_le', type: 'timestamptz' }) + cree_le: Date; + + @UpdateDateColumn({ name: 'modifie_le', type: 'timestamptz' }) + modifie_le: Date; +} diff --git a/database/migrations/2026_cards_system.sql b/database/migrations/2026_cards_system.sql new file mode 100644 index 0000000..4751c1b --- /dev/null +++ b/database/migrations/2026_cards_system.sql @@ -0,0 +1,123 @@ +-- Ticket #194 — Module Cartes SYSTEM (collecte absences/congés/arrêt) +-- Idempotent. + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'card_response_mode_type') THEN + CREATE TYPE card_response_mode_type AS ENUM ( + 'none', 'ack', 'accept_refuse' + ); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'card_instance_statut_type') THEN + CREATE TYPE card_instance_statut_type AS ENUM ( + 'ouverte', 'refusee', 'traitee' + ); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'card_operation_type') THEN + CREATE TYPE card_operation_type AS ENUM ('create', 'update'); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'card_response_action_type') THEN + CREATE TYPE card_response_action_type AS ENUM ( + 'accept', 'refuse', 'ack' + ); + END IF; +END $$; + +CREATE TABLE IF NOT EXISTS card_types ( + code VARCHAR(64) PRIMARY KEY, + system BOOLEAN NOT NULL DEFAULT true, + titre VARCHAR(120) NOT NULL, + emitter_roles TEXT[] NOT NULL, + recipient_roles TEXT[] NOT NULL, + audience_resolver VARCHAR(64) NOT NULL, + response_mode card_response_mode_type NOT NULL, + retention_days INT NOT NULL DEFAULT 14, + couleur VARCHAR(32), + cree_le TIMESTAMPTZ NOT NULL DEFAULT now(), + modifie_le TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS card_instances ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + type_code VARCHAR(64) NOT NULL REFERENCES card_types(code), + id_placement UUID NOT NULL + REFERENCES enfants_assistantes_maternelles(id) ON DELETE CASCADE, + id_absence UUID REFERENCES absences_garde(id) ON DELETE SET NULL, + cree_par UUID REFERENCES utilisateurs(id) ON DELETE SET NULL, + operation card_operation_type NOT NULL DEFAULT 'create', + statut card_instance_statut_type NOT NULL DEFAULT 'ouverte', + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + purge_at TIMESTAMPTZ NOT NULL, + cree_le TIMESTAMPTZ NOT NULL DEFAULT now(), + modifie_le TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_card_instances_placement + ON card_instances (id_placement, statut, cree_le DESC); +CREATE INDEX IF NOT EXISTS idx_card_instances_purge + ON card_instances (purge_at); +CREATE INDEX IF NOT EXISTS idx_card_instances_absence + ON card_instances (id_absence) + WHERE id_absence IS NOT NULL; + +CREATE TABLE IF NOT EXISTS card_audience_members ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + id_card UUID NOT NULL REFERENCES card_instances(id) ON DELETE CASCADE, + id_utilisateur UUID NOT NULL REFERENCES utilisateurs(id) ON DELETE CASCADE, + role_snapshot VARCHAR(64) NOT NULL, + is_creator BOOLEAN NOT NULL DEFAULT false, + UNIQUE (id_card, id_utilisateur) +); + +CREATE INDEX IF NOT EXISTS idx_card_audience_user + ON card_audience_members (id_utilisateur, id_card); + +CREATE TABLE IF NOT EXISTS card_responses ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + id_card UUID NOT NULL REFERENCES card_instances(id) ON DELETE CASCADE, + id_utilisateur UUID NOT NULL REFERENCES utilisateurs(id) ON DELETE CASCADE, + action card_response_action_type NOT NULL, + comment TEXT, + cree_le TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_card_responses_card + ON card_responses (id_card, cree_le DESC); + +-- Seeds SYSTEM +INSERT INTO card_types ( + code, system, titre, emitter_roles, recipient_roles, + audience_resolver, response_mode, retention_days, couleur +) VALUES + ( + 'absence_enfant', true, 'Absence enfant', + ARRAY['parent'], ARRAY['assistante_maternelle'], + 'couple_am', 'none', 7, 'peach' + ), + ( + 'absence_enfant_modif', true, 'Absence enfant modifiée', + ARRAY['parent'], ARRAY['assistante_maternelle'], + 'couple_am', 'ack', 7, 'peach' + ), + ( + 'conge_am', true, 'Congé AM', + ARRAY['assistante_maternelle'], ARRAY['parent'], + 'couple_parents', 'accept_refuse', 14, 'lavender' + ), + ( + 'arret_maladie_am', true, 'Arrêt maladie AM', + ARRAY['assistante_maternelle'], ARRAY['parent'], + 'couple_parents', 'ack', 14, 'pink' + ) +ON CONFLICT (code) DO UPDATE SET + titre = EXCLUDED.titre, + emitter_roles = EXCLUDED.emitter_roles, + recipient_roles = EXCLUDED.recipient_roles, + audience_resolver = EXCLUDED.audience_resolver, + response_mode = EXCLUDED.response_mode, + retention_days = EXCLUDED.retention_days, + couleur = EXCLUDED.couleur, + modifie_le = now(); + +COMMENT ON TABLE card_types IS 'Catalogue types de cartes (SYSTEM seeds #194 ; OPTIONNEL plus tard).'; +COMMENT ON TABLE card_instances IS 'Bulles / file d’attention — collecte, pas vérité métier absences.';