Compare commits

...
Author SHA1 Message Date
admin ccca7a6ff1 F4.8 · pruebas de cierre de Inspección 2026-09-07 20:13:41 -03:00
admin 63671686a9 F4.8 · sellado físico del Acta 2026-09-07 20:13:28 -03:00
admin 8baba89ce6 F4.8 · registrar cierre autoritativo 2026-09-07 20:13:17 -03:00
admin ad65b44142 F4.8 · activar cierre autoritativo 2026-09-07 20:13:10 -03:00
admin d0cc7c6f9d F4.8 · cierre autoritativo de Inspección 2026-09-07 20:12:48 -03:00
admin b80f83ed5f F4.7 · retirar respuesta de empresa por Hallazgo 2026-09-07 20:11:46 -03:00
admin fbc63fafb5 F4.7 · pruebas de reincidencia 2026-09-07 20:11:16 -03:00
admin edc05a5f50 F4.7 · activar reincidencias 2026-09-07 20:11:06 -03:00
admin ae583a8e45 F4.7 · endpoints de reincidencia 2026-09-07 20:10:54 -03:00
admin b160e7344b F4.7 · servicio de reincidencias 2026-09-07 20:10:38 -03:00
admin 41b52d34bf F4.7 · DTO de reincidencia 2026-09-07 20:10:11 -03:00
admin 7dab175958 F4.6 · retirar pruebas históricas de Survey 2026-09-07 20:09:28 -03:00
admin f1dbb75834 F4.6 · eliminar código muerto de Campañas 2026-09-07 20:07:23 -03:00
admin e9b318fdb5 F4.6 · retirar Campañas del producto activo 2026-09-07 20:06:21 -03:00
admin 890b54f7c8 F4.5 · SMTP Superadmin y Report Word al Inspector 2026-09-07 20:00:53 -03:00
admin 3634768f9a F4.4 · numeración institucional ACT e INF 2026-09-07 19:56:40 -03:00
admin fbe8f2e8cf F4.3 · dossier INF, GEDO IF y seguimiento 2026-09-07 19:55:04 -03:00
admin 367c7df45a F4.2 · bloquear Acta con urgencia y plazo congelado 2026-09-07 19:51:24 -03:00
admin 0f9aa589b5 F4.1 · administración de plazos por JEFE 2026-09-07 19:46:57 -03:00
admin dac6c94370 F4.0 · actualizar contratos y pruebas del nuevo flujo 2026-09-07 19:43:26 -03:00
admin 1cd11e83ab F4.0 · activar entidades de plazo y seguimiento 2026-09-07 19:42:21 -03:00
admin eb89680c32 F4.0 · numeración INSP y lifecycle explícito 2026-09-07 19:41:29 -03:00
admin cbab839935 F4.0 foundation · urgencia, GEDO y seguimiento 2026-09-07 19:39:43 -03:00
90 changed files with 4538 additions and 2988 deletions
+89
View File
@@ -0,0 +1,89 @@
name: F4 Document Flow CI
on:
push:
branches:
- 'feature/f4*'
pull_request:
branches:
- main
paths:
- 'api-v3/**'
- 'web-v2/**'
- 'android-app/**'
- 'scripts/**'
- '.github/workflows/f4-ci.yml'
workflow_dispatch:
permissions:
contents: read
jobs:
api:
name: API · F4
runs-on: ubuntu-latest
timeout-minutes: 25
defaults:
run:
working-directory: api-v3
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '24'
cache: npm
cache-dependency-path: api-v3/package-lock.json
- run: npm ci
- name: Typecheck
run: npm run typecheck
- name: Tests
run: npm test
- name: Build
run: npm run build
web:
name: WEB · regression
runs-on: ubuntu-latest
timeout-minutes: 20
defaults:
run:
working-directory: web-v2
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '24'
cache: npm
cache-dependency-path: web-v2/package-lock.json
- run: npm ci
- run: npm run typecheck
- name: F3.1 structural WEB contract
run: bash ../scripts/check-f3-1-web-contract.sh
- run: npm run build
deploy-preflight:
name: VPS-equivalent preflight / Docker
runs-on: ubuntu-latest
timeout-minutes: 30
needs: [api, web]
steps:
- uses: actions/checkout@v4
- name: Validate shell scripts
run: |
while IFS= read -r -d '' script; do
bash -n "$script"
done < <(find scripts -type f -name '*.sh' -print0)
- name: Validate Compose
run: docker compose --env-file .env.example config >/dev/null
- name: VPS-equivalent isolated API tests
run: |
set -Eeuo pipefail
image="dhv2-api:f4-preflight-${GITHUB_SHA::12}"
docker build --target builder -t "$image" api-v3
docker run --rm \
-v "$PWD/api-v3/test:/app/test:ro" \
-v "$PWD/api-v3/tsconfig.test.json:/app/tsconfig.test.json:ro" \
"$image" npm test
docker image rm "$image" >/dev/null 2>&1 || true
- name: Build production images
run: docker compose --env-file .env.example build api migrate web
+13 -24
View File
@@ -3,28 +3,27 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
import { APP_GUARD } from '@nestjs/core'; import { APP_GUARD } from '@nestjs/core';
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler'; import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { AuditModule } from './audit/audit.module'; import { ActAdministrationModule } from './act-administration/act-administration.module';
import { AdministrationModule } from './administration/administration.module'; import { AdministrationModule } from './administration/administration.module';
import { AuthorizationModule } from './authorization/authorization.module'; import { AssetImportsModule } from './asset-imports/asset-imports.module';
import { PermissionsGuard } from './authorization/guards/permissions.guard'; import { AssetMasterModule } from './asset-master/asset-master.module';
import { AuditModule } from './audit/audit.module';
import { AuthModule } from './auth/auth.module'; import { AuthModule } from './auth/auth.module';
import { AccessTokenGuard } from './auth/guards/access-token.guard'; import { AccessTokenGuard } from './auth/guards/access-token.guard';
import { AuthorizationModule } from './authorization/authorization.module';
import { PermissionsGuard } from './authorization/guards/permissions.guard';
import { CsrfGuard } from './auth/guards/csrf.guard'; import { CsrfGuard } from './auth/guards/csrf.guard';
import { PhaseADataModule } from './core-data/phase-a-data.module'; import { PhaseADataModule } from './core-data/phase-a-data.module';
import { DashboardModule } from './dashboard/dashboard.module';
import { HealthController } from './health.controller'; import { HealthController } from './health.controller';
import { HealthService } from './health.service'; import { HealthService } from './health.service';
import { DashboardModule } from './dashboard/dashboard.module';
import { AssetMasterModule } from './asset-master/asset-master.module';
import { SurveyPlanningModule } from './survey-planning/survey-planning.module';
import { SurveyExecutionModule } from './survey-execution/survey-execution.module';
import { InspectionVisitsModule } from './inspection-visits/inspection-visits.module';
import { InspectionActsModule } from './inspection-acts/inspection-acts.module'; import { InspectionActsModule } from './inspection-acts/inspection-acts.module';
import { InspectionFindingsModule } from './inspection-findings/inspection-findings.module';
import { InspectionClosingModule } from './inspection-closing/inspection-closing.module'; import { InspectionClosingModule } from './inspection-closing/inspection-closing.module';
import { AssetImportsModule } from './asset-imports/asset-imports.module'; import { InspectionDeadlinesModule } from './inspection-deadlines/inspection-deadlines.module';
import { InspectionFindingsModule } from './inspection-findings/inspection-findings.module';
import { InspectionReportsModule } from './inspection-reports/inspection-reports.module'; import { InspectionReportsModule } from './inspection-reports/inspection-reports.module';
import { InspectionVerificationsModule } from './inspection-verifications/inspection-verifications.module'; import { InspectionVerificationsModule } from './inspection-verifications/inspection-verifications.module';
import { ActAdministrationModule } from './act-administration/act-administration.module'; import { InspectionVisitsModule } from './inspection-visits/inspection-visits.module';
function required(config: ConfigService, key: string): string { function required(config: ConfigService, key: string): string {
const value = config.get<string>(key); const value = config.get<string>(key);
@@ -34,10 +33,7 @@ function required(config: ConfigService, key: string): string {
@Module({ @Module({
imports: [ imports: [
ConfigModule.forRoot({ ConfigModule.forRoot({ isGlobal: true, ignoreEnvFile: true }),
isGlobal: true,
ignoreEnvFile: true,
}),
TypeOrmModule.forRootAsync({ TypeOrmModule.forRootAsync({
inject: [ConfigService], inject: [ConfigService],
useFactory: (config: ConfigService) => ({ useFactory: (config: ConfigService) => ({
@@ -55,13 +51,7 @@ function required(config: ConfigService, key: string): string {
connectTimeoutMS: 5000, connectTimeoutMS: 5000,
}), }),
}), }),
ThrottlerModule.forRoot([ ThrottlerModule.forRoot([{ name: 'default', ttl: 60_000, limit: 120 }]),
{
name: 'default',
ttl: 60_000,
limit: 120,
},
]),
PhaseADataModule, PhaseADataModule,
AuditModule, AuditModule,
AuthorizationModule, AuthorizationModule,
@@ -69,12 +59,11 @@ function required(config: ConfigService, key: string): string {
AdministrationModule, AdministrationModule,
DashboardModule, DashboardModule,
AssetMasterModule, AssetMasterModule,
SurveyPlanningModule,
SurveyExecutionModule,
InspectionVisitsModule, InspectionVisitsModule,
InspectionActsModule, InspectionActsModule,
InspectionFindingsModule, InspectionFindingsModule,
InspectionClosingModule, InspectionClosingModule,
InspectionDeadlinesModule,
InspectionReportsModule, InspectionReportsModule,
InspectionVerificationsModule, InspectionVerificationsModule,
ActAdministrationModule, ActAdministrationModule,
+39 -160
View File
@@ -6,14 +6,8 @@ export { Role } from './role.entity';
export { UserRole } from './user-role.entity'; export { UserRole } from './user-role.entity';
export { User, UserStatus } from './user.entity'; export { User, UserStatus } from './user.entity';
export { Asset, AssetDataOrigin, AssetInformationStatus, AssetOperationalStatus } from './asset.entity'; export { Asset, AssetDataOrigin, AssetInformationStatus, AssetOperationalStatus } from './asset.entity';
export { export { AssetVersion, AssetVersionChangeType } from './asset-version.entity';
AssetVersion, export { AssetAttributeDataType, AssetAttributeDefinition } from './asset-attribute-definition.entity';
AssetVersionChangeType,
} from './asset-version.entity';
export {
AssetAttributeDataType,
AssetAttributeDefinition,
} from './asset-attribute-definition.entity';
export { AssetAttributeValue } from './asset-attribute-value.entity'; export { AssetAttributeValue } from './asset-attribute-value.entity';
export { AreaCompanyRelation, AreaOrganizationRole } from './area-company-relation.entity'; export { AreaCompanyRelation, AreaOrganizationRole } from './area-company-relation.entity';
export { OrganizationProfile, OrganizationKind } from './organization-profile.entity'; export { OrganizationProfile, OrganizationKind } from './organization-profile.entity';
@@ -25,111 +19,35 @@ export { AreaLegalRight, AreaLegalRightType, AreaLegalRightStatus } from './area
export { AreaLegalRightOrganization, AreaLegalRightOrganizationRole } from './area-legal-right-organization.entity'; export { AreaLegalRightOrganization, AreaLegalRightOrganizationRole } from './area-legal-right-organization.entity';
export { AssetTypeParentRule } from './asset-type-parent-rule.entity'; export { AssetTypeParentRule } from './asset-type-parent-rule.entity';
export { AssetType, AssetTypeOperationalRole } from './asset-type.entity'; export { AssetType, AssetTypeOperationalRole } from './asset-type.entity';
export { export { AssetGeometry, AssetGeometrySource, AssetGeometryType } from './asset-geometry.entity';
AssetGeometry, export { AssetMedia, AssetMediaKind, AssetMediaSource } from './asset-media.entity';
AssetGeometrySource, export { InspectionVisit, InspectionVisitStatus } from './inspection-visit.entity';
AssetGeometryType,
} from './asset-geometry.entity';
export {
AssetMedia,
AssetMediaKind,
AssetMediaSource,
} from './asset-media.entity';
export {
SurveyCampaign,
SurveyCampaignStatus,
} from './survey-campaign.entity';
export {
SurveyCampaignTarget,
SurveyTargetStatus,
} from './survey-campaign-target.entity';
export {
SurveyReportOutcome,
SurveyReportStatus,
SurveyTargetReport,
} from './survey-target-report.entity';
export { SurveyTargetReportMedia } from './survey-target-report-media.entity';
export {
SurveyReportVersionEvent,
SurveyTargetReportVersion,
} from './survey-target-report-version.entity';
export {
InspectionVisit,
InspectionVisitStatus,
} from './inspection-visit.entity';
export { InspectionVisitAsset, InspectionVisitAssetPlanningSource } from './inspection-visit-asset.entity'; export { InspectionVisitAsset, InspectionVisitAssetPlanningSource } from './inspection-visit-asset.entity';
export { InspectionVisitMember } from './inspection-visit-member.entity'; export { InspectionVisitMember } from './inspection-visit-member.entity';
export { export { DocumentAnnualSequence, DocumentSequenceType } from './document-annual-sequence.entity';
DocumentAnnualSequence, export { InspectionAct, InspectionActStatus, InspectionActUrgency, InspectionDeadlineBasis, InspectionDeadlineDayType } from './inspection-act.entity';
DocumentSequenceType,
} from './document-annual-sequence.entity';
export {
InspectionAct,
InspectionActStatus,
} from './inspection-act.entity';
export { InspectionActAsset } from './inspection-act-asset.entity'; export { InspectionActAsset } from './inspection-act-asset.entity';
export { export { InspectionDeadlinePolicy } from './inspection-deadline-policy.entity';
InspectionReport, export { InspectionNonWorkingDay } from './inspection-non-working-day.entity';
InspectionReportPdfStatus, export { InspectionReport, InspectionReportPdfStatus, InspectionReportReviewStatus, InspectionReportStatus, InspectionReportWordStatus } from './inspection-report.entity';
InspectionReportReviewStatus, export { InspectionReportFollowUp, InspectionReportFollowUpType } from './inspection-report-follow-up.entity';
InspectionReportStatus, export { InspectionReportFollowUpFile } from './inspection-report-follow-up-file.entity';
InspectionReportWordStatus, export { InspectionActVersion, InspectionActVersionEvent } from './inspection-act-version.entity';
} from './inspection-report.entity'; export { InspectionActResponsible, InspectionResponsibleAttendanceStatus, InspectionResponsibleDocumentType } from './inspection-act-responsible.entity';
export { export { InspectionActClosure, InspectionActUploadMode } from './inspection-act-closure.entity';
InspectionActVersion, export { InspectionActSignature, InspectionActSignatureSource, InspectionActSignatureStatus, InspectionActSignerType, InspectionCompanySignatureManifestation } from './inspection-act-signature.entity';
InspectionActVersionEvent,
} from './inspection-act-version.entity';
export {
InspectionActResponsible,
InspectionResponsibleAttendanceStatus,
InspectionResponsibleDocumentType,
} from './inspection-act-responsible.entity';
export {
InspectionActClosure,
InspectionActUploadMode,
} from './inspection-act-closure.entity';
export {
InspectionActSignature,
InspectionActSignatureSource,
InspectionActSignatureStatus,
InspectionActSignerType,
InspectionCompanySignatureManifestation,
} from './inspection-act-signature.entity';
export { FindingCategory } from './finding-category.entity'; export { FindingCategory } from './finding-category.entity';
export { FindingCatalogItem } from './finding-catalog-item.entity'; export { FindingCatalogItem } from './finding-catalog-item.entity';
export { FindingCatalogItemAssetType } from './finding-catalog-item-asset-type.entity'; export { FindingCatalogItemAssetType } from './finding-catalog-item-asset-type.entity';
export { FindingCatalogAssetTypeProfile } from './finding-catalog-asset-type-profile.entity'; export { FindingCatalogAssetTypeProfile } from './finding-catalog-asset-type-profile.entity';
export { FindingCatalogAssetOverride } from './finding-catalog-asset-override.entity'; export { FindingCatalogAssetOverride } from './finding-catalog-asset-override.entity';
export { FindingCatalogProposal, FindingCatalogProposalStatus } from './finding-catalog-proposal.entity'; export { FindingCatalogProposal, FindingCatalogProposalStatus } from './finding-catalog-proposal.entity';
export { export { InspectionFinding, InspectionFindingResponseDueBasis, InspectionFindingStatus } from './inspection-finding.entity';
InspectionFinding, export { InspectionFindingVersion, InspectionFindingVersionEvent } from './inspection-finding-version.entity';
InspectionFindingResponseDueBasis, export { InspectionCommunicationChannel, InspectionCommunicationDirection, InspectionCommunicationType, InspectionFindingCommunication } from './inspection-finding-communication.entity';
InspectionFindingStatus, export { InspectionEvidenceKind, InspectionEvidencePurpose, InspectionEvidenceSource, InspectionFindingEvidence } from './inspection-finding-evidence.entity';
} from './inspection-finding.entity'; export { InspectionFindingVerificationVisit, InspectionVerificationOutcome } from './inspection-finding-verification-visit.entity';
export { export { InspectionFindingVerificationEvent, InspectionFindingVerificationEventType } from './inspection-finding-verification-event.entity';
InspectionFindingVersion,
InspectionFindingVersionEvent,
} from './inspection-finding-version.entity';
export {
InspectionCommunicationChannel,
InspectionCommunicationDirection,
InspectionCommunicationType,
InspectionFindingCommunication,
} from './inspection-finding-communication.entity';
export {
InspectionEvidenceKind,
InspectionEvidencePurpose,
InspectionEvidenceSource,
InspectionFindingEvidence,
} from './inspection-finding-evidence.entity';
export {
InspectionFindingVerificationVisit,
InspectionVerificationOutcome,
} from './inspection-finding-verification-visit.entity';
export {
InspectionFindingVerificationEvent,
InspectionFindingVerificationEventType,
} from './inspection-finding-verification-event.entity';
import { AuditEvent } from './audit-event.entity'; import { AuditEvent } from './audit-event.entity';
import { AuthSession } from './auth-session.entity'; import { AuthSession } from './auth-session.entity';
@@ -154,18 +72,17 @@ import { AreaLegalRightOrganization } from './area-legal-right-organization.enti
import { AssetGeometry } from './asset-geometry.entity'; import { AssetGeometry } from './asset-geometry.entity';
import { AssetVersion } from './asset-version.entity'; import { AssetVersion } from './asset-version.entity';
import { AssetMedia } from './asset-media.entity'; import { AssetMedia } from './asset-media.entity';
import { SurveyCampaign } from './survey-campaign.entity';
import { SurveyCampaignTarget } from './survey-campaign-target.entity';
import { SurveyTargetReport } from './survey-target-report.entity';
import { SurveyTargetReportMedia } from './survey-target-report-media.entity';
import { SurveyTargetReportVersion } from './survey-target-report-version.entity';
import { InspectionVisit } from './inspection-visit.entity'; import { InspectionVisit } from './inspection-visit.entity';
import { InspectionVisitAsset } from './inspection-visit-asset.entity'; import { InspectionVisitAsset } from './inspection-visit-asset.entity';
import { InspectionVisitMember } from './inspection-visit-member.entity'; import { InspectionVisitMember } from './inspection-visit-member.entity';
import { DocumentAnnualSequence } from './document-annual-sequence.entity'; import { DocumentAnnualSequence } from './document-annual-sequence.entity';
import { InspectionAct } from './inspection-act.entity'; import { InspectionAct } from './inspection-act.entity';
import { InspectionActAsset } from './inspection-act-asset.entity'; import { InspectionActAsset } from './inspection-act-asset.entity';
import { InspectionDeadlinePolicy } from './inspection-deadline-policy.entity';
import { InspectionNonWorkingDay } from './inspection-non-working-day.entity';
import { InspectionReport } from './inspection-report.entity'; import { InspectionReport } from './inspection-report.entity';
import { InspectionReportFollowUp } from './inspection-report-follow-up.entity';
import { InspectionReportFollowUpFile } from './inspection-report-follow-up-file.entity';
import { InspectionActVersion } from './inspection-act-version.entity'; import { InspectionActVersion } from './inspection-act-version.entity';
import { InspectionActResponsible } from './inspection-act-responsible.entity'; import { InspectionActResponsible } from './inspection-act-responsible.entity';
import { InspectionActClosure } from './inspection-act-closure.entity'; import { InspectionActClosure } from './inspection-act-closure.entity';
@@ -184,55 +101,17 @@ import { InspectionFindingVerificationVisit } from './inspection-finding-verific
import { InspectionFindingVerificationEvent } from './inspection-finding-verification-event.entity'; import { InspectionFindingVerificationEvent } from './inspection-finding-verification-event.entity';
export const PHASE_A_ENTITIES = [ export const PHASE_A_ENTITIES = [
User, User, Role, Permission, UserRole, RolePermission, AuthSession, AuditEvent,
Role, AssetType, AssetTypeParentRule, AssetAttributeDefinition, Asset, AssetAttributeValue,
Permission, AreaCompanyRelation, OrganizationProfile, OrganizationMembership, SourceDocument,
UserRole, AssetSourceDocument, AssetExternalIdentifier, AreaLegalRight, AreaLegalRightOrganization,
RolePermission, AssetGeometry, AssetVersion, AssetMedia, InspectionVisit, InspectionVisitAsset,
AuthSession, InspectionVisitMember, DocumentAnnualSequence, InspectionAct, InspectionActAsset,
AuditEvent, InspectionDeadlinePolicy, InspectionNonWorkingDay, InspectionReport,
AssetType, InspectionReportFollowUp, InspectionReportFollowUpFile, InspectionActVersion,
AssetTypeParentRule, InspectionActResponsible, InspectionActClosure, InspectionActSignature, FindingCategory,
AssetAttributeDefinition, FindingCatalogItem, FindingCatalogItemAssetType, FindingCatalogAssetTypeProfile,
Asset, FindingCatalogAssetOverride, FindingCatalogProposal, InspectionFinding,
AssetAttributeValue, InspectionFindingVersion, InspectionFindingCommunication, InspectionFindingEvidence,
AreaCompanyRelation, InspectionFindingVerificationVisit, InspectionFindingVerificationEvent,
OrganizationProfile,
OrganizationMembership,
SourceDocument,
AssetSourceDocument,
AssetExternalIdentifier,
AreaLegalRight,
AreaLegalRightOrganization,
AssetGeometry,
AssetVersion,
AssetMedia,
SurveyCampaign,
SurveyCampaignTarget,
SurveyTargetReport,
SurveyTargetReportMedia,
SurveyTargetReportVersion,
InspectionVisit,
InspectionVisitAsset,
InspectionVisitMember,
DocumentAnnualSequence,
InspectionAct,
InspectionActAsset,
InspectionReport,
InspectionActVersion,
InspectionActResponsible,
InspectionActClosure,
InspectionActSignature,
FindingCategory,
FindingCatalogItem,
FindingCatalogItemAssetType,
FindingCatalogAssetTypeProfile,
FindingCatalogAssetOverride,
FindingCatalogProposal,
InspectionFinding,
InspectionFindingVersion,
InspectionFindingCommunication,
InspectionFindingEvidence,
InspectionFindingVerificationVisit,
InspectionFindingVerificationEvent,
]; ];
@@ -9,6 +9,21 @@ export enum InspectionActStatus {
RECTIFIED = 'RECTIFIED', RECTIFIED = 'RECTIFIED',
} }
export enum InspectionActUrgency {
URGENT = 'URGENT',
NON_URGENT = 'NON_URGENT',
}
export enum InspectionDeadlineDayType {
BUSINESS = 'BUSINESS',
CALENDAR = 'CALENDAR',
}
export enum InspectionDeadlineBasis {
ACT_DATE = 'ACT_DATE',
GEDO_LOAD_DATE = 'GEDO_LOAD_DATE',
}
@Entity({ name: 'inspection_acts' }) @Entity({ name: 'inspection_acts' })
@Index('uq_inspection_acts_one_draft_per_visit', ['visitId'], { unique: true, where: "status = 'DRAFT'" }) @Index('uq_inspection_acts_one_draft_per_visit', ['visitId'], { unique: true, where: "status = 'DRAFT'" })
@Index('uq_inspection_acts_year_number', ['actYear', 'actNumber'], { unique: true }) @Index('uq_inspection_acts_year_number', ['actYear', 'actNumber'], { unique: true })
@@ -47,6 +62,42 @@ export class InspectionAct extends TimestampedEntity {
@Column({ type: 'text', nullable: true }) @Column({ type: 'text', nullable: true })
observations!: string | null; observations!: string | null;
@Column({ type: 'varchar', length: 20, nullable: true })
urgency!: InspectionActUrgency | null;
@Column({ name: 'deadline_days', type: 'integer', nullable: true })
deadlineDays!: number | null;
@Column({ name: 'deadline_day_type', type: 'varchar', length: 20, nullable: true })
deadlineDayType!: InspectionDeadlineDayType | null;
@Column({ name: 'deadline_basis', type: 'varchar', length: 24, nullable: true })
deadlineBasis!: InspectionDeadlineBasis | null;
@Column({ name: 'deadline_base_on', type: 'date', nullable: true })
deadlineBaseOn!: string | null;
@Column({ name: 'deadline_due_on', type: 'date', nullable: true })
deadlineDueOn!: string | null;
@Column({ name: 'deadline_policy_snapshot', type: 'jsonb', nullable: true })
deadlinePolicySnapshot!: Record<string, unknown> | null;
@Column({ name: 'locked_at', type: 'timestamptz', nullable: true })
lockedAt!: Date | null;
@Column({ name: 'locked_by', type: 'uuid', nullable: true })
lockedBy!: string | null;
@Column({ name: 'locked_sha256', type: 'char', length: 64, nullable: true })
lockedSha256!: string | null;
@Column({ name: 'sealed_at', type: 'timestamptz', nullable: true })
sealedAt!: Date | null;
@Column({ name: 'sealed_by', type: 'uuid', nullable: true })
sealedBy!: string | null;
@Column({ name: 'current_version', type: 'integer', default: 0 }) @Column({ name: 'current_version', type: 'integer', default: 0 })
currentVersion!: number; currentVersion!: number;
@@ -0,0 +1,25 @@
import { Column, Entity, PrimaryColumn } from 'typeorm';
import { TimestampedEntity } from './timestamped.entity';
import {
InspectionActUrgency,
InspectionDeadlineBasis,
InspectionDeadlineDayType,
} from './inspection-act.entity';
@Entity({ name: 'inspection_deadline_policies' })
export class InspectionDeadlinePolicy extends TimestampedEntity {
@PrimaryColumn({ type: 'varchar', length: 20 })
urgency!: InspectionActUrgency;
@Column({ type: 'integer' })
days!: number;
@Column({ name: 'day_type', type: 'varchar', length: 20 })
dayType!: InspectionDeadlineDayType;
@Column({ type: 'varchar', length: 24 })
basis!: InspectionDeadlineBasis;
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
updatedBy!: string | null;
}
@@ -18,6 +18,7 @@ export enum InspectionFindingStatus {
@Index('idx_inspection_findings_status_control', ['status', 'nextControlOn']) @Index('idx_inspection_findings_status_control', ['status', 'nextControlOn'])
@Index('idx_inspection_findings_asset_status', ['assetId', 'status']) @Index('idx_inspection_findings_asset_status', ['assetId', 'status'])
@Index('idx_inspection_findings_catalog_item', ['catalogItemId']) @Index('idx_inspection_findings_catalog_item', ['catalogItemId'])
@Index('idx_inspection_findings_antecedent', ['antecedentFindingId'])
export class InspectionFinding extends TimestampedEntity { export class InspectionFinding extends TimestampedEntity {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn('uuid')
id!: string; id!: string;
@@ -61,6 +62,12 @@ export class InspectionFinding extends TimestampedEntity {
@Column({ type: 'smallint', nullable: true }) @Column({ type: 'smallint', nullable: true })
severity!: number | null; severity!: number | null;
@Column({ name: 'is_recurrence', type: 'boolean', default: false })
isRecurrence!: boolean;
@Column({ name: 'antecedent_finding_id', type: 'uuid', nullable: true })
antecedentFindingId!: string | null;
@Column({ name: 'correction_due_on', type: 'date', nullable: true }) @Column({ name: 'correction_due_on', type: 'date', nullable: true })
correctionDueOn!: string | null; correctionDueOn!: string | null;
@@ -0,0 +1,20 @@
import { Column, Entity, PrimaryColumn } from 'typeorm';
import { TimestampedEntity } from './timestamped.entity';
@Entity({ name: 'inspection_non_working_days' })
export class InspectionNonWorkingDay extends TimestampedEntity {
@PrimaryColumn({ type: 'date' })
day!: string;
@Column({ type: 'varchar', length: 200 })
label!: string;
@Column({ type: 'boolean', default: true })
enabled!: boolean;
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdBy!: string | null;
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
updatedBy!: string | null;
}
@@ -0,0 +1,30 @@
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
import { TimestampedEntity } from './timestamped.entity';
@Entity({ name: 'inspection_report_follow_up_files' })
@Index('idx_inspection_report_follow_up_files_follow_up', ['followUpId'])
export class InspectionReportFollowUpFile extends TimestampedEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'follow_up_id', type: 'uuid' })
followUpId!: string;
@Column({ name: 'original_name', type: 'varchar', length: 255 })
originalName!: string;
@Column({ name: 'stored_name', type: 'varchar', length: 255 })
storedName!: string;
@Column({ name: 'mime_type', type: 'varchar', length: 120 })
mimeType!: string;
@Column({ name: 'size_bytes', type: 'integer' })
sizeBytes!: number;
@Column({ type: 'char', length: 64 })
sha256!: string;
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdBy!: string | null;
}
@@ -0,0 +1,36 @@
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
import { TimestampedEntity } from './timestamped.entity';
export enum InspectionReportFollowUpType {
COMPANY_NOTE = 'COMPANY_NOTE',
COMPANY_DOCUMENT = 'COMPANY_DOCUMENT',
INTERNAL_NOTE = 'INTERNAL_NOTE',
VERIFICATION = 'VERIFICATION',
OTHER = 'OTHER',
}
@Entity({ name: 'inspection_report_follow_ups' })
@Index('idx_inspection_report_follow_ups_report_occurred', ['reportId', 'occurredOn'])
@Index('idx_inspection_report_follow_ups_type', ['eventType'])
export class InspectionReportFollowUp extends TimestampedEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'report_id', type: 'uuid' })
reportId!: string;
@Column({ name: 'event_type', type: 'varchar', length: 32 })
eventType!: InspectionReportFollowUpType;
@Column({ name: 'reference_number', type: 'varchar', length: 255, nullable: true })
referenceNumber!: string | null;
@Column({ name: 'occurred_on', type: 'date' })
occurredOn!: string;
@Column({ type: 'text', nullable: true })
description!: string | null;
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdBy!: string | null;
}
@@ -31,6 +31,7 @@ export enum InspectionReportReviewStatus {
@Index('uq_inspection_reports_code', ['code'], { unique: true }) @Index('uq_inspection_reports_code', ['code'], { unique: true })
@Index('idx_inspection_reports_generated_at', ['generatedAt']) @Index('idx_inspection_reports_generated_at', ['generatedAt'])
@Index('idx_inspection_reports_status', ['status', 'pdfStatus']) @Index('idx_inspection_reports_status', ['status', 'pdfStatus'])
@Index('idx_inspection_reports_gedo_if_identifier', ['gedoIfIdentifier'])
export class InspectionReport extends TimestampedEntity { export class InspectionReport extends TimestampedEntity {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn('uuid')
id!: string; id!: string;
@@ -80,6 +81,57 @@ export class InspectionReport extends TimestampedEntity {
@Column({ name: 'word_error', type: 'varchar', length: 500, nullable: true }) @Column({ name: 'word_error', type: 'varchar', length: 500, nullable: true })
wordError!: string | null; wordError!: string | null;
@Column({ name: 'reference_text', type: 'text', nullable: true })
referenceText!: string | null;
@Column({ name: 'general_objective', type: 'text', nullable: true })
generalObjective!: string | null;
@Column({ name: 'specific_objective', type: 'text', nullable: true })
specificObjective!: string | null;
@Column({ type: 'text', nullable: true })
background!: string | null;
@Column({ name: 'legal_framework', type: 'text', nullable: true })
legalFramework!: string | null;
@Column({ name: 'executive_summary', type: 'text', nullable: true })
executiveSummary!: string | null;
@Column({ type: 'text', nullable: true })
description!: string | null;
@Column({ type: 'text', nullable: true })
conclusion!: string | null;
@Column({ name: 'gedo_if_identifier', type: 'varchar', length: 255, nullable: true })
gedoIfIdentifier!: string | null;
@Column({ name: 'gedo_officialized_on', type: 'date', nullable: true })
gedoOfficializedOn!: string | null;
@Column({ name: 'gedo_pdf_original_name', type: 'varchar', length: 255, nullable: true })
gedoPdfOriginalName!: string | null;
@Column({ name: 'gedo_pdf_stored_name', type: 'varchar', length: 255, nullable: true })
gedoPdfStoredName!: string | null;
@Column({ name: 'gedo_pdf_mime_type', type: 'varchar', length: 120, nullable: true })
gedoPdfMimeType!: string | null;
@Column({ name: 'gedo_pdf_size_bytes', type: 'integer', nullable: true })
gedoPdfSizeBytes!: number | null;
@Column({ name: 'gedo_pdf_sha256', type: 'char', length: 64, nullable: true })
gedoPdfSha256!: string | null;
@Column({ name: 'gedo_pdf_uploaded_at', type: 'timestamptz', nullable: true })
gedoPdfUploadedAt!: Date | null;
@Column({ name: 'gedo_pdf_uploaded_by', type: 'uuid', nullable: true })
gedoPdfUploadedBy!: string | null;
@Column({ name: 'review_status', type: 'varchar', length: 32, default: InspectionReportReviewStatus.PENDING_REVIEW }) @Column({ name: 'review_status', type: 'varchar', length: 32, default: InspectionReportReviewStatus.PENDING_REVIEW })
reviewStatus!: InspectionReportReviewStatus; reviewStatus!: InspectionReportReviewStatus;
@@ -1,44 +0,0 @@
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
import { TimestampedEntity } from './timestamped.entity';
export enum SurveyTargetStatus {
PENDING = 'PENDING',
IN_PROGRESS = 'IN_PROGRESS',
SUBMITTED = 'SUBMITTED',
COMPLETED = 'COMPLETED',
SKIPPED = 'SKIPPED',
}
@Entity({ name: 'survey_campaign_targets' })
@Index('uq_survey_campaign_targets_campaign_asset', ['campaignId', 'assetId'], { unique: true })
@Index('idx_survey_campaign_targets_campaign_status', ['campaignId', 'status'])
@Index('idx_survey_campaign_targets_asset_id', ['assetId'])
@Index('idx_survey_campaign_targets_assigned_user_id', ['assignedUserId'])
export class SurveyCampaignTarget extends TimestampedEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'campaign_id', type: 'uuid' })
campaignId!: string;
@Column({ name: 'asset_id', type: 'uuid' })
assetId!: string;
@Column({ name: 'assigned_user_id', type: 'uuid', nullable: true })
assignedUserId!: string | null;
@Column({ type: 'varchar', length: 24, default: SurveyTargetStatus.PENDING })
status!: SurveyTargetStatus;
@Column({ name: 'due_at', type: 'timestamptz', nullable: true })
dueAt!: Date | null;
@Column({ type: 'text', nullable: true })
instructions!: string | null;
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdBy!: string | null;
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
updatedBy!: string | null;
}
@@ -1,51 +0,0 @@
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
import { TimestampedEntity } from './timestamped.entity';
export enum SurveyCampaignStatus {
DRAFT = 'DRAFT',
PLANNED = 'PLANNED',
IN_PROGRESS = 'IN_PROGRESS',
COMPLETED = 'COMPLETED',
CANCELLED = 'CANCELLED',
}
@Entity({ name: 'survey_campaigns' })
@Index('uq_survey_campaigns_code', ['code'], { unique: true })
@Index('idx_survey_campaigns_status', ['status'])
@Index('idx_survey_campaigns_scope_asset_id', ['scopeAssetId'])
@Index('idx_survey_campaigns_coordinator_user_id', ['coordinatorUserId'])
@Index('idx_survey_campaigns_planned_dates', ['plannedStartAt', 'plannedEndAt'])
export class SurveyCampaign extends TimestampedEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ type: 'varchar', length: 80 })
code!: string;
@Column({ type: 'varchar', length: 200 })
name!: string;
@Column({ type: 'text', nullable: true })
description!: string | null;
@Column({ type: 'varchar', length: 24, default: SurveyCampaignStatus.DRAFT })
status!: SurveyCampaignStatus;
@Column({ name: 'planned_start_at', type: 'timestamptz', nullable: true })
plannedStartAt!: Date | null;
@Column({ name: 'planned_end_at', type: 'timestamptz', nullable: true })
plannedEndAt!: Date | null;
@Column({ name: 'scope_asset_id', type: 'uuid', nullable: true })
scopeAssetId!: string | null;
@Column({ name: 'coordinator_user_id', type: 'uuid', nullable: true })
coordinatorUserId!: string | null;
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdBy!: string | null;
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
updatedBy!: string | null;
}
@@ -1,19 +0,0 @@
import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
import { TimestampedEntity } from './timestamped.entity';
@Entity({ name: 'survey_target_report_media' })
@Index('idx_survey_target_report_media_media_id', ['mediaId'])
@Index('idx_survey_target_report_media_included', ['reportId', 'included'])
export class SurveyTargetReportMedia extends TimestampedEntity {
@PrimaryColumn({ name: 'report_id', type: 'uuid' })
reportId!: string;
@PrimaryColumn({ name: 'media_id', type: 'uuid' })
mediaId!: string;
@Column({ type: 'boolean', default: true })
included!: boolean;
@Column({ name: 'added_by', type: 'uuid', nullable: true })
addedBy!: string | null;
}
@@ -1,36 +0,0 @@
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
export enum SurveyReportVersionEvent {
SUBMITTED = 'SUBMITTED',
APPROVED = 'APPROVED',
REJECTED = 'REJECTED',
}
@Entity({ name: 'survey_target_report_versions' })
@Index('uq_survey_target_report_versions_number', ['reportId', 'versionNumber'], { unique: true })
@Index('idx_survey_target_report_versions_created_at', ['createdAt'])
export class SurveyTargetReportVersion {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'report_id', type: 'uuid' })
reportId!: string;
@Column({ name: 'version_number', type: 'integer' })
versionNumber!: number;
@Column({ type: 'varchar', length: 24 })
event!: SurveyReportVersionEvent;
@Column({ type: 'jsonb' })
snapshot!: Record<string, unknown>;
@Column({ name: 'actor_user_id', type: 'uuid', nullable: true })
actorUserId!: string | null;
@Column({ name: 'actor_username', type: 'varchar', length: 80, nullable: true })
actorUsername!: string | null;
@Column({ name: 'created_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' })
createdAt!: Date;
}
@@ -1,73 +0,0 @@
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
import { TimestampedEntity } from './timestamped.entity';
export enum SurveyReportOutcome {
CONFIRMED = 'CONFIRMED',
CHANGES_RECORDED = 'CHANGES_RECORDED',
NOT_LOCATED = 'NOT_LOCATED',
}
export enum SurveyReportStatus {
DRAFT = 'DRAFT',
SUBMITTED = 'SUBMITTED',
APPROVED = 'APPROVED',
REJECTED = 'REJECTED',
}
@Entity({ name: 'survey_target_reports' })
@Index('uq_survey_target_reports_target_id', ['targetId'], { unique: true })
@Index('idx_survey_target_reports_status', ['status'])
@Index('idx_survey_target_reports_submitted_by', ['submittedBy'])
@Index('idx_survey_target_reports_reviewed_by', ['reviewedBy'])
export class SurveyTargetReport extends TimestampedEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'target_id', type: 'uuid' })
targetId!: string;
@Column({ type: 'varchar', length: 32, nullable: true })
outcome!: SurveyReportOutcome | null;
@Column({ type: 'varchar', length: 24, default: SurveyReportStatus.DRAFT })
status!: SurveyReportStatus;
@Column({ name: 'observed_at', type: 'timestamptz', nullable: true })
observedAt!: Date | null;
@Column({ type: 'numeric', precision: 9, scale: 6, nullable: true })
latitude!: string | null;
@Column({ type: 'numeric', precision: 9, scale: 6, nullable: true })
longitude!: string | null;
@Column({ name: 'accuracy_m', type: 'numeric', precision: 12, scale: 3, nullable: true })
accuracyM!: string | null;
@Column({ type: 'text', nullable: true })
notes!: string | null;
@Column({ name: 'asset_version_at_submission', type: 'integer', nullable: true })
assetVersionAtSubmission!: number | null;
@Column({ name: 'submitted_at', type: 'timestamptz', nullable: true })
submittedAt!: Date | null;
@Column({ name: 'submitted_by', type: 'uuid', nullable: true })
submittedBy!: string | null;
@Column({ name: 'reviewed_at', type: 'timestamptz', nullable: true })
reviewedAt!: Date | null;
@Column({ name: 'reviewed_by', type: 'uuid', nullable: true })
reviewedBy!: string | null;
@Column({ name: 'review_notes', type: 'text', nullable: true })
reviewNotes!: string | null;
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdBy!: string | null;
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
updatedBy!: string | null;
}
@@ -0,0 +1,263 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class PhaseF4DocumentFlowFoundation1790035200000 implements MigrationInterface {
name = 'PhaseF4DocumentFlowFoundation1790035200000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE inspection_acts
ADD COLUMN urgency varchar(20),
ADD COLUMN deadline_days integer,
ADD COLUMN deadline_day_type varchar(20),
ADD COLUMN deadline_basis varchar(24),
ADD COLUMN deadline_base_on date,
ADD COLUMN deadline_due_on date,
ADD COLUMN deadline_policy_snapshot jsonb,
ADD COLUMN locked_at timestamptz,
ADD COLUMN locked_by uuid,
ADD COLUMN locked_sha256 char(64),
ADD COLUMN sealed_at timestamptz,
ADD COLUMN sealed_by uuid
`);
await queryRunner.query(`
ALTER TABLE inspection_acts
ADD CONSTRAINT chk_inspection_acts_urgency
CHECK (urgency IS NULL OR urgency IN ('URGENT','NON_URGENT')),
ADD CONSTRAINT chk_inspection_acts_deadline_days
CHECK (deadline_days IS NULL OR deadline_days > 0),
ADD CONSTRAINT chk_inspection_acts_deadline_day_type
CHECK (deadline_day_type IS NULL OR deadline_day_type IN ('BUSINESS','CALENDAR')),
ADD CONSTRAINT chk_inspection_acts_deadline_basis
CHECK (deadline_basis IS NULL OR deadline_basis IN ('ACT_DATE','GEDO_LOAD_DATE')),
ADD CONSTRAINT fk_inspection_acts_locked_by
FOREIGN KEY (locked_by) REFERENCES users(id) ON DELETE SET NULL,
ADD CONSTRAINT fk_inspection_acts_sealed_by
FOREIGN KEY (sealed_by) REFERENCES users(id) ON DELETE SET NULL
`);
await queryRunner.query(`
CREATE INDEX idx_inspection_acts_deadline_due_on
ON inspection_acts(deadline_due_on)
WHERE deadline_due_on IS NOT NULL
`);
await queryRunner.query(`
CREATE TABLE inspection_deadline_policies (
urgency varchar(20) PRIMARY KEY,
days integer NOT NULL,
day_type varchar(20) NOT NULL,
basis varchar(24) NOT NULL,
updated_by uuid,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT chk_inspection_deadline_policies_urgency
CHECK (urgency IN ('URGENT','NON_URGENT')),
CONSTRAINT chk_inspection_deadline_policies_days
CHECK (days > 0),
CONSTRAINT chk_inspection_deadline_policies_day_type
CHECK (day_type IN ('BUSINESS','CALENDAR')),
CONSTRAINT chk_inspection_deadline_policies_basis
CHECK (basis IN ('ACT_DATE','GEDO_LOAD_DATE')),
CONSTRAINT fk_inspection_deadline_policies_updated_by
FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL
)
`);
await queryRunner.query(`
INSERT INTO inspection_deadline_policies (urgency, days, day_type, basis)
VALUES
('URGENT', 5, 'BUSINESS', 'ACT_DATE'),
('NON_URGENT', 10, 'BUSINESS', 'GEDO_LOAD_DATE')
`);
await queryRunner.query(`
CREATE TABLE inspection_non_working_days (
day date PRIMARY KEY,
label varchar(200) NOT NULL,
enabled boolean NOT NULL DEFAULT true,
created_by uuid,
updated_by uuid,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT chk_inspection_non_working_days_label
CHECK (LENGTH(TRIM(label)) > 0),
CONSTRAINT fk_inspection_non_working_days_created_by
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT fk_inspection_non_working_days_updated_by
FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL
)
`);
await queryRunner.query(`
ALTER TABLE inspection_findings
ADD COLUMN is_recurrence boolean NOT NULL DEFAULT false,
ADD COLUMN antecedent_finding_id uuid
`);
await queryRunner.query(`
ALTER TABLE inspection_findings
ADD CONSTRAINT fk_inspection_findings_antecedent
FOREIGN KEY (antecedent_finding_id) REFERENCES inspection_findings(id) ON DELETE RESTRICT,
ADD CONSTRAINT chk_inspection_findings_recurrence_link
CHECK (
(is_recurrence = false AND antecedent_finding_id IS NULL)
OR (is_recurrence = true AND antecedent_finding_id IS NOT NULL)
),
ADD CONSTRAINT chk_inspection_findings_not_self_antecedent
CHECK (antecedent_finding_id IS NULL OR antecedent_finding_id <> id)
`);
await queryRunner.query(`
CREATE INDEX idx_inspection_findings_antecedent
ON inspection_findings(antecedent_finding_id)
WHERE antecedent_finding_id IS NOT NULL
`);
await queryRunner.query(`
ALTER TABLE inspection_reports
ADD COLUMN reference_text text,
ADD COLUMN general_objective text,
ADD COLUMN specific_objective text,
ADD COLUMN background text,
ADD COLUMN legal_framework text,
ADD COLUMN executive_summary text,
ADD COLUMN description text,
ADD COLUMN conclusion text,
ADD COLUMN gedo_if_identifier varchar(255),
ADD COLUMN gedo_officialized_on date,
ADD COLUMN gedo_pdf_original_name varchar(255),
ADD COLUMN gedo_pdf_stored_name varchar(255),
ADD COLUMN gedo_pdf_mime_type varchar(120),
ADD COLUMN gedo_pdf_size_bytes integer,
ADD COLUMN gedo_pdf_sha256 char(64),
ADD COLUMN gedo_pdf_uploaded_at timestamptz,
ADD COLUMN gedo_pdf_uploaded_by uuid
`);
await queryRunner.query(`
ALTER TABLE inspection_reports
ADD CONSTRAINT chk_inspection_reports_gedo_pdf_size
CHECK (gedo_pdf_size_bytes IS NULL OR gedo_pdf_size_bytes >= 0),
ADD CONSTRAINT fk_inspection_reports_gedo_pdf_uploaded_by
FOREIGN KEY (gedo_pdf_uploaded_by) REFERENCES users(id) ON DELETE SET NULL
`);
await queryRunner.query(`
CREATE INDEX idx_inspection_reports_gedo_if_identifier
ON inspection_reports(gedo_if_identifier)
WHERE gedo_if_identifier IS NOT NULL
`);
await queryRunner.query(`
CREATE TABLE inspection_report_follow_ups (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
report_id uuid NOT NULL,
event_type varchar(32) NOT NULL,
reference_number varchar(255),
occurred_on date NOT NULL,
description text,
created_by uuid,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT chk_inspection_report_follow_ups_type
CHECK (event_type IN ('COMPANY_NOTE','COMPANY_DOCUMENT','INTERNAL_NOTE','VERIFICATION','OTHER')),
CONSTRAINT fk_inspection_report_follow_ups_report
FOREIGN KEY (report_id) REFERENCES inspection_reports(id) ON DELETE RESTRICT,
CONSTRAINT fk_inspection_report_follow_ups_created_by
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
)
`);
await queryRunner.query(`
CREATE INDEX idx_inspection_report_follow_ups_report_occurred
ON inspection_report_follow_ups(report_id, occurred_on, created_at)
`);
await queryRunner.query(`
CREATE INDEX idx_inspection_report_follow_ups_type
ON inspection_report_follow_ups(event_type)
`);
await queryRunner.query(`
CREATE TABLE inspection_report_follow_up_files (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
follow_up_id uuid NOT NULL,
original_name varchar(255) NOT NULL,
stored_name varchar(255) NOT NULL,
mime_type varchar(120) NOT NULL,
size_bytes integer NOT NULL,
sha256 char(64) NOT NULL,
created_by uuid,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT chk_inspection_report_follow_up_files_size
CHECK (size_bytes >= 0),
CONSTRAINT fk_inspection_report_follow_up_files_follow_up
FOREIGN KEY (follow_up_id) REFERENCES inspection_report_follow_ups(id) ON DELETE RESTRICT,
CONSTRAINT fk_inspection_report_follow_up_files_created_by
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
)
`);
await queryRunner.query(`
CREATE INDEX idx_inspection_report_follow_up_files_follow_up
ON inspection_report_follow_up_files(follow_up_id)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS inspection_report_follow_up_files`);
await queryRunner.query(`DROP TABLE IF EXISTS inspection_report_follow_ups`);
await queryRunner.query(`DROP INDEX IF EXISTS idx_inspection_reports_gedo_if_identifier`);
await queryRunner.query(`
ALTER TABLE inspection_reports
DROP CONSTRAINT IF EXISTS fk_inspection_reports_gedo_pdf_uploaded_by,
DROP CONSTRAINT IF EXISTS chk_inspection_reports_gedo_pdf_size,
DROP COLUMN IF EXISTS gedo_pdf_uploaded_by,
DROP COLUMN IF EXISTS gedo_pdf_uploaded_at,
DROP COLUMN IF EXISTS gedo_pdf_sha256,
DROP COLUMN IF EXISTS gedo_pdf_size_bytes,
DROP COLUMN IF EXISTS gedo_pdf_mime_type,
DROP COLUMN IF EXISTS gedo_pdf_stored_name,
DROP COLUMN IF EXISTS gedo_pdf_original_name,
DROP COLUMN IF EXISTS gedo_officialized_on,
DROP COLUMN IF EXISTS gedo_if_identifier,
DROP COLUMN IF EXISTS conclusion,
DROP COLUMN IF EXISTS description,
DROP COLUMN IF EXISTS executive_summary,
DROP COLUMN IF EXISTS legal_framework,
DROP COLUMN IF EXISTS background,
DROP COLUMN IF EXISTS specific_objective,
DROP COLUMN IF EXISTS general_objective,
DROP COLUMN IF EXISTS reference_text
`);
await queryRunner.query(`DROP INDEX IF EXISTS idx_inspection_findings_antecedent`);
await queryRunner.query(`
ALTER TABLE inspection_findings
DROP CONSTRAINT IF EXISTS chk_inspection_findings_not_self_antecedent,
DROP CONSTRAINT IF EXISTS chk_inspection_findings_recurrence_link,
DROP CONSTRAINT IF EXISTS fk_inspection_findings_antecedent,
DROP COLUMN IF EXISTS antecedent_finding_id,
DROP COLUMN IF EXISTS is_recurrence
`);
await queryRunner.query(`DROP TABLE IF EXISTS inspection_non_working_days`);
await queryRunner.query(`DROP TABLE IF EXISTS inspection_deadline_policies`);
await queryRunner.query(`DROP INDEX IF EXISTS idx_inspection_acts_deadline_due_on`);
await queryRunner.query(`
ALTER TABLE inspection_acts
DROP CONSTRAINT IF EXISTS fk_inspection_acts_sealed_by,
DROP CONSTRAINT IF EXISTS fk_inspection_acts_locked_by,
DROP CONSTRAINT IF EXISTS chk_inspection_acts_deadline_basis,
DROP CONSTRAINT IF EXISTS chk_inspection_acts_deadline_day_type,
DROP CONSTRAINT IF EXISTS chk_inspection_acts_deadline_days,
DROP CONSTRAINT IF EXISTS chk_inspection_acts_urgency,
DROP COLUMN IF EXISTS sealed_by,
DROP COLUMN IF EXISTS sealed_at,
DROP COLUMN IF EXISTS locked_sha256,
DROP COLUMN IF EXISTS locked_by,
DROP COLUMN IF EXISTS locked_at,
DROP COLUMN IF EXISTS deadline_policy_snapshot,
DROP COLUMN IF EXISTS deadline_due_on,
DROP COLUMN IF EXISTS deadline_base_on,
DROP COLUMN IF EXISTS deadline_basis,
DROP COLUMN IF EXISTS deadline_day_type,
DROP COLUMN IF EXISTS deadline_days,
DROP COLUMN IF EXISTS urgency
`);
}
}
@@ -0,0 +1,38 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class PhaseF4DeadlineAdministration1790038800000 implements MigrationInterface {
name = 'PhaseF4DeadlineAdministration1790038800000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
INSERT INTO permissions (code, description)
VALUES ('inspection_deadlines.manage', 'Administrar plazos institucionales y calendario no laborable de Actas')
ON CONFLICT (code) DO UPDATE SET description = EXCLUDED.description
`);
await queryRunner.query(`
WITH mapping(role_code, permission_code) AS (
VALUES
('admin', 'inspection_deadlines.manage'),
('supervisor', 'inspection_deadlines.manage')
)
INSERT INTO role_permissions (role_id, permission_id)
SELECT role.id, permission.id
FROM mapping
INNER JOIN roles role ON role.code = mapping.role_code
INNER JOIN permissions permission ON permission.code = mapping.permission_code
ON CONFLICT DO NOTHING
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DELETE FROM role_permissions
WHERE permission_id IN (
SELECT id FROM permissions WHERE code = 'inspection_deadlines.manage'
)
`);
await queryRunner.query(`
DELETE FROM permissions WHERE code = 'inspection_deadlines.manage'
`);
}
}
@@ -0,0 +1,64 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class PhaseF4ReportDossierPermissions1790042400000 implements MigrationInterface {
name = 'PhaseF4ReportDossierPermissions1790042400000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
INSERT INTO permissions (code, description)
VALUES
('inspection_reports.edit', 'Editar el contenido de trabajo del Informe de inspección'),
('inspection_reports.officialize', 'Registrar el IF y PDF oficial generado por GEDO'),
('inspection_reports.follow_up', 'Agregar respuestas, notas y documentos al seguimiento del Informe')
ON CONFLICT (code) DO UPDATE SET description = EXCLUDED.description
`);
await queryRunner.query(`
WITH mapping(role_code, permission_code) AS (
VALUES
('admin', 'inspection_reports.edit'),
('admin', 'inspection_reports.officialize'),
('admin', 'inspection_reports.follow_up'),
('supervisor', 'inspection_reports.edit'),
('supervisor', 'inspection_reports.officialize'),
('supervisor', 'inspection_reports.follow_up'),
('inspector', 'inspection_reports.edit'),
('inspector', 'inspection_reports.officialize'),
('inspector', 'inspection_reports.follow_up')
)
INSERT INTO role_permissions (role_id, permission_id)
SELECT role.id, permission.id
FROM mapping
INNER JOIN roles role ON role.code = mapping.role_code
INNER JOIN permissions permission ON permission.code = mapping.permission_code
ON CONFLICT DO NOTHING
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS uq_inspection_reports_gedo_if_identifier
ON inspection_reports(gedo_if_identifier)
WHERE gedo_if_identifier IS NOT NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS uq_inspection_reports_gedo_if_identifier`);
await queryRunner.query(`
DELETE FROM role_permissions
WHERE permission_id IN (
SELECT id FROM permissions
WHERE code IN (
'inspection_reports.edit',
'inspection_reports.officialize',
'inspection_reports.follow_up'
)
)
`);
await queryRunner.query(`
DELETE FROM permissions
WHERE code IN (
'inspection_reports.edit',
'inspection_reports.officialize',
'inspection_reports.follow_up'
)
`);
}
}
@@ -0,0 +1,144 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class PhaseF4DocumentNumbering1790046000000 implements MigrationInterface {
name = 'PhaseF4DocumentNumbering1790046000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM inspection_acts WHERE act_number > 99999) THEN
RAISE EXCEPTION 'F4 numbering requires inspection act numbers <= 99999';
END IF;
IF EXISTS (
SELECT 1
FROM inspection_visits
WHERE code ~ '^INS-[0-9]{4}-[0-9]{6}$'
AND RIGHT(code, 6)::integer > 99999
) THEN
RAISE EXCEPTION 'F4 numbering requires inspection visit numbers <= 99999';
END IF;
END $$
`);
await queryRunner.query(`
UPDATE inspection_visits
SET code = 'INSP-'
|| LPAD(RIGHT(code, 6)::integer::text, 5, '0')
|| '-'
|| TO_CHAR(
COALESCE(planned_start_at, created_at) AT TIME ZONE 'America/Argentina/Mendoza',
'DD-MM-YY'
),
title = CASE
WHEN title = inspection_visits.code THEN 'INSP-'
|| LPAD(RIGHT(inspection_visits.code, 6)::integer::text, 5, '0')
|| '-'
|| TO_CHAR(
COALESCE(planned_start_at, created_at) AT TIME ZONE 'America/Argentina/Mendoza',
'DD-MM-YY'
)
ELSE title
END
WHERE code ~ '^INS-[0-9]{4}-[0-9]{6}$'
`);
await queryRunner.query(`
UPDATE inspection_acts
SET code = 'ACT-'
|| LPAD(act_number::text, 5, '0')
|| '-'
|| TO_CHAR(occurred_at AT TIME ZONE 'America/Argentina/Mendoza', 'DD-MM-YY')
`);
await queryRunner.query(`DROP INDEX IF EXISTS uq_inspection_reports_year_number`);
await queryRunner.query(`
UPDATE inspection_reports report
SET report_year = act.act_year,
report_number = act.act_number,
code = 'INF-'
|| LPAD(act.act_number::text, 5, '0')
|| '-'
|| TO_CHAR(act.occurred_at AT TIME ZONE 'America/Argentina/Mendoza', 'DD-MM-YY')
FROM inspection_acts act
WHERE act.id = report.act_id
`);
await queryRunner.query(`
CREATE UNIQUE INDEX uq_inspection_reports_year_number
ON inspection_reports(report_year, report_number)
`);
await queryRunner.query(`
CREATE OR REPLACE FUNCTION dhv2_f4_set_act_code()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
IF NEW.act_number > 99999 THEN
RAISE EXCEPTION 'Inspection Act sequence exhausted for F4 institutional format';
END IF;
NEW.code := 'ACT-'
|| LPAD(NEW.act_number::text, 5, '0')
|| '-'
|| TO_CHAR(NEW.occurred_at AT TIME ZONE 'America/Argentina/Mendoza', 'DD-MM-YY');
RETURN NEW;
END;
$$
`);
await queryRunner.query(`
DROP TRIGGER IF EXISTS trg_dhv2_f4_set_act_code ON inspection_acts
`);
await queryRunner.query(`
CREATE TRIGGER trg_dhv2_f4_set_act_code
BEFORE INSERT OR UPDATE OF act_number, occurred_at
ON inspection_acts
FOR EACH ROW
EXECUTE FUNCTION dhv2_f4_set_act_code()
`);
await queryRunner.query(`
CREATE OR REPLACE FUNCTION dhv2_f4_set_report_code()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
source_act inspection_acts%ROWTYPE;
BEGIN
SELECT * INTO source_act
FROM inspection_acts
WHERE id = NEW.act_id;
IF NOT FOUND THEN
RAISE EXCEPTION 'Inspection Act not found for report %', NEW.act_id;
END IF;
IF source_act.act_number > 99999 THEN
RAISE EXCEPTION 'Inspection Report sequence exhausted for F4 institutional format';
END IF;
NEW.report_year := source_act.act_year;
NEW.report_number := source_act.act_number;
NEW.code := 'INF-'
|| LPAD(source_act.act_number::text, 5, '0')
|| '-'
|| TO_CHAR(source_act.occurred_at AT TIME ZONE 'America/Argentina/Mendoza', 'DD-MM-YY');
RETURN NEW;
END;
$$
`);
await queryRunner.query(`
DROP TRIGGER IF EXISTS trg_dhv2_f4_set_report_code ON inspection_reports
`);
await queryRunner.query(`
CREATE TRIGGER trg_dhv2_f4_set_report_code
BEFORE INSERT OR UPDATE OF act_id
ON inspection_reports
FOR EACH ROW
EXECUTE FUNCTION dhv2_f4_set_report_code()
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TRIGGER IF EXISTS trg_dhv2_f4_set_report_code ON inspection_reports`);
await queryRunner.query(`DROP FUNCTION IF EXISTS dhv2_f4_set_report_code()`);
await queryRunner.query(`DROP TRIGGER IF EXISTS trg_dhv2_f4_set_act_code ON inspection_acts`);
await queryRunner.query(`DROP FUNCTION IF EXISTS dhv2_f4_set_act_code()`);
}
}
@@ -0,0 +1,87 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class PhaseF4SmtpSuperadmin1790049600000 implements MigrationInterface {
name = 'PhaseF4SmtpSuperadmin1790049600000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE smtp_settings (
id smallint PRIMARY KEY,
enabled boolean NOT NULL DEFAULT false,
host varchar(255),
port integer,
security_mode varchar(20),
username varchar(255),
password_encrypted text,
from_name varchar(160),
from_email varchar(255),
reply_to varchar(255),
updated_by uuid,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT chk_smtp_settings_singleton CHECK (id = 1),
CONSTRAINT chk_smtp_settings_port CHECK (port IS NULL OR port BETWEEN 1 AND 65535),
CONSTRAINT chk_smtp_settings_security CHECK (
security_mode IS NULL OR security_mode IN ('TLS','STARTTLS')
),
CONSTRAINT fk_smtp_settings_updated_by
FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL
)
`);
await queryRunner.query(`
INSERT INTO smtp_settings (id, enabled)
VALUES (1, false)
`);
await queryRunner.query(`
INSERT INTO permissions (code, description)
VALUES ('system_mail.manage', 'Configurar la salida SMTP del sistema y probar el transporte')
ON CONFLICT (code) DO UPDATE SET description = EXCLUDED.description
`);
await queryRunner.query(`
INSERT INTO role_permissions (role_id, permission_id)
SELECT role.id, permission.id
FROM roles role
CROSS JOIN permissions permission
WHERE role.code = 'admin'
AND permission.code = 'system_mail.manage'
ON CONFLICT DO NOTHING
`);
await queryRunner.query(`
UPDATE inspection_document_deliveries delivery
SET recipient_kind = 'INSPECTOR',
recipient_user_id = visit.lead_inspector_user_id,
recipient_key = visit.lead_inspector_user_id,
recipient_email = inspector.email,
status = CASE WHEN inspector.email IS NULL THEN 'WAITING_RECIPIENT' ELSE 'PENDING' END,
last_error = NULL,
updated_at = CURRENT_TIMESTAMP
FROM inspection_acts act
INNER JOIN inspection_visits visit ON visit.id = act.visit_id
INNER JOIN users inspector ON inspector.id = visit.lead_inspector_user_id
WHERE delivery.act_id = act.id
AND delivery.document_kind = 'REPORT_WORD'
AND delivery.recipient_kind = 'DIRECTOR'
AND delivery.status <> 'SENT'
AND NOT EXISTS (
SELECT 1
FROM inspection_document_deliveries existing
WHERE existing.act_id = delivery.act_id
AND existing.document_kind = 'REPORT_WORD'
AND existing.recipient_kind = 'INSPECTOR'
AND existing.recipient_key = visit.lead_inspector_user_id
)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DELETE FROM role_permissions
WHERE permission_id IN (
SELECT id FROM permissions WHERE code = 'system_mail.manage'
)
`);
await queryRunner.query(`DELETE FROM permissions WHERE code = 'system_mail.manage'`);
await queryRunner.query(`DROP TABLE IF EXISTS smtp_settings`);
}
}
@@ -0,0 +1,28 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class PhaseF4RemoveSurveySubsystem1790053200000 implements MigrationInterface {
name = 'PhaseF4RemoveSurveySubsystem1790053200000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DELETE FROM role_permissions
WHERE permission_id IN (
SELECT id FROM permissions WHERE code LIKE 'surveys.%'
)
`);
await queryRunner.query(`
DELETE FROM permissions WHERE code LIKE 'surveys.%'
`);
await queryRunner.query(`DROP TABLE IF EXISTS survey_target_report_media`);
await queryRunner.query(`DROP TABLE IF EXISTS survey_target_report_versions`);
await queryRunner.query(`DROP TABLE IF EXISTS survey_target_reports`);
await queryRunner.query(`DROP TABLE IF EXISTS survey_campaign_targets`);
await queryRunner.query(`DROP TABLE IF EXISTS survey_campaigns`);
}
public async down(_queryRunner: QueryRunner): Promise<void> {
// Intentionally irreversible: historical migrations still document the old
// Survey schema, but restoring dropped campaign/report data would be unsafe.
}
}
@@ -0,0 +1,43 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class PhaseF4SealedActs1790056800000 implements MigrationInterface {
name = 'PhaseF4SealedActs1790056800000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
UPDATE inspection_acts
SET sealed_at = COALESCE(closed_at, updated_at, CURRENT_TIMESTAMP),
sealed_by = closed_by
WHERE status = 'CLOSED'
AND sealed_at IS NULL
`);
await queryRunner.query(`
CREATE OR REPLACE FUNCTION dhv2_f4_stamp_act_seal()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
IF NEW.status = 'CLOSED' AND OLD.status IS DISTINCT FROM 'CLOSED' THEN
NEW.sealed_at := COALESCE(NEW.closed_at, CURRENT_TIMESTAMP);
NEW.sealed_by := NEW.closed_by;
END IF;
RETURN NEW;
END;
$$
`);
await queryRunner.query(`
DROP TRIGGER IF EXISTS trg_dhv2_f4_stamp_act_seal ON inspection_acts
`);
await queryRunner.query(`
CREATE TRIGGER trg_dhv2_f4_stamp_act_seal
BEFORE UPDATE OF status ON inspection_acts
FOR EACH ROW
EXECUTE FUNCTION dhv2_f4_stamp_act_seal()
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TRIGGER IF EXISTS trg_dhv2_f4_stamp_act_seal ON inspection_acts`);
await queryRunner.query(`DROP FUNCTION IF EXISTS dhv2_f4_stamp_act_seal()`);
}
}
@@ -0,0 +1,7 @@
import { IsEnum } from 'class-validator';
import { InspectionActUrgency } from '../../database/entities';
export class LockInspectionActDto {
@IsEnum(InspectionActUrgency)
urgency!: InspectionActUrgency;
}
@@ -0,0 +1,22 @@
import { Body, Controller, Param, ParseUUIDPipe, Post, 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 { LockInspectionActDto } from './dto/lock-inspection-act.dto';
import { InspectionActLifecycleService } from './inspection-act-lifecycle.service';
@Controller('inspection-acts/:actId')
export class InspectionActLifecycleController {
constructor(private readonly lifecycle: InspectionActLifecycleService) {}
@Post('lock')
@RequirePermissions('inspection_closure.prepare')
lock(
@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string,
@Body() dto: LockInspectionActDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.lifecycle.lock(actId, dto, principal, request);
}
}
@@ -0,0 +1,509 @@
import {
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
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 {
AuditAction,
InspectionActStatus,
InspectionActVersionEvent,
InspectionVisitStatus,
} from '../database/entities';
import { InspectionDeadlinesService } from '../inspection-deadlines/inspection-deadlines.service';
import { assertMobileInspector } from '../inspection-operations/mobile-inspector-policy';
import { sha256CanonicalJson } from './canonical-json';
import type { LockInspectionActDto } from './dto/lock-inspection-act.dto';
const CLOSURE_SCHEMA_VERSION = 'DH-ACT-CLOSURE-V2';
interface LockContext {
id: string;
code: string;
visitId: string;
status: InspectionActStatus;
occurredAt: Date;
currentVersion: number;
visitStatus: InspectionVisitStatus;
leadInspectorUserId: string;
}
@Injectable()
export class InspectionActLifecycleService {
constructor(
private readonly dataSource: DataSource,
private readonly audit: AuditService,
private readonly deadlines: InspectionDeadlinesService,
) {}
async lock(
actId: string,
dto: LockInspectionActDto,
principal: AuthPrincipal,
request: RequestWithContext,
) {
assertMobileInspector(principal);
return this.dataSource.transaction(async (manager) => {
const context = await this.lockContext(manager, actId);
if (context.status !== InspectionActStatus.DRAFT) {
throw new ConflictException({
code: 'INSPECTION_ACT_NOT_DRAFT',
message: 'Sólo puede finalizarse y bloquearse un Acta que esté en borrador',
});
}
if (context.visitStatus !== InspectionVisitStatus.IN_PROGRESS) {
throw new ConflictException({
code: 'INSPECTION_VISIT_NOT_IN_PROGRESS',
message: 'El Acta sólo puede bloquearse durante una inspección en curso',
});
}
await this.assertActorAssigned(manager, context, principal);
await this.requireResponsible(manager, actId);
await this.requireVerificationResults(manager, context.visitId);
const [signatureCount] = (await manager.query(`
SELECT COUNT(*)::integer AS total
FROM inspection_act_signatures
WHERE act_id = $1
`, [actId])) as Array<{ total: number }>;
if (Number(signatureCount?.total ?? 0) > 0) {
throw new ConflictException({
code: 'INSPECTION_ACT_ALREADY_SIGNED',
message: 'El Acta ya tiene una manifestación de firma y no puede volver a bloquearse',
});
}
const deadline = await this.deadlines.snapshotForLock(
manager,
dto.urgency,
new Date(context.occurredAt),
);
const lockedAt = new Date();
const [updated] = (await manager.query(`
UPDATE inspection_acts
SET status = 'READY',
urgency = $2,
deadline_days = $3,
deadline_day_type = $4,
deadline_basis = $5,
deadline_base_on = $6::date,
deadline_due_on = $7::date,
deadline_policy_snapshot = $8::jsonb,
locked_at = $9,
locked_by = $10,
current_version = current_version + 1,
updated_by = $10,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1
RETURNING current_version AS "versionNumber"
`, [
actId,
dto.urgency,
deadline.snapshot.days,
deadline.snapshot.dayType,
deadline.snapshot.basis,
deadline.baseOn,
deadline.dueOn,
deadline.snapshot,
lockedAt,
principal.userId,
])) as Array<{ versionNumber: number }>;
const preparedSnapshot = await this.buildPreparedSnapshot(manager, actId, lockedAt);
const preparedSha256 = sha256CanonicalJson(preparedSnapshot);
await manager.query(`
UPDATE inspection_acts
SET locked_sha256 = $2
WHERE id = $1
`, [actId, preparedSha256]);
await manager.query(`
INSERT INTO inspection_act_closures (
act_id, schema_version, prepared_snapshot, prepared_sha256,
prepared_at, prepared_by
) VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (act_id) DO UPDATE SET
schema_version = EXCLUDED.schema_version,
prepared_snapshot = EXCLUDED.prepared_snapshot,
prepared_sha256 = EXCLUDED.prepared_sha256,
prepared_at = EXCLUDED.prepared_at,
prepared_by = EXCLUDED.prepared_by
`, [
actId,
CLOSURE_SCHEMA_VERSION,
preparedSnapshot,
preparedSha256,
lockedAt,
principal.userId,
]);
await manager.query(`
INSERT INTO inspection_act_versions (
act_id, version_number, event, snapshot, actor_user_id, actor_username
) VALUES ($1, $2, $3, $4, $5, $6)
`, [
actId,
Number(updated.versionNumber),
InspectionActVersionEvent.READY,
preparedSnapshot,
principal.userId,
principal.username,
]);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.INSPECTION_ACT_READY,
entityType: 'inspection_act',
entityId: actId,
afterData: {
status: 'LOCKED_PENDING_SIGNATURE',
physicalStatus: InspectionActStatus.READY,
urgency: dto.urgency,
deadlineDays: deadline.snapshot.days,
deadlineDayType: deadline.snapshot.dayType,
deadlineBasis: deadline.snapshot.basis,
deadlineBaseOn: deadline.baseOn,
deadlineDueOn: deadline.dueOn,
lockedAt: lockedAt.toISOString(),
preparedSha256,
},
metadata: {
actId,
visitId: context.visitId,
versionNumber: Number(updated.versionNumber),
immutable: true,
},
}, manager);
return {
id: actId,
code: context.code,
status: 'LOCKED_PENDING_SIGNATURE' as const,
physicalStatus: InspectionActStatus.READY,
urgency: dto.urgency,
deadline: {
days: deadline.snapshot.days,
dayType: deadline.snapshot.dayType,
basis: deadline.snapshot.basis,
baseOn: deadline.baseOn,
dueOn: deadline.dueOn,
},
lockedAt,
preparedSha256,
currentVersion: Number(updated.versionNumber),
};
});
}
private async lockContext(manager: EntityManager, actId: string): Promise<LockContext> {
const [row] = (await manager.query(`
SELECT
act.id,
act.code,
act.visit_id AS "visitId",
act.status,
act.occurred_at AS "occurredAt",
act.current_version AS "currentVersion",
visit.status AS "visitStatus",
visit.lead_inspector_user_id AS "leadInspectorUserId"
FROM inspection_acts act
INNER JOIN inspection_visits visit ON visit.id = act.visit_id
WHERE act.id = $1
FOR UPDATE OF act, visit
`, [actId])) as LockContext[];
if (!row) {
throw new NotFoundException({
code: 'INSPECTION_ACT_NOT_FOUND',
message: 'Acta de inspección no encontrada',
});
}
return row;
}
private async assertActorAssigned(
manager: EntityManager,
context: LockContext,
principal: AuthPrincipal,
): Promise<void> {
if (context.leadInspectorUserId === principal.userId) return;
const [membership] = (await manager.query(`
SELECT 1 AS found
FROM inspection_visit_members
WHERE visit_id = $1
AND user_id = $2
AND included = true
LIMIT 1
`, [context.visitId, principal.userId])) as Array<{ found: number }>;
if (!membership) {
throw new ForbiddenException({
code: 'INSPECTION_ACT_ACTOR_NOT_ASSIGNED',
message: 'Sólo un inspector asignado a la inspección puede finalizar el Acta',
});
}
}
private async requireResponsible(manager: EntityManager, actId: string): Promise<void> {
const [row] = (await manager.query(`
SELECT act_id
FROM inspection_act_responsibles
WHERE act_id = $1
`, [actId])) as Array<{ act_id: string }>;
if (!row) {
throw new ConflictException({
code: 'INSPECTION_ACT_RESPONSIBLE_REQUIRED',
message: 'Antes de finalizar el Acta debe identificarse al responsable de la empresa o documentar su ausencia',
});
}
}
private async requireVerificationResults(manager: EntityManager, visitId: string): Promise<void> {
const [verification] = (await manager.query(`
SELECT
COUNT(*)::integer AS total,
COUNT(*) FILTER (WHERE outcome IS NOT NULL)::integer AS completed
FROM inspection_finding_verification_visits
WHERE visit_id = $1
`, [visitId])) as Array<{ total: number; completed: number }>;
const total = Number(verification?.total ?? 0);
const completed = Number(verification?.completed ?? 0);
if (total > 0 && completed !== total) {
throw new ConflictException({
code: 'INSPECTION_VERIFICATION_RESULTS_REQUIRED',
message: 'Registrá el resultado de todas las verificaciones antes de finalizar el Acta',
});
}
}
private async buildPreparedSnapshot(
manager: EntityManager,
actId: string,
preparedAt: Date,
): Promise<Record<string, unknown>> {
const [act] = (await manager.query(`
SELECT
act.id,
act.code,
act.act_year AS "actYear",
act.act_number AS "actNumber",
act.status,
act.occurred_at AS "occurredAt",
act.title,
act.summary,
act.observations,
act.urgency,
act.deadline_days AS "deadlineDays",
act.deadline_day_type AS "deadlineDayType",
act.deadline_basis AS "deadlineBasis",
act.deadline_base_on AS "deadlineBaseOn",
act.deadline_due_on AS "deadlineDueOn",
act.deadline_policy_snapshot AS "deadlinePolicySnapshot",
act.locked_at AS "lockedAt",
act.current_version AS "currentVersion",
act.created_at AS "createdAt",
act.updated_at AS "updatedAt",
JSONB_BUILD_OBJECT(
'id', visit.id,
'code', visit.code,
'title', visit.title,
'objective', visit.objective,
'status', visit.status,
'scopeAssetId', visit.scope_asset_id,
'leadInspectorUserId', visit.lead_inspector_user_id,
'plannedStartAt', visit.planned_start_at,
'plannedEndAt', visit.planned_end_at,
'actualStartedAt', visit.actual_started_at,
'instructions', visit.instructions
) AS visit
FROM inspection_acts act
INNER JOIN inspection_visits visit ON visit.id = act.visit_id
WHERE act.id = $1
`, [actId])) as Array<Record<string, unknown>>;
if (!act) {
throw new NotFoundException({
code: 'INSPECTION_ACT_NOT_FOUND',
message: 'Acta de inspección no encontrada',
});
}
const [responsible] = (await manager.query(`
SELECT
act_id AS "actId",
attendance_status AS "attendanceStatus",
full_name AS "fullName",
document_type AS "documentType",
document_number AS "documentNumber",
position,
email,
phone,
absence_reason AS "absenceReason",
updated_by AS "updatedBy",
created_at AS "createdAt",
updated_at AS "updatedAt"
FROM inspection_act_responsibles
WHERE act_id = $1
`, [actId])) as Array<Record<string, unknown>>;
const team = await manager.query(`
SELECT
member.user_id AS "userId",
user_account.username,
user_account.first_name AS "firstName",
user_account.last_name AS "lastName",
user_account.email,
(visit.lead_inspector_user_id = member.user_id) AS "isLead"
FROM inspection_visit_members member
INNER JOIN inspection_visits visit ON visit.id = member.visit_id
INNER JOIN users user_account ON user_account.id = member.user_id
WHERE member.visit_id = $1 AND member.included = true
ORDER BY "isLead" DESC, user_account.username, member.user_id
`, [(act.visit as { id: string }).id]) as Array<Record<string, unknown>>;
const assets = await manager.query(`
SELECT
asset.id,
asset.code,
asset.name,
asset.common_name AS "commonName",
asset.description,
asset.parent_id AS "parentId",
CASE WHEN company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id', company.id,
'code', company.code,
'name', company.name,
'commonName', company.common_name
) END AS "operatorCompany",
CASE WHEN area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id', area.id,
'code', area.code,
'name', area.name,
'commonName', area.common_name
) END AS "operationalArea",
asset.information_status AS "informationStatus",
asset.current_version AS "currentVersion",
asset.data_origin AS "dataOrigin",
asset.source_name AS "sourceName",
asset.source_reference AS "sourceReference",
asset.source_observed_at AS "sourceObservedAt",
asset_type.id AS "typeId",
asset_type.code AS "typeCode",
asset_type.name AS "typeName",
COALESCE((
SELECT JSONB_AGG(JSONB_BUILD_OBJECT(
'definitionId', definition.id,
'code', definition.code,
'name', definition.name,
'dataType', definition.data_type,
'value', attribute_value.value
) ORDER BY definition.sort_order, definition.code, definition.id)
FROM asset_attribute_values attribute_value
INNER JOIN asset_attribute_definitions definition
ON definition.id = attribute_value.definition_id
WHERE attribute_value.asset_id = asset.id
), '[]'::jsonb) AS attributes,
CASE WHEN geometry.asset_id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'type', geometry.geometry_type,
'geojson', ST_AsGeoJSON(geometry.geometry)::jsonb,
'source', geometry.source,
'accuracyM', geometry.accuracy_m,
'capturedAt', geometry.captured_at,
'deviceLabel', geometry.device_label
) END AS geometry
FROM inspection_act_assets link
INNER JOIN assets asset ON asset.id = link.asset_id
INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id
LEFT JOIN assets company ON company.id = asset.operator_company_id
LEFT JOIN assets area ON area.id = asset.operational_area_id
LEFT JOIN asset_geometries geometry ON geometry.asset_id = asset.id
WHERE link.act_id = $1 AND link.included = true
ORDER BY asset.code, asset.id
`, [actId]) as Array<Record<string, unknown>>;
const findings = await manager.query(`
SELECT
finding.id,
finding.finding_number AS "findingNumber",
finding.code,
finding.status,
finding.asset_id AS "assetId",
finding.catalog_item_id AS "catalogItemId",
finding.title,
finding.description,
finding.legal_basis AS "legalBasis",
finding.glossary,
finding.catalog_revision AS "catalogRevision",
finding.suggested_severity AS "suggestedSeverity",
finding.severity,
finding.is_recurrence AS "isRecurrence",
finding.antecedent_finding_id AS "antecedentFindingId",
finding.correction_due_on AS "correctionDueOn",
finding.next_control_on AS "nextControlOn",
finding.current_version AS "currentVersion",
JSONB_BUILD_OBJECT(
'code', catalog.code,
'sourceNumber', catalog.source_number,
'title', catalog.title,
'revision', catalog.revision,
'suggestedSeverity', finding.suggested_severity,
'categoryCode', category.code,
'categoryName', category.name
) AS catalog,
COALESCE((
SELECT JSONB_AGG(JSONB_BUILD_OBJECT(
'id', evidence.id,
'communicationId', evidence.communication_id,
'kind', evidence.kind,
'purpose', evidence.purpose,
'originalName', evidence.original_name,
'mimeType', evidence.mime_type,
'sizeBytes', evidence.size_bytes,
'sha256', evidence.sha256,
'title', evidence.title,
'description', evidence.description,
'capturedAt', evidence.captured_at,
'latitude', evidence.latitude,
'longitude', evidence.longitude,
'accuracyM', evidence.accuracy_m,
'deviceLabel', evidence.device_label,
'source', evidence.source,
'uploadedBy', evidence.uploaded_by,
'createdAt', evidence.created_at
) ORDER BY evidence.created_at, evidence.id)
FROM inspection_finding_evidence evidence
WHERE evidence.finding_id = finding.id
), '[]'::jsonb) AS evidence
FROM inspection_findings finding
LEFT JOIN finding_catalog_items catalog ON catalog.id = finding.catalog_item_id
LEFT JOIN finding_categories category ON category.id = catalog.category_id
WHERE finding.act_id = $1 AND finding.status <> 'VOIDED'
ORDER BY finding.finding_number, finding.id
`, [actId]) as Array<Record<string, unknown>>;
const verificationResults = await manager.query(`
SELECT
verification_link.finding_id AS "findingId",
finding.code AS "findingCode",
finding.title AS "findingTitle",
finding.description AS "findingDescription",
finding.asset_id AS "assetId",
asset.code AS "assetCode",
asset.name AS "assetName",
verification_link.target_control_on AS "targetControlOn",
verification_link.outcome,
verification_link.result_notes AS "resultNotes",
verification_link.verified_at AS "verifiedAt",
verification_link.result_recorded_at AS "resultRecordedAt",
verification_link.rescheduled_control_on AS "rescheduledControlOn"
FROM inspection_finding_verification_visits verification_link
INNER JOIN inspection_findings finding ON finding.id = verification_link.finding_id
INNER JOIN assets asset ON asset.id = finding.asset_id
WHERE verification_link.visit_id = $1
ORDER BY finding.code
`, [(act.visit as { id: string }).id]) as Array<Record<string, unknown>>;
return {
schemaVersion: CLOSURE_SCHEMA_VERSION,
preparedAt: preparedAt.toISOString(),
act,
responsible: responsible ?? null,
team,
assets,
findings,
verificationResults,
};
}
}
@@ -48,26 +48,6 @@ export class InspectionClosingController {
return this.closing.upsertResponsible(actId, dto, principal, request); return this.closing.upsertResponsible(actId, dto, principal, request);
} }
@Post('ready')
@RequirePermissions('inspection_closure.prepare')
ready(
@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.closing.prepare(actId, principal, request);
}
@Post('reopen')
@RequirePermissions('inspection_closure.prepare')
reopen(
@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.closing.reopen(actId, principal, request);
}
@Post('signatures/inspector') @Post('signatures/inspector')
@RequirePermissions('inspection_closure.sign') @RequirePermissions('inspection_closure.sign')
@UseInterceptors(FileInterceptor('file', { @UseInterceptors(FileInterceptor('file', {
@@ -1,6 +1,9 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AuditModule } from '../audit/audit.module'; import { AuditModule } from '../audit/audit.module';
import { InspectionDeadlinesModule } from '../inspection-deadlines/inspection-deadlines.module';
import { InspectionReportsModule } from '../inspection-reports/inspection-reports.module'; import { InspectionReportsModule } from '../inspection-reports/inspection-reports.module';
import { InspectionActLifecycleController } from './inspection-act-lifecycle.controller';
import { InspectionActLifecycleService } from './inspection-act-lifecycle.service';
import { import {
InspectionClosingController, InspectionClosingController,
InspectionSignatureContentController, InspectionSignatureContentController,
@@ -8,8 +11,12 @@ import {
import { InspectionClosingService } from './inspection-closing.service'; import { InspectionClosingService } from './inspection-closing.service';
@Module({ @Module({
imports: [AuditModule, InspectionReportsModule], imports: [AuditModule, InspectionDeadlinesModule, InspectionReportsModule],
controllers: [InspectionClosingController, InspectionSignatureContentController], controllers: [
providers: [InspectionClosingService], InspectionActLifecycleController,
InspectionClosingController,
InspectionSignatureContentController,
],
providers: [InspectionActLifecycleService, InspectionClosingService],
}) })
export class InspectionClosingModule {} export class InspectionClosingModule {}
@@ -0,0 +1,14 @@
import { Transform } from 'class-transformer';
import { IsEnum, IsInt, Max, Min } from 'class-validator';
import { InspectionDeadlineDayType } from '../../database/entities';
export class UpdateInspectionDeadlinePolicyDto {
@Transform(({ value }) => Number(value))
@IsInt()
@Min(1)
@Max(365)
days!: number;
@IsEnum(InspectionDeadlineDayType)
dayType!: InspectionDeadlineDayType;
}
@@ -0,0 +1,18 @@
import { Transform } from 'class-transformer';
import { IsBoolean, IsOptional, IsString, Matches, MaxLength, MinLength } from 'class-validator';
export class UpsertInspectionNonWorkingDayDto {
@Matches(/^\d{4}-\d{2}-\d{2}$/)
day!: string;
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString()
@MinLength(2)
@MaxLength(200)
label!: string;
@IsOptional()
@Transform(({ value }) => value === true || value === 'true')
@IsBoolean()
enabled?: boolean;
}
@@ -0,0 +1,46 @@
import { Body, Controller, Get, Param, ParseEnumPipe, Post, Query, Req } from '@nestjs/common';
import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import { InspectionActUrgency } from '../database/entities';
import { UpdateInspectionDeadlinePolicyDto } from './dto/update-inspection-deadline-policy.dto';
import { UpsertInspectionNonWorkingDayDto } from './dto/upsert-inspection-non-working-day.dto';
import { InspectionDeadlinesService } from './inspection-deadlines.service';
@Controller('inspection-deadlines')
export class InspectionDeadlinesController {
constructor(private readonly deadlines: InspectionDeadlinesService) {}
@Get('policies')
@RequirePermissions('inspection_deadlines.manage')
policies() {
return this.deadlines.listPolicies();
}
@Post('policies/:urgency')
@RequirePermissions('inspection_deadlines.manage')
updatePolicy(
@Param('urgency', new ParseEnumPipe(InspectionActUrgency)) urgency: InspectionActUrgency,
@Body() dto: UpdateInspectionDeadlinePolicyDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.deadlines.updatePolicy(urgency, dto, principal, request);
}
@Get('non-working-days')
@RequirePermissions('inspection_deadlines.manage')
nonWorkingDays(@Query('year') year?: string) {
return this.deadlines.listNonWorkingDays(year);
}
@Post('non-working-days')
@RequirePermissions('inspection_deadlines.manage')
upsertNonWorkingDay(
@Body() dto: UpsertInspectionNonWorkingDayDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.deadlines.upsertNonWorkingDay(dto, principal, request);
}
}
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { AuditModule } from '../audit/audit.module';
import { InspectionDeadlinesController } from './inspection-deadlines.controller';
import { InspectionDeadlinesService } from './inspection-deadlines.service';
@Module({
imports: [AuditModule],
controllers: [InspectionDeadlinesController],
providers: [InspectionDeadlinesService],
exports: [InspectionDeadlinesService],
})
export class InspectionDeadlinesModule {}
@@ -0,0 +1,302 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
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 {
InspectionActUrgency,
InspectionDeadlineBasis,
InspectionDeadlineDayType,
InspectionDeadlinePolicy,
InspectionNonWorkingDay,
} from '../database/entities';
import type { UpdateInspectionDeadlinePolicyDto } from './dto/update-inspection-deadline-policy.dto';
import type { UpsertInspectionNonWorkingDayDto } from './dto/upsert-inspection-non-working-day.dto';
export interface InspectionDeadlineSnapshot {
urgency: InspectionActUrgency;
days: number;
dayType: InspectionDeadlineDayType;
basis: InspectionDeadlineBasis;
capturedAt: string;
}
export interface InspectionDeadlineLockResult {
snapshot: InspectionDeadlineSnapshot;
baseOn: string | null;
dueOn: string | null;
}
@Injectable()
export class InspectionDeadlinesService {
constructor(
private readonly dataSource: DataSource,
private readonly audit: AuditService,
) {}
async listPolicies() {
const data = await this.dataSource.getRepository(InspectionDeadlinePolicy).find({
order: { urgency: 'ASC' },
});
return { data };
}
async updatePolicy(
urgency: InspectionActUrgency,
dto: UpdateInspectionDeadlinePolicyDto,
principal: AuthPrincipal,
request: RequestWithContext,
) {
return this.dataSource.transaction(async (manager) => {
const repository = manager.getRepository(InspectionDeadlinePolicy);
const policy = await repository.findOne({ where: { urgency } });
if (!policy) {
throw new NotFoundException({
code: 'INSPECTION_DEADLINE_POLICY_NOT_FOUND',
message: 'No se encontró la política de plazo solicitada',
});
}
const before = {
urgency: policy.urgency,
days: policy.days,
dayType: policy.dayType,
basis: policy.basis,
};
policy.days = dto.days;
policy.dayType = dto.dayType;
policy.updatedBy = principal.userId;
await repository.save(policy);
const after = {
urgency: policy.urgency,
days: policy.days,
dayType: policy.dayType,
basis: policy.basis,
};
await this.audit.record({
...administrationAuditContext(principal, request),
action: 'INSPECTION_DEADLINE_POLICY_UPDATED',
entityType: 'inspection_deadline_policy',
entityId: urgency,
beforeData: before,
afterData: after,
}, manager);
return policy;
});
}
async snapshotForLock(
manager: EntityManager,
urgency: InspectionActUrgency,
occurredAt: Date,
): Promise<InspectionDeadlineLockResult> {
const [policy] = (await manager.query(`
SELECT
urgency,
days,
day_type AS "dayType",
basis
FROM inspection_deadline_policies
WHERE urgency = $1
FOR SHARE
`, [urgency])) as Array<{
urgency: InspectionActUrgency;
days: number;
dayType: InspectionDeadlineDayType;
basis: InspectionDeadlineBasis;
}>;
if (!policy) {
throw new NotFoundException({
code: 'INSPECTION_DEADLINE_POLICY_NOT_FOUND',
message: 'No existe una política de plazo para la urgencia seleccionada',
});
}
const snapshot: InspectionDeadlineSnapshot = {
urgency: policy.urgency,
days: Number(policy.days),
dayType: policy.dayType,
basis: policy.basis,
capturedAt: new Date().toISOString(),
};
if (policy.basis === InspectionDeadlineBasis.GEDO_LOAD_DATE) {
return { snapshot, baseOn: null, dueOn: null };
}
const [base] = (await manager.query(`
SELECT TO_CHAR(
$1::timestamptz AT TIME ZONE 'America/Argentina/Mendoza',
'YYYY-MM-DD'
) AS day
`, [occurredAt])) as Array<{ day: string }>;
if (!base?.day) {
throw new BadRequestException({
code: 'INSPECTION_ACT_DATE_INVALID',
message: 'No se pudo determinar la fecha del Acta para calcular el plazo',
});
}
const dueOn = await this.calculateDueOn(
manager,
base.day,
snapshot.days,
snapshot.dayType,
);
return { snapshot, baseOn: base.day, dueOn };
}
async calculateDueOn(
manager: EntityManager,
baseOn: string,
days: number,
dayType: InspectionDeadlineDayType,
): Promise<string> {
if (!Number.isInteger(days) || days < 1 || days > 365) {
throw new BadRequestException({
code: 'INSPECTION_DEADLINE_DAYS_INVALID',
message: 'La cantidad de días del plazo no es válida',
});
}
if (dayType === InspectionDeadlineDayType.CALENDAR) {
const [row] = (await manager.query(`
SELECT ($1::date + $2::integer)::text AS "dueOn"
`, [baseOn, days])) as Array<{ dueOn: string }>;
return row.dueOn;
}
const [row] = (await manager.query(`
SELECT candidate.day::text AS "dueOn"
FROM (
SELECT generated::date AS day
FROM generate_series(
$1::date + 1,
$1::date + (($2::integer * 3) + 31),
interval '1 day'
) generated
WHERE EXTRACT(ISODOW FROM generated) BETWEEN 1 AND 5
AND NOT EXISTS (
SELECT 1
FROM inspection_non_working_days holiday
WHERE holiday.day = generated::date
AND holiday.enabled = true
)
ORDER BY generated
LIMIT 1 OFFSET ($2::integer - 1)
) candidate
`, [baseOn, days])) as Array<{ dueOn: string }>;
if (!row?.dueOn) {
throw new BadRequestException({
code: 'INSPECTION_DEADLINE_CALCULATION_FAILED',
message: 'No se pudo calcular el vencimiento con el calendario configurado',
});
}
return row.dueOn;
}
async applyGedoOfficialization(
manager: EntityManager,
actId: string,
officializedOn: string,
): Promise<string | null> {
const [act] = (await manager.query(`
SELECT
deadline_days AS "days",
deadline_day_type AS "dayType",
deadline_basis AS "basis"
FROM inspection_acts
WHERE id = $1
FOR UPDATE
`, [actId])) as Array<{
days: number | null;
dayType: InspectionDeadlineDayType | null;
basis: InspectionDeadlineBasis | null;
}>;
if (!act || act.basis !== InspectionDeadlineBasis.GEDO_LOAD_DATE) return null;
if (!act.days || !act.dayType) {
throw new BadRequestException({
code: 'INSPECTION_ACT_DEADLINE_SNAPSHOT_MISSING',
message: 'El Acta no conserva la política de plazo requerida',
});
}
const dueOn = await this.calculateDueOn(manager, officializedOn, Number(act.days), act.dayType);
await manager.query(`
UPDATE inspection_acts
SET deadline_base_on = $2::date,
deadline_due_on = $3::date,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1
`, [actId, officializedOn, dueOn]);
return dueOn;
}
async listNonWorkingDays(year?: string) {
const parsedYear = year === undefined ? null : Number(year);
if (parsedYear !== null && (!Number.isInteger(parsedYear) || parsedYear < 2000 || parsedYear > 2200)) {
throw new BadRequestException({
code: 'INVALID_NON_WORKING_DAY_YEAR',
message: 'El año del calendario no es válido',
});
}
const parameters: unknown[] = [];
const where = parsedYear === null
? ''
: `WHERE EXTRACT(YEAR FROM day)::integer = $1`;
if (parsedYear !== null) parameters.push(parsedYear);
const data = await this.dataSource.query(`
SELECT
day::text AS day,
label,
enabled,
created_at AS "createdAt",
updated_at AS "updatedAt"
FROM inspection_non_working_days
${where}
ORDER BY day ASC
`, parameters);
return { data };
}
async upsertNonWorkingDay(
dto: UpsertInspectionNonWorkingDayDto,
principal: AuthPrincipal,
request: RequestWithContext,
) {
const parsed = new Date(`${dto.day}T00:00:00Z`);
if (!Number.isFinite(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== dto.day) {
throw new BadRequestException({
code: 'INVALID_NON_WORKING_DAY',
message: 'La fecha no laborable no es válida',
});
}
return this.dataSource.transaction(async (manager) => {
const repository = manager.getRepository(InspectionNonWorkingDay);
const existing = await repository.findOne({ where: { day: dto.day } });
const before = existing
? { day: existing.day, label: existing.label, enabled: existing.enabled }
: null;
const row = existing ?? repository.create({
day: dto.day,
label: dto.label,
enabled: dto.enabled ?? true,
createdBy: principal.userId,
updatedBy: principal.userId,
});
row.label = dto.label;
row.enabled = dto.enabled ?? true;
row.updatedBy = principal.userId;
await repository.save(row);
const after = { day: row.day, label: row.label, enabled: row.enabled };
await this.audit.record({
...administrationAuditContext(principal, request),
action: existing
? 'INSPECTION_NON_WORKING_DAY_UPDATED'
: 'INSPECTION_NON_WORKING_DAY_CREATED',
entityType: 'inspection_non_working_day',
entityId: row.day,
beforeData: before,
afterData: after,
}, manager);
return row;
});
}
}
@@ -0,0 +1,38 @@
import { Transform, Type } from 'class-transformer';
import {
IsInt,
IsOptional,
IsString,
IsUUID,
Matches,
Max,
MaxLength,
Min,
MinLength,
} from 'class-validator';
const optionalText = ({ value }: { value: unknown }) =>
typeof value === 'string' && value.trim() ? value.trim() : null;
export class CreateRecurrentInspectionFindingDto {
@IsUUID('4')
antecedentFindingId!: string;
@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,31 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, 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 { CreateRecurrentInspectionFindingDto } from './dto/create-recurrent-inspection-finding.dto';
import { InspectionFindingRecurrenceService } from './inspection-finding-recurrence.service';
@Controller('inspection-acts/:actId/findings')
export class InspectionFindingRecurrenceController {
constructor(private readonly recurrence: InspectionFindingRecurrenceService) {}
@Get('recurrence-candidates')
@RequirePermissions('inspection_findings.read')
candidates(
@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string,
@Query('assetId', new ParseUUIDPipe({ version: '4' })) assetId: string,
) {
return this.recurrence.candidates(actId, assetId);
}
@Post('recurrence')
@RequirePermissions('inspection_findings.create')
create(
@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string,
@Body() dto: CreateRecurrentInspectionFindingDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.recurrence.create(actId, dto, principal, request);
}
}
@@ -0,0 +1,301 @@
import {
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { DataSource } from 'typeorm';
import { administrationAuditContext } from '../administration/common/administration-audit';
import { AuditService } from '../audit/audit.service';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import { AuditAction } from '../database/entities';
import { assertMobileInspector } from '../inspection-operations/mobile-inspector-policy';
import type { CreateRecurrentInspectionFindingDto } from './dto/create-recurrent-inspection-finding.dto';
@Injectable()
export class InspectionFindingRecurrenceService {
constructor(
private readonly dataSource: DataSource,
private readonly audit: AuditService,
) {}
async candidates(actId: string, assetId: string) {
await this.requireDraftActAsset(actId, assetId);
const data = await this.dataSource.query(`
SELECT
finding.id,
finding.code,
finding.title,
finding.description,
finding.severity,
finding.catalog_item_id AS "catalogItemId",
finding.is_recurrence AS "isRecurrence",
finding.antecedent_finding_id AS "antecedentFindingId",
finding.created_at AS "createdAt",
act.id AS "actId",
act.code AS "actCode",
act.occurred_at AS "actOccurredAt",
visit.id AS "visitId",
visit.code AS "visitCode"
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.asset_id = $1
AND finding.status = 'OPEN'
AND finding.act_id <> $2
ORDER BY act.occurred_at DESC, finding.created_at DESC, finding.id DESC
`, [assetId, actId]);
return { data };
}
async create(
actId: string,
dto: CreateRecurrentInspectionFindingDto,
principal: AuthPrincipal,
request: RequestWithContext,
) {
assertMobileInspector(principal);
return this.dataSource.transaction(async (manager) => {
const [act] = await manager.query(`
SELECT
act.id,
act.code,
act.status,
act.occurred_at AS "occurredAt",
act.visit_id AS "visitId",
visit.status AS "visitStatus"
FROM inspection_acts act
INNER JOIN inspection_visits visit ON visit.id = act.visit_id
WHERE act.id = $1
FOR UPDATE OF act, visit
`, [actId]) as Array<{
id: string;
code: string;
status: string;
occurredAt: Date;
visitId: string;
visitStatus: string;
}>;
if (!act) throw this.actNotFound();
if (act.status !== 'DRAFT' || act.visitStatus !== 'IN_PROGRESS') {
throw new ConflictException({
code: 'INSPECTION_FINDING_ACT_NOT_EDITABLE',
message: 'La reincidencia sólo puede registrarse mientras el Acta está en borrador y la inspección en curso',
});
}
if (!principal.permissions.includes('inspections.manage')) {
const [member] = await manager.query(`
SELECT 1 AS found
FROM inspection_visit_members
WHERE visit_id = $1
AND user_id = $2
AND included = true
LIMIT 1
`, [act.visitId, principal.userId]) as Array<{ found: number }>;
if (!member) {
throw new ForbiddenException({
code: 'INSPECTION_VISIT_NOT_ASSIGNED',
message: 'La inspección no está asignada al usuario actual',
});
}
}
const [antecedent] = await manager.query(`
SELECT
finding.id,
finding.act_id AS "actId",
finding.asset_id AS "assetId",
finding.catalog_item_id AS "catalogItemId",
finding.title,
finding.legal_basis AS "legalBasis",
finding.glossary,
finding.catalog_revision AS "catalogRevision",
finding.suggested_severity AS "suggestedSeverity",
finding.severity,
finding.status,
previous_act.code AS "actCode",
previous_act.occurred_at AS "occurredAt"
FROM inspection_findings finding
INNER JOIN inspection_acts previous_act ON previous_act.id = finding.act_id
WHERE finding.id = $1
FOR SHARE OF finding, previous_act
`, [dto.antecedentFindingId]) as Array<{
id: string;
actId: string;
assetId: string;
catalogItemId: string | null;
title: string;
legalBasis: string | null;
glossary: string | null;
catalogRevision: number | null;
suggestedSeverity: number | null;
severity: number | null;
status: string;
actCode: string;
occurredAt: Date;
}>;
if (!antecedent) {
throw new NotFoundException({
code: 'INSPECTION_FINDING_ANTECEDENT_NOT_FOUND',
message: 'No se encontró el Hallazgo antecedente seleccionado',
});
}
if (antecedent.status !== 'OPEN') {
throw new ConflictException({
code: 'INSPECTION_FINDING_ANTECEDENT_RESOLVED',
message: 'Sólo un Hallazgo anterior todavía abierto puede originar una reincidencia',
});
}
if (antecedent.actId === actId) {
throw new ConflictException({
code: 'INSPECTION_FINDING_RECURRENCE_SAME_ACT',
message: 'Una reincidencia debe referenciar un Hallazgo de un Acta anterior',
});
}
if (new Date(antecedent.occurredAt).getTime() > new Date(act.occurredAt).getTime()) {
throw new ConflictException({
code: 'INSPECTION_FINDING_ANTECEDENT_AFTER_ACT',
message: 'El antecedente no puede pertenecer a un Acta posterior a la actual',
});
}
const [assetLink] = await manager.query(`
SELECT 1 AS found
FROM inspection_act_assets
WHERE act_id = $1 AND asset_id = $2 AND included = true
LIMIT 1
`, [actId, antecedent.assetId]) as Array<{ found: number }>;
if (!assetLink) {
throw new ConflictException({
code: 'INSPECTION_FINDING_RECURRENCE_ASSET_NOT_IN_ACT',
message: 'El Inventario del Hallazgo antecedente no forma parte del Acta actual',
});
}
const [sequence] = await manager.query(`
SELECT COALESCE(MAX(finding_number), 0)::integer + 1 AS number
FROM inspection_findings
WHERE act_id = $1
`, [actId]) as Array<{ number: number }>;
const findingNumber = Number(sequence?.number ?? 1);
if (findingNumber > 999) {
throw new ConflictException({
code: 'INSPECTION_FINDING_SEQUENCE_EXHAUSTED',
message: 'El Acta alcanzó el máximo de 999 Hallazgos',
});
}
const code = `${act.code}-H${String(findingNumber).padStart(3, '0')}`;
const [created] = await manager.query(`
INSERT INTO inspection_findings (
act_id,
asset_id,
catalog_item_id,
finding_number,
code,
status,
title,
description,
legal_basis,
glossary,
catalog_revision,
suggested_severity,
severity,
is_recurrence,
antecedent_finding_id,
correction_due_on,
current_version,
created_by,
updated_by
) VALUES (
$1,$2,$3,$4,$5,'OPEN',$6,$7,$8,$9,$10,$11,$12,true,$13,$14,1,$15,$15
)
RETURNING id, created_at AS "createdAt", updated_at AS "updatedAt"
`, [
actId,
antecedent.assetId,
antecedent.catalogItemId,
findingNumber,
code,
antecedent.title,
dto.description,
antecedent.legalBasis,
antecedent.glossary,
antecedent.catalogRevision,
antecedent.suggestedSeverity,
dto.severity ?? antecedent.severity ?? antecedent.suggestedSeverity,
antecedent.id,
dto.correctionDueOn ?? null,
principal.userId,
]) as Array<{ id: string; createdAt: Date; updatedAt: Date }>;
const snapshot = {
id: created.id,
actId,
assetId: antecedent.assetId,
catalogItemId: antecedent.catalogItemId,
findingNumber,
code,
status: 'OPEN',
title: antecedent.title,
description: dto.description,
legalBasis: antecedent.legalBasis,
glossary: antecedent.glossary,
catalogRevision: antecedent.catalogRevision,
suggestedSeverity: antecedent.suggestedSeverity,
severity: dto.severity ?? antecedent.severity ?? antecedent.suggestedSeverity,
isRecurrence: true,
antecedentFindingId: antecedent.id,
antecedentCode: `${antecedent.actCode}`,
correctionDueOn: dto.correctionDueOn ?? null,
currentVersion: 1,
createdAt: created.createdAt,
updatedAt: created.updatedAt,
};
await manager.query(`
INSERT INTO inspection_finding_versions (
finding_id, version_number, event, snapshot, actor_user_id, actor_username
) VALUES ($1, 1, 'CREATED', $2::jsonb, $3, $4)
`, [created.id, snapshot, principal.userId, principal.username]);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.INSPECTION_FINDING_CREATED,
entityType: 'inspection_finding',
entityId: created.id,
afterData: snapshot,
metadata: {
actId,
visitId: act.visitId,
recurrence: true,
antecedentFindingId: antecedent.id,
},
}, manager);
return snapshot;
});
}
private async requireDraftActAsset(actId: string, assetId: string): Promise<void> {
const [row] = await this.dataSource.query(`
SELECT 1 AS found
FROM inspection_acts act
INNER JOIN inspection_visits visit ON visit.id = act.visit_id
INNER JOIN inspection_act_assets link
ON link.act_id = act.id AND link.asset_id = $2 AND link.included = true
WHERE act.id = $1
AND act.status = 'DRAFT'
AND visit.status = 'IN_PROGRESS'
LIMIT 1
`, [actId, assetId]) as Array<{ found: number }>;
if (!row) {
throw new ConflictException({
code: 'INSPECTION_FINDING_RECURRENCE_CONTEXT_INVALID',
message: 'El Inventario debe estar incluido en un Acta en borrador de una inspección en curso',
});
}
}
private actNotFound(): NotFoundException {
return new NotFoundException({
code: 'INSPECTION_ACT_NOT_FOUND',
message: 'Acta de inspección no encontrada',
});
}
}
@@ -15,7 +15,6 @@ import type { AuthPrincipal, RequestWithContext } from '../common/http/request-c
import { CloseInspectionFindingDto } from './dto/close-inspection-finding.dto'; import { CloseInspectionFindingDto } from './dto/close-inspection-finding.dto';
import { CreateInspectionFindingDto } from './dto/create-inspection-finding.dto'; import { CreateInspectionFindingDto } from './dto/create-inspection-finding.dto';
import { ListInspectionFindingsQueryDto } from './dto/list-inspection-findings-query.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 { UpdateInspectionFindingDto } from './dto/update-inspection-finding.dto';
import { InspectionFindingsService } from './inspection-findings.service'; import { InspectionFindingsService } from './inspection-findings.service';
@@ -84,15 +83,4 @@ export class InspectionFindingsController {
) { ) {
return this.findings.close(id, dto, principal, request); 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);
}
} }
@@ -4,6 +4,8 @@ import { FindingCatalogController } from './finding-catalog.controller';
import { FindingCatalogMergeService } from './finding-catalog-merge.service'; import { FindingCatalogMergeService } from './finding-catalog-merge.service';
import { FindingCatalogService } from './finding-catalog.service'; import { FindingCatalogService } from './finding-catalog.service';
import { F3FindingCatalogResolverService } from './f3-finding-catalog-resolver.service'; import { F3FindingCatalogResolverService } from './f3-finding-catalog-resolver.service';
import { InspectionFindingRecurrenceController } from './inspection-finding-recurrence.controller';
import { InspectionFindingRecurrenceService } from './inspection-finding-recurrence.service';
import { import {
InspectionActFindingsController, InspectionActFindingsController,
InspectionFindingsController, InspectionFindingsController,
@@ -21,6 +23,7 @@ import { InspectionEvidenceService } from './inspection-evidence.service';
controllers: [ controllers: [
FindingCatalogController, FindingCatalogController,
InspectionActFindingsController, InspectionActFindingsController,
InspectionFindingRecurrenceController,
InspectionFindingsController, InspectionFindingsController,
InspectionFindingEvidenceController, InspectionFindingEvidenceController,
InspectionFindingCommunicationsController, InspectionFindingCommunicationsController,
@@ -31,6 +34,7 @@ import { InspectionEvidenceService } from './inspection-evidence.service';
F3FindingCatalogResolverService, F3FindingCatalogResolverService,
FindingCatalogMergeService, FindingCatalogMergeService,
InspectionFindingsService, InspectionFindingsService,
InspectionFindingRecurrenceService,
InspectionEvidenceService, InspectionEvidenceService,
], ],
exports: [ exports: [
@@ -38,6 +42,7 @@ import { InspectionEvidenceService } from './inspection-evidence.service';
F3FindingCatalogResolverService, F3FindingCatalogResolverService,
FindingCatalogMergeService, FindingCatalogMergeService,
InspectionFindingsService, InspectionFindingsService,
InspectionFindingRecurrenceService,
], ],
}) })
export class InspectionFindingsModule {} export class InspectionFindingsModule {}
@@ -0,0 +1,23 @@
import { Transform } from 'class-transformer';
import { IsEnum, IsOptional, IsString, Matches, MaxLength } from 'class-validator';
import { InspectionReportFollowUpType } from '../../database/entities';
export class CreateInspectionReportFollowUpDto {
@IsEnum(InspectionReportFollowUpType)
eventType!: InspectionReportFollowUpType;
@IsOptional()
@Transform(({ value }) => (typeof value === 'string' && value.trim() ? value.trim() : null))
@IsString()
@MaxLength(255)
referenceNumber?: string | null;
@Matches(/^\d{4}-\d{2}-\d{2}$/)
occurredOn!: string;
@IsOptional()
@Transform(({ value }) => (typeof value === 'string' && value.trim() ? value.trim() : null))
@IsString()
@MaxLength(20000)
description?: string | null;
}
@@ -0,0 +1,13 @@
import { Transform } from 'class-transformer';
import { IsString, Matches, MaxLength, MinLength } from 'class-validator';
export class OfficializeInspectionReportGedoDto {
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString()
@MinLength(3)
@MaxLength(255)
ifIdentifier!: string;
@Matches(/^\d{4}-\d{2}-\d{2}$/)
officializedOn!: string;
}
@@ -1,2 +1,12 @@
import { Transform } from 'class-transformer'; import { IsEmail,IsOptional,MaxLength } from 'class-validator'; import { Transform } from 'class-transformer';
export class UpdateDocumentDeliverySettingsDto { @IsOptional() @Transform(({value})=>typeof value==='string'&&value.trim()?value.trim().toLowerCase():null) @IsEmail() @MaxLength(320) officeEmail?:string|null; @IsOptional() @Transform(({value})=>typeof value==='string'&&value.trim()?value.trim().toLowerCase():null) @IsEmail() @MaxLength(320) directorEmail?:string|null; } import { IsEmail, IsOptional, MaxLength } from 'class-validator';
export class UpdateDocumentDeliverySettingsDto {
@IsOptional()
@Transform(({ value }) =>
typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : null,
)
@IsEmail()
@MaxLength(320)
officeEmail?: string | null;
}
@@ -0,0 +1,58 @@
import { Transform } from 'class-transformer';
import { IsOptional, IsString, MaxLength } from 'class-validator';
function optionalText(value: unknown): unknown {
if (typeof value !== 'string') return value;
const trimmed = value.trim();
return trimmed.length ? trimmed : null;
}
export class UpdateInspectionReportContentDto {
@IsOptional()
@Transform(({ value }) => optionalText(value))
@IsString()
@MaxLength(10000)
referenceText?: string | null;
@IsOptional()
@Transform(({ value }) => optionalText(value))
@IsString()
@MaxLength(20000)
generalObjective?: string | null;
@IsOptional()
@Transform(({ value }) => optionalText(value))
@IsString()
@MaxLength(20000)
specificObjective?: string | null;
@IsOptional()
@Transform(({ value }) => optionalText(value))
@IsString()
@MaxLength(40000)
background?: string | null;
@IsOptional()
@Transform(({ value }) => optionalText(value))
@IsString()
@MaxLength(40000)
legalFramework?: string | null;
@IsOptional()
@Transform(({ value }) => optionalText(value))
@IsString()
@MaxLength(40000)
executiveSummary?: string | null;
@IsOptional()
@Transform(({ value }) => optionalText(value))
@IsString()
@MaxLength(80000)
description?: string | null;
@IsOptional()
@Transform(({ value }) => optionalText(value))
@IsString()
@MaxLength(40000)
conclusion?: string | null;
}
@@ -0,0 +1,79 @@
import { Transform } from 'class-transformer';
import {
IsBoolean,
IsEmail,
IsEnum,
IsInt,
IsOptional,
IsString,
Max,
MaxLength,
Min,
MinLength,
} from 'class-validator';
export enum SmtpSecurityModeDto {
TLS = 'TLS',
STARTTLS = 'STARTTLS',
}
export class UpdateSmtpSettingsDto {
@Transform(({ value }) => value === true || value === 'true')
@IsBoolean()
enabled!: boolean;
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString()
@MinLength(1)
@MaxLength(255)
host!: string;
@Transform(({ value }) => Number(value))
@IsInt()
@Min(1)
@Max(65535)
port!: number;
@IsEnum(SmtpSecurityModeDto)
securityMode!: SmtpSecurityModeDto;
@IsOptional()
@Transform(({ value }) => (typeof value === 'string' && value.trim() ? value.trim() : null))
@IsString()
@MaxLength(255)
username?: string | null;
@IsOptional()
@IsString()
@MaxLength(2048)
password?: string;
@IsOptional()
@Transform(({ value }) => value === true || value === 'true')
@IsBoolean()
clearPassword?: boolean;
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString()
@MinLength(1)
@MaxLength(160)
fromName!: string;
@Transform(({ value }) => (typeof value === 'string' ? value.trim().toLowerCase() : value))
@IsEmail()
@MaxLength(255)
fromEmail!: string;
@IsOptional()
@Transform(({ value }) => (typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : null))
@IsEmail()
@MaxLength(255)
replyTo?: string | null;
}
export class TestSmtpSettingsDto {
@Transform(({ value }) => (typeof value === 'string' ? value.trim().toLowerCase() : value))
@IsEmail()
@MaxLength(255)
to!: string;
}
@@ -1,5 +1,4 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable, NotFoundException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import { administrationAuditContext } from '../administration/common/administration-audit'; import { administrationAuditContext } from '../administration/common/administration-audit';
import { AuditService } from '../audit/audit.service'; import { AuditService } from '../audit/audit.service';
@@ -10,7 +9,7 @@ import { InspectionActPdfService } from './inspection-act-pdf.service';
import { InspectionReportWordService } from './inspection-report-word.service'; import { InspectionReportWordService } from './inspection-report-word.service';
import { SmtpDeliveryService } from './smtp-delivery.service'; import { SmtpDeliveryService } from './smtp-delivery.service';
export type DeliveryRecipientKind = 'COMPANY' | 'OFFICE' | 'DIRECTOR' | 'INSPECTOR'; export type DeliveryRecipientKind = 'COMPANY' | 'OFFICE' | 'INSPECTOR' | 'DIRECTOR';
export interface DeliveryRow { export interface DeliveryRow {
id: string; id: string;
@@ -35,26 +34,18 @@ export class InspectionDocumentDeliveryService {
private readonly word: InspectionReportWordService, private readonly word: InspectionReportWordService,
private readonly smtp: SmtpDeliveryService, private readonly smtp: SmtpDeliveryService,
private readonly audit: AuditService, private readonly audit: AuditService,
private readonly config: ConfigService,
) {} ) {}
async settings() { async settings() {
const [row] = await this.dataSource.query(` const [row] = await this.dataSource.query(`
SELECT SELECT office_email AS "officeEmail", updated_at AS "updatedAt"
office_email AS "officeEmail",
director_email AS "directorEmail",
updated_at AS "updatedAt"
FROM institutional_delivery_settings FROM institutional_delivery_settings
WHERE id=1 WHERE id = 1
`) as Array<{ `) as Array<{ officeEmail: string | null; updatedAt: Date }>;
officeEmail: string | null;
directorEmail: string | null;
updatedAt: Date;
}>;
return { return {
...row, ...row,
smtpConfigured: this.smtp.configured(), smtpConfigured: this.smtp.configured(),
mailFrom: this.config.get<string>('MAIL_FROM') ?? null, smtpSource: this.smtp.activeSource(),
}; };
} }
@@ -66,9 +57,11 @@ export class InspectionDocumentDeliveryService {
const before = await this.settings(); const before = await this.settings();
await this.dataSource.query(` await this.dataSource.query(`
UPDATE institutional_delivery_settings UPDATE institutional_delivery_settings
SET office_email=$1,director_email=$2,updated_by=$3,updated_at=CURRENT_TIMESTAMP SET office_email = $1,
WHERE id=1 updated_by = $2,
`, [dto.officeEmail ?? null, dto.directorEmail ?? null, principal.userId]); updated_at = CURRENT_TIMESTAMP
WHERE id = 1
`, [dto.officeEmail ?? null, principal.userId]);
const after = await this.settings(); const after = await this.settings();
await this.audit.record({ await this.audit.record({
...administrationAuditContext(principal, request), ...administrationAuditContext(principal, request),
@@ -84,34 +77,34 @@ export class InspectionDocumentDeliveryService {
async list() { async list() {
const data = await this.dataSource.query(` const data = await this.dataSource.query(`
SELECT SELECT
d.id, delivery.id,
d.act_id AS "actId", delivery.act_id AS "actId",
d.report_id AS "reportId", delivery.report_id AS "reportId",
d.document_kind AS "documentKind", delivery.document_kind AS "documentKind",
d.recipient_kind AS "recipientKind", delivery.recipient_kind AS "recipientKind",
d.recipient_asset_id AS "recipientAssetId", delivery.recipient_asset_id AS "recipientAssetId",
d.recipient_user_id AS "recipientUserId", delivery.recipient_user_id AS "recipientUserId",
d.recipient_email AS "recipientEmail", delivery.recipient_email AS "recipientEmail",
d.status, delivery.status,
d.attempts, delivery.attempts,
d.last_attempt_at AS "lastAttemptAt", delivery.last_attempt_at AS "lastAttemptAt",
d.sent_at AS "sentAt", delivery.sent_at AS "sentAt",
d.provider_message_id AS "providerMessageId", delivery.provider_message_id AS "providerMessageId",
d.last_error AS "lastError", delivery.last_error AS "lastError",
d.created_at AS "createdAt", delivery.created_at AS "createdAt",
a.code AS "actCode", act.code AS "actCode",
r.code AS "reportCode", report.code AS "reportCode",
recipient.name AS "recipientAssetName", recipient.name AS "recipientAssetName",
CASE CASE
WHEN recipient_user.id IS NULL THEN NULL WHEN recipient_user.id IS NULL THEN NULL
ELSE btrim(concat_ws(' ', recipient_user.first_name, recipient_user.last_name)) ELSE btrim(concat_ws(' ', recipient_user.first_name, recipient_user.last_name))
END AS "recipientUserName" END AS "recipientUserName"
FROM inspection_document_deliveries d FROM inspection_document_deliveries delivery
JOIN inspection_acts a ON a.id=d.act_id JOIN inspection_acts act ON act.id = delivery.act_id
LEFT JOIN inspection_reports r ON r.id=d.report_id LEFT JOIN inspection_reports report ON report.id = delivery.report_id
LEFT JOIN assets recipient ON recipient.id=d.recipient_asset_id LEFT JOIN assets recipient ON recipient.id = delivery.recipient_asset_id
LEFT JOIN users recipient_user ON recipient_user.id=d.recipient_user_id LEFT JOIN users recipient_user ON recipient_user.id = delivery.recipient_user_id
ORDER BY d.created_at DESC ORDER BY delivery.created_at DESC
LIMIT 200 LIMIT 200
`); `);
return { data }; return { data };
@@ -120,13 +113,14 @@ export class InspectionDocumentDeliveryService {
async dispatchForAct(actId: string): Promise<void> { async dispatchForAct(actId: string): Promise<void> {
await this.pdf.ensure(actId).catch(() => undefined); await this.pdf.ensure(actId).catch(() => undefined);
const [report] = await this.dataSource.query( const [report] = await this.dataSource.query(
`SELECT id FROM inspection_reports WHERE act_id=$1`, `SELECT id FROM inspection_reports WHERE act_id = $1`,
[actId], [actId],
) as Array<{ id: string }>; ) as Array<{ id: string }>;
if (report) await this.word.ensure(report.id); if (report) await this.word.ensure(report.id);
await this.ensureRows(actId, report?.id ?? null); await this.ensureRows(actId, report?.id ?? null);
const rows = await this.rowsForAct(actId); for (const row of await this.rowsForAct(actId)) {
for (const row of rows) await this.attempt(row).catch(() => undefined); await this.attempt(row).catch(() => undefined);
}
} }
async retry( async retry(
@@ -156,42 +150,42 @@ export class InspectionDocumentDeliveryService {
const rows = await this.dataSource.query(` const rows = await this.dataSource.query(`
SELECT id SELECT id
FROM inspection_document_deliveries FROM inspection_document_deliveries
WHERE status<>'SENT' WHERE status <> 'SENT'
ORDER BY created_at ASC ORDER BY created_at ASC
LIMIT 100 LIMIT 100
`) as Array<{ id: string }>; `) as Array<{ id: string }>;
for (const item of rows) await this.retry(item.id, principal, request).catch(() => undefined); for (const item of rows) {
await this.retry(item.id, principal, request).catch(() => undefined);
}
return { processed: rows.length }; return { processed: rows.length };
} }
private async ensureRows(actId: string, reportId: string | null) { private async ensureRows(actId: string, reportId: string | null) {
const [settings] = await this.dataSource.query(` const [settings] = await this.dataSource.query(`
SELECT office_email AS "officeEmail",director_email AS "directorEmail" SELECT office_email AS "officeEmail"
FROM institutional_delivery_settings FROM institutional_delivery_settings
WHERE id=1 WHERE id = 1
`) as Array<{ officeEmail: string | null; directorEmail: string | null }>; `) as Array<{ officeEmail: string | null }>;
const companies = await this.dataSource.query(` const companies = await this.dataSource.query(`
SELECT DISTINCT company.id,profile.notification_email AS email SELECT DISTINCT company.id, profile.notification_email AS email
FROM inspection_act_assets link FROM inspection_act_assets link
JOIN assets asset ON asset.id=link.asset_id JOIN assets asset ON asset.id = link.asset_id
JOIN asset_types asset_type ON asset_type.id=asset.asset_type_id JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id
JOIN assets company ON company.id=COALESCE( JOIN assets company ON company.id = COALESCE(
asset.operator_company_id, asset.operator_company_id,
CASE WHEN asset_type.operational_role='COMPANY' THEN asset.id END CASE WHEN asset_type.operational_role = 'COMPANY' THEN asset.id END
) )
LEFT JOIN organization_profiles profile ON profile.asset_id=company.id LEFT JOIN organization_profiles profile ON profile.asset_id = company.id
WHERE link.act_id=$1 AND link.included=true WHERE link.act_id = $1 AND link.included = true
`, [actId]) as Array<{ id: string; email: string | null }>; `, [actId]) as Array<{ id: string; email: string | null }>;
const [inspector] = await this.dataSource.query(` const [inspector] = await this.dataSource.query(`
SELECT SELECT inspector.id, inspector.email
inspector.id,
inspector.email
FROM inspection_acts act FROM inspection_acts act
JOIN inspection_visits visit ON visit.id=act.visit_id JOIN inspection_visits visit ON visit.id = act.visit_id
JOIN users inspector ON inspector.id=visit.lead_inspector_user_id JOIN users inspector ON inspector.id = visit.lead_inspector_user_id
WHERE act.id=$1 WHERE act.id = $1
`, [actId]) as Array<{ id: string; email: string | null }>; `, [actId]) as Array<{ id: string; email: string | null }>;
for (const company of companies) { for (const company of companies) {
@@ -229,27 +223,26 @@ export class InspectionDocumentDeliveryService {
recipientKey: inspector.id, recipientKey: inspector.id,
recipientEmail: inspector.email, recipientEmail: inspector.email,
}); });
}
if (reportId) { if (reportId) {
await this.upsertRow({ await this.upsertRow({
actId, actId,
reportId, reportId,
documentKind: 'REPORT_WORD', documentKind: 'REPORT_WORD',
recipientKind: 'DIRECTOR', recipientKind: 'INSPECTOR',
recipientAssetId: null, recipientAssetId: null,
recipientUserId: null, recipientUserId: inspector.id,
recipientKey: '00000000-0000-0000-0000-000000000000', recipientKey: inspector.id,
recipientEmail: settings?.directorEmail ?? null, recipientEmail: inspector.email,
}); });
} }
} }
}
private async upsertRow(input: { private async upsertRow(input: {
actId: string; actId: string;
reportId: string | null; reportId: string | null;
documentKind: 'ACT_PDF' | 'REPORT_WORD'; documentKind: 'ACT_PDF' | 'REPORT_WORD';
recipientKind: DeliveryRecipientKind; recipientKind: Exclude<DeliveryRecipientKind, 'DIRECTOR'>;
recipientAssetId: string | null; recipientAssetId: string | null;
recipientUserId: string | null; recipientUserId: string | null;
recipientKey: string; recipientKey: string;
@@ -258,23 +251,23 @@ export class InspectionDocumentDeliveryService {
const initialStatus = input.recipientEmail ? 'PENDING' : 'WAITING_RECIPIENT'; const initialStatus = input.recipientEmail ? 'PENDING' : 'WAITING_RECIPIENT';
await this.dataSource.query(` await this.dataSource.query(`
INSERT INTO inspection_document_deliveries ( INSERT INTO inspection_document_deliveries (
act_id,report_id,document_kind,recipient_kind, act_id, report_id, document_kind, recipient_kind,
recipient_asset_id,recipient_user_id,recipient_key,recipient_email,status recipient_asset_id, recipient_user_id, recipient_key, recipient_email, status
) VALUES ($1,$2,$3,$4,$5,$6,$7::uuid,$8,$9) ) VALUES ($1,$2,$3,$4,$5,$6,$7::uuid,$8,$9)
ON CONFLICT (act_id,document_kind,recipient_kind,recipient_key) DO UPDATE SET ON CONFLICT (act_id, document_kind, recipient_kind, recipient_key) DO UPDATE SET
report_id=COALESCE(EXCLUDED.report_id,inspection_document_deliveries.report_id), report_id = COALESCE(EXCLUDED.report_id, inspection_document_deliveries.report_id),
recipient_asset_id=COALESCE(EXCLUDED.recipient_asset_id,inspection_document_deliveries.recipient_asset_id), recipient_asset_id = COALESCE(EXCLUDED.recipient_asset_id, inspection_document_deliveries.recipient_asset_id),
recipient_user_id=COALESCE(EXCLUDED.recipient_user_id,inspection_document_deliveries.recipient_user_id), recipient_user_id = COALESCE(EXCLUDED.recipient_user_id, inspection_document_deliveries.recipient_user_id),
recipient_email=CASE recipient_email = CASE
WHEN inspection_document_deliveries.status='SENT' THEN inspection_document_deliveries.recipient_email WHEN inspection_document_deliveries.status = 'SENT' THEN inspection_document_deliveries.recipient_email
ELSE EXCLUDED.recipient_email ELSE EXCLUDED.recipient_email
END, END,
status=CASE status = CASE
WHEN inspection_document_deliveries.status='SENT' THEN 'SENT' WHEN inspection_document_deliveries.status = 'SENT' THEN 'SENT'
WHEN EXCLUDED.recipient_email IS NULL THEN 'WAITING_RECIPIENT' WHEN EXCLUDED.recipient_email IS NULL THEN 'WAITING_RECIPIENT'
ELSE inspection_document_deliveries.status ELSE inspection_document_deliveries.status
END, END,
updated_at=CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
`, [ `, [
input.actId, input.actId,
input.reportId, input.reportId,
@@ -291,31 +284,45 @@ export class InspectionDocumentDeliveryService {
private async rowsForAct(actId: string): Promise<DeliveryRow[]> { private async rowsForAct(actId: string): Promise<DeliveryRow[]> {
return this.dataSource.query(` return this.dataSource.query(`
SELECT SELECT
d.id,d.act_id AS "actId",d.report_id AS "reportId", delivery.id,
d.document_kind AS "documentKind",d.recipient_kind AS "recipientKind", delivery.act_id AS "actId",
d.recipient_asset_id AS "recipientAssetId",d.recipient_user_id AS "recipientUserId", delivery.report_id AS "reportId",
d.recipient_email AS "recipientEmail",d.status,d.attempts, delivery.document_kind AS "documentKind",
a.code AS "actCode",r.code AS "reportCode" delivery.recipient_kind AS "recipientKind",
FROM inspection_document_deliveries d delivery.recipient_asset_id AS "recipientAssetId",
JOIN inspection_acts a ON a.id=d.act_id delivery.recipient_user_id AS "recipientUserId",
LEFT JOIN inspection_reports r ON r.id=d.report_id delivery.recipient_email AS "recipientEmail",
WHERE d.act_id=$1 delivery.status,
ORDER BY d.created_at delivery.attempts,
act.code AS "actCode",
report.code AS "reportCode"
FROM inspection_document_deliveries delivery
JOIN inspection_acts act ON act.id = delivery.act_id
LEFT JOIN inspection_reports report ON report.id = delivery.report_id
WHERE delivery.act_id = $1
ORDER BY delivery.created_at
`, [actId]) as Promise<DeliveryRow[]>; `, [actId]) as Promise<DeliveryRow[]>;
} }
private async load(id: string): Promise<DeliveryRow> { private async load(id: string): Promise<DeliveryRow> {
const [row] = await this.dataSource.query(` const [row] = await this.dataSource.query(`
SELECT SELECT
d.id,d.act_id AS "actId",d.report_id AS "reportId", delivery.id,
d.document_kind AS "documentKind",d.recipient_kind AS "recipientKind", delivery.act_id AS "actId",
d.recipient_asset_id AS "recipientAssetId",d.recipient_user_id AS "recipientUserId", delivery.report_id AS "reportId",
d.recipient_email AS "recipientEmail",d.status,d.attempts, delivery.document_kind AS "documentKind",
a.code AS "actCode",r.code AS "reportCode" delivery.recipient_kind AS "recipientKind",
FROM inspection_document_deliveries d delivery.recipient_asset_id AS "recipientAssetId",
JOIN inspection_acts a ON a.id=d.act_id delivery.recipient_user_id AS "recipientUserId",
LEFT JOIN inspection_reports r ON r.id=d.report_id delivery.recipient_email AS "recipientEmail",
WHERE d.id=$1 delivery.status,
delivery.attempts,
act.code AS "actCode",
report.code AS "reportCode"
FROM inspection_document_deliveries delivery
JOIN inspection_acts act ON act.id = delivery.act_id
LEFT JOIN inspection_reports report ON report.id = delivery.report_id
WHERE delivery.id = $1
`, [id]) as DeliveryRow[]; `, [id]) as DeliveryRow[];
if (!row) { if (!row) {
throw new NotFoundException({ throw new NotFoundException({
@@ -332,34 +339,41 @@ export class InspectionDocumentDeliveryService {
const [company] = await this.dataSource.query(` const [company] = await this.dataSource.query(`
SELECT notification_email AS email SELECT notification_email AS email
FROM organization_profiles FROM organization_profiles
WHERE asset_id=$1 WHERE asset_id = $1
`, [row.recipientAssetId]) as Array<{ email: string | null }>; `, [row.recipientAssetId]) as Array<{ email: string | null }>;
email = company?.email ?? null; email = company?.email ?? null;
} else if (row.recipientKind === 'INSPECTOR' && row.recipientUserId) { } else if (row.recipientKind === 'INSPECTOR' && row.recipientUserId) {
const [inspector] = await this.dataSource.query(` const [inspector] = await this.dataSource.query(`
SELECT email SELECT email
FROM users FROM users
WHERE id=$1 AND is_active=true WHERE id = $1 AND is_active = true
`, [row.recipientUserId]) as Array<{ email: string | null }>; `, [row.recipientUserId]) as Array<{ email: string | null }>;
email = inspector?.email ?? null; email = inspector?.email ?? null;
} else { } else if (row.recipientKind === 'OFFICE') {
const [settings] = await this.dataSource.query(` const [settings] = await this.dataSource.query(`
SELECT office_email AS "officeEmail",director_email AS "directorEmail" SELECT office_email AS "officeEmail"
FROM institutional_delivery_settings FROM institutional_delivery_settings
WHERE id=1 WHERE id = 1
`) as Array<{ officeEmail: string | null; directorEmail: string | null }>; `) as Array<{ officeEmail: string | null }>;
email = row.recipientKind === 'OFFICE' email = settings?.officeEmail ?? null;
? settings?.officeEmail ?? null } else if (row.recipientKind === 'DIRECTOR' && row.documentKind === 'REPORT_WORD') {
: settings?.directorEmail ?? null; const [inspector] = await this.dataSource.query(`
SELECT user_account.email
FROM inspection_acts act
JOIN inspection_visits visit ON visit.id = act.visit_id
JOIN users user_account ON user_account.id = visit.lead_inspector_user_id
WHERE act.id = $1
`, [row.actId]) as Array<{ email: string | null }>;
email = inspector?.email ?? null;
} }
await this.dataSource.query(` await this.dataSource.query(`
UPDATE inspection_document_deliveries UPDATE inspection_document_deliveries
SET recipient_email=$2, SET recipient_email = $2,
status=CASE WHEN $2::text IS NULL THEN 'WAITING_RECIPIENT' ELSE 'PENDING' END, status = CASE WHEN $2::text IS NULL THEN 'WAITING_RECIPIENT' ELSE 'PENDING' END,
last_error=NULL, last_error = NULL,
updated_at=CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
WHERE id=$1 AND status<>'SENT' WHERE id = $1 AND status <> 'SENT'
`, [row.id, email]); `, [row.id, email]);
} }
@@ -406,9 +420,12 @@ export class InspectionDocumentDeliveryService {
await this.dataSource.query(` await this.dataSource.query(`
UPDATE inspection_document_deliveries UPDATE inspection_document_deliveries
SET attempts=attempts+1,last_attempt_at=CURRENT_TIMESTAMP,status='PENDING', SET attempts = attempts + 1,
last_error=NULL,updated_at=CURRENT_TIMESTAMP last_attempt_at = CURRENT_TIMESTAMP,
WHERE id=$1 status = 'PENDING',
last_error = NULL,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1
`, [row.id]); `, [row.id]);
try { try {
@@ -416,10 +433,10 @@ export class InspectionDocumentDeliveryService {
? `Acta ${row.actCode}` ? `Acta ${row.actCode}`
: `Informe ${row.reportCode ?? ''}`; : `Informe ${row.reportCode ?? ''}`;
const text = row.documentKind === 'REPORT_WORD' const text = row.documentKind === 'REPORT_WORD'
? `Se adjunta el informe Word automático ${row.reportCode ?? ''} para revisión del Director de Hidrocarburos.` ? `Se adjunta el Informe Word automático ${row.reportCode ?? ''} para revisión y edición del inspector responsable antes de su carga en GEDO.`
: row.recipientKind === 'INSPECTOR' : row.recipientKind === 'INSPECTOR'
? `Se adjunta copia del acta cerrada e inmutable ${row.actCode} correspondiente a tu inspección.` ? `Se adjunta copia del Acta ${row.actCode} correspondiente a tu inspección.`
: `Se adjunta el acta cerrada e inmutable ${row.actCode}.`; : `Se adjunta el Acta ${row.actCode}.`;
const sent = await this.smtp.send({ const sent = await this.smtp.send({
to: row.recipientEmail, to: row.recipientEmail,
subject: `DH Inspección · ${label}`, subject: `DH Inspección · ${label}`,
@@ -428,9 +445,12 @@ export class InspectionDocumentDeliveryService {
}); });
await this.dataSource.query(` await this.dataSource.query(`
UPDATE inspection_document_deliveries UPDATE inspection_document_deliveries
SET status='SENT',sent_at=CURRENT_TIMESTAMP,provider_message_id=$2, SET status = 'SENT',
last_error=NULL,updated_at=CURRENT_TIMESTAMP sent_at = CURRENT_TIMESTAMP,
WHERE id=$1 provider_message_id = $2,
last_error = NULL,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1
`, [row.id, sent.messageId]); `, [row.id, sent.messageId]);
await this.audit.record({ await this.audit.record({
action: AuditAction.DOCUMENT_DELIVERY_SENT, action: AuditAction.DOCUMENT_DELIVERY_SENT,
@@ -463,8 +483,10 @@ export class InspectionDocumentDeliveryService {
private async setStatus(id: string, status: string, error: string) { private async setStatus(id: string, status: string, error: string) {
await this.dataSource.query(` await this.dataSource.query(`
UPDATE inspection_document_deliveries UPDATE inspection_document_deliveries
SET status=$2,last_error=$3,updated_at=CURRENT_TIMESTAMP SET status = $2,
WHERE id=$1 last_error = $3,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1
`, [id, status, error.slice(0, 500)]); `, [id, status, error.slice(0, 500)]);
} }
} }
@@ -0,0 +1,50 @@
import { BadRequestException } from '@nestjs/common';
export const MAX_REPORT_DOSSIER_PDF_BYTES = 25 * 1024 * 1024;
export interface UploadedReportDossierFile {
buffer: Buffer;
originalname: string;
mimetype?: string;
size: number;
}
export interface InspectedReportDossierPdf {
originalName: string;
mimeType: 'application/pdf';
extension: '.pdf';
}
export function inspectReportDossierPdf(
file: UploadedReportDossierFile | undefined,
): InspectedReportDossierPdf {
if (!file?.buffer?.length || file.size <= 0) {
throw new BadRequestException({
code: 'INSPECTION_REPORT_PDF_REQUIRED',
message: 'Debés adjuntar un archivo PDF no vacío',
});
}
if (file.size > MAX_REPORT_DOSSIER_PDF_BYTES || file.buffer.length > MAX_REPORT_DOSSIER_PDF_BYTES) {
throw new BadRequestException({
code: 'INSPECTION_REPORT_PDF_TOO_LARGE',
message: 'El PDF supera el máximo permitido de 25 MB',
});
}
if (file.buffer.length < 5 || file.buffer.subarray(0, 5).toString('ascii') !== '%PDF-') {
throw new BadRequestException({
code: 'INSPECTION_REPORT_PDF_INVALID',
message: 'El archivo adjunto no tiene una estructura PDF válida',
});
}
const originalName = file.originalname
.replace(/[\u0000-\u001f\u007f]/g, '')
.trim()
.slice(0, 255);
if (!originalName) {
throw new BadRequestException({
code: 'INSPECTION_REPORT_PDF_INVALID_NAME',
message: 'El nombre original del PDF no es válido',
});
}
return { originalName, mimeType: 'application/pdf', extension: '.pdf' };
}
@@ -0,0 +1,128 @@
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Patch,
Post,
Req,
Res,
UploadedFile,
UploadedFiles,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor, FilesInterceptor } 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 { CreateInspectionReportFollowUpDto } from './dto/create-inspection-report-follow-up.dto';
import { OfficializeInspectionReportGedoDto } from './dto/officialize-inspection-report-gedo.dto';
import { UpdateInspectionReportContentDto } from './dto/update-inspection-report-content.dto';
import {
MAX_REPORT_DOSSIER_PDF_BYTES,
type UploadedReportDossierFile,
} from './inspection-report-dossier-file';
import { InspectionReportDossierService } from './inspection-report-dossier.service';
@Controller('inspection-reports/:reportId/dossier')
export class InspectionReportDossierController {
constructor(private readonly dossier: InspectionReportDossierService) {}
@Get()
@RequirePermissions('inspection_reports.read')
get(@Param('reportId', new ParseUUIDPipe({ version: '4' })) reportId: string) {
return this.dossier.get(reportId);
}
@Patch('content')
@RequirePermissions('inspection_reports.edit')
updateContent(
@Param('reportId', new ParseUUIDPipe({ version: '4' })) reportId: string,
@Body() dto: UpdateInspectionReportContentDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.dossier.updateContent(reportId, dto, principal, request);
}
@Post('gedo')
@RequirePermissions('inspection_reports.officialize')
@UseInterceptors(FileInterceptor('file', {
limits: { fileSize: MAX_REPORT_DOSSIER_PDF_BYTES, files: 1 },
}))
officializeGedo(
@Param('reportId', new ParseUUIDPipe({ version: '4' })) reportId: string,
@Body() dto: OfficializeInspectionReportGedoDto,
@UploadedFile() file: UploadedReportDossierFile | undefined,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.dossier.officializeGedo(reportId, dto, file, principal, request);
}
@Get('gedo/pdf')
@RequirePermissions('inspection_reports.read')
async gedoPdf(
@Param('reportId', new ParseUUIDPipe({ version: '4' })) reportId: string,
@Res() response: Response,
): Promise<void> {
const content = await this.dossier.gedoPdfContent(reportId);
response.setHeader('Content-Type', 'application/pdf');
response.setHeader('Content-Disposition', `attachment; filename="${content.originalName.replaceAll('"', '')}"`);
response.setHeader('Cache-Control', 'private, no-store');
response.setHeader('X-Content-Type-Options', 'nosniff');
await new Promise<void>((resolveSend, rejectSend) => {
response.sendFile(content.filePath, (error) => {
if (error) rejectSend(error);
else resolveSend();
});
});
}
@Get('follow-ups')
@RequirePermissions('inspection_reports.read')
followUps(@Param('reportId', new ParseUUIDPipe({ version: '4' })) reportId: string) {
return this.dossier.listFollowUps(reportId);
}
@Post('follow-ups')
@RequirePermissions('inspection_reports.follow_up')
@UseInterceptors(FilesInterceptor('files', 10, {
limits: { fileSize: MAX_REPORT_DOSSIER_PDF_BYTES, files: 10 },
}))
createFollowUp(
@Param('reportId', new ParseUUIDPipe({ version: '4' })) reportId: string,
@Body() dto: CreateInspectionReportFollowUpDto,
@UploadedFiles() files: UploadedReportDossierFile[] | undefined,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.dossier.createFollowUp(reportId, dto, files ?? [], principal, request);
}
}
@Controller('inspection-report-follow-up-files')
export class InspectionReportFollowUpFileController {
constructor(private readonly dossier: InspectionReportDossierService) {}
@Get(':fileId/content')
@RequirePermissions('inspection_reports.read')
async content(
@Param('fileId', new ParseUUIDPipe({ version: '4' })) fileId: string,
@Res() response: Response,
): Promise<void> {
const content = await this.dossier.followUpFileContent(fileId);
response.setHeader('Content-Type', 'application/pdf');
response.setHeader('Content-Disposition', `attachment; filename="${content.originalName.replaceAll('"', '')}"`);
response.setHeader('Cache-Control', 'private, no-store');
response.setHeader('X-Content-Type-Options', 'nosniff');
await new Promise<void>((resolveSend, rejectSend) => {
response.sendFile(content.filePath, (error) => {
if (error) rejectSend(error);
else resolveSend();
});
});
}
}
@@ -0,0 +1,516 @@
import { createHash, randomUUID } from 'node:crypto';
import { mkdir, readFile, 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 {
InspectionReport,
InspectionReportFollowUp,
InspectionReportFollowUpFile,
InspectionReportFollowUpType,
} from '../database/entities';
import { InspectionDeadlinesService } from '../inspection-deadlines/inspection-deadlines.service';
import type { CreateInspectionReportFollowUpDto } from './dto/create-inspection-report-follow-up.dto';
import type { OfficializeInspectionReportGedoDto } from './dto/officialize-inspection-report-gedo.dto';
import type { UpdateInspectionReportContentDto } from './dto/update-inspection-report-content.dto';
import {
inspectReportDossierPdf,
type UploadedReportDossierFile,
} from './inspection-report-dossier-file';
interface ReportContext {
id: string;
code: string;
actId: string;
visitId: string;
actCode: string;
visitCode: string;
generatedBy: string;
gedoIfIdentifier: string | null;
gedoOfficializedOn: string | null;
gedoPdfStoredName: string | null;
gedoPdfSizeBytes: number | null;
gedoPdfSha256: string | null;
}
@Injectable()
export class InspectionReportDossierService {
private readonly root: string;
constructor(
private readonly dataSource: DataSource,
private readonly audit: AuditService,
private readonly deadlines: InspectionDeadlinesService,
config: ConfigService,
) {
const configured = config.get<string>('INSPECTION_REPORT_DOSSIER_ROOT')
?? '/app/storage/asset-media/inspection-report-dossier';
if (!isAbsolute(configured)) {
throw new Error('INSPECTION_REPORT_DOSSIER_ROOT must be an absolute path');
}
this.root = resolve(configured);
if (this.root === parse(this.root).root) {
throw new Error('INSPECTION_REPORT_DOSSIER_ROOT cannot be the filesystem root');
}
}
async get(reportId: string) {
await this.requireReport(this.dataSource.manager, reportId);
const [report] = await this.dataSource.query(`
SELECT
report.id,
report.code,
report.reference_text AS "referenceText",
report.general_objective AS "generalObjective",
report.specific_objective AS "specificObjective",
report.background,
report.legal_framework AS "legalFramework",
report.executive_summary AS "executiveSummary",
report.description,
report.conclusion,
report.gedo_if_identifier AS "gedoIfIdentifier",
report.gedo_officialized_on::text AS "gedoOfficializedOn",
report.gedo_pdf_original_name AS "gedoPdfOriginalName",
report.gedo_pdf_mime_type AS "gedoPdfMimeType",
report.gedo_pdf_size_bytes AS "gedoPdfSizeBytes",
report.gedo_pdf_sha256 AS "gedoPdfSha256",
report.gedo_pdf_uploaded_at AS "gedoPdfUploadedAt",
act.id AS "actId",
act.code AS "actCode",
act.urgency,
act.deadline_days AS "deadlineDays",
act.deadline_day_type AS "deadlineDayType",
act.deadline_basis AS "deadlineBasis",
act.deadline_base_on::text AS "deadlineBaseOn",
act.deadline_due_on::text AS "deadlineDueOn",
act.status AS "actStatus",
visit.id AS "visitId",
visit.code AS "visitCode"
FROM inspection_reports report
INNER JOIN inspection_acts act ON act.id = report.act_id
INNER JOIN inspection_visits visit ON visit.id = report.visit_id
WHERE report.id = $1
`, [reportId]) as Array<Record<string, unknown>>;
const followUps = await this.listFollowUps(reportId);
return { ...report, followUps: followUps.data };
}
async updateContent(
reportId: string,
dto: UpdateInspectionReportContentDto,
principal: AuthPrincipal,
request: RequestWithContext,
) {
return this.dataSource.transaction(async (manager) => {
const context = await this.requireReport(manager, reportId, true);
await this.assertCanWorkReport(manager, context, principal);
const repository = manager.getRepository(InspectionReport);
const report = await repository.findOne({ where: { id: reportId } });
if (!report) throw this.notFound();
const before = this.contentSnapshot(report);
const fields: Array<keyof UpdateInspectionReportContentDto> = [
'referenceText',
'generalObjective',
'specificObjective',
'background',
'legalFramework',
'executiveSummary',
'description',
'conclusion',
];
let changed = false;
for (const field of fields) {
if (dto[field] !== undefined) {
(report as unknown as Record<string, unknown>)[field] = dto[field] ?? null;
changed = true;
}
}
if (!changed) {
throw new BadRequestException({
code: 'INSPECTION_REPORT_CONTENT_EMPTY_UPDATE',
message: 'No se recibió ningún campo del Informe para actualizar',
});
}
await repository.save(report);
const after = this.contentSnapshot(report);
await this.audit.record({
...administrationAuditContext(principal, request),
action: 'INSPECTION_REPORT_CONTENT_UPDATED',
entityType: 'inspection_report',
entityId: reportId,
beforeData: before,
afterData: after,
metadata: { actId: context.actId, visitId: context.visitId },
}, manager);
return after;
});
}
async officializeGedo(
reportId: string,
dto: OfficializeInspectionReportGedoDto,
file: UploadedReportDossierFile | undefined,
principal: AuthPrincipal,
request: RequestWithContext,
) {
const inspected = inspectReportDossierPdf(file);
this.assertDate(dto.officializedOn, 'La fecha de carga en GEDO no es válida');
await mkdir(this.root, { recursive: true, mode: 0o700 });
const storedName = `gedo-${reportId}-${randomUUID()}.pdf`;
const filePath = this.safePath(storedName);
const sha256 = createHash('sha256').update(file!.buffer).digest('hex');
await writeFile(filePath, file!.buffer, { mode: 0o600 });
try {
return await this.dataSource.transaction(async (manager) => {
const context = await this.requireReport(manager, reportId, true);
await this.assertCanWorkReport(manager, context, principal);
if (context.gedoIfIdentifier || context.gedoOfficializedOn || context.gedoPdfStoredName) {
throw new ConflictException({
code: 'INSPECTION_REPORT_ALREADY_OFFICIALIZED',
message: 'Este Informe ya tiene un IF oficial de GEDO y no puede sobrescribirse',
});
}
await manager.query(`
UPDATE inspection_reports
SET gedo_if_identifier = $2,
gedo_officialized_on = $3::date,
gedo_pdf_original_name = $4,
gedo_pdf_stored_name = $5,
gedo_pdf_mime_type = 'application/pdf',
gedo_pdf_size_bytes = $6,
gedo_pdf_sha256 = $7,
gedo_pdf_uploaded_at = CURRENT_TIMESTAMP,
gedo_pdf_uploaded_by = $8,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1
`, [
reportId,
dto.ifIdentifier,
dto.officializedOn,
inspected.originalName,
storedName,
file!.buffer.length,
sha256,
principal.userId,
]);
const deadlineDueOn = await this.deadlines.applyGedoOfficialization(
manager,
context.actId,
dto.officializedOn,
);
const after = {
gedoIfIdentifier: dto.ifIdentifier,
gedoOfficializedOn: dto.officializedOn,
gedoPdfOriginalName: inspected.originalName,
gedoPdfSizeBytes: file!.buffer.length,
gedoPdfSha256: sha256,
deadlineDueOn,
};
await this.audit.record({
...administrationAuditContext(principal, request),
action: 'INSPECTION_REPORT_GEDO_OFFICIALIZED',
entityType: 'inspection_report',
entityId: reportId,
afterData: after,
metadata: {
actId: context.actId,
visitId: context.visitId,
officialLegalDocument: true,
},
}, manager);
return after;
});
} catch (error) {
await unlink(filePath).catch(() => undefined);
throw error;
}
}
async gedoPdfContent(reportId: string) {
const context = await this.requireReport(this.dataSource.manager, reportId);
if (!context.gedoPdfStoredName || !context.gedoPdfSizeBytes || !context.gedoPdfSha256) {
throw new NotFoundException({
code: 'INSPECTION_REPORT_GEDO_PDF_NOT_FOUND',
message: 'El Informe todavía no tiene un PDF oficial de GEDO',
});
}
const filePath = this.safePath(context.gedoPdfStoredName);
await this.verifyStoredFile(filePath, context.gedoPdfSizeBytes, context.gedoPdfSha256);
return { filePath, originalName: `${context.gedoIfIdentifier ?? context.code}.pdf` };
}
async listFollowUps(reportId: string) {
await this.requireReport(this.dataSource.manager, reportId);
const data = await this.dataSource.query(`
SELECT
follow_up.id,
follow_up.event_type AS "eventType",
follow_up.reference_number AS "referenceNumber",
follow_up.occurred_on::text AS "occurredOn",
follow_up.description,
follow_up.created_by AS "createdBy",
follow_up.created_at AS "createdAt",
COALESCE((
SELECT JSONB_AGG(JSONB_BUILD_OBJECT(
'id', file.id,
'originalName', file.original_name,
'mimeType', file.mime_type,
'sizeBytes', file.size_bytes,
'sha256', file.sha256,
'createdAt', file.created_at
) ORDER BY file.created_at, file.id)
FROM inspection_report_follow_up_files file
WHERE file.follow_up_id = follow_up.id
), '[]'::jsonb) AS files
FROM inspection_report_follow_ups follow_up
WHERE follow_up.report_id = $1
ORDER BY follow_up.occurred_on ASC, follow_up.created_at ASC, follow_up.id ASC
`, [reportId]);
return { data };
}
async createFollowUp(
reportId: string,
dto: CreateInspectionReportFollowUpDto,
files: UploadedReportDossierFile[],
principal: AuthPrincipal,
request: RequestWithContext,
) {
this.assertDate(dto.occurredOn, 'La fecha del seguimiento no es válida');
if (files.length > 10) {
throw new BadRequestException({
code: 'INSPECTION_REPORT_FOLLOW_UP_TOO_MANY_FILES',
message: 'Se permiten hasta 10 archivos PDF por registro de seguimiento',
});
}
if (dto.eventType === InspectionReportFollowUpType.COMPANY_NOTE && files.length < 1) {
throw new BadRequestException({
code: 'INSPECTION_REPORT_COMPANY_NOTE_FILE_REQUIRED',
message: 'La respuesta formal de la empresa debe conservar al menos el PDF de la Nota recibida',
});
}
const inspectedFiles = files.map((file) => ({ file, inspected: inspectReportDossierPdf(file) }));
const followUpId = randomUUID();
const stored: Array<{
id: string;
storedName: string;
filePath: string;
originalName: string;
sizeBytes: number;
sha256: string;
}> = [];
await mkdir(this.root, { recursive: true, mode: 0o700 });
try {
for (const item of inspectedFiles) {
const id = randomUUID();
const storedName = `follow-up-${followUpId}-${id}.pdf`;
const filePath = this.safePath(storedName);
const sha256 = createHash('sha256').update(item.file.buffer).digest('hex');
await writeFile(filePath, item.file.buffer, { mode: 0o600 });
stored.push({
id,
storedName,
filePath,
originalName: item.inspected.originalName,
sizeBytes: item.file.buffer.length,
sha256,
});
}
await this.dataSource.transaction(async (manager) => {
const context = await this.requireReport(manager, reportId, true);
await this.assertCanWorkReport(manager, context, principal);
await manager.getRepository(InspectionReportFollowUp).insert({
id: followUpId,
reportId,
eventType: dto.eventType,
referenceNumber: dto.referenceNumber ?? null,
occurredOn: dto.occurredOn,
description: dto.description ?? null,
createdBy: principal.userId,
});
for (const file of stored) {
await manager.getRepository(InspectionReportFollowUpFile).insert({
id: file.id,
followUpId,
originalName: file.originalName,
storedName: file.storedName,
mimeType: 'application/pdf',
sizeBytes: file.sizeBytes,
sha256: file.sha256,
createdBy: principal.userId,
});
}
await this.audit.record({
...administrationAuditContext(principal, request),
action: 'INSPECTION_REPORT_FOLLOW_UP_CREATED',
entityType: 'inspection_report_follow_up',
entityId: followUpId,
afterData: {
reportId,
eventType: dto.eventType,
referenceNumber: dto.referenceNumber ?? null,
occurredOn: dto.occurredOn,
description: dto.description ?? null,
files: stored.map((file) => ({
id: file.id,
originalName: file.originalName,
sizeBytes: file.sizeBytes,
sha256: file.sha256,
})),
},
metadata: {
actId: context.actId,
visitId: context.visitId,
appendOnly: true,
},
}, manager);
});
return this.listFollowUps(reportId);
} catch (error) {
await Promise.all(stored.map((file) => unlink(file.filePath).catch(() => undefined)));
throw error;
}
}
async followUpFileContent(fileId: string) {
const [file] = (await this.dataSource.query(`
SELECT
file.original_name AS "originalName",
file.stored_name AS "storedName",
file.size_bytes AS "sizeBytes",
file.sha256
FROM inspection_report_follow_up_files file
WHERE file.id = $1
`, [fileId])) as Array<{
originalName: string;
storedName: string;
sizeBytes: number;
sha256: string;
}>;
if (!file) {
throw new NotFoundException({
code: 'INSPECTION_REPORT_FOLLOW_UP_FILE_NOT_FOUND',
message: 'Documento de seguimiento no encontrado',
});
}
const filePath = this.safePath(file.storedName);
await this.verifyStoredFile(filePath, Number(file.sizeBytes), file.sha256);
return { filePath, originalName: file.originalName };
}
private async requireReport(
manager: EntityManager,
reportId: string,
lock = false,
): Promise<ReportContext> {
const suffix = lock ? 'FOR UPDATE OF report' : '';
const [row] = (await manager.query(`
SELECT
report.id,
report.code,
report.act_id AS "actId",
report.visit_id AS "visitId",
report.generated_by AS "generatedBy",
report.gedo_if_identifier AS "gedoIfIdentifier",
report.gedo_officialized_on::text AS "gedoOfficializedOn",
report.gedo_pdf_stored_name AS "gedoPdfStoredName",
report.gedo_pdf_size_bytes AS "gedoPdfSizeBytes",
report.gedo_pdf_sha256 AS "gedoPdfSha256",
act.code AS "actCode",
visit.code AS "visitCode"
FROM inspection_reports report
INNER JOIN inspection_acts act ON act.id = report.act_id
INNER JOIN inspection_visits visit ON visit.id = report.visit_id
WHERE report.id = $1
${suffix}
`, [reportId])) as ReportContext[];
if (!row) throw this.notFound();
return row;
}
private async assertCanWorkReport(
manager: EntityManager,
report: ReportContext,
principal: AuthPrincipal,
): Promise<void> {
if (principal.roles.includes('admin') || principal.roles.includes('supervisor')) return;
if (report.generatedBy === principal.userId) return;
const [member] = (await manager.query(`
SELECT 1 AS found
FROM inspection_visit_members
WHERE visit_id = $1
AND user_id = $2
AND included = true
LIMIT 1
`, [report.visitId, principal.userId])) as Array<{ found: number }>;
if (member) return;
throw new ForbiddenException({
code: 'INSPECTION_REPORT_NOT_ASSIGNED',
message: 'Sólo el inspector asignado a la inspección o su jefatura puede trabajar este Informe',
});
}
private contentSnapshot(report: InspectionReport): Record<string, unknown> {
return {
referenceText: report.referenceText,
generalObjective: report.generalObjective,
specificObjective: report.specificObjective,
background: report.background,
legalFramework: report.legalFramework,
executiveSummary: report.executiveSummary,
description: report.description,
conclusion: report.conclusion,
};
}
private assertDate(value: string, message: string): void {
const parsed = new Date(`${value}T00:00:00Z`);
if (!Number.isFinite(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== value) {
throw new BadRequestException({ code: 'INSPECTION_REPORT_DATE_INVALID', message });
}
}
private safePath(storedName: string): string {
const filePath = resolve(this.root, storedName);
if (!filePath.startsWith(`${this.root}/`)) {
throw new InternalServerErrorException({
code: 'INSPECTION_REPORT_STORAGE_INVALID_PATH',
message: 'Ruta de almacenamiento inválida',
});
}
return filePath;
}
private async verifyStoredFile(filePath: string, expectedSize: number, expectedSha256: string): Promise<void> {
const metadata = await stat(filePath).catch(() => null);
if (!metadata?.isFile() || metadata.size !== expectedSize) throw this.storageError();
const buffer = await readFile(filePath);
const actualSha256 = createHash('sha256').update(buffer).digest('hex');
if (actualSha256 !== expectedSha256) throw this.storageError();
}
private storageError(): InternalServerErrorException {
return new InternalServerErrorException({
code: 'INSPECTION_REPORT_STORAGE_INTEGRITY_ERROR',
message: 'El archivo almacenado no supera la verificación de integridad',
});
}
private notFound(): NotFoundException {
return new NotFoundException({
code: 'INSPECTION_REPORT_NOT_FOUND',
message: 'Informe de inspección no encontrado',
});
}
}
@@ -1,31 +1,39 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AuditModule } from '../audit/audit.module'; import { AuditModule } from '../audit/audit.module';
import { InspectionDeadlinesModule } from '../inspection-deadlines/inspection-deadlines.module';
import { DocumentDeliveryController } from './document-delivery.controller'; import { DocumentDeliveryController } from './document-delivery.controller';
import { InspectionActPdfService } from './inspection-act-pdf.service'; import { InspectionActPdfService } from './inspection-act-pdf.service';
import { InspectionDocumentDeliveryService } from './inspection-document-delivery.service'; import { InspectionDocumentDeliveryService } from './inspection-document-delivery.service';
import { InspectionReportReviewController, InspectionReportRevisionContentController } from './inspection-report-review.controller'; import {
import { InspectionReportReviewService } from './inspection-report-review.service'; InspectionReportDossierController,
InspectionReportFollowUpFileController,
} from './inspection-report-dossier.controller';
import { InspectionReportDossierService } from './inspection-report-dossier.service';
import { InspectionReportWordService } from './inspection-report-word.service'; import { InspectionReportWordService } from './inspection-report-word.service';
import { InspectionActReportController, InspectionReportsController } from './inspection-reports.controller'; import { InspectionActReportController, InspectionReportsController } from './inspection-reports.controller';
import { InspectionReportsService } from './inspection-reports.service'; import { InspectionReportsService } from './inspection-reports.service';
import { SmtpDeliveryService } from './smtp-delivery.service'; import { SmtpDeliveryService } from './smtp-delivery.service';
import { SmtpSettingsController } from './smtp-settings.controller';
import { SmtpSettingsService } from './smtp-settings.service';
@Module({ @Module({
imports: [AuditModule], imports: [AuditModule, InspectionDeadlinesModule],
controllers: [ controllers: [
InspectionReportsController, InspectionReportsController,
InspectionActReportController, InspectionActReportController,
InspectionReportDossierController,
InspectionReportFollowUpFileController,
DocumentDeliveryController, DocumentDeliveryController,
InspectionReportReviewController, SmtpSettingsController,
InspectionReportRevisionContentController,
], ],
providers: [ providers: [
InspectionReportsService, InspectionReportsService,
InspectionReportWordService, InspectionReportWordService,
InspectionReportDossierService,
InspectionActPdfService, InspectionActPdfService,
InspectionDocumentDeliveryService, InspectionDocumentDeliveryService,
SmtpDeliveryService, SmtpDeliveryService,
InspectionReportReviewService, SmtpSettingsService,
], ],
exports: [InspectionReportsService], exports: [InspectionReportsService],
}) })
@@ -1,37 +1,328 @@
import { randomUUID } from 'node:crypto';
import { connect as connectNet, Socket } from 'node:net'; import { connect as connectNet, Socket } from 'node:net';
import { connect as connectTls, TLSSocket } from 'node:tls'; import { connect as connectTls, TLSSocket } from 'node:tls';
import { randomUUID } from 'node:crypto'; import { Injectable, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { Injectable } from '@nestjs/common'; import { DataSource } from 'typeorm';
import { decryptSmtpSecret } from './smtp-settings-crypto';
interface MailAttachment { filename:string; mimeType:string; content:Buffer; } interface MailAttachment {
interface MailInput { to:string; subject:string; text:string; attachment:MailAttachment; } filename: string;
interface Reply { code:number; text:string; } mimeType: string;
content: Buffer;
}
interface MailInput {
to: string;
subject: string;
text: string;
attachment?: MailAttachment;
}
interface Reply {
code: number;
text: string;
}
interface RuntimeSmtpConfig {
source: 'DATABASE' | 'ENV';
host: string;
port: number;
securityMode: 'TLS' | 'STARTTLS';
user: string;
pass: string;
fromHeader: string;
fromEmail: string;
replyTo: string | null;
}
class SmtpSession { class SmtpSession {
private buffer=''; private waiters:Array<(reply:Reply)=>void>=[]; private replyLines:string[]=[]; private buffer = '';
private readonly dataHandler=(chunk:Buffer|string)=>this.onData(typeof chunk==='string'?chunk:chunk.toString('utf8')); private waiters: Array<(reply: Reply) => void> = [];
constructor(private socket:Socket|TLSSocket){ this.attach(socket); } private replyLines: string[] = [];
private attach(socket:Socket|TLSSocket){ socket.setEncoding('utf8'); socket.setTimeout(15000,()=>socket.destroy(new Error('SMTP timeout'))); socket.on('data',this.dataHandler); } private readonly dataHandler = (chunk: Buffer | string) =>
detachForUpgrade(){ this.socket.off('data',this.dataHandler); this.socket.setTimeout(0); return this.socket; } this.onData(typeof chunk === 'string' ? chunk : chunk.toString('utf8'));
replaceSocket(socket:Socket|TLSSocket){ this.socket=socket; this.buffer=''; this.attach(socket); }
private onData(chunk:string){ this.buffer+=chunk; const lines=this.buffer.split(/\r?\n/); this.buffer=lines.pop()??''; for(const line of lines){ if(!line)continue; this.replyLines.push(line); if(/^\d{3} /.test(line)){ const code=Number(line.slice(0,3)); const reply={code,text:this.replyLines.join('\n')}; this.replyLines=[]; const waiter=this.waiters.shift(); if(waiter)waiter(reply); } } } constructor(private socket: Socket | TLSSocket) {
reply():Promise<Reply>{return new Promise((resolve,reject)=>{ const onError=(e:Error)=>{this.socket.off('error',onError);reject(e)}; this.socket.once('error',onError); this.waiters.push((reply)=>{this.socket.off('error',onError);resolve(reply)}); });} this.attach(socket);
async command(command:string, expected:number|number[]){ this.socket.write(`${command}\r\n`); const reply=await this.reply(); const allowed=Array.isArray(expected)?expected:[expected]; if(!allowed.includes(reply.code))throw new Error(`SMTP ${reply.code}: ${reply.text}`); return reply; } }
write(data:string){this.socket.write(data);}
end(){this.socket.end();} private attach(socket: Socket | TLSSocket) {
current(){return this.socket;} socket.setEncoding('utf8');
socket.setTimeout(15000, () => socket.destroy(new Error('SMTP timeout')));
socket.on('data', this.dataHandler);
}
detachForUpgrade() {
this.socket.off('data', this.dataHandler);
this.socket.setTimeout(0);
return this.socket;
}
replaceSocket(socket: Socket | TLSSocket) {
this.socket = socket;
this.buffer = '';
this.attach(socket);
}
private onData(chunk: string) {
this.buffer += chunk;
const lines = this.buffer.split(/\r?\n/);
this.buffer = lines.pop() ?? '';
for (const line of lines) {
if (!line) continue;
this.replyLines.push(line);
if (/^\d{3} /.test(line)) {
const code = Number(line.slice(0, 3));
const reply = { code, text: this.replyLines.join('\n') };
this.replyLines = [];
this.waiters.shift()?.(reply);
}
}
}
reply(): Promise<Reply> {
return new Promise((resolve, reject) => {
const onError = (error: Error) => {
this.socket.off('error', onError);
reject(error);
};
this.socket.once('error', onError);
this.waiters.push((reply) => {
this.socket.off('error', onError);
resolve(reply);
});
});
}
async command(command: string, expected: number | number[]) {
this.socket.write(`${command}\r\n`);
const reply = await this.reply();
const allowed = Array.isArray(expected) ? expected : [expected];
if (!allowed.includes(reply.code)) {
throw new Error(`SMTP ${reply.code}: ${reply.text}`);
}
return reply;
}
write(data: string) {
this.socket.write(data);
}
end() {
this.socket.end();
}
} }
function subject(value:string){return `=?UTF-8?B?${Buffer.from(value,'utf8').toString('base64')}?=`;} function encodedSubject(value: string) {
function envelope(value:string){const m=value.match(/<([^>]+)>/);return (m?.[1]??value).trim();} return `=?UTF-8?B?${Buffer.from(value, 'utf8').toString('base64')}?=`;
function mime(input:MailInput,from:string){ const boundary=`dh-${randomUUID()}`; const body=[`From: ${from}`,`To: ${input.to}`,`Subject: ${subject(input.subject)}`,'MIME-Version: 1.0',`Content-Type: multipart/mixed; boundary="${boundary}"`,'',`--${boundary}`,'Content-Type: text/plain; charset=utf-8','Content-Transfer-Encoding: base64','',Buffer.from(input.text,'utf8').toString('base64'),`--${boundary}`,`Content-Type: ${input.attachment.mimeType}; name="${input.attachment.filename.replaceAll('"','')}"`,'Content-Transfer-Encoding: base64',`Content-Disposition: attachment; filename="${input.attachment.filename.replaceAll('"','')}"`,'',input.attachment.content.toString('base64').replace(/(.{76})/g,'$1\r\n'),`--${boundary}--`,''].join('\r\n'); return body.replace(/^\./gm,'..'); } }
function escapeHeader(value: string) {
return value.replace(/[\r\n]/g, ' ').replaceAll('"', "'");
}
function mime(input: MailInput, config: RuntimeSmtpConfig) {
const headers = [
`From: ${config.fromHeader}`,
`To: ${input.to}`,
`Subject: ${encodedSubject(input.subject)}`,
...(config.replyTo ? [`Reply-To: ${config.replyTo}`] : []),
'MIME-Version: 1.0',
];
if (!input.attachment) {
return [
...headers,
'Content-Type: text/plain; charset=utf-8',
'Content-Transfer-Encoding: base64',
'',
Buffer.from(input.text, 'utf8').toString('base64'),
'',
].join('\r\n').replace(/^\./gm, '..');
}
const boundary = `dh-${randomUUID()}`;
const filename = escapeHeader(input.attachment.filename);
return [
...headers,
`Content-Type: multipart/mixed; boundary="${boundary}"`,
'',
`--${boundary}`,
'Content-Type: text/plain; charset=utf-8',
'Content-Transfer-Encoding: base64',
'',
Buffer.from(input.text, 'utf8').toString('base64'),
`--${boundary}`,
`Content-Type: ${input.attachment.mimeType}; name="${filename}"`,
'Content-Transfer-Encoding: base64',
`Content-Disposition: attachment; filename="${filename}"`,
'',
input.attachment.content.toString('base64').replace(/(.{76})/g, '$1\r\n'),
`--${boundary}--`,
'',
].join('\r\n').replace(/^\./gm, '..');
}
@Injectable() @Injectable()
export class SmtpDeliveryService { export class SmtpDeliveryService implements OnModuleInit {
constructor(private readonly config:ConfigService){} private runtime: RuntimeSmtpConfig | null;
configured(){return Boolean(this.config.get<string>('SMTP_HOST')&&this.config.get<string>('MAIL_FROM'));} private runtimeError: string | null = null;
async send(input:MailInput):Promise<{messageId:string}>{ const host=this.config.get<string>('SMTP_HOST'); const from=this.config.get<string>('MAIL_FROM'); if(!host||!from)throw new Error('SMTP no configurado'); const port=Number(this.config.get<string>('SMTP_PORT')??587); const secure=String(this.config.get<string>('SMTP_SECURE')??'false').toLowerCase()==='true'; const user=this.config.get<string>('SMTP_USER')??''; const pass=this.config.get<string>('SMTP_PASS')??''; const base=await new Promise<Socket|TLSSocket>((resolve,reject)=>{ if(secure){ const tls=connectTls({host,port,servername:host,rejectUnauthorized:true},()=>resolve(tls)); tls.once('error',reject); } else { const raw=connectNet({host,port},()=>resolve(raw)); raw.once('error',reject); } }); const session=new SmtpSession(base); const welcome=await session.reply(); if(welcome.code!==220)throw new Error(`SMTP ${welcome.code}: ${welcome.text}`); let ehlo=await session.command(`EHLO dh-inspeccion`,250); if(!secure){ if(!/STARTTLS/i.test(ehlo.text))throw new Error('El servidor SMTP no ofrece STARTTLS'); await session.command('STARTTLS',220); const rawForTls=session.detachForUpgrade() as Socket; const upgraded=await new Promise<TLSSocket>((resolve,reject)=>{ const tls=connectTls({socket:rawForTls,servername:host,rejectUnauthorized:true},()=>resolve(tls)); tls.once('error',reject); }); session.replaceSocket(upgraded); ehlo=await session.command('EHLO dh-inspeccion',250); }
if(user){ if(/AUTH[^\n]*PLAIN/i.test(ehlo.text)){ const token=Buffer.from(`\u0000${user}\u0000${pass}`,'utf8').toString('base64'); await session.command(`AUTH PLAIN ${token}`,235); } else { await session.command('AUTH LOGIN',334); await session.command(Buffer.from(user).toString('base64'),334); await session.command(Buffer.from(pass).toString('base64'),235); } } constructor(
await session.command(`MAIL FROM:<${envelope(from)}>`,250); await session.command(`RCPT TO:<${input.to}>`,[250,251]); await session.command('DATA',354); session.write(`${mime(input,from)}\r\n.\r\n`); const sent=await session.reply(); if(sent.code!==250)throw new Error(`SMTP ${sent.code}: ${sent.text}`); await session.command('QUIT',221).catch(()=>undefined); session.end(); const match=sent.text.match(/(?:queued as|id=|message-id[=:]?)[\s<]*([^\s>]+)/i); return {messageId:match?.[1]??randomUUID()}; } private readonly dataSource: DataSource,
private readonly config: ConfigService,
) {
this.runtime = this.environmentConfig();
}
async onModuleInit(): Promise<void> {
await this.reload().catch((error) => {
this.runtime = null;
this.runtimeError = error instanceof Error ? error.message : 'SMTP configuration error';
});
}
configured() {
return Boolean(this.runtime);
}
activeSource() {
return this.runtime?.source ?? null;
}
async reload(): Promise<void> {
const [row] = (await this.dataSource.query(`
SELECT
enabled,
host,
port,
security_mode AS "securityMode",
username,
password_encrypted AS "passwordEncrypted",
from_name AS "fromName",
from_email AS "fromEmail",
reply_to AS "replyTo"
FROM smtp_settings
WHERE id = 1
`).catch(() => [])) as Array<{
enabled: boolean;
host: string | null;
port: number | null;
securityMode: 'TLS' | 'STARTTLS' | null;
username: string | null;
passwordEncrypted: string | null;
fromName: string | null;
fromEmail: string | null;
replyTo: string | null;
}>;
if (!row?.enabled) {
this.runtime = this.environmentConfig();
this.runtimeError = null;
return;
}
if (!row.host || !row.port || !row.securityMode || !row.fromName || !row.fromEmail) {
this.runtime = null;
this.runtimeError = 'La configuración SMTP del Superadmin está incompleta';
return;
}
let pass = '';
if (row.passwordEncrypted) {
const key = this.config.get<string>('SMTP_SETTINGS_ENCRYPTION_KEY');
if (!key) {
this.runtime = null;
this.runtimeError = 'Falta SMTP_SETTINGS_ENCRYPTION_KEY para descifrar la contraseña SMTP';
return;
}
pass = decryptSmtpSecret(row.passwordEncrypted, key);
}
this.runtime = {
source: 'DATABASE',
host: row.host,
port: Number(row.port),
securityMode: row.securityMode,
user: row.username ?? '',
pass,
fromHeader: `"${escapeHeader(row.fromName)}" <${row.fromEmail}>`,
fromEmail: row.fromEmail,
replyTo: row.replyTo,
};
this.runtimeError = null;
}
async send(input: MailInput): Promise<{ messageId: string }> {
if (!this.runtime) await this.reload();
const config = this.runtime;
if (!config) throw new Error(this.runtimeError ?? 'SMTP no configurado');
const base = await new Promise<Socket | TLSSocket>((resolve, reject) => {
if (config.securityMode === 'TLS') {
const tls = connectTls({
host: config.host,
port: config.port,
servername: config.host,
rejectUnauthorized: true,
}, () => resolve(tls));
tls.once('error', reject);
} else {
const raw = connectNet({ host: config.host, port: config.port }, () => resolve(raw));
raw.once('error', reject);
}
});
const session = new SmtpSession(base);
const welcome = await session.reply();
if (welcome.code !== 220) throw new Error(`SMTP ${welcome.code}: ${welcome.text}`);
let ehlo = await session.command('EHLO dh-inspeccion', 250);
if (config.securityMode === 'STARTTLS') {
if (!/STARTTLS/i.test(ehlo.text)) throw new Error('El servidor SMTP no ofrece STARTTLS');
await session.command('STARTTLS', 220);
const raw = session.detachForUpgrade() as Socket;
const upgraded = await new Promise<TLSSocket>((resolve, reject) => {
const tls = connectTls({ socket: raw, servername: config.host, rejectUnauthorized: true }, () => resolve(tls));
tls.once('error', reject);
});
session.replaceSocket(upgraded);
ehlo = await session.command('EHLO dh-inspeccion', 250);
}
if (config.user) {
if (/AUTH[^\n]*PLAIN/i.test(ehlo.text)) {
const token = Buffer.from(`\u0000${config.user}\u0000${config.pass}`, 'utf8').toString('base64');
await session.command(`AUTH PLAIN ${token}`, 235);
} else {
await session.command('AUTH LOGIN', 334);
await session.command(Buffer.from(config.user).toString('base64'), 334);
await session.command(Buffer.from(config.pass).toString('base64'), 235);
}
}
await session.command(`MAIL FROM:<${config.fromEmail}>`, 250);
await session.command(`RCPT TO:<${input.to}>`, [250, 251]);
await session.command('DATA', 354);
session.write(`${mime(input, config)}\r\n.\r\n`);
const sent = await session.reply();
if (sent.code !== 250) throw new Error(`SMTP ${sent.code}: ${sent.text}`);
await session.command('QUIT', 221).catch(() => undefined);
session.end();
const match = sent.text.match(/(?:queued as|id=|message-id[=:]?)[\s<]*([^\s>]+)/i);
return { messageId: match?.[1] ?? randomUUID() };
}
private environmentConfig(): RuntimeSmtpConfig | null {
const host = this.config.get<string>('SMTP_HOST');
const from = this.config.get<string>('MAIL_FROM');
if (!host || !from) return null;
const match = from.match(/^(?:\s*"?([^"<]+)"?\s*)?<([^>]+)>\s*$/);
const fromEmail = (match?.[2] ?? from).trim();
return {
source: 'ENV',
host,
port: Number(this.config.get<string>('SMTP_PORT') ?? 587),
securityMode: String(this.config.get<string>('SMTP_SECURE') ?? 'false').toLowerCase() === 'true'
? 'TLS'
: 'STARTTLS',
user: this.config.get<string>('SMTP_USER') ?? '',
pass: this.config.get<string>('SMTP_PASS') ?? '',
fromHeader: from,
fromEmail,
replyTo: null,
};
}
} }
@@ -0,0 +1,38 @@
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto';
function keyFromSecret(secret: string): Buffer {
if (secret.trim().length < 24) {
throw new Error('SMTP_SETTINGS_ENCRYPTION_KEY must contain at least 24 characters');
}
return createHash('sha256').update(secret, 'utf8').digest();
}
export function encryptSmtpSecret(value: string, secret: string): string {
const iv = randomBytes(12);
const cipher = createCipheriv('aes-256-gcm', keyFromSecret(secret), iv);
const encrypted = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
return [
'v1',
iv.toString('base64'),
tag.toString('base64'),
encrypted.toString('base64'),
].join(':');
}
export function decryptSmtpSecret(value: string, secret: string): string {
const [version, ivEncoded, tagEncoded, encryptedEncoded] = value.split(':');
if (version !== 'v1' || !ivEncoded || !tagEncoded || encryptedEncoded === undefined) {
throw new Error('Invalid encrypted SMTP password payload');
}
const decipher = createDecipheriv(
'aes-256-gcm',
keyFromSecret(secret),
Buffer.from(ivEncoded, 'base64'),
);
decipher.setAuthTag(Buffer.from(tagEncoded, 'base64'));
return Buffer.concat([
decipher.update(Buffer.from(encryptedEncoded, 'base64')),
decipher.final(),
]).toString('utf8');
}
@@ -0,0 +1,52 @@
import { Body, Controller, Get, Put, Post, 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 { TestSmtpSettingsDto, UpdateSmtpSettingsDto } from './dto/update-smtp-settings.dto';
import { SmtpDeliveryService } from './smtp-delivery.service';
import { SmtpSettingsService } from './smtp-settings.service';
@Controller('system-mail-settings')
export class SmtpSettingsController {
constructor(
private readonly settings: SmtpSettingsService,
private readonly smtp: SmtpDeliveryService,
) {}
@Get()
@RequirePermissions('system_mail.manage')
async get() {
return {
...(await this.settings.getPublic()),
transportConfigured: this.smtp.configured(),
activeSource: this.smtp.activeSource(),
};
}
@Put()
@RequirePermissions('system_mail.manage')
async update(
@Body() dto: UpdateSmtpSettingsDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
const result = await this.settings.update(dto, principal, request);
await this.smtp.reload();
return {
...result,
transportConfigured: this.smtp.configured(),
activeSource: this.smtp.activeSource(),
};
}
@Post('test')
@RequirePermissions('system_mail.manage')
async test(@Body() dto: TestSmtpSettingsDto) {
const sent = await this.smtp.send({
to: dto.to,
subject: 'DH Inspección · Prueba SMTP',
text: 'Este correo confirma que la salida SMTP configurada en Superadmin funciona correctamente.',
});
return { sent: true, messageId: sent.messageId };
}
}
@@ -0,0 +1,170 @@
import { ConflictException, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { DataSource } from 'typeorm';
import { administrationAuditContext } from '../administration/common/administration-audit';
import { AuditService } from '../audit/audit.service';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import type { UpdateSmtpSettingsDto } from './dto/update-smtp-settings.dto';
import { encryptSmtpSecret } from './smtp-settings-crypto';
interface SmtpSettingsRow {
enabled: boolean;
host: string | null;
port: number | null;
securityMode: string | null;
username: string | null;
passwordEncrypted: string | null;
fromName: string | null;
fromEmail: string | null;
replyTo: string | null;
updatedAt: Date;
}
@Injectable()
export class SmtpSettingsService {
constructor(
private readonly dataSource: DataSource,
private readonly config: ConfigService,
private readonly audit: AuditService,
) {}
async getPublic() {
const row = await this.load();
return this.toPublic(row);
}
async update(
dto: UpdateSmtpSettingsDto,
principal: AuthPrincipal,
request: RequestWithContext,
) {
const encryptionKey = this.config.get<string>('SMTP_SETTINGS_ENCRYPTION_KEY');
if (dto.password !== undefined && !encryptionKey) {
throw new ConflictException({
code: 'SMTP_SETTINGS_ENCRYPTION_KEY_MISSING',
message: 'Falta configurar la clave maestra del servidor para proteger la contraseña SMTP',
});
}
return this.dataSource.transaction(async (manager) => {
const [current] = (await manager.query(`
SELECT
enabled,
host,
port,
security_mode AS "securityMode",
username,
password_encrypted AS "passwordEncrypted",
from_name AS "fromName",
from_email AS "fromEmail",
reply_to AS "replyTo",
updated_at AS "updatedAt"
FROM smtp_settings
WHERE id = 1
FOR UPDATE
`)) as SmtpSettingsRow[];
let passwordEncrypted = current?.passwordEncrypted ?? null;
if (dto.clearPassword) passwordEncrypted = null;
if (dto.password !== undefined) {
passwordEncrypted = encryptSmtpSecret(dto.password, encryptionKey!);
}
await manager.query(`
UPDATE smtp_settings
SET enabled = $1,
host = $2,
port = $3,
security_mode = $4,
username = $5,
password_encrypted = $6,
from_name = $7,
from_email = $8,
reply_to = $9,
updated_by = $10,
updated_at = CURRENT_TIMESTAMP
WHERE id = 1
`, [
dto.enabled,
dto.host,
dto.port,
dto.securityMode,
dto.username ?? null,
passwordEncrypted,
dto.fromName,
dto.fromEmail,
dto.replyTo ?? null,
principal.userId,
]);
const [after] = (await manager.query(`
SELECT
enabled,
host,
port,
security_mode AS "securityMode",
username,
password_encrypted AS "passwordEncrypted",
from_name AS "fromName",
from_email AS "fromEmail",
reply_to AS "replyTo",
updated_at AS "updatedAt"
FROM smtp_settings
WHERE id = 1
`)) as SmtpSettingsRow[];
const beforePublic = current ? this.toPublic(current) : null;
const afterPublic = this.toPublic(after);
await this.audit.record({
...administrationAuditContext(principal, request),
action: 'SMTP_SETTINGS_UPDATED',
entityType: 'smtp_settings',
entityId: '1',
beforeData: beforePublic,
afterData: afterPublic,
metadata: { passwordChanged: dto.password !== undefined || Boolean(dto.clearPassword) },
}, manager);
return afterPublic;
});
}
private async load(): Promise<SmtpSettingsRow> {
const [row] = (await this.dataSource.query(`
SELECT
enabled,
host,
port,
security_mode AS "securityMode",
username,
password_encrypted AS "passwordEncrypted",
from_name AS "fromName",
from_email AS "fromEmail",
reply_to AS "replyTo",
updated_at AS "updatedAt"
FROM smtp_settings
WHERE id = 1
`)) as SmtpSettingsRow[];
return row ?? {
enabled: false,
host: null,
port: null,
securityMode: null,
username: null,
passwordEncrypted: null,
fromName: null,
fromEmail: null,
replyTo: null,
updatedAt: new Date(0),
};
}
private toPublic(row: SmtpSettingsRow): Record<string, unknown> {
return {
enabled: row.enabled,
host: row.host,
port: row.port,
securityMode: row.securityMode,
username: row.username,
passwordConfigured: Boolean(row.passwordEncrypted),
fromName: row.fromName,
fromEmail: row.fromEmail,
replyTo: row.replyTo,
updatedAt: row.updatedAt,
};
}
}
@@ -0,0 +1,186 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { DataSource } from 'typeorm';
import { administrationAuditContext } from '../administration/common/administration-audit';
import { AuditService } from '../audit/audit.service';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import { AuditAction, InspectionVisitStatus } from '../database/entities';
import { assertMobileInspector } from '../inspection-operations/mobile-inspector-policy';
import type { CloseInspectionVisitDto } from './dto/close-inspection-visit.dto';
import { InspectionVisitsService } from './inspection-visits.service';
@Injectable()
export class InspectionVisitClosureService {
constructor(
private readonly dataSource: DataSource,
private readonly audit: AuditService,
private readonly visits: InspectionVisitsService,
) {}
async close(
id: string,
dto: CloseInspectionVisitDto,
principal: AuthPrincipal,
request: RequestWithContext,
) {
assertMobileInspector(principal);
await this.dataSource.transaction(async (manager) => {
const [visit] = await manager.query(`
SELECT
id,
status,
actual_started_at AS "actualStartedAt"
FROM inspection_visits
WHERE id = $1
FOR UPDATE
`, [id]) as Array<{
id: string;
status: InspectionVisitStatus;
actualStartedAt: Date | null;
}>;
if (!visit) {
throw new NotFoundException({
code: 'INSPECTION_VISIT_NOT_FOUND',
message: 'Inspección no encontrada',
});
}
if (visit.status !== InspectionVisitStatus.IN_PROGRESS) {
throw new ConflictException({
code: 'INSPECTION_VISIT_NOT_IN_PROGRESS',
message: 'La Inspección debe estar en curso para cerrarse',
});
}
if (!principal.permissions.includes('inspections.manage')) {
const [member] = await manager.query(`
SELECT 1 AS found
FROM inspection_visit_members
WHERE visit_id = $1
AND user_id = $2
AND included = true
LIMIT 1
`, [id, principal.userId]) as Array<{ found: number }>;
if (!member) {
throw new ForbiddenException({
code: 'INSPECTION_VISIT_NOT_ASSIGNED',
message: 'La Inspección no está asignada al usuario actual',
});
}
}
const [actState] = await manager.query(`
SELECT
COUNT(*) FILTER (WHERE act.status <> 'CANCELLED')::integer AS total,
COUNT(*) FILTER (WHERE act.status = 'DRAFT')::integer AS drafts,
COUNT(*) FILTER (WHERE act.status = 'READY')::integer AS "pendingSignature",
COUNT(*) FILTER (
WHERE act.status NOT IN ('CLOSED', 'CANCELLED')
)::integer AS "notSealed",
COUNT(*) FILTER (
WHERE act.status = 'CLOSED'
AND (
act.closure_sha256 IS NULL
OR NOT EXISTS (
SELECT 1
FROM inspection_act_signatures signature
WHERE signature.act_id = act.id
AND signature.signer_type = 'INSPECTOR'
AND signature.status = 'SIGNED'
)
OR 1 <> (
SELECT COUNT(*)
FROM inspection_act_signatures signature
WHERE signature.act_id = act.id
AND signature.signer_type = 'COMPANY_RESPONSIBLE'
AND signature.status IN ('SIGNED', 'REFUSED', 'ABSENT')
)
)
)::integer AS "sealedInvalid"
FROM inspection_acts act
WHERE act.visit_id = $1
`, [id]) as Array<{
total: number;
drafts: number;
pendingSignature: number;
notSealed: number;
sealedInvalid: number;
}>;
if (Number(actState?.total ?? 0) < 1) {
throw new ConflictException({
code: 'INSPECTION_VISIT_ACT_REQUIRED',
message: 'La Inspección debe contener al menos un Acta antes de cerrarse',
});
}
if (Number(actState?.drafts ?? 0) > 0) {
throw new ConflictException({
code: 'INSPECTION_VISIT_DRAFT_ACTS_PENDING',
message: 'Finalizá o cancelá todas las Actas en borrador antes de cerrar la Inspección',
});
}
if (Number(actState?.pendingSignature ?? 0) > 0) {
throw new ConflictException({
code: 'INSPECTION_VISIT_COMPANY_OUTCOME_PENDING',
message: 'No se puede cerrar la Inspección mientras exista un Acta pendiente de firma, disidencia, negativa o constancia de ausencia de la empresa',
});
}
if (Number(actState?.notSealed ?? 0) > 0 || Number(actState?.sealedInvalid ?? 0) > 0) {
throw new ConflictException({
code: 'INSPECTION_VISIT_ACTS_NOT_SEALED',
message: 'Todas las Actas deben estar selladas con su evidencia de firma antes de cerrar la Inspección',
});
}
const serverClosedAt = new Date();
const clientClosedAt = new Date(dto.clientClosedAt);
if (!Number.isFinite(clientClosedAt.getTime())) {
throw new BadRequestException({
code: 'INSPECTION_VISIT_INVALID_CLOSE_TIME',
message: 'La fecha de cierre informada por el dispositivo no es válida',
});
}
if (visit.actualStartedAt && clientClosedAt.getTime() < new Date(visit.actualStartedAt).getTime()) {
throw new BadRequestException({
code: 'INSPECTION_VISIT_INVALID_CLOSE_TIME',
message: 'La fecha de cierre del dispositivo no puede ser anterior al inicio de la Inspección',
});
}
if (clientClosedAt.getTime() > serverClosedAt.getTime() + 24 * 60 * 60 * 1000) {
throw new BadRequestException({
code: 'INSPECTION_VISIT_INVALID_DEVICE_TIME',
message: 'La fecha informada por el dispositivo no puede estar más de 24 horas en el futuro',
});
}
await manager.query(`
UPDATE inspection_visits
SET status = 'CLOSED',
actual_closed_at = $2,
updated_by = $3,
updated_at = $2
WHERE id = $1
`, [id, serverClosedAt, principal.userId]);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.INSPECTION_VISIT_STATUS_CHANGED,
entityType: 'inspection_visit',
entityId: id,
beforeData: { status: InspectionVisitStatus.IN_PROGRESS },
afterData: {
status: InspectionVisitStatus.CLOSED,
clientClosedAt: clientClosedAt.toISOString(),
serverClosedAt: serverClosedAt.toISOString(),
allActsSealed: true,
},
metadata: {
closeSource: 'ANDROID',
companyOutcomeRequiredBeforeClose: true,
},
}, manager);
});
return this.visits.getById(id);
}
}
@@ -4,14 +4,15 @@ export async function nextInspectionVisitCode(
manager: EntityManager, manager: EntityManager,
plannedStartAt: Date, plannedStartAt: Date,
): Promise<string> { ): Promise<string> {
const [yearRow] = (await manager.query(` const [dateRow] = (await manager.query(`
SELECT EXTRACT( SELECT
YEAR FROM $1::timestamptz AT TIME ZONE 'America/Argentina/Mendoza' EXTRACT(YEAR FROM $1::timestamptz AT TIME ZONE 'America/Argentina/Mendoza')::integer AS year,
)::integer AS year TO_CHAR($1::timestamptz AT TIME ZONE 'America/Argentina/Mendoza', 'DD-MM-YY') AS "datePart"
`, [plannedStartAt])) as Array<{ year: number }>; `, [plannedStartAt])) as Array<{ year: number; datePart: string }>;
const year = Number(yearRow?.year); const year = Number(dateRow?.year);
if (!Number.isInteger(year) || year < 2000 || year > 9999) { const datePart = dateRow?.datePart;
throw new Error('Unable to derive inspection visit year'); if (!Number.isInteger(year) || year < 2000 || year > 9999 || !datePart) {
throw new Error('Unable to derive inspection visit date');
} }
await manager.query( await manager.query(
@@ -20,15 +21,25 @@ export async function nextInspectionVisitCode(
); );
const [sequenceRow] = (await manager.query(` const [sequenceRow] = (await manager.query(`
SELECT COALESCE(MAX(RIGHT(code, 6)::integer), 0)::integer AS sequence SELECT COALESCE(MAX(candidate.sequence), 0)::integer AS sequence
FROM (
SELECT SUBSTRING(code FROM '^INSP-([0-9]{5})-[0-9]{2}-[0-9]{2}-[0-9]{2}$')::integer AS sequence
FROM inspection_visits FROM inspection_visits
WHERE code LIKE $1 WHERE EXTRACT(
AND code ~ ('^INS-' || $2::text || '-[0-9]{6}$') YEAR FROM COALESCE(planned_start_at, created_at) AT TIME ZONE 'America/Argentina/Mendoza'
`, [`INS-${year}-%`, year])) as Array<{ sequence: number }>; )::integer = $1
AND code ~ '^INSP-[0-9]{5}-[0-9]{2}-[0-9]{2}-[0-9]{2}$'
UNION ALL
SELECT RIGHT(code, 6)::integer AS sequence
FROM inspection_visits
WHERE code LIKE $2
AND code ~ ('^INS-' || $1::text || '-[0-9]{6}$')
) candidate
`, [year, `INS-${year}-%`])) as Array<{ sequence: number }>;
const sequence = Number(sequenceRow?.sequence ?? 0) + 1; const sequence = Number(sequenceRow?.sequence ?? 0) + 1;
if (sequence > 999999) { if (sequence > 99999) {
throw new Error(`Inspection visit sequence exhausted for ${year}`); throw new Error(`Inspection visit sequence exhausted for ${year}`);
} }
return `INS-${year}-${String(sequence).padStart(6, '0')}`; return `INSP-${String(sequence).padStart(5, '0')}-${datePart}`;
} }
@@ -13,7 +13,6 @@ import {
import { CurrentAuth } from '../auth/decorators/current-auth.decorator'; import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator'; import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import { ChangeInspectionVisitStatusDto } from './dto/change-inspection-visit-status.dto';
import { CloseInspectionVisitDto } from './dto/close-inspection-visit.dto'; import { CloseInspectionVisitDto } from './dto/close-inspection-visit.dto';
import { CreateInspectionVisitDto } from './dto/create-inspection-visit.dto'; import { CreateInspectionVisitDto } from './dto/create-inspection-visit.dto';
import { ExcludeInspectionVisitAssetDto } from './dto/exclude-inspection-visit-asset.dto'; import { ExcludeInspectionVisitAssetDto } from './dto/exclude-inspection-visit-asset.dto';
@@ -21,11 +20,15 @@ import { ListInspectionVisitsQueryDto } from './dto/list-inspection-visits-query
import { ReplaceInspectionVisitAssetsDto } from './dto/replace-inspection-visit-assets.dto'; import { ReplaceInspectionVisitAssetsDto } from './dto/replace-inspection-visit-assets.dto';
import { ReplaceInspectionVisitTeamDto } from './dto/replace-inspection-visit-team.dto'; import { ReplaceInspectionVisitTeamDto } from './dto/replace-inspection-visit-team.dto';
import { UpdateInspectionVisitDto } from './dto/update-inspection-visit.dto'; import { UpdateInspectionVisitDto } from './dto/update-inspection-visit.dto';
import { InspectionVisitClosureService } from './inspection-visit-closure.service';
import { InspectionVisitsService } from './inspection-visits.service'; import { InspectionVisitsService } from './inspection-visits.service';
@Controller('inspection-visits') @Controller('inspection-visits')
export class InspectionVisitsController { export class InspectionVisitsController {
constructor(private readonly visits: InspectionVisitsService) {} constructor(
private readonly visits: InspectionVisitsService,
private readonly closure: InspectionVisitClosureService,
) {}
@Get() @Get()
@RequirePermissions('inspections.read') @RequirePermissions('inspections.read')
@@ -47,9 +50,7 @@ export class InspectionVisitsController {
@Get('planning-context/areas/:areaId/operators') @Get('planning-context/areas/:areaId/operators')
@RequirePermissions('inspections.read') @RequirePermissions('inspections.read')
planningOperators( planningOperators(@Param('areaId', new ParseUUIDPipe({ version: '4' })) areaId: string) {
@Param('areaId', new ParseUUIDPipe({ version: '4' })) areaId: string,
) {
return this.visits.listPlanningOperators(areaId); return this.visits.listPlanningOperators(areaId);
} }
@@ -135,17 +136,6 @@ export class InspectionVisitsController {
return this.visits.replaceTeam(id, dto, principal, request); return this.visits.replaceTeam(id, dto, principal, request);
} }
@Put(':id/status')
@RequirePermissions('inspections.manage')
changeStatus(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Body() dto: ChangeInspectionVisitStatusDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.visits.changeStatus(id, dto, principal, request);
}
@Post(':id/start') @Post(':id/start')
@RequirePermissions('inspections.execute') @RequirePermissions('inspections.execute')
start( start(
@@ -164,6 +154,6 @@ export class InspectionVisitsController {
@CurrentAuth() principal: AuthPrincipal, @CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext, @Req() request: RequestWithContext,
) { ) {
return this.visits.close(id, dto, principal, request); return this.closure.close(id, dto, principal, request);
} }
} }
@@ -7,6 +7,7 @@ import { FieldFindingsController } from './field-findings.controller';
import { FieldFindingsService } from './field-findings.service'; import { FieldFindingsService } from './field-findings.service';
import { FieldInventoryController } from './field-inventory.controller'; import { FieldInventoryController } from './field-inventory.controller';
import { FieldInventoryService } from './field-inventory.service'; import { FieldInventoryService } from './field-inventory.service';
import { InspectionVisitClosureService } from './inspection-visit-closure.service';
import { InspectionVisitsController } from './inspection-visits.controller'; import { InspectionVisitsController } from './inspection-visits.controller';
import { InspectionVisitsService } from './inspection-visits.service'; import { InspectionVisitsService } from './inspection-visits.service';
@@ -15,6 +16,7 @@ import { InspectionVisitsService } from './inspection-visits.service';
controllers: [InspectionVisitsController, FieldInventoryController, FieldFindingsController], controllers: [InspectionVisitsController, FieldInventoryController, FieldFindingsController],
providers: [ providers: [
InspectionVisitsService, InspectionVisitsService,
InspectionVisitClosureService,
FieldInventoryService, FieldInventoryService,
F3FieldInventoryStructureService, F3FieldInventoryStructureService,
FieldFindingsService, FieldFindingsService,
@@ -1,16 +0,0 @@
import { IsEnum, IsOptional, IsString, MaxLength } from 'class-validator';
export enum SurveyReviewDecision {
APPROVE = 'APPROVE',
REJECT = 'REJECT',
}
export class ReviewSurveyReportDto {
@IsEnum(SurveyReviewDecision)
decision!: SurveyReviewDecision;
@IsOptional()
@IsString()
@MaxLength(4000)
notes?: string | null;
}
@@ -1,58 +0,0 @@
import { Type } from 'class-transformer';
import {
ArrayMaxSize,
ArrayUnique,
IsArray,
IsEnum,
IsISO8601,
IsNumber,
IsOptional,
IsString,
IsUUID,
Max,
MaxLength,
Min,
} from 'class-validator';
import { SurveyReportOutcome } from '../../database/entities';
export class SaveSurveyReportDto {
@IsOptional()
@IsEnum(SurveyReportOutcome)
outcome?: SurveyReportOutcome | null;
@IsOptional()
@IsISO8601({ strict: true })
observedAt?: string | null;
@IsOptional()
@Type(() => Number)
@IsNumber({ maxDecimalPlaces: 6 })
@Min(-90)
@Max(90)
latitude?: number | null;
@IsOptional()
@Type(() => Number)
@IsNumber({ maxDecimalPlaces: 6 })
@Min(-180)
@Max(180)
longitude?: number | null;
@IsOptional()
@Type(() => Number)
@IsNumber({ maxDecimalPlaces: 3 })
@Min(0)
@Max(100000)
accuracyM?: number | null;
@IsOptional()
@IsString()
@MaxLength(8000)
notes?: string | null;
@IsArray()
@ArrayMaxSize(50)
@ArrayUnique()
@IsUUID('4', { each: true })
mediaIds!: string[];
}
@@ -1,61 +0,0 @@
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Post,
Put,
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 { ReviewSurveyReportDto } from './dto/review-survey-report.dto';
import { SaveSurveyReportDto } from './dto/save-survey-report.dto';
import { SurveyExecutionService } from './survey-execution.service';
@Controller('survey-campaign-targets/:targetId/report')
export class SurveyExecutionController {
constructor(private readonly execution: SurveyExecutionService) {}
@Get()
@RequirePermissions('surveys.read_reports')
get(
@Param('targetId', new ParseUUIDPipe({ version: '4' })) targetId: string,
) {
return this.execution.get(targetId);
}
@Put()
@RequirePermissions('surveys.capture')
save(
@Param('targetId', new ParseUUIDPipe({ version: '4' })) targetId: string,
@Body() dto: SaveSurveyReportDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.execution.save(targetId, dto, principal, request);
}
@Post('submit')
@RequirePermissions('surveys.capture')
submit(
@Param('targetId', new ParseUUIDPipe({ version: '4' })) targetId: string,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.execution.submit(targetId, principal, request);
}
@Post('review')
@RequirePermissions('surveys.review')
review(
@Param('targetId', new ParseUUIDPipe({ version: '4' })) targetId: string,
@Body() dto: ReviewSurveyReportDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.execution.review(targetId, dto, principal, request);
}
}
@@ -1,12 +0,0 @@
import { Module } from '@nestjs/common';
import { AssetMasterModule } from '../asset-master/asset-master.module';
import { AuditModule } from '../audit/audit.module';
import { SurveyExecutionController } from './survey-execution.controller';
import { SurveyExecutionService } from './survey-execution.service';
@Module({
imports: [AuditModule, AssetMasterModule],
controllers: [SurveyExecutionController],
providers: [SurveyExecutionService],
})
export class SurveyExecutionModule {}
@@ -1,924 +0,0 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { DataSource, EntityManager } from 'typeorm';
import { administrationAuditContext } from '../administration/common/administration-audit';
import { AssetHistoryService } from '../asset-master/asset-history.service';
import { AuditService } from '../audit/audit.service';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import {
Asset,
AssetDataOrigin,
AssetInformationStatus,
AssetVersionChangeType,
AuditAction,
SurveyCampaign,
SurveyCampaignStatus,
SurveyCampaignTarget,
SurveyReportOutcome,
SurveyReportStatus,
SurveyReportVersionEvent,
SurveyTargetReport,
SurveyTargetStatus,
} from '../database/entities';
import {
SurveyReviewDecision,
type ReviewSurveyReportDto,
} from './dto/review-survey-report.dto';
import type { SaveSurveyReportDto } from './dto/save-survey-report.dto';
interface ExecutionPerson {
id: string;
username: string;
firstName: string;
lastName: string;
}
interface ExecutionContextView {
target: {
id: string;
status: SurveyTargetStatus;
dueAt: Date | null;
instructions: string | null;
assignedUser: ExecutionPerson | null;
};
campaign: {
id: string;
code: string;
name: string;
status: SurveyCampaignStatus;
};
asset: {
id: string;
code: string;
name: string;
typeName: string;
informationStatus: AssetInformationStatus;
currentVersion: number;
};
}
interface SurveyReportView {
id: string;
targetId: string;
outcome: SurveyReportOutcome | null;
status: SurveyReportStatus;
observedAt: Date | null;
latitude: number | null;
longitude: number | null;
accuracyM: number | null;
notes: string | null;
assetVersionAtSubmission: number | null;
submittedAt: Date | null;
submittedBy: ExecutionPerson | null;
reviewedAt: Date | null;
reviewedBy: ExecutionPerson | null;
reviewNotes: string | null;
selectedMediaIds: string[];
createdAt: Date;
updatedAt: Date;
}
interface ReportMediaView {
id: string;
kind: 'PHOTO';
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;
source: string;
createdAt: Date;
selected: boolean;
}
interface ReportVersionView {
id: string;
versionNumber: number;
event: SurveyReportVersionEvent;
snapshot: Record<string, unknown>;
actorUserId: string | null;
actorUsername: string | null;
createdAt: Date;
}
export interface SurveyExecutionView extends ExecutionContextView {
report: SurveyReportView | null;
availableMedia: ReportMediaView[];
versions: ReportVersionView[];
}
function targetNotFound(): NotFoundException {
return new NotFoundException({
code: 'SURVEY_TARGET_NOT_FOUND',
message: 'Objetivo de relevamiento no encontrado',
});
}
function reportNotFound(): NotFoundException {
return new NotFoundException({
code: 'SURVEY_REPORT_NOT_FOUND',
message: 'El objetivo todavía no tiene un informe de campo',
});
}
@Injectable()
export class SurveyExecutionService {
constructor(
private readonly dataSource: DataSource,
private readonly audit: AuditService,
private readonly history: AssetHistoryService,
) {}
async get(targetId: string): Promise<SurveyExecutionView> {
return this.dataSource.transaction((manager) => this.loadView(manager, targetId));
}
async save(
targetId: string,
dto: SaveSurveyReportDto,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<SurveyExecutionView> {
return this.dataSource.transaction(async (manager) => {
const { target, campaign } = await this.lockTargetContext(manager, targetId);
this.assertCaptureAllowed(target, campaign, principal);
const repository = manager.getRepository(SurveyTargetReport);
let report = await this.lockReport(manager, targetId, false);
const before = report ? this.reportAuditView(report) : null;
if (!report) {
report = repository.create({
targetId,
outcome: null,
status: SurveyReportStatus.DRAFT,
observedAt: null,
latitude: null,
longitude: null,
accuracyM: null,
notes: null,
assetVersionAtSubmission: null,
submittedAt: null,
submittedBy: null,
reviewedAt: null,
reviewedBy: null,
reviewNotes: null,
createdBy: principal.userId,
updatedBy: principal.userId,
});
} else if (
report.status === SurveyReportStatus.SUBMITTED ||
report.status === SurveyReportStatus.APPROVED
) {
throw new ConflictException({
code: 'SURVEY_REPORT_LOCKED',
message: 'El informe enviado o aprobado ya no puede editarse',
});
}
const nextLatitude = dto.latitude === undefined
? this.numberOrNull(report.latitude)
: dto.latitude;
const nextLongitude = dto.longitude === undefined
? this.numberOrNull(report.longitude)
: dto.longitude;
const nextAccuracy = dto.accuracyM === undefined
? this.numberOrNull(report.accuracyM)
: dto.accuracyM;
this.assertCoordinatePair(nextLatitude, nextLongitude, nextAccuracy);
await this.validateMedia(manager, target.assetId, dto.mediaIds);
if (dto.outcome !== undefined) report.outcome = dto.outcome;
if (dto.observedAt !== undefined) {
report.observedAt = dto.observedAt ? new Date(dto.observedAt) : null;
}
if (dto.latitude !== undefined) {
report.latitude = dto.latitude == null ? null : String(dto.latitude);
}
if (dto.longitude !== undefined) {
report.longitude = dto.longitude == null ? null : String(dto.longitude);
}
if (dto.accuracyM !== undefined) {
report.accuracyM = dto.accuracyM == null ? null : String(dto.accuracyM);
}
if (dto.notes !== undefined) report.notes = dto.notes?.trim() || null;
if (report.status === SurveyReportStatus.REJECTED) {
report.status = SurveyReportStatus.DRAFT;
report.assetVersionAtSubmission = null;
report.submittedAt = null;
report.submittedBy = null;
report.reviewedAt = null;
report.reviewedBy = null;
report.reviewNotes = null;
}
report.updatedBy = principal.userId;
await repository.save(report);
await this.replaceMediaSelection(manager, report.id, dto.mediaIds, principal.userId);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.SURVEY_REPORT_SAVED,
entityType: 'survey_target_report',
entityId: report.id,
beforeData: before,
afterData: this.reportAuditView(report),
metadata: { targetId, mediaCount: dto.mediaIds.length },
}, manager);
return this.loadView(manager, targetId);
});
}
async submit(
targetId: string,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<SurveyExecutionView> {
return this.dataSource.transaction(async (manager) => {
const { target, campaign } = await this.lockTargetContext(manager, targetId);
this.assertCaptureAllowed(target, campaign, principal);
const report = await this.lockReport(manager, targetId, true);
if (!report) throw reportNotFound();
if (report.status !== SurveyReportStatus.DRAFT) {
throw new ConflictException({
code: 'SURVEY_REPORT_NOT_DRAFT',
message: 'Sólo se puede enviar un informe guardado como borrador',
});
}
await this.assertComplete(manager, report);
const asset = await this.lockAsset(manager, target.assetId);
const beforeAsset = this.assetAuditView(asset);
asset.informationStatus = report.outcome === SurveyReportOutcome.NOT_LOCATED
? AssetInformationStatus.OBSERVED
: AssetInformationStatus.SURVEYED;
asset.dataOrigin = AssetDataOrigin.FIELD_SURVEY;
asset.sourceName = `Relevamiento ${campaign.code}`;
asset.sourceReference = `survey-report:${report.id}`;
asset.sourceObservedAt = report.observedAt;
asset.sourceNotes = report.notes;
asset.provenanceVerifiedAt = null;
asset.provenanceVerifiedBy = null;
asset.provenanceUpdatedAt = new Date();
asset.provenanceUpdatedBy = principal.userId;
asset.updatedBy = principal.userId;
await manager.getRepository(Asset).save(asset);
const assetVersion = await this.history.capture(
manager,
asset.id,
AssetVersionChangeType.PROVENANCE_UPDATED,
principal,
request,
);
asset.currentVersion = assetVersion;
report.status = SurveyReportStatus.SUBMITTED;
report.assetVersionAtSubmission = assetVersion;
report.submittedAt = new Date();
report.submittedBy = principal.userId;
report.reviewedAt = null;
report.reviewedBy = null;
report.reviewNotes = null;
report.updatedBy = principal.userId;
await manager.getRepository(SurveyTargetReport).save(report);
target.status = SurveyTargetStatus.SUBMITTED;
target.updatedBy = principal.userId;
await manager.getRepository(SurveyCampaignTarget).save(target);
campaign.updatedBy = principal.userId;
await manager.getRepository(SurveyCampaign).save(campaign);
const reportVersion = await this.captureReportVersion(
manager,
report,
SurveyReportVersionEvent.SUBMITTED,
principal,
);
await this.recordAssetChange(
manager,
asset,
beforeAsset,
AuditAction.ASSET_PROVENANCE_UPDATED,
principal,
request,
targetId,
assetVersion,
);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.SURVEY_REPORT_SUBMITTED,
entityType: 'survey_target_report',
entityId: report.id,
afterData: this.reportAuditView(report),
metadata: { targetId, assetId: asset.id, assetVersion, reportVersion },
}, manager);
return this.loadView(manager, targetId);
});
}
async review(
targetId: string,
dto: ReviewSurveyReportDto,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<SurveyExecutionView> {
return this.dataSource.transaction(async (manager) => {
const { target, campaign } = await this.lockTargetContext(manager, targetId);
if (campaign.status !== SurveyCampaignStatus.IN_PROGRESS) {
throw new ConflictException({
code: 'SURVEY_CAMPAIGN_NOT_IN_PROGRESS',
message: 'La campaña debe estar en curso para revisar informes',
});
}
const report = await this.lockReport(manager, targetId, true);
if (!report) throw reportNotFound();
if (report.status !== SurveyReportStatus.SUBMITTED || target.status !== SurveyTargetStatus.SUBMITTED) {
throw new ConflictException({
code: 'SURVEY_REPORT_NOT_SUBMITTED',
message: 'El informe no está pendiente de revisión',
});
}
const notes = dto.notes?.trim() || null;
if (dto.decision === SurveyReviewDecision.REJECT && (!notes || notes.length < 10)) {
throw new BadRequestException({
code: 'SURVEY_REJECTION_REASON_REQUIRED',
message: 'Indicá un motivo de rechazo de al menos 10 caracteres',
});
}
const asset = await this.lockAsset(manager, target.assetId);
if (
dto.decision === SurveyReviewDecision.APPROVE &&
asset.currentVersion !== report.assetVersionAtSubmission
) {
throw new ConflictException({
code: 'SURVEY_ASSET_CHANGED_AFTER_SUBMISSION',
message: 'El activo cambió después del envío; rechazá el informe para que sea revisado nuevamente',
});
}
const beforeAsset = this.assetAuditView(asset);
let assetVersion = asset.currentVersion;
if (dto.decision === SurveyReviewDecision.APPROVE) {
if (report.outcome !== SurveyReportOutcome.NOT_LOCATED) {
asset.informationStatus = AssetInformationStatus.VALIDATED;
asset.provenanceVerifiedAt = new Date();
asset.provenanceVerifiedBy = principal.userId;
asset.provenanceUpdatedAt = new Date();
asset.provenanceUpdatedBy = principal.userId;
asset.updatedBy = principal.userId;
await manager.getRepository(Asset).save(asset);
assetVersion = await this.history.capture(
manager,
asset.id,
AssetVersionChangeType.PROVENANCE_VERIFIED,
principal,
request,
);
asset.currentVersion = assetVersion;
await this.recordAssetChange(
manager,
asset,
beforeAsset,
AuditAction.ASSET_PROVENANCE_VERIFIED,
principal,
request,
targetId,
assetVersion,
);
}
report.status = SurveyReportStatus.APPROVED;
target.status = SurveyTargetStatus.COMPLETED;
} else {
const assetStillMatchesSubmission =
asset.currentVersion === report.assetVersionAtSubmission;
const assetNeedsUpdate = assetStillMatchesSubmission && (
asset.informationStatus !== AssetInformationStatus.OBSERVED ||
asset.provenanceVerifiedAt !== null ||
asset.provenanceVerifiedBy !== null
);
if (assetNeedsUpdate) {
asset.informationStatus = AssetInformationStatus.OBSERVED;
asset.provenanceVerifiedAt = null;
asset.provenanceVerifiedBy = null;
asset.provenanceUpdatedAt = new Date();
asset.provenanceUpdatedBy = principal.userId;
asset.updatedBy = principal.userId;
await manager.getRepository(Asset).save(asset);
assetVersion = await this.history.capture(
manager,
asset.id,
AssetVersionChangeType.STATUS_CHANGED,
principal,
request,
);
asset.currentVersion = assetVersion;
await this.recordAssetChange(
manager,
asset,
beforeAsset,
AuditAction.ASSET_INFORMATION_STATUS_CHANGED,
principal,
request,
targetId,
assetVersion,
);
}
report.status = SurveyReportStatus.REJECTED;
target.status = SurveyTargetStatus.IN_PROGRESS;
}
report.reviewedAt = new Date();
report.reviewedBy = principal.userId;
report.reviewNotes = notes;
report.updatedBy = principal.userId;
await manager.getRepository(SurveyTargetReport).save(report);
target.updatedBy = principal.userId;
await manager.getRepository(SurveyCampaignTarget).save(target);
campaign.updatedBy = principal.userId;
await manager.getRepository(SurveyCampaign).save(campaign);
const event = dto.decision === SurveyReviewDecision.APPROVE
? SurveyReportVersionEvent.APPROVED
: SurveyReportVersionEvent.REJECTED;
const reportVersion = await this.captureReportVersion(manager, report, event, principal);
await this.audit.record({
...administrationAuditContext(principal, request),
action: dto.decision === SurveyReviewDecision.APPROVE
? AuditAction.SURVEY_REPORT_APPROVED
: AuditAction.SURVEY_REPORT_REJECTED,
entityType: 'survey_target_report',
entityId: report.id,
afterData: this.reportAuditView(report),
metadata: { targetId, assetId: asset.id, assetVersion, reportVersion },
}, manager);
return this.loadView(manager, targetId);
});
}
private async loadView(manager: EntityManager, targetId: string): Promise<SurveyExecutionView> {
const [context] = (await manager.query(`
SELECT
JSONB_BUILD_OBJECT(
'id', target.id,
'status', target.status,
'dueAt', target.due_at,
'instructions', target.instructions,
'assignedUser', CASE WHEN assignee.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id', assignee.id,
'username', assignee.username,
'firstName', assignee.first_name,
'lastName', assignee.last_name
) END
) AS target,
JSONB_BUILD_OBJECT(
'id', campaign.id,
'code', campaign.code,
'name', campaign.name,
'status', campaign.status
) AS campaign,
JSONB_BUILD_OBJECT(
'id', asset.id,
'code', asset.code,
'name', asset.name,
'typeName', asset_type.name,
'informationStatus', asset.information_status,
'currentVersion', asset.current_version
) AS asset
FROM survey_campaign_targets target
INNER JOIN survey_campaigns campaign ON campaign.id = target.campaign_id
INNER JOIN assets asset ON asset.id = target.asset_id
INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id
LEFT JOIN users assignee ON assignee.id = target.assigned_user_id
WHERE target.id = $1
`, [targetId])) as ExecutionContextView[];
if (!context) throw targetNotFound();
const [report] = (await manager.query(`
SELECT
report.id,
report.target_id AS "targetId",
report.outcome,
report.status,
report.observed_at AS "observedAt",
report.latitude::double precision AS latitude,
report.longitude::double precision AS longitude,
report.accuracy_m::double precision AS "accuracyM",
report.notes,
report.asset_version_at_submission AS "assetVersionAtSubmission",
report.submitted_at AS "submittedAt",
CASE WHEN submitter.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id', submitter.id,
'username', submitter.username,
'firstName', submitter.first_name,
'lastName', submitter.last_name
) END AS "submittedBy",
report.reviewed_at AS "reviewedAt",
CASE WHEN reviewer.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id', reviewer.id,
'username', reviewer.username,
'firstName', reviewer.first_name,
'lastName', reviewer.last_name
) END AS "reviewedBy",
report.review_notes AS "reviewNotes",
COALESCE((
SELECT ARRAY_AGG(link.media_id ORDER BY link.created_at)
FROM survey_target_report_media link
INNER JOIN asset_media media ON media.id = link.media_id
WHERE link.report_id = report.id
AND link.included = true
AND media.deleted_at IS NULL
), ARRAY[]::uuid[]) AS "selectedMediaIds",
report.created_at AS "createdAt",
report.updated_at AS "updatedAt"
FROM survey_target_reports report
LEFT JOIN users submitter ON submitter.id = report.submitted_by
LEFT JOIN users reviewer ON reviewer.id = report.reviewed_by
WHERE report.target_id = $1
`, [targetId])) as SurveyReportView[];
const availableMedia = (await manager.query(`
SELECT
media.id,
media.kind,
media.original_name AS "originalName",
media.mime_type AS "mimeType",
media.size_bytes::double precision AS "sizeBytes",
media.sha256,
media.title,
media.description,
media.captured_at AS "capturedAt",
media.latitude::double precision AS latitude,
media.longitude::double precision AS longitude,
media.accuracy_m::double precision AS "accuracyM",
media.source,
media.created_at AS "createdAt",
EXISTS (
SELECT 1 FROM survey_target_report_media link
WHERE link.report_id = $2 AND link.media_id = media.id AND link.included = true
) AS selected
FROM asset_media media
WHERE media.asset_id = $1
AND media.kind = 'PHOTO'
AND media.deleted_at IS NULL
ORDER BY media.created_at DESC
`, [context.asset.id, report?.id ?? null])) as ReportMediaView[];
const versions = report
? (await manager.query(`
SELECT
version.id,
version.version_number AS "versionNumber",
version.event,
version.snapshot,
version.actor_user_id AS "actorUserId",
version.actor_username AS "actorUsername",
version.created_at AS "createdAt"
FROM survey_target_report_versions version
WHERE version.report_id = $1
ORDER BY version.version_number DESC
`, [report.id])) as ReportVersionView[]
: [];
return { ...context, report: report ?? null, availableMedia, versions };
}
private async lockTargetContext(
manager: EntityManager,
targetId: string,
): Promise<{ target: SurveyCampaignTarget; campaign: SurveyCampaign }> {
const target = await manager.getRepository(SurveyCampaignTarget)
.createQueryBuilder('target')
.where('target.id = :targetId', { targetId })
.setLock('pessimistic_write')
.getOne();
if (!target) throw targetNotFound();
const campaign = await manager.getRepository(SurveyCampaign)
.createQueryBuilder('campaign')
.where('campaign.id = :campaignId', { campaignId: target.campaignId })
.setLock('pessimistic_write')
.getOne();
if (!campaign) throw targetNotFound();
return { target, campaign };
}
private async lockReport(
manager: EntityManager,
targetId: string,
required: boolean,
): Promise<SurveyTargetReport | null> {
const report = await manager.getRepository(SurveyTargetReport)
.createQueryBuilder('report')
.where('report.targetId = :targetId', { targetId })
.setLock('pessimistic_write')
.getOne();
if (!report && required) throw reportNotFound();
return report;
}
private async lockAsset(manager: EntityManager, assetId: string): Promise<Asset> {
const asset = await manager.getRepository(Asset)
.createQueryBuilder('asset')
.where('asset.id = :assetId', { assetId })
.setLock('pessimistic_write')
.getOne();
if (!asset) throw targetNotFound();
return asset;
}
private assertCaptureAllowed(
target: SurveyCampaignTarget,
campaign: SurveyCampaign,
principal: AuthPrincipal,
): void {
if (campaign.status !== SurveyCampaignStatus.IN_PROGRESS) {
throw new ConflictException({
code: 'SURVEY_CAMPAIGN_NOT_IN_PROGRESS',
message: 'La campaña debe estar en curso para capturar el relevamiento',
});
}
if (target.status !== SurveyTargetStatus.IN_PROGRESS) {
throw new ConflictException({
code: 'SURVEY_TARGET_NOT_IN_PROGRESS',
message: 'El objetivo debe estar en ejecución para editar o enviar el informe',
});
}
if (
!principal.permissions.includes('surveys.manage') &&
target.assignedUserId !== principal.userId
) {
throw new ForbiddenException({
code: 'SURVEY_TARGET_NOT_ASSIGNED',
message: 'El objetivo no está asignado al usuario actual',
});
}
}
private assertCoordinatePair(
latitude: number | null | undefined,
longitude: number | null | undefined,
accuracyM: number | null | undefined,
): void {
if ((latitude == null) !== (longitude == null)) {
throw new BadRequestException({
code: 'SURVEY_COORDINATES_INCOMPLETE',
message: 'Latitud y longitud deben informarse juntas',
});
}
if (accuracyM != null && latitude == null) {
throw new BadRequestException({
code: 'SURVEY_ACCURACY_WITHOUT_LOCATION',
message: 'La precisión requiere coordenadas GPS',
});
}
}
private async validateMedia(
manager: EntityManager,
assetId: string,
mediaIds: string[],
): Promise<void> {
if (mediaIds.length === 0) return;
const [row] = (await manager.query(`
SELECT COUNT(*)::integer AS count
FROM asset_media
WHERE asset_id = $1
AND id = ANY($2::uuid[])
AND kind = 'PHOTO'
AND deleted_at IS NULL
`, [assetId, mediaIds])) as Array<{ count: number }>;
if (Number(row?.count ?? 0) !== mediaIds.length) {
throw new BadRequestException({
code: 'SURVEY_MEDIA_INVALID',
message: 'Una o más evidencias no son fotografías activas del activo relevado',
});
}
}
private async replaceMediaSelection(
manager: EntityManager,
reportId: string,
mediaIds: string[],
userId: string,
): Promise<void> {
await manager.query(`
UPDATE survey_target_report_media
SET included = false, updated_at = CURRENT_TIMESTAMP
WHERE report_id = $1 AND included = true
`, [reportId]);
for (const mediaId of mediaIds) {
await manager.query(`
INSERT INTO survey_target_report_media (
report_id, media_id, included, added_by
) VALUES ($1, $2, true, $3)
ON CONFLICT (report_id, media_id) DO UPDATE SET
included = true,
added_by = EXCLUDED.added_by,
updated_at = CURRENT_TIMESTAMP
`, [reportId, mediaId, userId]);
}
}
private async assertComplete(
manager: EntityManager,
report: SurveyTargetReport,
): Promise<void> {
if (!report.outcome) {
throw new BadRequestException({
code: 'SURVEY_OUTCOME_REQUIRED',
message: 'Seleccioná el resultado de la verificación',
});
}
if (!report.observedAt) {
throw new BadRequestException({
code: 'SURVEY_OBSERVED_AT_REQUIRED',
message: 'Indicá la fecha y hora de observación',
});
}
if (report.latitude == null || report.longitude == null || report.accuracyM == null) {
throw new BadRequestException({
code: 'SURVEY_GPS_REQUIRED',
message: 'Capturá ubicación y precisión GPS antes de enviar',
});
}
if (
report.outcome !== SurveyReportOutcome.CONFIRMED &&
(!report.notes || report.notes.trim().length < 10)
) {
throw new BadRequestException({
code: 'SURVEY_NOTES_REQUIRED',
message: 'Describí los cambios o la imposibilidad de localizar el activo',
});
}
const [row] = (await manager.query(`
SELECT COUNT(*)::integer AS count
FROM survey_target_report_media link
INNER JOIN asset_media media ON media.id = link.media_id
WHERE link.report_id = $1
AND link.included = true
AND media.kind = 'PHOTO'
AND media.deleted_at IS NULL
`, [report.id])) as Array<{ count: number }>;
if (Number(row?.count ?? 0) < 1) {
throw new BadRequestException({
code: 'SURVEY_PHOTO_REQUIRED',
message: 'Seleccioná al menos una fotografía como evidencia',
});
}
}
private async captureReportVersion(
manager: EntityManager,
report: SurveyTargetReport,
event: SurveyReportVersionEvent,
principal: AuthPrincipal,
): Promise<number> {
const [row] = (await manager.query(`
SELECT COALESCE(MAX(version_number), 0)::integer + 1 AS "versionNumber"
FROM survey_target_report_versions
WHERE report_id = $1
`, [report.id])) as Array<{ versionNumber: number }>;
const versionNumber = Number(row?.versionNumber ?? 1);
const snapshot = await this.buildReportSnapshot(manager, report.id);
await manager.query(`
INSERT INTO survey_target_report_versions (
report_id, version_number, event, snapshot, actor_user_id, actor_username
) VALUES ($1, $2, $3, $4, $5, $6)
`, [report.id, versionNumber, event, snapshot, principal.userId, principal.username]);
return versionNumber;
}
private async buildReportSnapshot(
manager: EntityManager,
reportId: string,
): Promise<Record<string, unknown>> {
const [row] = (await manager.query(`
SELECT JSONB_BUILD_OBJECT(
'id', report.id,
'status', report.status,
'outcome', report.outcome,
'observedAt', report.observed_at,
'location', JSONB_BUILD_OBJECT(
'latitude', report.latitude,
'longitude', report.longitude,
'accuracyM', report.accuracy_m
),
'notes', report.notes,
'assetVersionAtSubmission', report.asset_version_at_submission,
'submittedAt', report.submitted_at,
'submittedBy', report.submitted_by,
'reviewedAt', report.reviewed_at,
'reviewedBy', report.reviewed_by,
'reviewNotes', report.review_notes,
'target', JSONB_BUILD_OBJECT(
'id', target.id,
'status', target.status,
'dueAt', target.due_at,
'instructions', target.instructions
),
'campaign', JSONB_BUILD_OBJECT(
'id', campaign.id,
'code', campaign.code,
'name', campaign.name
),
'asset', JSONB_BUILD_OBJECT(
'id', asset.id,
'code', asset.code,
'name', asset.name,
'informationStatus', asset.information_status,
'currentVersion', asset.current_version
),
'evidence', COALESCE((
SELECT JSONB_AGG(JSONB_BUILD_OBJECT(
'id', media.id,
'originalName', media.original_name,
'mimeType', media.mime_type,
'sizeBytes', media.size_bytes,
'sha256', media.sha256,
'title', media.title,
'description', media.description,
'capturedAt', media.captured_at,
'latitude', media.latitude,
'longitude', media.longitude,
'accuracyM', media.accuracy_m,
'source', media.source,
'createdAt', media.created_at
) ORDER BY media.created_at, media.id)
FROM survey_target_report_media link
INNER JOIN asset_media media ON media.id = link.media_id
WHERE link.report_id = report.id AND link.included = true
), '[]'::jsonb)
) AS snapshot
FROM survey_target_reports report
INNER JOIN survey_campaign_targets target ON target.id = report.target_id
INNER JOIN survey_campaigns campaign ON campaign.id = target.campaign_id
INNER JOIN assets asset ON asset.id = target.asset_id
WHERE report.id = $1
`, [reportId])) as Array<{ snapshot: Record<string, unknown> }>;
if (!row) throw reportNotFound();
return row.snapshot;
}
private async recordAssetChange(
manager: EntityManager,
asset: Asset,
beforeData: Record<string, unknown>,
action: AuditAction,
principal: AuthPrincipal,
request: RequestWithContext,
targetId: string,
versionNumber: number,
): Promise<void> {
await this.audit.record({
...administrationAuditContext(principal, request),
action,
entityType: 'asset',
entityId: asset.id,
beforeData,
afterData: this.assetAuditView(asset),
metadata: { targetId, versionNumber, source: 'survey_report' },
}, manager);
}
private assetAuditView(asset: Asset): Record<string, unknown> {
return {
informationStatus: asset.informationStatus,
dataOrigin: asset.dataOrigin,
sourceName: asset.sourceName,
sourceReference: asset.sourceReference,
sourceObservedAt: asset.sourceObservedAt,
provenanceVerifiedAt: asset.provenanceVerifiedAt,
provenanceVerifiedBy: asset.provenanceVerifiedBy,
currentVersion: asset.currentVersion,
};
}
private reportAuditView(report: SurveyTargetReport): Record<string, unknown> {
return {
targetId: report.targetId,
outcome: report.outcome,
status: report.status,
observedAt: report.observedAt,
latitude: this.numberOrNull(report.latitude),
longitude: this.numberOrNull(report.longitude),
accuracyM: this.numberOrNull(report.accuracyM),
notes: report.notes,
assetVersionAtSubmission: report.assetVersionAtSubmission,
submittedAt: report.submittedAt,
submittedBy: report.submittedBy,
reviewedAt: report.reviewedAt,
reviewedBy: report.reviewedBy,
reviewNotes: report.reviewNotes,
};
}
private numberOrNull(value: string | number | null): number | null {
return value == null ? null : Number(value);
}
}
@@ -1,23 +0,0 @@
import { Transform } from 'class-transformer';
import { IsISO8601, IsOptional, IsString, IsUUID, MaxLength } from 'class-validator';
export class AddSurveyTargetDto {
@IsUUID('4')
assetId!: string;
@IsOptional()
@IsUUID('4')
assignedUserId?: string | null;
@IsOptional()
@IsISO8601({ strict: true })
dueAt?: string | null;
@IsOptional()
@Transform(({ value }) =>
typeof value === 'string' && value.trim() ? value.trim() : null,
)
@IsString()
@MaxLength(4000)
instructions?: string | null;
}
@@ -1,7 +0,0 @@
import { IsOptional, IsUUID } from 'class-validator';
export class AssignSurveyTargetDto {
@IsOptional()
@IsUUID('4')
assignedUserId!: string | null;
}
@@ -1,7 +0,0 @@
import { IsEnum } from 'class-validator';
import { SurveyCampaignStatus } from '../../database/entities';
export class ChangeSurveyCampaignStatusDto {
@IsEnum(SurveyCampaignStatus)
status!: SurveyCampaignStatus;
}
@@ -1,7 +0,0 @@
import { IsEnum } from 'class-validator';
import { SurveyTargetStatus } from '../../database/entities';
export class ChangeSurveyTargetStatusDto {
@IsEnum(SurveyTargetStatus)
status!: SurveyTargetStatus;
}
@@ -1,51 +0,0 @@
import { Transform } from 'class-transformer';
import {
IsISO8601,
IsOptional,
IsString,
IsUUID,
Matches,
MaxLength,
MinLength,
} from 'class-validator';
export class CreateSurveyCampaignDto {
@Transform(({ value }) =>
typeof value === 'string' ? value.trim().toUpperCase() : value,
)
@IsString()
@MinLength(1)
@MaxLength(80)
@Matches(/^[A-Z0-9][A-Z0-9._/-]*$/)
code!: string;
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString()
@MinLength(1)
@MaxLength(200)
name!: string;
@IsOptional()
@Transform(({ value }) =>
typeof value === 'string' && value.trim() ? value.trim() : null,
)
@IsString()
@MaxLength(4000)
description?: string | null;
@IsOptional()
@IsISO8601({ strict: true })
plannedStartAt?: string | null;
@IsOptional()
@IsISO8601({ strict: true })
plannedEndAt?: string | null;
@IsOptional()
@IsUUID('4')
scopeAssetId?: string | null;
@IsOptional()
@IsUUID('4')
coordinatorUserId?: string | null;
}
@@ -1,40 +0,0 @@
import { Type } from 'class-transformer';
import {
IsEnum,
IsInt,
IsOptional,
IsString,
IsUUID,
Max,
MaxLength,
Min,
} from 'class-validator';
import { SurveyCampaignStatus } from '../../database/entities';
export class ListSurveyCampaignsQueryDto {
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page = 1;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
pageSize = 25;
@IsOptional()
@IsString()
@MaxLength(200)
search?: string;
@IsOptional()
@IsEnum(SurveyCampaignStatus)
status?: SurveyCampaignStatus;
@IsOptional()
@IsUUID('4')
coordinatorUserId?: string;
}
@@ -1,53 +0,0 @@
import { Transform } from 'class-transformer';
import {
IsISO8601,
IsOptional,
IsString,
IsUUID,
Matches,
MaxLength,
MinLength,
} from 'class-validator';
export class UpdateSurveyCampaignDto {
@IsOptional()
@Transform(({ value }) =>
typeof value === 'string' ? value.trim().toUpperCase() : value,
)
@IsString()
@MinLength(1)
@MaxLength(80)
@Matches(/^[A-Z0-9][A-Z0-9._/-]*$/)
code?: string;
@IsOptional()
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString()
@MinLength(1)
@MaxLength(200)
name?: string;
@IsOptional()
@Transform(({ value }) =>
typeof value === 'string' && value.trim() ? value.trim() : null,
)
@IsString()
@MaxLength(4000)
description?: string | null;
@IsOptional()
@IsISO8601({ strict: true })
plannedStartAt?: string | null;
@IsOptional()
@IsISO8601({ strict: true })
plannedEndAt?: string | null;
@IsOptional()
@IsUUID('4')
scopeAssetId?: string | null;
@IsOptional()
@IsUUID('4')
coordinatorUserId?: string | null;
}
@@ -1,16 +0,0 @@
import { Transform } from 'class-transformer';
import { IsISO8601, IsOptional, IsString, MaxLength } from 'class-validator';
export class UpdateSurveyTargetDto {
@IsOptional()
@IsISO8601({ strict: true })
dueAt?: string | null;
@IsOptional()
@Transform(({ value }) =>
typeof value === 'string' && value.trim() ? value.trim() : null,
)
@IsString()
@MaxLength(4000)
instructions?: string | null;
}
@@ -1,131 +0,0 @@
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Patch,
Post,
Put,
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 { AddSurveyTargetDto } from './dto/add-survey-target.dto';
import { AssignSurveyTargetDto } from './dto/assign-survey-target.dto';
import { ChangeSurveyCampaignStatusDto } from './dto/change-survey-campaign-status.dto';
import { ChangeSurveyTargetStatusDto } from './dto/change-survey-target-status.dto';
import { CreateSurveyCampaignDto } from './dto/create-survey-campaign.dto';
import { ListSurveyCampaignsQueryDto } from './dto/list-survey-campaigns-query.dto';
import { UpdateSurveyCampaignDto } from './dto/update-survey-campaign.dto';
import { UpdateSurveyTargetDto } from './dto/update-survey-target.dto';
import { SurveyPlanningService } from './survey-planning.service';
@Controller('survey-campaigns')
export class SurveyCampaignsController {
constructor(private readonly planning: SurveyPlanningService) {}
@Get()
@RequirePermissions('surveys.read')
list(@Query() query: ListSurveyCampaignsQueryDto) {
return this.planning.list(query);
}
@Get('assignees')
@RequirePermissions('surveys.assign')
assignees() {
return this.planning.listAssignees();
}
@Post()
@RequirePermissions('surveys.manage')
create(
@Body() dto: CreateSurveyCampaignDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.planning.create(dto, principal, request);
}
@Get(':id')
@RequirePermissions('surveys.read')
get(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
return this.planning.getById(id);
}
@Patch(':id')
@RequirePermissions('surveys.manage')
update(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Body() dto: UpdateSurveyCampaignDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.planning.update(id, dto, principal, request);
}
@Put(':id/status')
@RequirePermissions('surveys.manage')
changeStatus(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Body() dto: ChangeSurveyCampaignStatusDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.planning.changeCampaignStatus(id, dto, principal, request);
}
@Post(':id/targets')
@RequirePermissions('surveys.manage')
addTarget(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Body() dto: AddSurveyTargetDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.planning.addTarget(id, dto, principal, request);
}
}
@Controller('survey-campaign-targets')
export class SurveyCampaignTargetsController {
constructor(private readonly planning: SurveyPlanningService) {}
@Patch(':id')
@RequirePermissions('surveys.manage')
update(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Body() dto: UpdateSurveyTargetDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.planning.updateTarget(id, dto, principal, request);
}
@Put(':id/assignment')
@RequirePermissions('surveys.assign')
assign(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Body() dto: AssignSurveyTargetDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.planning.assignTarget(id, dto, principal, request);
}
@Put(':id/status')
@RequirePermissions('surveys.execute')
changeStatus(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Body() dto: ChangeSurveyTargetStatusDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.planning.changeTargetStatus(id, dto, principal, request);
}
}
@@ -1,14 +0,0 @@
import { Module } from '@nestjs/common';
import { AuditModule } from '../audit/audit.module';
import {
SurveyCampaignsController,
SurveyCampaignTargetsController,
} from './survey-campaigns.controller';
import { SurveyPlanningService } from './survey-planning.service';
@Module({
imports: [AuditModule],
controllers: [SurveyCampaignsController, SurveyCampaignTargetsController],
providers: [SurveyPlanningService],
})
export class SurveyPlanningModule {}
@@ -1,791 +0,0 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
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,
SurveyCampaign,
SurveyCampaignStatus,
SurveyCampaignTarget,
SurveyTargetStatus,
} from '../database/entities';
import type { AddSurveyTargetDto } from './dto/add-survey-target.dto';
import type { AssignSurveyTargetDto } from './dto/assign-survey-target.dto';
import type { ChangeSurveyCampaignStatusDto } from './dto/change-survey-campaign-status.dto';
import type { ChangeSurveyTargetStatusDto } from './dto/change-survey-target-status.dto';
import type { CreateSurveyCampaignDto } from './dto/create-survey-campaign.dto';
import type { ListSurveyCampaignsQueryDto } from './dto/list-survey-campaigns-query.dto';
import type { UpdateSurveyCampaignDto } from './dto/update-survey-campaign.dto';
import type { UpdateSurveyTargetDto } from './dto/update-survey-target.dto';
export interface PersonSummary {
id: string;
username: string;
firstName: string;
lastName: string;
}
interface AssetSummary {
id: string;
code: string;
name: string;
}
export interface SurveyCampaignListItem {
id: string;
code: string;
name: string;
description: string | null;
status: SurveyCampaignStatus;
plannedStartAt: Date | null;
plannedEndAt: Date | null;
scopeAsset: AssetSummary | null;
coordinator: PersonSummary | null;
targetCount: number;
pendingCount: number;
inProgressCount: number;
submittedCount: number;
completedCount: number;
skippedCount: number;
createdAt: Date;
updatedAt: Date;
}
export interface SurveyTargetView {
id: string;
campaignId: string;
asset: AssetSummary & { typeName: string };
assignedUser: PersonSummary | null;
status: SurveyTargetStatus;
dueAt: Date | null;
instructions: string | null;
createdAt: Date;
updatedAt: Date;
}
export interface SurveyCampaignView extends SurveyCampaignListItem {
targets: SurveyTargetView[];
}
const campaignTransitions: Record<SurveyCampaignStatus, SurveyCampaignStatus[]> = {
[SurveyCampaignStatus.DRAFT]: [SurveyCampaignStatus.PLANNED, SurveyCampaignStatus.CANCELLED],
[SurveyCampaignStatus.PLANNED]: [
SurveyCampaignStatus.DRAFT,
SurveyCampaignStatus.IN_PROGRESS,
SurveyCampaignStatus.CANCELLED,
],
[SurveyCampaignStatus.IN_PROGRESS]: [
SurveyCampaignStatus.COMPLETED,
SurveyCampaignStatus.CANCELLED,
],
[SurveyCampaignStatus.COMPLETED]: [],
[SurveyCampaignStatus.CANCELLED]: [],
};
const targetTransitions: Record<SurveyTargetStatus, SurveyTargetStatus[]> = {
[SurveyTargetStatus.PENDING]: [SurveyTargetStatus.IN_PROGRESS, SurveyTargetStatus.SKIPPED],
[SurveyTargetStatus.IN_PROGRESS]: [
SurveyTargetStatus.PENDING,
SurveyTargetStatus.SKIPPED,
],
[SurveyTargetStatus.SUBMITTED]: [],
[SurveyTargetStatus.COMPLETED]: [],
[SurveyTargetStatus.SKIPPED]: [],
};
function campaignNotFound(): NotFoundException {
return new NotFoundException({
code: 'SURVEY_CAMPAIGN_NOT_FOUND',
message: 'Campaña de relevamiento no encontrada',
});
}
function targetNotFound(): NotFoundException {
return new NotFoundException({
code: 'SURVEY_TARGET_NOT_FOUND',
message: 'Objetivo de relevamiento no encontrado',
});
}
function dateValue(value: string | null | undefined): Date | null {
return value ? new Date(value) : null;
}
@Injectable()
export class SurveyPlanningService {
constructor(
private readonly dataSource: DataSource,
private readonly audit: AuditService,
) {}
async list(query: ListSurveyCampaignsQueryDto) {
const conditions: string[] = [];
const parameters: unknown[] = [];
const addParameter = (value: unknown) => {
parameters.push(value);
return `$${parameters.length}`;
};
if (query.search?.trim()) {
const search = addParameter(`%${query.search.trim()}%`);
conditions.push(`(campaign.code ILIKE ${search} OR campaign.name ILIKE ${search})`);
}
if (query.status) conditions.push(`campaign.status = ${addParameter(query.status)}`);
if (query.coordinatorUserId) {
conditions.push(`campaign.coordinator_user_id = ${addParameter(query.coordinatorUserId)}`);
}
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
const [countRow] = (await this.dataSource.query(
`SELECT COUNT(*)::integer AS total FROM survey_campaigns campaign ${where}`,
parameters,
)) as Array<{ total: number }>;
const total = Number(countRow?.total ?? 0);
const offset = (query.page - 1) * query.pageSize;
const limitParameter = addParameter(query.pageSize);
const offsetParameter = addParameter(offset);
const data = (await this.dataSource.query(
`${this.campaignSelect(where)}
ORDER BY campaign.updated_at DESC, campaign.code ASC
LIMIT ${limitParameter} OFFSET ${offsetParameter}`,
parameters,
)) as SurveyCampaignListItem[];
return {
data,
meta: {
page: query.page,
pageSize: query.pageSize,
total,
totalPages: total === 0 ? 0 : Math.ceil(total / query.pageSize),
},
};
}
async listAssignees(): Promise<{ data: PersonSummary[] }> {
const data = (await this.dataSource.query(`
SELECT DISTINCT
user_account.id,
user_account.username,
user_account.first_name AS "firstName",
user_account.last_name AS "lastName"
FROM users user_account
INNER JOIN user_roles user_role ON user_role.user_id = user_account.id
INNER JOIN role_permissions role_permission ON role_permission.role_id = user_role.role_id
INNER JOIN permissions permission ON permission.id = role_permission.permission_id
WHERE user_account.status = 'ACTIVE'
AND permission.code = 'surveys.execute'
ORDER BY user_account.last_name, user_account.first_name, user_account.username
`)) as PersonSummary[];
return { data };
}
async getById(id: string): Promise<SurveyCampaignView> {
return this.dataSource.transaction((manager) => this.loadCampaignView(manager, id));
}
async create(
dto: CreateSurveyCampaignDto,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<SurveyCampaignView> {
try {
return await this.dataSource.transaction(async (manager) => {
await this.validateDates(dto.plannedStartAt ?? null, dto.plannedEndAt ?? null);
await this.requireAsset(manager, dto.scopeAssetId ?? null);
await this.requireActiveUser(manager, dto.coordinatorUserId ?? null);
const campaign = manager.getRepository(SurveyCampaign).create({
code: dto.code,
name: dto.name,
description: dto.description ?? null,
status: SurveyCampaignStatus.DRAFT,
plannedStartAt: dateValue(dto.plannedStartAt),
plannedEndAt: dateValue(dto.plannedEndAt),
scopeAssetId: dto.scopeAssetId ?? null,
coordinatorUserId: dto.coordinatorUserId ?? null,
createdBy: principal.userId,
updatedBy: principal.userId,
});
await manager.getRepository(SurveyCampaign).save(campaign);
const created = await this.loadCampaignView(manager, campaign.id);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.SURVEY_CAMPAIGN_CREATED,
entityType: 'survey_campaign',
entityId: campaign.id,
afterData: this.auditCampaign(created),
}, manager);
return created;
});
} catch (error) {
if (isUniqueViolation(error)) throw this.campaignConflict();
throw error;
}
}
async update(
id: string,
dto: UpdateSurveyCampaignDto,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<SurveyCampaignView> {
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 campaign = await this.lockCampaign(manager, id);
this.assertCampaignEditable(campaign);
const before = await this.loadCampaignView(manager, id);
const nextStart = dto.plannedStartAt === undefined
? campaign.plannedStartAt
: dateValue(dto.plannedStartAt);
const nextEnd = dto.plannedEndAt === undefined
? campaign.plannedEndAt
: dateValue(dto.plannedEndAt);
await this.validateDates(nextStart, nextEnd);
if (dto.scopeAssetId !== undefined) {
await this.requireAsset(manager, dto.scopeAssetId);
await this.assertCampaignTargetsInScope(manager, id, dto.scopeAssetId);
}
if (dto.coordinatorUserId !== undefined) {
await this.requireActiveUser(manager, dto.coordinatorUserId);
}
if (dto.code !== undefined) campaign.code = dto.code;
if (dto.name !== undefined) campaign.name = dto.name;
if (dto.description !== undefined) campaign.description = dto.description;
if (dto.plannedStartAt !== undefined) campaign.plannedStartAt = nextStart;
if (dto.plannedEndAt !== undefined) campaign.plannedEndAt = nextEnd;
if (dto.scopeAssetId !== undefined) campaign.scopeAssetId = dto.scopeAssetId;
if (dto.coordinatorUserId !== undefined) {
campaign.coordinatorUserId = dto.coordinatorUserId;
}
campaign.updatedBy = principal.userId;
await manager.getRepository(SurveyCampaign).save(campaign);
const updated = await this.loadCampaignView(manager, id);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.SURVEY_CAMPAIGN_UPDATED,
entityType: 'survey_campaign',
entityId: id,
beforeData: this.auditCampaign(before),
afterData: this.auditCampaign(updated),
}, manager);
return updated;
});
} catch (error) {
if (isUniqueViolation(error)) throw this.campaignConflict();
throw error;
}
}
async changeCampaignStatus(
id: string,
dto: ChangeSurveyCampaignStatusDto,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<SurveyCampaignView> {
return this.dataSource.transaction(async (manager) => {
const campaign = await this.lockCampaign(manager, id);
if (campaign.status === dto.status) return this.loadCampaignView(manager, id);
if (!campaignTransitions[campaign.status].includes(dto.status)) {
throw new ConflictException({
code: 'SURVEY_CAMPAIGN_INVALID_TRANSITION',
message: `No se puede pasar la campaña de ${campaign.status} a ${dto.status}`,
});
}
const [counts] = (await manager.query(`
SELECT
COUNT(*)::integer AS total,
COUNT(*) FILTER (WHERE status IN ('PENDING', 'IN_PROGRESS', 'SUBMITTED'))::integer AS outstanding
FROM survey_campaign_targets WHERE campaign_id = $1
`, [id])) as Array<{ total: number; outstanding: number }>;
if (dto.status === SurveyCampaignStatus.IN_PROGRESS && Number(counts?.total ?? 0) === 0) {
throw new ConflictException({
code: 'SURVEY_CAMPAIGN_EMPTY',
message: 'Agregá al menos un activo antes de iniciar la campaña',
});
}
if (
dto.status === SurveyCampaignStatus.COMPLETED &&
Number(counts?.outstanding ?? 0) > 0
) {
throw new ConflictException({
code: 'SURVEY_CAMPAIGN_HAS_OPEN_TARGETS',
message: 'Todos los objetivos deben estar completados u omitidos',
});
}
const beforeStatus = campaign.status;
campaign.status = dto.status;
campaign.updatedBy = principal.userId;
await manager.getRepository(SurveyCampaign).save(campaign);
const updated = await this.loadCampaignView(manager, id);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.SURVEY_CAMPAIGN_STATUS_CHANGED,
entityType: 'survey_campaign',
entityId: id,
beforeData: { status: beforeStatus },
afterData: { status: dto.status },
}, manager);
return updated;
});
}
async addTarget(
campaignId: string,
dto: AddSurveyTargetDto,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<SurveyCampaignView> {
try {
return await this.dataSource.transaction(async (manager) => {
const campaign = await this.lockCampaign(manager, campaignId);
this.assertCampaignEditable(campaign);
await this.requireAsset(manager, dto.assetId);
await this.assertAssetInScope(manager, dto.assetId, campaign.scopeAssetId);
await this.requireAssignee(manager, dto.assignedUserId ?? null);
const target = manager.getRepository(SurveyCampaignTarget).create({
campaignId,
assetId: dto.assetId,
assignedUserId: dto.assignedUserId ?? null,
status: SurveyTargetStatus.PENDING,
dueAt: dateValue(dto.dueAt),
instructions: dto.instructions ?? null,
createdBy: principal.userId,
updatedBy: principal.userId,
});
await manager.getRepository(SurveyCampaignTarget).save(target);
campaign.updatedBy = principal.userId;
await manager.getRepository(SurveyCampaign).save(campaign);
const targetView = await this.loadTargetView(manager, target.id);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.SURVEY_TARGET_ADDED,
entityType: 'survey_campaign_target',
entityId: target.id,
afterData: targetView as unknown as Record<string, unknown>,
metadata: { campaignId },
}, manager);
return this.loadCampaignView(manager, campaignId);
});
} catch (error) {
if (isUniqueViolation(error)) {
throw new ConflictException({
code: 'SURVEY_TARGET_ALREADY_EXISTS',
message: 'El activo ya forma parte de esta campaña',
});
}
throw error;
}
}
async updateTarget(
id: string,
dto: UpdateSurveyTargetDto,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<SurveyCampaignView> {
if (Object.keys(dto).length === 0) {
throw new BadRequestException({ code: 'NO_CHANGES', message: 'No se recibieron cambios' });
}
return this.dataSource.transaction(async (manager) => {
const target = await this.lockTarget(manager, id);
const campaign = await this.lockCampaign(manager, target.campaignId);
this.assertCampaignEditable(campaign);
this.assertTargetPlanningEditable(target);
const before = await this.loadTargetView(manager, id);
if (dto.dueAt !== undefined) target.dueAt = dateValue(dto.dueAt);
if (dto.instructions !== undefined) target.instructions = dto.instructions;
target.updatedBy = principal.userId;
await manager.getRepository(SurveyCampaignTarget).save(target);
campaign.updatedBy = principal.userId;
await manager.getRepository(SurveyCampaign).save(campaign);
const updated = await this.loadTargetView(manager, id);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.SURVEY_TARGET_UPDATED,
entityType: 'survey_campaign_target',
entityId: id,
beforeData: before as unknown as Record<string, unknown>,
afterData: updated as unknown as Record<string, unknown>,
metadata: { campaignId: target.campaignId },
}, manager);
return this.loadCampaignView(manager, target.campaignId);
});
}
async assignTarget(
id: string,
dto: AssignSurveyTargetDto,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<SurveyCampaignView> {
return this.dataSource.transaction(async (manager) => {
const target = await this.lockTarget(manager, id);
const campaign = await this.lockCampaign(manager, target.campaignId);
this.assertCampaignEditable(campaign);
this.assertTargetPlanningEditable(target);
await this.requireAssignee(manager, dto.assignedUserId);
const beforeUserId = target.assignedUserId;
if (beforeUserId === dto.assignedUserId) {
return this.loadCampaignView(manager, target.campaignId);
}
target.assignedUserId = dto.assignedUserId;
target.updatedBy = principal.userId;
await manager.getRepository(SurveyCampaignTarget).save(target);
campaign.updatedBy = principal.userId;
await manager.getRepository(SurveyCampaign).save(campaign);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.SURVEY_TARGET_ASSIGNED,
entityType: 'survey_campaign_target',
entityId: id,
beforeData: { assignedUserId: beforeUserId },
afterData: { assignedUserId: dto.assignedUserId },
metadata: { campaignId: target.campaignId },
}, manager);
return this.loadCampaignView(manager, target.campaignId);
});
}
async changeTargetStatus(
id: string,
dto: ChangeSurveyTargetStatusDto,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<SurveyCampaignView> {
return this.dataSource.transaction(async (manager) => {
const target = await this.lockTarget(manager, id);
const campaign = await this.lockCampaign(manager, target.campaignId);
const planningOmission =
principal.permissions.includes('surveys.manage') &&
[SurveyCampaignStatus.DRAFT, SurveyCampaignStatus.PLANNED].includes(campaign.status) &&
(
(target.status === SurveyTargetStatus.PENDING && dto.status === SurveyTargetStatus.SKIPPED) ||
(target.status === SurveyTargetStatus.SKIPPED && dto.status === SurveyTargetStatus.PENDING)
);
if (campaign.status !== SurveyCampaignStatus.IN_PROGRESS && !planningOmission) {
throw new ConflictException({
code: 'SURVEY_CAMPAIGN_NOT_IN_PROGRESS',
message: 'La campaña debe estar en curso para registrar avance; durante la planificación sólo se puede omitir o restaurar un objetivo',
});
}
if (
!principal.permissions.includes('surveys.manage') &&
target.assignedUserId !== principal.userId
) {
throw new ForbiddenException({
code: 'SURVEY_TARGET_NOT_ASSIGNED',
message: 'El objetivo no está asignado al usuario actual',
});
}
if (target.status === dto.status) return this.loadCampaignView(manager, target.campaignId);
if (!planningOmission && !targetTransitions[target.status].includes(dto.status)) {
throw new ConflictException({
code: 'SURVEY_TARGET_INVALID_TRANSITION',
message: `No se puede pasar el objetivo de ${target.status} a ${dto.status}`,
});
}
const beforeStatus = target.status;
target.status = dto.status;
target.updatedBy = principal.userId;
await manager.getRepository(SurveyCampaignTarget).save(target);
campaign.updatedBy = principal.userId;
await manager.getRepository(SurveyCampaign).save(campaign);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.SURVEY_TARGET_STATUS_CHANGED,
entityType: 'survey_campaign_target',
entityId: id,
beforeData: { status: beforeStatus },
afterData: { status: dto.status },
metadata: { campaignId: target.campaignId },
}, manager);
return this.loadCampaignView(manager, target.campaignId);
});
}
private campaignSelect(where: string): string {
return `
SELECT
campaign.id,
campaign.code,
campaign.name,
campaign.description,
campaign.status,
campaign.planned_start_at AS "plannedStartAt",
campaign.planned_end_at AS "plannedEndAt",
CASE WHEN scope_asset.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id', scope_asset.id, 'code', scope_asset.code, 'name', scope_asset.name
) END AS "scopeAsset",
CASE WHEN coordinator.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id', coordinator.id,
'username', coordinator.username,
'firstName', coordinator.first_name,
'lastName', coordinator.last_name
) END AS coordinator,
COALESCE(target_counts.total, 0)::integer AS "targetCount",
COALESCE(target_counts.pending, 0)::integer AS "pendingCount",
COALESCE(target_counts.in_progress, 0)::integer AS "inProgressCount",
COALESCE(target_counts.submitted, 0)::integer AS "submittedCount",
COALESCE(target_counts.completed, 0)::integer AS "completedCount",
COALESCE(target_counts.skipped, 0)::integer AS "skippedCount",
campaign.created_at AS "createdAt",
campaign.updated_at AS "updatedAt"
FROM survey_campaigns campaign
LEFT JOIN assets scope_asset ON scope_asset.id = campaign.scope_asset_id
LEFT JOIN users coordinator ON coordinator.id = campaign.coordinator_user_id
LEFT JOIN LATERAL (
SELECT
COUNT(*) AS total,
COUNT(*) FILTER (WHERE target.status = 'PENDING') AS pending,
COUNT(*) FILTER (WHERE target.status = 'IN_PROGRESS') AS in_progress,
COUNT(*) FILTER (WHERE target.status = 'SUBMITTED') AS submitted,
COUNT(*) FILTER (WHERE target.status = 'COMPLETED') AS completed,
COUNT(*) FILTER (WHERE target.status = 'SKIPPED') AS skipped
FROM survey_campaign_targets target
WHERE target.campaign_id = campaign.id
) target_counts ON true
${where}
`;
}
private async loadCampaignView(
manager: EntityManager,
id: string,
): Promise<SurveyCampaignView> {
const [campaign] = (await manager.query(
this.campaignSelect('WHERE campaign.id = $1'),
[id],
)) as SurveyCampaignListItem[];
if (!campaign) throw campaignNotFound();
const targets = (await manager.query(`
${this.targetSelect()}
WHERE target.campaign_id = $1
ORDER BY
CASE target.status
WHEN 'SUBMITTED' THEN 1 WHEN 'IN_PROGRESS' THEN 2 WHEN 'PENDING' THEN 3
WHEN 'COMPLETED' THEN 4 ELSE 5
END,
target.due_at NULLS LAST,
asset.code
`, [id])) as SurveyTargetView[];
return { ...campaign, targets };
}
private async loadTargetView(manager: EntityManager, id: string): Promise<SurveyTargetView> {
const [target] = (await manager.query(`
${this.targetSelect()} WHERE target.id = $1
`, [id])) as SurveyTargetView[];
if (!target) throw targetNotFound();
return target;
}
private targetSelect(): string {
return `
SELECT
target.id,
target.campaign_id AS "campaignId",
JSONB_BUILD_OBJECT(
'id', asset.id,
'code', asset.code,
'name', asset.name,
'typeName', asset_type.name
) AS asset,
CASE WHEN assignee.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id', assignee.id,
'username', assignee.username,
'firstName', assignee.first_name,
'lastName', assignee.last_name
) END AS "assignedUser",
target.status,
target.due_at AS "dueAt",
target.instructions,
target.created_at AS "createdAt",
target.updated_at AS "updatedAt"
FROM survey_campaign_targets target
INNER JOIN assets asset ON asset.id = target.asset_id
INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id
LEFT JOIN users assignee ON assignee.id = target.assigned_user_id
`;
}
private async lockCampaign(manager: EntityManager, id: string): Promise<SurveyCampaign> {
const campaign = await manager.getRepository(SurveyCampaign)
.createQueryBuilder('campaign')
.where('campaign.id = :id', { id })
.setLock('pessimistic_write')
.getOne();
if (!campaign) throw campaignNotFound();
return campaign;
}
private async lockTarget(manager: EntityManager, id: string): Promise<SurveyCampaignTarget> {
const target = await manager.getRepository(SurveyCampaignTarget)
.createQueryBuilder('target')
.where('target.id = :id', { id })
.setLock('pessimistic_write')
.getOne();
if (!target) throw targetNotFound();
return target;
}
private async requireAsset(manager: EntityManager, id: string | null): Promise<void> {
if (!id) return;
const rows = await manager.query(`SELECT 1 FROM assets WHERE id = $1`, [id]) as unknown[];
if (rows.length === 0) {
throw new BadRequestException({
code: 'SURVEY_ASSET_NOT_FOUND',
message: 'El activo seleccionado no existe',
});
}
}
private async requireActiveUser(manager: EntityManager, id: string | null): Promise<void> {
if (!id) return;
const rows = await manager.query(
`SELECT 1 FROM users WHERE id = $1 AND status = 'ACTIVE'`,
[id],
) as unknown[];
if (rows.length === 0) {
throw new BadRequestException({
code: 'SURVEY_USER_NOT_ACTIVE',
message: 'El usuario seleccionado no existe o está inactivo',
});
}
}
private async requireAssignee(manager: EntityManager, id: string | null): Promise<void> {
if (!id) return;
const rows = await manager.query(`
SELECT 1
FROM users user_account
WHERE user_account.id = $1
AND user_account.status = 'ACTIVE'
AND EXISTS (
SELECT 1
FROM user_roles user_role
INNER JOIN role_permissions role_permission ON role_permission.role_id = user_role.role_id
INNER JOIN permissions permission ON permission.id = role_permission.permission_id
WHERE user_role.user_id = user_account.id
AND permission.code = 'surveys.execute'
)
`, [id]) as unknown[];
if (rows.length === 0) {
throw new BadRequestException({
code: 'SURVEY_ASSIGNEE_INVALID',
message: 'El responsable debe ser un usuario activo con permiso de ejecución',
});
}
}
private async assertAssetInScope(
manager: EntityManager,
assetId: string,
scopeAssetId: string | null,
): Promise<void> {
if (!scopeAssetId) return;
const rows = await manager.query(`
WITH RECURSIVE ancestors AS (
SELECT id, parent_id FROM assets WHERE id = $1
UNION ALL
SELECT parent.id, parent.parent_id
FROM assets parent
INNER JOIN ancestors child ON child.parent_id = parent.id
)
SELECT 1 FROM ancestors WHERE id = $2
`, [assetId, scopeAssetId]) as unknown[];
if (rows.length === 0) {
throw new BadRequestException({
code: 'SURVEY_TARGET_OUTSIDE_SCOPE',
message: 'El activo no pertenece al alcance jerárquico de la campaña',
});
}
}
private async assertCampaignTargetsInScope(
manager: EntityManager,
campaignId: string,
scopeAssetId: string | null,
): Promise<void> {
if (!scopeAssetId) return;
const [row] = (await manager.query(`
SELECT COUNT(*)::integer AS outside
FROM survey_campaign_targets target
WHERE target.campaign_id = $1
AND NOT EXISTS (
WITH RECURSIVE ancestors AS (
SELECT id, parent_id FROM assets WHERE id = target.asset_id
UNION ALL
SELECT parent.id, parent.parent_id
FROM assets parent
INNER JOIN ancestors child ON child.parent_id = parent.id
)
SELECT 1 FROM ancestors WHERE id = $2
)
`, [campaignId, scopeAssetId])) as Array<{ outside: number }>;
if (Number(row?.outside ?? 0) > 0) {
throw new ConflictException({
code: 'SURVEY_SCOPE_EXCLUDES_TARGETS',
message: 'El nuevo alcance dejaría objetivos existentes fuera de la campaña',
});
}
}
private async validateDates(
start: string | Date | null,
end: string | Date | null,
): Promise<void> {
if (start && end && new Date(end).getTime() < new Date(start).getTime()) {
throw new BadRequestException({
code: 'SURVEY_INVALID_DATES',
message: 'La fecha de finalización no puede ser anterior al inicio',
});
}
}
private assertCampaignEditable(campaign: SurveyCampaign): void {
if (
campaign.status === SurveyCampaignStatus.COMPLETED ||
campaign.status === SurveyCampaignStatus.CANCELLED
) {
throw new ConflictException({
code: 'SURVEY_CAMPAIGN_CLOSED',
message: 'Una campaña completada o cancelada ya no puede modificarse',
});
}
}
private assertTargetPlanningEditable(target: SurveyCampaignTarget): void {
if (
target.status === SurveyTargetStatus.SUBMITTED ||
target.status === SurveyTargetStatus.COMPLETED
) {
throw new ConflictException({
code: 'SURVEY_TARGET_PLANNING_LOCKED',
message: 'Un objetivo enviado o completado ya no puede replanificarse',
});
}
}
private campaignConflict(): ConflictException {
return new ConflictException({
code: 'SURVEY_CAMPAIGN_CODE_EXISTS',
message: 'Ya existe una campaña con ese código',
});
}
private auditCampaign(campaign: SurveyCampaignView): Record<string, unknown> {
const { targets: _targets, ...summary } = campaign;
return summary as unknown as Record<string, unknown>;
}
}
@@ -2,6 +2,7 @@ import 'reflect-metadata';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { REQUIRED_PERMISSIONS_KEY } from '../../src/authorization/decorators/require-permissions.decorator'; import { REQUIRED_PERMISSIONS_KEY } from '../../src/authorization/decorators/require-permissions.decorator';
import { InspectionActLifecycleController } from '../../src/inspection-closing/inspection-act-lifecycle.controller';
import { import {
InspectionClosingController, InspectionClosingController,
InspectionSignatureContentController, InspectionSignatureContentController,
@@ -12,11 +13,12 @@ function permissionFor(controller: object, method: string): string[] {
return Reflect.getMetadata(REQUIRED_PERMISSIONS_KEY, handler) as string[]; return Reflect.getMetadata(REQUIRED_PERMISSIONS_KEY, handler) as string[];
} }
test('D5 closing endpoints separate read, prepare, sign and close permissions', () => { test('F4 closing endpoints separate responsible capture, immutable lock, signing and sealing', () => {
assert.deepEqual(permissionFor(InspectionClosingController.prototype, 'get'), ['inspection_closure.read']); assert.deepEqual(permissionFor(InspectionClosingController.prototype, 'get'), ['inspection_closure.read']);
assert.deepEqual(permissionFor(InspectionClosingController.prototype, 'responsible'), ['inspection_closure.prepare']); assert.deepEqual(permissionFor(InspectionClosingController.prototype, 'responsible'), ['inspection_closure.prepare']);
assert.deepEqual(permissionFor(InspectionClosingController.prototype, 'ready'), ['inspection_closure.prepare']); assert.deepEqual(permissionFor(InspectionActLifecycleController.prototype, 'lock'), ['inspection_closure.prepare']);
assert.deepEqual(permissionFor(InspectionClosingController.prototype, 'reopen'), ['inspection_closure.prepare']); assert.equal('ready' in InspectionClosingController.prototype, false);
assert.equal('reopen' in InspectionClosingController.prototype, false);
assert.deepEqual(permissionFor(InspectionClosingController.prototype, 'inspectorSignature'), ['inspection_closure.sign']); assert.deepEqual(permissionFor(InspectionClosingController.prototype, 'inspectorSignature'), ['inspection_closure.sign']);
assert.deepEqual(permissionFor(InspectionClosingController.prototype, 'companySignature'), ['inspection_closure.sign']); assert.deepEqual(permissionFor(InspectionClosingController.prototype, 'companySignature'), ['inspection_closure.sign']);
assert.deepEqual(permissionFor(InspectionClosingController.prototype, 'companyOutcome'), ['inspection_closure.sign']); assert.deepEqual(permissionFor(InspectionClosingController.prototype, 'companyOutcome'), ['inspection_closure.sign']);
@@ -10,7 +10,7 @@ function permissionFor(method: string): string[] {
return Reflect.getMetadata(REQUIRED_PERMISSIONS_KEY, handler) as string[]; return Reflect.getMetadata(REQUIRED_PERMISSIONS_KEY, handler) as string[];
} }
test('inspection visit endpoints separate reading, planning, assignment and execution', () => { test('inspection visit endpoints separate reading, planning, assignment and explicit execution', () => {
assert.deepEqual(permissionFor('list'), ['inspections.read']); assert.deepEqual(permissionFor('list'), ['inspections.read']);
assert.deepEqual(permissionFor('get'), ['inspections.read']); assert.deepEqual(permissionFor('get'), ['inspections.read']);
assert.deepEqual(permissionFor('assignees'), ['inspections.assign']); assert.deepEqual(permissionFor('assignees'), ['inspections.assign']);
@@ -18,7 +18,7 @@ test('inspection visit endpoints separate reading, planning, assignment and exec
assert.deepEqual(permissionFor('update'), ['inspections.manage']); assert.deepEqual(permissionFor('update'), ['inspections.manage']);
assert.deepEqual(permissionFor('replaceAssets'), ['inspections.manage']); assert.deepEqual(permissionFor('replaceAssets'), ['inspections.manage']);
assert.deepEqual(permissionFor('replaceTeam'), ['inspections.assign']); assert.deepEqual(permissionFor('replaceTeam'), ['inspections.assign']);
assert.deepEqual(permissionFor('changeStatus'), ['inspections.manage']); assert.equal('changeStatus' in InspectionVisitsController.prototype, false);
assert.deepEqual(permissionFor('start'), ['inspections.execute']); assert.deepEqual(permissionFor('start'), ['inspections.execute']);
assert.deepEqual(permissionFor('close'), ['inspections.execute']); assert.deepEqual(permissionFor('close'), ['inspections.execute']);
}); });
@@ -6,11 +6,13 @@ import test from 'node:test';
const root = process.cwd(); const root = process.cwd();
const read = (relative: string) => fs.readFileSync(path.join(root, relative), 'utf8'); const read = (relative: string) => fs.readFileSync(path.join(root, relative), 'utf8');
test('D5.6.4 generates inspection codes server-side with an annual transactional sequence', () => { test('F4 keeps server-side annual numbering but emits the institutional INSP-date format', () => {
const generator = read('src/inspection-visits/inspection-visit-code.ts'); const generator = read('src/inspection-visits/inspection-visit-code.ts');
assert.match(generator, /INS-\$\{year\}-\$\{String\(sequence\)\.padStart\(6, '0'\)\}/); assert.match(generator, /INSP-\$\{String\(sequence\)\.padStart\(5, '0'\)\}-\$\{datePart\}/);
assert.match(generator, /DD-MM-YY/);
assert.match(generator, /pg_advisory_xact_lock/); assert.match(generator, /pg_advisory_xact_lock/);
assert.match(generator, /America\/Argentina\/Mendoza/); assert.match(generator, /America\/Argentina\/Mendoza/);
assert.match(generator, /INS-\$\{year\}-%/);
}); });
test('D5.6.4 creates one inspection from Area Operator start and lead inspector without manual title or end date', () => { test('D5.6.4 creates one inspection from Area Operator start and lead inspector without manual title or end date', () => {
@@ -0,0 +1,30 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
const root = process.cwd();
const read = (relative: string) => fs.readFileSync(path.join(root, relative), 'utf8');
test('F4 locks an Act without requiring an artificial Finding', () => {
const service = read('src/inspection-closing/inspection-act-lifecycle.service.ts');
assert.doesNotMatch(service, /INSPECTION_ACT_FINDING_REQUIRED/);
assert.doesNotMatch(service, /COUNT\(\*\).*inspection_findings[\s\S]*< 1/);
assert.match(service, /requireVerificationResults/);
});
test('F4 freezes urgency and the exact deadline policy at lock time', () => {
const service = read('src/inspection-closing/inspection-act-lifecycle.service.ts');
assert.match(service, /snapshotForLock/);
assert.match(service, /deadline_policy_snapshot/);
assert.match(service, /locked_sha256/);
assert.match(service, /LOCKED_PENDING_SIGNATURE/);
});
test('F4 removes the reopen route once an Act has been blocked', () => {
const controller = read('src/inspection-closing/inspection-closing.controller.ts');
assert.doesNotMatch(controller, /@Post\('reopen'\)/);
assert.doesNotMatch(controller, /@Post\('ready'\)/);
const lifecycle = read('src/inspection-closing/inspection-act-lifecycle.controller.ts');
assert.match(lifecycle, /@Post\('lock'\)/);
});
@@ -0,0 +1,15 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
const root = process.cwd();
const read = (relative: string) => fs.readFileSync(path.join(root, relative), 'utf8');
test('F4 grants deadline administration to system admin and the current JEFE-equivalent supervisor role', () => {
const migration = read('src/database/migrations/1790038800000-phase-f4-deadline-administration.ts');
assert.match(migration, /inspection_deadlines\.manage/);
assert.match(migration, /\('admin', 'inspection_deadlines\.manage'\)/);
assert.match(migration, /\('supervisor', 'inspection_deadlines\.manage'\)/);
assert.doesNotMatch(migration, /\('inspector', 'inspection_deadlines\.manage'\)/);
});
@@ -0,0 +1,43 @@
import 'reflect-metadata';
import assert from 'node:assert/strict';
import test from 'node:test';
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { REQUIRED_PERMISSIONS_KEY } from '../../src/authorization/decorators/require-permissions.decorator';
import { InspectionDeadlineDayType } from '../../src/database/entities';
import { UpdateInspectionDeadlinePolicyDto } from '../../src/inspection-deadlines/dto/update-inspection-deadline-policy.dto';
import { InspectionDeadlinesController } from '../../src/inspection-deadlines/inspection-deadlines.controller';
function permissions(method: string): string[] {
const handler = InspectionDeadlinesController.prototype[
method as keyof InspectionDeadlinesController
];
return Reflect.getMetadata(REQUIRED_PERMISSIONS_KEY, handler) as string[];
}
test('F4 deadline administration is protected by one dedicated JEFE permission', () => {
assert.deepEqual(permissions('policies'), ['inspection_deadlines.manage']);
assert.deepEqual(permissions('updatePolicy'), ['inspection_deadlines.manage']);
assert.deepEqual(permissions('nonWorkingDays'), ['inspection_deadlines.manage']);
assert.deepEqual(permissions('upsertNonWorkingDay'), ['inspection_deadlines.manage']);
});
test('F4 deadline policy accepts business or calendar days with a bounded amount', async () => {
const valid = plainToInstance(UpdateInspectionDeadlinePolicyDto, {
days: 5,
dayType: InspectionDeadlineDayType.BUSINESS,
});
assert.equal((await validate(valid)).length, 0);
const calendar = plainToInstance(UpdateInspectionDeadlinePolicyDto, {
days: 10,
dayType: InspectionDeadlineDayType.CALENDAR,
});
assert.equal((await validate(calendar)).length, 0);
const invalid = plainToInstance(UpdateInspectionDeadlinePolicyDto, {
days: 0,
dayType: 'UNKNOWN',
});
assert.ok((await validate(invalid)).length >= 2);
});
@@ -0,0 +1,36 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
const root = process.cwd();
const read = (relative: string) => fs.readFileSync(path.join(root, relative), 'utf8');
test('F4 foundation snapshots Act deadlines and seeds the institutional 5/10 business-day policy', () => {
const migration = read('src/database/migrations/1790035200000-phase-f4-document-flow-foundation.ts');
assert.match(migration, /deadline_policy_snapshot jsonb/);
assert.match(migration, /'URGENT', 5, 'BUSINESS', 'ACT_DATE'/);
assert.match(migration, /'NON_URGENT', 10, 'BUSINESS', 'GEDO_LOAD_DATE'/);
assert.match(migration, /inspection_non_working_days/);
});
test('F4 foundation models recurrence as a new Finding linked to its antecedent', () => {
const finding = read('src/database/entities/inspection-finding.entity.ts');
const migration = read('src/database/migrations/1790035200000-phase-f4-document-flow-foundation.ts');
assert.match(finding, /isRecurrence!/);
assert.match(finding, /antecedentFindingId!/);
assert.match(migration, /fk_inspection_findings_antecedent/);
assert.match(migration, /chk_inspection_findings_recurrence_link/);
});
test('F4 foundation separates editable INF content, GEDO IF officialization and report-level follow-up', () => {
const report = read('src/database/entities/inspection-report.entity.ts');
const migration = read('src/database/migrations/1790035200000-phase-f4-document-flow-foundation.ts');
assert.match(report, /executiveSummary!/);
assert.match(report, /description!/);
assert.match(report, /gedoIfIdentifier!/);
assert.match(report, /gedoOfficializedOn!/);
assert.match(migration, /inspection_report_follow_ups/);
assert.match(migration, /COMPANY_NOTE/);
assert.doesNotMatch(migration, /finding_id uuid NOT NULL[\s\S]*inspection_report_follow_ups/);
});
@@ -0,0 +1,24 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
const root = process.cwd();
const read = (relative: string) => fs.readFileSync(path.join(root, relative), 'utf8');
test('F4 institutional numbering keeps Inspection, Act and INF date-readable and coherent', () => {
const migration = read('src/database/migrations/1790046000000-phase-f4-document-numbering.ts');
assert.match(migration, /INSP-/);
assert.match(migration, /ACT-/);
assert.match(migration, /INF-/);
assert.match(migration, /DD-MM-YY/);
assert.match(migration, /LPAD\(act_number::text, 5, '0'\)/);
});
test('F4 gives one INF the same annual correlativo as its one Act', () => {
const migration = read('src/database/migrations/1790046000000-phase-f4-document-numbering.ts');
assert.match(migration, /report_number = act\.act_number/);
assert.match(migration, /NEW\.report_number := source_act\.act_number/);
assert.match(migration, /NEW\.report_year := source_act\.act_year/);
assert.match(migration, /trg_dhv2_f4_set_report_code/);
});
@@ -0,0 +1,28 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
const root = process.cwd();
const read = (relative: string) => fs.readFileSync(path.join(root, relative), 'utf8');
test('F4 forbids closing an Inspection while any Act still awaits company outcome', () => {
const service = read('src/inspection-visits/inspection-visit-closure.service.ts');
assert.match(service, /INSPECTION_VISIT_COMPANY_OUTCOME_PENDING/);
assert.match(service, /signature\.status IN \('SIGNED', 'REFUSED', 'ABSENT'\)/);
assert.match(service, /recipient|signature|empresa/i);
});
test('F4 requires all non-cancelled Acts to be sealed before closing the Inspection', () => {
const service = read('src/inspection-visits/inspection-visit-closure.service.ts');
assert.match(service, /INSPECTION_VISIT_ACTS_NOT_SEALED/);
assert.match(service, /act\.closure_sha256 IS NULL/);
assert.match(service, /allActsSealed: true/);
});
test('F4 stamps sealed_at when an Act reaches the physical CLOSED state', () => {
const migration = read('src/database/migrations/1790056800000-phase-f4-sealed-acts.ts');
assert.match(migration, /sealed_at/);
assert.match(migration, /dhv2_f4_stamp_act_seal/);
assert.match(migration, /NEW\.status = 'CLOSED'/);
});
@@ -0,0 +1,39 @@
import 'reflect-metadata';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import { REQUIRED_PERMISSIONS_KEY } from '../../src/authorization/decorators/require-permissions.decorator';
import { InspectionFindingRecurrenceController } from '../../src/inspection-findings/inspection-finding-recurrence.controller';
const root = process.cwd();
const read = (relative: string) => fs.readFileSync(path.join(root, relative), 'utf8');
function permissions(method: string): string[] {
const handler = InspectionFindingRecurrenceController.prototype[
method as keyof InspectionFindingRecurrenceController
];
return Reflect.getMetadata(REQUIRED_PERMISSIONS_KEY, handler) as string[];
}
test('F4 exposes recurrence candidates for the selected Inventory and creates a new Finding', () => {
assert.deepEqual(permissions('candidates'), ['inspection_findings.read']);
assert.deepEqual(permissions('create'), ['inspection_findings.create']);
});
test('F4 recurrence only accepts an unresolved prior Finding from the same Inventory context', () => {
const service = read('src/inspection-findings/inspection-finding-recurrence.service.ts');
assert.match(service, /finding\.status = 'OPEN'/);
assert.match(service, /INSPECTION_FINDING_ANTECEDENT_RESOLVED/);
assert.match(service, /INSPECTION_FINDING_RECURRENCE_ASSET_NOT_IN_ACT/);
assert.match(service, /finding\.act_id <> \$2/);
});
test('F4 creates recurrence append-only as a new Finding linked to its antecedent', () => {
const service = read('src/inspection-findings/inspection-finding-recurrence.service.ts');
assert.match(service, /is_recurrence/);
assert.match(service, /antecedent_finding_id/);
assert.match(service, /INSERT INTO inspection_finding_versions/);
assert.match(service, /recurrence: true/);
assert.doesNotMatch(service, /UPDATE inspection_findings[\s\S]*antecedentFindingId/);
});
@@ -0,0 +1,25 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
const root = process.cwd();
const read = (relative: string) => fs.readFileSync(path.join(root, relative), 'utf8');
test('F4 removes Survey modules from the active API', () => {
const app = read('src/app.module.ts');
assert.doesNotMatch(app, /SurveyPlanningModule/);
assert.doesNotMatch(app, /SurveyExecutionModule/);
const entities = read('src/database/entities/index.ts');
assert.doesNotMatch(entities, /SurveyCampaign|SurveyTargetReport|survey-campaign|survey-target-report/);
});
test('F4 removes survey permissions and survey-specific tables through a new cleanup migration', () => {
const migration = read('src/database/migrations/1790053200000-phase-f4-remove-survey-subsystem.ts');
assert.match(migration, /code LIKE 'surveys\.\%'/);
assert.match(migration, /DROP TABLE IF EXISTS survey_target_report_media/);
assert.match(migration, /DROP TABLE IF EXISTS survey_target_report_versions/);
assert.match(migration, /DROP TABLE IF EXISTS survey_target_reports/);
assert.match(migration, /DROP TABLE IF EXISTS survey_campaign_targets/);
assert.match(migration, /DROP TABLE IF EXISTS survey_campaigns/);
});
@@ -0,0 +1,23 @@
import 'reflect-metadata';
import assert from 'node:assert/strict';
import test from 'node:test';
import { REQUIRED_PERMISSIONS_KEY } from '../../src/authorization/decorators/require-permissions.decorator';
import {
InspectionReportDossierController,
InspectionReportFollowUpFileController,
} from '../../src/inspection-reports/inspection-report-dossier.controller';
function permissions(controller: object, method: string): string[] {
const handler = controller[method as keyof typeof controller];
return Reflect.getMetadata(REQUIRED_PERMISSIONS_KEY, handler) as string[];
}
test('F4 report dossier separates read, edit, GEDO officialization and append-only follow-up', () => {
assert.deepEqual(permissions(InspectionReportDossierController.prototype, 'get'), ['inspection_reports.read']);
assert.deepEqual(permissions(InspectionReportDossierController.prototype, 'updateContent'), ['inspection_reports.edit']);
assert.deepEqual(permissions(InspectionReportDossierController.prototype, 'officializeGedo'), ['inspection_reports.officialize']);
assert.deepEqual(permissions(InspectionReportDossierController.prototype, 'gedoPdf'), ['inspection_reports.read']);
assert.deepEqual(permissions(InspectionReportDossierController.prototype, 'followUps'), ['inspection_reports.read']);
assert.deepEqual(permissions(InspectionReportDossierController.prototype, 'createFollowUp'), ['inspection_reports.follow_up']);
assert.deepEqual(permissions(InspectionReportFollowUpFileController.prototype, 'content'), ['inspection_reports.read']);
});
@@ -0,0 +1,30 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
const root = process.cwd();
const read = (relative: string) => fs.readFileSync(path.join(root, relative), 'utf8');
test('F4 keeps company responses at INF level and never asks for a Finding selector', () => {
const dto = read('src/inspection-reports/dto/create-inspection-report-follow-up.dto.ts');
const service = read('src/inspection-reports/inspection-report-dossier.service.ts');
assert.doesNotMatch(dto, /findingId|findingIds/);
assert.match(service, /InspectionReportFollowUpType\.COMPANY_NOTE/);
assert.doesNotMatch(service, /inspection_findings.*follow_up_id/);
});
test('F4 treats GEDO IF as the legal officialization of the existing INF and starts non-urgent deadline there', () => {
const service = read('src/inspection-reports/inspection-report-dossier.service.ts');
assert.match(service, /gedo_if_identifier/);
assert.match(service, /gedo_officialized_on/);
assert.match(service, /applyGedoOfficialization/);
assert.match(service, /INSPECTION_REPORT_ALREADY_OFFICIALIZED/);
});
test('F4 removes the active Director review controller from the report module', () => {
const moduleSource = read('src/inspection-reports/inspection-reports.module.ts');
assert.doesNotMatch(moduleSource, /InspectionReportReviewController/);
assert.doesNotMatch(moduleSource, /InspectionReportReviewService/);
assert.match(moduleSource, /InspectionReportDossierController/);
});
@@ -0,0 +1,18 @@
import 'reflect-metadata';
import assert from 'node:assert/strict';
import test from 'node:test';
import { REQUIRED_PERMISSIONS_KEY } from '../../src/authorization/decorators/require-permissions.decorator';
import { SmtpSettingsController } from '../../src/inspection-reports/smtp-settings.controller';
function permissions(method: string): string[] {
const handler = SmtpSettingsController.prototype[
method as keyof SmtpSettingsController
];
return Reflect.getMetadata(REQUIRED_PERMISSIONS_KEY, handler) as string[];
}
test('F4 SMTP configuration is restricted to the programmer Superadmin permission', () => {
assert.deepEqual(permissions('get'), ['system_mail.manage']);
assert.deepEqual(permissions('update'), ['system_mail.manage']);
assert.deepEqual(permissions('test'), ['system_mail.manage']);
});
@@ -0,0 +1,29 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
const root = process.cwd();
const read = (relative: string) => fs.readFileSync(path.join(root, relative), 'utf8');
test('F4 stores SMTP password encrypted and never exposes it through public settings', () => {
const crypto = read('src/inspection-reports/smtp-settings-crypto.ts');
const settings = read('src/inspection-reports/smtp-settings.service.ts');
assert.match(crypto, /aes-256-gcm/);
assert.match(settings, /passwordConfigured/);
assert.doesNotMatch(settings, /passwordEncrypted:\s*row\.passwordEncrypted/);
});
test('F4 sends the editable Report Word to the responsible Inspector instead of creating new Director deliveries', () => {
const delivery = read('src/inspection-reports/inspection-document-delivery.service.ts');
assert.match(delivery, /documentKind: 'REPORT_WORD',[\s\S]*recipientKind: 'INSPECTOR'/);
assert.doesNotMatch(delivery, /documentKind: 'REPORT_WORD',[\s\S]{0,180}recipientKind: 'DIRECTOR'/);
assert.match(delivery, /revisión y edición del inspector responsable antes de su carga en GEDO/);
});
test('F4 keeps ENV SMTP only as migration fallback and prioritizes enabled Superadmin DB settings', () => {
const smtp = read('src/inspection-reports/smtp-delivery.service.ts');
assert.match(smtp, /source: 'DATABASE'/);
assert.match(smtp, /source: 'ENV'/);
assert.match(smtp, /FROM smtp_settings/);
});
@@ -1,18 +0,0 @@
import 'reflect-metadata';
import assert from 'node:assert/strict';
import test from 'node:test';
import { REQUIRED_PERMISSIONS_KEY } from '../../src/authorization/decorators/require-permissions.decorator';
import { SurveyExecutionController } from '../../src/survey-execution/survey-execution.controller';
function permissionFor(method: string): string[] {
const controller = SurveyExecutionController.prototype;
const handler = controller[method as keyof typeof controller];
return Reflect.getMetadata(REQUIRED_PERMISSIONS_KEY, handler) as string[];
}
test('survey report endpoints separate reading, capture and review', () => {
assert.deepEqual(permissionFor('get'), ['surveys.read_reports']);
assert.deepEqual(permissionFor('save'), ['surveys.capture']);
assert.deepEqual(permissionFor('submit'), ['surveys.capture']);
assert.deepEqual(permissionFor('review'), ['surveys.review']);
});
@@ -1,48 +0,0 @@
import 'reflect-metadata';
import assert from 'node:assert/strict';
import test from 'node:test';
import { validate } from 'class-validator';
import { ReviewSurveyReportDto } from '../../src/survey-execution/dto/review-survey-report.dto';
import { SaveSurveyReportDto } from '../../src/survey-execution/dto/save-survey-report.dto';
const MEDIA_ID = '16e54e65-60cf-4739-b0d1-ccdd904fbfd5';
test('field report DTO accepts outcome, GPS and protected media references', async () => {
const dto = Object.assign(new SaveSurveyReportDto(), {
outcome: 'CONFIRMED',
observedAt: '2026-08-14T18:00:00.000Z',
latitude: -32.8895,
longitude: -68.8458,
accuracyM: 4.25,
notes: 'Activo verificado en campo.',
mediaIds: [MEDIA_ID],
});
assert.deepEqual(await validate(dto), []);
});
test('field report DTO rejects invalid coordinates and repeated evidence', async () => {
const dto = Object.assign(new SaveSurveyReportDto(), {
outcome: 'CONFIRMED',
observedAt: 'not-a-date',
latitude: -95,
longitude: -68.8458,
accuracyM: 4.25,
mediaIds: [MEDIA_ID, MEDIA_ID],
});
const properties = new Set((await validate(dto)).map((error) => error.property));
assert.equal(properties.has('observedAt'), true);
assert.equal(properties.has('latitude'), true);
assert.equal(properties.has('mediaIds'), true);
});
test('review DTO only accepts explicit approval or rejection decisions', async () => {
const dto = Object.assign(new ReviewSurveyReportDto(), {
decision: 'APPROVE',
notes: null,
});
assert.deepEqual(await validate(dto), []);
dto.decision = 'MAYBE' as typeof dto.decision;
const properties = new Set((await validate(dto)).map((error) => error.property));
assert.equal(properties.has('decision'), true);
});
@@ -1,31 +0,0 @@
import 'reflect-metadata';
import assert from 'node:assert/strict';
import test from 'node:test';
import { REQUIRED_PERMISSIONS_KEY } from '../../src/authorization/decorators/require-permissions.decorator';
import {
SurveyCampaignsController,
SurveyCampaignTargetsController,
} from '../../src/survey-planning/survey-campaigns.controller';
function permissionFor(controller: object, method: string): string[] {
const handler = controller[method as keyof typeof controller];
return Reflect.getMetadata(REQUIRED_PERMISSIONS_KEY, handler) as string[];
}
test('campaign endpoints separate reading, management and assignment', () => {
const controller = SurveyCampaignsController.prototype;
assert.deepEqual(permissionFor(controller, 'list'), ['surveys.read']);
assert.deepEqual(permissionFor(controller, 'get'), ['surveys.read']);
assert.deepEqual(permissionFor(controller, 'assignees'), ['surveys.assign']);
assert.deepEqual(permissionFor(controller, 'create'), ['surveys.manage']);
assert.deepEqual(permissionFor(controller, 'update'), ['surveys.manage']);
assert.deepEqual(permissionFor(controller, 'changeStatus'), ['surveys.manage']);
assert.deepEqual(permissionFor(controller, 'addTarget'), ['surveys.manage']);
});
test('target planning, assignment and execution use distinct permissions', () => {
const controller = SurveyCampaignTargetsController.prototype;
assert.deepEqual(permissionFor(controller, 'update'), ['surveys.manage']);
assert.deepEqual(permissionFor(controller, 'assign'), ['surveys.assign']);
assert.deepEqual(permissionFor(controller, 'changeStatus'), ['surveys.execute']);
});
@@ -1,35 +0,0 @@
import 'reflect-metadata';
import assert from 'node:assert/strict';
import test from 'node:test';
import { validate } from 'class-validator';
import { AddSurveyTargetDto } from '../../src/survey-planning/dto/add-survey-target.dto';
import { CreateSurveyCampaignDto } from '../../src/survey-planning/dto/create-survey-campaign.dto';
const ASSET_ID = '16e54e65-60cf-4739-b0d1-ccdd904fbfd5';
test('campaign DTO accepts dynamic asset scope and ISO planning dates', async () => {
const dto = Object.assign(new CreateSurveyCampaignDto(), {
code: 'REL-2026-001',
name: 'Campaña operativa',
description: null,
plannedStartAt: '2026-08-15T12:00:00.000Z',
plannedEndAt: '2026-08-20T12:00:00.000Z',
scopeAssetId: ASSET_ID,
coordinatorUserId: null,
});
assert.deepEqual(await validate(dto), []);
});
test('target DTO references an existing Maestro asset instead of copying it', async () => {
const dto = Object.assign(new AddSurveyTargetDto(), {
assetId: ASSET_ID,
assignedUserId: null,
dueAt: null,
instructions: null,
});
assert.deepEqual(await validate(dto), []);
dto.assetId = 'not-a-uuid';
const properties = new Set((await validate(dto)).map((error) => error.property));
assert.equal(properties.has('assetId'), true);
});