From 1d3ec0b759b028b03f9f96efae6c885b71b82193 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Mon, 5 Oct 2026 15:18:43 +0200 Subject: [PATCH] fix(cards): FK card_responses via relation JoinColumn + save(create) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Double mapping @Column/@JoinColumn sur id_card ; persist response via card/user relations. Garde update() sur l’instance (évite save parent qui nullifiait les FK enfants). Co-authored-by: Cursor --- backend/src/entities/card_responses.entity.ts | 12 ++-- .../src/modules/cards/cards.service.spec.ts | 57 ++++++++++++++++++- backend/src/modules/cards/cards.service.ts | 18 +++--- 3 files changed, 73 insertions(+), 14 deletions(-) diff --git a/backend/src/entities/card_responses.entity.ts b/backend/src/entities/card_responses.entity.ts index f278502..9047f4f 100644 --- a/backend/src/entities/card_responses.entity.ts +++ b/backend/src/entities/card_responses.entity.ts @@ -5,6 +5,7 @@ import { JoinColumn, ManyToOne, PrimaryGeneratedColumn, + RelationId, } from 'typeorm'; import { CardInstance } from './card_instances.entity'; import { Users } from './users.entity'; @@ -20,20 +21,21 @@ export class CardResponse { @PrimaryGeneratedColumn('uuid') id: string; - @Column({ name: 'id_card', type: 'uuid' }) - id_card: string; - + /** FK carte — une seule source de vérité via @JoinColumn (pas de @Column doublon). */ @ManyToOne(() => CardInstance, (c) => c.responses, { onDelete: 'CASCADE' }) @JoinColumn({ name: 'id_card', referencedColumnName: 'id' }) card: CardInstance; - @Column({ name: 'id_utilisateur', type: 'uuid' }) - id_utilisateur: string; + @RelationId((r: CardResponse) => r.card) + id_card: string; @ManyToOne(() => Users, { onDelete: 'CASCADE' }) @JoinColumn({ name: 'id_utilisateur', referencedColumnName: 'id' }) user: Users; + @RelationId((r: CardResponse) => r.user) + id_utilisateur: string; + @Column({ type: 'enum', enum: CardResponseActionType, diff --git a/backend/src/modules/cards/cards.service.spec.ts b/backend/src/modules/cards/cards.service.spec.ts index 6a43189..cfdb925 100644 --- a/backend/src/modules/cards/cards.service.spec.ts +++ b/backend/src/modules/cards/cards.service.spec.ts @@ -24,6 +24,7 @@ describe('CardsService (#194)', () => { const cardsRepo = { create: jest.fn((x) => x), save: jest.fn(async (x) => ({ ...x, id: x.id ?? 'card-1', cree_le: new Date(), modifie_le: new Date() })), + update: jest.fn(async () => ({ affected: 1 })), findOne: jest.fn(), delete: jest.fn(), createQueryBuilder: jest.fn(), @@ -37,7 +38,8 @@ describe('CardsService (#194)', () => { }; const responsesRepo = { create: jest.fn((x) => x), - save: jest.fn(), + save: jest.fn(async (x) => x), + insert: jest.fn(), }; const amChildrenRepo = { findOne: jest.fn() }; const parentsChildrenRepo = { find: jest.fn(), findOne: jest.fn() }; @@ -226,4 +228,57 @@ describe('CardsService (#194)', () => { expect(ids).toEqual(['am-1', 'p-1', 'p-2']); expect(saved.find((m) => m.id_utilisateur === 'p-2')?.is_creator).toBe(false); }); + + it('parent accepte conge_am → response via relation + carte traitee', async () => { + cardsRepo.findOne.mockResolvedValue({ + id: 'card-1', + type_code: 'conge_am', + id_placement: 'pl-1', + id_evenement: 'ev-1', + cree_par: 'am-1', + operation: CardOperationType.CREATE, + statut: CardInstanceStatutType.OUVERTE, + payload: { date_debut: '2026-10-27', date_fin: '2026-10-31' }, + purge_at: new Date(), + cree_le: new Date(), + modifie_le: new Date(), + type: { + titre: 'Congé AM', + response_mode: CardResponseModeType.ACCEPT_REFUSE, + retention_days: 14, + }, + responses: [], + audience: [ + { id_utilisateur: 'am-1' }, + { id_utilisateur: 'p-1' }, + { id_utilisateur: 'p-2' }, + ], + }); + audienceRepo.findOne.mockResolvedValue({ id_card: 'card-1', id_utilisateur: 'p-1' }); + evenementsService.maj.mockResolvedValue({}); + + const res = await service.repondre('p-1', RoleType.PARENT, 'card-1', { + action: 'accept' as never, + }); + + expect(responsesRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + card: { id: 'card-1' }, + user: { id: 'p-1' }, + action: 'accept', + }), + ); + expect(responsesRepo.save).toHaveBeenCalled(); + expect(evenementsService.maj).toHaveBeenCalledWith( + 'p-1', + RoleType.PARENT, + 'ev-1', + expect.objectContaining({ statut: StatutEvenementAgendaType.ACCEPTE }), + ); + expect(cardsRepo.update).toHaveBeenCalledWith( + { id: 'card-1' }, + expect.objectContaining({ statut: CardInstanceStatutType.TRAITEE }), + ); + expect(res.statut).toBe(CardInstanceStatutType.TRAITEE); + }); }); diff --git a/backend/src/modules/cards/cards.service.ts b/backend/src/modules/cards/cards.service.ts index 29a8ba2..b0402a2 100644 --- a/backend/src/modules/cards/cards.service.ts +++ b/backend/src/modules/cards/cards.service.ts @@ -227,12 +227,14 @@ export class CardsService { throw new BadRequestException('Motivation obligatoire en cas de refus'); } - await this.responsesRepo.insert({ - id_card: card.id, - id_utilisateur: userId, - action: dto.action, - comment: dto.comment?.trim() || undefined, - }); + 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) { @@ -252,8 +254,8 @@ export class CardsService { } card.purge_at = this.purgeAt(card.type.retention_days, card.statut); - // update() et non save() : save() avec relations responses/audience - // resynchronise les FK et tente UPDATE card_responses SET id_card=null. + // 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 },