feat(#172): API absences-garde (liste, CRUD, droits parent/AM)
Closes #172 Co-authored-by: Julien Martin <julien.martin@ptits-pas.fr>
This commit was merged in pull request #201.
This commit is contained in:
@@ -16,6 +16,7 @@ import { AllExceptionsFilter } from './common/filters/all_exceptions.filters';
|
|||||||
import { EnfantsModule } from './routes/enfants/enfants.module';
|
import { EnfantsModule } from './routes/enfants/enfants.module';
|
||||||
import { AppConfigModule } from './modules/config/config.module';
|
import { AppConfigModule } from './modules/config/config.module';
|
||||||
import { DocumentsLegauxModule } from './modules/documents-legaux';
|
import { DocumentsLegauxModule } from './modules/documents-legaux';
|
||||||
|
import { AbsencesGardeModule } from './modules/absences-garde';
|
||||||
import { RelaisModule } from './routes/relais/relais.module';
|
import { RelaisModule } from './routes/relais/relais.module';
|
||||||
import { DossiersModule } from './routes/dossiers/dossiers.module';
|
import { DossiersModule } from './routes/dossiers/dossiers.module';
|
||||||
import { SuppressionsModule } from './routes/suppressions/suppressions.module';
|
import { SuppressionsModule } from './routes/suppressions/suppressions.module';
|
||||||
@@ -56,6 +57,7 @@ import { SuppressionsModule } from './routes/suppressions/suppressions.module';
|
|||||||
AuthModule,
|
AuthModule,
|
||||||
AppConfigModule,
|
AppConfigModule,
|
||||||
DocumentsLegauxModule,
|
DocumentsLegauxModule,
|
||||||
|
AbsencesGardeModule,
|
||||||
RelaisModule,
|
RelaisModule,
|
||||||
DossiersModule,
|
DossiersModule,
|
||||||
SuppressionsModule,
|
SuppressionsModule,
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { AbsencesGardeController } from './absences-garde.controller';
|
||||||
|
import { AbsencesGardeService } from './absences-garde.service';
|
||||||
|
import { AuthGuard } from 'src/common/guards/auth.guard';
|
||||||
|
import { RolesGuard } from 'src/common/guards/roles.guard';
|
||||||
|
import { RoleType } from 'src/entities/users.entity';
|
||||||
|
import { TypeAbsenceGardeType } from 'src/entities/absences_garde.entity';
|
||||||
|
|
||||||
|
describe('AbsencesGardeController (#172)', () => {
|
||||||
|
let controller: AbsencesGardeController;
|
||||||
|
const serviceMock = {
|
||||||
|
lister: jest.fn(),
|
||||||
|
creer: jest.fn(),
|
||||||
|
maj: jest.fn(),
|
||||||
|
supprimer: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
controllers: [AbsencesGardeController],
|
||||||
|
providers: [{ provide: AbsencesGardeService, useValue: serviceMock }],
|
||||||
|
})
|
||||||
|
.overrideGuard(AuthGuard)
|
||||||
|
.useValue({ canActivate: () => true })
|
||||||
|
.overrideGuard(RolesGuard)
|
||||||
|
.useValue({ canActivate: () => true })
|
||||||
|
.compile();
|
||||||
|
|
||||||
|
controller = module.get(AbsencesGardeController);
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(controller).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lister délègue au service', async () => {
|
||||||
|
serviceMock.lister.mockResolvedValue({ items: [] });
|
||||||
|
const res = await controller.lister(
|
||||||
|
'u1',
|
||||||
|
RoleType.PARENT,
|
||||||
|
'pl-1',
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
'2026-01-01',
|
||||||
|
'2026-12-31',
|
||||||
|
);
|
||||||
|
expect(serviceMock.lister).toHaveBeenCalledWith('u1', RoleType.PARENT, {
|
||||||
|
placementId: 'pl-1',
|
||||||
|
type: undefined,
|
||||||
|
statut: undefined,
|
||||||
|
from: '2026-01-01',
|
||||||
|
to: '2026-12-31',
|
||||||
|
});
|
||||||
|
expect(res.items).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creer délègue au service', async () => {
|
||||||
|
serviceMock.creer.mockResolvedValue({ id: 'a1' });
|
||||||
|
const dto = {
|
||||||
|
id_placement: 'pl-1',
|
||||||
|
type: TypeAbsenceGardeType.ABSENCE_ENFANT,
|
||||||
|
date_debut: '2026-10-01',
|
||||||
|
date_fin: '2026-10-02',
|
||||||
|
};
|
||||||
|
await controller.creer('u1', RoleType.PARENT, dto);
|
||||||
|
expect(serviceMock.creer).toHaveBeenCalledWith(
|
||||||
|
'u1',
|
||||||
|
RoleType.PARENT,
|
||||||
|
dto,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
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 {
|
||||||
|
StatutAbsenceGardeType,
|
||||||
|
TypeAbsenceGardeType,
|
||||||
|
} from 'src/entities/absences_garde.entity';
|
||||||
|
import { RoleType } from 'src/entities/users.entity';
|
||||||
|
import { AbsencesGardeService } from './absences-garde.service';
|
||||||
|
import {
|
||||||
|
AbsenceGardeDto,
|
||||||
|
CreerAbsenceGardeDto,
|
||||||
|
ListeAbsencesGardeDto,
|
||||||
|
MajAbsenceGardeDto,
|
||||||
|
} from './dto/absences-garde.dto';
|
||||||
|
|
||||||
|
@ApiTags('Absences garde')
|
||||||
|
@ApiBearerAuth('access-token')
|
||||||
|
@Controller('absences-garde')
|
||||||
|
@UseGuards(AuthGuard, RolesGuard)
|
||||||
|
export class AbsencesGardeController {
|
||||||
|
constructor(private readonly absencesGardeService: AbsencesGardeService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@Roles(RoleType.PARENT, RoleType.ASSISTANTE_MATERNELLE)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Lister les absences / congés / arrêts — ticket #172',
|
||||||
|
description:
|
||||||
|
'Sans placementId : tous les placements du user (parent = famille multi-AM ; AM = tous accueils). ' +
|
||||||
|
'Avec placementId : un couple enfant–AM.',
|
||||||
|
})
|
||||||
|
@ApiQuery({ name: 'placementId', required: false })
|
||||||
|
@ApiQuery({ name: 'type', required: false, enum: TypeAbsenceGardeType })
|
||||||
|
@ApiQuery({ name: 'statut', required: false, enum: StatutAbsenceGardeType })
|
||||||
|
@ApiQuery({ name: 'from', required: false, description: 'YYYY-MM-DD' })
|
||||||
|
@ApiQuery({ name: 'to', required: false, description: 'YYYY-MM-DD' })
|
||||||
|
@ApiResponse({ status: 200, type: ListeAbsencesGardeDto })
|
||||||
|
lister(
|
||||||
|
@User('id') userId: string,
|
||||||
|
@User('role') role: RoleType,
|
||||||
|
@Query('placementId') placementId?: string,
|
||||||
|
@Query('type') type?: TypeAbsenceGardeType,
|
||||||
|
@Query('statut') statut?: StatutAbsenceGardeType,
|
||||||
|
@Query('from') from?: string,
|
||||||
|
@Query('to') to?: string,
|
||||||
|
): Promise<ListeAbsencesGardeDto> {
|
||||||
|
return this.absencesGardeService.lister(userId, role, {
|
||||||
|
placementId,
|
||||||
|
type,
|
||||||
|
statut,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@Roles(RoleType.PARENT, RoleType.ASSISTANTE_MATERNELLE)
|
||||||
|
@ApiOperation({ summary: 'Créer une période d’absence / congé / arrêt — #172' })
|
||||||
|
@ApiBody({ type: CreerAbsenceGardeDto })
|
||||||
|
@ApiResponse({ status: 201, type: AbsenceGardeDto })
|
||||||
|
@HttpCode(HttpStatus.CREATED)
|
||||||
|
creer(
|
||||||
|
@User('id') userId: string,
|
||||||
|
@User('role') role: RoleType,
|
||||||
|
@Body() dto: CreerAbsenceGardeDto,
|
||||||
|
): Promise<AbsenceGardeDto> {
|
||||||
|
return this.absencesGardeService.creer(userId, role, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
@Roles(RoleType.PARENT, RoleType.ASSISTANTE_MATERNELLE)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Mettre à jour dates / statut / motif — #172',
|
||||||
|
description:
|
||||||
|
'Parent : accept/refuse congé (motivation si refus), ack arrêt. ' +
|
||||||
|
'AM : modifier dates (en attente / refuse / accepté), republication après refus.',
|
||||||
|
})
|
||||||
|
@ApiBody({ type: MajAbsenceGardeDto })
|
||||||
|
@ApiResponse({ status: 200, type: AbsenceGardeDto })
|
||||||
|
maj(
|
||||||
|
@User('id') userId: string,
|
||||||
|
@User('role') role: RoleType,
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: MajAbsenceGardeDto,
|
||||||
|
): Promise<AbsenceGardeDto> {
|
||||||
|
return this.absencesGardeService.maj(userId, role, id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@Roles(RoleType.PARENT, RoleType.ASSISTANTE_MATERNELLE)
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
@ApiOperation({ summary: 'Supprimer une absence (hard delete) — #172' })
|
||||||
|
@ApiResponse({ status: 204 })
|
||||||
|
async supprimer(
|
||||||
|
@User('id') userId: string,
|
||||||
|
@User('role') role: RoleType,
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.absencesGardeService.supprimer(userId, role, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { AbsencesGarde } from 'src/entities/absences_garde.entity';
|
||||||
|
import { AmChildren } from 'src/entities/am_children.entity';
|
||||||
|
import { ParentsChildren } from 'src/entities/parents_children.entity';
|
||||||
|
import { AbsencesGardeController } from './absences-garde.controller';
|
||||||
|
import { AbsencesGardeService } from './absences-garde.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([AbsencesGarde, AmChildren, ParentsChildren]),
|
||||||
|
],
|
||||||
|
controllers: [AbsencesGardeController],
|
||||||
|
providers: [AbsencesGardeService],
|
||||||
|
exports: [AbsencesGardeService],
|
||||||
|
})
|
||||||
|
export class AbsencesGardeModule {}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||||
|
import { ForbiddenException, BadRequestException } from '@nestjs/common';
|
||||||
|
import { AbsencesGardeService } from './absences-garde.service';
|
||||||
|
import {
|
||||||
|
AbsencesGarde,
|
||||||
|
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';
|
||||||
|
|
||||||
|
describe('AbsencesGardeService (#172)', () => {
|
||||||
|
let service: AbsencesGardeService;
|
||||||
|
const absencesRepo = {
|
||||||
|
create: jest.fn((x) => x),
|
||||||
|
save: jest.fn(async (x) => ({
|
||||||
|
...x,
|
||||||
|
id: x.id ?? 'abs-1',
|
||||||
|
cree_le: new Date(),
|
||||||
|
modifie_le: new Date(),
|
||||||
|
})),
|
||||||
|
findOne: jest.fn(),
|
||||||
|
delete: jest.fn(),
|
||||||
|
createQueryBuilder: jest.fn(),
|
||||||
|
};
|
||||||
|
const amChildrenRepo = {
|
||||||
|
find: jest.fn(),
|
||||||
|
findOne: jest.fn(),
|
||||||
|
};
|
||||||
|
const parentsChildrenRepo = {
|
||||||
|
find: jest.fn(),
|
||||||
|
findOne: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
AbsencesGardeService,
|
||||||
|
{ provide: getRepositoryToken(AbsencesGarde), useValue: absencesRepo },
|
||||||
|
{ provide: getRepositoryToken(AmChildren), useValue: amChildrenRepo },
|
||||||
|
{
|
||||||
|
provide: getRepositoryToken(ParentsChildren),
|
||||||
|
useValue: parentsChildrenRepo,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
service = module.get(AbsencesGardeService);
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parent crée absence_enfant → statut accepte', async () => {
|
||||||
|
amChildrenRepo.findOne.mockResolvedValue({
|
||||||
|
id: 'pl-1',
|
||||||
|
amId: 'am-1',
|
||||||
|
enfantId: 'e-1',
|
||||||
|
date_fin: null,
|
||||||
|
});
|
||||||
|
parentsChildrenRepo.findOne.mockResolvedValue({
|
||||||
|
parentId: 'p-1',
|
||||||
|
enfantId: 'e-1',
|
||||||
|
});
|
||||||
|
absencesRepo.findOne.mockResolvedValue({
|
||||||
|
id: 'abs-1',
|
||||||
|
id_placement: 'pl-1',
|
||||||
|
type: TypeAbsenceGardeType.ABSENCE_ENFANT,
|
||||||
|
date_debut: '2026-10-01',
|
||||||
|
date_fin: '2026-10-02',
|
||||||
|
statut: StatutAbsenceGardeType.ACCEPTE,
|
||||||
|
expire_at: new Date('9999-12-31'),
|
||||||
|
cree_le: new Date(),
|
||||||
|
modifie_le: new Date(),
|
||||||
|
placement: {
|
||||||
|
enfantId: 'e-1',
|
||||||
|
amId: 'am-1',
|
||||||
|
child: { id: 'e-1', first_name: 'Léo' },
|
||||||
|
am: { user: { prenom: 'Marie', nom: 'AM' } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await service.creer('p-1', RoleType.PARENT, {
|
||||||
|
id_placement: 'pl-1',
|
||||||
|
type: TypeAbsenceGardeType.ABSENCE_ENFANT,
|
||||||
|
date_debut: '2026-10-01',
|
||||||
|
date_fin: '2026-10-02',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(absencesRepo.save).toHaveBeenCalled();
|
||||||
|
const savedArg = absencesRepo.save.mock.calls[0][0];
|
||||||
|
expect(savedArg.statut).toBe(StatutAbsenceGardeType.ACCEPTE);
|
||||||
|
expect(res.type).toBe(TypeAbsenceGardeType.ABSENCE_ENFANT);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parent ne peut pas créer un congé AM', async () => {
|
||||||
|
amChildrenRepo.findOne.mockResolvedValue({
|
||||||
|
id: 'pl-1',
|
||||||
|
amId: 'am-1',
|
||||||
|
enfantId: 'e-1',
|
||||||
|
date_fin: null,
|
||||||
|
});
|
||||||
|
parentsChildrenRepo.findOne.mockResolvedValue({
|
||||||
|
parentId: 'p-1',
|
||||||
|
enfantId: 'e-1',
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
service.creer('p-1', RoleType.PARENT, {
|
||||||
|
id_placement: 'pl-1',
|
||||||
|
type: TypeAbsenceGardeType.CONGE_AM,
|
||||||
|
date_debut: '2026-10-01',
|
||||||
|
date_fin: '2026-10-05',
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refus congé sans motif → BadRequest', async () => {
|
||||||
|
absencesRepo.findOne.mockResolvedValue({
|
||||||
|
id: 'abs-1',
|
||||||
|
id_placement: 'pl-1',
|
||||||
|
type: TypeAbsenceGardeType.CONGE_AM,
|
||||||
|
date_debut: '2026-10-01',
|
||||||
|
date_fin: '2026-10-05',
|
||||||
|
statut: StatutAbsenceGardeType.EN_ATTENTE,
|
||||||
|
expire_at: new Date(),
|
||||||
|
});
|
||||||
|
amChildrenRepo.findOne.mockResolvedValue({
|
||||||
|
id: 'pl-1',
|
||||||
|
amId: 'am-1',
|
||||||
|
enfantId: 'e-1',
|
||||||
|
date_fin: null,
|
||||||
|
});
|
||||||
|
parentsChildrenRepo.findOne.mockResolvedValue({
|
||||||
|
parentId: 'p-1',
|
||||||
|
enfantId: 'e-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.maj('p-1', RoleType.PARENT, 'abs-1', {
|
||||||
|
statut: StatutAbsenceGardeType.REFUSE,
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dates invalides → BadRequest', async () => {
|
||||||
|
amChildrenRepo.findOne.mockResolvedValue({
|
||||||
|
id: 'pl-1',
|
||||||
|
amId: 'am-1',
|
||||||
|
enfantId: 'e-1',
|
||||||
|
date_fin: null,
|
||||||
|
});
|
||||||
|
parentsChildrenRepo.findOne.mockResolvedValue({
|
||||||
|
parentId: 'p-1',
|
||||||
|
enfantId: 'e-1',
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
service.creer('p-1', RoleType.PARENT, {
|
||||||
|
id_placement: 'pl-1',
|
||||||
|
type: TypeAbsenceGardeType.ABSENCE_ENFANT,
|
||||||
|
date_debut: '2026-10-10',
|
||||||
|
date_fin: '2026-10-01',
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,420 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { In, IsNull, Repository } from 'typeorm';
|
||||||
|
import {
|
||||||
|
AbsencesGarde,
|
||||||
|
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 {
|
||||||
|
AbsenceGardeDto,
|
||||||
|
CreerAbsenceGardeDto,
|
||||||
|
ListeAbsencesGardeDto,
|
||||||
|
MajAbsenceGardeDto,
|
||||||
|
} from './dto/absences-garde.dto';
|
||||||
|
|
||||||
|
const TTL_EN_ATTENTE_MS = 15 * 24 * 60 * 60 * 1000;
|
||||||
|
const TTL_REFUSE_MS = 7 * 24 * 60 * 60 * 1000;
|
||||||
|
/** Sentinel « pas de purge » pour les périodes acceptées */
|
||||||
|
const EXPIRE_ACCEPTE = new Date('9999-12-31T23:59:59.999Z');
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AbsencesGardeService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(AbsencesGarde)
|
||||||
|
private readonly absencesRepo: Repository<AbsencesGarde>,
|
||||||
|
@InjectRepository(AmChildren)
|
||||||
|
private readonly amChildrenRepo: Repository<AmChildren>,
|
||||||
|
@InjectRepository(ParentsChildren)
|
||||||
|
private readonly parentsChildrenRepo: Repository<ParentsChildren>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async lister(
|
||||||
|
userId: string,
|
||||||
|
role: RoleType,
|
||||||
|
opts: {
|
||||||
|
placementId?: string;
|
||||||
|
type?: TypeAbsenceGardeType;
|
||||||
|
statut?: StatutAbsenceGardeType;
|
||||||
|
from?: string;
|
||||||
|
to?: string;
|
||||||
|
},
|
||||||
|
): Promise<ListeAbsencesGardeDto> {
|
||||||
|
const placementIds = await this.resolvePlacementIds(
|
||||||
|
userId,
|
||||||
|
role,
|
||||||
|
opts.placementId,
|
||||||
|
);
|
||||||
|
if (placementIds.length === 0) {
|
||||||
|
return { items: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const qb = this.absencesRepo
|
||||||
|
.createQueryBuilder('ag')
|
||||||
|
.leftJoinAndSelect('ag.placement', 'placement')
|
||||||
|
.leftJoinAndSelect('placement.child', 'child')
|
||||||
|
.leftJoinAndSelect('placement.am', 'am')
|
||||||
|
.leftJoinAndSelect('am.user', 'amUser')
|
||||||
|
.where('ag.id_placement IN (:...placementIds)', { placementIds })
|
||||||
|
.orderBy('ag.date_debut', 'DESC');
|
||||||
|
|
||||||
|
if (opts.type) {
|
||||||
|
qb.andWhere('ag.type = :type', { type: opts.type });
|
||||||
|
}
|
||||||
|
if (opts.statut) {
|
||||||
|
qb.andWhere('ag.statut = :statut', { statut: opts.statut });
|
||||||
|
}
|
||||||
|
if (opts.from) {
|
||||||
|
qb.andWhere('ag.date_fin >= :from', { from: opts.from });
|
||||||
|
}
|
||||||
|
if (opts.to) {
|
||||||
|
qb.andWhere('ag.date_debut <= :to', { to: opts.to });
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await qb.getMany();
|
||||||
|
return { items: rows.map((r) => this.toDto(r)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async creer(
|
||||||
|
userId: string,
|
||||||
|
role: RoleType,
|
||||||
|
dto: CreerAbsenceGardeDto,
|
||||||
|
): Promise<AbsenceGardeDto> {
|
||||||
|
this.assertDates(dto.date_debut, dto.date_fin);
|
||||||
|
await this.assertCanAccessPlacement(userId, role, dto.id_placement);
|
||||||
|
this.assertCanCreateType(role, dto.type);
|
||||||
|
|
||||||
|
const statut = this.statutInitial(dto.type);
|
||||||
|
const entity = this.absencesRepo.create({
|
||||||
|
id_placement: dto.id_placement,
|
||||||
|
type: dto.type,
|
||||||
|
date_debut: dto.date_debut,
|
||||||
|
date_fin: dto.date_fin,
|
||||||
|
statut,
|
||||||
|
expire_at: this.expireAtFor(statut),
|
||||||
|
cree_par: userId,
|
||||||
|
motif: dto.motif?.trim() || undefined,
|
||||||
|
});
|
||||||
|
const saved = await this.absencesRepo.save(entity);
|
||||||
|
return this.getByIdForUser(saved.id, userId, role);
|
||||||
|
}
|
||||||
|
|
||||||
|
async maj(
|
||||||
|
userId: string,
|
||||||
|
role: RoleType,
|
||||||
|
id: string,
|
||||||
|
dto: MajAbsenceGardeDto,
|
||||||
|
): Promise<AbsenceGardeDto> {
|
||||||
|
const row = await this.absencesRepo.findOne({ where: { id } });
|
||||||
|
if (!row) {
|
||||||
|
throw new NotFoundException('Absence introuvable');
|
||||||
|
}
|
||||||
|
await this.assertCanAccessPlacement(userId, role, row.id_placement);
|
||||||
|
|
||||||
|
if (dto.date_debut !== undefined || dto.date_fin !== undefined) {
|
||||||
|
const debut = dto.date_debut ?? row.date_debut;
|
||||||
|
const fin = dto.date_fin ?? row.date_fin;
|
||||||
|
this.assertDates(debut, fin);
|
||||||
|
// Parent : peut modifier ses absences enfant
|
||||||
|
// AM : peut modifier congé/arrêt en_attente (avant accept) ou dates si créateur
|
||||||
|
this.assertCanEditDates(role, row);
|
||||||
|
row.date_debut = debut;
|
||||||
|
row.date_fin = fin;
|
||||||
|
if (row.statut === StatutAbsenceGardeType.EN_ATTENTE) {
|
||||||
|
row.expire_at = this.expireAtFor(StatutAbsenceGardeType.EN_ATTENTE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.statut !== undefined && dto.statut !== row.statut) {
|
||||||
|
this.assertCanChangeStatut(role, row, dto.statut, dto.motif);
|
||||||
|
if (
|
||||||
|
dto.statut === StatutAbsenceGardeType.REFUSE &&
|
||||||
|
(!dto.motif || !dto.motif.trim())
|
||||||
|
) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Une motivation est obligatoire en cas de refus',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
row.statut = dto.statut;
|
||||||
|
row.expire_at = this.expireAtFor(dto.statut);
|
||||||
|
if (dto.motif?.trim()) {
|
||||||
|
row.motif = dto.motif.trim();
|
||||||
|
}
|
||||||
|
} else if (dto.motif !== undefined) {
|
||||||
|
row.motif = dto.motif.trim() || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.absencesRepo.save(row);
|
||||||
|
return this.getByIdForUser(id, userId, role);
|
||||||
|
}
|
||||||
|
|
||||||
|
async supprimer(
|
||||||
|
userId: string,
|
||||||
|
role: RoleType,
|
||||||
|
id: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const row = await this.absencesRepo.findOne({ where: { id } });
|
||||||
|
if (!row) {
|
||||||
|
throw new NotFoundException('Absence introuvable');
|
||||||
|
}
|
||||||
|
await this.assertCanAccessPlacement(userId, role, row.id_placement);
|
||||||
|
|
||||||
|
if (
|
||||||
|
role === RoleType.PARENT &&
|
||||||
|
row.type !== TypeAbsenceGardeType.ABSENCE_ENFANT
|
||||||
|
) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'Un parent ne peut supprimer que les absences enfant',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.absencesRepo.delete({ id });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getByIdForUser(
|
||||||
|
id: string,
|
||||||
|
userId: string,
|
||||||
|
role: RoleType,
|
||||||
|
): Promise<AbsenceGardeDto> {
|
||||||
|
const row = await this.absencesRepo.findOne({
|
||||||
|
where: { id },
|
||||||
|
relations: ['placement', 'placement.child', 'placement.am', 'placement.am.user'],
|
||||||
|
});
|
||||||
|
if (!row) {
|
||||||
|
throw new NotFoundException('Absence introuvable');
|
||||||
|
}
|
||||||
|
await this.assertCanAccessPlacement(userId, role, row.id_placement);
|
||||||
|
return this.toDto(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
private statutInitial(type: TypeAbsenceGardeType): StatutAbsenceGardeType {
|
||||||
|
if (type === TypeAbsenceGardeType.ABSENCE_ENFANT) {
|
||||||
|
return StatutAbsenceGardeType.ACCEPTE;
|
||||||
|
}
|
||||||
|
return StatutAbsenceGardeType.EN_ATTENTE;
|
||||||
|
}
|
||||||
|
|
||||||
|
private expireAtFor(statut: StatutAbsenceGardeType): Date {
|
||||||
|
const now = Date.now();
|
||||||
|
if (statut === StatutAbsenceGardeType.ACCEPTE) {
|
||||||
|
return EXPIRE_ACCEPTE;
|
||||||
|
}
|
||||||
|
if (statut === StatutAbsenceGardeType.REFUSE) {
|
||||||
|
return new Date(now + TTL_REFUSE_MS);
|
||||||
|
}
|
||||||
|
return new Date(now + TTL_EN_ATTENTE_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertDates(debut: string, fin: string): void {
|
||||||
|
if (fin < debut) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'date_fin doit être supérieure ou égale à date_debut',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertCanCreateType(role: RoleType, type: TypeAbsenceGardeType): void {
|
||||||
|
if (role === RoleType.PARENT) {
|
||||||
|
if (type !== TypeAbsenceGardeType.ABSENCE_ENFANT) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'Un parent ne peut créer que des absences enfant',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (role === RoleType.ASSISTANTE_MATERNELLE) {
|
||||||
|
if (
|
||||||
|
type !== TypeAbsenceGardeType.CONGE_AM &&
|
||||||
|
type !== TypeAbsenceGardeType.ARRET_MALADIE_AM
|
||||||
|
) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'Une AM ne peut créer que congé ou arrêt maladie',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new ForbiddenException('Rôle non autorisé à créer une absence');
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertCanEditDates(
|
||||||
|
role: RoleType,
|
||||||
|
row: AbsencesGarde,
|
||||||
|
): void {
|
||||||
|
if (role === RoleType.PARENT) {
|
||||||
|
if (row.type !== TypeAbsenceGardeType.ABSENCE_ENFANT) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'Un parent ne peut modifier que les absences enfant',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (role === RoleType.ASSISTANTE_MATERNELLE) {
|
||||||
|
if (
|
||||||
|
row.type === TypeAbsenceGardeType.CONGE_AM ||
|
||||||
|
row.type === TypeAbsenceGardeType.ARRET_MALADIE_AM
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
row.statut !== StatutAbsenceGardeType.EN_ATTENTE &&
|
||||||
|
row.statut !== StatutAbsenceGardeType.REFUSE &&
|
||||||
|
row.statut !== StatutAbsenceGardeType.ACCEPTE
|
||||||
|
) {
|
||||||
|
throw new ForbiddenException('Statut incompatible avec une modification');
|
||||||
|
}
|
||||||
|
// Accepté : autorisé (S2b — re-validation via cartes plus tard ; API permet update dates)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new ForbiddenException('Type non modifiable par l’AM');
|
||||||
|
}
|
||||||
|
throw new ForbiddenException('Modification non autorisée');
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertCanChangeStatut(
|
||||||
|
role: RoleType,
|
||||||
|
row: AbsencesGarde,
|
||||||
|
next: StatutAbsenceGardeType,
|
||||||
|
_motif?: string,
|
||||||
|
): void {
|
||||||
|
if (role === RoleType.PARENT) {
|
||||||
|
// Accept / refuse congé ; ack arrêt (accepte)
|
||||||
|
if (
|
||||||
|
row.type === TypeAbsenceGardeType.CONGE_AM ||
|
||||||
|
row.type === TypeAbsenceGardeType.ARRET_MALADIE_AM
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
next !== StatutAbsenceGardeType.ACCEPTE &&
|
||||||
|
next !== StatutAbsenceGardeType.REFUSE
|
||||||
|
) {
|
||||||
|
throw new BadRequestException('Transition de statut invalide');
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
row.type === TypeAbsenceGardeType.ARRET_MALADIE_AM &&
|
||||||
|
next === StatutAbsenceGardeType.REFUSE
|
||||||
|
) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Un arrêt maladie ne se refuse pas (accusé seulement)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (row.statut !== StatutAbsenceGardeType.EN_ATTENTE) {
|
||||||
|
throw new BadRequestException('Cette demande n’est plus en attente');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'Pas de changement de statut sur ce type pour un parent',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (role === RoleType.ASSISTANTE_MATERNELLE) {
|
||||||
|
// Remise en attente après refus (republication)
|
||||||
|
if (
|
||||||
|
row.statut === StatutAbsenceGardeType.REFUSE &&
|
||||||
|
next === StatutAbsenceGardeType.EN_ATTENTE
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'L’AM ne valide pas elle-même (sauf republication après refus)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw new ForbiddenException('Changement de statut non autorisé');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolvePlacementIds(
|
||||||
|
userId: string,
|
||||||
|
role: RoleType,
|
||||||
|
placementId?: string,
|
||||||
|
): Promise<string[]> {
|
||||||
|
if (placementId) {
|
||||||
|
await this.assertCanAccessPlacement(userId, role, placementId);
|
||||||
|
return [placementId];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role === RoleType.PARENT) {
|
||||||
|
const liens = await this.parentsChildrenRepo.find({
|
||||||
|
where: { parentId: userId },
|
||||||
|
select: ['enfantId'],
|
||||||
|
});
|
||||||
|
const enfantIds = liens.map((l) => l.enfantId);
|
||||||
|
if (enfantIds.length === 0) return [];
|
||||||
|
const placements = await this.amChildrenRepo.find({
|
||||||
|
where: { enfantId: In(enfantIds), date_fin: IsNull() },
|
||||||
|
select: ['id'],
|
||||||
|
});
|
||||||
|
return placements.map((p) => p.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role === RoleType.ASSISTANTE_MATERNELLE) {
|
||||||
|
const placements = await this.amChildrenRepo.find({
|
||||||
|
where: { amId: userId, date_fin: IsNull() },
|
||||||
|
select: ['id'],
|
||||||
|
});
|
||||||
|
return placements.map((p) => p.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ForbiddenException('Rôle non autorisé');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertCanAccessPlacement(
|
||||||
|
userId: string,
|
||||||
|
role: RoleType,
|
||||||
|
placementId: string,
|
||||||
|
): Promise<AmChildren> {
|
||||||
|
const placement = await this.amChildrenRepo.findOne({
|
||||||
|
where: { id: placementId, date_fin: IsNull() },
|
||||||
|
});
|
||||||
|
if (!placement) {
|
||||||
|
throw new NotFoundException('Placement / couple introuvable ou inactif');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role === RoleType.ASSISTANTE_MATERNELLE) {
|
||||||
|
if (placement.amId !== userId) {
|
||||||
|
throw new ForbiddenException('Ce placement ne vous appartient pas');
|
||||||
|
}
|
||||||
|
return placement;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role === RoleType.PARENT) {
|
||||||
|
const lien = await this.parentsChildrenRepo.findOne({
|
||||||
|
where: { parentId: userId, enfantId: placement.enfantId },
|
||||||
|
});
|
||||||
|
if (!lien) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'Ce placement ne concerne pas un de vos enfants',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return placement;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ForbiddenException('Rôle non autorisé');
|
||||||
|
}
|
||||||
|
|
||||||
|
private toDto(row: AbsencesGarde): AbsenceGardeDto {
|
||||||
|
const child = row.placement?.child;
|
||||||
|
const amUser = row.placement?.am?.user;
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
id_placement: row.id_placement,
|
||||||
|
type: row.type,
|
||||||
|
date_debut: row.date_debut,
|
||||||
|
date_fin: row.date_fin,
|
||||||
|
statut: row.statut,
|
||||||
|
expire_at: row.expire_at?.toISOString?.() ?? String(row.expire_at),
|
||||||
|
cree_par: row.cree_par ?? null,
|
||||||
|
motif: row.motif ?? null,
|
||||||
|
id_enfant: child?.id ?? row.placement?.enfantId ?? null,
|
||||||
|
prenom_enfant: child?.first_name ?? null,
|
||||||
|
id_am: row.placement?.amId ?? null,
|
||||||
|
prenom_am: amUser?.prenom ?? null,
|
||||||
|
nom_am: amUser?.nom ?? null,
|
||||||
|
cree_le: row.cree_le?.toISOString?.() ?? String(row.cree_le),
|
||||||
|
modifie_le: row.modifie_le?.toISOString?.() ?? String(row.modifie_le),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import {
|
||||||
|
IsDateString,
|
||||||
|
IsEnum,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
IsUUID,
|
||||||
|
MaxLength,
|
||||||
|
MinLength,
|
||||||
|
} from 'class-validator';
|
||||||
|
import {
|
||||||
|
StatutAbsenceGardeType,
|
||||||
|
TypeAbsenceGardeType,
|
||||||
|
} from 'src/entities/absences_garde.entity';
|
||||||
|
|
||||||
|
export class CreerAbsenceGardeDto {
|
||||||
|
@ApiProperty({ description: 'Placement AM↔enfant (couple)' })
|
||||||
|
@IsUUID()
|
||||||
|
id_placement: string;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: TypeAbsenceGardeType })
|
||||||
|
@IsEnum(TypeAbsenceGardeType)
|
||||||
|
type: TypeAbsenceGardeType;
|
||||||
|
|
||||||
|
@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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MajAbsenceGardeDto {
|
||||||
|
@ApiPropertyOptional({ example: '2026-10-02' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
date_debut?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: '2026-10-06' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
date_fin?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: StatutAbsenceGardeType })
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(StatutAbsenceGardeType)
|
||||||
|
statut?: StatutAbsenceGardeType;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Motivation de refus (parent) ou motif créateur',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(2000)
|
||||||
|
motif?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AbsenceGardeDto {
|
||||||
|
@ApiProperty()
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
id_placement: string;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: TypeAbsenceGardeType })
|
||||||
|
type: TypeAbsenceGardeType;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
date_debut: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
date_fin: string;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: StatutAbsenceGardeType })
|
||||||
|
statut: StatutAbsenceGardeType;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
expire_at: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
cree_par?: string | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
motif?: string | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
id_enfant?: string | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
prenom_enfant?: string | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
id_am?: string | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
prenom_am?: string | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
nom_am?: string | null;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
cree_le: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
modifie_le: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ListeAbsencesGardeDto {
|
||||||
|
@ApiProperty({ type: [AbsenceGardeDto] })
|
||||||
|
items: AbsenceGardeDto[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { AbsencesGardeModule } from './absences-garde.module';
|
||||||
|
export { AbsencesGardeService } from './absences-garde.service';
|
||||||
Reference in New Issue
Block a user