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 { InspectionActsModule } from './inspection-acts/inspection-acts.module';
|
||||||
import { InspectionFindingsModule } from './inspection-findings/inspection-findings.module';
|
import { InspectionFindingsModule } from './inspection-findings/inspection-findings.module';
|
||||||
import { InspectionClosingModule } from './inspection-closing/inspection-closing.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 { AssetImportsModule } from './asset-imports/asset-imports.module';
|
||||||
import { InspectionReportsModule } from './inspection-reports/inspection-reports.module';
|
import { InspectionReportsModule } from './inspection-reports/inspection-reports.module';
|
||||||
import { InspectionVerificationsModule } from './inspection-verifications/inspection-verifications.module';
|
import { InspectionVerificationsModule } from './inspection-verifications/inspection-verifications.module';
|
||||||
@@ -75,6 +76,7 @@ function required(config: ConfigService, key: string): string {
|
|||||||
InspectionActsModule,
|
InspectionActsModule,
|
||||||
InspectionFindingsModule,
|
InspectionFindingsModule,
|
||||||
InspectionClosingModule,
|
InspectionClosingModule,
|
||||||
|
InspectionDeadlinesModule,
|
||||||
InspectionReportsModule,
|
InspectionReportsModule,
|
||||||
InspectionVerificationsModule,
|
InspectionVerificationsModule,
|
||||||
ActAdministrationModule,
|
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