chore: import DH V2 D5.6.4 production baseline
This commit is contained in:
@@ -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
Reference in New Issue
Block a user