chore: import DH V2 D5.6.4 production baseline

This commit is contained in:
DH V2
2026-09-05 10:12:35 -03:00
commit 82213e72f5
757 changed files with 84218 additions and 0 deletions
@@ -0,0 +1,13 @@
import { Transform } from 'class-transformer';
import { IsString, MaxLength, MinLength } from 'class-validator';
const normalizeText = ({ value }: { value: unknown }) =>
typeof value === 'string' ? value.trim() : value;
export class CloseInspectionFindingDto {
@Transform(normalizeText)
@IsString()
@MinLength(3)
@MaxLength(5000)
closureNotes!: string;
}
@@ -0,0 +1,54 @@
import { Transform, Type } from 'class-transformer';
import { IsInt, IsOptional, IsString, IsUUID, Matches, Max, MaxLength, Min, MinLength } from 'class-validator';
const trimNullable = ({ value }: { value: unknown }) =>
typeof value === 'string' ? value.trim() || null : value;
export class CreateFindingCatalogItemDto {
@IsUUID('4')
categoryId!: string;
@Transform(({ value }) => typeof value === 'string' ? value.trim().toUpperCase() : value)
@IsString()
@MinLength(2)
@MaxLength(120)
@Matches(/^[A-Z][A-Z0-9_]+$/)
code!: string;
@Type(() => Number)
@IsInt()
@Min(1)
@Max(999999)
sourceNumber!: number;
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
@IsString()
@MinLength(1)
@MaxLength(500)
title!: string;
@IsOptional()
@Transform(trimNullable)
@IsString()
@MaxLength(12000)
legalBasis?: string | null;
@IsOptional()
@Transform(trimNullable)
@IsString()
@MaxLength(12000)
glossary?: string | null;
@IsOptional()
@Transform(trimNullable)
@IsString()
@MaxLength(4000)
importNote?: string | null;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(10)
suggestedSeverity?: number | null;
}
@@ -0,0 +1,23 @@
import { Transform, Type } from 'class-transformer';
import { IsInt, IsString, Matches, Max, MaxLength, Min, MinLength } from 'class-validator';
export class CreateFindingCategoryDto {
@Transform(({ value }) => typeof value === 'string' ? value.trim().toUpperCase() : value)
@IsString()
@MinLength(2)
@MaxLength(80)
@Matches(/^[A-Z][A-Z0-9_]+$/)
code!: string;
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
@IsString()
@MinLength(1)
@MaxLength(200)
name!: string;
@Type(() => Number)
@IsInt()
@Min(0)
@Max(10000)
sortOrder!: number;
}
@@ -0,0 +1,59 @@
import { Transform } from 'class-transformer';
import {
IsEmail,
IsEnum,
IsISO8601,
IsOptional,
IsString,
MaxLength,
MinLength,
} from 'class-validator';
import {
InspectionCommunicationChannel,
InspectionCommunicationDirection,
InspectionCommunicationType,
} from '../../database/entities';
const trim = ({ value }: { value: unknown }) =>
typeof value === 'string' ? value.trim() : value;
const optionalText = ({ value }: { value: unknown }) =>
typeof value === 'string' && value.trim() ? value.trim() : null;
export class CreateInspectionCommunicationDto {
@IsEnum(InspectionCommunicationDirection)
direction!: InspectionCommunicationDirection;
@IsEnum(InspectionCommunicationChannel)
channel!: InspectionCommunicationChannel;
@IsEnum(InspectionCommunicationType)
type!: InspectionCommunicationType;
@IsISO8601({ strict: true })
occurredAt!: string;
@Transform(trim)
@IsString()
@MinLength(1)
@MaxLength(250)
subject!: string;
@IsOptional()
@Transform(optionalText)
@IsString()
@MaxLength(10000)
details?: string | null;
@IsOptional()
@Transform(optionalText)
@IsString()
@MaxLength(200)
contactName?: string | null;
@IsOptional()
@Transform(optionalText)
@IsEmail()
@MaxLength(320)
contactEmail?: string | null;
}
@@ -0,0 +1,72 @@
import { Type } from 'class-transformer';
import {
IsEnum,
IsISO8601,
IsNumber,
IsOptional,
IsString,
IsUUID,
Max,
MaxLength,
Min,
} from 'class-validator';
import {
InspectionEvidenceKind,
InspectionEvidencePurpose,
} from '../../database/entities';
export class CreateInspectionEvidenceDto {
@IsEnum(InspectionEvidenceKind)
kind!: InspectionEvidenceKind;
@IsEnum(InspectionEvidencePurpose)
purpose!: InspectionEvidencePurpose;
@IsOptional()
@IsUUID('4')
communicationId?: string;
@IsOptional()
@IsUUID('4')
verificationVisitId?: string;
@IsOptional()
@IsString()
@MaxLength(200)
title?: string;
@IsOptional()
@IsString()
@MaxLength(4000)
description?: string;
@IsOptional()
@IsISO8601({ strict: true })
capturedAt?: string;
@IsOptional()
@Type(() => Number)
@IsNumber({ maxDecimalPlaces: 6 })
@Min(-90)
@Max(90)
latitude?: number;
@IsOptional()
@Type(() => Number)
@IsNumber({ maxDecimalPlaces: 6 })
@Min(-180)
@Max(180)
longitude?: number;
@IsOptional()
@Type(() => Number)
@IsNumber({ maxDecimalPlaces: 3 })
@Min(0)
@Max(100000)
accuracyM?: number;
@IsOptional()
@IsString()
@MaxLength(200)
deviceLabel?: string;
}
@@ -0,0 +1,57 @@
import { Transform, Type } from 'class-transformer';
import {
IsInt,
IsOptional,
IsString,
IsUUID,
Matches,
Max,
MaxLength,
Min,
MinLength,
ValidateIf,
} from 'class-validator';
const optionalText = ({ value }: { value: unknown }) =>
typeof value === 'string' && value.trim() ? value.trim() : null;
export class CreateInspectionFindingDto {
@IsUUID('4')
assetId!: string;
@IsOptional()
@Transform(optionalText)
@IsUUID('4')
catalogItemId?: string | null;
@ValidateIf((value: CreateInspectionFindingDto) => !value.catalogItemId)
@Transform(optionalText)
@IsString()
@MinLength(1)
@MaxLength(500)
customTitle?: string | null;
@IsOptional()
@Transform(optionalText)
@IsString()
@MaxLength(12000)
customLegalBasis?: string | null;
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString()
@MinLength(1)
@MaxLength(20000)
description!: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(10)
severity?: number;
@IsOptional()
@Transform(optionalText)
@Matches(/^\d{4}-\d{2}-\d{2}$/)
correctionDueOn?: string | null;
}
@@ -0,0 +1,12 @@
import { IsEnum, IsOptional, IsUUID } from 'class-validator';
import { FindingCatalogProposalStatus } from '../../database/entities';
export class ListFindingCatalogProposalsQueryDto {
@IsOptional()
@IsEnum(FindingCatalogProposalStatus)
status?: FindingCatalogProposalStatus;
@IsOptional()
@IsUUID('4')
assetTypeId?: string;
}
@@ -0,0 +1,14 @@
import { Transform } from 'class-transformer';
import { IsOptional, IsString, IsUUID, MaxLength } from 'class-validator';
export class ListFindingCatalogQueryDto {
@IsOptional()
@IsUUID('4')
categoryId?: string;
@IsOptional()
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString()
@MaxLength(200)
search?: string;
}
@@ -0,0 +1,64 @@
import { Transform, Type } from 'class-transformer';
import { IsIn, IsInt, IsOptional, IsString, IsUUID, Matches, Max, MaxLength, Min } from 'class-validator';
export const FINDING_WORKFLOWS = [
'ALL',
'OPEN',
'WAITING_COMPANY',
'COMPANY_OVERDUE',
'TO_SCHEDULE_VERIFICATION',
'TO_VERIFY',
'VERIFICATION_OVERDUE',
'READY_TO_CLOSE',
'CLOSED',
] as const;
export type FindingWorkflow = typeof FINDING_WORKFLOWS[number];
const normalizeText = ({ value }: { value: unknown }) =>
typeof value === 'string' ? value.trim() : value;
export class ListInspectionFindingsQueryDto {
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page = 1;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
pageSize = 25;
@IsOptional()
@Transform(normalizeText)
@IsString()
@MaxLength(180)
search?: string;
@IsOptional()
@IsIn(FINDING_WORKFLOWS)
workflow: FindingWorkflow = 'OPEN';
@IsOptional()
@IsUUID('4')
companyId?: string;
@IsOptional()
@IsUUID('4')
areaId?: string;
@IsOptional()
@IsUUID('4')
inspectorId?: string;
@IsOptional()
@Matches(/^\d{4}-\d{2}-\d{2}$/)
dateFrom?: string;
@IsOptional()
@Matches(/^\d{4}-\d{2}-\d{2}$/)
dateTo?: string;
}
@@ -0,0 +1,15 @@
import { Transform } from 'class-transformer';
import { ArrayUnique, IsArray, IsString, IsUUID, MaxLength, MinLength } from 'class-validator';
export class ReplaceFindingCatalogSelectionDto {
@IsArray()
@ArrayUnique()
@IsUUID('4', { each: true })
enabledItemIds!: string[];
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
@IsString()
@MinLength(5)
@MaxLength(2000)
reason!: string;
}
@@ -0,0 +1,23 @@
import { Transform } from 'class-transformer';
import { IsEnum, IsOptional, IsString, IsUUID, MaxLength, MinLength, ValidateIf } from 'class-validator';
export enum FindingCatalogProposalDecision {
MATCH = 'MATCH',
REJECT = 'REJECT',
}
export class ReviewFindingCatalogProposalDto {
@IsEnum(FindingCatalogProposalDecision)
decision!: FindingCatalogProposalDecision;
@ValidateIf((value: ReviewFindingCatalogProposalDto) => value.decision === FindingCatalogProposalDecision.MATCH)
@IsUUID('4')
catalogItemId?: string;
@IsOptional()
@Transform(({ value }) => typeof value === 'string' ? value.trim() || null : value)
@IsString()
@MinLength(5)
@MaxLength(4000)
notes?: string | null;
}
@@ -0,0 +1,54 @@
import { Transform, Type } from 'class-transformer';
import { IsBoolean, IsInt, IsOptional, IsString, IsUUID, Max, MaxLength, Min, MinLength } from 'class-validator';
const trimNullable = ({ value }: { value: unknown }) =>
typeof value === 'string' ? value.trim() || null : value;
export class UpdateFindingCatalogItemDto {
@IsOptional()
@IsUUID('4')
categoryId?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(999999)
sourceNumber?: number;
@IsOptional()
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
@IsString()
@MinLength(1)
@MaxLength(500)
title?: string;
@IsOptional()
@Transform(trimNullable)
@IsString()
@MaxLength(12000)
legalBasis?: string | null;
@IsOptional()
@Transform(trimNullable)
@IsString()
@MaxLength(12000)
glossary?: string | null;
@IsOptional()
@Transform(trimNullable)
@IsString()
@MaxLength(4000)
importNote?: string | null;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(10)
suggestedSeverity?: number | null;
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
@@ -0,0 +1,22 @@
import { Transform, Type } from 'class-transformer';
import { IsBoolean, IsInt, IsOptional, IsString, Max, MaxLength, Min, MinLength } from 'class-validator';
export class UpdateFindingCategoryDto {
@IsOptional()
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
@IsString()
@MinLength(1)
@MaxLength(200)
name?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
@Max(10000)
sortOrder?: number;
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
@@ -0,0 +1,59 @@
import { Transform, Type } from 'class-transformer';
import {
IsEnum,
IsInt,
IsOptional,
IsString,
Matches,
Max,
MaxLength,
Min,
MinLength,
} from 'class-validator';
import { InspectionFindingResponseDueBasis } from '../../database/entities';
const optionalText = ({ value }: { value: unknown }) =>
typeof value === 'string' && value.trim() ? value.trim() : null;
const optionalDate = ({ value }: { value: unknown }) =>
typeof value === 'string' && value.trim() ? value.trim() : null;
export class UpdateInspectionFindingFollowUpDto {
@IsOptional()
@IsEnum(InspectionFindingResponseDueBasis)
responseDueBasis?: InspectionFindingResponseDueBasis | null;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
@Max(3650)
responseDueDays?: number | null;
@IsOptional()
@Transform(optionalDate)
@Matches(/^\d{4}-\d{2}-\d{2}$/)
reportNotifiedOn?: string | null;
@IsOptional()
@Transform(optionalText)
@IsString()
@MinLength(1)
@MaxLength(20000)
companyResponse?: string | null;
@IsOptional()
@Transform(optionalDate)
@Matches(/^\d{4}-\d{2}-\d{2}$/)
companyResponseReceivedOn?: string | null;
@IsOptional()
@Transform(optionalDate)
@Matches(/^\d{4}-\d{2}-\d{2}$/)
companyCommittedCorrectionOn?: string | null;
@IsOptional()
@Transform(optionalDate)
@Matches(/^\d{4}-\d{2}-\d{2}$/)
nextControlOn?: string | null;
}
@@ -0,0 +1,40 @@
import { Transform, Type } from 'class-transformer';
import {
IsInt,
IsOptional,
IsString,
IsUUID,
Matches,
Max,
MaxLength,
Min,
MinLength,
} from 'class-validator';
const optionalDate = ({ value }: { value: unknown }) =>
typeof value === 'string' && value.trim() ? value.trim() : null;
export class UpdateInspectionFindingDto {
@IsOptional()
@IsUUID('4')
assetId?: string;
@IsOptional()
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString()
@MinLength(1)
@MaxLength(20000)
description?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(10)
severity?: number;
@IsOptional()
@Transform(optionalDate)
@Matches(/^\d{4}-\d{2}-\d{2}$/)
correctionDueOn?: string | null;
}
@@ -0,0 +1,136 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Put, Query, Req } from '@nestjs/common';
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import { CreateFindingCatalogItemDto } from './dto/create-finding-catalog-item.dto';
import { CreateFindingCategoryDto } from './dto/create-finding-category.dto';
import { ListFindingCatalogProposalsQueryDto } from './dto/list-finding-catalog-proposals-query.dto';
import { ListFindingCatalogQueryDto } from './dto/list-finding-catalog-query.dto';
import { ReplaceFindingCatalogSelectionDto } from './dto/replace-finding-catalog-selection.dto';
import { ReviewFindingCatalogProposalDto } from './dto/review-finding-catalog-proposal.dto';
import { UpdateFindingCatalogItemDto } from './dto/update-finding-catalog-item.dto';
import { UpdateFindingCategoryDto } from './dto/update-finding-category.dto';
import { FindingCatalogService } from './finding-catalog.service';
@Controller('finding-catalog')
export class FindingCatalogController {
constructor(private readonly catalog: FindingCatalogService) {}
@Get()
@RequirePermissions('finding_catalog.read')
list(@Query() query: ListFindingCatalogQueryDto) {
return this.catalog.list(query);
}
@Get('applicable/:assetId')
@RequirePermissions('finding_catalog.read')
applicable(
@Param('assetId', new ParseUUIDPipe({ version: '4' })) assetId: string,
@Query() query: ListFindingCatalogQueryDto,
) {
return this.catalog.listApplicableForAsset(assetId, query);
}
@Get('admin')
@RequirePermissions('finding_catalog.manage')
adminList() {
return this.catalog.listAdmin();
}
@Get('asset-types/:assetTypeId/selection')
@RequirePermissions('finding_catalog.manage')
getAssetTypeSelection(
@Param('assetTypeId', new ParseUUIDPipe({ version: '4' })) assetTypeId: string,
) {
return this.catalog.getAssetTypeSelection(assetTypeId);
}
@Put('asset-types/:assetTypeId/selection')
@RequirePermissions('finding_catalog.manage')
replaceAssetTypeSelection(
@Param('assetTypeId', new ParseUUIDPipe({ version: '4' })) assetTypeId: string,
@Body() dto: ReplaceFindingCatalogSelectionDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.catalog.replaceAssetTypeSelection(assetTypeId, dto, principal, request);
}
@Get('assets/:assetId/selection')
@RequirePermissions('finding_catalog.read')
getAssetSelection(
@Param('assetId', new ParseUUIDPipe({ version: '4' })) assetId: string,
) {
return this.catalog.getAssetSelection(assetId);
}
@Put('assets/:assetId/selection')
@RequirePermissions('finding_catalog.manage')
replaceAssetSelection(
@Param('assetId', new ParseUUIDPipe({ version: '4' })) assetId: string,
@Body() dto: ReplaceFindingCatalogSelectionDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.catalog.replaceAssetSelection(assetId, dto, principal, request);
}
@Get('proposals')
@RequirePermissions('finding_catalog.manage')
proposals(@Query() query: ListFindingCatalogProposalsQueryDto) {
return this.catalog.listProposals(query);
}
@Patch('proposals/:id/review')
@RequirePermissions('finding_catalog.manage')
reviewProposal(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Body() dto: ReviewFindingCatalogProposalDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.catalog.reviewProposal(id, dto, principal, request);
}
@Post('categories')
@RequirePermissions('finding_catalog.manage')
createCategory(
@Body() dto: CreateFindingCategoryDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.catalog.createCategory(dto, principal, request);
}
@Patch('categories/:id')
@RequirePermissions('finding_catalog.manage')
updateCategory(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Body() dto: UpdateFindingCategoryDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.catalog.updateCategory(id, dto, principal, request);
}
@Post('items')
@RequirePermissions('finding_catalog.manage')
createItem(
@Body() dto: CreateFindingCatalogItemDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.catalog.createItem(dto, principal, request);
}
@Patch('items/:id')
@RequirePermissions('finding_catalog.manage')
updateItem(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Body() dto: UpdateFindingCatalogItemDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.catalog.updateItem(id, dto, principal, request);
}
}
@@ -0,0 +1,901 @@
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { DataSource, EntityManager } from 'typeorm';
import {
administrationAuditContext,
isUniqueViolation,
} from '../administration/common/administration-audit';
import { AuditService } from '../audit/audit.service';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import {
AuditAction,
FindingCatalogItem,
FindingCatalogProposalStatus,
FindingCategory,
} from '../database/entities';
import type { CreateFindingCatalogItemDto } from './dto/create-finding-catalog-item.dto';
import type { CreateFindingCategoryDto } from './dto/create-finding-category.dto';
import type { ListFindingCatalogProposalsQueryDto } from './dto/list-finding-catalog-proposals-query.dto';
import type { ListFindingCatalogQueryDto } from './dto/list-finding-catalog-query.dto';
import type { ReplaceFindingCatalogSelectionDto } from './dto/replace-finding-catalog-selection.dto';
import {
FindingCatalogProposalDecision,
type ReviewFindingCatalogProposalDto,
} from './dto/review-finding-catalog-proposal.dto';
import type { UpdateFindingCatalogItemDto } from './dto/update-finding-catalog-item.dto';
import type { UpdateFindingCategoryDto } from './dto/update-finding-category.dto';
export interface FindingCategoryAdminView {
id: string;
code: string;
name: string;
sortOrder: number;
isActive: boolean;
itemCount: number;
activeItemCount: number;
createdAt: Date;
updatedAt: Date;
}
export interface FindingCatalogItemAdminView {
id: string;
categoryId: string;
categoryCode: string;
categoryName: string;
categoryActive: boolean;
code: string;
sourceNumber: number;
title: string;
legalBasis: string | null;
glossary: string | null;
importNote: string | null;
suggestedSeverity: number | null;
revision: number;
isActive: boolean;
usageCount: number;
createdAt: Date;
updatedAt: Date;
}
export interface CatalogSelectionItem {
id: string;
categoryId: string;
categoryName: string;
code: string;
sourceNumber: number;
title: string;
suggestedSeverity: number | null;
enabled: boolean;
}
export interface AssetSelectionItem extends CatalogSelectionItem {
typeDefaultEnabled: boolean;
assetOverride: boolean | null;
}
export interface AssetTypeSelectionView {
assetType: {
id: string;
code: string;
name: string;
operationalRole: string;
};
configured: boolean;
reason: string | null;
items: CatalogSelectionItem[];
}
export interface AssetSelectionView {
asset: {
id: string;
code: string;
name: string;
assetTypeId: string;
assetTypeCode: string;
assetTypeName: string;
};
typeConfigured: boolean;
typeReason: string | null;
items: AssetSelectionItem[];
}
function categoryNotFound(): NotFoundException {
return new NotFoundException({
code: 'FINDING_CATEGORY_NOT_FOUND',
message: 'Categoría de hallazgos no encontrada',
});
}
function itemNotFound(): NotFoundException {
return new NotFoundException({
code: 'FINDING_CATALOG_ITEM_NOT_FOUND',
message: 'Tipo de hallazgo no encontrado',
});
}
function assetTypeNotFound(): NotFoundException {
return new NotFoundException({
code: 'ASSET_TYPE_NOT_FOUND',
message: 'Tipo de Inventario no encontrado',
});
}
function assetNotFound(): NotFoundException {
return new NotFoundException({
code: 'ASSET_NOT_FOUND',
message: 'Registro de Inventario no encontrado',
});
}
function proposalNotFound(): NotFoundException {
return new NotFoundException({
code: 'FINDING_CATALOG_PROPOSAL_NOT_FOUND',
message: 'Propuesta de catálogo no encontrada',
});
}
function catalogConflict(): ConflictException {
return new ConflictException({
code: 'FINDING_CATALOG_DUPLICATE',
message: 'Ya existe ese código o número dentro de la categoría seleccionada',
});
}
@Injectable()
export class FindingCatalogService {
constructor(
private readonly dataSource: DataSource,
private readonly audit: AuditService,
) {}
async list(query: ListFindingCatalogQueryDto) {
const conditions = ['item.is_active = true', 'category.is_active = true'];
const parameters: unknown[] = [];
const add = (value: unknown): string => {
parameters.push(value);
return `$${parameters.length}`;
};
if (query.categoryId) conditions.push(`category.id = ${add(query.categoryId)}`);
if (query.search?.trim()) {
const search = add(`%${query.search.trim()}%`);
conditions.push(`(
item.title ILIKE ${search}
OR COALESCE(item.legal_basis, '') ILIKE ${search}
OR COALESCE(item.glossary, '') ILIKE ${search}
)`);
}
const categories = await this.dataSource.query(`
SELECT id, code, name, sort_order AS "sortOrder"
FROM finding_categories
WHERE is_active = true
ORDER BY sort_order, name, id
`) as unknown[];
const items = await this.dataSource.query(`
SELECT
item.id,
item.category_id AS "categoryId",
item.code,
item.source_number AS "sourceNumber",
item.title,
item.legal_basis AS "legalBasis",
item.glossary,
item.suggested_severity AS "suggestedSeverity",
item.revision,
category.name AS "categoryName"
FROM finding_catalog_items item
INNER JOIN finding_categories category ON category.id = item.category_id
WHERE ${conditions.join(' AND ')}
ORDER BY category.sort_order, item.source_number, item.code
`, parameters) as unknown[];
return { categories, items };
}
async listApplicableForAsset(assetId: string, query: ListFindingCatalogQueryDto) {
const selection = await this.loadAssetSelection(this.dataSource.manager, assetId);
const enabledIds = selection.items.filter((item) => item.enabled).map((item) => item.id);
const filters = ['item.is_active = true', 'category.is_active = true'];
const params: unknown[] = [enabledIds];
if (query.categoryId) {
params.push(query.categoryId);
filters.push(`category.id = $${params.length}::uuid`);
}
if (query.search?.trim()) {
params.push(`%${query.search.trim()}%`);
const search = `$${params.length}`;
filters.push(`(item.title ILIKE ${search} OR COALESCE(item.legal_basis, '') ILIKE ${search} OR COALESCE(item.glossary, '') ILIKE ${search})`);
}
filters.push('item.id = ANY($1::uuid[])');
const [categories, items] = await Promise.all([
this.dataSource.query(`
SELECT DISTINCT category.id, category.code, category.name, category.sort_order AS "sortOrder"
FROM finding_categories category
INNER JOIN finding_catalog_items item ON item.category_id = category.id
WHERE ${filters.join(' AND ')}
ORDER BY category.sort_order, category.name, category.id
`, params),
this.dataSource.query(`
SELECT
item.id,
item.category_id AS "categoryId",
item.code,
item.source_number AS "sourceNumber",
item.title,
item.legal_basis AS "legalBasis",
item.glossary,
item.suggested_severity AS "suggestedSeverity",
item.revision,
category.name AS "categoryName"
FROM finding_catalog_items item
INNER JOIN finding_categories category ON category.id = item.category_id
WHERE ${filters.join(' AND ')}
ORDER BY category.sort_order, item.source_number, item.code
`, params),
]);
return {
asset: selection.asset,
typeConfigured: selection.typeConfigured,
configurationReason: selection.typeReason,
categories,
items,
other: {
enabled: true,
code: 'OTHER',
label: 'OTROS',
help: 'Usalo sólo cuando el hallazgo no exista en el catálogo. Se enviará una propuesta a revisión de oficina.',
},
};
}
async listAdmin(): Promise<{
categories: FindingCategoryAdminView[];
items: FindingCatalogItemAdminView[];
}> {
const [categories, items] = await Promise.all([
this.dataSource.query(this.categoryQuery('')) as Promise<FindingCategoryAdminView[]>,
this.dataSource.query(this.itemQuery('')) as Promise<FindingCatalogItemAdminView[]>,
]);
return { categories, items };
}
async getAssetTypeSelection(assetTypeId: string): Promise<AssetTypeSelectionView> {
return this.loadAssetTypeSelection(this.dataSource.manager, assetTypeId);
}
async replaceAssetTypeSelection(
assetTypeId: string,
dto: ReplaceFindingCatalogSelectionDto,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<AssetTypeSelectionView> {
return this.dataSource.transaction(async (manager) => {
const before = await this.loadAssetTypeSelection(manager, assetTypeId);
await this.validateActiveItemIds(manager, dto.enabledItemIds);
await manager.query('DELETE FROM finding_catalog_item_asset_types WHERE asset_type_id = $1', [assetTypeId]);
if (dto.enabledItemIds.length) {
await manager.query(`
INSERT INTO finding_catalog_item_asset_types (catalog_item_id, asset_type_id, created_by)
SELECT value, $2::uuid, $3::uuid
FROM unnest($1::uuid[]) AS value
`, [dto.enabledItemIds, assetTypeId, principal.userId]);
}
await manager.query(`
INSERT INTO finding_catalog_asset_type_profiles (asset_type_id, reason, updated_by)
VALUES ($1, $2, $3)
ON CONFLICT (asset_type_id) DO UPDATE SET
reason = EXCLUDED.reason,
updated_by = EXCLUDED.updated_by,
updated_at = CURRENT_TIMESTAMP
`, [assetTypeId, dto.reason, principal.userId]);
const after = await this.loadAssetTypeSelection(manager, assetTypeId);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.FINDING_CATALOG_TYPE_APPLICABILITY_UPDATED,
entityType: 'finding_catalog_asset_type_profile',
entityId: assetTypeId,
beforeData: {
configured: before.configured,
enabledItemIds: before.items.filter((item) => item.enabled).map((item) => item.id),
},
afterData: {
configured: after.configured,
enabledItemIds: after.items.filter((item) => item.enabled).map((item) => item.id),
reason: dto.reason,
},
}, manager);
return after;
});
}
async getAssetSelection(assetId: string): Promise<AssetSelectionView> {
return this.loadAssetSelection(this.dataSource.manager, assetId);
}
async replaceAssetSelection(
assetId: string,
dto: ReplaceFindingCatalogSelectionDto,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<AssetSelectionView> {
return this.dataSource.transaction(async (manager) => {
const before = await this.loadAssetSelection(manager, assetId);
await this.validateActiveItemIds(manager, dto.enabledItemIds);
const validIds = new Set(before.items.map((item) => item.id));
const invalid = dto.enabledItemIds.find((id) => !validIds.has(id));
if (invalid) {
throw new BadRequestException({
code: 'FINDING_CATALOG_ITEM_INVALID',
message: 'La selección contiene un tipo de hallazgo inexistente o inactivo',
});
}
const enabled = new Set(dto.enabledItemIds);
await manager.query('DELETE FROM finding_catalog_asset_overrides WHERE asset_id = $1', [assetId]);
for (const item of before.items) {
const desired = enabled.has(item.id);
if (desired === item.typeDefaultEnabled) continue;
await manager.query(`
INSERT INTO finding_catalog_asset_overrides (
asset_id, catalog_item_id, is_enabled, reason, created_by, updated_by
) VALUES ($1, $2, $3, $4, $5, $5)
`, [assetId, item.id, desired, dto.reason, principal.userId]);
}
const after = await this.loadAssetSelection(manager, assetId);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.FINDING_CATALOG_ASSET_SELECTION_UPDATED,
entityType: 'finding_catalog_asset_selection',
entityId: assetId,
beforeData: {
enabledItemIds: before.items.filter((item) => item.enabled).map((item) => item.id),
},
afterData: {
enabledItemIds: after.items.filter((item) => item.enabled).map((item) => item.id),
reason: dto.reason,
},
}, manager);
return after;
});
}
async listProposals(query: ListFindingCatalogProposalsQueryDto) {
const params: unknown[] = [];
const filters: string[] = [];
if (query.status) {
params.push(query.status);
filters.push(`proposal.status = $${params.length}`);
}
if (query.assetTypeId) {
params.push(query.assetTypeId);
filters.push(`proposal.asset_type_id = $${params.length}::uuid`);
}
const where = filters.length ? `WHERE ${filters.join(' AND ')}` : '';
return this.dataSource.query(`
SELECT
proposal.id,
proposal.finding_id AS "findingId",
finding.code AS "findingCode",
proposal.asset_id AS "assetId",
asset.code AS "assetCode",
asset.name AS "assetName",
proposal.asset_type_id AS "assetTypeId",
asset_type.code AS "assetTypeCode",
asset_type.name AS "assetTypeName",
proposal.proposed_title AS "proposedTitle",
proposal.proposed_legal_basis AS "proposedLegalBasis",
proposal.proposed_severity AS "proposedSeverity",
proposal.description,
proposal.status,
proposal.resolved_catalog_item_id AS "resolvedCatalogItemId",
resolved.code AS "resolvedCatalogCode",
resolved.title AS "resolvedCatalogTitle",
proposal.office_notes AS "officeNotes",
proposal.reviewed_by AS "reviewedBy",
reviewer.username AS "reviewedByUsername",
proposal.reviewed_at AS "reviewedAt",
proposal.created_at AS "createdAt",
proposal.updated_at AS "updatedAt"
FROM finding_catalog_proposals proposal
INNER JOIN inspection_findings finding ON finding.id = proposal.finding_id
INNER JOIN assets asset ON asset.id = proposal.asset_id
INNER JOIN asset_types asset_type ON asset_type.id = proposal.asset_type_id
LEFT JOIN finding_catalog_items resolved ON resolved.id = proposal.resolved_catalog_item_id
LEFT JOIN users reviewer ON reviewer.id = proposal.reviewed_by
${where}
ORDER BY CASE proposal.status WHEN 'PENDING' THEN 0 ELSE 1 END,
proposal.created_at DESC,
proposal.id DESC
`, params) as Promise<unknown[]>;
}
async reviewProposal(
id: string,
dto: ReviewFindingCatalogProposalDto,
principal: AuthPrincipal,
request: RequestWithContext,
) {
return this.dataSource.transaction(async (manager) => {
const [proposal] = await manager.query(`
SELECT id, status, asset_type_id AS "assetTypeId"
FROM finding_catalog_proposals
WHERE id = $1
FOR UPDATE
`, [id]) as Array<{ id: string; status: FindingCatalogProposalStatus; assetTypeId: string }>;
if (!proposal) throw proposalNotFound();
if (proposal.status !== FindingCatalogProposalStatus.PENDING) {
throw new ConflictException({
code: 'FINDING_CATALOG_PROPOSAL_ALREADY_REVIEWED',
message: 'La propuesta ya fue resuelta y conserva esa decisión en el historial',
});
}
if (dto.decision === FindingCatalogProposalDecision.MATCH) {
if (!dto.catalogItemId) {
throw new BadRequestException({
code: 'FINDING_CATALOG_PROPOSAL_CATALOG_REQUIRED',
message: 'Seleccioná el tipo de hallazgo del catálogo que corresponde',
});
}
await this.validateActiveItemIds(manager, [dto.catalogItemId]);
await manager.query(`
UPDATE finding_catalog_proposals SET
status = 'MATCHED',
resolved_catalog_item_id = $2,
office_notes = $3,
reviewed_by = $4,
reviewed_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1
`, [id, dto.catalogItemId, dto.notes ?? null, principal.userId]);
const [profile] = await manager.query(`
SELECT asset_type_id FROM finding_catalog_asset_type_profiles WHERE asset_type_id = $1
`, [proposal.assetTypeId]) as unknown[];
if (profile) {
await manager.query(`
INSERT INTO finding_catalog_item_asset_types (catalog_item_id, asset_type_id, created_by)
VALUES ($1, $2, $3)
ON CONFLICT (catalog_item_id, asset_type_id) DO NOTHING
`, [dto.catalogItemId, proposal.assetTypeId, principal.userId]);
}
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.FINDING_CATALOG_PROPOSAL_MATCHED,
entityType: 'finding_catalog_proposal',
entityId: id,
afterData: { status: 'MATCHED', resolvedCatalogItemId: dto.catalogItemId, notes: dto.notes ?? null },
}, manager);
} else {
await manager.query(`
UPDATE finding_catalog_proposals SET
status = 'REJECTED',
office_notes = $2,
reviewed_by = $3,
reviewed_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1
`, [id, dto.notes ?? null, principal.userId]);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.FINDING_CATALOG_PROPOSAL_REJECTED,
entityType: 'finding_catalog_proposal',
entityId: id,
afterData: { status: 'REJECTED', notes: dto.notes ?? null },
}, manager);
}
const [value] = await manager.query(`
SELECT
proposal.id,
proposal.finding_id AS "findingId",
finding.code AS "findingCode",
proposal.asset_id AS "assetId",
asset.code AS "assetCode",
asset.name AS "assetName",
proposal.asset_type_id AS "assetTypeId",
asset_type.code AS "assetTypeCode",
asset_type.name AS "assetTypeName",
proposal.proposed_title AS "proposedTitle",
proposal.proposed_legal_basis AS "proposedLegalBasis",
proposal.proposed_severity AS "proposedSeverity",
proposal.description,
proposal.status,
proposal.resolved_catalog_item_id AS "resolvedCatalogItemId",
resolved.code AS "resolvedCatalogCode",
resolved.title AS "resolvedCatalogTitle",
proposal.office_notes AS "officeNotes",
proposal.reviewed_by AS "reviewedBy",
reviewer.username AS "reviewedByUsername",
proposal.reviewed_at AS "reviewedAt",
proposal.created_at AS "createdAt",
proposal.updated_at AS "updatedAt"
FROM finding_catalog_proposals proposal
INNER JOIN inspection_findings finding ON finding.id = proposal.finding_id
INNER JOIN assets asset ON asset.id = proposal.asset_id
INNER JOIN asset_types asset_type ON asset_type.id = proposal.asset_type_id
LEFT JOIN finding_catalog_items resolved ON resolved.id = proposal.resolved_catalog_item_id
LEFT JOIN users reviewer ON reviewer.id = proposal.reviewed_by
WHERE proposal.id = $1
`, [id]) as Array<{ id: string }>;
if (!value) throw proposalNotFound();
return value;
});
}
async createCategory(
dto: CreateFindingCategoryDto,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<FindingCategoryAdminView> {
try {
return await this.dataSource.transaction(async (manager) => {
const category = manager.getRepository(FindingCategory).create({
code: dto.code,
name: dto.name,
sortOrder: dto.sortOrder,
isActive: true,
});
await manager.getRepository(FindingCategory).save(category);
const created = await this.loadCategory(manager, category.id);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.FINDING_CATEGORY_CREATED,
entityType: 'finding_category',
entityId: category.id,
afterData: { ...created },
}, manager);
return created;
});
} catch (error) {
if (isUniqueViolation(error)) throw catalogConflict();
throw error;
}
}
async updateCategory(
id: string,
dto: UpdateFindingCategoryDto,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<FindingCategoryAdminView> {
if (Object.keys(dto).length === 0) {
throw new BadRequestException({ code: 'NO_CHANGES', message: 'No se recibieron cambios' });
}
return this.dataSource.transaction(async (manager) => {
const repository = manager.getRepository(FindingCategory);
const category = await repository.findOne({ where: { id } });
if (!category) throw categoryNotFound();
const before = await this.loadCategory(manager, id);
if (dto.name !== undefined) category.name = dto.name;
if (dto.sortOrder !== undefined) category.sortOrder = dto.sortOrder;
if (dto.isActive !== undefined) category.isActive = dto.isActive;
await repository.save(category);
const updated = await this.loadCategory(manager, id);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.FINDING_CATEGORY_UPDATED,
entityType: 'finding_category',
entityId: id,
beforeData: { ...before },
afterData: { ...updated },
}, manager);
return updated;
});
}
async createItem(
dto: CreateFindingCatalogItemDto,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<FindingCatalogItemAdminView> {
try {
return await this.dataSource.transaction(async (manager) => {
await this.requireCategory(manager, dto.categoryId);
const item = manager.getRepository(FindingCatalogItem).create({
categoryId: dto.categoryId,
code: dto.code,
sourceNumber: dto.sourceNumber,
title: dto.title,
legalBasis: dto.legalBasis ?? null,
glossary: dto.glossary ?? null,
importNote: dto.importNote ?? null,
suggestedSeverity: dto.suggestedSeverity ?? null,
revision: 1,
isActive: true,
});
await manager.getRepository(FindingCatalogItem).save(item);
const created = await this.loadItem(manager, item.id);
await this.captureVersion(manager, created, principal);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.FINDING_CATALOG_ITEM_CREATED,
entityType: 'finding_catalog_item',
entityId: item.id,
afterData: this.itemAuditView(created),
metadata: { revision: created.revision, categoryId: created.categoryId },
}, manager);
return created;
});
} catch (error) {
if (isUniqueViolation(error)) throw catalogConflict();
throw error;
}
}
async updateItem(
id: string,
dto: UpdateFindingCatalogItemDto,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<FindingCatalogItemAdminView> {
if (Object.keys(dto).length === 0) {
throw new BadRequestException({ code: 'NO_CHANGES', message: 'No se recibieron cambios' });
}
try {
return await this.dataSource.transaction(async (manager) => {
const repository = manager.getRepository(FindingCatalogItem);
const item = await repository.createQueryBuilder('item')
.where('item.id = :id', { id })
.setLock('pessimistic_write')
.getOne();
if (!item) throw itemNotFound();
const before = await this.loadItem(manager, id);
if (dto.categoryId !== undefined) {
await this.requireCategory(manager, dto.categoryId);
item.categoryId = dto.categoryId;
}
if (dto.sourceNumber !== undefined) item.sourceNumber = dto.sourceNumber;
if (dto.title !== undefined) item.title = dto.title;
if (dto.legalBasis !== undefined) item.legalBasis = dto.legalBasis;
if (dto.glossary !== undefined) item.glossary = dto.glossary;
if (dto.importNote !== undefined) item.importNote = dto.importNote;
if (dto.suggestedSeverity !== undefined) item.suggestedSeverity = dto.suggestedSeverity;
if (dto.isActive !== undefined) item.isActive = dto.isActive;
item.revision += 1;
await repository.save(item);
const updated = await this.loadItem(manager, id);
await this.captureVersion(manager, updated, principal);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.FINDING_CATALOG_ITEM_UPDATED,
entityType: 'finding_catalog_item',
entityId: id,
beforeData: this.itemAuditView(before),
afterData: this.itemAuditView(updated),
metadata: { revision: updated.revision, categoryId: updated.categoryId },
}, manager);
return updated;
});
} catch (error) {
if (isUniqueViolation(error)) throw catalogConflict();
throw error;
}
}
private async loadAssetTypeSelection(manager: EntityManager, assetTypeId: string): Promise<AssetTypeSelectionView> {
const [assetType] = await manager.query(`
SELECT type.id, type.code, type.name, type.operational_role AS "operationalRole",
profile.reason, (profile.asset_type_id IS NOT NULL) AS configured
FROM asset_types type
LEFT JOIN finding_catalog_asset_type_profiles profile ON profile.asset_type_id = type.id
WHERE type.id = $1
`, [assetTypeId]) as Array<{
id: string; code: string; name: string; operationalRole: string; reason: string | null; configured: boolean;
}>;
if (!assetType) throw assetTypeNotFound();
const items = await manager.query(`
SELECT
item.id,
item.category_id AS "categoryId",
category.name AS "categoryName",
item.code,
item.source_number AS "sourceNumber",
item.title,
item.suggested_severity AS "suggestedSeverity",
CASE
WHEN $2::boolean = false THEN true
ELSE mapping.id IS NOT NULL
END AS enabled
FROM finding_catalog_items item
INNER JOIN finding_categories category ON category.id = item.category_id
LEFT JOIN finding_catalog_item_asset_types mapping
ON mapping.catalog_item_id = item.id AND mapping.asset_type_id = $1
WHERE item.is_active = true AND category.is_active = true
ORDER BY category.sort_order, item.source_number, item.code
`, [assetTypeId, assetType.configured]) as CatalogSelectionItem[];
return {
assetType: {
id: assetType.id,
code: assetType.code,
name: assetType.name,
operationalRole: assetType.operationalRole,
},
configured: Boolean(assetType.configured),
reason: assetType.reason,
items,
};
}
private async loadAssetSelection(manager: EntityManager, assetId: string): Promise<AssetSelectionView> {
const [asset] = await manager.query(`
SELECT
asset.id,
asset.code,
asset.name,
asset.asset_type_id AS "assetTypeId",
type.code AS "assetTypeCode",
type.name AS "assetTypeName",
profile.reason AS "typeReason",
(profile.asset_type_id IS NOT NULL) AS "typeConfigured"
FROM assets asset
INNER JOIN asset_types type ON type.id = asset.asset_type_id
LEFT JOIN finding_catalog_asset_type_profiles profile ON profile.asset_type_id = asset.asset_type_id
WHERE asset.id = $1
`, [assetId]) as Array<{
id: string; code: string; name: string; assetTypeId: string; assetTypeCode: string; assetTypeName: string;
typeReason: string | null; typeConfigured: boolean;
}>;
if (!asset) throw assetNotFound();
const items = await manager.query(`
SELECT
item.id,
item.category_id AS "categoryId",
category.name AS "categoryName",
item.code,
item.source_number AS "sourceNumber",
item.title,
item.suggested_severity AS "suggestedSeverity",
CASE WHEN $3::boolean = false THEN true ELSE mapping.id IS NOT NULL END AS "typeDefaultEnabled",
override.is_enabled AS "assetOverride",
COALESCE(
override.is_enabled,
CASE WHEN $3::boolean = false THEN true ELSE mapping.id IS NOT NULL END
) AS enabled
FROM finding_catalog_items item
INNER JOIN finding_categories category ON category.id = item.category_id
LEFT JOIN finding_catalog_item_asset_types mapping
ON mapping.catalog_item_id = item.id AND mapping.asset_type_id = $2
LEFT JOIN finding_catalog_asset_overrides override
ON override.catalog_item_id = item.id AND override.asset_id = $1
WHERE item.is_active = true AND category.is_active = true
ORDER BY category.sort_order, item.source_number, item.code
`, [assetId, asset.assetTypeId, asset.typeConfigured]) as AssetSelectionItem[];
return {
asset: {
id: asset.id,
code: asset.code,
name: asset.name,
assetTypeId: asset.assetTypeId,
assetTypeCode: asset.assetTypeCode,
assetTypeName: asset.assetTypeName,
},
typeConfigured: Boolean(asset.typeConfigured),
typeReason: asset.typeReason,
items,
};
}
private async validateActiveItemIds(manager: EntityManager, ids: string[]): Promise<void> {
if (!ids.length) return;
const [row] = await manager.query(`
SELECT COUNT(*)::integer AS count
FROM finding_catalog_items item
INNER JOIN finding_categories category ON category.id = item.category_id
WHERE item.id = ANY($1::uuid[])
AND item.is_active = true
AND category.is_active = true
`, [ids]) as Array<{ count: number }>;
if (Number(row?.count ?? 0) !== ids.length) {
throw new BadRequestException({
code: 'FINDING_CATALOG_ITEM_INVALID',
message: 'La selección contiene un tipo de hallazgo inexistente o inactivo',
});
}
}
private async requireCategory(manager: EntityManager, id: string): Promise<FindingCategory> {
const category = await manager.getRepository(FindingCategory).findOne({ where: { id } });
if (!category) throw categoryNotFound();
return category;
}
private async loadCategory(manager: EntityManager, id: string): Promise<FindingCategoryAdminView> {
const [value] = await manager.query(this.categoryQuery('WHERE category.id = $1'), [id]) as FindingCategoryAdminView[];
if (!value) throw categoryNotFound();
return value;
}
private async loadItem(manager: EntityManager, id: string): Promise<FindingCatalogItemAdminView> {
const [value] = await manager.query(this.itemQuery('WHERE item.id = $1'), [id]) as FindingCatalogItemAdminView[];
if (!value) throw itemNotFound();
return value;
}
private async captureVersion(
manager: EntityManager,
item: FindingCatalogItemAdminView,
principal: AuthPrincipal,
): Promise<void> {
await manager.query(`
INSERT INTO finding_catalog_item_versions (
item_id, revision, snapshot, actor_user_id, actor_username
) VALUES ($1, $2, $3, $4, $5)
`, [
item.id,
item.revision,
this.itemAuditView(item),
principal.userId,
principal.username,
]);
}
private itemAuditView(item: FindingCatalogItemAdminView): Record<string, unknown> {
return {
id: item.id,
categoryId: item.categoryId,
categoryCode: item.categoryCode,
categoryName: item.categoryName,
code: item.code,
sourceNumber: item.sourceNumber,
title: item.title,
legalBasis: item.legalBasis,
glossary: item.glossary,
importNote: item.importNote,
suggestedSeverity: item.suggestedSeverity,
revision: item.revision,
isActive: item.isActive,
};
}
private categoryQuery(where: string): string {
return `
SELECT
category.id,
category.code,
category.name,
category.sort_order AS "sortOrder",
category.is_active AS "isActive",
COUNT(item.id)::integer AS "itemCount",
COUNT(item.id) FILTER (WHERE item.is_active)::integer AS "activeItemCount",
category.created_at AS "createdAt",
category.updated_at AS "updatedAt"
FROM finding_categories category
LEFT JOIN finding_catalog_items item ON item.category_id = category.id
${where}
GROUP BY category.id
ORDER BY category.sort_order, category.name, category.id
`;
}
private itemQuery(where: string): string {
return `
SELECT
item.id,
item.category_id AS "categoryId",
category.code AS "categoryCode",
category.name AS "categoryName",
category.is_active AS "categoryActive",
item.code,
item.source_number AS "sourceNumber",
item.title,
item.legal_basis AS "legalBasis",
item.glossary,
item.import_note AS "importNote",
item.suggested_severity AS "suggestedSeverity",
item.revision,
item.is_active AS "isActive",
(SELECT COUNT(*)::integer FROM inspection_findings finding
WHERE finding.catalog_item_id = item.id) AS "usageCount",
item.created_at AS "createdAt",
item.updated_at AS "updatedAt"
FROM finding_catalog_items item
INNER JOIN finding_categories category ON category.id = item.category_id
${where}
ORDER BY category.sort_order, item.source_number, item.code
`;
}
}
@@ -0,0 +1,77 @@
import { BadRequestException } from '@nestjs/common';
import { InspectionEvidenceKind } from '../database/entities';
export const MAX_INSPECTION_EVIDENCE_BYTES = 15 * 1024 * 1024;
export interface UploadedInspectionEvidenceFile {
buffer: Buffer;
originalname: string;
mimetype?: string;
size: number;
}
export interface InspectedInspectionEvidenceFile {
originalName: string;
mimeType: 'image/jpeg' | 'image/png' | 'image/webp' | 'application/pdf';
extension: '.jpg' | '.png' | '.webp' | '.pdf';
}
function invalidFile(message: string): BadRequestException {
return new BadRequestException({ code: 'INVALID_INSPECTION_EVIDENCE_FILE', message });
}
function detectedType(
buffer: Buffer,
): Omit<InspectedInspectionEvidenceFile, 'originalName'> | null {
if (buffer.length >= 4 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
return { mimeType: 'image/jpeg', extension: '.jpg' };
}
if (
buffer.length >= 8
&& buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
) {
return { mimeType: 'image/png', extension: '.png' };
}
if (
buffer.length >= 12
&& buffer.subarray(0, 4).toString('ascii') === 'RIFF'
&& buffer.subarray(8, 12).toString('ascii') === 'WEBP'
) {
return { mimeType: 'image/webp', extension: '.webp' };
}
if (buffer.length >= 5 && buffer.subarray(0, 5).toString('ascii') === '%PDF-') {
return { mimeType: 'application/pdf', extension: '.pdf' };
}
return null;
}
export function inspectInspectionEvidenceFile(
file: UploadedInspectionEvidenceFile | undefined,
kind: InspectionEvidenceKind,
): InspectedInspectionEvidenceFile {
if (!file?.buffer || file.size <= 0 || file.buffer.length <= 0) {
throw invalidFile('Debe seleccionar un archivo no vacío');
}
if (
file.size > MAX_INSPECTION_EVIDENCE_BYTES
|| file.buffer.length > MAX_INSPECTION_EVIDENCE_BYTES
) {
throw invalidFile('El archivo supera el máximo permitido de 15 MB');
}
const detected = detectedType(file.buffer);
if (!detected) {
throw invalidFile('Sólo se permiten JPG, PNG, WebP y PDF válidos');
}
if (kind === InspectionEvidenceKind.PHOTO && !detected.mimeType.startsWith('image/')) {
throw invalidFile('Una fotografía debe ser JPG, PNG o WebP');
}
if (kind === InspectionEvidenceKind.DOCUMENT && detected.mimeType !== 'application/pdf') {
throw invalidFile('Un documento debe ser un archivo PDF');
}
const originalName = file.originalname
.replace(/[\u0000-\u001f\u007f]/g, '')
.trim()
.slice(0, 255);
if (!originalName) throw invalidFile('El nombre original del archivo no es válido');
return { ...detected, originalName };
}
@@ -0,0 +1,113 @@
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Post,
Query,
Req,
Res,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import type { Response } from 'express';
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 { CreateInspectionCommunicationDto } from './dto/create-inspection-communication.dto';
import { CreateInspectionEvidenceDto } from './dto/create-inspection-evidence.dto';
import {
InspectionEvidenceService,
} from './inspection-evidence.service';
import {
MAX_INSPECTION_EVIDENCE_BYTES,
type UploadedInspectionEvidenceFile,
} from './inspection-evidence-file';
@Controller('inspection-findings/:findingId/evidence')
export class InspectionFindingEvidenceController {
constructor(private readonly evidence: InspectionEvidenceService) {}
@Get()
@RequirePermissions('inspection_evidence.read')
list(
@Param('findingId', new ParseUUIDPipe({ version: '4' })) findingId: string,
) {
return this.evidence.listEvidence(findingId);
}
@Post()
@RequirePermissions('inspection_evidence.create')
@UseInterceptors(FileInterceptor('file', {
limits: { fileSize: MAX_INSPECTION_EVIDENCE_BYTES, files: 1 },
}))
upload(
@Param('findingId', new ParseUUIDPipe({ version: '4' })) findingId: string,
@Body() dto: CreateInspectionEvidenceDto,
@UploadedFile() file: UploadedInspectionEvidenceFile | undefined,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.evidence.uploadEvidence(findingId, dto, file, principal, request);
}
}
@Controller('inspection-findings/:findingId/communications')
export class InspectionFindingCommunicationsController {
constructor(private readonly evidence: InspectionEvidenceService) {}
@Get()
@RequirePermissions('inspection_communications.read')
list(
@Param('findingId', new ParseUUIDPipe({ version: '4' })) findingId: string,
) {
return this.evidence.listCommunications(findingId);
}
@Post()
@RequirePermissions('inspection_communications.create')
create(
@Param('findingId', new ParseUUIDPipe({ version: '4' })) findingId: string,
@Body() dto: CreateInspectionCommunicationDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.evidence.createCommunication(findingId, dto, principal, request);
}
}
@Controller('inspection-finding-evidence')
export class InspectionEvidenceContentController {
constructor(private readonly evidence: InspectionEvidenceService) {}
@Get(':evidenceId/content')
@RequirePermissions('inspection_evidence.read')
async content(
@Param('evidenceId', new ParseUUIDPipe({ version: '4' })) evidenceId: string,
@Query('download') download: string | undefined,
@Res() response: Response,
): Promise<void> {
const { filePath, evidence } = await this.evidence.content(evidenceId);
const disposition = download === '1' ? 'attachment' : 'inline';
const fallbackName = evidence.originalName
.replace(/[^\x20-\x7e]/g, '_')
.replace(/["\\]/g, '_');
response.setHeader('Content-Type', evidence.mimeType);
response.setHeader('Content-Length', String(evidence.sizeBytes));
response.setHeader(
'Content-Disposition',
`${disposition}; filename="${fallbackName}"; filename*=UTF-8''${encodeURIComponent(evidence.originalName)}`,
);
response.setHeader('Cache-Control', 'private, no-store');
response.setHeader('X-Content-Type-Options', 'nosniff');
response.setHeader('Content-Security-Policy', "sandbox; default-src 'none'");
await new Promise<void>((resolveSend, rejectSend) => {
response.sendFile(filePath, (error) => {
if (error) rejectSend(error);
else resolveSend();
});
});
}
}
@@ -0,0 +1,681 @@
import { createHash, randomUUID } from 'node:crypto';
import { mkdir, stat, unlink, writeFile } from 'node:fs/promises';
import { isAbsolute, parse, resolve } from 'node:path';
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
InternalServerErrorException,
NotFoundException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { DataSource, EntityManager } 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 { assertMobileInspector } from '../inspection-operations/mobile-inspector-policy';
import {
AuditAction,
InspectionActStatus,
InspectionCommunicationDirection,
InspectionCommunicationType,
InspectionEvidenceKind,
InspectionEvidencePurpose,
InspectionEvidenceSource,
InspectionFindingStatus,
InspectionVisitStatus,
} from '../database/entities';
import type { CreateInspectionCommunicationDto } from './dto/create-inspection-communication.dto';
import type { CreateInspectionEvidenceDto } from './dto/create-inspection-evidence.dto';
import {
inspectInspectionEvidenceFile,
type UploadedInspectionEvidenceFile,
} from './inspection-evidence-file';
interface FindingContext {
id: string;
status: InspectionFindingStatus;
actId: string;
actStatus: InspectionActStatus;
visitId: string;
visitStatus: InspectionVisitStatus;
}
interface CommunicationLink {
id: string;
type: InspectionCommunicationType;
direction: InspectionCommunicationDirection;
subject: string;
}
export interface InspectionCommunicationView {
id: string;
findingId: string;
direction: string;
channel: string;
type: string;
occurredAt: Date;
subject: string;
details: string | null;
contactName: string | null;
contactEmail: string | null;
createdBy: string | null;
createdByUsername: string | null;
attachmentCount: number;
createdAt: Date;
}
export interface InspectionEvidenceView {
id: string;
findingId: string;
communicationId: string | null;
verificationVisitId: string | null;
communication: CommunicationLink | null;
kind: InspectionEvidenceKind;
purpose: InspectionEvidencePurpose;
originalName: string;
mimeType: string;
sizeBytes: number;
sha256: string;
title: string | null;
description: string | null;
capturedAt: Date | null;
latitude: number | null;
longitude: number | null;
accuracyM: number | null;
deviceLabel: string | null;
source: InspectionEvidenceSource;
uploadedBy: string | null;
uploadedByUsername: string | null;
createdAt: Date;
}
interface StoredInspectionEvidence extends InspectionEvidenceView {
storedName: string;
}
function findingNotFound(): NotFoundException {
return new NotFoundException({
code: 'INSPECTION_FINDING_NOT_FOUND',
message: 'Hallazgo de inspección no encontrado',
});
}
function evidenceNotFound(): NotFoundException {
return new NotFoundException({
code: 'INSPECTION_EVIDENCE_NOT_FOUND',
message: 'Evidencia de inspección no encontrada',
});
}
@Injectable()
export class InspectionEvidenceService {
private readonly storageRoot: string;
constructor(
private readonly dataSource: DataSource,
private readonly audit: AuditService,
config: ConfigService,
) {
const configured = config.get<string>('INSPECTION_EVIDENCE_ROOT')
?? '/app/storage/asset-media/inspection-findings';
if (!isAbsolute(configured)) {
throw new Error('INSPECTION_EVIDENCE_ROOT must be an absolute path');
}
this.storageRoot = resolve(configured);
if (this.storageRoot === parse(this.storageRoot).root) {
throw new Error('INSPECTION_EVIDENCE_ROOT cannot be the filesystem root');
}
}
async listEvidence(findingId: string): Promise<{ data: InspectionEvidenceView[] }> {
await this.requireFinding(this.dataSource.manager, findingId);
const rows = await this.dataSource.query(
`${this.evidenceSelect()}
WHERE evidence.finding_id = $1
ORDER BY evidence.created_at DESC, evidence.id`,
[findingId],
) as StoredInspectionEvidence[];
return {
data: rows.map(({ storedName: _storedName, ...evidence }) => evidence),
};
}
async uploadEvidence(
findingId: string,
dto: CreateInspectionEvidenceDto,
file: UploadedInspectionEvidenceFile | undefined,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<InspectionEvidenceView> {
if ([InspectionEvidencePurpose.OBSERVATION, InspectionEvidencePurpose.VERIFICATION].includes(dto.purpose)) {
assertMobileInspector(principal);
}
this.validateCoordinates(dto.latitude, dto.longitude, dto.accuracyM);
const inspected = inspectInspectionEvidenceFile(file, dto.kind);
this.validatePurpose(dto, inspected.mimeType);
const id = randomUUID();
const storedName = `${id}${inspected.extension}`;
const filePath = resolve(this.storageRoot, storedName);
const sha256 = createHash('sha256').update(file!.buffer).digest('hex');
const source = principal.transport === 'bearer'
? InspectionEvidenceSource.ANDROID
: InspectionEvidenceSource.WEB;
await mkdir(this.storageRoot, { recursive: true, mode: 0o700 });
await writeFile(filePath, file!.buffer, { flag: 'wx', mode: 0o600 });
try {
return await this.dataSource.transaction(async (manager) => {
const context = await this.lockFindingContext(manager, findingId);
this.assertFindingOpen(context);
if (dto.purpose === InspectionEvidencePurpose.OBSERVATION) {
this.assertObservationEditable(context);
await this.assertActorAssigned(manager, context.visitId, principal);
if (dto.verificationVisitId) {
throw new BadRequestException({
code: 'INSPECTION_OBSERVATION_VERIFICATION_VISIT_NOT_ALLOWED',
message: 'La evidencia inicial no debe indicar una visita de verificación',
});
}
}
if (dto.purpose === InspectionEvidencePurpose.VERIFICATION) {
if (!dto.verificationVisitId) {
throw new BadRequestException({
code: 'INSPECTION_VERIFICATION_VISIT_REQUIRED',
message: 'La foto de verificación debe indicar la visita en curso',
});
}
await this.assertVerificationEditable(
manager,
findingId,
dto.verificationVisitId,
principal,
);
} else if (dto.verificationVisitId) {
throw new BadRequestException({
code: 'INSPECTION_VERIFICATION_VISIT_PURPOSE_INVALID',
message: 'La visita de verificación sólo corresponde a evidencia de verificación',
});
}
const communication = dto.communicationId
? await this.requireCommunication(manager, findingId, dto.communicationId)
: null;
this.validateCommunicationPurpose(dto.purpose, communication);
await manager.query(`
INSERT INTO inspection_finding_evidence (
id, finding_id, communication_id, verification_visit_id, kind, purpose,
original_name, stored_name, mime_type, size_bytes, sha256,
title, description, captured_at, latitude, longitude, accuracy_m,
device_label, source, uploaded_by
) VALUES (
$1, $2, $3, $4, $5, $6,
$7, $8, $9, $10, $11,
$12, $13, $14, $15, $16, $17,
$18, $19, $20
)
`, [
id,
findingId,
dto.communicationId ?? null,
dto.verificationVisitId ?? null,
dto.kind,
dto.purpose,
inspected.originalName,
storedName,
inspected.mimeType,
file!.buffer.length,
sha256,
dto.title?.trim() || null,
dto.description?.trim() || null,
dto.capturedAt ? new Date(dto.capturedAt) : null,
dto.latitude ?? null,
dto.longitude ?? null,
dto.accuracyM ?? null,
dto.deviceLabel?.trim() || null,
source,
principal.userId,
]);
const created = await this.loadEvidence(manager, id);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.INSPECTION_FINDING_EVIDENCE_UPLOADED,
entityType: 'inspection_finding_evidence',
entityId: id,
afterData: this.evidenceAuditView(created),
metadata: {
findingId,
actId: context.actId,
visitId: dto.verificationVisitId ?? context.visitId,
sourceVisitId: context.visitId,
verificationVisitId: dto.verificationVisitId ?? null,
communicationId: dto.communicationId ?? null,
},
}, manager);
const { storedName: _storedName, ...view } = created;
return view;
});
} catch (error) {
await unlink(filePath).catch(() => undefined);
throw error;
}
}
async listCommunications(
findingId: string,
): Promise<{ data: InspectionCommunicationView[] }> {
await this.requireFinding(this.dataSource.manager, findingId);
return {
data: await this.dataSource.query(
`${this.communicationSelect('WHERE communication.finding_id = $1')}
ORDER BY communication.occurred_at DESC, communication.created_at DESC`,
[findingId],
) as InspectionCommunicationView[],
};
}
async createCommunication(
findingId: string,
dto: CreateInspectionCommunicationDto,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<InspectionCommunicationView> {
if (
dto.type === InspectionCommunicationType.COMPANY_RESPONSE
&& dto.direction !== InspectionCommunicationDirection.INBOUND
) {
throw new BadRequestException({
code: 'COMPANY_RESPONSE_MUST_BE_INBOUND',
message: 'Una respuesta de la empresa debe registrarse como comunicación recibida',
});
}
return this.dataSource.transaction(async (manager) => {
const context = await this.lockFindingContext(manager, findingId);
this.assertFindingOpen(context);
const id = randomUUID();
await manager.query(`
INSERT INTO inspection_finding_communications (
id, finding_id, direction, channel, type, occurred_at,
subject, details, contact_name, contact_email, created_by
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
`, [
id,
findingId,
dto.direction,
dto.channel,
dto.type,
new Date(dto.occurredAt),
dto.subject,
dto.details ?? null,
dto.contactName ?? null,
dto.contactEmail?.toLowerCase() ?? null,
principal.userId,
]);
const created = await this.loadCommunication(manager, id);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.INSPECTION_FINDING_COMMUNICATION_CREATED,
entityType: 'inspection_finding_communication',
entityId: id,
afterData: created as unknown as Record<string, unknown>,
metadata: {
findingId,
actId: context.actId,
visitId: context.visitId,
immutable: true,
},
}, manager);
return created;
});
}
async content(evidenceId: string): Promise<{
filePath: string;
evidence: StoredInspectionEvidence;
}> {
const evidence = await this.loadEvidence(this.dataSource.manager, evidenceId);
const filePath = resolve(this.storageRoot, evidence.storedName);
if (!filePath.startsWith(`${this.storageRoot}/`)) {
throw new InternalServerErrorException({
code: 'INVALID_INSPECTION_EVIDENCE_STORAGE_PATH',
message: 'Ruta de almacenamiento inválida',
});
}
try {
const fileStat = await stat(filePath);
if (!fileStat.isFile() || fileStat.size !== evidence.sizeBytes) {
throw new Error('size mismatch');
}
} catch {
throw new InternalServerErrorException({
code: 'INSPECTION_EVIDENCE_FILE_MISSING',
message: 'El archivo físico no está disponible',
});
}
return { filePath, evidence };
}
private validateCoordinates(
latitude: number | null | undefined,
longitude: number | null | undefined,
accuracyM: number | null | undefined,
): void {
const hasLatitude = latitude !== null && latitude !== undefined;
const hasLongitude = longitude !== null && longitude !== undefined;
if (hasLatitude !== hasLongitude) {
throw new BadRequestException({
code: 'INVALID_INSPECTION_EVIDENCE_COORDINATES',
message: 'Latitud y longitud deben informarse juntas',
});
}
if (accuracyM !== null && accuracyM !== undefined && !hasLatitude) {
throw new BadRequestException({
code: 'INSPECTION_EVIDENCE_ACCURACY_WITHOUT_COORDINATES',
message: 'La precisión requiere coordenadas',
});
}
}
private validatePurpose(dto: CreateInspectionEvidenceDto, mimeType: string): void {
if (
dto.kind === InspectionEvidenceKind.PHOTO
&& ![InspectionEvidencePurpose.OBSERVATION, InspectionEvidencePurpose.VERIFICATION].includes(dto.purpose)
) {
throw new BadRequestException({
code: 'INSPECTION_EVIDENCE_PHOTO_PURPOSE_INVALID',
message: 'Las fotografías se registran como evidencia inicial o de verificación',
});
}
if (dto.purpose === InspectionEvidencePurpose.VERIFICATION && dto.kind !== InspectionEvidenceKind.PHOTO) {
throw new BadRequestException({
code: 'INSPECTION_VERIFICATION_PHOTO_REQUIRED',
message: 'La evidencia de verificación debe ser una fotografía',
});
}
if (
dto.purpose === InspectionEvidencePurpose.COMPANY_RESPONSE
&& (dto.kind !== InspectionEvidenceKind.DOCUMENT || mimeType !== 'application/pdf')
) {
throw new BadRequestException({
code: 'COMPANY_RESPONSE_PDF_REQUIRED',
message: 'La respuesta de la empresa debe adjuntarse como PDF',
});
}
if (
dto.purpose === InspectionEvidencePurpose.OTHER_DOCUMENT
&& dto.kind !== InspectionEvidenceKind.DOCUMENT
) {
throw new BadRequestException({
code: 'INSPECTION_OTHER_DOCUMENT_PDF_REQUIRED',
message: 'Otros documentos deben adjuntarse como PDF',
});
}
}
private validateCommunicationPurpose(
purpose: InspectionEvidencePurpose,
communication: CommunicationLink | null,
): void {
if (
purpose === InspectionEvidencePurpose.COMPANY_RESPONSE
&& (!communication
|| communication.type !== InspectionCommunicationType.COMPANY_RESPONSE
|| communication.direction !== InspectionCommunicationDirection.INBOUND)
) {
throw new BadRequestException({
code: 'COMPANY_RESPONSE_COMMUNICATION_REQUIRED',
message: 'El PDF debe vincularse con una respuesta de empresa registrada',
});
}
if (
purpose === InspectionEvidencePurpose.COMMUNICATION_ATTACHMENT
&& !communication
) {
throw new BadRequestException({
code: 'COMMUNICATION_ATTACHMENT_LINK_REQUIRED',
message: 'El documento debe vincularse con una comunicación',
});
}
if (purpose === InspectionEvidencePurpose.VERIFICATION && communication) {
throw new BadRequestException({
code: 'VERIFICATION_EVIDENCE_COMMUNICATION_NOT_ALLOWED',
message: 'La foto de verificación se vincula con la visita, no con una comunicación',
});
}
}
private assertFindingOpen(context: FindingContext): void {
if (context.status !== InspectionFindingStatus.OPEN) {
throw new ConflictException({
code: 'INSPECTION_FINDING_NOT_OPEN',
message: 'No se pueden agregar registros a un hallazgo cerrado',
});
}
}
private assertObservationEditable(context: FindingContext): void {
if (
context.actStatus !== InspectionActStatus.DRAFT
|| context.visitStatus !== InspectionVisitStatus.IN_PROGRESS
) {
throw new ConflictException({
code: 'INSPECTION_OBSERVATION_EVIDENCE_LOCKED',
message: 'La evidencia de campo se carga durante la visita y con el acta en borrador',
});
}
}
private async assertVerificationEditable(
manager: EntityManager,
findingId: string,
verificationVisitId: string,
principal: AuthPrincipal,
): Promise<void> {
const [row] = await manager.query(`
SELECT visit.id, visit.status
FROM inspection_finding_verification_visits verification_link
INNER JOIN inspection_visits visit ON visit.id = verification_link.visit_id
WHERE verification_link.finding_id = $1 AND verification_link.visit_id = $2
`, [findingId, verificationVisitId]) as Array<{ id: string; status: InspectionVisitStatus }>;
if (!row) {
throw new BadRequestException({
code: 'INSPECTION_VERIFICATION_LINK_INVALID',
message: 'El hallazgo no pertenece a la visita de verificación indicada',
});
}
if (row.status !== InspectionVisitStatus.IN_PROGRESS) {
throw new ConflictException({
code: 'INSPECTION_VERIFICATION_EVIDENCE_LOCKED',
message: 'La evidencia de verificación sólo se carga durante la visita en curso',
});
}
await this.assertActorAssigned(manager, verificationVisitId, principal);
}
private async assertActorAssigned(
manager: EntityManager,
visitId: string,
principal: AuthPrincipal,
): Promise<void> {
if (principal.permissions.includes('inspections.manage')) return;
const rows = await manager.query(`
SELECT 1 FROM inspection_visit_members
WHERE visit_id = $1 AND user_id = $2 AND included = true
`, [visitId, principal.userId]) as unknown[];
if (rows.length === 0) {
throw new ForbiddenException({
code: 'INSPECTION_VISIT_NOT_ASSIGNED',
message: 'La visita no está asignada al usuario actual',
});
}
}
private async requireFinding(manager: EntityManager, id: string): Promise<void> {
const rows = await manager.query(
'SELECT 1 FROM inspection_findings WHERE id = $1',
[id],
) as unknown[];
if (rows.length === 0) throw findingNotFound();
}
private async lockFindingContext(
manager: EntityManager,
id: string,
): Promise<FindingContext> {
const [context] = await manager.query(`
SELECT
finding.id,
finding.status,
act.id AS "actId",
act.status AS "actStatus",
visit.id AS "visitId",
visit.status AS "visitStatus"
FROM inspection_findings finding
INNER JOIN inspection_acts act ON act.id = finding.act_id
INNER JOIN inspection_visits visit ON visit.id = act.visit_id
WHERE finding.id = $1
FOR UPDATE OF finding
`, [id]) as FindingContext[];
if (!context) throw findingNotFound();
return context;
}
private async requireCommunication(
manager: EntityManager,
findingId: string,
communicationId: string,
): Promise<CommunicationLink> {
const [communication] = await manager.query(`
SELECT id, type, direction, subject
FROM inspection_finding_communications
WHERE id = $1 AND finding_id = $2
`, [communicationId, findingId]) as CommunicationLink[];
if (!communication) {
throw new BadRequestException({
code: 'INSPECTION_COMMUNICATION_INVALID',
message: 'La comunicación seleccionada no pertenece al hallazgo',
});
}
return communication;
}
private evidenceSelect(): string {
return `
SELECT
evidence.id,
evidence.finding_id AS "findingId",
evidence.communication_id AS "communicationId",
evidence.verification_visit_id AS "verificationVisitId",
CASE WHEN communication.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id', communication.id,
'type', communication.type,
'direction', communication.direction,
'subject', communication.subject
) END AS communication,
evidence.kind,
evidence.purpose,
evidence.original_name AS "originalName",
evidence.stored_name AS "storedName",
evidence.mime_type AS "mimeType",
evidence.size_bytes::integer AS "sizeBytes",
evidence.sha256,
evidence.title,
evidence.description,
evidence.captured_at AS "capturedAt",
evidence.latitude::double precision AS latitude,
evidence.longitude::double precision AS longitude,
evidence.accuracy_m::double precision AS "accuracyM",
evidence.device_label AS "deviceLabel",
evidence.source,
evidence.uploaded_by AS "uploadedBy",
uploader.username AS "uploadedByUsername",
evidence.created_at AS "createdAt"
FROM inspection_finding_evidence evidence
LEFT JOIN inspection_finding_communications communication
ON communication.id = evidence.communication_id
LEFT JOIN users uploader ON uploader.id = evidence.uploaded_by
`;
}
private async loadEvidence(
manager: EntityManager,
id: string,
): Promise<StoredInspectionEvidence> {
const [evidence] = await manager.query(
`${this.evidenceSelect()} WHERE evidence.id = $1`,
[id],
) as StoredInspectionEvidence[];
if (!evidence) throw evidenceNotFound();
return evidence;
}
private communicationSelect(where: string): string {
return `
SELECT
communication.id,
communication.finding_id AS "findingId",
communication.direction,
communication.channel,
communication.type,
communication.occurred_at AS "occurredAt",
communication.subject,
communication.details,
communication.contact_name AS "contactName",
communication.contact_email AS "contactEmail",
communication.created_by AS "createdBy",
creator.username AS "createdByUsername",
COUNT(evidence.id)::integer AS "attachmentCount",
communication.created_at AS "createdAt"
FROM inspection_finding_communications communication
LEFT JOIN users creator ON creator.id = communication.created_by
LEFT JOIN inspection_finding_evidence evidence
ON evidence.communication_id = communication.id
${where}
GROUP BY communication.id, creator.username
`;
}
private async loadCommunication(
manager: EntityManager,
id: string,
): Promise<InspectionCommunicationView> {
const [communication] = await manager.query(
this.communicationSelect('WHERE communication.id = $1'),
[id],
) as InspectionCommunicationView[];
if (!communication) {
throw new NotFoundException({
code: 'INSPECTION_COMMUNICATION_NOT_FOUND',
message: 'Comunicación no encontrada',
});
}
return communication;
}
private evidenceAuditView(
evidence: StoredInspectionEvidence,
): Record<string, unknown> {
return {
findingId: evidence.findingId,
communicationId: evidence.communicationId,
verificationVisitId: evidence.verificationVisitId,
kind: evidence.kind,
purpose: evidence.purpose,
originalName: evidence.originalName,
mimeType: evidence.mimeType,
sizeBytes: evidence.sizeBytes,
sha256: evidence.sha256,
capturedAt: evidence.capturedAt,
latitude: evidence.latitude,
longitude: evidence.longitude,
accuracyM: evidence.accuracyM,
deviceLabel: evidence.deviceLabel,
source: evidence.source,
uploadedBy: evidence.uploadedBy,
immutable: true,
};
}
}
@@ -0,0 +1,98 @@
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Patch,
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 { CloseInspectionFindingDto } from './dto/close-inspection-finding.dto';
import { CreateInspectionFindingDto } from './dto/create-inspection-finding.dto';
import { ListInspectionFindingsQueryDto } from './dto/list-inspection-findings-query.dto';
import { UpdateInspectionFindingFollowUpDto } from './dto/update-inspection-finding-follow-up.dto';
import { UpdateInspectionFindingDto } from './dto/update-inspection-finding.dto';
import { InspectionFindingsService } from './inspection-findings.service';
@Controller('inspection-acts/:actId/findings')
export class InspectionActFindingsController {
constructor(private readonly findings: InspectionFindingsService) {}
@Get()
@RequirePermissions('inspection_findings.read')
list(@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string) {
return this.findings.listForAct(actId);
}
@Post()
@RequirePermissions('inspection_findings.create')
create(
@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string,
@Body() dto: CreateInspectionFindingDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.findings.create(actId, dto, principal, request);
}
}
@Controller('inspection-findings')
export class InspectionFindingsController {
constructor(private readonly findings: InspectionFindingsService) {}
@Get()
@RequirePermissions('inspection_findings.read')
list(@Query() query: ListInspectionFindingsQueryDto) {
return this.findings.listGlobal(query);
}
@Get(':id')
@RequirePermissions('inspection_findings.read')
get(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
return this.findings.getById(id);
}
@Get(':id/verification-history')
@RequirePermissions('inspection_findings.read')
getVerificationHistory(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
return this.findings.getVerificationHistory(id);
}
@Patch(':id')
@RequirePermissions('inspection_findings.update')
update(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Body() dto: UpdateInspectionFindingDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.findings.update(id, dto, principal, request);
}
@Patch(':id/close')
@RequirePermissions('inspection_findings.close')
close(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Body() dto: CloseInspectionFindingDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.findings.close(id, dto, principal, request);
}
@Patch(':id/follow-up')
@RequirePermissions('inspection_findings.follow_up')
updateFollowUp(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Body() dto: UpdateInspectionFindingFollowUpDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.findings.updateFollowUp(id, dto, principal, request);
}
}
@@ -0,0 +1,33 @@
import { Module } from '@nestjs/common';
import { AuditModule } from '../audit/audit.module';
import { FindingCatalogController } from './finding-catalog.controller';
import { FindingCatalogService } from './finding-catalog.service';
import {
InspectionActFindingsController,
InspectionFindingsController,
} from './inspection-findings.controller';
import { InspectionFindingsService } from './inspection-findings.service';
import {
InspectionEvidenceContentController,
InspectionFindingCommunicationsController,
InspectionFindingEvidenceController,
} from './inspection-evidence.controller';
import { InspectionEvidenceService } from './inspection-evidence.service';
@Module({
imports: [AuditModule],
controllers: [
FindingCatalogController,
InspectionActFindingsController,
InspectionFindingsController,
InspectionFindingEvidenceController,
InspectionFindingCommunicationsController,
InspectionEvidenceContentController,
],
providers: [
FindingCatalogService,
InspectionFindingsService,
InspectionEvidenceService,
],
})
export class InspectionFindingsModule {}
File diff suppressed because it is too large Load Diff