F4.1 · administración de plazos por JEFE

This commit is contained in:
2026-09-07 19:46:57 -03:00
parent dac6c94370
commit 0f9aa589b5
9 changed files with 329 additions and 0 deletions
+2
View File
@@ -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,
@@ -0,0 +1,38 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class PhaseF4DeadlineAdministration1790038800000 implements MigrationInterface {
name = 'PhaseF4DeadlineAdministration1790038800000';
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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'
`);
}
}
@@ -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;
}
@@ -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;
}
@@ -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);
}
}
@@ -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 {}
@@ -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;
});
}
}
@@ -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'\)/);
});
@@ -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);
});