From 2d8b857a84d51d7ea43f29fefd8969cf96edb688 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Thu, 24 Sep 2026 09:38:39 +0000 Subject: [PATCH 1/3] feat(#194): module Cartes SYSTEM Closes #194 Co-authored-by: Julien Martin --- backend/src/app.module.ts | 2 + .../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 +++ .../absences-garde/absences-garde.service.ts | 9 +- backend/src/modules/cards/cards.controller.ts | 119 +++++ backend/src/modules/cards/cards.module.ts | 29 ++ .../src/modules/cards/cards.service.spec.ts | 146 ++++++ backend/src/modules/cards/cards.service.ts | 453 ++++++++++++++++++ backend/src/modules/cards/dto/cards.dto.ts | 137 ++++++ backend/src/modules/cards/index.ts | 2 + database/migrations/2026_cards_system.sql | 123 +++++ 13 files changed, 1252 insertions(+), 4 deletions(-) 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 backend/src/modules/cards/cards.controller.ts create mode 100644 backend/src/modules/cards/cards.module.ts create mode 100644 backend/src/modules/cards/cards.service.spec.ts create mode 100644 backend/src/modules/cards/cards.service.ts create mode 100644 backend/src/modules/cards/dto/cards.dto.ts create mode 100644 backend/src/modules/cards/index.ts create mode 100644 database/migrations/2026_cards_system.sql diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index ff54b1e..4fb9b45 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -17,6 +17,7 @@ import { EnfantsModule } from './routes/enfants/enfants.module'; import { AppConfigModule } from './modules/config/config.module'; import { DocumentsLegauxModule } from './modules/documents-legaux'; import { AbsencesGardeModule } from './modules/absences-garde'; +import { CardsModule } from './modules/cards'; import { RelaisModule } from './routes/relais/relais.module'; import { DossiersModule } from './routes/dossiers/dossiers.module'; import { SuppressionsModule } from './routes/suppressions/suppressions.module'; @@ -58,6 +59,7 @@ import { SuppressionsModule } from './routes/suppressions/suppressions.module'; AppConfigModule, DocumentsLegauxModule, AbsencesGardeModule, + CardsModule, RelaisModule, DossiersModule, SuppressionsModule, 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/backend/src/modules/absences-garde/absences-garde.service.ts b/backend/src/modules/absences-garde/absences-garde.service.ts index a3bf9f5..dd251ae 100644 --- a/backend/src/modules/absences-garde/absences-garde.service.ts +++ b/backend/src/modules/absences-garde/absences-garde.service.ts @@ -312,15 +312,16 @@ export class AbsencesGardeService { ); } if (role === RoleType.ASSISTANTE_MATERNELLE) { - // Remise en attente après refus (republication) + // Remise en attente après refus OU modification d’un congé déjà accepté (S2b) if ( - row.statut === StatutAbsenceGardeType.REFUSE && - next === StatutAbsenceGardeType.EN_ATTENTE + next === StatutAbsenceGardeType.EN_ATTENTE && + (row.statut === StatutAbsenceGardeType.REFUSE || + row.statut === StatutAbsenceGardeType.ACCEPTE) ) { return; } throw new ForbiddenException( - 'L’AM ne valide pas elle-même (sauf republication après refus)', + 'L’AM ne valide pas elle-même (sauf republication / modification)', ); } throw new ForbiddenException('Changement de statut non autorisé'); diff --git a/backend/src/modules/cards/cards.controller.ts b/backend/src/modules/cards/cards.controller.ts new file mode 100644 index 0000000..1b8ce80 --- /dev/null +++ b/backend/src/modules/cards/cards.controller.ts @@ -0,0 +1,119 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + ParseUUIDPipe, + Patch, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { + ApiBearerAuth, + ApiBody, + ApiOperation, + ApiQuery, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; +import { AuthGuard } from 'src/common/guards/auth.guard'; +import { RolesGuard } from 'src/common/guards/roles.guard'; +import { Roles } from 'src/common/decorators/roles.decorator'; +import { User } from 'src/common/decorators/user.decorator'; +import { RoleType } from 'src/entities/users.entity'; +import { CardsService } from './cards.service'; +import { + CarteDto, + CreerCarteDto, + ListeCartesDto, + MajCarteDto, + RepondreCarteDto, +} from './dto/cards.dto'; + +@ApiTags('Cartes') +@ApiBearerAuth('access-token') +@Controller('cards') +@UseGuards(AuthGuard, RolesGuard) +export class CardsController { + constructor(private readonly cardsService: CardsService) {} + + @Get('types') + @Roles(RoleType.PARENT, RoleType.ASSISTANTE_MATERNELLE) + @ApiOperation({ summary: 'Types SYSTEM émissibles pour mon rôle — #194' }) + listerTypes(@User('role') role: RoleType) { + return this.cardsService.listerTypes(role); + } + + @Get() + @Roles(RoleType.PARENT, RoleType.ASSISTANTE_MATERNELLE) + @ApiOperation({ summary: 'Feed bulles de l’utilisateur — #194' }) + @ApiQuery({ name: 'placementId', required: false }) + @ApiResponse({ status: 200, type: ListeCartesDto }) + lister( + @User('id') userId: string, + @User('role') role: RoleType, + @Query('placementId') placementId?: string, + ): Promise { + return this.cardsService.lister(userId, role, placementId); + } + + @Post() + @Roles(RoleType.PARENT, RoleType.ASSISTANTE_MATERNELLE) + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ + summary: 'Créer une bulle (+ hook absences_garde) — #194', + }) + @ApiBody({ type: CreerCarteDto }) + @ApiResponse({ status: 201, type: CarteDto }) + creer( + @User('id') userId: string, + @User('role') role: RoleType, + @Body() dto: CreerCarteDto, + ): Promise { + return this.cardsService.creer(userId, role, dto); + } + + @Post(':id/respond') + @Roles(RoleType.PARENT, RoleType.ASSISTANTE_MATERNELLE) + @ApiOperation({ summary: 'Répondre (accept / refuse / ack) — #194' }) + @ApiBody({ type: RepondreCarteDto }) + repondre( + @User('id') userId: string, + @User('role') role: RoleType, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: RepondreCarteDto, + ): Promise { + return this.cardsService.repondre(userId, role, id, dto); + } + + @Patch(':id') + @Roles(RoleType.PARENT, RoleType.ASSISTANTE_MATERNELLE) + @ApiOperation({ + summary: 'Modifier dates (créateur, ouverte/refusée) — #194', + }) + @ApiBody({ type: MajCarteDto }) + maj( + @User('id') userId: string, + @User('role') role: RoleType, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: MajCarteDto, + ): Promise { + return this.cardsService.majEnAttente(userId, role, id, dto); + } + + @Delete(':id') + @Roles(RoleType.PARENT, RoleType.ASSISTANTE_MATERNELLE) + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Supprimer bulle (+ absence si non traitée) — #194' }) + async supprimer( + @User('id') userId: string, + @User('role') role: RoleType, + @Param('id', ParseUUIDPipe) id: string, + ): Promise { + await this.cardsService.supprimer(userId, role, id); + } +} diff --git a/backend/src/modules/cards/cards.module.ts b/backend/src/modules/cards/cards.module.ts new file mode 100644 index 0000000..caf9a36 --- /dev/null +++ b/backend/src/modules/cards/cards.module.ts @@ -0,0 +1,29 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AbsencesGardeModule } from '../absences-garde'; +import { CardType } from 'src/entities/card_types.entity'; +import { CardInstance } from 'src/entities/card_instances.entity'; +import { CardAudienceMember } from 'src/entities/card_audience_members.entity'; +import { CardResponse } from 'src/entities/card_responses.entity'; +import { AmChildren } from 'src/entities/am_children.entity'; +import { ParentsChildren } from 'src/entities/parents_children.entity'; +import { CardsController } from './cards.controller'; +import { CardsService } from './cards.service'; + +@Module({ + imports: [ + AbsencesGardeModule, + TypeOrmModule.forFeature([ + CardType, + CardInstance, + CardAudienceMember, + CardResponse, + AmChildren, + ParentsChildren, + ]), + ], + controllers: [CardsController], + providers: [CardsService], + exports: [CardsService], +}) +export class CardsModule {} diff --git a/backend/src/modules/cards/cards.service.spec.ts b/backend/src/modules/cards/cards.service.spec.ts new file mode 100644 index 0000000..fff3f4f --- /dev/null +++ b/backend/src/modules/cards/cards.service.spec.ts @@ -0,0 +1,146 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { ForbiddenException } from '@nestjs/common'; +import { CardsService } from './cards.service'; +import { AbsencesGardeService } from '../absences-garde/absences-garde.service'; +import { CardType, CardResponseModeType } from 'src/entities/card_types.entity'; +import { + CardInstance, + CardInstanceStatutType, + CardOperationType, +} from 'src/entities/card_instances.entity'; +import { CardAudienceMember } from 'src/entities/card_audience_members.entity'; +import { CardResponse } from 'src/entities/card_responses.entity'; +import { AmChildren } from 'src/entities/am_children.entity'; +import { ParentsChildren } from 'src/entities/parents_children.entity'; +import { RoleType } from 'src/entities/users.entity'; +import { StatutAbsenceGardeType, TypeAbsenceGardeType } from 'src/entities/absences_garde.entity'; + +describe('CardsService (#194)', () => { + let service: CardsService; + + const typesRepo = { find: jest.fn(), findOne: jest.fn() }; + 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() })), + findOne: jest.fn(), + delete: jest.fn(), + createQueryBuilder: jest.fn(), + manager: { query: jest.fn() }, + }; + const audienceRepo = { + create: jest.fn((x) => x), + save: jest.fn(), + findOne: jest.fn(), + }; + const responsesRepo = { + create: jest.fn((x) => x), + save: jest.fn(), + }; + const amChildrenRepo = { findOne: jest.fn() }; + const parentsChildrenRepo = { find: jest.fn(), findOne: jest.fn() }; + const absencesService = { + creer: jest.fn(), + maj: jest.fn(), + supprimer: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + CardsService, + { provide: getRepositoryToken(CardType), useValue: typesRepo }, + { provide: getRepositoryToken(CardInstance), useValue: cardsRepo }, + { provide: getRepositoryToken(CardAudienceMember), useValue: audienceRepo }, + { provide: getRepositoryToken(CardResponse), useValue: responsesRepo }, + { provide: getRepositoryToken(AmChildren), useValue: amChildrenRepo }, + { provide: getRepositoryToken(ParentsChildren), useValue: parentsChildrenRepo }, + { provide: AbsencesGardeService, useValue: absencesService }, + ], + }).compile(); + service = module.get(CardsService); + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + it('AM crée congé → absence + carte ouverte + audience parents', async () => { + typesRepo.findOne.mockResolvedValue({ + code: 'conge_am', + system: true, + titre: 'Congé AM', + emitter_roles: [RoleType.ASSISTANTE_MATERNELLE], + recipient_roles: [RoleType.PARENT], + audience_resolver: 'couple_parents', + response_mode: CardResponseModeType.ACCEPT_REFUSE, + retention_days: 14, + couleur: 'lavender', + }); + amChildrenRepo.findOne.mockResolvedValue({ + id: 'pl-1', + amId: 'am-1', + enfantId: 'e-1', + date_fin: null, + }); + absencesService.creer.mockResolvedValue({ + id: 'abs-1', + type: TypeAbsenceGardeType.CONGE_AM, + statut: StatutAbsenceGardeType.EN_ATTENTE, + }); + parentsChildrenRepo.find.mockResolvedValue([ + { parentId: 'p-1', enfantId: 'e-1' }, + { parentId: 'p-2', enfantId: 'e-1' }, + ]); + cardsRepo.findOne.mockResolvedValue({ + id: 'card-1', + type_code: 'conge_am', + id_placement: 'pl-1', + id_absence: 'abs-1', + cree_par: 'am-1', + operation: CardOperationType.CREATE, + statut: CardInstanceStatutType.OUVERTE, + payload: { date_debut: '2026-11-01', date_fin: '2026-11-07' }, + purge_at: new Date(), + cree_le: new Date(), + modifie_le: new Date(), + type: { + titre: 'Congé AM', + couleur: 'lavender', + response_mode: CardResponseModeType.ACCEPT_REFUSE, + retention_days: 14, + }, + responses: [], + audience: [], + }); + + const res = await service.creer('am-1', RoleType.ASSISTANTE_MATERNELLE, { + type_code: 'conge_am', + id_placement: 'pl-1', + date_debut: '2026-11-01', + date_fin: '2026-11-07', + }); + + expect(absencesService.creer).toHaveBeenCalled(); + expect(audienceRepo.save).toHaveBeenCalled(); + expect(res.type_code).toBe('conge_am'); + expect(res.statut).toBe(CardInstanceStatutType.OUVERTE); + }); + + it('parent ne peut pas émettre conge_am', async () => { + typesRepo.findOne.mockResolvedValue({ + code: 'conge_am', + system: true, + emitter_roles: [RoleType.ASSISTANTE_MATERNELLE], + }); + await expect( + service.creer('p-1', RoleType.PARENT, { + type_code: 'conge_am', + id_placement: 'pl-1', + date_debut: '2026-11-01', + date_fin: '2026-11-07', + }), + ).rejects.toBeInstanceOf(ForbiddenException); + }); +}); diff --git a/backend/src/modules/cards/cards.service.ts b/backend/src/modules/cards/cards.service.ts new file mode 100644 index 0000000..2d4fb08 --- /dev/null +++ b/backend/src/modules/cards/cards.service.ts @@ -0,0 +1,453 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { IsNull, Repository } from 'typeorm'; +import { AbsencesGardeService } from '../absences-garde/absences-garde.service'; +import { + StatutAbsenceGardeType, + TypeAbsenceGardeType, +} from 'src/entities/absences_garde.entity'; +import { AmChildren } from 'src/entities/am_children.entity'; +import { ParentsChildren } from 'src/entities/parents_children.entity'; +import { RoleType } from 'src/entities/users.entity'; +import { CardType, CardResponseModeType } from 'src/entities/card_types.entity'; +import { + CardInstance, + CardInstanceStatutType, + CardOperationType, +} from 'src/entities/card_instances.entity'; +import { CardAudienceMember } from 'src/entities/card_audience_members.entity'; +import { + CardResponse, + CardResponseActionType, +} from 'src/entities/card_responses.entity'; +import { + CarteDto, + CreerCarteDto, + ListeCartesDto, + MajCarteDto, + RepondreCarteDto, +} from './dto/cards.dto'; + +@Injectable() +export class CardsService { + constructor( + @InjectRepository(CardType) + private readonly typesRepo: Repository, + @InjectRepository(CardInstance) + private readonly cardsRepo: Repository, + @InjectRepository(CardAudienceMember) + private readonly audienceRepo: Repository, + @InjectRepository(CardResponse) + private readonly responsesRepo: Repository, + @InjectRepository(AmChildren) + private readonly amChildrenRepo: Repository, + @InjectRepository(ParentsChildren) + private readonly parentsChildrenRepo: Repository, + private readonly absencesService: AbsencesGardeService, + ) {} + + async listerTypes(role: RoleType): Promise { + const all = await this.typesRepo.find({ where: { system: true } }); + return all.filter((t) => t.emitter_roles.includes(role)); + } + + async lister( + userId: string, + role: RoleType, + placementId?: string, + ): Promise { + const qb = this.cardsRepo + .createQueryBuilder('c') + .innerJoin('c.audience', 'aud', 'aud.id_utilisateur = :userId', { userId }) + .leftJoinAndSelect('c.type', 'type') + .leftJoinAndSelect('c.responses', 'responses') + .where('c.purge_at > now()') + .orderBy( + `CASE c.statut WHEN 'refusee' THEN 0 WHEN 'ouverte' THEN 1 ELSE 2 END`, + 'ASC', + ) + .addOrderBy('c.modifie_le', 'DESC'); + + if (placementId) { + await this.assertPlacementAccess(userId, role, placementId); + qb.andWhere('c.id_placement = :placementId', { placementId }); + } + + const rows = await qb.getMany(); + return { + items: rows.map((c) => this.toDto(c, userId)), + }; + } + + async creer( + userId: string, + role: RoleType, + dto: CreerCarteDto, + ): Promise { + if (dto.date_fin < dto.date_debut) { + throw new BadRequestException('date_fin < date_debut'); + } + + const type = await this.typesRepo.findOne({ where: { code: dto.type_code } }); + if (!type || !type.system) { + throw new NotFoundException('Type de carte inconnu'); + } + if (!type.emitter_roles.includes(role)) { + throw new ForbiddenException('Vous ne pouvez pas émettre ce type de carte'); + } + + const placement = await this.assertPlacementAccess( + userId, + role, + dto.id_placement, + ); + const operation = dto.operation ?? CardOperationType.CREATE; + + let absenceId = dto.id_absence; + const absenceType = this.mapAbsenceType(dto.type_code); + + if (operation === CardOperationType.CREATE) { + const absence = await this.absencesService.creer(userId, role, { + id_placement: dto.id_placement, + type: absenceType, + date_debut: dto.date_debut, + date_fin: dto.date_fin, + motif: dto.motif, + }); + absenceId = absence.id; + } else { + if (!absenceId) { + throw new BadRequestException('id_absence requis pour operation=update'); + } + await this.absencesService.maj(userId, role, absenceId, { + date_debut: dto.date_debut, + date_fin: dto.date_fin, + motif: dto.motif, + // Congé accepté modifié → repasse en attente via cartes respond flow; + // pour absence enfant update immédiat déjà fait. + ...(dto.type_code === 'conge_am' + ? { statut: StatutAbsenceGardeType.EN_ATTENTE } + : {}), + }); + } + + const statutInitial = + type.response_mode === CardResponseModeType.NONE + ? CardInstanceStatutType.TRAITEE + : CardInstanceStatutType.OUVERTE; + + const card = this.cardsRepo.create({ + type_code: type.code, + id_placement: dto.id_placement, + id_absence: absenceId, + cree_par: userId, + operation, + statut: statutInitial, + payload: { + date_debut: dto.date_debut, + date_fin: dto.date_fin, + motif: dto.motif ?? null, + }, + purge_at: this.purgeAt(type.retention_days, statutInitial), + }); + const saved = await this.cardsRepo.save(card); + + if (absenceId) { + await this.linkAbsenceCard(absenceId, saved.id); + } + + await this.buildAudience(saved, type, placement, userId, role); + + const full = await this.loadCard(saved.id); + return this.toDto(full, userId); + } + + async repondre( + userId: string, + role: RoleType, + cardId: string, + dto: RepondreCarteDto, + ): Promise { + const card = await this.loadCard(cardId); + await this.assertInAudience(card, userId); + if (card.statut !== CardInstanceStatutType.OUVERTE) { + throw new BadRequestException('Cette carte n’est plus ouverte'); + } + if (card.cree_par === userId) { + throw new ForbiddenException('Le créateur ne répond pas à sa propre carte'); + } + + const mode = card.type.response_mode; + if (mode === CardResponseModeType.NONE) { + throw new BadRequestException('Ce type de carte ne demande pas de réponse'); + } + if (mode === CardResponseModeType.ACK && dto.action !== CardResponseActionType.ACK) { + throw new BadRequestException('Action attendue : ack'); + } + if ( + mode === CardResponseModeType.ACCEPT_REFUSE && + dto.action !== CardResponseActionType.ACCEPT && + dto.action !== CardResponseActionType.REFUSE + ) { + throw new BadRequestException('Action attendue : accept ou refuse'); + } + if (dto.action === CardResponseActionType.REFUSE && !dto.comment?.trim()) { + throw new BadRequestException('Motivation obligatoire en cas de refus'); + } + + await this.responsesRepo.save( + this.responsesRepo.create({ + id_card: card.id, + id_utilisateur: userId, + action: dto.action, + comment: dto.comment?.trim(), + }), + ); + + if (card.id_absence) { + if (dto.action === CardResponseActionType.ACCEPT || dto.action === CardResponseActionType.ACK) { + await this.absencesService.maj(userId, role, card.id_absence, { + statut: StatutAbsenceGardeType.ACCEPTE, + }); + card.statut = CardInstanceStatutType.TRAITEE; + } else if (dto.action === CardResponseActionType.REFUSE) { + await this.absencesService.maj(userId, role, card.id_absence, { + statut: StatutAbsenceGardeType.REFUSE, + motif: dto.comment!.trim(), + }); + card.statut = CardInstanceStatutType.REFUSEE; + } + } else if (dto.action === CardResponseActionType.ACK) { + card.statut = CardInstanceStatutType.TRAITEE; + } + + card.purge_at = this.purgeAt(card.type.retention_days, card.statut); + await this.cardsRepo.save(card); + return this.toDto(await this.loadCard(cardId), userId); + } + + async majEnAttente( + userId: string, + role: RoleType, + cardId: string, + dto: MajCarteDto, + ): Promise { + const card = await this.loadCard(cardId); + if (card.cree_par !== userId) { + throw new ForbiddenException('Seul le créateur peut modifier cette carte'); + } + 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'] ?? ''); + const fin = dto.date_fin ?? String(card.payload?.['date_fin'] ?? ''); + if (!debut || !fin || fin < debut) { + throw new BadRequestException('Dates invalides'); + } + + if (card.id_absence) { + await this.absencesService.maj(userId, role, card.id_absence, { + date_debut: debut, + date_fin: fin, + motif: dto.motif, + statut: StatutAbsenceGardeType.EN_ATTENTE, + }); + } + + card.payload = { + ...card.payload, + date_debut: debut, + date_fin: fin, + motif: dto.motif ?? card.payload?.['motif'] ?? null, + }; + card.statut = CardInstanceStatutType.OUVERTE; + card.purge_at = this.purgeAt(card.type.retention_days, card.statut); + await this.cardsRepo.save(card); + return this.toDto(await this.loadCard(cardId), userId); + } + + async supprimer( + userId: string, + role: RoleType, + cardId: string, + ): Promise { + const card = await this.loadCard(cardId); + if (card.cree_par !== userId) { + throw new ForbiddenException('Seul le créateur peut supprimer cette carte'); + } + if (card.id_absence) { + const absStatut = + card.statut === CardInstanceStatutType.TRAITEE + ? null + : card.id_absence; + // Supprime l’absence liée si pas encore acceptée définitivement + if ( + card.statut === CardInstanceStatutType.OUVERTE || + card.statut === CardInstanceStatutType.REFUSEE + ) { + await this.absencesService.supprimer(userId, role, card.id_absence); + } + void absStatut; + } + await this.cardsRepo.delete({ id: cardId }); + } + + private mapAbsenceType(typeCode: string): TypeAbsenceGardeType { + if (typeCode === 'absence_enfant' || typeCode === 'absence_enfant_modif') { + return TypeAbsenceGardeType.ABSENCE_ENFANT; + } + if (typeCode === 'conge_am') return TypeAbsenceGardeType.CONGE_AM; + if (typeCode === 'arret_maladie_am') { + return TypeAbsenceGardeType.ARRET_MALADIE_AM; + } + throw new BadRequestException(`Type non mappé à une absence: ${typeCode}`); + } + + private async linkAbsenceCard(absenceId: string, cardId: string): Promise { + // Update direct pour éviter droits maj vides + await this.cardsRepo.manager.query( + `UPDATE absences_garde SET id_card_instance = $1, modifie_le = now() WHERE id = $2`, + [cardId, absenceId], + ); + } + + private async buildAudience( + card: CardInstance, + type: CardType, + placement: AmChildren, + creatorId: string, + creatorRole: RoleType, + ): Promise { + const members: Partial[] = [ + { + id_card: card.id, + id_utilisateur: creatorId, + role_snapshot: creatorRole, + is_creator: true, + }, + ]; + + if (type.audience_resolver === 'couple_am') { + members.push({ + id_card: card.id, + id_utilisateur: placement.amId, + role_snapshot: RoleType.ASSISTANTE_MATERNELLE, + is_creator: false, + }); + } else if (type.audience_resolver === 'couple_parents') { + const liens = await this.parentsChildrenRepo.find({ + where: { enfantId: placement.enfantId }, + }); + for (const l of liens) { + if (l.parentId === creatorId) continue; + members.push({ + id_card: card.id, + id_utilisateur: l.parentId, + role_snapshot: RoleType.PARENT, + is_creator: false, + }); + } + } + + // dédup + const seen = new Set(); + const unique = members.filter((m) => { + const k = m.id_utilisateur!; + if (seen.has(k)) return false; + seen.add(k); + return true; + }); + await this.audienceRepo.save(this.audienceRepo.create(unique)); + } + + private purgeAt( + retentionDays: number, + statut: CardInstanceStatutType, + ): Date { + const days = + statut === CardInstanceStatutType.OUVERTE + ? Math.max(retentionDays, 15) + : retentionDays; + return new Date(Date.now() + days * 24 * 60 * 60 * 1000); + } + + private async loadCard(id: string): Promise { + const card = await this.cardsRepo.findOne({ + where: { id }, + relations: ['type', 'responses', 'audience'], + }); + if (!card) throw new NotFoundException('Carte introuvable'); + return card; + } + + private async assertInAudience(card: CardInstance, userId: string): Promise { + const ok = (card.audience ?? []).some((a) => a.id_utilisateur === userId); + if (!ok) { + const row = await this.audienceRepo.findOne({ + where: { id_card: card.id, id_utilisateur: userId }, + }); + if (!row) throw new ForbiddenException('Carte hors de votre audience'); + } + } + + private async assertPlacementAccess( + userId: string, + role: RoleType, + placementId: string, + ): Promise { + const placement = await this.amChildrenRepo.findOne({ + where: { id: placementId, date_fin: IsNull() }, + }); + if (!placement) { + throw new NotFoundException('Placement introuvable ou inactif'); + } + if (role === RoleType.ASSISTANTE_MATERNELLE) { + if (placement.amId !== userId) { + throw new ForbiddenException('Placement non autorisé'); + } + return placement; + } + if (role === RoleType.PARENT) { + const lien = await this.parentsChildrenRepo.findOne({ + where: { parentId: userId, enfantId: placement.enfantId }, + }); + if (!lien) throw new ForbiddenException('Placement non autorisé'); + return placement; + } + throw new ForbiddenException('Rôle non autorisé'); + } + + private toDto(card: CardInstance, userId: string): CarteDto { + const lastRefuse = [...(card.responses ?? [])] + .reverse() + .find((r) => r.action === CardResponseActionType.REFUSE); + return { + id: card.id, + type_code: card.type_code, + titre: card.type?.titre ?? card.type_code, + couleur: card.type?.couleur ?? null, + id_placement: card.id_placement, + id_absence: card.id_absence ?? null, + operation: card.operation, + statut: card.statut, + payload: card.payload ?? {}, + purge_at: card.purge_at?.toISOString?.() ?? String(card.purge_at), + cree_par: card.cree_par ?? null, + is_creator: card.cree_par === userId, + response_mode: card.type?.response_mode, + last_refuse_comment: lastRefuse?.comment ?? null, + cree_le: card.cree_le?.toISOString?.() ?? String(card.cree_le), + modifie_le: card.modifie_le?.toISOString?.() ?? String(card.modifie_le), + }; + } +} diff --git a/backend/src/modules/cards/dto/cards.dto.ts b/backend/src/modules/cards/dto/cards.dto.ts new file mode 100644 index 0000000..d2fc18b --- /dev/null +++ b/backend/src/modules/cards/dto/cards.dto.ts @@ -0,0 +1,137 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsDateString, + IsEnum, + IsOptional, + IsString, + IsUUID, + MaxLength, + MinLength, + ValidateIf, +} from 'class-validator'; +import { CardInstanceStatutType, CardOperationType } from 'src/entities/card_instances.entity'; +import { CardResponseActionType } from 'src/entities/card_responses.entity'; + +export class CreerCarteDto { + @ApiProperty({ + description: 'absence_enfant | absence_enfant_modif | conge_am | arret_maladie_am', + }) + @IsString() + type_code: string; + + @ApiProperty() + @IsUUID() + id_placement: string; + + @ApiProperty({ example: '2026-10-01' }) + @IsDateString() + date_debut: string; + + @ApiProperty({ example: '2026-10-05' }) + @IsDateString() + date_fin: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MaxLength(2000) + motif?: string; + + @ApiPropertyOptional({ + description: 'Requis pour operation=update (même id absences_garde)', + }) + @IsOptional() + @IsUUID() + id_absence?: string; + + @ApiPropertyOptional({ enum: CardOperationType, default: CardOperationType.CREATE }) + @IsOptional() + @IsEnum(CardOperationType) + operation?: CardOperationType; +} + +export class RepondreCarteDto { + @ApiProperty({ enum: CardResponseActionType }) + @IsEnum(CardResponseActionType) + action: CardResponseActionType; + + @ApiPropertyOptional({ description: 'Obligatoire si action=refuse' }) + @ValidateIf((o) => o.action === CardResponseActionType.REFUSE) + @IsString() + @MinLength(1) + @MaxLength(2000) + comment?: string; +} + +export class MajCarteDto { + @ApiPropertyOptional() + @IsOptional() + @IsDateString() + date_debut?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsDateString() + date_fin?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MaxLength(2000) + motif?: string; +} + +export class CarteDto { + @ApiProperty() + id: string; + + @ApiProperty() + type_code: string; + + @ApiProperty() + titre: string; + + @ApiPropertyOptional() + couleur?: string | null; + + @ApiProperty() + id_placement: string; + + @ApiPropertyOptional() + id_absence?: string | null; + + @ApiProperty({ enum: CardOperationType }) + operation: CardOperationType; + + @ApiProperty({ enum: CardInstanceStatutType }) + statut: CardInstanceStatutType; + + @ApiProperty() + payload: Record; + + @ApiProperty() + purge_at: string; + + @ApiPropertyOptional() + cree_par?: string | null; + + @ApiProperty() + is_creator: boolean; + + @ApiPropertyOptional() + response_mode?: string; + + @ApiPropertyOptional() + last_refuse_comment?: string | null; + + @ApiProperty() + cree_le: string; + + @ApiProperty() + modifie_le: string; +} + +export class ListeCartesDto { + @ApiProperty({ type: [CarteDto] }) + items: CarteDto[]; +} diff --git a/backend/src/modules/cards/index.ts b/backend/src/modules/cards/index.ts new file mode 100644 index 0000000..48c1462 --- /dev/null +++ b/backend/src/modules/cards/index.ts @@ -0,0 +1,2 @@ +export { CardsModule } from './cards.module'; +export { CardsService } from './cards.service'; 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.'; From dfac075a746196e947276bdba2fa8218891bda8b Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Thu, 24 Sep 2026 09:44:00 +0000 Subject: [PATCH 2/3] fix: JwtModule AbsencesGarde + Cards (AuthGuard DI) Corrige crash-loop Nest AuthGuard DI. Co-authored-by: Julien Martin --- .../modules/absences-garde/absences-garde.module.ts | 10 ++++++++++ backend/src/modules/cards/cards.module.ts | 10 ++++++++++ 2 files changed, 20 insertions(+) diff --git a/backend/src/modules/absences-garde/absences-garde.module.ts b/backend/src/modules/absences-garde/absences-garde.module.ts index 174fa37..26480e0 100644 --- a/backend/src/modules/absences-garde/absences-garde.module.ts +++ b/backend/src/modules/absences-garde/absences-garde.module.ts @@ -1,4 +1,6 @@ import { Module } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { JwtModule } from '@nestjs/jwt'; import { TypeOrmModule } from '@nestjs/typeorm'; import { AbsencesGarde } from 'src/entities/absences_garde.entity'; import { AmChildren } from 'src/entities/am_children.entity'; @@ -9,6 +11,14 @@ import { AbsencesGardeService } from './absences-garde.service'; @Module({ imports: [ TypeOrmModule.forFeature([AbsencesGarde, AmChildren, ParentsChildren]), + JwtModule.registerAsync({ + imports: [ConfigModule], + useFactory: (config: ConfigService) => ({ + secret: config.get('jwt.accessSecret'), + signOptions: { expiresIn: config.get('jwt.accessExpiresIn') }, + }), + inject: [ConfigService], + }), ], controllers: [AbsencesGardeController], providers: [AbsencesGardeService], diff --git a/backend/src/modules/cards/cards.module.ts b/backend/src/modules/cards/cards.module.ts index caf9a36..7d6ee06 100644 --- a/backend/src/modules/cards/cards.module.ts +++ b/backend/src/modules/cards/cards.module.ts @@ -1,4 +1,6 @@ import { Module } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { JwtModule } from '@nestjs/jwt'; import { TypeOrmModule } from '@nestjs/typeorm'; import { AbsencesGardeModule } from '../absences-garde'; import { CardType } from 'src/entities/card_types.entity'; @@ -21,6 +23,14 @@ import { CardsService } from './cards.service'; AmChildren, ParentsChildren, ]), + JwtModule.registerAsync({ + imports: [ConfigModule], + useFactory: (config: ConfigService) => ({ + secret: config.get('jwt.accessSecret'), + signOptions: { expiresIn: config.get('jwt.accessExpiresIn') }, + }), + inject: [ConfigService], + }), ], controllers: [CardsController], providers: [CardsService], From 823ea6cd22d5be126c4b6ef3aa338f2c912df0a5 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Thu, 24 Sep 2026 11:50:30 +0200 Subject: [PATCH 3/3] feat(#195): SSE realtime Cartes (stream + emit audience) GET /api/v1/cards/stream (Bearer ou ?access_token=). Events card.created|updated|deleted, response.added + heartbeat. Co-authored-by: Cursor --- .../cards/cards-realtime.controller.ts | 94 +++++++++++++++ .../cards/cards-realtime.service.spec.ts | 47 ++++++++ .../modules/cards/cards-realtime.service.ts | 111 ++++++++++++++++++ backend/src/modules/cards/cards.module.ts | 8 +- .../src/modules/cards/cards.service.spec.ts | 16 ++- backend/src/modules/cards/cards.service.ts | 42 ++++++- backend/src/modules/cards/index.ts | 1 + docs/30_DECOUPAGE-TICKETS-QUOTIDIEN.md | 2 +- 8 files changed, 313 insertions(+), 8 deletions(-) create mode 100644 backend/src/modules/cards/cards-realtime.controller.ts create mode 100644 backend/src/modules/cards/cards-realtime.service.spec.ts create mode 100644 backend/src/modules/cards/cards-realtime.service.ts diff --git a/backend/src/modules/cards/cards-realtime.controller.ts b/backend/src/modules/cards/cards-realtime.controller.ts new file mode 100644 index 0000000..3d162ef --- /dev/null +++ b/backend/src/modules/cards/cards-realtime.controller.ts @@ -0,0 +1,94 @@ +import { + Controller, + Get, + Headers, + MessageEvent, + Query, + Sse, + UnauthorizedException, + UseGuards, +} from '@nestjs/common'; +import { + ApiBearerAuth, + ApiOperation, + ApiQuery, + ApiTags, +} from '@nestjs/swagger'; +import { ConfigService } from '@nestjs/config'; +import { JwtService } from '@nestjs/jwt'; +import { Observable } from 'rxjs'; +import { AuthGuard } from 'src/common/guards/auth.guard'; +import { RolesGuard } from 'src/common/guards/roles.guard'; +import { Roles } from 'src/common/decorators/roles.decorator'; +import { Public } from 'src/common/decorators/public.decorator'; +import { User } from 'src/common/decorators/user.decorator'; +import { RoleType } from 'src/entities/users.entity'; +import { CardsRealtimeService } from './cards-realtime.service'; + +/** + * SSE Cartes — #195 + * Auth : Bearer (recommandé) ou `?access_token=` (EventSource navigateur). + */ +@ApiTags('Cartes') +@Controller('cards') +export class CardsRealtimeController { + constructor( + private readonly realtime: CardsRealtimeService, + private readonly jwtService: JwtService, + private readonly configService: ConfigService, + ) {} + + @Sse('stream') + @Public() + @ApiOperation({ + summary: 'Flux SSE bulles (card.created|updated|deleted, response.added) — #195', + }) + @ApiQuery({ + name: 'access_token', + required: false, + description: 'JWT si pas de header Authorization (EventSource)', + }) + @ApiBearerAuth('access-token') + async stream( + @Headers('authorization') authorization?: string, + @Query('access_token') accessToken?: string, + ): Promise> { + const userId = await this.resolveUserId(authorization, accessToken); + return this.realtime.streamFor(userId); + } + + /** Variante gardée (clients qui envoient Bearer correctement). */ + @Get('stream/info') + @UseGuards(AuthGuard, RolesGuard) + @Roles(RoleType.PARENT, RoleType.ASSISTANTE_MATERNELLE) + @ApiBearerAuth('access-token') + @ApiOperation({ summary: 'Debug : abonnés SSE pour mon user — #195' }) + info(@User('id') userId: string) { + return { + user_id: userId, + subscribers: this.realtime.subscriberCount(userId), + }; + } + + private async resolveUserId( + authorization?: string, + accessToken?: string, + ): Promise { + let token = accessToken?.trim(); + if (!token && authorization?.startsWith('Bearer ')) { + token = authorization.slice(7).trim(); + } + if (!token) { + throw new UnauthorizedException('Token manquant (Bearer ou access_token)'); + } + try { + const payload = await this.jwtService.verifyAsync<{ sub: string }>(token, { + secret: this.configService.get('jwt.accessSecret'), + }); + if (!payload?.sub) throw new UnauthorizedException('Token invalide'); + return payload.sub; + } catch { + throw new UnauthorizedException('Token invalide ou expiré'); + } + } +} diff --git a/backend/src/modules/cards/cards-realtime.service.spec.ts b/backend/src/modules/cards/cards-realtime.service.spec.ts new file mode 100644 index 0000000..3179587 --- /dev/null +++ b/backend/src/modules/cards/cards-realtime.service.spec.ts @@ -0,0 +1,47 @@ +import { MessageEvent } from '@nestjs/common'; +import { CardsRealtimeService } from './cards-realtime.service'; + +describe('CardsRealtimeService (#195)', () => { + let service: CardsRealtimeService; + + beforeEach(() => { + service = new CardsRealtimeService(); + }); + + afterEach(() => { + service.onModuleDestroy(); + }); + + it('émet card.created aux abonnés du user', async () => { + const events: MessageEvent[] = []; + const sub = service.streamFor('user-a').subscribe((e) => events.push(e)); + + await new Promise((r) => setTimeout(r, 20)); + expect(events[0]?.type).toBe('heartbeat'); + expect(service.subscriberCount('user-a')).toBe(1); + + service.emitToUsers(['user-a', 'user-b'], 'card.created', 'card-1', { + id: 'card-1', + }); + await new Promise((r) => setTimeout(r, 20)); + + const created = events.find((e) => e.type === 'card.created'); + expect(created).toBeDefined(); + expect((created!.data as { card_id: string }).card_id).toBe('card-1'); + expect(service.subscriberCount('user-b')).toBe(0); + + sub.unsubscribe(); + expect(service.subscriberCount('user-a')).toBe(0); + }); + + it('ne diffuse pas aux users non abonnés', async () => { + const events: MessageEvent[] = []; + const sub = service.streamFor('user-a').subscribe((e) => events.push(e)); + await new Promise((r) => setTimeout(r, 20)); + const before = events.length; + service.emitToUsers(['user-b'], 'card.updated', 'x'); + await new Promise((r) => setTimeout(r, 20)); + expect(events.length).toBe(before); + sub.unsubscribe(); + }); +}); diff --git a/backend/src/modules/cards/cards-realtime.service.ts b/backend/src/modules/cards/cards-realtime.service.ts new file mode 100644 index 0000000..aa6e434 --- /dev/null +++ b/backend/src/modules/cards/cards-realtime.service.ts @@ -0,0 +1,111 @@ +import { Injectable, MessageEvent, OnModuleDestroy } from '@nestjs/common'; +import { Observable, Subject, interval, merge, takeUntil } from 'rxjs'; +import { map } from 'rxjs/operators'; + +export type CardRealtimeEventType = + | 'card.created' + | 'card.updated' + | 'card.deleted' + | 'response.added' + | 'heartbeat'; + +export interface CardRealtimePayload { + event: CardRealtimeEventType; + card_id?: string; + data?: unknown; + at: string; +} + +/** + * Bus SSE in-memory par utilisateur (V1 mono-instance). + * Rooms = userId (audience carte). + */ +@Injectable() +export class CardsRealtimeService implements OnModuleDestroy { + private readonly byUser = new Map>>(); + private readonly destroy$ = new Subject(); + + onModuleDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + for (const set of this.byUser.values()) { + for (const s of set) s.complete(); + } + this.byUser.clear(); + } + + /** Flux SSE pour un utilisateur authentifié. */ + streamFor(userId: string): Observable { + const subject = new Subject(); + let set = this.byUser.get(userId); + if (!set) { + set = new Set(); + this.byUser.set(userId, set); + } + set.add(subject); + + const heartbeat$ = interval(25_000).pipe( + map( + (): CardRealtimePayload => ({ + event: 'heartbeat', + at: new Date().toISOString(), + }), + ), + ); + + return new Observable((observer) => { + const sub = merge(subject, heartbeat$) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: (payload) => + observer.next({ + type: payload.event, + data: payload, + } as MessageEvent), + error: (err) => observer.error(err), + complete: () => observer.complete(), + }); + + // ping initial + subject.next({ + event: 'heartbeat', + at: new Date().toISOString(), + }); + + return () => { + sub.unsubscribe(); + set!.delete(subject); + subject.complete(); + if (set!.size === 0) this.byUser.delete(userId); + }; + }); + } + + emitToUsers( + userIds: string[], + event: Exclude, + cardId: string | undefined, + data?: unknown, + ): void { + const unique = [...new Set(userIds.filter(Boolean))]; + const payload: CardRealtimePayload = { + event, + card_id: cardId, + data, + at: new Date().toISOString(), + }; + for (const uid of unique) { + const set = this.byUser.get(uid); + if (!set) continue; + for (const s of set) s.next(payload); + } + } + + /** Test / debug : nombre d’abonnés actifs. */ + subscriberCount(userId?: string): number { + if (userId) return this.byUser.get(userId)?.size ?? 0; + let n = 0; + for (const s of this.byUser.values()) n += s.size; + return n; + } +} diff --git a/backend/src/modules/cards/cards.module.ts b/backend/src/modules/cards/cards.module.ts index 7d6ee06..fd418ab 100644 --- a/backend/src/modules/cards/cards.module.ts +++ b/backend/src/modules/cards/cards.module.ts @@ -10,7 +10,9 @@ import { CardResponse } from 'src/entities/card_responses.entity'; import { AmChildren } from 'src/entities/am_children.entity'; import { ParentsChildren } from 'src/entities/parents_children.entity'; import { CardsController } from './cards.controller'; +import { CardsRealtimeController } from './cards-realtime.controller'; import { CardsService } from './cards.service'; +import { CardsRealtimeService } from './cards-realtime.service'; @Module({ imports: [ @@ -32,8 +34,8 @@ import { CardsService } from './cards.service'; inject: [ConfigService], }), ], - controllers: [CardsController], - providers: [CardsService], - exports: [CardsService], + controllers: [CardsController, CardsRealtimeController], + providers: [CardsService, CardsRealtimeService], + exports: [CardsService, CardsRealtimeService], }) export class CardsModule {} diff --git a/backend/src/modules/cards/cards.service.spec.ts b/backend/src/modules/cards/cards.service.spec.ts index fff3f4f..ff621f5 100644 --- a/backend/src/modules/cards/cards.service.spec.ts +++ b/backend/src/modules/cards/cards.service.spec.ts @@ -2,6 +2,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; import { ForbiddenException } from '@nestjs/common'; import { CardsService } from './cards.service'; +import { CardsRealtimeService } from './cards-realtime.service'; import { AbsencesGardeService } from '../absences-garde/absences-garde.service'; import { CardType, CardResponseModeType } from 'src/entities/card_types.entity'; import { @@ -32,6 +33,7 @@ describe('CardsService (#194)', () => { create: jest.fn((x) => x), save: jest.fn(), findOne: jest.fn(), + find: jest.fn(), }; const responsesRepo = { create: jest.fn((x) => x), @@ -44,6 +46,7 @@ describe('CardsService (#194)', () => { maj: jest.fn(), supprimer: jest.fn(), }; + const realtime = { emitToUsers: jest.fn() }; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -56,6 +59,7 @@ describe('CardsService (#194)', () => { { provide: getRepositoryToken(AmChildren), useValue: amChildrenRepo }, { provide: getRepositoryToken(ParentsChildren), useValue: parentsChildrenRepo }, { provide: AbsencesGardeService, useValue: absencesService }, + { provide: CardsRealtimeService, useValue: realtime }, ], }).compile(); service = module.get(CardsService); @@ -112,7 +116,11 @@ describe('CardsService (#194)', () => { retention_days: 14, }, responses: [], - audience: [], + audience: [ + { id_utilisateur: 'am-1' }, + { id_utilisateur: 'p-1' }, + { id_utilisateur: 'p-2' }, + ], }); const res = await service.creer('am-1', RoleType.ASSISTANTE_MATERNELLE, { @@ -124,6 +132,12 @@ describe('CardsService (#194)', () => { expect(absencesService.creer).toHaveBeenCalled(); expect(audienceRepo.save).toHaveBeenCalled(); + expect(realtime.emitToUsers).toHaveBeenCalledWith( + ['am-1', 'p-1', 'p-2'], + 'card.created', + 'card-1', + expect.any(Object), + ); expect(res.type_code).toBe('conge_am'); expect(res.statut).toBe(CardInstanceStatutType.OUVERTE); }); diff --git a/backend/src/modules/cards/cards.service.ts b/backend/src/modules/cards/cards.service.ts index 2d4fb08..750b68c 100644 --- a/backend/src/modules/cards/cards.service.ts +++ b/backend/src/modules/cards/cards.service.ts @@ -32,6 +32,7 @@ import { MajCarteDto, RepondreCarteDto, } from './dto/cards.dto'; +import { CardsRealtimeService } from './cards-realtime.service'; @Injectable() export class CardsService { @@ -49,6 +50,7 @@ export class CardsService { @InjectRepository(ParentsChildren) private readonly parentsChildrenRepo: Repository, private readonly absencesService: AbsencesGardeService, + private readonly realtime: CardsRealtimeService, ) {} async listerTypes(role: RoleType): Promise { @@ -164,7 +166,9 @@ export class CardsService { await this.buildAudience(saved, type, placement, userId, role); const full = await this.loadCard(saved.id); - return this.toDto(full, userId); + const dtoOut = this.toDto(full, userId); + this.emitAudience(full, 'card.created', dtoOut); + return dtoOut; } async repondre( @@ -228,7 +232,14 @@ export class CardsService { card.purge_at = this.purgeAt(card.type.retention_days, card.statut); await this.cardsRepo.save(card); - return this.toDto(await this.loadCard(cardId), userId); + const full = await this.loadCard(cardId); + const dtoOut = this.toDto(full, userId); + this.emitAudience(full, 'response.added', { + action: dto.action, + card: dtoOut, + }); + this.emitAudience(full, 'card.updated', dtoOut); + return dtoOut; } async majEnAttente( @@ -273,7 +284,10 @@ export class CardsService { card.statut = CardInstanceStatutType.OUVERTE; card.purge_at = this.purgeAt(card.type.retention_days, card.statut); await this.cardsRepo.save(card); - return this.toDto(await this.loadCard(cardId), userId); + const full = await this.loadCard(cardId); + const dtoOut = this.toDto(full, userId); + this.emitAudience(full, 'card.updated', dtoOut); + return dtoOut; } async supprimer( @@ -285,6 +299,7 @@ export class CardsService { if (card.cree_par !== userId) { throw new ForbiddenException('Seul le créateur peut supprimer cette carte'); } + const audienceIds = await this.audienceUserIds(card); if (card.id_absence) { const absStatut = card.statut === CardInstanceStatutType.TRAITEE @@ -300,6 +315,27 @@ export class CardsService { void absStatut; } await this.cardsRepo.delete({ id: cardId }); + this.realtime.emitToUsers(audienceIds, 'card.deleted', cardId, { + id: cardId, + }); + } + + private emitAudience( + card: CardInstance, + event: 'card.created' | 'card.updated' | 'response.added', + data: unknown, + ): void { + const ids = (card.audience ?? []).map((a) => a.id_utilisateur); + if (ids.length === 0) return; + this.realtime.emitToUsers(ids, event, card.id, data); + } + + private async audienceUserIds(card: CardInstance): Promise { + if (card.audience?.length) { + return card.audience.map((a) => a.id_utilisateur); + } + const rows = await this.audienceRepo.find({ where: { id_card: card.id } }); + return rows.map((r) => r.id_utilisateur); } private mapAbsenceType(typeCode: string): TypeAbsenceGardeType { diff --git a/backend/src/modules/cards/index.ts b/backend/src/modules/cards/index.ts index 48c1462..c343ce4 100644 --- a/backend/src/modules/cards/index.ts +++ b/backend/src/modules/cards/index.ts @@ -1,2 +1,3 @@ export { CardsModule } from './cards.module'; export { CardsService } from './cards.service'; +export { CardsRealtimeService } from './cards-realtime.service'; diff --git a/docs/30_DECOUPAGE-TICKETS-QUOTIDIEN.md b/docs/30_DECOUPAGE-TICKETS-QUOTIDIEN.md index d519a14..ee69363 100644 --- a/docs/30_DECOUPAGE-TICKETS-QUOTIDIEN.md +++ b/docs/30_DECOUPAGE-TICKETS-QUOTIDIEN.md @@ -107,7 +107,7 @@ Liste des enfants / foyers pour l’AM + contexte courant (symétrique A3). | BDD | Table `absences_garde` + drop `evenements` | | API | CRUD + **GET liste** (`placementId` \| tous les placements du user) | | Cartes SYSTEM | Module `cards/` types S1–S3 (sans sondages V1) | -| Realtime | WS/SSE bulles | +| Realtime | WS/SSE bulles → **SSE** `GET /cards/stream` (#195) | | Purge TTL | Job `expire_at` / `purge_at` | ### Front (après API — hors chantier back immédiat)