fix(cards): FK card_responses via relation JoinColumn + save(create)

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 <cursoragent@cursor.com>
This commit is contained in:
2026-10-05 15:18:43 +02:00
co-authored by Cursor
parent 608e62d662
commit 1d3ec0b759
3 changed files with 73 additions and 14 deletions
@@ -5,6 +5,7 @@ import {
JoinColumn, JoinColumn,
ManyToOne, ManyToOne,
PrimaryGeneratedColumn, PrimaryGeneratedColumn,
RelationId,
} from 'typeorm'; } from 'typeorm';
import { CardInstance } from './card_instances.entity'; import { CardInstance } from './card_instances.entity';
import { Users } from './users.entity'; import { Users } from './users.entity';
@@ -20,20 +21,21 @@ export class CardResponse {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn('uuid')
id: string; id: string;
@Column({ name: 'id_card', type: 'uuid' }) /** FK carte — une seule source de vérité via @JoinColumn (pas de @Column doublon). */
id_card: string;
@ManyToOne(() => CardInstance, (c) => c.responses, { onDelete: 'CASCADE' }) @ManyToOne(() => CardInstance, (c) => c.responses, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'id_card', referencedColumnName: 'id' }) @JoinColumn({ name: 'id_card', referencedColumnName: 'id' })
card: CardInstance; card: CardInstance;
@Column({ name: 'id_utilisateur', type: 'uuid' }) @RelationId((r: CardResponse) => r.card)
id_utilisateur: string; id_card: string;
@ManyToOne(() => Users, { onDelete: 'CASCADE' }) @ManyToOne(() => Users, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'id_utilisateur', referencedColumnName: 'id' }) @JoinColumn({ name: 'id_utilisateur', referencedColumnName: 'id' })
user: Users; user: Users;
@RelationId((r: CardResponse) => r.user)
id_utilisateur: string;
@Column({ @Column({
type: 'enum', type: 'enum',
enum: CardResponseActionType, enum: CardResponseActionType,
@@ -24,6 +24,7 @@ describe('CardsService (#194)', () => {
const cardsRepo = { const cardsRepo = {
create: jest.fn((x) => x), create: jest.fn((x) => x),
save: jest.fn(async (x) => ({ ...x, id: x.id ?? 'card-1', cree_le: new Date(), modifie_le: new Date() })), 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(), findOne: jest.fn(),
delete: jest.fn(), delete: jest.fn(),
createQueryBuilder: jest.fn(), createQueryBuilder: jest.fn(),
@@ -37,7 +38,8 @@ describe('CardsService (#194)', () => {
}; };
const responsesRepo = { const responsesRepo = {
create: jest.fn((x) => x), create: jest.fn((x) => x),
save: jest.fn(), save: jest.fn(async (x) => x),
insert: jest.fn(),
}; };
const amChildrenRepo = { findOne: jest.fn() }; const amChildrenRepo = { findOne: jest.fn() };
const parentsChildrenRepo = { find: jest.fn(), 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(ids).toEqual(['am-1', 'p-1', 'p-2']);
expect(saved.find((m) => m.id_utilisateur === 'p-2')?.is_creator).toBe(false); 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);
});
}); });
+10 -8
View File
@@ -227,12 +227,14 @@ export class CardsService {
throw new BadRequestException('Motivation obligatoire en cas de refus'); throw new BadRequestException('Motivation obligatoire en cas de refus');
} }
await this.responsesRepo.insert({ await this.responsesRepo.save(
id_card: card.id, this.responsesRepo.create({
id_utilisateur: userId, card: { id: card.id },
action: dto.action, user: { id: userId },
comment: dto.comment?.trim() || undefined, action: dto.action,
}); comment: dto.comment?.trim() || undefined,
}),
);
if (card.id_evenement) { if (card.id_evenement) {
if (dto.action === CardResponseActionType.ACCEPT || dto.action === CardResponseActionType.ACK) { 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); card.purge_at = this.purgeAt(card.type.retention_days, card.statut);
// update() et non save() : save() avec relations responses/audience // Ne pas cardsRepo.save(card) avec relations chargées : TypeORM peut
// resynchronise les FK et tente UPDATE card_responses SET id_card=null. // resynchroniser les FK enfants (UPDATE … SET id_card=null).
await this.cardsRepo.update( await this.cardsRepo.update(
{ id: card.id }, { id: card.id },
{ statut: card.statut, purge_at: card.purge_at }, { statut: card.statut, purge_at: card.purge_at },