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.';