From 8829e8c4f956e5e046d6d06ba5bf356c07b439d5 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Thu, 24 Sep 2026 11:27:42 +0200 Subject: [PATCH] feat(#194): module Cards API + hooks absences_garde. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET/POST/respond/PATCH/DELETE ; créateur en audience ; sans WS. Co-authored-by: Cursor --- backend/src/app.module.ts | 2 + .../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 + 8 files changed, 893 insertions(+), 4 deletions(-) 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 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/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';