F4.1 · administración de plazos por JEFE
This commit is contained in:
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user