diff --git a/api-v3/src/app.module.ts b/api-v3/src/app.module.ts index ed63e7f..65c4bf4 100644 --- a/api-v3/src/app.module.ts +++ b/api-v3/src/app.module.ts @@ -21,6 +21,7 @@ import { InspectionVisitsModule } from './inspection-visits/inspection-visits.mo import { InspectionActsModule } from './inspection-acts/inspection-acts.module'; import { InspectionFindingsModule } from './inspection-findings/inspection-findings.module'; import { InspectionClosingModule } from './inspection-closing/inspection-closing.module'; +import { InspectionDeadlinesModule } from './inspection-deadlines/inspection-deadlines.module'; import { AssetImportsModule } from './asset-imports/asset-imports.module'; import { InspectionReportsModule } from './inspection-reports/inspection-reports.module'; import { InspectionVerificationsModule } from './inspection-verifications/inspection-verifications.module'; @@ -75,6 +76,7 @@ function required(config: ConfigService, key: string): string { InspectionActsModule, InspectionFindingsModule, InspectionClosingModule, + InspectionDeadlinesModule, InspectionReportsModule, InspectionVerificationsModule, ActAdministrationModule, diff --git a/api-v3/src/database/migrations/1790038800000-phase-f4-deadline-administration.ts b/api-v3/src/database/migrations/1790038800000-phase-f4-deadline-administration.ts new file mode 100644 index 0000000..e0afcee --- /dev/null +++ b/api-v3/src/database/migrations/1790038800000-phase-f4-deadline-administration.ts @@ -0,0 +1,38 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class PhaseF4DeadlineAdministration1790038800000 implements MigrationInterface { + name = 'PhaseF4DeadlineAdministration1790038800000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + INSERT INTO permissions (code, description) + VALUES ('inspection_deadlines.manage', 'Administrar plazos institucionales y calendario no laborable de Actas') + ON CONFLICT (code) DO UPDATE SET description = EXCLUDED.description + `); + await queryRunner.query(` + WITH mapping(role_code, permission_code) AS ( + VALUES + ('admin', 'inspection_deadlines.manage'), + ('supervisor', 'inspection_deadlines.manage') + ) + INSERT INTO role_permissions (role_id, permission_id) + SELECT role.id, permission.id + FROM mapping + INNER JOIN roles role ON role.code = mapping.role_code + INNER JOIN permissions permission ON permission.code = mapping.permission_code + ON CONFLICT DO NOTHING + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DELETE FROM role_permissions + WHERE permission_id IN ( + SELECT id FROM permissions WHERE code = 'inspection_deadlines.manage' + ) + `); + await queryRunner.query(` + DELETE FROM permissions WHERE code = 'inspection_deadlines.manage' + `); + } +} diff --git a/api-v3/src/inspection-deadlines/dto/update-inspection-deadline-policy.dto.ts b/api-v3/src/inspection-deadlines/dto/update-inspection-deadline-policy.dto.ts new file mode 100644 index 0000000..77a19f9 --- /dev/null +++ b/api-v3/src/inspection-deadlines/dto/update-inspection-deadline-policy.dto.ts @@ -0,0 +1,14 @@ +import { Transform } from 'class-transformer'; +import { IsEnum, IsInt, Max, Min } from 'class-validator'; +import { InspectionDeadlineDayType } from '../../database/entities'; + +export class UpdateInspectionDeadlinePolicyDto { + @Transform(({ value }) => Number(value)) + @IsInt() + @Min(1) + @Max(365) + days!: number; + + @IsEnum(InspectionDeadlineDayType) + dayType!: InspectionDeadlineDayType; +} diff --git a/api-v3/src/inspection-deadlines/dto/upsert-inspection-non-working-day.dto.ts b/api-v3/src/inspection-deadlines/dto/upsert-inspection-non-working-day.dto.ts new file mode 100644 index 0000000..d300416 --- /dev/null +++ b/api-v3/src/inspection-deadlines/dto/upsert-inspection-non-working-day.dto.ts @@ -0,0 +1,18 @@ +import { Transform } from 'class-transformer'; +import { IsBoolean, IsOptional, IsString, Matches, MaxLength, MinLength } from 'class-validator'; + +export class UpsertInspectionNonWorkingDayDto { + @Matches(/^\d{4}-\d{2}-\d{2}$/) + day!: string; + + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) + @IsString() + @MinLength(2) + @MaxLength(200) + label!: string; + + @IsOptional() + @Transform(({ value }) => value === true || value === 'true') + @IsBoolean() + enabled?: boolean; +} diff --git a/api-v3/src/inspection-deadlines/inspection-deadlines.controller.ts b/api-v3/src/inspection-deadlines/inspection-deadlines.controller.ts new file mode 100644 index 0000000..f1b2fe9 --- /dev/null +++ b/api-v3/src/inspection-deadlines/inspection-deadlines.controller.ts @@ -0,0 +1,46 @@ +import { Body, Controller, Get, Param, ParseEnumPipe, Post, Query, Req } from '@nestjs/common'; +import { CurrentAuth } from '../auth/decorators/current-auth.decorator'; +import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator'; +import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; +import { InspectionActUrgency } from '../database/entities'; +import { UpdateInspectionDeadlinePolicyDto } from './dto/update-inspection-deadline-policy.dto'; +import { UpsertInspectionNonWorkingDayDto } from './dto/upsert-inspection-non-working-day.dto'; +import { InspectionDeadlinesService } from './inspection-deadlines.service'; + +@Controller('inspection-deadlines') +export class InspectionDeadlinesController { + constructor(private readonly deadlines: InspectionDeadlinesService) {} + + @Get('policies') + @RequirePermissions('inspection_deadlines.manage') + policies() { + return this.deadlines.listPolicies(); + } + + @Post('policies/:urgency') + @RequirePermissions('inspection_deadlines.manage') + updatePolicy( + @Param('urgency', new ParseEnumPipe(InspectionActUrgency)) urgency: InspectionActUrgency, + @Body() dto: UpdateInspectionDeadlinePolicyDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.deadlines.updatePolicy(urgency, dto, principal, request); + } + + @Get('non-working-days') + @RequirePermissions('inspection_deadlines.manage') + nonWorkingDays(@Query('year') year?: string) { + return this.deadlines.listNonWorkingDays(year); + } + + @Post('non-working-days') + @RequirePermissions('inspection_deadlines.manage') + upsertNonWorkingDay( + @Body() dto: UpsertInspectionNonWorkingDayDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.deadlines.upsertNonWorkingDay(dto, principal, request); + } +} diff --git a/api-v3/src/inspection-deadlines/inspection-deadlines.module.ts b/api-v3/src/inspection-deadlines/inspection-deadlines.module.ts new file mode 100644 index 0000000..95456c8 --- /dev/null +++ b/api-v3/src/inspection-deadlines/inspection-deadlines.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { AuditModule } from '../audit/audit.module'; +import { InspectionDeadlinesController } from './inspection-deadlines.controller'; +import { InspectionDeadlinesService } from './inspection-deadlines.service'; + +@Module({ + imports: [AuditModule], + controllers: [InspectionDeadlinesController], + providers: [InspectionDeadlinesService], + exports: [InspectionDeadlinesService], +}) +export class InspectionDeadlinesModule {} diff --git a/api-v3/src/inspection-deadlines/inspection-deadlines.service.ts b/api-v3/src/inspection-deadlines/inspection-deadlines.service.ts new file mode 100644 index 0000000..945538c --- /dev/null +++ b/api-v3/src/inspection-deadlines/inspection-deadlines.service.ts @@ -0,0 +1,141 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import { administrationAuditContext } from '../administration/common/administration-audit'; +import { AuditService } from '../audit/audit.service'; +import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; +import { + InspectionActUrgency, + InspectionDeadlinePolicy, + InspectionNonWorkingDay, +} from '../database/entities'; +import type { UpdateInspectionDeadlinePolicyDto } from './dto/update-inspection-deadline-policy.dto'; +import type { UpsertInspectionNonWorkingDayDto } from './dto/upsert-inspection-non-working-day.dto'; + +@Injectable() +export class InspectionDeadlinesService { + constructor( + private readonly dataSource: DataSource, + private readonly audit: AuditService, + ) {} + + async listPolicies() { + const data = await this.dataSource.getRepository(InspectionDeadlinePolicy).find({ + order: { urgency: 'ASC' }, + }); + return { data }; + } + + async updatePolicy( + urgency: InspectionActUrgency, + dto: UpdateInspectionDeadlinePolicyDto, + principal: AuthPrincipal, + request: RequestWithContext, + ) { + return this.dataSource.transaction(async (manager) => { + const repository = manager.getRepository(InspectionDeadlinePolicy); + const policy = await repository.findOne({ where: { urgency } }); + if (!policy) { + throw new NotFoundException({ + code: 'INSPECTION_DEADLINE_POLICY_NOT_FOUND', + message: 'No se encontró la política de plazo solicitada', + }); + } + const before = { + urgency: policy.urgency, + days: policy.days, + dayType: policy.dayType, + basis: policy.basis, + }; + policy.days = dto.days; + policy.dayType = dto.dayType; + policy.updatedBy = principal.userId; + await repository.save(policy); + const after = { + urgency: policy.urgency, + days: policy.days, + dayType: policy.dayType, + basis: policy.basis, + }; + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: 'INSPECTION_DEADLINE_POLICY_UPDATED', + entityType: 'inspection_deadline_policy', + entityId: urgency, + beforeData: before, + afterData: after, + }, manager); + return policy; + }); + } + + async listNonWorkingDays(year?: string) { + const parsedYear = year === undefined ? null : Number(year); + if (parsedYear !== null && (!Number.isInteger(parsedYear) || parsedYear < 2000 || parsedYear > 2200)) { + throw new BadRequestException({ + code: 'INVALID_NON_WORKING_DAY_YEAR', + message: 'El año del calendario no es válido', + }); + } + const parameters: unknown[] = []; + const where = parsedYear === null + ? '' + : `WHERE EXTRACT(YEAR FROM day)::integer = $1`; + if (parsedYear !== null) parameters.push(parsedYear); + const data = await this.dataSource.query(` + SELECT + day::text AS day, + label, + enabled, + created_at AS "createdAt", + updated_at AS "updatedAt" + FROM inspection_non_working_days + ${where} + ORDER BY day ASC + `, parameters); + return { data }; + } + + async upsertNonWorkingDay( + dto: UpsertInspectionNonWorkingDayDto, + principal: AuthPrincipal, + request: RequestWithContext, + ) { + const parsed = new Date(`${dto.day}T00:00:00Z`); + if (!Number.isFinite(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== dto.day) { + throw new BadRequestException({ + code: 'INVALID_NON_WORKING_DAY', + message: 'La fecha no laborable no es válida', + }); + } + return this.dataSource.transaction(async (manager) => { + const repository = manager.getRepository(InspectionNonWorkingDay); + const existing = await repository.findOne({ where: { day: dto.day } }); + const before = existing + ? { day: existing.day, label: existing.label, enabled: existing.enabled } + : null; + const row = existing ?? repository.create({ + day: dto.day, + label: dto.label, + enabled: dto.enabled ?? true, + createdBy: principal.userId, + updatedBy: principal.userId, + }); + row.label = dto.label; + row.enabled = dto.enabled ?? true; + row.updatedBy = principal.userId; + await repository.save(row); + const after = { day: row.day, label: row.label, enabled: row.enabled }; + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: existing + ? 'INSPECTION_NON_WORKING_DAY_UPDATED' + : 'INSPECTION_NON_WORKING_DAY_CREATED', + entityType: 'inspection_non_working_day', + entityId: row.day, + beforeData: before, + afterData: after, + }, manager); + return row; + }); + } +} diff --git a/api-v3/test/unit/phase-f4-deadline-administration-migration.test.ts b/api-v3/test/unit/phase-f4-deadline-administration-migration.test.ts new file mode 100644 index 0000000..94a1e78 --- /dev/null +++ b/api-v3/test/unit/phase-f4-deadline-administration-migration.test.ts @@ -0,0 +1,15 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; + +const root = process.cwd(); +const read = (relative: string) => fs.readFileSync(path.join(root, relative), 'utf8'); + +test('F4 grants deadline administration to system admin and the current JEFE-equivalent supervisor role', () => { + const migration = read('src/database/migrations/1790038800000-phase-f4-deadline-administration.ts'); + assert.match(migration, /inspection_deadlines\.manage/); + assert.match(migration, /\('admin', 'inspection_deadlines\.manage'\)/); + assert.match(migration, /\('supervisor', 'inspection_deadlines\.manage'\)/); + assert.doesNotMatch(migration, /\('inspector', 'inspection_deadlines\.manage'\)/); +}); diff --git a/api-v3/test/unit/phase-f4-deadline-administration.test.ts b/api-v3/test/unit/phase-f4-deadline-administration.test.ts new file mode 100644 index 0000000..a003451 --- /dev/null +++ b/api-v3/test/unit/phase-f4-deadline-administration.test.ts @@ -0,0 +1,43 @@ +import 'reflect-metadata'; +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; +import { REQUIRED_PERMISSIONS_KEY } from '../../src/authorization/decorators/require-permissions.decorator'; +import { InspectionDeadlineDayType } from '../../src/database/entities'; +import { UpdateInspectionDeadlinePolicyDto } from '../../src/inspection-deadlines/dto/update-inspection-deadline-policy.dto'; +import { InspectionDeadlinesController } from '../../src/inspection-deadlines/inspection-deadlines.controller'; + +function permissions(method: string): string[] { + const handler = InspectionDeadlinesController.prototype[ + method as keyof InspectionDeadlinesController + ]; + return Reflect.getMetadata(REQUIRED_PERMISSIONS_KEY, handler) as string[]; +} + +test('F4 deadline administration is protected by one dedicated JEFE permission', () => { + assert.deepEqual(permissions('policies'), ['inspection_deadlines.manage']); + assert.deepEqual(permissions('updatePolicy'), ['inspection_deadlines.manage']); + assert.deepEqual(permissions('nonWorkingDays'), ['inspection_deadlines.manage']); + assert.deepEqual(permissions('upsertNonWorkingDay'), ['inspection_deadlines.manage']); +}); + +test('F4 deadline policy accepts business or calendar days with a bounded amount', async () => { + const valid = plainToInstance(UpdateInspectionDeadlinePolicyDto, { + days: 5, + dayType: InspectionDeadlineDayType.BUSINESS, + }); + assert.equal((await validate(valid)).length, 0); + + const calendar = plainToInstance(UpdateInspectionDeadlinePolicyDto, { + days: 10, + dayType: InspectionDeadlineDayType.CALENDAR, + }); + assert.equal((await validate(calendar)).length, 0); + + const invalid = plainToInstance(UpdateInspectionDeadlinePolicyDto, { + days: 0, + dayType: 'UNKNOWN', + }); + assert.ok((await validate(invalid)).length >= 2); +});