Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ccca7a6ff1 | ||
|
|
63671686a9 | ||
|
|
8baba89ce6 | ||
|
|
ad65b44142 | ||
|
|
d0cc7c6f9d | ||
|
|
b80f83ed5f | ||
|
|
fbc63fafb5 | ||
|
|
edc05a5f50 | ||
|
|
ae583a8e45 | ||
|
|
b160e7344b | ||
|
|
41b52d34bf | ||
|
|
7dab175958 | ||
|
|
f1dbb75834 | ||
|
|
e9b318fdb5 | ||
|
|
890b54f7c8 | ||
|
|
3634768f9a | ||
|
|
fbe8f2e8cf | ||
|
|
367c7df45a | ||
|
|
0f9aa589b5 | ||
|
|
dac6c94370 | ||
|
|
1cd11e83ab | ||
|
|
eb89680c32 | ||
|
|
cbab839935 | ||
|
|
ce18f5d1a3 |
@@ -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
@@ -3,28 +3,27 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||
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 { AuthorizationModule } from './authorization/authorization.module';
|
||||
import { PermissionsGuard } from './authorization/guards/permissions.guard';
|
||||
import { AssetImportsModule } from './asset-imports/asset-imports.module';
|
||||
import { AssetMasterModule } from './asset-master/asset-master.module';
|
||||
import { AuditModule } from './audit/audit.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
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 { PhaseADataModule } from './core-data/phase-a-data.module';
|
||||
import { DashboardModule } from './dashboard/dashboard.module';
|
||||
import { HealthController } from './health.controller';
|
||||
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 { InspectionFindingsModule } from './inspection-findings/inspection-findings.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 { 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 {
|
||||
const value = config.get<string>(key);
|
||||
@@ -34,10 +33,7 @@ function required(config: ConfigService, key: string): string {
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
ignoreEnvFile: true,
|
||||
}),
|
||||
ConfigModule.forRoot({ isGlobal: true, ignoreEnvFile: true }),
|
||||
TypeOrmModule.forRootAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
@@ -55,13 +51,7 @@ function required(config: ConfigService, key: string): string {
|
||||
connectTimeoutMS: 5000,
|
||||
}),
|
||||
}),
|
||||
ThrottlerModule.forRoot([
|
||||
{
|
||||
name: 'default',
|
||||
ttl: 60_000,
|
||||
limit: 120,
|
||||
},
|
||||
]),
|
||||
ThrottlerModule.forRoot([{ name: 'default', ttl: 60_000, limit: 120 }]),
|
||||
PhaseADataModule,
|
||||
AuditModule,
|
||||
AuthorizationModule,
|
||||
@@ -69,12 +59,11 @@ function required(config: ConfigService, key: string): string {
|
||||
AdministrationModule,
|
||||
DashboardModule,
|
||||
AssetMasterModule,
|
||||
SurveyPlanningModule,
|
||||
SurveyExecutionModule,
|
||||
InspectionVisitsModule,
|
||||
InspectionActsModule,
|
||||
InspectionFindingsModule,
|
||||
InspectionClosingModule,
|
||||
InspectionDeadlinesModule,
|
||||
InspectionReportsModule,
|
||||
InspectionVerificationsModule,
|
||||
ActAdministrationModule,
|
||||
|
||||
@@ -6,14 +6,8 @@ export { Role } from './role.entity';
|
||||
export { UserRole } from './user-role.entity';
|
||||
export { User, UserStatus } from './user.entity';
|
||||
export { Asset, AssetDataOrigin, AssetInformationStatus, AssetOperationalStatus } from './asset.entity';
|
||||
export {
|
||||
AssetVersion,
|
||||
AssetVersionChangeType,
|
||||
} from './asset-version.entity';
|
||||
export {
|
||||
AssetAttributeDataType,
|
||||
AssetAttributeDefinition,
|
||||
} from './asset-attribute-definition.entity';
|
||||
export { AssetVersion, AssetVersionChangeType } from './asset-version.entity';
|
||||
export { AssetAttributeDataType, AssetAttributeDefinition } from './asset-attribute-definition.entity';
|
||||
export { AssetAttributeValue } from './asset-attribute-value.entity';
|
||||
export { AreaCompanyRelation, AreaOrganizationRole } from './area-company-relation.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 { AssetTypeParentRule } from './asset-type-parent-rule.entity';
|
||||
export { AssetType, AssetTypeOperationalRole } from './asset-type.entity';
|
||||
export {
|
||||
AssetGeometry,
|
||||
AssetGeometrySource,
|
||||
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 { AssetGeometry, AssetGeometrySource, AssetGeometryType } from './asset-geometry.entity';
|
||||
export { AssetMedia, AssetMediaKind, AssetMediaSource } from './asset-media.entity';
|
||||
export { InspectionVisit, InspectionVisitStatus } from './inspection-visit.entity';
|
||||
export { InspectionVisitAsset, InspectionVisitAssetPlanningSource } from './inspection-visit-asset.entity';
|
||||
export { InspectionVisitMember } from './inspection-visit-member.entity';
|
||||
export {
|
||||
DocumentAnnualSequence,
|
||||
DocumentSequenceType,
|
||||
} from './document-annual-sequence.entity';
|
||||
export {
|
||||
InspectionAct,
|
||||
InspectionActStatus,
|
||||
} from './inspection-act.entity';
|
||||
export { DocumentAnnualSequence, DocumentSequenceType } from './document-annual-sequence.entity';
|
||||
export { InspectionAct, InspectionActStatus, InspectionActUrgency, InspectionDeadlineBasis, InspectionDeadlineDayType } from './inspection-act.entity';
|
||||
export { InspectionActAsset } from './inspection-act-asset.entity';
|
||||
export {
|
||||
InspectionReport,
|
||||
InspectionReportPdfStatus,
|
||||
InspectionReportReviewStatus,
|
||||
InspectionReportStatus,
|
||||
InspectionReportWordStatus,
|
||||
} from './inspection-report.entity';
|
||||
export {
|
||||
InspectionActVersion,
|
||||
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 { InspectionDeadlinePolicy } from './inspection-deadline-policy.entity';
|
||||
export { InspectionNonWorkingDay } from './inspection-non-working-day.entity';
|
||||
export { InspectionReport, InspectionReportPdfStatus, InspectionReportReviewStatus, InspectionReportStatus, InspectionReportWordStatus } from './inspection-report.entity';
|
||||
export { InspectionReportFollowUp, InspectionReportFollowUpType } from './inspection-report-follow-up.entity';
|
||||
export { InspectionReportFollowUpFile } from './inspection-report-follow-up-file.entity';
|
||||
export { InspectionActVersion, 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 { FindingCatalogItem } from './finding-catalog-item.entity';
|
||||
export { FindingCatalogItemAssetType } from './finding-catalog-item-asset-type.entity';
|
||||
export { FindingCatalogAssetTypeProfile } from './finding-catalog-asset-type-profile.entity';
|
||||
export { FindingCatalogAssetOverride } from './finding-catalog-asset-override.entity';
|
||||
export { FindingCatalogProposal, FindingCatalogProposalStatus } from './finding-catalog-proposal.entity';
|
||||
export {
|
||||
InspectionFinding,
|
||||
InspectionFindingResponseDueBasis,
|
||||
InspectionFindingStatus,
|
||||
} from './inspection-finding.entity';
|
||||
export {
|
||||
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';
|
||||
export { InspectionFinding, InspectionFindingResponseDueBasis, InspectionFindingStatus } from './inspection-finding.entity';
|
||||
export { 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 { AuthSession } from './auth-session.entity';
|
||||
@@ -154,18 +72,17 @@ import { AreaLegalRightOrganization } from './area-legal-right-organization.enti
|
||||
import { AssetGeometry } from './asset-geometry.entity';
|
||||
import { AssetVersion } from './asset-version.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 { InspectionVisitAsset } from './inspection-visit-asset.entity';
|
||||
import { InspectionVisitMember } from './inspection-visit-member.entity';
|
||||
import { DocumentAnnualSequence } from './document-annual-sequence.entity';
|
||||
import { InspectionAct } from './inspection-act.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 { InspectionReportFollowUp } from './inspection-report-follow-up.entity';
|
||||
import { InspectionReportFollowUpFile } from './inspection-report-follow-up-file.entity';
|
||||
import { InspectionActVersion } from './inspection-act-version.entity';
|
||||
import { InspectionActResponsible } from './inspection-act-responsible.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';
|
||||
|
||||
export const PHASE_A_ENTITIES = [
|
||||
User,
|
||||
Role,
|
||||
Permission,
|
||||
UserRole,
|
||||
RolePermission,
|
||||
AuthSession,
|
||||
AuditEvent,
|
||||
AssetType,
|
||||
AssetTypeParentRule,
|
||||
AssetAttributeDefinition,
|
||||
Asset,
|
||||
AssetAttributeValue,
|
||||
AreaCompanyRelation,
|
||||
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,
|
||||
User, Role, Permission, UserRole, RolePermission, AuthSession, AuditEvent,
|
||||
AssetType, AssetTypeParentRule, AssetAttributeDefinition, Asset, AssetAttributeValue,
|
||||
AreaCompanyRelation, OrganizationProfile, OrganizationMembership, SourceDocument,
|
||||
AssetSourceDocument, AssetExternalIdentifier, AreaLegalRight, AreaLegalRightOrganization,
|
||||
AssetGeometry, AssetVersion, AssetMedia, InspectionVisit, InspectionVisitAsset,
|
||||
InspectionVisitMember, DocumentAnnualSequence, InspectionAct, InspectionActAsset,
|
||||
InspectionDeadlinePolicy, InspectionNonWorkingDay, InspectionReport,
|
||||
InspectionReportFollowUp, InspectionReportFollowUpFile, 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',
|
||||
}
|
||||
|
||||
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' })
|
||||
@Index('uq_inspection_acts_one_draft_per_visit', ['visitId'], { unique: true, where: "status = 'DRAFT'" })
|
||||
@Index('uq_inspection_acts_year_number', ['actYear', 'actNumber'], { unique: true })
|
||||
@@ -47,6 +62,42 @@ export class InspectionAct extends TimestampedEntity {
|
||||
@Column({ type: 'text', nullable: true })
|
||||
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 })
|
||||
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_asset_status', ['assetId', 'status'])
|
||||
@Index('idx_inspection_findings_catalog_item', ['catalogItemId'])
|
||||
@Index('idx_inspection_findings_antecedent', ['antecedentFindingId'])
|
||||
export class InspectionFinding extends TimestampedEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
@@ -61,6 +62,12 @@ export class InspectionFinding extends TimestampedEntity {
|
||||
@Column({ type: 'smallint', nullable: true })
|
||||
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 })
|
||||
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('idx_inspection_reports_generated_at', ['generatedAt'])
|
||||
@Index('idx_inspection_reports_status', ['status', 'pdfStatus'])
|
||||
@Index('idx_inspection_reports_gedo_if_identifier', ['gedoIfIdentifier'])
|
||||
export class InspectionReport extends TimestampedEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
@@ -80,6 +81,57 @@ export class InspectionReport extends TimestampedEntity {
|
||||
@Column({ name: 'word_error', type: 'varchar', length: 500, nullable: true })
|
||||
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 })
|
||||
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);
|
||||
}
|
||||
|
||||
@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')
|
||||
@RequirePermissions('inspection_closure.sign')
|
||||
@UseInterceptors(FileInterceptor('file', {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuditModule } from '../audit/audit.module';
|
||||
import { InspectionDeadlinesModule } from '../inspection-deadlines/inspection-deadlines.module';
|
||||
import { InspectionReportsModule } from '../inspection-reports/inspection-reports.module';
|
||||
import { InspectionActLifecycleController } from './inspection-act-lifecycle.controller';
|
||||
import { InspectionActLifecycleService } from './inspection-act-lifecycle.service';
|
||||
import {
|
||||
InspectionClosingController,
|
||||
InspectionSignatureContentController,
|
||||
@@ -8,8 +11,12 @@ import {
|
||||
import { InspectionClosingService } from './inspection-closing.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuditModule, InspectionReportsModule],
|
||||
controllers: [InspectionClosingController, InspectionSignatureContentController],
|
||||
providers: [InspectionClosingService],
|
||||
imports: [AuditModule, InspectionDeadlinesModule, InspectionReportsModule],
|
||||
controllers: [
|
||||
InspectionActLifecycleController,
|
||||
InspectionClosingController,
|
||||
InspectionSignatureContentController,
|
||||
],
|
||||
providers: [InspectionActLifecycleService, InspectionClosingService],
|
||||
})
|
||||
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 { CreateInspectionFindingDto } from './dto/create-inspection-finding.dto';
|
||||
import { ListInspectionFindingsQueryDto } from './dto/list-inspection-findings-query.dto';
|
||||
import { UpdateInspectionFindingFollowUpDto } from './dto/update-inspection-finding-follow-up.dto';
|
||||
import { UpdateInspectionFindingDto } from './dto/update-inspection-finding.dto';
|
||||
import { InspectionFindingsService } from './inspection-findings.service';
|
||||
|
||||
@@ -84,15 +83,4 @@ export class InspectionFindingsController {
|
||||
) {
|
||||
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 { FindingCatalogService } from './finding-catalog.service';
|
||||
import { F3FindingCatalogResolverService } from './f3-finding-catalog-resolver.service';
|
||||
import { InspectionFindingRecurrenceController } from './inspection-finding-recurrence.controller';
|
||||
import { InspectionFindingRecurrenceService } from './inspection-finding-recurrence.service';
|
||||
import {
|
||||
InspectionActFindingsController,
|
||||
InspectionFindingsController,
|
||||
@@ -21,6 +23,7 @@ import { InspectionEvidenceService } from './inspection-evidence.service';
|
||||
controllers: [
|
||||
FindingCatalogController,
|
||||
InspectionActFindingsController,
|
||||
InspectionFindingRecurrenceController,
|
||||
InspectionFindingsController,
|
||||
InspectionFindingEvidenceController,
|
||||
InspectionFindingCommunicationsController,
|
||||
@@ -31,6 +34,7 @@ import { InspectionEvidenceService } from './inspection-evidence.service';
|
||||
F3FindingCatalogResolverService,
|
||||
FindingCatalogMergeService,
|
||||
InspectionFindingsService,
|
||||
InspectionFindingRecurrenceService,
|
||||
InspectionEvidenceService,
|
||||
],
|
||||
exports: [
|
||||
@@ -38,6 +42,7 @@ import { InspectionEvidenceService } from './inspection-evidence.service';
|
||||
F3FindingCatalogResolverService,
|
||||
FindingCatalogMergeService,
|
||||
InspectionFindingsService,
|
||||
InspectionFindingRecurrenceService,
|
||||
],
|
||||
})
|
||||
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';
|
||||
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 { Transform } from 'class-transformer';
|
||||
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 { ConfigService } from '@nestjs/config';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { administrationAuditContext } from '../administration/common/administration-audit';
|
||||
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 { SmtpDeliveryService } from './smtp-delivery.service';
|
||||
|
||||
export type DeliveryRecipientKind = 'COMPANY' | 'OFFICE' | 'DIRECTOR' | 'INSPECTOR';
|
||||
export type DeliveryRecipientKind = 'COMPANY' | 'OFFICE' | 'INSPECTOR' | 'DIRECTOR';
|
||||
|
||||
export interface DeliveryRow {
|
||||
id: string;
|
||||
@@ -35,26 +34,18 @@ export class InspectionDocumentDeliveryService {
|
||||
private readonly word: InspectionReportWordService,
|
||||
private readonly smtp: SmtpDeliveryService,
|
||||
private readonly audit: AuditService,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
async settings() {
|
||||
const [row] = await this.dataSource.query(`
|
||||
SELECT
|
||||
office_email AS "officeEmail",
|
||||
director_email AS "directorEmail",
|
||||
updated_at AS "updatedAt"
|
||||
SELECT office_email AS "officeEmail", updated_at AS "updatedAt"
|
||||
FROM institutional_delivery_settings
|
||||
WHERE id=1
|
||||
`) as Array<{
|
||||
officeEmail: string | null;
|
||||
directorEmail: string | null;
|
||||
updatedAt: Date;
|
||||
}>;
|
||||
WHERE id = 1
|
||||
`) as Array<{ officeEmail: string | null; updatedAt: Date }>;
|
||||
return {
|
||||
...row,
|
||||
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();
|
||||
await this.dataSource.query(`
|
||||
UPDATE institutional_delivery_settings
|
||||
SET office_email=$1,director_email=$2,updated_by=$3,updated_at=CURRENT_TIMESTAMP
|
||||
WHERE id=1
|
||||
`, [dto.officeEmail ?? null, dto.directorEmail ?? null, principal.userId]);
|
||||
SET office_email = $1,
|
||||
updated_by = $2,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = 1
|
||||
`, [dto.officeEmail ?? null, principal.userId]);
|
||||
const after = await this.settings();
|
||||
await this.audit.record({
|
||||
...administrationAuditContext(principal, request),
|
||||
@@ -84,34 +77,34 @@ export class InspectionDocumentDeliveryService {
|
||||
async list() {
|
||||
const data = await this.dataSource.query(`
|
||||
SELECT
|
||||
d.id,
|
||||
d.act_id AS "actId",
|
||||
d.report_id AS "reportId",
|
||||
d.document_kind AS "documentKind",
|
||||
d.recipient_kind AS "recipientKind",
|
||||
d.recipient_asset_id AS "recipientAssetId",
|
||||
d.recipient_user_id AS "recipientUserId",
|
||||
d.recipient_email AS "recipientEmail",
|
||||
d.status,
|
||||
d.attempts,
|
||||
d.last_attempt_at AS "lastAttemptAt",
|
||||
d.sent_at AS "sentAt",
|
||||
d.provider_message_id AS "providerMessageId",
|
||||
d.last_error AS "lastError",
|
||||
d.created_at AS "createdAt",
|
||||
a.code AS "actCode",
|
||||
r.code AS "reportCode",
|
||||
delivery.id,
|
||||
delivery.act_id AS "actId",
|
||||
delivery.report_id AS "reportId",
|
||||
delivery.document_kind AS "documentKind",
|
||||
delivery.recipient_kind AS "recipientKind",
|
||||
delivery.recipient_asset_id AS "recipientAssetId",
|
||||
delivery.recipient_user_id AS "recipientUserId",
|
||||
delivery.recipient_email AS "recipientEmail",
|
||||
delivery.status,
|
||||
delivery.attempts,
|
||||
delivery.last_attempt_at AS "lastAttemptAt",
|
||||
delivery.sent_at AS "sentAt",
|
||||
delivery.provider_message_id AS "providerMessageId",
|
||||
delivery.last_error AS "lastError",
|
||||
delivery.created_at AS "createdAt",
|
||||
act.code AS "actCode",
|
||||
report.code AS "reportCode",
|
||||
recipient.name AS "recipientAssetName",
|
||||
CASE
|
||||
WHEN recipient_user.id IS NULL THEN NULL
|
||||
ELSE btrim(concat_ws(' ', recipient_user.first_name, recipient_user.last_name))
|
||||
END AS "recipientUserName"
|
||||
FROM inspection_document_deliveries d
|
||||
JOIN inspection_acts a ON a.id=d.act_id
|
||||
LEFT JOIN inspection_reports r ON r.id=d.report_id
|
||||
LEFT JOIN assets recipient ON recipient.id=d.recipient_asset_id
|
||||
LEFT JOIN users recipient_user ON recipient_user.id=d.recipient_user_id
|
||||
ORDER BY d.created_at DESC
|
||||
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
|
||||
LEFT JOIN assets recipient ON recipient.id = delivery.recipient_asset_id
|
||||
LEFT JOIN users recipient_user ON recipient_user.id = delivery.recipient_user_id
|
||||
ORDER BY delivery.created_at DESC
|
||||
LIMIT 200
|
||||
`);
|
||||
return { data };
|
||||
@@ -120,13 +113,14 @@ export class InspectionDocumentDeliveryService {
|
||||
async dispatchForAct(actId: string): Promise<void> {
|
||||
await this.pdf.ensure(actId).catch(() => undefined);
|
||||
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],
|
||||
) as Array<{ id: string }>;
|
||||
if (report) await this.word.ensure(report.id);
|
||||
await this.ensureRows(actId, report?.id ?? null);
|
||||
const rows = await this.rowsForAct(actId);
|
||||
for (const row of rows) await this.attempt(row).catch(() => undefined);
|
||||
for (const row of await this.rowsForAct(actId)) {
|
||||
await this.attempt(row).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async retry(
|
||||
@@ -156,42 +150,42 @@ export class InspectionDocumentDeliveryService {
|
||||
const rows = await this.dataSource.query(`
|
||||
SELECT id
|
||||
FROM inspection_document_deliveries
|
||||
WHERE status<>'SENT'
|
||||
WHERE status <> 'SENT'
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 100
|
||||
`) 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 };
|
||||
}
|
||||
|
||||
private async ensureRows(actId: string, reportId: string | null) {
|
||||
const [settings] = await this.dataSource.query(`
|
||||
SELECT office_email AS "officeEmail",director_email AS "directorEmail"
|
||||
SELECT office_email AS "officeEmail"
|
||||
FROM institutional_delivery_settings
|
||||
WHERE id=1
|
||||
`) as Array<{ officeEmail: string | null; directorEmail: string | null }>;
|
||||
WHERE id = 1
|
||||
`) as Array<{ officeEmail: string | null }>;
|
||||
|
||||
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
|
||||
JOIN assets asset ON asset.id=link.asset_id
|
||||
JOIN asset_types asset_type ON asset_type.id=asset.asset_type_id
|
||||
JOIN assets company ON company.id=COALESCE(
|
||||
JOIN assets asset ON asset.id = link.asset_id
|
||||
JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id
|
||||
JOIN assets company ON company.id = COALESCE(
|
||||
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
|
||||
WHERE link.act_id=$1 AND link.included=true
|
||||
LEFT JOIN organization_profiles profile ON profile.asset_id = company.id
|
||||
WHERE link.act_id = $1 AND link.included = true
|
||||
`, [actId]) as Array<{ id: string; email: string | null }>;
|
||||
|
||||
const [inspector] = await this.dataSource.query(`
|
||||
SELECT
|
||||
inspector.id,
|
||||
inspector.email
|
||||
SELECT inspector.id, inspector.email
|
||||
FROM inspection_acts act
|
||||
JOIN inspection_visits visit ON visit.id=act.visit_id
|
||||
JOIN users inspector ON inspector.id=visit.lead_inspector_user_id
|
||||
WHERE act.id=$1
|
||||
JOIN inspection_visits visit ON visit.id = act.visit_id
|
||||
JOIN users inspector ON inspector.id = visit.lead_inspector_user_id
|
||||
WHERE act.id = $1
|
||||
`, [actId]) as Array<{ id: string; email: string | null }>;
|
||||
|
||||
for (const company of companies) {
|
||||
@@ -229,19 +223,18 @@ export class InspectionDocumentDeliveryService {
|
||||
recipientKey: inspector.id,
|
||||
recipientEmail: inspector.email,
|
||||
});
|
||||
}
|
||||
|
||||
if (reportId) {
|
||||
await this.upsertRow({
|
||||
actId,
|
||||
reportId,
|
||||
documentKind: 'REPORT_WORD',
|
||||
recipientKind: 'DIRECTOR',
|
||||
recipientAssetId: null,
|
||||
recipientUserId: null,
|
||||
recipientKey: '00000000-0000-0000-0000-000000000000',
|
||||
recipientEmail: settings?.directorEmail ?? null,
|
||||
});
|
||||
if (reportId) {
|
||||
await this.upsertRow({
|
||||
actId,
|
||||
reportId,
|
||||
documentKind: 'REPORT_WORD',
|
||||
recipientKind: 'INSPECTOR',
|
||||
recipientAssetId: null,
|
||||
recipientUserId: inspector.id,
|
||||
recipientKey: inspector.id,
|
||||
recipientEmail: inspector.email,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,7 +242,7 @@ export class InspectionDocumentDeliveryService {
|
||||
actId: string;
|
||||
reportId: string | null;
|
||||
documentKind: 'ACT_PDF' | 'REPORT_WORD';
|
||||
recipientKind: DeliveryRecipientKind;
|
||||
recipientKind: Exclude<DeliveryRecipientKind, 'DIRECTOR'>;
|
||||
recipientAssetId: string | null;
|
||||
recipientUserId: string | null;
|
||||
recipientKey: string;
|
||||
@@ -258,23 +251,23 @@ export class InspectionDocumentDeliveryService {
|
||||
const initialStatus = input.recipientEmail ? 'PENDING' : 'WAITING_RECIPIENT';
|
||||
await this.dataSource.query(`
|
||||
INSERT INTO inspection_document_deliveries (
|
||||
act_id,report_id,document_kind,recipient_kind,
|
||||
recipient_asset_id,recipient_user_id,recipient_key,recipient_email,status
|
||||
act_id, report_id, document_kind, recipient_kind,
|
||||
recipient_asset_id, recipient_user_id, recipient_key, recipient_email, status
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7::uuid,$8,$9)
|
||||
ON CONFLICT (act_id,document_kind,recipient_kind,recipient_key) DO UPDATE SET
|
||||
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_user_id=COALESCE(EXCLUDED.recipient_user_id,inspection_document_deliveries.recipient_user_id),
|
||||
recipient_email=CASE
|
||||
WHEN inspection_document_deliveries.status='SENT' THEN inspection_document_deliveries.recipient_email
|
||||
ON CONFLICT (act_id, document_kind, recipient_kind, recipient_key) DO UPDATE SET
|
||||
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_user_id = COALESCE(EXCLUDED.recipient_user_id, inspection_document_deliveries.recipient_user_id),
|
||||
recipient_email = CASE
|
||||
WHEN inspection_document_deliveries.status = 'SENT' THEN inspection_document_deliveries.recipient_email
|
||||
ELSE EXCLUDED.recipient_email
|
||||
END,
|
||||
status=CASE
|
||||
WHEN inspection_document_deliveries.status='SENT' THEN 'SENT'
|
||||
status = CASE
|
||||
WHEN inspection_document_deliveries.status = 'SENT' THEN 'SENT'
|
||||
WHEN EXCLUDED.recipient_email IS NULL THEN 'WAITING_RECIPIENT'
|
||||
ELSE inspection_document_deliveries.status
|
||||
END,
|
||||
updated_at=CURRENT_TIMESTAMP
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
`, [
|
||||
input.actId,
|
||||
input.reportId,
|
||||
@@ -291,31 +284,45 @@ export class InspectionDocumentDeliveryService {
|
||||
private async rowsForAct(actId: string): Promise<DeliveryRow[]> {
|
||||
return this.dataSource.query(`
|
||||
SELECT
|
||||
d.id,d.act_id AS "actId",d.report_id AS "reportId",
|
||||
d.document_kind AS "documentKind",d.recipient_kind AS "recipientKind",
|
||||
d.recipient_asset_id AS "recipientAssetId",d.recipient_user_id AS "recipientUserId",
|
||||
d.recipient_email AS "recipientEmail",d.status,d.attempts,
|
||||
a.code AS "actCode",r.code AS "reportCode"
|
||||
FROM inspection_document_deliveries d
|
||||
JOIN inspection_acts a ON a.id=d.act_id
|
||||
LEFT JOIN inspection_reports r ON r.id=d.report_id
|
||||
WHERE d.act_id=$1
|
||||
ORDER BY d.created_at
|
||||
delivery.id,
|
||||
delivery.act_id AS "actId",
|
||||
delivery.report_id AS "reportId",
|
||||
delivery.document_kind AS "documentKind",
|
||||
delivery.recipient_kind AS "recipientKind",
|
||||
delivery.recipient_asset_id AS "recipientAssetId",
|
||||
delivery.recipient_user_id AS "recipientUserId",
|
||||
delivery.recipient_email AS "recipientEmail",
|
||||
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.act_id = $1
|
||||
ORDER BY delivery.created_at
|
||||
`, [actId]) as Promise<DeliveryRow[]>;
|
||||
}
|
||||
|
||||
private async load(id: string): Promise<DeliveryRow> {
|
||||
const [row] = await this.dataSource.query(`
|
||||
SELECT
|
||||
d.id,d.act_id AS "actId",d.report_id AS "reportId",
|
||||
d.document_kind AS "documentKind",d.recipient_kind AS "recipientKind",
|
||||
d.recipient_asset_id AS "recipientAssetId",d.recipient_user_id AS "recipientUserId",
|
||||
d.recipient_email AS "recipientEmail",d.status,d.attempts,
|
||||
a.code AS "actCode",r.code AS "reportCode"
|
||||
FROM inspection_document_deliveries d
|
||||
JOIN inspection_acts a ON a.id=d.act_id
|
||||
LEFT JOIN inspection_reports r ON r.id=d.report_id
|
||||
WHERE d.id=$1
|
||||
delivery.id,
|
||||
delivery.act_id AS "actId",
|
||||
delivery.report_id AS "reportId",
|
||||
delivery.document_kind AS "documentKind",
|
||||
delivery.recipient_kind AS "recipientKind",
|
||||
delivery.recipient_asset_id AS "recipientAssetId",
|
||||
delivery.recipient_user_id AS "recipientUserId",
|
||||
delivery.recipient_email AS "recipientEmail",
|
||||
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[];
|
||||
if (!row) {
|
||||
throw new NotFoundException({
|
||||
@@ -332,34 +339,41 @@ export class InspectionDocumentDeliveryService {
|
||||
const [company] = await this.dataSource.query(`
|
||||
SELECT notification_email AS email
|
||||
FROM organization_profiles
|
||||
WHERE asset_id=$1
|
||||
WHERE asset_id = $1
|
||||
`, [row.recipientAssetId]) as Array<{ email: string | null }>;
|
||||
email = company?.email ?? null;
|
||||
} else if (row.recipientKind === 'INSPECTOR' && row.recipientUserId) {
|
||||
const [inspector] = await this.dataSource.query(`
|
||||
SELECT email
|
||||
FROM users
|
||||
WHERE id=$1 AND is_active=true
|
||||
WHERE id = $1 AND is_active = true
|
||||
`, [row.recipientUserId]) as Array<{ email: string | null }>;
|
||||
email = inspector?.email ?? null;
|
||||
} else {
|
||||
} else if (row.recipientKind === 'OFFICE') {
|
||||
const [settings] = await this.dataSource.query(`
|
||||
SELECT office_email AS "officeEmail",director_email AS "directorEmail"
|
||||
SELECT office_email AS "officeEmail"
|
||||
FROM institutional_delivery_settings
|
||||
WHERE id=1
|
||||
`) as Array<{ officeEmail: string | null; directorEmail: string | null }>;
|
||||
email = row.recipientKind === 'OFFICE'
|
||||
? settings?.officeEmail ?? null
|
||||
: settings?.directorEmail ?? null;
|
||||
WHERE id = 1
|
||||
`) as Array<{ officeEmail: string | null }>;
|
||||
email = settings?.officeEmail ?? null;
|
||||
} else if (row.recipientKind === 'DIRECTOR' && row.documentKind === 'REPORT_WORD') {
|
||||
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(`
|
||||
UPDATE inspection_document_deliveries
|
||||
SET recipient_email=$2,
|
||||
status=CASE WHEN $2::text IS NULL THEN 'WAITING_RECIPIENT' ELSE 'PENDING' END,
|
||||
last_error=NULL,
|
||||
updated_at=CURRENT_TIMESTAMP
|
||||
WHERE id=$1 AND status<>'SENT'
|
||||
SET recipient_email = $2,
|
||||
status = CASE WHEN $2::text IS NULL THEN 'WAITING_RECIPIENT' ELSE 'PENDING' END,
|
||||
last_error = NULL,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $1 AND status <> 'SENT'
|
||||
`, [row.id, email]);
|
||||
}
|
||||
|
||||
@@ -406,9 +420,12 @@ export class InspectionDocumentDeliveryService {
|
||||
|
||||
await this.dataSource.query(`
|
||||
UPDATE inspection_document_deliveries
|
||||
SET attempts=attempts+1,last_attempt_at=CURRENT_TIMESTAMP,status='PENDING',
|
||||
last_error=NULL,updated_at=CURRENT_TIMESTAMP
|
||||
WHERE id=$1
|
||||
SET attempts = attempts + 1,
|
||||
last_attempt_at = CURRENT_TIMESTAMP,
|
||||
status = 'PENDING',
|
||||
last_error = NULL,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $1
|
||||
`, [row.id]);
|
||||
|
||||
try {
|
||||
@@ -416,10 +433,10 @@ export class InspectionDocumentDeliveryService {
|
||||
? `Acta ${row.actCode}`
|
||||
: `Informe ${row.reportCode ?? ''}`;
|
||||
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'
|
||||
? `Se adjunta copia del acta cerrada e inmutable ${row.actCode} correspondiente a tu inspección.`
|
||||
: `Se adjunta el acta cerrada e inmutable ${row.actCode}.`;
|
||||
? `Se adjunta copia del Acta ${row.actCode} correspondiente a tu inspección.`
|
||||
: `Se adjunta el Acta ${row.actCode}.`;
|
||||
const sent = await this.smtp.send({
|
||||
to: row.recipientEmail,
|
||||
subject: `DH Inspección · ${label}`,
|
||||
@@ -428,9 +445,12 @@ export class InspectionDocumentDeliveryService {
|
||||
});
|
||||
await this.dataSource.query(`
|
||||
UPDATE inspection_document_deliveries
|
||||
SET status='SENT',sent_at=CURRENT_TIMESTAMP,provider_message_id=$2,
|
||||
last_error=NULL,updated_at=CURRENT_TIMESTAMP
|
||||
WHERE id=$1
|
||||
SET status = 'SENT',
|
||||
sent_at = CURRENT_TIMESTAMP,
|
||||
provider_message_id = $2,
|
||||
last_error = NULL,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $1
|
||||
`, [row.id, sent.messageId]);
|
||||
await this.audit.record({
|
||||
action: AuditAction.DOCUMENT_DELIVERY_SENT,
|
||||
@@ -463,8 +483,10 @@ export class InspectionDocumentDeliveryService {
|
||||
private async setStatus(id: string, status: string, error: string) {
|
||||
await this.dataSource.query(`
|
||||
UPDATE inspection_document_deliveries
|
||||
SET status=$2,last_error=$3,updated_at=CURRENT_TIMESTAMP
|
||||
WHERE id=$1
|
||||
SET status = $2,
|
||||
last_error = $3,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $1
|
||||
`, [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 { AuditModule } from '../audit/audit.module';
|
||||
import { InspectionDeadlinesModule } from '../inspection-deadlines/inspection-deadlines.module';
|
||||
import { DocumentDeliveryController } from './document-delivery.controller';
|
||||
import { InspectionActPdfService } from './inspection-act-pdf.service';
|
||||
import { InspectionDocumentDeliveryService } from './inspection-document-delivery.service';
|
||||
import { InspectionReportReviewController, InspectionReportRevisionContentController } from './inspection-report-review.controller';
|
||||
import { InspectionReportReviewService } from './inspection-report-review.service';
|
||||
import {
|
||||
InspectionReportDossierController,
|
||||
InspectionReportFollowUpFileController,
|
||||
} from './inspection-report-dossier.controller';
|
||||
import { InspectionReportDossierService } from './inspection-report-dossier.service';
|
||||
import { InspectionReportWordService } from './inspection-report-word.service';
|
||||
import { InspectionActReportController, InspectionReportsController } from './inspection-reports.controller';
|
||||
import { InspectionReportsService } from './inspection-reports.service';
|
||||
import { SmtpDeliveryService } from './smtp-delivery.service';
|
||||
import { SmtpSettingsController } from './smtp-settings.controller';
|
||||
import { SmtpSettingsService } from './smtp-settings.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuditModule],
|
||||
imports: [AuditModule, InspectionDeadlinesModule],
|
||||
controllers: [
|
||||
InspectionReportsController,
|
||||
InspectionActReportController,
|
||||
InspectionReportDossierController,
|
||||
InspectionReportFollowUpFileController,
|
||||
DocumentDeliveryController,
|
||||
InspectionReportReviewController,
|
||||
InspectionReportRevisionContentController,
|
||||
SmtpSettingsController,
|
||||
],
|
||||
providers: [
|
||||
InspectionReportsService,
|
||||
InspectionReportWordService,
|
||||
InspectionReportDossierService,
|
||||
InspectionActPdfService,
|
||||
InspectionDocumentDeliveryService,
|
||||
SmtpDeliveryService,
|
||||
InspectionReportReviewService,
|
||||
SmtpSettingsService,
|
||||
],
|
||||
exports: [InspectionReportsService],
|
||||
})
|
||||
|
||||
@@ -1,37 +1,328 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { connect as connectNet, Socket } from 'node:net';
|
||||
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 { Injectable } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { decryptSmtpSecret } from './smtp-settings-crypto';
|
||||
|
||||
interface MailAttachment { filename:string; mimeType:string; content:Buffer; }
|
||||
interface MailInput { to:string; subject:string; text:string; attachment:MailAttachment; }
|
||||
interface Reply { code:number; text:string; }
|
||||
interface MailAttachment {
|
||||
filename: 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 {
|
||||
private buffer=''; private waiters:Array<(reply:Reply)=>void>=[]; private replyLines:string[]=[];
|
||||
private readonly dataHandler=(chunk:Buffer|string)=>this.onData(typeof chunk==='string'?chunk:chunk.toString('utf8'));
|
||||
constructor(private socket:Socket|TLSSocket){ this.attach(socket); }
|
||||
private attach(socket:Socket|TLSSocket){ 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=[]; const waiter=this.waiters.shift(); if(waiter)waiter(reply); } } }
|
||||
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)}); });}
|
||||
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();}
|
||||
current(){return this.socket;}
|
||||
private buffer = '';
|
||||
private waiters: Array<(reply: Reply) => void> = [];
|
||||
private replyLines: string[] = [];
|
||||
private readonly dataHandler = (chunk: Buffer | string) =>
|
||||
this.onData(typeof chunk === 'string' ? chunk : chunk.toString('utf8'));
|
||||
|
||||
constructor(private socket: Socket | TLSSocket) {
|
||||
this.attach(socket);
|
||||
}
|
||||
|
||||
private attach(socket: Socket | TLSSocket) {
|
||||
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 envelope(value:string){const m=value.match(/<([^>]+)>/);return (m?.[1]??value).trim();}
|
||||
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 encodedSubject(value: string) {
|
||||
return `=?UTF-8?B?${Buffer.from(value, 'utf8').toString('base64')}?=`;
|
||||
}
|
||||
|
||||
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()
|
||||
export class SmtpDeliveryService {
|
||||
constructor(private readonly config:ConfigService){}
|
||||
configured(){return Boolean(this.config.get<string>('SMTP_HOST')&&this.config.get<string>('MAIL_FROM'));}
|
||||
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); } }
|
||||
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()}; }
|
||||
export class SmtpDeliveryService implements OnModuleInit {
|
||||
private runtime: RuntimeSmtpConfig | null;
|
||||
private runtimeError: string | null = null;
|
||||
|
||||
constructor(
|
||||
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,
|
||||
plannedStartAt: Date,
|
||||
): Promise<string> {
|
||||
const [yearRow] = (await manager.query(`
|
||||
SELECT EXTRACT(
|
||||
YEAR FROM $1::timestamptz AT TIME ZONE 'America/Argentina/Mendoza'
|
||||
)::integer AS year
|
||||
`, [plannedStartAt])) as Array<{ year: number }>;
|
||||
const year = Number(yearRow?.year);
|
||||
if (!Number.isInteger(year) || year < 2000 || year > 9999) {
|
||||
throw new Error('Unable to derive inspection visit year');
|
||||
const [dateRow] = (await manager.query(`
|
||||
SELECT
|
||||
EXTRACT(YEAR FROM $1::timestamptz AT TIME ZONE 'America/Argentina/Mendoza')::integer AS year,
|
||||
TO_CHAR($1::timestamptz AT TIME ZONE 'America/Argentina/Mendoza', 'DD-MM-YY') AS "datePart"
|
||||
`, [plannedStartAt])) as Array<{ year: number; datePart: string }>;
|
||||
const year = Number(dateRow?.year);
|
||||
const datePart = dateRow?.datePart;
|
||||
if (!Number.isInteger(year) || year < 2000 || year > 9999 || !datePart) {
|
||||
throw new Error('Unable to derive inspection visit date');
|
||||
}
|
||||
|
||||
await manager.query(
|
||||
@@ -20,15 +21,25 @@ export async function nextInspectionVisitCode(
|
||||
);
|
||||
|
||||
const [sequenceRow] = (await manager.query(`
|
||||
SELECT COALESCE(MAX(RIGHT(code, 6)::integer), 0)::integer AS sequence
|
||||
FROM inspection_visits
|
||||
WHERE code LIKE $1
|
||||
AND code ~ ('^INS-' || $2::text || '-[0-9]{6}$')
|
||||
`, [`INS-${year}-%`, year])) as Array<{ sequence: number }>;
|
||||
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
|
||||
WHERE EXTRACT(
|
||||
YEAR FROM COALESCE(planned_start_at, created_at) AT TIME ZONE 'America/Argentina/Mendoza'
|
||||
)::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;
|
||||
if (sequence > 999999) {
|
||||
if (sequence > 99999) {
|
||||
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 { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
|
||||
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 { CreateInspectionVisitDto } from './dto/create-inspection-visit.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 { ReplaceInspectionVisitTeamDto } from './dto/replace-inspection-visit-team.dto';
|
||||
import { UpdateInspectionVisitDto } from './dto/update-inspection-visit.dto';
|
||||
import { InspectionVisitClosureService } from './inspection-visit-closure.service';
|
||||
import { InspectionVisitsService } from './inspection-visits.service';
|
||||
|
||||
@Controller('inspection-visits')
|
||||
export class InspectionVisitsController {
|
||||
constructor(private readonly visits: InspectionVisitsService) {}
|
||||
constructor(
|
||||
private readonly visits: InspectionVisitsService,
|
||||
private readonly closure: InspectionVisitClosureService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermissions('inspections.read')
|
||||
@@ -47,9 +50,7 @@ export class InspectionVisitsController {
|
||||
|
||||
@Get('planning-context/areas/:areaId/operators')
|
||||
@RequirePermissions('inspections.read')
|
||||
planningOperators(
|
||||
@Param('areaId', new ParseUUIDPipe({ version: '4' })) areaId: string,
|
||||
) {
|
||||
planningOperators(@Param('areaId', new ParseUUIDPipe({ version: '4' })) areaId: string) {
|
||||
return this.visits.listPlanningOperators(areaId);
|
||||
}
|
||||
|
||||
@@ -135,17 +136,6 @@ export class InspectionVisitsController {
|
||||
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')
|
||||
@RequirePermissions('inspections.execute')
|
||||
start(
|
||||
@@ -164,6 +154,6 @@ export class InspectionVisitsController {
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@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 { FieldInventoryController } from './field-inventory.controller';
|
||||
import { FieldInventoryService } from './field-inventory.service';
|
||||
import { InspectionVisitClosureService } from './inspection-visit-closure.service';
|
||||
import { InspectionVisitsController } from './inspection-visits.controller';
|
||||
import { InspectionVisitsService } from './inspection-visits.service';
|
||||
|
||||
@@ -15,6 +16,7 @@ import { InspectionVisitsService } from './inspection-visits.service';
|
||||
controllers: [InspectionVisitsController, FieldInventoryController, FieldFindingsController],
|
||||
providers: [
|
||||
InspectionVisitsService,
|
||||
InspectionVisitClosureService,
|
||||
FieldInventoryService,
|
||||
F3FieldInventoryStructureService,
|
||||
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 test from 'node:test';
|
||||
import { REQUIRED_PERMISSIONS_KEY } from '../../src/authorization/decorators/require-permissions.decorator';
|
||||
import { InspectionActLifecycleController } from '../../src/inspection-closing/inspection-act-lifecycle.controller';
|
||||
import {
|
||||
InspectionClosingController,
|
||||
InspectionSignatureContentController,
|
||||
@@ -12,11 +13,12 @@ function permissionFor(controller: object, method: string): 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, 'responsible'), ['inspection_closure.prepare']);
|
||||
assert.deepEqual(permissionFor(InspectionClosingController.prototype, 'ready'), ['inspection_closure.prepare']);
|
||||
assert.deepEqual(permissionFor(InspectionClosingController.prototype, 'reopen'), ['inspection_closure.prepare']);
|
||||
assert.deepEqual(permissionFor(InspectionActLifecycleController.prototype, 'lock'), ['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, 'companySignature'), ['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[];
|
||||
}
|
||||
|
||||
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('get'), ['inspections.read']);
|
||||
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('replaceAssets'), ['inspections.manage']);
|
||||
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('close'), ['inspections.execute']);
|
||||
});
|
||||
|
||||
@@ -6,11 +6,13 @@ import test from 'node:test';
|
||||
const root = process.cwd();
|
||||
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');
|
||||
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, /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', () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user