From 82213e72f5160f65072f1729263994282e60be09 Mon Sep 17 00:00:00 2001 From: DH V2 Date: Sat, 5 Sep 2026 10:12:35 -0300 Subject: [PATCH] chore: import DH V2 D5.6.4 production baseline --- .env.example | 33 + .gitignore | 51 + README.md | 35 + api-v3/.dockerignore | 5 + api-v3/Dockerfile | 19 + api-v3/nest-cli.json | 8 + api-v3/package-lock.json | 6267 +++++++++++++++++ api-v3/package.json | 46 + .../administration/administration.module.ts | 15 + .../common/administration-audit.ts | 72 + .../roles/dto/create-role.dto.ts | 38 + .../roles/dto/replace-role-permissions.dto.ts | 8 + .../roles/dto/update-role.dto.ts | 18 + .../administration/roles/roles.controller.ts | 76 + .../src/administration/roles/roles.service.ts | 309 + .../users/dto/change-user-status.dto.ts | 7 + .../users/dto/create-user.dto.ts | 58 + .../users/dto/list-users-query.dto.ts | 35 + .../users/dto/replace-user-roles.dto.ts | 8 + .../users/dto/reset-user-password.dto.ts | 12 + .../users/dto/update-user.dto.ts | 43 + .../administration/users/users.controller.ts | 96 + .../src/administration/users/users.service.ts | 497 ++ api-v3/src/app.module.ts | 90 + .../src/asset-imports/asset-import-parser.ts | 250 + api-v3/src/asset-imports/asset-import-plan.ts | 328 + .../asset-imports/asset-import-profiles.ts | 447 ++ .../asset-imports/asset-imports.controller.ts | 158 + .../src/asset-imports/asset-imports.module.ts | 11 + .../asset-imports/asset-imports.service.ts | 1621 +++++ .../dto/apply-asset-import-plan.dto.ts | 7 + .../dto/cancel-asset-import.dto.ts | 5 + .../dto/create-asset-import-plan.dto.ts | 13 + .../list-asset-import-plan-items-query.dto.ts | 14 + .../list-asset-import-reviews-query.dto.ts | 8 + .../dto/list-asset-import-rows-query.dto.ts | 9 + .../dto/list-asset-imports-query.dto.ts | 8 + .../dto/resolve-asset-import-plan-item.dto.ts | 15 + .../dto/rollback-asset-import-plan.dto.ts | 8 + .../dto/upload-asset-import.dto.ts | 18 + .../asset-master/asset-attribute-validator.ts | 131 + .../asset-geometries.controller.ts | 65 + .../asset-master/asset-geometries.service.ts | 301 + .../asset-master/asset-geometry-validator.ts | 93 + .../asset-master/asset-history.controller.ts | 43 + .../src/asset-master/asset-history.service.ts | 369 + .../asset-master/asset-master-bootstrap.ts | 405 ++ .../src/asset-master/asset-master.module.ts | 54 + api-v3/src/asset-master/asset-media-file.ts | 72 + .../asset-master/asset-media.controller.ts | 106 + .../src/asset-master/asset-media.service.ts | 402 ++ .../asset-operational-relations.controller.ts | 92 + .../asset-operational-relations.service.ts | 369 + .../asset-provenance.controller.ts | 52 + .../asset-master/asset-provenance.service.ts | 195 + .../asset-master/asset-registry.controller.ts | 60 + .../asset-master/asset-registry.service.ts | 252 + .../asset-master/asset-temporal.controller.ts | 33 + .../asset-master/asset-temporal.service.ts | 194 + .../asset-master/asset-types.controller.ts | 118 + .../src/asset-master/asset-types.service.ts | 905 +++ api-v3/src/asset-master/asset-version-diff.ts | 41 + api-v3/src/asset-master/assets.controller.ts | 193 + api-v3/src/asset-master/assets.service.ts | 1720 +++++ .../add-area-legal-right-organization.dto.ts | 2 + .../dto/add-organization-membership.dto.ts | 2 + .../dto/change-asset-context.dto.ts | 27 + .../change-asset-operational-status.dto.ts | 3 + .../dto/change-asset-status.dto.ts | 7 + .../dto/create-area-company-relation.dto.ts | 13 + .../dto/create-area-legal-right.dto.ts | 2 + .../dto/create-asset-media.dto.ts | 52 + .../asset-master/dto/create-asset-type.dto.ts | 46 + .../src/asset-master/dto/create-asset.dto.ts | 67 + .../dto/create-attribute-definition.dto.ts | 63 + .../dto/create-external-identifier.dto.ts | 2 + .../dto/create-field-discovery.dto.ts | 53 + .../dto/create-source-document.dto.ts | 2 + .../dto/end-area-company-relation.dto.ts | 10 + .../end-area-legal-right-organization.dto.ts | 1 + .../dto/end-external-identifier.dto.ts | 1 + .../dto/end-organization-membership.dto.ts | 1 + .../dto/link-asset-source-document.dto.ts | 2 + .../list-area-company-relations-query.dto.ts | 17 + .../dto/list-asset-tree-children-query.dto.ts | 16 + .../dto/list-asset-tree-query.dto.ts | 40 + .../dto/list-asset-versions-query.dto.ts | 62 + .../asset-master/dto/list-assets-query.dto.ts | 68 + .../dto/list-field-discoveries-query.dto.ts | 26 + .../dto/list-operational-areas-query.dto.ts | 7 + .../dto/list-temporal-assets-query.dto.ts | 50 + .../asset-master/dto/map-assets-query.dto.ts | 24 + .../dto/parent-options-query.dto.ts | 15 + .../dto/review-field-discovery.dto.ts | 29 + .../dto/update-area-legal-right.dto.ts | 2 + .../dto/update-asset-media.dto.ts | 47 + .../dto/update-asset-provenance.dto.ts | 39 + .../asset-master/dto/update-asset-type.dto.ts | 46 + .../src/asset-master/dto/update-asset.dto.ts | 65 + .../dto/update-attribute-definition.dto.ts | 58 + .../dto/upsert-asset-geometry.dto.ts | 44 + .../dto/upsert-organization-profile.dto.ts | 2 + api-v3/src/audit/audit-query.service.ts | 149 + api-v3/src/audit/audit-query.ts | 66 + api-v3/src/audit/audit-sanitizer.ts | 43 + api-v3/src/audit/audit.controller.ts | 27 + api-v3/src/audit/audit.module.ts | 13 + api-v3/src/audit/audit.service.ts | 60 + api-v3/src/audit/dto/list-audit-query.dto.ts | 88 + api-v3/src/auth/auth.controller.ts | 74 + api-v3/src/auth/auth.module.ts | 43 + api-v3/src/auth/auth.service.ts | 496 ++ .../auth/decorators/current-auth.decorator.ts | 10 + .../src/auth/decorators/public.decorator.ts | 4 + .../auth/decorators/skip-csrf.decorator.ts | 4 + api-v3/src/auth/dto/change-password.dto.ts | 13 + api-v3/src/auth/dto/login.dto.ts | 18 + api-v3/src/auth/guards/access-token.guard.ts | 108 + api-v3/src/auth/guards/csrf.guard.ts | 56 + .../auth/interfaces/access-token-payload.ts | 8 + api-v3/src/auth/services/cookie.service.ts | 90 + api-v3/src/auth/services/password.service.ts | 40 + api-v3/src/auth/services/token.service.ts | 98 + .../src/authorization/authorization.module.ts | 11 + .../require-permissions.decorator.ts | 15 + .../authorization/guards/permissions.guard.ts | 59 + .../permission-resolver.service.ts | 21 + api-v3/src/cli/bootstrap-admin.ts | 263 + api-v3/src/cli/dev-seed-mendoza-demo.ts | 216 + .../src/common/config/auth-config.service.ts | 93 + .../common/filters/api-exception.filter.ts | 65 + api-v3/src/common/http/request-context.ts | 21 + .../src/common/http/request-id.middleware.ts | 17 + api-v3/src/core-data/phase-a-data.module.ts | 21 + .../repositories/audit-events.repository.ts | 20 + .../repositories/auth-sessions.repository.ts | 110 + .../repositories/roles.repository.ts | 57 + .../repositories/users.repository.ts | 69 + api-v3/src/dashboard/dashboard.controller.ts | 14 + api-v3/src/dashboard/dashboard.module.ts | 9 + api-v3/src/dashboard/dashboard.service.ts | 167 + api-v3/src/database/data-source.ts | 34 + .../entities/area-company-relation.entity.ts | 51 + .../area-legal-right-organization.entity.ts | 4 + .../entities/area-legal-right.entity.ts | 5 + .../asset-attribute-definition.entity.ts | 51 + .../entities/asset-attribute-value.entity.ts | 19 + .../asset-external-identifier.entity.ts | 3 + .../entities/asset-geometry.entity.ts | 58 + .../database/entities/asset-media.entity.ts | 78 + .../entities/asset-source-document.entity.ts | 4 + .../entities/asset-type-parent-rule.entity.ts | 13 + .../database/entities/asset-type.entity.ts | 45 + .../database/entities/asset-version.entity.ts | 60 + api-v3/src/database/entities/asset.entity.ts | 115 + .../database/entities/audit-event.entity.ts | 163 + .../database/entities/auth-session.entity.ts | 54 + .../document-annual-sequence.entity.ts | 21 + .../finding-catalog-asset-override.entity.ts | 28 + ...nding-catalog-asset-type-profile.entity.ts | 15 + .../finding-catalog-item-asset-type.entity.ts | 19 + .../entities/finding-catalog-item.entity.ts | 41 + .../finding-catalog-proposal.entity.ts | 56 + .../entities/finding-category.entity.ts | 22 + api-v3/src/database/entities/index.ts | 237 + .../entities/inspection-act-asset.entity.ts | 19 + .../entities/inspection-act-closure.entity.ts | 51 + .../inspection-act-responsible.entity.ts | 52 + .../inspection-act-signature.entity.ts | 110 + .../entities/inspection-act-version.entity.ts | 39 + .../entities/inspection-act.entity.ts | 70 + ...inspection-finding-communication.entity.ts | 64 + .../inspection-finding-evidence.entity.ts | 89 + ...ction-finding-verification-event.entity.ts | 54 + ...ction-finding-verification-visit.entity.ts | 47 + .../inspection-finding-version.entity.ts | 37 + .../entities/inspection-finding.entity.ts | 108 + .../entities/inspection-report.entity.ts | 124 + .../entities/inspection-visit-asset.entity.ts | 38 + .../inspection-visit-member.entity.ts | 19 + .../entities/inspection-visit.entity.ts | 76 + .../organization-membership.entity.ts | 21 + .../entities/organization-profile.entity.ts | 13 + .../database/entities/permission.entity.ts | 16 + .../entities/role-permission.entity.ts | 21 + api-v3/src/database/entities/role.entity.ts | 20 + .../entities/source-document.entity.ts | 6 + .../entities/survey-campaign-target.entity.ts | 44 + .../entities/survey-campaign.entity.ts | 51 + .../survey-target-report-media.entity.ts | 19 + .../survey-target-report-version.entity.ts | 36 + .../entities/survey-target-report.entity.ts | 73 + .../database/entities/timestamped.entity.ts | 9 + .../src/database/entities/user-role.entity.ts | 35 + api-v3/src/database/entities/user.entity.ts | 59 + api-v3/src/database/migration-cli.ts | 49 + ...8000000-phase-a-users-roles-permissions.ts | 124 + .../1786548001000-phase-a-auth-sessions.ts | 48 + .../1786548002000-phase-a-audit-events.ts | 58 + ...48003000-phase-a-seed-roles-permissions.ts | 137 + .../1786548004000-phase-a-runtime-grants.ts | 86 + ...05000-phase-a2-session-security-indexes.ts | 24 + .../1786636800000-phase-b1-asset-master.ts | 340 + ...1786723200000-phase-b2-asset-geometries.ts | 114 + .../1786723201000-phase-b3-asset-versions.ts | 203 + .../1786723202000-phase-b4-asset-media.ts | 171 + ...6723203000-phase-b5-provenance-temporal.ts | 191 + .../1786809600000-phase-c1-survey-planning.ts | 155 + ...1786896000000-phase-c2-survey-execution.ts | 216 + ...786982400000-phase-d1-inspection-visits.ts | 178 + .../1787068800000-phase-d2-inspection-acts.ts | 205 + ...2400000-phase-d2-1-single-act-per-visit.ts | 34 + ...7155200000-phase-d3-inspection-findings.ts | 303 + ...hase-d4-finding-evidence-communications.ts | 234 + ...8000000-phase-d5-act-closing-signatures.ts | 554 ++ ...000-phase-d5-1-mobile-inspection-policy.ts | 81 + ...ase-d5-2-finding-catalog-administration.ts | 89 + ...21600000-phase-d5-3-operational-context.ts | 474 ++ ...ase-d5-3-3-definitive-operational-model.ts | 354 + ...-phase-d5-3-5-import-center-scalability.ts | 137 + ...hase-d5-3-7-import-planning-application.ts | 125 + ...hase-d5-3-7-2-relational-territory-plan.ts | 92 + ...-phase-d5-3-8-local-inventory-structure.ts | 89 + ...026400000-phase-d5-3-15-document-center.ts | 146 + ...000-phase-d5-3-16-verification-planning.ts | 79 + ...00-phase-d5-3-17-verification-execution.ts | 113 + ...se-d5-3-18-3-office-follow-up-hardening.ts | 49 + ...9-inventory-aliases-operational-filters.ts | 42 + ...000-phase-d5-3-20-automatic-word-report.ts | 61 + ...4800000-phase-d5-3-21-document-delivery.ts | 104 + ...00-phase-d5-3-22-director-report-review.ts | 143 + ...7600000-phase-d5-3-23-field-discoveries.ts | 52 + ...000-phase-d5-3-24-asset-context-history.ts | 187 + ...hase-d5-3-25-verification-event-history.ts | 190 + ...0-phase-d5-4-contextual-finding-catalog.ts | 209 + ...00000-phase-d5-5-web-planning-checklist.ts | 219 + .../seeds/phase-d3-finding-catalog.ts | 1646 +++++ api-v3/src/health.controller.ts | 14 + api-v3/src/health.service.ts | 24 + .../dto/cancel-inspection-act.dto.ts | 10 + .../dto/create-inspection-act.dto.ts | 45 + .../dto/list-inspection-acts-query.dto.ts | 56 + .../dto/update-inspection-act.dto.ts | 49 + .../inspection-acts.controller.ts | 83 + .../inspection-acts/inspection-acts.module.ts | 14 + .../inspection-acts.service.ts | 815 +++ .../src/inspection-closing/canonical-json.ts | 22 + .../dto/close-inspection-act.dto.ts | 10 + .../dto/create-company-outcome.dto.ts | 17 + .../dto/create-inspection-signature.dto.ts | 49 + .../dto/upsert-inspection-responsible.dto.ts | 77 + .../inspection-closing.controller.ts | 147 + .../inspection-closing.module.ts | 15 + .../inspection-closing.service.ts | 1414 ++++ .../inspection-signature-file.ts | 49 + .../dto/close-inspection-finding.dto.ts | 13 + .../dto/create-finding-catalog-item.dto.ts | 54 + .../dto/create-finding-category.dto.ts | 23 + .../create-inspection-communication.dto.ts | 59 + .../dto/create-inspection-evidence.dto.ts | 72 + .../dto/create-inspection-finding.dto.ts | 57 + ...ist-finding-catalog-proposals-query.dto.ts | 12 + .../dto/list-finding-catalog-query.dto.ts | 14 + .../dto/list-inspection-findings-query.dto.ts | 64 + .../replace-finding-catalog-selection.dto.ts | 15 + .../review-finding-catalog-proposal.dto.ts | 23 + .../dto/update-finding-catalog-item.dto.ts | 54 + .../dto/update-finding-category.dto.ts | 22 + ...update-inspection-finding-follow-up.dto.ts | 59 + .../dto/update-inspection-finding.dto.ts | 40 + .../finding-catalog.controller.ts | 136 + .../finding-catalog.service.ts | 901 +++ .../inspection-evidence-file.ts | 77 + .../inspection-evidence.controller.ts | 113 + .../inspection-evidence.service.ts | 681 ++ .../inspection-findings.controller.ts | 98 + .../inspection-findings.module.ts | 33 + .../inspection-findings.service.ts | 1268 ++++ ...field-discovery-inspection-link.service.ts | 64 + .../mobile-inspector-policy.ts | 17 + .../document-delivery.controller.ts | 17 + .../dto/approve-inspection-report.dto.ts | 8 + .../create-inspection-report-revision.dto.ts | 7 + .../dto/list-inspection-reports-query.dto.ts | 49 + .../dto/sign-final-inspection-report.dto.ts | 9 + .../update-document-delivery-settings.dto.ts | 2 + .../inspection-act-pdf-builder.ts | 77 + .../inspection-act-pdf.service.ts | 27 + .../inspection-document-delivery.service.ts | 40 + .../inspection-report-review.controller.ts | 87 + .../inspection-report-review.service.ts | 433 ++ .../inspection-report-revision-file.ts | 38 + .../inspection-report-word-builder.ts | 214 + .../inspection-report-word.service.ts | 128 + .../inspection-reports.controller.ts | 65 + .../inspection-reports.module.ts | 32 + .../inspection-reports.service.ts | 572 ++ .../smtp-delivery.service.ts | 37 + .../list-verification-planning-query.dto.ts | 49 + .../dto/plan-verification-visit.dto.ts | 22 + .../dto/record-verification-result.dto.ts | 20 + .../inspection-verifications.controller.ts | 41 + .../inspection-verifications.module.ts | 11 + .../inspection-verifications.service.ts | 763 ++ .../verification-event-ledger.ts | 52 + .../dto/change-inspection-visit-status.dto.ts | 16 + .../dto/create-inspection-visit.dto.ts | 15 + .../dto/exclude-inspection-visit-asset.dto.ts | 10 + .../dto/list-inspection-visits-query.dto.ts | 48 + .../replace-inspection-visit-assets.dto.ts | 9 + .../dto/replace-inspection-visit-team.dto.ts | 13 + .../dto/update-inspection-visit.dto.ts | 42 + .../inspection-visit-code.ts | 34 + .../inspection-visits.controller.ts | 157 + .../inspection-visits.module.ts | 11 + .../inspection-visits.service.ts | 1536 ++++ api-v3/src/main.ts | 69 + .../dto/review-survey-report.dto.ts | 16 + .../dto/save-survey-report.dto.ts | 58 + .../survey-execution.controller.ts | 61 + .../survey-execution.module.ts | 12 + .../survey-execution.service.ts | 924 +++ .../dto/add-survey-target.dto.ts | 23 + .../dto/assign-survey-target.dto.ts | 7 + .../dto/change-survey-campaign-status.dto.ts | 7 + .../dto/change-survey-target-status.dto.ts | 7 + .../dto/create-survey-campaign.dto.ts | 51 + .../dto/list-survey-campaigns-query.dto.ts | 40 + .../dto/update-survey-campaign.dto.ts | 53 + .../dto/update-survey-target.dto.ts | 16 + .../survey-campaigns.controller.ts | 131 + .../survey-planning/survey-planning.module.ts | 14 + .../survey-planning.service.ts | 791 +++ api-v3/src/version.ts | 2 + .../unit/administration-authorization.test.ts | 39 + api-v3/test/unit/administration-dto.test.ts | 49 + .../unit/administration-integrity.test.ts | 25 + .../unit/asset-attribute-validator.test.ts | 71 + api-v3/test/unit/asset-center-d5-3-4.test.ts | 31 + .../test/unit/asset-dossier-d5-3-14.test.ts | 32 + .../unit/asset-geometry-authorization.test.ts | 24 + .../unit/asset-geometry-validator.test.ts | 47 + .../unit/asset-history-authorization.test.ts | 17 + .../test/unit/asset-history-capture.test.ts | 54 + api-v3/test/unit/asset-history-dto.test.ts | 36 + .../test/unit/asset-import-profiles.test.ts | 53 + .../unit/asset-imports-authorization.test.ts | 20 + .../test/unit/asset-imports-d5-3-11.test.ts | 71 + api-v3/test/unit/asset-imports-d5-3-6.test.ts | 49 + .../test/unit/asset-imports-d5-3-7-2.test.ts | 133 + api-v3/test/unit/asset-imports-d5-3-7.test.ts | 159 + .../test/unit/asset-imports-integrity.test.ts | 40 + .../unit/asset-master-authorization.test.ts | 36 + .../test/unit/asset-master-bootstrap.test.ts | 57 + api-v3/test/unit/asset-master-dto.test.ts | 35 + .../unit/asset-media-authorization.test.ts | 22 + api-v3/test/unit/asset-media-dto.test.ts | 34 + api-v3/test/unit/asset-media-file.test.ts | 40 + .../unit/asset-navigation-d5-3-10-2.test.ts | 30 + .../unit/asset-navigation-d5-3-11-1.test.ts | 19 + ...perational-relations-authorization.test.ts | 21 + .../asset-operational-relations-dto.test.ts | 77 + ...et-operational-relations-integrity.test.ts | 43 + .../asset-provenance-authorization.test.ts | 24 + api-v3/test/unit/asset-provenance-dto.test.ts | 45 + .../unit/asset-provenance-service.test.ts | 26 + .../unit/asset-registry-authorization.test.ts | 18 + api-v3/test/unit/asset-registry-dto.test.ts | 28 + .../test/unit/asset-temporal-service.test.ts | 27 + api-v3/test/unit/asset-version-diff.test.ts | 47 + api-v3/test/unit/audit-controller.test.ts | 16 + api-v3/test/unit/audit-query-dto.test.ts | 36 + api-v3/test/unit/audit-query.test.ts | 84 + api-v3/test/unit/audit-sanitizer.test.ts | 36 + api-v3/test/unit/dashboard-controller.test.ts | 21 + api-v3/test/unit/dashboard-service.test.ts | 53 + ...g-catalog-administration-integrity.test.ts | 30 + api-v3/test/unit/finding-catalog-seed.test.ts | 21 + .../inspection-acts-authorization.test.ts | 34 + api-v3/test/unit/inspection-acts-dto.test.ts | 41 + .../unit/inspection-acts-single-act.test.ts | 40 + .../inspection-closing-authorization.test.ts | 25 + .../unit/inspection-closing-canonical.test.ts | 18 + .../test/unit/inspection-closing-dto.test.ts | 56 + .../test/unit/inspection-closing-file.test.ts | 43 + .../unit/inspection-closing-integrity.test.ts | 20 + .../inspection-evidence-authorization.test.ts | 40 + .../test/unit/inspection-evidence-dto.test.ts | 59 + .../unit/inspection-evidence-file.test.ts | 40 + .../inspection-evidence-integrity.test.ts | 18 + .../inspection-findings-authorization.test.ts | 52 + .../test/unit/inspection-findings-dto.test.ts | 107 + ...nspection-findings-office-workflow.test.ts | 22 + .../inspection-reports-authorization.test.ts | 17 + ...ection-verifications-authorization.test.ts | 20 + .../inspection-visits-authorization.test.ts | 23 + .../test/unit/inspection-visits-dto.test.ts | 48 + .../unit/mobile-inspector-integration.test.ts | 43 + .../test/unit/mobile-inspector-policy.test.ts | 44 + api-v3/test/unit/password.service.test.ts | 18 + api-v3/test/unit/permissions.guard.test.ts | 140 + .../phase-d5-3-15-document-center.test.ts | 31 + ...hase-d5-3-16-verification-planning.test.ts | 41 + ...ase-d5-3-17-verification-execution.test.ts | 55 + ...-3-18-3-office-follow-up-hardening.test.ts | 22 + .../phase-d5-3-18-office-workspace.test.ts | 26 + ...5-3-19-inventory-filters-deadlines.test.ts | 56 + ...hase-d5-3-20-automatic-word-report.test.ts | 41 + .../phase-d5-3-21-document-delivery.test.ts | 7 + ...ase-d5-3-22-director-report-review.test.ts | 56 + .../phase-d5-3-23-field-discoveries.test.ts | 55 + ...hase-d5-3-24-asset-context-history.test.ts | 51 + ...d5-3-25-verification-event-history.test.ts | 51 + .../phase-d5-3-3-definitive-model.test.ts | 41 + .../unit/phase-d5-4-1-build-contract.test.ts | 13 + ...se-d5-4-contextual-finding-catalog.test.ts | 65 + .../phase-d5-5-web-planning-checklist.test.ts | 66 + .../phase-d5-6-1-user-password-reset.test.ts | 27 + ...e-d5-6-4-quick-inspection-planning.test.ts | 43 + .../unit/phase-d5-6-phase-d-closure.test.ts | 83 + .../survey-execution-authorization.test.ts | 18 + api-v3/test/unit/survey-execution-dto.test.ts | 48 + .../survey-planning-authorization.test.ts | 31 + api-v3/test/unit/survey-planning-dto.test.ts | 35 + api-v3/test/unit/token.service.test.ts | 53 + api-v3/tsconfig.json | 23 + api-v3/tsconfig.test.json | 10 + docker-compose.yml | 119 + docs/BACKUP_INVENTARIO_A_REVISAR.md | 13 + docs/DATA_REFERENCE_MENDOZA_HYDROCARBONS.md | 69 + docs/DEV_DEMO_AND_CLEANUP.md | 84 + docs/DOMAIN_RESEARCH_MENDOZA_NOMENCLATURE.md | 86 + docs/HOTFIX_A2_1.md | 7 + docs/INSPECTION_DOCUMENT_MODEL.md | 96 + docs/INSTALL.md | 14 + docs/PHASE_A0_REVIEW.md | 40 + docs/PHASE_A1.md | 46 + docs/PHASE_A1_DEPLOY.md | 165 + docs/PHASE_A2.md | 79 + docs/PHASE_A2_DEPLOY.md | 53 + docs/PHASE_A3.md | 41 + docs/PHASE_A3_DEPLOY.md | 33 + docs/PHASE_A4.md | 72 + docs/PHASE_A4_DEPLOY.md | 34 + docs/PHASE_A5.md | 90 + docs/PHASE_A5_DEPLOY.md | 34 + docs/PHASE_A6.md | 93 + docs/PHASE_A6_DEPLOY.md | 33 + docs/PHASE_A7.md | 69 + docs/PHASE_A7_DEPLOY.md | 30 + docs/PHASE_A_FINAL_ACCEPTANCE.md | 49 + docs/PHASE_B1.md | 106 + docs/PHASE_B1_DEPLOY.md | 42 + docs/PHASE_B2.md | 80 + docs/PHASE_B2_DEPLOY.md | 28 + docs/PHASE_B3.md | 62 + docs/PHASE_B3_DEPLOY.md | 29 + docs/PHASE_B4.md | 73 + docs/PHASE_B4_DEPLOY.md | 30 + docs/PHASE_B5.md | 64 + docs/PHASE_B5_DEPLOY.md | 30 + docs/PHASE_C1.md | 67 + docs/PHASE_C1_DEPLOY.md | 29 + docs/PHASE_C2.md | 65 + docs/PHASE_C2_DEPLOY.md | 30 + docs/PHASE_D1.md | 69 + docs/PHASE_D1_DEPLOY.md | 29 + docs/PHASE_D2.md | 64 + docs/PHASE_D2_1.md | 40 + docs/PHASE_D2_1_DEPLOY.md | 19 + docs/PHASE_D2_DEPLOY.md | 30 + docs/PHASE_D3.md | 78 + docs/PHASE_D3_DEPLOY.md | 19 + docs/PHASE_D4.md | 62 + docs/PHASE_D4_DEPLOY.md | 19 + docs/PHASE_D5.md | 73 + docs/PHASE_D5_1.md | 46 + docs/PHASE_D5_1_DEPLOY.md | 18 + docs/PHASE_D5_2.md | 42 + docs/PHASE_D5_2_DEPLOY.md | 19 + docs/PHASE_D5_3.md | 173 + docs/PHASE_D5_3_1.md | 60 + docs/PHASE_D5_3_11.md | 28 + docs/PHASE_D5_3_11_1.md | 14 + docs/PHASE_D5_3_14.md | 29 + docs/PHASE_D5_3_15_1.md | 56 + docs/PHASE_D5_3_16.md | 47 + docs/PHASE_D5_3_16_DEPLOY.md | 7 + docs/PHASE_D5_3_17.md | 31 + docs/PHASE_D5_3_17_1.md | 11 + docs/PHASE_D5_3_18.md | 35 + docs/PHASE_D5_3_18_2.md | 30 + docs/PHASE_D5_3_18_3.md | 7 + docs/PHASE_D5_3_19.md | 36 + docs/PHASE_D5_3_19_1.md | 7 + docs/PHASE_D5_3_1_DEPLOY.md | 25 + docs/PHASE_D5_3_2.md | 32 + docs/PHASE_D5_3_20.md | 9 + docs/PHASE_D5_3_21.md | 5 + docs/PHASE_D5_3_21_1.md | 18 + docs/PHASE_D5_3_22.md | 38 + docs/PHASE_D5_3_23.md | 40 + docs/PHASE_D5_3_24.md | 75 + docs/PHASE_D5_3_25.md | 89 + docs/PHASE_D5_3_2_1.md | 25 + docs/PHASE_D5_3_2_DEPLOY.md | 12 + docs/PHASE_D5_3_3.md | 82 + docs/PHASE_D5_3_3_DEPLOY.md | 19 + docs/PHASE_D5_3_4.md | 66 + docs/PHASE_D5_3_4_DEPLOY.md | 19 + docs/PHASE_D5_3_5.md | 110 + docs/PHASE_D5_3_5_1.md | 13 + docs/PHASE_D5_3_5_DEPLOY.md | 38 + docs/PHASE_D5_3_6.md | 32 + docs/PHASE_D5_3_6_3.md | 19 + docs/PHASE_D5_3_7.md | 125 + docs/PHASE_D5_3_7_2.md | 60 + docs/PHASE_D5_3_7_2_1.md | 13 + docs/PHASE_D5_3_7_2_2.md | 21 + docs/PHASE_D5_3_7_2_3.md | 25 + docs/PHASE_D5_3_7_2_4.md | 24 + docs/PHASE_D5_3_7_2_5.md | 35 + docs/PHASE_D5_3_7_2_DEPLOY.md | 21 + docs/PHASE_D5_3_7_DEPLOY.md | 59 + docs/PHASE_D5_3_8.md | 47 + docs/PHASE_D5_3_9.md | 64 + docs/PHASE_D5_3_DEPLOY.md | 39 + docs/PHASE_D5_4.md | 134 + docs/PHASE_D5_4_1.md | 9 + docs/PHASE_D5_5.md | 66 + docs/PHASE_D5_5_2.md | 10 + docs/PHASE_D5_6.md | 36 + docs/PHASE_D5_6_1.md | 15 + docs/PHASE_D5_6_2.md | 13 + docs/PHASE_D5_DEPLOY.md | 19 + docs/PHASE_D_CLOSED.md | 7 + docs/REAL_INVENTORY_IMPORT_RESEARCH.md | 67 + docs/RELATIONAL_TERRITORY_RESEARCH.md | 22 + docs/RELEASE_A2.md | 32 + docs/RESTORE_RUNBOOK.md | 61 + docs/ROADMAP_AFTER_D3.md | 57 + docs/ROADMAP_AFTER_D4.md | 46 + docs/ROADMAP_AFTER_D5.md | 41 + docs/ROADMAP_AFTER_D5_1.md | 50 + docs/ROADMAP_AFTER_D5_2.md | 41 + docs/ROADMAP_AFTER_D5_3.md | 42 + docs/ROADMAP_AFTER_D5_3_1.md | 34 + docs/ROADMAP_AFTER_D5_3_3.md | 41 + docs/ROADMAP_AFTER_D5_3_4.md | 30 + docs/ROADMAP_AFTER_D5_3_5.md | 34 + docs/ROADMAP_AFTER_D5_3_7.md | 34 + docs/ROADMAP_AFTER_D5_3_7_2.md | 34 + docs/ROADMAP_AFTER_D5_3_9.md | 24 + docs/nginx-site.conf | 36 + infra/db-init/001-init.sh | 38 + scripts/backup-inventario-a-revisar.sh | 169 + scripts/backup-phase-a.sh | 91 + scripts/check-dashboard-d5-6-3.sh | 20 + scripts/check-phase-d5-6-4.sh | 72 + scripts/deploy-dashboard-d5-6-3.sh | 82 + scripts/deploy-phase-d5-3-1.sh | 93 + scripts/deploy-phase-d5-3-10-2.sh | 114 + scripts/deploy-phase-d5-3-11-1.sh | 120 + scripts/deploy-phase-d5-3-11.sh | 120 + scripts/deploy-phase-d5-3-12.sh | 120 + scripts/deploy-phase-d5-3-13.sh | 120 + scripts/deploy-phase-d5-3-14.sh | 120 + scripts/deploy-phase-d5-3-15-1.sh | 122 + scripts/deploy-phase-d5-3-16.sh | 122 + scripts/deploy-phase-d5-3-17-1.sh | 122 + scripts/deploy-phase-d5-3-17.sh | 122 + scripts/deploy-phase-d5-3-18-2.sh | 121 + scripts/deploy-phase-d5-3-18-3.sh | 121 + scripts/deploy-phase-d5-3-18.sh | 120 + scripts/deploy-phase-d5-3-19-1.sh | 121 + scripts/deploy-phase-d5-3-2-1.sh | 95 + scripts/deploy-phase-d5-3-2.sh | 95 + scripts/deploy-phase-d5-3-20.sh | 121 + scripts/deploy-phase-d5-3-21-1.sh | 121 + scripts/deploy-phase-d5-3-22.sh | 121 + scripts/deploy-phase-d5-3-23-1.sh | 121 + scripts/deploy-phase-d5-3-24.sh | 121 + scripts/deploy-phase-d5-3-25.sh | 121 + scripts/deploy-phase-d5-3-3.sh | 96 + scripts/deploy-phase-d5-3-4.sh | 96 + scripts/deploy-phase-d5-3-5-1.sh | 98 + scripts/deploy-phase-d5-3-5.sh | 98 + scripts/deploy-phase-d5-3-6-3.sh | 77 + scripts/deploy-phase-d5-3-6.sh | 77 + scripts/deploy-phase-d5-3-7-1.sh | 143 + scripts/deploy-phase-d5-3-7-2-1.sh | 143 + scripts/deploy-phase-d5-3-7-2-2.sh | 143 + scripts/deploy-phase-d5-3-7-2-3.sh | 143 + scripts/deploy-phase-d5-3-7-2-4.sh | 143 + scripts/deploy-phase-d5-3-7-2-5.sh | 143 + scripts/deploy-phase-d5-3-7-2.sh | 143 + scripts/deploy-phase-d5-3-8.sh | 147 + scripts/deploy-phase-d5-3-9.sh | 114 + scripts/deploy-phase-d5-4-1.sh | 125 + scripts/deploy-phase-d5-5-1.sh | 134 + scripts/deploy-phase-d5-5-2.sh | 132 + scripts/deploy-phase-d5-5.sh | 125 + scripts/deploy-phase-d5-6-2.sh | 140 + scripts/deploy-phase-d5-6-4.sh | 131 + scripts/deploy-phase-d5-6.sh | 146 + scripts/dev-clean-demo.sh | 276 + scripts/dev-seed-mendoza-demo.sh | 5 + scripts/phase-a-acceptance.sh | 159 + scripts/phase-b1-acceptance.sh | 93 + scripts/phase-b2-acceptance.sh | 93 + scripts/phase-b3-acceptance.sh | 106 + scripts/phase-b4-acceptance.sh | 132 + scripts/phase-b5-acceptance.sh | 144 + scripts/phase-c1-acceptance.sh | 127 + scripts/phase-c2-acceptance.sh | 133 + scripts/phase-d1-acceptance.sh | 160 + scripts/phase-d2-1-acceptance.sh | 138 + scripts/phase-d2-acceptance.sh | 172 + scripts/phase-d3-acceptance.sh | 198 + scripts/phase-d4-acceptance.sh | 177 + scripts/phase-d5-1-acceptance.sh | 141 + scripts/phase-d5-2-acceptance.sh | 115 + scripts/phase-d5-3-1-acceptance.sh | 82 + scripts/phase-d5-3-10-2-acceptance.sh | 81 + scripts/phase-d5-3-11-1-acceptance.sh | 95 + scripts/phase-d5-3-11-acceptance.sh | 86 + scripts/phase-d5-3-12-acceptance.sh | 95 + scripts/phase-d5-3-13-acceptance.sh | 104 + scripts/phase-d5-3-14-acceptance.sh | 89 + scripts/phase-d5-3-15-1-acceptance.sh | 88 + scripts/phase-d5-3-16-acceptance.sh | 81 + scripts/phase-d5-3-17-1-acceptance.sh | 84 + scripts/phase-d5-3-17-acceptance.sh | 84 + scripts/phase-d5-3-18-2-acceptance.sh | 88 + scripts/phase-d5-3-18-3-acceptance.sh | 88 + scripts/phase-d5-3-18-acceptance.sh | 88 + scripts/phase-d5-3-19-1-acceptance.sh | 110 + scripts/phase-d5-3-2-1-acceptance.sh | 49 + scripts/phase-d5-3-2-acceptance.sh | 94 + scripts/phase-d5-3-20-acceptance.sh | 94 + scripts/phase-d5-3-21-1-acceptance.sh | 118 + scripts/phase-d5-3-22-acceptance.sh | 119 + scripts/phase-d5-3-23-1-acceptance.sh | 115 + scripts/phase-d5-3-24-acceptance.sh | 121 + scripts/phase-d5-3-25-acceptance.sh | 119 + scripts/phase-d5-3-3-acceptance.sh | 93 + scripts/phase-d5-3-4-acceptance.sh | 72 + scripts/phase-d5-3-5-1-acceptance.sh | 97 + scripts/phase-d5-3-5-acceptance.sh | 94 + scripts/phase-d5-3-6-3-acceptance.sh | 72 + scripts/phase-d5-3-6-acceptance.sh | 69 + scripts/phase-d5-3-7-1-acceptance.sh | 136 + scripts/phase-d5-3-7-2-1-acceptance.sh | 126 + scripts/phase-d5-3-7-2-2-acceptance.sh | 128 + scripts/phase-d5-3-7-2-3-acceptance.sh | 129 + scripts/phase-d5-3-7-2-4-acceptance.sh | 141 + scripts/phase-d5-3-7-2-5-acceptance.sh | 152 + scripts/phase-d5-3-7-2-acceptance.sh | 125 + scripts/phase-d5-3-8-acceptance.sh | 132 + scripts/phase-d5-3-9-acceptance.sh | 71 + scripts/phase-d5-3-acceptance.sh | 166 + scripts/phase-d5-4-1-acceptance.sh | 138 + scripts/phase-d5-5-1-acceptance.sh | 151 + scripts/phase-d5-5-2-acceptance.sh | 151 + scripts/phase-d5-5-acceptance.sh | 151 + scripts/phase-d5-6-2-acceptance.sh | 76 + scripts/phase-d5-6-4-acceptance.sh | 36 + scripts/phase-d5-6-acceptance.sh | 153 + scripts/phase-d5-acceptance.sh | 238 + scripts/verify-backup.sh | 47 + web-v2/.dockerignore | 5 + web-v2/Dockerfile | 12 + web-v2/index.html | 13 + web-v2/nginx/default.conf | 14 + web-v2/package-lock.json | 2081 ++++++ web-v2/package.json | 27 + web-v2/src/app/App.tsx | 93 + web-v2/src/auth/AuthContext.tsx | 66 + web-v2/src/auth/PermissionGate.tsx | 14 + web-v2/src/auth/ProtectedRoute.tsx | 21 + web-v2/src/components/Feedback.tsx | 17 + web-v2/src/components/Icon.tsx | 33 + .../src/components/OperationalContextBar.tsx | 91 + web-v2/src/components/SearchableSelect.tsx | 228 + web-v2/src/config/version.ts | 2 + web-v2/src/context/OperationalContext.tsx | 64 + .../src/features/assets/AssetCenterTabs.tsx | 22 + .../assets/AssetContextHistoryPanel.tsx | 180 + .../src/features/assets/AssetDossierPanel.tsx | 144 + .../assets/AssetFindingCatalogPanel.tsx | 59 + .../features/assets/AssetHierarchyView.tsx | 256 + .../src/features/assets/AssetHistoryPanel.tsx | 66 + .../assets/AssetImportReviewsPanel.tsx | 62 + .../src/features/assets/AssetMediaPanel.tsx | 235 + .../assets/AssetOperationalRelationsPanel.tsx | 138 + .../features/assets/AssetProvenancePanel.tsx | 120 + .../features/assets/AssetRegistryPanel.tsx | 98 + .../features/assets/AssetVersionDrawer.tsx | 58 + .../src/features/assets/assetPresentation.ts | 39 + .../assets/assetProvenancePresentation.ts | 17 + .../assets/assetVersionPresentation.ts | 53 + .../features/documents/DocumentCenterTabs.tsx | 8 + .../FindingCatalogProposalsPanel.tsx | 48 + .../FindingCatalogTypeApplicabilityPanel.tsx | 95 + .../inspections/FindingOfficeWorkspace.tsx | 370 + .../inspections/InspectionActsPanel.tsx | 41 + .../inspections/InspectionClosurePanel.tsx | 85 + .../inspections/InspectionEvidencePanel.tsx | 330 + .../inspections/InspectionFindingsPanel.tsx | 122 + .../inspections/OperationalFilters.tsx | 28 + .../src/features/inspections/SignaturePad.tsx | 101 + .../inspections/inspectionActPresentation.ts | 33 + .../inspections/inspectionPresentation.ts | 23 + .../src/features/map/AssetGeometryEditor.tsx | 291 + web-v2/src/features/map/DhMap.tsx | 166 + .../features/surveys/surveyPresentation.ts | 32 + web-v2/src/layout/AppLayout.tsx | 130 + web-v2/src/lib/api.ts | 3096 ++++++++ web-v2/src/lib/format.ts | 96 + web-v2/src/main.tsx | 15 + web-v2/src/pages/ActsPage.tsx | 117 + web-v2/src/pages/AssetEditorPage.tsx | 250 + web-v2/src/pages/AssetImportsPage.tsx | 785 +++ web-v2/src/pages/AssetTypesPage.tsx | 232 + web-v2/src/pages/AssetsPage.tsx | 145 + web-v2/src/pages/AuditPage.tsx | 74 + web-v2/src/pages/ChangePasswordPage.tsx | 54 + web-v2/src/pages/DashboardPage.tsx | 78 + web-v2/src/pages/DocumentDeliveryPage.tsx | 16 + web-v2/src/pages/FieldDiscoveriesPage.tsx | 155 + web-v2/src/pages/FindingCatalogPage.tsx | 183 + web-v2/src/pages/FindingDetailPage.tsx | 121 + web-v2/src/pages/FindingsPage.tsx | 190 + web-v2/src/pages/HistoryPage.tsx | 130 + web-v2/src/pages/InspectionActEditorPage.tsx | 78 + .../src/pages/InspectionVisitEditorPage.tsx | 410 ++ web-v2/src/pages/InspectionVisitsPage.tsx | 113 + web-v2/src/pages/LoginPage.tsx | 62 + web-v2/src/pages/MapPage.tsx | 74 + web-v2/src/pages/NewUserPage.tsx | 57 + web-v2/src/pages/PlaceholderPage.tsx | 10 + web-v2/src/pages/ReportDetailPage.tsx | 208 + web-v2/src/pages/ReportsPage.tsx | 151 + web-v2/src/pages/RolesPage.tsx | 127 + web-v2/src/pages/SurveyCampaignEditorPage.tsx | 309 + web-v2/src/pages/SurveyCampaignsPage.tsx | 81 + web-v2/src/pages/SurveyExecutionPage.tsx | 303 + web-v2/src/pages/SystemPages.tsx | 9 + web-v2/src/pages/TemporalAssetsPage.tsx | 95 + web-v2/src/pages/UserDetailPage.tsx | 141 + web-v2/src/pages/UsersPage.tsx | 73 + web-v2/src/pages/VerificationPlanningPage.tsx | 238 + web-v2/src/styles.css | 1298 ++++ web-v2/tsconfig.app.json | 17 + web-v2/tsconfig.json | 7 + web-v2/tsconfig.node.json | 10 + web-v2/vite.config.ts | 10 + 757 files changed, 84218 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 api-v3/.dockerignore create mode 100644 api-v3/Dockerfile create mode 100644 api-v3/nest-cli.json create mode 100644 api-v3/package-lock.json create mode 100644 api-v3/package.json create mode 100644 api-v3/src/administration/administration.module.ts create mode 100644 api-v3/src/administration/common/administration-audit.ts create mode 100644 api-v3/src/administration/roles/dto/create-role.dto.ts create mode 100644 api-v3/src/administration/roles/dto/replace-role-permissions.dto.ts create mode 100644 api-v3/src/administration/roles/dto/update-role.dto.ts create mode 100644 api-v3/src/administration/roles/roles.controller.ts create mode 100644 api-v3/src/administration/roles/roles.service.ts create mode 100644 api-v3/src/administration/users/dto/change-user-status.dto.ts create mode 100644 api-v3/src/administration/users/dto/create-user.dto.ts create mode 100644 api-v3/src/administration/users/dto/list-users-query.dto.ts create mode 100644 api-v3/src/administration/users/dto/replace-user-roles.dto.ts create mode 100644 api-v3/src/administration/users/dto/reset-user-password.dto.ts create mode 100644 api-v3/src/administration/users/dto/update-user.dto.ts create mode 100644 api-v3/src/administration/users/users.controller.ts create mode 100644 api-v3/src/administration/users/users.service.ts create mode 100644 api-v3/src/app.module.ts create mode 100644 api-v3/src/asset-imports/asset-import-parser.ts create mode 100644 api-v3/src/asset-imports/asset-import-plan.ts create mode 100644 api-v3/src/asset-imports/asset-import-profiles.ts create mode 100644 api-v3/src/asset-imports/asset-imports.controller.ts create mode 100644 api-v3/src/asset-imports/asset-imports.module.ts create mode 100644 api-v3/src/asset-imports/asset-imports.service.ts create mode 100644 api-v3/src/asset-imports/dto/apply-asset-import-plan.dto.ts create mode 100644 api-v3/src/asset-imports/dto/cancel-asset-import.dto.ts create mode 100644 api-v3/src/asset-imports/dto/create-asset-import-plan.dto.ts create mode 100644 api-v3/src/asset-imports/dto/list-asset-import-plan-items-query.dto.ts create mode 100644 api-v3/src/asset-imports/dto/list-asset-import-reviews-query.dto.ts create mode 100644 api-v3/src/asset-imports/dto/list-asset-import-rows-query.dto.ts create mode 100644 api-v3/src/asset-imports/dto/list-asset-imports-query.dto.ts create mode 100644 api-v3/src/asset-imports/dto/resolve-asset-import-plan-item.dto.ts create mode 100644 api-v3/src/asset-imports/dto/rollback-asset-import-plan.dto.ts create mode 100644 api-v3/src/asset-imports/dto/upload-asset-import.dto.ts create mode 100644 api-v3/src/asset-master/asset-attribute-validator.ts create mode 100644 api-v3/src/asset-master/asset-geometries.controller.ts create mode 100644 api-v3/src/asset-master/asset-geometries.service.ts create mode 100644 api-v3/src/asset-master/asset-geometry-validator.ts create mode 100644 api-v3/src/asset-master/asset-history.controller.ts create mode 100644 api-v3/src/asset-master/asset-history.service.ts create mode 100644 api-v3/src/asset-master/asset-master-bootstrap.ts create mode 100644 api-v3/src/asset-master/asset-master.module.ts create mode 100644 api-v3/src/asset-master/asset-media-file.ts create mode 100644 api-v3/src/asset-master/asset-media.controller.ts create mode 100644 api-v3/src/asset-master/asset-media.service.ts create mode 100644 api-v3/src/asset-master/asset-operational-relations.controller.ts create mode 100644 api-v3/src/asset-master/asset-operational-relations.service.ts create mode 100644 api-v3/src/asset-master/asset-provenance.controller.ts create mode 100644 api-v3/src/asset-master/asset-provenance.service.ts create mode 100644 api-v3/src/asset-master/asset-registry.controller.ts create mode 100644 api-v3/src/asset-master/asset-registry.service.ts create mode 100644 api-v3/src/asset-master/asset-temporal.controller.ts create mode 100644 api-v3/src/asset-master/asset-temporal.service.ts create mode 100644 api-v3/src/asset-master/asset-types.controller.ts create mode 100644 api-v3/src/asset-master/asset-types.service.ts create mode 100644 api-v3/src/asset-master/asset-version-diff.ts create mode 100644 api-v3/src/asset-master/assets.controller.ts create mode 100644 api-v3/src/asset-master/assets.service.ts create mode 100644 api-v3/src/asset-master/dto/add-area-legal-right-organization.dto.ts create mode 100644 api-v3/src/asset-master/dto/add-organization-membership.dto.ts create mode 100644 api-v3/src/asset-master/dto/change-asset-context.dto.ts create mode 100644 api-v3/src/asset-master/dto/change-asset-operational-status.dto.ts create mode 100644 api-v3/src/asset-master/dto/change-asset-status.dto.ts create mode 100644 api-v3/src/asset-master/dto/create-area-company-relation.dto.ts create mode 100644 api-v3/src/asset-master/dto/create-area-legal-right.dto.ts create mode 100644 api-v3/src/asset-master/dto/create-asset-media.dto.ts create mode 100644 api-v3/src/asset-master/dto/create-asset-type.dto.ts create mode 100644 api-v3/src/asset-master/dto/create-asset.dto.ts create mode 100644 api-v3/src/asset-master/dto/create-attribute-definition.dto.ts create mode 100644 api-v3/src/asset-master/dto/create-external-identifier.dto.ts create mode 100644 api-v3/src/asset-master/dto/create-field-discovery.dto.ts create mode 100644 api-v3/src/asset-master/dto/create-source-document.dto.ts create mode 100644 api-v3/src/asset-master/dto/end-area-company-relation.dto.ts create mode 100644 api-v3/src/asset-master/dto/end-area-legal-right-organization.dto.ts create mode 100644 api-v3/src/asset-master/dto/end-external-identifier.dto.ts create mode 100644 api-v3/src/asset-master/dto/end-organization-membership.dto.ts create mode 100644 api-v3/src/asset-master/dto/link-asset-source-document.dto.ts create mode 100644 api-v3/src/asset-master/dto/list-area-company-relations-query.dto.ts create mode 100644 api-v3/src/asset-master/dto/list-asset-tree-children-query.dto.ts create mode 100644 api-v3/src/asset-master/dto/list-asset-tree-query.dto.ts create mode 100644 api-v3/src/asset-master/dto/list-asset-versions-query.dto.ts create mode 100644 api-v3/src/asset-master/dto/list-assets-query.dto.ts create mode 100644 api-v3/src/asset-master/dto/list-field-discoveries-query.dto.ts create mode 100644 api-v3/src/asset-master/dto/list-operational-areas-query.dto.ts create mode 100644 api-v3/src/asset-master/dto/list-temporal-assets-query.dto.ts create mode 100644 api-v3/src/asset-master/dto/map-assets-query.dto.ts create mode 100644 api-v3/src/asset-master/dto/parent-options-query.dto.ts create mode 100644 api-v3/src/asset-master/dto/review-field-discovery.dto.ts create mode 100644 api-v3/src/asset-master/dto/update-area-legal-right.dto.ts create mode 100644 api-v3/src/asset-master/dto/update-asset-media.dto.ts create mode 100644 api-v3/src/asset-master/dto/update-asset-provenance.dto.ts create mode 100644 api-v3/src/asset-master/dto/update-asset-type.dto.ts create mode 100644 api-v3/src/asset-master/dto/update-asset.dto.ts create mode 100644 api-v3/src/asset-master/dto/update-attribute-definition.dto.ts create mode 100644 api-v3/src/asset-master/dto/upsert-asset-geometry.dto.ts create mode 100644 api-v3/src/asset-master/dto/upsert-organization-profile.dto.ts create mode 100644 api-v3/src/audit/audit-query.service.ts create mode 100644 api-v3/src/audit/audit-query.ts create mode 100644 api-v3/src/audit/audit-sanitizer.ts create mode 100644 api-v3/src/audit/audit.controller.ts create mode 100644 api-v3/src/audit/audit.module.ts create mode 100644 api-v3/src/audit/audit.service.ts create mode 100644 api-v3/src/audit/dto/list-audit-query.dto.ts create mode 100644 api-v3/src/auth/auth.controller.ts create mode 100644 api-v3/src/auth/auth.module.ts create mode 100644 api-v3/src/auth/auth.service.ts create mode 100644 api-v3/src/auth/decorators/current-auth.decorator.ts create mode 100644 api-v3/src/auth/decorators/public.decorator.ts create mode 100644 api-v3/src/auth/decorators/skip-csrf.decorator.ts create mode 100644 api-v3/src/auth/dto/change-password.dto.ts create mode 100644 api-v3/src/auth/dto/login.dto.ts create mode 100644 api-v3/src/auth/guards/access-token.guard.ts create mode 100644 api-v3/src/auth/guards/csrf.guard.ts create mode 100644 api-v3/src/auth/interfaces/access-token-payload.ts create mode 100644 api-v3/src/auth/services/cookie.service.ts create mode 100644 api-v3/src/auth/services/password.service.ts create mode 100644 api-v3/src/auth/services/token.service.ts create mode 100644 api-v3/src/authorization/authorization.module.ts create mode 100644 api-v3/src/authorization/decorators/require-permissions.decorator.ts create mode 100644 api-v3/src/authorization/guards/permissions.guard.ts create mode 100644 api-v3/src/authorization/permission-resolver.service.ts create mode 100644 api-v3/src/cli/bootstrap-admin.ts create mode 100644 api-v3/src/cli/dev-seed-mendoza-demo.ts create mode 100644 api-v3/src/common/config/auth-config.service.ts create mode 100644 api-v3/src/common/filters/api-exception.filter.ts create mode 100644 api-v3/src/common/http/request-context.ts create mode 100644 api-v3/src/common/http/request-id.middleware.ts create mode 100644 api-v3/src/core-data/phase-a-data.module.ts create mode 100644 api-v3/src/core-data/repositories/audit-events.repository.ts create mode 100644 api-v3/src/core-data/repositories/auth-sessions.repository.ts create mode 100644 api-v3/src/core-data/repositories/roles.repository.ts create mode 100644 api-v3/src/core-data/repositories/users.repository.ts create mode 100644 api-v3/src/dashboard/dashboard.controller.ts create mode 100644 api-v3/src/dashboard/dashboard.module.ts create mode 100644 api-v3/src/dashboard/dashboard.service.ts create mode 100644 api-v3/src/database/data-source.ts create mode 100644 api-v3/src/database/entities/area-company-relation.entity.ts create mode 100644 api-v3/src/database/entities/area-legal-right-organization.entity.ts create mode 100644 api-v3/src/database/entities/area-legal-right.entity.ts create mode 100644 api-v3/src/database/entities/asset-attribute-definition.entity.ts create mode 100644 api-v3/src/database/entities/asset-attribute-value.entity.ts create mode 100644 api-v3/src/database/entities/asset-external-identifier.entity.ts create mode 100644 api-v3/src/database/entities/asset-geometry.entity.ts create mode 100644 api-v3/src/database/entities/asset-media.entity.ts create mode 100644 api-v3/src/database/entities/asset-source-document.entity.ts create mode 100644 api-v3/src/database/entities/asset-type-parent-rule.entity.ts create mode 100644 api-v3/src/database/entities/asset-type.entity.ts create mode 100644 api-v3/src/database/entities/asset-version.entity.ts create mode 100644 api-v3/src/database/entities/asset.entity.ts create mode 100644 api-v3/src/database/entities/audit-event.entity.ts create mode 100644 api-v3/src/database/entities/auth-session.entity.ts create mode 100644 api-v3/src/database/entities/document-annual-sequence.entity.ts create mode 100644 api-v3/src/database/entities/finding-catalog-asset-override.entity.ts create mode 100644 api-v3/src/database/entities/finding-catalog-asset-type-profile.entity.ts create mode 100644 api-v3/src/database/entities/finding-catalog-item-asset-type.entity.ts create mode 100644 api-v3/src/database/entities/finding-catalog-item.entity.ts create mode 100644 api-v3/src/database/entities/finding-catalog-proposal.entity.ts create mode 100644 api-v3/src/database/entities/finding-category.entity.ts create mode 100644 api-v3/src/database/entities/index.ts create mode 100644 api-v3/src/database/entities/inspection-act-asset.entity.ts create mode 100644 api-v3/src/database/entities/inspection-act-closure.entity.ts create mode 100644 api-v3/src/database/entities/inspection-act-responsible.entity.ts create mode 100644 api-v3/src/database/entities/inspection-act-signature.entity.ts create mode 100644 api-v3/src/database/entities/inspection-act-version.entity.ts create mode 100644 api-v3/src/database/entities/inspection-act.entity.ts create mode 100644 api-v3/src/database/entities/inspection-finding-communication.entity.ts create mode 100644 api-v3/src/database/entities/inspection-finding-evidence.entity.ts create mode 100644 api-v3/src/database/entities/inspection-finding-verification-event.entity.ts create mode 100644 api-v3/src/database/entities/inspection-finding-verification-visit.entity.ts create mode 100644 api-v3/src/database/entities/inspection-finding-version.entity.ts create mode 100644 api-v3/src/database/entities/inspection-finding.entity.ts create mode 100644 api-v3/src/database/entities/inspection-report.entity.ts create mode 100644 api-v3/src/database/entities/inspection-visit-asset.entity.ts create mode 100644 api-v3/src/database/entities/inspection-visit-member.entity.ts create mode 100644 api-v3/src/database/entities/inspection-visit.entity.ts create mode 100644 api-v3/src/database/entities/organization-membership.entity.ts create mode 100644 api-v3/src/database/entities/organization-profile.entity.ts create mode 100644 api-v3/src/database/entities/permission.entity.ts create mode 100644 api-v3/src/database/entities/role-permission.entity.ts create mode 100644 api-v3/src/database/entities/role.entity.ts create mode 100644 api-v3/src/database/entities/source-document.entity.ts create mode 100644 api-v3/src/database/entities/survey-campaign-target.entity.ts create mode 100644 api-v3/src/database/entities/survey-campaign.entity.ts create mode 100644 api-v3/src/database/entities/survey-target-report-media.entity.ts create mode 100644 api-v3/src/database/entities/survey-target-report-version.entity.ts create mode 100644 api-v3/src/database/entities/survey-target-report.entity.ts create mode 100644 api-v3/src/database/entities/timestamped.entity.ts create mode 100644 api-v3/src/database/entities/user-role.entity.ts create mode 100644 api-v3/src/database/entities/user.entity.ts create mode 100644 api-v3/src/database/migration-cli.ts create mode 100644 api-v3/src/database/migrations/1786548000000-phase-a-users-roles-permissions.ts create mode 100644 api-v3/src/database/migrations/1786548001000-phase-a-auth-sessions.ts create mode 100644 api-v3/src/database/migrations/1786548002000-phase-a-audit-events.ts create mode 100644 api-v3/src/database/migrations/1786548003000-phase-a-seed-roles-permissions.ts create mode 100644 api-v3/src/database/migrations/1786548004000-phase-a-runtime-grants.ts create mode 100644 api-v3/src/database/migrations/1786548005000-phase-a2-session-security-indexes.ts create mode 100644 api-v3/src/database/migrations/1786636800000-phase-b1-asset-master.ts create mode 100644 api-v3/src/database/migrations/1786723200000-phase-b2-asset-geometries.ts create mode 100644 api-v3/src/database/migrations/1786723201000-phase-b3-asset-versions.ts create mode 100644 api-v3/src/database/migrations/1786723202000-phase-b4-asset-media.ts create mode 100644 api-v3/src/database/migrations/1786723203000-phase-b5-provenance-temporal.ts create mode 100644 api-v3/src/database/migrations/1786809600000-phase-c1-survey-planning.ts create mode 100644 api-v3/src/database/migrations/1786896000000-phase-c2-survey-execution.ts create mode 100644 api-v3/src/database/migrations/1786982400000-phase-d1-inspection-visits.ts create mode 100644 api-v3/src/database/migrations/1787068800000-phase-d2-inspection-acts.ts create mode 100644 api-v3/src/database/migrations/1787072400000-phase-d2-1-single-act-per-visit.ts create mode 100644 api-v3/src/database/migrations/1787155200000-phase-d3-inspection-findings.ts create mode 100644 api-v3/src/database/migrations/1787241600000-phase-d4-finding-evidence-communications.ts create mode 100644 api-v3/src/database/migrations/1787328000000-phase-d5-act-closing-signatures.ts create mode 100644 api-v3/src/database/migrations/1787331600000-phase-d5-1-mobile-inspection-policy.ts create mode 100644 api-v3/src/database/migrations/1787335200000-phase-d5-2-finding-catalog-administration.ts create mode 100644 api-v3/src/database/migrations/1787421600000-phase-d5-3-operational-context.ts create mode 100644 api-v3/src/database/migrations/1787508000000-phase-d5-3-3-definitive-operational-model.ts create mode 100644 api-v3/src/database/migrations/1787594400000-phase-d5-3-5-import-center-scalability.ts create mode 100644 api-v3/src/database/migrations/1787680800000-phase-d5-3-7-import-planning-application.ts create mode 100644 api-v3/src/database/migrations/1787767200000-phase-d5-3-7-2-relational-territory-plan.ts create mode 100644 api-v3/src/database/migrations/1787853600000-phase-d5-3-8-local-inventory-structure.ts create mode 100644 api-v3/src/database/migrations/1788026400000-phase-d5-3-15-document-center.ts create mode 100644 api-v3/src/database/migrations/1788112800000-phase-d5-3-16-verification-planning.ts create mode 100644 api-v3/src/database/migrations/1788199200000-phase-d5-3-17-verification-execution.ts create mode 100644 api-v3/src/database/migrations/1788285600000-phase-d5-3-18-3-office-follow-up-hardening.ts create mode 100644 api-v3/src/database/migrations/1788372000000-phase-d5-3-19-inventory-aliases-operational-filters.ts create mode 100644 api-v3/src/database/migrations/1788458400000-phase-d5-3-20-automatic-word-report.ts create mode 100644 api-v3/src/database/migrations/1788544800000-phase-d5-3-21-document-delivery.ts create mode 100644 api-v3/src/database/migrations/1788631200000-phase-d5-3-22-director-report-review.ts create mode 100644 api-v3/src/database/migrations/1788717600000-phase-d5-3-23-field-discoveries.ts create mode 100644 api-v3/src/database/migrations/1788804000000-phase-d5-3-24-asset-context-history.ts create mode 100644 api-v3/src/database/migrations/1788890400000-phase-d5-3-25-verification-event-history.ts create mode 100644 api-v3/src/database/migrations/1788976800000-phase-d5-4-contextual-finding-catalog.ts create mode 100644 api-v3/src/database/migrations/1789063200000-phase-d5-5-web-planning-checklist.ts create mode 100644 api-v3/src/database/seeds/phase-d3-finding-catalog.ts create mode 100644 api-v3/src/health.controller.ts create mode 100644 api-v3/src/health.service.ts create mode 100644 api-v3/src/inspection-acts/dto/cancel-inspection-act.dto.ts create mode 100644 api-v3/src/inspection-acts/dto/create-inspection-act.dto.ts create mode 100644 api-v3/src/inspection-acts/dto/list-inspection-acts-query.dto.ts create mode 100644 api-v3/src/inspection-acts/dto/update-inspection-act.dto.ts create mode 100644 api-v3/src/inspection-acts/inspection-acts.controller.ts create mode 100644 api-v3/src/inspection-acts/inspection-acts.module.ts create mode 100644 api-v3/src/inspection-acts/inspection-acts.service.ts create mode 100644 api-v3/src/inspection-closing/canonical-json.ts create mode 100644 api-v3/src/inspection-closing/dto/close-inspection-act.dto.ts create mode 100644 api-v3/src/inspection-closing/dto/create-company-outcome.dto.ts create mode 100644 api-v3/src/inspection-closing/dto/create-inspection-signature.dto.ts create mode 100644 api-v3/src/inspection-closing/dto/upsert-inspection-responsible.dto.ts create mode 100644 api-v3/src/inspection-closing/inspection-closing.controller.ts create mode 100644 api-v3/src/inspection-closing/inspection-closing.module.ts create mode 100644 api-v3/src/inspection-closing/inspection-closing.service.ts create mode 100644 api-v3/src/inspection-closing/inspection-signature-file.ts create mode 100644 api-v3/src/inspection-findings/dto/close-inspection-finding.dto.ts create mode 100644 api-v3/src/inspection-findings/dto/create-finding-catalog-item.dto.ts create mode 100644 api-v3/src/inspection-findings/dto/create-finding-category.dto.ts create mode 100644 api-v3/src/inspection-findings/dto/create-inspection-communication.dto.ts create mode 100644 api-v3/src/inspection-findings/dto/create-inspection-evidence.dto.ts create mode 100644 api-v3/src/inspection-findings/dto/create-inspection-finding.dto.ts create mode 100644 api-v3/src/inspection-findings/dto/list-finding-catalog-proposals-query.dto.ts create mode 100644 api-v3/src/inspection-findings/dto/list-finding-catalog-query.dto.ts create mode 100644 api-v3/src/inspection-findings/dto/list-inspection-findings-query.dto.ts create mode 100644 api-v3/src/inspection-findings/dto/replace-finding-catalog-selection.dto.ts create mode 100644 api-v3/src/inspection-findings/dto/review-finding-catalog-proposal.dto.ts create mode 100644 api-v3/src/inspection-findings/dto/update-finding-catalog-item.dto.ts create mode 100644 api-v3/src/inspection-findings/dto/update-finding-category.dto.ts create mode 100644 api-v3/src/inspection-findings/dto/update-inspection-finding-follow-up.dto.ts create mode 100644 api-v3/src/inspection-findings/dto/update-inspection-finding.dto.ts create mode 100644 api-v3/src/inspection-findings/finding-catalog.controller.ts create mode 100644 api-v3/src/inspection-findings/finding-catalog.service.ts create mode 100644 api-v3/src/inspection-findings/inspection-evidence-file.ts create mode 100644 api-v3/src/inspection-findings/inspection-evidence.controller.ts create mode 100644 api-v3/src/inspection-findings/inspection-evidence.service.ts create mode 100644 api-v3/src/inspection-findings/inspection-findings.controller.ts create mode 100644 api-v3/src/inspection-findings/inspection-findings.module.ts create mode 100644 api-v3/src/inspection-findings/inspection-findings.service.ts create mode 100644 api-v3/src/inspection-operations/field-discovery-inspection-link.service.ts create mode 100644 api-v3/src/inspection-operations/mobile-inspector-policy.ts create mode 100644 api-v3/src/inspection-reports/document-delivery.controller.ts create mode 100644 api-v3/src/inspection-reports/dto/approve-inspection-report.dto.ts create mode 100644 api-v3/src/inspection-reports/dto/create-inspection-report-revision.dto.ts create mode 100644 api-v3/src/inspection-reports/dto/list-inspection-reports-query.dto.ts create mode 100644 api-v3/src/inspection-reports/dto/sign-final-inspection-report.dto.ts create mode 100644 api-v3/src/inspection-reports/dto/update-document-delivery-settings.dto.ts create mode 100644 api-v3/src/inspection-reports/inspection-act-pdf-builder.ts create mode 100644 api-v3/src/inspection-reports/inspection-act-pdf.service.ts create mode 100644 api-v3/src/inspection-reports/inspection-document-delivery.service.ts create mode 100644 api-v3/src/inspection-reports/inspection-report-review.controller.ts create mode 100644 api-v3/src/inspection-reports/inspection-report-review.service.ts create mode 100644 api-v3/src/inspection-reports/inspection-report-revision-file.ts create mode 100644 api-v3/src/inspection-reports/inspection-report-word-builder.ts create mode 100644 api-v3/src/inspection-reports/inspection-report-word.service.ts create mode 100644 api-v3/src/inspection-reports/inspection-reports.controller.ts create mode 100644 api-v3/src/inspection-reports/inspection-reports.module.ts create mode 100644 api-v3/src/inspection-reports/inspection-reports.service.ts create mode 100644 api-v3/src/inspection-reports/smtp-delivery.service.ts create mode 100644 api-v3/src/inspection-verifications/dto/list-verification-planning-query.dto.ts create mode 100644 api-v3/src/inspection-verifications/dto/plan-verification-visit.dto.ts create mode 100644 api-v3/src/inspection-verifications/dto/record-verification-result.dto.ts create mode 100644 api-v3/src/inspection-verifications/inspection-verifications.controller.ts create mode 100644 api-v3/src/inspection-verifications/inspection-verifications.module.ts create mode 100644 api-v3/src/inspection-verifications/inspection-verifications.service.ts create mode 100644 api-v3/src/inspection-verifications/verification-event-ledger.ts create mode 100644 api-v3/src/inspection-visits/dto/change-inspection-visit-status.dto.ts create mode 100644 api-v3/src/inspection-visits/dto/create-inspection-visit.dto.ts create mode 100644 api-v3/src/inspection-visits/dto/exclude-inspection-visit-asset.dto.ts create mode 100644 api-v3/src/inspection-visits/dto/list-inspection-visits-query.dto.ts create mode 100644 api-v3/src/inspection-visits/dto/replace-inspection-visit-assets.dto.ts create mode 100644 api-v3/src/inspection-visits/dto/replace-inspection-visit-team.dto.ts create mode 100644 api-v3/src/inspection-visits/dto/update-inspection-visit.dto.ts create mode 100644 api-v3/src/inspection-visits/inspection-visit-code.ts create mode 100644 api-v3/src/inspection-visits/inspection-visits.controller.ts create mode 100644 api-v3/src/inspection-visits/inspection-visits.module.ts create mode 100644 api-v3/src/inspection-visits/inspection-visits.service.ts create mode 100644 api-v3/src/main.ts create mode 100644 api-v3/src/survey-execution/dto/review-survey-report.dto.ts create mode 100644 api-v3/src/survey-execution/dto/save-survey-report.dto.ts create mode 100644 api-v3/src/survey-execution/survey-execution.controller.ts create mode 100644 api-v3/src/survey-execution/survey-execution.module.ts create mode 100644 api-v3/src/survey-execution/survey-execution.service.ts create mode 100644 api-v3/src/survey-planning/dto/add-survey-target.dto.ts create mode 100644 api-v3/src/survey-planning/dto/assign-survey-target.dto.ts create mode 100644 api-v3/src/survey-planning/dto/change-survey-campaign-status.dto.ts create mode 100644 api-v3/src/survey-planning/dto/change-survey-target-status.dto.ts create mode 100644 api-v3/src/survey-planning/dto/create-survey-campaign.dto.ts create mode 100644 api-v3/src/survey-planning/dto/list-survey-campaigns-query.dto.ts create mode 100644 api-v3/src/survey-planning/dto/update-survey-campaign.dto.ts create mode 100644 api-v3/src/survey-planning/dto/update-survey-target.dto.ts create mode 100644 api-v3/src/survey-planning/survey-campaigns.controller.ts create mode 100644 api-v3/src/survey-planning/survey-planning.module.ts create mode 100644 api-v3/src/survey-planning/survey-planning.service.ts create mode 100644 api-v3/src/version.ts create mode 100644 api-v3/test/unit/administration-authorization.test.ts create mode 100644 api-v3/test/unit/administration-dto.test.ts create mode 100644 api-v3/test/unit/administration-integrity.test.ts create mode 100644 api-v3/test/unit/asset-attribute-validator.test.ts create mode 100644 api-v3/test/unit/asset-center-d5-3-4.test.ts create mode 100644 api-v3/test/unit/asset-dossier-d5-3-14.test.ts create mode 100644 api-v3/test/unit/asset-geometry-authorization.test.ts create mode 100644 api-v3/test/unit/asset-geometry-validator.test.ts create mode 100644 api-v3/test/unit/asset-history-authorization.test.ts create mode 100644 api-v3/test/unit/asset-history-capture.test.ts create mode 100644 api-v3/test/unit/asset-history-dto.test.ts create mode 100644 api-v3/test/unit/asset-import-profiles.test.ts create mode 100644 api-v3/test/unit/asset-imports-authorization.test.ts create mode 100644 api-v3/test/unit/asset-imports-d5-3-11.test.ts create mode 100644 api-v3/test/unit/asset-imports-d5-3-6.test.ts create mode 100644 api-v3/test/unit/asset-imports-d5-3-7-2.test.ts create mode 100644 api-v3/test/unit/asset-imports-d5-3-7.test.ts create mode 100644 api-v3/test/unit/asset-imports-integrity.test.ts create mode 100644 api-v3/test/unit/asset-master-authorization.test.ts create mode 100644 api-v3/test/unit/asset-master-bootstrap.test.ts create mode 100644 api-v3/test/unit/asset-master-dto.test.ts create mode 100644 api-v3/test/unit/asset-media-authorization.test.ts create mode 100644 api-v3/test/unit/asset-media-dto.test.ts create mode 100644 api-v3/test/unit/asset-media-file.test.ts create mode 100644 api-v3/test/unit/asset-navigation-d5-3-10-2.test.ts create mode 100644 api-v3/test/unit/asset-navigation-d5-3-11-1.test.ts create mode 100644 api-v3/test/unit/asset-operational-relations-authorization.test.ts create mode 100644 api-v3/test/unit/asset-operational-relations-dto.test.ts create mode 100644 api-v3/test/unit/asset-operational-relations-integrity.test.ts create mode 100644 api-v3/test/unit/asset-provenance-authorization.test.ts create mode 100644 api-v3/test/unit/asset-provenance-dto.test.ts create mode 100644 api-v3/test/unit/asset-provenance-service.test.ts create mode 100644 api-v3/test/unit/asset-registry-authorization.test.ts create mode 100644 api-v3/test/unit/asset-registry-dto.test.ts create mode 100644 api-v3/test/unit/asset-temporal-service.test.ts create mode 100644 api-v3/test/unit/asset-version-diff.test.ts create mode 100644 api-v3/test/unit/audit-controller.test.ts create mode 100644 api-v3/test/unit/audit-query-dto.test.ts create mode 100644 api-v3/test/unit/audit-query.test.ts create mode 100644 api-v3/test/unit/audit-sanitizer.test.ts create mode 100644 api-v3/test/unit/dashboard-controller.test.ts create mode 100644 api-v3/test/unit/dashboard-service.test.ts create mode 100644 api-v3/test/unit/finding-catalog-administration-integrity.test.ts create mode 100644 api-v3/test/unit/finding-catalog-seed.test.ts create mode 100644 api-v3/test/unit/inspection-acts-authorization.test.ts create mode 100644 api-v3/test/unit/inspection-acts-dto.test.ts create mode 100644 api-v3/test/unit/inspection-acts-single-act.test.ts create mode 100644 api-v3/test/unit/inspection-closing-authorization.test.ts create mode 100644 api-v3/test/unit/inspection-closing-canonical.test.ts create mode 100644 api-v3/test/unit/inspection-closing-dto.test.ts create mode 100644 api-v3/test/unit/inspection-closing-file.test.ts create mode 100644 api-v3/test/unit/inspection-closing-integrity.test.ts create mode 100644 api-v3/test/unit/inspection-evidence-authorization.test.ts create mode 100644 api-v3/test/unit/inspection-evidence-dto.test.ts create mode 100644 api-v3/test/unit/inspection-evidence-file.test.ts create mode 100644 api-v3/test/unit/inspection-evidence-integrity.test.ts create mode 100644 api-v3/test/unit/inspection-findings-authorization.test.ts create mode 100644 api-v3/test/unit/inspection-findings-dto.test.ts create mode 100644 api-v3/test/unit/inspection-findings-office-workflow.test.ts create mode 100644 api-v3/test/unit/inspection-reports-authorization.test.ts create mode 100644 api-v3/test/unit/inspection-verifications-authorization.test.ts create mode 100644 api-v3/test/unit/inspection-visits-authorization.test.ts create mode 100644 api-v3/test/unit/inspection-visits-dto.test.ts create mode 100644 api-v3/test/unit/mobile-inspector-integration.test.ts create mode 100644 api-v3/test/unit/mobile-inspector-policy.test.ts create mode 100644 api-v3/test/unit/password.service.test.ts create mode 100644 api-v3/test/unit/permissions.guard.test.ts create mode 100644 api-v3/test/unit/phase-d5-3-15-document-center.test.ts create mode 100644 api-v3/test/unit/phase-d5-3-16-verification-planning.test.ts create mode 100644 api-v3/test/unit/phase-d5-3-17-verification-execution.test.ts create mode 100644 api-v3/test/unit/phase-d5-3-18-3-office-follow-up-hardening.test.ts create mode 100644 api-v3/test/unit/phase-d5-3-18-office-workspace.test.ts create mode 100644 api-v3/test/unit/phase-d5-3-19-inventory-filters-deadlines.test.ts create mode 100644 api-v3/test/unit/phase-d5-3-20-automatic-word-report.test.ts create mode 100644 api-v3/test/unit/phase-d5-3-21-document-delivery.test.ts create mode 100644 api-v3/test/unit/phase-d5-3-22-director-report-review.test.ts create mode 100644 api-v3/test/unit/phase-d5-3-23-field-discoveries.test.ts create mode 100644 api-v3/test/unit/phase-d5-3-24-asset-context-history.test.ts create mode 100644 api-v3/test/unit/phase-d5-3-25-verification-event-history.test.ts create mode 100644 api-v3/test/unit/phase-d5-3-3-definitive-model.test.ts create mode 100644 api-v3/test/unit/phase-d5-4-1-build-contract.test.ts create mode 100644 api-v3/test/unit/phase-d5-4-contextual-finding-catalog.test.ts create mode 100644 api-v3/test/unit/phase-d5-5-web-planning-checklist.test.ts create mode 100644 api-v3/test/unit/phase-d5-6-1-user-password-reset.test.ts create mode 100644 api-v3/test/unit/phase-d5-6-4-quick-inspection-planning.test.ts create mode 100644 api-v3/test/unit/phase-d5-6-phase-d-closure.test.ts create mode 100644 api-v3/test/unit/survey-execution-authorization.test.ts create mode 100644 api-v3/test/unit/survey-execution-dto.test.ts create mode 100644 api-v3/test/unit/survey-planning-authorization.test.ts create mode 100644 api-v3/test/unit/survey-planning-dto.test.ts create mode 100644 api-v3/test/unit/token.service.test.ts create mode 100644 api-v3/tsconfig.json create mode 100644 api-v3/tsconfig.test.json create mode 100644 docker-compose.yml create mode 100644 docs/BACKUP_INVENTARIO_A_REVISAR.md create mode 100644 docs/DATA_REFERENCE_MENDOZA_HYDROCARBONS.md create mode 100644 docs/DEV_DEMO_AND_CLEANUP.md create mode 100644 docs/DOMAIN_RESEARCH_MENDOZA_NOMENCLATURE.md create mode 100644 docs/HOTFIX_A2_1.md create mode 100644 docs/INSPECTION_DOCUMENT_MODEL.md create mode 100644 docs/INSTALL.md create mode 100644 docs/PHASE_A0_REVIEW.md create mode 100644 docs/PHASE_A1.md create mode 100644 docs/PHASE_A1_DEPLOY.md create mode 100644 docs/PHASE_A2.md create mode 100644 docs/PHASE_A2_DEPLOY.md create mode 100644 docs/PHASE_A3.md create mode 100644 docs/PHASE_A3_DEPLOY.md create mode 100644 docs/PHASE_A4.md create mode 100644 docs/PHASE_A4_DEPLOY.md create mode 100644 docs/PHASE_A5.md create mode 100644 docs/PHASE_A5_DEPLOY.md create mode 100644 docs/PHASE_A6.md create mode 100644 docs/PHASE_A6_DEPLOY.md create mode 100644 docs/PHASE_A7.md create mode 100644 docs/PHASE_A7_DEPLOY.md create mode 100644 docs/PHASE_A_FINAL_ACCEPTANCE.md create mode 100644 docs/PHASE_B1.md create mode 100644 docs/PHASE_B1_DEPLOY.md create mode 100644 docs/PHASE_B2.md create mode 100644 docs/PHASE_B2_DEPLOY.md create mode 100644 docs/PHASE_B3.md create mode 100644 docs/PHASE_B3_DEPLOY.md create mode 100644 docs/PHASE_B4.md create mode 100644 docs/PHASE_B4_DEPLOY.md create mode 100644 docs/PHASE_B5.md create mode 100644 docs/PHASE_B5_DEPLOY.md create mode 100644 docs/PHASE_C1.md create mode 100644 docs/PHASE_C1_DEPLOY.md create mode 100644 docs/PHASE_C2.md create mode 100644 docs/PHASE_C2_DEPLOY.md create mode 100644 docs/PHASE_D1.md create mode 100644 docs/PHASE_D1_DEPLOY.md create mode 100644 docs/PHASE_D2.md create mode 100644 docs/PHASE_D2_1.md create mode 100644 docs/PHASE_D2_1_DEPLOY.md create mode 100644 docs/PHASE_D2_DEPLOY.md create mode 100644 docs/PHASE_D3.md create mode 100644 docs/PHASE_D3_DEPLOY.md create mode 100644 docs/PHASE_D4.md create mode 100644 docs/PHASE_D4_DEPLOY.md create mode 100644 docs/PHASE_D5.md create mode 100644 docs/PHASE_D5_1.md create mode 100644 docs/PHASE_D5_1_DEPLOY.md create mode 100644 docs/PHASE_D5_2.md create mode 100644 docs/PHASE_D5_2_DEPLOY.md create mode 100644 docs/PHASE_D5_3.md create mode 100644 docs/PHASE_D5_3_1.md create mode 100644 docs/PHASE_D5_3_11.md create mode 100644 docs/PHASE_D5_3_11_1.md create mode 100644 docs/PHASE_D5_3_14.md create mode 100644 docs/PHASE_D5_3_15_1.md create mode 100644 docs/PHASE_D5_3_16.md create mode 100644 docs/PHASE_D5_3_16_DEPLOY.md create mode 100644 docs/PHASE_D5_3_17.md create mode 100644 docs/PHASE_D5_3_17_1.md create mode 100644 docs/PHASE_D5_3_18.md create mode 100644 docs/PHASE_D5_3_18_2.md create mode 100644 docs/PHASE_D5_3_18_3.md create mode 100644 docs/PHASE_D5_3_19.md create mode 100644 docs/PHASE_D5_3_19_1.md create mode 100644 docs/PHASE_D5_3_1_DEPLOY.md create mode 100644 docs/PHASE_D5_3_2.md create mode 100644 docs/PHASE_D5_3_20.md create mode 100644 docs/PHASE_D5_3_21.md create mode 100644 docs/PHASE_D5_3_21_1.md create mode 100644 docs/PHASE_D5_3_22.md create mode 100644 docs/PHASE_D5_3_23.md create mode 100644 docs/PHASE_D5_3_24.md create mode 100644 docs/PHASE_D5_3_25.md create mode 100644 docs/PHASE_D5_3_2_1.md create mode 100644 docs/PHASE_D5_3_2_DEPLOY.md create mode 100644 docs/PHASE_D5_3_3.md create mode 100644 docs/PHASE_D5_3_3_DEPLOY.md create mode 100644 docs/PHASE_D5_3_4.md create mode 100644 docs/PHASE_D5_3_4_DEPLOY.md create mode 100644 docs/PHASE_D5_3_5.md create mode 100644 docs/PHASE_D5_3_5_1.md create mode 100644 docs/PHASE_D5_3_5_DEPLOY.md create mode 100644 docs/PHASE_D5_3_6.md create mode 100644 docs/PHASE_D5_3_6_3.md create mode 100644 docs/PHASE_D5_3_7.md create mode 100644 docs/PHASE_D5_3_7_2.md create mode 100644 docs/PHASE_D5_3_7_2_1.md create mode 100644 docs/PHASE_D5_3_7_2_2.md create mode 100644 docs/PHASE_D5_3_7_2_3.md create mode 100644 docs/PHASE_D5_3_7_2_4.md create mode 100644 docs/PHASE_D5_3_7_2_5.md create mode 100644 docs/PHASE_D5_3_7_2_DEPLOY.md create mode 100644 docs/PHASE_D5_3_7_DEPLOY.md create mode 100644 docs/PHASE_D5_3_8.md create mode 100644 docs/PHASE_D5_3_9.md create mode 100644 docs/PHASE_D5_3_DEPLOY.md create mode 100644 docs/PHASE_D5_4.md create mode 100644 docs/PHASE_D5_4_1.md create mode 100644 docs/PHASE_D5_5.md create mode 100644 docs/PHASE_D5_5_2.md create mode 100644 docs/PHASE_D5_6.md create mode 100644 docs/PHASE_D5_6_1.md create mode 100644 docs/PHASE_D5_6_2.md create mode 100644 docs/PHASE_D5_DEPLOY.md create mode 100644 docs/PHASE_D_CLOSED.md create mode 100644 docs/REAL_INVENTORY_IMPORT_RESEARCH.md create mode 100644 docs/RELATIONAL_TERRITORY_RESEARCH.md create mode 100644 docs/RELEASE_A2.md create mode 100644 docs/RESTORE_RUNBOOK.md create mode 100644 docs/ROADMAP_AFTER_D3.md create mode 100644 docs/ROADMAP_AFTER_D4.md create mode 100644 docs/ROADMAP_AFTER_D5.md create mode 100644 docs/ROADMAP_AFTER_D5_1.md create mode 100644 docs/ROADMAP_AFTER_D5_2.md create mode 100644 docs/ROADMAP_AFTER_D5_3.md create mode 100644 docs/ROADMAP_AFTER_D5_3_1.md create mode 100644 docs/ROADMAP_AFTER_D5_3_3.md create mode 100644 docs/ROADMAP_AFTER_D5_3_4.md create mode 100644 docs/ROADMAP_AFTER_D5_3_5.md create mode 100644 docs/ROADMAP_AFTER_D5_3_7.md create mode 100644 docs/ROADMAP_AFTER_D5_3_7_2.md create mode 100644 docs/ROADMAP_AFTER_D5_3_9.md create mode 100644 docs/nginx-site.conf create mode 100755 infra/db-init/001-init.sh create mode 100755 scripts/backup-inventario-a-revisar.sh create mode 100755 scripts/backup-phase-a.sh create mode 100755 scripts/check-dashboard-d5-6-3.sh create mode 100755 scripts/check-phase-d5-6-4.sh create mode 100755 scripts/deploy-dashboard-d5-6-3.sh create mode 100755 scripts/deploy-phase-d5-3-1.sh create mode 100755 scripts/deploy-phase-d5-3-10-2.sh create mode 100755 scripts/deploy-phase-d5-3-11-1.sh create mode 100755 scripts/deploy-phase-d5-3-11.sh create mode 100755 scripts/deploy-phase-d5-3-12.sh create mode 100755 scripts/deploy-phase-d5-3-13.sh create mode 100755 scripts/deploy-phase-d5-3-14.sh create mode 100755 scripts/deploy-phase-d5-3-15-1.sh create mode 100755 scripts/deploy-phase-d5-3-16.sh create mode 100755 scripts/deploy-phase-d5-3-17-1.sh create mode 100755 scripts/deploy-phase-d5-3-17.sh create mode 100755 scripts/deploy-phase-d5-3-18-2.sh create mode 100755 scripts/deploy-phase-d5-3-18-3.sh create mode 100755 scripts/deploy-phase-d5-3-18.sh create mode 100755 scripts/deploy-phase-d5-3-19-1.sh create mode 100755 scripts/deploy-phase-d5-3-2-1.sh create mode 100755 scripts/deploy-phase-d5-3-2.sh create mode 100755 scripts/deploy-phase-d5-3-20.sh create mode 100755 scripts/deploy-phase-d5-3-21-1.sh create mode 100755 scripts/deploy-phase-d5-3-22.sh create mode 100755 scripts/deploy-phase-d5-3-23-1.sh create mode 100755 scripts/deploy-phase-d5-3-24.sh create mode 100755 scripts/deploy-phase-d5-3-25.sh create mode 100755 scripts/deploy-phase-d5-3-3.sh create mode 100755 scripts/deploy-phase-d5-3-4.sh create mode 100755 scripts/deploy-phase-d5-3-5-1.sh create mode 100755 scripts/deploy-phase-d5-3-5.sh create mode 100755 scripts/deploy-phase-d5-3-6-3.sh create mode 100755 scripts/deploy-phase-d5-3-6.sh create mode 100755 scripts/deploy-phase-d5-3-7-1.sh create mode 100755 scripts/deploy-phase-d5-3-7-2-1.sh create mode 100755 scripts/deploy-phase-d5-3-7-2-2.sh create mode 100755 scripts/deploy-phase-d5-3-7-2-3.sh create mode 100755 scripts/deploy-phase-d5-3-7-2-4.sh create mode 100755 scripts/deploy-phase-d5-3-7-2-5.sh create mode 100755 scripts/deploy-phase-d5-3-7-2.sh create mode 100755 scripts/deploy-phase-d5-3-8.sh create mode 100755 scripts/deploy-phase-d5-3-9.sh create mode 100755 scripts/deploy-phase-d5-4-1.sh create mode 100755 scripts/deploy-phase-d5-5-1.sh create mode 100755 scripts/deploy-phase-d5-5-2.sh create mode 100755 scripts/deploy-phase-d5-5.sh create mode 100755 scripts/deploy-phase-d5-6-2.sh create mode 100755 scripts/deploy-phase-d5-6-4.sh create mode 100755 scripts/deploy-phase-d5-6.sh create mode 100755 scripts/dev-clean-demo.sh create mode 100755 scripts/dev-seed-mendoza-demo.sh create mode 100755 scripts/phase-a-acceptance.sh create mode 100755 scripts/phase-b1-acceptance.sh create mode 100755 scripts/phase-b2-acceptance.sh create mode 100755 scripts/phase-b3-acceptance.sh create mode 100755 scripts/phase-b4-acceptance.sh create mode 100755 scripts/phase-b5-acceptance.sh create mode 100755 scripts/phase-c1-acceptance.sh create mode 100755 scripts/phase-c2-acceptance.sh create mode 100755 scripts/phase-d1-acceptance.sh create mode 100755 scripts/phase-d2-1-acceptance.sh create mode 100755 scripts/phase-d2-acceptance.sh create mode 100755 scripts/phase-d3-acceptance.sh create mode 100755 scripts/phase-d4-acceptance.sh create mode 100755 scripts/phase-d5-1-acceptance.sh create mode 100755 scripts/phase-d5-2-acceptance.sh create mode 100755 scripts/phase-d5-3-1-acceptance.sh create mode 100755 scripts/phase-d5-3-10-2-acceptance.sh create mode 100755 scripts/phase-d5-3-11-1-acceptance.sh create mode 100755 scripts/phase-d5-3-11-acceptance.sh create mode 100755 scripts/phase-d5-3-12-acceptance.sh create mode 100755 scripts/phase-d5-3-13-acceptance.sh create mode 100755 scripts/phase-d5-3-14-acceptance.sh create mode 100755 scripts/phase-d5-3-15-1-acceptance.sh create mode 100755 scripts/phase-d5-3-16-acceptance.sh create mode 100755 scripts/phase-d5-3-17-1-acceptance.sh create mode 100755 scripts/phase-d5-3-17-acceptance.sh create mode 100755 scripts/phase-d5-3-18-2-acceptance.sh create mode 100755 scripts/phase-d5-3-18-3-acceptance.sh create mode 100755 scripts/phase-d5-3-18-acceptance.sh create mode 100755 scripts/phase-d5-3-19-1-acceptance.sh create mode 100755 scripts/phase-d5-3-2-1-acceptance.sh create mode 100755 scripts/phase-d5-3-2-acceptance.sh create mode 100755 scripts/phase-d5-3-20-acceptance.sh create mode 100755 scripts/phase-d5-3-21-1-acceptance.sh create mode 100755 scripts/phase-d5-3-22-acceptance.sh create mode 100755 scripts/phase-d5-3-23-1-acceptance.sh create mode 100755 scripts/phase-d5-3-24-acceptance.sh create mode 100755 scripts/phase-d5-3-25-acceptance.sh create mode 100755 scripts/phase-d5-3-3-acceptance.sh create mode 100755 scripts/phase-d5-3-4-acceptance.sh create mode 100755 scripts/phase-d5-3-5-1-acceptance.sh create mode 100755 scripts/phase-d5-3-5-acceptance.sh create mode 100755 scripts/phase-d5-3-6-3-acceptance.sh create mode 100755 scripts/phase-d5-3-6-acceptance.sh create mode 100755 scripts/phase-d5-3-7-1-acceptance.sh create mode 100755 scripts/phase-d5-3-7-2-1-acceptance.sh create mode 100755 scripts/phase-d5-3-7-2-2-acceptance.sh create mode 100755 scripts/phase-d5-3-7-2-3-acceptance.sh create mode 100755 scripts/phase-d5-3-7-2-4-acceptance.sh create mode 100755 scripts/phase-d5-3-7-2-5-acceptance.sh create mode 100755 scripts/phase-d5-3-7-2-acceptance.sh create mode 100755 scripts/phase-d5-3-8-acceptance.sh create mode 100755 scripts/phase-d5-3-9-acceptance.sh create mode 100755 scripts/phase-d5-3-acceptance.sh create mode 100755 scripts/phase-d5-4-1-acceptance.sh create mode 100755 scripts/phase-d5-5-1-acceptance.sh create mode 100755 scripts/phase-d5-5-2-acceptance.sh create mode 100755 scripts/phase-d5-5-acceptance.sh create mode 100755 scripts/phase-d5-6-2-acceptance.sh create mode 100755 scripts/phase-d5-6-4-acceptance.sh create mode 100755 scripts/phase-d5-6-acceptance.sh create mode 100755 scripts/phase-d5-acceptance.sh create mode 100755 scripts/verify-backup.sh create mode 100644 web-v2/.dockerignore create mode 100644 web-v2/Dockerfile create mode 100644 web-v2/index.html create mode 100644 web-v2/nginx/default.conf create mode 100644 web-v2/package-lock.json create mode 100644 web-v2/package.json create mode 100644 web-v2/src/app/App.tsx create mode 100644 web-v2/src/auth/AuthContext.tsx create mode 100644 web-v2/src/auth/PermissionGate.tsx create mode 100644 web-v2/src/auth/ProtectedRoute.tsx create mode 100644 web-v2/src/components/Feedback.tsx create mode 100644 web-v2/src/components/Icon.tsx create mode 100644 web-v2/src/components/OperationalContextBar.tsx create mode 100644 web-v2/src/components/SearchableSelect.tsx create mode 100644 web-v2/src/config/version.ts create mode 100644 web-v2/src/context/OperationalContext.tsx create mode 100644 web-v2/src/features/assets/AssetCenterTabs.tsx create mode 100644 web-v2/src/features/assets/AssetContextHistoryPanel.tsx create mode 100644 web-v2/src/features/assets/AssetDossierPanel.tsx create mode 100644 web-v2/src/features/assets/AssetFindingCatalogPanel.tsx create mode 100644 web-v2/src/features/assets/AssetHierarchyView.tsx create mode 100644 web-v2/src/features/assets/AssetHistoryPanel.tsx create mode 100644 web-v2/src/features/assets/AssetImportReviewsPanel.tsx create mode 100644 web-v2/src/features/assets/AssetMediaPanel.tsx create mode 100644 web-v2/src/features/assets/AssetOperationalRelationsPanel.tsx create mode 100644 web-v2/src/features/assets/AssetProvenancePanel.tsx create mode 100644 web-v2/src/features/assets/AssetRegistryPanel.tsx create mode 100644 web-v2/src/features/assets/AssetVersionDrawer.tsx create mode 100644 web-v2/src/features/assets/assetPresentation.ts create mode 100644 web-v2/src/features/assets/assetProvenancePresentation.ts create mode 100644 web-v2/src/features/assets/assetVersionPresentation.ts create mode 100644 web-v2/src/features/documents/DocumentCenterTabs.tsx create mode 100644 web-v2/src/features/inspections/FindingCatalogProposalsPanel.tsx create mode 100644 web-v2/src/features/inspections/FindingCatalogTypeApplicabilityPanel.tsx create mode 100644 web-v2/src/features/inspections/FindingOfficeWorkspace.tsx create mode 100644 web-v2/src/features/inspections/InspectionActsPanel.tsx create mode 100644 web-v2/src/features/inspections/InspectionClosurePanel.tsx create mode 100644 web-v2/src/features/inspections/InspectionEvidencePanel.tsx create mode 100644 web-v2/src/features/inspections/InspectionFindingsPanel.tsx create mode 100644 web-v2/src/features/inspections/OperationalFilters.tsx create mode 100644 web-v2/src/features/inspections/SignaturePad.tsx create mode 100644 web-v2/src/features/inspections/inspectionActPresentation.ts create mode 100644 web-v2/src/features/inspections/inspectionPresentation.ts create mode 100644 web-v2/src/features/map/AssetGeometryEditor.tsx create mode 100644 web-v2/src/features/map/DhMap.tsx create mode 100644 web-v2/src/features/surveys/surveyPresentation.ts create mode 100644 web-v2/src/layout/AppLayout.tsx create mode 100644 web-v2/src/lib/api.ts create mode 100644 web-v2/src/lib/format.ts create mode 100644 web-v2/src/main.tsx create mode 100644 web-v2/src/pages/ActsPage.tsx create mode 100644 web-v2/src/pages/AssetEditorPage.tsx create mode 100644 web-v2/src/pages/AssetImportsPage.tsx create mode 100644 web-v2/src/pages/AssetTypesPage.tsx create mode 100644 web-v2/src/pages/AssetsPage.tsx create mode 100644 web-v2/src/pages/AuditPage.tsx create mode 100644 web-v2/src/pages/ChangePasswordPage.tsx create mode 100644 web-v2/src/pages/DashboardPage.tsx create mode 100644 web-v2/src/pages/DocumentDeliveryPage.tsx create mode 100644 web-v2/src/pages/FieldDiscoveriesPage.tsx create mode 100644 web-v2/src/pages/FindingCatalogPage.tsx create mode 100644 web-v2/src/pages/FindingDetailPage.tsx create mode 100644 web-v2/src/pages/FindingsPage.tsx create mode 100644 web-v2/src/pages/HistoryPage.tsx create mode 100644 web-v2/src/pages/InspectionActEditorPage.tsx create mode 100644 web-v2/src/pages/InspectionVisitEditorPage.tsx create mode 100644 web-v2/src/pages/InspectionVisitsPage.tsx create mode 100644 web-v2/src/pages/LoginPage.tsx create mode 100644 web-v2/src/pages/MapPage.tsx create mode 100644 web-v2/src/pages/NewUserPage.tsx create mode 100644 web-v2/src/pages/PlaceholderPage.tsx create mode 100644 web-v2/src/pages/ReportDetailPage.tsx create mode 100644 web-v2/src/pages/ReportsPage.tsx create mode 100644 web-v2/src/pages/RolesPage.tsx create mode 100644 web-v2/src/pages/SurveyCampaignEditorPage.tsx create mode 100644 web-v2/src/pages/SurveyCampaignsPage.tsx create mode 100644 web-v2/src/pages/SurveyExecutionPage.tsx create mode 100644 web-v2/src/pages/SystemPages.tsx create mode 100644 web-v2/src/pages/TemporalAssetsPage.tsx create mode 100644 web-v2/src/pages/UserDetailPage.tsx create mode 100644 web-v2/src/pages/UsersPage.tsx create mode 100644 web-v2/src/pages/VerificationPlanningPage.tsx create mode 100644 web-v2/src/styles.css create mode 100644 web-v2/tsconfig.app.json create mode 100644 web-v2/tsconfig.json create mode 100644 web-v2/tsconfig.node.json create mode 100644 web-v2/vite.config.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..74abc4a --- /dev/null +++ b/.env.example @@ -0,0 +1,33 @@ +COMPOSE_PROJECT_NAME=dhv2 + +DB_NAME=dhv2 +DB_OWNER_USER=dhv2_owner +DB_OWNER_PASSWORD=CHANGE_ME +DB_APP_USER=dhv2_app +DB_APP_PASSWORD=CHANGE_ME + +API_PORT=3000 +WEB_HOST_PORT=8182 +API_HOST_PORT=3101 + +WEB_ORIGIN=https://dhv2.korexlabs.com +JWT_ACCESS_SECRET=CHANGE_ME_WITH_AT_LEAST_64_RANDOM_CHARACTERS +REFRESH_TOKEN_PEPPER=CHANGE_ME_WITH_ANOTHER_64_RANDOM_CHARACTERS +ACCESS_TOKEN_TTL_SECONDS=900 +REFRESH_TOKEN_TTL_SECONDS=604800 +AUTH_MAX_LOGIN_ATTEMPTS=5 +AUTH_LOCKOUT_SECONDS=900 +ACCESS_COOKIE_NAME=dhv2_access +REFRESH_COOKIE_NAME=dhv2_refresh +CSRF_COOKIE_NAME=dhv2_csrf + +INSPECTION_REPORT_WORD_ROOT=/app/storage/asset-media/inspection-reports-word +INSPECTION_REPORT_REVISION_ROOT=/app/storage/asset-media/inspection-report-revisions + +INSPECTION_ACT_PDF_ROOT=/app/storage/asset-media/inspection-acts-pdf +SMTP_HOST= +SMTP_PORT=587 +SMTP_SECURE=false +SMTP_USER= +SMTP_PASS= +MAIL_FROM= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0015cc2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,51 @@ +# Secrets / environment +.env +.env.* +!.env.example + +# Dependencies / builds +**/node_modules/ +**/dist/ +**/.next/ +**/.cache/ +**/.vite/ +**/coverage/ + +# Backups / exports +*.zip +*.tar.gz +*.tgz +*.dump +*.sql +*.bak +*.bak-* +*.backup + +# Runtime / logs +*.log +*.pid +*.tmp +*.swp + +# OS / editors +.DS_Store +Thumbs.db +.vscode/ +.idea/ + +# Private keys / certificates +*.pem +*.key +*.p12 +*.pfx +id_rsa +id_ed25519 +*_github +*_github.pub + +# TypeScript generated metadata +*.tsbuildinfo + +# Generated from web-v2/vite.config.ts +web-v2/vite.config.js +web-v2/vite.config.d.ts diff --git a/README.md b/README.md new file mode 100644 index 0000000..a316882 --- /dev/null +++ b/README.md @@ -0,0 +1,35 @@ +# DH Inspección V2 · D5.6.2 + +Hotfix de mantenimiento de Fase D para accesos Android y contrato de regresión de cierre. + +# DH Inspección V2 + +Versión: **0.19.5-1** +Fase: **D5.5.1 · Corrección de staging de planificación** + +D5.5.1 conserva íntegramente D5.5 y corrige únicamente el contrato de pruebas de staging. D5.5 consolida la preparación de visitas desde oficina antes del trabajo de campo. La visita queda anclada explícitamente a Área y Organización operadora, y el sistema genera un checklist auditable con antecedentes, respuestas vencidas, controles vencidos y próximos controles. + +## Alcance D5.5 + +- selección web Área → Organización operadora vigente; +- fecha y equipo inspector dentro del plan; +- checklist automático versionado y append-only; +- respuestas de empresa vencidas y controles de verificación vencidos; +- próximos controles dentro de 30 días de la fecha planificada; +- antecedentes históricos del mismo contexto; +- incorporación automática de registros accionables; +- alta manual de registros preventivos del mismo Área/Operadora; +- exclusiones sólo mediante motivo auditable; +- reincorporación sin borrar la exclusión histórica; +- visitas de verificación integradas al mismo contrato de planificación; +- cambio de contexto o fecha invalida el checklist hasta regeneración explícita; +- inicio de visita exclusivamente desde APK mediante la política móvil ya existente. + +## Migración + +`1789063200000-phase-d5-5-web-planning-checklist.ts` +`PhaseD55WebPlanningChecklist1789063200000` + +## Despliegue + +El despliegue debe partir exactamente de API/WEB **0.19.4-1** y ejecutar `scripts/deploy-phase-d5-5.sh`. Antes de modificar producción, el script compila API y web en staging y exige **252 tests** aprobados. diff --git a/api-v3/.dockerignore b/api-v3/.dockerignore new file mode 100644 index 0000000..5f6e61b --- /dev/null +++ b/api-v3/.dockerignore @@ -0,0 +1,5 @@ +node_modules +dist +.env +.git +*.log diff --git a/api-v3/Dockerfile b/api-v3/Dockerfile new file mode 100644 index 0000000..cd51eba --- /dev/null +++ b/api-v3/Dockerfile @@ -0,0 +1,19 @@ +FROM node:24-alpine AS builder +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY nest-cli.json tsconfig.json ./ +COPY src ./src +RUN npm run build + +FROM node:24-alpine AS runner +RUN apk add --no-cache unzip +ENV NODE_ENV=production +WORKDIR /app +COPY package*.json ./ +RUN npm ci --omit=dev && npm cache clean --force +COPY --from=builder /app/dist ./dist +RUN mkdir -p /app/storage/asset-media/imports && chown -R node:node /app/storage +USER node +EXPOSE 3000 +CMD ["node", "dist/main.js"] diff --git a/api-v3/nest-cli.json b/api-v3/nest-cli.json new file mode 100644 index 0000000..f9aa683 --- /dev/null +++ b/api-v3/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/api-v3/package-lock.json b/api-v3/package-lock.json new file mode 100644 index 0000000..e426335 --- /dev/null +++ b/api-v3/package-lock.json @@ -0,0 +1,6267 @@ +{ + "name": "dhv2-api", + "version": "0.19.6-4", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "dhv2-api", + "version": "0.19.6-4", + "license": "UNLICENSED", + "dependencies": { + "@nestjs/common": "^11.0.0", + "@nestjs/config": "^4.0.0", + "@nestjs/core": "^11.0.0", + "@nestjs/jwt": "^11.0.2", + "@nestjs/platform-express": "^11.0.0", + "@nestjs/throttler": "^6.5.0", + "@nestjs/typeorm": "^11.0.0", + "argon2": "^0.45.1", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.2", + "cookie-parser": "^1.4.7", + "helmet": "^8.0.0", + "pg": "^8.0.0", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.0", + "typeorm": "^0.3.0" + }, + "devDependencies": { + "@nestjs/cli": "^11.0.0", + "@nestjs/schematics": "^11.0.0", + "@types/cookie-parser": "^1.4.9", + "@types/express": "^5.0.3", + "@types/node": "^24.0.0", + "ts-node": "^10.9.2", + "tsx": "^4.20.6", + "typescript": "^5.9.0" + } + }, + "node_modules/@angular-devkit/core": { + "version": "19.2.27", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-19.2.27.tgz", + "integrity": "sha512-3amNzoCVSKd7ah6l6lBQL4onwwJvqvam7FMoQBILrxtW5LB5ezh8gMSPuA4zJjKjoRzf9uoWdlzqv/84I52xZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "8.18.0", + "ajv-formats": "3.0.1", + "jsonc-parser": "3.3.1", + "picomatch": "4.0.4", + "rxjs": "7.8.1", + "source-map": "0.7.4" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "peerDependencies": { + "chokidar": "^4.0.0" + }, + "peerDependenciesMeta": { + "chokidar": { + "optional": true + } + } + }, + "node_modules/@angular-devkit/core/node_modules/rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@angular-devkit/schematics": { + "version": "19.2.27", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-19.2.27.tgz", + "integrity": "sha512-/PZmyAlb2NGWPikRRuiWLdfHQd8Wrx6lX4HqvTcaDhlU43M3T0ud4PH2T3QDp7BzHYY92xtD8iPxX2asg67G1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "19.2.27", + "jsonc-parser": "3.3.1", + "magic-string": "0.30.17", + "ora": "5.4.1", + "rxjs": "7.8.1" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@angular-devkit/schematics-cli": { + "version": "19.2.27", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics-cli/-/schematics-cli-19.2.27.tgz", + "integrity": "sha512-wHYH6SVXVykhLzovUHtYor3Nl4SpIiITi7r9DQDaKYUD4hpRBx25W6N9eGuakT9Vd5tV/x6wmvQFWQZQwFB7eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "19.2.27", + "@angular-devkit/schematics": "19.2.27", + "@inquirer/prompts": "7.3.2", + "ansi-colors": "4.1.3", + "symbol-observable": "4.0.0", + "yargs-parser": "21.1.1" + }, + "bin": { + "schematics": "bin/schematics.js" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@angular-devkit/schematics-cli/node_modules/@inquirer/prompts": { + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-7.3.2.tgz", + "integrity": "sha512-G1ytyOoHh5BphmEBxSwALin3n1KGNYB6yImbICcRQdzXfOGbuJ9Jske/Of5Sebk339NSGGNfUshnzK8YWkTPsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/checkbox": "^4.1.2", + "@inquirer/confirm": "^5.1.6", + "@inquirer/editor": "^4.2.7", + "@inquirer/expand": "^4.0.9", + "@inquirer/input": "^4.1.6", + "@inquirer/number": "^3.0.9", + "@inquirer/password": "^4.0.9", + "@inquirer/rawlist": "^4.0.9", + "@inquirer/search": "^3.0.9", + "@inquirer/select": "^4.0.9" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@angular-devkit/schematics/node_modules/rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@borewit/text-codec": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.2.tgz", + "integrity": "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@colors/colors": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", + "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@epic-web/invariant": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz", + "integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==", + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/ansi": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", + "integrity": "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/checkbox": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-4.3.2.tgz", + "integrity": "sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/confirm": { + "version": "5.1.21", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.21.tgz", + "integrity": "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "10.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.3.2.tgz", + "integrity": "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "cli-width": "^4.1.0", + "mute-stream": "^2.0.0", + "signal-exit": "^4.1.0", + "wrap-ansi": "^6.2.0", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/editor": { + "version": "4.2.23", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-4.2.23.tgz", + "integrity": "sha512-aLSROkEwirotxZ1pBaP8tugXRFCxW94gwrQLxXfrZsKkfjOYC1aRvAZuhpJOb5cu4IBTJdsCigUlf2iCOu4ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/external-editor": "^1.0.3", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/expand": { + "version": "4.0.23", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-4.0.23.tgz", + "integrity": "sha512-nRzdOyFYnpeYTTR2qFwEVmIWypzdAx/sIkCMeTNTcflFOovfqUk+HcFhQQVBftAh9gmGrpFj6QcGEqrDMDOiew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/external-editor": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.3.tgz", + "integrity": "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chardet": "^2.1.1", + "iconv-lite": "^0.7.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/figures": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz", + "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/input": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-4.3.1.tgz", + "integrity": "sha512-kN0pAM4yPrLjJ1XJBjDxyfDduXOuQHrBB8aLDMueuwUGn+vNpF7Gq7TvyVxx8u4SHlFFj4trmj+a2cbpG4Jn1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/number": { + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-3.0.23.tgz", + "integrity": "sha512-5Smv0OK7K0KUzUfYUXDXQc9jrf8OHo4ktlEayFlelCjwMXz0299Y8OrI+lj7i4gCBY15UObk76q0QtxjzFcFcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/password": { + "version": "4.0.23", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-4.0.23.tgz", + "integrity": "sha512-zREJHjhT5vJBMZX/IUbyI9zVtVfOLiTO66MrF/3GFZYZ7T4YILW5MSkEYHceSii/KtRk+4i3RE7E1CUXA2jHcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/prompts": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-7.10.1.tgz", + "integrity": "sha512-Dx/y9bCQcXLI5ooQ5KyvA4FTgeo2jYj/7plWfV5Ak5wDPKQZgudKez2ixyfz7tKXzcJciTxqLeK7R9HItwiByg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/checkbox": "^4.3.2", + "@inquirer/confirm": "^5.1.21", + "@inquirer/editor": "^4.2.23", + "@inquirer/expand": "^4.0.23", + "@inquirer/input": "^4.3.1", + "@inquirer/number": "^3.0.23", + "@inquirer/password": "^4.0.23", + "@inquirer/rawlist": "^4.1.11", + "@inquirer/search": "^3.2.2", + "@inquirer/select": "^4.4.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/rawlist": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-4.1.11.tgz", + "integrity": "sha512-+LLQB8XGr3I5LZN/GuAHo+GpDJegQwuPARLChlMICNdwW7OwV2izlCSCxN6cqpL0sMXmbKbFcItJgdQq5EBXTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/search": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-3.2.2.tgz", + "integrity": "sha512-p2bvRfENXCZdWF/U2BXvnSI9h+tuA8iNqtUKb9UWbmLYCRQxd8WkvwWvYn+3NgYaNwdUkHytJMGG4MMLucI1kA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/select": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-4.4.2.tgz", + "integrity": "sha512-l4xMuJo55MAe+N7Qr4rX90vypFwCajSakx59qe/tMaC1aEHWLyw68wF4o0A4SLAY4E0nd+Vt+EyskeDIqu1M6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/type": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.10.tgz", + "integrity": "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", + "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/gen-mapping/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/source-map/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@lukeed/csprng": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lukeed/csprng/-/csprng-1.1.0.tgz", + "integrity": "sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@nestjs/cli": { + "version": "11.0.24", + "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-11.0.24.tgz", + "integrity": "sha512-aIHxQLSYtXShifA3zwWIeznEsZnNa3Iz2QRykFj+sl9IcbERBHr5nH87FRgywM+He3NxoF5WazHfR8FsmVeWxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "19.2.27", + "@angular-devkit/schematics": "19.2.27", + "@angular-devkit/schematics-cli": "19.2.27", + "@inquirer/prompts": "7.10.1", + "@nestjs/schematics": "^11.0.1", + "ansis": "4.2.0", + "chokidar": "4.0.3", + "cli-table3": "0.6.5", + "commander": "4.1.1", + "fork-ts-checker-webpack-plugin": "9.1.0", + "glob": "13.0.6", + "node-emoji": "1.11.0", + "ora": "5.4.1", + "tsconfig-paths": "4.2.0", + "tsconfig-paths-webpack-plugin": "4.2.0", + "typescript": "5.9.3", + "webpack": "5.106.2", + "webpack-node-externals": "3.0.0" + }, + "bin": { + "nest": "bin/nest.js" + }, + "engines": { + "node": ">= 20.11" + }, + "peerDependencies": { + "@swc/cli": "^0.1.62 || ^0.3.0 || ^0.4.0 || ^0.5.0 || ^0.6.0 || ^0.7.0 || ^0.8.0", + "@swc/core": "^1.3.62" + }, + "peerDependenciesMeta": { + "@swc/cli": { + "optional": true + }, + "@swc/core": { + "optional": true + } + } + }, + "node_modules/@nestjs/common": { + "version": "11.1.29", + "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.29.tgz", + "integrity": "sha512-zkeNRlfiQIH/044r5zphjNzKYxBRC4O00onTdCsH2qq0R30Ixo0gOS9so5TadEbJJCuGNm0Vx3PBE2FG2vkBdA==", + "license": "MIT", + "dependencies": { + "file-type": "21.3.4", + "iterare": "1.2.1", + "load-esm": "1.0.3", + "tslib": "2.8.1", + "uid": "2.0.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "class-transformer": ">=0.4.1", + "class-validator": ">=0.13.2", + "reflect-metadata": "^0.1.12 || ^0.2.0", + "rxjs": "^7.1.0" + }, + "peerDependenciesMeta": { + "class-transformer": { + "optional": true + }, + "class-validator": { + "optional": true + } + } + }, + "node_modules/@nestjs/config": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@nestjs/config/-/config-4.0.4.tgz", + "integrity": "sha512-CJPjNitr0bAufSEnRe2N+JbnVmMmDoo6hvKCPzXgZoGwJSmp/dZPk9f/RMbuD/+Q1ZJPjwsRpq0vxna++Knwow==", + "license": "MIT", + "dependencies": { + "dotenv": "17.4.1", + "dotenv-expand": "12.0.3", + "lodash": "4.18.1" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "rxjs": "^7.1.0" + } + }, + "node_modules/@nestjs/core": { + "version": "11.1.29", + "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.29.tgz", + "integrity": "sha512-ANXnZxirMNAY+JuRHCIYIdTfbhX3rizeSbJloub/4EYwpIfCuUoaz9woOofWmywYYc3gsVkLxVZV03gqntI8vA==", + "license": "MIT", + "dependencies": { + "fast-safe-stringify": "2.1.1", + "iterare": "1.2.1", + "path-to-regexp": "8.4.2", + "tslib": "2.8.1", + "uid": "2.0.2" + }, + "engines": { + "node": ">= 20" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "@nestjs/common": "^11.0.0", + "@nestjs/microservices": "^11.0.0", + "@nestjs/platform-express": "^11.0.0", + "@nestjs/websockets": "^11.0.0", + "reflect-metadata": "^0.1.12 || ^0.2.0", + "rxjs": "^7.1.0" + }, + "peerDependenciesMeta": { + "@nestjs/microservices": { + "optional": true + }, + "@nestjs/platform-express": { + "optional": true + }, + "@nestjs/websockets": { + "optional": true + } + } + }, + "node_modules/@nestjs/jwt": { + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-11.0.2.tgz", + "integrity": "sha512-rK8aE/3/Ma45gAWfCksAXUNbOoSOUudU0Kn3rT39htPF7wsYXtKfjALKeKKJbFrIWbLjsbqfXX5bIJNvgBugGA==", + "license": "MIT", + "dependencies": { + "@types/jsonwebtoken": "9.0.10", + "jsonwebtoken": "9.0.3" + }, + "peerDependencies": { + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0" + } + }, + "node_modules/@nestjs/platform-express": { + "version": "11.1.29", + "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.29.tgz", + "integrity": "sha512-OngsAjdzM+SbbmP1XojKfBgl0Lj7u8b7IK5duwpFymzNP5EmjS8ujvqck9M8h0L14I91aRIFSjIpVfHj8WVtqw==", + "license": "MIT", + "dependencies": { + "cors": "2.8.6", + "express": "5.2.1", + "multer": "2.2.0", + "path-to-regexp": "8.4.2", + "tslib": "2.8.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "@nestjs/common": "^11.0.0", + "@nestjs/core": "^11.0.0" + } + }, + "node_modules/@nestjs/schematics": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-11.1.0.tgz", + "integrity": "sha512-lVxGZ46tcdItFMoXr6vyKWlnOsm1SZm/GUqAEDvy2RL4Q4O+3bkziAhrO7Y8JLssFUUvNFEGqAizI52WAxhjDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "19.2.24", + "@angular-devkit/schematics": "19.2.24", + "comment-json": "5.0.0", + "jsonc-parser": "3.3.1", + "pluralize": "8.0.0" + }, + "peerDependencies": { + "prettier": "^3.0.0", + "typescript": ">=4.8.2" + }, + "peerDependenciesMeta": { + "prettier": { + "optional": true + } + } + }, + "node_modules/@nestjs/schematics/node_modules/@angular-devkit/core": { + "version": "19.2.24", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-19.2.24.tgz", + "integrity": "sha512-Kd49warf6U/EyWe5BszF/eebN3zQ3bk7tgfEljAw8q/rX95UUtriJubWvp6pgzHfzBA4jwq8f+QiNZB8eBEXPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "8.18.0", + "ajv-formats": "3.0.1", + "jsonc-parser": "3.3.1", + "picomatch": "4.0.4", + "rxjs": "7.8.1", + "source-map": "0.7.4" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "peerDependencies": { + "chokidar": "^4.0.0" + }, + "peerDependenciesMeta": { + "chokidar": { + "optional": true + } + } + }, + "node_modules/@nestjs/schematics/node_modules/@angular-devkit/schematics": { + "version": "19.2.24", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-19.2.24.tgz", + "integrity": "sha512-lnw+ZM1Io+cJAkReC0NPDjqObL8NtKzKIkdgEEKC8CUmkhurYhedbicN8Y8NYHgG1uLd2GozW3+/QqPRZaN+Lw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "19.2.24", + "jsonc-parser": "3.3.1", + "magic-string": "0.30.17", + "ora": "5.4.1", + "rxjs": "7.8.1" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@nestjs/schematics/node_modules/rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@nestjs/throttler": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@nestjs/throttler/-/throttler-6.5.0.tgz", + "integrity": "sha512-9j0ZRfH0QE1qyrj9JjIRDz5gQLPqq9yVC2nHsrosDVAfI5HHw08/aUAWx9DZLSdQf4HDkmhTTEGLrRFHENvchQ==", + "license": "MIT", + "peerDependencies": { + "@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", + "@nestjs/core": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", + "reflect-metadata": "^0.1.13 || ^0.2.0" + } + }, + "node_modules/@nestjs/typeorm": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@nestjs/typeorm/-/typeorm-11.0.3.tgz", + "integrity": "sha512-zJ+E5l7auVVA7c0PsvcMdyvRPKTUqU5s2ToYmOA2QEsXQ42qbUGtK4+1HlRfpHqBkCSXP+phiH4luvf9DyJNog==", + "license": "MIT", + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "@nestjs/core": "^10.0.0 || ^11.0.0", + "reflect-metadata": "^0.1.13 || ^0.2.0", + "rxjs": "^7.2.0", + "typeorm": "^0.3.0 || ^1.0.0-dev" + } + }, + "node_modules/@phc/format": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz", + "integrity": "sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@sqltools/formatter": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@sqltools/formatter/-/formatter-1.2.5.tgz", + "integrity": "sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw==", + "license": "MIT" + }, + "node_modules/@tokenizer/inflate": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz", + "integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "token-types": "^6.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@tokenizer/token": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", + "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", + "license": "MIT" + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", + "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cookie-parser": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz", + "integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/eslint": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", + "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "node_modules/@types/eslint-scope": { + "version": "3.7.7", + "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", + "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/eslint": "*", + "@types/estree": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.3.tgz", + "integrity": "sha512-dPfW8NFiOF4wOHc7+N/QSxlY9cfSsenewGbAz8C8U/MULPd/YZ27LvJUIlzaXie7e6Ove9YunJGgC9tbHD2cKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, + "node_modules/@types/validator": { + "version": "13.15.10", + "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz", + "integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==", + "license": "MIT" + }, + "node_modules/@webassemblyjs/ast": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", + "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/helper-numbers": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2" + } + }, + "node_modules/@webassemblyjs/floating-point-hex-parser": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", + "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-api-error": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", + "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-buffer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", + "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-numbers": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", + "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/floating-point-hex-parser": "1.13.2", + "@webassemblyjs/helper-api-error": "1.13.2", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/helper-wasm-bytecode": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", + "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-wasm-section": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", + "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/wasm-gen": "1.14.1" + } + }, + "node_modules/@webassemblyjs/ieee754": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", + "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "node_modules/@webassemblyjs/leb128": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", + "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/utf8": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", + "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/wasm-edit": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", + "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/helper-wasm-section": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-opt": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1", + "@webassemblyjs/wast-printer": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-gen": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", + "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wasm-opt": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", + "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-parser": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", + "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-api-error": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wast-printer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", + "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@xtuc/ieee754": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@xtuc/long": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "devOptional": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-import-phases": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", + "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + }, + "peerDependencies": { + "acorn": "^8.14.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ansis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/ansis/-/ansis-4.2.0.tgz", + "integrity": "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + } + }, + "node_modules/app-root-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/app-root-path/-/app-root-path-3.1.0.tgz", + "integrity": "sha512-biN3PwB2gUtjaYy/isrU3aNWI5w+fAfvHkSvCKeQGxhmYpwKFUxudR3Yya+KqVRHBmEDYh+/lTozYCFbmzX4nA==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/argon2": { + "version": "0.45.1", + "resolved": "https://registry.npmjs.org/argon2/-/argon2-0.45.1.tgz", + "integrity": "sha512-skm+/WCjkGqCQxF7FG1LuZXM5yvbFjgbfiCGsud2oLgaDhh6b6dbH0b1EkghbM+xx4Bj8Ape+KKgixoIlWZicQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@phc/format": "^1.0.0", + "cross-env": "^10.1.0", + "node-addon-api": "^8.9.0", + "node-gyp-build": "^4.8.4" + }, + "engines": { + "node": ">=16.17.0" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/array-timsort": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/array-timsort/-/array-timsort-1.0.3.tgz", + "integrity": "sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.13", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.13.tgz", + "integrity": "sha512-k9HNuUVMlqVjQ9UHzfPjIqiDbWw7WqT1AoT7GL8VwvF3r0ZfArtgiSPAlmupyNquNgOJHTuH4CKYf8ttMTWBTQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/browserslist": { + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001809", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz", + "integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chardet": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.2.0.tgz", + "integrity": "sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==", + "dev": true, + "license": "MIT" + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/chrome-trace-event": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", + "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0" + } + }, + "node_modules/class-transformer": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", + "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", + "license": "MIT" + }, + "node_modules/class-validator": { + "version": "0.14.4", + "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.4.tgz", + "integrity": "sha512-AwNusCCam51q703dW82x95tOqQp6oC9HNUl724KxJJOfnKscI8dOloXFgyez7LbTTKWuRBA37FScqVbJEoq8Yw==", + "license": "MIT", + "dependencies": { + "@types/validator": "^13.15.3", + "libphonenumber-js": "^1.11.1", + "validator": "^13.15.22" + } + }, + "node_modules/cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-table3": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.5.tgz", + "integrity": "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "string-width": "^4.2.0" + }, + "engines": { + "node": "10.* || >= 12.*" + }, + "optionalDependencies": { + "@colors/colors": "1.5.0" + } + }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/comment-json": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/comment-json/-/comment-json-5.0.0.tgz", + "integrity": "sha512-uiqLcOiVDJtBP8WGkZHEP+FZIhTzP1dxvn59EfoYUi9gqupjrBWVQkO2atDrbnKPwLeotFYDsuNb26uBMqB+hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-timsort": "^1.0.3", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-parser/node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cosmiconfig": { + "version": "8.3.6", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", + "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0", + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/cross-env": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz", + "integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==", + "license": "MIT", + "dependencies": { + "@epic-web/invariant": "^1.0.0", + "cross-spawn": "^7.0.6" + }, + "bin": { + "cross-env": "dist/bin/cross-env.js", + "cross-env-shell": "dist/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/dayjs": { + "version": "1.11.21", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", + "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/dedent": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", + "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==", + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/diff": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", + "devOptional": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dotenv": { + "version": "17.4.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.1.tgz", + "integrity": "sha512-k8DaKGP6r1G30Lx8V4+pCsLzKr8vLmV2paqEj1Y55GdAgJuIqpRp5FfajGF8KtwMxCz9qJc6wUIJnm053d/WCw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-expand": { + "version": "12.0.3", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-12.0.3.tgz", + "integrity": "sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==", + "license": "BSD-2-Clause", + "dependencies": { + "dotenv": "^16.4.5" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-expand/node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "license": "MIT" + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.405", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.405.tgz", + "integrity": "sha512-bNglH7lPH5l+yHOes7Zr4VqxhOy4BQ9ZBUX4VdoFgxMpzJk7W1ZoO3Vgd9Pxa9PyjQ76sfm2aKH/nzEcCNRlew==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.24.5", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", + "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esrecurse/node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/file-type": { + "version": "21.3.4", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.4.tgz", + "integrity": "sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==", + "license": "MIT", + "dependencies": { + "@tokenizer/inflate": "^0.4.1", + "strtok3": "^10.3.4", + "token-types": "^6.1.1", + "uint8array-extras": "^1.4.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fork-ts-checker-webpack-plugin": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-9.1.0.tgz", + "integrity": "sha512-mpafl89VFPJmhnJ1ssH+8wmM2b50n+Rew5x42NeI2U78aRWgtkEtGmctp7iT16UjquJTjorEmIfESj3DxdW84Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.16.7", + "chalk": "^4.1.2", + "chokidar": "^4.0.1", + "cosmiconfig": "^8.2.0", + "deepmerge": "^4.2.2", + "fs-extra": "^10.0.0", + "memfs": "^3.4.1", + "minimatch": "^3.0.4", + "node-abort-controller": "^3.0.1", + "schema-utils": "^3.1.1", + "semver": "^7.3.5", + "tapable": "^2.2.1" + }, + "engines": { + "node": ">=14.21.3" + }, + "peerDependencies": { + "typescript": ">3.6.0", + "webpack": "^5.11.0" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/fs-monkey": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.1.0.tgz", + "integrity": "sha512-QMUezzXWII9EV5aTFXW1UBVUO77wYPpjqIF8/AviUCThNeSYZykpoTixUeaNNBwmCev0AMDWMAni+f8Hxb1IFw==", + "dev": true, + "license": "Unlicense" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/glob/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/helmet": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.3.0.tgz", + "integrity": "sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/EvanHahn" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-interactive": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", + "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/iterare": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/iterare/-/iterare-1.2.1.tgz", + "integrity": "sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q==", + "license": "ISC", + "engines": { + "node": ">=6" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/libphonenumber-js": { + "version": "1.13.10", + "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.13.10.tgz", + "integrity": "sha512-xJxrdqvbl2rtn2MaUJrUejz8J7/uZNC0V77oks2LxYrO/+ZtVpRmz+fEQMuu6VusnEB1fmpByiLS1WXecOnAnw==", + "license": "MIT" + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/load-esm": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/load-esm/-/load-esm-1.0.3.tgz", + "integrity": "sha512-v5xlu8eHD1+6r8EHTg6hfmO97LN8ugKtiXcy5e6oN72iD2r6u0RPfLl6fxM+7Wnh2ZRq15o0russMst44WauPA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + }, + { + "type": "buymeacoffee", + "url": "https://buymeacoffee.com/borewit" + } + ], + "license": "MIT", + "engines": { + "node": ">=13.2.0" + } + }, + "node_modules/loader-runner": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.2.tgz", + "integrity": "sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.11.5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/magic-string": { + "version": "0.30.17", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", + "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "devOptional": true, + "license": "ISC" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/memfs": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-3.5.3.tgz", + "integrity": "sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==", + "dev": true, + "license": "Unlicense", + "dependencies": { + "fs-monkey": "^1.0.4" + }, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/multer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz", + "integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/multer/node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mute-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", + "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-abort-controller": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", + "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-addon-api": { + "version": "8.9.2", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.2.tgz", + "integrity": "sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/node-emoji": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-1.11.0.tgz", + "integrity": "sha512-wo2DpQkQp7Sjm2A0cq+sN7EHKO6Sl0ctXeBdFZrL9T9+UywORbufTcTZxom8YqpLQt/FqNMUkOpkZrJVYSKD3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "lodash": "^4.17.21" + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/node-releases": { + "version": "2.0.53", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", + "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", + "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bl": "^4.1.0", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.5.0", + "is-interactive": "^1.0.0", + "is-unicode-supported": "^0.1.0", + "log-symbols": "^4.1.0", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pg": { + "version": "8.23.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz", + "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.16.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", + "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz", + "integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "license": "Apache-2.0" + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/restore-cursor/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/schema-utils": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/schema-utils/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/schema-utils/node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/schema-utils/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/sha.js": { + "version": "2.4.12", + "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.12.tgz", + "integrity": "sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==", + "license": "(MIT AND BSD-3-Clause)", + "dependencies": { + "inherits": "^2.0.4", + "safe-buffer": "^5.2.1", + "to-buffer": "^1.2.0" + }, + "bin": { + "sha.js": "bin.js" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/source-map": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", + "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 8" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/source-map-support/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/sql-highlight": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/sql-highlight/-/sql-highlight-6.1.0.tgz", + "integrity": "sha512-ed7OK4e9ywpE7pgRMkMQmZDPKSVdm0oX5IEtZiKnFucSF0zu6c80GZBe38UqHuVhTWJ9xsKgSMjCG2bml86KvA==", + "funding": [ + "https://github.com/scriptcoded/sql-highlight?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/scriptcoded" + } + ], + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/strtok3": { + "version": "10.3.5", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz", + "integrity": "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==", + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/symbol-observable": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-4.0.0.tgz", + "integrity": "sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/terser": { + "version": "5.50.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.50.0.tgz", + "integrity": "sha512-CN9BVxWhgS/hRxtUMjtC2uRWSTcSfQFHMDWma6sKKfIivCD91sM+FOPfvwoaRMqCSrUpe1nv3jDamd9eEQ4y+w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser-webpack-plugin": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.6.1.tgz", + "integrity": "sha512-201R5j+sJpK8nFWwKVyNfZot8FaJbLZDq5evriVzbV1wDtSXDjRUDRfJzHpAaxFDMEhsZL1QkeqM61wgsS3KaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "jest-worker": "^27.4.5", + "schema-utils": "^4.3.0", + "terser": "^5.31.1" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@minify-html/node": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "@swc/css": { + "optional": true + }, + "@swc/html": { + "optional": true + }, + "clean-css": { + "optional": true + }, + "cssnano": { + "optional": true + }, + "csso": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "html-minifier-terser": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "postcss": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, + "node_modules/terser-webpack-plugin/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/terser-webpack-plugin/node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/terser-webpack-plugin/node_modules/schema-utils": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/terser/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/to-buffer": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz", + "integrity": "sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==", + "license": "MIT", + "dependencies": { + "isarray": "^2.0.5", + "safe-buffer": "^5.2.1", + "typed-array-buffer": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/token-types": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz", + "integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==", + "license": "MIT", + "dependencies": { + "@borewit/text-codec": "^0.2.1", + "@tokenizer/token": "^0.3.0", + "ieee754": "^1.2.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/tsconfig-paths": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", + "integrity": "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "json5": "^2.2.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tsconfig-paths-webpack-plugin": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths-webpack-plugin/-/tsconfig-paths-webpack-plugin-4.2.0.tgz", + "integrity": "sha512-zbem3rfRS8BgeNK50Zz5SIQgXzLafiHjOwUAvk/38/o1jHn/V5QAgVUcz884or7WYcPaH3N2CIfUc2u0ul7UcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "enhanced-resolve": "^5.7.0", + "tapable": "^2.2.1", + "tsconfig-paths": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.23.12", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.12.tgz", + "integrity": "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/typeorm": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/typeorm/-/typeorm-0.3.31.tgz", + "integrity": "sha512-6u9EFtdLBgHjnPm78NStVeM+I/1MolTzKykDDcydzKUkh6E++YS6XViU/fePJbvDvEGU4Xq34KOM/CLeer9I2A==", + "license": "MIT", + "dependencies": { + "@sqltools/formatter": "^1.2.5", + "ansis": "^4.3.1", + "app-root-path": "^3.1.0", + "buffer": "^6.0.3", + "dayjs": "^1.11.21", + "debug": "^4.4.3", + "dedent": "^1.7.2", + "dotenv": "^16.6.1", + "glob": "^10.5.0", + "reflect-metadata": "^0.2.2", + "sha.js": "^2.4.12", + "sql-highlight": "^6.1.0", + "tslib": "^2.8.1", + "uuid": "^11.1.1", + "yargs": "^17.7.3" + }, + "bin": { + "typeorm": "cli.js", + "typeorm-ts-node-commonjs": "cli-ts-node-commonjs.js", + "typeorm-ts-node-esm": "cli-ts-node-esm.js" + }, + "engines": { + "node": ">=16.13.0" + }, + "funding": { + "url": "https://opencollective.com/typeorm" + }, + "peerDependencies": { + "@google-cloud/spanner": "^5.18.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "@sap/hana-client": "^2.14.22", + "better-sqlite3": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 || ^12.0.0", + "ioredis": "^5.0.4", + "mongodb": "^5.8.0 || ^6.0.0", + "mssql": "^9.1.1 || ^10.0.0 || ^11.0.0 || ^12.0.0", + "mysql2": "^2.2.5 || ^3.0.1", + "oracledb": "^6.3.0 || ^7.0.0", + "pg": "^8.5.1", + "pg-native": "^3.0.0", + "pg-query-stream": "^4.0.0", + "redis": "^3.1.1 || ^4.0.0 || ^5.0.14", + "sql.js": "^1.4.0", + "sqlite3": "^5.0.3 || ^6.0.0", + "ts-node": "^10.7.0", + "typeorm-aurora-data-api-driver": "^2.0.0 || ^3.0.0" + }, + "peerDependenciesMeta": { + "@google-cloud/spanner": { + "optional": true + }, + "@sap/hana-client": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "ioredis": { + "optional": true + }, + "mongodb": { + "optional": true + }, + "mssql": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "oracledb": { + "optional": true + }, + "pg": { + "optional": true + }, + "pg-native": { + "optional": true + }, + "pg-query-stream": { + "optional": true + }, + "redis": { + "optional": true + }, + "sql.js": { + "optional": true + }, + "sqlite3": { + "optional": true + }, + "ts-node": { + "optional": true + }, + "typeorm-aurora-data-api-driver": { + "optional": true + } + } + }, + "node_modules/typeorm/node_modules/ansis": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/ansis/-/ansis-4.3.1.tgz", + "integrity": "sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA==", + "license": "ISC", + "engines": { + "node": ">=14" + } + }, + "node_modules/typeorm/node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/typeorm/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/typeorm/node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/typeorm/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/typeorm/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/typeorm/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/typeorm/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uid": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/uid/-/uid-2.0.2.tgz", + "integrity": "sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==", + "license": "MIT", + "dependencies": { + "@lukeed/csprng": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/uint8array-extras": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", + "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "license": "MIT" + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz", + "integrity": "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/uuid": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", + "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/validator": { + "version": "13.15.35", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.35.tgz", + "integrity": "sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/watchpack": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.2.tgz", + "integrity": "sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", + "dev": true, + "license": "MIT", + "dependencies": { + "defaults": "^1.0.3" + } + }, + "node_modules/webpack": { + "version": "5.106.2", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.106.2.tgz", + "integrity": "sha512-wGN3qcrBQIFmQ/c0AiOAQBvrZ5lmY8vbbMv4Mxfgzqd/B6+9pXtLo73WuS1dSGXM5QYY3hZnIbvx+K1xxe6FyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/eslint-scope": "^3.7.7", + "@types/estree": "^1.0.8", + "@types/json-schema": "^7.0.15", + "@webassemblyjs/ast": "^1.14.1", + "@webassemblyjs/wasm-edit": "^1.14.1", + "@webassemblyjs/wasm-parser": "^1.14.1", + "acorn": "^8.16.0", + "acorn-import-phases": "^1.0.3", + "browserslist": "^4.28.1", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.20.0", + "es-module-lexer": "^2.0.0", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.11", + "loader-runner": "^4.3.1", + "mime-db": "^1.54.0", + "neo-async": "^2.6.2", + "schema-utils": "^4.3.3", + "tapable": "^2.3.0", + "terser-webpack-plugin": "^5.3.17", + "watchpack": "^2.5.1", + "webpack-sources": "^3.3.4" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-node-externals": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/webpack-node-externals/-/webpack-node-externals-3.0.0.tgz", + "integrity": "sha512-LnL6Z3GGDPht/AigwRh2dvL9PQPFQ8skEpVrWZXLWBYmqcaojHNN0onvHzie6rq7EWKrrBfPYqNEzTJgiwEQDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/webpack-sources": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.5.1.tgz", + "integrity": "sha512-jyuiGJdtvY434z5bUZrjz67v76/ePNvFZTp9Mdz29IlH4+GPsgyGjiv0fKI+M7BdkU6ADjulUcKAd3tUK3WlEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack/node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/webpack/node_modules/schema-utils": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.22", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz", + "integrity": "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==", + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/yoctocolors-cjs": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", + "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/api-v3/package.json b/api-v3/package.json new file mode 100644 index 0000000..829b100 --- /dev/null +++ b/api-v3/package.json @@ -0,0 +1,46 @@ +{ + "name": "dhv2-api", + "version": "0.19.6-4", + "private": true, + "license": "UNLICENSED", + "scripts": { + "build": "nest build", + "start": "node dist/main.js", + "start:dev": "nest start --watch", + "typecheck": "tsc --noEmit", + "test": "tsc -p tsconfig.test.json --noEmit && node --import tsx --test test/**/*.test.ts", + "migration:run": "node dist/database/migration-cli.js run", + "migration:show": "node dist/database/migration-cli.js show", + "migration:revert": "node dist/database/migration-cli.js revert", + "bootstrap:admin": "node dist/cli/bootstrap-admin.js", + "dev:seed:mendoza-demo": "node dist/cli/dev-seed-mendoza-demo.js" + }, + "dependencies": { + "@nestjs/common": "^11.0.0", + "@nestjs/config": "^4.0.0", + "@nestjs/core": "^11.0.0", + "@nestjs/jwt": "^11.0.2", + "@nestjs/platform-express": "^11.0.0", + "@nestjs/throttler": "^6.5.0", + "@nestjs/typeorm": "^11.0.0", + "argon2": "^0.45.1", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.2", + "cookie-parser": "^1.4.7", + "helmet": "^8.0.0", + "pg": "^8.0.0", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.0", + "typeorm": "^0.3.0" + }, + "devDependencies": { + "@nestjs/cli": "^11.0.0", + "@nestjs/schematics": "^11.0.0", + "@types/cookie-parser": "^1.4.9", + "@types/express": "^5.0.3", + "@types/node": "^24.0.0", + "ts-node": "^10.9.2", + "tsx": "^4.20.6", + "typescript": "^5.9.0" + } +} diff --git a/api-v3/src/administration/administration.module.ts b/api-v3/src/administration/administration.module.ts new file mode 100644 index 0000000..1317782 --- /dev/null +++ b/api-v3/src/administration/administration.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { AuditModule } from '../audit/audit.module'; +import { AuthModule } from '../auth/auth.module'; +import { PhaseADataModule } from '../core-data/phase-a-data.module'; +import { RolesController } from './roles/roles.controller'; +import { RolesService } from './roles/roles.service'; +import { UsersController } from './users/users.controller'; +import { UsersService } from './users/users.service'; + +@Module({ + imports: [PhaseADataModule, AuditModule, AuthModule], + controllers: [UsersController, RolesController], + providers: [UsersService, RolesService], +}) +export class AdministrationModule {} diff --git a/api-v3/src/administration/common/administration-audit.ts b/api-v3/src/administration/common/administration-audit.ts new file mode 100644 index 0000000..d945dca --- /dev/null +++ b/api-v3/src/administration/common/administration-audit.ts @@ -0,0 +1,72 @@ +import { isIP } from 'node:net'; +import { ConflictException } from '@nestjs/common'; +import { EntityManager } from 'typeorm'; +import type { + AuthPrincipal, + RequestWithContext, +} from '../../common/http/request-context'; +import { AuditSource } from '../../database/entities'; + +const REQUIRED_RECOVERY_PERMISSIONS = [ + 'roles.manage', + 'users.assign_roles', +] as const; + +export function administrationAuditContext( + principal: AuthPrincipal, + request: RequestWithContext, +) { + const candidateIp = request.ip || request.socket.remoteAddress || ''; + const rawUserAgent = request.header('user-agent')?.trim(); + + return { + actorUserId: principal.userId, + actorUsername: principal.username, + requestId: request.requestId, + source: AuditSource.WEB, + ip: isIP(candidateIp) ? candidateIp : null, + userAgent: rawUserAgent ? rawUserAgent.slice(0, 2048) : null, + }; +} + +export async function assertAdministrativeRecoveryRemains( + manager: EntityManager, +): Promise { + const [row] = (await manager.query( + ` + SELECT COUNT(*)::integer AS count + FROM ( + SELECT user_account.id + 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 = ANY($1::varchar[]) + GROUP BY user_account.id + HAVING COUNT(DISTINCT permission.code) = $2 + ) administrators + `, + [REQUIRED_RECOVERY_PERMISSIONS, REQUIRED_RECOVERY_PERMISSIONS.length], + )) as Array<{ count: number }>; + + if (!row || Number(row.count) < 1) { + throw new ConflictException({ + code: 'LAST_ADMINISTRATOR_PROTECTED', + message: + 'Debe permanecer al menos un usuario activo capaz de administrar roles y asignaciones', + }); + } +} + +export function isUniqueViolation(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as { code?: unknown }).code === '23505' + ); +} diff --git a/api-v3/src/administration/roles/dto/create-role.dto.ts b/api-v3/src/administration/roles/dto/create-role.dto.ts new file mode 100644 index 0000000..689da8a --- /dev/null +++ b/api-v3/src/administration/roles/dto/create-role.dto.ts @@ -0,0 +1,38 @@ +import { Transform } from 'class-transformer'; +import { + ArrayUnique, + IsArray, + IsString, + IsUUID, + Matches, + MaxLength, + MinLength, +} from 'class-validator'; + +export class CreateRoleDto { + @Transform(({ value }) => + typeof value === 'string' ? value.trim().toLowerCase() : value, + ) + @IsString() + @MinLength(3) + @MaxLength(80) + @Matches(/^[a-z][a-z0-9_-]+$/) + code!: string; + + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) + @IsString() + @MinLength(1) + @MaxLength(120) + name!: string; + + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) + @IsString() + @MinLength(1) + @MaxLength(1000) + description!: string; + + @IsArray() + @ArrayUnique() + @IsUUID('4', { each: true }) + permissionIds!: string[]; +} diff --git a/api-v3/src/administration/roles/dto/replace-role-permissions.dto.ts b/api-v3/src/administration/roles/dto/replace-role-permissions.dto.ts new file mode 100644 index 0000000..ec4452e --- /dev/null +++ b/api-v3/src/administration/roles/dto/replace-role-permissions.dto.ts @@ -0,0 +1,8 @@ +import { ArrayUnique, IsArray, IsUUID } from 'class-validator'; + +export class ReplaceRolePermissionsDto { + @IsArray() + @ArrayUnique() + @IsUUID('4', { each: true }) + permissionIds!: string[]; +} diff --git a/api-v3/src/administration/roles/dto/update-role.dto.ts b/api-v3/src/administration/roles/dto/update-role.dto.ts new file mode 100644 index 0000000..9d088c6 --- /dev/null +++ b/api-v3/src/administration/roles/dto/update-role.dto.ts @@ -0,0 +1,18 @@ +import { Transform } from 'class-transformer'; +import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator'; + +export class UpdateRoleDto { + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) + @IsOptional() + @IsString() + @MinLength(1) + @MaxLength(120) + name?: string; + + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) + @IsOptional() + @IsString() + @MinLength(1) + @MaxLength(1000) + description?: string; +} diff --git a/api-v3/src/administration/roles/roles.controller.ts b/api-v3/src/administration/roles/roles.controller.ts new file mode 100644 index 0000000..bbc3f4e --- /dev/null +++ b/api-v3/src/administration/roles/roles.controller.ts @@ -0,0 +1,76 @@ +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Patch, + Post, + Put, + Req, +} from '@nestjs/common'; +import { RequirePermissions } from '../../authorization/decorators/require-permissions.decorator'; +import { CurrentAuth } from '../../auth/decorators/current-auth.decorator'; +import type { + AuthPrincipal, + RequestWithContext, +} from '../../common/http/request-context'; +import { CreateRoleDto } from './dto/create-role.dto'; +import { ReplaceRolePermissionsDto } from './dto/replace-role-permissions.dto'; +import { UpdateRoleDto } from './dto/update-role.dto'; +import { RolesService } from './roles.service'; + +@Controller('roles') +export class RolesController { + constructor(private readonly roles: RolesService) {} + + @Get() + @RequirePermissions('roles.read') + list() { + return this.roles.list(); + } + + @Get('permissions') + @RequirePermissions('roles.read') + listPermissions() { + return this.roles.listPermissions(); + } + + @Post() + @RequirePermissions('roles.manage') + create( + @Body() dto: CreateRoleDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.roles.create(dto, principal, request); + } + + @Get(':id') + @RequirePermissions('roles.read') + getById(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) { + return this.roles.getById(id); + } + + @Patch(':id') + @RequirePermissions('roles.manage') + update( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @Body() dto: UpdateRoleDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.roles.update(id, dto, principal, request); + } + + @Put(':id/permissions') + @RequirePermissions('roles.manage') + replacePermissions( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @Body() dto: ReplaceRolePermissionsDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.roles.replacePermissions(id, dto, principal, request); + } +} diff --git a/api-v3/src/administration/roles/roles.service.ts b/api-v3/src/administration/roles/roles.service.ts new file mode 100644 index 0000000..d398860 --- /dev/null +++ b/api-v3/src/administration/roles/roles.service.ts @@ -0,0 +1,309 @@ +import { + BadRequestException, + ConflictException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { DataSource, EntityManager, In } from 'typeorm'; +import { AuditService } from '../../audit/audit.service'; +import type { + AuthPrincipal, + RequestWithContext, +} from '../../common/http/request-context'; +import { + AuditAction, + Permission, + Role, + RolePermission, +} from '../../database/entities'; +import { + administrationAuditContext, + assertAdministrativeRecoveryRemains, + isUniqueViolation, +} from '../common/administration-audit'; +import type { CreateRoleDto } from './dto/create-role.dto'; +import type { ReplaceRolePermissionsDto } from './dto/replace-role-permissions.dto'; +import type { UpdateRoleDto } from './dto/update-role.dto'; + +export interface PermissionView { + id: string; + code: string; + description: string; +} + +export interface AdministrativeRoleView { + id: string; + code: string; + name: string; + description: string; + isSystem: boolean; + createdAt: Date; + updatedAt: Date; + userCount: number; + permissions: PermissionView[]; +} + +function roleNotFound(): NotFoundException { + return new NotFoundException({ + code: 'ROLE_NOT_FOUND', + message: 'Rol no encontrado', + }); +} + +function permissionSelectionInvalid(): BadRequestException { + return new BadRequestException({ + code: 'PERMISSION_NOT_FOUND', + message: 'Uno o más permisos no existen', + }); +} + +@Injectable() +export class RolesService { + constructor( + private readonly dataSource: DataSource, + private readonly audit: AuditService, + ) {} + + async list(): Promise<{ data: AdministrativeRoleView[] }> { + const rows = (await this.dataSource.query(this.roleViewQuery(''), [])) as + AdministrativeRoleView[]; + return { data: rows }; + } + + async listPermissions(): Promise<{ data: PermissionView[] }> { + const rows = (await this.dataSource.query( + ` + SELECT id, code, description + FROM permissions + ORDER BY code ASC + `, + )) as PermissionView[]; + return { data: rows }; + } + + async getById(id: string): Promise { + return this.dataSource.transaction((manager) => + this.loadRoleView(manager, id), + ); + } + + async create( + dto: CreateRoleDto, + principal: AuthPrincipal, + request: RequestWithContext, + ): Promise { + try { + return await this.dataSource.transaction(async (manager) => { + const permissions = await this.resolvePermissions( + manager, + dto.permissionIds, + ); + const role = manager.getRepository(Role).create({ + code: dto.code.trim().toLowerCase(), + name: dto.name.trim(), + description: dto.description.trim(), + isSystem: false, + }); + await manager.getRepository(Role).save(role); + await this.insertPermissions(manager, role.id, permissions); + + const created = await this.loadRoleView(manager, role.id); + await this.audit.record( + { + ...administrationAuditContext(principal, request), + action: AuditAction.ROLE_CREATED, + entityType: 'role', + entityId: role.id, + afterData: { ...created }, + }, + manager, + ); + return created; + }); + } catch (error) { + if (isUniqueViolation(error)) throw this.roleConflict(); + throw error; + } + } + + async update( + id: string, + dto: UpdateRoleDto, + principal: AuthPrincipal, + request: RequestWithContext, + ): Promise { + if (dto.name === undefined && dto.description === undefined) { + throw new BadRequestException({ + code: 'NO_CHANGES', + message: 'No se recibieron cambios', + }); + } + + return this.dataSource.transaction(async (manager) => { + const role = await this.lockRole(manager, id); + const before = await this.loadRoleView(manager, id); + if (dto.name !== undefined) role.name = dto.name.trim(); + if (dto.description !== undefined) { + role.description = dto.description.trim(); + } + await manager.getRepository(Role).save(role); + + const updated = await this.loadRoleView(manager, id); + await this.audit.record( + { + ...administrationAuditContext(principal, request), + action: AuditAction.ROLE_UPDATED, + entityType: 'role', + entityId: id, + beforeData: { + name: before.name, + description: before.description, + }, + afterData: { + name: updated.name, + description: updated.description, + }, + }, + manager, + ); + return updated; + }); + } + + async replacePermissions( + id: string, + dto: ReplaceRolePermissionsDto, + principal: AuthPrincipal, + request: RequestWithContext, + ): Promise { + return this.dataSource.transaction(async (manager) => { + await this.lockRole(manager, id); + const permissions = await this.resolvePermissions( + manager, + dto.permissionIds, + ); + const before = await this.loadRoleView(manager, id); + const beforeIds = before.permissions + .map((permission) => permission.id) + .sort(); + const afterIds = permissions.map((permission) => permission.id).sort(); + if (beforeIds.join(',') === afterIds.join(',')) return before; + + await manager.getRepository(RolePermission).delete({ roleId: id }); + await this.insertPermissions(manager, id, permissions); + await assertAdministrativeRecoveryRemains(manager); + + const updated = await this.loadRoleView(manager, id); + await this.audit.record( + { + ...administrationAuditContext(principal, request), + action: AuditAction.ROLE_PERMISSIONS_CHANGED, + entityType: 'role', + entityId: id, + beforeData: { permissions: before.permissions }, + afterData: { permissions: updated.permissions }, + }, + manager, + ); + return updated; + }); + } + + private async lockRole(manager: EntityManager, id: string): Promise { + const role = await manager + .getRepository(Role) + .createQueryBuilder('role') + .where('role.id = :id', { id }) + .setLock('pessimistic_write') + .getOne(); + if (!role) throw roleNotFound(); + return role; + } + + private async resolvePermissions( + manager: EntityManager, + permissionIds: string[], + ): Promise { + const uniqueIds = [...new Set(permissionIds)]; + if (uniqueIds.length === 0) return []; + const permissions = await manager.getRepository(Permission).find({ + where: { id: In(uniqueIds) }, + order: { code: 'ASC' }, + }); + if (permissions.length !== uniqueIds.length) { + throw permissionSelectionInvalid(); + } + return permissions; + } + + private async insertPermissions( + manager: EntityManager, + roleId: string, + permissions: Permission[], + ): Promise { + if (permissions.length === 0) return; + const assignments = permissions.map((permission) => + manager.getRepository(RolePermission).create({ + roleId, + permissionId: permission.id, + }), + ); + await manager.getRepository(RolePermission).save(assignments); + } + + private async loadRoleView( + manager: EntityManager, + id: string, + ): Promise { + const rows = (await manager.query( + this.roleViewQuery('WHERE role.id = $1'), + [id], + )) as AdministrativeRoleView[]; + if (!rows[0]) throw roleNotFound(); + return rows[0]; + } + + private roleViewQuery(where: string): string { + return ` + SELECT + role.id, + role.code, + role.name, + role.description, + role.is_system AS "isSystem", + role.created_at AS "createdAt", + role.updated_at AS "updatedAt", + ( + SELECT COUNT(*)::integer + FROM user_roles user_role + WHERE user_role.role_id = role.id + ) AS "userCount", + COALESCE( + ( + SELECT JSONB_AGG( + JSONB_BUILD_OBJECT( + 'id', permission.id, + 'code', permission.code, + 'description', permission.description + ) ORDER BY permission.code + ) + FROM role_permissions role_permission + INNER JOIN permissions permission + ON permission.id = role_permission.permission_id + WHERE role_permission.role_id = role.id + ), + '[]'::jsonb + ) AS permissions + FROM roles role + ${where} + ORDER BY role.is_system DESC, role.code ASC + `; + } + + private roleConflict(): ConflictException { + return new ConflictException({ + code: 'ROLE_ALREADY_EXISTS', + message: 'Ya existe un rol con ese código', + }); + } +} diff --git a/api-v3/src/administration/users/dto/change-user-status.dto.ts b/api-v3/src/administration/users/dto/change-user-status.dto.ts new file mode 100644 index 0000000..f2b020e --- /dev/null +++ b/api-v3/src/administration/users/dto/change-user-status.dto.ts @@ -0,0 +1,7 @@ +import { IsEnum } from 'class-validator'; +import { UserStatus } from '../../../database/entities'; + +export class ChangeUserStatusDto { + @IsEnum(UserStatus) + status!: UserStatus; +} diff --git a/api-v3/src/administration/users/dto/create-user.dto.ts b/api-v3/src/administration/users/dto/create-user.dto.ts new file mode 100644 index 0000000..dbc3917 --- /dev/null +++ b/api-v3/src/administration/users/dto/create-user.dto.ts @@ -0,0 +1,58 @@ +import { Transform } from 'class-transformer'; +import { + ArrayUnique, + IsArray, + IsBoolean, + IsEmail, + IsOptional, + IsString, + IsUUID, + Matches, + MaxLength, + MinLength, +} from 'class-validator'; + +export class CreateUserDto { + @Transform(({ value }) => + typeof value === 'string' ? value.trim().toLowerCase() : value, + ) + @IsString() + @MinLength(3) + @MaxLength(80) + @Matches(/^[a-zA-Z0-9._-]+$/) + username!: string; + + @Transform(({ value }) => + typeof value === 'string' ? value.trim().toLowerCase() : value, + ) + @IsOptional() + @IsEmail() + @MaxLength(320) + email?: string | null; + + @IsString() + @MinLength(12) + @MaxLength(128) + password!: string; + + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) + @IsString() + @MinLength(1) + @MaxLength(120) + firstName!: string; + + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) + @IsString() + @MinLength(1) + @MaxLength(120) + lastName!: string; + + @IsOptional() + @IsBoolean() + mustChangePassword = true; + + @IsArray() + @ArrayUnique() + @IsUUID('4', { each: true }) + roleIds!: string[]; +} diff --git a/api-v3/src/administration/users/dto/list-users-query.dto.ts b/api-v3/src/administration/users/dto/list-users-query.dto.ts new file mode 100644 index 0000000..afb9171 --- /dev/null +++ b/api-v3/src/administration/users/dto/list-users-query.dto.ts @@ -0,0 +1,35 @@ +import { Type } from 'class-transformer'; +import { + IsEnum, + IsInt, + IsOptional, + IsString, + Max, + MaxLength, + Min, +} from 'class-validator'; +import { UserStatus } from '../../../database/entities'; + +export class ListUsersQueryDto { + @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(UserStatus) + status?: UserStatus; +} diff --git a/api-v3/src/administration/users/dto/replace-user-roles.dto.ts b/api-v3/src/administration/users/dto/replace-user-roles.dto.ts new file mode 100644 index 0000000..56e41c2 --- /dev/null +++ b/api-v3/src/administration/users/dto/replace-user-roles.dto.ts @@ -0,0 +1,8 @@ +import { ArrayUnique, IsArray, IsUUID } from 'class-validator'; + +export class ReplaceUserRolesDto { + @IsArray() + @ArrayUnique() + @IsUUID('4', { each: true }) + roleIds!: string[]; +} diff --git a/api-v3/src/administration/users/dto/reset-user-password.dto.ts b/api-v3/src/administration/users/dto/reset-user-password.dto.ts new file mode 100644 index 0000000..6ccc2e4 --- /dev/null +++ b/api-v3/src/administration/users/dto/reset-user-password.dto.ts @@ -0,0 +1,12 @@ +import { IsBoolean, IsOptional, IsString, MaxLength, MinLength } from 'class-validator'; + +export class ResetUserPasswordDto { + @IsString() + @MinLength(12) + @MaxLength(128) + password!: string; + + @IsOptional() + @IsBoolean() + mustChangePassword = true; +} diff --git a/api-v3/src/administration/users/dto/update-user.dto.ts b/api-v3/src/administration/users/dto/update-user.dto.ts new file mode 100644 index 0000000..0fa1e0f --- /dev/null +++ b/api-v3/src/administration/users/dto/update-user.dto.ts @@ -0,0 +1,43 @@ +import { Transform } from 'class-transformer'; +import { + IsEmail, + IsOptional, + IsString, + Matches, + MaxLength, + MinLength, +} from 'class-validator'; + +export class UpdateUserDto { + @Transform(({ value }) => + typeof value === 'string' ? value.trim().toLowerCase() : value, + ) + @IsOptional() + @IsString() + @MinLength(3) + @MaxLength(80) + @Matches(/^[a-zA-Z0-9._-]+$/) + username?: string; + + @Transform(({ value }) => + typeof value === 'string' ? value.trim().toLowerCase() : value, + ) + @IsOptional() + @IsEmail() + @MaxLength(320) + email?: string | null; + + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) + @IsOptional() + @IsString() + @MinLength(1) + @MaxLength(120) + firstName?: string; + + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) + @IsOptional() + @IsString() + @MinLength(1) + @MaxLength(120) + lastName?: string; +} diff --git a/api-v3/src/administration/users/users.controller.ts b/api-v3/src/administration/users/users.controller.ts new file mode 100644 index 0000000..1e9827c --- /dev/null +++ b/api-v3/src/administration/users/users.controller.ts @@ -0,0 +1,96 @@ +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Patch, + Post, + Put, + Query, + Req, +} from '@nestjs/common'; +import { RequirePermissions } from '../../authorization/decorators/require-permissions.decorator'; +import { CurrentAuth } from '../../auth/decorators/current-auth.decorator'; +import type { + AuthPrincipal, + RequestWithContext, +} from '../../common/http/request-context'; +import { ChangeUserStatusDto } from './dto/change-user-status.dto'; +import { CreateUserDto } from './dto/create-user.dto'; +import { ListUsersQueryDto } from './dto/list-users-query.dto'; +import { ReplaceUserRolesDto } from './dto/replace-user-roles.dto'; +import { ResetUserPasswordDto } from './dto/reset-user-password.dto'; +import { UpdateUserDto } from './dto/update-user.dto'; +import { UsersService } from './users.service'; + +@Controller('users') +export class UsersController { + constructor(private readonly users: UsersService) {} + + @Get() + @RequirePermissions('users.read') + list(@Query() query: ListUsersQueryDto) { + return this.users.list(query); + } + + @Post() + @RequirePermissions('users.create') + create( + @Body() dto: CreateUserDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.users.create(dto, principal, request); + } + + @Get(':id') + @RequirePermissions('users.read') + getById(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) { + return this.users.getById(id); + } + + @Patch(':id') + @RequirePermissions('users.update') + update( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @Body() dto: UpdateUserDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.users.update(id, dto, principal, request); + } + + @Patch(':id/status') + @RequirePermissions('users.change_status') + changeStatus( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @Body() dto: ChangeUserStatusDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.users.changeStatus(id, dto, principal, request); + } + + @Post(':id/reset-password') + @RequirePermissions('users.update') + resetPassword( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @Body() dto: ResetUserPasswordDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.users.resetPassword(id, dto, principal, request); + } + + @Put(':id/roles') + @RequirePermissions('users.assign_roles') + replaceRoles( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @Body() dto: ReplaceUserRolesDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.users.replaceRoles(id, dto, principal, request); + } +} diff --git a/api-v3/src/administration/users/users.service.ts b/api-v3/src/administration/users/users.service.ts new file mode 100644 index 0000000..16dfd5b --- /dev/null +++ b/api-v3/src/administration/users/users.service.ts @@ -0,0 +1,497 @@ +import { + BadRequestException, + ConflictException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { DataSource, EntityManager, In } from 'typeorm'; +import { AuditService } from '../../audit/audit.service'; +import { PasswordService } from '../../auth/services/password.service'; +import type { + AuthPrincipal, + RequestWithContext, +} from '../../common/http/request-context'; +import { AuthSessionsRepository } from '../../core-data/repositories/auth-sessions.repository'; +import { + AuditAction, + Role, + User, + UserRole, + UserStatus, +} from '../../database/entities'; +import { + administrationAuditContext, + assertAdministrativeRecoveryRemains, + isUniqueViolation, +} from '../common/administration-audit'; +import type { ChangeUserStatusDto } from './dto/change-user-status.dto'; +import type { CreateUserDto } from './dto/create-user.dto'; +import type { ListUsersQueryDto } from './dto/list-users-query.dto'; +import type { ReplaceUserRolesDto } from './dto/replace-user-roles.dto'; +import type { ResetUserPasswordDto } from './dto/reset-user-password.dto'; +import type { UpdateUserDto } from './dto/update-user.dto'; + +export interface UserRoleView { + id: string; + code: string; + name: string; +} + +export interface AdministrativeUserView { + id: string; + username: string; + email: string | null; + firstName: string; + lastName: string; + status: UserStatus; + mustChangePassword: boolean; + failedLoginAttempts: number; + lockedUntil: Date | null; + lastLoginAt: Date | null; + passwordChangedAt: Date | null; + createdAt: Date; + updatedAt: Date; + roles: UserRoleView[]; +} + +interface UserViewRow extends AdministrativeUserView { + total?: string | number; +} + +function userNotFound(): NotFoundException { + return new NotFoundException({ + code: 'USER_NOT_FOUND', + message: 'Usuario no encontrado', + }); +} + +function roleSelectionInvalid(): BadRequestException { + return new BadRequestException({ + code: 'ROLE_NOT_FOUND', + message: 'Uno o más roles no existen', + }); +} + +@Injectable() +export class UsersService { + constructor( + private readonly dataSource: DataSource, + private readonly passwords: PasswordService, + private readonly sessions: AuthSessionsRepository, + private readonly audit: AuditService, + ) {} + + async list(query: ListUsersQueryDto) { + const page = query.page; + const pageSize = query.pageSize; + const filters: string[] = []; + const parameters: unknown[] = []; + const search = query.search?.trim(); + + if (search) { + parameters.push(`%${search}%`); + filters.push(` + ( + user_account.username ILIKE $${parameters.length} + OR user_account.email ILIKE $${parameters.length} + OR user_account.first_name ILIKE $${parameters.length} + OR user_account.last_name ILIKE $${parameters.length} + ) + `); + } + if (query.status) { + parameters.push(query.status); + filters.push(`user_account.status = $${parameters.length}`); + } + + parameters.push(pageSize, (page - 1) * pageSize); + const limitParameter = parameters.length - 1; + const offsetParameter = parameters.length; + const where = filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : ''; + + const rows = (await this.dataSource.query( + ` + SELECT + user_account.id, + user_account.username, + user_account.email, + user_account.first_name AS "firstName", + user_account.last_name AS "lastName", + user_account.status, + user_account.must_change_password AS "mustChangePassword", + user_account.failed_login_attempts AS "failedLoginAttempts", + user_account.locked_until AS "lockedUntil", + user_account.last_login_at AS "lastLoginAt", + user_account.password_changed_at AS "passwordChangedAt", + user_account.created_at AS "createdAt", + user_account.updated_at AS "updatedAt", + COALESCE( + JSONB_AGG( + JSONB_BUILD_OBJECT( + 'id', role.id, + 'code', role.code, + 'name', role.name + ) ORDER BY role.code + ) FILTER (WHERE role.id IS NOT NULL), + '[]'::jsonb + ) AS roles, + COUNT(*) OVER() AS total + FROM users user_account + LEFT JOIN user_roles user_role + ON user_role.user_id = user_account.id + LEFT JOIN roles role ON role.id = user_role.role_id + ${where} + GROUP BY user_account.id + ORDER BY user_account.created_at DESC, user_account.username ASC + LIMIT $${limitParameter} OFFSET $${offsetParameter} + `, + parameters, + )) as UserViewRow[]; + + const total = rows.length > 0 ? Number(rows[0].total ?? 0) : 0; + return { + data: rows.map(({ total: _total, ...row }) => row), + meta: { + page, + pageSize, + total, + totalPages: Math.ceil(total / pageSize), + }, + }; + } + + async getById(id: string): Promise { + return this.dataSource.transaction(async (manager) => + this.loadUserView(manager, id), + ); + } + + async create( + dto: CreateUserDto, + principal: AuthPrincipal, + request: RequestWithContext, + ): Promise { + const passwordHash = await this.passwords.hash(dto.password); + + try { + return await this.dataSource.transaction(async (manager) => { + const roles = await this.resolveRoles(manager, dto.roleIds); + const user = manager.getRepository(User).create({ + username: dto.username.trim().toLowerCase(), + email: dto.email?.trim().toLowerCase() || null, + passwordHash, + firstName: dto.firstName.trim(), + lastName: dto.lastName.trim(), + status: UserStatus.ACTIVE, + mustChangePassword: dto.mustChangePassword, + failedLoginAttempts: 0, + lockedUntil: null, + lastLoginAt: null, + passwordChangedAt: null, + createdBy: principal.userId, + updatedBy: principal.userId, + }); + await manager.getRepository(User).save(user); + await this.insertUserRoles( + manager, + user.id, + roles, + principal.userId, + ); + + const created = await this.loadUserView(manager, user.id); + await this.audit.record( + { + ...administrationAuditContext(principal, request), + action: AuditAction.USER_CREATED, + entityType: 'user', + entityId: user.id, + afterData: { ...created }, + }, + manager, + ); + return created; + }); + } catch (error) { + if (isUniqueViolation(error)) throw this.userConflict(); + throw error; + } + } + + async update( + id: string, + dto: UpdateUserDto, + principal: AuthPrincipal, + request: RequestWithContext, + ): Promise { + if ( + dto.username === undefined && + dto.email === undefined && + dto.firstName === undefined && + dto.lastName === undefined + ) { + throw new BadRequestException({ + code: 'NO_CHANGES', + message: 'No se recibieron cambios', + }); + } + + try { + return await this.dataSource.transaction(async (manager) => { + const user = await this.lockUser(manager, id); + const before = await this.loadUserView(manager, id); + + if (dto.username !== undefined) { + user.username = dto.username.trim().toLowerCase(); + } + if (dto.email !== undefined) { + user.email = dto.email?.trim().toLowerCase() || null; + } + if (dto.firstName !== undefined) user.firstName = dto.firstName.trim(); + if (dto.lastName !== undefined) user.lastName = dto.lastName.trim(); + user.updatedBy = principal.userId; + await manager.getRepository(User).save(user); + + const updated = await this.loadUserView(manager, id); + await this.audit.record( + { + ...administrationAuditContext(principal, request), + action: AuditAction.USER_UPDATED, + entityType: 'user', + entityId: id, + beforeData: { ...before }, + afterData: { ...updated }, + }, + manager, + ); + return updated; + }); + } catch (error) { + if (isUniqueViolation(error)) throw this.userConflict(); + throw error; + } + } + + async changeStatus( + id: string, + dto: ChangeUserStatusDto, + principal: AuthPrincipal, + request: RequestWithContext, + ): Promise { + if (id === principal.userId && dto.status === UserStatus.INACTIVE) { + throw new ConflictException({ + code: 'SELF_DEACTIVATION_FORBIDDEN', + message: 'No puede desactivar su propio usuario', + }); + } + + return this.dataSource.transaction(async (manager) => { + const user = await this.lockUser(manager, id); + const before = await this.loadUserView(manager, id); + if (user.status === dto.status) return before; + + user.status = dto.status; + user.updatedBy = principal.userId; + if (dto.status === UserStatus.ACTIVE) { + user.failedLoginAttempts = 0; + user.lockedUntil = null; + } + await manager.getRepository(User).save(user); + + if (dto.status === UserStatus.INACTIVE) { + await this.sessions.revokeUserSessions(id, undefined, manager); + } + await assertAdministrativeRecoveryRemains(manager); + + const updated = await this.loadUserView(manager, id); + await this.audit.record( + { + ...administrationAuditContext(principal, request), + action: AuditAction.USER_STATUS_CHANGED, + entityType: 'user', + entityId: id, + beforeData: { status: before.status }, + afterData: { status: updated.status }, + metadata: { + sessionsRevoked: dto.status === UserStatus.INACTIVE, + }, + }, + manager, + ); + return updated; + }); + } + + async resetPassword( + id: string, + dto: ResetUserPasswordDto, + principal: AuthPrincipal, + request: RequestWithContext, + ): Promise { + const passwordHash = await this.passwords.hash(dto.password); + return this.dataSource.transaction(async (manager) => { + const user = await this.lockUser(manager, id); + const before = await this.loadUserView(manager, id); + const now = new Date(); + + user.passwordHash = passwordHash; + user.passwordChangedAt = now; + user.mustChangePassword = dto.mustChangePassword; + user.failedLoginAttempts = 0; + user.lockedUntil = null; + user.updatedBy = principal.userId; + await manager.getRepository(User).save(user); + await this.sessions.revokeUserSessions(id, undefined, manager); + + const updated = await this.loadUserView(manager, id); + await this.audit.record( + { + ...administrationAuditContext(principal, request), + action: AuditAction.USER_PASSWORD_RESET, + entityType: 'user', + entityId: id, + beforeData: { + mustChangePassword: before.mustChangePassword, + failedLoginAttempts: before.failedLoginAttempts, + lockedUntil: before.lockedUntil, + }, + afterData: { + mustChangePassword: updated.mustChangePassword, + failedLoginAttempts: updated.failedLoginAttempts, + lockedUntil: updated.lockedUntil, + passwordChangedAt: updated.passwordChangedAt, + }, + metadata: { sessionsRevoked: true, passwordValueRecorded: false }, + }, + manager, + ); + return updated; + }); + } + + async replaceRoles( + id: string, + dto: ReplaceUserRolesDto, + principal: AuthPrincipal, + request: RequestWithContext, + ): Promise { + return this.dataSource.transaction(async (manager) => { + await this.lockUser(manager, id); + const roles = await this.resolveRoles(manager, dto.roleIds); + const before = await this.loadUserView(manager, id); + const beforeIds = before.roles.map((role) => role.id).sort(); + const afterIds = roles.map((role) => role.id).sort(); + if (beforeIds.join(',') === afterIds.join(',')) return before; + + await manager.getRepository(UserRole).delete({ userId: id }); + await this.insertUserRoles(manager, id, roles, principal.userId); + await assertAdministrativeRecoveryRemains(manager); + + const updated = await this.loadUserView(manager, id); + await this.audit.record( + { + ...administrationAuditContext(principal, request), + action: AuditAction.USER_ROLES_CHANGED, + entityType: 'user', + entityId: id, + beforeData: { roles: before.roles }, + afterData: { roles: updated.roles }, + }, + manager, + ); + return updated; + }); + } + + private async resolveRoles( + manager: EntityManager, + roleIds: string[], + ): Promise { + const uniqueIds = [...new Set(roleIds)]; + if (uniqueIds.length === 0) return []; + const roles = await manager.getRepository(Role).find({ + where: { id: In(uniqueIds) }, + order: { code: 'ASC' }, + }); + if (roles.length !== uniqueIds.length) throw roleSelectionInvalid(); + return roles; + } + + private async insertUserRoles( + manager: EntityManager, + userId: string, + roles: Role[], + assignedBy: string, + ): Promise { + if (roles.length === 0) return; + const assignments = roles.map((role) => + manager.getRepository(UserRole).create({ + userId, + roleId: role.id, + assignedBy, + }), + ); + await manager.getRepository(UserRole).save(assignments); + } + + private async lockUser(manager: EntityManager, id: string): Promise { + const user = await manager + .getRepository(User) + .createQueryBuilder('user') + .where('user.id = :id', { id }) + .setLock('pessimistic_write') + .getOne(); + if (!user) throw userNotFound(); + return user; + } + + private async loadUserView( + manager: EntityManager, + id: string, + ): Promise { + const [row] = (await manager.query( + ` + SELECT + user_account.id, + user_account.username, + user_account.email, + user_account.first_name AS "firstName", + user_account.last_name AS "lastName", + user_account.status, + user_account.must_change_password AS "mustChangePassword", + user_account.failed_login_attempts AS "failedLoginAttempts", + user_account.locked_until AS "lockedUntil", + user_account.last_login_at AS "lastLoginAt", + user_account.password_changed_at AS "passwordChangedAt", + user_account.created_at AS "createdAt", + user_account.updated_at AS "updatedAt", + COALESCE( + JSONB_AGG( + JSONB_BUILD_OBJECT( + 'id', role.id, + 'code', role.code, + 'name', role.name + ) ORDER BY role.code + ) FILTER (WHERE role.id IS NOT NULL), + '[]'::jsonb + ) AS roles + FROM users user_account + LEFT JOIN user_roles user_role + ON user_role.user_id = user_account.id + LEFT JOIN roles role ON role.id = user_role.role_id + WHERE user_account.id = $1 + GROUP BY user_account.id + `, + [id], + )) as AdministrativeUserView[]; + if (!row) throw userNotFound(); + return row; + } + + private userConflict(): ConflictException { + return new ConflictException({ + code: 'USER_ALREADY_EXISTS', + message: 'El usuario o email ya está registrado', + }); + } +} diff --git a/api-v3/src/app.module.ts b/api-v3/src/app.module.ts new file mode 100644 index 0000000..3a50dca --- /dev/null +++ b/api-v3/src/app.module.ts @@ -0,0 +1,90 @@ +import { Module } from '@nestjs/common'; +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 { AdministrationModule } from './administration/administration.module'; +import { AuthorizationModule } from './authorization/authorization.module'; +import { PermissionsGuard } from './authorization/guards/permissions.guard'; +import { AuthModule } from './auth/auth.module'; +import { AccessTokenGuard } from './auth/guards/access-token.guard'; +import { CsrfGuard } from './auth/guards/csrf.guard'; +import { PhaseADataModule } from './core-data/phase-a-data.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 { InspectionReportsModule } from './inspection-reports/inspection-reports.module'; +import { InspectionVerificationsModule } from './inspection-verifications/inspection-verifications.module'; + +function required(config: ConfigService, key: string): string { + const value = config.get(key); + if (!value) throw new Error(`Missing required environment variable: ${key}`); + return value; +} + +@Module({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + ignoreEnvFile: true, + }), + TypeOrmModule.forRootAsync({ + inject: [ConfigService], + useFactory: (config: ConfigService) => ({ + type: 'postgres', + host: required(config, 'DB_HOST'), + port: Number(config.get('DB_PORT') ?? 5432), + database: required(config, 'DB_NAME'), + username: required(config, 'DB_APP_USER'), + password: required(config, 'DB_APP_PASSWORD'), + autoLoadEntities: true, + synchronize: false, + migrationsRun: false, + logging: false, + applicationName: 'dhv2-api', + connectTimeoutMS: 5000, + }), + }), + ThrottlerModule.forRoot([ + { + name: 'default', + ttl: 60_000, + limit: 120, + }, + ]), + PhaseADataModule, + AuditModule, + AuthorizationModule, + AuthModule, + AdministrationModule, + DashboardModule, + AssetMasterModule, + SurveyPlanningModule, + SurveyExecutionModule, + InspectionVisitsModule, + InspectionActsModule, + InspectionFindingsModule, + InspectionClosingModule, + InspectionReportsModule, + InspectionVerificationsModule, + AssetImportsModule, + ], + controllers: [HealthController], + providers: [ + HealthService, + { provide: APP_GUARD, useClass: ThrottlerGuard }, + { provide: APP_GUARD, useClass: AccessTokenGuard }, + { provide: APP_GUARD, useClass: PermissionsGuard }, + { provide: APP_GUARD, useClass: CsrfGuard }, + ], +}) +export class AppModule {} diff --git a/api-v3/src/asset-imports/asset-import-parser.ts b/api-v3/src/asset-imports/asset-import-parser.ts new file mode 100644 index 0000000..4adcd12 --- /dev/null +++ b/api-v3/src/asset-imports/asset-import-parser.ts @@ -0,0 +1,250 @@ +import { execFile as execFileCallback } from 'node:child_process'; +import { promisify } from 'node:util'; +import { BadRequestException } from '@nestjs/common'; + +const execFile = promisify(execFileCallback); + +export const MAX_ASSET_IMPORT_BYTES = 25 * 1024 * 1024; +const MAX_XLSX_UNCOMPRESSED_BYTES = 120 * 1024 * 1024; +const MAX_XLSX_ENTRIES = 2500; +const MAX_ROWS_PER_SHEET = 50_000; +const MAX_COLUMNS = 120; + +export interface UploadedImportFile { + originalname: string; + mimetype: string; + size: number; + buffer: Buffer; +} + +export interface ParsedSheet { + name: string; + rows: string[][]; +} + +export interface ParsedWorkbook { + kind: 'XLSX' | 'CSV'; + sheets: ParsedSheet[]; +} + +function importFileError(code: string, message: string): BadRequestException { + return new BadRequestException({ code, message }); +} + +export function inspectImportFile(file: UploadedImportFile | undefined): { extension: '.xlsx' | '.csv'; mimeType: string } { + if (!file?.buffer?.length) throw importFileError('IMPORT_FILE_REQUIRED', 'Seleccioná un archivo XLSX o CSV'); + if (file.buffer.length > MAX_ASSET_IMPORT_BYTES) throw importFileError('IMPORT_FILE_TOO_LARGE', 'El archivo supera el límite de 25 MB'); + const lower = file.originalname.toLowerCase(); + if (lower.endsWith('.xlsx')) { + if (!(file.buffer[0] === 0x50 && file.buffer[1] === 0x4b)) { + throw importFileError('INVALID_XLSX_FILE', 'El contenido no corresponde a un archivo XLSX válido'); + } + return { extension: '.xlsx', mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }; + } + if (lower.endsWith('.csv')) { + if (file.buffer.includes(0)) throw importFileError('INVALID_CSV_FILE', 'El CSV contiene datos binarios no admitidos'); + return { extension: '.csv', mimeType: 'text/csv' }; + } + throw importFileError('UNSUPPORTED_IMPORT_FILE', 'Sólo se admiten archivos .xlsx y .csv'); +} + +function decodeXml(value: string): string { + return value + .replace(/&#x([0-9a-f]+);/gi, (_match, hex: string) => String.fromCodePoint(Number.parseInt(hex, 16))) + .replace(/&#([0-9]+);/g, (_match, decimal: string) => String.fromCodePoint(Number.parseInt(decimal, 10))) + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/&/g, '&'); +} + +function stripXmlText(xml: string): string { + const parts: string[] = []; + for (const match of xml.matchAll(/]*)?>([\s\S]*?)<\/t>/g)) parts.push(decodeXml(match[1] ?? '')); + return parts.join(''); +} + +function columnIndex(reference: string): number { + const match = /^([A-Z]+)\d+$/i.exec(reference); + if (!match) return -1; + let result = 0; + for (const char of match[1]!.toUpperCase()) result = result * 26 + (char.charCodeAt(0) - 64); + return result - 1; +} + +async function zipList(filePath: string): Promise { + let stdout: string; + try { + ({ stdout } = await execFile('unzip', ['-Z1', filePath], { encoding: 'utf8', maxBuffer: 4 * 1024 * 1024 })); + } catch { + throw importFileError('XLSX_UNZIP_UNAVAILABLE', 'No se pudo inspeccionar el XLSX. Verificá que el archivo no esté dañado'); + } + const entries = stdout.split(/\r?\n/).map((item) => item.trim()).filter(Boolean); + if (entries.length > MAX_XLSX_ENTRIES) throw importFileError('XLSX_TOO_COMPLEX', 'El XLSX contiene demasiados archivos internos'); + if (entries.some((entry) => entry.startsWith('/') || entry.split('/').includes('..'))) { + throw importFileError('INVALID_XLSX_PATH', 'El XLSX contiene rutas internas inválidas'); + } + return entries; +} + +async function assertZipSize(filePath: string): Promise { + try { + const { stdout } = await execFile('unzip', ['-l', filePath], { encoding: 'utf8', maxBuffer: 4 * 1024 * 1024 }); + const summary = stdout.split(/\r?\n/).reverse().find((line) => /\bfiles?\b/.test(line)); + const bytes = summary ? Number(/^\s*(\d+)/.exec(summary)?.[1] ?? 0) : 0; + if (Number.isFinite(bytes) && bytes > MAX_XLSX_UNCOMPRESSED_BYTES) { + throw importFileError('XLSX_UNCOMPRESSED_TOO_LARGE', 'El contenido descomprimido del XLSX supera el límite de seguridad'); + } + } catch (error) { + if (error instanceof BadRequestException) throw error; + throw importFileError('INVALID_XLSX_FILE', 'No se pudo leer la estructura interna del XLSX'); + } +} + +async function zipEntry(filePath: string, entry: string): Promise { + try { + const { stdout } = await execFile('unzip', ['-p', filePath, entry], { + encoding: 'utf8', + maxBuffer: MAX_XLSX_UNCOMPRESSED_BYTES, + }); + return stdout; + } catch { + throw importFileError('INVALID_XLSX_FILE', `No se pudo leer ${entry} dentro del XLSX`); + } +} + +function workbookSheets(workbookXml: string, relationshipsXml: string): Array<{ name: string; path: string }> { + const relationTargets = new Map(); + for (const relation of relationshipsXml.matchAll(/]*)\/?\s*>/g)) { + const attributes = relation[1] ?? ''; + const id = /\bId="([^"]+)"/.exec(attributes)?.[1]; + const target = /\bTarget="([^"]+)"/.exec(attributes)?.[1]; + if (id && target) relationTargets.set(id, target); + } + const result: Array<{ name: string; path: string }> = []; + for (const sheet of workbookXml.matchAll(/]*)\/?\s*>/g)) { + const attributes = sheet[1] ?? ''; + const name = decodeXml(/\bname="([^"]+)"/.exec(attributes)?.[1] ?? 'Hoja'); + const relationId = /\br:id="([^"]+)"/.exec(attributes)?.[1]; + if (!relationId) continue; + const target = relationTargets.get(relationId); + if (!target) continue; + const clean = target.replace(/^\//, ''); + const path = clean.startsWith('xl/') ? clean : `xl/${clean.replace(/^\.\//, '')}`; + result.push({ name, path }); + } + return result; +} + +function parseSharedStrings(xml: string): string[] { + const result: string[] = []; + for (const match of xml.matchAll(/]*)?>([\s\S]*?)<\/si>/g)) result.push(stripXmlText(match[1] ?? '')); + return result; +} + +function cellValue(cellXml: string, cellType: string | undefined, sharedStrings: string[]): string { + if (cellType === 'inlineStr') return stripXmlText(cellXml).trim(); + const raw = /]*)?>([\s\S]*?)<\/v>/.exec(cellXml)?.[1] ?? ''; + const value = decodeXml(raw); + if (cellType === 's') { + const index = Number.parseInt(value, 10); + return Number.isInteger(index) ? (sharedStrings[index] ?? '') : ''; + } + if (cellType === 'b') return value === '1' ? 'TRUE' : 'FALSE'; + if (cellType === 'str') return value; + return value; +} + +function parseSheetXml(xml: string, sharedStrings: string[]): string[][] { + const rows: string[][] = []; + let count = 0; + for (const rowMatch of xml.matchAll(/]*>([\s\S]*?)<\/row>/g)) { + if (++count > MAX_ROWS_PER_SHEET) throw importFileError('IMPORT_TOO_MANY_ROWS', `La hoja supera ${MAX_ROWS_PER_SHEET.toLocaleString('es-AR')} filas`); + const values: string[] = []; + const rowXml = rowMatch[1] ?? ''; + for (const cellMatch of rowXml.matchAll(/]*)>([\s\S]*?)<\/c>/g)) { + const attributes = cellMatch[1] ?? ''; + const reference = /\br="([A-Z]+\d+)"/i.exec(attributes)?.[1]; + if (!reference) continue; + const index = columnIndex(reference); + if (index < 0 || index >= MAX_COLUMNS) continue; + const type = /\bt="([^"]+)"/.exec(attributes)?.[1]; + values[index] = cellValue(cellMatch[2] ?? '', type, sharedStrings).trim(); + } + while (values.length && !values[values.length - 1]) values.pop(); + rows.push(values.map((value) => value ?? '')); + } + return rows; +} + +function detectDelimiter(firstLines: string[]): ',' | ';' | '\t' { + const candidates: Array<',' | ';' | '\t'> = [',', ';', '\t']; + let best: ',' | ';' | '\t' = ','; + let score = -1; + for (const candidate of candidates) { + const current = firstLines.reduce((sum, line) => sum + line.split(candidate).length - 1, 0); + if (current > score) { score = current; best = candidate; } + } + return best; +} + +export function parseCsv(text: string): string[][] { + const normalized = text.replace(/^\uFEFF/, ''); + const sample = normalized.split(/\r?\n/).slice(0, 8); + const delimiter = detectDelimiter(sample); + const rows: string[][] = []; + let row: string[] = []; + let value = ''; + let quoted = false; + for (let i = 0; i < normalized.length; i += 1) { + const char = normalized[i]!; + if (char === '"') { + if (quoted && normalized[i + 1] === '"') { value += '"'; i += 1; } + else quoted = !quoted; + continue; + } + if (!quoted && char === delimiter) { row.push(value.trim()); value = ''; continue; } + if (!quoted && (char === '\n' || char === '\r')) { + if (char === '\r' && normalized[i + 1] === '\n') i += 1; + row.push(value.trim()); value = ''; + if (row.some(Boolean)) rows.push(row); + row = []; + if (rows.length > MAX_ROWS_PER_SHEET) throw importFileError('IMPORT_TOO_MANY_ROWS', `El archivo supera ${MAX_ROWS_PER_SHEET.toLocaleString('es-AR')} filas`); + continue; + } + value += char; + } + if (value.length || row.length) { row.push(value.trim()); if (row.some(Boolean)) rows.push(row); } + return rows; +} + +export async function parseImportWorkbook(filePath: string, extension: '.xlsx' | '.csv'): Promise { + if (extension === '.csv') { + const { readFile } = await import('node:fs/promises'); + const text = await readFile(filePath, 'utf8'); + return { kind: 'CSV', sheets: [{ name: 'CSV', rows: parseCsv(text) }] }; + } + await assertZipSize(filePath); + const entries = await zipList(filePath); + if (!entries.includes('xl/workbook.xml') || !entries.includes('xl/_rels/workbook.xml.rels')) { + throw importFileError('INVALID_XLSX_FILE', 'El archivo no contiene una estructura XLSX compatible'); + } + const [workbookXml, relationshipsXml] = await Promise.all([ + zipEntry(filePath, 'xl/workbook.xml'), + zipEntry(filePath, 'xl/_rels/workbook.xml.rels'), + ]); + const sharedStrings = entries.includes('xl/sharedStrings.xml') + ? parseSharedStrings(await zipEntry(filePath, 'xl/sharedStrings.xml')) + : []; + const sheets = workbookSheets(workbookXml, relationshipsXml); + if (!sheets.length) throw importFileError('XLSX_WITHOUT_SHEETS', 'El XLSX no contiene hojas legibles'); + const parsed: ParsedSheet[] = []; + for (const sheet of sheets.slice(0, 30)) { + if (!entries.includes(sheet.path)) continue; + const xml = await zipEntry(filePath, sheet.path); + parsed.push({ name: sheet.name, rows: parseSheetXml(xml, sharedStrings) }); + } + if (!parsed.length) throw importFileError('XLSX_WITHOUT_SHEETS', 'No se pudo leer ninguna hoja del XLSX'); + return { kind: 'XLSX', sheets: parsed }; +} diff --git a/api-v3/src/asset-imports/asset-import-plan.ts b/api-v3/src/asset-imports/asset-import-plan.ts new file mode 100644 index 0000000..9487ec1 --- /dev/null +++ b/api-v3/src/asset-imports/asset-import-plan.ts @@ -0,0 +1,328 @@ +import { createHash } from 'node:crypto'; + +export type AssetImportPlanEntityKind = 'DEPARTMENT' | 'ORGANIZATION' | 'AREA' | 'AREA_DEPARTMENT_RELATION' | 'FIELD' | 'OPERATOR_RELATION' | 'LEGAL_RIGHT' | 'LEGAL_RIGHT_ORGANIZATION' | 'INSTALLATION' | 'LOCAL_STRUCTURE' | 'TECHNICAL_ASSET'; +export type AssetImportPlanAction = 'CREATE' | 'MATCH' | 'REVIEW' | 'IGNORE'; +export type AssetImportPlanItemStatus = 'PLANNED' | 'MATCHED' | 'REVIEW' | 'IGNORED' | 'APPLIED' | 'ROLLED_BACK' | 'FAILED'; +export type AssetImportPlanStatus = 'REVIEW_REQUIRED' | 'READY' | 'APPLIED' | 'ROLLED_BACK' | 'SUPERSEDED' | 'FAILED'; + +export interface AssetImportPlanDraftItem { + entityKey: string; + entityKind: AssetImportPlanEntityKind; + action: AssetImportPlanAction; + status: AssetImportPlanItemStatus; + assetTypeCode: string | null; + displayName: string; + generatedCode: string | null; + parentEntityKey: string | null; + matchedAssetId: string | null; + payload: Record; + sourceRowNumbers: number[]; + reviewCodes: string[]; +} + +export function plainImportKey(value: unknown): string { + return String(value ?? '') + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, ' ') + .trim() + .replace(/\s+/g, ' '); +} + +export function organizationImportKey(value: unknown): string { + const tokens = plainImportKey(value).split(' ').filter(Boolean); + const output: string[] = []; + for (let index = 0; index < tokens.length;) { + if (tokens[index]!.length !== 1) { output.push(tokens[index]!); index += 1; continue; } + const letters: string[] = []; + let cursor = index; + while (cursor < tokens.length && tokens[cursor]!.length === 1) { letters.push(tokens[cursor]!); cursor += 1; } + output.push(letters.length >= 2 ? letters.join('') : letters[0]!); + index = cursor; + } + return output.join(' '); +} + + +export function isExplicitlyUnassignedOperator(value: unknown): boolean { + return plainImportKey(value) === 'sin empresa operadora'; +} + + +export type NormalizedLegalRightType = 'EXPLOITATION_CONCESSION' | 'EXPLORATION_PERMIT' | 'TRANSPORT_CONCESSION' | 'OTHER'; + +export function normalizedLegalRightType(value: unknown): NormalizedLegalRightType | null { + const normalized = plainImportKey(value); + if (normalized === 'explotacion') return 'EXPLOITATION_CONCESSION'; + if (normalized === 'exploracion') return 'EXPLORATION_PERMIT'; + if (normalized === 'transporte' || normalized === 'concesion de transporte') return 'TRANSPORT_CONCESSION'; + if (!normalized) return null; + return 'OTHER'; +} + +export function legalRightTypeLabel(value: NormalizedLegalRightType): string { + if (value === 'EXPLOITATION_CONCESSION') return 'Concesión de explotación'; + if (value === 'EXPLORATION_PERMIT') return 'Permiso de exploración'; + if (value === 'TRANSPORT_CONCESSION') return 'Concesión de transporte'; + return 'Otro derecho'; +} + +export function departmentCode(value: unknown): string { + const normalized = String(value ?? '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toUpperCase().replace(/[^A-Z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 70); + return normalized || 'SIN-DEPARTAMENTO'; +} + +export function externalIdNamespace(input: string | null | undefined, originalName: string): string { + const seed = (input?.trim() || originalName.replace(/\.[^.]+$/, '').split(/[-_]/)[0] || 'IMPORT').normalize('NFD').replace(/[\u0300-\u036f]/g, ''); + const normalized = seed.toUpperCase().replace(/[^A-Z0-9._/-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80); + if (normalized.length >= 2 && /^[A-Z0-9]/.test(normalized)) return normalized; + return 'IMPORT'; +} + +export function generatedImportCode(planId: string, typeCode: string, entityKey: string): string { + const prefix = typeCode.toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 10) || 'ACT'; + const digest = createHash('sha256').update(`${planId}\u001f${entityKey}`).digest('hex').slice(0, 12).toUpperCase(); + return `IMP-${prefix}-${digest}`; +} + +export function technicalFamilyTypeCode(family: unknown, subtype: unknown): string { + const normalizedFamily = plainImportKey(family).replace(/ /g, '_'); + const normalizedSubtype = plainImportKey(subtype).replace(/ /g, '_'); + const direct = new Set(['tanque','separador','bomba','caldera','antorcha','colector','filtro','calentador','ducto','pozo']); + if (direct.has(normalizedFamily)) return normalizedFamily; + if (normalizedFamily === 'pileta') return 'pileta_api'; + if (normalizedFamily === 'defensa_incendios') return 'sistema_defensa_incendios'; + if (normalizedFamily === 'instalacion' && normalizedSubtype === 'planta') return 'planta'; + if (normalizedFamily === 'instalacion' && normalizedSubtype === 'bateria') return 'bateria'; + return 'equipo'; +} + + + +export interface SourceLocalStructureSuggestion { + displayName: string; + sourcePath: string[]; + sourceInstallation: string | null; + sourceSubInstallation: string | null; + concreteFromLocation: boolean; + sourceGroupOnly: boolean; +} + +export function sourceLocalStructureSuggestion( + areaOrField: unknown, + installation: unknown, + subInstallation: unknown, + location: unknown, +): SourceLocalStructureSuggestion | null { + const areaText = String(areaOrField ?? '').trim(); + const installationText = String(installation ?? '').trim(); + const subInstallationText = String(subInstallation ?? '').trim(); + const locationText = String(location ?? '').trim(); + const unusable = (value: string) => !value || ['-', 'n/a', 'na', 's/d', 'sd', 'sin dato', 'sin datos'].includes(plainImportKey(value)); + const sourcePath = locationText.split('/').map((segment) => segment.trim()).filter((segment) => !unusable(segment)); + const areaKey = plainImportKey(areaText); + const installationKey = plainImportKey(installationText); + const subInstallationKey = plainImportKey(subInstallationText); + const genericProvinceKeys = new Set(['mendoza', 'provincia de mendoza']); + const genericLocationKeys = new Set([ + 'yacimiento','planta','energia','edilicio','transporte','repositorio','repositorios','op digitales', + 'bateria','set','pta','ptc','em','pcg','et','oficina','estacion de servicio','taller','almacen', + ]); + + const contextualSegments = sourcePath.filter((segment, index) => { + const key = plainImportKey(segment); + if (!key) return false; + if (index === 0 && genericProvinceKeys.has(key)) return false; + if (areaKey && key === areaKey) return false; + return true; + }); + + const concrete = [...contextualSegments].reverse().find((segment) => { + const key = plainImportKey(segment); + if (!key) return false; + if (installationKey && key === installationKey) return false; + if (subInstallationKey && key === subInstallationKey) return false; + if (/^pozo\b/.test(key)) return false; + return !genericLocationKeys.has(key); + }) ?? null; + + if (concrete) { + return { + displayName: concrete.slice(0, 200), + sourcePath, + sourceInstallation: unusable(installationText) ? null : installationText, + sourceSubInstallation: unusable(subInstallationText) ? null : subInstallationText, + concreteFromLocation: true, + sourceGroupOnly: false, + }; + } + + const categoryParts = [installationText, subInstallationText] + .filter((value) => !unusable(value)) + .filter((value, index, values) => values.findIndex((candidate) => plainImportKey(candidate) === plainImportKey(value)) === index); + if (!categoryParts.length) return null; + return { + displayName: categoryParts.join(' / ').slice(0, 200), + sourcePath, + sourceInstallation: unusable(installationText) ? null : installationText, + sourceSubInstallation: unusable(subInstallationText) ? null : subInstallationText, + concreteFromLocation: false, + sourceGroupOnly: true, + }; +} +export function sourceContainerTypeCode(installation: unknown, subInstallation?: unknown): string { + const parent = plainImportKey(installation); + const sub = plainImportKey(subInstallation); + if (sub === 'bateria') return 'bateria'; + if (['pta', 'ptc', 'pcg'].includes(sub)) return 'planta'; + if (sub === 'estacion de servicio') return 'estacion'; + if (/\bplanta\b/.test(parent)) return 'planta'; + if (/\bbateria\b/.test(parent)) return 'bateria'; + if (/\bsubestacion\b/.test(parent)) return 'subestacion'; + if (/\bestacion\b/.test(parent)) return 'estacion'; + if (/\blocacion\b/.test(parent)) return 'locacion'; + return 'instalacion'; +} + +export function sourceContainerName(installation: unknown, subInstallation: unknown, location?: unknown): string | null { + const sub = String(subInstallation ?? '').trim(); + const parent = String(installation ?? '').trim(); + const locationText = String(location ?? '').trim(); + const unusable = (value: string) => !value || ['-', 'n/a', 'na', 's/d', 'sd', 'sin dato', 'sin datos'].includes(plainImportKey(value)); + const genericSub = new Set(['bateria','set','pta','ptc','em','pcg','et','transporte','oficina','repositorio','repositorios','estacion de servicio','taller','op digitales','almacen']); + const subKey = plainImportKey(sub); + if (!unusable(sub) && !genericSub.has(subKey)) return sub; + + const segments = locationText.split('/').map((segment) => segment.trim()).filter(Boolean); + const prefixes: Record = { + bateria: /^BAT[A-Z0-9-]/i, + pta: /^PTA[A-Z0-9-]/i, + ptc: /^(PTC[A-Z0-9-]|ESTACION DE BOMBEO)/i, + pcg: /^PCG[A-Z0-9-]/i, + }; + const prefix = prefixes[subKey]; + if (prefix) { + const candidate = segments.find((segment) => plainImportKey(segment) !== subKey && prefix.test(segment)); + if (candidate) return candidate; + } + + if (!unusable(parent) && !['planta','bateria','estacion','subestacion','yacimiento','locacion','energia','edilicio'].includes(plainImportKey(parent))) return parent; + return null; +} + +export function planItemHash(items: Array>): string { + const canonical = items + .map((item) => ({ + entityKey: item.entityKey, + entityKind: item.entityKind, + action: item.action, + status: item.status, + assetTypeCode: item.assetTypeCode, + displayName: item.displayName, + generatedCode: item.generatedCode, + parentEntityKey: item.parentEntityKey, + matchedAssetId: item.matchedAssetId, + payload: item.payload, + sourceRowNumbers: [...item.sourceRowNumbers].sort((a, b) => a - b), + reviewCodes: [...item.reviewCodes].sort(), + })) + .sort((a, b) => a.entityKey.localeCompare(b.entityKey)); + return createHash('sha256').update(JSON.stringify(canonical)).digest('hex'); +} + +export const PLAN_DEPENDENCY_REVIEW_CODES = new Set([ + 'PLAN_DEPARTMENT_REVIEW_REQUIRED', + 'PLAN_LEGAL_RIGHT_REVIEW_REQUIRED', + 'PLAN_AREA_REVIEW_REQUIRED', + 'PLAN_ORGANIZATION_REVIEW_REQUIRED', + 'PLAN_TERRITORY_CONTEXT_REQUIRED', + 'PLAN_CONTEXT_DECISION_REQUIRED', + 'PLAN_CONTAINER_REVIEW_REQUIRED', + 'PLAN_PARENT_NOT_RESOLVED', +]); + +export function isPlanDependencyReview(item: Pick): boolean { + return item.action === 'REVIEW' + && item.reviewCodes.length > 0 + && item.reviewCodes.every((code) => PLAN_DEPENDENCY_REVIEW_CODES.has(code)); +} + +export function planDependencyKeys(item: Pick): string[] { + const keys = new Set(); + if (item.parentEntityKey) keys.add(item.parentEntityKey); + for (const key of ['areaEntityKey','organizationEntityKey','departmentEntityKey','legalRightEntityKey','operationalAreaEntityKey','operatorEntityKey','localStructureEntityKey']) { + const value = item.payload[key]; + if (typeof value === 'string' && value.trim()) keys.add(value.trim()); + } + keys.delete(item.entityKey); + return [...keys]; +} + +export function safePlanItems>(items: T[]): T[] { + const byKey = new Map(items.map((item) => [item.entityKey, item])); + const memo = new Map(); + const visiting = new Set(); + const isSafe = (item: T): boolean => { + if (item.status === 'APPLIED') return true; + if (item.action === 'REVIEW') return false; + const cached = memo.get(item.entityKey); + if (cached !== undefined) return cached; + if (visiting.has(item.entityKey)) return false; + visiting.add(item.entityKey); + const safe = planDependencyKeys(item).every((key) => { + const dependency = byKey.get(key); + return !dependency || isSafe(dependency); + }); + visiting.delete(item.entityKey); + memo.set(item.entityKey, safe); + return safe; + }; + return items.filter((item) => isSafe(item)); +} + +export function planStatusForItems(items: Array>): AssetImportPlanStatus { + return items.some((item) => item.action === 'REVIEW') ? 'REVIEW_REQUIRED' : 'READY'; +} + +export function summarizePlanItems(items: AssetImportPlanDraftItem[]): Record { + const actionCounts = { create: 0, match: 0, review: 0, ignore: 0 }; + let directReviewItems = 0; + let dependencyReviewItems = 0; + let appliedCreateItems = 0; + let pendingCreateItems = 0; + const byKind: Record = {}; + for (const item of items) { + const action = item.action.toLowerCase() as keyof typeof actionCounts; + actionCounts[action] += 1; + if (item.action === 'CREATE') { + if (item.status === 'APPLIED') appliedCreateItems += 1; + else pendingCreateItems += 1; + } + if (item.action === 'REVIEW') { + if (isPlanDependencyReview(item)) dependencyReviewItems += 1; + else directReviewItems += 1; + } + const current = byKind[item.entityKind] ?? { create: 0, match: 0, review: 0, ignore: 0, total: 0 }; + current[action] += 1; + current.total += 1; + byKind[item.entityKind] = current; + } + const safeCreateItems = safePlanItems(items).filter((item) => item.action === 'CREATE' && item.status !== 'APPLIED').length; + return { + totalItems: items.length, + createItems: actionCounts.create, + matchItems: actionCounts.match, + reviewItems: actionCounts.review, + directReviewItems, + dependencyReviewItems, + ignoreItems: actionCounts.ignore, + appliedCreateItems, + pendingCreateItems, + safeCreateItems, + blockedCreateItems: Math.max(0, pendingCreateItems - safeCreateItems), + partialApplied: appliedCreateItems > 0 && actionCounts.review > 0, + byKind, + blocked: actionCounts.review > 0, + }; +} diff --git a/api-v3/src/asset-imports/asset-import-profiles.ts b/api-v3/src/asset-imports/asset-import-profiles.ts new file mode 100644 index 0000000..791e714 --- /dev/null +++ b/api-v3/src/asset-imports/asset-import-profiles.ts @@ -0,0 +1,447 @@ +import { createHash } from 'node:crypto'; +import type { ParsedSheet, ParsedWorkbook } from './asset-import-parser'; + +export type AssetImportProfileCode = 'MENDOZA_INVENTORY_V1' | 'MENDOZA_YACIMIENTOS_V1' | 'UNKNOWN'; +export type AssetImportRowStatus = 'READY' | 'WARNING' | 'CONFLICT' | 'IGNORED'; +export type AssetImportSuggestedAction = 'CREATE' | 'MATCH' | 'REVIEW' | 'IGNORE'; + +export interface ImportProfileDetection { + profileCode: AssetImportProfileCode; + confidence: number; + sheetName: string; + headerRow: number; + columnMap: Record; + detectedHeaders: string[]; +} + +export interface ImportNormalizedRow { + profileCode: AssetImportProfileCode; + rowNumber: number; + sheetName: string; + raw: Record; + normalized: Record; + status: AssetImportRowStatus; + suggestedAction: AssetImportSuggestedAction; + issues: string[]; + fingerprint: string; +} + +export interface ImportAnalysis { + detection: ImportProfileDetection; + rows: ImportNormalizedRow[]; + summary: { + totalRows: number; + readyRows: number; + warningRows: number; + conflictRows: number; + ignoredRows: number; + issueCounts: Record; + }; +} + +function plain(value: string): string { + return value + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, ' ') + .trim() + .replace(/\s+/g, ' '); +} + +function compact(value: string): string { + return plain(value).replace(/\s+/g, ''); +} + +const inventoryAliases: Record = { + item: ['item', 'n item', 'numero item'], + areaOrField: ['area yacimiento', 'area/yacimiento', 'area y yacimiento', 'area yacimiento '], + installation: ['instalacion', 'intalacion'], + subInstallation: ['sub instalacion', 'subinstalacion'], + equipment: ['equipo'], + equipmentDenomination: ['denominacion del equipo', 'denominacion equipo'], + location: ['ubicacion del equipo o instalacion', 'ubicacion equipo o instalacion', 'ubicacion'], + inventoryId: ['n id inventario', 'n° id inventario', 'nº id inventario', 'id inventario', 'numero id inventario'], + quantity: ['cantidad'], + technicalSpecs: ['especificaciones tecnicas', 'especificacion tecnica'], + sourceStatus: ['estado en servicio fuera de servicio', 'estado', 'estado servicio'], +}; + +const fieldAliases: Record = { + field: ['yacimiento', 'nombre yacimiento'], + area: ['area', 'area hidrocarburifera'], + department: ['departamento'], + rightType: ['tipo concesion', 'tipo de concesion', 'tipo permiso', 'tipo'], + operator: ['operadora', 'operador', 'empresa operadora'], +}; + +function aliasScore(header: string, aliases: string[]): number { + const normalized = plain(header); + const normalizedCompact = compact(header); + let best = 0; + for (const alias of aliases) { + const a = plain(alias); + const ac = compact(alias); + if (normalized === a || normalizedCompact === ac) best = Math.max(best, 10 + a.length / 100); + else if (normalized.includes(a) || a.includes(normalized)) best = Math.max(best, 6 + Math.min(a.length, normalized.length) / 100); + } + return best; +} + +function mapHeaders(headers: string[], aliases: Record): { map: Record; score: number } { + const candidates: Array<{ field: string; index: number; score: number }> = []; + headers.forEach((header, index) => { + Object.entries(aliases).forEach(([field, values]) => { + const score = aliasScore(header, values); + if (score > 0) candidates.push({ field, index, score }); + }); + }); + candidates.sort((a, b) => b.score - a.score); + const usedFields = new Set(); + const usedIndexes = new Set(); + const map: Record = {}; + let score = 0; + for (const candidate of candidates) { + if (usedFields.has(candidate.field) || usedIndexes.has(candidate.index)) continue; + usedFields.add(candidate.field); + usedIndexes.add(candidate.index); + map[candidate.field] = candidate.index; + score += candidate.score; + } + return { map, score }; +} + +function bestHeader(sheet: ParsedSheet, aliases: Record): { row: number; map: Record; score: number; headers: string[] } { + let best = { row: 0, map: {} as Record, score: -1, headers: [] as string[] }; + for (let index = 0; index < Math.min(sheet.rows.length, 30); index += 1) { + const headers = sheet.rows[index] ?? []; + const mapped = mapHeaders(headers, aliases); + if (mapped.score > best.score) best = { row: index + 1, map: mapped.map, score: mapped.score, headers }; + } + return best; +} + +export function detectImportProfile(workbook: ParsedWorkbook): ImportProfileDetection { + let best: ImportProfileDetection = { profileCode: 'UNKNOWN', confidence: 0, sheetName: workbook.sheets[0]?.name ?? 'Hoja', headerRow: 1, columnMap: {}, detectedHeaders: [] }; + for (const sheet of workbook.sheets) { + const inventory = bestHeader(sheet, inventoryAliases); + const inventoryRequired = ['areaOrField', 'installation', 'equipment', 'inventoryId']; + const inventoryHits = inventoryRequired.filter((field) => inventory.map[field] !== undefined).length; + const inventoryConfidence = Math.min(100, Math.round((inventory.score / 95) * 100)); + if (inventoryHits >= 3 && inventoryConfidence > best.confidence) { + best = { profileCode: 'MENDOZA_INVENTORY_V1', confidence: inventoryConfidence, sheetName: sheet.name, headerRow: inventory.row, columnMap: inventory.map, detectedHeaders: inventory.headers }; + } + + const fields = bestHeader(sheet, fieldAliases); + const fieldRequired = ['field', 'area', 'operator']; + const fieldHits = fieldRequired.filter((field) => fields.map[field] !== undefined).length; + const fieldConfidence = Math.min(100, Math.round((fields.score / 55) * 100)); + if (fieldHits >= 2 && fieldConfidence > best.confidence) { + best = { profileCode: 'MENDOZA_YACIMIENTOS_V1', confidence: fieldConfidence, sheetName: sheet.name, headerRow: fields.row, columnMap: fields.map, detectedHeaders: fields.headers }; + } + } + return best; +} + +function text(row: string[], index: number | undefined): string { + return index === undefined ? '' : (row[index] ?? '').trim(); +} + +function asNumber(value: string): number | null { + if (!value.trim()) return null; + const normalized = value.trim().replace(/\s+/g, '').replace(',', '.'); + const number = Number(normalized); + return Number.isFinite(number) ? number : null; +} + +function inventoryStatusSuggestion(value: string): { operationalStatus?: string; conditionStatus?: string; ambiguous: boolean } { + const normalized = plain(value); + if (!normalized) return { ambiguous: false }; + if (['en servicio', 'servicio', 'operativo', 'operativa'].includes(normalized)) return { operationalStatus: 'IN_SERVICE', ambiguous: false }; + if (['fuera de servicio', 'f servicio', 'f serv', 'fs', 'f s'].includes(normalized)) return { operationalStatus: 'OUT_OF_SERVICE', ambiguous: false }; + if (['bueno', 'buena'].includes(normalized)) return { conditionStatus: 'GOOD', ambiguous: true }; + if (['regular'].includes(normalized)) return { conditionStatus: 'FAIR', ambiguous: true }; + if (['malo', 'mala'].includes(normalized)) return { conditionStatus: 'POOR', ambiguous: true }; + if (['si', 'no', 's', 'n'].includes(normalized)) return { ambiguous: true }; + return { ambiguous: true }; +} + +function splitClassType(value: string): { sourceClass: string | null; sourceSubtype: string | null } { + const classMatch = /clase\s*:\s*([^;|]+)/i.exec(value)?.[1]?.trim() ?? null; + const typeMatch = /tipo\s*:\s*([^;|]+)/i.exec(value)?.[1]?.trim() ?? null; + return { sourceClass: classMatch, sourceSubtype: typeMatch }; +} + +function splitManufacturerModel(value: string): { manufacturer: string | null; model: string | null } { + const manufacturer = /fabricante\s*[;:]\s*([^;|]+)/i.exec(value)?.[1]?.trim() ?? null; + const model = /modelo\s*[;:]\s*([^;|]+)/i.exec(value)?.[1]?.trim() ?? null; + if (manufacturer || model) return { manufacturer, model }; + const parts = value.split('|').map((part) => part.trim()).filter(Boolean); + if (parts.length >= 3) return { manufacturer: parts[1] ?? null, model: parts[2] ?? null }; + return { manufacturer: null, model: null }; +} + +export interface TechnicalNormalizationSuggestion { + family: string | null; + subtype: string | null; +} + +function technicalNormalizationSuggestion( + equipment: string, + denomination: string, + sourceClass: string | null, + sourceSubtype: string | null, +): TechnicalNormalizationSuggestion { + const source = plain(`${sourceClass ?? ''} ${sourceSubtype ?? ''} ${equipment} ${denomination}`); + const classCode = plain(sourceClass ?? ''); + const subtypeCode = plain(sourceSubtype ?? ''); + + if (/rectificador/.test(plain(equipment))) return { family: 'equipo_electrico', subtype: 'rectificador' }; + const equipmentOnly = plain(equipment); + if (/^psv$/.test(equipmentOnly)) return { family: 'valvula', subtype: 'seguridad' }; + if (/^vpsv$|^vpv$/.test(equipmentOnly)) return { family: 'valvula', subtype: 'presion_vacio' }; + if (/^aib$/.test(equipmentOnly)) return { family: 'sistema_extraccion', subtype: 'aib' }; + if (/seccionador/.test(equipmentOnly)) return { family: 'equipo_electrico', subtype: 'seccionador' }; + if (/interruptor/.test(equipmentOnly)) return { family: 'equipo_electrico', subtype: 'interruptor' }; + if (/reconectador/.test(equipmentOnly)) return { family: 'equipo_electrico', subtype: 'reconectador' }; + if (/^colector$/.test(equipmentOnly)) return { family: 'colector', subtype: null }; + if (/punto de medicion/.test(equipmentOnly)) return { family: 'instrumentacion', subtype: 'punto_medicion' }; + if (/sistema rci|^rci$/.test(equipmentOnly)) return { family: 'defensa_incendios', subtype: null }; + if (/aeroenfriador/.test(equipmentOnly)) return { family: 'aeroenfriador', subtype: null }; + if (/^bateria$/.test(equipmentOnly)) return { family: 'instalacion', subtype: 'bateria' }; + if (/^planta$/.test(equipmentOnly)) return { family: 'instalacion', subtype: 'planta' }; + if (classCode === 'bba' || /\bbomba\b/.test(source)) { + if (/\bcen\b|centrifug/.test(source)) return { family: 'bomba', subtype: 'centrifuga' }; + if (/\btx\b|triplex|quintuplex|alternativa/.test(source)) return { family: 'bomba', subtype: 'alternativa' }; + if (/tornillo/.test(source)) return { family: 'bomba', subtype: 'tornillo' }; + if (/diafragma/.test(source)) return { family: 'bomba', subtype: 'diafragma' }; + return { family: 'bomba', subtype: null }; + } + if (classCode === 'tk' || /\btanque\b|\btk\b/.test(source)) return { family: 'tanque', subtype: null }; + if (classCode === 'sep' || /separador/.test(source)) { + if (/sep b|bifasic/.test(source)) return { family: 'separador', subtype: 'bifasico' }; + if (/sep g|gas/.test(source)) return { family: 'separador', subtype: 'gas' }; + return { family: 'separador', subtype: null }; + } + if (classCode === 'cald' || /\bcaldera\b/.test(source)) return { family: 'caldera', subtype: null }; + if (classCode === 'cal' || /calentador|hot oil/.test(source)) return { family: 'calentador', subtype: null }; + if (classCode === 'ant' || /antorcha|flare/.test(source)) return { family: 'antorcha', subtype: /frio/.test(source) ? 'venteo_frio' : null }; + if (classCode === 'fil' || /\bfiltro\b/.test(source)) return { family: 'filtro', subtype: /arena/.test(source) ? 'arena' : null }; + if (classCode === 'tra' || /transformador/.test(source)) return { family: 'equipo_electrico', subtype: 'transformador' }; + if (classCode === 'moe' || /motor electr/.test(source)) return { family: 'motor', subtype: 'electrico' }; + if (classCode === 'moex' || /motor de combustion|motor a explosion/.test(source)) return { family: 'motor', subtype: 'combustion' }; + if (classCode === 'com' || /compresor|soplador/.test(source)) return { family: 'compresor', subtype: null }; + if (classCode === 'va' || /valvula/.test(source)) { + if (/vpv|presion y vacio/.test(source)) return { family: 'valvula', subtype: 'presion_vacio' }; + if (/\bvs\b|seguridad/.test(source)) return { family: 'valvula', subtype: 'seguridad' }; + if (/\bvr\b|reguladora/.test(source)) return { family: 'valvula', subtype: 'reguladora' }; + return { family: 'valvula', subtype: null }; + } + if (classCode === 'caud' || /caudalimetro/.test(source)) return { family: 'instrumentacion', subtype: 'caudalimetro' }; + if (classCode === 'eg' || /generador|motogenerador/.test(source)) return { family: 'generador', subtype: null }; + if (classCode === 'cel' || /\bcelda\b/.test(source)) return { family: 'equipo_electrico', subtype: 'celda' }; + if (classCode === 'pil' || /pileta/.test(source)) return { family: 'pileta', subtype: null }; + if (classCode === 'aib' || /aparato individual de bombeo|rotaflex/.test(source)) { + const subtype = /rotaflex/.test(source) ? 'rotaflex' : /mark ii/.test(source) ? 'mark_ii' : /convencional/.test(source) ? 'convencional' : null; + return { family: 'sistema_extraccion', subtype }; + } + if (classCode === 'pcp' || /\bpcp\b/.test(source)) return { family: 'sistema_extraccion', subtype: 'pcp' }; + if (/\bbes\b|electrosumerg/.test(source)) return { family: 'sistema_extraccion', subtype: 'bes' }; + if (/\bpozo\b/.test(source)) return { family: 'pozo', subtype: null }; + if (/oleoducto/.test(source)) return { family: 'ducto', subtype: 'oleoducto' }; + if (/gasoducto/.test(source)) return { family: 'ducto', subtype: 'gasoducto' }; + if (/acueducto/.test(source)) return { family: 'ducto', subtype: 'acueducto' }; + if (/caneria/.test(source)) return { family: 'ducto', subtype: 'caneria' }; + return { family: null, subtype: null }; +} +function rawObject(headers: string[], row: string[]): Record { + const result: Record = {}; + headers.forEach((header, index) => { + if (!header.trim() && !row[index]?.trim()) return; + result[header.trim() || `Columna ${index + 1}`] = row[index]?.trim() ?? ''; + }); + return result; +} + +function fingerprint(value: Record): string { + return createHash('sha256').update(JSON.stringify(value)).digest('hex'); +} + +function normalizeInventory(sheet: ParsedSheet, detection: ImportProfileDetection): ImportNormalizedRow[] { + const headers = sheet.rows[detection.headerRow - 1] ?? []; + const result: ImportNormalizedRow[] = []; + const map = detection.columnMap; + for (let index = detection.headerRow; index < sheet.rows.length; index += 1) { + const row = sheet.rows[index] ?? []; + if (!row.some((value) => value.trim())) continue; + const areaOrField = text(row, map.areaOrField); + const installation = text(row, map.installation); + const subInstallation = text(row, map.subInstallation); + const equipment = text(row, map.equipment); + const denomination = text(row, map.equipmentDenomination); + const location = text(row, map.location); + const inventoryId = text(row, map.inventoryId); + const quantityRaw = text(row, map.quantity); + const technicalSpecs = text(row, map.technicalSpecs); + const sourceStatus = text(row, map.sourceStatus); + const item = text(row, map.item); + const isHeaderRepeat = compact(areaOrField) === compact(headers[map.areaOrField ?? -1] ?? '') && compact(equipment) === compact(headers[map.equipment ?? -1] ?? ''); + if (isHeaderRepeat) continue; + const issues: string[] = []; + if (!areaOrField) issues.push('MISSING_AREA_OR_YACIMIENTO'); + if (!equipment && !denomination) issues.push('MISSING_EQUIPMENT_DESCRIPTION'); + if (!inventoryId) issues.push('MISSING_INVENTORY_ID'); + const quantity = asNumber(quantityRaw); + if (quantity !== null && (!Number.isInteger(quantity) || quantity <= 0)) issues.push('INVALID_QUANTITY'); + if (quantity !== null && quantity > 1) issues.push('GROUPED_QUANTITY'); + const sourceState = inventoryStatusSuggestion(sourceStatus); + if (sourceState.ambiguous && sourceStatus) issues.push('SOURCE_STATUS_REQUIRES_MAPPING'); + const classType = splitClassType(denomination); + const manufacturerModel = splitManufacturerModel(technicalSpecs); + const technical = technicalNormalizationSuggestion(equipment, denomination, classType.sourceClass, classType.sourceSubtype); + const normalized: Record = { + item: item || null, + areaOrField: areaOrField || null, + installation: installation || null, + subInstallation: subInstallation || null, + equipment: equipment || null, + sourceClassification: denomination || null, + location: location || null, + inventoryId: inventoryId || null, + quantity: quantity ?? (quantityRaw || null), + technicalSpecs: technicalSpecs || null, + sourceStatus: sourceStatus || null, + operationalStatusSuggestion: sourceState.operationalStatus ?? null, + conditionStatusSuggestion: sourceState.conditionStatus ?? null, + sourceClass: classType.sourceClass, + sourceSubtype: classType.sourceSubtype, + manufacturer: manufacturerModel.manufacturer, + model: manufacturerModel.model, + familySuggestion: technical.family, + normalizedFamily: technical.family, + normalizedSubtype: technical.subtype, + }; + const conflict = issues.includes('MISSING_EQUIPMENT_DESCRIPTION') || issues.includes('INVALID_QUANTITY'); + const status: AssetImportRowStatus = conflict ? 'CONFLICT' : issues.length ? 'WARNING' : 'READY'; + result.push({ + profileCode: 'MENDOZA_INVENTORY_V1', + rowNumber: index + 1, + sheetName: sheet.name, + raw: rawObject(headers, row), + normalized, + status, + suggestedAction: conflict ? 'REVIEW' : 'CREATE', + issues, + fingerprint: fingerprint(normalized), + }); + } + return applyBatchDuplicateRules(result); +} + +function applyBatchDuplicateRules(rows: ImportNormalizedRow[]): ImportNormalizedRow[] { + const groups = new Map(); + for (const row of rows) { + const id = String(row.normalized.inventoryId ?? '').trim().toLowerCase(); + if (!id) continue; + const area = plain(String(row.normalized.areaOrField ?? '')); + const key = `${area}|${id}`; + const current = groups.get(key) ?? []; + current.push(row); + groups.set(key, current); + } + for (const group of groups.values()) { + if (group.length < 2) continue; + const locations = new Set(group.map((row) => plain(`${row.normalized.installation ?? ''}|${row.normalized.subInstallation ?? ''}|${row.normalized.location ?? ''}`))); + const issue = locations.size > 1 ? 'INVENTORY_ID_MULTIPLE_LOCATIONS' : 'DUPLICATE_INVENTORY_ID_IN_BATCH'; + for (const row of group) { + if (!row.issues.includes(issue)) row.issues.push(issue); + if (issue === 'INVENTORY_ID_MULTIPLE_LOCATIONS') { + row.status = 'CONFLICT'; + row.suggestedAction = 'REVIEW'; + } else if (row.status === 'READY') { + row.status = 'WARNING'; + } + } + } + return rows; +} + +function normalizeFields(sheet: ParsedSheet, detection: ImportProfileDetection): ImportNormalizedRow[] { + const headers = sheet.rows[detection.headerRow - 1] ?? []; + const result: ImportNormalizedRow[] = []; + const map = detection.columnMap; + for (let index = detection.headerRow; index < sheet.rows.length; index += 1) { + const row = sheet.rows[index] ?? []; + if (!row.some((value) => value.trim())) continue; + const field = text(row, map.field); + const area = text(row, map.area); + const department = text(row, map.department); + const rightType = text(row, map.rightType); + const operator = text(row, map.operator); + const issues: string[] = []; + if (!field) issues.push('MISSING_FIELD'); + if (!area) issues.push('MISSING_AREA'); + if (!operator) issues.push('MISSING_OPERATOR'); + const normalized: Record = { + field: field || null, + area: area || null, + department: department || null, + rightType: rightType || null, + operator: operator || null, + }; + const conflict = !field || !area; + result.push({ + profileCode: 'MENDOZA_YACIMIENTOS_V1', + rowNumber: index + 1, + sheetName: sheet.name, + raw: rawObject(headers, row), + normalized, + status: conflict ? 'CONFLICT' : issues.length ? 'WARNING' : 'READY', + suggestedAction: conflict ? 'REVIEW' : 'CREATE', + issues, + fingerprint: fingerprint(normalized), + }); + } + return result; +} + +function summarize(rows: ImportNormalizedRow[]): ImportAnalysis['summary'] { + const issueCounts: Record = {}; + rows.forEach((row) => row.issues.forEach((issue) => { issueCounts[issue] = (issueCounts[issue] ?? 0) + 1; })); + return { + totalRows: rows.length, + readyRows: rows.filter((row) => row.status === 'READY').length, + warningRows: rows.filter((row) => row.status === 'WARNING').length, + conflictRows: rows.filter((row) => row.status === 'CONFLICT').length, + ignoredRows: rows.filter((row) => row.status === 'IGNORED').length, + issueCounts, + }; +} + +export function analyzeImportWorkbook(workbook: ParsedWorkbook, forcedProfile?: AssetImportProfileCode): ImportAnalysis { + const detected = detectImportProfile(workbook); + const profileCode = forcedProfile && forcedProfile !== 'UNKNOWN' ? forcedProfile : detected.profileCode; + if (profileCode === 'UNKNOWN') { + return { detection: detected, rows: [], summary: { totalRows: 0, readyRows: 0, warningRows: 0, conflictRows: 0, ignoredRows: 0, issueCounts: { PROFILE_NOT_RECOGNIZED: 1 } } }; + } + const aliases = profileCode === 'MENDOZA_YACIMIENTOS_V1' ? fieldAliases : inventoryAliases; + let selected: { sheet: ParsedSheet; header: ReturnType } | null = null; + for (const candidate of workbook.sheets) { + const header = bestHeader(candidate, aliases); + if (!selected || header.score > selected.header.score) selected = { sheet: candidate, header }; + } + if (!selected) { + return { detection: detected, rows: [], summary: { totalRows: 0, readyRows: 0, warningRows: 0, conflictRows: 0, ignoredRows: 0, issueCounts: { PROFILE_NOT_RECOGNIZED: 1 } } }; + } + const denominator = profileCode === 'MENDOZA_YACIMIENTOS_V1' ? 55 : 95; + const confidence = Math.min(100, Math.max(0, Math.round((selected.header.score / denominator) * 100))); + const detection: ImportProfileDetection = { profileCode, confidence, sheetName: selected.sheet.name, headerRow: selected.header.row, columnMap: selected.header.map, detectedHeaders: selected.header.headers }; + const rows = profileCode === 'MENDOZA_YACIMIENTOS_V1' ? normalizeFields(selected.sheet, detection) : normalizeInventory(selected.sheet, detection); + return { detection, rows, summary: summarize(rows) }; +} + +export function profileLabel(code: AssetImportProfileCode): string { + if (code === 'MENDOZA_INVENTORY_V1') return 'Inventario de instalaciones · Mendoza'; + if (code === 'MENDOZA_YACIMIENTOS_V1') return 'Tabla Área / Yacimiento · Mendoza'; + return 'Formato no reconocido'; +} diff --git a/api-v3/src/asset-imports/asset-imports.controller.ts b/api-v3/src/asset-imports/asset-imports.controller.ts new file mode 100644 index 0000000..b97549f --- /dev/null +++ b/api-v3/src/asset-imports/asset-imports.controller.ts @@ -0,0 +1,158 @@ +import { Body, Controller, Get, Param, ParseUUIDPipe, Post, Query, Req, UploadedFile, UseInterceptors } from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator'; +import { CurrentAuth } from '../auth/decorators/current-auth.decorator'; +import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; +import { AssetImportsService, MAX_ASSET_IMPORT_BYTES, type UploadedImportFile } from './asset-imports.service'; +import { ApplyAssetImportPlanDto } from './dto/apply-asset-import-plan.dto'; +import { CancelAssetImportDto } from './dto/cancel-asset-import.dto'; +import { CreateAssetImportPlanDto } from './dto/create-asset-import-plan.dto'; +import { ResolveAssetImportPlanItemDto } from './dto/resolve-asset-import-plan-item.dto'; +import { RollbackAssetImportPlanDto } from './dto/rollback-asset-import-plan.dto'; +import { ListAssetImportPlanItemsQueryDto } from './dto/list-asset-import-plan-items-query.dto'; +import { ListAssetImportReviewsQueryDto } from './dto/list-asset-import-reviews-query.dto'; +import { ListAssetImportRowsQueryDto } from './dto/list-asset-import-rows-query.dto'; +import { ListAssetImportsQueryDto } from './dto/list-asset-imports-query.dto'; +import { UploadAssetImportDto } from './dto/upload-asset-import.dto'; + +@Controller('asset-imports') +export class AssetImportsController { + constructor(private readonly imports: AssetImportsService) {} + + @Get() + @RequirePermissions('asset_imports.read') + list(@Query() query: ListAssetImportsQueryDto) { + return this.imports.list(query); + } + + @Post() + @RequirePermissions('asset_imports.manage') + @UseInterceptors(FileInterceptor('file', { limits: { fileSize: MAX_ASSET_IMPORT_BYTES, files: 1 } })) + upload( + @Body() dto: UploadAssetImportDto, + @UploadedFile() file: UploadedImportFile | undefined, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.imports.upload(dto, file, principal, request); + } + + @Get('context/organizations') + @RequirePermissions('asset_imports.read') + organizations(@Query('search') search?: string) { + return this.imports.organizationOptions(search); + } + + @Get('reviews') + @RequirePermissions('asset_imports.read') + reviews(@Query() query: ListAssetImportReviewsQueryDto) { + return this.imports.reviews(query); + } + + @Get(':id') + @RequirePermissions('asset_imports.read') + get(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) { + return this.imports.get(id); + } + + @Get(':id/plan') + @RequirePermissions('asset_imports.read') + plan(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) { + return this.imports.plan(id); + } + + @Get(':id/plan/items') + @RequirePermissions('asset_imports.read') + planItems( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @Query() query: ListAssetImportPlanItemsQueryDto, + ) { + return this.imports.planItems(id, query); + } + + @Post(':id/plan') + @RequirePermissions('asset_imports.manage') + generatePlan( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @Body() dto: CreateAssetImportPlanDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.imports.generatePlan(id, dto, principal, request); + } + + @Post(':id/plan/items/:itemId/resolve') + @RequirePermissions('asset_imports.manage') + resolvePlanItem( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @Param('itemId', new ParseUUIDPipe({ version: '4' })) itemId: string, + @Body() dto: ResolveAssetImportPlanItemDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.imports.resolvePlanItem(id, itemId, dto, principal, request); + } + + @Post(':id/apply-safe') + @RequirePermissions('asset_imports.apply') + applySafePlan( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @Body() dto: ApplyAssetImportPlanDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.imports.applySafePlan(id, dto, principal, request); + } + + @Post(':id/apply') + @RequirePermissions('asset_imports.apply') + applyPlan( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @Body() dto: ApplyAssetImportPlanDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.imports.applyPlan(id, dto, principal, request); + } + + @Post(':id/rollback') + @RequirePermissions('asset_imports.apply') + rollbackPlan( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @Body() dto: RollbackAssetImportPlanDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.imports.rollbackPlan(id, dto, principal, request); + } + + @Get(':id/rows') + @RequirePermissions('asset_imports.read') + rows( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @Query() query: ListAssetImportRowsQueryDto, + ) { + return this.imports.rows(id, query); + } + + @Post(':id/reconcile') + @RequirePermissions('asset_imports.manage') + reconcile( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.imports.reconcile(id, principal, request); + } + + @Post(':id/cancel') + @RequirePermissions('asset_imports.manage') + cancel( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @Body() dto: CancelAssetImportDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.imports.cancel(id, dto, principal, request); + } +} diff --git a/api-v3/src/asset-imports/asset-imports.module.ts b/api-v3/src/asset-imports/asset-imports.module.ts new file mode 100644 index 0000000..74c7ae1 --- /dev/null +++ b/api-v3/src/asset-imports/asset-imports.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { AuditModule } from '../audit/audit.module'; +import { AssetImportsController } from './asset-imports.controller'; +import { AssetImportsService } from './asset-imports.service'; + +@Module({ + imports: [AuditModule], + controllers: [AssetImportsController], + providers: [AssetImportsService], +}) +export class AssetImportsModule {} diff --git a/api-v3/src/asset-imports/asset-imports.service.ts b/api-v3/src/asset-imports/asset-imports.service.ts new file mode 100644 index 0000000..79ea58b --- /dev/null +++ b/api-v3/src/asset-imports/asset-imports.service.ts @@ -0,0 +1,1621 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { mkdir, unlink, writeFile } from 'node:fs/promises'; +import { isAbsolute, parse, resolve } from 'node:path'; +import { BadRequestException, ConflictException, Injectable, 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 { AuditAction, SourceDocumentType } from '../database/entities'; +import { analyzeImportWorkbook, profileLabel, type AssetImportProfileCode, type ImportNormalizedRow } from './asset-import-profiles'; +import { inspectImportFile, MAX_ASSET_IMPORT_BYTES, parseImportWorkbook, type UploadedImportFile } from './asset-import-parser'; +import type { ApplyAssetImportPlanDto } from './dto/apply-asset-import-plan.dto'; +import type { CancelAssetImportDto } from './dto/cancel-asset-import.dto'; +import type { CreateAssetImportPlanDto } from './dto/create-asset-import-plan.dto'; +import type { ResolveAssetImportPlanItemDto } from './dto/resolve-asset-import-plan-item.dto'; +import type { RollbackAssetImportPlanDto } from './dto/rollback-asset-import-plan.dto'; +import type { ListAssetImportPlanItemsQueryDto } from './dto/list-asset-import-plan-items-query.dto'; +import type { ListAssetImportReviewsQueryDto } from './dto/list-asset-import-reviews-query.dto'; +import type { ListAssetImportRowsQueryDto } from './dto/list-asset-import-rows-query.dto'; +import type { ListAssetImportsQueryDto } from './dto/list-asset-imports-query.dto'; +import type { UploadAssetImportDto } from './dto/upload-asset-import.dto'; +import { + departmentCode, + externalIdNamespace, + generatedImportCode, + isExplicitlyUnassignedOperator, + legalRightTypeLabel, + normalizedLegalRightType, + organizationImportKey, + planItemHash, + planStatusForItems, + safePlanItems, + plainImportKey, + sourceContainerName, + sourceContainerTypeCode, + sourceLocalStructureSuggestion, + summarizePlanItems, + technicalFamilyTypeCode, + type AssetImportPlanAction, + type AssetImportPlanDraftItem, + type AssetImportPlanEntityKind, + type AssetImportPlanItemStatus, +} from './asset-import-plan'; + +export { MAX_ASSET_IMPORT_BYTES, type UploadedImportFile } from './asset-import-parser'; + +export interface ImportBatchRow { + id: string; + originalName: string; + mimeType: string; + sizeBytes: number; + sha256: string; + profileCode: string; + profileConfidence: number; + worksheetName: string | null; + headerRow: number | null; + totalRows: number; + readyRows: number; + warningRows: number; + conflictRows: number; + ignoredRows: number; + status: string; + sourceDocumentId: string | null; + sourceLabel: string | null; + notes: string | null; + analysis: Record; + uploadedBy: string | null; + uploadedByUsername: string | null; + analyzedAt: Date; + createdAt: Date; + updatedAt: Date; +} + + +export interface ImportReconciliationSummary { + totalRows: number; + createRows: number; + matchRows: number; + reviewRows: number; + ignoreRows: number; + exactExternalIdMatches: number; + exactCodeMatches: number; + exactTerritoryMatches: number; + exactOrganizationMatches: number; + multipleMatches: number; + reconciledAt: string; +} + +export interface ImportPlanRow { + id: string; + batchId: string; + revision: number; + status: string; + externalIdNamespace: string | null; + operatorAssetId: string | null; + summary: Record; + planHash: string; + generatedBy: string | null; + generatedAt: Date; + appliedBy: string | null; + appliedAt: Date | null; + applicationSummary: Record | null; + rolledBackBy: string | null; + rolledBackAt: Date | null; + rollbackSummary: Record | null; + supersededAt: Date | null; + createdAt: Date; + updatedAt: Date; +} + +export interface PlanItemRow { + id: string; + planId: string; + itemOrder: number; + entityKey: string; + entityKind: AssetImportPlanEntityKind; + action: AssetImportPlanAction; + status: AssetImportPlanItemStatus; + assetTypeCode: string | null; + displayName: string; + generatedCode: string | null; + parentEntityKey: string | null; + matchedAssetId: string | null; + appliedAssetId: string | null; + appliedObjectId: string | null; + payload: Record; + sourceRowNumbers: number[]; + reviewCodes: string[]; + resolutionNote: string | null; + resolvedBy: string | null; + resolvedAt: Date | null; +} + +interface ImportSourceRow { + id: string; + rowNumber: number; + status: string; + suggestedAction: string; + normalizedData: Record; + issues: string[]; + matchedAssetId: string | null; +} + +interface AssetMatch { + id: string; + code: string; + name: string; + typeCode: string; + parentId: string | null; + operationalAreaId: string | null; + operatorCompanyId: string | null; +} + +function importNotFound(): NotFoundException { + return new NotFoundException({ code: 'ASSET_IMPORT_NOT_FOUND', message: 'Lote de importación no encontrado' }); +} + +@Injectable() +export class AssetImportsService { + private readonly storageRoot: string; + + constructor( + private readonly dataSource: DataSource, + private readonly audit: AuditService, + config: ConfigService, + ) { + const configured = config.get('ASSET_IMPORT_ROOT') ?? '/app/storage/asset-media/imports'; + if (!isAbsolute(configured)) throw new Error('ASSET_IMPORT_ROOT must be an absolute path'); + this.storageRoot = resolve(configured); + if (this.storageRoot === parse(this.storageRoot).root) throw new Error('ASSET_IMPORT_ROOT cannot be the filesystem root'); + } + + async list(query: ListAssetImportsQueryDto) { + const parameters: unknown[] = []; + const conditions: string[] = []; + if (query.status) { parameters.push(query.status); conditions.push(`batch.status = $${parameters.length}::asset_import_batch_status`); } + const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''; + const [count] = (await this.dataSource.query(`SELECT COUNT(*)::integer AS total FROM asset_import_batches batch ${where}`, parameters)) as Array<{ total: number }>; + parameters.push(query.pageSize); const limit = `$${parameters.length}`; + parameters.push((query.page - 1) * query.pageSize); const offset = `$${parameters.length}`; + const data = (await this.dataSource.query(`${this.batchSelect()} ${where} ORDER BY batch.created_at DESC LIMIT ${limit} OFFSET ${offset}`, parameters)) as ImportBatchRow[]; + const total = Number(count?.total ?? 0); + return { data, meta: { page: query.page, pageSize: query.pageSize, total, totalPages: total ? Math.ceil(total / query.pageSize) : 0 } }; + } + + async reviews(query: ListAssetImportReviewsQueryDto) { + const dependencyCodes = '["PLAN_DEPARTMENT_REVIEW_REQUIRED","PLAN_LEGAL_RIGHT_REVIEW_REQUIRED","PLAN_AREA_REVIEW_REQUIRED","PLAN_ORGANIZATION_REVIEW_REQUIRED","PLAN_TERRITORY_CONTEXT_REQUIRED","PLAN_CONTEXT_DECISION_REQUIRED","PLAN_CONTAINER_REVIEW_REQUIRED","PLAN_PARENT_NOT_RESOLVED"]'; + const params: unknown[] = []; + const conditions = ["plan.superseded_at IS NULL", "plan.status='REVIEW_REQUIRED'", "item.action='REVIEW'", "batch.status<>'CANCELLED'"]; + if (query.kind === 'DIRECT') conditions.push(`NOT (item.review_codes <@ '${dependencyCodes}'::jsonb)`); + if (query.kind === 'DEPENDENCY') conditions.push(`item.review_codes <@ '${dependencyCodes}'::jsonb`); + const where = `WHERE ${conditions.join(' AND ')}`; + const [count] = (await this.dataSource.query(` + SELECT COUNT(*)::integer AS total + FROM asset_import_plan_items item + JOIN asset_import_plans plan ON plan.id=item.plan_id + JOIN asset_import_batches batch ON batch.id=plan.batch_id + ${where} + `, params)) as Array<{ total: number }>; + params.push(query.pageSize); const limit = `$${params.length}`; + params.push((query.page - 1) * query.pageSize); const offset = `$${params.length}`; + const data = await this.dataSource.query(` + SELECT item.id,item.plan_id AS "planId",item.item_order AS "itemOrder",item.entity_key AS "entityKey",item.entity_kind AS "entityKind", + item.action,item.status,item.asset_type_code AS "assetTypeCode",item.display_name AS "displayName",item.generated_code AS "generatedCode", + item.parent_entity_key AS "parentEntityKey",item.matched_asset_id AS "matchedAssetId",item.applied_asset_id AS "appliedAssetId", + item.applied_object_id AS "appliedObjectId",item.payload,item.source_row_numbers AS "sourceRowNumbers",item.review_codes AS "reviewCodes", + item.resolution_note AS "resolutionNote",item.resolved_by AS "resolvedBy",item.resolved_at AS "resolvedAt", + batch.id AS "batchId",batch.original_name AS "batchName",batch.source_label AS "sourceLabel",batch.profile_code AS "profileCode", + plan.revision AS "planRevision",plan.status AS "planStatus", + (item.review_codes <@ '${dependencyCodes}'::jsonb) AS dependency + FROM asset_import_plan_items item + JOIN asset_import_plans plan ON plan.id=item.plan_id + JOIN asset_import_batches batch ON batch.id=plan.batch_id + ${where} + ORDER BY dependency ASC,batch.created_at DESC,item.item_order + LIMIT ${limit} OFFSET ${offset} + `, params); + const total = Number(count?.total ?? 0); + return { data, meta: { page: query.page, pageSize: query.pageSize, total, totalPages: total ? Math.ceil(total / query.pageSize) : 0 } }; + } + + async get(id: string) { + const batch = await this.loadBatch(this.dataSource.manager, id); + const topIssues = await this.dataSource.query(` + SELECT issue AS code, COUNT(*)::integer AS count + FROM asset_import_rows row, jsonb_array_elements_text(row.issue_codes) issue + WHERE row.batch_id=$1 + GROUP BY issue ORDER BY COUNT(*) DESC, issue LIMIT 20 + `, [id]); + const duplicateFiles = await this.dataSource.query(` + SELECT id, original_name AS "originalName", created_at AS "createdAt", status + FROM asset_import_batches WHERE sha256=$1 AND id<>$2 ORDER BY created_at DESC LIMIT 10 + `, [batch.sha256, id]); + return { ...batch, topIssues, duplicateFiles }; + } + + async rows(id: string, query: ListAssetImportRowsQueryDto) { + await this.loadBatch(this.dataSource.manager, id); + const parameters: unknown[] = [id]; + const conditions = ['row.batch_id=$1']; + if (query.status) { parameters.push(query.status); conditions.push(`row.status=$${parameters.length}::asset_import_row_status`); } + if (query.search?.trim()) { + parameters.push(`%${query.search.trim()}%`); + const p = `$${parameters.length}`; + conditions.push(`(row.normalized_data::text ILIKE ${p} OR row.raw_data::text ILIKE ${p})`); + } + const where = `WHERE ${conditions.join(' AND ')}`; + const [count] = (await this.dataSource.query(`SELECT COUNT(*)::integer AS total FROM asset_import_rows row ${where}`, parameters)) as Array<{ total: number }>; + parameters.push(query.pageSize); const limit = `$${parameters.length}`; + parameters.push((query.page - 1) * query.pageSize); const offset = `$${parameters.length}`; + const data = await this.dataSource.query(` + SELECT row.id,row.worksheet_name AS "worksheetName",row.row_number AS "rowNumber",row.status, + row.suggested_action AS "suggestedAction",row.raw_data AS "rawData",row.normalized_data AS "normalizedData", + row.issue_codes AS issues,row.fingerprint,row.matched_asset_id AS "matchedAssetId",row.imported_asset_id AS "importedAssetId", + row.created_at AS "createdAt",row.updated_at AS "updatedAt" + FROM asset_import_rows row ${where} + ORDER BY row.row_number LIMIT ${limit} OFFSET ${offset} + `, parameters); + const total = Number(count?.total ?? 0); + return { data, meta: { page: query.page, pageSize: query.pageSize, total, totalPages: total ? Math.ceil(total / query.pageSize) : 0 } }; + } + + async organizationOptions(search?: string): Promise<{ data: Array<{ id: string; code: string; name: string; legalName: string | null }> }> { + const parameters: unknown[] = []; + let filter = ''; + if (search?.trim()) { + parameters.push(`%${search.trim()}%`); + filter = `AND (asset.code ILIKE $1 OR asset.name ILIKE $1 OR profile.legal_name ILIKE $1)`; + } + const data = (await this.dataSource.query(` + SELECT asset.id,asset.code,asset.name,profile.legal_name AS "legalName" + FROM assets asset + JOIN asset_types type ON type.id=asset.asset_type_id + LEFT JOIN organization_profiles profile ON profile.asset_id=asset.id + WHERE type.operational_role='COMPANY' AND type.is_active=true AND asset.information_status<>'INACTIVE' + ${filter} + ORDER BY asset.name,asset.code LIMIT 500 + `, parameters)) as Array<{ id: string; code: string; name: string; legalName: string | null }>; + return { data }; + } + + async plan(id: string): Promise<(ImportPlanRow & { previewItems: PlanItemRow[]; reviewItems: PlanItemRow[]; masterStateStale: boolean; masterStateHashAvailable: boolean; masterStateCheckedAt: string }) | null> { + await this.loadBatch(this.dataSource.manager, id); + const plan = await this.loadActivePlan(this.dataSource.manager, id); + if (!plan) return null; + const previewItems = await this.loadPlanItems(this.dataSource.manager, plan.id, `ORDER BY item_order LIMIT 80`); + const reviewItems = await this.loadPlanItems(this.dataSource.manager, plan.id, `AND action='REVIEW' ORDER BY CASE WHEN review_codes <@ '["PLAN_DEPARTMENT_REVIEW_REQUIRED","PLAN_LEGAL_RIGHT_REVIEW_REQUIRED","PLAN_AREA_REVIEW_REQUIRED","PLAN_ORGANIZATION_REVIEW_REQUIRED","PLAN_TERRITORY_CONTEXT_REQUIRED","PLAN_CONTEXT_DECISION_REQUIRED","PLAN_CONTAINER_REVIEW_REQUIRED","PLAN_PARENT_NOT_RESOLVED"]'::jsonb THEN 1 ELSE 0 END, item_order LIMIT 80`); + const masterState = await this.planMasterState(this.dataSource.manager, plan); + return { ...plan, previewItems, reviewItems, ...masterState }; + } + + async planItems(id: string, query: ListAssetImportPlanItemsQueryDto) { + await this.loadBatch(this.dataSource.manager, id); + const plan = await this.loadActivePlan(this.dataSource.manager, id); + if (!plan) throw new NotFoundException({ code: 'ASSET_IMPORT_PLAN_NOT_FOUND', message: 'Plan de importación no encontrado' }); + const params: unknown[] = [plan.id]; + const conditions = ['plan_id=$1']; + if (query.entityKind) { params.push(query.entityKind); conditions.push(`entity_kind=$${params.length}`); } + if (query.action) { params.push(query.action); conditions.push(`action=$${params.length}`); } + const where = `WHERE ${conditions.join(' AND ')}`; + const [count] = (await this.dataSource.query(`SELECT COUNT(*)::integer AS total FROM asset_import_plan_items ${where}`, params)) as Array<{ total: number }>; + params.push(query.pageSize); const limit = `$${params.length}`; + params.push((query.page - 1) * query.pageSize); const offset = `$${params.length}`; + const data = (await this.dataSource.query(` + SELECT id,plan_id AS "planId",item_order AS "itemOrder",entity_key AS "entityKey",entity_kind AS "entityKind",action,status, + asset_type_code AS "assetTypeCode",display_name AS "displayName",generated_code AS "generatedCode",parent_entity_key AS "parentEntityKey", + matched_asset_id AS "matchedAssetId",applied_asset_id AS "appliedAssetId",applied_object_id AS "appliedObjectId",payload, + source_row_numbers AS "sourceRowNumbers",review_codes AS "reviewCodes",resolution_note AS "resolutionNote",resolved_by AS "resolvedBy",resolved_at AS "resolvedAt" + FROM asset_import_plan_items ${where} ORDER BY item_order LIMIT ${limit} OFFSET ${offset} + `, params)) as PlanItemRow[]; + const total = Number(count?.total ?? 0); + return { data, meta: { page: query.page, pageSize: query.pageSize, total, totalPages: total ? Math.ceil(total / query.pageSize) : 0 } }; + } + + async generatePlan(id: string, dto: CreateAssetImportPlanDto, principal: AuthPrincipal, request: RequestWithContext) { + return this.dataSource.transaction(async (manager) => { + const batch = await this.loadBatch(manager, id, true); + if (batch.status === 'CANCELLED') throw new ConflictException({ code: 'ASSET_IMPORT_CANCELLED', message: 'El lote está cancelado' }); + if (!batch.analysis?.reconciliation) throw new ConflictException({ code: 'ASSET_IMPORT_RECONCILIATION_REQUIRED', message: 'Conciliá el lote con el Maestro antes de generar el plan' }); + const current = await this.loadActivePlan(manager, id, true); + if (current?.status === 'APPLIED') throw new ConflictException({ code: 'ASSET_IMPORT_ALREADY_APPLIED', message: 'El lote ya tiene un plan aplicado. Revertí el lote antes de generar otro plan.' }); + if (current) { + await manager.query(`UPDATE asset_import_plans SET status='SUPERSEDED',superseded_at=CURRENT_TIMESTAMP,updated_at=CURRENT_TIMESTAMP WHERE id=$1`, [current.id]); + } + const [revisionRow] = (await manager.query(`SELECT COALESCE(MAX(revision),0)::integer+1 AS revision FROM asset_import_plans WHERE batch_id=$1`, [id])) as Array<{ revision: number }>; + const revision = Number(revisionRow?.revision ?? 1); + const planId = randomUUID(); + const namespace = batch.profileCode === 'MENDOZA_INVENTORY_V1' ? externalIdNamespace(dto.externalIdNamespace, batch.originalName) : null; + let operatorAssetId: string | null = null; + if (batch.profileCode === 'MENDOZA_INVENTORY_V1') { + if (!dto.operatorAssetId) throw new BadRequestException({ code: 'ASSET_IMPORT_OPERATOR_REQUIRED', message: 'Seleccioná la organización operadora para planificar este inventario' }); + await this.requireActiveOrganization(manager, dto.operatorAssetId); + operatorAssetId = dto.operatorAssetId; + } + const masterStateBefore = await this.masterStateFingerprint(manager); + const rows = await this.loadSourceRows(manager, id); + const items = batch.profileCode === 'MENDOZA_YACIMIENTOS_V1' + ? await this.buildTerritoryPlan(manager, planId, batch, rows) + : batch.profileCode === 'MENDOZA_INVENTORY_V1' + ? await this.buildInventoryPlan(manager, planId, batch, rows, operatorAssetId!, namespace!) + : [this.reviewPlanItem(planId, 'unsupported-profile', 'TECHNICAL_ASSET', 'Formato no soportado', rows.map((row) => row.rowNumber), ['PLAN_PROFILE_UNSUPPORTED'])]; + const masterStateHash = await this.masterStateFingerprint(manager); + if (masterStateBefore !== masterStateHash) throw new ConflictException({ code: 'ASSET_IMPORT_MASTER_CHANGED_DURING_PLAN', message: 'El Maestro cambió mientras se generaba el plan. Volvé a generarlo para trabajar sobre un estado consistente.' }); + const summary = { + ...summarizePlanItems(items), + masterStateHash, + sourceRows: rows.length, + sourceReadyRows: batch.readyRows, + sourceWarningRows: batch.warningRows, + sourceConflictRows: batch.conflictRows, + relationalTerritoryPlan: batch.profileCode === 'MENDOZA_YACIMIENTOS_V1', + normalizedDepartments: items.filter((item) => item.entityKind === 'DEPARTMENT').length, + normalizedLegalRightTypes: [...new Set(items.filter((item) => item.entityKind === 'LEGAL_RIGHT').map((item) => String(item.payload.rightType ?? '')).filter(Boolean))].length, + areaDepartmentRelations: items.filter((item) => item.entityKind === 'AREA_DEPARTMENT_RELATION').length, + legalRights: items.filter((item) => item.entityKind === 'LEGAL_RIGHT').length, + legalRightOrganizationRelations: items.filter((item) => item.entityKind === 'LEGAL_RIGHT_ORGANIZATION').length, + explicitUnassignedOperatorFields: items.filter((item) => item.entityKind === 'FIELD' && item.payload.operatorAssignmentStatus === 'UNASSIGNED_SOURCE').length, + externalIdNamespace: namespace, + operatorAssetId, + generatedAt: new Date().toISOString(), + }; + const status = planStatusForItems(items); + const planHash = planItemHash(items); + await manager.query(` + INSERT INTO asset_import_plans (id,batch_id,revision,status,external_id_namespace,operator_asset_id,summary,plan_hash,generated_by) + VALUES ($1,$2,$3,$4,$5,$6,$7::jsonb,$8,$9) + `, [planId,id,revision,status,namespace,operatorAssetId,JSON.stringify(summary),planHash,principal.userId]); + await this.insertPlanItems(manager, planId, items); + await manager.query(`UPDATE asset_import_batches SET analysis=jsonb_set(analysis,'{importPlan}',$2::jsonb,true),updated_at=CURRENT_TIMESTAMP WHERE id=$1`, [id, JSON.stringify({ planId, revision, status, planHash, ...summary })]); + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.ASSET_IMPORT_PLAN_GENERATED, + entityType: 'asset_import_plan', entityId: planId, + afterData: { revision, status, planHash, ...summary }, + metadata: { batchId: id, masterModified: false }, + }, manager); + return this.planView(manager, planId); + }); + } + + async resolvePlanItem(id: string, itemId: string, dto: ResolveAssetImportPlanItemDto, principal: AuthPrincipal, request: RequestWithContext) { + return this.dataSource.transaction(async (manager) => { + const plan = await this.requireMutableActivePlan(manager, id); + const [item] = (await manager.query(` + SELECT id,entity_key AS "entityKey",action,status,entity_kind AS "entityKind",asset_type_code AS "assetTypeCode",matched_asset_id AS "matchedAssetId",review_codes AS "reviewCodes",payload + FROM asset_import_plan_items WHERE id=$1 AND plan_id=$2 FOR UPDATE + `, [itemId, plan.id])) as Array<{ id: string; entityKey: string; action: AssetImportPlanAction; status: AssetImportPlanItemStatus; entityKind: AssetImportPlanEntityKind; assetTypeCode: string | null; matchedAssetId: string | null; reviewCodes: string[]; payload: Record }>; + if (!item) throw new NotFoundException({ code: 'ASSET_IMPORT_PLAN_ITEM_NOT_FOUND', message: 'Ítem del plan no encontrado' }); + if (item.action !== 'REVIEW') throw new ConflictException({ code: 'ASSET_IMPORT_PLAN_ITEM_NOT_REVIEW', message: 'Sólo se resuelven manualmente los ítems pendientes de revisión' }); + let matchedAssetId: string | null = null; + let status: AssetImportPlanItemStatus; + if (dto.action === 'MATCH') { + if (item.payload.manualMatchAllowed === false) throw new ConflictException({ code: 'ASSET_IMPORT_MATCH_CONTEXT_BLOCKED', message: 'La coincidencia encontrada contradice el contexto operativo de la fuente. Corregí el Maestro o la fuente y regenerá el plan.' }); + if (!item.assetTypeCode || item.entityKind === 'OPERATOR_RELATION') throw new ConflictException({ code: 'ASSET_IMPORT_MATCH_NOT_SUPPORTED', message: 'Este ítem no admite vinculación manual a un activo. Corregí el contexto y regenerá el plan.' }); + if (!dto.matchedAssetId) throw new BadRequestException({ code: 'ASSET_IMPORT_MATCH_REQUIRED', message: 'Seleccioná el activo del Maestro para resolver como coincidencia' }); + const [asset] = (await manager.query(`SELECT asset.id,type.code AS "typeCode" FROM assets asset JOIN asset_types type ON type.id=asset.asset_type_id WHERE asset.id=$1 AND asset.information_status<>'INACTIVE'`, [dto.matchedAssetId])) as Array<{ id: string; typeCode: string }>; + if (!asset) throw new BadRequestException({ code: 'ASSET_IMPORT_MATCH_INVALID', message: 'El activo seleccionado no existe o está inactivo' }); + if (item.assetTypeCode && asset.typeCode !== item.assetTypeCode) throw new BadRequestException({ code: 'ASSET_IMPORT_MATCH_TYPE_INVALID', message: `La coincidencia debe ser del tipo ${item.assetTypeCode}` }); + if (item.payload.inventoryContextDecision === true) { + const options = Array.isArray(item.payload.candidateOptions) ? item.payload.candidateOptions as Array> : []; + if (!options.some((option) => String(option.id ?? '') === dto.matchedAssetId)) throw new BadRequestException({ code: 'ASSET_IMPORT_CONTEXT_MATCH_INVALID', message: 'El contexto elegido no pertenece a las alternativas seguras detectadas para este inventario.' }); + const contextLookup = String(item.payload.contextLookup ?? '').trim(); + if (!contextLookup) throw new ConflictException({ code: 'ASSET_IMPORT_CONTEXT_KEY_REQUIRED', message: 'No se pudo identificar la clave territorial de esta decisión.' }); + await manager.query(` + UPDATE asset_import_batches + SET analysis=jsonb_set( + COALESCE(analysis,'{}'::jsonb), + '{inventoryContextOverrides}', + COALESCE(analysis->'inventoryContextOverrides','{}'::jsonb) || jsonb_build_object($2::text,$3::text), + true + ),updated_at=CURRENT_TIMESTAMP + WHERE id=$1 + `,[id,contextLookup,dto.matchedAssetId]); + } + matchedAssetId = dto.matchedAssetId; + status = 'MATCHED'; + } else if (dto.action === 'IGNORE') { + if (item.entityKind !== 'TECHNICAL_ASSET') throw new ConflictException({ code: 'ASSET_IMPORT_STRUCTURAL_ITEM_REQUIRED', message: 'Las entidades estructurales no se omiten aisladamente. Vinculalas o aceptá su creación para preservar la jerarquía.' }); + const [dependent] = (await manager.query(`SELECT 1 FROM asset_import_plan_items WHERE plan_id=$1 AND parent_entity_key=$2 AND action<>'IGNORE' LIMIT 1`, [plan.id, item.entityKey])) as unknown[]; + if (dependent) throw new ConflictException({ code: 'ASSET_IMPORT_STRUCTURAL_PARENT_REQUIRED', message: 'Este ítem es padre de otras operaciones del plan. Vinculalo o aceptá su creación antes de continuar.' }); + status = 'IGNORED'; + } else { + if (item.payload.manualCreateAllowed !== true || !item.assetTypeCode) throw new ConflictException({ code: 'ASSET_IMPORT_MANUAL_CREATE_BLOCKED', message: 'Este conflicto no puede resolverse creando automáticamente. Corregí el contexto o elegí una coincidencia.' }); + const inventoryId = String(item.payload.inventoryId ?? '').trim().toLowerCase(); + if (inventoryId && item.reviewCodes.includes('DUPLICATE_INVENTORY_ID_IN_BATCH')) { + const [otherDuplicate] = (await manager.query(` + SELECT id FROM asset_import_plan_items + WHERE plan_id=$1 AND id<>$2 AND entity_kind='TECHNICAL_ASSET' AND action<>'IGNORE' + AND lower(btrim(COALESCE(payload->>'inventoryId','')))=$3 + LIMIT 1 + `, [plan.id,item.id,inventoryId])) as Array<{ id: string }>; + if (otherDuplicate) throw new ConflictException({ code: 'ASSET_IMPORT_DUPLICATE_ID_UNRESOLVED', message: 'Este ID aparece en más de una fila. Primero vinculá o ignorá las otras filas duplicadas y dejá una sola candidata a creación.' }); + } + status = 'PLANNED'; + } + const generatedCode = dto.action === 'CREATE' && item.assetTypeCode ? generatedImportCode(plan.id, item.assetTypeCode, item.entityKey) : null; + await manager.query(` + UPDATE asset_import_plan_items SET action=$3,status=$4,matched_asset_id=$5,generated_code=$6,resolution_note=$7,resolved_by=$8,resolved_at=CURRENT_TIMESTAMP,updated_at=CURRENT_TIMESTAMP + WHERE id=$1 AND plan_id=$2 + `, [itemId,plan.id,dto.action,status,matchedAssetId,generatedCode,dto.reason.trim(),principal.userId]); + const updated = await this.refreshPlan(manager, plan.id); + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.ASSET_IMPORT_PLAN_ITEM_RESOLVED, + entityType: 'asset_import_plan_item', entityId: itemId, + beforeData: { action: item.action, status: item.status, reviewCodes: item.reviewCodes }, + afterData: { action: dto.action, status, matchedAssetId, reason: dto.reason.trim() }, + metadata: { batchId: id, planId: plan.id, masterModified: false }, + }, manager); + return updated; + }); + } + + + async applySafePlan(id: string, dto: ApplyAssetImportPlanDto, principal: AuthPrincipal, request: RequestWithContext) { + return this.dataSource.transaction(async (manager) => { + const batch = await this.loadBatch(manager, id, true); + if (batch.status === 'CANCELLED') throw new ConflictException({ code: 'ASSET_IMPORT_CANCELLED', message: 'El lote está cancelado' }); + const plan = await this.loadActivePlan(manager, id, true); + if (!plan) throw new ConflictException({ code: 'ASSET_IMPORT_PLAN_REQUIRED', message: 'Generá el plan de importación antes de aplicar el lote' }); + if (plan.status !== 'REVIEW_REQUIRED') throw new ConflictException({ code: 'ASSET_IMPORT_PARTIAL_NOT_REQUIRED', message: 'La aplicación parcial se utiliza cuando existen revisiones pendientes' }); + if (plan.planHash !== dto.planHash) throw new ConflictException({ code: 'ASSET_IMPORT_PLAN_STALE', message: 'El plan cambió desde la confirmación. Recargá la vista antes de aplicar.' }); + const plannedMasterStateHash = String(plan.summary?.masterStateHash ?? '').trim(); + const currentMasterStateHash = await this.masterStateFingerprint(manager); + if (!plannedMasterStateHash || plannedMasterStateHash !== currentMasterStateHash) throw new ConflictException({ code: 'ASSET_IMPORT_PLAN_STALE', message: 'El Maestro cambió desde que se generó este plan. Regenerá el plan antes de aplicar.' }); + const allItems = await this.loadPlanItems(manager, plan.id, `ORDER BY item_order FOR UPDATE`); + const safeItems = safePlanItems(allItems); + const pendingWrites = safeItems.filter((item) => item.action === 'CREATE' && item.status !== 'APPLIED'); + if (!pendingWrites.length) throw new ConflictException({ code: 'ASSET_IMPORT_NO_SAFE_ITEMS', message: 'No quedan registros independientes listos para importar. Resolvé alguna revisión para continuar.' }); + await this.revalidateMatchedAssets(manager, safeItems); + await this.revalidateMatchedRelationalObjects(manager, safeItems); + if (plan.operatorAssetId) await this.requireActiveOrganization(manager, plan.operatorAssetId); + await this.revalidateExternalIdentifiers(manager, plan, safeItems); + const result = await this.applyPlanItems(manager, batch, plan, safeItems, principal, request); + const nextMasterStateHash = await this.masterStateFingerprint(manager); + const partialAt = new Date().toISOString(); + const refreshed = await this.refreshPlan(manager, plan.id, { masterStateHash: nextMasterStateHash, lastPartialAppliedAt: partialAt }); + const progress = await this.batchApplicationProgress(manager, id); + const partialSummary = { + status: 'PARTIAL', planId: plan.id, planHash: refreshed.planHash, + appliedCreateItems: progress.appliedCreateItems, + importedRows: progress.importedRows, + pendingCreateItems: Number(refreshed.summary.pendingCreateItems ?? 0), + reviewItems: Number(refreshed.summary.reviewItems ?? 0), + directReviewItems: Number(refreshed.summary.directReviewItems ?? 0), + dependencyReviewItems: Number(refreshed.summary.dependencyReviewItems ?? 0), + lastResult: result, appliedAt: partialAt, + }; + await manager.query(`UPDATE asset_import_plans SET application_summary=$2::jsonb,updated_at=CURRENT_TIMESTAMP WHERE id=$1`, [plan.id, JSON.stringify(partialSummary)]); + await manager.query(`UPDATE asset_import_batches SET analysis=jsonb_set(analysis,'{importExecution}',$2::jsonb,true),updated_at=CURRENT_TIMESTAMP WHERE id=$1`, [id, JSON.stringify(partialSummary)]); + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.ASSET_IMPORT_PLAN_APPLIED, + entityType: 'asset_import_plan', entityId: plan.id, + afterData: partialSummary, + metadata: { batchId: id, planHash: refreshed.planHash, transactional: true, partial: true }, + }, manager); + return this.planView(manager, plan.id); + }); + } + + async applyPlan(id: string, dto: ApplyAssetImportPlanDto, principal: AuthPrincipal, request: RequestWithContext) { + return this.dataSource.transaction(async (manager) => { + const batch = await this.loadBatch(manager, id, true); + if (batch.status === 'CANCELLED') throw new ConflictException({ code: 'ASSET_IMPORT_CANCELLED', message: 'El lote está cancelado' }); + const plan = await this.loadActivePlan(manager, id, true); + if (!plan) throw new ConflictException({ code: 'ASSET_IMPORT_PLAN_REQUIRED', message: 'Generá el plan de importación antes de aplicar el lote' }); + if (plan.status === 'APPLIED') return this.planView(manager, plan.id); + if (plan.status !== 'READY') throw new ConflictException({ code: 'ASSET_IMPORT_PLAN_BLOCKED', message: 'El plan tiene revisiones pendientes o no está listo para aplicarse' }); + if (plan.planHash !== dto.planHash) throw new ConflictException({ code: 'ASSET_IMPORT_PLAN_STALE', message: 'El plan cambió desde la confirmación. Recargá la vista antes de aplicar.' }); + const plannedMasterStateHash = String(plan.summary?.masterStateHash ?? '').trim(); + const currentMasterStateHash = await this.masterStateFingerprint(manager); + if (!plannedMasterStateHash || plannedMasterStateHash !== currentMasterStateHash) throw new ConflictException({ code: 'ASSET_IMPORT_PLAN_STALE', message: 'El Maestro cambió desde que se generó este plan. Conciliá y regenerá el plan antes de aplicar para evitar duplicados.' }); + const items = await this.loadPlanItems(manager, plan.id, `ORDER BY item_order FOR UPDATE`); + if (items.some((item) => item.action === 'REVIEW')) throw new ConflictException({ code: 'ASSET_IMPORT_PLAN_BLOCKED', message: 'Existen ítems pendientes de revisión' }); + await this.revalidateMatchedAssets(manager, items); + await this.revalidateMatchedRelationalObjects(manager, items); + if (plan.operatorAssetId) await this.requireActiveOrganization(manager, plan.operatorAssetId); + await this.revalidateExternalIdentifiers(manager, plan, items); + const result = await this.applyPlanItems(manager, batch, plan, items, principal, request); + const progress = await this.batchApplicationProgress(manager, id); + const appliedAt = new Date().toISOString(); + const finalSummary = { status: 'APPLIED', planId: plan.id, planHash: plan.planHash, appliedCreateItems: progress.appliedCreateItems, importedRows: progress.importedRows, lastResult: result, appliedAt }; + await manager.query(` + UPDATE asset_import_plans SET status='APPLIED',applied_by=$2,applied_at=CURRENT_TIMESTAMP,application_summary=$3::jsonb,updated_at=CURRENT_TIMESTAMP WHERE id=$1 + `, [plan.id,principal.userId,JSON.stringify(finalSummary)]); + await manager.query(`UPDATE asset_import_batches SET analysis=jsonb_set(analysis,'{importExecution}',$2::jsonb,true),updated_at=CURRENT_TIMESTAMP WHERE id=$1`, [id, JSON.stringify(finalSummary)]); + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.ASSET_IMPORT_PLAN_APPLIED, + entityType: 'asset_import_plan', entityId: plan.id, + afterData: finalSummary, + metadata: { batchId: id, planHash: plan.planHash, transactional: true }, + }, manager); + return this.planView(manager, plan.id); + }); + } + + async rollbackPlan(id: string, dto: RollbackAssetImportPlanDto, principal: AuthPrincipal, request: RequestWithContext) { + return this.dataSource.transaction(async (manager) => { + const plan = await this.loadActivePlan(manager, id, true); + if (!plan) throw new ConflictException({ code: 'ASSET_IMPORT_PLAN_NOT_APPLIED', message: 'No existe una importación activa para revertir' }); + const items = (await manager.query(` + SELECT item.id,item.plan_id AS "planId",item.item_order AS "itemOrder",item.entity_key AS "entityKey",item.entity_kind AS "entityKind",item.action,item.status, + item.asset_type_code AS "assetTypeCode",item.display_name AS "displayName",item.generated_code AS "generatedCode",item.parent_entity_key AS "parentEntityKey", + item.matched_asset_id AS "matchedAssetId",item.applied_asset_id AS "appliedAssetId",item.applied_object_id AS "appliedObjectId",item.payload, + item.source_row_numbers AS "sourceRowNumbers",item.review_codes AS "reviewCodes",item.resolution_note AS "resolutionNote",item.resolved_by AS "resolvedBy",item.resolved_at AS "resolvedAt" + FROM asset_import_plan_items item + JOIN asset_import_plans history ON history.id=item.plan_id + WHERE history.batch_id=$1 AND item.action='CREATE' AND item.status='APPLIED' + ORDER BY history.revision DESC,item.item_order DESC + FOR UPDATE OF item + `,[id])) as PlanItemRow[]; + if (!items.length) throw new ConflictException({ code: 'ASSET_IMPORT_PLAN_NOT_APPLIED', message: 'Este lote todavía no incorporó registros al Maestro' }); + const createdAssetIds = [...new Set(items.filter((item) => item.appliedAssetId).map((item) => item.appliedAssetId!))]; + await this.assertRollbackSafe(manager, items, createdAssetIds); + const reason = dto.reason.trim(); + let endedOperatorRelations = 0; + let endedAreaDepartmentRelations = 0; + let endedLegalRightOrganizationRelations = 0; + let revokedLegalRights = 0; + let deactivatedDepartments = 0; + let endedIdentifiers = 0; + const operatorRelationIds = [...new Set(items.filter((item) => item.entityKind === 'OPERATOR_RELATION' && item.appliedObjectId).map((item) => item.appliedObjectId!))]; + const areaDepartmentRelationIds = [...new Set(items.filter((item) => item.entityKind === 'AREA_DEPARTMENT_RELATION' && item.appliedObjectId).map((item) => item.appliedObjectId!))]; + const legalRightIds = [...new Set(items.filter((item) => item.entityKind === 'LEGAL_RIGHT' && item.appliedObjectId).map((item) => item.appliedObjectId!))]; + const legalRightOrganizationIds = [...new Set(items.filter((item) => item.entityKind === 'LEGAL_RIGHT_ORGANIZATION' && item.appliedObjectId).map((item) => item.appliedObjectId!))]; + const departmentIds = [...new Set(items.filter((item) => item.entityKind === 'DEPARTMENT' && item.appliedObjectId).map((item) => item.appliedObjectId!))]; + const roleRows = createdAssetIds.length ? (await manager.query(` + SELECT asset.id,type.operational_role AS role FROM assets asset JOIN asset_types type ON type.id=asset.asset_type_id WHERE asset.id=ANY($1::uuid[]) + `, [createdAssetIds])) as Array<{ id: string; role: 'AREA' | 'COMPANY' | 'GENERIC' }> : []; + const genericIds = roleRows.filter((row) => row.role === 'GENERIC').map((row) => row.id); + const anchorIds = roleRows.filter((row) => row.role !== 'GENERIC').map((row) => row.id); + if (createdAssetIds.length) { + const result = await manager.query(`UPDATE asset_external_identifiers SET valid_until=CURRENT_TIMESTAMP,end_reason=$2,ended_by=$3,updated_at=CURRENT_TIMESTAMP WHERE asset_id=ANY($1::uuid[]) AND valid_until IS NULL RETURNING id`, [createdAssetIds,`Reversión de importación: ${reason}`,principal.userId]); + endedIdentifiers = result.length; + } + if (genericIds.length) await manager.query(`UPDATE assets SET information_status='INACTIVE',operational_area_id=NULL,operator_company_id=NULL,updated_by=$2,updated_at=CURRENT_TIMESTAMP,source_notes=concat_ws(E'\n',source_notes,$3),current_version=current_version+1 WHERE id=ANY($1::uuid[])`, [genericIds,principal.userId,`Revertido por lote ${id}: ${reason}`]); + if (legalRightOrganizationIds.length) { + const result = await manager.query(`UPDATE area_legal_right_organizations SET valid_until=CURRENT_DATE,end_reason=$2,ended_by=$3,updated_at=CURRENT_TIMESTAMP WHERE id=ANY($1::uuid[]) AND valid_until IS NULL RETURNING id`, [legalRightOrganizationIds,`Reversión de importación: ${reason}`,principal.userId]); + endedLegalRightOrganizationRelations = result.length; + } + if (legalRightIds.length) { + const result = await manager.query(`UPDATE area_legal_rights SET status='REVOKED',valid_until=COALESCE(valid_until,CURRENT_DATE),notes=concat_ws(E'\n',notes,$2),updated_by=$3,updated_at=CURRENT_TIMESTAMP WHERE id=ANY($1::uuid[]) AND status IN ('ACTIVE','PENDING') RETURNING id`, [legalRightIds,`Revertido por lote ${id}: ${reason}`,principal.userId]); + revokedLegalRights = result.length; + } + if (areaDepartmentRelationIds.length) { + const result = await manager.query(`UPDATE area_department_relations SET valid_until=CURRENT_DATE,notes=concat_ws(E'\n',notes,$2),ended_by=$3,updated_at=CURRENT_TIMESTAMP WHERE id=ANY($1::uuid[]) AND valid_until IS NULL RETURNING id`, [areaDepartmentRelationIds,`Reversión de importación: ${reason}`,principal.userId]); + endedAreaDepartmentRelations = result.length; + } + if (operatorRelationIds.length) { + const result = await manager.query(`UPDATE area_company_relations SET valid_until=CURRENT_TIMESTAMP,end_reason=$2,ended_by=$3,updated_at=CURRENT_TIMESTAMP WHERE id=ANY($1::uuid[]) AND valid_until IS NULL RETURNING id`, [operatorRelationIds,`Reversión de importación: ${reason}`,principal.userId]); + endedOperatorRelations = result.length; + } + if (departmentIds.length) { + const result = await manager.query(`UPDATE administrative_departments SET is_active=false,updated_by=$2,updated_at=CURRENT_TIMESTAMP WHERE id=ANY($1::uuid[]) AND is_active=true RETURNING id`, [departmentIds,principal.userId]); + deactivatedDepartments = result.length; + } + if (anchorIds.length) await manager.query(`UPDATE assets SET information_status='INACTIVE',updated_by=$2,updated_at=CURRENT_TIMESTAMP,source_notes=concat_ws(E'\n',source_notes,$3),current_version=current_version+1 WHERE id=ANY($1::uuid[])`, [anchorIds,principal.userId,`Revertido por lote ${id}: ${reason}`]); + if (createdAssetIds.length) await this.insertRollbackVersions(manager, createdAssetIds, principal, request); + await manager.query(`UPDATE asset_import_rows SET imported_asset_id=NULL,updated_at=CURRENT_TIMESTAMP WHERE batch_id=$1`, [id]); + const itemIds = items.map((item) => item.id); + await manager.query(`UPDATE asset_import_plan_items SET status='ROLLED_BACK',updated_at=CURRENT_TIMESTAMP WHERE id=ANY($1::uuid[])`, [itemIds]); + const historyPlanIds = [...new Set([...items.map((item) => item.planId), plan.id])]; + const summary = { assetsInactivated: createdAssetIds.length, operatorRelationsEnded: endedOperatorRelations, areaDepartmentRelationsEnded: endedAreaDepartmentRelations, legalRightOrganizationRelationsEnded: endedLegalRightOrganizationRelations, legalRightsRevoked: revokedLegalRights, departmentsDeactivated: deactivatedDepartments, identifiersEnded: endedIdentifiers, reason, rolledBackAt: new Date().toISOString() }; + await manager.query(`UPDATE asset_import_plans SET status='ROLLED_BACK',rolled_back_by=$2,rolled_back_at=CURRENT_TIMESTAMP,rollback_summary=$3::jsonb,updated_at=CURRENT_TIMESTAMP WHERE id=ANY($1::uuid[])`, [historyPlanIds,principal.userId,JSON.stringify(summary)]); + await manager.query(`UPDATE asset_import_batches SET analysis=jsonb_set(analysis,'{importExecution}',$2::jsonb,true),updated_at=CURRENT_TIMESTAMP WHERE id=$1`, [id,JSON.stringify({ status: 'ROLLED_BACK',planId:plan.id,...summary })]); + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.ASSET_IMPORT_PLAN_ROLLED_BACK, + entityType: 'asset_import_plan', entityId: plan.id, + beforeData: { status: plan.status }, afterData: { status: 'ROLLED_BACK', ...summary }, + metadata: { batchId: id, logicalRollback: true, relationalTerritory: true, planRevisions: historyPlanIds.length }, + }, manager); + return this.planView(manager, plan.id); + }); + } + + async upload(dto: UploadAssetImportDto, file: UploadedImportFile | undefined, principal: AuthPrincipal, request: RequestWithContext) { + const inspected = inspectImportFile(file); + const id = randomUUID(); + const storedName = `${id}${inspected.extension}`; + const filePath = resolve(this.storageRoot, storedName); + const sha256 = createHash('sha256').update(file!.buffer).digest('hex'); + await mkdir(this.storageRoot, { recursive: true, mode: 0o700 }); + await writeFile(filePath, file!.buffer, { flag: 'wx', mode: 0o600 }); + + try { + const workbook = await parseImportWorkbook(filePath, inspected.extension); + const analysis = analyzeImportWorkbook(workbook, dto.profileCode as AssetImportProfileCode | undefined); + const status = analysis.detection.profileCode === 'UNKNOWN' || analysis.summary.conflictRows > 0 ? 'REVIEW_REQUIRED' : 'ANALYZED'; + return await this.dataSource.transaction(async (manager) => { + const [document] = await manager.query(` + INSERT INTO source_documents (document_type,title,issuer,external_reference,notes,created_by,updated_by) + VALUES ($1,$2,$3,$4,$5,$6,$6) + RETURNING id + `, [SourceDocumentType.SPREADSHEET, `Archivo de inventario: ${file!.originalname}`, dto.sourceLabel?.trim() || null, `sha256:${sha256}`, dto.notes?.trim() || null, principal.userId]); + const sourceDocumentId = String(document.id); + await manager.query(` + INSERT INTO asset_import_batches ( + id,original_name,stored_name,mime_type,size_bytes,sha256,profile_code,profile_confidence,worksheet_name,header_row, + total_rows,ready_rows,warning_rows,conflict_rows,ignored_rows,status,source_document_id,source_label,notes,analysis,uploaded_by + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20::jsonb,$21) + `, [ + id,file!.originalname,storedName,inspected.mimeType,file!.buffer.length,sha256,analysis.detection.profileCode,analysis.detection.confidence, + analysis.detection.sheetName,analysis.detection.headerRow,analysis.summary.totalRows,analysis.summary.readyRows,analysis.summary.warningRows, + analysis.summary.conflictRows,analysis.summary.ignoredRows,status,sourceDocumentId,dto.sourceLabel?.trim() || null,dto.notes?.trim() || null, + JSON.stringify({ profileLabel: profileLabel(analysis.detection.profileCode), detectedHeaders: analysis.detection.detectedHeaders, columnMap: analysis.detection.columnMap, issueCounts: analysis.summary.issueCounts, workbookKind: workbook.kind, sheetCount: workbook.sheets.length }), + principal.userId, + ]); + await this.insertRows(manager, id, analysis.rows); + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.ASSET_IMPORT_BATCH_ANALYZED, + entityType: 'asset_import_batch', + entityId: id, + afterData: { originalName: file!.originalname, profileCode: analysis.detection.profileCode, status, ...analysis.summary }, + metadata: { sha256, sourceDocumentId }, + }, manager); + return this.loadBatch(manager, id); + }); + } catch (error) { + await unlink(filePath).catch(() => undefined); + throw error; + } + } + + async reconcile(id: string, principal: AuthPrincipal, request: RequestWithContext): Promise { + return this.dataSource.transaction(async (manager) => { + const batch = await this.loadBatch(manager, id, true); + if (batch.status === 'CANCELLED') throw new ConflictException({ code: 'ASSET_IMPORT_CANCELLED', message: 'El lote está cancelado' }); + const activePlan = await this.loadActivePlan(manager, id, true); + if (activePlan?.status === 'APPLIED') throw new ConflictException({ code: 'ASSET_IMPORT_ALREADY_APPLIED', message: 'El lote ya fue aplicado. Revertí la importación antes de volver a conciliar.' }); + if (await this.batchHasAppliedItems(manager, id)) throw new ConflictException({ code: 'ASSET_IMPORT_PARTIAL_RECONCILE_BLOCKED', message: 'El lote ya tiene registros importados parcialmente. Regenerá el plan directamente o revertí la importación antes de volver a conciliar.' }); + if (activePlan) await manager.query(`UPDATE asset_import_plans SET status='SUPERSEDED',superseded_at=CURRENT_TIMESTAMP,updated_at=CURRENT_TIMESTAMP WHERE id=$1`, [activePlan.id]); + + const rows = (await manager.query(` + SELECT id,row_number AS "rowNumber",status,suggested_action AS "suggestedAction", + normalized_data AS "normalizedData",issue_codes AS issues + FROM asset_import_rows WHERE batch_id=$1 ORDER BY row_number + `, [id])) as Array<{ + id: string; rowNumber: number; status: string; suggestedAction: string; + normalizedData: Record; issues: string[]; + }>; + + const inventoryIds = [...new Set(rows.map((row) => String(row.normalizedData.inventoryId ?? '').trim().toLowerCase()).filter(Boolean))]; + const exactByInventory = new Map>(); + let exactExternalIdMatches = 0; + let exactCodeMatches = 0; + if (inventoryIds.length) { + const external = (await manager.query(` + SELECT lower(value) AS lookup,asset_id AS "assetId" + FROM asset_external_identifiers + WHERE valid_until IS NULL AND lower(value)=ANY($1::text[]) + `, [inventoryIds])) as Array<{ lookup: string; assetId: string }>; + for (const item of external) { + const set = exactByInventory.get(item.lookup) ?? new Set(); set.add(item.assetId); exactByInventory.set(item.lookup, set); exactExternalIdMatches += 1; + } + const codes = (await manager.query(`SELECT lower(code) AS lookup,id AS "assetId" FROM assets WHERE lower(code)=ANY($1::text[])`, [inventoryIds])) as Array<{ lookup: string; assetId: string }>; + for (const item of codes) { + const set = exactByInventory.get(item.lookup) ?? new Set(); set.add(item.assetId); exactByInventory.set(item.lookup, set); exactCodeMatches += 1; + } + } + + const areaNames = [...new Set(rows.map((row) => String(row.normalizedData.area ?? '').trim().toLowerCase()).filter(Boolean))]; + const fieldNames = [...new Set(rows.map((row) => String(row.normalizedData.field ?? '').trim().toLowerCase()).filter(Boolean))]; + const territoryMatches = new Map>(); + let exactTerritoryMatches = 0; + if (areaNames.length && fieldNames.length) { + const territories = (await manager.query(` + SELECT lower(area.name) AS area,lower(field.name) AS field,field.id AS "assetId" + FROM assets field + JOIN assets area ON area.id=field.parent_id + JOIN asset_types area_type ON area_type.id=area.asset_type_id + WHERE area_type.operational_role='AREA' + AND lower(area.name)=ANY($1::text[]) AND lower(field.name)=ANY($2::text[]) + `, [areaNames, fieldNames])) as Array<{ area: string; field: string; assetId: string }>; + for (const item of territories) { + const key = `${item.area}\u001f${item.field}`; + const set = territoryMatches.get(key) ?? new Set(); set.add(item.assetId); territoryMatches.set(key, set); exactTerritoryMatches += 1; + } + } + + const operatorNames = [...new Set(rows.map((row) => String(row.normalizedData.operator ?? '').trim().toLowerCase()).filter(Boolean))]; + const organizationMatches = new Map>(); + let exactOrganizationMatches = 0; + if (operatorNames.length) { + const organizations = (await manager.query(` + SELECT lookup,"assetId" FROM ( + SELECT lower(asset.name) AS lookup,asset.id AS "assetId" + FROM assets asset JOIN asset_types type ON type.id=asset.asset_type_id + WHERE type.operational_role='COMPANY' AND lower(asset.name)=ANY($1::text[]) + UNION + SELECT lower(profile.legal_name) AS lookup,profile.asset_id AS "assetId" + FROM organization_profiles profile WHERE lower(profile.legal_name)=ANY($1::text[]) + ) candidates + `, [operatorNames])) as Array<{ lookup: string; assetId: string }>; + for (const item of organizations) { + const set = organizationMatches.get(item.lookup) ?? new Set(); + set.add(item.assetId); + organizationMatches.set(item.lookup, set); + exactOrganizationMatches += 1; + } + } + + let createRows = 0; let matchRows = 0; let reviewRows = 0; let ignoreRows = 0; let multipleMatches = 0; + const updates: Array<{ id: string; matchedAssetId: string | null; action: string; status: string; issues: string[]; reconciliation: Record }> = []; + for (const row of rows) { + const existingIssues = Array.isArray(row.issues) ? [...row.issues] : []; + let candidates = new Set(); + let matchMethod: string | null = null; + const inventoryId = String(row.normalizedData.inventoryId ?? '').trim().toLowerCase(); + if (inventoryId) { candidates = exactByInventory.get(inventoryId) ?? new Set(); if (candidates.size) matchMethod = 'INVENTORY_ID_OR_CODE_EXACT'; } + if (!candidates.size && batch.profileCode === 'MENDOZA_YACIMIENTOS_V1') { + const key = `${String(row.normalizedData.area ?? '').trim().toLowerCase()}\u001f${String(row.normalizedData.field ?? '').trim().toLowerCase()}`; + candidates = territoryMatches.get(key) ?? new Set(); if (candidates.size) matchMethod = 'AREA_FIELD_EXACT'; + } + + const operatorKey = String(row.normalizedData.operator ?? '').trim().toLowerCase(); + const operatorCandidates = operatorKey ? (organizationMatches.get(operatorKey) ?? new Set()) : new Set(); + const operatorMatchedAssetId = operatorCandidates.size === 1 ? ([...operatorCandidates][0] ?? null) : null; + if (operatorCandidates.size > 1 && !existingIssues.includes('MATCH_MULTIPLE_ORGANIZATIONS')) existingIssues.push('MATCH_MULTIPLE_ORGANIZATIONS'); + + let action = row.suggestedAction; + let status = row.status; + let matchedAssetId: string | null = null; + if (status === 'IGNORED') { action = 'IGNORE'; ignoreRows += 1; } + else if (status === 'CONFLICT' || status === 'WARNING') { + action = 'REVIEW'; reviewRows += 1; + if (candidates.size > 1) multipleMatches += 1; + } + else if (candidates.size === 1) { matchedAssetId = [...candidates][0] ?? null; action = 'MATCH'; matchRows += 1; } + else if (candidates.size > 1) { + action = 'REVIEW'; status = 'CONFLICT'; reviewRows += 1; multipleMatches += 1; + if (!existingIssues.includes('MATCH_MULTIPLE_ASSETS')) existingIssues.push('MATCH_MULTIPLE_ASSETS'); + } else if (operatorCandidates.size > 1) { action = 'REVIEW'; status = 'CONFLICT'; reviewRows += 1; multipleMatches += 1; } + else { action = 'CREATE'; createRows += 1; } + + updates.push({ id: row.id, matchedAssetId, action, status, issues: existingIssues, reconciliation: { matchMethod, candidateCount: candidates.size, matchedAssetId, operatorMatchedAssetId, operatorCandidateCount: operatorCandidates.size, operatorMatchMethod: operatorCandidates.size ? 'ORGANIZATION_NAME_EXACT' : null } }); + } + + for (let start = 0; start < updates.length; start += 250) { + const chunk = updates.slice(start, start + 250); + const params: unknown[] = []; + const values = chunk.map((item) => { + const base = params.length; + params.push(item.id,item.matchedAssetId,item.action,item.status,JSON.stringify(item.issues),JSON.stringify(item.reconciliation)); + return `($${base+1}::uuid,$${base+2}::uuid,$${base+3}::text,$${base+4}::text,$${base+5}::jsonb,$${base+6}::jsonb)`; + }); + await manager.query(` + UPDATE asset_import_rows row SET + matched_asset_id=v.matched_asset_id, + suggested_action=v.action::asset_import_suggested_action, + status=v.status::asset_import_row_status, + issue_codes=v.issues, + normalized_data=jsonb_set(row.normalized_data,'{reconciliation}',v.reconciliation,true), + updated_at=CURRENT_TIMESTAMP + FROM (VALUES ${values.join(',')}) AS v(id,matched_asset_id,action,status,issues,reconciliation) + WHERE row.id=v.id + `, params); + } + + const summary: ImportReconciliationSummary = { + totalRows: rows.length, createRows, matchRows, reviewRows, ignoreRows, + exactExternalIdMatches, exactCodeMatches, exactTerritoryMatches, exactOrganizationMatches, multipleMatches, + reconciledAt: new Date().toISOString(), + }; + await manager.query(` + UPDATE asset_import_batches SET + analysis=jsonb_set(analysis,'{reconciliation}',$2::jsonb,true), + status=CASE WHEN $3::integer>0 THEN 'REVIEW_REQUIRED'::asset_import_batch_status ELSE 'ANALYZED'::asset_import_batch_status END, + updated_at=CURRENT_TIMESTAMP + WHERE id=$1 + `, [id, JSON.stringify(summary), reviewRows]); + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.ASSET_IMPORT_BATCH_RECONCILED, + entityType: 'asset_import_batch', entityId: id, + afterData: { ...summary }, + metadata: { conservativeMatching: true, masterModified: false }, + }, manager); + return this.loadBatch(manager, id); + }); + } + + async cancel(id: string, dto: CancelAssetImportDto, principal: AuthPrincipal, request: RequestWithContext) { + return this.dataSource.transaction(async (manager) => { + const batch = await this.loadBatch(manager, id, true); + if (batch.status === 'CANCELLED') return batch; + const activePlan = await this.loadActivePlan(manager, id, true); + if (activePlan?.status === 'APPLIED' || await this.batchHasAppliedItems(manager, id)) throw new ConflictException({ code: 'ASSET_IMPORT_APPLIED_CANNOT_CANCEL', message: 'El lote ya incorporó registros al Maestro. Revertí primero la importación antes de cancelarlo.' }); + if (activePlan) await manager.query(`UPDATE asset_import_plans SET status='SUPERSEDED',superseded_at=CURRENT_TIMESTAMP,updated_at=CURRENT_TIMESTAMP WHERE id=$1`, [activePlan.id]); + await manager.query(`UPDATE asset_import_batches SET status='CANCELLED', notes=concat_ws(E'\n',notes,$2),updated_at=CURRENT_TIMESTAMP WHERE id=$1`, [id, `Cancelado: ${dto.reason.trim()}`]); + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.ASSET_IMPORT_BATCH_CANCELLED, + entityType: 'asset_import_batch', entityId: id, + beforeData: { status: batch.status }, afterData: { status: 'CANCELLED' }, metadata: { reason: dto.reason.trim() }, + }, manager); + return this.loadBatch(manager, id); + }); + } + + private async loadSourceRows(manager: EntityManager, batchId: string): Promise { + return (await manager.query(` + SELECT id,row_number AS "rowNumber",status,suggested_action AS "suggestedAction", + normalized_data AS "normalizedData",issue_codes AS issues,matched_asset_id AS "matchedAssetId" + FROM asset_import_rows WHERE batch_id=$1 ORDER BY row_number + `, [batchId])) as ImportSourceRow[]; + } + + private async requireActiveOrganization(manager: EntityManager, assetId: string): Promise { + const [row] = (await manager.query(` + SELECT 1 FROM assets asset JOIN asset_types type ON type.id=asset.asset_type_id + WHERE asset.id=$1 AND type.operational_role='COMPANY' AND type.is_active=true AND asset.information_status<>'INACTIVE' + `, [assetId])) as unknown[]; + if (!row) throw new BadRequestException({ code: 'ASSET_IMPORT_OPERATOR_INVALID', message: 'La organización operadora seleccionada no existe o está inactiva' }); + } + + private async loadActivePlan(manager: EntityManager, batchId: string, lock = false): Promise { + const rows = (await manager.query(` + SELECT id,batch_id AS "batchId",revision,status,external_id_namespace AS "externalIdNamespace",operator_asset_id AS "operatorAssetId", + summary,plan_hash AS "planHash",generated_by AS "generatedBy",generated_at AS "generatedAt",applied_by AS "appliedBy",applied_at AS "appliedAt", + application_summary AS "applicationSummary",rolled_back_by AS "rolledBackBy",rolled_back_at AS "rolledBackAt",rollback_summary AS "rollbackSummary", + superseded_at AS "supersededAt",created_at AS "createdAt",updated_at AS "updatedAt" + FROM asset_import_plans WHERE batch_id=$1 AND superseded_at IS NULL + ORDER BY revision DESC LIMIT 1${lock ? ' FOR UPDATE' : ''} + `, [batchId])) as ImportPlanRow[]; + return rows[0] ?? null; + } + + private async loadPlanById(manager: EntityManager, planId: string): Promise { + const rows = (await manager.query(` + SELECT id,batch_id AS "batchId",revision,status,external_id_namespace AS "externalIdNamespace",operator_asset_id AS "operatorAssetId", + summary,plan_hash AS "planHash",generated_by AS "generatedBy",generated_at AS "generatedAt",applied_by AS "appliedBy",applied_at AS "appliedAt", + application_summary AS "applicationSummary",rolled_back_by AS "rolledBackBy",rolled_back_at AS "rolledBackAt",rollback_summary AS "rollbackSummary", + superseded_at AS "supersededAt",created_at AS "createdAt",updated_at AS "updatedAt" + FROM asset_import_plans WHERE id=$1 + `, [planId])) as ImportPlanRow[]; + if (!rows[0]) throw new NotFoundException({ code: 'ASSET_IMPORT_PLAN_NOT_FOUND', message: 'Plan de importación no encontrado' }); + return rows[0]; + } + + private async loadPlanItems(manager: EntityManager, planId: string, suffix = 'ORDER BY item_order'): Promise { + return (await manager.query(` + SELECT id,plan_id AS "planId",item_order AS "itemOrder",entity_key AS "entityKey",entity_kind AS "entityKind",action,status, + asset_type_code AS "assetTypeCode",display_name AS "displayName",generated_code AS "generatedCode",parent_entity_key AS "parentEntityKey", + matched_asset_id AS "matchedAssetId",applied_asset_id AS "appliedAssetId",applied_object_id AS "appliedObjectId",payload, + source_row_numbers AS "sourceRowNumbers",review_codes AS "reviewCodes",resolution_note AS "resolutionNote",resolved_by AS "resolvedBy",resolved_at AS "resolvedAt" + FROM asset_import_plan_items WHERE plan_id=$1 ${suffix} + `, [planId])) as PlanItemRow[]; + } + + private async planView(manager: EntityManager, planId: string) { + const plan = await this.loadPlanById(manager, planId); + const previewItems = await this.loadPlanItems(manager, plan.id, `ORDER BY item_order LIMIT 80`); + const reviewItems = await this.loadPlanItems(manager, plan.id, `AND action='REVIEW' ORDER BY CASE WHEN review_codes <@ '["PLAN_DEPARTMENT_REVIEW_REQUIRED","PLAN_LEGAL_RIGHT_REVIEW_REQUIRED","PLAN_AREA_REVIEW_REQUIRED","PLAN_ORGANIZATION_REVIEW_REQUIRED","PLAN_TERRITORY_CONTEXT_REQUIRED","PLAN_CONTEXT_DECISION_REQUIRED","PLAN_CONTAINER_REVIEW_REQUIRED","PLAN_PARENT_NOT_RESOLVED"]'::jsonb THEN 1 ELSE 0 END, item_order LIMIT 80`); + const masterState = await this.planMasterState(manager, plan); + return { ...plan, previewItems, reviewItems, ...masterState }; + } + + private async planMasterState(manager: EntityManager, plan: ImportPlanRow): Promise<{ masterStateStale: boolean; masterStateHashAvailable: boolean; masterStateCheckedAt: string }> { + const planned = String(plan.summary?.masterStateHash ?? '').trim(); + if (!['READY','REVIEW_REQUIRED'].includes(plan.status)) return { masterStateStale: false, masterStateHashAvailable: Boolean(planned), masterStateCheckedAt: new Date().toISOString() }; + const current = await this.masterStateFingerprint(manager); + return { masterStateStale: !planned || planned !== current, masterStateHashAvailable: Boolean(planned), masterStateCheckedAt: new Date().toISOString() }; + } + + private async masterStateFingerprint(manager: EntityManager): Promise { + const [state] = (await manager.query(` + SELECT + (SELECT COUNT(*)::text FROM assets WHERE information_status<>'INACTIVE') AS "assetsCount", + COALESCE((SELECT MAX(updated_at)::text FROM assets),'') AS "assetsUpdatedAt", + (SELECT COUNT(*)::text FROM organization_profiles) AS "organizationsCount", + COALESCE((SELECT MAX(updated_at)::text FROM organization_profiles),'') AS "organizationsUpdatedAt", + (SELECT COUNT(*)::text FROM administrative_departments WHERE is_active=true) AS "departmentsCount", + COALESCE((SELECT MAX(updated_at)::text FROM administrative_departments),'') AS "departmentsUpdatedAt", + (SELECT COUNT(*)::text FROM area_department_relations WHERE valid_until IS NULL) AS "areaDepartmentsCount", + COALESCE((SELECT MAX(updated_at)::text FROM area_department_relations),'') AS "areaDepartmentsUpdatedAt", + (SELECT COUNT(*)::text FROM area_company_relations WHERE valid_until IS NULL) AS "areaOrganizationsCount", + COALESCE((SELECT MAX(updated_at)::text FROM area_company_relations),'') AS "areaOrganizationsUpdatedAt", + (SELECT COUNT(*)::text FROM area_legal_rights WHERE status IN ('ACTIVE','PENDING')) AS "legalRightsCount", + COALESCE((SELECT MAX(updated_at)::text FROM area_legal_rights),'') AS "legalRightsUpdatedAt", + (SELECT COUNT(*)::text FROM area_legal_right_organizations WHERE valid_until IS NULL) AS "legalRightOrganizationsCount", + COALESCE((SELECT MAX(updated_at)::text FROM area_legal_right_organizations),'') AS "legalRightOrganizationsUpdatedAt", + (SELECT COUNT(*)::text FROM asset_external_identifiers WHERE valid_until IS NULL) AS "externalIdentifiersCount", + COALESCE((SELECT MAX(updated_at)::text FROM asset_external_identifiers),'') AS "externalIdentifiersUpdatedAt" + `)) as Array>; + return createHash('sha256').update(JSON.stringify(state ?? {})).digest('hex'); + } + + private async batchApplicationProgress(manager: EntityManager, batchId: string): Promise<{ appliedCreateItems: number; importedRows: number }> { + const [row] = (await manager.query(` + SELECT + (SELECT COUNT(*)::integer FROM asset_import_plan_items item JOIN asset_import_plans plan ON plan.id=item.plan_id WHERE plan.batch_id=$1 AND item.action='CREATE' AND item.status='APPLIED') AS "appliedCreateItems", + (SELECT COUNT(*)::integer FROM asset_import_rows WHERE batch_id=$1 AND imported_asset_id IS NOT NULL) AS "importedRows" + `,[batchId])) as Array<{ appliedCreateItems: number; importedRows: number }>; + return { appliedCreateItems: Number(row?.appliedCreateItems ?? 0), importedRows: Number(row?.importedRows ?? 0) }; + } + + private async batchHasAppliedItems(manager: EntityManager, batchId: string): Promise { + const progress = await this.batchApplicationProgress(manager, batchId); + return progress.appliedCreateItems > 0; + } + + private async requireMutableActivePlan(manager: EntityManager, batchId: string): Promise { + const plan = await this.loadActivePlan(manager, batchId, true); + if (!plan) throw new ConflictException({ code: 'ASSET_IMPORT_PLAN_REQUIRED', message: 'Generá primero el plan de importación' }); + if (!['READY','REVIEW_REQUIRED'].includes(plan.status)) throw new ConflictException({ code: 'ASSET_IMPORT_PLAN_LOCKED', message: 'El plan ya no admite resoluciones manuales' }); + return plan; + } + + private reviewPlanItem( + planId: string, + entityKey: string, + entityKind: AssetImportPlanEntityKind, + displayName: string, + sourceRowNumbers: number[], + reviewCodes: string[], + payload: Record = {}, + ): AssetImportPlanDraftItem { + return { + entityKey,entityKind,action:'REVIEW',status:'REVIEW',assetTypeCode:null,displayName,generatedCode:null,parentEntityKey:null,matchedAssetId:null, + payload: { ...payload, manualCreateAllowed: false, planId }, + sourceRowNumbers:[...new Set(sourceRowNumbers)].sort((a,b)=>a-b),reviewCodes:[...new Set(reviewCodes)], + }; + } + + private async buildTerritoryPlan(manager: EntityManager, planId: string, batch: ImportBatchRow, rows: ImportSourceRow[]): Promise { + type Aggregate = { name: string; rows: number[]; issues: Set; values: Set }; + type FieldAggregate = Aggregate & { + areaKey: string; + departmentKeys: Set; + rightKeys: Set; + operatorKeys: Set; + operatorUnassigned: boolean; + operatorSourceValues: Set; + }; + const departments = new Map(); + const organizations = new Map(); + const areas = new Map(); + const fields = new Map(); + const areaDepartments = new Map }>(); + const operatorRelations = new Map; rightTypes: Set }>(); + const legalRights = new Map; rows: number[]; issues: Set }>(); + const legalRightOrganizations = new Map }>(); + + const add = (map: Map, key: string, name: string, row: ImportSourceRow, value?: string) => { + const current = map.get(key) ?? { name, rows: [], issues: new Set(), values: new Set() }; + current.rows.push(row.rowNumber); + for (const issue of row.issues ?? []) current.issues.add(issue); + if (row.status !== 'READY') current.issues.add(`SOURCE_ROW_${row.status}`); + if (value) current.values.add(value); + map.set(key, current); + }; + const addIssues = (target: Set, row: ImportSourceRow) => { + for (const issue of row.issues ?? []) target.add(issue); + if (row.status !== 'READY') target.add(`SOURCE_ROW_${row.status}`); + }; + + for (const row of rows) { + const area = String(row.normalizedData.area ?? '').trim(); + const field = String(row.normalizedData.field ?? '').trim(); + const operator = String(row.normalizedData.operator ?? '').trim(); + const department = String(row.normalizedData.department ?? '').trim(); + const sourceRightType = String(row.normalizedData.rightType ?? '').trim(); + const areaKey = plainImportKey(area); + const fieldKeyPart = plainImportKey(field); + const departmentKey = plainImportKey(department); + const operatorUnassigned = isExplicitlyUnassignedOperator(operator); + const operatorKey = operatorUnassigned ? '' : organizationImportKey(operator); + const normalizedRight = normalizedLegalRightType(sourceRightType); + const rightKey = normalizedRight ? `${areaKey}\u001f${normalizedRight}` : ''; + + if (departmentKey) add(departments, departmentKey, department, row); + if (areaKey) add(areas, areaKey, area, row); + if (operatorKey) add(organizations, operatorKey, operator, row); + + if (areaKey && departmentKey) { + const key = `${areaKey}\u001f${departmentKey}`; + const current = areaDepartments.get(key) ?? { areaKey, departmentKey, rows: [], issues: new Set() }; + current.rows.push(row.rowNumber); addIssues(current.issues, row); areaDepartments.set(key, current); + } + + if (areaKey && normalizedRight) { + const current = legalRights.get(rightKey) ?? { areaKey, rightType: normalizedRight, sourceRightTypes: new Set(), rows: [], issues: new Set() }; + current.rows.push(row.rowNumber); addIssues(current.issues, row); if (sourceRightType) current.sourceRightTypes.add(sourceRightType); + if (normalizedRight === 'OTHER') current.issues.add('PLAN_LEGAL_RIGHT_TYPE_UNRECOGNIZED'); + legalRights.set(rightKey, current); + } else if (areaKey) { + const syntheticKey = `${areaKey}\u001fmissing`; + const current = legalRights.get(syntheticKey) ?? { areaKey, rightType: 'OTHER', sourceRightTypes: new Set(), rows: [], issues: new Set() }; + current.rows.push(row.rowNumber); addIssues(current.issues, row); current.issues.add('PLAN_LEGAL_RIGHT_TYPE_MISSING'); + legalRights.set(syntheticKey, current); + } + + if (areaKey && fieldKeyPart) { + const key = `${areaKey}\u001f${fieldKeyPart}`; + const current = fields.get(key) ?? { + name: field, areaKey, rows: [], issues: new Set(), values: new Set(), departmentKeys: new Set(), rightKeys: new Set(), operatorKeys: new Set(), operatorUnassigned: false, operatorSourceValues: new Set(), + }; + current.rows.push(row.rowNumber); addIssues(current.issues, row); + if (departmentKey) current.departmentKeys.add(departmentKey); + if (rightKey) current.rightKeys.add(rightKey); + if (operatorKey) current.operatorKeys.add(operatorKey); + if (operator) current.operatorSourceValues.add(operator); + if (operatorUnassigned) current.operatorUnassigned = true; + fields.set(key, current); + } + + if (areaKey && operatorKey) { + const key = `${areaKey}\u001f${operatorKey}`; + const current = operatorRelations.get(key) ?? { areaKey, organizationKey: operatorKey, rows: [], issues: new Set(), rightTypes: new Set() }; + current.rows.push(row.rowNumber); addIssues(current.issues, row); if (sourceRightType) current.rightTypes.add(sourceRightType); operatorRelations.set(key, current); + if (rightKey) { + const legalOrgKey = `${rightKey}\u001f${operatorKey}`; + const legalOrg = legalRightOrganizations.get(legalOrgKey) ?? { legalRightKey: rightKey, organizationKey: operatorKey, rows: [], issues: new Set() }; + legalOrg.rows.push(row.rowNumber); addIssues(legalOrg.issues, row); legalRightOrganizations.set(legalOrgKey, legalOrg); + } + } + } + + const areaNames = [...areas.keys()]; + const orgNames = [...organizations.keys()]; + const fieldNames = [...new Set([...fields.values()].map((item) => plainImportKey(item.name)))]; + const existingAreas = await this.assetNameMatches(manager, 'AREA', areaNames); + const existingOrganizations = await this.organizationNameMatches(manager, orgNames); + + const existingDepartments = new Map(); + if (departments.size) { + const found = (await manager.query(`SELECT id,name,code,normalized_name AS "normalizedName" FROM administrative_departments WHERE province_code='MENDOZA' AND is_active=true AND normalized_name=ANY($1::text[])`, [[...departments.keys()]])) as Array<{id:string;name:string;code:string;normalizedName:string}>; + for (const item of found) { const list = existingDepartments.get(item.normalizedName) ?? []; list.push(item); existingDepartments.set(item.normalizedName, list); } + } + + const existingFields = new Map(); + if (areaNames.length && fieldNames.length) { + const wanted = new Set(fields.keys()); + const result = (await manager.query(` + SELECT field.id,field.code,field.name,type.code AS "typeCode",field.parent_id AS "parentId",field.operational_area_id AS "operationalAreaId",field.operator_company_id AS "operatorCompanyId",area.name AS "areaName" + FROM assets field JOIN asset_types type ON type.id=field.asset_type_id JOIN assets area ON area.id=field.parent_id + WHERE type.code='yacimiento' AND field.information_status<>'INACTIVE' AND area.information_status<>'INACTIVE' + `)) as Array; + for (const item of result) { const key = `${plainImportKey(item.areaName)}\u001f${plainImportKey(item.name)}`; if (!wanted.has(key)) continue; const list = existingFields.get(key) ?? []; list.push(item); existingFields.set(key, list); } + } + + const items: AssetImportPlanDraftItem[] = []; + const departmentItemByKey = new Map(); + const areaItemByKey = new Map(); + const orgItemByKey = new Map(); + const legalRightItemByKey = new Map(); + + for (const [key, aggregate] of [...departments.entries()].sort(([a],[b]) => a.localeCompare(b))) { + const matches = existingDepartments.get(key) ?? []; + const reviews = [...aggregate.issues]; if (matches.length > 1) reviews.push('PLAN_MULTIPLE_DEPARTMENTS'); + const action: AssetImportPlanAction = reviews.length ? 'REVIEW' : matches.length === 1 ? 'MATCH' : 'CREATE'; + const entityKey = `department:${key}`; + const item: AssetImportPlanDraftItem = { + entityKey, entityKind: 'DEPARTMENT', action, status: action === 'REVIEW' ? 'REVIEW' : action === 'MATCH' ? 'MATCHED' : 'PLANNED', assetTypeCode: null, + displayName: aggregate.name, generatedCode: null, parentEntityKey: null, matchedAssetId: null, + payload: { provinceCode: 'MENDOZA', departmentCode: departmentCode(aggregate.name), normalizedName: key, matchedDepartmentId: matches.length === 1 ? matches[0]!.id : null, sourceDocumentId: batch.sourceDocumentId, manualCreateAllowed: matches.length <= 1 }, + sourceRowNumbers: [...new Set(aggregate.rows)].sort((a,b)=>a-b), reviewCodes: [...new Set(reviews)], + }; + items.push(item); departmentItemByKey.set(key, item); + } + + for (const [key, aggregate] of [...organizations.entries()].sort(([a],[b]) => a.localeCompare(b))) { + const matches = existingOrganizations.get(key) ?? []; + const reviews = [...aggregate.issues]; if (matches.length > 1) reviews.push('PLAN_MULTIPLE_ORGANIZATIONS'); + const action: AssetImportPlanAction = reviews.length ? 'REVIEW' : matches.length === 1 ? 'MATCH' : 'CREATE'; + const entityKey = `organization:${key}`; + const item: AssetImportPlanDraftItem = { entityKey, entityKind:'ORGANIZATION', action, status:action==='REVIEW'?'REVIEW':action==='MATCH'?'MATCHED':'PLANNED', assetTypeCode:'empresa', displayName:aggregate.name, generatedCode:action==='CREATE'?generatedImportCode(planId,'empresa',entityKey):null, parentEntityKey:null, matchedAssetId:matches.length===1?matches[0]!.id:null, payload:{legalName:aggregate.name,organizationKind:/^UTE\b/i.test(aggregate.name.trim())?'UTE':'COMPANY',sourceDocumentId:batch.sourceDocumentId,manualCreateAllowed:matches.length<=1}, sourceRowNumbers:[...new Set(aggregate.rows)].sort((a,b)=>a-b), reviewCodes:[...new Set(reviews)] }; + items.push(item); orgItemByKey.set(key,item); + } + + for (const [key, aggregate] of [...areas.entries()].sort(([a],[b]) => a.localeCompare(b))) { + const matches = existingAreas.get(key) ?? []; + const reviews = [...aggregate.issues]; if (matches.length > 1) reviews.push('PLAN_MULTIPLE_AREAS'); + const action: AssetImportPlanAction = reviews.length ? 'REVIEW' : matches.length === 1 ? 'MATCH' : 'CREATE'; + const entityKey = `area:${key}`; + const item: AssetImportPlanDraftItem = { entityKey,entityKind:'AREA',action,status:action==='REVIEW'?'REVIEW':action==='MATCH'?'MATCHED':'PLANNED',assetTypeCode:'area',displayName:aggregate.name,generatedCode:action==='CREATE'?generatedImportCode(planId,'area',entityKey):null,parentEntityKey:null,matchedAssetId:matches.length===1?matches[0]!.id:null,payload:{sourceDocumentId:batch.sourceDocumentId,manualCreateAllowed:matches.length<=1},sourceRowNumbers:[...new Set(aggregate.rows)].sort((a,b)=>a-b),reviewCodes:[...new Set(reviews)] }; + items.push(item); areaItemByKey.set(key,item); + } + + const matchedAreaIds = [...areaItemByKey.values()].map(i=>i.matchedAssetId).filter((v):v is string=>Boolean(v)); + const matchedOrgIds = [...orgItemByKey.values()].map(i=>i.matchedAssetId).filter((v):v is string=>Boolean(v)); + const matchedDepartmentIds = [...departmentItemByKey.values()].map(item=>String(item.payload.matchedDepartmentId??'')).filter(Boolean); + + const existingAreaDepartmentPairs = new Map(); + if (matchedAreaIds.length && matchedDepartmentIds.length) { + const found = (await manager.query(`SELECT id,area_id AS "areaId",department_id AS "departmentId" FROM area_department_relations WHERE area_id=ANY($1::uuid[]) AND department_id=ANY($2::uuid[]) AND valid_until IS NULL`, [matchedAreaIds, matchedDepartmentIds])) as Array<{id:string;areaId:string;departmentId:string}>; + for (const relation of found) existingAreaDepartmentPairs.set(`${relation.areaId}\u001f${relation.departmentId}`, relation.id); + } + + for (const [key, aggregate] of [...areaDepartments.entries()].sort(([a],[b])=>a.localeCompare(b))) { + const areaItem = areaItemByKey.get(aggregate.areaKey); const departmentItem = departmentItemByKey.get(aggregate.departmentKey); if (!areaItem || !departmentItem) continue; + const reviews = [...aggregate.issues]; if (areaItem.action==='REVIEW') reviews.push('PLAN_AREA_REVIEW_REQUIRED'); if (departmentItem.action==='REVIEW') reviews.push('PLAN_DEPARTMENT_REVIEW_REQUIRED'); + const departmentId = String(departmentItem.payload.matchedDepartmentId ?? '') || null; + const relationId = areaItem.matchedAssetId && departmentId ? existingAreaDepartmentPairs.get(`${areaItem.matchedAssetId}\u001f${departmentId}`) ?? null : null; + const action: AssetImportPlanAction = reviews.length ? 'REVIEW' : relationId ? 'MATCH' : 'CREATE'; + items.push({ entityKey:`area-department:${key}`, entityKind:'AREA_DEPARTMENT_RELATION', action, status:action==='REVIEW'?'REVIEW':action==='MATCH'?'MATCHED':'PLANNED', assetTypeCode:null, displayName:`${areaItem.displayName} ↔ ${departmentItem.displayName}`, generatedCode:null, parentEntityKey:null, matchedAssetId:null, payload:{areaEntityKey:areaItem.entityKey,departmentEntityKey:departmentItem.entityKey,matchedRelationId:relationId,sourceDocumentId:batch.sourceDocumentId,manualCreateAllowed:reviews.length===0}, sourceRowNumbers:[...new Set(aggregate.rows)].sort((a,b)=>a-b), reviewCodes:[...new Set(reviews)] }); + } + + const existingOperatorPairs = new Map(); + if (matchedAreaIds.length && matchedOrgIds.length) { + const found = (await manager.query(`SELECT id,area_id AS "areaId",company_id AS "companyId" FROM area_company_relations WHERE area_id=ANY($1::uuid[]) AND company_id=ANY($2::uuid[]) AND relation_role='OPERATOR' AND valid_until IS NULL`,[matchedAreaIds,matchedOrgIds])) as Array<{id:string;areaId:string;companyId:string}>; + for(const relation of found) existingOperatorPairs.set(`${relation.areaId}\u001f${relation.companyId}`,relation.id); + } + for (const [key, aggregate] of [...operatorRelations.entries()].sort(([a],[b])=>a.localeCompare(b))) { + const areaItem=areaItemByKey.get(aggregate.areaKey); const orgItem=orgItemByKey.get(aggregate.organizationKey); if(!areaItem||!orgItem) continue; + const reviews=[...aggregate.issues]; if(areaItem.action==='REVIEW') reviews.push('PLAN_AREA_REVIEW_REQUIRED'); if(orgItem.action==='REVIEW') reviews.push('PLAN_ORGANIZATION_REVIEW_REQUIRED'); + const relationId=areaItem.matchedAssetId&&orgItem.matchedAssetId?existingOperatorPairs.get(`${areaItem.matchedAssetId}\u001f${orgItem.matchedAssetId}`)??null:null; + const action:AssetImportPlanAction=reviews.length?'REVIEW':relationId?'MATCH':'CREATE'; + items.push({entityKey:`operator-relation:${key}`,entityKind:'OPERATOR_RELATION',action,status:action==='REVIEW'?'REVIEW':action==='MATCH'?'MATCHED':'PLANNED',assetTypeCode:null,displayName:`${areaItem.displayName} ↔ ${orgItem.displayName}`,generatedCode:null,parentEntityKey:null,matchedAssetId:null,payload:{areaEntityKey:areaItem.entityKey,organizationEntityKey:orgItem.entityKey,matchedRelationId:relationId,rightTypes:[...aggregate.rightTypes].sort(),sourceDocumentId:batch.sourceDocumentId,manualCreateAllowed:reviews.length===0},sourceRowNumbers:[...new Set(aggregate.rows)].sort((a,b)=>a-b),reviewCodes:[...new Set(reviews)]}); + } + + const existingLegalRights = new Map(); + if (matchedAreaIds.length) { + const found = (await manager.query(`SELECT id,area_id AS "areaId",right_type AS "rightType" FROM area_legal_rights WHERE area_id=ANY($1::uuid[]) AND status IN ('ACTIVE','PENDING') AND (valid_until IS NULL OR valid_until>=CURRENT_DATE)`, [matchedAreaIds])) as Array<{id:string;areaId:string;rightType:string}>; + for (const right of found) { const key=`${right.areaId}\u001f${right.rightType}`; const list=existingLegalRights.get(key)??[]; list.push(right); existingLegalRights.set(key,list); } + } + for (const [key, aggregate] of [...legalRights.entries()].sort(([a],[b])=>a.localeCompare(b))) { + const areaItem=areaItemByKey.get(aggregate.areaKey); if(!areaItem) continue; + const reviews=[...aggregate.issues]; if(areaItem.action==='REVIEW') reviews.push('PLAN_AREA_REVIEW_REQUIRED'); + const matches=areaItem.matchedAssetId ? (existingLegalRights.get(`${areaItem.matchedAssetId}\u001f${aggregate.rightType}`)??[]) : []; + if(matches.length>1) reviews.push('PLAN_MULTIPLE_LEGAL_RIGHTS'); + const action:AssetImportPlanAction=reviews.length?'REVIEW':matches.length===1?'MATCH':'CREATE'; + const entityKey=`legal-right:${key}`; + const sourceLabels=[...aggregate.sourceRightTypes].sort(); + const displayName=`${legalRightTypeLabel(aggregate.rightType as 'EXPLOITATION_CONCESSION' | 'EXPLORATION_PERMIT' | 'TRANSPORT_CONCESSION' | 'OTHER')} · ${areaItem.displayName}`; + const item:AssetImportPlanDraftItem={entityKey,entityKind:'LEGAL_RIGHT',action,status:action==='REVIEW'?'REVIEW':action==='MATCH'?'MATCHED':'PLANNED',assetTypeCode:null,displayName,generatedCode:null,parentEntityKey:null,matchedAssetId:null,payload:{areaEntityKey:areaItem.entityKey,rightType:aggregate.rightType,sourceRightTypes:sourceLabels,legalRightName:displayName,status:'PENDING',matchedLegalRightId:matches.length===1?matches[0]!.id:null,sourceDocumentId:batch.sourceDocumentId,manualCreateAllowed:reviews.length===0},sourceRowNumbers:[...new Set(aggregate.rows)].sort((a,b)=>a-b),reviewCodes:[...new Set(reviews)]}; + items.push(item); legalRightItemByKey.set(key,item); + } + + const matchedLegalRightIds=[...legalRightItemByKey.values()].map(item=>String(item.payload.matchedLegalRightId??'')).filter(Boolean); + const existingLegalOrgPairs=new Map(); + if(matchedLegalRightIds.length&&matchedOrgIds.length){const found=(await manager.query(`SELECT id,right_id AS "rightId",organization_id AS "organizationId" FROM area_legal_right_organizations WHERE right_id=ANY($1::uuid[]) AND organization_id=ANY($2::uuid[]) AND role='OPERATOR' AND valid_until IS NULL`,[matchedLegalRightIds,matchedOrgIds])) as Array<{id:string;rightId:string;organizationId:string}>;for(const row of found)existingLegalOrgPairs.set(`${row.rightId}\u001f${row.organizationId}`,row.id);} + for(const [key, aggregate] of [...legalRightOrganizations.entries()].sort(([a],[b])=>a.localeCompare(b))){const rightItem=legalRightItemByKey.get(aggregate.legalRightKey);const orgItem=orgItemByKey.get(aggregate.organizationKey);if(!rightItem||!orgItem)continue;const reviews=[...aggregate.issues];if(rightItem.action==='REVIEW')reviews.push('PLAN_LEGAL_RIGHT_REVIEW_REQUIRED');if(orgItem.action==='REVIEW')reviews.push('PLAN_ORGANIZATION_REVIEW_REQUIRED');const rightId=String(rightItem.payload.matchedLegalRightId??'')||null;const relationId=rightId&&orgItem.matchedAssetId?existingLegalOrgPairs.get(`${rightId}\u001f${orgItem.matchedAssetId}`)??null:null;const action:AssetImportPlanAction=reviews.length?'REVIEW':relationId?'MATCH':'CREATE';items.push({entityKey:`legal-right-organization:${key}`,entityKind:'LEGAL_RIGHT_ORGANIZATION',action,status:action==='REVIEW'?'REVIEW':action==='MATCH'?'MATCHED':'PLANNED',assetTypeCode:null,displayName:`${rightItem.displayName} ↔ ${orgItem.displayName}`,generatedCode:null,parentEntityKey:null,matchedAssetId:null,payload:{legalRightEntityKey:rightItem.entityKey,organizationEntityKey:orgItem.entityKey,role:'OPERATOR',matchedRelationId:relationId,sourceDocumentId:batch.sourceDocumentId,manualCreateAllowed:reviews.length===0},sourceRowNumbers:[...new Set(aggregate.rows)].sort((a,b)=>a-b),reviewCodes:[...new Set(reviews)]});} + + for (const [key,aggregate] of [...fields.entries()].sort(([a],[b])=>a.localeCompare(b))) { + const areaItem=areaItemByKey.get(aggregate.areaKey); if(!areaItem) continue; + const fieldCandidates=areaItem.action==='MATCH'?(existingFields.get(key)??[]):[]; + const reviews=[...aggregate.issues]; if(areaItem.action==='REVIEW') reviews.push('PLAN_AREA_REVIEW_REQUIRED'); + const departmentItems=[...aggregate.departmentKeys].map(k=>departmentItemByKey.get(k)).filter((v):v is AssetImportPlanDraftItem=>Boolean(v)); + if(departmentItems.some(item=>item.action==='REVIEW')) reviews.push('PLAN_DEPARTMENT_REVIEW_REQUIRED'); + const rightItems=[...aggregate.rightKeys].map(k=>legalRightItemByKey.get(k)).filter((v):v is AssetImportPlanDraftItem=>Boolean(v)); + if(rightItems.some(item=>item.action==='REVIEW')) reviews.push('PLAN_LEGAL_RIGHT_REVIEW_REQUIRED'); + const operatorItems=[...aggregate.operatorKeys].map(operatorKey=>orgItemByKey.get(operatorKey)).filter((value):value is AssetImportPlanDraftItem=>Boolean(value)); + const uniqueOperatorItems=[...new Map(operatorItems.map(item=>[item.entityKey,item])).values()]; + const explicitlyUnassigned=aggregate.operatorUnassigned&&uniqueOperatorItems.length===0; + if(uniqueOperatorItems.length>1||(aggregate.operatorUnassigned&&uniqueOperatorItems.length>0))reviews.push('PLAN_FIELD_OPERATOR_AMBIGUOUS'); + if(uniqueOperatorItems.length===0&&!explicitlyUnassigned)reviews.push('PLAN_FIELD_OPERATOR_MISSING'); + if(uniqueOperatorItems[0]?.action==='REVIEW')reviews.push('PLAN_ORGANIZATION_REVIEW_REQUIRED'); + let matches=fieldCandidates; + if(fieldCandidates.length){if(explicitlyUnassigned)matches=fieldCandidates.filter(candidate=>!candidate.operatorCompanyId);else if(uniqueOperatorItems.length===1&&uniqueOperatorItems[0]!.action==='MATCH'&&uniqueOperatorItems[0]!.matchedAssetId)matches=fieldCandidates.filter(candidate=>candidate.operatorCompanyId===uniqueOperatorItems[0]!.matchedAssetId);else matches=[];if(matches.length===0)reviews.push('PLAN_FIELD_OPERATOR_CONTEXT_MISMATCH');} + if(matches.length>1)reviews.push('PLAN_MULTIPLE_FIELDS'); + const action:AssetImportPlanAction=reviews.length?'REVIEW':matches.length===1?'MATCH':'CREATE'; + const entityKey=`field:${key}`;const contextMismatch=reviews.includes('PLAN_FIELD_OPERATOR_CONTEXT_MISMATCH'); + items.push({entityKey,entityKind:'FIELD',action,status:action==='REVIEW'?'REVIEW':action==='MATCH'?'MATCHED':'PLANNED',assetTypeCode:'yacimiento',displayName:aggregate.name,generatedCode:action==='CREATE'?generatedImportCode(planId,'yacimiento',entityKey):null,parentEntityKey:areaItem.entityKey,matchedAssetId:matches.length===1?matches[0]!.id:null,payload:{areaEntityKey:areaItem.entityKey,operationalAreaEntityKey:explicitlyUnassigned?null:areaItem.entityKey,operatorEntityKey:uniqueOperatorItems[0]?.entityKey??null,operatorAssignmentStatus:explicitlyUnassigned?'UNASSIGNED_SOURCE':'ASSIGNED',operatorSourceValues:[...aggregate.operatorSourceValues].sort(),departmentEntityKeys:departmentItems.map(item=>item.entityKey),legalRightEntityKeys:rightItems.map(item=>item.entityKey),sourceDocumentId:batch.sourceDocumentId,manualMatchAllowed:!contextMismatch,manualCreateAllowed:reviews.every(code=>code.startsWith('SOURCE_ROW_'))},sourceRowNumbers:[...new Set(aggregate.rows)].sort((a,b)=>a-b),reviewCodes:[...new Set(reviews)]}); + } + return items; + } + + private async buildInventoryPlan(manager: EntityManager, planId: string, batch: ImportBatchRow, rows: ImportSourceRow[], operatorAssetId: string, namespace: string): Promise { + const operatedAreas=(await manager.query(` + SELECT area.id,area.code,area.name,type.code AS "typeCode",area.parent_id AS "parentId",area.operational_area_id AS "operationalAreaId",area.operator_company_id AS "operatorCompanyId" + FROM area_company_relations relation JOIN assets area ON area.id=relation.area_id JOIN asset_types type ON type.id=area.asset_type_id + WHERE relation.company_id=$1 AND relation.relation_role='OPERATOR' AND relation.valid_until IS NULL AND area.information_status<>'INACTIVE' + ORDER BY area.name + `,[operatorAssetId])) as AssetMatch[]; + if(!operatedAreas.length) throw new ConflictException({code:'ASSET_IMPORT_OPERATOR_WITHOUT_AREAS',message:'La organización seleccionada no tiene áreas operadas activas. Importá o configurá primero la relación Área ↔ Operadora.'}); + const areaIds=operatedAreas.map(area=>area.id); + const areaMap=new Map(); + for(const area of operatedAreas){const key=plainImportKey(area.name);const list=areaMap.get(key)??[];list.push(area);areaMap.set(key,list);} + const lookups=[...new Set(rows.map(row=>plainImportKey(row.normalizedData.areaOrField)).filter(Boolean))]; + const lookupSet=new Set(lookups); + type ContextCandidate=AssetMatch & {parentName?:string|null}; + const fieldMap=new Map(); + if(lookups.length){ + const found=(await manager.query(` + SELECT field.id,field.code,field.name,type.code AS "typeCode",field.parent_id AS "parentId",field.operational_area_id AS "operationalAreaId",field.operator_company_id AS "operatorCompanyId",area.name AS "parentName" + FROM assets field JOIN asset_types type ON type.id=field.asset_type_id LEFT JOIN assets area ON area.id=field.parent_id + WHERE type.code='yacimiento' AND field.information_status<>'INACTIVE' AND field.parent_id=ANY($1::uuid[]) + `,[areaIds])) as ContextCandidate[]; + for(const field of found){const key=plainImportKey(field.name);if(!lookupSet.has(key))continue;const list=fieldMap.get(key)??[];list.push(field);fieldMap.set(key,list);} + } + const inventoryIds=[...new Set(rows.map(row=>String(row.normalizedData.inventoryId??'').trim().toLowerCase()).filter(Boolean))]; + const inventoryIdCounts=new Map(); + for(const row of rows){const key=plainImportKey(row.normalizedData.inventoryId);if(key)inventoryIdCounts.set(key,(inventoryIdCounts.get(key)??0)+1);} + const namespaceMatches=new Map(); + if(inventoryIds.length){ + const found=(await manager.query(` + SELECT asset.id,asset.code,asset.name,type.code AS "typeCode",asset.parent_id AS "parentId",asset.operational_area_id AS "operationalAreaId",asset.operator_company_id AS "operatorCompanyId",lower(identifier.value) AS lookup + FROM asset_external_identifiers identifier JOIN assets asset ON asset.id=identifier.asset_id JOIN asset_types type ON type.id=asset.asset_type_id + WHERE identifier.namespace=$1 AND identifier.valid_until IS NULL AND lower(identifier.value)=ANY($2::text[]) AND asset.information_status<>'INACTIVE' + `,[namespace,inventoryIds])) as Array; + for(const asset of found){const list=namespaceMatches.get(asset.lookup)??[];list.push(asset);namespaceMatches.set(asset.lookup,list);} + } + + const rawOverrides=batch.analysis?.inventoryContextOverrides; + const contextOverrides=(rawOverrides&&typeof rawOverrides==='object'&&!Array.isArray(rawOverrides)?rawOverrides:{}) as Record; + type Context = { row:ImportSourceRow; anchor:ContextCandidate|null; areaId:string|null; contextKey:string|null; contextItem:AssetImportPlanDraftItem|null; reviewCodes:string[] }; + const contextItems=new Map(); + const contextDecisionItems=new Map(); + const contexts:Context[]=[]; + for(const row of rows){ + const lookup=plainImportKey(row.normalizedData.areaOrField); + const fields=fieldMap.get(lookup)??[]; + const areas=(areaMap.get(lookup)??[]) as ContextCandidate[]; + const candidates:ContextCandidate[]=fields.length?fields:areas; + const reviews:string[]=[]; + let anchor:ContextCandidate|null=null; + const overrideId=String(contextOverrides[lookup]??'').trim(); + if(overrideId){ + anchor=candidates.find(candidate=>candidate.id===overrideId)??null; + if(!anchor)reviews.push('PLAN_TERRITORY_CONTEXT_REQUIRED'); + } + if(!anchor){ + if(!overrideId&&candidates.length===1)anchor=candidates[0]!; + else if(candidates.length!==1||Boolean(overrideId)){ + reviews.push('PLAN_TERRITORY_CONTEXT_REQUIRED'); + const decisionKey=`inventory-context:${lookup}`; + const existing=contextDecisionItems.get(decisionKey); + const candidateOptions=candidates.map(candidate=>({id:candidate.id,code:candidate.code,name:candidate.name,typeCode:candidate.typeCode,parentName:candidate.parentName??null})); + const decisionCode=overrideId?'PLAN_TERRITORY_OVERRIDE_STALE':candidates.length?'PLAN_TERRITORY_AMBIGUOUS':'PLAN_TERRITORY_NOT_FOUND'; + const decision=existing??{entityKey:decisionKey,entityKind:fields.length?'FIELD':'AREA',action:'REVIEW',status:'REVIEW',assetTypeCode:fields.length?'yacimiento':'area',displayName:String(row.normalizedData.areaOrField??'Contexto territorial'),generatedCode:null,parentEntityKey:null,matchedAssetId:null,payload:{inventoryContextDecision:true,contextLookup:lookup,candidateOptions,operatorAssetId,manualMatchAllowed:candidateOptions.length>0,manualCreateAllowed:false},sourceRowNumbers:[],reviewCodes:[decisionCode]}; + decision.sourceRowNumbers.push(row.rowNumber);contextDecisionItems.set(decisionKey,decision); + } + } + let contextItem:AssetImportPlanDraftItem|null=null; + let contextKey:string|null=null; + let areaId:string|null=null; + if(anchor){ + areaId=anchor.typeCode==='area'?anchor.id:(anchor.operationalAreaId??anchor.parentId); + contextKey=`context:${anchor.typeCode}:${anchor.id}`; + contextItem=contextItems.get(contextKey)??{entityKey:contextKey,entityKind:anchor.typeCode==='area'?'AREA':'FIELD',action:'MATCH',status:'MATCHED',assetTypeCode:anchor.typeCode,displayName:anchor.name,generatedCode:null,parentEntityKey:null,matchedAssetId:anchor.id,payload:{operationalAreaAssetId:areaId,operatorAssetId,contextOnly:true,sourceContextOverride:Boolean(overrideId)},sourceRowNumbers:[],reviewCodes:[]}; + contextItem.sourceRowNumbers.push(row.rowNumber);contextItems.set(contextKey,contextItem); + } + contexts.push({row,anchor,areaId,contextKey,contextItem,reviewCodes:reviews}); + } + + type LocalStructureSpec={key:string;name:string;parentKey:string;parentAssetId:string;areaId:string;rows:number[];sourceInstallation:string|null;sourceSubInstallation:string|null;sourcePath:string[];concreteFromLocation:boolean;sourceGroupOnly:boolean;reviewCodes:string[]}; + const localStructures=new Map(); + const rowLocalStructureKeys=new Map(); + for(const context of contexts){ + if(!context.anchor||!context.contextKey||!context.areaId)continue; + const suggestion=sourceLocalStructureSuggestion(context.row.normalizedData.areaOrField,context.row.normalizedData.installation,context.row.normalizedData.subInstallation,context.row.normalizedData.location); + if(!suggestion)continue; + const hierarchyKey=suggestion.concreteFromLocation + ? plainImportKey(suggestion.displayName) + : [plainImportKey(suggestion.sourceInstallation),plainImportKey(suggestion.sourceSubInstallation)].filter(Boolean).join(':'); + const key=`local-structure:${operatorAssetId}:${context.anchor.id}:${hierarchyKey}`; + const spec=localStructures.get(key)??{key,name:suggestion.displayName,parentKey:context.contextKey,parentAssetId:context.anchor.id,areaId:context.areaId,rows:[],sourceInstallation:suggestion.sourceInstallation,sourceSubInstallation:suggestion.sourceSubInstallation,sourcePath:suggestion.sourcePath,concreteFromLocation:suggestion.concreteFromLocation,sourceGroupOnly:suggestion.sourceGroupOnly,reviewCodes:[]}; + spec.rows.push(context.row.rowNumber);localStructures.set(key,spec);rowLocalStructureKeys.set(context.row.rowNumber,key); + } + const localMatches=new Map(); + if(localStructures.size){ + const parentIds=[...new Set([...localStructures.values()].map(item=>item.parentAssetId))]; + const wanted=new Set([...localStructures.values()].map(item=>`local:${item.parentAssetId}:${plainImportKey(item.name)}`)); + if(parentIds.length){ + const found=(await manager.query(` + SELECT asset.id,asset.code,asset.name,type.code AS "typeCode",asset.parent_id AS "parentId",asset.operational_area_id AS "operationalAreaId",asset.operator_company_id AS "operatorCompanyId" + FROM assets asset JOIN asset_types type ON type.id=asset.asset_type_id + WHERE asset.parent_id=ANY($1::uuid[]) AND type.code='estructura_local' AND asset.operator_company_id=$2 AND asset.information_status<>'INACTIVE' + `,[parentIds,operatorAssetId])) as AssetMatch[]; + for(const asset of found){const key=`local:${asset.parentId}:${plainImportKey(asset.name)}`;if(!wanted.has(key))continue;const list=localMatches.get(key)??[];list.push(asset);localMatches.set(key,list);} + } + } + + const items:AssetImportPlanDraftItem[]=[...contextItems.values(),...contextDecisionItems.values()].map(item=>({...item,sourceRowNumbers:[...new Set(item.sourceRowNumbers)].sort((a,b)=>a-b)})); + const localItemByKey=new Map(); + for(const spec of [...localStructures.values()].sort((a,b)=>a.key.localeCompare(b.key))){ + const matchKey=`local:${spec.parentAssetId}:${plainImportKey(spec.name)}`; + const matches=localMatches.get(matchKey)??[]; + const reviews=[...spec.reviewCodes];if(matches.length>1)reviews.push('PLAN_MULTIPLE_LOCAL_STRUCTURES'); + const action:AssetImportPlanAction=reviews.length?'REVIEW':matches.length===1?'MATCH':'CREATE'; + const item:AssetImportPlanDraftItem={entityKey:spec.key,entityKind:'LOCAL_STRUCTURE',action,status:action==='REVIEW'?'REVIEW':action==='MATCH'?'MATCHED':'PLANNED',assetTypeCode:'estructura_local',displayName:spec.name,generatedCode:action==='CREATE'?generatedImportCode(planId,'estructura_local',spec.key):null,parentEntityKey:spec.parentKey,matchedAssetId:matches.length===1?matches[0]!.id:null,payload:{operationalAreaAssetId:spec.areaId,operatorAssetId,sourceInstallation:spec.sourceInstallation,sourceSubInstallation:spec.sourceSubInstallation,sourcePath:spec.sourcePath,sourcePathText:spec.sourcePath.join(' / '),concreteFromLocation:spec.concreteFromLocation,sourceGroupOnly:spec.sourceGroupOnly,sourceDocumentId:batch.sourceDocumentId,localNomenclature:true,classificationStatus:'SOURCE_PRESERVED',manualCreateAllowed:reviews.length===0},sourceRowNumbers:[...new Set(spec.rows)].sort((a,b)=>a-b),reviewCodes:[...new Set(reviews)]}; + items.push(item);localItemByKey.set(spec.key,item); + } + + const directAnchorTypes=new Set(['pozo','ducto','colector']); + for(const context of contexts){ + const row=context.row; + const inventoryId=String(row.normalizedData.inventoryId??'').trim(); + const typeCode=technicalFamilyTypeCode(row.normalizedData.normalizedFamily,row.normalizedData.normalizedSubtype); + const displayName=inventoryId?`${String(row.normalizedData.equipment??'Activo').trim()} · ${inventoryId}`:String(row.normalizedData.equipment??'Activo sin identificar'); + const inventoryKey=plainImportKey(inventoryId); + const entityKey=inventoryKey&&inventoryIdCounts.get(inventoryKey)===1?`technical:${namespace}:${inventoryKey}`:`technical:${namespace}:${inventoryKey||'sin-id'}:row-${row.rowNumber}`; + const reviews=[...context.reviewCodes]; + if(inventoryId.length>180)reviews.push('PLAN_INVENTORY_ID_TOO_LONG'); + const exact=inventoryId?(namespaceMatches.get(inventoryId.toLowerCase())??[]):[]; + if(exact.length>1)reviews.push('PLAN_MULTIPLE_NAMESPACE_MATCHES'); + if(exact.length===1&&(exact[0]!.operationalAreaId!==context.areaId||exact[0]!.operatorCompanyId!==operatorAssetId))reviews.push('PLAN_MATCH_OPERATIONAL_CONTEXT_MISMATCH'); + if(!exact.length&&row.matchedAssetId)reviews.push('PLAN_GLOBAL_MATCH_REQUIRES_NAMESPACE'); + if(row.status==='CONFLICT'||row.status==='WARNING')reviews.push(...(row.issues??[]),`SOURCE_ROW_${row.status}`); + if(row.status==='IGNORED'){ + items.push({entityKey,entityKind:'TECHNICAL_ASSET',action:'IGNORE',status:'IGNORED',assetTypeCode:typeCode,displayName,generatedCode:null,parentEntityKey:null,matchedAssetId:null,payload:{...row.normalizedData,externalIdNamespace:namespace,operatorAssetId,manualCreateAllowed:false},sourceRowNumbers:[row.rowNumber],reviewCodes:[...new Set(reviews)]}); + continue; + } + let parentKey=context.contextKey; + if(!directAnchorTypes.has(typeCode)){ + const key=rowLocalStructureKeys.get(row.rowNumber); + parentKey=key?localItemByKey.get(key)?.entityKey??null:null; + if(context.anchor&&context.areaId&&!parentKey)reviews.push('PLAN_LOCAL_STRUCTURE_NOT_IDENTIFIED'); + } + if(!context.anchor||!context.areaId){ + if(reviews.includes('PLAN_TERRITORY_CONTEXT_REQUIRED'))reviews.push('PLAN_CONTEXT_DECISION_REQUIRED'); + else reviews.push('PLAN_PARENT_NOT_RESOLVED'); + } else if(!parentKey)reviews.push('PLAN_PARENT_NOT_RESOLVED'); + const action:AssetImportPlanAction=reviews.length?'REVIEW':exact.length===1?'MATCH':'CREATE'; + items.push({entityKey,entityKind:'TECHNICAL_ASSET',action,status:action==='REVIEW'?'REVIEW':action==='MATCH'?'MATCHED':'PLANNED',assetTypeCode:typeCode,displayName,generatedCode:action==='CREATE'?generatedImportCode(planId,typeCode,entityKey):null,parentEntityKey:parentKey,matchedAssetId:exact.length===1?exact[0]!.id:null,payload:{...row.normalizedData,operationalAreaAssetId:context.areaId,operatorAssetId,externalIdNamespace:namespace,sourceDocumentId:batch.sourceDocumentId,localStructureEntityKey:parentKey&&parentKey.startsWith('local-structure:')?parentKey:null,manualMatchAllowed:!reviews.includes('PLAN_MATCH_OPERATIONAL_CONTEXT_MISMATCH'),manualCreateAllowed:reviews.every(code=>code.startsWith('SOURCE_ROW_')||code==='GROUPED_QUANTITY'||code==='SOURCE_STATUS_REQUIRES_MAPPING'||code==='DUPLICATE_INVENTORY_ID_IN_BATCH')},sourceRowNumbers:[row.rowNumber],reviewCodes:[...new Set(reviews)]}); + } + return items; + } + + + private async assetNameMatches(manager:EntityManager,role:'AREA'|'COMPANY',keys:string[]):Promise>{ + const map=new Map();if(!keys.length)return map;const wanted=new Set(keys); + const rows=(await manager.query(`SELECT asset.id,asset.code,asset.name,type.code AS "typeCode",asset.parent_id AS "parentId",asset.operational_area_id AS "operationalAreaId",asset.operator_company_id AS "operatorCompanyId" FROM assets asset JOIN asset_types type ON type.id=asset.asset_type_id WHERE type.operational_role=$1 AND type.is_active=true AND asset.information_status<>'INACTIVE'`,[role])) as AssetMatch[]; + for(const row of rows){const key=plainImportKey(row.name);if(!wanted.has(key))continue;const list=map.get(key)??[];list.push(row);map.set(key,list);}return map; + } + + private async organizationNameMatches(manager:EntityManager,keys:string[]):Promise>{ + const map=new Map();if(!keys.length)return map;const wanted=new Set(keys); + const rows=(await manager.query(` + SELECT asset.id,asset.code,asset.name,type.code AS "typeCode",asset.parent_id AS "parentId",asset.operational_area_id AS "operationalAreaId",asset.operator_company_id AS "operatorCompanyId",profile.legal_name AS "legalName" + FROM assets asset JOIN asset_types type ON type.id=asset.asset_type_id LEFT JOIN organization_profiles profile ON profile.asset_id=asset.id + WHERE type.operational_role='COMPANY' AND type.is_active=true AND asset.information_status<>'INACTIVE' + `)) as Array; + for(const row of rows){ + const candidateKeys=[organizationImportKey(row.name),organizationImportKey(row.legalName)].filter(Boolean); + for(const key of new Set(candidateKeys)){if(!wanted.has(key))continue;const list=map.get(key)??[];if(!list.some(item=>item.id===row.id))list.push(row);map.set(key,list);} + } + return map; + } + + private async insertPlanItems(manager: EntityManager, planId: string, items: AssetImportPlanDraftItem[]): Promise { + for(let start=0;start{const base=params.length;params.push(planId,start+index+1,item.entityKey,item.entityKind,item.action,item.status,item.assetTypeCode,item.displayName,item.generatedCode,item.parentEntityKey,item.matchedAssetId,JSON.stringify(item.payload),JSON.stringify(item.sourceRowNumbers),JSON.stringify(item.reviewCodes));return `($${base+1}::uuid,$${base+2},$${base+3},$${base+4},$${base+5},$${base+6},$${base+7},$${base+8},$${base+9},$${base+10},$${base+11}::uuid,$${base+12}::jsonb,$${base+13}::jsonb,$${base+14}::jsonb)`;});await manager.query(`INSERT INTO asset_import_plan_items (plan_id,item_order,entity_key,entity_kind,action,status,asset_type_code,display_name,generated_code,parent_entity_key,matched_asset_id,payload,source_row_numbers,review_codes) VALUES ${values.join(',')}`,params);} + } + + private async refreshPlan(manager:EntityManager,planId:string,summaryPatch:Record={}){ + const plan=await this.loadPlanById(manager,planId);const items=await this.loadPlanItems(manager,planId); + const drafts:AssetImportPlanDraftItem[]=items.map(item=>({entityKey:item.entityKey,entityKind:item.entityKind,action:item.action,status:item.status,assetTypeCode:item.assetTypeCode,displayName:item.displayName,generatedCode:item.generatedCode,parentEntityKey:item.parentEntityKey,matchedAssetId:item.matchedAssetId,payload:item.payload,sourceRowNumbers:item.sourceRowNumbers,reviewCodes:item.reviewCodes})); + const summary={...plan.summary,...summarizePlanItems(drafts),...summaryPatch,updatedAt:new Date().toISOString()};const status=planStatusForItems(drafts);const hash=planItemHash(drafts); + await manager.query(`UPDATE asset_import_plans SET status=$2,summary=$3::jsonb,plan_hash=$4,updated_at=CURRENT_TIMESTAMP WHERE id=$1`,[planId,status,JSON.stringify(summary),hash]); + await manager.query(`UPDATE asset_import_batches SET analysis=jsonb_set(analysis,'{importPlan}',$2::jsonb,true),updated_at=CURRENT_TIMESTAMP WHERE id=$1`,[plan.batchId,JSON.stringify({planId,revision:plan.revision,status,planHash:hash,...summary})]); + return this.planView(manager,planId); + } + + private async revalidateMatchedAssets(manager:EntityManager,items:PlanItemRow[]):Promise{ + const ids=[...new Set(items.filter(item=>item.action==='MATCH'&&item.matchedAssetId).map(item=>item.matchedAssetId!))];if(!ids.length)return; + const [row]=(await manager.query(`SELECT COUNT(*)::integer AS total FROM assets WHERE id=ANY($1::uuid[]) AND information_status<>'INACTIVE'`,[ids])) as Array<{total:number}>; + if(Number(row?.total??0)!==ids.length)throw new ConflictException({code:'ASSET_IMPORT_PLAN_STALE',message:'Uno o más activos coincidentes cambiaron o fueron inactivados. Regenerá el plan.'}); + } + + private async revalidateMatchedRelationalObjects(manager: EntityManager, items: PlanItemRow[]): Promise { + const departmentIds = [...new Set(items.filter((item) => item.entityKind === 'DEPARTMENT' && item.action === 'MATCH').map((item) => String(item.payload.matchedDepartmentId ?? '')).filter(Boolean))]; + if (departmentIds.length) { + const [row] = (await manager.query(`SELECT COUNT(*)::integer AS total FROM administrative_departments WHERE id=ANY($1::uuid[]) AND is_active=true`, [departmentIds])) as Array<{ total: number }>; + if (Number(row?.total ?? 0) !== departmentIds.length) throw new ConflictException({ code: 'ASSET_IMPORT_PLAN_STALE', message: 'Uno o más Departamentos del plan cambiaron. Regenerá el plan.' }); + } + const rightIds = [...new Set(items.filter((item) => item.entityKind === 'LEGAL_RIGHT' && item.action === 'MATCH').map((item) => String(item.payload.matchedLegalRightId ?? '')).filter(Boolean))]; + if (rightIds.length) { + const [row] = (await manager.query(`SELECT COUNT(*)::integer AS total FROM area_legal_rights WHERE id=ANY($1::uuid[]) AND status IN ('ACTIVE','PENDING') AND (valid_until IS NULL OR valid_until>=CURRENT_DATE)`, [rightIds])) as Array<{ total: number }>; + if (Number(row?.total ?? 0) !== rightIds.length) throw new ConflictException({ code: 'ASSET_IMPORT_PLAN_STALE', message: 'Uno o más derechos del plan cambiaron. Regenerá el plan.' }); + } + const relationChecks: Array<{ kind: AssetImportPlanEntityKind; table: string }> = [ + { kind: 'AREA_DEPARTMENT_RELATION', table: 'area_department_relations' }, + { kind: 'OPERATOR_RELATION', table: 'area_company_relations' }, + { kind: 'LEGAL_RIGHT_ORGANIZATION', table: 'area_legal_right_organizations' }, + ]; + for (const check of relationChecks) { + const ids = [...new Set(items.filter((item) => item.entityKind === check.kind && item.action === 'MATCH').map((item) => String(item.payload.matchedRelationId ?? '')).filter(Boolean))]; + if (!ids.length) continue; + const [row] = (await manager.query(`SELECT COUNT(*)::integer AS total FROM ${check.table} WHERE id=ANY($1::uuid[]) AND valid_until IS NULL`, [ids])) as Array<{ total: number }>; + if (Number(row?.total ?? 0) !== ids.length) throw new ConflictException({ code: 'ASSET_IMPORT_PLAN_STALE', message: `Una relación ${check.kind} cambió. Regenerá el plan.` }); + } + } + + private async revalidateExternalIdentifiers(manager:EntityManager,plan:ImportPlanRow,items:PlanItemRow[]):Promise{ + if(!plan.externalIdNamespace)return; + const values=[...new Set(items.filter(item=>item.action==='CREATE'&&item.status!=='APPLIED'&&item.entityKind==='TECHNICAL_ASSET').map(item=>String(item.payload.inventoryId??'').trim().toLowerCase()).filter(Boolean))]; + if(!values.length)return; + const [hit]=(await manager.query(`SELECT identifier.value FROM asset_external_identifiers identifier JOIN assets asset ON asset.id=identifier.asset_id WHERE identifier.namespace=$1 AND identifier.valid_until IS NULL AND lower(identifier.value)=ANY($2::text[]) AND asset.information_status<>'INACTIVE' LIMIT 1`,[plan.externalIdNamespace,values])) as Array<{value:string}>; + if(hit)throw new ConflictException({code:'ASSET_IMPORT_PLAN_STALE',message:`El identificador ${plan.externalIdNamespace}:${hit.value} apareció en el Maestro después de generar el plan. Regenerá el plan.`}); + } + + private async revalidateMatchedOperationalContext(manager:EntityManager,items:PlanItemRow[],getRef:(key:string|null|undefined)=>string|null):Promise{ + const matched=items.filter(item=>item.action==='MATCH'&&item.matchedAssetId&&['FIELD','INSTALLATION','LOCAL_STRUCTURE','TECHNICAL_ASSET'].includes(item.entityKind)); + if(!matched.length)return; + const ids=[...new Set(matched.map(item=>item.matchedAssetId!))]; + const rows=(await manager.query(`SELECT asset.id,asset.operational_area_id AS "operationalAreaId",asset.operator_company_id AS "operatorCompanyId" FROM assets asset WHERE asset.id=ANY($1::uuid[]) AND asset.information_status<>'INACTIVE'`,[ids])) as Array<{id:string;operationalAreaId:string|null;operatorCompanyId:string|null}>; + const byId=new Map(rows.map(row=>[row.id,row])); + for(const item of matched){ + const current=byId.get(item.matchedAssetId!);if(!current)throw new ConflictException({code:'ASSET_IMPORT_PLAN_STALE',message:`La coincidencia ${item.displayName} ya no está disponible`}); + const areaKey=String(item.payload.operationalAreaEntityKey??'');const operatorKey=String(item.payload.operatorEntityKey??''); + const expectedArea=areaKey?getRef(areaKey):(String(item.payload.operationalAreaAssetId??'')||null); + const expectedOperator=operatorKey?getRef(operatorKey):(String(item.payload.operatorAssetId??'')||null); + const explicitlyUnassigned=item.payload.operatorAssignmentStatus==='UNASSIGNED_SOURCE'; + if(explicitlyUnassigned){if(current.operationalAreaId!==null||current.operatorCompanyId!==null)throw new ConflictException({code:'ASSET_IMPORT_PLAN_STALE',message:`${item.displayName} recibió contexto operativo después de generar el plan. Regenerá antes de aplicar.`});continue;} + if(expectedArea!==null||expectedOperator!==null){if(current.operationalAreaId!==expectedArea||current.operatorCompanyId!==expectedOperator)throw new ConflictException({code:'ASSET_IMPORT_PLAN_STALE',message:`El contexto operativo de ${item.displayName} cambió. Regenerá el plan.`});} + } + } + + private async applyPlanItems(manager:EntityManager,batch:ImportBatchRow,plan:ImportPlanRow,items:PlanItemRow[],principal:AuthPrincipal,request:RequestWithContext):Promise>{ + const assetKinds = new Set(['ORGANIZATION','AREA','FIELD','INSTALLATION','LOCAL_STRUCTURE','TECHNICAL_ASSET']); + const resolved=new Map(); + for(const item of items){if(item.status==='APPLIED'&&item.appliedAssetId&&assetKinds.has(item.entityKind)){resolved.set(item.entityKey,{id:item.appliedAssetId,typeCode:item.assetTypeCode??''});continue;}if(item.action==='MATCH'&&item.matchedAssetId&&assetKinds.has(item.entityKind)){resolved.set(item.entityKey,{id:item.matchedAssetId,typeCode:item.assetTypeCode??''});}} + const createAssetItems=items.filter(item=>item.action==='CREATE'&&item.status!=='APPLIED'&&assetKinds.has(item.entityKind)); + for(const item of createAssetItems){resolved.set(item.entityKey,{id:randomUUID(),typeCode:item.assetTypeCode??''});} + + const resolvedObjects=new Map(); + for(const item of items){ + if(item.status==='APPLIED'&&item.appliedObjectId&&(item.entityKind==='DEPARTMENT'||item.entityKind==='LEGAL_RIGHT')){resolvedObjects.set(item.entityKey,item.appliedObjectId);continue;} + if(item.entityKind==='DEPARTMENT'&&item.action==='MATCH'){const id=String(item.payload.matchedDepartmentId??'');if(id)resolvedObjects.set(item.entityKey,id);} + if(item.entityKind==='LEGAL_RIGHT'&&item.action==='MATCH'){const id=String(item.payload.matchedLegalRightId??'');if(id)resolvedObjects.set(item.entityKey,id);} + } + for(const item of items){if(item.action!=='CREATE'||item.status==='APPLIED')continue;if(item.entityKind==='DEPARTMENT'||item.entityKind==='LEGAL_RIGHT')resolvedObjects.set(item.entityKey,randomUUID());} + + const typeCodes=[...new Set(createAssetItems.map(item=>item.assetTypeCode).filter((value):value is string=>Boolean(value)))]; + const typeRows=typeCodes.length?(await manager.query(`SELECT id,code,operational_role AS "operationalRole",is_active AS "isActive",can_be_root AS "canBeRoot" FROM asset_types WHERE code=ANY($1::text[])`,[typeCodes])) as Array<{id:string;code:string;operationalRole:string;isActive:boolean;canBeRoot:boolean}>:[]; + const typeMap=new Map(typeRows.map(row=>[row.code,row])); + for(const code of typeCodes){const type=typeMap.get(code);if(!type||!type.isActive)throw new ConflictException({code:'ASSET_IMPORT_TYPE_NOT_AVAILABLE',message:`El tipo ${code} no existe o está inactivo`});} + const createdAssetIdSet=new Set(createAssetItems.map(item=>resolved.get(item.entityKey)!.id)); + const matchedIds=[...new Set([...resolved.values()].map(value=>value.id).filter(id=>!createdAssetIdSet.has(id)))]; + const matchedTypes=new Map();if(matchedIds.length){const rows=(await manager.query(`SELECT asset.id,type.code FROM assets asset JOIN asset_types type ON type.id=asset.asset_type_id WHERE asset.id=ANY($1::uuid[])`,[matchedIds])) as Array<{id:string;code:string}>;for(const row of rows)matchedTypes.set(row.id,row.code);} + const getRef=(key:string|null|undefined)=>key?resolved.get(key)?.id??null:null; + const getObjectRef=(key:string|null|undefined)=>key?resolvedObjects.get(key)??null:null; + const getType=(key:string|null|undefined)=>{if(!key)return null;const ref=resolved.get(key);if(!ref)return null;return ref.typeCode||matchedTypes.get(ref.id)||null;}; + await this.revalidateMatchedOperationalContext(manager,items,getRef); + + const rootItems=createAssetItems.filter(item=>item.entityKind==='ORGANIZATION'||item.entityKind==='AREA'); + const fieldItems=createAssetItems.filter(item=>item.entityKind==='FIELD'); + const installationItems=createAssetItems.filter(item=>item.entityKind==='INSTALLATION'||item.entityKind==='LOCAL_STRUCTURE'); + const technicalItems=createAssetItems.filter(item=>item.entityKind==='TECHNICAL_ASSET'); + await this.insertAssetItems(manager,batch,rootItems,resolved,typeMap,getRef,getType,principal); + + let createdDepartments=0; + for(const item of items.filter(item=>item.entityKind==='DEPARTMENT'&&item.action==='CREATE'&&item.status!=='APPLIED')){ + const id=getObjectRef(item.entityKey);if(!id)throw new ConflictException({code:'ASSET_IMPORT_PLAN_DEPENDENCY_MISSING',message:`No se pudo resolver Departamento ${item.displayName}`}); + await manager.query(`INSERT INTO administrative_departments (id,province_code,code,name,normalized_name,source_document_id,created_by,updated_by) VALUES ($1,$2,$3,$4,$5,$6,$7,$7)`,[id,String(item.payload.provinceCode??'MENDOZA'),String(item.payload.departmentCode??departmentCode(item.displayName)),item.displayName.slice(0,160),String(item.payload.normalizedName??plainImportKey(item.displayName)),batch.sourceDocumentId,principal.userId]); + await manager.query(`UPDATE asset_import_plan_items SET status='APPLIED',applied_object_id=$2,updated_at=CURRENT_TIMESTAMP WHERE id=$1`,[item.id,id]);createdDepartments+=1; + } + + let createdAreaDepartmentRelations=0; + for(const item of items.filter(item=>item.entityKind==='AREA_DEPARTMENT_RELATION'&&item.action==='CREATE'&&item.status!=='APPLIED')){ + const areaId=getRef(String(item.payload.areaEntityKey??''));const departmentId=getObjectRef(String(item.payload.departmentEntityKey??''));if(!areaId||!departmentId)throw new ConflictException({code:'ASSET_IMPORT_PLAN_DEPENDENCY_MISSING',message:`No se pudo resolver ${item.displayName}`}); + const [created]=await manager.query(`INSERT INTO area_department_relations (area_id,department_id,source_document_id,notes,created_by) VALUES ($1,$2,$3,$4,$5) RETURNING id`,[areaId,departmentId,batch.sourceDocumentId,`Importación controlada · lote ${batch.id}`,principal.userId]); + await manager.query(`UPDATE asset_import_plan_items SET status='APPLIED',applied_object_id=$2,updated_at=CURRENT_TIMESTAMP WHERE id=$1`,[item.id,String(created.id)]);createdAreaDepartmentRelations+=1; + } + + let createdOperatorRelations=0; + for(const item of items.filter(item=>item.entityKind==='OPERATOR_RELATION'&&item.action==='CREATE'&&item.status!=='APPLIED')){const areaId=getRef(String(item.payload.areaEntityKey??''));const organizationId=getRef(String(item.payload.organizationEntityKey??''));if(!areaId||!organizationId)throw new ConflictException({code:'ASSET_IMPORT_PLAN_DEPENDENCY_MISSING',message:`No se pudo resolver la relación ${item.displayName}`});const [created]=await manager.query(`INSERT INTO area_company_relations (area_id,company_id,start_reason,created_by,relation_role,source_document_id) VALUES ($1,$2,$3,$4,'OPERATOR',$5) RETURNING id`,[areaId,organizationId,`Importación controlada · lote ${batch.id}`,principal.userId,batch.sourceDocumentId]);await manager.query(`UPDATE asset_import_plan_items SET status='APPLIED',applied_object_id=$2,updated_at=CURRENT_TIMESTAMP WHERE id=$1`,[item.id,String(created.id)]);createdOperatorRelations+=1;} + + let createdLegalRights=0; + for(const item of items.filter(item=>item.entityKind==='LEGAL_RIGHT'&&item.action==='CREATE'&&item.status!=='APPLIED')){ + const id=getObjectRef(item.entityKey);const areaId=getRef(String(item.payload.areaEntityKey??''));if(!id||!areaId)throw new ConflictException({code:'ASSET_IMPORT_PLAN_DEPENDENCY_MISSING',message:`No se pudo resolver ${item.displayName}`}); + const rightType=String(item.payload.rightType??'OTHER'); + if(!['EXPLOITATION_CONCESSION','EXPLORATION_PERMIT','TRANSPORT_CONCESSION','OTHER'].includes(rightType))throw new ConflictException({code:'ASSET_IMPORT_LEGAL_RIGHT_TYPE_INVALID',message:`Tipo de derecho inválido para ${item.displayName}`}); + await manager.query(`INSERT INTO area_legal_rights (id,area_id,right_type,name,status,source_document_id,notes,created_by,updated_by) VALUES ($1,$2,$3::area_legal_right_type,$4,'PENDING',$5,$6,$7,$7)`,[id,areaId,rightType,String(item.payload.legalRightName??item.displayName).slice(0,260),batch.sourceDocumentId,`Importado como PENDING: la fuente define tipo y Área pero no instrumento ni vigencia. Lote ${batch.id}.`,principal.userId]); + await manager.query(`UPDATE asset_import_plan_items SET status='APPLIED',applied_object_id=$2,updated_at=CURRENT_TIMESTAMP WHERE id=$1`,[item.id,id]);createdLegalRights+=1; + } + + let createdLegalRightOrganizationRelations=0; + for(const item of items.filter(item=>item.entityKind==='LEGAL_RIGHT_ORGANIZATION'&&item.action==='CREATE'&&item.status!=='APPLIED')){ + const rightId=getObjectRef(String(item.payload.legalRightEntityKey??''));const organizationId=getRef(String(item.payload.organizationEntityKey??''));if(!rightId||!organizationId)throw new ConflictException({code:'ASSET_IMPORT_PLAN_DEPENDENCY_MISSING',message:`No se pudo resolver ${item.displayName}`}); + const [created]=await manager.query(`INSERT INTO area_legal_right_organizations (right_id,organization_id,role,notes,created_by) VALUES ($1,$2,'OPERATOR',$3,$4) RETURNING id`,[rightId,organizationId,`La fuente identifica a la organización como operadora; no se infiere titularidad. Lote ${batch.id}.`,principal.userId]); + await manager.query(`UPDATE asset_import_plan_items SET status='APPLIED',applied_object_id=$2,updated_at=CURRENT_TIMESTAMP WHERE id=$1`,[item.id,String(created.id)]);createdLegalRightOrganizationRelations+=1; + } + + await this.insertAssetItems(manager,batch,fieldItems,resolved,typeMap,getRef,getType,principal); + await this.insertAssetItems(manager,batch,installationItems,resolved,typeMap,getRef,getType,principal); + await this.insertAssetItems(manager,batch,technicalItems,resolved,typeMap,getRef,getType,principal); + const createdIds=createAssetItems.map(item=>resolved.get(item.entityKey)!.id); + await this.insertSourceLinks(manager,batch,createAssetItems,resolved,principal.userId); + const createdIdentifiers=await this.insertExternalIdentifiers(manager,batch,technicalItems,resolved,plan.externalIdNamespace,principal.userId); + await this.insertKnownAttributes(manager,createAssetItems,resolved,typeMap,principal.userId); + await this.insertImportVersions(manager,createdIds,principal,request); + await this.updateImportedRows(manager,batch.id,items,resolved); + for(const item of createAssetItems){await manager.query(`UPDATE asset_import_plan_items SET status='APPLIED',applied_asset_id=$2::uuid,applied_object_id=($2::uuid)::text,updated_at=CURRENT_TIMESTAMP WHERE id=$1`,[item.id,resolved.get(item.entityKey)!.id]);} + const createdByKind:Record={};for(const item of items.filter(item=>item.action==='CREATE'&&item.status!=='APPLIED'))createdByKind[item.entityKind]=(createdByKind[item.entityKind]??0)+1; + return {createdAssets:createdIds.length,createdDepartments,createdAreaDepartmentRelations,createdOperatorRelations,createdLegalRights,createdLegalRightOrganizationRelations,createdExternalIdentifiers:createdIdentifiers,matchedItems:items.filter(item=>item.action==='MATCH').length,ignoredItems:items.filter(item=>item.action==='IGNORE').length,createdByKind,sourceRowsLinked:items.filter(item=>['FIELD','TECHNICAL_ASSET'].includes(item.entityKind)&&item.action!=='IGNORE').reduce((sum,item)=>sum+item.sourceRowNumbers.length,0)}; + } + + private async insertAssetItems( + manager:EntityManager,batch:ImportBatchRow,items:PlanItemRow[],resolved:Map,typeMap:Map,getRef:(key:string|null|undefined)=>string|null,getType:(key:string|null|undefined)=>string|null,principal:AuthPrincipal, + ):Promise{ + if(!items.length)return; + const parentRuleRows=(await manager.query(`SELECT child.code AS child,parent.code AS parent FROM asset_type_parent_rules rule JOIN asset_types child ON child.id=rule.child_type_id JOIN asset_types parent ON parent.id=rule.parent_type_id`)) as Array<{child:string;parent:string}>; + const rules=new Set(parentRuleRows.map(row=>`${row.child}\u001f${row.parent}`)); + const records:Array<{item:PlanItemRow;id:string;typeId:string;parentId:string|null;areaId:string|null;operatorId:string|null;operationalStatus:string;description:string}>=[]; + for(const item of items){const typeCode=item.assetTypeCode;if(!typeCode)throw new ConflictException({code:'ASSET_IMPORT_TYPE_REQUIRED',message:`El ítem ${item.displayName} no tiene tipo`});const type=typeMap.get(typeCode);if(!type)throw new ConflictException({code:'ASSET_IMPORT_TYPE_NOT_AVAILABLE',message:`Tipo no disponible: ${typeCode}`});const id=resolved.get(item.entityKey)!.id;const parentId=getRef(item.parentEntityKey);const parentType=getType(item.parentEntityKey);if(!parentId&&!type.canBeRoot)throw new ConflictException({code:'ASSET_IMPORT_ROOT_RULE_INVALID',message:`${typeCode} no admite activos raíz (${item.displayName})`});if(parentId&&parentType&&!rules.has(`${typeCode}\u001f${parentType}`))throw new ConflictException({code:'ASSET_IMPORT_PARENT_RULE_INVALID',message:`${typeCode} no admite ${parentType} como padre (${item.displayName})`});let areaId:string|null=null;let operatorId:string|null=null;if(type.operationalRole==='GENERIC'){const areaKey=String(item.payload.operationalAreaEntityKey??'');const operatorKey=String(item.payload.operatorEntityKey??'');areaId=areaKey?getRef(areaKey):String(item.payload.operationalAreaAssetId??'')||null;operatorId=operatorKey?getRef(operatorKey):String(item.payload.operatorAssetId??'')||null;if((areaId&&!operatorId)||(!areaId&&operatorId))throw new ConflictException({code:'ASSET_IMPORT_OPERATIONAL_CONTEXT_INCOMPLETE',message:`Contexto operativo incompleto para ${item.displayName}`});} + const suggested=String(item.payload.operationalStatusSuggestion??'');const operationalStatus=['IN_SERVICE','TEMPORARILY_OUT_OF_SERVICE','OUT_OF_SERVICE','DECOMMISSIONED','ABANDONED'].includes(suggested)?suggested:'UNKNOWN';const sourceClassification=String(item.payload.sourceClassification??'').trim();const family=String(item.payload.normalizedFamily??'').trim();const subtype=String(item.payload.normalizedSubtype??'').trim();const description=[`Importado desde ${batch.originalName}.`,sourceClassification?`Clasificación fuente: ${sourceClassification}.`:'',family?`Familia normalizada: ${family}${subtype?` / ${subtype}`:''}.`:''].filter(Boolean).join(' ');records.push({item,id,typeId:type.id,parentId,areaId,operatorId,operationalStatus,description});} + for(let start=0;start{const base=params.length;params.push(record.id,record.typeId,record.parentId,record.areaId,record.operatorId,record.item.generatedCode,record.item.displayName.slice(0,200),record.description,record.operationalStatus,principal.userId,batch.sourceLabel||batch.originalName,`asset-import:${batch.id}`,`Importación planificada ${batch.id}`);return `($${base+1}::uuid,$${base+2}::uuid,$${base+3}::uuid,$${base+4}::uuid,$${base+5}::uuid,$${base+6},$${base+7},$${base+8},'DRAFT',$${base+9}::asset_operational_status,$${base+10},$${base+10},1,'IMPORT',$${base+11},$${base+12},$${base+13},$${base+10})`;});await manager.query(`INSERT INTO assets (id,asset_type_id,parent_id,operational_area_id,operator_company_id,code,name,description,information_status,operational_status,created_by,updated_by,current_version,data_origin,source_name,source_reference,source_notes,provenance_updated_by) VALUES ${values.join(',')}`,params);} + const organizations=records.filter(record=>record.item.entityKind==='ORGANIZATION');for(let start=0;start{const base=params.length;const kind=record.item.payload.organizationKind==='UTE'?'UTE':'COMPANY';params.push(record.id,kind,record.item.displayName.slice(0,240),principal.userId);return `($${base+1}::uuid,$${base+2}::organization_kind,$${base+3},$${base+4})`;});await manager.query(`INSERT INTO organization_profiles (asset_id,organization_kind,legal_name,updated_by) VALUES ${values.join(',')} ON CONFLICT (asset_id) DO NOTHING`,params);} + } + + private async insertSourceLinks(manager:EntityManager,batch:ImportBatchRow,items:PlanItemRow[],resolved:Map,userId:string):Promise{if(!batch.sourceDocumentId||!items.length)return;const ids=[...new Set(items.map(item=>resolved.get(item.entityKey)?.id).filter((value):value is string=>Boolean(value)))];for(let start=0;start,namespace:string|null,userId:string):Promise{if(!namespace||!items.length)return 0;const values:Array<{assetId:string;value:string}>=[];for(const item of items){const value=String(item.payload.inventoryId??'').trim();if(value)values.push({assetId:resolved.get(item.entityKey)!.id,value});}let count=0;for(let start=0;start{const base=params.length;params.push(item.assetId,namespace,item.value,batch.sourceDocumentId,userId,`Inventario importado · lote ${batch.id}`);return `($${base+1}::uuid,$${base+2},$${base+3},$${base+4}::uuid,$${base+5},$${base+6})`;});const result=await manager.query(`INSERT INTO asset_external_identifiers (asset_id,namespace,value,source_document_id,created_by,notes) VALUES ${sql.join(',')} RETURNING id`,params);count+=result.length;}return count;} + + private async insertKnownAttributes(manager:EntityManager,items:PlanItemRow[],resolved:Map,typeMap:Map,userId:string):Promise{ + if(!items.length)return;const typeIds=[...new Set(items.map(item=>item.assetTypeCode?typeMap.get(item.assetTypeCode)?.id:null).filter((value):value is string=>Boolean(value)))];const definitions=(await manager.query(`SELECT id,asset_type_id AS "assetTypeId",code FROM asset_attribute_definitions WHERE asset_type_id=ANY($1::uuid[]) AND is_active=true`,[typeIds])) as Array<{id:string;assetTypeId:string;code:string}>;const defs=new Map();for(const def of definitions)defs.set(`${def.assetTypeId}\u001f${def.code}`,def.id);const rows:Array<{assetId:string;definitionId:string;value:unknown}>=[];for(const item of items){const type=item.assetTypeCode?typeMap.get(item.assetTypeCode):null;if(!type)continue;const add=(code:string,value:unknown)=>{if(value===null||value===undefined||String(value).trim()==='')return;const definitionId=defs.get(`${type.id}\u001f${code}`);if(definitionId)rows.push({assetId:resolved.get(item.entityKey)!.id,definitionId,value});};if(item.entityKind==='ORGANIZATION')add('razon_social',item.displayName);add('fabricante',item.payload.manufacturer);add('modelo',item.payload.model);if(item.assetTypeCode==='instalacion')add('tipo_instalacion',item.payload.sourceInstallation);if(item.assetTypeCode==='estructura_local'){add('nivel_fuente',item.payload.sourceInstallation);add('clasificacion_fuente',item.payload.sourceSubInstallation);add('ruta_fuente',item.payload.sourcePathText);}if(item.assetTypeCode==='planta')add('funcion_planta',item.payload.sourceSubInstallation||item.payload.sourceInstallation);if(item.assetTypeCode==='pozo')add('estado_operativo',item.payload.sourceStatus);} + for(let start=0;start{const base=params.length;params.push(row.assetId,row.definitionId,JSON.stringify(row.value),userId);return `($${base+1}::uuid,$${base+2}::uuid,$${base+3}::jsonb,$${base+4})`;});await manager.query(`INSERT INTO asset_attribute_values (asset_id,definition_id,value,updated_by) VALUES ${values.join(',')} ON CONFLICT (asset_id,definition_id) DO UPDATE SET value=EXCLUDED.value,updated_by=EXCLUDED.updated_by,updated_at=CURRENT_TIMESTAMP`,params);} + } + + private async insertImportVersions(manager:EntityManager,assetIds:string[],principal:AuthPrincipal,request:RequestWithContext):Promise{for(let start=0;start):Promise{const pairs:Array<{rowNumber:number;assetId:string}>=[];for(const item of items){if(!['FIELD','TECHNICAL_ASSET'].includes(item.entityKind)||item.action==='IGNORE'||item.action==='REVIEW')continue;const assetId=item.action==='MATCH'?item.matchedAssetId:resolved.get(item.entityKey)?.id;if(!assetId)continue;for(const rowNumber of item.sourceRowNumbers)pairs.push({rowNumber,assetId});}for(let start=0;start{const base=params.length;params.push(pair.rowNumber,pair.assetId);return `($${base+1}::integer,$${base+2}::uuid)`;});await manager.query(`UPDATE asset_import_rows row SET imported_asset_id=v.asset_id,updated_at=CURRENT_TIMESTAMP FROM (VALUES ${values.join(',')}) AS v(row_number,asset_id) WHERE row.batch_id=$1 AND row.row_number=v.row_number`,params);}} + + private async assertRollbackSafe(manager:EntityManager,items:PlanItemRow[],createdAssetIds:string[]):Promise{ + const planObjects=items.filter(item=>item.action==='CREATE'&&item.appliedObjectId).map(item=>({entityKind:item.entityKind,id:item.appliedObjectId!})); + const ids=(kind:AssetImportPlanEntityKind)=>planObjects.filter(row=>row.entityKind===kind).map(row=>row.id); + const operatorRelationIds=ids('OPERATOR_RELATION'); + const areaDepartmentRelationIds=ids('AREA_DEPARTMENT_RELATION'); + const legalRightIds=ids('LEGAL_RIGHT'); + const legalRightOrganizationIds=ids('LEGAL_RIGHT_ORGANIZATION'); + const departmentIds=ids('DEPARTMENT'); + const checks:Array<{code:string;message:string;sql:string;params:unknown[]}>=[]; + if(createdAssetIds.length){checks.push( + {code:'ASSET_IMPORT_ROLLBACK_HAS_CHILDREN',message:'Hay activos posteriores que dependen de activos creados por este lote',sql:`SELECT 1 FROM assets WHERE parent_id=ANY($1::uuid[]) AND NOT (id=ANY($1::uuid[])) AND information_status<>'INACTIVE' LIMIT 1`,params:[createdAssetIds]}, + {code:'ASSET_IMPORT_ROLLBACK_HAS_ASSIGNMENTS',message:'Hay activos posteriores asignados a áreas u organizaciones creadas por este lote',sql:`SELECT 1 FROM assets WHERE (operational_area_id=ANY($1::uuid[]) OR operator_company_id=ANY($1::uuid[])) AND NOT (id=ANY($1::uuid[])) AND information_status<>'INACTIVE' LIMIT 1`,params:[createdAssetIds]}, + {code:'ASSET_IMPORT_ROLLBACK_HAS_RELATIONS',message:'Hay relaciones operativas externas al lote que dependen de activos importados',sql:`SELECT 1 FROM area_company_relations WHERE (area_id=ANY($1::uuid[]) OR company_id=ANY($1::uuid[])) AND valid_until IS NULL AND NOT (id=ANY($2::uuid[])) LIMIT 1`,params:[createdAssetIds,operatorRelationIds.length?operatorRelationIds:['00000000-0000-0000-0000-000000000000']]}, + {code:'ASSET_IMPORT_ROLLBACK_IN_USE',message:'Hay relevamientos o inspecciones que ya utilizan activos creados por este lote',sql:`SELECT 1 FROM (SELECT asset_id FROM survey_campaign_targets WHERE asset_id=ANY($1::uuid[]) UNION ALL SELECT scope_asset_id FROM survey_campaigns WHERE scope_asset_id=ANY($1::uuid[]) UNION ALL SELECT asset_id FROM inspection_visit_assets WHERE asset_id=ANY($1::uuid[]) UNION ALL SELECT scope_asset_id FROM inspection_visits WHERE scope_asset_id=ANY($1::uuid[]) UNION ALL SELECT asset_id FROM inspection_act_assets WHERE asset_id=ANY($1::uuid[]) UNION ALL SELECT asset_id FROM inspection_findings WHERE asset_id=ANY($1::uuid[])) usage LIMIT 1`,params:[createdAssetIds]} + );} + if(departmentIds.length)checks.push({code:'ASSET_IMPORT_ROLLBACK_DEPARTMENT_IN_USE',message:'Un Departamento creado por el lote ya está relacionado con otra Área',sql:`SELECT 1 FROM area_department_relations WHERE department_id=ANY($1::uuid[]) AND valid_until IS NULL AND NOT (id=ANY($2::uuid[])) LIMIT 1`,params:[departmentIds,areaDepartmentRelationIds.length?areaDepartmentRelationIds:['00000000-0000-0000-0000-000000000000']]}); + if(legalRightIds.length)checks.push({code:'ASSET_IMPORT_ROLLBACK_LEGAL_RIGHT_IN_USE',message:'Un derecho creado por el lote ya tiene relaciones posteriores',sql:`SELECT 1 FROM area_legal_right_organizations WHERE right_id=ANY($1::uuid[]) AND valid_until IS NULL AND NOT (id=ANY($2::uuid[])) LIMIT 1`,params:[legalRightIds,legalRightOrganizationIds.length?legalRightOrganizationIds:['00000000-0000-0000-0000-000000000000']]}); + for(const check of checks){const [hit]=(await manager.query(check.sql,check.params)) as unknown[];if(hit)throw new ConflictException({code:check.code,message:check.message});} + } + + private async insertRollbackVersions(manager:EntityManager,assetIds:string[],principal:AuthPrincipal,request:RequestWithContext):Promise{for(let start=0;start { + const chunkSize = 200; + for (let start = 0; start < rows.length; start += chunkSize) { + const chunk = rows.slice(start, start + chunkSize); + if (!chunk.length) continue; + const params: unknown[] = []; + const values = chunk.map((row) => { + const base = params.length; + params.push(batchId,row.sheetName,row.rowNumber,row.status,row.suggestedAction,JSON.stringify(row.raw),JSON.stringify(row.normalized),JSON.stringify(row.issues),row.fingerprint); + return `($${base+1},$${base+2},$${base+3},$${base+4}::asset_import_row_status,$${base+5}::asset_import_suggested_action,$${base+6}::jsonb,$${base+7}::jsonb,$${base+8}::jsonb,$${base+9})`; + }); + await manager.query(` + INSERT INTO asset_import_rows (batch_id,worksheet_name,row_number,status,suggested_action,raw_data,normalized_data,issue_codes,fingerprint) + VALUES ${values.join(',')} + `, params); + } + } + + private async loadBatch(manager: EntityManager, id: string, lock = false): Promise { + const rows = (await manager.query(`${this.batchSelect()} WHERE batch.id=$1${lock ? ' FOR UPDATE OF batch' : ''}`, [id])) as ImportBatchRow[]; + if (!rows[0]) throw importNotFound(); + return rows[0]; + } + + private batchSelect(): string { + return `SELECT batch.id,batch.original_name AS "originalName",batch.mime_type AS "mimeType",batch.size_bytes AS "sizeBytes",batch.sha256, + batch.profile_code AS "profileCode",batch.profile_confidence AS "profileConfidence",batch.worksheet_name AS "worksheetName",batch.header_row AS "headerRow", + batch.total_rows AS "totalRows",batch.ready_rows AS "readyRows",batch.warning_rows AS "warningRows",batch.conflict_rows AS "conflictRows",batch.ignored_rows AS "ignoredRows", + batch.status,batch.source_document_id AS "sourceDocumentId",batch.source_label AS "sourceLabel",batch.notes,batch.analysis, + batch.uploaded_by AS "uploadedBy",u.username AS "uploadedByUsername",batch.analyzed_at AS "analyzedAt",batch.created_at AS "createdAt",batch.updated_at AS "updatedAt" + FROM asset_import_batches batch LEFT JOIN users u ON u.id=batch.uploaded_by`; + } +} diff --git a/api-v3/src/asset-imports/dto/apply-asset-import-plan.dto.ts b/api-v3/src/asset-imports/dto/apply-asset-import-plan.dto.ts new file mode 100644 index 0000000..e2fa257 --- /dev/null +++ b/api-v3/src/asset-imports/dto/apply-asset-import-plan.dto.ts @@ -0,0 +1,7 @@ +import { IsString, Matches } from 'class-validator'; + +export class ApplyAssetImportPlanDto { + @IsString() + @Matches(/^[0-9a-f]{64}$/) + planHash!: string; +} diff --git a/api-v3/src/asset-imports/dto/cancel-asset-import.dto.ts b/api-v3/src/asset-imports/dto/cancel-asset-import.dto.ts new file mode 100644 index 0000000..57af175 --- /dev/null +++ b/api-v3/src/asset-imports/dto/cancel-asset-import.dto.ts @@ -0,0 +1,5 @@ +import { IsString, MaxLength, MinLength } from 'class-validator'; + +export class CancelAssetImportDto { + @IsString() @MinLength(5) @MaxLength(500) reason!: string; +} diff --git a/api-v3/src/asset-imports/dto/create-asset-import-plan.dto.ts b/api-v3/src/asset-imports/dto/create-asset-import-plan.dto.ts new file mode 100644 index 0000000..cb2d190 --- /dev/null +++ b/api-v3/src/asset-imports/dto/create-asset-import-plan.dto.ts @@ -0,0 +1,13 @@ +import { IsOptional, IsString, IsUUID, Matches, MaxLength } from 'class-validator'; + +export class CreateAssetImportPlanDto { + @IsOptional() + @IsUUID('4') + operatorAssetId?: string; + + @IsOptional() + @IsString() + @MaxLength(80) + @Matches(/^[A-Z0-9][A-Z0-9._/-]{1,79}$/i) + externalIdNamespace?: string; +} diff --git a/api-v3/src/asset-imports/dto/list-asset-import-plan-items-query.dto.ts b/api-v3/src/asset-imports/dto/list-asset-import-plan-items-query.dto.ts new file mode 100644 index 0000000..a255a80 --- /dev/null +++ b/api-v3/src/asset-imports/dto/list-asset-import-plan-items-query.dto.ts @@ -0,0 +1,14 @@ +import { Type } from 'class-transformer'; +import { IsIn, IsInt, IsOptional, Max, Min } from 'class-validator'; + +const entityKinds = [ + 'DEPARTMENT','ORGANIZATION','AREA','AREA_DEPARTMENT_RELATION','FIELD','OPERATOR_RELATION', + 'LEGAL_RIGHT','LEGAL_RIGHT_ORGANIZATION','INSTALLATION','LOCAL_STRUCTURE','TECHNICAL_ASSET', +] as const; + +export class ListAssetImportPlanItemsQueryDto { + @IsOptional() @Type(() => Number) @IsInt() @Min(1) page = 1; + @IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(200) pageSize = 50; + @IsOptional() @IsIn(entityKinds) entityKind?: typeof entityKinds[number]; + @IsOptional() @IsIn(['CREATE','MATCH','REVIEW','IGNORE']) action?: 'CREATE' | 'MATCH' | 'REVIEW' | 'IGNORE'; +} diff --git a/api-v3/src/asset-imports/dto/list-asset-import-reviews-query.dto.ts b/api-v3/src/asset-imports/dto/list-asset-import-reviews-query.dto.ts new file mode 100644 index 0000000..299c28c --- /dev/null +++ b/api-v3/src/asset-imports/dto/list-asset-import-reviews-query.dto.ts @@ -0,0 +1,8 @@ +import { Type } from 'class-transformer'; +import { IsIn, IsInt, IsOptional, Max, Min } from 'class-validator'; + +export class ListAssetImportReviewsQueryDto { + @IsOptional() @Type(() => Number) @IsInt() @Min(1) page = 1; + @IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(100) pageSize = 30; + @IsOptional() @IsIn(['ALL','DIRECT','DEPENDENCY']) kind: 'ALL' | 'DIRECT' | 'DEPENDENCY' = 'ALL'; +} diff --git a/api-v3/src/asset-imports/dto/list-asset-import-rows-query.dto.ts b/api-v3/src/asset-imports/dto/list-asset-import-rows-query.dto.ts new file mode 100644 index 0000000..94450d7 --- /dev/null +++ b/api-v3/src/asset-imports/dto/list-asset-import-rows-query.dto.ts @@ -0,0 +1,9 @@ +import { Type } from 'class-transformer'; +import { IsIn, IsInt, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator'; + +export class ListAssetImportRowsQueryDto { + @IsOptional() @Type(() => Number) @IsInt() @Min(1) page = 1; + @IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(200) pageSize = 50; + @IsOptional() @IsIn(['READY','WARNING','CONFLICT','IGNORED']) status?: string; + @IsOptional() @IsString() @MaxLength(160) search?: string; +} diff --git a/api-v3/src/asset-imports/dto/list-asset-imports-query.dto.ts b/api-v3/src/asset-imports/dto/list-asset-imports-query.dto.ts new file mode 100644 index 0000000..b5f7001 --- /dev/null +++ b/api-v3/src/asset-imports/dto/list-asset-imports-query.dto.ts @@ -0,0 +1,8 @@ +import { Type } from 'class-transformer'; +import { IsIn, IsInt, IsOptional, Max, Min } from 'class-validator'; + +export class ListAssetImportsQueryDto { + @IsOptional() @Type(() => Number) @IsInt() @Min(1) page = 1; + @IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(100) pageSize = 20; + @IsOptional() @IsIn(['ANALYZED','REVIEW_REQUIRED','CANCELLED','FAILED']) status?: string; +} diff --git a/api-v3/src/asset-imports/dto/resolve-asset-import-plan-item.dto.ts b/api-v3/src/asset-imports/dto/resolve-asset-import-plan-item.dto.ts new file mode 100644 index 0000000..bea0099 --- /dev/null +++ b/api-v3/src/asset-imports/dto/resolve-asset-import-plan-item.dto.ts @@ -0,0 +1,15 @@ +import { IsIn, IsOptional, IsString, IsUUID, MaxLength, MinLength, ValidateIf } from 'class-validator'; + +export class ResolveAssetImportPlanItemDto { + @IsIn(['CREATE', 'MATCH', 'IGNORE']) + action!: 'CREATE' | 'MATCH' | 'IGNORE'; + + @ValidateIf((value: ResolveAssetImportPlanItemDto) => value.action === 'MATCH') + @IsUUID('4') + matchedAssetId?: string; + + @IsString() + @MinLength(5) + @MaxLength(1000) + reason!: string; +} diff --git a/api-v3/src/asset-imports/dto/rollback-asset-import-plan.dto.ts b/api-v3/src/asset-imports/dto/rollback-asset-import-plan.dto.ts new file mode 100644 index 0000000..f4bf89b --- /dev/null +++ b/api-v3/src/asset-imports/dto/rollback-asset-import-plan.dto.ts @@ -0,0 +1,8 @@ +import { IsString, MaxLength, MinLength } from 'class-validator'; + +export class RollbackAssetImportPlanDto { + @IsString() + @MinLength(8) + @MaxLength(1000) + reason!: string; +} diff --git a/api-v3/src/asset-imports/dto/upload-asset-import.dto.ts b/api-v3/src/asset-imports/dto/upload-asset-import.dto.ts new file mode 100644 index 0000000..8862b5d --- /dev/null +++ b/api-v3/src/asset-imports/dto/upload-asset-import.dto.ts @@ -0,0 +1,18 @@ +import { IsIn, IsOptional, IsString, MaxLength, MinLength } from 'class-validator'; +import type { AssetImportProfileCode } from '../asset-import-profiles'; + +export class UploadAssetImportDto { + @IsOptional() + @IsString() + @MaxLength(240) + sourceLabel?: string; + + @IsOptional() + @IsString() + @MaxLength(2000) + notes?: string; + + @IsOptional() + @IsIn(['MENDOZA_INVENTORY_V1', 'MENDOZA_YACIMIENTOS_V1']) + profileCode?: Exclude; +} diff --git a/api-v3/src/asset-master/asset-attribute-validator.ts b/api-v3/src/asset-master/asset-attribute-validator.ts new file mode 100644 index 0000000..27fe102 --- /dev/null +++ b/api-v3/src/asset-master/asset-attribute-validator.ts @@ -0,0 +1,131 @@ +import { BadRequestException } from '@nestjs/common'; +import { + AssetAttributeDataType, + AssetAttributeDefinition, +} from '../database/entities'; + +export interface NormalizedAttributeValue { + definitionId: string; + value: unknown; +} + +function invalidAttribute(message: string, definitionId?: string): never { + throw new BadRequestException({ + code: 'INVALID_ASSET_ATTRIBUTE', + message, + ...(definitionId ? { definitionId } : {}), + }); +} + +function isMissing(value: unknown): boolean { + return value === undefined || value === null || value === ''; +} + +function isValidCalendarDate(value: string): boolean { + if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false; + const [year, month, day] = value.split('-').map(Number); + const parsed = new Date(Date.UTC(year!, month! - 1, day)); + return ( + parsed.getUTCFullYear() === year && + parsed.getUTCMonth() === month! - 1 && + parsed.getUTCDate() === day + ); +} + +function normalizeValue( + definition: AssetAttributeDefinition, + value: unknown, +): unknown { + switch (definition.dataType) { + case AssetAttributeDataType.TEXT: + if (typeof value !== 'string' || value.length > 4000) { + return invalidAttribute( + `El atributo ${definition.name} debe ser un texto de hasta 4000 caracteres`, + definition.id, + ); + } + return value.trim(); + + case AssetAttributeDataType.NUMBER: + if (typeof value !== 'number' || !Number.isFinite(value)) { + return invalidAttribute( + `El atributo ${definition.name} debe ser numérico`, + definition.id, + ); + } + return value; + + case AssetAttributeDataType.BOOLEAN: + if (typeof value !== 'boolean') { + return invalidAttribute( + `El atributo ${definition.name} debe ser verdadero o falso`, + definition.id, + ); + } + return value; + + case AssetAttributeDataType.DATE: + if (typeof value !== 'string' || !isValidCalendarDate(value)) { + return invalidAttribute( + `El atributo ${definition.name} debe ser una fecha válida`, + definition.id, + ); + } + return value; + + case AssetAttributeDataType.DATETIME: + if ( + typeof value !== 'string' || + !value.trim() || + !Number.isFinite(Date.parse(value)) + ) { + return invalidAttribute( + `El atributo ${definition.name} debe ser una fecha y hora válida`, + definition.id, + ); + } + return new Date(value).toISOString(); + + case AssetAttributeDataType.SELECT: { + const options = definition.options ?? []; + if (typeof value !== 'string' || !options.includes(value)) { + return invalidAttribute( + `El atributo ${definition.name} no contiene una opción válida`, + definition.id, + ); + } + return value; + } + } +} + +export function validateAssetAttributeValues( + definitions: AssetAttributeDefinition[], + values: Record, +): NormalizedAttributeValue[] { + const activeDefinitions = definitions.filter((definition) => definition.isActive); + const byId = new Map(activeDefinitions.map((definition) => [definition.id, definition])); + + for (const definitionId of Object.keys(values)) { + if (!byId.has(definitionId)) { + invalidAttribute('Se recibió un atributo que no pertenece al tipo de activo', definitionId); + } + } + + const normalized: NormalizedAttributeValue[] = []; + for (const definition of activeDefinitions) { + const value = values[definition.id]; + if (isMissing(value)) { + if (definition.isRequired) { + invalidAttribute(`El atributo ${definition.name} es obligatorio`, definition.id); + } + continue; + } + normalized.push({ + definitionId: definition.id, + value: normalizeValue(definition, value), + }); + } + + return normalized; +} diff --git a/api-v3/src/asset-master/asset-geometries.controller.ts b/api-v3/src/asset-master/asset-geometries.controller.ts new file mode 100644 index 0000000..2534a8a --- /dev/null +++ b/api-v3/src/asset-master/asset-geometries.controller.ts @@ -0,0 +1,65 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + ParseUUIDPipe, + Put, + Query, + Req, +} from '@nestjs/common'; +import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator'; +import { CurrentAuth } from '../auth/decorators/current-auth.decorator'; +import type { + AuthPrincipal, + RequestWithContext, +} from '../common/http/request-context'; +import { AssetGeometriesService } from './asset-geometries.service'; +import { MapAssetsQueryDto } from './dto/map-assets-query.dto'; +import { UpsertAssetGeometryDto } from './dto/upsert-asset-geometry.dto'; + +@Controller('assets') +export class AssetGeometriesController { + constructor(private readonly geometries: AssetGeometriesService) {} + + @Get(':id/geometry') + @RequirePermissions('assets.read') + get( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + ) { + return this.geometries.get(id); + } + + @Put(':id/geometry') + @RequirePermissions('assets.update_geometry') + upsert( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @Body() dto: UpsertAssetGeometryDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.geometries.upsert(id, dto, principal, request); + } + + @Delete(':id/geometry') + @RequirePermissions('assets.update_geometry') + remove( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.geometries.remove(id, principal, request); + } +} + +@Controller('map/assets') +export class MapAssetsController { + constructor(private readonly geometries: AssetGeometriesService) {} + + @Get() + @RequirePermissions('assets.read') + map(@Query() query: MapAssetsQueryDto) { + return this.geometries.map(query); + } +} diff --git a/api-v3/src/asset-master/asset-geometries.service.ts b/api-v3/src/asset-master/asset-geometries.service.ts new file mode 100644 index 0000000..fceef25 --- /dev/null +++ b/api-v3/src/asset-master/asset-geometries.service.ts @@ -0,0 +1,301 @@ +import { 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 { + Asset, + AssetGeometrySource, + AssetGeometryType, + AssetVersionChangeType, + AuditAction, +} from '../database/entities'; +import { + parseBoundingBox, + validateGeoJsonGeometry, + type GeoJsonGeometry, +} from './asset-geometry-validator'; +import type { MapAssetsQueryDto } from './dto/map-assets-query.dto'; +import type { UpsertAssetGeometryDto } from './dto/upsert-asset-geometry.dto'; +import { AssetHistoryService } from './asset-history.service'; + +export interface AssetGeometryView { + assetId: string; + geometry: GeoJsonGeometry; + geometryType: AssetGeometryType; + source: AssetGeometrySource; + accuracyM: number | null; + capturedAt: Date | null; + deviceLabel: string | null; + createdAt: Date; + updatedAt: Date; + updatedBy: string | null; +} + +interface MapAssetRow { + id: string; + geometry: GeoJsonGeometry; + code: string; + name: string; + typeId: string; + typeCode: string; + typeName: string; + parentId: string | null; + parentName: string | null; + informationStatus: string; + geometryType: AssetGeometryType; + accuracyM: number | string | null; + capturedAt: Date | null; + updatedAt: Date; +} + +function assetNotFound(): NotFoundException { + return new NotFoundException({ + code: 'ASSET_NOT_FOUND', + message: 'Activo no encontrado', + }); +} + +@Injectable() +export class AssetGeometriesService { + constructor( + private readonly dataSource: DataSource, + private readonly audit: AuditService, + private readonly history: AssetHistoryService, + ) {} + + async get(assetId: string): Promise<{ data: AssetGeometryView | null }> { + return this.dataSource.transaction(async (manager) => { + await this.requireAsset(manager, assetId); + return { data: await this.loadGeometry(manager, assetId) }; + }); + } + + async upsert( + assetId: string, + dto: UpsertAssetGeometryDto, + principal: AuthPrincipal, + request: RequestWithContext, + ): Promise { + const geometry = validateGeoJsonGeometry(dto.geometry); + return this.dataSource.transaction(async (manager) => { + await this.requireAsset(manager, assetId, true); + const before = await this.loadGeometry(manager, assetId); + const source = principal.transport === 'bearer' + ? AssetGeometrySource.ANDROID + : AssetGeometrySource.WEB; + + await manager.query( + `INSERT INTO asset_geometries ( + asset_id, geometry, geometry_type, source, accuracy_m, + captured_at, device_label, updated_by + ) VALUES ( + $1, + ST_SetSRID(ST_GeomFromGeoJSON($2::text), 4326), + $3, $4, $5, $6, $7, $8 + ) + ON CONFLICT (asset_id) DO UPDATE SET + geometry = EXCLUDED.geometry, + geometry_type = EXCLUDED.geometry_type, + source = EXCLUDED.source, + accuracy_m = EXCLUDED.accuracy_m, + captured_at = EXCLUDED.captured_at, + device_label = EXCLUDED.device_label, + updated_at = CURRENT_TIMESTAMP, + updated_by = EXCLUDED.updated_by`, + [ + assetId, + JSON.stringify(geometry), + geometry.type, + source, + dto.accuracyM ?? null, + dto.capturedAt ? new Date(dto.capturedAt) : null, + dto.deviceLabel?.trim() || null, + principal.userId, + ], + ); + const updated = await this.loadGeometry(manager, assetId); + if (!updated) throw new Error('Asset geometry was not persisted'); + const versionNumber = await this.history.capture( + manager, + assetId, + AssetVersionChangeType.GEOMETRY_UPDATED, + principal, + request, + ); + await this.audit.record( + { + ...administrationAuditContext(principal, request), + action: AuditAction.ASSET_GEOMETRY_UPDATED, + entityType: 'asset', + entityId: assetId, + beforeData: before ? this.auditGeometry(before) : null, + afterData: this.auditGeometry(updated), + metadata: { versionNumber }, + }, + manager, + ); + return updated; + }); + } + + async remove( + assetId: string, + principal: AuthPrincipal, + request: RequestWithContext, + ): Promise<{ status: 'removed' | 'absent' }> { + return this.dataSource.transaction(async (manager) => { + await this.requireAsset(manager, assetId, true); + const before = await this.loadGeometry(manager, assetId); + if (!before) return { status: 'absent' }; + await manager.query('DELETE FROM asset_geometries WHERE asset_id = $1', [assetId]); + const versionNumber = await this.history.capture( + manager, + assetId, + AssetVersionChangeType.GEOMETRY_REMOVED, + principal, + request, + ); + await this.audit.record( + { + ...administrationAuditContext(principal, request), + action: AuditAction.ASSET_GEOMETRY_REMOVED, + entityType: 'asset', + entityId: assetId, + beforeData: this.auditGeometry(before), + afterData: { geometry: null }, + metadata: { versionNumber }, + }, + manager, + ); + return { status: 'removed' }; + }); + } + + async map(query: MapAssetsQueryDto) { + const conditions: string[] = []; + const parameters: unknown[] = []; + const add = (value: unknown): string => { + parameters.push(value); + return `$${parameters.length}`; + }; + const bbox = parseBoundingBox(query.bbox); + if (bbox) { + const placeholders = bbox.map((value) => add(value)); + conditions.push( + `ST_Intersects(geometry.geometry, ST_MakeEnvelope(${placeholders.join(', ')}, 4326))`, + ); + } + if (query.typeId) conditions.push(`asset.asset_type_id = ${add(query.typeId)}`); + if (query.status) conditions.push(`asset.information_status = ${add(query.status)}`); + if (query.geometryType) conditions.push(`geometry.geometry_type = ${add(query.geometryType)}`); + const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''; + + const rows = (await this.dataSource.query( + `SELECT + asset.id, + ST_AsGeoJSON(geometry.geometry)::jsonb AS geometry, + asset.code, + asset.name, + asset_type.id AS "typeId", + asset_type.code AS "typeCode", + asset_type.name AS "typeName", + parent.id AS "parentId", + parent.name AS "parentName", + asset.information_status AS "informationStatus", + geometry.geometry_type AS "geometryType", + geometry.accuracy_m AS "accuracyM", + geometry.captured_at AS "capturedAt", + geometry.updated_at AS "updatedAt" + FROM asset_geometries geometry + INNER JOIN assets asset ON asset.id = geometry.asset_id + INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id + LEFT JOIN assets parent ON parent.id = asset.parent_id + ${where} + ORDER BY asset.name, asset.code + LIMIT 5001`, + parameters, + )) as MapAssetRow[]; + const truncated = rows.length > 5000; + const visible = truncated ? rows.slice(0, 5000) : rows; + + return { + type: 'FeatureCollection' as const, + features: visible.map((row) => ({ + type: 'Feature' as const, + id: row.id, + geometry: row.geometry, + properties: { + id: row.id, + code: row.code, + name: row.name, + typeId: row.typeId, + typeCode: row.typeCode, + typeName: row.typeName, + parentId: row.parentId, + parentName: row.parentName, + informationStatus: row.informationStatus, + geometryType: row.geometryType, + accuracyM: row.accuracyM == null ? null : Number(row.accuracyM), + capturedAt: row.capturedAt, + updatedAt: row.updatedAt, + }, + })), + meta: { count: visible.length, truncated }, + }; + } + + private async requireAsset( + manager: EntityManager, + id: string, + lock = false, + ): Promise { + if (lock) { + const [row] = (await manager.query( + 'SELECT 1 FROM assets WHERE id = $1 FOR UPDATE', + [id], + )) as unknown[]; + if (!row) throw assetNotFound(); + return; + } + const exists = await manager.getRepository(Asset).exist({ where: { id } }); + if (!exists) throw assetNotFound(); + } + + private async loadGeometry( + manager: EntityManager, + assetId: string, + ): Promise { + const [row] = (await manager.query( + `SELECT + asset_id AS "assetId", + ST_AsGeoJSON(geometry)::jsonb AS geometry, + geometry_type AS "geometryType", + source, + accuracy_m::double precision AS "accuracyM", + captured_at AS "capturedAt", + device_label AS "deviceLabel", + created_at AS "createdAt", + updated_at AS "updatedAt", + updated_by AS "updatedBy" + FROM asset_geometries + WHERE asset_id = $1`, + [assetId], + )) as AssetGeometryView[]; + return row ?? null; + } + + private auditGeometry(view: AssetGeometryView): Record { + return { + geometry: view.geometry, + geometryType: view.geometryType, + source: view.source, + accuracyM: view.accuracyM, + capturedAt: view.capturedAt, + deviceLabel: view.deviceLabel, + }; + } +} diff --git a/api-v3/src/asset-master/asset-geometry-validator.ts b/api-v3/src/asset-master/asset-geometry-validator.ts new file mode 100644 index 0000000..d9cc696 --- /dev/null +++ b/api-v3/src/asset-master/asset-geometry-validator.ts @@ -0,0 +1,93 @@ +import { BadRequestException } from '@nestjs/common'; +import { AssetGeometryType } from '../database/entities'; + +export interface GeoJsonGeometry { + type: AssetGeometryType; + coordinates: unknown[]; +} + +type Position = [number, number]; + +function invalid(message: string): never { + throw new BadRequestException({ + code: 'INVALID_ASSET_GEOMETRY', + message, + }); +} + +function position(value: unknown, label: string): Position { + if (!Array.isArray(value) || value.length < 2) { + return invalid(`${label} debe contener longitud y latitud`); + } + const longitude = Number(value[0]); + const latitude = Number(value[1]); + if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) { + return invalid(`${label} contiene coordenadas no numéricas`); + } + if (longitude < -180 || longitude > 180 || latitude < -90 || latitude > 90) { + return invalid(`${label} está fuera del rango geográfico válido`); + } + return [longitude, latitude]; +} + +function positions(value: unknown, minimum: number, label: string): Position[] { + if (!Array.isArray(value) || value.length < minimum) { + return invalid(`${label} necesita al menos ${minimum} vértices`); + } + if (value.length > 10_000) return invalid(`${label} supera el máximo de 10000 vértices`); + return value.map((item, index) => position(item, `${label} · vértice ${index + 1}`)); +} + +function samePosition(first: Position, last: Position): boolean { + return first[0] === last[0] && first[1] === last[1]; +} + +export function validateGeoJsonGeometry(input: GeoJsonGeometry): GeoJsonGeometry { + if (!Object.values(AssetGeometryType).includes(input.type)) { + return invalid('El tipo de geometría no está permitido'); + } + + if (input.type === AssetGeometryType.POINT) { + return { type: input.type, coordinates: position(input.coordinates, 'El punto') }; + } + + if (input.type === AssetGeometryType.LINESTRING) { + const line = positions(input.coordinates, 2, 'La línea'); + if (new Set(line.map((item) => item.join(','))).size < 2) { + return invalid('La línea necesita al menos dos posiciones diferentes'); + } + return { type: input.type, coordinates: line }; + } + + if (!Array.isArray(input.coordinates) || input.coordinates.length < 1) { + return invalid('El polígono necesita al menos un anillo'); + } + if (input.coordinates.length > 20) return invalid('El polígono supera el máximo de 20 anillos'); + const rings = input.coordinates.map((ring, ringIndex) => { + const normalized = positions(ring, 4, `Anillo ${ringIndex + 1}`); + if (!samePosition(normalized[0]!, normalized[normalized.length - 1]!)) { + return invalid(`El anillo ${ringIndex + 1} debe estar cerrado`); + } + if (new Set(normalized.slice(0, -1).map((item) => item.join(','))).size < 3) { + return invalid(`El anillo ${ringIndex + 1} necesita tres posiciones diferentes`); + } + return normalized; + }); + return { type: input.type, coordinates: rings }; +} + +export function parseBoundingBox(value?: string): [number, number, number, number] | null { + if (!value) return null; + const numbers = value.split(',').map(Number); + if (numbers.length !== 4 || numbers.some((item) => !Number.isFinite(item))) { + return invalid('El área visible del mapa no es válida'); + } + const [west, south, east, north] = numbers as [number, number, number, number]; + if ( + west < -180 || east > 180 || south < -90 || north > 90 || + west >= east || south >= north + ) { + return invalid('El área visible del mapa está fuera de rango'); + } + return [west, south, east, north]; +} diff --git a/api-v3/src/asset-master/asset-history.controller.ts b/api-v3/src/asset-master/asset-history.controller.ts new file mode 100644 index 0000000..6ca4368 --- /dev/null +++ b/api-v3/src/asset-master/asset-history.controller.ts @@ -0,0 +1,43 @@ +import { + Controller, + Get, + Param, + ParseIntPipe, + ParseUUIDPipe, + Query, +} from '@nestjs/common'; +import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator'; +import { AssetHistoryService } from './asset-history.service'; +import { + AssetVersionPageQueryDto, + ListAssetVersionsQueryDto, +} from './dto/list-asset-versions-query.dto'; + +@Controller() +export class AssetHistoryController { + constructor(private readonly history: AssetHistoryService) {} + + @Get('asset-versions') + @RequirePermissions('assets.read_history') + list(@Query() query: ListAssetVersionsQueryDto) { + return this.history.list(query); + } + + @Get('assets/:id/versions') + @RequirePermissions('assets.read_history') + listForAsset( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @Query() query: AssetVersionPageQueryDto, + ) { + return this.history.listForAsset(id, query); + } + + @Get('assets/:id/versions/:versionNumber') + @RequirePermissions('assets.read_history') + getVersion( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @Param('versionNumber', new ParseIntPipe()) versionNumber: number, + ) { + return this.history.getVersion(id, versionNumber); + } +} diff --git a/api-v3/src/asset-master/asset-history.service.ts b/api-v3/src/asset-master/asset-history.service.ts new file mode 100644 index 0000000..5dc216d --- /dev/null +++ b/api-v3/src/asset-master/asset-history.service.ts @@ -0,0 +1,369 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { DataSource, EntityManager } from 'typeorm'; +import type { + AuthPrincipal, + RequestWithContext, +} from '../common/http/request-context'; +import { + AssetVersionChangeType, + AuditSource, +} from '../database/entities'; +import { changedSnapshotFields } from './asset-version-diff'; +import type { + AssetVersionPageQueryDto, + ListAssetVersionsQueryDto, +} from './dto/list-asset-versions-query.dto'; + +export interface AssetVersionSummary { + id: string; + assetId: string; + assetCode: string; + assetName: string; + typeId: string; + typeName: string; + informationStatus: string; + operationalStatus: string; + versionNumber: number; + changeType: AssetVersionChangeType; + changedFields: string[]; + occurredAt: Date; + actorUserId: string | null; + actorUsername: string | null; + source: AuditSource; + requestId: string | null; + isCurrent: boolean; +} + +export interface AssetVersionDetail extends AssetVersionSummary { + snapshot: Record; +} + +function assetNotFound(): NotFoundException { + return new NotFoundException({ + code: 'ASSET_NOT_FOUND', + message: 'Activo no encontrado', + }); +} + +function versionNotFound(): NotFoundException { + return new NotFoundException({ + code: 'ASSET_VERSION_NOT_FOUND', + message: 'Versión de activo no encontrada', + }); +} + +@Injectable() +export class AssetHistoryService { + constructor(private readonly dataSource: DataSource) {} + + async capture( + manager: EntityManager, + assetId: string, + changeType: AssetVersionChangeType, + principal: AuthPrincipal, + request: RequestWithContext, + ): Promise { + await manager.query( + `UPDATE assets + SET current_version = COALESCE(current_version, 0) + 1 + WHERE id = $1`, + [assetId], + ); + const [versionRow] = (await manager.query( + `SELECT current_version + FROM assets + WHERE id = $1`, + [assetId], + )) as Array<{ current_version: number | string | null }>; + if (!versionRow) throw assetNotFound(); + + const versionNumber = Number(versionRow.current_version); + if (!Number.isInteger(versionNumber) || versionNumber < 1) { + throw new Error(`Versión de activo inválida después de incrementar: ${versionRow.current_version}`); + } + + const snapshot = await this.loadCurrentSnapshot(manager, assetId); + const [previousRow] = (await manager.query( + `SELECT snapshot + FROM asset_versions + WHERE asset_id = $1 AND version_number < $2 + ORDER BY version_number DESC + LIMIT 1`, + [assetId, versionNumber], + )) as Array<{ snapshot: Record }>; + const changedFields = changedSnapshotFields(previousRow?.snapshot ?? null, snapshot); + const source = principal.transport === 'bearer' + ? AuditSource.ANDROID + : AuditSource.WEB; + + await manager.query( + `INSERT INTO asset_versions ( + asset_id, version_number, change_type, changed_fields, snapshot, + actor_user_id, actor_username, source, request_id + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, + [ + assetId, + versionNumber, + changeType, + changedFields, + snapshot, + principal.userId, + principal.username, + source, + request.requestId, + ], + ); + return versionNumber; + } + + async list(query: ListAssetVersionsQueryDto) { + const conditions: string[] = []; + const parameters: unknown[] = []; + const add = (value: unknown): string => { + parameters.push(value); + return `$${parameters.length}`; + }; + + if (query.search?.trim()) { + const search = add(`%${query.search.trim()}%`); + conditions.push(`( + version.snapshot->>'code' ILIKE ${search} + OR version.snapshot->>'name' ILIKE ${search} + OR version.actor_username ILIKE ${search} + )`); + } + if (query.typeId) { + conditions.push(`version.snapshot #>> '{type,id}' = ${add(query.typeId)}`); + } + if (query.status) { + conditions.push(`version.snapshot->>'informationStatus' = ${add(query.status)}`); + } + if (query.changeType) { + conditions.push(`version.change_type = ${add(query.changeType)}`); + } + if (query.from) conditions.push(`version.occurred_at >= ${add(new Date(query.from))}`); + if (query.to) conditions.push(`version.occurred_at <= ${add(new Date(query.to))}`); + + return this.listWithConditions(query.page, query.pageSize, conditions, parameters); + } + + async listForAsset(assetId: string, query: AssetVersionPageQueryDto) { + await this.requireAsset(assetId); + return this.listWithConditions( + query.page, + query.pageSize, + ['version.asset_id = $1'], + [assetId], + ); + } + + async getVersion(assetId: string, versionNumber: number): Promise { + await this.requireAsset(assetId); + const [row] = (await this.dataSource.query( + `${this.selectSummary()}, version.snapshot + FROM asset_versions version + INNER JOIN assets current_asset ON current_asset.id = version.asset_id + WHERE version.asset_id = $1 AND version.version_number = $2`, + [assetId, versionNumber], + )) as AssetVersionDetail[]; + if (!row) throw versionNotFound(); + return row; + } + + private async listWithConditions( + page: number, + pageSize: number, + conditions: string[], + parameters: unknown[], + ) { + const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''; + const [countRow] = (await this.dataSource.query( + `SELECT COUNT(*)::integer AS total FROM asset_versions version ${where}`, + parameters, + )) as Array<{ total: number }>; + const total = Number(countRow?.total ?? 0); + const paginated = [...parameters, pageSize, (page - 1) * pageSize]; + const limit = `$${parameters.length + 1}`; + const offset = `$${parameters.length + 2}`; + const data = (await this.dataSource.query( + `${this.selectSummary()} + FROM asset_versions version + INNER JOIN assets current_asset ON current_asset.id = version.asset_id + ${where} + ORDER BY version.occurred_at DESC, version.version_number DESC + LIMIT ${limit} OFFSET ${offset}`, + paginated, + )) as AssetVersionSummary[]; + + return { + data, + meta: { + page, + pageSize, + total, + totalPages: total === 0 ? 0 : Math.ceil(total / pageSize), + }, + }; + } + + private selectSummary(): string { + return `SELECT + version.id, + version.asset_id AS "assetId", + version.snapshot->>'code' AS "assetCode", + version.snapshot->>'name' AS "assetName", + version.snapshot #>> '{type,id}' AS "typeId", + version.snapshot #>> '{type,name}' AS "typeName", + version.snapshot->>'informationStatus' AS "informationStatus", + version.snapshot->>'operationalStatus' AS "operationalStatus", + version.version_number AS "versionNumber", + version.change_type AS "changeType", + version.changed_fields AS "changedFields", + version.occurred_at AS "occurredAt", + version.actor_user_id AS "actorUserId", + version.actor_username AS "actorUsername", + version.source, + version.request_id AS "requestId", + (version.version_number = current_asset.current_version) AS "isCurrent"`; + } + + private async requireAsset(assetId: string): Promise { + const [row] = (await this.dataSource.query( + 'SELECT 1 FROM assets WHERE id = $1', + [assetId], + )) as unknown[]; + if (!row) throw assetNotFound(); + } + + private async loadCurrentSnapshot( + manager: EntityManager, + assetId: string, + ): Promise> { + const [row] = (await manager.query( + `SELECT JSONB_BUILD_OBJECT( + 'id', asset.id, + 'code', asset.code, + 'name', asset.name, + 'commonName', asset.common_name, + 'description', asset.description, + 'type', JSONB_BUILD_OBJECT( + 'id', asset_type.id, + 'code', asset_type.code, + 'name', asset_type.name, + 'operationalRole', asset_type.operational_role + ), + 'parent', CASE WHEN parent.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( + 'id', parent.id, + 'code', parent.code, + 'name', parent.name + ) END, + 'operationalArea', CASE WHEN operational_area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( + 'id', operational_area.id, + 'code', operational_area.code, + 'name', operational_area.name + ) END, + 'operatorCompany', CASE WHEN operator_company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( + 'id', operator_company.id, + 'code', operator_company.code, + 'name', operator_company.name + ) END, + 'informationStatus', asset.information_status, + 'operationalStatus', asset.operational_status, + 'attributes', COALESCE(( + SELECT JSONB_AGG(JSONB_BUILD_OBJECT( + 'definitionId', definition.id, + 'code', definition.code, + 'name', definition.name, + 'dataType', definition.data_type, + 'isRequired', definition.is_required, + 'unit', definition.unit, + 'options', definition.options, + 'sortOrder', definition.sort_order, + 'value', value.value + ) ORDER BY definition.sort_order, definition.name) + FROM asset_attribute_definitions definition + LEFT JOIN asset_attribute_values value + ON value.definition_id = definition.id + AND value.asset_id = asset.id + WHERE definition.asset_type_id = asset.asset_type_id + AND definition.is_active = true + ), '[]'::jsonb), + 'geometry', CASE WHEN geometry.asset_id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( + 'assetId', geometry.asset_id, + 'geometry', ST_AsGeoJSON(geometry.geometry)::jsonb, + 'geometryType', geometry.geometry_type, + 'source', geometry.source, + 'accuracyM', geometry.accuracy_m::double precision, + 'capturedAt', geometry.captured_at, + 'deviceLabel', geometry.device_label, + 'createdAt', geometry.created_at, + 'updatedAt', geometry.updated_at, + 'updatedBy', geometry.updated_by + ) END, + 'media', COALESCE(( + SELECT JSONB_AGG(JSONB_BUILD_OBJECT( + 'id', media.id, + 'kind', media.kind, + '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, + 'uploadedBy', media.uploaded_by, + 'createdAt', media.created_at, + 'updatedAt', media.updated_at + ) ORDER BY media.created_at, media.id) + FROM asset_media media + WHERE media.asset_id = asset.id + AND media.deleted_at IS NULL + ), '[]'::jsonb), + 'organizationProfile', (SELECT TO_JSONB(profile) - 'created_at' - 'updated_at' FROM organization_profiles profile WHERE profile.asset_id=asset.id), + 'organizationMemberships', COALESCE(( + SELECT JSONB_AGG(JSONB_BUILD_OBJECT('id',m.id,'parentOrganizationId',m.parent_organization_id,'memberOrganizationId',m.member_organization_id,'role',m.role,'participationPercent',m.participation_percent::double precision,'validFrom',m.valid_from,'validUntil',m.valid_until,'sourceDocumentId',m.source_document_id,'notes',m.notes,'endReason',m.end_reason) ORDER BY m.valid_until NULLS FIRST,m.valid_from DESC) + FROM organization_memberships m WHERE m.parent_organization_id=asset.id OR m.member_organization_id=asset.id + ),'[]'::jsonb), + 'externalIdentifiers', COALESCE(( + SELECT JSONB_AGG(JSONB_BUILD_OBJECT('id',i.id,'namespace',i.namespace,'value',i.value,'validFrom',i.valid_from,'validUntil',i.valid_until,'sourceDocumentId',i.source_document_id,'notes',i.notes,'endReason',i.end_reason) ORDER BY i.valid_until NULLS FIRST,i.namespace,i.valid_from DESC) FROM asset_external_identifiers i WHERE i.asset_id=asset.id + ),'[]'::jsonb), + 'sourceDocuments', COALESCE(( + SELECT JSONB_AGG(JSONB_BUILD_OBJECT('linkId',l.id,'relationType',l.relation_type,'documentId',d.id,'documentType',d.document_type,'documentNumber',d.document_number,'title',d.title,'issuer',d.issuer,'documentDate',d.document_date,'externalReference',d.external_reference) ORDER BY d.document_date DESC NULLS LAST,d.created_at DESC) FROM asset_source_documents l JOIN source_documents d ON d.id=l.document_id WHERE l.asset_id=asset.id + ),'[]'::jsonb), + 'legalRights', COALESCE(( + SELECT JSONB_AGG(JSONB_BUILD_OBJECT('id',r.id,'rightType',r.right_type,'name',r.name,'instrumentNumber',r.instrument_number,'validFrom',r.valid_from,'validUntil',r.valid_until,'status',r.status,'sourceDocumentId',r.source_document_id,'notes',r.notes,'organizations',COALESCE((SELECT JSONB_AGG(JSONB_BUILD_OBJECT('id',o.id,'organizationId',o.organization_id,'role',o.role,'participationPercent',o.participation_percent::double precision,'validFrom',o.valid_from,'validUntil',o.valid_until,'notes',o.notes,'endReason',o.end_reason) ORDER BY o.valid_until NULLS FIRST,o.valid_from DESC) FROM area_legal_right_organizations o WHERE o.right_id=r.id),'[]'::jsonb)) ORDER BY r.valid_until DESC NULLS FIRST,r.valid_from DESC NULLS LAST) FROM area_legal_rights r WHERE r.area_id=asset.id + ),'[]'::jsonb), + 'provenance', JSONB_BUILD_OBJECT( + 'origin', asset.data_origin, + 'sourceName', asset.source_name, + 'sourceReference', asset.source_reference, + 'observedAt', asset.source_observed_at, + 'notes', asset.source_notes, + 'verifiedAt', asset.provenance_verified_at, + 'verifiedBy', asset.provenance_verified_by, + 'updatedAt', asset.provenance_updated_at, + 'updatedBy', asset.provenance_updated_by + ), + 'createdAt', asset.created_at, + 'updatedAt', asset.updated_at, + 'createdBy', asset.created_by, + 'updatedBy', asset.updated_by, + 'currentVersion', asset.current_version + ) AS snapshot + FROM assets asset + INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id + LEFT JOIN assets parent ON parent.id = asset.parent_id + LEFT JOIN assets operational_area ON operational_area.id = asset.operational_area_id + LEFT JOIN assets operator_company ON operator_company.id = asset.operator_company_id + LEFT JOIN asset_geometries geometry ON geometry.asset_id = asset.id + WHERE asset.id = $1`, + [assetId], + )) as Array<{ snapshot: Record }>; + if (!row) throw assetNotFound(); + return row.snapshot; + } +} diff --git a/api-v3/src/asset-master/asset-master-bootstrap.ts b/api-v3/src/asset-master/asset-master-bootstrap.ts new file mode 100644 index 0000000..8487a7a --- /dev/null +++ b/api-v3/src/asset-master/asset-master-bootstrap.ts @@ -0,0 +1,405 @@ +import { + AssetAttributeDataType, + AssetTypeOperationalRole, +} from '../database/entities'; + +export interface MasterBootstrapAttributePreset { + code: string; + name: string; + dataType: AssetAttributeDataType; + isRequired: boolean; + unit: string | null; + options: string[] | null; + sortOrder: number; +} + +export interface MasterBootstrapTypePreset { + code: string; + name: string; + description: string; + canBeRoot: boolean; + operationalRole: AssetTypeOperationalRole; + allowedParentCodes: string[]; + attributes: MasterBootstrapAttributePreset[]; +} + +const text = (code: string, name: string, sortOrder: number, unit: string | null = null): MasterBootstrapAttributePreset => ({ + code, + name, + dataType: AssetAttributeDataType.TEXT, + isRequired: false, + unit, + options: null, + sortOrder, +}); + +const number = (code: string, name: string, sortOrder: number, unit: string | null = null): MasterBootstrapAttributePreset => ({ + code, + name, + dataType: AssetAttributeDataType.NUMBER, + isRequired: false, + unit, + options: null, + sortOrder, +}); + +const date = (code: string, name: string, sortOrder: number): MasterBootstrapAttributePreset => ({ + code, + name, + dataType: AssetAttributeDataType.DATE, + isRequired: false, + unit: null, + options: null, + sortOrder, +}); + +const select = (code: string, name: string, options: string[], sortOrder: number): MasterBootstrapAttributePreset => ({ + code, + name, + dataType: AssetAttributeDataType.SELECT, + isRequired: false, + unit: null, + options, + sortOrder, +}); + +export const MASTER_BOOTSTRAP_PRESET_CODE = 'mendoza-hidrocarburos-v2'; +export const MASTER_BOOTSTRAP_PRESET_NAME = 'Hidrocarburos · Mendoza'; + +export const MASTER_BOOTSTRAP_CORE_CODES = [ + 'area', + 'empresa', + 'yacimiento', + 'instalacion', + 'estacion', + 'subestacion', + 'pozo', + 'equipo', + 'ducto', +] as const; + +export const MASTER_BOOTSTRAP_TYPES: MasterBootstrapTypePreset[] = [ + { + code: 'area', + name: 'Área', + description: 'Área hidrocarburífera administrada como ancla territorial. Concesiones, permisos y titulares se registran por separado en la capa legal.', + canBeRoot: true, + operationalRole: AssetTypeOperationalRole.AREA, + allowedParentCodes: [], + attributes: [ + text('identificacion_oficial', 'Identificación oficial', 10), + text('cuenca', 'Cuenca', 20), + text('departamento', 'Departamento', 30), + ], + }, + { + code: 'empresa', + name: 'Organización', + description: 'Entidad jurídica u organización administrada (empresa, UTE u otra figura). Su rol como operadora, titular o participante se registra mediante relaciones históricas.', + canBeRoot: true, + operationalRole: AssetTypeOperationalRole.COMPANY, + allowedParentCodes: [], + attributes: [ + text('cuit', 'CUIT', 10), + text('razon_social', 'Razón social', 20), + ], + }, + { + code: 'yacimiento', + name: 'Yacimiento', + description: 'Unidad territorial u operativa dentro de un Área. Es opcional porque la documentación también utiliza el campo combinado Área/Yacimiento.', + canBeRoot: false, + operationalRole: AssetTypeOperationalRole.GENERIC, + allowedParentCodes: ['area'], + attributes: [text('identificacion_oficial', 'Identificación oficial', 10)], + }, + { + code: 'estructura_local', + name: 'Estructura local (fuente)', + description: 'Nodo estructural provisional que conserva la nomenclatura propia de cada operadora o Área/Yacimiento. No implica una clasificación física normalizada por DH hasta su revisión.', + canBeRoot: false, + operationalRole: AssetTypeOperationalRole.GENERIC, + allowedParentCodes: ['area', 'yacimiento', 'locacion'], + attributes: [ + text('nivel_fuente', 'Nivel informado por la fuente', 10), + text('clasificacion_fuente', 'Clasificación local informada', 20), + text('ruta_fuente', 'Ruta / nomenclatura de origen', 30), + ], + }, + { + code: 'locacion', + name: 'Locación', + description: 'Sitio físico dentro de un área o yacimiento que puede agrupar pozos, instalaciones o equipos.', + canBeRoot: false, + operationalRole: AssetTypeOperationalRole.GENERIC, + allowedParentCodes: ['area', 'yacimiento'], + attributes: [], + }, + { + code: 'instalacion', + name: 'Instalación de superficie', + description: 'Tipo genérico de instalación de proceso, tratamiento, almacenamiento o apoyo. Se conserva como alternativa cuando no exista un tipo técnico más específico.', + canBeRoot: false, + operationalRole: AssetTypeOperationalRole.GENERIC, + allowedParentCodes: ['area', 'yacimiento', 'locacion'], + attributes: [text('tipo_instalacion', 'Tipo de instalación', 10)], + }, + { + code: 'planta', + name: 'Planta', + description: 'Instalación de superficie administrada como unidad de proceso, tratamiento, entrega o almacenamiento.', + canBeRoot: false, + operationalRole: AssetTypeOperationalRole.GENERIC, + allowedParentCodes: ['area', 'yacimiento', 'locacion'], + attributes: [text('funcion_planta', 'Función / denominación operativa', 10)], + }, + { + code: 'bateria', + name: 'Batería', + description: 'Batería hidrocarburífera administrada como instalación y contenedor de sistemas/equipos.', + canBeRoot: false, + operationalRole: AssetTypeOperationalRole.GENERIC, + allowedParentCodes: ['area', 'yacimiento', 'locacion'], + attributes: [], + }, + { + code: 'estacion', + name: 'Estación', + description: 'Estación operativa perteneciente a un área, yacimiento, locación o instalación.', + canBeRoot: false, + operationalRole: AssetTypeOperationalRole.GENERIC, + allowedParentCodes: ['area', 'yacimiento', 'locacion', 'instalacion', 'planta', 'bateria'], + attributes: [], + }, + { + code: 'subestacion', + name: 'Subestación', + description: 'Subestación o unidad subordinada dentro de una instalación o estación.', + canBeRoot: false, + operationalRole: AssetTypeOperationalRole.GENERIC, + allowedParentCodes: ['area', 'yacimiento', 'locacion', 'instalacion', 'planta', 'bateria', 'estacion'], + attributes: [], + }, + { + code: 'zona_bombas', + name: 'Zona de bombas', + description: 'Sector o conjunto físico donde se agrupan bombas. Se distingue de cada equipo Bomba individual.', + canBeRoot: false, + operationalRole: AssetTypeOperationalRole.GENERIC, + allowedParentCodes: ['instalacion', 'planta', 'bateria', 'estacion'], + attributes: [], + }, + { + code: 'sistema_drenaje', + name: 'Sistema de drenaje', + description: 'Sistema de drenaje de una instalación. Puede contener piletas u otros elementos que requieran identidad propia.', + canBeRoot: false, + operationalRole: AssetTypeOperationalRole.GENERIC, + allowedParentCodes: ['instalacion', 'planta', 'bateria', 'estacion'], + attributes: [], + }, + { + code: 'sistema_electrico_iluminacion', + name: 'Sistema eléctrico / iluminación', + description: 'Sistema eléctrico y de iluminación administrable cuando requiere historial e inspección propios.', + canBeRoot: false, + operationalRole: AssetTypeOperationalRole.GENERIC, + allowedParentCodes: ['instalacion', 'planta', 'bateria', 'estacion'], + attributes: [], + }, + { + code: 'sistema_defensa_incendios', + name: 'Defensa contra incendios', + description: 'Sistema de defensa contra incendios, incluyendo red y equipos asociados cuando corresponda.', + canBeRoot: false, + operationalRole: AssetTypeOperationalRole.GENERIC, + allowedParentCodes: ['instalacion', 'planta', 'bateria', 'estacion'], + attributes: [], + }, + { + code: 'cargadero_descargadero', + name: 'Cargadero / descargadero de camiones', + description: 'Instalación utilizada para carga o descarga de camiones, inspeccionable como unidad.', + canBeRoot: false, + operationalRole: AssetTypeOperationalRole.GENERIC, + allowedParentCodes: ['area', 'yacimiento', 'locacion', 'instalacion', 'planta', 'bateria', 'estacion'], + attributes: [], + }, + { + code: 'pileta_api', + name: 'Pileta API', + description: 'Pileta API identificable dentro de una instalación o sistema de drenaje.', + canBeRoot: false, + operationalRole: AssetTypeOperationalRole.GENERIC, + allowedParentCodes: ['sistema_drenaje', 'instalacion', 'planta', 'bateria', 'estacion'], + attributes: [], + }, + { + code: 'pozo', + name: 'Pozo', + description: 'Pozo hidrocarburífero. El método o función se administra como atributo para no duplicar el Maestro en varios tipos de pozo.', + canBeRoot: false, + operationalRole: AssetTypeOperationalRole.GENERIC, + allowedParentCodes: ['area', 'yacimiento', 'locacion'], + attributes: [ + text('identificacion_oficial', 'Identificación oficial', 10), + text('tipo_pozo', 'Tipo de pozo informado', 20), + select('metodo_extraccion', 'Método / función operativa', [ + 'Bombeo mecánico', + 'Bombeo electrosumergible', + 'Bombeo de cavidad progresiva (PCP)', + 'Surgente / productor de gas', + 'Inyector de agua', + 'Otro / a validar', + ], 30), + number('profundidad', 'Profundidad', 40, 'm'), + text('estado_operativo', 'Estado operativo informado', 50), + date('ultima_intervencion', 'Última intervención informada', 60), + ], + }, + { + code: 'equipo', + name: 'Equipo', + description: 'Equipo físico genérico. Se conserva como alternativa cuando el inventario no permita clasificarlo todavía en una familia técnica específica.', + canBeRoot: false, + operationalRole: AssetTypeOperationalRole.GENERIC, + allowedParentCodes: ['instalacion', 'planta', 'bateria', 'estacion', 'subestacion', 'pozo', 'zona_bombas', 'sistema_defensa_incendios', 'cargadero_descargadero'], + attributes: [ + text('fabricante', 'Fabricante', 10), + text('modelo', 'Modelo', 20), + text('numero_serie', 'Número de serie', 30), + ], + }, + { + code: 'tanque', + name: 'Tanque', + description: 'Tanque de almacenamiento o proceso administrado como equipo individual.', + canBeRoot: false, + operationalRole: AssetTypeOperationalRole.GENERIC, + allowedParentCodes: ['instalacion', 'planta', 'bateria', 'estacion'], + attributes: [ + number('capacidad_nominal', 'Capacidad nominal', 10, 'm³'), + text('producto_servicio', 'Producto / servicio', 20), + text('fabricante', 'Fabricante', 30), + text('modelo', 'Modelo', 40), + text('numero_serie', 'Número de serie', 50), + ], + }, + { + code: 'separador', + name: 'Separador', + description: 'Separador de proceso administrado como equipo individual.', + canBeRoot: false, + operationalRole: AssetTypeOperationalRole.GENERIC, + allowedParentCodes: ['instalacion', 'planta', 'bateria', 'estacion'], + attributes: [], + }, + { + code: 'bomba', + name: 'Bomba', + description: 'Bomba individual. Puede pertenecer a una zona de bombas, sistema contra incendios, pozo u otra instalación.', + canBeRoot: false, + operationalRole: AssetTypeOperationalRole.GENERIC, + allowedParentCodes: ['instalacion', 'planta', 'bateria', 'estacion', 'zona_bombas', 'sistema_defensa_incendios', 'cargadero_descargadero', 'pozo'], + attributes: [ + text('fabricante', 'Fabricante', 10), + text('modelo', 'Modelo', 20), + text('numero_serie', 'Número de serie', 30), + ], + }, + { + code: 'caldera', + name: 'Caldera', + description: 'Caldera administrada como equipo individual con controles documentales y operativos propios.', + canBeRoot: false, + operationalRole: AssetTypeOperationalRole.GENERIC, + allowedParentCodes: ['instalacion', 'planta', 'bateria', 'estacion'], + attributes: [], + }, + { + code: 'antorcha', + name: 'Antorcha', + description: 'Antorcha administrada como equipo o instalación individual.', + canBeRoot: false, + operationalRole: AssetTypeOperationalRole.GENERIC, + allowedParentCodes: ['instalacion', 'planta', 'bateria', 'estacion'], + attributes: [], + }, + { + code: 'colector', + name: 'Colector', + description: 'Colector administrado como activo individual cuando requiere trazabilidad propia.', + canBeRoot: false, + operationalRole: AssetTypeOperationalRole.GENERIC, + allowedParentCodes: ['area', 'yacimiento', 'locacion', 'instalacion', 'planta', 'bateria', 'estacion'], + attributes: [], + }, + { + code: 'filtro', + name: 'Filtro', + description: 'Filtro administrado como equipo individual.', + canBeRoot: false, + operationalRole: AssetTypeOperationalRole.GENERIC, + allowedParentCodes: ['instalacion', 'planta', 'bateria', 'estacion'], + attributes: [], + }, + { + code: 'equipo_flotacion', + name: 'Equipo de flotación', + description: 'Equipo de flotación administrado como activo individual.', + canBeRoot: false, + operationalRole: AssetTypeOperationalRole.GENERIC, + allowedParentCodes: ['instalacion', 'planta', 'bateria', 'estacion'], + attributes: [], + }, + { + code: 'fwko', + name: 'FWKO', + description: 'Free Water Knock Out administrado como equipo individual.', + canBeRoot: false, + operationalRole: AssetTypeOperationalRole.GENERIC, + allowedParentCodes: ['instalacion', 'planta', 'bateria', 'estacion'], + attributes: [], + }, + { + code: 'tratador', + name: 'Tratador', + description: 'Tratador administrado como equipo individual.', + canBeRoot: false, + operationalRole: AssetTypeOperationalRole.GENERIC, + allowedParentCodes: ['instalacion', 'planta', 'bateria', 'estacion'], + attributes: [], + }, + { + code: 'calentador', + name: 'Calentador', + description: 'Calentador administrado como equipo individual.', + canBeRoot: false, + operationalRole: AssetTypeOperationalRole.GENERIC, + allowedParentCodes: ['instalacion', 'planta', 'bateria', 'estacion'], + attributes: [], + }, + { + code: 'ducto', + name: 'Ducto / Cañería', + description: 'Tramo de ducto o cañería administrable e inspeccionable dentro de un área, yacimiento, locación o instalación.', + canBeRoot: false, + operationalRole: AssetTypeOperationalRole.GENERIC, + allowedParentCodes: ['area', 'yacimiento', 'locacion', 'instalacion', 'planta', 'bateria', 'estacion'], + attributes: [ + text('servicio', 'Servicio / fluido', 10), + text('diametro_nominal', 'Diámetro nominal', 20), + ], + }, +]; + +for (const preset of MASTER_BOOTSTRAP_TYPES) { + if ( + preset.operationalRole === AssetTypeOperationalRole.GENERIC + && !['yacimiento', 'locacion', 'estructura_local', 'pozo', 'ducto', 'colector'].includes(preset.code) + && !preset.allowedParentCodes.includes('estructura_local') + ) { + preset.allowedParentCodes.push('estructura_local'); + } +} + diff --git a/api-v3/src/asset-master/asset-master.module.ts b/api-v3/src/asset-master/asset-master.module.ts new file mode 100644 index 0000000..d1071f9 --- /dev/null +++ b/api-v3/src/asset-master/asset-master.module.ts @@ -0,0 +1,54 @@ +import { Module } from '@nestjs/common'; +import { AuditModule } from '../audit/audit.module'; +import { AssetTypesController } from './asset-types.controller'; +import { AssetTypesService } from './asset-types.service'; +import { AssetsController } from './assets.controller'; +import { AssetsService } from './assets.service'; +import { + AssetGeometriesController, + MapAssetsController, +} from './asset-geometries.controller'; +import { AssetGeometriesService } from './asset-geometries.service'; +import { AssetHistoryController } from './asset-history.controller'; +import { AssetHistoryService } from './asset-history.service'; +import { AssetMediaController } from './asset-media.controller'; +import { AssetMediaService } from './asset-media.service'; +import { AssetProvenanceController } from './asset-provenance.controller'; +import { AssetProvenanceService } from './asset-provenance.service'; +import { AssetTemporalController } from './asset-temporal.controller'; +import { AssetTemporalService } from './asset-temporal.service'; +import { AssetOperationalRelationsController } from './asset-operational-relations.controller'; +import { AssetOperationalRelationsService } from './asset-operational-relations.service'; +import { AssetRegistryController } from './asset-registry.controller'; +import { AssetRegistryService } from './asset-registry.service'; +import { FieldDiscoveryInspectionLinkService } from '../inspection-operations/field-discovery-inspection-link.service'; + +@Module({ + imports: [AuditModule], + controllers: [ + AssetTypesController, + AssetsController, + AssetGeometriesController, + MapAssetsController, + AssetHistoryController, + AssetMediaController, + AssetProvenanceController, + AssetTemporalController, + AssetOperationalRelationsController, + AssetRegistryController, + ], + providers: [ + AssetTypesService, + AssetsService, + AssetGeometriesService, + AssetHistoryService, + AssetMediaService, + AssetProvenanceService, + AssetTemporalService, + AssetOperationalRelationsService, + AssetRegistryService, + FieldDiscoveryInspectionLinkService, + ], + exports: [AssetHistoryService], +}) +export class AssetMasterModule {} diff --git a/api-v3/src/asset-master/asset-media-file.ts b/api-v3/src/asset-master/asset-media-file.ts new file mode 100644 index 0000000..b2fc361 --- /dev/null +++ b/api-v3/src/asset-master/asset-media-file.ts @@ -0,0 +1,72 @@ +import { BadRequestException } from '@nestjs/common'; +import { AssetMediaKind } from '../database/entities'; + +export const MAX_ASSET_MEDIA_BYTES = 15 * 1024 * 1024; + +export interface UploadedAssetFile { + buffer: Buffer; + originalname: string; + mimetype?: string; + size: number; +} + +export interface InspectedAssetFile { + originalName: string; + mimeType: 'image/jpeg' | 'image/png' | 'image/webp' | 'application/pdf'; + extension: '.jpg' | '.png' | '.webp' | '.pdf'; +} + +function invalidFile(message: string): BadRequestException { + return new BadRequestException({ code: 'INVALID_ASSET_FILE', message }); +} + +function detectedType(buffer: Buffer): Omit | null { + if (buffer.length >= 4 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) { + return { mimeType: 'image/jpeg', extension: '.jpg' }; + } + if ( + buffer.length >= 8 + && buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) + ) { + return { mimeType: 'image/png', extension: '.png' }; + } + if ( + buffer.length >= 12 + && buffer.subarray(0, 4).toString('ascii') === 'RIFF' + && buffer.subarray(8, 12).toString('ascii') === 'WEBP' + ) { + return { mimeType: 'image/webp', extension: '.webp' }; + } + if (buffer.length >= 5 && buffer.subarray(0, 5).toString('ascii') === '%PDF-') { + return { mimeType: 'application/pdf', extension: '.pdf' }; + } + return null; +} + +export function inspectAssetFile( + file: UploadedAssetFile | undefined, + kind: AssetMediaKind, +): InspectedAssetFile { + if (!file?.buffer || file.size <= 0 || file.buffer.length <= 0) { + throw invalidFile('Debe seleccionar un archivo no vacío'); + } + if (file.size > MAX_ASSET_MEDIA_BYTES || file.buffer.length > MAX_ASSET_MEDIA_BYTES) { + throw invalidFile('El archivo supera el máximo permitido de 15 MB'); + } + const detected = detectedType(file.buffer); + if (!detected) { + throw invalidFile('Sólo se permiten JPG, PNG, WebP y PDF válidos'); + } + if (kind === AssetMediaKind.PHOTO && !detected.mimeType.startsWith('image/')) { + throw invalidFile('Una fotografía debe ser JPG, PNG o WebP'); + } + if (kind === AssetMediaKind.DOCUMENT && detected.mimeType !== 'application/pdf') { + throw invalidFile('Un documento debe ser un archivo PDF'); + } + const originalName = file.originalname + .replace(/[\u0000-\u001f\u007f]/g, '') + .trim() + .slice(0, 255); + if (!originalName) throw invalidFile('El nombre original del archivo no es válido'); + return { ...detected, originalName }; +} diff --git a/api-v3/src/asset-master/asset-media.controller.ts b/api-v3/src/asset-master/asset-media.controller.ts new file mode 100644 index 0000000..0900989 --- /dev/null +++ b/api-v3/src/asset-master/asset-media.controller.ts @@ -0,0 +1,106 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + ParseUUIDPipe, + Patch, + Post, + Query, + Req, + Res, + UploadedFile, + UseInterceptors, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import type { Response } from 'express'; +import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator'; +import { CurrentAuth } from '../auth/decorators/current-auth.decorator'; +import type { + AuthPrincipal, + RequestWithContext, +} from '../common/http/request-context'; +import { AssetMediaKind } from '../database/entities'; +import { + MAX_ASSET_MEDIA_BYTES, + type UploadedAssetFile, +} from './asset-media-file'; +import { AssetMediaService } from './asset-media.service'; +import { CreateAssetMediaDto } from './dto/create-asset-media.dto'; +import { UpdateAssetMediaDto } from './dto/update-asset-media.dto'; + +@Controller() +export class AssetMediaController { + constructor(private readonly media: AssetMediaService) {} + + @Get('assets/:assetId/media') + @RequirePermissions('assets.read_media') + list( + @Param('assetId', new ParseUUIDPipe({ version: '4' })) assetId: string, + ) { + return this.media.list(assetId); + } + + @Post('assets/:assetId/media') + @RequirePermissions('assets.manage_media') + @UseInterceptors(FileInterceptor('file', { + limits: { fileSize: MAX_ASSET_MEDIA_BYTES, files: 1 }, + })) + upload( + @Param('assetId', new ParseUUIDPipe({ version: '4' })) assetId: string, + @Body() dto: CreateAssetMediaDto, + @UploadedFile() file: UploadedAssetFile | undefined, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.media.upload(assetId, dto, file, principal, request); + } + + @Patch('asset-media/:mediaId') + @RequirePermissions('assets.manage_media') + update( + @Param('mediaId', new ParseUUIDPipe({ version: '4' })) mediaId: string, + @Body() dto: UpdateAssetMediaDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.media.update(mediaId, dto, principal, request); + } + + @Delete('asset-media/:mediaId') + @RequirePermissions('assets.manage_media') + remove( + @Param('mediaId', new ParseUUIDPipe({ version: '4' })) mediaId: string, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.media.remove(mediaId, principal, request); + } + + @Get('asset-media/:mediaId/content') + @RequirePermissions('assets.read_media') + async content( + @Param('mediaId', new ParseUUIDPipe({ version: '4' })) mediaId: string, + @Query('download') download: string | undefined, + @Res() response: Response, + ): Promise { + const { filePath, media } = await this.media.content(mediaId); + const forceDownload = download === '1' || media.kind === AssetMediaKind.DOCUMENT; + const disposition = forceDownload ? 'attachment' : 'inline'; + const fallbackName = media.originalName + .replace(/[^\x20-\x7e]/g, '_') + .replace(/["\\]/g, '_'); + response.setHeader('Content-Type', media.mimeType); + response.setHeader('Content-Length', String(media.sizeBytes)); + response.setHeader('Content-Disposition', `${disposition}; filename="${fallbackName}"; filename*=UTF-8''${encodeURIComponent(media.originalName)}`); + response.setHeader('Cache-Control', 'private, no-store'); + response.setHeader('X-Content-Type-Options', 'nosniff'); + await new Promise((resolveSend, rejectSend) => { + response.sendFile(filePath, (error) => { + if (error) rejectSend(error); + else resolveSend(); + }); + }); + } +} diff --git a/api-v3/src/asset-master/asset-media.service.ts b/api-v3/src/asset-master/asset-media.service.ts new file mode 100644 index 0000000..331582f --- /dev/null +++ b/api-v3/src/asset-master/asset-media.service.ts @@ -0,0 +1,402 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { mkdir, stat, unlink, writeFile } from 'node:fs/promises'; +import { isAbsolute, parse, resolve } from 'node:path'; +import { + BadRequestException, + 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 { + AssetMediaKind, + AssetMediaSource, + AssetVersionChangeType, + AuditAction, +} from '../database/entities'; +import { AssetHistoryService } from './asset-history.service'; +import { + inspectAssetFile, + type UploadedAssetFile, +} from './asset-media-file'; +import type { CreateAssetMediaDto } from './dto/create-asset-media.dto'; +import type { UpdateAssetMediaDto } from './dto/update-asset-media.dto'; + +export interface AssetMediaView { + id: string; + assetId: string; + kind: AssetMediaKind; + 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: AssetMediaSource; + uploadedBy: string | null; + uploadedByUsername: string | null; + createdAt: Date; + updatedAt: Date; +} + +interface StoredAssetMedia extends AssetMediaView { + storedName: string; +} + +function assetNotFound(): NotFoundException { + return new NotFoundException({ + code: 'ASSET_NOT_FOUND', + message: 'Activo no encontrado', + }); +} + +function mediaNotFound(): NotFoundException { + return new NotFoundException({ + code: 'ASSET_MEDIA_NOT_FOUND', + message: 'Archivo de activo no encontrado', + }); +} + +function coordinateError(): BadRequestException { + return new BadRequestException({ + code: 'INVALID_MEDIA_COORDINATES', + message: 'Latitud y longitud deben informarse juntas', + }); +} + +@Injectable() +export class AssetMediaService { + private readonly storageRoot: string; + + constructor( + private readonly dataSource: DataSource, + private readonly audit: AuditService, + private readonly history: AssetHistoryService, + config: ConfigService, + ) { + const configured = config.get('ASSET_MEDIA_ROOT') ?? '/app/storage/asset-media'; + if (!isAbsolute(configured)) { + throw new Error('ASSET_MEDIA_ROOT must be an absolute path'); + } + this.storageRoot = resolve(configured); + if (this.storageRoot === parse(this.storageRoot).root) { + throw new Error('ASSET_MEDIA_ROOT cannot be the filesystem root'); + } + } + + async list(assetId: string): Promise<{ data: AssetMediaView[] }> { + await this.requireAsset(this.dataSource.manager, assetId, false); + const rows = (await this.dataSource.query( + `${this.mediaSelect()} + WHERE media.asset_id = $1 AND media.deleted_at IS NULL + ORDER BY media.created_at DESC`, + [assetId], + )) as StoredAssetMedia[]; + return { data: rows.map(({ storedName: _storedName, ...media }) => media) }; + } + + async upload( + assetId: string, + dto: CreateAssetMediaDto, + file: UploadedAssetFile | undefined, + principal: AuthPrincipal, + request: RequestWithContext, + ): Promise { + this.validateCoordinates(dto.latitude, dto.longitude, dto.accuracyM); + const inspected = inspectAssetFile(file, dto.kind); + const id = randomUUID(); + const storedName = `${id}${inspected.extension}`; + const filePath = resolve(this.storageRoot, storedName); + const source = principal.transport === 'bearer' + ? AssetMediaSource.ANDROID + : AssetMediaSource.WEB; + const sha256 = createHash('sha256').update(file!.buffer).digest('hex'); + + await mkdir(this.storageRoot, { recursive: true, mode: 0o700 }); + await writeFile(filePath, file!.buffer, { flag: 'wx', mode: 0o600 }); + + try { + return await this.dataSource.transaction(async (manager) => { + await this.requireAsset(manager, assetId, true); + await manager.query( + `INSERT INTO asset_media ( + id, asset_id, kind, original_name, stored_name, mime_type, + size_bytes, sha256, title, description, captured_at, + latitude, longitude, accuracy_m, source, uploaded_by + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, + $12, $13, $14, $15, $16 + )`, + [ + id, + assetId, + dto.kind, + inspected.originalName, + storedName, + inspected.mimeType, + file!.buffer.length, + sha256, + dto.title?.trim() || null, + dto.description?.trim() || null, + dto.capturedAt ? new Date(dto.capturedAt) : null, + dto.latitude ?? null, + dto.longitude ?? null, + dto.accuracyM ?? null, + source, + principal.userId, + ], + ); + const created = await this.loadActive(manager, id); + const versionNumber = await this.history.capture( + manager, + assetId, + AssetVersionChangeType.MEDIA_UPLOADED, + principal, + request, + ); + await this.audit.record( + { + ...administrationAuditContext(principal, request), + action: AuditAction.ASSET_MEDIA_UPLOADED, + entityType: 'asset_media', + entityId: id, + afterData: this.auditView(created), + metadata: { assetId, versionNumber }, + }, + manager, + ); + const { storedName: _storedName, ...view } = created; + return view; + }); + } catch (error) { + await unlink(filePath).catch(() => undefined); + throw error; + } + } + + async update( + mediaId: string, + dto: UpdateAssetMediaDto, + principal: AuthPrincipal, + request: RequestWithContext, + ): Promise { + if (Object.keys(dto).length === 0) { + throw new BadRequestException({ code: 'NO_CHANGES', message: 'No se recibieron cambios' }); + } + return this.dataSource.transaction(async (manager) => { + const before = await this.loadActive(manager, mediaId, true); + const latitude = dto.latitude === undefined ? before.latitude : dto.latitude; + const longitude = dto.longitude === undefined ? before.longitude : dto.longitude; + const accuracyM = dto.accuracyM === undefined ? before.accuracyM : dto.accuracyM; + this.validateCoordinates(latitude, longitude, accuracyM); + + await manager.query( + `UPDATE asset_media SET + title = $2, + description = $3, + captured_at = $4, + latitude = $5, + longitude = $6, + accuracy_m = $7, + updated_at = CURRENT_TIMESTAMP + WHERE id = $1 AND deleted_at IS NULL`, + [ + mediaId, + dto.title === undefined ? before.title : dto.title?.trim() || null, + dto.description === undefined + ? before.description + : dto.description?.trim() || null, + dto.capturedAt === undefined + ? before.capturedAt + : dto.capturedAt ? new Date(dto.capturedAt) : null, + latitude, + longitude, + accuracyM, + ], + ); + const updated = await this.loadActive(manager, mediaId); + const versionNumber = await this.history.capture( + manager, + updated.assetId, + AssetVersionChangeType.MEDIA_UPDATED, + principal, + request, + ); + await this.audit.record( + { + ...administrationAuditContext(principal, request), + action: AuditAction.ASSET_MEDIA_UPDATED, + entityType: 'asset_media', + entityId: mediaId, + beforeData: this.auditView(before), + afterData: this.auditView(updated), + metadata: { assetId: updated.assetId, versionNumber }, + }, + manager, + ); + const { storedName: _storedName, ...view } = updated; + return view; + }); + } + + async remove( + mediaId: string, + principal: AuthPrincipal, + request: RequestWithContext, + ): Promise<{ status: 'removed' }> { + return this.dataSource.transaction(async (manager) => { + const before = await this.loadActive(manager, mediaId, true); + await manager.query( + `UPDATE asset_media + SET deleted_at = CURRENT_TIMESTAMP, + deleted_by = $2, + updated_at = CURRENT_TIMESTAMP + WHERE id = $1 AND deleted_at IS NULL`, + [mediaId, principal.userId], + ); + const versionNumber = await this.history.capture( + manager, + before.assetId, + AssetVersionChangeType.MEDIA_REMOVED, + principal, + request, + ); + await this.audit.record( + { + ...administrationAuditContext(principal, request), + action: AuditAction.ASSET_MEDIA_REMOVED, + entityType: 'asset_media', + entityId: mediaId, + beforeData: this.auditView(before), + afterData: { active: false }, + metadata: { assetId: before.assetId, versionNumber, physicalFileRetained: true }, + }, + manager, + ); + return { status: 'removed' }; + }); + } + + async content(mediaId: string): Promise<{ + filePath: string; + media: StoredAssetMedia; + }> { + const media = await this.loadActive(this.dataSource.manager, mediaId); + const filePath = resolve(this.storageRoot, media.storedName); + if (!filePath.startsWith(`${this.storageRoot}/`)) { + throw new InternalServerErrorException({ + code: 'INVALID_MEDIA_STORAGE_PATH', + message: 'Ruta de almacenamiento inválida', + }); + } + try { + const fileStat = await stat(filePath); + if (!fileStat.isFile() || fileStat.size !== media.sizeBytes) throw new Error('size mismatch'); + } catch { + throw new InternalServerErrorException({ + code: 'ASSET_MEDIA_FILE_MISSING', + message: 'El archivo físico no está disponible', + }); + } + return { filePath, media }; + } + + private validateCoordinates( + latitude: number | null | undefined, + longitude: number | null | undefined, + accuracyM: number | null | undefined, + ): void { + const hasLatitude = latitude !== null && latitude !== undefined; + const hasLongitude = longitude !== null && longitude !== undefined; + if (hasLatitude !== hasLongitude) throw coordinateError(); + if (accuracyM !== null && accuracyM !== undefined && !hasLatitude) { + throw new BadRequestException({ + code: 'MEDIA_ACCURACY_WITHOUT_COORDINATES', + message: 'La precisión GPS requiere latitud y longitud', + }); + } + } + + private async requireAsset( + manager: EntityManager, + assetId: string, + lock: boolean, + ): Promise { + const suffix = lock ? ' FOR UPDATE' : ''; + const [row] = (await manager.query( + `SELECT 1 FROM assets WHERE id = $1${suffix}`, + [assetId], + )) as unknown[]; + if (!row) throw assetNotFound(); + } + + private async loadActive( + manager: EntityManager, + mediaId: string, + lock = false, + ): Promise { + const [row] = (await manager.query( + `${this.mediaSelect()} + WHERE media.id = $1 AND media.deleted_at IS NULL + ${lock ? 'FOR UPDATE OF media' : ''}`, + [mediaId], + )) as StoredAssetMedia[]; + if (!row) throw mediaNotFound(); + return row; + } + + private mediaSelect(): string { + return `SELECT + media.id, + media.asset_id AS "assetId", + media.kind, + media.original_name AS "originalName", + media.stored_name AS "storedName", + 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.uploaded_by AS "uploadedBy", + uploader.username AS "uploadedByUsername", + media.created_at AS "createdAt", + media.updated_at AS "updatedAt" + FROM asset_media media + LEFT JOIN users uploader ON uploader.id = media.uploaded_by`; + } + + private auditView(media: StoredAssetMedia): Record { + return { + id: media.id, + assetId: media.assetId, + kind: media.kind, + originalName: media.originalName, + mimeType: media.mimeType, + sizeBytes: media.sizeBytes, + sha256: media.sha256, + title: media.title, + description: media.description, + capturedAt: media.capturedAt, + latitude: media.latitude, + longitude: media.longitude, + accuracyM: media.accuracyM, + source: media.source, + }; + } +} diff --git a/api-v3/src/asset-master/asset-operational-relations.controller.ts b/api-v3/src/asset-master/asset-operational-relations.controller.ts new file mode 100644 index 0000000..697311f --- /dev/null +++ b/api-v3/src/asset-master/asset-operational-relations.controller.ts @@ -0,0 +1,92 @@ +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 { AssetOperationalRelationsService } from './asset-operational-relations.service'; +import { CreateAreaCompanyRelationDto } from './dto/create-area-company-relation.dto'; +import { EndAreaCompanyRelationDto } from './dto/end-area-company-relation.dto'; +import { ListAreaCompanyRelationsQueryDto } from './dto/list-area-company-relations-query.dto'; +import { ListOperationalAreasQueryDto } from './dto/list-operational-areas-query.dto'; + +@Controller('asset-operational-relations') +export class AssetOperationalRelationsController { + constructor(private readonly relations: AssetOperationalRelationsService) {} + + @Get('areas') + @RequirePermissions('asset_relations.read') + areas(@Query() query: ListOperationalAreasQueryDto) { + return this.relations.listAreas(query.parentId); + } + + @Get('companies') + @RequirePermissions('asset_relations.read') + companies() { + return this.relations.listCompanies(); + } + + @Get('organizations') + @RequirePermissions('asset_relations.read') + organizations() { + return this.relations.listCompanies(); + } + + @Get('areas/:areaId/companies') + @RequirePermissions('asset_relations.read') + companiesForArea(@Param('areaId', new ParseUUIDPipe({ version: '4' })) areaId: string) { + return this.relations.listCompaniesForArea(areaId); + } + + @Get('areas/:areaId/organizations') + @RequirePermissions('asset_relations.read') + organizationsForArea(@Param('areaId', new ParseUUIDPipe({ version: '4' })) areaId: string) { + return this.relations.listCompaniesForArea(areaId); + } + + @Get('companies/:companyId/areas') + @RequirePermissions('asset_relations.read') + areasForCompany(@Param('companyId', new ParseUUIDPipe({ version: '4' })) companyId: string) { + return this.relations.listAreasForCompany(companyId); + } + + @Get('organizations/:organizationId/areas') + @RequirePermissions('asset_relations.read') + areasForOrganization(@Param('organizationId', new ParseUUIDPipe({ version: '4' })) organizationId: string) { + return this.relations.listAreasForCompany(organizationId); + } + + @Get() + @RequirePermissions('asset_relations.read') + list(@Query() query: ListAreaCompanyRelationsQueryDto) { + return this.relations.list(query); + } + + @Post() + @RequirePermissions('asset_relations.manage') + create( + @Body() dto: CreateAreaCompanyRelationDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.relations.create(dto, principal, request); + } + + @Post(':id/end') + @RequirePermissions('asset_relations.manage') + end( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @Body() dto: EndAreaCompanyRelationDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.relations.end(id, dto, principal, request); + } +} diff --git a/api-v3/src/asset-master/asset-operational-relations.service.ts b/api-v3/src/asset-master/asset-operational-relations.service.ts new file mode 100644 index 0000000..a42909a --- /dev/null +++ b/api-v3/src/asset-master/asset-operational-relations.service.ts @@ -0,0 +1,369 @@ +import { + BadRequestException, + ConflictException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { DataSource, EntityManager } from 'typeorm'; +import { AuditService } from '../audit/audit.service'; +import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; +import { AreaOrganizationRole, AssetTypeOperationalRole, AuditAction } from '../database/entities'; +import { administrationAuditContext, isUniqueViolation } from '../administration/common/administration-audit'; +import type { CreateAreaCompanyRelationDto } from './dto/create-area-company-relation.dto'; +import type { EndAreaCompanyRelationDto } from './dto/end-area-company-relation.dto'; +import type { ListAreaCompanyRelationsQueryDto } from './dto/list-area-company-relations-query.dto'; + +export interface OperationalAssetSummary { + id: string; + code: string; + name: string; + commonName: string | null; + typeName: string; +} + +export interface AreaCompanyRelationView { + id: string; + area: OperationalAssetSummary; + company: OperationalAssetSummary; + relationRole: AreaOrganizationRole; + participationPercent: number | null; + legalInstrument: string | null; + sourceDocumentId: string | null; + validFrom: Date; + validUntil: Date | null; + startReason: string; + endReason: string | null; + createdBy: { id: string; username: string } | null; + endedBy: { id: string; username: string } | null; + createdAt: Date; + updatedAt: Date; + active: boolean; + assignedAssetCount: number; +} + +@Injectable() +export class AssetOperationalRelationsService { + constructor( + private readonly dataSource: DataSource, + private readonly audit: AuditService, + ) {} + + async listAreas(parentId?: string): Promise<{ data: OperationalAssetSummary[] }> { + if (!parentId) { + return { data: await this.listAssetsByRole(AssetTypeOperationalRole.AREA) }; + } + const data = (await this.dataSource.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 current ON parent.id = current.parent_id + ) + SELECT asset.id, asset.code, asset.name, asset.common_name AS "commonName", asset_type.name AS "typeName" + FROM ancestors + INNER JOIN assets asset ON asset.id = ancestors.id + INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id + WHERE asset_type.operational_role = $2 + AND asset_type.is_active = true + AND asset.information_status <> 'INACTIVE' + ORDER BY asset.name, asset.code + `, [parentId, AssetTypeOperationalRole.AREA])) as OperationalAssetSummary[]; + return { data }; + } + + async listCompanies(): Promise<{ data: OperationalAssetSummary[] }> { + return { data: await this.listAssetsByRole(AssetTypeOperationalRole.COMPANY) }; + } + + async listCompaniesForArea(areaId: string): Promise<{ data: OperationalAssetSummary[] }> { + await this.requireAssetRole(this.dataSource.manager, areaId, AssetTypeOperationalRole.AREA); + const data = (await this.dataSource.query(` + SELECT DISTINCT company.id, company.code, company.name, company.common_name AS "commonName", company_type.name AS "typeName" + FROM ( + SELECT relation.company_id + FROM area_company_relations relation + WHERE relation.area_id = $1 + AND relation.relation_role = 'OPERATOR' + AND relation.valid_until IS NULL + UNION + SELECT asset.operator_company_id AS company_id + FROM assets asset + WHERE asset.operational_area_id = $1 + AND asset.operator_company_id IS NOT NULL + AND asset.information_status <> 'INACTIVE' + ) linked + INNER JOIN assets company ON company.id = linked.company_id + INNER JOIN asset_types company_type ON company_type.id = company.asset_type_id + WHERE company.information_status <> 'INACTIVE' + ORDER BY company.name, company.code + `, [areaId])) as OperationalAssetSummary[]; + return { data }; + } + + async listAreasForCompany(companyId: string): Promise<{ data: OperationalAssetSummary[] }> { + await this.requireAssetRole(this.dataSource.manager, companyId, AssetTypeOperationalRole.COMPANY); + const data = (await this.dataSource.query(` + SELECT DISTINCT area.id, area.code, area.name, area.common_name AS "commonName", area_type.name AS "typeName" + FROM ( + SELECT relation.area_id + FROM area_company_relations relation + WHERE relation.company_id = $1 + AND relation.relation_role = 'OPERATOR' + AND relation.valid_until IS NULL + UNION + SELECT asset.operational_area_id AS area_id + FROM assets asset + WHERE asset.operator_company_id = $1 + AND asset.operational_area_id IS NOT NULL + AND asset.information_status <> 'INACTIVE' + ) linked + INNER JOIN assets area ON area.id = linked.area_id + INNER JOIN asset_types area_type ON area_type.id = area.asset_type_id + WHERE area.information_status <> 'INACTIVE' + ORDER BY area.name, area.code + `, [companyId])) as OperationalAssetSummary[]; + return { data }; + } + + async list(query: ListAreaCompanyRelationsQueryDto): Promise<{ data: AreaCompanyRelationView[] }> { + const conditions: string[] = []; + const parameters: unknown[] = []; + const add = (value: unknown): string => { + parameters.push(value); + return `$${parameters.length}`; + }; + if (query.areaId) conditions.push(`relation.area_id = ${add(query.areaId)}`); + if (query.companyId) conditions.push(`relation.company_id = ${add(query.companyId)}`); + if (!query.includeHistory) conditions.push('relation.valid_until IS NULL'); + const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''; + const data = (await this.dataSource.query( + `${this.relationSelect(where)} ORDER BY relation.valid_until NULLS FIRST, relation.valid_from DESC`, + parameters, + )) as AreaCompanyRelationView[]; + return { data }; + } + + async create( + dto: CreateAreaCompanyRelationDto, + principal: AuthPrincipal, + request: RequestWithContext, + ): Promise { + try { + return await this.dataSource.transaction(async (manager) => { + await this.requireAssetRole(manager, dto.areaId, AssetTypeOperationalRole.AREA); + await this.requireAssetRole(manager, dto.companyId, AssetTypeOperationalRole.COMPANY); + if (dto.sourceDocumentId) { + const [document] = await manager.query('SELECT 1 FROM source_documents WHERE id=$1', [dto.sourceDocumentId]); + if (!document) throw new BadRequestException({ code: 'SOURCE_DOCUMENT_NOT_FOUND', message: 'El documento fuente no existe' }); + } + const [row] = (await manager.query(` + INSERT INTO area_company_relations ( + area_id, company_id, relation_role, participation_percent, legal_instrument, source_document_id, start_reason, created_by + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING id + `, [dto.areaId, dto.companyId, dto.relationRole, dto.participationPercent ?? null, dto.legalInstrument ?? null, dto.sourceDocumentId ?? null, dto.reason, principal.userId])) as Array<{ id: string }>; + const created = await this.loadRelation(manager, row.id); + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.ASSET_AREA_COMPANY_RELATION_CREATED, + entityType: 'area_company_relation', + entityId: row.id, + afterData: this.auditView(created), + }, manager); + return created; + }); + } catch (error) { + if (isUniqueViolation(error)) { + throw new ConflictException({ + code: 'AREA_COMPANY_RELATION_EXISTS', + message: 'La organización ya tiene ese rol activo en el área', + }); + } + throw error; + } + } + + async end( + id: string, + dto: EndAreaCompanyRelationDto, + principal: AuthPrincipal, + request: RequestWithContext, + ): Promise { + return this.dataSource.transaction(async (manager) => { + const before = await this.loadRelation(manager, id, true); + if (!before.active) { + throw new ConflictException({ + code: 'AREA_COMPANY_RELATION_ALREADY_ENDED', + message: 'La relación ya se encuentra finalizada', + }); + } + if (before.assignedAssetCount > 0) { + throw new ConflictException({ + code: 'AREA_COMPANY_RELATION_IN_USE', + message: `No se puede finalizar la relación: ${before.assignedAssetCount} activo(s) todavía dependen de esta combinación`, + }); + } + await manager.query(` + UPDATE area_company_relations + SET valid_until = CURRENT_TIMESTAMP, + end_reason = $2, + ended_by = $3, + updated_at = CURRENT_TIMESTAMP + WHERE id = $1 AND valid_until IS NULL + `, [id, dto.reason, principal.userId]); + const updated = await this.loadRelation(manager, id); + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.ASSET_AREA_COMPANY_RELATION_ENDED, + entityType: 'area_company_relation', + entityId: id, + beforeData: this.auditView(before), + afterData: this.auditView(updated), + }, manager); + return updated; + }); + } + + private async listAssetsByRole(role: AssetTypeOperationalRole): Promise { + return (await this.dataSource.query(` + SELECT asset.id, asset.code, asset.name, asset.common_name AS "commonName", asset_type.name AS "typeName" + FROM assets asset + INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id + WHERE asset_type.operational_role = $1 + AND asset_type.is_active = true + AND asset.information_status <> 'INACTIVE' + ORDER BY asset.name, asset.code + `, [role])) as OperationalAssetSummary[]; + } + + private async requireAssetRole( + manager: EntityManager, + assetId: string, + role: AssetTypeOperationalRole, + ): Promise { + const [row] = (await manager.query(` + SELECT + asset.id, + asset.information_status AS "informationStatus", + asset_type.operational_role AS role, + asset_type.is_active AS "typeActive" + FROM assets asset + INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id + WHERE asset.id = $1 + `, [assetId])) as Array<{ + id: string; + informationStatus: string; + role: AssetTypeOperationalRole; + typeActive: boolean; + }>; + if (!row) { + throw new BadRequestException({ + code: 'OPERATIONAL_ASSET_NOT_FOUND', + message: role === AssetTypeOperationalRole.AREA ? 'El área seleccionada no existe' : 'La empresa seleccionada no existe', + }); + } + if (row.role !== role) { + throw new BadRequestException({ + code: 'OPERATIONAL_ASSET_ROLE_INVALID', + message: role === AssetTypeOperationalRole.AREA + ? 'El activo seleccionado no está configurado como Área' + : 'El activo seleccionado no está configurado como Empresa', + }); + } + if (!row.typeActive || row.informationStatus === 'INACTIVE') { + throw new ConflictException({ + code: 'OPERATIONAL_ASSET_INACTIVE', + message: role === AssetTypeOperationalRole.AREA + ? 'El área o su tipo se encuentra inactivo' + : 'La empresa o su tipo se encuentra inactivo', + }); + } + } + + private async loadRelation( + manager: EntityManager, + id: string, + lock = false, + ): Promise { + if (lock) { + const rows = await manager.query( + 'SELECT id FROM area_company_relations WHERE id = $1 FOR UPDATE', + [id], + ) as unknown[]; + if (rows.length === 0) throw this.relationNotFound(); + } + const [row] = (await manager.query( + `${this.relationSelect('WHERE relation.id = $1')}`, + [id], + )) as AreaCompanyRelationView[]; + if (!row) throw this.relationNotFound(); + return row; + } + + private relationSelect(where: string): string { + return ` + SELECT + relation.id, + JSONB_BUILD_OBJECT( + 'id', area.id, 'code', area.code, 'name', area.name, 'commonName', area.common_name, 'typeName', area_type.name + ) AS area, + JSONB_BUILD_OBJECT( + 'id', company.id, 'code', company.code, 'name', company.name, 'commonName', company.common_name, 'typeName', company_type.name + ) AS company, + relation.relation_role AS "relationRole", + relation.participation_percent::double precision AS "participationPercent", + relation.legal_instrument AS "legalInstrument", + relation.source_document_id AS "sourceDocumentId", + relation.valid_from AS "validFrom", + relation.valid_until AS "validUntil", + relation.start_reason AS "startReason", + relation.end_reason AS "endReason", + CASE WHEN creator.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( + 'id', creator.id, 'username', creator.username + ) END AS "createdBy", + CASE WHEN ender.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( + 'id', ender.id, 'username', ender.username + ) END AS "endedBy", + relation.created_at AS "createdAt", + relation.updated_at AS "updatedAt", + (relation.valid_until IS NULL) AS active, + CASE WHEN relation.relation_role = 'OPERATOR' THEN (SELECT COUNT(*)::integer FROM assets asset + WHERE asset.operational_area_id = relation.area_id + AND asset.operator_company_id = relation.company_id) ELSE 0 END AS "assignedAssetCount" + FROM area_company_relations relation + INNER JOIN assets area ON area.id = relation.area_id + INNER JOIN asset_types area_type ON area_type.id = area.asset_type_id + INNER JOIN assets company ON company.id = relation.company_id + INNER JOIN asset_types company_type ON company_type.id = company.asset_type_id + LEFT JOIN users creator ON creator.id = relation.created_by + LEFT JOIN users ender ON ender.id = relation.ended_by + ${where} + `; + } + + private relationNotFound(): NotFoundException { + return new NotFoundException({ + code: 'AREA_COMPANY_RELATION_NOT_FOUND', + message: 'Relación entre área y empresa no encontrada', + }); + } + + private auditView(relation: AreaCompanyRelationView): Record { + return { + id: relation.id, + areaId: relation.area.id, + companyId: relation.company.id, + relationRole: relation.relationRole, + participationPercent: relation.participationPercent, + legalInstrument: relation.legalInstrument, + sourceDocumentId: relation.sourceDocumentId, + validFrom: relation.validFrom, + validUntil: relation.validUntil, + startReason: relation.startReason, + endReason: relation.endReason, + active: relation.active, + assignedAssetCount: relation.assignedAssetCount, + }; + } +} diff --git a/api-v3/src/asset-master/asset-provenance.controller.ts b/api-v3/src/asset-master/asset-provenance.controller.ts new file mode 100644 index 0000000..80d5779 --- /dev/null +++ b/api-v3/src/asset-master/asset-provenance.controller.ts @@ -0,0 +1,52 @@ +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Post, + Put, + Req, +} from '@nestjs/common'; +import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator'; +import { CurrentAuth } from '../auth/decorators/current-auth.decorator'; +import type { + AuthPrincipal, + RequestWithContext, +} from '../common/http/request-context'; +import { AssetProvenanceService } from './asset-provenance.service'; +import { UpdateAssetProvenanceDto } from './dto/update-asset-provenance.dto'; + +@Controller('assets/:assetId/provenance') +export class AssetProvenanceController { + constructor(private readonly provenance: AssetProvenanceService) {} + + @Get() + @RequirePermissions('assets.read_provenance') + get( + @Param('assetId', new ParseUUIDPipe({ version: '4' })) assetId: string, + ) { + return this.provenance.get(assetId); + } + + @Put() + @RequirePermissions('assets.manage_provenance') + update( + @Param('assetId', new ParseUUIDPipe({ version: '4' })) assetId: string, + @Body() dto: UpdateAssetProvenanceDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.provenance.update(assetId, dto, principal, request); + } + + @Post('verify') + @RequirePermissions('assets.verify_provenance') + verify( + @Param('assetId', new ParseUUIDPipe({ version: '4' })) assetId: string, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.provenance.verify(assetId, principal, request); + } +} diff --git a/api-v3/src/asset-master/asset-provenance.service.ts b/api-v3/src/asset-master/asset-provenance.service.ts new file mode 100644 index 0000000..0e7fe49 --- /dev/null +++ b/api-v3/src/asset-master/asset-provenance.service.ts @@ -0,0 +1,195 @@ +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 { + AssetDataOrigin, + AssetVersionChangeType, + AuditAction, +} from '../database/entities'; +import { AssetHistoryService } from './asset-history.service'; +import type { UpdateAssetProvenanceDto } from './dto/update-asset-provenance.dto'; + +export interface AssetProvenanceView { + assetId: string; + origin: AssetDataOrigin; + sourceName: string | null; + sourceReference: string | null; + observedAt: Date | null; + notes: string | null; + verifiedAt: Date | null; + verifiedBy: string | null; + verifiedByUsername: string | null; + updatedAt: Date; + updatedBy: string | null; + updatedByUsername: string | null; +} + +function assetNotFound(): NotFoundException { + return new NotFoundException({ + code: 'ASSET_NOT_FOUND', + message: 'Activo no encontrado', + }); +} + +@Injectable() +export class AssetProvenanceService { + constructor( + private readonly dataSource: DataSource, + private readonly audit: AuditService, + private readonly history: AssetHistoryService, + ) {} + + get(assetId: string): Promise { + return this.load(this.dataSource.manager, assetId); + } + + async update( + assetId: string, + dto: UpdateAssetProvenanceDto, + principal: AuthPrincipal, + request: RequestWithContext, + ): Promise { + this.validateSource(dto); + return this.dataSource.transaction(async (manager) => { + const before = await this.load(manager, assetId, true); + await manager.query( + `UPDATE assets SET + data_origin = $2, + source_name = $3, + source_reference = $4, + source_observed_at = $5, + source_notes = $6, + provenance_verified_at = NULL, + provenance_verified_by = NULL, + provenance_updated_at = CURRENT_TIMESTAMP, + provenance_updated_by = $7, + updated_at = CURRENT_TIMESTAMP, + updated_by = $7 + WHERE id = $1`, + [ + assetId, + dto.origin, + dto.sourceName?.trim() || null, + dto.sourceReference?.trim() || null, + dto.observedAt ? new Date(dto.observedAt) : null, + dto.notes?.trim() || null, + principal.userId, + ], + ); + const versionNumber = await this.history.capture( + manager, + assetId, + AssetVersionChangeType.PROVENANCE_UPDATED, + principal, + request, + ); + const updated = await this.load(manager, assetId); + await this.audit.record( + { + ...administrationAuditContext(principal, request), + action: AuditAction.ASSET_PROVENANCE_UPDATED, + entityType: 'asset_provenance', + entityId: assetId, + beforeData: { ...before }, + afterData: { ...updated }, + metadata: { versionNumber, verificationCleared: before.verifiedAt !== null }, + }, + manager, + ); + return updated; + }); + } + + async verify( + assetId: string, + principal: AuthPrincipal, + request: RequestWithContext, + ): Promise { + return this.dataSource.transaction(async (manager) => { + const before = await this.load(manager, assetId, true); + if (before.verifiedAt) return before; + await manager.query( + `UPDATE assets SET + provenance_verified_at = CURRENT_TIMESTAMP, + provenance_verified_by = $2, + provenance_updated_at = CURRENT_TIMESTAMP, + provenance_updated_by = $2, + updated_at = CURRENT_TIMESTAMP, + updated_by = $2 + WHERE id = $1`, + [assetId, principal.userId], + ); + const versionNumber = await this.history.capture( + manager, + assetId, + AssetVersionChangeType.PROVENANCE_VERIFIED, + principal, + request, + ); + const verified = await this.load(manager, assetId); + await this.audit.record( + { + ...administrationAuditContext(principal, request), + action: AuditAction.ASSET_PROVENANCE_VERIFIED, + entityType: 'asset_provenance', + entityId: assetId, + beforeData: { ...before }, + afterData: { ...verified }, + metadata: { versionNumber }, + }, + manager, + ); + return verified; + }); + } + + private validateSource(dto: UpdateAssetProvenanceDto): void { + const requiresNamedSource = dto.origin === AssetDataOrigin.PROVIDED_DOCUMENT + || dto.origin === AssetDataOrigin.IMPORT; + if (requiresNamedSource && !dto.sourceName?.trim()) { + throw new BadRequestException({ + code: 'PROVENANCE_SOURCE_REQUIRED', + message: 'La documentación recibida y las importaciones requieren identificar la fuente', + }); + } + } + + private async load( + manager: EntityManager, + assetId: string, + lock = false, + ): Promise { + const [row] = (await manager.query( + `SELECT + asset.id AS "assetId", + asset.data_origin AS origin, + asset.source_name AS "sourceName", + asset.source_reference AS "sourceReference", + asset.source_observed_at AS "observedAt", + asset.source_notes AS notes, + asset.provenance_verified_at AS "verifiedAt", + asset.provenance_verified_by AS "verifiedBy", + verifier.username AS "verifiedByUsername", + asset.provenance_updated_at AS "updatedAt", + asset.provenance_updated_by AS "updatedBy", + updater.username AS "updatedByUsername" + FROM assets asset + LEFT JOIN users verifier ON verifier.id = asset.provenance_verified_by + LEFT JOIN users updater ON updater.id = asset.provenance_updated_by + WHERE asset.id = $1 + ${lock ? 'FOR UPDATE OF asset' : ''}`, + [assetId], + )) as AssetProvenanceView[]; + if (!row) throw assetNotFound(); + return row; + } +} diff --git a/api-v3/src/asset-master/asset-registry.controller.ts b/api-v3/src/asset-master/asset-registry.controller.ts new file mode 100644 index 0000000..dd95a26 --- /dev/null +++ b/api-v3/src/asset-master/asset-registry.controller.ts @@ -0,0 +1,60 @@ +import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, Req } from '@nestjs/common'; +import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator'; +import { CurrentAuth } from '../auth/decorators/current-auth.decorator'; +import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; +import { AssetRegistryService } from './asset-registry.service'; +import { AddAreaLegalRightOrganizationDto } from './dto/add-area-legal-right-organization.dto'; +import { AddOrganizationMembershipDto } from './dto/add-organization-membership.dto'; +import { CreateAreaLegalRightDto } from './dto/create-area-legal-right.dto'; +import { CreateExternalIdentifierDto } from './dto/create-external-identifier.dto'; +import { CreateSourceDocumentDto } from './dto/create-source-document.dto'; +import { EndAreaLegalRightOrganizationDto } from './dto/end-area-legal-right-organization.dto'; +import { EndExternalIdentifierDto } from './dto/end-external-identifier.dto'; +import { EndOrganizationMembershipDto } from './dto/end-organization-membership.dto'; +import { LinkAssetSourceDocumentDto } from './dto/link-asset-source-document.dto'; +import { UpdateAreaLegalRightDto } from './dto/update-area-legal-right.dto'; +import { UpsertOrganizationProfileDto } from './dto/upsert-organization-profile.dto'; + +@Controller() +export class AssetRegistryController { + constructor(private readonly registry: AssetRegistryService) {} + + @Get('assets/:assetId/registry') @RequirePermissions('asset_registry.read') + get(@Param('assetId',new ParseUUIDPipe({version:'4'})) assetId:string){ return this.registry.get(assetId); } + + @Get('source-documents') @RequirePermissions('asset_registry.read') + documents(@Query('search') search?:string){ return this.registry.listSourceDocuments(search); } + + @Post('source-documents') @RequirePermissions('asset_registry.manage') + createDocument(@Body() dto:CreateSourceDocumentDto,@CurrentAuth() principal:AuthPrincipal,@Req() request:RequestWithContext){ return this.registry.createSourceDocument(dto,principal,request); } + + @Patch('assets/:assetId/organization-profile') @RequirePermissions('asset_registry.manage') + profile(@Param('assetId',new ParseUUIDPipe({version:'4'})) assetId:string,@Body() dto:UpsertOrganizationProfileDto,@CurrentAuth() principal:AuthPrincipal,@Req() request:RequestWithContext){ return this.registry.upsertOrganizationProfile(assetId,dto,principal,request); } + + @Post('organizations/:organizationId/memberships') @RequirePermissions('asset_registry.manage') + addMembership(@Param('organizationId',new ParseUUIDPipe({version:'4'})) id:string,@Body() dto:AddOrganizationMembershipDto,@CurrentAuth() p:AuthPrincipal,@Req() r:RequestWithContext){ return this.registry.addOrganizationMembership(id,dto,p,r); } + + @Post('organization-memberships/:id/end') @RequirePermissions('asset_registry.manage') + endMembership(@Param('id',new ParseUUIDPipe({version:'4'})) id:string,@Body() dto:EndOrganizationMembershipDto,@CurrentAuth() p:AuthPrincipal,@Req() r:RequestWithContext){ return this.registry.endOrganizationMembership(id,dto,p,r); } + + @Post('assets/:assetId/source-documents/:documentId') @RequirePermissions('asset_registry.manage') + link(@Param('assetId',new ParseUUIDPipe({version:'4'})) assetId:string,@Param('documentId',new ParseUUIDPipe({version:'4'})) documentId:string,@Body() dto:LinkAssetSourceDocumentDto,@CurrentAuth() p:AuthPrincipal,@Req() r:RequestWithContext){ return this.registry.linkDocument(assetId,documentId,dto,p,r); } + + @Post('assets/:assetId/external-identifiers') @RequirePermissions('asset_registry.manage') + identifier(@Param('assetId',new ParseUUIDPipe({version:'4'})) assetId:string,@Body() dto:CreateExternalIdentifierDto,@CurrentAuth() p:AuthPrincipal,@Req() r:RequestWithContext){ return this.registry.addExternalIdentifier(assetId,dto,p,r); } + + @Post('asset-external-identifiers/:id/end') @RequirePermissions('asset_registry.manage') + endIdentifier(@Param('id',new ParseUUIDPipe({version:'4'})) id:string,@Body() dto:EndExternalIdentifierDto,@CurrentAuth() p:AuthPrincipal,@Req() r:RequestWithContext){ return this.registry.endExternalIdentifier(id,dto,p,r); } + + @Post('areas/:areaId/legal-rights') @RequirePermissions('asset_registry.manage') + legalRight(@Param('areaId',new ParseUUIDPipe({version:'4'})) areaId:string,@Body() dto:CreateAreaLegalRightDto,@CurrentAuth() p:AuthPrincipal,@Req() r:RequestWithContext){ return this.registry.createLegalRight(areaId,dto,p,r); } + + @Patch('area-legal-rights/:rightId') @RequirePermissions('asset_registry.manage') + updateRight(@Param('rightId',new ParseUUIDPipe({version:'4'})) id:string,@Body() dto:UpdateAreaLegalRightDto,@CurrentAuth() p:AuthPrincipal,@Req() r:RequestWithContext){ return this.registry.updateLegalRight(id,dto,p,r); } + + @Post('area-legal-rights/:rightId/organizations') @RequirePermissions('asset_registry.manage') + rightOrg(@Param('rightId',new ParseUUIDPipe({version:'4'})) id:string,@Body() dto:AddAreaLegalRightOrganizationDto,@CurrentAuth() p:AuthPrincipal,@Req() r:RequestWithContext){ return this.registry.addLegalRightOrganization(id,dto,p,r); } + + @Post('area-legal-right-organizations/:id/end') @RequirePermissions('asset_registry.manage') + endRightOrg(@Param('id',new ParseUUIDPipe({version:'4'})) id:string,@Body() dto:EndAreaLegalRightOrganizationDto,@CurrentAuth() p:AuthPrincipal,@Req() r:RequestWithContext){ return this.registry.endLegalRightOrganization(id,dto,p,r); } +} diff --git a/api-v3/src/asset-master/asset-registry.service.ts b/api-v3/src/asset-master/asset-registry.service.ts new file mode 100644 index 0000000..6b20f2c --- /dev/null +++ b/api-v3/src/asset-master/asset-registry.service.ts @@ -0,0 +1,252 @@ +import { + BadRequestException, + ConflictException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { DataSource, EntityManager } from 'typeorm'; +import { AuditService } from '../audit/audit.service'; +import { administrationAuditContext, isUniqueViolation } from '../administration/common/administration-audit'; +import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; +import { + AreaLegalRight, + AreaLegalRightOrganization, + Asset, + AssetExternalIdentifier, + AssetSourceDocument, + AssetTypeOperationalRole, + AssetVersionChangeType, + AuditAction, + OrganizationKind, + OrganizationMembership, + OrganizationProfile, + SourceDocument, +} from '../database/entities'; +import { AssetHistoryService } from './asset-history.service'; +import type { AddAreaLegalRightOrganizationDto } from './dto/add-area-legal-right-organization.dto'; +import type { AddOrganizationMembershipDto } from './dto/add-organization-membership.dto'; +import type { CreateAreaLegalRightDto } from './dto/create-area-legal-right.dto'; +import type { CreateExternalIdentifierDto } from './dto/create-external-identifier.dto'; +import type { CreateSourceDocumentDto } from './dto/create-source-document.dto'; +import type { EndAreaLegalRightOrganizationDto } from './dto/end-area-legal-right-organization.dto'; +import type { EndExternalIdentifierDto } from './dto/end-external-identifier.dto'; +import type { EndOrganizationMembershipDto } from './dto/end-organization-membership.dto'; +import type { LinkAssetSourceDocumentDto } from './dto/link-asset-source-document.dto'; +import type { UpdateAreaLegalRightDto } from './dto/update-area-legal-right.dto'; +import type { UpsertOrganizationProfileDto } from './dto/upsert-organization-profile.dto'; + +function registryNotFound(entity: string): NotFoundException { + return new NotFoundException({ code: 'ASSET_REGISTRY_NOT_FOUND', message: `${entity} no encontrado` }); +} + +@Injectable() +export class AssetRegistryService { + constructor( + private readonly dataSource: DataSource, + private readonly audit: AuditService, + private readonly history: AssetHistoryService, + ) {} + + async get(assetId: string) { + await this.requireAsset(this.dataSource.manager, assetId); + const [organizationProfile] = await this.dataSource.query(` + SELECT asset_id AS "assetId", organization_kind AS "organizationKind", legal_name AS "legalName", + tax_id AS "taxId", notification_email AS "notificationEmail", notes, created_at AS "createdAt", updated_at AS "updatedAt", updated_by AS "updatedBy" + FROM organization_profiles WHERE asset_id=$1 + `, [assetId]); + const organizationMemberships = await this.dataSource.query(` + SELECT m.id, + JSONB_BUILD_OBJECT('id',p.id,'code',p.code,'name',p.name) AS parent, + JSONB_BUILD_OBJECT('id',member.id,'code',member.code,'name',member.name) AS member, + m.role, m.participation_percent::double precision AS "participationPercent", + m.valid_from AS "validFrom", m.valid_until AS "validUntil", m.source_document_id AS "sourceDocumentId", + m.notes, m.end_reason AS "endReason", m.created_at AS "createdAt", m.updated_at AS "updatedAt" + FROM organization_memberships m + JOIN assets p ON p.id=m.parent_organization_id + JOIN assets member ON member.id=m.member_organization_id + WHERE m.parent_organization_id=$1 OR m.member_organization_id=$1 + ORDER BY m.valid_until NULLS FIRST, m.valid_from DESC + `, [assetId]); + const externalIdentifiers = await this.dataSource.query(` + SELECT id, namespace, value, valid_from AS "validFrom", valid_until AS "validUntil", + source_document_id AS "sourceDocumentId", notes, end_reason AS "endReason", created_at AS "createdAt" + FROM asset_external_identifiers WHERE asset_id=$1 ORDER BY valid_until NULLS FIRST, namespace, valid_from DESC + `, [assetId]); + const sourceDocuments = await this.dataSource.query(` + SELECT link.id AS "linkId", link.relation_type AS "relationType", link.notes AS "linkNotes", + doc.id, doc.document_type AS "documentType", doc.document_number AS "documentNumber", doc.title, + doc.issuer, doc.document_date AS "documentDate", doc.external_reference AS "externalReference", doc.notes + FROM asset_source_documents link JOIN source_documents doc ON doc.id=link.document_id + WHERE link.asset_id=$1 ORDER BY doc.document_date DESC NULLS LAST, doc.created_at DESC + `, [assetId]); + const legalRights = await this.dataSource.query(` + SELECT r.id, r.right_type AS "rightType", r.name, r.instrument_number AS "instrumentNumber", + r.valid_from AS "validFrom", r.valid_until AS "validUntil", r.status, + r.source_document_id AS "sourceDocumentId", r.notes, + COALESCE((SELECT JSONB_AGG(JSONB_BUILD_OBJECT( + 'id',o.id,'organizationId',o.organization_id,'organizationName',a.name,'role',o.role, + 'participationPercent',o.participation_percent::double precision,'validFrom',o.valid_from, + 'validUntil',o.valid_until,'notes',o.notes,'endReason',o.end_reason + ) ORDER BY o.valid_until NULLS FIRST,o.valid_from DESC) + FROM area_legal_right_organizations o JOIN assets a ON a.id=o.organization_id WHERE o.right_id=r.id),'[]'::jsonb) AS organizations + FROM area_legal_rights r WHERE r.area_id=$1 ORDER BY r.valid_until DESC NULLS FIRST, r.valid_from DESC NULLS LAST + `, [assetId]); + return { organizationProfile: organizationProfile ?? null, organizationMemberships, externalIdentifiers, sourceDocuments, legalRights }; + } + + async listSourceDocuments(search?: string) { + const q = search?.trim(); + const params: unknown[] = []; + const where = q ? `WHERE title ILIKE $1 OR document_number ILIKE $1 OR issuer ILIKE $1` : ''; + if (q) params.push(`%${q}%`); + const data = await this.dataSource.query(` + SELECT id, document_type AS "documentType", document_number AS "documentNumber", title, issuer, + document_date AS "documentDate", external_reference AS "externalReference", notes, + created_at AS "createdAt", updated_at AS "updatedAt" + FROM source_documents ${where} + ORDER BY document_date DESC NULLS LAST, created_at DESC LIMIT 100 + `, params); + return { data }; + } + + async createSourceDocument(dto: CreateSourceDocumentDto, principal: AuthPrincipal, request: RequestWithContext) { + try { + return await this.dataSource.transaction(async manager => { + const doc = manager.getRepository(SourceDocument).create({ + documentType: dto.documentType, + documentNumber: dto.documentNumber ?? null, + title: dto.title, + issuer: dto.issuer ?? null, + documentDate: dto.documentDate ?? null, + externalReference: dto.externalReference ?? null, + notes: dto.notes ?? null, + createdBy: principal.userId, + updatedBy: principal.userId, + }); + await manager.getRepository(SourceDocument).save(doc); + await this.audit.record({ + ...administrationAuditContext(principal, request), action: AuditAction.SOURCE_DOCUMENT_CREATED, + entityType: 'source_document', entityId: doc.id, afterData: { ...doc }, + }, manager); + return doc; + }); + } catch (error) { + if (isUniqueViolation(error)) throw new ConflictException({ code:'SOURCE_DOCUMENT_EXISTS', message:'Ya existe un documento con ese número y emisor' }); + throw error; + } + } + + async upsertOrganizationProfile(assetId: string, dto: UpsertOrganizationProfileDto, principal: AuthPrincipal, request: RequestWithContext) { + try { + return await this.dataSource.transaction(async manager => { + const asset = await this.requireRole(manager, assetId, AssetTypeOperationalRole.COMPANY); + const repo = manager.getRepository(OrganizationProfile); + const before = await repo.findOne({ where: { assetId } }); + if (before && before.organizationKind !== dto.organizationKind) { + const [usage] = await manager.query(`SELECT 1 FROM organization_memberships WHERE (parent_organization_id=$1 OR member_organization_id=$1) AND valid_until IS NULL LIMIT 1`, [assetId]); + if (usage) throw new ConflictException({ code:'ORGANIZATION_KIND_IN_USE', message:'No se puede cambiar el tipo de organización mientras tenga una composición UTE activa' }); + } + const profile = repo.create({ ...(before ?? {}), assetId, organizationKind:dto.organizationKind, legalName:dto.legalName ?? asset.name, taxId:dto.taxId ?? null, notificationEmail:dto.notificationEmail ?? null, notes:dto.notes ?? null, updatedBy:principal.userId }); + await repo.save(profile); + const versionNumber = await this.history.capture(manager, assetId, AssetVersionChangeType.REGISTRY_UPDATED, principal, request); + await this.audit.record({ ...administrationAuditContext(principal,request), action:AuditAction.ASSET_REGISTRY_UPDATED, entityType:'organization_profile', entityId:assetId, beforeData:before ? { ...before }:null, afterData:{ ...profile }, metadata:{versionNumber} }, manager); + return profile; + }); + } catch (error) { + if (isUniqueViolation(error)) throw new ConflictException({ code:'ORGANIZATION_TAX_ID_EXISTS', message:'El identificador fiscal ya pertenece a otra organización' }); + throw error; + } + } + + async addOrganizationMembership(parentId:string, dto:AddOrganizationMembershipDto, principal:AuthPrincipal, request:RequestWithContext) { + try { + return await this.dataSource.transaction(async manager => { + await this.requireRole(manager,parentId,AssetTypeOperationalRole.COMPANY); + await manager.query('SELECT id FROM assets WHERE id=$1 FOR UPDATE',[parentId]); + await this.requireRole(manager,dto.memberOrganizationId,AssetTypeOperationalRole.COMPANY); + const parentProfile = await manager.getRepository(OrganizationProfile).findOne({where:{assetId:parentId}}); + const memberProfile = await manager.getRepository(OrganizationProfile).findOne({where:{assetId:dto.memberOrganizationId}}); + if (parentProfile?.organizationKind !== OrganizationKind.UTE) throw new BadRequestException({code:'UTE_REQUIRED',message:'La organización contenedora debe estar configurada como UTE'}); + if (memberProfile?.organizationKind === OrganizationKind.UTE) throw new BadRequestException({code:'UTE_MEMBER_INVALID',message:'Una UTE no puede ser miembro directo de otra UTE'}); + if (dto.sourceDocumentId) await this.requireDocument(manager,dto.sourceDocumentId); + const validFrom = dto.validFrom ?? this.today(); + const [overlap] = await manager.query(` + SELECT 1 FROM organization_memberships + WHERE parent_organization_id=$1 AND member_organization_id=$2 AND role=$3 + AND daterange(valid_from,COALESCE(valid_until,'infinity'::date),'[]') && daterange($4::date,'infinity'::date,'[]') + LIMIT 1 + `,[parentId,dto.memberOrganizationId,dto.role,validFrom]); + if (overlap) throw new ConflictException({code:'UTE_MEMBERSHIP_OVERLAP',message:'La participación se superpone con una vigencia histórica existente'}); + if (dto.participationPercent != null) { + const [sum] = await manager.query(`SELECT COALESCE(SUM(participation_percent),0)::double precision AS total FROM organization_memberships WHERE parent_organization_id=$1 AND valid_until IS NULL`,[parentId]); + if (Number(sum?.total ?? 0) + dto.participationPercent > 100.0001) throw new BadRequestException({code:'UTE_PARTICIPATION_EXCEEDS_100',message:'La participación activa de la UTE no puede superar el 100%'}); + } + const repo=manager.getRepository(OrganizationMembership); + const membership=repo.create({parentOrganizationId:parentId,memberOrganizationId:dto.memberOrganizationId,role:dto.role,participationPercent:dto.participationPercent == null ? null:String(dto.participationPercent),validFrom,validUntil:null,sourceDocumentId:dto.sourceDocumentId ?? null,notes:dto.notes ?? null,endReason:null,createdBy:principal.userId,endedBy:null}); + await repo.save(membership); + await this.captureAssets(manager,[parentId,dto.memberOrganizationId],principal,request); + await this.audit.record({ ...administrationAuditContext(principal,request),action:AuditAction.ASSET_REGISTRY_UPDATED,entityType:'organization_membership',entityId:membership.id,afterData:{...membership}},manager); + return membership; + }); + } catch(error){ if(isUniqueViolation(error)) throw new ConflictException({code:'UTE_MEMBERSHIP_EXISTS',message:'La participación ya se encuentra activa'}); throw error; } + } + + async endOrganizationMembership(id:string,dto:EndOrganizationMembershipDto,principal:AuthPrincipal,request:RequestWithContext){ + return this.dataSource.transaction(async manager=>{ + const repo=manager.getRepository(OrganizationMembership); + const membership=await repo.createQueryBuilder('m').where('m.id=:id',{id}).setLock('pessimistic_write').getOne(); + if(!membership) throw registryNotFound('Participación de organización'); + if(membership.validUntil) throw new ConflictException({code:'UTE_MEMBERSHIP_ENDED',message:'La participación ya está finalizada'}); + const end=dto.validUntil ?? this.today(); this.validateEndDate(end,membership.validFrom); + membership.validUntil=end; membership.endReason=dto.reason; membership.endedBy=principal.userId; await repo.save(membership); + await this.captureAssets(manager,[membership.parentOrganizationId,membership.memberOrganizationId],principal,request); + await this.audit.record({...administrationAuditContext(principal,request),action:AuditAction.ASSET_REGISTRY_UPDATED,entityType:'organization_membership',entityId:id,afterData:{...membership}},manager); + return membership; + }); + } + + async linkDocument(assetId:string,documentId:string,dto:LinkAssetSourceDocumentDto,principal:AuthPrincipal,request:RequestWithContext){ + try { return await this.dataSource.transaction(async manager=>{ + await this.requireAsset(manager,assetId); await this.requireDocument(manager,documentId); + const repo=manager.getRepository(AssetSourceDocument); const link=repo.create({assetId,documentId,relationType:dto.relationType,notes:dto.notes??null,createdBy:principal.userId}); await repo.save(link); + const versionNumber=await this.history.capture(manager,assetId,AssetVersionChangeType.REGISTRY_UPDATED,principal,request); + await this.audit.record({...administrationAuditContext(principal,request),action:AuditAction.ASSET_REGISTRY_UPDATED,entityType:'asset_source_document',entityId:link.id,afterData:{...link},metadata:{versionNumber}},manager); return link; + }); } catch(error){ if(isUniqueViolation(error)) throw new ConflictException({code:'ASSET_DOCUMENT_LINK_EXISTS',message:'El documento ya está vinculado al activo con ese tipo de relación'}); throw error; } + } + + async addExternalIdentifier(assetId:string,dto:CreateExternalIdentifierDto,principal:AuthPrincipal,request:RequestWithContext){ + try { return await this.dataSource.transaction(async manager=>{ + await this.requireAsset(manager,assetId); if(dto.sourceDocumentId) await this.requireDocument(manager,dto.sourceDocumentId); + const repo=manager.getRepository(AssetExternalIdentifier); const identifier=repo.create({assetId,namespace:dto.namespace,value:dto.value,validFrom:dto.validFrom ? new Date(dto.validFrom):new Date(),validUntil:null,sourceDocumentId:dto.sourceDocumentId??null,notes:dto.notes??null,endReason:null,createdBy:principal.userId,endedBy:null}); await repo.save(identifier); + const versionNumber=await this.history.capture(manager,assetId,AssetVersionChangeType.REGISTRY_UPDATED,principal,request); await this.audit.record({...administrationAuditContext(principal,request),action:AuditAction.ASSET_REGISTRY_UPDATED,entityType:'asset_external_identifier',entityId:identifier.id,afterData:{...identifier},metadata:{versionNumber}},manager); return identifier; + }); } catch(error){ if(isUniqueViolation(error)) throw new ConflictException({code:'EXTERNAL_IDENTIFIER_EXISTS',message:'Ese identificador externo ya está activo'}); throw error; } + } + + async endExternalIdentifier(id:string,dto:EndExternalIdentifierDto,principal:AuthPrincipal,request:RequestWithContext){ + return this.dataSource.transaction(async manager=>{ const repo=manager.getRepository(AssetExternalIdentifier); const item=await repo.createQueryBuilder('i').where('i.id=:id',{id}).setLock('pessimistic_write').getOne(); if(!item) throw registryNotFound('Identificador'); if(item.validUntil) throw new ConflictException({code:'EXTERNAL_IDENTIFIER_ENDED',message:'El identificador ya está finalizado'}); item.validUntil=new Date(); item.endReason=dto.reason; item.endedBy=principal.userId; await repo.save(item); const versionNumber=await this.history.capture(manager,item.assetId,AssetVersionChangeType.REGISTRY_UPDATED,principal,request); await this.audit.record({...administrationAuditContext(principal,request),action:AuditAction.ASSET_REGISTRY_UPDATED,entityType:'asset_external_identifier',entityId:id,afterData:{...item},metadata:{versionNumber}},manager); return item; }); + } + + async createLegalRight(areaId:string,dto:CreateAreaLegalRightDto,principal:AuthPrincipal,request:RequestWithContext){ + return this.dataSource.transaction(async manager=>{ await this.requireRole(manager,areaId,AssetTypeOperationalRole.AREA); if(dto.sourceDocumentId) await this.requireDocument(manager,dto.sourceDocumentId); this.validateDateRange(dto.validFrom??null,dto.validUntil??null); const repo=manager.getRepository(AreaLegalRight); const right=repo.create({areaId,rightType:dto.rightType,name:dto.name,instrumentNumber:dto.instrumentNumber??null,validFrom:dto.validFrom??null,validUntil:dto.validUntil??null,status:dto.status,sourceDocumentId:dto.sourceDocumentId??null,notes:dto.notes??null,createdBy:principal.userId,updatedBy:principal.userId}); await repo.save(right); const versionNumber=await this.history.capture(manager,areaId,AssetVersionChangeType.REGISTRY_UPDATED,principal,request); await this.audit.record({...administrationAuditContext(principal,request),action:AuditAction.AREA_LEGAL_RIGHT_CREATED,entityType:'area_legal_right',entityId:right.id,afterData:{...right},metadata:{versionNumber}},manager); return right; }); + } + + async updateLegalRight(id:string,dto:UpdateAreaLegalRightDto,principal:AuthPrincipal,request:RequestWithContext){ + return this.dataSource.transaction(async manager=>{ const repo=manager.getRepository(AreaLegalRight); const right=await repo.createQueryBuilder('r').where('r.id=:id',{id}).setLock('pessimistic_write').getOne(); if(!right) throw registryNotFound('Derecho hidrocarburífero'); const before={...right}; if(dto.sourceDocumentId) await this.requireDocument(manager,dto.sourceDocumentId); const validFrom=dto.validFrom===undefined?right.validFrom:dto.validFrom; const validUntil=dto.validUntil===undefined?right.validUntil:dto.validUntil; this.validateDateRange(validFrom,validUntil); if(dto.name!==undefined)right.name=dto.name;if(dto.instrumentNumber!==undefined)right.instrumentNumber=dto.instrumentNumber;if(dto.validFrom!==undefined)right.validFrom=dto.validFrom;if(dto.validUntil!==undefined)right.validUntil=dto.validUntil;if(dto.status!==undefined)right.status=dto.status;if(dto.sourceDocumentId!==undefined)right.sourceDocumentId=dto.sourceDocumentId;if(dto.notes!==undefined)right.notes=dto.notes;right.updatedBy=principal.userId;await repo.save(right);const versionNumber=await this.history.capture(manager,right.areaId,AssetVersionChangeType.REGISTRY_UPDATED,principal,request);await this.audit.record({...administrationAuditContext(principal,request),action:AuditAction.AREA_LEGAL_RIGHT_UPDATED,entityType:'area_legal_right',entityId:id,beforeData:before,afterData:{...right},metadata:{reason:dto.reason,versionNumber}},manager);return right; }); + } + + async addLegalRightOrganization(rightId:string,dto:AddAreaLegalRightOrganizationDto,principal:AuthPrincipal,request:RequestWithContext){ + try { return await this.dataSource.transaction(async manager=>{ const right=await manager.getRepository(AreaLegalRight).createQueryBuilder('r').where('r.id=:rightId',{rightId}).setLock('pessimistic_write').getOne(); if(!right) throw registryNotFound('Derecho hidrocarburífero'); await this.requireRole(manager,dto.organizationId,AssetTypeOperationalRole.COMPANY); const validFrom=dto.validFrom??this.today(); const [overlap]=await manager.query(`SELECT 1 FROM area_legal_right_organizations WHERE right_id=$1 AND organization_id=$2 AND role=$3 AND daterange(valid_from,COALESCE(valid_until,'infinity'::date),'[]') && daterange($4::date,'infinity'::date,'[]') LIMIT 1`,[rightId,dto.organizationId,dto.role,validFrom]); if(overlap) throw new ConflictException({code:'LEGAL_RIGHT_ORGANIZATION_OVERLAP',message:'La participación se superpone con una vigencia histórica existente'}); const repo=manager.getRepository(AreaLegalRightOrganization); const item=repo.create({rightId,organizationId:dto.organizationId,role:dto.role,participationPercent:dto.participationPercent==null?null:String(dto.participationPercent),validFrom,validUntil:null,notes:dto.notes??null,endReason:null,createdBy:principal.userId,endedBy:null}); await repo.save(item); await this.captureAssets(manager,[right.areaId,dto.organizationId],principal,request); await this.audit.record({...administrationAuditContext(principal,request),action:AuditAction.ASSET_REGISTRY_UPDATED,entityType:'area_legal_right_organization',entityId:item.id,afterData:{...item}},manager); return item; }); } catch(error){ if(isUniqueViolation(error)) throw new ConflictException({code:'LEGAL_RIGHT_ORGANIZATION_EXISTS',message:'La organización ya tiene ese rol activo en el derecho'}); throw error; } + } + + async endLegalRightOrganization(id:string,dto:EndAreaLegalRightOrganizationDto,principal:AuthPrincipal,request:RequestWithContext){ + return this.dataSource.transaction(async manager=>{ const repo=manager.getRepository(AreaLegalRightOrganization); const item=await repo.createQueryBuilder('o').where('o.id=:id',{id}).setLock('pessimistic_write').getOne(); if(!item) throw registryNotFound('Participación legal'); if(item.validUntil) throw new ConflictException({code:'LEGAL_RIGHT_ORGANIZATION_ENDED',message:'La participación ya está finalizada'}); const end=dto.validUntil??this.today();this.validateEndDate(end,item.validFrom);item.validUntil=end;item.endReason=dto.reason;item.endedBy=principal.userId;await repo.save(item);const right=await manager.getRepository(AreaLegalRight).findOne({where:{id:item.rightId}});if(right)await this.captureAssets(manager,[right.areaId,item.organizationId],principal,request);await this.audit.record({...administrationAuditContext(principal,request),action:AuditAction.AREA_LEGAL_RIGHT_ORGANIZATION_ENDED,entityType:'area_legal_right_organization',entityId:id,afterData:{...item}},manager);return item; }); + } + + private async captureAssets(manager:EntityManager,ids:string[],principal:AuthPrincipal,request:RequestWithContext){ for(const id of [...new Set(ids)]) await this.history.capture(manager,id,AssetVersionChangeType.REGISTRY_UPDATED,principal,request); } + private async requireAsset(manager:EntityManager,id:string):Promise{ const asset=await manager.getRepository(Asset).findOne({where:{id}}); if(!asset) throw registryNotFound('Activo'); return asset; } + private async requireRole(manager:EntityManager,id:string,role:AssetTypeOperationalRole):Promise{ const [row]=await manager.query(`SELECT a.id FROM assets a JOIN asset_types t ON t.id=a.asset_type_id WHERE a.id=$1 AND t.operational_role=$2`,[id,role]); if(!row) throw new BadRequestException({code:'ASSET_ROLE_INVALID',message:role===AssetTypeOperationalRole.AREA?'El activo debe ser un Área':'El activo debe ser una Organización'}); return this.requireAsset(manager,id); } + private async requireDocument(manager:EntityManager,id:string){ const doc=await manager.getRepository(SourceDocument).findOne({where:{id}}); if(!doc) throw registryNotFound('Documento fuente'); return doc; } + private today(){ return new Date().toISOString().slice(0,10); } + private validateEndDate(end:string,start:string){ const today=this.today(); if(endtoday) throw new BadRequestException({code:'FUTURE_END_DATE',message:'La fecha de finalización no puede estar en el futuro'}); } + private validateDateRange(start:string|null,end:string|null){ if(start&&end&&end; + asOf: Date; +} + +function temporalAssetNotFound(): NotFoundException { + return new NotFoundException({ + code: 'ASSET_NOT_EXISTING_AT_DATE', + message: 'El activo no tenía una versión registrada en la fecha solicitada', + }); +} + +@Injectable() +export class AssetTemporalService { + constructor(private readonly dataSource: DataSource) {} + + async list(query: ListTemporalAssetsQueryDto) { + const parameters: unknown[] = [new Date(query.at)]; + const conditions: string[] = []; + const add = (value: unknown): string => { + parameters.push(value); + return `$${parameters.length}`; + }; + + if (query.search?.trim()) { + const search = add(`%${query.search.trim()}%`); + conditions.push(`( + version.snapshot->>'code' ILIKE ${search} + OR version.snapshot->>'name' ILIKE ${search} + )`); + } + if (query.typeId) { + conditions.push(`version.snapshot #>> '{type,id}' = ${add(query.typeId)}`); + } + if (query.status) { + conditions.push(`version.snapshot->>'informationStatus' = ${add(query.status)}`); + } + const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''; + const selected = this.selectedAt('$1'); + const [countRow] = (await this.dataSource.query( + `WITH selected_version AS (${selected}) + SELECT COUNT(*)::integer AS total + FROM selected_version version + ${where}`, + parameters, + )) as Array<{ total: number }>; + const total = Number(countRow?.total ?? 0); + const paginated = [ + ...parameters, + query.pageSize, + (query.page - 1) * query.pageSize, + ]; + const limit = `$${parameters.length + 1}`; + const offset = `$${parameters.length + 2}`; + const data = (await this.dataSource.query( + `WITH selected_version AS (${selected}) + ${this.selectSummary()} + FROM selected_version version + INNER JOIN assets current_asset ON current_asset.id = version.asset_id + LEFT JOIN LATERAL ( + SELECT candidate.occurred_at + FROM asset_versions candidate + WHERE candidate.asset_id = version.asset_id + AND candidate.version_number > version.version_number + ORDER BY candidate.version_number ASC + LIMIT 1 + ) next_version ON true + ${where} + ORDER BY "assetName" ASC, "assetCode" ASC + LIMIT ${limit} OFFSET ${offset}`, + paginated, + )) as TemporalAssetSummary[]; + + return { + data, + asOf: new Date(query.at), + meta: { + page: query.page, + pageSize: query.pageSize, + total, + totalPages: total === 0 ? 0 : Math.ceil(total / query.pageSize), + }, + }; + } + + async get(assetId: string, query: TemporalAtQueryDto): Promise { + const [row] = (await this.dataSource.query( + `${this.selectSummary()}, version.snapshot + FROM asset_versions version + INNER JOIN assets current_asset ON current_asset.id = version.asset_id + LEFT JOIN LATERAL ( + SELECT candidate.occurred_at + FROM asset_versions candidate + WHERE candidate.asset_id = version.asset_id + AND candidate.version_number > version.version_number + ORDER BY candidate.version_number ASC + LIMIT 1 + ) next_version ON true + WHERE version.asset_id = $1 + AND version.occurred_at <= $2 + ORDER BY version.occurred_at DESC, version.version_number DESC + LIMIT 1`, + [assetId, new Date(query.at)], + )) as TemporalAssetDetail[]; + if (!row) throw temporalAssetNotFound(); + const asOf = new Date(query.at); + row.asOf = asOf; + const [context] = (await this.dataSource.query(` + SELECT + CASE WHEN parent.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',parent.id,'code',parent.code,'name',parent.name) END AS parent, + CASE WHEN area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',area.id,'code',area.code,'name',area.name) END AS "operationalArea", + CASE WHEN company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',company.id,'code',company.code,'name',company.name) END AS "operatorCompany" + FROM asset_context_history history + LEFT JOIN assets parent ON parent.id=history.parent_id + LEFT JOIN assets area ON area.id=history.operational_area_id + LEFT JOIN assets company ON company.id=history.operator_company_id + WHERE history.asset_id=$1 + AND history.valid_from <= $2 + AND (history.valid_until IS NULL OR history.valid_until > $2) + ORDER BY history.valid_from DESC + LIMIT 1 + `, [assetId, asOf])) as Array<{ parent: Record | null; operationalArea: Record | null; operatorCompany: Record | null }>; + if (context && row.snapshot) { + row.snapshot = { + ...row.snapshot, + parent: context.parent, + operationalArea: context.operationalArea, + operatorCompany: context.operatorCompany, + }; + } + return row; + } + + private selectedAt(atParameter: string): string { + return `SELECT DISTINCT ON (candidate.asset_id) candidate.* + FROM asset_versions candidate + WHERE candidate.occurred_at <= ${atParameter} + ORDER BY candidate.asset_id, candidate.occurred_at DESC, candidate.version_number DESC`; + } + + private selectSummary(): string { + return `SELECT + version.id, + version.asset_id AS "assetId", + version.snapshot->>'code' AS "assetCode", + version.snapshot->>'name' AS "assetName", + version.snapshot #>> '{type,id}' AS "typeId", + version.snapshot #>> '{type,name}' AS "typeName", + version.snapshot->>'informationStatus' AS "informationStatus", + version.snapshot->>'operationalStatus' AS "operationalStatus", + version.version_number AS "versionNumber", + version.change_type AS "changeType", + version.changed_fields AS "changedFields", + version.occurred_at AS "occurredAt", + next_version.occurred_at AS "effectiveUntil", + version.actor_user_id AS "actorUserId", + version.actor_username AS "actorUsername", + version.source, + version.request_id AS "requestId", + (version.version_number = current_asset.current_version) AS "isCurrent"`; + } +} diff --git a/api-v3/src/asset-master/asset-types.controller.ts b/api-v3/src/asset-master/asset-types.controller.ts new file mode 100644 index 0000000..f28cd67 --- /dev/null +++ b/api-v3/src/asset-master/asset-types.controller.ts @@ -0,0 +1,118 @@ +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Patch, + Post, + Req, +} from '@nestjs/common'; +import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator'; +import { CurrentAuth } from '../auth/decorators/current-auth.decorator'; +import type { + AuthPrincipal, + RequestWithContext, +} from '../common/http/request-context'; +import { AssetTypesService } from './asset-types.service'; +import { CreateAssetTypeDto } from './dto/create-asset-type.dto'; +import { UpdateAssetTypeDto } from './dto/update-asset-type.dto'; +import { CreateAttributeDefinitionDto } from './dto/create-attribute-definition.dto'; +import { UpdateAttributeDefinitionDto } from './dto/update-attribute-definition.dto'; + +@Controller('asset-types') +export class AssetTypesController { + constructor(private readonly assetTypes: AssetTypesService) {} + + @Get() + @RequirePermissions('asset_types.read') + list() { + return this.assetTypes.list(); + } + + @Get('bootstrap-status') + @RequirePermissions('asset_types.read') + bootstrapStatus() { + return this.assetTypes.bootstrapStatus(); + } + + @Post('bootstrap-defaults') + @RequirePermissions('asset_types.manage') + bootstrapDefaults( + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.assetTypes.bootstrapDefaults(principal, request); + } + + @Get('enrichment-status') + @RequirePermissions('asset_types.read') + enrichmentStatus() { + return this.assetTypes.enrichmentStatus(); + } + + @Post('enrich-defaults') + @RequirePermissions('asset_types.manage') + enrichDefaults( + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.assetTypes.enrichDefaults(principal, request); + } + + @Post() + @RequirePermissions('asset_types.manage') + create( + @Body() dto: CreateAssetTypeDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.assetTypes.create(dto, principal, request); + } + + @Get(':id') + @RequirePermissions('asset_types.read') + getById(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) { + return this.assetTypes.getById(id); + } + + @Patch(':id') + @RequirePermissions('asset_types.manage') + update( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @Body() dto: UpdateAssetTypeDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.assetTypes.update(id, dto, principal, request); + } + + @Post(':id/attributes') + @RequirePermissions('asset_types.manage') + createAttribute( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @Body() dto: CreateAttributeDefinitionDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.assetTypes.createAttribute(id, dto, principal, request); + } + + @Patch(':id/attributes/:attributeId') + @RequirePermissions('asset_types.manage') + updateAttribute( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @Param('attributeId', new ParseUUIDPipe({ version: '4' })) attributeId: string, + @Body() dto: UpdateAttributeDefinitionDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.assetTypes.updateAttribute( + id, + attributeId, + dto, + principal, + request, + ); + } +} diff --git a/api-v3/src/asset-master/asset-types.service.ts b/api-v3/src/asset-master/asset-types.service.ts new file mode 100644 index 0000000..9a6c3c7 --- /dev/null +++ b/api-v3/src/asset-master/asset-types.service.ts @@ -0,0 +1,905 @@ +import { + BadRequestException, + ConflictException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { DataSource, EntityManager, In } from 'typeorm'; +import { AuditService } from '../audit/audit.service'; +import type { + AuthPrincipal, + RequestWithContext, +} from '../common/http/request-context'; +import { + AssetAttributeDataType, + AssetAttributeDefinition, + AssetType, + AssetTypeOperationalRole, + AssetTypeParentRule, + AuditAction, +} from '../database/entities'; +import { + administrationAuditContext, + isUniqueViolation, +} from '../administration/common/administration-audit'; +import type { CreateAssetTypeDto } from './dto/create-asset-type.dto'; +import type { UpdateAssetTypeDto } from './dto/update-asset-type.dto'; +import type { CreateAttributeDefinitionDto } from './dto/create-attribute-definition.dto'; +import type { UpdateAttributeDefinitionDto } from './dto/update-attribute-definition.dto'; +import { + MASTER_BOOTSTRAP_PRESET_CODE, + MASTER_BOOTSTRAP_PRESET_NAME, + MASTER_BOOTSTRAP_TYPES, + MASTER_BOOTSTRAP_CORE_CODES, +} from './asset-master-bootstrap'; + +export interface AssetAttributeDefinitionView { + id: string; + code: string; + name: string; + dataType: AssetAttributeDataType; + isRequired: boolean; + isActive: boolean; + unit: string | null; + options: string[] | null; + sortOrder: number; +} + +export interface AssetTypeSummary { + id: string; + code: string; + name: string; + isActive: boolean; + operationalRole: AssetTypeOperationalRole; +} + +export interface AssetTypeView extends AssetTypeSummary { + description: string; + canBeRoot: boolean; + createdAt: Date; + updatedAt: Date; + assetCount: number; + allowedParentTypes: AssetTypeSummary[]; + attributes: AssetAttributeDefinitionView[]; +} + +export interface MasterBootstrapStatus { + presetCode: string; + presetName: string; + typeCount: number; + canApply: boolean; + reason: string | null; + types: Array<{ + code: string; + name: string; + operationalRole: AssetTypeOperationalRole; + parentCodes: string[]; + attributeCount: number; + }>; +} + +export interface MasterBootstrapResult { + presetCode: string; + presetName: string; + createdTypeCount: number; + createdAttributeCount: number; + createdParentRuleCount: number; + data: AssetTypeView[]; +} + +export interface MasterEnrichmentStatus { + presetCode: string; + presetName: string; + typeCount: number; + canApply: boolean; + complete: boolean; + reason: string | null; + missingTypeCodes: string[]; + missingAttributeCount: number; + missingParentRuleCount: number; +} + +function typeNotFound(): NotFoundException { + return new NotFoundException({ + code: 'ASSET_TYPE_NOT_FOUND', + message: 'Tipo de activo no encontrado', + }); +} + +function attributeNotFound(): NotFoundException { + return new NotFoundException({ + code: 'ASSET_ATTRIBUTE_NOT_FOUND', + message: 'Atributo configurable no encontrado', + }); +} + +@Injectable() +export class AssetTypesService { + constructor( + private readonly dataSource: DataSource, + private readonly audit: AuditService, + ) {} + + async bootstrapStatus(): Promise { + const [row] = (await this.dataSource.query( + 'SELECT COUNT(*)::integer AS count FROM asset_types', + )) as Array<{ count: number }>; + const typeCount = Number(row?.count ?? 0); + return { + presetCode: MASTER_BOOTSTRAP_PRESET_CODE, + presetName: MASTER_BOOTSTRAP_PRESET_NAME, + typeCount, + canApply: typeCount === 0, + reason: typeCount === 0 + ? null + : 'La configuración inicial sólo puede aplicarse cuando el Maestro no tiene tipos de activo.', + types: MASTER_BOOTSTRAP_TYPES.map((type) => ({ + code: type.code, + name: type.name, + operationalRole: type.operationalRole, + parentCodes: [...type.allowedParentCodes], + attributeCount: type.attributes.length, + })), + }; + } + + async bootstrapDefaults( + principal: AuthPrincipal, + request: RequestWithContext, + ): Promise { + return this.dataSource.transaction(async (manager) => { + await manager.query('LOCK TABLE asset_types IN SHARE ROW EXCLUSIVE MODE'); + const [existing] = (await manager.query( + 'SELECT COUNT(*)::integer AS count FROM asset_types', + )) as Array<{ count: number }>; + if (Number(existing?.count ?? 0) !== 0) { + throw new ConflictException({ + code: 'MASTER_BOOTSTRAP_REQUIRES_EMPTY_MASTER', + message: 'La configuración inicial sólo puede aplicarse cuando el Maestro está vacío', + }); + } + + const typeRepository = manager.getRepository(AssetType); + const ruleRepository = manager.getRepository(AssetTypeParentRule); + const attributeRepository = manager.getRepository(AssetAttributeDefinition); + const idsByCode = new Map(); + + for (const preset of MASTER_BOOTSTRAP_TYPES) { + const type = typeRepository.create({ + code: preset.code, + name: preset.name, + description: preset.description, + canBeRoot: preset.canBeRoot, + isActive: true, + operationalRole: preset.operationalRole, + createdBy: principal.userId, + updatedBy: principal.userId, + }); + const saved = await typeRepository.save(type); + idsByCode.set(preset.code, saved.id); + } + + let createdParentRuleCount = 0; + let createdAttributeCount = 0; + for (const preset of MASTER_BOOTSTRAP_TYPES) { + const childTypeId = idsByCode.get(preset.code)!; + const rules = preset.allowedParentCodes.map((parentCode) => + ruleRepository.create({ + childTypeId, + parentTypeId: idsByCode.get(parentCode)!, + }), + ); + if (rules.length) { + await ruleRepository.save(rules); + createdParentRuleCount += rules.length; + } + + const attributes = preset.attributes.map((attribute) => + attributeRepository.create({ + assetTypeId: childTypeId, + code: attribute.code, + name: attribute.name, + dataType: attribute.dataType, + isRequired: attribute.isRequired, + isActive: true, + unit: attribute.unit, + options: attribute.options, + sortOrder: attribute.sortOrder, + }), + ); + if (attributes.length) { + await attributeRepository.save(attributes); + createdAttributeCount += attributes.length; + } + } + + const data = (await manager.query(this.viewQuery(''), [])) as AssetTypeView[]; + await this.audit.record( + { + ...administrationAuditContext(principal, request), + action: AuditAction.ASSET_MASTER_BOOTSTRAPPED, + entityType: 'asset_master', + entityId: MASTER_BOOTSTRAP_PRESET_CODE, + afterData: { + presetCode: MASTER_BOOTSTRAP_PRESET_CODE, + presetName: MASTER_BOOTSTRAP_PRESET_NAME, + mode: 'INITIAL_BOOTSTRAP', + createdTypeCount: MASTER_BOOTSTRAP_TYPES.length, + createdAttributeCount, + createdParentRuleCount, + }, + }, + manager, + ); + + return { + presetCode: MASTER_BOOTSTRAP_PRESET_CODE, + presetName: MASTER_BOOTSTRAP_PRESET_NAME, + createdTypeCount: MASTER_BOOTSTRAP_TYPES.length, + createdAttributeCount, + createdParentRuleCount, + data, + }; + }); + } + + async enrichmentStatus(): Promise { + return this.dataSource.transaction((manager) => this.computeEnrichmentStatus(manager)); + } + + async enrichDefaults( + principal: AuthPrincipal, + request: RequestWithContext, + ): Promise { + return this.dataSource.transaction(async (manager) => { + await manager.query('LOCK TABLE asset_types IN SHARE ROW EXCLUSIVE MODE'); + const before = await this.computeEnrichmentStatus(manager); + if (!before.canApply) { + throw new ConflictException({ + code: 'MASTER_ENRICHMENT_INCOMPATIBLE', + message: before.reason ?? 'El Maestro actual no es compatible con el catálogo técnico', + }); + } + + const typeRepository = manager.getRepository(AssetType); + const ruleRepository = manager.getRepository(AssetTypeParentRule); + const attributeRepository = manager.getRepository(AssetAttributeDefinition); + const existingTypes = await typeRepository.find(); + const typesByCode = new Map(existingTypes.map((type) => [type.code, type])); + let createdTypeCount = 0; + let createdAttributeCount = 0; + let createdParentRuleCount = 0; + + for (const preset of MASTER_BOOTSTRAP_TYPES) { + if (typesByCode.has(preset.code)) continue; + const saved = await typeRepository.save(typeRepository.create({ + code: preset.code, + name: preset.name, + description: preset.description, + canBeRoot: preset.canBeRoot, + isActive: true, + operationalRole: preset.operationalRole, + createdBy: principal.userId, + updatedBy: principal.userId, + })); + typesByCode.set(preset.code, saved); + createdTypeCount += 1; + } + + for (const preset of MASTER_BOOTSTRAP_TYPES) { + const childType = typesByCode.get(preset.code)!; + const existingAttributes = await attributeRepository.find({ + where: { assetTypeId: childType.id }, + }); + const attributeCodes = new Set(existingAttributes.map((attribute) => attribute.code)); + for (const attribute of preset.attributes) { + if (attributeCodes.has(attribute.code)) continue; + await attributeRepository.save(attributeRepository.create({ + assetTypeId: childType.id, + code: attribute.code, + name: attribute.name, + dataType: attribute.dataType, + isRequired: attribute.isRequired, + isActive: true, + unit: attribute.unit, + options: attribute.options, + sortOrder: attribute.sortOrder, + })); + createdAttributeCount += 1; + } + + for (const parentCode of preset.allowedParentCodes) { + const parentType = typesByCode.get(parentCode)!; + const existing = await ruleRepository.findOne({ + where: { childTypeId: childType.id, parentTypeId: parentType.id }, + }); + if (existing) continue; + await ruleRepository.save(ruleRepository.create({ + childTypeId: childType.id, + parentTypeId: parentType.id, + })); + createdParentRuleCount += 1; + } + } + + const data = (await manager.query(this.viewQuery(''), [])) as AssetTypeView[]; + await this.audit.record( + { + ...administrationAuditContext(principal, request), + action: AuditAction.ASSET_MASTER_BOOTSTRAPPED, + entityType: 'asset_master', + entityId: MASTER_BOOTSTRAP_PRESET_CODE, + afterData: { + presetCode: MASTER_BOOTSTRAP_PRESET_CODE, + presetName: MASTER_BOOTSTRAP_PRESET_NAME, + mode: 'TECHNICAL_ENRICHMENT', + createdTypeCount, + createdAttributeCount, + createdParentRuleCount, + }, + }, + manager, + ); + + return { + presetCode: MASTER_BOOTSTRAP_PRESET_CODE, + presetName: MASTER_BOOTSTRAP_PRESET_NAME, + createdTypeCount, + createdAttributeCount, + createdParentRuleCount, + data, + }; + }); + } + + private async computeEnrichmentStatus(manager: EntityManager): Promise { + const existingTypes = await manager.getRepository(AssetType).find(); + const typesByCode = new Map(existingTypes.map((type) => [type.code, type])); + const missingCore = MASTER_BOOTSTRAP_CORE_CODES.filter((code) => !typesByCode.has(code)); + const area = typesByCode.get('area'); + const company = typesByCode.get('empresa'); + const incompatible = [ + ...(missingCore.length ? [`Faltan tipos base: ${missingCore.join(', ')}`] : []), + ...(area && area.operationalRole !== AssetTypeOperationalRole.AREA ? ['El tipo area no tiene rol AREA'] : []), + ...(company && company.operationalRole !== AssetTypeOperationalRole.COMPANY ? ['El tipo empresa no tiene rol COMPANY'] : []), + ]; + const canApply = incompatible.length === 0; + const missingTypeCodes = MASTER_BOOTSTRAP_TYPES + .filter((preset) => !typesByCode.has(preset.code)) + .map((preset) => preset.code); + + let missingAttributeCount = 0; + let missingParentRuleCount = 0; + if (canApply) { + for (const preset of MASTER_BOOTSTRAP_TYPES) { + const childType = typesByCode.get(preset.code); + if (!childType) { + missingAttributeCount += preset.attributes.length; + missingParentRuleCount += preset.allowedParentCodes.length; + continue; + } + const attributeRows = (await manager.query( + 'SELECT code FROM asset_attribute_definitions WHERE asset_type_id = $1', + [childType.id], + )) as Array<{ code: string }>; + const attributeCodes = new Set(attributeRows.map((row) => row.code)); + missingAttributeCount += preset.attributes.filter((attribute) => !attributeCodes.has(attribute.code)).length; + for (const parentCode of preset.allowedParentCodes) { + const parentType = typesByCode.get(parentCode); + if (!parentType) { + missingParentRuleCount += 1; + continue; + } + const [rule] = (await manager.query( + 'SELECT 1 FROM asset_type_parent_rules WHERE child_type_id = $1 AND parent_type_id = $2 LIMIT 1', + [childType.id, parentType.id], + )) as unknown[]; + if (!rule) missingParentRuleCount += 1; + } + } + } + + const complete = canApply + && missingTypeCodes.length === 0 + && missingAttributeCount === 0 + && missingParentRuleCount === 0; + return { + presetCode: MASTER_BOOTSTRAP_PRESET_CODE, + presetName: MASTER_BOOTSTRAP_PRESET_NAME, + typeCount: existingTypes.length, + canApply, + complete, + reason: canApply ? null : incompatible.join('. '), + missingTypeCodes, + missingAttributeCount, + missingParentRuleCount, + }; + } + + async list(): Promise<{ data: AssetTypeView[] }> { + const rows = (await this.dataSource.query(this.viewQuery(''), [])) as AssetTypeView[]; + return { data: rows }; + } + + async getById(id: string): Promise { + return this.dataSource.transaction((manager) => this.loadView(manager, id)); + } + + async create( + dto: CreateAssetTypeDto, + principal: AuthPrincipal, + request: RequestWithContext, + ): Promise { + try { + return await this.dataSource.transaction(async (manager) => { + await this.validateParentTypes(manager, null, dto.allowedParentTypeIds); + const type = manager.getRepository(AssetType).create({ + code: dto.code, + name: dto.name, + description: dto.description, + canBeRoot: dto.canBeRoot, + isActive: true, + operationalRole: dto.operationalRole, + createdBy: principal.userId, + updatedBy: principal.userId, + }); + await manager.getRepository(AssetType).save(type); + await this.replaceParentRules(manager, type.id, dto.allowedParentTypeIds); + const created = await this.loadView(manager, type.id); + await this.audit.record( + { + ...administrationAuditContext(principal, request), + action: AuditAction.ASSET_TYPE_CREATED, + entityType: 'asset_type', + entityId: type.id, + afterData: { ...created }, + }, + manager, + ); + return created; + }); + } catch (error) { + if (isUniqueViolation(error)) { + throw new ConflictException({ + code: 'ASSET_TYPE_ALREADY_EXISTS', + message: 'Ya existe un tipo de activo con ese código', + }); + } + throw error; + } + } + + async update( + id: string, + dto: UpdateAssetTypeDto, + principal: AuthPrincipal, + request: RequestWithContext, + ): Promise { + if (Object.keys(dto).length === 0) { + throw new BadRequestException({ code: 'NO_CHANGES', message: 'No se recibieron cambios' }); + } + + return this.dataSource.transaction(async (manager) => { + const type = await manager.getRepository(AssetType).findOne({ where: { id } }); + if (!type) throw typeNotFound(); + const before = await this.loadView(manager, id); + const nextCanBeRoot = dto.canBeRoot ?? type.canBeRoot; + const nextOperationalRole = dto.operationalRole ?? type.operationalRole; + const nextParentIds = dto.allowedParentTypeIds ?? before.allowedParentTypes.map((item) => item.id); + await this.validateParentTypes(manager, id, nextParentIds); + await this.assertExistingAssetsRemainValid(manager, id, nextCanBeRoot, nextParentIds); + await this.assertOperationalRoleChangeAllowed(manager, id, type.operationalRole, nextOperationalRole); + if (dto.isActive === false && type.isActive) { + await this.assertOperationalTypeCanBeDeactivated(manager, id, type.operationalRole); + } + + if (dto.name !== undefined) type.name = dto.name; + if (dto.description !== undefined) type.description = dto.description; + if (dto.canBeRoot !== undefined) type.canBeRoot = dto.canBeRoot; + if (dto.isActive !== undefined) type.isActive = dto.isActive; + if (dto.operationalRole !== undefined) type.operationalRole = dto.operationalRole; + type.updatedBy = principal.userId; + await manager.getRepository(AssetType).save(type); + if (dto.allowedParentTypeIds !== undefined) { + await this.replaceParentRules(manager, id, nextParentIds); + } + + const updated = await this.loadView(manager, id); + await this.audit.record( + { + ...administrationAuditContext(principal, request), + action: AuditAction.ASSET_TYPE_UPDATED, + entityType: 'asset_type', + entityId: id, + beforeData: { ...before }, + afterData: { ...updated }, + }, + manager, + ); + return updated; + }); + } + + async createAttribute( + typeId: string, + dto: CreateAttributeDefinitionDto, + principal: AuthPrincipal, + request: RequestWithContext, + ): Promise { + try { + return await this.dataSource.transaction(async (manager) => { + await this.requireType(manager, typeId); + this.validateOptions(dto.dataType, dto.options); + if (dto.isRequired) { + const [{ count }] = (await manager.query( + 'SELECT COUNT(*)::integer AS count FROM assets WHERE asset_type_id = $1', + [typeId], + )) as Array<{ count: number }>; + if (Number(count) > 0) { + throw new ConflictException({ + code: 'ATTRIBUTE_REQUIRED_VALUES_MISSING', + message: 'No se puede agregar un atributo obligatorio a activos existentes sin completar sus valores', + }); + } + } + const normalizedOptions = dto.options?.map((item) => item.trim()); + const definition = manager.getRepository(AssetAttributeDefinition).create({ + assetTypeId: typeId, + code: dto.code, + name: dto.name, + dataType: dto.dataType, + isRequired: dto.isRequired, + isActive: true, + unit: dto.unit ?? null, + options: dto.dataType === AssetAttributeDataType.SELECT ? normalizedOptions! : null, + sortOrder: dto.sortOrder, + }); + await manager.getRepository(AssetAttributeDefinition).save(definition); + const updated = await this.loadView(manager, typeId); + await this.audit.record( + { + ...administrationAuditContext(principal, request), + action: AuditAction.ASSET_ATTRIBUTE_CREATED, + entityType: 'asset_attribute_definition', + entityId: definition.id, + afterData: { ...definition }, + metadata: { assetTypeId: typeId }, + }, + manager, + ); + return updated; + }); + } catch (error) { + if (isUniqueViolation(error)) { + throw new ConflictException({ + code: 'ASSET_ATTRIBUTE_ALREADY_EXISTS', + message: 'Ya existe un atributo con ese código para el tipo seleccionado', + }); + } + throw error; + } + } + + async updateAttribute( + typeId: string, + attributeId: string, + dto: UpdateAttributeDefinitionDto, + principal: AuthPrincipal, + request: RequestWithContext, + ): Promise { + if (Object.keys(dto).length === 0) { + throw new BadRequestException({ code: 'NO_CHANGES', message: 'No se recibieron cambios' }); + } + + return this.dataSource.transaction(async (manager) => { + const repository = manager.getRepository(AssetAttributeDefinition); + const definition = await repository.findOne({ + where: { id: attributeId, assetTypeId: typeId }, + }); + if (!definition) throw attributeNotFound(); + const before = { ...definition }; + const nextType = dto.dataType ?? definition.dataType; + const nextOptions = dto.options === undefined + ? definition.options + : dto.options?.map((item) => item.trim()) ?? null; + this.validateOptions(nextType, nextOptions ?? undefined); + + if (dto.dataType !== undefined && dto.dataType !== definition.dataType) { + const [{ count }] = (await manager.query( + 'SELECT COUNT(*)::integer AS count FROM asset_attribute_values WHERE definition_id = $1', + [attributeId], + )) as Array<{ count: number }>; + if (Number(count) > 0) { + throw new ConflictException({ + code: 'ATTRIBUTE_TYPE_IN_USE', + message: 'No se puede cambiar el tipo de un atributo que ya tiene valores', + }); + } + } + if (dto.isRequired === true && !definition.isRequired) { + const [{ count }] = (await manager.query( + `SELECT COUNT(*)::integer AS count FROM assets asset + WHERE asset.asset_type_id = $1 + AND NOT EXISTS ( + SELECT 1 FROM asset_attribute_values value + WHERE value.asset_id = asset.id AND value.definition_id = $2 + )`, + [typeId, attributeId], + )) as Array<{ count: number }>; + if (Number(count) > 0) { + throw new ConflictException({ + code: 'ATTRIBUTE_REQUIRED_VALUES_MISSING', + message: 'Hay activos existentes sin valor para este atributo', + }); + } + } + + if (dto.name !== undefined) definition.name = dto.name; + if (dto.dataType !== undefined) definition.dataType = dto.dataType; + if (dto.isRequired !== undefined) definition.isRequired = dto.isRequired; + if (dto.isActive !== undefined) definition.isActive = dto.isActive; + if (dto.unit !== undefined) definition.unit = dto.unit; + if (dto.options !== undefined || dto.dataType !== undefined) { + definition.options = nextType === AssetAttributeDataType.SELECT ? nextOptions : null; + } + if (dto.sortOrder !== undefined) definition.sortOrder = dto.sortOrder; + await repository.save(definition); + + const updated = await this.loadView(manager, typeId); + await this.audit.record( + { + ...administrationAuditContext(principal, request), + action: AuditAction.ASSET_ATTRIBUTE_UPDATED, + entityType: 'asset_attribute_definition', + entityId: attributeId, + beforeData: before, + afterData: { ...definition }, + metadata: { assetTypeId: typeId }, + }, + manager, + ); + return updated; + }); + } + + private validateOptions( + dataType: AssetAttributeDataType, + options?: string[] | null, + ): void { + if (dataType === AssetAttributeDataType.SELECT) { + const cleaned = (options ?? []).map((item) => item.trim()).filter(Boolean); + if (cleaned.length < 1 || new Set(cleaned).size !== cleaned.length) { + throw new BadRequestException({ + code: 'INVALID_ATTRIBUTE_OPTIONS', + message: 'Los atributos de selección necesitan opciones únicas', + }); + } + } else if (options != null) { + throw new BadRequestException({ + code: 'INVALID_ATTRIBUTE_OPTIONS', + message: 'Sólo los atributos de selección pueden tener opciones', + }); + } + } + + private async requireType(manager: EntityManager, id: string): Promise { + const type = await manager.getRepository(AssetType).findOne({ where: { id } }); + if (!type) throw typeNotFound(); + return type; + } + + private async validateParentTypes( + manager: EntityManager, + childTypeId: string | null, + parentTypeIds: string[], + ): Promise { + if (childTypeId && parentTypeIds.includes(childTypeId)) { + throw new BadRequestException({ + code: 'INVALID_PARENT_TYPE_RULE', + message: 'Un tipo no puede ser padre de sí mismo', + }); + } + if (parentTypeIds.length === 0) return; + const parents = await manager.getRepository(AssetType).find({ + where: { id: In(parentTypeIds) }, + }); + if (parents.length !== parentTypeIds.length) { + throw new BadRequestException({ + code: 'ASSET_PARENT_TYPE_NOT_FOUND', + message: 'Uno o más tipos padre no existen', + }); + } + } + + private async replaceParentRules( + manager: EntityManager, + childTypeId: string, + parentTypeIds: string[], + ): Promise { + const repository = manager.getRepository(AssetTypeParentRule); + await repository.delete({ childTypeId }); + if (parentTypeIds.length) { + await repository.save( + parentTypeIds.map((parentTypeId) => + repository.create({ childTypeId, parentTypeId }), + ), + ); + } + } + + private async assertExistingAssetsRemainValid( + manager: EntityManager, + typeId: string, + canBeRoot: boolean, + parentTypeIds: string[], + ): Promise { + const [row] = (await manager.query( + `SELECT COUNT(*)::integer AS count + FROM assets asset + LEFT JOIN assets parent ON parent.id = asset.parent_id + WHERE asset.asset_type_id = $1 + AND ( + (asset.parent_id IS NULL AND $2::boolean = false) + OR (asset.parent_id IS NOT NULL AND NOT(parent.asset_type_id = ANY($3::uuid[]))) + )`, + [typeId, canBeRoot, parentTypeIds], + )) as Array<{ count: number }>; + if (Number(row?.count ?? 0) > 0) { + throw new ConflictException({ + code: 'ASSET_TYPE_RULES_IN_USE', + message: 'Las reglas dejarían activos existentes fuera de una jerarquía válida', + }); + } + } + + + private async assertOperationalTypeCanBeDeactivated( + manager: EntityManager, + typeId: string, + role: AssetTypeOperationalRole, + ): Promise { + if (role === AssetTypeOperationalRole.GENERIC) return; + const relationColumn = role === AssetTypeOperationalRole.AREA ? 'area_id' : 'company_id'; + const assignmentColumn = role === AssetTypeOperationalRole.AREA + ? 'operational_area_id' + : 'operator_company_id'; + const [usage] = (await manager.query(` + SELECT ( + (SELECT COUNT(*) FROM area_company_relations relation + INNER JOIN assets anchor ON anchor.id = relation.${relationColumn} + WHERE anchor.asset_type_id = $1 AND relation.valid_until IS NULL) + + + (SELECT COUNT(*) FROM assets assigned + INNER JOIN assets anchor ON anchor.id = assigned.${assignmentColumn} + WHERE anchor.asset_type_id = $1) + )::integer AS count + `, [typeId])) as Array<{ count: number }>; + if (Number(usage?.count ?? 0) > 0) { + throw new ConflictException({ + code: 'ASSET_TYPE_OPERATIONAL_ROLE_IN_USE', + message: role === AssetTypeOperationalRole.AREA + ? 'No se puede inactivar el tipo mientras sus áreas tengan relaciones o activos operativos asociados' + : 'No se puede inactivar el tipo mientras sus empresas tengan relaciones o activos operativos asociados', + }); + } + } + + private async assertOperationalRoleChangeAllowed( + manager: EntityManager, + typeId: string, + currentRole: AssetTypeOperationalRole, + nextRole: AssetTypeOperationalRole, + ): Promise { + if (currentRole === nextRole) return; + + if (nextRole !== AssetTypeOperationalRole.GENERIC) { + const [assigned] = (await manager.query( + `SELECT COUNT(*)::integer AS count + FROM assets + WHERE asset_type_id = $1 + AND (operational_area_id IS NOT NULL OR operator_company_id IS NOT NULL)`, + [typeId], + )) as Array<{ count: number }>; + if (Number(assigned?.count ?? 0) > 0) { + throw new ConflictException({ + code: 'ASSET_TYPE_OPERATIONAL_ROLE_IN_USE', + message: 'No se puede convertir el tipo en Área o Empresa mientras sus activos tengan asignaciones operativas', + }); + } + } + + if (currentRole === AssetTypeOperationalRole.AREA) { + const [used] = (await manager.query( + `SELECT ( + (SELECT COUNT(*) FROM area_company_relations relation + INNER JOIN assets area ON area.id = relation.area_id + WHERE area.asset_type_id = $1) + + + (SELECT COUNT(*) FROM assets asset + INNER JOIN assets area ON area.id = asset.operational_area_id + WHERE area.asset_type_id = $1) + )::integer AS count`, + [typeId], + )) as Array<{ count: number }>; + if (Number(used?.count ?? 0) > 0) { + throw new ConflictException({ + code: 'ASSET_TYPE_OPERATIONAL_ROLE_IN_USE', + message: 'El tipo todavía está utilizado como Área en relaciones o asignaciones operativas', + }); + } + } + + if (currentRole === AssetTypeOperationalRole.COMPANY) { + const [used] = (await manager.query( + `SELECT ( + (SELECT COUNT(*) FROM area_company_relations relation + INNER JOIN assets company ON company.id = relation.company_id + WHERE company.asset_type_id = $1) + + + (SELECT COUNT(*) FROM assets asset + INNER JOIN assets company ON company.id = asset.operator_company_id + WHERE company.asset_type_id = $1) + )::integer AS count`, + [typeId], + )) as Array<{ count: number }>; + if (Number(used?.count ?? 0) > 0) { + throw new ConflictException({ + code: 'ASSET_TYPE_OPERATIONAL_ROLE_IN_USE', + message: 'El tipo todavía está utilizado como Empresa en relaciones o asignaciones operativas', + }); + } + } + } + + private async loadView(manager: EntityManager, id: string): Promise { + const rows = (await manager.query(this.viewQuery('WHERE asset_type.id = $1'), [id])) as AssetTypeView[]; + if (!rows[0]) throw typeNotFound(); + return rows[0]; + } + + private viewQuery(where: string): string { + return ` + SELECT + asset_type.id, + asset_type.code, + asset_type.name, + asset_type.description, + asset_type.can_be_root AS "canBeRoot", + asset_type.is_active AS "isActive", + asset_type.operational_role AS "operationalRole", + asset_type.created_at AS "createdAt", + asset_type.updated_at AS "updatedAt", + (SELECT COUNT(*)::integer FROM assets WHERE asset_type_id = asset_type.id) AS "assetCount", + COALESCE(( + SELECT JSONB_AGG(JSONB_BUILD_OBJECT( + 'id', parent_type.id, + 'code', parent_type.code, + 'name', parent_type.name, + 'isActive', parent_type.is_active, + 'operationalRole', parent_type.operational_role + ) ORDER BY parent_type.name) + FROM asset_type_parent_rules rule + INNER JOIN asset_types parent_type ON parent_type.id = rule.parent_type_id + WHERE rule.child_type_id = asset_type.id + ), '[]'::jsonb) AS "allowedParentTypes", + COALESCE(( + SELECT JSONB_AGG(JSONB_BUILD_OBJECT( + 'id', definition.id, + 'code', definition.code, + 'name', definition.name, + 'dataType', definition.data_type, + 'isRequired', definition.is_required, + 'isActive', definition.is_active, + 'unit', definition.unit, + 'options', definition.options, + 'sortOrder', definition.sort_order + ) ORDER BY definition.sort_order, definition.name) + FROM asset_attribute_definitions definition + WHERE definition.asset_type_id = asset_type.id + ), '[]'::jsonb) AS attributes + FROM asset_types asset_type + ${where} + ORDER BY asset_type.is_active DESC, asset_type.name ASC + `; + } +} diff --git a/api-v3/src/asset-master/asset-version-diff.ts b/api-v3/src/asset-master/asset-version-diff.ts new file mode 100644 index 0000000..9451ce4 --- /dev/null +++ b/api-v3/src/asset-master/asset-version-diff.ts @@ -0,0 +1,41 @@ +import { isDeepStrictEqual } from 'node:util'; + +const TRACKED_FIELDS = [ + 'code', + 'name', + 'commonName', + 'description', + 'type', + 'parent', + 'operationalArea', + 'operatorCompany', + 'informationStatus', + 'operationalStatus', + 'organizationProfile', + 'organizationMemberships', + 'externalIdentifiers', + 'sourceDocuments', + 'legalRights', + 'attributes', + 'geometry', + 'media', + 'provenance', +] as const; + +export function changedSnapshotFields( + previous: Record | null, + current: Record, +): string[] { + if (!previous) { + return TRACKED_FIELDS.filter((field) => { + const value = current[field]; + return value !== null && value !== undefined && !( + Array.isArray(value) && value.length === 0 + ); + }); + } + + return TRACKED_FIELDS.filter( + (field) => !isDeepStrictEqual(previous[field], current[field]), + ); +} diff --git a/api-v3/src/asset-master/assets.controller.ts b/api-v3/src/asset-master/assets.controller.ts new file mode 100644 index 0000000..e1061e2 --- /dev/null +++ b/api-v3/src/asset-master/assets.controller.ts @@ -0,0 +1,193 @@ +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Patch, + Post, + Query, + Req, +} from '@nestjs/common'; +import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator'; +import { CurrentAuth } from '../auth/decorators/current-auth.decorator'; +import type { + AuthPrincipal, + RequestWithContext, +} from '../common/http/request-context'; +import { AssetsService } from './assets.service'; +import { ChangeAssetStatusDto } from './dto/change-asset-status.dto'; +import { ChangeAssetOperationalStatusDto } from './dto/change-asset-operational-status.dto'; +import { CreateAssetDto } from './dto/create-asset.dto'; +import { CreateFieldDiscoveryDto } from './dto/create-field-discovery.dto'; +import { ListFieldDiscoveriesQueryDto } from './dto/list-field-discoveries-query.dto'; +import { MatchFieldDiscoveryDto, RejectFieldDiscoveryDto, ReviewFieldDiscoveryDto } from './dto/review-field-discovery.dto'; +import { ListAssetsQueryDto } from './dto/list-assets-query.dto'; +import { ListAssetTreeQueryDto } from './dto/list-asset-tree-query.dto'; +import { ListAssetTreeChildrenQueryDto } from './dto/list-asset-tree-children-query.dto'; +import { ParentOptionsQueryDto } from './dto/parent-options-query.dto'; +import { UpdateAssetDto } from './dto/update-asset.dto'; +import { ChangeAssetContextDto } from './dto/change-asset-context.dto'; + +@Controller('assets') +export class AssetsController { + constructor(private readonly assets: AssetsService) {} + + @Get() + @RequirePermissions('assets.read') + list(@Query() query: ListAssetsQueryDto) { + return this.assets.list(query); + } + + @Get('field-discoveries') + @RequirePermissions('assets.read') + listFieldDiscoveries(@Query() query: ListFieldDiscoveriesQueryDto) { + return this.assets.listFieldDiscoveries(query); + } + + @Post('field-discoveries') + @RequirePermissions('assets.create') + createFieldDiscovery( + @Body() dto: CreateFieldDiscoveryDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.assets.createFieldDiscovery(dto, principal, request); + } + + @Post('field-discoveries/:id/approve') + @RequirePermissions('assets.change_status') + approveFieldDiscovery( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @Body() dto: ReviewFieldDiscoveryDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.assets.approveFieldDiscovery(id, dto, principal, request); + } + + @Post('field-discoveries/:id/reject') + @RequirePermissions('assets.change_status') + rejectFieldDiscovery( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @Body() dto: RejectFieldDiscoveryDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.assets.rejectFieldDiscovery(id, dto, principal, request); + } + + @Post('field-discoveries/:id/match') + @RequirePermissions('assets.change_status') + matchFieldDiscovery( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @Body() dto: MatchFieldDiscoveryDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.assets.matchFieldDiscovery(id, dto, principal, request); + } + + @Get('tree') + @RequirePermissions('assets.read') + tree(@Query() query: ListAssetTreeQueryDto) { + return this.assets.tree(query); + } + + @Get('tree-children') + @RequirePermissions('assets.read') + treeChildren(@Query() query: ListAssetTreeChildrenQueryDto) { + return this.assets.treeChildren(query); + } + + @Get('parent-options') + @RequirePermissions('assets.read') + parentOptions(@Query() query: ParentOptionsQueryDto) { + return this.assets.parentOptions(query); + } + + @Post() + @RequirePermissions('assets.create') + create( + @Body() dto: CreateAssetDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.assets.create(dto, principal, request); + } + + @Get(':id/lineage') + @RequirePermissions('assets.read') + lineage(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) { + return this.assets.lineage(id); + } + + @Get(':id/dossier') + @RequirePermissions( + 'assets.read', + 'inspections.read', + 'inspection_acts.read', + 'inspection_findings.read', + 'inspection_evidence.read', + 'inspection_communications.read', + ) + dossier(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) { + return this.assets.dossier(id); + } + + @Get(':id/context-history') + @RequirePermissions('assets.read_history') + contextHistory(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) { + return this.assets.contextHistory(id); + } + + @Post(':id/context') + @RequirePermissions('assets.manage_context') + changeContext( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @Body() dto: ChangeAssetContextDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.assets.changeContext(id, dto, principal, request); + } + + @Get(':id') + @RequirePermissions('assets.read') + getById(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) { + return this.assets.getById(id); + } + + @Patch(':id') + @RequirePermissions('assets.update') + update( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @Body() dto: UpdateAssetDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.assets.update(id, dto, principal, request); + } + + @Patch(':id/operational-status') + @RequirePermissions('assets.change_operational_status') + changeOperationalStatus( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @Body() dto: ChangeAssetOperationalStatusDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.assets.changeOperationalStatus(id, dto, principal, request); + } + + @Patch(':id/information-status') + @RequirePermissions('assets.change_status') + changeStatus( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @Body() dto: ChangeAssetStatusDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.assets.changeStatus(id, dto, principal, request); + } +} diff --git a/api-v3/src/asset-master/assets.service.ts b/api-v3/src/asset-master/assets.service.ts new file mode 100644 index 0000000..53a6481 --- /dev/null +++ b/api-v3/src/asset-master/assets.service.ts @@ -0,0 +1,1720 @@ +import { + BadRequestException, + ConflictException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { DataSource, EntityManager } from 'typeorm'; +import { AuditService } from '../audit/audit.service'; +import type { + AuthPrincipal, + RequestWithContext, +} from '../common/http/request-context'; +import { + Asset, + AssetDataOrigin, + AssetAttributeDefinition, + AssetAttributeValue, + AssetInformationStatus, + AssetOperationalStatus, + AssetType, + OrganizationKind, + OrganizationProfile, + AssetTypeOperationalRole, + AssetVersionChangeType, + AuditAction, +} from '../database/entities'; +import { + administrationAuditContext, + isUniqueViolation, +} from '../administration/common/administration-audit'; +import { validateAssetAttributeValues } from './asset-attribute-validator'; +import type { CreateAssetDto } from './dto/create-asset.dto'; +import type { UpdateAssetDto } from './dto/update-asset.dto'; +import type { ChangeAssetStatusDto } from './dto/change-asset-status.dto'; +import type { ChangeAssetOperationalStatusDto } from './dto/change-asset-operational-status.dto'; +import type { ListAssetsQueryDto } from './dto/list-assets-query.dto'; +import type { ListAssetTreeQueryDto } from './dto/list-asset-tree-query.dto'; +import type { ListAssetTreeChildrenQueryDto } from './dto/list-asset-tree-children-query.dto'; +import type { ParentOptionsQueryDto } from './dto/parent-options-query.dto'; +import { AssetHistoryService } from './asset-history.service'; +import { assertMobileInspector } from '../inspection-operations/mobile-inspector-policy'; +import type { CreateFieldDiscoveryDto } from './dto/create-field-discovery.dto'; +import type { ListFieldDiscoveriesQueryDto } from './dto/list-field-discoveries-query.dto'; +import type { MatchFieldDiscoveryDto, RejectFieldDiscoveryDto, ReviewFieldDiscoveryDto } from './dto/review-field-discovery.dto'; +import type { ChangeAssetContextDto } from './dto/change-asset-context.dto'; +import { FieldDiscoveryInspectionLinkService } from '../inspection-operations/field-discovery-inspection-link.service'; + +export interface AssetListItem { + id: string; + code: string; + name: string; + commonName: string | null; + type: { id: string; code: string; name: string }; + parent: { id: string; code: string; name: string } | null; + operationalArea: { id: string; code: string; name: string } | null; + operatorCompany: { id: string; code: string; name: string } | null; + informationStatus: AssetInformationStatus; + operationalStatus: AssetOperationalStatus; + childrenCount: number; + hasGeometry: boolean; + geometryType: string | null; + mediaCount: number; + dataOrigin: AssetDataOrigin; + provenanceVerified: boolean; + currentVersion: number; + updatedAt: Date; +} + +export interface AssetView extends AssetListItem { + description: string | null; + createdAt: Date; + createdBy: string | null; + updatedBy: string | null; + attributes: Array<{ + definitionId: string; + code: string; + name: string; + dataType: string; + isRequired: boolean; + unit: string | null; + options: string[] | null; + sortOrder: number; + value: unknown; + }>; +} + +function assetNotFound(): NotFoundException { + return new NotFoundException({ + code: 'ASSET_NOT_FOUND', + message: 'Activo no encontrado', + }); +} + +@Injectable() +export class AssetsService { + constructor( + private readonly dataSource: DataSource, + private readonly audit: AuditService, + private readonly history: AssetHistoryService, + private readonly fieldDiscoveryInspectionLinks: FieldDiscoveryInspectionLinkService, + ) {} + + async list(query: ListAssetsQueryDto) { + const conditions: string[] = []; + const parameters: unknown[] = []; + const addParameter = (value: unknown): string => { + parameters.push(value); + return `$${parameters.length}`; + }; + + if (query.search?.trim()) { + const placeholder = addParameter(`%${query.search.trim()}%`); + conditions.push(`(asset.code ILIKE ${placeholder} OR asset.name ILIKE ${placeholder} OR asset.common_name ILIKE ${placeholder} OR asset.description ILIKE ${placeholder} OR EXISTS (SELECT 1 FROM asset_external_identifiers external_id WHERE external_id.asset_id = asset.id AND external_id.valid_until IS NULL AND (external_id.value ILIKE ${placeholder} OR external_id.namespace ILIKE ${placeholder})) OR EXISTS (SELECT 1 FROM asset_attribute_values attribute_value JOIN asset_attribute_definitions attribute_definition ON attribute_definition.id=attribute_value.definition_id WHERE attribute_value.asset_id=asset.id AND attribute_definition.is_active=true AND attribute_definition.code IN ('fabricante','modelo','numero_serie','identificacion_oficial','ubicacion','ubicacion_fuente') AND (attribute_value.value #>> '{}') ILIKE ${placeholder}) OR EXISTS (SELECT 1 FROM asset_source_documents source_link JOIN source_documents source_document ON source_document.id=source_link.document_id WHERE source_link.asset_id=asset.id AND (source_document.title ILIKE ${placeholder} OR source_document.document_number ILIKE ${placeholder})))`); + } + if (query.typeId) conditions.push(`asset.asset_type_id = ${addParameter(query.typeId)}`); + if (query.status) conditions.push(`asset.information_status = ${addParameter(query.status)}`); + if (query.operationalStatus) conditions.push(`asset.operational_status = ${addParameter(query.operationalStatus)}`); + if (query.needsValidation === true) conditions.push(`asset.information_status NOT IN ('VALIDATED','INACTIVE')`); + if (query.needsValidation === false) conditions.push(`asset.information_status = 'VALIDATED'`); + if (query.hasGeometry === true) conditions.push(`EXISTS (SELECT 1 FROM asset_geometries geometry_filter WHERE geometry_filter.asset_id = asset.id)`); + if (query.hasGeometry === false) conditions.push(`NOT EXISTS (SELECT 1 FROM asset_geometries geometry_filter WHERE geometry_filter.asset_id = asset.id)`); + if (query.parentId) conditions.push(`asset.parent_id = ${addParameter(query.parentId)}`); + if (query.operationalAreaId) conditions.push(`asset.operational_area_id = ${addParameter(query.operationalAreaId)}`); + if (query.operatorCompanyId) conditions.push(`asset.operator_company_id = ${addParameter(query.operatorCompanyId)}`); + + const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''; + const [countRow] = (await this.dataSource.query( + `SELECT COUNT(*)::integer AS total FROM assets asset ${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.listQuery(where)} LIMIT ${limitParameter} OFFSET ${offsetParameter}`, + parameters, + )) as AssetListItem[]; + + return { + data, + meta: { + page: query.page, + pageSize: query.pageSize, + total, + totalPages: total === 0 ? 0 : Math.ceil(total / query.pageSize), + }, + }; + } + + async tree(query: ListAssetTreeQueryDto): Promise<{ data: AssetListItem[]; meta: { count: number; truncated: boolean } }> { + const conditions: string[] = []; + const parameters: unknown[] = []; + const addParameter = (value: unknown): string => { parameters.push(value); return `$${parameters.length}`; }; + if (query.search?.trim()) { + const p = addParameter(`%${query.search.trim()}%`); + conditions.push(`(asset.code ILIKE ${p} OR asset.name ILIKE ${p} OR asset.common_name ILIKE ${p} OR asset.description ILIKE ${p} OR EXISTS (SELECT 1 FROM asset_external_identifiers external_id WHERE external_id.asset_id = asset.id AND external_id.valid_until IS NULL AND (external_id.value ILIKE ${p} OR external_id.namespace ILIKE ${p})) OR EXISTS (SELECT 1 FROM asset_attribute_values attribute_value JOIN asset_attribute_definitions attribute_definition ON attribute_definition.id=attribute_value.definition_id WHERE attribute_value.asset_id=asset.id AND attribute_definition.is_active=true AND attribute_definition.code IN ('fabricante','modelo','numero_serie','identificacion_oficial','ubicacion','ubicacion_fuente') AND (attribute_value.value #>> '{}') ILIKE ${p}))`); + } + if (query.typeId) conditions.push(`asset.asset_type_id = ${addParameter(query.typeId)}`); + if (query.status) conditions.push(`asset.information_status = ${addParameter(query.status)}`); + if (query.operationalStatus) conditions.push(`asset.operational_status = ${addParameter(query.operationalStatus)}`); + if (query.operationalAreaId) conditions.push(`asset.operational_area_id = ${addParameter(query.operationalAreaId)}`); + if (query.operatorCompanyId) conditions.push(`asset.operator_company_id = ${addParameter(query.operatorCompanyId)}`); + if (query.needsValidation === true) conditions.push(`asset.information_status NOT IN ('VALIDATED','INACTIVE')`); + if (query.needsValidation === false) conditions.push(`asset.information_status = 'VALIDATED'`); + if (query.hasGeometry === true) conditions.push(`EXISTS (SELECT 1 FROM asset_geometries g WHERE g.asset_id=asset.id)`); + if (query.hasGeometry === false) conditions.push(`NOT EXISTS (SELECT 1 FROM asset_geometries g WHERE g.asset_id=asset.id)`); + const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''; + const limit = 5001; parameters.push(limit); const limitParam=`$${parameters.length}`; + const rows = (await this.dataSource.query(` + WITH RECURSIVE matched AS ( + SELECT asset.id FROM assets asset ${where} + ), visible(id) AS ( + SELECT id FROM matched + UNION + SELECT parent.id + FROM assets child + JOIN visible current ON current.id=child.id + JOIN assets parent ON parent.id=child.parent_id + ) + ${this.listQuery('WHERE asset.id IN (SELECT id FROM visible)').replace(/ORDER BY asset\.name ASC, asset\.code ASC\s*$/, '')} + ORDER BY COALESCE(parent.name, asset.name), asset.name, asset.code + LIMIT ${limitParam} + `, parameters)) as AssetListItem[]; + const truncated = rows.length > 5000; + return { data: truncated ? rows.slice(0, 5000) : rows, meta: { count: truncated ? 5000 : rows.length, truncated } }; + } + + async treeChildren(query: ListAssetTreeChildrenQueryDto): Promise<{ data: AssetListItem[]; meta: { count: number; hasMore: boolean; parentId: string | null } }> { + const parameters: unknown[] = []; + const conditions: string[] = []; + const addParameter = (value: unknown): string => { parameters.push(value); return `$${parameters.length}`; }; + if (query.search?.trim()) { + const p = addParameter(`%${query.search.trim()}%`); + conditions.push(`(asset.code ILIKE ${p} OR asset.name ILIKE ${p} OR asset.common_name ILIKE ${p} OR asset.description ILIKE ${p} OR EXISTS (SELECT 1 FROM asset_external_identifiers external_id WHERE external_id.asset_id=asset.id AND external_id.valid_until IS NULL AND (external_id.value ILIKE ${p} OR external_id.namespace ILIKE ${p})) OR EXISTS (SELECT 1 FROM asset_attribute_values attribute_value JOIN asset_attribute_definitions attribute_definition ON attribute_definition.id=attribute_value.definition_id WHERE attribute_value.asset_id=asset.id AND attribute_definition.is_active=true AND attribute_definition.code IN ('fabricante','modelo','numero_serie','identificacion_oficial','ubicacion','ubicacion_fuente') AND (attribute_value.value #>> '{}') ILIKE ${p}))`); + } + if (query.typeId) conditions.push(`asset.asset_type_id = ${addParameter(query.typeId)}`); + if (query.status) conditions.push(`asset.information_status = ${addParameter(query.status)}`); + if (query.operationalStatus) conditions.push(`asset.operational_status = ${addParameter(query.operationalStatus)}`); + if (query.operationalAreaId) conditions.push(`asset.operational_area_id = ${addParameter(query.operationalAreaId)}`); + if (query.operatorCompanyId) conditions.push(`asset.operator_company_id = ${addParameter(query.operatorCompanyId)}`); + if (query.needsValidation === true) conditions.push(`asset.information_status NOT IN ('VALIDATED','INACTIVE')`); + if (query.needsValidation === false) conditions.push(`asset.information_status = 'VALIDATED'`); + if (query.hasGeometry === true) conditions.push(`EXISTS (SELECT 1 FROM asset_geometries g WHERE g.asset_id=asset.id)`); + if (query.hasGeometry === false) conditions.push(`NOT EXISTS (SELECT 1 FROM asset_geometries g WHERE g.asset_id=asset.id)`); + + const hasFilters = conditions.length > 0; + const targetParent = query.parentId ?? null; + const parentParameter = addParameter(targetParent); + const limitParameter = addParameter(query.limit + 1); + const directParent = `asset.parent_id IS NOT DISTINCT FROM ${parentParameter}::uuid`; + let sql: string; + if (!hasFilters) { + sql = `${this.listQuery(`WHERE ${directParent}`).replace(/ORDER BY asset\.name ASC, asset\.code ASC\s*$/, '')} ORDER BY asset.name,asset.code LIMIT ${limitParameter}`; + } else { + const matchWhere = `WHERE ${conditions.join(' AND ')}`; + sql = ` + WITH RECURSIVE matched AS ( + SELECT asset.id FROM assets asset ${matchWhere} + ), visible(id) AS ( + SELECT id FROM matched + UNION + SELECT parent.id FROM assets child JOIN visible current ON current.id=child.id JOIN assets parent ON parent.id=child.parent_id + ) + ${this.listQuery(`WHERE ${directParent} AND asset.id IN (SELECT id FROM visible)`).replace(/ORDER BY asset\.name ASC, asset\.code ASC\s*$/, '')} + ORDER BY asset.name,asset.code LIMIT ${limitParameter} + `; + } + const rows = (await this.dataSource.query(sql, parameters)) as AssetListItem[]; + const hasMore = rows.length > query.limit; + const data = hasMore ? rows.slice(0, query.limit) : rows; + return { data, meta: { count: data.length, hasMore, parentId: targetParent } }; + } + + async parentOptions(query: ParentOptionsQueryDto): Promise<{ data: AssetListItem[] }> { + const parameters: unknown[] = [query.childTypeId]; + const conditions = [ + `asset.asset_type_id IN ( + SELECT parent_type_id FROM asset_type_parent_rules WHERE child_type_id = $1 + )`, + ]; + if (query.assetId) { + parameters.push(query.assetId); + const assetIdParameter = `$${parameters.length}`; + conditions.push(`asset.id <> ${assetIdParameter}`); + conditions.push(`asset.id NOT IN ( + WITH RECURSIVE descendants AS ( + SELECT id FROM assets WHERE parent_id = ${assetIdParameter} + UNION ALL + SELECT child.id FROM assets child + INNER JOIN descendants current ON child.parent_id = current.id + ) SELECT id FROM descendants + )`); + } + if (query.search?.trim()) { + parameters.push(`%${query.search.trim()}%`); + const searchParameter = `$${parameters.length}`; + conditions.push(`(asset.code ILIKE ${searchParameter} OR asset.name ILIKE ${searchParameter} OR asset.common_name ILIKE ${searchParameter})`); + } + const rows = (await this.dataSource.query( + `${this.listQuery(`WHERE ${conditions.join(' AND ')}`)} LIMIT 50`, + parameters, + )) as AssetListItem[]; + return { data: rows }; + } + + async lineage(id: string): Promise<{ data: Array<{ id: string; code: string; name: string; commonName: string | null; type: { id: string; code: string; name: string } }> }> { + const rows = (await this.dataSource.query( + `WITH RECURSIVE lineage AS ( + SELECT asset.id, asset.parent_id, 0 AS depth + FROM assets asset + WHERE asset.id = $1 + UNION ALL + SELECT parent.id, parent.parent_id, lineage.depth + 1 + FROM assets parent + INNER JOIN lineage ON lineage.parent_id = parent.id + ) + SELECT asset.id, asset.code, asset.name, asset.common_name AS "commonName", + json_build_object('id', asset_type.id, 'code', asset_type.code, 'name', asset_type.name) AS type, + lineage.depth + FROM lineage + INNER JOIN assets asset ON asset.id = lineage.id + INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id + ORDER BY lineage.depth DESC`, + [id], + )) as Array<{ id: string; code: string; name: string; commonName: string | null; type: { id: string; code: string; name: string }; depth: number }>; + if (rows.length === 0) throw assetNotFound(); + return { data: rows.map(({ depth: _depth, ...item }) => item) }; + } + + async dossier(id: string) { + const assetRows = (await this.dataSource.query( + `SELECT id, code, name, common_name AS "commonName", created_at AS "createdAt" FROM assets WHERE id = $1`, + [id], + )) as Array<{ id: string; code: string; name: string; commonName: string | null; createdAt: Date }>; + const asset = assetRows[0]; + if (!asset) throw assetNotFound(); + + const [visits, acts, findings, evidence, communications, verificationResults, documents, inspectionReports, media, versions] = await Promise.all([ + this.dataSource.query( + `SELECT DISTINCT visit.id, visit.code, visit.title, visit.status, + visit.planned_start_at AS "plannedStartAt", + visit.actual_started_at AS "actualStartedAt", + visit.actual_closed_at AS "actualClosedAt", + visit.created_at AS "createdAt" + FROM inspection_visits visit + LEFT JOIN inspection_visit_assets visit_asset + ON visit_asset.visit_id = visit.id AND visit_asset.included = true + LEFT JOIN inspection_acts act ON act.visit_id = visit.id + LEFT JOIN inspection_act_assets act_asset + ON act_asset.act_id = act.id AND act_asset.included = true + LEFT JOIN inspection_findings finding ON finding.act_id = act.id + WHERE visit.scope_asset_id = $1 + OR visit_asset.asset_id = $1 + OR act_asset.asset_id = $1 + OR finding.asset_id = $1 + ORDER BY COALESCE(visit.actual_started_at, visit.planned_start_at, visit.created_at) DESC + LIMIT 200`, + [id], + ), + this.dataSource.query( + `SELECT DISTINCT act.id, act.visit_id AS "visitId", act.code, act.status, + act.occurred_at AS "occurredAt", act.title, act.summary, + act.closed_at AS "closedAt", act.current_version AS "currentVersion", + visit.code AS "visitCode", visit.title AS "visitTitle" + FROM inspection_acts act + JOIN inspection_visits visit ON visit.id = act.visit_id + LEFT JOIN inspection_act_assets act_asset + ON act_asset.act_id = act.id AND act_asset.included = true + LEFT JOIN inspection_findings finding ON finding.act_id = act.id + WHERE act_asset.asset_id = $1 OR finding.asset_id = $1 + ORDER BY act.occurred_at DESC + LIMIT 200`, + [id], + ), + this.dataSource.query( + `SELECT finding.id, finding.act_id AS "actId", finding.code, finding.status, + finding.title, finding.description, + finding.correction_due_on AS "correctionDueOn", + finding.company_response_received_on AS "companyResponseReceivedOn", + finding.next_control_on AS "nextControlOn", + finding.closed_at AS "closedAt", finding.closure_notes AS "closureNotes", + finding.created_at AS "createdAt", finding.updated_at AS "updatedAt", + act.code AS "actCode", act.occurred_at AS "actOccurredAt", + visit.id AS "visitId", visit.code AS "visitCode", visit.title AS "visitTitle" + FROM inspection_findings finding + JOIN inspection_acts act ON act.id = finding.act_id + JOIN inspection_visits visit ON visit.id = act.visit_id + WHERE finding.asset_id = $1 + ORDER BY finding.created_at DESC + LIMIT 500`, + [id], + ), + this.dataSource.query( + `SELECT evidence.id, evidence.finding_id AS "findingId", evidence.communication_id AS "communicationId", + evidence.kind, evidence.purpose, evidence.original_name AS "originalName", + evidence.title, evidence.description, evidence.captured_at AS "capturedAt", + evidence.created_at AS "createdAt", finding.code AS "findingCode", + finding.title AS "findingTitle" + FROM inspection_finding_evidence evidence + JOIN inspection_findings finding ON finding.id = evidence.finding_id + WHERE finding.asset_id = $1 + ORDER BY COALESCE(evidence.captured_at, evidence.created_at) DESC + LIMIT 500`, + [id], + ), + this.dataSource.query( + `SELECT communication.id, communication.finding_id AS "findingId", + communication.direction, communication.channel, communication.type, + communication.occurred_at AS "occurredAt", communication.subject, + communication.details, communication.contact_name AS "contactName", + communication.created_at AS "createdAt", finding.code AS "findingCode", + finding.title AS "findingTitle" + FROM inspection_finding_communications communication + JOIN inspection_findings finding ON finding.id = communication.finding_id + WHERE finding.asset_id = $1 + ORDER BY communication.occurred_at DESC + LIMIT 500`, + [id], + ), + this.dataSource.query( + `SELECT verification_link.id, + verification_link.finding_id AS "findingId", + verification_link.visit_id AS "visitId", + 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", + finding.code AS "findingCode", finding.title AS "findingTitle", + visit.code AS "visitCode", visit.title AS "visitTitle", visit.status AS "visitStatus", + (SELECT COUNT(*)::integer + FROM inspection_finding_evidence verification_evidence + WHERE verification_evidence.finding_id = finding.id + AND verification_evidence.verification_visit_id = visit.id + AND verification_evidence.purpose = 'VERIFICATION') AS "evidenceCount" + FROM inspection_finding_verification_visits verification_link + JOIN inspection_findings finding ON finding.id = verification_link.finding_id + JOIN inspection_visits visit ON visit.id = verification_link.visit_id + WHERE finding.asset_id = $1 AND verification_link.outcome IS NOT NULL + ORDER BY verification_link.verified_at DESC NULLS LAST, verification_link.result_recorded_at DESC + LIMIT 500`, + [id], + ), + this.dataSource.query( + `SELECT document.id, document.document_type AS "documentType", + document.document_number AS "documentNumber", document.title, + document.issuer, document.document_date AS "documentDate", + document.external_reference AS "externalReference", + link.relation_type AS "relationType", link.notes, + link.created_at AS "linkedAt" + FROM asset_source_documents link + JOIN source_documents document ON document.id = link.document_id + WHERE link.asset_id = $1 + ORDER BY COALESCE(document.document_date::timestamptz, link.created_at) DESC + LIMIT 300`, + [id], + ), + this.dataSource.query( + `SELECT DISTINCT report.id, report.code, report.status, + report.pdf_status AS "pdfStatus", report.title, + report.generated_at AS "generatedAt", report.frozen_sha256 AS "frozenSha256", + act.id AS "actId", act.code AS "actCode", + visit.id AS "visitId", visit.code AS "visitCode" + FROM inspection_reports report + JOIN inspection_acts act ON act.id = report.act_id + JOIN inspection_visits visit ON visit.id = report.visit_id + LEFT JOIN inspection_act_assets act_asset + ON act_asset.act_id = act.id AND act_asset.included = true + LEFT JOIN inspection_findings finding ON finding.act_id = act.id + WHERE act_asset.asset_id = $1 OR finding.asset_id = $1 OR visit.scope_asset_id = $1 + ORDER BY report.generated_at DESC + LIMIT 200`, + [id], + ), + this.dataSource.query( + `SELECT media.id, media.kind, media.original_name AS "originalName", + media.title, media.description, media.captured_at AS "capturedAt", + media.created_at AS "createdAt", media.source + FROM asset_media media + WHERE media.asset_id = $1 AND media.deleted_at IS NULL + ORDER BY COALESCE(media.captured_at, media.created_at) DESC + LIMIT 500`, + [id], + ), + this.dataSource.query( + `SELECT version.id, version.version_number AS "versionNumber", + version.change_type AS "changeType", version.changed_fields AS "changedFields", + version.occurred_at AS "occurredAt", version.actor_username AS "actorUsername", + version.source + FROM asset_versions version + WHERE version.asset_id = $1 + ORDER BY version.occurred_at DESC + LIMIT 500`, + [id], + ), + ]) as [ + Array>, + Array>, + Array>, + Array>, + Array>, + Array>, + Array>, + Array>, + Array>, + Array>, + ]; + + const timeline: Array> = []; + const push = (event: Record) => timeline.push(event); + + versions.forEach((version) => push({ + id: `version:${String(version.id)}`, + kind: 'INVENTORY_CHANGE', + occurredAt: version.occurredAt, + title: version.changeType === 'CREATED' || version.changeType === 'BASELINE' ? 'Registro incorporado al inventario' : 'Inventario actualizado', + description: Array.isArray(version.changedFields) && version.changedFields.length > 0 ? `Campos: ${(version.changedFields as string[]).join(', ')}` : null, + meta: { versionNumber: version.versionNumber, changeType: version.changeType, actorUsername: version.actorUsername, source: version.source }, + })); + visits.forEach((visit) => push({ + id: `visit:${String(visit.id)}`, + kind: 'INSPECTION', + occurredAt: visit.actualStartedAt ?? visit.plannedStartAt ?? visit.createdAt, + title: `Inspección ${String(visit.code)}`, + description: visit.title, + href: `/inspecciones/${String(visit.id)}`, + meta: { status: visit.status }, + })); + acts.forEach((act) => push({ + id: `act:${String(act.id)}`, + kind: 'ACT', + occurredAt: act.occurredAt, + title: `Acta ${String(act.code)}`, + description: act.title, + href: `/inspecciones/actas/${String(act.id)}`, + meta: { status: act.status, visitCode: act.visitCode }, + })); + findings.forEach((finding) => { + push({ + id: `finding:${String(finding.id)}`, + kind: 'FINDING', + occurredAt: finding.createdAt, + title: `Hallazgo ${String(finding.code)}`, + description: finding.title, + href: `/hallazgos/${String(finding.id)}`, + meta: { status: finding.status, actCode: finding.actCode }, + }); + if (finding.closedAt) push({ + id: `finding-close:${String(finding.id)}`, + kind: 'FINDING_CLOSED', + occurredAt: finding.closedAt, + title: `Hallazgo ${String(finding.code)} cerrado`, + description: finding.closureNotes, + href: `/hallazgos/${String(finding.id)}`, + meta: { status: finding.status }, + }); + }); + verificationResults.forEach((verification) => push({ + id: `verification:${String(verification.id)}`, + kind: 'VERIFICATION', + occurredAt: verification.verifiedAt ?? verification.resultRecordedAt, + title: verification.outcome === 'RESOLVED' + ? `Verificación conforme · ${String(verification.findingCode)}` + : verification.outcome === 'NOT_RESOLVED' + ? `Verificación no conforme · ${String(verification.findingCode)}` + : `Verificación reprogramada · ${String(verification.findingCode)}`, + description: verification.resultNotes, + href: `/hallazgos/${String(verification.findingId)}`, + meta: { + outcome: verification.outcome, + visitCode: verification.visitCode, + targetControlOn: verification.targetControlOn, + rescheduledControlOn: verification.rescheduledControlOn, + evidenceCount: verification.evidenceCount, + }, + })); + communications.forEach((communication) => push({ + id: `communication:${String(communication.id)}`, + kind: 'COMMUNICATION', + occurredAt: communication.occurredAt, + title: communication.type === 'COMPANY_RESPONSE' ? 'Respuesta de la empresa' : String(communication.subject), + description: communication.details, + href: `/hallazgos/${String(communication.findingId)}`, + meta: { findingCode: communication.findingCode, direction: communication.direction, channel: communication.channel }, + })); + evidence.forEach((item) => push({ + id: `evidence:${String(item.id)}`, + kind: item.kind === 'PHOTO' ? 'PHOTO' : 'DOCUMENT', + occurredAt: item.capturedAt ?? item.createdAt, + title: item.kind === 'PHOTO' ? 'Fotografía / evidencia' : 'Documento incorporado', + description: item.title ?? item.originalName, + href: `/hallazgos/${String(item.findingId)}`, + meta: { findingCode: item.findingCode, purpose: item.purpose }, + })); + inspectionReports.forEach((report) => push({ + id: `inspection-report:${String(report.id)}`, + kind: 'REPORT', + occurredAt: report.generatedAt, + title: `Informe ${String(report.code)}`, + description: report.title, + href: `/informes/${String(report.id)}`, + meta: { status: report.status, pdfStatus: report.pdfStatus, actCode: report.actCode }, + })); + documents.forEach((document) => push({ + id: `source-document:${String(document.id)}`, + kind: document.documentType === 'TECHNICAL_REPORT' ? 'REPORT' : 'SOURCE_DOCUMENT', + occurredAt: document.documentDate ?? document.linkedAt, + title: String(document.title), + description: document.documentNumber ? `Documento ${String(document.documentNumber)}` : 'Documento vinculado al inventario', + meta: { documentType: document.documentType, relationType: document.relationType }, + })); + media.forEach((item) => push({ + id: `asset-media:${String(item.id)}`, + kind: item.kind === 'PHOTO' ? 'PHOTO' : 'DOCUMENT', + occurredAt: item.capturedAt ?? item.createdAt, + title: item.kind === 'PHOTO' ? 'Fotografía del inventario' : 'Archivo del inventario', + description: item.title ?? item.originalName, + meta: { source: item.source }, + })); + + timeline.sort((a, b) => new Date(String(b.occurredAt ?? 0)).getTime() - new Date(String(a.occurredAt ?? 0)).getTime()); + + const openFindings = findings.filter((finding) => finding.status === 'OPEN').length; + const closedFindings = findings.filter((finding) => finding.status === 'CLOSED').length; + const reports = documents.filter((document) => document.documentType === 'TECHNICAL_REPORT'); + + return { + asset: { id: asset.id, code: asset.code, name: asset.name, commonName: asset.commonName }, + counters: { + inspections: visits.length, + acts: acts.length, + findings: findings.length, + verifications: verificationResults.length, + openFindings, + closedFindings, + evidence: evidence.length, + documents: documents.length + media.filter((item) => item.kind === 'DOCUMENT').length, + photos: evidence.filter((item) => item.kind === 'PHOTO').length + media.filter((item) => item.kind === 'PHOTO').length, + reports: reports.length + inspectionReports.length, + }, + visits, + acts, + findings, + evidence, + communications, + verificationResults, + documents, + inspectionReports, + reports, + media, + versions, + timeline: timeline.slice(0, 500), + }; + } + + async getById(id: string): Promise { + return this.dataSource.transaction((manager) => this.loadView(manager, id)); + } + + async create( + dto: CreateAssetDto, + principal: AuthPrincipal, + request: RequestWithContext, + ): Promise { + try { + return await this.dataSource.transaction(async (manager) => { + const type = await this.requireActiveType(manager, dto.typeId); + await this.validateParent(manager, type, dto.parentId ?? null, null); + await this.validateOperationalAssignment( + manager, + type, + dto.parentId ?? null, + dto.operationalAreaId ?? null, + dto.operatorCompanyId ?? null, + ); + const definitions = await this.loadDefinitions(manager, type.id); + const values = validateAssetAttributeValues(definitions, dto.attributes); + + const asset = manager.getRepository(Asset).create({ + assetTypeId: type.id, + parentId: dto.parentId ?? null, + operationalAreaId: dto.operationalAreaId ?? null, + operatorCompanyId: dto.operatorCompanyId ?? null, + code: dto.code, + name: dto.name, + commonName: dto.commonName ?? null, + description: dto.description ?? null, + informationStatus: principal.permissions.includes('assets.change_status') + ? dto.informationStatus + : AssetInformationStatus.DRAFT, + createdBy: principal.userId, + updatedBy: principal.userId, + dataOrigin: AssetDataOrigin.MANUAL, + provenanceUpdatedBy: principal.userId, + }); + await manager.getRepository(Asset).save(asset); + if (type.operationalRole === AssetTypeOperationalRole.COMPANY) { + await manager.getRepository(OrganizationProfile).save( + manager.getRepository(OrganizationProfile).create({ + assetId: asset.id, + organizationKind: OrganizationKind.COMPANY, + legalName: asset.name, + taxId: null, + notes: null, + updatedBy: principal.userId, + }), + ); + } + await this.replaceAttributeValues(manager, asset.id, values, principal.userId); + const versionNumber = await this.history.capture( + manager, + asset.id, + AssetVersionChangeType.CREATED, + principal, + request, + ); + await this.insertInitialContextHistory(manager, asset, versionNumber, principal, request, 'Contexto inicial del registro'); + const created = await this.loadView(manager, asset.id); + await this.audit.record( + { + ...administrationAuditContext(principal, request), + action: AuditAction.ASSET_CREATED, + entityType: 'asset', + entityId: asset.id, + afterData: this.auditView(created), + metadata: { versionNumber }, + }, + manager, + ); + return created; + }); + } catch (error) { + if (isUniqueViolation(error)) throw this.assetConflict(); + throw error; + } + } + + async listFieldDiscoveries(query: ListFieldDiscoveriesQueryDto) { + const conditions: string[] = []; + const parameters: unknown[] = []; + const add = (value: unknown): string => { parameters.push(value); return `$${parameters.length}`; }; + if (query.status) conditions.push(`discovery.status = ${add(query.status)}`); + else conditions.push(`discovery.status = 'PENDING'`); + if (query.search?.trim()) { + const search = add(`%${query.search.trim()}%`); + conditions.push(`(asset.code ILIKE ${search} OR asset.name ILIKE ${search} OR asset.common_name ILIKE ${search} OR visit.code ILIKE ${search} OR creator.username ILIKE ${search})`); + } + const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''; + const [countRow] = (await this.dataSource.query(` + SELECT COUNT(*)::integer AS total + FROM asset_field_discoveries discovery + JOIN assets asset ON asset.id = discovery.asset_id + JOIN inspection_visits visit ON visit.id = discovery.visit_id + JOIN users creator ON creator.id = discovery.created_by + ${where} + `, parameters)) as Array<{ total: number }>; + const total = Number(countRow?.total ?? 0); + const limit = add(query.pageSize); + const offset = add((query.page - 1) * query.pageSize); + const data = await this.dataSource.query(` + ${this.fieldDiscoverySelect()} + ${where} + ORDER BY CASE discovery.status WHEN 'PENDING' THEN 1 ELSE 2 END, discovery.created_at ASC + LIMIT ${limit} OFFSET ${offset} + `, parameters); + return { data, meta: { page: query.page, pageSize: query.pageSize, total, totalPages: total === 0 ? 0 : Math.ceil(total / query.pageSize) } }; + } + + async createFieldDiscovery( + dto: CreateFieldDiscoveryDto, + principal: AuthPrincipal, + request: RequestWithContext, + ) { + assertMobileInspector(principal); + try { + return await this.dataSource.transaction(async (manager) => { + const [visit] = (await manager.query(` + SELECT visit.id, visit.code, visit.status, + (visit.lead_inspector_user_id = $2 OR EXISTS ( + SELECT 1 FROM inspection_visit_members member + WHERE member.visit_id = visit.id AND member.user_id = $2 AND member.included = true + )) AS assigned + FROM inspection_visits visit + WHERE visit.id = $1 + FOR UPDATE + `, [dto.visitId, principal.userId])) as Array<{ id: string; code: string; status: string; assigned: boolean }>; + if (!visit) throw new NotFoundException({ code: 'INSPECTION_VISIT_NOT_FOUND', message: 'Inspección no encontrada' }); + if (visit.status !== 'IN_PROGRESS') throw new ConflictException({ code: 'FIELD_DISCOVERY_VISIT_NOT_IN_PROGRESS', message: 'Sólo se pueden registrar elementos nuevos durante una inspección en curso' }); + if (!visit.assigned) throw new ConflictException({ code: 'FIELD_DISCOVERY_INSPECTOR_NOT_ASSIGNED', message: 'El inspector no está asignado a esta inspección' }); + + const type = await this.requireActiveType(manager, dto.typeId); + await this.validateParent(manager, type, dto.parentId, null); + await this.validateOperationalAssignment(manager, type, dto.parentId, dto.operationalAreaId, dto.operatorCompanyId); + const definitions = await this.loadDefinitions(manager, type.id); + const values = validateAssetAttributeValues(definitions, dto.attributes); + const asset = manager.getRepository(Asset).create({ + assetTypeId: type.id, + parentId: dto.parentId, + operationalAreaId: dto.operationalAreaId, + operatorCompanyId: dto.operatorCompanyId, + code: dto.code, + name: dto.name, + commonName: dto.commonName ?? null, + description: dto.description ?? null, + informationStatus: AssetInformationStatus.DRAFT, + createdBy: principal.userId, + updatedBy: principal.userId, + dataOrigin: AssetDataOrigin.FIELD_SURVEY, + sourceName: `Inspección ${visit.code}`, + sourceReference: `inspection-visit:${visit.id}`, + sourceObservedAt: new Date(), + sourceNotes: dto.discoveryNotes ?? null, + provenanceUpdatedBy: principal.userId, + }); + await manager.getRepository(Asset).save(asset); + await this.replaceAttributeValues(manager, asset.id, values, principal.userId); + const versionNumber = await this.history.capture(manager, asset.id, AssetVersionChangeType.CREATED, principal, request); + await this.insertInitialContextHistory(manager, asset, versionNumber, principal, request, 'Contexto observado en alta de campo'); + const inspectionLinks = await this.fieldDiscoveryInspectionLinks.attach( + manager, + visit.id, + asset.id, + principal.userId, + ); + const [discovery] = (await manager.query(` + INSERT INTO asset_field_discoveries (asset_id, visit_id, discovery_notes, observed_at, created_by) + VALUES ($1,$2,$3,CURRENT_TIMESTAMP,$4) + RETURNING id + `, [asset.id, visit.id, dto.discoveryNotes ?? null, principal.userId])) as Array<{ id: string }>; + const created = await this.loadView(manager, asset.id); + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.ASSET_FIELD_DISCOVERY_CREATED, + entityType: 'asset_field_discovery', + entityId: discovery.id, + afterData: this.auditView(created), + metadata: { assetId: asset.id, visitId: visit.id, versionNumber, addedToActId: inspectionLinks.actId }, + }, manager); + return this.loadFieldDiscovery(manager, discovery.id); + }); + } catch (error) { + if (isUniqueViolation(error)) throw this.assetConflict(); + throw error; + } + } + + async approveFieldDiscovery(id: string, dto: ReviewFieldDiscoveryDto, principal: AuthPrincipal, request: RequestWithContext) { + return this.dataSource.transaction(async (manager) => { + const discovery = await this.lockPendingFieldDiscovery(manager, id); + const asset = await manager.getRepository(Asset).createQueryBuilder('asset').where('asset.id=:id', { id: discovery.assetId }).setLock('pessimistic_write').getOne(); + if (!asset) throw assetNotFound(); + if (!asset.operatorCompanyId || !asset.operationalAreaId || !asset.assetTypeId || !asset.code || !asset.name) { + throw new ConflictException({ code: 'FIELD_DISCOVERY_MINIMUM_CONTEXT_MISSING', message: 'Antes de validar deben estar definidos Empresa, Área, Tipo de elemento y Nombre o código' }); + } + asset.informationStatus = AssetInformationStatus.VALIDATED; + asset.updatedBy = principal.userId; + await manager.getRepository(Asset).save(asset); + const versionNumber = await this.history.capture(manager, asset.id, AssetVersionChangeType.STATUS_CHANGED, principal, request); + await manager.query(`UPDATE asset_field_discoveries SET status='APPROVED', reviewed_at=CURRENT_TIMESTAMP, reviewed_by=$2, review_notes=$3, updated_at=CURRENT_TIMESTAMP WHERE id=$1`, [id, principal.userId, dto.notes ?? null]); + await this.audit.record({ ...administrationAuditContext(principal, request), action: AuditAction.ASSET_FIELD_DISCOVERY_APPROVED, entityType: 'asset_field_discovery', entityId: id, afterData: { assetId: asset.id, status: 'APPROVED' }, metadata: { versionNumber } }, manager); + return this.loadFieldDiscovery(manager, id); + }); + } + + async rejectFieldDiscovery(id: string, dto: RejectFieldDiscoveryDto, principal: AuthPrincipal, request: RequestWithContext) { + return this.dataSource.transaction(async (manager) => { + const discovery = await this.lockPendingFieldDiscovery(manager, id); + const asset = await manager.getRepository(Asset).createQueryBuilder('asset').where('asset.id=:id', { id: discovery.assetId }).setLock('pessimistic_write').getOne(); + if (!asset) throw assetNotFound(); + asset.informationStatus = AssetInformationStatus.INACTIVE; + asset.updatedBy = principal.userId; + await manager.getRepository(Asset).save(asset); + const versionNumber = await this.history.capture(manager, asset.id, AssetVersionChangeType.STATUS_CHANGED, principal, request); + await manager.query(`UPDATE asset_field_discoveries SET status='REJECTED', reviewed_at=CURRENT_TIMESTAMP, reviewed_by=$2, review_notes=$3, updated_at=CURRENT_TIMESTAMP WHERE id=$1`, [id, principal.userId, dto.reason]); + await this.audit.record({ ...administrationAuditContext(principal, request), action: AuditAction.ASSET_FIELD_DISCOVERY_REJECTED, entityType: 'asset_field_discovery', entityId: id, afterData: { assetId: asset.id, status: 'REJECTED', reason: dto.reason }, metadata: { versionNumber } }, manager); + return this.loadFieldDiscovery(manager, id); + }); + } + + async matchFieldDiscovery(id: string, dto: MatchFieldDiscoveryDto, principal: AuthPrincipal, request: RequestWithContext) { + return this.dataSource.transaction(async (manager) => { + const discovery = await this.lockPendingFieldDiscovery(manager, id); + if (dto.matchedAssetId === discovery.assetId) throw new BadRequestException({ code: 'FIELD_DISCOVERY_SELF_MATCH', message: 'No se puede conciliar un registro consigo mismo' }); + const [source, target] = await Promise.all([ + manager.getRepository(Asset).findOne({ where: { id: discovery.assetId } }), + manager.getRepository(Asset).findOne({ where: { id: dto.matchedAssetId } }), + ]); + if (!source) throw assetNotFound(); + if (!target || target.informationStatus === AssetInformationStatus.INACTIVE) throw new NotFoundException({ code: 'FIELD_DISCOVERY_MATCH_TARGET_NOT_FOUND', message: 'El registro elegido para conciliar no está disponible' }); + const [targetPending] = (await manager.query(`SELECT id FROM asset_field_discoveries WHERE asset_id=$1 AND status='PENDING' LIMIT 1`, [target.id])) as Array<{ id: string }>; + if (targetPending) throw new ConflictException({ code: 'FIELD_DISCOVERY_MATCH_TARGET_PROVISIONAL', message: 'No se puede conciliar contra otra alta de campo todavía pendiente' }); + if (source.operationalAreaId !== target.operationalAreaId || source.operatorCompanyId !== target.operatorCompanyId) { + throw new ConflictException({ code: 'FIELD_DISCOVERY_MATCH_CONTEXT_MISMATCH', message: 'La coincidencia debe pertenecer a la misma Empresa y Área' }); + } + source.informationStatus = AssetInformationStatus.INACTIVE; + source.updatedBy = principal.userId; + await manager.getRepository(Asset).save(source); + const versionNumber = await this.history.capture(manager, source.id, AssetVersionChangeType.STATUS_CHANGED, principal, request); + await manager.query(`UPDATE asset_field_discoveries SET status='MATCHED', matched_asset_id=$2, reviewed_at=CURRENT_TIMESTAMP, reviewed_by=$3, review_notes=$4, updated_at=CURRENT_TIMESTAMP WHERE id=$1`, [id, target.id, principal.userId, dto.reason]); + await this.audit.record({ ...administrationAuditContext(principal, request), action: AuditAction.ASSET_FIELD_DISCOVERY_MATCHED, entityType: 'asset_field_discovery', entityId: id, afterData: { assetId: source.id, matchedAssetId: target.id, status: 'MATCHED', reason: dto.reason }, metadata: { versionNumber } }, manager); + return this.loadFieldDiscovery(manager, id); + }); + } + + private fieldDiscoverySelect(): string { + return `SELECT discovery.id, discovery.status, discovery.discovery_notes AS "discoveryNotes", discovery.observed_at AS "observedAt", discovery.reviewed_at AS "reviewedAt", discovery.review_notes AS "reviewNotes", discovery.created_at AS "createdAt", + JSONB_BUILD_OBJECT('id',asset.id,'code',asset.code,'name',asset.name,'commonName',asset.common_name,'informationStatus',asset.information_status,'typeId',asset.asset_type_id,'typeName',asset_type.name,'parentId',asset.parent_id,'operationalAreaId',asset.operational_area_id,'operatorCompanyId',asset.operator_company_id) AS asset, + JSONB_BUILD_OBJECT('id',visit.id,'code',visit.code,'title',visit.title,'status',visit.status) AS visit, + JSONB_BUILD_OBJECT('id',creator.id,'username',creator.username,'firstName',creator.first_name,'lastName',creator.last_name) AS creator, + 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 reviewer, + CASE WHEN matched.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',matched.id,'code',matched.code,'name',matched.name,'commonName',matched.common_name) END AS "matchedAsset" + FROM asset_field_discoveries discovery + JOIN assets asset ON asset.id=discovery.asset_id + JOIN asset_types asset_type ON asset_type.id=asset.asset_type_id + JOIN inspection_visits visit ON visit.id=discovery.visit_id + JOIN users creator ON creator.id=discovery.created_by + LEFT JOIN users reviewer ON reviewer.id=discovery.reviewed_by + LEFT JOIN assets matched ON matched.id=discovery.matched_asset_id`; + } + + private async loadFieldDiscovery(manager: EntityManager, id: string) { + const [row] = await manager.query(`${this.fieldDiscoverySelect()} WHERE discovery.id=$1`, [id]); + if (!row) throw new NotFoundException({ code: 'FIELD_DISCOVERY_NOT_FOUND', message: 'Alta de campo no encontrada' }); + return row; + } + + private async lockPendingFieldDiscovery(manager: EntityManager, id: string): Promise<{ id: string; assetId: string }> { + const [row] = (await manager.query(`SELECT id, asset_id AS "assetId", status FROM asset_field_discoveries WHERE id=$1 FOR UPDATE`, [id])) as Array<{ id: string; assetId: string; status: string }>; + if (!row) throw new NotFoundException({ code: 'FIELD_DISCOVERY_NOT_FOUND', message: 'Alta de campo no encontrada' }); + if (row.status !== 'PENDING') throw new ConflictException({ code: 'FIELD_DISCOVERY_ALREADY_REVIEWED', message: 'Esta alta de campo ya fue resuelta' }); + return row; + } + + async contextHistory(id: string) { + const [asset] = (await this.dataSource.query(`SELECT id FROM assets WHERE id=$1`, [id])) as Array<{ id: string }>; + if (!asset) throw assetNotFound(); + const data = await this.dataSource.query(` + SELECT history.id, + history.asset_id AS "assetId", + history.valid_from AS "validFrom", + history.valid_until AS "validUntil", + history.change_reason AS "changeReason", + history.end_reason AS "endReason", + history.asset_version_number AS "assetVersionNumber", + history.source, + history.request_id AS "requestId", + history.created_at AS "createdAt", + CASE WHEN parent.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',parent.id,'code',parent.code,'name',parent.name) END AS parent, + CASE WHEN area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',area.id,'code',area.code,'name',area.name) END AS "operationalArea", + CASE WHEN company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',company.id,'code',company.code,'name',company.name) END AS "operatorCompany", + CASE WHEN creator.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',creator.id,'username',creator.username,'firstName',creator.first_name,'lastName',creator.last_name) END AS creator, + (history.valid_until IS NULL) AS "isCurrent" + FROM asset_context_history history + LEFT JOIN assets parent ON parent.id=history.parent_id + LEFT JOIN assets area ON area.id=history.operational_area_id + LEFT JOIN assets company ON company.id=history.operator_company_id + LEFT JOIN users creator ON creator.id=history.created_by + WHERE history.asset_id=$1 + ORDER BY history.valid_from DESC, history.created_at DESC + `, [id]); + return { data }; + } + + async changeContext( + id: string, + dto: ChangeAssetContextDto, + principal: AuthPrincipal, + request: RequestWithContext, + ): Promise { + return this.dataSource.transaction(async (manager) => { + const repository = manager.getRepository(Asset); + const asset = await repository.createQueryBuilder('asset').where('asset.id=:id', { id }).setLock('pessimistic_write').getOne(); + if (!asset) throw assetNotFound(); + const before = await this.loadView(manager, id); + const type = await this.requireType(manager, asset.assetTypeId); + const nextParentId = dto.parentId === undefined ? asset.parentId : dto.parentId; + const nextOperationalAreaId = dto.operationalAreaId === undefined ? asset.operationalAreaId : dto.operationalAreaId; + const nextOperatorCompanyId = dto.operatorCompanyId === undefined ? asset.operatorCompanyId : dto.operatorCompanyId; + const contextChanged = nextParentId !== asset.parentId + || nextOperationalAreaId !== asset.operationalAreaId + || nextOperatorCompanyId !== asset.operatorCompanyId; + if (!contextChanged) { + throw new BadRequestException({ code: 'NO_CONTEXT_CHANGES', message: 'El contexto indicado coincide con el contexto actual' }); + } + await this.validateParent(manager, type, nextParentId ?? null, id); + await this.validateOperationalAssignment(manager, type, nextParentId ?? null, nextOperationalAreaId ?? null, nextOperatorCompanyId ?? null); + if (nextParentId !== asset.parentId) await this.assertHierarchyMoveKeepsOperationalAssignments(manager, id, nextParentId ?? null); + + const effectiveAt = dto.effectiveAt ?? new Date(); + if (effectiveAt.getTime() > Date.now() + 60_000) { + throw new BadRequestException({ code: 'ASSET_CONTEXT_FUTURE_DATE', message: 'La vigencia del cambio no puede comenzar en el futuro' }); + } + const [currentHistory] = (await manager.query(` + SELECT id, valid_from AS "validFrom" + FROM asset_context_history + WHERE asset_id=$1 AND valid_until IS NULL + FOR UPDATE + `, [id])) as Array<{ id: string; validFrom: Date }>; + if (!currentHistory) { + throw new ConflictException({ code: 'ASSET_CONTEXT_HISTORY_MISSING', message: 'El registro no tiene una asignación de contexto vigente' }); + } + if (effectiveAt.getTime() <= new Date(currentHistory.validFrom).getTime()) { + throw new ConflictException({ code: 'ASSET_CONTEXT_EFFECTIVE_DATE_INVALID', message: 'La nueva vigencia debe ser posterior al inicio del contexto actual' }); + } + + asset.parentId = nextParentId ?? null; + asset.operationalAreaId = nextOperationalAreaId ?? null; + asset.operatorCompanyId = nextOperatorCompanyId ?? null; + asset.updatedBy = principal.userId; + await repository.save(asset); + const versionNumber = await this.history.capture(manager, id, AssetVersionChangeType.CONTEXT_CHANGED, principal, request); + await this.appendContextTransition( + manager, + id, + nextParentId ?? null, + nextOperationalAreaId ?? null, + nextOperatorCompanyId ?? null, + effectiveAt, + dto.reason, + versionNumber, + principal, + request, + ); + const updated = await this.loadView(manager, id); + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.ASSET_CONTEXT_CHANGED, + entityType: 'asset', + entityId: id, + beforeData: this.auditView(before), + afterData: this.auditView(updated), + metadata: { versionNumber, effectiveAt: effectiveAt.toISOString(), reason: dto.reason }, + }, manager); + return updated; + }); + } + + private async insertInitialContextHistory( + manager: EntityManager, + asset: Asset, + versionNumber: number, + principal: AuthPrincipal, + request: RequestWithContext, + reason: string, + ): Promise { + await manager.query(` + INSERT INTO asset_context_history ( + asset_id, parent_id, operational_area_id, operator_company_id, + valid_from, change_reason, asset_version_number, source, request_id, created_by + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) + ON CONFLICT DO NOTHING + `, [ + asset.id, + asset.parentId, + asset.operationalAreaId, + asset.operatorCompanyId, + asset.createdAt ?? new Date(), + reason, + versionNumber, + principal.transport === 'bearer' ? 'ANDROID' : 'WEB', + request.requestId, + principal.userId, + ]); + } + + private async appendContextTransition( + manager: EntityManager, + assetId: string, + parentId: string | null, + operationalAreaId: string | null, + operatorCompanyId: string | null, + effectiveAt: Date, + reason: string, + versionNumber: number, + principal: AuthPrincipal, + request: RequestWithContext, + ): Promise { + const [current] = (await manager.query(` + SELECT id, valid_from AS "validFrom" + FROM asset_context_history + WHERE asset_id=$1 AND valid_until IS NULL + FOR UPDATE + `, [assetId])) as Array<{ id: string; validFrom: Date }>; + if (!current) { + throw new ConflictException({ code: 'ASSET_CONTEXT_HISTORY_MISSING', message: 'El registro no tiene una asignación de contexto vigente' }); + } + const safeEffectiveAt = effectiveAt.getTime() <= new Date(current.validFrom).getTime() + ? new Date(new Date(current.validFrom).getTime() + 1) + : effectiveAt; + await manager.query(` + UPDATE asset_context_history + SET valid_until=$2, end_reason=$3, ended_by=$4, ended_at=CURRENT_TIMESTAMP + WHERE id=$1 + `, [current.id, safeEffectiveAt, reason, principal.userId]); + await manager.query(` + INSERT INTO asset_context_history ( + asset_id, parent_id, operational_area_id, operator_company_id, + valid_from, change_reason, asset_version_number, source, request_id, created_by + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) + `, [ + assetId, parentId, operationalAreaId, operatorCompanyId, + safeEffectiveAt, reason, versionNumber, + principal.transport === 'bearer' ? 'ANDROID' : 'WEB', request.requestId, principal.userId, + ]); + } + + async update( + id: string, + dto: UpdateAssetDto, + principal: AuthPrincipal, + request: RequestWithContext, + ): Promise { + if (Object.keys(dto).length === 0) { + throw new BadRequestException({ code: 'NO_CHANGES', message: 'No se recibieron cambios' }); + } + + try { + return await this.dataSource.transaction(async (manager) => { + const repository = manager.getRepository(Asset); + const asset = await repository + .createQueryBuilder('asset') + .where('asset.id = :id', { id }) + .setLock('pessimistic_write') + .getOne(); + if (!asset) throw assetNotFound(); + const before = await this.loadView(manager, id); + const currentType = await this.requireType(manager, asset.assetTypeId); + let type = currentType; + const typeChanged = dto.typeId !== undefined && dto.typeId !== asset.assetTypeId; + const nextParentId = dto.parentId === undefined ? asset.parentId : dto.parentId; + const nextOperationalAreaId = dto.operationalAreaId === undefined + ? asset.operationalAreaId + : dto.operationalAreaId; + const nextOperatorCompanyId = dto.operatorCompanyId === undefined + ? asset.operatorCompanyId + : dto.operatorCompanyId; + const contextChanged = nextParentId !== asset.parentId + || nextOperationalAreaId !== asset.operationalAreaId + || nextOperatorCompanyId !== asset.operatorCompanyId; + let pendingDiscovery: { id: string } | undefined; + if (typeChanged || contextChanged) { + [pendingDiscovery] = (await manager.query(`SELECT id FROM asset_field_discoveries WHERE asset_id=$1 AND status='PENDING' LIMIT 1`, [id])) as Array<{ id: string }>; + } + const canCorrectPendingDiscovery = asset.dataOrigin === AssetDataOrigin.FIELD_SURVEY + && asset.informationStatus === AssetInformationStatus.DRAFT + && Boolean(pendingDiscovery); + if (typeChanged) { + if (!canCorrectPendingDiscovery) { + throw new ConflictException({ code: 'ASSET_TYPE_CHANGE_NOT_ALLOWED', message: 'El tipo sólo puede corregirse mientras una alta de campo está pendiente de revisión' }); + } + type = await this.requireActiveType(manager, dto.typeId!); + } + if (contextChanged && !canCorrectPendingDiscovery) { + throw new ConflictException({ + code: 'ASSET_CONTEXT_CHANGE_REQUIRES_TRANSFER', + message: 'Los cambios de jerarquía, Área u Operadora deben registrarse desde Cambiar contexto para conservar la historia', + }); + } + await this.validateParent(manager, type, nextParentId ?? null, id); + await this.validateOperationalAssignment( + manager, + type, + nextParentId ?? null, + nextOperationalAreaId ?? null, + nextOperatorCompanyId ?? null, + ); + if (dto.parentId !== undefined && nextParentId !== asset.parentId) { + await this.assertHierarchyMoveKeepsOperationalAssignments( + manager, + id, + nextParentId ?? null, + ); + } + + if (typeChanged) asset.assetTypeId = type.id; + if (dto.code !== undefined) asset.code = dto.code; + if (dto.name !== undefined) asset.name = dto.name; + if (dto.commonName !== undefined) asset.commonName = dto.commonName; + if (dto.parentId !== undefined) asset.parentId = dto.parentId; + if (dto.operationalAreaId !== undefined) asset.operationalAreaId = dto.operationalAreaId; + if (dto.operatorCompanyId !== undefined) asset.operatorCompanyId = dto.operatorCompanyId; + if (dto.description !== undefined) asset.description = dto.description; + asset.updatedBy = principal.userId; + await repository.save(asset); + + if (dto.attributes !== undefined) { + const definitions = await this.loadDefinitions(manager, type.id); + const values = validateAssetAttributeValues(definitions, dto.attributes); + await this.replaceAttributeValues(manager, id, values, principal.userId); + } + + const versionNumber = await this.history.capture( + manager, + id, + AssetVersionChangeType.UPDATED, + principal, + request, + ); + if (contextChanged) { + await this.appendContextTransition( + manager, + id, + nextParentId ?? null, + nextOperationalAreaId ?? null, + nextOperatorCompanyId ?? null, + new Date(), + 'Corrección de contexto durante revisión de alta de campo', + versionNumber, + principal, + request, + ); + } + const updated = await this.loadView(manager, id); + await this.audit.record( + { + ...administrationAuditContext(principal, request), + action: AuditAction.ASSET_UPDATED, + entityType: 'asset', + entityId: id, + beforeData: this.auditView(before), + afterData: this.auditView(updated), + metadata: { versionNumber }, + }, + manager, + ); + return updated; + }); + } catch (error) { + if (isUniqueViolation(error)) throw this.assetConflict(); + throw error; + } + } + + async changeStatus( + id: string, + dto: ChangeAssetStatusDto, + principal: AuthPrincipal, + request: RequestWithContext, + ): Promise { + return this.dataSource.transaction(async (manager) => { + const repository = manager.getRepository(Asset); + const asset = await repository + .createQueryBuilder('asset') + .where('asset.id = :id', { id }) + .setLock('pessimistic_write') + .getOne(); + if (!asset) throw assetNotFound(); + const beforeStatus = asset.informationStatus; + if (beforeStatus === dto.informationStatus) return this.loadView(manager, id); + if (dto.informationStatus === AssetInformationStatus.INACTIVE) { + await this.assertOperationalAnchorCanBeInactivated(manager, asset); + } + asset.informationStatus = dto.informationStatus; + asset.updatedBy = principal.userId; + await repository.save(asset); + const versionNumber = await this.history.capture( + manager, + id, + AssetVersionChangeType.STATUS_CHANGED, + principal, + request, + ); + const updated = await this.loadView(manager, id); + await this.audit.record( + { + ...administrationAuditContext(principal, request), + action: AuditAction.ASSET_INFORMATION_STATUS_CHANGED, + entityType: 'asset', + entityId: id, + beforeData: { informationStatus: beforeStatus }, + afterData: { informationStatus: dto.informationStatus }, + metadata: { versionNumber }, + }, + manager, + ); + return updated; + }); + } + + async changeOperationalStatus( + id: string, + dto: ChangeAssetOperationalStatusDto, + principal: AuthPrincipal, + request: RequestWithContext, + ): Promise { + return this.dataSource.transaction(async (manager) => { + const repository = manager.getRepository(Asset); + const asset = await repository.createQueryBuilder('asset').where('asset.id = :id', { id }).setLock('pessimistic_write').getOne(); + if (!asset) throw assetNotFound(); + const beforeStatus = asset.operationalStatus; + if (beforeStatus === dto.status) return this.loadView(manager, id); + asset.operationalStatus = dto.status; + asset.updatedBy = principal.userId; + await repository.save(asset); + const versionNumber = await this.history.capture(manager, id, AssetVersionChangeType.OPERATIONAL_STATUS_CHANGED, principal, request); + const updated = await this.loadView(manager, id); + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.ASSET_OPERATIONAL_STATUS_CHANGED, + entityType: 'asset', + entityId: id, + beforeData: { operationalStatus: beforeStatus }, + afterData: { operationalStatus: dto.status }, + metadata: { versionNumber }, + }, manager); + return updated; + }); + } + + private async requireActiveType(manager: EntityManager, id: string): Promise { + const type = await this.requireType(manager, id); + if (!type.isActive) { + throw new ConflictException({ + code: 'ASSET_TYPE_INACTIVE', + message: 'El tipo de activo está inactivo', + }); + } + return type; + } + + private async requireType(manager: EntityManager, id: string): Promise { + const type = await manager.getRepository(AssetType).findOne({ where: { id } }); + if (!type) { + throw new BadRequestException({ + code: 'ASSET_TYPE_NOT_FOUND', + message: 'El tipo de activo no existe', + }); + } + return type; + } + + private async validateParent( + manager: EntityManager, + type: AssetType, + parentId: string | null, + assetId: string | null, + ): Promise { + if (!parentId) { + if (!type.canBeRoot) { + throw new BadRequestException({ + code: 'ASSET_PARENT_REQUIRED', + message: 'El tipo seleccionado requiere un activo padre', + }); + } + return; + } + if (parentId === assetId) { + throw new BadRequestException({ + code: 'ASSET_HIERARCHY_CYCLE', + message: 'Un activo no puede ser su propio padre', + }); + } + const parent = await manager.getRepository(Asset).findOne({ where: { id: parentId } }); + if (!parent) { + throw new BadRequestException({ + code: 'ASSET_PARENT_NOT_FOUND', + message: 'El activo padre no existe', + }); + } + const [rule] = (await manager.query( + `SELECT 1 FROM asset_type_parent_rules + WHERE child_type_id = $1 AND parent_type_id = $2`, + [type.id, parent.assetTypeId], + )) as unknown[]; + if (!rule) { + throw new BadRequestException({ + code: 'ASSET_PARENT_TYPE_NOT_ALLOWED', + message: 'El tipo del activo padre no está permitido para esta jerarquía', + }); + } + if (assetId) { + const [cycle] = (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 current ON parent.id = current.parent_id + ) + SELECT 1 FROM ancestors WHERE id = $2 LIMIT 1`, + [parentId, assetId], + )) as unknown[]; + if (cycle) { + throw new BadRequestException({ + code: 'ASSET_HIERARCHY_CYCLE', + message: 'La relación generaría un ciclo en la jerarquía', + }); + } + } + } + + private async validateOperationalAssignment( + manager: EntityManager, + type: AssetType, + parentId: string | null, + operationalAreaId: string | null, + operatorCompanyId: string | null, + ): Promise { + if (!operationalAreaId && !operatorCompanyId) return; + if (!operationalAreaId || !operatorCompanyId) { + throw new BadRequestException({ + code: 'ASSET_OPERATIONAL_CONTEXT_INCOMPLETE', + message: 'El área operativa y la organización operadora deben asignarse juntas', + }); + } + if (type.operationalRole !== AssetTypeOperationalRole.GENERIC) { + throw new BadRequestException({ + code: 'ASSET_OPERATIONAL_CONTEXT_NOT_ALLOWED', + message: 'Los activos configurados como Área u Organización no reciben una asignación operativa', + }); + } + + const [context] = (await manager.query(` + SELECT + area_type.operational_role AS "areaRole", + area_type.is_active AS "areaTypeActive", + area.information_status AS "areaStatus", + company_type.operational_role AS "companyRole", + company_type.is_active AS "companyTypeActive", + company.information_status AS "companyStatus" + FROM assets area + INNER JOIN asset_types area_type ON area_type.id = area.asset_type_id + CROSS JOIN assets company + INNER JOIN asset_types company_type ON company_type.id = company.asset_type_id + WHERE area.id = $1 AND company.id = $2 + `, [operationalAreaId, operatorCompanyId])) as Array<{ + areaRole: AssetTypeOperationalRole; + areaTypeActive: boolean; + areaStatus: AssetInformationStatus; + companyRole: AssetTypeOperationalRole; + companyTypeActive: boolean; + companyStatus: AssetInformationStatus; + }>; + if (!context) { + throw new BadRequestException({ + code: 'ASSET_OPERATIONAL_CONTEXT_NOT_FOUND', + message: 'El área o la organización seleccionada no existe', + }); + } + if (context.areaRole !== AssetTypeOperationalRole.AREA) { + throw new BadRequestException({ + code: 'ASSET_OPERATIONAL_AREA_INVALID', + message: 'El activo seleccionado como área no tiene el rol operativo Área', + }); + } + if (context.companyRole !== AssetTypeOperationalRole.COMPANY) { + throw new BadRequestException({ + code: 'ASSET_OPERATOR_COMPANY_INVALID', + message: 'El activo seleccionado como organización no tiene el rol interno requerido', + }); + } + if ( + !context.areaTypeActive + || !context.companyTypeActive + || context.areaStatus === AssetInformationStatus.INACTIVE + || context.companyStatus === AssetInformationStatus.INACTIVE + ) { + throw new ConflictException({ + code: 'ASSET_OPERATIONAL_CONTEXT_INACTIVE', + message: 'El área, la organización y sus tipos deben estar activos para asignar activos', + }); + } + const relationRows = (await manager.query(` + SELECT id + FROM area_company_relations + WHERE area_id = $1 + AND company_id = $2 + AND relation_role = 'OPERATOR' + AND valid_until IS NULL + FOR KEY SHARE + `, [operationalAreaId, operatorCompanyId])) as Array<{ id: string }>; + if (relationRows.length === 0) { + throw new BadRequestException({ + code: 'ASSET_OPERATIONAL_RELATION_REQUIRED', + message: 'La organización no tiene un rol Operadora activo en el área seleccionada', + }); + } + if (!parentId) { + throw new BadRequestException({ + code: 'ASSET_OPERATIONAL_AREA_OUTSIDE_HIERARCHY', + message: 'El activo debe estar contenido físicamente dentro del área operativa seleccionada', + }); + } + const [inside] = (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 current ON parent.id = current.parent_id + ) + SELECT 1 FROM ancestors WHERE id = $2 LIMIT 1 + `, [parentId, operationalAreaId])) as unknown[]; + if (!inside) { + throw new BadRequestException({ + code: 'ASSET_OPERATIONAL_AREA_OUTSIDE_HIERARCHY', + message: 'El área operativa seleccionada debe formar parte de la jerarquía física del activo', + }); + } + } + + private async assertHierarchyMoveKeepsOperationalAssignments( + manager: EntityManager, + assetId: string, + nextParentId: string | null, + ): Promise { + const [invalid] = (await manager.query(` + WITH RECURSIVE + subtree AS ( + SELECT id, code, name, parent_id, operational_area_id + FROM assets + WHERE id = $1 + UNION ALL + SELECT child.id, child.code, child.name, child.parent_id, child.operational_area_id + FROM assets child + INNER JOIN subtree parent ON child.parent_id = parent.id + ), + lineage AS ( + SELECT + subtree.id AS asset_id, + CASE WHEN subtree.id = $1 THEN $2::uuid ELSE subtree.parent_id END AS ancestor_id + FROM subtree + WHERE CASE WHEN subtree.id = $1 THEN $2::uuid ELSE subtree.parent_id END IS NOT NULL + UNION ALL + SELECT + lineage.asset_id, + CASE WHEN parent.id = $1 THEN $2::uuid ELSE parent.parent_id END AS ancestor_id + FROM lineage + INNER JOIN assets parent ON parent.id = lineage.ancestor_id + WHERE CASE WHEN parent.id = $1 THEN $2::uuid ELSE parent.parent_id END IS NOT NULL + ) + SELECT subtree.id, subtree.code, subtree.name + FROM subtree + WHERE subtree.operational_area_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 + FROM lineage + WHERE lineage.asset_id = subtree.id + AND lineage.ancestor_id = subtree.operational_area_id + ) + LIMIT 1 + `, [assetId, nextParentId])) as Array<{ id: string; code: string; name: string }>; + if (invalid) { + throw new ConflictException({ + code: 'ASSET_HIERARCHY_MOVE_BREAKS_OPERATIONAL_CONTEXT', + message: `El cambio de jerarquía dejaría al activo ${invalid.code} · ${invalid.name} fuera de su área operativa`, + assetId: invalid.id, + }); + } + } + + private async assertOperationalAnchorCanBeInactivated( + manager: EntityManager, + asset: Asset, + ): Promise { + const type = await this.requireType(manager, asset.assetTypeId); + if (type.operationalRole === AssetTypeOperationalRole.GENERIC) return; + const relationColumn = type.operationalRole === AssetTypeOperationalRole.AREA + ? 'area_id' + : 'company_id'; + const assignmentColumn = type.operationalRole === AssetTypeOperationalRole.AREA + ? 'operational_area_id' + : 'operator_company_id'; + const [usage] = (await manager.query(` + SELECT + (SELECT COUNT(*)::integer FROM area_company_relations + WHERE ${relationColumn} = $1 AND valid_until IS NULL) AS "activeRelations", + (SELECT COUNT(*)::integer FROM assets + WHERE ${assignmentColumn} = $1) AS "assignedAssets", + (SELECT COUNT(*)::integer FROM area_legal_rights + WHERE area_id = $1 AND status IN ('ACTIVE','PENDING') AND (valid_until IS NULL OR valid_until >= CURRENT_DATE)) AS "activeLegalRights", + (SELECT COUNT(*)::integer FROM organization_memberships + WHERE (parent_organization_id = $1 OR member_organization_id = $1) AND valid_until IS NULL) AS "activeMemberships", + (SELECT COUNT(*)::integer FROM area_legal_right_organizations participant + JOIN area_legal_rights right_record ON right_record.id=participant.right_id + WHERE participant.organization_id=$1 AND participant.valid_until IS NULL + AND right_record.status IN ('ACTIVE','PENDING') AND (right_record.valid_until IS NULL OR right_record.valid_until >= CURRENT_DATE)) AS "activeLegalParticipations" + `, [asset.id])) as Array<{ activeRelations: number; assignedAssets: number; activeLegalRights: number; activeMemberships: number; activeLegalParticipations: number }>; + const activeRelations = Number(usage?.activeRelations ?? 0); + const assignedAssets = Number(usage?.assignedAssets ?? 0); + const activeLegalRights = Number(usage?.activeLegalRights ?? 0); + const activeMemberships = Number(usage?.activeMemberships ?? 0); + const activeLegalParticipations = Number(usage?.activeLegalParticipations ?? 0); + const blocked = type.operationalRole === AssetTypeOperationalRole.AREA + ? activeRelations + assignedAssets + activeLegalRights + : activeRelations + assignedAssets + activeMemberships + activeLegalParticipations; + if (blocked > 0) { + throw new ConflictException({ + code: 'OPERATIONAL_ANCHOR_IN_USE', + message: type.operationalRole === AssetTypeOperationalRole.AREA + ? 'No se puede inactivar el área mientras tenga relaciones, activos operativos o derechos vigentes asociados' + : 'No se puede inactivar la organización mientras tenga relaciones, activos, composición UTE o participación legal vigente', + activeRelations, assignedAssets, activeLegalRights, activeMemberships, activeLegalParticipations, + }); + } + } + + private loadDefinitions(manager: EntityManager, typeId: string) { + return manager.getRepository(AssetAttributeDefinition).find({ + where: { assetTypeId: typeId }, + order: { sortOrder: 'ASC', name: 'ASC' }, + }); + } + + private async replaceAttributeValues( + manager: EntityManager, + assetId: string, + values: Array<{ definitionId: string; value: unknown }>, + userId: string, + ): Promise { + const repository = manager.getRepository(AssetAttributeValue); + await repository.delete({ assetId }); + if (values.length) { + await repository.save( + values.map((entry) => + repository.create({ + assetId, + definitionId: entry.definitionId, + value: entry.value, + updatedBy: userId, + }), + ), + ); + } + } + + private async loadView(manager: EntityManager, id: string): Promise { + const rows = (await manager.query( + `${this.detailQuery()} WHERE asset.id = $1`, + [id], + )) as AssetView[]; + if (!rows[0]) throw assetNotFound(); + return rows[0]; + } + + private listQuery(where: string): string { + return ` + SELECT + asset.id, + asset.code, + asset.name, + asset.common_name AS "commonName", + JSONB_BUILD_OBJECT('id', asset_type.id, 'code', asset_type.code, 'name', asset_type.name) AS type, + CASE WHEN parent.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( + 'id', parent.id, 'code', parent.code, 'name', parent.name + ) END AS parent, + CASE WHEN operational_area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( + 'id', operational_area.id, 'code', operational_area.code, 'name', operational_area.name + ) END AS "operationalArea", + CASE WHEN operator_company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( + 'id', operator_company.id, 'code', operator_company.code, 'name', operator_company.name + ) END AS "operatorCompany", + asset.information_status AS "informationStatus", + asset.operational_status AS "operationalStatus", + (SELECT COUNT(*)::integer FROM assets child WHERE child.parent_id = asset.id) AS "childrenCount", + (current_geometry.asset_id IS NOT NULL) AS "hasGeometry", + current_geometry.geometry_type AS "geometryType", + (SELECT COUNT(*)::integer FROM asset_media media WHERE media.asset_id = asset.id AND media.deleted_at IS NULL) AS "mediaCount", + asset.data_origin AS "dataOrigin", + (asset.provenance_verified_at IS NOT NULL) AS "provenanceVerified", + asset.current_version AS "currentVersion", + asset.updated_at AS "updatedAt" + FROM assets asset + INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id + LEFT JOIN assets parent ON parent.id = asset.parent_id + LEFT JOIN assets operational_area ON operational_area.id = asset.operational_area_id + LEFT JOIN assets operator_company ON operator_company.id = asset.operator_company_id + LEFT JOIN asset_geometries current_geometry ON current_geometry.asset_id = asset.id + ${where} + ORDER BY asset.name ASC, asset.code ASC + `; + } + + private detailQuery(): string { + return ` + SELECT + asset.id, + asset.code, + asset.name, + asset.common_name AS "commonName", + asset.description, + JSONB_BUILD_OBJECT('id', asset_type.id, 'code', asset_type.code, 'name', asset_type.name) AS type, + CASE WHEN parent.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( + 'id', parent.id, 'code', parent.code, 'name', parent.name + ) END AS parent, + CASE WHEN operational_area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( + 'id', operational_area.id, 'code', operational_area.code, 'name', operational_area.name + ) END AS "operationalArea", + CASE WHEN operator_company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( + 'id', operator_company.id, 'code', operator_company.code, 'name', operator_company.name + ) END AS "operatorCompany", + asset.information_status AS "informationStatus", + asset.operational_status AS "operationalStatus", + (SELECT COUNT(*)::integer FROM assets child WHERE child.parent_id = asset.id) AS "childrenCount", + (current_geometry.asset_id IS NOT NULL) AS "hasGeometry", + current_geometry.geometry_type AS "geometryType", + (SELECT COUNT(*)::integer FROM asset_media media WHERE media.asset_id = asset.id AND media.deleted_at IS NULL) AS "mediaCount", + asset.data_origin AS "dataOrigin", + (asset.provenance_verified_at IS NOT NULL) AS "provenanceVerified", + asset.current_version AS "currentVersion", + asset.created_at AS "createdAt", + asset.updated_at AS "updatedAt", + asset.created_by AS "createdBy", + asset.updated_by AS "updatedBy", + COALESCE(( + SELECT JSONB_AGG(JSONB_BUILD_OBJECT( + 'definitionId', definition.id, + 'code', definition.code, + 'name', definition.name, + 'dataType', definition.data_type, + 'isRequired', definition.is_required, + 'unit', definition.unit, + 'options', definition.options, + 'sortOrder', definition.sort_order, + 'value', value.value + ) ORDER BY definition.sort_order, definition.name) + FROM asset_attribute_definitions definition + LEFT JOIN asset_attribute_values value + ON value.definition_id = definition.id AND value.asset_id = asset.id + WHERE definition.asset_type_id = asset.asset_type_id + AND definition.is_active = true + ), '[]'::jsonb) AS attributes + FROM assets asset + INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id + LEFT JOIN assets parent ON parent.id = asset.parent_id + LEFT JOIN assets operational_area ON operational_area.id = asset.operational_area_id + LEFT JOIN assets operator_company ON operator_company.id = asset.operator_company_id + LEFT JOIN asset_geometries current_geometry ON current_geometry.asset_id = asset.id + `; + } + + private auditView(asset: AssetView): Record { + return { + id: asset.id, + code: asset.code, + name: asset.name, + description: asset.description, + type: asset.type, + parent: asset.parent, + operationalArea: asset.operationalArea, + operatorCompany: asset.operatorCompany, + informationStatus: asset.informationStatus, + operationalStatus: asset.operationalStatus, + attributes: asset.attributes.map((attribute) => ({ + definitionId: attribute.definitionId, + code: attribute.code, + value: attribute.value, + })), + }; + } + + private assetConflict(): ConflictException { + return new ConflictException({ + code: 'ASSET_CODE_ALREADY_EXISTS', + message: 'Ya existe un activo con ese código', + }); + } +} diff --git a/api-v3/src/asset-master/dto/add-area-legal-right-organization.dto.ts b/api-v3/src/asset-master/dto/add-area-legal-right-organization.dto.ts new file mode 100644 index 0000000..0e9d100 --- /dev/null +++ b/api-v3/src/asset-master/dto/add-area-legal-right-organization.dto.ts @@ -0,0 +1,2 @@ +import { Transform,Type } from 'class-transformer'; import { IsDateString,IsEnum,IsNumber,IsOptional,IsString,IsUUID,Max,MaxLength,Min } from 'class-validator'; import { AreaLegalRightOrganizationRole } from '../../database/entities'; +export class AddAreaLegalRightOrganizationDto { @IsUUID('4') organizationId!:string; @IsEnum(AreaLegalRightOrganizationRole) role!:AreaLegalRightOrganizationRole; @IsOptional() @Type(()=>Number) @IsNumber({maxDecimalPlaces:4}) @Min(0.0001) @Max(100) participationPercent?:number|null; @IsOptional() @IsDateString() validFrom?:string|null; @IsOptional() @Transform(({value})=>typeof value==='string'&&value.trim()?value.trim():null) @IsString() @MaxLength(2000) notes?:string|null; } diff --git a/api-v3/src/asset-master/dto/add-organization-membership.dto.ts b/api-v3/src/asset-master/dto/add-organization-membership.dto.ts new file mode 100644 index 0000000..ed31601 --- /dev/null +++ b/api-v3/src/asset-master/dto/add-organization-membership.dto.ts @@ -0,0 +1,2 @@ +import { Transform,Type } from 'class-transformer'; import { IsDateString,IsEnum,IsNumber,IsOptional,IsString,IsUUID,Max,MaxLength,Min } from 'class-validator'; import { OrganizationMembershipRole } from '../../database/entities'; +export class AddOrganizationMembershipDto { @IsUUID('4') memberOrganizationId!:string; @IsEnum(OrganizationMembershipRole) role:OrganizationMembershipRole=OrganizationMembershipRole.MEMBER; @IsOptional() @Type(()=>Number) @IsNumber({maxDecimalPlaces:4}) @Min(0.0001) @Max(100) participationPercent?:number|null; @IsOptional() @IsDateString() validFrom?:string|null; @IsOptional() @IsUUID('4') sourceDocumentId?:string|null; @IsOptional() @Transform(({value})=>typeof value==='string'&&value.trim()?value.trim():null) @IsString() @MaxLength(2000) notes?:string|null; } diff --git a/api-v3/src/asset-master/dto/change-asset-context.dto.ts b/api-v3/src/asset-master/dto/change-asset-context.dto.ts new file mode 100644 index 0000000..831a70b --- /dev/null +++ b/api-v3/src/asset-master/dto/change-asset-context.dto.ts @@ -0,0 +1,27 @@ +import { Transform, Type } from 'class-transformer'; +import { IsDate, IsOptional, IsString, IsUUID, MaxLength, MinLength } from 'class-validator'; + +export class ChangeAssetContextDto { + @IsOptional() + @IsUUID('4') + parentId?: string | null; + + @IsOptional() + @IsUUID('4') + operationalAreaId?: string | null; + + @IsOptional() + @IsUUID('4') + operatorCompanyId?: string | null; + + @IsOptional() + @Type(() => Date) + @IsDate() + effectiveAt?: Date; + + @Transform(({ value }) => typeof value === 'string' ? value.trim() : value) + @IsString() + @MinLength(5) + @MaxLength(2000) + reason!: string; +} diff --git a/api-v3/src/asset-master/dto/change-asset-operational-status.dto.ts b/api-v3/src/asset-master/dto/change-asset-operational-status.dto.ts new file mode 100644 index 0000000..2cf3e14 --- /dev/null +++ b/api-v3/src/asset-master/dto/change-asset-operational-status.dto.ts @@ -0,0 +1,3 @@ +import { IsEnum } from 'class-validator'; +import { AssetOperationalStatus } from '../../database/entities'; +export class ChangeAssetOperationalStatusDto { @IsEnum(AssetOperationalStatus) status!: AssetOperationalStatus; } diff --git a/api-v3/src/asset-master/dto/change-asset-status.dto.ts b/api-v3/src/asset-master/dto/change-asset-status.dto.ts new file mode 100644 index 0000000..0012a3a --- /dev/null +++ b/api-v3/src/asset-master/dto/change-asset-status.dto.ts @@ -0,0 +1,7 @@ +import { IsEnum } from 'class-validator'; +import { AssetInformationStatus } from '../../database/entities'; + +export class ChangeAssetStatusDto { + @IsEnum(AssetInformationStatus) + informationStatus!: AssetInformationStatus; +} diff --git a/api-v3/src/asset-master/dto/create-area-company-relation.dto.ts b/api-v3/src/asset-master/dto/create-area-company-relation.dto.ts new file mode 100644 index 0000000..70d264b --- /dev/null +++ b/api-v3/src/asset-master/dto/create-area-company-relation.dto.ts @@ -0,0 +1,13 @@ +import { Transform, Type } from 'class-transformer'; +import { IsEnum, IsNumber, IsOptional, IsString, IsUUID, Max, MaxLength, Min, MinLength } from 'class-validator'; +import { AreaOrganizationRole } from '../../database/entities'; + +export class CreateAreaCompanyRelationDto { + @IsUUID('4') areaId!: string; + @IsUUID('4') companyId!: string; + @IsOptional() @IsEnum(AreaOrganizationRole) relationRole: AreaOrganizationRole = AreaOrganizationRole.OPERATOR; + @IsOptional() @Type(() => Number) @IsNumber({ maxDecimalPlaces: 4 }) @Min(0.0001) @Max(100) participationPercent?: number | null; + @IsOptional() @Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null) @IsString() @MaxLength(240) legalInstrument?: string | null; + @IsOptional() @IsUUID('4') sourceDocumentId?: string | null; + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) @IsString() @MinLength(3) @MaxLength(1000) reason!: string; +} diff --git a/api-v3/src/asset-master/dto/create-area-legal-right.dto.ts b/api-v3/src/asset-master/dto/create-area-legal-right.dto.ts new file mode 100644 index 0000000..0294353 --- /dev/null +++ b/api-v3/src/asset-master/dto/create-area-legal-right.dto.ts @@ -0,0 +1,2 @@ +import { Transform } from 'class-transformer'; import { IsDateString,IsEnum,IsOptional,IsString,IsUUID,MaxLength,MinLength } from 'class-validator'; import { AreaLegalRightStatus,AreaLegalRightType } from '../../database/entities'; +export class CreateAreaLegalRightDto { @IsEnum(AreaLegalRightType) rightType!:AreaLegalRightType; @Transform(({value})=>typeof value==='string'?value.trim():value) @IsString() @MinLength(3) @MaxLength(260) name!:string; @IsOptional() @Transform(({value})=>typeof value==='string'&&value.trim()?value.trim():null) @IsString() @MaxLength(180) instrumentNumber?:string|null; @IsOptional() @IsDateString() validFrom?:string|null; @IsOptional() @IsDateString() validUntil?:string|null; @IsOptional() @IsEnum(AreaLegalRightStatus) status:AreaLegalRightStatus=AreaLegalRightStatus.ACTIVE; @IsOptional() @IsUUID('4') sourceDocumentId?:string|null; @IsOptional() @Transform(({value})=>typeof value==='string'&&value.trim()?value.trim():null) @IsString() @MaxLength(4000) notes?:string|null; } diff --git a/api-v3/src/asset-master/dto/create-asset-media.dto.ts b/api-v3/src/asset-master/dto/create-asset-media.dto.ts new file mode 100644 index 0000000..0685ae1 --- /dev/null +++ b/api-v3/src/asset-master/dto/create-asset-media.dto.ts @@ -0,0 +1,52 @@ +import { Type } from 'class-transformer'; +import { + IsEnum, + IsISO8601, + IsNumber, + IsOptional, + IsString, + Max, + MaxLength, + Min, +} from 'class-validator'; +import { AssetMediaKind } from '../../database/entities'; + +export class CreateAssetMediaDto { + @IsEnum(AssetMediaKind) + kind!: AssetMediaKind; + + @IsOptional() + @IsString() + @MaxLength(200) + title?: string; + + @IsOptional() + @IsString() + @MaxLength(4000) + description?: string; + + @IsOptional() + @IsISO8601({ strict: true }) + capturedAt?: string; + + @IsOptional() + @Type(() => Number) + @IsNumber({ maxDecimalPlaces: 6 }) + @Min(-90) + @Max(90) + latitude?: number; + + @IsOptional() + @Type(() => Number) + @IsNumber({ maxDecimalPlaces: 6 }) + @Min(-180) + @Max(180) + longitude?: number; + + @IsOptional() + @Type(() => Number) + @IsNumber({ maxDecimalPlaces: 3 }) + @Min(0) + @Max(100000) + accuracyM?: number; +} diff --git a/api-v3/src/asset-master/dto/create-asset-type.dto.ts b/api-v3/src/asset-master/dto/create-asset-type.dto.ts new file mode 100644 index 0000000..5c12a03 --- /dev/null +++ b/api-v3/src/asset-master/dto/create-asset-type.dto.ts @@ -0,0 +1,46 @@ +import { Transform } from 'class-transformer'; +import { + ArrayUnique, + IsArray, + IsBoolean, + IsEnum, + IsString, + IsUUID, + Matches, + MaxLength, + MinLength, +} from 'class-validator'; +import { AssetTypeOperationalRole } from '../../database/entities'; + +export class CreateAssetTypeDto { + @Transform(({ value }) => + typeof value === 'string' ? value.trim().toLowerCase() : value, + ) + @IsString() + @MinLength(2) + @MaxLength(80) + @Matches(/^[a-z][a-z0-9_-]+$/) + code!: string; + + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) + @IsString() + @MinLength(1) + @MaxLength(160) + name!: string; + + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) + @IsString() + @MaxLength(2000) + description!: string; + + @IsBoolean() + canBeRoot!: boolean; + + @IsEnum(AssetTypeOperationalRole) + operationalRole = AssetTypeOperationalRole.GENERIC; + + @IsArray() + @ArrayUnique() + @IsUUID('4', { each: true }) + allowedParentTypeIds!: string[]; +} diff --git a/api-v3/src/asset-master/dto/create-asset.dto.ts b/api-v3/src/asset-master/dto/create-asset.dto.ts new file mode 100644 index 0000000..a0064af --- /dev/null +++ b/api-v3/src/asset-master/dto/create-asset.dto.ts @@ -0,0 +1,67 @@ +import { Transform } from 'class-transformer'; +import { + IsEnum, + IsObject, + IsOptional, + IsString, + IsUUID, + Matches, + MaxLength, + MinLength, +} from 'class-validator'; +import { AssetInformationStatus } from '../../database/entities'; + +export class CreateAssetDto { + @Transform(({ value }) => + typeof value === 'string' ? value.trim().toUpperCase() : value, + ) + @IsString() + @MinLength(1) + @MaxLength(120) + @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(200) + commonName?: string | null; + + @IsUUID('4') + typeId!: string; + + @IsOptional() + @IsUUID('4') + parentId?: string | null; + + @IsOptional() + @IsUUID('4') + operationalAreaId?: string | null; + + @IsOptional() + @IsUUID('4') + operatorCompanyId?: string | null; + + @IsOptional() + @Transform(({ value }) => + typeof value === 'string' && value.trim() ? value.trim() : null, + ) + @IsString() + @MaxLength(4000) + description?: string | null; + + @IsOptional() + @IsEnum(AssetInformationStatus) + informationStatus = AssetInformationStatus.DRAFT; + + @IsObject() + attributes!: Record; +} diff --git a/api-v3/src/asset-master/dto/create-attribute-definition.dto.ts b/api-v3/src/asset-master/dto/create-attribute-definition.dto.ts new file mode 100644 index 0000000..d886354 --- /dev/null +++ b/api-v3/src/asset-master/dto/create-attribute-definition.dto.ts @@ -0,0 +1,63 @@ +import { Transform } from 'class-transformer'; +import { + ArrayUnique, + IsArray, + IsBoolean, + IsEnum, + IsInt, + IsOptional, + IsString, + Matches, + Max, + MaxLength, + Min, + MinLength, + ValidateIf, +} from 'class-validator'; +import { AssetAttributeDataType } from '../../database/entities'; + +export class CreateAttributeDefinitionDto { + @Transform(({ value }) => + typeof value === 'string' ? value.trim().toLowerCase() : value, + ) + @IsString() + @MinLength(2) + @MaxLength(80) + @Matches(/^[a-z][a-z0-9_-]+$/) + code!: string; + + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) + @IsString() + @MinLength(1) + @MaxLength(160) + name!: string; + + @IsEnum(AssetAttributeDataType) + dataType!: AssetAttributeDataType; + + @IsBoolean() + isRequired!: boolean; + + @IsOptional() + @Transform(({ value }) => + typeof value === 'string' && value.trim() ? value.trim() : null, + ) + @IsString() + @MaxLength(40) + unit?: string | null; + + @ValidateIf((dto: CreateAttributeDefinitionDto) => + dto.dataType === AssetAttributeDataType.SELECT, + ) + @IsArray() + @ArrayUnique() + @IsString({ each: true }) + @MinLength(1, { each: true }) + @MaxLength(160, { each: true }) + options?: string[]; + + @IsInt() + @Min(0) + @Max(10_000) + sortOrder!: number; +} diff --git a/api-v3/src/asset-master/dto/create-external-identifier.dto.ts b/api-v3/src/asset-master/dto/create-external-identifier.dto.ts new file mode 100644 index 0000000..825cf31 --- /dev/null +++ b/api-v3/src/asset-master/dto/create-external-identifier.dto.ts @@ -0,0 +1,2 @@ +import { Transform } from 'class-transformer'; import { IsDateString,IsOptional,IsString,IsUUID,Matches,MaxLength,MinLength } from 'class-validator'; +export class CreateExternalIdentifierDto { @Transform(({value})=>typeof value==='string'?value.trim().toUpperCase():value) @IsString() @Matches(/^[A-Z0-9][A-Z0-9._/-]{1,79}$/) namespace!:string; @Transform(({value})=>typeof value==='string'?value.trim():value) @IsString() @MinLength(1) @MaxLength(180) value!:string; @IsOptional() @IsDateString() validFrom?:string|null; @IsOptional() @IsUUID('4') sourceDocumentId?:string|null; @IsOptional() @Transform(({value})=>typeof value==='string'&&value.trim()?value.trim():null) @IsString() @MaxLength(2000) notes?:string|null; } diff --git a/api-v3/src/asset-master/dto/create-field-discovery.dto.ts b/api-v3/src/asset-master/dto/create-field-discovery.dto.ts new file mode 100644 index 0000000..afe76d0 --- /dev/null +++ b/api-v3/src/asset-master/dto/create-field-discovery.dto.ts @@ -0,0 +1,53 @@ +import { Transform } from 'class-transformer'; +import { IsObject, IsOptional, IsString, IsUUID, Matches, MaxLength, MinLength } from 'class-validator'; + +export class CreateFieldDiscoveryDto { + @IsUUID('4') + visitId!: string; + + @Transform(({ value }) => typeof value === 'string' ? value.trim().toUpperCase() : value) + @IsString() + @MinLength(1) + @MaxLength(120) + @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(200) + commonName?: string | null; + + @IsUUID('4') + typeId!: string; + + @IsUUID('4') + parentId!: string; + + @IsUUID('4') + operationalAreaId!: string; + + @IsUUID('4') + operatorCompanyId!: string; + + @IsOptional() + @Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null) + @IsString() + @MaxLength(4000) + description?: string | null; + + @IsOptional() + @Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null) + @IsString() + @MaxLength(4000) + discoveryNotes?: string | null; + + @IsObject() + attributes!: Record; +} diff --git a/api-v3/src/asset-master/dto/create-source-document.dto.ts b/api-v3/src/asset-master/dto/create-source-document.dto.ts new file mode 100644 index 0000000..e874b5c --- /dev/null +++ b/api-v3/src/asset-master/dto/create-source-document.dto.ts @@ -0,0 +1,2 @@ +import { Transform } from 'class-transformer'; import { IsDateString,IsEnum,IsOptional,IsString,MaxLength,MinLength } from 'class-validator'; import { SourceDocumentType } from '../../database/entities'; +export class CreateSourceDocumentDto { @IsEnum(SourceDocumentType) documentType!:SourceDocumentType; @IsOptional() @Transform(({value})=>typeof value==='string'&&value.trim()?value.trim():null) @IsString() @MaxLength(160) documentNumber?:string|null; @Transform(({value})=>typeof value==='string'?value.trim():value) @IsString() @MinLength(3) @MaxLength(300) title!:string; @IsOptional() @Transform(({value})=>typeof value==='string'&&value.trim()?value.trim():null) @IsString() @MaxLength(240) issuer?:string|null; @IsOptional() @IsDateString() documentDate?:string|null; @IsOptional() @Transform(({value})=>typeof value==='string'&&value.trim()?value.trim():null) @IsString() @MaxLength(500) externalReference?:string|null; @IsOptional() @Transform(({value})=>typeof value==='string'&&value.trim()?value.trim():null) @IsString() @MaxLength(4000) notes?:string|null; } diff --git a/api-v3/src/asset-master/dto/end-area-company-relation.dto.ts b/api-v3/src/asset-master/dto/end-area-company-relation.dto.ts new file mode 100644 index 0000000..5327031 --- /dev/null +++ b/api-v3/src/asset-master/dto/end-area-company-relation.dto.ts @@ -0,0 +1,10 @@ +import { Transform } from 'class-transformer'; +import { IsString, MaxLength, MinLength } from 'class-validator'; + +export class EndAreaCompanyRelationDto { + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) + @IsString() + @MinLength(3) + @MaxLength(1000) + reason!: string; +} diff --git a/api-v3/src/asset-master/dto/end-area-legal-right-organization.dto.ts b/api-v3/src/asset-master/dto/end-area-legal-right-organization.dto.ts new file mode 100644 index 0000000..c8652f0 --- /dev/null +++ b/api-v3/src/asset-master/dto/end-area-legal-right-organization.dto.ts @@ -0,0 +1 @@ +import { Transform } from 'class-transformer'; import { IsDateString,IsOptional,IsString,MaxLength,MinLength } from 'class-validator'; export class EndAreaLegalRightOrganizationDto { @IsOptional() @IsDateString() validUntil?:string|null; @Transform(({value})=>typeof value==='string'?value.trim():value) @IsString() @MinLength(3) @MaxLength(1000) reason!:string; } diff --git a/api-v3/src/asset-master/dto/end-external-identifier.dto.ts b/api-v3/src/asset-master/dto/end-external-identifier.dto.ts new file mode 100644 index 0000000..6d039a4 --- /dev/null +++ b/api-v3/src/asset-master/dto/end-external-identifier.dto.ts @@ -0,0 +1 @@ +import { Transform } from 'class-transformer'; import { IsString,MaxLength,MinLength } from 'class-validator'; export class EndExternalIdentifierDto { @Transform(({value})=>typeof value==='string'?value.trim():value) @IsString() @MinLength(3) @MaxLength(1000) reason!:string; } diff --git a/api-v3/src/asset-master/dto/end-organization-membership.dto.ts b/api-v3/src/asset-master/dto/end-organization-membership.dto.ts new file mode 100644 index 0000000..96ebb17 --- /dev/null +++ b/api-v3/src/asset-master/dto/end-organization-membership.dto.ts @@ -0,0 +1 @@ +import { Transform } from 'class-transformer'; import { IsDateString,IsOptional,IsString,MaxLength,MinLength } from 'class-validator'; export class EndOrganizationMembershipDto { @IsOptional() @IsDateString() validUntil?:string|null; @Transform(({value})=>typeof value==='string'?value.trim():value) @IsString() @MinLength(3) @MaxLength(1000) reason!:string; } diff --git a/api-v3/src/asset-master/dto/link-asset-source-document.dto.ts b/api-v3/src/asset-master/dto/link-asset-source-document.dto.ts new file mode 100644 index 0000000..7db2be0 --- /dev/null +++ b/api-v3/src/asset-master/dto/link-asset-source-document.dto.ts @@ -0,0 +1,2 @@ +import { Transform } from 'class-transformer'; import { IsEnum,IsOptional,IsString,MaxLength } from 'class-validator'; import { AssetSourceDocumentRelationType } from '../../database/entities'; +export class LinkAssetSourceDocumentDto { @IsEnum(AssetSourceDocumentRelationType) relationType:AssetSourceDocumentRelationType=AssetSourceDocumentRelationType.SOURCE; @IsOptional() @Transform(({value})=>typeof value==='string'&&value.trim()?value.trim():null) @IsString() @MaxLength(2000) notes?:string|null; } diff --git a/api-v3/src/asset-master/dto/list-area-company-relations-query.dto.ts b/api-v3/src/asset-master/dto/list-area-company-relations-query.dto.ts new file mode 100644 index 0000000..d383e76 --- /dev/null +++ b/api-v3/src/asset-master/dto/list-area-company-relations-query.dto.ts @@ -0,0 +1,17 @@ +import { Transform } from 'class-transformer'; +import { IsBoolean, IsOptional, IsUUID } from 'class-validator'; + +export class ListAreaCompanyRelationsQueryDto { + @IsOptional() + @IsUUID('4') + areaId?: string; + + @IsOptional() + @IsUUID('4') + companyId?: string; + + @IsOptional() + @Transform(({ value }) => value === true || value === 'true' || value === '1') + @IsBoolean() + includeHistory = false; +} diff --git a/api-v3/src/asset-master/dto/list-asset-tree-children-query.dto.ts b/api-v3/src/asset-master/dto/list-asset-tree-children-query.dto.ts new file mode 100644 index 0000000..da5cb49 --- /dev/null +++ b/api-v3/src/asset-master/dto/list-asset-tree-children-query.dto.ts @@ -0,0 +1,16 @@ +import { Type } from 'class-transformer'; +import { IsInt, IsOptional, IsUUID, Max, Min } from 'class-validator'; +import { ListAssetTreeQueryDto } from './list-asset-tree-query.dto'; + +export class ListAssetTreeChildrenQueryDto extends ListAssetTreeQueryDto { + @IsOptional() + @IsUUID('4') + parentId?: string; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(200) + limit = 100; +} diff --git a/api-v3/src/asset-master/dto/list-asset-tree-query.dto.ts b/api-v3/src/asset-master/dto/list-asset-tree-query.dto.ts new file mode 100644 index 0000000..21f1d06 --- /dev/null +++ b/api-v3/src/asset-master/dto/list-asset-tree-query.dto.ts @@ -0,0 +1,40 @@ +import { Transform } from 'class-transformer'; +import { IsBoolean, IsEnum, IsOptional, IsString, IsUUID, MaxLength } from 'class-validator'; +import { AssetInformationStatus, AssetOperationalStatus } from '../../database/entities'; + +export class ListAssetTreeQueryDto { + @IsOptional() + @IsString() + @MaxLength(200) + search?: string; + + @IsOptional() + @IsUUID('4') + typeId?: string; + + @IsOptional() + @IsEnum(AssetInformationStatus) + status?: AssetInformationStatus; + + @IsOptional() + @IsEnum(AssetOperationalStatus) + operationalStatus?: AssetOperationalStatus; + + @IsOptional() + @IsUUID('4') + operationalAreaId?: string; + + @IsOptional() + @IsUUID('4') + operatorCompanyId?: string; + + @IsOptional() + @Transform(({ value }) => value === 'true' ? true : value === 'false' ? false : value) + @IsBoolean() + needsValidation?: boolean; + + @IsOptional() + @Transform(({ value }) => value === 'true' ? true : value === 'false' ? false : value) + @IsBoolean() + hasGeometry?: boolean; +} diff --git a/api-v3/src/asset-master/dto/list-asset-versions-query.dto.ts b/api-v3/src/asset-master/dto/list-asset-versions-query.dto.ts new file mode 100644 index 0000000..13e1e64 --- /dev/null +++ b/api-v3/src/asset-master/dto/list-asset-versions-query.dto.ts @@ -0,0 +1,62 @@ +import { Transform, Type } from 'class-transformer'; +import { + IsEnum, + IsInt, + IsISO8601, + IsOptional, + IsString, + IsUUID, + Max, + MaxLength, + Min, +} from 'class-validator'; +import { + AssetInformationStatus, + AssetVersionChangeType, +} from '../../database/entities'; + +const trim = ({ value }: { value: unknown }) => + typeof value === 'string' ? value.trim() : value; + +export class AssetVersionPageQueryDto { + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page = 1; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + pageSize = 25; +} + +export class ListAssetVersionsQueryDto extends AssetVersionPageQueryDto { + @Transform(trim) + @IsOptional() + @IsString() + @MaxLength(200) + search?: string; + + @IsOptional() + @IsUUID('4') + typeId?: string; + + @IsOptional() + @IsEnum(AssetInformationStatus) + status?: AssetInformationStatus; + + @IsOptional() + @IsEnum(AssetVersionChangeType) + changeType?: AssetVersionChangeType; + + @IsOptional() + @IsISO8601({ strict: true }) + from?: string; + + @IsOptional() + @IsISO8601({ strict: true }) + to?: string; +} diff --git a/api-v3/src/asset-master/dto/list-assets-query.dto.ts b/api-v3/src/asset-master/dto/list-assets-query.dto.ts new file mode 100644 index 0000000..3c8fca4 --- /dev/null +++ b/api-v3/src/asset-master/dto/list-assets-query.dto.ts @@ -0,0 +1,68 @@ +import { Transform, Type } from 'class-transformer'; +import { + IsBoolean, + IsEnum, + IsInt, + IsOptional, + IsString, + IsUUID, + Max, + MaxLength, + Min, +} from 'class-validator'; +import { AssetInformationStatus, AssetOperationalStatus } from '../../database/entities'; + +export class ListAssetsQueryDto { + @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() + @IsUUID('4') + typeId?: string; + + @IsOptional() + @IsEnum(AssetInformationStatus) + status?: AssetInformationStatus; + + + @IsOptional() + @IsEnum(AssetOperationalStatus) + operationalStatus?: AssetOperationalStatus; + + @IsOptional() + @Transform(({ value }) => value === 'true' ? true : value === 'false' ? false : value) + @IsBoolean() + needsValidation?: boolean; + + @IsOptional() + @Transform(({ value }) => value === 'true' ? true : value === 'false' ? false : value) + @IsBoolean() + hasGeometry?: boolean; + + @IsOptional() + @IsUUID('4') + parentId?: string; + + @IsOptional() + @IsUUID('4') + operationalAreaId?: string; + + @IsOptional() + @IsUUID('4') + operatorCompanyId?: string; +} diff --git a/api-v3/src/asset-master/dto/list-field-discoveries-query.dto.ts b/api-v3/src/asset-master/dto/list-field-discoveries-query.dto.ts new file mode 100644 index 0000000..32bf2e3 --- /dev/null +++ b/api-v3/src/asset-master/dto/list-field-discoveries-query.dto.ts @@ -0,0 +1,26 @@ +import { Transform } from 'class-transformer'; +import { IsIn, IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; + +export class ListFieldDiscoveriesQueryDto { + @IsOptional() + @IsIn(['PENDING', 'APPROVED', 'MATCHED', 'REJECTED']) + status?: 'PENDING' | 'APPROVED' | 'MATCHED' | 'REJECTED'; + + @IsOptional() + @Transform(({ value }) => typeof value === 'string' ? value.trim() : value) + @IsString() + search?: string; + + @IsOptional() + @Transform(({ value }) => Number(value ?? 1)) + @IsInt() + @Min(1) + page = 1; + + @IsOptional() + @Transform(({ value }) => Number(value ?? 25)) + @IsInt() + @Min(1) + @Max(100) + pageSize = 25; +} diff --git a/api-v3/src/asset-master/dto/list-operational-areas-query.dto.ts b/api-v3/src/asset-master/dto/list-operational-areas-query.dto.ts new file mode 100644 index 0000000..5736318 --- /dev/null +++ b/api-v3/src/asset-master/dto/list-operational-areas-query.dto.ts @@ -0,0 +1,7 @@ +import { IsOptional, IsUUID } from 'class-validator'; + +export class ListOperationalAreasQueryDto { + @IsOptional() + @IsUUID('4') + parentId?: string; +} diff --git a/api-v3/src/asset-master/dto/list-temporal-assets-query.dto.ts b/api-v3/src/asset-master/dto/list-temporal-assets-query.dto.ts new file mode 100644 index 0000000..d6dcd43 --- /dev/null +++ b/api-v3/src/asset-master/dto/list-temporal-assets-query.dto.ts @@ -0,0 +1,50 @@ +import { Transform, Type } from 'class-transformer'; +import { + IsEnum, + IsInt, + IsISO8601, + IsOptional, + IsString, + IsUUID, + Max, + MaxLength, + Min, +} from 'class-validator'; +import { AssetInformationStatus } from '../../database/entities'; + +const trim = ({ value }: { value: unknown }) => + typeof value === 'string' ? value.trim() : value; + +export class TemporalAtQueryDto { + @IsISO8601({ strict: true }) + at!: string; +} + +export class ListTemporalAssetsQueryDto extends TemporalAtQueryDto { + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page = 1; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + pageSize = 25; + + @Transform(trim) + @IsOptional() + @IsString() + @MaxLength(200) + search?: string; + + @IsOptional() + @IsUUID('4') + typeId?: string; + + @IsOptional() + @IsEnum(AssetInformationStatus) + status?: AssetInformationStatus; +} diff --git a/api-v3/src/asset-master/dto/map-assets-query.dto.ts b/api-v3/src/asset-master/dto/map-assets-query.dto.ts new file mode 100644 index 0000000..77bb66d --- /dev/null +++ b/api-v3/src/asset-master/dto/map-assets-query.dto.ts @@ -0,0 +1,24 @@ +import { IsEnum, IsOptional, IsString, IsUUID, Matches } from 'class-validator'; +import { + AssetGeometryType, + AssetInformationStatus, +} from '../../database/entities'; + +export class MapAssetsQueryDto { + @IsOptional() + @IsString() + @Matches(/^-?\d+(?:\.\d+)?,-?\d+(?:\.\d+)?,-?\d+(?:\.\d+)?,-?\d+(?:\.\d+)?$/) + bbox?: string; + + @IsOptional() + @IsUUID('4') + typeId?: string; + + @IsOptional() + @IsEnum(AssetInformationStatus) + status?: AssetInformationStatus; + + @IsOptional() + @IsEnum(AssetGeometryType) + geometryType?: AssetGeometryType; +} diff --git a/api-v3/src/asset-master/dto/parent-options-query.dto.ts b/api-v3/src/asset-master/dto/parent-options-query.dto.ts new file mode 100644 index 0000000..38b9b8e --- /dev/null +++ b/api-v3/src/asset-master/dto/parent-options-query.dto.ts @@ -0,0 +1,15 @@ +import { IsOptional, IsString, IsUUID, MaxLength } from 'class-validator'; + +export class ParentOptionsQueryDto { + @IsUUID('4') + childTypeId!: string; + + @IsOptional() + @IsUUID('4') + assetId?: string; + + @IsOptional() + @IsString() + @MaxLength(200) + search?: string; +} diff --git a/api-v3/src/asset-master/dto/review-field-discovery.dto.ts b/api-v3/src/asset-master/dto/review-field-discovery.dto.ts new file mode 100644 index 0000000..5174478 --- /dev/null +++ b/api-v3/src/asset-master/dto/review-field-discovery.dto.ts @@ -0,0 +1,29 @@ +import { Transform } from 'class-transformer'; +import { IsOptional, IsString, IsUUID, MaxLength, MinLength } from 'class-validator'; + +export class ReviewFieldDiscoveryDto { + @IsOptional() + @Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null) + @IsString() + @MaxLength(4000) + notes?: string | null; +} + +export class RejectFieldDiscoveryDto { + @Transform(({ value }) => typeof value === 'string' ? value.trim() : value) + @IsString() + @MinLength(5) + @MaxLength(4000) + reason!: string; +} + +export class MatchFieldDiscoveryDto { + @IsUUID('4') + matchedAssetId!: string; + + @Transform(({ value }) => typeof value === 'string' ? value.trim() : value) + @IsString() + @MinLength(5) + @MaxLength(4000) + reason!: string; +} diff --git a/api-v3/src/asset-master/dto/update-area-legal-right.dto.ts b/api-v3/src/asset-master/dto/update-area-legal-right.dto.ts new file mode 100644 index 0000000..815ebfd --- /dev/null +++ b/api-v3/src/asset-master/dto/update-area-legal-right.dto.ts @@ -0,0 +1,2 @@ +import { Transform } from 'class-transformer'; import { IsDateString,IsEnum,IsOptional,IsString,IsUUID,MaxLength,MinLength } from 'class-validator'; import { AreaLegalRightStatus } from '../../database/entities'; +export class UpdateAreaLegalRightDto { @IsOptional() @Transform(({value})=>typeof value==='string'?value.trim():value) @IsString() @MinLength(3) @MaxLength(260) name?:string; @IsOptional() @Transform(({value})=>typeof value==='string'&&value.trim()?value.trim():null) @IsString() @MaxLength(180) instrumentNumber?:string|null; @IsOptional() @IsDateString() validFrom?:string|null; @IsOptional() @IsDateString() validUntil?:string|null; @IsOptional() @IsEnum(AreaLegalRightStatus) status?:AreaLegalRightStatus; @IsOptional() @IsUUID('4') sourceDocumentId?:string|null; @IsOptional() @Transform(({value})=>typeof value==='string'&&value.trim()?value.trim():null) @IsString() @MaxLength(4000) notes?:string|null; @Transform(({value})=>typeof value==='string'?value.trim():value) @IsString() @MinLength(3) @MaxLength(1000) reason!:string; } diff --git a/api-v3/src/asset-master/dto/update-asset-media.dto.ts b/api-v3/src/asset-master/dto/update-asset-media.dto.ts new file mode 100644 index 0000000..4510f23 --- /dev/null +++ b/api-v3/src/asset-master/dto/update-asset-media.dto.ts @@ -0,0 +1,47 @@ +import { Type } from 'class-transformer'; +import { + IsISO8601, + IsNumber, + IsOptional, + IsString, + Max, + MaxLength, + Min, +} from 'class-validator'; + +export class UpdateAssetMediaDto { + @IsOptional() + @IsString() + @MaxLength(200) + title?: string | null; + + @IsOptional() + @IsString() + @MaxLength(4000) + description?: string | null; + + @IsOptional() + @IsISO8601({ strict: true }) + capturedAt?: 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; +} diff --git a/api-v3/src/asset-master/dto/update-asset-provenance.dto.ts b/api-v3/src/asset-master/dto/update-asset-provenance.dto.ts new file mode 100644 index 0000000..9dfa7eb --- /dev/null +++ b/api-v3/src/asset-master/dto/update-asset-provenance.dto.ts @@ -0,0 +1,39 @@ +import { Transform } from 'class-transformer'; +import { + IsEnum, + IsISO8601, + IsOptional, + IsString, + MaxLength, +} from 'class-validator'; +import { AssetDataOrigin } from '../../database/entities'; + +const nullableTrim = ({ value }: { value: unknown }) => + typeof value === 'string' ? value.trim() || null : value; + +export class UpdateAssetProvenanceDto { + @IsEnum(AssetDataOrigin) + origin!: AssetDataOrigin; + + @IsOptional() + @Transform(nullableTrim) + @IsString() + @MaxLength(160) + sourceName?: string | null; + + @IsOptional() + @Transform(nullableTrim) + @IsString() + @MaxLength(255) + sourceReference?: string | null; + + @IsOptional() + @IsISO8601({ strict: true }) + observedAt?: string | null; + + @IsOptional() + @Transform(nullableTrim) + @IsString() + @MaxLength(4000) + notes?: string | null; +} diff --git a/api-v3/src/asset-master/dto/update-asset-type.dto.ts b/api-v3/src/asset-master/dto/update-asset-type.dto.ts new file mode 100644 index 0000000..aed2d5d --- /dev/null +++ b/api-v3/src/asset-master/dto/update-asset-type.dto.ts @@ -0,0 +1,46 @@ +import { Transform } from 'class-transformer'; +import { + ArrayUnique, + IsArray, + IsBoolean, + IsEnum, + IsOptional, + IsString, + IsUUID, + MaxLength, + MinLength, +} from 'class-validator'; +import { AssetTypeOperationalRole } from '../../database/entities'; + +export class UpdateAssetTypeDto { + @IsOptional() + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) + @IsString() + @MinLength(1) + @MaxLength(160) + name?: string; + + @IsOptional() + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) + @IsString() + @MaxLength(2000) + description?: string; + + @IsOptional() + @IsBoolean() + canBeRoot?: boolean; + + @IsOptional() + @IsBoolean() + isActive?: boolean; + + @IsOptional() + @IsEnum(AssetTypeOperationalRole) + operationalRole?: AssetTypeOperationalRole; + + @IsOptional() + @IsArray() + @ArrayUnique() + @IsUUID('4', { each: true }) + allowedParentTypeIds?: string[]; +} diff --git a/api-v3/src/asset-master/dto/update-asset.dto.ts b/api-v3/src/asset-master/dto/update-asset.dto.ts new file mode 100644 index 0000000..144fad5 --- /dev/null +++ b/api-v3/src/asset-master/dto/update-asset.dto.ts @@ -0,0 +1,65 @@ +import { Transform } from 'class-transformer'; +import { + IsObject, + IsOptional, + IsString, + IsUUID, + Matches, + MaxLength, + MinLength, +} from 'class-validator'; + +export class UpdateAssetDto { + @IsOptional() + @IsUUID('4') + typeId?: string; + + @IsOptional() + @Transform(({ value }) => + typeof value === 'string' ? value.trim().toUpperCase() : value, + ) + @IsString() + @MinLength(1) + @MaxLength(120) + @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(200) + commonName?: string | null; + + @IsOptional() + @IsUUID('4') + parentId?: string | null; + + @IsOptional() + @IsUUID('4') + operationalAreaId?: string | null; + + @IsOptional() + @IsUUID('4') + operatorCompanyId?: string | null; + + @IsOptional() + @Transform(({ value }) => + typeof value === 'string' && value.trim() ? value.trim() : null, + ) + @IsString() + @MaxLength(4000) + description?: string | null; + + @IsOptional() + @IsObject() + attributes?: Record; +} diff --git a/api-v3/src/asset-master/dto/update-attribute-definition.dto.ts b/api-v3/src/asset-master/dto/update-attribute-definition.dto.ts new file mode 100644 index 0000000..b10a0dd --- /dev/null +++ b/api-v3/src/asset-master/dto/update-attribute-definition.dto.ts @@ -0,0 +1,58 @@ +import { Transform } from 'class-transformer'; +import { + ArrayUnique, + IsArray, + IsBoolean, + IsEnum, + IsInt, + IsOptional, + IsString, + Max, + MaxLength, + Min, + MinLength, +} from 'class-validator'; +import { AssetAttributeDataType } from '../../database/entities'; + +export class UpdateAttributeDefinitionDto { + @IsOptional() + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) + @IsString() + @MinLength(1) + @MaxLength(160) + name?: string; + + @IsOptional() + @IsEnum(AssetAttributeDataType) + dataType?: AssetAttributeDataType; + + @IsOptional() + @IsBoolean() + isRequired?: boolean; + + @IsOptional() + @IsBoolean() + isActive?: boolean; + + @IsOptional() + @Transform(({ value }) => + typeof value === 'string' && value.trim() ? value.trim() : null, + ) + @IsString() + @MaxLength(40) + unit?: string | null; + + @IsOptional() + @IsArray() + @ArrayUnique() + @IsString({ each: true }) + @MinLength(1, { each: true }) + @MaxLength(160, { each: true }) + options?: string[] | null; + + @IsOptional() + @IsInt() + @Min(0) + @Max(10_000) + sortOrder?: number; +} diff --git a/api-v3/src/asset-master/dto/upsert-asset-geometry.dto.ts b/api-v3/src/asset-master/dto/upsert-asset-geometry.dto.ts new file mode 100644 index 0000000..3f6504a --- /dev/null +++ b/api-v3/src/asset-master/dto/upsert-asset-geometry.dto.ts @@ -0,0 +1,44 @@ +import { Type } from 'class-transformer'; +import { + IsArray, + IsEnum, + IsISO8601, + IsNumber, + IsOptional, + IsString, + Max, + MaxLength, + Min, + ValidateNested, +} from 'class-validator'; +import { AssetGeometryType } from '../../database/entities'; + +export class AssetGeometryPayloadDto { + @IsEnum(AssetGeometryType) + type!: AssetGeometryType; + + @IsArray() + coordinates!: unknown[]; +} + +export class UpsertAssetGeometryDto { + @ValidateNested() + @Type(() => AssetGeometryPayloadDto) + geometry!: AssetGeometryPayloadDto; + + @IsOptional() + @Type(() => Number) + @IsNumber({ maxDecimalPlaces: 3 }) + @Min(0) + @Max(100_000) + accuracyM?: number | null; + + @IsOptional() + @IsISO8601({ strict: true }) + capturedAt?: string | null; + + @IsOptional() + @IsString() + @MaxLength(255) + deviceLabel?: string | null; +} diff --git a/api-v3/src/asset-master/dto/upsert-organization-profile.dto.ts b/api-v3/src/asset-master/dto/upsert-organization-profile.dto.ts new file mode 100644 index 0000000..08be782 --- /dev/null +++ b/api-v3/src/asset-master/dto/upsert-organization-profile.dto.ts @@ -0,0 +1,2 @@ +import { Transform } from 'class-transformer'; import { IsEmail,IsEnum,IsOptional,IsString,MaxLength } from 'class-validator'; import { OrganizationKind } from '../../database/entities'; +export class UpsertOrganizationProfileDto { @IsEnum(OrganizationKind) organizationKind!:OrganizationKind; @IsOptional() @Transform(({value})=>typeof value==='string'&&value.trim()?value.trim():null) @IsString() @MaxLength(240) legalName?:string|null; @IsOptional() @Transform(({value})=>typeof value==='string'&&value.trim()?value.trim():null) @IsString() @MaxLength(32) taxId?:string|null; @IsOptional() @Transform(({value})=>typeof value==='string'&&value.trim()?value.trim().toLowerCase():null) @IsEmail() @MaxLength(320) notificationEmail?:string|null; @IsOptional() @Transform(({value})=>typeof value==='string'&&value.trim()?value.trim():null) @IsString() @MaxLength(4000) notes?:string|null; } diff --git a/api-v3/src/audit/audit-query.service.ts b/api-v3/src/audit/audit-query.service.ts new file mode 100644 index 0000000..eeb2c80 --- /dev/null +++ b/api-v3/src/audit/audit-query.service.ts @@ -0,0 +1,149 @@ +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { DataSource, EntityManager } from 'typeorm'; +import { AuditSource } from '../database/entities'; +import { buildAuditFilters } from './audit-query'; +import type { ListAuditQueryDto } from './dto/list-audit-query.dto'; + +export interface AuditEventSummary { + id: string; + occurredAt: Date; + actorUserId: string | null; + actorUsername: string | null; + action: string; + entityType: string | null; + entityId: string | null; + requestId: string | null; + source: AuditSource; + ip: string | null; + hasDetails: boolean; +} + +export interface AuditEventDetail extends AuditEventSummary { + userAgent: string | null; + beforeData: Record | null; + afterData: Record | null; + metadata: Record | null; +} + +@Injectable() +export class AuditQueryService { + constructor(private readonly dataSource: DataSource) {} + + async list(query: ListAuditQueryDto) { + this.validateDateRange(query); + const filters = buildAuditFilters(query); + const page = query.page; + const pageSize = query.pageSize; + + return this.dataSource.transaction('REPEATABLE READ', async (manager) => { + const [countRow] = (await manager.query( + `SELECT COUNT(*)::integer AS total FROM audit_events event ${filters.clause}`, + filters.parameters, + )) as Array<{ total: number }>; + const total = Number(countRow?.total ?? 0); + + const parameters = [ + ...filters.parameters, + pageSize, + (page - 1) * pageSize, + ]; + const limitParameter = parameters.length - 1; + const offsetParameter = parameters.length; + const rows = (await manager.query( + ` + SELECT + event.id, + event.occurred_at AS "occurredAt", + event.actor_user_id AS "actorUserId", + event.actor_username AS "actorUsername", + event.action, + event.entity_type AS "entityType", + event.entity_id AS "entityId", + event.request_id AS "requestId", + event.source, + event.ip, + ( + event.before_data IS NOT NULL + OR event.after_data IS NOT NULL + OR event.metadata IS NOT NULL + ) AS "hasDetails" + FROM audit_events event + ${filters.clause} + ORDER BY event.occurred_at DESC, event.id DESC + LIMIT $${limitParameter} OFFSET $${offsetParameter} + `, + parameters, + )) as AuditEventSummary[]; + + return { + data: rows, + meta: { + page, + pageSize, + total, + totalPages: Math.ceil(total / pageSize), + }, + }; + }); + } + + async getById(id: string): Promise { + return this.dataSource.transaction((manager) => + this.loadEvent(manager, id), + ); + } + + private async loadEvent( + manager: EntityManager, + id: string, + ): Promise { + const [row] = (await manager.query( + ` + SELECT + event.id, + event.occurred_at AS "occurredAt", + event.actor_user_id AS "actorUserId", + event.actor_username AS "actorUsername", + event.action, + event.entity_type AS "entityType", + event.entity_id AS "entityId", + event.request_id AS "requestId", + event.source, + event.ip, + event.user_agent AS "userAgent", + event.before_data AS "beforeData", + event.after_data AS "afterData", + event.metadata, + ( + event.before_data IS NOT NULL + OR event.after_data IS NOT NULL + OR event.metadata IS NOT NULL + ) AS "hasDetails" + FROM audit_events event + WHERE event.id = $1 + `, + [id], + )) as AuditEventDetail[]; + + if (!row) { + throw new NotFoundException({ + code: 'AUDIT_EVENT_NOT_FOUND', + message: 'Evento de auditoría no encontrado', + }); + } + return row; + } + + private validateDateRange(query: ListAuditQueryDto): void { + if (query.from && query.to && new Date(query.from) > new Date(query.to)) { + throw new BadRequestException({ + code: 'INVALID_DATE_RANGE', + message: 'La fecha inicial no puede ser posterior a la fecha final', + }); + } + } +} diff --git a/api-v3/src/audit/audit-query.ts b/api-v3/src/audit/audit-query.ts new file mode 100644 index 0000000..f7203e8 --- /dev/null +++ b/api-v3/src/audit/audit-query.ts @@ -0,0 +1,66 @@ +import type { ListAuditQueryDto } from './dto/list-audit-query.dto'; + +export interface AuditFilters { + clause: string; + parameters: unknown[]; +} + +export function buildAuditFilters(query: ListAuditQueryDto): AuditFilters { + const conditions: string[] = []; + const parameters: unknown[] = []; + + const add = (condition: (parameter: string) => string, value: unknown) => { + parameters.push(value); + conditions.push(condition(`$${parameters.length}`)); + }; + + if (query.actorUserId) { + add((parameter) => `event.actor_user_id = ${parameter}`, query.actorUserId); + } + if (query.actorUsername) { + add( + (parameter) => `event.actor_username ILIKE ${parameter}`, + `%${query.actorUsername}%`, + ); + } + if (query.action) { + add((parameter) => `event.action = ${parameter}`, query.action); + } + if (query.source) { + add((parameter) => `event.source = ${parameter}`, query.source); + } + if (query.entityType) { + add((parameter) => `event.entity_type = ${parameter}`, query.entityType); + } + if (query.entityId) { + add((parameter) => `event.entity_id = ${parameter}`, query.entityId); + } + if (query.requestId) { + add((parameter) => `event.request_id = ${parameter}`, query.requestId); + } + if (query.from) { + add((parameter) => `event.occurred_at >= ${parameter}`, new Date(query.from)); + } + if (query.to) { + add((parameter) => `event.occurred_at <= ${parameter}`, new Date(query.to)); + } + if (query.search) { + add( + (parameter) => ` + ( + event.actor_username ILIKE ${parameter} + OR event.action ILIKE ${parameter} + OR event.entity_type ILIKE ${parameter} + OR event.entity_id ILIKE ${parameter} + OR event.request_id ILIKE ${parameter} + ) + `, + `%${query.search}%`, + ); + } + + return { + clause: conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '', + parameters, + }; +} diff --git a/api-v3/src/audit/audit-sanitizer.ts b/api-v3/src/audit/audit-sanitizer.ts new file mode 100644 index 0000000..2f72a74 --- /dev/null +++ b/api-v3/src/audit/audit-sanitizer.ts @@ -0,0 +1,43 @@ +const SENSITIVE_KEYS = new Set([ + 'password', + 'currentpassword', + 'newpassword', + 'passwordhash', + 'token', + 'accesstoken', + 'refreshtoken', + 'refreshtokenhash', + 'authorization', + 'cookie', + 'cookies', + 'setcookie', + 'secret', + 'pepper', + 'jwt', +]); + +function normalizedKey(key: string): string { + return key.replaceAll(/[^a-zA-Z0-9]/g, '').toLowerCase(); +} + +export function sanitizeAuditData( + value: unknown, + seen = new WeakSet(), +): unknown { + if (value === null || value === undefined) return value; + if (value instanceof Date) return value.toISOString(); + if (Array.isArray(value)) { + return value.map((entry) => sanitizeAuditData(entry, seen)); + } + if (typeof value !== 'object') return value; + if (seen.has(value)) return '[CIRCULAR]'; + seen.add(value); + + const sanitized: Record = {}; + for (const [key, entry] of Object.entries(value)) { + sanitized[key] = SENSITIVE_KEYS.has(normalizedKey(key)) + ? '[REDACTED]' + : sanitizeAuditData(entry, seen); + } + return sanitized; +} diff --git a/api-v3/src/audit/audit.controller.ts b/api-v3/src/audit/audit.controller.ts new file mode 100644 index 0000000..2bc42f9 --- /dev/null +++ b/api-v3/src/audit/audit.controller.ts @@ -0,0 +1,27 @@ +import { + Controller, + Get, + Param, + ParseUUIDPipe, + Query, +} from '@nestjs/common'; +import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator'; +import { AuditQueryService } from './audit-query.service'; +import { ListAuditQueryDto } from './dto/list-audit-query.dto'; + +@Controller('audit') +export class AuditController { + constructor(private readonly audit: AuditQueryService) {} + + @Get() + @RequirePermissions('audit.read') + list(@Query() query: ListAuditQueryDto) { + return this.audit.list(query); + } + + @Get(':id') + @RequirePermissions('audit.read') + getById(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) { + return this.audit.getById(id); + } +} diff --git a/api-v3/src/audit/audit.module.ts b/api-v3/src/audit/audit.module.ts new file mode 100644 index 0000000..7e891c9 --- /dev/null +++ b/api-v3/src/audit/audit.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { PhaseADataModule } from '../core-data/phase-a-data.module'; +import { AuditController } from './audit.controller'; +import { AuditQueryService } from './audit-query.service'; +import { AuditService } from './audit.service'; + +@Module({ + imports: [PhaseADataModule], + controllers: [AuditController], + providers: [AuditService, AuditQueryService], + exports: [AuditService], +}) +export class AuditModule {} diff --git a/api-v3/src/audit/audit.service.ts b/api-v3/src/audit/audit.service.ts new file mode 100644 index 0000000..1f3c42c --- /dev/null +++ b/api-v3/src/audit/audit.service.ts @@ -0,0 +1,60 @@ +import { Injectable } from '@nestjs/common'; +import { EntityManager } from 'typeorm'; +import { AuditEventsRepository } from '../core-data/repositories/audit-events.repository'; +import { AuditEvent, AuditSource } from '../database/entities'; +import { sanitizeAuditData } from './audit-sanitizer'; + +export interface RecordAuditInput { + actorUserId?: string | null; + actorUsername?: string | null; + action: string; + entityType?: string | null; + entityId?: string | null; + requestId?: string | null; + source?: AuditSource; + ip?: string | null; + userAgent?: string | null; + beforeData?: Record | null; + afterData?: Record | null; + metadata?: Record | null; +} + +@Injectable() +export class AuditService { + constructor(private readonly events: AuditEventsRepository) {} + + async record( + input: RecordAuditInput, + manager?: EntityManager, + ): Promise { + const eventInput = { + actorUserId: input.actorUserId ?? null, + actorUsername: input.actorUsername ?? null, + action: input.action, + entityType: input.entityType ?? null, + entityId: input.entityId ?? null, + requestId: input.requestId ?? null, + source: input.source ?? AuditSource.WEB, + ip: input.ip ?? null, + userAgent: input.userAgent ?? null, + beforeData: sanitizeAuditData(input.beforeData ?? null) as Record< + string, + unknown + > | null, + afterData: sanitizeAuditData(input.afterData ?? null) as Record< + string, + unknown + > | null, + metadata: sanitizeAuditData(input.metadata ?? null) as Record< + string, + unknown + > | null, + }; + + if (manager) { + await manager.getRepository(AuditEvent).save(eventInput); + return; + } + await this.events.save(this.events.create(eventInput)); + } +} diff --git a/api-v3/src/audit/dto/list-audit-query.dto.ts b/api-v3/src/audit/dto/list-audit-query.dto.ts new file mode 100644 index 0000000..803f64c --- /dev/null +++ b/api-v3/src/audit/dto/list-audit-query.dto.ts @@ -0,0 +1,88 @@ +import { Transform, Type } from 'class-transformer'; +import { + IsEnum, + IsInt, + IsISO8601, + IsOptional, + IsString, + IsUUID, + Max, + MaxLength, + Min, +} from 'class-validator'; +import { AuditSource } from '../../database/entities'; + +const trim = ({ value }: { value: unknown }) => + typeof value === 'string' ? value.trim() : value; + +export class ListAuditQueryDto { + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page = 1; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + pageSize = 25; + + @IsOptional() + @IsUUID('4') + actorUserId?: string; + + @Transform(trim) + @IsOptional() + @IsString() + @MaxLength(80) + actorUsername?: string; + + @Transform(({ value }) => + typeof value === 'string' ? value.trim().toUpperCase() : value, + ) + @IsOptional() + @IsString() + @MaxLength(100) + action?: string; + + @Transform(({ value }) => + typeof value === 'string' ? value.trim().toUpperCase() : value, + ) + @IsOptional() + @IsEnum(AuditSource) + source?: AuditSource; + + @Transform(trim) + @IsOptional() + @IsString() + @MaxLength(100) + entityType?: string; + + @Transform(trim) + @IsOptional() + @IsString() + @MaxLength(255) + entityId?: string; + + @Transform(trim) + @IsOptional() + @IsString() + @MaxLength(128) + requestId?: string; + + @IsOptional() + @IsISO8601({ strict: true }) + from?: string; + + @IsOptional() + @IsISO8601({ strict: true }) + to?: string; + + @Transform(trim) + @IsOptional() + @IsString() + @MaxLength(200) + search?: string; +} diff --git a/api-v3/src/auth/auth.controller.ts b/api-v3/src/auth/auth.controller.ts new file mode 100644 index 0000000..52b5a64 --- /dev/null +++ b/api-v3/src/auth/auth.controller.ts @@ -0,0 +1,74 @@ +import { + Body, + Controller, + Get, + HttpCode, + Post, + Req, + Res, +} from '@nestjs/common'; +import { Throttle } from '@nestjs/throttler'; +import type { Response } from 'express'; +import type { RequestWithContext } from '../common/http/request-context'; +import { AuthService } from './auth.service'; +import { CurrentAuth } from './decorators/current-auth.decorator'; +import { Public } from './decorators/public.decorator'; +import { SkipCsrf } from './decorators/skip-csrf.decorator'; +import { ChangePasswordDto } from './dto/change-password.dto'; +import { LoginDto } from './dto/login.dto'; +import type { AuthPrincipal } from '../common/http/request-context'; + +@Controller('auth') +export class AuthController { + constructor(private readonly auth: AuthService) {} + + @Post('login') + @HttpCode(200) + @Public() + @SkipCsrf() + @Throttle({ default: { limit: 5, ttl: 60_000, blockDuration: 60_000 } }) + login( + @Body() dto: LoginDto, + @Req() request: RequestWithContext, + @Res({ passthrough: true }) response: Response, + ) { + return this.auth.login(dto, request, response); + } + + @Post('refresh') + @HttpCode(200) + @Public() + @Throttle({ default: { limit: 20, ttl: 60_000 } }) + refresh( + @Req() request: RequestWithContext, + @Res({ passthrough: true }) response: Response, + ) { + return this.auth.refresh(request, response); + } + + @Post('logout') + @HttpCode(200) + logout( + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + @Res({ passthrough: true }) response: Response, + ) { + return this.auth.logout(principal, request, response); + } + + @Get('me') + me(@CurrentAuth() principal: AuthPrincipal) { + return this.auth.me(principal); + } + + @Post('change-password') + @HttpCode(200) + @Throttle({ default: { limit: 10, ttl: 60_000 } }) + changePassword( + @CurrentAuth() principal: AuthPrincipal, + @Body() dto: ChangePasswordDto, + @Req() request: RequestWithContext, + ) { + return this.auth.changePassword(principal, dto, request); + } +} diff --git a/api-v3/src/auth/auth.module.ts b/api-v3/src/auth/auth.module.ts new file mode 100644 index 0000000..5875058 --- /dev/null +++ b/api-v3/src/auth/auth.module.ts @@ -0,0 +1,43 @@ +import { Module } from '@nestjs/common'; +import { JwtModule } from '@nestjs/jwt'; +import { AuditModule } from '../audit/audit.module'; +import { AuthorizationModule } from '../authorization/authorization.module'; +import { PhaseADataModule } from '../core-data/phase-a-data.module'; +import { AuthController } from './auth.controller'; +import { AuthService } from './auth.service'; +import { AccessTokenGuard } from './guards/access-token.guard'; +import { CsrfGuard } from './guards/csrf.guard'; +import { AuthConfigService } from '../common/config/auth-config.service'; +import { CookieService } from './services/cookie.service'; +import { PasswordService } from './services/password.service'; +import { TokenService } from './services/token.service'; + +const providers = [ + AuthConfigService, + CookieService, + PasswordService, + TokenService, + AuthService, + AccessTokenGuard, + CsrfGuard, +]; + +@Module({ + imports: [ + JwtModule.register({}), + PhaseADataModule, + AuditModule, + AuthorizationModule, + ], + controllers: [AuthController], + providers, + exports: [ + AuthConfigService, + CookieService, + PasswordService, + TokenService, + AccessTokenGuard, + CsrfGuard, + ], +}) +export class AuthModule {} diff --git a/api-v3/src/auth/auth.service.ts b/api-v3/src/auth/auth.service.ts new file mode 100644 index 0000000..e672760 --- /dev/null +++ b/api-v3/src/auth/auth.service.ts @@ -0,0 +1,496 @@ +import { isIP } from 'node:net'; +import { + BadRequestException, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import type { Response } from 'express'; +import { DataSource, EntityManager } from 'typeorm'; +import { AuditService } from '../audit/audit.service'; +import type { + AuthPrincipal, + RequestWithContext, +} from '../common/http/request-context'; +import { AuthSessionsRepository } from '../core-data/repositories/auth-sessions.repository'; +import { RolesRepository } from '../core-data/repositories/roles.repository'; +import { UsersRepository } from '../core-data/repositories/users.repository'; +import { + AuditAction, + AuditSource, + AuthSession, + User, + UserStatus, +} from '../database/entities'; +import type { ChangePasswordDto } from './dto/change-password.dto'; +import type { LoginDto } from './dto/login.dto'; +import { CookieService } from './services/cookie.service'; +import { PasswordService } from './services/password.service'; +import { + IssuedRefreshToken, + TokenService, +} from './services/token.service'; + +interface RequestMetadata { + ip: string | null; + userAgent: string | null; +} + +export interface PublicUser { + id: string; + username: string; + firstName: string; + lastName: string; + email: string | null; + mustChangePassword: boolean; + roles: string[]; + permissions: string[]; +} + +interface SuccessfulAuthentication { + user: User; + accessToken: string; + refreshToken: IssuedRefreshToken; +} + +type LoginOutcome = SuccessfulAuthentication | null; + +type RefreshOutcome = + | ({ kind: 'ok' } & SuccessfulAuthentication) + | { kind: 'reuse' } + | { kind: 'invalid' }; + +function invalidCredentials(): UnauthorizedException { + return new UnauthorizedException({ + code: 'INVALID_CREDENTIALS', + message: 'Credenciales inválidas', + }); +} + +function invalidSession(): UnauthorizedException { + return new UnauthorizedException({ + code: 'INVALID_SESSION', + message: 'Sesión inválida o vencida', + }); +} + +function requestMetadata(request: RequestWithContext): RequestMetadata { + const candidateIp = request.ip || request.socket.remoteAddress || ''; + const ip = isIP(candidateIp) ? candidateIp : null; + const rawUserAgent = request.header('user-agent')?.trim(); + return { + ip, + userAgent: rawUserAgent ? rawUserAgent.slice(0, 2048) : null, + }; +} + +@Injectable() +export class AuthService { + constructor( + private readonly dataSource: DataSource, + private readonly users: UsersRepository, + private readonly sessions: AuthSessionsRepository, + private readonly roles: RolesRepository, + private readonly passwords: PasswordService, + private readonly tokens: TokenService, + private readonly cookies: CookieService, + private readonly audit: AuditService, + ) {} + + async login( + dto: LoginDto, + request: RequestWithContext, + response: Response, + ) { + const identifier = dto.identifier.trim().toLowerCase(); + const candidate = await this.users.findForAuthentication(identifier); + const metadata = requestMetadata(request); + + if (!candidate) { + await this.passwords.verifyUnknown(dto.password); + await this.audit.record({ + action: AuditAction.AUTH_LOGIN_FAILED, + requestId: request.requestId, + source: AuditSource.WEB, + ...metadata, + metadata: { + identifier, + reason: 'UNKNOWN_IDENTIFIER', + }, + }); + throw invalidCredentials(); + } + + const outcome = await this.dataSource.transaction( + async (manager) => { + const user = await manager + .getRepository(User) + .createQueryBuilder('user') + .addSelect('user.passwordHash') + .where('user.id = :id', { id: candidate.id }) + .setLock('pessimistic_write') + .getOne(); + + if (!user) return null; + + const now = new Date(); + const passwordMatches = await this.passwords.verify( + user.passwordHash, + dto.password, + ); + const locked = Boolean(user.lockedUntil && user.lockedUntil > now); + const active = user.status === UserStatus.ACTIVE; + + if (!passwordMatches || locked || !active) { + if (!passwordMatches && active && !locked) { + await manager.query( + ` + UPDATE users + SET + failed_login_attempts = failed_login_attempts + 1, + locked_until = CASE + WHEN failed_login_attempts + 1 >= $2 + THEN CURRENT_TIMESTAMP + ($3 * INTERVAL '1 second') + ELSE locked_until + END, + updated_at = CURRENT_TIMESTAMP + WHERE id = $1 + `, + [ + user.id, + this.tokens.maxLoginAttempts, + this.tokens.lockoutSeconds, + ], + ); + } + + await this.audit.record( + { + actorUserId: user.id, + actorUsername: user.username, + action: AuditAction.AUTH_LOGIN_FAILED, + entityType: 'user', + entityId: user.id, + requestId: request.requestId, + source: AuditSource.WEB, + ...metadata, + metadata: { + reason: !active + ? 'INACTIVE_USER' + : locked + ? 'LOCKED_USER' + : 'INVALID_PASSWORD', + }, + }, + manager, + ); + return null; + } + + if (this.passwords.needsRehash(user.passwordHash)) { + user.passwordHash = await this.passwords.hash(dto.password); + } + user.failedLoginAttempts = 0; + user.lockedUntil = null; + user.lastLoginAt = now; + await manager.getRepository(User).save(user); + + const refreshToken = this.tokens.issueRefreshToken(); + const session = manager.getRepository(AuthSession).create({ + id: refreshToken.sessionId, + userId: user.id, + refreshTokenHash: refreshToken.tokenHash, + expiresAt: this.tokens.refreshExpiresAt(now), + lastUsedAt: now, + revokedAt: null, + replacedBySessionId: null, + ip: metadata.ip, + userAgent: metadata.userAgent, + deviceLabel: dto.deviceLabel?.trim() || null, + }); + await manager.getRepository(AuthSession).save(session); + + const accessToken = await this.tokens.issueAccessToken({ + userId: user.id, + sessionId: session.id, + username: user.username, + }); + + await this.audit.record( + { + actorUserId: user.id, + actorUsername: user.username, + action: AuditAction.AUTH_LOGIN_SUCCESS, + entityType: 'auth_session', + entityId: session.id, + requestId: request.requestId, + source: AuditSource.WEB, + ...metadata, + metadata: { deviceLabel: session.deviceLabel }, + }, + manager, + ); + + return { user, accessToken, refreshToken }; + }, + ); + + if (!outcome) throw invalidCredentials(); + return this.completeAuthentication(outcome, response); + } + + async refresh(request: RequestWithContext, response: Response) { + const rawToken = this.cookies.getRefreshToken(request); + const parsed = rawToken ? this.tokens.parseRefreshToken(rawToken) : null; + if (!rawToken || !parsed) { + this.cookies.clearAuthCookies(response); + throw invalidSession(); + } + + const metadata = requestMetadata(request); + const outcome = await this.dataSource.transaction( + async (manager) => { + const session = await manager + .getRepository(AuthSession) + .createQueryBuilder('session') + .addSelect('session.refreshTokenHash') + .where('session.id = :id', { id: parsed.sessionId }) + .setLock('pessimistic_write') + .getOne(); + + if ( + !session || + !this.tokens.verifyRefreshToken(rawToken, session.refreshTokenHash) + ) { + return { kind: 'invalid' }; + } + + const user = await manager.getRepository(User).findOne({ + where: { id: session.userId }, + }); + + if (session.revokedAt) { + await this.sessions.revokeSessionFamily(session.id, manager); + await this.audit.record( + { + actorUserId: user?.id ?? null, + actorUsername: user?.username ?? null, + action: AuditAction.AUTH_REFRESH_REUSE_DETECTED, + entityType: 'auth_session', + entityId: session.id, + requestId: request.requestId, + source: AuditSource.WEB, + ...metadata, + }, + manager, + ); + return { kind: 'reuse' }; + } + + const now = new Date(); + if (!user || user.status !== UserStatus.ACTIVE || session.expiresAt <= now) { + session.revokedAt = now; + session.lastUsedAt = now; + await manager.getRepository(AuthSession).save(session); + if (user?.status === UserStatus.INACTIVE) { + await this.sessions.revokeUserSessions(user.id, undefined, manager); + } + return { kind: 'invalid' }; + } + + const refreshToken = this.tokens.issueRefreshToken(); + const replacement = manager.getRepository(AuthSession).create({ + id: refreshToken.sessionId, + userId: user.id, + refreshTokenHash: refreshToken.tokenHash, + expiresAt: this.tokens.refreshExpiresAt(now), + lastUsedAt: now, + revokedAt: null, + replacedBySessionId: null, + ip: metadata.ip, + userAgent: metadata.userAgent, + deviceLabel: session.deviceLabel, + }); + await manager.getRepository(AuthSession).save(replacement); + + session.revokedAt = now; + session.lastUsedAt = now; + session.replacedBySessionId = replacement.id; + await manager.getRepository(AuthSession).save(session); + + const accessToken = await this.tokens.issueAccessToken({ + userId: user.id, + sessionId: replacement.id, + username: user.username, + }); + + await this.audit.record( + { + actorUserId: user.id, + actorUsername: user.username, + action: AuditAction.AUTH_REFRESH, + entityType: 'auth_session', + entityId: replacement.id, + requestId: request.requestId, + source: AuditSource.WEB, + ...metadata, + metadata: { replacedSessionId: session.id }, + }, + manager, + ); + + return { kind: 'ok', user, accessToken, refreshToken }; + }, + ); + + if (outcome.kind !== 'ok') { + this.cookies.clearAuthCookies(response); + throw invalidSession(); + } + return this.completeAuthentication(outcome, response); + } + + async logout( + principal: AuthPrincipal, + request: RequestWithContext, + response: Response, + ) { + const metadata = requestMetadata(request); + await this.dataSource.transaction(async (manager) => { + await this.sessions.revokeSession( + principal.sessionId, + principal.userId, + manager, + ); + await this.audit.record( + { + actorUserId: principal.userId, + actorUsername: principal.username, + action: AuditAction.AUTH_LOGOUT, + entityType: 'auth_session', + entityId: principal.sessionId, + requestId: request.requestId, + source: AuditSource.WEB, + ...metadata, + }, + manager, + ); + }); + this.cookies.clearAuthCookies(response); + return { status: 'ok' }; + } + + me(principal: AuthPrincipal) { + return { user: this.publicPrincipal(principal) }; + } + + async changePassword( + principal: AuthPrincipal, + dto: ChangePasswordDto, + request: RequestWithContext, + ) { + const metadata = requestMetadata(request); + await this.dataSource.transaction(async (manager) => { + const user = await manager + .getRepository(User) + .createQueryBuilder('user') + .addSelect('user.passwordHash') + .where('user.id = :id', { id: principal.userId }) + .setLock('pessimistic_write') + .getOne(); + + if ( + !user || + !(await this.passwords.verify(user.passwordHash, dto.currentPassword)) + ) { + throw invalidCredentials(); + } + if (await this.passwords.verify(user.passwordHash, dto.newPassword)) { + throw new BadRequestException({ + code: 'PASSWORD_UNCHANGED', + message: 'La nueva contraseña debe ser diferente', + }); + } + + const now = new Date(); + user.passwordHash = await this.passwords.hash(dto.newPassword); + user.passwordChangedAt = now; + user.mustChangePassword = false; + user.failedLoginAttempts = 0; + user.lockedUntil = null; + user.updatedBy = user.id; + await manager.getRepository(User).save(user); + await this.sessions.revokeUserSessions( + user.id, + principal.sessionId, + manager, + ); + await this.audit.record( + { + actorUserId: user.id, + actorUsername: user.username, + action: AuditAction.AUTH_PASSWORD_CHANGED, + entityType: 'user', + entityId: user.id, + requestId: request.requestId, + source: AuditSource.WEB, + ...metadata, + metadata: { otherSessionsRevoked: true }, + }, + manager, + ); + }); + + return { status: 'ok', mustChangePassword: false }; + } + + private async completeAuthentication( + authentication: SuccessfulAuthentication, + response: Response, + ) { + const [roleCodes, permissionCodes] = await Promise.all([ + this.roles.findRoleCodesForUser(authentication.user.id), + this.roles.findPermissionCodesForUser(authentication.user.id), + ]); + const csrfToken = this.cookies.setAuthCookies( + response, + authentication.accessToken, + authentication.refreshToken.token, + ); + + return { + user: this.publicUser(authentication.user, roleCodes, permissionCodes), + csrfToken, + accessExpiresInSeconds: this.tokens.accessTokenTtlSeconds, + }; + } + + private publicUser( + user: User, + roles: string[], + permissions: string[], + ): PublicUser { + return { + id: user.id, + username: user.username, + firstName: user.firstName, + lastName: user.lastName, + email: user.email, + mustChangePassword: user.mustChangePassword, + roles, + permissions, + }; + } + + private publicPrincipal(principal: AuthPrincipal): PublicUser { + return { + id: principal.userId, + username: principal.username, + firstName: principal.firstName, + lastName: principal.lastName, + email: principal.email, + mustChangePassword: principal.mustChangePassword, + roles: principal.roles, + permissions: principal.permissions, + }; + } +} diff --git a/api-v3/src/auth/decorators/current-auth.decorator.ts b/api-v3/src/auth/decorators/current-auth.decorator.ts new file mode 100644 index 0000000..33325ea --- /dev/null +++ b/api-v3/src/auth/decorators/current-auth.decorator.ts @@ -0,0 +1,10 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; +import type { AuthPrincipal, RequestWithContext } from '../../common/http/request-context'; + +export const CurrentAuth = createParamDecorator( + (_data: unknown, context: ExecutionContext): AuthPrincipal => { + const request = context.switchToHttp().getRequest(); + if (!request.auth) throw new Error('Authenticated principal is missing'); + return request.auth; + }, +); diff --git a/api-v3/src/auth/decorators/public.decorator.ts b/api-v3/src/auth/decorators/public.decorator.ts new file mode 100644 index 0000000..b3845e1 --- /dev/null +++ b/api-v3/src/auth/decorators/public.decorator.ts @@ -0,0 +1,4 @@ +import { SetMetadata } from '@nestjs/common'; + +export const IS_PUBLIC_KEY = 'isPublic'; +export const Public = () => SetMetadata(IS_PUBLIC_KEY, true); diff --git a/api-v3/src/auth/decorators/skip-csrf.decorator.ts b/api-v3/src/auth/decorators/skip-csrf.decorator.ts new file mode 100644 index 0000000..afd891f --- /dev/null +++ b/api-v3/src/auth/decorators/skip-csrf.decorator.ts @@ -0,0 +1,4 @@ +import { SetMetadata } from '@nestjs/common'; + +export const SKIP_CSRF_KEY = 'skipCsrf'; +export const SkipCsrf = () => SetMetadata(SKIP_CSRF_KEY, true); diff --git a/api-v3/src/auth/dto/change-password.dto.ts b/api-v3/src/auth/dto/change-password.dto.ts new file mode 100644 index 0000000..6646c63 --- /dev/null +++ b/api-v3/src/auth/dto/change-password.dto.ts @@ -0,0 +1,13 @@ +import { IsString, MaxLength, MinLength } from 'class-validator'; + +export class ChangePasswordDto { + @IsString() + @MinLength(1) + @MaxLength(128) + currentPassword!: string; + + @IsString() + @MinLength(12) + @MaxLength(128) + newPassword!: string; +} diff --git a/api-v3/src/auth/dto/login.dto.ts b/api-v3/src/auth/dto/login.dto.ts new file mode 100644 index 0000000..fbd0efd --- /dev/null +++ b/api-v3/src/auth/dto/login.dto.ts @@ -0,0 +1,18 @@ +import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator'; + +export class LoginDto { + @IsString() + @MinLength(3) + @MaxLength(320) + identifier!: string; + + @IsString() + @MinLength(1) + @MaxLength(128) + password!: string; + + @IsOptional() + @IsString() + @MaxLength(120) + deviceLabel?: string; +} diff --git a/api-v3/src/auth/guards/access-token.guard.ts b/api-v3/src/auth/guards/access-token.guard.ts new file mode 100644 index 0000000..607bef9 --- /dev/null +++ b/api-v3/src/auth/guards/access-token.guard.ts @@ -0,0 +1,108 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import type { Request } from 'express'; +import { PermissionResolverService } from '../../authorization/permission-resolver.service'; +import { AuthSessionsRepository } from '../../core-data/repositories/auth-sessions.repository'; +import { UsersRepository } from '../../core-data/repositories/users.repository'; +import { UserStatus } from '../../database/entities'; +import type { AuthTransport, RequestWithContext } from '../../common/http/request-context'; +import { IS_PUBLIC_KEY } from '../decorators/public.decorator'; +import { CookieService } from '../services/cookie.service'; +import { TokenService } from '../services/token.service'; +import type { AccessTokenPayload } from '../interfaces/access-token-payload'; + +function unauthorized(): UnauthorizedException { + return new UnauthorizedException({ + code: 'UNAUTHORIZED', + message: 'Autenticación requerida', + }); +} + +@Injectable() +export class AccessTokenGuard implements CanActivate { + constructor( + private readonly reflector: Reflector, + private readonly tokens: TokenService, + private readonly cookies: CookieService, + private readonly users: UsersRepository, + private readonly sessions: AuthSessionsRepository, + private readonly permissions: PermissionResolverService, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ + context.getHandler(), + context.getClass(), + ]); + if (isPublic) return true; + + const request = context.switchToHttp().getRequest(); + const extracted = this.extractToken(request); + if (!extracted) throw unauthorized(); + + let payload: AccessTokenPayload; + try { + payload = await this.tokens.verifyAccessToken(extracted.token); + } catch { + throw unauthorized(); + } + + if ( + payload.typ !== 'access' || + typeof payload.sub !== 'string' || + typeof payload.sid !== 'string' || + typeof payload.username !== 'string' + ) { + throw unauthorized(); + } + + const [user, session, authorization] = await Promise.all([ + this.users.findById(payload.sub), + this.sessions.findActiveById(payload.sid), + this.permissions.resolveForUser(payload.sub), + ]); + + if ( + !user || + user.status !== UserStatus.ACTIVE || + !session || + session.userId !== user.id + ) { + throw unauthorized(); + } + + request.auth = { + userId: user.id, + username: user.username, + sessionId: session.id, + firstName: user.firstName, + lastName: user.lastName, + email: user.email, + mustChangePassword: user.mustChangePassword, + roles: authorization.roles, + permissions: authorization.permissions, + transport: extracted.transport, + }; + return true; + } + + private extractToken( + request: Request, + ): { token: string; transport: AuthTransport } | null { + const authorization = request.header('authorization'); + if (authorization) { + const [scheme, token, extra] = authorization.split(' '); + if (scheme?.toLowerCase() === 'bearer' && token && !extra) { + return { token, transport: 'bearer' }; + } + } + + const cookieToken = this.cookies.getAccessToken(request); + return cookieToken ? { token: cookieToken, transport: 'cookie' } : null; + } +} diff --git a/api-v3/src/auth/guards/csrf.guard.ts b/api-v3/src/auth/guards/csrf.guard.ts new file mode 100644 index 0000000..6e0a43e --- /dev/null +++ b/api-v3/src/auth/guards/csrf.guard.ts @@ -0,0 +1,56 @@ +import { + CanActivate, + ExecutionContext, + ForbiddenException, + Injectable, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import type { RequestWithContext } from '../../common/http/request-context'; +import { AuthConfigService } from '../../common/config/auth-config.service'; +import { IS_PUBLIC_KEY } from '../decorators/public.decorator'; +import { SKIP_CSRF_KEY } from '../decorators/skip-csrf.decorator'; +import { CookieService } from '../services/cookie.service'; + +const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); + +@Injectable() +export class CsrfGuard implements CanActivate { + constructor( + private readonly cookies: CookieService, + private readonly config: AuthConfigService, + private readonly reflector: Reflector, + ) {} + + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest(); + if (SAFE_METHODS.has(request.method.toUpperCase())) return true; + if (request.auth?.transport === 'bearer') return true; + const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ + context.getHandler(), + context.getClass(), + ]); + if ( + !isPublic && + /^Bearer\s+\S+$/i.test(request.header('authorization') ?? '') + ) { + return true; + } + + const origin = request.header('origin'); + if (origin && origin !== this.config.webOrigin) throw this.invalidCsrf(); + const skipToken = this.reflector.getAllAndOverride(SKIP_CSRF_KEY, [ + context.getHandler(), + context.getClass(), + ]); + if (skipToken) return true; + if (!this.cookies.csrfMatches(request)) throw this.invalidCsrf(); + return true; + } + + private invalidCsrf(): ForbiddenException { + return new ForbiddenException({ + code: 'CSRF_INVALID', + message: 'Verificación CSRF inválida', + }); + } +} diff --git a/api-v3/src/auth/interfaces/access-token-payload.ts b/api-v3/src/auth/interfaces/access-token-payload.ts new file mode 100644 index 0000000..29709ad --- /dev/null +++ b/api-v3/src/auth/interfaces/access-token-payload.ts @@ -0,0 +1,8 @@ +export interface AccessTokenPayload { + sub: string; + sid: string; + username: string; + typ: 'access'; + iat?: number; + exp?: number; +} diff --git a/api-v3/src/auth/services/cookie.service.ts b/api-v3/src/auth/services/cookie.service.ts new file mode 100644 index 0000000..119a45b --- /dev/null +++ b/api-v3/src/auth/services/cookie.service.ts @@ -0,0 +1,90 @@ +import { randomBytes, timingSafeEqual } from 'node:crypto'; +import { Injectable } from '@nestjs/common'; +import type { Request, Response } from 'express'; +import { AuthConfigService } from '../../common/config/auth-config.service'; + +@Injectable() +export class CookieService { + constructor(private readonly config: AuthConfigService) {} + + setAuthCookies( + response: Response, + accessToken: string, + refreshToken: string, + ): string { + response.cookie(this.config.accessCookieName, accessToken, { + httpOnly: true, + secure: true, + sameSite: 'strict', + path: '/', + maxAge: this.config.accessTokenTtlSeconds * 1000, + }); + response.cookie(this.config.refreshCookieName, refreshToken, { + httpOnly: true, + secure: true, + sameSite: 'strict', + path: '/api/v3/auth', + maxAge: this.config.refreshTokenTtlSeconds * 1000, + }); + return this.rotateCsrfCookie(response); + } + + rotateCsrfCookie(response: Response): string { + const csrfToken = randomBytes(32).toString('base64url'); + response.cookie(this.config.csrfCookieName, csrfToken, { + httpOnly: false, + secure: true, + sameSite: 'strict', + path: '/', + maxAge: this.config.refreshTokenTtlSeconds * 1000, + }); + return csrfToken; + } + + clearAuthCookies(response: Response): void { + response.clearCookie(this.config.accessCookieName, { + httpOnly: true, + secure: true, + sameSite: 'strict', + path: '/', + }); + response.clearCookie(this.config.refreshCookieName, { + httpOnly: true, + secure: true, + sameSite: 'strict', + path: '/api/v3/auth', + }); + response.clearCookie(this.config.csrfCookieName, { + httpOnly: false, + secure: true, + sameSite: 'strict', + path: '/', + }); + } + + getAccessToken(request: Request): string | undefined { + return this.readCookie(request, this.config.accessCookieName); + } + + getRefreshToken(request: Request): string | undefined { + return this.readCookie(request, this.config.refreshCookieName); + } + + csrfMatches(request: Request): boolean { + const cookieValue = this.readCookie(request, this.config.csrfCookieName); + const headerValue = request.header('x-csrf-token'); + if (!cookieValue || !headerValue) return false; + const cookieBuffer = Buffer.from(cookieValue, 'utf8'); + const headerBuffer = Buffer.from(headerValue, 'utf8'); + return ( + cookieBuffer.length === headerBuffer.length && + timingSafeEqual(cookieBuffer, headerBuffer) + ); + } + + private readCookie(request: Request, name: string): string | undefined { + const cookies = request.cookies as Record | undefined; + const value = cookies?.[name]; + return typeof value === 'string' ? value : undefined; + } +} diff --git a/api-v3/src/auth/services/password.service.ts b/api-v3/src/auth/services/password.service.ts new file mode 100644 index 0000000..0b92bc0 --- /dev/null +++ b/api-v3/src/auth/services/password.service.ts @@ -0,0 +1,40 @@ +import { Injectable } from '@nestjs/common'; +import * as argon2 from 'argon2'; + +const HASH_OPTIONS: argon2.HashOptions = { + type: argon2.argon2id, + memoryCost: 65536, + timeCost: 3, + parallelism: 1, + hashLength: 32, +}; + +const DUMMY_PASSWORD_HASH = + '$argon2id$v=19$m=65536,p=1,t=3$aTSorDuRpmrZ/ILFbcoRYQ$sjvsmuiBlN3oTS1qeUiYxSShzsEVWiNnMY4Jo5MQw2c'; + +@Injectable() +export class PasswordService { + hash(password: string): Promise { + return argon2.hash(password, HASH_OPTIONS); + } + + async verify(hash: string, password: string): Promise { + try { + return await argon2.verify(hash, password); + } catch { + return false; + } + } + + async verifyUnknown(password: string): Promise { + await this.verify(DUMMY_PASSWORD_HASH, password); + } + + needsRehash(hash: string): boolean { + return argon2.needsRehash(hash, { + memoryCost: HASH_OPTIONS.memoryCost, + timeCost: HASH_OPTIONS.timeCost, + parallelism: HASH_OPTIONS.parallelism, + }); + } +} diff --git a/api-v3/src/auth/services/token.service.ts b/api-v3/src/auth/services/token.service.ts new file mode 100644 index 0000000..3ff233a --- /dev/null +++ b/api-v3/src/auth/services/token.service.ts @@ -0,0 +1,98 @@ +import { + createHmac, + randomBytes, + randomUUID, + timingSafeEqual, +} from 'node:crypto'; +import { Injectable } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { AuthConfigService } from '../../common/config/auth-config.service'; +import type { AccessTokenPayload } from '../interfaces/access-token-payload'; + +const REFRESH_TOKEN_PATTERN = + /^(?[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})\.(?[A-Za-z0-9_-]{64})$/i; + +export interface IssuedRefreshToken { + sessionId: string; + token: string; + tokenHash: string; +} + +@Injectable() +export class TokenService { + constructor( + private readonly jwtService: JwtService, + private readonly config: AuthConfigService, + ) {} + + get accessTokenTtlSeconds(): number { + return this.config.accessTokenTtlSeconds; + } + + get maxLoginAttempts(): number { + return this.config.maxLoginAttempts; + } + + get lockoutSeconds(): number { + return this.config.lockoutSeconds; + } + + refreshExpiresAt(from = new Date()): Date { + return new Date(from.getTime() + this.config.refreshTokenTtlSeconds * 1000); + } + + issueAccessToken(input: { + userId: string; + sessionId: string; + username: string; + }): Promise { + const payload: AccessTokenPayload = { + sub: input.userId, + sid: input.sessionId, + username: input.username, + typ: 'access', + }; + + return this.jwtService.signAsync(payload, { + secret: this.config.accessTokenSecret, + algorithm: 'HS256', + expiresIn: this.config.accessTokenTtlSeconds, + issuer: 'dhv2-api', + audience: 'dhv2', + }); + } + + verifyAccessToken(token: string): Promise { + return this.jwtService.verifyAsync(token, { + secret: this.config.accessTokenSecret, + algorithms: ['HS256'], + issuer: 'dhv2-api', + audience: 'dhv2', + }); + } + + issueRefreshToken(): IssuedRefreshToken { + const sessionId = randomUUID(); + const secret = randomBytes(48).toString('base64url'); + const token = `${sessionId}.${secret}`; + return { sessionId, token, tokenHash: this.hashRefreshToken(token) }; + } + + parseRefreshToken(token: string): { sessionId: string } | null { + const match = REFRESH_TOKEN_PATTERN.exec(token); + const sessionId = match?.groups?.sessionId; + return sessionId ? { sessionId: sessionId.toLowerCase() } : null; + } + + verifyRefreshToken(token: string, expectedHash: string): boolean { + const actual = Buffer.from(this.hashRefreshToken(token), 'hex'); + const expected = Buffer.from(expectedHash, 'hex'); + return actual.length === expected.length && timingSafeEqual(actual, expected); + } + + private hashRefreshToken(token: string): string { + return createHmac('sha256', this.config.refreshTokenPepper) + .update(token, 'utf8') + .digest('hex'); + } +} diff --git a/api-v3/src/authorization/authorization.module.ts b/api-v3/src/authorization/authorization.module.ts new file mode 100644 index 0000000..6d0ffaa --- /dev/null +++ b/api-v3/src/authorization/authorization.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { PhaseADataModule } from '../core-data/phase-a-data.module'; +import { PermissionsGuard } from './guards/permissions.guard'; +import { PermissionResolverService } from './permission-resolver.service'; + +@Module({ + imports: [PhaseADataModule], + providers: [PermissionResolverService, PermissionsGuard], + exports: [PermissionResolverService, PermissionsGuard], +}) +export class AuthorizationModule {} diff --git a/api-v3/src/authorization/decorators/require-permissions.decorator.ts b/api-v3/src/authorization/decorators/require-permissions.decorator.ts new file mode 100644 index 0000000..010c435 --- /dev/null +++ b/api-v3/src/authorization/decorators/require-permissions.decorator.ts @@ -0,0 +1,15 @@ +import { SetMetadata } from '@nestjs/common'; + +export const REQUIRED_PERMISSIONS_KEY = 'requiredPermissions'; + +export function RequirePermissions(...permissions: string[]) { + const normalized = [ + ...new Set(permissions.map((permission) => permission.trim()).filter(Boolean)), + ]; + + if (normalized.length === 0) { + throw new Error('RequirePermissions needs at least one permission code'); + } + + return SetMetadata(REQUIRED_PERMISSIONS_KEY, normalized); +} diff --git a/api-v3/src/authorization/guards/permissions.guard.ts b/api-v3/src/authorization/guards/permissions.guard.ts new file mode 100644 index 0000000..be203c4 --- /dev/null +++ b/api-v3/src/authorization/guards/permissions.guard.ts @@ -0,0 +1,59 @@ +import { + CanActivate, + ExecutionContext, + ForbiddenException, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { IS_PUBLIC_KEY } from '../../auth/decorators/public.decorator'; +import type { RequestWithContext } from '../../common/http/request-context'; +import { REQUIRED_PERMISSIONS_KEY } from '../decorators/require-permissions.decorator'; + +@Injectable() +export class PermissionsGuard implements CanActivate { + constructor(private readonly reflector: Reflector) {} + + canActivate(context: ExecutionContext): boolean { + const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ + context.getHandler(), + context.getClass(), + ]); + if (isPublic) return true; + + const required = + this.reflector.getAllAndMerge(REQUIRED_PERMISSIONS_KEY, [ + context.getHandler(), + context.getClass(), + ]) ?? []; + + const uniqueRequired = [...new Set(required)]; + if (uniqueRequired.length === 0) return true; + + const request = context.switchToHttp().getRequest(); + if (!request.auth) { + throw new UnauthorizedException({ + code: 'UNAUTHORIZED', + message: 'Autenticación requerida', + }); + } + + if (request.auth.mustChangePassword) { + throw new ForbiddenException({ + code: 'PASSWORD_CHANGE_REQUIRED', + message: 'Debe cambiar la contraseña temporal antes de continuar', + }); + } + + const granted = new Set(request.auth.permissions); + const missing = uniqueRequired.filter((permission) => !granted.has(permission)); + if (missing.length > 0) { + throw new ForbiddenException({ + code: 'FORBIDDEN', + message: 'Permisos insuficientes', + }); + } + + return true; + } +} diff --git a/api-v3/src/authorization/permission-resolver.service.ts b/api-v3/src/authorization/permission-resolver.service.ts new file mode 100644 index 0000000..7c9450c --- /dev/null +++ b/api-v3/src/authorization/permission-resolver.service.ts @@ -0,0 +1,21 @@ +import { Injectable } from '@nestjs/common'; +import { RolesRepository } from '../core-data/repositories/roles.repository'; + +export interface ResolvedPermissions { + roles: string[]; + permissions: string[]; +} + +@Injectable() +export class PermissionResolverService { + constructor(private readonly roles: RolesRepository) {} + + async resolveForUser(userId: string): Promise { + const [roles, permissions] = await Promise.all([ + this.roles.findRoleCodesForUser(userId), + this.roles.findPermissionCodesForUser(userId), + ]); + + return { roles, permissions }; + } +} diff --git a/api-v3/src/cli/bootstrap-admin.ts b/api-v3/src/cli/bootstrap-admin.ts new file mode 100644 index 0000000..67111c3 --- /dev/null +++ b/api-v3/src/cli/bootstrap-admin.ts @@ -0,0 +1,263 @@ +import 'reflect-metadata'; + +import { Writable } from 'node:stream'; +import { createInterface } from 'node:readline/promises'; +import { DataSource } from 'typeorm'; +import { PasswordService } from '../auth/services/password.service'; +import { + AuditAction, + AuditEvent, + AuditSource, + PHASE_A_ENTITIES, + Role, + User, + UserRole, + UserStatus, +} from '../database/entities'; + +function requiredEnvironmentVariable(key: string): string { + const value = process.env[key]; + if (!value) throw new Error(`Missing required environment variable: ${key}`); + return value; +} + +function databasePort(): number { + const port = Number(process.env.DB_PORT ?? 5432); + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error('DB_PORT must be a valid TCP port'); + } + return port; +} + +function createRuntimeDataSource(): DataSource { + return new DataSource({ + type: 'postgres', + host: requiredEnvironmentVariable('DB_HOST'), + port: databasePort(), + database: requiredEnvironmentVariable('DB_NAME'), + username: requiredEnvironmentVariable('DB_APP_USER'), + password: requiredEnvironmentVariable('DB_APP_PASSWORD'), + entities: PHASE_A_ENTITIES, + synchronize: false, + migrationsRun: false, + logging: false, + applicationName: 'dhv2-bootstrap-admin', + connectTimeoutMS: 5000, + }); +} + +class MutedOutput extends Writable { + muted = false; + + override _write( + chunk: Buffer | string, + _encoding: BufferEncoding, + callback: (error?: Error | null) => void, + ): void { + if (!this.muted) process.stdout.write(chunk); + callback(); + } +} + +function validatedText( + label: string, + value: string, + maximumLength: number, +): string { + const trimmed = value.trim(); + if (!trimmed) throw new Error(`${label} is required`); + if (trimmed.length > maximumLength) { + throw new Error(`${label} is too long`); + } + return trimmed; +} + +async function main(): Promise { + if (!process.stdin.isTTY || !process.stdout.isTTY) { + throw new Error('Bootstrap admin must run in an interactive terminal'); + } + + const dataSource = createRuntimeDataSource(); + await dataSource.initialize(); + + try { + const existing = (await dataSource.query(` + SELECT COUNT(*)::integer AS count + FROM user_roles user_role + INNER JOIN roles role ON role.id = user_role.role_id + WHERE role.code = 'admin' + `)) as Array<{ count: number }>; + if (Number(existing[0]?.count ?? 0) > 0) { + throw new Error('An administrator already exists; bootstrap was cancelled'); + } + + const output = new MutedOutput(); + const prompt = createInterface({ + input: process.stdin, + output, + terminal: true, + }); + + let username = ''; + let email = ''; + let firstName = ''; + let lastName = ''; + let password = ''; + let passwordConfirmation = ''; + let mustChangeAnswer = ''; + + try { + username = validatedText( + 'Username', + await prompt.question('Username: '), + 80, + ).toLowerCase(); + if (!/^[a-z0-9._-]{3,80}$/.test(username)) { + throw new Error( + 'Username must contain 3-80 letters, numbers, dots, underscores or hyphens', + ); + } + + email = (await prompt.question('Email (optional): ')).trim().toLowerCase(); + if ( + email && + (email.length > 320 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) + ) { + throw new Error('Email is invalid'); + } + firstName = validatedText( + 'First name', + await prompt.question('Nombre: '), + 120, + ); + lastName = validatedText( + 'Last name', + await prompt.question('Apellido: '), + 120, + ); + + const askSecret = async (label: string): Promise => { + output.muted = false; + const pending = prompt.question(label); + output.muted = true; + const answer = await pending; + output.muted = false; + process.stdout.write('\n'); + return answer; + }; + + password = await askSecret('Contraseña (mínimo 12 caracteres): '); + passwordConfirmation = await askSecret('Repetir contraseña: '); + mustChangeAnswer = ( + await prompt.question('¿Forzar cambio en el primer ingreso? [S/n]: ') + ) + .trim() + .toLowerCase(); + } finally { + prompt.close(); + } + + if (password.length < 12 || password.length > 128) { + throw new Error('Password must contain between 12 and 128 characters'); + } + if (password !== passwordConfirmation) { + throw new Error('Password confirmation does not match'); + } + const mustChangePassword = !['n', 'no'].includes(mustChangeAnswer); + const passwordHash = await new PasswordService().hash(password); + + await dataSource.transaction(async (manager) => { + await manager.query( + "SELECT pg_advisory_xact_lock(hashtext('dhv2-bootstrap-admin'))", + ); + + const adminCount = (await manager.query(` + SELECT COUNT(*)::integer AS count + FROM user_roles user_role + INNER JOIN roles role ON role.id = user_role.role_id + WHERE role.code = 'admin' + `)) as Array<{ count: number }>; + if (Number(adminCount[0]?.count ?? 0) > 0) { + throw new Error('An administrator already exists; bootstrap was cancelled'); + } + + const duplicate = (await manager.query( + ` + SELECT 1 + FROM users + WHERE LOWER(username) = $1 + OR ($2::text IS NOT NULL AND LOWER(email) = $2) + LIMIT 1 + `, + [username, email || null], + )) as unknown[]; + if (duplicate.length > 0) { + throw new Error('Username or email already exists'); + } + + const adminRole = await manager.getRepository(Role).findOne({ + where: { code: 'admin' }, + }); + if (!adminRole) throw new Error('Admin role seed is missing'); + + const user = manager.getRepository(User).create({ + username, + email: email || null, + passwordHash, + firstName, + lastName, + status: UserStatus.ACTIVE, + mustChangePassword, + failedLoginAttempts: 0, + lockedUntil: null, + lastLoginAt: null, + passwordChangedAt: new Date(), + createdBy: null, + updatedBy: null, + }); + await manager.getRepository(User).save(user); + await manager.getRepository(UserRole).save( + manager.getRepository(UserRole).create({ + userId: user.id, + roleId: adminRole.id, + assignedAt: new Date(), + assignedBy: user.id, + }), + ); + await manager.getRepository(AuditEvent).save( + manager.getRepository(AuditEvent).create({ + actorUserId: user.id, + actorUsername: user.username, + action: AuditAction.SYSTEM_BOOTSTRAP_ADMIN_CREATED, + entityType: 'user', + entityId: user.id, + requestId: null, + source: AuditSource.SYSTEM, + ip: null, + userAgent: null, + beforeData: null, + afterData: { + username: user.username, + email: user.email, + firstName: user.firstName, + lastName: user.lastName, + status: user.status, + mustChangePassword: user.mustChangePassword, + roles: ['admin'], + }, + metadata: { bootstrap: true }, + }), + ); + + process.stdout.write(`Administrador creado: ${user.username}\n`); + }); + } finally { + await dataSource.destroy(); + } +} + +main().catch((error: unknown) => { + const message = error instanceof Error ? error.message : 'Unknown bootstrap error'; + process.stderr.write(`Bootstrap cancelado: ${message}\n`); + process.exitCode = 1; +}); diff --git a/api-v3/src/cli/dev-seed-mendoza-demo.ts b/api-v3/src/cli/dev-seed-mendoza-demo.ts new file mode 100644 index 0000000..142b6da --- /dev/null +++ b/api-v3/src/cli/dev-seed-mendoza-demo.ts @@ -0,0 +1,216 @@ +import 'reflect-metadata'; + +import { randomUUID } from 'node:crypto'; +import { NestFactory } from '@nestjs/core'; +import { DataSource } from 'typeorm'; +import { AppModule } from '../app.module'; +import { AssetsService } from '../asset-master/assets.service'; +import { AssetOperationalRelationsService } from '../asset-master/asset-operational-relations.service'; +import { AssetProvenanceService } from '../asset-master/asset-provenance.service'; +import { AssetRegistryService } from '../asset-master/asset-registry.service'; +import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; +import { + AreaOrganizationRole, + AssetDataOrigin, + AssetInformationStatus, + AssetOperationalStatus, + AssetSourceDocumentRelationType, + OrganizationKind, + SourceDocumentType, +} from '../database/entities'; + +const DEMO_PREFIX = 'DEV-DEMO-'; +const SOURCE_REFERENCE = 'DH-DEV-DEMO:ITN124/2026'; + +function fakeRequest(): RequestWithContext { + return { + requestId: `dev-seed-${randomUUID()}`, + ip: '127.0.0.1', + socket: { remoteAddress: '127.0.0.1' }, + header: () => 'dhv2-dev-seed', + } as unknown as RequestWithContext; +} + +async function buildPrincipal(dataSource: DataSource): Promise { + const [user] = (await dataSource.query(` + SELECT id, username, first_name AS "firstName", last_name AS "lastName", email, + must_change_password AS "mustChangePassword" + FROM users + WHERE status = 'ACTIVE' + ORDER BY CASE WHEN username = 'admin' THEN 0 ELSE 1 END, created_at + LIMIT 1 + `)) as Array<{ id: string; username: string; firstName: string; lastName: string; email: string | null; mustChangePassword: boolean }>; + if (!user) throw new Error('No hay un usuario activo para registrar la auditoría del demo'); + const roles = (await dataSource.query(` + SELECT role.code FROM user_roles ur JOIN roles role ON role.id = ur.role_id WHERE ur.user_id = $1 + `, [user.id])) as Array<{ code: string }>; + const permissions = (await dataSource.query(` + SELECT DISTINCT permission.code + FROM user_roles ur + JOIN role_permissions rp ON rp.role_id = ur.role_id + JOIN permissions permission ON permission.id = rp.permission_id + WHERE ur.user_id = $1 + `, [user.id])) as Array<{ code: string }>; + return { + userId: user.id, + username: user.username, + sessionId: 'development-seed', + firstName: user.firstName, + lastName: user.lastName, + email: user.email, + mustChangePassword: user.mustChangePassword, + roles: roles.map((row) => row.code), + permissions: permissions.map((row) => row.code), + transport: 'cookie', + }; +} + +async function main(): Promise { + const app = await NestFactory.createApplicationContext(AppModule, { logger: ['error', 'warn'] }); + try { + const dataSource = app.get(DataSource); + const assets = app.get(AssetsService); + const relations = app.get(AssetOperationalRelationsService); + const provenance = app.get(AssetProvenanceService); + const registry = app.get(AssetRegistryService); + const principal = await buildPrincipal(dataSource); + const request = fakeRequest(); + + const [existing] = (await dataSource.query( + `SELECT COUNT(*)::integer AS count FROM assets WHERE code LIKE $1 OR source_reference = $2`, + [`${DEMO_PREFIX}%`, SOURCE_REFERENCE], + )) as Array<{ count: number }>; + if (Number(existing?.count ?? 0) > 0) { + throw new Error('Ya existen datos DEV-DEMO. Ejecutá primero scripts/dev-clean-demo.sh si querés reconstruir el ejemplo.'); + } + + const typeRows = (await dataSource.query(` + SELECT id, code FROM asset_types WHERE code = ANY($1::varchar[]) AND is_active = true + `, [['area', 'empresa', 'planta', 'tanque']])) as Array<{ id: string; code: string }>; + const types = new Map(typeRows.map((row) => [row.code, row.id])); + const missing = ['area', 'empresa', 'planta', 'tanque'].filter((code) => !types.has(code)); + if (missing.length) { + throw new Error(`Faltan tipos técnicos (${missing.join(', ')}). En Maestro > Tipos y atributos ejecutá “Completar catálogo técnico”.`); + } + + const attributeRows = (await dataSource.query(` + SELECT d.id, d.code, t.code AS type_code + FROM asset_attribute_definitions d + JOIN asset_types t ON t.id = d.asset_type_id + WHERE t.code = 'tanque' AND d.is_active = true + `)) as Array<{ id: string; code: string; type_code: string }>; + const tankAttrs = new Map(attributeRows.map((row) => [row.code, row.id])); + + const createAsset = async (input: { + code: string; name: string; typeCode: string; parentId?: string | null; + areaId?: string | null; companyId?: string | null; description: string; + attributes?: Record; + }) => { + const created = await assets.create({ + code: input.code, + name: input.name, + typeId: types.get(input.typeCode)!, + parentId: input.parentId ?? null, + operationalAreaId: input.areaId ?? null, + operatorCompanyId: input.companyId ?? null, + description: input.description, + informationStatus: AssetInformationStatus.DRAFT, + attributes: input.attributes ?? {}, + }, principal, request); + await provenance.update(created.id, { + origin: AssetDataOrigin.PROVIDED_DOCUMENT, + sourceName: 'DH DEV DEMO · Informe Técnico Nº 124/2026', + sourceReference: SOURCE_REFERENCE, + observedAt: '2026-06-25T12:00:00-03:00', + notes: 'DATO DE PRUEBA. Caso construido a partir del informe técnico recibido para validar nomenclatura, jerarquía y flujo. No constituye carga maestra real ni validación de vigencia.', + }, principal, request); + return created; + }; + + const area = await createAsset({ + code: 'DEV-DEMO-AREA-ATAMISQUI', + name: '[PRUEBA] Atamisqui', + typeCode: 'area', + description: 'Área usada exclusivamente como ejemplo de desarrollo a partir del Informe Técnico Nº 124/2026.', + }); + const company = await createAsset({ + code: 'DEV-DEMO-EMP-PS', + name: '[PRUEBA] Petróleos Sudamericanos Energy S.A.', + typeCode: 'empresa', + description: 'Organización de prueba para validar la relación Área–Operadora. No implica vigencia administrativa real.', + }); + await registry.upsertOrganizationProfile(company.id, { + organizationKind: OrganizationKind.COMPANY, + legalName: 'Petróleos Sudamericanos Energy S.A.', + notes: 'Perfil de prueba DH DEV DEMO.', + }, principal, request); + const sourceDocument = await registry.createSourceDocument({ + documentType: SourceDocumentType.TECHNICAL_REPORT, + documentNumber: 'IT 124/2026 · DH-DEV-DEMO', + title: 'DH DEV DEMO · Informe Técnico Nº 124/2026', + issuer: 'Dirección de Hidrocarburos · Gobierno de Mendoza', + documentDate: '2026-06-25', + externalReference: SOURCE_REFERENCE, + notes: 'Documento de referencia cargado exclusivamente para el caso de prueba del sistema.', + }, principal, request); + await relations.create({ + areaId: area.id, + companyId: company.id, + relationRole: AreaOrganizationRole.OPERATOR, + sourceDocumentId: sourceDocument.id, + reason: 'Relación creada para el caso de prueba DH DEV DEMO basado en ITN 124/2026.', + }, principal, request); + + const plant = await createAsset({ + code: 'DEV-DEMO-PLANTA-3PB', + name: '[PRUEBA] Planta de Entrega 3 PB', + typeCode: 'planta', + parentId: area.id, + areaId: area.id, + companyId: company.id, + description: 'Instalación seleccionada como referencia en el Informe Técnico Nº 124/2026.', + }); + + const tankAttributes = (capacity: number): Record => { + const values: Record = {}; + const capacityId = tankAttrs.get('capacidad_nominal'); + const productId = tankAttrs.get('producto_servicio'); + if (capacityId) values[capacityId] = capacity; + if (productId) values[productId] = 'Petróleo'; + return values; + }; + + const tank57 = await createAsset({ + code: 'DEV-DEMO-TK-57', name: '[PRUEBA] Tanque TK-57', typeCode: 'tanque', + parentId: plant.id, areaId: area.id, companyId: company.id, + description: 'Tanque de prueba citado en ITN 124/2026; capacidad informada 480 m³.', + attributes: tankAttributes(480), + }); + const tank58 = await createAsset({ + code: 'DEV-DEMO-TK-58', name: '[PRUEBA] Tanque TK-58', typeCode: 'tanque', + parentId: plant.id, areaId: area.id, companyId: company.id, + description: 'Tanque de prueba citado en ITN 124/2026; capacidad informada 320 m³.', + attributes: tankAttributes(320), + }); + + for (const assetId of [area.id, company.id, plant.id, tank57.id, tank58.id]) { + await registry.linkDocument(assetId, sourceDocument.id, { relationType: AssetSourceDocumentRelationType.SOURCE }, principal, request); + } + await assets.changeOperationalStatus(tank57.id, { status: AssetOperationalStatus.OUT_OF_SERVICE }, principal, request); + await assets.changeOperationalStatus(tank58.id, { status: AssetOperationalStatus.OUT_OF_SERVICE }, principal, request); + + console.log('DEMO DH creado correctamente'); + console.log('Área: [PRUEBA] Atamisqui'); + console.log('Organización operadora: [PRUEBA] Petróleos Sudamericanos Energy S.A.'); + console.log('Instalación: [PRUEBA] Planta de Entrega 3 PB'); + console.log('Equipos: [PRUEBA] TK-57 (480 m³), TK-58 (320 m³)'); + console.log(`Marca de limpieza: ${SOURCE_REFERENCE}`); + } finally { + await app.close(); + } +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; +}); diff --git a/api-v3/src/common/config/auth-config.service.ts b/api-v3/src/common/config/auth-config.service.ts new file mode 100644 index 0000000..e962b6d --- /dev/null +++ b/api-v3/src/common/config/auth-config.service.ts @@ -0,0 +1,93 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +function positiveInteger( + config: ConfigService, + key: string, + fallback: number, +): number { + const value = Number(config.get(key) ?? fallback); + if (!Number.isInteger(value) || value <= 0) { + throw new Error(`${key} must be a positive integer`); + } + return value; +} + +function requiredSecret(config: ConfigService, key: string): string { + const value = config.get(key); + if (!value || value.length < 64) { + throw new Error(`${key} must contain at least 64 characters`); + } + return value; +} + +function cookieName(config: ConfigService, key: string, fallback: string): string { + const value = config.get(key) ?? fallback; + if (!/^[A-Za-z0-9_-]{1,80}$/.test(value)) { + throw new Error(`${key} must be a valid cookie name`); + } + return value; +} + +@Injectable() +export class AuthConfigService { + readonly accessTokenSecret: string; + readonly refreshTokenPepper: string; + readonly accessTokenTtlSeconds: number; + readonly refreshTokenTtlSeconds: number; + readonly maxLoginAttempts: number; + readonly lockoutSeconds: number; + readonly webOrigin: string; + readonly accessCookieName: string; + readonly refreshCookieName: string; + readonly csrfCookieName: string; + + constructor(config: ConfigService) { + this.accessTokenSecret = requiredSecret(config, 'JWT_ACCESS_SECRET'); + this.refreshTokenPepper = requiredSecret(config, 'REFRESH_TOKEN_PEPPER'); + if (this.accessTokenSecret === this.refreshTokenPepper) { + throw new Error('JWT_ACCESS_SECRET and REFRESH_TOKEN_PEPPER must differ'); + } + this.accessTokenTtlSeconds = positiveInteger( + config, + 'ACCESS_TOKEN_TTL_SECONDS', + 900, + ); + this.refreshTokenTtlSeconds = positiveInteger( + config, + 'REFRESH_TOKEN_TTL_SECONDS', + 604800, + ); + this.maxLoginAttempts = positiveInteger( + config, + 'AUTH_MAX_LOGIN_ATTEMPTS', + 5, + ); + this.lockoutSeconds = positiveInteger( + config, + 'AUTH_LOCKOUT_SECONDS', + 900, + ); + + const webOrigin = config.get('WEB_ORIGIN'); + if (!webOrigin) throw new Error('Missing required environment variable: WEB_ORIGIN'); + + const parsedOrigin = new URL(webOrigin).origin; + if (parsedOrigin !== webOrigin || !webOrigin.startsWith('https://')) { + throw new Error('WEB_ORIGIN must be an HTTPS origin without a path'); + } + this.webOrigin = parsedOrigin; + + this.accessCookieName = cookieName( + config, + 'ACCESS_COOKIE_NAME', + 'dhv2_access', + ); + this.refreshCookieName = cookieName( + config, + 'REFRESH_COOKIE_NAME', + 'dhv2_refresh', + ); + this.csrfCookieName = cookieName(config, 'CSRF_COOKIE_NAME', 'dhv2_csrf'); + } +} diff --git a/api-v3/src/common/filters/api-exception.filter.ts b/api-v3/src/common/filters/api-exception.filter.ts new file mode 100644 index 0000000..88c241c --- /dev/null +++ b/api-v3/src/common/filters/api-exception.filter.ts @@ -0,0 +1,65 @@ +import { + ArgumentsHost, + Catch, + ExceptionFilter, + HttpException, + HttpStatus, +} from '@nestjs/common'; +import type { Response } from 'express'; +import type { RequestWithContext } from '../http/request-context'; + +interface ErrorBody { + code?: unknown; + message?: unknown; +} + +@Catch() +export class ApiExceptionFilter implements ExceptionFilter { + catch(exception: unknown, host: ArgumentsHost): void { + const context = host.switchToHttp(); + const request = context.getRequest(); + const response = context.getResponse(); + const status = + exception instanceof HttpException + ? exception.getStatus() + : HttpStatus.INTERNAL_SERVER_ERROR; + + const rawBody = + exception instanceof HttpException ? exception.getResponse() : undefined; + const body = + typeof rawBody === 'object' && rawBody !== null + ? (rawBody as ErrorBody) + : undefined; + + let code = typeof body?.code === 'string' ? body.code : undefined; + let message = typeof body?.message === 'string' ? body.message : undefined; + + if (status === HttpStatus.BAD_REQUEST && !code) { + code = 'VALIDATION_ERROR'; + message = 'Datos inválidos'; + } + if (status === HttpStatus.UNAUTHORIZED && !code) { + code = 'UNAUTHORIZED'; + message = 'Autenticación requerida'; + } + if (status === HttpStatus.FORBIDDEN && !code) { + code = 'FORBIDDEN'; + message = 'Acceso denegado'; + } + if (status === HttpStatus.TOO_MANY_REQUESTS && !code) { + code = 'RATE_LIMITED'; + message = 'Demasiadas solicitudes'; + } + if (status >= 500) { + code = 'INTERNAL_ERROR'; + message = 'Error interno'; + } + + response.status(status).json({ + statusCode: status, + code: code ?? 'REQUEST_ERROR', + message: message ?? 'La solicitud no pudo procesarse', + requestId: request.requestId, + }); + } +} diff --git a/api-v3/src/common/http/request-context.ts b/api-v3/src/common/http/request-context.ts new file mode 100644 index 0000000..14a0bbc --- /dev/null +++ b/api-v3/src/common/http/request-context.ts @@ -0,0 +1,21 @@ +import type { Request } from 'express'; + +export type AuthTransport = 'cookie' | 'bearer'; + +export interface AuthPrincipal { + userId: string; + username: string; + sessionId: string; + firstName: string; + lastName: string; + email: string | null; + mustChangePassword: boolean; + roles: string[]; + permissions: string[]; + transport: AuthTransport; +} + +export interface RequestWithContext extends Request { + requestId: string; + auth?: AuthPrincipal; +} diff --git a/api-v3/src/common/http/request-id.middleware.ts b/api-v3/src/common/http/request-id.middleware.ts new file mode 100644 index 0000000..1932a40 --- /dev/null +++ b/api-v3/src/common/http/request-id.middleware.ts @@ -0,0 +1,17 @@ +import { randomUUID } from 'node:crypto'; +import type { NextFunction, Response } from 'express'; +import type { RequestWithContext } from './request-context'; + +const REQUEST_ID_PATTERN = /^[A-Za-z0-9._:-]{8,128}$/; + +export function requestIdMiddleware( + request: RequestWithContext, + response: Response, + next: NextFunction, +): void { + const supplied = request.header('x-request-id'); + request.requestId = + supplied && REQUEST_ID_PATTERN.test(supplied) ? supplied : randomUUID(); + response.setHeader('X-Request-ID', request.requestId); + next(); +} diff --git a/api-v3/src/core-data/phase-a-data.module.ts b/api-v3/src/core-data/phase-a-data.module.ts new file mode 100644 index 0000000..83bcd74 --- /dev/null +++ b/api-v3/src/core-data/phase-a-data.module.ts @@ -0,0 +1,21 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { PHASE_A_ENTITIES } from '../database/entities'; +import { AuditEventsRepository } from './repositories/audit-events.repository'; +import { AuthSessionsRepository } from './repositories/auth-sessions.repository'; +import { RolesRepository } from './repositories/roles.repository'; +import { UsersRepository } from './repositories/users.repository'; + +const repositories = [ + UsersRepository, + RolesRepository, + AuthSessionsRepository, + AuditEventsRepository, +]; + +@Module({ + imports: [TypeOrmModule.forFeature(PHASE_A_ENTITIES)], + providers: repositories, + exports: repositories, +}) +export class PhaseADataModule {} diff --git a/api-v3/src/core-data/repositories/audit-events.repository.ts b/api-v3/src/core-data/repositories/audit-events.repository.ts new file mode 100644 index 0000000..fea5f0a --- /dev/null +++ b/api-v3/src/core-data/repositories/audit-events.repository.ts @@ -0,0 +1,20 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { DeepPartial, Repository } from 'typeorm'; +import { AuditEvent } from '../../database/entities'; + +@Injectable() +export class AuditEventsRepository { + constructor( + @InjectRepository(AuditEvent) + private readonly repository: Repository, + ) {} + + create(input: DeepPartial): AuditEvent { + return this.repository.create(input); + } + + save(event: AuditEvent): Promise { + return this.repository.save(event); + } +} diff --git a/api-v3/src/core-data/repositories/auth-sessions.repository.ts b/api-v3/src/core-data/repositories/auth-sessions.repository.ts new file mode 100644 index 0000000..c2b48fb --- /dev/null +++ b/api-v3/src/core-data/repositories/auth-sessions.repository.ts @@ -0,0 +1,110 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { + DeepPartial, + EntityManager, + IsNull, + MoreThan, + Repository, +} from 'typeorm'; +import { AuthSession } from '../../database/entities'; + +@Injectable() +export class AuthSessionsRepository { + constructor( + @InjectRepository(AuthSession) + private readonly repository: Repository, + ) {} + + create(input: DeepPartial): AuthSession { + return this.repository.create(input); + } + + save(session: AuthSession): Promise { + return this.repository.save(session); + } + + findActiveById(id: string, now = new Date()): Promise { + return this.repository.findOne({ + where: { + id, + revokedAt: IsNull(), + expiresAt: MoreThan(now), + }, + }); + } + + findByIdWithRefreshTokenHash(id: string): Promise { + return this.repository + .createQueryBuilder('session') + .addSelect('session.refreshTokenHash') + .where('session.id = :id', { id }) + .getOne(); + } + + async revokeUserSessions( + userId: string, + exceptSessionId?: string, + manager?: EntityManager, + ): Promise { + const parameters: unknown[] = [userId]; + let exception = ''; + if (exceptSessionId) { + parameters.push(exceptSessionId); + exception = 'AND id <> $2'; + } + await (manager ?? this.repository.manager).query( + ` + UPDATE auth_sessions + SET revoked_at = COALESCE(revoked_at, CURRENT_TIMESTAMP) + WHERE user_id = $1 + AND revoked_at IS NULL + ${exception} + `, + parameters, + ); + } + + async revokeSessionFamily( + sessionId: string, + manager?: EntityManager, + ): Promise { + await (manager ?? this.repository.manager).query( + ` + WITH RECURSIVE family AS ( + SELECT id, replaced_by_session_id + FROM auth_sessions + WHERE id = $1 + UNION + SELECT session.id, session.replaced_by_session_id + FROM auth_sessions session + INNER JOIN family member + ON session.id = member.replaced_by_session_id + OR session.replaced_by_session_id = member.id + ) + UPDATE auth_sessions + SET revoked_at = COALESCE(revoked_at, CURRENT_TIMESTAMP) + WHERE id IN (SELECT id FROM family) + `, + [sessionId], + ); + } + + + async revokeSession( + sessionId: string, + userId: string, + manager?: EntityManager, + ): Promise { + await (manager ?? this.repository.manager).query( + ` + UPDATE auth_sessions + SET + revoked_at = COALESCE(revoked_at, CURRENT_TIMESTAMP), + last_used_at = CURRENT_TIMESTAMP + WHERE id = $1 AND user_id = $2 + `, + [sessionId, userId], + ); + } +} diff --git a/api-v3/src/core-data/repositories/roles.repository.ts b/api-v3/src/core-data/repositories/roles.repository.ts new file mode 100644 index 0000000..a2685f9 --- /dev/null +++ b/api-v3/src/core-data/repositories/roles.repository.ts @@ -0,0 +1,57 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { In, Repository } from 'typeorm'; +import { Role } from '../../database/entities'; + +interface PermissionCodeRow { + code: string; +} + +interface RoleCodeRow { + code: string; +} + +@Injectable() +export class RolesRepository { + constructor( + @InjectRepository(Role) + private readonly repository: Repository, + ) {} + + findByCodes(codes: string[]): Promise { + if (codes.length === 0) return Promise.resolve([]); + return this.repository.find({ where: { code: In(codes) } }); + } + + async findRoleCodesForUser(userId: string): Promise { + const rows = (await this.repository.query( + ` + SELECT role.code + FROM roles role + INNER JOIN user_roles user_role ON user_role.role_id = role.id + WHERE user_role.user_id = $1 + ORDER BY role.code ASC + `, + [userId], + )) as RoleCodeRow[]; + return rows.map((row) => row.code); + } + + async findPermissionCodesForUser(userId: string): Promise { + const rows = (await this.repository.query( + ` + SELECT DISTINCT permission.code + FROM permissions permission + INNER JOIN role_permissions role_permission + ON role_permission.permission_id = permission.id + INNER JOIN user_roles user_role + ON user_role.role_id = role_permission.role_id + WHERE user_role.user_id = $1 + ORDER BY permission.code ASC + `, + [userId], + )) as PermissionCodeRow[]; + + return rows.map((row) => row.code); + } +} diff --git a/api-v3/src/core-data/repositories/users.repository.ts b/api-v3/src/core-data/repositories/users.repository.ts new file mode 100644 index 0000000..1e1aa59 --- /dev/null +++ b/api-v3/src/core-data/repositories/users.repository.ts @@ -0,0 +1,69 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { DeepPartial, Repository } from 'typeorm'; +import { User } from '../../database/entities'; + +@Injectable() +export class UsersRepository { + constructor( + @InjectRepository(User) + private readonly repository: Repository, + ) {} + + create(input: DeepPartial): User { + return this.repository.create(input); + } + + save(user: User): Promise { + return this.repository.save(user); + } + + findById(id: string): Promise { + return this.repository.findOne({ where: { id } }); + } + + findForAuthentication(identifier: string): Promise { + const normalizedIdentifier = identifier.trim().toLowerCase(); + + return this.repository + .createQueryBuilder('user') + .addSelect('user.passwordHash') + .where('LOWER(user.username) = :identifier', { + identifier: normalizedIdentifier, + }) + .orWhere('LOWER(user.email) = :identifier', { + identifier: normalizedIdentifier, + }) + .getOne(); + } + + findByIdForPasswordVerification(id: string): Promise { + return this.repository + .createQueryBuilder('user') + .addSelect('user.passwordHash') + .where('user.id = :id', { id }) + .getOne(); + } + + async registerFailedLogin( + userId: string, + maxAttempts: number, + lockoutSeconds: number, + ): Promise { + await this.repository.query( + ` + UPDATE users + SET + failed_login_attempts = failed_login_attempts + 1, + locked_until = CASE + WHEN failed_login_attempts + 1 >= $2 + THEN CURRENT_TIMESTAMP + ($3 * INTERVAL '1 second') + ELSE locked_until + END, + updated_at = CURRENT_TIMESTAMP + WHERE id = $1 + `, + [userId, maxAttempts, lockoutSeconds], + ); + } +} diff --git a/api-v3/src/dashboard/dashboard.controller.ts b/api-v3/src/dashboard/dashboard.controller.ts new file mode 100644 index 0000000..fe393b3 --- /dev/null +++ b/api-v3/src/dashboard/dashboard.controller.ts @@ -0,0 +1,14 @@ +import { Controller, Get } from '@nestjs/common'; +import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator'; +import { DashboardService } from './dashboard.service'; + +@Controller('dashboard') +export class DashboardController { + constructor(private readonly dashboard: DashboardService) {} + + @Get('summary') + @RequirePermissions('dashboard.read') + summary() { + return this.dashboard.summary(); + } +} diff --git a/api-v3/src/dashboard/dashboard.module.ts b/api-v3/src/dashboard/dashboard.module.ts new file mode 100644 index 0000000..c4a4a45 --- /dev/null +++ b/api-v3/src/dashboard/dashboard.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { DashboardController } from './dashboard.controller'; +import { DashboardService } from './dashboard.service'; + +@Module({ + controllers: [DashboardController], + providers: [DashboardService], +}) +export class DashboardModule {} diff --git a/api-v3/src/dashboard/dashboard.service.ts b/api-v3/src/dashboard/dashboard.service.ts new file mode 100644 index 0000000..4db8e0a --- /dev/null +++ b/api-v3/src/dashboard/dashboard.service.ts @@ -0,0 +1,167 @@ +import { Injectable } from '@nestjs/common'; +import { DataSource } from 'typeorm'; + +export interface DashboardAuditItem { + id: string; + occurredAt: Date; + actorUsername: string | null; + action: string; + entityType: string | null; + entityId: string | null; +} + +interface DashboardCountsRow { + totalAssets: number | string; + assetsNeedValidation: number | string; + assetsWithoutGeometry: number | string; + plannedInspections: number | string; + activeUsers: number | string; + inactiveUsers: number | string; + activeSessions: number | string; + openFindings: number | string; + awaitingCompanyResponse: number | string; + overdueCompanyResponses: number | string; + companyResponsesDueNext7Days: number | string; + awaitingVerificationSchedule: number | string; + overdueControls: number | string; + controlsNext30Days: number | string; +} + +export interface DashboardUpcomingControl { + id: string; + code: string; + title: string; + nextControlOn: string; + actId: string; + actCode: string; + visitId: string; + visitCode: string; + assetId: string; + assetCode: string; + assetName: string; +} + +@Injectable() +export class DashboardService { + constructor(private readonly dataSource: DataSource) {} + + async summary() { + return this.dataSource.transaction('REPEATABLE READ', async (manager) => { + const [counts] = (await manager.query(` + SELECT + (SELECT COUNT(*) FROM assets WHERE information_status <> 'INACTIVE') AS "totalAssets", + (SELECT COUNT(*) FROM assets WHERE information_status NOT IN ('VALIDATED','INACTIVE')) AS "assetsNeedValidation", + (SELECT COUNT(*) FROM assets asset WHERE asset.information_status <> 'INACTIVE' AND NOT EXISTS (SELECT 1 FROM asset_geometries geometry WHERE geometry.asset_id=asset.id)) AS "assetsWithoutGeometry", + (SELECT COUNT(*) FROM inspection_visits WHERE status='PLANNED') AS "plannedInspections", + (SELECT COUNT(*) FROM users WHERE status = 'ACTIVE') AS "activeUsers", + (SELECT COUNT(*) FROM users WHERE status = 'INACTIVE') AS "inactiveUsers", + ( + SELECT COUNT(*) + FROM auth_sessions + WHERE revoked_at IS NULL + AND expires_at > CURRENT_TIMESTAMP + ) AS "activeSessions", + ( + SELECT COUNT(*) FROM inspection_findings WHERE status = 'OPEN' + ) AS "openFindings", + ( + SELECT COUNT(*) FROM inspection_findings + WHERE status = 'OPEN' AND company_response_received_on IS NULL + ) AS "awaitingCompanyResponse", + ( + SELECT COUNT(*) FROM inspection_findings + WHERE status = 'OPEN' + AND company_response_received_on IS NULL + AND correction_due_on IS NOT NULL + AND correction_due_on < (CURRENT_TIMESTAMP AT TIME ZONE 'America/Argentina/Mendoza')::date + ) AS "overdueCompanyResponses", + ( + SELECT COUNT(*) FROM inspection_findings + WHERE status = 'OPEN' + AND company_response_received_on IS NULL + AND correction_due_on BETWEEN + (CURRENT_TIMESTAMP AT TIME ZONE 'America/Argentina/Mendoza')::date + AND (CURRENT_TIMESTAMP AT TIME ZONE 'America/Argentina/Mendoza')::date + 7 + ) AS "companyResponsesDueNext7Days", + ( + SELECT COUNT(*) FROM inspection_findings + WHERE status = 'OPEN' + AND company_response_received_on IS NOT NULL + AND next_control_on IS NULL + ) AS "awaitingVerificationSchedule", + ( + SELECT COUNT(*) FROM inspection_findings + WHERE status = 'OPEN' + AND company_response_received_on IS NOT NULL + AND next_control_on < (CURRENT_TIMESTAMP AT TIME ZONE 'America/Argentina/Mendoza')::date + ) AS "overdueControls", + ( + SELECT COUNT(*) FROM inspection_findings + WHERE status = 'OPEN' + AND next_control_on BETWEEN + (CURRENT_TIMESTAMP AT TIME ZONE 'America/Argentina/Mendoza')::date + AND (CURRENT_TIMESTAMP AT TIME ZONE 'America/Argentina/Mendoza')::date + 30 + ) AS "controlsNext30Days" + `)) as DashboardCountsRow[]; + + const recentAudit = (await manager.query(` + SELECT + event.id, + event.occurred_at AS "occurredAt", + event.actor_username AS "actorUsername", + event.action, + event.entity_type AS "entityType", + event.entity_id AS "entityId" + FROM audit_events event + ORDER BY event.occurred_at DESC, event.id DESC + LIMIT 6 + `)) as DashboardAuditItem[]; + + const upcomingControls = (await manager.query(` + SELECT + finding.id, + finding.code, + finding.title, + finding.next_control_on AS "nextControlOn", + act.id AS "actId", + act.code AS "actCode", + visit.id AS "visitId", + visit.code AS "visitCode", + asset.id AS "assetId", + asset.code AS "assetCode", + asset.name AS "assetName" + 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 + INNER JOIN assets asset ON asset.id = finding.asset_id + WHERE finding.status = 'OPEN' + AND finding.company_response_received_on IS NOT NULL + AND finding.next_control_on IS NOT NULL + ORDER BY finding.next_control_on, finding.code + LIMIT 8 + `)) as DashboardUpcomingControl[]; + + return { + counts: { + totalAssets: Number(counts?.totalAssets ?? 0), + assetsNeedValidation: Number(counts?.assetsNeedValidation ?? 0), + assetsWithoutGeometry: Number(counts?.assetsWithoutGeometry ?? 0), + plannedInspections: Number(counts?.plannedInspections ?? 0), + activeUsers: Number(counts?.activeUsers ?? 0), + inactiveUsers: Number(counts?.inactiveUsers ?? 0), + activeSessions: Number(counts?.activeSessions ?? 0), + openFindings: Number(counts?.openFindings ?? 0), + awaitingCompanyResponse: Number(counts?.awaitingCompanyResponse ?? 0), + overdueCompanyResponses: Number(counts?.overdueCompanyResponses ?? 0), + companyResponsesDueNext7Days: Number(counts?.companyResponsesDueNext7Days ?? 0), + awaitingVerificationSchedule: Number(counts?.awaitingVerificationSchedule ?? 0), + overdueControls: Number(counts?.overdueControls ?? 0), + controlsNext30Days: Number(counts?.controlsNext30Days ?? 0), + }, + recentAudit, + upcomingControls, + generatedAt: new Date().toISOString(), + }; + }); + } +} diff --git a/api-v3/src/database/data-source.ts b/api-v3/src/database/data-source.ts new file mode 100644 index 0000000..79d69cc --- /dev/null +++ b/api-v3/src/database/data-source.ts @@ -0,0 +1,34 @@ +import { join } from 'node:path'; +import { DataSource } from 'typeorm'; +import { PHASE_A_ENTITIES } from './entities'; + +function requiredEnvironmentVariable(key: string): string { + const value = process.env[key]; + if (!value) throw new Error(`Missing required environment variable: ${key}`); + return value; +} + +function databasePort(): number { + const port = Number(process.env.DB_PORT ?? 5432); + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error('DB_PORT must be a valid TCP port'); + } + return port; +} + +export const migrationDataSource = new DataSource({ + type: 'postgres', + host: requiredEnvironmentVariable('DB_HOST'), + port: databasePort(), + database: requiredEnvironmentVariable('DB_NAME'), + username: requiredEnvironmentVariable('DB_MIGRATION_USER'), + password: requiredEnvironmentVariable('DB_MIGRATION_PASSWORD'), + entities: PHASE_A_ENTITIES, + migrations: [join(__dirname, 'migrations', '*.{js,ts}')], + migrationsTableName: 'typeorm_migrations', + synchronize: false, + migrationsRun: false, + logging: false, + applicationName: 'dhv2-migrations', + connectTimeoutMS: 5000, +}); diff --git a/api-v3/src/database/entities/area-company-relation.entity.ts b/api-v3/src/database/entities/area-company-relation.entity.ts new file mode 100644 index 0000000..d4765e1 --- /dev/null +++ b/api-v3/src/database/entities/area-company-relation.entity.ts @@ -0,0 +1,51 @@ +import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; +import { TimestampedEntity } from './timestamped.entity'; + +export enum AreaOrganizationRole { + OPERATOR='OPERATOR', TECHNICAL_OPERATOR='TECHNICAL_OPERATOR', CONCESSIONAIRE='CONCESSIONAIRE', PERMIT_HOLDER='PERMIT_HOLDER', PARTICIPANT='PARTICIPANT', OTHER='OTHER' +} + +@Entity({ name: 'area_company_relations' }) +@Index('idx_area_company_relations_area_id', ['areaId']) +@Index('idx_area_company_relations_company_id', ['companyId']) +@Index('idx_area_company_relations_valid_until', ['validUntil']) +export class AreaCompanyRelation extends TimestampedEntity { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ name: 'area_id', type: 'uuid' }) + areaId!: string; + + @Column({ name: 'company_id', type: 'uuid' }) + companyId!: string; + + @Column({ name:'relation_role', type:'enum', enum:AreaOrganizationRole, enumName:'area_organization_role', default:AreaOrganizationRole.OPERATOR }) + relationRole!: AreaOrganizationRole; + + @Column({ name:'participation_percent', type:'numeric', precision:7, scale:4, nullable:true }) + participationPercent!: string|null; + + @Column({ name:'legal_instrument', type:'varchar', length:240, nullable:true }) + legalInstrument!: string|null; + + @Column({ name:'source_document_id', type:'uuid', nullable:true }) + sourceDocumentId!: string|null; + + @Column({ name: 'valid_from', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' }) + validFrom!: Date; + + @Column({ name: 'valid_until', type: 'timestamptz', nullable: true }) + validUntil!: Date | null; + + @Column({ name: 'start_reason', type: 'text' }) + startReason!: string; + + @Column({ name: 'end_reason', type: 'text', nullable: true }) + endReason!: string | null; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy!: string | null; + + @Column({ name: 'ended_by', type: 'uuid', nullable: true }) + endedBy!: string | null; +} diff --git a/api-v3/src/database/entities/area-legal-right-organization.entity.ts b/api-v3/src/database/entities/area-legal-right-organization.entity.ts new file mode 100644 index 0000000..9e335d9 --- /dev/null +++ b/api-v3/src/database/entities/area-legal-right-organization.entity.ts @@ -0,0 +1,4 @@ +import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; import { TimestampedEntity } from './timestamped.entity'; +export enum AreaLegalRightOrganizationRole { HOLDER='HOLDER', PARTICIPANT='PARTICIPANT', OPERATOR='OPERATOR', OTHER='OTHER' } +@Entity({name:'area_legal_right_organizations'}) @Index('idx_area_legal_right_org_right_id',['rightId']) @Index('idx_area_legal_right_org_organization_id',['organizationId']) @Index('idx_area_legal_right_org_valid_until',['validUntil']) +export class AreaLegalRightOrganization extends TimestampedEntity { @PrimaryGeneratedColumn('uuid') id!:string; @Column({name:'right_id',type:'uuid'}) rightId!:string; @Column({name:'organization_id',type:'uuid'}) organizationId!:string; @Column({type:'enum',enum:AreaLegalRightOrganizationRole,enumName:'area_legal_right_organization_role'}) role!:AreaLegalRightOrganizationRole; @Column({name:'participation_percent',type:'numeric',precision:7,scale:4,nullable:true}) participationPercent!:string|null; @Column({name:'valid_from',type:'date',default:()=> 'CURRENT_DATE'}) validFrom!:string; @Column({name:'valid_until',type:'date',nullable:true}) validUntil!:string|null; @Column({type:'text',nullable:true}) notes!:string|null; @Column({name:'end_reason',type:'text',nullable:true}) endReason!:string|null; @Column({name:'created_by',type:'uuid',nullable:true}) createdBy!:string|null; @Column({name:'ended_by',type:'uuid',nullable:true}) endedBy!:string|null; } diff --git a/api-v3/src/database/entities/area-legal-right.entity.ts b/api-v3/src/database/entities/area-legal-right.entity.ts new file mode 100644 index 0000000..ef75062 --- /dev/null +++ b/api-v3/src/database/entities/area-legal-right.entity.ts @@ -0,0 +1,5 @@ +import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; import { TimestampedEntity } from './timestamped.entity'; +export enum AreaLegalRightType { EXPLOITATION_CONCESSION='EXPLOITATION_CONCESSION', EXPLORATION_PERMIT='EXPLORATION_PERMIT', TRANSPORT_CONCESSION='TRANSPORT_CONCESSION', OTHER='OTHER' } +export enum AreaLegalRightStatus { ACTIVE='ACTIVE', EXPIRED='EXPIRED', REVOKED='REVOKED', PENDING='PENDING' } +@Entity({name:'area_legal_rights'}) @Index('idx_area_legal_rights_area_id',['areaId']) @Index('idx_area_legal_rights_valid_until',['validUntil']) +export class AreaLegalRight extends TimestampedEntity { @PrimaryGeneratedColumn('uuid') id!:string; @Column({name:'area_id',type:'uuid'}) areaId!:string; @Column({name:'right_type',type:'enum',enum:AreaLegalRightType,enumName:'area_legal_right_type'}) rightType!:AreaLegalRightType; @Column({type:'varchar',length:260}) name!:string; @Column({name:'instrument_number',type:'varchar',length:180,nullable:true}) instrumentNumber!:string|null; @Column({name:'valid_from',type:'date',nullable:true}) validFrom!:string|null; @Column({name:'valid_until',type:'date',nullable:true}) validUntil!:string|null; @Column({type:'enum',enum:AreaLegalRightStatus,enumName:'area_legal_right_status',default:AreaLegalRightStatus.ACTIVE}) status!:AreaLegalRightStatus; @Column({name:'source_document_id',type:'uuid',nullable:true}) sourceDocumentId!:string|null; @Column({type:'text',nullable:true}) notes!:string|null; @Column({name:'created_by',type:'uuid',nullable:true}) createdBy!:string|null; @Column({name:'updated_by',type:'uuid',nullable:true}) updatedBy!:string|null; } diff --git a/api-v3/src/database/entities/asset-attribute-definition.entity.ts b/api-v3/src/database/entities/asset-attribute-definition.entity.ts new file mode 100644 index 0000000..5df887c --- /dev/null +++ b/api-v3/src/database/entities/asset-attribute-definition.entity.ts @@ -0,0 +1,51 @@ +import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; +import { TimestampedEntity } from './timestamped.entity'; + +export enum AssetAttributeDataType { + TEXT = 'TEXT', + NUMBER = 'NUMBER', + BOOLEAN = 'BOOLEAN', + DATE = 'DATE', + DATETIME = 'DATETIME', + SELECT = 'SELECT', +} + +@Entity({ name: 'asset_attribute_definitions' }) +@Index('idx_asset_attribute_definitions_type', ['assetTypeId', 'sortOrder']) +@Index('idx_asset_attribute_definitions_active', ['isActive']) +export class AssetAttributeDefinition extends TimestampedEntity { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ name: 'asset_type_id', type: 'uuid' }) + assetTypeId!: string; + + @Column({ type: 'varchar', length: 80 }) + code!: string; + + @Column({ type: 'varchar', length: 160 }) + name!: string; + + @Column({ + name: 'data_type', + type: 'enum', + enum: AssetAttributeDataType, + enumName: 'asset_attribute_data_type', + }) + dataType!: AssetAttributeDataType; + + @Column({ name: 'is_required', type: 'boolean', default: false }) + isRequired!: boolean; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive!: boolean; + + @Column({ type: 'varchar', length: 40, nullable: true }) + unit!: string | null; + + @Column({ type: 'jsonb', nullable: true }) + options!: string[] | null; + + @Column({ name: 'sort_order', type: 'integer', default: 0 }) + sortOrder!: number; +} diff --git a/api-v3/src/database/entities/asset-attribute-value.entity.ts b/api-v3/src/database/entities/asset-attribute-value.entity.ts new file mode 100644 index 0000000..c86c8f6 --- /dev/null +++ b/api-v3/src/database/entities/asset-attribute-value.entity.ts @@ -0,0 +1,19 @@ +import { Column, Entity, PrimaryColumn } from 'typeorm'; + +@Entity({ name: 'asset_attribute_values' }) +export class AssetAttributeValue { + @PrimaryColumn({ name: 'asset_id', type: 'uuid' }) + assetId!: string; + + @PrimaryColumn({ name: 'definition_id', type: 'uuid' }) + definitionId!: string; + + @Column({ type: 'jsonb' }) + value!: unknown; + + @Column({ name: 'updated_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' }) + updatedAt!: Date; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedBy!: string | null; +} diff --git a/api-v3/src/database/entities/asset-external-identifier.entity.ts b/api-v3/src/database/entities/asset-external-identifier.entity.ts new file mode 100644 index 0000000..2e57ed3 --- /dev/null +++ b/api-v3/src/database/entities/asset-external-identifier.entity.ts @@ -0,0 +1,3 @@ +import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; import { TimestampedEntity } from './timestamped.entity'; +@Entity({name:'asset_external_identifiers'}) @Index('idx_asset_external_identifiers_asset_id',['assetId']) @Index('idx_asset_external_identifiers_namespace',['namespace']) +export class AssetExternalIdentifier extends TimestampedEntity { @PrimaryGeneratedColumn('uuid') id!:string; @Column({name:'asset_id',type:'uuid'}) assetId!:string; @Column({type:'varchar',length:80}) namespace!:string; @Column({type:'varchar',length:180}) value!:string; @Column({name:'valid_from',type:'timestamptz',default:()=> 'CURRENT_TIMESTAMP'}) validFrom!:Date; @Column({name:'valid_until',type:'timestamptz',nullable:true}) validUntil!:Date|null; @Column({name:'source_document_id',type:'uuid',nullable:true}) sourceDocumentId!:string|null; @Column({type:'text',nullable:true}) notes!:string|null; @Column({name:'end_reason',type:'text',nullable:true}) endReason!:string|null; @Column({name:'created_by',type:'uuid',nullable:true}) createdBy!:string|null; @Column({name:'ended_by',type:'uuid',nullable:true}) endedBy!:string|null; } diff --git a/api-v3/src/database/entities/asset-geometry.entity.ts b/api-v3/src/database/entities/asset-geometry.entity.ts new file mode 100644 index 0000000..aabe8e8 --- /dev/null +++ b/api-v3/src/database/entities/asset-geometry.entity.ts @@ -0,0 +1,58 @@ +import { Column, Entity, Index, PrimaryColumn } from 'typeorm'; + +export enum AssetGeometryType { + POINT = 'POINT', + LINESTRING = 'LINESTRING', + POLYGON = 'POLYGON', +} + +export enum AssetGeometrySource { + WEB = 'WEB', + ANDROID = 'ANDROID', + IMPORT = 'IMPORT', + SURVEY = 'SURVEY', +} + +@Entity({ name: 'asset_geometries' }) +@Index('idx_asset_geometries_geometry', ['geometry'], { spatial: true }) +@Index('idx_asset_geometries_type', ['geometryType']) +export class AssetGeometry { + @PrimaryColumn({ name: 'asset_id', type: 'uuid' }) + assetId!: string; + + @Column({ + type: 'geometry', + spatialFeatureType: 'Geometry', + srid: 4326, + }) + geometry!: { type: AssetGeometryType; coordinates: unknown }; + + @Column({ name: 'geometry_type', type: 'varchar', length: 20 }) + geometryType!: AssetGeometryType; + + @Column({ + type: 'enum', + enum: AssetGeometrySource, + enumName: 'asset_geometry_source', + default: AssetGeometrySource.WEB, + }) + source!: AssetGeometrySource; + + @Column({ name: 'accuracy_m', type: 'numeric', precision: 12, scale: 3, nullable: true }) + accuracyM!: string | null; + + @Column({ name: 'captured_at', type: 'timestamptz', nullable: true }) + capturedAt!: Date | null; + + @Column({ name: 'device_label', type: 'varchar', length: 255, nullable: true }) + deviceLabel!: string | null; + + @Column({ name: 'created_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' }) + createdAt!: Date; + + @Column({ name: 'updated_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' }) + updatedAt!: Date; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedBy!: string | null; +} diff --git a/api-v3/src/database/entities/asset-media.entity.ts b/api-v3/src/database/entities/asset-media.entity.ts new file mode 100644 index 0000000..1ff61e6 --- /dev/null +++ b/api-v3/src/database/entities/asset-media.entity.ts @@ -0,0 +1,78 @@ +import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; + +export enum AssetMediaKind { + PHOTO = 'PHOTO', + DOCUMENT = 'DOCUMENT', +} + +export enum AssetMediaSource { + WEB = 'WEB', + ANDROID = 'ANDROID', + IMPORT = 'IMPORT', +} + +@Entity({ name: 'asset_media' }) +@Index('idx_asset_media_asset_created', ['assetId', 'createdAt']) +@Index('idx_asset_media_sha256', ['sha256']) +@Index('idx_asset_media_active', ['assetId', 'deletedAt']) +export class AssetMedia { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ name: 'asset_id', type: 'uuid' }) + assetId!: string; + + @Column({ type: 'varchar', length: 20 }) + kind!: AssetMediaKind; + + @Column({ name: 'original_name', type: 'varchar', length: 255 }) + originalName!: string; + + @Column({ name: 'stored_name', type: 'varchar', length: 80 }) + storedName!: string; + + @Column({ name: 'mime_type', type: 'varchar', length: 100 }) + mimeType!: string; + + @Column({ name: 'size_bytes', type: 'bigint' }) + sizeBytes!: string; + + @Column({ type: 'char', length: 64 }) + sha256!: string; + + @Column({ type: 'varchar', length: 200, nullable: true }) + title!: string | null; + + @Column({ type: 'text', nullable: true }) + description!: string | null; + + @Column({ name: 'captured_at', type: 'timestamptz', nullable: true }) + capturedAt!: 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: 'varchar', length: 20 }) + source!: AssetMediaSource; + + @Column({ name: 'uploaded_by', type: 'uuid', nullable: true }) + uploadedBy!: string | null; + + @Column({ name: 'created_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' }) + createdAt!: Date; + + @Column({ name: 'updated_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' }) + updatedAt!: Date; + + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt!: Date | null; + + @Column({ name: 'deleted_by', type: 'uuid', nullable: true }) + deletedBy!: string | null; +} diff --git a/api-v3/src/database/entities/asset-source-document.entity.ts b/api-v3/src/database/entities/asset-source-document.entity.ts new file mode 100644 index 0000000..aab18de --- /dev/null +++ b/api-v3/src/database/entities/asset-source-document.entity.ts @@ -0,0 +1,4 @@ +import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; import { TimestampedEntity } from './timestamped.entity'; +export enum AssetSourceDocumentRelationType { SOURCE='SOURCE', MENTIONS='MENTIONS', VALIDATES='VALIDATES', SUPERSEDES='SUPERSEDES', OTHER='OTHER' } +@Entity({name:'asset_source_documents'}) @Index('idx_asset_source_documents_asset_id',['assetId']) @Index('idx_asset_source_documents_document_id',['documentId']) +export class AssetSourceDocument extends TimestampedEntity { @PrimaryGeneratedColumn('uuid') id!:string; @Column({name:'asset_id',type:'uuid'}) assetId!:string; @Column({name:'document_id',type:'uuid'}) documentId!:string; @Column({name:'relation_type',type:'enum',enum:AssetSourceDocumentRelationType,enumName:'asset_source_document_relation_type',default:AssetSourceDocumentRelationType.SOURCE}) relationType!:AssetSourceDocumentRelationType; @Column({type:'text',nullable:true}) notes!:string|null; @Column({name:'created_by',type:'uuid',nullable:true}) createdBy!:string|null; } diff --git a/api-v3/src/database/entities/asset-type-parent-rule.entity.ts b/api-v3/src/database/entities/asset-type-parent-rule.entity.ts new file mode 100644 index 0000000..87e18fd --- /dev/null +++ b/api-v3/src/database/entities/asset-type-parent-rule.entity.ts @@ -0,0 +1,13 @@ +import { Column, Entity, PrimaryColumn } from 'typeorm'; + +@Entity({ name: 'asset_type_parent_rules' }) +export class AssetTypeParentRule { + @PrimaryColumn({ name: 'child_type_id', type: 'uuid' }) + childTypeId!: string; + + @PrimaryColumn({ name: 'parent_type_id', type: 'uuid' }) + parentTypeId!: string; + + @Column({ name: 'created_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' }) + createdAt!: Date; +} diff --git a/api-v3/src/database/entities/asset-type.entity.ts b/api-v3/src/database/entities/asset-type.entity.ts new file mode 100644 index 0000000..ee08daa --- /dev/null +++ b/api-v3/src/database/entities/asset-type.entity.ts @@ -0,0 +1,45 @@ +import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; +import { TimestampedEntity } from './timestamped.entity'; + +export enum AssetTypeOperationalRole { + GENERIC = 'GENERIC', + AREA = 'AREA', + COMPANY = 'COMPANY', +} + +@Entity({ name: 'asset_types' }) +@Index('idx_asset_types_is_active', ['isActive']) +export class AssetType extends TimestampedEntity { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ type: 'varchar', length: 80 }) + code!: string; + + @Column({ type: 'varchar', length: 160 }) + name!: string; + + @Column({ type: 'text', default: '' }) + description!: string; + + @Column({ name: 'can_be_root', type: 'boolean', default: false }) + canBeRoot!: boolean; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive!: boolean; + + @Column({ + name: 'operational_role', + type: 'enum', + enum: AssetTypeOperationalRole, + enumName: 'asset_type_operational_role', + default: AssetTypeOperationalRole.GENERIC, + }) + operationalRole!: AssetTypeOperationalRole; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy!: string | null; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedBy!: string | null; +} diff --git a/api-v3/src/database/entities/asset-version.entity.ts b/api-v3/src/database/entities/asset-version.entity.ts new file mode 100644 index 0000000..6c9e241 --- /dev/null +++ b/api-v3/src/database/entities/asset-version.entity.ts @@ -0,0 +1,60 @@ +import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; + +export enum AssetVersionChangeType { + BASELINE = 'BASELINE', + CREATED = 'CREATED', + UPDATED = 'UPDATED', + CONTEXT_CHANGED = 'CONTEXT_CHANGED', + STATUS_CHANGED = 'STATUS_CHANGED', + OPERATIONAL_STATUS_CHANGED = 'OPERATIONAL_STATUS_CHANGED', + REGISTRY_UPDATED = 'REGISTRY_UPDATED', + GEOMETRY_UPDATED = 'GEOMETRY_UPDATED', + GEOMETRY_REMOVED = 'GEOMETRY_REMOVED', + MEDIA_UPLOADED = 'MEDIA_UPLOADED', + MEDIA_UPDATED = 'MEDIA_UPDATED', + MEDIA_REMOVED = 'MEDIA_REMOVED', + PROVENANCE_BASELINE = 'PROVENANCE_BASELINE', + PROVENANCE_UPDATED = 'PROVENANCE_UPDATED', + PROVENANCE_VERIFIED = 'PROVENANCE_VERIFIED', +} + +@Entity({ name: 'asset_versions' }) +@Index('idx_asset_versions_asset_number', ['assetId', 'versionNumber'], { + unique: true, +}) +@Index('idx_asset_versions_occurred_at', ['occurredAt']) +@Index('idx_asset_versions_change_type', ['changeType']) +export class AssetVersion { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ name: 'asset_id', type: 'uuid' }) + assetId!: string; + + @Column({ name: 'version_number', type: 'integer' }) + versionNumber!: number; + + @Column({ name: 'change_type', type: 'varchar', length: 32 }) + changeType!: AssetVersionChangeType; + + @Column({ name: 'changed_fields', type: 'text', array: true, default: () => "'{}'" }) + changedFields!: string[]; + + @Column({ type: 'jsonb' }) + snapshot!: Record; + + @Column({ name: 'occurred_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' }) + occurredAt!: Date; + + @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({ type: 'varchar', length: 32 }) + source!: string; + + @Column({ name: 'request_id', type: 'varchar', length: 128, nullable: true }) + requestId!: string | null; +} diff --git a/api-v3/src/database/entities/asset.entity.ts b/api-v3/src/database/entities/asset.entity.ts new file mode 100644 index 0000000..b607071 --- /dev/null +++ b/api-v3/src/database/entities/asset.entity.ts @@ -0,0 +1,115 @@ +import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; +import { TimestampedEntity } from './timestamped.entity'; + +export enum AssetInformationStatus { + DRAFT = 'DRAFT', + PENDING_SURVEY = 'PENDING_SURVEY', + SURVEYED = 'SURVEYED', + VALIDATED = 'VALIDATED', + OBSERVED = 'OBSERVED', + OUTDATED = 'OUTDATED', + INACTIVE = 'INACTIVE', +} + +export enum AssetOperationalStatus { + UNKNOWN = 'UNKNOWN', + IN_SERVICE = 'IN_SERVICE', + TEMPORARILY_OUT_OF_SERVICE = 'TEMPORARILY_OUT_OF_SERVICE', + OUT_OF_SERVICE = 'OUT_OF_SERVICE', + DECOMMISSIONED = 'DECOMMISSIONED', + ABANDONED = 'ABANDONED', +} + +export enum AssetDataOrigin { + MANUAL = 'MANUAL', + FIELD_SURVEY = 'FIELD_SURVEY', + PROVIDED_DOCUMENT = 'PROVIDED_DOCUMENT', + IMPORT = 'IMPORT', + SYSTEM = 'SYSTEM', +} + +@Entity({ name: 'assets' }) +@Index('idx_assets_type_id', ['assetTypeId']) +@Index('idx_assets_parent_id', ['parentId']) +@Index('idx_assets_operational_area_id', ['operationalAreaId']) +@Index('idx_assets_operator_company_id', ['operatorCompanyId']) +@Index('idx_assets_information_status', ['informationStatus']) +@Index('idx_assets_operational_status', ['operationalStatus']) +@Index('idx_assets_data_origin', ['dataOrigin']) +@Index('idx_assets_provenance_verified_at', ['provenanceVerifiedAt']) +export class Asset extends TimestampedEntity { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ name: 'asset_type_id', type: 'uuid' }) + assetTypeId!: string; + + @Column({ name: 'parent_id', type: 'uuid', nullable: true }) + parentId!: string | null; + + @Column({ name: 'operational_area_id', type: 'uuid', nullable: true }) + operationalAreaId!: string | null; + + @Column({ name: 'operator_company_id', type: 'uuid', nullable: true }) + operatorCompanyId!: string | null; + + @Column({ type: 'varchar', length: 120 }) + code!: string; + + @Column({ type: 'varchar', length: 200 }) + name!: string; + + @Column({ name: 'common_name', type: 'varchar', length: 200, nullable: true }) + commonName!: string | null; + + @Column({ type: 'text', nullable: true }) + description!: string | null; + + @Column({ + name: 'information_status', + type: 'enum', + enum: AssetInformationStatus, + enumName: 'asset_information_status', + default: AssetInformationStatus.DRAFT, + }) + informationStatus!: AssetInformationStatus; + + @Column({ name: 'operational_status', type: 'enum', enum: AssetOperationalStatus, enumName: 'asset_operational_status', default: AssetOperationalStatus.UNKNOWN }) + operationalStatus!: AssetOperationalStatus; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy!: string | null; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedBy!: string | null; + + @Column({ name: 'current_version', type: 'integer', default: 0 }) + currentVersion!: number; + + @Column({ name: 'data_origin', type: 'varchar', length: 32, default: AssetDataOrigin.MANUAL }) + dataOrigin!: AssetDataOrigin; + + @Column({ name: 'source_name', type: 'varchar', length: 160, nullable: true }) + sourceName!: string | null; + + @Column({ name: 'source_reference', type: 'varchar', length: 255, nullable: true }) + sourceReference!: string | null; + + @Column({ name: 'source_observed_at', type: 'timestamptz', nullable: true }) + sourceObservedAt!: Date | null; + + @Column({ name: 'source_notes', type: 'text', nullable: true }) + sourceNotes!: string | null; + + @Column({ name: 'provenance_verified_at', type: 'timestamptz', nullable: true }) + provenanceVerifiedAt!: Date | null; + + @Column({ name: 'provenance_verified_by', type: 'uuid', nullable: true }) + provenanceVerifiedBy!: string | null; + + @Column({ name: 'provenance_updated_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' }) + provenanceUpdatedAt!: Date; + + @Column({ name: 'provenance_updated_by', type: 'uuid', nullable: true }) + provenanceUpdatedBy!: string | null; +} diff --git a/api-v3/src/database/entities/audit-event.entity.ts b/api-v3/src/database/entities/audit-event.entity.ts new file mode 100644 index 0000000..f77d9b2 --- /dev/null +++ b/api-v3/src/database/entities/audit-event.entity.ts @@ -0,0 +1,163 @@ +import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; + +export enum AuditSource { + WEB = 'WEB', + ANDROID = 'ANDROID', + SYSTEM = 'SYSTEM', + IMPORT = 'IMPORT', +} + +export enum AuditAction { + AUTH_LOGIN_SUCCESS = 'AUTH_LOGIN_SUCCESS', + AUTH_LOGIN_FAILED = 'AUTH_LOGIN_FAILED', + AUTH_LOGOUT = 'AUTH_LOGOUT', + AUTH_REFRESH = 'AUTH_REFRESH', + AUTH_REFRESH_REUSE_DETECTED = 'AUTH_REFRESH_REUSE_DETECTED', + AUTH_PASSWORD_CHANGED = 'AUTH_PASSWORD_CHANGED', + SYSTEM_BOOTSTRAP_ADMIN_CREATED = 'SYSTEM_BOOTSTRAP_ADMIN_CREATED', + USER_CREATED = 'USER_CREATED', + USER_UPDATED = 'USER_UPDATED', + USER_PASSWORD_RESET = 'USER_PASSWORD_RESET', + USER_STATUS_CHANGED = 'USER_STATUS_CHANGED', + USER_ROLES_CHANGED = 'USER_ROLES_CHANGED', + ROLE_CREATED = 'ROLE_CREATED', + ROLE_UPDATED = 'ROLE_UPDATED', + ROLE_PERMISSIONS_CHANGED = 'ROLE_PERMISSIONS_CHANGED', + ASSET_TYPE_CREATED = 'ASSET_TYPE_CREATED', + ASSET_TYPE_UPDATED = 'ASSET_TYPE_UPDATED', + ASSET_MASTER_BOOTSTRAPPED = 'ASSET_MASTER_BOOTSTRAPPED', + ASSET_ATTRIBUTE_CREATED = 'ASSET_ATTRIBUTE_CREATED', + ASSET_ATTRIBUTE_UPDATED = 'ASSET_ATTRIBUTE_UPDATED', + ASSET_CREATED = 'ASSET_CREATED', + ASSET_FIELD_DISCOVERY_CREATED = 'ASSET_FIELD_DISCOVERY_CREATED', + ASSET_FIELD_DISCOVERY_APPROVED = 'ASSET_FIELD_DISCOVERY_APPROVED', + ASSET_FIELD_DISCOVERY_MATCHED = 'ASSET_FIELD_DISCOVERY_MATCHED', + ASSET_FIELD_DISCOVERY_REJECTED = 'ASSET_FIELD_DISCOVERY_REJECTED', + ASSET_UPDATED = 'ASSET_UPDATED', + ASSET_CONTEXT_CHANGED = 'ASSET_CONTEXT_CHANGED', + ASSET_INFORMATION_STATUS_CHANGED = 'ASSET_INFORMATION_STATUS_CHANGED', + ASSET_OPERATIONAL_STATUS_CHANGED = 'ASSET_OPERATIONAL_STATUS_CHANGED', + ASSET_REGISTRY_UPDATED = 'ASSET_REGISTRY_UPDATED', + ASSET_IMPORT_BATCH_ANALYZED = 'ASSET_IMPORT_BATCH_ANALYZED', + ASSET_IMPORT_BATCH_RECONCILED = 'ASSET_IMPORT_BATCH_RECONCILED', + ASSET_IMPORT_PLAN_GENERATED = 'ASSET_IMPORT_PLAN_GENERATED', + ASSET_IMPORT_PLAN_ITEM_RESOLVED = 'ASSET_IMPORT_PLAN_ITEM_RESOLVED', + ASSET_IMPORT_PLAN_APPLIED = 'ASSET_IMPORT_PLAN_APPLIED', + ASSET_IMPORT_PLAN_ROLLED_BACK = 'ASSET_IMPORT_PLAN_ROLLED_BACK', + ASSET_IMPORT_BATCH_CANCELLED = 'ASSET_IMPORT_BATCH_CANCELLED', + SOURCE_DOCUMENT_CREATED = 'SOURCE_DOCUMENT_CREATED', + AREA_LEGAL_RIGHT_CREATED = 'AREA_LEGAL_RIGHT_CREATED', + AREA_LEGAL_RIGHT_UPDATED = 'AREA_LEGAL_RIGHT_UPDATED', + AREA_LEGAL_RIGHT_ORGANIZATION_ENDED = 'AREA_LEGAL_RIGHT_ORGANIZATION_ENDED', + ASSET_GEOMETRY_UPDATED = 'ASSET_GEOMETRY_UPDATED', + ASSET_GEOMETRY_REMOVED = 'ASSET_GEOMETRY_REMOVED', + ASSET_MEDIA_UPLOADED = 'ASSET_MEDIA_UPLOADED', + ASSET_MEDIA_UPDATED = 'ASSET_MEDIA_UPDATED', + ASSET_MEDIA_REMOVED = 'ASSET_MEDIA_REMOVED', + ASSET_PROVENANCE_UPDATED = 'ASSET_PROVENANCE_UPDATED', + ASSET_PROVENANCE_VERIFIED = 'ASSET_PROVENANCE_VERIFIED', + ASSET_AREA_COMPANY_RELATION_CREATED = 'ASSET_AREA_COMPANY_RELATION_CREATED', + ASSET_AREA_COMPANY_RELATION_ENDED = 'ASSET_AREA_COMPANY_RELATION_ENDED', + SURVEY_CAMPAIGN_CREATED = 'SURVEY_CAMPAIGN_CREATED', + SURVEY_CAMPAIGN_UPDATED = 'SURVEY_CAMPAIGN_UPDATED', + SURVEY_CAMPAIGN_STATUS_CHANGED = 'SURVEY_CAMPAIGN_STATUS_CHANGED', + SURVEY_TARGET_ADDED = 'SURVEY_TARGET_ADDED', + SURVEY_TARGET_UPDATED = 'SURVEY_TARGET_UPDATED', + SURVEY_TARGET_ASSIGNED = 'SURVEY_TARGET_ASSIGNED', + SURVEY_TARGET_STATUS_CHANGED = 'SURVEY_TARGET_STATUS_CHANGED', + SURVEY_REPORT_SAVED = 'SURVEY_REPORT_SAVED', + SURVEY_REPORT_SUBMITTED = 'SURVEY_REPORT_SUBMITTED', + SURVEY_REPORT_APPROVED = 'SURVEY_REPORT_APPROVED', + SURVEY_REPORT_REJECTED = 'SURVEY_REPORT_REJECTED', + INSPECTION_VISIT_CREATED = 'INSPECTION_VISIT_CREATED', + INSPECTION_VISIT_UPDATED = 'INSPECTION_VISIT_UPDATED', + INSPECTION_VISIT_STATUS_CHANGED = 'INSPECTION_VISIT_STATUS_CHANGED', + INSPECTION_VISIT_ASSETS_UPDATED = 'INSPECTION_VISIT_ASSETS_UPDATED', + INSPECTION_VISIT_TEAM_UPDATED = 'INSPECTION_VISIT_TEAM_UPDATED', + INSPECTION_VISIT_CHECKLIST_GENERATED = 'INSPECTION_VISIT_CHECKLIST_GENERATED', + INSPECTION_VISIT_ASSET_EXCLUDED = 'INSPECTION_VISIT_ASSET_EXCLUDED', + INSPECTION_VISIT_ASSET_REINCLUDED = 'INSPECTION_VISIT_ASSET_REINCLUDED', + INSPECTION_VISIT_STARTED = 'INSPECTION_VISIT_STARTED', + INSPECTION_ACT_CREATED = 'INSPECTION_ACT_CREATED', + INSPECTION_ACT_UPDATED = 'INSPECTION_ACT_UPDATED', + INSPECTION_ACT_CANCELLED = 'INSPECTION_ACT_CANCELLED', + INSPECTION_ACT_RESPONSIBLE_UPDATED = 'INSPECTION_ACT_RESPONSIBLE_UPDATED', + INSPECTION_ACT_READY = 'INSPECTION_ACT_READY', + INSPECTION_ACT_REOPENED = 'INSPECTION_ACT_REOPENED', + INSPECTION_ACT_SIGNATURE_RECORDED = 'INSPECTION_ACT_SIGNATURE_RECORDED', + INSPECTION_ACT_COMPANY_OUTCOME_RECORDED = 'INSPECTION_ACT_COMPANY_OUTCOME_RECORDED', + INSPECTION_ACT_CLOSED = 'INSPECTION_ACT_CLOSED', + INSPECTION_REPORT_GENERATED = 'INSPECTION_REPORT_GENERATED', + INSPECTION_REPORT_REVISION_ADDED = 'INSPECTION_REPORT_REVISION_ADDED', + INSPECTION_REPORT_APPROVED = 'INSPECTION_REPORT_APPROVED', + INSPECTION_REPORT_SIGNED = 'INSPECTION_REPORT_SIGNED', + DOCUMENT_DELIVERY_SETTINGS_UPDATED = 'DOCUMENT_DELIVERY_SETTINGS_UPDATED', + DOCUMENT_DELIVERY_RETRY_REQUESTED = 'DOCUMENT_DELIVERY_RETRY_REQUESTED', + DOCUMENT_DELIVERY_SENT = 'DOCUMENT_DELIVERY_SENT', + INSPECTION_VERIFICATION_PLANNED = 'INSPECTION_VERIFICATION_PLANNED', + INSPECTION_VERIFICATION_RESULT_RECORDED = 'INSPECTION_VERIFICATION_RESULT_RECORDED', + INSPECTION_FINDING_CREATED = 'INSPECTION_FINDING_CREATED', + INSPECTION_FINDING_UPDATED = 'INSPECTION_FINDING_UPDATED', + INSPECTION_FINDING_FOLLOW_UP_UPDATED = 'INSPECTION_FINDING_FOLLOW_UP_UPDATED', + INSPECTION_FINDING_CLOSED = 'INSPECTION_FINDING_CLOSED', + INSPECTION_FINDING_EVIDENCE_UPLOADED = 'INSPECTION_FINDING_EVIDENCE_UPLOADED', + INSPECTION_FINDING_COMMUNICATION_CREATED = 'INSPECTION_FINDING_COMMUNICATION_CREATED', + FINDING_CATEGORY_CREATED = 'FINDING_CATEGORY_CREATED', + FINDING_CATEGORY_UPDATED = 'FINDING_CATEGORY_UPDATED', + FINDING_CATALOG_ITEM_CREATED = 'FINDING_CATALOG_ITEM_CREATED', + FINDING_CATALOG_ITEM_UPDATED = 'FINDING_CATALOG_ITEM_UPDATED', + FINDING_CATALOG_TYPE_APPLICABILITY_UPDATED = 'FINDING_CATALOG_TYPE_APPLICABILITY_UPDATED', + FINDING_CATALOG_ASSET_SELECTION_UPDATED = 'FINDING_CATALOG_ASSET_SELECTION_UPDATED', + FINDING_CATALOG_PROPOSAL_MATCHED = 'FINDING_CATALOG_PROPOSAL_MATCHED', + FINDING_CATALOG_PROPOSAL_REJECTED = 'FINDING_CATALOG_PROPOSAL_REJECTED', +} + +@Entity({ name: 'audit_events' }) +@Index('idx_audit_events_occurred_at', ['occurredAt']) +@Index('idx_audit_events_actor_user_id', ['actorUserId']) +@Index('idx_audit_events_action', ['action']) +@Index('idx_audit_events_entity', ['entityType', 'entityId']) +@Index('idx_audit_events_request_id', ['requestId']) +export class AuditEvent { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ name: 'occurred_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' }) + occurredAt!: Date; + + @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({ type: 'varchar', length: 100 }) + action!: string; + + @Column({ name: 'entity_type', type: 'varchar', length: 100, nullable: true }) + entityType!: string | null; + + @Column({ name: 'entity_id', type: 'varchar', length: 255, nullable: true }) + entityId!: string | null; + + @Column({ name: 'request_id', type: 'varchar', length: 128, nullable: true }) + requestId!: string | null; + + @Column({ type: 'varchar', length: 32 }) + source!: AuditSource; + + @Column({ type: 'inet', nullable: true }) + ip!: string | null; + + @Column({ name: 'user_agent', type: 'text', nullable: true }) + userAgent!: string | null; + + @Column({ name: 'before_data', type: 'jsonb', nullable: true }) + beforeData!: Record | null; + + @Column({ name: 'after_data', type: 'jsonb', nullable: true }) + afterData!: Record | null; + + @Column({ type: 'jsonb', nullable: true }) + metadata!: Record | null; +} diff --git a/api-v3/src/database/entities/auth-session.entity.ts b/api-v3/src/database/entities/auth-session.entity.ts new file mode 100644 index 0000000..abc4458 --- /dev/null +++ b/api-v3/src/database/entities/auth-session.entity.ts @@ -0,0 +1,54 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { User } from './user.entity'; + +@Entity({ name: 'auth_sessions' }) +@Index('idx_auth_sessions_user_id', ['userId']) +@Index('idx_auth_sessions_expires_at', ['expiresAt']) +@Index('idx_auth_sessions_revoked_at', ['revokedAt']) +@Index('idx_auth_sessions_replaced_by_session_id', ['replacedBySessionId']) +export class AuthSession { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ name: 'user_id', type: 'uuid' }) + userId!: string; + + @Column({ name: 'refresh_token_hash', type: 'text', select: false }) + refreshTokenHash!: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt!: Date; + + @Column({ name: 'expires_at', type: 'timestamptz' }) + expiresAt!: Date; + + @Column({ name: 'last_used_at', type: 'timestamptz' }) + lastUsedAt!: Date; + + @Column({ name: 'revoked_at', type: 'timestamptz', nullable: true }) + revokedAt!: Date | null; + + @Column({ name: 'replaced_by_session_id', type: 'uuid', nullable: true }) + replacedBySessionId!: string | null; + + @Column({ type: 'inet', nullable: true }) + ip!: string | null; + + @Column({ name: 'user_agent', type: 'text', nullable: true }) + userAgent!: string | null; + + @Column({ name: 'device_label', type: 'text', nullable: true }) + deviceLabel!: string | null; + + @ManyToOne(() => User, { onDelete: 'RESTRICT' }) + @JoinColumn({ name: 'user_id' }) + user!: User; +} diff --git a/api-v3/src/database/entities/document-annual-sequence.entity.ts b/api-v3/src/database/entities/document-annual-sequence.entity.ts new file mode 100644 index 0000000..f87b7cb --- /dev/null +++ b/api-v3/src/database/entities/document-annual-sequence.entity.ts @@ -0,0 +1,21 @@ +import { Column, Entity, PrimaryColumn, UpdateDateColumn } from 'typeorm'; + +export enum DocumentSequenceType { + ACT = 'ACT', + REPORT = 'REPORT', +} + +@Entity({ name: 'document_annual_sequences' }) +export class DocumentAnnualSequence { + @PrimaryColumn({ name: 'document_type', type: 'varchar', length: 20 }) + documentType!: DocumentSequenceType; + + @PrimaryColumn({ type: 'integer' }) + year!: number; + + @Column({ name: 'last_number', type: 'integer', default: 0 }) + lastNumber!: number; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt!: Date; +} diff --git a/api-v3/src/database/entities/finding-catalog-asset-override.entity.ts b/api-v3/src/database/entities/finding-catalog-asset-override.entity.ts new file mode 100644 index 0000000..b5a9b57 --- /dev/null +++ b/api-v3/src/database/entities/finding-catalog-asset-override.entity.ts @@ -0,0 +1,28 @@ +import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; +import { TimestampedEntity } from './timestamped.entity'; + +@Entity({ name: 'finding_catalog_asset_overrides' }) +@Index('uq_finding_catalog_asset_overrides_pair', ['assetId', 'catalogItemId'], { unique: true }) +@Index('idx_finding_catalog_asset_overrides_asset', ['assetId']) +export class FindingCatalogAssetOverride extends TimestampedEntity { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ name: 'asset_id', type: 'uuid' }) + assetId!: string; + + @Column({ name: 'catalog_item_id', type: 'uuid' }) + catalogItemId!: string; + + @Column({ name: 'is_enabled', type: 'boolean' }) + isEnabled!: boolean; + + @Column({ type: 'text' }) + reason!: string; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy!: string | null; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedBy!: string | null; +} diff --git a/api-v3/src/database/entities/finding-catalog-asset-type-profile.entity.ts b/api-v3/src/database/entities/finding-catalog-asset-type-profile.entity.ts new file mode 100644 index 0000000..3cff807 --- /dev/null +++ b/api-v3/src/database/entities/finding-catalog-asset-type-profile.entity.ts @@ -0,0 +1,15 @@ +import { Column, Entity, Index, PrimaryColumn } from 'typeorm'; +import { TimestampedEntity } from './timestamped.entity'; + +@Entity({ name: 'finding_catalog_asset_type_profiles' }) +@Index('idx_finding_catalog_asset_type_profiles_updated', ['updatedAt']) +export class FindingCatalogAssetTypeProfile extends TimestampedEntity { + @PrimaryColumn({ name: 'asset_type_id', type: 'uuid' }) + assetTypeId!: string; + + @Column({ type: 'text' }) + reason!: string; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedBy!: string | null; +} diff --git a/api-v3/src/database/entities/finding-catalog-item-asset-type.entity.ts b/api-v3/src/database/entities/finding-catalog-item-asset-type.entity.ts new file mode 100644 index 0000000..6f3f7d5 --- /dev/null +++ b/api-v3/src/database/entities/finding-catalog-item-asset-type.entity.ts @@ -0,0 +1,19 @@ +import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; +import { TimestampedEntity } from './timestamped.entity'; + +@Entity({ name: 'finding_catalog_item_asset_types' }) +@Index('uq_finding_catalog_item_asset_types_pair', ['catalogItemId', 'assetTypeId'], { unique: true }) +@Index('idx_finding_catalog_item_asset_types_type', ['assetTypeId', 'catalogItemId']) +export class FindingCatalogItemAssetType extends TimestampedEntity { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ name: 'catalog_item_id', type: 'uuid' }) + catalogItemId!: string; + + @Column({ name: 'asset_type_id', type: 'uuid' }) + assetTypeId!: string; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy!: string | null; +} diff --git a/api-v3/src/database/entities/finding-catalog-item.entity.ts b/api-v3/src/database/entities/finding-catalog-item.entity.ts new file mode 100644 index 0000000..6cfd20a --- /dev/null +++ b/api-v3/src/database/entities/finding-catalog-item.entity.ts @@ -0,0 +1,41 @@ +import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; +import { TimestampedEntity } from './timestamped.entity'; + +@Entity({ name: 'finding_catalog_items' }) +@Index('uq_finding_catalog_items_code', ['code'], { unique: true }) +@Index('uq_finding_catalog_items_source', ['categoryId', 'sourceNumber'], { unique: true }) +@Index('idx_finding_catalog_items_active', ['categoryId', 'isActive']) +export class FindingCatalogItem extends TimestampedEntity { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ name: 'category_id', type: 'uuid' }) + categoryId!: string; + + @Column({ type: 'varchar', length: 120 }) + code!: string; + + @Column({ name: 'source_number', type: 'integer' }) + sourceNumber!: number; + + @Column({ type: 'varchar', length: 500 }) + title!: string; + + @Column({ name: 'legal_basis', type: 'text', nullable: true }) + legalBasis!: string | null; + + @Column({ type: 'text', nullable: true }) + glossary!: string | null; + + @Column({ name: 'import_note', type: 'text', nullable: true }) + importNote!: string | null; + + @Column({ name: 'suggested_severity', type: 'smallint', nullable: true }) + suggestedSeverity!: number | null; + + @Column({ type: 'integer', default: 1 }) + revision!: number; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive!: boolean; +} diff --git a/api-v3/src/database/entities/finding-catalog-proposal.entity.ts b/api-v3/src/database/entities/finding-catalog-proposal.entity.ts new file mode 100644 index 0000000..dbf7e44 --- /dev/null +++ b/api-v3/src/database/entities/finding-catalog-proposal.entity.ts @@ -0,0 +1,56 @@ +import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; +import { TimestampedEntity } from './timestamped.entity'; + +export enum FindingCatalogProposalStatus { + PENDING = 'PENDING', + MATCHED = 'MATCHED', + REJECTED = 'REJECTED', +} + +@Entity({ name: 'finding_catalog_proposals' }) +@Index('uq_finding_catalog_proposals_finding', ['findingId'], { unique: true }) +@Index('idx_finding_catalog_proposals_status', ['status', 'createdAt']) +@Index('idx_finding_catalog_proposals_asset_type', ['assetTypeId', 'status']) +export class FindingCatalogProposal extends TimestampedEntity { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ name: 'finding_id', type: 'uuid' }) + findingId!: string; + + @Column({ name: 'asset_id', type: 'uuid' }) + assetId!: string; + + @Column({ name: 'asset_type_id', type: 'uuid' }) + assetTypeId!: string; + + @Column({ name: 'proposed_title', type: 'varchar', length: 500 }) + proposedTitle!: string; + + @Column({ name: 'proposed_legal_basis', type: 'text', nullable: true }) + proposedLegalBasis!: string | null; + + @Column({ name: 'proposed_severity', type: 'smallint', nullable: true }) + proposedSeverity!: number | null; + + @Column({ type: 'text' }) + description!: string; + + @Column({ type: 'varchar', length: 20, default: FindingCatalogProposalStatus.PENDING }) + status!: FindingCatalogProposalStatus; + + @Column({ name: 'resolved_catalog_item_id', type: 'uuid', nullable: true }) + resolvedCatalogItemId!: string | null; + + @Column({ name: 'office_notes', type: 'text', nullable: true }) + officeNotes!: string | null; + + @Column({ name: 'reviewed_by', type: 'uuid', nullable: true }) + reviewedBy!: string | null; + + @Column({ name: 'reviewed_at', type: 'timestamptz', nullable: true }) + reviewedAt!: Date | null; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy!: string | null; +} diff --git a/api-v3/src/database/entities/finding-category.entity.ts b/api-v3/src/database/entities/finding-category.entity.ts new file mode 100644 index 0000000..ecedbbd --- /dev/null +++ b/api-v3/src/database/entities/finding-category.entity.ts @@ -0,0 +1,22 @@ +import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; +import { TimestampedEntity } from './timestamped.entity'; + +@Entity({ name: 'finding_categories' }) +@Index('uq_finding_categories_code', ['code'], { unique: true }) +@Index('idx_finding_categories_active_order', ['isActive', 'sortOrder']) +export class FindingCategory extends TimestampedEntity { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ type: 'varchar', length: 80 }) + code!: string; + + @Column({ type: 'varchar', length: 200 }) + name!: string; + + @Column({ name: 'sort_order', type: 'integer', default: 0 }) + sortOrder!: number; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive!: boolean; +} diff --git a/api-v3/src/database/entities/index.ts b/api-v3/src/database/entities/index.ts new file mode 100644 index 0000000..1702629 --- /dev/null +++ b/api-v3/src/database/entities/index.ts @@ -0,0 +1,237 @@ +export { AuditAction, AuditEvent, AuditSource } from './audit-event.entity'; +export { AuthSession } from './auth-session.entity'; +export { Permission } from './permission.entity'; +export { RolePermission } from './role-permission.entity'; +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 { AssetAttributeValue } from './asset-attribute-value.entity'; +export { AreaCompanyRelation, AreaOrganizationRole } from './area-company-relation.entity'; +export { OrganizationProfile, OrganizationKind } from './organization-profile.entity'; +export { OrganizationMembership, OrganizationMembershipRole } from './organization-membership.entity'; +export { SourceDocument, SourceDocumentType } from './source-document.entity'; +export { AssetSourceDocument, AssetSourceDocumentRelationType } from './asset-source-document.entity'; +export { AssetExternalIdentifier } from './asset-external-identifier.entity'; +export { AreaLegalRight, AreaLegalRightType, AreaLegalRightStatus } from './area-legal-right.entity'; +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 { 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 { 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, +} 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'; + +import { AuditEvent } from './audit-event.entity'; +import { AuthSession } from './auth-session.entity'; +import { Permission } from './permission.entity'; +import { RolePermission } from './role-permission.entity'; +import { Role } from './role.entity'; +import { UserRole } from './user-role.entity'; +import { User } from './user.entity'; +import { Asset } from './asset.entity'; +import { AssetAttributeDefinition } from './asset-attribute-definition.entity'; +import { AssetAttributeValue } from './asset-attribute-value.entity'; +import { AssetTypeParentRule } from './asset-type-parent-rule.entity'; +import { AssetType } from './asset-type.entity'; +import { AreaCompanyRelation } from './area-company-relation.entity'; +import { OrganizationProfile } from './organization-profile.entity'; +import { OrganizationMembership } from './organization-membership.entity'; +import { SourceDocument } from './source-document.entity'; +import { AssetSourceDocument } from './asset-source-document.entity'; +import { AssetExternalIdentifier } from './asset-external-identifier.entity'; +import { AreaLegalRight } from './area-legal-right.entity'; +import { AreaLegalRightOrganization } from './area-legal-right-organization.entity'; +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 { InspectionReport } from './inspection-report.entity'; +import { InspectionActVersion } from './inspection-act-version.entity'; +import { InspectionActResponsible } from './inspection-act-responsible.entity'; +import { InspectionActClosure } from './inspection-act-closure.entity'; +import { InspectionActSignature } from './inspection-act-signature.entity'; +import { FindingCategory } from './finding-category.entity'; +import { FindingCatalogItem } from './finding-catalog-item.entity'; +import { FindingCatalogItemAssetType } from './finding-catalog-item-asset-type.entity'; +import { FindingCatalogAssetTypeProfile } from './finding-catalog-asset-type-profile.entity'; +import { FindingCatalogAssetOverride } from './finding-catalog-asset-override.entity'; +import { FindingCatalogProposal } from './finding-catalog-proposal.entity'; +import { InspectionFinding } from './inspection-finding.entity'; +import { InspectionFindingVersion } from './inspection-finding-version.entity'; +import { InspectionFindingCommunication } from './inspection-finding-communication.entity'; +import { InspectionFindingEvidence } from './inspection-finding-evidence.entity'; +import { InspectionFindingVerificationVisit } from './inspection-finding-verification-visit.entity'; +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, +]; diff --git a/api-v3/src/database/entities/inspection-act-asset.entity.ts b/api-v3/src/database/entities/inspection-act-asset.entity.ts new file mode 100644 index 0000000..9797d75 --- /dev/null +++ b/api-v3/src/database/entities/inspection-act-asset.entity.ts @@ -0,0 +1,19 @@ +import { Column, Entity, Index, PrimaryColumn } from 'typeorm'; +import { TimestampedEntity } from './timestamped.entity'; + +@Entity({ name: 'inspection_act_assets' }) +@Index('idx_inspection_act_assets_asset_id', ['assetId']) +@Index('idx_inspection_act_assets_included', ['actId', 'included']) +export class InspectionActAsset extends TimestampedEntity { + @PrimaryColumn({ name: 'act_id', type: 'uuid' }) + actId!: string; + + @PrimaryColumn({ name: 'asset_id', type: 'uuid' }) + assetId!: string; + + @Column({ type: 'boolean', default: true }) + included!: boolean; + + @Column({ name: 'added_by', type: 'uuid', nullable: true }) + addedBy!: string | null; +} diff --git a/api-v3/src/database/entities/inspection-act-closure.entity.ts b/api-v3/src/database/entities/inspection-act-closure.entity.ts new file mode 100644 index 0000000..843b798 --- /dev/null +++ b/api-v3/src/database/entities/inspection-act-closure.entity.ts @@ -0,0 +1,51 @@ +import { Column, Entity, PrimaryColumn } from 'typeorm'; + +export enum InspectionActUploadMode { + IMMEDIATE = 'IMMEDIATE', + DEFERRED = 'DEFERRED', +} + +@Entity({ name: 'inspection_act_closures' }) +export class InspectionActClosure { + @PrimaryColumn({ name: 'act_id', type: 'uuid' }) + actId!: string; + + @Column({ name: 'schema_version', type: 'varchar', length: 40 }) + schemaVersion!: string; + + @Column({ name: 'prepared_snapshot', type: 'jsonb' }) + preparedSnapshot!: Record; + + @Column({ name: 'prepared_sha256', type: 'char', length: 64 }) + preparedSha256!: string; + + @Column({ name: 'prepared_at', type: 'timestamptz' }) + preparedAt!: Date; + + @Column({ name: 'prepared_by', type: 'uuid' }) + preparedBy!: string; + + @Column({ name: 'final_snapshot', type: 'jsonb', nullable: true }) + finalSnapshot!: Record | null; + + @Column({ name: 'final_sha256', type: 'char', length: 64, nullable: true }) + finalSha256!: string | null; + + @Column({ name: 'device_closed_at', type: 'timestamptz', nullable: true }) + deviceClosedAt!: Date | null; + + @Column({ name: 'server_closed_at', type: 'timestamptz', nullable: true }) + serverClosedAt!: Date | null; + + @Column({ name: 'upload_mode', type: 'varchar', length: 20, nullable: true }) + uploadMode!: InspectionActUploadMode | null; + + @Column({ name: 'closed_by', type: 'uuid', nullable: true }) + closedBy!: string | null; + + @Column({ name: 'created_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' }) + createdAt!: Date; + + @Column({ name: 'updated_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' }) + updatedAt!: Date; +} diff --git a/api-v3/src/database/entities/inspection-act-responsible.entity.ts b/api-v3/src/database/entities/inspection-act-responsible.entity.ts new file mode 100644 index 0000000..db09fb6 --- /dev/null +++ b/api-v3/src/database/entities/inspection-act-responsible.entity.ts @@ -0,0 +1,52 @@ +import { Column, Entity, PrimaryColumn } from 'typeorm'; + +export enum InspectionResponsibleAttendanceStatus { + PRESENT = 'PRESENT', + ABSENT = 'ABSENT', +} + +export enum InspectionResponsibleDocumentType { + DNI = 'DNI', + CUIL = 'CUIL', + PASSPORT = 'PASSPORT', + OTHER = 'OTHER', +} + +@Entity({ name: 'inspection_act_responsibles' }) +export class InspectionActResponsible { + @PrimaryColumn({ name: 'act_id', type: 'uuid' }) + actId!: string; + + @Column({ name: 'attendance_status', type: 'varchar', length: 20 }) + attendanceStatus!: InspectionResponsibleAttendanceStatus; + + @Column({ name: 'full_name', type: 'varchar', length: 200, nullable: true }) + fullName!: string | null; + + @Column({ name: 'document_type', type: 'varchar', length: 20, nullable: true }) + documentType!: InspectionResponsibleDocumentType | null; + + @Column({ name: 'document_number', type: 'varchar', length: 40, nullable: true }) + documentNumber!: string | null; + + @Column({ type: 'varchar', length: 200, nullable: true }) + position!: string | null; + + @Column({ type: 'varchar', length: 320, nullable: true }) + email!: string | null; + + @Column({ type: 'varchar', length: 50, nullable: true }) + phone!: string | null; + + @Column({ name: 'absence_reason', type: 'text', nullable: true }) + absenceReason!: string | null; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedBy!: string | null; + + @Column({ name: 'created_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' }) + createdAt!: Date; + + @Column({ name: 'updated_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' }) + updatedAt!: Date; +} diff --git a/api-v3/src/database/entities/inspection-act-signature.entity.ts b/api-v3/src/database/entities/inspection-act-signature.entity.ts new file mode 100644 index 0000000..f78fd90 --- /dev/null +++ b/api-v3/src/database/entities/inspection-act-signature.entity.ts @@ -0,0 +1,110 @@ +import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; +import { InspectionResponsibleDocumentType } from './inspection-act-responsible.entity'; + +export enum InspectionActSignerType { + INSPECTOR = 'INSPECTOR', + COMPANY_RESPONSIBLE = 'COMPANY_RESPONSIBLE', +} + +export enum InspectionActSignatureStatus { + SIGNED = 'SIGNED', + REFUSED = 'REFUSED', + ABSENT = 'ABSENT', +} + +export enum InspectionActSignatureSource { + WEB = 'WEB', + ANDROID = 'ANDROID', +} + +@Entity({ name: 'inspection_act_signatures' }) +@Index('idx_inspection_act_signatures_act_created', ['actId', 'createdAt']) +@Index('idx_inspection_act_signatures_sha256', ['signaturePayloadSha256']) +export class InspectionActSignature { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ name: 'act_id', type: 'uuid' }) + actId!: string; + + @Column({ name: 'signer_type', type: 'varchar', length: 32 }) + signerType!: InspectionActSignerType; + + @Column({ name: 'signer_user_id', type: 'uuid', nullable: true }) + signerUserId!: string | null; + + @Column({ name: 'signer_name', type: 'varchar', length: 200 }) + signerName!: string; + + @Column({ name: 'document_type', type: 'varchar', length: 20, nullable: true }) + documentType!: InspectionResponsibleDocumentType | null; + + @Column({ name: 'document_number', type: 'varchar', length: 40, nullable: true }) + documentNumber!: string | null; + + @Column({ type: 'varchar', length: 200, nullable: true }) + position!: string | null; + + @Column({ type: 'varchar', length: 20 }) + status!: InspectionActSignatureStatus; + + @Column({ type: 'text', nullable: true }) + reason!: string | null; + + @Column({ name: 'original_name', type: 'varchar', length: 255, nullable: true }) + originalName!: string | null; + + @Column({ name: 'stored_name', type: 'varchar', length: 80, nullable: true }) + storedName!: string | null; + + @Column({ name: 'mime_type', type: 'varchar', length: 100, nullable: true }) + mimeType!: string | null; + + @Column({ name: 'size_bytes', type: 'bigint', nullable: true }) + sizeBytes!: number | null; + + @Column({ name: 'image_sha256', type: 'char', length: 64, nullable: true }) + imageSha256!: string | null; + + @Column({ name: 'consent_text', type: 'text', nullable: true }) + consentText!: string | null; + + @Column({ name: 'consent_version', type: 'varchar', length: 20, nullable: true }) + consentVersion!: string | null; + + @Column({ name: 'consent_accepted_at', type: 'timestamptz', nullable: true }) + consentAcceptedAt!: Date | null; + + @Column({ name: 'client_signed_at', type: 'timestamptz', nullable: true }) + clientSignedAt!: Date | null; + + @Column({ name: 'signed_at', type: 'timestamptz', nullable: true }) + signedAt!: Date | null; + + @Column({ type: 'numeric', precision: 9, scale: 6, nullable: true }) + latitude!: number | null; + + @Column({ type: 'numeric', precision: 9, scale: 6, nullable: true }) + longitude!: number | null; + + @Column({ name: 'accuracy_m', type: 'numeric', precision: 12, scale: 3, nullable: true }) + accuracyM!: number | null; + + @Column({ name: 'device_label', type: 'varchar', length: 200, nullable: true }) + deviceLabel!: string | null; + + @Column({ type: 'varchar', length: 20 }) + source!: InspectionActSignatureSource; + + @Column({ name: 'prepared_sha256', type: 'char', length: 64 }) + preparedSha256!: string; + + @Column({ name: 'signature_payload_sha256', type: 'char', length: 64 }) + signaturePayloadSha256!: string; + + @Column({ name: 'uploaded_by', type: 'uuid' }) + uploadedBy!: string; + + @Column({ name: 'created_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' }) + createdAt!: Date; +} diff --git a/api-v3/src/database/entities/inspection-act-version.entity.ts b/api-v3/src/database/entities/inspection-act-version.entity.ts new file mode 100644 index 0000000..ba98320 --- /dev/null +++ b/api-v3/src/database/entities/inspection-act-version.entity.ts @@ -0,0 +1,39 @@ +import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; + +export enum InspectionActVersionEvent { + CREATED = 'CREATED', + UPDATED = 'UPDATED', + READY = 'READY', + REOPENED = 'REOPENED', + CLOSED = 'CLOSED', + CANCELLED = 'CANCELLED', +} + +@Entity({ name: 'inspection_act_versions' }) +@Index('uq_inspection_act_versions_number', ['actId', 'versionNumber'], { unique: true }) +@Index('idx_inspection_act_versions_created_at', ['createdAt']) +export class InspectionActVersion { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ name: 'act_id', type: 'uuid' }) + actId!: string; + + @Column({ name: 'version_number', type: 'integer' }) + versionNumber!: number; + + @Column({ type: 'varchar', length: 24 }) + event!: InspectionActVersionEvent; + + @Column({ type: 'jsonb' }) + snapshot!: Record; + + @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; +} diff --git a/api-v3/src/database/entities/inspection-act.entity.ts b/api-v3/src/database/entities/inspection-act.entity.ts new file mode 100644 index 0000000..2b4f4d0 --- /dev/null +++ b/api-v3/src/database/entities/inspection-act.entity.ts @@ -0,0 +1,70 @@ +import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; +import { TimestampedEntity } from './timestamped.entity'; + +export enum InspectionActStatus { + DRAFT = 'DRAFT', + READY = 'READY', + CLOSED = 'CLOSED', + CANCELLED = 'CANCELLED', + RECTIFIED = 'RECTIFIED', +} + +@Entity({ name: 'inspection_acts' }) +@Index('uq_inspection_acts_visit', ['visitId'], { unique: true }) +@Index('uq_inspection_acts_year_number', ['actYear', 'actNumber'], { unique: true }) +@Index('uq_inspection_acts_code', ['code'], { unique: true }) +@Index('idx_inspection_acts_visit_status', ['visitId', 'status']) +@Index('idx_inspection_acts_occurred_at', ['occurredAt']) +@Index('idx_inspection_acts_created_by', ['createdBy']) +export class InspectionAct extends TimestampedEntity { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ name: 'visit_id', type: 'uuid' }) + visitId!: string; + + @Column({ name: 'act_year', type: 'integer' }) + actYear!: number; + + @Column({ name: 'act_number', type: 'integer' }) + actNumber!: number; + + @Column({ type: 'varchar', length: 24 }) + code!: string; + + @Column({ type: 'varchar', length: 24, default: InspectionActStatus.DRAFT }) + status!: InspectionActStatus; + + @Column({ name: 'occurred_at', type: 'timestamptz' }) + occurredAt!: Date; + + @Column({ type: 'varchar', length: 200 }) + title!: string; + + @Column({ type: 'text' }) + summary!: string; + + @Column({ type: 'text', nullable: true }) + observations!: string | null; + + @Column({ name: 'current_version', type: 'integer', default: 0 }) + currentVersion!: number; + + @Column({ name: 'cancellation_reason', type: 'text', nullable: true }) + cancellationReason!: string | null; + + @Column({ name: 'closed_at', type: 'timestamptz', nullable: true }) + closedAt!: Date | null; + + @Column({ name: 'closed_by', type: 'uuid', nullable: true }) + closedBy!: string | null; + + @Column({ name: 'closure_sha256', type: 'char', length: 64, nullable: true }) + closureSha256!: string | null; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy!: string | null; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedBy!: string | null; +} diff --git a/api-v3/src/database/entities/inspection-finding-communication.entity.ts b/api-v3/src/database/entities/inspection-finding-communication.entity.ts new file mode 100644 index 0000000..c3ab885 --- /dev/null +++ b/api-v3/src/database/entities/inspection-finding-communication.entity.ts @@ -0,0 +1,64 @@ +import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; + +export enum InspectionCommunicationDirection { + INBOUND = 'INBOUND', + OUTBOUND = 'OUTBOUND', + INTERNAL = 'INTERNAL', +} + +export enum InspectionCommunicationChannel { + EMAIL = 'EMAIL', + IN_PERSON = 'IN_PERSON', + PHONE = 'PHONE', + LETTER = 'LETTER', + SYSTEM = 'SYSTEM', + OTHER = 'OTHER', +} + +export enum InspectionCommunicationType { + COMPANY_RESPONSE = 'COMPANY_RESPONSE', + AUTHORITY_NOTICE = 'AUTHORITY_NOTICE', + FOLLOW_UP = 'FOLLOW_UP', + OTHER = 'OTHER', +} + +@Entity({ name: 'inspection_finding_communications' }) +@Index('uq_inspection_finding_communications_id_finding', ['id', 'findingId'], { unique: true }) +@Index('idx_inspection_finding_communications_timeline', ['findingId', 'occurredAt']) +export class InspectionFindingCommunication { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ name: 'finding_id', type: 'uuid' }) + findingId!: string; + + @Column({ type: 'varchar', length: 20 }) + direction!: InspectionCommunicationDirection; + + @Column({ type: 'varchar', length: 20 }) + channel!: InspectionCommunicationChannel; + + @Column({ type: 'varchar', length: 32 }) + type!: InspectionCommunicationType; + + @Column({ name: 'occurred_at', type: 'timestamptz' }) + occurredAt!: Date; + + @Column({ type: 'varchar', length: 250 }) + subject!: string; + + @Column({ type: 'text', nullable: true }) + details!: string | null; + + @Column({ name: 'contact_name', type: 'varchar', length: 200, nullable: true }) + contactName!: string | null; + + @Column({ name: 'contact_email', type: 'varchar', length: 320, nullable: true }) + contactEmail!: string | null; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy!: string | null; + + @Column({ name: 'created_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' }) + createdAt!: Date; +} diff --git a/api-v3/src/database/entities/inspection-finding-evidence.entity.ts b/api-v3/src/database/entities/inspection-finding-evidence.entity.ts new file mode 100644 index 0000000..3c8a522 --- /dev/null +++ b/api-v3/src/database/entities/inspection-finding-evidence.entity.ts @@ -0,0 +1,89 @@ +import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; + +export enum InspectionEvidenceKind { + PHOTO = 'PHOTO', + DOCUMENT = 'DOCUMENT', +} + +export enum InspectionEvidencePurpose { + OBSERVATION = 'OBSERVATION', + VERIFICATION = 'VERIFICATION', + COMPANY_RESPONSE = 'COMPANY_RESPONSE', + COMMUNICATION_ATTACHMENT = 'COMMUNICATION_ATTACHMENT', + OTHER_DOCUMENT = 'OTHER_DOCUMENT', +} + +export enum InspectionEvidenceSource { + WEB = 'WEB', + ANDROID = 'ANDROID', + IMPORT = 'IMPORT', +} + +@Entity({ name: 'inspection_finding_evidence' }) +@Index('idx_inspection_finding_evidence_finding_created', ['findingId', 'createdAt']) +@Index('idx_inspection_finding_evidence_sha256', ['sha256']) +@Index('idx_inspection_finding_evidence_communication', ['communicationId']) +export class InspectionFindingEvidence { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ name: 'finding_id', type: 'uuid' }) + findingId!: string; + + @Column({ name: 'communication_id', type: 'uuid', nullable: true }) + communicationId!: string | null; + + @Column({ name: 'verification_visit_id', type: 'uuid', nullable: true }) + verificationVisitId!: string | null; + + @Column({ type: 'varchar', length: 20 }) + kind!: InspectionEvidenceKind; + + @Column({ type: 'varchar', length: 40 }) + purpose!: InspectionEvidencePurpose; + + @Column({ name: 'original_name', type: 'varchar', length: 255 }) + originalName!: string; + + @Column({ name: 'stored_name', type: 'varchar', length: 80 }) + storedName!: string; + + @Column({ name: 'mime_type', type: 'varchar', length: 100 }) + mimeType!: string; + + @Column({ name: 'size_bytes', type: 'bigint' }) + sizeBytes!: string; + + @Column({ type: 'char', length: 64 }) + sha256!: string; + + @Column({ type: 'varchar', length: 200, nullable: true }) + title!: string | null; + + @Column({ type: 'text', nullable: true }) + description!: string | null; + + @Column({ name: 'captured_at', type: 'timestamptz', nullable: true }) + capturedAt!: 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({ name: 'device_label', type: 'varchar', length: 200, nullable: true }) + deviceLabel!: string | null; + + @Column({ type: 'varchar', length: 20 }) + source!: InspectionEvidenceSource; + + @Column({ name: 'uploaded_by', type: 'uuid', nullable: true }) + uploadedBy!: string | null; + + @Column({ name: 'created_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' }) + createdAt!: Date; +} diff --git a/api-v3/src/database/entities/inspection-finding-verification-event.entity.ts b/api-v3/src/database/entities/inspection-finding-verification-event.entity.ts new file mode 100644 index 0000000..7b27011 --- /dev/null +++ b/api-v3/src/database/entities/inspection-finding-verification-event.entity.ts @@ -0,0 +1,54 @@ +import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; +import { InspectionVerificationOutcome } from './inspection-finding-verification-visit.entity'; + +export enum InspectionFindingVerificationEventType { + CONTROL_DATE_DEFINED = 'CONTROL_DATE_DEFINED', + CONTROL_DATE_CHANGED = 'CONTROL_DATE_CHANGED', + CONTROL_DATE_CLEARED = 'CONTROL_DATE_CLEARED', + VISIT_PLANNED = 'VISIT_PLANNED', + RESULT_RECORDED = 'RESULT_RECORDED', +} + +@Entity({ name: 'inspection_finding_verification_events' }) +@Index('idx_inspection_finding_verification_events_finding', ['findingId', 'occurredAt']) +@Index('idx_inspection_finding_verification_events_visit', ['verificationVisitId']) +export class InspectionFindingVerificationEvent { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ name: 'finding_id', type: 'uuid' }) + findingId!: string; + + @Column({ name: 'verification_visit_id', type: 'uuid', nullable: true }) + verificationVisitId!: string | null; + + @Column({ name: 'event_type', type: 'varchar', length: 40 }) + eventType!: InspectionFindingVerificationEventType; + + @Column({ name: 'occurred_at', type: 'timestamptz' }) + occurredAt!: Date; + + @Column({ name: 'target_control_on', type: 'date', nullable: true }) + targetControlOn!: string | null; + + @Column({ name: 'previous_control_on', type: 'date', nullable: true }) + previousControlOn!: string | null; + + @Column({ name: 'next_control_on', type: 'date', nullable: true }) + nextControlOn!: string | null; + + @Column({ type: 'varchar', length: 24, nullable: true }) + outcome!: InspectionVerificationOutcome | null; + + @Column({ type: 'text', nullable: true }) + notes!: string | null; + + @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; +} diff --git a/api-v3/src/database/entities/inspection-finding-verification-visit.entity.ts b/api-v3/src/database/entities/inspection-finding-verification-visit.entity.ts new file mode 100644 index 0000000..8e90d8e --- /dev/null +++ b/api-v3/src/database/entities/inspection-finding-verification-visit.entity.ts @@ -0,0 +1,47 @@ +import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; +import { TimestampedEntity } from './timestamped.entity'; + +export enum InspectionVerificationOutcome { + RESOLVED = 'RESOLVED', + NOT_RESOLVED = 'NOT_RESOLVED', + REQUIRES_NEW_DATE = 'REQUIRES_NEW_DATE', +} + +@Entity({ name: 'inspection_finding_verification_visits' }) +@Index('uq_inspection_finding_verification_visit', ['findingId', 'visitId'], { unique: true }) +@Index('idx_inspection_finding_verification_finding', ['findingId']) +@Index('idx_inspection_finding_verification_visit', ['visitId']) +export class InspectionFindingVerificationVisit extends TimestampedEntity { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ name: 'finding_id', type: 'uuid' }) + findingId!: string; + + @Column({ name: 'visit_id', type: 'uuid' }) + visitId!: string; + + @Column({ name: 'linked_by', type: 'uuid', nullable: true }) + linkedBy!: string | null; + + @Column({ name: 'target_control_on', type: 'date', nullable: true }) + targetControlOn!: string | null; + + @Column({ type: 'varchar', length: 24, nullable: true }) + outcome!: InspectionVerificationOutcome | null; + + @Column({ name: 'result_notes', type: 'text', nullable: true }) + resultNotes!: string | null; + + @Column({ name: 'verified_at', type: 'timestamptz', nullable: true }) + verifiedAt!: Date | null; + + @Column({ name: 'result_recorded_at', type: 'timestamptz', nullable: true }) + resultRecordedAt!: Date | null; + + @Column({ name: 'result_recorded_by', type: 'uuid', nullable: true }) + resultRecordedBy!: string | null; + + @Column({ name: 'rescheduled_control_on', type: 'date', nullable: true }) + rescheduledControlOn!: string | null; +} diff --git a/api-v3/src/database/entities/inspection-finding-version.entity.ts b/api-v3/src/database/entities/inspection-finding-version.entity.ts new file mode 100644 index 0000000..2616c21 --- /dev/null +++ b/api-v3/src/database/entities/inspection-finding-version.entity.ts @@ -0,0 +1,37 @@ +import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; + +export enum InspectionFindingVersionEvent { + CREATED = 'CREATED', + UPDATED = 'UPDATED', + FOLLOW_UP_UPDATED = 'FOLLOW_UP_UPDATED', + VERIFICATION_RECORDED = 'VERIFICATION_RECORDED', +} + +@Entity({ name: 'inspection_finding_versions' }) +@Index('uq_inspection_finding_versions_number', ['findingId', 'versionNumber'], { unique: true }) +@Index('idx_inspection_finding_versions_created_at', ['createdAt']) +export class InspectionFindingVersion { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ name: 'finding_id', type: 'uuid' }) + findingId!: string; + + @Column({ name: 'version_number', type: 'integer' }) + versionNumber!: number; + + @Column({ type: 'varchar', length: 32 }) + event!: InspectionFindingVersionEvent; + + @Column({ type: 'jsonb' }) + snapshot!: Record; + + @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; +} diff --git a/api-v3/src/database/entities/inspection-finding.entity.ts b/api-v3/src/database/entities/inspection-finding.entity.ts new file mode 100644 index 0000000..e1f5a69 --- /dev/null +++ b/api-v3/src/database/entities/inspection-finding.entity.ts @@ -0,0 +1,108 @@ +import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; +import { TimestampedEntity } from './timestamped.entity'; + +export enum InspectionFindingResponseDueBasis { + FINDING_DATE = 'FINDING_DATE', + REPORT_NOTIFICATION = 'REPORT_NOTIFICATION', +} + +export enum InspectionFindingStatus { + OPEN = 'OPEN', + CLOSED = 'CLOSED', + VOIDED = 'VOIDED', +} + +@Entity({ name: 'inspection_findings' }) +@Index('uq_inspection_findings_number', ['actId', 'findingNumber'], { unique: true }) +@Index('uq_inspection_findings_code', ['code'], { unique: true }) +@Index('idx_inspection_findings_status_control', ['status', 'nextControlOn']) +@Index('idx_inspection_findings_asset_status', ['assetId', 'status']) +@Index('idx_inspection_findings_catalog_item', ['catalogItemId']) +export class InspectionFinding extends TimestampedEntity { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ name: 'act_id', type: 'uuid' }) + actId!: string; + + @Column({ name: 'asset_id', type: 'uuid' }) + assetId!: string; + + @Column({ name: 'catalog_item_id', type: 'uuid', nullable: true }) + catalogItemId!: string | null; + + @Column({ name: 'finding_number', type: 'integer' }) + findingNumber!: number; + + @Column({ type: 'varchar', length: 40 }) + code!: string; + + @Column({ type: 'varchar', length: 20, default: InspectionFindingStatus.OPEN }) + status!: InspectionFindingStatus; + + @Column({ type: 'varchar', length: 500 }) + title!: string; + + @Column({ type: 'text' }) + description!: string; + + @Column({ name: 'legal_basis', type: 'text', nullable: true }) + legalBasis!: string | null; + + @Column({ type: 'text', nullable: true }) + glossary!: string | null; + + @Column({ name: 'catalog_revision', type: 'integer', nullable: true }) + catalogRevision!: number | null; + + @Column({ name: 'suggested_severity', type: 'smallint', nullable: true }) + suggestedSeverity!: number | null; + + @Column({ type: 'smallint', nullable: true }) + severity!: number | null; + + @Column({ name: 'correction_due_on', type: 'date', nullable: true }) + correctionDueOn!: string | null; + + @Column({ name: 'response_due_basis', type: 'varchar', length: 32, nullable: true }) + responseDueBasis!: InspectionFindingResponseDueBasis | null; + + @Column({ name: 'response_due_days', type: 'integer', nullable: true }) + responseDueDays!: number | null; + + @Column({ name: 'response_due_base_on', type: 'date', nullable: true }) + responseDueBaseOn!: string | null; + + @Column({ name: 'report_notified_on', type: 'date', nullable: true }) + reportNotifiedOn!: string | null; + + @Column({ name: 'company_response', type: 'text', nullable: true }) + companyResponse!: string | null; + + @Column({ name: 'company_response_received_on', type: 'date', nullable: true }) + companyResponseReceivedOn!: string | null; + + @Column({ name: 'company_committed_correction_on', type: 'date', nullable: true }) + companyCommittedCorrectionOn!: string | null; + + @Column({ name: 'next_control_on', type: 'date', nullable: true }) + nextControlOn!: string | null; + + @Column({ name: 'current_version', type: 'integer', default: 0 }) + currentVersion!: number; + + @Column({ name: 'closed_at', type: 'timestamptz', nullable: true }) + closedAt!: Date | null; + + @Column({ name: 'closed_by', type: 'uuid', nullable: true }) + closedBy!: string | null; + + @Column({ name: 'closure_notes', type: 'text', nullable: true }) + closureNotes!: string | null; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy!: string | null; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedBy!: string | null; +} diff --git a/api-v3/src/database/entities/inspection-report.entity.ts b/api-v3/src/database/entities/inspection-report.entity.ts new file mode 100644 index 0000000..0a146ed --- /dev/null +++ b/api-v3/src/database/entities/inspection-report.entity.ts @@ -0,0 +1,124 @@ +import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; +import { TimestampedEntity } from './timestamped.entity'; + +export enum InspectionReportStatus { + FROZEN = 'FROZEN', + CANCELLED = 'CANCELLED', +} + +export enum InspectionReportPdfStatus { + PENDING = 'PENDING', + READY = 'READY', + FAILED = 'FAILED', +} + +export enum InspectionReportWordStatus { + PENDING = 'PENDING', + READY = 'READY', + FAILED = 'FAILED', +} + +export enum InspectionReportReviewStatus { + PENDING_REVIEW = 'PENDING_REVIEW', + APPROVED = 'APPROVED', + SIGNED = 'SIGNED', +} + +@Entity({ name: 'inspection_reports' }) +@Index('uq_inspection_reports_visit', ['visitId'], { unique: true }) +@Index('uq_inspection_reports_act', ['actId'], { unique: true }) +@Index('uq_inspection_reports_year_number', ['reportYear', 'reportNumber'], { unique: true }) +@Index('uq_inspection_reports_code', ['code'], { unique: true }) +@Index('idx_inspection_reports_generated_at', ['generatedAt']) +@Index('idx_inspection_reports_status', ['status', 'pdfStatus']) +export class InspectionReport extends TimestampedEntity { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ name: 'visit_id', type: 'uuid' }) + visitId!: string; + + @Column({ name: 'act_id', type: 'uuid' }) + actId!: string; + + @Column({ name: 'report_year', type: 'integer' }) + reportYear!: number; + + @Column({ name: 'report_number', type: 'integer' }) + reportNumber!: number; + + @Column({ type: 'varchar', length: 24 }) + code!: string; + + @Column({ type: 'varchar', length: 24, default: InspectionReportStatus.FROZEN }) + status!: InspectionReportStatus; + + @Column({ name: 'pdf_status', type: 'varchar', length: 24, default: InspectionReportPdfStatus.PENDING }) + pdfStatus!: InspectionReportPdfStatus; + + @Column({ name: 'word_status', type: 'varchar', length: 24, default: InspectionReportWordStatus.PENDING }) + wordStatus!: InspectionReportWordStatus; + + @Column({ name: 'word_original_name', type: 'varchar', length: 255, nullable: true }) + wordOriginalName!: string | null; + + @Column({ name: 'word_stored_name', type: 'varchar', length: 255, nullable: true }) + wordStoredName!: string | null; + + @Column({ name: 'word_mime_type', type: 'varchar', length: 120, nullable: true }) + wordMimeType!: string | null; + + @Column({ name: 'word_size_bytes', type: 'integer', nullable: true }) + wordSizeBytes!: number | null; + + @Column({ name: 'word_sha256', type: 'char', length: 64, nullable: true }) + wordSha256!: string | null; + + @Column({ name: 'word_generated_at', type: 'timestamptz', nullable: true }) + wordGeneratedAt!: Date | null; + + @Column({ name: 'word_error', type: 'varchar', length: 500, nullable: true }) + wordError!: string | null; + + @Column({ name: 'review_status', type: 'varchar', length: 32, default: InspectionReportReviewStatus.PENDING_REVIEW }) + reviewStatus!: InspectionReportReviewStatus; + + @Column({ name: 'current_revision_number', type: 'integer', default: 0 }) + currentRevisionNumber!: number; + + @Column({ name: 'approved_revision_id', type: 'uuid', nullable: true }) + approvedRevisionId!: string | null; + + @Column({ name: 'approved_by', type: 'uuid', nullable: true }) + approvedBy!: string | null; + + @Column({ name: 'approved_at', type: 'timestamptz', nullable: true }) + approvedAt!: Date | null; + + @Column({ name: 'review_note', type: 'varchar', length: 1000, nullable: true }) + reviewNote!: string | null; + + @Column({ name: 'signed_at', type: 'timestamptz', nullable: true }) + signedAt!: Date | null; + + @Column({ type: 'varchar', length: 220 }) + title!: string; + + @Column({ name: 'act_version', type: 'integer' }) + actVersion!: number; + + @Column({ name: 'act_closure_sha256', type: 'char', length: 64 }) + actClosureSha256!: string; + + @Column({ name: 'frozen_sha256', type: 'char', length: 64 }) + frozenSha256!: string; + + @Column({ name: 'frozen_snapshot', type: 'jsonb' }) + frozenSnapshot!: Record; + + @Column({ name: 'generated_at', type: 'timestamptz' }) + generatedAt!: Date; + + @Column({ name: 'generated_by', type: 'uuid' }) + generatedBy!: string; +} diff --git a/api-v3/src/database/entities/inspection-visit-asset.entity.ts b/api-v3/src/database/entities/inspection-visit-asset.entity.ts new file mode 100644 index 0000000..e20dea9 --- /dev/null +++ b/api-v3/src/database/entities/inspection-visit-asset.entity.ts @@ -0,0 +1,38 @@ +import { Column, Entity, Index, PrimaryColumn } from 'typeorm'; +import { TimestampedEntity } from './timestamped.entity'; + +export enum InspectionVisitAssetPlanningSource { + LEGACY = 'LEGACY', + AUTOMATIC = 'AUTOMATIC', + PREVENTIVE = 'PREVENTIVE', + VERIFICATION = 'VERIFICATION', +} + +@Entity({ name: 'inspection_visit_assets' }) +@Index('idx_inspection_visit_assets_asset_id', ['assetId']) +@Index('idx_inspection_visit_assets_included', ['visitId', 'included']) +export class InspectionVisitAsset extends TimestampedEntity { + @PrimaryColumn({ name: 'visit_id', type: 'uuid' }) + visitId!: string; + + @PrimaryColumn({ name: 'asset_id', type: 'uuid' }) + assetId!: string; + + @Column({ type: 'boolean', default: true }) + included!: boolean; + + @Column({ name: 'planning_source', type: 'varchar', length: 24, default: InspectionVisitAssetPlanningSource.LEGACY }) + planningSource!: InspectionVisitAssetPlanningSource; + + @Column({ name: 'exclusion_reason', type: 'text', nullable: true }) + exclusionReason!: string | null; + + @Column({ name: 'excluded_by', type: 'uuid', nullable: true }) + excludedBy!: string | null; + + @Column({ name: 'excluded_at', type: 'timestamptz', nullable: true }) + excludedAt!: Date | null; + + @Column({ name: 'added_by', type: 'uuid', nullable: true }) + addedBy!: string | null; +} diff --git a/api-v3/src/database/entities/inspection-visit-member.entity.ts b/api-v3/src/database/entities/inspection-visit-member.entity.ts new file mode 100644 index 0000000..9389479 --- /dev/null +++ b/api-v3/src/database/entities/inspection-visit-member.entity.ts @@ -0,0 +1,19 @@ +import { Column, Entity, Index, PrimaryColumn } from 'typeorm'; +import { TimestampedEntity } from './timestamped.entity'; + +@Entity({ name: 'inspection_visit_members' }) +@Index('idx_inspection_visit_members_user_id', ['userId']) +@Index('idx_inspection_visit_members_included', ['visitId', 'included']) +export class InspectionVisitMember extends TimestampedEntity { + @PrimaryColumn({ name: 'visit_id', type: 'uuid' }) + visitId!: string; + + @PrimaryColumn({ name: 'user_id', type: 'uuid' }) + userId!: string; + + @Column({ type: 'boolean', default: true }) + included!: boolean; + + @Column({ name: 'assigned_by', type: 'uuid', nullable: true }) + assignedBy!: string | null; +} diff --git a/api-v3/src/database/entities/inspection-visit.entity.ts b/api-v3/src/database/entities/inspection-visit.entity.ts new file mode 100644 index 0000000..23f3b5c --- /dev/null +++ b/api-v3/src/database/entities/inspection-visit.entity.ts @@ -0,0 +1,76 @@ +import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; +import { TimestampedEntity } from './timestamped.entity'; + +export enum InspectionVisitStatus { + DRAFT = 'DRAFT', + PLANNED = 'PLANNED', + IN_PROGRESS = 'IN_PROGRESS', + CLOSED = 'CLOSED', + CANCELLED = 'CANCELLED', +} + +@Entity({ name: 'inspection_visits' }) +@Index('uq_inspection_visits_code', ['code'], { unique: true }) +@Index('idx_inspection_visits_status', ['status']) +@Index('idx_inspection_visits_scope_asset_id', ['scopeAssetId']) +@Index('idx_inspection_visits_lead_inspector_user_id', ['leadInspectorUserId']) +@Index('idx_inspection_visits_planned_dates', ['plannedStartAt', 'plannedEndAt']) +@Index('idx_inspection_visits_operational_context', ['operationalAreaId', 'operatorCompanyId']) +export class InspectionVisit extends TimestampedEntity { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ type: 'varchar', length: 80 }) + code!: string; + + @Column({ type: 'varchar', length: 200 }) + title!: string; + + @Column({ type: 'text', nullable: true }) + objective!: string | null; + + @Column({ type: 'varchar', length: 24, default: InspectionVisitStatus.DRAFT }) + status!: InspectionVisitStatus; + + @Column({ name: 'scope_asset_id', type: 'uuid', nullable: true }) + scopeAssetId!: string | null; + + @Column({ name: 'operational_area_id', type: 'uuid', nullable: true }) + operationalAreaId!: string | null; + + @Column({ name: 'operator_company_id', type: 'uuid', nullable: true }) + operatorCompanyId!: string | null; + + @Column({ name: 'lead_inspector_user_id', type: 'uuid', nullable: true }) + leadInspectorUserId!: string | null; + + @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: 'actual_started_at', type: 'timestamptz', nullable: true }) + actualStartedAt!: Date | null; + + @Column({ name: 'actual_closed_at', type: 'timestamptz', nullable: true }) + actualClosedAt!: Date | null; + + @Column({ type: 'text', nullable: true }) + instructions!: string | null; + + @Column({ name: 'cancellation_reason', type: 'text', nullable: true }) + cancellationReason!: string | null; + + @Column({ name: 'checklist_generation', type: 'integer', default: 0 }) + checklistGeneration!: number; + + @Column({ name: 'checklist_generated_at', type: 'timestamptz', nullable: true }) + checklistGeneratedAt!: Date | null; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy!: string | null; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedBy!: string | null; +} diff --git a/api-v3/src/database/entities/organization-membership.entity.ts b/api-v3/src/database/entities/organization-membership.entity.ts new file mode 100644 index 0000000..0006108 --- /dev/null +++ b/api-v3/src/database/entities/organization-membership.entity.ts @@ -0,0 +1,21 @@ +import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; +import { TimestampedEntity } from './timestamped.entity'; +export enum OrganizationMembershipRole { MEMBER='MEMBER', LEAD_MEMBER='LEAD_MEMBER', OTHER='OTHER' } +@Entity({name:'organization_memberships'}) +@Index('idx_organization_memberships_parent_id',['parentOrganizationId']) +@Index('idx_organization_memberships_member_id',['memberOrganizationId']) +@Index('idx_organization_memberships_valid_until',['validUntil']) +export class OrganizationMembership extends TimestampedEntity { + @PrimaryGeneratedColumn('uuid') id!:string; + @Column({name:'parent_organization_id',type:'uuid'}) parentOrganizationId!:string; + @Column({name:'member_organization_id',type:'uuid'}) memberOrganizationId!:string; + @Column({type:'enum',enum:OrganizationMembershipRole,enumName:'organization_membership_role'}) role!:OrganizationMembershipRole; + @Column({name:'participation_percent',type:'numeric',precision:7,scale:4,nullable:true}) participationPercent!:string|null; + @Column({name:'valid_from',type:'date',default:()=> 'CURRENT_DATE'}) validFrom!:string; + @Column({name:'valid_until',type:'date',nullable:true}) validUntil!:string|null; + @Column({name:'source_document_id',type:'uuid',nullable:true}) sourceDocumentId!:string|null; + @Column({type:'text',nullable:true}) notes!:string|null; + @Column({name:'end_reason',type:'text',nullable:true}) endReason!:string|null; + @Column({name:'created_by',type:'uuid',nullable:true}) createdBy!:string|null; + @Column({name:'ended_by',type:'uuid',nullable:true}) endedBy!:string|null; +} diff --git a/api-v3/src/database/entities/organization-profile.entity.ts b/api-v3/src/database/entities/organization-profile.entity.ts new file mode 100644 index 0000000..9bc6fb2 --- /dev/null +++ b/api-v3/src/database/entities/organization-profile.entity.ts @@ -0,0 +1,13 @@ +import { Column, Entity, PrimaryColumn } from 'typeorm'; +import { TimestampedEntity } from './timestamped.entity'; +export enum OrganizationKind { COMPANY='COMPANY', UTE='UTE', PUBLIC_ENTITY='PUBLIC_ENTITY', OTHER='OTHER' } +@Entity({ name: 'organization_profiles' }) +export class OrganizationProfile extends TimestampedEntity { + @PrimaryColumn({ name:'asset_id', type:'uuid' }) assetId!: string; + @Column({ name:'organization_kind', type:'enum', enum:OrganizationKind, enumName:'organization_kind', default:OrganizationKind.COMPANY }) organizationKind!: OrganizationKind; + @Column({ name:'legal_name', type:'varchar', length:240, nullable:true }) legalName!: string|null; + @Column({ name:'tax_id', type:'varchar', length:32, nullable:true }) taxId!: string|null; + @Column({ name:'notification_email', type:'varchar', length:320, nullable:true }) notificationEmail!: string|null; + @Column({ type:'text', nullable:true }) notes!: string|null; + @Column({ name:'updated_by', type:'uuid', nullable:true }) updatedBy!: string|null; +} diff --git a/api-v3/src/database/entities/permission.entity.ts b/api-v3/src/database/entities/permission.entity.ts new file mode 100644 index 0000000..9e2b7a5 --- /dev/null +++ b/api-v3/src/database/entities/permission.entity.ts @@ -0,0 +1,16 @@ +import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from 'typeorm'; + +@Entity({ name: 'permissions' }) +export class Permission { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ type: 'varchar', length: 120 }) + code!: string; + + @Column({ type: 'text' }) + description!: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt!: Date; +} diff --git a/api-v3/src/database/entities/role-permission.entity.ts b/api-v3/src/database/entities/role-permission.entity.ts new file mode 100644 index 0000000..d8b6338 --- /dev/null +++ b/api-v3/src/database/entities/role-permission.entity.ts @@ -0,0 +1,21 @@ +import { Entity, Index, JoinColumn, ManyToOne, PrimaryColumn } from 'typeorm'; +import { Permission } from './permission.entity'; +import { Role } from './role.entity'; + +@Entity({ name: 'role_permissions' }) +@Index('idx_role_permissions_permission_id', ['permissionId']) +export class RolePermission { + @PrimaryColumn({ name: 'role_id', type: 'uuid' }) + roleId!: string; + + @PrimaryColumn({ name: 'permission_id', type: 'uuid' }) + permissionId!: string; + + @ManyToOne(() => Role, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'role_id' }) + role!: Role; + + @ManyToOne(() => Permission, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'permission_id' }) + permission!: Permission; +} diff --git a/api-v3/src/database/entities/role.entity.ts b/api-v3/src/database/entities/role.entity.ts new file mode 100644 index 0000000..7d69112 --- /dev/null +++ b/api-v3/src/database/entities/role.entity.ts @@ -0,0 +1,20 @@ +import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; +import { TimestampedEntity } from './timestamped.entity'; + +@Entity({ name: 'roles' }) +export class Role extends TimestampedEntity { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ type: 'varchar', length: 80 }) + code!: string; + + @Column({ type: 'varchar', length: 120 }) + name!: string; + + @Column({ type: 'text' }) + description!: string; + + @Column({ name: 'is_system', type: 'boolean', default: false }) + isSystem!: boolean; +} diff --git a/api-v3/src/database/entities/source-document.entity.ts b/api-v3/src/database/entities/source-document.entity.ts new file mode 100644 index 0000000..b5d3261 --- /dev/null +++ b/api-v3/src/database/entities/source-document.entity.ts @@ -0,0 +1,6 @@ +import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; +import { TimestampedEntity } from './timestamped.entity'; +export enum SourceDocumentType { NOTE='NOTE', TECHNICAL_REPORT='TECHNICAL_REPORT', INSPECTION_ACT='INSPECTION_ACT', INVENTORY='INVENTORY', RESOLUTION='RESOLUTION', DECREE='DECREE', CONTRACT='CONTRACT', SPREADSHEET='SPREADSHEET', OTHER='OTHER' } +@Entity({name:'source_documents'}) +@Index('idx_source_documents_number',['documentNumber']) @Index('idx_source_documents_document_date',['documentDate']) +export class SourceDocument extends TimestampedEntity { @PrimaryGeneratedColumn('uuid') id!:string; @Column({name:'document_type',type:'enum',enum:SourceDocumentType,enumName:'source_document_type'}) documentType!:SourceDocumentType; @Column({name:'document_number',type:'varchar',length:160,nullable:true}) documentNumber!:string|null; @Column({type:'varchar',length:300}) title!:string; @Column({type:'varchar',length:240,nullable:true}) issuer!:string|null; @Column({name:'document_date',type:'date',nullable:true}) documentDate!:string|null; @Column({name:'external_reference',type:'varchar',length:500,nullable:true}) externalReference!:string|null; @Column({type:'text',nullable:true}) notes!:string|null; @Column({name:'created_by',type:'uuid',nullable:true}) createdBy!:string|null; @Column({name:'updated_by',type:'uuid',nullable:true}) updatedBy!:string|null; } diff --git a/api-v3/src/database/entities/survey-campaign-target.entity.ts b/api-v3/src/database/entities/survey-campaign-target.entity.ts new file mode 100644 index 0000000..f944fbb --- /dev/null +++ b/api-v3/src/database/entities/survey-campaign-target.entity.ts @@ -0,0 +1,44 @@ +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; +} diff --git a/api-v3/src/database/entities/survey-campaign.entity.ts b/api-v3/src/database/entities/survey-campaign.entity.ts new file mode 100644 index 0000000..a6c536d --- /dev/null +++ b/api-v3/src/database/entities/survey-campaign.entity.ts @@ -0,0 +1,51 @@ +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; +} diff --git a/api-v3/src/database/entities/survey-target-report-media.entity.ts b/api-v3/src/database/entities/survey-target-report-media.entity.ts new file mode 100644 index 0000000..66dfadb --- /dev/null +++ b/api-v3/src/database/entities/survey-target-report-media.entity.ts @@ -0,0 +1,19 @@ +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; +} diff --git a/api-v3/src/database/entities/survey-target-report-version.entity.ts b/api-v3/src/database/entities/survey-target-report-version.entity.ts new file mode 100644 index 0000000..b32a790 --- /dev/null +++ b/api-v3/src/database/entities/survey-target-report-version.entity.ts @@ -0,0 +1,36 @@ +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; + + @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; +} diff --git a/api-v3/src/database/entities/survey-target-report.entity.ts b/api-v3/src/database/entities/survey-target-report.entity.ts new file mode 100644 index 0000000..b835063 --- /dev/null +++ b/api-v3/src/database/entities/survey-target-report.entity.ts @@ -0,0 +1,73 @@ +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; +} diff --git a/api-v3/src/database/entities/timestamped.entity.ts b/api-v3/src/database/entities/timestamped.entity.ts new file mode 100644 index 0000000..f14e5cb --- /dev/null +++ b/api-v3/src/database/entities/timestamped.entity.ts @@ -0,0 +1,9 @@ +import { CreateDateColumn, UpdateDateColumn } from 'typeorm'; + +export abstract class TimestampedEntity { + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt!: Date; +} diff --git a/api-v3/src/database/entities/user-role.entity.ts b/api-v3/src/database/entities/user-role.entity.ts new file mode 100644 index 0000000..a2f66c2 --- /dev/null +++ b/api-v3/src/database/entities/user-role.entity.ts @@ -0,0 +1,35 @@ +import { + Column, + Entity, + Index, + JoinColumn, + ManyToOne, + PrimaryColumn, +} from 'typeorm'; +import { Role } from './role.entity'; +import { User } from './user.entity'; + +@Entity({ name: 'user_roles' }) +@Index('idx_user_roles_user_id', ['userId']) +@Index('idx_user_roles_role_id', ['roleId']) +export class UserRole { + @PrimaryColumn({ name: 'user_id', type: 'uuid' }) + userId!: string; + + @PrimaryColumn({ name: 'role_id', type: 'uuid' }) + roleId!: string; + + @Column({ name: 'assigned_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' }) + assignedAt!: Date; + + @Column({ name: 'assigned_by', type: 'uuid', nullable: true }) + assignedBy!: string | null; + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'user_id' }) + user!: User; + + @ManyToOne(() => Role, { onDelete: 'RESTRICT' }) + @JoinColumn({ name: 'role_id' }) + role!: Role; +} diff --git a/api-v3/src/database/entities/user.entity.ts b/api-v3/src/database/entities/user.entity.ts new file mode 100644 index 0000000..4a38447 --- /dev/null +++ b/api-v3/src/database/entities/user.entity.ts @@ -0,0 +1,59 @@ +import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; +import { TimestampedEntity } from './timestamped.entity'; + +export enum UserStatus { + ACTIVE = 'ACTIVE', + INACTIVE = 'INACTIVE', +} + +@Entity({ name: 'users' }) +@Index('idx_users_status', ['status']) +@Index('idx_users_locked_until', ['lockedUntil']) +export class User extends TimestampedEntity { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ type: 'varchar', length: 80 }) + username!: string; + + @Column({ type: 'varchar', length: 320, nullable: true }) + email!: string | null; + + @Column({ name: 'password_hash', type: 'text', select: false }) + passwordHash!: string; + + @Column({ name: 'first_name', type: 'varchar', length: 120 }) + firstName!: string; + + @Column({ name: 'last_name', type: 'varchar', length: 120 }) + lastName!: string; + + @Column({ + type: 'enum', + enum: UserStatus, + enumName: 'user_status', + default: UserStatus.ACTIVE, + }) + status!: UserStatus; + + @Column({ name: 'must_change_password', type: 'boolean', default: true }) + mustChangePassword!: boolean; + + @Column({ name: 'failed_login_attempts', type: 'integer', default: 0 }) + failedLoginAttempts!: number; + + @Column({ name: 'locked_until', type: 'timestamptz', nullable: true }) + lockedUntil!: Date | null; + + @Column({ name: 'last_login_at', type: 'timestamptz', nullable: true }) + lastLoginAt!: Date | null; + + @Column({ name: 'password_changed_at', type: 'timestamptz', nullable: true }) + passwordChangedAt!: Date | null; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy!: string | null; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedBy!: string | null; +} diff --git a/api-v3/src/database/migration-cli.ts b/api-v3/src/database/migration-cli.ts new file mode 100644 index 0000000..faa8fd6 --- /dev/null +++ b/api-v3/src/database/migration-cli.ts @@ -0,0 +1,49 @@ +import 'reflect-metadata'; +import { migrationDataSource } from './data-source'; + +type MigrationCommand = 'run' | 'show' | 'revert'; + +function readCommand(): MigrationCommand { + const command = process.argv[2]; + if (command === 'run' || command === 'show' || command === 'revert') { + return command; + } + throw new Error('Expected migration command: run, show or revert'); +} + +async function main(): Promise { + const command = readCommand(); + await migrationDataSource.initialize(); + + try { + if (command === 'run') { + const migrations = await migrationDataSource.runMigrations({ + transaction: 'each', + }); + console.log(`Applied migrations: ${migrations.length}`); + for (const migration of migrations) console.log(`- ${migration.name}`); + return; + } + + if (command === 'revert') { + await migrationDataSource.undoLastMigration({ transaction: 'each' }); + console.log('Last migration reverted'); + return; + } + + const hasPendingMigrations = await migrationDataSource.showMigrations(); + console.log( + hasPendingMigrations + ? 'Pending migrations: yes' + : 'Pending migrations: no', + ); + } finally { + await migrationDataSource.destroy(); + } +} + +main().catch((error: unknown) => { + const message = error instanceof Error ? error.message : 'Unknown error'; + console.error(`Migration command failed: ${message}`); + process.exit(1); +}); diff --git a/api-v3/src/database/migrations/1786548000000-phase-a-users-roles-permissions.ts b/api-v3/src/database/migrations/1786548000000-phase-a-users-roles-permissions.ts new file mode 100644 index 0000000..c4fa148 --- /dev/null +++ b/api-v3/src/database/migrations/1786548000000-phase-a-users-roles-permissions.ts @@ -0,0 +1,124 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class PhaseAUsersRolesPermissions1786548000000 + implements MigrationInterface +{ + name = 'PhaseAUsersRolesPermissions1786548000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TYPE user_status AS ENUM ('ACTIVE', 'INACTIVE') + `); + + await queryRunner.query(` + CREATE TABLE users ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + username varchar(80) NOT NULL, + email varchar(320), + password_hash text NOT NULL, + first_name varchar(120) NOT NULL, + last_name varchar(120) NOT NULL, + status user_status NOT NULL DEFAULT 'ACTIVE', + must_change_password boolean NOT NULL DEFAULT true, + failed_login_attempts integer NOT NULL DEFAULT 0, + locked_until timestamptz, + last_login_at timestamptz, + password_changed_at timestamptz, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_by uuid, + updated_by uuid, + CONSTRAINT chk_users_failed_login_attempts + CHECK (failed_login_attempts >= 0), + CONSTRAINT fk_users_created_by + FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL, + CONSTRAINT fk_users_updated_by + FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL + ) + `); + + await queryRunner.query( + 'CREATE UNIQUE INDEX uq_users_username_ci ON users (LOWER(username))', + ); + await queryRunner.query(` + CREATE UNIQUE INDEX uq_users_email_ci + ON users (LOWER(email)) + WHERE email IS NOT NULL + `); + await queryRunner.query('CREATE INDEX idx_users_status ON users (status)'); + + await queryRunner.query(` + CREATE TABLE roles ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + code varchar(80) NOT NULL, + name varchar(120) NOT NULL, + description text NOT NULL, + is_system boolean NOT NULL DEFAULT false, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + `); + await queryRunner.query( + 'CREATE UNIQUE INDEX uq_roles_code ON roles (code)', + ); + + await queryRunner.query(` + CREATE TABLE permissions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + code varchar(120) NOT NULL, + description text NOT NULL, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + `); + await queryRunner.query( + 'CREATE UNIQUE INDEX uq_permissions_code ON permissions (code)', + ); + + await queryRunner.query(` + CREATE TABLE user_roles ( + user_id uuid NOT NULL, + role_id uuid NOT NULL, + assigned_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + assigned_by uuid, + CONSTRAINT pk_user_roles PRIMARY KEY (user_id, role_id), + CONSTRAINT fk_user_roles_user + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + CONSTRAINT fk_user_roles_role + FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE RESTRICT, + CONSTRAINT fk_user_roles_assigned_by + FOREIGN KEY (assigned_by) REFERENCES users(id) ON DELETE SET NULL + ) + `); + await queryRunner.query( + 'CREATE INDEX idx_user_roles_user_id ON user_roles (user_id)', + ); + await queryRunner.query( + 'CREATE INDEX idx_user_roles_role_id ON user_roles (role_id)', + ); + + await queryRunner.query(` + CREATE TABLE role_permissions ( + role_id uuid NOT NULL, + permission_id uuid NOT NULL, + CONSTRAINT pk_role_permissions PRIMARY KEY (role_id, permission_id), + CONSTRAINT fk_role_permissions_role + FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE, + CONSTRAINT fk_role_permissions_permission + FOREIGN KEY (permission_id) REFERENCES permissions(id) ON DELETE CASCADE + ) + `); + await queryRunner.query(` + CREATE INDEX idx_role_permissions_permission_id + ON role_permissions (permission_id) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query('DROP TABLE role_permissions'); + await queryRunner.query('DROP TABLE user_roles'); + await queryRunner.query('DROP TABLE permissions'); + await queryRunner.query('DROP TABLE roles'); + await queryRunner.query('DROP TABLE users'); + await queryRunner.query('DROP TYPE user_status'); + } +} diff --git a/api-v3/src/database/migrations/1786548001000-phase-a-auth-sessions.ts b/api-v3/src/database/migrations/1786548001000-phase-a-auth-sessions.ts new file mode 100644 index 0000000..98f66aa --- /dev/null +++ b/api-v3/src/database/migrations/1786548001000-phase-a-auth-sessions.ts @@ -0,0 +1,48 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class PhaseAAuthSessions1786548001000 implements MigrationInterface { + name = 'PhaseAAuthSessions1786548001000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE auth_sessions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL, + refresh_token_hash text NOT NULL, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at timestamptz NOT NULL, + last_used_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + revoked_at timestamptz, + replaced_by_session_id uuid, + ip inet, + user_agent text, + device_label text, + CONSTRAINT fk_auth_sessions_user + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE RESTRICT, + CONSTRAINT fk_auth_sessions_replaced_by + FOREIGN KEY (replaced_by_session_id) + REFERENCES auth_sessions(id) ON DELETE SET NULL, + CONSTRAINT chk_auth_sessions_expiration + CHECK (expires_at > created_at) + ) + `); + + await queryRunner.query(` + CREATE UNIQUE INDEX uq_auth_sessions_refresh_token_hash + ON auth_sessions (refresh_token_hash) + `); + await queryRunner.query(` + CREATE INDEX idx_auth_sessions_user_id ON auth_sessions (user_id) + `); + await queryRunner.query(` + CREATE INDEX idx_auth_sessions_expires_at ON auth_sessions (expires_at) + `); + await queryRunner.query(` + CREATE INDEX idx_auth_sessions_revoked_at ON auth_sessions (revoked_at) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query('DROP TABLE auth_sessions'); + } +} diff --git a/api-v3/src/database/migrations/1786548002000-phase-a-audit-events.ts b/api-v3/src/database/migrations/1786548002000-phase-a-audit-events.ts new file mode 100644 index 0000000..03f147d --- /dev/null +++ b/api-v3/src/database/migrations/1786548002000-phase-a-audit-events.ts @@ -0,0 +1,58 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class PhaseAAuditEvents1786548002000 implements MigrationInterface { + name = 'PhaseAAuditEvents1786548002000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE audit_events ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + occurred_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + actor_user_id uuid, + actor_username varchar(80), + action varchar(100) NOT NULL, + entity_type varchar(100), + entity_id varchar(255), + request_id varchar(128), + source varchar(32) NOT NULL, + ip inet, + user_agent text, + before_data jsonb, + after_data jsonb, + metadata jsonb, + CONSTRAINT fk_audit_events_actor_user + FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL, + CONSTRAINT chk_audit_events_json_objects + CHECK ( + (before_data IS NULL OR jsonb_typeof(before_data) = 'object') AND + (after_data IS NULL OR jsonb_typeof(after_data) = 'object') AND + (metadata IS NULL OR jsonb_typeof(metadata) = 'object') + ) + ) + `); + + await queryRunner.query(` + CREATE INDEX idx_audit_events_occurred_at + ON audit_events (occurred_at DESC) + `); + await queryRunner.query(` + CREATE INDEX idx_audit_events_actor_user_id + ON audit_events (actor_user_id) + `); + await queryRunner.query(` + CREATE INDEX idx_audit_events_action ON audit_events (action) + `); + await queryRunner.query(` + CREATE INDEX idx_audit_events_entity + ON audit_events (entity_type, entity_id) + `); + await queryRunner.query(` + CREATE INDEX idx_audit_events_request_id + ON audit_events (request_id) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query('DROP TABLE audit_events'); + } +} diff --git a/api-v3/src/database/migrations/1786548003000-phase-a-seed-roles-permissions.ts b/api-v3/src/database/migrations/1786548003000-phase-a-seed-roles-permissions.ts new file mode 100644 index 0000000..5b1d441 --- /dev/null +++ b/api-v3/src/database/migrations/1786548003000-phase-a-seed-roles-permissions.ts @@ -0,0 +1,137 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +const permissionCodes = [ + 'dashboard.read', + 'users.read', + 'users.create', + 'users.update', + 'users.change_status', + 'users.assign_roles', + 'roles.read', + 'roles.manage', + 'audit.read', +] as const; + +const roleCodes = [ + 'admin', + 'director', + 'supervisor', + 'inspector', + 'auditor', +] as const; + +const rolePermissionValues = ` + ('admin', 'dashboard.read'), + ('admin', 'users.read'), + ('admin', 'users.create'), + ('admin', 'users.update'), + ('admin', 'users.change_status'), + ('admin', 'users.assign_roles'), + ('admin', 'roles.read'), + ('admin', 'roles.manage'), + ('admin', 'audit.read'), + ('director', 'dashboard.read'), + ('director', 'users.read'), + ('director', 'roles.read'), + ('director', 'audit.read'), + ('supervisor', 'dashboard.read'), + ('supervisor', 'users.read'), + ('supervisor', 'roles.read'), + ('inspector', 'dashboard.read'), + ('inspector', 'roles.read'), + ('auditor', 'dashboard.read'), + ('auditor', 'roles.read'), + ('auditor', 'audit.read') +`; + +export class PhaseASeedRolesPermissions1786548003000 + implements MigrationInterface +{ + name = 'PhaseASeedRolesPermissions1786548003000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + INSERT INTO permissions (code, description) + VALUES + ('dashboard.read', 'Ver el panel general'), + ('users.read', 'Listar y consultar usuarios'), + ('users.create', 'Crear usuarios'), + ('users.update', 'Editar usuarios'), + ('users.change_status', 'Activar o desactivar usuarios'), + ('users.assign_roles', 'Asignar roles a usuarios'), + ('roles.read', 'Listar y consultar roles y permisos'), + ('roles.manage', 'Administrar roles y su matriz de permisos'), + ('audit.read', 'Consultar eventos de auditoría') + ON CONFLICT (code) DO UPDATE + SET description = EXCLUDED.description + `); + + await queryRunner.query(` + INSERT INTO roles (code, name, description, is_system) + VALUES + ('admin', 'Administrador', 'Administración total del sistema', true), + ('director', 'Director', 'Consulta directiva y auditoría', true), + ('supervisor', 'Supervisor', 'Supervisión operativa', true), + ('inspector', 'Inspector', 'Operación de inspecciones', true), + ('auditor', 'Auditor', 'Consulta de información y auditoría', true) + ON CONFLICT (code) DO UPDATE + SET + name = EXCLUDED.name, + description = EXCLUDED.description, + is_system = true, + updated_at = CURRENT_TIMESTAMP + `); + + await queryRunner.query(` + WITH mapping(role_code, permission_code) AS ( + VALUES ${rolePermissionValues} + ) + 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 (role_id, permission_id) DO NOTHING + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + WITH mapping(role_code, permission_code) AS ( + VALUES ${rolePermissionValues} + ) + DELETE FROM role_permissions role_permission + USING roles role, permissions permission, mapping + WHERE role_permission.role_id = role.id + AND role_permission.permission_id = permission.id + AND role.code = mapping.role_code + AND permission.code = mapping.permission_code + `); + + await queryRunner.query( + ` + DELETE FROM roles role + WHERE role.code = ANY($1::varchar[]) + AND role.is_system = true + AND NOT EXISTS ( + SELECT 1 FROM user_roles user_role WHERE user_role.role_id = role.id + ) + `, + [roleCodes], + ); + + await queryRunner.query( + ` + DELETE FROM permissions permission + WHERE permission.code = ANY($1::varchar[]) + AND NOT EXISTS ( + SELECT 1 + FROM role_permissions role_permission + WHERE role_permission.permission_id = permission.id + ) + `, + [permissionCodes], + ); + } +} diff --git a/api-v3/src/database/migrations/1786548004000-phase-a-runtime-grants.ts b/api-v3/src/database/migrations/1786548004000-phase-a-runtime-grants.ts new file mode 100644 index 0000000..48c6b1b --- /dev/null +++ b/api-v3/src/database/migrations/1786548004000-phase-a-runtime-grants.ts @@ -0,0 +1,86 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +const phaseATables = [ + 'users', + 'roles', + 'permissions', + 'user_roles', + 'role_permissions', + 'auth_sessions', + 'audit_events', +] as const; + +function quoteIdentifier(identifier: string): string { + return `"${identifier.replaceAll('"', '""')}"`; +} + +async function databaseContext(queryRunner: QueryRunner): Promise<{ + appRole: string; + ownerRole: string; + databaseName: string; +}> { + const appRole = process.env.DB_APP_USER; + if (!appRole) throw new Error('Missing required environment variable: DB_APP_USER'); + + const roleRows = (await queryRunner.query( + 'SELECT 1 FROM pg_roles WHERE rolname = $1', + [appRole], + )) as unknown[]; + if (roleRows.length !== 1) throw new Error('Configured DB_APP_USER does not exist'); + + const [context] = (await queryRunner.query(` + SELECT current_user AS "ownerRole", current_database() AS "databaseName" + `)) as Array<{ ownerRole: string; databaseName: string }>; + + if (!context) throw new Error('Could not resolve database migration context'); + return { appRole, ...context }; +} + +export class PhaseARuntimeGrants1786548004000 implements MigrationInterface { + name = 'PhaseARuntimeGrants1786548004000'; + + public async up(queryRunner: QueryRunner): Promise { + const { appRole, ownerRole, databaseName } = await databaseContext(queryRunner); + const applicationRole = quoteIdentifier(appRole); + const migrationOwner = quoteIdentifier(ownerRole); + const database = quoteIdentifier(databaseName); + const tables = phaseATables.map(quoteIdentifier).join(', '); + + await queryRunner.query(`GRANT CONNECT ON DATABASE ${database} TO ${applicationRole}`); + await queryRunner.query(`GRANT USAGE ON SCHEMA public TO ${applicationRole}`); + await queryRunner.query(` + GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE ${tables} TO ${applicationRole} + `); + await queryRunner.query(` + GRANT USAGE, SELECT, UPDATE ON ALL SEQUENCES IN SCHEMA public + TO ${applicationRole} + `); + await queryRunner.query(` + ALTER DEFAULT PRIVILEGES FOR ROLE ${migrationOwner} IN SCHEMA public + GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO ${applicationRole} + `); + await queryRunner.query(` + ALTER DEFAULT PRIVILEGES FOR ROLE ${migrationOwner} IN SCHEMA public + GRANT USAGE, SELECT, UPDATE ON SEQUENCES TO ${applicationRole} + `); + } + + public async down(queryRunner: QueryRunner): Promise { + const { appRole, ownerRole } = await databaseContext(queryRunner); + const applicationRole = quoteIdentifier(appRole); + const migrationOwner = quoteIdentifier(ownerRole); + const tables = phaseATables.map(quoteIdentifier).join(', '); + + await queryRunner.query(` + ALTER DEFAULT PRIVILEGES FOR ROLE ${migrationOwner} IN SCHEMA public + REVOKE SELECT, INSERT, UPDATE, DELETE ON TABLES FROM ${applicationRole} + `); + await queryRunner.query(` + ALTER DEFAULT PRIVILEGES FOR ROLE ${migrationOwner} IN SCHEMA public + REVOKE USAGE, SELECT, UPDATE ON SEQUENCES FROM ${applicationRole} + `); + await queryRunner.query(` + REVOKE SELECT, INSERT, UPDATE, DELETE ON TABLE ${tables} FROM ${applicationRole} + `); + } +} diff --git a/api-v3/src/database/migrations/1786548005000-phase-a2-session-security-indexes.ts b/api-v3/src/database/migrations/1786548005000-phase-a2-session-security-indexes.ts new file mode 100644 index 0000000..45cf00b --- /dev/null +++ b/api-v3/src/database/migrations/1786548005000-phase-a2-session-security-indexes.ts @@ -0,0 +1,24 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class PhaseA2SessionSecurityIndexes1786548005000 + implements MigrationInterface +{ + name = 'PhaseA2SessionSecurityIndexes1786548005000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE INDEX idx_users_locked_until ON users (locked_until) + `); + await queryRunner.query(` + CREATE INDEX idx_auth_sessions_replaced_by_session_id + ON auth_sessions (replaced_by_session_id) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + 'DROP INDEX idx_auth_sessions_replaced_by_session_id', + ); + await queryRunner.query('DROP INDEX idx_users_locked_until'); + } +} diff --git a/api-v3/src/database/migrations/1786636800000-phase-b1-asset-master.ts b/api-v3/src/database/migrations/1786636800000-phase-b1-asset-master.ts new file mode 100644 index 0000000..a547a8d --- /dev/null +++ b/api-v3/src/database/migrations/1786636800000-phase-b1-asset-master.ts @@ -0,0 +1,340 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +const newPermissions = [ + 'asset_types.read', + 'asset_types.manage', + 'assets.read', + 'assets.create', + 'assets.update', + 'assets.change_status', +] as const; + +const rolePermissionValues = ` + ('admin', 'asset_types.read'), + ('admin', 'asset_types.manage'), + ('admin', 'assets.read'), + ('admin', 'assets.create'), + ('admin', 'assets.update'), + ('admin', 'assets.change_status'), + ('director', 'asset_types.read'), + ('director', 'assets.read'), + ('director', 'assets.change_status'), + ('supervisor', 'asset_types.read'), + ('supervisor', 'assets.read'), + ('supervisor', 'assets.create'), + ('supervisor', 'assets.update'), + ('supervisor', 'assets.change_status'), + ('inspector', 'asset_types.read'), + ('inspector', 'assets.read'), + ('inspector', 'assets.create'), + ('inspector', 'assets.update'), + ('auditor', 'asset_types.read'), + ('auditor', 'assets.read') +`; + +function quoteIdentifier(identifier: string): string { + return `"${identifier.replaceAll('"', '""')}"`; +} + +export class PhaseB1AssetMaster1786636800000 implements MigrationInterface { + name = 'PhaseB1AssetMaster1786636800000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TYPE asset_information_status AS ENUM ( + 'DRAFT', 'PENDING_SURVEY', 'SURVEYED', 'VALIDATED', + 'OBSERVED', 'OUTDATED', 'INACTIVE' + ) + `); + await queryRunner.query(` + CREATE TYPE asset_attribute_data_type AS ENUM ( + 'TEXT', 'NUMBER', 'BOOLEAN', 'DATE', 'DATETIME', 'SELECT' + ) + `); + + await queryRunner.query(` + CREATE TABLE asset_types ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + code varchar(80) NOT NULL, + name varchar(160) NOT NULL, + description text NOT NULL DEFAULT '', + can_be_root boolean NOT NULL DEFAULT false, + is_active boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_by uuid, + updated_by uuid, + CONSTRAINT fk_asset_types_created_by FOREIGN KEY (created_by) + REFERENCES users(id) ON DELETE SET NULL, + CONSTRAINT fk_asset_types_updated_by FOREIGN KEY (updated_by) + REFERENCES users(id) ON DELETE SET NULL + ) + `); + await queryRunner.query( + 'CREATE UNIQUE INDEX uq_asset_types_code_ci ON asset_types (LOWER(code))', + ); + await queryRunner.query( + 'CREATE INDEX idx_asset_types_is_active ON asset_types (is_active)', + ); + + await queryRunner.query(` + CREATE TABLE asset_type_parent_rules ( + child_type_id uuid NOT NULL, + parent_type_id uuid NOT NULL, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT pk_asset_type_parent_rules + PRIMARY KEY (child_type_id, parent_type_id), + CONSTRAINT chk_asset_type_parent_rules_not_self + CHECK (child_type_id <> parent_type_id), + CONSTRAINT fk_asset_type_parent_rules_child FOREIGN KEY (child_type_id) + REFERENCES asset_types(id) ON DELETE CASCADE, + CONSTRAINT fk_asset_type_parent_rules_parent FOREIGN KEY (parent_type_id) + REFERENCES asset_types(id) ON DELETE RESTRICT + ) + `); + await queryRunner.query(` + CREATE INDEX idx_asset_type_parent_rules_parent + ON asset_type_parent_rules (parent_type_id) + `); + + await queryRunner.query(` + CREATE TABLE asset_attribute_definitions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + asset_type_id uuid NOT NULL, + code varchar(80) NOT NULL, + name varchar(160) NOT NULL, + data_type asset_attribute_data_type NOT NULL, + is_required boolean NOT NULL DEFAULT false, + is_active boolean NOT NULL DEFAULT true, + unit varchar(40), + options jsonb, + sort_order integer NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT chk_asset_attribute_sort_order CHECK (sort_order >= 0), + CONSTRAINT chk_asset_attribute_select_options CHECK ( + (data_type = 'SELECT' AND options IS NOT NULL AND jsonb_typeof(options) = 'array') + OR (data_type <> 'SELECT' AND options IS NULL) + ), + CONSTRAINT fk_asset_attribute_definitions_type FOREIGN KEY (asset_type_id) + REFERENCES asset_types(id) ON DELETE RESTRICT + ) + `); + await queryRunner.query(` + CREATE UNIQUE INDEX uq_asset_attribute_definitions_code_ci + ON asset_attribute_definitions (asset_type_id, LOWER(code)) + `); + await queryRunner.query(` + CREATE INDEX idx_asset_attribute_definitions_type + ON asset_attribute_definitions (asset_type_id, sort_order) + `); + await queryRunner.query(` + CREATE INDEX idx_asset_attribute_definitions_active + ON asset_attribute_definitions (is_active) + `); + + await queryRunner.query(` + CREATE TABLE assets ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + asset_type_id uuid NOT NULL, + parent_id uuid, + code varchar(120) NOT NULL, + name varchar(200) NOT NULL, + description text, + information_status asset_information_status NOT NULL DEFAULT 'DRAFT', + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_by uuid, + updated_by uuid, + CONSTRAINT chk_assets_not_self_parent CHECK (id <> parent_id), + CONSTRAINT fk_assets_type FOREIGN KEY (asset_type_id) + REFERENCES asset_types(id) ON DELETE RESTRICT, + CONSTRAINT fk_assets_parent FOREIGN KEY (parent_id) + REFERENCES assets(id) ON DELETE RESTRICT, + CONSTRAINT fk_assets_created_by FOREIGN KEY (created_by) + REFERENCES users(id) ON DELETE SET NULL, + CONSTRAINT fk_assets_updated_by FOREIGN KEY (updated_by) + REFERENCES users(id) ON DELETE SET NULL + ) + `); + await queryRunner.query( + 'CREATE UNIQUE INDEX uq_assets_code_ci ON assets (LOWER(code))', + ); + await queryRunner.query('CREATE INDEX idx_assets_type_id ON assets (asset_type_id)'); + await queryRunner.query('CREATE INDEX idx_assets_parent_id ON assets (parent_id)'); + await queryRunner.query( + 'CREATE INDEX idx_assets_information_status ON assets (information_status)', + ); + await queryRunner.query(` + CREATE FUNCTION enforce_asset_hierarchy_rules() + RETURNS trigger + LANGUAGE plpgsql + AS $$ + BEGIN + IF NEW.parent_id IS NULL THEN + IF NOT EXISTS ( + SELECT 1 FROM asset_types + WHERE id = NEW.asset_type_id AND can_be_root = true + ) THEN + RAISE EXCEPTION USING + ERRCODE = '23514', + MESSAGE = 'asset type does not allow root assets'; + END IF; + ELSE + IF NEW.parent_id = NEW.id THEN + RAISE EXCEPTION USING + ERRCODE = '23514', + MESSAGE = 'asset cannot be its own parent'; + END IF; + IF NOT EXISTS ( + SELECT 1 + FROM assets parent + INNER JOIN asset_type_parent_rules rule + ON rule.parent_type_id = parent.asset_type_id + AND rule.child_type_id = NEW.asset_type_id + WHERE parent.id = NEW.parent_id + ) THEN + RAISE EXCEPTION USING + ERRCODE = '23514', + MESSAGE = 'asset parent type is not allowed'; + END IF; + IF EXISTS ( + WITH RECURSIVE ancestors AS ( + SELECT id, parent_id FROM assets WHERE id = NEW.parent_id + UNION ALL + SELECT parent.id, parent.parent_id + FROM assets parent + INNER JOIN ancestors current ON parent.id = current.parent_id + ) + SELECT 1 FROM ancestors WHERE id = NEW.id + ) THEN + RAISE EXCEPTION USING + ERRCODE = '23514', + MESSAGE = 'asset hierarchy cycle detected'; + END IF; + END IF; + RETURN NEW; + END + $$ + `); + await queryRunner.query(` + CREATE TRIGGER trg_assets_hierarchy_rules + BEFORE INSERT OR UPDATE OF asset_type_id, parent_id ON assets + FOR EACH ROW EXECUTE FUNCTION enforce_asset_hierarchy_rules() + `); + + await queryRunner.query(` + CREATE TABLE asset_attribute_values ( + asset_id uuid NOT NULL, + definition_id uuid NOT NULL, + value jsonb NOT NULL, + updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by uuid, + CONSTRAINT pk_asset_attribute_values PRIMARY KEY (asset_id, definition_id), + CONSTRAINT fk_asset_attribute_values_asset FOREIGN KEY (asset_id) + REFERENCES assets(id) ON DELETE CASCADE, + CONSTRAINT fk_asset_attribute_values_definition FOREIGN KEY (definition_id) + REFERENCES asset_attribute_definitions(id) ON DELETE RESTRICT, + CONSTRAINT fk_asset_attribute_values_updated_by FOREIGN KEY (updated_by) + REFERENCES users(id) ON DELETE SET NULL + ) + `); + await queryRunner.query(` + CREATE INDEX idx_asset_attribute_values_definition + ON asset_attribute_values (definition_id) + `); + await queryRunner.query(` + CREATE FUNCTION enforce_asset_attribute_type_match() + RETURNS trigger + LANGUAGE plpgsql + AS $$ + BEGIN + IF ( + SELECT asset.asset_type_id IS DISTINCT FROM definition.asset_type_id + FROM assets asset, asset_attribute_definitions definition + WHERE asset.id = NEW.asset_id AND definition.id = NEW.definition_id + ) THEN + RAISE EXCEPTION USING + ERRCODE = '23514', + MESSAGE = 'attribute definition does not belong to asset type'; + END IF; + RETURN NEW; + END + $$ + `); + await queryRunner.query(` + CREATE TRIGGER trg_asset_attribute_type_match + BEFORE INSERT OR UPDATE OF asset_id, definition_id ON asset_attribute_values + FOR EACH ROW EXECUTE FUNCTION enforce_asset_attribute_type_match() + `); + + await queryRunner.query(` + INSERT INTO permissions (code, description) + VALUES + ('asset_types.read', 'Consultar tipos y atributos del Maestro de Activos'), + ('asset_types.manage', 'Administrar tipos, jerarquías permitidas y atributos'), + ('assets.read', 'Listar y consultar activos'), + ('assets.create', 'Crear activos'), + ('assets.update', 'Editar activos y sus atributos'), + ('assets.change_status', 'Cambiar el estado de información de activos') + ON CONFLICT (code) DO UPDATE SET description = EXCLUDED.description + `); + await queryRunner.query(` + WITH mapping(role_code, permission_code) AS (VALUES ${rolePermissionValues}) + 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 (role_id, permission_id) DO NOTHING + `); + + const appRole = process.env.DB_APP_USER; + if (!appRole) throw new Error('Missing required environment variable: DB_APP_USER'); + const roleRows = (await queryRunner.query( + 'SELECT 1 FROM pg_roles WHERE rolname = $1', + [appRole], + )) as unknown[]; + if (roleRows.length !== 1) throw new Error('Configured DB_APP_USER does not exist'); + const applicationRole = quoteIdentifier(appRole); + await queryRunner.query(` + GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE + asset_types, + asset_type_parent_rules, + asset_attribute_definitions, + assets, + asset_attribute_values + TO ${applicationRole} + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + WITH mapping(role_code, permission_code) AS (VALUES ${rolePermissionValues}) + DELETE FROM role_permissions role_permission + USING roles role, permissions permission, mapping + WHERE role_permission.role_id = role.id + AND role_permission.permission_id = permission.id + AND role.code = mapping.role_code + AND permission.code = mapping.permission_code + `); + await queryRunner.query( + `DELETE FROM permissions WHERE code = ANY($1::varchar[]) + AND NOT EXISTS ( + SELECT 1 FROM role_permissions WHERE permission_id = permissions.id + )`, + [newPermissions], + ); + await queryRunner.query('DROP TRIGGER trg_asset_attribute_type_match ON asset_attribute_values'); + await queryRunner.query('DROP FUNCTION enforce_asset_attribute_type_match'); + await queryRunner.query('DROP TABLE asset_attribute_values'); + await queryRunner.query('DROP TRIGGER trg_assets_hierarchy_rules ON assets'); + await queryRunner.query('DROP FUNCTION enforce_asset_hierarchy_rules'); + await queryRunner.query('DROP TABLE assets'); + await queryRunner.query('DROP TABLE asset_attribute_definitions'); + await queryRunner.query('DROP TABLE asset_type_parent_rules'); + await queryRunner.query('DROP TABLE asset_types'); + await queryRunner.query('DROP TYPE asset_attribute_data_type'); + await queryRunner.query('DROP TYPE asset_information_status'); + } +} diff --git a/api-v3/src/database/migrations/1786723200000-phase-b2-asset-geometries.ts b/api-v3/src/database/migrations/1786723200000-phase-b2-asset-geometries.ts new file mode 100644 index 0000000..b22695c --- /dev/null +++ b/api-v3/src/database/migrations/1786723200000-phase-b2-asset-geometries.ts @@ -0,0 +1,114 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +const permissionCode = 'assets.update_geometry'; + +const rolePermissionValues = ` + ('admin', 'assets.update_geometry'), + ('supervisor', 'assets.update_geometry'), + ('inspector', 'assets.update_geometry') +`; + +function quoteIdentifier(identifier: string): string { + return `"${identifier.replaceAll('"', '""')}"`; +} + +export class PhaseB2AssetGeometries1786723200000 + implements MigrationInterface +{ + name = 'PhaseB2AssetGeometries1786723200000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TYPE asset_geometry_source AS ENUM ( + 'WEB', 'ANDROID', 'IMPORT', 'SURVEY' + ) + `); + + await queryRunner.query(` + CREATE TABLE asset_geometries ( + asset_id uuid PRIMARY KEY, + geometry geometry(Geometry, 4326) NOT NULL, + geometry_type varchar(20) NOT NULL, + source asset_geometry_source NOT NULL DEFAULT 'WEB', + accuracy_m numeric(12, 3), + captured_at timestamptz, + device_label varchar(255), + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by uuid, + CONSTRAINT chk_asset_geometries_type + CHECK (geometry_type IN ('POINT', 'LINESTRING', 'POLYGON')), + CONSTRAINT chk_asset_geometries_type_matches + CHECK (geometry_type = UPPER(GeometryType(geometry))), + CONSTRAINT chk_asset_geometries_valid + CHECK (ST_IsValid(geometry)), + CONSTRAINT chk_asset_geometries_accuracy + CHECK (accuracy_m IS NULL OR accuracy_m >= 0), + CONSTRAINT fk_asset_geometries_asset FOREIGN KEY (asset_id) + REFERENCES assets(id) ON DELETE CASCADE, + CONSTRAINT fk_asset_geometries_updated_by FOREIGN KEY (updated_by) + REFERENCES users(id) ON DELETE SET NULL + ) + `); + await queryRunner.query(` + CREATE INDEX idx_asset_geometries_geometry + ON asset_geometries USING GIST (geometry) + `); + await queryRunner.query(` + CREATE INDEX idx_asset_geometries_type + ON asset_geometries (geometry_type) + `); + await queryRunner.query(` + CREATE INDEX idx_asset_geometries_updated_at + ON asset_geometries (updated_at DESC) + `); + + await queryRunner.query(` + INSERT INTO permissions (code, description) + VALUES ('assets.update_geometry', 'Crear, actualizar o quitar la ubicación geográfica de activos') + ON CONFLICT (code) DO UPDATE SET description = EXCLUDED.description + `); + await queryRunner.query(` + WITH mapping(role_code, permission_code) AS (VALUES ${rolePermissionValues}) + 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 (role_id, permission_id) DO NOTHING + `); + + const appRole = process.env.DB_APP_USER; + if (!appRole) throw new Error('Missing required environment variable: DB_APP_USER'); + const roleRows = (await queryRunner.query( + 'SELECT 1 FROM pg_roles WHERE rolname = $1', + [appRole], + )) as unknown[]; + if (roleRows.length !== 1) throw new Error('Configured DB_APP_USER does not exist'); + await queryRunner.query(` + GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE asset_geometries + TO ${quoteIdentifier(appRole)} + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + WITH mapping(role_code, permission_code) AS (VALUES ${rolePermissionValues}) + DELETE FROM role_permissions role_permission + USING roles role, permissions permission, mapping + WHERE role_permission.role_id = role.id + AND role_permission.permission_id = permission.id + AND role.code = mapping.role_code + AND permission.code = mapping.permission_code + `); + await queryRunner.query( + `DELETE FROM permissions WHERE code = $1 + AND NOT EXISTS ( + SELECT 1 FROM role_permissions WHERE permission_id = permissions.id + )`, + [permissionCode], + ); + await queryRunner.query('DROP TABLE asset_geometries'); + await queryRunner.query('DROP TYPE asset_geometry_source'); + } +} diff --git a/api-v3/src/database/migrations/1786723201000-phase-b3-asset-versions.ts b/api-v3/src/database/migrations/1786723201000-phase-b3-asset-versions.ts new file mode 100644 index 0000000..9a7ff12 --- /dev/null +++ b/api-v3/src/database/migrations/1786723201000-phase-b3-asset-versions.ts @@ -0,0 +1,203 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +const permissionCode = 'assets.read_history'; + +const rolePermissionValues = ` + ('admin', 'assets.read_history'), + ('director', 'assets.read_history'), + ('supervisor', 'assets.read_history'), + ('inspector', 'assets.read_history'), + ('auditor', 'assets.read_history') +`; + +function quoteIdentifier(identifier: string): string { + return `"${identifier.replaceAll('"', '""')}"`; +} + +export class PhaseB3AssetVersions1786723201000 implements MigrationInterface { + name = 'PhaseB3AssetVersions1786723201000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE assets + ADD COLUMN current_version integer NOT NULL DEFAULT 0, + ADD CONSTRAINT chk_assets_current_version CHECK (current_version >= 0) + `); + + await queryRunner.query(` + CREATE TABLE asset_versions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + asset_id uuid NOT NULL, + version_number integer NOT NULL, + change_type varchar(32) NOT NULL, + changed_fields text[] NOT NULL DEFAULT '{}', + snapshot jsonb NOT NULL, + occurred_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + actor_user_id uuid, + actor_username varchar(80), + source varchar(32) NOT NULL, + request_id varchar(128), + CONSTRAINT uq_asset_versions_asset_number + UNIQUE (asset_id, version_number), + CONSTRAINT chk_asset_versions_number CHECK (version_number >= 1), + CONSTRAINT chk_asset_versions_change_type CHECK (change_type IN ( + 'BASELINE', 'CREATED', 'UPDATED', 'STATUS_CHANGED', + 'GEOMETRY_UPDATED', 'GEOMETRY_REMOVED' + )), + CONSTRAINT chk_asset_versions_source CHECK (source IN ( + 'WEB', 'ANDROID', 'SYSTEM', 'IMPORT' + )), + CONSTRAINT chk_asset_versions_snapshot_object + CHECK (jsonb_typeof(snapshot) = 'object'), + CONSTRAINT fk_asset_versions_asset FOREIGN KEY (asset_id) + REFERENCES assets(id) ON DELETE RESTRICT, + CONSTRAINT fk_asset_versions_actor FOREIGN KEY (actor_user_id) + REFERENCES users(id) ON DELETE SET NULL + ) + `); + await queryRunner.query(` + CREATE INDEX idx_asset_versions_asset_number + ON asset_versions (asset_id, version_number DESC) + `); + await queryRunner.query(` + CREATE INDEX idx_asset_versions_occurred_at + ON asset_versions (occurred_at DESC) + `); + await queryRunner.query(` + CREATE INDEX idx_asset_versions_change_type + ON asset_versions (change_type) + `); + await queryRunner.query(` + CREATE INDEX idx_asset_versions_snapshot_gin + ON asset_versions USING GIN (snapshot jsonb_path_ops) + `); + + await queryRunner.query('UPDATE assets SET current_version = 1'); + await queryRunner.query(` + INSERT INTO asset_versions ( + asset_id, version_number, change_type, changed_fields, snapshot, + occurred_at, actor_user_id, actor_username, source + ) + SELECT + asset.id, + 1, + 'BASELINE', + ARRAY['baseline']::text[], + JSONB_BUILD_OBJECT( + 'id', asset.id, + 'code', asset.code, + 'name', asset.name, + 'description', asset.description, + 'type', JSONB_BUILD_OBJECT( + 'id', asset_type.id, + 'code', asset_type.code, + 'name', asset_type.name + ), + 'parent', CASE WHEN parent.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( + 'id', parent.id, + 'code', parent.code, + 'name', parent.name + ) END, + 'informationStatus', asset.information_status, + 'attributes', COALESCE(( + SELECT JSONB_AGG(JSONB_BUILD_OBJECT( + 'definitionId', definition.id, + 'code', definition.code, + 'name', definition.name, + 'dataType', definition.data_type, + 'isRequired', definition.is_required, + 'unit', definition.unit, + 'options', definition.options, + 'sortOrder', definition.sort_order, + 'value', value.value + ) ORDER BY definition.sort_order, definition.name) + FROM asset_attribute_definitions definition + LEFT JOIN asset_attribute_values value + ON value.definition_id = definition.id + AND value.asset_id = asset.id + WHERE definition.asset_type_id = asset.asset_type_id + AND definition.is_active = true + ), '[]'::jsonb), + 'geometry', CASE WHEN geometry.asset_id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( + 'assetId', geometry.asset_id, + 'geometry', ST_AsGeoJSON(geometry.geometry)::jsonb, + 'geometryType', geometry.geometry_type, + 'source', geometry.source, + 'accuracyM', geometry.accuracy_m::double precision, + 'capturedAt', geometry.captured_at, + 'deviceLabel', geometry.device_label, + 'createdAt', geometry.created_at, + 'updatedAt', geometry.updated_at, + 'updatedBy', geometry.updated_by + ) END, + 'createdAt', asset.created_at, + 'updatedAt', asset.updated_at, + 'createdBy', asset.created_by, + 'updatedBy', asset.updated_by, + 'currentVersion', 1 + ), + asset.updated_at, + asset.updated_by, + actor.username, + 'SYSTEM' + FROM assets asset + INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id + LEFT JOIN assets parent ON parent.id = asset.parent_id + LEFT JOIN asset_geometries geometry ON geometry.asset_id = asset.id + LEFT JOIN users actor ON actor.id = asset.updated_by + `); + + await queryRunner.query(` + INSERT INTO permissions (code, description) + VALUES ('assets.read_history', 'Consultar versiones históricas completas de activos') + ON CONFLICT (code) DO UPDATE SET description = EXCLUDED.description + `); + await queryRunner.query(` + WITH mapping(role_code, permission_code) AS (VALUES ${rolePermissionValues}) + 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 (role_id, permission_id) DO NOTHING + `); + + const appRole = process.env.DB_APP_USER; + if (!appRole) throw new Error('Missing required environment variable: DB_APP_USER'); + const roleRows = (await queryRunner.query( + 'SELECT 1 FROM pg_roles WHERE rolname = $1', + [appRole], + )) as unknown[]; + if (roleRows.length !== 1) throw new Error('Configured DB_APP_USER does not exist'); + await queryRunner.query(` + GRANT SELECT, INSERT ON TABLE asset_versions + TO ${quoteIdentifier(appRole)} + `); + await queryRunner.query(` + REVOKE UPDATE, DELETE ON TABLE asset_versions + FROM ${quoteIdentifier(appRole)} + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + WITH mapping(role_code, permission_code) AS (VALUES ${rolePermissionValues}) + DELETE FROM role_permissions role_permission + USING roles role, permissions permission, mapping + WHERE role_permission.role_id = role.id + AND role_permission.permission_id = permission.id + AND role.code = mapping.role_code + AND permission.code = mapping.permission_code + `); + await queryRunner.query( + `DELETE FROM permissions WHERE code = $1 + AND NOT EXISTS ( + SELECT 1 FROM role_permissions WHERE permission_id = permissions.id + )`, + [permissionCode], + ); + await queryRunner.query('DROP TABLE asset_versions'); + await queryRunner.query('ALTER TABLE assets DROP CONSTRAINT chk_assets_current_version'); + await queryRunner.query('ALTER TABLE assets DROP COLUMN current_version'); + } +} diff --git a/api-v3/src/database/migrations/1786723202000-phase-b4-asset-media.ts b/api-v3/src/database/migrations/1786723202000-phase-b4-asset-media.ts new file mode 100644 index 0000000..60cd36e --- /dev/null +++ b/api-v3/src/database/migrations/1786723202000-phase-b4-asset-media.ts @@ -0,0 +1,171 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +const newPermissions = ['assets.read_media', 'assets.manage_media'] as const; + +const rolePermissionValues = ` + ('admin', 'assets.read_media'), + ('admin', 'assets.manage_media'), + ('director', 'assets.read_media'), + ('supervisor', 'assets.read_media'), + ('supervisor', 'assets.manage_media'), + ('inspector', 'assets.read_media'), + ('inspector', 'assets.manage_media'), + ('auditor', 'assets.read_media') +`; + +function quoteIdentifier(identifier: string): string { + return `"${identifier.replaceAll('"', '""')}"`; +} + +export class PhaseB4AssetMedia1786723202000 implements MigrationInterface { + name = 'PhaseB4AssetMedia1786723202000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE asset_media ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + asset_id uuid NOT NULL, + kind varchar(20) NOT NULL, + original_name varchar(255) NOT NULL, + stored_name varchar(80) NOT NULL, + mime_type varchar(100) NOT NULL, + size_bytes bigint NOT NULL, + sha256 char(64) NOT NULL, + title varchar(200), + description text, + captured_at timestamptz, + latitude numeric(9, 6), + longitude numeric(9, 6), + accuracy_m numeric(12, 3), + source varchar(20) NOT NULL, + uploaded_by uuid, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at timestamptz, + deleted_by uuid, + CONSTRAINT uq_asset_media_stored_name UNIQUE (stored_name), + CONSTRAINT chk_asset_media_kind CHECK (kind IN ('PHOTO', 'DOCUMENT')), + CONSTRAINT chk_asset_media_source CHECK (source IN ('WEB', 'ANDROID', 'IMPORT')), + CONSTRAINT chk_asset_media_mime CHECK (mime_type IN ( + 'image/jpeg', 'image/png', 'image/webp', 'application/pdf' + )), + CONSTRAINT chk_asset_media_size CHECK ( + size_bytes > 0 AND size_bytes <= 15728640 + ), + CONSTRAINT chk_asset_media_sha256 CHECK (sha256 ~ '^[0-9a-f]{64}$'), + CONSTRAINT chk_asset_media_coordinates CHECK ( + (latitude IS NULL AND longitude IS NULL) + OR ( + latitude IS NOT NULL + AND longitude IS NOT NULL + AND + latitude BETWEEN -90 AND 90 + AND longitude BETWEEN -180 AND 180 + ) + ), + CONSTRAINT chk_asset_media_accuracy CHECK ( + accuracy_m IS NULL OR accuracy_m >= 0 + ), + CONSTRAINT fk_asset_media_asset FOREIGN KEY (asset_id) + REFERENCES assets(id) ON DELETE RESTRICT, + CONSTRAINT fk_asset_media_uploaded_by FOREIGN KEY (uploaded_by) + REFERENCES users(id) ON DELETE SET NULL, + CONSTRAINT fk_asset_media_deleted_by FOREIGN KEY (deleted_by) + REFERENCES users(id) ON DELETE SET NULL + ) + `); + await queryRunner.query(` + CREATE INDEX idx_asset_media_asset_created + ON asset_media (asset_id, created_at DESC) + `); + await queryRunner.query(` + CREATE INDEX idx_asset_media_sha256 + ON asset_media (sha256) + `); + await queryRunner.query(` + CREATE INDEX idx_asset_media_active + ON asset_media (asset_id, deleted_at) + `); + + await queryRunner.query(` + ALTER TABLE asset_versions + DROP CONSTRAINT chk_asset_versions_change_type + `); + await queryRunner.query(` + ALTER TABLE asset_versions + ADD CONSTRAINT chk_asset_versions_change_type CHECK (change_type IN ( + 'BASELINE', 'CREATED', 'UPDATED', 'STATUS_CHANGED', + 'GEOMETRY_UPDATED', 'GEOMETRY_REMOVED', + 'MEDIA_UPLOADED', 'MEDIA_UPDATED', 'MEDIA_REMOVED' + )) + `); + + await queryRunner.query(` + INSERT INTO permissions (code, description) + VALUES + ('assets.read_media', 'Consultar fotografías y documentos de activos'), + ('assets.manage_media', 'Cargar, editar o retirar archivos de activos') + ON CONFLICT (code) DO UPDATE SET description = EXCLUDED.description + `); + await queryRunner.query(` + WITH mapping(role_code, permission_code) AS (VALUES ${rolePermissionValues}) + 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 (role_id, permission_id) DO NOTHING + `); + + const appRole = process.env.DB_APP_USER; + if (!appRole) throw new Error('Missing required environment variable: DB_APP_USER'); + const roleRows = (await queryRunner.query( + 'SELECT 1 FROM pg_roles WHERE rolname = $1', + [appRole], + )) as unknown[]; + if (roleRows.length !== 1) throw new Error('Configured DB_APP_USER does not exist'); + const applicationRole = quoteIdentifier(appRole); + await queryRunner.query(` + GRANT SELECT, INSERT, UPDATE ON TABLE asset_media TO ${applicationRole} + `); + await queryRunner.query(` + REVOKE DELETE ON TABLE asset_media FROM ${applicationRole} + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + WITH mapping(role_code, permission_code) AS (VALUES ${rolePermissionValues}) + DELETE FROM role_permissions role_permission + USING roles role, permissions permission, mapping + WHERE role_permission.role_id = role.id + AND role_permission.permission_id = permission.id + AND role.code = mapping.role_code + AND permission.code = mapping.permission_code + `); + await queryRunner.query( + `DELETE FROM permissions WHERE code = ANY($1::varchar[]) + AND NOT EXISTS ( + SELECT 1 FROM role_permissions WHERE permission_id = permissions.id + )`, + [newPermissions], + ); + await queryRunner.query(` + UPDATE asset_versions + SET change_type = 'UPDATED' + WHERE change_type IN ('MEDIA_UPLOADED', 'MEDIA_UPDATED', 'MEDIA_REMOVED') + `); + await queryRunner.query(` + ALTER TABLE asset_versions + DROP CONSTRAINT chk_asset_versions_change_type + `); + await queryRunner.query(` + ALTER TABLE asset_versions + ADD CONSTRAINT chk_asset_versions_change_type CHECK (change_type IN ( + 'BASELINE', 'CREATED', 'UPDATED', 'STATUS_CHANGED', + 'GEOMETRY_UPDATED', 'GEOMETRY_REMOVED' + )) + `); + await queryRunner.query('DROP TABLE asset_media'); + } +} diff --git a/api-v3/src/database/migrations/1786723203000-phase-b5-provenance-temporal.ts b/api-v3/src/database/migrations/1786723203000-phase-b5-provenance-temporal.ts new file mode 100644 index 0000000..3dcb923 --- /dev/null +++ b/api-v3/src/database/migrations/1786723203000-phase-b5-provenance-temporal.ts @@ -0,0 +1,191 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +const newPermissions = [ + 'assets.read_provenance', + 'assets.manage_provenance', + 'assets.verify_provenance', + 'assets.read_temporal', +] as const; + +const rolePermissionValues = ` + ('admin', 'assets.read_provenance'), + ('admin', 'assets.manage_provenance'), + ('admin', 'assets.verify_provenance'), + ('admin', 'assets.read_temporal'), + ('director', 'assets.read_provenance'), + ('director', 'assets.verify_provenance'), + ('director', 'assets.read_temporal'), + ('supervisor', 'assets.read_provenance'), + ('supervisor', 'assets.manage_provenance'), + ('supervisor', 'assets.verify_provenance'), + ('supervisor', 'assets.read_temporal'), + ('inspector', 'assets.read_provenance'), + ('inspector', 'assets.manage_provenance'), + ('inspector', 'assets.read_temporal'), + ('auditor', 'assets.read_provenance'), + ('auditor', 'assets.read_temporal') +`; + +export class PhaseB5ProvenanceTemporal1786723203000 implements MigrationInterface { + name = 'PhaseB5ProvenanceTemporal1786723203000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE assets + ADD COLUMN data_origin varchar(32) NOT NULL DEFAULT 'MANUAL', + ADD COLUMN source_name varchar(160), + ADD COLUMN source_reference varchar(255), + ADD COLUMN source_observed_at timestamptz, + ADD COLUMN source_notes text, + ADD COLUMN provenance_verified_at timestamptz, + ADD COLUMN provenance_verified_by uuid, + ADD COLUMN provenance_updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + ADD COLUMN provenance_updated_by uuid, + ADD CONSTRAINT chk_assets_data_origin CHECK (data_origin IN ( + 'MANUAL', 'FIELD_SURVEY', 'PROVIDED_DOCUMENT', 'IMPORT', 'SYSTEM' + )), + ADD CONSTRAINT fk_assets_provenance_verified_by FOREIGN KEY (provenance_verified_by) + REFERENCES users(id) ON DELETE SET NULL, + ADD CONSTRAINT fk_assets_provenance_updated_by FOREIGN KEY (provenance_updated_by) + REFERENCES users(id) ON DELETE SET NULL + `); + await queryRunner.query(` + UPDATE assets + SET provenance_updated_by = updated_by + `); + await queryRunner.query(`CREATE INDEX idx_assets_data_origin ON assets (data_origin)`); + await queryRunner.query(` + CREATE INDEX idx_assets_provenance_verified_at + ON assets (provenance_verified_at) + `); + + await queryRunner.query(` + ALTER TABLE asset_versions + DROP CONSTRAINT chk_asset_versions_change_type + `); + await queryRunner.query(` + ALTER TABLE asset_versions + ADD CONSTRAINT chk_asset_versions_change_type CHECK (change_type IN ( + 'BASELINE', 'CREATED', 'UPDATED', 'STATUS_CHANGED', + 'GEOMETRY_UPDATED', 'GEOMETRY_REMOVED', + 'MEDIA_UPLOADED', 'MEDIA_UPDATED', 'MEDIA_REMOVED', + 'PROVENANCE_BASELINE', 'PROVENANCE_UPDATED', 'PROVENANCE_VERIFIED' + )) + `); + + await queryRunner.query(` + WITH bumped AS ( + UPDATE assets + SET current_version = current_version + 1 + RETURNING * + ) + INSERT INTO asset_versions ( + asset_id, version_number, change_type, changed_fields, snapshot, + actor_user_id, actor_username, source, request_id + ) + SELECT + asset.id, + asset.current_version, + 'PROVENANCE_BASELINE', + ARRAY['provenance']::text[], + COALESCE(previous.snapshot, '{}'::jsonb) || JSONB_BUILD_OBJECT( + 'provenance', JSONB_BUILD_OBJECT( + 'origin', asset.data_origin, + 'sourceName', asset.source_name, + 'sourceReference', asset.source_reference, + 'observedAt', asset.source_observed_at, + 'notes', asset.source_notes, + 'verifiedAt', asset.provenance_verified_at, + 'verifiedBy', asset.provenance_verified_by, + 'updatedAt', asset.provenance_updated_at, + 'updatedBy', asset.provenance_updated_by + ), + 'currentVersion', asset.current_version + ), + NULL, + NULL, + 'SYSTEM', + 'migration:phase-b5-provenance-baseline' + FROM bumped asset + LEFT JOIN LATERAL ( + SELECT version.snapshot + FROM asset_versions version + WHERE version.asset_id = asset.id + ORDER BY version.version_number DESC + LIMIT 1 + ) previous ON true + `); + + await queryRunner.query(` + INSERT INTO permissions (code, description) + VALUES + ('assets.read_provenance', 'Consultar procedencia de datos de activos'), + ('assets.manage_provenance', 'Actualizar procedencia de datos de activos'), + ('assets.verify_provenance', 'Verificar procedencia de datos de activos'), + ('assets.read_temporal', 'Reconstruir el maestro de activos a una fecha') + ON CONFLICT (code) DO UPDATE SET description = EXCLUDED.description + `); + await queryRunner.query(` + WITH mapping(role_code, permission_code) AS (VALUES ${rolePermissionValues}) + 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 (role_id, permission_id) DO NOTHING + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + WITH mapping(role_code, permission_code) AS (VALUES ${rolePermissionValues}) + DELETE FROM role_permissions role_permission + USING roles role, permissions permission, mapping + WHERE role_permission.role_id = role.id + AND role_permission.permission_id = permission.id + AND role.code = mapping.role_code + AND permission.code = mapping.permission_code + `); + await queryRunner.query( + `DELETE FROM permissions WHERE code = ANY($1::varchar[]) + AND NOT EXISTS ( + SELECT 1 FROM role_permissions WHERE permission_id = permissions.id + )`, + [newPermissions], + ); + await queryRunner.query(` + UPDATE asset_versions + SET change_type = 'UPDATED' + WHERE change_type IN ( + 'PROVENANCE_BASELINE', 'PROVENANCE_UPDATED', 'PROVENANCE_VERIFIED' + ) + `); + await queryRunner.query(` + ALTER TABLE asset_versions + DROP CONSTRAINT chk_asset_versions_change_type + `); + await queryRunner.query(` + ALTER TABLE asset_versions + ADD CONSTRAINT chk_asset_versions_change_type CHECK (change_type IN ( + 'BASELINE', 'CREATED', 'UPDATED', 'STATUS_CHANGED', + 'GEOMETRY_UPDATED', 'GEOMETRY_REMOVED', + 'MEDIA_UPLOADED', 'MEDIA_UPDATED', 'MEDIA_REMOVED' + )) + `); + await queryRunner.query(` + ALTER TABLE assets + DROP CONSTRAINT fk_assets_provenance_verified_by, + DROP CONSTRAINT fk_assets_provenance_updated_by, + DROP CONSTRAINT chk_assets_data_origin, + DROP COLUMN data_origin, + DROP COLUMN source_name, + DROP COLUMN source_reference, + DROP COLUMN source_observed_at, + DROP COLUMN source_notes, + DROP COLUMN provenance_verified_at, + DROP COLUMN provenance_verified_by, + DROP COLUMN provenance_updated_at, + DROP COLUMN provenance_updated_by + `); + } +} diff --git a/api-v3/src/database/migrations/1786809600000-phase-c1-survey-planning.ts b/api-v3/src/database/migrations/1786809600000-phase-c1-survey-planning.ts new file mode 100644 index 0000000..093276d --- /dev/null +++ b/api-v3/src/database/migrations/1786809600000-phase-c1-survey-planning.ts @@ -0,0 +1,155 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +const newPermissions = [ + 'surveys.read', + 'surveys.manage', + 'surveys.assign', + 'surveys.execute', +] as const; + +const rolePermissionValues = ` + ('admin', 'surveys.read'), + ('admin', 'surveys.manage'), + ('admin', 'surveys.assign'), + ('admin', 'surveys.execute'), + ('director', 'surveys.read'), + ('supervisor', 'surveys.read'), + ('supervisor', 'surveys.manage'), + ('supervisor', 'surveys.assign'), + ('supervisor', 'surveys.execute'), + ('inspector', 'surveys.read'), + ('inspector', 'surveys.execute'), + ('auditor', 'surveys.read') +`; + +function quoteIdentifier(identifier: string): string { + return `"${identifier.replaceAll('"', '""')}"`; +} + +export class PhaseC1SurveyPlanning1786809600000 implements MigrationInterface { + name = 'PhaseC1SurveyPlanning1786809600000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE survey_campaigns ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + code varchar(80) NOT NULL, + name varchar(200) NOT NULL, + description text, + status varchar(24) NOT NULL DEFAULT 'DRAFT', + planned_start_at timestamptz, + planned_end_at timestamptz, + scope_asset_id uuid, + coordinator_user_id uuid, + created_by uuid, + updated_by uuid, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uq_survey_campaigns_code UNIQUE (code), + CONSTRAINT chk_survey_campaigns_status CHECK (status IN ( + 'DRAFT', 'PLANNED', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED' + )), + CONSTRAINT chk_survey_campaigns_dates CHECK ( + planned_start_at IS NULL OR planned_end_at IS NULL OR planned_end_at >= planned_start_at + ), + CONSTRAINT fk_survey_campaigns_scope_asset FOREIGN KEY (scope_asset_id) + REFERENCES assets(id) ON DELETE RESTRICT, + CONSTRAINT fk_survey_campaigns_coordinator FOREIGN KEY (coordinator_user_id) + REFERENCES users(id) ON DELETE SET NULL, + CONSTRAINT fk_survey_campaigns_created_by FOREIGN KEY (created_by) + REFERENCES users(id) ON DELETE SET NULL, + CONSTRAINT fk_survey_campaigns_updated_by FOREIGN KEY (updated_by) + REFERENCES users(id) ON DELETE SET NULL + ) + `); + await queryRunner.query(`CREATE INDEX idx_survey_campaigns_status ON survey_campaigns (status)`); + await queryRunner.query(`CREATE INDEX idx_survey_campaigns_scope_asset_id ON survey_campaigns (scope_asset_id)`); + await queryRunner.query(`CREATE INDEX idx_survey_campaigns_coordinator_user_id ON survey_campaigns (coordinator_user_id)`); + await queryRunner.query(`CREATE INDEX idx_survey_campaigns_planned_dates ON survey_campaigns (planned_start_at, planned_end_at)`); + + await queryRunner.query(` + CREATE TABLE survey_campaign_targets ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + campaign_id uuid NOT NULL, + asset_id uuid NOT NULL, + assigned_user_id uuid, + status varchar(24) NOT NULL DEFAULT 'PENDING', + due_at timestamptz, + instructions text, + created_by uuid, + updated_by uuid, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uq_survey_campaign_targets_campaign_asset UNIQUE (campaign_id, asset_id), + CONSTRAINT chk_survey_campaign_targets_status CHECK (status IN ( + 'PENDING', 'IN_PROGRESS', 'COMPLETED', 'SKIPPED' + )), + CONSTRAINT fk_survey_campaign_targets_campaign FOREIGN KEY (campaign_id) + REFERENCES survey_campaigns(id) ON DELETE RESTRICT, + CONSTRAINT fk_survey_campaign_targets_asset FOREIGN KEY (asset_id) + REFERENCES assets(id) ON DELETE RESTRICT, + CONSTRAINT fk_survey_campaign_targets_assignee FOREIGN KEY (assigned_user_id) + REFERENCES users(id) ON DELETE SET NULL, + CONSTRAINT fk_survey_campaign_targets_created_by FOREIGN KEY (created_by) + REFERENCES users(id) ON DELETE SET NULL, + CONSTRAINT fk_survey_campaign_targets_updated_by FOREIGN KEY (updated_by) + REFERENCES users(id) ON DELETE SET NULL + ) + `); + await queryRunner.query(`CREATE INDEX idx_survey_campaign_targets_campaign_status ON survey_campaign_targets (campaign_id, status)`); + await queryRunner.query(`CREATE INDEX idx_survey_campaign_targets_asset_id ON survey_campaign_targets (asset_id)`); + await queryRunner.query(`CREATE INDEX idx_survey_campaign_targets_assigned_user_id ON survey_campaign_targets (assigned_user_id)`); + + await queryRunner.query(` + INSERT INTO permissions (code, description) + VALUES + ('surveys.read', 'Consultar planificación de relevamientos'), + ('surveys.manage', 'Crear y actualizar campañas de relevamiento'), + ('surveys.assign', 'Asignar objetivos de relevamiento'), + ('surveys.execute', 'Actualizar el avance de objetivos asignados') + ON CONFLICT (code) DO UPDATE SET description = EXCLUDED.description + `); + await queryRunner.query(` + WITH mapping(role_code, permission_code) AS (VALUES ${rolePermissionValues}) + 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 (role_id, permission_id) DO NOTHING + `); + + const appRole = process.env.DB_APP_USER; + if (!appRole) throw new Error('Missing required environment variable: DB_APP_USER'); + const applicationRole = quoteIdentifier(appRole); + await queryRunner.query(` + GRANT SELECT, INSERT, UPDATE ON TABLE survey_campaigns, survey_campaign_targets + TO ${applicationRole} + `); + await queryRunner.query(` + REVOKE DELETE ON TABLE survey_campaigns, survey_campaign_targets + FROM ${applicationRole} + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + WITH mapping(role_code, permission_code) AS (VALUES ${rolePermissionValues}) + DELETE FROM role_permissions role_permission + USING roles role, permissions permission, mapping + WHERE role_permission.role_id = role.id + AND role_permission.permission_id = permission.id + AND role.code = mapping.role_code + AND permission.code = mapping.permission_code + `); + await queryRunner.query( + `DELETE FROM permissions WHERE code = ANY($1::varchar[]) + AND NOT EXISTS ( + SELECT 1 FROM role_permissions WHERE permission_id = permissions.id + )`, + [newPermissions], + ); + await queryRunner.query(`DROP TABLE survey_campaign_targets`); + await queryRunner.query(`DROP TABLE survey_campaigns`); + } +} diff --git a/api-v3/src/database/migrations/1786896000000-phase-c2-survey-execution.ts b/api-v3/src/database/migrations/1786896000000-phase-c2-survey-execution.ts new file mode 100644 index 0000000..5bd44b6 --- /dev/null +++ b/api-v3/src/database/migrations/1786896000000-phase-c2-survey-execution.ts @@ -0,0 +1,216 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +const newPermissions = [ + 'surveys.read_reports', + 'surveys.capture', + 'surveys.review', +] as const; + +const rolePermissionValues = ` + ('admin', 'surveys.read_reports'), + ('admin', 'surveys.capture'), + ('admin', 'surveys.review'), + ('director', 'surveys.read_reports'), + ('director', 'surveys.review'), + ('supervisor', 'surveys.read_reports'), + ('supervisor', 'surveys.capture'), + ('supervisor', 'surveys.review'), + ('inspector', 'surveys.read_reports'), + ('inspector', 'surveys.capture'), + ('auditor', 'surveys.read_reports') +`; + +function quoteIdentifier(identifier: string): string { + return `"${identifier.replaceAll('"', '""')}"`; +} + +export class PhaseC2SurveyExecution1786896000000 implements MigrationInterface { + name = 'PhaseC2SurveyExecution1786896000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE survey_campaign_targets + DROP CONSTRAINT chk_survey_campaign_targets_status + `); + await queryRunner.query(` + ALTER TABLE survey_campaign_targets + ADD CONSTRAINT chk_survey_campaign_targets_status CHECK (status IN ( + 'PENDING', 'IN_PROGRESS', 'SUBMITTED', 'COMPLETED', 'SKIPPED' + )) + `); + + await queryRunner.query(` + CREATE TABLE survey_target_reports ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + target_id uuid NOT NULL, + outcome varchar(32), + status varchar(24) NOT NULL DEFAULT 'DRAFT', + observed_at timestamptz, + latitude numeric(9, 6), + longitude numeric(9, 6), + accuracy_m numeric(12, 3), + notes text, + asset_version_at_submission integer, + submitted_at timestamptz, + submitted_by uuid, + reviewed_at timestamptz, + reviewed_by uuid, + review_notes text, + created_by uuid, + updated_by uuid, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uq_survey_target_reports_target_id UNIQUE (target_id), + CONSTRAINT chk_survey_target_reports_outcome CHECK ( + outcome IS NULL OR outcome IN ('CONFIRMED', 'CHANGES_RECORDED', 'NOT_LOCATED') + ), + CONSTRAINT chk_survey_target_reports_status CHECK ( + status IN ('DRAFT', 'SUBMITTED', 'APPROVED', 'REJECTED') + ), + CONSTRAINT chk_survey_target_reports_coordinates CHECK ( + (latitude IS NULL AND longitude IS NULL) + OR ( + latitude IS NOT NULL AND longitude IS NOT NULL + AND latitude BETWEEN -90 AND 90 + AND longitude BETWEEN -180 AND 180 + ) + ), + CONSTRAINT chk_survey_target_reports_accuracy CHECK ( + accuracy_m IS NULL OR ( + accuracy_m >= 0 AND latitude IS NOT NULL AND longitude IS NOT NULL + ) + ), + CONSTRAINT fk_survey_target_reports_target FOREIGN KEY (target_id) + REFERENCES survey_campaign_targets(id) ON DELETE RESTRICT, + CONSTRAINT fk_survey_target_reports_submitted_by FOREIGN KEY (submitted_by) + REFERENCES users(id) ON DELETE SET NULL, + CONSTRAINT fk_survey_target_reports_reviewed_by FOREIGN KEY (reviewed_by) + REFERENCES users(id) ON DELETE SET NULL, + CONSTRAINT fk_survey_target_reports_created_by FOREIGN KEY (created_by) + REFERENCES users(id) ON DELETE SET NULL, + CONSTRAINT fk_survey_target_reports_updated_by FOREIGN KEY (updated_by) + REFERENCES users(id) ON DELETE SET NULL + ) + `); + await queryRunner.query(`CREATE INDEX idx_survey_target_reports_status ON survey_target_reports (status)`); + await queryRunner.query(`CREATE INDEX idx_survey_target_reports_submitted_by ON survey_target_reports (submitted_by)`); + await queryRunner.query(`CREATE INDEX idx_survey_target_reports_reviewed_by ON survey_target_reports (reviewed_by)`); + + await queryRunner.query(` + CREATE TABLE survey_target_report_media ( + report_id uuid NOT NULL, + media_id uuid NOT NULL, + included boolean NOT NULL DEFAULT true, + added_by uuid, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (report_id, media_id), + CONSTRAINT fk_survey_target_report_media_report FOREIGN KEY (report_id) + REFERENCES survey_target_reports(id) ON DELETE RESTRICT, + CONSTRAINT fk_survey_target_report_media_media FOREIGN KEY (media_id) + REFERENCES asset_media(id) ON DELETE RESTRICT, + CONSTRAINT fk_survey_target_report_media_added_by FOREIGN KEY (added_by) + REFERENCES users(id) ON DELETE SET NULL + ) + `); + await queryRunner.query(`CREATE INDEX idx_survey_target_report_media_media_id ON survey_target_report_media (media_id)`); + await queryRunner.query(`CREATE INDEX idx_survey_target_report_media_included ON survey_target_report_media (report_id, included)`); + + await queryRunner.query(` + CREATE TABLE survey_target_report_versions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + report_id uuid NOT NULL, + version_number integer NOT NULL, + event varchar(24) NOT NULL, + snapshot jsonb NOT NULL, + actor_user_id uuid, + actor_username varchar(80), + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uq_survey_target_report_versions_number UNIQUE (report_id, version_number), + CONSTRAINT chk_survey_target_report_versions_event CHECK ( + event IN ('SUBMITTED', 'APPROVED', 'REJECTED') + ), + CONSTRAINT fk_survey_target_report_versions_report FOREIGN KEY (report_id) + REFERENCES survey_target_reports(id) ON DELETE RESTRICT, + CONSTRAINT fk_survey_target_report_versions_actor FOREIGN KEY (actor_user_id) + REFERENCES users(id) ON DELETE SET NULL + ) + `); + await queryRunner.query(`CREATE INDEX idx_survey_target_report_versions_created_at ON survey_target_report_versions (created_at)`); + + await queryRunner.query(` + INSERT INTO permissions (code, description) + VALUES + ('surveys.read_reports', 'Consultar capturas y versiones de relevamientos'), + ('surveys.capture', 'Capturar y enviar relevamientos de campo'), + ('surveys.review', 'Aprobar o rechazar relevamientos enviados') + ON CONFLICT (code) DO UPDATE SET description = EXCLUDED.description + `); + await queryRunner.query(` + WITH mapping(role_code, permission_code) AS (VALUES ${rolePermissionValues}) + 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 (role_id, permission_id) DO NOTHING + `); + + const appRole = process.env.DB_APP_USER; + if (!appRole) throw new Error('Missing required environment variable: DB_APP_USER'); + const applicationRole = quoteIdentifier(appRole); + await queryRunner.query(` + GRANT SELECT, INSERT, UPDATE ON TABLE + survey_target_reports, survey_target_report_media + TO ${applicationRole} + `); + await queryRunner.query(` + GRANT SELECT, INSERT ON TABLE survey_target_report_versions + TO ${applicationRole} + `); + await queryRunner.query(` + REVOKE DELETE ON TABLE + survey_target_reports, survey_target_report_media, survey_target_report_versions + FROM ${applicationRole} + `); + await queryRunner.query(` + REVOKE UPDATE ON TABLE survey_target_report_versions + FROM ${applicationRole} + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + WITH mapping(role_code, permission_code) AS (VALUES ${rolePermissionValues}) + DELETE FROM role_permissions role_permission + USING roles role, permissions permission, mapping + WHERE role_permission.role_id = role.id + AND role_permission.permission_id = permission.id + AND role.code = mapping.role_code + AND permission.code = mapping.permission_code + `); + await queryRunner.query( + `DELETE FROM permissions WHERE code = ANY($1::varchar[]) + AND NOT EXISTS ( + SELECT 1 FROM role_permissions WHERE permission_id = permissions.id + )`, + [newPermissions], + ); + await queryRunner.query(`DROP TABLE survey_target_report_versions`); + await queryRunner.query(`DROP TABLE survey_target_report_media`); + await queryRunner.query(`DROP TABLE survey_target_reports`); + await queryRunner.query(` + UPDATE survey_campaign_targets SET status = 'IN_PROGRESS' WHERE status = 'SUBMITTED' + `); + await queryRunner.query(` + ALTER TABLE survey_campaign_targets + DROP CONSTRAINT chk_survey_campaign_targets_status + `); + await queryRunner.query(` + ALTER TABLE survey_campaign_targets + ADD CONSTRAINT chk_survey_campaign_targets_status CHECK (status IN ( + 'PENDING', 'IN_PROGRESS', 'COMPLETED', 'SKIPPED' + )) + `); + } +} diff --git a/api-v3/src/database/migrations/1786982400000-phase-d1-inspection-visits.ts b/api-v3/src/database/migrations/1786982400000-phase-d1-inspection-visits.ts new file mode 100644 index 0000000..2abdebb --- /dev/null +++ b/api-v3/src/database/migrations/1786982400000-phase-d1-inspection-visits.ts @@ -0,0 +1,178 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +const newPermissions = [ + 'inspections.read', + 'inspections.manage', + 'inspections.assign', + 'inspections.execute', +] as const; + +const rolePermissionValues = ` + ('admin', 'inspections.read'), + ('admin', 'inspections.manage'), + ('admin', 'inspections.assign'), + ('admin', 'inspections.execute'), + ('director', 'inspections.read'), + ('supervisor', 'inspections.read'), + ('supervisor', 'inspections.manage'), + ('supervisor', 'inspections.assign'), + ('supervisor', 'inspections.execute'), + ('inspector', 'inspections.read'), + ('inspector', 'inspections.execute'), + ('auditor', 'inspections.read') +`; + +function quoteIdentifier(identifier: string): string { + return `"${identifier.replaceAll('"', '""')}"`; +} + +export class PhaseD1InspectionVisits1786982400000 implements MigrationInterface { + name = 'PhaseD1InspectionVisits1786982400000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE inspection_visits ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + code varchar(80) NOT NULL, + title varchar(200) NOT NULL, + objective text, + status varchar(24) NOT NULL DEFAULT 'DRAFT', + scope_asset_id uuid, + lead_inspector_user_id uuid, + planned_start_at timestamptz, + planned_end_at timestamptz, + actual_started_at timestamptz, + actual_closed_at timestamptz, + instructions text, + cancellation_reason text, + created_by uuid, + updated_by uuid, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uq_inspection_visits_code UNIQUE (code), + CONSTRAINT chk_inspection_visits_code CHECK ( + code = UPPER(code) AND code ~ '^[A-Z0-9][A-Z0-9._/-]*$' + ), + CONSTRAINT chk_inspection_visits_status CHECK ( + status IN ('DRAFT', 'PLANNED', 'IN_PROGRESS', 'CLOSED', 'CANCELLED') + ), + CONSTRAINT chk_inspection_visits_planned_dates CHECK ( + planned_start_at IS NULL OR planned_end_at IS NULL OR planned_end_at >= planned_start_at + ), + CONSTRAINT chk_inspection_visits_actual_dates CHECK ( + actual_started_at IS NULL OR actual_closed_at IS NULL OR actual_closed_at >= actual_started_at + ), + CONSTRAINT chk_inspection_visits_cancellation CHECK ( + status <> 'CANCELLED' OR LENGTH(TRIM(COALESCE(cancellation_reason, ''))) >= 10 + ), + CONSTRAINT fk_inspection_visits_scope_asset FOREIGN KEY (scope_asset_id) + REFERENCES assets(id) ON DELETE RESTRICT, + CONSTRAINT fk_inspection_visits_lead_inspector FOREIGN KEY (lead_inspector_user_id) + REFERENCES users(id) ON DELETE SET NULL, + CONSTRAINT fk_inspection_visits_created_by FOREIGN KEY (created_by) + REFERENCES users(id) ON DELETE SET NULL, + CONSTRAINT fk_inspection_visits_updated_by FOREIGN KEY (updated_by) + REFERENCES users(id) ON DELETE SET NULL + ) + `); + await queryRunner.query(`CREATE INDEX idx_inspection_visits_status ON inspection_visits (status)`); + await queryRunner.query(`CREATE INDEX idx_inspection_visits_scope_asset_id ON inspection_visits (scope_asset_id)`); + await queryRunner.query(`CREATE INDEX idx_inspection_visits_lead_inspector_user_id ON inspection_visits (lead_inspector_user_id)`); + await queryRunner.query(`CREATE INDEX idx_inspection_visits_planned_dates ON inspection_visits (planned_start_at, planned_end_at)`); + + await queryRunner.query(` + CREATE TABLE inspection_visit_assets ( + visit_id uuid NOT NULL, + asset_id uuid NOT NULL, + included boolean NOT NULL DEFAULT true, + added_by uuid, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (visit_id, asset_id), + CONSTRAINT fk_inspection_visit_assets_visit FOREIGN KEY (visit_id) + REFERENCES inspection_visits(id) ON DELETE RESTRICT, + CONSTRAINT fk_inspection_visit_assets_asset FOREIGN KEY (asset_id) + REFERENCES assets(id) ON DELETE RESTRICT, + CONSTRAINT fk_inspection_visit_assets_added_by FOREIGN KEY (added_by) + REFERENCES users(id) ON DELETE SET NULL + ) + `); + await queryRunner.query(`CREATE INDEX idx_inspection_visit_assets_asset_id ON inspection_visit_assets (asset_id)`); + await queryRunner.query(`CREATE INDEX idx_inspection_visit_assets_included ON inspection_visit_assets (visit_id, included)`); + + await queryRunner.query(` + CREATE TABLE inspection_visit_members ( + visit_id uuid NOT NULL, + user_id uuid NOT NULL, + included boolean NOT NULL DEFAULT true, + assigned_by uuid, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (visit_id, user_id), + CONSTRAINT fk_inspection_visit_members_visit FOREIGN KEY (visit_id) + REFERENCES inspection_visits(id) ON DELETE RESTRICT, + CONSTRAINT fk_inspection_visit_members_user FOREIGN KEY (user_id) + REFERENCES users(id) ON DELETE RESTRICT, + CONSTRAINT fk_inspection_visit_members_assigned_by FOREIGN KEY (assigned_by) + REFERENCES users(id) ON DELETE SET NULL + ) + `); + await queryRunner.query(`CREATE INDEX idx_inspection_visit_members_user_id ON inspection_visit_members (user_id)`); + await queryRunner.query(`CREATE INDEX idx_inspection_visit_members_included ON inspection_visit_members (visit_id, included)`); + + await queryRunner.query(` + INSERT INTO permissions (code, description) + VALUES + ('inspections.read', 'Consultar visitas de inspección'), + ('inspections.manage', 'Crear y planificar visitas de inspección'), + ('inspections.assign', 'Asignar equipos de inspección'), + ('inspections.execute', 'Iniciar y ejecutar visitas asignadas') + ON CONFLICT (code) DO UPDATE SET description = EXCLUDED.description + `); + await queryRunner.query(` + WITH mapping(role_code, permission_code) AS (VALUES ${rolePermissionValues}) + 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 (role_id, permission_id) DO NOTHING + `); + + const appRole = process.env.DB_APP_USER; + if (!appRole) throw new Error('Missing required environment variable: DB_APP_USER'); + const applicationRole = quoteIdentifier(appRole); + await queryRunner.query(` + GRANT SELECT, INSERT, UPDATE ON TABLE + inspection_visits, inspection_visit_assets, inspection_visit_members + TO ${applicationRole} + `); + await queryRunner.query(` + REVOKE DELETE ON TABLE + inspection_visits, inspection_visit_assets, inspection_visit_members + FROM ${applicationRole} + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + WITH mapping(role_code, permission_code) AS (VALUES ${rolePermissionValues}) + DELETE FROM role_permissions role_permission + USING roles role, permissions permission, mapping + WHERE role_permission.role_id = role.id + AND role_permission.permission_id = permission.id + AND role.code = mapping.role_code + AND permission.code = mapping.permission_code + `); + await queryRunner.query( + `DELETE FROM permissions WHERE code = ANY($1::varchar[]) + AND NOT EXISTS ( + SELECT 1 FROM role_permissions WHERE permission_id = permissions.id + )`, + [newPermissions], + ); + await queryRunner.query(`DROP TABLE inspection_visit_members`); + await queryRunner.query(`DROP TABLE inspection_visit_assets`); + await queryRunner.query(`DROP TABLE inspection_visits`); + } +} diff --git a/api-v3/src/database/migrations/1787068800000-phase-d2-inspection-acts.ts b/api-v3/src/database/migrations/1787068800000-phase-d2-inspection-acts.ts new file mode 100644 index 0000000..4c863e4 --- /dev/null +++ b/api-v3/src/database/migrations/1787068800000-phase-d2-inspection-acts.ts @@ -0,0 +1,205 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +const newPermissions = [ + 'inspection_acts.read', + 'inspection_acts.create', + 'inspection_acts.update', + 'inspection_acts.cancel', +] as const; + +const rolePermissionValues = ` + ('admin', 'inspection_acts.read'), + ('admin', 'inspection_acts.create'), + ('admin', 'inspection_acts.update'), + ('admin', 'inspection_acts.cancel'), + ('director', 'inspection_acts.read'), + ('supervisor', 'inspection_acts.read'), + ('supervisor', 'inspection_acts.create'), + ('supervisor', 'inspection_acts.update'), + ('supervisor', 'inspection_acts.cancel'), + ('inspector', 'inspection_acts.read'), + ('inspector', 'inspection_acts.create'), + ('inspector', 'inspection_acts.update'), + ('auditor', 'inspection_acts.read') +`; + +function quoteIdentifier(identifier: string): string { + return `"${identifier.replaceAll('"', '""')}"`; +} + +export class PhaseD2InspectionActs1787068800000 implements MigrationInterface { + name = 'PhaseD2InspectionActs1787068800000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE document_annual_sequences ( + document_type varchar(20) NOT NULL, + year integer NOT NULL, + last_number integer NOT NULL DEFAULT 0, + updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (document_type, year), + CONSTRAINT chk_document_annual_sequences_type CHECK ( + document_type IN ('ACT', 'REPORT') + ), + CONSTRAINT chk_document_annual_sequences_year CHECK ( + year BETWEEN 2000 AND 9999 + ), + CONSTRAINT chk_document_annual_sequences_number CHECK ( + last_number >= 0 + ) + ) + `); + + await queryRunner.query(` + CREATE TABLE inspection_acts ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + visit_id uuid NOT NULL, + act_year integer NOT NULL, + act_number integer NOT NULL, + code varchar(24) NOT NULL, + status varchar(24) NOT NULL DEFAULT 'DRAFT', + occurred_at timestamptz NOT NULL, + title varchar(200) NOT NULL, + summary text NOT NULL, + observations text, + current_version integer NOT NULL DEFAULT 0, + cancellation_reason text, + created_by uuid, + updated_by uuid, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uq_inspection_acts_year_number UNIQUE (act_year, act_number), + CONSTRAINT uq_inspection_acts_code UNIQUE (code), + CONSTRAINT chk_inspection_acts_year CHECK (act_year BETWEEN 2000 AND 9999), + CONSTRAINT chk_inspection_acts_number CHECK (act_number > 0), + CONSTRAINT chk_inspection_acts_code CHECK ( + code ~ '^ACTA-[0-9]{4}-[0-9]{6}$' + ), + CONSTRAINT chk_inspection_acts_status CHECK ( + status IN ('DRAFT', 'READY', 'CLOSED', 'CANCELLED', 'RECTIFIED') + ), + CONSTRAINT chk_inspection_acts_current_version CHECK (current_version >= 0), + CONSTRAINT chk_inspection_acts_cancellation CHECK ( + status <> 'CANCELLED' OR LENGTH(TRIM(COALESCE(cancellation_reason, ''))) >= 10 + ), + CONSTRAINT fk_inspection_acts_visit FOREIGN KEY (visit_id) + REFERENCES inspection_visits(id) ON DELETE RESTRICT, + CONSTRAINT fk_inspection_acts_created_by FOREIGN KEY (created_by) + REFERENCES users(id) ON DELETE SET NULL, + CONSTRAINT fk_inspection_acts_updated_by FOREIGN KEY (updated_by) + REFERENCES users(id) ON DELETE SET NULL + ) + `); + await queryRunner.query(`CREATE INDEX idx_inspection_acts_visit_status ON inspection_acts (visit_id, status)`); + await queryRunner.query(`CREATE INDEX idx_inspection_acts_occurred_at ON inspection_acts (occurred_at)`); + await queryRunner.query(`CREATE INDEX idx_inspection_acts_created_by ON inspection_acts (created_by)`); + + await queryRunner.query(` + CREATE TABLE inspection_act_assets ( + act_id uuid NOT NULL, + asset_id uuid NOT NULL, + included boolean NOT NULL DEFAULT true, + added_by uuid, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (act_id, asset_id), + CONSTRAINT fk_inspection_act_assets_act FOREIGN KEY (act_id) + REFERENCES inspection_acts(id) ON DELETE RESTRICT, + CONSTRAINT fk_inspection_act_assets_asset FOREIGN KEY (asset_id) + REFERENCES assets(id) ON DELETE RESTRICT, + CONSTRAINT fk_inspection_act_assets_added_by FOREIGN KEY (added_by) + REFERENCES users(id) ON DELETE SET NULL + ) + `); + await queryRunner.query(`CREATE INDEX idx_inspection_act_assets_asset_id ON inspection_act_assets (asset_id)`); + await queryRunner.query(`CREATE INDEX idx_inspection_act_assets_included ON inspection_act_assets (act_id, included)`); + + await queryRunner.query(` + CREATE TABLE inspection_act_versions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + act_id uuid NOT NULL, + version_number integer NOT NULL, + event varchar(24) NOT NULL, + snapshot jsonb NOT NULL, + actor_user_id uuid, + actor_username varchar(80), + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uq_inspection_act_versions_number UNIQUE (act_id, version_number), + CONSTRAINT chk_inspection_act_versions_number CHECK (version_number > 0), + CONSTRAINT chk_inspection_act_versions_event CHECK ( + event IN ('CREATED', 'UPDATED', 'CANCELLED') + ), + CONSTRAINT fk_inspection_act_versions_act FOREIGN KEY (act_id) + REFERENCES inspection_acts(id) ON DELETE RESTRICT, + CONSTRAINT fk_inspection_act_versions_actor FOREIGN KEY (actor_user_id) + REFERENCES users(id) ON DELETE SET NULL + ) + `); + await queryRunner.query(`CREATE INDEX idx_inspection_act_versions_created_at ON inspection_act_versions (created_at)`); + + await queryRunner.query(` + INSERT INTO permissions (code, description) + VALUES + ('inspection_acts.read', 'Consultar actas y sus versiones'), + ('inspection_acts.create', 'Crear actas dentro de visitas asignadas'), + ('inspection_acts.update', 'Actualizar actas en borrador'), + ('inspection_acts.cancel', 'Cancelar actas en borrador') + ON CONFLICT (code) DO UPDATE SET description = EXCLUDED.description + `); + await queryRunner.query(` + WITH mapping(role_code, permission_code) AS (VALUES ${rolePermissionValues}) + 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 (role_id, permission_id) DO NOTHING + `); + + const appRole = process.env.DB_APP_USER; + if (!appRole) throw new Error('Missing required environment variable: DB_APP_USER'); + const applicationRole = quoteIdentifier(appRole); + await queryRunner.query(` + GRANT SELECT, INSERT, UPDATE ON TABLE + document_annual_sequences, inspection_acts, inspection_act_assets + TO ${applicationRole} + `); + await queryRunner.query(` + GRANT SELECT, INSERT ON TABLE inspection_act_versions + TO ${applicationRole} + `); + await queryRunner.query(` + REVOKE DELETE ON TABLE + document_annual_sequences, inspection_acts, + inspection_act_assets, inspection_act_versions + FROM ${applicationRole} + `); + await queryRunner.query(` + REVOKE UPDATE ON TABLE inspection_act_versions + FROM ${applicationRole} + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + WITH mapping(role_code, permission_code) AS (VALUES ${rolePermissionValues}) + DELETE FROM role_permissions role_permission + USING roles role, permissions permission, mapping + WHERE role_permission.role_id = role.id + AND role_permission.permission_id = permission.id + AND role.code = mapping.role_code + AND permission.code = mapping.permission_code + `); + await queryRunner.query( + `DELETE FROM permissions WHERE code = ANY($1::varchar[]) + AND NOT EXISTS ( + SELECT 1 FROM role_permissions WHERE permission_id = permissions.id + )`, + [newPermissions], + ); + await queryRunner.query(`DROP TABLE inspection_act_versions`); + await queryRunner.query(`DROP TABLE inspection_act_assets`); + await queryRunner.query(`DROP TABLE inspection_acts`); + await queryRunner.query(`DROP TABLE document_annual_sequences`); + } +} diff --git a/api-v3/src/database/migrations/1787072400000-phase-d2-1-single-act-per-visit.ts b/api-v3/src/database/migrations/1787072400000-phase-d2-1-single-act-per-visit.ts new file mode 100644 index 0000000..92f6b65 --- /dev/null +++ b/api-v3/src/database/migrations/1787072400000-phase-d2-1-single-act-per-visit.ts @@ -0,0 +1,34 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class PhaseD21SingleActPerVisit1787072400000 implements MigrationInterface { + name = 'PhaseD21SingleActPerVisit1787072400000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DO $$ + BEGIN + IF EXISTS ( + SELECT visit_id + FROM inspection_acts + GROUP BY visit_id + HAVING COUNT(*) > 1 + ) THEN + RAISE EXCEPTION + 'D2.1 no puede aplicar un acta por visita: existen visitas con actas duplicadas'; + END IF; + END + $$ + `); + await queryRunner.query(` + ALTER TABLE inspection_acts + ADD CONSTRAINT uq_inspection_acts_visit UNIQUE (visit_id) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE inspection_acts + DROP CONSTRAINT uq_inspection_acts_visit + `); + } +} diff --git a/api-v3/src/database/migrations/1787155200000-phase-d3-inspection-findings.ts b/api-v3/src/database/migrations/1787155200000-phase-d3-inspection-findings.ts new file mode 100644 index 0000000..35b01e8 --- /dev/null +++ b/api-v3/src/database/migrations/1787155200000-phase-d3-inspection-findings.ts @@ -0,0 +1,303 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; +import { + PHASE_D3_FINDING_CATALOG_ITEMS, + PHASE_D3_FINDING_CATEGORIES, +} from '../seeds/phase-d3-finding-catalog'; + +const newPermissions = [ + 'finding_catalog.read', + 'finding_catalog.manage', + 'inspection_findings.read', + 'inspection_findings.create', + 'inspection_findings.update', + 'inspection_findings.follow_up', +] as const; + +const rolePermissionValues = ` + ('admin', 'finding_catalog.read'), + ('admin', 'finding_catalog.manage'), + ('admin', 'inspection_findings.read'), + ('admin', 'inspection_findings.create'), + ('admin', 'inspection_findings.update'), + ('admin', 'inspection_findings.follow_up'), + ('director', 'finding_catalog.read'), + ('director', 'inspection_findings.read'), + ('director', 'inspection_findings.follow_up'), + ('supervisor', 'finding_catalog.read'), + ('supervisor', 'finding_catalog.manage'), + ('supervisor', 'inspection_findings.read'), + ('supervisor', 'inspection_findings.create'), + ('supervisor', 'inspection_findings.update'), + ('supervisor', 'inspection_findings.follow_up'), + ('inspector', 'finding_catalog.read'), + ('inspector', 'inspection_findings.read'), + ('inspector', 'inspection_findings.create'), + ('inspector', 'inspection_findings.update'), + ('inspector', 'inspection_findings.follow_up'), + ('auditor', 'finding_catalog.read'), + ('auditor', 'inspection_findings.read') +`; + +function quoteIdentifier(identifier: string): string { + return `"${identifier.replaceAll('"', '""')}"`; +} + +export class PhaseD3InspectionFindings1787155200000 implements MigrationInterface { + name = 'PhaseD3InspectionFindings1787155200000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE finding_categories ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + code varchar(80) NOT NULL, + name varchar(200) NOT NULL, + sort_order integer NOT NULL DEFAULT 0, + is_active boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uq_finding_categories_code UNIQUE (code), + CONSTRAINT chk_finding_categories_sort_order CHECK (sort_order >= 0), + CONSTRAINT chk_finding_categories_name CHECK (LENGTH(TRIM(name)) > 0) + ) + `); + await queryRunner.query(` + CREATE INDEX idx_finding_categories_active_order + ON finding_categories (is_active, sort_order) + `); + + await queryRunner.query(` + CREATE TABLE finding_catalog_items ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + category_id uuid NOT NULL, + code varchar(120) NOT NULL, + source_number integer NOT NULL, + title varchar(500) NOT NULL, + legal_basis text, + glossary text, + import_note text, + revision integer NOT NULL DEFAULT 1, + is_active boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uq_finding_catalog_items_code UNIQUE (code), + CONSTRAINT uq_finding_catalog_items_source UNIQUE (category_id, source_number), + CONSTRAINT chk_finding_catalog_items_source CHECK (source_number > 0), + CONSTRAINT chk_finding_catalog_items_revision CHECK (revision > 0), + CONSTRAINT chk_finding_catalog_items_title CHECK (LENGTH(TRIM(title)) > 0), + CONSTRAINT fk_finding_catalog_items_category FOREIGN KEY (category_id) + REFERENCES finding_categories(id) ON DELETE RESTRICT + ) + `); + await queryRunner.query(` + CREATE INDEX idx_finding_catalog_items_active + ON finding_catalog_items (category_id, is_active) + `); + + await queryRunner.query(` + CREATE TABLE inspection_findings ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + act_id uuid NOT NULL, + asset_id uuid NOT NULL, + catalog_item_id uuid, + finding_number integer NOT NULL, + code varchar(40) NOT NULL, + status varchar(20) NOT NULL DEFAULT 'OPEN', + title varchar(500) NOT NULL, + description text NOT NULL, + legal_basis text, + glossary text, + catalog_revision integer, + correction_due_on date, + company_response text, + company_response_received_on date, + company_committed_correction_on date, + next_control_on date, + current_version integer NOT NULL DEFAULT 0, + closed_at timestamptz, + closed_by uuid, + closure_notes text, + created_by uuid, + updated_by uuid, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uq_inspection_findings_number UNIQUE (act_id, finding_number), + CONSTRAINT uq_inspection_findings_code UNIQUE (code), + CONSTRAINT chk_inspection_findings_number CHECK ( + finding_number BETWEEN 1 AND 999 + ), + CONSTRAINT chk_inspection_findings_code CHECK ( + code ~ '^ACTA-[0-9]{4}-[0-9]{6}-H[0-9]{3}$' + ), + CONSTRAINT chk_inspection_findings_status CHECK ( + status IN ('OPEN', 'CLOSED', 'VOIDED') + ), + CONSTRAINT chk_inspection_findings_content CHECK ( + LENGTH(TRIM(title)) > 0 AND LENGTH(TRIM(description)) > 0 + ), + CONSTRAINT chk_inspection_findings_version CHECK (current_version >= 0), + CONSTRAINT chk_inspection_findings_catalog_revision CHECK ( + catalog_revision IS NULL OR catalog_revision > 0 + ), + CONSTRAINT chk_inspection_findings_response CHECK ( + (company_response IS NULL AND company_response_received_on IS NULL) + OR ( + LENGTH(TRIM(COALESCE(company_response, ''))) > 0 + AND company_response_received_on IS NOT NULL + ) + ), + CONSTRAINT chk_inspection_findings_commitment CHECK ( + company_committed_correction_on IS NULL + OR company_response_received_on IS NOT NULL + ), + CONSTRAINT chk_inspection_findings_closure CHECK ( + status <> 'CLOSED' + OR (closed_at IS NOT NULL AND closed_by IS NOT NULL + AND LENGTH(TRIM(COALESCE(closure_notes, ''))) >= 10) + ), + CONSTRAINT fk_inspection_findings_act FOREIGN KEY (act_id) + REFERENCES inspection_acts(id) ON DELETE RESTRICT, + CONSTRAINT fk_inspection_findings_asset FOREIGN KEY (asset_id) + REFERENCES assets(id) ON DELETE RESTRICT, + CONSTRAINT fk_inspection_findings_catalog_item FOREIGN KEY (catalog_item_id) + REFERENCES finding_catalog_items(id) ON DELETE RESTRICT, + CONSTRAINT fk_inspection_findings_closed_by FOREIGN KEY (closed_by) + REFERENCES users(id) ON DELETE SET NULL, + CONSTRAINT fk_inspection_findings_created_by FOREIGN KEY (created_by) + REFERENCES users(id) ON DELETE SET NULL, + CONSTRAINT fk_inspection_findings_updated_by FOREIGN KEY (updated_by) + REFERENCES users(id) ON DELETE SET NULL + ) + `); + await queryRunner.query(` + CREATE INDEX idx_inspection_findings_status_control + ON inspection_findings (status, next_control_on) + `); + await queryRunner.query(` + CREATE INDEX idx_inspection_findings_asset_status + ON inspection_findings (asset_id, status) + `); + await queryRunner.query(` + CREATE INDEX idx_inspection_findings_catalog_item + ON inspection_findings (catalog_item_id) + `); + + await queryRunner.query(` + CREATE TABLE inspection_finding_versions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + finding_id uuid NOT NULL, + version_number integer NOT NULL, + event varchar(32) NOT NULL, + snapshot jsonb NOT NULL, + actor_user_id uuid, + actor_username varchar(80), + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uq_inspection_finding_versions_number + UNIQUE (finding_id, version_number), + CONSTRAINT chk_inspection_finding_versions_number CHECK (version_number > 0), + CONSTRAINT chk_inspection_finding_versions_event CHECK ( + event IN ('CREATED', 'UPDATED', 'FOLLOW_UP_UPDATED') + ), + CONSTRAINT fk_inspection_finding_versions_finding FOREIGN KEY (finding_id) + REFERENCES inspection_findings(id) ON DELETE RESTRICT, + CONSTRAINT fk_inspection_finding_versions_actor FOREIGN KEY (actor_user_id) + REFERENCES users(id) ON DELETE SET NULL + ) + `); + await queryRunner.query(` + CREATE INDEX idx_inspection_finding_versions_created_at + ON inspection_finding_versions (created_at) + `); + + for (const category of PHASE_D3_FINDING_CATEGORIES) { + await queryRunner.query(` + INSERT INTO finding_categories (code, name, sort_order) + VALUES ($1, $2, $3) + `, [category.code, category.name, category.sortOrder]); + } + for (const item of PHASE_D3_FINDING_CATALOG_ITEMS) { + await queryRunner.query(` + INSERT INTO finding_catalog_items ( + category_id, code, source_number, title, legal_basis, glossary, import_note + ) + SELECT id, $2, $3, $4, $5, $6, $7 + FROM finding_categories + WHERE code = $1 + `, [ + item.categoryCode, + item.code, + item.sourceNumber, + item.title, + item.legalBasis, + item.glossary, + item.importNote, + ]); + } + + await queryRunner.query(` + INSERT INTO permissions (code, description) + VALUES + ('finding_catalog.read', 'Consultar el catálogo de hallazgos'), + ('finding_catalog.manage', 'Administrar el catálogo de hallazgos'), + ('inspection_findings.read', 'Consultar hallazgos y seguimiento'), + ('inspection_findings.create', 'Crear hallazgos dentro de actas'), + ('inspection_findings.update', 'Actualizar hallazgos en borrador'), + ('inspection_findings.follow_up', 'Registrar respuestas y próximos controles') + ON CONFLICT (code) DO UPDATE SET description = EXCLUDED.description + `); + await queryRunner.query(` + WITH mapping(role_code, permission_code) AS (VALUES ${rolePermissionValues}) + 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 (role_id, permission_id) DO NOTHING + `); + + const appRole = process.env.DB_APP_USER; + if (!appRole) throw new Error('Missing required environment variable: DB_APP_USER'); + const applicationRole = quoteIdentifier(appRole); + await queryRunner.query(` + GRANT SELECT, INSERT, UPDATE ON TABLE + finding_categories, finding_catalog_items, inspection_findings + TO ${applicationRole} + `); + await queryRunner.query(` + GRANT SELECT, INSERT ON TABLE inspection_finding_versions + TO ${applicationRole} + `); + await queryRunner.query(` + REVOKE DELETE ON TABLE + finding_categories, finding_catalog_items, + inspection_findings, inspection_finding_versions + FROM ${applicationRole} + `); + await queryRunner.query(` + REVOKE UPDATE ON TABLE inspection_finding_versions + FROM ${applicationRole} + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + WITH mapping(role_code, permission_code) AS (VALUES ${rolePermissionValues}) + DELETE FROM role_permissions role_permission + USING roles role, permissions permission, mapping + WHERE role_permission.role_id = role.id + AND role_permission.permission_id = permission.id + AND role.code = mapping.role_code + AND permission.code = mapping.permission_code + `); + await queryRunner.query( + `DELETE FROM permissions WHERE code = ANY($1::varchar[]) + AND NOT EXISTS ( + SELECT 1 FROM role_permissions WHERE permission_id = permissions.id + )`, + [newPermissions], + ); + await queryRunner.query(`DROP TABLE inspection_finding_versions`); + await queryRunner.query(`DROP TABLE inspection_findings`); + await queryRunner.query(`DROP TABLE finding_catalog_items`); + await queryRunner.query(`DROP TABLE finding_categories`); + } +} diff --git a/api-v3/src/database/migrations/1787241600000-phase-d4-finding-evidence-communications.ts b/api-v3/src/database/migrations/1787241600000-phase-d4-finding-evidence-communications.ts new file mode 100644 index 0000000..a62387b --- /dev/null +++ b/api-v3/src/database/migrations/1787241600000-phase-d4-finding-evidence-communications.ts @@ -0,0 +1,234 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +const newPermissions = [ + 'inspection_evidence.read', + 'inspection_evidence.create', + 'inspection_communications.read', + 'inspection_communications.create', +] as const; + +const rolePermissionValues = ` + ('admin', 'inspection_evidence.read'), + ('admin', 'inspection_evidence.create'), + ('admin', 'inspection_communications.read'), + ('admin', 'inspection_communications.create'), + ('director', 'inspection_evidence.read'), + ('director', 'inspection_evidence.create'), + ('director', 'inspection_communications.read'), + ('director', 'inspection_communications.create'), + ('supervisor', 'inspection_evidence.read'), + ('supervisor', 'inspection_evidence.create'), + ('supervisor', 'inspection_communications.read'), + ('supervisor', 'inspection_communications.create'), + ('inspector', 'inspection_evidence.read'), + ('inspector', 'inspection_evidence.create'), + ('inspector', 'inspection_communications.read'), + ('inspector', 'inspection_communications.create'), + ('auditor', 'inspection_evidence.read'), + ('auditor', 'inspection_communications.read') +`; + +function quoteIdentifier(identifier: string): string { + return `"${identifier.replaceAll('"', '""')}"`; +} + +export class PhaseD4FindingEvidenceCommunications1787241600000 implements MigrationInterface { + name = 'PhaseD4FindingEvidenceCommunications1787241600000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE inspection_finding_communications ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + finding_id uuid NOT NULL, + direction varchar(20) NOT NULL, + channel varchar(20) NOT NULL, + type varchar(32) NOT NULL, + occurred_at timestamptz NOT NULL, + subject varchar(250) NOT NULL, + details text, + contact_name varchar(200), + contact_email varchar(320), + created_by uuid, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uq_inspection_finding_communications_id_finding + UNIQUE (id, finding_id), + CONSTRAINT chk_inspection_finding_communications_direction CHECK ( + direction IN ('INBOUND', 'OUTBOUND', 'INTERNAL') + ), + CONSTRAINT chk_inspection_finding_communications_channel CHECK ( + channel IN ('EMAIL', 'IN_PERSON', 'PHONE', 'LETTER', 'SYSTEM', 'OTHER') + ), + CONSTRAINT chk_inspection_finding_communications_type CHECK ( + type IN ('COMPANY_RESPONSE', 'AUTHORITY_NOTICE', 'FOLLOW_UP', 'OTHER') + ), + CONSTRAINT chk_inspection_finding_communications_subject CHECK ( + LENGTH(TRIM(subject)) > 0 + ), + CONSTRAINT chk_inspection_finding_company_response_direction CHECK ( + type <> 'COMPANY_RESPONSE' OR direction = 'INBOUND' + ), + CONSTRAINT fk_inspection_finding_communications_finding FOREIGN KEY (finding_id) + REFERENCES inspection_findings(id) ON DELETE RESTRICT, + CONSTRAINT fk_inspection_finding_communications_created_by FOREIGN KEY (created_by) + REFERENCES users(id) ON DELETE SET NULL + ) + `); + await queryRunner.query(` + CREATE INDEX idx_inspection_finding_communications_timeline + ON inspection_finding_communications (finding_id, occurred_at DESC) + `); + + await queryRunner.query(` + CREATE TABLE inspection_finding_evidence ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + finding_id uuid NOT NULL, + communication_id uuid, + kind varchar(20) NOT NULL, + purpose varchar(40) NOT NULL, + original_name varchar(255) NOT NULL, + stored_name varchar(80) NOT NULL, + mime_type varchar(100) NOT NULL, + size_bytes bigint NOT NULL, + sha256 char(64) NOT NULL, + title varchar(200), + description text, + captured_at timestamptz, + latitude numeric(9, 6), + longitude numeric(9, 6), + accuracy_m numeric(12, 3), + device_label varchar(200), + source varchar(20) NOT NULL, + uploaded_by uuid, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uq_inspection_finding_evidence_stored_name UNIQUE (stored_name), + CONSTRAINT chk_inspection_finding_evidence_kind CHECK ( + kind IN ('PHOTO', 'DOCUMENT') + ), + CONSTRAINT chk_inspection_finding_evidence_purpose CHECK ( + purpose IN ( + 'OBSERVATION', 'COMPANY_RESPONSE', + 'COMMUNICATION_ATTACHMENT', 'OTHER_DOCUMENT' + ) + ), + CONSTRAINT chk_inspection_finding_evidence_source CHECK ( + source IN ('WEB', 'ANDROID', 'IMPORT') + ), + CONSTRAINT chk_inspection_finding_evidence_mime CHECK ( + mime_type IN ('image/jpeg', 'image/png', 'image/webp', 'application/pdf') + ), + CONSTRAINT chk_inspection_finding_evidence_size CHECK ( + size_bytes > 0 AND size_bytes <= 15728640 + ), + CONSTRAINT chk_inspection_finding_evidence_sha256 CHECK ( + sha256 ~ '^[0-9a-f]{64}$' + ), + CONSTRAINT chk_inspection_finding_evidence_coordinates CHECK ( + (latitude IS NULL AND longitude IS NULL) + OR ( + latitude IS NOT NULL AND longitude IS NOT NULL + AND latitude BETWEEN -90 AND 90 + AND longitude BETWEEN -180 AND 180 + ) + ), + CONSTRAINT chk_inspection_finding_evidence_accuracy CHECK ( + accuracy_m IS NULL OR accuracy_m >= 0 + ), + CONSTRAINT chk_inspection_finding_evidence_kind_mime CHECK ( + (kind = 'PHOTO' AND mime_type IN ('image/jpeg', 'image/png', 'image/webp')) + OR (kind = 'DOCUMENT' AND mime_type = 'application/pdf') + ), + CONSTRAINT chk_inspection_finding_company_response_pdf CHECK ( + purpose <> 'COMPANY_RESPONSE' + OR ( + kind = 'DOCUMENT' + AND mime_type = 'application/pdf' + AND communication_id IS NOT NULL + ) + ), + CONSTRAINT chk_inspection_finding_attachment_link CHECK ( + purpose <> 'COMMUNICATION_ATTACHMENT' OR communication_id IS NOT NULL + ), + CONSTRAINT fk_inspection_finding_evidence_finding FOREIGN KEY (finding_id) + REFERENCES inspection_findings(id) ON DELETE RESTRICT, + CONSTRAINT fk_inspection_finding_evidence_communication + FOREIGN KEY (communication_id, finding_id) + REFERENCES inspection_finding_communications(id, finding_id) ON DELETE RESTRICT, + CONSTRAINT fk_inspection_finding_evidence_uploaded_by FOREIGN KEY (uploaded_by) + REFERENCES users(id) ON DELETE SET NULL + ) + `); + await queryRunner.query(` + CREATE INDEX idx_inspection_finding_evidence_finding_created + ON inspection_finding_evidence (finding_id, created_at DESC) + `); + await queryRunner.query(` + CREATE INDEX idx_inspection_finding_evidence_sha256 + ON inspection_finding_evidence (sha256) + `); + await queryRunner.query(` + CREATE INDEX idx_inspection_finding_evidence_communication + ON inspection_finding_evidence (communication_id) + `); + + await queryRunner.query(` + INSERT INTO permissions (code, description) + VALUES + ('inspection_evidence.read', 'Consultar evidencias y documentos de hallazgos'), + ('inspection_evidence.create', 'Incorporar evidencias y documentos de hallazgos'), + ('inspection_communications.read', 'Consultar comunicaciones de hallazgos'), + ('inspection_communications.create', 'Registrar comunicaciones de hallazgos') + ON CONFLICT (code) DO UPDATE SET description = EXCLUDED.description + `); + await queryRunner.query(` + WITH mapping(role_code, permission_code) AS (VALUES ${rolePermissionValues}) + 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 (role_id, permission_id) DO NOTHING + `); + + const appRole = process.env.DB_APP_USER; + if (!appRole) throw new Error('Missing required environment variable: DB_APP_USER'); + const roleRows = (await queryRunner.query( + 'SELECT 1 FROM pg_roles WHERE rolname = $1', + [appRole], + )) as unknown[]; + if (roleRows.length !== 1) throw new Error('Configured DB_APP_USER does not exist'); + const applicationRole = quoteIdentifier(appRole); + await queryRunner.query(` + GRANT SELECT, INSERT ON TABLE + inspection_finding_communications, + inspection_finding_evidence + TO ${applicationRole} + `); + await queryRunner.query(` + REVOKE UPDATE, DELETE ON TABLE + inspection_finding_communications, + inspection_finding_evidence + FROM ${applicationRole} + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + WITH mapping(role_code, permission_code) AS (VALUES ${rolePermissionValues}) + DELETE FROM role_permissions role_permission + USING roles role, permissions permission, mapping + WHERE role_permission.role_id = role.id + AND role_permission.permission_id = permission.id + AND role.code = mapping.role_code + AND permission.code = mapping.permission_code + `); + await queryRunner.query( + `DELETE FROM permissions WHERE code = ANY($1::varchar[]) + AND NOT EXISTS ( + SELECT 1 FROM role_permissions WHERE permission_id = permissions.id + )`, + [newPermissions], + ); + await queryRunner.query('DROP TABLE inspection_finding_evidence'); + await queryRunner.query('DROP TABLE inspection_finding_communications'); + } +} diff --git a/api-v3/src/database/migrations/1787328000000-phase-d5-act-closing-signatures.ts b/api-v3/src/database/migrations/1787328000000-phase-d5-act-closing-signatures.ts new file mode 100644 index 0000000..d62ce4c --- /dev/null +++ b/api-v3/src/database/migrations/1787328000000-phase-d5-act-closing-signatures.ts @@ -0,0 +1,554 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +const newPermissions = [ + 'inspection_closure.read', + 'inspection_closure.prepare', + 'inspection_closure.sign', + 'inspection_closure.close', +] as const; + +const rolePermissionValues = ` + ('admin', 'inspection_closure.read'), + ('admin', 'inspection_closure.prepare'), + ('admin', 'inspection_closure.sign'), + ('admin', 'inspection_closure.close'), + ('director', 'inspection_closure.read'), + ('director', 'inspection_closure.close'), + ('supervisor', 'inspection_closure.read'), + ('supervisor', 'inspection_closure.prepare'), + ('supervisor', 'inspection_closure.sign'), + ('supervisor', 'inspection_closure.close'), + ('inspector', 'inspection_closure.read'), + ('inspector', 'inspection_closure.prepare'), + ('inspector', 'inspection_closure.sign'), + ('inspector', 'inspection_closure.close'), + ('auditor', 'inspection_closure.read') +`; + +function quoteIdentifier(identifier: string): string { + return `"${identifier.replaceAll('"', '""')}"`; +} + +export class PhaseD5ActClosingSignatures1787328000000 implements MigrationInterface { + name = 'PhaseD5ActClosingSignatures1787328000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE inspection_acts + ADD COLUMN closed_at timestamptz, + ADD COLUMN closed_by uuid, + ADD COLUMN closure_sha256 char(64), + ADD CONSTRAINT fk_inspection_acts_closed_by FOREIGN KEY (closed_by) + REFERENCES users(id) ON DELETE SET NULL, + ADD CONSTRAINT chk_inspection_acts_closure CHECK ( + status <> 'CLOSED' + OR ( + closed_at IS NOT NULL + AND closed_by IS NOT NULL + AND closure_sha256 ~ '^[0-9a-f]{64}$' + ) + ) + `); + await queryRunner.query(` + CREATE INDEX idx_inspection_acts_closure_sha256 + ON inspection_acts (closure_sha256) + WHERE closure_sha256 IS NOT NULL + `); + await queryRunner.query(` + ALTER TABLE inspection_act_versions + DROP CONSTRAINT chk_inspection_act_versions_event + `); + await queryRunner.query(` + ALTER TABLE inspection_act_versions + ADD CONSTRAINT chk_inspection_act_versions_event CHECK ( + event IN ('CREATED', 'UPDATED', 'READY', 'REOPENED', 'CLOSED', 'CANCELLED') + ) + `); + + await queryRunner.query(` + CREATE TABLE inspection_act_responsibles ( + act_id uuid PRIMARY KEY, + attendance_status varchar(20) NOT NULL, + full_name varchar(200), + document_type varchar(20), + document_number varchar(40), + position varchar(200), + email varchar(320), + phone varchar(50), + absence_reason text, + updated_by uuid, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT chk_inspection_act_responsibles_attendance CHECK ( + attendance_status IN ('PRESENT', 'ABSENT') + ), + CONSTRAINT chk_inspection_act_responsibles_document_type CHECK ( + document_type IS NULL OR document_type IN ('DNI', 'CUIL', 'PASSPORT', 'OTHER') + ), + CONSTRAINT chk_inspection_act_responsibles_details CHECK ( + ( + attendance_status = 'PRESENT' + AND LENGTH(TRIM(COALESCE(full_name, ''))) > 0 + AND document_type IS NOT NULL + AND LENGTH(TRIM(COALESCE(document_number, ''))) > 0 + AND LENGTH(TRIM(COALESCE(position, ''))) > 0 + AND absence_reason IS NULL + ) OR ( + attendance_status = 'ABSENT' + AND LENGTH(TRIM(COALESCE(absence_reason, ''))) >= 10 + ) + ), + CONSTRAINT fk_inspection_act_responsibles_act FOREIGN KEY (act_id) + REFERENCES inspection_acts(id) ON DELETE RESTRICT, + CONSTRAINT fk_inspection_act_responsibles_updated_by FOREIGN KEY (updated_by) + REFERENCES users(id) ON DELETE SET NULL + ) + `); + + await queryRunner.query(` + CREATE TABLE inspection_act_closures ( + act_id uuid PRIMARY KEY, + schema_version varchar(40) NOT NULL, + prepared_snapshot jsonb NOT NULL, + prepared_sha256 char(64) NOT NULL, + prepared_at timestamptz NOT NULL, + prepared_by uuid NOT NULL, + final_snapshot jsonb, + final_sha256 char(64), + device_closed_at timestamptz, + server_closed_at timestamptz, + upload_mode varchar(20), + closed_by uuid, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT chk_inspection_act_closures_schema CHECK ( + LENGTH(TRIM(schema_version)) > 0 + ), + CONSTRAINT chk_inspection_act_closures_prepared_sha CHECK ( + prepared_sha256 ~ '^[0-9a-f]{64}$' + ), + CONSTRAINT chk_inspection_act_closures_final_sha CHECK ( + final_sha256 IS NULL OR final_sha256 ~ '^[0-9a-f]{64}$' + ), + CONSTRAINT chk_inspection_act_closures_upload_mode CHECK ( + upload_mode IS NULL OR upload_mode IN ('IMMEDIATE', 'DEFERRED') + ), + CONSTRAINT chk_inspection_act_closures_final CHECK ( + ( + final_snapshot IS NULL + AND final_sha256 IS NULL + AND device_closed_at IS NULL + AND server_closed_at IS NULL + AND upload_mode IS NULL + AND closed_by IS NULL + ) OR ( + final_snapshot IS NOT NULL + AND final_sha256 IS NOT NULL + AND device_closed_at IS NOT NULL + AND server_closed_at IS NOT NULL + AND upload_mode IS NOT NULL + AND closed_by IS NOT NULL + ) + ), + CONSTRAINT fk_inspection_act_closures_act FOREIGN KEY (act_id) + REFERENCES inspection_acts(id) ON DELETE RESTRICT, + CONSTRAINT fk_inspection_act_closures_prepared_by FOREIGN KEY (prepared_by) + REFERENCES users(id) ON DELETE RESTRICT, + CONSTRAINT fk_inspection_act_closures_closed_by FOREIGN KEY (closed_by) + REFERENCES users(id) ON DELETE RESTRICT + ) + `); + await queryRunner.query(` + CREATE INDEX idx_inspection_act_closures_prepared_sha + ON inspection_act_closures (prepared_sha256) + `); + await queryRunner.query(` + CREATE UNIQUE INDEX uq_inspection_act_closures_final_sha + ON inspection_act_closures (final_sha256) + WHERE final_sha256 IS NOT NULL + `); + + await queryRunner.query(` + CREATE TABLE inspection_act_signatures ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + act_id uuid NOT NULL, + signer_type varchar(32) NOT NULL, + signer_user_id uuid, + signer_name varchar(200) NOT NULL, + document_type varchar(20), + document_number varchar(40), + position varchar(200), + status varchar(20) NOT NULL, + reason text, + original_name varchar(255), + stored_name varchar(80), + mime_type varchar(100), + size_bytes bigint, + image_sha256 char(64), + consent_text text, + consent_version varchar(20), + consent_accepted_at timestamptz, + client_signed_at timestamptz, + signed_at timestamptz, + latitude numeric(9, 6), + longitude numeric(9, 6), + accuracy_m numeric(12, 3), + device_label varchar(200), + source varchar(20) NOT NULL, + prepared_sha256 char(64) NOT NULL, + signature_payload_sha256 char(64) NOT NULL, + uploaded_by uuid NOT NULL, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uq_inspection_act_signatures_stored_name UNIQUE (stored_name), + CONSTRAINT chk_inspection_act_signatures_signer_type CHECK ( + signer_type IN ('INSPECTOR', 'COMPANY_RESPONSIBLE') + ), + CONSTRAINT chk_inspection_act_signatures_status CHECK ( + status IN ('SIGNED', 'REFUSED', 'ABSENT') + ), + CONSTRAINT chk_inspection_act_signatures_document_type CHECK ( + document_type IS NULL OR document_type IN ('DNI', 'CUIL', 'PASSPORT', 'OTHER') + ), + CONSTRAINT chk_inspection_act_signatures_source CHECK ( + source IN ('WEB', 'ANDROID') + ), + CONSTRAINT chk_inspection_act_signatures_sha CHECK ( + prepared_sha256 ~ '^[0-9a-f]{64}$' + AND signature_payload_sha256 ~ '^[0-9a-f]{64}$' + AND (image_sha256 IS NULL OR image_sha256 ~ '^[0-9a-f]{64}$') + ), + CONSTRAINT chk_inspection_act_signatures_coordinates CHECK ( + (latitude IS NULL AND longitude IS NULL) + OR ( + latitude IS NOT NULL AND longitude IS NOT NULL + AND latitude BETWEEN -90 AND 90 + AND longitude BETWEEN -180 AND 180 + ) + ), + CONSTRAINT chk_inspection_act_signatures_accuracy CHECK ( + accuracy_m IS NULL OR (accuracy_m >= 0 AND latitude IS NOT NULL) + ), + CONSTRAINT chk_inspection_act_signatures_inspector CHECK ( + signer_type <> 'INSPECTOR' + OR (signer_user_id IS NOT NULL AND status = 'SIGNED') + ), + CONSTRAINT chk_inspection_act_signatures_company CHECK ( + signer_type <> 'COMPANY_RESPONSIBLE' OR signer_user_id IS NULL + ), + CONSTRAINT chk_inspection_act_signatures_signed CHECK ( + ( + status = 'SIGNED' + AND reason IS NULL + AND stored_name IS NOT NULL + AND mime_type = 'image/png' + AND size_bytes BETWEEN 1 AND 1048576 + AND image_sha256 IS NOT NULL + AND LENGTH(TRIM(COALESCE(consent_text, ''))) > 0 + AND consent_version IS NOT NULL + AND consent_accepted_at IS NOT NULL + AND signed_at IS NOT NULL + ) OR ( + status IN ('REFUSED', 'ABSENT') + AND signer_type = 'COMPANY_RESPONSIBLE' + AND LENGTH(TRIM(COALESCE(reason, ''))) >= 10 + AND stored_name IS NULL + AND mime_type IS NULL + AND size_bytes IS NULL + AND image_sha256 IS NULL + AND consent_text IS NULL + AND consent_version IS NULL + AND consent_accepted_at IS NULL + AND signed_at IS NULL + ) + ), + CONSTRAINT fk_inspection_act_signatures_act FOREIGN KEY (act_id) + REFERENCES inspection_acts(id) ON DELETE RESTRICT, + CONSTRAINT fk_inspection_act_signatures_signer_user FOREIGN KEY (signer_user_id) + REFERENCES users(id) ON DELETE RESTRICT, + CONSTRAINT fk_inspection_act_signatures_uploaded_by FOREIGN KEY (uploaded_by) + REFERENCES users(id) ON DELETE RESTRICT + ) + `); + await queryRunner.query(` + CREATE INDEX idx_inspection_act_signatures_act_created + ON inspection_act_signatures (act_id, created_at) + `); + await queryRunner.query(` + CREATE INDEX idx_inspection_act_signatures_sha256 + ON inspection_act_signatures (signature_payload_sha256) + `); + await queryRunner.query(` + CREATE UNIQUE INDEX uq_inspection_act_inspector_signature + ON inspection_act_signatures (act_id, signer_user_id) + WHERE signer_type = 'INSPECTOR' + `); + await queryRunner.query(` + CREATE UNIQUE INDEX uq_inspection_act_company_outcome + ON inspection_act_signatures (act_id) + WHERE signer_type = 'COMPANY_RESPONSIBLE' + `); + + await queryRunner.query(` + CREATE OR REPLACE FUNCTION dhv2_guard_act_responsible_draft() + RETURNS trigger LANGUAGE plpgsql AS $$ + DECLARE act_status varchar(24); + BEGIN + SELECT status INTO act_status FROM inspection_acts WHERE id = NEW.act_id; + IF act_status <> 'DRAFT' THEN + RAISE EXCEPTION 'El responsable sólo puede modificarse con el acta en borrador'; + END IF; + NEW.updated_at := CURRENT_TIMESTAMP; + RETURN NEW; + END + $$ + `); + await queryRunner.query(` + CREATE TRIGGER trg_inspection_act_responsibles_draft + BEFORE INSERT OR UPDATE ON inspection_act_responsibles + FOR EACH ROW EXECUTE FUNCTION dhv2_guard_act_responsible_draft() + `); + + await queryRunner.query(` + CREATE OR REPLACE FUNCTION dhv2_guard_act_signature_ready() + RETURNS trigger LANGUAGE plpgsql AS $$ + DECLARE current_status varchar(24); + DECLARE current_sha char(64); + BEGIN + SELECT act.status, closure.prepared_sha256 + INTO current_status, current_sha + FROM inspection_acts act + INNER JOIN inspection_act_closures closure ON closure.act_id = act.id + WHERE act.id = NEW.act_id; + IF current_status <> 'READY' OR current_sha IS DISTINCT FROM NEW.prepared_sha256 THEN + RAISE EXCEPTION 'La firma no corresponde a un acta preparada vigente'; + END IF; + RETURN NEW; + END + $$ + `); + await queryRunner.query(` + CREATE TRIGGER trg_inspection_act_signatures_ready + BEFORE INSERT ON inspection_act_signatures + FOR EACH ROW EXECUTE FUNCTION dhv2_guard_act_signature_ready() + `); + + await queryRunner.query(` + CREATE OR REPLACE FUNCTION dhv2_guard_closed_act_immutable() + RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN + IF OLD.status IN ('CLOSED', 'RECTIFIED') THEN + RAISE EXCEPTION 'El acta cerrada es inmutable'; + END IF; + RETURN NEW; + END + $$ + `); + await queryRunner.query(` + CREATE TRIGGER trg_inspection_acts_closed_immutable + BEFORE UPDATE ON inspection_acts + FOR EACH ROW EXECUTE FUNCTION dhv2_guard_closed_act_immutable() + `); + + await queryRunner.query(` + CREATE OR REPLACE FUNCTION dhv2_guard_closed_closure_immutable() + RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN + IF OLD.server_closed_at IS NOT NULL THEN + RAISE EXCEPTION 'El cierre sellado es inmutable'; + END IF; + NEW.updated_at := CURRENT_TIMESTAMP; + RETURN NEW; + END + $$ + `); + await queryRunner.query(` + CREATE TRIGGER trg_inspection_act_closures_immutable + BEFORE UPDATE ON inspection_act_closures + FOR EACH ROW EXECUTE FUNCTION dhv2_guard_closed_closure_immutable() + `); + + await queryRunner.query(` + CREATE OR REPLACE FUNCTION dhv2_guard_act_asset_draft() + RETURNS trigger LANGUAGE plpgsql AS $$ + DECLARE act_status varchar(24); + BEGIN + SELECT status INTO act_status FROM inspection_acts + WHERE id = COALESCE(NEW.act_id, OLD.act_id); + IF act_status <> 'DRAFT' THEN + RAISE EXCEPTION 'Los activos del acta están congelados'; + END IF; + IF TG_OP = 'DELETE' THEN + RETURN OLD; + END IF; + RETURN NEW; + END + $$ + `); + await queryRunner.query(` + CREATE TRIGGER trg_inspection_act_assets_draft + BEFORE INSERT OR UPDATE OR DELETE ON inspection_act_assets + FOR EACH ROW EXECUTE FUNCTION dhv2_guard_act_asset_draft() + `); + + await queryRunner.query(` + CREATE OR REPLACE FUNCTION dhv2_guard_finding_inspection_fields() + RETURNS trigger LANGUAGE plpgsql AS $$ + DECLARE act_status varchar(24); + BEGIN + IF TG_OP = 'INSERT' THEN + SELECT status INTO act_status FROM inspection_acts WHERE id = NEW.act_id; + IF act_status <> 'DRAFT' THEN + RAISE EXCEPTION 'No pueden agregarse hallazgos al acta congelada'; + END IF; + RETURN NEW; + END IF; + SELECT status INTO act_status FROM inspection_acts WHERE id = OLD.act_id; + IF act_status <> 'DRAFT' AND ( + NEW.act_id IS DISTINCT FROM OLD.act_id + OR NEW.asset_id IS DISTINCT FROM OLD.asset_id + OR NEW.catalog_item_id IS DISTINCT FROM OLD.catalog_item_id + OR NEW.finding_number IS DISTINCT FROM OLD.finding_number + OR NEW.code IS DISTINCT FROM OLD.code + OR NEW.title IS DISTINCT FROM OLD.title + OR NEW.description IS DISTINCT FROM OLD.description + OR NEW.legal_basis IS DISTINCT FROM OLD.legal_basis + OR NEW.glossary IS DISTINCT FROM OLD.glossary + OR NEW.catalog_revision IS DISTINCT FROM OLD.catalog_revision + ) THEN + RAISE EXCEPTION 'El contenido constatado del hallazgo está congelado'; + END IF; + RETURN NEW; + END + $$ + `); + await queryRunner.query(` + CREATE TRIGGER trg_inspection_findings_frozen_fields + BEFORE INSERT OR UPDATE ON inspection_findings + FOR EACH ROW EXECUTE FUNCTION dhv2_guard_finding_inspection_fields() + `); + + await queryRunner.query(` + CREATE OR REPLACE FUNCTION dhv2_guard_observation_evidence_draft() + RETURNS trigger LANGUAGE plpgsql AS $$ + DECLARE act_status varchar(24); + BEGIN + IF NEW.purpose = 'OBSERVATION' THEN + SELECT act.status INTO act_status + FROM inspection_findings finding + INNER JOIN inspection_acts act ON act.id = finding.act_id + WHERE finding.id = NEW.finding_id; + IF act_status <> 'DRAFT' THEN + RAISE EXCEPTION 'La evidencia de constatación pertenece a un acta congelada'; + END IF; + END IF; + RETURN NEW; + END + $$ + `); + await queryRunner.query(` + CREATE TRIGGER trg_inspection_evidence_observation_draft + BEFORE INSERT ON inspection_finding_evidence + FOR EACH ROW EXECUTE FUNCTION dhv2_guard_observation_evidence_draft() + `); + + await queryRunner.query(` + INSERT INTO permissions (code, description) + VALUES + ('inspection_closure.read', 'Consultar responsable, firmas y cierre del acta'), + ('inspection_closure.prepare', 'Identificar responsable y preparar el acta para firmas'), + ('inspection_closure.sign', 'Registrar firmas o resultado de recepción del acta'), + ('inspection_closure.close', 'Cerrar y sellar el acta y la visita') + ON CONFLICT (code) DO UPDATE SET description = EXCLUDED.description + `); + await queryRunner.query(` + WITH mapping(role_code, permission_code) AS (VALUES ${rolePermissionValues}) + 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 (role_id, permission_id) DO NOTHING + `); + + const appRole = process.env.DB_APP_USER; + if (!appRole) throw new Error('Missing required environment variable: DB_APP_USER'); + const applicationRole = quoteIdentifier(appRole); + await queryRunner.query(` + GRANT SELECT, INSERT, UPDATE ON TABLE + inspection_act_responsibles, + inspection_act_closures + TO ${applicationRole} + `); + await queryRunner.query(` + GRANT SELECT, INSERT ON TABLE inspection_act_signatures + TO ${applicationRole} + `); + await queryRunner.query(` + REVOKE DELETE ON TABLE + inspection_act_responsibles, + inspection_act_closures, + inspection_act_signatures + FROM ${applicationRole} + `); + await queryRunner.query(` + REVOKE UPDATE ON TABLE inspection_act_signatures + FROM ${applicationRole} + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query('DROP TRIGGER trg_inspection_evidence_observation_draft ON inspection_finding_evidence'); + await queryRunner.query('DROP FUNCTION dhv2_guard_observation_evidence_draft()'); + await queryRunner.query('DROP TRIGGER trg_inspection_findings_frozen_fields ON inspection_findings'); + await queryRunner.query('DROP FUNCTION dhv2_guard_finding_inspection_fields()'); + await queryRunner.query('DROP TRIGGER trg_inspection_act_assets_draft ON inspection_act_assets'); + await queryRunner.query('DROP FUNCTION dhv2_guard_act_asset_draft()'); + await queryRunner.query('DROP TRIGGER trg_inspection_act_closures_immutable ON inspection_act_closures'); + await queryRunner.query('DROP FUNCTION dhv2_guard_closed_closure_immutable()'); + await queryRunner.query('DROP TRIGGER trg_inspection_acts_closed_immutable ON inspection_acts'); + await queryRunner.query('DROP FUNCTION dhv2_guard_closed_act_immutable()'); + await queryRunner.query('DROP TRIGGER trg_inspection_act_signatures_ready ON inspection_act_signatures'); + await queryRunner.query('DROP FUNCTION dhv2_guard_act_signature_ready()'); + await queryRunner.query('DROP TRIGGER trg_inspection_act_responsibles_draft ON inspection_act_responsibles'); + await queryRunner.query('DROP FUNCTION dhv2_guard_act_responsible_draft()'); + + await queryRunner.query(` + WITH mapping(role_code, permission_code) AS (VALUES ${rolePermissionValues}) + DELETE FROM role_permissions role_permission + USING roles role, permissions permission, mapping + WHERE role_permission.role_id = role.id + AND role_permission.permission_id = permission.id + AND role.code = mapping.role_code + AND permission.code = mapping.permission_code + `); + await queryRunner.query( + `DELETE FROM permissions WHERE code = ANY($1::varchar[]) + AND NOT EXISTS ( + SELECT 1 FROM role_permissions WHERE permission_id = permissions.id + )`, + [newPermissions], + ); + + await queryRunner.query('DROP TABLE inspection_act_signatures'); + await queryRunner.query('DROP TABLE inspection_act_closures'); + await queryRunner.query('DROP TABLE inspection_act_responsibles'); + await queryRunner.query(` + ALTER TABLE inspection_act_versions + DROP CONSTRAINT chk_inspection_act_versions_event + `); + await queryRunner.query(` + ALTER TABLE inspection_act_versions + ADD CONSTRAINT chk_inspection_act_versions_event CHECK ( + event IN ('CREATED', 'UPDATED', 'CANCELLED') + ) + `); + await queryRunner.query(` + ALTER TABLE inspection_acts + DROP CONSTRAINT chk_inspection_acts_closure, + DROP CONSTRAINT fk_inspection_acts_closed_by, + DROP COLUMN closure_sha256, + DROP COLUMN closed_by, + DROP COLUMN closed_at + `); + } +} diff --git a/api-v3/src/database/migrations/1787331600000-phase-d5-1-mobile-inspection-policy.ts b/api-v3/src/database/migrations/1787331600000-phase-d5-1-mobile-inspection-policy.ts new file mode 100644 index 0000000..2e2962a --- /dev/null +++ b/api-v3/src/database/migrations/1787331600000-phase-d5-1-mobile-inspection-policy.ts @@ -0,0 +1,81 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +const operationalPermissions = [ + 'inspections.execute', + 'inspection_acts.create', + 'inspection_acts.update', + 'inspection_acts.cancel', + 'inspection_findings.create', + 'inspection_findings.update', + 'inspection_closure.prepare', + 'inspection_closure.sign', + 'inspection_closure.close', +] as const; + +const previousNonInspectorMappings = ` + ('admin', 'inspections.execute'), + ('supervisor', 'inspections.execute'), + ('admin', 'inspection_acts.create'), + ('admin', 'inspection_acts.update'), + ('admin', 'inspection_acts.cancel'), + ('supervisor', 'inspection_acts.create'), + ('supervisor', 'inspection_acts.update'), + ('supervisor', 'inspection_acts.cancel'), + ('admin', 'inspection_findings.create'), + ('admin', 'inspection_findings.update'), + ('supervisor', 'inspection_findings.create'), + ('supervisor', 'inspection_findings.update'), + ('admin', 'inspection_closure.prepare'), + ('admin', 'inspection_closure.sign'), + ('admin', 'inspection_closure.close'), + ('director', 'inspection_closure.close'), + ('supervisor', 'inspection_closure.prepare'), + ('supervisor', 'inspection_closure.sign'), + ('supervisor', 'inspection_closure.close') +`; + +export class PhaseD51MobileInspectionPolicy1787331600000 implements MigrationInterface { + name = 'PhaseD51MobileInspectionPolicy1787331600000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DELETE FROM role_permissions role_permission + USING roles role, permissions permission + WHERE role_permission.role_id = role.id + AND role_permission.permission_id = permission.id + AND role.code <> 'inspector' + AND permission.code = ANY($1::varchar[]) + `, [operationalPermissions]); + + 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 = 'inspector' + AND permission.code = ANY($1::varchar[]) + ON CONFLICT (role_id, permission_id) DO NOTHING + `, [operationalPermissions]); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DELETE FROM role_permissions role_permission + USING roles role, permissions permission + WHERE role_permission.role_id = role.id + AND role_permission.permission_id = permission.id + AND role.code = 'inspector' + AND permission.code = 'inspection_acts.cancel' + `); + + await queryRunner.query(` + WITH mapping(role_code, permission_code) AS (VALUES ${previousNonInspectorMappings}) + 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 (role_id, permission_id) DO NOTHING + `); + } +} diff --git a/api-v3/src/database/migrations/1787335200000-phase-d5-2-finding-catalog-administration.ts b/api-v3/src/database/migrations/1787335200000-phase-d5-2-finding-catalog-administration.ts new file mode 100644 index 0000000..7771345 --- /dev/null +++ b/api-v3/src/database/migrations/1787335200000-phase-d5-2-finding-catalog-administration.ts @@ -0,0 +1,89 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +function quoteIdentifier(identifier: string): string { + return `"${identifier.replaceAll('"', '""')}"`; +} + +export class PhaseD52FindingCatalogAdministration1787335200000 implements MigrationInterface { + name = 'PhaseD52FindingCatalogAdministration1787335200000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE finding_catalog_item_versions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + item_id uuid NOT NULL, + revision integer NOT NULL, + snapshot jsonb NOT NULL, + actor_user_id uuid, + actor_username varchar(80), + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uq_finding_catalog_item_versions_revision + UNIQUE (item_id, revision), + CONSTRAINT chk_finding_catalog_item_versions_revision + CHECK (revision > 0), + CONSTRAINT chk_finding_catalog_item_versions_snapshot + CHECK (jsonb_typeof(snapshot) = 'object'), + CONSTRAINT fk_finding_catalog_item_versions_item FOREIGN KEY (item_id) + REFERENCES finding_catalog_items(id) ON DELETE RESTRICT, + CONSTRAINT fk_finding_catalog_item_versions_actor FOREIGN KEY (actor_user_id) + REFERENCES users(id) ON DELETE SET NULL + ) + `); + await queryRunner.query(` + CREATE INDEX idx_finding_catalog_item_versions_item_revision + ON finding_catalog_item_versions (item_id, revision DESC) + `); + await queryRunner.query(` + CREATE INDEX idx_finding_catalog_item_versions_created_at + ON finding_catalog_item_versions (created_at DESC) + `); + + await queryRunner.query(` + INSERT INTO finding_catalog_item_versions ( + item_id, revision, snapshot, actor_username, created_at + ) + SELECT + item.id, + item.revision, + JSONB_BUILD_OBJECT( + 'id', item.id, + 'categoryId', category.id, + 'categoryCode', category.code, + 'categoryName', category.name, + 'code', item.code, + 'sourceNumber', item.source_number, + 'title', item.title, + 'legalBasis', item.legal_basis, + 'glossary', item.glossary, + 'importNote', item.import_note, + 'revision', item.revision, + 'isActive', item.is_active + ), + 'migration:D5.2', + item.updated_at + FROM finding_catalog_items item + INNER JOIN finding_categories category ON category.id = item.category_id + `); + + const appRole = process.env.DB_APP_USER; + if (!appRole) throw new Error('Missing required environment variable: DB_APP_USER'); + const roleRows = (await queryRunner.query( + 'SELECT 1 FROM pg_roles WHERE rolname = $1', + [appRole], + )) as unknown[]; + if (roleRows.length !== 1) throw new Error('Configured DB_APP_USER does not exist'); + const applicationRole = quoteIdentifier(appRole); + await queryRunner.query(` + GRANT SELECT, INSERT ON TABLE finding_catalog_item_versions + TO ${applicationRole} + `); + await queryRunner.query(` + REVOKE UPDATE, DELETE ON TABLE finding_catalog_item_versions + FROM ${applicationRole} + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query('DROP TABLE finding_catalog_item_versions'); + } +} diff --git a/api-v3/src/database/migrations/1787421600000-phase-d5-3-operational-context.ts b/api-v3/src/database/migrations/1787421600000-phase-d5-3-operational-context.ts new file mode 100644 index 0000000..e7dcc46 --- /dev/null +++ b/api-v3/src/database/migrations/1787421600000-phase-d5-3-operational-context.ts @@ -0,0 +1,474 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +const newPermissions = [ + 'asset_relations.read', + 'asset_relations.manage', +] as const; + +const rolePermissionValues = ` + ('admin', 'asset_relations.read'), + ('admin', 'asset_relations.manage'), + ('director', 'asset_relations.read'), + ('supervisor', 'asset_relations.read'), + ('supervisor', 'asset_relations.manage'), + ('inspector', 'asset_relations.read'), + ('auditor', 'asset_relations.read') +`; + +function quoteIdentifier(identifier: string): string { + return `"${identifier.replaceAll('"', '""')}"`; +} + +export class PhaseD53OperationalContext1787421600000 implements MigrationInterface { + name = 'PhaseD53OperationalContext1787421600000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TYPE asset_type_operational_role AS ENUM ('GENERIC', 'AREA', 'COMPANY') + `); + + await queryRunner.query(` + ALTER TABLE asset_types + ADD COLUMN operational_role asset_type_operational_role NOT NULL DEFAULT 'GENERIC' + `); + await queryRunner.query(` + CREATE INDEX idx_asset_types_operational_role + ON asset_types (operational_role) + `); + + await queryRunner.query(` + CREATE TABLE area_company_relations ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + area_id uuid NOT NULL, + company_id uuid NOT NULL, + valid_from timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + valid_until timestamptz, + start_reason text NOT NULL, + end_reason text, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_by uuid, + ended_by uuid, + CONSTRAINT chk_area_company_relation_dates + CHECK (valid_until IS NULL OR valid_until >= valid_from), + CONSTRAINT chk_area_company_relation_start_reason + CHECK (length(btrim(start_reason)) >= 3), + CONSTRAINT chk_area_company_relation_end_reason + CHECK (valid_until IS NULL OR (end_reason IS NOT NULL AND length(btrim(end_reason)) >= 3)), + CONSTRAINT fk_area_company_relation_area FOREIGN KEY (area_id) + REFERENCES assets(id) ON DELETE RESTRICT, + CONSTRAINT fk_area_company_relation_company FOREIGN KEY (company_id) + REFERENCES assets(id) ON DELETE RESTRICT, + CONSTRAINT fk_area_company_relation_created_by FOREIGN KEY (created_by) + REFERENCES users(id) ON DELETE SET NULL, + CONSTRAINT fk_area_company_relation_ended_by FOREIGN KEY (ended_by) + REFERENCES users(id) ON DELETE SET NULL + ) + `); + await queryRunner.query(` + CREATE INDEX idx_area_company_relations_area_id + ON area_company_relations (area_id, valid_from DESC) + `); + await queryRunner.query(` + CREATE INDEX idx_area_company_relations_company_id + ON area_company_relations (company_id, valid_from DESC) + `); + await queryRunner.query(` + CREATE INDEX idx_area_company_relations_valid_until + ON area_company_relations (valid_until) + `); + await queryRunner.query(` + CREATE UNIQUE INDEX uq_area_company_relations_active_pair + ON area_company_relations (area_id, company_id) + WHERE valid_until IS NULL + `); + + await queryRunner.query(` + ALTER TABLE assets + ADD COLUMN operational_area_id uuid, + ADD COLUMN operator_company_id uuid, + ADD CONSTRAINT chk_assets_operational_assignment_pair CHECK ( + (operational_area_id IS NULL AND operator_company_id IS NULL) + OR (operational_area_id IS NOT NULL AND operator_company_id IS NOT NULL) + ), + ADD CONSTRAINT fk_assets_operational_area FOREIGN KEY (operational_area_id) + REFERENCES assets(id) ON DELETE RESTRICT, + ADD CONSTRAINT fk_assets_operator_company FOREIGN KEY (operator_company_id) + REFERENCES assets(id) ON DELETE RESTRICT + `); + await queryRunner.query(` + CREATE INDEX idx_assets_operational_area_id ON assets (operational_area_id) + `); + await queryRunner.query(` + CREATE INDEX idx_assets_operator_company_id ON assets (operator_company_id) + `); + await queryRunner.query(` + CREATE INDEX idx_assets_operational_context + ON assets (operational_area_id, operator_company_id) + `); + + await queryRunner.query(` + UPDATE asset_versions + SET snapshot = JSONB_SET( + JSONB_SET( + JSONB_SET( + snapshot, + '{type,operationalRole}', + '"GENERIC"'::jsonb, + true + ), + '{operationalArea}', + 'null'::jsonb, + true + ), + '{operatorCompany}', + 'null'::jsonb, + true + ) + `); + + await queryRunner.query(` + CREATE FUNCTION protect_operational_type_changes() + RETURNS trigger + LANGUAGE plpgsql + AS $$ + BEGIN + IF NEW.operational_role IN ('AREA'::asset_type_operational_role, 'COMPANY'::asset_type_operational_role) + AND EXISTS ( + SELECT 1 FROM assets + WHERE asset_type_id = NEW.id + AND (operational_area_id IS NOT NULL OR operator_company_id IS NOT NULL) + ) THEN + RAISE EXCEPTION USING ERRCODE = '23514', MESSAGE = 'AREA or COMPANY types cannot contain assets with their own operational assignment'; + END IF; + + IF OLD.operational_role = 'AREA'::asset_type_operational_role + AND (NEW.operational_role <> OLD.operational_role OR NEW.is_active = false) + AND ( + EXISTS ( + SELECT 1 FROM area_company_relations relation + INNER JOIN assets area ON area.id = relation.area_id + WHERE area.asset_type_id = OLD.id AND relation.valid_until IS NULL + ) + OR EXISTS ( + SELECT 1 FROM assets assigned + INNER JOIN assets area ON area.id = assigned.operational_area_id + WHERE area.asset_type_id = OLD.id + ) + ) THEN + RAISE EXCEPTION USING ERRCODE = '23514', MESSAGE = 'AREA type cannot change role or be inactivated while it is operationally in use'; + END IF; + + IF OLD.operational_role = 'COMPANY'::asset_type_operational_role + AND (NEW.operational_role <> OLD.operational_role OR NEW.is_active = false) + AND ( + EXISTS ( + SELECT 1 FROM area_company_relations relation + INNER JOIN assets company ON company.id = relation.company_id + WHERE company.asset_type_id = OLD.id AND relation.valid_until IS NULL + ) + OR EXISTS ( + SELECT 1 FROM assets assigned + INNER JOIN assets company ON company.id = assigned.operator_company_id + WHERE company.asset_type_id = OLD.id + ) + ) THEN + RAISE EXCEPTION USING ERRCODE = '23514', MESSAGE = 'COMPANY type cannot change role or be inactivated while it is operationally in use'; + END IF; + RETURN NEW; + END + $$ + `); + await queryRunner.query(` + CREATE TRIGGER trg_asset_types_protect_operational_changes + BEFORE UPDATE OF operational_role, is_active ON asset_types + FOR EACH ROW EXECUTE FUNCTION protect_operational_type_changes() + `); + + await queryRunner.query(` + CREATE FUNCTION enforce_area_company_relation_roles() + RETURNS trigger + LANGUAGE plpgsql + AS $$ + DECLARE + area_role asset_type_operational_role; + company_role asset_type_operational_role; + BEGIN + SELECT asset_type.operational_role INTO area_role + FROM assets asset + INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id + WHERE asset.id = NEW.area_id + AND asset.information_status <> 'INACTIVE' + AND asset_type.is_active = true; + + SELECT asset_type.operational_role INTO company_role + FROM assets asset + INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id + WHERE asset.id = NEW.company_id + AND asset.information_status <> 'INACTIVE' + AND asset_type.is_active = true; + + IF area_role IS DISTINCT FROM 'AREA'::asset_type_operational_role THEN + RAISE EXCEPTION USING ERRCODE = '23514', MESSAGE = 'area relation target must be an active AREA asset'; + END IF; + IF company_role IS DISTINCT FROM 'COMPANY'::asset_type_operational_role THEN + RAISE EXCEPTION USING ERRCODE = '23514', MESSAGE = 'company relation target must be an active COMPANY asset'; + END IF; + IF NEW.area_id = NEW.company_id THEN + RAISE EXCEPTION USING ERRCODE = '23514', MESSAGE = 'area and company must be different assets'; + END IF; + RETURN NEW; + END + $$ + `); + await queryRunner.query(` + CREATE TRIGGER trg_area_company_relation_roles + BEFORE INSERT OR UPDATE OF area_id, company_id ON area_company_relations + FOR EACH ROW EXECUTE FUNCTION enforce_area_company_relation_roles() + `); + + await queryRunner.query(` + CREATE FUNCTION enforce_asset_operational_context() + RETURNS trigger + LANGUAGE plpgsql + AS $$ + DECLARE + asset_role asset_type_operational_role; + area_role asset_type_operational_role; + company_role asset_type_operational_role; + active_relation_id uuid; + BEGIN + IF NEW.operational_area_id IS NULL AND NEW.operator_company_id IS NULL THEN + RETURN NEW; + END IF; + IF NEW.operational_area_id IS NULL OR NEW.operator_company_id IS NULL THEN + RAISE EXCEPTION USING ERRCODE = '23514', MESSAGE = 'operational area and company must be assigned together'; + END IF; + + SELECT operational_role INTO asset_role FROM asset_types WHERE id = NEW.asset_type_id; + IF asset_role <> 'GENERIC'::asset_type_operational_role THEN + RAISE EXCEPTION USING ERRCODE = '23514', MESSAGE = 'area and company assets cannot receive an operational assignment'; + END IF; + + SELECT asset_type.operational_role INTO area_role + FROM assets asset + INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id + WHERE asset.id = NEW.operational_area_id + AND asset.information_status <> 'INACTIVE' + AND asset_type.is_active = true; + SELECT asset_type.operational_role INTO company_role + FROM assets asset + INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id + WHERE asset.id = NEW.operator_company_id + AND asset.information_status <> 'INACTIVE' + AND asset_type.is_active = true; + + IF area_role IS DISTINCT FROM 'AREA'::asset_type_operational_role THEN + RAISE EXCEPTION USING ERRCODE = '23514', MESSAGE = 'operational area must be an active AREA asset'; + END IF; + IF company_role IS DISTINCT FROM 'COMPANY'::asset_type_operational_role THEN + RAISE EXCEPTION USING ERRCODE = '23514', MESSAGE = 'operator company must be an active COMPANY asset'; + END IF; + SELECT relation.id INTO active_relation_id + FROM area_company_relations relation + WHERE relation.area_id = NEW.operational_area_id + AND relation.company_id = NEW.operator_company_id + AND relation.valid_until IS NULL + FOR KEY SHARE; + IF active_relation_id IS NULL THEN + RAISE EXCEPTION USING ERRCODE = '23514', MESSAGE = 'operational area and company do not have an active relation'; + END IF; + IF NEW.parent_id IS NULL OR NOT EXISTS ( + WITH RECURSIVE ancestors AS ( + SELECT id, parent_id FROM assets WHERE id = NEW.parent_id + UNION ALL + SELECT parent.id, parent.parent_id + FROM assets parent + INNER JOIN ancestors current ON parent.id = current.parent_id + ) + SELECT 1 FROM ancestors WHERE id = NEW.operational_area_id LIMIT 1 + ) THEN + RAISE EXCEPTION USING ERRCODE = '23514', MESSAGE = 'operational area must be an ancestor in the physical hierarchy'; + END IF; + RETURN NEW; + END + $$ + `); + await queryRunner.query(` + CREATE TRIGGER trg_assets_operational_context + BEFORE INSERT OR UPDATE OF asset_type_id, parent_id, operational_area_id, operator_company_id ON assets + FOR EACH ROW EXECUTE FUNCTION enforce_asset_operational_context() + `); + + await queryRunner.query(` + CREATE FUNCTION enforce_asset_operational_descendants() + RETURNS trigger + LANGUAGE plpgsql + AS $$ + BEGIN + IF NEW.parent_id IS NOT DISTINCT FROM OLD.parent_id THEN + RETURN NEW; + END IF; + IF EXISTS ( + WITH RECURSIVE descendants AS ( + SELECT child.id, child.parent_id, child.operational_area_id + FROM assets child + WHERE child.parent_id = NEW.id + UNION ALL + SELECT child.id, child.parent_id, child.operational_area_id + FROM assets child + INNER JOIN descendants parent ON child.parent_id = parent.id + ) + SELECT 1 + FROM descendants descendant + WHERE descendant.operational_area_id IS NOT NULL + AND NOT EXISTS ( + WITH RECURSIVE ancestors AS ( + SELECT id, parent_id FROM assets WHERE id = descendant.parent_id + UNION ALL + SELECT parent.id, parent.parent_id + FROM assets parent + INNER JOIN ancestors current ON parent.id = current.parent_id + ) + SELECT 1 FROM ancestors + WHERE id = descendant.operational_area_id + LIMIT 1 + ) + LIMIT 1 + ) THEN + RAISE EXCEPTION USING ERRCODE = '23514', MESSAGE = 'physical hierarchy change would invalidate descendant operational assignments'; + END IF; + RETURN NEW; + END + $$ + `); + await queryRunner.query(` + CREATE TRIGGER trg_assets_operational_descendants + AFTER UPDATE OF parent_id ON assets + FOR EACH ROW EXECUTE FUNCTION enforce_asset_operational_descendants() + `); + + await queryRunner.query(` + CREATE FUNCTION protect_operational_anchor_inactivation() + RETURNS trigger + LANGUAGE plpgsql + AS $$ + DECLARE + role asset_type_operational_role; + BEGIN + IF NEW.information_status IS NOT DISTINCT FROM OLD.information_status + OR NEW.information_status <> 'INACTIVE' THEN + RETURN NEW; + END IF; + SELECT operational_role INTO role FROM asset_types WHERE id = NEW.asset_type_id; + IF role = 'AREA'::asset_type_operational_role AND ( + EXISTS (SELECT 1 FROM area_company_relations WHERE area_id = NEW.id AND valid_until IS NULL) + OR EXISTS (SELECT 1 FROM assets WHERE operational_area_id = NEW.id) + ) THEN + RAISE EXCEPTION USING ERRCODE = '23514', MESSAGE = 'area cannot be inactivated while operational relations or assignments are active'; + END IF; + IF role = 'COMPANY'::asset_type_operational_role AND ( + EXISTS (SELECT 1 FROM area_company_relations WHERE company_id = NEW.id AND valid_until IS NULL) + OR EXISTS (SELECT 1 FROM assets WHERE operator_company_id = NEW.id) + ) THEN + RAISE EXCEPTION USING ERRCODE = '23514', MESSAGE = 'company cannot be inactivated while operational relations or assignments are active'; + END IF; + RETURN NEW; + END + $$ + `); + await queryRunner.query(` + CREATE TRIGGER trg_assets_protect_operational_anchor_inactivation + BEFORE UPDATE OF information_status ON assets + FOR EACH ROW EXECUTE FUNCTION protect_operational_anchor_inactivation() + `); + + for (const permission of newPermissions) { + await queryRunner.query( + `INSERT INTO permissions (code, description) + VALUES ($1, $2) + ON CONFLICT (code) DO UPDATE SET description = EXCLUDED.description`, + [ + permission, + permission === 'asset_relations.read' + ? 'Consultar relaciones operativas entre áreas y empresas' + : 'Administrar relaciones operativas entre áreas y empresas', + ], + ); + } + await queryRunner.query(` + WITH mapping(role_code, permission_code) AS (VALUES ${rolePermissionValues}) + 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 (role_id, permission_id) DO NOTHING + `); + + const appRole = process.env.DB_APP_USER; + if (!appRole) throw new Error('Missing required environment variable: DB_APP_USER'); + const roleRows = (await queryRunner.query( + 'SELECT 1 FROM pg_roles WHERE rolname = $1', + [appRole], + )) as unknown[]; + if (roleRows.length !== 1) throw new Error('Configured DB_APP_USER does not exist'); + const applicationRole = quoteIdentifier(appRole); + await queryRunner.query(` + GRANT SELECT, INSERT, UPDATE ON TABLE area_company_relations + TO ${applicationRole} + `); + await queryRunner.query(` + REVOKE DELETE ON TABLE area_company_relations + FROM ${applicationRole} + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + UPDATE asset_versions + SET snapshot = (snapshot #- '{operationalArea}' #- '{operatorCompany}' #- '{type,operationalRole}') + `); + + await queryRunner.query(` + WITH mapping(role_code, permission_code) AS (VALUES ${rolePermissionValues}) + DELETE FROM role_permissions role_permission + USING roles role, permissions permission, mapping + WHERE role_permission.role_id = role.id + AND role_permission.permission_id = permission.id + AND role.code = mapping.role_code + AND permission.code = mapping.permission_code + `); + await queryRunner.query(` + DELETE FROM permissions + WHERE code = ANY($1::text[]) + AND NOT EXISTS ( + SELECT 1 FROM role_permissions WHERE permission_id = permissions.id + ) + `, [newPermissions]); + + await queryRunner.query('DROP TRIGGER trg_asset_types_protect_operational_changes ON asset_types'); + await queryRunner.query('DROP FUNCTION protect_operational_type_changes()'); + await queryRunner.query('DROP TRIGGER trg_assets_protect_operational_anchor_inactivation ON assets'); + await queryRunner.query('DROP FUNCTION protect_operational_anchor_inactivation()'); + await queryRunner.query('DROP TRIGGER trg_assets_operational_descendants ON assets'); + await queryRunner.query('DROP FUNCTION enforce_asset_operational_descendants()'); + await queryRunner.query('DROP TRIGGER trg_assets_operational_context ON assets'); + await queryRunner.query('DROP FUNCTION enforce_asset_operational_context()'); + await queryRunner.query('ALTER TABLE assets DROP CONSTRAINT fk_assets_operator_company'); + await queryRunner.query('ALTER TABLE assets DROP CONSTRAINT fk_assets_operational_area'); + await queryRunner.query('ALTER TABLE assets DROP CONSTRAINT chk_assets_operational_assignment_pair'); + await queryRunner.query('DROP INDEX idx_assets_operational_context'); + await queryRunner.query('DROP INDEX idx_assets_operator_company_id'); + await queryRunner.query('DROP INDEX idx_assets_operational_area_id'); + await queryRunner.query('ALTER TABLE assets DROP COLUMN operator_company_id'); + await queryRunner.query('ALTER TABLE assets DROP COLUMN operational_area_id'); + + await queryRunner.query('DROP TRIGGER trg_area_company_relation_roles ON area_company_relations'); + await queryRunner.query('DROP FUNCTION enforce_area_company_relation_roles()'); + await queryRunner.query('DROP TABLE area_company_relations'); + await queryRunner.query('DROP INDEX idx_asset_types_operational_role'); + await queryRunner.query('ALTER TABLE asset_types DROP COLUMN operational_role'); + await queryRunner.query('DROP TYPE asset_type_operational_role'); + } +} diff --git a/api-v3/src/database/migrations/1787508000000-phase-d5-3-3-definitive-operational-model.ts b/api-v3/src/database/migrations/1787508000000-phase-d5-3-3-definitive-operational-model.ts new file mode 100644 index 0000000..569569c --- /dev/null +++ b/api-v3/src/database/migrations/1787508000000-phase-d5-3-3-definitive-operational-model.ts @@ -0,0 +1,354 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +const newPermissions = [ + 'asset_registry.read', + 'asset_registry.manage', + 'assets.change_operational_status', +] as const; + +const rolePermissionValues = ` + ('admin', 'asset_registry.read'), ('admin', 'asset_registry.manage'), ('admin', 'assets.change_operational_status'), + ('director', 'asset_registry.read'), ('director', 'asset_registry.manage'), ('director', 'assets.change_operational_status'), + ('supervisor', 'asset_registry.read'), ('supervisor', 'asset_registry.manage'), ('supervisor', 'assets.change_operational_status'), + ('inspector', 'asset_registry.read'), ('auditor', 'asset_registry.read') +`; + +function quoteIdentifier(identifier: string): string { + return `"${identifier.replaceAll('"', '""')}"`; +} + +export class PhaseD533DefinitiveOperationalModel1787508000000 implements MigrationInterface { + name = 'PhaseD533DefinitiveOperationalModel1787508000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TYPE asset_operational_status AS ENUM ('UNKNOWN','IN_SERVICE','TEMPORARILY_OUT_OF_SERVICE','OUT_OF_SERVICE','DECOMMISSIONED','ABANDONED')`); + await queryRunner.query(`ALTER TABLE assets ADD COLUMN operational_status asset_operational_status NOT NULL DEFAULT 'UNKNOWN'`); + await queryRunner.query(`CREATE INDEX idx_assets_operational_status ON assets (operational_status)`); + + await queryRunner.query(` + UPDATE asset_types SET name='Área', + description='Área hidrocarburífera administrada como ancla territorial. Los permisos, concesiones y titulares se registran por separado en la capa legal.', + updated_at=CURRENT_TIMESTAMP WHERE code='area' + `); + await queryRunner.query(` + UPDATE asset_types SET name='Organización', + description='Entidad jurídica u organización administrada (empresa, UTE u otra figura). Su rol como operadora, titular o participante se registra mediante relaciones históricas.', + updated_at=CURRENT_TIMESTAMP WHERE code='empresa' + `); + await queryRunner.query(` + UPDATE asset_attribute_definitions d SET is_active=false,updated_at=CURRENT_TIMESTAMP + FROM asset_types t WHERE d.asset_type_id=t.id AND t.code='area' + AND d.code IN ('situacion_concesion','vencimiento_concesion') + `); + await queryRunner.query(` + INSERT INTO asset_attribute_definitions (asset_type_id,code,name,data_type,is_required,is_active,unit,options,sort_order) + SELECT t.id,p.code,p.name,'TEXT'::asset_attribute_data_type,false,true,NULL,NULL,p.sort_order + FROM asset_types t CROSS JOIN (VALUES ('cuenca','Cuenca',20),('departamento','Departamento',30)) p(code,name,sort_order) + WHERE t.code='area' ON CONFLICT DO NOTHING + `); + + await queryRunner.query(`CREATE TYPE organization_kind AS ENUM ('COMPANY','UTE','PUBLIC_ENTITY','OTHER')`); + await queryRunner.query(` + CREATE TABLE organization_profiles ( + asset_id uuid PRIMARY KEY, + organization_kind organization_kind NOT NULL DEFAULT 'COMPANY', + legal_name varchar(240), tax_id varchar(32), notes text, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by uuid, + CONSTRAINT fk_organization_profiles_asset FOREIGN KEY (asset_id) REFERENCES assets(id) ON DELETE RESTRICT, + CONSTRAINT fk_organization_profiles_updated_by FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL + ) + `); + await queryRunner.query(`CREATE UNIQUE INDEX uq_organization_profiles_tax_id ON organization_profiles (tax_id) WHERE tax_id IS NOT NULL`); + await queryRunner.query(` + INSERT INTO organization_profiles (asset_id,organization_kind,legal_name) + SELECT a.id,'COMPANY'::organization_kind,a.name FROM assets a JOIN asset_types t ON t.id=a.asset_type_id + WHERE t.operational_role='COMPANY' ON CONFLICT (asset_id) DO NOTHING + `); + + await queryRunner.query(`CREATE TYPE source_document_type AS ENUM ('NOTE','TECHNICAL_REPORT','INSPECTION_ACT','INVENTORY','RESOLUTION','DECREE','CONTRACT','SPREADSHEET','OTHER')`); + await queryRunner.query(` + CREATE TABLE source_documents ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), document_type source_document_type NOT NULL, + document_number varchar(160), title varchar(300) NOT NULL, issuer varchar(240), document_date date, + external_reference varchar(500), notes text, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_by uuid, updated_by uuid, + CONSTRAINT chk_source_documents_title CHECK (length(btrim(title)) >= 3), + CONSTRAINT fk_source_documents_created_by FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL, + CONSTRAINT fk_source_documents_updated_by FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL + ) + `); + await queryRunner.query(`CREATE INDEX idx_source_documents_number ON source_documents (document_number)`); + await queryRunner.query(`CREATE INDEX idx_source_documents_document_date ON source_documents (document_date DESC)`); + await queryRunner.query(`CREATE UNIQUE INDEX uq_source_documents_number_issuer ON source_documents (document_number,issuer) WHERE document_number IS NOT NULL AND issuer IS NOT NULL`); + + await queryRunner.query(`CREATE TYPE organization_membership_role AS ENUM ('MEMBER','LEAD_MEMBER','OTHER')`); + await queryRunner.query(` + CREATE TABLE organization_memberships ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), parent_organization_id uuid NOT NULL, member_organization_id uuid NOT NULL, + role organization_membership_role NOT NULL DEFAULT 'MEMBER', participation_percent numeric(7,4), + valid_from date NOT NULL DEFAULT CURRENT_DATE, valid_until date, source_document_id uuid, notes text, end_reason text, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_by uuid, ended_by uuid, + CONSTRAINT chk_organization_membership_different CHECK (parent_organization_id <> member_organization_id), + CONSTRAINT chk_organization_membership_participation CHECK (participation_percent IS NULL OR (participation_percent > 0 AND participation_percent <= 100)), + CONSTRAINT chk_organization_membership_dates CHECK (valid_until IS NULL OR valid_until >= valid_from), + CONSTRAINT fk_organization_membership_parent FOREIGN KEY (parent_organization_id) REFERENCES assets(id) ON DELETE RESTRICT, + CONSTRAINT fk_organization_membership_member FOREIGN KEY (member_organization_id) REFERENCES assets(id) ON DELETE RESTRICT, + CONSTRAINT fk_organization_membership_document FOREIGN KEY (source_document_id) REFERENCES source_documents(id) ON DELETE SET NULL, + CONSTRAINT fk_organization_membership_created_by FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL, + CONSTRAINT fk_organization_membership_ended_by FOREIGN KEY (ended_by) REFERENCES users(id) ON DELETE SET NULL + ) + `); + await queryRunner.query(`CREATE INDEX idx_organization_memberships_parent_id ON organization_memberships (parent_organization_id)`); + await queryRunner.query(`CREATE INDEX idx_organization_memberships_member_id ON organization_memberships (member_organization_id)`); + await queryRunner.query(`CREATE INDEX idx_organization_memberships_valid_until ON organization_memberships (valid_until)`); + await queryRunner.query(`CREATE UNIQUE INDEX uq_organization_memberships_active_role ON organization_memberships (parent_organization_id,member_organization_id,role) WHERE valid_until IS NULL`); + + await queryRunner.query(`CREATE TYPE asset_source_document_relation_type AS ENUM ('SOURCE','MENTIONS','VALIDATES','SUPERSEDES','OTHER')`); + await queryRunner.query(` + CREATE TABLE asset_source_documents ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), asset_id uuid NOT NULL, document_id uuid NOT NULL, + relation_type asset_source_document_relation_type NOT NULL DEFAULT 'SOURCE', notes text, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, created_by uuid, + CONSTRAINT fk_asset_source_documents_asset FOREIGN KEY (asset_id) REFERENCES assets(id) ON DELETE RESTRICT, + CONSTRAINT fk_asset_source_documents_document FOREIGN KEY (document_id) REFERENCES source_documents(id) ON DELETE RESTRICT, + CONSTRAINT fk_asset_source_documents_created_by FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL, + CONSTRAINT uq_asset_source_documents UNIQUE (asset_id,document_id,relation_type) + ) + `); + await queryRunner.query(`CREATE INDEX idx_asset_source_documents_asset_id ON asset_source_documents (asset_id)`); + await queryRunner.query(`CREATE INDEX idx_asset_source_documents_document_id ON asset_source_documents (document_id)`); + + await queryRunner.query(` + CREATE TABLE asset_external_identifiers ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), asset_id uuid NOT NULL, namespace varchar(80) NOT NULL, value varchar(180) NOT NULL, + valid_from timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, valid_until timestamptz, source_document_id uuid, notes text, end_reason text, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, created_by uuid, ended_by uuid, + CONSTRAINT chk_asset_external_identifier_namespace CHECK (namespace ~ '^[A-Z0-9][A-Z0-9._/-]{1,79}$'), + CONSTRAINT chk_asset_external_identifier_value CHECK (length(btrim(value)) >= 1), + CONSTRAINT chk_asset_external_identifier_dates CHECK (valid_until IS NULL OR valid_until >= valid_from), + CONSTRAINT fk_asset_external_identifier_asset FOREIGN KEY (asset_id) REFERENCES assets(id) ON DELETE RESTRICT, + CONSTRAINT fk_asset_external_identifier_document FOREIGN KEY (source_document_id) REFERENCES source_documents(id) ON DELETE SET NULL, + CONSTRAINT fk_asset_external_identifier_created_by FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL, + CONSTRAINT fk_asset_external_identifier_ended_by FOREIGN KEY (ended_by) REFERENCES users(id) ON DELETE SET NULL + ) + `); + await queryRunner.query(`CREATE INDEX idx_asset_external_identifiers_asset_id ON asset_external_identifiers (asset_id)`); + await queryRunner.query(`CREATE INDEX idx_asset_external_identifiers_namespace ON asset_external_identifiers (namespace)`); + await queryRunner.query(`CREATE UNIQUE INDEX uq_asset_external_identifiers_active_namespace_value ON asset_external_identifiers (namespace,value) WHERE valid_until IS NULL`); + + await queryRunner.query(`CREATE TYPE area_organization_role AS ENUM ('OPERATOR','TECHNICAL_OPERATOR','CONCESSIONAIRE','PERMIT_HOLDER','PARTICIPANT','OTHER')`); + await queryRunner.query(`DROP INDEX uq_area_company_relations_active_pair`); + await queryRunner.query(` + ALTER TABLE area_company_relations + ADD COLUMN relation_role area_organization_role NOT NULL DEFAULT 'OPERATOR', + ADD COLUMN participation_percent numeric(7,4), ADD COLUMN legal_instrument varchar(240), ADD COLUMN source_document_id uuid, + ADD CONSTRAINT chk_area_company_relations_participation CHECK (participation_percent IS NULL OR (participation_percent > 0 AND participation_percent <= 100)), + ADD CONSTRAINT fk_area_company_relations_source_document FOREIGN KEY (source_document_id) REFERENCES source_documents(id) ON DELETE SET NULL + `); + await queryRunner.query(`CREATE UNIQUE INDEX uq_area_company_relations_active_role ON area_company_relations (area_id,company_id,relation_role) WHERE valid_until IS NULL`); + await queryRunner.query(`CREATE INDEX idx_area_company_relations_role ON area_company_relations (relation_role)`); + + await queryRunner.query(` + CREATE OR REPLACE FUNCTION enforce_asset_operational_context() RETURNS trigger LANGUAGE plpgsql AS $$ + DECLARE asset_role asset_type_operational_role; area_role asset_type_operational_role; company_role asset_type_operational_role; active_relation_id uuid; + BEGIN + IF NEW.operational_area_id IS NULL AND NEW.operator_company_id IS NULL THEN RETURN NEW; END IF; + IF NEW.operational_area_id IS NULL OR NEW.operator_company_id IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='operational area and organization must be assigned together'; END IF; + SELECT operational_role INTO asset_role FROM asset_types WHERE id=NEW.asset_type_id; + IF asset_role <> 'GENERIC'::asset_type_operational_role THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='area and organization assets cannot receive an operational assignment'; END IF; + SELECT t.operational_role INTO area_role FROM assets a JOIN asset_types t ON t.id=a.asset_type_id WHERE a.id=NEW.operational_area_id AND a.information_status<>'INACTIVE' AND t.is_active=true; + SELECT t.operational_role INTO company_role FROM assets a JOIN asset_types t ON t.id=a.asset_type_id WHERE a.id=NEW.operator_company_id AND a.information_status<>'INACTIVE' AND t.is_active=true; + IF area_role IS DISTINCT FROM 'AREA'::asset_type_operational_role THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='operational area must be an active AREA asset'; END IF; + IF company_role IS DISTINCT FROM 'COMPANY'::asset_type_operational_role THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='operator organization must be an active COMPANY-role asset'; END IF; + SELECT r.id INTO active_relation_id FROM area_company_relations r WHERE r.area_id=NEW.operational_area_id AND r.company_id=NEW.operator_company_id AND r.relation_role='OPERATOR'::area_organization_role AND r.valid_until IS NULL FOR KEY SHARE; + IF active_relation_id IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='operational area and organization do not have an active OPERATOR relation'; END IF; + IF NEW.parent_id IS NULL OR NOT EXISTS (WITH RECURSIVE ancestors AS (SELECT id,parent_id FROM assets WHERE id=NEW.parent_id UNION ALL SELECT p.id,p.parent_id FROM assets p JOIN ancestors c ON p.id=c.parent_id) SELECT 1 FROM ancestors WHERE id=NEW.operational_area_id LIMIT 1) THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='operational area must be an ancestor in the physical hierarchy'; END IF; + RETURN NEW; + END $$ + `); + + await queryRunner.query(`CREATE TYPE area_legal_right_type AS ENUM ('EXPLOITATION_CONCESSION','EXPLORATION_PERMIT','TRANSPORT_CONCESSION','OTHER')`); + await queryRunner.query(`CREATE TYPE area_legal_right_status AS ENUM ('ACTIVE','EXPIRED','REVOKED','PENDING')`); + await queryRunner.query(` + CREATE TABLE area_legal_rights ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), area_id uuid NOT NULL, right_type area_legal_right_type NOT NULL, + name varchar(260) NOT NULL, instrument_number varchar(180), valid_from date, valid_until date, + status area_legal_right_status NOT NULL DEFAULT 'ACTIVE', source_document_id uuid, notes text, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, created_by uuid, updated_by uuid, + CONSTRAINT chk_area_legal_right_dates CHECK (valid_until IS NULL OR valid_from IS NULL OR valid_until >= valid_from), + CONSTRAINT chk_area_legal_right_name CHECK (length(btrim(name)) >= 3), + CONSTRAINT fk_area_legal_right_area FOREIGN KEY (area_id) REFERENCES assets(id) ON DELETE RESTRICT, + CONSTRAINT fk_area_legal_right_source_document FOREIGN KEY (source_document_id) REFERENCES source_documents(id) ON DELETE SET NULL, + CONSTRAINT fk_area_legal_right_created_by FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL, + CONSTRAINT fk_area_legal_right_updated_by FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL + ) + `); + await queryRunner.query(`CREATE INDEX idx_area_legal_rights_area_id ON area_legal_rights (area_id)`); + await queryRunner.query(`CREATE INDEX idx_area_legal_rights_valid_until ON area_legal_rights (valid_until)`); + + await queryRunner.query(`CREATE TYPE area_legal_right_organization_role AS ENUM ('HOLDER','PARTICIPANT','OPERATOR','OTHER')`); + await queryRunner.query(` + CREATE TABLE area_legal_right_organizations ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), right_id uuid NOT NULL, organization_id uuid NOT NULL, + role area_legal_right_organization_role NOT NULL, participation_percent numeric(7,4), valid_from date NOT NULL DEFAULT CURRENT_DATE, + valid_until date, notes text, end_reason text, created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, created_by uuid, ended_by uuid, + CONSTRAINT chk_area_legal_right_org_participation CHECK (participation_percent IS NULL OR (participation_percent > 0 AND participation_percent <= 100)), + CONSTRAINT chk_area_legal_right_org_dates CHECK (valid_until IS NULL OR valid_until >= valid_from), + CONSTRAINT fk_area_legal_right_org_right FOREIGN KEY (right_id) REFERENCES area_legal_rights(id) ON DELETE RESTRICT, + CONSTRAINT fk_area_legal_right_org_organization FOREIGN KEY (organization_id) REFERENCES assets(id) ON DELETE RESTRICT, + CONSTRAINT fk_area_legal_right_org_created_by FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL, + CONSTRAINT fk_area_legal_right_org_ended_by FOREIGN KEY (ended_by) REFERENCES users(id) ON DELETE SET NULL + ) + `); + await queryRunner.query(`CREATE INDEX idx_area_legal_right_org_right_id ON area_legal_right_organizations (right_id)`); + await queryRunner.query(`CREATE INDEX idx_area_legal_right_org_organization_id ON area_legal_right_organizations (organization_id)`); + await queryRunner.query(`CREATE INDEX idx_area_legal_right_org_valid_until ON area_legal_right_organizations (valid_until)`); + await queryRunner.query(`CREATE UNIQUE INDEX uq_area_legal_right_org_active_role ON area_legal_right_organizations (right_id,organization_id,role) WHERE valid_until IS NULL`); + + await queryRunner.query(` + CREATE FUNCTION enforce_d533_registry_roles() RETURNS trigger LANGUAGE plpgsql AS $$ + DECLARE resolved_role asset_type_operational_role; parent_kind organization_kind; member_kind organization_kind; + BEGIN + IF TG_TABLE_NAME='organization_profiles' THEN + SELECT t.operational_role INTO resolved_role FROM assets a JOIN asset_types t ON t.id=a.asset_type_id WHERE a.id=NEW.asset_id; + IF resolved_role IS DISTINCT FROM 'COMPANY'::asset_type_operational_role THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='organization profile requires a COMPANY-role asset'; END IF; + IF NEW.organization_kind <> 'UTE'::organization_kind AND EXISTS (SELECT 1 FROM organization_memberships m WHERE m.parent_organization_id=NEW.asset_id AND m.valid_until IS NULL) THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='organization with active members must remain profiled as UTE'; END IF; + IF NEW.organization_kind = 'UTE'::organization_kind AND EXISTS (SELECT 1 FROM organization_memberships m WHERE m.member_organization_id=NEW.asset_id AND m.valid_until IS NULL) THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='organization that actively belongs to a UTE cannot itself become UTE'; END IF; + ELSIF TG_TABLE_NAME='organization_memberships' THEN + IF NEW.parent_organization_id=NEW.member_organization_id THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='organization cannot be a member of itself'; END IF; + SELECT t.operational_role,p.organization_kind INTO resolved_role,parent_kind FROM assets a JOIN asset_types t ON t.id=a.asset_type_id LEFT JOIN organization_profiles p ON p.asset_id=a.id WHERE a.id=NEW.parent_organization_id; + IF resolved_role IS DISTINCT FROM 'COMPANY'::asset_type_operational_role OR parent_kind IS DISTINCT FROM 'UTE'::organization_kind THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='parent organization membership requires an organization profiled as UTE'; END IF; + SELECT t.operational_role,p.organization_kind INTO resolved_role,member_kind FROM assets a JOIN asset_types t ON t.id=a.asset_type_id LEFT JOIN organization_profiles p ON p.asset_id=a.id WHERE a.id=NEW.member_organization_id; + IF resolved_role IS DISTINCT FROM 'COMPANY'::asset_type_operational_role OR member_kind IS NULL OR member_kind='UTE'::organization_kind THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='UTE member must be a non-UTE organization'; END IF; + ELSIF TG_TABLE_NAME='area_legal_rights' THEN + SELECT t.operational_role INTO resolved_role FROM assets a JOIN asset_types t ON t.id=a.asset_type_id WHERE a.id=NEW.area_id; + IF resolved_role IS DISTINCT FROM 'AREA'::asset_type_operational_role THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='legal right requires an AREA asset'; END IF; + ELSIF TG_TABLE_NAME='area_legal_right_organizations' THEN + SELECT t.operational_role INTO resolved_role FROM assets a JOIN asset_types t ON t.id=a.asset_type_id WHERE a.id=NEW.organization_id; + IF resolved_role IS DISTINCT FROM 'COMPANY'::asset_type_operational_role THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='legal right participant requires a COMPANY-role asset'; END IF; + END IF; + RETURN NEW; + END $$ + `); + await queryRunner.query(`CREATE TRIGGER trg_organization_profiles_role BEFORE INSERT OR UPDATE OF asset_id,organization_kind ON organization_profiles FOR EACH ROW EXECUTE FUNCTION enforce_d533_registry_roles()`); + await queryRunner.query(`CREATE TRIGGER trg_organization_memberships_role BEFORE INSERT OR UPDATE OF parent_organization_id,member_organization_id ON organization_memberships FOR EACH ROW EXECUTE FUNCTION enforce_d533_registry_roles()`); + await queryRunner.query(`CREATE TRIGGER trg_area_legal_rights_role BEFORE INSERT OR UPDATE OF area_id ON area_legal_rights FOR EACH ROW EXECUTE FUNCTION enforce_d533_registry_roles()`); + await queryRunner.query(`CREATE TRIGGER trg_area_legal_right_org_role BEFORE INSERT OR UPDATE OF organization_id ON area_legal_right_organizations FOR EACH ROW EXECUTE FUNCTION enforce_d533_registry_roles()`); + + await queryRunner.query(` + CREATE OR REPLACE FUNCTION protect_operational_anchor_inactivation() RETURNS trigger LANGUAGE plpgsql AS $$ + DECLARE role asset_type_operational_role; + BEGIN + IF NEW.information_status IS NOT DISTINCT FROM OLD.information_status OR NEW.information_status <> 'INACTIVE' THEN RETURN NEW; END IF; + SELECT operational_role INTO role FROM asset_types WHERE id=NEW.asset_type_id; + IF role='AREA'::asset_type_operational_role AND ( + EXISTS (SELECT 1 FROM area_company_relations WHERE area_id=NEW.id AND valid_until IS NULL) OR EXISTS (SELECT 1 FROM assets WHERE operational_area_id=NEW.id) OR + EXISTS (SELECT 1 FROM area_legal_rights r WHERE r.area_id=NEW.id AND r.status IN ('ACTIVE','PENDING') AND (r.valid_until IS NULL OR r.valid_until>=CURRENT_DATE)) + ) THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='area cannot be inactivated while operational relations, assignments or legal rights are active'; END IF; + IF role='COMPANY'::asset_type_operational_role AND ( + EXISTS (SELECT 1 FROM area_company_relations WHERE company_id=NEW.id AND valid_until IS NULL) OR EXISTS (SELECT 1 FROM assets WHERE operator_company_id=NEW.id) OR + EXISTS (SELECT 1 FROM organization_memberships m WHERE (m.parent_organization_id=NEW.id OR m.member_organization_id=NEW.id) AND m.valid_until IS NULL) OR + EXISTS (SELECT 1 FROM area_legal_right_organizations p JOIN area_legal_rights r ON r.id=p.right_id WHERE p.organization_id=NEW.id AND p.valid_until IS NULL AND r.status IN ('ACTIVE','PENDING') AND (r.valid_until IS NULL OR r.valid_until>=CURRENT_DATE)) + ) THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='organization cannot be inactivated while operational relations, assignments, memberships or legal participation are active'; END IF; + RETURN NEW; + END $$ + `); + + await queryRunner.query(`UPDATE asset_versions SET snapshot=jsonb_set(snapshot,'{operationalStatus}','"UNKNOWN"'::jsonb,true) WHERE NOT (snapshot ? 'operationalStatus')`); + + const descriptions: Record = { + 'asset_registry.read':'Consultar identificadores, documentos, organizaciones y derechos del Maestro', + 'asset_registry.manage':'Administrar identificadores, documentos, organizaciones y derechos del Maestro', + 'assets.change_operational_status':'Cambiar el estado operativo de activos', + }; + for (const permission of newPermissions) { + await queryRunner.query(`INSERT INTO permissions (code,description) VALUES ($1,$2) ON CONFLICT (code) DO UPDATE SET description=EXCLUDED.description`,[permission,descriptions[permission]]); + } + await queryRunner.query(`WITH mapping(role_code,permission_code) AS (VALUES ${rolePermissionValues}) INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM mapping m JOIN roles r ON r.code=m.role_code JOIN permissions p ON p.code=m.permission_code ON CONFLICT (role_id,permission_id) DO NOTHING`); + + const appRole=process.env.DB_APP_USER; + if (!appRole) throw new Error('Missing required environment variable: DB_APP_USER'); + const roleRows=(await queryRunner.query('SELECT 1 FROM pg_roles WHERE rolname=$1',[appRole])) as unknown[]; + if (roleRows.length!==1) throw new Error('Configured DB_APP_USER does not exist'); + const applicationRole=quoteIdentifier(appRole); + const tables=['organization_profiles','organization_memberships','source_documents','asset_source_documents','asset_external_identifiers','area_legal_rights','area_legal_right_organizations'].map(quoteIdentifier).join(', '); + await queryRunner.query(`GRANT SELECT, INSERT, UPDATE ON TABLE ${tables} TO ${applicationRole}`); + await queryRunner.query(`REVOKE DELETE ON TABLE ${tables} FROM ${applicationRole}`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`WITH mapping(role_code,permission_code) AS (VALUES ${rolePermissionValues}) DELETE FROM role_permissions rp USING roles r,permissions p,mapping m WHERE rp.role_id=r.id AND rp.permission_id=p.id AND r.code=m.role_code AND p.code=m.permission_code`); + await queryRunner.query(`DELETE FROM permissions WHERE code=ANY($1::text[]) AND NOT EXISTS (SELECT 1 FROM role_permissions WHERE permission_id=permissions.id)`,[newPermissions]); + await queryRunner.query(`UPDATE asset_versions SET snapshot=snapshot-'operationalStatus'`); + + await queryRunner.query(` + CREATE OR REPLACE FUNCTION protect_operational_anchor_inactivation() RETURNS trigger LANGUAGE plpgsql AS $$ + DECLARE role asset_type_operational_role; BEGIN + IF NEW.information_status IS NOT DISTINCT FROM OLD.information_status OR NEW.information_status<>'INACTIVE' THEN RETURN NEW; END IF; + SELECT operational_role INTO role FROM asset_types WHERE id=NEW.asset_type_id; + IF role='AREA'::asset_type_operational_role AND (EXISTS (SELECT 1 FROM area_company_relations WHERE area_id=NEW.id AND valid_until IS NULL) OR EXISTS (SELECT 1 FROM assets WHERE operational_area_id=NEW.id)) THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='area cannot be inactivated while operational relations or assignments are active'; END IF; + IF role='COMPANY'::asset_type_operational_role AND (EXISTS (SELECT 1 FROM area_company_relations WHERE company_id=NEW.id AND valid_until IS NULL) OR EXISTS (SELECT 1 FROM assets WHERE operator_company_id=NEW.id)) THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='company cannot be inactivated while operational relations or assignments are active'; END IF; + RETURN NEW; END $$ + `); + + await queryRunner.query('DROP TRIGGER trg_area_legal_right_org_role ON area_legal_right_organizations'); + await queryRunner.query('DROP TRIGGER trg_area_legal_rights_role ON area_legal_rights'); + await queryRunner.query('DROP TRIGGER trg_organization_memberships_role ON organization_memberships'); + await queryRunner.query('DROP TRIGGER trg_organization_profiles_role ON organization_profiles'); + await queryRunner.query('DROP FUNCTION enforce_d533_registry_roles()'); + await queryRunner.query('DROP TABLE area_legal_right_organizations'); + await queryRunner.query('DROP TYPE area_legal_right_organization_role'); + await queryRunner.query('DROP TABLE area_legal_rights'); + await queryRunner.query('DROP TYPE area_legal_right_status'); + await queryRunner.query('DROP TYPE area_legal_right_type'); + + await queryRunner.query(` + CREATE OR REPLACE FUNCTION enforce_asset_operational_context() RETURNS trigger LANGUAGE plpgsql AS $$ + DECLARE asset_role asset_type_operational_role; area_role asset_type_operational_role; company_role asset_type_operational_role; active_relation_id uuid; + BEGIN + IF NEW.operational_area_id IS NULL AND NEW.operator_company_id IS NULL THEN RETURN NEW; END IF; + IF NEW.operational_area_id IS NULL OR NEW.operator_company_id IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='operational area and company must be assigned together'; END IF; + SELECT operational_role INTO asset_role FROM asset_types WHERE id=NEW.asset_type_id; + IF asset_role <> 'GENERIC'::asset_type_operational_role THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='area and company assets cannot receive an operational assignment'; END IF; + SELECT t.operational_role INTO area_role FROM assets a JOIN asset_types t ON t.id=a.asset_type_id WHERE a.id=NEW.operational_area_id AND a.information_status<>'INACTIVE' AND t.is_active=true; + SELECT t.operational_role INTO company_role FROM assets a JOIN asset_types t ON t.id=a.asset_type_id WHERE a.id=NEW.operator_company_id AND a.information_status<>'INACTIVE' AND t.is_active=true; + IF area_role IS DISTINCT FROM 'AREA'::asset_type_operational_role THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='operational area must be an active AREA asset'; END IF; + IF company_role IS DISTINCT FROM 'COMPANY'::asset_type_operational_role THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='operator company must be an active COMPANY asset'; END IF; + SELECT r.id INTO active_relation_id FROM area_company_relations r WHERE r.area_id=NEW.operational_area_id AND r.company_id=NEW.operator_company_id AND r.valid_until IS NULL FOR KEY SHARE; + IF active_relation_id IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='operational area and company do not have an active relation'; END IF; + IF NEW.parent_id IS NULL OR NOT EXISTS (WITH RECURSIVE ancestors AS (SELECT id,parent_id FROM assets WHERE id=NEW.parent_id UNION ALL SELECT p.id,p.parent_id FROM assets p JOIN ancestors c ON p.id=c.parent_id) SELECT 1 FROM ancestors WHERE id=NEW.operational_area_id LIMIT 1) THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='operational area must be an ancestor in the physical hierarchy'; END IF; + RETURN NEW; END $$ + `); + await queryRunner.query('DROP INDEX idx_area_company_relations_role'); + await queryRunner.query('DROP INDEX uq_area_company_relations_active_role'); + await queryRunner.query('ALTER TABLE area_company_relations DROP CONSTRAINT fk_area_company_relations_source_document'); + await queryRunner.query('ALTER TABLE area_company_relations DROP CONSTRAINT chk_area_company_relations_participation'); + await queryRunner.query('ALTER TABLE area_company_relations DROP COLUMN source_document_id,DROP COLUMN legal_instrument,DROP COLUMN participation_percent,DROP COLUMN relation_role'); + await queryRunner.query(`CREATE UNIQUE INDEX uq_area_company_relations_active_pair ON area_company_relations (area_id,company_id) WHERE valid_until IS NULL`); + await queryRunner.query('DROP TYPE area_organization_role'); + + await queryRunner.query('DROP TABLE asset_external_identifiers'); + await queryRunner.query('DROP TABLE asset_source_documents'); + await queryRunner.query('DROP TYPE asset_source_document_relation_type'); + await queryRunner.query('DROP TABLE organization_memberships'); + await queryRunner.query('DROP TYPE organization_membership_role'); + await queryRunner.query('DROP TABLE source_documents'); + await queryRunner.query('DROP TYPE source_document_type'); + await queryRunner.query('DROP TABLE organization_profiles'); + await queryRunner.query('DROP TYPE organization_kind'); + + await queryRunner.query(`UPDATE asset_types SET name='Área / Concesión',description='Área hidrocarburífera o concesión administrada. Funciona como ancla territorial y operativa.',updated_at=CURRENT_TIMESTAMP WHERE code='area'`); + await queryRunner.query(`UPDATE asset_types SET name='Empresa / Operadora',description='Empresa operadora, concesionaria o integrante de una explotación. Se vincula a una o más áreas mediante relaciones operativas históricas.',updated_at=CURRENT_TIMESTAMP WHERE code='empresa'`); + await queryRunner.query(`UPDATE asset_attribute_definitions d SET is_active=true,updated_at=CURRENT_TIMESTAMP FROM asset_types t WHERE d.asset_type_id=t.id AND t.code='area' AND d.code IN ('situacion_concesion','vencimiento_concesion')`); + await queryRunner.query(`DELETE FROM asset_attribute_definitions d USING asset_types t WHERE d.asset_type_id=t.id AND t.code='area' AND d.code IN ('cuenca','departamento') AND NOT EXISTS (SELECT 1 FROM asset_attribute_values v WHERE v.definition_id=d.id)`); + + await queryRunner.query('DROP INDEX idx_assets_operational_status'); + await queryRunner.query('ALTER TABLE assets DROP COLUMN operational_status'); + await queryRunner.query('DROP TYPE asset_operational_status'); + } +} diff --git a/api-v3/src/database/migrations/1787594400000-phase-d5-3-5-import-center-scalability.ts b/api-v3/src/database/migrations/1787594400000-phase-d5-3-5-import-center-scalability.ts new file mode 100644 index 0000000..d9b25dd --- /dev/null +++ b/api-v3/src/database/migrations/1787594400000-phase-d5-3-5-import-center-scalability.ts @@ -0,0 +1,137 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +const newPermissions = ['asset_imports.read', 'asset_imports.manage'] as const; +const rolePermissionValues = ` + ('admin', 'asset_imports.read'), ('admin', 'asset_imports.manage'), + ('director', 'asset_imports.read'), ('director', 'asset_imports.manage'), + ('supervisor', 'asset_imports.read'), ('supervisor', 'asset_imports.manage') +`; + +function quoteIdentifier(identifier: string): string { + return `"${identifier.replaceAll('"', '""')}"`; +} + +export class PhaseD535ImportCenterScalability1787594400000 implements MigrationInterface { + name = 'PhaseD535ImportCenterScalability1787594400000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS pg_trgm`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_assets_code_trgm ON assets USING gin (code gin_trgm_ops)`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_assets_name_trgm ON assets USING gin (name gin_trgm_ops)`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_asset_external_identifiers_value_trgm ON asset_external_identifiers USING gin (value gin_trgm_ops)`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_asset_attribute_values_text_trgm ON asset_attribute_values USING gin ((value #>> '{}') gin_trgm_ops)`); + + await queryRunner.query(`CREATE TYPE asset_import_batch_status AS ENUM ('ANALYZED','REVIEW_REQUIRED','CANCELLED','FAILED')`); + await queryRunner.query(`CREATE TYPE asset_import_row_status AS ENUM ('READY','WARNING','CONFLICT','IGNORED')`); + await queryRunner.query(`CREATE TYPE asset_import_suggested_action AS ENUM ('CREATE','MATCH','REVIEW','IGNORE')`); + + await queryRunner.query(` + CREATE TABLE asset_import_batches ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + 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, + profile_code varchar(80) NOT NULL, + profile_confidence integer NOT NULL DEFAULT 0, + worksheet_name varchar(160), + header_row integer, + total_rows integer NOT NULL DEFAULT 0, + ready_rows integer NOT NULL DEFAULT 0, + warning_rows integer NOT NULL DEFAULT 0, + conflict_rows integer NOT NULL DEFAULT 0, + ignored_rows integer NOT NULL DEFAULT 0, + status asset_import_batch_status NOT NULL, + source_document_id uuid, + source_label varchar(240), + notes text, + analysis jsonb NOT NULL DEFAULT '{}'::jsonb, + uploaded_by uuid, + analyzed_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT chk_asset_import_batch_size CHECK (size_bytes > 0 AND size_bytes <= 26214400), + CONSTRAINT chk_asset_import_profile_confidence CHECK (profile_confidence >= 0 AND profile_confidence <= 100), + CONSTRAINT chk_asset_import_batch_counts CHECK (total_rows >= 0 AND ready_rows >= 0 AND warning_rows >= 0 AND conflict_rows >= 0 AND ignored_rows >= 0), + CONSTRAINT fk_asset_import_batch_source_document FOREIGN KEY (source_document_id) REFERENCES source_documents(id) ON DELETE SET NULL, + CONSTRAINT fk_asset_import_batch_uploaded_by FOREIGN KEY (uploaded_by) REFERENCES users(id) ON DELETE SET NULL + ) + `); + await queryRunner.query(`CREATE INDEX idx_asset_import_batches_created_at ON asset_import_batches (created_at DESC)`); + await queryRunner.query(`CREATE INDEX idx_asset_import_batches_status ON asset_import_batches (status)`); + await queryRunner.query(`CREATE INDEX idx_asset_import_batches_sha256 ON asset_import_batches (sha256)`); + + await queryRunner.query(` + CREATE TABLE asset_import_rows ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + batch_id uuid NOT NULL, + worksheet_name varchar(160) NOT NULL, + row_number integer NOT NULL, + status asset_import_row_status NOT NULL, + suggested_action asset_import_suggested_action NOT NULL, + raw_data jsonb NOT NULL, + normalized_data jsonb NOT NULL, + issue_codes jsonb NOT NULL DEFAULT '[]'::jsonb, + fingerprint char(64) NOT NULL, + matched_asset_id uuid, + imported_asset_id uuid, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT chk_asset_import_row_number CHECK (row_number > 0), + CONSTRAINT chk_asset_import_row_issues_array CHECK (jsonb_typeof(issue_codes) = 'array'), + CONSTRAINT fk_asset_import_rows_batch FOREIGN KEY (batch_id) REFERENCES asset_import_batches(id) ON DELETE RESTRICT, + CONSTRAINT fk_asset_import_rows_matched_asset FOREIGN KEY (matched_asset_id) REFERENCES assets(id) ON DELETE SET NULL, + CONSTRAINT fk_asset_import_rows_imported_asset FOREIGN KEY (imported_asset_id) REFERENCES assets(id) ON DELETE SET NULL, + CONSTRAINT uq_asset_import_row_source UNIQUE (batch_id, worksheet_name, row_number) + ) + `); + await queryRunner.query(`CREATE INDEX idx_asset_import_rows_batch_status ON asset_import_rows (batch_id,status,row_number)`); + await queryRunner.query(`CREATE INDEX idx_asset_import_rows_batch_action ON asset_import_rows (batch_id,suggested_action)`); + await queryRunner.query(`CREATE INDEX idx_asset_import_rows_fingerprint ON asset_import_rows (fingerprint)`); + await queryRunner.query(`CREATE INDEX idx_asset_import_rows_inventory_id ON asset_import_rows ((normalized_data->>'inventoryId')) WHERE normalized_data ? 'inventoryId'`); + + const descriptions: Record = { + 'asset_imports.read': 'Consultar lotes, análisis y conflictos de importación del Maestro', + 'asset_imports.manage': 'Analizar archivos XLSX/CSV y administrar lotes de importación del Maestro', + }; + for (const permission of newPermissions) { + await queryRunner.query( + `INSERT INTO permissions (code,description) VALUES ($1,$2) ON CONFLICT (code) DO UPDATE SET description=EXCLUDED.description`, + [permission, descriptions[permission]], + ); + } + await queryRunner.query(` + WITH mapping(role_code,permission_code) AS (VALUES ${rolePermissionValues}) + INSERT INTO role_permissions (role_id,permission_id) + SELECT r.id,p.id FROM mapping m JOIN roles r ON r.code=m.role_code JOIN permissions p ON p.code=m.permission_code + ON CONFLICT (role_id,permission_id) DO NOTHING + `); + + const appRole = process.env.DB_APP_USER; + if (!appRole) throw new Error('Missing required environment variable: DB_APP_USER'); + const roleRows = (await queryRunner.query('SELECT 1 FROM pg_roles WHERE rolname=$1', [appRole])) as unknown[]; + if (roleRows.length !== 1) throw new Error('Configured DB_APP_USER does not exist'); + const applicationRole = quoteIdentifier(appRole); + await queryRunner.query(`GRANT SELECT, INSERT, UPDATE ON TABLE asset_import_batches, asset_import_rows TO ${applicationRole}`); + await queryRunner.query(`REVOKE DELETE ON TABLE asset_import_batches, asset_import_rows FROM ${applicationRole}`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + WITH mapping(role_code,permission_code) AS (VALUES ${rolePermissionValues}) + DELETE FROM role_permissions rp USING roles r,permissions p,mapping m + WHERE rp.role_id=r.id AND rp.permission_id=p.id AND r.code=m.role_code AND p.code=m.permission_code + `); + await queryRunner.query(`DELETE FROM permissions WHERE code=ANY($1::text[]) AND NOT EXISTS (SELECT 1 FROM role_permissions WHERE permission_id=permissions.id)`, [newPermissions]); + await queryRunner.query(`DROP TABLE asset_import_rows`); + await queryRunner.query(`DROP TABLE asset_import_batches`); + await queryRunner.query(`DROP TYPE asset_import_suggested_action`); + await queryRunner.query(`DROP TYPE asset_import_row_status`); + await queryRunner.query(`DROP TYPE asset_import_batch_status`); + await queryRunner.query(`DROP INDEX IF EXISTS idx_asset_attribute_values_text_trgm`); + await queryRunner.query(`DROP INDEX IF EXISTS idx_asset_external_identifiers_value_trgm`); + await queryRunner.query(`DROP INDEX IF EXISTS idx_assets_name_trgm`); + await queryRunner.query(`DROP INDEX IF EXISTS idx_assets_code_trgm`); + } +} diff --git a/api-v3/src/database/migrations/1787680800000-phase-d5-3-7-import-planning-application.ts b/api-v3/src/database/migrations/1787680800000-phase-d5-3-7-import-planning-application.ts new file mode 100644 index 0000000..2b651f6 --- /dev/null +++ b/api-v3/src/database/migrations/1787680800000-phase-d5-3-7-import-planning-application.ts @@ -0,0 +1,125 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +const newPermissions = ['asset_imports.apply'] as const; +const rolePermissionValues = ` + ('admin', 'asset_imports.apply'), + ('director', 'asset_imports.apply') +`; + +function quoteIdentifier(identifier: string): string { + return `"${identifier.replaceAll('"', '""')}"`; +} + +export class PhaseD537ImportPlanningApplication1787680800000 implements MigrationInterface { + name = 'PhaseD537ImportPlanningApplication1787680800000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE asset_import_plans ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + batch_id uuid NOT NULL, + revision integer NOT NULL, + status varchar(24) NOT NULL, + external_id_namespace varchar(80), + operator_asset_id uuid, + summary jsonb NOT NULL DEFAULT '{}'::jsonb, + plan_hash char(64) NOT NULL, + generated_by uuid, + generated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + applied_by uuid, + applied_at timestamptz, + application_summary jsonb, + rolled_back_by uuid, + rolled_back_at timestamptz, + rollback_summary jsonb, + superseded_at timestamptz, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT chk_asset_import_plan_revision CHECK (revision > 0), + CONSTRAINT chk_asset_import_plan_status CHECK (status IN ('REVIEW_REQUIRED','READY','APPLIED','ROLLED_BACK','SUPERSEDED','FAILED')), + CONSTRAINT chk_asset_import_plan_namespace CHECK (external_id_namespace IS NULL OR external_id_namespace ~ '^[A-Z0-9][A-Z0-9._/-]{1,79}$'), + CONSTRAINT chk_asset_import_plan_hash CHECK (plan_hash ~ '^[0-9a-f]{64}$'), + CONSTRAINT fk_asset_import_plan_batch FOREIGN KEY (batch_id) REFERENCES asset_import_batches(id) ON DELETE RESTRICT, + CONSTRAINT fk_asset_import_plan_operator FOREIGN KEY (operator_asset_id) REFERENCES assets(id) ON DELETE RESTRICT, + CONSTRAINT fk_asset_import_plan_generated_by FOREIGN KEY (generated_by) REFERENCES users(id) ON DELETE SET NULL, + CONSTRAINT fk_asset_import_plan_applied_by FOREIGN KEY (applied_by) REFERENCES users(id) ON DELETE SET NULL, + CONSTRAINT fk_asset_import_plan_rolled_back_by FOREIGN KEY (rolled_back_by) REFERENCES users(id) ON DELETE SET NULL, + CONSTRAINT uq_asset_import_plan_revision UNIQUE (batch_id, revision) + ) + `); + await queryRunner.query(`CREATE UNIQUE INDEX uq_asset_import_plan_active_batch ON asset_import_plans (batch_id) WHERE superseded_at IS NULL`); + await queryRunner.query(`CREATE INDEX idx_asset_import_plans_status ON asset_import_plans (status, generated_at DESC)`); + + await queryRunner.query(` + CREATE TABLE asset_import_plan_items ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + plan_id uuid NOT NULL, + item_order integer NOT NULL, + entity_key varchar(320) NOT NULL, + entity_kind varchar(40) NOT NULL, + action varchar(16) NOT NULL, + status varchar(24) NOT NULL, + asset_type_code varchar(80), + display_name varchar(260) NOT NULL, + generated_code varchar(120), + parent_entity_key varchar(320), + matched_asset_id uuid, + applied_asset_id uuid, + applied_object_id varchar(255), + payload jsonb NOT NULL DEFAULT '{}'::jsonb, + source_row_numbers jsonb NOT NULL DEFAULT '[]'::jsonb, + review_codes jsonb NOT NULL DEFAULT '[]'::jsonb, + resolution_note text, + resolved_by uuid, + resolved_at timestamptz, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT chk_asset_import_plan_item_order CHECK (item_order > 0), + CONSTRAINT chk_asset_import_plan_item_kind CHECK (entity_kind IN ('ORGANIZATION','AREA','FIELD','OPERATOR_RELATION','INSTALLATION','TECHNICAL_ASSET')), + CONSTRAINT chk_asset_import_plan_item_action CHECK (action IN ('CREATE','MATCH','REVIEW','IGNORE')), + CONSTRAINT chk_asset_import_plan_item_status CHECK (status IN ('PLANNED','MATCHED','REVIEW','IGNORED','APPLIED','ROLLED_BACK','FAILED')), + CONSTRAINT chk_asset_import_plan_item_rows CHECK (jsonb_typeof(source_row_numbers)='array'), + CONSTRAINT chk_asset_import_plan_item_reviews CHECK (jsonb_typeof(review_codes)='array'), + CONSTRAINT fk_asset_import_plan_item_plan FOREIGN KEY (plan_id) REFERENCES asset_import_plans(id) ON DELETE RESTRICT, + CONSTRAINT fk_asset_import_plan_item_match FOREIGN KEY (matched_asset_id) REFERENCES assets(id) ON DELETE SET NULL, + CONSTRAINT fk_asset_import_plan_item_applied_asset FOREIGN KEY (applied_asset_id) REFERENCES assets(id) ON DELETE SET NULL, + CONSTRAINT fk_asset_import_plan_item_resolved_by FOREIGN KEY (resolved_by) REFERENCES users(id) ON DELETE SET NULL, + CONSTRAINT uq_asset_import_plan_item_key UNIQUE (plan_id, entity_key) + ) + `); + await queryRunner.query(`CREATE INDEX idx_asset_import_plan_items_plan_order ON asset_import_plan_items (plan_id, item_order)`); + await queryRunner.query(`CREATE INDEX idx_asset_import_plan_items_plan_action ON asset_import_plan_items (plan_id, action, entity_kind)`); + await queryRunner.query(`CREATE INDEX idx_asset_import_plan_items_match ON asset_import_plan_items (matched_asset_id) WHERE matched_asset_id IS NOT NULL`); + await queryRunner.query(`CREATE INDEX idx_asset_import_plan_items_applied ON asset_import_plan_items (applied_asset_id) WHERE applied_asset_id IS NOT NULL`); + + await queryRunner.query( + `INSERT INTO permissions (code,description) VALUES ($1,$2) ON CONFLICT (code) DO UPDATE SET description=EXCLUDED.description`, + ['asset_imports.apply', 'Aplicar o revertir planes de importación transaccional sobre el Maestro'], + ); + await queryRunner.query(` + WITH mapping(role_code,permission_code) AS (VALUES ${rolePermissionValues}) + INSERT INTO role_permissions (role_id,permission_id) + SELECT r.id,p.id FROM mapping m JOIN roles r ON r.code=m.role_code JOIN permissions p ON p.code=m.permission_code + ON CONFLICT (role_id,permission_id) DO NOTHING + `); + + const appRole = process.env.DB_APP_USER; + if (!appRole) throw new Error('Missing required environment variable: DB_APP_USER'); + const roleRows = (await queryRunner.query('SELECT 1 FROM pg_roles WHERE rolname=$1', [appRole])) as unknown[]; + if (roleRows.length !== 1) throw new Error('Configured DB_APP_USER does not exist'); + const applicationRole = quoteIdentifier(appRole); + await queryRunner.query(`GRANT SELECT, INSERT, UPDATE ON TABLE asset_import_plans, asset_import_plan_items TO ${applicationRole}`); + await queryRunner.query(`REVOKE DELETE ON TABLE asset_import_plans, asset_import_plan_items FROM ${applicationRole}`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + WITH mapping(role_code,permission_code) AS (VALUES ${rolePermissionValues}) + DELETE FROM role_permissions rp USING roles r,permissions p,mapping m + WHERE rp.role_id=r.id AND rp.permission_id=p.id AND r.code=m.role_code AND p.code=m.permission_code + `); + await queryRunner.query(`DELETE FROM permissions WHERE code=ANY($1::text[]) AND NOT EXISTS (SELECT 1 FROM role_permissions WHERE permission_id=permissions.id)`, [newPermissions]); + await queryRunner.query(`DROP TABLE asset_import_plan_items`); + await queryRunner.query(`DROP TABLE asset_import_plans`); + } +} diff --git a/api-v3/src/database/migrations/1787767200000-phase-d5-3-7-2-relational-territory-plan.ts b/api-v3/src/database/migrations/1787767200000-phase-d5-3-7-2-relational-territory-plan.ts new file mode 100644 index 0000000..27943f1 --- /dev/null +++ b/api-v3/src/database/migrations/1787767200000-phase-d5-3-7-2-relational-territory-plan.ts @@ -0,0 +1,92 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +function quoteIdentifier(identifier: string): string { + return `"${identifier.replaceAll('"', '""')}"`; +} + +export class PhaseD5372RelationalTerritoryPlan1787767200000 implements MigrationInterface { + name = 'PhaseD5372RelationalTerritoryPlan1787767200000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE asset_import_plan_items DROP CONSTRAINT chk_asset_import_plan_item_kind + `); + await queryRunner.query(` + ALTER TABLE asset_import_plan_items ADD CONSTRAINT chk_asset_import_plan_item_kind CHECK ( + entity_kind IN ( + 'DEPARTMENT','ORGANIZATION','AREA','AREA_DEPARTMENT_RELATION','FIELD','OPERATOR_RELATION', + 'LEGAL_RIGHT','LEGAL_RIGHT_ORGANIZATION','INSTALLATION','TECHNICAL_ASSET' + ) + ) + `); + + await queryRunner.query(` + CREATE TABLE administrative_departments ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + province_code varchar(40) NOT NULL DEFAULT 'MENDOZA', + code varchar(80) NOT NULL, + name varchar(160) NOT NULL, + normalized_name varchar(180) NOT NULL, + is_active boolean NOT NULL DEFAULT true, + source_document_id uuid, + created_by uuid, + updated_by uuid, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT chk_administrative_department_code CHECK (code ~ '^[A-Z0-9][A-Z0-9._/-]{1,79}$'), + CONSTRAINT chk_administrative_department_name CHECK (length(btrim(name)) >= 2), + CONSTRAINT chk_administrative_department_normalized CHECK (length(btrim(normalized_name)) >= 2), + CONSTRAINT fk_administrative_department_source FOREIGN KEY (source_document_id) REFERENCES source_documents(id) ON DELETE SET NULL, + CONSTRAINT fk_administrative_department_created_by FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL, + CONSTRAINT fk_administrative_department_updated_by FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL, + CONSTRAINT uq_administrative_department_code UNIQUE (province_code, code), + CONSTRAINT uq_administrative_department_name UNIQUE (province_code, normalized_name) + ) + `); + await queryRunner.query(`CREATE INDEX idx_administrative_departments_active_name ON administrative_departments (is_active, normalized_name)`); + + await queryRunner.query(` + CREATE TABLE area_department_relations ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + area_id uuid NOT NULL, + department_id uuid NOT NULL, + valid_from date NOT NULL DEFAULT CURRENT_DATE, + valid_until date, + source_document_id uuid, + notes text, + created_by uuid, + ended_by uuid, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT chk_area_department_relation_dates CHECK (valid_until IS NULL OR valid_until >= valid_from), + CONSTRAINT fk_area_department_relation_area FOREIGN KEY (area_id) REFERENCES assets(id) ON DELETE RESTRICT, + CONSTRAINT fk_area_department_relation_department FOREIGN KEY (department_id) REFERENCES administrative_departments(id) ON DELETE RESTRICT, + CONSTRAINT fk_area_department_relation_source FOREIGN KEY (source_document_id) REFERENCES source_documents(id) ON DELETE SET NULL, + CONSTRAINT fk_area_department_relation_created_by FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL, + CONSTRAINT fk_area_department_relation_ended_by FOREIGN KEY (ended_by) REFERENCES users(id) ON DELETE SET NULL + ) + `); + await queryRunner.query(`CREATE UNIQUE INDEX uq_area_department_relation_active_pair ON area_department_relations (area_id, department_id) WHERE valid_until IS NULL`); + await queryRunner.query(`CREATE INDEX idx_area_department_relations_area ON area_department_relations (area_id) WHERE valid_until IS NULL`); + await queryRunner.query(`CREATE INDEX idx_area_department_relations_department ON area_department_relations (department_id) WHERE valid_until IS NULL`); + + const appRole = process.env.DB_APP_USER; + if (!appRole) throw new Error('Missing required environment variable: DB_APP_USER'); + const roleRows = (await queryRunner.query('SELECT 1 FROM pg_roles WHERE rolname=$1', [appRole])) as unknown[]; + if (roleRows.length !== 1) throw new Error('Configured DB_APP_USER does not exist'); + const applicationRole = quoteIdentifier(appRole); + await queryRunner.query(`GRANT SELECT, INSERT, UPDATE ON TABLE administrative_departments, area_department_relations TO ${applicationRole}`); + await queryRunner.query(`REVOKE DELETE ON TABLE administrative_departments, area_department_relations FROM ${applicationRole}`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE area_department_relations`); + await queryRunner.query(`DROP TABLE administrative_departments`); + await queryRunner.query(`ALTER TABLE asset_import_plan_items DROP CONSTRAINT chk_asset_import_plan_item_kind`); + await queryRunner.query(` + ALTER TABLE asset_import_plan_items ADD CONSTRAINT chk_asset_import_plan_item_kind CHECK ( + entity_kind IN ('ORGANIZATION','AREA','FIELD','OPERATOR_RELATION','INSTALLATION','TECHNICAL_ASSET') + ) + `); + } +} diff --git a/api-v3/src/database/migrations/1787853600000-phase-d5-3-8-local-inventory-structure.ts b/api-v3/src/database/migrations/1787853600000-phase-d5-3-8-local-inventory-structure.ts new file mode 100644 index 0000000..9aa57b7 --- /dev/null +++ b/api-v3/src/database/migrations/1787853600000-phase-d5-3-8-local-inventory-structure.ts @@ -0,0 +1,89 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class PhaseD538LocalInventoryStructure1787853600000 implements MigrationInterface { + name = 'PhaseD538LocalInventoryStructure1787853600000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE asset_import_plan_items DROP CONSTRAINT chk_asset_import_plan_item_kind + `); + await queryRunner.query(` + ALTER TABLE asset_import_plan_items ADD CONSTRAINT chk_asset_import_plan_item_kind CHECK ( + entity_kind IN ( + 'DEPARTMENT','ORGANIZATION','AREA','AREA_DEPARTMENT_RELATION','FIELD','OPERATOR_RELATION', + 'LEGAL_RIGHT','LEGAL_RIGHT_ORGANIZATION','INSTALLATION','LOCAL_STRUCTURE','TECHNICAL_ASSET' + ) + ) + `); + + await queryRunner.query(` + INSERT INTO asset_types (code,name,description,can_be_root,is_active,operational_role) + SELECT + 'estructura_local', + 'Estructura local (fuente)', + 'Nodo estructural provisional que conserva la nomenclatura propia de cada operadora o Área/Yacimiento. No implica una clasificación física normalizada por DH hasta su revisión.', + false,true,'GENERIC' + WHERE NOT EXISTS (SELECT 1 FROM asset_types WHERE lower(code)='estructura_local') + `); + + await queryRunner.query(` + INSERT INTO asset_attribute_definitions (asset_type_id,code,name,data_type,is_required,is_active,sort_order) + SELECT type.id,definition.code,definition.name,'TEXT',false,true,definition.sort_order + FROM asset_types type + CROSS JOIN (VALUES + ('nivel_fuente','Nivel informado por la fuente',10), + ('clasificacion_fuente','Clasificación local informada',20), + ('ruta_fuente','Ruta / nomenclatura de origen',30) + ) AS definition(code,name,sort_order) + WHERE lower(type.code)='estructura_local' + AND NOT EXISTS ( + SELECT 1 FROM asset_attribute_definitions existing + WHERE existing.asset_type_id=type.id AND lower(existing.code)=lower(definition.code) + ) + `); + + await queryRunner.query(` + INSERT INTO asset_type_parent_rules (child_type_id,parent_type_id) + SELECT local.id,parent.id + FROM asset_types local + JOIN asset_types parent ON lower(parent.code) IN ('area','yacimiento','locacion') + WHERE lower(local.code)='estructura_local' + ON CONFLICT DO NOTHING + `); + + await queryRunner.query(` + INSERT INTO asset_type_parent_rules (child_type_id,parent_type_id) + SELECT child.id,local.id + FROM asset_types child + CROSS JOIN asset_types local + WHERE lower(local.code)='estructura_local' + AND child.operational_role='GENERIC' + AND lower(child.code) NOT IN ('yacimiento','locacion','estructura_local','pozo','ducto','colector') + ON CONFLICT DO NOTHING + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE asset_import_plan_items DROP CONSTRAINT chk_asset_import_plan_item_kind`); + await queryRunner.query(` + ALTER TABLE asset_import_plan_items ADD CONSTRAINT chk_asset_import_plan_item_kind CHECK ( + entity_kind IN ( + 'DEPARTMENT','ORGANIZATION','AREA','AREA_DEPARTMENT_RELATION','FIELD','OPERATOR_RELATION', + 'LEGAL_RIGHT','LEGAL_RIGHT_ORGANIZATION','INSTALLATION','TECHNICAL_ASSET' + ) + ) + `); + await queryRunner.query(` + DELETE FROM asset_type_parent_rules rule + USING asset_types local + WHERE lower(local.code)='estructura_local' + AND (rule.child_type_id=local.id OR rule.parent_type_id=local.id) + `); + await queryRunner.query(` + DELETE FROM asset_attribute_definitions definition + USING asset_types local + WHERE definition.asset_type_id=local.id AND lower(local.code)='estructura_local' + `); + await queryRunner.query(`DELETE FROM asset_types WHERE lower(code)='estructura_local'`); + } +} diff --git a/api-v3/src/database/migrations/1788026400000-phase-d5-3-15-document-center.ts b/api-v3/src/database/migrations/1788026400000-phase-d5-3-15-document-center.ts new file mode 100644 index 0000000..3418d70 --- /dev/null +++ b/api-v3/src/database/migrations/1788026400000-phase-d5-3-15-document-center.ts @@ -0,0 +1,146 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +const newPermissions = [ + 'inspection_reports.read', + 'inspection_reports.generate', +] as const; + +const rolePermissionValues = ` + ('admin', 'inspection_reports.read'), + ('director', 'inspection_reports.read'), + ('supervisor', 'inspection_reports.read'), + ('inspector', 'inspection_reports.read'), + ('inspector', 'inspection_reports.generate'), + ('auditor', 'inspection_reports.read') +`; + +function quoteIdentifier(identifier: string): string { + return `"${identifier.replaceAll('"', '""')}"`; +} + +export class PhaseD5315DocumentCenter1788026400000 implements MigrationInterface { + name = 'PhaseD5315DocumentCenter1788026400000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE inspection_reports ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + visit_id uuid NOT NULL, + act_id uuid NOT NULL, + report_year integer NOT NULL, + report_number integer NOT NULL, + code varchar(24) NOT NULL, + status varchar(24) NOT NULL DEFAULT 'FROZEN', + pdf_status varchar(24) NOT NULL DEFAULT 'PENDING', + title varchar(220) NOT NULL, + act_version integer NOT NULL, + act_closure_sha256 char(64) NOT NULL, + frozen_sha256 char(64) NOT NULL, + frozen_snapshot jsonb NOT NULL, + generated_at timestamptz NOT NULL, + generated_by uuid NOT NULL, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uq_inspection_reports_visit UNIQUE (visit_id), + CONSTRAINT uq_inspection_reports_act UNIQUE (act_id), + CONSTRAINT uq_inspection_reports_year_number UNIQUE (report_year, report_number), + CONSTRAINT uq_inspection_reports_code UNIQUE (code), + CONSTRAINT chk_inspection_reports_number CHECK (report_year >= 2000 AND report_number > 0), + CONSTRAINT chk_inspection_reports_status CHECK (status IN ('FROZEN', 'CANCELLED')), + CONSTRAINT chk_inspection_reports_pdf_status CHECK (pdf_status IN ('PENDING', 'READY', 'FAILED')), + CONSTRAINT chk_inspection_reports_hashes CHECK ( + act_closure_sha256 ~ '^[0-9a-f]{64}$' + AND frozen_sha256 ~ '^[0-9a-f]{64}$' + ), + CONSTRAINT chk_inspection_reports_snapshot CHECK (jsonb_typeof(frozen_snapshot) = 'object'), + CONSTRAINT fk_inspection_reports_visit FOREIGN KEY (visit_id) + REFERENCES inspection_visits(id) ON DELETE RESTRICT, + CONSTRAINT fk_inspection_reports_act FOREIGN KEY (act_id) + REFERENCES inspection_acts(id) ON DELETE RESTRICT, + CONSTRAINT fk_inspection_reports_generated_by FOREIGN KEY (generated_by) + REFERENCES users(id) ON DELETE RESTRICT + ) + `); + await queryRunner.query('CREATE INDEX idx_inspection_reports_generated_at ON inspection_reports (generated_at DESC)'); + await queryRunner.query('CREATE INDEX idx_inspection_reports_status ON inspection_reports (status, pdf_status)'); + + await queryRunner.query(` + CREATE FUNCTION dhv2_guard_inspection_report_frozen() + RETURNS trigger AS $$ + BEGIN + IF NEW.visit_id IS DISTINCT FROM OLD.visit_id + OR NEW.act_id IS DISTINCT FROM OLD.act_id + OR NEW.report_year IS DISTINCT FROM OLD.report_year + OR NEW.report_number IS DISTINCT FROM OLD.report_number + OR NEW.code IS DISTINCT FROM OLD.code + OR NEW.title IS DISTINCT FROM OLD.title + OR NEW.act_version IS DISTINCT FROM OLD.act_version + OR NEW.act_closure_sha256 IS DISTINCT FROM OLD.act_closure_sha256 + OR NEW.frozen_sha256 IS DISTINCT FROM OLD.frozen_sha256 + OR NEW.frozen_snapshot IS DISTINCT FROM OLD.frozen_snapshot + OR NEW.generated_at IS DISTINCT FROM OLD.generated_at + OR NEW.generated_by IS DISTINCT FROM OLD.generated_by + OR NEW.created_at IS DISTINCT FROM OLD.created_at + THEN + RAISE EXCEPTION 'El contenido congelado del informe es inmutable'; + END IF; + RETURN NEW; + END; + $$ LANGUAGE plpgsql + `); + await queryRunner.query(` + CREATE TRIGGER trg_inspection_reports_frozen + BEFORE UPDATE ON inspection_reports + FOR EACH ROW EXECUTE FUNCTION dhv2_guard_inspection_report_frozen() + `); + + await queryRunner.query(` + INSERT INTO permissions (code, description) + VALUES + ('inspection_reports.read', 'Consultar informes de inspección y documentos pendientes de emisión'), + ('inspection_reports.generate', 'Solicitar la emisión y congelado de un informe desde una inspección cerrada') + ON CONFLICT (code) DO UPDATE SET description = EXCLUDED.description + `); + await queryRunner.query(` + WITH mapping(role_code, permission_code) AS (VALUES ${rolePermissionValues}) + 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 (role_id, permission_id) DO NOTHING + `); + + const appRole = process.env.DB_APP_USER; + if (!appRole) throw new Error('Missing required environment variable: DB_APP_USER'); + const applicationRole = quoteIdentifier(appRole); + await queryRunner.query(` + GRANT SELECT, INSERT, UPDATE ON TABLE inspection_reports TO ${applicationRole} + `); + await queryRunner.query(` + REVOKE DELETE ON TABLE inspection_reports FROM ${applicationRole} + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query('DROP TRIGGER trg_inspection_reports_frozen ON inspection_reports'); + await queryRunner.query('DROP FUNCTION dhv2_guard_inspection_report_frozen()'); + await queryRunner.query(` + WITH mapping(role_code, permission_code) AS (VALUES ${rolePermissionValues}) + DELETE FROM role_permissions role_permission + USING roles role, permissions permission, mapping + WHERE role_permission.role_id = role.id + AND role_permission.permission_id = permission.id + AND role.code = mapping.role_code + AND permission.code = mapping.permission_code + `); + await queryRunner.query( + `DELETE FROM permissions WHERE code = ANY($1::varchar[]) + AND NOT EXISTS ( + SELECT 1 FROM role_permissions WHERE permission_id = permissions.id + )`, + [newPermissions], + ); + await queryRunner.query('DROP TABLE inspection_reports'); + } +} diff --git a/api-v3/src/database/migrations/1788112800000-phase-d5-3-16-verification-planning.ts b/api-v3/src/database/migrations/1788112800000-phase-d5-3-16-verification-planning.ts new file mode 100644 index 0000000..669eb5f --- /dev/null +++ b/api-v3/src/database/migrations/1788112800000-phase-d5-3-16-verification-planning.ts @@ -0,0 +1,79 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +const permissionCode = 'inspection_verifications.plan'; +const rolePermissionValues = ` + ('admin', 'inspection_verifications.plan'), + ('director', 'inspection_verifications.plan'), + ('supervisor', 'inspection_verifications.plan') +`; + +function quoteIdentifier(identifier: string): string { + return `"${identifier.replaceAll('"', '""')}"`; +} + +export class PhaseD5316VerificationPlanning1788112800000 implements MigrationInterface { + name = 'PhaseD5316VerificationPlanning1788112800000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE inspection_finding_verification_visits ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + finding_id uuid NOT NULL, + visit_id uuid NOT NULL, + linked_by uuid NULL, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uq_inspection_finding_verification_visit UNIQUE (finding_id, visit_id), + CONSTRAINT fk_inspection_finding_verification_finding FOREIGN KEY (finding_id) + REFERENCES inspection_findings(id) ON DELETE RESTRICT, + CONSTRAINT fk_inspection_finding_verification_visit FOREIGN KEY (visit_id) + REFERENCES inspection_visits(id) ON DELETE RESTRICT, + CONSTRAINT fk_inspection_finding_verification_linked_by FOREIGN KEY (linked_by) + REFERENCES users(id) ON DELETE SET NULL + ) + `); + await queryRunner.query('CREATE INDEX idx_inspection_finding_verification_finding ON inspection_finding_verification_visits (finding_id, created_at DESC)'); + await queryRunner.query('CREATE INDEX idx_inspection_finding_verification_visit ON inspection_finding_verification_visits (visit_id)'); + + await queryRunner.query(` + INSERT INTO permissions (code, description) + VALUES ($1, 'Planificar visitas de verificación a partir de hallazgos con fecha de control') + ON CONFLICT (code) DO UPDATE SET description = EXCLUDED.description + `, [permissionCode]); + await queryRunner.query(` + WITH mapping(role_code, permission_code) AS (VALUES ${rolePermissionValues}) + 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 (role_id, permission_id) DO NOTHING + `); + + const appRole = process.env.DB_APP_USER; + if (!appRole) throw new Error('Missing required environment variable: DB_APP_USER'); + const applicationRole = quoteIdentifier(appRole); + await queryRunner.query(`GRANT SELECT, INSERT ON TABLE inspection_finding_verification_visits TO ${applicationRole}`); + await queryRunner.query(`REVOKE UPDATE, DELETE ON TABLE inspection_finding_verification_visits FROM ${applicationRole}`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + WITH mapping(role_code, permission_code) AS (VALUES ${rolePermissionValues}) + DELETE FROM role_permissions role_permission + USING roles role, permissions permission, mapping + WHERE role_permission.role_id = role.id + AND role_permission.permission_id = permission.id + AND role.code = mapping.role_code + AND permission.code = mapping.permission_code + `); + await queryRunner.query(` + DELETE FROM permissions + WHERE code = $1 + AND NOT EXISTS ( + SELECT 1 FROM role_permissions WHERE permission_id = permissions.id + ) + `, [permissionCode]); + await queryRunner.query('DROP TABLE inspection_finding_verification_visits'); + } +} diff --git a/api-v3/src/database/migrations/1788199200000-phase-d5-3-17-verification-execution.ts b/api-v3/src/database/migrations/1788199200000-phase-d5-3-17-verification-execution.ts new file mode 100644 index 0000000..48d5916 --- /dev/null +++ b/api-v3/src/database/migrations/1788199200000-phase-d5-3-17-verification-execution.ts @@ -0,0 +1,113 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +function quoteIdentifier(identifier: string): string { + return `"${identifier.replaceAll('"', '""')}"`; +} + +export class PhaseD5317VerificationExecution1788199200000 implements MigrationInterface { + name = 'PhaseD5317VerificationExecution1788199200000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE inspection_finding_verification_visits + ADD COLUMN target_control_on date NULL, + ADD COLUMN outcome varchar(24) NULL, + ADD COLUMN result_notes text NULL, + ADD COLUMN verified_at timestamptz NULL, + ADD COLUMN result_recorded_at timestamptz NULL, + ADD COLUMN result_recorded_by uuid NULL, + ADD COLUMN rescheduled_control_on date NULL, + ADD CONSTRAINT chk_inspection_verification_outcome + CHECK (outcome IS NULL OR outcome IN ('RESOLVED', 'NOT_RESOLVED', 'REQUIRES_NEW_DATE')), + ADD CONSTRAINT fk_inspection_verification_result_user + FOREIGN KEY (result_recorded_by) REFERENCES users(id) ON DELETE SET NULL + `); + await queryRunner.query(` + UPDATE inspection_finding_verification_visits verification_link + SET target_control_on = finding.next_control_on + FROM inspection_findings finding + WHERE finding.id = verification_link.finding_id + AND verification_link.target_control_on IS NULL + `); + await queryRunner.query(` + CREATE INDEX idx_inspection_finding_verification_result + ON inspection_finding_verification_visits (visit_id, outcome, result_recorded_at DESC) + `); + + await queryRunner.query(` + ALTER TABLE inspection_finding_evidence + DROP CONSTRAINT chk_inspection_finding_evidence_purpose, + ADD CONSTRAINT chk_inspection_finding_evidence_purpose CHECK ( + purpose IN ( + 'OBSERVATION', 'VERIFICATION', 'COMPANY_RESPONSE', + 'COMMUNICATION_ATTACHMENT', 'OTHER_DOCUMENT' + ) + ), + ADD COLUMN verification_visit_id uuid NULL, + ADD CONSTRAINT fk_inspection_finding_evidence_verification_visit + FOREIGN KEY (verification_visit_id) REFERENCES inspection_visits(id) ON DELETE RESTRICT + `); + await queryRunner.query(` + CREATE INDEX idx_inspection_finding_evidence_verification_visit + ON inspection_finding_evidence (verification_visit_id, created_at DESC) + `); + + const appRole = process.env.DB_APP_USER; + if (!appRole) throw new Error('Missing required environment variable: DB_APP_USER'); + const applicationRole = quoteIdentifier(appRole); + await queryRunner.query(` + GRANT UPDATE ( + outcome, + result_notes, + verified_at, + result_recorded_at, + result_recorded_by, + rescheduled_control_on, + updated_at + ) ON inspection_finding_verification_visits TO ${applicationRole} + `); + } + + public async down(queryRunner: QueryRunner): Promise { + const appRole = process.env.DB_APP_USER; + if (!appRole) throw new Error('Missing required environment variable: DB_APP_USER'); + const applicationRole = quoteIdentifier(appRole); + await queryRunner.query(` + REVOKE UPDATE ( + outcome, + result_notes, + verified_at, + result_recorded_at, + result_recorded_by, + rescheduled_control_on, + updated_at + ) ON inspection_finding_verification_visits FROM ${applicationRole} + `); + await queryRunner.query('DROP INDEX idx_inspection_finding_evidence_verification_visit'); + await queryRunner.query(` + ALTER TABLE inspection_finding_evidence + DROP CONSTRAINT fk_inspection_finding_evidence_verification_visit, + DROP CONSTRAINT chk_inspection_finding_evidence_purpose, + DROP COLUMN verification_visit_id, + ADD CONSTRAINT chk_inspection_finding_evidence_purpose CHECK ( + purpose IN ( + 'OBSERVATION', 'COMPANY_RESPONSE', + 'COMMUNICATION_ATTACHMENT', 'OTHER_DOCUMENT' + ) + ) + `); + await queryRunner.query('DROP INDEX idx_inspection_finding_verification_result'); + await queryRunner.query(` + ALTER TABLE inspection_finding_verification_visits + DROP CONSTRAINT fk_inspection_verification_result_user, + DROP CONSTRAINT chk_inspection_verification_outcome, + DROP COLUMN rescheduled_control_on, + DROP COLUMN result_recorded_by, + DROP COLUMN result_recorded_at, + DROP COLUMN verified_at, + DROP COLUMN result_notes, + DROP COLUMN outcome, + DROP COLUMN target_control_on + `); + } +} diff --git a/api-v3/src/database/migrations/1788285600000-phase-d5-3-18-3-office-follow-up-hardening.ts b/api-v3/src/database/migrations/1788285600000-phase-d5-3-18-3-office-follow-up-hardening.ts new file mode 100644 index 0000000..0ca5aa3 --- /dev/null +++ b/api-v3/src/database/migrations/1788285600000-phase-d5-3-18-3-office-follow-up-hardening.ts @@ -0,0 +1,49 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +const permissionCode = 'inspection_findings.close'; +const rolePermissionValues = ` + ('inspector', 'inspection_findings.close'), + ('supervisor', 'inspection_findings.close'), + ('director', 'inspection_findings.close') +`; + +export class PhaseD53183OfficeFollowUpHardening1788285600000 implements MigrationInterface { + name = 'PhaseD53183OfficeFollowUpHardening1788285600000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + INSERT INTO permissions (code, description) + VALUES ($1, 'Cerrar administrativamente hallazgos después de revisar su seguimiento') + ON CONFLICT (code) DO UPDATE SET description = EXCLUDED.description + `, [permissionCode]); + + await queryRunner.query(` + WITH mapping(role_code, permission_code) AS (VALUES ${rolePermissionValues}) + 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 (role_id, permission_id) DO NOTHING + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + WITH mapping(role_code, permission_code) AS (VALUES ${rolePermissionValues}) + DELETE FROM role_permissions role_permission + USING roles role, permissions permission, mapping + WHERE role_permission.role_id = role.id + AND role_permission.permission_id = permission.id + AND role.code = mapping.role_code + AND permission.code = mapping.permission_code + `); + await queryRunner.query(` + DELETE FROM permissions + WHERE code = $1 + AND NOT EXISTS ( + SELECT 1 FROM role_permissions WHERE permission_id = permissions.id + ) + `, [permissionCode]); + } +} diff --git a/api-v3/src/database/migrations/1788372000000-phase-d5-3-19-inventory-aliases-operational-filters.ts b/api-v3/src/database/migrations/1788372000000-phase-d5-3-19-inventory-aliases-operational-filters.ts new file mode 100644 index 0000000..b8d85e0 --- /dev/null +++ b/api-v3/src/database/migrations/1788372000000-phase-d5-3-19-inventory-aliases-operational-filters.ts @@ -0,0 +1,42 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class PhaseD5319InventoryAliasesOperationalFilters1788372000000 implements MigrationInterface { + name = 'PhaseD5319InventoryAliasesOperationalFilters1788372000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE assets + ADD COLUMN common_name varchar(200) + `); + await queryRunner.query(` + CREATE INDEX idx_assets_common_name_lower + ON assets (LOWER(common_name)) + WHERE common_name IS NOT NULL + `); + await queryRunner.query(` + ALTER TABLE inspection_findings + ADD COLUMN response_due_basis varchar(32), + ADD COLUMN response_due_days integer, + ADD COLUMN response_due_base_on date, + ADD COLUMN report_notified_on date, + ADD CONSTRAINT ck_inspection_findings_response_due_basis + CHECK (response_due_basis IS NULL OR response_due_basis IN ('FINDING_DATE','REPORT_NOTIFICATION')), + ADD CONSTRAINT ck_inspection_findings_response_due_days + CHECK (response_due_days IS NULL OR response_due_days BETWEEN 0 AND 3650) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE inspection_findings + DROP CONSTRAINT IF EXISTS ck_inspection_findings_response_due_days, + DROP CONSTRAINT IF EXISTS ck_inspection_findings_response_due_basis, + DROP COLUMN IF EXISTS report_notified_on, + DROP COLUMN IF EXISTS response_due_base_on, + DROP COLUMN IF EXISTS response_due_days, + DROP COLUMN IF EXISTS response_due_basis + `); + await queryRunner.query('DROP INDEX IF EXISTS idx_assets_common_name_lower'); + await queryRunner.query('ALTER TABLE assets DROP COLUMN IF EXISTS common_name'); + } +} diff --git a/api-v3/src/database/migrations/1788458400000-phase-d5-3-20-automatic-word-report.ts b/api-v3/src/database/migrations/1788458400000-phase-d5-3-20-automatic-word-report.ts new file mode 100644 index 0000000..2e93f0b --- /dev/null +++ b/api-v3/src/database/migrations/1788458400000-phase-d5-3-20-automatic-word-report.ts @@ -0,0 +1,61 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class PhaseD5320AutomaticWordReport1788458400000 implements MigrationInterface { + name = 'PhaseD5320AutomaticWordReport1788458400000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE inspection_reports + ADD COLUMN word_status varchar(24) NOT NULL DEFAULT 'PENDING', + ADD COLUMN word_original_name varchar(255), + ADD COLUMN word_stored_name varchar(255), + ADD COLUMN word_mime_type varchar(120), + ADD COLUMN word_size_bytes integer, + ADD COLUMN word_sha256 char(64), + ADD COLUMN word_generated_at timestamptz, + ADD COLUMN word_error varchar(500) + `); + await queryRunner.query(` + ALTER TABLE inspection_reports + ADD CONSTRAINT chk_inspection_reports_word_status + CHECK (word_status IN ('PENDING', 'READY', 'FAILED')), + ADD CONSTRAINT chk_inspection_reports_word_metadata + CHECK ( + (word_status = 'READY' + AND word_original_name IS NOT NULL + AND word_stored_name IS NOT NULL + AND word_mime_type = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' + AND word_size_bytes > 0 + AND word_sha256 ~ '^[0-9a-f]{64}$' + AND word_generated_at IS NOT NULL + AND word_error IS NULL) + OR (word_status = 'PENDING' + AND word_original_name IS NULL + AND word_stored_name IS NULL + AND word_mime_type IS NULL + AND word_size_bytes IS NULL + AND word_sha256 IS NULL + AND word_generated_at IS NULL) + OR word_status = 'FAILED' + ) + `); + await queryRunner.query('CREATE INDEX idx_inspection_reports_word_status ON inspection_reports (word_status, generated_at DESC)'); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query('DROP INDEX idx_inspection_reports_word_status'); + await queryRunner.query('ALTER TABLE inspection_reports DROP CONSTRAINT chk_inspection_reports_word_metadata'); + await queryRunner.query('ALTER TABLE inspection_reports DROP CONSTRAINT chk_inspection_reports_word_status'); + await queryRunner.query(` + ALTER TABLE inspection_reports + DROP COLUMN word_error, + DROP COLUMN word_generated_at, + DROP COLUMN word_sha256, + DROP COLUMN word_size_bytes, + DROP COLUMN word_mime_type, + DROP COLUMN word_stored_name, + DROP COLUMN word_original_name, + DROP COLUMN word_status + `); + } +} diff --git a/api-v3/src/database/migrations/1788544800000-phase-d5-3-21-document-delivery.ts b/api-v3/src/database/migrations/1788544800000-phase-d5-3-21-document-delivery.ts new file mode 100644 index 0000000..3e45dbe --- /dev/null +++ b/api-v3/src/database/migrations/1788544800000-phase-d5-3-21-document-delivery.ts @@ -0,0 +1,104 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +const permissions = [ + ['document_delivery.read', 'Consultar configuración y entregas documentales'], + ['document_delivery.manage', 'Configurar destinatarios y reintentar entregas documentales'], +] as const; + +export class PhaseD5321DocumentDelivery1788544800000 implements MigrationInterface { + name = 'PhaseD5321DocumentDelivery1788544800000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE organization_profiles ADD COLUMN notification_email varchar(320)`); + await queryRunner.query(` + CREATE TABLE institutional_delivery_settings ( + id smallint PRIMARY KEY DEFAULT 1 CHECK (id = 1), + office_email varchar(320), + director_email varchar(320), + updated_by uuid, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_institutional_delivery_settings_updated_by FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL + ) + `); + await queryRunner.query(`INSERT INTO institutional_delivery_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING`); + await queryRunner.query(` + CREATE TABLE inspection_act_pdf_artifacts ( + act_id uuid PRIMARY KEY, + status varchar(24) NOT NULL DEFAULT 'PENDING', + original_name varchar(255), + stored_name varchar(255), + mime_type varchar(120), + size_bytes integer, + sha256 char(64), + generated_at timestamptz, + error varchar(500), + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_inspection_act_pdf_artifacts_act FOREIGN KEY (act_id) REFERENCES inspection_acts(id) ON DELETE CASCADE, + CONSTRAINT chk_inspection_act_pdf_artifacts_status CHECK (status IN ('PENDING','READY','FAILED')), + CONSTRAINT chk_inspection_act_pdf_artifacts_ready CHECK ( + status <> 'READY' OR ( + original_name IS NOT NULL AND stored_name IS NOT NULL AND mime_type = 'application/pdf' + AND size_bytes > 0 AND sha256 ~ '^[0-9a-f]{64}$' AND generated_at IS NOT NULL AND error IS NULL + ) + ) + ) + `); + await queryRunner.query(` + CREATE TABLE inspection_document_deliveries ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + act_id uuid NOT NULL, + report_id uuid, + document_kind varchar(24) NOT NULL, + recipient_kind varchar(24) NOT NULL, + recipient_asset_id uuid, + recipient_key uuid NOT NULL, + recipient_email varchar(320), + status varchar(32) NOT NULL DEFAULT 'PENDING', + attempts integer NOT NULL DEFAULT 0, + last_attempt_at timestamptz, + sent_at timestamptz, + provider_message_id varchar(255), + last_error varchar(500), + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_inspection_document_deliveries_act FOREIGN KEY (act_id) REFERENCES inspection_acts(id) ON DELETE CASCADE, + CONSTRAINT fk_inspection_document_deliveries_report FOREIGN KEY (report_id) REFERENCES inspection_reports(id) ON DELETE CASCADE, + CONSTRAINT fk_inspection_document_deliveries_recipient_asset FOREIGN KEY (recipient_asset_id) REFERENCES assets(id) ON DELETE SET NULL, + CONSTRAINT chk_inspection_document_deliveries_document_kind CHECK (document_kind IN ('ACT_PDF','REPORT_WORD')), + CONSTRAINT chk_inspection_document_deliveries_recipient_kind CHECK (recipient_kind IN ('COMPANY','OFFICE','DIRECTOR')), + CONSTRAINT chk_inspection_document_deliveries_status CHECK (status IN ('PENDING','WAITING_RECIPIENT','WAITING_TRANSPORT','WAITING_ARTIFACT','SENT','FAILED')), + CONSTRAINT chk_inspection_document_deliveries_report CHECK ((document_kind='REPORT_WORD' AND report_id IS NOT NULL) OR document_kind='ACT_PDF') + ) + `); + await queryRunner.query(`CREATE UNIQUE INDEX uq_inspection_document_delivery_role ON inspection_document_deliveries (act_id, document_kind, recipient_kind, recipient_key)`); + await queryRunner.query(`CREATE INDEX idx_inspection_document_deliveries_status ON inspection_document_deliveries (status, created_at DESC)`); + for (const [code, description] of permissions) { + await queryRunner.query(`INSERT INTO permissions (code, description) VALUES ($1,$2) ON CONFLICT (code) DO UPDATE SET description=EXCLUDED.description`, [code, description]); + } + await queryRunner.query(` + WITH mapping(role_code, permission_code) AS (VALUES + ('admin','document_delivery.read'),('admin','document_delivery.manage'), + ('director','document_delivery.read'),('director','document_delivery.manage'), + ('supervisor','document_delivery.read') + ) + INSERT INTO role_permissions (role_id, permission_id) + SELECT role.id, permission.id FROM mapping + JOIN roles role ON role.code=mapping.role_code + JOIN permissions permission ON permission.code=mapping.permission_code + ON CONFLICT (role_id, permission_id) DO NOTHING + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DELETE FROM role_permissions rp USING permissions p WHERE rp.permission_id=p.id AND p.code IN ('document_delivery.read','document_delivery.manage')`); + await queryRunner.query(`DELETE FROM permissions WHERE code IN ('document_delivery.read','document_delivery.manage')`); + await queryRunner.query(`DROP INDEX idx_inspection_document_deliveries_status`); + await queryRunner.query(`DROP INDEX uq_inspection_document_delivery_role`); + await queryRunner.query(`DROP TABLE inspection_document_deliveries`); + await queryRunner.query(`DROP TABLE inspection_act_pdf_artifacts`); + await queryRunner.query(`DROP TABLE institutional_delivery_settings`); + await queryRunner.query(`ALTER TABLE organization_profiles DROP COLUMN notification_email`); + } +} diff --git a/api-v3/src/database/migrations/1788631200000-phase-d5-3-22-director-report-review.ts b/api-v3/src/database/migrations/1788631200000-phase-d5-3-22-director-report-review.ts new file mode 100644 index 0000000..a3b5478 --- /dev/null +++ b/api-v3/src/database/migrations/1788631200000-phase-d5-3-22-director-report-review.ts @@ -0,0 +1,143 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +const permissions = [ + ['inspection_reports.revise', 'Cargar versiones corregidas del informe para revisión directiva'], + ['inspection_reports.review', 'Aprobar la versión vigente del informe'], + ['inspection_reports.sign_final', 'Firmar electrónicamente el informe final aprobado'], +] as const; + +export class PhaseD5322DirectorReportReview1788631200000 implements MigrationInterface { + name = 'PhaseD5322DirectorReportReview1788631200000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE inspection_reports + ADD COLUMN review_status varchar(32) NOT NULL DEFAULT 'PENDING_REVIEW', + ADD COLUMN current_revision_number integer NOT NULL DEFAULT 0, + ADD COLUMN approved_revision_id uuid, + ADD COLUMN approved_by uuid, + ADD COLUMN approved_at timestamptz, + ADD COLUMN review_note varchar(1000), + ADD COLUMN signed_at timestamptz, + ADD CONSTRAINT chk_inspection_reports_review_status CHECK (review_status IN ('PENDING_REVIEW','APPROVED','SIGNED')), + ADD CONSTRAINT chk_inspection_reports_revision_number CHECK (current_revision_number >= 0), + ADD CONSTRAINT fk_inspection_reports_approved_by FOREIGN KEY (approved_by) REFERENCES users(id) ON DELETE SET NULL + `); + await queryRunner.query(` + CREATE TABLE inspection_report_revisions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + report_id uuid NOT NULL, + revision_number integer NOT NULL, + source varchar(24) 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, + change_summary varchar(1000), + created_by uuid NOT NULL, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_inspection_report_revisions_report FOREIGN KEY (report_id) REFERENCES inspection_reports(id) ON DELETE CASCADE, + CONSTRAINT fk_inspection_report_revisions_created_by FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE RESTRICT, + CONSTRAINT uq_inspection_report_revision_number UNIQUE (report_id, revision_number), + CONSTRAINT chk_inspection_report_revision_number CHECK (revision_number > 0), + CONSTRAINT chk_inspection_report_revision_source CHECK (source IN ('AUTO','DIRECTOR_UPLOAD')), + CONSTRAINT chk_inspection_report_revision_file CHECK ( + mime_type = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' + AND size_bytes > 0 + AND sha256 ~ '^[0-9a-f]{64}$' + ) + ) + `); + await queryRunner.query(`CREATE INDEX idx_inspection_report_revisions_report_created ON inspection_report_revisions (report_id, revision_number DESC)`); + await queryRunner.query(` + INSERT INTO inspection_report_revisions ( + report_id, revision_number, source, original_name, stored_name, mime_type, + size_bytes, sha256, change_summary, created_by, created_at + ) + SELECT + id, 1, 'AUTO', word_original_name, word_stored_name, word_mime_type, + word_size_bytes, word_sha256, 'Versión automática inicial', generated_by, + COALESCE(word_generated_at, generated_at) + FROM inspection_reports + WHERE word_status = 'READY' + AND word_original_name IS NOT NULL + AND word_stored_name IS NOT NULL + AND word_mime_type = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' + AND word_size_bytes > 0 + AND word_sha256 ~ '^[0-9a-f]{64}$' + ON CONFLICT (report_id, revision_number) DO NOTHING + `); + await queryRunner.query(` + UPDATE inspection_reports report + SET current_revision_number = 1 + WHERE EXISTS ( + SELECT 1 FROM inspection_report_revisions revision + WHERE revision.report_id = report.id AND revision.revision_number = 1 + ) + `); + await queryRunner.query(` + ALTER TABLE inspection_reports + ADD CONSTRAINT fk_inspection_reports_approved_revision + FOREIGN KEY (approved_revision_id) REFERENCES inspection_report_revisions(id) ON DELETE RESTRICT + `); + await queryRunner.query(` + CREATE TABLE inspection_report_signatures ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + report_id uuid NOT NULL UNIQUE, + revision_id uuid NOT NULL UNIQUE, + signed_by uuid NOT NULL, + signed_at timestamptz NOT NULL, + confirmation_text varchar(500) NOT NULL, + signature_payload jsonb NOT NULL, + signature_sha256 char(64) NOT NULL, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_inspection_report_signatures_report FOREIGN KEY (report_id) REFERENCES inspection_reports(id) ON DELETE CASCADE, + CONSTRAINT fk_inspection_report_signatures_revision FOREIGN KEY (revision_id) REFERENCES inspection_report_revisions(id) ON DELETE RESTRICT, + CONSTRAINT fk_inspection_report_signatures_user FOREIGN KEY (signed_by) REFERENCES users(id) ON DELETE RESTRICT, + CONSTRAINT chk_inspection_report_signature_sha CHECK (signature_sha256 ~ '^[0-9a-f]{64}$') + ) + `); + await queryRunner.query(`CREATE INDEX idx_inspection_report_signatures_signed_at ON inspection_report_signatures (signed_at DESC)`); + for (const [code, description] of permissions) { + await queryRunner.query( + `INSERT INTO permissions (code, description) VALUES ($1, $2) ON CONFLICT (code) DO UPDATE SET description = EXCLUDED.description`, + [code, description], + ); + } + await queryRunner.query(` + WITH mapping(role_code, permission_code) AS (VALUES + ('director', 'inspection_reports.revise'), + ('director', 'inspection_reports.review'), + ('director', 'inspection_reports.sign_final') + ) + INSERT INTO role_permissions (role_id, permission_id) + SELECT role.id, permission.id + FROM mapping + JOIN roles role ON role.code = mapping.role_code + JOIN permissions permission ON permission.code = mapping.permission_code + ON CONFLICT (role_id, permission_id) DO NOTHING + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DELETE FROM role_permissions rp USING permissions p WHERE rp.permission_id = p.id AND p.code IN ('inspection_reports.revise','inspection_reports.review','inspection_reports.sign_final')`); + await queryRunner.query(`DELETE FROM permissions WHERE code IN ('inspection_reports.revise','inspection_reports.review','inspection_reports.sign_final')`); + await queryRunner.query(`DROP TABLE IF EXISTS inspection_report_signatures`); + await queryRunner.query(`ALTER TABLE inspection_reports DROP CONSTRAINT IF EXISTS fk_inspection_reports_approved_revision`); + await queryRunner.query(`DROP TABLE IF EXISTS inspection_report_revisions`); + await queryRunner.query(` + ALTER TABLE inspection_reports + DROP CONSTRAINT IF EXISTS fk_inspection_reports_approved_by, + DROP CONSTRAINT IF EXISTS chk_inspection_reports_revision_number, + DROP CONSTRAINT IF EXISTS chk_inspection_reports_review_status, + DROP COLUMN IF EXISTS signed_at, + DROP COLUMN IF EXISTS review_note, + DROP COLUMN IF EXISTS approved_at, + DROP COLUMN IF EXISTS approved_by, + DROP COLUMN IF EXISTS approved_revision_id, + DROP COLUMN IF EXISTS current_revision_number, + DROP COLUMN IF EXISTS review_status + `); + } +} diff --git a/api-v3/src/database/migrations/1788717600000-phase-d5-3-23-field-discoveries.ts b/api-v3/src/database/migrations/1788717600000-phase-d5-3-23-field-discoveries.ts new file mode 100644 index 0000000..5dff527 --- /dev/null +++ b/api-v3/src/database/migrations/1788717600000-phase-d5-3-23-field-discoveries.ts @@ -0,0 +1,52 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +function quoteIdentifier(identifier: string): string { + return `"${identifier.replaceAll('\"', '\"\"')}"`; +} + +export class PhaseD5323FieldDiscoveries1788717600000 implements MigrationInterface { + name = 'PhaseD5323FieldDiscoveries1788717600000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE asset_field_discoveries ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + asset_id uuid NOT NULL UNIQUE, + visit_id uuid NOT NULL, + status varchar(24) NOT NULL DEFAULT 'PENDING', + discovery_notes text, + observed_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_by uuid NOT NULL, + reviewed_at timestamptz, + reviewed_by uuid, + review_notes text, + matched_asset_id uuid, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_asset_field_discoveries_asset FOREIGN KEY (asset_id) REFERENCES assets(id) ON DELETE RESTRICT, + CONSTRAINT fk_asset_field_discoveries_visit FOREIGN KEY (visit_id) REFERENCES inspection_visits(id) ON DELETE RESTRICT, + CONSTRAINT fk_asset_field_discoveries_created_by FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE RESTRICT, + CONSTRAINT fk_asset_field_discoveries_reviewed_by FOREIGN KEY (reviewed_by) REFERENCES users(id) ON DELETE SET NULL, + CONSTRAINT fk_asset_field_discoveries_matched_asset FOREIGN KEY (matched_asset_id) REFERENCES assets(id) ON DELETE RESTRICT, + CONSTRAINT chk_asset_field_discoveries_status CHECK (status IN ('PENDING','APPROVED','MATCHED','REJECTED')), + CONSTRAINT chk_asset_field_discoveries_match CHECK ( + (status = 'MATCHED' AND matched_asset_id IS NOT NULL) + OR (status <> 'MATCHED' AND matched_asset_id IS NULL) + ), + CONSTRAINT chk_asset_field_discoveries_not_self_match CHECK (matched_asset_id IS NULL OR matched_asset_id <> asset_id) + ) + `); + await queryRunner.query(`CREATE INDEX idx_asset_field_discoveries_status_created ON asset_field_discoveries (status, created_at DESC)`); + await queryRunner.query(`CREATE INDEX idx_asset_field_discoveries_visit ON asset_field_discoveries (visit_id, created_at DESC)`); + await queryRunner.query(`CREATE INDEX idx_asset_field_discoveries_matched ON asset_field_discoveries (matched_asset_id) WHERE matched_asset_id IS NOT NULL`); + const appRole = process.env.DB_APP_USER; + if (!appRole) throw new Error('Missing required environment variable: DB_APP_USER'); + const applicationRole = quoteIdentifier(appRole); + await queryRunner.query(`GRANT SELECT, INSERT, UPDATE ON TABLE asset_field_discoveries TO ${applicationRole}`); + await queryRunner.query(`REVOKE DELETE ON TABLE asset_field_discoveries FROM ${applicationRole}`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS asset_field_discoveries`); + } +} diff --git a/api-v3/src/database/migrations/1788804000000-phase-d5-3-24-asset-context-history.ts b/api-v3/src/database/migrations/1788804000000-phase-d5-3-24-asset-context-history.ts new file mode 100644 index 0000000..4ee1d0d --- /dev/null +++ b/api-v3/src/database/migrations/1788804000000-phase-d5-3-24-asset-context-history.ts @@ -0,0 +1,187 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +function quoteIdentifier(identifier: string): string { + return `"${identifier.replaceAll('"', '""')}"`; +} + +export class PhaseD5324AssetContextHistory1788804000000 implements MigrationInterface { + name = 'PhaseD5324AssetContextHistory1788804000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE asset_context_history ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + asset_id uuid NOT NULL, + parent_id uuid, + operational_area_id uuid, + operator_company_id uuid, + valid_from timestamptz NOT NULL, + valid_until timestamptz, + change_reason text NOT NULL, + end_reason text, + asset_version_number integer NOT NULL, + source varchar(32) NOT NULL DEFAULT 'WEB', + request_id varchar(128), + created_by uuid, + ended_by uuid, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + ended_at timestamptz, + CONSTRAINT fk_asset_context_history_asset FOREIGN KEY (asset_id) REFERENCES assets(id) ON DELETE RESTRICT, + CONSTRAINT fk_asset_context_history_parent FOREIGN KEY (parent_id) REFERENCES assets(id) ON DELETE RESTRICT, + CONSTRAINT fk_asset_context_history_area FOREIGN KEY (operational_area_id) REFERENCES assets(id) ON DELETE RESTRICT, + CONSTRAINT fk_asset_context_history_company FOREIGN KEY (operator_company_id) REFERENCES assets(id) ON DELETE RESTRICT, + CONSTRAINT fk_asset_context_history_created_by FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL, + CONSTRAINT fk_asset_context_history_ended_by FOREIGN KEY (ended_by) REFERENCES users(id) ON DELETE SET NULL, + CONSTRAINT chk_asset_context_history_dates CHECK (valid_until IS NULL OR valid_until >= valid_from), + CONSTRAINT chk_asset_context_history_context_pair CHECK ( + (operational_area_id IS NULL AND operator_company_id IS NULL) + OR (operational_area_id IS NOT NULL AND operator_company_id IS NOT NULL) + ), + CONSTRAINT chk_asset_context_history_reason CHECK (length(btrim(change_reason)) >= 5), + CONSTRAINT chk_asset_context_history_version CHECK (asset_version_number >= 1) + ) + `); + await queryRunner.query(`CREATE UNIQUE INDEX uq_asset_context_history_active ON asset_context_history (asset_id) WHERE valid_until IS NULL`); + await queryRunner.query(`CREATE INDEX idx_asset_context_history_asset_period ON asset_context_history (asset_id, valid_from DESC, valid_until)`); + await queryRunner.query(`CREATE INDEX idx_asset_context_history_area_period ON asset_context_history (operational_area_id, valid_from DESC) WHERE operational_area_id IS NOT NULL`); + await queryRunner.query(`CREATE INDEX idx_asset_context_history_company_period ON asset_context_history (operator_company_id, valid_from DESC) WHERE operator_company_id IS NOT NULL`); + + await queryRunner.query(` + WITH ordered AS ( + SELECT + version.asset_id, + version.version_number, + version.occurred_at, + NULLIF(version.snapshot #>> '{parent,id}', '')::uuid AS parent_id, + NULLIF(version.snapshot #>> '{operationalArea,id}', '')::uuid AS operational_area_id, + NULLIF(version.snapshot #>> '{operatorCompany,id}', '')::uuid AS operator_company_id, + version.actor_user_id, + version.source, + version.request_id, + ROW_NUMBER() OVER (PARTITION BY version.asset_id ORDER BY version.occurred_at, version.version_number) AS row_number, + LAG(NULLIF(version.snapshot #>> '{parent,id}', '')::uuid) OVER (PARTITION BY version.asset_id ORDER BY version.occurred_at, version.version_number) AS previous_parent_id, + LAG(NULLIF(version.snapshot #>> '{operationalArea,id}', '')::uuid) OVER (PARTITION BY version.asset_id ORDER BY version.occurred_at, version.version_number) AS previous_operational_area_id, + LAG(NULLIF(version.snapshot #>> '{operatorCompany,id}', '')::uuid) OVER (PARTITION BY version.asset_id ORDER BY version.occurred_at, version.version_number) AS previous_operator_company_id + FROM asset_versions version + ), changes AS ( + SELECT * + FROM ordered + WHERE row_number = 1 + OR parent_id IS DISTINCT FROM previous_parent_id + OR operational_area_id IS DISTINCT FROM previous_operational_area_id + OR operator_company_id IS DISTINCT FROM previous_operator_company_id + ), bounded AS ( + SELECT + changes.*, + LEAD(changes.occurred_at) OVER (PARTITION BY changes.asset_id ORDER BY changes.occurred_at, changes.version_number) AS valid_until + FROM changes + ) + INSERT INTO asset_context_history ( + asset_id, parent_id, operational_area_id, operator_company_id, + valid_from, valid_until, change_reason, asset_version_number, + source, request_id, created_by + ) + SELECT + bounded.asset_id, + bounded.parent_id, + bounded.operational_area_id, + bounded.operator_company_id, + bounded.occurred_at, + bounded.valid_until, + CASE WHEN bounded.row_number = 1 + THEN 'Contexto inicial reconstruido desde el historial versionado' + ELSE 'Cambio de contexto reconstruido desde el historial versionado' + END, + bounded.version_number, + bounded.source, + bounded.request_id, + bounded.actor_user_id + FROM bounded + ORDER BY bounded.asset_id, bounded.occurred_at, bounded.version_number + `); + + await queryRunner.query(` + INSERT INTO asset_context_history ( + asset_id, parent_id, operational_area_id, operator_company_id, + valid_from, change_reason, asset_version_number, source, created_by + ) + SELECT + asset.id, asset.parent_id, asset.operational_area_id, asset.operator_company_id, + asset.created_at, 'Contexto inicial incorporado al historial', GREATEST(asset.current_version, 1), + 'SYSTEM', asset.created_by + FROM assets asset + WHERE NOT EXISTS ( + SELECT 1 FROM asset_context_history history WHERE history.asset_id = asset.id + ) + `); + + await queryRunner.query(` + WITH mismatched AS ( + SELECT + history.id AS history_id, + asset.id AS asset_id, + asset.parent_id, + asset.operational_area_id, + asset.operator_company_id, + GREATEST(asset.updated_at, history.valid_from) AS effective_from, + GREATEST(asset.current_version, 1) AS asset_version_number, + asset.updated_by + FROM asset_context_history history + JOIN assets asset ON asset.id=history.asset_id + WHERE history.valid_until IS NULL + AND ( + history.parent_id IS DISTINCT FROM asset.parent_id + OR history.operational_area_id IS DISTINCT FROM asset.operational_area_id + OR history.operator_company_id IS DISTINCT FROM asset.operator_company_id + ) + ), closed AS ( + UPDATE asset_context_history history + SET valid_until=mismatched.effective_from, + end_reason='Sincronización con contexto vigente al instalar D5.3.24', + ended_at=CURRENT_TIMESTAMP + FROM mismatched + WHERE history.id=mismatched.history_id + RETURNING mismatched.* + ) + INSERT INTO asset_context_history ( + asset_id, parent_id, operational_area_id, operator_company_id, + valid_from, change_reason, asset_version_number, source, created_by + ) + SELECT + closed.asset_id, closed.parent_id, closed.operational_area_id, closed.operator_company_id, + closed.effective_from, 'Contexto vigente sincronizado al instalar D5.3.24', + closed.asset_version_number, 'SYSTEM', closed.updated_by + FROM closed + `); + + await queryRunner.query(` + INSERT INTO permissions (code, description) + VALUES ('assets.manage_context', 'Cambiar jerarquía, Área u Operadora preservando el historial temporal') + ON CONFLICT (code) DO UPDATE SET description = EXCLUDED.description + `); + await queryRunner.query(` + WITH mapping(role_code, permission_code) AS (VALUES + ('admin', 'assets.manage_context'), + ('supervisor', 'assets.manage_context') + ) + INSERT INTO role_permissions (role_id, permission_id) + SELECT role.id, permission.id + FROM mapping + JOIN roles role ON role.code = mapping.role_code + JOIN permissions permission ON permission.code = mapping.permission_code + ON CONFLICT (role_id, permission_id) DO NOTHING + `); + + const appRole = process.env.DB_APP_USER; + if (!appRole) throw new Error('Missing required environment variable: DB_APP_USER'); + const applicationRole = quoteIdentifier(appRole); + await queryRunner.query(`GRANT SELECT, INSERT, UPDATE ON TABLE asset_context_history TO ${applicationRole}`); + await queryRunner.query(`REVOKE DELETE ON TABLE asset_context_history FROM ${applicationRole}`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DELETE FROM role_permissions rp USING permissions p WHERE rp.permission_id = p.id AND p.code = 'assets.manage_context'`); + await queryRunner.query(`DELETE FROM permissions WHERE code = 'assets.manage_context'`); + await queryRunner.query(`DROP TABLE IF EXISTS asset_context_history`); + } +} diff --git a/api-v3/src/database/migrations/1788890400000-phase-d5-3-25-verification-event-history.ts b/api-v3/src/database/migrations/1788890400000-phase-d5-3-25-verification-event-history.ts new file mode 100644 index 0000000..f23d4a3 --- /dev/null +++ b/api-v3/src/database/migrations/1788890400000-phase-d5-3-25-verification-event-history.ts @@ -0,0 +1,190 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +function quoteIdentifier(identifier: string): string { + return `"${identifier.replaceAll('"', '""')}"`; +} + +export class PhaseD5325VerificationEventHistory1788890400000 implements MigrationInterface { + name = 'PhaseD5325VerificationEventHistory1788890400000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE inspection_finding_verification_events ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + finding_id uuid NOT NULL, + verification_visit_id uuid NULL, + event_type varchar(40) NOT NULL, + occurred_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + target_control_on date NULL, + previous_control_on date NULL, + next_control_on date NULL, + outcome varchar(24) NULL, + notes text NULL, + actor_user_id uuid NULL, + actor_username varchar(80) NULL, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT chk_inspection_finding_verification_event_type CHECK ( + event_type IN ( + 'CONTROL_DATE_DEFINED', 'CONTROL_DATE_CHANGED', 'CONTROL_DATE_CLEARED', + 'VISIT_PLANNED', 'RESULT_RECORDED' + ) + ), + CONSTRAINT chk_inspection_finding_verification_event_outcome CHECK ( + outcome IS NULL OR outcome IN ('RESOLVED', 'NOT_RESOLVED', 'REQUIRES_NEW_DATE') + ), + CONSTRAINT fk_inspection_finding_verification_event_finding FOREIGN KEY (finding_id) + REFERENCES inspection_findings(id) ON DELETE RESTRICT, + CONSTRAINT fk_inspection_finding_verification_event_visit FOREIGN KEY (verification_visit_id) + REFERENCES inspection_visits(id) ON DELETE RESTRICT, + CONSTRAINT fk_inspection_finding_verification_event_actor FOREIGN KEY (actor_user_id) + REFERENCES users(id) ON DELETE SET NULL + ) + `); + await queryRunner.query(` + CREATE INDEX idx_inspection_finding_verification_events_finding + ON inspection_finding_verification_events (finding_id, occurred_at DESC, created_at DESC) + `); + await queryRunner.query(` + CREATE INDEX idx_inspection_finding_verification_events_visit + ON inspection_finding_verification_events (verification_visit_id, occurred_at DESC) + `); + + await queryRunner.query(` + WITH ordered AS ( + SELECT + version.finding_id, + version.event, + version.created_at, + version.actor_user_id, + version.actor_username, + NULLIF(version.snapshot ->> 'nextControlOn', '')::date AS next_control_on, + NULLIF(LAG(version.snapshot ->> 'nextControlOn') OVER ( + PARTITION BY version.finding_id ORDER BY version.version_number + ), '')::date AS previous_control_on + FROM inspection_finding_versions version + ) + INSERT INTO inspection_finding_verification_events ( + finding_id, event_type, occurred_at, target_control_on, + previous_control_on, next_control_on, notes, actor_user_id, actor_username + ) + SELECT + ordered.finding_id, + CASE + WHEN ordered.next_control_on IS NULL THEN 'CONTROL_DATE_CLEARED' + WHEN ordered.previous_control_on IS NULL THEN 'CONTROL_DATE_DEFINED' + ELSE 'CONTROL_DATE_CHANGED' + END, + ordered.created_at, + ordered.next_control_on, + ordered.previous_control_on, + ordered.next_control_on, + 'Reconstruido desde historial versionado de seguimiento', + ordered.actor_user_id, + ordered.actor_username + FROM ordered + WHERE ordered.event = 'FOLLOW_UP_UPDATED' + AND ordered.next_control_on IS DISTINCT FROM ordered.previous_control_on + `); + + await queryRunner.query(` + INSERT INTO inspection_finding_verification_events ( + finding_id, verification_visit_id, event_type, occurred_at, + target_control_on, next_control_on, notes, actor_user_id + ) + SELECT + verification_link.finding_id, + verification_link.visit_id, + 'VISIT_PLANNED', + verification_link.created_at, + verification_link.target_control_on, + verification_link.target_control_on, + 'Visita de verificación reconstruida desde vínculo histórico', + verification_link.linked_by + FROM inspection_finding_verification_visits verification_link + `); + + await queryRunner.query(` + INSERT INTO inspection_finding_verification_events ( + finding_id, verification_visit_id, event_type, occurred_at, + target_control_on, previous_control_on, next_control_on, + outcome, notes, actor_user_id + ) + SELECT + verification_link.finding_id, + verification_link.visit_id, + 'RESULT_RECORDED', + COALESCE(verification_link.result_recorded_at, verification_link.verified_at, verification_link.updated_at), + verification_link.target_control_on, + verification_link.target_control_on, + verification_link.rescheduled_control_on, + verification_link.outcome, + verification_link.result_notes, + verification_link.result_recorded_by + FROM inspection_finding_verification_visits verification_link + WHERE verification_link.outcome IS NOT NULL + `); + + await queryRunner.query(` + INSERT INTO inspection_finding_verification_events ( + finding_id, verification_visit_id, event_type, occurred_at, + target_control_on, previous_control_on, next_control_on, + notes, actor_user_id + ) + SELECT + verification_link.finding_id, + verification_link.visit_id, + CASE WHEN verification_link.rescheduled_control_on IS NULL + THEN 'CONTROL_DATE_CLEARED' + ELSE 'CONTROL_DATE_CHANGED' + END, + COALESCE(verification_link.result_recorded_at, verification_link.verified_at, verification_link.updated_at), + verification_link.rescheduled_control_on, + verification_link.target_control_on, + verification_link.rescheduled_control_on, + CASE WHEN verification_link.rescheduled_control_on IS NULL + THEN 'La verificación consumió la fecha prevista sin fijar un nuevo control' + ELSE 'Nueva fecha definida como resultado de la verificación' + END, + verification_link.result_recorded_by + FROM inspection_finding_verification_visits verification_link + WHERE verification_link.outcome IS NOT NULL + `); + + await queryRunner.query(` + CREATE OR REPLACE FUNCTION protect_inspection_verification_result_immutability() + RETURNS trigger AS $$ + BEGIN + IF OLD.result_recorded_at IS NOT NULL AND ( + NEW.outcome IS DISTINCT FROM OLD.outcome + OR NEW.result_notes IS DISTINCT FROM OLD.result_notes + OR NEW.verified_at IS DISTINCT FROM OLD.verified_at + OR NEW.result_recorded_at IS DISTINCT FROM OLD.result_recorded_at + OR NEW.result_recorded_by IS DISTINCT FROM OLD.result_recorded_by + OR NEW.rescheduled_control_on IS DISTINCT FROM OLD.rescheduled_control_on + ) THEN + RAISE EXCEPTION 'El resultado de verificación ya registrado es inmutable'; + END IF; + RETURN NEW; + END; + $$ LANGUAGE plpgsql + `); + await queryRunner.query(` + CREATE TRIGGER trg_inspection_verification_result_immutable + BEFORE UPDATE ON inspection_finding_verification_visits + FOR EACH ROW + EXECUTE FUNCTION protect_inspection_verification_result_immutability() + `); + + const appRole = process.env.DB_APP_USER; + if (!appRole) throw new Error('Missing required environment variable: DB_APP_USER'); + const applicationRole = quoteIdentifier(appRole); + await queryRunner.query(`GRANT SELECT, INSERT ON TABLE inspection_finding_verification_events TO ${applicationRole}`); + await queryRunner.query(`REVOKE UPDATE, DELETE ON TABLE inspection_finding_verification_events FROM ${applicationRole}`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query('DROP TRIGGER IF EXISTS trg_inspection_verification_result_immutable ON inspection_finding_verification_visits'); + await queryRunner.query('DROP FUNCTION IF EXISTS protect_inspection_verification_result_immutability()'); + await queryRunner.query('DROP TABLE inspection_finding_verification_events'); + } +} diff --git a/api-v3/src/database/migrations/1788976800000-phase-d5-4-contextual-finding-catalog.ts b/api-v3/src/database/migrations/1788976800000-phase-d5-4-contextual-finding-catalog.ts new file mode 100644 index 0000000..e5dbf27 --- /dev/null +++ b/api-v3/src/database/migrations/1788976800000-phase-d5-4-contextual-finding-catalog.ts @@ -0,0 +1,209 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +function quoteIdentifier(identifier: string): string { + return `"${identifier.replaceAll('"', '""')}"`; +} + +export class PhaseD54ContextualFindingCatalog1788976800000 implements MigrationInterface { + name = 'PhaseD54ContextualFindingCatalog1788976800000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE finding_catalog_items + ADD COLUMN suggested_severity smallint, + ADD CONSTRAINT chk_finding_catalog_items_suggested_severity + CHECK (suggested_severity IS NULL OR suggested_severity BETWEEN 1 AND 10) + `); + await queryRunner.query(` + ALTER TABLE inspection_findings + ADD COLUMN suggested_severity smallint, + ADD COLUMN severity smallint, + ADD CONSTRAINT chk_inspection_findings_suggested_severity + CHECK (suggested_severity IS NULL OR suggested_severity BETWEEN 1 AND 10), + ADD CONSTRAINT chk_inspection_findings_severity + CHECK (severity IS NULL OR severity BETWEEN 1 AND 10) + `); + + await queryRunner.query(` + CREATE TABLE finding_catalog_asset_type_profiles ( + asset_type_id uuid PRIMARY KEY, + reason text NOT NULL, + updated_by uuid, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT chk_finding_catalog_asset_type_profiles_reason CHECK (char_length(btrim(reason)) >= 5), + CONSTRAINT fk_finding_catalog_asset_type_profiles_type FOREIGN KEY (asset_type_id) + REFERENCES asset_types(id) ON DELETE CASCADE, + CONSTRAINT fk_finding_catalog_asset_type_profiles_updated_by FOREIGN KEY (updated_by) + REFERENCES users(id) ON DELETE SET NULL + ) + `); + await queryRunner.query(` + CREATE INDEX idx_finding_catalog_asset_type_profiles_updated + ON finding_catalog_asset_type_profiles (updated_at DESC) + `); + + await queryRunner.query(` + CREATE TABLE finding_catalog_item_asset_types ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + catalog_item_id uuid NOT NULL, + asset_type_id uuid NOT NULL, + created_by uuid, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uq_finding_catalog_item_asset_types_pair UNIQUE (catalog_item_id, asset_type_id), + CONSTRAINT fk_finding_catalog_item_asset_types_item FOREIGN KEY (catalog_item_id) + REFERENCES finding_catalog_items(id) ON DELETE CASCADE, + CONSTRAINT fk_finding_catalog_item_asset_types_type FOREIGN KEY (asset_type_id) + REFERENCES asset_types(id) ON DELETE CASCADE, + CONSTRAINT fk_finding_catalog_item_asset_types_created_by FOREIGN KEY (created_by) + REFERENCES users(id) ON DELETE SET NULL + ) + `); + await queryRunner.query(` + CREATE INDEX idx_finding_catalog_item_asset_types_type + ON finding_catalog_item_asset_types (asset_type_id, catalog_item_id) + `); + + await queryRunner.query(` + CREATE TABLE finding_catalog_asset_overrides ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + asset_id uuid NOT NULL, + catalog_item_id uuid NOT NULL, + is_enabled boolean NOT NULL, + reason text NOT NULL, + created_by uuid, + updated_by uuid, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uq_finding_catalog_asset_overrides_pair UNIQUE (asset_id, catalog_item_id), + CONSTRAINT chk_finding_catalog_asset_overrides_reason CHECK (char_length(btrim(reason)) >= 5), + CONSTRAINT fk_finding_catalog_asset_overrides_asset FOREIGN KEY (asset_id) + REFERENCES assets(id) ON DELETE CASCADE, + CONSTRAINT fk_finding_catalog_asset_overrides_item FOREIGN KEY (catalog_item_id) + REFERENCES finding_catalog_items(id) ON DELETE CASCADE, + CONSTRAINT fk_finding_catalog_asset_overrides_created_by FOREIGN KEY (created_by) + REFERENCES users(id) ON DELETE SET NULL, + CONSTRAINT fk_finding_catalog_asset_overrides_updated_by FOREIGN KEY (updated_by) + REFERENCES users(id) ON DELETE SET NULL + ) + `); + await queryRunner.query(` + CREATE INDEX idx_finding_catalog_asset_overrides_asset + ON finding_catalog_asset_overrides (asset_id) + `); + + await queryRunner.query(` + CREATE TABLE finding_catalog_proposals ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + finding_id uuid NOT NULL, + asset_id uuid NOT NULL, + asset_type_id uuid NOT NULL, + proposed_title varchar(500) NOT NULL, + proposed_legal_basis text, + proposed_severity smallint, + description text NOT NULL, + status varchar(20) NOT NULL DEFAULT 'PENDING', + resolved_catalog_item_id uuid, + office_notes text, + reviewed_by uuid, + reviewed_at timestamptz, + created_by uuid, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uq_finding_catalog_proposals_finding UNIQUE (finding_id), + CONSTRAINT chk_finding_catalog_proposals_status CHECK (status IN ('PENDING', 'MATCHED', 'REJECTED')), + CONSTRAINT chk_finding_catalog_proposals_severity + CHECK (proposed_severity IS NULL OR proposed_severity BETWEEN 1 AND 10), + CONSTRAINT fk_finding_catalog_proposals_finding FOREIGN KEY (finding_id) + REFERENCES inspection_findings(id) ON DELETE RESTRICT, + CONSTRAINT fk_finding_catalog_proposals_asset FOREIGN KEY (asset_id) + REFERENCES assets(id) ON DELETE RESTRICT, + CONSTRAINT fk_finding_catalog_proposals_asset_type FOREIGN KEY (asset_type_id) + REFERENCES asset_types(id) ON DELETE RESTRICT, + CONSTRAINT fk_finding_catalog_proposals_resolved_item FOREIGN KEY (resolved_catalog_item_id) + REFERENCES finding_catalog_items(id) ON DELETE RESTRICT, + CONSTRAINT fk_finding_catalog_proposals_reviewed_by FOREIGN KEY (reviewed_by) + REFERENCES users(id) ON DELETE SET NULL, + CONSTRAINT fk_finding_catalog_proposals_created_by FOREIGN KEY (created_by) + REFERENCES users(id) ON DELETE SET NULL + ) + `); + await queryRunner.query(` + CREATE INDEX idx_finding_catalog_proposals_status + ON finding_catalog_proposals (status, created_at DESC) + `); + await queryRunner.query(` + CREATE INDEX idx_finding_catalog_proposals_asset_type + ON finding_catalog_proposals (asset_type_id, status, created_at DESC) + `); + + await queryRunner.query(` + INSERT INTO finding_catalog_proposals ( + finding_id, asset_id, asset_type_id, proposed_title, proposed_legal_basis, + proposed_severity, description, status, created_by, created_at, updated_at + ) + SELECT + finding.id, + finding.asset_id, + asset.asset_type_id, + finding.title, + finding.legal_basis, + finding.severity, + finding.description, + 'PENDING', + finding.created_by, + finding.created_at, + finding.updated_at + FROM inspection_findings finding + INNER JOIN assets asset ON asset.id = finding.asset_id + WHERE finding.catalog_item_id IS NULL + ON CONFLICT (finding_id) DO NOTHING + `); + + const appRole = process.env.DB_APP_USER; + if (!appRole) throw new Error('Missing required environment variable: DB_APP_USER'); + const roleRows = (await queryRunner.query( + 'SELECT 1 FROM pg_roles WHERE rolname = $1', + [appRole], + )) as unknown[]; + if (roleRows.length !== 1) throw new Error('Configured DB_APP_USER does not exist'); + const applicationRole = quoteIdentifier(appRole); + + await queryRunner.query(` + GRANT SELECT, INSERT, UPDATE ON TABLE finding_catalog_asset_type_profiles + TO ${applicationRole} + `); + await queryRunner.query(` + GRANT SELECT, INSERT, DELETE ON TABLE finding_catalog_item_asset_types + TO ${applicationRole} + `); + await queryRunner.query(` + GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE finding_catalog_asset_overrides + TO ${applicationRole} + `); + await queryRunner.query(` + GRANT SELECT, INSERT, UPDATE ON TABLE finding_catalog_proposals + TO ${applicationRole} + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query('DROP TABLE finding_catalog_proposals'); + await queryRunner.query('DROP TABLE finding_catalog_asset_overrides'); + await queryRunner.query('DROP TABLE finding_catalog_item_asset_types'); + await queryRunner.query('DROP TABLE finding_catalog_asset_type_profiles'); + await queryRunner.query(` + ALTER TABLE inspection_findings + DROP CONSTRAINT chk_inspection_findings_severity, + DROP CONSTRAINT chk_inspection_findings_suggested_severity, + DROP COLUMN severity, + DROP COLUMN suggested_severity + `); + await queryRunner.query(` + ALTER TABLE finding_catalog_items + DROP CONSTRAINT chk_finding_catalog_items_suggested_severity, + DROP COLUMN suggested_severity + `); + } +} diff --git a/api-v3/src/database/migrations/1789063200000-phase-d5-5-web-planning-checklist.ts b/api-v3/src/database/migrations/1789063200000-phase-d5-5-web-planning-checklist.ts new file mode 100644 index 0000000..dd55675 --- /dev/null +++ b/api-v3/src/database/migrations/1789063200000-phase-d5-5-web-planning-checklist.ts @@ -0,0 +1,219 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +function quoteIdentifier(identifier: string): string { + return `"${identifier.replaceAll('"', '""')}"`; +} + +export class PhaseD55WebPlanningChecklist1789063200000 implements MigrationInterface { + name = 'PhaseD55WebPlanningChecklist1789063200000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE inspection_visits + ADD COLUMN operational_area_id uuid, + ADD COLUMN operator_company_id uuid, + ADD COLUMN checklist_generation integer NOT NULL DEFAULT 0, + ADD COLUMN checklist_generated_at timestamptz, + ADD CONSTRAINT fk_inspection_visits_operational_area FOREIGN KEY (operational_area_id) + REFERENCES assets(id) ON DELETE RESTRICT, + ADD CONSTRAINT fk_inspection_visits_operator_company FOREIGN KEY (operator_company_id) + REFERENCES assets(id) ON DELETE RESTRICT, + ADD CONSTRAINT chk_inspection_visits_checklist_generation CHECK (checklist_generation >= 0), + ADD CONSTRAINT chk_inspection_visits_operational_context_pair CHECK ( + (operational_area_id IS NULL AND operator_company_id IS NULL) + OR (operational_area_id IS NOT NULL AND operator_company_id IS NOT NULL) + ) + `); + await queryRunner.query(`CREATE INDEX idx_inspection_visits_operational_context ON inspection_visits (operational_area_id, operator_company_id)`); + + await queryRunner.query(` + WITH context AS ( + SELECT + visit.id, + COALESCE( + (MAX(scope.id::text) FILTER (WHERE scope_type.operational_role = 'AREA'))::uuid, + MIN(asset.operational_area_id::text)::uuid + ) AS area_id, + CASE + WHEN COUNT(DISTINCT asset.operator_company_id) FILTER (WHERE asset.operator_company_id IS NOT NULL) = 1 + THEN MIN(asset.operator_company_id::text)::uuid + ELSE NULL + END AS company_id + FROM inspection_visits visit + LEFT JOIN assets scope ON scope.id = visit.scope_asset_id + LEFT JOIN asset_types scope_type ON scope_type.id = scope.asset_type_id + LEFT JOIN inspection_visit_assets link ON link.visit_id = visit.id AND link.included = true + LEFT JOIN assets asset ON asset.id = link.asset_id + GROUP BY visit.id + ) + UPDATE inspection_visits visit + SET operational_area_id = context.area_id, + operator_company_id = context.company_id + FROM context + WHERE context.id = visit.id + AND context.area_id IS NOT NULL + AND context.company_id IS NOT NULL + `); + + await queryRunner.query(` + ALTER TABLE inspection_visit_assets + ADD COLUMN planning_source varchar(24) NOT NULL DEFAULT 'LEGACY', + ADD COLUMN exclusion_reason text, + ADD COLUMN excluded_by uuid, + ADD COLUMN excluded_at timestamptz, + ADD CONSTRAINT fk_inspection_visit_assets_excluded_by FOREIGN KEY (excluded_by) + REFERENCES users(id) ON DELETE SET NULL, + ADD CONSTRAINT chk_inspection_visit_assets_planning_source CHECK ( + planning_source IN ('LEGACY','AUTOMATIC','PREVENTIVE','VERIFICATION') + ) + `); + await queryRunner.query(` + UPDATE inspection_visit_assets link + SET planning_source = 'VERIFICATION' + WHERE EXISTS ( + SELECT 1 + FROM inspection_finding_verification_visits verification_link + INNER JOIN inspection_findings finding ON finding.id = verification_link.finding_id + WHERE verification_link.visit_id = link.visit_id + AND finding.asset_id = link.asset_id + ) + `); + await queryRunner.query(` + UPDATE inspection_visit_assets + SET exclusion_reason = 'Exclusión histórica previa a D5.5', + excluded_by = added_by, + excluded_at = updated_at + WHERE included = false + AND exclusion_reason IS NULL + `); + await queryRunner.query(` + ALTER TABLE inspection_visit_assets + ADD CONSTRAINT chk_inspection_visit_assets_exclusion_state CHECK ( + (included = true AND exclusion_reason IS NULL AND excluded_at IS NULL) + OR (included = false AND LENGTH(TRIM(COALESCE(exclusion_reason, ''))) >= 10 AND excluded_at IS NOT NULL) + ) + `); + + await queryRunner.query(` + CREATE TABLE inspection_visit_checklist_items ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + visit_id uuid NOT NULL, + generation_number integer NOT NULL, + finding_id uuid NOT NULL, + asset_id uuid NOT NULL, + item_kind varchar(32) NOT NULL, + reference_on date, + finding_status varchar(20) NOT NULL, + finding_code varchar(40) NOT NULL, + finding_title varchar(500) NOT NULL, + severity smallint, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_inspection_visit_checklist_visit FOREIGN KEY (visit_id) + REFERENCES inspection_visits(id) ON DELETE RESTRICT, + CONSTRAINT fk_inspection_visit_checklist_finding FOREIGN KEY (finding_id) + REFERENCES inspection_findings(id) ON DELETE RESTRICT, + CONSTRAINT fk_inspection_visit_checklist_asset FOREIGN KEY (asset_id) + REFERENCES assets(id) ON DELETE RESTRICT, + CONSTRAINT uq_inspection_visit_checklist_generation_finding UNIQUE (visit_id, generation_number, finding_id), + CONSTRAINT chk_inspection_visit_checklist_generation CHECK (generation_number > 0), + CONSTRAINT chk_inspection_visit_checklist_kind CHECK ( + item_kind IN ('ANTECEDENT','COMPANY_OVERDUE','VERIFICATION_OVERDUE','UPCOMING_CONTROL') + ), + CONSTRAINT chk_inspection_visit_checklist_severity CHECK (severity IS NULL OR severity BETWEEN 1 AND 10) + ) + `); + await queryRunner.query(`CREATE INDEX idx_inspection_visit_checklist_current ON inspection_visit_checklist_items (visit_id, generation_number, item_kind)`); + await queryRunner.query(`CREATE INDEX idx_inspection_visit_checklist_asset ON inspection_visit_checklist_items (visit_id, generation_number, asset_id)`); + + await queryRunner.query(` + CREATE TABLE inspection_visit_asset_events ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + visit_id uuid NOT NULL, + asset_id uuid NOT NULL, + event_type varchar(32) NOT NULL, + reason text, + actor_user_id uuid, + occurred_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + metadata jsonb, + CONSTRAINT fk_inspection_visit_asset_events_visit FOREIGN KEY (visit_id) + REFERENCES inspection_visits(id) ON DELETE RESTRICT, + CONSTRAINT fk_inspection_visit_asset_events_asset FOREIGN KEY (asset_id) + REFERENCES assets(id) ON DELETE RESTRICT, + CONSTRAINT fk_inspection_visit_asset_events_actor FOREIGN KEY (actor_user_id) + REFERENCES users(id) ON DELETE SET NULL, + CONSTRAINT chk_inspection_visit_asset_events_type CHECK ( + event_type IN ('LEGACY_INCLUDED','AUTO_INCLUDED','PREVENTIVE_INCLUDED','VERIFICATION_INCLUDED','EXCLUDED','REINCLUDED') + ), + CONSTRAINT chk_inspection_visit_asset_events_exclusion_reason CHECK ( + event_type <> 'EXCLUDED' OR LENGTH(TRIM(COALESCE(reason, ''))) >= 10 + ) + ) + `); + await queryRunner.query(`CREATE INDEX idx_inspection_visit_asset_events_visit ON inspection_visit_asset_events (visit_id, occurred_at, id)`); + await queryRunner.query(`CREATE INDEX idx_inspection_visit_asset_events_asset ON inspection_visit_asset_events (asset_id, occurred_at, id)`); + + await queryRunner.query(` + INSERT INTO inspection_visit_asset_events ( + visit_id, asset_id, event_type, actor_user_id, occurred_at, metadata + ) + SELECT + link.visit_id, + link.asset_id, + CASE WHEN link.planning_source = 'VERIFICATION' THEN 'VERIFICATION_INCLUDED' ELSE 'LEGACY_INCLUDED' END, + link.added_by, + link.created_at, + JSONB_BUILD_OBJECT('backfill', true, 'planningSource', link.planning_source) + FROM inspection_visit_assets link + `); + await queryRunner.query(` + INSERT INTO inspection_visit_asset_events ( + visit_id, asset_id, event_type, reason, actor_user_id, occurred_at, metadata + ) + SELECT + link.visit_id, + link.asset_id, + 'EXCLUDED', + link.exclusion_reason, + link.excluded_by, + COALESCE(link.excluded_at, link.updated_at), + JSONB_BUILD_OBJECT('backfill', true) + FROM inspection_visit_assets link + WHERE link.included = false + `); + + const appRole = process.env.DB_APP_USER; + if (!appRole) throw new Error('Missing required environment variable: DB_APP_USER'); + const applicationRole = quoteIdentifier(appRole); + await queryRunner.query(` + GRANT SELECT, INSERT ON TABLE + inspection_visit_checklist_items, inspection_visit_asset_events + TO ${applicationRole} + `); + await queryRunner.query(` + REVOKE UPDATE, DELETE ON TABLE + inspection_visit_checklist_items, inspection_visit_asset_events + FROM ${applicationRole} + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE inspection_visit_asset_events`); + await queryRunner.query(`DROP TABLE inspection_visit_checklist_items`); + await queryRunner.query(`ALTER TABLE inspection_visit_assets DROP CONSTRAINT chk_inspection_visit_assets_exclusion_state`); + await queryRunner.query(`ALTER TABLE inspection_visit_assets DROP CONSTRAINT fk_inspection_visit_assets_excluded_by`); + await queryRunner.query(`ALTER TABLE inspection_visit_assets DROP CONSTRAINT chk_inspection_visit_assets_planning_source`); + await queryRunner.query(`ALTER TABLE inspection_visit_assets DROP COLUMN excluded_at`); + await queryRunner.query(`ALTER TABLE inspection_visit_assets DROP COLUMN excluded_by`); + await queryRunner.query(`ALTER TABLE inspection_visit_assets DROP COLUMN exclusion_reason`); + await queryRunner.query(`ALTER TABLE inspection_visit_assets DROP COLUMN planning_source`); + await queryRunner.query(`DROP INDEX idx_inspection_visits_operational_context`); + await queryRunner.query(`ALTER TABLE inspection_visits DROP CONSTRAINT chk_inspection_visits_operational_context_pair`); + await queryRunner.query(`ALTER TABLE inspection_visits DROP CONSTRAINT chk_inspection_visits_checklist_generation`); + await queryRunner.query(`ALTER TABLE inspection_visits DROP CONSTRAINT fk_inspection_visits_operator_company`); + await queryRunner.query(`ALTER TABLE inspection_visits DROP CONSTRAINT fk_inspection_visits_operational_area`); + await queryRunner.query(`ALTER TABLE inspection_visits DROP COLUMN checklist_generated_at`); + await queryRunner.query(`ALTER TABLE inspection_visits DROP COLUMN checklist_generation`); + await queryRunner.query(`ALTER TABLE inspection_visits DROP COLUMN operator_company_id`); + await queryRunner.query(`ALTER TABLE inspection_visits DROP COLUMN operational_area_id`); + } +} diff --git a/api-v3/src/database/seeds/phase-d3-finding-catalog.ts b/api-v3/src/database/seeds/phase-d3-finding-catalog.ts new file mode 100644 index 0000000..6afd175 --- /dev/null +++ b/api-v3/src/database/seeds/phase-d3-finding-catalog.ts @@ -0,0 +1,1646 @@ +export interface PhaseD3FindingCategorySeed { + code: string; + name: string; + sortOrder: number; +} + +export interface PhaseD3FindingCatalogItemSeed { + code: string; + categoryCode: string; + sourceNumber: number; + title: string; + legalBasis: string | null; + glossary: string | null; + importNote: string | null; +} + +export const PHASE_D3_FINDING_CATEGORIES: PhaseD3FindingCategorySeed[] = [ + { + "code": "TANQUES", + "name": "Tanques", + "sortOrder": 1 + }, + { + "code": "SEPARADORES", + "name": "Separadores", + "sortOrder": 2 + }, + { + "code": "BOMBAS", + "name": "Bombas y zona de bombas", + "sortOrder": 3 + }, + { + "code": "CALDERAS", + "name": "Calderas", + "sortOrder": 4 + }, + { + "code": "DRENAJE", + "name": "Drenaje", + "sortOrder": 5 + }, + { + "code": "ANTORCHA", + "name": "Antorcha", + "sortOrder": 6 + }, + { + "code": "COLECTORES", + "name": "Colectores", + "sortOrder": 7 + }, + { + "code": "SISTEMA_ELECTRICO_ILUMINACION", + "name": "Sistema eléctrico e iluminación", + "sortOrder": 8 + }, + { + "code": "DEFENSA_CONTRA_INCENDIOS", + "name": "Defensa contra incendios", + "sortOrder": 9 + }, + { + "code": "CARGADERO_DESCARGADERO", + "name": "Cargadero y descargadero de camiones", + "sortOrder": 10 + }, + { + "code": "FILTROS", + "name": "Filtros", + "sortOrder": 11 + }, + { + "code": "EQUIPO_FLOTACION", + "name": "Equipo de flotación", + "sortOrder": 12 + }, + { + "code": "BATERIAS_PLANTAS", + "name": "Baterías y plantas", + "sortOrder": 13 + }, + { + "code": "FWKO_TRATADORES_CALENTADORES", + "name": "FWKO, tratadores y calentadores", + "sortOrder": 14 + }, + { + "code": "BOMBEO_MECANICO", + "name": "Bombeo mecánico", + "sortOrder": 15 + }, + { + "code": "BOMBEO_ELECTROSUMERGIBLE", + "name": "Bombeo electrosumergible", + "sortOrder": 16 + }, + { + "code": "BOMBEO_CAVIDAD_PROGRESIVA", + "name": "Bombeo de cavidad progresiva (PCP)", + "sortOrder": 17 + }, + { + "code": "POZOS_SURGENTES_GAS", + "name": "Pozos surgentes y productores de gas", + "sortOrder": 18 + }, + { + "code": "POZOS_INYECTORES_AGUA", + "name": "Pozos inyectores de agua", + "sortOrder": 19 + } +]; + +export const PHASE_D3_FINDING_CATALOG_ITEMS: PhaseD3FindingCatalogItemSeed[] = [ + { + "code": "TANQUES-001", + "categoryCode": "TANQUES", + "sourceNumber": 1, + "title": "ESTADO DEL TANQUE", + "legalBasis": "Ley 13660 Decreto 10877 art 346", + "glossary": "TANQUE A CIELO ABIERTO- CORROSION-INTEGRIDAD-PERDIADS DE HC- ESTADO ENVOLVENTE-PISO DAÑADO", + "importNote": null + }, + { + "code": "TANQUES-002", + "categoryCode": "TANQUES", + "sourceNumber": 2, + "title": "BARANDAS-PLATAFORMAS-ESCALERAS-PISADERAS", + "legalBasis": "Ley 13660 Decreto 10877 art 346 / Norma NAG 125 (Cap II - 2.3.)", + "glossary": "PISADERA EN TECHO- BARANDA EN TECHO- ESCALERA EN TANQUE", + "importNote": null + }, + { + "code": "TANQUES-003", + "categoryCode": "TANQUES", + "sourceNumber": 3, + "title": "INTEGRIDAD DEL TANQUE", + "legalBasis": "API 650", + "glossary": "PISO, TECHO, PAREDES (ESTADO GENERAL/ PÉRDIDAS)", + "importNote": null + }, + { + "code": "TANQUES-004", + "categoryCode": "TANQUES", + "sourceNumber": 4, + "title": "VALVULAS TECHO DE TANQUE", + "legalBasis": "Ley 13660 Decreto 10877, art 339, artt 340 - Decreto 1102/2024 art 33 - NFPA 30 art 21,4,3,10", + "glossary": "PEFOW, VPV, CUELLO DE CISNE", + "importNote": null + }, + { + "code": "TANQUES-007", + "categoryCode": "TANQUES", + "sourceNumber": 7, + "title": "CHAPA DE IDENTIFICACION", + "legalBasis": "API 650", + "glossary": null, + "importNote": null + }, + { + "code": "TANQUES-008", + "categoryCode": "TANQUES", + "sourceNumber": 8, + "title": "CARTELERIA", + "legalBasis": "Res 277/25", + "glossary": null, + "importNote": null + }, + { + "code": "TANQUES-009", + "categoryCode": "TANQUES", + "sourceNumber": 9, + "title": "SEÑALIZACION NFPA (ROMBO)", + "legalBasis": "NFPA 704", + "glossary": null, + "importNote": null + }, + { + "code": "TANQUES-010", + "categoryCode": "TANQUES", + "sourceNumber": 10, + "title": "CAÑERIA (ESTADO /INDENTIFICACION)", + "legalBasis": "NAG 123 // IRAM 2507 -NFPA 30 art 27.10", + "glossary": null, + "importNote": null + }, + { + "code": "TANQUES-011", + "categoryCode": "TANQUES", + "sourceNumber": 11, + "title": "PUESTA A TIERRA (CANTIDAD / MEDICION)", + "legalBasis": "Ley 13660 Decreto 10877 art 336 - Decreto 1102/2024 art 33 inc II-A-e Ley 13.660, Decreto 10877/60, art. 336 (tanques)\nLey N° 19.587, Decreto N° 351/79, Anexo VI\nNorma NAG 125 (Cap II - 2.4.2.)", + "glossary": null, + "importNote": null + }, + { + "code": "TANQUES-013", + "categoryCode": "TANQUES", + "sourceNumber": 13, + "title": "BRIDAS (ESTADO / BULONERIA)", + "legalBasis": null, + "glossary": null, + "importNote": null + }, + { + "code": "TANQUES-014", + "categoryCode": "TANQUES", + "sourceNumber": 14, + "title": "DISTANCIAMIENTO MINIMO", + "legalBasis": "Ley 13660 Decreto 10877, art 321", + "glossary": null, + "importNote": null + }, + { + "code": "TANQUES-015", + "categoryCode": "TANQUES", + "sourceNumber": 15, + "title": "RECINTO DE CONTENCION", + "legalBasis": "Ley 13660 Decreto 10877, art 329, art 331 - NFPA 30 - NFPA 30 art 22// Res. SE 105/1992", + "glossary": "(ESTADO / CAPACIDAD/ COTA DE LLENADO/ TAPAS Y/O PISADERAS)", + "importNote": null + }, + { + "code": "TANQUES-018", + "categoryCode": "TANQUES", + "sourceNumber": 18, + "title": "CAMARA COLECTORA DE DRENAJE", + "legalBasis": "Ley 13660 Decreto 10877 art 347", + "glossary": null, + "importNote": null + }, + { + "code": "TANQUES-019", + "categoryCode": "TANQUES", + "sourceNumber": 19, + "title": "CUMPLIMIENTO RESOLUCION 277/2025 TAAH", + "legalBasis": "Res 277/25", + "glossary": null, + "importNote": null + }, + { + "code": "TANQUES-020", + "categoryCode": "TANQUES", + "sourceNumber": 20, + "title": "CARTELERIA DE SEGURIDAD", + "legalBasis": "Ley 19587 art 9", + "glossary": null, + "importNote": null + }, + { + "code": "TANQUES-024", + "categoryCode": "TANQUES", + "sourceNumber": 24, + "title": "ORDEN Y LIMPIEZA", + "legalBasis": "Ley 13.660, Decreto 10877/60, art. 230 y art. 354", + "glossary": null, + "importNote": null + }, + { + "code": "SEPARADORES-001", + "categoryCode": "SEPARADORES", + "sourceNumber": 1, + "title": "ESTADO GENERAL", + "legalBasis": "Ley N° 19.587, decreto reglamentario N° 351/79", + "glossary": null, + "importNote": null + }, + { + "code": "SEPARADORES-002", + "categoryCode": "SEPARADORES", + "sourceNumber": 2, + "title": "CARTELERIA DE SEGURIDAD", + "legalBasis": "Ley 19587 art 9", + "glossary": null, + "importNote": null + }, + { + "code": "SEPARADORES-003", + "categoryCode": "SEPARADORES", + "sourceNumber": 3, + "title": "PUESTA TIERRA", + "legalBasis": null, + "glossary": null, + "importNote": null + }, + { + "code": "SEPARADORES-004", + "categoryCode": "SEPARADORES", + "sourceNumber": 4, + "title": "RECINTO DE CONTENCIÓN", + "legalBasis": "cv", + "glossary": null, + "importNote": null + }, + { + "code": "SEPARADORES-005", + "categoryCode": "SEPARADORES", + "sourceNumber": 5, + "title": "PLACA DE ESTALLIDO", + "legalBasis": "Ley N° 19.587, decreto reglamentario N° 351/79, art.141", + "glossary": null, + "importNote": null + }, + { + "code": "SEPARADORES-006", + "categoryCode": "SEPARADORES", + "sourceNumber": 6, + "title": "VÁLVULA DE SEGURIDAD", + "legalBasis": "Ley N° 19.587, decreto reglamentario N° 351/79, art.141\nNormas: NAG 125 (Cap. II - 2.11)// NAG 201", + "glossary": null, + "importNote": null + }, + { + "code": "SEPARADORES-007", + "categoryCode": "SEPARADORES", + "sourceNumber": 7, + "title": "MANOMETROS", + "legalBasis": "Ley N° 19.587, decreto reglamentario N° 351/79, art.141", + "glossary": null, + "importNote": null + }, + { + "code": "SEPARADORES-008", + "categoryCode": "SEPARADORES", + "sourceNumber": 8, + "title": "VALVULA DE PURGA DE FONDO", + "legalBasis": null, + "glossary": null, + "importNote": null + }, + { + "code": "SEPARADORES-009", + "categoryCode": "SEPARADORES", + "sourceNumber": 9, + "title": "ESCALERAS Y PASARELAS", + "legalBasis": "Norma NAG 125 (Cap II - 2.3.)", + "glossary": null, + "importNote": null + }, + { + "code": "SEPARADORES-010", + "categoryCode": "SEPARADORES", + "sourceNumber": 10, + "title": "ORDEN Y LIMPIEZA", + "legalBasis": "Ley 13.660, Decreto 10877/60, art. 230 y art. 354", + "glossary": null, + "importNote": null + }, + { + "code": "BOMBAS-001", + "categoryCode": "BOMBAS", + "sourceNumber": 1, + "title": "ESTADO GENERAL (PERDIDAS)", + "legalBasis": "DATOS DEL EQUIPO NFPA 30", + "glossary": null, + "importNote": null + }, + { + "code": "BOMBAS-002", + "categoryCode": "BOMBAS", + "sourceNumber": 2, + "title": "RECINTO DE CONTENCIÓN - CANALETAS -CAMARAS DE DRENAJE", + "legalBasis": "Ley 13660 Decreto 10877/60, art 347, art.410, art. 989", + "glossary": null, + "importNote": null + }, + { + "code": "BOMBAS-003", + "categoryCode": "BOMBAS", + "sourceNumber": 3, + "title": "PUESTA A TIERRA", + "legalBasis": "Ley 19587, Art.149", + "glossary": null, + "importNote": "La celda de título contenía el número 4; se normalizó por contexto y normativa." + }, + { + "code": "BOMBAS-004", + "categoryCode": "BOMBAS", + "sourceNumber": 4, + "title": "CARTELERIA INDICATIVA", + "legalBasis": "Ley 19587 art 9", + "glossary": null, + "importNote": null + }, + { + "code": "BOMBAS-005", + "categoryCode": "BOMBAS", + "sourceNumber": 5, + "title": "ORDEN Y LIMPIEZA", + "legalBasis": "Ley 13.660, Decreto 10877/60, art. 230 y art. 354", + "glossary": null, + "importNote": null + }, + { + "code": "BOMBAS-006", + "categoryCode": "BOMBAS", + "sourceNumber": 6, + "title": "SISTEMAS DE PROTECCION", + "legalBasis": null, + "glossary": "VÁLVULA DE SEGURIDAD EN BOMBAS DE DESPLAZAMIENTO POSITIVO", + "importNote": null + }, + { + "code": "BOMBAS-008", + "categoryCode": "BOMBAS", + "sourceNumber": 8, + "title": "GUARDACORREA", + "legalBasis": null, + "glossary": null, + "importNote": null + }, + { + "code": "BOMBAS-009", + "categoryCode": "BOMBAS", + "sourceNumber": 9, + "title": "SISTEMA DE PURGA DEL EQUIPO", + "legalBasis": null, + "glossary": null, + "importNote": null + }, + { + "code": "BOMBAS-010", + "categoryCode": "BOMBAS", + "sourceNumber": 10, + "title": "CONDICIONES APE", + "legalBasis": null, + "glossary": null, + "importNote": null + }, + { + "code": "CALDERAS-001", + "categoryCode": "CALDERAS", + "sourceNumber": 1, + "title": "HABILITACIÓN POR ORGANISMO OFICIAL (DOCUMENTACIÓN)", + "legalBasis": "Decreto 5475/71 - Decreto 2136-01 - Res 1066-2006 STSS", + "glossary": null, + "importNote": null + }, + { + "code": "CALDERAS-002", + "categoryCode": "CALDERAS", + "sourceNumber": 2, + "title": "CARNET HABILITANTE DEL OPERADOR (DOCUMENTACIÓN)", + "legalBasis": "Decreto 5475/71 - Decreto 2136-01 - Res 1066-2006 STSS", + "glossary": null, + "importNote": null + }, + { + "code": "CALDERAS-003", + "categoryCode": "CALDERAS", + "sourceNumber": 3, + "title": "PRUEBA HIDRÁULICA HABILITADA POR ORGANISMO OFICIAL", + "legalBasis": "Decreto 5475/71 - Decreto 2136-01 - Res 1066-2006 STSS", + "glossary": null, + "importNote": null + }, + { + "code": "CALDERAS-004", + "categoryCode": "CALDERAS", + "sourceNumber": 4, + "title": "ALARMA SONORA", + "legalBasis": "Decreto 5475/71 - Decreto 2136-01 - Res 1066-2006 STSS", + "glossary": null, + "importNote": null + }, + { + "code": "CALDERAS-005", + "categoryCode": "CALDERAS", + "sourceNumber": 5, + "title": "ALARMA VISUAL", + "legalBasis": "Decreto 5475/71 - Decreto 2136-01 - Res 1066-2006 STSS", + "glossary": null, + "importNote": null + }, + { + "code": "CALDERAS-006", + "categoryCode": "CALDERAS", + "sourceNumber": 6, + "title": "SOPLADO DE VÁLVULA DE SEGURIDAD", + "legalBasis": "Decreto 5475/71 - Decreto 2136-01 - Res 1066-2006 STSS", + "glossary": null, + "importNote": null + }, + { + "code": "CALDERAS-007", + "categoryCode": "CALDERAS", + "sourceNumber": 7, + "title": "CALIBRACIÓN DE VÁLVULA DE SEGURIDAD", + "legalBasis": "Decreto 5475/71 - Decreto 2136-01 - Res 1066-2006 STSS", + "glossary": null, + "importNote": null + }, + { + "code": "CALDERAS-008", + "categoryCode": "CALDERAS", + "sourceNumber": 8, + "title": "CONTROL DE NIVEL DE AGUA", + "legalBasis": "Decreto 5475/71 - Decreto 2136-01 - Res 1066-2006 STSS", + "glossary": null, + "importNote": null + }, + { + "code": "CALDERAS-009", + "categoryCode": "CALDERAS", + "sourceNumber": 9, + "title": "CONTROL DE PRESIÓN", + "legalBasis": "Decreto 5475/71 - Decreto 2136-01 - Res 1066-2006 STSS", + "glossary": null, + "importNote": null + }, + { + "code": "CALDERAS-010", + "categoryCode": "CALDERAS", + "sourceNumber": 10, + "title": "DATOS NECESARIOS", + "legalBasis": "Decreto 5475/71 - Decreto 2136-01 - Res 1066-2006 STSS", + "glossary": "Tipo de combustible, Presión de trabajo,", + "importNote": null + }, + { + "code": "CALDERAS-011", + "categoryCode": "CALDERAS", + "sourceNumber": 11, + "title": "PURGA DE SUPERFICIE", + "legalBasis": "Decreto 5475/71 - Decreto 2136-01 - Res 1066-2006 STSS", + "glossary": null, + "importNote": null + }, + { + "code": "CALDERAS-012", + "categoryCode": "CALDERAS", + "sourceNumber": 12, + "title": "PURGA DE FONDO", + "legalBasis": "Decreto 5475/71 - Decreto 2136-01 - Res 1066-2006 STSS", + "glossary": null, + "importNote": null + }, + { + "code": "CALDERAS-013", + "categoryCode": "CALDERAS", + "sourceNumber": 13, + "title": "ESTADO DEL CUADRO DE COMBUSTIBLE", + "legalBasis": "Decreto 5475/71 - Decreto 2136-01 - Res 1066-2006 STSS", + "glossary": null, + "importNote": null + }, + { + "code": "CALDERAS-014", + "categoryCode": "CALDERAS", + "sourceNumber": 14, + "title": "ESTADO DEL CUADRO DE AGUA", + "legalBasis": "Decreto 5475/71 - Decreto 2136-01 - Res 1066-2006 STSS", + "glossary": null, + "importNote": null + }, + { + "code": "CALDERAS-015", + "categoryCode": "CALDERAS", + "sourceNumber": 15, + "title": "SISTEMA SHUT DOWN", + "legalBasis": null, + "glossary": null, + "importNote": null + }, + { + "code": "DRENAJE-001", + "categoryCode": "DRENAJE", + "sourceNumber": 1, + "title": "MATERIAL DE CONSTRUCCIÓN DE LA PILETA", + "legalBasis": null, + "glossary": null, + "importNote": null + }, + { + "code": "DRENAJE-002", + "categoryCode": "DRENAJE", + "sourceNumber": 2, + "title": "SELLO HIDRÁULICO (ESTADO)", + "legalBasis": "Resolución 341/93 SEN", + "glossary": null, + "importNote": null + }, + { + "code": "DRENAJE-003", + "categoryCode": "DRENAJE", + "sourceNumber": 3, + "title": "PILETAS API (PROTECCIÓN DE AVES, CERCO, SEÑALIZACIÓN, DERRAMES, IMPERMEABILIZACIÓN)", + "legalBasis": "Resolución 341/93 SEN", + "glossary": null, + "importNote": null + }, + { + "code": "DRENAJE-004", + "categoryCode": "DRENAJE", + "sourceNumber": 4, + "title": "PILETAS DE EMERGENCIA (NIVEL, PROTECCIÓN DE AVES, CERCO, SEÑALIZACIÓN, DERRAMES, IMPERMEABILIZACIÓN)", + "legalBasis": null, + "glossary": null, + "importNote": null + }, + { + "code": "DRENAJE-005", + "categoryCode": "DRENAJE", + "sourceNumber": 5, + "title": "SISTEMA DE CONTROL DE INCENDIOS (EXISTENCIA, ESTADO GENERAL)", + "legalBasis": "NFPA 30", + "glossary": null, + "importNote": null + }, + { + "code": "DRENAJE-006", + "categoryCode": "DRENAJE", + "sourceNumber": 6, + "title": "ESTADO DE CÁMARAS (TAPADAS, PISADERAS)", + "legalBasis": null, + "glossary": null, + "importNote": null + }, + { + "code": "DRENAJE-007", + "categoryCode": "DRENAJE", + "sourceNumber": 7, + "title": "ESTADO DE CANALETAS (TAPADAS, PISADERAS)", + "legalBasis": null, + "glossary": null, + "importNote": null + }, + { + "code": "ANTORCHA-001", + "categoryCode": "ANTORCHA", + "sourceNumber": 1, + "title": "Llama encendida", + "legalBasis": "Resolución 143/98 Art. 3.4", + "glossary": null, + "importNote": null + }, + { + "code": "ANTORCHA-002", + "categoryCode": "ANTORCHA", + "sourceNumber": 2, + "title": "Construccion/Operacion/Mantenimiento/Piloto", + "legalBasis": "API STD 537", + "glossary": null, + "importNote": null + }, + { + "code": "ANTORCHA-003", + "categoryCode": "ANTORCHA", + "sourceNumber": 3, + "title": "humo negro, hollín / minimizar las emisiones de gases nocivos", + "legalBasis": "Resolución 143/98 Art. 3.4", + "glossary": null, + "importNote": null + }, + { + "code": "ANTORCHA-004", + "categoryCode": "ANTORCHA", + "sourceNumber": 4, + "title": "sistema de medición instalado", + "legalBasis": "Resolución 143/98 Art. 6.1.1", + "glossary": null, + "importNote": null + }, + { + "code": "ANTORCHA-005", + "categoryCode": "ANTORCHA", + "sourceNumber": 5, + "title": "registro de medición", + "legalBasis": "Resolución 143/98 Art. 6.1.2", + "glossary": null, + "importNote": null + }, + { + "code": "COLECTORES-001", + "categoryCode": "COLECTORES", + "sourceNumber": 1, + "title": "PERDIDA", + "legalBasis": null, + "glossary": null, + "importNote": null + }, + { + "code": "SISTEMA_ELECTRICO_ILUMINACION-001", + "categoryCode": "SISTEMA_ELECTRICO_ILUMINACION", + "sourceNumber": 1, + "title": "Escasa iluminación y/o falta de ilumunación de emergencia en zona con actividad nocturna", + "legalBasis": "Ley 19587 HyST, Dec. 351/79, Cap 12, Anexo IV_Infoleg", + "glossary": null, + "importNote": null + }, + { + "code": "SISTEMA_ELECTRICO_ILUMINACION-002", + "categoryCode": "SISTEMA_ELECTRICO_ILUMINACION", + "sourceNumber": 2, + "title": "Iluminación deficiente, cantidad e medición intensidad lumen insuficientes.", + "legalBasis": "Ley 19587 HyST, Dec. 351/79, Cap 12, Anexo IV_Infoleg", + "glossary": null, + "importNote": null + }, + { + "code": "SISTEMA_ELECTRICO_ILUMINACION-003", + "categoryCode": "SISTEMA_ELECTRICO_ILUMINACION", + "sourceNumber": 3, + "title": "Dustribución de luminarias, layout del sistema.", + "legalBasis": "Ley 19587 HyST, Dec. 351/79, Cap 12, Anexo IV_Infoleg", + "glossary": null, + "importNote": null + }, + { + "code": "SISTEMA_ELECTRICO_ILUMINACION-004", + "categoryCode": "SISTEMA_ELECTRICO_ILUMINACION", + "sourceNumber": 4, + "title": "Falta de puesta a tierra en columnas fijas", + "legalBasis": "Ley 19587 HyST, Dec. 351/79, Cap 12, Anexo IV_Infoleg", + "glossary": null, + "importNote": null + }, + { + "code": "SISTEMA_ELECTRICO_ILUMINACION-005", + "categoryCode": "SISTEMA_ELECTRICO_ILUMINACION", + "sourceNumber": 5, + "title": "Palmeras o columnas de iluminación transportables, sistema de alimentación de energia.", + "legalBasis": "Ley 19587 HyST, Dec. 351/79, Cap 12, Anexo IV_Infoleg", + "glossary": null, + "importNote": null + }, + { + "code": "SISTEMA_ELECTRICO_ILUMINACION-006", + "categoryCode": "SISTEMA_ELECTRICO_ILUMINACION", + "sourceNumber": 6, + "title": "Pararrayos dsitribución, cantidad, puesta a tierra de los mismos.", + "legalBasis": "Ley 19587 HyST, Dec. 351/79, Cap 12, Anexo IV_Infoleg", + "glossary": null, + "importNote": null + }, + { + "code": "DEFENSA_CONTRA_INCENDIOS-001", + "categoryCode": "DEFENSA_CONTRA_INCENDIOS", + "sourceNumber": 1, + "title": "ESTADO GENERAL (PERDIDAS) de la motobombas", + "legalBasis": "DATOS DEL EQUIPO NFPA 30", + "glossary": null, + "importNote": null + }, + { + "code": "DEFENSA_CONTRA_INCENDIOS-002", + "categoryCode": "DEFENSA_CONTRA_INCENDIOS", + "sourceNumber": 2, + "title": "RECINTO DE CONTENCIÓN - CANALETAS -CAMARAS DE DRENAJE", + "legalBasis": "Ley 13660 Decreto 10877/60, art 347, art.410, art. 989 NFPA 30 art. 17.10.1", + "glossary": null, + "importNote": null + }, + { + "code": "DEFENSA_CONTRA_INCENDIOS-003", + "categoryCode": "DEFENSA_CONTRA_INCENDIOS", + "sourceNumber": 3, + "title": "PUESTA A TIERRA", + "legalBasis": "Ley 19587, Art.149", + "glossary": null, + "importNote": null + }, + { + "code": "DEFENSA_CONTRA_INCENDIOS-004", + "categoryCode": "DEFENSA_CONTRA_INCENDIOS", + "sourceNumber": 4, + "title": "CARTELERIA INDICATIVA", + "legalBasis": "Ley 19587 art 9", + "glossary": null, + "importNote": null + }, + { + "code": "DEFENSA_CONTRA_INCENDIOS-005", + "categoryCode": "DEFENSA_CONTRA_INCENDIOS", + "sourceNumber": 5, + "title": "ORDEN Y LIMPIEZA", + "legalBasis": "Ley 13.660, Decreto 10877/60, art. 230 y art. 354", + "glossary": null, + "importNote": null + }, + { + "code": "DEFENSA_CONTRA_INCENDIOS-006", + "categoryCode": "DEFENSA_CONTRA_INCENDIOS", + "sourceNumber": 6, + "title": "SISTEMAS DE PROTECCION", + "legalBasis": null, + "glossary": null, + "importNote": null + }, + { + "code": "DEFENSA_CONTRA_INCENDIOS-007", + "categoryCode": "DEFENSA_CONTRA_INCENDIOS", + "sourceNumber": 7, + "title": "GUARDACORREA", + "legalBasis": null, + "glossary": null, + "importNote": null + }, + { + "code": "DEFENSA_CONTRA_INCENDIOS-008", + "categoryCode": "DEFENSA_CONTRA_INCENDIOS", + "sourceNumber": 8, + "title": "CONDICIONES APE", + "legalBasis": null, + "glossary": null, + "importNote": null + }, + { + "code": "DEFENSA_CONTRA_INCENDIOS-009", + "categoryCode": "DEFENSA_CONTRA_INCENDIOS", + "sourceNumber": 9, + "title": "existencia de la bombas de Jokey", + "legalBasis": "NFPA 30", + "glossary": null, + "importNote": null + }, + { + "code": "DEFENSA_CONTRA_INCENDIOS-010", + "categoryCode": "DEFENSA_CONTRA_INCENDIOS", + "sourceNumber": 10, + "title": "estado en válvulas/juntas/lineas", + "legalBasis": null, + "glossary": null, + "importNote": null + }, + { + "code": "DEFENSA_CONTRA_INCENDIOS-011", + "categoryCode": "DEFENSA_CONTRA_INCENDIOS", + "sourceNumber": 11, + "title": "manómetro en pto más alejado y en salida de bomba", + "legalBasis": "NFPA 30", + "glossary": null, + "importNote": null + }, + { + "code": "DEFENSA_CONTRA_INCENDIOS-012", + "categoryCode": "DEFENSA_CONTRA_INCENDIOS", + "sourceNumber": 12, + "title": "encendido manual", + "legalBasis": "NFPA 30", + "glossary": null, + "importNote": null + }, + { + "code": "DEFENSA_CONTRA_INCENDIOS-013", + "categoryCode": "DEFENSA_CONTRA_INCENDIOS", + "sourceNumber": 13, + "title": "sistema de alimentación independiente de cada motobomba", + "legalBasis": "NFPA 30", + "glossary": null, + "importNote": null + }, + { + "code": "CARGADERO_DESCARGADERO-001", + "categoryCode": "CARGADERO_DESCARGADERO", + "sourceNumber": 1, + "title": "Estado de accesos", + "legalBasis": null, + "glossary": null, + "importNote": null + }, + { + "code": "CARGADERO_DESCARGADERO-002", + "categoryCode": "CARGADERO_DESCARGADERO", + "sourceNumber": 2, + "title": "Indicador dirección de viento (Manga)", + "legalBasis": null, + "glossary": null, + "importNote": null + }, + { + "code": "CARGADERO_DESCARGADERO-003", + "categoryCode": "CARGADERO_DESCARGADERO", + "sourceNumber": 3, + "title": "Piso del cargadero (Grava - Cemento - Otro)", + "legalBasis": null, + "glossary": null, + "importNote": null + }, + { + "code": "CARGADERO_DESCARGADERO-004", + "categoryCode": "CARGADERO_DESCARGADERO", + "sourceNumber": 4, + "title": "Cámaras de drenaje", + "legalBasis": null, + "glossary": null, + "importNote": null + }, + { + "code": "CARGADERO_DESCARGADERO-005", + "categoryCode": "CARGADERO_DESCARGADERO", + "sourceNumber": 5, + "title": "Puesta a tierra (conexión eléctrica)", + "legalBasis": null, + "glossary": null, + "importNote": null + }, + { + "code": "CARGADERO_DESCARGADERO-006", + "categoryCode": "CARGADERO_DESCARGADERO", + "sourceNumber": 6, + "title": "Matafuegos (Cantidad - Tipo)", + "legalBasis": null, + "glossary": null, + "importNote": null + }, + { + "code": "CARGADERO_DESCARGADERO-007", + "categoryCode": "CARGADERO_DESCARGADERO", + "sourceNumber": 7, + "title": "Iluminación", + "legalBasis": null, + "glossary": null, + "importNote": null + }, + { + "code": "CARGADERO_DESCARGADERO-008", + "categoryCode": "CARGADERO_DESCARGADERO", + "sourceNumber": 8, + "title": "Orden y limpieza", + "legalBasis": null, + "glossary": null, + "importNote": null + }, + { + "code": "CARGADERO_DESCARGADERO-009", + "categoryCode": "CARGADERO_DESCARGADERO", + "sourceNumber": 9, + "title": "Línea de vida", + "legalBasis": null, + "glossary": null, + "importNote": null + }, + { + "code": "FILTROS-001", + "categoryCode": "FILTROS", + "sourceNumber": 1, + "title": "Perdiras de liquidos, uniones bridadas, soldadas, válvulas, piting.", + "legalBasis": null, + "glossary": null, + "importNote": null + }, + { + "code": "FILTROS-002", + "categoryCode": "FILTROS", + "sourceNumber": 2, + "title": "Identificación", + "legalBasis": null, + "glossary": null, + "importNote": null + }, + { + "code": "FILTROS-003", + "categoryCode": "FILTROS", + "sourceNumber": 3, + "title": "Puesta a tierra (conexión eléctrica)", + "legalBasis": null, + "glossary": null, + "importNote": null + }, + { + "code": "EQUIPO_FLOTACION-001", + "categoryCode": "EQUIPO_FLOTACION", + "sourceNumber": 1, + "title": "Perdiras de liquidos, uniones bridadas, soldadas, válvulas, piting.", + "legalBasis": null, + "glossary": null, + "importNote": null + }, + { + "code": "EQUIPO_FLOTACION-002", + "categoryCode": "EQUIPO_FLOTACION", + "sourceNumber": 2, + "title": "Identificación", + "legalBasis": null, + "glossary": null, + "importNote": null + }, + { + "code": "EQUIPO_FLOTACION-003", + "categoryCode": "EQUIPO_FLOTACION", + "sourceNumber": 3, + "title": "Puesta a tierra (conexión eléctrica)", + "legalBasis": null, + "glossary": null, + "importNote": null + }, + { + "code": "BATERIAS_PLANTAS-001", + "categoryCode": "BATERIAS_PLANTAS", + "sourceNumber": 1, + "title": "ADJUNTA P&ID", + "legalBasis": "Decreto 351/79 (Anexo I)", + "glossary": null, + "importNote": null + }, + { + "code": "BATERIAS_PLANTAS-002", + "categoryCode": "BATERIAS_PLANTAS", + "sourceNumber": 2, + "title": "RED CONTRA INCENDIO / EXTINTORES", + "legalBasis": "NFPA 30", + "glossary": null, + "importNote": null + }, + { + "code": "BATERIAS_PLANTAS-003", + "categoryCode": "BATERIAS_PLANTAS", + "sourceNumber": 3, + "title": "Caudalimetros de producción general, y gas (Existencia y Estado)", + "legalBasis": "Ley N° 17.319 (Ley de Hidrocarburos): su Art. 75", + "glossary": null, + "importNote": null + }, + { + "code": "BATERIAS_PLANTAS-004", + "categoryCode": "BATERIAS_PLANTAS", + "sourceNumber": 4, + "title": "Piping y lineas de conducción, colores, nomenclaturas, sistema cosntructivo.", + "legalBasis": "NAG 123", + "glossary": null, + "importNote": null + }, + { + "code": "FWKO_TRATADORES_CALENTADORES-001", + "categoryCode": "FWKO_TRATADORES_CALENTADORES", + "sourceNumber": 1, + "title": "Pretil", + "legalBasis": "El Artículo 409 del Decreto Reglamentario 10.877 de la Ley 13.660", + "glossary": null, + "importNote": null + }, + { + "code": "FWKO_TRATADORES_CALENTADORES-002", + "categoryCode": "FWKO_TRATADORES_CALENTADORES", + "sourceNumber": 2, + "title": "válvula seguridad", + "legalBasis": "Ley N° 19.587, decreto reglamentario N° 351/79, art.141", + "glossary": null, + "importNote": null + }, + { + "code": "FWKO_TRATADORES_CALENTADORES-003", + "categoryCode": "FWKO_TRATADORES_CALENTADORES", + "sourceNumber": 3, + "title": "Placa de estallido", + "legalBasis": "Ley N° 19.587, decreto reglamentario N° 351/79, art.141", + "glossary": null, + "importNote": null + }, + { + "code": "FWKO_TRATADORES_CALENTADORES-004", + "categoryCode": "FWKO_TRATADORES_CALENTADORES", + "sourceNumber": 4, + "title": "Boca de Hombre", + "legalBasis": null, + "glossary": null, + "importNote": null + }, + { + "code": "FWKO_TRATADORES_CALENTADORES-005", + "categoryCode": "FWKO_TRATADORES_CALENTADORES", + "sourceNumber": 5, + "title": "Captación de gas", + "legalBasis": null, + "glossary": null, + "importNote": null + }, + { + "code": "FWKO_TRATADORES_CALENTADORES-006", + "categoryCode": "FWKO_TRATADORES_CALENTADORES", + "sourceNumber": 6, + "title": "Puesta a Tierra", + "legalBasis": "Ley 13.660, Decreto 10877/60, art. 336 (tanques)\nLey N° 19.587, Decreto N° 351/79, Anexo VI\nNorma NAG 125 (Cap II - 2.4.2.)", + "glossary": null, + "importNote": null + }, + { + "code": "FWKO_TRATADORES_CALENTADORES-007", + "categoryCode": "FWKO_TRATADORES_CALENTADORES", + "sourceNumber": 7, + "title": "Cartelería", + "legalBasis": null, + "glossary": null, + "importNote": null + }, + { + "code": "FWKO_TRATADORES_CALENTADORES-008", + "categoryCode": "FWKO_TRATADORES_CALENTADORES", + "sourceNumber": 8, + "title": "Drenajes de fondo, estado y cantidad", + "legalBasis": null, + "glossary": null, + "importNote": null + }, + { + "code": "FWKO_TRATADORES_CALENTADORES-009", + "categoryCode": "FWKO_TRATADORES_CALENTADORES", + "sourceNumber": 9, + "title": "Instrumentos de Medición", + "legalBasis": null, + "glossary": null, + "importNote": null + }, + { + "code": "FWKO_TRATADORES_CALENTADORES-010", + "categoryCode": "FWKO_TRATADORES_CALENTADORES", + "sourceNumber": 10, + "title": "Instrumentos de control visusl estado", + "legalBasis": null, + "glossary": null, + "importNote": null + }, + { + "code": "BOMBEO_MECANICO-001", + "categoryCode": "BOMBEO_MECANICO", + "sourceNumber": 1, + "title": "Cartel Identificador de pozo en camino y en locacion", + "legalBasis": "Res. SE 105/1992 — Art. 4 y 5, API RP 100-1 — Well Identification, IRAM 10005 — Señales de seguridad", + "glossary": null, + "importNote": null + }, + { + "code": "BOMBEO_MECANICO-002", + "categoryCode": "BOMBEO_MECANICO", + "sourceNumber": 2, + "title": "Cartelería Preventiva / Seguridad", + "legalBasis": "Dec. 351/79 — Anexo IV (Ley 19.587), IRAM 10005 / ISO 7010, OSHA 1910.145 — Accident Prevention Signs", + "glossary": null, + "importNote": null + }, + { + "code": "BOMBEO_MECANICO-003", + "categoryCode": "BOMBEO_MECANICO", + "sourceNumber": 3, + "title": "Cerco perimetral AIB/ Puente de producción", + "legalBasis": "Res. SE 105/1992 — Art. 7, Dec. 351/79 — Anexo I, Cap. XVIII, API RP 500 — Clasificación de Áreas,", + "glossary": null, + "importNote": null + }, + { + "code": "BOMBEO_MECANICO-004", + "categoryCode": "BOMBEO_MECANICO", + "sourceNumber": 4, + "title": "Cerco contrapesos", + "legalBasis": "Res. SE 105/1992 — Art. 7 / Res. SRT 299/2011, Dec. 351/79 — Anexo I, Art. 114", + "glossary": null, + "importNote": null + }, + { + "code": "BOMBEO_MECANICO-005", + "categoryCode": "BOMBEO_MECANICO", + "sourceNumber": 5, + "title": "Guardacorreas / Escalera", + "legalBasis": "Dec. 351/79 — Ley 19.587, Cap. XVIII, Res. SRT 299/2011 — Trabajos en Altura, ASME B20.1 — Safety Standard for Conveyors", + "glossary": null, + "importNote": null + }, + { + "code": "BOMBEO_MECANICO-006", + "categoryCode": "BOMBEO_MECANICO", + "sourceNumber": 6, + "title": "Protección y señalización de líneas de producción", + "legalBasis": null, + "glossary": null, + "importNote": null + }, + { + "code": "BOMBEO_MECANICO-007", + "categoryCode": "BOMBEO_MECANICO", + "sourceNumber": 7, + "title": "Puesta tierra: Motor AIB", + "legalBasis": "Dec. 351/79 — Cap. XIII / Res. SRT 900/2015, AEA 90364 / IRAM 2281, IEC 60364-5-54 — Earthing Arrangements", + "glossary": null, + "importNote": null + }, + { + "code": "BOMBEO_MECANICO-008", + "categoryCode": "BOMBEO_MECANICO", + "sourceNumber": 8, + "title": "Puesta tierra: Tablero", + "legalBasis": "AEA 90364 — Reglamento Electrotécnico AR, IRAM 2010 / IEC 60439 — Tableros BT", + "glossary": null, + "importNote": null + }, + { + "code": "BOMBEO_MECANICO-009", + "categoryCode": "BOMBEO_MECANICO", + "sourceNumber": 9, + "title": "Tablero y cableado (cable soterrado, riesgo electrico)", + "legalBasis": "AEA 90364 — Instalaciones Eléctricas AR, Dec. 351/79 — Ley 19.587, Cap. XIII, API RP 500 / NFPA 70E — Electrical Safety, IEC 60079 — Atmósferas Explosivas (ATEX)", + "glossary": null, + "importNote": null + }, + { + "code": "BOMBEO_MECANICO-010", + "categoryCode": "BOMBEO_MECANICO", + "sourceNumber": 10, + "title": "Antepozo (Bodega)", + "legalBasis": "Res. SE 105/1992 — Art. 9 y 10, Res. SRT 299/2011 — Espacios Confinados", + "glossary": null, + "importNote": null + }, + { + "code": "BOMBEO_MECANICO-011", + "categoryCode": "BOMBEO_MECANICO", + "sourceNumber": 11, + "title": "Puente producción (Estado)", + "legalBasis": "Res. SE 105/1992 — Art. 11, API RP 14C — Surface Safety Systems", + "glossary": null, + "importNote": null + }, + { + "code": "BOMBEO_MECANICO-012", + "categoryCode": "BOMBEO_MECANICO", + "sourceNumber": 12, + "title": "Valvulas y línea Directa estado, empaquetadura estado", + "legalBasis": "Res. SE 105/1992 — Art. 12, API 6A — Wellhead and Christmas Tree - API 6A — Wellhead Equipment, Normas operativas del operador", + "glossary": null, + "importNote": null + }, + { + "code": "BOMBEO_MECANICO-013", + "categoryCode": "BOMBEO_MECANICO", + "sourceNumber": 13, + "title": "Valvula EC Cerrada y tapon Ciego", + "legalBasis": "Res. SE 105/1992 — Art. 12, API 6A / NACE MR0175", + "glossary": null, + "importNote": null + }, + { + "code": "BOMBEO_MECANICO-014", + "categoryCode": "BOMBEO_MECANICO", + "sourceNumber": 14, + "title": "Anclajes equipos de torre / Verificación-Protección", + "legalBasis": "Res. SE 105/1992 — Art. 14 / CIRSOC 101, API Spec 11E — Pumping Units (Anclaje) CIRSOC 101 — Cargas Mínimas / INPRES-CIRSOC 103, Normas internas del operador — Integridad Mecánica", + "glossary": null, + "importNote": null + }, + { + "code": "BOMBEO_MECANICO-015", + "categoryCode": "BOMBEO_MECANICO", + "sourceNumber": 15, + "title": "Locación libre de derrames", + "legalBasis": "Ley 25.675 — Ley General del Ambiente, Res. SE 25/2004 — Gestión Ambiental UP, Ley 17.319 — Art. 33 / Leyes provinciales", + "glossary": null, + "importNote": null + }, + { + "code": "BOMBEO_MECANICO-016", + "categoryCode": "BOMBEO_MECANICO", + "sourceNumber": 16, + "title": "Locación protegida por talud", + "legalBasis": "Res. SE 25/2004 — Anexo I, punto 3.2, Normas provinciales (Neuquén Dec. 2453/2012)", + "glossary": null, + "importNote": null + }, + { + "code": "BOMBEO_MECANICO-017", + "categoryCode": "BOMBEO_MECANICO", + "sourceNumber": 17, + "title": "Vías de acceso/Escape/Emergencia", + "legalBasis": "Dec. 351/79 — Ley 19.587, Cap. IX, Res. SRT 523/2007 — Planes de Emergencia, NFPA 101 — Life Safety Code", + "glossary": null, + "importNote": null + }, + { + "code": "BOMBEO_MECANICO-018", + "categoryCode": "BOMBEO_MECANICO", + "sourceNumber": 18, + "title": "Orden y limpieza general", + "legalBasis": "Dec. 351/79 — Ley 19.587, Cap. IV Art. 42, ISO 45001 — Seguridad y Salud en el Trabajo", + "glossary": null, + "importNote": null + }, + { + "code": "BOMBEO_ELECTROSUMERGIBLE-001", + "categoryCode": "BOMBEO_ELECTROSUMERGIBLE", + "sourceNumber": 1, + "title": "Cartel Identificador de pozo en camino y en locacion", + "legalBasis": "Res. SE 105/1992 — Art. 4 y 5, API RP 100-1 — Well Identification, IRAM 10005 — Señales de seguridad", + "glossary": null, + "importNote": null + }, + { + "code": "BOMBEO_ELECTROSUMERGIBLE-002", + "categoryCode": "BOMBEO_ELECTROSUMERGIBLE", + "sourceNumber": 2, + "title": "Cartelería Preventiva / Seguridad", + "legalBasis": "Dec. 351/79 — Anexo IV (Ley 19.587), IRAM 10005 / ISO 7010, OSHA 1910.145 — Accident Prevention Signs", + "glossary": null, + "importNote": null + }, + { + "code": "BOMBEO_ELECTROSUMERGIBLE-003", + "categoryCode": "BOMBEO_ELECTROSUMERGIBLE", + "sourceNumber": 3, + "title": "Cerco perimetral BDP/ Puente de producción", + "legalBasis": "Res. SE 105/1992 — Art. 7, Dec. 351/79 — Anexo I, Cap. XVIII, API RP 500 — Clasificación de Áreas,", + "glossary": "Glosario", + "importNote": null + }, + { + "code": "BOMBEO_ELECTROSUMERGIBLE-004", + "categoryCode": "BOMBEO_ELECTROSUMERGIBLE", + "sourceNumber": 4, + "title": "Puesta tierra: Motor, Gabinete de comando y Variador.", + "legalBasis": "Dec. 351/79 — Cap. XIII / Res. SRT 900/2015, AEA 90364 / IRAM 2281, IEC 60364-5-54 — Earthing Arrangements", + "glossary": null, + "importNote": null + }, + { + "code": "BOMBEO_ELECTROSUMERGIBLE-005", + "categoryCode": "BOMBEO_ELECTROSUMERGIBLE", + "sourceNumber": 5, + "title": "Puesta tierra: Tablero", + "legalBasis": "AEA 90364 — Reglamento Electrotécnico AR, IRAM 2010 / IEC 60439 — Tableros BT", + "glossary": null, + "importNote": null + }, + { + "code": "BOMBEO_ELECTROSUMERGIBLE-006", + "categoryCode": "BOMBEO_ELECTROSUMERGIBLE", + "sourceNumber": 6, + "title": "Tablero y cableado (cable soterrado, riesgo electrico)", + "legalBasis": "AEA 90364 — Instalaciones Eléctricas AR, Dec. 351/79 — Ley 19.587, Cap. XIII, API RP 500 / NFPA 70E — Electrical Safety, IEC 60079 — Atmósferas Explosivas (ATEX)", + "glossary": null, + "importNote": null + }, + { + "code": "BOMBEO_ELECTROSUMERGIBLE-007", + "categoryCode": "BOMBEO_ELECTROSUMERGIBLE", + "sourceNumber": 7, + "title": "Antepozo (Bodega)", + "legalBasis": "Res. SE 105/1992 — Art. 9 y 10, Res. SRT 299/2011 — Espacios Confinados", + "glossary": null, + "importNote": null + }, + { + "code": "BOMBEO_ELECTROSUMERGIBLE-008", + "categoryCode": "BOMBEO_ELECTROSUMERGIBLE", + "sourceNumber": 8, + "title": "Puente producción (Estado)", + "legalBasis": "Res. SE 105/1992 — Art. 11, API RP 14C — Surface Safety Systems", + "glossary": null, + "importNote": null + }, + { + "code": "BOMBEO_ELECTROSUMERGIBLE-009", + "categoryCode": "BOMBEO_ELECTROSUMERGIBLE", + "sourceNumber": 9, + "title": "Valvulas y línea Directa estado, empaquetadura estado", + "legalBasis": "Res. SE 105/1992 — Art. 12, API 6A — Wellhead and Christmas Tree - API 6A — Wellhead Equipment, Normas operativas del operador", + "glossary": null, + "importNote": null + }, + { + "code": "BOMBEO_ELECTROSUMERGIBLE-010", + "categoryCode": "BOMBEO_ELECTROSUMERGIBLE", + "sourceNumber": 10, + "title": "Valvula EC Cerrada y tapon Ciego", + "legalBasis": "Res. SE 105/1992 — Art. 12, API 6A / NACE MR0175", + "glossary": null, + "importNote": null + }, + { + "code": "BOMBEO_ELECTROSUMERGIBLE-011", + "categoryCode": "BOMBEO_ELECTROSUMERGIBLE", + "sourceNumber": 11, + "title": "Anclajes equipos de torre / Verificación-Protección", + "legalBasis": "Res. SE 105/1992 — Art. 14 / CIRSOC 101, API Spec 11E — Pumping Units (Anclaje) CIRSOC 101 — Cargas Mínimas / INPRES-CIRSOC 103, Normas internas del operador — Integridad Mecánica", + "glossary": null, + "importNote": null + }, + { + "code": "BOMBEO_ELECTROSUMERGIBLE-012", + "categoryCode": "BOMBEO_ELECTROSUMERGIBLE", + "sourceNumber": 12, + "title": "Locación libre de derrames", + "legalBasis": "Ley 25.675 — Ley General del Ambiente, Res. SE 25/2004 — Gestión Ambiental UP, Ley 17.319 — Art. 33 / Leyes provinciales", + "glossary": null, + "importNote": null + }, + { + "code": "BOMBEO_ELECTROSUMERGIBLE-013", + "categoryCode": "BOMBEO_ELECTROSUMERGIBLE", + "sourceNumber": 13, + "title": "Locación protegida por talud", + "legalBasis": "Res. SE 25/2004 — Anexo I, punto 3.2, Normas provinciales (Neuquén Dec. 2453/2012)", + "glossary": null, + "importNote": null + }, + { + "code": "BOMBEO_ELECTROSUMERGIBLE-014", + "categoryCode": "BOMBEO_ELECTROSUMERGIBLE", + "sourceNumber": 14, + "title": "Vías de acceso/Escape/Emergencia", + "legalBasis": "Dec. 351/79 — Ley 19.587, Cap. IX, Res. SRT 523/2007 — Planes de Emergencia, NFPA 101 — Life Safety Code", + "glossary": null, + "importNote": null + }, + { + "code": "BOMBEO_ELECTROSUMERGIBLE-015", + "categoryCode": "BOMBEO_ELECTROSUMERGIBLE", + "sourceNumber": 15, + "title": "Orden y limpieza general", + "legalBasis": "Dec. 351/79 — Ley 19.587, Cap. IV Art. 42, ISO 45001 — Seguridad y Salud en el Trabajo", + "glossary": null, + "importNote": null + }, + { + "code": "BOMBEO_CAVIDAD_PROGRESIVA-001", + "categoryCode": "BOMBEO_CAVIDAD_PROGRESIVA", + "sourceNumber": 1, + "title": "Cartel Identificador de pozo en camino y en locacion", + "legalBasis": "Res. SE 105/1992 — Art. 4 y 5, API RP 100-1 — Well Identification, IRAM 10005 — Señales de seguridad", + "glossary": null, + "importNote": null + }, + { + "code": "BOMBEO_CAVIDAD_PROGRESIVA-002", + "categoryCode": "BOMBEO_CAVIDAD_PROGRESIVA", + "sourceNumber": 2, + "title": "Cartelería Preventiva / Seguridad", + "legalBasis": "Dec. 351/79 — Anexo IV (Ley 19.587), IRAM 10005 / ISO 7010, OSHA 1910.145 — Accident Prevention Signs", + "glossary": null, + "importNote": null + }, + { + "code": "BOMBEO_CAVIDAD_PROGRESIVA-003", + "categoryCode": "BOMBEO_CAVIDAD_PROGRESIVA", + "sourceNumber": 3, + "title": "Cerco perimetral BDP/ Puente de producción", + "legalBasis": "Res. SE 105/1992 — Art. 7, Dec. 351/79 — Anexo I, Cap. XVIII, API RP 500 — Clasificación de Áreas,", + "glossary": null, + "importNote": null + }, + { + "code": "BOMBEO_CAVIDAD_PROGRESIVA-004", + "categoryCode": "BOMBEO_CAVIDAD_PROGRESIVA", + "sourceNumber": 4, + "title": "Puesta tierra: Motor, Gabinete de comando y Variador.", + "legalBasis": "Dec. 351/79 — Cap. XIII / Res. SRT 900/2015, AEA 90364 / IRAM 2281, IEC 60364-5-54 — Earthing Arrangements", + "glossary": null, + "importNote": null + }, + { + "code": "BOMBEO_CAVIDAD_PROGRESIVA-005", + "categoryCode": "BOMBEO_CAVIDAD_PROGRESIVA", + "sourceNumber": 5, + "title": "Puesta tierra: Tablero", + "legalBasis": "AEA 90364 — Reglamento Electrotécnico AR, IRAM 2010 / IEC 60439 — Tableros BT", + "glossary": null, + "importNote": null + }, + { + "code": "BOMBEO_CAVIDAD_PROGRESIVA-006", + "categoryCode": "BOMBEO_CAVIDAD_PROGRESIVA", + "sourceNumber": 6, + "title": "Tablero y cableado (cable soterrado, riesgo electrico)", + "legalBasis": "AEA 90364 — Instalaciones Eléctricas AR, Dec. 351/79 — Ley 19.587, Cap. XIII, API RP 500 / NFPA 70E — Electrical Safety, IEC 60079 — Atmósferas Explosivas (ATEX)", + "glossary": null, + "importNote": null + }, + { + "code": "BOMBEO_CAVIDAD_PROGRESIVA-007", + "categoryCode": "BOMBEO_CAVIDAD_PROGRESIVA", + "sourceNumber": 7, + "title": "Antepozo (Bodega)", + "legalBasis": "Res. SE 105/1992 — Art. 9 y 10, Res. SRT 299/2011 — Espacios Confinados", + "glossary": null, + "importNote": null + }, + { + "code": "BOMBEO_CAVIDAD_PROGRESIVA-008", + "categoryCode": "BOMBEO_CAVIDAD_PROGRESIVA", + "sourceNumber": 8, + "title": "Puente producción (Estado)", + "legalBasis": "Res. SE 105/1992 — Art. 11, API RP 14C — Surface Safety Systems", + "glossary": null, + "importNote": null + }, + { + "code": "BOMBEO_CAVIDAD_PROGRESIVA-009", + "categoryCode": "BOMBEO_CAVIDAD_PROGRESIVA", + "sourceNumber": 9, + "title": "Valvulas y línea Directa estado, empaquetadura estado", + "legalBasis": "Res. SE 105/1992 — Art. 12, API 6A — Wellhead and Christmas Tree - API 6A — Wellhead Equipment, Normas operativas del operador", + "glossary": null, + "importNote": null + }, + { + "code": "BOMBEO_CAVIDAD_PROGRESIVA-010", + "categoryCode": "BOMBEO_CAVIDAD_PROGRESIVA", + "sourceNumber": 10, + "title": "Valvula EC Cerrada y tapon Ciego", + "legalBasis": "Res. SE 105/1992 — Art. 12, API 6A / NACE MR0175", + "glossary": null, + "importNote": null + }, + { + "code": "BOMBEO_CAVIDAD_PROGRESIVA-011", + "categoryCode": "BOMBEO_CAVIDAD_PROGRESIVA", + "sourceNumber": 11, + "title": "Anclajes equipos de torre / Verificación-Protección", + "legalBasis": "Res. SE 105/1992 — Art. 14 / CIRSOC 101, API Spec 11E — Pumping Units (Anclaje) CIRSOC 101 — Cargas Mínimas / INPRES-CIRSOC 103, Normas internas del operador — Integridad Mecánica", + "glossary": null, + "importNote": null + }, + { + "code": "BOMBEO_CAVIDAD_PROGRESIVA-012", + "categoryCode": "BOMBEO_CAVIDAD_PROGRESIVA", + "sourceNumber": 12, + "title": "Locación libre de derrames", + "legalBasis": "Ley 25.675 — Ley General del Ambiente, Res. SE 25/2004 — Gestión Ambiental UP, Ley 17.319 — Art. 33 / Leyes provinciales", + "glossary": null, + "importNote": null + }, + { + "code": "BOMBEO_CAVIDAD_PROGRESIVA-013", + "categoryCode": "BOMBEO_CAVIDAD_PROGRESIVA", + "sourceNumber": 13, + "title": "Locación protegida por talud", + "legalBasis": "Res. SE 25/2004 — Anexo I, punto 3.2, Normas provinciales (Neuquén Dec. 2453/2012)", + "glossary": null, + "importNote": null + }, + { + "code": "BOMBEO_CAVIDAD_PROGRESIVA-014", + "categoryCode": "BOMBEO_CAVIDAD_PROGRESIVA", + "sourceNumber": 14, + "title": "Vías de acceso/Escape/Emergencia", + "legalBasis": "Dec. 351/79 — Ley 19.587, Cap. IX, Res. SRT 523/2007 — Planes de Emergencia, NFPA 101 — Life Safety Code", + "glossary": null, + "importNote": null + }, + { + "code": "BOMBEO_CAVIDAD_PROGRESIVA-015", + "categoryCode": "BOMBEO_CAVIDAD_PROGRESIVA", + "sourceNumber": 15, + "title": "Orden y limpieza general", + "legalBasis": "Dec. 351/79 — Ley 19.587, Cap. IV Art. 42, ISO 45001 — Seguridad y Salud en el Trabajo", + "glossary": null, + "importNote": null + }, + { + "code": "POZOS_SURGENTES_GAS-001", + "categoryCode": "POZOS_SURGENTES_GAS", + "sourceNumber": 1, + "title": "Cartel Identificador de pozo en camino y en locacion", + "legalBasis": "Res. SE 105/1992 — Art. 4 y 5, API RP 100-1 — Well Identification, IRAM 10005 — Señales de seguridad", + "glossary": null, + "importNote": null + }, + { + "code": "POZOS_SURGENTES_GAS-002", + "categoryCode": "POZOS_SURGENTES_GAS", + "sourceNumber": 2, + "title": "Cartelería Preventiva / Seguridad", + "legalBasis": "Dec. 351/79 — Anexo IV (Ley 19.587), IRAM 10005 / ISO 7010, OSHA 1910.145 — Accident Prevention Signs", + "glossary": null, + "importNote": null + }, + { + "code": "POZOS_SURGENTES_GAS-003", + "categoryCode": "POZOS_SURGENTES_GAS", + "sourceNumber": 3, + "title": "Cerco perimetral BDP/ Puente de producción", + "legalBasis": "Res. SE 105/1992 — Art. 7, Dec. 351/79 — Anexo I, Cap. XVIII, API RP 500 — Clasificación de Áreas,", + "glossary": null, + "importNote": null + }, + { + "code": "POZOS_SURGENTES_GAS-004", + "categoryCode": "POZOS_SURGENTES_GAS", + "sourceNumber": 4, + "title": "Medición en locación (estado)", + "legalBasis": null, + "glossary": null, + "importNote": null + }, + { + "code": "POZOS_SURGENTES_GAS-005", + "categoryCode": "POZOS_SURGENTES_GAS", + "sourceNumber": 5, + "title": "Separación de gas liquido, (estado)", + "legalBasis": null, + "glossary": null, + "importNote": null + }, + { + "code": "POZOS_SURGENTES_GAS-006", + "categoryCode": "POZOS_SURGENTES_GAS", + "sourceNumber": 6, + "title": "Tanque elevado (estado)", + "legalBasis": null, + "glossary": null, + "importNote": null + }, + { + "code": "POZOS_SURGENTES_GAS-007", + "categoryCode": "POZOS_SURGENTES_GAS", + "sourceNumber": 7, + "title": "Cargadero de camiones (estado)", + "legalBasis": null, + "glossary": null, + "importNote": null + }, + { + "code": "POZOS_INYECTORES_AGUA-001", + "categoryCode": "POZOS_INYECTORES_AGUA", + "sourceNumber": 1, + "title": "Cartel Identificador de pozo en camino y en locacion", + "legalBasis": "Res. SE 105/1992 — Art. 4 y 5, API RP 100-1 — Well Identification, IRAM 10005 — Señales de seguridad", + "glossary": null, + "importNote": null + }, + { + "code": "POZOS_INYECTORES_AGUA-002", + "categoryCode": "POZOS_INYECTORES_AGUA", + "sourceNumber": 2, + "title": "Cartelería Preventiva / Seguridad", + "legalBasis": "Dec. 351/79 — Anexo IV (Ley 19.587), IRAM 10005 / ISO 7010, OSHA 1910.145 — Accident Prevention Signs", + "glossary": null, + "importNote": null + }, + { + "code": "POZOS_INYECTORES_AGUA-003", + "categoryCode": "POZOS_INYECTORES_AGUA", + "sourceNumber": 3, + "title": "Cerco perimetral BDP/ Puente de producción", + "legalBasis": "Res. SE 105/1992 — Art. 7, Dec. 351/79 — Anexo I, Cap. XVIII, API RP 500 — Clasificación de Áreas,", + "glossary": null, + "importNote": null + }, + { + "code": "POZOS_INYECTORES_AGUA-004", + "categoryCode": "POZOS_INYECTORES_AGUA", + "sourceNumber": 4, + "title": "Control de Manómetros en tramos A, B, C.", + "legalBasis": "Res. 81/1918 DGI", + "glossary": null, + "importNote": null + } +]; diff --git a/api-v3/src/health.controller.ts b/api-v3/src/health.controller.ts new file mode 100644 index 0000000..1a86d4f --- /dev/null +++ b/api-v3/src/health.controller.ts @@ -0,0 +1,14 @@ +import { Controller, Get } from '@nestjs/common'; +import { Public } from './auth/decorators/public.decorator'; +import { HealthService } from './health.service'; + +@Controller('health') +export class HealthController { + constructor(private readonly healthService: HealthService) {} + + @Get() + @Public() + async health() { + return this.healthService.check(); + } +} diff --git a/api-v3/src/health.service.ts b/api-v3/src/health.service.ts new file mode 100644 index 0000000..c767175 --- /dev/null +++ b/api-v3/src/health.service.ts @@ -0,0 +1,24 @@ +import { Injectable } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import { API_PHASE, API_VERSION } from './version'; + +@Injectable() +export class HealthService { + constructor(private readonly dataSource: DataSource) {} + + async check() { + const startedAt = Date.now(); + await this.dataSource.query('SELECT 1'); + + return { + status: 'ok', + service: 'dhv2-api', + api: 'v3', + version: API_VERSION, + phase: API_PHASE, + database: 'ok', + time: new Date().toISOString(), + latencyMs: Date.now() - startedAt, + }; + } +} diff --git a/api-v3/src/inspection-acts/dto/cancel-inspection-act.dto.ts b/api-v3/src/inspection-acts/dto/cancel-inspection-act.dto.ts new file mode 100644 index 0000000..562b919 --- /dev/null +++ b/api-v3/src/inspection-acts/dto/cancel-inspection-act.dto.ts @@ -0,0 +1,10 @@ +import { Transform } from 'class-transformer'; +import { IsString, MaxLength, MinLength } from 'class-validator'; + +export class CancelInspectionActDto { + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) + @IsString() + @MinLength(10) + @MaxLength(4000) + reason!: string; +} diff --git a/api-v3/src/inspection-acts/dto/create-inspection-act.dto.ts b/api-v3/src/inspection-acts/dto/create-inspection-act.dto.ts new file mode 100644 index 0000000..5ba1ee7 --- /dev/null +++ b/api-v3/src/inspection-acts/dto/create-inspection-act.dto.ts @@ -0,0 +1,45 @@ +import { Transform } from 'class-transformer'; +import { + ArrayMaxSize, + ArrayMinSize, + ArrayUnique, + IsArray, + IsISO8601, + IsOptional, + IsString, + IsUUID, + MaxLength, + MinLength, +} from 'class-validator'; + +export class CreateInspectionActDto { + @IsISO8601({ strict: true }) + occurredAt!: string; + + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) + @IsString() + @MinLength(1) + @MaxLength(200) + title!: string; + + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) + @IsString() + @MinLength(1) + @MaxLength(20000) + summary!: string; + + @IsOptional() + @Transform(({ value }) => + typeof value === 'string' && value.trim() ? value.trim() : null, + ) + @IsString() + @MaxLength(8000) + observations?: string | null; + + @IsArray() + @ArrayMinSize(1) + @ArrayMaxSize(200) + @ArrayUnique() + @IsUUID('4', { each: true }) + assetIds!: string[]; +} diff --git a/api-v3/src/inspection-acts/dto/list-inspection-acts-query.dto.ts b/api-v3/src/inspection-acts/dto/list-inspection-acts-query.dto.ts new file mode 100644 index 0000000..626b6f0 --- /dev/null +++ b/api-v3/src/inspection-acts/dto/list-inspection-acts-query.dto.ts @@ -0,0 +1,56 @@ +import { Type } from 'class-transformer'; +import { IsEnum, IsInt, IsOptional, IsString, IsUUID, Matches, Max, MaxLength, Min } from 'class-validator'; +import { InspectionActStatus } from '../../database/entities'; + +export class ListInspectionActsQueryDto { + @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(InspectionActStatus) + status?: InspectionActStatus; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(2000) + @Max(2100) + year?: number; + + @IsOptional() + @IsUUID('4') + companyId?: string; + + @IsOptional() + @IsUUID('4') + areaId?: string; + + @IsOptional() + @IsUUID('4') + inspectorId?: string; + + @IsOptional() + @IsString() + @Matches(/^\d{4}-\d{2}-\d{2}$/) + dateFrom?: string; + + @IsOptional() + @IsString() + @Matches(/^\d{4}-\d{2}-\d{2}$/) + dateTo?: string; +} diff --git a/api-v3/src/inspection-acts/dto/update-inspection-act.dto.ts b/api-v3/src/inspection-acts/dto/update-inspection-act.dto.ts new file mode 100644 index 0000000..7a780aa --- /dev/null +++ b/api-v3/src/inspection-acts/dto/update-inspection-act.dto.ts @@ -0,0 +1,49 @@ +import { Transform } from 'class-transformer'; +import { + ArrayMaxSize, + ArrayMinSize, + ArrayUnique, + IsArray, + IsISO8601, + IsOptional, + IsString, + IsUUID, + MaxLength, + MinLength, +} from 'class-validator'; + +export class UpdateInspectionActDto { + @IsOptional() + @IsISO8601({ strict: true }) + occurredAt?: string; + + @IsOptional() + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) + @IsString() + @MinLength(1) + @MaxLength(200) + title?: string; + + @IsOptional() + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) + @IsString() + @MinLength(1) + @MaxLength(20000) + summary?: string; + + @IsOptional() + @Transform(({ value }) => + typeof value === 'string' && value.trim() ? value.trim() : null, + ) + @IsString() + @MaxLength(8000) + observations?: string | null; + + @IsOptional() + @IsArray() + @ArrayMinSize(1) + @ArrayMaxSize(200) + @ArrayUnique() + @IsUUID('4', { each: true }) + assetIds?: string[]; +} diff --git a/api-v3/src/inspection-acts/inspection-acts.controller.ts b/api-v3/src/inspection-acts/inspection-acts.controller.ts new file mode 100644 index 0000000..07d83d8 --- /dev/null +++ b/api-v3/src/inspection-acts/inspection-acts.controller.ts @@ -0,0 +1,83 @@ +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Patch, + Post, + Query, + Req, +} from '@nestjs/common'; +import { CurrentAuth } from '../auth/decorators/current-auth.decorator'; +import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator'; +import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; +import { CancelInspectionActDto } from './dto/cancel-inspection-act.dto'; +import { CreateInspectionActDto } from './dto/create-inspection-act.dto'; +import { ListInspectionActsQueryDto } from './dto/list-inspection-acts-query.dto'; +import { UpdateInspectionActDto } from './dto/update-inspection-act.dto'; +import { InspectionActsService } from './inspection-acts.service'; + +@Controller('inspection-visits/:visitId/acts') +export class InspectionVisitActsController { + constructor(private readonly acts: InspectionActsService) {} + + @Get() + @RequirePermissions('inspection_acts.read') + list( + @Param('visitId', new ParseUUIDPipe({ version: '4' })) visitId: string, + @Query() query: ListInspectionActsQueryDto, + ) { + return this.acts.listForVisit(visitId, query); + } + + @Post() + @RequirePermissions('inspection_acts.create') + create( + @Param('visitId', new ParseUUIDPipe({ version: '4' })) visitId: string, + @Body() dto: CreateInspectionActDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.acts.create(visitId, dto, principal, request); + } +} + +@Controller('inspection-acts') +export class InspectionActsController { + constructor(private readonly acts: InspectionActsService) {} + + @Get() + @RequirePermissions('inspection_acts.read') + list(@Query() query: ListInspectionActsQueryDto) { + return this.acts.listGlobal(query); + } + + @Get(':id') + @RequirePermissions('inspection_acts.read') + get(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) { + return this.acts.getById(id); + } + + @Patch(':id') + @RequirePermissions('inspection_acts.update') + update( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @Body() dto: UpdateInspectionActDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.acts.update(id, dto, principal, request); + } + + @Post(':id/cancel') + @RequirePermissions('inspection_acts.cancel') + cancel( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @Body() dto: CancelInspectionActDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.acts.cancel(id, dto, principal, request); + } +} diff --git a/api-v3/src/inspection-acts/inspection-acts.module.ts b/api-v3/src/inspection-acts/inspection-acts.module.ts new file mode 100644 index 0000000..3b9d039 --- /dev/null +++ b/api-v3/src/inspection-acts/inspection-acts.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { AuditModule } from '../audit/audit.module'; +import { + InspectionActsController, + InspectionVisitActsController, +} from './inspection-acts.controller'; +import { InspectionActsService } from './inspection-acts.service'; + +@Module({ + imports: [AuditModule], + controllers: [InspectionVisitActsController, InspectionActsController], + providers: [InspectionActsService], +}) +export class InspectionActsModule {} diff --git a/api-v3/src/inspection-acts/inspection-acts.service.ts b/api-v3/src/inspection-acts/inspection-acts.service.ts new file mode 100644 index 0000000..d089c25 --- /dev/null +++ b/api-v3/src/inspection-acts/inspection-acts.service.ts @@ -0,0 +1,815 @@ +import { + BadRequestException, + 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, + DocumentSequenceType, + InspectionAct, + InspectionActStatus, + InspectionActVersionEvent, + InspectionVisit, + InspectionVisitStatus, +} from '../database/entities'; +import type { CancelInspectionActDto } from './dto/cancel-inspection-act.dto'; +import type { CreateInspectionActDto } from './dto/create-inspection-act.dto'; +import type { ListInspectionActsQueryDto } from './dto/list-inspection-acts-query.dto'; +import type { UpdateInspectionActDto } from './dto/update-inspection-act.dto'; +import { assertMobileInspector } from '../inspection-operations/mobile-inspector-policy'; + +interface ActPerson { + id: string; + username: string; + firstName: string; + lastName: string; +} + +interface ActAsset { + id: string; + code: string; + name: string; + typeName: string; +} + +interface ActVisitSummary { + id: string; + code: string; + title: string; + status: InspectionVisitStatus; + actualStartedAt: Date | null; +} + +interface ActContextAsset { + id: string; + code: string; + name: string; +} + +interface ActReportSummary { + id: string; + code: string; + status: string; + pdfStatus: string; + generatedAt: Date; +} + +export interface InspectionActListItem { + id: string; + visitId: string; + visit: ActVisitSummary; + actYear: number; + actNumber: number; + code: string; + status: InspectionActStatus; + occurredAt: Date; + title: string; + summary: string; + observations: string | null; + currentVersion: number; + cancellationReason: string | null; + closedAt: Date | null; + closedBy: string | null; + closureSha256: string | null; + assetCount: number; + findingCount: number; + companies: ActContextAsset[]; + areas: ActContextAsset[]; + report: ActReportSummary | null; + createdBy: ActPerson | null; + updatedBy: ActPerson | null; + createdAt: Date; + updatedAt: Date; +} + +interface InspectionActVersionView { + id: string; + versionNumber: number; + event: InspectionActVersionEvent; + snapshot: Record; + actorUserId: string | null; + actorUsername: string | null; + createdAt: Date; +} + +export interface InspectionActView extends InspectionActListItem { + assets: ActAsset[]; + versions: InspectionActVersionView[]; +} + +function actNotFound(): NotFoundException { + return new NotFoundException({ + code: 'INSPECTION_ACT_NOT_FOUND', + message: 'Acta de inspección no encontrada', + }); +} + +function visitNotFound(): NotFoundException { + return new NotFoundException({ + code: 'INSPECTION_VISIT_NOT_FOUND', + message: 'Visita de inspección no encontrada', + }); +} + +@Injectable() +export class InspectionActsService { + constructor( + private readonly dataSource: DataSource, + private readonly audit: AuditService, + ) {} + + async listGlobal(query: ListInspectionActsQueryDto) { + const conditions = ['1 = 1']; + const parameters: unknown[] = []; + const add = (value: unknown): string => { + parameters.push(value); + return `$${parameters.length}`; + }; + if (query.search?.trim()) { + const search = add(`%${query.search.trim()}%`); + conditions.push(`( + act.code ILIKE ${search} + OR act.title ILIKE ${search} + OR visit.code ILIKE ${search} + OR visit.title ILIKE ${search} + OR EXISTS ( + SELECT 1 + FROM inspection_act_assets search_link + INNER JOIN assets search_asset ON search_asset.id = search_link.asset_id + LEFT JOIN assets search_company ON search_company.id = search_asset.operator_company_id + LEFT JOIN assets search_area ON search_area.id = search_asset.operational_area_id + WHERE search_link.act_id = act.id + AND search_link.included = true + AND ( + search_asset.code ILIKE ${search} + OR search_asset.name ILIKE ${search} + OR search_company.name ILIKE ${search} + OR search_area.name ILIKE ${search} + ) + ) + )`); + } + if (query.status) conditions.push(`act.status = ${add(query.status)}`); + if (query.year) conditions.push(`act.act_year = ${add(query.year)}`); + if (query.companyId) { + conditions.push(`EXISTS ( + SELECT 1 + FROM inspection_act_assets company_link + INNER JOIN assets company_asset ON company_asset.id = company_link.asset_id + WHERE company_link.act_id = act.id + AND company_link.included = true + AND company_asset.operator_company_id = ${add(query.companyId)}::uuid + )`); + } + if (query.areaId) { + conditions.push(`EXISTS ( + SELECT 1 + FROM inspection_act_assets area_link + INNER JOIN assets area_asset ON area_asset.id = area_link.asset_id + WHERE area_link.act_id = act.id + AND area_link.included = true + AND area_asset.operational_area_id = ${add(query.areaId)}::uuid + )`); + } + if (query.inspectorId) { + const inspector = add(query.inspectorId); + conditions.push(`( + visit.lead_inspector_user_id = ${inspector}::uuid + OR EXISTS ( + SELECT 1 FROM inspection_visit_members member_filter + WHERE member_filter.visit_id = visit.id + AND member_filter.included = true + AND member_filter.user_id = ${inspector}::uuid + ) + )`); + } + if (query.dateFrom) conditions.push(`act.occurred_at::date >= ${add(query.dateFrom)}::date`); + if (query.dateTo) conditions.push(`act.occurred_at::date <= ${add(query.dateTo)}::date`); + const where = `WHERE ${conditions.join(' AND ')}`; + const [countRow] = (await this.dataSource.query( + `SELECT COUNT(*)::integer AS total + FROM inspection_acts act + INNER JOIN inspection_visits visit ON visit.id = act.visit_id + ${where}`, + parameters, + )) as Array<{ total: number }>; + const total = Number(countRow?.total ?? 0); + const limit = add(query.pageSize); + const offset = add((query.page - 1) * query.pageSize); + const data = (await this.dataSource.query( + `${this.actSelect(where)} + ORDER BY act.act_year DESC, act.act_number DESC + LIMIT ${limit} OFFSET ${offset}`, + parameters, + )) as InspectionActListItem[]; + return { + data, + meta: { + page: query.page, + pageSize: query.pageSize, + total, + totalPages: total === 0 ? 0 : Math.ceil(total / query.pageSize), + }, + }; + } + + async listForVisit(visitId: string, query: ListInspectionActsQueryDto) { + await this.requireVisit(this.dataSource.manager, visitId); + const conditions = ['act.visit_id = $1']; + const parameters: unknown[] = [visitId]; + const add = (value: unknown): string => { + parameters.push(value); + return `$${parameters.length}`; + }; + if (query.search?.trim()) { + const search = add(`%${query.search.trim()}%`); + conditions.push(`(act.code ILIKE ${search} OR act.title ILIKE ${search})`); + } + if (query.status) conditions.push(`act.status = ${add(query.status)}`); + if (query.year) conditions.push(`act.act_year = ${add(query.year)}`); + if (query.companyId) { + conditions.push(`EXISTS ( + SELECT 1 FROM inspection_act_assets company_link + INNER JOIN assets company_asset ON company_asset.id = company_link.asset_id + WHERE company_link.act_id = act.id + AND company_link.included = true + AND company_asset.operator_company_id = ${add(query.companyId)}::uuid + )`); + } + if (query.areaId) { + conditions.push(`EXISTS ( + SELECT 1 FROM inspection_act_assets area_link + INNER JOIN assets area_asset ON area_asset.id = area_link.asset_id + WHERE area_link.act_id = act.id + AND area_link.included = true + AND area_asset.operational_area_id = ${add(query.areaId)}::uuid + )`); + } + if (query.inspectorId) { + const inspector = add(query.inspectorId); + conditions.push(`( + visit.lead_inspector_user_id = ${inspector}::uuid + OR EXISTS ( + SELECT 1 FROM inspection_visit_members member_filter + WHERE member_filter.visit_id = visit.id + AND member_filter.included = true + AND member_filter.user_id = ${inspector}::uuid + ) + )`); + } + if (query.dateFrom) conditions.push(`act.occurred_at::date >= ${add(query.dateFrom)}::date`); + if (query.dateTo) conditions.push(`act.occurred_at::date <= ${add(query.dateTo)}::date`); + const where = `WHERE ${conditions.join(' AND ')}`; + const [countRow] = (await this.dataSource.query( + `SELECT COUNT(*)::integer AS total + FROM inspection_acts act + INNER JOIN inspection_visits visit ON visit.id = act.visit_id + ${where}`, + parameters, + )) as Array<{ total: number }>; + const total = Number(countRow?.total ?? 0); + const limit = add(query.pageSize); + const offset = add((query.page - 1) * query.pageSize); + const data = (await this.dataSource.query( + `${this.actSelect(where)} + ORDER BY act.act_year DESC, act.act_number DESC + LIMIT ${limit} OFFSET ${offset}`, + parameters, + )) as InspectionActListItem[]; + return { + data, + meta: { + page: query.page, + pageSize: query.pageSize, + total, + totalPages: total === 0 ? 0 : Math.ceil(total / query.pageSize), + }, + }; + } + + async getById(id: string): Promise { + return this.dataSource.transaction((manager) => this.loadView(manager, id)); + } + + async create( + visitId: string, + dto: CreateInspectionActDto, + principal: AuthPrincipal, + request: RequestWithContext, + ): Promise { + assertMobileInspector(principal); + return this.dataSource.transaction(async (manager) => { + const visit = await this.lockVisit(manager, visitId); + this.assertVisitOpen(visit); + await this.assertActorAssigned(manager, visitId, principal); + await this.assertVisitHasNoAct(manager, visitId); + await this.assertVisitAssets(manager, visitId, dto.assetIds); + const occurredAt = new Date(dto.occurredAt); + const actYear = await this.yearAtProjectTimezone(manager, occurredAt); + const actNumber = await this.allocateNumber(manager, actYear); + if (actNumber > 999999) { + throw new ConflictException({ + code: 'INSPECTION_ACT_SEQUENCE_EXHAUSTED', + message: 'La numeración anual de actas agotó su rango disponible', + }); + } + const code = `ACTA-${actYear}-${String(actNumber).padStart(6, '0')}`; + const act = manager.getRepository(InspectionAct).create({ + visitId, + actYear, + actNumber, + code, + status: InspectionActStatus.DRAFT, + occurredAt, + title: dto.title, + summary: dto.summary, + observations: dto.observations ?? null, + currentVersion: 0, + cancellationReason: null, + createdBy: principal.userId, + updatedBy: principal.userId, + }); + await manager.getRepository(InspectionAct).save(act); + await this.replaceAssetLinks(manager, act.id, dto.assetIds, principal.userId); + const versionNumber = await this.captureVersion( + manager, + act, + InspectionActVersionEvent.CREATED, + principal, + ); + const created = await this.loadView(manager, act.id); + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.INSPECTION_ACT_CREATED, + entityType: 'inspection_act', + entityId: act.id, + afterData: this.auditView(created), + metadata: { visitId, versionNumber }, + }, manager); + return created; + }); + } + + async update( + id: string, + dto: UpdateInspectionActDto, + principal: AuthPrincipal, + request: RequestWithContext, + ): Promise { + assertMobileInspector(principal); + if (Object.keys(dto).length === 0) { + throw new BadRequestException({ code: 'NO_CHANGES', message: 'No se recibieron cambios' }); + } + return this.dataSource.transaction(async (manager) => { + const act = await this.lockAct(manager, id); + const visit = await this.lockVisit(manager, act.visitId); + this.assertVisitOpen(visit); + this.assertActDraft(act); + await this.assertActorAssigned(manager, visit.id, principal); + const nextOccurredAt = dto.occurredAt ? new Date(dto.occurredAt) : act.occurredAt; + const nextYear = await this.yearAtProjectTimezone(manager, nextOccurredAt); + if (nextYear !== act.actYear) { + throw new ConflictException({ + code: 'INSPECTION_ACT_YEAR_LOCKED', + message: 'La fecha no puede cambiar el año de la numeración oficial asignada', + }); + } + const nextAssetIds = dto.assetIds ?? await this.activeAssetIds(manager, id); + await this.assertVisitAssets(manager, visit.id, nextAssetIds); + const before = await this.loadView(manager, id); + if (dto.occurredAt !== undefined) act.occurredAt = nextOccurredAt; + if (dto.title !== undefined) act.title = dto.title; + if (dto.summary !== undefined) act.summary = dto.summary; + if (dto.observations !== undefined) act.observations = dto.observations; + act.updatedBy = principal.userId; + await manager.getRepository(InspectionAct).save(act); + if (dto.assetIds !== undefined) { + await this.replaceAssetLinks(manager, id, dto.assetIds, principal.userId); + } + const versionNumber = await this.captureVersion( + manager, + act, + InspectionActVersionEvent.UPDATED, + principal, + ); + const updated = await this.loadView(manager, id); + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.INSPECTION_ACT_UPDATED, + entityType: 'inspection_act', + entityId: id, + beforeData: this.auditView(before), + afterData: this.auditView(updated), + metadata: { visitId: visit.id, versionNumber }, + }, manager); + return updated; + }); + } + + async cancel( + id: string, + dto: CancelInspectionActDto, + principal: AuthPrincipal, + request: RequestWithContext, + ): Promise { + assertMobileInspector(principal); + return this.dataSource.transaction(async (manager) => { + const act = await this.lockAct(manager, id); + const visit = await this.lockVisit(manager, act.visitId); + if (visit.status === InspectionVisitStatus.CLOSED) { + throw new ConflictException({ + code: 'INSPECTION_VISIT_CLOSED', + message: 'No se puede cancelar un acta de una visita cerrada', + }); + } + this.assertActDraft(act); + const before = await this.loadView(manager, id); + act.status = InspectionActStatus.CANCELLED; + act.cancellationReason = dto.reason; + act.updatedBy = principal.userId; + await manager.getRepository(InspectionAct).save(act); + const versionNumber = await this.captureVersion( + manager, + act, + InspectionActVersionEvent.CANCELLED, + principal, + ); + const cancelled = await this.loadView(manager, id); + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.INSPECTION_ACT_CANCELLED, + entityType: 'inspection_act', + entityId: id, + beforeData: this.auditView(before), + afterData: this.auditView(cancelled), + metadata: { visitId: visit.id, versionNumber }, + }, manager); + return cancelled; + }); + } + + private actSelect(where: string): string { + return ` + SELECT + act.id, + act.visit_id AS "visitId", + JSONB_BUILD_OBJECT( + 'id', visit.id, + 'code', visit.code, + 'title', visit.title, + 'status', visit.status, + 'actualStartedAt', visit.actual_started_at + ) AS visit, + act.act_year AS "actYear", + act.act_number AS "actNumber", + act.code, + act.status, + act.occurred_at AS "occurredAt", + act.title, + act.summary, + act.observations, + act.current_version AS "currentVersion", + act.cancellation_reason AS "cancellationReason", + act.closed_at AS "closedAt", + act.closed_by AS "closedBy", + act.closure_sha256 AS "closureSha256", + COALESCE(asset_count.total, 0)::integer AS "assetCount", + COALESCE(finding_count.total, 0)::integer AS "findingCount", + COALESCE(context.companies, '[]'::jsonb) AS companies, + COALESCE(context.areas, '[]'::jsonb) AS areas, + CASE WHEN report.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( + 'id', report.id, + 'code', report.code, + 'status', report.status, + 'pdfStatus', report.pdf_status, + 'generatedAt', report.generated_at + ) END AS report, + CASE WHEN creator.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( + 'id', creator.id, + 'username', creator.username, + 'firstName', creator.first_name, + 'lastName', creator.last_name + ) END AS "createdBy", + CASE WHEN updater.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( + 'id', updater.id, + 'username', updater.username, + 'firstName', updater.first_name, + 'lastName', updater.last_name + ) END AS "updatedBy", + act.created_at AS "createdAt", + act.updated_at AS "updatedAt" + FROM inspection_acts act + INNER JOIN inspection_visits visit ON visit.id = act.visit_id + LEFT JOIN users creator ON creator.id = act.created_by + LEFT JOIN users updater ON updater.id = act.updated_by + LEFT JOIN inspection_reports report ON report.act_id = act.id + LEFT JOIN LATERAL ( + SELECT + COALESCE(JSONB_AGG(DISTINCT JSONB_BUILD_OBJECT( + 'id', company.id, + 'code', company.code, + 'name', company.name + )) FILTER (WHERE company.id IS NOT NULL), '[]'::jsonb) AS companies, + COALESCE(JSONB_AGG(DISTINCT JSONB_BUILD_OBJECT( + 'id', area.id, + 'code', area.code, + 'name', area.name + )) FILTER (WHERE area.id IS NOT NULL), '[]'::jsonb) AS areas + FROM inspection_act_assets context_link + INNER JOIN assets context_asset ON context_asset.id = context_link.asset_id + LEFT JOIN assets company ON company.id = context_asset.operator_company_id + LEFT JOIN assets area ON area.id = context_asset.operational_area_id + WHERE context_link.act_id = act.id AND context_link.included = true + ) context ON true + LEFT JOIN LATERAL ( + SELECT COUNT(*) AS total + FROM inspection_act_assets link + WHERE link.act_id = act.id AND link.included = true + ) asset_count ON true + LEFT JOIN LATERAL ( + SELECT COUNT(*) AS total + FROM inspection_findings finding + WHERE finding.act_id = act.id AND finding.status <> 'VOIDED' + ) finding_count ON true + ${where} + `; + } + + private async loadView(manager: EntityManager, id: string): Promise { + const [act] = (await manager.query( + this.actSelect('WHERE act.id = $1'), + [id], + )) as InspectionActListItem[]; + if (!act) throw actNotFound(); + const assets = (await manager.query(` + SELECT asset.id, asset.code, asset.name, asset_type.name AS "typeName" + 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 + WHERE link.act_id = $1 AND link.included = true + ORDER BY asset.code, asset.name + `, [id])) as ActAsset[]; + const versions = (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 inspection_act_versions version + WHERE version.act_id = $1 + ORDER BY version.version_number DESC + `, [id])) as InspectionActVersionView[]; + return { ...act, assets, versions }; + } + + private async requireVisit(manager: EntityManager, id: string): Promise { + const rows = await manager.query('SELECT 1 FROM inspection_visits WHERE id = $1', [id]) as unknown[]; + if (rows.length === 0) throw visitNotFound(); + } + + private async lockVisit(manager: EntityManager, id: string): Promise { + const visit = await manager.getRepository(InspectionVisit) + .createQueryBuilder('visit') + .where('visit.id = :id', { id }) + .setLock('pessimistic_write') + .getOne(); + if (!visit) throw visitNotFound(); + return visit; + } + + private async lockAct(manager: EntityManager, id: string): Promise { + const act = await manager.getRepository(InspectionAct) + .createQueryBuilder('act') + .where('act.id = :id', { id }) + .setLock('pessimistic_write') + .getOne(); + if (!act) throw actNotFound(); + return act; + } + + private assertVisitOpen(visit: InspectionVisit): void { + if (visit.status !== InspectionVisitStatus.IN_PROGRESS) { + throw new ConflictException({ + code: 'INSPECTION_VISIT_NOT_IN_PROGRESS', + message: 'La visita debe estar en curso para crear o editar actas', + }); + } + } + + private assertActDraft(act: InspectionAct): void { + if (act.status !== InspectionActStatus.DRAFT) { + throw new ConflictException({ + code: 'INSPECTION_ACT_IMMUTABLE', + message: 'Sólo se puede editar o cancelar un acta en borrador', + }); + } + } + + private async assertActorAssigned( + manager: EntityManager, + visitId: string, + principal: AuthPrincipal, + ): Promise { + if (principal.permissions.includes('inspections.manage')) return; + const rows = await manager.query(` + SELECT 1 FROM inspection_visit_members + WHERE visit_id = $1 AND user_id = $2 AND included = true + `, [visitId, principal.userId]) as unknown[]; + if (rows.length === 0) { + throw new ForbiddenException({ + code: 'INSPECTION_VISIT_NOT_ASSIGNED', + message: 'La visita no está asignada al usuario actual', + }); + } + } + + private async assertVisitHasNoAct( + manager: EntityManager, + visitId: string, + ): Promise { + const rows = (await manager.query(` + SELECT code + FROM inspection_acts + WHERE visit_id = $1 + LIMIT 1 + `, [visitId])) as Array<{ code: string }>; + if (rows.length > 0) { + throw new ConflictException({ + code: 'INSPECTION_VISIT_ACT_ALREADY_EXISTS', + message: 'La visita ya tiene un acta; cada visita admite una única acta', + }); + } + } + + private async assertVisitAssets( + manager: EntityManager, + visitId: string, + assetIds: string[], + ): Promise { + const [row] = (await manager.query(` + SELECT COUNT(*)::integer AS count + FROM inspection_visit_assets + WHERE visit_id = $1 + AND asset_id = ANY($2::uuid[]) + AND included = true + `, [visitId, assetIds])) as Array<{ count: number }>; + if (assetIds.length < 1 || Number(row?.count ?? 0) !== assetIds.length) { + throw new BadRequestException({ + code: 'INSPECTION_ACT_ASSET_INVALID', + message: 'Cada activo del acta debe estar incluido en la visita', + }); + } + } + + private async yearAtProjectTimezone(manager: EntityManager, value: Date): Promise { + const [row] = (await manager.query(` + SELECT EXTRACT(YEAR FROM $1::timestamptz AT TIME ZONE 'America/Argentina/Mendoza')::integer AS year + `, [value])) as Array<{ year: number }>; + return Number(row.year); + } + + private async allocateNumber(manager: EntityManager, year: number): Promise { + const [row] = (await manager.query(` + INSERT INTO document_annual_sequences (document_type, year, last_number) + VALUES ($1, $2, 1) + ON CONFLICT (document_type, year) DO UPDATE SET + last_number = document_annual_sequences.last_number + 1, + updated_at = CURRENT_TIMESTAMP + RETURNING last_number AS number + `, [DocumentSequenceType.ACT, year])) as Array<{ number: number }>; + return Number(row.number); + } + + private async activeAssetIds(manager: EntityManager, actId: string): Promise { + const rows = (await manager.query(` + SELECT asset_id AS id FROM inspection_act_assets + WHERE act_id = $1 AND included = true ORDER BY created_at, asset_id + `, [actId])) as Array<{ id: string }>; + return rows.map((row) => row.id); + } + + private async replaceAssetLinks( + manager: EntityManager, + actId: string, + assetIds: string[], + userId: string, + ): Promise { + await manager.query(` + UPDATE inspection_act_assets + SET included = false, updated_at = CURRENT_TIMESTAMP + WHERE act_id = $1 AND included = true + `, [actId]); + for (const assetId of assetIds) { + await manager.query(` + INSERT INTO inspection_act_assets (act_id, asset_id, included, added_by) + VALUES ($1, $2, true, $3) + ON CONFLICT (act_id, asset_id) DO UPDATE SET + included = true, + added_by = EXCLUDED.added_by, + updated_at = CURRENT_TIMESTAMP + `, [actId, assetId, userId]); + } + } + + private async captureVersion( + manager: EntityManager, + act: InspectionAct, + event: InspectionActVersionEvent, + principal: AuthPrincipal, + ): Promise { + const [row] = (await manager.query(` + UPDATE inspection_acts + SET current_version = current_version + 1 + WHERE id = $1 + RETURNING current_version AS "versionNumber" + `, [act.id])) as Array<{ versionNumber: number }>; + const versionNumber = Number(row.versionNumber); + act.currentVersion = versionNumber; + const snapshot = await this.buildSnapshot(manager, act.id); + 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) + `, [act.id, versionNumber, event, snapshot, principal.userId, principal.username]); + return versionNumber; + } + + private async buildSnapshot( + manager: EntityManager, + actId: string, + ): Promise> { + const [row] = (await manager.query(` + SELECT JSONB_BUILD_OBJECT( + 'id', act.id, + 'code', act.code, + 'actYear', act.act_year, + 'actNumber', act.act_number, + 'status', act.status, + 'occurredAt', act.occurred_at, + 'title', act.title, + 'summary', act.summary, + 'observations', act.observations, + 'currentVersion', act.current_version, + 'cancellationReason', act.cancellation_reason, + 'createdBy', act.created_by, + 'updatedBy', act.updated_by, + 'visit', JSONB_BUILD_OBJECT( + 'id', visit.id, + 'code', visit.code, + 'title', visit.title, + 'status', visit.status, + 'scopeAssetId', visit.scope_asset_id, + 'actualStartedAt', visit.actual_started_at + ), + 'assets', COALESCE(( + SELECT JSONB_AGG(JSONB_BUILD_OBJECT( + 'id', asset.id, + 'code', asset.code, + 'name', asset.name, + 'typeId', asset.asset_type_id, + 'typeName', asset_type.name, + 'currentVersion', asset.current_version + ) ORDER BY asset.code, asset.id) + 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 + WHERE link.act_id = act.id AND link.included = true + ), '[]'::jsonb) + ) AS snapshot + FROM inspection_acts act + INNER JOIN inspection_visits visit ON visit.id = act.visit_id + WHERE act.id = $1 + `, [actId])) as Array<{ snapshot: Record }>; + if (!row) throw actNotFound(); + return row.snapshot; + } + + private auditView(act: InspectionActView): Record { + return { + visitId: act.visitId, + code: act.code, + actYear: act.actYear, + actNumber: act.actNumber, + status: act.status, + occurredAt: act.occurredAt, + title: act.title, + summary: act.summary, + observations: act.observations, + currentVersion: act.currentVersion, + cancellationReason: act.cancellationReason, + closedAt: act.closedAt, + closedBy: act.closedBy, + closureSha256: act.closureSha256, + assetIds: act.assets.map((asset) => asset.id), + }; + } +} diff --git a/api-v3/src/inspection-closing/canonical-json.ts b/api-v3/src/inspection-closing/canonical-json.ts new file mode 100644 index 0000000..fece4e0 --- /dev/null +++ b/api-v3/src/inspection-closing/canonical-json.ts @@ -0,0 +1,22 @@ +import { createHash } from 'node:crypto'; + +function canonicalValue(value: unknown): unknown { + if (value instanceof Date) return value.toISOString(); + if (Array.isArray(value)) return value.map(canonicalValue); + if (value !== null && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, item]) => [key, canonicalValue(item)]), + ); + } + return value; +} + +export function canonicalJson(value: unknown): string { + return JSON.stringify(canonicalValue(value)); +} + +export function sha256CanonicalJson(value: unknown): string { + return createHash('sha256').update(canonicalJson(value)).digest('hex'); +} diff --git a/api-v3/src/inspection-closing/dto/close-inspection-act.dto.ts b/api-v3/src/inspection-closing/dto/close-inspection-act.dto.ts new file mode 100644 index 0000000..35ad5b9 --- /dev/null +++ b/api-v3/src/inspection-closing/dto/close-inspection-act.dto.ts @@ -0,0 +1,10 @@ +import { IsEnum, IsISO8601 } from 'class-validator'; +import { InspectionActUploadMode } from '../../database/entities'; + +export class CloseInspectionActDto { + @IsISO8601({ strict: true }) + clientClosedAt!: string; + + @IsEnum(InspectionActUploadMode) + uploadMode!: InspectionActUploadMode; +} diff --git a/api-v3/src/inspection-closing/dto/create-company-outcome.dto.ts b/api-v3/src/inspection-closing/dto/create-company-outcome.dto.ts new file mode 100644 index 0000000..cc8d3e9 --- /dev/null +++ b/api-v3/src/inspection-closing/dto/create-company-outcome.dto.ts @@ -0,0 +1,17 @@ +import { Transform } from 'class-transformer'; +import { IsIn, IsString, MaxLength, MinLength } from 'class-validator'; +import { InspectionActSignatureStatus } from '../../database/entities'; + +export class CreateCompanyOutcomeDto { + @IsIn([ + InspectionActSignatureStatus.REFUSED, + InspectionActSignatureStatus.ABSENT, + ]) + status!: InspectionActSignatureStatus.REFUSED | InspectionActSignatureStatus.ABSENT; + + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) + @IsString() + @MinLength(10) + @MaxLength(4000) + reason!: string; +} diff --git a/api-v3/src/inspection-closing/dto/create-inspection-signature.dto.ts b/api-v3/src/inspection-closing/dto/create-inspection-signature.dto.ts new file mode 100644 index 0000000..9575ba3 --- /dev/null +++ b/api-v3/src/inspection-closing/dto/create-inspection-signature.dto.ts @@ -0,0 +1,49 @@ +import { Transform, Type } from 'class-transformer'; +import { + Equals, + IsBoolean, + IsISO8601, + IsNumber, + IsOptional, + IsString, + Max, + MaxLength, + Min, +} from 'class-validator'; + +export class CreateInspectionSignatureDto { + @Transform(({ value }) => value === true || value === 'true') + @IsBoolean() + @Equals(true) + consentAccepted!: boolean; + + @IsOptional() + @IsISO8601({ strict: true }) + clientSignedAt?: string; + + @IsOptional() + @Type(() => Number) + @IsNumber({ maxDecimalPlaces: 6 }) + @Min(-90) + @Max(90) + latitude?: number; + + @IsOptional() + @Type(() => Number) + @IsNumber({ maxDecimalPlaces: 6 }) + @Min(-180) + @Max(180) + longitude?: number; + + @IsOptional() + @Type(() => Number) + @IsNumber({ maxDecimalPlaces: 3 }) + @Min(0) + @Max(100000) + accuracyM?: number; + + @IsOptional() + @IsString() + @MaxLength(200) + deviceLabel?: string; +} diff --git a/api-v3/src/inspection-closing/dto/upsert-inspection-responsible.dto.ts b/api-v3/src/inspection-closing/dto/upsert-inspection-responsible.dto.ts new file mode 100644 index 0000000..35490c8 --- /dev/null +++ b/api-v3/src/inspection-closing/dto/upsert-inspection-responsible.dto.ts @@ -0,0 +1,77 @@ +import { Transform } from 'class-transformer'; +import { + IsEmail, + IsEnum, + IsOptional, + IsString, + MaxLength, + MinLength, + ValidateIf, +} from 'class-validator'; +import { + InspectionResponsibleAttendanceStatus, + InspectionResponsibleDocumentType, +} from '../../database/entities'; + +const trimOrUndefined = ({ value }: { value: unknown }) => ( + typeof value === 'string' && value.trim() ? value.trim() : undefined +); + +export class UpsertInspectionResponsibleDto { + @IsEnum(InspectionResponsibleAttendanceStatus) + attendanceStatus!: InspectionResponsibleAttendanceStatus; + + @ValidateIf((dto: UpsertInspectionResponsibleDto) => ( + dto.attendanceStatus === InspectionResponsibleAttendanceStatus.PRESENT + )) + @Transform(trimOrUndefined) + @IsString() + @MinLength(1) + @MaxLength(200) + fullName?: string; + + @ValidateIf((dto: UpsertInspectionResponsibleDto) => ( + dto.attendanceStatus === InspectionResponsibleAttendanceStatus.PRESENT + )) + @IsEnum(InspectionResponsibleDocumentType) + documentType?: InspectionResponsibleDocumentType; + + @ValidateIf((dto: UpsertInspectionResponsibleDto) => ( + dto.attendanceStatus === InspectionResponsibleAttendanceStatus.PRESENT + )) + @Transform(trimOrUndefined) + @IsString() + @MinLength(1) + @MaxLength(40) + documentNumber?: string; + + @ValidateIf((dto: UpsertInspectionResponsibleDto) => ( + dto.attendanceStatus === InspectionResponsibleAttendanceStatus.PRESENT + )) + @Transform(trimOrUndefined) + @IsString() + @MinLength(1) + @MaxLength(200) + position?: string; + + @IsOptional() + @Transform(trimOrUndefined) + @IsEmail() + @MaxLength(320) + email?: string; + + @IsOptional() + @Transform(trimOrUndefined) + @IsString() + @MaxLength(50) + phone?: string; + + @ValidateIf((dto: UpsertInspectionResponsibleDto) => ( + dto.attendanceStatus === InspectionResponsibleAttendanceStatus.ABSENT + )) + @Transform(trimOrUndefined) + @IsString() + @MinLength(10) + @MaxLength(4000) + absenceReason?: string; +} diff --git a/api-v3/src/inspection-closing/inspection-closing.controller.ts b/api-v3/src/inspection-closing/inspection-closing.controller.ts new file mode 100644 index 0000000..fc3ec38 --- /dev/null +++ b/api-v3/src/inspection-closing/inspection-closing.controller.ts @@ -0,0 +1,147 @@ +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Post, + Put, + Req, + Res, + UploadedFile, + UseInterceptors, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import type { Response } from 'express'; +import { CurrentAuth } from '../auth/decorators/current-auth.decorator'; +import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator'; +import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; +import { CloseInspectionActDto } from './dto/close-inspection-act.dto'; +import { CreateCompanyOutcomeDto } from './dto/create-company-outcome.dto'; +import { CreateInspectionSignatureDto } from './dto/create-inspection-signature.dto'; +import { UpsertInspectionResponsibleDto } from './dto/upsert-inspection-responsible.dto'; +import { InspectionClosingService } from './inspection-closing.service'; +import { + MAX_INSPECTION_SIGNATURE_BYTES, + type UploadedInspectionSignatureFile, +} from './inspection-signature-file'; + +@Controller('inspection-acts/:actId') +export class InspectionClosingController { + constructor(private readonly closing: InspectionClosingService) {} + + @Get('closure') + @RequirePermissions('inspection_closure.read') + get(@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string) { + return this.closing.get(actId); + } + + @Put('responsible') + @RequirePermissions('inspection_closure.prepare') + responsible( + @Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string, + @Body() dto: UpsertInspectionResponsibleDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + 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', { + limits: { fileSize: MAX_INSPECTION_SIGNATURE_BYTES, files: 1 }, + })) + inspectorSignature( + @Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string, + @Body() dto: CreateInspectionSignatureDto, + @UploadedFile() file: UploadedInspectionSignatureFile | undefined, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.closing.signInspector(actId, dto, file, principal, request); + } + + @Post('signatures/company') + @RequirePermissions('inspection_closure.sign') + @UseInterceptors(FileInterceptor('file', { + limits: { fileSize: MAX_INSPECTION_SIGNATURE_BYTES, files: 1 }, + })) + companySignature( + @Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string, + @Body() dto: CreateInspectionSignatureDto, + @UploadedFile() file: UploadedInspectionSignatureFile | undefined, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.closing.signCompany(actId, dto, file, principal, request); + } + + @Post('company-outcome') + @RequirePermissions('inspection_closure.sign') + companyOutcome( + @Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string, + @Body() dto: CreateCompanyOutcomeDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.closing.recordCompanyOutcome(actId, dto, principal, request); + } + + @Post('close') + @RequirePermissions('inspection_closure.close') + close( + @Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string, + @Body() dto: CloseInspectionActDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.closing.close(actId, dto, principal, request); + } +} + +@Controller('inspection-act-signatures') +export class InspectionSignatureContentController { + constructor(private readonly closing: InspectionClosingService) {} + + @Get(':signatureId/content') + @RequirePermissions('inspection_closure.read') + async content( + @Param('signatureId', new ParseUUIDPipe({ version: '4' })) signatureId: string, + @Res() response: Response, + ): Promise { + const { filePath, signature } = await this.closing.signatureContent(signatureId); + response.setHeader('Content-Type', 'image/png'); + response.setHeader('Content-Length', String(signature.sizeBytes)); + response.setHeader('Content-Disposition', 'inline; filename="firma.png"'); + response.setHeader('Cache-Control', 'private, no-store'); + response.setHeader('X-Content-Type-Options', 'nosniff'); + response.setHeader('Content-Security-Policy', "sandbox; default-src 'none'"); + await new Promise((resolveSend, rejectSend) => { + response.sendFile(filePath, (error) => { + if (error) rejectSend(error); + else resolveSend(); + }); + }); + } +} diff --git a/api-v3/src/inspection-closing/inspection-closing.module.ts b/api-v3/src/inspection-closing/inspection-closing.module.ts new file mode 100644 index 0000000..4ea68b5 --- /dev/null +++ b/api-v3/src/inspection-closing/inspection-closing.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { AuditModule } from '../audit/audit.module'; +import { InspectionReportsModule } from '../inspection-reports/inspection-reports.module'; +import { + InspectionClosingController, + InspectionSignatureContentController, +} from './inspection-closing.controller'; +import { InspectionClosingService } from './inspection-closing.service'; + +@Module({ + imports: [AuditModule, InspectionReportsModule], + controllers: [InspectionClosingController, InspectionSignatureContentController], + providers: [InspectionClosingService], +}) +export class InspectionClosingModule {} diff --git a/api-v3/src/inspection-closing/inspection-closing.service.ts b/api-v3/src/inspection-closing/inspection-closing.service.ts new file mode 100644 index 0000000..8954a10 --- /dev/null +++ b/api-v3/src/inspection-closing/inspection-closing.service.ts @@ -0,0 +1,1414 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { mkdir, stat, unlink, writeFile } from 'node:fs/promises'; +import { isAbsolute, parse, resolve } from 'node:path'; +import { + BadRequestException, + ConflictException, + ForbiddenException, + Injectable, + InternalServerErrorException, + NotFoundException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { DataSource, EntityManager } from 'typeorm'; +import { administrationAuditContext } from '../administration/common/administration-audit'; +import { AuditService } from '../audit/audit.service'; +import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; +import { assertMobileInspector } from '../inspection-operations/mobile-inspector-policy'; +import { + AuditAction, + InspectionAct, + InspectionActSignatureSource, + InspectionActSignatureStatus, + InspectionActSignerType, + InspectionActStatus, + InspectionActUploadMode, + InspectionActVersionEvent, + InspectionResponsibleAttendanceStatus, + InspectionVisit, + InspectionVisitStatus, +} from '../database/entities'; +import { sha256CanonicalJson } from './canonical-json'; +import type { CloseInspectionActDto } from './dto/close-inspection-act.dto'; +import { InspectionReportsService } from '../inspection-reports/inspection-reports.service'; +import type { CreateCompanyOutcomeDto } from './dto/create-company-outcome.dto'; +import type { CreateInspectionSignatureDto } from './dto/create-inspection-signature.dto'; +import type { UpsertInspectionResponsibleDto } from './dto/upsert-inspection-responsible.dto'; +import { + inspectInspectionSignatureFile, + type UploadedInspectionSignatureFile, +} from './inspection-signature-file'; + +const CLOSURE_SCHEMA_VERSION = 'DH-ACT-CLOSURE-V2'; +const CONSENT_VERSION = 'D5-1'; +const INSPECTOR_CONSENT = 'Declaro que esta firma manuscrita fue realizada por mí y se incorpora al acta como constancia de mi intervención y conformidad con su contenido.'; +const COMPANY_CONSENT = 'Declaro haber leído o recibido explicación del contenido del acta y que esta firma manuscrita se incorpora como constancia de recepción, sin implicar aceptación de los hallazgos.'; + +interface ResponsibleView { + actId: string; + attendanceStatus: InspectionResponsibleAttendanceStatus; + fullName: string | null; + documentType: string | null; + documentNumber: string | null; + position: string | null; + email: string | null; + phone: string | null; + absenceReason: string | null; + updatedBy: string | null; + createdAt: Date; + updatedAt: Date; +} + +interface SignatureView { + id: string; + actId: string; + signerType: InspectionActSignerType; + signerUserId: string | null; + signerName: string; + documentType: string | null; + documentNumber: string | null; + position: string | null; + status: InspectionActSignatureStatus; + reason: string | null; + mimeType: string | null; + sizeBytes: number | null; + imageSha256: string | null; + consentText: string | null; + consentVersion: string | null; + consentAcceptedAt: Date | null; + clientSignedAt: Date | null; + signedAt: Date | null; + latitude: number | null; + longitude: number | null; + accuracyM: number | null; + deviceLabel: string | null; + source: InspectionActSignatureSource; + preparedSha256: string; + signaturePayloadSha256: string; + uploadedBy: string; + uploadedByUsername: string; + createdAt: Date; +} + +interface StoredSignature extends SignatureView { + originalName: string | null; + storedName: string | null; +} + +interface ClosureRecord { + actId: string; + schemaVersion: string; + preparedSnapshot: Record; + preparedSha256: string; + preparedAt: Date; + preparedBy: string; + finalSnapshot: Record | null; + finalSha256: string | null; + deviceClosedAt: Date | null; + serverClosedAt: Date | null; + uploadMode: InspectionActUploadMode | null; + closedBy: string | null; +} + +export interface InspectionClosureView { + act: { + id: string; + code: string; + status: InspectionActStatus; + visitId: string; + currentVersion: number; + closedAt: Date | null; + closedBy: string | null; + closureSha256: string | null; + }; + visit: { + id: string; + code: string; + status: InspectionVisitStatus; + actualClosedAt: Date | null; + }; + responsible: ResponsibleView | null; + closure: null | { + schemaVersion: string; + preparedSha256: string; + preparedAt: Date; + preparedBy: string; + finalSha256: string | null; + deviceClosedAt: Date | null; + serverClosedAt: Date | null; + uploadMode: InspectionActUploadMode | null; + closedBy: string | null; + isCurrent: boolean; + }; + signatures: SignatureView[]; + consents: { + version: string; + inspector: string; + company: string; + }; +} + +function actNotFound(): NotFoundException { + return new NotFoundException({ + code: 'INSPECTION_ACT_NOT_FOUND', + message: 'Acta de inspección no encontrada', + }); +} + +function signatureNotFound(): NotFoundException { + return new NotFoundException({ + code: 'INSPECTION_SIGNATURE_NOT_FOUND', + message: 'Firma de inspección no encontrada', + }); +} + +@Injectable() +export class InspectionClosingService { + private readonly signatureRoot: string; + + constructor( + private readonly dataSource: DataSource, + private readonly audit: AuditService, + private readonly reports: InspectionReportsService, + config: ConfigService, + ) { + const configured = config.get('INSPECTION_SIGNATURE_ROOT') + ?? '/app/storage/asset-media/inspection-signatures'; + if (!isAbsolute(configured)) { + throw new Error('INSPECTION_SIGNATURE_ROOT must be an absolute path'); + } + this.signatureRoot = resolve(configured); + if (this.signatureRoot === parse(this.signatureRoot).root) { + throw new Error('INSPECTION_SIGNATURE_ROOT cannot be the filesystem root'); + } + } + + async get(actId: string): Promise { + return this.loadView(this.dataSource.manager, actId); + } + + async upsertResponsible( + actId: string, + dto: UpsertInspectionResponsibleDto, + principal: AuthPrincipal, + request: RequestWithContext, + ): Promise { + assertMobileInspector(principal); + return this.dataSource.transaction(async (manager) => { + const { act, visit } = await this.lockContext(manager, actId); + this.assertDraftInProgress(act, visit); + await this.assertActorAssigned(manager, visit.id, principal, true); + const before = await this.loadResponsible(manager, actId); + const present = dto.attendanceStatus === InspectionResponsibleAttendanceStatus.PRESENT; + await manager.query(` + INSERT INTO inspection_act_responsibles ( + act_id, attendance_status, full_name, document_type, document_number, + position, email, phone, absence_reason, updated_by + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + ON CONFLICT (act_id) DO UPDATE SET + attendance_status = EXCLUDED.attendance_status, + full_name = EXCLUDED.full_name, + document_type = EXCLUDED.document_type, + document_number = EXCLUDED.document_number, + position = EXCLUDED.position, + email = EXCLUDED.email, + phone = EXCLUDED.phone, + absence_reason = EXCLUDED.absence_reason, + updated_by = EXCLUDED.updated_by + `, [ + actId, + dto.attendanceStatus, + present ? dto.fullName : null, + present ? dto.documentType : null, + present ? dto.documentNumber : null, + present ? dto.position : null, + dto.email?.toLowerCase() ?? null, + dto.phone ?? null, + present ? null : dto.absenceReason, + principal.userId, + ]); + const after = await this.loadResponsible(manager, actId); + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.INSPECTION_ACT_RESPONSIBLE_UPDATED, + entityType: 'inspection_act_responsible', + entityId: actId, + beforeData: before as unknown as Record | null, + afterData: after as unknown as Record, + metadata: { actId, visitId: visit.id }, + }, manager); + return this.loadView(manager, actId); + }); + } + + async prepare( + actId: string, + principal: AuthPrincipal, + request: RequestWithContext, + ): Promise { + assertMobileInspector(principal); + return this.dataSource.transaction(async (manager) => { + const { act, visit } = await this.lockContext(manager, actId); + this.assertDraftInProgress(act, visit); + await this.assertActorAssigned(manager, visit.id, principal, true); + await this.requireResponsible(manager, actId); + const [count] = (await manager.query(` + SELECT COUNT(*)::integer AS total + FROM inspection_findings + WHERE act_id = $1 AND status <> 'VOIDED' + `, [actId])) as Array<{ total: number }>; + const [verification] = (await manager.query(` + SELECT + COUNT(*)::integer AS total, + COUNT(*) FILTER (WHERE verification_link.outcome IS NOT NULL)::integer AS completed + FROM inspection_finding_verification_visits verification_link + WHERE verification_link.visit_id = $1 + `, [visit.id])) as Array<{ total: number; completed: number }>; + const verificationTotal = Number(verification?.total ?? 0); + const verificationCompleted = Number(verification?.completed ?? 0); + if (verificationTotal > 0 && verificationCompleted !== verificationTotal) { + throw new ConflictException({ + code: 'INSPECTION_VERIFICATION_RESULTS_REQUIRED', + message: 'Registrá el resultado de todos los hallazgos a verificar antes de preparar el acta', + }); + } + if (Number(count?.total ?? 0) < 1 && verificationTotal < 1) { + throw new ConflictException({ + code: 'INSPECTION_ACT_FINDING_REQUIRED', + message: 'El acta debe contener al menos un hallazgo o una verificación registrada antes de prepararse', + }); + } + const signatures = await this.signatureCount(manager, actId); + if (signatures > 0) { + throw new ConflictException({ + code: 'INSPECTION_ACT_ALREADY_SIGNED', + message: 'El acta ya tiene firmas y no puede volver a prepararse', + }); + } + const [updated] = (await manager.query(` + UPDATE inspection_acts + SET status = 'READY', + current_version = current_version + 1, + updated_by = $2, + updated_at = CURRENT_TIMESTAMP + WHERE id = $1 + RETURNING current_version AS "versionNumber" + `, [actId, principal.userId])) as Array<{ versionNumber: number }>; + const preparedAt = new Date(); + const preparedSnapshot = await this.buildPreparedSnapshot(manager, actId, preparedAt); + const preparedSha256 = sha256CanonicalJson(preparedSnapshot); + 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, + preparedAt, + 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: InspectionActStatus.READY, + preparedSha256, + schemaVersion: CLOSURE_SCHEMA_VERSION, + }, + metadata: { actId, visitId: visit.id, versionNumber: Number(updated.versionNumber) }, + }, manager); + return this.loadView(manager, actId); + }); + } + + async reopen( + actId: string, + principal: AuthPrincipal, + request: RequestWithContext, + ): Promise { + assertMobileInspector(principal); + return this.dataSource.transaction(async (manager) => { + const { act, visit } = await this.lockContext(manager, actId); + if (act.status !== InspectionActStatus.READY || visit.status !== InspectionVisitStatus.IN_PROGRESS) { + throw new ConflictException({ + code: 'INSPECTION_ACT_NOT_READY', + message: 'Sólo puede reabrirse un acta preparada de una visita en curso', + }); + } + await this.assertActorAssigned(manager, visit.id, principal, true); + if (await this.signatureCount(manager, actId)) { + throw new ConflictException({ + code: 'INSPECTION_ACT_SIGNED_CANNOT_REOPEN', + message: 'El acta ya tiene firmas y no puede volver a borrador', + }); + } + const closure = await this.requireClosure(manager, actId); + const [updated] = (await manager.query(` + UPDATE inspection_acts + SET status = 'DRAFT', + current_version = current_version + 1, + updated_by = $2, + updated_at = CURRENT_TIMESTAMP + WHERE id = $1 + RETURNING current_version AS "versionNumber" + `, [actId, principal.userId])) as Array<{ versionNumber: number }>; + const snapshot = { + actId, + actCode: act.code, + status: InspectionActStatus.DRAFT, + reopenedAt: new Date().toISOString(), + previousPreparedSha256: closure.preparedSha256, + }; + 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.REOPENED, + snapshot, + principal.userId, + principal.username, + ]); + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.INSPECTION_ACT_REOPENED, + entityType: 'inspection_act', + entityId: actId, + afterData: snapshot, + metadata: { actId, visitId: visit.id, versionNumber: Number(updated.versionNumber) }, + }, manager); + return this.loadView(manager, actId); + }); + } + + async signInspector( + actId: string, + dto: CreateInspectionSignatureDto, + file: UploadedInspectionSignatureFile | undefined, + principal: AuthPrincipal, + request: RequestWithContext, + ): Promise { + assertMobileInspector(principal); + const identity = { + signerType: InspectionActSignerType.INSPECTOR, + signerUserId: principal.userId, + signerName: `${principal.firstName} ${principal.lastName}`.trim(), + documentType: null, + documentNumber: null, + position: 'Inspector/a', + consentText: INSPECTOR_CONSENT, + auditAction: AuditAction.INSPECTION_ACT_SIGNATURE_RECORDED, + } as const; + return this.createSignedSignature(actId, dto, file, identity, principal, request, true); + } + + async signCompany( + actId: string, + dto: CreateInspectionSignatureDto, + file: UploadedInspectionSignatureFile | undefined, + principal: AuthPrincipal, + request: RequestWithContext, + ): Promise { + assertMobileInspector(principal); + return this.createSignedSignature(actId, dto, file, null, principal, request, false); + } + + async recordCompanyOutcome( + actId: string, + dto: CreateCompanyOutcomeDto, + principal: AuthPrincipal, + request: RequestWithContext, + ): Promise { + assertMobileInspector(principal); + return this.dataSource.transaction(async (manager) => { + const { act, visit } = await this.lockContext(manager, actId); + this.assertReadyInProgress(act, visit); + await this.assertActorAssigned(manager, visit.id, principal, true); + const responsible = await this.requireResponsible(manager, actId); + if ( + dto.status === InspectionActSignatureStatus.REFUSED + && responsible.attendanceStatus !== InspectionResponsibleAttendanceStatus.PRESENT + ) { + throw new BadRequestException({ + code: 'INSPECTION_COMPANY_REFUSAL_REQUIRES_PRESENCE', + message: 'La negativa a firmar requiere que el responsable esté presente', + }); + } + if ( + dto.status === InspectionActSignatureStatus.ABSENT + && responsible.attendanceStatus !== InspectionResponsibleAttendanceStatus.ABSENT + ) { + throw new BadRequestException({ + code: 'INSPECTION_COMPANY_ABSENCE_MISMATCH', + message: 'El resultado ausente requiere que el responsable se haya registrado como ausente', + }); + } + const closure = await this.requireClosure(manager, actId); + if (await this.hasCompanyOutcome(manager, actId)) { + throw new ConflictException({ + code: 'INSPECTION_ACT_COMPANY_OUTCOME_ALREADY_RECORDED', + message: 'La recepción de la empresa ya fue registrada y es inmutable', + }); + } + const source = this.signatureSource(principal); + const createdAt = new Date(); + const signerName = responsible.fullName ?? 'Responsable de la empresa no presente'; + const payload = { + actId, + preparedSha256: closure.preparedSha256, + signerType: InspectionActSignerType.COMPANY_RESPONSIBLE, + signerName, + documentType: responsible.documentType, + documentNumber: responsible.documentNumber, + position: responsible.position, + status: dto.status, + reason: dto.reason, + source, + uploadedBy: principal.userId, + createdAt: createdAt.toISOString(), + }; + const signaturePayloadSha256 = sha256CanonicalJson(payload); + const id = randomUUID(); + await manager.query(` + INSERT INTO inspection_act_signatures ( + id, act_id, signer_type, signer_user_id, signer_name, + document_type, document_number, position, status, reason, + source, prepared_sha256, signature_payload_sha256, uploaded_by, created_at + ) VALUES ( + $1, $2, $3, NULL, $4, $5, $6, $7, $8, $9, + $10, $11, $12, $13, $14 + ) + `, [ + id, + actId, + InspectionActSignerType.COMPANY_RESPONSIBLE, + signerName, + responsible.documentType, + responsible.documentNumber, + responsible.position, + dto.status, + dto.reason, + source, + closure.preparedSha256, + signaturePayloadSha256, + principal.userId, + createdAt, + ]); + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.INSPECTION_ACT_COMPANY_OUTCOME_RECORDED, + entityType: 'inspection_act_signature', + entityId: id, + afterData: payload, + metadata: { actId, visitId: visit.id, immutable: true, signaturePayloadSha256 }, + }, manager); + return this.loadView(manager, actId); + }); + } + + async close( + actId: string, + dto: CloseInspectionActDto, + principal: AuthPrincipal, + request: RequestWithContext, + ): Promise { + assertMobileInspector(principal); + const closed = await this.dataSource.transaction(async (manager) => { + const { act, visit } = await this.lockContext(manager, actId); + this.assertReadyInProgress(act, visit); + const closure = await this.requireClosure(manager, actId); + const signatures = await this.loadSignatures(manager, actId); + if (!signatures.some((item) => ( + item.signerType === InspectionActSignerType.INSPECTOR + && item.status === InspectionActSignatureStatus.SIGNED + ))) { + throw new ConflictException({ + code: 'INSPECTION_ACT_INSPECTOR_SIGNATURE_REQUIRED', + message: 'Se requiere al menos una firma de inspector para cerrar el acta', + }); + } + if (signatures.filter((item) => item.signerType === InspectionActSignerType.COMPANY_RESPONSIBLE).length !== 1) { + throw new ConflictException({ + code: 'INSPECTION_ACT_COMPANY_OUTCOME_REQUIRED', + message: 'Debe registrarse la firma, negativa o ausencia del responsable de la empresa', + }); + } + const serverClosedAt = new Date(); + const deviceClosedAt = new Date(dto.clientClosedAt); + if (deviceClosedAt.getTime() > serverClosedAt.getTime() + 24 * 60 * 60 * 1000) { + throw new BadRequestException({ + code: 'INSPECTION_ACT_INVALID_DEVICE_TIME', + message: 'La fecha informada por el dispositivo no puede estar más de 24 horas en el futuro', + }); + } + const signatureSnapshot = signatures.map((item) => ({ + id: item.id, + signerType: item.signerType, + signerUserId: item.signerUserId, + signerName: item.signerName, + documentType: item.documentType, + documentNumber: item.documentNumber, + position: item.position, + status: item.status, + reason: item.reason, + imageSha256: item.imageSha256, + consentText: item.consentText, + consentVersion: item.consentVersion, + consentAcceptedAt: item.consentAcceptedAt, + clientSignedAt: item.clientSignedAt, + signedAt: item.signedAt, + latitude: item.latitude, + longitude: item.longitude, + accuracyM: item.accuracyM, + deviceLabel: item.deviceLabel, + source: item.source, + preparedSha256: item.preparedSha256, + signaturePayloadSha256: item.signaturePayloadSha256, + uploadedBy: item.uploadedBy, + createdAt: item.createdAt, + })); + const finalSnapshot = { + schemaVersion: CLOSURE_SCHEMA_VERSION, + preparedSha256: closure.preparedSha256, + preparedSnapshot: closure.preparedSnapshot, + signatures: signatureSnapshot, + closure: { + deviceClosedAt: deviceClosedAt.toISOString(), + serverClosedAt: serverClosedAt.toISOString(), + uploadMode: dto.uploadMode, + closedBy: principal.userId, + closedByUsername: principal.username, + }, + }; + const finalSha256 = sha256CanonicalJson(finalSnapshot); + await manager.query(` + UPDATE inspection_act_closures + SET final_snapshot = $2, + final_sha256 = $3, + device_closed_at = $4, + server_closed_at = $5, + upload_mode = $6, + closed_by = $7 + WHERE act_id = $1 + `, [ + actId, + finalSnapshot, + finalSha256, + deviceClosedAt, + serverClosedAt, + dto.uploadMode, + principal.userId, + ]); + const [updated] = (await manager.query(` + UPDATE inspection_acts + SET status = 'CLOSED', + closed_at = $2, + closed_by = $3, + closure_sha256 = $4, + current_version = current_version + 1, + updated_by = $3, + updated_at = $2 + WHERE id = $1 + RETURNING current_version AS "versionNumber" + `, [actId, serverClosedAt, principal.userId, finalSha256])) as Array<{ versionNumber: number }>; + await manager.query(` + UPDATE inspection_visits + SET status = 'CLOSED', + actual_closed_at = $2, + updated_by = $3, + updated_at = $2 + WHERE id = $1 + `, [visit.id, serverClosedAt, 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.CLOSED, + finalSnapshot, + principal.userId, + principal.username, + ]); + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.INSPECTION_ACT_CLOSED, + entityType: 'inspection_act', + entityId: actId, + afterData: { + status: InspectionActStatus.CLOSED, + closureSha256: finalSha256, + serverClosedAt, + uploadMode: dto.uploadMode, + }, + metadata: { + actId, + visitId: visit.id, + versionNumber: Number(updated.versionNumber), + findingsRemainOpen: true, + }, + }, manager); + await this.reports.ensureFrozenReport(manager, actId, principal, request); + return this.loadView(manager, actId); + }); + await this.reports.ensureWordForAct(actId); + return closed; + } + + async signatureContent(signatureId: string): Promise<{ + filePath: string; + signature: StoredSignature; + }> { + const signature = await this.loadStoredSignature(this.dataSource.manager, signatureId); + if (signature.status !== InspectionActSignatureStatus.SIGNED || !signature.storedName) { + throw signatureNotFound(); + } + const filePath = resolve(this.signatureRoot, signature.storedName); + if (!filePath.startsWith(`${this.signatureRoot}/`)) { + throw new InternalServerErrorException({ + code: 'INVALID_INSPECTION_SIGNATURE_STORAGE_PATH', + message: 'Ruta de almacenamiento de firma inválida', + }); + } + try { + const fileStat = await stat(filePath); + if (!fileStat.isFile() || fileStat.size !== signature.sizeBytes) throw new Error('size mismatch'); + } catch { + throw new InternalServerErrorException({ + code: 'INSPECTION_SIGNATURE_FILE_MISSING', + message: 'El archivo físico de la firma no está disponible', + }); + } + return { filePath, signature }; + } + + private async createSignedSignature( + actId: string, + dto: CreateInspectionSignatureDto, + file: UploadedInspectionSignatureFile | undefined, + fixedIdentity: null | { + signerType: InspectionActSignerType; + signerUserId: string; + signerName: string; + documentType: null; + documentNumber: null; + position: string; + consentText: string; + auditAction: AuditAction; + }, + principal: AuthPrincipal, + request: RequestWithContext, + requireSelfAssignment: boolean, + ): Promise { + this.validateCoordinates(dto.latitude, dto.longitude, dto.accuracyM); + const inspected = inspectInspectionSignatureFile(file); + const id = randomUUID(); + const storedName = `${id}.png`; + const filePath = resolve(this.signatureRoot, storedName); + const imageSha256 = createHash('sha256').update(file!.buffer).digest('hex'); + await mkdir(this.signatureRoot, { recursive: true, mode: 0o700 }); + await writeFile(filePath, file!.buffer, { flag: 'wx', mode: 0o600 }); + try { + return await this.dataSource.transaction(async (manager) => { + const { act, visit } = await this.lockContext(manager, actId); + this.assertReadyInProgress(act, visit); + await this.assertActorAssigned(manager, visit.id, principal, !requireSelfAssignment); + const closure = await this.requireClosure(manager, actId); + const responsible = fixedIdentity ? null : await this.requireResponsible(manager, actId); + if (responsible && responsible.attendanceStatus !== InspectionResponsibleAttendanceStatus.PRESENT) { + throw new ConflictException({ + code: 'INSPECTION_COMPANY_RESPONSIBLE_ABSENT', + message: 'No puede registrarse firma de empresa cuando el responsable está ausente', + }); + } + const identity = fixedIdentity ?? { + signerType: InspectionActSignerType.COMPANY_RESPONSIBLE, + signerUserId: null, + signerName: responsible!.fullName!, + documentType: responsible!.documentType, + documentNumber: responsible!.documentNumber, + position: responsible!.position, + consentText: COMPANY_CONSENT, + auditAction: AuditAction.INSPECTION_ACT_SIGNATURE_RECORDED, + }; + if (identity.signerType === InspectionActSignerType.INSPECTOR) { + const [existing] = (await manager.query(` + SELECT 1 + FROM inspection_act_signatures + WHERE act_id = $1 AND signer_type = 'INSPECTOR' AND signer_user_id = $2 + `, [actId, identity.signerUserId])) as unknown[]; + if (existing) { + throw new ConflictException({ + code: 'INSPECTION_ACT_INSPECTOR_ALREADY_SIGNED', + message: 'El inspector actual ya firmó esta acta', + }); + } + } else if (await this.hasCompanyOutcome(manager, actId)) { + throw new ConflictException({ + code: 'INSPECTION_ACT_COMPANY_OUTCOME_ALREADY_RECORDED', + message: 'La recepción de la empresa ya fue registrada y es inmutable', + }); + } + const source = this.signatureSource(principal); + const signedAt = new Date(); + const clientSignedAt = dto.clientSignedAt ? new Date(dto.clientSignedAt) : null; + const payload = { + actId, + preparedSha256: closure.preparedSha256, + signerType: identity.signerType, + signerUserId: identity.signerUserId, + signerName: identity.signerName, + documentType: identity.documentType, + documentNumber: identity.documentNumber, + position: identity.position, + status: InspectionActSignatureStatus.SIGNED, + imageSha256, + consentText: identity.consentText, + consentVersion: CONSENT_VERSION, + consentAcceptedAt: signedAt.toISOString(), + clientSignedAt: clientSignedAt?.toISOString() ?? null, + signedAt: signedAt.toISOString(), + latitude: dto.latitude ?? null, + longitude: dto.longitude ?? null, + accuracyM: dto.accuracyM ?? null, + deviceLabel: dto.deviceLabel?.trim() || null, + source, + uploadedBy: principal.userId, + }; + const signaturePayloadSha256 = sha256CanonicalJson(payload); + await manager.query(` + INSERT INTO inspection_act_signatures ( + id, act_id, signer_type, signer_user_id, signer_name, + document_type, document_number, position, status, + original_name, stored_name, mime_type, size_bytes, image_sha256, + consent_text, consent_version, consent_accepted_at, + client_signed_at, signed_at, latitude, longitude, accuracy_m, + device_label, source, prepared_sha256, signature_payload_sha256, + uploaded_by, created_at + ) VALUES ( + $1, $2, $3, $4, $5, + $6, $7, $8, 'SIGNED', + $9, $10, $11, $12, $13, + $14, $15, $16, + $17, $18, $19, $20, $21, + $22, $23, $24, $25, + $26, $18 + ) + `, [ + id, + actId, + identity.signerType, + identity.signerUserId, + identity.signerName, + identity.documentType, + identity.documentNumber, + identity.position, + inspected.originalName, + storedName, + inspected.mimeType, + file!.buffer.length, + imageSha256, + identity.consentText, + CONSENT_VERSION, + signedAt, + clientSignedAt, + signedAt, + dto.latitude ?? null, + dto.longitude ?? null, + dto.accuracyM ?? null, + dto.deviceLabel?.trim() || null, + source, + closure.preparedSha256, + signaturePayloadSha256, + principal.userId, + ]); + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: identity.auditAction, + entityType: 'inspection_act_signature', + entityId: id, + afterData: payload, + metadata: { + actId, + visitId: visit.id, + immutable: true, + signaturePayloadSha256, + }, + }, manager); + return this.loadView(manager, actId); + }); + } catch (error) { + await unlink(filePath).catch(() => undefined); + throw error; + } + } + + private async buildPreparedSnapshot( + manager: EntityManager, + actId: string, + preparedAt: Date, + ): Promise> { + 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.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>; + if (!act) throw actNotFound(); + const responsible = await this.requireResponsible(manager, actId); + 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>; + 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>; + 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.correction_due_on AS "correctionDueOn", + finding.company_response AS "companyResponse", + finding.company_response_received_on AS "companyResponseReceivedOn", + finding.company_committed_correction_on AS "companyCommittedCorrectionOn", + 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, + COALESCE(( + SELECT JSONB_AGG(JSONB_BUILD_OBJECT( + 'id', communication.id, + 'direction', communication.direction, + 'channel', communication.channel, + 'type', communication.type, + 'occurredAt', communication.occurred_at, + 'subject', communication.subject, + 'details', communication.details, + 'contactName', communication.contact_name, + 'contactEmail', communication.contact_email, + 'createdBy', communication.created_by, + 'createdAt', communication.created_at + ) ORDER BY communication.occurred_at, communication.id) + FROM inspection_finding_communications communication + WHERE communication.finding_id = finding.id + ), '[]'::jsonb) AS communications + 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>; + 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", + COALESCE(( + SELECT JSONB_AGG(JSONB_BUILD_OBJECT( + 'id', evidence.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 + AND evidence.verification_visit_id = $1 + AND evidence.purpose = 'VERIFICATION' + ), '[]'::jsonb) AS evidence + 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>; + return { + schemaVersion: CLOSURE_SCHEMA_VERSION, + preparedAt: preparedAt.toISOString(), + act, + responsible, + team, + assets, + findings, + verificationResults, + }; + } + + private async loadView(manager: EntityManager, actId: string): Promise { + const [context] = (await manager.query(` + SELECT + act.id, + act.code, + act.status, + act.visit_id AS "visitId", + act.current_version AS "currentVersion", + act.closed_at AS "closedAt", + act.closed_by AS "closedBy", + act.closure_sha256 AS "closureSha256", + visit.code AS "visitCode", + visit.status AS "visitStatus", + visit.actual_closed_at AS "visitActualClosedAt" + FROM inspection_acts act + INNER JOIN inspection_visits visit ON visit.id = act.visit_id + WHERE act.id = $1 + `, [actId])) as Array<{ + id: string; + code: string; + status: InspectionActStatus; + visitId: string; + currentVersion: number; + closedAt: Date | null; + closedBy: string | null; + closureSha256: string | null; + visitCode: string; + visitStatus: InspectionVisitStatus; + visitActualClosedAt: Date | null; + }>; + if (!context) throw actNotFound(); + const responsible = await this.loadResponsible(manager, actId); + const closure = await this.loadClosure(manager, actId); + const signatures = await this.loadSignatures(manager, actId); + return { + act: { + id: context.id, + code: context.code, + status: context.status, + visitId: context.visitId, + currentVersion: Number(context.currentVersion), + closedAt: context.closedAt, + closedBy: context.closedBy, + closureSha256: context.closureSha256, + }, + visit: { + id: context.visitId, + code: context.visitCode, + status: context.visitStatus, + actualClosedAt: context.visitActualClosedAt, + }, + responsible, + closure: closure ? { + schemaVersion: closure.schemaVersion, + preparedSha256: closure.preparedSha256, + preparedAt: closure.preparedAt, + preparedBy: closure.preparedBy, + finalSha256: closure.finalSha256, + deviceClosedAt: closure.deviceClosedAt, + serverClosedAt: closure.serverClosedAt, + uploadMode: closure.uploadMode, + closedBy: closure.closedBy, + isCurrent: context.status === InspectionActStatus.READY + || context.status === InspectionActStatus.CLOSED, + } : null, + signatures, + consents: { + version: CONSENT_VERSION, + inspector: INSPECTOR_CONSENT, + company: COMPANY_CONSENT, + }, + }; + } + + private async loadResponsible(manager: EntityManager, actId: string): Promise { + const [row] = (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 ResponsibleView[]; + return row ?? null; + } + + private async requireResponsible(manager: EntityManager, actId: string): Promise { + const responsible = await this.loadResponsible(manager, actId); + if (!responsible) { + throw new ConflictException({ + code: 'INSPECTION_ACT_RESPONSIBLE_REQUIRED', + message: 'Debe identificarse al responsable presente o documentar su ausencia', + }); + } + return responsible; + } + + private async loadClosure(manager: EntityManager, actId: string): Promise { + const [row] = (await manager.query(` + SELECT + act_id AS "actId", + schema_version AS "schemaVersion", + prepared_snapshot AS "preparedSnapshot", + prepared_sha256 AS "preparedSha256", + prepared_at AS "preparedAt", + prepared_by AS "preparedBy", + final_snapshot AS "finalSnapshot", + final_sha256 AS "finalSha256", + device_closed_at AS "deviceClosedAt", + server_closed_at AS "serverClosedAt", + upload_mode AS "uploadMode", + closed_by AS "closedBy" + FROM inspection_act_closures + WHERE act_id = $1 + `, [actId])) as ClosureRecord[]; + return row ?? null; + } + + private async requireClosure(manager: EntityManager, actId: string): Promise { + const closure = await this.loadClosure(manager, actId); + if (!closure) { + throw new ConflictException({ + code: 'INSPECTION_ACT_NOT_PREPARED', + message: 'El acta todavía no fue preparada para firmas', + }); + } + return closure; + } + + private signatureSelect(): string { + return ` + SELECT + signature.id, + signature.act_id AS "actId", + signature.signer_type AS "signerType", + signature.signer_user_id AS "signerUserId", + signature.signer_name AS "signerName", + signature.document_type AS "documentType", + signature.document_number AS "documentNumber", + signature.position, + signature.status, + signature.reason, + signature.original_name AS "originalName", + signature.stored_name AS "storedName", + signature.mime_type AS "mimeType", + signature.size_bytes::integer AS "sizeBytes", + signature.image_sha256 AS "imageSha256", + signature.consent_text AS "consentText", + signature.consent_version AS "consentVersion", + signature.consent_accepted_at AS "consentAcceptedAt", + signature.client_signed_at AS "clientSignedAt", + signature.signed_at AS "signedAt", + signature.latitude::double precision AS latitude, + signature.longitude::double precision AS longitude, + signature.accuracy_m::double precision AS "accuracyM", + signature.device_label AS "deviceLabel", + signature.source, + signature.prepared_sha256 AS "preparedSha256", + signature.signature_payload_sha256 AS "signaturePayloadSha256", + signature.uploaded_by AS "uploadedBy", + uploader.username AS "uploadedByUsername", + signature.created_at AS "createdAt" + FROM inspection_act_signatures signature + INNER JOIN users uploader ON uploader.id = signature.uploaded_by + `; + } + + private async loadSignatures(manager: EntityManager, actId: string): Promise { + const rows = await manager.query(` + ${this.signatureSelect()} + WHERE signature.act_id = $1 + ORDER BY signature.signer_type, signature.created_at, signature.id + `, [actId]) as StoredSignature[]; + return rows.map(({ storedName: _storedName, originalName: _originalName, ...row }) => row); + } + + private async loadStoredSignature(manager: EntityManager, id: string): Promise { + const [row] = await manager.query(` + ${this.signatureSelect()} + WHERE signature.id = $1 + `, [id]) as StoredSignature[]; + if (!row) throw signatureNotFound(); + return row; + } + + private async signatureCount(manager: EntityManager, actId: string): Promise { + const [row] = (await manager.query(` + SELECT COUNT(*)::integer AS total + FROM inspection_act_signatures + WHERE act_id = $1 + `, [actId])) as Array<{ total: number }>; + return Number(row?.total ?? 0); + } + + private async hasCompanyOutcome(manager: EntityManager, actId: string): Promise { + const rows = await manager.query(` + SELECT 1 + FROM inspection_act_signatures + WHERE act_id = $1 AND signer_type = 'COMPANY_RESPONSIBLE' + LIMIT 1 + `, [actId]) as unknown[]; + return rows.length > 0; + } + + private async lockContext( + manager: EntityManager, + actId: string, + ): Promise<{ act: InspectionAct; visit: InspectionVisit }> { + const act = await manager.getRepository(InspectionAct) + .createQueryBuilder('act') + .where('act.id = :actId', { actId }) + .setLock('pessimistic_write') + .getOne(); + if (!act) throw actNotFound(); + const visit = await manager.getRepository(InspectionVisit) + .createQueryBuilder('visit') + .where('visit.id = :visitId', { visitId: act.visitId }) + .setLock('pessimistic_write') + .getOne(); + if (!visit) throw actNotFound(); + return { act, visit }; + } + + private assertDraftInProgress(act: InspectionAct, visit: InspectionVisit): void { + if (act.status !== InspectionActStatus.DRAFT || visit.status !== InspectionVisitStatus.IN_PROGRESS) { + throw new ConflictException({ + code: 'INSPECTION_ACT_NOT_EDITABLE', + message: 'El responsable sólo puede modificarse con el acta en borrador y la visita en curso', + }); + } + } + + private assertReadyInProgress(act: InspectionAct, visit: InspectionVisit): void { + if (act.status !== InspectionActStatus.READY || visit.status !== InspectionVisitStatus.IN_PROGRESS) { + throw new ConflictException({ + code: 'INSPECTION_ACT_NOT_READY', + message: 'El acta debe estar preparada y la visita en curso', + }); + } + } + + private async assertActorAssigned( + manager: EntityManager, + visitId: string, + principal: AuthPrincipal, + allowManager: boolean, + ): Promise { + if (allowManager && principal.permissions.includes('inspections.manage')) return; + const rows = await manager.query(` + SELECT 1 + FROM inspection_visit_members + WHERE visit_id = $1 AND user_id = $2 AND included = true + `, [visitId, principal.userId]) as unknown[]; + if (rows.length === 0) { + throw new ForbiddenException({ + code: 'INSPECTION_VISIT_NOT_ASSIGNED', + message: 'Sólo un integrante asignado a la visita puede realizar esta firma', + }); + } + } + + private signatureSource(principal: AuthPrincipal): InspectionActSignatureSource { + return principal.transport === 'bearer' + ? InspectionActSignatureSource.ANDROID + : InspectionActSignatureSource.WEB; + } + + private validateCoordinates( + latitude: number | undefined, + longitude: number | undefined, + accuracyM: number | undefined, + ): void { + const hasLatitude = latitude !== undefined; + const hasLongitude = longitude !== undefined; + if (hasLatitude !== hasLongitude) { + throw new BadRequestException({ + code: 'INVALID_INSPECTION_SIGNATURE_COORDINATES', + message: 'Latitud y longitud deben informarse juntas', + }); + } + if (accuracyM !== undefined && !hasLatitude) { + throw new BadRequestException({ + code: 'INSPECTION_SIGNATURE_ACCURACY_WITHOUT_COORDINATES', + message: 'La precisión requiere coordenadas', + }); + } + } +} diff --git a/api-v3/src/inspection-closing/inspection-signature-file.ts b/api-v3/src/inspection-closing/inspection-signature-file.ts new file mode 100644 index 0000000..a7228bb --- /dev/null +++ b/api-v3/src/inspection-closing/inspection-signature-file.ts @@ -0,0 +1,49 @@ +import { BadRequestException } from '@nestjs/common'; + +export const MAX_INSPECTION_SIGNATURE_BYTES = 1024 * 1024; + +export interface UploadedInspectionSignatureFile { + originalname: string; + mimetype: string; + size: number; + buffer: Buffer; +} + +export interface InspectedInspectionSignatureFile { + originalName: string; + mimeType: 'image/png'; + extension: '.png'; +} + +export function inspectInspectionSignatureFile( + file: UploadedInspectionSignatureFile | undefined, +): InspectedInspectionSignatureFile { + if (!file?.buffer?.length) { + throw new BadRequestException({ + code: 'INSPECTION_SIGNATURE_FILE_REQUIRED', + message: 'La firma manuscrita en formato PNG es obligatoria', + }); + } + if (file.buffer.length > MAX_INSPECTION_SIGNATURE_BYTES) { + throw new BadRequestException({ + code: 'INSPECTION_SIGNATURE_FILE_TOO_LARGE', + message: 'La firma no puede superar 1 MB', + }); + } + const pngSignature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); + if ( + file.mimetype !== 'image/png' + || file.buffer.length < pngSignature.length + || !file.buffer.subarray(0, pngSignature.length).equals(pngSignature) + ) { + throw new BadRequestException({ + code: 'INSPECTION_SIGNATURE_PNG_REQUIRED', + message: 'La firma debe ser una imagen PNG válida', + }); + } + return { + originalName: (file.originalname || 'firma.png').slice(0, 255), + mimeType: 'image/png', + extension: '.png', + }; +} diff --git a/api-v3/src/inspection-findings/dto/close-inspection-finding.dto.ts b/api-v3/src/inspection-findings/dto/close-inspection-finding.dto.ts new file mode 100644 index 0000000..c4a5ced --- /dev/null +++ b/api-v3/src/inspection-findings/dto/close-inspection-finding.dto.ts @@ -0,0 +1,13 @@ +import { Transform } from 'class-transformer'; +import { IsString, MaxLength, MinLength } from 'class-validator'; + +const normalizeText = ({ value }: { value: unknown }) => + typeof value === 'string' ? value.trim() : value; + +export class CloseInspectionFindingDto { + @Transform(normalizeText) + @IsString() + @MinLength(3) + @MaxLength(5000) + closureNotes!: string; +} diff --git a/api-v3/src/inspection-findings/dto/create-finding-catalog-item.dto.ts b/api-v3/src/inspection-findings/dto/create-finding-catalog-item.dto.ts new file mode 100644 index 0000000..d299f11 --- /dev/null +++ b/api-v3/src/inspection-findings/dto/create-finding-catalog-item.dto.ts @@ -0,0 +1,54 @@ +import { Transform, Type } from 'class-transformer'; +import { IsInt, IsOptional, IsString, IsUUID, Matches, Max, MaxLength, Min, MinLength } from 'class-validator'; + +const trimNullable = ({ value }: { value: unknown }) => + typeof value === 'string' ? value.trim() || null : value; + +export class CreateFindingCatalogItemDto { + @IsUUID('4') + categoryId!: string; + + @Transform(({ value }) => typeof value === 'string' ? value.trim().toUpperCase() : value) + @IsString() + @MinLength(2) + @MaxLength(120) + @Matches(/^[A-Z][A-Z0-9_]+$/) + code!: string; + + @Type(() => Number) + @IsInt() + @Min(1) + @Max(999999) + sourceNumber!: number; + + @Transform(({ value }) => typeof value === 'string' ? value.trim() : value) + @IsString() + @MinLength(1) + @MaxLength(500) + title!: string; + + @IsOptional() + @Transform(trimNullable) + @IsString() + @MaxLength(12000) + legalBasis?: string | null; + + @IsOptional() + @Transform(trimNullable) + @IsString() + @MaxLength(12000) + glossary?: string | null; + + @IsOptional() + @Transform(trimNullable) + @IsString() + @MaxLength(4000) + importNote?: string | null; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(10) + suggestedSeverity?: number | null; +} diff --git a/api-v3/src/inspection-findings/dto/create-finding-category.dto.ts b/api-v3/src/inspection-findings/dto/create-finding-category.dto.ts new file mode 100644 index 0000000..4b550f5 --- /dev/null +++ b/api-v3/src/inspection-findings/dto/create-finding-category.dto.ts @@ -0,0 +1,23 @@ +import { Transform, Type } from 'class-transformer'; +import { IsInt, IsString, Matches, Max, MaxLength, Min, MinLength } from 'class-validator'; + +export class CreateFindingCategoryDto { + @Transform(({ value }) => typeof value === 'string' ? value.trim().toUpperCase() : value) + @IsString() + @MinLength(2) + @MaxLength(80) + @Matches(/^[A-Z][A-Z0-9_]+$/) + code!: string; + + @Transform(({ value }) => typeof value === 'string' ? value.trim() : value) + @IsString() + @MinLength(1) + @MaxLength(200) + name!: string; + + @Type(() => Number) + @IsInt() + @Min(0) + @Max(10000) + sortOrder!: number; +} diff --git a/api-v3/src/inspection-findings/dto/create-inspection-communication.dto.ts b/api-v3/src/inspection-findings/dto/create-inspection-communication.dto.ts new file mode 100644 index 0000000..f8b0d96 --- /dev/null +++ b/api-v3/src/inspection-findings/dto/create-inspection-communication.dto.ts @@ -0,0 +1,59 @@ +import { Transform } from 'class-transformer'; +import { + IsEmail, + IsEnum, + IsISO8601, + IsOptional, + IsString, + MaxLength, + MinLength, +} from 'class-validator'; +import { + InspectionCommunicationChannel, + InspectionCommunicationDirection, + InspectionCommunicationType, +} from '../../database/entities'; + +const trim = ({ value }: { value: unknown }) => + typeof value === 'string' ? value.trim() : value; + +const optionalText = ({ value }: { value: unknown }) => + typeof value === 'string' && value.trim() ? value.trim() : null; + +export class CreateInspectionCommunicationDto { + @IsEnum(InspectionCommunicationDirection) + direction!: InspectionCommunicationDirection; + + @IsEnum(InspectionCommunicationChannel) + channel!: InspectionCommunicationChannel; + + @IsEnum(InspectionCommunicationType) + type!: InspectionCommunicationType; + + @IsISO8601({ strict: true }) + occurredAt!: string; + + @Transform(trim) + @IsString() + @MinLength(1) + @MaxLength(250) + subject!: string; + + @IsOptional() + @Transform(optionalText) + @IsString() + @MaxLength(10000) + details?: string | null; + + @IsOptional() + @Transform(optionalText) + @IsString() + @MaxLength(200) + contactName?: string | null; + + @IsOptional() + @Transform(optionalText) + @IsEmail() + @MaxLength(320) + contactEmail?: string | null; +} diff --git a/api-v3/src/inspection-findings/dto/create-inspection-evidence.dto.ts b/api-v3/src/inspection-findings/dto/create-inspection-evidence.dto.ts new file mode 100644 index 0000000..ea4e132 --- /dev/null +++ b/api-v3/src/inspection-findings/dto/create-inspection-evidence.dto.ts @@ -0,0 +1,72 @@ +import { Type } from 'class-transformer'; +import { + IsEnum, + IsISO8601, + IsNumber, + IsOptional, + IsString, + IsUUID, + Max, + MaxLength, + Min, +} from 'class-validator'; +import { + InspectionEvidenceKind, + InspectionEvidencePurpose, +} from '../../database/entities'; + +export class CreateInspectionEvidenceDto { + @IsEnum(InspectionEvidenceKind) + kind!: InspectionEvidenceKind; + + @IsEnum(InspectionEvidencePurpose) + purpose!: InspectionEvidencePurpose; + + @IsOptional() + @IsUUID('4') + communicationId?: string; + + @IsOptional() + @IsUUID('4') + verificationVisitId?: string; + + @IsOptional() + @IsString() + @MaxLength(200) + title?: string; + + @IsOptional() + @IsString() + @MaxLength(4000) + description?: string; + + @IsOptional() + @IsISO8601({ strict: true }) + capturedAt?: string; + + @IsOptional() + @Type(() => Number) + @IsNumber({ maxDecimalPlaces: 6 }) + @Min(-90) + @Max(90) + latitude?: number; + + @IsOptional() + @Type(() => Number) + @IsNumber({ maxDecimalPlaces: 6 }) + @Min(-180) + @Max(180) + longitude?: number; + + @IsOptional() + @Type(() => Number) + @IsNumber({ maxDecimalPlaces: 3 }) + @Min(0) + @Max(100000) + accuracyM?: number; + + @IsOptional() + @IsString() + @MaxLength(200) + deviceLabel?: string; +} diff --git a/api-v3/src/inspection-findings/dto/create-inspection-finding.dto.ts b/api-v3/src/inspection-findings/dto/create-inspection-finding.dto.ts new file mode 100644 index 0000000..5f006e4 --- /dev/null +++ b/api-v3/src/inspection-findings/dto/create-inspection-finding.dto.ts @@ -0,0 +1,57 @@ +import { Transform, Type } from 'class-transformer'; +import { + IsInt, + IsOptional, + IsString, + IsUUID, + Matches, + Max, + MaxLength, + Min, + MinLength, + ValidateIf, +} from 'class-validator'; + +const optionalText = ({ value }: { value: unknown }) => + typeof value === 'string' && value.trim() ? value.trim() : null; + +export class CreateInspectionFindingDto { + @IsUUID('4') + assetId!: string; + + @IsOptional() + @Transform(optionalText) + @IsUUID('4') + catalogItemId?: string | null; + + @ValidateIf((value: CreateInspectionFindingDto) => !value.catalogItemId) + @Transform(optionalText) + @IsString() + @MinLength(1) + @MaxLength(500) + customTitle?: string | null; + + @IsOptional() + @Transform(optionalText) + @IsString() + @MaxLength(12000) + customLegalBasis?: string | null; + + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) + @IsString() + @MinLength(1) + @MaxLength(20000) + description!: string; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(10) + severity?: number; + + @IsOptional() + @Transform(optionalText) + @Matches(/^\d{4}-\d{2}-\d{2}$/) + correctionDueOn?: string | null; +} diff --git a/api-v3/src/inspection-findings/dto/list-finding-catalog-proposals-query.dto.ts b/api-v3/src/inspection-findings/dto/list-finding-catalog-proposals-query.dto.ts new file mode 100644 index 0000000..eb86250 --- /dev/null +++ b/api-v3/src/inspection-findings/dto/list-finding-catalog-proposals-query.dto.ts @@ -0,0 +1,12 @@ +import { IsEnum, IsOptional, IsUUID } from 'class-validator'; +import { FindingCatalogProposalStatus } from '../../database/entities'; + +export class ListFindingCatalogProposalsQueryDto { + @IsOptional() + @IsEnum(FindingCatalogProposalStatus) + status?: FindingCatalogProposalStatus; + + @IsOptional() + @IsUUID('4') + assetTypeId?: string; +} diff --git a/api-v3/src/inspection-findings/dto/list-finding-catalog-query.dto.ts b/api-v3/src/inspection-findings/dto/list-finding-catalog-query.dto.ts new file mode 100644 index 0000000..572c07c --- /dev/null +++ b/api-v3/src/inspection-findings/dto/list-finding-catalog-query.dto.ts @@ -0,0 +1,14 @@ +import { Transform } from 'class-transformer'; +import { IsOptional, IsString, IsUUID, MaxLength } from 'class-validator'; + +export class ListFindingCatalogQueryDto { + @IsOptional() + @IsUUID('4') + categoryId?: string; + + @IsOptional() + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) + @IsString() + @MaxLength(200) + search?: string; +} diff --git a/api-v3/src/inspection-findings/dto/list-inspection-findings-query.dto.ts b/api-v3/src/inspection-findings/dto/list-inspection-findings-query.dto.ts new file mode 100644 index 0000000..85d397f --- /dev/null +++ b/api-v3/src/inspection-findings/dto/list-inspection-findings-query.dto.ts @@ -0,0 +1,64 @@ +import { Transform, Type } from 'class-transformer'; +import { IsIn, IsInt, IsOptional, IsString, IsUUID, Matches, Max, MaxLength, Min } from 'class-validator'; + +export const FINDING_WORKFLOWS = [ + 'ALL', + 'OPEN', + 'WAITING_COMPANY', + 'COMPANY_OVERDUE', + 'TO_SCHEDULE_VERIFICATION', + 'TO_VERIFY', + 'VERIFICATION_OVERDUE', + 'READY_TO_CLOSE', + 'CLOSED', +] as const; + +export type FindingWorkflow = typeof FINDING_WORKFLOWS[number]; + +const normalizeText = ({ value }: { value: unknown }) => + typeof value === 'string' ? value.trim() : value; + +export class ListInspectionFindingsQueryDto { + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page = 1; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + pageSize = 25; + + @IsOptional() + @Transform(normalizeText) + @IsString() + @MaxLength(180) + search?: string; + + @IsOptional() + @IsIn(FINDING_WORKFLOWS) + workflow: FindingWorkflow = 'OPEN'; + + @IsOptional() + @IsUUID('4') + companyId?: string; + + @IsOptional() + @IsUUID('4') + areaId?: string; + + @IsOptional() + @IsUUID('4') + inspectorId?: string; + + @IsOptional() + @Matches(/^\d{4}-\d{2}-\d{2}$/) + dateFrom?: string; + + @IsOptional() + @Matches(/^\d{4}-\d{2}-\d{2}$/) + dateTo?: string; +} diff --git a/api-v3/src/inspection-findings/dto/replace-finding-catalog-selection.dto.ts b/api-v3/src/inspection-findings/dto/replace-finding-catalog-selection.dto.ts new file mode 100644 index 0000000..fc13adf --- /dev/null +++ b/api-v3/src/inspection-findings/dto/replace-finding-catalog-selection.dto.ts @@ -0,0 +1,15 @@ +import { Transform } from 'class-transformer'; +import { ArrayUnique, IsArray, IsString, IsUUID, MaxLength, MinLength } from 'class-validator'; + +export class ReplaceFindingCatalogSelectionDto { + @IsArray() + @ArrayUnique() + @IsUUID('4', { each: true }) + enabledItemIds!: string[]; + + @Transform(({ value }) => typeof value === 'string' ? value.trim() : value) + @IsString() + @MinLength(5) + @MaxLength(2000) + reason!: string; +} diff --git a/api-v3/src/inspection-findings/dto/review-finding-catalog-proposal.dto.ts b/api-v3/src/inspection-findings/dto/review-finding-catalog-proposal.dto.ts new file mode 100644 index 0000000..33386c3 --- /dev/null +++ b/api-v3/src/inspection-findings/dto/review-finding-catalog-proposal.dto.ts @@ -0,0 +1,23 @@ +import { Transform } from 'class-transformer'; +import { IsEnum, IsOptional, IsString, IsUUID, MaxLength, MinLength, ValidateIf } from 'class-validator'; + +export enum FindingCatalogProposalDecision { + MATCH = 'MATCH', + REJECT = 'REJECT', +} + +export class ReviewFindingCatalogProposalDto { + @IsEnum(FindingCatalogProposalDecision) + decision!: FindingCatalogProposalDecision; + + @ValidateIf((value: ReviewFindingCatalogProposalDto) => value.decision === FindingCatalogProposalDecision.MATCH) + @IsUUID('4') + catalogItemId?: string; + + @IsOptional() + @Transform(({ value }) => typeof value === 'string' ? value.trim() || null : value) + @IsString() + @MinLength(5) + @MaxLength(4000) + notes?: string | null; +} diff --git a/api-v3/src/inspection-findings/dto/update-finding-catalog-item.dto.ts b/api-v3/src/inspection-findings/dto/update-finding-catalog-item.dto.ts new file mode 100644 index 0000000..2b32351 --- /dev/null +++ b/api-v3/src/inspection-findings/dto/update-finding-catalog-item.dto.ts @@ -0,0 +1,54 @@ +import { Transform, Type } from 'class-transformer'; +import { IsBoolean, IsInt, IsOptional, IsString, IsUUID, Max, MaxLength, Min, MinLength } from 'class-validator'; + +const trimNullable = ({ value }: { value: unknown }) => + typeof value === 'string' ? value.trim() || null : value; + +export class UpdateFindingCatalogItemDto { + @IsOptional() + @IsUUID('4') + categoryId?: string; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(999999) + sourceNumber?: number; + + @IsOptional() + @Transform(({ value }) => typeof value === 'string' ? value.trim() : value) + @IsString() + @MinLength(1) + @MaxLength(500) + title?: string; + + @IsOptional() + @Transform(trimNullable) + @IsString() + @MaxLength(12000) + legalBasis?: string | null; + + @IsOptional() + @Transform(trimNullable) + @IsString() + @MaxLength(12000) + glossary?: string | null; + + @IsOptional() + @Transform(trimNullable) + @IsString() + @MaxLength(4000) + importNote?: string | null; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(10) + suggestedSeverity?: number | null; + + @IsOptional() + @IsBoolean() + isActive?: boolean; +} diff --git a/api-v3/src/inspection-findings/dto/update-finding-category.dto.ts b/api-v3/src/inspection-findings/dto/update-finding-category.dto.ts new file mode 100644 index 0000000..5ac6baa --- /dev/null +++ b/api-v3/src/inspection-findings/dto/update-finding-category.dto.ts @@ -0,0 +1,22 @@ +import { Transform, Type } from 'class-transformer'; +import { IsBoolean, IsInt, IsOptional, IsString, Max, MaxLength, Min, MinLength } from 'class-validator'; + +export class UpdateFindingCategoryDto { + @IsOptional() + @Transform(({ value }) => typeof value === 'string' ? value.trim() : value) + @IsString() + @MinLength(1) + @MaxLength(200) + name?: string; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + @Max(10000) + sortOrder?: number; + + @IsOptional() + @IsBoolean() + isActive?: boolean; +} diff --git a/api-v3/src/inspection-findings/dto/update-inspection-finding-follow-up.dto.ts b/api-v3/src/inspection-findings/dto/update-inspection-finding-follow-up.dto.ts new file mode 100644 index 0000000..3a60c94 --- /dev/null +++ b/api-v3/src/inspection-findings/dto/update-inspection-finding-follow-up.dto.ts @@ -0,0 +1,59 @@ +import { Transform, Type } from 'class-transformer'; +import { + IsEnum, + IsInt, + IsOptional, + IsString, + Matches, + Max, + MaxLength, + Min, + MinLength, +} from 'class-validator'; +import { InspectionFindingResponseDueBasis } from '../../database/entities'; + +const optionalText = ({ value }: { value: unknown }) => + typeof value === 'string' && value.trim() ? value.trim() : null; + +const optionalDate = ({ value }: { value: unknown }) => + typeof value === 'string' && value.trim() ? value.trim() : null; + +export class UpdateInspectionFindingFollowUpDto { + @IsOptional() + @IsEnum(InspectionFindingResponseDueBasis) + responseDueBasis?: InspectionFindingResponseDueBasis | null; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + @Max(3650) + responseDueDays?: number | null; + + @IsOptional() + @Transform(optionalDate) + @Matches(/^\d{4}-\d{2}-\d{2}$/) + reportNotifiedOn?: string | null; + + @IsOptional() + @Transform(optionalText) + @IsString() + @MinLength(1) + @MaxLength(20000) + companyResponse?: string | null; + + @IsOptional() + @Transform(optionalDate) + @Matches(/^\d{4}-\d{2}-\d{2}$/) + companyResponseReceivedOn?: string | null; + + @IsOptional() + @Transform(optionalDate) + @Matches(/^\d{4}-\d{2}-\d{2}$/) + companyCommittedCorrectionOn?: string | null; + + @IsOptional() + @Transform(optionalDate) + @Matches(/^\d{4}-\d{2}-\d{2}$/) + nextControlOn?: string | null; +} diff --git a/api-v3/src/inspection-findings/dto/update-inspection-finding.dto.ts b/api-v3/src/inspection-findings/dto/update-inspection-finding.dto.ts new file mode 100644 index 0000000..285b90d --- /dev/null +++ b/api-v3/src/inspection-findings/dto/update-inspection-finding.dto.ts @@ -0,0 +1,40 @@ +import { Transform, Type } from 'class-transformer'; +import { + IsInt, + IsOptional, + IsString, + IsUUID, + Matches, + Max, + MaxLength, + Min, + MinLength, +} from 'class-validator'; + +const optionalDate = ({ value }: { value: unknown }) => + typeof value === 'string' && value.trim() ? value.trim() : null; + +export class UpdateInspectionFindingDto { + @IsOptional() + @IsUUID('4') + assetId?: string; + + @IsOptional() + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) + @IsString() + @MinLength(1) + @MaxLength(20000) + description?: string; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(10) + severity?: number; + + @IsOptional() + @Transform(optionalDate) + @Matches(/^\d{4}-\d{2}-\d{2}$/) + correctionDueOn?: string | null; +} diff --git a/api-v3/src/inspection-findings/finding-catalog.controller.ts b/api-v3/src/inspection-findings/finding-catalog.controller.ts new file mode 100644 index 0000000..178cee3 --- /dev/null +++ b/api-v3/src/inspection-findings/finding-catalog.controller.ts @@ -0,0 +1,136 @@ +import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Put, Query, Req } from '@nestjs/common'; +import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator'; +import { CurrentAuth } from '../auth/decorators/current-auth.decorator'; +import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; +import { CreateFindingCatalogItemDto } from './dto/create-finding-catalog-item.dto'; +import { CreateFindingCategoryDto } from './dto/create-finding-category.dto'; +import { ListFindingCatalogProposalsQueryDto } from './dto/list-finding-catalog-proposals-query.dto'; +import { ListFindingCatalogQueryDto } from './dto/list-finding-catalog-query.dto'; +import { ReplaceFindingCatalogSelectionDto } from './dto/replace-finding-catalog-selection.dto'; +import { ReviewFindingCatalogProposalDto } from './dto/review-finding-catalog-proposal.dto'; +import { UpdateFindingCatalogItemDto } from './dto/update-finding-catalog-item.dto'; +import { UpdateFindingCategoryDto } from './dto/update-finding-category.dto'; +import { FindingCatalogService } from './finding-catalog.service'; + +@Controller('finding-catalog') +export class FindingCatalogController { + constructor(private readonly catalog: FindingCatalogService) {} + + @Get() + @RequirePermissions('finding_catalog.read') + list(@Query() query: ListFindingCatalogQueryDto) { + return this.catalog.list(query); + } + + @Get('applicable/:assetId') + @RequirePermissions('finding_catalog.read') + applicable( + @Param('assetId', new ParseUUIDPipe({ version: '4' })) assetId: string, + @Query() query: ListFindingCatalogQueryDto, + ) { + return this.catalog.listApplicableForAsset(assetId, query); + } + + @Get('admin') + @RequirePermissions('finding_catalog.manage') + adminList() { + return this.catalog.listAdmin(); + } + + @Get('asset-types/:assetTypeId/selection') + @RequirePermissions('finding_catalog.manage') + getAssetTypeSelection( + @Param('assetTypeId', new ParseUUIDPipe({ version: '4' })) assetTypeId: string, + ) { + return this.catalog.getAssetTypeSelection(assetTypeId); + } + + @Put('asset-types/:assetTypeId/selection') + @RequirePermissions('finding_catalog.manage') + replaceAssetTypeSelection( + @Param('assetTypeId', new ParseUUIDPipe({ version: '4' })) assetTypeId: string, + @Body() dto: ReplaceFindingCatalogSelectionDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.catalog.replaceAssetTypeSelection(assetTypeId, dto, principal, request); + } + + @Get('assets/:assetId/selection') + @RequirePermissions('finding_catalog.read') + getAssetSelection( + @Param('assetId', new ParseUUIDPipe({ version: '4' })) assetId: string, + ) { + return this.catalog.getAssetSelection(assetId); + } + + @Put('assets/:assetId/selection') + @RequirePermissions('finding_catalog.manage') + replaceAssetSelection( + @Param('assetId', new ParseUUIDPipe({ version: '4' })) assetId: string, + @Body() dto: ReplaceFindingCatalogSelectionDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.catalog.replaceAssetSelection(assetId, dto, principal, request); + } + + @Get('proposals') + @RequirePermissions('finding_catalog.manage') + proposals(@Query() query: ListFindingCatalogProposalsQueryDto) { + return this.catalog.listProposals(query); + } + + @Patch('proposals/:id/review') + @RequirePermissions('finding_catalog.manage') + reviewProposal( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @Body() dto: ReviewFindingCatalogProposalDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.catalog.reviewProposal(id, dto, principal, request); + } + + @Post('categories') + @RequirePermissions('finding_catalog.manage') + createCategory( + @Body() dto: CreateFindingCategoryDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.catalog.createCategory(dto, principal, request); + } + + @Patch('categories/:id') + @RequirePermissions('finding_catalog.manage') + updateCategory( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @Body() dto: UpdateFindingCategoryDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.catalog.updateCategory(id, dto, principal, request); + } + + @Post('items') + @RequirePermissions('finding_catalog.manage') + createItem( + @Body() dto: CreateFindingCatalogItemDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.catalog.createItem(dto, principal, request); + } + + @Patch('items/:id') + @RequirePermissions('finding_catalog.manage') + updateItem( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @Body() dto: UpdateFindingCatalogItemDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.catalog.updateItem(id, dto, principal, request); + } +} diff --git a/api-v3/src/inspection-findings/finding-catalog.service.ts b/api-v3/src/inspection-findings/finding-catalog.service.ts new file mode 100644 index 0000000..9174fcd --- /dev/null +++ b/api-v3/src/inspection-findings/finding-catalog.service.ts @@ -0,0 +1,901 @@ +import { + BadRequestException, + ConflictException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { DataSource, EntityManager } from 'typeorm'; +import { + administrationAuditContext, + isUniqueViolation, +} from '../administration/common/administration-audit'; +import { AuditService } from '../audit/audit.service'; +import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; +import { + AuditAction, + FindingCatalogItem, + FindingCatalogProposalStatus, + FindingCategory, +} from '../database/entities'; +import type { CreateFindingCatalogItemDto } from './dto/create-finding-catalog-item.dto'; +import type { CreateFindingCategoryDto } from './dto/create-finding-category.dto'; +import type { ListFindingCatalogProposalsQueryDto } from './dto/list-finding-catalog-proposals-query.dto'; +import type { ListFindingCatalogQueryDto } from './dto/list-finding-catalog-query.dto'; +import type { ReplaceFindingCatalogSelectionDto } from './dto/replace-finding-catalog-selection.dto'; +import { + FindingCatalogProposalDecision, + type ReviewFindingCatalogProposalDto, +} from './dto/review-finding-catalog-proposal.dto'; +import type { UpdateFindingCatalogItemDto } from './dto/update-finding-catalog-item.dto'; +import type { UpdateFindingCategoryDto } from './dto/update-finding-category.dto'; + +export interface FindingCategoryAdminView { + id: string; + code: string; + name: string; + sortOrder: number; + isActive: boolean; + itemCount: number; + activeItemCount: number; + createdAt: Date; + updatedAt: Date; +} + +export interface FindingCatalogItemAdminView { + id: string; + categoryId: string; + categoryCode: string; + categoryName: string; + categoryActive: boolean; + code: string; + sourceNumber: number; + title: string; + legalBasis: string | null; + glossary: string | null; + importNote: string | null; + suggestedSeverity: number | null; + revision: number; + isActive: boolean; + usageCount: number; + createdAt: Date; + updatedAt: Date; +} + +export interface CatalogSelectionItem { + id: string; + categoryId: string; + categoryName: string; + code: string; + sourceNumber: number; + title: string; + suggestedSeverity: number | null; + enabled: boolean; +} + +export interface AssetSelectionItem extends CatalogSelectionItem { + typeDefaultEnabled: boolean; + assetOverride: boolean | null; +} + +export interface AssetTypeSelectionView { + assetType: { + id: string; + code: string; + name: string; + operationalRole: string; + }; + configured: boolean; + reason: string | null; + items: CatalogSelectionItem[]; +} + +export interface AssetSelectionView { + asset: { + id: string; + code: string; + name: string; + assetTypeId: string; + assetTypeCode: string; + assetTypeName: string; + }; + typeConfigured: boolean; + typeReason: string | null; + items: AssetSelectionItem[]; +} + +function categoryNotFound(): NotFoundException { + return new NotFoundException({ + code: 'FINDING_CATEGORY_NOT_FOUND', + message: 'Categoría de hallazgos no encontrada', + }); +} + +function itemNotFound(): NotFoundException { + return new NotFoundException({ + code: 'FINDING_CATALOG_ITEM_NOT_FOUND', + message: 'Tipo de hallazgo no encontrado', + }); +} + +function assetTypeNotFound(): NotFoundException { + return new NotFoundException({ + code: 'ASSET_TYPE_NOT_FOUND', + message: 'Tipo de Inventario no encontrado', + }); +} + +function assetNotFound(): NotFoundException { + return new NotFoundException({ + code: 'ASSET_NOT_FOUND', + message: 'Registro de Inventario no encontrado', + }); +} + +function proposalNotFound(): NotFoundException { + return new NotFoundException({ + code: 'FINDING_CATALOG_PROPOSAL_NOT_FOUND', + message: 'Propuesta de catálogo no encontrada', + }); +} + +function catalogConflict(): ConflictException { + return new ConflictException({ + code: 'FINDING_CATALOG_DUPLICATE', + message: 'Ya existe ese código o número dentro de la categoría seleccionada', + }); +} + +@Injectable() +export class FindingCatalogService { + constructor( + private readonly dataSource: DataSource, + private readonly audit: AuditService, + ) {} + + async list(query: ListFindingCatalogQueryDto) { + const conditions = ['item.is_active = true', 'category.is_active = true']; + const parameters: unknown[] = []; + const add = (value: unknown): string => { + parameters.push(value); + return `$${parameters.length}`; + }; + if (query.categoryId) conditions.push(`category.id = ${add(query.categoryId)}`); + if (query.search?.trim()) { + const search = add(`%${query.search.trim()}%`); + conditions.push(`( + item.title ILIKE ${search} + OR COALESCE(item.legal_basis, '') ILIKE ${search} + OR COALESCE(item.glossary, '') ILIKE ${search} + )`); + } + + const categories = await this.dataSource.query(` + SELECT id, code, name, sort_order AS "sortOrder" + FROM finding_categories + WHERE is_active = true + ORDER BY sort_order, name, id + `) as unknown[]; + const items = await this.dataSource.query(` + SELECT + item.id, + item.category_id AS "categoryId", + item.code, + item.source_number AS "sourceNumber", + item.title, + item.legal_basis AS "legalBasis", + item.glossary, + item.suggested_severity AS "suggestedSeverity", + item.revision, + category.name AS "categoryName" + FROM finding_catalog_items item + INNER JOIN finding_categories category ON category.id = item.category_id + WHERE ${conditions.join(' AND ')} + ORDER BY category.sort_order, item.source_number, item.code + `, parameters) as unknown[]; + return { categories, items }; + } + + async listApplicableForAsset(assetId: string, query: ListFindingCatalogQueryDto) { + const selection = await this.loadAssetSelection(this.dataSource.manager, assetId); + const enabledIds = selection.items.filter((item) => item.enabled).map((item) => item.id); + const filters = ['item.is_active = true', 'category.is_active = true']; + const params: unknown[] = [enabledIds]; + if (query.categoryId) { + params.push(query.categoryId); + filters.push(`category.id = $${params.length}::uuid`); + } + if (query.search?.trim()) { + params.push(`%${query.search.trim()}%`); + const search = `$${params.length}`; + filters.push(`(item.title ILIKE ${search} OR COALESCE(item.legal_basis, '') ILIKE ${search} OR COALESCE(item.glossary, '') ILIKE ${search})`); + } + filters.push('item.id = ANY($1::uuid[])'); + + const [categories, items] = await Promise.all([ + this.dataSource.query(` + SELECT DISTINCT category.id, category.code, category.name, category.sort_order AS "sortOrder" + FROM finding_categories category + INNER JOIN finding_catalog_items item ON item.category_id = category.id + WHERE ${filters.join(' AND ')} + ORDER BY category.sort_order, category.name, category.id + `, params), + this.dataSource.query(` + SELECT + item.id, + item.category_id AS "categoryId", + item.code, + item.source_number AS "sourceNumber", + item.title, + item.legal_basis AS "legalBasis", + item.glossary, + item.suggested_severity AS "suggestedSeverity", + item.revision, + category.name AS "categoryName" + FROM finding_catalog_items item + INNER JOIN finding_categories category ON category.id = item.category_id + WHERE ${filters.join(' AND ')} + ORDER BY category.sort_order, item.source_number, item.code + `, params), + ]); + + return { + asset: selection.asset, + typeConfigured: selection.typeConfigured, + configurationReason: selection.typeReason, + categories, + items, + other: { + enabled: true, + code: 'OTHER', + label: 'OTROS', + help: 'Usalo sólo cuando el hallazgo no exista en el catálogo. Se enviará una propuesta a revisión de oficina.', + }, + }; + } + + async listAdmin(): Promise<{ + categories: FindingCategoryAdminView[]; + items: FindingCatalogItemAdminView[]; + }> { + const [categories, items] = await Promise.all([ + this.dataSource.query(this.categoryQuery('')) as Promise, + this.dataSource.query(this.itemQuery('')) as Promise, + ]); + return { categories, items }; + } + + async getAssetTypeSelection(assetTypeId: string): Promise { + return this.loadAssetTypeSelection(this.dataSource.manager, assetTypeId); + } + + async replaceAssetTypeSelection( + assetTypeId: string, + dto: ReplaceFindingCatalogSelectionDto, + principal: AuthPrincipal, + request: RequestWithContext, + ): Promise { + return this.dataSource.transaction(async (manager) => { + const before = await this.loadAssetTypeSelection(manager, assetTypeId); + await this.validateActiveItemIds(manager, dto.enabledItemIds); + await manager.query('DELETE FROM finding_catalog_item_asset_types WHERE asset_type_id = $1', [assetTypeId]); + if (dto.enabledItemIds.length) { + await manager.query(` + INSERT INTO finding_catalog_item_asset_types (catalog_item_id, asset_type_id, created_by) + SELECT value, $2::uuid, $3::uuid + FROM unnest($1::uuid[]) AS value + `, [dto.enabledItemIds, assetTypeId, principal.userId]); + } + await manager.query(` + INSERT INTO finding_catalog_asset_type_profiles (asset_type_id, reason, updated_by) + VALUES ($1, $2, $3) + ON CONFLICT (asset_type_id) DO UPDATE SET + reason = EXCLUDED.reason, + updated_by = EXCLUDED.updated_by, + updated_at = CURRENT_TIMESTAMP + `, [assetTypeId, dto.reason, principal.userId]); + const after = await this.loadAssetTypeSelection(manager, assetTypeId); + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.FINDING_CATALOG_TYPE_APPLICABILITY_UPDATED, + entityType: 'finding_catalog_asset_type_profile', + entityId: assetTypeId, + beforeData: { + configured: before.configured, + enabledItemIds: before.items.filter((item) => item.enabled).map((item) => item.id), + }, + afterData: { + configured: after.configured, + enabledItemIds: after.items.filter((item) => item.enabled).map((item) => item.id), + reason: dto.reason, + }, + }, manager); + return after; + }); + } + + async getAssetSelection(assetId: string): Promise { + return this.loadAssetSelection(this.dataSource.manager, assetId); + } + + async replaceAssetSelection( + assetId: string, + dto: ReplaceFindingCatalogSelectionDto, + principal: AuthPrincipal, + request: RequestWithContext, + ): Promise { + return this.dataSource.transaction(async (manager) => { + const before = await this.loadAssetSelection(manager, assetId); + await this.validateActiveItemIds(manager, dto.enabledItemIds); + const validIds = new Set(before.items.map((item) => item.id)); + const invalid = dto.enabledItemIds.find((id) => !validIds.has(id)); + if (invalid) { + throw new BadRequestException({ + code: 'FINDING_CATALOG_ITEM_INVALID', + message: 'La selección contiene un tipo de hallazgo inexistente o inactivo', + }); + } + const enabled = new Set(dto.enabledItemIds); + await manager.query('DELETE FROM finding_catalog_asset_overrides WHERE asset_id = $1', [assetId]); + for (const item of before.items) { + const desired = enabled.has(item.id); + if (desired === item.typeDefaultEnabled) continue; + await manager.query(` + INSERT INTO finding_catalog_asset_overrides ( + asset_id, catalog_item_id, is_enabled, reason, created_by, updated_by + ) VALUES ($1, $2, $3, $4, $5, $5) + `, [assetId, item.id, desired, dto.reason, principal.userId]); + } + const after = await this.loadAssetSelection(manager, assetId); + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.FINDING_CATALOG_ASSET_SELECTION_UPDATED, + entityType: 'finding_catalog_asset_selection', + entityId: assetId, + beforeData: { + enabledItemIds: before.items.filter((item) => item.enabled).map((item) => item.id), + }, + afterData: { + enabledItemIds: after.items.filter((item) => item.enabled).map((item) => item.id), + reason: dto.reason, + }, + }, manager); + return after; + }); + } + + async listProposals(query: ListFindingCatalogProposalsQueryDto) { + const params: unknown[] = []; + const filters: string[] = []; + if (query.status) { + params.push(query.status); + filters.push(`proposal.status = $${params.length}`); + } + if (query.assetTypeId) { + params.push(query.assetTypeId); + filters.push(`proposal.asset_type_id = $${params.length}::uuid`); + } + const where = filters.length ? `WHERE ${filters.join(' AND ')}` : ''; + return this.dataSource.query(` + SELECT + proposal.id, + proposal.finding_id AS "findingId", + finding.code AS "findingCode", + proposal.asset_id AS "assetId", + asset.code AS "assetCode", + asset.name AS "assetName", + proposal.asset_type_id AS "assetTypeId", + asset_type.code AS "assetTypeCode", + asset_type.name AS "assetTypeName", + proposal.proposed_title AS "proposedTitle", + proposal.proposed_legal_basis AS "proposedLegalBasis", + proposal.proposed_severity AS "proposedSeverity", + proposal.description, + proposal.status, + proposal.resolved_catalog_item_id AS "resolvedCatalogItemId", + resolved.code AS "resolvedCatalogCode", + resolved.title AS "resolvedCatalogTitle", + proposal.office_notes AS "officeNotes", + proposal.reviewed_by AS "reviewedBy", + reviewer.username AS "reviewedByUsername", + proposal.reviewed_at AS "reviewedAt", + proposal.created_at AS "createdAt", + proposal.updated_at AS "updatedAt" + FROM finding_catalog_proposals proposal + INNER JOIN inspection_findings finding ON finding.id = proposal.finding_id + INNER JOIN assets asset ON asset.id = proposal.asset_id + INNER JOIN asset_types asset_type ON asset_type.id = proposal.asset_type_id + LEFT JOIN finding_catalog_items resolved ON resolved.id = proposal.resolved_catalog_item_id + LEFT JOIN users reviewer ON reviewer.id = proposal.reviewed_by + ${where} + ORDER BY CASE proposal.status WHEN 'PENDING' THEN 0 ELSE 1 END, + proposal.created_at DESC, + proposal.id DESC + `, params) as Promise; + } + + async reviewProposal( + id: string, + dto: ReviewFindingCatalogProposalDto, + principal: AuthPrincipal, + request: RequestWithContext, + ) { + return this.dataSource.transaction(async (manager) => { + const [proposal] = await manager.query(` + SELECT id, status, asset_type_id AS "assetTypeId" + FROM finding_catalog_proposals + WHERE id = $1 + FOR UPDATE + `, [id]) as Array<{ id: string; status: FindingCatalogProposalStatus; assetTypeId: string }>; + if (!proposal) throw proposalNotFound(); + if (proposal.status !== FindingCatalogProposalStatus.PENDING) { + throw new ConflictException({ + code: 'FINDING_CATALOG_PROPOSAL_ALREADY_REVIEWED', + message: 'La propuesta ya fue resuelta y conserva esa decisión en el historial', + }); + } + + if (dto.decision === FindingCatalogProposalDecision.MATCH) { + if (!dto.catalogItemId) { + throw new BadRequestException({ + code: 'FINDING_CATALOG_PROPOSAL_CATALOG_REQUIRED', + message: 'Seleccioná el tipo de hallazgo del catálogo que corresponde', + }); + } + await this.validateActiveItemIds(manager, [dto.catalogItemId]); + await manager.query(` + UPDATE finding_catalog_proposals SET + status = 'MATCHED', + resolved_catalog_item_id = $2, + office_notes = $3, + reviewed_by = $4, + reviewed_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP + WHERE id = $1 + `, [id, dto.catalogItemId, dto.notes ?? null, principal.userId]); + const [profile] = await manager.query(` + SELECT asset_type_id FROM finding_catalog_asset_type_profiles WHERE asset_type_id = $1 + `, [proposal.assetTypeId]) as unknown[]; + if (profile) { + await manager.query(` + INSERT INTO finding_catalog_item_asset_types (catalog_item_id, asset_type_id, created_by) + VALUES ($1, $2, $3) + ON CONFLICT (catalog_item_id, asset_type_id) DO NOTHING + `, [dto.catalogItemId, proposal.assetTypeId, principal.userId]); + } + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.FINDING_CATALOG_PROPOSAL_MATCHED, + entityType: 'finding_catalog_proposal', + entityId: id, + afterData: { status: 'MATCHED', resolvedCatalogItemId: dto.catalogItemId, notes: dto.notes ?? null }, + }, manager); + } else { + await manager.query(` + UPDATE finding_catalog_proposals SET + status = 'REJECTED', + office_notes = $2, + reviewed_by = $3, + reviewed_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP + WHERE id = $1 + `, [id, dto.notes ?? null, principal.userId]); + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.FINDING_CATALOG_PROPOSAL_REJECTED, + entityType: 'finding_catalog_proposal', + entityId: id, + afterData: { status: 'REJECTED', notes: dto.notes ?? null }, + }, manager); + } + + const [value] = await manager.query(` + SELECT + proposal.id, + proposal.finding_id AS "findingId", + finding.code AS "findingCode", + proposal.asset_id AS "assetId", + asset.code AS "assetCode", + asset.name AS "assetName", + proposal.asset_type_id AS "assetTypeId", + asset_type.code AS "assetTypeCode", + asset_type.name AS "assetTypeName", + proposal.proposed_title AS "proposedTitle", + proposal.proposed_legal_basis AS "proposedLegalBasis", + proposal.proposed_severity AS "proposedSeverity", + proposal.description, + proposal.status, + proposal.resolved_catalog_item_id AS "resolvedCatalogItemId", + resolved.code AS "resolvedCatalogCode", + resolved.title AS "resolvedCatalogTitle", + proposal.office_notes AS "officeNotes", + proposal.reviewed_by AS "reviewedBy", + reviewer.username AS "reviewedByUsername", + proposal.reviewed_at AS "reviewedAt", + proposal.created_at AS "createdAt", + proposal.updated_at AS "updatedAt" + FROM finding_catalog_proposals proposal + INNER JOIN inspection_findings finding ON finding.id = proposal.finding_id + INNER JOIN assets asset ON asset.id = proposal.asset_id + INNER JOIN asset_types asset_type ON asset_type.id = proposal.asset_type_id + LEFT JOIN finding_catalog_items resolved ON resolved.id = proposal.resolved_catalog_item_id + LEFT JOIN users reviewer ON reviewer.id = proposal.reviewed_by + WHERE proposal.id = $1 + `, [id]) as Array<{ id: string }>; + if (!value) throw proposalNotFound(); + return value; + }); + } + + async createCategory( + dto: CreateFindingCategoryDto, + principal: AuthPrincipal, + request: RequestWithContext, + ): Promise { + try { + return await this.dataSource.transaction(async (manager) => { + const category = manager.getRepository(FindingCategory).create({ + code: dto.code, + name: dto.name, + sortOrder: dto.sortOrder, + isActive: true, + }); + await manager.getRepository(FindingCategory).save(category); + const created = await this.loadCategory(manager, category.id); + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.FINDING_CATEGORY_CREATED, + entityType: 'finding_category', + entityId: category.id, + afterData: { ...created }, + }, manager); + return created; + }); + } catch (error) { + if (isUniqueViolation(error)) throw catalogConflict(); + throw error; + } + } + + async updateCategory( + id: string, + dto: UpdateFindingCategoryDto, + principal: AuthPrincipal, + request: RequestWithContext, + ): Promise { + if (Object.keys(dto).length === 0) { + throw new BadRequestException({ code: 'NO_CHANGES', message: 'No se recibieron cambios' }); + } + return this.dataSource.transaction(async (manager) => { + const repository = manager.getRepository(FindingCategory); + const category = await repository.findOne({ where: { id } }); + if (!category) throw categoryNotFound(); + const before = await this.loadCategory(manager, id); + if (dto.name !== undefined) category.name = dto.name; + if (dto.sortOrder !== undefined) category.sortOrder = dto.sortOrder; + if (dto.isActive !== undefined) category.isActive = dto.isActive; + await repository.save(category); + const updated = await this.loadCategory(manager, id); + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.FINDING_CATEGORY_UPDATED, + entityType: 'finding_category', + entityId: id, + beforeData: { ...before }, + afterData: { ...updated }, + }, manager); + return updated; + }); + } + + async createItem( + dto: CreateFindingCatalogItemDto, + principal: AuthPrincipal, + request: RequestWithContext, + ): Promise { + try { + return await this.dataSource.transaction(async (manager) => { + await this.requireCategory(manager, dto.categoryId); + const item = manager.getRepository(FindingCatalogItem).create({ + categoryId: dto.categoryId, + code: dto.code, + sourceNumber: dto.sourceNumber, + title: dto.title, + legalBasis: dto.legalBasis ?? null, + glossary: dto.glossary ?? null, + importNote: dto.importNote ?? null, + suggestedSeverity: dto.suggestedSeverity ?? null, + revision: 1, + isActive: true, + }); + await manager.getRepository(FindingCatalogItem).save(item); + const created = await this.loadItem(manager, item.id); + await this.captureVersion(manager, created, principal); + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.FINDING_CATALOG_ITEM_CREATED, + entityType: 'finding_catalog_item', + entityId: item.id, + afterData: this.itemAuditView(created), + metadata: { revision: created.revision, categoryId: created.categoryId }, + }, manager); + return created; + }); + } catch (error) { + if (isUniqueViolation(error)) throw catalogConflict(); + throw error; + } + } + + async updateItem( + id: string, + dto: UpdateFindingCatalogItemDto, + principal: AuthPrincipal, + request: RequestWithContext, + ): Promise { + if (Object.keys(dto).length === 0) { + throw new BadRequestException({ code: 'NO_CHANGES', message: 'No se recibieron cambios' }); + } + try { + return await this.dataSource.transaction(async (manager) => { + const repository = manager.getRepository(FindingCatalogItem); + const item = await repository.createQueryBuilder('item') + .where('item.id = :id', { id }) + .setLock('pessimistic_write') + .getOne(); + if (!item) throw itemNotFound(); + const before = await this.loadItem(manager, id); + if (dto.categoryId !== undefined) { + await this.requireCategory(manager, dto.categoryId); + item.categoryId = dto.categoryId; + } + if (dto.sourceNumber !== undefined) item.sourceNumber = dto.sourceNumber; + if (dto.title !== undefined) item.title = dto.title; + if (dto.legalBasis !== undefined) item.legalBasis = dto.legalBasis; + if (dto.glossary !== undefined) item.glossary = dto.glossary; + if (dto.importNote !== undefined) item.importNote = dto.importNote; + if (dto.suggestedSeverity !== undefined) item.suggestedSeverity = dto.suggestedSeverity; + if (dto.isActive !== undefined) item.isActive = dto.isActive; + item.revision += 1; + await repository.save(item); + const updated = await this.loadItem(manager, id); + await this.captureVersion(manager, updated, principal); + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.FINDING_CATALOG_ITEM_UPDATED, + entityType: 'finding_catalog_item', + entityId: id, + beforeData: this.itemAuditView(before), + afterData: this.itemAuditView(updated), + metadata: { revision: updated.revision, categoryId: updated.categoryId }, + }, manager); + return updated; + }); + } catch (error) { + if (isUniqueViolation(error)) throw catalogConflict(); + throw error; + } + } + + private async loadAssetTypeSelection(manager: EntityManager, assetTypeId: string): Promise { + const [assetType] = await manager.query(` + SELECT type.id, type.code, type.name, type.operational_role AS "operationalRole", + profile.reason, (profile.asset_type_id IS NOT NULL) AS configured + FROM asset_types type + LEFT JOIN finding_catalog_asset_type_profiles profile ON profile.asset_type_id = type.id + WHERE type.id = $1 + `, [assetTypeId]) as Array<{ + id: string; code: string; name: string; operationalRole: string; reason: string | null; configured: boolean; + }>; + if (!assetType) throw assetTypeNotFound(); + const items = await manager.query(` + SELECT + item.id, + item.category_id AS "categoryId", + category.name AS "categoryName", + item.code, + item.source_number AS "sourceNumber", + item.title, + item.suggested_severity AS "suggestedSeverity", + CASE + WHEN $2::boolean = false THEN true + ELSE mapping.id IS NOT NULL + END AS enabled + FROM finding_catalog_items item + INNER JOIN finding_categories category ON category.id = item.category_id + LEFT JOIN finding_catalog_item_asset_types mapping + ON mapping.catalog_item_id = item.id AND mapping.asset_type_id = $1 + WHERE item.is_active = true AND category.is_active = true + ORDER BY category.sort_order, item.source_number, item.code + `, [assetTypeId, assetType.configured]) as CatalogSelectionItem[]; + return { + assetType: { + id: assetType.id, + code: assetType.code, + name: assetType.name, + operationalRole: assetType.operationalRole, + }, + configured: Boolean(assetType.configured), + reason: assetType.reason, + items, + }; + } + + private async loadAssetSelection(manager: EntityManager, assetId: string): Promise { + const [asset] = await manager.query(` + SELECT + asset.id, + asset.code, + asset.name, + asset.asset_type_id AS "assetTypeId", + type.code AS "assetTypeCode", + type.name AS "assetTypeName", + profile.reason AS "typeReason", + (profile.asset_type_id IS NOT NULL) AS "typeConfigured" + FROM assets asset + INNER JOIN asset_types type ON type.id = asset.asset_type_id + LEFT JOIN finding_catalog_asset_type_profiles profile ON profile.asset_type_id = asset.asset_type_id + WHERE asset.id = $1 + `, [assetId]) as Array<{ + id: string; code: string; name: string; assetTypeId: string; assetTypeCode: string; assetTypeName: string; + typeReason: string | null; typeConfigured: boolean; + }>; + if (!asset) throw assetNotFound(); + const items = await manager.query(` + SELECT + item.id, + item.category_id AS "categoryId", + category.name AS "categoryName", + item.code, + item.source_number AS "sourceNumber", + item.title, + item.suggested_severity AS "suggestedSeverity", + CASE WHEN $3::boolean = false THEN true ELSE mapping.id IS NOT NULL END AS "typeDefaultEnabled", + override.is_enabled AS "assetOverride", + COALESCE( + override.is_enabled, + CASE WHEN $3::boolean = false THEN true ELSE mapping.id IS NOT NULL END + ) AS enabled + FROM finding_catalog_items item + INNER JOIN finding_categories category ON category.id = item.category_id + LEFT JOIN finding_catalog_item_asset_types mapping + ON mapping.catalog_item_id = item.id AND mapping.asset_type_id = $2 + LEFT JOIN finding_catalog_asset_overrides override + ON override.catalog_item_id = item.id AND override.asset_id = $1 + WHERE item.is_active = true AND category.is_active = true + ORDER BY category.sort_order, item.source_number, item.code + `, [assetId, asset.assetTypeId, asset.typeConfigured]) as AssetSelectionItem[]; + return { + asset: { + id: asset.id, + code: asset.code, + name: asset.name, + assetTypeId: asset.assetTypeId, + assetTypeCode: asset.assetTypeCode, + assetTypeName: asset.assetTypeName, + }, + typeConfigured: Boolean(asset.typeConfigured), + typeReason: asset.typeReason, + items, + }; + } + + private async validateActiveItemIds(manager: EntityManager, ids: string[]): Promise { + if (!ids.length) return; + const [row] = await manager.query(` + SELECT COUNT(*)::integer AS count + FROM finding_catalog_items item + INNER JOIN finding_categories category ON category.id = item.category_id + WHERE item.id = ANY($1::uuid[]) + AND item.is_active = true + AND category.is_active = true + `, [ids]) as Array<{ count: number }>; + if (Number(row?.count ?? 0) !== ids.length) { + throw new BadRequestException({ + code: 'FINDING_CATALOG_ITEM_INVALID', + message: 'La selección contiene un tipo de hallazgo inexistente o inactivo', + }); + } + } + + private async requireCategory(manager: EntityManager, id: string): Promise { + const category = await manager.getRepository(FindingCategory).findOne({ where: { id } }); + if (!category) throw categoryNotFound(); + return category; + } + + private async loadCategory(manager: EntityManager, id: string): Promise { + const [value] = await manager.query(this.categoryQuery('WHERE category.id = $1'), [id]) as FindingCategoryAdminView[]; + if (!value) throw categoryNotFound(); + return value; + } + + private async loadItem(manager: EntityManager, id: string): Promise { + const [value] = await manager.query(this.itemQuery('WHERE item.id = $1'), [id]) as FindingCatalogItemAdminView[]; + if (!value) throw itemNotFound(); + return value; + } + + private async captureVersion( + manager: EntityManager, + item: FindingCatalogItemAdminView, + principal: AuthPrincipal, + ): Promise { + await manager.query(` + INSERT INTO finding_catalog_item_versions ( + item_id, revision, snapshot, actor_user_id, actor_username + ) VALUES ($1, $2, $3, $4, $5) + `, [ + item.id, + item.revision, + this.itemAuditView(item), + principal.userId, + principal.username, + ]); + } + + private itemAuditView(item: FindingCatalogItemAdminView): Record { + return { + id: item.id, + categoryId: item.categoryId, + categoryCode: item.categoryCode, + categoryName: item.categoryName, + code: item.code, + sourceNumber: item.sourceNumber, + title: item.title, + legalBasis: item.legalBasis, + glossary: item.glossary, + importNote: item.importNote, + suggestedSeverity: item.suggestedSeverity, + revision: item.revision, + isActive: item.isActive, + }; + } + + private categoryQuery(where: string): string { + return ` + SELECT + category.id, + category.code, + category.name, + category.sort_order AS "sortOrder", + category.is_active AS "isActive", + COUNT(item.id)::integer AS "itemCount", + COUNT(item.id) FILTER (WHERE item.is_active)::integer AS "activeItemCount", + category.created_at AS "createdAt", + category.updated_at AS "updatedAt" + FROM finding_categories category + LEFT JOIN finding_catalog_items item ON item.category_id = category.id + ${where} + GROUP BY category.id + ORDER BY category.sort_order, category.name, category.id + `; + } + + private itemQuery(where: string): string { + return ` + SELECT + item.id, + item.category_id AS "categoryId", + category.code AS "categoryCode", + category.name AS "categoryName", + category.is_active AS "categoryActive", + item.code, + item.source_number AS "sourceNumber", + item.title, + item.legal_basis AS "legalBasis", + item.glossary, + item.import_note AS "importNote", + item.suggested_severity AS "suggestedSeverity", + item.revision, + item.is_active AS "isActive", + (SELECT COUNT(*)::integer FROM inspection_findings finding + WHERE finding.catalog_item_id = item.id) AS "usageCount", + item.created_at AS "createdAt", + item.updated_at AS "updatedAt" + FROM finding_catalog_items item + INNER JOIN finding_categories category ON category.id = item.category_id + ${where} + ORDER BY category.sort_order, item.source_number, item.code + `; + } +} diff --git a/api-v3/src/inspection-findings/inspection-evidence-file.ts b/api-v3/src/inspection-findings/inspection-evidence-file.ts new file mode 100644 index 0000000..2278bd0 --- /dev/null +++ b/api-v3/src/inspection-findings/inspection-evidence-file.ts @@ -0,0 +1,77 @@ +import { BadRequestException } from '@nestjs/common'; +import { InspectionEvidenceKind } from '../database/entities'; + +export const MAX_INSPECTION_EVIDENCE_BYTES = 15 * 1024 * 1024; + +export interface UploadedInspectionEvidenceFile { + buffer: Buffer; + originalname: string; + mimetype?: string; + size: number; +} + +export interface InspectedInspectionEvidenceFile { + originalName: string; + mimeType: 'image/jpeg' | 'image/png' | 'image/webp' | 'application/pdf'; + extension: '.jpg' | '.png' | '.webp' | '.pdf'; +} + +function invalidFile(message: string): BadRequestException { + return new BadRequestException({ code: 'INVALID_INSPECTION_EVIDENCE_FILE', message }); +} + +function detectedType( + buffer: Buffer, +): Omit | null { + if (buffer.length >= 4 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) { + return { mimeType: 'image/jpeg', extension: '.jpg' }; + } + if ( + buffer.length >= 8 + && buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) + ) { + return { mimeType: 'image/png', extension: '.png' }; + } + if ( + buffer.length >= 12 + && buffer.subarray(0, 4).toString('ascii') === 'RIFF' + && buffer.subarray(8, 12).toString('ascii') === 'WEBP' + ) { + return { mimeType: 'image/webp', extension: '.webp' }; + } + if (buffer.length >= 5 && buffer.subarray(0, 5).toString('ascii') === '%PDF-') { + return { mimeType: 'application/pdf', extension: '.pdf' }; + } + return null; +} + +export function inspectInspectionEvidenceFile( + file: UploadedInspectionEvidenceFile | undefined, + kind: InspectionEvidenceKind, +): InspectedInspectionEvidenceFile { + if (!file?.buffer || file.size <= 0 || file.buffer.length <= 0) { + throw invalidFile('Debe seleccionar un archivo no vacío'); + } + if ( + file.size > MAX_INSPECTION_EVIDENCE_BYTES + || file.buffer.length > MAX_INSPECTION_EVIDENCE_BYTES + ) { + throw invalidFile('El archivo supera el máximo permitido de 15 MB'); + } + const detected = detectedType(file.buffer); + if (!detected) { + throw invalidFile('Sólo se permiten JPG, PNG, WebP y PDF válidos'); + } + if (kind === InspectionEvidenceKind.PHOTO && !detected.mimeType.startsWith('image/')) { + throw invalidFile('Una fotografía debe ser JPG, PNG o WebP'); + } + if (kind === InspectionEvidenceKind.DOCUMENT && detected.mimeType !== 'application/pdf') { + throw invalidFile('Un documento debe ser un archivo PDF'); + } + const originalName = file.originalname + .replace(/[\u0000-\u001f\u007f]/g, '') + .trim() + .slice(0, 255); + if (!originalName) throw invalidFile('El nombre original del archivo no es válido'); + return { ...detected, originalName }; +} diff --git a/api-v3/src/inspection-findings/inspection-evidence.controller.ts b/api-v3/src/inspection-findings/inspection-evidence.controller.ts new file mode 100644 index 0000000..828bcc6 --- /dev/null +++ b/api-v3/src/inspection-findings/inspection-evidence.controller.ts @@ -0,0 +1,113 @@ +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Post, + Query, + Req, + Res, + UploadedFile, + UseInterceptors, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import type { Response } from 'express'; +import { CurrentAuth } from '../auth/decorators/current-auth.decorator'; +import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator'; +import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; +import { CreateInspectionCommunicationDto } from './dto/create-inspection-communication.dto'; +import { CreateInspectionEvidenceDto } from './dto/create-inspection-evidence.dto'; +import { + InspectionEvidenceService, +} from './inspection-evidence.service'; +import { + MAX_INSPECTION_EVIDENCE_BYTES, + type UploadedInspectionEvidenceFile, +} from './inspection-evidence-file'; + +@Controller('inspection-findings/:findingId/evidence') +export class InspectionFindingEvidenceController { + constructor(private readonly evidence: InspectionEvidenceService) {} + + @Get() + @RequirePermissions('inspection_evidence.read') + list( + @Param('findingId', new ParseUUIDPipe({ version: '4' })) findingId: string, + ) { + return this.evidence.listEvidence(findingId); + } + + @Post() + @RequirePermissions('inspection_evidence.create') + @UseInterceptors(FileInterceptor('file', { + limits: { fileSize: MAX_INSPECTION_EVIDENCE_BYTES, files: 1 }, + })) + upload( + @Param('findingId', new ParseUUIDPipe({ version: '4' })) findingId: string, + @Body() dto: CreateInspectionEvidenceDto, + @UploadedFile() file: UploadedInspectionEvidenceFile | undefined, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.evidence.uploadEvidence(findingId, dto, file, principal, request); + } +} + +@Controller('inspection-findings/:findingId/communications') +export class InspectionFindingCommunicationsController { + constructor(private readonly evidence: InspectionEvidenceService) {} + + @Get() + @RequirePermissions('inspection_communications.read') + list( + @Param('findingId', new ParseUUIDPipe({ version: '4' })) findingId: string, + ) { + return this.evidence.listCommunications(findingId); + } + + @Post() + @RequirePermissions('inspection_communications.create') + create( + @Param('findingId', new ParseUUIDPipe({ version: '4' })) findingId: string, + @Body() dto: CreateInspectionCommunicationDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.evidence.createCommunication(findingId, dto, principal, request); + } +} + +@Controller('inspection-finding-evidence') +export class InspectionEvidenceContentController { + constructor(private readonly evidence: InspectionEvidenceService) {} + + @Get(':evidenceId/content') + @RequirePermissions('inspection_evidence.read') + async content( + @Param('evidenceId', new ParseUUIDPipe({ version: '4' })) evidenceId: string, + @Query('download') download: string | undefined, + @Res() response: Response, + ): Promise { + const { filePath, evidence } = await this.evidence.content(evidenceId); + const disposition = download === '1' ? 'attachment' : 'inline'; + const fallbackName = evidence.originalName + .replace(/[^\x20-\x7e]/g, '_') + .replace(/["\\]/g, '_'); + response.setHeader('Content-Type', evidence.mimeType); + response.setHeader('Content-Length', String(evidence.sizeBytes)); + response.setHeader( + 'Content-Disposition', + `${disposition}; filename="${fallbackName}"; filename*=UTF-8''${encodeURIComponent(evidence.originalName)}`, + ); + response.setHeader('Cache-Control', 'private, no-store'); + response.setHeader('X-Content-Type-Options', 'nosniff'); + response.setHeader('Content-Security-Policy', "sandbox; default-src 'none'"); + await new Promise((resolveSend, rejectSend) => { + response.sendFile(filePath, (error) => { + if (error) rejectSend(error); + else resolveSend(); + }); + }); + } +} diff --git a/api-v3/src/inspection-findings/inspection-evidence.service.ts b/api-v3/src/inspection-findings/inspection-evidence.service.ts new file mode 100644 index 0000000..ecdc6a6 --- /dev/null +++ b/api-v3/src/inspection-findings/inspection-evidence.service.ts @@ -0,0 +1,681 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { mkdir, stat, unlink, writeFile } from 'node:fs/promises'; +import { isAbsolute, parse, resolve } from 'node:path'; +import { + BadRequestException, + ConflictException, + ForbiddenException, + Injectable, + InternalServerErrorException, + NotFoundException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { DataSource, EntityManager } from 'typeorm'; +import { administrationAuditContext } from '../administration/common/administration-audit'; +import { AuditService } from '../audit/audit.service'; +import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; +import { assertMobileInspector } from '../inspection-operations/mobile-inspector-policy'; +import { + AuditAction, + InspectionActStatus, + InspectionCommunicationDirection, + InspectionCommunicationType, + InspectionEvidenceKind, + InspectionEvidencePurpose, + InspectionEvidenceSource, + InspectionFindingStatus, + InspectionVisitStatus, +} from '../database/entities'; +import type { CreateInspectionCommunicationDto } from './dto/create-inspection-communication.dto'; +import type { CreateInspectionEvidenceDto } from './dto/create-inspection-evidence.dto'; +import { + inspectInspectionEvidenceFile, + type UploadedInspectionEvidenceFile, +} from './inspection-evidence-file'; + +interface FindingContext { + id: string; + status: InspectionFindingStatus; + actId: string; + actStatus: InspectionActStatus; + visitId: string; + visitStatus: InspectionVisitStatus; +} + +interface CommunicationLink { + id: string; + type: InspectionCommunicationType; + direction: InspectionCommunicationDirection; + subject: string; +} + +export interface InspectionCommunicationView { + id: string; + findingId: string; + direction: string; + channel: string; + type: string; + occurredAt: Date; + subject: string; + details: string | null; + contactName: string | null; + contactEmail: string | null; + createdBy: string | null; + createdByUsername: string | null; + attachmentCount: number; + createdAt: Date; +} + +export interface InspectionEvidenceView { + id: string; + findingId: string; + communicationId: string | null; + verificationVisitId: string | null; + communication: CommunicationLink | null; + kind: InspectionEvidenceKind; + purpose: InspectionEvidencePurpose; + originalName: string; + mimeType: string; + sizeBytes: number; + sha256: string; + title: string | null; + description: string | null; + capturedAt: Date | null; + latitude: number | null; + longitude: number | null; + accuracyM: number | null; + deviceLabel: string | null; + source: InspectionEvidenceSource; + uploadedBy: string | null; + uploadedByUsername: string | null; + createdAt: Date; +} + +interface StoredInspectionEvidence extends InspectionEvidenceView { + storedName: string; +} + +function findingNotFound(): NotFoundException { + return new NotFoundException({ + code: 'INSPECTION_FINDING_NOT_FOUND', + message: 'Hallazgo de inspección no encontrado', + }); +} + +function evidenceNotFound(): NotFoundException { + return new NotFoundException({ + code: 'INSPECTION_EVIDENCE_NOT_FOUND', + message: 'Evidencia de inspección no encontrada', + }); +} + +@Injectable() +export class InspectionEvidenceService { + private readonly storageRoot: string; + + constructor( + private readonly dataSource: DataSource, + private readonly audit: AuditService, + config: ConfigService, + ) { + const configured = config.get('INSPECTION_EVIDENCE_ROOT') + ?? '/app/storage/asset-media/inspection-findings'; + if (!isAbsolute(configured)) { + throw new Error('INSPECTION_EVIDENCE_ROOT must be an absolute path'); + } + this.storageRoot = resolve(configured); + if (this.storageRoot === parse(this.storageRoot).root) { + throw new Error('INSPECTION_EVIDENCE_ROOT cannot be the filesystem root'); + } + } + + async listEvidence(findingId: string): Promise<{ data: InspectionEvidenceView[] }> { + await this.requireFinding(this.dataSource.manager, findingId); + const rows = await this.dataSource.query( + `${this.evidenceSelect()} + WHERE evidence.finding_id = $1 + ORDER BY evidence.created_at DESC, evidence.id`, + [findingId], + ) as StoredInspectionEvidence[]; + return { + data: rows.map(({ storedName: _storedName, ...evidence }) => evidence), + }; + } + + async uploadEvidence( + findingId: string, + dto: CreateInspectionEvidenceDto, + file: UploadedInspectionEvidenceFile | undefined, + principal: AuthPrincipal, + request: RequestWithContext, + ): Promise { + if ([InspectionEvidencePurpose.OBSERVATION, InspectionEvidencePurpose.VERIFICATION].includes(dto.purpose)) { + assertMobileInspector(principal); + } + this.validateCoordinates(dto.latitude, dto.longitude, dto.accuracyM); + const inspected = inspectInspectionEvidenceFile(file, dto.kind); + this.validatePurpose(dto, inspected.mimeType); + + const id = randomUUID(); + const storedName = `${id}${inspected.extension}`; + const filePath = resolve(this.storageRoot, storedName); + const sha256 = createHash('sha256').update(file!.buffer).digest('hex'); + const source = principal.transport === 'bearer' + ? InspectionEvidenceSource.ANDROID + : InspectionEvidenceSource.WEB; + + await mkdir(this.storageRoot, { recursive: true, mode: 0o700 }); + await writeFile(filePath, file!.buffer, { flag: 'wx', mode: 0o600 }); + + try { + return await this.dataSource.transaction(async (manager) => { + const context = await this.lockFindingContext(manager, findingId); + this.assertFindingOpen(context); + if (dto.purpose === InspectionEvidencePurpose.OBSERVATION) { + this.assertObservationEditable(context); + await this.assertActorAssigned(manager, context.visitId, principal); + if (dto.verificationVisitId) { + throw new BadRequestException({ + code: 'INSPECTION_OBSERVATION_VERIFICATION_VISIT_NOT_ALLOWED', + message: 'La evidencia inicial no debe indicar una visita de verificación', + }); + } + } + if (dto.purpose === InspectionEvidencePurpose.VERIFICATION) { + if (!dto.verificationVisitId) { + throw new BadRequestException({ + code: 'INSPECTION_VERIFICATION_VISIT_REQUIRED', + message: 'La foto de verificación debe indicar la visita en curso', + }); + } + await this.assertVerificationEditable( + manager, + findingId, + dto.verificationVisitId, + principal, + ); + } else if (dto.verificationVisitId) { + throw new BadRequestException({ + code: 'INSPECTION_VERIFICATION_VISIT_PURPOSE_INVALID', + message: 'La visita de verificación sólo corresponde a evidencia de verificación', + }); + } + const communication = dto.communicationId + ? await this.requireCommunication(manager, findingId, dto.communicationId) + : null; + this.validateCommunicationPurpose(dto.purpose, communication); + + await manager.query(` + INSERT INTO inspection_finding_evidence ( + id, finding_id, communication_id, verification_visit_id, kind, purpose, + original_name, stored_name, mime_type, size_bytes, sha256, + title, description, captured_at, latitude, longitude, accuracy_m, + device_label, source, uploaded_by + ) VALUES ( + $1, $2, $3, $4, $5, $6, + $7, $8, $9, $10, $11, + $12, $13, $14, $15, $16, $17, + $18, $19, $20 + ) + `, [ + id, + findingId, + dto.communicationId ?? null, + dto.verificationVisitId ?? null, + dto.kind, + dto.purpose, + inspected.originalName, + storedName, + inspected.mimeType, + file!.buffer.length, + sha256, + dto.title?.trim() || null, + dto.description?.trim() || null, + dto.capturedAt ? new Date(dto.capturedAt) : null, + dto.latitude ?? null, + dto.longitude ?? null, + dto.accuracyM ?? null, + dto.deviceLabel?.trim() || null, + source, + principal.userId, + ]); + const created = await this.loadEvidence(manager, id); + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.INSPECTION_FINDING_EVIDENCE_UPLOADED, + entityType: 'inspection_finding_evidence', + entityId: id, + afterData: this.evidenceAuditView(created), + metadata: { + findingId, + actId: context.actId, + visitId: dto.verificationVisitId ?? context.visitId, + sourceVisitId: context.visitId, + verificationVisitId: dto.verificationVisitId ?? null, + communicationId: dto.communicationId ?? null, + }, + }, manager); + const { storedName: _storedName, ...view } = created; + return view; + }); + } catch (error) { + await unlink(filePath).catch(() => undefined); + throw error; + } + } + + async listCommunications( + findingId: string, + ): Promise<{ data: InspectionCommunicationView[] }> { + await this.requireFinding(this.dataSource.manager, findingId); + return { + data: await this.dataSource.query( + `${this.communicationSelect('WHERE communication.finding_id = $1')} + ORDER BY communication.occurred_at DESC, communication.created_at DESC`, + [findingId], + ) as InspectionCommunicationView[], + }; + } + + async createCommunication( + findingId: string, + dto: CreateInspectionCommunicationDto, + principal: AuthPrincipal, + request: RequestWithContext, + ): Promise { + if ( + dto.type === InspectionCommunicationType.COMPANY_RESPONSE + && dto.direction !== InspectionCommunicationDirection.INBOUND + ) { + throw new BadRequestException({ + code: 'COMPANY_RESPONSE_MUST_BE_INBOUND', + message: 'Una respuesta de la empresa debe registrarse como comunicación recibida', + }); + } + return this.dataSource.transaction(async (manager) => { + const context = await this.lockFindingContext(manager, findingId); + this.assertFindingOpen(context); + const id = randomUUID(); + await manager.query(` + INSERT INTO inspection_finding_communications ( + id, finding_id, direction, channel, type, occurred_at, + subject, details, contact_name, contact_email, created_by + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + `, [ + id, + findingId, + dto.direction, + dto.channel, + dto.type, + new Date(dto.occurredAt), + dto.subject, + dto.details ?? null, + dto.contactName ?? null, + dto.contactEmail?.toLowerCase() ?? null, + principal.userId, + ]); + const created = await this.loadCommunication(manager, id); + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.INSPECTION_FINDING_COMMUNICATION_CREATED, + entityType: 'inspection_finding_communication', + entityId: id, + afterData: created as unknown as Record, + metadata: { + findingId, + actId: context.actId, + visitId: context.visitId, + immutable: true, + }, + }, manager); + return created; + }); + } + + async content(evidenceId: string): Promise<{ + filePath: string; + evidence: StoredInspectionEvidence; + }> { + const evidence = await this.loadEvidence(this.dataSource.manager, evidenceId); + const filePath = resolve(this.storageRoot, evidence.storedName); + if (!filePath.startsWith(`${this.storageRoot}/`)) { + throw new InternalServerErrorException({ + code: 'INVALID_INSPECTION_EVIDENCE_STORAGE_PATH', + message: 'Ruta de almacenamiento inválida', + }); + } + try { + const fileStat = await stat(filePath); + if (!fileStat.isFile() || fileStat.size !== evidence.sizeBytes) { + throw new Error('size mismatch'); + } + } catch { + throw new InternalServerErrorException({ + code: 'INSPECTION_EVIDENCE_FILE_MISSING', + message: 'El archivo físico no está disponible', + }); + } + return { filePath, evidence }; + } + + private validateCoordinates( + latitude: number | null | undefined, + longitude: number | null | undefined, + accuracyM: number | null | undefined, + ): void { + const hasLatitude = latitude !== null && latitude !== undefined; + const hasLongitude = longitude !== null && longitude !== undefined; + if (hasLatitude !== hasLongitude) { + throw new BadRequestException({ + code: 'INVALID_INSPECTION_EVIDENCE_COORDINATES', + message: 'Latitud y longitud deben informarse juntas', + }); + } + if (accuracyM !== null && accuracyM !== undefined && !hasLatitude) { + throw new BadRequestException({ + code: 'INSPECTION_EVIDENCE_ACCURACY_WITHOUT_COORDINATES', + message: 'La precisión requiere coordenadas', + }); + } + } + + private validatePurpose(dto: CreateInspectionEvidenceDto, mimeType: string): void { + if ( + dto.kind === InspectionEvidenceKind.PHOTO + && ![InspectionEvidencePurpose.OBSERVATION, InspectionEvidencePurpose.VERIFICATION].includes(dto.purpose) + ) { + throw new BadRequestException({ + code: 'INSPECTION_EVIDENCE_PHOTO_PURPOSE_INVALID', + message: 'Las fotografías se registran como evidencia inicial o de verificación', + }); + } + if (dto.purpose === InspectionEvidencePurpose.VERIFICATION && dto.kind !== InspectionEvidenceKind.PHOTO) { + throw new BadRequestException({ + code: 'INSPECTION_VERIFICATION_PHOTO_REQUIRED', + message: 'La evidencia de verificación debe ser una fotografía', + }); + } + if ( + dto.purpose === InspectionEvidencePurpose.COMPANY_RESPONSE + && (dto.kind !== InspectionEvidenceKind.DOCUMENT || mimeType !== 'application/pdf') + ) { + throw new BadRequestException({ + code: 'COMPANY_RESPONSE_PDF_REQUIRED', + message: 'La respuesta de la empresa debe adjuntarse como PDF', + }); + } + if ( + dto.purpose === InspectionEvidencePurpose.OTHER_DOCUMENT + && dto.kind !== InspectionEvidenceKind.DOCUMENT + ) { + throw new BadRequestException({ + code: 'INSPECTION_OTHER_DOCUMENT_PDF_REQUIRED', + message: 'Otros documentos deben adjuntarse como PDF', + }); + } + } + + private validateCommunicationPurpose( + purpose: InspectionEvidencePurpose, + communication: CommunicationLink | null, + ): void { + if ( + purpose === InspectionEvidencePurpose.COMPANY_RESPONSE + && (!communication + || communication.type !== InspectionCommunicationType.COMPANY_RESPONSE + || communication.direction !== InspectionCommunicationDirection.INBOUND) + ) { + throw new BadRequestException({ + code: 'COMPANY_RESPONSE_COMMUNICATION_REQUIRED', + message: 'El PDF debe vincularse con una respuesta de empresa registrada', + }); + } + if ( + purpose === InspectionEvidencePurpose.COMMUNICATION_ATTACHMENT + && !communication + ) { + throw new BadRequestException({ + code: 'COMMUNICATION_ATTACHMENT_LINK_REQUIRED', + message: 'El documento debe vincularse con una comunicación', + }); + } + if (purpose === InspectionEvidencePurpose.VERIFICATION && communication) { + throw new BadRequestException({ + code: 'VERIFICATION_EVIDENCE_COMMUNICATION_NOT_ALLOWED', + message: 'La foto de verificación se vincula con la visita, no con una comunicación', + }); + } + } + + private assertFindingOpen(context: FindingContext): void { + if (context.status !== InspectionFindingStatus.OPEN) { + throw new ConflictException({ + code: 'INSPECTION_FINDING_NOT_OPEN', + message: 'No se pueden agregar registros a un hallazgo cerrado', + }); + } + } + + private assertObservationEditable(context: FindingContext): void { + if ( + context.actStatus !== InspectionActStatus.DRAFT + || context.visitStatus !== InspectionVisitStatus.IN_PROGRESS + ) { + throw new ConflictException({ + code: 'INSPECTION_OBSERVATION_EVIDENCE_LOCKED', + message: 'La evidencia de campo se carga durante la visita y con el acta en borrador', + }); + } + } + + private async assertVerificationEditable( + manager: EntityManager, + findingId: string, + verificationVisitId: string, + principal: AuthPrincipal, + ): Promise { + const [row] = await manager.query(` + SELECT visit.id, visit.status + FROM inspection_finding_verification_visits verification_link + INNER JOIN inspection_visits visit ON visit.id = verification_link.visit_id + WHERE verification_link.finding_id = $1 AND verification_link.visit_id = $2 + `, [findingId, verificationVisitId]) as Array<{ id: string; status: InspectionVisitStatus }>; + if (!row) { + throw new BadRequestException({ + code: 'INSPECTION_VERIFICATION_LINK_INVALID', + message: 'El hallazgo no pertenece a la visita de verificación indicada', + }); + } + if (row.status !== InspectionVisitStatus.IN_PROGRESS) { + throw new ConflictException({ + code: 'INSPECTION_VERIFICATION_EVIDENCE_LOCKED', + message: 'La evidencia de verificación sólo se carga durante la visita en curso', + }); + } + await this.assertActorAssigned(manager, verificationVisitId, principal); + } + + private async assertActorAssigned( + manager: EntityManager, + visitId: string, + principal: AuthPrincipal, + ): Promise { + if (principal.permissions.includes('inspections.manage')) return; + const rows = await manager.query(` + SELECT 1 FROM inspection_visit_members + WHERE visit_id = $1 AND user_id = $2 AND included = true + `, [visitId, principal.userId]) as unknown[]; + if (rows.length === 0) { + throw new ForbiddenException({ + code: 'INSPECTION_VISIT_NOT_ASSIGNED', + message: 'La visita no está asignada al usuario actual', + }); + } + } + + private async requireFinding(manager: EntityManager, id: string): Promise { + const rows = await manager.query( + 'SELECT 1 FROM inspection_findings WHERE id = $1', + [id], + ) as unknown[]; + if (rows.length === 0) throw findingNotFound(); + } + + private async lockFindingContext( + manager: EntityManager, + id: string, + ): Promise { + const [context] = await manager.query(` + SELECT + finding.id, + finding.status, + act.id AS "actId", + act.status AS "actStatus", + visit.id AS "visitId", + visit.status AS "visitStatus" + FROM inspection_findings finding + INNER JOIN inspection_acts act ON act.id = finding.act_id + INNER JOIN inspection_visits visit ON visit.id = act.visit_id + WHERE finding.id = $1 + FOR UPDATE OF finding + `, [id]) as FindingContext[]; + if (!context) throw findingNotFound(); + return context; + } + + private async requireCommunication( + manager: EntityManager, + findingId: string, + communicationId: string, + ): Promise { + const [communication] = await manager.query(` + SELECT id, type, direction, subject + FROM inspection_finding_communications + WHERE id = $1 AND finding_id = $2 + `, [communicationId, findingId]) as CommunicationLink[]; + if (!communication) { + throw new BadRequestException({ + code: 'INSPECTION_COMMUNICATION_INVALID', + message: 'La comunicación seleccionada no pertenece al hallazgo', + }); + } + return communication; + } + + private evidenceSelect(): string { + return ` + SELECT + evidence.id, + evidence.finding_id AS "findingId", + evidence.communication_id AS "communicationId", + evidence.verification_visit_id AS "verificationVisitId", + CASE WHEN communication.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( + 'id', communication.id, + 'type', communication.type, + 'direction', communication.direction, + 'subject', communication.subject + ) END AS communication, + evidence.kind, + evidence.purpose, + evidence.original_name AS "originalName", + evidence.stored_name AS "storedName", + evidence.mime_type AS "mimeType", + evidence.size_bytes::integer AS "sizeBytes", + evidence.sha256, + evidence.title, + evidence.description, + evidence.captured_at AS "capturedAt", + evidence.latitude::double precision AS latitude, + evidence.longitude::double precision AS longitude, + evidence.accuracy_m::double precision AS "accuracyM", + evidence.device_label AS "deviceLabel", + evidence.source, + evidence.uploaded_by AS "uploadedBy", + uploader.username AS "uploadedByUsername", + evidence.created_at AS "createdAt" + FROM inspection_finding_evidence evidence + LEFT JOIN inspection_finding_communications communication + ON communication.id = evidence.communication_id + LEFT JOIN users uploader ON uploader.id = evidence.uploaded_by + `; + } + + private async loadEvidence( + manager: EntityManager, + id: string, + ): Promise { + const [evidence] = await manager.query( + `${this.evidenceSelect()} WHERE evidence.id = $1`, + [id], + ) as StoredInspectionEvidence[]; + if (!evidence) throw evidenceNotFound(); + return evidence; + } + + private communicationSelect(where: string): string { + return ` + SELECT + communication.id, + communication.finding_id AS "findingId", + communication.direction, + communication.channel, + communication.type, + communication.occurred_at AS "occurredAt", + communication.subject, + communication.details, + communication.contact_name AS "contactName", + communication.contact_email AS "contactEmail", + communication.created_by AS "createdBy", + creator.username AS "createdByUsername", + COUNT(evidence.id)::integer AS "attachmentCount", + communication.created_at AS "createdAt" + FROM inspection_finding_communications communication + LEFT JOIN users creator ON creator.id = communication.created_by + LEFT JOIN inspection_finding_evidence evidence + ON evidence.communication_id = communication.id + ${where} + GROUP BY communication.id, creator.username + `; + } + + private async loadCommunication( + manager: EntityManager, + id: string, + ): Promise { + const [communication] = await manager.query( + this.communicationSelect('WHERE communication.id = $1'), + [id], + ) as InspectionCommunicationView[]; + if (!communication) { + throw new NotFoundException({ + code: 'INSPECTION_COMMUNICATION_NOT_FOUND', + message: 'Comunicación no encontrada', + }); + } + return communication; + } + + private evidenceAuditView( + evidence: StoredInspectionEvidence, + ): Record { + return { + findingId: evidence.findingId, + communicationId: evidence.communicationId, + verificationVisitId: evidence.verificationVisitId, + kind: evidence.kind, + purpose: evidence.purpose, + originalName: evidence.originalName, + mimeType: evidence.mimeType, + sizeBytes: evidence.sizeBytes, + sha256: evidence.sha256, + capturedAt: evidence.capturedAt, + latitude: evidence.latitude, + longitude: evidence.longitude, + accuracyM: evidence.accuracyM, + deviceLabel: evidence.deviceLabel, + source: evidence.source, + uploadedBy: evidence.uploadedBy, + immutable: true, + }; + } +} diff --git a/api-v3/src/inspection-findings/inspection-findings.controller.ts b/api-v3/src/inspection-findings/inspection-findings.controller.ts new file mode 100644 index 0000000..1540671 --- /dev/null +++ b/api-v3/src/inspection-findings/inspection-findings.controller.ts @@ -0,0 +1,98 @@ +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Patch, + Post, + Query, + Req, +} from '@nestjs/common'; +import { CurrentAuth } from '../auth/decorators/current-auth.decorator'; +import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator'; +import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; +import { CloseInspectionFindingDto } from './dto/close-inspection-finding.dto'; +import { CreateInspectionFindingDto } from './dto/create-inspection-finding.dto'; +import { ListInspectionFindingsQueryDto } from './dto/list-inspection-findings-query.dto'; +import { UpdateInspectionFindingFollowUpDto } from './dto/update-inspection-finding-follow-up.dto'; +import { UpdateInspectionFindingDto } from './dto/update-inspection-finding.dto'; +import { InspectionFindingsService } from './inspection-findings.service'; + +@Controller('inspection-acts/:actId/findings') +export class InspectionActFindingsController { + constructor(private readonly findings: InspectionFindingsService) {} + + @Get() + @RequirePermissions('inspection_findings.read') + list(@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string) { + return this.findings.listForAct(actId); + } + + @Post() + @RequirePermissions('inspection_findings.create') + create( + @Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string, + @Body() dto: CreateInspectionFindingDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.findings.create(actId, dto, principal, request); + } +} + +@Controller('inspection-findings') +export class InspectionFindingsController { + constructor(private readonly findings: InspectionFindingsService) {} + + @Get() + @RequirePermissions('inspection_findings.read') + list(@Query() query: ListInspectionFindingsQueryDto) { + return this.findings.listGlobal(query); + } + + @Get(':id') + @RequirePermissions('inspection_findings.read') + get(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) { + return this.findings.getById(id); + } + + @Get(':id/verification-history') + @RequirePermissions('inspection_findings.read') + getVerificationHistory(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) { + return this.findings.getVerificationHistory(id); + } + + @Patch(':id') + @RequirePermissions('inspection_findings.update') + update( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @Body() dto: UpdateInspectionFindingDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.findings.update(id, dto, principal, request); + } + + @Patch(':id/close') + @RequirePermissions('inspection_findings.close') + close( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @Body() dto: CloseInspectionFindingDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.findings.close(id, dto, principal, request); + } + + @Patch(':id/follow-up') + @RequirePermissions('inspection_findings.follow_up') + updateFollowUp( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @Body() dto: UpdateInspectionFindingFollowUpDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.findings.updateFollowUp(id, dto, principal, request); + } +} diff --git a/api-v3/src/inspection-findings/inspection-findings.module.ts b/api-v3/src/inspection-findings/inspection-findings.module.ts new file mode 100644 index 0000000..2e64d20 --- /dev/null +++ b/api-v3/src/inspection-findings/inspection-findings.module.ts @@ -0,0 +1,33 @@ +import { Module } from '@nestjs/common'; +import { AuditModule } from '../audit/audit.module'; +import { FindingCatalogController } from './finding-catalog.controller'; +import { FindingCatalogService } from './finding-catalog.service'; +import { + InspectionActFindingsController, + InspectionFindingsController, +} from './inspection-findings.controller'; +import { InspectionFindingsService } from './inspection-findings.service'; +import { + InspectionEvidenceContentController, + InspectionFindingCommunicationsController, + InspectionFindingEvidenceController, +} from './inspection-evidence.controller'; +import { InspectionEvidenceService } from './inspection-evidence.service'; + +@Module({ + imports: [AuditModule], + controllers: [ + FindingCatalogController, + InspectionActFindingsController, + InspectionFindingsController, + InspectionFindingEvidenceController, + InspectionFindingCommunicationsController, + InspectionEvidenceContentController, + ], + providers: [ + FindingCatalogService, + InspectionFindingsService, + InspectionEvidenceService, + ], +}) +export class InspectionFindingsModule {} diff --git a/api-v3/src/inspection-findings/inspection-findings.service.ts b/api-v3/src/inspection-findings/inspection-findings.service.ts new file mode 100644 index 0000000..9f58330 --- /dev/null +++ b/api-v3/src/inspection-findings/inspection-findings.service.ts @@ -0,0 +1,1268 @@ +import { + BadRequestException, + 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, + InspectionAct, + InspectionActStatus, + InspectionFinding, + InspectionFindingResponseDueBasis, + InspectionFindingStatus, + InspectionFindingVerificationEventType, + InspectionFindingVersionEvent, + InspectionVerificationOutcome, + InspectionVisit, + InspectionVisitStatus, +} from '../database/entities'; +import type { CloseInspectionFindingDto } from './dto/close-inspection-finding.dto'; +import type { CreateInspectionFindingDto } from './dto/create-inspection-finding.dto'; +import type { ListInspectionFindingsQueryDto } from './dto/list-inspection-findings-query.dto'; +import type { UpdateInspectionFindingFollowUpDto } from './dto/update-inspection-finding-follow-up.dto'; +import type { UpdateInspectionFindingDto } from './dto/update-inspection-finding.dto'; +import { assertMobileInspector } from '../inspection-operations/mobile-inspector-policy'; +import { appendVerificationEvent } from '../inspection-verifications/verification-event-ledger'; + +interface FindingContextView { + id: string; + code: string; + name: string; +} + +interface FindingAssetView { + id: string; + code: string; + name: string; + commonName: string | null; + typeName: string; + operatorCompany: FindingContextView | null; + operationalArea: FindingContextView | null; +} + +type FindingCatalogView = { + id: string; + code: string; + categoryId: string; + categoryName: string; + sourceNumber: number; + revision: number; + suggestedSeverity: number | null; +} | null; + +interface FindingDocumentView { + actId: string; + actCode: string; + actStatus: InspectionActStatus; + visitId: string; + visitCode: string; + visitTitle: string; + visitStatus: InspectionVisitStatus; +} + +export interface InspectionFindingListItem { + id: string; + actId: string; + assetId: string; + catalogItemId: string | null; + findingNumber: number; + code: string; + status: InspectionFindingStatus; + title: string; + description: string; + legalBasis: string | null; + glossary: string | null; + catalogRevision: number | null; + suggestedSeverity: number | null; + severity: number | null; + correctionDueOn: string | null; + responseDueBasis: InspectionFindingResponseDueBasis | null; + responseDueDays: number | null; + responseDueBaseOn: string | null; + reportNotifiedOn: string | null; + companyResponse: string | null; + companyResponseReceivedOn: string | null; + companyCommittedCorrectionOn: string | null; + nextControlOn: string | null; + currentVersion: number; + closedAt: Date | null; + closureNotes: string | null; + asset: FindingAssetView; + catalog: FindingCatalogView; + document: FindingDocumentView; + verificationVisit: { + id: string; + code: string; + title: string; + status: InspectionVisitStatus; + plannedStartAt: Date | null; + } | null; + latestVerification: { + visitId: string; + visitCode: string; + visitStatus: InspectionVisitStatus; + targetControlOn: string | null; + outcome: 'RESOLVED' | 'NOT_RESOLVED' | 'REQUIRES_NEW_DATE' | null; + resultNotes: string | null; + verifiedAt: Date | null; + resultRecordedAt: Date | null; + rescheduledControlOn: string | null; + evidenceCount: number; + } | null; + createdAt: Date; + updatedAt: Date; +} + +interface FindingVersionView { + id: string; + versionNumber: number; + event: InspectionFindingVersionEvent; + snapshot: Record; + actorUserId: string | null; + actorUsername: string | null; + createdAt: Date; +} + +export interface FindingVerificationHistoryEvent { + id: string; + eventType: InspectionFindingVerificationEventType; + occurredAt: Date; + targetControlOn: string | null; + previousControlOn: string | null; + nextControlOn: string | null; + outcome: InspectionVerificationOutcome | null; + notes: string | null; + actorUserId: string | null; + actorUsername: string | null; + verificationVisit: { + id: string; + code: string; + title: string; + status: InspectionVisitStatus; + plannedStartAt: Date | null; + } | null; + evidenceCount: number; +} + +export type InspectionFindingOfficeStage = + | 'WAITING_COMPANY' + | 'COMPANY_OVERDUE' + | 'DEFINE_VERIFICATION' + | 'PLAN_VERIFICATION' + | 'VERIFICATION_PLANNED' + | 'RESCHEDULE_VERIFICATION' + | 'READY_TO_CLOSE' + | 'CLOSED'; + +export interface InspectionFindingOfficeWorkflow { + stage: InspectionFindingOfficeStage; + label: string; + action: string; + dueOn: string | null; + overdue: boolean; +} + +export interface InspectionFindingView extends InspectionFindingListItem { + versions: FindingVersionView[]; + verificationHistory: FindingVerificationHistoryEvent[]; + officeWorkflow: InspectionFindingOfficeWorkflow; +} + + +export interface InspectionFindingWorkflowCounters { + open: number; + waitingCompany: number; + companyOverdue: number; + companyDueNext7Days: number; + awaitingVerificationSchedule: number; + toVerify: number; + verificationOverdue: number; + verificationNext30Days: number; + readyToClose: number; + closed: number; +} + +export interface InspectionFindingGlobalPage { + data: InspectionFindingListItem[]; + meta: { page: number; pageSize: number; total: number; totalPages: number }; + counters: InspectionFindingWorkflowCounters; +} + +interface CatalogSnapshot { + id: string; + title: string; + legalBasis: string | null; + glossary: string | null; + revision: number; + suggestedSeverity: number | null; +} + +function findingNotFound(): NotFoundException { + return new NotFoundException({ + code: 'INSPECTION_FINDING_NOT_FOUND', + message: 'Hallazgo de inspección no encontrado', + }); +} + +function actNotFound(): NotFoundException { + return new NotFoundException({ + code: 'INSPECTION_ACT_NOT_FOUND', + message: 'Acta de inspección no encontrada', + }); +} + +function addCalendarDays(dateOnly: string, days: number): string { + const [year, month, day] = dateOnly.split('-').map(Number); + const date = new Date(Date.UTC(year, month - 1, day)); + date.setUTCDate(date.getUTCDate() + days); + return date.toISOString().slice(0, 10); +} + +@Injectable() +export class InspectionFindingsService { + constructor( + private readonly dataSource: DataSource, + private readonly audit: AuditService, + ) {} + + async listForAct(actId: string): Promise<{ data: InspectionFindingListItem[] }> { + await this.requireAct(this.dataSource.manager, actId); + const data = await this.dataSource.query( + `${this.findingSelect('WHERE finding.act_id = $1')} + ORDER BY finding.finding_number, finding.id`, + [actId], + ) as InspectionFindingListItem[]; + return { data }; + } + + + async listGlobal(query: ListInspectionFindingsQueryDto): Promise { + const page = query.page ?? 1; + const pageSize = query.pageSize ?? 25; + const workflow = query.workflow ?? 'OPEN'; + const values: unknown[] = []; + const filters: string[] = []; + const today = `(CURRENT_TIMESTAMP AT TIME ZONE 'America/Argentina/Mendoza')::date`; + const latestVerificationOutcome = `(SELECT latest_verification.outcome + FROM inspection_finding_verification_visits latest_verification + WHERE latest_verification.finding_id = finding.id + AND latest_verification.outcome IS NOT NULL + ORDER BY latest_verification.result_recorded_at DESC NULLS LAST, latest_verification.created_at DESC, latest_verification.id DESC + LIMIT 1)`; + const latestVerificationVisitStatus = `(SELECT latest_visit.status + FROM inspection_finding_verification_visits latest_verification + INNER JOIN inspection_visits latest_visit ON latest_visit.id = latest_verification.visit_id + WHERE latest_verification.finding_id = finding.id + AND latest_verification.outcome IS NOT NULL + ORDER BY latest_verification.result_recorded_at DESC NULLS LAST, latest_verification.created_at DESC, latest_verification.id DESC + LIMIT 1)`; + + if (query.search) { + values.push(`%${query.search}%`); + const parameter = `$${values.length}`; + filters.push(`( + finding.code ILIKE ${parameter} + OR finding.title ILIKE ${parameter} + OR asset.code ILIKE ${parameter} + OR asset.name ILIKE ${parameter} + OR company.name ILIKE ${parameter} + OR area.name ILIKE ${parameter} + OR act.code ILIKE ${parameter} + )`); + } + + if (query.companyId) { + values.push(query.companyId); + filters.push(`company.id = $${values.length}::uuid`); + } + if (query.areaId) { + values.push(query.areaId); + filters.push(`area.id = $${values.length}::uuid`); + } + if (query.inspectorId) { + values.push(query.inspectorId); + const inspector = `$${values.length}`; + filters.push(`( + visit.lead_inspector_user_id = ${inspector}::uuid + OR EXISTS ( + SELECT 1 FROM inspection_visit_members member_filter + WHERE member_filter.visit_id = visit.id + AND member_filter.included = true + AND member_filter.user_id = ${inspector}::uuid + ) + )`); + } + if (query.dateFrom) { + values.push(query.dateFrom); + filters.push(`act.occurred_at::date >= $${values.length}::date`); + } + if (query.dateTo) { + values.push(query.dateTo); + filters.push(`act.occurred_at::date <= $${values.length}::date`); + } + + if (workflow === 'OPEN') filters.push(`finding.status = 'OPEN'`); + if (workflow === 'WAITING_COMPANY') filters.push(`finding.status = 'OPEN' AND finding.company_response_received_on IS NULL`); + if (workflow === 'COMPANY_OVERDUE') filters.push(`finding.status = 'OPEN' AND finding.company_response_received_on IS NULL AND finding.correction_due_on IS NOT NULL AND finding.correction_due_on < ${today}`); + if (workflow === 'TO_SCHEDULE_VERIFICATION') filters.push(`finding.status = 'OPEN' AND finding.company_response_received_on IS NOT NULL AND finding.next_control_on IS NULL AND COALESCE(${latestVerificationOutcome}, '') <> 'RESOLVED'`); + if (workflow === 'TO_VERIFY') filters.push(`finding.status = 'OPEN' AND finding.company_response_received_on IS NOT NULL AND finding.next_control_on IS NOT NULL AND COALESCE(${latestVerificationOutcome}, '') <> 'RESOLVED'`); + if (workflow === 'VERIFICATION_OVERDUE') filters.push(`finding.status = 'OPEN' AND finding.company_response_received_on IS NOT NULL AND finding.next_control_on IS NOT NULL AND finding.next_control_on < ${today} AND COALESCE(${latestVerificationOutcome}, '') <> 'RESOLVED'`); + if (workflow === 'READY_TO_CLOSE') filters.push(`finding.status = 'OPEN' AND ${latestVerificationOutcome} = 'RESOLVED' AND ${latestVerificationVisitStatus} = 'CLOSED'`); + if (workflow === 'CLOSED') filters.push(`finding.status = 'CLOSED'`); + + const where = filters.length ? `WHERE ${filters.join(' AND ')}` : ''; + const countRows = await this.dataSource.query(` + SELECT COUNT(*)::integer AS total + 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 + INNER JOIN assets asset ON asset.id = finding.asset_id + LEFT JOIN assets company ON company.id = asset.operator_company_id + LEFT JOIN assets area ON area.id = asset.operational_area_id + ${where} + `, values) as Array<{ total: number }>; + const total = Number(countRows[0]?.total ?? 0); + values.push(pageSize); + const limitParameter = `$${values.length}`; + values.push((page - 1) * pageSize); + const offsetParameter = `$${values.length}`; + const data = await this.dataSource.query( + `${this.findingSelect(where)} + ORDER BY + CASE WHEN finding.status = 'OPEN' THEN 0 ELSE 1 END, + COALESCE(finding.next_control_on, finding.correction_due_on, '9999-12-31'::date), + finding.updated_at DESC, + finding.code + LIMIT ${limitParameter} OFFSET ${offsetParameter}`, + values, + ) as InspectionFindingListItem[]; + const [counters] = await this.dataSource.query(` + SELECT + COUNT(*) FILTER (WHERE status = 'OPEN')::integer AS "open", + COUNT(*) FILTER (WHERE status = 'OPEN' AND company_response_received_on IS NULL)::integer AS "waitingCompany", + COUNT(*) FILTER (WHERE status = 'OPEN' AND company_response_received_on IS NULL AND correction_due_on IS NOT NULL AND correction_due_on < ${today})::integer AS "companyOverdue", + COUNT(*) FILTER (WHERE status = 'OPEN' AND company_response_received_on IS NULL AND correction_due_on BETWEEN ${today} AND ${today} + 7)::integer AS "companyDueNext7Days", + COUNT(*) FILTER (WHERE status = 'OPEN' AND company_response_received_on IS NOT NULL AND next_control_on IS NULL AND COALESCE((SELECT v.outcome FROM inspection_finding_verification_visits v WHERE v.finding_id = inspection_findings.id AND v.outcome IS NOT NULL ORDER BY v.result_recorded_at DESC NULLS LAST, v.created_at DESC, v.id DESC LIMIT 1), '') <> 'RESOLVED')::integer AS "awaitingVerificationSchedule", + COUNT(*) FILTER (WHERE status = 'OPEN' AND company_response_received_on IS NOT NULL AND next_control_on IS NOT NULL AND COALESCE((SELECT v.outcome FROM inspection_finding_verification_visits v WHERE v.finding_id = inspection_findings.id AND v.outcome IS NOT NULL ORDER BY v.result_recorded_at DESC NULLS LAST, v.created_at DESC, v.id DESC LIMIT 1), '') <> 'RESOLVED')::integer AS "toVerify", + COUNT(*) FILTER (WHERE status = 'OPEN' AND company_response_received_on IS NOT NULL AND next_control_on IS NOT NULL AND next_control_on < ${today} AND COALESCE((SELECT v.outcome FROM inspection_finding_verification_visits v WHERE v.finding_id = inspection_findings.id AND v.outcome IS NOT NULL ORDER BY v.result_recorded_at DESC NULLS LAST, v.created_at DESC, v.id DESC LIMIT 1), '') <> 'RESOLVED')::integer AS "verificationOverdue", + COUNT(*) FILTER (WHERE status = 'OPEN' AND company_response_received_on IS NOT NULL AND next_control_on BETWEEN ${today} AND ${today} + 30 AND COALESCE((SELECT v.outcome FROM inspection_finding_verification_visits v WHERE v.finding_id = inspection_findings.id AND v.outcome IS NOT NULL ORDER BY v.result_recorded_at DESC NULLS LAST, v.created_at DESC, v.id DESC LIMIT 1), '') <> 'RESOLVED')::integer AS "verificationNext30Days", + COUNT(*) FILTER (WHERE status = 'OPEN' AND (SELECT v.outcome FROM inspection_finding_verification_visits v WHERE v.finding_id = inspection_findings.id AND v.outcome IS NOT NULL ORDER BY v.result_recorded_at DESC NULLS LAST, v.created_at DESC, v.id DESC LIMIT 1) = 'RESOLVED' AND (SELECT vv.status FROM inspection_finding_verification_visits v INNER JOIN inspection_visits vv ON vv.id = v.visit_id WHERE v.finding_id = inspection_findings.id AND v.outcome IS NOT NULL ORDER BY v.result_recorded_at DESC NULLS LAST, v.created_at DESC, v.id DESC LIMIT 1) = 'CLOSED')::integer AS "readyToClose", + COUNT(*) FILTER (WHERE status = 'CLOSED')::integer AS "closed" + FROM inspection_findings + `) as InspectionFindingWorkflowCounters[]; + return { + data, + meta: { + page, + pageSize, + total, + totalPages: Math.ceil(total / pageSize), + }, + counters: { + open: Number(counters?.open ?? 0), + waitingCompany: Number(counters?.waitingCompany ?? 0), + companyOverdue: Number(counters?.companyOverdue ?? 0), + companyDueNext7Days: Number(counters?.companyDueNext7Days ?? 0), + awaitingVerificationSchedule: Number(counters?.awaitingVerificationSchedule ?? 0), + toVerify: Number(counters?.toVerify ?? 0), + verificationOverdue: Number(counters?.verificationOverdue ?? 0), + verificationNext30Days: Number(counters?.verificationNext30Days ?? 0), + readyToClose: Number(counters?.readyToClose ?? 0), + closed: Number(counters?.closed ?? 0), + }, + }; + } + + async getById(id: string): Promise { + return this.dataSource.transaction((manager) => this.loadView(manager, id)); + } + + async getVerificationHistory(id: string): Promise { + const [exists] = await this.dataSource.query('SELECT id FROM inspection_findings WHERE id = $1', [id]) as Array<{ id: string }>; + if (!exists) throw findingNotFound(); + return this.loadVerificationHistory(this.dataSource.manager, id); + } + + async create( + actId: string, + dto: CreateInspectionFindingDto, + principal: AuthPrincipal, + request: RequestWithContext, + ): Promise { + assertMobileInspector(principal); + return this.dataSource.transaction(async (manager) => { + const act = await this.lockAct(manager, actId); + const visit = await this.lockVisit(manager, act.visitId); + this.assertActEditable(act, visit); + await this.assertActorAssigned(manager, visit.id, principal); + await this.assertActAsset(manager, actId, dto.assetId); + + const catalog = dto.catalogItemId + ? await this.requireCatalogItem(manager, dto.catalogItemId, dto.assetId) + : null; + if (!catalog && !dto.customTitle?.trim()) { + throw new BadRequestException({ + code: 'INSPECTION_FINDING_TITLE_REQUIRED', + message: 'Un hallazgo personalizado debe indicar un título', + }); + } + + const findingNumber = await this.allocateFindingNumber(manager, actId); + if (findingNumber > 999) { + throw new ConflictException({ + code: 'INSPECTION_FINDING_SEQUENCE_EXHAUSTED', + message: 'El acta alcanzó el máximo de 999 hallazgos', + }); + } + const finding = manager.getRepository(InspectionFinding).create({ + actId, + assetId: dto.assetId, + catalogItemId: catalog?.id ?? null, + findingNumber, + code: `${act.code}-H${String(findingNumber).padStart(3, '0')}`, + status: InspectionFindingStatus.OPEN, + title: catalog?.title ?? dto.customTitle!.trim(), + description: dto.description, + legalBasis: catalog?.legalBasis ?? dto.customLegalBasis ?? null, + glossary: catalog?.glossary ?? null, + catalogRevision: catalog?.revision ?? null, + suggestedSeverity: catalog?.suggestedSeverity ?? null, + severity: dto.severity ?? catalog?.suggestedSeverity ?? null, + correctionDueOn: dto.correctionDueOn ?? null, + companyResponse: null, + companyResponseReceivedOn: null, + companyCommittedCorrectionOn: null, + nextControlOn: null, + currentVersion: 0, + closedAt: null, + closedBy: null, + closureNotes: null, + createdBy: principal.userId, + updatedBy: principal.userId, + }); + await manager.getRepository(InspectionFinding).save(finding); + if (!catalog) { + await manager.query(` + INSERT INTO finding_catalog_proposals ( + finding_id, asset_id, asset_type_id, proposed_title, proposed_legal_basis, + proposed_severity, description, status, created_by + ) + SELECT $1, asset.id, asset.asset_type_id, $2, $3, $4, $5, 'PENDING', $6 + FROM assets asset + WHERE asset.id = $7 + ON CONFLICT (finding_id) DO NOTHING + `, [ + finding.id, + finding.title, + finding.legalBasis, + finding.severity, + finding.description, + principal.userId, + finding.assetId, + ]); + } + const versionNumber = await this.captureVersion( + manager, + finding, + InspectionFindingVersionEvent.CREATED, + principal, + ); + const created = await this.loadView(manager, finding.id); + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.INSPECTION_FINDING_CREATED, + entityType: 'inspection_finding', + entityId: finding.id, + afterData: this.auditView(created), + metadata: { actId, visitId: visit.id, versionNumber }, + }, manager); + return created; + }); + } + + async update( + id: string, + dto: UpdateInspectionFindingDto, + principal: AuthPrincipal, + request: RequestWithContext, + ): Promise { + assertMobileInspector(principal); + if (Object.keys(dto).length === 0) { + throw new BadRequestException({ code: 'NO_CHANGES', message: 'No se recibieron cambios' }); + } + return this.dataSource.transaction(async (manager) => { + const finding = await this.lockFinding(manager, id); + const act = await this.lockAct(manager, finding.actId); + const visit = await this.lockVisit(manager, act.visitId); + this.assertActEditable(act, visit); + this.assertFindingOpen(finding); + await this.assertActorAssigned(manager, visit.id, principal); + if (dto.assetId !== undefined) { + await this.assertActAsset(manager, act.id, dto.assetId); + if (finding.catalogItemId) { + await this.requireCatalogItem(manager, finding.catalogItemId, dto.assetId); + } + } + const before = await this.loadView(manager, id); + if (dto.assetId !== undefined) { + finding.assetId = dto.assetId; + if (!finding.catalogItemId) { + await manager.query(` + UPDATE finding_catalog_proposals proposal SET + asset_id = asset.id, + asset_type_id = asset.asset_type_id, + updated_at = CURRENT_TIMESTAMP + FROM assets asset + WHERE proposal.finding_id = $1 + AND proposal.status = 'PENDING' + AND asset.id = $2 + `, [finding.id, dto.assetId]); + } + } + if (dto.description !== undefined) finding.description = dto.description; + if (dto.severity !== undefined) finding.severity = dto.severity; + if (dto.correctionDueOn !== undefined) finding.correctionDueOn = dto.correctionDueOn; + finding.updatedBy = principal.userId; + await manager.getRepository(InspectionFinding).save(finding); + const versionNumber = await this.captureVersion( + manager, + finding, + InspectionFindingVersionEvent.UPDATED, + principal, + ); + const updated = await this.loadView(manager, id); + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.INSPECTION_FINDING_UPDATED, + entityType: 'inspection_finding', + entityId: id, + beforeData: this.auditView(before), + afterData: this.auditView(updated), + metadata: { actId: act.id, visitId: visit.id, versionNumber }, + }, manager); + return updated; + }); + } + + + async close( + id: string, + dto: CloseInspectionFindingDto, + principal: AuthPrincipal, + request: RequestWithContext, + ): Promise { + return this.dataSource.transaction(async (manager) => { + const finding = await this.lockFinding(manager, id); + this.assertFindingOpen(finding); + const [activeVerification] = await manager.query(` + SELECT visit.code, visit.status + FROM inspection_finding_verification_visits verification_link + INNER JOIN inspection_visits visit ON visit.id = verification_link.visit_id + WHERE verification_link.finding_id = $1 + AND visit.status IN ('DRAFT', 'PLANNED', 'IN_PROGRESS') + ORDER BY verification_link.created_at DESC + LIMIT 1 + `, [id]) as Array<{ code: string; status: InspectionVisitStatus }>; + if (activeVerification) { + throw new ConflictException({ + code: 'INSPECTION_FINDING_VERIFICATION_ACTIVE', + message: `El hallazgo tiene una verificación activa (${activeVerification.code}) y no puede cerrarse todavía`, + }); + } + const before = await this.loadView(manager, id); + finding.status = InspectionFindingStatus.CLOSED; + finding.closedAt = new Date(); + finding.closedBy = principal.userId; + finding.closureNotes = dto.closureNotes; + finding.updatedBy = principal.userId; + await manager.getRepository(InspectionFinding).save(finding); + const versionNumber = await this.captureVersion( + manager, + finding, + InspectionFindingVersionEvent.FOLLOW_UP_UPDATED, + principal, + ); + const updated = await this.loadView(manager, id); + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.INSPECTION_FINDING_CLOSED, + entityType: 'inspection_finding', + entityId: id, + beforeData: this.auditView(before), + afterData: this.auditView(updated), + metadata: { + actId: updated.document.actId, + visitId: updated.document.visitId, + versionNumber, + }, + }, manager); + return updated; + }); + } + + async updateFollowUp( + id: string, + dto: UpdateInspectionFindingFollowUpDto, + principal: AuthPrincipal, + request: RequestWithContext, + ): Promise { + if (Object.keys(dto).length === 0) { + throw new BadRequestException({ code: 'NO_CHANGES', message: 'No se recibieron cambios' }); + } + return this.dataSource.transaction(async (manager) => { + const finding = await this.lockFinding(manager, id); + this.assertFindingOpen(finding); + const before = await this.loadView(manager, id); + + if (finding.companyResponseReceivedOn && (dto.companyResponse !== undefined || dto.companyResponseReceivedOn !== undefined)) { + throw new ConflictException({ + code: 'INSPECTION_FINDING_RESPONSE_IMMUTABLE', + message: 'La primera respuesta registrada no se modifica. Las nuevas presentaciones deben agregarse a la trazabilidad.', + }); + } + + const deadlineTouched = dto.responseDueBasis !== undefined + || dto.responseDueDays !== undefined + || dto.reportNotifiedOn !== undefined; + if (deadlineTouched && finding.companyResponseReceivedOn) { + throw new ConflictException({ + code: 'INSPECTION_FINDING_RESPONSE_DEADLINE_LOCKED', + message: 'El plazo administrativo ya no puede modificarse después de registrar la respuesta de la empresa', + }); + } + if (deadlineTouched) { + const basis = dto.responseDueBasis !== undefined ? dto.responseDueBasis : finding.responseDueBasis; + const days = dto.responseDueDays !== undefined ? dto.responseDueDays : finding.responseDueDays; + const reportNotifiedOn = dto.reportNotifiedOn !== undefined ? dto.reportNotifiedOn : finding.reportNotifiedOn; + if (!basis || days === null || days === undefined) { + throw new BadRequestException({ + code: 'INSPECTION_FINDING_RESPONSE_DEADLINE_INCOMPLETE', + message: 'Definí el origen del plazo y la cantidad de días para calcular el vencimiento de respuesta', + }); + } + let baseOn: string; + if (basis === InspectionFindingResponseDueBasis.FINDING_DATE) { + const [row] = await manager.query(` + SELECT act.occurred_at::date::text AS "findingDate" + FROM inspection_acts act + WHERE act.id = $1 + `, [finding.actId]) as Array<{ findingDate: string }>; + if (!row?.findingDate) throw actNotFound(); + baseOn = row.findingDate; + } else { + if (!reportNotifiedOn) { + throw new BadRequestException({ + code: 'INSPECTION_FINDING_REPORT_NOTIFICATION_REQUIRED', + message: 'Indicá la fecha en que el informe fue notificado para iniciar el plazo de respuesta', + }); + } + baseOn = reportNotifiedOn; + } + finding.responseDueBasis = basis; + finding.responseDueDays = days; + finding.reportNotifiedOn = reportNotifiedOn ?? null; + finding.responseDueBaseOn = baseOn; + finding.correctionDueOn = addCalendarDays(baseOn, days); + } + + const response = dto.companyResponse !== undefined + ? dto.companyResponse + : finding.companyResponse; + const receivedOn = dto.companyResponseReceivedOn !== undefined + ? dto.companyResponseReceivedOn + : finding.companyResponseReceivedOn; + const committedOn = dto.companyCommittedCorrectionOn !== undefined + ? dto.companyCommittedCorrectionOn + : finding.companyCommittedCorrectionOn; + if ((response === null) !== (receivedOn === null)) { + throw new BadRequestException({ + code: 'INSPECTION_FINDING_RESPONSE_INCOMPLETE', + message: 'La respuesta de la empresa debe incluir texto y fecha de recepción', + }); + } + if (committedOn && !receivedOn) { + throw new BadRequestException({ + code: 'INSPECTION_FINDING_COMMITMENT_WITHOUT_RESPONSE', + message: 'La fecha prometida requiere registrar primero la respuesta de la empresa', + }); + } + if (committedOn && receivedOn && committedOn < receivedOn) { + throw new BadRequestException({ + code: 'INSPECTION_FINDING_COMMITMENT_BEFORE_RESPONSE', + message: 'La fecha prometida no puede ser anterior a la respuesta recibida', + }); + } + + if (dto.companyResponse !== undefined) finding.companyResponse = dto.companyResponse; + if (dto.companyResponseReceivedOn !== undefined) { + finding.companyResponseReceivedOn = dto.companyResponseReceivedOn; + } + if (dto.companyCommittedCorrectionOn !== undefined) { + finding.companyCommittedCorrectionOn = dto.companyCommittedCorrectionOn; + } + if (dto.nextControlOn !== undefined) finding.nextControlOn = dto.nextControlOn; + finding.updatedBy = principal.userId; + await manager.getRepository(InspectionFinding).save(finding); + if (dto.nextControlOn !== undefined && dto.nextControlOn !== before.nextControlOn) { + const eventType = dto.nextControlOn === null + ? InspectionFindingVerificationEventType.CONTROL_DATE_CLEARED + : before.nextControlOn === null + ? InspectionFindingVerificationEventType.CONTROL_DATE_DEFINED + : InspectionFindingVerificationEventType.CONTROL_DATE_CHANGED; + await appendVerificationEvent(manager, { + findingId: id, + eventType, + occurredAt: new Date(), + targetControlOn: dto.nextControlOn, + previousControlOn: before.nextControlOn, + nextControlOn: dto.nextControlOn, + notes: dto.nextControlOn === null + ? 'Fecha de verificación retirada desde seguimiento de oficina' + : before.nextControlOn === null + ? 'Fecha de verificación definida desde seguimiento de oficina' + : 'Fecha de verificación reprogramada desde seguimiento de oficina', + }, principal); + } + const versionNumber = await this.captureVersion( + manager, + finding, + InspectionFindingVersionEvent.FOLLOW_UP_UPDATED, + principal, + ); + const updated = await this.loadView(manager, id); + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.INSPECTION_FINDING_FOLLOW_UP_UPDATED, + entityType: 'inspection_finding', + entityId: id, + beforeData: this.auditView(before), + afterData: this.auditView(updated), + metadata: { + actId: updated.document.actId, + visitId: updated.document.visitId, + versionNumber, + remainsOpen: updated.status === InspectionFindingStatus.OPEN, + }, + }, manager); + return updated; + }); + } + + private findingSelect(where: string): string { + return ` + SELECT + finding.id, + finding.act_id AS "actId", + finding.asset_id AS "assetId", + finding.catalog_item_id AS "catalogItemId", + finding.finding_number AS "findingNumber", + finding.code, + finding.status, + finding.title, + finding.description, + finding.legal_basis AS "legalBasis", + finding.glossary, + finding.catalog_revision AS "catalogRevision", + finding.suggested_severity AS "suggestedSeverity", + finding.severity, + finding.correction_due_on AS "correctionDueOn", + finding.response_due_basis AS "responseDueBasis", + finding.response_due_days AS "responseDueDays", + finding.response_due_base_on AS "responseDueBaseOn", + finding.report_notified_on AS "reportNotifiedOn", + finding.company_response AS "companyResponse", + finding.company_response_received_on AS "companyResponseReceivedOn", + finding.company_committed_correction_on AS "companyCommittedCorrectionOn", + finding.next_control_on AS "nextControlOn", + finding.current_version AS "currentVersion", + finding.closed_at AS "closedAt", + finding.closure_notes AS "closureNotes", + JSONB_BUILD_OBJECT( + 'id', asset.id, + 'code', asset.code, + 'name', asset.name, + 'commonName', asset.common_name, + 'typeName', asset_type.name, + 'operatorCompany', CASE WHEN company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( + 'id', company.id, 'code', company.code, 'name', company.name + ) END, + 'operationalArea', CASE WHEN area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( + 'id', area.id, 'code', area.code, 'name', area.name + ) END + ) AS asset, + CASE WHEN catalog.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( + 'id', catalog.id, + 'code', catalog.code, + 'categoryId', category.id, + 'categoryName', category.name, + 'sourceNumber', catalog.source_number, + 'revision', finding.catalog_revision, + 'suggestedSeverity', finding.suggested_severity + ) END AS catalog, + JSONB_BUILD_OBJECT( + 'actId', act.id, + 'actCode', act.code, + 'actStatus', act.status, + 'visitId', visit.id, + 'visitCode', visit.code, + 'visitTitle', visit.title, + 'visitStatus', visit.status + ) AS document, + CASE WHEN verification_visit.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( + 'id', verification_visit.id, + 'code', verification_visit.code, + 'title', verification_visit.title, + 'status', verification_visit.status, + 'plannedStartAt', verification_visit.planned_start_at + ) END AS "verificationVisit", + CASE WHEN latest_verification.visit_id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( + 'visitId', latest_verification.visit_id, + 'visitCode', latest_verification.visit_code, + 'visitStatus', latest_verification.visit_status, + 'targetControlOn', latest_verification.target_control_on, + 'outcome', latest_verification.outcome, + 'resultNotes', latest_verification.result_notes, + 'verifiedAt', latest_verification.verified_at, + 'resultRecordedAt', latest_verification.result_recorded_at, + 'rescheduledControlOn', latest_verification.rescheduled_control_on, + 'evidenceCount', latest_verification.evidence_count + ) END AS "latestVerification", + finding.created_at AS "createdAt", + finding.updated_at AS "updatedAt" + 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 + INNER JOIN assets asset ON asset.id = finding.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 finding_catalog_items catalog ON catalog.id = finding.catalog_item_id + LEFT JOIN finding_categories category ON category.id = catalog.category_id + LEFT JOIN LATERAL ( + SELECT verification_target.id, verification_target.code, verification_target.title, + verification_target.status, verification_target.planned_start_at + FROM inspection_finding_verification_visits verification_link + INNER JOIN inspection_visits verification_target ON verification_target.id = verification_link.visit_id + WHERE verification_link.finding_id = finding.id + AND verification_target.status IN ('DRAFT', 'PLANNED', 'IN_PROGRESS') + ORDER BY verification_link.created_at DESC, verification_link.id DESC + LIMIT 1 + ) verification_visit ON true + LEFT JOIN LATERAL ( + SELECT + verification_link.visit_id, + verification_target.code AS visit_code, + verification_target.status AS visit_status, + verification_link.target_control_on, + verification_link.outcome, + verification_link.result_notes, + verification_link.verified_at, + verification_link.result_recorded_at, + verification_link.rescheduled_control_on, + (SELECT COUNT(*)::integer + FROM inspection_finding_evidence evidence + WHERE evidence.finding_id = finding.id + AND evidence.verification_visit_id = verification_link.visit_id + AND evidence.purpose = 'VERIFICATION') AS evidence_count + FROM inspection_finding_verification_visits verification_link + INNER JOIN inspection_visits verification_target ON verification_target.id = verification_link.visit_id + WHERE verification_link.finding_id = finding.id + ORDER BY COALESCE(verification_link.result_recorded_at, verification_link.created_at) DESC, + verification_link.id DESC + LIMIT 1 + ) latest_verification ON true + ${where} + `; + } + + private async loadView(manager: EntityManager, id: string): Promise { + const [finding] = await manager.query( + this.findingSelect('WHERE finding.id = $1'), + [id], + ) as InspectionFindingListItem[]; + if (!finding) throw findingNotFound(); + const versions = await manager.query(` + SELECT + id, + version_number AS "versionNumber", + event, + snapshot, + actor_user_id AS "actorUserId", + actor_username AS "actorUsername", + created_at AS "createdAt" + FROM inspection_finding_versions + WHERE finding_id = $1 + ORDER BY version_number DESC + `, [id]) as FindingVersionView[]; + const verificationHistory = await this.loadVerificationHistory(manager, id); + return { ...finding, versions, verificationHistory, officeWorkflow: this.officeWorkflow(finding) }; + } + + private async loadVerificationHistory( + manager: EntityManager, + findingId: string, + ): Promise { + return manager.query(` + SELECT + event.id, + event.event_type AS "eventType", + event.occurred_at AS "occurredAt", + event.target_control_on AS "targetControlOn", + event.previous_control_on AS "previousControlOn", + event.next_control_on AS "nextControlOn", + event.outcome, + event.notes, + event.actor_user_id AS "actorUserId", + COALESCE(event.actor_username, actor.username) AS "actorUsername", + CASE WHEN visit.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( + 'id', visit.id, + 'code', visit.code, + 'title', visit.title, + 'status', visit.status, + 'plannedStartAt', visit.planned_start_at + ) END AS "verificationVisit", + CASE WHEN visit.id IS NULL THEN 0 ELSE ( + SELECT COUNT(*)::integer + FROM inspection_finding_evidence evidence + WHERE evidence.finding_id = event.finding_id + AND evidence.verification_visit_id = visit.id + AND evidence.purpose = 'VERIFICATION' + ) END AS "evidenceCount" + FROM inspection_finding_verification_events event + LEFT JOIN inspection_visits visit ON visit.id = event.verification_visit_id + LEFT JOIN users actor ON actor.id = event.actor_user_id + WHERE event.finding_id = $1 + ORDER BY event.occurred_at DESC, event.created_at DESC, event.id DESC + `, [findingId]) as Promise; + } + + private officeWorkflow(finding: InspectionFindingListItem): InspectionFindingOfficeWorkflow { + if (finding.status === InspectionFindingStatus.CLOSED) { + return { + stage: 'CLOSED', + label: 'Hallazgo cerrado', + action: 'El seguimiento está finalizado y permanece disponible en el expediente histórico.', + dueOn: null, + overdue: false, + }; + } + + const today = this.mendozaDate(); + const latest = finding.latestVerification; + if (latest?.outcome === 'RESOLVED' && latest.visitStatus === InspectionVisitStatus.CLOSED) { + return { + stage: 'READY_TO_CLOSE', + label: 'Listo para cierre', + action: 'Revisar la evidencia de verificación y registrar la conclusión administrativa.', + dueOn: null, + overdue: false, + }; + } + + if (latest?.outcome === 'NOT_RESOLVED' && !finding.nextControlOn) { + return { + stage: 'RESCHEDULE_VERIFICATION', + label: 'Requiere nueva verificación', + action: 'Definir una nueva fecha de verificación para devolver el hallazgo a planificación.', + dueOn: null, + overdue: false, + }; + } + + if (!finding.companyResponseReceivedOn) { + const overdue = Boolean(finding.correctionDueOn && finding.correctionDueOn < today); + return { + stage: overdue ? 'COMPANY_OVERDUE' : 'WAITING_COMPANY', + label: overdue ? 'Respuesta de empresa vencida' : 'Esperando respuesta de empresa', + action: overdue + ? 'Registrar la presentación recibida o gestionar el reclamo administrativo.' + : 'Registrar la respuesta de la empresa cuando sea recibida.', + dueOn: finding.correctionDueOn, + overdue, + }; + } + + if (!finding.nextControlOn) { + return { + stage: 'DEFINE_VERIFICATION', + label: 'Definir fecha de verificación', + action: 'Establecer cuándo debe volver a controlarse el hallazgo.', + dueOn: null, + overdue: false, + }; + } + + if (finding.verificationVisit) { + return { + stage: 'VERIFICATION_PLANNED', + label: 'Verificación planificada', + action: 'La visita ya está vinculada. Consultar o completar la inspección de verificación.', + dueOn: finding.nextControlOn, + overdue: finding.nextControlOn < today, + }; + } + + return { + stage: 'PLAN_VERIFICATION', + label: finding.nextControlOn < today ? 'Verificación vencida sin visita' : 'Verificación pendiente de planificación', + action: 'Crear una visita de verificación para la fecha operativa definida.', + dueOn: finding.nextControlOn, + overdue: finding.nextControlOn < today, + }; + } + + private mendozaDate(): string { + return new Intl.DateTimeFormat('en-CA', { + timeZone: 'America/Argentina/Mendoza', + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).format(new Date()); + } + + private async requireAct(manager: EntityManager, id: string): Promise { + const rows = await manager.query('SELECT 1 FROM inspection_acts WHERE id = $1', [id]) as unknown[]; + if (rows.length === 0) throw actNotFound(); + } + + private async lockAct(manager: EntityManager, id: string): Promise { + const act = await manager.getRepository(InspectionAct) + .createQueryBuilder('act') + .where('act.id = :id', { id }) + .setLock('pessimistic_write') + .getOne(); + if (!act) throw actNotFound(); + return act; + } + + private async lockVisit(manager: EntityManager, id: string): Promise { + const visit = await manager.getRepository(InspectionVisit) + .createQueryBuilder('visit') + .where('visit.id = :id', { id }) + .setLock('pessimistic_write') + .getOne(); + if (!visit) { + throw new NotFoundException({ + code: 'INSPECTION_VISIT_NOT_FOUND', + message: 'Visita de inspección no encontrada', + }); + } + return visit; + } + + private async lockFinding(manager: EntityManager, id: string): Promise { + const finding = await manager.getRepository(InspectionFinding) + .createQueryBuilder('finding') + .where('finding.id = :id', { id }) + .setLock('pessimistic_write') + .getOne(); + if (!finding) throw findingNotFound(); + return finding; + } + + private assertActEditable(act: InspectionAct, visit: InspectionVisit): void { + if (act.status !== InspectionActStatus.DRAFT || visit.status !== InspectionVisitStatus.IN_PROGRESS) { + throw new ConflictException({ + code: 'INSPECTION_FINDING_ACT_NOT_EDITABLE', + message: 'Los hallazgos sólo se editan mientras el acta está en borrador y la visita en curso', + }); + } + } + + private assertFindingOpen(finding: InspectionFinding): void { + if (finding.status !== InspectionFindingStatus.OPEN) { + throw new ConflictException({ + code: 'INSPECTION_FINDING_NOT_OPEN', + message: 'El hallazgo ya no está abierto', + }); + } + } + + private async assertActorAssigned( + manager: EntityManager, + visitId: string, + principal: AuthPrincipal, + ): Promise { + if (principal.permissions.includes('inspections.manage')) return; + const rows = await manager.query(` + SELECT 1 FROM inspection_visit_members + WHERE visit_id = $1 AND user_id = $2 AND included = true + `, [visitId, principal.userId]) as unknown[]; + if (rows.length === 0) { + throw new ForbiddenException({ + code: 'INSPECTION_VISIT_NOT_ASSIGNED', + message: 'La visita no está asignada al usuario actual', + }); + } + } + + private async assertActAsset(manager: EntityManager, actId: string, assetId: string): Promise { + const rows = await manager.query(` + SELECT 1 FROM inspection_act_assets + WHERE act_id = $1 AND asset_id = $2 AND included = true + `, [actId, assetId]) as unknown[]; + if (rows.length === 0) { + throw new BadRequestException({ + code: 'INSPECTION_FINDING_ASSET_INVALID', + message: 'El activo del hallazgo debe estar incluido en el acta', + }); + } + } + + private async requireCatalogItem( + manager: EntityManager, + id: string, + assetId: string, + ): Promise { + const [item] = await manager.query(` + SELECT + item.id, + item.title, + item.legal_basis AS "legalBasis", + item.glossary, + item.revision, + item.suggested_severity AS "suggestedSeverity" + FROM finding_catalog_items item + INNER JOIN finding_categories category ON category.id = item.category_id + INNER JOIN assets asset ON asset.id = $2 + LEFT JOIN finding_catalog_asset_type_profiles profile + ON profile.asset_type_id = asset.asset_type_id + LEFT JOIN finding_catalog_item_asset_types mapping + ON mapping.catalog_item_id = item.id + AND mapping.asset_type_id = asset.asset_type_id + LEFT JOIN finding_catalog_asset_overrides override + ON override.catalog_item_id = item.id + AND override.asset_id = asset.id + WHERE item.id = $1 + AND item.is_active = true + AND category.is_active = true + AND COALESCE( + override.is_enabled, + CASE WHEN profile.asset_type_id IS NULL THEN true ELSE mapping.id IS NOT NULL END + ) = true + `, [id, assetId]) as CatalogSnapshot[]; + if (!item) { + throw new BadRequestException({ + code: 'FINDING_CATALOG_ITEM_NOT_APPLICABLE', + message: 'El hallazgo seleccionado no está habilitado para este elemento del Inventario', + }); + } + return item; + } + + private async allocateFindingNumber(manager: EntityManager, actId: string): Promise { + const [row] = 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 }>; + return Number(row.number); + } + + private async captureVersion( + manager: EntityManager, + finding: InspectionFinding, + event: InspectionFindingVersionEvent, + principal: AuthPrincipal, + ): Promise { + const [row] = await manager.query(` + UPDATE inspection_findings + SET current_version = current_version + 1 + WHERE id = $1 + RETURNING current_version AS "versionNumber" + `, [finding.id]) as Array<{ versionNumber: number }>; + const versionNumber = Number(row.versionNumber); + finding.currentVersion = versionNumber; + const snapshot = await this.buildSnapshot(manager, finding.id); + await manager.query(` + INSERT INTO inspection_finding_versions ( + finding_id, version_number, event, snapshot, actor_user_id, actor_username + ) VALUES ($1, $2, $3, $4, $5, $6) + `, [finding.id, versionNumber, event, snapshot, principal.userId, principal.username]); + return versionNumber; + } + + private async buildSnapshot( + manager: EntityManager, + findingId: string, + ): Promise> { + const [row] = await manager.query(` + SELECT JSONB_BUILD_OBJECT( + 'id', finding.id, + 'code', finding.code, + 'findingNumber', finding.finding_number, + 'status', finding.status, + 'title', finding.title, + 'description', finding.description, + 'legalBasis', finding.legal_basis, + 'glossary', finding.glossary, + 'catalogRevision', finding.catalog_revision, + 'suggestedSeverity', finding.suggested_severity, + 'severity', finding.severity, + 'correctionDueOn', finding.correction_due_on, + 'responseDueBasis', finding.response_due_basis, + 'responseDueDays', finding.response_due_days, + 'responseDueBaseOn', finding.response_due_base_on, + 'reportNotifiedOn', finding.report_notified_on, + 'companyResponse', finding.company_response, + 'companyResponseReceivedOn', finding.company_response_received_on, + 'companyCommittedCorrectionOn', finding.company_committed_correction_on, + 'nextControlOn', finding.next_control_on, + 'currentVersion', finding.current_version, + 'act', JSONB_BUILD_OBJECT('id', act.id, 'code', act.code, 'status', act.status), + 'visit', JSONB_BUILD_OBJECT('id', visit.id, 'code', visit.code, 'status', visit.status), + 'asset', JSONB_BUILD_OBJECT( + 'id', asset.id, 'code', asset.code, 'name', asset.name, + 'typeId', asset.asset_type_id, 'currentVersion', asset.current_version + ), + 'catalog', CASE WHEN catalog.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( + 'id', catalog.id, 'code', catalog.code, 'sourceNumber', catalog.source_number, + 'revision', finding.catalog_revision, 'suggestedSeverity', finding.suggested_severity, 'categoryCode', category.code, + 'categoryName', category.name + ) END + ) AS snapshot + 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 + INNER JOIN assets asset ON asset.id = finding.asset_id + 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.id = $1 + `, [findingId]) as Array<{ snapshot: Record }>; + if (!row) throw findingNotFound(); + return row.snapshot; + } + + private auditView(finding: InspectionFindingListItem): Record { + return { + actId: finding.actId, + visitId: finding.document.visitId, + assetId: finding.assetId, + catalogItemId: finding.catalogItemId, + code: finding.code, + status: finding.status, + title: finding.title, + description: finding.description, + suggestedSeverity: finding.suggestedSeverity, + severity: finding.severity, + correctionDueOn: finding.correctionDueOn, + responseDueBasis: finding.responseDueBasis, + responseDueDays: finding.responseDueDays, + responseDueBaseOn: finding.responseDueBaseOn, + reportNotifiedOn: finding.reportNotifiedOn, + companyResponse: finding.companyResponse, + companyResponseReceivedOn: finding.companyResponseReceivedOn, + companyCommittedCorrectionOn: finding.companyCommittedCorrectionOn, + nextControlOn: finding.nextControlOn, + currentVersion: finding.currentVersion, + closedAt: finding.closedAt, + closureNotes: finding.closureNotes, + }; + } +} diff --git a/api-v3/src/inspection-operations/field-discovery-inspection-link.service.ts b/api-v3/src/inspection-operations/field-discovery-inspection-link.service.ts new file mode 100644 index 0000000..31c4753 --- /dev/null +++ b/api-v3/src/inspection-operations/field-discovery-inspection-link.service.ts @@ -0,0 +1,64 @@ +import { ConflictException, Injectable } from '@nestjs/common'; +import type { EntityManager } from 'typeorm'; + +export interface FieldDiscoveryInspectionLinkResult { + actId: string | null; +} + +@Injectable() +export class FieldDiscoveryInspectionLinkService { + async attach( + manager: EntityManager, + visitId: string, + assetId: string, + userId: string, + ): Promise { + const [act] = (await manager.query( + ` + SELECT id, status + FROM inspection_acts + WHERE visit_id = $1 + AND status <> 'CANCELLED' + ORDER BY created_at DESC + LIMIT 1 + FOR UPDATE + `, + [visitId], + )) as Array<{ id: string; status: string }>; + + if (act && act.status !== 'DRAFT') { + throw new ConflictException({ + code: 'FIELD_DISCOVERY_ACT_NOT_EDITABLE', + message: 'El Acta ya no admite nuevos elementos ni Hallazgos', + }); + } + + await manager.query( + ` + INSERT INTO inspection_visit_assets (visit_id, asset_id, included, added_by) + VALUES ($1, $2, true, $3) + ON CONFLICT (visit_id, asset_id) DO UPDATE SET + included = true, + added_by = EXCLUDED.added_by, + updated_at = CURRENT_TIMESTAMP + `, + [visitId, assetId, userId], + ); + + if (act) { + await manager.query( + ` + INSERT INTO inspection_act_assets (act_id, asset_id, included, added_by) + VALUES ($1, $2, true, $3) + ON CONFLICT (act_id, asset_id) DO UPDATE SET + included = true, + added_by = EXCLUDED.added_by, + updated_at = CURRENT_TIMESTAMP + `, + [act.id, assetId, userId], + ); + } + + return { actId: act?.id ?? null }; + } +} diff --git a/api-v3/src/inspection-operations/mobile-inspector-policy.ts b/api-v3/src/inspection-operations/mobile-inspector-policy.ts new file mode 100644 index 0000000..817582e --- /dev/null +++ b/api-v3/src/inspection-operations/mobile-inspector-policy.ts @@ -0,0 +1,17 @@ +import { ForbiddenException } from '@nestjs/common'; +import type { AuthPrincipal } from '../common/http/request-context'; + +export function assertMobileInspector(principal: AuthPrincipal): void { + if (principal.transport !== 'bearer') { + throw new ForbiddenException({ + code: 'INSPECTION_MOBILE_APP_REQUIRED', + message: 'Esta operación sólo está disponible desde la aplicación móvil', + }); + } + if (!principal.roles.includes('inspector')) { + throw new ForbiddenException({ + code: 'INSPECTION_INSPECTOR_ROLE_REQUIRED', + message: 'Sólo un usuario con rol inspector puede realizar esta operación', + }); + } +} diff --git a/api-v3/src/inspection-reports/document-delivery.controller.ts b/api-v3/src/inspection-reports/document-delivery.controller.ts new file mode 100644 index 0000000..54704b4 --- /dev/null +++ b/api-v3/src/inspection-reports/document-delivery.controller.ts @@ -0,0 +1,17 @@ +import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, 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 { InspectionDocumentDeliveryService } from './inspection-document-delivery.service'; +import type { DeliveryRow } from './inspection-document-delivery.service'; +import { UpdateDocumentDeliverySettingsDto } from './dto/update-document-delivery-settings.dto'; + +@Controller('document-delivery') +export class DocumentDeliveryController { + constructor(private readonly delivery:InspectionDocumentDeliveryService){} + @Get('settings') @RequirePermissions('document_delivery.read') settings(){return this.delivery.settings();} + @Patch('settings') @RequirePermissions('document_delivery.manage') update(@Body() dto:UpdateDocumentDeliverySettingsDto,@CurrentAuth() principal:AuthPrincipal,@Req() request:RequestWithContext){return this.delivery.updateSettings(dto,principal,request);} + @Get('outbox') @RequirePermissions('document_delivery.read') outbox(){return this.delivery.list();} + @Post('outbox/:id/retry') @RequirePermissions('document_delivery.manage') retry(@Param('id',new ParseUUIDPipe({version:'4'})) id:string,@CurrentAuth() principal:AuthPrincipal,@Req() request:RequestWithContext):Promise{return this.delivery.retry(id,principal,request);} + @Post('outbox/retry-pending') @RequirePermissions('document_delivery.manage') retryPending(@CurrentAuth() principal:AuthPrincipal,@Req() request:RequestWithContext){return this.delivery.retryPending(principal,request);} +} diff --git a/api-v3/src/inspection-reports/dto/approve-inspection-report.dto.ts b/api-v3/src/inspection-reports/dto/approve-inspection-report.dto.ts new file mode 100644 index 0000000..02a1ab6 --- /dev/null +++ b/api-v3/src/inspection-reports/dto/approve-inspection-report.dto.ts @@ -0,0 +1,8 @@ +import { IsOptional, IsString, MaxLength } from 'class-validator'; + +export class ApproveInspectionReportDto { + @IsOptional() + @IsString() + @MaxLength(1000) + note?: string; +} diff --git a/api-v3/src/inspection-reports/dto/create-inspection-report-revision.dto.ts b/api-v3/src/inspection-reports/dto/create-inspection-report-revision.dto.ts new file mode 100644 index 0000000..0f6d1be --- /dev/null +++ b/api-v3/src/inspection-reports/dto/create-inspection-report-revision.dto.ts @@ -0,0 +1,7 @@ +import { IsString, Length } from 'class-validator'; + +export class CreateInspectionReportRevisionDto { + @IsString() + @Length(5, 1000) + changeSummary!: string; +} diff --git a/api-v3/src/inspection-reports/dto/list-inspection-reports-query.dto.ts b/api-v3/src/inspection-reports/dto/list-inspection-reports-query.dto.ts new file mode 100644 index 0000000..6f00643 --- /dev/null +++ b/api-v3/src/inspection-reports/dto/list-inspection-reports-query.dto.ts @@ -0,0 +1,49 @@ +import { Type } from 'class-transformer'; +import { IsInt, IsOptional, IsString, IsUUID, Matches, Max, MaxLength, Min } from 'class-validator'; + +export class ListInspectionReportsQueryDto { + @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() + @Type(() => Number) + @IsInt() + @Min(2000) + @Max(2100) + year?: number; + + @IsOptional() + @IsUUID('4') + companyId?: string; + + @IsOptional() + @IsUUID('4') + areaId?: string; + + @IsOptional() + @IsUUID('4') + inspectorId?: string; + + @IsOptional() + @Matches(/^\d{4}-\d{2}-\d{2}$/) + dateFrom?: string; + + @IsOptional() + @Matches(/^\d{4}-\d{2}-\d{2}$/) + dateTo?: string; +} diff --git a/api-v3/src/inspection-reports/dto/sign-final-inspection-report.dto.ts b/api-v3/src/inspection-reports/dto/sign-final-inspection-report.dto.ts new file mode 100644 index 0000000..0704a64 --- /dev/null +++ b/api-v3/src/inspection-reports/dto/sign-final-inspection-report.dto.ts @@ -0,0 +1,9 @@ +import { Equals, IsBoolean } from 'class-validator'; +import { Transform } from 'class-transformer'; + +export class SignFinalInspectionReportDto { + @Transform(({ value }) => value === true || value === 'true') + @IsBoolean() + @Equals(true) + confirmation!: boolean; +} diff --git a/api-v3/src/inspection-reports/dto/update-document-delivery-settings.dto.ts b/api-v3/src/inspection-reports/dto/update-document-delivery-settings.dto.ts new file mode 100644 index 0000000..3ed84a0 --- /dev/null +++ b/api-v3/src/inspection-reports/dto/update-document-delivery-settings.dto.ts @@ -0,0 +1,2 @@ +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; } diff --git a/api-v3/src/inspection-reports/inspection-act-pdf-builder.ts b/api-v3/src/inspection-reports/inspection-act-pdf-builder.ts new file mode 100644 index 0000000..8b4a0fb --- /dev/null +++ b/api-v3/src/inspection-reports/inspection-act-pdf-builder.ts @@ -0,0 +1,77 @@ +import { createHash } from 'node:crypto'; + +function asRecord(value: unknown): Record { return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; } +function asArray(value: unknown): Array> { return Array.isArray(value) ? value.map(asRecord) : []; } +function text(value: unknown, fallback='-'): string { const out=String(value ?? '').trim(); return out || fallback; } +function date(value: unknown): string { const d=new Date(String(value ?? '')); return Number.isFinite(d.getTime()) ? d.toLocaleDateString('es-AR') : '-'; } +function clean(value: string): string { return value.normalize('NFKD').replace(/[\u0300-\u036f]/g,'').replace(/[–—]/g,'-').replace(/[“”]/g,'"').replace(/[‘’]/g,"'").replace(/[^\x20-\xFF]/g,'?'); } +function escapePdf(value: string): string { return clean(value).replaceAll('\\','\\\\').replaceAll('(','\\(').replaceAll(')','\\)'); } +function wrap(value: string, max=92): string[] { const words=clean(value).split(/\s+/).filter(Boolean); const out:string[]=[]; let line=''; for(const word of words){ const next=line?`${line} ${word}`:word; if(next.length>max&&line){out.push(line);line=word;}else line=next;} if(line)out.push(line); return out.length?out:['-']; } + +function lines(snapshot: Record): string[] { + const prepared=asRecord(asRecord(snapshot).preparedSnapshot); + const act=asRecord(prepared.act); + const visit=asRecord(act.visit); + const responsible=asRecord(prepared.responsible); + const team=asArray(prepared.team); + const assets=asArray(prepared.assets); + const findings=asArray(prepared.findings); + const companies=[...new Set(assets.map(x=>text(asRecord(x.operatorCompany).name,'')).filter(Boolean))]; + const areas=[...new Set(assets.map(x=>text(asRecord(x.operationalArea).name,'')).filter(Boolean))]; + const inspectors=team.map(x=>`${text(x.firstName,'')} ${text(x.lastName,'')}`.trim()).filter(Boolean); + const out:string[]=[ + 'ACTA DE INSPECCION', + 'Documento automatico - diseno institucional pendiente de definicion', + '', + `Acta: ${text(act.code)}`, + `Inspeccion: ${text(visit.code)}`, + `Fecha: ${date(act.occurredAt)}`, + `Empresa: ${companies.join(' / ') || 'Segun alcance del acta'}`, + `Area: ${areas.join(' / ') || 'Segun alcance del acta'}`, + `Inspector/es: ${inspectors.join(' / ') || '-'}`, + `Responsable empresa: ${text(responsible.fullName)}`, + `Cargo: ${text(responsible.position)}`, + '', + 'OBJETIVO', ...wrap(text(visit.objective)), '', + 'RESUMEN', ...wrap(text(act.summary)), '', + 'OBSERVACIONES', ...wrap(text(act.observations)), '', + 'ELEMENTOS INSPECCIONADOS', + ]; + if(!assets.length) out.push('-'); + for(const item of assets) out.push(...wrap(`${text(item.code)} | ${text(item.name)}${text(item.commonName,'') ? ` | ${text(item.commonName,'')}` : ''} | ${text(item.typeName)}`)); + out.push('', 'HALLAZGOS'); + if(!findings.length) out.push('Sin hallazgos registrados.'); + for(const item of findings){ out.push(...wrap(`${text(item.code)} | ${text(item.title)} | Vencimiento: ${date(item.correctionDueOn)}`)); out.push(...wrap(text(item.description))); } + out.push('', 'INTEGRIDAD', `Hash de cierre: ${text(asRecord(snapshot).finalSha256 ?? asRecord(snapshot).preparedSha256)}`); + return out; +} + +function objectBuffer(id: number, body: Buffer|string): Buffer { const data=Buffer.isBuffer(body)?body:Buffer.from(body,'latin1'); return Buffer.concat([Buffer.from(`${id} 0 obj\n`,'ascii'),data,Buffer.from('\nendobj\n','ascii')]); } + +export function buildInspectionActPdf(snapshot: Record): { buffer: Buffer; sha256: string } { + const all=lines(snapshot); + const chunks:Array=[]; + for(let i=0;i4+i*2); + const contentIds=Array.from({length:pageCount},(_,i)=>5+i*2); + const objects:Buffer[]=[]; + objects.push(objectBuffer(1,'<< /Type /Catalog /Pages 2 0 R >>')); + objects.push(objectBuffer(2,`<< /Type /Pages /Count ${pageCount} /Kids [${pageIds.map(id=>`${id} 0 R`).join(' ')}] >>`)); + objects.push(objectBuffer(3,'<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>')); + chunks.forEach((chunk,index)=>{ + const content=chunk.map((line,i)=>`${i===0?'':'T* '}(${escapePdf(line)}) Tj`).join('\n'); + const stream=Buffer.from(`BT\n/F1 9 Tf\n40 800 Td\n12 TL\n${content}\nET`,'latin1'); + objects.push(objectBuffer(pageIds[index]!,`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R >> >> /Contents ${contentIds[index]} 0 R >>`)); + objects.push(objectBuffer(contentIds[index]!,Buffer.concat([Buffer.from(`<< /Length ${stream.length} >>\nstream\n`,'ascii'),stream,Buffer.from('\nendstream','ascii')]))); + }); + const header=Buffer.from('%PDF-1.4\n%\xE2\xE3\xCF\xD3\n','binary'); + const offsets:number[]=[0]; let pos=header.length; + for(const obj of objects){ offsets.push(pos); pos+=obj.length; } + const xrefOffset=pos; + const xref=[`xref\n0 ${objects.length+1}\n`,`0000000000 65535 f \n`,...objects.map((_,i)=>`${String(offsets[i+1]).padStart(10,'0')} 00000 n \n`)].join(''); + const trailer=`trailer\n<< /Size ${objects.length+1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`; + const buffer=Buffer.concat([header,...objects,Buffer.from(xref+trailer,'ascii')]); + return { buffer, sha256:createHash('sha256').update(buffer).digest('hex') }; +} diff --git a/api-v3/src/inspection-reports/inspection-act-pdf.service.ts b/api-v3/src/inspection-reports/inspection-act-pdf.service.ts new file mode 100644 index 0000000..056731a --- /dev/null +++ b/api-v3/src/inspection-reports/inspection-act-pdf.service.ts @@ -0,0 +1,27 @@ +import { createHash } from 'node:crypto'; +import { mkdir, readFile, stat, writeFile } from 'node:fs/promises'; +import { isAbsolute, parse, resolve } from 'node:path'; +import { ConfigService } from '@nestjs/config'; +import { Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import { buildInspectionActPdf } from './inspection-act-pdf-builder'; + +@Injectable() +export class InspectionActPdfService { + private readonly root:string; + constructor(private readonly dataSource:DataSource, config:ConfigService){ const configured=config.get('INSPECTION_ACT_PDF_ROOT')??'/app/storage/asset-media/inspection-acts-pdf'; if(!isAbsolute(configured))throw new Error('INSPECTION_ACT_PDF_ROOT must be an absolute path'); this.root=resolve(configured); if(this.root===parse(this.root).root)throw new Error('INSPECTION_ACT_PDF_ROOT cannot be the filesystem root'); } + + async ensure(actId:string):Promise{ + const [row]=await this.dataSource.query(`SELECT a.id,a.code,a.closure_sha256 AS "closureSha256",c.final_snapshot AS "finalSnapshot",p.status,p.stored_name AS "storedName" FROM inspection_acts a JOIN inspection_act_closures c ON c.act_id=a.id LEFT JOIN inspection_act_pdf_artifacts p ON p.act_id=a.id WHERE a.id=$1 AND a.status='CLOSED'`,[actId]) as Array<{id:string;code:string;closureSha256:string;finalSnapshot:Record;status:string|null;storedName:string|null}>; + if(!row)throw new NotFoundException({code:'INSPECTION_ACT_NOT_CLOSED',message:'El acta cerrada no está disponible'}); + if(row.status==='READY'&&row.storedName){ try{ await this.content(actId); return; }catch{} } + await this.dataSource.query(`INSERT INTO inspection_act_pdf_artifacts (act_id,status) VALUES ($1,'PENDING') ON CONFLICT (act_id) DO UPDATE SET status='PENDING',error=NULL,updated_at=CURRENT_TIMESTAMP`,[actId]); + try{ + const snapshot={...row.finalSnapshot,finalSha256:row.closureSha256}; const built=buildInspectionActPdf(snapshot); await mkdir(this.root,{recursive:true,mode:0o700}); const storedName=`${actId}.pdf`; const originalName=`${row.code}.pdf`; const filePath=resolve(this.root,storedName); await writeFile(filePath,built.buffer,{mode:0o600}); + await this.dataSource.query(`UPDATE inspection_act_pdf_artifacts SET status='READY',original_name=$2,stored_name=$3,mime_type='application/pdf',size_bytes=$4,sha256=$5,generated_at=CURRENT_TIMESTAMP,error=NULL,updated_at=CURRENT_TIMESTAMP WHERE act_id=$1`,[actId,originalName,storedName,built.buffer.length,built.sha256]); + }catch(error){ const message=error instanceof Error?error.message.slice(0,500):'Error desconocido'; await this.dataSource.query(`UPDATE inspection_act_pdf_artifacts SET status='FAILED',error=$2,updated_at=CURRENT_TIMESTAMP WHERE act_id=$1`,[actId,message]).catch(()=>undefined); } + } + + async content(actId:string):Promise<{buffer:Buffer;originalName:string;mimeType:string}>{ const [row]=await this.dataSource.query(`SELECT original_name AS "originalName",stored_name AS "storedName",mime_type AS "mimeType",size_bytes::integer AS "sizeBytes",sha256 FROM inspection_act_pdf_artifacts WHERE act_id=$1 AND status='READY'`,[actId]) as Array<{originalName:string;storedName:string;mimeType:string;sizeBytes:number;sha256:string}>; if(!row)throw new NotFoundException({code:'INSPECTION_ACT_PDF_NOT_READY',message:'El PDF del acta todavía no está disponible'}); const filePath=resolve(this.root,row.storedName); if(!filePath.startsWith(`${this.root}/`))throw this.storageError(); const st=await stat(filePath).catch(()=>null); if(!st?.isFile()||st.size!==row.sizeBytes)throw this.storageError(); const buffer=await readFile(filePath); if(createHash('sha256').update(buffer).digest('hex')!==row.sha256)throw this.storageError(); return {buffer,originalName:row.originalName,mimeType:row.mimeType}; } + private storageError(){return new InternalServerErrorException({code:'INSPECTION_ACT_PDF_STORAGE_ERROR',message:'El PDF del acta no está disponible o no supera la validación de integridad'});} +} diff --git a/api-v3/src/inspection-reports/inspection-document-delivery.service.ts b/api-v3/src/inspection-reports/inspection-document-delivery.service.ts new file mode 100644 index 0000000..8a63220 --- /dev/null +++ b/api-v3/src/inspection-reports/inspection-document-delivery.service.ts @@ -0,0 +1,40 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import { ConfigService } from '@nestjs/config'; +import { InspectionActPdfService } from './inspection-act-pdf.service'; +import { InspectionReportWordService } from './inspection-report-word.service'; +import { SmtpDeliveryService } from './smtp-delivery.service'; +import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; +import { AuditService } from '../audit/audit.service'; +import { administrationAuditContext } from '../administration/common/administration-audit'; +import { AuditAction, AuditSource } from '../database/entities'; +import type { UpdateDocumentDeliverySettingsDto } from './dto/update-document-delivery-settings.dto'; + +export interface DeliveryRow { id:string; actId:string; reportId:string|null; documentKind:'ACT_PDF'|'REPORT_WORD'; recipientKind:'COMPANY'|'OFFICE'|'DIRECTOR'; recipientAssetId:string|null; recipientEmail:string|null; status:string; attempts:number; actCode:string; reportCode:string|null; } + +@Injectable() +export class InspectionDocumentDeliveryService { + constructor(private readonly dataSource:DataSource, private readonly pdf:InspectionActPdfService, 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" FROM institutional_delivery_settings WHERE id=1`) as Array<{officeEmail:string|null;directorEmail:string|null;updatedAt:Date}>; return {...row,smtpConfigured:this.smtp.configured(),mailFrom:this.config.get('MAIL_FROM')??null}; } + async updateSettings(dto:UpdateDocumentDeliverySettingsDto,principal:AuthPrincipal,request:RequestWithContext){ 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]); const after=await this.settings(); await this.audit.record({...administrationAuditContext(principal,request),action:AuditAction.DOCUMENT_DELIVERY_SETTINGS_UPDATED,entityType:'institutional_delivery_settings',entityId:'1',beforeData:before,afterData:after}); return after; } + 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_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",recipient.name AS "recipientAssetName" 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 ORDER BY d.created_at DESC LIMIT 200`); return {data}; } + + async dispatchForAct(actId:string):Promise{ await this.pdf.ensure(actId).catch(()=>undefined); const [report]=await this.dataSource.query(`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); } + async retry(id:string,principal:AuthPrincipal,request:RequestWithContext):Promise{ const row=await this.load(id); await this.audit.record({...administrationAuditContext(principal,request),action:AuditAction.DOCUMENT_DELIVERY_RETRY_REQUESTED,entityType:'inspection_document_delivery',entityId:id,metadata:{actId:row.actId,documentKind:row.documentKind,recipientKind:row.recipientKind}}); await this.refreshRecipient(row); await this.attempt(await this.load(id)); return this.load(id); } + async retryPending(principal:AuthPrincipal,request:RequestWithContext){ const rows=await this.dataSource.query(`SELECT id FROM inspection_document_deliveries 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); 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" FROM institutional_delivery_settings WHERE id=1`) as Array<{officeEmail:string|null;directorEmail:string|null}>; const companies=await this.dataSource.query(`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(asset.operator_company_id,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`,[actId]) as Array<{id:string;email:string|null}>; + for(const company of companies)await this.upsertRow(actId,reportId,'ACT_PDF','COMPANY',company.id,company.email); + await this.upsertRow(actId,reportId,'ACT_PDF','OFFICE',null,settings?.officeEmail??null); + if(reportId)await this.upsertRow(actId,reportId,'REPORT_WORD','DIRECTOR',null,settings?.directorEmail??null); + } + private async upsertRow(actId:string,reportId:string|null,documentKind:string,recipientKind:string,recipientAssetId:string|null,recipientEmail:string|null){ await this.dataSource.query(`INSERT INTO inspection_document_deliveries (act_id,report_id,document_kind,recipient_kind,recipient_asset_id,recipient_key,recipient_email,status) VALUES ($1,$2,$3,$4,$5,COALESCE($5::uuid,'00000000-0000-0000-0000-000000000000'::uuid),$6,$7) 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_email=COALESCE(inspection_document_deliveries.recipient_email,EXCLUDED.recipient_email),updated_at=CURRENT_TIMESTAMP`,[actId,reportId,documentKind,recipientKind,recipientAssetId,recipientEmail,recipientEmail?'PENDING':'WAITING_RECIPIENT']); } + private async rowsForAct(actId:string){ 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_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`,[actId]) as Promise; } + private async load(id:string){ 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_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`,[id]) as DeliveryRow[]; if(!row)throw new NotFoundException({code:'DOCUMENT_DELIVERY_NOT_FOUND',message:'Entrega documental no encontrada'}); return row; } + private async refreshRecipient(row:DeliveryRow){ let email:string|null=null; if(row.recipientKind==='COMPANY'&&row.recipientAssetId){ const [company]=await this.dataSource.query(`SELECT notification_email AS email FROM organization_profiles WHERE asset_id=$1`,[row.recipientAssetId]) as Array<{email:string|null}>; email=company?.email??null; } else { const [settings]=await this.dataSource.query(`SELECT office_email AS "officeEmail",director_email AS "directorEmail" 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; } 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'`,[row.id,email]); } + private async attempt(row:DeliveryRow){ if(row.status==='SENT')return; if(!row.recipientEmail){await this.setStatus(row.id,'WAITING_RECIPIENT','Destinatario no configurado');return;} if(!this.smtp.configured()){await this.setStatus(row.id,'WAITING_TRANSPORT','SMTP no configurado');return;} let attachment:{filename:string;mimeType:string;content:Buffer}; try{ if(row.documentKind==='ACT_PDF'){await this.pdf.ensure(row.actId); const file=await this.pdf.content(row.actId); attachment={filename:file.originalName,mimeType:file.mimeType,content:file.buffer};}else{if(!row.reportId)throw new Error('Informe no vinculado'); await this.word.ensure(row.reportId); const file=await this.word.content(row.reportId); const {readFile}=await import('node:fs/promises'); attachment={filename:file.originalName,mimeType:file.mimeType,content:await readFile(file.filePath)};}}catch(error){await this.setStatus(row.id,'WAITING_ARTIFACT',error instanceof Error?error.message:'Documento no disponible');return;} + 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`,[row.id]); try{ const label=row.documentKind==='ACT_PDF'?`Acta ${row.actCode}`:`Informe ${row.reportCode??''}`; const sent=await this.smtp.send({to:row.recipientEmail,subject:`DH Inspección · ${label}`,text:row.documentKind==='ACT_PDF'?`Se adjunta el acta cerrada e inmutable ${row.actCode}.`:`Se adjunta el informe Word automático ${row.reportCode??''} para revisión del Director de Hidrocarburos.`,attachment}); 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`,[row.id,sent.messageId]); await this.audit.record({action:AuditAction.DOCUMENT_DELIVERY_SENT,entityType:'inspection_document_delivery',entityId:row.id,source:AuditSource.SYSTEM,actorUserId:null,actorUsername:null,requestId:null,ip:null,userAgent:null,beforeData:null,afterData:{recipientKind:row.recipientKind,documentKind:row.documentKind,status:'SENT'},metadata:{actId:row.actId,reportId:row.reportId}}); }catch(error){await this.setStatus(row.id,'FAILED',error instanceof Error?error.message:'Error de entrega');} + } + 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`,[id,status,error.slice(0,500)]);} +} diff --git a/api-v3/src/inspection-reports/inspection-report-review.controller.ts b/api-v3/src/inspection-reports/inspection-report-review.controller.ts new file mode 100644 index 0000000..0e397bd --- /dev/null +++ b/api-v3/src/inspection-reports/inspection-report-review.controller.ts @@ -0,0 +1,87 @@ +import { Body, Controller, Get, Param, ParseUUIDPipe, Post, Req, Res, UploadedFile, UseInterceptors } from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import type { Response } from 'express'; +import { CurrentAuth } from '../auth/decorators/current-auth.decorator'; +import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator'; +import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; +import { ApproveInspectionReportDto } from './dto/approve-inspection-report.dto'; +import { CreateInspectionReportRevisionDto } from './dto/create-inspection-report-revision.dto'; +import { SignFinalInspectionReportDto } from './dto/sign-final-inspection-report.dto'; +import { + MAX_INSPECTION_REPORT_REVISION_BYTES, + type UploadedInspectionReportRevisionFile, +} from './inspection-report-revision-file'; +import { + InspectionReportReviewService, + type InspectionReportReviewView, +} from './inspection-report-review.service'; + +@Controller('inspection-reports/:reportId/review') +export class InspectionReportReviewController { + constructor(private readonly review: InspectionReportReviewService) {} + + @Get() + @RequirePermissions('inspection_reports.read') + get(@Param('reportId', new ParseUUIDPipe({ version: '4' })) reportId: string): Promise { + return this.review.get(reportId); + } + + @Post('revisions') + @RequirePermissions('inspection_reports.revise') + @UseInterceptors(FileInterceptor('file', { limits: { fileSize: MAX_INSPECTION_REPORT_REVISION_BYTES, files: 1 } })) + revision( + @Param('reportId', new ParseUUIDPipe({ version: '4' })) reportId: string, + @Body() dto: CreateInspectionReportRevisionDto, + @UploadedFile() file: UploadedInspectionReportRevisionFile | undefined, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ): Promise { + return this.review.createRevision(reportId, dto, file, principal, request); + } + + @Post('approve') + @RequirePermissions('inspection_reports.review') + approve( + @Param('reportId', new ParseUUIDPipe({ version: '4' })) reportId: string, + @Body() dto: ApproveInspectionReportDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ): Promise { + return this.review.approve(reportId, dto, principal, request); + } + + @Post('sign-final') + @RequirePermissions('inspection_reports.sign_final') + signFinal( + @Param('reportId', new ParseUUIDPipe({ version: '4' })) reportId: string, + @Body() dto: SignFinalInspectionReportDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ): Promise { + return this.review.signFinal(reportId, dto, principal, request); + } +} + +@Controller('inspection-report-revisions') +export class InspectionReportRevisionContentController { + constructor(private readonly review: InspectionReportReviewService) {} + + @Get(':revisionId/content') + @RequirePermissions('inspection_reports.read') + async content( + @Param('revisionId', new ParseUUIDPipe({ version: '4' })) revisionId: string, + @Res() response: Response, + ): Promise { + const content = await this.review.revisionContent(revisionId); + response.setHeader('Content-Type', content.mimeType); + 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((resolveSend, rejectSend) => { + response.sendFile(content.filePath, (error) => { + if (error) rejectSend(error); + else resolveSend(); + }); + }); + } +} diff --git a/api-v3/src/inspection-reports/inspection-report-review.service.ts b/api-v3/src/inspection-reports/inspection-report-review.service.ts new file mode 100644 index 0000000..c1991fd --- /dev/null +++ b/api-v3/src/inspection-reports/inspection-report-review.service.ts @@ -0,0 +1,433 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import { isAbsolute, parse, resolve } from 'node:path'; +import { 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 { AuditAction } from '../database/entities'; +import { sha256CanonicalJson } from '../inspection-closing/canonical-json'; +import type { ApproveInspectionReportDto } from './dto/approve-inspection-report.dto'; +import type { CreateInspectionReportRevisionDto } from './dto/create-inspection-report-revision.dto'; +import type { SignFinalInspectionReportDto } from './dto/sign-final-inspection-report.dto'; +import { + INSPECTION_REPORT_WORD_MIME, + inspectInspectionReportRevisionUpload, + type UploadedInspectionReportRevisionFile, + validateInspectionReportRevisionContainer, +} from './inspection-report-revision-file'; +import { InspectionReportWordService } from './inspection-report-word.service'; + +export type InspectionReportReviewStatus = 'PENDING_REVIEW' | 'APPROVED' | 'SIGNED'; +export type InspectionReportRevisionSource = 'AUTO' | 'DIRECTOR_UPLOAD'; + +export interface InspectionReportReviewPerson { + id: string; + username: string; + firstName: string; + lastName: string; +} + +export interface InspectionReportRevisionView { + id: string; + reportId: string; + revisionNumber: number; + source: InspectionReportRevisionSource; + originalName: string; + mimeType: string; + sizeBytes: number; + sha256: string; + changeSummary: string | null; + createdAt: Date; + createdBy: InspectionReportReviewPerson; +} + +export interface InspectionReportSignatureView { + id: string; + revisionId: string; + signedAt: Date; + confirmationText: string; + signatureSha256: string; + signedBy: InspectionReportReviewPerson; +} + +export interface InspectionReportReviewView { + reportId: string; + reportCode: string; + reviewStatus: InspectionReportReviewStatus; + currentRevisionNumber: number; + approvedRevisionId: string | null; + approvedAt: Date | null; + reviewNote: string | null; + approvedBy: InspectionReportReviewPerson | null; + signature: InspectionReportSignatureView | null; + revisions: InspectionReportRevisionView[]; +} + +interface LockedReport { + id: string; + code: string; + status: string; + frozenSha256: string; + reviewStatus: InspectionReportReviewStatus; + currentRevisionNumber: number; + approvedRevisionId: string | null; + approvedAt: Date | null; + reviewNote: string | null; +} + +interface StoredRevisionRow { + id: string; + reportId: string; + revisionNumber: number; + source: InspectionReportRevisionSource; + originalName: string; + storedName: string; + mimeType: string; + sizeBytes: number; + sha256: string; +} + +const FINAL_CONFIRMATION_TEXT = 'Confirmo que revisé la versión aprobada y firmo electrónicamente el informe final como Director de Hidrocarburos.'; + +@Injectable() +export class InspectionReportReviewService { + private readonly revisionRoot: string; + + constructor( + private readonly dataSource: DataSource, + private readonly audit: AuditService, + private readonly word: InspectionReportWordService, + config: ConfigService, + ) { + const configured = config.get('INSPECTION_REPORT_REVISION_ROOT') ?? '/app/storage/asset-media/inspection-report-revisions'; + if (!isAbsolute(configured)) throw new Error('INSPECTION_REPORT_REVISION_ROOT must be an absolute path'); + this.revisionRoot = resolve(configured); + if (this.revisionRoot === parse(this.revisionRoot).root) throw new Error('INSPECTION_REPORT_REVISION_ROOT cannot be the filesystem root'); + } + + async get(reportId: string): Promise { + await this.word.ensure(reportId); + await this.ensureAutomaticRevision(reportId); + return this.loadView(reportId); + } + + async createRevision( + reportId: string, + dto: CreateInspectionReportRevisionDto, + file: UploadedInspectionReportRevisionFile | undefined, + principal: AuthPrincipal, + request: RequestWithContext, + ): Promise { + inspectInspectionReportRevisionUpload(file); + await this.assertDirector(principal.userId, this.dataSource.manager); + await mkdir(this.revisionRoot, { recursive: true, mode: 0o700 }); + const storedName = `${reportId}-${randomUUID()}.docx`; + const filePath = resolve(this.revisionRoot, storedName); + if (!filePath.startsWith(`${this.revisionRoot}/`)) throw this.storageError(); + await writeFile(filePath, file!.buffer, { mode: 0o600 }); + try { + await validateInspectionReportRevisionContainer(filePath); + const sha256 = createHash('sha256').update(file!.buffer).digest('hex'); + const created = await this.dataSource.transaction(async (manager) => { + const report = await this.lockReport(manager, reportId); + this.assertOpenForReview(report); + const nextRevision = report.currentRevisionNumber + 1; + const [row] = await manager.query(` + INSERT INTO inspection_report_revisions ( + report_id, revision_number, source, original_name, stored_name, mime_type, + size_bytes, sha256, change_summary, created_by + ) VALUES ($1,$2,'DIRECTOR_UPLOAD',$3,$4,$5,$6,$7,$8,$9) + RETURNING id + `, [ + report.id, + nextRevision, + this.cleanOriginalName(file!.originalname), + storedName, + INSPECTION_REPORT_WORD_MIME, + file!.buffer.length, + sha256, + dto.changeSummary.trim(), + principal.userId, + ]) as Array<{ id: string }>; + await manager.query(` + UPDATE inspection_reports + SET current_revision_number = $2, updated_at = CURRENT_TIMESTAMP + WHERE id = $1 + `, [report.id, nextRevision]); + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.INSPECTION_REPORT_REVISION_ADDED, + entityType: 'inspection_report_revision', + entityId: row.id, + afterData: { + reportId: report.id, + reportCode: report.code, + revisionNumber: nextRevision, + sha256, + source: 'DIRECTOR_UPLOAD', + }, + }, manager); + return row.id; + }); + if (!created) throw this.storageError(); + } catch (error) { + await rm(filePath, { force: true }).catch(() => undefined); + throw error; + } + return this.loadView(reportId); + } + + async approve( + reportId: string, + dto: ApproveInspectionReportDto, + principal: AuthPrincipal, + request: RequestWithContext, + ): Promise { + await this.dataSource.transaction(async (manager) => { + await this.assertDirector(principal.userId, manager); + const report = await this.lockReport(manager, reportId); + this.assertOpenForReview(report); + const [revision] = await manager.query(` + SELECT id, revision_number AS "revisionNumber", sha256 + FROM inspection_report_revisions + WHERE report_id = $1 AND revision_number = $2 + `, [report.id, report.currentRevisionNumber]) as Array<{ id: string; revisionNumber: number; sha256: string }>; + if (!revision) throw new ConflictException({ code: 'INSPECTION_REPORT_REVISION_REQUIRED', message: 'El informe necesita una versión Word válida antes de aprobarse' }); + const approvedAt = new Date(); + await manager.query(` + UPDATE inspection_reports + SET review_status = 'APPROVED', approved_revision_id = $2, approved_by = $3, + approved_at = $4, review_note = $5, updated_at = CURRENT_TIMESTAMP + WHERE id = $1 + `, [report.id, revision.id, principal.userId, approvedAt, dto.note?.trim() || null]); + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.INSPECTION_REPORT_APPROVED, + entityType: 'inspection_report', + entityId: report.id, + afterData: { + reportCode: report.code, + revisionId: revision.id, + revisionNumber: revision.revisionNumber, + revisionSha256: revision.sha256, + approvedAt: approvedAt.toISOString(), + }, + }, manager); + }); + return this.loadView(reportId); + } + + async signFinal( + reportId: string, + dto: SignFinalInspectionReportDto, + principal: AuthPrincipal, + request: RequestWithContext, + ): Promise { + if (dto.confirmation !== true) throw new ConflictException({ code: 'INSPECTION_REPORT_SIGNATURE_CONFIRMATION_REQUIRED', message: 'Debés confirmar expresamente la firma final del informe' }); + await this.dataSource.transaction(async (manager) => { + await this.assertDirector(principal.userId, manager); + const report = await this.lockReport(manager, reportId); + if (report.reviewStatus === 'SIGNED') throw new ConflictException({ code: 'INSPECTION_REPORT_ALREADY_SIGNED', message: 'El informe ya tiene una firma final inmutable' }); + if (report.reviewStatus !== 'APPROVED' || !report.approvedRevisionId || !report.approvedAt) { + throw new ConflictException({ code: 'INSPECTION_REPORT_NOT_APPROVED', message: 'El Director debe aprobar una versión antes de firmar el informe final' }); + } + const [revision] = await manager.query(` + SELECT id, revision_number AS "revisionNumber", sha256 + FROM inspection_report_revisions + WHERE id = $1 AND report_id = $2 + `, [report.approvedRevisionId, report.id]) as Array<{ id: string; revisionNumber: number; sha256: string }>; + if (!revision) throw new ConflictException({ code: 'INSPECTION_REPORT_APPROVED_REVISION_MISSING', message: 'La versión aprobada no está disponible para la firma final' }); + const signedAt = new Date(); + const payload = { + schemaVersion: 'DH-INSPECTION-REPORT-SIGNATURE-V1', + reportId: report.id, + reportCode: report.code, + reportFrozenSha256: report.frozenSha256, + revisionId: revision.id, + revisionNumber: revision.revisionNumber, + revisionSha256: revision.sha256, + signedBy: principal.userId, + signedByUsername: principal.username, + signedAt: signedAt.toISOString(), + confirmationText: FINAL_CONFIRMATION_TEXT, + }; + const signatureSha256 = sha256CanonicalJson(payload); + const [signature] = await manager.query(` + INSERT INTO inspection_report_signatures ( + report_id, revision_id, signed_by, signed_at, confirmation_text, + signature_payload, signature_sha256 + ) VALUES ($1,$2,$3,$4,$5,$6,$7) + RETURNING id + `, [report.id, revision.id, principal.userId, signedAt, FINAL_CONFIRMATION_TEXT, payload, signatureSha256]) as Array<{ id: string }>; + await manager.query(` + UPDATE inspection_reports + SET review_status = 'SIGNED', signed_at = $2, updated_at = CURRENT_TIMESTAMP + WHERE id = $1 + `, [report.id, signedAt]); + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.INSPECTION_REPORT_SIGNED, + entityType: 'inspection_report_signature', + entityId: signature.id, + afterData: { + reportId: report.id, + reportCode: report.code, + revisionId: revision.id, + revisionNumber: revision.revisionNumber, + revisionSha256: revision.sha256, + signatureSha256, + signedAt: signedAt.toISOString(), + }, + }, manager); + }); + return this.loadView(reportId); + } + + async revisionContent(revisionId: string): Promise<{ filePath: string; originalName: string; mimeType: string }> { + const [revision] = await this.dataSource.query(` + SELECT id, report_id AS "reportId", revision_number AS "revisionNumber", source, + original_name AS "originalName", stored_name AS "storedName", mime_type AS "mimeType", + size_bytes::integer AS "sizeBytes", sha256 + FROM inspection_report_revisions WHERE id = $1 + `, [revisionId]) as StoredRevisionRow[]; + if (!revision) throw new NotFoundException({ code: 'INSPECTION_REPORT_REVISION_NOT_FOUND', message: 'Versión del informe no encontrada' }); + if (revision.source === 'AUTO') return this.word.content(revision.reportId); + const filePath = resolve(this.revisionRoot, revision.storedName); + if (!filePath.startsWith(`${this.revisionRoot}/`)) throw this.storageError(); + const fileStat = await stat(filePath).catch(() => null); + if (!fileStat?.isFile() || fileStat.size !== revision.sizeBytes) throw this.storageError(); + const buffer = await readFile(filePath); + const sha256 = createHash('sha256').update(buffer).digest('hex'); + if (sha256 !== revision.sha256) throw this.storageError(); + return { filePath, originalName: revision.originalName, mimeType: revision.mimeType }; + } + + private async ensureAutomaticRevision(reportId: string): Promise { + await this.dataSource.query(` + INSERT INTO inspection_report_revisions ( + report_id, revision_number, source, original_name, stored_name, mime_type, + size_bytes, sha256, change_summary, created_by, created_at + ) + SELECT + id, 1, 'AUTO', word_original_name, word_stored_name, word_mime_type, + word_size_bytes, word_sha256, 'Versión automática inicial', generated_by, + COALESCE(word_generated_at, generated_at) + FROM inspection_reports + WHERE id = $1 + AND word_status = 'READY' + AND word_original_name IS NOT NULL + AND word_stored_name IS NOT NULL + AND word_mime_type = $2 + AND word_size_bytes > 0 + AND word_sha256 ~ '^[0-9a-f]{64}$' + ON CONFLICT (report_id, revision_number) DO NOTHING + `, [reportId, INSPECTION_REPORT_WORD_MIME]); + await this.dataSource.query(` + UPDATE inspection_reports + SET current_revision_number = GREATEST(current_revision_number, 1), updated_at = CURRENT_TIMESTAMP + WHERE id = $1 + AND EXISTS (SELECT 1 FROM inspection_report_revisions WHERE report_id = $1 AND revision_number = 1) + `, [reportId]); + } + + private async loadView(reportId: string): Promise { + const [report] = await this.dataSource.query(` + SELECT + report.id AS "reportId", + report.code AS "reportCode", + report.review_status AS "reviewStatus", + report.current_revision_number AS "currentRevisionNumber", + report.approved_revision_id AS "approvedRevisionId", + report.approved_at AS "approvedAt", + report.review_note AS "reviewNote", + CASE WHEN approver.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( + 'id', approver.id, 'username', approver.username, 'firstName', approver.first_name, 'lastName', approver.last_name + ) END AS "approvedBy" + FROM inspection_reports report + LEFT JOIN users approver ON approver.id = report.approved_by + WHERE report.id = $1 + `, [reportId]) as Array>; + if (!report) throw new NotFoundException({ code: 'INSPECTION_REPORT_NOT_FOUND', message: 'Informe de inspección no encontrado' }); + const revisions = await this.dataSource.query(` + SELECT + revision.id, + revision.report_id AS "reportId", + revision.revision_number AS "revisionNumber", + revision.source, + revision.original_name AS "originalName", + revision.mime_type AS "mimeType", + revision.size_bytes::integer AS "sizeBytes", + revision.sha256, + revision.change_summary AS "changeSummary", + revision.created_at AS "createdAt", + JSONB_BUILD_OBJECT( + 'id', creator.id, 'username', creator.username, 'firstName', creator.first_name, 'lastName', creator.last_name + ) AS "createdBy" + FROM inspection_report_revisions revision + JOIN users creator ON creator.id = revision.created_by + WHERE revision.report_id = $1 + ORDER BY revision.revision_number DESC + `, [reportId]) as InspectionReportRevisionView[]; + const [signature] = await this.dataSource.query(` + SELECT + signature.id, + signature.revision_id AS "revisionId", + signature.signed_at AS "signedAt", + signature.confirmation_text AS "confirmationText", + signature.signature_sha256 AS "signatureSha256", + JSONB_BUILD_OBJECT( + 'id', signer.id, 'username', signer.username, 'firstName', signer.first_name, 'lastName', signer.last_name + ) AS "signedBy" + FROM inspection_report_signatures signature + JOIN users signer ON signer.id = signature.signed_by + WHERE signature.report_id = $1 + `, [reportId]) as InspectionReportSignatureView[]; + return { ...report, signature: signature ?? null, revisions }; + } + + private async lockReport(manager: EntityManager, reportId: string): Promise { + const [report] = await manager.query(` + SELECT + id, code, status, frozen_sha256 AS "frozenSha256", + review_status AS "reviewStatus", + current_revision_number AS "currentRevisionNumber", + approved_revision_id AS "approvedRevisionId", + approved_at AS "approvedAt", + review_note AS "reviewNote" + FROM inspection_reports + WHERE id = $1 + FOR UPDATE + `, [reportId]) as LockedReport[]; + if (!report) throw new NotFoundException({ code: 'INSPECTION_REPORT_NOT_FOUND', message: 'Informe de inspección no encontrado' }); + if (report.status !== 'FROZEN') throw new ConflictException({ code: 'INSPECTION_REPORT_NOT_REVIEWABLE', message: 'Sólo los informes congelados pueden ingresar al circuito de revisión' }); + return report; + } + + private assertOpenForReview(report: LockedReport): void { + if (report.reviewStatus === 'SIGNED') throw new ConflictException({ code: 'INSPECTION_REPORT_ALREADY_SIGNED', message: 'El informe firmado es definitivo y no admite nuevas versiones' }); + if (report.reviewStatus === 'APPROVED') throw new ConflictException({ code: 'INSPECTION_REPORT_ALREADY_APPROVED', message: 'La versión ya fue aprobada y sólo resta la firma final del Director' }); + } + + private async assertDirector(userId: string, manager: EntityManager): Promise { + const [role] = await manager.query(` + SELECT role.code + FROM user_roles membership + JOIN roles role ON role.id = membership.role_id + WHERE membership.user_id = $1 AND role.code = 'director' + LIMIT 1 + `, [userId]) as Array<{ code: string }>; + if (!role) throw new ForbiddenException({ code: 'INSPECTION_REPORT_DIRECTOR_REQUIRED', message: 'Esta operación está reservada al Director de Hidrocarburos' }); + } + + private cleanOriginalName(value: string): string { + const cleaned = value.replace(/[\\/\0\r\n]/g, '_').trim(); + return (cleaned || 'informe-corregido.docx').slice(0, 255); + } + + private storageError(): InternalServerErrorException { + return new InternalServerErrorException({ code: 'INSPECTION_REPORT_REVISION_STORAGE_ERROR', message: 'La versión del informe no está disponible o no supera la validación de integridad' }); + } +} diff --git a/api-v3/src/inspection-reports/inspection-report-revision-file.ts b/api-v3/src/inspection-reports/inspection-report-revision-file.ts new file mode 100644 index 0000000..e4871b9 --- /dev/null +++ b/api-v3/src/inspection-reports/inspection-report-revision-file.ts @@ -0,0 +1,38 @@ +import { BadRequestException } from '@nestjs/common'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); +export const MAX_INSPECTION_REPORT_REVISION_BYTES = 15 * 1024 * 1024; +export const INSPECTION_REPORT_WORD_MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; + +export interface UploadedInspectionReportRevisionFile { + originalname: string; + mimetype: string; + size: number; + buffer: Buffer; +} + +function invalidFile(message: string): BadRequestException { + return new BadRequestException({ code: 'INSPECTION_REPORT_REVISION_INVALID_FILE', message }); +} + +export function inspectInspectionReportRevisionUpload(file: UploadedInspectionReportRevisionFile | undefined): void { + if (!file?.buffer?.length) throw invalidFile('Debés adjuntar un archivo Word .docx'); + if (file.size <= 0 || file.size > MAX_INSPECTION_REPORT_REVISION_BYTES) throw invalidFile('El Word corregido debe pesar hasta 15 MB'); + if (!file.originalname.toLowerCase().endsWith('.docx')) throw invalidFile('La versión corregida debe ser un archivo .docx'); + if (file.mimetype && file.mimetype !== INSPECTION_REPORT_WORD_MIME && file.mimetype !== 'application/octet-stream') throw invalidFile('El tipo de archivo no corresponde a un Word .docx'); + if (file.buffer.length < 4 || file.buffer[0] !== 0x50 || file.buffer[1] !== 0x4b || file.buffer[2] !== 0x03 || file.buffer[3] !== 0x04) throw invalidFile('El archivo no tiene una estructura DOCX válida'); +} + +export async function validateInspectionReportRevisionContainer(filePath: string): Promise { + let stdout = ''; + try { + ({ stdout } = await execFileAsync('unzip', ['-Z1', filePath], { encoding: 'utf8', maxBuffer: 4 * 1024 * 1024 })); + } catch { + throw invalidFile('No se pudo validar la estructura interna del Word'); + } + const entries = stdout.split(/\r?\n/).map((item) => item.trim()).filter(Boolean); + if (!entries.includes('[Content_Types].xml') || !entries.includes('word/document.xml')) throw invalidFile('El archivo no contiene la estructura mínima de un documento Word'); + if (entries.some((entry) => /(^|\/)vbaProject\.bin$/i.test(entry) || /(^|\/)embeddings\//i.test(entry))) throw invalidFile('No se permiten macros ni objetos embebidos en las versiones del informe'); +} diff --git a/api-v3/src/inspection-reports/inspection-report-word-builder.ts b/api-v3/src/inspection-reports/inspection-report-word-builder.ts new file mode 100644 index 0000000..387e270 --- /dev/null +++ b/api-v3/src/inspection-reports/inspection-report-word-builder.ts @@ -0,0 +1,214 @@ +import { createHash } from 'node:crypto'; + +interface ZipEntry { + name: string; + data: Buffer; +} + +interface ReportWordInput { + code: string; + title: string; + generatedAt: Date; + frozenSha256: string; + frozenSnapshot: Record; +} + +const crcTable = (() => { + const table = new Uint32Array(256); + for (let n = 0; n < 256; n += 1) { + let value = n; + for (let k = 0; k < 8; k += 1) { + value = (value & 1) ? 0xedb88320 ^ (value >>> 1) : value >>> 1; + } + table[n] = value >>> 0; + } + return table; +})(); + +function crc32(data: Buffer): number { + let crc = 0xffffffff; + for (const byte of data) crc = crcTable[(crc ^ byte) & 0xff]! ^ (crc >>> 8); + return (crc ^ 0xffffffff) >>> 0; +} + +function xmlEscape(value: unknown): string { + return String(value ?? '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +function asRecord(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : {}; +} + +function asArray(value: unknown): Array> { + return Array.isArray(value) ? value.map(asRecord) : []; +} + +function text(value: unknown, fallback = '—'): string { + const normalized = String(value ?? '').trim(); + return normalized || fallback; +} + +function isoDate(value: unknown): string { + const date = new Date(String(value ?? '')); + return Number.isFinite(date.getTime()) ? date.toLocaleDateString('es-AR') : '—'; +} + +function paragraph(value: string, style?: 'Title' | 'Heading1' | 'Heading2'): string { + const styleXml = style ? `` : ''; + return `${styleXml}${xmlEscape(value)}`; +} + +function labelValue(label: string, value: string): string { + return `${xmlEscape(label)}: ${xmlEscape(value)}`; +} + +function table(headers: string[], rows: string[][]): string { + const cell = (value: string, bold = false) => `${bold ? '' : ''}${xmlEscape(value)}`; + const header = `${headers.map((item) => cell(item, true)).join('')}`; + const body = rows.map((row) => `${row.map((item) => cell(item)).join('')}`).join(''); + return `${header}${body}`; +} + +function documentXml(input: ReportWordInput): string { + const report = asRecord(input.frozenSnapshot.report); + const actClosure = asRecord(input.frozenSnapshot.actClosure); + const prepared = asRecord(actClosure.preparedSnapshot); + const act = asRecord(prepared.act); + const visit = asRecord(act.visit); + const responsible = asRecord(prepared.responsible); + const team = asArray(prepared.team); + const assets = asArray(prepared.assets); + const findings = asArray(prepared.findings); + const signatures = asArray(actClosure.signatures); + const companies = [...new Set(assets.map((item) => text(asRecord(item.operatorCompany).name, '')).filter(Boolean))]; + const areas = [...new Set(assets.map((item) => text(asRecord(item.operationalArea).name, '')).filter(Boolean))]; + const inspectorNames = team.map((member) => `${text(member.firstName, '')} ${text(member.lastName, '')}`.trim()).filter(Boolean); + const assetRows = assets.map((item) => [text(item.code), text(item.name), text(item.commonName, ''), text(item.typeName)]); + const findingRows = findings.map((item) => [text(item.code), text(item.title), text(item.description), item.severity == null ? '—' : `${text(item.severity)}/10`, isoDate(item.correctionDueOn)]); + const signatureRows = signatures.map((item) => [text(item.signerType), text(item.signerName), text(item.status), isoDate(item.signedAt ?? item.createdAt)]); + const body = [ + paragraph('INFORME TÉCNICO DE INSPECCIÓN', 'Title'), + paragraph('Borrador automático para revisión del Director de Hidrocarburos', 'Heading2'), + labelValue('Informe', input.code), + labelValue('Título', input.title), + labelValue('Fecha de generación', input.generatedAt.toLocaleString('es-AR')), + labelValue('Acta', text(report.actCode ?? act.code)), + labelValue('Inspección', text(report.visitCode ?? visit.code)), + labelValue('Empresa', companies.join(' · ') || 'Según alcance del acta'), + labelValue('Área', areas.join(' · ') || 'Según alcance del acta'), + labelValue('Inspector/es', inspectorNames.join(' · ') || '—'), + labelValue('Responsable de empresa', text(responsible.fullName)), + labelValue('Cargo', text(responsible.position)), + paragraph('Datos de la visita', 'Heading1'), + labelValue('Objetivo', text(visit.objective)), + labelValue('Fecha de inspección', isoDate(act.occurredAt)), + labelValue('Resumen', text(act.summary)), + labelValue('Observaciones', text(act.observations)), + paragraph('Elementos inspeccionados', 'Heading1'), + assetRows.length ? table(['Código', 'Nombre técnico', 'Nombre habitual', 'Tipo'], assetRows) : paragraph('No se registraron elementos en la instantánea.'), + paragraph('Hallazgos', 'Heading1'), + findingRows.length ? table(['Código', 'Título', 'Descripción', 'Gravedad', 'Vencimiento'], findingRows) : paragraph('No se registraron hallazgos en la instantánea.'), + paragraph('Firmas y constancias', 'Heading1'), + signatureRows.length ? table(['Tipo', 'Firmante', 'Estado', 'Fecha'], signatureRows) : paragraph('No se registraron firmas en la instantánea.'), + paragraph('Integridad documental', 'Heading1'), + labelValue('Hash del informe', input.frozenSha256), + labelValue('Hash de cierre del acta', text(report.actClosureSha256)), + paragraph('El contenido de este archivo fue generado desde la instantánea congelada del acta. El diseño institucional y la firma final del Director se incorporarán en la etapa de revisión correspondiente.'), + ].join(''); + return `${body}`; +} + +function buildZip(entries: ZipEntry[]): Buffer { + const locals: Buffer[] = []; + const centrals: Buffer[] = []; + let offset = 0; + const dosDate = 33; + const dosTime = 0; + for (const entry of entries) { + const name = Buffer.from(entry.name, 'utf8'); + const crc = crc32(entry.data); + const local = Buffer.alloc(30); + local.writeUInt32LE(0x04034b50, 0); + local.writeUInt16LE(20, 4); + local.writeUInt16LE(0, 6); + local.writeUInt16LE(0, 8); + local.writeUInt16LE(dosTime, 10); + local.writeUInt16LE(dosDate, 12); + local.writeUInt32LE(crc, 14); + local.writeUInt32LE(entry.data.length, 18); + local.writeUInt32LE(entry.data.length, 22); + local.writeUInt16LE(name.length, 26); + local.writeUInt16LE(0, 28); + locals.push(local, name, entry.data); + const central = Buffer.alloc(46); + central.writeUInt32LE(0x02014b50, 0); + central.writeUInt16LE(20, 4); + central.writeUInt16LE(20, 6); + central.writeUInt16LE(0, 8); + central.writeUInt16LE(0, 10); + central.writeUInt16LE(dosTime, 12); + central.writeUInt16LE(dosDate, 14); + central.writeUInt32LE(crc, 16); + central.writeUInt32LE(entry.data.length, 20); + central.writeUInt32LE(entry.data.length, 24); + central.writeUInt16LE(name.length, 28); + central.writeUInt16LE(0, 30); + central.writeUInt16LE(0, 32); + central.writeUInt16LE(0, 34); + central.writeUInt16LE(0, 36); + central.writeUInt32LE(0, 38); + central.writeUInt32LE(offset, 42); + centrals.push(central, name); + offset += local.length + name.length + entry.data.length; + } + const centralData = Buffer.concat(centrals); + const end = Buffer.alloc(22); + end.writeUInt32LE(0x06054b50, 0); + end.writeUInt16LE(0, 4); + end.writeUInt16LE(0, 6); + end.writeUInt16LE(entries.length, 8); + end.writeUInt16LE(entries.length, 10); + end.writeUInt32LE(centralData.length, 12); + end.writeUInt32LE(offset, 16); + end.writeUInt16LE(0, 20); + return Buffer.concat([...locals, centralData, end]); +} + +export function buildInspectionReportWord(input: ReportWordInput): { buffer: Buffer; sha256: string } { + const entries: ZipEntry[] = [ + { + name: '[Content_Types].xml', + data: Buffer.from('', 'utf8'), + }, + { + name: '_rels/.rels', + data: Buffer.from('', 'utf8'), + }, + { + name: 'word/_rels/document.xml.rels', + data: Buffer.from('', 'utf8'), + }, + { + name: 'word/styles.xml', + data: Buffer.from('', 'utf8'), + }, + { name: 'word/document.xml', data: Buffer.from(documentXml(input), 'utf8') }, + { + name: 'docProps/core.xml', + data: Buffer.from(`${xmlEscape(input.title)}DH InspecciónDH Inspección${input.generatedAt.toISOString()}`, 'utf8'), + }, + { + name: 'docProps/app.xml', + data: Buffer.from('DH Inspección', 'utf8'), + }, + ]; + const buffer = buildZip(entries); + return { buffer, sha256: createHash('sha256').update(buffer).digest('hex') }; +} diff --git a/api-v3/src/inspection-reports/inspection-report-word.service.ts b/api-v3/src/inspection-reports/inspection-report-word.service.ts new file mode 100644 index 0000000..4074c81 --- /dev/null +++ b/api-v3/src/inspection-reports/inspection-report-word.service.ts @@ -0,0 +1,128 @@ +import { createHash } from 'node:crypto'; +import { mkdir, readFile, stat, writeFile } from 'node:fs/promises'; +import { isAbsolute, parse, resolve } from 'node:path'; +import { Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { DataSource } from 'typeorm'; +import { buildInspectionReportWord } from './inspection-report-word-builder'; + +const WORD_MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; + +interface WordRow { + id: string; + code: string; + title: string; + generatedAt: Date; + frozenSha256: string; + frozenSnapshot: Record; + wordStatus: 'PENDING' | 'READY' | 'FAILED'; + wordOriginalName: string | null; + wordStoredName: string | null; + wordMimeType: string | null; + wordSizeBytes: number | null; + wordSha256: string | null; + generatedBy: string; +} + +@Injectable() +export class InspectionReportWordService { + private readonly root: string; + + constructor(private readonly dataSource: DataSource, config: ConfigService) { + const configured = config.get('INSPECTION_REPORT_WORD_ROOT') ?? '/app/storage/asset-media/inspection-reports-word'; + if (!isAbsolute(configured)) throw new Error('INSPECTION_REPORT_WORD_ROOT must be an absolute path'); + this.root = resolve(configured); + if (this.root === parse(this.root).root) throw new Error('INSPECTION_REPORT_WORD_ROOT cannot be the filesystem root'); + } + + async ensure(reportId: string): Promise { + const row = await this.load(reportId); + if (row.wordStatus === 'READY' && row.wordStoredName) { + try { await this.content(row.id); await this.ensureInitialRevision(row); return; } catch {} + } + try { + const built = buildInspectionReportWord({ + code: row.code, + title: row.title, + generatedAt: new Date(row.generatedAt), + frozenSha256: row.frozenSha256, + frozenSnapshot: row.frozenSnapshot, + }); + await mkdir(this.root, { recursive: true, mode: 0o700 }); + const storedName = `${row.id}.docx`; + const originalName = `${row.code}.docx`; + const filePath = resolve(this.root, storedName); + await writeFile(filePath, built.buffer, { mode: 0o600 }); + await this.dataSource.query(` + UPDATE inspection_reports + SET word_status = 'READY', + word_original_name = $2, + word_stored_name = $3, + word_mime_type = $4, + word_size_bytes = $5, + word_sha256 = $6, + word_generated_at = CURRENT_TIMESTAMP, + word_error = NULL, + updated_at = CURRENT_TIMESTAMP + WHERE id = $1 + `, [row.id, originalName, storedName, WORD_MIME, built.buffer.length, built.sha256]); + await this.ensureInitialRevision(await this.load(row.id)); + } catch (error) { + const message = error instanceof Error ? error.message.slice(0, 500) : 'Error desconocido'; + await this.dataSource.query(` + UPDATE inspection_reports + SET word_status = 'FAILED', word_error = $2, updated_at = CURRENT_TIMESTAMP + WHERE id = $1 + `, [row.id, message]).catch(() => undefined); + } + } + + async content(reportId: string): Promise<{ filePath: string; originalName: string; mimeType: string }> { + const row = await this.load(reportId); + if (row.wordStatus !== 'READY' || !row.wordStoredName || !row.wordOriginalName || !row.wordSha256 || !row.wordSizeBytes) { + throw new NotFoundException({ code: 'INSPECTION_REPORT_WORD_NOT_READY', message: 'El archivo Word del informe todavía no está disponible' }); + } + const filePath = resolve(this.root, row.wordStoredName); + if (!filePath.startsWith(`${this.root}/`)) throw this.storageError(); + const fileStat = await stat(filePath).catch(() => null); + if (!fileStat?.isFile() || fileStat.size !== row.wordSizeBytes) throw this.storageError(); + const buffer = await readFile(filePath); + const sha256 = createHash('sha256').update(buffer).digest('hex'); + if (sha256 !== row.wordSha256) throw this.storageError(); + return { filePath, originalName: row.wordOriginalName, mimeType: row.wordMimeType ?? WORD_MIME }; + } + + + private async ensureInitialRevision(row: WordRow): Promise { + if (row.wordStatus !== 'READY' || !row.wordOriginalName || !row.wordStoredName || !row.wordMimeType || !row.wordSizeBytes || !row.wordSha256) return; + await this.dataSource.query(` + INSERT INTO inspection_report_revisions ( + report_id, revision_number, source, original_name, stored_name, mime_type, + size_bytes, sha256, change_summary, created_by, created_at + ) VALUES ($1,1,'AUTO',$2,$3,$4,$5,$6,'Versión automática inicial',$7,COALESCE((SELECT word_generated_at FROM inspection_reports WHERE id=$1),CURRENT_TIMESTAMP)) + ON CONFLICT (report_id, revision_number) DO NOTHING + `, [row.id, row.wordOriginalName, row.wordStoredName, row.wordMimeType, row.wordSizeBytes, row.wordSha256, row.generatedBy]); + await this.dataSource.query(` + UPDATE inspection_reports + SET current_revision_number = GREATEST(current_revision_number, 1), updated_at = CURRENT_TIMESTAMP + WHERE id = $1 + `, [row.id]); + } + + private async load(reportId: string): Promise { + const [row] = await this.dataSource.query(` + SELECT id, code, title, generated_at AS "generatedAt", frozen_sha256 AS "frozenSha256", + frozen_snapshot AS "frozenSnapshot", word_status AS "wordStatus", + word_original_name AS "wordOriginalName", word_stored_name AS "wordStoredName", + word_mime_type AS "wordMimeType", word_size_bytes::integer AS "wordSizeBytes", + word_sha256 AS "wordSha256", generated_by AS "generatedBy" + FROM inspection_reports WHERE id = $1 + `, [reportId]) as WordRow[]; + if (!row) throw new NotFoundException({ code: 'INSPECTION_REPORT_NOT_FOUND', message: 'Informe de inspección no encontrado' }); + return row; + } + + private storageError(): InternalServerErrorException { + return new InternalServerErrorException({ code: 'INSPECTION_REPORT_WORD_STORAGE_ERROR', message: 'El archivo Word del informe no está disponible o no supera la validación de integridad' }); + } +} diff --git a/api-v3/src/inspection-reports/inspection-reports.controller.ts b/api-v3/src/inspection-reports/inspection-reports.controller.ts new file mode 100644 index 0000000..536a543 --- /dev/null +++ b/api-v3/src/inspection-reports/inspection-reports.controller.ts @@ -0,0 +1,65 @@ +import { Controller, Get, Param, ParseUUIDPipe, Post, Query, Req, Res } from '@nestjs/common'; +import { CurrentAuth } from '../auth/decorators/current-auth.decorator'; +import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator'; +import type { Response } from 'express'; +import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; +import { ListInspectionReportsQueryDto } from './dto/list-inspection-reports-query.dto'; +import { InspectionReportsService } from './inspection-reports.service'; +import { InspectionReportWordService } from './inspection-report-word.service'; + +@Controller('inspection-reports') +export class InspectionReportsController { + constructor(private readonly reports: InspectionReportsService, private readonly word: InspectionReportWordService) {} + + @Get() + @RequirePermissions('inspection_reports.read') + list(@Query() query: ListInspectionReportsQueryDto) { + return this.reports.list(query); + } + + @Get('pending') + @RequirePermissions('inspection_reports.read') + pending(@Query() query: ListInspectionReportsQueryDto) { + return this.reports.listPending(query); + } + + @Post(':id/word') + @RequirePermissions('inspection_reports.generate') + async generateWord(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) { + await this.word.ensure(id); + return this.reports.get(id); + } + + @Get(':id/word') + @RequirePermissions('inspection_reports.read') + async wordContent( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @Res() response: Response, + ) { + const content = await this.word.content(id); + response.setHeader('Content-Type', content.mimeType); + response.setHeader('Content-Disposition', `attachment; filename="${content.originalName.replaceAll('"', '')}"`); + return response.sendFile(content.filePath); + } + + @Get(':id') + @RequirePermissions('inspection_reports.read') + get(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) { + return this.reports.get(id); + } +} + +@Controller('inspection-acts/:actId/report') +export class InspectionActReportController { + constructor(private readonly reports: InspectionReportsService) {} + + @Post() + @RequirePermissions('inspection_reports.generate') + generate( + @Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.reports.generate(actId, principal, request); + } +} diff --git a/api-v3/src/inspection-reports/inspection-reports.module.ts b/api-v3/src/inspection-reports/inspection-reports.module.ts new file mode 100644 index 0000000..a997459 --- /dev/null +++ b/api-v3/src/inspection-reports/inspection-reports.module.ts @@ -0,0 +1,32 @@ +import { Module } from '@nestjs/common'; +import { AuditModule } from '../audit/audit.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 { 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'; + +@Module({ + imports: [AuditModule], + controllers: [ + InspectionReportsController, + InspectionActReportController, + DocumentDeliveryController, + InspectionReportReviewController, + InspectionReportRevisionContentController, + ], + providers: [ + InspectionReportsService, + InspectionReportWordService, + InspectionActPdfService, + InspectionDocumentDeliveryService, + SmtpDeliveryService, + InspectionReportReviewService, + ], + exports: [InspectionReportsService], +}) +export class InspectionReportsModule {} diff --git a/api-v3/src/inspection-reports/inspection-reports.service.ts b/api-v3/src/inspection-reports/inspection-reports.service.ts new file mode 100644 index 0000000..4d7d13f --- /dev/null +++ b/api-v3/src/inspection-reports/inspection-reports.service.ts @@ -0,0 +1,572 @@ +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, + DocumentSequenceType, + InspectionActStatus, + InspectionReportPdfStatus, + InspectionReportStatus, +} from '../database/entities'; +import { sha256CanonicalJson } from '../inspection-closing/canonical-json'; +import { assertMobileInspector } from '../inspection-operations/mobile-inspector-policy'; +import type { ListInspectionReportsQueryDto } from './dto/list-inspection-reports-query.dto'; +import { InspectionReportWordService } from './inspection-report-word.service'; +import { InspectionDocumentDeliveryService } from './inspection-document-delivery.service'; + +const REPORT_SCHEMA_VERSION = 'DH-INSPECTION-REPORT-V1'; + +interface ContextAsset { + id: string; + code: string; + name: string; +} + +export interface InspectionReportListItem { + id: string; + visitId: string; + actId: string; + reportYear: number; + reportNumber: number; + code: string; + status: InspectionReportStatus; + pdfStatus: InspectionReportPdfStatus; + wordStatus: 'PENDING' | 'READY' | 'FAILED'; + wordGeneratedAt: Date | null; + reviewStatus: 'PENDING_REVIEW' | 'APPROVED' | 'SIGNED'; + currentRevisionNumber: number; + approvedAt: Date | null; + signedAt: Date | null; + title: string; + actVersion: number; + actClosureSha256: string; + frozenSha256: string; + generatedAt: Date; + generatedBy: { id: string; username: string; firstName: string; lastName: string }; + act: { id: string; code: string; title: string; status: string; occurredAt: Date; closedAt: Date | null }; + visit: { id: string; code: string; title: string; status: string }; + companies: ContextAsset[]; + areas: ContextAsset[]; + findingCount: number; +} + +export interface InspectionReportView extends InspectionReportListItem { + frozenSnapshot: Record; +} + +export interface PendingInspectionReportItem { + actId: string; + visitId: string; + actCode: string; + actTitle: string; + actYear: number; + occurredAt: Date; + closedAt: Date; + closureSha256: string; + visitCode: string; + visitTitle: string; + companies: ContextAsset[]; + areas: ContextAsset[]; + findingCount: number; +} + +function reportNotFound(): NotFoundException { + return new NotFoundException({ + code: 'INSPECTION_REPORT_NOT_FOUND', + message: 'Informe de inspección no encontrado', + }); +} + +function actNotFound(): NotFoundException { + return new NotFoundException({ + code: 'INSPECTION_ACT_NOT_FOUND', + message: 'Acta de inspección no encontrada', + }); +} + +@Injectable() +export class InspectionReportsService { + constructor( + private readonly dataSource: DataSource, + private readonly audit: AuditService, + private readonly word: InspectionReportWordService, + private readonly delivery: InspectionDocumentDeliveryService, + ) {} + + async list(query: ListInspectionReportsQueryDto) { + const { where, parameters, add } = this.reportFilters(query); + const [countRow] = (await this.dataSource.query( + `SELECT COUNT(*)::integer AS total + 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}`, + parameters, + )) as Array<{ total: number }>; + const total = Number(countRow?.total ?? 0); + const limit = add(query.pageSize); + const offset = add((query.page - 1) * query.pageSize); + const data = (await this.dataSource.query( + `${this.reportSelect(where)} + ORDER BY report.report_year DESC, report.report_number DESC + LIMIT ${limit} OFFSET ${offset}`, + parameters, + )) as InspectionReportListItem[]; + return { + data, + meta: { + page: query.page, + pageSize: query.pageSize, + total, + totalPages: total === 0 ? 0 : Math.ceil(total / query.pageSize), + }, + }; + } + + async listPending(query: ListInspectionReportsQueryDto) { + const conditions = [ + `act.status = 'CLOSED'`, + 'report.id IS NULL', + 'act.closure_sha256 IS NOT NULL', + ]; + const parameters: unknown[] = []; + const add = (value: unknown): string => { + parameters.push(value); + return `$${parameters.length}`; + }; + if (query.search?.trim()) { + const search = add(`%${query.search.trim()}%`); + conditions.push(`( + act.code ILIKE ${search} + OR act.title ILIKE ${search} + OR visit.code ILIKE ${search} + OR visit.title ILIKE ${search} + OR EXISTS ( + SELECT 1 + FROM inspection_act_assets search_link + INNER JOIN assets search_asset ON search_asset.id = search_link.asset_id + LEFT JOIN assets search_company ON search_company.id = search_asset.operator_company_id + LEFT JOIN assets search_area ON search_area.id = search_asset.operational_area_id + WHERE search_link.act_id = act.id + AND search_link.included = true + AND ( + search_asset.name ILIKE ${search} + OR search_asset.code ILIKE ${search} + OR search_company.name ILIKE ${search} + OR search_area.name ILIKE ${search} + ) + ) + )`); + } + if (query.year) conditions.push(`act.act_year = ${add(query.year)}`); + if (query.companyId) conditions.push(this.contextFilter('operator_company_id', add(query.companyId))); + if (query.areaId) conditions.push(this.contextFilter('operational_area_id', add(query.areaId))); + if (query.inspectorId) { + const inspector = add(query.inspectorId); + conditions.push(`( + visit.lead_inspector_user_id = ${inspector}::uuid + OR EXISTS ( + SELECT 1 FROM inspection_visit_members member_filter + WHERE member_filter.visit_id = visit.id + AND member_filter.included = true + AND member_filter.user_id = ${inspector}::uuid + ) + )`); + } + if (query.dateFrom) conditions.push(`act.occurred_at::date >= ${add(query.dateFrom)}::date`); + if (query.dateTo) conditions.push(`act.occurred_at::date <= ${add(query.dateTo)}::date`); + const where = `WHERE ${conditions.join(' AND ')}`; + const base = `FROM inspection_acts act + INNER JOIN inspection_visits visit ON visit.id = act.visit_id + LEFT JOIN inspection_reports report ON report.act_id = act.id`; + const [countRow] = (await this.dataSource.query( + `SELECT COUNT(*)::integer AS total ${base} ${where}`, + parameters, + )) as Array<{ total: number }>; + const total = Number(countRow?.total ?? 0); + const limit = add(query.pageSize); + const offset = add((query.page - 1) * query.pageSize); + const data = (await this.dataSource.query(` + SELECT + act.id AS "actId", + visit.id AS "visitId", + act.code AS "actCode", + act.title AS "actTitle", + act.act_year AS "actYear", + act.occurred_at AS "occurredAt", + act.closed_at AS "closedAt", + act.closure_sha256 AS "closureSha256", + visit.code AS "visitCode", + visit.title AS "visitTitle", + COALESCE(context.companies, '[]'::jsonb) AS companies, + COALESCE(context.areas, '[]'::jsonb) AS areas, + COALESCE(finding_count.total, 0)::integer AS "findingCount" + ${base} + LEFT JOIN LATERAL (${this.contextSelect()}) context ON true + LEFT JOIN LATERAL ( + SELECT COUNT(*) AS total + FROM inspection_findings finding + WHERE finding.act_id = act.id AND finding.status <> 'VOIDED' + ) finding_count ON true + ${where} + ORDER BY act.act_year DESC, act.act_number DESC + LIMIT ${limit} OFFSET ${offset} + `, parameters)) as PendingInspectionReportItem[]; + return { + data, + meta: { + page: query.page, + pageSize: query.pageSize, + total, + totalPages: total === 0 ? 0 : Math.ceil(total / query.pageSize), + }, + }; + } + + async get(id: string): Promise { + const [report] = (await this.dataSource.query( + `${this.reportSelect('WHERE report.id = $1')}`, + [id], + )) as InspectionReportListItem[]; + if (!report) throw reportNotFound(); + const [snapshot] = (await this.dataSource.query( + 'SELECT frozen_snapshot AS "frozenSnapshot" FROM inspection_reports WHERE id = $1', + [id], + )) as Array<{ frozenSnapshot: Record }>; + return { ...report, frozenSnapshot: snapshot.frozenSnapshot }; + } + + async generate( + actId: string, + principal: AuthPrincipal, + request: RequestWithContext, + ): Promise { + assertMobileInspector(principal); + const report = await this.dataSource.transaction(async (manager) => this.ensureFrozenReport(manager, actId, principal, request)); + await this.word.ensure(report.id); + return this.get(report.id); + } + + async ensureFrozenReport( + manager: EntityManager, + actId: string, + principal: AuthPrincipal, + request: RequestWithContext, + ): Promise { + const [act] = (await manager.query(` + SELECT + act.id, + act.visit_id AS "visitId", + act.act_year AS "actYear", + act.code, + act.title, + act.status, + act.current_version AS "currentVersion", + act.closure_sha256 AS "closureSha256", + visit.code AS "visitCode", + visit.title AS "visitTitle" + FROM inspection_acts act + INNER JOIN inspection_visits visit ON visit.id = act.visit_id + WHERE act.id = $1 + FOR UPDATE OF act + `, [actId])) as Array<{ + id: string; + visitId: string; + actYear: number; + code: string; + title: string; + status: InspectionActStatus; + currentVersion: number; + closureSha256: string | null; + visitCode: string; + visitTitle: string; + }>; + if (!act) throw actNotFound(); + if (act.status !== InspectionActStatus.CLOSED || !act.closureSha256) { + throw new ConflictException({ + code: 'INSPECTION_REPORT_ACT_NOT_CLOSED', + message: 'El informe sólo puede emitirse después del cierre definitivo del acta', + }); + } + await this.assertActorAssigned(manager, act.visitId, principal.userId); + const [existing] = (await manager.query( + 'SELECT id FROM inspection_reports WHERE act_id = $1', + [actId], + )) as Array<{ id: string }>; + if (existing) return this.getWithManager(manager, existing.id); + const [closure] = (await manager.query(` + SELECT final_snapshot AS "finalSnapshot", final_sha256 AS "finalSha256" + FROM inspection_act_closures + WHERE act_id = $1 + `, [actId])) as Array<{ + finalSnapshot: Record | null; + finalSha256: string | null; + }>; + if (!closure?.finalSnapshot || closure.finalSha256 !== act.closureSha256) { + throw new ConflictException({ + code: 'INSPECTION_REPORT_CLOSURE_NOT_FROZEN', + message: 'El cierre del acta no tiene una instantánea final válida para emitir el informe', + }); + } + const reportNumber = await this.allocateNumber(manager, act.actYear); + if (reportNumber > 999999) { + throw new ConflictException({ + code: 'INSPECTION_REPORT_SEQUENCE_EXHAUSTED', + message: 'La numeración anual de informes agotó su rango disponible', + }); + } + const code = `INF-${act.actYear}-${String(reportNumber).padStart(6, '0')}`; + const generatedAt = new Date(); + const title = `Informe técnico · ${act.title}`.slice(0, 220); + const frozenSnapshot = { + schemaVersion: REPORT_SCHEMA_VERSION, + report: { + code, + reportYear: act.actYear, + reportNumber, + generatedAt: generatedAt.toISOString(), + generatedBy: principal.userId, + generatedByUsername: principal.username, + visitId: act.visitId, + visitCode: act.visitCode, + visitTitle: act.visitTitle, + actId: act.id, + actCode: act.code, + actVersion: act.currentVersion, + actClosureSha256: act.closureSha256, + }, + actClosure: closure.finalSnapshot, + }; + const frozenSha256 = sha256CanonicalJson(frozenSnapshot); + const [created] = (await manager.query(` + INSERT INTO inspection_reports ( + visit_id, act_id, report_year, report_number, code, status, pdf_status, + title, act_version, act_closure_sha256, frozen_sha256, frozen_snapshot, + generated_at, generated_by + ) VALUES ( + $1, $2, $3, $4, $5, 'FROZEN', 'PENDING', $6, $7, $8, $9, $10, $11, $12 + ) + RETURNING id + `, [ + act.visitId, + act.id, + act.actYear, + reportNumber, + code, + title, + act.currentVersion, + act.closureSha256, + frozenSha256, + frozenSnapshot, + generatedAt, + principal.userId, + ])) as Array<{ id: string }>; + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.INSPECTION_REPORT_GENERATED, + entityType: 'inspection_report', + entityId: created.id, + afterData: { + code, + actId: act.id, + visitId: act.visitId, + actVersion: act.currentVersion, + frozenSha256, + pdfStatus: InspectionReportPdfStatus.PENDING, + }, + }, manager); + return this.getWithManager(manager, created.id); + } + + async ensureWordForAct(actId: string): Promise { + const [row] = await this.dataSource.query('SELECT id FROM inspection_reports WHERE act_id = $1', [actId]) as Array<{ id: string }>; + if (row) await this.word.ensure(row.id); + await this.delivery.dispatchForAct(actId).catch(() => undefined); + } + + private reportFilters(query: ListInspectionReportsQueryDto) { + const conditions = ['1 = 1']; + const parameters: unknown[] = []; + const add = (value: unknown): string => { + parameters.push(value); + return `$${parameters.length}`; + }; + if (query.search?.trim()) { + const search = add(`%${query.search.trim()}%`); + conditions.push(`( + report.code ILIKE ${search} + OR report.title ILIKE ${search} + OR act.code ILIKE ${search} + OR act.title ILIKE ${search} + OR visit.code ILIKE ${search} + OR visit.title ILIKE ${search} + OR EXISTS ( + SELECT 1 + FROM inspection_act_assets search_link + INNER JOIN assets search_asset ON search_asset.id = search_link.asset_id + LEFT JOIN assets search_company ON search_company.id = search_asset.operator_company_id + LEFT JOIN assets search_area ON search_area.id = search_asset.operational_area_id + WHERE search_link.act_id = act.id + AND search_link.included = true + AND ( + search_asset.name ILIKE ${search} + OR search_asset.code ILIKE ${search} + OR search_company.name ILIKE ${search} + OR search_area.name ILIKE ${search} + ) + ) + )`); + } + if (query.year) conditions.push(`report.report_year = ${add(query.year)}`); + if (query.companyId) conditions.push(this.contextFilter('operator_company_id', add(query.companyId))); + if (query.areaId) conditions.push(this.contextFilter('operational_area_id', add(query.areaId))); + if (query.inspectorId) { + const inspector = add(query.inspectorId); + conditions.push(`( + visit.lead_inspector_user_id = ${inspector}::uuid + OR EXISTS ( + SELECT 1 FROM inspection_visit_members member_filter + WHERE member_filter.visit_id = visit.id + AND member_filter.included = true + AND member_filter.user_id = ${inspector}::uuid + ) + )`); + } + if (query.dateFrom) conditions.push(`act.occurred_at::date >= ${add(query.dateFrom)}::date`); + if (query.dateTo) conditions.push(`act.occurred_at::date <= ${add(query.dateTo)}::date`); + return { where: `WHERE ${conditions.join(' AND ')}`, parameters, add }; + } + + private contextFilter(column: 'operator_company_id' | 'operational_area_id', parameter: string): string { + return `EXISTS ( + SELECT 1 + FROM inspection_act_assets context_filter_link + INNER JOIN assets context_filter_asset ON context_filter_asset.id = context_filter_link.asset_id + WHERE context_filter_link.act_id = act.id + AND context_filter_link.included = true + AND context_filter_asset.${column} = ${parameter}::uuid + )`; + } + + private contextSelect(): string { + return ` + SELECT + COALESCE(JSONB_AGG(DISTINCT JSONB_BUILD_OBJECT( + 'id', company.id, + 'code', company.code, + 'name', company.name + )) FILTER (WHERE company.id IS NOT NULL), '[]'::jsonb) AS companies, + COALESCE(JSONB_AGG(DISTINCT JSONB_BUILD_OBJECT( + 'id', area.id, + 'code', area.code, + 'name', area.name + )) FILTER (WHERE area.id IS NOT NULL), '[]'::jsonb) AS areas + FROM inspection_act_assets context_link + INNER JOIN assets context_asset ON context_asset.id = context_link.asset_id + LEFT JOIN assets company ON company.id = context_asset.operator_company_id + LEFT JOIN assets area ON area.id = context_asset.operational_area_id + WHERE context_link.act_id = act.id AND context_link.included = true + `; + } + + private reportSelect(where: string): string { + return ` + SELECT + report.id, + report.visit_id AS "visitId", + report.act_id AS "actId", + report.report_year AS "reportYear", + report.report_number AS "reportNumber", + report.code, + report.status, + report.pdf_status AS "pdfStatus", + report.word_status AS "wordStatus", + report.word_generated_at AS "wordGeneratedAt", + report.review_status AS "reviewStatus", + report.current_revision_number AS "currentRevisionNumber", + report.approved_at AS "approvedAt", + report.signed_at AS "signedAt", + report.title, + report.act_version AS "actVersion", + report.act_closure_sha256 AS "actClosureSha256", + report.frozen_sha256 AS "frozenSha256", + report.generated_at AS "generatedAt", + JSONB_BUILD_OBJECT( + 'id', generator.id, + 'username', generator.username, + 'firstName', generator.first_name, + 'lastName', generator.last_name + ) AS "generatedBy", + JSONB_BUILD_OBJECT( + 'id', act.id, + 'code', act.code, + 'title', act.title, + 'status', act.status, + 'occurredAt', act.occurred_at, + 'closedAt', act.closed_at + ) AS act, + JSONB_BUILD_OBJECT( + 'id', visit.id, + 'code', visit.code, + 'title', visit.title, + 'status', visit.status + ) AS visit, + COALESCE(context.companies, '[]'::jsonb) AS companies, + COALESCE(context.areas, '[]'::jsonb) AS areas, + COALESCE(finding_count.total, 0)::integer AS "findingCount" + 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 + INNER JOIN users generator ON generator.id = report.generated_by + LEFT JOIN LATERAL (${this.contextSelect()}) context ON true + LEFT JOIN LATERAL ( + SELECT COUNT(*) AS total + FROM inspection_findings finding + WHERE finding.act_id = act.id AND finding.status <> 'VOIDED' + ) finding_count ON true + ${where} + `; + } + + private async getWithManager(manager: EntityManager, id: string): Promise { + const [report] = (await manager.query( + `${this.reportSelect('WHERE report.id = $1')}`, + [id], + )) as InspectionReportListItem[]; + if (!report) throw reportNotFound(); + const [snapshot] = (await manager.query( + 'SELECT frozen_snapshot AS "frozenSnapshot" FROM inspection_reports WHERE id = $1', + [id], + )) as Array<{ frozenSnapshot: Record }>; + return { ...report, frozenSnapshot: snapshot.frozenSnapshot }; + } + + private async allocateNumber(manager: EntityManager, year: number): Promise { + const [row] = (await manager.query(` + INSERT INTO document_annual_sequences (document_type, year, last_number) + VALUES ($1, $2, 1) + ON CONFLICT (document_type, year) DO UPDATE SET + last_number = document_annual_sequences.last_number + 1, + updated_at = CURRENT_TIMESTAMP + RETURNING last_number AS number + `, [DocumentSequenceType.REPORT, year])) as Array<{ number: number }>; + return Number(row.number); + } + + private async assertActorAssigned(manager: EntityManager, visitId: string, userId: string): Promise { + const rows = (await manager.query(` + SELECT 1 + FROM inspection_visit_members + WHERE visit_id = $1 AND user_id = $2 AND included = true + LIMIT 1 + `, [visitId, userId])) as unknown[]; + if (rows.length === 0) { + throw new ForbiddenException({ + code: 'INSPECTION_REPORT_ACTOR_NOT_ASSIGNED', + message: 'Sólo un inspector asignado a la visita puede solicitar el informe', + }); + } + } +} diff --git a/api-v3/src/inspection-reports/smtp-delivery.service.ts b/api-v3/src/inspection-reports/smtp-delivery.service.ts new file mode 100644 index 0000000..bce88d6 --- /dev/null +++ b/api-v3/src/inspection-reports/smtp-delivery.service.ts @@ -0,0 +1,37 @@ +import { connect as connectNet, Socket } from 'node:net'; +import { connect as connectTls, TLSSocket } from 'node:tls'; +import { randomUUID } from 'node:crypto'; +import { ConfigService } from '@nestjs/config'; +import { Injectable } from '@nestjs/common'; + +interface MailAttachment { filename:string; mimeType:string; content:Buffer; } +interface MailInput { to:string; subject:string; text:string; attachment:MailAttachment; } +interface Reply { code:number; text:string; } + +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{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;} +} + +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,'..'); } + +@Injectable() +export class SmtpDeliveryService { + constructor(private readonly config:ConfigService){} + configured(){return Boolean(this.config.get('SMTP_HOST')&&this.config.get('MAIL_FROM'));} + async send(input:MailInput):Promise<{messageId:string}>{ const host=this.config.get('SMTP_HOST'); const from=this.config.get('MAIL_FROM'); if(!host||!from)throw new Error('SMTP no configurado'); const port=Number(this.config.get('SMTP_PORT')??587); const secure=String(this.config.get('SMTP_SECURE')??'false').toLowerCase()==='true'; const user=this.config.get('SMTP_USER')??''; const pass=this.config.get('SMTP_PASS')??''; const base=await new Promise((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((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()}; } +} diff --git a/api-v3/src/inspection-verifications/dto/list-verification-planning-query.dto.ts b/api-v3/src/inspection-verifications/dto/list-verification-planning-query.dto.ts new file mode 100644 index 0000000..06854fc --- /dev/null +++ b/api-v3/src/inspection-verifications/dto/list-verification-planning-query.dto.ts @@ -0,0 +1,49 @@ +import { Transform, Type } from 'class-transformer'; +import { IsIn, IsInt, IsOptional, IsString, IsUUID, Matches, Max, MaxLength, Min } from 'class-validator'; + +export const VERIFICATION_PLANNING_STATUSES = ['ALL', 'UNPLANNED', 'PLANNED'] as const; +export type VerificationPlanningStatus = typeof VERIFICATION_PLANNING_STATUSES[number]; + +const normalizeText = ({ value }: { value: unknown }) => + typeof value === 'string' ? value.trim() : value; + +export class ListVerificationPlanningQueryDto { + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page = 1; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + pageSize = 50; + + @IsOptional() + @Transform(normalizeText) + @IsString() + @MaxLength(180) + search?: string; + + @IsOptional() + @IsIn(VERIFICATION_PLANNING_STATUSES) + planningStatus: VerificationPlanningStatus = 'UNPLANNED'; + + @IsOptional() + @IsUUID('4') + companyId?: string; + + @IsOptional() + @IsUUID('4') + areaId?: string; + + @IsOptional() + @Matches(/^\d{4}-\d{2}-\d{2}$/) + dueFrom?: string; + + @IsOptional() + @Matches(/^\d{4}-\d{2}-\d{2}$/) + dueTo?: string; +} diff --git a/api-v3/src/inspection-verifications/dto/plan-verification-visit.dto.ts b/api-v3/src/inspection-verifications/dto/plan-verification-visit.dto.ts new file mode 100644 index 0000000..a61e5ed --- /dev/null +++ b/api-v3/src/inspection-verifications/dto/plan-verification-visit.dto.ts @@ -0,0 +1,22 @@ +import { Transform } from 'class-transformer'; +import { ArrayMaxSize, ArrayMinSize, IsArray, IsISO8601, IsOptional, IsString, IsUUID, MaxLength } from 'class-validator'; + +const optionalText = ({ value }: { value: unknown }) => + typeof value === 'string' && value.trim() ? value.trim() : null; + +export class PlanVerificationVisitDto { + @IsArray() + @ArrayMinSize(1) + @ArrayMaxSize(100) + @IsUUID('4', { each: true }) + findingIds!: string[]; + + @IsISO8601({ strict: true }) + plannedStartAt!: string; + + @IsOptional() + @Transform(optionalText) + @IsString() + @MaxLength(4000) + notes?: string | null; +} diff --git a/api-v3/src/inspection-verifications/dto/record-verification-result.dto.ts b/api-v3/src/inspection-verifications/dto/record-verification-result.dto.ts new file mode 100644 index 0000000..ad57f4d --- /dev/null +++ b/api-v3/src/inspection-verifications/dto/record-verification-result.dto.ts @@ -0,0 +1,20 @@ +import { IsEnum, IsISO8601, IsOptional, IsString, Matches, MaxLength, MinLength, ValidateIf } from 'class-validator'; +import { InspectionVerificationOutcome } from '../../database/entities'; + +export class RecordVerificationResultDto { + @IsEnum(InspectionVerificationOutcome) + outcome!: InspectionVerificationOutcome; + + @IsString() + @MinLength(5) + @MaxLength(4000) + notes!: string; + + @IsOptional() + @IsISO8601({ strict: true }) + verifiedAt?: string; + + @ValidateIf((value: RecordVerificationResultDto) => value.outcome === InspectionVerificationOutcome.REQUIRES_NEW_DATE) + @Matches(/^\d{4}-\d{2}-\d{2}$/) + nextControlOn?: string; +} diff --git a/api-v3/src/inspection-verifications/inspection-verifications.controller.ts b/api-v3/src/inspection-verifications/inspection-verifications.controller.ts new file mode 100644 index 0000000..68b5534 --- /dev/null +++ b/api-v3/src/inspection-verifications/inspection-verifications.controller.ts @@ -0,0 +1,41 @@ +import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, Req } from '@nestjs/common'; +import { CurrentAuth } from '../auth/decorators/current-auth.decorator'; +import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator'; +import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; +import { ListVerificationPlanningQueryDto } from './dto/list-verification-planning-query.dto'; +import { PlanVerificationVisitDto } from './dto/plan-verification-visit.dto'; +import { RecordVerificationResultDto } from './dto/record-verification-result.dto'; +import { InspectionVerificationsService } from './inspection-verifications.service'; + +@Controller('inspection-verifications') +export class InspectionVerificationsController { + constructor(private readonly verifications: InspectionVerificationsService) {} + + @Get() + @RequirePermissions('inspection_findings.read') + list(@Query() query: ListVerificationPlanningQueryDto) { + return this.verifications.listQueue(query); + } + + @Post('plan') + @RequirePermissions('inspection_findings.read', 'inspection_verifications.plan') + plan( + @Body() dto: PlanVerificationVisitDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.verifications.planVisit(dto, principal, request); + } + + @Patch('visits/:visitId/findings/:findingId/result') + @RequirePermissions('inspections.execute') + recordResult( + @Param('visitId', new ParseUUIDPipe({ version: '4' })) visitId: string, + @Param('findingId', new ParseUUIDPipe({ version: '4' })) findingId: string, + @Body() dto: RecordVerificationResultDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.verifications.recordResult(visitId, findingId, dto, principal, request); + } +} diff --git a/api-v3/src/inspection-verifications/inspection-verifications.module.ts b/api-v3/src/inspection-verifications/inspection-verifications.module.ts new file mode 100644 index 0000000..bb51072 --- /dev/null +++ b/api-v3/src/inspection-verifications/inspection-verifications.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { AuditModule } from '../audit/audit.module'; +import { InspectionVerificationsController } from './inspection-verifications.controller'; +import { InspectionVerificationsService } from './inspection-verifications.service'; + +@Module({ + imports: [AuditModule], + controllers: [InspectionVerificationsController], + providers: [InspectionVerificationsService], +}) +export class InspectionVerificationsModule {} diff --git a/api-v3/src/inspection-verifications/inspection-verifications.service.ts b/api-v3/src/inspection-verifications/inspection-verifications.service.ts new file mode 100644 index 0000000..eff5ccd --- /dev/null +++ b/api-v3/src/inspection-verifications/inspection-verifications.service.ts @@ -0,0 +1,763 @@ +import { BadRequestException, ConflictException, 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, + InspectionFindingVersionEvent, + InspectionFindingVerificationEventType, + InspectionVerificationOutcome, + InspectionVisit, + InspectionVisitStatus, +} from '../database/entities'; +import type { ListVerificationPlanningQueryDto } from './dto/list-verification-planning-query.dto'; +import type { PlanVerificationVisitDto } from './dto/plan-verification-visit.dto'; +import type { RecordVerificationResultDto } from './dto/record-verification-result.dto'; +import { assertMobileInspector } from '../inspection-operations/mobile-inspector-policy'; +import { nextInspectionVisitCode } from '../inspection-visits/inspection-visit-code'; +import { appendVerificationEvent } from './verification-event-ledger'; + +interface VerificationContextView { + id: string; + code: string; + name: string; +} + +interface VerificationVisitView { + id: string; + code: string; + title: string; + status: InspectionVisitStatus; + plannedStartAt: Date | null; + plannedEndAt: Date | null; +} + +export interface VerificationPlanningItem { + id: string; + code: string; + title: string; + status: 'OPEN'; + nextControlOn: string; + companyResponseReceivedOn: string; + asset: VerificationContextView & { typeName: string }; + company: VerificationContextView | null; + area: VerificationContextView | null; + act: { id: string; code: string }; + sourceVisit: { id: string; code: string; title: string }; + verificationVisit: VerificationVisitView | null; +} + +export interface VerificationPlanningCounters { + eligible: number; + unplanned: number; + overdueUnplanned: number; + dueNext30Days: number; + planned: number; +} + +export interface VerificationPlanningPage { + data: VerificationPlanningItem[]; + meta: { page: number; pageSize: number; total: number; totalPages: number }; + counters: VerificationPlanningCounters; +} + +interface LockedFindingRow { + id: string; + code: string; + title: string; + assetId: string; + assetCode: string; + assetName: string; + companyId: string | null; + companyCode: string | null; + companyName: string | null; + areaId: string | null; + areaCode: string | null; + areaName: string | null; + companyResponseReceivedOn: string | null; + nextControlOn: string | null; + status: string; +} + +@Injectable() +export class InspectionVerificationsService { + constructor( + private readonly dataSource: DataSource, + private readonly audit: AuditService, + ) {} + + async listQueue(query: ListVerificationPlanningQueryDto): Promise { + const page = query.page ?? 1; + const pageSize = query.pageSize ?? 50; + const values: unknown[] = []; + const baseFilters = [ + `finding.status = 'OPEN'`, + `finding.company_response_received_on IS NOT NULL`, + `finding.next_control_on IS NOT NULL`, + `COALESCE(( + SELECT latest_verification.outcome + FROM inspection_finding_verification_visits latest_verification + WHERE latest_verification.finding_id = finding.id + AND latest_verification.outcome IS NOT NULL + ORDER BY latest_verification.result_recorded_at DESC NULLS LAST, latest_verification.created_at DESC, latest_verification.id DESC + LIMIT 1 + ), '') <> 'RESOLVED'`, + ]; + const add = (value: unknown): string => { + values.push(value); + return `$${values.length}`; + }; + + if (query.search) { + const parameter = add(`%${query.search}%`); + baseFilters.push(`( + finding.code ILIKE ${parameter} + OR finding.title ILIKE ${parameter} + OR asset.code ILIKE ${parameter} + OR asset.name ILIKE ${parameter} + OR company.name ILIKE ${parameter} + OR area.name ILIKE ${parameter} + OR act.code ILIKE ${parameter} + )`); + } + if (query.companyId) baseFilters.push(`company.id = ${add(query.companyId)}`); + if (query.areaId) baseFilters.push(`area.id = ${add(query.areaId)}`); + if (query.dueFrom) baseFilters.push(`finding.next_control_on >= ${add(query.dueFrom)}::date`); + if (query.dueTo) baseFilters.push(`finding.next_control_on <= ${add(query.dueTo)}::date`); + + const baseWhere = `WHERE ${baseFilters.join(' AND ')}`; + const activeVisitJoin = ` + LEFT JOIN LATERAL ( + SELECT + visit.id, + visit.code, + visit.title, + visit.status, + visit.planned_start_at AS "plannedStartAt", + visit.planned_end_at AS "plannedEndAt" + FROM inspection_finding_verification_visits verification_link + INNER JOIN inspection_visits visit ON visit.id = verification_link.visit_id + WHERE verification_link.finding_id = finding.id + AND visit.status IN ('DRAFT', 'PLANNED', 'IN_PROGRESS') + ORDER BY verification_link.created_at DESC, verification_link.id DESC + LIMIT 1 + ) verification_visit ON true + `; + const joins = ` + INNER JOIN inspection_acts act ON act.id = finding.act_id + INNER JOIN inspection_visits source_visit ON source_visit.id = act.visit_id + INNER JOIN assets asset ON asset.id = finding.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 + ${activeVisitJoin} + `; + + const planningFilters: string[] = []; + if (query.planningStatus === 'UNPLANNED') planningFilters.push('verification_visit.id IS NULL'); + if (query.planningStatus === 'PLANNED') planningFilters.push('verification_visit.id IS NOT NULL'); + const listWhere = planningFilters.length + ? `${baseWhere} AND ${planningFilters.join(' AND ')}` + : baseWhere; + + const countRows = await this.dataSource.query(` + SELECT COUNT(*)::integer AS total + FROM inspection_findings finding + ${joins} + ${listWhere} + `, values) as Array<{ total: number }>; + const total = Number(countRows[0]?.total ?? 0); + + const listValues = [...values]; + listValues.push(pageSize); + const limitParameter = `$${listValues.length}`; + listValues.push((page - 1) * pageSize); + const offsetParameter = `$${listValues.length}`; + const data = await this.dataSource.query(` + SELECT + finding.id, + finding.code, + finding.title, + finding.status, + finding.next_control_on AS "nextControlOn", + finding.company_response_received_on AS "companyResponseReceivedOn", + jsonb_build_object( + 'id', asset.id, + 'code', asset.code, + 'name', asset.name, + 'typeName', asset_type.name + ) AS asset, + CASE WHEN company.id IS NULL THEN NULL ELSE jsonb_build_object( + 'id', company.id, + 'code', company.code, + 'name', company.name + ) END AS company, + CASE WHEN area.id IS NULL THEN NULL ELSE jsonb_build_object( + 'id', area.id, + 'code', area.code, + 'name', area.name + ) END AS area, + jsonb_build_object('id', act.id, 'code', act.code) AS act, + jsonb_build_object( + 'id', source_visit.id, + 'code', source_visit.code, + 'title', source_visit.title + ) AS "sourceVisit", + CASE WHEN verification_visit.id IS NULL THEN NULL ELSE jsonb_build_object( + 'id', verification_visit.id, + 'code', verification_visit.code, + 'title', verification_visit.title, + 'status', verification_visit.status, + 'plannedStartAt', verification_visit."plannedStartAt", + 'plannedEndAt', verification_visit."plannedEndAt" + ) END AS "verificationVisit" + FROM inspection_findings finding + ${joins} + ${listWhere} + ORDER BY + CASE WHEN finding.next_control_on < (CURRENT_TIMESTAMP AT TIME ZONE 'America/Argentina/Mendoza')::date THEN 0 ELSE 1 END, + finding.next_control_on, + company.name NULLS LAST, + area.name NULLS LAST, + finding.code + LIMIT ${limitParameter} OFFSET ${offsetParameter} + `, listValues) as VerificationPlanningItem[]; + + const today = `(CURRENT_TIMESTAMP AT TIME ZONE 'America/Argentina/Mendoza')::date`; + const counterRows = await this.dataSource.query(` + SELECT + COUNT(*)::integer AS eligible, + COUNT(*) FILTER (WHERE verification_visit.id IS NULL)::integer AS unplanned, + COUNT(*) FILTER ( + WHERE verification_visit.id IS NULL AND finding.next_control_on < ${today} + )::integer AS "overdueUnplanned", + COUNT(*) FILTER ( + WHERE verification_visit.id IS NULL + AND finding.next_control_on BETWEEN ${today} AND ${today} + 30 + )::integer AS "dueNext30Days", + COUNT(*) FILTER (WHERE verification_visit.id IS NOT NULL)::integer AS planned + FROM inspection_findings finding + ${joins} + ${baseWhere} + `, values) as VerificationPlanningCounters[]; + const counters = counterRows[0]; + + return { + data, + meta: { + page, + pageSize, + total, + totalPages: total === 0 ? 0 : Math.ceil(total / pageSize), + }, + counters: { + eligible: Number(counters?.eligible ?? 0), + unplanned: Number(counters?.unplanned ?? 0), + overdueUnplanned: Number(counters?.overdueUnplanned ?? 0), + dueNext30Days: Number(counters?.dueNext30Days ?? 0), + planned: Number(counters?.planned ?? 0), + }, + }; + } + + async recordResult( + visitId: string, + findingId: string, + dto: RecordVerificationResultDto, + principal: AuthPrincipal, + request: RequestWithContext, + ) { + assertMobileInspector(principal); + const notes = dto.notes.trim(); + const verifiedAt = dto.verifiedAt ? new Date(dto.verifiedAt) : new Date(); + if (!Number.isFinite(verifiedAt.getTime())) { + throw new BadRequestException({ + code: 'VERIFICATION_RESULT_DATE_INVALID', + message: 'La fecha de verificación no es válida', + }); + } + if (dto.outcome === InspectionVerificationOutcome.REQUIRES_NEW_DATE && !dto.nextControlOn) { + throw new BadRequestException({ + code: 'VERIFICATION_NEW_DATE_REQUIRED', + message: 'Indicá la nueva fecha de verificación', + }); + } + if (dto.outcome !== InspectionVerificationOutcome.REQUIRES_NEW_DATE && dto.nextControlOn) { + throw new BadRequestException({ + code: 'VERIFICATION_NEW_DATE_NOT_ALLOWED', + message: 'La nueva fecha sólo corresponde cuando el hallazgo requiere un nuevo control', + }); + } + + return this.dataSource.transaction(async (manager) => { + const [context] = await manager.query(` + SELECT + verification_link.id AS "linkId", + 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", + finding.id AS "findingId", + finding.code AS "findingCode", + finding.status AS "findingStatus", + finding.next_control_on AS "findingNextControlOn", + finding.act_id AS "sourceActId", + visit.id AS "visitId", + visit.code AS "visitCode", + visit.status AS "visitStatus" + FROM inspection_finding_verification_visits verification_link + INNER JOIN inspection_findings finding ON finding.id = verification_link.finding_id + INNER JOIN inspection_visits visit ON visit.id = verification_link.visit_id + WHERE verification_link.visit_id = $1 + AND verification_link.finding_id = $2 + FOR UPDATE OF verification_link, finding, visit + `, [visitId, findingId]) as Array<{ + linkId: string; + targetControlOn: string | null; + outcome: InspectionVerificationOutcome | null; + resultNotes: string | null; + verifiedAt: Date | null; + resultRecordedAt: Date | null; + rescheduledControlOn: string | null; + findingId: string; + findingCode: string; + findingStatus: string; + findingNextControlOn: string | null; + sourceActId: string; + visitId: string; + visitCode: string; + visitStatus: InspectionVisitStatus; + }>; + if (!context) { + throw new NotFoundException({ + code: 'VERIFICATION_LINK_NOT_FOUND', + message: 'El hallazgo no pertenece a esta visita de verificación', + }); + } + if (context.outcome !== null || context.resultRecordedAt !== null) { + throw new ConflictException({ + code: 'VERIFICATION_RESULT_IMMUTABLE', + message: 'El resultado de esta verificación ya fue registrado y no puede reemplazarse', + }); + } + if (context.visitStatus !== InspectionVisitStatus.IN_PROGRESS) { + throw new ConflictException({ + code: 'VERIFICATION_VISIT_NOT_IN_PROGRESS', + message: 'El resultado sólo puede registrarse durante una visita de verificación en curso', + }); + } + if (context.findingStatus !== 'OPEN') { + throw new ConflictException({ + code: 'VERIFICATION_FINDING_CLOSED', + message: `${context.findingCode} ya no está abierto`, + }); + } + await this.assertActorAssigned(manager, visitId, principal); + + let nextControlOn: string | null = context.findingNextControlOn; + let rescheduledControlOn: string | null = null; + if (dto.outcome === InspectionVerificationOutcome.RESOLVED) { + nextControlOn = null; + } + if (dto.outcome === InspectionVerificationOutcome.NOT_RESOLVED) { + nextControlOn = null; + } + if (dto.outcome === InspectionVerificationOutcome.REQUIRES_NEW_DATE) { + rescheduledControlOn = dto.nextControlOn ?? null; + nextControlOn = rescheduledControlOn; + const verifiedDate = verifiedAt.toISOString().slice(0, 10); + if (!rescheduledControlOn || rescheduledControlOn <= verifiedDate) { + throw new BadRequestException({ + code: 'VERIFICATION_NEW_DATE_NOT_FUTURE', + message: 'La nueva fecha debe ser posterior a la verificación realizada', + }); + } + } + + const before = { + outcome: context.outcome, + resultNotes: context.resultNotes, + verifiedAt: context.verifiedAt, + resultRecordedAt: context.resultRecordedAt, + rescheduledControlOn: context.rescheduledControlOn, + findingNextControlOn: context.findingNextControlOn, + }; + const recordedAt = new Date(); + await manager.query(` + UPDATE inspection_finding_verification_visits + SET outcome = $3, + result_notes = $4, + verified_at = $5, + result_recorded_at = $6, + result_recorded_by = $7, + rescheduled_control_on = $8, + updated_at = CURRENT_TIMESTAMP + WHERE visit_id = $1 AND finding_id = $2 + `, [ + visitId, + findingId, + dto.outcome, + notes, + verifiedAt, + recordedAt, + principal.userId, + rescheduledControlOn, + ]); + await manager.query(` + UPDATE inspection_findings + SET next_control_on = $2, + updated_by = $3, + updated_at = CURRENT_TIMESTAMP + WHERE id = $1 + `, [findingId, nextControlOn, principal.userId]); + await appendVerificationEvent(manager, { + findingId, + verificationVisitId: visitId, + eventType: InspectionFindingVerificationEventType.RESULT_RECORDED, + occurredAt: verifiedAt, + targetControlOn: context.targetControlOn, + previousControlOn: context.findingNextControlOn, + nextControlOn, + outcome: dto.outcome, + notes, + }, principal); + await appendVerificationEvent(manager, { + findingId, + verificationVisitId: visitId, + eventType: rescheduledControlOn + ? InspectionFindingVerificationEventType.CONTROL_DATE_CHANGED + : InspectionFindingVerificationEventType.CONTROL_DATE_CLEARED, + occurredAt: verifiedAt, + targetControlOn: rescheduledControlOn, + previousControlOn: context.findingNextControlOn, + nextControlOn, + notes: rescheduledControlOn + ? 'Nueva fecha definida como resultado de la verificación' + : 'La verificación consumió la fecha prevista sin fijar un nuevo control', + }, principal); + const versionNumber = await this.captureFindingVerificationVersion( + manager, + findingId, + visitId, + dto.outcome, + notes, + verifiedAt, + rescheduledControlOn, + principal, + ); + const [result] = await manager.query(` + SELECT + verification_link.finding_id AS "findingId", + verification_link.visit_id AS "visitId", + verification_link.target_control_on AS "targetControlOn", + verification_link.outcome, + verification_link.result_notes AS "notes", + verification_link.verified_at AS "verifiedAt", + verification_link.result_recorded_at AS "recordedAt", + verification_link.rescheduled_control_on AS "rescheduledControlOn", + finding.next_control_on AS "nextControlOn", + finding.status AS "findingStatus", + visit.status AS "visitStatus" + FROM inspection_finding_verification_visits verification_link + INNER JOIN inspection_findings finding ON finding.id = verification_link.finding_id + INNER JOIN inspection_visits visit ON visit.id = verification_link.visit_id + WHERE verification_link.visit_id = $1 AND verification_link.finding_id = $2 + `, [visitId, findingId]) as Array>; + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.INSPECTION_VERIFICATION_RESULT_RECORDED, + entityType: 'inspection_finding_verification_visit', + entityId: context.linkId, + beforeData: before, + afterData: result ?? null, + metadata: { + findingId, + sourceActId: context.sourceActId, + visitId, + visitCode: context.visitCode, + versionNumber, + }, + }, manager); + return result; + }); + } + + async planVisit( + dto: PlanVerificationVisitDto, + principal: AuthPrincipal, + request: RequestWithContext, + ) { + const findingIds = [...new Set(dto.findingIds)]; + const plannedStartAt = new Date(dto.plannedStartAt); + if (!Number.isFinite(plannedStartAt.getTime())) { + throw new BadRequestException({ code: 'VERIFICATION_START_INVALID', message: 'La fecha prevista de inicio no es válida' }); + } + return this.dataSource.transaction(async (manager) => { + const rows = await manager.query(` + SELECT + finding.id, + finding.code, + finding.title, + finding.status, + finding.asset_id AS "assetId", + finding.company_response_received_on AS "companyResponseReceivedOn", + finding.next_control_on AS "nextControlOn", + asset.code AS "assetCode", + asset.name AS "assetName", + company.id AS "companyId", + company.code AS "companyCode", + company.name AS "companyName", + area.id AS "areaId", + area.code AS "areaCode", + area.name AS "areaName" + FROM inspection_findings finding + INNER JOIN assets asset ON asset.id = finding.asset_id + LEFT JOIN assets company ON company.id = asset.operator_company_id + LEFT JOIN assets area ON area.id = asset.operational_area_id + WHERE finding.id = ANY($1::uuid[]) + FOR UPDATE OF finding + `, [findingIds]) as LockedFindingRow[]; + + if (rows.length !== findingIds.length) { + throw new NotFoundException({ code: 'VERIFICATION_FINDING_NOT_FOUND', message: 'Uno o más hallazgos ya no están disponibles' }); + } + const invalid = rows.find((row) => + row.status !== 'OPEN' || !row.companyResponseReceivedOn || !row.nextControlOn, + ); + if (invalid) { + throw new ConflictException({ + code: 'VERIFICATION_FINDING_NOT_READY', + message: `${invalid.code} no está listo para una verificación planificada`, + }); + } + const missingContext = rows.find((row) => !row.companyId || !row.areaId); + if (missingContext) { + throw new BadRequestException({ + code: 'VERIFICATION_CONTEXT_REQUIRED', + message: `${missingContext.code} necesita empresa y área operativa antes de planificar la visita`, + }); + } + const companyId = rows[0]?.companyId; + const areaId = rows[0]?.areaId; + if (!companyId || !areaId) { + throw new BadRequestException({ code: 'VERIFICATION_CONTEXT_REQUIRED', message: 'Los hallazgos necesitan empresa y área operativa' }); + } + if (rows.some((row) => row.companyId !== companyId || row.areaId !== areaId)) { + throw new BadRequestException({ + code: 'VERIFICATION_CONTEXT_MIXED', + message: 'Seleccioná hallazgos de una misma empresa y área para crear una visita de verificación', + }); + } + + const activeLinks = await manager.query(` + SELECT verification_link.finding_id AS "findingId", visit.code + FROM inspection_finding_verification_visits verification_link + INNER JOIN inspection_visits visit ON visit.id = verification_link.visit_id + WHERE verification_link.finding_id = ANY($1::uuid[]) + AND visit.status IN ('DRAFT', 'PLANNED', 'IN_PROGRESS') + `, [findingIds]) as Array<{ findingId: string; code: string }>; + if (activeLinks.length > 0) { + throw new ConflictException({ + code: 'VERIFICATION_ALREADY_PLANNED', + message: `Al menos un hallazgo ya está asociado a una visita activa (${activeLinks[0]?.code ?? 'existente'})`, + }); + } + + const companyName = rows[0]?.companyName ?? 'Empresa'; + const areaName = rows[0]?.areaName ?? 'Área'; + const code = await nextInspectionVisitCode(manager, plannedStartAt); + const title = code; + const findingCodes = rows.map((row) => row.code).join(', '); + const notes = dto.notes?.trim() || null; + const instructions = [ + `Verificar ${rows.length} hallazgo${rows.length === 1 ? '' : 's'}: ${findingCodes}.`, + notes, + ].filter(Boolean).join('\n\n'); + + const visit = manager.getRepository(InspectionVisit).create({ + code, + title, + objective: 'Verificar en campo la resolución de hallazgos con fecha de control programada.', + status: InspectionVisitStatus.DRAFT, + scopeAssetId: areaId, + operationalAreaId: areaId, + operatorCompanyId: companyId, + leadInspectorUserId: null, + plannedStartAt, + plannedEndAt: null, + actualStartedAt: null, + actualClosedAt: null, + instructions, + cancellationReason: null, + checklistGeneration: 0, + checklistGeneratedAt: null, + createdBy: principal.userId, + updatedBy: principal.userId, + }); + await manager.getRepository(InspectionVisit).save(visit); + + const assetIds = [...new Set(rows.map((row) => row.assetId))]; + for (const assetId of assetIds) { + await manager.query(` + INSERT INTO inspection_visit_assets ( + visit_id, asset_id, included, planning_source, exclusion_reason, + excluded_by, excluded_at, added_by + ) + VALUES ($1, $2, true, 'VERIFICATION', NULL, NULL, NULL, $3) + ON CONFLICT (visit_id, asset_id) DO UPDATE SET + included = true, + planning_source = 'VERIFICATION', + exclusion_reason = NULL, + excluded_by = NULL, + excluded_at = NULL, + added_by = EXCLUDED.added_by, + updated_at = CURRENT_TIMESTAMP + `, [visit.id, assetId, principal.userId]); + await manager.query(` + INSERT INTO inspection_visit_asset_events ( + visit_id, asset_id, event_type, actor_user_id, metadata + ) VALUES ($1, $2, 'VERIFICATION_INCLUDED', $3, $4::jsonb) + `, [visit.id, assetId, principal.userId, JSON.stringify({ findingIds })]); + } + for (const row of rows) { + await manager.query(` + INSERT INTO inspection_finding_verification_visits ( + finding_id, visit_id, linked_by, target_control_on + ) VALUES ($1, $2, $3, $4) + ON CONFLICT (finding_id, visit_id) DO NOTHING + `, [row.id, visit.id, principal.userId, row.nextControlOn]); + await appendVerificationEvent(manager, { + findingId: row.id, + verificationVisitId: visit.id, + eventType: InspectionFindingVerificationEventType.VISIT_PLANNED, + occurredAt: new Date(), + targetControlOn: row.nextControlOn, + previousControlOn: row.nextControlOn, + nextControlOn: row.nextControlOn, + notes: `Visita ${visit.code} planificada para verificar ${row.code}`, + }, principal); + } + + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.INSPECTION_VERIFICATION_PLANNED, + entityType: 'inspection_visit', + entityId: visit.id, + afterData: { + code: visit.code, + status: visit.status, + plannedStartAt: visit.plannedStartAt, + plannedEndAt: visit.plannedEndAt, + companyId, + areaId, + findingIds, + assetIds, + }, + }, manager); + + return { + visit: { + id: visit.id, + code: visit.code, + title: visit.title, + status: visit.status, + plannedStartAt: visit.plannedStartAt, + plannedEndAt: visit.plannedEndAt, + }, + company: { id: companyId, code: rows[0]?.companyCode ?? '', name: companyName }, + area: { id: areaId, code: rows[0]?.areaCode ?? '', name: areaName }, + findingCount: findingIds.length, + assetCount: assetIds.length, + }; + }); + } + + private async assertActorAssigned( + manager: EntityManager, + visitId: string, + principal: AuthPrincipal, + ): Promise { + const rows = await manager.query(` + SELECT 1 + FROM inspection_visit_members + WHERE visit_id = $1 AND user_id = $2 AND included = true + `, [visitId, principal.userId]) as unknown[]; + if (rows.length === 0) { + throw new ConflictException({ + code: 'VERIFICATION_INSPECTOR_NOT_ASSIGNED', + message: 'La visita de verificación no está asignada al inspector actual', + }); + } + } + + private async captureFindingVerificationVersion( + manager: EntityManager, + findingId: string, + verificationVisitId: string, + outcome: InspectionVerificationOutcome, + notes: string, + verifiedAt: Date, + rescheduledControlOn: string | null, + principal: AuthPrincipal, + ): Promise { + const [updated] = await manager.query(` + UPDATE inspection_findings + SET current_version = current_version + 1 + WHERE id = $1 + RETURNING current_version AS "versionNumber" + `, [findingId]) as Array<{ versionNumber: number }>; + const versionNumber = Number(updated?.versionNumber ?? 0); + const [snapshot] = await manager.query(` + SELECT JSONB_BUILD_OBJECT( + 'id', finding.id, + 'code', finding.code, + 'findingNumber', finding.finding_number, + 'status', finding.status, + 'title', finding.title, + 'description', finding.description, + 'correctionDueOn', finding.correction_due_on, + 'companyResponse', finding.company_response, + 'companyResponseReceivedOn', finding.company_response_received_on, + 'companyCommittedCorrectionOn', finding.company_committed_correction_on, + 'nextControlOn', finding.next_control_on, + 'currentVersion', finding.current_version, + 'verification', JSONB_BUILD_OBJECT( + 'visitId', verification_visit.id, + 'visitCode', verification_visit.code, + 'outcome', $3::text, + 'notes', $4::text, + 'verifiedAt', $5::timestamptz, + 'rescheduledControlOn', $6::date + ), + 'act', JSONB_BUILD_OBJECT('id', act.id, 'code', act.code, 'status', act.status), + 'sourceVisit', JSONB_BUILD_OBJECT('id', source_visit.id, 'code', source_visit.code, 'status', source_visit.status), + 'asset', JSONB_BUILD_OBJECT('id', asset.id, 'code', asset.code, 'name', asset.name) + ) AS snapshot + FROM inspection_findings finding + INNER JOIN inspection_acts act ON act.id = finding.act_id + INNER JOIN inspection_visits source_visit ON source_visit.id = act.visit_id + INNER JOIN assets asset ON asset.id = finding.asset_id + INNER JOIN inspection_visits verification_visit ON verification_visit.id = $2 + WHERE finding.id = $1 + `, [findingId, verificationVisitId, outcome, notes, verifiedAt, rescheduledControlOn]) as Array<{ + snapshot: Record; + }>; + await manager.query(` + INSERT INTO inspection_finding_versions ( + finding_id, version_number, event, snapshot, actor_user_id, actor_username + ) VALUES ($1, $2, $3, $4, $5, $6) + `, [ + findingId, + versionNumber, + InspectionFindingVersionEvent.VERIFICATION_RECORDED, + snapshot?.snapshot ?? {}, + principal.userId, + principal.username, + ]); + return versionNumber; + } + +} diff --git a/api-v3/src/inspection-verifications/verification-event-ledger.ts b/api-v3/src/inspection-verifications/verification-event-ledger.ts new file mode 100644 index 0000000..0b75f6f --- /dev/null +++ b/api-v3/src/inspection-verifications/verification-event-ledger.ts @@ -0,0 +1,52 @@ +import type { EntityManager } from 'typeorm'; +import { + InspectionFindingVerificationEventType, + InspectionVerificationOutcome, +} from '../database/entities'; +import type { AuthPrincipal } from '../common/http/request-context'; + +export interface AppendVerificationEventInput { + findingId: string; + verificationVisitId?: string | null; + eventType: InspectionFindingVerificationEventType; + occurredAt?: Date; + targetControlOn?: string | null; + previousControlOn?: string | null; + nextControlOn?: string | null; + outcome?: InspectionVerificationOutcome | null; + notes?: string | null; +} + +export async function appendVerificationEvent( + manager: EntityManager, + input: AppendVerificationEventInput, + principal: AuthPrincipal, +): Promise { + await manager.query(` + INSERT INTO inspection_finding_verification_events ( + finding_id, + verification_visit_id, + event_type, + occurred_at, + target_control_on, + previous_control_on, + next_control_on, + outcome, + notes, + actor_user_id, + actor_username + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + `, [ + input.findingId, + input.verificationVisitId ?? null, + input.eventType, + input.occurredAt ?? new Date(), + input.targetControlOn ?? null, + input.previousControlOn ?? null, + input.nextControlOn ?? null, + input.outcome ?? null, + input.notes?.trim() || null, + principal.userId, + principal.username, + ]); +} diff --git a/api-v3/src/inspection-visits/dto/change-inspection-visit-status.dto.ts b/api-v3/src/inspection-visits/dto/change-inspection-visit-status.dto.ts new file mode 100644 index 0000000..d65b8f2 --- /dev/null +++ b/api-v3/src/inspection-visits/dto/change-inspection-visit-status.dto.ts @@ -0,0 +1,16 @@ +import { Transform } from 'class-transformer'; +import { IsEnum, IsOptional, IsString, MaxLength } from 'class-validator'; +import { InspectionVisitStatus } from '../../database/entities'; + +export class ChangeInspectionVisitStatusDto { + @IsEnum(InspectionVisitStatus) + status!: InspectionVisitStatus; + + @IsOptional() + @Transform(({ value }) => + typeof value === 'string' && value.trim() ? value.trim() : null, + ) + @IsString() + @MaxLength(4000) + reason?: string | null; +} diff --git a/api-v3/src/inspection-visits/dto/create-inspection-visit.dto.ts b/api-v3/src/inspection-visits/dto/create-inspection-visit.dto.ts new file mode 100644 index 0000000..8965a87 --- /dev/null +++ b/api-v3/src/inspection-visits/dto/create-inspection-visit.dto.ts @@ -0,0 +1,15 @@ +import { IsISO8601, IsUUID } from 'class-validator'; + +export class CreateInspectionVisitDto { + @IsUUID('4') + operationalAreaId!: string; + + @IsUUID('4') + operatorCompanyId!: string; + + @IsISO8601({ strict: true }) + plannedStartAt!: string; + + @IsUUID('4') + leadInspectorUserId!: string; +} diff --git a/api-v3/src/inspection-visits/dto/exclude-inspection-visit-asset.dto.ts b/api-v3/src/inspection-visits/dto/exclude-inspection-visit-asset.dto.ts new file mode 100644 index 0000000..0feb4b3 --- /dev/null +++ b/api-v3/src/inspection-visits/dto/exclude-inspection-visit-asset.dto.ts @@ -0,0 +1,10 @@ +import { Transform } from 'class-transformer'; +import { IsString, MaxLength, MinLength } from 'class-validator'; + +export class ExcludeInspectionVisitAssetDto { + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) + @IsString() + @MinLength(10) + @MaxLength(2000) + reason!: string; +} diff --git a/api-v3/src/inspection-visits/dto/list-inspection-visits-query.dto.ts b/api-v3/src/inspection-visits/dto/list-inspection-visits-query.dto.ts new file mode 100644 index 0000000..7d8f0e3 --- /dev/null +++ b/api-v3/src/inspection-visits/dto/list-inspection-visits-query.dto.ts @@ -0,0 +1,48 @@ +import { Type } from 'class-transformer'; +import { IsEnum, IsInt, IsOptional, IsString, IsUUID, Matches, Max, MaxLength, Min } from 'class-validator'; +import { InspectionVisitStatus } from '../../database/entities'; + +export class ListInspectionVisitsQueryDto { + @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(InspectionVisitStatus) + status?: InspectionVisitStatus; + + @IsOptional() + @IsUUID('4') + companyId?: string; + + @IsOptional() + @IsUUID('4') + areaId?: string; + + @IsOptional() + @IsUUID('4') + inspectorId?: string; + + @IsOptional() + @Matches(/^\d{4}-\d{2}-\d{2}$/) + dateFrom?: string; + + @IsOptional() + @Matches(/^\d{4}-\d{2}-\d{2}$/) + dateTo?: string; + +} diff --git a/api-v3/src/inspection-visits/dto/replace-inspection-visit-assets.dto.ts b/api-v3/src/inspection-visits/dto/replace-inspection-visit-assets.dto.ts new file mode 100644 index 0000000..3d5450d --- /dev/null +++ b/api-v3/src/inspection-visits/dto/replace-inspection-visit-assets.dto.ts @@ -0,0 +1,9 @@ +import { ArrayMaxSize, ArrayUnique, IsArray, IsUUID } from 'class-validator'; + +export class ReplaceInspectionVisitAssetsDto { + @IsArray() + @ArrayMaxSize(500) + @ArrayUnique() + @IsUUID('4', { each: true }) + assetIds!: string[]; +} diff --git a/api-v3/src/inspection-visits/dto/replace-inspection-visit-team.dto.ts b/api-v3/src/inspection-visits/dto/replace-inspection-visit-team.dto.ts new file mode 100644 index 0000000..04e59b7 --- /dev/null +++ b/api-v3/src/inspection-visits/dto/replace-inspection-visit-team.dto.ts @@ -0,0 +1,13 @@ +import { ArrayMaxSize, ArrayUnique, IsArray, IsOptional, IsUUID } from 'class-validator'; + +export class ReplaceInspectionVisitTeamDto { + @IsOptional() + @IsUUID('4') + leadInspectorUserId!: string | null; + + @IsArray() + @ArrayMaxSize(50) + @ArrayUnique() + @IsUUID('4', { each: true }) + memberUserIds!: string[]; +} diff --git a/api-v3/src/inspection-visits/dto/update-inspection-visit.dto.ts b/api-v3/src/inspection-visits/dto/update-inspection-visit.dto.ts new file mode 100644 index 0000000..f5b5685 --- /dev/null +++ b/api-v3/src/inspection-visits/dto/update-inspection-visit.dto.ts @@ -0,0 +1,42 @@ +import { Transform } from 'class-transformer'; +import { + IsISO8601, + IsOptional, + IsString, + IsUUID, + MaxLength, +} from 'class-validator'; + +export class UpdateInspectionVisitDto { + @IsOptional() + @Transform(({ value }) => + typeof value === 'string' && value.trim() ? value.trim() : null, + ) + @IsString() + @MaxLength(4000) + objective?: string | null; + + @IsOptional() + @IsUUID('4') + scopeAssetId?: string | null; + + @IsOptional() + @IsUUID('4') + operationalAreaId?: string | null; + + @IsOptional() + @IsUUID('4') + operatorCompanyId?: string | null; + + @IsOptional() + @IsISO8601({ strict: true }) + plannedStartAt?: string | null; + + @IsOptional() + @Transform(({ value }) => + typeof value === 'string' && value.trim() ? value.trim() : null, + ) + @IsString() + @MaxLength(4000) + instructions?: string | null; +} diff --git a/api-v3/src/inspection-visits/inspection-visit-code.ts b/api-v3/src/inspection-visits/inspection-visit-code.ts new file mode 100644 index 0000000..3b48b52 --- /dev/null +++ b/api-v3/src/inspection-visits/inspection-visit-code.ts @@ -0,0 +1,34 @@ +import type { EntityManager } from 'typeorm'; + +export async function nextInspectionVisitCode( + manager: EntityManager, + plannedStartAt: Date, +): Promise { + 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'); + } + + await manager.query( + `SELECT pg_advisory_xact_lock(hashtext($1))`, + [`inspection-visit-code:${year}`], + ); + + 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 }>; + + const sequence = Number(sequenceRow?.sequence ?? 0) + 1; + if (sequence > 999999) { + throw new Error(`Inspection visit sequence exhausted for ${year}`); + } + return `INS-${year}-${String(sequence).padStart(6, '0')}`; +} diff --git a/api-v3/src/inspection-visits/inspection-visits.controller.ts b/api-v3/src/inspection-visits/inspection-visits.controller.ts new file mode 100644 index 0000000..7347650 --- /dev/null +++ b/api-v3/src/inspection-visits/inspection-visits.controller.ts @@ -0,0 +1,157 @@ +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 { ChangeInspectionVisitStatusDto } from './dto/change-inspection-visit-status.dto'; +import { CreateInspectionVisitDto } from './dto/create-inspection-visit.dto'; +import { ExcludeInspectionVisitAssetDto } from './dto/exclude-inspection-visit-asset.dto'; +import { ListInspectionVisitsQueryDto } from './dto/list-inspection-visits-query.dto'; +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 { InspectionVisitsService } from './inspection-visits.service'; + +@Controller('inspection-visits') +export class InspectionVisitsController { + constructor(private readonly visits: InspectionVisitsService) {} + + @Get() + @RequirePermissions('inspections.read') + list(@Query() query: ListInspectionVisitsQueryDto) { + return this.visits.list(query); + } + + @Get('assignees') + @RequirePermissions('inspections.assign') + assignees() { + return this.visits.listAssignees(); + } + + @Get('planning-context/areas') + @RequirePermissions('inspections.read') + planningAreas() { + return this.visits.listPlanningAreas(); + } + + @Get('planning-context/areas/:areaId/operators') + @RequirePermissions('inspections.read') + planningOperators( + @Param('areaId', new ParseUUIDPipe({ version: '4' })) areaId: string, + ) { + return this.visits.listPlanningOperators(areaId); + } + + @Get(':id') + @RequirePermissions('inspections.read') + get(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) { + return this.visits.getById(id); + } + + @Post() + @RequirePermissions('inspections.manage', 'inspections.assign') + create( + @Body() dto: CreateInspectionVisitDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.visits.create(dto, principal, request); + } + + @Patch(':id') + @RequirePermissions('inspections.manage') + update( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @Body() dto: UpdateInspectionVisitDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.visits.update(id, dto, principal, request); + } + + @Put(':id/assets') + @RequirePermissions('inspections.manage') + replaceAssets( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @Body() dto: ReplaceInspectionVisitAssetsDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.visits.replaceAssets(id, dto, principal, request); + } + + @Post(':id/checklist/generate') + @RequirePermissions('inspections.manage') + generateChecklist( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.visits.generateChecklist(id, principal, request); + } + + @Post(':id/assets/:assetId/exclude') + @RequirePermissions('inspections.manage') + excludeAsset( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @Param('assetId', new ParseUUIDPipe({ version: '4' })) assetId: string, + @Body() dto: ExcludeInspectionVisitAssetDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.visits.excludeAsset(id, assetId, dto.reason, principal, request); + } + + @Post(':id/assets/:assetId/include') + @RequirePermissions('inspections.manage') + includeAsset( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @Param('assetId', new ParseUUIDPipe({ version: '4' })) assetId: string, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.visits.includeAsset(id, assetId, principal, request); + } + + @Put(':id/team') + @RequirePermissions('inspections.assign') + replaceTeam( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @Body() dto: ReplaceInspectionVisitTeamDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + 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( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.visits.start(id, principal, request); + } +} diff --git a/api-v3/src/inspection-visits/inspection-visits.module.ts b/api-v3/src/inspection-visits/inspection-visits.module.ts new file mode 100644 index 0000000..45927c0 --- /dev/null +++ b/api-v3/src/inspection-visits/inspection-visits.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { AuditModule } from '../audit/audit.module'; +import { InspectionVisitsController } from './inspection-visits.controller'; +import { InspectionVisitsService } from './inspection-visits.service'; + +@Module({ + imports: [AuditModule], + controllers: [InspectionVisitsController], + providers: [InspectionVisitsService], +}) +export class InspectionVisitsModule {} diff --git a/api-v3/src/inspection-visits/inspection-visits.service.ts b/api-v3/src/inspection-visits/inspection-visits.service.ts new file mode 100644 index 0000000..868ba1d --- /dev/null +++ b/api-v3/src/inspection-visits/inspection-visits.service.ts @@ -0,0 +1,1536 @@ +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, + InspectionVisit, + InspectionVisitAssetPlanningSource, + InspectionVisitStatus, +} from '../database/entities'; +import type { ChangeInspectionVisitStatusDto } from './dto/change-inspection-visit-status.dto'; +import type { CreateInspectionVisitDto } from './dto/create-inspection-visit.dto'; +import type { ListInspectionVisitsQueryDto } from './dto/list-inspection-visits-query.dto'; +import type { ReplaceInspectionVisitAssetsDto } from './dto/replace-inspection-visit-assets.dto'; +import type { ReplaceInspectionVisitTeamDto } from './dto/replace-inspection-visit-team.dto'; +import type { UpdateInspectionVisitDto } from './dto/update-inspection-visit.dto'; +import { assertMobileInspector } from '../inspection-operations/mobile-inspector-policy'; +import { nextInspectionVisitCode } from './inspection-visit-code'; + +export interface InspectionPerson { + id: string; + username: string; + firstName: string; + lastName: string; +} + +export interface InspectionAssetSummary { + id: string; + code: string; + name: string; + typeName: string; +} + +export interface InspectionPlanningContextAsset { + id: string; + code: string; + name: string; +} + +export interface InspectionPlannedAsset extends InspectionAssetSummary { + included: boolean; + planningSource: InspectionVisitAssetPlanningSource; + exclusionReason: string | null; + excludedAt: Date | null; + excludedBy: InspectionPerson | null; +} + +export type InspectionChecklistItemKind = + | 'ANTECEDENT' + | 'COMPANY_OVERDUE' + | 'VERIFICATION_OVERDUE' + | 'UPCOMING_CONTROL'; + +export interface InspectionVisitChecklistItem { + id: string; + findingId: string; + findingCode: string; + findingTitle: string; + findingStatus: string; + severity: number | null; + itemKind: InspectionChecklistItemKind; + referenceOn: string | null; + asset: InspectionAssetSummary; + assetIncluded: boolean; +} + +export interface InspectionVisitChecklistSummary { + generation: number; + generatedAt: Date | null; + stale: boolean; + antecedents: number; + companyOverdue: number; + verificationOverdue: number; + upcomingControls: number; + actionableAssets: number; + excludedAssets: number; + items: InspectionVisitChecklistItem[]; +} + +export interface InspectionVisitListItem { + id: string; + code: string; + title: string; + objective: string | null; + status: InspectionVisitStatus; + scopeAsset: InspectionAssetSummary | null; + operationalArea: InspectionPlanningContextAsset | null; + operatorCompany: InspectionPlanningContextAsset | null; + leadInspector: InspectionPerson | null; + plannedStartAt: Date | null; + plannedEndAt: Date | null; + actualStartedAt: Date | null; + actualClosedAt: Date | null; + instructions: string | null; + cancellationReason: string | null; + checklistGeneration: number; + checklistGeneratedAt: Date | null; + assetCount: number; + memberCount: number; + createdAt: Date; + updatedAt: Date; +} + +export interface InspectionVerificationFindingSummary { + id: string; + code: string; + title: string; + status: string; + nextControlOn: string | null; + assetId: string; + assetCode: string; + assetName: string; + targetControlOn: string | null; + outcome: 'RESOLVED' | 'NOT_RESOLVED' | 'REQUIRES_NEW_DATE' | null; + resultNotes: string | null; + verifiedAt: Date | null; + resultRecordedAt: Date | null; + rescheduledControlOn: string | null; + verificationEvidenceCount: number; +} + +export interface InspectionVisitView extends InspectionVisitListItem { + assets: InspectionAssetSummary[]; + planningAssets: InspectionPlannedAsset[]; + team: InspectionPerson[]; + verificationFindings: InspectionVerificationFindingSummary[]; + checklist: InspectionVisitChecklistSummary; +} + +function visitNotFound(): NotFoundException { + return new NotFoundException({ + code: 'INSPECTION_VISIT_NOT_FOUND', + message: 'Visita de inspección no encontrada', + }); +} + +function dateValue(value: string | null | undefined): Date | null { + return value ? new Date(value) : null; +} + +@Injectable() +export class InspectionVisitsService { + constructor( + private readonly dataSource: DataSource, + private readonly audit: AuditService, + ) {} + + async list(query: ListInspectionVisitsQueryDto) { + const conditions: string[] = []; + const parameters: unknown[] = []; + const add = (value: unknown): string => { + parameters.push(value); + return `$${parameters.length}`; + }; + if (query.search?.trim()) { + const search = add(`%${query.search.trim()}%`); + conditions.push(`(visit.code ILIKE ${search} OR visit.title ILIKE ${search})`); + } + if (query.status) conditions.push(`visit.status = ${add(query.status)}`); + if (query.companyId) { + const company = add(query.companyId); + conditions.push(`( + visit.operator_company_id = ${company}::uuid + OR visit.scope_asset_id = ${company}::uuid + OR EXISTS ( + SELECT 1 + FROM inspection_visit_assets company_link + INNER JOIN assets company_asset ON company_asset.id = company_link.asset_id + WHERE company_link.visit_id = visit.id + AND company_link.included = true + AND company_asset.operator_company_id = ${company}::uuid + ) + )`); + } + if (query.areaId) { + const area = add(query.areaId); + conditions.push(`( + visit.operational_area_id = ${area}::uuid + OR visit.scope_asset_id = ${area}::uuid + OR EXISTS ( + SELECT 1 + FROM inspection_visit_assets area_link + INNER JOIN assets area_asset ON area_asset.id = area_link.asset_id + WHERE area_link.visit_id = visit.id + AND area_link.included = true + AND area_asset.operational_area_id = ${area}::uuid + ) + )`); + } + if (query.inspectorId) { + const inspector = add(query.inspectorId); + conditions.push(`( + visit.lead_inspector_user_id = ${inspector}::uuid + OR EXISTS ( + SELECT 1 FROM inspection_visit_members member_filter + WHERE member_filter.visit_id = visit.id + AND member_filter.included = true + AND member_filter.user_id = ${inspector}::uuid + ) + )`); + } + if (query.dateFrom) { + conditions.push(`COALESCE(visit.actual_started_at, visit.planned_start_at, visit.created_at)::date >= ${add(query.dateFrom)}::date`); + } + if (query.dateTo) { + conditions.push(`COALESCE(visit.actual_started_at, visit.planned_start_at, visit.created_at)::date <= ${add(query.dateTo)}::date`); + } + const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''; + const [countRow] = (await this.dataSource.query( + `SELECT COUNT(*)::integer AS total FROM inspection_visits visit ${where}`, + parameters, + )) as Array<{ total: number }>; + const total = Number(countRow?.total ?? 0); + const limit = add(query.pageSize); + const offset = add((query.page - 1) * query.pageSize); + const data = (await this.dataSource.query( + `${this.visitSelect(where)} + ORDER BY + CASE visit.status + WHEN 'IN_PROGRESS' THEN 1 WHEN 'PLANNED' THEN 2 WHEN 'DRAFT' THEN 3 + WHEN 'CLOSED' THEN 4 ELSE 5 + END, + visit.planned_start_at NULLS LAST, + visit.updated_at DESC + LIMIT ${limit} OFFSET ${offset}`, + parameters, + )) as InspectionVisitListItem[]; + return { + data, + meta: { + page: query.page, + pageSize: query.pageSize, + total, + totalPages: total === 0 ? 0 : Math.ceil(total / query.pageSize), + }, + }; + } + + async listAssignees(): Promise<{ data: InspectionPerson[] }> { + 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 = 'inspections.execute' + ORDER BY user_account.last_name, user_account.first_name, user_account.username + `)) as InspectionPerson[]; + return { data }; + } + + async listPlanningAreas(): Promise<{ data: InspectionPlanningContextAsset[] }> { + const data = (await this.dataSource.query(` + SELECT DISTINCT area.id, area.code, area.name + FROM assets area + INNER JOIN asset_types area_type ON area_type.id = area.asset_type_id + INNER JOIN area_company_relations relation ON relation.area_id = area.id + WHERE area_type.operational_role = 'AREA' + AND area.information_status <> 'INACTIVE' + AND relation.relation_role = 'OPERATOR' + AND relation.valid_from <= CURRENT_TIMESTAMP + AND (relation.valid_until IS NULL OR relation.valid_until > CURRENT_TIMESTAMP) + ORDER BY area.name, area.code + `)) as InspectionPlanningContextAsset[]; + return { data }; + } + + async listPlanningOperators(areaId: string): Promise<{ data: InspectionPlanningContextAsset[] }> { + const data = (await this.dataSource.query(` + SELECT DISTINCT company.id, company.code, company.name + FROM area_company_relations relation + INNER JOIN assets company ON company.id = relation.company_id + INNER JOIN asset_types company_type ON company_type.id = company.asset_type_id + WHERE relation.area_id = $1 + AND relation.relation_role = 'OPERATOR' + AND relation.valid_from <= CURRENT_TIMESTAMP + AND (relation.valid_until IS NULL OR relation.valid_until > CURRENT_TIMESTAMP) + AND company_type.operational_role = 'COMPANY' + AND company.information_status <> 'INACTIVE' + ORDER BY company.name, company.code + `, [areaId])) as InspectionPlanningContextAsset[]; + return { data }; + } + + async getById(id: string): Promise { + return this.dataSource.transaction((manager) => this.loadView(manager, id)); + } + + async create( + dto: CreateInspectionVisitDto, + principal: AuthPrincipal, + request: RequestWithContext, + ): Promise { + try { + return await this.dataSource.transaction(async (manager) => { + const plannedStartAt = dateValue(dto.plannedStartAt); + if (!plannedStartAt || !Number.isFinite(plannedStartAt.getTime())) { + throw new BadRequestException({ + code: 'INSPECTION_START_DATE_REQUIRED', + message: 'Indicá una fecha y hora prevista de inicio válida', + }); + } + const operationalAreaId = dto.operationalAreaId; + const operatorCompanyId = dto.operatorCompanyId; + await this.validatePlanningContext(manager, operationalAreaId, operatorCompanyId); + await this.requireAsset(manager, operationalAreaId); + await this.requireInspectors(manager, [dto.leadInspectorUserId]); + const code = await nextInspectionVisitCode(manager, plannedStartAt); + const visit = manager.getRepository(InspectionVisit).create({ + code, + title: code, + objective: null, + status: InspectionVisitStatus.DRAFT, + scopeAssetId: operationalAreaId, + operationalAreaId, + operatorCompanyId, + leadInspectorUserId: dto.leadInspectorUserId, + plannedStartAt, + plannedEndAt: null, + actualStartedAt: null, + actualClosedAt: null, + instructions: null, + cancellationReason: null, + checklistGeneration: 0, + checklistGeneratedAt: null, + createdBy: principal.userId, + updatedBy: principal.userId, + }); + await manager.getRepository(InspectionVisit).save(visit); + await this.replaceMemberLinks( + manager, + visit.id, + [dto.leadInspectorUserId], + principal.userId, + ); + await this.generateChecklistInternal(manager, visit, principal.userId); + const created = await this.loadView(manager, visit.id); + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.INSPECTION_VISIT_CREATED, + entityType: 'inspection_visit', + entityId: visit.id, + afterData: this.auditView(created), + }, manager); + return created; + }); + } catch (error) { + if (isUniqueViolation(error)) throw this.codeConflict(); + throw error; + } + } + + async update( + id: string, + dto: UpdateInspectionVisitDto, + principal: AuthPrincipal, + request: RequestWithContext, + ): Promise { + 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 visit = await this.lockVisit(manager, id); + this.assertPlanningEditable(visit); + const before = await this.loadView(manager, id); + const nextStart = dto.plannedStartAt === undefined + ? visit.plannedStartAt + : dateValue(dto.plannedStartAt); + const nextAreaId = dto.operationalAreaId === undefined + ? visit.operationalAreaId + : dto.operationalAreaId; + const nextCompanyId = dto.operatorCompanyId === undefined + ? visit.operatorCompanyId + : dto.operatorCompanyId; + const contextTouched = dto.operationalAreaId !== undefined || dto.operatorCompanyId !== undefined; + const nextScope = contextTouched + ? nextAreaId + : dto.scopeAssetId === undefined + ? visit.scopeAssetId + : dto.scopeAssetId; + await this.validatePlanningContext(manager, nextAreaId, nextCompanyId); + await this.requireAsset(manager, nextScope); + if (contextTouched && nextAreaId && nextCompanyId) { + await this.assertCurrentAssetsMatchContext(manager, id, nextAreaId, nextCompanyId); + } else if (dto.scopeAssetId !== undefined) { + await this.assertCurrentAssetsInScope(manager, id, nextScope); + } + if (dto.objective !== undefined) visit.objective = dto.objective; + if (contextTouched) { + visit.operationalAreaId = nextAreaId; + visit.operatorCompanyId = nextCompanyId; + visit.scopeAssetId = nextAreaId; + } else if (dto.scopeAssetId !== undefined) { + visit.scopeAssetId = dto.scopeAssetId; + } + if (dto.plannedStartAt !== undefined) visit.plannedStartAt = nextStart; + if (dto.instructions !== undefined) visit.instructions = dto.instructions; + if (contextTouched || dto.plannedStartAt !== undefined) visit.checklistGeneratedAt = null; + visit.updatedBy = principal.userId; + await manager.getRepository(InspectionVisit).save(visit); + const updated = await this.loadView(manager, id); + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.INSPECTION_VISIT_UPDATED, + entityType: 'inspection_visit', + entityId: id, + beforeData: this.auditView(before), + afterData: this.auditView(updated), + }, manager); + return updated; + }); + } catch (error) { + if (isUniqueViolation(error)) throw this.codeConflict(); + throw error; + } + } + + async replaceAssets( + id: string, + dto: ReplaceInspectionVisitAssetsDto, + principal: AuthPrincipal, + request: RequestWithContext, + ): Promise { + return this.dataSource.transaction(async (manager) => { + const visit = await this.lockVisit(manager, id); + this.assertPlanningEditable(visit); + await this.requireAssets(manager, dto.assetIds); + const before = await this.activeAssetIds(manager, id); + const removed = before.filter((assetId) => !dto.assetIds.includes(assetId)); + if (removed.length > 0) { + throw new BadRequestException({ + code: 'INSPECTION_EXCLUSION_REASON_REQUIRED', + message: 'Para retirar un registro del checklist usá la exclusión con motivo auditable', + }); + } + const additions = dto.assetIds.filter((assetId) => !before.includes(assetId)); + if (additions.length > 0) { + const excludedExisting = (await manager.query(` + SELECT asset_id AS id + FROM inspection_visit_assets + WHERE visit_id = $1 + AND asset_id = ANY($2::uuid[]) + AND included = false + `, [id, additions])) as Array<{ id: string }>; + if (excludedExisting.length > 0) { + throw new BadRequestException({ + code: 'INSPECTION_REINCLUDE_REQUIRED', + message: 'Un registro excluido debe reincorporarse con la acción específica para conservar su trazabilidad', + }); + } + if (!visit.operationalAreaId || !visit.operatorCompanyId) { + throw new BadRequestException({ + code: 'INSPECTION_CONTEXT_REQUIRED', + message: 'Seleccioná Área y Operadora antes de agregar registros preventivos', + }); + } + await this.assertAssetsMatchContext( + manager, + additions, + visit.operationalAreaId, + visit.operatorCompanyId, + ); + for (const assetId of additions) { + await manager.query(` + INSERT INTO inspection_visit_assets ( + visit_id, asset_id, included, planning_source, exclusion_reason, + excluded_by, excluded_at, added_by + ) VALUES ($1, $2, true, 'PREVENTIVE', NULL, NULL, NULL, $3) + ON CONFLICT (visit_id, asset_id) DO UPDATE SET + included = true, + planning_source = CASE + WHEN inspection_visit_assets.planning_source = 'VERIFICATION' THEN 'VERIFICATION' + ELSE 'PREVENTIVE' + END, + exclusion_reason = NULL, + excluded_by = NULL, + excluded_at = NULL, + added_by = EXCLUDED.added_by, + updated_at = CURRENT_TIMESTAMP + `, [id, assetId, principal.userId]); + await this.appendAssetEvent( + manager, + id, + assetId, + 'PREVENTIVE_INCLUDED', + null, + principal.userId, + { source: 'web_planning' }, + ); + } + } + visit.updatedBy = principal.userId; + await manager.getRepository(InspectionVisit).save(visit); + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.INSPECTION_VISIT_ASSETS_UPDATED, + entityType: 'inspection_visit', + entityId: id, + beforeData: { assetIds: before }, + afterData: { assetIds: await this.activeAssetIds(manager, id), preventiveAdded: additions }, + }, manager); + return this.loadView(manager, id); + }); + } + + async generateChecklist( + id: string, + principal: AuthPrincipal, + request: RequestWithContext, + ): Promise { + return this.dataSource.transaction(async (manager) => { + const visit = await this.lockVisit(manager, id); + this.assertPlanningEditable(visit); + const beforeGeneration = visit.checklistGeneration; + await this.generateChecklistInternal(manager, visit, principal.userId); + const updated = await this.loadView(manager, id); + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.INSPECTION_VISIT_CHECKLIST_GENERATED, + entityType: 'inspection_visit', + entityId: id, + beforeData: { checklistGeneration: beforeGeneration }, + afterData: { + checklistGeneration: updated.checklist.generation, + checklistGeneratedAt: updated.checklist.generatedAt, + summary: { + antecedents: updated.checklist.antecedents, + companyOverdue: updated.checklist.companyOverdue, + verificationOverdue: updated.checklist.verificationOverdue, + upcomingControls: updated.checklist.upcomingControls, + actionableAssets: updated.checklist.actionableAssets, + }, + }, + }, manager); + return updated; + }); + } + + async excludeAsset( + id: string, + assetId: string, + reason: string, + principal: AuthPrincipal, + request: RequestWithContext, + ): Promise { + return this.dataSource.transaction(async (manager) => { + const visit = await this.lockVisit(manager, id); + this.assertPlanningEditable(visit); + const [link] = (await manager.query(` + SELECT included, planning_source AS "planningSource" + FROM inspection_visit_assets + WHERE visit_id = $1 AND asset_id = $2 + FOR UPDATE + `, [id, assetId])) as Array<{ included: boolean; planningSource: InspectionVisitAssetPlanningSource }>; + if (!link) { + throw new NotFoundException({ + code: 'INSPECTION_VISIT_ASSET_NOT_FOUND', + message: 'El registro no forma parte del checklist de esta visita', + }); + } + if (!link.included) return this.loadView(manager, id); + await manager.query(` + UPDATE inspection_visit_assets + SET included = false, + exclusion_reason = $3, + excluded_by = $4, + excluded_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP + WHERE visit_id = $1 AND asset_id = $2 + `, [id, assetId, reason.trim(), principal.userId]); + await this.appendAssetEvent( + manager, + id, + assetId, + 'EXCLUDED', + reason.trim(), + principal.userId, + { planningSource: link.planningSource }, + ); + visit.updatedBy = principal.userId; + await manager.getRepository(InspectionVisit).save(visit); + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.INSPECTION_VISIT_ASSET_EXCLUDED, + entityType: 'inspection_visit', + entityId: id, + beforeData: { assetId, included: true }, + afterData: { assetId, included: false, reason: reason.trim() }, + }, manager); + return this.loadView(manager, id); + }); + } + + async includeAsset( + id: string, + assetId: string, + principal: AuthPrincipal, + request: RequestWithContext, + ): Promise { + return this.dataSource.transaction(async (manager) => { + const visit = await this.lockVisit(manager, id); + this.assertPlanningEditable(visit); + const [link] = (await manager.query(` + SELECT included, planning_source AS "planningSource", exclusion_reason AS "exclusionReason" + FROM inspection_visit_assets + WHERE visit_id = $1 AND asset_id = $2 + FOR UPDATE + `, [id, assetId])) as Array<{ + included: boolean; + planningSource: InspectionVisitAssetPlanningSource; + exclusionReason: string | null; + }>; + if (!link) { + throw new NotFoundException({ + code: 'INSPECTION_VISIT_ASSET_NOT_FOUND', + message: 'El registro no forma parte del checklist de esta visita', + }); + } + if (link.included) return this.loadView(manager, id); + if (!visit.operationalAreaId || !visit.operatorCompanyId) { + throw new BadRequestException({ code: 'INSPECTION_CONTEXT_REQUIRED', message: 'La visita no tiene Área y Operadora definidas' }); + } + await this.assertAssetsMatchContext( + manager, + [assetId], + visit.operationalAreaId, + visit.operatorCompanyId, + ); + await manager.query(` + UPDATE inspection_visit_assets + SET included = true, + exclusion_reason = NULL, + excluded_by = NULL, + excluded_at = NULL, + updated_at = CURRENT_TIMESTAMP + WHERE visit_id = $1 AND asset_id = $2 + `, [id, assetId]); + await this.appendAssetEvent( + manager, + id, + assetId, + 'REINCLUDED', + link.exclusionReason, + principal.userId, + { planningSource: link.planningSource }, + ); + visit.updatedBy = principal.userId; + await manager.getRepository(InspectionVisit).save(visit); + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.INSPECTION_VISIT_ASSET_REINCLUDED, + entityType: 'inspection_visit', + entityId: id, + beforeData: { assetId, included: false, reason: link.exclusionReason }, + afterData: { assetId, included: true }, + }, manager); + return this.loadView(manager, id); + }); + } + + async replaceTeam( + id: string, + dto: ReplaceInspectionVisitTeamDto, + principal: AuthPrincipal, + request: RequestWithContext, + ): Promise { + return this.dataSource.transaction(async (manager) => { + const visit = await this.lockVisit(manager, id); + this.assertPlanningEditable(visit); + const leadInspectorUserId = dto.leadInspectorUserId ?? null; + await this.requireInspectors(manager, dto.memberUserIds); + if (leadInspectorUserId && !dto.memberUserIds.includes(leadInspectorUserId)) { + throw new BadRequestException({ + code: 'INSPECTION_LEAD_NOT_IN_TEAM', + message: 'El inspector responsable debe formar parte del equipo', + }); + } + const before = { + leadInspectorUserId: visit.leadInspectorUserId, + memberUserIds: await this.activeMemberIds(manager, id), + }; + visit.leadInspectorUserId = leadInspectorUserId; + visit.updatedBy = principal.userId; + await manager.getRepository(InspectionVisit).save(visit); + await this.replaceMemberLinks(manager, id, dto.memberUserIds, principal.userId); + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.INSPECTION_VISIT_TEAM_UPDATED, + entityType: 'inspection_visit', + entityId: id, + beforeData: before, + afterData: { + leadInspectorUserId, + memberUserIds: dto.memberUserIds, + }, + }, manager); + return this.loadView(manager, id); + }); + } + + async changeStatus( + id: string, + dto: ChangeInspectionVisitStatusDto, + principal: AuthPrincipal, + request: RequestWithContext, + ): Promise { + return this.dataSource.transaction(async (manager) => { + const visit = await this.lockVisit(manager, id); + if (visit.status === dto.status) return this.loadView(manager, id); + const cancellation = dto.status === InspectionVisitStatus.CANCELLED; + const reversiblePlanning = + (visit.status === InspectionVisitStatus.DRAFT && dto.status === InspectionVisitStatus.PLANNED) || + (visit.status === InspectionVisitStatus.PLANNED && dto.status === InspectionVisitStatus.DRAFT); + const cancellable = cancellation && [ + InspectionVisitStatus.DRAFT, + InspectionVisitStatus.PLANNED, + InspectionVisitStatus.IN_PROGRESS, + ].includes(visit.status); + if (!reversiblePlanning && !cancellable) { + throw new ConflictException({ + code: 'INSPECTION_VISIT_INVALID_TRANSITION', + message: 'La transición solicitada no está permitida en esta fase', + }); + } + if (dto.status === InspectionVisitStatus.PLANNED) { + await this.validatePlan(manager, visit); + } + const reason = dto.reason?.trim() || null; + if (cancellation && (!reason || reason.length < 10)) { + throw new BadRequestException({ + code: 'INSPECTION_CANCELLATION_REASON_REQUIRED', + message: 'Indicá un motivo de cancelación de al menos 10 caracteres', + }); + } + const beforeStatus = visit.status; + visit.status = dto.status; + visit.cancellationReason = cancellation ? reason : null; + visit.updatedBy = principal.userId; + await manager.getRepository(InspectionVisit).save(visit); + const updated = await this.loadView(manager, id); + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.INSPECTION_VISIT_STATUS_CHANGED, + entityType: 'inspection_visit', + entityId: id, + beforeData: { status: beforeStatus }, + afterData: { status: dto.status, cancellationReason: visit.cancellationReason }, + }, manager); + return updated; + }); + } + + async start( + id: string, + principal: AuthPrincipal, + request: RequestWithContext, + ): Promise { + assertMobileInspector(principal); + return this.dataSource.transaction(async (manager) => { + const visit = await this.lockVisit(manager, id); + if (visit.status !== InspectionVisitStatus.PLANNED) { + throw new ConflictException({ + code: 'INSPECTION_VISIT_NOT_PLANNED', + message: 'La visita debe estar planificada antes de iniciarse', + }); + } + await this.validatePlan(manager, visit); + if (!principal.permissions.includes('inspections.manage')) { + const memberIds = await this.activeMemberIds(manager, id); + if (!memberIds.includes(principal.userId)) { + throw new ForbiddenException({ + code: 'INSPECTION_VISIT_NOT_ASSIGNED', + message: 'La visita no está asignada al usuario actual', + }); + } + } + visit.status = InspectionVisitStatus.IN_PROGRESS; + visit.actualStartedAt = new Date(); + visit.updatedBy = principal.userId; + await manager.getRepository(InspectionVisit).save(visit); + const updated = await this.loadView(manager, id); + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.INSPECTION_VISIT_STARTED, + entityType: 'inspection_visit', + entityId: id, + beforeData: { status: InspectionVisitStatus.PLANNED, actualStartedAt: null }, + afterData: { + status: InspectionVisitStatus.IN_PROGRESS, + actualStartedAt: visit.actualStartedAt, + }, + }, manager); + return updated; + }); + } + + private visitSelect(where: string): string { + return ` + SELECT + visit.id, + visit.code, + visit.title, + visit.objective, + visit.status, + CASE WHEN scope.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( + 'id', scope.id, + 'code', scope.code, + 'name', scope.name, + 'typeName', scope_type.name + ) END AS "scopeAsset", + CASE WHEN area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( + 'id', area.id, + 'code', area.code, + 'name', area.name + ) END AS "operationalArea", + CASE WHEN company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( + 'id', company.id, + 'code', company.code, + 'name', company.name + ) END AS "operatorCompany", + CASE WHEN lead.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( + 'id', lead.id, + 'username', lead.username, + 'firstName', lead.first_name, + 'lastName', lead.last_name + ) END AS "leadInspector", + visit.planned_start_at AS "plannedStartAt", + visit.planned_end_at AS "plannedEndAt", + visit.actual_started_at AS "actualStartedAt", + visit.actual_closed_at AS "actualClosedAt", + visit.instructions, + visit.cancellation_reason AS "cancellationReason", + visit.checklist_generation AS "checklistGeneration", + visit.checklist_generated_at AS "checklistGeneratedAt", + COALESCE(link_counts.assets, 0)::integer AS "assetCount", + COALESCE(link_counts.members, 0)::integer AS "memberCount", + visit.created_at AS "createdAt", + visit.updated_at AS "updatedAt" + FROM inspection_visits visit + LEFT JOIN assets scope ON scope.id = visit.scope_asset_id + LEFT JOIN asset_types scope_type ON scope_type.id = scope.asset_type_id + LEFT JOIN assets area ON area.id = visit.operational_area_id + LEFT JOIN assets company ON company.id = visit.operator_company_id + LEFT JOIN users lead ON lead.id = visit.lead_inspector_user_id + LEFT JOIN LATERAL ( + SELECT + (SELECT COUNT(*) FROM inspection_visit_assets asset_link + WHERE asset_link.visit_id = visit.id AND asset_link.included = true) AS assets, + (SELECT COUNT(*) FROM inspection_visit_members member_link + WHERE member_link.visit_id = visit.id AND member_link.included = true) AS members + ) link_counts ON true + ${where} + `; + } + + private async loadView(manager: EntityManager, id: string): Promise { + const [visit] = (await manager.query( + this.visitSelect('WHERE visit.id = $1'), + [id], + )) as InspectionVisitListItem[]; + if (!visit) throw visitNotFound(); + const assets = (await manager.query(` + SELECT asset.id, asset.code, asset.name, asset_type.name AS "typeName" + FROM inspection_visit_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 + WHERE link.visit_id = $1 AND link.included = true + ORDER BY asset.code, asset.name + `, [id])) as InspectionAssetSummary[]; + const planningAssets = (await manager.query(` + SELECT + asset.id, + asset.code, + asset.name, + asset_type.name AS "typeName", + link.included, + link.planning_source AS "planningSource", + link.exclusion_reason AS "exclusionReason", + link.excluded_at AS "excludedAt", + CASE WHEN excluded_user.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( + 'id', excluded_user.id, + 'username', excluded_user.username, + 'firstName', excluded_user.first_name, + 'lastName', excluded_user.last_name + ) END AS "excludedBy" + FROM inspection_visit_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 users excluded_user ON excluded_user.id = link.excluded_by + WHERE link.visit_id = $1 + ORDER BY link.included DESC, asset.code, asset.name + `, [id])) as InspectionPlannedAsset[]; + const team = (await manager.query(` + SELECT + user_account.id, + user_account.username, + user_account.first_name AS "firstName", + user_account.last_name AS "lastName" + FROM inspection_visit_members link + INNER JOIN users user_account ON user_account.id = link.user_id + WHERE link.visit_id = $1 AND link.included = true + ORDER BY + CASE WHEN user_account.id = $2 THEN 0 ELSE 1 END, + user_account.last_name, + user_account.first_name + `, [id, visit.leadInspector?.id ?? null])) as InspectionPerson[]; + const verificationFindings = (await manager.query(` + SELECT + finding.id, + finding.code, + finding.title, + finding.status, + finding.next_control_on AS "nextControlOn", + 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", + (SELECT COUNT(*)::integer + FROM inspection_finding_evidence evidence + WHERE evidence.finding_id = finding.id + AND evidence.verification_visit_id = verification_link.visit_id + AND evidence.purpose = 'VERIFICATION') AS "verificationEvidenceCount" + 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.next_control_on NULLS LAST, finding.code + `, [id])) as InspectionVerificationFindingSummary[]; + const checklistItems = visit.checklistGeneration > 0 + ? (await manager.query(` + SELECT + item.id, + item.finding_id AS "findingId", + item.finding_code AS "findingCode", + item.finding_title AS "findingTitle", + item.finding_status AS "findingStatus", + item.severity, + item.item_kind AS "itemKind", + item.reference_on AS "referenceOn", + JSONB_BUILD_OBJECT( + 'id', asset.id, + 'code', asset.code, + 'name', asset.name, + 'typeName', asset_type.name + ) AS asset, + COALESCE(link.included, false) AS "assetIncluded" + FROM inspection_visit_checklist_items item + INNER JOIN assets asset ON asset.id = item.asset_id + INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id + LEFT JOIN inspection_visit_assets link + ON link.visit_id = item.visit_id AND link.asset_id = item.asset_id + WHERE item.visit_id = $1 AND item.generation_number = $2 + ORDER BY + CASE item.item_kind + WHEN 'VERIFICATION_OVERDUE' THEN 1 + WHEN 'COMPANY_OVERDUE' THEN 2 + WHEN 'UPCOMING_CONTROL' THEN 3 + ELSE 4 + END, + item.reference_on NULLS LAST, + item.finding_code + `, [id, visit.checklistGeneration])) as InspectionVisitChecklistItem[] + : []; + const actionableAssets = new Set( + checklistItems + .filter((item) => item.itemKind !== 'ANTECEDENT') + .map((item) => item.asset.id), + ).size; + const checklist: InspectionVisitChecklistSummary = { + generation: visit.checklistGeneration, + generatedAt: visit.checklistGeneratedAt, + stale: visit.checklistGeneration > 0 && !visit.checklistGeneratedAt, + antecedents: checklistItems.filter((item) => item.itemKind === 'ANTECEDENT').length, + companyOverdue: checklistItems.filter((item) => item.itemKind === 'COMPANY_OVERDUE').length, + verificationOverdue: checklistItems.filter((item) => item.itemKind === 'VERIFICATION_OVERDUE').length, + upcomingControls: checklistItems.filter((item) => item.itemKind === 'UPCOMING_CONTROL').length, + actionableAssets, + excludedAssets: planningAssets.filter((asset) => !asset.included).length, + items: checklistItems, + }; + return { ...visit, assets, planningAssets, team, verificationFindings, checklist }; + } + + private async lockVisit(manager: EntityManager, id: string): Promise { + const visit = await manager.getRepository(InspectionVisit) + .createQueryBuilder('visit') + .where('visit.id = :id', { id }) + .setLock('pessimistic_write') + .getOne(); + if (!visit) throw visitNotFound(); + return visit; + } + + private assertPlanningEditable(visit: InspectionVisit): void { + if (![InspectionVisitStatus.DRAFT, InspectionVisitStatus.PLANNED].includes(visit.status)) { + throw new ConflictException({ + code: 'INSPECTION_VISIT_PLANNING_LOCKED', + message: 'Una visita iniciada, cerrada o cancelada ya no puede replanificarse', + }); + } + } + + private async validatePlanningContext( + manager: EntityManager, + operationalAreaId: string | null, + operatorCompanyId: string | null, + ): Promise { + if (!operationalAreaId && !operatorCompanyId) return; + if (!operationalAreaId || !operatorCompanyId) { + throw new BadRequestException({ + code: 'INSPECTION_CONTEXT_PAIR_REQUIRED', + message: 'Área y Operadora deben seleccionarse juntas', + }); + } + const [context] = (await manager.query(` + SELECT + area.id AS "areaId", + company.id AS "companyId", + EXISTS ( + SELECT 1 + FROM area_company_relations relation + WHERE relation.area_id = area.id + AND relation.company_id = company.id + AND relation.relation_role = 'OPERATOR' + AND relation.valid_from <= CURRENT_TIMESTAMP + AND (relation.valid_until IS NULL OR relation.valid_until > CURRENT_TIMESTAMP) + ) AS "activeOperatorRelation" + FROM assets area + INNER JOIN asset_types area_type ON area_type.id = area.asset_type_id + CROSS JOIN assets company + INNER JOIN asset_types company_type ON company_type.id = company.asset_type_id + WHERE area.id = $1 + AND company.id = $2 + AND area_type.operational_role = 'AREA' + AND company_type.operational_role = 'COMPANY' + AND area.information_status <> 'INACTIVE' + AND company.information_status <> 'INACTIVE' + `, [operationalAreaId, operatorCompanyId])) as Array<{ + areaId: string; + companyId: string; + activeOperatorRelation: boolean; + }>; + if (!context) { + throw new BadRequestException({ + code: 'INSPECTION_CONTEXT_INVALID', + message: 'El Área o la Organización operadora no son válidas para planificación', + }); + } + if (!context.activeOperatorRelation) { + throw new BadRequestException({ + code: 'INSPECTION_OPERATOR_NOT_ACTIVE_IN_AREA', + message: 'La Organización seleccionada no tiene relación OPERATOR vigente con el Área', + }); + } + } + + private async assertAssetsMatchContext( + manager: EntityManager, + assetIds: string[], + operationalAreaId: string, + operatorCompanyId: string, + ): Promise { + if (assetIds.length === 0) return; + const [row] = (await manager.query(` + SELECT COUNT(*)::integer AS outside + FROM assets asset + WHERE asset.id = ANY($1::uuid[]) + AND ( + asset.operational_area_id IS DISTINCT FROM $2::uuid + OR asset.operator_company_id IS DISTINCT FROM $3::uuid + ) + `, [assetIds, operationalAreaId, operatorCompanyId])) as Array<{ outside: number }>; + if (Number(row?.outside ?? 0) > 0) { + throw new BadRequestException({ + code: 'INSPECTION_ASSET_OUTSIDE_OPERATIONAL_CONTEXT', + message: 'Uno o más registros no pertenecen al Área y Operadora de la visita', + }); + } + } + + private async assertCurrentAssetsMatchContext( + manager: EntityManager, + visitId: string, + operationalAreaId: string, + operatorCompanyId: string, + ): Promise { + await this.assertAssetsMatchContext( + manager, + await this.activeAssetIds(manager, visitId), + operationalAreaId, + operatorCompanyId, + ); + } + + private async appendAssetEvent( + manager: EntityManager, + visitId: string, + assetId: string, + eventType: 'LEGACY_INCLUDED' | 'AUTO_INCLUDED' | 'PREVENTIVE_INCLUDED' | 'VERIFICATION_INCLUDED' | 'EXCLUDED' | 'REINCLUDED', + reason: string | null, + actorUserId: string | null, + metadata: Record | null, + ): Promise { + await manager.query(` + INSERT INTO inspection_visit_asset_events ( + visit_id, asset_id, event_type, reason, actor_user_id, metadata + ) VALUES ($1, $2, $3, $4, $5, $6::jsonb) + `, [visitId, assetId, eventType, reason, actorUserId, JSON.stringify(metadata ?? {})]); + } + + private async generateChecklistInternal( + manager: EntityManager, + visit: InspectionVisit, + actorUserId: string, + ): Promise { + if (!visit.operationalAreaId || !visit.operatorCompanyId) { + throw new BadRequestException({ + code: 'INSPECTION_CONTEXT_REQUIRED', + message: 'Seleccioná primero el Área y la Organización operadora', + }); + } + if (!visit.plannedStartAt) { + throw new BadRequestException({ + code: 'INSPECTION_START_DATE_REQUIRED', + message: 'Indicá la fecha prevista antes de generar el checklist', + }); + } + await this.validatePlanningContext(manager, visit.operationalAreaId, visit.operatorCompanyId); + const generation = visit.checklistGeneration + 1; + await manager.query(` + INSERT INTO inspection_visit_checklist_items ( + visit_id, generation_number, finding_id, asset_id, item_kind, + reference_on, finding_status, finding_code, finding_title, severity + ) + SELECT + $1, + $2, + finding.id, + finding.asset_id, + CASE + WHEN finding.status = 'OPEN' + AND finding.company_response_received_on IS NULL + AND finding.correction_due_on IS NOT NULL + AND finding.correction_due_on < $5::date + THEN 'COMPANY_OVERDUE' + WHEN finding.status = 'OPEN' + AND finding.company_response_received_on IS NOT NULL + AND finding.next_control_on IS NOT NULL + AND finding.next_control_on < $5::date + AND COALESCE(( + SELECT verification.outcome + FROM inspection_finding_verification_visits verification + WHERE verification.finding_id = finding.id + AND verification.outcome IS NOT NULL + ORDER BY verification.result_recorded_at DESC NULLS LAST, verification.created_at DESC, verification.id DESC + LIMIT 1 + ), '') <> 'RESOLVED' + THEN 'VERIFICATION_OVERDUE' + WHEN finding.status = 'OPEN' + AND finding.company_response_received_on IS NOT NULL + AND finding.next_control_on IS NOT NULL + AND finding.next_control_on BETWEEN $5::date AND ($5::date + 30) + AND COALESCE(( + SELECT verification.outcome + FROM inspection_finding_verification_visits verification + WHERE verification.finding_id = finding.id + AND verification.outcome IS NOT NULL + ORDER BY verification.result_recorded_at DESC NULLS LAST, verification.created_at DESC, verification.id DESC + LIMIT 1 + ), '') <> 'RESOLVED' + THEN 'UPCOMING_CONTROL' + ELSE 'ANTECEDENT' + END, + CASE + WHEN finding.status = 'OPEN' + AND finding.company_response_received_on IS NULL + AND finding.correction_due_on IS NOT NULL + AND finding.correction_due_on < $5::date + THEN finding.correction_due_on + WHEN finding.status = 'OPEN' + AND finding.company_response_received_on IS NOT NULL + AND finding.next_control_on IS NOT NULL + THEN finding.next_control_on + ELSE NULL + END, + finding.status, + finding.code, + finding.title, + finding.severity + FROM inspection_findings finding + INNER JOIN assets asset ON asset.id = finding.asset_id + WHERE asset.operational_area_id = $3 + AND asset.operator_company_id = $4 + AND finding.status <> 'VOIDED' + ORDER BY finding.created_at, finding.id + `, [ + visit.id, + generation, + visit.operationalAreaId, + visit.operatorCompanyId, + visit.plannedStartAt.toISOString().slice(0, 10), + ]); + + const candidates = (await manager.query(` + SELECT DISTINCT asset_id AS id + FROM inspection_visit_checklist_items + WHERE visit_id = $1 + AND generation_number = $2 + AND item_kind IN ('COMPANY_OVERDUE','VERIFICATION_OVERDUE','UPCOMING_CONTROL') + ORDER BY asset_id + `, [visit.id, generation])) as Array<{ id: string }>; + const candidateIds = candidates.map((row) => row.id); + + const obsolete = (await manager.query(` + SELECT asset_id AS id + FROM inspection_visit_assets + WHERE visit_id = $1 + AND planning_source = 'AUTOMATIC' + AND included = true + AND NOT (asset_id = ANY($2::uuid[])) + FOR UPDATE + `, [visit.id, candidateIds])) as Array<{ id: string }>; + const obsoleteReason = 'Ya no forma parte del checklist automático vigente'; + for (const row of obsolete) { + await manager.query(` + UPDATE inspection_visit_assets + SET included = false, + exclusion_reason = $3, + excluded_by = $4, + excluded_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP + WHERE visit_id = $1 AND asset_id = $2 + `, [visit.id, row.id, obsoleteReason, actorUserId]); + await this.appendAssetEvent( + manager, + visit.id, + row.id, + 'EXCLUDED', + obsoleteReason, + actorUserId, + { automatic: true, generation }, + ); + } + + for (const assetId of candidateIds) { + const [existing] = (await manager.query(` + SELECT included, planning_source AS "planningSource", exclusion_reason AS "exclusionReason" + FROM inspection_visit_assets + WHERE visit_id = $1 AND asset_id = $2 + FOR UPDATE + `, [visit.id, assetId])) as Array<{ + included: boolean; + planningSource: InspectionVisitAssetPlanningSource; + exclusionReason: string | null; + }>; + if (!existing) { + await manager.query(` + INSERT INTO inspection_visit_assets ( + visit_id, asset_id, included, planning_source, added_by + ) VALUES ($1, $2, true, 'AUTOMATIC', $3) + `, [visit.id, assetId, actorUserId]); + await this.appendAssetEvent( + manager, + visit.id, + assetId, + 'AUTO_INCLUDED', + null, + actorUserId, + { generation }, + ); + } else if (existing.planningSource === InspectionVisitAssetPlanningSource.LEGACY && existing.included) { + await manager.query(` + UPDATE inspection_visit_assets + SET planning_source = 'AUTOMATIC', updated_at = CURRENT_TIMESTAMP + WHERE visit_id = $1 AND asset_id = $2 + `, [visit.id, assetId]); + } + } + + visit.checklistGeneration = generation; + visit.checklistGeneratedAt = new Date(); + visit.updatedBy = actorUserId; + await manager.getRepository(InspectionVisit).save(visit); + } + + private async requireAsset(manager: EntityManager, id: string | null): Promise { + 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: 'INSPECTION_SCOPE_ASSET_NOT_FOUND', + message: 'El activo de ubicación o alcance no existe', + }); + } + } + + private async requireAssets(manager: EntityManager, ids: string[]): Promise { + if (ids.length === 0) return; + const [row] = (await manager.query( + `SELECT COUNT(*)::integer AS count FROM assets WHERE id = ANY($1::uuid[])`, + [ids], + )) as Array<{ count: number }>; + if (Number(row?.count ?? 0) !== ids.length) { + throw new BadRequestException({ + code: 'INSPECTION_ASSET_NOT_FOUND', + message: 'Uno o más activos seleccionados no existen', + }); + } + } + + private async requireInspectors(manager: EntityManager, ids: string[]): Promise { + if (ids.length === 0) return; + const [row] = (await manager.query(` + SELECT COUNT(*)::integer AS count + FROM users user_account + WHERE user_account.id = ANY($1::uuid[]) + 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 = 'inspections.execute' + ) + `, [ids])) as Array<{ count: number }>; + if (Number(row?.count ?? 0) !== ids.length) { + throw new BadRequestException({ + code: 'INSPECTION_MEMBER_INVALID', + message: 'Todo integrante debe ser un usuario activo con permiso de ejecución', + }); + } + } + + private async assertAssetsInScope( + manager: EntityManager, + assetIds: string[], + scopeAssetId: string | null, + ): Promise { + if (!scopeAssetId || assetIds.length === 0) return; + const [row] = (await manager.query(` + SELECT COUNT(*)::integer AS outside + FROM UNNEST($1::uuid[]) selected(asset_id) + WHERE NOT EXISTS ( + WITH RECURSIVE ancestors AS ( + SELECT asset.id, asset.parent_id FROM assets asset WHERE asset.id = selected.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 + ) + `, [assetIds, scopeAssetId])) as Array<{ outside: number }>; + if (Number(row?.outside ?? 0) > 0) { + throw new BadRequestException({ + code: 'INSPECTION_ASSET_OUTSIDE_SCOPE', + message: 'Uno o más activos no pertenecen al alcance jerárquico de la visita', + }); + } + } + + private async assertCurrentAssetsInScope( + manager: EntityManager, + visitId: string, + scopeAssetId: string | null, + ): Promise { + await this.assertAssetsInScope( + manager, + await this.activeAssetIds(manager, visitId), + scopeAssetId, + ); + } + + private async validatePlan(manager: EntityManager, visit: InspectionVisit): Promise { + if (!visit.operationalAreaId || !visit.operatorCompanyId) { + throw new BadRequestException({ + code: 'INSPECTION_CONTEXT_REQUIRED', + message: 'Seleccioná Área y Organización operadora antes de confirmar la planificación', + }); + } + await this.validatePlanningContext(manager, visit.operationalAreaId, visit.operatorCompanyId); + if (!visit.scopeAssetId) { + throw new BadRequestException({ + code: 'INSPECTION_SCOPE_REQUIRED', + message: 'La visita no tiene un alcance operativo válido', + }); + } + if (!visit.plannedStartAt) { + throw new BadRequestException({ + code: 'INSPECTION_START_DATE_REQUIRED', + message: 'Indicá la fecha y hora prevista de inicio', + }); + } + if (!visit.checklistGeneratedAt || visit.checklistGeneration < 1) { + throw new BadRequestException({ + code: 'INSPECTION_CHECKLIST_REQUIRED', + message: 'Generá el checklist automático antes de confirmar la planificación', + }); + } + if (!visit.leadInspectorUserId) { + throw new BadRequestException({ + code: 'INSPECTION_LEAD_REQUIRED', + message: 'Asigná un inspector responsable', + }); + } + await this.requireInspectors(manager, [visit.leadInspectorUserId]); + const memberIds = await this.activeMemberIds(manager, visit.id); + await this.requireInspectors(manager, memberIds); + const [counts] = (await manager.query(` + SELECT + (SELECT COUNT(*) FROM inspection_visit_assets + WHERE visit_id = $1 AND included = true)::integer AS assets, + (SELECT COUNT(*) FROM inspection_visit_members + WHERE visit_id = $1 AND included = true)::integer AS members, + EXISTS ( + SELECT 1 FROM inspection_visit_members + WHERE visit_id = $1 AND user_id = $2 AND included = true + ) AS "leadIncluded" + `, [visit.id, visit.leadInspectorUserId])) as Array<{ + assets: number; + members: number; + leadIncluded: boolean; + }>; + if (Number(counts?.assets ?? 0) < 1) { + throw new BadRequestException({ + code: 'INSPECTION_ASSETS_REQUIRED', + message: 'Agregá al menos un activo existente para inspeccionar', + }); + } + if (memberIds.length < 1 || Number(counts?.members ?? 0) < 1 || !counts?.leadIncluded) { + throw new BadRequestException({ + code: 'INSPECTION_TEAM_REQUIRED', + message: 'El equipo debe incluir al inspector responsable', + }); + } + await this.assertCurrentAssetsInScope(manager, visit.id, visit.scopeAssetId); + await this.assertCurrentAssetsMatchContext( + manager, + visit.id, + visit.operationalAreaId, + visit.operatorCompanyId, + ); + } + + private async activeAssetIds(manager: EntityManager, visitId: string): Promise { + const rows = (await manager.query(` + SELECT asset_id AS id FROM inspection_visit_assets + WHERE visit_id = $1 AND included = true ORDER BY created_at, asset_id + `, [visitId])) as Array<{ id: string }>; + return rows.map((row) => row.id); + } + + private async activeMemberIds(manager: EntityManager, visitId: string): Promise { + const rows = (await manager.query(` + SELECT user_id AS id FROM inspection_visit_members + WHERE visit_id = $1 AND included = true ORDER BY created_at, user_id + `, [visitId])) as Array<{ id: string }>; + return rows.map((row) => row.id); + } + + private async replaceMemberLinks( + manager: EntityManager, + visitId: string, + memberIds: string[], + userId: string, + ): Promise { + await manager.query(` + UPDATE inspection_visit_members + SET included = false, updated_at = CURRENT_TIMESTAMP + WHERE visit_id = $1 AND included = true + `, [visitId]); + for (const memberId of memberIds) { + await manager.query(` + INSERT INTO inspection_visit_members (visit_id, user_id, included, assigned_by) + VALUES ($1, $2, true, $3) + ON CONFLICT (visit_id, user_id) DO UPDATE SET + included = true, + assigned_by = EXCLUDED.assigned_by, + updated_at = CURRENT_TIMESTAMP + `, [visitId, memberId, userId]); + } + } + + private codeConflict(): ConflictException { + return new ConflictException({ + code: 'INSPECTION_VISIT_CODE_EXISTS', + message: 'Ya existe una visita con ese código', + }); + } + + private auditView(visit: InspectionVisitView): Record { + return { + code: visit.code, + title: visit.title, + objective: visit.objective, + status: visit.status, + scopeAssetId: visit.scopeAsset?.id ?? null, + operationalAreaId: visit.operationalArea?.id ?? null, + operatorCompanyId: visit.operatorCompany?.id ?? null, + leadInspectorUserId: visit.leadInspector?.id ?? null, + plannedStartAt: visit.plannedStartAt, + plannedEndAt: visit.plannedEndAt, + actualStartedAt: visit.actualStartedAt, + actualClosedAt: visit.actualClosedAt, + instructions: visit.instructions, + cancellationReason: visit.cancellationReason, + checklistGeneration: visit.checklist.generation, + checklistGeneratedAt: visit.checklist.generatedAt, + assetIds: visit.assets.map((asset) => asset.id), + excludedAssetIds: visit.planningAssets.filter((asset) => !asset.included).map((asset) => asset.id), + memberUserIds: visit.team.map((member) => member.id), + }; + } +} diff --git a/api-v3/src/main.ts b/api-v3/src/main.ts new file mode 100644 index 0000000..e13e7fc --- /dev/null +++ b/api-v3/src/main.ts @@ -0,0 +1,69 @@ +import 'reflect-metadata'; + +import { ValidationPipe } from '@nestjs/common'; +import { NestFactory } from '@nestjs/core'; +import { NestExpressApplication } from '@nestjs/platform-express'; +import cookieParser = require('cookie-parser'); +import helmet from 'helmet'; + +import { AppModule } from './app.module'; +import { ApiExceptionFilter } from './common/filters/api-exception.filter'; +import { requestIdMiddleware } from './common/http/request-id.middleware'; + +async function bootstrap() { + const app = await NestFactory.create( + AppModule, + { + bufferLogs: true, + }, + ); + + app.set('trust proxy', 1); + + app.getHttpAdapter().getInstance().disable('x-powered-by'); + + app.use( + helmet({ + contentSecurityPolicy: false, + crossOriginResourcePolicy: { + policy: 'same-site', + }, + }), + ); + + app.use(requestIdMiddleware); + app.use(cookieParser()); + + app.useBodyParser('json', { + limit: '1mb', + }); + + app.useBodyParser('urlencoded', { + limit: '1mb', + extended: false, + }); + + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }), + ); + + app.useGlobalFilters(new ApiExceptionFilter()); + + app.setGlobalPrefix('api/v3'); + + app.enableShutdownHooks(); + + const port = Number(process.env.API_PORT ?? 3000); + + await app.listen(port, '0.0.0.0'); +} + +bootstrap().catch((error: unknown) => { + console.error('Fatal bootstrap error'); + console.error(error); + process.exit(1); +}); diff --git a/api-v3/src/survey-execution/dto/review-survey-report.dto.ts b/api-v3/src/survey-execution/dto/review-survey-report.dto.ts new file mode 100644 index 0000000..cf878fb --- /dev/null +++ b/api-v3/src/survey-execution/dto/review-survey-report.dto.ts @@ -0,0 +1,16 @@ +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; +} diff --git a/api-v3/src/survey-execution/dto/save-survey-report.dto.ts b/api-v3/src/survey-execution/dto/save-survey-report.dto.ts new file mode 100644 index 0000000..74f1cb0 --- /dev/null +++ b/api-v3/src/survey-execution/dto/save-survey-report.dto.ts @@ -0,0 +1,58 @@ +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[]; +} diff --git a/api-v3/src/survey-execution/survey-execution.controller.ts b/api-v3/src/survey-execution/survey-execution.controller.ts new file mode 100644 index 0000000..8d8790c --- /dev/null +++ b/api-v3/src/survey-execution/survey-execution.controller.ts @@ -0,0 +1,61 @@ +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); + } +} diff --git a/api-v3/src/survey-execution/survey-execution.module.ts b/api-v3/src/survey-execution/survey-execution.module.ts new file mode 100644 index 0000000..6e0bf58 --- /dev/null +++ b/api-v3/src/survey-execution/survey-execution.module.ts @@ -0,0 +1,12 @@ +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 {} diff --git a/api-v3/src/survey-execution/survey-execution.service.ts b/api-v3/src/survey-execution/survey-execution.service.ts new file mode 100644 index 0000000..a0d16a8 --- /dev/null +++ b/api-v3/src/survey-execution/survey-execution.service.ts @@ -0,0 +1,924 @@ +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; + 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 { + return this.dataSource.transaction((manager) => this.loadView(manager, targetId)); + } + + async save( + targetId: string, + dto: SaveSurveyReportDto, + principal: AuthPrincipal, + request: RequestWithContext, + ): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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> { + 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 }>; + if (!row) throw reportNotFound(); + return row.snapshot; + } + + private async recordAssetChange( + manager: EntityManager, + asset: Asset, + beforeData: Record, + action: AuditAction, + principal: AuthPrincipal, + request: RequestWithContext, + targetId: string, + versionNumber: number, + ): Promise { + 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 { + 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 { + 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); + } +} diff --git a/api-v3/src/survey-planning/dto/add-survey-target.dto.ts b/api-v3/src/survey-planning/dto/add-survey-target.dto.ts new file mode 100644 index 0000000..1842129 --- /dev/null +++ b/api-v3/src/survey-planning/dto/add-survey-target.dto.ts @@ -0,0 +1,23 @@ +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; +} diff --git a/api-v3/src/survey-planning/dto/assign-survey-target.dto.ts b/api-v3/src/survey-planning/dto/assign-survey-target.dto.ts new file mode 100644 index 0000000..84bf835 --- /dev/null +++ b/api-v3/src/survey-planning/dto/assign-survey-target.dto.ts @@ -0,0 +1,7 @@ +import { IsOptional, IsUUID } from 'class-validator'; + +export class AssignSurveyTargetDto { + @IsOptional() + @IsUUID('4') + assignedUserId!: string | null; +} diff --git a/api-v3/src/survey-planning/dto/change-survey-campaign-status.dto.ts b/api-v3/src/survey-planning/dto/change-survey-campaign-status.dto.ts new file mode 100644 index 0000000..253e16e --- /dev/null +++ b/api-v3/src/survey-planning/dto/change-survey-campaign-status.dto.ts @@ -0,0 +1,7 @@ +import { IsEnum } from 'class-validator'; +import { SurveyCampaignStatus } from '../../database/entities'; + +export class ChangeSurveyCampaignStatusDto { + @IsEnum(SurveyCampaignStatus) + status!: SurveyCampaignStatus; +} diff --git a/api-v3/src/survey-planning/dto/change-survey-target-status.dto.ts b/api-v3/src/survey-planning/dto/change-survey-target-status.dto.ts new file mode 100644 index 0000000..760b817 --- /dev/null +++ b/api-v3/src/survey-planning/dto/change-survey-target-status.dto.ts @@ -0,0 +1,7 @@ +import { IsEnum } from 'class-validator'; +import { SurveyTargetStatus } from '../../database/entities'; + +export class ChangeSurveyTargetStatusDto { + @IsEnum(SurveyTargetStatus) + status!: SurveyTargetStatus; +} diff --git a/api-v3/src/survey-planning/dto/create-survey-campaign.dto.ts b/api-v3/src/survey-planning/dto/create-survey-campaign.dto.ts new file mode 100644 index 0000000..dcea6a9 --- /dev/null +++ b/api-v3/src/survey-planning/dto/create-survey-campaign.dto.ts @@ -0,0 +1,51 @@ +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; +} diff --git a/api-v3/src/survey-planning/dto/list-survey-campaigns-query.dto.ts b/api-v3/src/survey-planning/dto/list-survey-campaigns-query.dto.ts new file mode 100644 index 0000000..fa37549 --- /dev/null +++ b/api-v3/src/survey-planning/dto/list-survey-campaigns-query.dto.ts @@ -0,0 +1,40 @@ +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; +} diff --git a/api-v3/src/survey-planning/dto/update-survey-campaign.dto.ts b/api-v3/src/survey-planning/dto/update-survey-campaign.dto.ts new file mode 100644 index 0000000..5beed45 --- /dev/null +++ b/api-v3/src/survey-planning/dto/update-survey-campaign.dto.ts @@ -0,0 +1,53 @@ +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; +} diff --git a/api-v3/src/survey-planning/dto/update-survey-target.dto.ts b/api-v3/src/survey-planning/dto/update-survey-target.dto.ts new file mode 100644 index 0000000..b3c9acc --- /dev/null +++ b/api-v3/src/survey-planning/dto/update-survey-target.dto.ts @@ -0,0 +1,16 @@ +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; +} diff --git a/api-v3/src/survey-planning/survey-campaigns.controller.ts b/api-v3/src/survey-planning/survey-campaigns.controller.ts new file mode 100644 index 0000000..a1a506a --- /dev/null +++ b/api-v3/src/survey-planning/survey-campaigns.controller.ts @@ -0,0 +1,131 @@ +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); + } +} diff --git a/api-v3/src/survey-planning/survey-planning.module.ts b/api-v3/src/survey-planning/survey-planning.module.ts new file mode 100644 index 0000000..42a8cf6 --- /dev/null +++ b/api-v3/src/survey-planning/survey-planning.module.ts @@ -0,0 +1,14 @@ +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 {} diff --git a/api-v3/src/survey-planning/survey-planning.service.ts b/api-v3/src/survey-planning/survey-planning.service.ts new file mode 100644 index 0000000..7a4a111 --- /dev/null +++ b/api-v3/src/survey-planning/survey-planning.service.ts @@ -0,0 +1,791 @@ +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.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.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 { + return this.dataSource.transaction((manager) => this.loadCampaignView(manager, id)); + } + + async create( + dto: CreateSurveyCampaignDto, + principal: AuthPrincipal, + request: RequestWithContext, + ): Promise { + 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 { + 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 { + 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 { + 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, + 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 { + 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, + afterData: updated as unknown as Record, + metadata: { campaignId: target.campaignId }, + }, manager); + return this.loadCampaignView(manager, target.campaignId); + }); + } + + async assignTarget( + id: string, + dto: AssignSurveyTargetDto, + principal: AuthPrincipal, + request: RequestWithContext, + ): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + const { targets: _targets, ...summary } = campaign; + return summary as unknown as Record; + } +} diff --git a/api-v3/src/version.ts b/api-v3/src/version.ts new file mode 100644 index 0000000..601dc54 --- /dev/null +++ b/api-v3/src/version.ts @@ -0,0 +1,2 @@ +export const API_VERSION = '0.19.6-4'; +export const API_PHASE = 'D5.6.4'; diff --git a/api-v3/test/unit/administration-authorization.test.ts b/api-v3/test/unit/administration-authorization.test.ts new file mode 100644 index 0000000..f0764d1 --- /dev/null +++ b/api-v3/test/unit/administration-authorization.test.ts @@ -0,0 +1,39 @@ +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 { RolesController } from '../../src/administration/roles/roles.controller'; +import { UsersController } from '../../src/administration/users/users.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('UsersController declares a permission for every administrative action', () => { + const controller = UsersController.prototype; + assert.deepEqual(permissionFor(controller, 'list'), ['users.read']); + assert.deepEqual(permissionFor(controller, 'getById'), ['users.read']); + assert.deepEqual(permissionFor(controller, 'create'), ['users.create']); + assert.deepEqual(permissionFor(controller, 'update'), ['users.update']); + assert.deepEqual(permissionFor(controller, 'changeStatus'), [ + 'users.change_status', + ]); + assert.deepEqual(permissionFor(controller, 'replaceRoles'), [ + 'users.assign_roles', + ]); +}); + +test('RolesController separates reading from matrix administration', () => { + const controller = RolesController.prototype; + assert.deepEqual(permissionFor(controller, 'list'), ['roles.read']); + assert.deepEqual(permissionFor(controller, 'listPermissions'), [ + 'roles.read', + ]); + assert.deepEqual(permissionFor(controller, 'getById'), ['roles.read']); + assert.deepEqual(permissionFor(controller, 'create'), ['roles.manage']); + assert.deepEqual(permissionFor(controller, 'update'), ['roles.manage']); + assert.deepEqual(permissionFor(controller, 'replacePermissions'), [ + 'roles.manage', + ]); +}); diff --git a/api-v3/test/unit/administration-dto.test.ts b/api-v3/test/unit/administration-dto.test.ts new file mode 100644 index 0000000..6a2915a --- /dev/null +++ b/api-v3/test/unit/administration-dto.test.ts @@ -0,0 +1,49 @@ +import 'reflect-metadata'; +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { validate } from 'class-validator'; +import { CreateRoleDto } from '../../src/administration/roles/dto/create-role.dto'; +import { CreateUserDto } from '../../src/administration/users/dto/create-user.dto'; + +const ROLE_ID = '9c3e6a45-8de3-4acf-a078-7994168231f7'; +const PERMISSION_ID = '35f62a40-5fd0-4ee7-89fc-381c3c0eceaf'; + +test('CreateUserDto accepts a controlled temporary credential and role ids', async () => { + const dto = Object.assign(new CreateUserDto(), { + username: 'nuevo.inspector', + email: 'inspector@example.com', + password: 'Temporal-segura-2026', + firstName: 'Nuevo', + lastName: 'Inspector', + mustChangePassword: true, + roleIds: [ROLE_ID], + }); + + assert.deepEqual(await validate(dto), []); +}); + +test('CreateUserDto rejects weak passwords and malformed role ids', async () => { + const dto = Object.assign(new CreateUserDto(), { + username: 'nuevo inspector', + password: 'corta', + firstName: 'Nuevo', + lastName: 'Inspector', + roleIds: ['no-es-uuid'], + }); + + const properties = new Set((await validate(dto)).map((error) => error.property)); + assert.equal(properties.has('username'), true); + assert.equal(properties.has('password'), true); + assert.equal(properties.has('roleIds'), true); +}); + +test('CreateRoleDto validates dynamic permission identifiers', async () => { + const dto = Object.assign(new CreateRoleDto(), { + code: 'coordinador_campo', + name: 'Coordinador de campo', + description: 'Coordina equipos operativos', + permissionIds: [PERMISSION_ID], + }); + + assert.deepEqual(await validate(dto), []); +}); diff --git a/api-v3/test/unit/administration-integrity.test.ts b/api-v3/test/unit/administration-integrity.test.ts new file mode 100644 index 0000000..9a36ac2 --- /dev/null +++ b/api-v3/test/unit/administration-integrity.test.ts @@ -0,0 +1,25 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { ConflictException } from '@nestjs/common'; +import type { EntityManager } from 'typeorm'; +import { assertAdministrativeRecoveryRemains } from '../../src/administration/common/administration-audit'; + +function managerWithCount(count: number): EntityManager { + return { + query: async () => [{ count }], + } as unknown as EntityManager; +} + +test('administrative integrity accepts at least one recovery administrator', async () => { + await assert.doesNotReject( + assertAdministrativeRecoveryRemains(managerWithCount(1)), + ); +}); + +test('administrative integrity prevents removing the last recovery administrator', async () => { + await assert.rejects( + assertAdministrativeRecoveryRemains(managerWithCount(0)), + (error: unknown) => + error instanceof ConflictException && error.getStatus() === 409, + ); +}); diff --git a/api-v3/test/unit/asset-attribute-validator.test.ts b/api-v3/test/unit/asset-attribute-validator.test.ts new file mode 100644 index 0000000..fbfe138 --- /dev/null +++ b/api-v3/test/unit/asset-attribute-validator.test.ts @@ -0,0 +1,71 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { validateAssetAttributeValues } from '../../src/asset-master/asset-attribute-validator'; +import { + AssetAttributeDataType, + type AssetAttributeDefinition, +} from '../../src/database/entities'; + +function definition( + id: string, + name: string, + dataType: AssetAttributeDataType, + overrides: Partial = {}, +): AssetAttributeDefinition { + return { + id, + name, + code: name.toLowerCase(), + dataType, + assetTypeId: '16e54e65-60cf-4739-b0d1-ccdd904fbfd5', + isRequired: false, + isActive: true, + unit: null, + options: null, + sortOrder: 0, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; +} + +test('normalizes configured values without accepting arbitrary attributes', () => { + const definitions = [ + definition('7450ea58-6348-4cfc-88f1-e0f3bcd38913', 'Presión', AssetAttributeDataType.NUMBER), + definition('f82088f8-6fc0-46c9-98b2-d694f80449c3', 'Crítico', AssetAttributeDataType.BOOLEAN), + ]; + assert.deepEqual(validateAssetAttributeValues(definitions, { + [definitions[0]!.id]: 12.5, + [definitions[1]!.id]: false, + }), [ + { definitionId: definitions[0]!.id, value: 12.5 }, + { definitionId: definitions[1]!.id, value: false }, + ]); + assert.throws(() => validateAssetAttributeValues(definitions, { unknown: 'value' })); +}); + +test('enforces required, date and selection semantics', () => { + const requiredDate = definition( + '880d0cdf-2400-4892-9a78-bcd8e7a3e270', + 'Instalación', + AssetAttributeDataType.DATE, + { isRequired: true }, + ); + const selector = definition( + '26430113-1116-45f0-a1d5-f96858e97b2e', + 'Clase', + AssetAttributeDataType.SELECT, + { options: ['A', 'B'] }, + ); + + assert.throws(() => validateAssetAttributeValues([requiredDate], {})); + assert.throws(() => validateAssetAttributeValues([requiredDate], { [requiredDate.id]: '2026-02-30' })); + assert.throws(() => validateAssetAttributeValues([selector], { [selector.id]: 'C' })); + assert.deepEqual(validateAssetAttributeValues([requiredDate, selector], { + [requiredDate.id]: '2026-08-13', + [selector.id]: 'B', + }), [ + { definitionId: requiredDate.id, value: '2026-08-13' }, + { definitionId: selector.id, value: 'B' }, + ]); +}); diff --git a/api-v3/test/unit/asset-center-d5-3-4.test.ts b/api-v3/test/unit/asset-center-d5-3-4.test.ts new file mode 100644 index 0000000..9b94451 --- /dev/null +++ b/api-v3/test/unit/asset-center-d5-3-4.test.ts @@ -0,0 +1,31 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import test from 'node:test'; + +async function source(relativePath: string): Promise { + return readFile(resolve(process.cwd(), 'src', relativePath), 'utf8'); +} + +test('D5.3.4 adds a bounded hierarchy query without changing physical parent semantics', async () => { + const controller = await source('asset-master/assets.controller.ts'); + const service = await source('asset-master/assets.service.ts'); + const dto = await source('asset-master/dto/list-asset-tree-query.dto.ts'); + + assert.match(controller, /@Get\('tree'\)/); + assert.match(controller, /@RequirePermissions\('assets\.read'\)/); + assert.match(service, /WITH RECURSIVE matched AS/); + assert.match(service, /JOIN visible current/); + assert.match(service, /rows\.length > 5000/); + assert.match(service, /asset_external_identifiers external_id/); + assert.match(dto, /needsValidation\?: boolean/); + assert.match(dto, /hasGeometry\?: boolean/); +}); + +test('D5.3.4 exposes operational dashboard counts before technical administration metrics', async () => { + const dashboard = await source('dashboard/dashboard.service.ts'); + assert.match(dashboard, /"plannedInspections"/); + assert.match(dashboard, /"assetsNeedValidation"/); + assert.match(dashboard, /"assetsWithoutGeometry"/); + assert.match(dashboard, /information_status NOT IN \('VALIDATED','INACTIVE'\)/); +}); diff --git a/api-v3/test/unit/asset-dossier-d5-3-14.test.ts b/api-v3/test/unit/asset-dossier-d5-3-14.test.ts new file mode 100644 index 0000000..6d52a99 --- /dev/null +++ b/api-v3/test/unit/asset-dossier-d5-3-14.test.ts @@ -0,0 +1,32 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import test from 'node:test'; + +async function source(relativePath: string): Promise { + return readFile(resolve(process.cwd(), 'src', relativePath), 'utf8'); +} + +test('D5.3.14 protects the inventory dossier with all related read permissions', async () => { + const controller = await source('asset-master/assets.controller.ts'); + assert.match(controller, /@Get\(':id\/dossier'\)/); + assert.match(controller, /'assets\.read'/); + assert.match(controller, /'inspections\.read'/); + assert.match(controller, /'inspection_acts\.read'/); + assert.match(controller, /'inspection_findings\.read'/); + assert.match(controller, /'inspection_evidence\.read'/); + assert.match(controller, /'inspection_communications\.read'/); +}); + +test('D5.3.14 assembles a chronological dossier without duplicating business data', async () => { + const service = await source('asset-master/assets.service.ts'); + assert.match(service, /inspection_visit_assets/); + assert.match(service, /inspection_act_assets/); + assert.match(service, /inspection_findings/); + assert.match(service, /inspection_finding_evidence/); + assert.match(service, /inspection_finding_communications/); + assert.match(service, /asset_source_documents/); + assert.match(service, /asset_versions/); + assert.match(service, /timeline\.sort/); + assert.doesNotMatch(service, /INSERT INTO inspection_/); +}); diff --git a/api-v3/test/unit/asset-geometry-authorization.test.ts b/api-v3/test/unit/asset-geometry-authorization.test.ts new file mode 100644 index 0000000..d2829d8 --- /dev/null +++ b/api-v3/test/unit/asset-geometry-authorization.test.ts @@ -0,0 +1,24 @@ +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 { + AssetGeometriesController, + MapAssetsController, +} from '../../src/asset-master/asset-geometries.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('geometry reads use assets.read and writes require the specific permission', () => { + const controller = AssetGeometriesController.prototype; + assert.deepEqual(permissionFor(controller, 'get'), ['assets.read']); + assert.deepEqual(permissionFor(controller, 'upsert'), ['assets.update_geometry']); + assert.deepEqual(permissionFor(controller, 'remove'), ['assets.update_geometry']); +}); + +test('the operational map is protected by assets.read', () => { + assert.deepEqual(permissionFor(MapAssetsController.prototype, 'map'), ['assets.read']); +}); diff --git a/api-v3/test/unit/asset-geometry-validator.test.ts b/api-v3/test/unit/asset-geometry-validator.test.ts new file mode 100644 index 0000000..abde396 --- /dev/null +++ b/api-v3/test/unit/asset-geometry-validator.test.ts @@ -0,0 +1,47 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + parseBoundingBox, + validateGeoJsonGeometry, +} from '../../src/asset-master/asset-geometry-validator'; +import { AssetGeometryType } from '../../src/database/entities'; + +test('accepts points and strips unsupported altitude values', () => { + assert.deepEqual(validateGeoJsonGeometry({ + type: AssetGeometryType.POINT, + coordinates: [-68.8458, -32.8895, 900], + }), { + type: AssetGeometryType.POINT, + coordinates: [-68.8458, -32.8895], + }); +}); + +test('rejects invalid lines and out-of-range coordinates', () => { + assert.throws(() => validateGeoJsonGeometry({ + type: AssetGeometryType.LINESTRING, + coordinates: [[-68, -32], [-68, -32]], + })); + assert.throws(() => validateGeoJsonGeometry({ + type: AssetGeometryType.POINT, + coordinates: [-181, -32], + })); +}); + +test('requires closed, non-degenerate polygon rings', () => { + assert.throws(() => validateGeoJsonGeometry({ + type: AssetGeometryType.POLYGON, + coordinates: [[[-68, -32], [-67, -32], [-67, -33]]], + })); + assert.deepEqual(validateGeoJsonGeometry({ + type: AssetGeometryType.POLYGON, + coordinates: [[ + [-68, -32], [-67, -32], [-67, -33], [-68, -32], + ]], + }).type, AssetGeometryType.POLYGON); +}); + +test('parses only ordered WGS84 bounding boxes', () => { + assert.deepEqual(parseBoundingBox('-70,-35,-66,-31'), [-70, -35, -66, -31]); + assert.throws(() => parseBoundingBox('-66,-31,-70,-35')); + assert.throws(() => parseBoundingBox('-200,-35,-66,-31')); +}); diff --git a/api-v3/test/unit/asset-history-authorization.test.ts b/api-v3/test/unit/asset-history-authorization.test.ts new file mode 100644 index 0000000..13dc8c0 --- /dev/null +++ b/api-v3/test/unit/asset-history-authorization.test.ts @@ -0,0 +1,17 @@ +import 'reflect-metadata'; +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { AssetHistoryController } from '../../src/asset-master/asset-history.controller'; +import { REQUIRED_PERMISSIONS_KEY } from '../../src/authorization/decorators/require-permissions.decorator'; + +function permissionFor(method: string): string[] { + const controller = AssetHistoryController.prototype; + const handler = controller[method as keyof typeof controller]; + return Reflect.getMetadata(REQUIRED_PERMISSIONS_KEY, handler) as string[]; +} + +test('all asset history endpoints require the dedicated permission', () => { + assert.deepEqual(permissionFor('list'), ['assets.read_history']); + assert.deepEqual(permissionFor('listForAsset'), ['assets.read_history']); + assert.deepEqual(permissionFor('getVersion'), ['assets.read_history']); +}); diff --git a/api-v3/test/unit/asset-history-capture.test.ts b/api-v3/test/unit/asset-history-capture.test.ts new file mode 100644 index 0000000..9fd96b2 --- /dev/null +++ b/api-v3/test/unit/asset-history-capture.test.ts @@ -0,0 +1,54 @@ +import 'reflect-metadata'; +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { DataSource, EntityManager } from 'typeorm'; +import { AssetHistoryService } from '../../src/asset-master/asset-history.service'; +import { AssetVersionChangeType } from '../../src/database/entities/asset-version.entity'; +import type { AuthPrincipal, RequestWithContext } from '../../src/common/http/request-context'; + +test('asset history capture increments then reads current_version before inserting the revision', async () => { + const calls: Array<{ sql: string; parameters: unknown[] }> = []; + const manager = { + query: async (sql: string, parameters: unknown[] = []) => { + calls.push({ sql, parameters }); + if (/^\s*UPDATE assets/i.test(sql)) return []; + if (/^\s*SELECT current_version/i.test(sql)) return [{ current_version: 1 }]; + if (/FROM asset_versions/i.test(sql)) return []; + if (/^\s*INSERT INTO asset_versions/i.test(sql)) return []; + throw new Error(`Consulta no esperada en test: ${sql}`); + }, + } as unknown as EntityManager; + + const service = new AssetHistoryService({} as DataSource); + (service as unknown as { loadCurrentSnapshot: () => Promise> }) + .loadCurrentSnapshot = async () => ({ id: 'asset-1', currentVersion: 1 }); + + const principal = { + userId: '00000000-0000-4000-8000-000000000001', + username: 'admin', + sessionId: 'test', + firstName: 'Admin', + lastName: 'Test', + email: null, + mustChangePassword: false, + roles: ['admin'], + permissions: [], + transport: 'cookie', + } as AuthPrincipal; + const request = { requestId: 'test-request' } as RequestWithContext; + + const version = await service.capture( + manager, + '00000000-0000-4000-8000-000000000002', + AssetVersionChangeType.CREATED, + principal, + request, + ); + + assert.equal(version, 1); + assert.match(calls[0].sql, /COALESCE\(current_version, 0\) \+ 1/); + assert.match(calls[1].sql, /SELECT current_version/); + const insert = calls.find((call) => /INSERT INTO asset_versions/i.test(call.sql)); + assert.ok(insert); + assert.equal(insert.parameters[1], 1); +}); diff --git a/api-v3/test/unit/asset-history-dto.test.ts b/api-v3/test/unit/asset-history-dto.test.ts new file mode 100644 index 0000000..e65a6e4 --- /dev/null +++ b/api-v3/test/unit/asset-history-dto.test.ts @@ -0,0 +1,36 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; +import { ListAssetVersionsQueryDto } from '../../src/asset-master/dto/list-asset-versions-query.dto'; +import { AssetVersionChangeType } from '../../src/database/entities'; + +test('asset history filters accept pagination, dates and known change types', async () => { + const dto = plainToInstance(ListAssetVersionsQueryDto, { + page: '2', + pageSize: '50', + search: ' ACT-001 ', + changeType: AssetVersionChangeType.GEOMETRY_UPDATED, + from: '2026-08-01T00:00:00.000Z', + }); + + assert.deepEqual(await validate(dto), []); + assert.equal(dto.page, 2); + assert.equal(dto.pageSize, 50); + assert.equal(dto.search, 'ACT-001'); +}); + +test('asset history filters reject invalid pages, dates and change types', async () => { + const dto = plainToInstance(ListAssetVersionsQueryDto, { + page: '0', + pageSize: '500', + changeType: 'DELETED', + to: 'fecha-invalida', + }); + const properties = new Set((await validate(dto)).map((error) => error.property)); + + assert.equal(properties.has('page'), true); + assert.equal(properties.has('pageSize'), true); + assert.equal(properties.has('changeType'), true); + assert.equal(properties.has('to'), true); +}); diff --git a/api-v3/test/unit/asset-import-profiles.test.ts b/api-v3/test/unit/asset-import-profiles.test.ts new file mode 100644 index 0000000..4f80c0a --- /dev/null +++ b/api-v3/test/unit/asset-import-profiles.test.ts @@ -0,0 +1,53 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { analyzeImportWorkbook, detectImportProfile } from '../../src/asset-imports/asset-import-profiles'; +import type { ParsedWorkbook } from '../../src/asset-imports/asset-import-parser'; + +function workbook(rows: string[][], name = 'Inventario'): ParsedWorkbook { + return { kind: 'XLSX', sheets: [{ name, rows }] }; +} + +test('D5.3.5 detects the Mendoza inventory layout and preserves source semantics', () => { + const input = workbook([ + ['Item','Area/ Yacimiento','Instalacion','Sub instalacion','Equipo','Denominación del equipo','Ubicación del equipo o instalación','N° ID/ Inventario','Cantidad','Especificaciones técnicas','Estado (en servicio/ fuera de servicio)'], + ['1','REFUGIO TUPUNGATO','TUP-PIAS','ZONA BOMBAS PIAS','Bomba Centrífuga','Clase: BBA; Tipo: CEN','N/A','BBA-050','1','Fabricante; GOULD; Modelo: 3196','NO'], + ]); + const detection = detectImportProfile(input); + assert.equal(detection.profileCode, 'MENDOZA_INVENTORY_V1'); + const analysis = analyzeImportWorkbook(input); + assert.equal(analysis.summary.totalRows, 1); + assert.equal(analysis.rows[0]?.normalized.areaOrField, 'REFUGIO TUPUNGATO'); + assert.equal(analysis.rows[0]?.normalized.inventoryId, 'BBA-050'); + assert.equal(analysis.rows[0]?.normalized.sourceClass, 'BBA'); + assert.equal(analysis.rows[0]?.normalized.sourceSubtype, 'CEN'); + assert.equal(analysis.rows[0]?.normalized.manufacturer, 'GOULD'); + assert.equal(analysis.rows[0]?.normalized.model, '3196'); + assert.equal(analysis.rows[0]?.normalized.sourceStatus, 'NO'); + assert.ok(analysis.rows[0]?.issues.includes('SOURCE_STATUS_REQUIRES_MAPPING')); +}); + +test('D5.3.5 treats grouped quantity and repeated inventory IDs as review signals', () => { + const input = workbook([ + ['Item','Area/ Yacimiento','Instalacion','Sub instalacion','Equipo','Denominación del equipo','Ubicación del equipo o instalación','N° ID/ Inventario','Cantidad','Especificaciones técnicas','Estado'], + ['1','Loma de La Mina','PTC','Playa Tanques','Bomba','','Pozo 1','MEX000062','2','','en servicio'], + ['2','Loma de La Mina','PTC','Playa Tanques','Bomba','','Pozo 2','MEX000062','1','','fuera de servicio'], + ]); + const analysis = analyzeImportWorkbook(input); + assert.equal(analysis.summary.totalRows, 2); + assert.ok(analysis.rows[0]?.issues.includes('GROUPED_QUANTITY')); + assert.ok(analysis.rows.every((row) => row.issues.includes('INVENTORY_ID_MULTIPLE_LOCATIONS'))); + assert.equal(analysis.summary.conflictRows, 2); +}); + +test('D5.3.5 detects the Mendoza Yacimiento-Area reference table separately', () => { + const input = workbook([ + ['Yacimiento','Área','Departamento','Tipo Concesión','Operadora'], + ['Cañada Dura','Vizcacheras','San Carlos','Explotación','Petróleos Sudamericanos Energy S.A.'], + ], 'Tabla yacimientos'); + const analysis = analyzeImportWorkbook(input); + assert.equal(analysis.detection.profileCode, 'MENDOZA_YACIMIENTOS_V1'); + assert.equal(analysis.rows[0]?.normalized.field, 'Cañada Dura'); + assert.equal(analysis.rows[0]?.normalized.area, 'Vizcacheras'); + assert.equal(analysis.rows[0]?.normalized.rightType, 'Explotación'); + assert.equal(analysis.rows[0]?.normalized.operator, 'Petróleos Sudamericanos Energy S.A.'); +}); diff --git a/api-v3/test/unit/asset-imports-authorization.test.ts b/api-v3/test/unit/asset-imports-authorization.test.ts new file mode 100644 index 0000000..b4743e3 --- /dev/null +++ b/api-v3/test/unit/asset-imports-authorization.test.ts @@ -0,0 +1,20 @@ +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 { AssetImportsController } from '../../src/asset-imports/asset-imports.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('D5.3.5 separates import analysis reading from batch administration', () => { + const controller = AssetImportsController.prototype; + assert.deepEqual(permissionFor(controller, 'list'), ['asset_imports.read']); + assert.deepEqual(permissionFor(controller, 'get'), ['asset_imports.read']); + assert.deepEqual(permissionFor(controller, 'rows'), ['asset_imports.read']); + assert.deepEqual(permissionFor(controller, 'upload'), ['asset_imports.manage']); + assert.deepEqual(permissionFor(controller, 'reconcile'), ['asset_imports.manage']); + assert.deepEqual(permissionFor(controller, 'cancel'), ['asset_imports.manage']); +}); diff --git a/api-v3/test/unit/asset-imports-d5-3-11.test.ts b/api-v3/test/unit/asset-imports-d5-3-11.test.ts new file mode 100644 index 0000000..a76d801 --- /dev/null +++ b/api-v3/test/unit/asset-imports-d5-3-11.test.ts @@ -0,0 +1,71 @@ +import 'reflect-metadata'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import test from 'node:test'; +import { REQUIRED_PERMISSIONS_KEY } from '../../src/authorization/decorators/require-permissions.decorator'; +import { safePlanItems, summarizePlanItems, type AssetImportPlanDraftItem } from '../../src/asset-imports/asset-import-plan'; +import { AssetImportsController } from '../../src/asset-imports/asset-imports.controller'; + +function source(relative: string): string { return readFileSync(resolve(process.cwd(), relative), 'utf8'); } +function permissionFor(method: string): string[] { + const controller = AssetImportsController.prototype; + return Reflect.getMetadata(REQUIRED_PERMISSIONS_KEY, controller[method as keyof typeof controller]) as string[]; +} +function item(overrides: Partial = {}): AssetImportPlanDraftItem { + return { + entityKey: 'technical:safe', entityKind: 'TECHNICAL_ASSET', action: 'CREATE', status: 'PLANNED', assetTypeCode: 'tanque', + displayName: 'TK-1', generatedCode: 'IMP-TANQUE-1', parentEntityKey: null, matchedAssetId: null, payload: {}, sourceRowNumbers: [2], reviewCodes: [], + ...overrides, + }; +} + +test('D5.3.11 exposes the review inbox and privileged safe application', () => { + assert.deepEqual(permissionFor('reviews'), ['asset_imports.read']); + assert.deepEqual(permissionFor('applySafePlan'), ['asset_imports.apply']); +}); + +test('D5.3.11 imports only branches independent from pending reviews', () => { + const decision = item({ entityKey: 'field:ambiguous', entityKind: 'FIELD', action: 'REVIEW', status: 'REVIEW', assetTypeCode: 'yacimiento', reviewCodes: ['PLAN_TERRITORY_AMBIGUOUS'] }); + const blockedParent = item({ entityKey: 'structure:blocked', entityKind: 'LOCAL_STRUCTURE', assetTypeCode: 'estructura_local', parentEntityKey: decision.entityKey }); + const blockedChild = item({ entityKey: 'technical:blocked', parentEntityKey: blockedParent.entityKey }); + const safeField = item({ entityKey: 'field:safe', entityKind: 'FIELD', assetTypeCode: 'yacimiento' }); + const safeChild = item({ entityKey: 'technical:safe', parentEntityKey: safeField.entityKey }); + const planItems = [decision, blockedParent, blockedChild, safeField, safeChild]; + const safe = safePlanItems(planItems); + assert.deepEqual(safe.map((candidate) => candidate.entityKey), ['field:safe', 'technical:safe']); + const summary = summarizePlanItems(planItems); + assert.equal(summary.safeCreateItems, 2); + assert.equal(summary.blockedCreateItems, 2); +}); + +test('D5.3.11 treats already-applied parents as valid support for later safe branches', () => { + const parent = item({ entityKey: 'field:applied', entityKind: 'FIELD', assetTypeCode: 'yacimiento', status: 'APPLIED' }); + const child = item({ entityKey: 'technical:next', parentEntityKey: parent.entityKey }); + assert.deepEqual(safePlanItems([parent, child]).map((candidate) => candidate.entityKey), ['field:applied', 'technical:next']); + const summary = summarizePlanItems([parent, child]); + assert.equal(summary.appliedCreateItems, 1); + assert.equal(summary.pendingCreateItems, 1); + assert.equal(summary.safeCreateItems, 1); + assert.equal(summary.blockedCreateItems, 0); +}); + +test('D5.3.11 keeps partial application transactional and rollback cumulative across plan revisions', () => { + const service = source('src/asset-imports/asset-imports.service.ts'); + const partial = service.slice(service.indexOf('async applySafePlan'), service.indexOf('async applyPlan')); + const rollback = service.slice(service.indexOf('async rollbackPlan'), service.indexOf('async upload')); + assert.match(partial, /this\.dataSource\.transaction/); + assert.match(partial, /safePlanItems\(allItems\)/); + assert.match(partial, /status: 'PARTIAL'/); + assert.match(partial, /masterStateHash: nextMasterStateHash/); + assert.match(rollback, /JOIN asset_import_plans history ON history\.id=item\.plan_id/); + assert.match(rollback, /history\.batch_id=\$1 AND item\.action='CREATE' AND item\.status='APPLIED'/); + assert.match(rollback, /UPDATE asset_import_rows SET imported_asset_id=NULL/); +}); + +test('D5.3.11 prevents normal reconciliation from bypassing an existing partial import', () => { + const service = source('src/asset-imports/asset-imports.service.ts'); + assert.match(service, /ASSET_IMPORT_PARTIAL_RECONCILE_BLOCKED/); + assert.match(service, /batchHasAppliedItems\(manager, id\)/); + assert.match(service, /item\.status !== 'APPLIED'/); +}); diff --git a/api-v3/test/unit/asset-imports-d5-3-6.test.ts b/api-v3/test/unit/asset-imports-d5-3-6.test.ts new file mode 100644 index 0000000..2d1959d --- /dev/null +++ b/api-v3/test/unit/asset-imports-d5-3-6.test.ts @@ -0,0 +1,49 @@ +import 'reflect-metadata'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import test from 'node:test'; +import { REQUIRED_PERMISSIONS_KEY } from '../../src/authorization/decorators/require-permissions.decorator'; +import { AssetImportsController } from '../../src/asset-imports/asset-imports.controller'; +import { analyzeImportWorkbook } from '../../src/asset-imports/asset-import-profiles'; +import type { ParsedWorkbook } from '../../src/asset-imports/asset-import-parser'; + +function source(relative: string): string { return readFileSync(resolve(process.cwd(), relative), 'utf8'); } +function permissionFor(method: string): string[] { + const controller = AssetImportsController.prototype; + return Reflect.getMetadata(REQUIRED_PERMISSIONS_KEY, controller[method as keyof typeof controller]) as string[]; +} +function workbook(rows: string[][]): ParsedWorkbook { return { kind: 'XLSX', sheets: [{ name: 'Inventario', rows }] }; } + +test('D5.3.6 protects reconciliation as an import-management operation', () => { + assert.deepEqual(permissionFor('reconcile'), ['asset_imports.manage']); +}); + +test('D5.3.6 normalizes technical family and subtype while preserving source classification', () => { + const analysis = analyzeImportWorkbook(workbook([ + ['Item','Area/ Yacimiento','Instalacion','Sub instalacion','Equipo','Denominación del equipo','Ubicación del equipo o instalación','N° ID/ Inventario','Cantidad','Especificaciones técnicas','Estado'], + ['1','REFUGIO TUPUNGATO','TUP-PIAS','ZONA BOMBAS','Bomba Centrífuga','Clase: BBA; Tipo: CEN','N/A','BBA-050','1','Fabricante; GOULD; Modelo: 3196','NO'], + ])); + assert.equal(analysis.rows[0]?.normalized.sourceClass, 'BBA'); + assert.equal(analysis.rows[0]?.normalized.sourceSubtype, 'CEN'); + assert.equal(analysis.rows[0]?.normalized.normalizedFamily, 'bomba'); + assert.equal(analysis.rows[0]?.normalized.normalizedSubtype, 'centrifuga'); + assert.equal(analysis.rows[0]?.normalized.sourceStatus, 'NO'); +}); + +test('D5.3.6 reconciliation is conservative and does not write Maestro assets', () => { + const service = source('src/asset-imports/asset-imports.service.ts'); + const reconcile = service.slice(service.indexOf('async reconcile'), service.indexOf('async cancel')); + assert.match(reconcile, /INVENTORY_ID_OR_CODE_EXACT/); + assert.match(reconcile, /AREA_FIELD_EXACT/); + assert.match(reconcile, /MATCH_MULTIPLE_ASSETS/); + assert.match(reconcile, /masterModified: false/); + assert.doesNotMatch(reconcile, /INSERT INTO assets\s*\(/); + assert.doesNotMatch(reconcile, /UPDATE assets\s+SET/); +}); + + +test('D5.3.6.3 audit summary is exposed as a plain record', () => { + const source = readFileSync(join(process.cwd(), 'src/asset-imports/asset-imports.service.ts'), 'utf8'); + assert.match(source, /afterData:\s*\{\s*\.\.\.summary\s*\}/); +}); diff --git a/api-v3/test/unit/asset-imports-d5-3-7-2.test.ts b/api-v3/test/unit/asset-imports-d5-3-7-2.test.ts new file mode 100644 index 0000000..bda4f69 --- /dev/null +++ b/api-v3/test/unit/asset-imports-d5-3-7-2.test.ts @@ -0,0 +1,133 @@ +import 'reflect-metadata'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import test from 'node:test'; +import { REQUIRED_PERMISSIONS_KEY } from '../../src/authorization/decorators/require-permissions.decorator'; +import { AssetImportsController } from '../../src/asset-imports/asset-imports.controller'; +import { departmentCode, normalizedLegalRightType, organizationImportKey } from '../../src/asset-imports/asset-import-plan'; + +function source(relative: string): string { return readFileSync(resolve(process.cwd(), relative), 'utf8'); } +function permissionFor(method: string): string[] { + const controller = AssetImportsController.prototype; + return Reflect.getMetadata(REQUIRED_PERMISSIONS_KEY, controller[method as keyof typeof controller]) as string[]; +} + +test('D5.3.7.2 normalizes Departments and legal-right source values', () => { + assert.equal(departmentCode('Luján de Cuyo'), 'LUJAN-DE-CUYO'); + assert.equal(departmentCode('San Rafael'), 'SAN-RAFAEL'); + assert.equal(normalizedLegalRightType('Explotación'), 'EXPLOITATION_CONCESSION'); + assert.equal(normalizedLegalRightType('Exploración'), 'EXPLORATION_PERMIT'); + assert.equal(normalizedLegalRightType('Concesión de Transporte'), 'TRANSPORT_CONCESSION'); +}); + +test('D5.3.7.2 exposes paginated plan details as read-only import information', () => { + assert.deepEqual(permissionFor('planItems'), ['asset_imports.read']); + const controller = source('src/asset-imports/asset-imports.controller.ts'); + assert.match(controller, /@Get\(':id\/plan\/items'\)/); + const service = source('src/asset-imports/asset-imports.service.ts'); + assert.match(service, /async planItems\(/); + assert.match(service, /entity_kind=\$\$\{params\.length\}/); +}); + +test('D5.3.7.2 migration creates a normalized Department catalog and Area relation', () => { + const migration = source('src/database/migrations/1787767200000-phase-d5-3-7-2-relational-territory-plan.ts'); + assert.match(migration, /CREATE TABLE administrative_departments/); + assert.match(migration, /CREATE TABLE area_department_relations/); + assert.match(migration, /uq_administrative_department_name/); + assert.match(migration, /uq_area_department_relation_active_pair/); + assert.match(migration, /'DEPARTMENT'.*'AREA_DEPARTMENT_RELATION'.*'LEGAL_RIGHT'.*'LEGAL_RIGHT_ORGANIZATION'/s); + assert.match(migration, /REVOKE DELETE ON TABLE administrative_departments, area_department_relations/); +}); + +test('D5.3.7.2 territory planning deduplicates catalogs before relations and fields', () => { + const service = source('src/asset-imports/asset-imports.service.ts'); + const territory = service.slice(service.indexOf('private async buildTerritoryPlan'), service.indexOf('private async buildInventoryPlan')); + assert.match(territory, /const departments = new Map/); + assert.match(territory, /entityKind: 'DEPARTMENT'|entityKind:'DEPARTMENT'/); + assert.match(territory, /entityKind:'AREA_DEPARTMENT_RELATION'|entityKind: 'AREA_DEPARTMENT_RELATION'/); + assert.match(territory, /entityKind:'LEGAL_RIGHT'|entityKind: 'LEGAL_RIGHT'/); + assert.match(territory, /entityKind:'LEGAL_RIGHT_ORGANIZATION'|entityKind: 'LEGAL_RIGHT_ORGANIZATION'/); + assert.match(territory, /matchedDepartmentId/); + assert.match(territory, /matchedLegalRightId/); + assert.match(territory, /departmentEntityKeys/); + assert.match(territory, /legalRightEntityKeys/); +}); + +test('D5.3.7.2 applies relational objects transactionally without duplicating Department attributes', () => { + const service = source('src/asset-imports/asset-imports.service.ts'); + const apply = service.slice(service.indexOf('private async applyPlanItems'), service.indexOf('private async insertAssetItems')); + assert.match(apply, /INSERT INTO administrative_departments/); + assert.match(apply, /INSERT INTO area_department_relations/); + assert.match(apply, /INSERT INTO area_legal_rights/); + assert.match(apply, /INSERT INTO area_legal_right_organizations/); + assert.match(apply, /'PENDING'/); + assert.match(apply, /no se infiere titularidad/i); + const attributes = service.slice(service.indexOf('private async insertKnownAttributes'), service.indexOf('private async insertImportVersions')); + assert.doesNotMatch(attributes, /add\('departamento'/); + assert.match(service, /return `\(\$\$\{base\+1\}::integer,\$\$\{base\+2\}::uuid\)`/); + assert.match(service, /row\.row_number=v\.row_number/); +}); + +test('D5.3.7.2 rollback is logical for relational territory objects', () => { + const service = source('src/asset-imports/asset-imports.service.ts'); + const rollback = service.slice(service.indexOf('async rollbackPlan'), service.indexOf('async upload')); + assert.match(rollback, /area_department_relations SET valid_until=CURRENT_DATE/); + assert.match(rollback, /area_legal_rights SET status='REVOKED'/); + assert.match(rollback, /administrative_departments SET is_active=false/); + assert.doesNotMatch(rollback, /DELETE FROM administrative_departments|DELETE FROM area_department_relations|DELETE FROM area_legal_rights/); +}); + + +test('D5.3.7.2.3 stores an applied asset id without PostgreSQL parameter type ambiguity', () => { + const service = source('src/asset-imports/asset-imports.service.ts'); + assert.match(service, /applied_asset_id=\$2::uuid,applied_object_id=\(\$2::uuid\)::text/); + assert.doesNotMatch(service, /applied_asset_id=\$2,applied_object_id=\$2/); +}); + + +test('D5.3.7.2.4 snapshots the Maestro and blocks stale READY plans before any write', () => { + const service = source('src/asset-imports/asset-imports.service.ts'); + const generate = service.slice(service.indexOf('async generatePlan'), service.indexOf('async resolvePlanItem')); + const apply = service.slice(service.indexOf('async applyPlan'), service.indexOf('async rollbackPlan')); + assert.match(service, /private async masterStateFingerprint/); + assert.match(generate, /masterStateBefore = await this\.masterStateFingerprint/); + assert.match(generate, /masterStateHash = await this\.masterStateFingerprint/); + assert.match(generate, /ASSET_IMPORT_MASTER_CHANGED_DURING_PLAN/); + assert.match(generate, /masterStateHash,/); + assert.match(apply, /plannedMasterStateHash/); + assert.match(apply, /currentMasterStateHash/); + assert.match(apply, /El Maestro cambió desde que se generó este plan/); +}); + +test('D5.3.8 preserves operator-local hierarchy instead of inventing universal installation names', () => { + const service = source('src/asset-imports/asset-imports.service.ts'); + const planner = service.slice(service.indexOf('private async buildInventoryPlan'), service.indexOf('private async assetNameMatches')); + assert.match(planner, /sourceLocalStructureSuggestion/); + assert.match(planner, /entityKind:'LOCAL_STRUCTURE'/); + assert.match(planner, /assetTypeCode:'estructura_local'/); + assert.match(planner, /localNomenclature:true/); + assert.match(planner, /classificationStatus:'SOURCE_PRESERVED'/); + assert.doesNotMatch(planner, /\[A VALIDAR\]/); +}); + +test('D5.3.8 collapses territory ambiguity into one explicit context decision', () => { + const service = source('src/asset-imports/asset-imports.service.ts'); + assert.match(service, /contextDecisionItems=new Map/); + assert.match(service, /inventoryContextDecision:true/); + assert.match(service, /candidateOptions/); + assert.match(service, /PLAN_CONTEXT_DECISION_REQUIRED/); + assert.match(service, /inventoryContextOverrides/); + assert.match(service, /ASSET_IMPORT_CONTEXT_MATCH_INVALID/); +}); + +test('D5.3.8 migration adds the provisional local structure type and hierarchy rules', () => { + const migration = source('src/database/migrations/1787853600000-phase-d5-3-8-local-inventory-structure.ts'); + assert.match(migration, /'LOCAL_STRUCTURE'/); + assert.match(migration, /'estructura_local'/); + assert.match(migration, /Estructura local \(fuente\)/); + assert.match(migration, /nivel_fuente/); + assert.match(migration, /clasificacion_fuente/); + assert.match(migration, /ruta_fuente/); + assert.match(migration, /asset_type_parent_rules/); +}); diff --git a/api-v3/test/unit/asset-imports-d5-3-7.test.ts b/api-v3/test/unit/asset-imports-d5-3-7.test.ts new file mode 100644 index 0000000..17d2a01 --- /dev/null +++ b/api-v3/test/unit/asset-imports-d5-3-7.test.ts @@ -0,0 +1,159 @@ +import 'reflect-metadata'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import test from 'node:test'; +import { REQUIRED_PERMISSIONS_KEY } from '../../src/authorization/decorators/require-permissions.decorator'; +import { + externalIdNamespace, + generatedImportCode, + isExplicitlyUnassignedOperator, + planItemHash, + planStatusForItems, + summarizePlanItems, + isPlanDependencyReview, + sourceContainerName, + sourceLocalStructureSuggestion, + technicalFamilyTypeCode, + type AssetImportPlanDraftItem, +} from '../../src/asset-imports/asset-import-plan'; +import { AssetImportsController } from '../../src/asset-imports/asset-imports.controller'; + +function source(relative: string): string { return readFileSync(resolve(process.cwd(), relative), 'utf8'); } +function permissionFor(method: string): string[] { + const controller = AssetImportsController.prototype; + return Reflect.getMetadata(REQUIRED_PERMISSIONS_KEY, controller[method as keyof typeof controller]) as string[]; +} +function draft(overrides: Partial = {}): AssetImportPlanDraftItem { + return { + entityKey: 'technical:PSENERGY:abc', entityKind: 'TECHNICAL_ASSET', action: 'CREATE', status: 'PLANNED', + assetTypeCode: 'tanque', displayName: 'TK-1', generatedCode: 'IMP-TANQUE-ABC', parentEntityKey: 'field:a', matchedAssetId: null, + payload: { inventoryId: 'TK-1' }, sourceRowNumbers: [4], reviewCodes: [], ...overrides, + }; +} + +test('D5.3.7 separates planning from privileged application and rollback', () => { + assert.deepEqual(permissionFor('organizations'), ['asset_imports.read']); + assert.deepEqual(permissionFor('plan'), ['asset_imports.read']); + assert.deepEqual(permissionFor('generatePlan'), ['asset_imports.manage']); + assert.deepEqual(permissionFor('resolvePlanItem'), ['asset_imports.manage']); + assert.deepEqual(permissionFor('applyPlan'), ['asset_imports.apply']); + assert.deepEqual(permissionFor('rollbackPlan'), ['asset_imports.apply']); +}); + +test('D5.3.7 normalizes namespaces and technical families without losing source identity', () => { + assert.equal(externalIdNamespace(undefined, 'PSEnergy-Planilla de Inventario general PS MZA.xlsx'), 'PSENERGY'); + assert.equal(externalIdNamespace('Phoenix Mendoza', 'x.xlsx'), 'PHOENIX-MENDOZA'); + assert.equal(isExplicitlyUnassignedOperator('Sin Empresa Operadora'), true); + assert.equal(isExplicitlyUnassignedOperator('Petróleos Sudamericanos Energy S.A.'), false); + assert.equal(technicalFamilyTypeCode('bomba', 'centrifuga'), 'bomba'); + assert.equal(technicalFamilyTypeCode('válvula', 'presión y vacío'), 'equipo'); + assert.equal(sourceContainerName('PLANTA', 'PTC', 'MENDOZA / VIZCACHERAS / PTC / PTCVIZ01'), 'PTCVIZ01'); + assert.equal(sourceContainerName('YACIMIENTO', 'BATERIA', 'MENDOZA / VIZCACHERAS / BATERIA / BATVIZ10'), 'BATVIZ10'); + assert.equal(sourceContainerName('YACIMIENTO', 'TRANSPORTE', 'MENDOZA / VIZCACHERAS / TRANSPORTE'), null); + assert.deepEqual(sourceLocalStructureSuggestion('ATAMISQUI','YACIMIENTO','BATERIA','MENDOZA / ATAMISQUI / YACIMIENTO / BATATA01'), { + displayName:'BATATA01', sourcePath:['MENDOZA','ATAMISQUI','YACIMIENTO','BATATA01'], sourceInstallation:'YACIMIENTO', sourceSubInstallation:'BATERIA', concreteFromLocation:true, sourceGroupOnly:false, + }); + assert.deepEqual(sourceLocalStructureSuggestion('VIZCACHERAS','ENERGIA','SET','MENDOZA / VIZCACHERAS / ENERGIA'), { + displayName:'ENERGIA / SET', sourcePath:['MENDOZA','VIZCACHERAS','ENERGIA'], sourceInstallation:'ENERGIA', sourceSubInstallation:'SET', concreteFromLocation:false, sourceGroupOnly:true, + }); + assert.deepEqual(sourceLocalStructureSuggestion('BARRANCAS','YACIMIENTO','BATERIA','MENDOZA / BARRANCAS / BATERIA / BATBCA10 / POZO B-208'), { + displayName:'BATBCA10', sourcePath:['MENDOZA','BARRANCAS','BATERIA','BATBCA10','POZO B-208'], sourceInstallation:'YACIMIENTO', sourceSubInstallation:'BATERIA', concreteFromLocation:true, sourceGroupOnly:false, + }); + assert.match(generatedImportCode('11111111-1111-4111-8111-111111111111', 'tanque', 'technical:x'), /^IMP-TANQUE-[A-F0-9]{12}$/); +}); + +test('D5.3.7 plan hash is stable and review items block application', () => { + const one = draft(); + const two = draft({ sourceRowNumbers: [7, 4] }); + assert.equal(planItemHash([one]), planItemHash([{ ...one, reviewCodes: [], sourceRowNumbers: [4] }])); + assert.equal(planStatusForItems([one]), 'READY'); + assert.equal(planStatusForItems([two, draft({ entityKey: 'review', action: 'REVIEW', status: 'REVIEW', reviewCodes: ['GROUPED_QUANTITY'] })]), 'REVIEW_REQUIRED'); +}); + +test('D5.3.9 separates direct human decisions from automatic review dependencies', () => { + const direct = draft({ entityKey: 'direct', action: 'REVIEW', status: 'REVIEW', reviewCodes: ['PLAN_TERRITORY_AMBIGUOUS'] }); + const dependency = draft({ entityKey: 'dependency', action: 'REVIEW', status: 'REVIEW', reviewCodes: ['PLAN_CONTEXT_DECISION_REQUIRED'] }); + assert.equal(isPlanDependencyReview(direct), false); + assert.equal(isPlanDependencyReview(dependency), true); + const summary = summarizePlanItems([draft(), direct, dependency]); + assert.equal(summary.reviewItems, 2); + assert.equal(summary.directReviewItems, 1); + assert.equal(summary.dependencyReviewItems, 1); + assert.equal(summary.blocked, true); +}); + +test('D5.3.7 migration creates protected plan tables and apply permission', () => { + const migration = source('src/database/migrations/1787680800000-phase-d5-3-7-import-planning-application.ts'); + assert.match(migration, /CREATE TABLE asset_import_plans/); + assert.match(migration, /CREATE TABLE asset_import_plan_items/); + assert.match(migration, /asset_imports\.apply/); + assert.match(migration, /REVOKE DELETE ON TABLE asset_import_plans, asset_import_plan_items/); + assert.match(migration, /uq_asset_import_plan_active_batch/); +}); + +test('D5.3.7 warning rows require review and duplicate inventory IDs get row-scoped plan keys', () => { + const service = source('src/asset-imports/asset-imports.service.ts'); + assert.match(service, /status === 'CONFLICT' \|\| status === 'WARNING'/); + assert.ok(service.indexOf("status === 'CONFLICT' || status === 'WARNING'") < service.indexOf("candidates.size === 1"), 'source warnings must be reviewed before exact-match auto decisions'); + assert.match(service, /inventoryIdCounts/); + assert.match(service, /:row-\$\{row\.rowNumber\}/); + assert.match(service, /SOURCE_ROW_\$\{row\.status\}/); + assert.match(service, /DUPLICATE_INVENTORY_ID_IN_BATCH/); + assert.match(service, /ASSET_IMPORT_DUPLICATE_ID_UNRESOLVED/); +}); + +test('D5.3.7 applies a stale-safe plan transactionally and never deletes Maestro assets', () => { + const service = source('src/asset-imports/asset-imports.service.ts'); + const apply = service.slice(service.indexOf('async applyPlan'), service.indexOf('async rollbackPlan')); + const rollback = service.slice(service.indexOf('async rollbackPlan'), service.indexOf('async upload')); + assert.match(apply, /this\.dataSource\.transaction/); + assert.match(apply, /plan\.planHash !== dto\.planHash/); + assert.match(apply, /ASSET_IMPORT_PLAN_STALE/); + assert.match(rollback, /information_status='INACTIVE'/); + assert.match(rollback, /imported_asset_id=NULL/); + assert.doesNotMatch(`${apply}\n${rollback}`, /DELETE FROM assets/); +}); + +test('D5.3.7 validates physical parent rules and complete operational context before INSERT', () => { + const service = source('src/asset-imports/asset-imports.service.ts'); + assert.match(service, /asset_type_parent_rules/); + assert.match(service, /ASSET_IMPORT_PARENT_RULE_INVALID/); + assert.match(service, /ASSET_IMPORT_OPERATIONAL_CONTEXT_INCOMPLETE/); + assert.match(service, /const areaKey=String\(item\.payload\.operationalAreaEntityKey/); +}); + + +test('D5.3.7 structural reviews cannot be silently ignored and manual CREATE gets a deterministic code', () => { + const service = source('src/asset-imports/asset-imports.service.ts'); + assert.match(service, /item\.entityKind !== 'TECHNICAL_ASSET'/); + assert.match(service, /ASSET_IMPORT_STRUCTURAL_ITEM_REQUIRED/); + assert.match(service, /generatedImportCode\(plan\.id, item\.assetTypeCode, item\.entityKey\)/); + assert.match(service, /entityKind:'LOCAL_STRUCTURE'/); + assert.match(service, /classificationStatus:'SOURCE_PRESERVED'/); +}); + +test('D5.3.7 preserves UTE identity without inferring memberships from a display name', () => { + const service = source('src/asset-imports/asset-imports.service.ts'); + assert.match(service, /organizationKind:\/\^UTE\\b\/i\.test/); + assert.doesNotMatch(service.slice(service.indexOf('buildTerritoryPlan'), service.indexOf('buildInventoryPlan')), /INSERT INTO organization_memberships/); +}); + + +test('D5.3.7 keeps the explicit no-operator sentinel as absence, never as a fake company', () => { + const service = source('src/asset-imports/asset-imports.service.ts'); + assert.match(service, /const operatorUnassigned = isExplicitlyUnassignedOperator\(operator\)/); + assert.match(service, /operatorUnassigned \? '' : organizationImportKey\(operator\)/); + assert.match(service, /operatorAssignmentStatus:explicitlyUnassigned\?'UNASSIGNED_SOURCE':'ASSIGNED'/); + assert.match(service, /operationalAreaEntityKey:explicitlyUnassigned\?null:areaItem\.entityKey/); +}); + + +test('D5.3.7 operational context mismatches block silent MATCH and are revalidated at apply time', () => { + const service = source('src/asset-imports/asset-imports.service.ts'); + assert.match(service, /PLAN_FIELD_OPERATOR_CONTEXT_MISMATCH/); + assert.match(service, /PLAN_MATCH_OPERATIONAL_CONTEXT_MISMATCH/); + assert.match(service, /ASSET_IMPORT_MATCH_CONTEXT_BLOCKED/); + assert.match(service, /revalidateMatchedOperationalContext/); + assert.match(service, /current\.operationalAreaId!==expectedArea\|\|current\.operatorCompanyId!==expectedOperator/); +}); diff --git a/api-v3/test/unit/asset-imports-integrity.test.ts b/api-v3/test/unit/asset-imports-integrity.test.ts new file mode 100644 index 0000000..e6ce510 --- /dev/null +++ b/api-v3/test/unit/asset-imports-integrity.test.ts @@ -0,0 +1,40 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import test from 'node:test'; + +function source(relative: string): string { + return readFileSync(resolve(process.cwd(), relative), 'utf8'); +} + +test('D5.3.5 migration creates append-preserving import analysis tables and search indexes', () => { + const migration = source('src/database/migrations/1787594400000-phase-d5-3-5-import-center-scalability.ts'); + assert.match(migration, /CREATE EXTENSION IF NOT EXISTS pg_trgm/); + assert.match(migration, /CREATE TABLE asset_import_batches/); + assert.match(migration, /CREATE TABLE asset_import_rows/); + assert.match(migration, /asset_imports\.read/); + assert.match(migration, /asset_imports\.manage/); + assert.match(migration, /GRANT SELECT, INSERT, UPDATE ON TABLE asset_import_batches, asset_import_rows/); + assert.match(migration, /REVOKE DELETE ON TABLE asset_import_batches, asset_import_rows/); + assert.match(migration, /idx_assets_name_trgm/); + assert.match(migration, /idx_asset_external_identifiers_value_trgm/); +}); + +test('D5.3.5 import analysis itself does not create Maestro assets', () => { + const service = source('src/asset-imports/asset-imports.service.ts'); + const upload = service.slice(service.indexOf('async upload'), service.indexOf('async reconcile')); + assert.match(upload, /INSERT INTO asset_import_batches/); + assert.match(upload, /INSERT INTO source_documents/); + assert.doesNotMatch(upload, /INSERT INTO assets\s*\(/); + assert.doesNotMatch(upload, /UPDATE assets\s+SET/); + assert.match(service, /INSERT INTO asset_import_rows/); + assert.match(service, /FOR UPDATE OF batch/); +}); + +test('D5.3.5 hierarchy loads children on demand instead of returning the full tree', () => { + const controller = source('src/asset-master/assets.controller.ts'); + const service = source('src/asset-master/assets.service.ts'); + assert.match(controller, /@Get\('tree-children'\)/); + assert.match(service, /async treeChildren/); + assert.match(service, /query\.limit \+ 1/); +}); diff --git a/api-v3/test/unit/asset-master-authorization.test.ts b/api-v3/test/unit/asset-master-authorization.test.ts new file mode 100644 index 0000000..354997a --- /dev/null +++ b/api-v3/test/unit/asset-master-authorization.test.ts @@ -0,0 +1,36 @@ +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 { AssetTypesController } from '../../src/asset-master/asset-types.controller'; +import { AssetsController } from '../../src/asset-master/assets.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('AssetsController separates reading, editing and status permissions', () => { + const controller = AssetsController.prototype; + assert.deepEqual(permissionFor(controller, 'list'), ['assets.read']); + assert.deepEqual(permissionFor(controller, 'tree'), ['assets.read']); + assert.deepEqual(permissionFor(controller, 'treeChildren'), ['assets.read']); + assert.deepEqual(permissionFor(controller, 'parentOptions'), ['assets.read']); + assert.deepEqual(permissionFor(controller, 'getById'), ['assets.read']); + assert.deepEqual(permissionFor(controller, 'create'), ['assets.create']); + assert.deepEqual(permissionFor(controller, 'update'), ['assets.update']); + assert.deepEqual(permissionFor(controller, 'changeStatus'), ['assets.change_status']); + assert.deepEqual(permissionFor(controller, 'changeOperationalStatus'), ['assets.change_operational_status']); +}); + +test('AssetTypesController reserves configuration for asset_types.manage', () => { + const controller = AssetTypesController.prototype; + assert.deepEqual(permissionFor(controller, 'list'), ['asset_types.read']); + assert.deepEqual(permissionFor(controller, 'bootstrapStatus'), ['asset_types.read']); + assert.deepEqual(permissionFor(controller, 'bootstrapDefaults'), ['asset_types.manage']); + assert.deepEqual(permissionFor(controller, 'getById'), ['asset_types.read']); + assert.deepEqual(permissionFor(controller, 'create'), ['asset_types.manage']); + assert.deepEqual(permissionFor(controller, 'update'), ['asset_types.manage']); + assert.deepEqual(permissionFor(controller, 'createAttribute'), ['asset_types.manage']); + assert.deepEqual(permissionFor(controller, 'updateAttribute'), ['asset_types.manage']); +}); diff --git a/api-v3/test/unit/asset-master-bootstrap.test.ts b/api-v3/test/unit/asset-master-bootstrap.test.ts new file mode 100644 index 0000000..e9f7316 --- /dev/null +++ b/api-v3/test/unit/asset-master-bootstrap.test.ts @@ -0,0 +1,57 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + MASTER_BOOTSTRAP_CORE_CODES, + MASTER_BOOTSTRAP_PRESET_CODE, + MASTER_BOOTSTRAP_TYPES, +} from '../../src/asset-master/asset-master-bootstrap'; +import { AssetAttributeDataType, AssetTypeOperationalRole } from '../../src/database/entities'; + +test('D5.3.2 preset preserves the D5.3.1 anchors and operational roles', () => { + assert.equal(MASTER_BOOTSTRAP_PRESET_CODE, 'mendoza-hidrocarburos-v2'); + assert.deepEqual(MASTER_BOOTSTRAP_CORE_CODES, [ + 'area', 'empresa', 'yacimiento', 'instalacion', 'estacion', 'subestacion', 'pozo', 'equipo', 'ducto', + ]); + const byCode = new Map(MASTER_BOOTSTRAP_TYPES.map((type) => [type.code, type])); + assert.equal(byCode.get('area')?.operationalRole, AssetTypeOperationalRole.AREA); + assert.equal(byCode.get('empresa')?.operationalRole, AssetTypeOperationalRole.COMPANY); + assert.equal(byCode.get('equipo')?.operationalRole, AssetTypeOperationalRole.GENERIC); + for (const code of MASTER_BOOTSTRAP_CORE_CODES) assert.ok(byCode.has(code), `missing core type ${code}`); +}); + +test('D5.3.2 preset models technical inspection families instead of only generic Equipo', () => { + const codes = new Set(MASTER_BOOTSTRAP_TYPES.map((type) => type.code)); + for (const code of [ + 'locacion', 'planta', 'bateria', 'zona_bombas', 'sistema_drenaje', + 'sistema_electrico_iluminacion', 'sistema_defensa_incendios', + 'cargadero_descargadero', 'pileta_api', 'tanque', 'separador', 'bomba', + 'caldera', 'antorcha', 'colector', 'filtro', 'equipo_flotacion', + 'fwko', 'tratador', 'calentador', + ]) { + assert.ok(codes.has(code), `missing technical type ${code}`); + } + assert.ok(codes.has('equipo'), 'generic equipment fallback must remain available'); +}); + +test('D5.3.2 keeps one Pozo type and selects its operational family by attribute', () => { + const wells = MASTER_BOOTSTRAP_TYPES.filter((type) => type.code === 'pozo'); + assert.equal(wells.length, 1); + const method = wells[0].attributes.find((attribute) => attribute.code === 'metodo_extraccion'); + assert.equal(method?.dataType, AssetAttributeDataType.SELECT); + assert.deepEqual(method?.options, [ + 'Bombeo mecánico', + 'Bombeo electrosumergible', + 'Bombeo de cavidad progresiva (PCP)', + 'Surgente / productor de gas', + 'Inyector de agua', + 'Otro / a validar', + ]); +}); + +test('D5.3.2 preset adds technical attributes without embedding real operators or areas', () => { + const byCode = new Map(MASTER_BOOTSTRAP_TYPES.map((type) => [type.code, type])); + assert.ok(byCode.get('empresa')?.attributes.some((attribute) => attribute.code === 'cuit')); + assert.ok(byCode.get('tanque')?.attributes.some((attribute) => attribute.code === 'capacidad_nominal')); + assert.ok(byCode.get('equipo')?.attributes.some((attribute) => attribute.code === 'numero_serie')); + assert.equal(MASTER_BOOTSTRAP_TYPES.every((type) => !('assets' in type)), true); +}); diff --git a/api-v3/test/unit/asset-master-dto.test.ts b/api-v3/test/unit/asset-master-dto.test.ts new file mode 100644 index 0000000..58797b2 --- /dev/null +++ b/api-v3/test/unit/asset-master-dto.test.ts @@ -0,0 +1,35 @@ +import 'reflect-metadata'; +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { validate } from 'class-validator'; +import { CreateAssetDto } from '../../src/asset-master/dto/create-asset.dto'; +import { CreateAssetTypeDto } from '../../src/asset-master/dto/create-asset-type.dto'; + +const TYPE_ID = '16e54e65-60cf-4739-b0d1-ccdd904fbfd5'; + +test('CreateAssetTypeDto accepts a root type without fixed hierarchy levels', async () => { + const dto = Object.assign(new CreateAssetTypeDto(), { + code: 'empresa_operadora', + name: 'Empresa operadora', + description: 'Entidad administrable raíz', + canBeRoot: true, + allowedParentTypeIds: [], + }); + assert.deepEqual(await validate(dto), []); +}); + +test('CreateAssetDto validates identity, type and the attributes object', async () => { + const dto = Object.assign(new CreateAssetDto(), { + code: 'ACT-001', + name: 'Activo de prueba', + typeId: TYPE_ID, + parentId: null, + description: null, + attributes: {}, + }); + assert.deepEqual(await validate(dto), []); + + dto.code = 'código con espacios'; + const properties = new Set((await validate(dto)).map((error) => error.property)); + assert.equal(properties.has('code'), true); +}); diff --git a/api-v3/test/unit/asset-media-authorization.test.ts b/api-v3/test/unit/asset-media-authorization.test.ts new file mode 100644 index 0000000..0fa812a --- /dev/null +++ b/api-v3/test/unit/asset-media-authorization.test.ts @@ -0,0 +1,22 @@ +import 'reflect-metadata'; +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { AssetMediaController } from '../../src/asset-master/asset-media.controller'; +import { REQUIRED_PERMISSIONS_KEY } from '../../src/authorization/decorators/require-permissions.decorator'; + +function permissionFor(method: string): string[] { + const controller = AssetMediaController.prototype; + const handler = controller[method as keyof typeof controller]; + return Reflect.getMetadata(REQUIRED_PERMISSIONS_KEY, handler) as string[]; +} + +test('media reads and content use assets.read_media', () => { + assert.deepEqual(permissionFor('list'), ['assets.read_media']); + assert.deepEqual(permissionFor('content'), ['assets.read_media']); +}); + +test('media mutations use assets.manage_media', () => { + assert.deepEqual(permissionFor('upload'), ['assets.manage_media']); + assert.deepEqual(permissionFor('update'), ['assets.manage_media']); + assert.deepEqual(permissionFor('remove'), ['assets.manage_media']); +}); diff --git a/api-v3/test/unit/asset-media-dto.test.ts b/api-v3/test/unit/asset-media-dto.test.ts new file mode 100644 index 0000000..a77dc84 --- /dev/null +++ b/api-v3/test/unit/asset-media-dto.test.ts @@ -0,0 +1,34 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; +import { CreateAssetMediaDto } from '../../src/asset-master/dto/create-asset-media.dto'; +import { AssetMediaKind } from '../../src/database/entities'; + +test('multipart media metadata transforms valid numeric coordinates', async () => { + const dto = plainToInstance(CreateAssetMediaDto, { + kind: AssetMediaKind.PHOTO, + title: 'Cabezal del pozo', + capturedAt: '2026-08-14T12:00:00.000Z', + latitude: '-32.889500', + longitude: '-68.845800', + accuracyM: '3.250', + }); + + assert.deepEqual(await validate(dto), []); + assert.equal(dto.latitude, -32.8895); + assert.equal(dto.longitude, -68.8458); + assert.equal(dto.accuracyM, 3.25); +}); + +test('media metadata rejects unknown kinds and coordinate ranges', async () => { + const dto = plainToInstance(CreateAssetMediaDto, { + kind: 'VIDEO', + latitude: '91', + longitude: '-181', + }); + const properties = new Set((await validate(dto)).map((error) => error.property)); + assert.equal(properties.has('kind'), true); + assert.equal(properties.has('latitude'), true); + assert.equal(properties.has('longitude'), true); +}); diff --git a/api-v3/test/unit/asset-media-file.test.ts b/api-v3/test/unit/asset-media-file.test.ts new file mode 100644 index 0000000..57efbca --- /dev/null +++ b/api-v3/test/unit/asset-media-file.test.ts @@ -0,0 +1,40 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + inspectAssetFile, + MAX_ASSET_MEDIA_BYTES, +} from '../../src/asset-master/asset-media-file'; +import { AssetMediaKind } from '../../src/database/entities'; + +function file(buffer: Buffer, originalname: string) { + return { buffer, originalname, size: buffer.length }; +} + +test('file inspection trusts magic bytes instead of the declared extension', () => { + const result = inspectAssetFile( + file(Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00]), 'registro.pdf'), + AssetMediaKind.PHOTO, + ); + assert.equal(result.mimeType, 'image/jpeg'); + assert.equal(result.extension, '.jpg'); +}); + +test('file inspection rejects kind mismatches and executable text', () => { + assert.throws(() => inspectAssetFile( + file(Buffer.from('%PDF-1.7'), 'documento.pdf'), + AssetMediaKind.PHOTO, + )); + assert.throws(() => inspectAssetFile( + file(Buffer.from(''), 'foto.jpg'), + AssetMediaKind.PHOTO, + )); +}); + +test('file inspection enforces the 15 MB boundary', () => { + const oversized = { + buffer: Buffer.from([0xff, 0xd8, 0xff]), + originalname: 'foto.jpg', + size: MAX_ASSET_MEDIA_BYTES + 1, + }; + assert.throws(() => inspectAssetFile(oversized, AssetMediaKind.PHOTO)); +}); diff --git a/api-v3/test/unit/asset-navigation-d5-3-10-2.test.ts b/api-v3/test/unit/asset-navigation-d5-3-10-2.test.ts new file mode 100644 index 0000000..47e294d --- /dev/null +++ b/api-v3/test/unit/asset-navigation-d5-3-10-2.test.ts @@ -0,0 +1,30 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import test from 'node:test'; + +async function source(relativePath: string): Promise { + return readFile(resolve(process.cwd(), 'src', relativePath), 'utf8'); +} + +test('D5.3.10.2 exposes a read-only lineage endpoint for Maestro breadcrumbs', async () => { + const controller = await source('asset-master/assets.controller.ts'); + const service = await source('asset-master/assets.service.ts'); + + assert.match(controller, /@Get\(':id\/lineage'\)/); + assert.match(controller, /@RequirePermissions\('assets\.read'\)/); + assert.match(service, /async lineage\(id: string\)/); + assert.match(service, /WITH RECURSIVE lineage AS/); + assert.match(service, /ORDER BY lineage\.depth DESC/); +}); + +test('production API source keeps external-review hygiene', async () => { + const files = [ + 'main.ts', + 'asset-imports/asset-import-plan.ts', + 'inspection-operations/mobile-inspector-policy.ts', + 'asset-master/asset-master-bootstrap.ts', + ]; + const content = (await Promise.all(files.map(source))).join('\n'); + assert.doesNotMatch(content, /^\s*(?:\/\/|\/\*|\*)/m); +}); diff --git a/api-v3/test/unit/asset-navigation-d5-3-11-1.test.ts b/api-v3/test/unit/asset-navigation-d5-3-11-1.test.ts new file mode 100644 index 0000000..51fcb93 --- /dev/null +++ b/api-v3/test/unit/asset-navigation-d5-3-11-1.test.ts @@ -0,0 +1,19 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import test from 'node:test'; + +async function source(relativePath: string): Promise { + return readFile(resolve(process.cwd(), 'src', relativePath), 'utf8'); +} + +test('D5.3.11.1 exposes imported operational assignments in Maestro navigation without creating legal relations', async () => { + const service = await source('asset-master/asset-operational-relations.service.ts'); + + assert.match(service, /SELECT asset\.operator_company_id AS company_id/); + assert.match(service, /WHERE asset\.operational_area_id = \$1/); + assert.match(service, /SELECT asset\.operational_area_id AS area_id/); + assert.match(service, /WHERE asset\.operator_company_id = \$1/); + assert.match(service, /UNION/); + assert.doesNotMatch(service, /INSERT INTO area_company_relations[\s\S]*listAreasForCompany/); +}); diff --git a/api-v3/test/unit/asset-operational-relations-authorization.test.ts b/api-v3/test/unit/asset-operational-relations-authorization.test.ts new file mode 100644 index 0000000..038b1d0 --- /dev/null +++ b/api-v3/test/unit/asset-operational-relations-authorization.test.ts @@ -0,0 +1,21 @@ +import 'reflect-metadata'; +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { AssetOperationalRelationsController } from '../../src/asset-master/asset-operational-relations.controller'; +import { REQUIRED_PERMISSIONS_KEY } from '../../src/authorization/decorators/require-permissions.decorator'; + +function permissionFor(method: string): string[] { + const controller = AssetOperationalRelationsController.prototype; + const handler = controller[method as keyof typeof controller]; + return Reflect.getMetadata(REQUIRED_PERMISSIONS_KEY, handler) as string[]; +} + +test('D5.3 separates relation reading from relation management', () => { + assert.deepEqual(permissionFor('areas'), ['asset_relations.read']); + assert.deepEqual(permissionFor('companies'), ['asset_relations.read']); + assert.deepEqual(permissionFor('companiesForArea'), ['asset_relations.read']); + assert.deepEqual(permissionFor('areasForCompany'), ['asset_relations.read']); + assert.deepEqual(permissionFor('list'), ['asset_relations.read']); + assert.deepEqual(permissionFor('create'), ['asset_relations.manage']); + assert.deepEqual(permissionFor('end'), ['asset_relations.manage']); +}); diff --git a/api-v3/test/unit/asset-operational-relations-dto.test.ts b/api-v3/test/unit/asset-operational-relations-dto.test.ts new file mode 100644 index 0000000..5ce5947 --- /dev/null +++ b/api-v3/test/unit/asset-operational-relations-dto.test.ts @@ -0,0 +1,77 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; +import { CreateAreaCompanyRelationDto } from '../../src/asset-master/dto/create-area-company-relation.dto'; +import { EndAreaCompanyRelationDto } from '../../src/asset-master/dto/end-area-company-relation.dto'; +import { ListAreaCompanyRelationsQueryDto } from '../../src/asset-master/dto/list-area-company-relations-query.dto'; +import { ListOperationalAreasQueryDto } from '../../src/asset-master/dto/list-operational-areas-query.dto'; +import { CreateAssetTypeDto } from '../../src/asset-master/dto/create-asset-type.dto'; +import { AssetTypeOperationalRole } from '../../src/database/entities'; + +const AREA_ID = '11111111-1111-4111-8111-111111111111'; +const COMPANY_ID = '22222222-2222-4222-8222-222222222222'; + +test('D5.3 relation DTO trims reasons and accepts UUID v4 anchors', async () => { + const dto = plainToInstance(CreateAreaCompanyRelationDto, { + areaId: AREA_ID, + companyId: COMPANY_ID, + reason: ' Contrato de explotación vigente ', + }); + assert.deepEqual(await validate(dto), []); + assert.equal(dto.reason, 'Contrato de explotación vigente'); + + const end = plainToInstance(EndAreaCompanyRelationDto, { reason: ' Fin de contrato ' }); + assert.deepEqual(await validate(end), []); + assert.equal(end.reason, 'Fin de contrato'); +}); + +test('D5.3 relation DTO rejects incomplete identifiers and short reasons', async () => { + const dto = plainToInstance(CreateAreaCompanyRelationDto, { + areaId: 'area', + companyId: COMPANY_ID, + reason: 'x', + }); + const properties = new Set((await validate(dto)).map((error) => error.property)); + assert.equal(properties.has('areaId'), true); + assert.equal(properties.has('reason'), true); +}); + +test('D5.3 history filter accepts explicit booleans', async () => { + const query = plainToInstance(ListAreaCompanyRelationsQueryDto, { + areaId: AREA_ID, + includeHistory: 'true', + }); + assert.deepEqual(await validate(query), []); + assert.equal(query.includeHistory, true); +}); + + +test('D5.3 operational area filter validates the physical parent', async () => { + const valid = plainToInstance(ListOperationalAreasQueryDto, { parentId: AREA_ID }); + assert.deepEqual(await validate(valid), []); + const invalid = plainToInstance(ListOperationalAreasQueryDto, { parentId: 'parent' }); + assert.equal((await validate(invalid)).some((error) => error.property === 'parentId'), true); +}); + +test('D5.3 asset type operational role is constrained to the enum', async () => { + const valid = plainToInstance(CreateAssetTypeDto, { + code: 'area', + name: 'Área', + description: '', + canBeRoot: true, + operationalRole: AssetTypeOperationalRole.AREA, + allowedParentTypeIds: [], + }); + assert.deepEqual(await validate(valid), []); + + const invalid = plainToInstance(CreateAssetTypeDto, { + code: 'company', + name: 'Empresa', + description: '', + canBeRoot: true, + operationalRole: 'MULTI', + allowedParentTypeIds: [], + }); + assert.equal((await validate(invalid)).some((error) => error.property === 'operationalRole'), true); +}); diff --git a/api-v3/test/unit/asset-operational-relations-integrity.test.ts b/api-v3/test/unit/asset-operational-relations-integrity.test.ts new file mode 100644 index 0000000..214aad4 --- /dev/null +++ b/api-v3/test/unit/asset-operational-relations-integrity.test.ts @@ -0,0 +1,43 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import test from 'node:test'; + +async function source(relativePath: string): Promise { + return readFile(resolve(process.cwd(), 'src', relativePath), 'utf8'); +} + +test('D5.3 migration preserves physical hierarchy and adds operational context', async () => { + const migration = await source( + 'database/migrations/1787421600000-phase-d5-3-operational-context.ts', + ); + assert.match(migration, /CREATE TYPE asset_type_operational_role AS ENUM \('GENERIC', 'AREA', 'COMPANY'\)/); + assert.match(migration, /CREATE TABLE area_company_relations/); + assert.match(migration, /CREATE UNIQUE INDEX uq_area_company_relations_active_pair/); + assert.match(migration, /ADD COLUMN operational_area_id uuid/); + assert.match(migration, /ADD COLUMN operator_company_id uuid/); + assert.match(migration, /chk_assets_operational_assignment_pair/); + assert.match(migration, /operational area must be an ancestor in the physical hierarchy/); + assert.match(migration, /physical hierarchy change would invalidate descendant operational assignments/); + assert.match(migration, /area cannot be inactivated while operational relations or assignments are active/); + assert.match(migration, /FOR KEY SHARE/); + assert.match(migration, /REVOKE DELETE ON TABLE area_company_relations/); +}); + +test('D5.3 API enforces active relation, exclusive pair and physical ancestry', async () => { + const assets = await source('asset-master/assets.service.ts'); + assert.match(assets, /ASSET_OPERATIONAL_CONTEXT_INCOMPLETE/); + assert.match(assets, /ASSET_OPERATIONAL_RELATION_REQUIRED/); + assert.match(assets, /ASSET_OPERATIONAL_AREA_OUTSIDE_HIERARCHY/); + assert.match(assets, /ASSET_HIERARCHY_MOVE_BREAKS_OPERATIONAL_CONTEXT/); + assert.match(assets, /OPERATIONAL_ANCHOR_IN_USE/); +}); + +test('D5.3 relation lifecycle is historical and audited', async () => { + const service = await source('asset-master/asset-operational-relations.service.ts'); + assert.match(service, /INSERT INTO area_company_relations/); + assert.match(service, /valid_until = CURRENT_TIMESTAMP/); + assert.match(service, /AREA_COMPANY_RELATION_IN_USE/); + assert.match(service, /ASSET_AREA_COMPANY_RELATION_CREATED/); + assert.match(service, /ASSET_AREA_COMPANY_RELATION_ENDED/); +}); diff --git a/api-v3/test/unit/asset-provenance-authorization.test.ts b/api-v3/test/unit/asset-provenance-authorization.test.ts new file mode 100644 index 0000000..09eedef --- /dev/null +++ b/api-v3/test/unit/asset-provenance-authorization.test.ts @@ -0,0 +1,24 @@ +import 'reflect-metadata'; +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { AssetProvenanceController } from '../../src/asset-master/asset-provenance.controller'; +import { AssetTemporalController } from '../../src/asset-master/asset-temporal.controller'; +import { REQUIRED_PERMISSIONS_KEY } from '../../src/authorization/decorators/require-permissions.decorator'; + +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('provenance reads, updates and verification use separate permissions', () => { + const controller = AssetProvenanceController.prototype; + assert.deepEqual(permissionFor(controller, 'get'), ['assets.read_provenance']); + assert.deepEqual(permissionFor(controller, 'update'), ['assets.manage_provenance']); + assert.deepEqual(permissionFor(controller, 'verify'), ['assets.verify_provenance']); +}); + +test('all temporal reconstruction endpoints require temporal reading', () => { + const controller = AssetTemporalController.prototype; + assert.deepEqual(permissionFor(controller, 'list'), ['assets.read_temporal']); + assert.deepEqual(permissionFor(controller, 'get'), ['assets.read_temporal']); +}); diff --git a/api-v3/test/unit/asset-provenance-dto.test.ts b/api-v3/test/unit/asset-provenance-dto.test.ts new file mode 100644 index 0000000..5010387 --- /dev/null +++ b/api-v3/test/unit/asset-provenance-dto.test.ts @@ -0,0 +1,45 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; +import { UpdateAssetProvenanceDto } from '../../src/asset-master/dto/update-asset-provenance.dto'; +import { ListTemporalAssetsQueryDto } from '../../src/asset-master/dto/list-temporal-assets-query.dto'; +import { AssetDataOrigin } from '../../src/database/entities'; + +test('provenance metadata accepts controlled origins and trims sources', async () => { + const dto = plainToInstance(UpdateAssetProvenanceDto, { + origin: AssetDataOrigin.PROVIDED_DOCUMENT, + sourceName: ' Expediente 2026-14 ', + observedAt: '2026-08-14T15:00:00.000Z', + }); + assert.deepEqual(await validate(dto), []); + assert.equal(dto.sourceName, 'Expediente 2026-14'); +}); + +test('provenance metadata rejects unknown origins and invalid dates', async () => { + const dto = plainToInstance(UpdateAssetProvenanceDto, { + origin: 'INTERNET', + observedAt: 'ayer', + }); + const properties = new Set((await validate(dto)).map((error) => error.property)); + assert.equal(properties.has('origin'), true); + assert.equal(properties.has('observedAt'), true); +}); + +test('temporal filters require a valid instant and bounded pagination', async () => { + const valid = plainToInstance(ListTemporalAssetsQueryDto, { + at: '2026-08-14T15:00:00.000Z', + page: '2', + pageSize: '50', + }); + assert.deepEqual(await validate(valid), []); + assert.equal(valid.page, 2); + + const invalid = plainToInstance(ListTemporalAssetsQueryDto, { + at: 'fecha', + pageSize: '101', + }); + const properties = new Set((await validate(invalid)).map((error) => error.property)); + assert.equal(properties.has('at'), true); + assert.equal(properties.has('pageSize'), true); +}); diff --git a/api-v3/test/unit/asset-provenance-service.test.ts b/api-v3/test/unit/asset-provenance-service.test.ts new file mode 100644 index 0000000..af7c6e7 --- /dev/null +++ b/api-v3/test/unit/asset-provenance-service.test.ts @@ -0,0 +1,26 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { BadRequestException } from '@nestjs/common'; +import type { DataSource } from 'typeorm'; +import { AssetProvenanceService } from '../../src/asset-master/asset-provenance.service'; +import { UpdateAssetProvenanceDto } from '../../src/asset-master/dto/update-asset-provenance.dto'; +import { AssetDataOrigin } from '../../src/database/entities'; + +test('document and import provenance require an identified source', async () => { + const service = new AssetProvenanceService( + {} as DataSource, + {} as never, + {} as never, + ); + const dto = Object.assign(new UpdateAssetProvenanceDto(), { + origin: AssetDataOrigin.IMPORT, + sourceName: null, + }); + + await assert.rejects( + service.update('asset-id', dto, {} as never, {} as never), + (error: unknown) => error instanceof BadRequestException + && error.getResponse() instanceof Object + && (error.getResponse() as { code?: string }).code === 'PROVENANCE_SOURCE_REQUIRED', + ); +}); diff --git a/api-v3/test/unit/asset-registry-authorization.test.ts b/api-v3/test/unit/asset-registry-authorization.test.ts new file mode 100644 index 0000000..b9d85b4 --- /dev/null +++ b/api-v3/test/unit/asset-registry-authorization.test.ts @@ -0,0 +1,18 @@ +import 'reflect-metadata'; +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { AssetRegistryController } from '../../src/asset-master/asset-registry.controller'; +import { REQUIRED_PERMISSIONS_KEY } from '../../src/authorization/decorators/require-permissions.decorator'; + +function permissionFor(method: string): string[] { + const handler = AssetRegistryController.prototype[method as keyof AssetRegistryController]; + return Reflect.getMetadata(REQUIRED_PERMISSIONS_KEY, handler) as string[]; +} + +test('D5.3.3 registry separates consultation and administration', () => { + assert.deepEqual(permissionFor('get'), ['asset_registry.read']); + assert.deepEqual(permissionFor('documents'), ['asset_registry.read']); + for (const method of ['createDocument','profile','addMembership','endMembership','link','identifier','endIdentifier','legalRight','updateRight','rightOrg','endRightOrg']) { + assert.deepEqual(permissionFor(method), ['asset_registry.manage']); + } +}); diff --git a/api-v3/test/unit/asset-registry-dto.test.ts b/api-v3/test/unit/asset-registry-dto.test.ts new file mode 100644 index 0000000..4d9c7dd --- /dev/null +++ b/api-v3/test/unit/asset-registry-dto.test.ts @@ -0,0 +1,28 @@ +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 { AddOrganizationMembershipDto } from '../../src/asset-master/dto/add-organization-membership.dto'; +import { CreateExternalIdentifierDto } from '../../src/asset-master/dto/create-external-identifier.dto'; +import { UpsertOrganizationProfileDto } from '../../src/asset-master/dto/upsert-organization-profile.dto'; + +const uuid = '550e8400-e29b-41d4-a716-446655440000'; + +test('D5.3.3 organization profile accepts explicit UTE kind', async () => { + const dto = plainToInstance(UpsertOrganizationProfileDto, { organizationKind:'UTE', legalName:'UTE Prueba' }); + assert.equal((await validate(dto)).length, 0); +}); + +test('D5.3.3 membership validates participant percentage', async () => { + const valid = plainToInstance(AddOrganizationMembershipDto, { memberOrganizationId:uuid, role:'MEMBER', participationPercent:50 }); + assert.equal((await validate(valid)).length, 0); + const invalid = plainToInstance(AddOrganizationMembershipDto, { memberOrganizationId:uuid, role:'MEMBER', participationPercent:101 }); + assert.ok((await validate(invalid)).length > 0); +}); + +test('D5.3.3 external identifiers normalize namespace', async () => { + const dto = plainToInstance(CreateExternalIdentifierDto, { namespace:'sen', value:'12345' }); + assert.equal(dto.namespace, 'SEN'); + assert.equal((await validate(dto)).length, 0); +}); diff --git a/api-v3/test/unit/asset-temporal-service.test.ts b/api-v3/test/unit/asset-temporal-service.test.ts new file mode 100644 index 0000000..4b2b6f4 --- /dev/null +++ b/api-v3/test/unit/asset-temporal-service.test.ts @@ -0,0 +1,27 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { DataSource } from 'typeorm'; +import { AssetTemporalService } from '../../src/asset-master/asset-temporal.service'; +import { ListTemporalAssetsQueryDto } from '../../src/asset-master/dto/list-temporal-assets-query.dto'; + +test('temporal reconstruction parameterizes dates and user searches', async () => { + const calls: Array<{ sql: string; parameters: unknown[] }> = []; + const dataSource = { + query: async (sql: string, parameters: unknown[]) => { + calls.push({ sql, parameters }); + return calls.length === 1 ? [{ total: 0 }] : []; + }, + } as unknown as DataSource; + const service = new AssetTemporalService(dataSource); + const query = Object.assign(new ListTemporalAssetsQueryDto(), { + at: '2026-08-14T15:00:00.000Z', + search: "ACT' OR 1=1 --", + }); + + const result = await service.list(query); + assert.equal(result.meta.total, 0); + assert.equal(calls[0].sql.includes("ACT' OR 1=1"), false); + assert.match(calls[0].sql, /occurred_at <= \$1/); + assert.match(calls[0].sql, /ILIKE \$2/); + assert.equal(calls[0].parameters[1], "%ACT' OR 1=1 --%"); +}); diff --git a/api-v3/test/unit/asset-version-diff.test.ts b/api-v3/test/unit/asset-version-diff.test.ts new file mode 100644 index 0000000..cf18702 --- /dev/null +++ b/api-v3/test/unit/asset-version-diff.test.ts @@ -0,0 +1,47 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { changedSnapshotFields } from '../../src/asset-master/asset-version-diff'; + +const base = { + code: 'ACT-001', + name: 'Pozo 1', + description: null, + type: { id: 'type-1', code: 'WELL', name: 'Pozo' }, + parent: null, + informationStatus: 'DRAFT', + attributes: [{ code: 'depth', value: 1200 }], + geometry: null, + updatedAt: '2026-08-14T10:00:00.000Z', +}; + +test('version diff ignores technical timestamps and detects functional fields', () => { + const current = { + ...base, + informationStatus: 'SURVEYED', + updatedAt: '2026-08-14T11:00:00.000Z', + }; + + assert.deepEqual(changedSnapshotFields(base, current), ['informationStatus']); +}); + +test('version diff detects attribute and geometry changes', () => { + const current = { + ...base, + attributes: [{ code: 'depth', value: 1300 }], + geometry: { geometryType: 'POINT', geometry: { type: 'POINT', coordinates: [-68, -32] } }, + }; + + assert.deepEqual(changedSnapshotFields(base, current), ['attributes', 'geometry']); +}); + +test('version diff detects media changes independently', () => { + const previous = { ...base, media: [] }; + const current = { ...base, media: [{ id: 'media-1', sha256: 'abc' }] }; + assert.deepEqual(changedSnapshotFields(previous, current), ['media']); +}); + +test('version diff detects provenance changes independently', () => { + const previous = { ...base, provenance: { origin: 'MANUAL', verifiedAt: null } }; + const current = { ...base, provenance: { origin: 'FIELD_SURVEY', verifiedAt: null } }; + assert.deepEqual(changedSnapshotFields(previous, current), ['provenance']); +}); diff --git a/api-v3/test/unit/audit-controller.test.ts b/api-v3/test/unit/audit-controller.test.ts new file mode 100644 index 0000000..d6a5638 --- /dev/null +++ b/api-v3/test/unit/audit-controller.test.ts @@ -0,0 +1,16 @@ +import 'reflect-metadata'; +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { AuditController } from '../../src/audit/audit.controller'; +import { REQUIRED_PERMISSIONS_KEY } from '../../src/authorization/decorators/require-permissions.decorator'; + +function permissionFor(method: string): string[] { + const prototype = AuditController.prototype; + const handler = prototype[method as keyof typeof prototype]; + return Reflect.getMetadata(REQUIRED_PERMISSIONS_KEY, handler) as string[]; +} + +test('AuditController protects list and detail with audit.read', () => { + assert.deepEqual(permissionFor('list'), ['audit.read']); + assert.deepEqual(permissionFor('getById'), ['audit.read']); +}); diff --git a/api-v3/test/unit/audit-query-dto.test.ts b/api-v3/test/unit/audit-query-dto.test.ts new file mode 100644 index 0000000..fff5073 --- /dev/null +++ b/api-v3/test/unit/audit-query-dto.test.ts @@ -0,0 +1,36 @@ +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 { ListAuditQueryDto } from '../../src/audit/dto/list-audit-query.dto'; +import { AuditSource } from '../../src/database/entities'; + +test('ListAuditQueryDto transforms pagination and normalized enums', async () => { + const dto = plainToInstance(ListAuditQueryDto, { + page: '2', + pageSize: '50', + action: ' user_created ', + source: 'web', + from: '2026-08-01T00:00:00.000Z', + }); + + assert.deepEqual(await validate(dto), []); + assert.equal(dto.page, 2); + assert.equal(dto.pageSize, 50); + assert.equal(dto.action, 'USER_CREATED'); + assert.equal(dto.source, AuditSource.WEB); +}); + +test('ListAuditQueryDto rejects oversized pages and invalid dates', async () => { + const dto = plainToInstance(ListAuditQueryDto, { + page: '0', + pageSize: '500', + from: 'fecha-invalida', + }); + const properties = new Set((await validate(dto)).map((error) => error.property)); + + assert.equal(properties.has('page'), true); + assert.equal(properties.has('pageSize'), true); + assert.equal(properties.has('from'), true); +}); diff --git a/api-v3/test/unit/audit-query.test.ts b/api-v3/test/unit/audit-query.test.ts new file mode 100644 index 0000000..f110390 --- /dev/null +++ b/api-v3/test/unit/audit-query.test.ts @@ -0,0 +1,84 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { BadRequestException } from '@nestjs/common'; +import type { DataSource, EntityManager } from 'typeorm'; +import { AuditQueryService } from '../../src/audit/audit-query.service'; +import { buildAuditFilters } from '../../src/audit/audit-query'; +import { ListAuditQueryDto } from '../../src/audit/dto/list-audit-query.dto'; +import { AuditSource } from '../../src/database/entities'; + +test('buildAuditFilters parameterizes every audit filter', () => { + const query = Object.assign(new ListAuditQueryDto(), { + actorUsername: 'admin', + action: 'USER_CREATED', + source: AuditSource.WEB, + entityType: 'user', + requestId: 'request-12345678', + from: '2026-08-01T00:00:00.000Z', + to: '2026-08-31T23:59:59.999Z', + search: "anything' OR 1=1 --", + }); + + const filters = buildAuditFilters(query); + assert.match(filters.clause, /event\.actor_username ILIKE \$1/); + assert.match(filters.clause, /event\.action = \$2/); + assert.match(filters.clause, /event\.source = \$3/); + assert.match(filters.clause, /event\.occurred_at >= \$6/); + assert.match(filters.clause, /event\.occurred_at <= \$7/); + assert.match(filters.clause, /event\.request_id ILIKE \$8/); + assert.equal(filters.clause.includes("anything' OR 1=1"), false); + assert.equal(filters.parameters[7], "%anything' OR 1=1 --%"); +}); + +test('AuditQueryService rejects an inverted date range before querying', async () => { + const dataSource = { + transaction: () => { + throw new Error('transaction should not run'); + }, + } as unknown as DataSource; + const service = new AuditQueryService(dataSource); + const query = Object.assign(new ListAuditQueryDto(), { + from: '2026-08-31T00:00:00.000Z', + to: '2026-08-01T00:00:00.000Z', + }); + + await assert.rejects( + service.list(query), + (error: unknown) => + error instanceof BadRequestException && error.getStatus() === 400, + ); +}); + +test('AuditQueryService returns stable pagination metadata', async () => { + let calls = 0; + const manager = { + query: async () => { + calls += 1; + return calls === 1 + ? [{ total: 26 }] + : [ + { + id: '0f45472b-541b-4d6c-969f-0821463d0ea7', + action: 'USER_CREATED', + }, + ]; + }, + } as unknown as EntityManager; + const dataSource = { + transaction: async ( + _isolation: string, + operation: (entityManager: EntityManager) => Promise, + ) => operation(manager), + } as unknown as DataSource; + const service = new AuditQueryService(dataSource); + const query = Object.assign(new ListAuditQueryDto(), { + page: 2, + pageSize: 25, + }); + + const result = await service.list(query); + assert.equal(result.meta.total, 26); + assert.equal(result.meta.totalPages, 2); + assert.equal(result.meta.page, 2); + assert.equal(result.data.length, 1); +}); diff --git a/api-v3/test/unit/audit-sanitizer.test.ts b/api-v3/test/unit/audit-sanitizer.test.ts new file mode 100644 index 0000000..c79646a --- /dev/null +++ b/api-v3/test/unit/audit-sanitizer.test.ts @@ -0,0 +1,36 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { sanitizeAuditData } from '../../src/audit/audit-sanitizer'; + +test('sanitizeAuditData redacts sensitive keys at every level', () => { + const input = { + username: 'inspector', + password: 'hidden', + nested: { + password_hash: 'hidden', + refresh_token: 'hidden', + safe: 'visible', + }, + entries: [{ Authorization: 'Bearer hidden' }], + }; + + assert.deepEqual(sanitizeAuditData(input), { + username: 'inspector', + password: '[REDACTED]', + nested: { + password_hash: '[REDACTED]', + refresh_token: '[REDACTED]', + safe: 'visible', + }, + entries: [{ Authorization: '[REDACTED]' }], + }); +}); + +test('sanitizeAuditData handles circular data without throwing', () => { + const input: Record = { value: 1 }; + input.self = input; + assert.deepEqual(sanitizeAuditData(input), { + value: 1, + self: '[CIRCULAR]', + }); +}); diff --git a/api-v3/test/unit/dashboard-controller.test.ts b/api-v3/test/unit/dashboard-controller.test.ts new file mode 100644 index 0000000..72ff5f5 --- /dev/null +++ b/api-v3/test/unit/dashboard-controller.test.ts @@ -0,0 +1,21 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; +import 'reflect-metadata'; +import { REQUIRED_PERMISSIONS_KEY } from '../../src/authorization/decorators/require-permissions.decorator'; +import { DashboardController } from '../../src/dashboard/dashboard.controller'; + +test('dashboard summary requires dashboard.read', () => { + const permissions = Reflect.getMetadata( + REQUIRED_PERMISSIONS_KEY, + DashboardController.prototype.summary, + ); + assert.deepEqual(permissions, ['dashboard.read']); +}); + +test('dashboard controller delegates summary', async () => { + const expected = { counts: {}, recentAudit: [] }; + const controller = new DashboardController({ + summary: async () => expected, + } as never); + assert.equal(await controller.summary(), expected); +}); diff --git a/api-v3/test/unit/dashboard-service.test.ts b/api-v3/test/unit/dashboard-service.test.ts new file mode 100644 index 0000000..3281fef --- /dev/null +++ b/api-v3/test/unit/dashboard-service.test.ts @@ -0,0 +1,53 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; +import { DashboardService } from '../../src/dashboard/dashboard.service'; + +test('DashboardService returns numeric counts and recent activity consistently', async () => { + const recent = [{ + id: 'event-id', + occurredAt: new Date('2026-08-13T12:00:00.000Z'), + actorUsername: 'admin', + action: 'AUTH_LOGIN_SUCCESS', + entityType: 'auth_session', + entityId: 'session-id', + }]; + const manager = { + query: async (sql: string) => { + if (sql.includes('COUNT(*) FROM users')) { + return [{ + activeUsers: '4', inactiveUsers: '2', activeSessions: '3', + totalAssets: '12', assetsNeedValidation: '4', assetsWithoutGeometry: '6', plannedInspections: '2', + openFindings: '8', awaitingCompanyResponse: '5', overdueCompanyResponses: '2', + companyResponsesDueNext7Days: '1', awaitingVerificationSchedule: '4', + overdueControls: '2', controlsNext30Days: '3', + }]; + } + if (sql.includes('FROM inspection_findings finding')) return []; + return recent; + }, + }; + const dataSource = { + transaction: async (_isolation: string, operation: (value: typeof manager) => unknown) => operation(manager), + }; + + const result = await new DashboardService(dataSource as never).summary(); + assert.deepEqual(result.counts, { + activeUsers: 4, + inactiveUsers: 2, + activeSessions: 3, + totalAssets: 12, + assetsNeedValidation: 4, + assetsWithoutGeometry: 6, + plannedInspections: 2, + openFindings: 8, + awaitingCompanyResponse: 5, + overdueCompanyResponses: 2, + companyResponsesDueNext7Days: 1, + awaitingVerificationSchedule: 4, + overdueControls: 2, + controlsNext30Days: 3, + }); + assert.equal(result.recentAudit, recent); + assert.deepEqual(result.upcomingControls, []); + assert.equal(typeof result.generatedAt, 'string'); +}); diff --git a/api-v3/test/unit/finding-catalog-administration-integrity.test.ts b/api-v3/test/unit/finding-catalog-administration-integrity.test.ts new file mode 100644 index 0000000..f443cdc --- /dev/null +++ b/api-v3/test/unit/finding-catalog-administration-integrity.test.ts @@ -0,0 +1,30 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import test from 'node:test'; + +async function source(relativePath: string): Promise { + return readFile(resolve(process.cwd(), 'src', relativePath), 'utf8'); +} + +test('D5.2 versions every catalog item and keeps versions append-only', async () => { + const migration = await source( + 'database/migrations/1787335200000-phase-d5-2-finding-catalog-administration.ts', + ); + assert.match(migration, /CREATE TABLE finding_catalog_item_versions/); + assert.match(migration, /UNIQUE \(item_id, revision\)/); + assert.match(migration, /jsonb_typeof\(snapshot\) = 'object'/); + assert.match(migration, /INSERT INTO finding_catalog_item_versions/); + assert.match(migration, /FROM finding_catalog_items item/); + assert.match(migration, /GRANT SELECT, INSERT ON TABLE finding_catalog_item_versions/); + assert.match(migration, /REVOKE UPDATE, DELETE ON TABLE finding_catalog_item_versions/); +}); + +test('D5.2 catalog administration captures audit and immutable revisions', async () => { + const service = await source('inspection-findings/finding-catalog.service.ts'); + assert.match(service, /item\.revision \+= 1/); + assert.match(service, /captureVersion\(manager, created, principal\)/); + assert.match(service, /captureVersion\(manager, updated, principal\)/); + assert.match(service, /FINDING_CATEGORY_CREATED/); + assert.match(service, /FINDING_CATALOG_ITEM_UPDATED/); +}); diff --git a/api-v3/test/unit/finding-catalog-seed.test.ts b/api-v3/test/unit/finding-catalog-seed.test.ts new file mode 100644 index 0000000..0199304 --- /dev/null +++ b/api-v3/test/unit/finding-catalog-seed.test.ts @@ -0,0 +1,21 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + PHASE_D3_FINDING_CATALOG_ITEMS, + PHASE_D3_FINDING_CATEGORIES, +} from '../../src/database/seeds/phase-d3-finding-catalog'; + +test('D3 catalog contains 19 categories and 170 unique finding templates', () => { + assert.equal(PHASE_D3_FINDING_CATEGORIES.length, 19); + assert.equal(PHASE_D3_FINDING_CATALOG_ITEMS.length, 170); + assert.equal( + new Set(PHASE_D3_FINDING_CATALOG_ITEMS.map((item) => item.code)).size, + 170, + ); +}); + +test('D3 catalog records the repaired shifted pump row', () => { + const repaired = PHASE_D3_FINDING_CATALOG_ITEMS.find((item) => item.code === 'BOMBAS-003'); + assert.equal(repaired?.title, 'PUESTA A TIERRA'); + assert.match(repaired?.importNote ?? '', /celda de título contenía el número 4/i); +}); diff --git a/api-v3/test/unit/inspection-acts-authorization.test.ts b/api-v3/test/unit/inspection-acts-authorization.test.ts new file mode 100644 index 0000000..770af88 --- /dev/null +++ b/api-v3/test/unit/inspection-acts-authorization.test.ts @@ -0,0 +1,34 @@ +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 { + InspectionActsController, + InspectionVisitActsController, +} from '../../src/inspection-acts/inspection-acts.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('inspection act endpoints separate reading, creation, editing and cancellation', () => { + assert.deepEqual(permissionFor(InspectionVisitActsController.prototype, 'list'), [ + 'inspection_acts.read', + ]); + assert.deepEqual(permissionFor(InspectionVisitActsController.prototype, 'create'), [ + 'inspection_acts.create', + ]); + assert.deepEqual(permissionFor(InspectionActsController.prototype, 'list'), [ + 'inspection_acts.read', + ]); + assert.deepEqual(permissionFor(InspectionActsController.prototype, 'get'), [ + 'inspection_acts.read', + ]); + assert.deepEqual(permissionFor(InspectionActsController.prototype, 'update'), [ + 'inspection_acts.update', + ]); + assert.deepEqual(permissionFor(InspectionActsController.prototype, 'cancel'), [ + 'inspection_acts.cancel', + ]); +}); diff --git a/api-v3/test/unit/inspection-acts-dto.test.ts b/api-v3/test/unit/inspection-acts-dto.test.ts new file mode 100644 index 0000000..14297c4 --- /dev/null +++ b/api-v3/test/unit/inspection-acts-dto.test.ts @@ -0,0 +1,41 @@ +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 { CancelInspectionActDto } from '../../src/inspection-acts/dto/cancel-inspection-act.dto'; +import { CreateInspectionActDto } from '../../src/inspection-acts/dto/create-inspection-act.dto'; + +const ASSET_ID = '16e54e65-60cf-4739-b0d1-ccdd904fbfd5'; + +test('inspection act DTO accepts content and selected visit assets', async () => { + const dto = plainToInstance(CreateInspectionActDto, { + occurredAt: '2026-08-14T15:30:00.000Z', + title: ' Verificación de instalación ', + summary: ' Se inspeccionó el activo seleccionado. ', + observations: ' Sin novedades. ', + assetIds: [ASSET_ID], + }); + assert.deepEqual(await validate(dto), []); + assert.equal(dto.title, 'Verificación de instalación'); + assert.equal(dto.summary, 'Se inspeccionó el activo seleccionado.'); + assert.equal(dto.observations, 'Sin novedades.'); +}); + +test('inspection act DTO rejects duplicate assets', async () => { + const dto = plainToInstance(CreateInspectionActDto, { + occurredAt: '2026-08-14T15:30:00.000Z', + title: 'Acta', + summary: 'Contenido', + assetIds: [ASSET_ID, ASSET_ID], + }); + assert.equal((await validate(dto)).some((error) => error.property === 'assetIds'), true); +}); + +test('cancellation requires a meaningful reason', async () => { + const invalid = plainToInstance(CancelInspectionActDto, { reason: ' breve ' }); + const valid = plainToInstance(CancelInspectionActDto, { reason: ' Error de carga confirmado ' }); + assert.equal((await validate(invalid)).some((error) => error.property === 'reason'), true); + assert.deepEqual(await validate(valid), []); + assert.equal(valid.reason, 'Error de carga confirmado'); +}); diff --git a/api-v3/test/unit/inspection-acts-single-act.test.ts b/api-v3/test/unit/inspection-acts-single-act.test.ts new file mode 100644 index 0000000..7b7768f --- /dev/null +++ b/api-v3/test/unit/inspection-acts-single-act.test.ts @@ -0,0 +1,40 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { ConflictException } from '@nestjs/common'; +import type { DataSource, EntityManager } from 'typeorm'; +import { InspectionActsService } from '../../src/inspection-acts/inspection-acts.service'; + +type SingleActGuard = { + assertVisitHasNoAct(manager: EntityManager, visitId: string): Promise; +}; + +function serviceGuard(): SingleActGuard { + return new InspectionActsService( + {} as DataSource, + {} as never, + ) as unknown as SingleActGuard; +} + +test('single-act guard allows a visit without an act', async () => { + const manager = { + query: async () => [], + } as unknown as EntityManager; + + await assert.doesNotReject( + serviceGuard().assertVisitHasNoAct(manager, 'visit-id'), + ); +}); + +test('single-act guard rejects a second act for the same visit', async () => { + const manager = { + query: async () => [{ code: 'ACTA-2026-000001' }], + } as unknown as EntityManager; + + await assert.rejects( + serviceGuard().assertVisitHasNoAct(manager, 'visit-id'), + (error: unknown) => error instanceof ConflictException + && error.getResponse() instanceof Object + && (error.getResponse() as { code?: string }).code + === 'INSPECTION_VISIT_ACT_ALREADY_EXISTS', + ); +}); diff --git a/api-v3/test/unit/inspection-closing-authorization.test.ts b/api-v3/test/unit/inspection-closing-authorization.test.ts new file mode 100644 index 0000000..66b8037 --- /dev/null +++ b/api-v3/test/unit/inspection-closing-authorization.test.ts @@ -0,0 +1,25 @@ +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 { + InspectionClosingController, + InspectionSignatureContentController, +} from '../../src/inspection-closing/inspection-closing.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('D5 closing endpoints separate read, prepare, sign and close permissions', () => { + 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(InspectionClosingController.prototype, 'inspectorSignature'), ['inspection_closure.sign']); + assert.deepEqual(permissionFor(InspectionClosingController.prototype, 'companySignature'), ['inspection_closure.sign']); + assert.deepEqual(permissionFor(InspectionClosingController.prototype, 'companyOutcome'), ['inspection_closure.sign']); + assert.deepEqual(permissionFor(InspectionClosingController.prototype, 'close'), ['inspection_closure.close']); + assert.deepEqual(permissionFor(InspectionSignatureContentController.prototype, 'content'), ['inspection_closure.read']); +}); diff --git a/api-v3/test/unit/inspection-closing-canonical.test.ts b/api-v3/test/unit/inspection-closing-canonical.test.ts new file mode 100644 index 0000000..cc0069a --- /dev/null +++ b/api-v3/test/unit/inspection-closing-canonical.test.ts @@ -0,0 +1,18 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { canonicalJson, sha256CanonicalJson } from '../../src/inspection-closing/canonical-json'; + +test('D5 canonical JSON sorts object keys recursively and preserves array order', () => { + const left = { z: 3, nested: { y: 2, x: 1 }, list: [{ b: 2, a: 1 }, 4] }; + const right = { list: [{ a: 1, b: 2 }, 4], nested: { x: 1, y: 2 }, z: 3 }; + assert.equal(canonicalJson(left), canonicalJson(right)); + assert.equal(sha256CanonicalJson(left), sha256CanonicalJson(right)); + assert.notEqual(sha256CanonicalJson(left), sha256CanonicalJson({ ...right, z: 4 })); +}); + +test('D5 canonical JSON serializes dates as ISO-8601', () => { + assert.equal( + canonicalJson({ at: new Date('2026-08-15T01:02:03.000Z') }), + '{"at":"2026-08-15T01:02:03.000Z"}', + ); +}); diff --git a/api-v3/test/unit/inspection-closing-dto.test.ts b/api-v3/test/unit/inspection-closing-dto.test.ts new file mode 100644 index 0000000..ae74bcf --- /dev/null +++ b/api-v3/test/unit/inspection-closing-dto.test.ts @@ -0,0 +1,56 @@ +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 { CreateCompanyOutcomeDto } from '../../src/inspection-closing/dto/create-company-outcome.dto'; +import { CreateInspectionSignatureDto } from '../../src/inspection-closing/dto/create-inspection-signature.dto'; +import { UpsertInspectionResponsibleDto } from '../../src/inspection-closing/dto/upsert-inspection-responsible.dto'; + +test('D5 present responsible requires identity and position', async () => { + const valid = plainToInstance(UpsertInspectionResponsibleDto, { + attendanceStatus: 'PRESENT', + fullName: ' Responsable Empresa ', + documentType: 'DNI', + documentNumber: '12345678', + position: 'Encargado', + email: 'responsable@empresa.test', + }); + assert.deepEqual(await validate(valid), []); + assert.equal(valid.fullName, 'Responsable Empresa'); + const invalid = plainToInstance(UpsertInspectionResponsibleDto, { + attendanceStatus: 'PRESENT', + }); + assert.equal((await validate(invalid)).length > 0, true); +}); + +test('D5 absent responsible and company outcome require a detailed reason', async () => { + const responsible = plainToInstance(UpsertInspectionResponsibleDto, { + attendanceStatus: 'ABSENT', + absenceReason: 'No había personal responsable en el establecimiento.', + }); + assert.deepEqual(await validate(responsible), []); + const outcome = plainToInstance(CreateCompanyOutcomeDto, { + status: 'ABSENT', + reason: 'Se dejó constancia ante el personal presente.', + }); + assert.deepEqual(await validate(outcome), []); + const invalid = plainToInstance(CreateCompanyOutcomeDto, { + status: 'SIGNED', + reason: 'Motivo suficientemente extenso', + }); + assert.equal((await validate(invalid)).some((error) => error.property === 'status'), true); +}); + +test('D5 signature DTO transforms multipart consent and coordinates', async () => { + const dto = plainToInstance(CreateInspectionSignatureDto, { + consentAccepted: 'true', + clientSignedAt: '2026-08-15T01:02:03.000Z', + latitude: '-32.889458', + longitude: '-68.845839', + accuracyM: '5.5', + }); + assert.deepEqual(await validate(dto), []); + assert.equal(dto.consentAccepted, true); + assert.equal(dto.latitude, -32.889458); +}); diff --git a/api-v3/test/unit/inspection-closing-file.test.ts b/api-v3/test/unit/inspection-closing-file.test.ts new file mode 100644 index 0000000..5c07ff8 --- /dev/null +++ b/api-v3/test/unit/inspection-closing-file.test.ts @@ -0,0 +1,43 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + inspectInspectionSignatureFile, + MAX_INSPECTION_SIGNATURE_BYTES, +} from '../../src/inspection-closing/inspection-signature-file'; + +const png = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 0]); + +test('D5 signature accepts only PNG content and MIME', () => { + assert.deepEqual(inspectInspectionSignatureFile({ + originalname: 'firma.png', + mimetype: 'image/png', + size: png.length, + buffer: png, + }), { + originalName: 'firma.png', + mimeType: 'image/png', + extension: '.png', + }); + assert.throws(() => inspectInspectionSignatureFile({ + originalname: 'firma.png', + mimetype: 'image/png', + size: 4, + buffer: Buffer.from('test'), + })); + assert.throws(() => inspectInspectionSignatureFile({ + originalname: 'firma.png', + mimetype: 'application/octet-stream', + size: png.length, + buffer: png, + })); +}); + +test('D5 signature rejects empty and oversized files', () => { + assert.throws(() => inspectInspectionSignatureFile(undefined)); + assert.throws(() => inspectInspectionSignatureFile({ + originalname: 'firma.png', + mimetype: 'image/png', + size: MAX_INSPECTION_SIGNATURE_BYTES + 1, + buffer: Buffer.concat([png, Buffer.alloc(MAX_INSPECTION_SIGNATURE_BYTES)]), + })); +}); diff --git a/api-v3/test/unit/inspection-closing-integrity.test.ts b/api-v3/test/unit/inspection-closing-integrity.test.ts new file mode 100644 index 0000000..2f2ca5d --- /dev/null +++ b/api-v3/test/unit/inspection-closing-integrity.test.ts @@ -0,0 +1,20 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import test from 'node:test'; + +test('D5 migration protects signatures, frozen findings and sealed acts', async () => { + const source = await readFile( + resolve( + process.cwd(), + 'src/database/migrations/1787328000000-phase-d5-act-closing-signatures.ts', + ), + 'utf8', + ); + assert.match(source, /REVOKE UPDATE ON TABLE inspection_act_signatures/); + assert.match(source, /trg_inspection_acts_closed_immutable/); + assert.match(source, /trg_inspection_findings_frozen_fields/); + assert.match(source, /trg_inspection_evidence_observation_draft/); + assert.match(source, /uq_inspection_act_company_outcome/); + assert.match(source, /closure_sha256 ~ '\^\[0-9a-f\]\{64\}\$'/); +}); diff --git a/api-v3/test/unit/inspection-evidence-authorization.test.ts b/api-v3/test/unit/inspection-evidence-authorization.test.ts new file mode 100644 index 0000000..762b14d --- /dev/null +++ b/api-v3/test/unit/inspection-evidence-authorization.test.ts @@ -0,0 +1,40 @@ +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 { + InspectionEvidenceContentController, + InspectionFindingCommunicationsController, + InspectionFindingEvidenceController, +} from '../../src/inspection-findings/inspection-evidence.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('D4 evidence endpoints separate reading and creation', () => { + assert.deepEqual( + permissionFor(InspectionFindingEvidenceController.prototype, 'list'), + ['inspection_evidence.read'], + ); + assert.deepEqual( + permissionFor(InspectionFindingEvidenceController.prototype, 'upload'), + ['inspection_evidence.create'], + ); + assert.deepEqual( + permissionFor(InspectionEvidenceContentController.prototype, 'content'), + ['inspection_evidence.read'], + ); +}); + +test('D4 communications endpoints separate reading and creation', () => { + assert.deepEqual( + permissionFor(InspectionFindingCommunicationsController.prototype, 'list'), + ['inspection_communications.read'], + ); + assert.deepEqual( + permissionFor(InspectionFindingCommunicationsController.prototype, 'create'), + ['inspection_communications.create'], + ); +}); diff --git a/api-v3/test/unit/inspection-evidence-dto.test.ts b/api-v3/test/unit/inspection-evidence-dto.test.ts new file mode 100644 index 0000000..e5ef142 --- /dev/null +++ b/api-v3/test/unit/inspection-evidence-dto.test.ts @@ -0,0 +1,59 @@ +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 { + InspectionCommunicationChannel, + InspectionCommunicationDirection, + InspectionCommunicationType, + InspectionEvidenceKind, + InspectionEvidencePurpose, +} from '../../src/database/entities'; +import { CreateInspectionCommunicationDto } from '../../src/inspection-findings/dto/create-inspection-communication.dto'; +import { CreateInspectionEvidenceDto } from '../../src/inspection-findings/dto/create-inspection-evidence.dto'; + +test('D4 evidence DTO transforms multipart coordinates and accepts a communication link', async () => { + const dto = plainToInstance(CreateInspectionEvidenceDto, { + kind: InspectionEvidenceKind.DOCUMENT, + purpose: InspectionEvidencePurpose.COMPANY_RESPONSE, + communicationId: 'd567722e-888f-45e8-8ac6-a8bfa6855518', + capturedAt: '2026-08-14T20:30:00.000Z', + latitude: '-32.889458', + longitude: '-68.845839', + accuracyM: '4.250', + deviceLabel: 'Panel web', + }); + assert.deepEqual(await validate(dto), []); + assert.equal(dto.latitude, -32.889458); + assert.equal(dto.accuracyM, 4.25); +}); + +test('D4 communication DTO accepts a company response and normalizes text', async () => { + const dto = plainToInstance(CreateInspectionCommunicationDto, { + direction: InspectionCommunicationDirection.INBOUND, + channel: InspectionCommunicationChannel.EMAIL, + type: InspectionCommunicationType.COMPANY_RESPONSE, + occurredAt: '2026-08-14T20:30:00.000Z', + subject: ' Respuesta al hallazgo H001 ', + details: ' Se comprometen a corregir. ', + contactEmail: 'responsable@empresa.test', + }); + assert.deepEqual(await validate(dto), []); + assert.equal(dto.subject, 'Respuesta al hallazgo H001'); + assert.equal(dto.details, 'Se comprometen a corregir.'); +}); + +test('D4 communication DTO rejects invalid email and date', async () => { + const dto = plainToInstance(CreateInspectionCommunicationDto, { + direction: InspectionCommunicationDirection.OUTBOUND, + channel: InspectionCommunicationChannel.EMAIL, + type: InspectionCommunicationType.AUTHORITY_NOTICE, + occurredAt: '14/08/2026', + subject: 'Notificación', + contactEmail: 'correo-invalido', + }); + const errors = await validate(dto); + assert.equal(errors.some((error) => error.property === 'occurredAt'), true); + assert.equal(errors.some((error) => error.property === 'contactEmail'), true); +}); diff --git a/api-v3/test/unit/inspection-evidence-file.test.ts b/api-v3/test/unit/inspection-evidence-file.test.ts new file mode 100644 index 0000000..5114f83 --- /dev/null +++ b/api-v3/test/unit/inspection-evidence-file.test.ts @@ -0,0 +1,40 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { InspectionEvidenceKind } from '../../src/database/entities'; +import { + inspectInspectionEvidenceFile, + MAX_INSPECTION_EVIDENCE_BYTES, +} from '../../src/inspection-findings/inspection-evidence-file'; + +function file(buffer: Buffer, originalname: string) { + return { buffer, originalname, size: buffer.length }; +} + +test('D4 evidence detects content by magic bytes', () => { + const photo = inspectInspectionEvidenceFile( + file(Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00]), 'evidencia.pdf'), + InspectionEvidenceKind.PHOTO, + ); + assert.equal(photo.mimeType, 'image/jpeg'); + const document = inspectInspectionEvidenceFile( + file(Buffer.from('%PDF-1.7'), 'respuesta.bin'), + InspectionEvidenceKind.DOCUMENT, + ); + assert.equal(document.mimeType, 'application/pdf'); +}); + +test('D4 evidence rejects executable text, mismatches and oversize files', () => { + assert.throws(() => inspectInspectionEvidenceFile( + file(Buffer.from(''), 'foto.jpg'), + InspectionEvidenceKind.PHOTO, + )); + assert.throws(() => inspectInspectionEvidenceFile( + file(Buffer.from('%PDF-1.7'), 'foto.pdf'), + InspectionEvidenceKind.PHOTO, + )); + assert.throws(() => inspectInspectionEvidenceFile({ + buffer: Buffer.from([0xff, 0xd8, 0xff]), + originalname: 'grande.jpg', + size: MAX_INSPECTION_EVIDENCE_BYTES + 1, + }, InspectionEvidenceKind.PHOTO)); +}); diff --git a/api-v3/test/unit/inspection-evidence-integrity.test.ts b/api-v3/test/unit/inspection-evidence-integrity.test.ts new file mode 100644 index 0000000..501e167 --- /dev/null +++ b/api-v3/test/unit/inspection-evidence-integrity.test.ts @@ -0,0 +1,18 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import test from 'node:test'; + +test('D4 migration makes evidence and communications append-only for runtime', async () => { + const source = await readFile( + resolve( + process.cwd(), + 'src/database/migrations/1787241600000-phase-d4-finding-evidence-communications.ts', + ), + 'utf8', + ); + assert.match(source, /GRANT SELECT, INSERT ON TABLE/); + assert.match(source, /REVOKE UPDATE, DELETE ON TABLE/); + assert.match(source, /chk_inspection_finding_company_response_pdf/); + assert.match(source, /FOREIGN KEY \(communication_id, finding_id\)/); +}); diff --git a/api-v3/test/unit/inspection-findings-authorization.test.ts b/api-v3/test/unit/inspection-findings-authorization.test.ts new file mode 100644 index 0000000..b4b4257 --- /dev/null +++ b/api-v3/test/unit/inspection-findings-authorization.test.ts @@ -0,0 +1,52 @@ +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 { FindingCatalogController } from '../../src/inspection-findings/finding-catalog.controller'; +import { + InspectionActFindingsController, + InspectionFindingsController, +} from '../../src/inspection-findings/inspection-findings.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('finding endpoints separate catalog, content, follow-up and closure permissions', () => { + assert.deepEqual(permissionFor(FindingCatalogController.prototype, 'list'), [ + 'finding_catalog.read', + ]); + for (const method of [ + 'adminList', + 'createCategory', + 'updateCategory', + 'createItem', + 'updateItem', + ]) { + assert.deepEqual(permissionFor(FindingCatalogController.prototype, method), [ + 'finding_catalog.manage', + ]); + } + assert.deepEqual(permissionFor(InspectionActFindingsController.prototype, 'list'), [ + 'inspection_findings.read', + ]); + assert.deepEqual(permissionFor(InspectionActFindingsController.prototype, 'create'), [ + 'inspection_findings.create', + ]); + assert.deepEqual(permissionFor(InspectionFindingsController.prototype, 'list'), [ + 'inspection_findings.read', + ]); + assert.deepEqual(permissionFor(InspectionFindingsController.prototype, 'get'), [ + 'inspection_findings.read', + ]); + assert.deepEqual(permissionFor(InspectionFindingsController.prototype, 'update'), [ + 'inspection_findings.update', + ]); + assert.deepEqual(permissionFor(InspectionFindingsController.prototype, 'updateFollowUp'), [ + 'inspection_findings.follow_up', + ]); + assert.deepEqual(permissionFor(InspectionFindingsController.prototype, 'close'), [ + 'inspection_findings.close', + ]); +}); diff --git a/api-v3/test/unit/inspection-findings-dto.test.ts b/api-v3/test/unit/inspection-findings-dto.test.ts new file mode 100644 index 0000000..1812773 --- /dev/null +++ b/api-v3/test/unit/inspection-findings-dto.test.ts @@ -0,0 +1,107 @@ +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 { CloseInspectionFindingDto } from '../../src/inspection-findings/dto/close-inspection-finding.dto'; +import { CreateInspectionFindingDto } from '../../src/inspection-findings/dto/create-inspection-finding.dto'; +import { ListInspectionFindingsQueryDto } from '../../src/inspection-findings/dto/list-inspection-findings-query.dto'; +import { CreateFindingCatalogItemDto } from '../../src/inspection-findings/dto/create-finding-catalog-item.dto'; +import { CreateFindingCategoryDto } from '../../src/inspection-findings/dto/create-finding-category.dto'; +import { UpdateFindingCatalogItemDto } from '../../src/inspection-findings/dto/update-finding-catalog-item.dto'; +import { UpdateInspectionFindingFollowUpDto } from '../../src/inspection-findings/dto/update-inspection-finding-follow-up.dto'; + +const ASSET_ID = '16e54e65-60cf-4739-b0d1-ccdd904fbfd5'; +const CATALOG_ID = '9e38a67b-d6b1-49e0-8ec3-69a68366bd88'; + +test('finding DTO accepts catalog findings with a correction deadline', async () => { + const dto = plainToInstance(CreateInspectionFindingDto, { + assetId: ASSET_ID, + catalogItemId: CATALOG_ID, + description: ' Pérdida visible en la unión bridada. ', + correctionDueOn: '2026-09-15', + }); + assert.deepEqual(await validate(dto), []); + assert.equal(dto.description, 'Pérdida visible en la unión bridada.'); +}); + +test('custom findings require a title and reject malformed dates', async () => { + const dto = plainToInstance(CreateInspectionFindingDto, { + assetId: ASSET_ID, + catalogItemId: null, + description: 'Observación específica', + correctionDueOn: '15/09/2026', + }); + const errors = await validate(dto); + assert.equal(errors.some((error) => error.property === 'customTitle'), true); + assert.equal(errors.some((error) => error.property === 'correctionDueOn'), true); +}); + +test('follow-up DTO keeps company response and verification planning as separate dates', async () => { + const dto = plainToInstance(UpdateInspectionFindingFollowUpDto, { + companyResponse: ' Se corregirá durante la parada programada. ', + companyResponseReceivedOn: '2026-08-20', + companyCommittedCorrectionOn: '2026-09-10', + nextControlOn: '2026-09-15', + }); + assert.deepEqual(await validate(dto), []); + assert.equal(dto.companyResponse, 'Se corregirá durante la parada programada.'); +}); + +test('global finding filters accept the two operational work queues', async () => { + const administrative = plainToInstance(ListInspectionFindingsQueryDto, { + page: '1', pageSize: '25', workflow: 'COMPANY_OVERDUE', search: ' PSEnergy ', + }); + const operational = plainToInstance(ListInspectionFindingsQueryDto, { + page: '1', pageSize: '25', workflow: 'VERIFICATION_OVERDUE', + }); + assert.deepEqual(await validate(administrative), []); + assert.deepEqual(await validate(operational), []); + assert.equal(administrative.search, 'PSEnergy'); +}); + +test('finding close DTO requires a meaningful conclusion', async () => { + const valid = plainToInstance(CloseInspectionFindingDto, { closureNotes: ' Solución verificada en campo. ' }); + const invalid = plainToInstance(CloseInspectionFindingDto, { closureNotes: 'x' }); + assert.deepEqual(await validate(valid), []); + assert.equal(valid.closureNotes, 'Solución verificada en campo.'); + assert.equal((await validate(invalid)).length > 0, true); +}); + +test('catalog category DTO normalizes the immutable code', async () => { + const dto = plainToInstance(CreateFindingCategoryDto, { + code: ' seguridad_operativa ', + name: ' Seguridad operativa ', + sortOrder: '25', + }); + assert.deepEqual(await validate(dto), []); + assert.equal(dto.code, 'SEGURIDAD_OPERATIVA'); + assert.equal(dto.name, 'Seguridad operativa'); + assert.equal(dto.sortOrder, 25); +}); + +test('catalog item DTO accepts nullable reference fields and normalizes its code', async () => { + const dto = plainToInstance(CreateFindingCatalogItemDto, { + categoryId: CATALOG_ID, + code: ' perdida_brida ', + sourceNumber: '171', + title: ' Pérdida visible en unión bridada ', + legalBasis: ' ', + glossary: null, + importNote: ' Alta manual ', + }); + assert.deepEqual(await validate(dto), []); + assert.equal(dto.code, 'PERDIDA_BRIDA'); + assert.equal(dto.legalBasis, null); + assert.equal(dto.importNote, 'Alta manual'); +}); + +test('catalog item update rejects an empty title and invalid source number', async () => { + const dto = plainToInstance(UpdateFindingCatalogItemDto, { + title: ' ', + sourceNumber: 0, + }); + const errors = await validate(dto); + assert.equal(errors.some((error) => error.property === 'title'), true); + assert.equal(errors.some((error) => error.property === 'sourceNumber'), true); +}); diff --git a/api-v3/test/unit/inspection-findings-office-workflow.test.ts b/api-v3/test/unit/inspection-findings-office-workflow.test.ts new file mode 100644 index 0000000..b07b812 --- /dev/null +++ b/api-v3/test/unit/inspection-findings-office-workflow.test.ts @@ -0,0 +1,22 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const source = readFileSync(resolve(process.cwd(), 'src/inspection-findings/inspection-findings.service.ts'), 'utf8'); +const controller = readFileSync(resolve(process.cwd(), 'src/inspection-findings/inspection-findings.controller.ts'), 'utf8'); + +test('D5.3.13 separates company response due dates from verification due dates', () => { + assert.match(source, /correction_due_on IS NOT NULL AND correction_due_on/); + assert.match(source, /company_response_received_on IS NOT NULL AND next_control_on/); + assert.match(source, /companyOverdue/); + assert.match(source, /verificationOverdue/); +}); + +test('D5.3.13 exposes a global office queue and controlled finding closure', () => { + assert.match(controller, /@Get\(\)/); + assert.match(controller, /this\.findings\.listGlobal\(query\)/); + assert.match(controller, /@Patch\(':id\/close'\)/); + assert.match(source, /finding\.status = InspectionFindingStatus\.CLOSED/); + assert.match(source, /finding\.closureNotes = dto\.closureNotes/); +}); diff --git a/api-v3/test/unit/inspection-reports-authorization.test.ts b/api-v3/test/unit/inspection-reports-authorization.test.ts new file mode 100644 index 0000000..202857c --- /dev/null +++ b/api-v3/test/unit/inspection-reports-authorization.test.ts @@ -0,0 +1,17 @@ +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 { InspectionActReportController, InspectionReportsController } from '../../src/inspection-reports/inspection-reports.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('D5.3.15 separates report consultation from report generation', () => { + assert.deepEqual(permissionFor(InspectionReportsController.prototype, 'list'), ['inspection_reports.read']); + assert.deepEqual(permissionFor(InspectionReportsController.prototype, 'pending'), ['inspection_reports.read']); + assert.deepEqual(permissionFor(InspectionReportsController.prototype, 'get'), ['inspection_reports.read']); + assert.deepEqual(permissionFor(InspectionActReportController.prototype, 'generate'), ['inspection_reports.generate']); +}); diff --git a/api-v3/test/unit/inspection-verifications-authorization.test.ts b/api-v3/test/unit/inspection-verifications-authorization.test.ts new file mode 100644 index 0000000..9e02219 --- /dev/null +++ b/api-v3/test/unit/inspection-verifications-authorization.test.ts @@ -0,0 +1,20 @@ +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 { InspectionVerificationsController } from '../../src/inspection-verifications/inspection-verifications.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('D5.3.16 separates queue consultation from office verification planning', () => { + assert.deepEqual(permissionFor(InspectionVerificationsController.prototype, 'list'), ['inspection_findings.read']); + assert.deepEqual(permissionFor(InspectionVerificationsController.prototype, 'plan'), ['inspection_findings.read', 'inspection_verifications.plan']); +}); + + +test('D5.3.17 reserves verification result capture for field execution', () => { + assert.deepEqual(permissionFor(InspectionVerificationsController.prototype, 'recordResult'), ['inspections.execute']); +}); diff --git a/api-v3/test/unit/inspection-visits-authorization.test.ts b/api-v3/test/unit/inspection-visits-authorization.test.ts new file mode 100644 index 0000000..c7751c1 --- /dev/null +++ b/api-v3/test/unit/inspection-visits-authorization.test.ts @@ -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 { InspectionVisitsController } from '../../src/inspection-visits/inspection-visits.controller'; + +function permissionFor(method: string): string[] { + const controller = InspectionVisitsController.prototype; + const handler = controller[method as keyof typeof controller]; + return Reflect.getMetadata(REQUIRED_PERMISSIONS_KEY, handler) as string[]; +} + +test('inspection visit endpoints separate reading, planning, assignment and execution', () => { + assert.deepEqual(permissionFor('list'), ['inspections.read']); + assert.deepEqual(permissionFor('get'), ['inspections.read']); + assert.deepEqual(permissionFor('assignees'), ['inspections.assign']); + assert.deepEqual(permissionFor('create'), ['inspections.manage', 'inspections.assign']); + 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.deepEqual(permissionFor('start'), ['inspections.execute']); +}); diff --git a/api-v3/test/unit/inspection-visits-dto.test.ts b/api-v3/test/unit/inspection-visits-dto.test.ts new file mode 100644 index 0000000..98f979d --- /dev/null +++ b/api-v3/test/unit/inspection-visits-dto.test.ts @@ -0,0 +1,48 @@ +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 { ChangeInspectionVisitStatusDto } from '../../src/inspection-visits/dto/change-inspection-visit-status.dto'; +import { CreateInspectionVisitDto } from '../../src/inspection-visits/dto/create-inspection-visit.dto'; +import { ReplaceInspectionVisitAssetsDto } from '../../src/inspection-visits/dto/replace-inspection-visit-assets.dto'; +import { ReplaceInspectionVisitTeamDto } from '../../src/inspection-visits/dto/replace-inspection-visit-team.dto'; + +const AREA_ID = '16e54e65-60cf-4739-b0d1-ccdd904fbfd5'; +const COMPANY_ID = 'ce96211d-ac15-4421-8868-4185323aff61'; +const USER_ID = '9e38a67b-d6b1-49e0-8ec3-69a68366bd88'; + +test('visit DTO accepts only the minimum planning data required for quick creation', async () => { + const dto = plainToInstance(CreateInspectionVisitDto, { + operationalAreaId: AREA_ID, + operatorCompanyId: COMPANY_ID, + plannedStartAt: '2026-08-17T12:00:00.000Z', + leadInspectorUserId: USER_ID, + }); + assert.deepEqual(await validate(dto), []); + assert.equal(dto.operationalAreaId, AREA_ID); + assert.equal(dto.operatorCompanyId, COMPANY_ID); + assert.equal(dto.leadInspectorUserId, USER_ID); +}); + +test('asset and team DTOs reject duplicate references', async () => { + const assets = Object.assign(new ReplaceInspectionVisitAssetsDto(), { + assetIds: [AREA_ID, AREA_ID], + }); + const team = Object.assign(new ReplaceInspectionVisitTeamDto(), { + leadInspectorUserId: USER_ID, + memberUserIds: [USER_ID, USER_ID], + }); + assert.equal((await validate(assets)).some((error) => error.property === 'assetIds'), true); + assert.equal((await validate(team)).some((error) => error.property === 'memberUserIds'), true); +}); + +test('status DTO accepts controlled states and rejects unknown transitions at input', async () => { + const dto = Object.assign(new ChangeInspectionVisitStatusDto(), { + status: 'PLANNED', + reason: null, + }); + assert.deepEqual(await validate(dto), []); + dto.status = 'OPEN' as typeof dto.status; + assert.equal((await validate(dto)).some((error) => error.property === 'status'), true); +}); diff --git a/api-v3/test/unit/mobile-inspector-integration.test.ts b/api-v3/test/unit/mobile-inspector-integration.test.ts new file mode 100644 index 0000000..afd10d4 --- /dev/null +++ b/api-v3/test/unit/mobile-inspector-integration.test.ts @@ -0,0 +1,43 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import test from 'node:test'; + +async function source(relativePath: string): Promise { + return readFile(resolve(process.cwd(), 'src', relativePath), 'utf8'); +} + +function policyCalls(value: string): number { + return value.match(/assertMobileInspector\(principal\);/g)?.length ?? 0; +} + +test('D5.1 applies the mobile-inspector policy to every field operation', async () => { + const [visits, acts, findings, evidence, closing] = await Promise.all([ + source('inspection-visits/inspection-visits.service.ts'), + source('inspection-acts/inspection-acts.service.ts'), + source('inspection-findings/inspection-findings.service.ts'), + source('inspection-findings/inspection-evidence.service.ts'), + source('inspection-closing/inspection-closing.service.ts'), + ]); + assert.equal(policyCalls(visits), 1); + assert.equal(policyCalls(acts), 3); + assert.equal(policyCalls(findings), 2); + assert.equal(policyCalls(evidence), 1); + assert.equal(policyCalls(closing), 7); + assert.match(evidence, /purpose === InspectionEvidencePurpose\.OBSERVATION/); +}); + +test('D5.1 leaves company follow-up outside the field-operation gate', async () => { + const findings = await source('inspection-findings/inspection-findings.service.ts'); + const followUp = findings.slice(findings.indexOf('async updateFollowUp(')); + assert.doesNotMatch(followUp, /assertMobileInspector/); +}); + +test('D5.1 migration grants operational permissions only to inspector', async () => { + const migration = await source( + 'database/migrations/1787331600000-phase-d5-1-mobile-inspection-policy.ts', + ); + assert.match(migration, /role\.code <> 'inspector'/); + assert.match(migration, /role\.code = 'inspector'/); + assert.doesNotMatch(migration, /inspection_findings\.follow_up/); +}); diff --git a/api-v3/test/unit/mobile-inspector-policy.test.ts b/api-v3/test/unit/mobile-inspector-policy.test.ts new file mode 100644 index 0000000..731c10d --- /dev/null +++ b/api-v3/test/unit/mobile-inspector-policy.test.ts @@ -0,0 +1,44 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { ForbiddenException } from '@nestjs/common'; +import type { AuthPrincipal, AuthTransport } from '../../src/common/http/request-context'; +import { assertMobileInspector } from '../../src/inspection-operations/mobile-inspector-policy'; + +function principal(transport: AuthTransport, roles: string[]): AuthPrincipal { + return { + userId: 'b6f6b10e-434a-4d72-9ec9-3520212dd290', + username: 'inspector', + sessionId: '1bab61ac-65d1-40e6-816b-63d90131a795', + firstName: 'Inspector', + lastName: 'DH', + email: null, + mustChangePassword: false, + roles, + permissions: [], + transport, + }; +} + +function forbiddenCode(error: unknown): string | undefined { + return error instanceof ForbiddenException + ? (error.getResponse() as { code?: string }).code + : undefined; +} + +test('operational inspection writes reject web cookie sessions', () => { + assert.throws( + () => assertMobileInspector(principal('cookie', ['inspector'])), + (error: unknown) => forbiddenCode(error) === 'INSPECTION_MOBILE_APP_REQUIRED', + ); +}); + +test('operational inspection writes reject non-inspector bearer users', () => { + assert.throws( + () => assertMobileInspector(principal('bearer', ['admin'])), + (error: unknown) => forbiddenCode(error) === 'INSPECTION_INSPECTOR_ROLE_REQUIRED', + ); +}); + +test('operational inspection writes accept inspector bearer users', () => { + assert.doesNotThrow(() => assertMobileInspector(principal('bearer', ['inspector']))); +}); diff --git a/api-v3/test/unit/password.service.test.ts b/api-v3/test/unit/password.service.test.ts new file mode 100644 index 0000000..4e8effa --- /dev/null +++ b/api-v3/test/unit/password.service.test.ts @@ -0,0 +1,18 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { PasswordService } from '../../src/auth/services/password.service'; + +test('PasswordService hashes with Argon2id and verifies safely', async () => { + const service = new PasswordService(); + const hash = await service.hash('Una-clave-segura-2026'); + + assert.match(hash, /^\$argon2id\$/); + assert.equal(await service.verify(hash, 'Una-clave-segura-2026'), true); + assert.equal(await service.verify(hash, 'clave-incorrecta'), false); + assert.equal(service.needsRehash(hash), false); +}); + +test('PasswordService performs dummy verification for unknown users', async () => { + const service = new PasswordService(); + await assert.doesNotReject(service.verifyUnknown('cualquier-clave')); +}); diff --git a/api-v3/test/unit/permissions.guard.test.ts b/api-v3/test/unit/permissions.guard.test.ts new file mode 100644 index 0000000..99209c4 --- /dev/null +++ b/api-v3/test/unit/permissions.guard.test.ts @@ -0,0 +1,140 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + ExecutionContext, + ForbiddenException, + UnauthorizedException, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { RequirePermissions } from '../../src/authorization/decorators/require-permissions.decorator'; +import { PermissionsGuard } from '../../src/authorization/guards/permissions.guard'; +import { Public } from '../../src/auth/decorators/public.decorator'; +import type { AuthPrincipal } from '../../src/common/http/request-context'; + +@RequirePermissions('dashboard.read') +class ProtectedController { + @RequirePermissions('users.create') + createUser() {} + + openToAuthenticatedUsers() {} +} + +class UndecoratedController { + handle() {} +} + +@RequirePermissions('dashboard.read') +class PublicController { + @Public() + handle() {} +} + +function principal(permissions: string[]): AuthPrincipal { + return { + userId: 'b6f6b10e-434a-4d72-9ec9-3520212dd290', + username: 'admin', + sessionId: '1bab61ac-65d1-40e6-816b-63d90131a795', + firstName: 'Admin', + lastName: 'DH', + email: null, + mustChangePassword: false, + roles: ['admin'], + permissions, + transport: 'cookie', + }; +} + +function context( + controller: new () => object, + handlerName: string, + auth?: AuthPrincipal, +): ExecutionContext { + const handler = controller.prototype[ + handlerName as keyof typeof controller.prototype + ]; + + return { + getHandler: () => handler, + getClass: () => controller, + switchToHttp: () => ({ + getRequest: () => ({ auth }), + }), + } as unknown as ExecutionContext; +} + +const guard = new PermissionsGuard(new Reflector()); + +test('PermissionsGuard leaves handlers without requirements available', () => { + assert.equal( + guard.canActivate(context(UndecoratedController, 'handle')), + true, + ); +}); + +test('PermissionsGuard gives explicit public metadata priority', () => { + assert.equal(guard.canActivate(context(PublicController, 'handle')), true); +}); + +test('PermissionsGuard returns 401 when required permissions have no principal', () => { + assert.throws( + () => guard.canActivate(context(ProtectedController, 'createUser')), + (error: unknown) => + error instanceof UnauthorizedException && error.getStatus() === 401, + ); +}); + +test('PermissionsGuard returns 403 when any required permission is missing', () => { + assert.throws( + () => + guard.canActivate( + context( + ProtectedController, + 'createUser', + principal(['dashboard.read']), + ), + ), + (error: unknown) => + error instanceof ForbiddenException && error.getStatus() === 403, + ); +}); + +test('PermissionsGuard requires changing a temporary password first', () => { + const auth = principal(['dashboard.read', 'users.create']); + auth.mustChangePassword = true; + + assert.throws( + () => + guard.canActivate(context(ProtectedController, 'createUser', auth)), + (error: unknown) => + error instanceof ForbiddenException && + error.getStatus() === 403 && + (error.getResponse() as { code?: string }).code === + 'PASSWORD_CHANGE_REQUIRED', + ); +}); + +test('PermissionsGuard authorizes when class and handler permissions are granted', () => { + assert.equal( + guard.canActivate( + context( + ProtectedController, + 'createUser', + principal(['dashboard.read', 'users.create']), + ), + ), + true, + ); +}); + +test('PermissionsGuard applies class requirements to every handler', () => { + assert.equal( + guard.canActivate( + context( + ProtectedController, + 'openToAuthenticatedUsers', + principal(['dashboard.read']), + ), + ), + true, + ); +}); diff --git a/api-v3/test/unit/phase-d5-3-15-document-center.test.ts b/api-v3/test/unit/phase-d5-3-15-document-center.test.ts new file mode 100644 index 0000000..52ab6ed --- /dev/null +++ b/api-v3/test/unit/phase-d5-3-15-document-center.test.ts @@ -0,0 +1,31 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const reportService = readFileSync(resolve(process.cwd(), 'src/inspection-reports/inspection-reports.service.ts'), 'utf8'); +const actService = readFileSync(resolve(process.cwd(), 'src/inspection-acts/inspection-acts.service.ts'), 'utf8'); +const migration = readFileSync(resolve(process.cwd(), 'src/database/migrations/1788026400000-phase-d5-3-15-document-center.ts'), 'utf8'); + +test('D5.3.15 exposes global acts and pending report emission', () => { + assert.match(actService, /async listGlobal\(query: ListInspectionActsQueryDto\)/); + assert.match(reportService, /async listPending\(query: ListInspectionReportsQueryDto\)/); + assert.match(reportService, /act\.status = 'CLOSED'/); + assert.match(reportService, /report\.id IS NULL/); +}); + +test('D5.3.15 freezes a report from the final closed act snapshot', () => { + assert.match(reportService, /assertMobileInspector\(principal\)/); + assert.match(reportService, /final_snapshot AS "finalSnapshot"/); + assert.match(reportService, /DocumentSequenceType\.REPORT/); + assert.match(reportService, /sha256CanonicalJson\(frozenSnapshot\)/); + assert.match(reportService, /INSPECTION_REPORT_GENERATED/); +}); + +test('D5.3.15 migration creates independent report numbering and protected frozen content', () => { + assert.match(migration, /CREATE TABLE inspection_reports/); + assert.match(migration, /UNIQUE \(report_year, report_number\)/); + assert.match(migration, /dhv2_guard_inspection_report_frozen/); + assert.match(migration, /inspection_reports\.read/); + assert.match(migration, /inspection_reports\.generate/); +}); diff --git a/api-v3/test/unit/phase-d5-3-16-verification-planning.test.ts b/api-v3/test/unit/phase-d5-3-16-verification-planning.test.ts new file mode 100644 index 0000000..2e2bcda --- /dev/null +++ b/api-v3/test/unit/phase-d5-3-16-verification-planning.test.ts @@ -0,0 +1,41 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const service = readFileSync(resolve(process.cwd(), 'src/inspection-verifications/inspection-verifications.service.ts'), 'utf8'); +const migration = readFileSync(resolve(process.cwd(), 'src/database/migrations/1788112800000-phase-d5-3-16-verification-planning.ts'), 'utf8'); +const visitService = readFileSync(resolve(process.cwd(), 'src/inspection-visits/inspection-visits.service.ts'), 'utf8'); + +test('D5.3.16 builds the verification queue from the second finding deadline', () => { + assert.match(service, /company_response_received_on IS NOT NULL/); + assert.match(service, /finding\.next_control_on IS NOT NULL/); + assert.match(service, /verification_visit\.id IS NULL/); + assert.match(service, /overdueUnplanned/); +}); + +test('D5.3.16 creates one draft verification visit for one company and area', () => { + assert.match(service, /VERIFICATION_CONTEXT_MIXED/); + assert.match(service, /status: InspectionVisitStatus\.DRAFT/); + assert.match(service, /scopeAssetId: areaId/); + assert.match(service, /inspection_visit_assets/); + assert.match(service, /inspection_finding_verification_visits/); + assert.match(service, /INSPECTION_VERIFICATION_PLANNED/); +}); + +test('D5.3.16 prevents an open finding from being scheduled into two active verification visits', () => { + assert.match(service, /visit\.status IN \('DRAFT', 'PLANNED', 'IN_PROGRESS'\)/); + assert.match(service, /VERIFICATION_ALREADY_PLANNED/); +}); + +test('D5.3.16 exposes linked findings in the created visit', () => { + assert.match(visitService, /verificationFindings/); + assert.match(visitService, /inspection_finding_verification_visits/); +}); + +test('D5.3.16 migration adds traceable verification links and an office planning permission', () => { + assert.match(migration, /CREATE TABLE inspection_finding_verification_visits/); + assert.match(migration, /inspection_verifications\.plan/); + assert.match(migration, /REFERENCES inspection_findings\(id\) ON DELETE RESTRICT/); + assert.match(migration, /REFERENCES inspection_visits\(id\) ON DELETE RESTRICT/); +}); diff --git a/api-v3/test/unit/phase-d5-3-17-verification-execution.test.ts b/api-v3/test/unit/phase-d5-3-17-verification-execution.test.ts new file mode 100644 index 0000000..5c5919a --- /dev/null +++ b/api-v3/test/unit/phase-d5-3-17-verification-execution.test.ts @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const verificationService = readFileSync(resolve(process.cwd(), 'src/inspection-verifications/inspection-verifications.service.ts'), 'utf8'); +const evidenceService = readFileSync(resolve(process.cwd(), 'src/inspection-findings/inspection-evidence.service.ts'), 'utf8'); +const closingService = readFileSync(resolve(process.cwd(), 'src/inspection-closing/inspection-closing.service.ts'), 'utf8'); +const findingsService = readFileSync(resolve(process.cwd(), 'src/inspection-findings/inspection-findings.service.ts'), 'utf8'); +const migration = readFileSync(resolve(process.cwd(), 'src/database/migrations/1788199200000-phase-d5-3-17-verification-execution.ts'), 'utf8'); + + test('D5.3.17 records one controlled field result for a linked verification finding', () => { + assert.match(verificationService, /assertMobileInspector\(principal\)/); + assert.match(verificationService, /VERIFICATION_VISIT_NOT_IN_PROGRESS/); + assert.match(verificationService, /InspectionVerificationOutcome\.RESOLVED/); + assert.match(verificationService, /InspectionVerificationOutcome\.NOT_RESOLVED/); + assert.match(verificationService, /InspectionVerificationOutcome\.REQUIRES_NEW_DATE/); + assert.match(verificationService, /VERIFICATION_RECORDED/); + assert.match(verificationService, /INSPECTION_VERIFICATION_RESULT_RECORDED/); +}); + +test('D5.3.17 preserves the original target and only reschedules when explicitly requested', () => { + assert.match(verificationService, /target_control_on/); + assert.match(verificationService, /rescheduled_control_on/); + assert.match(verificationService, /VERIFICATION_NEW_DATE_REQUIRED/); + assert.match(verificationService, /VERIFICATION_NEW_DATE_NOT_FUTURE/); +}); + +test('D5.3.17 links verification photos to the verification visit', () => { + assert.match(evidenceService, /InspectionEvidencePurpose\.VERIFICATION/); + assert.match(evidenceService, /verification_visit_id/); + assert.match(evidenceService, /INSPECTION_VERIFICATION_VISIT_REQUIRED/); + assert.match(evidenceService, /INSPECTION_VERIFICATION_PHOTO_REQUIRED/); +}); + +test('D5.3.17 closes a verification act only after every linked finding has a result', () => { + assert.match(closingService, /INSPECTION_VERIFICATION_RESULTS_REQUIRED/); + assert.match(closingService, /verificationResults/); + assert.match(closingService, /DH-ACT-CLOSURE-V2/); +}); + +test('D5.3.17 moves verified conforming findings into an office ready-to-close queue', () => { + assert.match(findingsService, /READY_TO_CLOSE/); + assert.match(findingsService, /readyToClose/); + assert.match(findingsService, /latestVerificationOutcome/); +}); + +test('D5.3.17 migration adds verification outcome and verification evidence linkage', () => { + assert.match(migration, /target_control_on date/); + assert.match(migration, /outcome varchar\(24\)/); + assert.match(migration, /verified_at timestamptz/); + assert.match(migration, /rescheduled_control_on date/); + assert.match(migration, /verification_visit_id uuid/); + assert.match(migration, /'VERIFICATION'/); +}); diff --git a/api-v3/test/unit/phase-d5-3-18-3-office-follow-up-hardening.test.ts b/api-v3/test/unit/phase-d5-3-18-3-office-follow-up-hardening.test.ts new file mode 100644 index 0000000..f6d1c0a --- /dev/null +++ b/api-v3/test/unit/phase-d5-3-18-3-office-follow-up-hardening.test.ts @@ -0,0 +1,22 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const controller = readFileSync(resolve(process.cwd(), 'src/inspection-findings/inspection-findings.controller.ts'), 'utf8'); +const service = readFileSync(resolve(process.cwd(), 'src/inspection-findings/inspection-findings.service.ts'), 'utf8'); +const migration = readFileSync(resolve(process.cwd(), 'src/database/migrations/1788285600000-phase-d5-3-18-3-office-follow-up-hardening.ts'), 'utf8'); + +test('D5.3.18.3 separates finding closure from general follow-up permission', () => { + assert.match(controller, /@Patch\(':id\/close'\)[\s\S]*@RequirePermissions\('inspection_findings\.close'\)/); + assert.match(migration, /\('inspector', 'inspection_findings\.close'\)/); + assert.match(migration, /\('supervisor', 'inspection_findings\.close'\)/); + assert.match(migration, /\('director', 'inspection_findings\.close'\)/); + assert.doesNotMatch(migration, /\('admin', 'inspection_findings\.close'\)/); +}); + +test('D5.3.18.3 keeps the first company response immutable and requires later presentations in history', () => { + assert.match(service, /INSPECTION_FINDING_RESPONSE_IMMUTABLE/); + assert.match(service, /La primera respuesta registrada no se modifica/); + assert.match(service, /finding\.companyResponseReceivedOn && \(dto\.companyResponse !== undefined \|\| dto\.companyResponseReceivedOn !== undefined\)/); +}); diff --git a/api-v3/test/unit/phase-d5-3-18-office-workspace.test.ts b/api-v3/test/unit/phase-d5-3-18-office-workspace.test.ts new file mode 100644 index 0000000..2a3f44e --- /dev/null +++ b/api-v3/test/unit/phase-d5-3-18-office-workspace.test.ts @@ -0,0 +1,26 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const findingsService = readFileSync(resolve(process.cwd(), 'src/inspection-findings/inspection-findings.service.ts'), 'utf8'); + + test('D5.3.18 exposes one server-side office workflow for the finding dossier', () => { + assert.match(findingsService, /officeWorkflow/); + assert.match(findingsService, /WAITING_COMPANY/); + assert.match(findingsService, /COMPANY_OVERDUE/); + assert.match(findingsService, /DEFINE_VERIFICATION/); + assert.match(findingsService, /PLAN_VERIFICATION/); + assert.match(findingsService, /VERIFICATION_PLANNED/); + assert.match(findingsService, /RESCHEDULE_VERIFICATION/); + assert.match(findingsService, /READY_TO_CLOSE/); + assert.match(findingsService, /CLOSED/); +}); + +test('D5.3.18 preserves administrative and operational dates as independent fields', () => { + assert.match(findingsService, /correctionDueOn/); + assert.match(findingsService, /companyResponseReceivedOn/); + assert.match(findingsService, /companyCommittedCorrectionOn/); + assert.match(findingsService, /nextControlOn/); + assert.doesNotMatch(findingsService, /finding\.correctionDueOn = dto\.nextControlOn/); +}); diff --git a/api-v3/test/unit/phase-d5-3-19-inventory-filters-deadlines.test.ts b/api-v3/test/unit/phase-d5-3-19-inventory-filters-deadlines.test.ts new file mode 100644 index 0000000..ac9e7ef --- /dev/null +++ b/api-v3/test/unit/phase-d5-3-19-inventory-filters-deadlines.test.ts @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const read = (path: string) => readFileSync(resolve(process.cwd(), path), 'utf8'); +const migration = read('src/database/migrations/1788372000000-phase-d5-3-19-inventory-aliases-operational-filters.ts'); +const assetsService = read('src/asset-master/assets.service.ts'); +const createAssetDto = read('src/asset-master/dto/create-asset.dto.ts'); +const findingService = read('src/inspection-findings/inspection-findings.service.ts'); +const followUpDto = read('src/inspection-findings/dto/update-inspection-finding-follow-up.dto.ts'); +const visitsDto = read('src/inspection-visits/dto/list-inspection-visits-query.dto.ts'); +const actsDto = read('src/inspection-acts/dto/list-inspection-acts-query.dto.ts'); +const reportsDto = read('src/inspection-reports/dto/list-inspection-reports-query.dto.ts'); +const findingsDto = read('src/inspection-findings/dto/list-inspection-findings-query.dto.ts'); +const actsService = read('src/inspection-acts/inspection-acts.service.ts'); + + +test('D5.3.19 adds one searchable common name without replacing technical identity', () => { + assert.match(migration, /ADD COLUMN common_name varchar\(200\)/); + assert.match(migration, /idx_assets_common_name_lower/); + assert.match(createAssetDto, /commonName\?: string \| null/); + assert.match(assetsService, /asset\.common_name ILIKE/); + assert.match(assetsService, /common_name AS "commonName"/); +}); + +test('D5.3.19 stores the administrative response rule and its immutable base', () => { + assert.match(migration, /response_due_basis varchar\(32\)/); + assert.match(migration, /response_due_days integer/); + assert.match(migration, /response_due_base_on date/); + assert.match(migration, /report_notified_on date/); + assert.match(followUpDto, /InspectionFindingResponseDueBasis/); + assert.match(findingService, /INSPECTION_FINDING_RESPONSE_DEADLINE_LOCKED/); + assert.match(findingService, /InspectionFindingResponseDueBasis\.FINDING_DATE/); + assert.match(findingService, /INSPECTION_FINDING_REPORT_NOTIFICATION_REQUIRED/); + assert.match(findingService, /finding\.correctionDueOn = addCalendarDays\(baseOn, days\)/); +}); + +test('D5.3.19 exposes company area inspector and date filters across operational lists', () => { + for (const dto of [visitsDto, actsDto, reportsDto, findingsDto]) { + assert.match(dto, /companyId\?: string|companyId/); + assert.match(dto, /areaId\?: string|areaId/); + assert.match(dto, /inspectorId\?: string/); + assert.match(dto, /dateFrom\?: string/); + assert.match(dto, /dateTo\?: string/); + } +}); + +test('D5.3.19 keeps one visit equal to one act', () => { + assert.match(actsService, /await this\.assertVisitHasNoAct\(manager, visitId\)/); + assert.match(actsService, /INSPECTION_VISIT_ACT_ALREADY_EXISTS/); +}); + +test('D5.3.19 does not introduce the contextual finding catalog before its office definition', () => { + assert.doesNotMatch(migration, /finding_catalog.*asset_type|asset_type.*finding_catalog/i); +}); diff --git a/api-v3/test/unit/phase-d5-3-20-automatic-word-report.test.ts b/api-v3/test/unit/phase-d5-3-20-automatic-word-report.test.ts new file mode 100644 index 0000000..da3d897 --- /dev/null +++ b/api-v3/test/unit/phase-d5-3-20-automatic-word-report.test.ts @@ -0,0 +1,41 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const read = (path: string) => readFileSync(resolve(process.cwd(), path), 'utf8'); +const migration = read('src/database/migrations/1788458400000-phase-d5-3-20-automatic-word-report.ts'); +const closing = read('src/inspection-closing/inspection-closing.service.ts'); +const reports = read('src/inspection-reports/inspection-reports.service.ts'); +const controller = read('src/inspection-reports/inspection-reports.controller.ts'); +const builder = read('src/inspection-reports/inspection-report-word-builder.ts'); +const wordService = read('src/inspection-reports/inspection-report-word.service.ts'); + +test('D5.3.20 freezes one report automatically in the same act-closing transaction', () => { + assert.match(closing, /await this\.reports\.ensureFrozenReport\(manager, actId, principal, request\)/); + assert.match(closing, /await this\.reports\.ensureWordForAct\(actId\)/); + assert.match(reports, /async ensureFrozenReport\(/); + assert.match(reports, /SELECT id FROM inspection_reports WHERE act_id/); +}); + +test('D5.3.20 stores an integrity-checked Word artifact without changing the frozen report', () => { + assert.match(migration, /ADD COLUMN word_status varchar\(24\)/); + assert.match(migration, /word_sha256 char\(64\)/); + assert.match(migration, /word_generated_at timestamptz/); + assert.match(wordService, /createHash\('sha256'\)/); + assert.match(wordService, /word_status = 'READY'/); + assert.match(controller, /@Get\(':id\/word'\)/); +}); + +test('D5.3.20 builds OOXML Word from the frozen act snapshot without external document dependencies', () => { + assert.match(builder, /\[Content_Types\]\.xml/); + assert.match(builder, /word\/document\.xml/); + assert.match(builder, /0x04034b50/); + assert.match(builder, /buildInspectionReportWord/); + assert.doesNotMatch(builder, /nodemailer|libreoffice|pandoc/i); +}); + +test('D5.3.20 preserves one visit one act and leaves email delivery for a configured phase', () => { + assert.match(closing, /findingsRemainOpen: true/); + assert.doesNotMatch(migration, /smtp|email_outbox|mail_delivery/i); +}); diff --git a/api-v3/test/unit/phase-d5-3-21-document-delivery.test.ts b/api-v3/test/unit/phase-d5-3-21-document-delivery.test.ts new file mode 100644 index 0000000..dc6d36e --- /dev/null +++ b/api-v3/test/unit/phase-d5-3-21-document-delivery.test.ts @@ -0,0 +1,7 @@ +import assert from 'node:assert/strict'; import test from 'node:test'; import {readFileSync} from 'node:fs'; import {resolve} from 'node:path'; +const read=(path:string)=>readFileSync(resolve(process.cwd(),path),'utf8'); const migration=read('src/database/migrations/1788544800000-phase-d5-3-21-document-delivery.ts'); const delivery=read('src/inspection-reports/inspection-document-delivery.service.ts'); const smtp=read('src/inspection-reports/smtp-delivery.service.ts'); const pdf=read('src/inspection-reports/inspection-act-pdf-builder.ts'); const controller=read('src/inspection-reports/document-delivery.controller.ts'); const reports=read('src/inspection-reports/inspection-reports.service.ts'); +test('D5.3.21 stores institutional and company recipients without hardcoding addresses',()=>{assert.match(migration,/institutional_delivery_settings/);assert.match(migration,/notification_email varchar\(320\)/);assert.match(delivery,/office_email/);assert.match(delivery,/director_email/);assert.doesNotMatch(delivery,/@gmail\.com|@hotmail\.com|@mendoza\.gov/i);}); +test('D5.3.21 creates an immutable act PDF and separates its delivery from the report Word',()=>{assert.match(migration,/inspection_act_pdf_artifacts/);assert.match(migration,/ACT_PDF/);assert.match(migration,/REPORT_WORD/);assert.match(pdf,/%PDF-1\.4/);assert.match(delivery,/this\.pdf\.ensure/);assert.match(delivery,/this\.word\.ensure/);}); +test('D5.3.21 keeps an auditable retryable outbox with explicit waiting states',()=>{assert.match(migration,/WAITING_RECIPIENT/);assert.match(migration,/WAITING_TRANSPORT/);assert.match(migration,/WAITING_ARTIFACT/);assert.match(delivery,/export interface DeliveryRow/);assert.match(controller,/Promise/);assert.match(controller,/outbox\/:id\/retry/);assert.match(controller,/retry-pending/);}); +test('D5.3.21 only attempts SMTP when transport and recipients are configured',()=>{assert.match(smtp,/SMTP_HOST/);assert.match(smtp,/MAIL_FROM/);assert.match(smtp,/STARTTLS/);assert.match(delivery,/this\.smtp\.configured\(\)/);assert.match(delivery,/WAITING_TRANSPORT/);}); +test('D5.3.21 triggers document delivery after the frozen report Word step without changing one visit one act',()=>{assert.match(reports,/await this\.delivery\.dispatchForAct\(actId\)/); const acts=read('src/inspection-acts/inspection-acts.service.ts');assert.match(acts,/assertVisitHasNoAct/);assert.match(acts,/INSPECTION_VISIT_ACT_ALREADY_EXISTS/);}); diff --git a/api-v3/test/unit/phase-d5-3-22-director-report-review.test.ts b/api-v3/test/unit/phase-d5-3-22-director-report-review.test.ts new file mode 100644 index 0000000..69cf705 --- /dev/null +++ b/api-v3/test/unit/phase-d5-3-22-director-report-review.test.ts @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const read = (path: string) => readFileSync(resolve(process.cwd(), path), 'utf8'); +const migration = read('src/database/migrations/1788631200000-phase-d5-3-22-director-report-review.ts'); +const review = read('src/inspection-reports/inspection-report-review.service.ts'); +const controller = read('src/inspection-reports/inspection-report-review.controller.ts'); +const word = read('src/inspection-reports/inspection-report-word.service.ts'); +const acts = read('src/inspection-acts/inspection-acts.service.ts'); +const findings = read('src/inspection-findings/inspection-findings.service.ts'); + +test('D5.3.22 keeps report corrections as immutable numbered Word revisions', () => { + assert.match(migration, /inspection_report_revisions/); + assert.match(migration, /UNIQUE \(report_id, revision_number\)/); + assert.match(migration, /source IN \('AUTO','DIRECTOR_UPLOAD'\)/); + assert.match(word, /'AUTO'/); + assert.match(review, /'DIRECTOR_UPLOAD'/); + assert.match(review, /current_revision_number = \$2/); +}); + +test('D5.3.22 reserves revision approval and final signature to the Director role', () => { + assert.match(migration, /inspection_reports\.revise/); + assert.match(migration, /inspection_reports\.review/); + assert.match(migration, /inspection_reports\.sign_final/); + assert.match(migration, /\('director', 'inspection_reports\.sign_final'\)/); + assert.doesNotMatch(migration, /\('admin', 'inspection_reports\.sign_final'\)/); + assert.match(review, /role\.code = 'director'/); + assert.match(controller, /@RequirePermissions\('inspection_reports\.sign_final'\)/); +}); + +test('D5.3.22 approves exactly the latest revision before allowing a final signature', () => { + assert.match(review, /revision_number = \$2/); + assert.match(review, /approved_revision_id = \$2/); + assert.match(review, /review_status = 'APPROVED'/); + assert.match(review, /INSPECTION_REPORT_NOT_APPROVED/); + assert.match(review, /review_status = 'SIGNED'/); +}); + +test('D5.3.22 binds the final Director signature to report and revision hashes', () => { + assert.match(migration, /inspection_report_signatures/); + assert.match(review, /DH-INSPECTION-REPORT-SIGNATURE-V1/); + assert.match(review, /reportFrozenSha256/); + assert.match(review, /revisionSha256/); + assert.match(review, /sha256CanonicalJson\(payload\)/); + assert.match(review, /INSPECTION_REPORT_ALREADY_SIGNED/); +}); + +test('D5.3.22 changes only the report review layer and preserves inspection immutability', () => { + assert.match(acts, /assertVisitHasNoAct/); + assert.match(acts, /INSPECTION_VISIT_ACT_ALREADY_EXISTS/); + assert.match(findings, /INSPECTION_FINDING_RESPONSE_IMMUTABLE/); + assert.doesNotMatch(migration, /ALTER TABLE inspection_acts/); + assert.doesNotMatch(migration, /ALTER TABLE inspection_findings/); +}); diff --git a/api-v3/test/unit/phase-d5-3-23-field-discoveries.test.ts b/api-v3/test/unit/phase-d5-3-23-field-discoveries.test.ts new file mode 100644 index 0000000..21a265c --- /dev/null +++ b/api-v3/test/unit/phase-d5-3-23-field-discoveries.test.ts @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const read = (path: string) => readFileSync(resolve(process.cwd(), path), 'utf8'); +const migration = read('src/database/migrations/1788717600000-phase-d5-3-23-field-discoveries.ts'); +const assets = read('src/asset-master/assets.service.ts'); +const inspectionLinks = read('src/inspection-operations/field-discovery-inspection-link.service.ts'); +const controller = read('src/asset-master/assets.controller.ts'); +const mobilePolicy = read('src/inspection-operations/mobile-inspector-policy.ts'); +const findings = read('src/inspection-findings/inspection-findings.service.ts'); + + test('D5.3.23 records field discoveries as auditable provisional inventory records', () => { + assert.match(migration, /asset_field_discoveries/); + assert.match(migration, /status IN \('PENDING','APPROVED','MATCHED','REJECTED'\)/); + assert.match(assets, /AssetDataOrigin\.FIELD_SURVEY/); + assert.match(assets, /AssetInformationStatus\.DRAFT/); + assert.match(assets, /inspection-visit:/); +}); + +test('D5.3.23 only lets a mobile assigned inspector create during an active visit', () => { + assert.match(assets, /assertMobileInspector\(principal\)/); + assert.match(mobilePolicy, /principal\.transport !== 'bearer'/); + assert.match(assets, /visit\.status !== 'IN_PROGRESS'/); + assert.match(assets, /FIELD_DISCOVERY_INSPECTOR_NOT_ASSIGNED/); +}); + +test('D5.3.23 immediately links the provisional object to the visit and open act', () => { + assert.match(assets, /fieldDiscoveryInspectionLinks\.attach/); + assert.doesNotMatch(assets, /INSERT INTO inspection_/); + assert.match(inspectionLinks, /INSERT INTO inspection_visit_assets/); + assert.match(inspectionLinks, /INSERT INTO inspection_act_assets/); + assert.match(inspectionLinks, /FIELD_DISCOVERY_ACT_NOT_EDITABLE/); + assert.match(findings, /assertActAsset/); +}); + +test('D5.3.23 office review can approve match or reject without deleting historical objects', () => { + assert.match(controller, /field-discoveries\/:id\/approve/); + assert.match(controller, /field-discoveries\/:id\/match/); + assert.match(controller, /field-discoveries\/:id\/reject/); + assert.match(controller, /@RequirePermissions\('assets\.change_status'\)/); + assert.match(assets, /AssetInformationStatus\.VALIDATED/); + assert.match(assets, /ASSET_TYPE_CHANGE_NOT_ALLOWED/); + assert.match(assets, /AssetInformationStatus\.INACTIVE/); + assert.doesNotMatch(assets, /DELETE FROM assets/); +}); + +test('D5.3.23 preserves immutable inspection history when matching an existing inventory object', () => { + assert.match(migration, /matched_asset_id uuid/); + assert.match(migration, /matched_asset_id <> asset_id/); + assert.match(assets, /FIELD_DISCOVERY_MATCH_CONTEXT_MISMATCH/); + assert.doesNotMatch(assets, /UPDATE inspection_findings SET asset_id/); + assert.doesNotMatch(assets, /UPDATE inspection_act_assets SET asset_id/); +}); diff --git a/api-v3/test/unit/phase-d5-3-24-asset-context-history.test.ts b/api-v3/test/unit/phase-d5-3-24-asset-context-history.test.ts new file mode 100644 index 0000000..0f467b1 --- /dev/null +++ b/api-v3/test/unit/phase-d5-3-24-asset-context-history.test.ts @@ -0,0 +1,51 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const read = (path: string) => readFileSync(resolve(process.cwd(), path), 'utf8'); +const migration = read('src/database/migrations/1788804000000-phase-d5-3-24-asset-context-history.ts'); +const assets = read('src/asset-master/assets.service.ts'); +const controller = read('src/asset-master/assets.controller.ts'); +const temporal = read('src/asset-master/asset-temporal.service.ts'); +const versionEntity = read('src/database/entities/asset-version.entity.ts'); + +test('D5.3.24 stores parent area and operator as explicit non-overlapping temporal context', () => { + assert.match(migration, /CREATE TABLE asset_context_history/); + assert.match(migration, /valid_from timestamptz NOT NULL/); + assert.match(migration, /valid_until timestamptz/); + assert.match(migration, /uq_asset_context_history_active/); + assert.match(migration, /operational_area_id IS NULL AND operator_company_id IS NULL/); + assert.match(migration, /REVOKE DELETE ON TABLE asset_context_history/); +}); + +test('D5.3.24 reconstructs previous context from immutable asset versions during migration', () => { + assert.match(migration, /FROM asset_versions version/); + assert.match(migration, /LAG\(NULLIF\(version\.snapshot #>> '\{parent,id\}'/); + assert.match(migration, /LEAD\(changes\.occurred_at\)/); + assert.match(migration, /Cambio de contexto reconstruido desde el historial versionado/); +}); + +test('D5.3.24 separates ordinary editing from an audited context transfer', () => { + assert.match(controller, /@Get\(':id\/context-history'\)/); + assert.match(controller, /@Post\(':id\/context'\)/); + assert.match(controller, /@RequirePermissions\('assets\.manage_context'\)/); + assert.match(assets, /ASSET_CONTEXT_CHANGE_REQUIRES_TRANSFER/); + assert.match(assets, /AssetVersionChangeType\.CONTEXT_CHANGED/); + assert.match(versionEntity, /CONTEXT_CHANGED = 'CONTEXT_CHANGED'/); + assert.match(assets, /AuditAction\.ASSET_CONTEXT_CHANGED/); +}); + +test('D5.3.24 closes the previous interval and appends the replacement without deleting history', () => { + assert.match(assets, /UPDATE asset_context_history[\s\S]*SET valid_until=\$2/); + assert.match(assets, /INSERT INTO asset_context_history/); + assert.match(assets, /change_reason/); + assert.doesNotMatch(assets, /DELETE FROM asset_context_history/); +}); + +test('D5.3.24 temporal reconstruction resolves context by effective validity at the requested instant', () => { + assert.match(temporal, /FROM asset_context_history history/); + assert.match(temporal, /history\.valid_from <= \$2/); + assert.match(temporal, /history\.valid_until IS NULL OR history\.valid_until > \$2/); + assert.match(temporal, /operatorCompany: context\.operatorCompany/); +}); diff --git a/api-v3/test/unit/phase-d5-3-25-verification-event-history.test.ts b/api-v3/test/unit/phase-d5-3-25-verification-event-history.test.ts new file mode 100644 index 0000000..168e608 --- /dev/null +++ b/api-v3/test/unit/phase-d5-3-25-verification-event-history.test.ts @@ -0,0 +1,51 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const read = (path: string) => readFileSync(resolve(process.cwd(), path), 'utf8'); +const migration = read('src/database/migrations/1788890400000-phase-d5-3-25-verification-event-history.ts'); +const findings = read('src/inspection-findings/inspection-findings.service.ts'); +const findingsController = read('src/inspection-findings/inspection-findings.controller.ts'); +const verifications = read('src/inspection-verifications/inspection-verifications.service.ts'); +const ledger = read('src/inspection-verifications/verification-event-ledger.ts'); + +test('D5.3.25 creates an append-only verification event ledger and reconstructs prior history', () => { + assert.match(migration, /CREATE TABLE inspection_finding_verification_events/); + assert.match(migration, /FROM inspection_finding_versions version/); + assert.match(migration, /FROM inspection_finding_verification_visits verification_link/); + assert.match(migration, /REVOKE UPDATE, DELETE ON TABLE inspection_finding_verification_events/); + assert.doesNotMatch(ledger, /UPDATE inspection_finding_verification_events/); + assert.doesNotMatch(ledger, /DELETE FROM inspection_finding_verification_events/); +}); + +test('D5.3.25 records every office control-date change without replacing historical events', () => { + assert.match(findings, /dto\.nextControlOn !== undefined && dto\.nextControlOn !== before\.nextControlOn/); + assert.match(findings, /InspectionFindingVerificationEventType\.CONTROL_DATE_DEFINED/); + assert.match(findings, /InspectionFindingVerificationEventType\.CONTROL_DATE_CHANGED/); + assert.match(findings, /InspectionFindingVerificationEventType\.CONTROL_DATE_CLEARED/); + assert.match(findings, /appendVerificationEvent\(manager/); +}); + +test('D5.3.25 makes one field result immutable and appends its result event', () => { + assert.match(verifications, /VERIFICATION_RESULT_IMMUTABLE/); + assert.match(verifications, /InspectionFindingVerificationEventType\.RESULT_RECORDED/); + assert.match(migration, /protect_inspection_verification_result_immutability/); + assert.match(migration, /trg_inspection_verification_result_immutable/); + assert.match(migration, /OLD\.result_recorded_at IS NOT NULL/); +}); + +test('D5.3.25 preserves successive verification visits as separate historical attempts', () => { + assert.match(verifications, /visit\.status IN \('DRAFT', 'PLANNED', 'IN_PROGRESS'\)/); + assert.match(verifications, /INSERT INTO inspection_finding_verification_visits/); + assert.match(verifications, /InspectionFindingVerificationEventType\.VISIT_PLANNED/); + assert.doesNotMatch(verifications, /DELETE FROM inspection_finding_verification_visits/); +}); + +test('D5.3.25 exposes the complete verification history through finding detail and a dedicated endpoint', () => { + assert.match(findingsController, /@Get\(':id\/verification-history'\)/); + assert.match(findings, /loadVerificationHistory/); + assert.match(findings, /inspection_finding_verification_events event/); + assert.match(findings, /verificationHistory/); + assert.match(findings, /evidenceCount/); +}); diff --git a/api-v3/test/unit/phase-d5-3-3-definitive-model.test.ts b/api-v3/test/unit/phase-d5-3-3-definitive-model.test.ts new file mode 100644 index 0000000..74d99ce --- /dev/null +++ b/api-v3/test/unit/phase-d5-3-3-definitive-model.test.ts @@ -0,0 +1,41 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import test from 'node:test'; + +async function source(relativePath: string): Promise { + return readFile(resolve(process.cwd(), 'src', relativePath), 'utf8'); +} + +test('D5.3.3 separates operational state from data quality and adds registry structures', async () => { + const migration = await source('database/migrations/1787508000000-phase-d5-3-3-definitive-operational-model.ts'); + assert.match(migration, /CREATE TYPE asset_operational_status/); + assert.match(migration, /CREATE TABLE organization_profiles/); + assert.match(migration, /CREATE TABLE organization_memberships/); + assert.match(migration, /CREATE TABLE source_documents/); + assert.match(migration, /CREATE TABLE asset_external_identifiers/); + assert.match(migration, /CREATE TABLE area_legal_rights/); + assert.match(migration, /CREATE TABLE area_legal_right_organizations/); + assert.match(migration, /relation_role='OPERATOR'/); + assert.match(migration, /organization cannot be inactivated while operational relations, assignments, memberships or legal participation are active/); +}); + +test('D5.3.3 keeps physical hierarchy separate from legal and organizational relations', async () => { + const bootstrap = await source('asset-master/asset-master-bootstrap.ts'); + const registry = await source('asset-master/asset-registry.service.ts'); + assert.match(bootstrap, /name: 'Área'/); + assert.match(bootstrap, /name: 'Organización'/); + assert.match(bootstrap, /text\('cuenca', 'Cuenca'/); + assert.match(bootstrap, /text\('departamento', 'Departamento'/); + assert.match(registry, /OrganizationKind\.UTE/); + assert.match(registry, /AreaLegalRight/); + assert.match(registry, /source_documents/); +}); + +test('D5.3.3 only accepts OPERATOR relations for exclusive asset assignment', async () => { + const assets = await source('asset-master/assets.service.ts'); + const relations = await source('asset-master/asset-operational-relations.service.ts'); + assert.match(assets, /relation_role = 'OPERATOR'/); + assert.match(relations, /relation\.relation_role = 'OPERATOR'/); + assert.match(relations, /relationRole/); +}); diff --git a/api-v3/test/unit/phase-d5-4-1-build-contract.test.ts b/api-v3/test/unit/phase-d5-4-1-build-contract.test.ts new file mode 100644 index 0000000..d021c1d --- /dev/null +++ b/api-v3/test/unit/phase-d5-4-1-build-contract.test.ts @@ -0,0 +1,13 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const service = readFileSync(resolve(process.cwd(), 'src/inspection-findings/finding-catalog.service.ts'), 'utf8'); + +test('D5.4.1 exports controller-visible selection views so Nest declaration build can name them', () => { + assert.match(service, /export interface CatalogSelectionItem/); + assert.match(service, /export interface AssetSelectionItem extends CatalogSelectionItem/); + assert.match(service, /export interface AssetTypeSelectionView/); + assert.match(service, /export interface AssetSelectionView/); +}); diff --git a/api-v3/test/unit/phase-d5-4-contextual-finding-catalog.test.ts b/api-v3/test/unit/phase-d5-4-contextual-finding-catalog.test.ts new file mode 100644 index 0000000..0369f07 --- /dev/null +++ b/api-v3/test/unit/phase-d5-4-contextual-finding-catalog.test.ts @@ -0,0 +1,65 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const read = (path: string) => readFileSync(resolve(process.cwd(), path), 'utf8'); +const migration = read('src/database/migrations/1788976800000-phase-d5-4-contextual-finding-catalog.ts'); +const catalogService = read('src/inspection-findings/finding-catalog.service.ts'); +const catalogController = read('src/inspection-findings/finding-catalog.controller.ts'); +const findingsService = read('src/inspection-findings/inspection-findings.service.ts'); +const createFindingDto = read('src/inspection-findings/dto/create-inspection-finding.dto.ts'); +const catalogItemDto = read('src/inspection-findings/dto/create-finding-catalog-item.dto.ts'); +const closingService = read('src/inspection-closing/inspection-closing.service.ts'); +const wordBuilder = read('src/inspection-reports/inspection-report-word-builder.ts'); + +test('D5.4 models type applicability and concrete Inventory exceptions separately', () => { + assert.match(migration, /CREATE TABLE finding_catalog_asset_type_profiles/); + assert.match(migration, /CREATE TABLE finding_catalog_item_asset_types/); + assert.match(migration, /CREATE TABLE finding_catalog_asset_overrides/); + assert.match(catalogService, /replaceAssetTypeSelection/); + assert.match(catalogService, /replaceAssetSelection/); + assert.match(catalogService, /typeDefaultEnabled/); +}); + +test('D5.4 exposes only applicable catalog items and enforces the same rule on field creation', () => { + assert.match(catalogController, /@Get\('applicable\/:assetId'\)/); + assert.match(catalogService, /listApplicableForAsset/); + assert.match(findingsService, /FINDING_CATALOG_ITEM_NOT_APPLICABLE/); + assert.match(findingsService, /finding_catalog_asset_type_profiles profile/); + assert.match(findingsService, /finding_catalog_asset_overrides override/); + assert.match(findingsService, /await this\.requireCatalogItem\(manager, finding\.catalogItemId, dto\.assetId\)/); +}); + +test('D5.4 stores suggested and actual severity independently on the 1 to 10 scale', () => { + assert.match(migration, /suggested_severity smallint/); + assert.match(migration, /severity smallint/); + assert.match(migration, /BETWEEN 1 AND 10/); + assert.match(catalogItemDto, /@Min\(1\)/); + assert.match(catalogItemDto, /@Max\(10\)/); + assert.match(createFindingDto, /severity\?: number/); + assert.match(findingsService, /suggestedSeverity: catalog\?\.suggestedSeverity/); + assert.match(findingsService, /severity: dto\.severity \?\? catalog\?\.suggestedSeverity/); + assert.match(closingService, /finding\.suggested_severity AS "suggestedSeverity"/); + assert.match(closingService, /finding\.severity/); + assert.match(wordBuilder, /'Gravedad'/); +}); + +test('D5.4 turns OTHER into an office proposal without rewriting the original finding', () => { + assert.match(migration, /CREATE TABLE finding_catalog_proposals/); + assert.match(findingsService, /INSERT INTO finding_catalog_proposals/); + assert.match(catalogService, /FINDING_CATALOG_PROPOSAL_ALREADY_REVIEWED/); + assert.match(catalogService, /status = 'MATCHED'/); + assert.match(catalogService, /status = 'REJECTED'/); + assert.doesNotMatch(catalogService, /UPDATE inspection_findings SET catalog_item_id/); +}); + +test('D5.4 keeps contextual catalog administration in office permissions and audited routes', () => { + assert.match(catalogController, /@Put\('asset-types\/:assetTypeId\/selection'\)/); + assert.match(catalogController, /@Put\('assets\/:assetId\/selection'\)/); + assert.match(catalogController, /@Get\('proposals'\)/); + assert.match(catalogController, /finding_catalog\.manage/); + assert.match(catalogService, /FINDING_CATALOG_TYPE_APPLICABILITY_UPDATED/); + assert.match(catalogService, /FINDING_CATALOG_ASSET_SELECTION_UPDATED/); + assert.match(catalogService, /FINDING_CATALOG_PROPOSAL_MATCHED/); +}); diff --git a/api-v3/test/unit/phase-d5-5-web-planning-checklist.test.ts b/api-v3/test/unit/phase-d5-5-web-planning-checklist.test.ts new file mode 100644 index 0000000..90d58ea --- /dev/null +++ b/api-v3/test/unit/phase-d5-5-web-planning-checklist.test.ts @@ -0,0 +1,66 @@ +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('D5.5 stores explicit Area and Operator context plus append-only checklist generations', () => { + const migration = read('src/database/migrations/1789063200000-phase-d5-5-web-planning-checklist.ts'); + assert.match(migration, /ADD COLUMN operational_area_id uuid/); + assert.match(migration, /ADD COLUMN operator_company_id uuid/); + assert.match(migration, /CREATE TABLE inspection_visit_checklist_items/); + assert.match(migration, /generation_number integer NOT NULL/); + assert.match(migration, /REVOKE UPDATE, DELETE ON TABLE[\s\S]*inspection_visit_checklist_items, inspection_visit_asset_events/); +}); + +test('D5.5 exposes Area then active OPERATOR choices and a server-generated planning checklist', () => { + const controller = read('src/inspection-visits/inspection-visits.controller.ts'); + const service = read('src/inspection-visits/inspection-visits.service.ts'); + assert.match(controller, /@Get\('planning-context\/areas'\)/); + assert.match(controller, /@Get\('planning-context\/areas\/:areaId\/operators'\)/); + assert.match(controller, /@Post\(':id\/checklist\/generate'\)/); + assert.match(service, /relation\.relation_role = 'OPERATOR'/); + assert.match(service, /INSPECTION_OPERATOR_NOT_ACTIVE_IN_AREA/); +}); + +test('D5.5 classifies antecedents overdue items and next controls while adding actionable assets automatically', () => { + const service = read('src/inspection-visits/inspection-visits.service.ts'); + assert.match(service, /'COMPANY_OVERDUE'/); + assert.match(service, /'VERIFICATION_OVERDUE'/); + assert.match(service, /'UPCOMING_CONTROL'/); + assert.match(service, /'ANTECEDENT'/); + assert.match(service, /item_kind IN \('COMPANY_OVERDUE','VERIFICATION_OVERDUE','UPCOMING_CONTROL'\)/); + assert.match(service, /'AUTO_INCLUDED'/); +}); + +test('D5.5 treats manual additions as preventive and requires an auditable reason for exclusions', () => { + const service = read('src/inspection-visits/inspection-visits.service.ts'); + const dto = read('src/inspection-visits/dto/exclude-inspection-visit-asset.dto.ts'); + assert.match(service, /planning_source[^\n]*[\s\S]*'PREVENTIVE'/); + assert.match(service, /INSPECTION_EXCLUSION_REASON_REQUIRED/); + assert.match(service, /INSPECTION_VISIT_ASSET_EXCLUDED/); + assert.match(service, /INSPECTION_VISIT_ASSET_REINCLUDED/); + assert.match(dto, /@MinLength\(10\)/); +}); + +test('D5.5 keeps verification visits inside the same Area Operator planning contract and start remains APK-only', () => { + const verification = read('src/inspection-verifications/inspection-verifications.service.ts'); + const visits = read('src/inspection-visits/inspection-visits.service.ts'); + assert.match(verification, /operationalAreaId: areaId/); + assert.match(verification, /operatorCompanyId: companyId/); + assert.match(verification, /planning_source = 'VERIFICATION'/); + assert.match(visits, /assertMobileInspector\(principal\)/); + assert.match(visits, /INSPECTION_CHECKLIST_REQUIRED/); +}); + +test('D5.5 confirmation requires a reviewed current checklist and keeps visit start under mobile inspector policy', () => { + const visits = read('src/inspection-visits/inspection-visits.service.ts'); + const policy = read('src/inspection-operations/mobile-inspector-policy.ts'); + assert.match(visits, /INSPECTION_CHECKLIST_REQUIRED/); + assert.match(visits, /checklistGeneratedAt/); + assert.match(visits, /assertMobileInspector\(principal\)/); + assert.match(policy, /transport !== 'bearer'/); + assert.match(policy, /includes\('inspector'\)/); +}); diff --git a/api-v3/test/unit/phase-d5-6-1-user-password-reset.test.ts b/api-v3/test/unit/phase-d5-6-1-user-password-reset.test.ts new file mode 100644 index 0000000..a16e9a4 --- /dev/null +++ b/api-v3/test/unit/phase-d5-6-1-user-password-reset.test.ts @@ -0,0 +1,27 @@ +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('D5.6.1 exposes controlled administrative password reset with the existing user-update permission', () => { + const controller = read('src/administration/users/users.controller.ts'); + const dto = read('src/administration/users/dto/reset-user-password.dto.ts'); + assert.match(controller, /@Post\(':id\/reset-password'\)/); + assert.match(controller, /@RequirePermissions\('users.update'\)/); + assert.match(dto, /@MinLength\(12\)/); + assert.match(dto, /mustChangePassword = true/); +}); + +test('D5.6.1 resets lockout revokes sessions and audits without persisting the password value', () => { + const service = read('src/administration/users/users.service.ts'); + const audit = read('src/database/entities/audit-event.entity.ts'); + assert.match(service, /user\.failedLoginAttempts = 0/); + assert.match(service, /user\.lockedUntil = null/); + assert.match(service, /revokeUserSessions\(id, undefined, manager\)/); + assert.match(service, /AuditAction\.USER_PASSWORD_RESET/); + assert.match(service, /passwordValueRecorded: false/); + assert.match(audit, /USER_PASSWORD_RESET = 'USER_PASSWORD_RESET'/); +}); diff --git a/api-v3/test/unit/phase-d5-6-4-quick-inspection-planning.test.ts b/api-v3/test/unit/phase-d5-6-4-quick-inspection-planning.test.ts new file mode 100644 index 0000000..c7f2698 --- /dev/null +++ b/api-v3/test/unit/phase-d5-6-4-quick-inspection-planning.test.ts @@ -0,0 +1,43 @@ +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('D5.6.4 generates inspection codes server-side with an annual transactional sequence', () => { + const generator = read('src/inspection-visits/inspection-visit-code.ts'); + assert.match(generator, /INS-\$\{year\}-\$\{String\(sequence\)\.padStart\(6, '0'\)\}/); + assert.match(generator, /pg_advisory_xact_lock/); + assert.match(generator, /America\/Argentina\/Mendoza/); +}); + +test('D5.6.4 creates one inspection from Area Operator start and lead inspector without manual title or end date', () => { + const dto = read('src/inspection-visits/dto/create-inspection-visit.dto.ts'); + const service = read('src/inspection-visits/inspection-visits.service.ts'); + assert.doesNotMatch(dto, /code!/); + assert.doesNotMatch(dto, /title!/); + assert.doesNotMatch(dto, /plannedEndAt/); + assert.match(dto, /operationalAreaId!/); + assert.match(dto, /operatorCompanyId!/); + assert.match(dto, /plannedStartAt!/); + assert.match(dto, /leadInspectorUserId!/); + assert.match(service, /title: code/); + assert.match(service, /plannedEndAt: null/); +}); + +test('D5.6.4 assigns the lead inspector and generates the checklist in the same creation transaction', () => { + const service = read('src/inspection-visits/inspection-visits.service.ts'); + assert.match(service, /leadInspectorUserId: dto\.leadInspectorUserId/); + assert.match(service, /await this\.replaceMemberLinks\(/); + assert.match(service, /await this\.generateChecklistInternal\(manager, visit, principal\.userId\)/); +}); + +test('D5.6.4 uses the same inspection numbering and start-only planning for verification visits', () => { + const dto = read('src/inspection-verifications/dto/plan-verification-visit.dto.ts'); + const service = read('src/inspection-verifications/inspection-verifications.service.ts'); + assert.doesNotMatch(dto, /plannedEndAt/); + assert.match(service, /nextInspectionVisitCode\(manager, plannedStartAt\)/); + assert.match(service, /plannedEndAt: null/); +}); diff --git a/api-v3/test/unit/phase-d5-6-phase-d-closure.test.ts b/api-v3/test/unit/phase-d5-6-phase-d-closure.test.ts new file mode 100644 index 0000000..ea91cbc --- /dev/null +++ b/api-v3/test/unit/phase-d5-6-phase-d-closure.test.ts @@ -0,0 +1,83 @@ +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('D5.6 preserves one visit one immutable act with a frozen closure snapshot', () => { + const acts = read('src/inspection-acts/inspection-acts.service.ts'); + const closing = read('src/inspection-closing/inspection-closing.service.ts'); + assert.match(acts, /INSPECTION_VISIT_ACT_ALREADY_EXISTS/); + assert.match(closing, /final_snapshot = \$2/); + assert.match(closing, /final_sha256 = \$3/); + assert.match(closing, /InspectionActVersionEvent\.CLOSED/); +}); + +test('D5.6 preserves findings responses evidence and verification history as non destructive follow-up', () => { + const findings = read('src/inspection-findings/inspection-findings.service.ts'); + const evidence = read('src/inspection-findings/inspection-evidence.service.ts'); + const verification = read('src/inspection-verifications/inspection-verifications.service.ts'); + const ledger = read('src/inspection-verifications/verification-event-ledger.ts'); + assert.match(findings, /INSPECTION_FINDING_RESPONSE_IMMUTABLE/); + assert.match(evidence, /immutable: true/); + assert.match(verification, /VERIFICATION_RESULT_IMMUTABLE/); + assert.match(ledger, /inspection_finding_verification_events/); +}); + +test('D5.6 keeps Inventory temporal context and contextual finding applicability independent and historical', () => { + const temporal = read('src/asset-master/asset-temporal.service.ts'); + const assets = read('src/asset-master/assets.service.ts'); + const findings = read('src/inspection-findings/inspection-findings.service.ts'); + assert.match(temporal, /asset_context_history/); + assert.match(assets, /asset_context_history/); + assert.match(findings, /FINDING_CATALOG_ITEM_NOT_APPLICABLE/); + assert.match(findings, /finding_catalog_asset_overrides/); +}); + +test('D5.6 preserves reviewed web planning and APK only visit start', () => { + const visits = read('src/inspection-visits/inspection-visits.service.ts'); + const policy = read('src/inspection-operations/mobile-inspector-policy.ts'); + assert.match(visits, /INSPECTION_CHECKLIST_REQUIRED/); + assert.match(visits, /inspection_visit_checklist_items/); + assert.match(visits, /assertMobileInspector\(principal\)/); + assert.match(policy, /transport !== 'bearer'/); + assert.match(policy, /includes\('inspector'\)/); +}); + +test('D5.6 closes the act then freezes the report and produces its Word artifact', () => { + const closing = read('src/inspection-closing/inspection-closing.service.ts'); + const word = read('src/inspection-reports/inspection-report-word.service.ts'); + assert.match(closing, /ensureFrozenReport\(manager, actId, principal, request\)/); + assert.match(closing, /ensureWordForAct\(actId\)/); + assert.match(word, /buildInspectionReportWord/); + assert.match(word, /inspection_report_revisions/); +}); + +test('D5.6 leaves report revision approval and final signature exclusively under Director validation', () => { + const review = read('src/inspection-reports/inspection-report-review.service.ts'); + assert.match(review, /await this\.assertDirector\(principal\.userId/); + assert.match(review, /review_status = 'APPROVED'/); + assert.match(review, /INSPECTION_REPORT_NOT_APPROVED/); + assert.match(review, /inspection_report_signatures/); + assert.match(review, /Director de Hidrocarburos/); +}); + +test('D5.6 preserves auditable document delivery while allowing SMTP to remain pending', () => { + const delivery = read('src/inspection-reports/inspection-document-delivery.service.ts'); + const smtp = read('src/inspection-reports/smtp-delivery.service.ts'); + assert.match(delivery, /WAITING_TRANSPORT/); + assert.match(delivery, /ACT_PDF/); + assert.match(delivery, /REPORT_WORD/); + assert.match(smtp, /SMTP_HOST/); + assert.match(smtp, /MAIL_FROM/); +}); + +test('D5.6 declares a stable Phase D closure across maintenance hotfixes without introducing another functional migration', () => { + const apiVersion = read('src/version.ts'); + assert.match(apiVersion, /API_VERSION = '0\.19\.6(?:-\d+)?'/); + assert.match(apiVersion, /API_PHASE = 'D5\.6(?:\.\d+)?'/); + const migrations = fs.readdirSync(path.join(root, 'src/database/migrations')); + assert.equal(migrations.some((name) => /phase-d5-6/i.test(name)), false); +}); diff --git a/api-v3/test/unit/survey-execution-authorization.test.ts b/api-v3/test/unit/survey-execution-authorization.test.ts new file mode 100644 index 0000000..9a7f7c4 --- /dev/null +++ b/api-v3/test/unit/survey-execution-authorization.test.ts @@ -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 { 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']); +}); diff --git a/api-v3/test/unit/survey-execution-dto.test.ts b/api-v3/test/unit/survey-execution-dto.test.ts new file mode 100644 index 0000000..fa7a655 --- /dev/null +++ b/api-v3/test/unit/survey-execution-dto.test.ts @@ -0,0 +1,48 @@ +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); +}); diff --git a/api-v3/test/unit/survey-planning-authorization.test.ts b/api-v3/test/unit/survey-planning-authorization.test.ts new file mode 100644 index 0000000..63949ae --- /dev/null +++ b/api-v3/test/unit/survey-planning-authorization.test.ts @@ -0,0 +1,31 @@ +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']); +}); diff --git a/api-v3/test/unit/survey-planning-dto.test.ts b/api-v3/test/unit/survey-planning-dto.test.ts new file mode 100644 index 0000000..4df9a52 --- /dev/null +++ b/api-v3/test/unit/survey-planning-dto.test.ts @@ -0,0 +1,35 @@ +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); +}); diff --git a/api-v3/test/unit/token.service.test.ts b/api-v3/test/unit/token.service.test.ts new file mode 100644 index 0000000..934c45e --- /dev/null +++ b/api-v3/test/unit/token.service.test.ts @@ -0,0 +1,53 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { JwtService } from '@nestjs/jwt'; +import { AuthConfigService } from '../../src/common/config/auth-config.service'; +import { TokenService } from '../../src/auth/services/token.service'; + +function config(): AuthConfigService { + return { + accessTokenSecret: 'a'.repeat(64), + refreshTokenPepper: 'b'.repeat(64), + accessTokenTtlSeconds: 900, + refreshTokenTtlSeconds: 604800, + maxLoginAttempts: 5, + lockoutSeconds: 900, + webOrigin: 'https://dhv2.korexlabs.com', + accessCookieName: 'dhv2_access', + refreshCookieName: 'dhv2_refresh', + csrfCookieName: 'dhv2_csrf', + } as AuthConfigService; +} + +test('TokenService issues and verifies access tokens', async () => { + const service = new TokenService(new JwtService(), config()); + const token = await service.issueAccessToken({ + userId: 'b6f6b10e-434a-4d72-9ec9-3520212dd290', + sessionId: '1bab61ac-65d1-40e6-816b-63d90131a795', + username: 'admin', + }); + const payload = await service.verifyAccessToken(token); + + assert.equal(payload.sub, 'b6f6b10e-434a-4d72-9ec9-3520212dd290'); + assert.equal(payload.sid, '1bab61ac-65d1-40e6-816b-63d90131a795'); + assert.equal(payload.typ, 'access'); +}); + +test('TokenService rotates opaque refresh credentials without storing plaintext', () => { + const service = new TokenService(new JwtService(), config()); + const issued = service.issueRefreshToken(); + + assert.deepEqual(service.parseRefreshToken(issued.token), { + sessionId: issued.sessionId, + }); + assert.equal(service.verifyRefreshToken(issued.token, issued.tokenHash), true); + const replacement = issued.token.endsWith('x') ? 'y' : 'x'; + assert.equal( + service.verifyRefreshToken( + `${issued.token.slice(0, -1)}${replacement}`, + issued.tokenHash, + ), + false, + ); + assert.equal(issued.tokenHash.includes(issued.token), false); +}); diff --git a/api-v3/tsconfig.json b/api-v3/tsconfig.json new file mode 100644 index 0000000..f7819ec --- /dev/null +++ b/api-v3/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2023", + "sourceMap": false, + "rootDir": "./src", + "outDir": "./dist", + "baseUrl": "./", + "incremental": false, + "skipLibCheck": true, + "strict": true, + "strictNullChecks": true, + "noImplicitAny": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "test"] +} diff --git a/api-v3/tsconfig.test.json b/api-v3/tsconfig.test.json new file mode 100644 index 0000000..3101dfb --- /dev/null +++ b/api-v3/tsconfig.test.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "rootDir": ".", + "incremental": false + }, + "include": ["src/**/*.ts", "test/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..12ab715 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,119 @@ +services: + db: + image: postgis/postgis:16-3.5 + container_name: dhv2-db + restart: unless-stopped + shm_size: 256mb + environment: + POSTGRES_DB: ${DB_NAME} + POSTGRES_USER: ${DB_OWNER_USER} + POSTGRES_PASSWORD: ${DB_OWNER_PASSWORD} + APP_DB_USER: ${DB_APP_USER} + APP_DB_PASSWORD: ${DB_APP_PASSWORD} + volumes: + - dhv2_pgdata:/var/lib/postgresql/data + - ./infra/db-init:/docker-entrypoint-initdb.d:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_OWNER_USER} -d ${DB_NAME}"] + interval: 10s + timeout: 5s + retries: 10 + networks: + - dhv2_internal + security_opt: + - no-new-privileges:true + + api: + build: + context: ./api-v3 + container_name: dhv2-api + restart: unless-stopped + environment: + NODE_ENV: production + API_PORT: ${API_PORT} + DB_HOST: db + DB_PORT: 5432 + DB_NAME: ${DB_NAME} + DB_APP_USER: ${DB_APP_USER} + DB_APP_PASSWORD: ${DB_APP_PASSWORD} + WEB_ORIGIN: ${WEB_ORIGIN} + JWT_ACCESS_SECRET: ${JWT_ACCESS_SECRET} + REFRESH_TOKEN_PEPPER: ${REFRESH_TOKEN_PEPPER} + ACCESS_TOKEN_TTL_SECONDS: ${ACCESS_TOKEN_TTL_SECONDS:-900} + REFRESH_TOKEN_TTL_SECONDS: ${REFRESH_TOKEN_TTL_SECONDS:-604800} + AUTH_MAX_LOGIN_ATTEMPTS: ${AUTH_MAX_LOGIN_ATTEMPTS:-5} + AUTH_LOCKOUT_SECONDS: ${AUTH_LOCKOUT_SECONDS:-900} + ACCESS_COOKIE_NAME: ${ACCESS_COOKIE_NAME:-dhv2_access} + REFRESH_COOKIE_NAME: ${REFRESH_COOKIE_NAME:-dhv2_refresh} + CSRF_COOKIE_NAME: ${CSRF_COOKIE_NAME:-dhv2_csrf} + ASSET_MEDIA_ROOT: /app/storage/asset-media + ASSET_IMPORT_ROOT: /app/storage/asset-media/imports + INSPECTION_EVIDENCE_ROOT: /app/storage/asset-media/inspection-findings + INSPECTION_REPORT_WORD_ROOT: /app/storage/asset-media/inspection-reports-word + INSPECTION_REPORT_REVISION_ROOT: /app/storage/asset-media/inspection-report-revisions + INSPECTION_ACT_PDF_ROOT: /app/storage/asset-media/inspection-acts-pdf + SMTP_HOST: ${SMTP_HOST:-} + SMTP_PORT: ${SMTP_PORT:-587} + SMTP_SECURE: ${SMTP_SECURE:-false} + SMTP_USER: ${SMTP_USER:-} + SMTP_PASS: ${SMTP_PASS:-} + MAIL_FROM: ${MAIL_FROM:-} + depends_on: + db: + condition: service_healthy + ports: + - "127.0.0.1:${API_HOST_PORT}:${API_PORT}" + volumes: + - dhv2_asset_media:/app/storage/asset-media + networks: + - dhv2_internal + security_opt: + - no-new-privileges:true + + migrate: + build: + context: ./api-v3 + container_name: dhv2-migrate + profiles: + - tools + restart: "no" + environment: + NODE_ENV: production + DB_HOST: db + DB_PORT: 5432 + DB_NAME: ${DB_NAME} + DB_MIGRATION_USER: ${DB_OWNER_USER} + DB_MIGRATION_PASSWORD: ${DB_OWNER_PASSWORD} + DB_APP_USER: ${DB_APP_USER} + depends_on: + db: + condition: service_healthy + command: ["npm", "run", "migration:run"] + networks: + - dhv2_internal + security_opt: + - no-new-privileges:true + + web: + build: + context: ./web-v2 + container_name: dhv2-web + restart: unless-stopped + depends_on: + - api + ports: + - "127.0.0.1:${WEB_HOST_PORT}:80" + networks: + - dhv2_internal + security_opt: + - no-new-privileges:true + +networks: + dhv2_internal: + name: dhv2_internal + +volumes: + dhv2_pgdata: + name: dhv2_pgdata + dhv2_asset_media: + name: dhv2_asset_media diff --git a/docs/BACKUP_INVENTARIO_A_REVISAR.md b/docs/BACKUP_INVENTARIO_A_REVISAR.md new file mode 100644 index 0000000..3dd6248 --- /dev/null +++ b/docs/BACKUP_INVENTARIO_A_REVISAR.md @@ -0,0 +1,13 @@ +# Punto de retorno — INVENTARIO A REVISAR + +Antes de introducir la interpretación flexible de inventarios técnicos se crea un backup fuerte con la palabra clave exacta **INVENTARIO A REVISAR**. + +Su finalidad es conservar un punto de retorno anterior a la decisión conceptual de tratar la jerarquía posterior a Área/Yacimiento como nomenclatura local de cada operadora, por si esa decisión debe modificarse después de la reunión. + +El backup se guarda en: + +`/root/DH_V2_BACKUPS/INVENTARIO_A_REVISAR_YYYYMMDD_HHMMSS/` + +Incluye dump PostgreSQL custom validado, source, `.env`, `docker-compose.yml`, volumen `dhv2_asset_media`, imágenes Docker exactas de API/Web/Migrate, health, estado de migraciones, snapshot legible del Maestro/importaciones y hashes SHA-256. El directorio y los archivos sensibles quedan restringidos a root. + +El deploy D5.3.8 ejecuta este backup **después de compilar y testear en staging y antes de modificar source, base de datos o contenedores de producción**. Si el backup falla, el deploy se detiene. diff --git a/docs/DATA_REFERENCE_MENDOZA_HYDROCARBONS.md b/docs/DATA_REFERENCE_MENDOZA_HYDROCARBONS.md new file mode 100644 index 0000000..66e546e --- /dev/null +++ b/docs/DATA_REFERENCE_MENDOZA_HYDROCARBONS.md @@ -0,0 +1,69 @@ +# Referencia de datos — Hidrocarburos Mendoza + +Fecha de relevamiento de referencia: 2026-08-16. + +Fuente pública principal: + +- https://www.mendoza.gob.ar/energiayambiente/hidrocarburos/ +- versión indexada equivalente: https://sitios.mendoza.gob.ar/energiayambiente/hidrocarburos/ + +## Qué confirma la fuente para el modelo DH + +La publicación oficial organiza la gestión de inspecciones de campo por dos ejes que +coinciden con el modelo D5.3: + +1. **operadora**; +2. **área operativa**. + +La misma fuente publica informes de inspección por inspector/mes y viajes de campo +por operadora y área. Esto refuerza que el contexto obligatorio de una futura +planificación sea `Área → Empresa` y no una jerarquía donde la empresa sea padre +físico del activo. + +Las publicaciones oficiales de Hidrocarburos también describen como datos de interés: + +- áreas y yacimientos; +- geolocalización e identificación de pozos; +- operadores; +- profundidad y tipo de pozo; +- situación legal y estado actual; +- fechas de intervención; +- plazos de concesión; +- instalaciones de superficie; +- producción e inspecciones. + +## Política de precarga de DH + +D5.3.1 **no precarga empresas, áreas, yacimientos ni pozos reales** desde una página +web. Esos registros pueden cambiar por cesiones de concesión, nuevas licitaciones, +renovaciones o cambios de operador. + +En cambio, la configuración inicial instala solamente una taxonomía estable: + +- Área / Concesión; +- Empresa / Operadora; +- Yacimiento; +- Instalación de superficie; +- Estación; +- Subestación; +- Pozo; +- Equipo; +- Ducto / Cañería. + +Además crea atributos opcionales que permiten recibir datos oficiales sin obligar a +completarlos cuando no estén disponibles. + +## Futuro importador de datos oficiales + +Cuando se incorpore una fuente oficial descargable/API, la importación deberá: + +- identificar la fuente y fecha de extracción; +- conservar el identificador oficial cuando exista; +- no sobrescribir silenciosamente datos validados en campo; +- detectar duplicados por código/nombre/ubicación; +- registrar alta, modificación o finalización de relaciones Área–Empresa con vigencia; +- importar activos como `IMPORT` y conservar procedencia; +- permitir revisión humana antes de fusionar datos contradictorios. + +Los valores de tableros públicos (cantidad de informes, viajes, operadoras, etc.) son +métricas temporales y no deben almacenarse como datos maestros fijos. diff --git a/docs/DEV_DEMO_AND_CLEANUP.md b/docs/DEV_DEMO_AND_CLEANUP.md new file mode 100644 index 0000000..fc43cc0 --- /dev/null +++ b/docs/DEV_DEMO_AND_CLEANUP.md @@ -0,0 +1,84 @@ +# DH V2 · Datos de desarrollo y limpieza controlada + +## Objetivo + +Durante el desarrollo se necesita poder probar el sistema con datos representativos sin confundirlos con el futuro padrón real. D5.3.2 incorpora un caso demostrativo identificado de forma inequívoca y un limpiador seguro. + +## Caso demostrativo + +El seed usa el Informe Técnico Nº 124/2026 exclusivamente como referencia de nomenclatura y estructura: + +```text +[PRUEBA] Atamisqui Área +└── [PRUEBA] Planta de Entrega 3 PB Planta + ├── [PRUEBA] Tanque TK-57 Tanque · 480 m³ + └── [PRUEBA] Tanque TK-58 Tanque · 320 m³ + +[PRUEBA] Petróleos Sudamericanos Energy S.A. Empresa/Operadora + ↕ relación operativa +[PRUEBA] Atamisqui +``` + +Todos los activos creados por el seed tienen: + +- código `DEV-DEMO-*`; +- nombre visible con prefijo `[PRUEBA]`; +- procedencia `PROVIDED_DOCUMENT`; +- referencia `DH-DEV-DEMO:ITN124/2026`; +- estado de información `DRAFT`; +- nota expresa de que no son carga maestra real ni validación de vigencia. + +## Crear o reconstruir el ejemplo + +Primero debe haberse completado el catálogo técnico desde `Maestro > Tipos y atributos`. + +```bash +cd /var/www/dhv2.korexlabs.com +bash scripts/dev-seed-mendoza-demo.sh +``` + +El seed rechaza una segunda ejecución mientras existan registros `DEV-DEMO`. + +## Simular limpieza + +Este comando es el recomendado durante el desarrollo. **No elimina nada.** + +```bash +cd /var/www/dhv2.korexlabs.com +bash scripts/dev-clean-demo.sh +``` + +Informa cuántos activos, visitas, actas y relevamientos quedarían afectados. + +## Limpieza real + +Sólo usar cuando se quiera reconstruir el demo o preparar una futura entrega limpia: + +```bash +cd /var/www/dhv2.korexlabs.com +bash scripts/dev-clean-demo.sh --apply DH-DEV-DEMO +``` + +Antes de borrar, el script crea obligatoriamente un `pg_dump` en `/root/DH_V2_BACKUPS/DEV_CLEAN_PRE_*`. + +## Protección contra mezcla PRUEBA / REAL + +El script aborta si detecta que un activo no marcado como demo depende física u operativamente de un activo demo, o si una visita, acta, hallazgo o relevamiento mezcla activos demo con activos no demo. Nunca amplía silenciosamente el conjunto a borrar. + +## Qué se elimina + +Se eliminan los activos `DEV-DEMO` y, cuando son exclusivamente de prueba, sus relaciones, relevamientos, visitas, actas, hallazgos, comunicaciones, evidencias, firmas, versiones y eventos de auditoría asociados. También se retiran los archivos físicos vinculados. + +## Qué se conserva + +Se conservan expresamente: + +- tipos de activo; +- atributos y reglas padre/hijo; +- relaciones/configuración estructural que no sean del demo; +- usuarios; +- roles y permisos; +- catálogo/modelos de hallazgo; +- configuración general del sistema. + +La limpieza de desarrollo no equivale a “vaciar la base”. diff --git a/docs/DOMAIN_RESEARCH_MENDOZA_NOMENCLATURE.md b/docs/DOMAIN_RESEARCH_MENDOZA_NOMENCLATURE.md new file mode 100644 index 0000000..9179619 --- /dev/null +++ b/docs/DOMAIN_RESEARCH_MENDOZA_NOMENCLATURE.md @@ -0,0 +1,86 @@ +# DH V2 · Investigación de nomenclatura hidrocarburífera de Mendoza + +Fecha de trabajo: 16/08/2026 +Estado: referencia funcional para desarrollo. No constituye padrón oficial ni validación jurídica. + +## 1. Criterio principal + +La información analizada converge en un orden operativo estable: + +1. **Área operativa / Área-Concesión**: contexto territorial de la inspección. +2. **Operadora / Empresa inspeccionada**: responsable operativo dentro de esa área. +3. **Yacimiento / Locación**: nivel territorial intermedio cuando existe y aporta valor. +4. **Instalación**: planta, batería, estación, subestación, cargadero, etc. +5. **Sistema**: drenaje, eléctrico/iluminación, defensa contra incendios, zona de bombas, cuando necesita identidad e historial propios. +6. **Equipo / activo físico**: tanque, separador, bomba, caldera, antorcha, filtro, FWKO, tratador, calentador, pozo, etc. +7. **Componente o punto de control**: baranda, escalera, válvula, brida, cartel, manómetro, guardacorrea, etc. No debe convertirse automáticamente en activo. + +DH mantiene separadas dos dimensiones: + +- la **jerarquía física**, representada por `parent_id`; +- el **contexto operativo**, representado por `Área + Empresa`. + +Esto permite que una empresa opere más de un área y que un área tenga más de una empresa a lo largo de su historia, sin mezclar esa relación con la contención física. + +## 2. Nomenclatura observada en fuentes de la Dirección + +La página pública de Hidrocarburos de Mendoza organiza las salidas a campo por **operadora** y por **área operativa**. Ese par se toma como contexto primario para planificación y consulta. + +Los Informes Técnicos recibidos utilizan los campos **Área/Yacimiento**, **Empresa Inspeccionada**, **Ref.** de la instalación, **Acta N°** e **Informe Técnico N°**. Por lo tanto, `Yacimiento` es un nivel opcional: el sistema no debe exigirlo cuando el documento o la operación sólo identifica un área. + +Las notas de inventario relacionan operadoras con áreas y, luego, con inventarios de instalaciones. Las notas no reemplazan las planillas técnicas de inventario: sirven como fuente de procedencia y relación administrativa. + +## 3. Familias técnicas de la planilla de hallazgos + +La lista de hallazgos recibida está organizada por familias técnicas, no por un único concepto genérico de “Equipo”. Entre las familias observadas aparecen: + +- Tanques +- Separadores +- Bomba / Zona de bombas +- Calderas +- Drenaje +- Antorcha +- Colectores +- Sistema eléctrico / iluminación +- Defensa contra incendios +- Cargadero / descargadero de camiones +- Filtros +- Equipo de flotación +- Baterías y plantas +- FWKO / Tratadores / Calentadores +- Pozos por método o función operativa + +Por esa razón D5.3.2 agrega tipos técnicos específicos. `Equipo` se conserva solamente como clasificación genérica cuando el inventario todavía no permite una clasificación mejor. + +## 4. Pozos + +Se mantiene **un solo tipo de activo Pozo**. El bloque de inspección se seleccionará por un atributo de método/función operativa: + +- Bombeo mecánico +- Bombeo electrosumergible +- Bombeo de cavidad progresiva (PCP) +- Surgente / productor de gas +- Inyector de agua +- Otro / a validar + +Así se evita crear cinco árboles de activos distintos para el mismo concepto físico. + +## 5. Activo vs. componente + +Debe ser activo cuando necesita al menos una de estas capacidades: identidad, código, ubicación propia, estado, historial, relaciones, documentación o inspección independiente. + +Debe permanecer como componente/punto de control cuando sólo describe una parte o condición del activo superior y no requiere gestión individual. + +Ejemplo: `Tanque TK-57` es activo. Su `baranda`, `escalera`, `válvula`, `cartelería` o `guardacorrea` pueden ser puntos de control, salvo que la Dirección decida en el futuro administrarlos individualmente. + +## 6. Fuentes de referencia utilizadas + +- Portal oficial: https://sitios.mendoza.gob.ar/energiayambiente/hidrocarburos/ +- Informe Técnico Nº 124/2026, Acta Nº 105/2026. +- Modelo de Informe Técnico suministrado por la Dirección. +- Notas de inventario de CPESA, EMESA, Phoenix, Quintana, Petróleos Sudamericanos, CGC, GyG y PCR recibidas durante el desarrollo. +- Lista de hallazgos suministrada en formato XLSX. + +## 7. Datos cambiantes + +Operadoras, concesiones, denominaciones de áreas y vigencias pueden cambiar. Por eso no se hardcodean como configuración estructural. Cuando se carguen datos reales, deben guardar fuente, fecha observada, usuario, procedencia y, cuando corresponda, vigencia histórica. diff --git a/docs/HOTFIX_A2_1.md b/docs/HOTFIX_A2_1.md new file mode 100644 index 0000000..d914916 --- /dev/null +++ b/docs/HOTFIX_A2_1.md @@ -0,0 +1,7 @@ +# Hotfix DH V2 v0.2.1 + +Corrige el arranque de la API en producción al cargar `cookie-parser` desde +CommonJS. No agrega ni modifica migraciones de base de datos. + +También actualiza la versión visible del footer y del endpoint de salud a +`0.2.1`. diff --git a/docs/INSPECTION_DOCUMENT_MODEL.md b/docs/INSPECTION_DOCUMENT_MODEL.md new file mode 100644 index 0000000..98789a2 --- /dev/null +++ b/docs/INSPECTION_DOCUMENT_MODEL.md @@ -0,0 +1,96 @@ +# Modelo documental de inspecciones + +Este documento fija el modelo funcional validado a partir del Informe Técnico +Nº 124/2026, el acta manuscrita adjunta y la planilla de hallazgos entregada. + +## Canales de operación + +| Función | APK móvil | Dashboard web | +|---|---:|---:| +| Planificar visita, activos y equipo | Consulta | Administra | +| Administrar activos y catálogo de hallazgos | Consulta | Administra | +| Iniciar y ejecutar la visita | Inspector | No | +| Crear o modificar el acta y sus hallazgos | Inspector | Sólo consulta | +| Incorporar evidencia de campo y GPS | Inspector | Sólo consulta | +| Identificar responsable, firmar y cerrar | Inspector | Sólo consulta | +| Registrar respuesta empresarial, PDF y próximo control | Consulta | Administra | +| Consultar acta, hallazgos e informe sincronizados | Sí | Sí | + +La API aplica esta separación además de la interfaz: las escrituras operativas +requieren autenticación bearer de la aplicación móvil y el rol `inspector`. +Una sesión web no puede eludir la restricción llamando directamente al endpoint. + +## Cardinalidades + +| Origen | Relación | Destino | +|---|---:|---| +| Inspección o visita | 1 a 1 | Acta de inspección | +| Acta | 1 a muchos | Hallazgos | +| Hallazgo | muchos a 1 | Activo o equipo del Maestro | +| Hallazgo | muchos a 1 | Ítem del catálogo configurable | +| Hallazgo | 1 a muchos | Evidencias, principalmente fotografías | +| Acta | 1 a muchos | Firmas de inspectores | +| Acta | 1 a 1 | Firma del responsable que atendió la visita | +| Inspección o visita | 1 a 1 | Informe técnico | +| Informe técnico | incorpora | Acta, hallazgos y evidencias congelados | + +## Acta digital + +El acta debe reunir como mínimo: + +- número anual oficial, fecha, hora y ubicación; +- empresa, área, yacimiento o instalación inspeccionada; +- objeto y resultado de la inspección; +- todos los hallazgos de la visita y sus plazos de corrección; +- inspectores intervinientes; +- identidad, DNI, cargo y correo del responsable de la empresa que recibió al + equipo; +- firmas manuscritas capturadas en pantalla de inspectores y responsable; +- versión inmutable, hash, sello de tiempo y token de verificación. + +Al finalizar la visita, el acta se cierra y ya no admite edición. Se genera el +PDF verificable y se envía al correo del responsable de la empresa y a una +dirección interna de Hidrocarburos definida por configuración. Cada intento y +resultado de entrega debe quedar auditado. + +Si se requiere corregir un acta cerrada, se emitirá una rectificativa vinculada +sin reemplazar ni borrar el documento original. + +## Hallazgos + +La planilla entregada contiene 170 modelos de hallazgo agrupados en 19 +categorías de equipos, con descripción, normativa y glosario. Se implementará +como catálogo administrable, importable y versionado. No se copiarán esas +opciones como constantes en la interfaz. + +Cada hallazgo operativo conservará una instantánea de su descripción y base +legal para que posteriores cambios del catálogo no alteren documentos ya +emitidos. También podrá registrar estado, observación específica, activo, +ubicación/GPS, plazo y evidencias. + +Cada hallazgo nace abierto. Se distinguen el plazo exigido, la respuesta y +fecha comprometida por la empresa, y la fecha de próximo control fijada por el +organismo. La respuesta empresarial no cierra el hallazgo: sólo una verificación +posterior autorizada puede hacerlo. + +## Informe técnico + +El informe se genera después del cierre del acta, con numeración anual global +independiente. Siguiendo el modelo entregado, contempla: + +- título, número, acta referenciada, empresa, área/yacimiento y autores; +- objetivos; +- antecedentes; +- marco legal; +- resumen ejecutivo; +- descripción de la inspección y de todos los hallazgos; +- registro fotográfico; +- conclusión; +- acta digital completa como anexo final. + +El informe congela las versiones exactas del acta, hallazgos, activos, +evidencias y normativa que incorpora. + +La solicitud de creación del informe corresponde al flujo del inspector en la +APK después de cerrar la visita. El backend numera, compone y sella el PDF; el +dashboard sólo lo consulta, descarga y administra su seguimiento documental. diff --git a/docs/INSTALL.md b/docs/INSTALL.md new file mode 100644 index 0000000..79e37fb --- /dev/null +++ b/docs/INSTALL.md @@ -0,0 +1,14 @@ +# Instalación resumida + +1. Crear `/var/www/dhv2.korexlabs.com`. +2. Copiar este proyecto dentro. +3. Generar `.env` con contraseñas aleatorias. +4. Generar `package-lock.json` para api y web con Node 24. +5. `docker compose config`. +6. `docker compose build --pull`. +7. `docker compose up -d`. +8. Verificar health local. +9. Instalar Nginx host. +10. Verificar HTTP. +11. Emitir certificado con Certbot. +12. Verificar HTTPS. diff --git a/docs/PHASE_A0_REVIEW.md b/docs/PHASE_A0_REVIEW.md new file mode 100644 index 0000000..3805a17 --- /dev/null +++ b/docs/PHASE_A0_REVIEW.md @@ -0,0 +1,40 @@ +# DH Inspección V2 — Revisión A0 + +Fecha de revisión: 12/08/2026. + +## Backend + +- NestJS 11 con TypeScript estricto. +- `ValidationPipe` usa `whitelist`, `forbidNonWhitelisted` y `transform`. +- Helmet está activo y `x-powered-by` deshabilitado. +- La API está prefijada con `/api/v3`. +- TypeORM usa `synchronize: false` y `migrationsRun: false`. +- El runtime se conecta con `DB_APP_USER`; no recibe credenciales del owner. +- Antes de A1 no había entidades, migraciones, usuarios ni autorización. + +## Base de datos e infraestructura + +- PostgreSQL 16 con PostGIS 3.5. +- La base no publica puertos al host. +- `dhv2_app` ya se crea como rol sin privilegios administrativos. +- Los default privileges estaban previstos en la inicialización de bases nuevas. +- Faltaba una vía controlada para ejecutar migraciones como `dhv2_owner` sobre + la base existente; A1 incorpora el servicio temporal `migrate`. + +## Frontend + +- React 19, Vite y React Router. +- Sólo existe el skeleton de navegación y el health check. +- No hay autenticación ni persistencia de tokens. +- No se modificó el frontend durante A0/A1. + +## Observaciones resueltas + +- El README indicaba PostGIS 3.6; se corrigió a 3.5. +- Se mantuvo `synchronize: false` en runtime y migraciones. +- Las credenciales del owner quedan fuera del contenedor permanente de la API. + +## Próximo bloque + +A2 implementará autenticación, Argon2id, cookies seguras, sesiones rotativas, +lockout, rate limiting y bootstrap seguro del primer administrador. diff --git a/docs/PHASE_A1.md b/docs/PHASE_A1.md new file mode 100644 index 0000000..2f7028c --- /dev/null +++ b/docs/PHASE_A1.md @@ -0,0 +1,46 @@ +# DH Inspección V2 — Fase A1 + +## Alcance implementado + +- entidades TypeORM para usuarios, roles, permisos, sesiones y auditoría; +- cinco migraciones controladas e independientes de `synchronize`; +- seed idempotente de roles, permisos y matriz inicial; +- grants explícitos y default privileges para `dhv2_app`; +- repositorios base para A2, A4 y A5; +- servicio Docker temporal para ejecutar migraciones como owner. + +No se agregaron endpoints públicos ni autenticación incompleta. El único +endpoint disponible continúa siendo `/api/v3/health`. + +## Migraciones + +1. `PhaseAUsersRolesPermissions1786548000000` +2. `PhaseAAuthSessions1786548001000` +3. `PhaseAAuditEvents1786548002000` +4. `PhaseASeedRolesPermissions1786548003000` +5. `PhaseARuntimeGrants1786548004000` + +## Ejecución + +```bash +docker compose --profile tools build api migrate +docker compose up -d db +docker compose --profile tools run --rm migrate +docker compose up -d --no-deps api +``` + +## Verificación + +```bash +docker compose --profile tools run --rm migrate npm run migration:show +docker compose ps +curl -fsS http://127.0.0.1:3101/api/v3/health +``` + +El resultado esperado de `migration:show` es `Pending migrations: no`. + +## Reversión inmediata + +`migration:revert` revierte una sola migración por ejecución. Sólo debe usarse +antes de crear usuarios o sesiones reales. Si ya existe actividad, restaurar el +backup previo en lugar de ejecutar reversiones parciales. diff --git a/docs/PHASE_A1_DEPLOY.md b/docs/PHASE_A1_DEPLOY.md new file mode 100644 index 0000000..e1bdc4f --- /dev/null +++ b/docs/PHASE_A1_DEPLOY.md @@ -0,0 +1,165 @@ +# DH Inspección V2 — Despliegue A1 + +Estos comandos se ejecutan únicamente en `dhv2.korexlabs.com`. + +## 1. Verificación previa + +```bash +cd /var/www/dhv2.korexlabs.com + +docker compose ps +docker compose config >/dev/null && echo "Compose actual OK" +curl -fsS http://127.0.0.1:3101/api/v3/health +``` + +## 2. Backup previo + +```bash +cd /var/www/dhv2.korexlabs.com + +PHASEA1_STAMP="$(date +%Y%m%d_%H%M%S)" +PHASEA1_BACKUP_DIR="/var/www/dh-backup/dhv2-pre-phase-a1-${PHASEA1_STAMP}" + +mkdir -p "$PHASEA1_BACKUP_DIR" + +docker compose exec -T db sh -lc \ + 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' \ + > "$PHASEA1_BACKUP_DIR/database.dump" + +tar -czf "$PHASEA1_BACKUP_DIR/source.tar.gz" \ + --exclude='./api-v3/node_modules' \ + --exclude='./api-v3/dist' \ + --exclude='./web-v2/node_modules' \ + --exclude='./web-v2/dist' \ + . + +install -m 600 .env "$PHASEA1_BACKUP_DIR/.env" +cp /etc/nginx/sites-available/dhv2.korexlabs.com \ + "$PHASEA1_BACKUP_DIR/nginx-site.conf" + +cd "$PHASEA1_BACKUP_DIR" +sha256sum database.dump source.tar.gz .env nginx-site.conf > SHA256SUMS +``` + +Conservar el valor mostrado por: + +```bash +echo "$PHASEA1_BACKUP_DIR" +``` + +## 3. Reemplazo de archivos + +Subir `DH_V2_FASE_A1_SOURCE.zip` a: + +```text +/var/www/dhv2.korexlabs.com +``` + +Después: + +```bash +cd /var/www/dhv2.korexlabs.com +unzip -oq DH_V2_FASE_A1_SOURCE.zip +chmod 600 .env +docker compose config >/dev/null && echo "Compose A1 OK" +``` + +## 4. Build y migraciones + +```bash +cd /var/www/dhv2.korexlabs.com + +docker compose --profile tools build api migrate +docker compose up -d db +docker compose --profile tools run --rm migrate +docker compose --profile tools run --rm migrate npm run migration:show +docker compose up -d --no-deps api +``` + +La migración debe informar cinco migraciones aplicadas y luego: + +```text +Pending migrations: no +``` + +## 5. Verificación de esquema y seeds + +```bash +cd /var/www/dhv2.korexlabs.com + +docker compose exec -T db sh -lc \ + 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -v ON_ERROR_STOP=1' <<'SQL' +SELECT table_name +FROM information_schema.tables +WHERE table_schema = 'public' + AND table_name IN ( + 'users', 'roles', 'permissions', 'user_roles', + 'role_permissions', 'auth_sessions', 'audit_events' + ) +ORDER BY table_name; + +SELECT + (SELECT count(*) FROM roles) AS roles, + (SELECT count(*) FROM permissions) AS permissions, + (SELECT count(*) FROM role_permissions) AS role_permissions; + +SELECT role.code, array_agg(permission.code ORDER BY permission.code) AS permissions +FROM roles role +LEFT JOIN role_permissions mapping ON mapping.role_id = role.id +LEFT JOIN permissions permission ON permission.id = mapping.permission_id +GROUP BY role.code +ORDER BY role.code; +SQL +``` + +Valores iniciales esperados: + +```text +roles = 5 +permissions = 9 +role_permissions = 21 +``` + +## 6. Verificación del usuario runtime + +```bash +cd /var/www/dhv2.korexlabs.com + +docker compose exec -T db sh -lc \ + 'PGPASSWORD="$APP_DB_PASSWORD" psql -U "$APP_DB_USER" -d "$POSTGRES_DB" -v ON_ERROR_STOP=1 -c "SELECT count(*) FROM roles"' +``` + +Este comando debe funcionar sin convertir a `dhv2_app` en owner o superusuario. + +## 7. Verificación final + +```bash +cd /var/www/dhv2.korexlabs.com + +docker compose ps +docker compose logs --tail=100 api +curl -fsS http://127.0.0.1:3101/api/v3/health +curl -fsS https://dhv2.korexlabs.com/api/v3/health +``` + +## 8. Rollback seguro del código + +Si el build o el arranque de la API falla, se puede restaurar el código anterior +sin eliminar las tablas nuevas: + +```bash +cd /var/www/dhv2.korexlabs.com + +tar -xzf "$PHASEA1_BACKUP_DIR/source.tar.gz" \ + -C /var/www/dhv2.korexlabs.com + +install -m 600 "$PHASEA1_BACKUP_DIR/.env" \ + /var/www/dhv2.korexlabs.com/.env + +docker compose build api +docker compose up -d --no-deps api +curl -fsS http://127.0.0.1:3101/api/v3/health +``` + +No revertir migraciones ni restaurar la base si ya se crearon usuarios o +sesiones. Ese rollback de datos debe decidirse usando el backup previo. diff --git a/docs/PHASE_A2.md b/docs/PHASE_A2.md new file mode 100644 index 0000000..4b3ef9b --- /dev/null +++ b/docs/PHASE_A2.md @@ -0,0 +1,79 @@ +# DH Inspección V2 — Fase A2 + +## Versión + +```text +v0.2.1 · Fase A2 · Autenticación y sesiones +``` + +La leyenda se muestra en el footer del frontend. El health de la API también +informa `version: 0.2.1` y `phase: A2`. + +## Endpoints + +```text +POST /api/v3/auth/login +POST /api/v3/auth/refresh +POST /api/v3/auth/logout +GET /api/v3/auth/me +POST /api/v3/auth/change-password +``` + +`GET /api/v3/health` continúa siendo el único endpoint público sin datos de +negocio. + +## Seguridad + +- passwords con Argon2id; +- respuesta genérica para credenciales inválidas; +- 5 intentos fallidos y 15 minutos de bloqueo por defecto; +- rate limiting por IP; +- access token JWT HS256 corto; +- refresh token opaco, rotativo y persistido únicamente como HMAC-SHA256; +- cookies `HttpOnly`, `Secure` y `SameSite=Strict`; +- defensa CSRF de doble envío para operaciones mutables con cookies; +- validación estricta del origen web configurado; +- sesiones revocables y detección de reutilización; +- sanitización de passwords, hashes, tokens, cookies y secretos en auditoría; +- `X-Request-ID` en respuestas y eventos de auditoría. + +El navegador no utiliza `localStorage` ni `sessionStorage` para tokens. + +## Migración A2 + +```text +PhaseA2SessionSecurityIndexes1786548005000 +``` + +Agrega índices para `users.locked_until` y +`auth_sessions.replaced_by_session_id`. No elimina ni transforma datos A1. + +## Primer administrador + +La CLI `npm run bootstrap:admin`: + +- funciona únicamente en una terminal interactiva; +- oculta la contraseña mientras se escribe; +- exige entre 12 y 128 caracteres; +- evita crear un segundo administrador por bootstrap; +- usa una transacción y un advisory lock para impedir ejecuciones simultáneas; +- asigna el rol `admin` sembrado en A1; +- registra el evento `SYSTEM_BOOTSTRAP_ADMIN_CREATED`; +- nunca imprime la contraseña ni su hash. + +## Variables nuevas + +```text +WEB_ORIGIN +JWT_ACCESS_SECRET +REFRESH_TOKEN_PEPPER +ACCESS_TOKEN_TTL_SECONDS +REFRESH_TOKEN_TTL_SECONDS +AUTH_MAX_LOGIN_ATTEMPTS +AUTH_LOCKOUT_SECONDS +ACCESS_COOKIE_NAME +REFRESH_COOKIE_NAME +CSRF_COOKIE_NAME +``` + +Los dos secretos deben ser diferentes y tener al menos 64 caracteres. diff --git a/docs/PHASE_A2_DEPLOY.md b/docs/PHASE_A2_DEPLOY.md new file mode 100644 index 0000000..f5614e9 --- /dev/null +++ b/docs/PHASE_A2_DEPLOY.md @@ -0,0 +1,53 @@ +# Despliegue DH V2 — Fase A2 + +## Precondiciones + +- A1 desplegada y validada; +- backup posterior a A1 conservado; +- ZIP A2 subido manualmente a `/var/www/dhv2.korexlabs.com`; +- no modificar `dh.korexlabs.com`; +- no reemplazar el `.env` existente. + +## Orden + +1. validar el ZIP; +2. extraer el código sin `.env`; +3. agregar las variables A2 faltantes; +4. validar Compose; +5. compilar API, migrador y Web; +6. ejecutar la migración A2; +7. recrear API y Web; +8. validar health, protección y footer; +9. crear el primer administrador mediante CLI interactiva; +10. generar backup posterior a A2. + +## Resultados esperados + +Después de migrar: + +```text +Applied migrations: 1 +- PhaseA2SessionSecurityIndexes1786548005000 +``` + +Después de reiniciar: + +```json +{ + "status": "ok", + "service": "dhv2-api", + "api": "v3", + "version": "0.2.1", + "phase": "A2", + "database": "ok" +} +``` + +`GET /api/v3/auth/me` sin cookies debe responder `401 UNAUTHORIZED` con un +`requestId` y nunca devolver datos internos. + +## Rollback + +Si la API nueva no inicia, restaurar el código y `.env` del backup A1 y recrear +API/Web con esas fuentes. La migración A2 sólo agrega dos índices, por lo que no +es necesario revertirla para volver temporalmente al código A1. diff --git a/docs/PHASE_A3.md b/docs/PHASE_A3.md new file mode 100644 index 0000000..ff0800e --- /dev/null +++ b/docs/PHASE_A3.md @@ -0,0 +1,41 @@ +# DH Inspección V2 — Fase A3 + +## Versión + +```text +v0.3.0 · Fase A3 · Guards y autorización +``` + +## Alcance + +A3 agrega autorización declarativa y reutilizable al backend: + +```ts +@RequirePermissions('users.create') +``` + +`PermissionsGuard` se registra globalmente después de `AccessTokenGuard`. Los +permisos se resuelven desde las relaciones `user_roles` y `role_permissions` +en cada request autenticado, por lo que los cambios futuros de A4 no dependen +de regenerar el access token. + +Reglas: + +- endpoint sin sesión: `401 UNAUTHORIZED`; +- sesión válida sin todos los permisos requeridos: `403 FORBIDDEN`; +- sesión con todos los permisos requeridos: acceso permitido; +- permisos de controller y handler se acumulan; +- endpoints marcados con `@Public()` no requieren permisos; +- el frontend podrá ocultar acciones, pero la autorización definitiva siempre + queda en el backend. + +## Migraciones + +A3 no modifica el esquema y no incluye migraciones. Utiliza la matriz de roles +y permisos creada en A1. + +## Exclusiones + +- endpoints administrativos de usuarios y roles: A4; +- consultas administrativas de auditoría: A5; +- login y panel web autenticado: A6. diff --git a/docs/PHASE_A3_DEPLOY.md b/docs/PHASE_A3_DEPLOY.md new file mode 100644 index 0000000..d9f76b6 --- /dev/null +++ b/docs/PHASE_A3_DEPLOY.md @@ -0,0 +1,33 @@ +# Despliegue DH V2 — Fase A3 + +## Precondiciones + +- A2 `v0.2.1` desplegada y operativa; +- ZIP A3 subido manualmente a `/var/www/dhv2.korexlabs.com`; +- conservar el `.env` existente; +- no modificar `dh.korexlabs.com`. + +## Orden + +1. validar y extraer el ZIP; +2. validar Compose; +3. compilar API y Web; +4. recrear API y Web; +5. validar health, protección de autenticación y footer. + +No se ejecutan migraciones porque A3 no cambia la base de datos. + +## Resultado esperado + +```json +{ + "status": "ok", + "service": "dhv2-api", + "api": "v3", + "version": "0.3.0", + "phase": "A3", + "database": "ok" +} +``` + +`GET /api/v3/auth/me` sin sesión debe continuar respondiendo `401`. diff --git a/docs/PHASE_A4.md b/docs/PHASE_A4.md new file mode 100644 index 0000000..187f213 --- /dev/null +++ b/docs/PHASE_A4.md @@ -0,0 +1,72 @@ +# DH Inspección V2 — Fase A4 + +## Versión + +```text +v0.4.0 · Fase A4 · Administración de usuarios y roles +``` + +## Usuarios + +```text +GET /api/v3/users +POST /api/v3/users +GET /api/v3/users/:id +PATCH /api/v3/users/:id +PATCH /api/v3/users/:id/status +PUT /api/v3/users/:id/roles +``` + +El listado acepta `page`, `pageSize`, `search` y `status`. No existe endpoint de +borrado físico. La desactivación revoca todas las sesiones del usuario. + +Los usuarios se crean con una contraseña temporal de al menos 12 caracteres. +El password se transforma a Argon2id antes de persistir y nunca aparece en la +respuesta ni en auditoría. Cuando `mustChangePassword` está activo, los guards +bloquean endpoints administrativos hasta completar el cambio de contraseña. + +## Roles + +```text +GET /api/v3/roles +GET /api/v3/roles/permissions +GET /api/v3/roles/:id +POST /api/v3/roles +PATCH /api/v3/roles/:id +PUT /api/v3/roles/:id/permissions +``` + +Los códigos de rol son inmutables después de su creación. Los nombres, +descripciones y permisos se administran por API. No existe borrado de roles en +A4. + +## Permisos requeridos + +```text +users.read +users.create +users.update +users.change_status +users.assign_roles +roles.read +roles.manage +``` + +Cada endpoint usa `@RequirePermissions(...)` y el backend siempre valida la +matriz actual almacenada en PostgreSQL. + +## Integridad y auditoría + +- todas las mutaciones son transaccionales; +- se registran `USER_CREATED`, `USER_UPDATED`, `USER_STATUS_CHANGED`, + `USER_ROLES_CHANGED`, `ROLE_CREATED`, `ROLE_UPDATED` y + `ROLE_PERMISSIONS_CHANGED`; +- no se auditan passwords, hashes, tokens ni cookies; +- no se permite la autodesactivación; +- no se puede dejar el sistema sin al menos un usuario activo con + `roles.manage` y `users.assign_roles`; +- cambios de roles y permisos tienen efecto en el siguiente request. + +## Migraciones + +A4 no modifica el esquema y no incluye migraciones. diff --git a/docs/PHASE_A4_DEPLOY.md b/docs/PHASE_A4_DEPLOY.md new file mode 100644 index 0000000..2062882 --- /dev/null +++ b/docs/PHASE_A4_DEPLOY.md @@ -0,0 +1,34 @@ +# Despliegue DH V2 — Fase A4 + +## Precondiciones + +- A3 `v0.3.0` desplegada; +- ZIP A4 subido a `/var/www/dhv2.korexlabs.com`; +- conservar el `.env` existente; +- no modificar `dh.korexlabs.com`. + +## Orden + +1. crear backup previo; +2. validar y extraer el ZIP; +3. validar Compose; +4. compilar API y Web; +5. recrear API y Web; +6. validar health, rutas protegidas y footer. + +No se ejecutan migraciones porque A4 utiliza el esquema existente. + +## Resultado esperado + +```json +{ + "status": "ok", + "service": "dhv2-api", + "api": "v3", + "version": "0.4.0", + "phase": "A4", + "database": "ok" +} +``` + +Sin sesión, `/api/v3/users` y `/api/v3/roles` deben responder `401`. diff --git a/docs/PHASE_A5.md b/docs/PHASE_A5.md new file mode 100644 index 0000000..dce1412 --- /dev/null +++ b/docs/PHASE_A5.md @@ -0,0 +1,90 @@ +# DH Inspección V2 — Fase A5 + +## Versión + +```text +v0.5.0 · Fase A5 · Auditoría, filtros y trazabilidad +``` + +## Endpoints + +```text +GET /api/v3/audit +GET /api/v3/audit/:id +``` + +Ambos requieren: + +```text +audit.read +``` + +## Listado + +El listado devuelve únicamente la información necesaria para una grilla: + +```text +fecha/hora +usuario +acción +entidad +request ID +origen +IP +indicador de detalle +``` + +Los objetos `beforeData`, `afterData` y `metadata` se reservan para la consulta +individual, evitando respuestas innecesariamente pesadas. + +## Filtros + +```text +page +pageSize +actorUserId +actorUsername +action +source +entityType +entityId +requestId +from +to +search +``` + +`pageSize` admite hasta 100 resultados. `from` y `to` usan ISO 8601 y se +rechaza un rango en el que la fecha inicial sea posterior a la final. + +La respuesta paginada conserva el contrato: + +```json +{ + "data": [], + "meta": { + "page": 1, + "pageSize": 25, + "total": 0, + "totalPages": 0 + } +} +``` + +## Detalle + +Además de los campos del listado devuelve: + +```text +userAgent +beforeData +afterData +metadata +``` + +La escritura continúa pasando por `sanitizeAuditData`, que elimina passwords, +hashes, JWT, refresh tokens, cookies, encabezados de autorización y secretos. + +## Migraciones + +A5 no cambia el esquema y no incluye migraciones. diff --git a/docs/PHASE_A5_DEPLOY.md b/docs/PHASE_A5_DEPLOY.md new file mode 100644 index 0000000..cca5143 --- /dev/null +++ b/docs/PHASE_A5_DEPLOY.md @@ -0,0 +1,34 @@ +# Despliegue DH V2 — Fase A5 + +## Precondiciones + +- A4 `v0.4.0` desplegada; +- ZIP A5 subido a `/var/www/dhv2.korexlabs.com`; +- conservar el `.env` existente; +- no modificar `dh.korexlabs.com`. + +## Orden + +1. crear backup previo; +2. validar y extraer el ZIP; +3. validar Compose; +4. compilar API y Web; +5. recrear API y Web; +6. validar health, endpoint protegido y footer. + +No se ejecutan migraciones. + +## Resultado esperado + +```json +{ + "status": "ok", + "service": "dhv2-api", + "api": "v3", + "version": "0.5.0", + "phase": "A5", + "database": "ok" +} +``` + +Sin sesión, `/api/v3/audit` debe responder `401`. diff --git a/docs/PHASE_A6.md b/docs/PHASE_A6.md new file mode 100644 index 0000000..35b4c32 --- /dev/null +++ b/docs/PHASE_A6.md @@ -0,0 +1,93 @@ +# DH Inspección V2 — Fase A6 + +## Versión + +```text +v0.6.0 · Fase A6 · Panel web administrativo +``` + +## Rutas web + +```text +/login +/ +/change-password +/admin/users +/admin/users/new +/admin/users/:id +/admin/roles +/admin/audit +``` + +## Autenticación web + +- el JWT de acceso y el token de renovación permanecen en cookies seguras; +- el frontend no almacena JWT en `localStorage` ni `sessionStorage`; +- las escrituras envían `x-csrf-token`; +- un acceso vencido intenta una única renovación y repite el request original; +- una sesión inválida vuelve al login; +- la contraseña temporal bloquea todos los módulos hasta ser cambiada; +- el cierre de sesión revoca la sesión actual. + +## Autorización visible + +La navegación y las acciones se ocultan cuando el usuario no dispone del +permiso correspondiente. El backend continúa siendo la autoridad definitiva. + +| Área | Permiso | +|---|---| +| Dashboard | `dashboard.read` | +| Usuarios | `users.read`, `users.create`, `users.update`, `users.change_status`, `users.assign_roles` | +| Roles | `roles.read`, `roles.manage` | +| Auditoría | `audit.read` | + +## Resumen administrativo + +A6 agrega: + +```text +GET /api/v3/dashboard/summary +``` + +Requiere `dashboard.read` y devuelve: + +- usuarios activos; +- usuarios inactivos; +- sesiones vigentes; +- seis eventos recientes de auditoría; +- fecha de generación. + +La lectura se ejecuta dentro de una transacción `REPEATABLE READ`. + +## Usuarios + +- búsqueda y filtro por estado; +- paginación; +- alta con contraseña temporal y roles; +- edición de datos personales; +- activación y desactivación; +- asignación múltiple de roles; +- información de último acceso, bloqueo y cambio de contraseña. + +Se mantienen las protecciones de A4: no es posible desactivar la propia cuenta, +quitar el último administrador de recuperación ni dejar el sistema sin acceso +administrativo. + +## Roles y permisos + +- listado de roles del sistema y personalizados; +- alta de roles personalizados; +- edición de nombre y descripción; +- matriz dinámica de permisos; +- cantidad de usuarios por rol. + +## Auditoría + +- filtros por texto, acción, origen y fechas; +- paginación; +- detalle técnico en panel lateral; +- visualización de `beforeData`, `afterData` y `metadata` ya sanitizados. + +## Migraciones + +A6 no cambia el esquema y no incluye migraciones. diff --git a/docs/PHASE_A6_DEPLOY.md b/docs/PHASE_A6_DEPLOY.md new file mode 100644 index 0000000..4c5f157 --- /dev/null +++ b/docs/PHASE_A6_DEPLOY.md @@ -0,0 +1,33 @@ +# Deploy A6 + +## Archivo + +```text +DH_V2_FASE_A6_SOURCE.zip +``` + +El archivo se sube por FTP a: + +```text +/var/www/dhv2.korexlabs.com/ +``` + +Antes de reemplazar los fuentes se guarda un backup de archivos, `.env` y base +de datos en `/var/www/dh-backup/`. + +El despliegue reconstruye únicamente `api` y `web`; la base no se recrea y A6 +no requiere migraciones. Al finalizar deben verificarse: + +```text +health interno: version 0.6.0, phase A6, database ok +health externo: version 0.6.0, phase A6, database ok +/api/v3/dashboard/summary sin sesión: HTTP 401 +/api/v3/users sin sesión: HTTP 401 +/api/v3/roles sin sesión: HTTP 401 +/api/v3/audit sin sesión: HTTP 401 +login compilado: OK +footer v0.6.0: OK +api, db y web: Up +``` + +El bloque único de comandos y el SHA-256 se entregan junto con el ZIP final. diff --git a/docs/PHASE_A7.md b/docs/PHASE_A7.md new file mode 100644 index 0000000..212104b --- /dev/null +++ b/docs/PHASE_A7.md @@ -0,0 +1,69 @@ +# DH Inspección V2 — Fase A7 + +## Versión + +```text +v0.7.0 · Fase A7 · Integración y cierre de Fase A +``` + +## Objetivo + +A7 no agrega módulos funcionales ni modifica tablas. Cierra la Fase A mediante +pruebas integradas sobre el despliegue real, controles de seguridad y un backup +post-fase verificable. + +## Aceptación autenticada + +`scripts/phase-a-acceptance.sh` solicita usuario y contraseña en la terminal. +La contraseña se ingresa sin mostrarse, se utiliza para un único login y no se +escribe en logs ni archivos persistentes. + +La prueba valida: + +1. `401` sin sesión en dashboard, usuarios, roles y auditoría; +2. respuesta genérica ante un usuario inexistente; +3. login administrativo válido; +4. tres cookies con `Secure` y `SameSite=Strict`; +5. dos cookies de autenticación con `HttpOnly`; +6. `200` autenticado en `/auth/me`, dashboard, usuarios, roles y auditoría; +7. `403 CSRF_INVALID` en una escritura sin token CSRF; +8. logout válido con CSRF; +9. `401` después de revocar la sesión. + +La prueba no crea, edita ni elimina usuarios o roles. Sólo genera los eventos +de autenticación y seguridad esperables en auditoría. + +## Backup post-fase + +`scripts/backup-phase-a.sh` crea una carpeta privada bajo +`/var/www/dh-backup/` con: + +```text +source.tar.gz +database.dump +.env +nginx-site.conf o NGINX_CONFIG_NOT_FOUND.txt +MANIFEST.txt +SHA256SUMS +``` + +El TAR excluye `.env`, ZIP, `node_modules`, `dist` y archivos incrementales de +TypeScript. El dump usa formato custom de PostgreSQL. + +## Verificación + +`scripts/verify-backup.sh` es no destructivo y comprueba: + +- archivos obligatorios; +- permisos `600` del `.env` respaldado; +- todos los SHA-256; +- integridad y exclusiones del TAR; +- legibilidad del catálogo de `database.dump` mediante `pg_restore --list`. + +## Migraciones + +A7 no incluye migraciones. El despliegue debe confirmar: + +```text +Pending migrations: no +``` diff --git a/docs/PHASE_A7_DEPLOY.md b/docs/PHASE_A7_DEPLOY.md new file mode 100644 index 0000000..7c321f6 --- /dev/null +++ b/docs/PHASE_A7_DEPLOY.md @@ -0,0 +1,30 @@ +# Deploy A7 + +## Archivo + +```text +DH_V2_FASE_A7_SOURCE.zip +``` + +Se sube por FTP a: + +```text +/var/www/dhv2.korexlabs.com/ +``` + +El bloque de deploy realiza, en orden: + +1. checksum e integridad del ZIP; +2. backup pre-A7; +3. reemplazo de fuentes; +4. build de API, web y herramienta de migraciones; +5. confirmación de que no existen migraciones pendientes; +6. recreación controlada de API y web; +7. health interno y externo; +8. aceptación autenticada interactiva; +9. backup post-A7; +10. verificación no destructiva del backup; +11. estado y logs finales. + +A7 no agrega migraciones ni variables de entorno. El SHA-256 y el bloque único +se entregan junto con el ZIP final. diff --git a/docs/PHASE_A_FINAL_ACCEPTANCE.md b/docs/PHASE_A_FINAL_ACCEPTANCE.md new file mode 100644 index 0000000..4b175ae --- /dev/null +++ b/docs/PHASE_A_FINAL_ACCEPTANCE.md @@ -0,0 +1,49 @@ +# Cierre de Fase A — Matriz de aceptación + +## Backend + +| Criterio | Evidencia | +|---|---| +| Migraciones controladas | TypeORM con `synchronize: false` y `migration:show` sin pendientes | +| Tablas, índices y seeds | Migraciones A1/A2 aplicadas y 26 índices verificados en producción | +| Bootstrap seguro | CLI interactiva, contraseña oculta y bloqueo si ya existe admin | +| Auth completa | login, refresh, logout, me y change-password | +| Sesiones | hash del refresh, rotación, revocación y detección de reutilización | +| Autorización | guard global y permisos resueltos desde base | +| Usuarios y roles | endpoints administrativos protegidos y auditados | +| Auditoría | filtros, paginación, detalle, request ID y sanitización | +| Dashboard | resumen protegido por `dashboard.read` | +| Rate limiting | throttling global y límites reforzados en autenticación | +| Usuario DB runtime | API configurada con `DB_APP_USER`, separada del owner | + +## Frontend + +| Criterio | Evidencia | +|---|---| +| Login y sesión | AuthContext con cookies y renovación controlada | +| Tokens | ningún JWT en `localStorage` o `sessionStorage` | +| Rutas | protección por sesión y redirección al login | +| Contraseña temporal | bloqueo de módulos hasta completar el cambio | +| Permisos visibles | navegación y acciones mediante `PermissionGate` | +| Administración | dashboard, usuarios, roles y auditoría | +| Cierre de sesión | revocación backend y limpieza local | +| Identificación | footer visible `v0.7.0 · Fase A7` | + +## Seguridad e infraestructura + +| Criterio | Evidencia | +|---|---| +| HTTPS | login y API externa accesibles únicamente mediante el proxy configurado | +| Cookies | `HttpOnly` para acceso/refresh; `Secure` y `SameSite=Strict` | +| CSRF | escritura con cookie rechazada sin `x-csrf-token` | +| Acceso anónimo | sólo `/api/v3/health` es público | +| Secretos | sanitización de auditoría y ninguna credencial en artefactos fuente | +| Docker | DB saludable, API y web en ejecución | +| Backup | fuentes, base, `.env`, Nginx, manifiesto y checksums | +| Recuperación | dump y TAR verificables sin modificar producción | + +## Resultado + +Cuando el deploy A7, la aceptación autenticada y la verificación del backup +finalizan sin errores, la Fase A queda formalmente cerrada y puede comenzar la +Fase B — Maestro de Activos. diff --git a/docs/PHASE_B1.md b/docs/PHASE_B1.md new file mode 100644 index 0000000..332e4b4 --- /dev/null +++ b/docs/PHASE_B1.md @@ -0,0 +1,106 @@ +# Fase B1 — Base del Maestro de Activos + +Versión: `0.8.0` + +## Objetivo + +Reemplazar el placeholder de Activos por el primer núcleo funcional del Maestro +de Activos, sin fijar niveles como Empresa, Área, Yacimiento o Pozo. + +## Modelo incorporado + +- `asset_types`: tipos configurables y activables/desactivables; +- `asset_type_parent_rules`: múltiples tipos padre permitidos por tipo hijo; +- `asset_attribute_definitions`: atributos configurables por tipo; +- `assets`: entidades administrables con identidad y padre opcional; +- `asset_attribute_values`: valores tipados de cada activo. + +La jerarquía se controla con dos reglas: + +1. un tipo puede o no permitir activos raíz; +2. un tipo puede aceptar cero, uno o varios tipos padre. + +La API también evita ciclos en la jerarquía real de activos. + +## Atributos dinámicos + +Tipos iniciales de dato: + +- texto; +- número; +- verdadero/falso; +- fecha; +- fecha y hora; +- selección con opciones configurables. + +Los atributos pueden ser obligatorios, tener unidad, orden e inactivarse sin +borrar valores existentes. + +## Estados de información + +- `DRAFT`; +- `PENDING_SURVEY`; +- `SURVEYED`; +- `VALIDATED`; +- `OBSERVED`; +- `OUTDATED`; +- `INACTIVE`. + +Estos estados describen la calidad o vigencia de la información. No representan +el estado operativo o mecánico del activo. + +## API + +Tipos y atributos: + +- `GET /api/v3/asset-types`; +- `GET /api/v3/asset-types/:id`; +- `POST /api/v3/asset-types`; +- `PATCH /api/v3/asset-types/:id`; +- `POST /api/v3/asset-types/:id/attributes`; +- `PATCH /api/v3/asset-types/:id/attributes/:attributeId`. + +Activos: + +- `GET /api/v3/assets`; +- `GET /api/v3/assets/parent-options`; +- `GET /api/v3/assets/:id`; +- `POST /api/v3/assets`; +- `PATCH /api/v3/assets/:id`; +- `PATCH /api/v3/assets/:id/information-status`. + +No se implementa borrado físico. + +## Permisos + +- `asset_types.read`; +- `asset_types.manage`; +- `assets.read`; +- `assets.create`; +- `assets.update`; +- `assets.change_status`. + +La migración los asigna a los roles de sistema según su alcance y entrega todos +al rol `admin`. + +## Interfaz web + +- `/activos`: búsqueda, filtros, tabla y jerarquía visible; +- `/activos/nuevo`: alta con atributos dinámicos; +- `/activos/:id`: consulta/edición y cambio de estado según permiso; +- `/admin/asset-types`: configuración de tipos, padres y atributos. + +## Trazabilidad + +Se auditan altas y cambios de tipos, atributos, activos y estado de información. +Los valores dinámicos quedan incluidos en el antes/después sanitizado. + +## Fuera de B1 + +Quedan para próximas subfases: + +- geometrías PostGIS y edición en mapa; +- fotografías y media versionada; +- versiones históricas y consulta temporal; +- importaciones/procedencia avanzada; +- relevamiento de campo. diff --git a/docs/PHASE_B1_DEPLOY.md b/docs/PHASE_B1_DEPLOY.md new file mode 100644 index 0000000..8983cab --- /dev/null +++ b/docs/PHASE_B1_DEPLOY.md @@ -0,0 +1,42 @@ +# Deploy B1 + +## Archivo + +```text +DH_V2_FASE_B1_SOURCE.zip +``` + +Subir por FTP a: + +```text +/var/www/dhv2.korexlabs.com/ +``` + +## Cambios de base + +B1 incorpora una migración controlada: + +```text +PhaseB1AssetMaster1786636800000 +``` + +La migración crea cinco tablas, dos enums, índices, permisos y grants para el +usuario runtime. TypeORM permanece con `synchronize: false`. + +No se agregan variables de entorno. + +## Orden del bloque único + +1. verificar checksum e integridad del ZIP; +2. crear backup previo de fuentes, `.env` y base; +3. extraer el código; +4. validar Compose; +5. construir API, migrador y web; +6. aplicar la migración como owner; +7. confirmar que no quedan migraciones pendientes; +8. recrear únicamente API y web; +9. esperar health interno; +10. ejecutar `scripts/phase-b1-acceptance.sh`; +11. mostrar contenedores, logs y ubicación del backup. + +El checksum definitivo y el bloque completo se entregan junto al ZIP. diff --git a/docs/PHASE_B2.md b/docs/PHASE_B2.md new file mode 100644 index 0000000..70b89bd --- /dev/null +++ b/docs/PHASE_B2.md @@ -0,0 +1,80 @@ +# Fase B2 — Geometrías PostGIS y mapa operativo + +Versión: `0.9.0` + +## Objetivo + +Incorporar ubicación geográfica real al Maestro de Activos y reemplazar el +mapa demostrativo por una consulta territorial conectada a PostGIS. + +## Modelo + +La tabla `asset_geometries` mantiene la geometría actual de cada activo: + +- `POINT`, `LINESTRING` o `POLYGON`; +- SRID `4326`; +- fuente `WEB`, `ANDROID`, `IMPORT` o `SURVEY`; +- precisión en metros; +- fecha/hora de captura; +- dispositivo; +- usuario y fechas de actualización. + +PostgreSQL valida tipo, rango, validez geométrica y relación con el activo. La +columna posee un índice espacial `GIST`. + +## API + +- `GET /api/v3/map/assets` devuelve un `FeatureCollection` GeoJSON; +- `GET /api/v3/assets/:id/geometry` consulta la geometría actual; +- `PUT /api/v3/assets/:id/geometry` crea o actualiza ubicación y metadatos; +- `DELETE /api/v3/assets/:id/geometry` retira la geometría actual. + +El mapa admite filtros por tipo de activo, estado de información, tipo de +geometría y área visible mediante `bbox`. La respuesta se limita a 5000 +features e informa si fue truncada. + +## Seguridad + +- lectura y mapa: `assets.read`; +- escritura geográfica: `assets.update_geometry`. + +La migración asigna escritura a `admin`, `supervisor` e `inspector`. Director y +auditor conservan lectura territorial. + +## Interfaz + +### Mapa + +`/mapa` muestra puntos, líneas y polígonos con: + +- filtros reales; +- selección visual; +- datos básicos, jerarquía y estado; +- acceso directo al detalle del activo; +- aviso de resultados truncados o sin ubicación. + +### Editor del activo + +El detalle del activo permite: + +- elegir punto, línea o polígono; +- marcar vértices sobre el mapa; +- deshacer y limpiar; +- usar explícitamente la geolocalización del navegador; +- registrar precisión, timestamp y dispositivo; +- retirar una geometría dejando auditoría. + +La captura GPS sólo ocurre cuando el usuario presiona “Usar mi ubicación”. + +## Trazabilidad + +Cada creación, reemplazo o retiro genera un evento de auditoría con geometría y +metadatos antes/después. El versionado temporal formal de activos se incorpora +en una subfase posterior. + +## Fuera de B2 + +- versiones históricas completas y consulta temporal; +- media/fotografías; +- importaciones masivas; +- relevamiento de campo y sincronización Android. diff --git a/docs/PHASE_B2_DEPLOY.md b/docs/PHASE_B2_DEPLOY.md new file mode 100644 index 0000000..64fe4af --- /dev/null +++ b/docs/PHASE_B2_DEPLOY.md @@ -0,0 +1,28 @@ +# Deploy B2 + +## Archivo + +```text +DH_V2_FASE_B2_SOURCE.zip +``` + +Subir por FTP a: + +```text +/var/www/dhv2.korexlabs.com/ +``` + +## Migración + +```text +PhaseB2AssetGeometries1786723200000 +``` + +Crea la tabla espacial, enum de fuente, índices, permiso y grants runtime. La +extensión PostGIS ya existente no se modifica. TypeORM continúa con +`synchronize: false`. + +No se agregan variables de entorno. + +El bloque único de deploy realiza backup, verifica el ZIP, construye API, +migrador y web, aplica la migración, recrea API/web y ejecuta la aceptación B2. diff --git a/docs/PHASE_B3.md b/docs/PHASE_B3.md new file mode 100644 index 0000000..78470ec --- /dev/null +++ b/docs/PHASE_B3.md @@ -0,0 +1,62 @@ +# Fase B3 — Historial y versiones de activos + +Versión: `0.10.0` + +## Objetivo + +Conservar el estado exacto de cada activo a través del tiempo, sin depender de +la auditoría general para reconstruirlo. + +## Modelo + +La tabla append-only `asset_versions` registra una versión completa cuando: + +- se crea un activo; +- cambian su identificación, jerarquía o atributos; +- cambia su estado de información; +- se crea, reemplaza o retira su geometría. + +Cada snapshot incluye datos principales, tipo y padre, atributos configurables, +geometría GeoJSON, metadatos de captura, actor, origen, request ID y fecha. +El evento de auditoría correspondiente conserva además el número de versión +generado, permitiendo relacionar ambos registros. + +La columna `assets.current_version` identifica la versión vigente. La migración +genera automáticamente una versión `BASELINE` para cada activo que ya existía +al desplegar B3. + +## Inmutabilidad + +El usuario runtime de la API recibe solamente `SELECT` e `INSERT` sobre +`asset_versions`. No recibe `UPDATE` ni `DELETE`. Una versión guardada no puede +ser reescrita por la aplicación. + +## API + +- `GET /api/v3/asset-versions`: historial global paginado y filtrable; +- `GET /api/v3/assets/:id/versions`: línea de tiempo de un activo; +- `GET /api/v3/assets/:id/versions/:versionNumber`: snapshot exacto. + +Los filtros globales incluyen búsqueda, tipo, estado, clase de cambio y rango +de fechas. + +## Seguridad + +Todas las rutas requieren `assets.read_history`. La migración asigna lectura +histórica a `admin`, `director`, `supervisor`, `inspector` y `auditor`. + +## Interfaz + +- la sección **Historial** reemplaza el placeholder y muestra las versiones de + todos los activos; +- cada activo incluye su propia línea de tiempo; +- el visor distingue la versión vigente y muestra datos, atributos, ubicación + y el snapshot técnico exacto; +- la lista maestra muestra el número de versión vigente. + +## Fuera de B3 + +- restauración automática de versiones anteriores; +- fotografías, documentos y evidencias; +- importaciones masivas; +- relevamiento Android y sincronización offline. diff --git a/docs/PHASE_B3_DEPLOY.md b/docs/PHASE_B3_DEPLOY.md new file mode 100644 index 0000000..11e4b21 --- /dev/null +++ b/docs/PHASE_B3_DEPLOY.md @@ -0,0 +1,29 @@ +# Deploy B3 + +## Archivo + +```text +DH_V2_FASE_B3_SOURCE.zip +``` + +Subir por FTP a: + +```text +/var/www/dhv2.korexlabs.com/ +``` + +## Migración + +```text +PhaseB3AssetVersions1786723201000 +``` + +Crea `asset_versions`, agrega `assets.current_version`, genera las versiones +base de los activos existentes, incorpora el permiso y configura grants +append-only para el usuario runtime. + +TypeORM continúa con `synchronize: false`. No se agregan variables de entorno. + +El bloque único de deploy realiza backup, valida el ZIP, construye API, +migrador y web, aplica la migración, recrea los servicios y ejecuta la +aceptación B3. diff --git a/docs/PHASE_B4.md b/docs/PHASE_B4.md new file mode 100644 index 0000000..6ed330e --- /dev/null +++ b/docs/PHASE_B4.md @@ -0,0 +1,73 @@ +# Fase B4 — Fotografías y documentos de activos + +Versión: `0.11.0` + +## Objetivo + +Adjuntar evidencia visual y documental a cada activo, conservando el archivo +original en almacenamiento privado y relacionando toda operación con el +historial inmutable y la auditoría. + +## Modelo y almacenamiento + +La tabla `asset_media` guarda los metadatos, el hash SHA-256 y el vínculo con +el activo. Los binarios se almacenan fuera de la imagen de la API, en el +volumen Docker persistente `dhv2_asset_media`. + +Formatos permitidos: + +- fotografías JPEG, PNG y WebP; +- documentos PDF; +- tamaño máximo de 15 MB por archivo. + +La API inspecciona la firma real del contenido y no confía en la extensión ni +en el MIME informado por el navegador. Cada original recibe un nombre interno +aleatorio y permisos de archivo restringidos. + +## API + +- `GET /api/v3/assets/:assetId/media`: lista archivos activos; +- `POST /api/v3/assets/:assetId/media`: incorpora un archivo multipart; +- `PATCH /api/v3/asset-media/:mediaId`: actualiza metadatos; +- `DELETE /api/v3/asset-media/:mediaId`: retiro lógico; +- `GET /api/v3/asset-media/:mediaId/content`: visualización o descarga protegida. + +Los archivos no se publican desde Nginx. Toda lectura atraviesa autenticación y +autorización en la API. + +## Seguridad y permisos + +- `assets.read_media`: admin, director, supervisor, inspector y auditor; +- `assets.manage_media`: admin, supervisor e inspector. + +El usuario runtime puede consultar, insertar y actualizar `asset_media`, pero +no recibe `DELETE`. Retirar un archivo es una baja lógica: el registro y el +original físico se conservan. + +## Trazabilidad + +Incorporar, editar o retirar un archivo: + +- genera un evento específico en `audit_events`; +- incrementa `assets.current_version`; +- produce un snapshot B3 con los metadatos vigentes de fotografías y + documentos, sin duplicar los binarios dentro de PostgreSQL. + +## Interfaz + +Cada activo incluye una sección de fotografías y documentos con: + +- captura desde la cámara o selección de archivo; +- fecha, título y descripción opcionales; +- captura GPS explícita con precisión; +- previsualización autenticada de fotografías; +- descarga protegida; +- edición de metadatos y retiro lógico; +- cantidad de archivos en el listado maestro. + +## Fuera de B4 + +- miniaturas y transformaciones derivadas; +- antivirus externo; +- importación masiva de archivos; +- sincronización Android offline. diff --git a/docs/PHASE_B4_DEPLOY.md b/docs/PHASE_B4_DEPLOY.md new file mode 100644 index 0000000..9c534a7 --- /dev/null +++ b/docs/PHASE_B4_DEPLOY.md @@ -0,0 +1,30 @@ +# Deploy B4 + +## Archivo + +```text +DH_V2_FASE_B4_SOURCE.zip +``` + +Subir por FTP a: + +```text +/var/www/dhv2.korexlabs.com/ +``` + +## Migración + +```text +PhaseB4AssetMedia1786723202000 +``` + +Crea `asset_media`, amplía los tipos del historial, incorpora dos permisos y +configura los grants runtime sin borrado físico. + +TypeORM continúa con `synchronize: false`. No se agregan variables al `.env`. +El volumen persistente `dhv2_asset_media` es creado por Docker Compose y queda +montado únicamente en la API. + +El bloque único de deploy respalda fuentes, base, `.env` y —cuando ya existe— +el volumen de archivos; valida el ZIP, construye los tres artefactos, aplica la +migración, recrea API y web y ejecuta la aceptación B4. diff --git a/docs/PHASE_B5.md b/docs/PHASE_B5.md new file mode 100644 index 0000000..e0aadad --- /dev/null +++ b/docs/PHASE_B5.md @@ -0,0 +1,64 @@ +# Fase B5 — Procedencia de datos y consulta temporal + +Versión: `0.12.0` + +## Objetivo + +Completar el Maestro de Activos con dos capacidades previstas desde su diseño: + +- identificar de dónde proviene la información principal de cada activo; +- reconstruir cómo se encontraba el maestro en una fecha y hora determinada. + +## Procedencia + +Cada activo registra: + +- origen controlado: carga manual, relevamiento de campo, documentación + recibida, importación o sistema; +- nombre de la fuente y referencia; +- fecha y hora observada; +- notas de procedencia; +- usuario y fecha de la última actualización; +- usuario y fecha de verificación. + +Actualizar la procedencia reinicia cualquier verificación previa. Las fuentes +documentales e importaciones requieren identificar la fuente. + +## Permisos + +- `assets.read_provenance`: cinco roles base; +- `assets.manage_provenance`: admin, supervisor e inspector; +- `assets.verify_provenance`: admin, director y supervisor; +- `assets.read_temporal`: cinco roles base. + +## Versionado y auditoría + +La migración incorpora una versión inicial de procedencia a todos los activos +existentes. Las actualizaciones y verificaciones posteriores: + +- incrementan `assets.current_version`; +- generan un snapshot inmutable completo; +- registran actor, origen, request ID y evento de auditoría; +- no permiten al usuario runtime modificar ni borrar versiones anteriores. + +## Consulta temporal + +La API selecciona la última versión de cada activo cuya fecha sea menor o igual +al instante solicitado. Así reconstruye sólo los activos que ya existían en ese +momento y conserva sus códigos, nombres, tipos, jerarquías, estados, atributos, +geometrías, archivos y procedencia histórica. + +Rutas: + +- `GET /api/v3/temporal-assets?at=...`: maestro histórico paginado y filtrable; +- `GET /api/v3/temporal-assets/:assetId?at=...`: snapshot exacto de un activo. + +Las consultas son de sólo lectura y no modifican el estado actual. + +## Interfaz + +- panel de procedencia dentro de cada activo; +- estado pendiente o verificado en el listado maestro; +- sección **Consulta temporal** con fecha, hora y filtros; +- vigencia desde/hasta de cada snapshot; +- visor semántico y snapshot técnico exacto. diff --git a/docs/PHASE_B5_DEPLOY.md b/docs/PHASE_B5_DEPLOY.md new file mode 100644 index 0000000..dd00f3d --- /dev/null +++ b/docs/PHASE_B5_DEPLOY.md @@ -0,0 +1,30 @@ +# Deploy B5 + +## Archivo + +```text +DH_V2_FASE_B5_SOURCE.zip +``` + +Subir por FTP a: + +```text +/var/www/dhv2.korexlabs.com/ +``` + +## Migración + +```text +PhaseB5ProvenanceTemporal1786723203000 +``` + +Agrega nueve campos de procedencia en `assets`, cuatro permisos y tres tipos de +cambio histórico. También genera una versión de procedencia inicial para cada +activo existente, sin modificar versiones previas. + +TypeORM continúa con `synchronize: false`. No se agregan variables al `.env` ni +volúmenes nuevos. + +El bloque único de deploy respalda fuentes, base, `.env` y el volumen de +archivos, valida el ZIP, construye los tres artefactos, aplica la migración, +recrea API y web y ejecuta la aceptación B5. diff --git a/docs/PHASE_C1.md b/docs/PHASE_C1.md new file mode 100644 index 0000000..dda1330 --- /dev/null +++ b/docs/PHASE_C1.md @@ -0,0 +1,67 @@ +# Fase C1 — Planificación de relevamientos + +Versión: `0.13.0` + +## Objetivo + +Iniciar el ciclo operativo de relevamiento planificando trabajo sobre el único +Maestro de Activos. La fase no crea inventarios ni copias paralelas. + +## Campañas + +Cada campaña define código, nombre, descripción, fechas previstas, coordinación +y un alcance opcional. El alcance referencia cualquier activo del árbol +configurable y comprende ese activo y sus descendientes, sin niveles fijos. + +Estados controlados: + +- borrador; +- planificada; +- en curso; +- completada; +- cancelada. + +Una campaña sólo puede iniciarse si contiene objetivos. Sólo puede completarse +cuando todos están completados u omitidos. Completadas y canceladas quedan +cerradas. + +## Objetivos + +Cada objetivo referencia exactamente un activo existente y registra responsable, +vencimiento, instrucciones y avance. La restricción campaña-activo impide +duplicados. Si existe alcance, la API verifica la jerarquía antes de agregar el +activo. + +Los inspectores sólo pueden avanzar objetivos que tengan asignados. La base no +concede `DELETE` al usuario runtime: la omisión es un estado trazable, no un +borrado físico. + +## Permisos + +- `surveys.read`: admin, director, supervisor, inspector y auditor; +- `surveys.manage`: admin y supervisor; +- `surveys.assign`: admin y supervisor; +- `surveys.execute`: admin, supervisor e inspector. + +## Auditoría + +Se registran creación, actualización y cambio de estado de campañas, además de +alta, actualización, asignación y cambio de avance de objetivos. + +## Interfaz y API + +La sección **Relevamientos** incluye listado, filtros, alta, edición, flujo de +estados, incorporación de activos existentes, asignaciones y avance. + +Rutas principales: + +- `GET|POST /api/v3/survey-campaigns`; +- `GET|PATCH /api/v3/survey-campaigns/:id`; +- `PUT /api/v3/survey-campaigns/:id/status`; +- `POST /api/v3/survey-campaigns/:id/targets`; +- `PATCH /api/v3/survey-campaign-targets/:id`; +- `PUT /api/v3/survey-campaign-targets/:id/assignment`; +- `PUT /api/v3/survey-campaign-targets/:id/status`. + +La captura de datos de campo y la consolidación de relevamientos continúan en +las fases siguientes. diff --git a/docs/PHASE_C1_DEPLOY.md b/docs/PHASE_C1_DEPLOY.md new file mode 100644 index 0000000..65073bf --- /dev/null +++ b/docs/PHASE_C1_DEPLOY.md @@ -0,0 +1,29 @@ +# Deploy C1 + +## Archivo + +```text +DH_V2_FASE_C1_SOURCE.zip +``` + +Subir por FTP a: + +```text +/var/www/dhv2.korexlabs.com/ +``` + +## Migración + +```text +PhaseC1SurveyPlanning1786809600000 +``` + +Crea `survey_campaigns` y `survey_campaign_targets`, cuatro permisos, índices, +restricciones y privilegios runtime sin borrado físico. + +TypeORM continúa con `synchronize: false`. C1 no agrega variables al `.env` ni +volúmenes nuevos. + +El bloque único de deploy respalda fuentes, base, `.env` y archivos, valida el +ZIP, construye API, migraciones y web, aplica la migración, recrea servicios y +ejecuta `scripts/phase-c1-acceptance.sh`. diff --git a/docs/PHASE_C2.md b/docs/PHASE_C2.md new file mode 100644 index 0000000..4e8eeb7 --- /dev/null +++ b/docs/PHASE_C2.md @@ -0,0 +1,65 @@ +# Fase C2 — Ejecución y validación de relevamientos + +Versión: `0.14.0` + +## Objetivo + +Ejecutar en campo los objetivos planificados en C1 y consolidar su resultado en +el único Maestro de Activos. C2 no crea una segunda ficha ni un inventario +paralelo: el informe referencia al objetivo y al activo originales. + +## Flujo operativo + +1. El responsable inicia un objetivo asignado. +2. Guarda un borrador con resultado, fecha observada, GPS, precisión, notas y + fotografías protegidas. +3. Si detecta diferencias, actualiza el activo original desde el Maestro. +4. Envía el informe. C2 actualiza la procedencia del activo, crea una versión de + activo y congela una versión inmutable del informe con los hashes de evidencia. +5. Un revisor aprueba o rechaza. La aprobación sólo continúa si el activo conserva + exactamente la versión enviada. +6. Al aprobar, el activo queda validado y el objetivo completado. Al rechazar, + vuelve a ejecución con el motivo registrado. + +Los resultados admitidos son activo confirmado, cambios registrados y no +localizado. GPS con precisión y al menos una fotografía son obligatorios para el +envío. Cambios registrados y no localizado requieren una explicación. + +## Inmutabilidad y Maestro único + +- `survey_target_reports` conserva el estado actual del informe; +- `survey_target_report_media` vincula evidencia existente del activo; +- `survey_target_report_versions` conserva cada envío y decisión como JSON + inmutable, incluyendo identificación, versión del activo y hash SHA-256 de cada + evidencia; +- las tres tablas revocan `DELETE` al usuario runtime; +- las versiones congeladas revocan también `UPDATE`; +- el envío actualiza procedencia y estado del activo original mediante el historial + ya existente del Maestro. + +Un informe enviado no puede editarse. Las correcciones requieren rechazo, +nuevo borrador y un nuevo envío; las versiones anteriores permanecen disponibles. + +## Permisos + +- `surveys.read_reports`: admin, director, supervisor, inspector y auditor; +- `surveys.capture`: admin, supervisor e inspector; +- `surveys.review`: admin, director y supervisor. + +Además se conservan los permisos de C1 para planificación y avance y los de B4 +para cargar fotografías protegidas. + +## API e interfaz + +Rutas nuevas: + +- `GET|PUT /api/v3/survey-campaign-targets/:targetId/report`; +- `POST /api/v3/survey-campaign-targets/:targetId/report/submit`; +- `POST /api/v3/survey-campaign-targets/:targetId/report/review`. + +La pantalla de campaña abre cada objetivo en una vista de ejecución con captura +GPS, carga y selección de evidencia, envío, control de calidad y consulta de todas +las versiones inmutables. + +Las inspecciones, visitas, actas y hallazgos continúan siendo un dominio separado +para una fase posterior. diff --git a/docs/PHASE_C2_DEPLOY.md b/docs/PHASE_C2_DEPLOY.md new file mode 100644 index 0000000..36103b1 --- /dev/null +++ b/docs/PHASE_C2_DEPLOY.md @@ -0,0 +1,30 @@ +# Deploy C2 + +## Archivo + +```text +DH_V2_FASE_C2_SOURCE.zip +``` + +Subir por FTP a: + +```text +/var/www/dhv2.korexlabs.com/ +``` + +## Migración + +```text +PhaseC2SurveyExecution1786896000000 +``` + +Crea informes, vínculos de evidencia y versiones inmutables, agrega el estado de +objetivo `SUBMITTED`, tres permisos específicos, índices, restricciones y +privilegios runtime sin borrado físico. + +TypeORM continúa con `synchronize: false`. C2 no agrega variables al `.env` ni +volúmenes nuevos. + +El bloque único de deploy respalda fuentes, base, `.env` y archivos, valida el +ZIP, construye API, migraciones y web, aplica la migración, recrea servicios y +ejecuta `scripts/phase-c2-acceptance.sh`. diff --git a/docs/PHASE_D1.md b/docs/PHASE_D1.md new file mode 100644 index 0000000..a0d8d62 --- /dev/null +++ b/docs/PHASE_D1.md @@ -0,0 +1,69 @@ +# Fase D1 — Planificación y apertura de visitas + +Versión: `0.15.0` + +## Objetivo + +Iniciar el dominio de inspecciones con la regla funcional acordada: una +inspección representa una visita. D1 planifica y abre esa visita; no incorpora +todavía actas, hallazgos ni el informe consolidado. + +## Modelo + +`inspection_visits` conserva identidad, título, objetivo, estado, fechas, +ubicación o alcance principal, responsable, instrucciones y trazabilidad. + +La visita utiliza el Maestro de Activos de dos maneras: + +- `scope_asset_id` referencia cualquier activo que represente su ubicación o + alcance principal; +- `inspection_visit_assets` vincula los activos concretos a inspeccionar. + +No se copian nombres, ubicaciones ni atributos. Todos los vínculos apuntan a los +activos originales y respetan la jerarquía dinámica: si se define un alcance, +los activos seleccionados deben pertenecer a esa rama. + +`inspection_visit_members` vincula usuarios activos con permiso de ejecución. +Un integrante se designa como inspector responsable y debe pertenecer al equipo. + +## Flujo controlado + +- `DRAFT`: admite cambios generales, activos y equipo; +- `PLANNED`: el plan está confirmado, pero aún puede volver a borrador; +- `IN_PROGRESS`: la visita fue iniciada y su planificación queda congelada; +- `CANCELLED`: estado terminal con motivo obligatorio; +- `CLOSED`: reservado para la futura operación de cierre e informe consolidado. + +Antes de confirmar o iniciar se exige ubicación, fecha de inicio, al menos un +activo, un equipo válido y un responsable incluido. Sólo un integrante asignado +o un usuario con gestión puede iniciar la visita. + +## Permisos + +- `inspections.read`: admin, director, supervisor, inspector y auditor; +- `inspections.manage`: admin y supervisor; +- `inspections.assign`: admin y supervisor; +- `inspections.execute`: admin, supervisor e inspector. + +Las tres tablas conceden al usuario runtime lectura, inserción y actualización, +pero revocan borrado físico. + +## API e interfaz + +Rutas principales: + +- `GET|POST /api/v3/inspection-visits`; +- `GET|PATCH /api/v3/inspection-visits/:id`; +- `PUT /api/v3/inspection-visits/:id/assets`; +- `PUT /api/v3/inspection-visits/:id/team`; +- `PUT /api/v3/inspection-visits/:id/status`; +- `POST /api/v3/inspection-visits/:id/start`. + +La sección **Inspecciones** reemplaza el placeholder por un listado y un editor +completo de visita. + +## Continuidad + +La próxima subfase agregará el acta única de cada visita. Después se +incorporarán hallazgos y evidencias, y finalmente firmas, rectificativas y el +informe técnico que incorporará el acta y sus hallazgos. diff --git a/docs/PHASE_D1_DEPLOY.md b/docs/PHASE_D1_DEPLOY.md new file mode 100644 index 0000000..992c3b4 --- /dev/null +++ b/docs/PHASE_D1_DEPLOY.md @@ -0,0 +1,29 @@ +# Deploy D1 + +## Archivo + +```text +DH_V2_FASE_D1_SOURCE.zip +``` + +Subir por FTP a: + +```text +/var/www/dhv2.korexlabs.com/ +``` + +## Migración + +```text +PhaseD1InspectionVisits1786982400000 +``` + +Crea visitas, vínculos a activos y equipos, cuatro permisos, índices, +restricciones y privilegios runtime sin borrado físico. + +TypeORM continúa con `synchronize: false`. D1 no agrega variables al `.env` ni +volúmenes nuevos. + +El bloque único de deploy respalda fuentes, base, `.env` y archivos, valida el +ZIP, construye API, migraciones y web, aplica la migración, recrea servicios y +ejecuta `scripts/phase-d1-acceptance.sh`. diff --git a/docs/PHASE_D2.md b/docs/PHASE_D2.md new file mode 100644 index 0000000..bb46ca2 --- /dev/null +++ b/docs/PHASE_D2.md @@ -0,0 +1,64 @@ +# Fase D2 — Base de actas y versiones + +Versión: `0.16.0` + +## Objetivo + +Crear la base documental de las actas. D2.1 corrige posteriormente la +cardinalidad para que cada visita contenga una única acta. El acta conserva +identidad oficial, activos vinculados y todas sus versiones, sin duplicar +información del Maestro de Activos. + +## Numeración oficial + +Al crear un acta el servidor asigna `ACTA-AAAA-NNNNNN`. El contador es global +para todas las actas del año y se incrementa dentro de la misma transacción que +crea el documento. Los números cancelados no se reutilizan. + +`document_annual_sequences` reserva también la serie `REPORT` para el futuro +informe consolidado, pero D2 sólo emite números de tipo `ACT`. + +## Modelo e integridad + +- `inspection_acts`: identidad, visita, número, estado y contenido actual; +- `inspection_act_assets`: referencias a activos incluidos en la visita; +- `inspection_act_versions`: snapshots inmutables de cada guardado; +- `document_annual_sequences`: contadores transaccionales por tipo y año. + +Los snapshots congelan el contenido del acta, el contexto de la visita y la +identidad y versión actual de cada activo seleccionado. La API runtime no tiene +permisos para borrar estas tablas ni para modificar versiones históricas. + +## Flujo D2 + +- la visita debe estar `IN_PROGRESS`; +- un integrante asignado, o gestión, puede crear y editar el acta; +- cada acta nace `DRAFT` y recibe número oficial inmediatamente; +- sólo un borrador puede editarse; +- admin o supervisor pueden cancelar un borrador con motivo obligatorio; +- la cancelación agrega una versión y conserva el número y todo el historial. + +Los estados `READY`, `CLOSED` y `RECTIFIED` quedan reservados. D2 no implementa +todavía cierre, firma, PDF, hash, token público ni rectificación. + +## Permisos y rutas + +- `inspection_acts.read`: consultar actas y versiones; +- `inspection_acts.create`: crear actas en visitas asignadas; +- `inspection_acts.update`: actualizar borradores; +- `inspection_acts.cancel`: cancelar borradores. + +Rutas: + +- `GET|POST /api/v3/inspection-visits/:visitId/acts`; +- `GET|PATCH /api/v3/inspection-acts/:id`; +- `POST /api/v3/inspection-acts/:id/cancel`. + +La pantalla de visita contiene su acta. El editor permite elegir los activos de +esa visita y consultar las versiones inmutables. + +## Continuidad + +D2.1 garantiza un acta por visita. La siguiente subfase incorporará múltiples +hallazgos dentro de esa acta. Luego se agregarán cierre y firma, documentos +verificables, rectificativas e informe técnico de la visita. diff --git a/docs/PHASE_D2_1.md b/docs/PHASE_D2_1.md new file mode 100644 index 0000000..37a60d8 --- /dev/null +++ b/docs/PHASE_D2_1.md @@ -0,0 +1,40 @@ +# Fase D2.1 — Un acta por visita + +Versión: `0.16.1` + +## Corrección conceptual + +El dominio queda definido así: + +1. una inspección es una visita; +2. cada visita genera una única acta; +3. el acta contiene múltiples hallazgos; +4. cada hallazgo puede vincular activos, normativa y evidencias; +5. al finalizar la visita, el acta se cierra, se firma y se distribuye; +6. después se genera un informe técnico que incorpora el acta y todos sus + hallazgos. + +El Informe Técnico Nº 124/2026 y su Acta Nº 105/2026 se usaron como referencia +funcional. La planilla de hallazgos se interpreta como catálogo configurable de +hallazgos por tipo de equipo y normativa, no como texto fijo de la interfaz. + +## Alcance de D2.1 + +- agrega una restricción única sobre `inspection_acts.visit_id`; +- bloquea en la API un segundo intento de creación con código + `INSPECTION_VISIT_ACT_ALREADY_EXISTS`; +- adapta la interfaz y el footer al concepto de acta única; +- mantiene numeración anual global, versiones, activos y auditoría de D2; +- no elimina ni transforma actas existentes. + +Antes de crear la restricción, la migración verifica que no existan visitas con +más de un acta. Si encuentra alguna, falla de forma segura y no modifica los +datos para que la situación se resuelva explícitamente. + +## Próximas fases + +D3 incorporará múltiples hallazgos dentro del acta y el catálogo configurable +derivado de la planilla entregada. Más adelante se implementarán cierre, +firmas de inspectores y responsable de empresa, PDF verificable, envío al +responsable y al correo interno configurable de Hidrocarburos, y el informe +técnico con numeración anual propia. diff --git a/docs/PHASE_D2_1_DEPLOY.md b/docs/PHASE_D2_1_DEPLOY.md new file mode 100644 index 0000000..6b0bff7 --- /dev/null +++ b/docs/PHASE_D2_1_DEPLOY.md @@ -0,0 +1,19 @@ +# Deploy D2.1 + +## Archivo + +`DH_V2_FASE_D2_1_SOURCE.zip` + +Subir por FTP a `/var/www/dhv2.korexlabs.com/`. + +## Migración + +`PhaseD21SingleActPerVisit1787072400000` + +La migración agrega la unicidad de `inspection_acts.visit_id`. No borra ni +combina datos. Si detecta más de un acta en alguna visita, interrumpe el deploy +antes de crear la restricción. + +D2.1 no agrega variables al `.env` ni volúmenes. El bloque único de despliegue +respalda fuentes, base, `.env` y archivos, valida el ZIP, construye servicios, +aplica la migración y ejecuta `scripts/phase-d2-1-acceptance.sh`. diff --git a/docs/PHASE_D2_DEPLOY.md b/docs/PHASE_D2_DEPLOY.md new file mode 100644 index 0000000..42e9812 --- /dev/null +++ b/docs/PHASE_D2_DEPLOY.md @@ -0,0 +1,30 @@ +# Deploy D2 + +## Archivo + +```text +DH_V2_FASE_D2_SOURCE.zip +``` + +Subir por FTP a: + +```text +/var/www/dhv2.korexlabs.com/ +``` + +## Migración + +```text +PhaseD2InspectionActs1787068800000 +``` + +Crea el contador anual, actas, referencias a activos, versiones inmutables, +cuatro permisos, restricciones, índices y privilegios runtime mínimos. + +TypeORM continúa con `synchronize: false`. D2 no agrega variables al `.env` ni +volúmenes nuevos. El ZIP también contiene D1; si D1 aún estuviera pendiente, el +comando de migración aplica ambas en orden. + +El bloque único de deploy respalda fuentes, base, `.env` y archivos, valida el +ZIP, construye API, migraciones y web, aplica migraciones, recrea servicios y +ejecuta `scripts/phase-d2-acceptance.sh`. diff --git a/docs/PHASE_D3.md b/docs/PHASE_D3.md new file mode 100644 index 0000000..41d8dd9 --- /dev/null +++ b/docs/PHASE_D3.md @@ -0,0 +1,78 @@ +# Fase D3 — Hallazgos y seguimiento + +Versión: `0.17.0` + +## Objetivo + +Permitir que el acta única de una visita contenga múltiples hallazgos y que +cada hallazgo permanezca abierto después de recibir la respuesta de la empresa, +hasta que el organismo verifique efectivamente la corrección. + +## Catálogo importado + +La fuente es `Lista de hallazgos (1).xlsx`, con SHA-256 +`a49e5d8627b3d43490b43b90d4a7efde6fd88f76cdc1c29fbb8906012e13587d`. + +La importación contiene: + +- 19 categorías; +- 170 modelos de hallazgo; +- descripción, normativa y glosario cuando la planilla los informa; +- códigos estables por categoría y número de origen; +- revisión e indicador de actividad para administración futura. + +La celda correspondiente a `BOMBAS-003` contenía solamente el número `4` en la +columna del título. Se normalizó como `PUESTA A TIERRA` por la normativa de la +misma fila y su repetición exacta en la categoría Defensa contra incendios. La +corrección queda registrada en `import_note`. + +## Ciclo operativo + +1. El inspector selecciona un modelo del catálogo o crea uno personalizado. +2. Vincula el hallazgo con un activo incluido en el acta. +3. Registra la descripción concreta y, si corresponde, el plazo exigido. +4. El hallazgo nace con estado `OPEN`. +5. Después de notificar a la empresa, se registra su respuesta y fecha. +6. Se registra por separado la fecha en que la empresa promete corregir. +7. El organismo define la fecha del próximo control. +8. La respuesta no cierra el hallazgo. +9. Una fase posterior permitirá cerrarlo únicamente después de verificar la + corrección en campo o mediante el procedimiento autorizado. + +## Fechas separadas + +- `correction_due_on`: plazo exigido por el organismo; +- `company_response_received_on`: recepción de la respuesta empresarial; +- `company_committed_correction_on`: compromiso declarado por la empresa; +- `next_control_on`: fecha programada por el organismo para volver a controlar. + +Estas fechas no se reemplazan entre sí. El dashboard usa `next_control_on` para +detectar controles vencidos y los próximos 30 días. + +## Integridad y trazabilidad + +- todo hallazgo referencia un activo incluido en el acta; +- el contenido del catálogo se copia al hallazgo como snapshot; +- cada alta, edición y seguimiento genera una versión inmutable; +- la API runtime no puede borrar hallazgos ni versiones; +- los cambios generan auditoría; +- editar el contenido requiere acta en borrador, visita en curso y asignación; +- registrar seguimiento requiere permiso específico y puede continuar después + del cierre documental del acta; +- sólo los estados futuros de verificación podrán pasar un hallazgo a cerrado. + +## Dashboard + +El panel muestra: + +- hallazgos abiertos; +- hallazgos todavía sin respuesta empresarial; +- controles cuya fecha ya venció; +- controles programados en los próximos 30 días; +- agenda de los ocho próximos controles con acceso al acta correspondiente. + +## Límites de D3 + +D3 no cierra hallazgos, actas ni visitas. Tampoco adjunta todavía el PDF de la +respuesta empresarial. D4 agregará fotografías, GPS y documentos protegidos, +incluido ese PDF. D5 implementará responsables, cierre y firmas. diff --git a/docs/PHASE_D3_DEPLOY.md b/docs/PHASE_D3_DEPLOY.md new file mode 100644 index 0000000..b2bd56b --- /dev/null +++ b/docs/PHASE_D3_DEPLOY.md @@ -0,0 +1,19 @@ +# Deploy D3 + +## Archivo + +`DH_V2_FASE_D3_SOURCE.zip` + +Subir por FTP a `/var/www/dhv2.korexlabs.com/`. + +## Migración + +`PhaseD3InspectionFindings1787155200000` + +Crea categorías, catálogo, hallazgos y versiones inmutables; importa 19 +categorías y 170 modelos; agrega seis permisos, asignaciones de roles, +restricciones, índices y privilegios runtime mínimos. + +D3 no agrega variables al `.env` ni volúmenes. El bloque único de despliegue +respalda fuentes, base, `.env` y archivos, valida el ZIP, construye los tres +servicios, aplica la migración y ejecuta `scripts/phase-d3-acceptance.sh`. diff --git a/docs/PHASE_D4.md b/docs/PHASE_D4.md new file mode 100644 index 0000000..255b136 --- /dev/null +++ b/docs/PHASE_D4.md @@ -0,0 +1,62 @@ +# Fase D4 — Evidencias y comunicaciones + +Versión: `0.18.0` + +## Objetivo + +Incorporar fotografías, documentos y comunicaciones a cada hallazgo sin +alterar el modelo definitivo: + +`visita → acta única → múltiples hallazgos → evidencias → firma y envío → informe`. + +## Evidencias + +- múltiples fotografías JPG, PNG o WebP por hallazgo; +- documentos PDF de campo, respuesta empresarial u otras comunicaciones; +- archivo original, autor, fecha, dispositivo, origen y SHA-256; +- GPS WGS84 y precisión opcionales; +- almacenamiento autenticado dentro del volumen persistente existente; +- vista y descarga sin exponer rutas físicas ni URL públicas. + +Las evidencias son inmutables. La API runtime solamente puede leerlas e +insertarlas: no puede modificarlas ni borrarlas. + +## Comunicaciones + +Cada hallazgo abierto admite un historial cronológico con: + +- sentido recibido, enviado o interno; +- medio email, presencial, teléfono, nota formal, sistema u otro; +- tipo respuesta empresarial, notificación, seguimiento u otro; +- fecha y hora, asunto, detalle y contacto; +- uno o más documentos vinculados. + +Una respuesta empresarial debe ser recibida. Su PDF sólo se acepta como +documento válido y vinculado a esa comunicación. Registrar la respuesta o su +PDF no cierra el hallazgo. + +## Reglas de campo + +- fotografías y documentos de observación sólo durante la visita en curso, + con acta en borrador y usuario asignado o con gestión general; +- comunicaciones y documentos posteriores pueden incorporarse mientras el + hallazgo permanezca abierto; +- auditor conserva acceso de sólo lectura; +- cada alta genera un evento de auditoría con el vínculo acta/visita/hallazgo. + +## Seguridad + +- validación por bytes mágicos, no por extensión declarada; +- límite de 15 MB; +- nombres físicos aleatorios y permisos `0600`; +- coordenadas completas y precisión controlada; +- vínculo compuesto que impide adjuntar un documento a una comunicación de + otro hallazgo; +- respuestas empresariales restringidas a PDF; +- `Cache-Control: private, no-store`, `nosniff` y sandbox para el contenido. + +## Límites de D4 + +D4 no cierra el acta ni genera firmas. D5 incorporará responsable empresarial, +firmas manuscritas, negativa o ausencia de firma y congelamiento coordinado de +la visita, el acta, los hallazgos y sus evidencias. diff --git a/docs/PHASE_D4_DEPLOY.md b/docs/PHASE_D4_DEPLOY.md new file mode 100644 index 0000000..9db7bf0 --- /dev/null +++ b/docs/PHASE_D4_DEPLOY.md @@ -0,0 +1,19 @@ +# Deploy D4 + +## Archivo + +`DH_V2_FASE_D4_SOURCE.zip` + +Subir por FTP a `/var/www/dhv2.korexlabs.com/`. + +## Migración + +`PhaseD4FindingEvidenceCommunications1787241600000` + +Crea dos tablas inmutables, cuatro permisos, asignaciones de roles, +restricciones, índices y privilegios runtime mínimos. Reutiliza el volumen +`dhv2_asset_media`; no agrega volúmenes ni exige cambios manuales en `.env`. + +El bloque único entregado junto con el ZIP valida el checksum, realiza backup, +construye API/migraciones/web, aplica la migración, recrea servicios y ejecuta +`scripts/phase-d4-acceptance.sh`. diff --git a/docs/PHASE_D5.md b/docs/PHASE_D5.md new file mode 100644 index 0000000..8c820e3 --- /dev/null +++ b/docs/PHASE_D5.md @@ -0,0 +1,73 @@ +# Fase D5 — Cierre y firmas + +Versión: `0.19.0` + +## Objetivo + +Cerrar la inspección conservando una constancia exacta e inmutable de lo +observado. El cierre del acta no cierra los hallazgos: cada hallazgo continúa +abierto hasta su verificación posterior. + +## Flujo + +1. El acta sigue en `DRAFT` durante la visita. +2. Se identifica al responsable de la empresa o se documenta su ausencia. +3. `Preparar para firmas` cambia el acta a `READY`, congela el contenido y + genera una huella SHA-256 canónica. +4. Firma al menos un inspector asignado. +5. La empresa firma la recepción o se registra una negativa/ausencia motivada. +6. `Cerrar y sellar` cierra atómicamente el acta y la visita. +7. Los hallazgos permanecen `OPEN`, admitiendo respuestas, PDFs, comunicaciones + y fecha del próximo control. + +Antes de la primera firma, un acta `READY` puede volver a `DRAFT`. Después de +registrar una firma o constancia ya no puede reabrirse ni modificarse. + +## Constancia manuscrita + +La captura en pantalla se denomina firma manuscrita o constancia de recepción. +No se presenta como firma digital certificada. Cada registro conserva: + +- identidad y rol del firmante; +- declaración aceptada y su versión; +- fecha del dispositivo y fecha del servidor; +- origen web/Android, dispositivo y GPS opcional; +- hash de la imagen y hash del payload de firma; +- vínculo con la huella exacta del contenido preparado. + +Las firmas sólo admiten `SELECT` e `INSERT` para el usuario runtime de la API. +No pueden actualizarse ni borrarse. + +## Congelado canónico + +El snapshot preparado usa el esquema `DH-ACT-CLOSURE-V1` e incluye: + +- acta y visita; +- responsable empresarial y equipo inspector; +- activos, atributos y geometrías vigentes; +- hallazgos, texto constatado, catálogo y base legal copiada; +- evidencias con metadatos y SHA-256; +- comunicaciones existentes al momento de preparar. + +Los objetos se serializan con claves ordenadas y fechas ISO-8601 antes de +calcular SHA-256. El cierre final agrega las firmas y las fechas de cierre, y +genera una segunda huella almacenada en el acta. + +## Persistencia y permisos + +La migración `PhaseD5ActClosingSignatures1787328000000` crea: + +- `inspection_act_responsibles`; +- `inspection_act_closures`; +- `inspection_act_signatures`; +- cuatro permisos `inspection_closure.*`; +- restricciones, índices y triggers de inmutabilidad. + +Las imágenes se guardan bajo +`/app/storage/asset-media/inspection-signatures`, dentro del volumen existente +`dhv2_asset_media`. No hay variables obligatorias nuevas. + +## Fuera de alcance + +El PDF oficial, QR/token de verificación y envío por correo corresponden a D6. +El informe técnico y su aprobación corresponden a D7. diff --git a/docs/PHASE_D5_1.md b/docs/PHASE_D5_1.md new file mode 100644 index 0000000..69044b3 --- /dev/null +++ b/docs/PHASE_D5_1.md @@ -0,0 +1,46 @@ +# Fase D5.1 — Operación exclusiva desde APK + +Versión: `0.19.1` + +## Regla funcional + +La visita se planifica en el dashboard, pero su ejecución documental pertenece +al inspector en campo. Sólo la APK puede iniciar una visita, crear o modificar +el acta única, registrar hallazgos y evidencias de observación, identificar al +responsable, capturar firmas y cerrar el acta y la visita. + +No alcanza con ocultar botones. Cada servicio operativo exige simultáneamente: + +- transporte bearer, utilizado por la aplicación móvil; +- rol `inspector`; +- permiso específico y asignación a la visita cuando corresponda. + +Las sesiones web usan cookie y reciben `403 INSPECTION_MOBILE_APP_REQUIRED` si +intentan invocar una operación de campo directamente. + +## Dashboard + +El dashboard conserva: + +- Maestro de Activos y tipos configurables; +- consulta del catálogo de hallazgos; su administración completa se incorpora + en D5.2; +- planificación de visita, selección de activos y asignación de inspectores; +- consulta de acta, hallazgos, evidencias, firmas e informe sincronizados; +- respuesta de la empresa, comunicación/PDF y fecha del próximo control; +- auditoría y administración de seguridad. + +La respuesta empresarial no altera el acta cerrada ni cierra el hallazgo. + +## Base de datos + +La migración `PhaseD51MobileInspectionPolicy1787331600000` no crea tablas. +Retira de roles no inspectores nueve permisos operativos y garantiza esos nueve +permisos al rol `inspector`. Los permisos de lectura, catálogos, planificación y +seguimiento posterior no cambian. + +## Alcance técnico + +Esta entrega fija el contrato y protege la API que consumirá la APK. El código +fuente Android no forma parte de este repositorio actual (`api-v3` + `web-v2`); +su cliente debe usar estos endpoints con bearer respetando la misma política. diff --git a/docs/PHASE_D5_1_DEPLOY.md b/docs/PHASE_D5_1_DEPLOY.md new file mode 100644 index 0000000..75f2bd5 --- /dev/null +++ b/docs/PHASE_D5_1_DEPLOY.md @@ -0,0 +1,18 @@ +# Deploy D5.1 + +## Archivo + +`DH_V2_FASE_D5_1_SOURCE.zip` + +Subir por FTP a `/var/www/dhv2.korexlabs.com/`. + +## Migración + +`PhaseD51MobileInspectionPolicy1787331600000` + +Es una migración aditiva de permisos. No modifica `.env`, no agrega tablas ni +volúmenes y no reescribe las migraciones D1–D5 ya instaladas. + +El bloque único entregado junto con el ZIP valida el checksum, hace backup de +código/base/.env/archivos protegidos, extrae, compila, migra, recrea API/web y +ejecuta `scripts/phase-d5-1-acceptance.sh`. diff --git a/docs/PHASE_D5_2.md b/docs/PHASE_D5_2.md new file mode 100644 index 0000000..899abdf --- /dev/null +++ b/docs/PHASE_D5_2.md @@ -0,0 +1,42 @@ +# Fase D5.2 — Catálogo administrable de hallazgos + +Versión: `0.19.2` + +## Objetivo + +El dashboard permite administrar las categorías y los tipos de hallazgo que +consume la APK durante una inspección. Esta configuración no habilita crear +visitas operativas, actas, hallazgos ni informes desde la web. + +## Alcance funcional + +- listado completo de categorías y tipos activos e inactivos; +- alta y edición de categorías, con orden y estado; +- alta y edición de tipos, con número, título, fundamento legal, glosario y + nota de origen; +- búsqueda y conteo de uso en hallazgos reales; +- desactivación sin borrado físico; +- permisos `finding_catalog.manage` para admin y supervisor. + +Los inspectores siguen siendo los únicos que pueden registrar hallazgos, +crear el acta y solicitar el informe desde la APK. El dashboard configura el +catálogo y consulta o gestiona el seguimiento posterior. + +## Integridad histórica + +Cada tipo comienza en revisión 1. Una edición incrementa la revisión y crea un +snapshot en `finding_catalog_item_versions`. La tabla es append-only para el +usuario de runtime: puede leer e insertar, pero no actualizar ni borrar. + +Los hallazgos ya existentes conservan el título, fundamento, glosario y +revisión que fueron copiados al momento de la inspección. Cambiar el catálogo +no altera un acta cerrada ni un informe histórico. + +## Auditoría + +Se registran altas y cambios de categorías y tipos mediante: + +- `FINDING_CATEGORY_CREATED`; +- `FINDING_CATEGORY_UPDATED`; +- `FINDING_CATALOG_ITEM_CREATED`; +- `FINDING_CATALOG_ITEM_UPDATED`. diff --git a/docs/PHASE_D5_2_DEPLOY.md b/docs/PHASE_D5_2_DEPLOY.md new file mode 100644 index 0000000..e2c7a41 --- /dev/null +++ b/docs/PHASE_D5_2_DEPLOY.md @@ -0,0 +1,19 @@ +# Deploy D5.2 + +## Archivo + +`DH_V2_FASE_D5_2_SOURCE.zip` + +Subir por FTP a `/var/www/dhv2.korexlabs.com/` y ejecutar el bloque único +entregado junto con el archivo. + +## Migración + +`PhaseD52FindingCatalogAdministration1787335200000` + +Es aditiva: crea y completa `finding_catalog_item_versions`. No modifica +`.env`, no agrega volúmenes y no reescribe las migraciones ya aplicadas. + +El despliegue valida checksum y ZIP, realiza backup, compila API/migrador/web, +aplica la migración, recrea API/web y ejecuta +`scripts/phase-d5-2-acceptance.sh`. diff --git a/docs/PHASE_D5_3.md b/docs/PHASE_D5_3.md new file mode 100644 index 0000000..5ad58ae --- /dev/null +++ b/docs/PHASE_D5_3.md @@ -0,0 +1,173 @@ +# Fase D5.3 — Contexto operativo Área–Empresa + +Versión: `0.19.3` + +## Objetivo + +Separar definitivamente la **jerarquía física** del Maestro de Activos de las +**relaciones operativas** que determinan qué empresa explota activos dentro de +un área. + +D5.3 prepara el contrato de datos que después utilizarán la planificación web +(D5.5) y la APK. No modifica todavía el flujo de inicio de inspecciones. + +## Modelo funcional + +La jerarquía física existente continúa usando `assets.parent_id`: + +```text +Área +└── Instalación + └── Estación + └── Equipo +``` + +Sobre esa jerarquía se agrega el contexto operativo: + +```text +Área ↔ Empresa + └── activos explotados por esa empresa dentro de esa área +``` + +Reglas: + +- una empresa puede explotar varias áreas; +- un área puede estar explotada por varias empresas; +- un activo operativo tiene como máximo una combinación activa + `Área + Empresa`; +- el área asignada debe ser ancestro físico del activo; +- el activo sólo puede asignarse a una empresa con vínculo activo con el área; +- Área y Empresa son roles configurables del **tipo de activo**; +- un activo Área o Empresa no recibe a su vez una asignación operativa; +- la relación Área–Empresa no se borra físicamente: se finaliza y conserva + vigencia, motivo y auditoría. + +## Cambios de datos + +### `asset_types` + +Se agrega `operational_role`: + +- `GENERIC`: instalación, estación, equipo u otro activo operativo; +- `AREA`: activo que funciona como área/concesión; +- `COMPANY`: activo que funciona como empresa explotadora. + +La migración deja todos los tipos existentes en `GENERIC`. No intenta deducir +roles por nombre o código, para evitar clasificaciones automáticas erróneas. + +### `area_company_relations` + +Nueva tabla histórica con: + +- área; +- empresa; +- fecha de inicio; +- fecha de finalización; +- motivo de alta; +- motivo de finalización; +- usuario que creó el vínculo; +- usuario que lo finalizó; +- timestamps de auditoría. + +Sólo puede existir un vínculo activo por cada par Área–Empresa. + +### `assets` + +Se agregan: + +- `operational_area_id`; +- `operator_company_id`. + +Ambos campos son nulos o están completos juntos. La pareja representa la +asignación operativa exclusiva del activo. + +## Integridad + +La API y PostgreSQL validan que: + +1. el Área y la Empresa existan y estén activos; +2. sus tipos tengan roles `AREA` y `COMPANY` respectivamente; +3. exista un vínculo Área–Empresa activo; +4. el activo asignado sea de un tipo `GENERIC`; +5. el Área pertenezca a la cadena de ancestros físicos del activo; +6. mover una rama de la jerarquía no deje descendientes fuera de su Área; +7. no se pueda finalizar una relación mientras existan activos asignados; +8. no se pueda inactivar un Área o Empresa mientras siga en uso. + +## Dashboard + +### Tipos de activo + +El administrador puede indicar el rol operativo de cada tipo: + +- Genérico; +- Área; +- Empresa. + +No se permite cambiar un rol si eso rompería relaciones o asignaciones ya +existentes. + +### Activos Área / Empresa + +En la ficha se muestra **Relaciones operativas**: + +- vincular Área ↔ Empresa; +- indicar motivo; +- consultar vínculos vigentes e históricos; +- ver cuántos activos dependen del vínculo; +- finalizar el vínculo con motivo, sólo cuando ya no tenga activos asignados. + +### Activos operativos + +En activos de tipo `GENERIC` aparece **Contexto operativo**: + +1. se selecciona Área; +2. se muestran sólo Empresas con vínculo activo en esa Área; +3. se selecciona la Empresa explotadora. + +La asignación es opcional durante la transición de datos, para que la migración +no invalide el Maestro ya existente. Antes de D5.5 deberán quedar configurados +los activos que participen en inspecciones. + +### Listado + +El Maestro incorpora filtros por: + +- Área operativa; +- Empresa explotadora. + +También muestra el contexto operativo actual de cada activo. + +## Permisos + +- `asset_relations.read`: admin, director, supervisor, inspector y auditor; +- `asset_relations.manage`: admin y supervisor. + +## Auditoría e historial + +Las altas y finalizaciones generan: + +- `ASSET_AREA_COMPANY_RELATION_CREATED`; +- `ASSET_AREA_COMPANY_RELATION_ENDED`. + +Los snapshots históricos de activos incorporan: + +- rol operativo del tipo; +- Área operativa; +- Empresa explotadora. + +La migración completa las versiones anteriores con valores neutros para evitar +falsos cambios históricos. + +## Fuera de alcance de D5.3 + +No se implementa todavía: + +- selección Área → Empresa en una planificación; +- generación automática de checklist; +- modelos de hallazgo por tipo/activo; +- gravedad 1–10; +- opción `OTROS`; +- cambios de APK. + +Esos puntos pertenecen a D5.4, D5.5 y E1. diff --git a/docs/PHASE_D5_3_1.md b/docs/PHASE_D5_3_1.md new file mode 100644 index 0000000..c4e2cfe --- /dev/null +++ b/docs/PHASE_D5_3_1.md @@ -0,0 +1,60 @@ +# Fase D5.3.1 — Configuración inicial del Maestro + +Versión de paquete: `0.19.3-1` +Fase visible: `D5.3.1` + +> Se usa `0.19.3-1` porque `0.19.3.1` no es una versión SemVer válida para los +> paquetes npm. Funcionalmente corresponde a la corrección D5.3.1. + +## Objetivo + +Evitar que una instalación nueva de DH comience con un Maestro totalmente vacío y +obligue a recrear manualmente la taxonomía hidrocarburífera básica. + +## Configuración inicial + +Desde `Maestro → Tipos y atributos`, cuando no existe ningún tipo, un administrador +puede ejecutar **Instalar configuración base**. + +La operación se ejecuta en una sola transacción y crea: + +- Área / Concesión (`AREA`); +- Empresa / Operadora (`COMPANY`); +- Yacimiento; +- Instalación de superficie; +- Estación; +- Subestación; +- Pozo; +- Equipo; +- Ducto / Cañería. + +También crea sus reglas padre/hijo y atributos opcionales iniciales. + +## Seguridad + +- requiere `asset_types.manage`; +- sólo puede ejecutarse con `asset_types = 0`; +- bloquea intentos concurrentes; +- no reemplaza ni modifica configuraciones existentes; +- no crea datos operativos reales; +- registra `ASSET_MASTER_BOOTSTRAPPED` en auditoría. + +## Fuente de dominio + +La taxonomía se alineó con la terminología que usa la Dirección de Hidrocarburos de +Mendoza y con la estructura funcional acordada para DH. Ver: + +`docs/DATA_REFERENCE_MENDOZA_HYDROCARBONS.md`. + +## Alcance excluido + +D5.3.1 no importa automáticamente: + +- operadoras actuales; +- concesiones o áreas actuales; +- yacimientos; +- pozos; +- instalaciones; +- métricas públicas de inspección. + +Esos datos necesitan una fuente versionada/importable y trazabilidad de vigencia. diff --git a/docs/PHASE_D5_3_11.md b/docs/PHASE_D5_3_11.md new file mode 100644 index 0000000..4c87db2 --- /dev/null +++ b/docs/PHASE_D5_3_11.md @@ -0,0 +1,28 @@ +# Fase D5.3.11 · Importación parcial y revisiones + +## Objetivo + +Permitir que un lote avance con las ramas independientes aunque existan decisiones pendientes, manteniendo protegidos los registros que dependen de esas decisiones. + +## Reglas + +1. Una revisión abierta no habilita escrituras sobre su propia rama ni sobre entidades dependientes. +2. Las ramas sin dependencia de una revisión pueden aplicarse mediante `asset_imports.apply`. +3. La aplicación parcial valida el hash del plan y la huella vigente del Maestro antes de escribir. +4. Cada aplicación se ejecuta dentro de una transacción. +5. Las revisiones permanecen asociadas al plan activo y se consultan desde una bandeja general de Importaciones. +6. Una nueva revisión del plan puede reconocer como existentes los registros incorporados anteriormente. +7. El rollback lógico contempla registros creados por todas las revisiones del mismo lote. +8. Un lote con aplicación parcial no puede cancelarse ni volver a conciliación preliminar sin revertir; sí puede regenerar su plan. + +## Estados visibles + +- Pendiente de aplicar. +- Importado parcialmente. +- Revisiones pendientes. +- Importado completo. +- Revertido. + +## Base de datos + +La fase no agrega migraciones. Utiliza las tablas de lotes, planes e ítems existentes y conserva el progreso en `application_summary` y `analysis.importExecution`. diff --git a/docs/PHASE_D5_3_11_1.md b/docs/PHASE_D5_3_11_1.md new file mode 100644 index 0000000..cf959ab --- /dev/null +++ b/docs/PHASE_D5_3_11_1.md @@ -0,0 +1,14 @@ +# Fase D5.3.11.1 · Navegación de importados + +## Objetivo + +Hacer visibles en el Maestro los activos incorporados por importación parcial cuando la fuente identifica Área y Operadora, sin convertir esa asignación de inventario en una relación formal `area_company_relations`. + +## Comportamiento + +- La navegación Empresa → Área considera relaciones operativas vigentes registradas y también Áreas usadas por activos no inactivos asignados a esa empresa. +- El filtro Área → Operadora aplica el mismo criterio. +- No se crean ni modifican relaciones legales u operativas formales. +- La jerarquía física continúa usando `parent_id`. +- Se conserva la importación parcial y la bandeja de revisiones de D5.3.11. +- No requiere migración de base de datos. diff --git a/docs/PHASE_D5_3_14.md b/docs/PHASE_D5_3_14.md new file mode 100644 index 0000000..dbc4aec --- /dev/null +++ b/docs/PHASE_D5_3_14.md @@ -0,0 +1,29 @@ +# Fase D5.3.14 · Expediente técnico + +## Objetivo + +Convertir cada elemento del Inventario en un punto de acceso a su historia operativa y documental. + +## Alcance + +- Nueva pestaña **Expediente** en la ficha de Inventarios. +- Resumen de inspecciones, actas, hallazgos, fotografías y documentos. +- Línea de tiempo unificada del elemento. +- Acceso directo desde el expediente a inspecciones, actas y hallazgos. +- Consulta de evidencias y comunicaciones vinculadas a hallazgos del elemento. +- Consulta de documentos fuente e informes técnicos vinculados al registro. +- Preservación del historial de versiones del Inventario. + +## Criterio de datos + +El expediente no copia información ni crea estructuras paralelas. Consulta las entidades existentes y presenta una vista cronológica común. + +Los informes consolidados de inspección continúan fuera de esta fase y se incorporarán al centro documental general en una etapa posterior. + +## Seguridad + +El endpoint de expediente exige permisos de lectura de Inventarios, inspecciones, actas, hallazgos, evidencias y comunicaciones. + +## Base de datos + +No requiere migraciones. diff --git a/docs/PHASE_D5_3_15_1.md b/docs/PHASE_D5_3_15_1.md new file mode 100644 index 0000000..d1448a2 --- /dev/null +++ b/docs/PHASE_D5_3_15_1.md @@ -0,0 +1,56 @@ +# D5.3.15.1 · Centro documental + +## Objetivo + +Concentrar la consulta de actas e informes de inspección sin duplicar el contenido operativo que ya existe en visitas, hallazgos e Inventarios. + +## Actas + +El dashboard incorpora una vista global de actas con búsqueda y filtros por estado y año. Cada fila mantiene acceso directo a la inspección, los hallazgos y el informe relacionado cuando exista. + +La API incorpora `GET /inspection-acts` para consulta paginada global. El endpoint por visita permanece disponible y conserva su comportamiento. + +## Informes + +Se incorpora el registro `inspection_reports` con numeración anual independiente mediante la secuencia `REPORT` ya prevista en el modelo documental. + +Un informe sólo puede solicitarse sobre un acta cerrada. La solicitud se realiza mediante una operación autenticada de inspector desde la aplicación de campo. El backend: + +- valida que el acta esté cerrada y sellada; +- valida que el inspector esté asignado a la visita; +- asigna un número anual global de informe; +- congela la instantánea final del cierre del acta; +- conserva versión del acta y hashes de integridad; +- deja la composición PDF en estado pendiente para la etapa documental posterior. + +El dashboard no numera ni genera informes manualmente. Muestra dos vistas: informes emitidos y actas cerradas pendientes de emisión. + +## Navegación + +Se agregan accesos globales: + +- `/actas` +- `/informes` +- `/informes/:id` + +Desde estas vistas se puede navegar a la inspección, acta y hallazgos relacionados. + +## Seguridad + +Permisos incorporados: + +- `inspection_reports.read` +- `inspection_reports.generate` + +La lectura se concede a perfiles de consulta operativa. La generación queda reservada al inspector y además requiere autenticación de campo mediante la política móvil existente. + +El contenido congelado del informe está protegido a nivel de base de datos. La tabla no admite eliminación mediante el rol de aplicación y un trigger impide alterar la identidad, numeración, snapshot y hashes una vez emitidos. + +## Base de datos + +La fase incorpora una migración para crear `inspection_reports`, permisos, índices y protección de inmutabilidad. + + +## Corrección 15.1 + +Ajuste de tipado estricto en los rótulos de contexto de las bandejas globales de Actas e Informes. No modifica el modelo documental ni la migración de D5.3.15. diff --git a/docs/PHASE_D5_3_16.md b/docs/PHASE_D5_3_16.md new file mode 100644 index 0000000..d13357e --- /dev/null +++ b/docs/PHASE_D5_3_16.md @@ -0,0 +1,47 @@ +# D5.3.16 · Planificación de verificaciones + +## Objetivo + +Transformar la segunda fecha del seguimiento de un hallazgo en trabajo operativo planificable desde oficina, sin confundirla con el vencimiento administrativo de respuesta de la empresa. + +## Regla funcional + +- `correction_due_on`: vencimiento administrativo para recibir la respuesta de la empresa. +- `next_control_on`: fecha objetivo para verificar en campo la resolución del hallazgo. +- Una verificación sólo entra en la bandeja operativa cuando el hallazgo sigue abierto, la empresa ya respondió y `next_control_on` está definido. +- La planificación no modifica ninguna de esas fechas. + +## Bandeja de planificación + +Ruta: `/hallazgos/planificacion`. + +La pantalla separa: + +- hallazgos verificables sin visita; +- verificaciones vencidas sin planificar; +- verificaciones próximas; +- hallazgos que ya tienen una visita activa asociada. + +Los hallazgos se seleccionan por empresa y área. No se permite mezclar contextos operativos distintos dentro de una misma visita. + +## Creación de visita + +La acción de oficina crea una visita de verificación en estado `DRAFT` con: + +- área como alcance principal; +- elementos del Inventario afectados; +- fecha y hora previstas; +- referencias explícitas a los hallazgos; +- trazabilidad entre hallazgo y visita. + +La visita se completa después con inspector y equipo y se marca `PLANNED` usando el flujo existente de Inspecciones. + +## Trazabilidad + +La tabla `inspection_finding_verification_visits` conserva el vínculo histórico entre hallazgo y visita. Un hallazgo no puede incorporarse simultáneamente a dos visitas activas (`DRAFT`, `PLANNED` o `IN_PROGRESS`). Si una visita se cancela o termina y el hallazgo continúa abierto, puede planificarse una nueva verificación sin perder el vínculo anterior. + +## Permisos + +`inspection_verifications.plan` se asigna inicialmente a admin, director y supervisor. + +La consulta de la bandeja conserva `inspection_findings.read`. diff --git a/docs/PHASE_D5_3_16_DEPLOY.md b/docs/PHASE_D5_3_16_DEPLOY.md new file mode 100644 index 0000000..3697f13 --- /dev/null +++ b/docs/PHASE_D5_3_16_DEPLOY.md @@ -0,0 +1,7 @@ +# Deploy D5.3.16 + +Origen requerido: D5.3.15.1 / `0.19.3-15.1`. + +El deploy compila API, ejecuta tests y compila Web en staging antes de modificar producción. Luego crea backup PRE, instala el source, construye las imágenes, ejecuta la migración, recrea API/Web, corre aceptación y genera backup POST. + +La migración crea `inspection_finding_verification_visits` y el permiso `inspection_verifications.plan`. diff --git a/docs/PHASE_D5_3_17.md b/docs/PHASE_D5_3_17.md new file mode 100644 index 0000000..a630e18 --- /dev/null +++ b/docs/PHASE_D5_3_17.md @@ -0,0 +1,31 @@ +# D5.3.17 · Verificación de campo + +## Objetivo + +Cerrar el circuito iniciado por la planificación de verificaciones, manteniendo separadas la gestión administrativa del hallazgo y la constatación en campo. + +## Resultado de campo + +Cada hallazgo vinculado a una visita de verificación en curso admite un resultado: + +- `RESOLVED`: la condición fue verificada como solucionada; queda disponible para revisión y cierre administrativo cuando finaliza la visita. +- `NOT_RESOLVED`: el hallazgo continúa abierto y vuelve a oficina sin una nueva fecha automática. +- `REQUIRES_NEW_DATE`: el hallazgo continúa abierto y el inspector propone una nueva fecha de control posterior a la verificación. + +La captura requiere sesión móvil del inspector asignado y permiso `inspections.execute`. + +## Evidencia + +Las fotografías de verificación usan la finalidad `VERIFICATION` y quedan asociadas al hallazgo y a la visita de verificación. No reemplazan la evidencia original ni la documentación presentada por la empresa. + +## Cierre documental + +Una visita de verificación puede preparar su acta aunque no se hayan creado hallazgos nuevos, siempre que tenga hallazgos de verificación vinculados y todos posean un resultado. El snapshot de cierre incorpora resultados y fotografías de verificación y utiliza el esquema `DH-ACT-CLOSURE-V2`. + +## Trabajo de oficina + +Los hallazgos cuyo último resultado sea `RESOLVED` y cuya visita ya esté cerrada aparecen en `Hallazgos → Listos para cerrar`. Los resultados resueltos dejan de aparecer en la cola de nuevas verificaciones mientras el hallazgo siga abierto administrativamente. + +## Expediente del Inventario + +La cronología del elemento registra cada verificación y conserva el vínculo hacia la visita y el hallazgo correspondiente. diff --git a/docs/PHASE_D5_3_17_1.md b/docs/PHASE_D5_3_17_1.md new file mode 100644 index 0000000..8080dfa --- /dev/null +++ b/docs/PHASE_D5_3_17_1.md @@ -0,0 +1,11 @@ +# D5.3.17.1 · Verificación de campo + +## Corrección + +Esta entrega corrige la validación de D5.3.17 antes de producción. La versión anterior se detuvo en staging y no modificó la base de datos ni los servicios productivos. + +El resultado `RESOLVED` queda tratado explícitamente en el servicio de verificaciones. Al verificarse un hallazgo como solucionado, se elimina su próxima fecha operativa de control y queda disponible para revisión y cierre administrativo una vez finalizada la visita. + +Se conserva sin cambios el modelo funcional de D5.3.17: resultados `RESOLVED`, `NOT_RESOLVED` y `REQUIRES_NEW_DATE`, evidencia de verificación, cierre documental de la visita, bandeja de hallazgos listos para cerrar y cronología del Inventario. + +La migración de D5.3.17 se mantiene sin cambios porque la entrega anterior no llegó a ejecutarla en producción. diff --git a/docs/PHASE_D5_3_18.md b/docs/PHASE_D5_3_18.md new file mode 100644 index 0000000..3980cdc --- /dev/null +++ b/docs/PHASE_D5_3_18.md @@ -0,0 +1,35 @@ +# D5.3.18 · Expediente operativo de hallazgos + +## Objetivo + +Convertir la ficha del hallazgo en una mesa de trabajo única para oficina, evitando saltos innecesarios entre seguimiento, comunicaciones, documentos, planificación y cierre. + +## Flujo operativo + +La ficha muestra cuatro etapas visibles: hallazgo, respuesta de empresa, verificación y cierre. El backend expone una recomendación de trabajo según el estado real del hallazgo y sus dos fechas operativas. + +El vencimiento administrativo permanece en `correction_due_on` y representa el plazo de la empresa para responder. La fecha `next_control_on` continúa siendo independiente y alimenta la planificación de verificaciones. `company_committed_correction_on` se conserva como fecha informada por la empresa y no reemplaza a ninguna de las dos anteriores. + +## Respuesta de empresa + +La primera respuesta puede registrarse desde una sola sección. El operador carga el resumen recibido, fecha y hora, medio, contacto opcional, fecha informada por la empresa, fecha de verificación opcional y un PDF opcional. + +El resumen actualiza el seguimiento versionado. La comunicación queda registrada en el historial inmutable y, cuando se adjunta un PDF, se vincula a esa comunicación usando el almacenamiento protegido y el hash SHA-256 existente. + +## Verificación + +Con respuesta recibida, la misma ficha permite definir o modificar la fecha operativa de verificación. Cuando corresponde planificar una visita, se ofrece acceso directo a la bandeja de planificación. Si ya existe una visita activa, se abre directamente desde el expediente. + +El último resultado de campo se muestra dentro del mismo circuito y conserva los estados Solucionado, No solucionado y Requiere nueva fecha. + +## Cierre + +El cierre administrativo se ejecuta desde la misma ficha. Si existe una visita de verificación activa, permanece bloqueado. Cuando la última verificación está resuelta y la visita cerrada, el expediente destaca que el hallazgo está listo para cierre. + +## Documentos y trazabilidad + +La respuesta principal se gestiona en el expediente operativo. El panel inferior queda como centro de documentos adicionales, adjuntos de comunicaciones y trazabilidad histórica. Se mantiene disponible la carga de un PDF de respuesta para completar o reintentar un adjunto vinculado. + +## Base de datos + +D5.3.18 no agrega migraciones. Reutiliza los campos, comunicaciones, evidencias, verificaciones, auditoría y versionado ya existentes. diff --git a/docs/PHASE_D5_3_18_2.md b/docs/PHASE_D5_3_18_2.md new file mode 100644 index 0000000..44b9b82 --- /dev/null +++ b/docs/PHASE_D5_3_18_2.md @@ -0,0 +1,30 @@ +# D5.3.18.2 · Expediente operativo de Hallazgos + +Esta fase es un hotfix acumulativo sobre D5.3.18 ya desplegada y parte directamente de D5.3.18. + +## Objetivo + +Concentrar en una sola ficha el trabajo de oficina sobre un hallazgo y alinear el seguimiento con las definiciones funcionales confirmadas: + +- la primera respuesta registrada de la empresa no se modifica; +- las presentaciones posteriores son históricas y append-only; +- documentos y evidencias quedan vinculados a cada presentación; +- el vencimiento administrativo y la fecha de verificación siguen separados; +- el cierre administrativo queda reservado a Inspector, Supervisor y Director; +- una verificación resuelta habilita el cierre, pero no lo ejecuta automáticamente. + +## Alcance + +La ficha muestra una acción recomendada según el estado del hallazgo, integra respuesta de empresa, documentos, fechas de seguimiento, planificación/resultado de verificación y cierre. + +Las comunicaciones existentes siguen siendo la fuente histórica para múltiples presentaciones sucesivas. El resumen de la primera respuesta en `inspection_findings` queda protegido contra sobrescritura accidental una vez registrada su fecha de recepción. + +## Migración + +La fase agrega el permiso `inspection_findings.close` y lo asigna exclusivamente a los roles `inspector`, `supervisor` y `director`. + +No modifica la estructura de los hallazgos ni borra información existente. + +## Fuera de alcance + +La relación contextual Tipo de elemento ↔ Catálogo de Hallazgos se mantiene pendiente de definición fina con Hidrocarburos. Se recomienda que sea sugerida, no restrictiva, con `OTROS` siempre disponible y versionada. diff --git a/docs/PHASE_D5_3_18_3.md b/docs/PHASE_D5_3_18_3.md new file mode 100644 index 0000000..a0e2591 --- /dev/null +++ b/docs/PHASE_D5_3_18_3.md @@ -0,0 +1,7 @@ +# D5.3.18.3 · Expediente operativo de Hallazgos + +Corrección de consistencia de pruebas sobre D5.3.18.2. + +El endpoint de cierre usa el permiso específico `inspection_findings.close`. La prueba heredada que todavía esperaba `inspection_findings.follow_up` fue actualizada para representar la regla funcional vigente. + +La fase parte de producción D5.3.18 / 0.19.3-18. D5.3.18.2 no fue desplegada: su ejecución se detuvo en staging antes de modificar producción. diff --git a/docs/PHASE_D5_3_19.md b/docs/PHASE_D5_3_19.md new file mode 100644 index 0000000..947c7bc --- /dev/null +++ b/docs/PHASE_D5_3_19.md @@ -0,0 +1,36 @@ +# D5.3.19 · Inventarios y filtros operativos + +## Objetivo + +Mejorar la identificación cotidiana de los elementos del Inventario y hacer más ágil la consulta de la operación anual, sin modificar las reglas documentales ya confirmadas. + +## Nombre habitual / sobrenombre + +Todos los elementos pueden guardar un `common_name` opcional. El dato se muestra como **Nombre habitual / sobrenombre** y no reemplaza al nombre técnico ni al código. La búsqueda de Inventarios contempla los tres valores y el historial/versionado captura sus cambios. + +## Filtros operativos + +Las bandejas globales de Inspecciones, Actas, Informes y Hallazgos aceptan Empresa, Área, Inspector, Desde y Hasta. Los filtros se aplican en servidor y se conservan en la URL para permitir volver a la misma consulta. + +## Vencimiento administrativo de respuesta + +El hallazgo conserva `correction_due_on` como vencimiento efectivo y agrega metadatos para explicar cómo se calculó: + +- `FINDING_DATE`: X días desde la fecha del hallazgo/acta; +- `REPORT_NOTIFICATION`: X días desde la fecha de notificación del informe. + +Se guardan la regla, la cantidad de días, la fecha base y, cuando corresponde, la fecha de notificación. Una vez registrada la primera respuesta de la empresa, esa regla administrativa queda bloqueada junto con la respuesta original. + +La fecha informada por la empresa y la fecha operativa de verificación continúan siendo conceptos independientes. + +## Reglas preservadas + +- una visita genera una sola acta con múltiples hallazgos; +- acta cerrada y hallazgo original no se reescriben; +- respuestas posteriores y verificaciones son históricas; +- cierre de hallazgo reservado a Inspector, Supervisor y Director; +- no se implementa en esta fase la relación contextual Tipo de elemento ↔ Catálogo de Hallazgos. + +## Nota de integración futura + +Mientras no esté automatizado el envío/notificación del Informe Word, la fecha de notificación puede ser registrada desde el seguimiento administrativo. Cuando se implemente el envío automático, esa fecha deberá alimentarse desde el evento real de notificación y no duplicarse manualmente. diff --git a/docs/PHASE_D5_3_19_1.md b/docs/PHASE_D5_3_19_1.md new file mode 100644 index 0000000..099f59c --- /dev/null +++ b/docs/PHASE_D5_3_19_1.md @@ -0,0 +1,7 @@ +# D5.3.19.1 · Inventarios y filtros operativos + +Patch de D5.3.19 detenido en staging antes de producción. + +Corrige el contrato TypeScript de `createAsset` en Web para aceptar `commonName?: string | null`, alineándolo con el DTO/API y con `updateAsset`. No altera la migración funcional D5.3.19 ni los datos. + +Mantiene como origen productivo D5.3.18.3 (`0.19.3-18.3`) porque D5.3.19 falló durante el build Web, antes del backup PRE y antes de instalar source. diff --git a/docs/PHASE_D5_3_1_DEPLOY.md b/docs/PHASE_D5_3_1_DEPLOY.md new file mode 100644 index 0000000..13177a4 --- /dev/null +++ b/docs/PHASE_D5_3_1_DEPLOY.md @@ -0,0 +1,25 @@ +# Deploy D5.3.1 + +## Particularidad + +D5.3.1 no agrega migraciones. El deploy debe: + +1. respaldar base y código; +2. reemplazar source; +3. compilar API y web; +4. ejecutar tests API; +5. confirmar `Pending migrations: no`; +6. recrear API y web; +7. ejecutar `scripts/phase-d5-3-1-acceptance.sh`. + +El bootstrap del Maestro **no se ejecuta durante el deploy**. Después de validar la +versión, un administrador entra a `Maestro → Tipos y atributos` y elige +`Instalar configuración base`. + +## Resultado esperado + +- API `0.19.3-1`, fase `D5.3.1`; +- WEB `0.19.3-1`; +- base sin migraciones pendientes; +- Maestro vacío hasta que el administrador confirme la configuración inicial; +- rutas de bootstrap protegidas por autenticación y permisos. diff --git a/docs/PHASE_D5_3_2.md b/docs/PHASE_D5_3_2.md new file mode 100644 index 0000000..8cfc2b2 --- /dev/null +++ b/docs/PHASE_D5_3_2.md @@ -0,0 +1,32 @@ +# Fase D5.3.2 · Nomenclatura técnica y datos de desarrollo + +Versión: `0.19.3-2` + +## Objetivo + +Alinear el Maestro con la nomenclatura observada en la operatoria de Hidrocarburos de Mendoza y disponer de un caso demostrativo seguro antes de avanzar con D5.4. + +## Alcance + +- ampliación aditiva del preset de Maestro ya instalado en D5.3.1; +- tipos de instalación, sistema y equipo específicos; +- un único tipo Pozo con método/función configurable; +- endpoints protegidos para consultar y aplicar el enriquecimiento; +- acción visible en dashboard `Completar catálogo técnico`; +- seed de desarrollo basado en ITN 124/2026; +- limpiador controlado con dry-run, backup y protección contra mezcla demo/real; +- documentación de nomenclatura y criterio activo vs componente. + +## Compatibilidad + +D5.3.2 no agrega migraciones de esquema. Conserva la migración D5.3 y no modifica los activos reales existentes. La ampliación sólo crea tipos, atributos y reglas faltantes; no reemplaza configuraciones ya existentes. + +## Tipos técnicos incorporados + +Además de los tipos base se contemplan, entre otros: Locación, Planta, Batería, Zona de bombas, Sistema de drenaje, Sistema eléctrico/iluminación, Defensa contra incendios, Cargadero/descargadero, Pileta API, Tanque, Separador, Bomba, Caldera, Antorcha, Colector, Filtro, Equipo de flotación, FWKO, Tratador y Calentador. + +`Equipo` continúa disponible como tipo genérico para inventarios incompletos. + +## Siguiente fase + +Con la taxonomía probada, D5.4 podrá relacionar el catálogo de hallazgos con tipos técnicos y activos concretos, agregar gravedad 1–10 y soportar `OTROS` como propuesta de catálogo. diff --git a/docs/PHASE_D5_3_20.md b/docs/PHASE_D5_3_20.md new file mode 100644 index 0000000..7f0e91b --- /dev/null +++ b/docs/PHASE_D5_3_20.md @@ -0,0 +1,9 @@ +# D5.3.20 · Informe Word automático + +D5.3.20 alinea el cierre documental con la regla funcional vigente: una visita produce una sola acta con múltiples hallazgos y, al cerrarse definitivamente esa acta, el sistema crea automáticamente el registro congelado del informe y genera un archivo Word para revisión manual del Director de Hidrocarburos. + +El Word se construye exclusivamente desde la instantánea congelada del acta. El archivo posee SHA-256 propio, metadatos de almacenamiento y estado PENDING/READY/FAILED. La pantalla de Informes muestra el estado del Word y permite descargarlo cuando está disponible. + +El diseño institucional definitivo y la firma final del Director siguen pendientes de definición. Esta fase no activa correo electrónico: el envío automático del acta a empresa/oficina y del Word al Director requiere definir y administrar destinatarios institucionales antes de conectar SMTP. + +Se preservan una visita = una acta, hallazgos inmutables con seguimiento append-only y la numeración anual independiente del informe. diff --git a/docs/PHASE_D5_3_21.md b/docs/PHASE_D5_3_21.md new file mode 100644 index 0000000..e9fb7e5 --- /dev/null +++ b/docs/PHASE_D5_3_21.md @@ -0,0 +1,5 @@ +# Fase D5.3.21 · Entrega documental + +La fase incorpora destinatarios institucionales configurables, email oficial por empresa, PDF automático provisional del Acta, cola auditable de entregas, reintentos y transporte SMTP opcional. El cierre documental nunca depende de que el correo esté disponible: si falta destinatario, SMTP o artefacto, la entrega queda pendiente con un estado explícito. + +Reglas: Acta cerrada e inmutable → PDF automático → empresa + oficina. Informe Word congelado → Director de Hidrocarburos. Las direcciones no se codifican en source y los secretos SMTP permanecen en `.env`. diff --git a/docs/PHASE_D5_3_21_1.md b/docs/PHASE_D5_3_21_1.md new file mode 100644 index 0000000..b1e40c9 --- /dev/null +++ b/docs/PHASE_D5_3_21_1.md @@ -0,0 +1,18 @@ +# Fase D5.3.21.1 · Entrega documental + +Parche de compilación de D5.3.21 sobre producción D5.3.20. + +D5.3.21 no llegó a producción: el build de API se detuvo en staging por TS4053 porque el método público `retry` exponía por inferencia el tipo interno `DeliveryRow`. + +D5.3.21.1 exporta el contrato `DeliveryRow` y tipa de forma explícita el retorno público del endpoint de reintento. No cambia la lógica de negocio, el esquema previsto ni la migración funcional de entrega documental. + +La fase mantiene: +- Acta PDF para empresa y oficina. +- Informe Word para Director de Hidrocarburos. +- Entrega no bloqueante, auditable y reintentable. +- Destinatarios configurables sin direcciones hardcodeadas. +- Credenciales SMTP únicamente por entorno. +- Una visita = una acta. +- Actas cerradas y hallazgos originales inmutables. + +El deploy exige como origen API/Web `0.19.3-20` porque D5.3.21 falló antes de instalar source o ejecutar migraciones. diff --git a/docs/PHASE_D5_3_22.md b/docs/PHASE_D5_3_22.md new file mode 100644 index 0000000..006c085 --- /dev/null +++ b/docs/PHASE_D5_3_22.md @@ -0,0 +1,38 @@ +# Fase D5.3.22 · Revisión directiva de Informes + +D5.3.22 incorpora el circuito posterior a la generación automática del Informe Word. + +## Flujo + +1. El cierre del Acta conserva la generación automática del Informe y del Word inicial. +2. El Word automático pasa a ser la Versión 1 del Informe. +3. El Director puede descargarlo, revisarlo en Word y cargar una nueva versión corregida con un resumen obligatorio de cambios. +4. Cada corrección crea una revisión nueva e inmutable. Nunca reemplaza ni borra una versión anterior. +5. El Director aprueba explícitamente la versión vigente. +6. Sólo después de esa aprobación puede registrar la firma electrónica final. +7. La firma final vincula el Informe congelado, la revisión aprobada, ambos hashes, el usuario Director y la fecha de firma. +8. Después de la firma no se admiten nuevas revisiones. + +## Permisos + +- `inspection_reports.revise`: cargar versiones corregidas. +- `inspection_reports.review`: aprobar la versión vigente. +- `inspection_reports.sign_final`: firma electrónica final. + +Los tres permisos se asignan inicialmente únicamente al rol `director`. El backend además exige que el usuario tenga efectivamente el rol Director para revisar, aprobar o firmar. + +## Integridad documental + +La revisión no modifica el Acta, los Hallazgos ni el `frozen_snapshot` del Informe. La Versión 1 automática se conserva. Las versiones corregidas se almacenan como documentos DOCX separados, con número, autor, fecha, resumen de cambios, tamaño y SHA-256. + +Los DOCX cargados tienen un límite de 15 MB, deben contener la estructura mínima OOXML y no pueden contener macros VBA ni objetos embebidos. + +## Firma + +La fase implementa una firma electrónica de aprobación trazable y exclusiva del Director. Se almacena un payload canónico con el hash del Informe y el hash de la revisión aprobada, y se calcula un SHA-256 de firma. + +La ubicación visual de una rúbrica o sello dentro del documento final no se fija todavía porque la plantilla institucional definitiva continúa pendiente de definición. + +## Correo + +D5.3.22 no exige SMTP y no modifica el alcance de D5.3.21.1. El envío inicial del Word al Director permanece preparado pero puede seguir en estado pendiente mientras no exista una cuenta institucional definida. diff --git a/docs/PHASE_D5_3_23.md b/docs/PHASE_D5_3_23.md new file mode 100644 index 0000000..96a4724 --- /dev/null +++ b/docs/PHASE_D5_3_23.md @@ -0,0 +1,40 @@ +# D5.3.23.1 · Altas de campo y revisión de Inventarios + +## Objetivo + +Permitir que un inspector registre durante una inspección un elemento que no existe todavía en el Inventario, siga trabajando con ese registro provisional y deje la decisión definitiva para oficina. + +## Flujo + +1. Sólo un inspector autenticado desde la aplicación móvil puede generar un alta de campo. +2. La visita debe estar `IN_PROGRESS` y el inspector debe integrar el equipo asignado. +3. El elemento se crea como `DRAFT`, con origen `FIELD_SURVEY`, referencia a la visita y trazabilidad completa. +4. El elemento se incorpora inmediatamente a la visita y, si el Acta ya existe y sigue abierta, también al Acta para poder registrar Hallazgos sobre él. +5. En oficina, la bandeja **Inventarios → Altas de campo** permite: + - abrir y corregir la ficha; + - aprobarla, pasando a `VALIDATED`; + - conciliarla con un registro existente de la misma Empresa y Área; + - rechazarla. +6. Conciliar o rechazar nunca elimina el registro provisional: se archiva como `INACTIVE` para conservar las referencias históricas. +7. Actas y Hallazgos ya emitidos no cambian de `asset_id`; el vínculo con un registro existente se registra aparte mediante `matched_asset_id`. + +## Validación mínima al aprobar + +La ficha debe mantener como mínimo Empresa, Área, Tipo de elemento y Nombre/código. El esquema ya exige Nombre y Código; la aprobación agrega el control explícito de Empresa y Área. + +## Seguridad + +- Creación en campo: permiso `assets.create` + política de inspector móvil. +- Bandeja: `assets.read`. +- Aprobar, conciliar y rechazar: `assets.change_status`. +- No se agregan permisos nuevos ni se amplían roles en esta fase. + +## Persistencia + +Nueva tabla `asset_field_discoveries`, append-preserving, con estado `PENDING | APPROVED | MATCHED | REJECTED`, visita, inspector, decisión de oficina y eventual coincidencia. + +SMTP no forma parte de esta fase y puede continuar sin configurar. + + +## Parche 23.1 +El enlace del objeto provisional con `inspection_visit_assets` y `inspection_act_assets` se aisló en `FieldDiscoveryInspectionLinkService`, manteniendo `AssetsService` libre de escrituras directas sobre tablas de inspección y conservando el test arquitectónico de D5.3.14. diff --git a/docs/PHASE_D5_3_24.md b/docs/PHASE_D5_3_24.md new file mode 100644 index 0000000..a93cb52 --- /dev/null +++ b/docs/PHASE_D5_3_24.md @@ -0,0 +1,75 @@ +# Fase D5.3.24 · Historial de contexto operativo + +## Objetivo + +Preservar de forma explícita la evolución de la ubicación jerárquica y del contexto operativo de cada elemento del Inventario: padre físico, Área y Operadora. + +## Regla funcional + +Un cambio actual de jerarquía, Área u Operadora nunca elimina la relación anterior. La relación vigente se cierra con una fecha de fin y se agrega una nueva relación con fecha de inicio, motivo, usuario, request y versión del Inventario. + +Los cambios ordinarios de nombre, código, atributos o descripción siguen usando la edición normal. Los registros consolidados no pueden cambiar `parentId`, `operationalAreaId` ni `operatorCompanyId` por el endpoint general: deben usar el cambio de contexto auditado. La única excepción es una alta de campo todavía DRAFT y pendiente de revisión, porque en ese momento oficina está corrigiendo el registro provisional antes de validarlo. + +## Persistencia + +Nueva tabla `asset_context_history` con un único registro activo por elemento. Guarda: + +- `parent_id` +- `operational_area_id` +- `operator_company_id` +- `valid_from` +- `valid_until` +- `change_reason` +- `end_reason` +- `asset_version_number` +- `source` +- `request_id` +- usuario creador/finalizador + +No existe eliminación de historial para el rol de aplicación. + +## Reconstrucción de antecedentes + +La migración analiza `asset_versions` y detecta sólo los puntos donde cambió alguno de estos tres valores. Esos puntos se convierten en intervalos históricos. Así se aprovecha el versionado ya existente y no se presupone que la relación actual existió desde el alta del elemento. + +Para activos sin versiones recuperables se crea una línea base a partir del contexto vigente. + +## Cambio de contexto + +Nuevo endpoint: + +`POST /api/v3/assets/:id/context` + +Requiere `assets.manage_context` y un motivo obligatorio. Puede indicar una fecha efectiva pasada siempre que sea posterior al inicio del contexto vigente y no esté en el futuro. + +La operación es transaccional: + +1. valida padre y reglas de jerarquía; +2. valida Área–Operadora; +3. cierra el contexto anterior; +4. actualiza el contexto actual de `assets`; +5. captura una nueva `asset_version` tipo `CONTEXT_CHANGED`; +6. inserta el nuevo intervalo; +7. registra auditoría `ASSET_CONTEXT_CHANGED`. + +## Consulta histórica + +Nuevo endpoint: + +`GET /api/v3/assets/:id/context-history` + +El panel de Inventarios muestra contexto vigente e intervalos anteriores con motivo y vigencia. + +`AssetTemporalService` también consulta `asset_context_history`, por lo que al pedir un activo a una fecha determinada reemplaza padre, Área y Operadora por los que estaban vigentes en ese instante. + +## Permisos iniciales + +`assets.manage_context` queda asignado por defecto a `admin` y `supervisor`. El Inspector conserva las altas de campo y las correcciones provisionales, pero no puede transferir un Inventario consolidado. + +## Reglas preservadas + +- Una visita = una Acta. +- Actas cerradas no se modifican. +- Hallazgos originales no se reescriben. +- Una conciliación de alta de campo no sustituye referencias históricas. +- SMTP puede continuar sin configurar. diff --git a/docs/PHASE_D5_3_25.md b/docs/PHASE_D5_3_25.md new file mode 100644 index 0000000..e3d4f3b --- /dev/null +++ b/docs/PHASE_D5_3_25.md @@ -0,0 +1,89 @@ +# Fase D5.3.25 · Historial de verificaciones de hallazgos + +## Objetivo + +Convertir el seguimiento de verificaciones en una historia explícita de eventos. `nextControlOn` continúa siendo la proyección vigente usada por colas y planificación, pero deja de ser la única evidencia de lo ocurrido. + +## Regla funcional + +Cada hallazgo conserva todas las decisiones de control y todas las verificaciones sucesivas. Una nueva fecha nunca elimina la anterior y un resultado de campo ya registrado no puede sustituirse. + +La secuencia puede contener tantas iteraciones como sean necesarias: + +1. fecha de verificación definida por oficina; +2. visita de verificación planificada; +3. resultado de campo; +4. nueva fecha si el hallazgo continúa abierto; +5. nueva visita; +6. nuevo resultado; +7. cierre administrativo cuando corresponda. + +## Ledger append-only + +Nueva tabla `inspection_finding_verification_events`. + +Registra eventos: + +- `CONTROL_DATE_DEFINED` +- `CONTROL_DATE_CHANGED` +- `CONTROL_DATE_CLEARED` +- `VISIT_PLANNED` +- `RESULT_RECORDED` + +Cada evento conserva fecha de ocurrencia, fecha anterior, fecha nueva, visita vinculada, resultado, observaciones, usuario y evidencia relacionada. + +El rol de aplicación sólo tiene `SELECT` e `INSERT`. No puede actualizar ni borrar eventos. + +## Antecedentes + +La migración reconstruye eventos existentes desde: + +- `inspection_finding_versions`, para cambios de `nextControlOn` realizados por oficina; +- `inspection_finding_verification_visits`, para visitas y resultados ya registrados. + +No se inventan eventos que no puedan deducirse de los antecedentes persistidos. + +## Resultado de campo inmutable + +Una fila `inspection_finding_verification_visits` representa un intento concreto dentro de una visita concreta. D5.3.25 impide registrar dos veces el resultado del mismo intento. + +La protección existe en dos niveles: + +- servicio: devuelve `VERIFICATION_RESULT_IMMUTABLE` si ya existe resultado; +- base de datos: un trigger impide modificar los campos del resultado una vez que `result_recorded_at` quedó definido. + +Una verificación posterior se realiza en una nueva visita y genera nuevos eventos, sin tocar la anterior. + +## Proyección vigente + +`inspection_findings.next_control_on` se conserva para búsquedas, dashboard y planificación. Cada cambio de ese campo se acompaña de un evento histórico dentro de la misma transacción. + +Cuando un resultado: + +- es `RESOLVED`, la proyección se limpia y el historial conserva el control realizado; +- es `NOT_RESOLVED`, la proyección se limpia y oficina debe definir el siguiente control; +- es `REQUIRES_NEW_DATE`, la nueva fecha se convierte en la proyección vigente y también queda registrada en el ledger. + +## Consulta y web + +`GET /api/v3/inspection-findings/:id/verification-history` + +La ficha de Hallazgos incorpora “Historial completo de verificaciones” con: + +- cambios de fecha; +- visitas planificadas; +- resultados sucesivos; +- observaciones; +- usuario; +- enlace a cada visita; +- cantidad de evidencias vinculadas. + +## Reglas preservadas + +- Una visita = una Acta. +- Actas cerradas no se modifican. +- El hallazgo original no se reescribe. +- La primera respuesta de empresa continúa inmutable. +- Las presentaciones posteriores siguen siendo append-only. +- El cierre del hallazgo continúa siendo una decisión administrativa. +- SMTP puede continuar pendiente sin bloquear esta fase. diff --git a/docs/PHASE_D5_3_2_1.md b/docs/PHASE_D5_3_2_1.md new file mode 100644 index 0000000..2fe3e2d --- /dev/null +++ b/docs/PHASE_D5_3_2_1.md @@ -0,0 +1,25 @@ +# Fase D5.3.2.1 · Hotfix de versionado de activos + +Versión: `0.19.3-2.1` + +## Motivo + +Durante la creación del caso de desarrollo Mendoza, el primer alta de activo falló al insertar su revisión en `asset_versions` porque `version_number` llegó como `NULL` desde la ruta de captura de historial. + +## Corrección + +`AssetHistoryService.capture()` ya no depende del valor retornado por un `UPDATE ... RETURNING` para alimentar `version_number`. Ahora: + +1. incrementa `assets.current_version` de forma atómica; +2. lee explícitamente `current_version` dentro de la misma transacción; +3. valida que sea un entero mayor o igual a 1; +4. recién entonces crea la fila de `asset_versions`. + +Se agregó un test de regresión específico para esta secuencia. + +## Alcance + +- No agrega migraciones. +- No cambia el modelo funcional D5.3.2. +- Conserva catálogo técnico, demo y limpiador. +- Corrige una ruta transversal usada por altas y modificaciones de activos, no sólo el seed de desarrollo. diff --git a/docs/PHASE_D5_3_2_DEPLOY.md b/docs/PHASE_D5_3_2_DEPLOY.md new file mode 100644 index 0000000..1614665 --- /dev/null +++ b/docs/PHASE_D5_3_2_DEPLOY.md @@ -0,0 +1,12 @@ +# Deploy D5.3.2 + +1. Subir `DH_V2_FASE_D5_3_2_SOURCE.zip` a `/var/www/dhv2.korexlabs.com/`. +2. Extraer `scripts/deploy-phase-d5-3-2.sh` a `/root/deploy-dhv2-d532.sh`. +3. Ejecutar preferentemente dentro de `tmux`. +4. El deploy compila API, ejecuta tests, compila web y crea backup PRE antes de reemplazar producción. +5. No hay migración nueva; `migration:show` debe terminar en `Pending migrations: no`. +6. La aceptación debe finalizar con `ACEPTACIÓN DE FASE D5.3.2: OK`. +7. Después del deploy, completar el catálogo técnico desde el dashboard. +8. Crear el demo sólo si se desea validar visualmente el Maestro y las relaciones. + +La limpieza real del demo no debe ejecutarse por rutina. El modo por defecto de `scripts/dev-clean-demo.sh` es simulación. diff --git a/docs/PHASE_D5_3_3.md b/docs/PHASE_D5_3_3.md new file mode 100644 index 0000000..e1be486 --- /dev/null +++ b/docs/PHASE_D5_3_3.md @@ -0,0 +1,82 @@ +# D5.3.3 · Modelo operativo definitivo + +Versión: `0.19.3-3` + +## Objetivo + +Cerrar el modelo de Maestro antes de implementar hallazgos por activo y antes del rediseño UX. +La fase mantiene la jerarquía padre/hijo como contención física y separa explícitamente: + +- Área territorial; +- Organización (empresa, UTE u otra figura); +- rol operativo de una organización sobre un Área; +- permiso/concesión/derecho legal sobre un Área; +- estado de calidad del dato; +- estado operativo del activo; +- identificadores oficiales/externos; +- documentos fuente; +- composición histórica de una UTE. + +## Compatibilidad + +No se renombran columnas históricas como `operator_company_id` ni se reemplaza la tabla +`area_company_relations`. Se amplían para conservar compatibilidad con D5.3–D5.3.2.1. +La API incorpora alias semánticos de Organización sin romper las rutas existentes de Empresa. + +## Reglas centrales + +1. `parent_id` representa sólo jerarquía física. +2. El tipo `Área / Concesión` pasa a mostrarse como `Área`. +3. El tipo `Empresa / Operadora` pasa a mostrarse como `Organización`. +4. Una Organización puede perfilarse como Empresa, UTE, entidad pública u otra figura. +5. Una UTE conserva participantes, porcentaje, vigencia y documento fuente de forma histórica. +6. Una relación Área–Organización tiene rol (`OPERATOR`, `CONCESSIONAIRE`, etc.). +7. Sólo una relación activa con rol `OPERATOR` habilita la asignación exclusiva de activos. +8. Las concesiones/permisos se registran como derechos legales separados del Área. +9. `information_status` sigue indicando calidad/validación del registro. +10. `operational_status` indica situación física/operativa y no reemplaza la calidad del dato. +11. Un activo puede tener múltiples identificadores externos y múltiples documentos fuente. +12. Cuenca y Departamento son clasificaciones del Área, no niveles obligatorios del árbol. + +## Estado operativo + +- `UNKNOWN` +- `IN_SERVICE` +- `TEMPORARILY_OUT_OF_SERVICE` +- `OUT_OF_SERVICE` +- `DECOMMISSIONED` +- `ABANDONED` + +## Registro documental + +Se agregan: + +- `source_documents` +- `asset_source_documents` +- `asset_external_identifiers` +- `organization_profiles` +- `organization_memberships` +- `area_legal_rights` +- `area_legal_right_organizations` + +Todas las relaciones sensibles conservan vigencia histórica y no dependen de borrado físico en +operación normal. + +## UI de esta fase + +D5.3.3 sólo expone el estado operativo y actualiza la semántica Área/Organización en las pantallas +existentes. La administración intuitiva de documentos, UTE, derechos e identificadores se realizará +en D5.3.4 dentro del nuevo Asset Center, para no agregar una pantalla técnica temporal que luego +haya que descartar. + +## Datos de desarrollo + +El caso `DH-DEV-DEMO` se actualiza para: + +- registrar el Informe Técnico 124/2026 como documento fuente de prueba; +- vincular ese documento con Área, Organización, Planta y Tanques; +- registrar la Organización de prueba con perfil `COMPANY`; +- utilizar una relación Área–Organización con rol `OPERATOR`; +- marcar TK-57 y TK-58 como `OUT_OF_SERVICE`, según el caso de prueba. + +El limpiador protegido incorpora las nuevas tablas y sigue siendo dry-run por defecto. diff --git a/docs/PHASE_D5_3_3_DEPLOY.md b/docs/PHASE_D5_3_3_DEPLOY.md new file mode 100644 index 0000000..524a9ed --- /dev/null +++ b/docs/PHASE_D5_3_3_DEPLOY.md @@ -0,0 +1,19 @@ +# Deploy D5.3.3 + +La entrega incluye `scripts/deploy-phase-d5-3-3.sh`. + +El deploy: + +1. descomprime la entrega en staging; +2. compila API y ejecuta tests antes de tocar producción; +3. compila el frontend en staging; +4. genera backup PRE de base y source; +5. instala el source; +6. construye imágenes finales; +7. muestra y aplica la migración D5.3.3 sólo si está pendiente; +8. verifica que no queden migraciones pendientes; +9. recrea API/web y espera health; +10. ejecuta aceptación automática y genera backup POST. + +Ejecutar el script dentro de `tmux`. El `set -Eeuo pipefail` vive dentro del script y no se pega +en la shell SSH interactiva. diff --git a/docs/PHASE_D5_3_4.md b/docs/PHASE_D5_3_4.md new file mode 100644 index 0000000..e6b8316 --- /dev/null +++ b/docs/PHASE_D5_3_4.md @@ -0,0 +1,66 @@ +# D5.3.4 · Asset Center y administración intuitiva + +Versión: `0.19.3-4` + +## Objetivo + +Reducir la complejidad visible del Maestro y de la administración sin simplificar el modelo de datos definido en D5.3.3. +La regla de diseño de esta fase es: **modelo completo, interfaz simple**. + +## Asset Center + +`Activos` pasa a ser el punto de entrada único al Maestro y ofrece: + +- búsqueda por nombre, código DH e identificadores externos vigentes; +- vistas Lista, Jerarquía, Mapa, Historial y Consulta temporal; +- filtros Área → Operadora, tipo, estado del dato y estado operativo; +- vistas rápidas para pendientes de validar, sin ubicación y fuera de servicio; +- tabla compacta orientada al uso cotidiano; +- jerarquía física navegable conservando `parent_id` como única relación de contención. + +La API agrega `GET /api/v3/assets/tree`, protegido con `assets.read`, que devuelve los activos coincidentes junto con sus ancestros físicos. La respuesta se limita a 5.000 nodos y avisa si fue truncada. + +## Alta y detalle de activos + +- alta contextual desde cualquier activo con `Agregar activo aquí`; +- herencia del padre y, cuando corresponde, del contexto Área–Operadora; +- búsqueda de padre compatible en lugar de depender de un listado fijo; +- detalle organizado por pestañas: Resumen, Ubicación, Documentos y registro, Archivos e Historial; +- estado del dato y estado operativo mostrados como conceptos separados. + +## Registro documental e institucional + +El modelo D5.3.3 se expone en una interfaz administrable desde el detalle del activo: + +- identificadores externos; +- documentos fuente; +- perfil de Organización; +- composición histórica de UTE; +- permisos y concesiones del Área; +- participantes de los derechos legales. + +## Configuración del Maestro + +La pantalla de tipos prioriza nombre, descripción y comportamiento comprensible. +Código interno, rol operativo, posibilidad de raíz y matriz de padres permitidos quedan dentro de `Configuración avanzada`. + +## Roles y permisos + +Los permisos se presentan primero con nombres funcionales en castellano y el código técnico queda como referencia secundaria. + +## Dashboard y navegación + +El Inicio prioriza operación: + +- inspecciones planificadas; +- controles vencidos; +- hallazgos abiertos; +- activos pendientes de validar; +- activos sin ubicación; +- respuestas pendientes y próximos controles. + +La navegación lateral se agrupa en Operación, Seguimiento, Maestro, Administración y Sistema. Mapa/Historial/Consulta temporal dejan de competir como módulos principales y pasan a ser vistas del Asset Center. + +## Compatibilidad + +D5.3.4 no agrega una migración propia. Si D5.3.3 aún estuviera pendiente, el deploy aplica las migraciones incluidas en la entrega antes de levantar la nueva versión. diff --git a/docs/PHASE_D5_3_4_DEPLOY.md b/docs/PHASE_D5_3_4_DEPLOY.md new file mode 100644 index 0000000..47cba1f --- /dev/null +++ b/docs/PHASE_D5_3_4_DEPLOY.md @@ -0,0 +1,19 @@ +# Deploy D5.3.4 + +La entrega incluye `scripts/deploy-phase-d5-3-4.sh`. + +El deploy trabaja en staging antes de modificar producción: + +1. descomprime la entrega; +2. compila API; +3. ejecuta todos los tests de API; +4. compila el frontend; +5. genera backup PRE de base y source; +6. instala el source validado; +7. construye imágenes finales; +8. aplica cualquier migración pendiente incluida en la línea D5.3; +9. recrea API/web y espera health; +10. ejecuta aceptación D5.3.4; +11. genera backup POST. + +Ejecutar dentro de `tmux`. No pegar `set -Eeuo pipefail` en la shell SSH interactiva; el modo estricto vive dentro del script. diff --git a/docs/PHASE_D5_3_5.md b/docs/PHASE_D5_3_5.md new file mode 100644 index 0000000..4e7dbd1 --- /dev/null +++ b/docs/PHASE_D5_3_5.md @@ -0,0 +1,110 @@ +# DH V2 · Fase D5.3.5 — Centro de Importaciones y escalabilidad + +Versión: `0.19.3-5` + +## Objetivo + +D5.3.5 parte de D5.3.4 ya desplegada y adapta el Maestro a volúmenes y formatos reales recibidos por la Dirección de Hidrocarburos. + +La fase tiene dos objetivos: + +1. evitar que el Asset Center intente cargar árboles completos de miles de activos; +2. incorporar un Centro de Importaciones que permita analizar XLSX/CSV de forma trazable antes de decidir qué registros se incorporan al Maestro. + +## Regla de seguridad de esta fase + +**D5.3.5 NO crea ni modifica activos desde una importación.** + +Un archivo subido: + +1. se conserva con SHA-256; +2. se registra como documento fuente; +3. se inspecciona y detecta el formato; +4. se normalizan las filas en una capa de staging; +5. se detectan advertencias y conflictos; +6. se muestra una vista previa en el dashboard. + +La aplicación efectiva al Maestro queda deliberadamente fuera de esta fase. + +## Perfiles iniciales + +### `MENDOZA_INVENTORY_V1` + +Reconoce el esquema general utilizado en inventarios de instalaciones con campos equivalentes a: + +- Item; +- Área / Yacimiento; +- Instalación; +- Sub instalación; +- Equipo; +- Denominación; +- Ubicación; +- N° ID / Inventario; +- Cantidad; +- Especificaciones técnicas; +- Estado. + +El análisis conserva el valor original y propone, cuando es seguro, normalizaciones auxiliares para: + +- familia técnica; +- clase/tipo de fuente; +- fabricante/modelo; +- estado operativo sugerido; +- condición sugerida. + +Los valores ambiguos (`SI/NO`, `S/N`, `Bueno`, etc.) no se transforman automáticamente en un estado operativo definitivo. + +### `MENDOZA_YACIMIENTOS_V1` + +Reconoce tablas de referencia con Yacimiento, Área, Departamento, tipo de derecho y Operadora. + +## Conflictos detectables + +Entre otros: + +- campos estructurales faltantes; +- cantidad inválida; +- cantidad agrupada mayor a uno; +- estado fuente que requiere mapeo; +- ID de inventario repetido; +- mismo ID de inventario en ubicaciones diferentes; +- formato no reconocido. + +La repetición de un ID no se interpreta automáticamente como duplicado real, traslado o error: se deriva a revisión. + +## Persistencia + +Se incorporan: + +- `asset_import_batches`; +- `asset_import_rows`; +- permisos `asset_imports.read` y `asset_imports.manage`; +- índices trigram para búsqueda del Maestro. + +Los lotes no tienen DELETE para el rol de ejecución de la aplicación. Cancelar un lote cambia su estado y conserva evidencia y archivo para auditoría. + +## Asset Center escalable + +D5.3.4 incluía un árbol completo con límite de seguridad. D5.3.5 mantiene ese endpoint por compatibilidad, pero el dashboard utiliza ahora `GET /assets/tree-children`. + +La jerarquía: + +- carga sólo el primer nivel; +- consulta hijos al expandir; +- limita cada nivel a 200 filas; +- usa filtros y búsqueda del servidor; +- no intenta renderizar miles de nodos simultáneamente. + +La búsqueda del Maestro se amplía a datos relevantes como identificadores externos y ciertos atributos configurables (fabricante, modelo, número de serie y ubicación fuente). + +## Fuera de alcance + +D5.3.5 no: + +- crea activos desde filas de importación; +- decide automáticamente jerarquías ambiguas por empresa; +- crea 70+ tipos de activo a partir de denominaciones crudas; +- modifica hallazgos/checklists; +- reemplaza el valor original de una fuente. + +Estos puntos se resolverán progresivamente con perfiles específicos y taxonomía normalizada antes de D5.4. diff --git a/docs/PHASE_D5_3_5_1.md b/docs/PHASE_D5_3_5_1.md new file mode 100644 index 0000000..1d73ba2 --- /dev/null +++ b/docs/PHASE_D5_3_5_1.md @@ -0,0 +1,13 @@ +# DH V2 · Fase D5.3.5.1 — Hotfix compilación Centro de Importaciones + +Versión: `0.19.3-5.1` + +## Motivo + +El build de D5.3.5 fallaba con `TS4053` en `AssetImportsController.list`, `upload` y `cancel` porque el tipo inferido de retorno incluía `ImportBatchRow`, declarado como interfaz privada dentro de `asset-imports.service.ts`. + +## Corrección + +`ImportBatchRow` pasa a ser un contrato exportado. Esto permite a TypeScript nombrarlo al emitir archivos `.d.ts` con `declaration: true`. + +No se altera el esquema ni el comportamiento funcional del Centro de Importaciones. La migración de D5.3.5 continúa siendo la misma y se aplicará al desplegar este paquete si sigue pendiente. diff --git a/docs/PHASE_D5_3_5_DEPLOY.md b/docs/PHASE_D5_3_5_DEPLOY.md new file mode 100644 index 0000000..82e5e33 --- /dev/null +++ b/docs/PHASE_D5_3_5_DEPLOY.md @@ -0,0 +1,38 @@ +# Deploy D5.3.5 · v0.19.3-5 + +Prerequisito: D5.3.4 instalada o source compatible con su esquema. + +## Entrega + +`DH_V2_FASE_D5_3_5_SOURCE.zip` + +## Despliegue recomendado + +El ZIP incluye `scripts/deploy-phase-d5-3-5.sh`. + +Extraer sólo ese script y ejecutarlo dentro de `tmux`. El script: + +1. descomprime en staging; +2. compila API; +3. ejecuta tests; +4. compila web; +5. crea backup PRE; +6. instala source; +7. construye imágenes finales; +8. aplica migraciones pendientes; +9. recrea API/web; +10. ejecuta aceptación D5.3.5; +11. crea backup POST. + +## Aceptación esperada + +```text +ACEPTACIÓN DE FASE D5.3.5: OK +``` + +Y health con: + +```text +version: 0.19.3-5 +phase: D5.3.5 +``` diff --git a/docs/PHASE_D5_3_6.md b/docs/PHASE_D5_3_6.md new file mode 100644 index 0000000..eb61d1a --- /dev/null +++ b/docs/PHASE_D5_3_6.md @@ -0,0 +1,32 @@ +# DH V2 · D5.3.6 · Normalización, matching y preview + +## Objetivo + +Convertir el Centro de Importaciones de D5.3.5 en una herramienta de conciliación segura antes de habilitar escrituras al Maestro. + +## Reglas funcionales + +1. Ningún análisis o conciliación de D5.3.6 crea, actualiza ni elimina activos. +2. La clasificación original de cada empresa se conserva en `raw_data` y `normalized_data`. +3. La normalización técnica es una sugerencia controlada: familia + subtipo. +4. El estado informado por la fuente no se pisa: se conserva y, cuando es inequívoco, se propone estado operativo o condición. +5. El matching inicial es deliberadamente conservador: + - ID de inventario contra identificadores externos activos; + - ID de inventario contra código DH; + - Área + Yacimiento exactos para la tabla territorial; + - nombre legal/nombre del activo exacto para Organización. +6. Más de una coincidencia exacta genera revisión, nunca una fusión automática. +7. La importación real al Maestro queda para D5.3.7. + +## UX + +El Centro muestra cuatro pasos: Análisis → Normalización → Conciliación → Importación. + +- `MENDOZA_YACIMIENTOS_V1`: Área, Yacimiento, Departamento, Derecho, Operadora. +- `MENDOZA_INVENTORY_V1`: estructura fuente, equipo, familia/subtipo normalizados, ID, cantidad y estados. + +`Listas` pasa a llamarse `Analizables` para no implicar que la fila ya está autorizada para importar. + +## Sin migración + +La fase reutiliza `asset_import_batches.analysis`, `asset_import_rows.matched_asset_id`, `suggested_action`, `status` y `normalized_data` creados en D5.3.5. diff --git a/docs/PHASE_D5_3_6_3.md b/docs/PHASE_D5_3_6_3.md new file mode 100644 index 0000000..9e72207 --- /dev/null +++ b/docs/PHASE_D5_3_6_3.md @@ -0,0 +1,19 @@ +# DH V2 · D5.3.6.3 · Hotfix de renderizado de conciliación + +Hotfix acumulativo sobre D5.3.6. + +## Corrección + +El resumen tipado `ImportReconciliationSummary` no era asignable directamente a `Record` exigido por `AuditService`. La conciliación ahora entrega una copia como objeto plano (`afterData: { ...summary }`), conservando exactamente los mismos campos y sin modificar la lógica de matching. + +## Alcance + +- Sin migraciones nuevas. +- Sin cambios en el modelo de datos. +- Sin escrituras al Maestro durante análisis/conciliación. +- Agrega una prueba de regresión para este contrato. + + +## Hotfix D5.3.6.3 + +Corrige TS2322 en `AssetImportsPage.tsx`: el valor `analysis.reconciliation` tiene tipo `unknown` y no debe renderizarse directamente mediante `&&`. Se convierte explícitamente a booleano para que el resultado JSX sea `ReactNode` válido. No cambia la lógica de conciliación ni escribe en el Maestro. diff --git a/docs/PHASE_D5_3_7.md b/docs/PHASE_D5_3_7.md new file mode 100644 index 0000000..aaff4c6 --- /dev/null +++ b/docs/PHASE_D5_3_7.md @@ -0,0 +1,125 @@ +# Fase D5.3.7 — Plan e importación transaccional + +Versión: `0.19.3-7.1` + +## Objetivo + +D5.3.7 agrega la capa de seguridad que faltaba entre la conciliación fila-a-fila de D5.3.6 y la escritura del Maestro. Un archivo analizado ya no se interpreta como una colección directa de `INSERT`: primero se transforma en un **plan de entidades únicas y relaciones**, se resuelven revisiones obligatorias y recién entonces un usuario con permiso explícito puede aplicar el lote en una sola transacción. + +El flujo queda: + +1. Análisis. +2. Normalización. +3. Conciliación preliminar contra Maestro. +4. Plan de importación por entidad. +5. Confirmación explícita. +6. Aplicación transaccional. + +## Principios de seguridad + +- `WARNING` y `CONFLICT` nunca se convierten silenciosamente en altas automáticas. +- Un plan con cualquier ítem `REVIEW` queda bloqueado. +- La confirmación utiliza el `plan_hash`; si el plan cambió, la aplicación se rechaza como obsoleta. +- La aplicación completa corre dentro de una única transacción PostgreSQL. +- Los identificadores externos se vuelven a validar justo antes de insertar para reducir carreras entre planificación y aplicación. +- Una coincidencia exacta no se acepta si el activo existente pertenece a otra Área/Operadora; D5.3.7 no reubica ni reasigna activos existentes silenciosamente. +- Las reglas de jerarquía y el contexto Área + Operadora se vuelven a verificar antes de escribir activos. +- La API separa `asset_imports.manage` de `asset_imports.apply`. Aplicar/revertir queda reservado inicialmente a Admin y Director. +- La reversión es lógica: no borra activos del Maestro. + +## Plan territorial Área / Yacimiento + +El perfil `MENDOZA_YACIMIENTOS_V1` deduplica las filas en entidades y relaciones reales: + +- Organizaciones. +- Áreas. +- Yacimientos. +- Relaciones Área ↔ Operadora. + +Por eso 230 filas no significan 230 altas. El mismo Área u Operadora puede participar en muchas filas y se representa una sola vez dentro del plan. + +La coincidencia sigue siendo conservadora y normalizada para mayúsculas, signos y acentos. El nombre de Yacimiento no se trata como clave global: se evalúa dentro del Área. + +`Tipo Concesión` y Departamento se preservan en el payload del plan y en la trazabilidad, pero D5.3.7 **no crea derechos legales automáticamente** porque el padrón no aporta por sí solo instrumento, vigencia y evidencia suficientes para construir esa capa con seguridad. + +Cuando una organización comienza por `UTE`, el plan propone perfil de organización `UTE`; la composición interna y porcentajes de participación no se infieren de un nombre concatenado. + + +El valor fuente `Sin Empresa Operadora` se interpreta como **ausencia explícita de operadora**, no como una empresa. Esos Yacimientos pueden quedar registrados en `DRAFT` dentro de su Área, sin contexto operativo Área + Operadora, preservando el literal fuente para una asignación posterior. Nunca se crea una organización ficticia con ese nombre. + +Como referencia de validación del archivo real recibido (sin considerar coincidencias ya existentes en el Maestro), las 230 filas se descomponen en 64 Áreas, 230 pares Área/Yacimiento, 12 Organizaciones reales y 47 relaciones Área↔Operadora: 353 ítems de entidad/relación. Además, 26 Yacimientos traen explícitamente `Sin Empresa Operadora`. Los conteos que muestre producción pueden ser menores en `CREATE` si existen coincidencias válidas. + +## Plan de inventario técnico + +El perfil `MENDOZA_INVENTORY_V1` exige antes de planificar: + +- seleccionar la Organización operadora existente; +- confirmar/definir un namespace de identificadores externos, por ejemplo `PSENERGY`. + +El plan usa ese namespace para distinguir inventarios que pueden reutilizar el mismo número de identificación en fuentes distintas. + +Los contenedores físicos se crean sólo cuando la fuente permite identificar una instancia concreta. Por ejemplo, de una ubicación como: + +`MENDOZA / VIZCACHERAS / PTC / PTCVIZ01` + +se puede proponer `PTCVIZ01`. En cambio categorías genéricas como `SET`, `EM`, `TRANSPORTE`, `OFICINA` o `ALMACEN` no se convierten silenciosamente en un activo por cada fila. Se agrupan en un único ítem estructural `[A VALIDAR] ...` por contexto, que requiere decidir si corresponde crear o vincular un contenedor real. + +Las cantidades agrupadas, estados ambiguos y conflictos de fuente quedan en revisión obligatoria. + +## Acciones del plan + +Cada ítem puede quedar en: + +- `CREATE`: crear una entidad nueva. +- `MATCH`: reutilizar una entidad existente del mismo tipo. +- `REVIEW`: requiere intervención antes de aplicar. +- `IGNORE`: sólo permitido para activos técnicos; no se permite romper la estructura ignorando Áreas, Organizaciones, Yacimientos o contenedores estructurales. + +Las resoluciones manuales quedan registradas con usuario, fecha y motivo. + +## Aplicación + +El endpoint de aplicación exige: + +- plan activo; +- estado `READY`; +- mismo `plan_hash` confirmado en pantalla; +- permiso `asset_imports.apply`; +- coincidencias todavía activas; +- Operadora todavía activa; +- IDs externos todavía disponibles; +- tipos y reglas padre/hijo vigentes; +- contexto operativo completo. + +La aplicación crea en orden las entidades raíz, relaciones operativas, Yacimientos, instalaciones y activos técnicos. Las altas quedan en estado de información `DRAFT`, origen `IMPORT`, con documento fuente, procedencia e historial inicial. + +Los ítems que ya coinciden con Maestro **no son modificados** por esta fase. + +## Reversión + +`Revertir lote` funciona sólo sobre el plan aplicado activo y dentro de una transacción. + +La reversión: + +- cierra identificadores externos creados por el lote; +- inactiva lógicamente los activos creados por el lote; +- retira su contexto operativo antes de inactivarlos; +- cierra las relaciones Área ↔ Operadora creadas por el lote; +- registra nuevas versiones de estado; +- limpia la referencia `imported_asset_id` de las filas del lote; +- conserva toda la trazabilidad del plan. + +La reversión se bloquea si activos posteriores, relaciones externas, relevamientos o inspecciones ya dependen de entidades creadas por el lote. + +## Limitaciones deliberadas + +D5.3.7 no intenta todavía: + +- interpretar automáticamente integrantes y porcentajes de una UTE a partir del nombre; +- crear concesiones/permisos legales sin instrumento y vigencia verificables; +- modificar activos existentes que hagan `MATCH`; +- resolver de forma probabilística alias o nombres parecidos; +- convertir todos los términos del inventario fuente en niveles de jerarquía; +- persistir una taxonomía completa de condición técnica cuando la fuente sólo trae textos ambiguos. + +Estas restricciones son intencionales para evitar contaminación masiva del Maestro. diff --git a/docs/PHASE_D5_3_7_2.md b/docs/PHASE_D5_3_7_2.md new file mode 100644 index 0000000..0ccfa01 --- /dev/null +++ b/docs/PHASE_D5_3_7_2.md @@ -0,0 +1,60 @@ +# Fase D5.3.7.2 — Plan relacional normalizado + +Versión: `0.19.3-7.2` + +## Objetivo + +Evitar que una importación replique como texto datos maestros que se repiten en cientos de filas. El perfil territorial de Mendoza pasa de una lectura por fila a un plan compuesto por entidades únicas y relaciones explícitas. + +## Modelo aplicado a `Tablas de yacimiento.xlsx` + +Cada fila fuente conserva su trazabilidad, pero el plan deduplica y separa: + +- **Departamentos**: catálogo normalizado `administrative_departments`; un Departamento se crea una sola vez. +- **Organizaciones / Operadoras**: activos de rol COMPANY, con clave canónica que normaliza siglas con puntos (`E.M.E.S.A.` → `emesa`, `S.R.L.` → `srl`) sin fusionar nombres jurídicos semánticamente distintos. +- **Áreas**: una entidad por Área. +- **Yacimientos**: una entidad contextual por `Área + Yacimiento`; el nombre de Yacimiento no es clave global. +- **Área ↔ Departamento**: relación histórica explícita en `area_department_relations`. +- **Área ↔ Operadora**: relación histórica existente `area_company_relations`, rol `OPERATOR`. +- **Tipo de derecho**: se normaliza al enum jurídico ya existente (`EXPLOITATION_CONCESSION`, `EXPLORATION_PERMIT`, `TRANSPORT_CONCESSION`, `OTHER`), sin duplicar un catálogo paralelo. +- **Derecho / concesión**: entidad jurídica `area_legal_rights`, inicialmente `PENDING` cuando la fuente sólo informa tipo y Área pero no instrumento o vigencia. +- **Derecho ↔ Organización**: relación `area_legal_right_organizations` con rol `OPERATOR`. No se infiere titularidad, participación ni tenencia. + +`Sin Empresa Operadora` sigue representando ausencia explícita y nunca se convierte en una Organización. + +## Referencia sobre el archivo real + +Sobre un Maestro vacío, el archivo de 230 filas produce conceptualmente: + +- 7 Departamentos únicos; +- 12 Organizaciones reales (13 valores fuente menos `Sin Empresa Operadora`); +- 64 Áreas; +- 64 relaciones Área ↔ Departamento; +- 230 pares contextuales Área + Yacimiento; +- 47 relaciones Área ↔ Operadora; +- 64 derechos normalizados por Área + tipo; +- 47 relaciones Derecho ↔ Organización. + +Total de referencia: **535 operaciones de plan**, antes de descontar coincidencias que ya existan en el Maestro. + +El archivo contiene 26 filas con `Sin Empresa Operadora`; esas filas no generan una empresa ficticia. + +## Vista del plan + +El frontend agrupa las operaciones en: + +1. Territorio. +2. Organizaciones. +3. Marco jurídico. +4. Inventario técnico. + +Cada clase de entidad muestra Crear / Coincidir / Revisar / Ignorar / Total y dispone de `Ver detalle`, paginado, para revisar las entidades únicas y sus filas fuente antes de aplicar. + +## Seguridad + +- El plan se calcula antes de escribir el Maestro. +- Las revisiones bloquean la aplicación. +- La aplicación sigue siendo transaccional y validada por hash. +- Los objetos relacionales se revalidan antes de aplicar. +- El rollback es lógico: finaliza relaciones, revoca derechos creados por el lote, inactiva Departamentos creados por el lote y no ejecuta `DELETE FROM assets`. +- El usuario debe validar visualmente el plan real antes de pulsar `APLICAR`. diff --git a/docs/PHASE_D5_3_7_2_1.md b/docs/PHASE_D5_3_7_2_1.md new file mode 100644 index 0000000..56d44b0 --- /dev/null +++ b/docs/PHASE_D5_3_7_2_1.md @@ -0,0 +1,13 @@ +# Fase D5.3.7.2.1 — Hotfix de regresión del plan relacional + +Versión: `0.19.3-7.2.1` + +Hotfix acumulativo sobre D5.3.7.2. No cambia el modelo relacional ni la migración. + +## Corrección + +El test heredado `D5.3.7 keeps the explicit no-operator sentinel...` todavía esperaba que la clave de organización usara `plainImportKey(operator)`. D5.3.7.2 reemplazó deliberadamente esa clave por `organizationImportKey(operator)` para deduplicar variantes de siglas y puntuación (`E.M.E.S.A.` / `EMESA`, `S.R.L.` / `SRL`) sin crear una organización ficticia para el sentinel `Sin Empresa Operadora`. + +La implementación era correcta; se actualiza únicamente la expectativa de regresión y el versionado del hotfix. + +La migración sigue siendo `PhaseD5372RelationalTerritoryPlan1787767200000`. diff --git a/docs/PHASE_D5_3_7_2_2.md b/docs/PHASE_D5_3_7_2_2.md new file mode 100644 index 0000000..13de3c0 --- /dev/null +++ b/docs/PHASE_D5_3_7_2_2.md @@ -0,0 +1,21 @@ +# D5.3.7.2.2 — Hotfix de aplicación del plan relacional + +## Problema corregido + +Durante `POST /api/v3/asset-imports/:id/apply`, el UPDATE por lote de `asset_import_rows.imported_asset_id` construía `VALUES` con `row_number` sin casteo explícito. PostgreSQL podía inferir esa columna como `text` y fallaba al compararla con `asset_import_rows.row_number` (`integer`): + +`operator does not exist: integer = text` + +## Corrección + +Cada número de fila del `VALUES` se tipa explícitamente como `integer`: + +```sql +($n::integer, $m::uuid) +``` + +La operación sigue dentro de la misma transacción de importación. No cambia el modelo relacional ni requiere migración nueva. + +## Regresión + +Se agregó una prueba que exige el casteo `::integer` en `updateImportedRows`. diff --git a/docs/PHASE_D5_3_7_2_3.md b/docs/PHASE_D5_3_7_2_3.md new file mode 100644 index 0000000..f6b3aa9 --- /dev/null +++ b/docs/PHASE_D5_3_7_2_3.md @@ -0,0 +1,25 @@ +# D5.3.7.2.3 — Hotfix de persistencia del plan aplicado + +## Problema corregido + +La aplicación relacional ya superaba la actualización de `asset_import_rows`, pero al marcar un activo creado como aplicado se reutilizaba el mismo parámetro SQL para dos columnas de tipos distintos: + +- `applied_asset_id`: `uuid` +- `applied_object_id`: `varchar(255)` + +PostgreSQL rechazaba la sentencia con `inconsistent types deduced for parameter $2` (`uuid versus character varying`). + +## Corrección + +El parámetro se tipa una sola vez como UUID y su representación textual se deriva explícitamente para `applied_object_id`: + +```sql +applied_asset_id = $2::uuid, +applied_object_id = ($2::uuid)::text +``` + +La operación continúa dentro de la misma transacción. El fallo previo no dejó datos parciales y esta fase no requiere migración nueva. + +## Regresión + +Se agregó una prueba que impide volver a usar el mismo parámetro sin casteos compatibles en ambas columnas. diff --git a/docs/PHASE_D5_3_7_2_4.md b/docs/PHASE_D5_3_7_2_4.md new file mode 100644 index 0000000..7b004a7 --- /dev/null +++ b/docs/PHASE_D5_3_7_2_4.md @@ -0,0 +1,24 @@ +# D5.3.7.2.4 — Protección de planes obsoletos e idempotencia + +Versión: `0.19.3-7.2.4`. + +## Objetivo + +Evitar que un plan READY generado contra un estado anterior del Maestro pueda aplicarse después de que otro lote o una edición administrativa haya modificado los datos maestros. + +## Cambios + +- El plan guarda `masterStateHash` dentro de su resumen. +- El hash representa el estado relevante de activos, organizaciones, Departamentos, relaciones operativas, derechos y IDs externos. +- La generación toma una huella antes y después de construir el plan; si el Maestro cambia durante la generación, se aborta. +- La aplicación compara la huella actual contra la guardada **antes de cualquier escritura**. +- Los planes READY de versiones anteriores, que no tienen huella, se consideran obsoletos y deben regenerarse. +- El frontend muestra `Plan obsoleto` y deshabilita `Confirmar y aplicar` hasta regenerar. + +## Caso real cubierto + +Si un lote A aplica 64 Áreas, 12 Organizaciones, 230 Yacimientos y sus relaciones mientras existe un lote B con un plan READY anterior, el lote B ya no puede volver a crear esas entidades. Debe conciliar y regenerar; el nuevo plan debe resolverlas como coincidencias. + +## Migraciones + +No agrega migraciones. La huella se almacena en el JSON `summary` ya existente del plan. diff --git a/docs/PHASE_D5_3_7_2_5.md b/docs/PHASE_D5_3_7_2_5.md new file mode 100644 index 0000000..1f0b404 --- /dev/null +++ b/docs/PHASE_D5_3_7_2_5.md @@ -0,0 +1,35 @@ +# D5.3.7.2.5 — Conciliación y revisión más claras + +Versión: `0.19.3-7.2.5`. + +## Objetivo + +Hacer que el Centro de Importaciones explique en lenguaje administrativo qué se va a crear, qué registro existente se reutiliza y por qué un elemento requiere intervención humana antes de aplicar. + +## Cambios de interfaz + +- `Coincidir` pasa a **Usar existentes** y explica que esos objetos ya están en el Maestro y no se duplican. +- Los cuatro KPI del plan son accionables: **Crear nuevos**, **Usar existentes**, **Requieren revisión** e **Ignorar**. +- Al abrir **Usar existentes** se lista la entidad, el criterio seguro utilizado por el motor de conciliación y el identificador del objeto encontrado. +- El desglose relacional usa la misma terminología clara. +- La conciliación preliminar por filas habla de **Nuevas** y **Ya existentes**. + +## Revisión obligatoria + +La sección se renombra **Decisiones pendientes antes de importar** y distingue: + +- **Decisiones directas**: requieren una decisión humana real. +- **Dependencias**: no deben forzarse; normalmente se recalculan después de resolver un padre o contexto y regenerar el plan. + +Cada tarjeta muestra: + +- tipo de entidad y filas fuente; +- nombre provisional sin ocultar que `[A VALIDAR]` no es un nombre confirmado; +- datos fuente relevantes (Área/Yacimiento, instalación, sector, equipo, familia, ID, cantidad, estado); +- explicación del motivo; +- qué debe revisar la persona; +- acciones con nombres explícitos: **Vincular con existente**, **Crear como nuevo** o **Excluir de esta importación**, únicamente cuando la lógica ya las permite. + +## Seguridad + +No cambia la lógica de aplicación, matching, transacciones, permisos ni base de datos. No agrega migraciones. D5.3.7.2.4 sigue protegiendo contra planes obsoletos y duplicación. diff --git a/docs/PHASE_D5_3_7_2_DEPLOY.md b/docs/PHASE_D5_3_7_2_DEPLOY.md new file mode 100644 index 0000000..71d4149 --- /dev/null +++ b/docs/PHASE_D5_3_7_2_DEPLOY.md @@ -0,0 +1,21 @@ +# Deploy D5.3.7.2 + +Artefacto esperado: `DH_V2_FASE_D5_3_7_2_SOURCE.zip` + +La fase es acumulativa sobre D5.3.7.1. El deploy: + +1. verifica PostgreSQL healthy y espacio libre; +2. descomprime a staging; +3. compila API; +4. ejecuta todos los tests API; +5. compila web; +6. crea backup PRE; +7. instala source; +8. construye imágenes finales; +9. aplica la migración D5.3.7.2; +10. recrea API/Web; +11. ejecuta aceptación automática y backup POST. + +La migración `PhaseD5372RelationalTerritoryPlan1787767200000` agrega el catálogo de Departamentos y su relación con Áreas, y extiende los tipos permitidos de ítems del plan. + +Después del deploy no se debe pulsar `APLICAR` hasta regenerar y revisar el plan de `Tablas de yacimiento.xlsx`. diff --git a/docs/PHASE_D5_3_7_DEPLOY.md b/docs/PHASE_D5_3_7_DEPLOY.md new file mode 100644 index 0000000..562da87 --- /dev/null +++ b/docs/PHASE_D5_3_7_DEPLOY.md @@ -0,0 +1,59 @@ +# Deploy D5.3.7 + +Artefacto esperado: + +`DH_V2_FASE_D5_3_7_1_SOURCE.zip` + +Ruta de producción: + +`/var/www/dhv2.korexlabs.com` + +## Seguridad del deploy + +El script `scripts/deploy-phase-d5-3-7-1.sh` valida antes de modificar producción: + +- espacio libre suficiente; +- PostgreSQL `healthy` y aceptando conexiones; +- ZIP y versiones esperadas; +- build real de API en staging; +- suite completa de tests API en staging; +- build real del frontend en staging. + +Sólo después crea backup PRE, instala el source, reconstruye imágenes y aplica la migración. + +El chequeo de migraciones se imprime directamente; no se oculta dentro de una sustitución de shell. Esto facilita diagnosticar errores del contenedor `migrate`. + +## Ejecución + +Desde el directorio del sitio, extraer el script del ZIP y ejecutarlo preferentemente dentro de `tmux`: + +```bash +unzip -p DH_V2_FASE_D5_3_7_1_SOURCE.zip scripts/deploy-phase-d5-3-7-1.sh > /root/deploy-dhv2-d537.sh +chmod +x /root/deploy-dhv2-d537.sh +bash /root/deploy-dhv2-d537.sh 2>&1 | tee /root/deploy-dhv2-d537.log +``` + +Resultado esperado: + +`ACEPTACIÓN DE FASE D5.3.7: OK` + +Health esperado: + +- version `0.19.3-7.1` +- phase `D5.3.7` +- database `ok` + +## Validación funcional posterior + +No aplicar masivamente un lote apenas termina el deploy. + +Orden recomendado: + +1. Abrir `Tablas de yacimiento.xlsx`. +2. Ejecutar nuevamente Conciliación. +3. Generar Plan. +4. Revisar el desglose por Organizaciones / Áreas / Yacimientos / Relaciones y enviar captura para validar. +5. Sólo después de validar el plan territorial, confirmar su aplicación. +6. Reconciliar nuevamente PSEnergy, porque el Maestro ya habrá cambiado. +7. Seleccionar la operadora correcta y namespace `PSENERGY`. +8. Generar y resolver el plan técnico antes de cualquier aplicación. diff --git a/docs/PHASE_D5_3_8.md b/docs/PHASE_D5_3_8.md new file mode 100644 index 0000000..2fce9c9 --- /dev/null +++ b/docs/PHASE_D5_3_8.md @@ -0,0 +1,47 @@ +# D5.3.8 — Estructura local de inventario según la fuente + +Versión: `0.19.3-8`. + +## Motivo + +Los inventarios reales muestran que, después del contexto territorial Área/Yacimiento, cada operadora puede usar su propia nomenclatura para instalaciones, sectores, baterías, plantas, SET, PTC, PTA y otros agrupadores. Esa terminología no debe convertirse automáticamente en una jerarquía universal de DH. + +D5.3.8 preserva la estructura local declarada por la fuente y separa **identidad/nomenclatura de origen** de una futura **clasificación normalizada por DH**. + +## Modelo + +Se incorpora el tipo provisional `estructura_local` / **Estructura local (fuente)**. Es un activo estructural GENERIC y en DRAFT que puede colgar de Área, Yacimiento o Locación y actuar como padre de activos técnicos compatibles. + +Sus atributos conservan: + +- nivel informado por la fuente; +- clasificación local informada; +- ruta/nomenclatura de origen. + +La identidad nunca es global por nombre: se concilia dentro del contexto Área/Yacimiento + Operadora + padre. + +## Importador de inventarios + +Para PSEnergy y perfiles equivalentes: + +- si la ubicación contiene una instancia local concreta (`BATATA01`, `PTCVIZ01`, etc.), se conserva esa denominación; +- si sólo existen categorías locales (`ENERGIA / SET`, `YACIMIENTO / BATERIA`), se conserva el grupo provisional sin afirmar que sea una instalación física universal; +- `pozo`, `ducto` y `colector` continúan anclándose directamente al contexto territorial para evitar inferir una jerarquía física incorrecta; +- ya no se generan nombres `[A VALIDAR]` para inventar instalaciones; +- la clasificación posterior puede cambiar sin perder el literal ni la ruta original. + +## Ambigüedad territorial + +Si un nombre como Atamisqui puede corresponder a más de un Área/Yacimiento válido, el plan genera **una sola decisión raíz por contexto**, no decenas de conflictos derivados. + +La UI muestra las alternativas con su Área padre. La selección se guarda únicamente para ese lote. Al regenerar el plan se recalculan automáticamente estructura local, padres y activos dependientes. + +Así, “falta instalación” o “padre sin resolver” dejan de presentarse como decisiones independientes cuando son sólo consecuencia de un Área/Yacimiento todavía ambiguo. + +## Seguridad + +La fase mantiene plan previo, huella de Maestro, protección contra plan obsoleto, aplicación transaccional, idempotencia, procedencia, reversión lógica y reglas de contexto operativo. + +Agrega una migración para `LOCAL_STRUCTURE`, el tipo `estructura_local`, sus atributos y reglas de padres. + +Antes de tocar producción el deploy crea el backup fuerte **INVENTARIO A REVISAR**. diff --git a/docs/PHASE_D5_3_9.md b/docs/PHASE_D5_3_9.md new file mode 100644 index 0000000..5ff7c94 --- /dev/null +++ b/docs/PHASE_D5_3_9.md @@ -0,0 +1,64 @@ +# D5.3.9 — Revisión obligatoria clara + +Versión: `0.19.3-9`. + +## Motivo + +D5.3.8 corrigió el modelo conceptual del inventario: la ambigüedad territorial se resuelve como una causa raíz y la nomenclatura propia de cada operadora se preserva como estructura local. Sin embargo, la interfaz todavía mostraba las decisiones humanas y sus consecuencias técnicas en una misma lista, lo que podía hacer pensar que cada instalación, padre o equipo debía resolverse manualmente. + +D5.3.9 separa explícitamente **lo que una persona debe decidir** de **lo que el sistema recalcula automáticamente**. + +## Cambios + +### 1. Decisiones humanas vs. dependencias automáticas + +El resumen del plan incorpora dos contadores adicionales: + +- `directReviewItems`: decisiones que requieren intervención humana; +- `dependencyReviewItems`: elementos bloqueados únicamente porque dependen de una decisión raíz. + +El plan continúa bloqueado mientras exista cualquier `REVIEW`, por lo que no se debilita ninguna protección de aplicación. + +### 2. Nueva pantalla de revisión obligatoria + +La UI muestra tres pasos claros: + +1. resolver decisiones humanas; +2. recalcular dependencias; +3. confirmar la importación. + +Las decisiones reales aparecen como tarjetas de acción. Las dependencias se muestran en un bloque secundario desplegable y se etiquetan expresamente como automáticas. + +Cada tarjeta explica: + +- qué informa el archivo; +- por qué el sistema no decide solo; +- qué debe decidir la persona. + +### 3. Contexto territorial con regeneración automática + +Cuando el usuario elige un Área/Yacimiento entre alternativas seguras: + +1. la elección queda guardada sólo para ese lote; +2. el sistema regenera inmediatamente el plan; +3. se recalculan estructura local, padres y activos dependientes; +4. la persona no tiene que recordar un segundo botón de “Regenerar plan”. + +La elección territorial sigue validándose en backend contra las alternativas seguras detectadas. + +### 4. Seguridad preservada + +No se agregan migraciones en D5.3.9. + +Se mantienen: + +- huella del Maestro y bloqueo de planes obsoletos; +- plan previo a cualquier escritura; +- aplicación transaccional; +- idempotencia; +- procedencia; +- reversión lógica; +- validación de contexto operativo; +- backup fuerte `INVENTARIO A REVISAR` creado antes de D5.3.8. + +El deploy D5.3.9 crea además un backup PRE liviano de código + DB antes de reemplazar producción. diff --git a/docs/PHASE_D5_3_DEPLOY.md b/docs/PHASE_D5_3_DEPLOY.md new file mode 100644 index 0000000..a289c62 --- /dev/null +++ b/docs/PHASE_D5_3_DEPLOY.md @@ -0,0 +1,39 @@ +# Deploy D5.3 + +## Archivo + +`DH_V2_FASE_D5_3_SOURCE.zip` + +Subir manualmente a `/var/www/dhv2.korexlabs.com/`, reemplazando el código del +proyecto según el procedimiento habitual, y ejecutar el bloque único de deploy +entregado junto con el archivo. + +## Migración + +`PhaseD53OperationalContext1787421600000` + +La migración es aditiva y preserva los datos existentes: + +- agrega el rol operativo de tipos; +- crea `area_company_relations`; +- agrega Área y Empresa operativas a `assets`; +- amplía snapshots históricos; +- incorpora restricciones y triggers de integridad; +- agrega permisos de lectura/gestión de relaciones. + +No modifica `.env` ni volúmenes. + +## Configuración posterior obligatoria + +La migración **no adivina** qué tipos existentes son Área o Empresa. Después +del deploy: + +1. ir a **Configuración → Tipos de activo**; +2. marcar el/los tipos correspondientes como `Área`; +3. marcar el/los tipos correspondientes como `Empresa`; +4. abrir activos Área/Empresa y crear los vínculos operativos necesarios; +5. editar instalaciones/estaciones/equipos y seleccionar `Área → Empresa`; +6. comprobar el Maestro usando los nuevos filtros. + +Esta carga se realiza antes de D5.5, donde la planificación pasará a depender +obligatoriamente del contexto Área–Empresa. diff --git a/docs/PHASE_D5_4.md b/docs/PHASE_D5_4.md new file mode 100644 index 0000000..687d0ae --- /dev/null +++ b/docs/PHASE_D5_4.md @@ -0,0 +1,134 @@ +# Fase D5.4 · Hallazgos aplicables al Inventario + +## Objetivo + +Hacer que el catálogo de hallazgos deje de ser una lista universal y pase a responder al elemento concreto que se inspecciona, sin perder compatibilidad con el funcionamiento actual ni alterar hallazgos históricos. + +D5.4 incorpora tres niveles separados: + +1. catálogo institucional de hallazgos; +2. aplicabilidad por tipo técnico de Inventario; +3. excepción explícita por registro concreto del Inventario. + +También separa la gravedad sugerida por catálogo de la gravedad real observada en una inspección y formaliza `OTROS` como propuesta administrable desde oficina. + +## Compatibilidad controlada + +Un tipo técnico que todavía no tenga una configuración explícita continúa viendo todos los ítems activos del catálogo. El API informa que ese tipo está `configured: false` para que oficina pueda distinguir el estado transitorio. + +En cuanto oficina guarda la primera configuración del tipo, sólo quedan habilitados los ítems seleccionados para ese tipo. Las excepciones por registro concreto se aplican encima de esa selección. + +De este modo el despliegue no deja a la APK o a la web sin catálogo mientras Hidrocarburos completa la parametrización inicial. + +## Aplicabilidad por tipo técnico + +Nuevas estructuras: + +- `finding_catalog_asset_type_profiles`: marca que un tipo técnico ya fue configurado y conserva el motivo de la última decisión; +- `finding_catalog_item_asset_types`: relación entre cada ítem de catálogo y los tipos técnicos para los que resulta aplicable. + +Administración: + +- `GET /api/v3/finding-catalog/asset-types/:assetTypeId/selection` +- `PUT /api/v3/finding-catalog/asset-types/:assetTypeId/selection` + +El cambio requiere `finding_catalog.manage` y queda auditado. + +## Excepciones por objeto concreto + +Nueva tabla `finding_catalog_asset_overrides`. + +Sólo guarda diferencias respecto de la regla del tipo técnico. Un registro concreto puede habilitar un hallazgo normalmente deshabilitado para su tipo o excluir uno normalmente habilitado. + +Administración: + +- `GET /api/v3/finding-catalog/assets/:assetId/selection` +- `PUT /api/v3/finding-catalog/assets/:assetId/selection` + +La ficha de Inventario incorpora la pestaña **Hallazgos aplicables** con la regla heredada del tipo y las excepciones del objeto. + +## Catálogo aplicable en campo + +Nuevo endpoint: + +- `GET /api/v3/finding-catalog/applicable/:assetId` + +Devuelve solamente categorías e ítems habilitados para el objeto concreto, junto con el estado de configuración del tipo y la opción `OTROS`. + +La misma regla se valida al crear el hallazgo. Aunque un cliente antiguo intente enviar manualmente un `catalogItemId` no habilitado para ese objeto, el servidor responde `FINDING_CATALOG_ITEM_NOT_APPLICABLE`. + +Si durante la edición de un hallazgo abierto se cambia el objeto inspeccionado, el servidor vuelve a validar la aplicabilidad del ítem de catálogo para el nuevo objeto. + +## Gravedad sugerida y gravedad real + +`finding_catalog_items.suggested_severity` define una recomendación opcional de 1 a 10. + +Cada `inspection_findings` conserva dos valores independientes: + +- `suggested_severity`: copia congelada de la sugerencia vigente al momento de crear el hallazgo; +- `severity`: gravedad real asignada al hallazgo, también entre 1 y 10. + +Si el inspector no indica una gravedad real al crear un hallazgo de catálogo, se usa la sugerida como valor inicial. El inspector puede corregirla mientras el acta siga editable. + +Cambios posteriores en la gravedad sugerida del catálogo no modifican hallazgos anteriores. La gravedad queda incluida en las versiones del hallazgo, la instantánea congelada del acta y el Word automático del informe. + +## OTROS y propuestas de catálogo + +Un hallazgo personalizado continúa permitido cuando el inspector encuentra una situación que no existe en el catálogo. + +D5.4 crea automáticamente una fila en `finding_catalog_proposals` vinculada al hallazgo original, al objeto y al tipo técnico. La propuesta conserva: + +- título propuesto; +- fundamento legal informado; +- gravedad propuesta; +- descripción; +- estado de revisión; +- decisión y usuario de oficina. + +Estados: + +- `PENDING` +- `MATCHED` +- `REJECTED` + +Administración: + +- `GET /api/v3/finding-catalog/proposals` +- `PATCH /api/v3/finding-catalog/proposals/:id/review` + +Al marcar una propuesta como `MATCHED`, oficina la vincula a un ítem existente del catálogo. Si el tipo técnico ya estaba configurado, ese ítem se habilita para el tipo hacia adelante. + +La decisión nunca modifica `inspection_findings.catalog_item_id` del hallazgo histórico que originó la propuesta. Ese hallazgo conserva exactamente la identidad con la que fue registrado en campo. + +## Backfill + +La migración crea propuestas `PENDING` para hallazgos personalizados existentes que no tenían `catalog_item_id`. + +No se infiere gravedad ni aplicabilidad histórica cuando no existe evidencia persistida para hacerlo. + +## Web de oficina + +El Catálogo de Hallazgos incorpora: + +- gravedad sugerida por ítem; +- configuración de aplicabilidad por tipo técnico; +- bandeja de propuestas `OTROS` pendientes. + +El Maestro de Inventario incorpora: + +- pestaña **Hallazgos aplicables**; +- visualización del default del tipo; +- excepción del objeto; +- motivo obligatorio para guardar cambios. + +La ficha del hallazgo muestra gravedad real y gravedad sugerida. + +## Reglas preservadas + +- Una visita = una Acta. +- El catálogo sigue versionado por revisiones. +- Hallazgos históricos no se reescriben por cambios de catálogo. +- Actas cerradas e informes congelados conservan su snapshot. +- Las verificaciones de D5.3.25 siguen siendo append-only. +- La primera respuesta de empresa continúa inmutable. +- SMTP puede continuar pendiente sin bloquear esta fase. diff --git a/docs/PHASE_D5_4_1.md b/docs/PHASE_D5_4_1.md new file mode 100644 index 0000000..ffabfd7 --- /dev/null +++ b/docs/PHASE_D5_4_1.md @@ -0,0 +1,9 @@ +# Fase D5.4.1 · Corrección de build + +D5.4.1 es una corrección técnica de D5.4 detectada durante el build de staging antes de tocar producción. + +El build de Nest con `declaration: true` rechazó cuatro métodos públicos de `FindingCatalogController` porque sus retornos inferidos utilizaban `AssetTypeSelectionView` y `AssetSelectionView`, tipos internos no exportados por `finding-catalog.service.ts`. + +La corrección exporta `CatalogSelectionItem`, `AssetSelectionItem`, `AssetTypeSelectionView` y `AssetSelectionView`. No modifica endpoints, payloads, tablas, migraciones ni reglas funcionales. + +D5.4.1 debe desplegarse directamente desde API/WEB 0.19.3-25, dado que D5.4 se detuvo en staging antes del backup PRE, instalación de source y migración. diff --git a/docs/PHASE_D5_5.md b/docs/PHASE_D5_5.md new file mode 100644 index 0000000..f362dd1 --- /dev/null +++ b/docs/PHASE_D5_5.md @@ -0,0 +1,66 @@ +# Fase D5.5 · Planificación web y checklist + +## Objetivo + +Consolidar la preparación de una visita desde oficina antes de su inicio en campo. La planificación queda anclada a un Área y a una Organización operadora vigente, incorpora antecedentes y próximos controles automáticamente, permite sumar registros preventivos y exige que toda exclusión quede justificada y trazable. + +## Contexto operativo explícito + +Cada visita nueva puede guardar `operational_area_id` y `operator_company_id`. La API valida que ambos pertenezcan a tipos con roles `AREA` y `COMPANY` y que exista una relación `OPERATOR` vigente entre ellos. El `scope_asset_id` conserva compatibilidad y, para la planificación D5.5, referencia el Área seleccionada. + +La web resuelve primero el Área y luego ofrece únicamente las Organizaciones operadoras vigentes de ese Área. + +## Checklist automático + +Cada generación se conserva como una fotografía append-only en `inspection_visit_checklist_items`. La generación vigente clasifica los hallazgos del contexto en: + +- `COMPANY_OVERDUE`: respuesta de empresa vencida. +- `VERIFICATION_OVERDUE`: control de campo vencido. +- `UPCOMING_CONTROL`: control previsto dentro de los próximos 30 días desde la fecha planificada. +- `ANTECEDENT`: antecedente histórico sin acción automática inmediata. + +Los registros con vencimientos o controles accionables se incorporan automáticamente a la visita. Regenerar no borra generaciones anteriores. + +Si cambia Área, Operadora o fecha de inicio, el checklist vigente queda marcado como no válido hasta que oficina lo regenere y revise. La confirmación a `PLANNED` no regenera silenciosamente: exige una generación vigente. + +## Registros preventivos y exclusiones + +Oficina puede agregar registros del Inventario que pertenezcan al mismo Área y Operadora aunque no tengan un pendiente previo. Esos registros quedan identificados con origen `PREVENTIVE`. + +Los orígenes disponibles son: + +- `LEGACY` +- `AUTOMATIC` +- `PREVENTIVE` +- `VERIFICATION` + +Retirar un registro de la visita requiere una exclusión específica con motivo de al menos 10 caracteres. La proyección vigente se guarda en `inspection_visit_assets`, mientras la historia se agrega a `inspection_visit_asset_events`. Una reincorporación no elimina la exclusión anterior. Un registro excluido tampoco puede reincorporarse por la operación genérica de alta preventiva. + +## Visitas de verificación + +Las visitas creadas desde Hallazgos continúan siendo visitas normales, ahora con Área y Operadora explícitas y registros con origen `VERIFICATION`. La planificación debe completar equipo y checklist antes de confirmarse. + +## Inicio exclusivo desde APK + +La web crea, configura y confirma la planificación. No expone una acción de inicio. El endpoint de inicio mantiene `assertMobileInspector`, que exige transporte bearer y rol inspector, por lo que el inicio y la ejecución de campo permanecen reservados a la APK. + +## Trazabilidad + +D5.5 agrega auditoría de: + +- generación de checklist; +- exclusión de un registro; +- reincorporación de un registro. + +Las tablas históricas nuevas sólo conceden `SELECT` e `INSERT` al rol de aplicación y revocan `UPDATE` y `DELETE`. + +## Compatibilidad preservada + +D5.5 no modifica las reglas consolidadas: + +- una visita genera una sola Acta; +- el Acta cerrada no se reescribe; +- los hallazgos y respuestas históricas permanecen protegidos; +- las verificaciones sucesivas siguen siendo append-only; +- el catálogo contextual D5.4.1 continúa validando aplicabilidad y gravedad; +- SMTP puede seguir pendiente sin bloquear esta fase. diff --git a/docs/PHASE_D5_5_2.md b/docs/PHASE_D5_5_2.md new file mode 100644 index 0000000..5701e09 --- /dev/null +++ b/docs/PHASE_D5_5_2.md @@ -0,0 +1,10 @@ +# D5.5.2 · Cierre de aceptación de planificación + +Hotfix de trazabilidad posterior a D5.5.1. + +- No cambia reglas funcionales de D5.5. +- No agrega ni modifica migraciones. +- Parte de API/WEB 0.19.5-1 con `PhaseD55WebPlanningChecklist1789063200000` ya aplicada. +- Normaliza la etiqueta de fase entre frontend, deploy y aceptación. +- Mantiene 252 pruebas de API. +- Repite build y aceptación antes de generar backup POST. diff --git a/docs/PHASE_D5_6.md b/docs/PHASE_D5_6.md new file mode 100644 index 0000000..179c944 --- /dev/null +++ b/docs/PHASE_D5_6.md @@ -0,0 +1,36 @@ +# D5.6 · Cierre integral de Fase D + +D5.6 es una fase de consolidación y aceptación. No agrega reglas de negocio ni migraciones. + +## Objetivo + +Cerrar formalmente el backend y dashboard web antes de comenzar Android, verificando como un único contrato: + +- Inventario jerárquico, contexto temporal de Área y Operadora y trazabilidad; +- importación/revisiones sin sobrescritura destructiva del Maestro; +- catálogo contextual de Hallazgos por tipo y por registro concreto; +- hallazgos originales inmutables y seguimiento append-only; +- múltiples verificaciones sucesivas con historia propia; +- planificación web Área → Operadora, checklist versionado, preventivos y exclusiones auditadas; +- inicio y ejecución de visita reservados a APK/inspector bearer; +- una visita = una Acta con múltiples hallazgos; +- cierre de Acta inmutable con snapshot/hash; +- Informe Word automático congelado a partir del Acta cerrada; +- revisiones y firma final reservadas al Director; +- entrega documental auditable con SMTP configurable. + +## Alcance técnico + +- API/WEB: 0.19.6. +- Sin migración D5.6. +- Ocho pruebas transversales nuevas; total esperado: 260. +- Normalización final de terminología visible: “Inventario” para registros administrables. Los estados “Activo/Inactivo” y conceptos jurídicos conservan su significado propio. +- Aceptación integral y backup fuerte `FASE_D_CERRADA_*`. + +## Pendiente no bloqueante + +SMTP puede permanecer sin configuración. La cola de entrega debe conservar estados `WAITING_TRANSPORT` y reintentos auditables hasta que se configuren `SMTP_HOST` y `MAIL_FROM`. + +## Condición de cierre + +La Fase D se considera cerrada sólo si compilan API y web, pasan 260/260 pruebas, no existen migraciones pendientes, health y DB están OK, la aceptación integral pasa y se crea el backup fuerte `FASE_D_CERRADA_*`. diff --git a/docs/PHASE_D5_6_1.md b/docs/PHASE_D5_6_1.md new file mode 100644 index 0000000..bb3dc18 --- /dev/null +++ b/docs/PHASE_D5_6_1.md @@ -0,0 +1,15 @@ +# D5.6.1 · Accesos y compatibilidad Android + +Hotfix posterior al cierre funcional de Fase D. No agrega migraciones ni cambia el modelo de inspección. + +## Administración + +- `POST /api/v3/users/:id/reset-password` bajo permiso `users.update`. +- Contraseña temporal de 12 a 128 caracteres. +- `mustChangePassword` configurable y activado por defecto. +- Restablecer una contraseña limpia bloqueo/intentos fallidos y revoca todas las sesiones del usuario. +- La contraseña nunca se guarda en auditoría; se registra `USER_PASSWORD_RESET`. + +## Android + +La API pública mantiene el prefijo `/api/v3`. El host web es `https://dhv2.korexlabs.com/` y la API es `https://dhv2.korexlabs.com/api/v3/`. diff --git a/docs/PHASE_D5_6_2.md b/docs/PHASE_D5_6_2.md new file mode 100644 index 0000000..d38d211 --- /dev/null +++ b/docs/PHASE_D5_6_2.md @@ -0,0 +1,13 @@ +# D5.6.2 · Accesos y compatibilidad Android + +Hotfix de mantenimiento sobre D5.6.1. + +## Objetivo + +- Mantener el restablecimiento administrativo de contraseña incorporado para la integración Android. +- Corregir el contrato de regresión de cierre de Fase D para aceptar versiones de mantenimiento `0.19.6-N` / `D5.6.N`. +- No introducir nuevas reglas de negocio ni migraciones. + +## Regla de cierre + +La Fase D permanece cerrada mientras los hotfixes `D5.6.x` no agreguen una migración funcional y conserven los contratos transversales de D5.6. diff --git a/docs/PHASE_D5_DEPLOY.md b/docs/PHASE_D5_DEPLOY.md new file mode 100644 index 0000000..870dc8e --- /dev/null +++ b/docs/PHASE_D5_DEPLOY.md @@ -0,0 +1,19 @@ +# Deploy D5 + +## Archivo + +`DH_V2_FASE_D5_SOURCE.zip` + +Subir por FTP a `/var/www/dhv2.korexlabs.com/`. + +## Migración + +`PhaseD5ActClosingSignatures1787328000000` + +Crea tres tablas, cuatro permisos, índices, restricciones y siete triggers de +integridad. Reutiliza el volumen `dhv2_asset_media`; no agrega volúmenes ni +requiere editar `.env`. + +El bloque único entregado junto con el ZIP valida el checksum, genera un backup +de código/base/.env/archivos protegidos, extrae, compila, aplica la migración, +recrea API/web y ejecuta `scripts/phase-d5-acceptance.sh`. diff --git a/docs/PHASE_D_CLOSED.md b/docs/PHASE_D_CLOSED.md new file mode 100644 index 0000000..d1f8dfc --- /dev/null +++ b/docs/PHASE_D_CLOSED.md @@ -0,0 +1,7 @@ +# Fase D · Contrato cerrado + +La Fase D deja estable el contrato backend/web que consumirá la futura aplicación Android. + +No debe alterarse desde Android la semántica ya cerrada: una visita produce una sola Acta, el Acta cerrada y sus snapshots son inmutables, los Hallazgos evolucionan mediante eventos append-only, las verificaciones son sucesivas e históricas, el contexto del Inventario es temporal y el Informe final sólo puede ser aprobado/firmado por Director según el flujo de revisión vigente. + +Cualquier evolución posterior que requiera cambiar estas reglas debe tratarse como cambio de contrato y no como detalle de implementación móvil. diff --git a/docs/REAL_INVENTORY_IMPORT_RESEARCH.md b/docs/REAL_INVENTORY_IMPORT_RESEARCH.md new file mode 100644 index 0000000..adfe633 --- /dev/null +++ b/docs/REAL_INVENTORY_IMPORT_RESEARCH.md @@ -0,0 +1,67 @@ +# Investigación de inventarios reales — criterio de importación DH + +## Material analizado durante desarrollo + +Se utilizaron inventarios reales recibidos por la Dirección, entre ellos archivos de Petróleos Sudamericanos Energy, EMESA, Petroquímica Comodoro Rivadavia, GyG Oil Service, Cerro Los Leones y una tabla Área/Yacimiento, además de material PDF de Phoenix y otras presentaciones. + +El objetivo de la investigación no es considerar esos archivos como padrón vigente automático, sino utilizarlos para diseñar un modelo/importador que no pierda información ni invente correspondencias. + +## Resultados de prueba del perfil genérico D5.3.5 + +Los números siguientes describen el resultado del **analizador**, no registros importados al Maestro: + +| Fuente de desarrollo | Filas analizadas | Listas | Advertencias | Conflictos | +|---|---:|---:|---:|---:| +| PSEnergy inventario MZA | 8.320 | 8.318 | 2 | 0 | +| Tabla Área/Yacimiento | 230 | 230 | 0 | 0 | +| EMESA Loma de la Mina | 85 | 71 | 1 | 13 | +| PCR Áreas Malargüe | 105 | 63 | 1 | 41 | +| PCR Llancanelo | 84 | 16 | 2 | 66 | +| PCR Sosneado | 85 | 60 | 0 | 25 | +| GyG | 85 | 0 | 0 | 85 | +| Cerro Los Leones | 85 | 0 | 0 | 85 | + +Que un archivo tenga conflictos **no significa que sus datos sean incorrectos**. Significa que el perfil genérico no debe adivinar cómo interpretar secciones desplazadas, encabezados secundarios o usos particulares de las columnas. + +## Decisiones derivadas + +### 1. No existe una única semántica universal para las columnas + +Las planillas comparten un formato visual, pero cada operadora utiliza `Instalación`, `Sub instalación`, `Equipo`, `Denominación` y `Ubicación` de manera diferente. + +Por eso D5.3.5 detecta y muestra, pero la aplicación futura deberá disponer de perfiles/mapeos específicos cuando corresponda. + +### 2. El ID de inventario no es clave global + +Un identificador externo debe conservar namespace/contexto de fuente. Un mismo texto puede repetirse en áreas, instalaciones o registros distintos. + +### 3. Cantidad mayor a uno requiere revisión + +Una fila con cantidad 2 no habilita al sistema a inventar dos identidades físicas. Se marca como registro agrupado hasta individualización o confirmación. + +### 4. Estado fuente se conserva literalmente + +Se observaron distintas convenciones: `EN SERVICIO`, `F/Servicio`, `S`, `F/S`, `SI`, `NO`, `Bueno`, etc. + +DH conserva el valor fuente y sólo genera una sugerencia cuando la equivalencia es inequívoca. + +### 5. Familia normalizada y clasificación fuente deben coexistir + +Ejemplos como `Clase: BBA; Tipo: CEN`, AIB, PCP, motores, válvulas y otras denominaciones demuestran que no conviene convertir toda clasificación de origen en un tipo de activo del Maestro. + +La evolución prevista es: + +- familia técnica DH; +- subtipo DH; +- clase/tipo original de la fuente; +- fabricante/modelo/especificación original. + +### 6. El Asset Center debe usar carga por demanda + +Una sola fuente de desarrollo supera las ocho mil filas. El árbol completo de D5.3.4 no es una estrategia sostenible, por lo que D5.3.5 usa expansión lazy server-side. + +## Principio de implementación + +**Ante una ambigüedad, DH debe mostrarla y pedir revisión; no debe inferir silenciosamente.** + +Esto es especialmente importante porque los datos se usarán después para planificación, checklist, hallazgos, evidencias e informes oficiales. diff --git a/docs/RELATIONAL_TERRITORY_RESEARCH.md b/docs/RELATIONAL_TERRITORY_RESEARCH.md new file mode 100644 index 0000000..f499a84 --- /dev/null +++ b/docs/RELATIONAL_TERRITORY_RESEARCH.md @@ -0,0 +1,22 @@ +# Validación del padrón territorial real + +Archivo de referencia: `Tablas de yacimiento.xlsx`, hoja `cr26e_tabla1`. + +Campos usados: Yacimiento, Área, Departamento, Tipo de concesión y Empresa operadora. + +Conteos observados en las 230 filas de datos: + +- 64 Áreas únicas. +- 219 nombres de Yacimiento globalmente únicos. +- 230 pares únicos Área + Yacimiento; por eso Yacimiento no puede deduplicarse sólo por nombre. +- 7 Departamentos: La Paz, Luján de Cuyo, Malargüe, Rivadavia, San Carlos, San Rafael y Tupungato. +- 210 filas de Explotación y 20 de Exploración. +- 13 valores fuente de Operadora, de los cuales uno es `Sin Empresa Operadora`. +- 12 Organizaciones reales. +- 26 filas sin empresa operadora asignada. +- 64 pares Área + Departamento. +- 64 pares Área + tipo de derecho. +- 47 pares Área + Operadora real. +- 47 pares Derecho + Operadora real. + +Estos conteos son referencia del archivo, no cantidades garantizadas de INSERT: las coincidencias existentes en el Maestro reducen las creaciones. diff --git a/docs/RELEASE_A2.md b/docs/RELEASE_A2.md new file mode 100644 index 0000000..7140e3f --- /dev/null +++ b/docs/RELEASE_A2.md @@ -0,0 +1,32 @@ +# Release DH V2 v0.2.1 — Fase A2 + +**Fecha:** 12/08/2026 +**Alcance:** autenticación, sesiones y versión visible. + +## Entrega + +- 5 endpoints de autenticación; +- cookies seguras y CSRF; +- refresh rotativo con detección de reutilización; +- bloqueo por intentos y rate limiting; +- cambio de contraseña y revocación de otras sesiones; +- auditoría sanitizada y request ID; +- bootstrap interactivo del primer administrador; +- 1 migración aditiva con 2 índices; +- footer `DH Inspección V2 · v0.2.1 · Fase A2`; +- health con versión y fase. + +## Validación local + +```text +API typecheck: OK +API tests: 6/6 OK +API clean build: OK +API runtime layout: OK +Web typecheck: OK +Web production build: OK +``` + +El warning de tamaño del bundle Web corresponde al skeleton con MapLibre y no +bloquea A2. La división de chunks se tratará junto con el frontend +administrativo, cuando existan rutas reales para cargar en forma diferida. diff --git a/docs/RESTORE_RUNBOOK.md b/docs/RESTORE_RUNBOOK.md new file mode 100644 index 0000000..0a31e3b --- /dev/null +++ b/docs/RESTORE_RUNBOOK.md @@ -0,0 +1,61 @@ +# Procedimiento de recuperación — DH V2 + +Este documento es un runbook para incidentes. No debe ejecutarse durante un +deploy normal. + +## Principios + +- identificar exactamente el backup a utilizar; +- verificar `SHA256SUMS` antes de cualquier cambio; +- generar un backup de seguridad del estado actual; +- no usar `docker compose down -v`; +- no eliminar `dhv2_pgdata`; +- no eliminar `dhv2_asset_media`; +- detener únicamente API y web mientras se recupera la base; +- conservar el `.env` actual salvo que el incidente requiera recuperar el del backup; +- validar primero el dump con `scripts/verify-backup.sh`. + +## Verificación previa + +```bash +cd /var/www/dhv2.korexlabs.com +bash scripts/verify-backup.sh /var/www/dh-backup/dhv2-BACKUP-ELEGIDO +``` + +## Orden de recuperación + +1. Registrar el motivo, fecha y backup seleccionado. +2. Ejecutar `scripts/backup-phase-a.sh pre-restore`. +3. Verificar ambos backups. +4. Detener `api` y `web`, manteniendo `db` activo. +5. Extraer `source.tar.gz` en un directorio temporal, no sobre producción. +6. Revisar su contenido y recién entonces reemplazar los fuentes. +7. Restaurar `database.dump` con el usuario owner y `pg_restore`. +8. Si el backup contiene `asset-media.tar.gz`, restaurarlo en el volumen + `dhv2_asset_media` antes de iniciar la API. +9. Ejecutar `migration:show` y aplicar sólo migraciones pendientes conocidas. +10. Reconstruir API y web. +11. Ejecutar health interno y externo. +12. Ejecutar la aceptación de la fase y una descarga autenticada conocida. +13. Conservar el backup anterior hasta confirmar estabilidad. + +La restauración de la base reemplaza datos y requiere una autorización +específica para el incidente. Por ese motivo no se incluye un script automático +que borre o sobrescriba producción. + +## Validación posterior + +```text +health interno y externo: ok +database: ok +versión esperada +Pending migrations: no +login: 200 +endpoints administrativos con sesión: 200 +endpoints administrativos sin sesión: 401 +logout: sesión revocada +contenedores: Up +logs: sin errores de bootstrap +volumen dhv2_asset_media: montado y escribible +fotografía o documento conocido: descarga íntegra +``` diff --git a/docs/ROADMAP_AFTER_D3.md b/docs/ROADMAP_AFTER_D3.md new file mode 100644 index 0000000..60a5f55 --- /dev/null +++ b/docs/ROADMAP_AFTER_D3.md @@ -0,0 +1,57 @@ +# Plan posterior a D3 + +Este plan respeta el modelo definitivo: + +`visita → una única acta → múltiples hallazgos → firma y envío → informe`. + +## D4 — Evidencias y comunicaciones + +- múltiples fotos y documentos por hallazgo; +- GPS, fecha, dispositivo, autor y hash; +- PDF de respuesta enviado por la empresa; +- historial de comunicaciones y documentos protegidos. + +## D5 — Cierre y firmas + +- datos del responsable que atendió la visita; +- firmas en pantalla de inspectores y responsable empresarial; +- negativa o ausencia de firma documentada; +- congelamiento de acta, hallazgos, evidencias, activos y normativa; +- cierre coordinado de acta y visita. + +## D6 — Acta verificable y envío + +- PDF oficial del acta; +- hash SHA-256 y token/QR de verificación; +- envío al responsable empresarial y correo configurable de Hidrocarburos; +- cola, reintentos y auditoría de entrega. + +## D7 — Informe técnico + +- numeración anual independiente; +- objetivos, antecedentes, marco legal y resumen ejecutivo; +- descripción, hallazgos, registro fotográfico y conclusión; +- acta digital como anexo; +- preparación, aprobación y PDF final inmutable. + +## D8 — Verificación y rectificaciones + +- nueva inspección o control de cada hallazgo abierto; +- evidencias de subsanación; +- cierre solamente por verificación autorizada; +- rectificativas y adendas sin modificar originales. + +## D9 — Explotación operativa + +- búsqueda global de hallazgos; +- filtros por empresa, activo, estado y fechas; +- alertas y agenda ampliada; +- mapas, evolución antes/después y exportaciones. + +## Fase E — Android V2 + +- login, permisos y Maestro de Activos; +- visita y acta única; +- hallazgos, fotos, GPS y firmas; +- operación online-first con cola offline idempotente; +- sincronización segura al recuperar conectividad. diff --git a/docs/ROADMAP_AFTER_D4.md b/docs/ROADMAP_AFTER_D4.md new file mode 100644 index 0000000..7d239b2 --- /dev/null +++ b/docs/ROADMAP_AFTER_D4.md @@ -0,0 +1,46 @@ +# Plan posterior a D4 + +## D5 — Cierre y firmas + +- datos del responsable que atendió la visita; +- firmas en pantalla de inspectores y responsable empresarial; +- negativa o ausencia de firma documentada; +- congelamiento de acta, hallazgos, evidencias, activos y normativa; +- cierre coordinado de acta y visita. + +## D6 — Acta verificable y envío + +- PDF oficial del acta; +- hash SHA-256 y token/QR de verificación; +- envío al responsable empresarial y correo configurable de Hidrocarburos; +- cola, reintentos y auditoría de entrega. + +## D7 — Informe técnico + +- numeración anual independiente; +- objetivos, antecedentes, marco legal y resumen ejecutivo; +- descripción, hallazgos, registro fotográfico y conclusión; +- acta digital como anexo; +- preparación, aprobación y PDF final inmutable. + +## D8 — Verificación y rectificaciones + +- nueva inspección o control de cada hallazgo abierto; +- evidencias de subsanación; +- cierre solamente por verificación autorizada; +- rectificativas y adendas sin modificar originales. + +## D9 — Explotación operativa + +- búsqueda global de hallazgos; +- filtros por empresa, activo, estado y fechas; +- alertas y agenda ampliada; +- mapas, evolución antes/después y exportaciones. + +## Fase E — Android V2 + +- login, permisos y Maestro de Activos; +- visita y acta única; +- hallazgos, fotos, GPS y firmas; +- operación online-first con cola offline idempotente; +- sincronización segura al recuperar conectividad. diff --git a/docs/ROADMAP_AFTER_D5.md b/docs/ROADMAP_AFTER_D5.md new file mode 100644 index 0000000..70cf488 --- /dev/null +++ b/docs/ROADMAP_AFTER_D5.md @@ -0,0 +1,41 @@ +# Plan posterior a D5 + +> Reemplazado por `ROADMAP_AFTER_D5_1.md`. La corrección D5.1 adelanta la APK +> operativa porque el dashboard no puede crear actas, hallazgos ni informes. + +## D6 — Acta verificable y envío + +- PDF oficial del acta cerrada, sin recalcular contenido mutable; +- hash SHA-256 del PDF y token/QR de verificación; +- destinatarios: responsable empresarial y correo configurable de Hidrocarburos; +- cola de envío, reintentos, estados y auditoría de entrega; +- descarga protegida y vista de comprobación. + +## D7 — Informe técnico + +- numeración anual independiente del acta; +- objetivos, antecedentes, marco legal y resumen ejecutivo; +- hallazgos y registro fotográfico desde el snapshot de cierre; +- conclusión, preparación y aprobación; +- acta digital como anexo y PDF final inmutable. + +## D8 — Control y rectificaciones + +- nueva visita de control sobre hallazgos abiertos; +- evidencias de subsanación y decisión autorizada de cierre; +- rectificativas/adendas vinculadas sin modificar el original; +- cadena de hashes y documento vigente verificable. + +## D9 — Explotación operativa + +- búsqueda global por empresa, activo, estado y fechas; +- alertas y agenda de próximos controles; +- mapas y comparación antes/después; +- exportaciones operativas y métricas. + +## Fase E — Android V2 + +- login, permisos y Maestro de Activos; +- visita, acta única, múltiples hallazgos, fotos, GPS y firmas; +- operación online-first con cola offline idempotente; +- sincronización segura y registro de carga inmediata/diferida. diff --git a/docs/ROADMAP_AFTER_D5_1.md b/docs/ROADMAP_AFTER_D5_1.md new file mode 100644 index 0000000..987f61e --- /dev/null +++ b/docs/ROADMAP_AFTER_D5_1.md @@ -0,0 +1,50 @@ +# Plan posterior a D5.1 + +## D5.2 — Configuración administrativa para inspecciones + +- CRUD web de categorías y tipos/modelos de hallazgo; +- activación, orden, revisión, normativa y glosario del catálogo; +- parámetros que consumirá la APK: correo interno de Hidrocarburos, plazos y + reglas documentales; +- conservación de versiones para no alterar actas ya emitidas; +- activos, equipos y asignaciones continúan administrándose en el dashboard. + +## E1 — APK Android operativa + +- login bearer y perfil inspector; +- visitas asignadas, detalle de activos y catálogo sincronizado; +- inicio de visita, acta única y múltiples hallazgos; +- fotos, documentos, GPS, responsable y firmas; +- cierre y sincronización segura, con reintentos idempotentes. + +Esta fase debe preceder a la puesta en uso real del flujo de inspección: el +dashboard ya no es un canal operativo alternativo. + +## D6 — Acta verificable y envío + +- PDF oficial desde el snapshot cerrado; +- hash SHA-256 y token/QR de verificación; +- envío al responsable empresarial y correo configurable de Hidrocarburos; +- cola, reintentos, estados y auditoría de entrega; +- solicitud desde la APK y consulta/descarga desde el dashboard. + +## D7 — Informe técnico + +- creación solicitada por el inspector desde la APK; +- numeración anual independiente; +- objetivos, antecedentes, marco legal, resumen, hallazgos y fotografías; +- acta digital como anexo y PDF final inmutable; +- consulta y descarga desde el dashboard. + +## D8 — Control y rectificaciones + +- visita de control sobre hallazgos abiertos; +- evidencia de subsanación y decisión autorizada de cierre; +- rectificativas vinculadas sin modificar el original; +- cadena de hashes y documento vigente verificable. + +## D9 — Explotación operativa + +- agenda y alertas de próximos controles; +- búsqueda por empresa, activo, estado y fechas; +- mapas, comparación antes/después, exportaciones y métricas. diff --git a/docs/ROADMAP_AFTER_D5_2.md b/docs/ROADMAP_AFTER_D5_2.md new file mode 100644 index 0000000..6171835 --- /dev/null +++ b/docs/ROADMAP_AFTER_D5_2.md @@ -0,0 +1,41 @@ +# Plan posterior a D5.2 + +## E1 — APK Android operativa + +- login bearer y perfil inspector; +- visitas y activos asignados con catálogo sincronizado; +- inicio de visita, acta única y múltiples hallazgos; +- evidencia, GPS, responsable, firmas y trabajo sin conexión; +- cierre y sincronización idempotente. + +El código Android es la siguiente entrega necesaria: no forma parte del +repositorio actual de API y dashboard. + +## D6 — Acta oficial, verificación y envío + +- PDF oficial generado desde el snapshot cerrado; +- hash SHA-256 y QR/token de verificación; +- envío al responsable empresarial y al correo configurable de Hidrocarburos; +- cola, reintentos, estado y auditoría de entrega; +- solicitud desde la APK, consulta y descarga desde el dashboard. + +## D7 — Informe técnico + +- creación solicitada por el inspector desde la APK; +- numeración anual independiente; +- objetivos, antecedentes, marco legal, resumen, hallazgos y fotografías; +- acta digital como anexo y PDF final inmutable; +- consulta y descarga desde el dashboard. + +## D8 — Control y rectificaciones + +- visitas de control sobre hallazgos abiertos; +- respuesta de empresa, PDF, compromiso y próxima fecha integrados; +- evidencia de subsanación y decisión autorizada de cierre; +- rectificativas vinculadas sin modificar el original. + +## D9 — Explotación operativa + +- agenda y alertas de próximos controles; +- búsqueda por empresa, activo, estado y fechas; +- mapas, comparación antes/después, exportaciones y métricas. diff --git a/docs/ROADMAP_AFTER_D5_3.md b/docs/ROADMAP_AFTER_D5_3.md new file mode 100644 index 0000000..919958f --- /dev/null +++ b/docs/ROADMAP_AFTER_D5_3.md @@ -0,0 +1,42 @@ +# Plan posterior a D5.3 + +## D5.4 · v0.19.4 — Hallazgos aplicables al activo + +- vincular modelos del catálogo a tipos de activo; +- asignar a cada activo concreto el subconjunto habilitado; +- gravedad sugerida en catálogo y gravedad real obligatoria de 1 a 10; +- administrar descripción de los niveles de gravedad; +- opción `OTROS` para registrar un hallazgo real y una propuesta de catálogo; +- revisión de propuestas desde el dashboard sin alterar automáticamente el + catálogo desde campo. + +## D5.5 · v0.19.5 — Planificación web y checklist + +- planificación desde el dashboard; +- selección obligatoria `Área → Empresa`; +- mostrar únicamente activos de esa empresa dentro del área; +- equipo inspector y fecha; +- checklist automático con hallazgos abiertos, vencidos, próximos controles, + compromisos y última evidencia; +- posibilidad de agregar activos preventivos; +- exclusiones del checklist sólo con motivo auditable; +- edición de la planificación hasta el inicio; +- inicio de inspección exclusivamente desde la APK. + +## E1 — APK Android sobre contrato estable + +- sincronización de planificaciones y checklist; +- inicio al llegar al lugar; +- acta única por inspección; +- hallazgos nuevos y revisión de anteriores; +- alta provisional desde Relevamiento; +- evidencia, fotografías, GPS y firmas; +- funcionamiento online con continuidad offline; +- cierre operativo y sincronización idempotente. + +## Etapas posteriores + +- DOCX técnico generado automáticamente y revisable por jefatura; +- PDF definitivo y trazabilidad documental; +- controles y rectificativas; +- agenda global, búsqueda, mapas, comparación antes/después y métricas. diff --git a/docs/ROADMAP_AFTER_D5_3_1.md b/docs/ROADMAP_AFTER_D5_3_1.md new file mode 100644 index 0000000..cf41988 --- /dev/null +++ b/docs/ROADMAP_AFTER_D5_3_1.md @@ -0,0 +1,34 @@ +# Plan posterior a D5.3.1 + +## D5.4 — Hallazgos aplicables al activo + +- vincular modelos del catálogo a tipos de activo; +- asignar a cada activo concreto el subconjunto habilitado; +- gravedad sugerida en catálogo y gravedad real obligatoria de 1 a 10; +- administrar descripción de los niveles de gravedad; +- opción `OTROS` para registrar un hallazgo real y una propuesta de catálogo; +- revisión de propuestas desde el dashboard sin alterar automáticamente el catálogo desde campo. + +## D5.5 — Planificación web y checklist + +- planificación desde el dashboard; +- selección obligatoria `Área → Empresa`; +- mostrar únicamente activos de esa empresa dentro del área; +- equipo inspector y fecha; +- checklist automático con hallazgos abiertos, vencidos, próximos controles, + compromisos y última evidencia; +- activos preventivos adicionales; +- exclusiones del checklist sólo con motivo auditable; +- edición hasta el inicio; +- inicio exclusivamente desde la APK. + +## Importación oficial de Maestro + +En paralelo o antes de una carga masiva real, preparar un importador versionado para +fuentes oficiales de Mendoza. Debe usar procedencia `IMPORT`, detectar duplicados y +preservar la vigencia Área–Empresa. No debe depender de scraping de una página de +indicadores. + +## E1 — APK Android + +Comienza recién cuando el contrato de D5.4 y D5.5 quede estable. diff --git a/docs/ROADMAP_AFTER_D5_3_3.md b/docs/ROADMAP_AFTER_D5_3_3.md new file mode 100644 index 0000000..9f234c7 --- /dev/null +++ b/docs/ROADMAP_AFTER_D5_3_3.md @@ -0,0 +1,41 @@ +# Roadmap posterior a D5.3.3 + +## D5.3.4 · UX Maestro y Administración + +Objetivo: que el modelo completo de D5.3.3 se use sin exponer complejidad técnica innecesaria. + +- convertir Activos en un Asset Center; +- vistas Lista / Jerarquía / Mapa del mismo Maestro; +- búsqueda global por nombre, código e identificador externo; +- filtros claros y vistas guardadas; +- detalle de activo por pestañas: Resumen, Jerarquía, Inspecciones, Hallazgos, Documentos, + Ubicación e Historial; +- creación contextual desde un activo padre heredando Área y Operadora; +- selector buscable de padre en lugar de un `select` limitado; +- simplificar Tipos y atributos y mover reglas técnicas a Configuración avanzada; +- administración visual de Organización/UTE, documentos fuente, identificadores y derechos; +- simplificar roles/permisos con nombres humanos y presets; +- reorganizar navegación lateral; +- mover salud técnica del sistema fuera del Inicio operativo; +- dashboard principal orientado a pendientes y próximas acciones. + +## D5.4 · Hallazgos aplicables al activo + +- catálogo vinculado a tipo técnico; +- subconjunto habilitado por activo concreto; +- gravedad sugerida y gravedad real 1–10; +- opción OTROS y propuesta de catálogo; +- administración de propuestas desde oficina. + +## D5.5 · Planificación web y checklist + +- selección Área → Organización operadora; +- equipo inspector y fecha; +- checklist automático de antecedentes, vencidos y próximos controles; +- activos preventivos; +- exclusiones sólo con motivo auditable; +- inicio exclusivamente desde APK. + +## E1 · Android sobre contrato estable + +Ejecución del checklist, relevamiento, hallazgos, evidencias, GPS, firmas, acta e informe. diff --git a/docs/ROADMAP_AFTER_D5_3_4.md b/docs/ROADMAP_AFTER_D5_3_4.md new file mode 100644 index 0000000..027b793 --- /dev/null +++ b/docs/ROADMAP_AFTER_D5_3_4.md @@ -0,0 +1,30 @@ +# Roadmap posterior a D5.3.4 + +## D5.4 · Hallazgos aplicables al activo + +- vincular modelos de hallazgo con tipos técnicos; +- habilitar subconjuntos por activo concreto; +- gravedad sugerida del modelo y gravedad real obligatoria 1–10; +- opción `OTROS` desde campo; +- propuesta de nuevo modelo pendiente de revisión en dashboard; +- evitar que la APK muestre controles que no correspondan al activo. + +## D5.5 · Planificación web y checklist + +- planificación desde dashboard con Área → Operadora; +- fecha y equipo inspector; +- checklist automático de hallazgos abiertos, vencidos y próximos; +- inclusión de activos preventivos; +- exclusiones sólo con motivo auditable; +- modificación web únicamente mientras la inspección no haya comenzado; +- inicio exclusivo desde APK. + +## E1 · Android sobre contrato estable + +- sincronización de planificación; +- inicio en campo; +- relevamiento y activos provisionales; +- ejecución del checklist; +- hallazgos nuevos y revisión de anteriores; +- evidencias, GPS y firmas; +- acta única e informe. diff --git a/docs/ROADMAP_AFTER_D5_3_5.md b/docs/ROADMAP_AFTER_D5_3_5.md new file mode 100644 index 0000000..67dcf70 --- /dev/null +++ b/docs/ROADMAP_AFTER_D5_3_5.md @@ -0,0 +1,34 @@ +# Roadmap posterior a D5.3.5 + +## D5.3.6 · Normalización e importación controlada + +- catálogo de familias/subtipos técnicos normalizados; +- condición técnica separada del estado operativo y del estado del dato; +- namespace y reglas de identificadores externos; +- perfiles específicos para fuentes que el perfil genérico deja en conflicto; +- asistente de mapeo de columnas/semántica; +- propuesta de jerarquía Área → Yacimiento → Instalación → Sector → Activo; +- matching contra activos existentes; +- bandeja de conflictos; +- preview de cambios; +- aplicación transaccional al Maestro sólo tras confirmación; +- rollback lógico/lote y trazabilidad fila → activo. + +## D5.4 · Hallazgos aplicables al activo + +Una vez estabilizada la normalización real: + +- modelos por familia/tipo técnico; +- subconjuntos por activo concreto; +- gravedad sugerida y gravedad real obligatoria 1–10; +- `OTROS` con propuesta de nuevo modelo; +- evitar checklists irrelevantes. + +## D5.5 · Planificación web y checklist + +- planificación Área → Operadora; +- selección de activos desde Asset Center; +- antecedentes/hallazgos abiertos; +- controles preventivos; +- exclusiones auditadas; +- inicio exclusivo desde APK. diff --git a/docs/ROADMAP_AFTER_D5_3_7.md b/docs/ROADMAP_AFTER_D5_3_7.md new file mode 100644 index 0000000..3cdb83c --- /dev/null +++ b/docs/ROADMAP_AFTER_D5_3_7.md @@ -0,0 +1,34 @@ +# Roadmap después de D5.3.7 + +## Validación inmediata + +Primero validar D5.3.7 con datos reales, sin apresurar la escritura masiva: + +1. Plan territorial de `Tablas de yacimiento.xlsx`. +2. Confirmar deduplicación de Áreas, Yacimientos, Organizaciones y relaciones, verificando que `Sin Empresa Operadora` no se cree como organización. +3. Aplicar el padrón territorial sólo con plan `READY` y conteos revisados. +4. Reconciliar PSEnergy contra ese nuevo Maestro. +5. Resolver grupos estructurales y las dos filas de advertencia del inventario. +6. Aplicar PSEnergy sólo después de revisar el plan final. + +## D5.3.8 — Perfiles y calidad de datos + +Previsto después de validar importaciones reales: + +- perfiles específicos por empresa/fuente; +- alias y denominaciones históricas de organizaciones; +- reglas de mapeo configurables para columnas y categorías; +- condición técnica separada del estado operativo; +- mejoras de namespace e identificadores externos; +- asistencia para UTE y composición histórica con revisión humana; +- mayor cobertura de clasificación técnica normalizada. + +## D5.4 — Hallazgos por familia técnica + +Con un Maestro real y normalizado: + +- asignar familias/modelos de inspección por tipo técnico; +- filtrar hallazgos pertinentes al activo inspeccionado; +- severidad sugerida y editable 1–10; +- opción `OTROS` para hallazgo nuevo en campo; +- preparación del checklist para planificación y futura APK. diff --git a/docs/ROADMAP_AFTER_D5_3_7_2.md b/docs/ROADMAP_AFTER_D5_3_7_2.md new file mode 100644 index 0000000..aa38659 --- /dev/null +++ b/docs/ROADMAP_AFTER_D5_3_7_2.md @@ -0,0 +1,34 @@ +# Roadmap después de D5.3.7.2 + +## Validación inmediata + +1. Desplegar D5.3.7.2. +2. Reconciliar nuevamente `Tablas de yacimiento.xlsx`. +3. Regenerar el plan relacional. +4. Revisar el desglose por Departamentos, Organizaciones, Áreas, Yacimientos, Área↔Departamento, Área↔Operadora y marco jurídico. +5. Confirmar que los Departamentos se deduplican y que `Sin Empresa Operadora` no existe como Organización. +6. No pulsar `APLICAR` hasta revisar conteos y detalle del plan. +7. Una vez validado el padrón territorial, aplicar en una operación controlada y verificar Maestro/rollback. +8. Reconciliar PSEnergy contra el Maestro territorial ya cargado. + +## D5.3.8 — Perfiles y calidad de datos + +Después de validar la primera importación territorial: + +- perfiles específicos por empresa/fuente; +- alias y denominaciones históricas de organizaciones; +- reglas configurables de mapeo; +- condición técnica separada del estado operativo; +- namespaces e identificadores externos más ricos; +- UTE y composición histórica asistida; +- mayor cobertura de clasificación técnica normalizada. + +## D5.4 — Hallazgos por familia técnica + +Con el Maestro real y normalizado: + +- familias/modelos de inspección por tipo técnico; +- hallazgos pertinentes por activo; +- severidad sugerida/editable 1–10; +- `OTROS` para hallazgos nuevos de campo; +- checklist para planificación y futura APK. diff --git a/docs/ROADMAP_AFTER_D5_3_9.md b/docs/ROADMAP_AFTER_D5_3_9.md new file mode 100644 index 0000000..fd826a5 --- /dev/null +++ b/docs/ROADMAP_AFTER_D5_3_9.md @@ -0,0 +1,24 @@ +# Roadmap después de D5.3.9 + +## Validación inmediata + +1. Desplegar D5.3.9. +2. Abrir el lote real de PSEnergy que está en `REVIEW_REQUIRED`. +3. Regenerar el plan para obtener los nuevos contadores `directReviewItems` y `dependencyReviewItems`. +4. Verificar que la pantalla muestre únicamente las decisiones humanas como acción principal. +5. Elegir un Área/Yacimiento ambiguo y confirmar que el plan se regenere automáticamente. +6. Confirmar que instalaciones, estructura local y padres derivados desaparezcan de la lista principal y se recalculen. +7. No aplicar el lote hasta revisar visualmente el resultado completo. + +## Siguiente paso recomendado + +Con la revisión del inventario ya comprensible, continuar con la calidad de datos del inventario real: + +- perfiles específicos por empresa/fuente; +- alias históricos de organizaciones; +- reglas configurables de mapeo; +- condición técnica separada del estado operativo; +- identifiers externos más ricos; +- mayor cobertura de clasificación técnica normalizada. + +Después de estabilizar el Maestro real, avanzar a D5.4 — Hallazgos por familia técnica. diff --git a/docs/nginx-site.conf b/docs/nginx-site.conf new file mode 100644 index 0000000..9fdb7db --- /dev/null +++ b/docs/nginx-site.conf @@ -0,0 +1,36 @@ +server { + listen 80; + listen [::]:80; + + server_name dhv2.korexlabs.com; + + client_max_body_size 30m; + + location /api/v3/ { + proxy_pass http://127.0.0.1:3101; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_connect_timeout 10s; + proxy_read_timeout 60s; + proxy_hide_header X-Powered-By; + } + + location / { + proxy_pass http://127.0.0.1:8182; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header X-Frame-Options "DENY" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=(self)" always; + + add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://tile.openstreetmap.org; connect-src 'self' https://tile.openstreetmap.org; worker-src 'self' blob:; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'" always; +} diff --git a/infra/db-init/001-init.sh b/infra/db-init/001-init.sh new file mode 100755 index 0000000..009fd15 --- /dev/null +++ b/infra/db-init/001-init.sh @@ -0,0 +1,38 @@ +\ +#!/usr/bin/env bash +set -Eeuo pipefail + +: "${POSTGRES_USER:?}" +: "${POSTGRES_DB:?}" +: "${APP_DB_USER:?}" +: "${APP_DB_PASSWORD:?}" + +psql -v ON_ERROR_STOP=1 \ + --username "$POSTGRES_USER" \ + --dbname "$POSTGRES_DB" <&2; exit 1; } + +cd "$ROOT" +[[ -f docker-compose.yml ]] || fail "No existe docker-compose.yml en $ROOT" +[[ -f api-v3/package.json ]] || fail "No se encontró api-v3/package.json" + +FREE_KB="$(df -Pk "$ROOT" | awk 'NR==2 {print $4}')" +[[ "$FREE_KB" =~ ^[0-9]+$ ]] || fail "No se pudo determinar espacio libre" +(( FREE_KB >= MIN_FREE_KB )) || fail "Se requieren al menos 12 GB libres para el backup fuerte; hay $(( FREE_KB / 1024 / 1024 )) GB" + +DB_HEALTH="$(docker inspect dhv2-db --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' 2>/dev/null || true)" +[[ "$DB_HEALTH" == "healthy" ]] || fail "PostgreSQL no está healthy (estado: $DB_HEALTH)" +docker compose exec -T db sh -lc 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' >/dev/null + +umask 077 +mkdir -p "$BACKUP_DIR" +chmod 700 "$BACKUP_DIR" + +API_VERSION="$(node -p "require('./api-v3/package.json').version")" +WEB_VERSION="$(node -p "require('./web-v2/package.json').version")" +PHASE="$(sed -n "s/.*API_PHASE = '\([^']*\)'.*/\1/p" api-v3/src/version.ts | head -n1)" + +cat > "$MARKER" < "$BACKUP_DIR/database.dump" +[[ -s "$BACKUP_DIR/database.dump" ]] || fail "database.dump quedó vacío" +docker compose exec -T db sh -lc 'pg_restore -l' < "$BACKUP_DIR/database.dump" > "$BACKUP_DIR/database-list.txt" +[[ -s "$BACKUP_DIR/database-list.txt" ]] || fail "No se pudo validar database.dump" +cat <<'SQL' | docker compose exec -T db sh -lc 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -v ON_ERROR_STOP=1' > "$BACKUP_DIR/inventory-state.txt" +\echo '========== INVENTARIO A REVISAR · ESTADO DEL MAESTRO ==========' +SELECT count(*) AS assets_total FROM assets; +SELECT type.code,type.name,count(asset.id) AS cantidad FROM asset_types type LEFT JOIN assets asset ON asset.asset_type_id=type.id AND asset.information_status<>'INACTIVE' GROUP BY type.id,type.code,type.name ORDER BY type.code; +\echo '' +\echo '========== IMPORTACIONES ==========' +SELECT batch.id,batch.original_name,batch.status,plan.revision,plan.status AS plan_status,plan.applied_at FROM asset_import_batches batch LEFT JOIN LATERAL (SELECT revision,status,applied_at FROM asset_import_plans p WHERE p.batch_id=batch.id ORDER BY revision DESC LIMIT 1) plan ON true ORDER BY batch.created_at DESC; +SQL + +printf '%s\n' "[2/8] Source" +tar \ + --exclude='api-v3/node_modules' \ + --exclude='api-v3/dist' \ + --exclude='web-v2/node_modules' \ + --exclude='web-v2/dist' \ + --exclude='.git' \ + --exclude='DH_V2_FASE_*.zip' \ + -czf "$BACKUP_DIR/source.tar.gz" \ + api-v3 web-v2 docs scripts infra docker-compose.yml README.md .env.example +[[ -s "$BACKUP_DIR/source.tar.gz" ]] || fail "source.tar.gz quedó vacío" +tar -tzf "$BACKUP_DIR/source.tar.gz" >/dev/null + +printf '%s\n' "[3/8] Configuración privada" +cp -a docker-compose.yml "$BACKUP_DIR/docker-compose.yml" +if [[ -f .env ]]; then + cp -a .env "$BACKUP_DIR/.env" + chmod 600 "$BACKUP_DIR/.env" +fi + +printf '%s\n' "[4/8] Asset media / archivos importados" +docker volume inspect dhv2_asset_media >/dev/null +# nginx:alpine ya forma parte del stack web y aporta tar/gzip BusyBox sin instalar nada en el host. +docker run --rm \ + -v dhv2_asset_media:/source:ro \ + -v "$BACKUP_DIR:/backup" \ + --entrypoint sh nginx:alpine \ + -c 'cd /source && tar -czf /backup/asset-media.tar.gz .' +[[ -s "$BACKUP_DIR/asset-media.tar.gz" ]] || fail "asset-media.tar.gz quedó vacío" +tar -tzf "$BACKUP_DIR/asset-media.tar.gz" >/dev/null + +printf '%s\n' "[5/8] Imágenes Docker exactas" +for image in dhv2-api:latest dhv2-web:latest dhv2-migrate:latest; do + docker image inspect "$image" >/dev/null || fail "Falta la imagen $image" +done +docker image save dhv2-api:latest dhv2-web:latest dhv2-migrate:latest | gzip -1 > "$BACKUP_DIR/docker-images.tar.gz" +[[ -s "$BACKUP_DIR/docker-images.tar.gz" ]] || fail "docker-images.tar.gz quedó vacío" +gzip -t "$BACKUP_DIR/docker-images.tar.gz" + +printf '%s\n' "[6/8] Estado operativo" +docker compose ps -a > "$BACKUP_DIR/docker-status.txt" +for image in dhv2-api:latest dhv2-web:latest dhv2-migrate:latest; do docker image inspect "$image" --format '{{.RepoTags}} {{.Id}} {{.Created}}'; done > "$BACKUP_DIR/docker-images.txt" +docker compose --profile tools run --rm migrate npm run migration:show > "$BACKUP_DIR/migrations.txt" 2>&1 +grep -Fq 'Pending migrations: no' "$BACKUP_DIR/migrations.txt" || fail "Hay migraciones pendientes; backup no se marca como aceptado" +curl -fsS http://127.0.0.1:3101/api/v3/health > "$BACKUP_DIR/health.json" + +printf '%s\n' "[7/8] Runbook de restauración" +cat > "$BACKUP_DIR/RESTORE_INVENTARIO_A_REVISAR.txt" <<'TXT' +RESTAURACIÓN — INVENTARIO A REVISAR + +No ejecutar estas acciones durante operación normal. Este paquete es un punto de retorno. + +Contenido principal: +- database.dump PostgreSQL completo +- source.tar.gz código fuente previo +- asset-media.tar.gz volumen de archivos/importaciones +- docker-images.tar.gz imágenes exactas api/web/migrate +- .env secretos/configuración previa (si existía) +- docker-compose.yml compose previo + +Orden recomendado de recuperación: +1) detener api/web/migrate y mantener una copia del estado actual; +2) restaurar source.tar.gz y .env/docker-compose.yml; +3) cargar docker-images.tar.gz con: gzip -dc docker-images.tar.gz | docker image load; +4) restaurar PostgreSQL desde database.dump con el usuario owner; +5) restaurar dhv2_asset_media desde asset-media.tar.gz; +6) recrear api/web y verificar /api/v3/health y migration:show. + +La restauración destructiva debe hacerse sólo después de revisar el incidente y conservar un backup del estado que se reemplazará. +TXT +chmod 600 "$BACKUP_DIR/RESTORE_INVENTARIO_A_REVISAR.txt" + +printf '%s\n' "[8/8] Integridad SHA-256" +cat > "$BACKUP_DIR/BACKUP_OK.txt" < "$BACKUP_DIR/SHA256SUMS.txt" +chmod 600 "$BACKUP_DIR/SHA256SUMS.txt" +( + cd "$BACKUP_DIR" + sha256sum -c SHA256SUMS.txt >/dev/null +) || fail "Falló la validación SHA-256" + +printf '\nBACKUP FUERTE INVENTARIO A REVISAR: OK\n' +printf 'Ruta: %s\n' "$BACKUP_DIR" +du -sh "$BACKUP_DIR" diff --git a/scripts/backup-phase-a.sh b/scripts/backup-phase-a.sh new file mode 100755 index 0000000..65d0e4b --- /dev/null +++ b/scripts/backup-phase-a.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail + +dhv2_project="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +dhv2_label="${1:-manual}" +dhv2_stamp="$(date +%Y%m%d_%H%M%S)" + +if [[ ! "$dhv2_label" =~ ^[a-zA-Z0-9_-]{1,40}$ ]]; then + echo "Etiqueta de backup inválida" >&2 + exit 1 +fi + +dhv2_backup="/var/www/dh-backup/dhv2-${dhv2_label}-${dhv2_stamp}" +install -d -m 700 "$dhv2_backup" +cd "$dhv2_project" + +test -f .env || { echo "No se encontró $dhv2_project/.env" >&2; exit 1; } +docker compose ps --status running >/dev/null + +tar \ + --exclude='*.zip' \ + --exclude='.env' \ + --exclude='*/node_modules' \ + --exclude='*/dist' \ + --exclude='*.tsbuildinfo' \ + -czf "$dhv2_backup/source.tar.gz" . + +install -m 600 .env "$dhv2_backup/.env" + +docker compose exec -T db sh -lc \ + 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' \ + > "$dhv2_backup/database.dump" + +dhv2_media_file="" +if docker volume inspect dhv2_asset_media >/dev/null 2>&1; then + dhv2_media_file="asset-media.tar.gz" + docker compose exec -T api sh -lc \ + 'test -d "$ASSET_MEDIA_ROOT" && tar -czf - -C "$ASSET_MEDIA_ROOT" .' \ + > "$dhv2_backup/$dhv2_media_file" + chmod 600 "$dhv2_backup/$dhv2_media_file" +fi + +dhv2_nginx_file="" +for dhv2_candidate in \ + /etc/nginx/sites-available/dhv2.korexlabs.com \ + /etc/nginx/sites-available/dhv2.korexlabs.com.conf \ + /etc/nginx/sites-enabled/dhv2.korexlabs.com \ + /etc/nginx/sites-enabled/dhv2.korexlabs.com.conf; do + if [ -f "$dhv2_candidate" ]; then + dhv2_nginx_file="nginx-site.conf" + install -m 600 "$dhv2_candidate" "$dhv2_backup/$dhv2_nginx_file" + break + fi +done + +if [ -z "$dhv2_nginx_file" ]; then + dhv2_nginx_file="NGINX_CONFIG_NOT_FOUND.txt" + printf '%s\n' 'No se encontró una configuración Nginx en las rutas conocidas.' > "$dhv2_backup/$dhv2_nginx_file" + chmod 600 "$dhv2_backup/$dhv2_nginx_file" +fi + +{ + echo "Proyecto: DH Inspección V2" + echo "Fase: D5.2" + echo "Versión: 0.19.2" + echo "Fecha UTC: $(date -u +%Y-%m-%dT%H:%M:%SZ)" + echo "Origen: $dhv2_project" + echo "Base: dump PostgreSQL custom format" + if [ -n "$dhv2_media_file" ]; then + echo "Archivos protegidos de activos e inspecciones: $dhv2_media_file" + else + echo "Archivos protegidos: volumen todavía inexistente" + fi + echo + echo "Contenedores al generar el backup:" + docker compose ps +} > "$dhv2_backup/MANIFEST.txt" +chmod 600 "$dhv2_backup/MANIFEST.txt" + +( + cd "$dhv2_backup" + dhv2_checksum_files=(source.tar.gz database.dump .env "$dhv2_nginx_file" MANIFEST.txt) + if [ -n "$dhv2_media_file" ]; then + dhv2_checksum_files+=("$dhv2_media_file") + fi + sha256sum "${dhv2_checksum_files[@]}" > SHA256SUMS +) +chmod 600 "$dhv2_backup/SHA256SUMS" + +echo "$dhv2_backup" diff --git a/scripts/check-dashboard-d5-6-3.sh b/scripts/check-dashboard-d5-6-3.sh new file mode 100755 index 0000000..f0d7505 --- /dev/null +++ b/scripts/check-dashboard-d5-6-3.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +ROOT="${ROOT:-/var/www/dhv2.korexlabs.com}" +cd "$ROOT" + +[[ "$(node -p "require('./web-v2/package.json').version")" == "0.19.6-3" ]] +grep -Fq "APP_PHASE = 'Fase D5.6.3 · Navegación contextual'" web-v2/src/config/version.ts +grep -Fq 'OperationalContextBar' web-v2/src/layout/AppLayout.tsx +grep -Fq 'OperationalContextProvider' web-v2/src/app/App.tsx +grep -Fq 'path="/planificacion"' web-v2/src/app/App.tsx +! grep -Fq "label: 'Planificación'" web-v2/src/layout/AppLayout.tsx +grep -Fq 'Planificar inspección' web-v2/src/pages/InspectionVisitsPage.tsx +grep -Fq 'inspection-status-tabs' web-v2/src/pages/InspectionVisitsPage.tsx +grep -Fq 'useOperationalContext' web-v2/src/pages/AssetsPage.tsx +grep -Fq 'useOperationalContext' web-v2/src/pages/ActsPage.tsx +grep -Fq 'useOperationalContext' web-v2/src/pages/ReportsPage.tsx +grep -Fq 'useOperationalContext' web-v2/src/pages/FindingsPage.tsx +grep -Fq 'operationalContext.areaId' web-v2/src/pages/InspectionVisitEditorPage.tsx + +echo 'D5.6.3 dashboard checks: 13/13 OK' diff --git a/scripts/check-phase-d5-6-4.sh b/scripts/check-phase-d5-6-4.sh new file mode 100755 index 0000000..8b538df --- /dev/null +++ b/scripts/check-phase-d5-6-4.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" +cd "$ROOT" + +checks=0 +ok() { checks=$((checks+1)); echo "OK $1"; } +fail() { echo "FAIL $1"; exit 1; } + +[[ "$(node -p "require('./api-v3/package.json').version")" == "0.19.6-4" ]] || fail 'API 0.19.6-4' +ok 'API 0.19.6-4' +[[ "$(node -p "require('./web-v2/package.json').version")" == "0.19.6-4" ]] || fail 'WEB 0.19.6-4' +ok 'WEB 0.19.6-4' +grep -Fq "API_PHASE = 'D5.6.4'" api-v3/src/version.ts || fail 'fase API' +ok 'fase API D5.6.4' +grep -Fq "Fase D5.6.4 · Inspecciones simples y combobox buscables" web-v2/src/config/version.ts || fail 'fase WEB' +ok 'fase WEB D5.6.4' + +DTO='api-v3/src/inspection-visits/dto/create-inspection-visit.dto.ts' +grep -Fq 'operationalAreaId!' "$DTO" && grep -Fq 'operatorCompanyId!' "$DTO" && grep -Fq 'plannedStartAt!' "$DTO" && grep -Fq 'leadInspectorUserId!' "$DTO" || fail 'DTO mínimo' +! grep -Eq 'code!|title!|plannedEndAt' "$DTO" || fail 'DTO conserva campos eliminados' +ok 'creación mínima Área + Operadora + inicio + Inspector' + +grep -Fq 'pg_advisory_xact_lock' api-v3/src/inspection-visits/inspection-visit-code.ts || fail 'lock código' +grep -Fq 'America/Argentina/Mendoza' api-v3/src/inspection-visits/inspection-visit-code.ts || fail 'zona código' +grep -Fq 'INS-${year}-${String(sequence).padStart(6, '\''0'\'')}' api-v3/src/inspection-visits/inspection-visit-code.ts || fail 'formato código' +ok 'código INS anual automático y transaccional' + +grep -Fq 'title: code' api-v3/src/inspection-visits/inspection-visits.service.ts || fail 'título interno' +grep -Fq 'plannedEndAt: null' api-v3/src/inspection-visits/inspection-visits.service.ts || fail 'fin null' +ok 'sin título editable ni fin planificado nuevo' + +grep -Fq 'leadInspectorUserId: dto.leadInspectorUserId' api-v3/src/inspection-visits/inspection-visits.service.ts || fail 'lead create' +grep -Fq 'await this.replaceMemberLinks(' api-v3/src/inspection-visits/inspection-visits.service.ts || fail 'team create' +grep -Fq 'generateChecklistInternal(manager, visit, principal.userId)' api-v3/src/inspection-visits/inspection-visits.service.ts || fail 'checklist create' +ok 'inspector + equipo + checklist en creación' + +grep -Fq "@RequirePermissions('inspections.manage', 'inspections.assign')" api-v3/src/inspection-visits/inspection-visits.controller.ts || fail 'permisos create' +ok 'creación requiere planificación y asignación' + +! grep -Fq 'plannedEndAt' api-v3/src/inspection-verifications/dto/plan-verification-visit.dto.ts || fail 'fin verificación DTO' +grep -Fq 'nextInspectionVisitCode(manager, plannedStartAt)' api-v3/src/inspection-verifications/inspection-verifications.service.ts || fail 'código verificación' +ok 'verificaciones usan misma numeración y sólo inicio' + +grep -Fq 'CREACIÓN RÁPIDA' web-v2/src/pages/InspectionVisitEditorPage.tsx || fail 'creación rápida UI' +grep -Fq 'Fecha y hora de inicio' web-v2/src/pages/InspectionVisitEditorPage.tsx || fail 'inicio UI' +grep -Fq 'Buscar Inspector…' web-v2/src/pages/InspectionVisitEditorPage.tsx || fail 'inspector buscable' +! grep -Fq 'Fin previsto' web-v2/src/pages/InspectionVisitEditorPage.tsx || fail 'fin UI' +! grep -Fq 'Título' web-v2/src/pages/InspectionVisitEditorPage.tsx || fail 'título UI' +ok 'formulario rápido de Inspección' + +grep -Fq 'searchable-select-search' web-v2/src/styles.css || fail 'estilos combobox' +test -f web-v2/src/components/SearchableSelect.tsx || fail 'componente combobox' +ok 'componente de combobox buscable' + +if grep -R '/dev/null; then + echo 'Quedaron selects nativos:' + grep -R '= 59 )) || fail 'cantidad combobox migrados' +ok "$COUNT usos de SearchableSelect" + +if find api-v3/src/database/migrations -maxdepth 1 -type f -iname '*d5-6-4*' | grep -q .; then fail 'migración inesperada'; fi +ok 'sin migración D5.6.4' + +echo +echo "D5.6.4 checks: $checks/$checks OK" diff --git a/scripts/deploy-dashboard-d5-6-3.sh b/scripts/deploy-dashboard-d5-6-3.sh new file mode 100755 index 0000000..43bde33 --- /dev/null +++ b/scripts/deploy-dashboard-d5-6-3.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT="${ROOT:-/var/www/dhv2.korexlabs.com}" +ZIP="${ZIP:-$ROOT/DH_V2_PATCH_DASHBOARD_D5_6_3.zip}" +STAMP="$(date +%Y%m%d_%H%M%S)" +STAGE="/root/dhv2-dashboard-d563-stage-$STAMP" +BACKUP_ROOT="/root/DH_V2_BACKUPS" +PRE_BACKUP="$BACKUP_ROOT/D5_6_3_DASH_PRE_$STAMP" +POST_BACKUP="$BACKUP_ROOT/D5_6_3_DASH_OK_$STAMP" +TEST_IMAGE="dhv2-web:d563-test" + +cleanup() { + rm -rf "$STAGE" + docker image rm "$TEST_IMAGE" >/dev/null 2>&1 || true +} +trap cleanup EXIT +trap 'echo; echo "ERROR D5.6.3 dashboard en línea $LINENO: $BASH_COMMAND"' ERR + +cd "$ROOT" +test -f "$ZIP" +CURRENT_WEB="$(node -p "require('./web-v2/package.json').version")" +[[ "$CURRENT_WEB" == "0.19.6-2" ]] || { echo "ERROR: este patch requiere WEB 0.19.6-2. Actual: $CURRENT_WEB"; exit 1; } + +echo '[1/7] Staging del patch' +mkdir -p "$STAGE" +cp -a web-v2 "$STAGE/web-v2" +unzip -oq "$ZIP" 'web-v2/*' -d "$STAGE" +[[ "$(node -p "require('$STAGE/web-v2/package.json').version")" == "0.19.6-3" ]] +grep -Fq "APP_PHASE = 'Fase D5.6.3 · Navegación contextual'" "$STAGE/web-v2/src/config/version.ts" + +echo '[2/7] Build web en staging' +docker build -t "$TEST_IMAGE" "$STAGE/web-v2" + +echo '[3/7] Validaciones de navegación' +grep -Fq 'OperationalContextBar' "$STAGE/web-v2/src/layout/AppLayout.tsx" +grep -Fq 'OperationalContextProvider' "$STAGE/web-v2/src/app/App.tsx" +grep -Fq 'path="/planificacion"' "$STAGE/web-v2/src/app/App.tsx" +! grep -Fq "label: 'Planificación'" "$STAGE/web-v2/src/layout/AppLayout.tsx" +grep -Fq 'Planificar inspección' "$STAGE/web-v2/src/pages/InspectionVisitsPage.tsx" +grep -Fq 'inspection-status-tabs' "$STAGE/web-v2/src/pages/InspectionVisitsPage.tsx" +grep -Fq 'useOperationalContext' "$STAGE/web-v2/src/pages/AssetsPage.tsx" +grep -Fq 'useOperationalContext' "$STAGE/web-v2/src/pages/ActsPage.tsx" +grep -Fq 'useOperationalContext' "$STAGE/web-v2/src/pages/ReportsPage.tsx" +grep -Fq 'useOperationalContext' "$STAGE/web-v2/src/pages/FindingsPage.tsx" +grep -Fq 'operationalContext.areaId' "$STAGE/web-v2/src/pages/InspectionVisitEditorPage.tsx" + +echo '[4/7] Backup PRE del dashboard' +mkdir -p "$PRE_BACKUP"; chmod 700 "$PRE_BACKUP" +tar -czf "$PRE_BACKUP/web-v2-source.tar.gz" --exclude='web-v2/node_modules' --exclude='web-v2/dist' web-v2 +printf 'WEB previa=%s\nAPI sin cambios=%s\n' "$CURRENT_WEB" "$(node -p "require('./api-v3/package.json').version")" > "$PRE_BACKUP/BASE_VERSION.txt" +( cd "$PRE_BACKUP" && sha256sum web-v2-source.tar.gz BASE_VERSION.txt > SHA256SUMS.txt && sha256sum -c SHA256SUMS.txt ) + +echo '[5/7] Aplicando patch incremental' +unzip -oq "$ZIP" 'web-v2/*' 'scripts/check-dashboard-d5-6-3.sh' 'scripts/deploy-dashboard-d5-6-3.sh' -d "$ROOT" +chmod +x scripts/check-dashboard-d5-6-3.sh scripts/deploy-dashboard-d5-6-3.sh +bash scripts/check-dashboard-d5-6-3.sh + +echo '[6/7] Build y recreación del dashboard' +docker compose build web +docker compose up -d --force-recreate web +for i in $(seq 1 30); do + WEB_ADDR="$(docker compose port web 80 2>/dev/null | tail -1)" + WEB_PORT="${WEB_ADDR##*:}" + if [[ "$WEB_PORT" =~ ^[0-9]+$ ]] && curl -fsS "http://127.0.0.1:$WEB_PORT/" >/dev/null 2>&1; then break; fi + if [[ "$i" == "30" ]]; then docker compose logs --tail=160 web; exit 1; fi + sleep 1 +done + +echo '[7/7] Backup POST' +mkdir -p "$POST_BACKUP"; chmod 700 "$POST_BACKUP" +tar -czf "$POST_BACKUP/web-v2-source.tar.gz" --exclude='web-v2/node_modules' --exclude='web-v2/dist' web-v2 +cp -a "$ZIP" "$POST_BACKUP/" +printf 'D5.6.3 DASHBOARD OK\nWEB final=0.19.6-3\nAPI sin cambios=%s\n' "$(node -p "require('./api-v3/package.json').version")" > "$POST_BACKUP/BACKUP_OK.txt" +( cd "$POST_BACKUP" && sha256sum web-v2-source.tar.gz "$(basename "$ZIP")" BACKUP_OK.txt > SHA256SUMS.txt && sha256sum -c SHA256SUMS.txt ) + +echo +echo 'D5.6.3 DASHBOARD: OK' +echo 'WEB: 0.19.6-3' +echo "API: $(node -p "require('./api-v3/package.json').version") (sin cambios)" +echo "Backup PRE: $PRE_BACKUP" +echo "Backup POST: $POST_BACKUP" diff --git a/scripts/deploy-phase-d5-3-1.sh b/scripts/deploy-phase-d5-3-1.sh new file mode 100755 index 0000000..40c4a4b --- /dev/null +++ b/scripts/deploy-phase-d5-3-1.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT="${ROOT:-/var/www/dhv2.korexlabs.com}" +ZIP="${ZIP:-$ROOT/DH_V2_FASE_D5_3_1_SOURCE.zip}" +STAMP="$(date +%Y%m%d_%H%M%S)" +STAGE="/root/dhv2-d531-stage-$STAMP" +BACKUP_ROOT="/root/DH_V2_BACKUPS" +PRE_BACKUP="$BACKUP_ROOT/D5_3_1_PRE_$STAMP" +POST_BACKUP="$BACKUP_ROOT/D5_3_1_OK_$STAMP" + +cleanup() { rm -rf "$STAGE"; } +trap cleanup EXIT +trap 'echo; echo "ERROR D5.3.1 en línea $LINENO: $BASH_COMMAND"' ERR + +cd "$ROOT" +test -f "$ZIP" + +echo "[1/10] Preparando staging" +mkdir -p "$STAGE" +unzip -q "$ZIP" -d "$STAGE" + +echo "[2/10] Compilando API en staging" +docker build --target builder -t dhv2-api:d531-test "$STAGE/api-v3" + +echo "[3/10] Ejecutando tests API en staging" +docker run --rm \ + -v "$STAGE/api-v3/test:/app/test:ro" \ + -v "$STAGE/api-v3/tsconfig.test.json:/app/tsconfig.test.json:ro" \ + dhv2-api:d531-test npm test + +echo "[4/10] Compilando web en staging" +docker build -t dhv2-web:d531-test "$STAGE/web-v2" + +echo "[5/10] Backup PRE" +mkdir -p "$PRE_BACKUP" +docker compose exec -T db sh -lc \ + 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' \ + > "$PRE_BACKUP/database.dump" +tar \ + --exclude='api-v3/node_modules' --exclude='api-v3/dist' \ + --exclude='web-v2/node_modules' --exclude='web-v2/dist' \ + --exclude='.git' --exclude='DH_V2_FASE_*.zip' \ + -czf "$PRE_BACKUP/source.tar.gz" \ + api-v3 web-v2 docs scripts infra docker-compose.yml README.md .env.example + +if [[ -f .env ]]; then cp -a .env "$PRE_BACKUP/.env"; chmod 600 "$PRE_BACKUP/.env"; fi + +echo "[6/10] Instalando source" +unzip -oq "$ZIP" -d "$ROOT" +chmod +x "$ROOT/scripts/phase-d5-3-1-acceptance.sh" "$ROOT/scripts/deploy-phase-d5-3-1.sh" + +echo "API source: $(node -p "require('./api-v3/package.json').version")" +echo "WEB source: $(node -p "require('./web-v2/package.json').version")" + +echo "[7/10] Construyendo imágenes finales" +docker compose build api migrate web + +echo "[8/10] Verificando que no haya migraciones nuevas" +MIGRATION_OUTPUT="$(docker compose --profile tools run --rm migrate npm run migration:show 2>&1)" +echo "$MIGRATION_OUTPUT" +grep -q 'Pending migrations: no' <<<"$MIGRATION_OUTPUT" + +echo "[9/10] Recreando API y web" +docker compose up -d --force-recreate api web + +for i in $(seq 1 40); do + if curl -fsS http://127.0.0.1:3101/api/v3/health >/tmp/dhv2-d531-health.json 2>/dev/null; then + break + fi + if [[ "$i" == "40" ]]; then + docker compose logs --tail=200 api + exit 1 + fi + sleep 2 +done +cat /tmp/dhv2-d531-health.json; echo + +echo "[10/10] Aceptación y backup POST" +bash "$ROOT/scripts/phase-d5-3-1-acceptance.sh" +mkdir -p "$POST_BACKUP" +docker compose exec -T db sh -lc \ + 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' \ + > "$POST_BACKUP/database.dump" +docker compose ps -a > "$POST_BACKUP/docker-status.txt" +cp -a "$ZIP" "$POST_BACKUP/" +if [[ -f .env ]]; then cp -a .env "$POST_BACKUP/.env"; chmod 600 "$POST_BACKUP/.env"; fi +sha256sum "$POST_BACKUP/$(basename "$ZIP")" > "$POST_BACKUP/SHA256SUMS.txt" + +echo +echo "D5.3.1 desplegada correctamente" +echo "Backup PRE : $PRE_BACKUP" +echo "Backup POST: $POST_BACKUP" diff --git a/scripts/deploy-phase-d5-3-10-2.sh b/scripts/deploy-phase-d5-3-10-2.sh new file mode 100755 index 0000000..f217eab --- /dev/null +++ b/scripts/deploy-phase-d5-3-10-2.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT="${ROOT:-/var/www/dhv2.korexlabs.com}" +ZIP="${ZIP:-$ROOT/DH_V2_FASE_D5_3_10_2_SOURCE.zip}" +STAMP="$(date +%Y%m%d_%H%M%S)" +STAGE="/root/dhv2-d53102-stage-$STAMP" +BACKUP_ROOT="/root/DH_V2_BACKUPS" +PRE_BACKUP="$BACKUP_ROOT/D5_3_10_2_PRE_$STAMP" +POST_BACKUP="$BACKUP_ROOT/D5_3_10_2_OK_$STAMP" +MIGRATION_LOG="/tmp/dhv2-d53102-migration-$STAMP.log" +HEALTH_FILE="/tmp/dhv2-d53102-health-$STAMP.json" +MIN_FREE_KB=$((4 * 1024 * 1024)) + +cleanup() { + rm -rf "$STAGE" + rm -f "$MIGRATION_LOG" "$HEALTH_FILE" + docker image rm dhv2-api:d53102-test dhv2-web:d53102-test >/dev/null 2>&1 || true +} +trap cleanup EXIT +trap 'echo; echo "ERROR D5.3.10.2 en línea $LINENO: $BASH_COMMAND"' ERR + +cd "$ROOT" +test -f "$ZIP" + +echo "[1/10] Preflight y staging" +FREE_KB="$(df -Pk "$ROOT" | awk 'NR==2 {print $4}')" +if [[ ! "$FREE_KB" =~ ^[0-9]+$ ]] || (( FREE_KB < MIN_FREE_KB )); then + echo "ERROR: espacio libre insuficiente. Libre: $(( FREE_KB / 1024 )) MB" + exit 1 +fi +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}' 2>/dev/null || true)" == "healthy" ]] +docker compose exec -T db sh -lc 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' +mkdir -p "$STAGE" +unzip -q "$ZIP" -d "$STAGE" +[[ "$(node -p "require('$STAGE/api-v3/package.json').version")" == "0.19.3-10.2" ]] +[[ "$(node -p "require('$STAGE/web-v2/package.json').version")" == "0.19.3-10.2" ]] +grep -Fq "API_PHASE = 'D5.3.10.2'" "$STAGE/api-v3/src/version.ts" + +echo "[2/10] Compilando API en staging" +docker build --target builder -t dhv2-api:d53102-test "$STAGE/api-v3" + +echo "[3/10] Ejecutando tests API en staging" +docker run --rm \ + -v "$STAGE/api-v3/test:/app/test:ro" \ + -v "$STAGE/api-v3/tsconfig.test.json:/app/tsconfig.test.json:ro" \ + dhv2-api:d53102-test npm test + +echo "[4/10] Compilando web en staging" +docker build -t dhv2-web:d53102-test "$STAGE/web-v2" + +echo "[5/10] Backup PRE D5.3.10.2" +mkdir -p "$PRE_BACKUP" +chmod 700 "$PRE_BACKUP" +docker compose exec -T db sh -lc 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > "$PRE_BACKUP/database.dump" +tar -czf "$PRE_BACKUP/source.tar.gz" \ + --exclude='api-v3/node_modules' --exclude='api-v3/dist' \ + --exclude='web-v2/node_modules' --exclude='web-v2/dist' \ + api-v3 web-v2 scripts docs docker-compose.yml .env.example .gitignore +if [[ -f .env ]]; then cp -a .env "$PRE_BACKUP/.env"; chmod 600 "$PRE_BACKUP/.env"; fi +STRONG_BACKUP="$(find "$BACKUP_ROOT" -maxdepth 1 -type d -name 'INVENTARIO_A_REVISAR_*' -printf '%T@ %p\n' | sort -nr | head -n1 | cut -d' ' -f2-)" +printf 'Backup fuerte conceptual previo: %s\nPalabra clave: INVENTARIO A REVISAR\n' "$STRONG_BACKUP" > "$PRE_BACKUP/INVENTARIO_A_REVISAR_REFERENCE.txt" +( + cd "$PRE_BACKUP" + sha256sum database.dump source.tar.gz INVENTARIO_A_REVISAR_REFERENCE.txt > SHA256SUMS.txt + [[ ! -f .env ]] || sha256sum .env >> SHA256SUMS.txt + sha256sum -c SHA256SUMS.txt +) + +echo "[6/10] Instalando source D5.3.10.2" +unzip -oq "$ZIP" -d "$ROOT" +chmod +x "$ROOT/scripts/deploy-phase-d5-3-10-2.sh" "$ROOT/scripts/phase-d5-3-10-2-acceptance.sh" +echo "API source: $(node -p "require('./api-v3/package.json').version")" +echo "WEB source: $(node -p "require('./web-v2/package.json').version")" + +echo "[7/10] Construyendo imágenes finales" +docker compose build api migrate web + +echo "[8/10] Verificando que D5.3.10.2 no tenga migraciones pendientes" +docker compose --profile tools run --rm migrate npm run migration:show 2>&1 | tee "$MIGRATION_LOG" +grep -Fq 'Pending migrations: no' "$MIGRATION_LOG" + +echo "[9/10] Recreando API/web y aceptación" +docker compose up -d --force-recreate api web +for i in $(seq 1 40); do + if curl -fsS http://127.0.0.1:3101/api/v3/health >"$HEALTH_FILE" 2>/dev/null; then break; fi + if [[ "$i" == "40" ]]; then docker compose logs --tail=200 api; exit 1; fi + sleep 2 +done +cat "$HEALTH_FILE"; echo +grep -Fq '"version":"0.19.3-10.2"' "$HEALTH_FILE" +grep -Fq '"phase":"D5.3.10.2"' "$HEALTH_FILE" +grep -Fq '"database":"ok"' "$HEALTH_FILE" +bash "$ROOT/scripts/phase-d5-3-10-2-acceptance.sh" + +echo "[10/10] Backup POST" +mkdir -p "$POST_BACKUP" +chmod 700 "$POST_BACKUP" +docker compose exec -T db sh -lc 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > "$POST_BACKUP/database.dump" +docker compose ps -a > "$POST_BACKUP/docker-status.txt" +cp -a "$ZIP" "$POST_BACKUP/" +if [[ -f .env ]]; then cp -a .env "$POST_BACKUP/.env"; chmod 600 "$POST_BACKUP/.env"; fi +printf 'PRE D5.3.10.2: %s\nINVENTARIO A REVISAR: %s\n' "$PRE_BACKUP" "$STRONG_BACKUP" > "$POST_BACKUP/BACKUP_REFERENCES.txt" +( + cd "$POST_BACKUP" + sha256sum database.dump "$(basename "$ZIP")" BACKUP_REFERENCES.txt > SHA256SUMS.txt + [[ ! -f .env ]] || sha256sum .env >> SHA256SUMS.txt +) + +echo +echo "D5.3.10.2 desplegada correctamente" +echo "Backup PRE: $PRE_BACKUP" +echo "Backup fuerte INVENTARIO A REVISAR: $STRONG_BACKUP" +echo "Backup POST: $POST_BACKUP" diff --git a/scripts/deploy-phase-d5-3-11-1.sh b/scripts/deploy-phase-d5-3-11-1.sh new file mode 100755 index 0000000..0a9acdb --- /dev/null +++ b/scripts/deploy-phase-d5-3-11-1.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT="${ROOT:-/var/www/dhv2.korexlabs.com}" +ZIP="${ZIP:-$ROOT/DH_V2_FASE_D5_3_11_1_SOURCE.zip}" +STAMP="$(date +%Y%m%d_%H%M%S)" +STAGE="/root/dhv2-d53111-stage-$STAMP" +BACKUP_ROOT="/root/DH_V2_BACKUPS" +PRE_BACKUP="$BACKUP_ROOT/D5_3_11_1_PRE_$STAMP" +POST_BACKUP="$BACKUP_ROOT/D5_3_11_1_OK_$STAMP" +MIGRATION_LOG="/tmp/dhv2-d53111-migration-$STAMP.log" +HEALTH_FILE="/tmp/dhv2-d53111-health-$STAMP.json" +MIN_FREE_KB=$((4 * 1024 * 1024)) + +cleanup() { + rm -rf "$STAGE" + rm -f "$MIGRATION_LOG" "$HEALTH_FILE" + docker image rm dhv2-api:d53111-test dhv2-web:d53111-test >/dev/null 2>&1 || true +} +trap cleanup EXIT +trap 'echo; echo "ERROR D5.3.11.1 en línea $LINENO: $BASH_COMMAND"' ERR + +cd "$ROOT" +test -f "$ZIP" + +CURRENT_API="$(node -p "require('./api-v3/package.json').version")" +CURRENT_WEB="$(node -p "require('./web-v2/package.json').version")" +[[ "$CURRENT_API" == "0.19.3-11" ]] +[[ "$CURRENT_WEB" == "0.19.3-11" ]] + +echo "[1/10] Preflight y staging" +FREE_KB="$(df -Pk "$ROOT" | awk 'NR==2 {print $4}')" +if [[ ! "$FREE_KB" =~ ^[0-9]+$ ]] || (( FREE_KB < MIN_FREE_KB )); then + echo "ERROR: espacio libre insuficiente. Libre: $(( FREE_KB / 1024 )) MB" + exit 1 +fi +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}' 2>/dev/null || true)" == "healthy" ]] +docker compose exec -T db sh -lc 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' +mkdir -p "$STAGE" +unzip -q "$ZIP" -d "$STAGE" +[[ "$(node -p "require('$STAGE/api-v3/package.json').version")" == "0.19.3-11.1" ]] +[[ "$(node -p "require('$STAGE/web-v2/package.json').version")" == "0.19.3-11.1" ]] +grep -Fq "API_PHASE = 'D5.3.11.1'" "$STAGE/api-v3/src/version.ts" +grep -Fq "APP_PHASE = 'Fase D5.3.11.1 · Navegación de importados'" "$STAGE/web-v2/src/config/version.ts" + +echo "[2/10] Compilando API en staging" +docker build --target builder -t dhv2-api:d53111-test "$STAGE/api-v3" + +echo "[3/10] Ejecutando tests API en staging" +docker run --rm \ + -v "$STAGE/api-v3/test:/app/test:ro" \ + -v "$STAGE/api-v3/tsconfig.test.json:/app/tsconfig.test.json:ro" \ + dhv2-api:d53111-test npm test + +echo "[4/10] Compilando web en staging" +docker build -t dhv2-web:d53111-test "$STAGE/web-v2" + +echo "[5/10] Backup PRE D5.3.11.1" +mkdir -p "$PRE_BACKUP" +chmod 700 "$PRE_BACKUP" +docker compose exec -T db sh -lc 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > "$PRE_BACKUP/database.dump" +tar -czf "$PRE_BACKUP/source.tar.gz" \ + --exclude='api-v3/node_modules' --exclude='api-v3/dist' \ + --exclude='web-v2/node_modules' --exclude='web-v2/dist' \ + api-v3 web-v2 scripts docs docker-compose.yml .env.example .gitignore +if [[ -f .env ]]; then cp -a .env "$PRE_BACKUP/.env"; chmod 600 "$PRE_BACKUP/.env"; fi +STRONG_BACKUP="$(find "$BACKUP_ROOT" -maxdepth 1 -type d -name 'INVENTARIO_A_REVISAR_*' -printf '%T@ %p\n' | sort -nr | head -n1 | cut -d' ' -f2-)" +printf 'Backup fuerte conceptual previo: %s\nPalabra clave: INVENTARIO A REVISAR\n' "$STRONG_BACKUP" > "$PRE_BACKUP/INVENTARIO_A_REVISAR_REFERENCE.txt" +( + cd "$PRE_BACKUP" + sha256sum database.dump source.tar.gz INVENTARIO_A_REVISAR_REFERENCE.txt > SHA256SUMS.txt + [[ ! -f .env ]] || sha256sum .env >> SHA256SUMS.txt + sha256sum -c SHA256SUMS.txt +) + +echo "[6/10] Instalando source D5.3.11.1" +unzip -oq "$ZIP" -d "$ROOT" +chmod +x "$ROOT/scripts/deploy-phase-d5-3-11-1.sh" "$ROOT/scripts/phase-d5-3-11-1-acceptance.sh" +echo "API source: $(node -p "require('./api-v3/package.json').version")" +echo "WEB source: $(node -p "require('./web-v2/package.json').version")" + +echo "[7/10] Construyendo imágenes finales" +docker compose build api migrate web + +echo "[8/10] Verificando que D5.3.11.1 no tenga migraciones pendientes" +docker compose --profile tools run --rm migrate npm run migration:show 2>&1 | tee "$MIGRATION_LOG" +grep -Fq 'Pending migrations: no' "$MIGRATION_LOG" + +echo "[9/10] Recreando API/web y aceptación" +docker compose up -d --force-recreate api web +for i in $(seq 1 40); do + if curl -fsS http://127.0.0.1:3101/api/v3/health >"$HEALTH_FILE" 2>/dev/null; then break; fi + if [[ "$i" == "40" ]]; then docker compose logs --tail=200 api; exit 1; fi + sleep 2 +done +cat "$HEALTH_FILE"; echo +grep -Fq '"version":"0.19.3-11.1"' "$HEALTH_FILE" +grep -Fq '"phase":"D5.3.11.1"' "$HEALTH_FILE" +grep -Fq '"database":"ok"' "$HEALTH_FILE" +bash "$ROOT/scripts/phase-d5-3-11-1-acceptance.sh" + +echo "[10/10] Backup POST" +mkdir -p "$POST_BACKUP" +chmod 700 "$POST_BACKUP" +docker compose exec -T db sh -lc 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > "$POST_BACKUP/database.dump" +docker compose ps -a > "$POST_BACKUP/docker-status.txt" +cp -a "$ZIP" "$POST_BACKUP/" +if [[ -f .env ]]; then cp -a .env "$POST_BACKUP/.env"; chmod 600 "$POST_BACKUP/.env"; fi +printf 'PRE D5.3.11.1: %s\nINVENTARIO A REVISAR: %s\n' "$PRE_BACKUP" "$STRONG_BACKUP" > "$POST_BACKUP/BACKUP_REFERENCES.txt" +( + cd "$POST_BACKUP" + sha256sum database.dump "$(basename "$ZIP")" BACKUP_REFERENCES.txt > SHA256SUMS.txt + [[ ! -f .env ]] || sha256sum .env >> SHA256SUMS.txt +) + +echo +echo "D5.3.11.1 desplegada correctamente" +echo "Backup PRE: $PRE_BACKUP" +echo "Backup fuerte INVENTARIO A REVISAR: $STRONG_BACKUP" +echo "Backup POST: $POST_BACKUP" diff --git a/scripts/deploy-phase-d5-3-11.sh b/scripts/deploy-phase-d5-3-11.sh new file mode 100755 index 0000000..3281a58 --- /dev/null +++ b/scripts/deploy-phase-d5-3-11.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT="${ROOT:-/var/www/dhv2.korexlabs.com}" +ZIP="${ZIP:-$ROOT/DH_V2_FASE_D5_3_11_SOURCE.zip}" +STAMP="$(date +%Y%m%d_%H%M%S)" +STAGE="/root/dhv2-d5311-stage-$STAMP" +BACKUP_ROOT="/root/DH_V2_BACKUPS" +PRE_BACKUP="$BACKUP_ROOT/D5_3_11_PRE_$STAMP" +POST_BACKUP="$BACKUP_ROOT/D5_3_11_OK_$STAMP" +MIGRATION_LOG="/tmp/dhv2-d5311-migration-$STAMP.log" +HEALTH_FILE="/tmp/dhv2-d5311-health-$STAMP.json" +MIN_FREE_KB=$((4 * 1024 * 1024)) + +cleanup() { + rm -rf "$STAGE" + rm -f "$MIGRATION_LOG" "$HEALTH_FILE" + docker image rm dhv2-api:d5311-test dhv2-web:d5311-test >/dev/null 2>&1 || true +} +trap cleanup EXIT +trap 'echo; echo "ERROR D5.3.11 en línea $LINENO: $BASH_COMMAND"' ERR + +cd "$ROOT" +test -f "$ZIP" + +CURRENT_API="$(node -p "require('./api-v3/package.json').version")" +CURRENT_WEB="$(node -p "require('./web-v2/package.json').version")" +[[ "$CURRENT_API" == "0.19.3-10.2" ]] +[[ "$CURRENT_WEB" == "0.19.3-10.2" ]] + +echo "[1/10] Preflight y staging" +FREE_KB="$(df -Pk "$ROOT" | awk 'NR==2 {print $4}')" +if [[ ! "$FREE_KB" =~ ^[0-9]+$ ]] || (( FREE_KB < MIN_FREE_KB )); then + echo "ERROR: espacio libre insuficiente. Libre: $(( FREE_KB / 1024 )) MB" + exit 1 +fi +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}' 2>/dev/null || true)" == "healthy" ]] +docker compose exec -T db sh -lc 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' +mkdir -p "$STAGE" +unzip -q "$ZIP" -d "$STAGE" +[[ "$(node -p "require('$STAGE/api-v3/package.json').version")" == "0.19.3-11" ]] +[[ "$(node -p "require('$STAGE/web-v2/package.json').version")" == "0.19.3-11" ]] +grep -Fq "API_PHASE = 'D5.3.11'" "$STAGE/api-v3/src/version.ts" +grep -Fq "APP_PHASE = 'Fase D5.3.11 · Importación parcial y revisiones'" "$STAGE/web-v2/src/config/version.ts" + +echo "[2/10] Compilando API en staging" +docker build --target builder -t dhv2-api:d5311-test "$STAGE/api-v3" + +echo "[3/10] Ejecutando tests API en staging" +docker run --rm \ + -v "$STAGE/api-v3/test:/app/test:ro" \ + -v "$STAGE/api-v3/tsconfig.test.json:/app/tsconfig.test.json:ro" \ + dhv2-api:d5311-test npm test + +echo "[4/10] Compilando web en staging" +docker build -t dhv2-web:d5311-test "$STAGE/web-v2" + +echo "[5/10] Backup PRE D5.3.11" +mkdir -p "$PRE_BACKUP" +chmod 700 "$PRE_BACKUP" +docker compose exec -T db sh -lc 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > "$PRE_BACKUP/database.dump" +tar -czf "$PRE_BACKUP/source.tar.gz" \ + --exclude='api-v3/node_modules' --exclude='api-v3/dist' \ + --exclude='web-v2/node_modules' --exclude='web-v2/dist' \ + api-v3 web-v2 scripts docs docker-compose.yml .env.example .gitignore +if [[ -f .env ]]; then cp -a .env "$PRE_BACKUP/.env"; chmod 600 "$PRE_BACKUP/.env"; fi +STRONG_BACKUP="$(find "$BACKUP_ROOT" -maxdepth 1 -type d -name 'INVENTARIO_A_REVISAR_*' -printf '%T@ %p\n' | sort -nr | head -n1 | cut -d' ' -f2-)" +printf 'Backup fuerte conceptual previo: %s\nPalabra clave: INVENTARIO A REVISAR\n' "$STRONG_BACKUP" > "$PRE_BACKUP/INVENTARIO_A_REVISAR_REFERENCE.txt" +( + cd "$PRE_BACKUP" + sha256sum database.dump source.tar.gz INVENTARIO_A_REVISAR_REFERENCE.txt > SHA256SUMS.txt + [[ ! -f .env ]] || sha256sum .env >> SHA256SUMS.txt + sha256sum -c SHA256SUMS.txt +) + +echo "[6/10] Instalando source D5.3.11" +unzip -oq "$ZIP" -d "$ROOT" +chmod +x "$ROOT/scripts/deploy-phase-d5-3-11.sh" "$ROOT/scripts/phase-d5-3-11-acceptance.sh" +echo "API source: $(node -p "require('./api-v3/package.json').version")" +echo "WEB source: $(node -p "require('./web-v2/package.json').version")" + +echo "[7/10] Construyendo imágenes finales" +docker compose build api migrate web + +echo "[8/10] Verificando que D5.3.11 no tenga migraciones pendientes" +docker compose --profile tools run --rm migrate npm run migration:show 2>&1 | tee "$MIGRATION_LOG" +grep -Fq 'Pending migrations: no' "$MIGRATION_LOG" + +echo "[9/10] Recreando API/web y aceptación" +docker compose up -d --force-recreate api web +for i in $(seq 1 40); do + if curl -fsS http://127.0.0.1:3101/api/v3/health >"$HEALTH_FILE" 2>/dev/null; then break; fi + if [[ "$i" == "40" ]]; then docker compose logs --tail=200 api; exit 1; fi + sleep 2 +done +cat "$HEALTH_FILE"; echo +grep -Fq '"version":"0.19.3-11"' "$HEALTH_FILE" +grep -Fq '"phase":"D5.3.11"' "$HEALTH_FILE" +grep -Fq '"database":"ok"' "$HEALTH_FILE" +bash "$ROOT/scripts/phase-d5-3-11-acceptance.sh" + +echo "[10/10] Backup POST" +mkdir -p "$POST_BACKUP" +chmod 700 "$POST_BACKUP" +docker compose exec -T db sh -lc 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > "$POST_BACKUP/database.dump" +docker compose ps -a > "$POST_BACKUP/docker-status.txt" +cp -a "$ZIP" "$POST_BACKUP/" +if [[ -f .env ]]; then cp -a .env "$POST_BACKUP/.env"; chmod 600 "$POST_BACKUP/.env"; fi +printf 'PRE D5.3.11: %s\nINVENTARIO A REVISAR: %s\n' "$PRE_BACKUP" "$STRONG_BACKUP" > "$POST_BACKUP/BACKUP_REFERENCES.txt" +( + cd "$POST_BACKUP" + sha256sum database.dump "$(basename "$ZIP")" BACKUP_REFERENCES.txt > SHA256SUMS.txt + [[ ! -f .env ]] || sha256sum .env >> SHA256SUMS.txt +) + +echo +echo "D5.3.11 desplegada correctamente" +echo "Backup PRE: $PRE_BACKUP" +echo "Backup fuerte INVENTARIO A REVISAR: $STRONG_BACKUP" +echo "Backup POST: $POST_BACKUP" diff --git a/scripts/deploy-phase-d5-3-12.sh b/scripts/deploy-phase-d5-3-12.sh new file mode 100755 index 0000000..ae02ec4 --- /dev/null +++ b/scripts/deploy-phase-d5-3-12.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT="${ROOT:-/var/www/dhv2.korexlabs.com}" +ZIP="${ZIP:-$ROOT/DH_V2_FASE_D5_3_12_SOURCE.zip}" +STAMP="$(date +%Y%m%d_%H%M%S)" +STAGE="/root/dhv2-d5312-stage-$STAMP" +BACKUP_ROOT="/root/DH_V2_BACKUPS" +PRE_BACKUP="$BACKUP_ROOT/D5_3_12_PRE_$STAMP" +POST_BACKUP="$BACKUP_ROOT/D5_3_12_OK_$STAMP" +MIGRATION_LOG="/tmp/dhv2-d5312-migration-$STAMP.log" +HEALTH_FILE="/tmp/dhv2-d5312-health-$STAMP.json" +MIN_FREE_KB=$((4 * 1024 * 1024)) + +cleanup() { + rm -rf "$STAGE" + rm -f "$MIGRATION_LOG" "$HEALTH_FILE" + docker image rm dhv2-api:d5312-test dhv2-web:d5312-test >/dev/null 2>&1 || true +} +trap cleanup EXIT +trap 'echo; echo "ERROR D5.3.12 en línea $LINENO: $BASH_COMMAND"' ERR + +cd "$ROOT" +test -f "$ZIP" + +CURRENT_API="$(node -p "require('./api-v3/package.json').version")" +CURRENT_WEB="$(node -p "require('./web-v2/package.json').version")" +[[ "$CURRENT_API" == "0.19.3-11.1" ]] +[[ "$CURRENT_WEB" == "0.19.3-11.1" ]] + +echo "[1/10] Preflight y staging" +FREE_KB="$(df -Pk "$ROOT" | awk 'NR==2 {print $4}')" +if [[ ! "$FREE_KB" =~ ^[0-9]+$ ]] || (( FREE_KB < MIN_FREE_KB )); then + echo "ERROR: espacio libre insuficiente. Libre: $(( FREE_KB / 1024 )) MB" + exit 1 +fi +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}' 2>/dev/null || true)" == "healthy" ]] +docker compose exec -T db sh -lc 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' +mkdir -p "$STAGE" +unzip -q "$ZIP" -d "$STAGE" +[[ "$(node -p "require('$STAGE/api-v3/package.json').version")" == "0.19.3-12" ]] +[[ "$(node -p "require('$STAGE/web-v2/package.json').version")" == "0.19.3-12" ]] +grep -Fq "API_PHASE = 'D5.3.12'" "$STAGE/api-v3/src/version.ts" +grep -Fq "APP_PHASE = 'Fase D5.3.12 · Inventarios por empresa'" "$STAGE/web-v2/src/config/version.ts" + +echo "[2/10] Compilando API en staging" +docker build --target builder -t dhv2-api:d5312-test "$STAGE/api-v3" + +echo "[3/10] Ejecutando tests API en staging" +docker run --rm \ + -v "$STAGE/api-v3/test:/app/test:ro" \ + -v "$STAGE/api-v3/tsconfig.test.json:/app/tsconfig.test.json:ro" \ + dhv2-api:d5312-test npm test + +echo "[4/10] Compilando web en staging" +docker build -t dhv2-web:d5312-test "$STAGE/web-v2" + +echo "[5/10] Backup PRE D5.3.12" +mkdir -p "$PRE_BACKUP" +chmod 700 "$PRE_BACKUP" +docker compose exec -T db sh -lc 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > "$PRE_BACKUP/database.dump" +tar -czf "$PRE_BACKUP/source.tar.gz" \ + --exclude='api-v3/node_modules' --exclude='api-v3/dist' \ + --exclude='web-v2/node_modules' --exclude='web-v2/dist' \ + api-v3 web-v2 scripts docs docker-compose.yml .env.example .gitignore +if [[ -f .env ]]; then cp -a .env "$PRE_BACKUP/.env"; chmod 600 "$PRE_BACKUP/.env"; fi +STRONG_BACKUP="$(find "$BACKUP_ROOT" -maxdepth 1 -type d -name 'INVENTARIO_A_REVISAR_*' -printf '%T@ %p\n' | sort -nr | head -n1 | cut -d' ' -f2-)" +printf 'Backup fuerte conceptual previo: %s\nPalabra clave: INVENTARIO A REVISAR\n' "$STRONG_BACKUP" > "$PRE_BACKUP/INVENTARIO_A_REVISAR_REFERENCE.txt" +( + cd "$PRE_BACKUP" + sha256sum database.dump source.tar.gz INVENTARIO_A_REVISAR_REFERENCE.txt > SHA256SUMS.txt + [[ ! -f .env ]] || sha256sum .env >> SHA256SUMS.txt + sha256sum -c SHA256SUMS.txt +) + +echo "[6/10] Instalando source D5.3.12" +unzip -oq "$ZIP" -d "$ROOT" +chmod +x "$ROOT/scripts/deploy-phase-d5-3-12.sh" "$ROOT/scripts/phase-d5-3-12-acceptance.sh" +echo "API source: $(node -p "require('./api-v3/package.json').version")" +echo "WEB source: $(node -p "require('./web-v2/package.json').version")" + +echo "[7/10] Construyendo imágenes finales" +docker compose build api migrate web + +echo "[8/10] Verificando que D5.3.12 no tenga migraciones pendientes" +docker compose --profile tools run --rm migrate npm run migration:show 2>&1 | tee "$MIGRATION_LOG" +grep -Fq 'Pending migrations: no' "$MIGRATION_LOG" + +echo "[9/10] Recreando API/web y aceptación" +docker compose up -d --force-recreate api web +for i in $(seq 1 40); do + if curl -fsS http://127.0.0.1:3101/api/v3/health >"$HEALTH_FILE" 2>/dev/null; then break; fi + if [[ "$i" == "40" ]]; then docker compose logs --tail=200 api; exit 1; fi + sleep 2 +done +cat "$HEALTH_FILE"; echo +grep -Fq '"version":"0.19.3-12"' "$HEALTH_FILE" +grep -Fq '"phase":"D5.3.12"' "$HEALTH_FILE" +grep -Fq '"database":"ok"' "$HEALTH_FILE" +bash "$ROOT/scripts/phase-d5-3-12-acceptance.sh" + +echo "[10/10] Backup POST" +mkdir -p "$POST_BACKUP" +chmod 700 "$POST_BACKUP" +docker compose exec -T db sh -lc 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > "$POST_BACKUP/database.dump" +docker compose ps -a > "$POST_BACKUP/docker-status.txt" +cp -a "$ZIP" "$POST_BACKUP/" +if [[ -f .env ]]; then cp -a .env "$POST_BACKUP/.env"; chmod 600 "$POST_BACKUP/.env"; fi +printf 'PRE D5.3.12: %s\nINVENTARIO A REVISAR: %s\n' "$PRE_BACKUP" "$STRONG_BACKUP" > "$POST_BACKUP/BACKUP_REFERENCES.txt" +( + cd "$POST_BACKUP" + sha256sum database.dump "$(basename "$ZIP")" BACKUP_REFERENCES.txt > SHA256SUMS.txt + [[ ! -f .env ]] || sha256sum .env >> SHA256SUMS.txt +) + +echo +echo "D5.3.12 desplegada correctamente" +echo "Backup PRE: $PRE_BACKUP" +echo "Backup fuerte INVENTARIO A REVISAR: $STRONG_BACKUP" +echo "Backup POST: $POST_BACKUP" diff --git a/scripts/deploy-phase-d5-3-13.sh b/scripts/deploy-phase-d5-3-13.sh new file mode 100755 index 0000000..bc1e6db --- /dev/null +++ b/scripts/deploy-phase-d5-3-13.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT="${ROOT:-/var/www/dhv2.korexlabs.com}" +ZIP="${ZIP:-$ROOT/DH_V2_FASE_D5_3_13_SOURCE.zip}" +STAMP="$(date +%Y%m%d_%H%M%S)" +STAGE="/root/dhv2-d5313-stage-$STAMP" +BACKUP_ROOT="/root/DH_V2_BACKUPS" +PRE_BACKUP="$BACKUP_ROOT/D5_3_13_PRE_$STAMP" +POST_BACKUP="$BACKUP_ROOT/D5_3_13_OK_$STAMP" +MIGRATION_LOG="/tmp/dhv2-d5313-migration-$STAMP.log" +HEALTH_FILE="/tmp/dhv2-d5313-health-$STAMP.json" +MIN_FREE_KB=$((4 * 1024 * 1024)) + +cleanup() { + rm -rf "$STAGE" + rm -f "$MIGRATION_LOG" "$HEALTH_FILE" + docker image rm dhv2-api:d5313-test dhv2-web:d5313-test >/dev/null 2>&1 || true +} +trap cleanup EXIT +trap 'echo; echo "ERROR D5.3.13 en línea $LINENO: $BASH_COMMAND"' ERR + +cd "$ROOT" +test -f "$ZIP" + +CURRENT_API="$(node -p "require('./api-v3/package.json').version")" +CURRENT_WEB="$(node -p "require('./web-v2/package.json').version")" +[[ "$CURRENT_API" == "0.19.3-12" ]] +[[ "$CURRENT_WEB" == "0.19.3-12" ]] + +echo "[1/10] Preflight y staging" +FREE_KB="$(df -Pk "$ROOT" | awk 'NR==2 {print $4}')" +if [[ ! "$FREE_KB" =~ ^[0-9]+$ ]] || (( FREE_KB < MIN_FREE_KB )); then + echo "ERROR: espacio libre insuficiente. Libre: $(( FREE_KB / 1024 )) MB" + exit 1 +fi +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}' 2>/dev/null || true)" == "healthy" ]] +docker compose exec -T db sh -lc 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' +mkdir -p "$STAGE" +unzip -q "$ZIP" -d "$STAGE" +[[ "$(node -p "require('$STAGE/api-v3/package.json').version")" == "0.19.3-13" ]] +[[ "$(node -p "require('$STAGE/web-v2/package.json').version")" == "0.19.3-13" ]] +grep -Fq "API_PHASE = 'D5.3.13'" "$STAGE/api-v3/src/version.ts" +grep -Fq "APP_PHASE = 'Fase D5.3.13 · Seguimiento de hallazgos'" "$STAGE/web-v2/src/config/version.ts" + +echo "[2/10] Compilando API en staging" +docker build --target builder -t dhv2-api:d5313-test "$STAGE/api-v3" + +echo "[3/10] Ejecutando tests API en staging" +docker run --rm \ + -v "$STAGE/api-v3/test:/app/test:ro" \ + -v "$STAGE/api-v3/tsconfig.test.json:/app/tsconfig.test.json:ro" \ + dhv2-api:d5313-test npm test + +echo "[4/10] Compilando web en staging" +docker build -t dhv2-web:d5313-test "$STAGE/web-v2" + +echo "[5/10] Backup PRE D5.3.13" +mkdir -p "$PRE_BACKUP" +chmod 700 "$PRE_BACKUP" +docker compose exec -T db sh -lc 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > "$PRE_BACKUP/database.dump" +tar -czf "$PRE_BACKUP/source.tar.gz" \ + --exclude='api-v3/node_modules' --exclude='api-v3/dist' \ + --exclude='web-v2/node_modules' --exclude='web-v2/dist' \ + api-v3 web-v2 scripts docs docker-compose.yml .env.example .gitignore +if [[ -f .env ]]; then cp -a .env "$PRE_BACKUP/.env"; chmod 600 "$PRE_BACKUP/.env"; fi +STRONG_BACKUP="$(find "$BACKUP_ROOT" -maxdepth 1 -type d -name 'INVENTARIO_A_REVISAR_*' -printf '%T@ %p\n' | sort -nr | head -n1 | cut -d' ' -f2-)" +printf 'Backup fuerte conceptual previo: %s\nPalabra clave: INVENTARIO A REVISAR\n' "$STRONG_BACKUP" > "$PRE_BACKUP/INVENTARIO_A_REVISAR_REFERENCE.txt" +( + cd "$PRE_BACKUP" + sha256sum database.dump source.tar.gz INVENTARIO_A_REVISAR_REFERENCE.txt > SHA256SUMS.txt + [[ ! -f .env ]] || sha256sum .env >> SHA256SUMS.txt + sha256sum -c SHA256SUMS.txt +) + +echo "[6/10] Instalando source D5.3.13" +unzip -oq "$ZIP" -d "$ROOT" +chmod +x "$ROOT/scripts/deploy-phase-d5-3-13.sh" "$ROOT/scripts/phase-d5-3-13-acceptance.sh" +echo "API source: $(node -p "require('./api-v3/package.json').version")" +echo "WEB source: $(node -p "require('./web-v2/package.json').version")" + +echo "[7/10] Construyendo imágenes finales" +docker compose build api migrate web + +echo "[8/10] Verificando que D5.3.13 no tenga migraciones pendientes" +docker compose --profile tools run --rm migrate npm run migration:show 2>&1 | tee "$MIGRATION_LOG" +grep -Fq 'Pending migrations: no' "$MIGRATION_LOG" + +echo "[9/10] Recreando API/web y aceptación" +docker compose up -d --force-recreate api web +for i in $(seq 1 40); do + if curl -fsS http://127.0.0.1:3101/api/v3/health >"$HEALTH_FILE" 2>/dev/null; then break; fi + if [[ "$i" == "40" ]]; then docker compose logs --tail=200 api; exit 1; fi + sleep 2 +done +cat "$HEALTH_FILE"; echo +grep -Fq '"version":"0.19.3-13"' "$HEALTH_FILE" +grep -Fq '"phase":"D5.3.13"' "$HEALTH_FILE" +grep -Fq '"database":"ok"' "$HEALTH_FILE" +bash "$ROOT/scripts/phase-d5-3-13-acceptance.sh" + +echo "[10/10] Backup POST" +mkdir -p "$POST_BACKUP" +chmod 700 "$POST_BACKUP" +docker compose exec -T db sh -lc 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > "$POST_BACKUP/database.dump" +docker compose ps -a > "$POST_BACKUP/docker-status.txt" +cp -a "$ZIP" "$POST_BACKUP/" +if [[ -f .env ]]; then cp -a .env "$POST_BACKUP/.env"; chmod 600 "$POST_BACKUP/.env"; fi +printf 'PRE D5.3.13: %s\nINVENTARIO A REVISAR: %s\n' "$PRE_BACKUP" "$STRONG_BACKUP" > "$POST_BACKUP/BACKUP_REFERENCES.txt" +( + cd "$POST_BACKUP" + sha256sum database.dump "$(basename "$ZIP")" BACKUP_REFERENCES.txt > SHA256SUMS.txt + [[ ! -f .env ]] || sha256sum .env >> SHA256SUMS.txt +) + +echo +echo "D5.3.13 desplegada correctamente" +echo "Backup PRE: $PRE_BACKUP" +echo "Backup fuerte INVENTARIO A REVISAR: $STRONG_BACKUP" +echo "Backup POST: $POST_BACKUP" diff --git a/scripts/deploy-phase-d5-3-14.sh b/scripts/deploy-phase-d5-3-14.sh new file mode 100755 index 0000000..7df3af6 --- /dev/null +++ b/scripts/deploy-phase-d5-3-14.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT="${ROOT:-/var/www/dhv2.korexlabs.com}" +ZIP="${ZIP:-$ROOT/DH_V2_FASE_D5_3_14_SOURCE.zip}" +STAMP="$(date +%Y%m%d_%H%M%S)" +STAGE="/root/dhv2-d5314-stage-$STAMP" +BACKUP_ROOT="/root/DH_V2_BACKUPS" +PRE_BACKUP="$BACKUP_ROOT/D5_3_14_PRE_$STAMP" +POST_BACKUP="$BACKUP_ROOT/D5_3_14_OK_$STAMP" +MIGRATION_LOG="/tmp/dhv2-d5314-migration-$STAMP.log" +HEALTH_FILE="/tmp/dhv2-d5314-health-$STAMP.json" +MIN_FREE_KB=$((4 * 1024 * 1024)) + +cleanup() { + rm -rf "$STAGE" + rm -f "$MIGRATION_LOG" "$HEALTH_FILE" + docker image rm dhv2-api:d5314-test dhv2-web:d5314-test >/dev/null 2>&1 || true +} +trap cleanup EXIT +trap 'echo; echo "ERROR D5.3.14 en línea $LINENO: $BASH_COMMAND"' ERR + +cd "$ROOT" +test -f "$ZIP" + +CURRENT_API="$(node -p "require('./api-v3/package.json').version")" +CURRENT_WEB="$(node -p "require('./web-v2/package.json').version")" +[[ "$CURRENT_API" == "0.19.3-13" ]] +[[ "$CURRENT_WEB" == "0.19.3-13" ]] + +echo "[1/10] Preflight y staging" +FREE_KB="$(df -Pk "$ROOT" | awk 'NR==2 {print $4}')" +if [[ ! "$FREE_KB" =~ ^[0-9]+$ ]] || (( FREE_KB < MIN_FREE_KB )); then + echo "ERROR: espacio libre insuficiente. Libre: $(( FREE_KB / 1024 )) MB" + exit 1 +fi +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}' 2>/dev/null || true)" == "healthy" ]] +docker compose exec -T db sh -lc 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' +mkdir -p "$STAGE" +unzip -q "$ZIP" -d "$STAGE" +[[ "$(node -p "require('$STAGE/api-v3/package.json').version")" == "0.19.3-14" ]] +[[ "$(node -p "require('$STAGE/web-v2/package.json').version")" == "0.19.3-14" ]] +grep -Fq "API_PHASE = 'D5.3.14'" "$STAGE/api-v3/src/version.ts" +grep -Fq "APP_PHASE = 'Fase D5.3.14 · Expediente técnico'" "$STAGE/web-v2/src/config/version.ts" + +echo "[2/10] Compilando API en staging" +docker build --target builder -t dhv2-api:d5314-test "$STAGE/api-v3" + +echo "[3/10] Ejecutando tests API en staging" +docker run --rm \ + -v "$STAGE/api-v3/test:/app/test:ro" \ + -v "$STAGE/api-v3/tsconfig.test.json:/app/tsconfig.test.json:ro" \ + dhv2-api:d5314-test npm test + +echo "[4/10] Compilando web en staging" +docker build -t dhv2-web:d5314-test "$STAGE/web-v2" + +echo "[5/10] Backup PRE D5.3.14" +mkdir -p "$PRE_BACKUP" +chmod 700 "$PRE_BACKUP" +docker compose exec -T db sh -lc 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > "$PRE_BACKUP/database.dump" +tar -czf "$PRE_BACKUP/source.tar.gz" \ + --exclude='api-v3/node_modules' --exclude='api-v3/dist' \ + --exclude='web-v2/node_modules' --exclude='web-v2/dist' \ + api-v3 web-v2 scripts docs docker-compose.yml .env.example .gitignore +if [[ -f .env ]]; then cp -a .env "$PRE_BACKUP/.env"; chmod 600 "$PRE_BACKUP/.env"; fi +STRONG_BACKUP="$(find "$BACKUP_ROOT" -maxdepth 1 -type d -name 'INVENTARIO_A_REVISAR_*' -printf '%T@ %p\n' | sort -nr | head -n1 | cut -d' ' -f2-)" +printf 'Backup fuerte conceptual previo: %s\nPalabra clave: INVENTARIO A REVISAR\n' "$STRONG_BACKUP" > "$PRE_BACKUP/INVENTARIO_A_REVISAR_REFERENCE.txt" +( + cd "$PRE_BACKUP" + sha256sum database.dump source.tar.gz INVENTARIO_A_REVISAR_REFERENCE.txt > SHA256SUMS.txt + [[ ! -f .env ]] || sha256sum .env >> SHA256SUMS.txt + sha256sum -c SHA256SUMS.txt +) + +echo "[6/10] Instalando source D5.3.14" +unzip -oq "$ZIP" -d "$ROOT" +chmod +x "$ROOT/scripts/deploy-phase-d5-3-14.sh" "$ROOT/scripts/phase-d5-3-14-acceptance.sh" +echo "API source: $(node -p "require('./api-v3/package.json').version")" +echo "WEB source: $(node -p "require('./web-v2/package.json').version")" + +echo "[7/10] Construyendo imágenes finales" +docker compose build api migrate web + +echo "[8/10] Verificando que D5.3.14 no tenga migraciones pendientes" +docker compose --profile tools run --rm migrate npm run migration:show 2>&1 | tee "$MIGRATION_LOG" +grep -Fq 'Pending migrations: no' "$MIGRATION_LOG" + +echo "[9/10] Recreando API/web y aceptación" +docker compose up -d --force-recreate api web +for i in $(seq 1 40); do + if curl -fsS http://127.0.0.1:3101/api/v3/health >"$HEALTH_FILE" 2>/dev/null; then break; fi + if [[ "$i" == "40" ]]; then docker compose logs --tail=200 api; exit 1; fi + sleep 2 +done +cat "$HEALTH_FILE"; echo +grep -Fq '"version":"0.19.3-14"' "$HEALTH_FILE" +grep -Fq '"phase":"D5.3.14"' "$HEALTH_FILE" +grep -Fq '"database":"ok"' "$HEALTH_FILE" +bash "$ROOT/scripts/phase-d5-3-14-acceptance.sh" + +echo "[10/10] Backup POST" +mkdir -p "$POST_BACKUP" +chmod 700 "$POST_BACKUP" +docker compose exec -T db sh -lc 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > "$POST_BACKUP/database.dump" +docker compose ps -a > "$POST_BACKUP/docker-status.txt" +cp -a "$ZIP" "$POST_BACKUP/" +if [[ -f .env ]]; then cp -a .env "$POST_BACKUP/.env"; chmod 600 "$POST_BACKUP/.env"; fi +printf 'PRE D5.3.14: %s\nINVENTARIO A REVISAR: %s\n' "$PRE_BACKUP" "$STRONG_BACKUP" > "$POST_BACKUP/BACKUP_REFERENCES.txt" +( + cd "$POST_BACKUP" + sha256sum database.dump "$(basename "$ZIP")" BACKUP_REFERENCES.txt > SHA256SUMS.txt + [[ ! -f .env ]] || sha256sum .env >> SHA256SUMS.txt +) + +echo +echo "D5.3.14 desplegada correctamente" +echo "Backup PRE: $PRE_BACKUP" +echo "Backup fuerte INVENTARIO A REVISAR: $STRONG_BACKUP" +echo "Backup POST: $POST_BACKUP" diff --git a/scripts/deploy-phase-d5-3-15-1.sh b/scripts/deploy-phase-d5-3-15-1.sh new file mode 100755 index 0000000..49271e3 --- /dev/null +++ b/scripts/deploy-phase-d5-3-15-1.sh @@ -0,0 +1,122 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT="${ROOT:-/var/www/dhv2.korexlabs.com}" +ZIP="${ZIP:-$ROOT/DH_V2_FASE_D5_3_15_1_SOURCE.zip}" +STAMP="$(date +%Y%m%d_%H%M%S)" +STAGE="/root/dhv2-d53151-stage-$STAMP" +BACKUP_ROOT="/root/DH_V2_BACKUPS" +PRE_BACKUP="$BACKUP_ROOT/D5_3_15_1_PRE_$STAMP" +POST_BACKUP="$BACKUP_ROOT/D5_3_15_1_OK_$STAMP" +MIGRATION_RUN_LOG="/tmp/dhv2-d53151-migration-run-$STAMP.log" +MIGRATION_SHOW_LOG="/tmp/dhv2-d53151-migration-show-$STAMP.log" +HEALTH_FILE="/tmp/dhv2-d53151-health-$STAMP.json" +MIN_FREE_KB=$((4 * 1024 * 1024)) + +cleanup() { + rm -rf "$STAGE" + rm -f "$MIGRATION_RUN_LOG" "$MIGRATION_SHOW_LOG" "$HEALTH_FILE" + docker image rm dhv2-api:d53151-test dhv2-web:d53151-test >/dev/null 2>&1 || true +} +trap cleanup EXIT +trap 'echo; echo "ERROR D5.3.15.1 en línea $LINENO: $BASH_COMMAND"' ERR + +cd "$ROOT" +test -f "$ZIP" + +CURRENT_API="$(node -p "require('./api-v3/package.json').version")" +CURRENT_WEB="$(node -p "require('./web-v2/package.json').version")" +[[ "$CURRENT_API" == "0.19.3-14" ]] +[[ "$CURRENT_WEB" == "0.19.3-14" ]] + +echo "[1/10] Preflight y staging" +FREE_KB="$(df -Pk "$ROOT" | awk 'NR==2 {print $4}')" +if [[ ! "$FREE_KB" =~ ^[0-9]+$ ]] || (( FREE_KB < MIN_FREE_KB )); then + echo "ERROR: espacio libre insuficiente. Libre: $(( FREE_KB / 1024 )) MB" + exit 1 +fi +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}' 2>/dev/null || true)" == "healthy" ]] +docker compose exec -T db sh -lc 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' +mkdir -p "$STAGE" +unzip -q "$ZIP" -d "$STAGE" +[[ "$(node -p "require('$STAGE/api-v3/package.json').version")" == "0.19.3-15.1" ]] +[[ "$(node -p "require('$STAGE/web-v2/package.json').version")" == "0.19.3-15.1" ]] +grep -Fq "API_PHASE = 'D5.3.15.1'" "$STAGE/api-v3/src/version.ts" +grep -Fq "APP_PHASE = 'Fase D5.3.15.1 · Centro documental'" "$STAGE/web-v2/src/config/version.ts" + +echo "[2/10] Compilando API en staging" +docker build --target builder -t dhv2-api:d53151-test "$STAGE/api-v3" + +echo "[3/10] Ejecutando tests API en staging" +docker run --rm \ + -v "$STAGE/api-v3/test:/app/test:ro" \ + -v "$STAGE/api-v3/tsconfig.test.json:/app/tsconfig.test.json:ro" \ + dhv2-api:d53151-test npm test + +echo "[4/10] Compilando web en staging" +docker build -t dhv2-web:d53151-test "$STAGE/web-v2" + +echo "[5/10] Backup PRE D5.3.15.1" +mkdir -p "$PRE_BACKUP" +chmod 700 "$PRE_BACKUP" +docker compose exec -T db sh -lc 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > "$PRE_BACKUP/database.dump" +tar -czf "$PRE_BACKUP/source.tar.gz" \ + --exclude='api-v3/node_modules' --exclude='api-v3/dist' \ + --exclude='web-v2/node_modules' --exclude='web-v2/dist' \ + api-v3 web-v2 scripts docs docker-compose.yml .env.example .gitignore +if [[ -f .env ]]; then cp -a .env "$PRE_BACKUP/.env"; chmod 600 "$PRE_BACKUP/.env"; fi +STRONG_BACKUP="$(find "$BACKUP_ROOT" -maxdepth 1 -type d -name 'INVENTARIO_A_REVISAR_*' -printf '%T@ %p\n' | sort -nr | head -n1 | cut -d' ' -f2-)" +printf 'Backup fuerte conceptual previo: %s\nPalabra clave: INVENTARIO A REVISAR\n' "$STRONG_BACKUP" > "$PRE_BACKUP/INVENTARIO_A_REVISAR_REFERENCE.txt" +( + cd "$PRE_BACKUP" + sha256sum database.dump source.tar.gz INVENTARIO_A_REVISAR_REFERENCE.txt > SHA256SUMS.txt + [[ ! -f .env ]] || sha256sum .env >> SHA256SUMS.txt + sha256sum -c SHA256SUMS.txt +) + +echo "[6/10] Instalando source D5.3.15.1" +unzip -oq "$ZIP" -d "$ROOT" +chmod +x "$ROOT/scripts/deploy-phase-d5-3-15-1.sh" "$ROOT/scripts/phase-d5-3-15-1-acceptance.sh" +echo "API source: $(node -p "require('./api-v3/package.json').version")" +echo "WEB source: $(node -p "require('./web-v2/package.json').version")" + +echo "[7/10] Construyendo imágenes finales" +docker compose build api migrate web + +echo "[8/10] Aplicando migración D5.3.15.1" +docker compose --profile tools run --rm migrate npm run migration:run 2>&1 | tee "$MIGRATION_RUN_LOG" +docker compose --profile tools run --rm migrate npm run migration:show 2>&1 | tee "$MIGRATION_SHOW_LOG" +grep -Fq 'Pending migrations: no' "$MIGRATION_SHOW_LOG" + +echo "[9/10] Recreando API/web y aceptación" +docker compose up -d --force-recreate api web +for i in $(seq 1 40); do + if curl -fsS http://127.0.0.1:3101/api/v3/health >"$HEALTH_FILE" 2>/dev/null; then break; fi + if [[ "$i" == "40" ]]; then docker compose logs --tail=200 api; exit 1; fi + sleep 2 +done +cat "$HEALTH_FILE"; echo +grep -Fq '"version":"0.19.3-15.1"' "$HEALTH_FILE" +grep -Fq '"phase":"D5.3.15.1"' "$HEALTH_FILE" +grep -Fq '"database":"ok"' "$HEALTH_FILE" +bash "$ROOT/scripts/phase-d5-3-15-1-acceptance.sh" + +echo "[10/10] Backup POST" +mkdir -p "$POST_BACKUP" +chmod 700 "$POST_BACKUP" +docker compose exec -T db sh -lc 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > "$POST_BACKUP/database.dump" +docker compose ps -a > "$POST_BACKUP/docker-status.txt" +cp -a "$ZIP" "$POST_BACKUP/" +if [[ -f .env ]]; then cp -a .env "$POST_BACKUP/.env"; chmod 600 "$POST_BACKUP/.env"; fi +printf 'PRE D5.3.15.1: %s\nINVENTARIO A REVISAR: %s\n' "$PRE_BACKUP" "$STRONG_BACKUP" > "$POST_BACKUP/BACKUP_REFERENCES.txt" +( + cd "$POST_BACKUP" + sha256sum database.dump "$(basename "$ZIP")" BACKUP_REFERENCES.txt > SHA256SUMS.txt + [[ ! -f .env ]] || sha256sum .env >> SHA256SUMS.txt +) + +echo +echo "D5.3.15.1 desplegada correctamente" +echo "Backup PRE: $PRE_BACKUP" +echo "Backup fuerte INVENTARIO A REVISAR: $STRONG_BACKUP" +echo "Backup POST: $POST_BACKUP" diff --git a/scripts/deploy-phase-d5-3-16.sh b/scripts/deploy-phase-d5-3-16.sh new file mode 100755 index 0000000..3221841 --- /dev/null +++ b/scripts/deploy-phase-d5-3-16.sh @@ -0,0 +1,122 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT="${ROOT:-/var/www/dhv2.korexlabs.com}" +ZIP="${ZIP:-$ROOT/DH_V2_FASE_D5_3_16_SOURCE.zip}" +STAMP="$(date +%Y%m%d_%H%M%S)" +STAGE="/root/dhv2-d5316-stage-$STAMP" +BACKUP_ROOT="/root/DH_V2_BACKUPS" +PRE_BACKUP="$BACKUP_ROOT/D5_3_16_PRE_$STAMP" +POST_BACKUP="$BACKUP_ROOT/D5_3_16_OK_$STAMP" +MIGRATION_RUN_LOG="/tmp/dhv2-d5316-migration-run-$STAMP.log" +MIGRATION_SHOW_LOG="/tmp/dhv2-d5316-migration-show-$STAMP.log" +HEALTH_FILE="/tmp/dhv2-d5316-health-$STAMP.json" +MIN_FREE_KB=$((4 * 1024 * 1024)) + +cleanup() { + rm -rf "$STAGE" + rm -f "$MIGRATION_RUN_LOG" "$MIGRATION_SHOW_LOG" "$HEALTH_FILE" + docker image rm dhv2-api:d5316-test dhv2-web:d5316-test >/dev/null 2>&1 || true +} +trap cleanup EXIT +trap 'echo; echo "ERROR D5.3.16 en línea $LINENO: $BASH_COMMAND"' ERR + +cd "$ROOT" +test -f "$ZIP" + +CURRENT_API="$(node -p "require('./api-v3/package.json').version")" +CURRENT_WEB="$(node -p "require('./web-v2/package.json').version")" +[[ "$CURRENT_API" == "0.19.3-15.1" ]] +[[ "$CURRENT_WEB" == "0.19.3-15.1" ]] + +echo "[1/10] Preflight y staging" +FREE_KB="$(df -Pk "$ROOT" | awk 'NR==2 {print $4}')" +if [[ ! "$FREE_KB" =~ ^[0-9]+$ ]] || (( FREE_KB < MIN_FREE_KB )); then + echo "ERROR: espacio libre insuficiente. Libre: $(( FREE_KB / 1024 )) MB" + exit 1 +fi +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}' 2>/dev/null || true)" == "healthy" ]] +docker compose exec -T db sh -lc 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' +mkdir -p "$STAGE" +unzip -q "$ZIP" -d "$STAGE" +[[ "$(node -p "require('$STAGE/api-v3/package.json').version")" == "0.19.3-16" ]] +[[ "$(node -p "require('$STAGE/web-v2/package.json').version")" == "0.19.3-16" ]] +grep -Fq "API_PHASE = 'D5.3.16'" "$STAGE/api-v3/src/version.ts" +grep -Fq "APP_PHASE = 'Fase D5.3.16 · Planificación de verificaciones'" "$STAGE/web-v2/src/config/version.ts" + +echo "[2/10] Compilando API en staging" +docker build --target builder -t dhv2-api:d5316-test "$STAGE/api-v3" + +echo "[3/10] Ejecutando tests API en staging" +docker run --rm \ + -v "$STAGE/api-v3/test:/app/test:ro" \ + -v "$STAGE/api-v3/tsconfig.test.json:/app/tsconfig.test.json:ro" \ + dhv2-api:d5316-test npm test + +echo "[4/10] Compilando web en staging" +docker build -t dhv2-web:d5316-test "$STAGE/web-v2" + +echo "[5/10] Backup PRE D5.3.16" +mkdir -p "$PRE_BACKUP" +chmod 700 "$PRE_BACKUP" +docker compose exec -T db sh -lc 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > "$PRE_BACKUP/database.dump" +tar -czf "$PRE_BACKUP/source.tar.gz" \ + --exclude='api-v3/node_modules' --exclude='api-v3/dist' \ + --exclude='web-v2/node_modules' --exclude='web-v2/dist' \ + api-v3 web-v2 scripts docs docker-compose.yml .env.example .gitignore +if [[ -f .env ]]; then cp -a .env "$PRE_BACKUP/.env"; chmod 600 "$PRE_BACKUP/.env"; fi +STRONG_BACKUP="$(find "$BACKUP_ROOT" -maxdepth 1 -type d -name 'INVENTARIO_A_REVISAR_*' -printf '%T@ %p\n' | sort -nr | head -n1 | cut -d' ' -f2-)" +printf 'Backup fuerte conceptual previo: %s\nPalabra clave: INVENTARIO A REVISAR\n' "$STRONG_BACKUP" > "$PRE_BACKUP/INVENTARIO_A_REVISAR_REFERENCE.txt" +( + cd "$PRE_BACKUP" + sha256sum database.dump source.tar.gz INVENTARIO_A_REVISAR_REFERENCE.txt > SHA256SUMS.txt + [[ ! -f .env ]] || sha256sum .env >> SHA256SUMS.txt + sha256sum -c SHA256SUMS.txt +) + +echo "[6/10] Instalando source D5.3.16" +unzip -oq "$ZIP" -d "$ROOT" +chmod +x "$ROOT/scripts/deploy-phase-d5-3-16.sh" "$ROOT/scripts/phase-d5-3-16-acceptance.sh" +echo "API source: $(node -p "require('./api-v3/package.json').version")" +echo "WEB source: $(node -p "require('./web-v2/package.json').version")" + +echo "[7/10] Construyendo imágenes finales" +docker compose build api migrate web + +echo "[8/10] Aplicando migración D5.3.16" +docker compose --profile tools run --rm migrate npm run migration:run 2>&1 | tee "$MIGRATION_RUN_LOG" +docker compose --profile tools run --rm migrate npm run migration:show 2>&1 | tee "$MIGRATION_SHOW_LOG" +grep -Fq 'Pending migrations: no' "$MIGRATION_SHOW_LOG" + +echo "[9/10] Recreando API/web y aceptación" +docker compose up -d --force-recreate api web +for i in $(seq 1 40); do + if curl -fsS http://127.0.0.1:3101/api/v3/health >"$HEALTH_FILE" 2>/dev/null; then break; fi + if [[ "$i" == "40" ]]; then docker compose logs --tail=200 api; exit 1; fi + sleep 2 +done +cat "$HEALTH_FILE"; echo +grep -Fq '"version":"0.19.3-16"' "$HEALTH_FILE" +grep -Fq '"phase":"D5.3.16"' "$HEALTH_FILE" +grep -Fq '"database":"ok"' "$HEALTH_FILE" +bash "$ROOT/scripts/phase-d5-3-16-acceptance.sh" + +echo "[10/10] Backup POST" +mkdir -p "$POST_BACKUP" +chmod 700 "$POST_BACKUP" +docker compose exec -T db sh -lc 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > "$POST_BACKUP/database.dump" +docker compose ps -a > "$POST_BACKUP/docker-status.txt" +cp -a "$ZIP" "$POST_BACKUP/" +if [[ -f .env ]]; then cp -a .env "$POST_BACKUP/.env"; chmod 600 "$POST_BACKUP/.env"; fi +printf 'PRE D5.3.16: %s\nINVENTARIO A REVISAR: %s\n' "$PRE_BACKUP" "$STRONG_BACKUP" > "$POST_BACKUP/BACKUP_REFERENCES.txt" +( + cd "$POST_BACKUP" + sha256sum database.dump "$(basename "$ZIP")" BACKUP_REFERENCES.txt > SHA256SUMS.txt + [[ ! -f .env ]] || sha256sum .env >> SHA256SUMS.txt +) + +echo +echo "D5.3.16 desplegada correctamente" +echo "Backup PRE: $PRE_BACKUP" +echo "Backup fuerte INVENTARIO A REVISAR: $STRONG_BACKUP" +echo "Backup POST: $POST_BACKUP" diff --git a/scripts/deploy-phase-d5-3-17-1.sh b/scripts/deploy-phase-d5-3-17-1.sh new file mode 100755 index 0000000..75c42b2 --- /dev/null +++ b/scripts/deploy-phase-d5-3-17-1.sh @@ -0,0 +1,122 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT="${ROOT:-/var/www/dhv2.korexlabs.com}" +ZIP="${ZIP:-$ROOT/DH_V2_FASE_D5_3_17_1_SOURCE.zip}" +STAMP="$(date +%Y%m%d_%H%M%S)" +STAGE="/root/dhv2-d53171-stage-$STAMP" +BACKUP_ROOT="/root/DH_V2_BACKUPS" +PRE_BACKUP="$BACKUP_ROOT/D5_3_17_1_PRE_$STAMP" +POST_BACKUP="$BACKUP_ROOT/D5_3_17_1_OK_$STAMP" +MIGRATION_RUN_LOG="/tmp/dhv2-d53171-migration-run-$STAMP.log" +MIGRATION_SHOW_LOG="/tmp/dhv2-d53171-migration-show-$STAMP.log" +HEALTH_FILE="/tmp/dhv2-d53171-health-$STAMP.json" +MIN_FREE_KB=$((4 * 1024 * 1024)) + +cleanup() { + rm -rf "$STAGE" + rm -f "$MIGRATION_RUN_LOG" "$MIGRATION_SHOW_LOG" "$HEALTH_FILE" + docker image rm dhv2-api:d53171-test dhv2-web:d53171-test >/dev/null 2>&1 || true +} +trap cleanup EXIT +trap 'echo; echo "ERROR D5.3.17.1 en línea $LINENO: $BASH_COMMAND"' ERR + +cd "$ROOT" +test -f "$ZIP" + +CURRENT_API="$(node -p "require('./api-v3/package.json').version")" +CURRENT_WEB="$(node -p "require('./web-v2/package.json').version")" +[[ "$CURRENT_API" == "0.19.3-16" ]] +[[ "$CURRENT_WEB" == "0.19.3-16" ]] + +echo "[1/10] Preflight y staging" +FREE_KB="$(df -Pk "$ROOT" | awk 'NR==2 {print $4}')" +if [[ ! "$FREE_KB" =~ ^[0-9]+$ ]] || (( FREE_KB < MIN_FREE_KB )); then + echo "ERROR: espacio libre insuficiente. Libre: $(( FREE_KB / 1024 )) MB" + exit 1 +fi +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}' 2>/dev/null || true)" == "healthy" ]] +docker compose exec -T db sh -lc 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' +mkdir -p "$STAGE" +unzip -q "$ZIP" -d "$STAGE" +[[ "$(node -p "require('$STAGE/api-v3/package.json').version")" == "0.19.3-17.1" ]] +[[ "$(node -p "require('$STAGE/web-v2/package.json').version")" == "0.19.3-17.1" ]] +grep -Fq "API_PHASE = 'D5.3.17.1'" "$STAGE/api-v3/src/version.ts" +grep -Fq "APP_PHASE = 'Fase D5.3.17.1 · Verificación de campo'" "$STAGE/web-v2/src/config/version.ts" + +echo "[2/10] Compilando API en staging" +docker build --target builder -t dhv2-api:d53171-test "$STAGE/api-v3" + +echo "[3/10] Ejecutando tests API en staging" +docker run --rm \ + -v "$STAGE/api-v3/test:/app/test:ro" \ + -v "$STAGE/api-v3/tsconfig.test.json:/app/tsconfig.test.json:ro" \ + dhv2-api:d53171-test npm test + +echo "[4/10] Compilando web en staging" +docker build -t dhv2-web:d53171-test "$STAGE/web-v2" + +echo "[5/10] Backup PRE D5.3.17.1" +mkdir -p "$PRE_BACKUP" +chmod 700 "$PRE_BACKUP" +docker compose exec -T db sh -lc 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > "$PRE_BACKUP/database.dump" +tar -czf "$PRE_BACKUP/source.tar.gz" \ + --exclude='api-v3/node_modules' --exclude='api-v3/dist' \ + --exclude='web-v2/node_modules' --exclude='web-v2/dist' \ + api-v3 web-v2 scripts docs docker-compose.yml .env.example .gitignore +if [[ -f .env ]]; then cp -a .env "$PRE_BACKUP/.env"; chmod 600 "$PRE_BACKUP/.env"; fi +STRONG_BACKUP="$(find "$BACKUP_ROOT" -maxdepth 1 -type d -name 'INVENTARIO_A_REVISAR_*' -printf '%T@ %p\n' | sort -nr | head -n1 | cut -d' ' -f2-)" +printf 'Backup fuerte conceptual previo: %s\nPalabra clave: INVENTARIO A REVISAR\n' "$STRONG_BACKUP" > "$PRE_BACKUP/INVENTARIO_A_REVISAR_REFERENCE.txt" +( + cd "$PRE_BACKUP" + sha256sum database.dump source.tar.gz INVENTARIO_A_REVISAR_REFERENCE.txt > SHA256SUMS.txt + [[ ! -f .env ]] || sha256sum .env >> SHA256SUMS.txt + sha256sum -c SHA256SUMS.txt +) + +echo "[6/10] Instalando source D5.3.17.1" +unzip -oq "$ZIP" -d "$ROOT" +chmod +x "$ROOT/scripts/deploy-phase-d5-3-17-1.sh" "$ROOT/scripts/phase-d5-3-17-1-acceptance.sh" +echo "API source: $(node -p "require('./api-v3/package.json').version")" +echo "WEB source: $(node -p "require('./web-v2/package.json').version")" + +echo "[7/10] Construyendo imágenes finales" +docker compose build api migrate web + +echo "[8/10] Aplicando migración D5.3.17.1" +docker compose --profile tools run --rm migrate npm run migration:run 2>&1 | tee "$MIGRATION_RUN_LOG" +docker compose --profile tools run --rm migrate npm run migration:show 2>&1 | tee "$MIGRATION_SHOW_LOG" +grep -Fq 'Pending migrations: no' "$MIGRATION_SHOW_LOG" + +echo "[9/10] Recreando API/web y aceptación" +docker compose up -d --force-recreate api web +for i in $(seq 1 40); do + if curl -fsS http://127.0.0.1:3101/api/v3/health >"$HEALTH_FILE" 2>/dev/null; then break; fi + if [[ "$i" == "40" ]]; then docker compose logs --tail=200 api; exit 1; fi + sleep 2 +done +cat "$HEALTH_FILE"; echo +grep -Fq '"version":"0.19.3-17.1"' "$HEALTH_FILE" +grep -Fq '"phase":"D5.3.17.1"' "$HEALTH_FILE" +grep -Fq '"database":"ok"' "$HEALTH_FILE" +bash "$ROOT/scripts/phase-d5-3-17-1-acceptance.sh" + +echo "[10/10] Backup POST" +mkdir -p "$POST_BACKUP" +chmod 700 "$POST_BACKUP" +docker compose exec -T db sh -lc 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > "$POST_BACKUP/database.dump" +docker compose ps -a > "$POST_BACKUP/docker-status.txt" +cp -a "$ZIP" "$POST_BACKUP/" +if [[ -f .env ]]; then cp -a .env "$POST_BACKUP/.env"; chmod 600 "$POST_BACKUP/.env"; fi +printf 'PRE D5.3.17.1: %s\nINVENTARIO A REVISAR: %s\n' "$PRE_BACKUP" "$STRONG_BACKUP" > "$POST_BACKUP/BACKUP_REFERENCES.txt" +( + cd "$POST_BACKUP" + sha256sum database.dump "$(basename "$ZIP")" BACKUP_REFERENCES.txt > SHA256SUMS.txt + [[ ! -f .env ]] || sha256sum .env >> SHA256SUMS.txt +) + +echo +echo "D5.3.17.1 desplegada correctamente" +echo "Backup PRE: $PRE_BACKUP" +echo "Backup fuerte INVENTARIO A REVISAR: $STRONG_BACKUP" +echo "Backup POST: $POST_BACKUP" diff --git a/scripts/deploy-phase-d5-3-17.sh b/scripts/deploy-phase-d5-3-17.sh new file mode 100755 index 0000000..7d5bf4d --- /dev/null +++ b/scripts/deploy-phase-d5-3-17.sh @@ -0,0 +1,122 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT="${ROOT:-/var/www/dhv2.korexlabs.com}" +ZIP="${ZIP:-$ROOT/DH_V2_FASE_D5_3_17_SOURCE.zip}" +STAMP="$(date +%Y%m%d_%H%M%S)" +STAGE="/root/dhv2-d5317-stage-$STAMP" +BACKUP_ROOT="/root/DH_V2_BACKUPS" +PRE_BACKUP="$BACKUP_ROOT/D5_3_17_PRE_$STAMP" +POST_BACKUP="$BACKUP_ROOT/D5_3_17_OK_$STAMP" +MIGRATION_RUN_LOG="/tmp/dhv2-d5317-migration-run-$STAMP.log" +MIGRATION_SHOW_LOG="/tmp/dhv2-d5317-migration-show-$STAMP.log" +HEALTH_FILE="/tmp/dhv2-d5317-health-$STAMP.json" +MIN_FREE_KB=$((4 * 1024 * 1024)) + +cleanup() { + rm -rf "$STAGE" + rm -f "$MIGRATION_RUN_LOG" "$MIGRATION_SHOW_LOG" "$HEALTH_FILE" + docker image rm dhv2-api:d5317-test dhv2-web:d5317-test >/dev/null 2>&1 || true +} +trap cleanup EXIT +trap 'echo; echo "ERROR D5.3.17 en línea $LINENO: $BASH_COMMAND"' ERR + +cd "$ROOT" +test -f "$ZIP" + +CURRENT_API="$(node -p "require('./api-v3/package.json').version")" +CURRENT_WEB="$(node -p "require('./web-v2/package.json').version")" +[[ "$CURRENT_API" == "0.19.3-16" ]] +[[ "$CURRENT_WEB" == "0.19.3-16" ]] + +echo "[1/10] Preflight y staging" +FREE_KB="$(df -Pk "$ROOT" | awk 'NR==2 {print $4}')" +if [[ ! "$FREE_KB" =~ ^[0-9]+$ ]] || (( FREE_KB < MIN_FREE_KB )); then + echo "ERROR: espacio libre insuficiente. Libre: $(( FREE_KB / 1024 )) MB" + exit 1 +fi +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}' 2>/dev/null || true)" == "healthy" ]] +docker compose exec -T db sh -lc 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' +mkdir -p "$STAGE" +unzip -q "$ZIP" -d "$STAGE" +[[ "$(node -p "require('$STAGE/api-v3/package.json').version")" == "0.19.3-17" ]] +[[ "$(node -p "require('$STAGE/web-v2/package.json').version")" == "0.19.3-17" ]] +grep -Fq "API_PHASE = 'D5.3.17'" "$STAGE/api-v3/src/version.ts" +grep -Fq "APP_PHASE = 'Fase D5.3.17 · Verificación de campo'" "$STAGE/web-v2/src/config/version.ts" + +echo "[2/10] Compilando API en staging" +docker build --target builder -t dhv2-api:d5317-test "$STAGE/api-v3" + +echo "[3/10] Ejecutando tests API en staging" +docker run --rm \ + -v "$STAGE/api-v3/test:/app/test:ro" \ + -v "$STAGE/api-v3/tsconfig.test.json:/app/tsconfig.test.json:ro" \ + dhv2-api:d5317-test npm test + +echo "[4/10] Compilando web en staging" +docker build -t dhv2-web:d5317-test "$STAGE/web-v2" + +echo "[5/10] Backup PRE D5.3.17" +mkdir -p "$PRE_BACKUP" +chmod 700 "$PRE_BACKUP" +docker compose exec -T db sh -lc 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > "$PRE_BACKUP/database.dump" +tar -czf "$PRE_BACKUP/source.tar.gz" \ + --exclude='api-v3/node_modules' --exclude='api-v3/dist' \ + --exclude='web-v2/node_modules' --exclude='web-v2/dist' \ + api-v3 web-v2 scripts docs docker-compose.yml .env.example .gitignore +if [[ -f .env ]]; then cp -a .env "$PRE_BACKUP/.env"; chmod 600 "$PRE_BACKUP/.env"; fi +STRONG_BACKUP="$(find "$BACKUP_ROOT" -maxdepth 1 -type d -name 'INVENTARIO_A_REVISAR_*' -printf '%T@ %p\n' | sort -nr | head -n1 | cut -d' ' -f2-)" +printf 'Backup fuerte conceptual previo: %s\nPalabra clave: INVENTARIO A REVISAR\n' "$STRONG_BACKUP" > "$PRE_BACKUP/INVENTARIO_A_REVISAR_REFERENCE.txt" +( + cd "$PRE_BACKUP" + sha256sum database.dump source.tar.gz INVENTARIO_A_REVISAR_REFERENCE.txt > SHA256SUMS.txt + [[ ! -f .env ]] || sha256sum .env >> SHA256SUMS.txt + sha256sum -c SHA256SUMS.txt +) + +echo "[6/10] Instalando source D5.3.17" +unzip -oq "$ZIP" -d "$ROOT" +chmod +x "$ROOT/scripts/deploy-phase-d5-3-17.sh" "$ROOT/scripts/phase-d5-3-17-acceptance.sh" +echo "API source: $(node -p "require('./api-v3/package.json').version")" +echo "WEB source: $(node -p "require('./web-v2/package.json').version")" + +echo "[7/10] Construyendo imágenes finales" +docker compose build api migrate web + +echo "[8/10] Aplicando migración D5.3.17" +docker compose --profile tools run --rm migrate npm run migration:run 2>&1 | tee "$MIGRATION_RUN_LOG" +docker compose --profile tools run --rm migrate npm run migration:show 2>&1 | tee "$MIGRATION_SHOW_LOG" +grep -Fq 'Pending migrations: no' "$MIGRATION_SHOW_LOG" + +echo "[9/10] Recreando API/web y aceptación" +docker compose up -d --force-recreate api web +for i in $(seq 1 40); do + if curl -fsS http://127.0.0.1:3101/api/v3/health >"$HEALTH_FILE" 2>/dev/null; then break; fi + if [[ "$i" == "40" ]]; then docker compose logs --tail=200 api; exit 1; fi + sleep 2 +done +cat "$HEALTH_FILE"; echo +grep -Fq '"version":"0.19.3-17"' "$HEALTH_FILE" +grep -Fq '"phase":"D5.3.17"' "$HEALTH_FILE" +grep -Fq '"database":"ok"' "$HEALTH_FILE" +bash "$ROOT/scripts/phase-d5-3-16-acceptance.sh" + +echo "[10/10] Backup POST" +mkdir -p "$POST_BACKUP" +chmod 700 "$POST_BACKUP" +docker compose exec -T db sh -lc 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > "$POST_BACKUP/database.dump" +docker compose ps -a > "$POST_BACKUP/docker-status.txt" +cp -a "$ZIP" "$POST_BACKUP/" +if [[ -f .env ]]; then cp -a .env "$POST_BACKUP/.env"; chmod 600 "$POST_BACKUP/.env"; fi +printf 'PRE D5.3.17: %s\nINVENTARIO A REVISAR: %s\n' "$PRE_BACKUP" "$STRONG_BACKUP" > "$POST_BACKUP/BACKUP_REFERENCES.txt" +( + cd "$POST_BACKUP" + sha256sum database.dump "$(basename "$ZIP")" BACKUP_REFERENCES.txt > SHA256SUMS.txt + [[ ! -f .env ]] || sha256sum .env >> SHA256SUMS.txt +) + +echo +echo "D5.3.17 desplegada correctamente" +echo "Backup PRE: $PRE_BACKUP" +echo "Backup fuerte INVENTARIO A REVISAR: $STRONG_BACKUP" +echo "Backup POST: $POST_BACKUP" diff --git a/scripts/deploy-phase-d5-3-18-2.sh b/scripts/deploy-phase-d5-3-18-2.sh new file mode 100755 index 0000000..33aa079 --- /dev/null +++ b/scripts/deploy-phase-d5-3-18-2.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT="${ROOT:-/var/www/dhv2.korexlabs.com}" +ZIP="${ZIP:-$ROOT/DH_V2_FASE_D5_3_18_2_SOURCE.zip}" +STAMP="$(date +%Y%m%d_%H%M%S)" +STAGE="/root/dhv2-d53182-stage-$STAMP" +BACKUP_ROOT="/root/DH_V2_BACKUPS" +PRE_BACKUP="$BACKUP_ROOT/D5_3_18_2_PRE_$STAMP" +POST_BACKUP="$BACKUP_ROOT/D5_3_18_2_OK_$STAMP" +MIGRATION_LOG="/tmp/dhv2-d53182-migration-$STAMP.log" +HEALTH_FILE="/tmp/dhv2-d53182-health-$STAMP.json" +MIN_FREE_KB=$((4 * 1024 * 1024)) + +cleanup() { + rm -rf "$STAGE" + rm -f "$MIGRATION_LOG" "$HEALTH_FILE" + docker image rm dhv2-api:d53182-test dhv2-web:d53182-test >/dev/null 2>&1 || true +} +trap cleanup EXIT +trap 'echo; echo "ERROR D5.3.18.2 en línea $LINENO: $BASH_COMMAND"' ERR + +cd "$ROOT" +test -f "$ZIP" + +CURRENT_API="$(node -p "require('./api-v3/package.json').version")" +CURRENT_WEB="$(node -p "require('./web-v2/package.json').version")" +[[ "$CURRENT_API" == "0.19.3-18" ]] +[[ "$CURRENT_WEB" == "0.19.3-18" ]] + +echo "[1/10] Preflight y staging" +FREE_KB="$(df -Pk "$ROOT" | awk 'NR==2 {print $4}')" +if [[ ! "$FREE_KB" =~ ^[0-9]+$ ]] || (( FREE_KB < MIN_FREE_KB )); then + echo "ERROR: espacio libre insuficiente. Libre: $(( FREE_KB / 1024 )) MB" + exit 1 +fi +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}' 2>/dev/null || true)" == "healthy" ]] +docker compose exec -T db sh -lc 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' +mkdir -p "$STAGE" +unzip -q "$ZIP" -d "$STAGE" +[[ "$(node -p "require('$STAGE/api-v3/package.json').version")" == "0.19.3-18.2" ]] +[[ "$(node -p "require('$STAGE/web-v2/package.json').version")" == "0.19.3-18.2" ]] +grep -Fq "API_PHASE = 'D5.3.18.2'" "$STAGE/api-v3/src/version.ts" +grep -Fq "APP_PHASE = 'Fase D5.3.18.2 · Expediente operativo'" "$STAGE/web-v2/src/config/version.ts" + +echo "[2/10] Compilando API en staging" +docker build --target builder -t dhv2-api:d53182-test "$STAGE/api-v3" + +echo "[3/10] Ejecutando tests API en staging" +docker run --rm \ + -v "$STAGE/api-v3/test:/app/test:ro" \ + -v "$STAGE/api-v3/tsconfig.test.json:/app/tsconfig.test.json:ro" \ + dhv2-api:d53182-test npm test + +echo "[4/10] Compilando web en staging" +docker build -t dhv2-web:d53182-test "$STAGE/web-v2" + +echo "[5/10] Backup PRE D5.3.18.2" +mkdir -p "$PRE_BACKUP" +chmod 700 "$PRE_BACKUP" +docker compose exec -T db sh -lc 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > "$PRE_BACKUP/database.dump" +tar -czf "$PRE_BACKUP/source.tar.gz" \ + --exclude='api-v3/node_modules' --exclude='api-v3/dist' \ + --exclude='web-v2/node_modules' --exclude='web-v2/dist' \ + api-v3 web-v2 scripts docs docker-compose.yml .env.example .gitignore +if [[ -f .env ]]; then cp -a .env "$PRE_BACKUP/.env"; chmod 600 "$PRE_BACKUP/.env"; fi +STRONG_BACKUP="$(find "$BACKUP_ROOT" -maxdepth 1 -type d -name 'INVENTARIO_A_REVISAR_*' -printf '%T@ %p\n' | sort -nr | head -n1 | cut -d' ' -f2-)" +printf 'Backup fuerte conceptual previo: %s\nPalabra clave: INVENTARIO A REVISAR\n' "$STRONG_BACKUP" > "$PRE_BACKUP/INVENTARIO_A_REVISAR_REFERENCE.txt" +( + cd "$PRE_BACKUP" + sha256sum database.dump source.tar.gz INVENTARIO_A_REVISAR_REFERENCE.txt > SHA256SUMS.txt + [[ ! -f .env ]] || sha256sum .env >> SHA256SUMS.txt + sha256sum -c SHA256SUMS.txt +) + +echo "[6/10] Instalando source D5.3.18.2" +unzip -oq "$ZIP" -d "$ROOT" +chmod +x "$ROOT/scripts/deploy-phase-d5-3-18-2.sh" "$ROOT/scripts/phase-d5-3-18-2-acceptance.sh" +echo "API source: $(node -p "require('./api-v3/package.json').version")" +echo "WEB source: $(node -p "require('./web-v2/package.json').version")" + +echo "[7/10] Construyendo imágenes finales" +docker compose build api migrate web + +echo "[8/10] Aplicando migración D5.3.18.2" +docker compose --profile tools run --rm migrate npm run migration:run +docker compose --profile tools run --rm migrate npm run migration:show 2>&1 | tee "$MIGRATION_LOG" +grep -Fq 'Pending migrations: no' "$MIGRATION_LOG" + +echo "[9/10] Recreando API/web y aceptación" +docker compose up -d --force-recreate api web +for i in $(seq 1 40); do + if curl -fsS http://127.0.0.1:3101/api/v3/health >"$HEALTH_FILE" 2>/dev/null; then break; fi + if [[ "$i" == "40" ]]; then docker compose logs --tail=200 api; exit 1; fi + sleep 2 +done +cat "$HEALTH_FILE"; echo +grep -Fq '"version":"0.19.3-18.2"' "$HEALTH_FILE" +grep -Fq '"phase":"D5.3.18.2"' "$HEALTH_FILE" +grep -Fq '"database":"ok"' "$HEALTH_FILE" +bash "$ROOT/scripts/phase-d5-3-18-2-acceptance.sh" + +echo "[10/10] Backup POST" +mkdir -p "$POST_BACKUP" +chmod 700 "$POST_BACKUP" +docker compose exec -T db sh -lc 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > "$POST_BACKUP/database.dump" +docker compose ps -a > "$POST_BACKUP/docker-status.txt" +cp -a "$ZIP" "$POST_BACKUP/" +if [[ -f .env ]]; then cp -a .env "$POST_BACKUP/.env"; chmod 600 "$POST_BACKUP/.env"; fi +printf 'PRE D5.3.18.2: %s\nINVENTARIO A REVISAR: %s\n' "$PRE_BACKUP" "$STRONG_BACKUP" > "$POST_BACKUP/BACKUP_REFERENCES.txt" +( + cd "$POST_BACKUP" + sha256sum database.dump "$(basename "$ZIP")" BACKUP_REFERENCES.txt > SHA256SUMS.txt + [[ ! -f .env ]] || sha256sum .env >> SHA256SUMS.txt +) + +echo +echo "D5.3.18.2 desplegada correctamente" +echo "Backup PRE: $PRE_BACKUP" +echo "Backup fuerte INVENTARIO A REVISAR: $STRONG_BACKUP" +echo "Backup POST: $POST_BACKUP" diff --git a/scripts/deploy-phase-d5-3-18-3.sh b/scripts/deploy-phase-d5-3-18-3.sh new file mode 100755 index 0000000..84ea3a2 --- /dev/null +++ b/scripts/deploy-phase-d5-3-18-3.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT="${ROOT:-/var/www/dhv2.korexlabs.com}" +ZIP="${ZIP:-$ROOT/DH_V2_FASE_D5_3_18_3_SOURCE.zip}" +STAMP="$(date +%Y%m%d_%H%M%S)" +STAGE="/root/dhv2-d53183-stage-$STAMP" +BACKUP_ROOT="/root/DH_V2_BACKUPS" +PRE_BACKUP="$BACKUP_ROOT/D5_3_18_3_PRE_$STAMP" +POST_BACKUP="$BACKUP_ROOT/D5_3_18_3_OK_$STAMP" +MIGRATION_LOG="/tmp/dhv2-d53183-migration-$STAMP.log" +HEALTH_FILE="/tmp/dhv2-d53183-health-$STAMP.json" +MIN_FREE_KB=$((4 * 1024 * 1024)) + +cleanup() { + rm -rf "$STAGE" + rm -f "$MIGRATION_LOG" "$HEALTH_FILE" + docker image rm dhv2-api:d53183-test dhv2-web:d53183-test >/dev/null 2>&1 || true +} +trap cleanup EXIT +trap 'echo; echo "ERROR D5.3.18.3 en línea $LINENO: $BASH_COMMAND"' ERR + +cd "$ROOT" +test -f "$ZIP" + +CURRENT_API="$(node -p "require('./api-v3/package.json').version")" +CURRENT_WEB="$(node -p "require('./web-v2/package.json').version")" +[[ "$CURRENT_API" == "0.19.3-18" ]] +[[ "$CURRENT_WEB" == "0.19.3-18" ]] + +echo "[1/10] Preflight y staging" +FREE_KB="$(df -Pk "$ROOT" | awk 'NR==2 {print $4}')" +if [[ ! "$FREE_KB" =~ ^[0-9]+$ ]] || (( FREE_KB < MIN_FREE_KB )); then + echo "ERROR: espacio libre insuficiente. Libre: $(( FREE_KB / 1024 )) MB" + exit 1 +fi +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}' 2>/dev/null || true)" == "healthy" ]] +docker compose exec -T db sh -lc 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' +mkdir -p "$STAGE" +unzip -q "$ZIP" -d "$STAGE" +[[ "$(node -p "require('$STAGE/api-v3/package.json').version")" == "0.19.3-18.3" ]] +[[ "$(node -p "require('$STAGE/web-v2/package.json').version")" == "0.19.3-18.3" ]] +grep -Fq "API_PHASE = 'D5.3.18.3'" "$STAGE/api-v3/src/version.ts" +grep -Fq "APP_PHASE = 'Fase D5.3.18.3 · Expediente operativo'" "$STAGE/web-v2/src/config/version.ts" + +echo "[2/10] Compilando API en staging" +docker build --target builder -t dhv2-api:d53183-test "$STAGE/api-v3" + +echo "[3/10] Ejecutando tests API en staging" +docker run --rm \ + -v "$STAGE/api-v3/test:/app/test:ro" \ + -v "$STAGE/api-v3/tsconfig.test.json:/app/tsconfig.test.json:ro" \ + dhv2-api:d53183-test npm test + +echo "[4/10] Compilando web en staging" +docker build -t dhv2-web:d53183-test "$STAGE/web-v2" + +echo "[5/10] Backup PRE D5.3.18.3" +mkdir -p "$PRE_BACKUP" +chmod 700 "$PRE_BACKUP" +docker compose exec -T db sh -lc 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > "$PRE_BACKUP/database.dump" +tar -czf "$PRE_BACKUP/source.tar.gz" \ + --exclude='api-v3/node_modules' --exclude='api-v3/dist' \ + --exclude='web-v2/node_modules' --exclude='web-v2/dist' \ + api-v3 web-v2 scripts docs docker-compose.yml .env.example .gitignore +if [[ -f .env ]]; then cp -a .env "$PRE_BACKUP/.env"; chmod 600 "$PRE_BACKUP/.env"; fi +STRONG_BACKUP="$(find "$BACKUP_ROOT" -maxdepth 1 -type d -name 'INVENTARIO_A_REVISAR_*' -printf '%T@ %p\n' | sort -nr | head -n1 | cut -d' ' -f2-)" +printf 'Backup fuerte conceptual previo: %s\nPalabra clave: INVENTARIO A REVISAR\n' "$STRONG_BACKUP" > "$PRE_BACKUP/INVENTARIO_A_REVISAR_REFERENCE.txt" +( + cd "$PRE_BACKUP" + sha256sum database.dump source.tar.gz INVENTARIO_A_REVISAR_REFERENCE.txt > SHA256SUMS.txt + [[ ! -f .env ]] || sha256sum .env >> SHA256SUMS.txt + sha256sum -c SHA256SUMS.txt +) + +echo "[6/10] Instalando source D5.3.18.3" +unzip -oq "$ZIP" -d "$ROOT" +chmod +x "$ROOT/scripts/deploy-phase-d5-3-18-3.sh" "$ROOT/scripts/phase-d5-3-18-3-acceptance.sh" +echo "API source: $(node -p "require('./api-v3/package.json').version")" +echo "WEB source: $(node -p "require('./web-v2/package.json').version")" + +echo "[7/10] Construyendo imágenes finales" +docker compose build api migrate web + +echo "[8/10] Aplicando migración D5.3.18.3" +docker compose --profile tools run --rm migrate npm run migration:run +docker compose --profile tools run --rm migrate npm run migration:show 2>&1 | tee "$MIGRATION_LOG" +grep -Fq 'Pending migrations: no' "$MIGRATION_LOG" + +echo "[9/10] Recreando API/web y aceptación" +docker compose up -d --force-recreate api web +for i in $(seq 1 40); do + if curl -fsS http://127.0.0.1:3101/api/v3/health >"$HEALTH_FILE" 2>/dev/null; then break; fi + if [[ "$i" == "40" ]]; then docker compose logs --tail=200 api; exit 1; fi + sleep 2 +done +cat "$HEALTH_FILE"; echo +grep -Fq '"version":"0.19.3-18.3"' "$HEALTH_FILE" +grep -Fq '"phase":"D5.3.18.3"' "$HEALTH_FILE" +grep -Fq '"database":"ok"' "$HEALTH_FILE" +bash "$ROOT/scripts/phase-d5-3-18-3-acceptance.sh" + +echo "[10/10] Backup POST" +mkdir -p "$POST_BACKUP" +chmod 700 "$POST_BACKUP" +docker compose exec -T db sh -lc 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > "$POST_BACKUP/database.dump" +docker compose ps -a > "$POST_BACKUP/docker-status.txt" +cp -a "$ZIP" "$POST_BACKUP/" +if [[ -f .env ]]; then cp -a .env "$POST_BACKUP/.env"; chmod 600 "$POST_BACKUP/.env"; fi +printf 'PRE D5.3.18.3: %s\nINVENTARIO A REVISAR: %s\n' "$PRE_BACKUP" "$STRONG_BACKUP" > "$POST_BACKUP/BACKUP_REFERENCES.txt" +( + cd "$POST_BACKUP" + sha256sum database.dump "$(basename "$ZIP")" BACKUP_REFERENCES.txt > SHA256SUMS.txt + [[ ! -f .env ]] || sha256sum .env >> SHA256SUMS.txt +) + +echo +echo "D5.3.18.3 desplegada correctamente" +echo "Backup PRE: $PRE_BACKUP" +echo "Backup fuerte INVENTARIO A REVISAR: $STRONG_BACKUP" +echo "Backup POST: $POST_BACKUP" diff --git a/scripts/deploy-phase-d5-3-18.sh b/scripts/deploy-phase-d5-3-18.sh new file mode 100755 index 0000000..2514afa --- /dev/null +++ b/scripts/deploy-phase-d5-3-18.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT="${ROOT:-/var/www/dhv2.korexlabs.com}" +ZIP="${ZIP:-$ROOT/DH_V2_FASE_D5_3_18_SOURCE.zip}" +STAMP="$(date +%Y%m%d_%H%M%S)" +STAGE="/root/dhv2-d5318-stage-$STAMP" +BACKUP_ROOT="/root/DH_V2_BACKUPS" +PRE_BACKUP="$BACKUP_ROOT/D5_3_18_PRE_$STAMP" +POST_BACKUP="$BACKUP_ROOT/D5_3_18_OK_$STAMP" +MIGRATION_LOG="/tmp/dhv2-d5318-migration-$STAMP.log" +HEALTH_FILE="/tmp/dhv2-d5318-health-$STAMP.json" +MIN_FREE_KB=$((4 * 1024 * 1024)) + +cleanup() { + rm -rf "$STAGE" + rm -f "$MIGRATION_LOG" "$HEALTH_FILE" + docker image rm dhv2-api:d5318-test dhv2-web:d5318-test >/dev/null 2>&1 || true +} +trap cleanup EXIT +trap 'echo; echo "ERROR D5.3.18 en línea $LINENO: $BASH_COMMAND"' ERR + +cd "$ROOT" +test -f "$ZIP" + +CURRENT_API="$(node -p "require('./api-v3/package.json').version")" +CURRENT_WEB="$(node -p "require('./web-v2/package.json').version")" +[[ "$CURRENT_API" == "0.19.3-17.1" ]] +[[ "$CURRENT_WEB" == "0.19.3-17.1" ]] + +echo "[1/10] Preflight y staging" +FREE_KB="$(df -Pk "$ROOT" | awk 'NR==2 {print $4}')" +if [[ ! "$FREE_KB" =~ ^[0-9]+$ ]] || (( FREE_KB < MIN_FREE_KB )); then + echo "ERROR: espacio libre insuficiente. Libre: $(( FREE_KB / 1024 )) MB" + exit 1 +fi +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}' 2>/dev/null || true)" == "healthy" ]] +docker compose exec -T db sh -lc 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' +mkdir -p "$STAGE" +unzip -q "$ZIP" -d "$STAGE" +[[ "$(node -p "require('$STAGE/api-v3/package.json').version")" == "0.19.3-18" ]] +[[ "$(node -p "require('$STAGE/web-v2/package.json').version")" == "0.19.3-18" ]] +grep -Fq "API_PHASE = 'D5.3.18'" "$STAGE/api-v3/src/version.ts" +grep -Fq "APP_PHASE = 'Fase D5.3.18 · Expediente operativo'" "$STAGE/web-v2/src/config/version.ts" + +echo "[2/10] Compilando API en staging" +docker build --target builder -t dhv2-api:d5318-test "$STAGE/api-v3" + +echo "[3/10] Ejecutando tests API en staging" +docker run --rm \ + -v "$STAGE/api-v3/test:/app/test:ro" \ + -v "$STAGE/api-v3/tsconfig.test.json:/app/tsconfig.test.json:ro" \ + dhv2-api:d5318-test npm test + +echo "[4/10] Compilando web en staging" +docker build -t dhv2-web:d5318-test "$STAGE/web-v2" + +echo "[5/10] Backup PRE D5.3.18" +mkdir -p "$PRE_BACKUP" +chmod 700 "$PRE_BACKUP" +docker compose exec -T db sh -lc 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > "$PRE_BACKUP/database.dump" +tar -czf "$PRE_BACKUP/source.tar.gz" \ + --exclude='api-v3/node_modules' --exclude='api-v3/dist' \ + --exclude='web-v2/node_modules' --exclude='web-v2/dist' \ + api-v3 web-v2 scripts docs docker-compose.yml .env.example .gitignore +if [[ -f .env ]]; then cp -a .env "$PRE_BACKUP/.env"; chmod 600 "$PRE_BACKUP/.env"; fi +STRONG_BACKUP="$(find "$BACKUP_ROOT" -maxdepth 1 -type d -name 'INVENTARIO_A_REVISAR_*' -printf '%T@ %p\n' | sort -nr | head -n1 | cut -d' ' -f2-)" +printf 'Backup fuerte conceptual previo: %s\nPalabra clave: INVENTARIO A REVISAR\n' "$STRONG_BACKUP" > "$PRE_BACKUP/INVENTARIO_A_REVISAR_REFERENCE.txt" +( + cd "$PRE_BACKUP" + sha256sum database.dump source.tar.gz INVENTARIO_A_REVISAR_REFERENCE.txt > SHA256SUMS.txt + [[ ! -f .env ]] || sha256sum .env >> SHA256SUMS.txt + sha256sum -c SHA256SUMS.txt +) + +echo "[6/10] Instalando source D5.3.18" +unzip -oq "$ZIP" -d "$ROOT" +chmod +x "$ROOT/scripts/deploy-phase-d5-3-18.sh" "$ROOT/scripts/phase-d5-3-18-acceptance.sh" +echo "API source: $(node -p "require('./api-v3/package.json').version")" +echo "WEB source: $(node -p "require('./web-v2/package.json').version")" + +echo "[7/10] Construyendo imágenes finales" +docker compose build api migrate web + +echo "[8/10] Verificando que D5.3.18 no tenga migraciones pendientes" +docker compose --profile tools run --rm migrate npm run migration:show 2>&1 | tee "$MIGRATION_LOG" +grep -Fq 'Pending migrations: no' "$MIGRATION_LOG" + +echo "[9/10] Recreando API/web y aceptación" +docker compose up -d --force-recreate api web +for i in $(seq 1 40); do + if curl -fsS http://127.0.0.1:3101/api/v3/health >"$HEALTH_FILE" 2>/dev/null; then break; fi + if [[ "$i" == "40" ]]; then docker compose logs --tail=200 api; exit 1; fi + sleep 2 +done +cat "$HEALTH_FILE"; echo +grep -Fq '"version":"0.19.3-18"' "$HEALTH_FILE" +grep -Fq '"phase":"D5.3.18"' "$HEALTH_FILE" +grep -Fq '"database":"ok"' "$HEALTH_FILE" +bash "$ROOT/scripts/phase-d5-3-18-acceptance.sh" + +echo "[10/10] Backup POST" +mkdir -p "$POST_BACKUP" +chmod 700 "$POST_BACKUP" +docker compose exec -T db sh -lc 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > "$POST_BACKUP/database.dump" +docker compose ps -a > "$POST_BACKUP/docker-status.txt" +cp -a "$ZIP" "$POST_BACKUP/" +if [[ -f .env ]]; then cp -a .env "$POST_BACKUP/.env"; chmod 600 "$POST_BACKUP/.env"; fi +printf 'PRE D5.3.18: %s\nINVENTARIO A REVISAR: %s\n' "$PRE_BACKUP" "$STRONG_BACKUP" > "$POST_BACKUP/BACKUP_REFERENCES.txt" +( + cd "$POST_BACKUP" + sha256sum database.dump "$(basename "$ZIP")" BACKUP_REFERENCES.txt > SHA256SUMS.txt + [[ ! -f .env ]] || sha256sum .env >> SHA256SUMS.txt +) + +echo +echo "D5.3.18 desplegada correctamente" +echo "Backup PRE: $PRE_BACKUP" +echo "Backup fuerte INVENTARIO A REVISAR: $STRONG_BACKUP" +echo "Backup POST: $POST_BACKUP" diff --git a/scripts/deploy-phase-d5-3-19-1.sh b/scripts/deploy-phase-d5-3-19-1.sh new file mode 100755 index 0000000..f52bdb3 --- /dev/null +++ b/scripts/deploy-phase-d5-3-19-1.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT="${ROOT:-/var/www/dhv2.korexlabs.com}" +ZIP="${ZIP:-$ROOT/DH_V2_FASE_D5_3_19_1_SOURCE.zip}" +STAMP="$(date +%Y%m%d_%H%M%S)" +STAGE="/root/dhv2-d53191-stage-$STAMP" +BACKUP_ROOT="/root/DH_V2_BACKUPS" +PRE_BACKUP="$BACKUP_ROOT/D5_3_19_1_PRE_$STAMP" +POST_BACKUP="$BACKUP_ROOT/D5_3_19_1_OK_$STAMP" +MIGRATION_LOG="/tmp/dhv2-d53191-migration-$STAMP.log" +HEALTH_FILE="/tmp/dhv2-d53191-health-$STAMP.json" +MIN_FREE_KB=$((4 * 1024 * 1024)) + +cleanup() { + rm -rf "$STAGE" + rm -f "$MIGRATION_LOG" "$HEALTH_FILE" + docker image rm dhv2-api:d53191-test dhv2-web:d53191-test >/dev/null 2>&1 || true +} +trap cleanup EXIT +trap 'echo; echo "ERROR D5.3.19.1 en línea $LINENO: $BASH_COMMAND"' ERR + +cd "$ROOT" +test -f "$ZIP" + +CURRENT_API="$(node -p "require('./api-v3/package.json').version")" +CURRENT_WEB="$(node -p "require('./web-v2/package.json').version")" +[[ "$CURRENT_API" == "0.19.3-18.3" ]] +[[ "$CURRENT_WEB" == "0.19.3-18.3" ]] + +echo "[1/10] Preflight y staging" +FREE_KB="$(df -Pk "$ROOT" | awk 'NR==2 {print $4}')" +if [[ ! "$FREE_KB" =~ ^[0-9]+$ ]] || (( FREE_KB < MIN_FREE_KB )); then + echo "ERROR: espacio libre insuficiente. Libre: $(( FREE_KB / 1024 )) MB" + exit 1 +fi +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}' 2>/dev/null || true)" == "healthy" ]] +docker compose exec -T db sh -lc 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' +mkdir -p "$STAGE" +unzip -q "$ZIP" -d "$STAGE" +[[ "$(node -p "require('$STAGE/api-v3/package.json').version")" == "0.19.3-19.1" ]] +[[ "$(node -p "require('$STAGE/web-v2/package.json').version")" == "0.19.3-19.1" ]] +grep -Fq "API_PHASE = 'D5.3.19.1'" "$STAGE/api-v3/src/version.ts" +grep -Fq "APP_PHASE = 'Fase D5.3.19.1 · Inventarios y filtros operativos'" "$STAGE/web-v2/src/config/version.ts" + +echo "[2/10] Compilando API en staging" +docker build --target builder -t dhv2-api:d53191-test "$STAGE/api-v3" + +echo "[3/10] Ejecutando tests API en staging" +docker run --rm \ + -v "$STAGE/api-v3/test:/app/test:ro" \ + -v "$STAGE/api-v3/tsconfig.test.json:/app/tsconfig.test.json:ro" \ + dhv2-api:d53191-test npm test + +echo "[4/10] Compilando web en staging" +docker build -t dhv2-web:d53191-test "$STAGE/web-v2" + +echo "[5/10] Backup PRE D5.3.19.1" +mkdir -p "$PRE_BACKUP" +chmod 700 "$PRE_BACKUP" +docker compose exec -T db sh -lc 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > "$PRE_BACKUP/database.dump" +tar -czf "$PRE_BACKUP/source.tar.gz" \ + --exclude='api-v3/node_modules' --exclude='api-v3/dist' \ + --exclude='web-v2/node_modules' --exclude='web-v2/dist' \ + api-v3 web-v2 scripts docs docker-compose.yml .env.example .gitignore +if [[ -f .env ]]; then cp -a .env "$PRE_BACKUP/.env"; chmod 600 "$PRE_BACKUP/.env"; fi +STRONG_BACKUP="$(find "$BACKUP_ROOT" -maxdepth 1 -type d -name 'INVENTARIO_A_REVISAR_*' -printf '%T@ %p\n' | sort -nr | head -n1 | cut -d' ' -f2-)" +printf 'Backup fuerte conceptual previo: %s\nPalabra clave: INVENTARIO A REVISAR\n' "$STRONG_BACKUP" > "$PRE_BACKUP/INVENTARIO_A_REVISAR_REFERENCE.txt" +( + cd "$PRE_BACKUP" + sha256sum database.dump source.tar.gz INVENTARIO_A_REVISAR_REFERENCE.txt > SHA256SUMS.txt + [[ ! -f .env ]] || sha256sum .env >> SHA256SUMS.txt + sha256sum -c SHA256SUMS.txt +) + +echo "[6/10] Instalando source D5.3.19.1" +unzip -oq "$ZIP" -d "$ROOT" +chmod +x "$ROOT/scripts/deploy-phase-d5-3-19-1.sh" "$ROOT/scripts/phase-d5-3-19-1-acceptance.sh" +echo "API source: $(node -p "require('./api-v3/package.json').version")" +echo "WEB source: $(node -p "require('./web-v2/package.json').version")" + +echo "[7/10] Construyendo imágenes finales" +docker compose build api migrate web + +echo "[8/10] Aplicando migración D5.3.19.1" +docker compose --profile tools run --rm migrate npm run migration:run +docker compose --profile tools run --rm migrate npm run migration:show 2>&1 | tee "$MIGRATION_LOG" +grep -Fq 'Pending migrations: no' "$MIGRATION_LOG" + +echo "[9/10] Recreando API/web y aceptación" +docker compose up -d --force-recreate api web +for i in $(seq 1 40); do + if curl -fsS http://127.0.0.1:3101/api/v3/health >"$HEALTH_FILE" 2>/dev/null; then break; fi + if [[ "$i" == "40" ]]; then docker compose logs --tail=200 api; exit 1; fi + sleep 2 +done +cat "$HEALTH_FILE"; echo +grep -Fq '"version":"0.19.3-19.1"' "$HEALTH_FILE" +grep -Fq '"phase":"D5.3.19.1"' "$HEALTH_FILE" +grep -Fq '"database":"ok"' "$HEALTH_FILE" +bash "$ROOT/scripts/phase-d5-3-19-1-acceptance.sh" + +echo "[10/10] Backup POST" +mkdir -p "$POST_BACKUP" +chmod 700 "$POST_BACKUP" +docker compose exec -T db sh -lc 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > "$POST_BACKUP/database.dump" +docker compose ps -a > "$POST_BACKUP/docker-status.txt" +cp -a "$ZIP" "$POST_BACKUP/" +if [[ -f .env ]]; then cp -a .env "$POST_BACKUP/.env"; chmod 600 "$POST_BACKUP/.env"; fi +printf 'PRE D5.3.19.1: %s\nINVENTARIO A REVISAR: %s\n' "$PRE_BACKUP" "$STRONG_BACKUP" > "$POST_BACKUP/BACKUP_REFERENCES.txt" +( + cd "$POST_BACKUP" + sha256sum database.dump "$(basename "$ZIP")" BACKUP_REFERENCES.txt > SHA256SUMS.txt + [[ ! -f .env ]] || sha256sum .env >> SHA256SUMS.txt +) + +echo +echo "D5.3.19.1 desplegada correctamente" +echo "Backup PRE: $PRE_BACKUP" +echo "Backup fuerte INVENTARIO A REVISAR: $STRONG_BACKUP" +echo "Backup POST: $POST_BACKUP" diff --git a/scripts/deploy-phase-d5-3-2-1.sh b/scripts/deploy-phase-d5-3-2-1.sh new file mode 100755 index 0000000..ab92a1b --- /dev/null +++ b/scripts/deploy-phase-d5-3-2-1.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT="${ROOT:-/var/www/dhv2.korexlabs.com}" +ZIP="${ZIP:-$ROOT/DH_V2_FASE_D5_3_2_1_SOURCE.zip}" +STAMP="$(date +%Y%m%d_%H%M%S)" +STAGE="/root/dhv2-d5321-stage-$STAMP" +BACKUP_ROOT="/root/DH_V2_BACKUPS" +PRE_BACKUP="$BACKUP_ROOT/D5_3_2_1_PRE_$STAMP" +POST_BACKUP="$BACKUP_ROOT/D5_3_2_1_OK_$STAMP" + +cleanup() { rm -rf "$STAGE"; } +trap cleanup EXIT +trap 'echo; echo "ERROR D5.3.2.1 en línea $LINENO: $BASH_COMMAND"' ERR + +cd "$ROOT" +test -f "$ZIP" + +echo "[1/10] Preparando staging" +mkdir -p "$STAGE" +unzip -q "$ZIP" -d "$STAGE" + +echo "[2/10] Compilando API en staging" +docker build --target builder -t dhv2-api:d5321-test "$STAGE/api-v3" + +echo "[3/10] Ejecutando tests API en staging" +docker run --rm \ + -v "$STAGE/api-v3/test:/app/test:ro" \ + -v "$STAGE/api-v3/tsconfig.test.json:/app/tsconfig.test.json:ro" \ + dhv2-api:d5321-test npm test + +echo "[4/10] Compilando web en staging" +docker build -t dhv2-web:d5321-test "$STAGE/web-v2" + +echo "[5/10] Backup PRE" +mkdir -p "$PRE_BACKUP" +docker compose exec -T db sh -lc \ + 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' \ + > "$PRE_BACKUP/database.dump" +tar \ + --exclude='api-v3/node_modules' --exclude='api-v3/dist' \ + --exclude='web-v2/node_modules' --exclude='web-v2/dist' \ + --exclude='.git' --exclude='DH_V2_FASE_*.zip' \ + -czf "$PRE_BACKUP/source.tar.gz" \ + api-v3 web-v2 docs scripts infra docker-compose.yml README.md .env.example +if [[ -f .env ]]; then cp -a .env "$PRE_BACKUP/.env"; chmod 600 "$PRE_BACKUP/.env"; fi + +echo "[6/10] Instalando source" +unzip -oq "$ZIP" -d "$ROOT" +chmod +x \ + "$ROOT/scripts/deploy-phase-d5-3-2-1.sh" \ + "$ROOT/scripts/phase-d5-3-2-1-acceptance.sh" \ + "$ROOT/scripts/dev-seed-mendoza-demo.sh" \ + "$ROOT/scripts/dev-clean-demo.sh" + +echo "API source: $(node -p "require('./api-v3/package.json').version")" +echo "WEB source: $(node -p "require('./web-v2/package.json').version")" + +echo "[7/10] Construyendo imágenes finales" +docker compose build api migrate web + +echo "[8/10] Verificando migraciones" +MIGRATION_OUTPUT="$(docker compose --profile tools run --rm migrate npm run migration:show 2>&1)" +echo "$MIGRATION_OUTPUT" +grep -q 'Pending migrations: no' <<<"$MIGRATION_OUTPUT" + +echo "[9/10] Recreando API y web" +docker compose up -d --force-recreate api web +for i in $(seq 1 40); do + if curl -fsS http://127.0.0.1:3101/api/v3/health >/tmp/dhv2-d5321-health.json 2>/dev/null; then + break + fi + if [[ "$i" == "40" ]]; then + docker compose logs --tail=200 api + exit 1 + fi + sleep 2 +done +cat /tmp/dhv2-d5321-health.json; echo + +echo "[10/10] Aceptación y backup POST" +bash "$ROOT/scripts/phase-d5-3-2-1-acceptance.sh" +mkdir -p "$POST_BACKUP" +docker compose exec -T db sh -lc \ + 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' \ + > "$POST_BACKUP/database.dump" +docker compose ps -a > "$POST_BACKUP/docker-status.txt" +cp -a "$ZIP" "$POST_BACKUP/" +if [[ -f .env ]]; then cp -a .env "$POST_BACKUP/.env"; chmod 600 "$POST_BACKUP/.env"; fi +sha256sum "$POST_BACKUP/$(basename "$ZIP")" > "$POST_BACKUP/SHA256SUMS.txt" + +echo +echo "D5.3.2.1 desplegada correctamente" +echo "Backup PRE : $PRE_BACKUP" +echo "Backup POST: $POST_BACKUP" diff --git a/scripts/deploy-phase-d5-3-2.sh b/scripts/deploy-phase-d5-3-2.sh new file mode 100755 index 0000000..4520f24 --- /dev/null +++ b/scripts/deploy-phase-d5-3-2.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT="${ROOT:-/var/www/dhv2.korexlabs.com}" +ZIP="${ZIP:-$ROOT/DH_V2_FASE_D5_3_2_SOURCE.zip}" +STAMP="$(date +%Y%m%d_%H%M%S)" +STAGE="/root/dhv2-d532-stage-$STAMP" +BACKUP_ROOT="/root/DH_V2_BACKUPS" +PRE_BACKUP="$BACKUP_ROOT/D5_3_2_PRE_$STAMP" +POST_BACKUP="$BACKUP_ROOT/D5_3_2_OK_$STAMP" + +cleanup() { rm -rf "$STAGE"; } +trap cleanup EXIT +trap 'echo; echo "ERROR D5.3.2 en línea $LINENO: $BASH_COMMAND"' ERR + +cd "$ROOT" +test -f "$ZIP" + +echo "[1/10] Preparando staging" +mkdir -p "$STAGE" +unzip -q "$ZIP" -d "$STAGE" + +echo "[2/10] Compilando API en staging" +docker build --target builder -t dhv2-api:d532-test "$STAGE/api-v3" + +echo "[3/10] Ejecutando tests API en staging" +docker run --rm \ + -v "$STAGE/api-v3/test:/app/test:ro" \ + -v "$STAGE/api-v3/tsconfig.test.json:/app/tsconfig.test.json:ro" \ + dhv2-api:d532-test npm test + +echo "[4/10] Compilando web en staging" +docker build -t dhv2-web:d532-test "$STAGE/web-v2" + +echo "[5/10] Backup PRE" +mkdir -p "$PRE_BACKUP" +docker compose exec -T db sh -lc \ + 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' \ + > "$PRE_BACKUP/database.dump" +tar \ + --exclude='api-v3/node_modules' --exclude='api-v3/dist' \ + --exclude='web-v2/node_modules' --exclude='web-v2/dist' \ + --exclude='.git' --exclude='DH_V2_FASE_*.zip' \ + -czf "$PRE_BACKUP/source.tar.gz" \ + api-v3 web-v2 docs scripts infra docker-compose.yml README.md .env.example +if [[ -f .env ]]; then cp -a .env "$PRE_BACKUP/.env"; chmod 600 "$PRE_BACKUP/.env"; fi + +echo "[6/10] Instalando source" +unzip -oq "$ZIP" -d "$ROOT" +chmod +x \ + "$ROOT/scripts/deploy-phase-d5-3-2.sh" \ + "$ROOT/scripts/phase-d5-3-2-acceptance.sh" \ + "$ROOT/scripts/dev-seed-mendoza-demo.sh" \ + "$ROOT/scripts/dev-clean-demo.sh" + +echo "API source: $(node -p "require('./api-v3/package.json').version")" +echo "WEB source: $(node -p "require('./web-v2/package.json').version")" + +echo "[7/10] Construyendo imágenes finales" +docker compose build api migrate web + +echo "[8/10] Verificando migraciones" +MIGRATION_OUTPUT="$(docker compose --profile tools run --rm migrate npm run migration:show 2>&1)" +echo "$MIGRATION_OUTPUT" +grep -q 'Pending migrations: no' <<<"$MIGRATION_OUTPUT" + +echo "[9/10] Recreando API y web" +docker compose up -d --force-recreate api web +for i in $(seq 1 40); do + if curl -fsS http://127.0.0.1:3101/api/v3/health >/tmp/dhv2-d532-health.json 2>/dev/null; then + break + fi + if [[ "$i" == "40" ]]; then + docker compose logs --tail=200 api + exit 1 + fi + sleep 2 +done +cat /tmp/dhv2-d532-health.json; echo + +echo "[10/10] Aceptación y backup POST" +bash "$ROOT/scripts/phase-d5-3-2-acceptance.sh" +mkdir -p "$POST_BACKUP" +docker compose exec -T db sh -lc \ + 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' \ + > "$POST_BACKUP/database.dump" +docker compose ps -a > "$POST_BACKUP/docker-status.txt" +cp -a "$ZIP" "$POST_BACKUP/" +if [[ -f .env ]]; then cp -a .env "$POST_BACKUP/.env"; chmod 600 "$POST_BACKUP/.env"; fi +sha256sum "$POST_BACKUP/$(basename "$ZIP")" > "$POST_BACKUP/SHA256SUMS.txt" + +echo +echo "D5.3.2 desplegada correctamente" +echo "Backup PRE : $PRE_BACKUP" +echo "Backup POST: $POST_BACKUP" diff --git a/scripts/deploy-phase-d5-3-20.sh b/scripts/deploy-phase-d5-3-20.sh new file mode 100755 index 0000000..6bf288e --- /dev/null +++ b/scripts/deploy-phase-d5-3-20.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT="${ROOT:-/var/www/dhv2.korexlabs.com}" +ZIP="${ZIP:-$ROOT/DH_V2_FASE_D5_3_20_SOURCE.zip}" +STAMP="$(date +%Y%m%d_%H%M%S)" +STAGE="/root/dhv2-d5320-stage-$STAMP" +BACKUP_ROOT="/root/DH_V2_BACKUPS" +PRE_BACKUP="$BACKUP_ROOT/D5_3_20_PRE_$STAMP" +POST_BACKUP="$BACKUP_ROOT/D5_3_20_OK_$STAMP" +MIGRATION_LOG="/tmp/dhv2-d5320-migration-$STAMP.log" +HEALTH_FILE="/tmp/dhv2-d5320-health-$STAMP.json" +MIN_FREE_KB=$((4 * 1024 * 1024)) + +cleanup() { + rm -rf "$STAGE" + rm -f "$MIGRATION_LOG" "$HEALTH_FILE" + docker image rm dhv2-api:d5320-test dhv2-web:d5320-test >/dev/null 2>&1 || true +} +trap cleanup EXIT +trap 'echo; echo "ERROR D5.3.20 en línea $LINENO: $BASH_COMMAND"' ERR + +cd "$ROOT" +test -f "$ZIP" + +CURRENT_API="$(node -p "require('./api-v3/package.json').version")" +CURRENT_WEB="$(node -p "require('./web-v2/package.json').version")" +[[ "$CURRENT_API" == "0.19.3-19.1" ]] +[[ "$CURRENT_WEB" == "0.19.3-19.1" ]] + +echo "[1/10] Preflight y staging" +FREE_KB="$(df -Pk "$ROOT" | awk 'NR==2 {print $4}')" +if [[ ! "$FREE_KB" =~ ^[0-9]+$ ]] || (( FREE_KB < MIN_FREE_KB )); then + echo "ERROR: espacio libre insuficiente. Libre: $(( FREE_KB / 1024 )) MB" + exit 1 +fi +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}' 2>/dev/null || true)" == "healthy" ]] +docker compose exec -T db sh -lc 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' +mkdir -p "$STAGE" +unzip -q "$ZIP" -d "$STAGE" +[[ "$(node -p "require('$STAGE/api-v3/package.json').version")" == "0.19.3-20" ]] +[[ "$(node -p "require('$STAGE/web-v2/package.json').version")" == "0.19.3-20" ]] +grep -Fq "API_PHASE = 'D5.3.20'" "$STAGE/api-v3/src/version.ts" +grep -Fq "APP_PHASE = 'Fase D5.3.20 · Informe Word automático'" "$STAGE/web-v2/src/config/version.ts" + +echo "[2/10] Compilando API en staging" +docker build --target builder -t dhv2-api:d5320-test "$STAGE/api-v3" + +echo "[3/10] Ejecutando tests API en staging" +docker run --rm \ + -v "$STAGE/api-v3/test:/app/test:ro" \ + -v "$STAGE/api-v3/tsconfig.test.json:/app/tsconfig.test.json:ro" \ + dhv2-api:d5320-test npm test + +echo "[4/10] Compilando web en staging" +docker build -t dhv2-web:d5320-test "$STAGE/web-v2" + +echo "[5/10] Backup PRE D5.3.20" +mkdir -p "$PRE_BACKUP" +chmod 700 "$PRE_BACKUP" +docker compose exec -T db sh -lc 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > "$PRE_BACKUP/database.dump" +tar -czf "$PRE_BACKUP/source.tar.gz" \ + --exclude='api-v3/node_modules' --exclude='api-v3/dist' \ + --exclude='web-v2/node_modules' --exclude='web-v2/dist' \ + api-v3 web-v2 scripts docs docker-compose.yml .env.example .gitignore +if [[ -f .env ]]; then cp -a .env "$PRE_BACKUP/.env"; chmod 600 "$PRE_BACKUP/.env"; fi +STRONG_BACKUP="$(find "$BACKUP_ROOT" -maxdepth 1 -type d -name 'INVENTARIO_A_REVISAR_*' -printf '%T@ %p\n' | sort -nr | head -n1 | cut -d' ' -f2-)" +printf 'Backup fuerte conceptual previo: %s\nPalabra clave: INVENTARIO A REVISAR\n' "$STRONG_BACKUP" > "$PRE_BACKUP/INVENTARIO_A_REVISAR_REFERENCE.txt" +( + cd "$PRE_BACKUP" + sha256sum database.dump source.tar.gz INVENTARIO_A_REVISAR_REFERENCE.txt > SHA256SUMS.txt + [[ ! -f .env ]] || sha256sum .env >> SHA256SUMS.txt + sha256sum -c SHA256SUMS.txt +) + +echo "[6/10] Instalando source D5.3.20" +unzip -oq "$ZIP" -d "$ROOT" +chmod +x "$ROOT/scripts/deploy-phase-d5-3-20.sh" "$ROOT/scripts/phase-d5-3-20-acceptance.sh" +echo "API source: $(node -p "require('./api-v3/package.json').version")" +echo "WEB source: $(node -p "require('./web-v2/package.json').version")" + +echo "[7/10] Construyendo imágenes finales" +docker compose build api migrate web + +echo "[8/10] Aplicando migración D5.3.20" +docker compose --profile tools run --rm migrate npm run migration:run +docker compose --profile tools run --rm migrate npm run migration:show 2>&1 | tee "$MIGRATION_LOG" +grep -Fq 'Pending migrations: no' "$MIGRATION_LOG" + +echo "[9/10] Recreando API/web y aceptación" +docker compose up -d --force-recreate api web +for i in $(seq 1 40); do + if curl -fsS http://127.0.0.1:3101/api/v3/health >"$HEALTH_FILE" 2>/dev/null; then break; fi + if [[ "$i" == "40" ]]; then docker compose logs --tail=200 api; exit 1; fi + sleep 2 +done +cat "$HEALTH_FILE"; echo +grep -Fq '"version":"0.19.3-20"' "$HEALTH_FILE" +grep -Fq '"phase":"D5.3.20"' "$HEALTH_FILE" +grep -Fq '"database":"ok"' "$HEALTH_FILE" +bash "$ROOT/scripts/phase-d5-3-20-acceptance.sh" + +echo "[10/10] Backup POST" +mkdir -p "$POST_BACKUP" +chmod 700 "$POST_BACKUP" +docker compose exec -T db sh -lc 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > "$POST_BACKUP/database.dump" +docker compose ps -a > "$POST_BACKUP/docker-status.txt" +cp -a "$ZIP" "$POST_BACKUP/" +if [[ -f .env ]]; then cp -a .env "$POST_BACKUP/.env"; chmod 600 "$POST_BACKUP/.env"; fi +printf 'PRE D5.3.20: %s\nINVENTARIO A REVISAR: %s\n' "$PRE_BACKUP" "$STRONG_BACKUP" > "$POST_BACKUP/BACKUP_REFERENCES.txt" +( + cd "$POST_BACKUP" + sha256sum database.dump "$(basename "$ZIP")" BACKUP_REFERENCES.txt > SHA256SUMS.txt + [[ ! -f .env ]] || sha256sum .env >> SHA256SUMS.txt +) + +echo +echo "D5.3.20 desplegada correctamente" +echo "Backup PRE: $PRE_BACKUP" +echo "Backup fuerte INVENTARIO A REVISAR: $STRONG_BACKUP" +echo "Backup POST: $POST_BACKUP" diff --git a/scripts/deploy-phase-d5-3-21-1.sh b/scripts/deploy-phase-d5-3-21-1.sh new file mode 100755 index 0000000..345f198 --- /dev/null +++ b/scripts/deploy-phase-d5-3-21-1.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT="${ROOT:-/var/www/dhv2.korexlabs.com}" +ZIP="${ZIP:-$ROOT/DH_V2_FASE_D5_3_21_1_SOURCE.zip}" +STAMP="$(date +%Y%m%d_%H%M%S)" +STAGE="/root/dhv2-d53211-stage-$STAMP" +BACKUP_ROOT="/root/DH_V2_BACKUPS" +PRE_BACKUP="$BACKUP_ROOT/D5_3_21_1_PRE_$STAMP" +POST_BACKUP="$BACKUP_ROOT/D5_3_21_1_OK_$STAMP" +MIGRATION_LOG="/tmp/dhv2-d53211-migration-$STAMP.log" +HEALTH_FILE="/tmp/dhv2-d53211-health-$STAMP.json" +MIN_FREE_KB=$((4 * 1024 * 1024)) + +cleanup() { + rm -rf "$STAGE" + rm -f "$MIGRATION_LOG" "$HEALTH_FILE" + docker image rm dhv2-api:d53211-test dhv2-web:d53211-test >/dev/null 2>&1 || true +} +trap cleanup EXIT +trap 'echo; echo "ERROR D5.3.21.1 en línea $LINENO: $BASH_COMMAND"' ERR + +cd "$ROOT" +test -f "$ZIP" + +CURRENT_API="$(node -p "require('./api-v3/package.json').version")" +CURRENT_WEB="$(node -p "require('./web-v2/package.json').version")" +[[ "$CURRENT_API" == "0.19.3-20" ]] +[[ "$CURRENT_WEB" == "0.19.3-20" ]] + +echo "[1/10] Preflight y staging" +FREE_KB="$(df -Pk "$ROOT" | awk 'NR==2 {print $4}')" +if [[ ! "$FREE_KB" =~ ^[0-9]+$ ]] || (( FREE_KB < MIN_FREE_KB )); then + echo "ERROR: espacio libre insuficiente. Libre: $(( FREE_KB / 1024 )) MB" + exit 1 +fi +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}' 2>/dev/null || true)" == "healthy" ]] +docker compose exec -T db sh -lc 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' +mkdir -p "$STAGE" +unzip -q "$ZIP" -d "$STAGE" +[[ "$(node -p "require('$STAGE/api-v3/package.json').version")" == "0.19.3-21.1" ]] +[[ "$(node -p "require('$STAGE/web-v2/package.json').version")" == "0.19.3-21.1" ]] +grep -Fq "API_PHASE = 'D5.3.21.1'" "$STAGE/api-v3/src/version.ts" +grep -Fq "APP_PHASE = 'Fase D5.3.21.1 · Entrega documental'" "$STAGE/web-v2/src/config/version.ts" + +echo "[2/10] Compilando API en staging" +docker build --target builder -t dhv2-api:d53211-test "$STAGE/api-v3" + +echo "[3/10] Ejecutando tests API en staging" +docker run --rm \ + -v "$STAGE/api-v3/test:/app/test:ro" \ + -v "$STAGE/api-v3/tsconfig.test.json:/app/tsconfig.test.json:ro" \ + dhv2-api:d53211-test npm test + +echo "[4/10] Compilando web en staging" +docker build -t dhv2-web:d53211-test "$STAGE/web-v2" + +echo "[5/10] Backup PRE D5.3.21.1" +mkdir -p "$PRE_BACKUP" +chmod 700 "$PRE_BACKUP" +docker compose exec -T db sh -lc 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > "$PRE_BACKUP/database.dump" +tar -czf "$PRE_BACKUP/source.tar.gz" \ + --exclude='api-v3/node_modules' --exclude='api-v3/dist' \ + --exclude='web-v2/node_modules' --exclude='web-v2/dist' \ + api-v3 web-v2 scripts docs docker-compose.yml .env.example .gitignore +if [[ -f .env ]]; then cp -a .env "$PRE_BACKUP/.env"; chmod 600 "$PRE_BACKUP/.env"; fi +STRONG_BACKUP="$(find "$BACKUP_ROOT" -maxdepth 1 -type d -name 'INVENTARIO_A_REVISAR_*' -printf '%T@ %p\n' | sort -nr | head -n1 | cut -d' ' -f2-)" +printf 'Backup fuerte conceptual previo: %s\nPalabra clave: INVENTARIO A REVISAR\n' "$STRONG_BACKUP" > "$PRE_BACKUP/INVENTARIO_A_REVISAR_REFERENCE.txt" +( + cd "$PRE_BACKUP" + sha256sum database.dump source.tar.gz INVENTARIO_A_REVISAR_REFERENCE.txt > SHA256SUMS.txt + [[ ! -f .env ]] || sha256sum .env >> SHA256SUMS.txt + sha256sum -c SHA256SUMS.txt +) + +echo "[6/10] Instalando source D5.3.21.1" +unzip -oq "$ZIP" -d "$ROOT" +chmod +x "$ROOT/scripts/deploy-phase-d5-3-21-1.sh" "$ROOT/scripts/phase-d5-3-21-1-acceptance.sh" +echo "API source: $(node -p "require('./api-v3/package.json').version")" +echo "WEB source: $(node -p "require('./web-v2/package.json').version")" + +echo "[7/10] Construyendo imágenes finales" +docker compose build api migrate web + +echo "[8/10] Aplicando migración D5.3.21.1" +docker compose --profile tools run --rm migrate npm run migration:run +docker compose --profile tools run --rm migrate npm run migration:show 2>&1 | tee "$MIGRATION_LOG" +grep -Fq 'Pending migrations: no' "$MIGRATION_LOG" + +echo "[9/10] Recreando API/web y aceptación" +docker compose up -d --force-recreate api web +for i in $(seq 1 40); do + if curl -fsS http://127.0.0.1:3101/api/v3/health >"$HEALTH_FILE" 2>/dev/null; then break; fi + if [[ "$i" == "40" ]]; then docker compose logs --tail=200 api; exit 1; fi + sleep 2 +done +cat "$HEALTH_FILE"; echo +grep -Fq '"version":"0.19.3-21.1"' "$HEALTH_FILE" +grep -Fq '"phase":"D5.3.21.1"' "$HEALTH_FILE" +grep -Fq '"database":"ok"' "$HEALTH_FILE" +bash "$ROOT/scripts/phase-d5-3-21-1-acceptance.sh" + +echo "[10/10] Backup POST" +mkdir -p "$POST_BACKUP" +chmod 700 "$POST_BACKUP" +docker compose exec -T db sh -lc 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > "$POST_BACKUP/database.dump" +docker compose ps -a > "$POST_BACKUP/docker-status.txt" +cp -a "$ZIP" "$POST_BACKUP/" +if [[ -f .env ]]; then cp -a .env "$POST_BACKUP/.env"; chmod 600 "$POST_BACKUP/.env"; fi +printf 'PRE D5.3.21.1: %s\nINVENTARIO A REVISAR: %s\n' "$PRE_BACKUP" "$STRONG_BACKUP" > "$POST_BACKUP/BACKUP_REFERENCES.txt" +( + cd "$POST_BACKUP" + sha256sum database.dump "$(basename "$ZIP")" BACKUP_REFERENCES.txt > SHA256SUMS.txt + [[ ! -f .env ]] || sha256sum .env >> SHA256SUMS.txt +) + +echo +echo "D5.3.21.1 desplegada correctamente" +echo "Backup PRE: $PRE_BACKUP" +echo "Backup fuerte INVENTARIO A REVISAR: $STRONG_BACKUP" +echo "Backup POST: $POST_BACKUP" diff --git a/scripts/deploy-phase-d5-3-22.sh b/scripts/deploy-phase-d5-3-22.sh new file mode 100755 index 0000000..1006639 --- /dev/null +++ b/scripts/deploy-phase-d5-3-22.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT="${ROOT:-/var/www/dhv2.korexlabs.com}" +ZIP="${ZIP:-$ROOT/DH_V2_FASE_D5_3_22_SOURCE.zip}" +STAMP="$(date +%Y%m%d_%H%M%S)" +STAGE="/root/dhv2-d5322-stage-$STAMP" +BACKUP_ROOT="/root/DH_V2_BACKUPS" +PRE_BACKUP="$BACKUP_ROOT/D5_3_22_PRE_$STAMP" +POST_BACKUP="$BACKUP_ROOT/D5_3_22_OK_$STAMP" +MIGRATION_LOG="/tmp/dhv2-d5322-migration-$STAMP.log" +HEALTH_FILE="/tmp/dhv2-d5322-health-$STAMP.json" +MIN_FREE_KB=$((4 * 1024 * 1024)) + +cleanup() { + rm -rf "$STAGE" + rm -f "$MIGRATION_LOG" "$HEALTH_FILE" + docker image rm dhv2-api:d5322-test dhv2-web:d5322-test >/dev/null 2>&1 || true +} +trap cleanup EXIT +trap 'echo; echo "ERROR D5.3.22 en línea $LINENO: $BASH_COMMAND"' ERR + +cd "$ROOT" +test -f "$ZIP" + +CURRENT_API="$(node -p "require('./api-v3/package.json').version")" +CURRENT_WEB="$(node -p "require('./web-v2/package.json').version")" +[[ "$CURRENT_API" == "0.19.3-21.1" ]] +[[ "$CURRENT_WEB" == "0.19.3-21.1" ]] + +echo "[1/10] Preflight y staging" +FREE_KB="$(df -Pk "$ROOT" | awk 'NR==2 {print $4}')" +if [[ ! "$FREE_KB" =~ ^[0-9]+$ ]] || (( FREE_KB < MIN_FREE_KB )); then + echo "ERROR: espacio libre insuficiente. Libre: $(( FREE_KB / 1024 )) MB" + exit 1 +fi +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}' 2>/dev/null || true)" == "healthy" ]] +docker compose exec -T db sh -lc 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' +mkdir -p "$STAGE" +unzip -q "$ZIP" -d "$STAGE" +[[ "$(node -p "require('$STAGE/api-v3/package.json').version")" == "0.19.3-22" ]] +[[ "$(node -p "require('$STAGE/web-v2/package.json').version")" == "0.19.3-22" ]] +grep -Fq "API_PHASE = 'D5.3.22'" "$STAGE/api-v3/src/version.ts" +grep -Fq "APP_PHASE = 'Fase D5.3.22 · Revisión directiva de Informes'" "$STAGE/web-v2/src/config/version.ts" + +echo "[2/10] Compilando API en staging" +docker build --target builder -t dhv2-api:d5322-test "$STAGE/api-v3" + +echo "[3/10] Ejecutando tests API en staging" +docker run --rm \ + -v "$STAGE/api-v3/test:/app/test:ro" \ + -v "$STAGE/api-v3/tsconfig.test.json:/app/tsconfig.test.json:ro" \ + dhv2-api:d5322-test npm test + +echo "[4/10] Compilando web en staging" +docker build -t dhv2-web:d5322-test "$STAGE/web-v2" + +echo "[5/10] Backup PRE D5.3.22" +mkdir -p "$PRE_BACKUP" +chmod 700 "$PRE_BACKUP" +docker compose exec -T db sh -lc 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > "$PRE_BACKUP/database.dump" +tar -czf "$PRE_BACKUP/source.tar.gz" \ + --exclude='api-v3/node_modules' --exclude='api-v3/dist' \ + --exclude='web-v2/node_modules' --exclude='web-v2/dist' \ + api-v3 web-v2 scripts docs docker-compose.yml .env.example .gitignore +if [[ -f .env ]]; then cp -a .env "$PRE_BACKUP/.env"; chmod 600 "$PRE_BACKUP/.env"; fi +STRONG_BACKUP="$(find "$BACKUP_ROOT" -maxdepth 1 -type d -name 'INVENTARIO_A_REVISAR_*' -printf '%T@ %p\n' | sort -nr | head -n1 | cut -d' ' -f2-)" +printf 'Backup fuerte conceptual previo: %s\nPalabra clave: INVENTARIO A REVISAR\n' "$STRONG_BACKUP" > "$PRE_BACKUP/INVENTARIO_A_REVISAR_REFERENCE.txt" +( + cd "$PRE_BACKUP" + sha256sum database.dump source.tar.gz INVENTARIO_A_REVISAR_REFERENCE.txt > SHA256SUMS.txt + [[ ! -f .env ]] || sha256sum .env >> SHA256SUMS.txt + sha256sum -c SHA256SUMS.txt +) + +echo "[6/10] Instalando source D5.3.22" +unzip -oq "$ZIP" -d "$ROOT" +chmod +x "$ROOT/scripts/deploy-phase-d5-3-22.sh" "$ROOT/scripts/phase-d5-3-22-acceptance.sh" +echo "API source: $(node -p "require('./api-v3/package.json').version")" +echo "WEB source: $(node -p "require('./web-v2/package.json').version")" + +echo "[7/10] Construyendo imágenes finales" +docker compose build api migrate web + +echo "[8/10] Aplicando migración D5.3.22" +docker compose --profile tools run --rm migrate npm run migration:run +docker compose --profile tools run --rm migrate npm run migration:show 2>&1 | tee "$MIGRATION_LOG" +grep -Fq 'Pending migrations: no' "$MIGRATION_LOG" + +echo "[9/10] Recreando API/web y aceptación" +docker compose up -d --force-recreate api web +for i in $(seq 1 40); do + if curl -fsS http://127.0.0.1:3101/api/v3/health >"$HEALTH_FILE" 2>/dev/null; then break; fi + if [[ "$i" == "40" ]]; then docker compose logs --tail=200 api; exit 1; fi + sleep 2 +done +cat "$HEALTH_FILE"; echo +grep -Fq '"version":"0.19.3-22"' "$HEALTH_FILE" +grep -Fq '"phase":"D5.3.22"' "$HEALTH_FILE" +grep -Fq '"database":"ok"' "$HEALTH_FILE" +bash "$ROOT/scripts/phase-d5-3-22-acceptance.sh" + +echo "[10/10] Backup POST" +mkdir -p "$POST_BACKUP" +chmod 700 "$POST_BACKUP" +docker compose exec -T db sh -lc 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > "$POST_BACKUP/database.dump" +docker compose ps -a > "$POST_BACKUP/docker-status.txt" +cp -a "$ZIP" "$POST_BACKUP/" +if [[ -f .env ]]; then cp -a .env "$POST_BACKUP/.env"; chmod 600 "$POST_BACKUP/.env"; fi +printf 'PRE D5.3.22: %s\nINVENTARIO A REVISAR: %s\n' "$PRE_BACKUP" "$STRONG_BACKUP" > "$POST_BACKUP/BACKUP_REFERENCES.txt" +( + cd "$POST_BACKUP" + sha256sum database.dump "$(basename "$ZIP")" BACKUP_REFERENCES.txt > SHA256SUMS.txt + [[ ! -f .env ]] || sha256sum .env >> SHA256SUMS.txt +) + +echo +echo "D5.3.22 desplegada correctamente" +echo "Backup PRE: $PRE_BACKUP" +echo "Backup fuerte INVENTARIO A REVISAR: $STRONG_BACKUP" +echo "Backup POST: $POST_BACKUP" diff --git a/scripts/deploy-phase-d5-3-23-1.sh b/scripts/deploy-phase-d5-3-23-1.sh new file mode 100755 index 0000000..0172fc3 --- /dev/null +++ b/scripts/deploy-phase-d5-3-23-1.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT="${ROOT:-/var/www/dhv2.korexlabs.com}" +ZIP="${ZIP:-$ROOT/DH_V2_FASE_D5_3_23_1_SOURCE.zip}" +STAMP="$(date +%Y%m%d_%H%M%S)" +STAGE="/root/dhv2-d53231-stage-$STAMP" +BACKUP_ROOT="/root/DH_V2_BACKUPS" +PRE_BACKUP="$BACKUP_ROOT/D5_3_23_1_PRE_$STAMP" +POST_BACKUP="$BACKUP_ROOT/D5_3_23_1_OK_$STAMP" +MIGRATION_LOG="/tmp/dhv2-d53231-migration-$STAMP.log" +HEALTH_FILE="/tmp/dhv2-d53231-health-$STAMP.json" +MIN_FREE_KB=$((4 * 1024 * 1024)) + +cleanup() { + rm -rf "$STAGE" + rm -f "$MIGRATION_LOG" "$HEALTH_FILE" + docker image rm dhv2-api:d53231-test dhv2-web:d53231-test >/dev/null 2>&1 || true +} +trap cleanup EXIT +trap 'echo; echo "ERROR D5.3.23.1 en línea $LINENO: $BASH_COMMAND"' ERR + +cd "$ROOT" +test -f "$ZIP" + +CURRENT_API="$(node -p "require('./api-v3/package.json').version")" +CURRENT_WEB="$(node -p "require('./web-v2/package.json').version")" +[[ "$CURRENT_API" == "0.19.3-22" ]] +[[ "$CURRENT_WEB" == "0.19.3-22" ]] + +echo "[1/10] Preflight y staging" +FREE_KB="$(df -Pk "$ROOT" | awk 'NR==2 {print $4}')" +if [[ ! "$FREE_KB" =~ ^[0-9]+$ ]] || (( FREE_KB < MIN_FREE_KB )); then + echo "ERROR: espacio libre insuficiente. Libre: $(( FREE_KB / 1024 )) MB" + exit 1 +fi +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}' 2>/dev/null || true)" == "healthy" ]] +docker compose exec -T db sh -lc 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' +mkdir -p "$STAGE" +unzip -q "$ZIP" -d "$STAGE" +[[ "$(node -p "require('$STAGE/api-v3/package.json').version")" == "0.19.3-23.1" ]] +[[ "$(node -p "require('$STAGE/web-v2/package.json').version")" == "0.19.3-23.1" ]] +grep -Fq "API_PHASE = 'D5.3.23.1'" "$STAGE/api-v3/src/version.ts" +grep -Fq "APP_PHASE = 'Fase D5.3.23.1 · Altas de campo y revisión de Inventarios'" "$STAGE/web-v2/src/config/version.ts" + +echo "[2/10] Compilando API en staging" +docker build --target builder -t dhv2-api:d53231-test "$STAGE/api-v3" + +echo "[3/10] Ejecutando tests API en staging" +docker run --rm \ + -v "$STAGE/api-v3/test:/app/test:ro" \ + -v "$STAGE/api-v3/tsconfig.test.json:/app/tsconfig.test.json:ro" \ + dhv2-api:d53231-test npm test + +echo "[4/10] Compilando web en staging" +docker build -t dhv2-web:d53231-test "$STAGE/web-v2" + +echo "[5/10] Backup PRE D5.3.23.1" +mkdir -p "$PRE_BACKUP" +chmod 700 "$PRE_BACKUP" +docker compose exec -T db sh -lc 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > "$PRE_BACKUP/database.dump" +tar -czf "$PRE_BACKUP/source.tar.gz" \ + --exclude='api-v3/node_modules' --exclude='api-v3/dist' \ + --exclude='web-v2/node_modules' --exclude='web-v2/dist' \ + api-v3 web-v2 scripts docs docker-compose.yml .env.example .gitignore +if [[ -f .env ]]; then cp -a .env "$PRE_BACKUP/.env"; chmod 600 "$PRE_BACKUP/.env"; fi +STRONG_BACKUP="$(find "$BACKUP_ROOT" -maxdepth 1 -type d -name 'INVENTARIO_A_REVISAR_*' -printf '%T@ %p\n' | sort -nr | head -n1 | cut -d' ' -f2-)" +printf 'Backup fuerte conceptual previo: %s\nPalabra clave: INVENTARIO A REVISAR\n' "$STRONG_BACKUP" > "$PRE_BACKUP/INVENTARIO_A_REVISAR_REFERENCE.txt" +( + cd "$PRE_BACKUP" + sha256sum database.dump source.tar.gz INVENTARIO_A_REVISAR_REFERENCE.txt > SHA256SUMS.txt + [[ ! -f .env ]] || sha256sum .env >> SHA256SUMS.txt + sha256sum -c SHA256SUMS.txt +) + +echo "[6/10] Instalando source D5.3.23.1" +unzip -oq "$ZIP" -d "$ROOT" +chmod +x "$ROOT/scripts/deploy-phase-d5-3-23-1.sh" "$ROOT/scripts/phase-d5-3-23-1-acceptance.sh" +echo "API source: $(node -p "require('./api-v3/package.json').version")" +echo "WEB source: $(node -p "require('./web-v2/package.json').version")" + +echo "[7/10] Construyendo imágenes finales" +docker compose build api migrate web + +echo "[8/10] Aplicando migración D5.3.23.1" +docker compose --profile tools run --rm migrate npm run migration:run +docker compose --profile tools run --rm migrate npm run migration:show 2>&1 | tee "$MIGRATION_LOG" +grep -Fq 'Pending migrations: no' "$MIGRATION_LOG" + +echo "[9/10] Recreando API/web y aceptación" +docker compose up -d --force-recreate api web +for i in $(seq 1 40); do + if curl -fsS http://127.0.0.1:3101/api/v3/health >"$HEALTH_FILE" 2>/dev/null; then break; fi + if [[ "$i" == "40" ]]; then docker compose logs --tail=200 api; exit 1; fi + sleep 2 +done +cat "$HEALTH_FILE"; echo +grep -Fq '"version":"0.19.3-23.1"' "$HEALTH_FILE" +grep -Fq '"phase":"D5.3.23.1"' "$HEALTH_FILE" +grep -Fq '"database":"ok"' "$HEALTH_FILE" +bash "$ROOT/scripts/phase-d5-3-23-1-acceptance.sh" + +echo "[10/10] Backup POST" +mkdir -p "$POST_BACKUP" +chmod 700 "$POST_BACKUP" +docker compose exec -T db sh -lc 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > "$POST_BACKUP/database.dump" +docker compose ps -a > "$POST_BACKUP/docker-status.txt" +cp -a "$ZIP" "$POST_BACKUP/" +if [[ -f .env ]]; then cp -a .env "$POST_BACKUP/.env"; chmod 600 "$POST_BACKUP/.env"; fi +printf 'PRE D5.3.23.1: %s\nINVENTARIO A REVISAR: %s\n' "$PRE_BACKUP" "$STRONG_BACKUP" > "$POST_BACKUP/BACKUP_REFERENCES.txt" +( + cd "$POST_BACKUP" + sha256sum database.dump "$(basename "$ZIP")" BACKUP_REFERENCES.txt > SHA256SUMS.txt + [[ ! -f .env ]] || sha256sum .env >> SHA256SUMS.txt +) + +echo +echo "D5.3.23.1 desplegada correctamente" +echo "Backup PRE: $PRE_BACKUP" +echo "Backup fuerte INVENTARIO A REVISAR: $STRONG_BACKUP" +echo "Backup POST: $POST_BACKUP" diff --git a/scripts/deploy-phase-d5-3-24.sh b/scripts/deploy-phase-d5-3-24.sh new file mode 100755 index 0000000..cead370 --- /dev/null +++ b/scripts/deploy-phase-d5-3-24.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT="${ROOT:-/var/www/dhv2.korexlabs.com}" +ZIP="${ZIP:-$ROOT/DH_V2_FASE_D5_3_24_SOURCE.zip}" +STAMP="$(date +%Y%m%d_%H%M%S)" +STAGE="/root/dhv2-d5324-stage-$STAMP" +BACKUP_ROOT="/root/DH_V2_BACKUPS" +PRE_BACKUP="$BACKUP_ROOT/D5_3_24_PRE_$STAMP" +POST_BACKUP="$BACKUP_ROOT/D5_3_24_OK_$STAMP" +MIGRATION_LOG="/tmp/dhv2-d5324-migration-$STAMP.log" +HEALTH_FILE="/tmp/dhv2-d5324-health-$STAMP.json" +MIN_FREE_KB=$((4 * 1024 * 1024)) + +cleanup() { + rm -rf "$STAGE" + rm -f "$MIGRATION_LOG" "$HEALTH_FILE" + docker image rm dhv2-api:d5324-test dhv2-web:d5324-test >/dev/null 2>&1 || true +} +trap cleanup EXIT +trap 'echo; echo "ERROR D5.3.24 en línea $LINENO: $BASH_COMMAND"' ERR + +cd "$ROOT" +test -f "$ZIP" + +CURRENT_API="$(node -p "require('./api-v3/package.json').version")" +CURRENT_WEB="$(node -p "require('./web-v2/package.json').version")" +[[ "$CURRENT_API" == "0.19.3-23.1" ]] +[[ "$CURRENT_WEB" == "0.19.3-23.1" ]] + +echo "[1/10] Preflight y staging" +FREE_KB="$(df -Pk "$ROOT" | awk 'NR==2 {print $4}')" +if [[ ! "$FREE_KB" =~ ^[0-9]+$ ]] || (( FREE_KB < MIN_FREE_KB )); then + echo "ERROR: espacio libre insuficiente. Libre: $(( FREE_KB / 1024 )) MB" + exit 1 +fi +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}' 2>/dev/null || true)" == "healthy" ]] +docker compose exec -T db sh -lc 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' +mkdir -p "$STAGE" +unzip -q "$ZIP" -d "$STAGE" +[[ "$(node -p "require('$STAGE/api-v3/package.json').version")" == "0.19.3-24" ]] +[[ "$(node -p "require('$STAGE/web-v2/package.json').version")" == "0.19.3-24" ]] +grep -Fq "API_PHASE = 'D5.3.24'" "$STAGE/api-v3/src/version.ts" +grep -Fq "APP_PHASE = 'Fase D5.3.24 · Historial de contexto operativo'" "$STAGE/web-v2/src/config/version.ts" + +echo "[2/10] Compilando API en staging" +docker build --target builder -t dhv2-api:d5324-test "$STAGE/api-v3" + +echo "[3/10] Ejecutando tests API en staging" +docker run --rm \ + -v "$STAGE/api-v3/test:/app/test:ro" \ + -v "$STAGE/api-v3/tsconfig.test.json:/app/tsconfig.test.json:ro" \ + dhv2-api:d5324-test npm test + +echo "[4/10] Compilando web en staging" +docker build -t dhv2-web:d5324-test "$STAGE/web-v2" + +echo "[5/10] Backup PRE D5.3.24" +mkdir -p "$PRE_BACKUP" +chmod 700 "$PRE_BACKUP" +docker compose exec -T db sh -lc 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > "$PRE_BACKUP/database.dump" +tar -czf "$PRE_BACKUP/source.tar.gz" \ + --exclude='api-v3/node_modules' --exclude='api-v3/dist' \ + --exclude='web-v2/node_modules' --exclude='web-v2/dist' \ + api-v3 web-v2 scripts docs docker-compose.yml .env.example .gitignore +if [[ -f .env ]]; then cp -a .env "$PRE_BACKUP/.env"; chmod 600 "$PRE_BACKUP/.env"; fi +STRONG_BACKUP="$(find "$BACKUP_ROOT" -maxdepth 1 -type d -name 'INVENTARIO_A_REVISAR_*' -printf '%T@ %p\n' | sort -nr | head -n1 | cut -d' ' -f2-)" +printf 'Backup fuerte conceptual previo: %s\nPalabra clave: INVENTARIO A REVISAR\n' "$STRONG_BACKUP" > "$PRE_BACKUP/INVENTARIO_A_REVISAR_REFERENCE.txt" +( + cd "$PRE_BACKUP" + sha256sum database.dump source.tar.gz INVENTARIO_A_REVISAR_REFERENCE.txt > SHA256SUMS.txt + [[ ! -f .env ]] || sha256sum .env >> SHA256SUMS.txt + sha256sum -c SHA256SUMS.txt +) + +echo "[6/10] Instalando source D5.3.24" +unzip -oq "$ZIP" -d "$ROOT" +chmod +x "$ROOT/scripts/deploy-phase-d5-3-24.sh" "$ROOT/scripts/phase-d5-3-24-acceptance.sh" +echo "API source: $(node -p "require('./api-v3/package.json').version")" +echo "WEB source: $(node -p "require('./web-v2/package.json').version")" + +echo "[7/10] Construyendo imágenes finales" +docker compose build api migrate web + +echo "[8/10] Aplicando migración D5.3.24" +docker compose --profile tools run --rm migrate npm run migration:run +docker compose --profile tools run --rm migrate npm run migration:show 2>&1 | tee "$MIGRATION_LOG" +grep -Fq 'Pending migrations: no' "$MIGRATION_LOG" + +echo "[9/10] Recreando API/web y aceptación" +docker compose up -d --force-recreate api web +for i in $(seq 1 40); do + if curl -fsS http://127.0.0.1:3101/api/v3/health >"$HEALTH_FILE" 2>/dev/null; then break; fi + if [[ "$i" == "40" ]]; then docker compose logs --tail=200 api; exit 1; fi + sleep 2 +done +cat "$HEALTH_FILE"; echo +grep -Fq '"version":"0.19.3-24"' "$HEALTH_FILE" +grep -Fq '"phase":"D5.3.24"' "$HEALTH_FILE" +grep -Fq '"database":"ok"' "$HEALTH_FILE" +bash "$ROOT/scripts/phase-d5-3-24-acceptance.sh" + +echo "[10/10] Backup POST" +mkdir -p "$POST_BACKUP" +chmod 700 "$POST_BACKUP" +docker compose exec -T db sh -lc 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > "$POST_BACKUP/database.dump" +docker compose ps -a > "$POST_BACKUP/docker-status.txt" +cp -a "$ZIP" "$POST_BACKUP/" +if [[ -f .env ]]; then cp -a .env "$POST_BACKUP/.env"; chmod 600 "$POST_BACKUP/.env"; fi +printf 'PRE D5.3.24: %s\nINVENTARIO A REVISAR: %s\n' "$PRE_BACKUP" "$STRONG_BACKUP" > "$POST_BACKUP/BACKUP_REFERENCES.txt" +( + cd "$POST_BACKUP" + sha256sum database.dump "$(basename "$ZIP")" BACKUP_REFERENCES.txt > SHA256SUMS.txt + [[ ! -f .env ]] || sha256sum .env >> SHA256SUMS.txt +) + +echo +echo "D5.3.24 desplegada correctamente" +echo "Backup PRE: $PRE_BACKUP" +echo "Backup fuerte INVENTARIO A REVISAR: $STRONG_BACKUP" +echo "Backup POST: $POST_BACKUP" diff --git a/scripts/deploy-phase-d5-3-25.sh b/scripts/deploy-phase-d5-3-25.sh new file mode 100755 index 0000000..83c9fa4 --- /dev/null +++ b/scripts/deploy-phase-d5-3-25.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT="${ROOT:-/var/www/dhv2.korexlabs.com}" +ZIP="${ZIP:-$ROOT/DH_V2_FASE_D5_3_25_SOURCE.zip}" +STAMP="$(date +%Y%m%d_%H%M%S)" +STAGE="/root/dhv2-d5325-stage-$STAMP" +BACKUP_ROOT="/root/DH_V2_BACKUPS" +PRE_BACKUP="$BACKUP_ROOT/D5_3_25_PRE_$STAMP" +POST_BACKUP="$BACKUP_ROOT/D5_3_25_OK_$STAMP" +MIGRATION_LOG="/tmp/dhv2-d5325-migration-$STAMP.log" +HEALTH_FILE="/tmp/dhv2-d5325-health-$STAMP.json" +MIN_FREE_KB=$((4 * 1024 * 1024)) + +cleanup() { + rm -rf "$STAGE" + rm -f "$MIGRATION_LOG" "$HEALTH_FILE" + docker image rm dhv2-api:d5325-test dhv2-web:d5325-test >/dev/null 2>&1 || true +} +trap cleanup EXIT +trap 'echo; echo "ERROR D5.3.25 en línea $LINENO: $BASH_COMMAND"' ERR + +cd "$ROOT" +test -f "$ZIP" + +CURRENT_API="$(node -p "require('./api-v3/package.json').version")" +CURRENT_WEB="$(node -p "require('./web-v2/package.json').version")" +[[ "$CURRENT_API" == "0.19.3-24" ]] +[[ "$CURRENT_WEB" == "0.19.3-24" ]] + +echo "[1/10] Preflight y staging" +FREE_KB="$(df -Pk "$ROOT" | awk 'NR==2 {print $4}')" +if [[ ! "$FREE_KB" =~ ^[0-9]+$ ]] || (( FREE_KB < MIN_FREE_KB )); then + echo "ERROR: espacio libre insuficiente. Libre: $(( FREE_KB / 1024 )) MB" + exit 1 +fi +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}' 2>/dev/null || true)" == "healthy" ]] +docker compose exec -T db sh -lc 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' +mkdir -p "$STAGE" +unzip -q "$ZIP" -d "$STAGE" +[[ "$(node -p "require('$STAGE/api-v3/package.json').version")" == "0.19.3-25" ]] +[[ "$(node -p "require('$STAGE/web-v2/package.json').version")" == "0.19.3-25" ]] +grep -Fq "API_PHASE = 'D5.3.25'" "$STAGE/api-v3/src/version.ts" +grep -Fq "APP_PHASE = 'Fase D5.3.25 · Historial de verificaciones'" "$STAGE/web-v2/src/config/version.ts" + +echo "[2/10] Compilando API en staging" +docker build --target builder -t dhv2-api:d5325-test "$STAGE/api-v3" + +echo "[3/10] Ejecutando tests API en staging" +docker run --rm \ + -v "$STAGE/api-v3/test:/app/test:ro" \ + -v "$STAGE/api-v3/tsconfig.test.json:/app/tsconfig.test.json:ro" \ + dhv2-api:d5325-test npm test + +echo "[4/10] Compilando web en staging" +docker build -t dhv2-web:d5325-test "$STAGE/web-v2" + +echo "[5/10] Backup PRE D5.3.25" +mkdir -p "$PRE_BACKUP" +chmod 700 "$PRE_BACKUP" +docker compose exec -T db sh -lc 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > "$PRE_BACKUP/database.dump" +tar -czf "$PRE_BACKUP/source.tar.gz" \ + --exclude='api-v3/node_modules' --exclude='api-v3/dist' \ + --exclude='web-v2/node_modules' --exclude='web-v2/dist' \ + api-v3 web-v2 scripts docs docker-compose.yml .env.example .gitignore +if [[ -f .env ]]; then cp -a .env "$PRE_BACKUP/.env"; chmod 600 "$PRE_BACKUP/.env"; fi +STRONG_BACKUP="$(find "$BACKUP_ROOT" -maxdepth 1 -type d -name 'INVENTARIO_A_REVISAR_*' -printf '%T@ %p\n' | sort -nr | head -n1 | cut -d' ' -f2-)" +printf 'Backup fuerte conceptual previo: %s\nPalabra clave: INVENTARIO A REVISAR\n' "$STRONG_BACKUP" > "$PRE_BACKUP/INVENTARIO_A_REVISAR_REFERENCE.txt" +( + cd "$PRE_BACKUP" + sha256sum database.dump source.tar.gz INVENTARIO_A_REVISAR_REFERENCE.txt > SHA256SUMS.txt + [[ ! -f .env ]] || sha256sum .env >> SHA256SUMS.txt + sha256sum -c SHA256SUMS.txt +) + +echo "[6/10] Instalando source D5.3.25" +unzip -oq "$ZIP" -d "$ROOT" +chmod +x "$ROOT/scripts/deploy-phase-d5-3-25.sh" "$ROOT/scripts/phase-d5-3-25-acceptance.sh" +echo "API source: $(node -p "require('./api-v3/package.json').version")" +echo "WEB source: $(node -p "require('./web-v2/package.json').version")" + +echo "[7/10] Construyendo imágenes finales" +docker compose build api migrate web + +echo "[8/10] Aplicando migración D5.3.25" +docker compose --profile tools run --rm migrate npm run migration:run +docker compose --profile tools run --rm migrate npm run migration:show 2>&1 | tee "$MIGRATION_LOG" +grep -Fq 'Pending migrations: no' "$MIGRATION_LOG" + +echo "[9/10] Recreando API/web y aceptación" +docker compose up -d --force-recreate api web +for i in $(seq 1 40); do + if curl -fsS http://127.0.0.1:3101/api/v3/health >"$HEALTH_FILE" 2>/dev/null; then break; fi + if [[ "$i" == "40" ]]; then docker compose logs --tail=200 api; exit 1; fi + sleep 2 +done +cat "$HEALTH_FILE"; echo +grep -Fq '"version":"0.19.3-25"' "$HEALTH_FILE" +grep -Fq '"phase":"D5.3.25"' "$HEALTH_FILE" +grep -Fq '"database":"ok"' "$HEALTH_FILE" +bash "$ROOT/scripts/phase-d5-3-25-acceptance.sh" + +echo "[10/10] Backup POST" +mkdir -p "$POST_BACKUP" +chmod 700 "$POST_BACKUP" +docker compose exec -T db sh -lc 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > "$POST_BACKUP/database.dump" +docker compose ps -a > "$POST_BACKUP/docker-status.txt" +cp -a "$ZIP" "$POST_BACKUP/" +if [[ -f .env ]]; then cp -a .env "$POST_BACKUP/.env"; chmod 600 "$POST_BACKUP/.env"; fi +printf 'PRE D5.3.25: %s\nINVENTARIO A REVISAR: %s\n' "$PRE_BACKUP" "$STRONG_BACKUP" > "$POST_BACKUP/BACKUP_REFERENCES.txt" +( + cd "$POST_BACKUP" + sha256sum database.dump "$(basename "$ZIP")" BACKUP_REFERENCES.txt > SHA256SUMS.txt + [[ ! -f .env ]] || sha256sum .env >> SHA256SUMS.txt +) + +echo +echo "D5.3.25 desplegada correctamente" +echo "Backup PRE: $PRE_BACKUP" +echo "Backup fuerte INVENTARIO A REVISAR: $STRONG_BACKUP" +echo "Backup POST: $POST_BACKUP" diff --git a/scripts/deploy-phase-d5-3-3.sh b/scripts/deploy-phase-d5-3-3.sh new file mode 100755 index 0000000..803c7d6 --- /dev/null +++ b/scripts/deploy-phase-d5-3-3.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT="${ROOT:-/var/www/dhv2.korexlabs.com}" +ZIP="${ZIP:-$ROOT/DH_V2_FASE_D5_3_3_SOURCE.zip}" +STAMP="$(date +%Y%m%d_%H%M%S)" +STAGE="/root/dhv2-d533-stage-$STAMP" +BACKUP_ROOT="/root/DH_V2_BACKUPS" +PRE_BACKUP="$BACKUP_ROOT/D5_3_3_PRE_$STAMP" +POST_BACKUP="$BACKUP_ROOT/D5_3_3_OK_$STAMP" + +cleanup() { rm -rf "$STAGE"; } +trap cleanup EXIT +trap 'echo; echo "ERROR D5.3.3 en línea $LINENO: $BASH_COMMAND"' ERR + +cd "$ROOT" +test -f "$ZIP" + +echo "[1/11] Preparando staging" +mkdir -p "$STAGE" +unzip -q "$ZIP" -d "$STAGE" + +echo "[2/11] Compilando API en staging" +docker build --target builder -t dhv2-api:d533-test "$STAGE/api-v3" + +echo "[3/11] Ejecutando tests API en staging" +docker run --rm \ + -v "$STAGE/api-v3/test:/app/test:ro" \ + -v "$STAGE/api-v3/tsconfig.test.json:/app/tsconfig.test.json:ro" \ + dhv2-api:d533-test npm test + +echo "[4/11] Compilando web en staging" +docker build -t dhv2-web:d533-test "$STAGE/web-v2" + +echo "[5/11] Backup PRE" +mkdir -p "$PRE_BACKUP" +docker compose exec -T db sh -lc \ + 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' \ + > "$PRE_BACKUP/database.dump" +tar \ + --exclude='api-v3/node_modules' --exclude='api-v3/dist' \ + --exclude='web-v2/node_modules' --exclude='web-v2/dist' \ + --exclude='.git' --exclude='DH_V2_FASE_*.zip' \ + -czf "$PRE_BACKUP/source.tar.gz" \ + api-v3 web-v2 docs scripts infra docker-compose.yml README.md .env.example +if [[ -f .env ]]; then cp -a .env "$PRE_BACKUP/.env"; chmod 600 "$PRE_BACKUP/.env"; fi + +echo "[6/11] Instalando source" +unzip -oq "$ZIP" -d "$ROOT" +chmod +x \ + "$ROOT/scripts/deploy-phase-d5-3-3.sh" \ + "$ROOT/scripts/phase-d5-3-3-acceptance.sh" \ + "$ROOT/scripts/dev-seed-mendoza-demo.sh" \ + "$ROOT/scripts/dev-clean-demo.sh" +echo "API source: $(node -p "require('./api-v3/package.json').version")" +echo "WEB source: $(node -p "require('./web-v2/package.json').version")" + +echo "[7/11] Construyendo imágenes finales" +docker compose build api migrate web + +echo "[8/11] Aplicando migración si corresponde" +MIGRATION_OUTPUT="$(docker compose --profile tools run --rm migrate npm run migration:show 2>&1)" +echo "$MIGRATION_OUTPUT" +if grep -q 'Pending migrations: yes' <<<"$MIGRATION_OUTPUT"; then + docker compose --profile tools run --rm migrate npm run migration:run +fi +MIGRATION_AFTER="$(docker compose --profile tools run --rm migrate npm run migration:show 2>&1)" +echo "$MIGRATION_AFTER" +grep -q 'Pending migrations: no' <<<"$MIGRATION_AFTER" + +echo "[9/11] Recreando API y web" +docker compose up -d --force-recreate api web +for i in $(seq 1 40); do + if curl -fsS http://127.0.0.1:3101/api/v3/health >/tmp/dhv2-d533-health.json 2>/dev/null; then break; fi + if [[ "$i" == "40" ]]; then docker compose logs --tail=200 api; exit 1; fi + sleep 2 +done +cat /tmp/dhv2-d533-health.json; echo + +echo "[10/11] Aceptación automática" +bash "$ROOT/scripts/phase-d5-3-3-acceptance.sh" + +echo "[11/11] Backup POST" +mkdir -p "$POST_BACKUP" +docker compose exec -T db sh -lc \ + 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' \ + > "$POST_BACKUP/database.dump" +docker compose ps -a > "$POST_BACKUP/docker-status.txt" +cp -a "$ZIP" "$POST_BACKUP/" +if [[ -f .env ]]; then cp -a .env "$POST_BACKUP/.env"; chmod 600 "$POST_BACKUP/.env"; fi +sha256sum "$POST_BACKUP/$(basename "$ZIP")" > "$POST_BACKUP/SHA256SUMS.txt" + +echo +echo "D5.3.3 desplegada correctamente" +echo "Backup PRE : $PRE_BACKUP" +echo "Backup POST: $POST_BACKUP" diff --git a/scripts/deploy-phase-d5-3-4.sh b/scripts/deploy-phase-d5-3-4.sh new file mode 100755 index 0000000..558def4 --- /dev/null +++ b/scripts/deploy-phase-d5-3-4.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT="${ROOT:-/var/www/dhv2.korexlabs.com}" +ZIP="${ZIP:-$ROOT/DH_V2_FASE_D5_3_4_SOURCE.zip}" +STAMP="$(date +%Y%m%d_%H%M%S)" +STAGE="/root/dhv2-d534-stage-$STAMP" +BACKUP_ROOT="/root/DH_V2_BACKUPS" +PRE_BACKUP="$BACKUP_ROOT/D5_3_4_PRE_$STAMP" +POST_BACKUP="$BACKUP_ROOT/D5_3_4_OK_$STAMP" + +cleanup() { rm -rf "$STAGE"; } +trap cleanup EXIT +trap 'echo; echo "ERROR D5.3.4 en línea $LINENO: $BASH_COMMAND"' ERR + +cd "$ROOT" +test -f "$ZIP" + +echo "[1/11] Preparando staging" +mkdir -p "$STAGE" +unzip -q "$ZIP" -d "$STAGE" + +echo "[2/11] Compilando API en staging" +docker build --target builder -t dhv2-api:d534-test "$STAGE/api-v3" + +echo "[3/11] Ejecutando tests API en staging" +docker run --rm \ + -v "$STAGE/api-v3/test:/app/test:ro" \ + -v "$STAGE/api-v3/tsconfig.test.json:/app/tsconfig.test.json:ro" \ + dhv2-api:d534-test npm test + +echo "[4/11] Compilando web en staging" +docker build -t dhv2-web:d534-test "$STAGE/web-v2" + +echo "[5/11] Backup PRE" +mkdir -p "$PRE_BACKUP" +docker compose exec -T db sh -lc \ + 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' \ + > "$PRE_BACKUP/database.dump" +tar \ + --exclude='api-v3/node_modules' --exclude='api-v3/dist' \ + --exclude='web-v2/node_modules' --exclude='web-v2/dist' \ + --exclude='.git' --exclude='DH_V2_FASE_*.zip' \ + -czf "$PRE_BACKUP/source.tar.gz" \ + api-v3 web-v2 docs scripts infra docker-compose.yml README.md .env.example +if [[ -f .env ]]; then cp -a .env "$PRE_BACKUP/.env"; chmod 600 "$PRE_BACKUP/.env"; fi + +echo "[6/11] Instalando source" +unzip -oq "$ZIP" -d "$ROOT" +chmod +x \ + "$ROOT/scripts/deploy-phase-d5-3-4.sh" \ + "$ROOT/scripts/phase-d5-3-4-acceptance.sh" \ + "$ROOT/scripts/dev-seed-mendoza-demo.sh" \ + "$ROOT/scripts/dev-clean-demo.sh" +echo "API source: $(node -p "require('./api-v3/package.json').version")" +echo "WEB source: $(node -p "require('./web-v2/package.json').version")" + +echo "[7/11] Construyendo imágenes finales" +docker compose build api migrate web + +echo "[8/11] Aplicando migraciones pendientes si corresponde" +MIGRATION_OUTPUT="$(docker compose --profile tools run --rm migrate npm run migration:show 2>&1)" +echo "$MIGRATION_OUTPUT" +if grep -q 'Pending migrations: yes' <<<"$MIGRATION_OUTPUT"; then + docker compose --profile tools run --rm migrate npm run migration:run +fi +MIGRATION_AFTER="$(docker compose --profile tools run --rm migrate npm run migration:show 2>&1)" +echo "$MIGRATION_AFTER" +grep -q 'Pending migrations: no' <<<"$MIGRATION_AFTER" + +echo "[9/11] Recreando API y web" +docker compose up -d --force-recreate api web +for i in $(seq 1 40); do + if curl -fsS http://127.0.0.1:3101/api/v3/health >/tmp/dhv2-d534-health.json 2>/dev/null; then break; fi + if [[ "$i" == "40" ]]; then docker compose logs --tail=200 api; exit 1; fi + sleep 2 +done +cat /tmp/dhv2-d534-health.json; echo + +echo "[10/11] Aceptación automática" +bash "$ROOT/scripts/phase-d5-3-4-acceptance.sh" + +echo "[11/11] Backup POST" +mkdir -p "$POST_BACKUP" +docker compose exec -T db sh -lc \ + 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' \ + > "$POST_BACKUP/database.dump" +docker compose ps -a > "$POST_BACKUP/docker-status.txt" +cp -a "$ZIP" "$POST_BACKUP/" +if [[ -f .env ]]; then cp -a .env "$POST_BACKUP/.env"; chmod 600 "$POST_BACKUP/.env"; fi +sha256sum "$POST_BACKUP/$(basename "$ZIP")" > "$POST_BACKUP/SHA256SUMS.txt" + +echo +echo "D5.3.4 desplegada correctamente" +echo "Backup PRE : $PRE_BACKUP" +echo "Backup POST: $POST_BACKUP" diff --git a/scripts/deploy-phase-d5-3-5-1.sh b/scripts/deploy-phase-d5-3-5-1.sh new file mode 100755 index 0000000..2bdbaa3 --- /dev/null +++ b/scripts/deploy-phase-d5-3-5-1.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT="${ROOT:-/var/www/dhv2.korexlabs.com}" +ZIP="${ZIP:-$ROOT/DH_V2_FASE_D5_3_5_1_SOURCE.zip}" +STAMP="$(date +%Y%m%d_%H%M%S)" +STAGE="/root/dhv2-d5351-stage-$STAMP" +BACKUP_ROOT="/root/DH_V2_BACKUPS" +PRE_BACKUP="$BACKUP_ROOT/D5_3_5_1_PRE_$STAMP" +POST_BACKUP="$BACKUP_ROOT/D5_3_5_1_OK_$STAMP" + +cleanup() { rm -rf "$STAGE"; } +trap cleanup EXIT +trap 'echo; echo "ERROR D5.3.5.1 en línea $LINENO: $BASH_COMMAND"' ERR + +cd "$ROOT" +test -f "$ZIP" + +echo "[1/11] Preparando staging" +mkdir -p "$STAGE" +unzip -q "$ZIP" -d "$STAGE" +[[ "$(node -p "require('$STAGE/api-v3/package.json').version")" == "0.19.3-5.1" ]] +[[ "$(node -p "require('$STAGE/web-v2/package.json').version")" == "0.19.3-5.1" ]] + +echo "[2/11] Compilando API en staging" +docker build --target builder -t dhv2-api:d5351-test "$STAGE/api-v3" + +echo "[3/11] Ejecutando tests API en staging" +docker run --rm \ + -v "$STAGE/api-v3/test:/app/test:ro" \ + -v "$STAGE/api-v3/tsconfig.test.json:/app/tsconfig.test.json:ro" \ + dhv2-api:d5351-test npm test + +echo "[4/11] Compilando web en staging" +docker build -t dhv2-web:d5351-test "$STAGE/web-v2" + +echo "[5/11] Backup PRE" +mkdir -p "$PRE_BACKUP" +docker compose exec -T db sh -lc \ + 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' \ + > "$PRE_BACKUP/database.dump" +tar \ + --exclude='api-v3/node_modules' --exclude='api-v3/dist' \ + --exclude='web-v2/node_modules' --exclude='web-v2/dist' \ + --exclude='.git' --exclude='DH_V2_FASE_*.zip' \ + -czf "$PRE_BACKUP/source.tar.gz" \ + api-v3 web-v2 docs scripts infra docker-compose.yml README.md .env.example +if [[ -f .env ]]; then cp -a .env "$PRE_BACKUP/.env"; chmod 600 "$PRE_BACKUP/.env"; fi + +echo "[6/11] Instalando source" +unzip -oq "$ZIP" -d "$ROOT" +chmod +x \ + "$ROOT/scripts/deploy-phase-d5-3-5-1.sh" \ + "$ROOT/scripts/phase-d5-3-5-1-acceptance.sh" \ + "$ROOT/scripts/dev-seed-mendoza-demo.sh" \ + "$ROOT/scripts/dev-clean-demo.sh" +echo "API source: $(node -p "require('./api-v3/package.json').version")" +echo "WEB source: $(node -p "require('./web-v2/package.json').version")" + +echo "[7/11] Construyendo imágenes finales" +docker compose build api migrate web + +echo "[8/11] Aplicando migraciones pendientes" +MIGRATION_OUTPUT="$(docker compose --profile tools run --rm migrate npm run migration:show 2>&1)" +echo "$MIGRATION_OUTPUT" +if grep -q 'Pending migrations: yes' <<<"$MIGRATION_OUTPUT"; then + docker compose --profile tools run --rm migrate npm run migration:run +fi +MIGRATION_AFTER="$(docker compose --profile tools run --rm migrate npm run migration:show 2>&1)" +echo "$MIGRATION_AFTER" +grep -q 'Pending migrations: no' <<<"$MIGRATION_AFTER" + +echo "[9/11] Recreando API y web" +docker compose up -d --force-recreate api web +for i in $(seq 1 40); do + if curl -fsS http://127.0.0.1:3101/api/v3/health >/tmp/dhv2-d5351-health.json 2>/dev/null; then break; fi + if [[ "$i" == "40" ]]; then docker compose logs --tail=200 api; exit 1; fi + sleep 2 +done +cat /tmp/dhv2-d5351-health.json; echo + +echo "[10/11] Aceptación automática" +bash "$ROOT/scripts/phase-d5-3-5-1-acceptance.sh" + +echo "[11/11] Backup POST" +mkdir -p "$POST_BACKUP" +docker compose exec -T db sh -lc \ + 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' \ + > "$POST_BACKUP/database.dump" +docker compose ps -a > "$POST_BACKUP/docker-status.txt" +cp -a "$ZIP" "$POST_BACKUP/" +if [[ -f .env ]]; then cp -a .env "$POST_BACKUP/.env"; chmod 600 "$POST_BACKUP/.env"; fi +sha256sum "$POST_BACKUP/$(basename "$ZIP")" > "$POST_BACKUP/SHA256SUMS.txt" + +echo +echo "D5.3.5.1 desplegada correctamente" +echo "Backup PRE : $PRE_BACKUP" +echo "Backup POST: $POST_BACKUP" diff --git a/scripts/deploy-phase-d5-3-5.sh b/scripts/deploy-phase-d5-3-5.sh new file mode 100755 index 0000000..e69ca6a --- /dev/null +++ b/scripts/deploy-phase-d5-3-5.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT="${ROOT:-/var/www/dhv2.korexlabs.com}" +ZIP="${ZIP:-$ROOT/DH_V2_FASE_D5_3_5_SOURCE.zip}" +STAMP="$(date +%Y%m%d_%H%M%S)" +STAGE="/root/dhv2-d535-stage-$STAMP" +BACKUP_ROOT="/root/DH_V2_BACKUPS" +PRE_BACKUP="$BACKUP_ROOT/D5_3_5_PRE_$STAMP" +POST_BACKUP="$BACKUP_ROOT/D5_3_5_OK_$STAMP" + +cleanup() { rm -rf "$STAGE"; } +trap cleanup EXIT +trap 'echo; echo "ERROR D5.3.5 en línea $LINENO: $BASH_COMMAND"' ERR + +cd "$ROOT" +test -f "$ZIP" + +echo "[1/11] Preparando staging" +mkdir -p "$STAGE" +unzip -q "$ZIP" -d "$STAGE" +[[ "$(node -p "require('$STAGE/api-v3/package.json').version")" == "0.19.3-5" ]] +[[ "$(node -p "require('$STAGE/web-v2/package.json').version")" == "0.19.3-5" ]] + +echo "[2/11] Compilando API en staging" +docker build --target builder -t dhv2-api:d535-test "$STAGE/api-v3" + +echo "[3/11] Ejecutando tests API en staging" +docker run --rm \ + -v "$STAGE/api-v3/test:/app/test:ro" \ + -v "$STAGE/api-v3/tsconfig.test.json:/app/tsconfig.test.json:ro" \ + dhv2-api:d535-test npm test + +echo "[4/11] Compilando web en staging" +docker build -t dhv2-web:d535-test "$STAGE/web-v2" + +echo "[5/11] Backup PRE" +mkdir -p "$PRE_BACKUP" +docker compose exec -T db sh -lc \ + 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' \ + > "$PRE_BACKUP/database.dump" +tar \ + --exclude='api-v3/node_modules' --exclude='api-v3/dist' \ + --exclude='web-v2/node_modules' --exclude='web-v2/dist' \ + --exclude='.git' --exclude='DH_V2_FASE_*.zip' \ + -czf "$PRE_BACKUP/source.tar.gz" \ + api-v3 web-v2 docs scripts infra docker-compose.yml README.md .env.example +if [[ -f .env ]]; then cp -a .env "$PRE_BACKUP/.env"; chmod 600 "$PRE_BACKUP/.env"; fi + +echo "[6/11] Instalando source" +unzip -oq "$ZIP" -d "$ROOT" +chmod +x \ + "$ROOT/scripts/deploy-phase-d5-3-5.sh" \ + "$ROOT/scripts/phase-d5-3-5-acceptance.sh" \ + "$ROOT/scripts/dev-seed-mendoza-demo.sh" \ + "$ROOT/scripts/dev-clean-demo.sh" +echo "API source: $(node -p "require('./api-v3/package.json').version")" +echo "WEB source: $(node -p "require('./web-v2/package.json').version")" + +echo "[7/11] Construyendo imágenes finales" +docker compose build api migrate web + +echo "[8/11] Aplicando migraciones pendientes" +MIGRATION_OUTPUT="$(docker compose --profile tools run --rm migrate npm run migration:show 2>&1)" +echo "$MIGRATION_OUTPUT" +if grep -q 'Pending migrations: yes' <<<"$MIGRATION_OUTPUT"; then + docker compose --profile tools run --rm migrate npm run migration:run +fi +MIGRATION_AFTER="$(docker compose --profile tools run --rm migrate npm run migration:show 2>&1)" +echo "$MIGRATION_AFTER" +grep -q 'Pending migrations: no' <<<"$MIGRATION_AFTER" + +echo "[9/11] Recreando API y web" +docker compose up -d --force-recreate api web +for i in $(seq 1 40); do + if curl -fsS http://127.0.0.1:3101/api/v3/health >/tmp/dhv2-d535-health.json 2>/dev/null; then break; fi + if [[ "$i" == "40" ]]; then docker compose logs --tail=200 api; exit 1; fi + sleep 2 +done +cat /tmp/dhv2-d535-health.json; echo + +echo "[10/11] Aceptación automática" +bash "$ROOT/scripts/phase-d5-3-5-acceptance.sh" + +echo "[11/11] Backup POST" +mkdir -p "$POST_BACKUP" +docker compose exec -T db sh -lc \ + 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' \ + > "$POST_BACKUP/database.dump" +docker compose ps -a > "$POST_BACKUP/docker-status.txt" +cp -a "$ZIP" "$POST_BACKUP/" +if [[ -f .env ]]; then cp -a .env "$POST_BACKUP/.env"; chmod 600 "$POST_BACKUP/.env"; fi +sha256sum "$POST_BACKUP/$(basename "$ZIP")" > "$POST_BACKUP/SHA256SUMS.txt" + +echo +echo "D5.3.5 desplegada correctamente" +echo "Backup PRE : $PRE_BACKUP" +echo "Backup POST: $POST_BACKUP" diff --git a/scripts/deploy-phase-d5-3-6-3.sh b/scripts/deploy-phase-d5-3-6-3.sh new file mode 100755 index 0000000..9dbf9bd --- /dev/null +++ b/scripts/deploy-phase-d5-3-6-3.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +ROOT="${ROOT:-/var/www/dhv2.korexlabs.com}" +ZIP="${ZIP:-$ROOT/DH_V2_FASE_D5_3_6_3_SOURCE.zip}" +STAMP="$(date +%Y%m%d_%H%M%S)" +STAGE="/root/dhv2-d5363-stage-$STAMP" +BACKUP_ROOT="/root/DH_V2_BACKUPS" +PRE_BACKUP="$BACKUP_ROOT/D5_3_6_3_PRE_$STAMP" +POST_BACKUP="$BACKUP_ROOT/D5_3_6_3_OK_$STAMP" +cleanup(){ rm -rf "$STAGE"; } +trap cleanup EXIT +trap 'echo; echo "ERROR D5.3.6.3 en línea $LINENO: $BASH_COMMAND"' ERR +cd "$ROOT" +test -f "$ZIP" + +echo "[1/11] Preparando staging" +mkdir -p "$STAGE" +unzip -q "$ZIP" -d "$STAGE" +[[ "$(node -p "require('$STAGE/api-v3/package.json').version")" == "0.19.3-6.3" ]] +[[ "$(node -p "require('$STAGE/web-v2/package.json').version")" == "0.19.3-6.3" ]] + +echo "[2/11] Compilando API en staging" +docker build --target builder -t dhv2-api:d5363-test "$STAGE/api-v3" + +echo "[3/11] Ejecutando tests API en staging" +docker run --rm -v "$STAGE/api-v3/test:/app/test:ro" -v "$STAGE/api-v3/tsconfig.test.json:/app/tsconfig.test.json:ro" dhv2-api:d5363-test npm test + +echo "[4/11] Compilando web en staging" +docker build -t dhv2-web:d5363-test "$STAGE/web-v2" + +echo "[5/11] Backup PRE" +mkdir -p "$PRE_BACKUP" +docker compose exec -T db sh -lc 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > "$PRE_BACKUP/database.dump" +tar --exclude='api-v3/node_modules' --exclude='api-v3/dist' --exclude='web-v2/node_modules' --exclude='web-v2/dist' --exclude='.git' --exclude='DH_V2_FASE_*.zip' -czf "$PRE_BACKUP/source.tar.gz" api-v3 web-v2 docs scripts infra docker-compose.yml README.md .env.example +if [[ -f .env ]]; then cp -a .env "$PRE_BACKUP/.env"; chmod 600 "$PRE_BACKUP/.env"; fi + +echo "[6/11] Instalando source" +unzip -oq "$ZIP" -d "$ROOT" +chmod +x "$ROOT/scripts/deploy-phase-d5-3-6-3.sh" "$ROOT/scripts/phase-d5-3-6-3-acceptance.sh" +echo "API source: $(node -p "require('./api-v3/package.json').version")" +echo "WEB source: $(node -p "require('./web-v2/package.json').version")" + +echo "[7/11] Construyendo imágenes finales" +docker compose build api migrate web + +echo "[8/11] Verificando migraciones" +MIGRATION_OUTPUT="$(docker compose --profile tools run --rm migrate npm run migration:show 2>&1)" +echo "$MIGRATION_OUTPUT" +if grep -Fq 'Pending migrations: yes' <<<"$MIGRATION_OUTPUT"; then docker compose --profile tools run --rm migrate npm run migration:run; fi +MIGRATION_AFTER="$(docker compose --profile tools run --rm migrate npm run migration:show 2>&1)" +echo "$MIGRATION_AFTER" +grep -Fq 'Pending migrations: no' <<<"$MIGRATION_AFTER" + +echo "[9/11] Recreando API y web" +docker compose up -d --force-recreate api web +for i in $(seq 1 40); do + if curl -fsS http://127.0.0.1:3101/api/v3/health >/tmp/dhv2-d5363-health.json 2>/dev/null; then break; fi + if [[ "$i" == "40" ]]; then docker compose logs --tail=200 api; exit 1; fi + sleep 2 +done +cat /tmp/dhv2-d5363-health.json; echo + +echo "[10/11] Aceptación automática" +bash "$ROOT/scripts/phase-d5-3-6-3-acceptance.sh" + +echo "[11/11] Backup POST" +mkdir -p "$POST_BACKUP" +docker compose exec -T db sh -lc 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > "$POST_BACKUP/database.dump" +docker compose ps -a > "$POST_BACKUP/docker-status.txt" +cp -a "$ZIP" "$POST_BACKUP/" +if [[ -f .env ]]; then cp -a .env "$POST_BACKUP/.env"; chmod 600 "$POST_BACKUP/.env"; fi +sha256sum "$POST_BACKUP/$(basename "$ZIP")" > "$POST_BACKUP/SHA256SUMS.txt" + +echo +echo "D5.3.6.3 desplegada correctamente" +echo "Backup PRE : $PRE_BACKUP" +echo "Backup POST: $POST_BACKUP" diff --git a/scripts/deploy-phase-d5-3-6.sh b/scripts/deploy-phase-d5-3-6.sh new file mode 100755 index 0000000..5aa90e5 --- /dev/null +++ b/scripts/deploy-phase-d5-3-6.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +ROOT="${ROOT:-/var/www/dhv2.korexlabs.com}" +ZIP="${ZIP:-$ROOT/DH_V2_FASE_D5_3_6_SOURCE.zip}" +STAMP="$(date +%Y%m%d_%H%M%S)" +STAGE="/root/dhv2-d536-stage-$STAMP" +BACKUP_ROOT="/root/DH_V2_BACKUPS" +PRE_BACKUP="$BACKUP_ROOT/D5_3_6_PRE_$STAMP" +POST_BACKUP="$BACKUP_ROOT/D5_3_6_OK_$STAMP" +cleanup(){ rm -rf "$STAGE"; } +trap cleanup EXIT +trap 'echo; echo "ERROR D5.3.6 en línea $LINENO: $BASH_COMMAND"' ERR +cd "$ROOT" +test -f "$ZIP" + +echo "[1/11] Preparando staging" +mkdir -p "$STAGE" +unzip -q "$ZIP" -d "$STAGE" +[[ "$(node -p "require('$STAGE/api-v3/package.json').version")" == "0.19.3-6" ]] +[[ "$(node -p "require('$STAGE/web-v2/package.json').version")" == "0.19.3-6" ]] + +echo "[2/11] Compilando API en staging" +docker build --target builder -t dhv2-api:d536-test "$STAGE/api-v3" + +echo "[3/11] Ejecutando tests API en staging" +docker run --rm -v "$STAGE/api-v3/test:/app/test:ro" -v "$STAGE/api-v3/tsconfig.test.json:/app/tsconfig.test.json:ro" dhv2-api:d536-test npm test + +echo "[4/11] Compilando web en staging" +docker build -t dhv2-web:d536-test "$STAGE/web-v2" + +echo "[5/11] Backup PRE" +mkdir -p "$PRE_BACKUP" +docker compose exec -T db sh -lc 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > "$PRE_BACKUP/database.dump" +tar --exclude='api-v3/node_modules' --exclude='api-v3/dist' --exclude='web-v2/node_modules' --exclude='web-v2/dist' --exclude='.git' --exclude='DH_V2_FASE_*.zip' -czf "$PRE_BACKUP/source.tar.gz" api-v3 web-v2 docs scripts infra docker-compose.yml README.md .env.example +if [[ -f .env ]]; then cp -a .env "$PRE_BACKUP/.env"; chmod 600 "$PRE_BACKUP/.env"; fi + +echo "[6/11] Instalando source" +unzip -oq "$ZIP" -d "$ROOT" +chmod +x "$ROOT/scripts/deploy-phase-d5-3-6.sh" "$ROOT/scripts/phase-d5-3-6-acceptance.sh" +echo "API source: $(node -p "require('./api-v3/package.json').version")" +echo "WEB source: $(node -p "require('./web-v2/package.json').version")" + +echo "[7/11] Construyendo imágenes finales" +docker compose build api migrate web + +echo "[8/11] Verificando migraciones" +MIGRATION_OUTPUT="$(docker compose --profile tools run --rm migrate npm run migration:show 2>&1)" +echo "$MIGRATION_OUTPUT" +if grep -Fq 'Pending migrations: yes' <<<"$MIGRATION_OUTPUT"; then docker compose --profile tools run --rm migrate npm run migration:run; fi +MIGRATION_AFTER="$(docker compose --profile tools run --rm migrate npm run migration:show 2>&1)" +echo "$MIGRATION_AFTER" +grep -Fq 'Pending migrations: no' <<<"$MIGRATION_AFTER" + +echo "[9/11] Recreando API y web" +docker compose up -d --force-recreate api web +for i in $(seq 1 40); do + if curl -fsS http://127.0.0.1:3101/api/v3/health >/tmp/dhv2-d536-health.json 2>/dev/null; then break; fi + if [[ "$i" == "40" ]]; then docker compose logs --tail=200 api; exit 1; fi + sleep 2 +done +cat /tmp/dhv2-d536-health.json; echo + +echo "[10/11] Aceptación automática" +bash "$ROOT/scripts/phase-d5-3-6-acceptance.sh" + +echo "[11/11] Backup POST" +mkdir -p "$POST_BACKUP" +docker compose exec -T db sh -lc 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > "$POST_BACKUP/database.dump" +docker compose ps -a > "$POST_BACKUP/docker-status.txt" +cp -a "$ZIP" "$POST_BACKUP/" +if [[ -f .env ]]; then cp -a .env "$POST_BACKUP/.env"; chmod 600 "$POST_BACKUP/.env"; fi +sha256sum "$POST_BACKUP/$(basename "$ZIP")" > "$POST_BACKUP/SHA256SUMS.txt" + +echo +echo "D5.3.6 desplegada correctamente" +echo "Backup PRE : $PRE_BACKUP" +echo "Backup POST: $POST_BACKUP" diff --git a/scripts/deploy-phase-d5-3-7-1.sh b/scripts/deploy-phase-d5-3-7-1.sh new file mode 100755 index 0000000..d01669d --- /dev/null +++ b/scripts/deploy-phase-d5-3-7-1.sh @@ -0,0 +1,143 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT="${ROOT:-/var/www/dhv2.korexlabs.com}" +ZIP="${ZIP:-$ROOT/DH_V2_FASE_D5_3_7_1_SOURCE.zip}" +STAMP="$(date +%Y%m%d_%H%M%S)" +STAGE="/root/dhv2-d5371-stage-$STAMP" +BACKUP_ROOT="/root/DH_V2_BACKUPS" +PRE_BACKUP="$BACKUP_ROOT/D5_3_7_1_PRE_$STAMP" +POST_BACKUP="$BACKUP_ROOT/D5_3_7_1_OK_$STAMP" +MIGRATION_LOG="/tmp/dhv2-d5371-migration-$STAMP.log" +MIGRATION_AFTER_LOG="/tmp/dhv2-d5371-migration-after-$STAMP.log" +HEALTH_FILE="/tmp/dhv2-d5371-health-$STAMP.json" +MIN_FREE_KB=$((12 * 1024 * 1024)) + +cleanup() { + rm -rf "$STAGE" + rm -f "$MIGRATION_LOG" "$MIGRATION_AFTER_LOG" "$HEALTH_FILE" + docker image rm dhv2-api:d5371-test dhv2-web:d5371-test >/dev/null 2>&1 || true +} +trap cleanup EXIT +trap 'echo; echo "ERROR D5.3.7.1 en línea $LINENO: $BASH_COMMAND"' ERR + +cd "$ROOT" +test -f "$ZIP" + +echo "[1/11] Preflight y staging" +FREE_KB="$(df -Pk "$ROOT" | awk 'NR==2 {print $4}')" +if [[ ! "$FREE_KB" =~ ^[0-9]+$ ]] || (( FREE_KB < MIN_FREE_KB )); then + echo "ERROR: espacio libre insuficiente para un deploy con builds Docker." + echo "Libre: $(( FREE_KB / 1024 )) MB · requerido: $(( MIN_FREE_KB / 1024 )) MB" + echo "Revisá logs y caché. Una opción segura para caché de builds es: docker builder prune -af" + exit 1 +fi +DB_HEALTH="$(docker inspect dhv2-db --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' 2>/dev/null || true)" +if [[ "$DB_HEALTH" != "healthy" ]]; then + echo "ERROR: PostgreSQL no está healthy (estado: $DB_HEALTH). No se inicia el deploy." + docker compose ps -a || true + docker logs --tail 80 dhv2-db || true + exit 1 +fi +docker compose exec -T db sh -lc 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' +mkdir -p "$STAGE" +unzip -q "$ZIP" -d "$STAGE" +[[ "$(node -p "require('$STAGE/api-v3/package.json').version")" == "0.19.3-7.1" ]] +[[ "$(node -p "require('$STAGE/web-v2/package.json').version")" == "0.19.3-7.1" ]] +grep -Fq "API_PHASE = 'D5.3.7.1'" "$STAGE/api-v3/src/version.ts" +echo "Espacio libre antes de builds: $(( FREE_KB / 1024 / 1024 )) GB" + +echo "[2/11] Compilando API en staging" +docker build --target builder -t dhv2-api:d5371-test "$STAGE/api-v3" + +echo "[3/11] Ejecutando tests API en staging" +docker run --rm \ + -v "$STAGE/api-v3/test:/app/test:ro" \ + -v "$STAGE/api-v3/tsconfig.test.json:/app/tsconfig.test.json:ro" \ + dhv2-api:d5371-test npm test + +echo "[4/11] Compilando web en staging" +docker build -t dhv2-web:d5371-test "$STAGE/web-v2" + +FREE_AFTER_BUILD_KB="$(df -Pk "$ROOT" | awk 'NR==2 {print $4}')" +if [[ ! "$FREE_AFTER_BUILD_KB" =~ ^[0-9]+$ ]] || (( FREE_AFTER_BUILD_KB < 4 * 1024 * 1024 )); then + echo "ERROR: los builds dejaron menos de 4 GB libres. Producción no será modificada." + df -h "$ROOT" + exit 1 +fi +if [[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}' 2>/dev/null || true)" != "healthy" ]]; then + echo "ERROR: PostgreSQL dejó de estar healthy durante staging. Producción no será modificada." + docker logs --tail 80 dhv2-db || true + exit 1 +fi + +echo "[5/11] Backup PRE" +mkdir -p "$PRE_BACKUP" +docker compose exec -T db sh -lc 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > "$PRE_BACKUP/database.dump" +tar \ + --exclude='api-v3/node_modules' \ + --exclude='api-v3/dist' \ + --exclude='web-v2/node_modules' \ + --exclude='web-v2/dist' \ + --exclude='.git' \ + --exclude='DH_V2_FASE_*.zip' \ + -czf "$PRE_BACKUP/source.tar.gz" \ + api-v3 web-v2 docs scripts infra docker-compose.yml README.md .env.example +if [[ -f .env ]]; then + cp -a .env "$PRE_BACKUP/.env" + chmod 600 "$PRE_BACKUP/.env" +fi + +echo "[6/11] Instalando source" +unzip -oq "$ZIP" -d "$ROOT" +chmod +x "$ROOT/scripts/deploy-phase-d5-3-7-1.sh" "$ROOT/scripts/phase-d5-3-7-1-acceptance.sh" +echo "API source: $(node -p "require('./api-v3/package.json').version")" +echo "WEB source: $(node -p "require('./web-v2/package.json').version")" + +echo "[7/11] Construyendo imágenes finales" +docker compose build api migrate web + +echo "[8/11] Aplicando migraciones pendientes" +# Se muestra la salida real; con pipefail un fallo del contenedor no queda oculto por tee. +docker compose --profile tools run --rm migrate npm run migration:show 2>&1 | tee "$MIGRATION_LOG" +if grep -Fq 'Pending migrations: yes' "$MIGRATION_LOG"; then + docker compose --profile tools run --rm migrate npm run migration:run +fi +docker compose --profile tools run --rm migrate npm run migration:show 2>&1 | tee "$MIGRATION_AFTER_LOG" +grep -Fq 'Pending migrations: no' "$MIGRATION_AFTER_LOG" + +echo "[9/11] Recreando API y web" +docker compose up -d --force-recreate api web +for i in $(seq 1 40); do + if curl -fsS http://127.0.0.1:3101/api/v3/health >"$HEALTH_FILE" 2>/dev/null; then + break + fi + if [[ "$i" == "40" ]]; then + docker compose logs --tail=200 api + exit 1 + fi + sleep 2 +done +cat "$HEALTH_FILE"; echo +grep -Fq '"version":"0.19.3-7.1"' "$HEALTH_FILE" +grep -Fq '"phase":"D5.3.7.1"' "$HEALTH_FILE" +grep -Fq '"database":"ok"' "$HEALTH_FILE" + +echo "[10/11] Aceptación automática" +bash "$ROOT/scripts/phase-d5-3-7-1-acceptance.sh" + +echo "[11/11] Backup POST" +mkdir -p "$POST_BACKUP" +docker compose exec -T db sh -lc 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > "$POST_BACKUP/database.dump" +docker compose ps -a > "$POST_BACKUP/docker-status.txt" +cp -a "$ZIP" "$POST_BACKUP/" +if [[ -f .env ]]; then + cp -a .env "$POST_BACKUP/.env" + chmod 600 "$POST_BACKUP/.env" +fi +sha256sum "$POST_BACKUP/$(basename "$ZIP")" > "$POST_BACKUP/SHA256SUMS.txt" + +echo +echo "D5.3.7.1 desplegada correctamente" +echo "Backup PRE : $PRE_BACKUP" +echo "Backup POST: $POST_BACKUP" diff --git a/scripts/deploy-phase-d5-3-7-2-1.sh b/scripts/deploy-phase-d5-3-7-2-1.sh new file mode 100755 index 0000000..de1eff9 --- /dev/null +++ b/scripts/deploy-phase-d5-3-7-2-1.sh @@ -0,0 +1,143 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT="${ROOT:-/var/www/dhv2.korexlabs.com}" +ZIP="${ZIP:-$ROOT/DH_V2_FASE_D5_3_7_2_1_SOURCE.zip}" +STAMP="$(date +%Y%m%d_%H%M%S)" +STAGE="/root/dhv2-d53721-stage-$STAMP" +BACKUP_ROOT="/root/DH_V2_BACKUPS" +PRE_BACKUP="$BACKUP_ROOT/D5_3_7_2_1_PRE_$STAMP" +POST_BACKUP="$BACKUP_ROOT/D5_3_7_2_1_OK_$STAMP" +MIGRATION_LOG="/tmp/dhv2-d53721-migration-$STAMP.log" +MIGRATION_AFTER_LOG="/tmp/dhv2-d53721-migration-after-$STAMP.log" +HEALTH_FILE="/tmp/dhv2-d53721-health-$STAMP.json" +MIN_FREE_KB=$((12 * 1024 * 1024)) + +cleanup() { + rm -rf "$STAGE" + rm -f "$MIGRATION_LOG" "$MIGRATION_AFTER_LOG" "$HEALTH_FILE" + docker image rm dhv2-api:d53721-test dhv2-web:d53721-test >/dev/null 2>&1 || true +} +trap cleanup EXIT +trap 'echo; echo "ERROR D5.3.7.2.1 en línea $LINENO: $BASH_COMMAND"' ERR + +cd "$ROOT" +test -f "$ZIP" + +echo "[1/11] Preflight y staging" +FREE_KB="$(df -Pk "$ROOT" | awk 'NR==2 {print $4}')" +if [[ ! "$FREE_KB" =~ ^[0-9]+$ ]] || (( FREE_KB < MIN_FREE_KB )); then + echo "ERROR: espacio libre insuficiente para un deploy con builds Docker." + echo "Libre: $(( FREE_KB / 1024 )) MB · requerido: $(( MIN_FREE_KB / 1024 )) MB" + echo "Revisá logs y caché. Una opción segura para caché de builds es: docker builder prune -af" + exit 1 +fi +DB_HEALTH="$(docker inspect dhv2-db --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' 2>/dev/null || true)" +if [[ "$DB_HEALTH" != "healthy" ]]; then + echo "ERROR: PostgreSQL no está healthy (estado: $DB_HEALTH). No se inicia el deploy." + docker compose ps -a || true + docker logs --tail 80 dhv2-db || true + exit 1 +fi +docker compose exec -T db sh -lc 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' +mkdir -p "$STAGE" +unzip -q "$ZIP" -d "$STAGE" +[[ "$(node -p "require('$STAGE/api-v3/package.json').version")" == "0.19.3-7.2.1" ]] +[[ "$(node -p "require('$STAGE/web-v2/package.json').version")" == "0.19.3-7.2.1" ]] +grep -Fq "API_PHASE = 'D5.3.7.2.1'" "$STAGE/api-v3/src/version.ts" +echo "Espacio libre antes de builds: $(( FREE_KB / 1024 / 1024 )) GB" + +echo "[2/11] Compilando API en staging" +docker build --target builder -t dhv2-api:d53721-test "$STAGE/api-v3" + +echo "[3/11] Ejecutando tests API en staging" +docker run --rm \ + -v "$STAGE/api-v3/test:/app/test:ro" \ + -v "$STAGE/api-v3/tsconfig.test.json:/app/tsconfig.test.json:ro" \ + dhv2-api:d53721-test npm test + +echo "[4/11] Compilando web en staging" +docker build -t dhv2-web:d53721-test "$STAGE/web-v2" + +FREE_AFTER_BUILD_KB="$(df -Pk "$ROOT" | awk 'NR==2 {print $4}')" +if [[ ! "$FREE_AFTER_BUILD_KB" =~ ^[0-9]+$ ]] || (( FREE_AFTER_BUILD_KB < 4 * 1024 * 1024 )); then + echo "ERROR: los builds dejaron menos de 4 GB libres. Producción no será modificada." + df -h "$ROOT" + exit 1 +fi +if [[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}' 2>/dev/null || true)" != "healthy" ]]; then + echo "ERROR: PostgreSQL dejó de estar healthy durante staging. Producción no será modificada." + docker logs --tail 80 dhv2-db || true + exit 1 +fi + +echo "[5/11] Backup PRE" +mkdir -p "$PRE_BACKUP" +docker compose exec -T db sh -lc 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > "$PRE_BACKUP/database.dump" +tar \ + --exclude='api-v3/node_modules' \ + --exclude='api-v3/dist' \ + --exclude='web-v2/node_modules' \ + --exclude='web-v2/dist' \ + --exclude='.git' \ + --exclude='DH_V2_FASE_*.zip' \ + -czf "$PRE_BACKUP/source.tar.gz" \ + api-v3 web-v2 docs scripts infra docker-compose.yml README.md .env.example +if [[ -f .env ]]; then + cp -a .env "$PRE_BACKUP/.env" + chmod 600 "$PRE_BACKUP/.env" +fi + +echo "[6/11] Instalando source" +unzip -oq "$ZIP" -d "$ROOT" +chmod +x "$ROOT/scripts/deploy-phase-d5-3-7-2-1.sh" "$ROOT/scripts/phase-d5-3-7-2-1-acceptance.sh" +echo "API source: $(node -p "require('./api-v3/package.json').version")" +echo "WEB source: $(node -p "require('./web-v2/package.json').version")" + +echo "[7/11] Construyendo imágenes finales" +docker compose build api migrate web + +echo "[8/11] Aplicando migraciones pendientes" +# Se muestra la salida real; con pipefail un fallo del contenedor no queda oculto por tee. +docker compose --profile tools run --rm migrate npm run migration:show 2>&1 | tee "$MIGRATION_LOG" +if grep -Fq 'Pending migrations: yes' "$MIGRATION_LOG"; then + docker compose --profile tools run --rm migrate npm run migration:run +fi +docker compose --profile tools run --rm migrate npm run migration:show 2>&1 | tee "$MIGRATION_AFTER_LOG" +grep -Fq 'Pending migrations: no' "$MIGRATION_AFTER_LOG" + +echo "[9/11] Recreando API y web" +docker compose up -d --force-recreate api web +for i in $(seq 1 40); do + if curl -fsS http://127.0.0.1:3101/api/v3/health >"$HEALTH_FILE" 2>/dev/null; then + break + fi + if [[ "$i" == "40" ]]; then + docker compose logs --tail=200 api + exit 1 + fi + sleep 2 +done +cat "$HEALTH_FILE"; echo +grep -Fq '"version":"0.19.3-7.2.1"' "$HEALTH_FILE" +grep -Fq '"phase":"D5.3.7.2.1"' "$HEALTH_FILE" +grep -Fq '"database":"ok"' "$HEALTH_FILE" + +echo "[10/11] Aceptación automática" +bash "$ROOT/scripts/phase-d5-3-7-2-1-acceptance.sh" + +echo "[11/11] Backup POST" +mkdir -p "$POST_BACKUP" +docker compose exec -T db sh -lc 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > "$POST_BACKUP/database.dump" +docker compose ps -a > "$POST_BACKUP/docker-status.txt" +cp -a "$ZIP" "$POST_BACKUP/" +if [[ -f .env ]]; then + cp -a .env "$POST_BACKUP/.env" + chmod 600 "$POST_BACKUP/.env" +fi +sha256sum "$POST_BACKUP/$(basename "$ZIP")" > "$POST_BACKUP/SHA256SUMS.txt" + +echo +echo "D5.3.7.2.1 desplegada correctamente" +echo "Backup PRE : $PRE_BACKUP" +echo "Backup POST: $POST_BACKUP" diff --git a/scripts/deploy-phase-d5-3-7-2-2.sh b/scripts/deploy-phase-d5-3-7-2-2.sh new file mode 100755 index 0000000..4f00ca2 --- /dev/null +++ b/scripts/deploy-phase-d5-3-7-2-2.sh @@ -0,0 +1,143 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT="${ROOT:-/var/www/dhv2.korexlabs.com}" +ZIP="${ZIP:-$ROOT/DH_V2_FASE_D5_3_7_2_2_SOURCE.zip}" +STAMP="$(date +%Y%m%d_%H%M%S)" +STAGE="/root/dhv2-d53722-stage-$STAMP" +BACKUP_ROOT="/root/DH_V2_BACKUPS" +PRE_BACKUP="$BACKUP_ROOT/D5_3_7_2_2_PRE_$STAMP" +POST_BACKUP="$BACKUP_ROOT/D5_3_7_2_2_OK_$STAMP" +MIGRATION_LOG="/tmp/dhv2-d53722-migration-$STAMP.log" +MIGRATION_AFTER_LOG="/tmp/dhv2-d53722-migration-after-$STAMP.log" +HEALTH_FILE="/tmp/dhv2-d53722-health-$STAMP.json" +MIN_FREE_KB=$((12 * 1024 * 1024)) + +cleanup() { + rm -rf "$STAGE" + rm -f "$MIGRATION_LOG" "$MIGRATION_AFTER_LOG" "$HEALTH_FILE" + docker image rm dhv2-api:d53722-test dhv2-web:d53722-test >/dev/null 2>&1 || true +} +trap cleanup EXIT +trap 'echo; echo "ERROR D5.3.7.2.2 en línea $LINENO: $BASH_COMMAND"' ERR + +cd "$ROOT" +test -f "$ZIP" + +echo "[1/11] Preflight y staging" +FREE_KB="$(df -Pk "$ROOT" | awk 'NR==2 {print $4}')" +if [[ ! "$FREE_KB" =~ ^[0-9]+$ ]] || (( FREE_KB < MIN_FREE_KB )); then + echo "ERROR: espacio libre insuficiente para un deploy con builds Docker." + echo "Libre: $(( FREE_KB / 1024 )) MB · requerido: $(( MIN_FREE_KB / 1024 )) MB" + echo "Revisá logs y caché. Una opción segura para caché de builds es: docker builder prune -af" + exit 1 +fi +DB_HEALTH="$(docker inspect dhv2-db --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' 2>/dev/null || true)" +if [[ "$DB_HEALTH" != "healthy" ]]; then + echo "ERROR: PostgreSQL no está healthy (estado: $DB_HEALTH). No se inicia el deploy." + docker compose ps -a || true + docker logs --tail 80 dhv2-db || true + exit 1 +fi +docker compose exec -T db sh -lc 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' +mkdir -p "$STAGE" +unzip -q "$ZIP" -d "$STAGE" +[[ "$(node -p "require('$STAGE/api-v3/package.json').version")" == "0.19.3-7.2.2" ]] +[[ "$(node -p "require('$STAGE/web-v2/package.json').version")" == "0.19.3-7.2.2" ]] +grep -Fq "API_PHASE = 'D5.3.7.2.2'" "$STAGE/api-v3/src/version.ts" +echo "Espacio libre antes de builds: $(( FREE_KB / 1024 / 1024 )) GB" + +echo "[2/11] Compilando API en staging" +docker build --target builder -t dhv2-api:d53722-test "$STAGE/api-v3" + +echo "[3/11] Ejecutando tests API en staging" +docker run --rm \ + -v "$STAGE/api-v3/test:/app/test:ro" \ + -v "$STAGE/api-v3/tsconfig.test.json:/app/tsconfig.test.json:ro" \ + dhv2-api:d53722-test npm test + +echo "[4/11] Compilando web en staging" +docker build -t dhv2-web:d53722-test "$STAGE/web-v2" + +FREE_AFTER_BUILD_KB="$(df -Pk "$ROOT" | awk 'NR==2 {print $4}')" +if [[ ! "$FREE_AFTER_BUILD_KB" =~ ^[0-9]+$ ]] || (( FREE_AFTER_BUILD_KB < 4 * 1024 * 1024 )); then + echo "ERROR: los builds dejaron menos de 4 GB libres. Producción no será modificada." + df -h "$ROOT" + exit 1 +fi +if [[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}' 2>/dev/null || true)" != "healthy" ]]; then + echo "ERROR: PostgreSQL dejó de estar healthy durante staging. Producción no será modificada." + docker logs --tail 80 dhv2-db || true + exit 1 +fi + +echo "[5/11] Backup PRE" +mkdir -p "$PRE_BACKUP" +docker compose exec -T db sh -lc 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > "$PRE_BACKUP/database.dump" +tar \ + --exclude='api-v3/node_modules' \ + --exclude='api-v3/dist' \ + --exclude='web-v2/node_modules' \ + --exclude='web-v2/dist' \ + --exclude='.git' \ + --exclude='DH_V2_FASE_*.zip' \ + -czf "$PRE_BACKUP/source.tar.gz" \ + api-v3 web-v2 docs scripts infra docker-compose.yml README.md .env.example +if [[ -f .env ]]; then + cp -a .env "$PRE_BACKUP/.env" + chmod 600 "$PRE_BACKUP/.env" +fi + +echo "[6/11] Instalando source" +unzip -oq "$ZIP" -d "$ROOT" +chmod +x "$ROOT/scripts/deploy-phase-d5-3-7-2-2.sh" "$ROOT/scripts/phase-d5-3-7-2-2-acceptance.sh" +echo "API source: $(node -p "require('./api-v3/package.json').version")" +echo "WEB source: $(node -p "require('./web-v2/package.json').version")" + +echo "[7/11] Construyendo imágenes finales" +docker compose build api migrate web + +echo "[8/11] Aplicando migraciones pendientes" +# Se muestra la salida real; con pipefail un fallo del contenedor no queda oculto por tee. +docker compose --profile tools run --rm migrate npm run migration:show 2>&1 | tee "$MIGRATION_LOG" +if grep -Fq 'Pending migrations: yes' "$MIGRATION_LOG"; then + docker compose --profile tools run --rm migrate npm run migration:run +fi +docker compose --profile tools run --rm migrate npm run migration:show 2>&1 | tee "$MIGRATION_AFTER_LOG" +grep -Fq 'Pending migrations: no' "$MIGRATION_AFTER_LOG" + +echo "[9/11] Recreando API y web" +docker compose up -d --force-recreate api web +for i in $(seq 1 40); do + if curl -fsS http://127.0.0.1:3101/api/v3/health >"$HEALTH_FILE" 2>/dev/null; then + break + fi + if [[ "$i" == "40" ]]; then + docker compose logs --tail=200 api + exit 1 + fi + sleep 2 +done +cat "$HEALTH_FILE"; echo +grep -Fq '"version":"0.19.3-7.2.2"' "$HEALTH_FILE" +grep -Fq '"phase":"D5.3.7.2.2"' "$HEALTH_FILE" +grep -Fq '"database":"ok"' "$HEALTH_FILE" + +echo "[10/11] Aceptación automática" +bash "$ROOT/scripts/phase-d5-3-7-2-2-acceptance.sh" + +echo "[11/11] Backup POST" +mkdir -p "$POST_BACKUP" +docker compose exec -T db sh -lc 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > "$POST_BACKUP/database.dump" +docker compose ps -a > "$POST_BACKUP/docker-status.txt" +cp -a "$ZIP" "$POST_BACKUP/" +if [[ -f .env ]]; then + cp -a .env "$POST_BACKUP/.env" + chmod 600 "$POST_BACKUP/.env" +fi +sha256sum "$POST_BACKUP/$(basename "$ZIP")" > "$POST_BACKUP/SHA256SUMS.txt" + +echo +echo "D5.3.7.2.2 desplegada correctamente" +echo "Backup PRE : $PRE_BACKUP" +echo "Backup POST: $POST_BACKUP" diff --git a/scripts/deploy-phase-d5-3-7-2-3.sh b/scripts/deploy-phase-d5-3-7-2-3.sh new file mode 100755 index 0000000..ca4fa95 --- /dev/null +++ b/scripts/deploy-phase-d5-3-7-2-3.sh @@ -0,0 +1,143 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT="${ROOT:-/var/www/dhv2.korexlabs.com}" +ZIP="${ZIP:-$ROOT/DH_V2_FASE_D5_3_7_2_3_SOURCE.zip}" +STAMP="$(date +%Y%m%d_%H%M%S)" +STAGE="/root/dhv2-d53723-stage-$STAMP" +BACKUP_ROOT="/root/DH_V2_BACKUPS" +PRE_BACKUP="$BACKUP_ROOT/D5_3_7_2_3_PRE_$STAMP" +POST_BACKUP="$BACKUP_ROOT/D5_3_7_2_3_OK_$STAMP" +MIGRATION_LOG="/tmp/dhv2-d53723-migration-$STAMP.log" +MIGRATION_AFTER_LOG="/tmp/dhv2-d53723-migration-after-$STAMP.log" +HEALTH_FILE="/tmp/dhv2-d53723-health-$STAMP.json" +MIN_FREE_KB=$((12 * 1024 * 1024)) + +cleanup() { + rm -rf "$STAGE" + rm -f "$MIGRATION_LOG" "$MIGRATION_AFTER_LOG" "$HEALTH_FILE" + docker image rm dhv2-api:d53723-test dhv2-web:d53723-test >/dev/null 2>&1 || true +} +trap cleanup EXIT +trap 'echo; echo "ERROR D5.3.7.2.3 en línea $LINENO: $BASH_COMMAND"' ERR + +cd "$ROOT" +test -f "$ZIP" + +echo "[1/11] Preflight y staging" +FREE_KB="$(df -Pk "$ROOT" | awk 'NR==2 {print $4}')" +if [[ ! "$FREE_KB" =~ ^[0-9]+$ ]] || (( FREE_KB < MIN_FREE_KB )); then + echo "ERROR: espacio libre insuficiente para un deploy con builds Docker." + echo "Libre: $(( FREE_KB / 1024 )) MB · requerido: $(( MIN_FREE_KB / 1024 )) MB" + echo "Revisá logs y caché. Una opción segura para caché de builds es: docker builder prune -af" + exit 1 +fi +DB_HEALTH="$(docker inspect dhv2-db --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' 2>/dev/null || true)" +if [[ "$DB_HEALTH" != "healthy" ]]; then + echo "ERROR: PostgreSQL no está healthy (estado: $DB_HEALTH). No se inicia el deploy." + docker compose ps -a || true + docker logs --tail 80 dhv2-db || true + exit 1 +fi +docker compose exec -T db sh -lc 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' +mkdir -p "$STAGE" +unzip -q "$ZIP" -d "$STAGE" +[[ "$(node -p "require('$STAGE/api-v3/package.json').version")" == "0.19.3-7.2.3" ]] +[[ "$(node -p "require('$STAGE/web-v2/package.json').version")" == "0.19.3-7.2.3" ]] +grep -Fq "API_PHASE = 'D5.3.7.2.3'" "$STAGE/api-v3/src/version.ts" +echo "Espacio libre antes de builds: $(( FREE_KB / 1024 / 1024 )) GB" + +echo "[2/11] Compilando API en staging" +docker build --target builder -t dhv2-api:d53723-test "$STAGE/api-v3" + +echo "[3/11] Ejecutando tests API en staging" +docker run --rm \ + -v "$STAGE/api-v3/test:/app/test:ro" \ + -v "$STAGE/api-v3/tsconfig.test.json:/app/tsconfig.test.json:ro" \ + dhv2-api:d53723-test npm test + +echo "[4/11] Compilando web en staging" +docker build -t dhv2-web:d53723-test "$STAGE/web-v2" + +FREE_AFTER_BUILD_KB="$(df -Pk "$ROOT" | awk 'NR==2 {print $4}')" +if [[ ! "$FREE_AFTER_BUILD_KB" =~ ^[0-9]+$ ]] || (( FREE_AFTER_BUILD_KB < 4 * 1024 * 1024 )); then + echo "ERROR: los builds dejaron menos de 4 GB libres. Producción no será modificada." + df -h "$ROOT" + exit 1 +fi +if [[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}' 2>/dev/null || true)" != "healthy" ]]; then + echo "ERROR: PostgreSQL dejó de estar healthy durante staging. Producción no será modificada." + docker logs --tail 80 dhv2-db || true + exit 1 +fi + +echo "[5/11] Backup PRE" +mkdir -p "$PRE_BACKUP" +docker compose exec -T db sh -lc 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > "$PRE_BACKUP/database.dump" +tar \ + --exclude='api-v3/node_modules' \ + --exclude='api-v3/dist' \ + --exclude='web-v2/node_modules' \ + --exclude='web-v2/dist' \ + --exclude='.git' \ + --exclude='DH_V2_FASE_*.zip' \ + -czf "$PRE_BACKUP/source.tar.gz" \ + api-v3 web-v2 docs scripts infra docker-compose.yml README.md .env.example +if [[ -f .env ]]; then + cp -a .env "$PRE_BACKUP/.env" + chmod 600 "$PRE_BACKUP/.env" +fi + +echo "[6/11] Instalando source" +unzip -oq "$ZIP" -d "$ROOT" +chmod +x "$ROOT/scripts/deploy-phase-d5-3-7-2-3.sh" "$ROOT/scripts/phase-d5-3-7-2-3-acceptance.sh" +echo "API source: $(node -p "require('./api-v3/package.json').version")" +echo "WEB source: $(node -p "require('./web-v2/package.json').version")" + +echo "[7/11] Construyendo imágenes finales" +docker compose build api migrate web + +echo "[8/11] Aplicando migraciones pendientes" +# Se muestra la salida real; con pipefail un fallo del contenedor no queda oculto por tee. +docker compose --profile tools run --rm migrate npm run migration:show 2>&1 | tee "$MIGRATION_LOG" +if grep -Fq 'Pending migrations: yes' "$MIGRATION_LOG"; then + docker compose --profile tools run --rm migrate npm run migration:run +fi +docker compose --profile tools run --rm migrate npm run migration:show 2>&1 | tee "$MIGRATION_AFTER_LOG" +grep -Fq 'Pending migrations: no' "$MIGRATION_AFTER_LOG" + +echo "[9/11] Recreando API y web" +docker compose up -d --force-recreate api web +for i in $(seq 1 40); do + if curl -fsS http://127.0.0.1:3101/api/v3/health >"$HEALTH_FILE" 2>/dev/null; then + break + fi + if [[ "$i" == "40" ]]; then + docker compose logs --tail=200 api + exit 1 + fi + sleep 2 +done +cat "$HEALTH_FILE"; echo +grep -Fq '"version":"0.19.3-7.2.3"' "$HEALTH_FILE" +grep -Fq '"phase":"D5.3.7.2.3"' "$HEALTH_FILE" +grep -Fq '"database":"ok"' "$HEALTH_FILE" + +echo "[10/11] Aceptación automática" +bash "$ROOT/scripts/phase-d5-3-7-2-3-acceptance.sh" + +echo "[11/11] Backup POST" +mkdir -p "$POST_BACKUP" +docker compose exec -T db sh -lc 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > "$POST_BACKUP/database.dump" +docker compose ps -a > "$POST_BACKUP/docker-status.txt" +cp -a "$ZIP" "$POST_BACKUP/" +if [[ -f .env ]]; then + cp -a .env "$POST_BACKUP/.env" + chmod 600 "$POST_BACKUP/.env" +fi +sha256sum "$POST_BACKUP/$(basename "$ZIP")" > "$POST_BACKUP/SHA256SUMS.txt" + +echo +echo "D5.3.7.2.3 desplegada correctamente" +echo "Backup PRE : $PRE_BACKUP" +echo "Backup POST: $POST_BACKUP" diff --git a/scripts/deploy-phase-d5-3-7-2-4.sh b/scripts/deploy-phase-d5-3-7-2-4.sh new file mode 100755 index 0000000..5955a1b --- /dev/null +++ b/scripts/deploy-phase-d5-3-7-2-4.sh @@ -0,0 +1,143 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT="${ROOT:-/var/www/dhv2.korexlabs.com}" +ZIP="${ZIP:-$ROOT/DH_V2_FASE_D5_3_7_2_4_SOURCE.zip}" +STAMP="$(date +%Y%m%d_%H%M%S)" +STAGE="/root/dhv2-d53724-stage-$STAMP" +BACKUP_ROOT="/root/DH_V2_BACKUPS" +PRE_BACKUP="$BACKUP_ROOT/D5_3_7_2_4_PRE_$STAMP" +POST_BACKUP="$BACKUP_ROOT/D5_3_7_2_4_OK_$STAMP" +MIGRATION_LOG="/tmp/dhv2-d53724-migration-$STAMP.log" +MIGRATION_AFTER_LOG="/tmp/dhv2-d53724-migration-after-$STAMP.log" +HEALTH_FILE="/tmp/dhv2-d53724-health-$STAMP.json" +MIN_FREE_KB=$((12 * 1024 * 1024)) + +cleanup() { + rm -rf "$STAGE" + rm -f "$MIGRATION_LOG" "$MIGRATION_AFTER_LOG" "$HEALTH_FILE" + docker image rm dhv2-api:d53724-test dhv2-web:d53724-test >/dev/null 2>&1 || true +} +trap cleanup EXIT +trap 'echo; echo "ERROR D5.3.7.2.4 en línea $LINENO: $BASH_COMMAND"' ERR + +cd "$ROOT" +test -f "$ZIP" + +echo "[1/11] Preflight y staging" +FREE_KB="$(df -Pk "$ROOT" | awk 'NR==2 {print $4}')" +if [[ ! "$FREE_KB" =~ ^[0-9]+$ ]] || (( FREE_KB < MIN_FREE_KB )); then + echo "ERROR: espacio libre insuficiente para un deploy con builds Docker." + echo "Libre: $(( FREE_KB / 1024 )) MB · requerido: $(( MIN_FREE_KB / 1024 )) MB" + echo "Revisá logs y caché. Una opción segura para caché de builds es: docker builder prune -af" + exit 1 +fi +DB_HEALTH="$(docker inspect dhv2-db --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' 2>/dev/null || true)" +if [[ "$DB_HEALTH" != "healthy" ]]; then + echo "ERROR: PostgreSQL no está healthy (estado: $DB_HEALTH). No se inicia el deploy." + docker compose ps -a || true + docker logs --tail 80 dhv2-db || true + exit 1 +fi +docker compose exec -T db sh -lc 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' +mkdir -p "$STAGE" +unzip -q "$ZIP" -d "$STAGE" +[[ "$(node -p "require('$STAGE/api-v3/package.json').version")" == "0.19.3-7.2.4" ]] +[[ "$(node -p "require('$STAGE/web-v2/package.json').version")" == "0.19.3-7.2.4" ]] +grep -Fq "API_PHASE = 'D5.3.7.2.4'" "$STAGE/api-v3/src/version.ts" +echo "Espacio libre antes de builds: $(( FREE_KB / 1024 / 1024 )) GB" + +echo "[2/11] Compilando API en staging" +docker build --target builder -t dhv2-api:d53724-test "$STAGE/api-v3" + +echo "[3/11] Ejecutando tests API en staging" +docker run --rm \ + -v "$STAGE/api-v3/test:/app/test:ro" \ + -v "$STAGE/api-v3/tsconfig.test.json:/app/tsconfig.test.json:ro" \ + dhv2-api:d53724-test npm test + +echo "[4/11] Compilando web en staging" +docker build -t dhv2-web:d53724-test "$STAGE/web-v2" + +FREE_AFTER_BUILD_KB="$(df -Pk "$ROOT" | awk 'NR==2 {print $4}')" +if [[ ! "$FREE_AFTER_BUILD_KB" =~ ^[0-9]+$ ]] || (( FREE_AFTER_BUILD_KB < 4 * 1024 * 1024 )); then + echo "ERROR: los builds dejaron menos de 4 GB libres. Producción no será modificada." + df -h "$ROOT" + exit 1 +fi +if [[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}' 2>/dev/null || true)" != "healthy" ]]; then + echo "ERROR: PostgreSQL dejó de estar healthy durante staging. Producción no será modificada." + docker logs --tail 80 dhv2-db || true + exit 1 +fi + +echo "[5/11] Backup PRE" +mkdir -p "$PRE_BACKUP" +docker compose exec -T db sh -lc 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > "$PRE_BACKUP/database.dump" +tar \ + --exclude='api-v3/node_modules' \ + --exclude='api-v3/dist' \ + --exclude='web-v2/node_modules' \ + --exclude='web-v2/dist' \ + --exclude='.git' \ + --exclude='DH_V2_FASE_*.zip' \ + -czf "$PRE_BACKUP/source.tar.gz" \ + api-v3 web-v2 docs scripts infra docker-compose.yml README.md .env.example +if [[ -f .env ]]; then + cp -a .env "$PRE_BACKUP/.env" + chmod 600 "$PRE_BACKUP/.env" +fi + +echo "[6/11] Instalando source" +unzip -oq "$ZIP" -d "$ROOT" +chmod +x "$ROOT/scripts/deploy-phase-d5-3-7-2-4.sh" "$ROOT/scripts/phase-d5-3-7-2-4-acceptance.sh" +echo "API source: $(node -p "require('./api-v3/package.json').version")" +echo "WEB source: $(node -p "require('./web-v2/package.json').version")" + +echo "[7/11] Construyendo imágenes finales" +docker compose build api migrate web + +echo "[8/11] Aplicando migraciones pendientes" +# Se muestra la salida real; con pipefail un fallo del contenedor no queda oculto por tee. +docker compose --profile tools run --rm migrate npm run migration:show 2>&1 | tee "$MIGRATION_LOG" +if grep -Fq 'Pending migrations: yes' "$MIGRATION_LOG"; then + docker compose --profile tools run --rm migrate npm run migration:run +fi +docker compose --profile tools run --rm migrate npm run migration:show 2>&1 | tee "$MIGRATION_AFTER_LOG" +grep -Fq 'Pending migrations: no' "$MIGRATION_AFTER_LOG" + +echo "[9/11] Recreando API y web" +docker compose up -d --force-recreate api web +for i in $(seq 1 40); do + if curl -fsS http://127.0.0.1:3101/api/v3/health >"$HEALTH_FILE" 2>/dev/null; then + break + fi + if [[ "$i" == "40" ]]; then + docker compose logs --tail=200 api + exit 1 + fi + sleep 2 +done +cat "$HEALTH_FILE"; echo +grep -Fq '"version":"0.19.3-7.2.4"' "$HEALTH_FILE" +grep -Fq '"phase":"D5.3.7.2.4"' "$HEALTH_FILE" +grep -Fq '"database":"ok"' "$HEALTH_FILE" + +echo "[10/11] Aceptación automática" +bash "$ROOT/scripts/phase-d5-3-7-2-4-acceptance.sh" + +echo "[11/11] Backup POST" +mkdir -p "$POST_BACKUP" +docker compose exec -T db sh -lc 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > "$POST_BACKUP/database.dump" +docker compose ps -a > "$POST_BACKUP/docker-status.txt" +cp -a "$ZIP" "$POST_BACKUP/" +if [[ -f .env ]]; then + cp -a .env "$POST_BACKUP/.env" + chmod 600 "$POST_BACKUP/.env" +fi +sha256sum "$POST_BACKUP/$(basename "$ZIP")" > "$POST_BACKUP/SHA256SUMS.txt" + +echo +echo "D5.3.7.2.4 desplegada correctamente" +echo "Backup PRE : $PRE_BACKUP" +echo "Backup POST: $POST_BACKUP" diff --git a/scripts/deploy-phase-d5-3-7-2-5.sh b/scripts/deploy-phase-d5-3-7-2-5.sh new file mode 100755 index 0000000..d9f6c1c --- /dev/null +++ b/scripts/deploy-phase-d5-3-7-2-5.sh @@ -0,0 +1,143 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT="${ROOT:-/var/www/dhv2.korexlabs.com}" +ZIP="${ZIP:-$ROOT/DH_V2_FASE_D5_3_7_2_5_SOURCE.zip}" +STAMP="$(date +%Y%m%d_%H%M%S)" +STAGE="/root/dhv2-d53725-stage-$STAMP" +BACKUP_ROOT="/root/DH_V2_BACKUPS" +PRE_BACKUP="$BACKUP_ROOT/D5_3_7_2_5_PRE_$STAMP" +POST_BACKUP="$BACKUP_ROOT/D5_3_7_2_5_OK_$STAMP" +MIGRATION_LOG="/tmp/dhv2-d53725-migration-$STAMP.log" +MIGRATION_AFTER_LOG="/tmp/dhv2-d53725-migration-after-$STAMP.log" +HEALTH_FILE="/tmp/dhv2-d53725-health-$STAMP.json" +MIN_FREE_KB=$((12 * 1024 * 1024)) + +cleanup() { + rm -rf "$STAGE" + rm -f "$MIGRATION_LOG" "$MIGRATION_AFTER_LOG" "$HEALTH_FILE" + docker image rm dhv2-api:d53725-test dhv2-web:d53725-test >/dev/null 2>&1 || true +} +trap cleanup EXIT +trap 'echo; echo "ERROR D5.3.7.2.5 en línea $LINENO: $BASH_COMMAND"' ERR + +cd "$ROOT" +test -f "$ZIP" + +echo "[1/11] Preflight y staging" +FREE_KB="$(df -Pk "$ROOT" | awk 'NR==2 {print $4}')" +if [[ ! "$FREE_KB" =~ ^[0-9]+$ ]] || (( FREE_KB < MIN_FREE_KB )); then + echo "ERROR: espacio libre insuficiente para un deploy con builds Docker." + echo "Libre: $(( FREE_KB / 1024 )) MB · requerido: $(( MIN_FREE_KB / 1024 )) MB" + echo "Revisá logs y caché. Una opción segura para caché de builds es: docker builder prune -af" + exit 1 +fi +DB_HEALTH="$(docker inspect dhv2-db --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' 2>/dev/null || true)" +if [[ "$DB_HEALTH" != "healthy" ]]; then + echo "ERROR: PostgreSQL no está healthy (estado: $DB_HEALTH). No se inicia el deploy." + docker compose ps -a || true + docker logs --tail 80 dhv2-db || true + exit 1 +fi +docker compose exec -T db sh -lc 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' +mkdir -p "$STAGE" +unzip -q "$ZIP" -d "$STAGE" +[[ "$(node -p "require('$STAGE/api-v3/package.json').version")" == "0.19.3-7.2.5" ]] +[[ "$(node -p "require('$STAGE/web-v2/package.json').version")" == "0.19.3-7.2.5" ]] +grep -Fq "API_PHASE = 'D5.3.7.2.5'" "$STAGE/api-v3/src/version.ts" +echo "Espacio libre antes de builds: $(( FREE_KB / 1024 / 1024 )) GB" + +echo "[2/11] Compilando API en staging" +docker build --target builder -t dhv2-api:d53725-test "$STAGE/api-v3" + +echo "[3/11] Ejecutando tests API en staging" +docker run --rm \ + -v "$STAGE/api-v3/test:/app/test:ro" \ + -v "$STAGE/api-v3/tsconfig.test.json:/app/tsconfig.test.json:ro" \ + dhv2-api:d53725-test npm test + +echo "[4/11] Compilando web en staging" +docker build -t dhv2-web:d53725-test "$STAGE/web-v2" + +FREE_AFTER_BUILD_KB="$(df -Pk "$ROOT" | awk 'NR==2 {print $4}')" +if [[ ! "$FREE_AFTER_BUILD_KB" =~ ^[0-9]+$ ]] || (( FREE_AFTER_BUILD_KB < 4 * 1024 * 1024 )); then + echo "ERROR: los builds dejaron menos de 4 GB libres. Producción no será modificada." + df -h "$ROOT" + exit 1 +fi +if [[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}' 2>/dev/null || true)" != "healthy" ]]; then + echo "ERROR: PostgreSQL dejó de estar healthy durante staging. Producción no será modificada." + docker logs --tail 80 dhv2-db || true + exit 1 +fi + +echo "[5/11] Backup PRE" +mkdir -p "$PRE_BACKUP" +docker compose exec -T db sh -lc 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > "$PRE_BACKUP/database.dump" +tar \ + --exclude='api-v3/node_modules' \ + --exclude='api-v3/dist' \ + --exclude='web-v2/node_modules' \ + --exclude='web-v2/dist' \ + --exclude='.git' \ + --exclude='DH_V2_FASE_*.zip' \ + -czf "$PRE_BACKUP/source.tar.gz" \ + api-v3 web-v2 docs scripts infra docker-compose.yml README.md .env.example +if [[ -f .env ]]; then + cp -a .env "$PRE_BACKUP/.env" + chmod 600 "$PRE_BACKUP/.env" +fi + +echo "[6/11] Instalando source" +unzip -oq "$ZIP" -d "$ROOT" +chmod +x "$ROOT/scripts/deploy-phase-d5-3-7-2-5.sh" "$ROOT/scripts/phase-d5-3-7-2-5-acceptance.sh" +echo "API source: $(node -p "require('./api-v3/package.json').version")" +echo "WEB source: $(node -p "require('./web-v2/package.json').version")" + +echo "[7/11] Construyendo imágenes finales" +docker compose build api migrate web + +echo "[8/11] Aplicando migraciones pendientes" +# Se muestra la salida real; con pipefail un fallo del contenedor no queda oculto por tee. +docker compose --profile tools run --rm migrate npm run migration:show 2>&1 | tee "$MIGRATION_LOG" +if grep -Fq 'Pending migrations: yes' "$MIGRATION_LOG"; then + docker compose --profile tools run --rm migrate npm run migration:run +fi +docker compose --profile tools run --rm migrate npm run migration:show 2>&1 | tee "$MIGRATION_AFTER_LOG" +grep -Fq 'Pending migrations: no' "$MIGRATION_AFTER_LOG" + +echo "[9/11] Recreando API y web" +docker compose up -d --force-recreate api web +for i in $(seq 1 40); do + if curl -fsS http://127.0.0.1:3101/api/v3/health >"$HEALTH_FILE" 2>/dev/null; then + break + fi + if [[ "$i" == "40" ]]; then + docker compose logs --tail=200 api + exit 1 + fi + sleep 2 +done +cat "$HEALTH_FILE"; echo +grep -Fq '"version":"0.19.3-7.2.5"' "$HEALTH_FILE" +grep -Fq '"phase":"D5.3.7.2.5"' "$HEALTH_FILE" +grep -Fq '"database":"ok"' "$HEALTH_FILE" + +echo "[10/11] Aceptación automática" +bash "$ROOT/scripts/phase-d5-3-7-2-5-acceptance.sh" + +echo "[11/11] Backup POST" +mkdir -p "$POST_BACKUP" +docker compose exec -T db sh -lc 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > "$POST_BACKUP/database.dump" +docker compose ps -a > "$POST_BACKUP/docker-status.txt" +cp -a "$ZIP" "$POST_BACKUP/" +if [[ -f .env ]]; then + cp -a .env "$POST_BACKUP/.env" + chmod 600 "$POST_BACKUP/.env" +fi +sha256sum "$POST_BACKUP/$(basename "$ZIP")" > "$POST_BACKUP/SHA256SUMS.txt" + +echo +echo "D5.3.7.2.5 desplegada correctamente" +echo "Backup PRE : $PRE_BACKUP" +echo "Backup POST: $POST_BACKUP" diff --git a/scripts/deploy-phase-d5-3-7-2.sh b/scripts/deploy-phase-d5-3-7-2.sh new file mode 100755 index 0000000..e6b292b --- /dev/null +++ b/scripts/deploy-phase-d5-3-7-2.sh @@ -0,0 +1,143 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT="${ROOT:-/var/www/dhv2.korexlabs.com}" +ZIP="${ZIP:-$ROOT/DH_V2_FASE_D5_3_7_2_SOURCE.zip}" +STAMP="$(date +%Y%m%d_%H%M%S)" +STAGE="/root/dhv2-d5372-stage-$STAMP" +BACKUP_ROOT="/root/DH_V2_BACKUPS" +PRE_BACKUP="$BACKUP_ROOT/D5_3_7_2_PRE_$STAMP" +POST_BACKUP="$BACKUP_ROOT/D5_3_7_2_OK_$STAMP" +MIGRATION_LOG="/tmp/dhv2-d5372-migration-$STAMP.log" +MIGRATION_AFTER_LOG="/tmp/dhv2-d5372-migration-after-$STAMP.log" +HEALTH_FILE="/tmp/dhv2-d5372-health-$STAMP.json" +MIN_FREE_KB=$((12 * 1024 * 1024)) + +cleanup() { + rm -rf "$STAGE" + rm -f "$MIGRATION_LOG" "$MIGRATION_AFTER_LOG" "$HEALTH_FILE" + docker image rm dhv2-api:d5372-test dhv2-web:d5372-test >/dev/null 2>&1 || true +} +trap cleanup EXIT +trap 'echo; echo "ERROR D5.3.7.2 en línea $LINENO: $BASH_COMMAND"' ERR + +cd "$ROOT" +test -f "$ZIP" + +echo "[1/11] Preflight y staging" +FREE_KB="$(df -Pk "$ROOT" | awk 'NR==2 {print $4}')" +if [[ ! "$FREE_KB" =~ ^[0-9]+$ ]] || (( FREE_KB < MIN_FREE_KB )); then + echo "ERROR: espacio libre insuficiente para un deploy con builds Docker." + echo "Libre: $(( FREE_KB / 1024 )) MB · requerido: $(( MIN_FREE_KB / 1024 )) MB" + echo "Revisá logs y caché. Una opción segura para caché de builds es: docker builder prune -af" + exit 1 +fi +DB_HEALTH="$(docker inspect dhv2-db --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' 2>/dev/null || true)" +if [[ "$DB_HEALTH" != "healthy" ]]; then + echo "ERROR: PostgreSQL no está healthy (estado: $DB_HEALTH). No se inicia el deploy." + docker compose ps -a || true + docker logs --tail 80 dhv2-db || true + exit 1 +fi +docker compose exec -T db sh -lc 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' +mkdir -p "$STAGE" +unzip -q "$ZIP" -d "$STAGE" +[[ "$(node -p "require('$STAGE/api-v3/package.json').version")" == "0.19.3-7.2" ]] +[[ "$(node -p "require('$STAGE/web-v2/package.json').version")" == "0.19.3-7.2" ]] +grep -Fq "API_PHASE = 'D5.3.7.2'" "$STAGE/api-v3/src/version.ts" +echo "Espacio libre antes de builds: $(( FREE_KB / 1024 / 1024 )) GB" + +echo "[2/11] Compilando API en staging" +docker build --target builder -t dhv2-api:d5372-test "$STAGE/api-v3" + +echo "[3/11] Ejecutando tests API en staging" +docker run --rm \ + -v "$STAGE/api-v3/test:/app/test:ro" \ + -v "$STAGE/api-v3/tsconfig.test.json:/app/tsconfig.test.json:ro" \ + dhv2-api:d5372-test npm test + +echo "[4/11] Compilando web en staging" +docker build -t dhv2-web:d5372-test "$STAGE/web-v2" + +FREE_AFTER_BUILD_KB="$(df -Pk "$ROOT" | awk 'NR==2 {print $4}')" +if [[ ! "$FREE_AFTER_BUILD_KB" =~ ^[0-9]+$ ]] || (( FREE_AFTER_BUILD_KB < 4 * 1024 * 1024 )); then + echo "ERROR: los builds dejaron menos de 4 GB libres. Producción no será modificada." + df -h "$ROOT" + exit 1 +fi +if [[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}' 2>/dev/null || true)" != "healthy" ]]; then + echo "ERROR: PostgreSQL dejó de estar healthy durante staging. Producción no será modificada." + docker logs --tail 80 dhv2-db || true + exit 1 +fi + +echo "[5/11] Backup PRE" +mkdir -p "$PRE_BACKUP" +docker compose exec -T db sh -lc 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > "$PRE_BACKUP/database.dump" +tar \ + --exclude='api-v3/node_modules' \ + --exclude='api-v3/dist' \ + --exclude='web-v2/node_modules' \ + --exclude='web-v2/dist' \ + --exclude='.git' \ + --exclude='DH_V2_FASE_*.zip' \ + -czf "$PRE_BACKUP/source.tar.gz" \ + api-v3 web-v2 docs scripts infra docker-compose.yml README.md .env.example +if [[ -f .env ]]; then + cp -a .env "$PRE_BACKUP/.env" + chmod 600 "$PRE_BACKUP/.env" +fi + +echo "[6/11] Instalando source" +unzip -oq "$ZIP" -d "$ROOT" +chmod +x "$ROOT/scripts/deploy-phase-d5-3-7-2.sh" "$ROOT/scripts/phase-d5-3-7-2-acceptance.sh" +echo "API source: $(node -p "require('./api-v3/package.json').version")" +echo "WEB source: $(node -p "require('./web-v2/package.json').version")" + +echo "[7/11] Construyendo imágenes finales" +docker compose build api migrate web + +echo "[8/11] Aplicando migraciones pendientes" +# Se muestra la salida real; con pipefail un fallo del contenedor no queda oculto por tee. +docker compose --profile tools run --rm migrate npm run migration:show 2>&1 | tee "$MIGRATION_LOG" +if grep -Fq 'Pending migrations: yes' "$MIGRATION_LOG"; then + docker compose --profile tools run --rm migrate npm run migration:run +fi +docker compose --profile tools run --rm migrate npm run migration:show 2>&1 | tee "$MIGRATION_AFTER_LOG" +grep -Fq 'Pending migrations: no' "$MIGRATION_AFTER_LOG" + +echo "[9/11] Recreando API y web" +docker compose up -d --force-recreate api web +for i in $(seq 1 40); do + if curl -fsS http://127.0.0.1:3101/api/v3/health >"$HEALTH_FILE" 2>/dev/null; then + break + fi + if [[ "$i" == "40" ]]; then + docker compose logs --tail=200 api + exit 1 + fi + sleep 2 +done +cat "$HEALTH_FILE"; echo +grep -Fq '"version":"0.19.3-7.2"' "$HEALTH_FILE" +grep -Fq '"phase":"D5.3.7.2"' "$HEALTH_FILE" +grep -Fq '"database":"ok"' "$HEALTH_FILE" + +echo "[10/11] Aceptación automática" +bash "$ROOT/scripts/phase-d5-3-7-2-acceptance.sh" + +echo "[11/11] Backup POST" +mkdir -p "$POST_BACKUP" +docker compose exec -T db sh -lc 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > "$POST_BACKUP/database.dump" +docker compose ps -a > "$POST_BACKUP/docker-status.txt" +cp -a "$ZIP" "$POST_BACKUP/" +if [[ -f .env ]]; then + cp -a .env "$POST_BACKUP/.env" + chmod 600 "$POST_BACKUP/.env" +fi +sha256sum "$POST_BACKUP/$(basename "$ZIP")" > "$POST_BACKUP/SHA256SUMS.txt" + +echo +echo "D5.3.7.2 desplegada correctamente" +echo "Backup PRE : $PRE_BACKUP" +echo "Backup POST: $POST_BACKUP" diff --git a/scripts/deploy-phase-d5-3-8.sh b/scripts/deploy-phase-d5-3-8.sh new file mode 100755 index 0000000..6752f0d --- /dev/null +++ b/scripts/deploy-phase-d5-3-8.sh @@ -0,0 +1,147 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT="${ROOT:-/var/www/dhv2.korexlabs.com}" +ZIP="${ZIP:-$ROOT/DH_V2_FASE_D5_3_8_SOURCE.zip}" +STAMP="$(date +%Y%m%d_%H%M%S)" +STAGE="/root/dhv2-d538-stage-$STAMP" +BACKUP_ROOT="/root/DH_V2_BACKUPS" +STRONG_BACKUP="$BACKUP_ROOT/INVENTARIO_A_REVISAR_$STAMP" +POST_BACKUP="$BACKUP_ROOT/D5_3_8_OK_$STAMP" +MIGRATION_LOG="/tmp/dhv2-d538-migration-$STAMP.log" +MIGRATION_AFTER_LOG="/tmp/dhv2-d538-migration-after-$STAMP.log" +HEALTH_FILE="/tmp/dhv2-d538-health-$STAMP.json" +MIN_FREE_KB=$((18 * 1024 * 1024)) + +cleanup() { + rm -rf "$STAGE" + rm -f "$MIGRATION_LOG" "$MIGRATION_AFTER_LOG" "$HEALTH_FILE" + docker image rm dhv2-api:d538-test dhv2-web:d538-test >/dev/null 2>&1 || true +} +trap cleanup EXIT +trap 'echo; echo "ERROR D5.3.8 en línea $LINENO: $BASH_COMMAND"' ERR + +cd "$ROOT" +test -f "$ZIP" + +echo "[1/11] Preflight y staging" +FREE_KB="$(df -Pk "$ROOT" | awk 'NR==2 {print $4}')" +if [[ ! "$FREE_KB" =~ ^[0-9]+$ ]] || (( FREE_KB < MIN_FREE_KB )); then + echo "ERROR: espacio libre insuficiente para staging + backup fuerte + build final." + echo "Libre: $(( FREE_KB / 1024 )) MB · requerido: $(( MIN_FREE_KB / 1024 )) MB" + echo "No se modificó producción. Revisá logs/caché antes de continuar." + exit 1 +fi +DB_HEALTH="$(docker inspect dhv2-db --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' 2>/dev/null || true)" +if [[ "$DB_HEALTH" != "healthy" ]]; then + echo "ERROR: PostgreSQL no está healthy (estado: $DB_HEALTH). No se inicia el deploy." + docker compose ps -a || true + docker logs --tail 80 dhv2-db || true + exit 1 +fi +docker compose exec -T db sh -lc 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' +mkdir -p "$STAGE" +unzip -q "$ZIP" -d "$STAGE" +[[ "$(node -p "require('$STAGE/api-v3/package.json').version")" == "0.19.3-8" ]] +[[ "$(node -p "require('$STAGE/web-v2/package.json').version")" == "0.19.3-8" ]] +grep -Fq "API_PHASE = 'D5.3.8'" "$STAGE/api-v3/src/version.ts" +grep -Fq 'INVENTARIO A REVISAR' "$STAGE/scripts/backup-inventario-a-revisar.sh" +echo "Espacio libre antes de builds: $(( FREE_KB / 1024 / 1024 )) GB" + +echo "[2/11] Compilando API en staging" +docker build --target builder -t dhv2-api:d538-test "$STAGE/api-v3" + +echo "[3/11] Ejecutando tests API en staging" +docker run --rm \ + -v "$STAGE/api-v3/test:/app/test:ro" \ + -v "$STAGE/api-v3/tsconfig.test.json:/app/tsconfig.test.json:ro" \ + dhv2-api:d538-test npm test + +echo "[4/11] Compilando web en staging" +docker build -t dhv2-web:d538-test "$STAGE/web-v2" + +FREE_AFTER_BUILD_KB="$(df -Pk "$ROOT" | awk 'NR==2 {print $4}')" +if [[ ! "$FREE_AFTER_BUILD_KB" =~ ^[0-9]+$ ]] || (( FREE_AFTER_BUILD_KB < 12 * 1024 * 1024 )); then + echo "ERROR: staging dejó menos de 12 GB libres para el backup fuerte. Producción no será modificada." + df -h "$ROOT" + exit 1 +fi +if [[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}' 2>/dev/null || true)" != "healthy" ]]; then + echo "ERROR: PostgreSQL dejó de estar healthy durante staging. Producción no será modificada." + docker logs --tail 80 dhv2-db || true + exit 1 +fi + +echo "[5/11] BACKUP FUERTE · INVENTARIO A REVISAR" +BACKUP_DIR="$STRONG_BACKUP" ROOT="$ROOT" bash "$STAGE/scripts/backup-inventario-a-revisar.sh" +test -s "$STRONG_BACKUP/database.dump" +test -s "$STRONG_BACKUP/source.tar.gz" +test -s "$STRONG_BACKUP/asset-media.tar.gz" +test -s "$STRONG_BACKUP/docker-images.tar.gz" +test -s "$STRONG_BACKUP/BACKUP_OK.txt" +grep -Fq 'INVENTARIO A REVISAR' "$STRONG_BACKUP/BACKUP_OK.txt" + +echo "[6/11] Instalando source D5.3.8" +unzip -oq "$ZIP" -d "$ROOT" +chmod +x \ + "$ROOT/scripts/deploy-phase-d5-3-8.sh" \ + "$ROOT/scripts/phase-d5-3-8-acceptance.sh" \ + "$ROOT/scripts/backup-inventario-a-revisar.sh" +echo "API source: $(node -p "require('./api-v3/package.json').version")" +echo "WEB source: $(node -p "require('./web-v2/package.json').version")" + +echo "[7/11] Construyendo imágenes finales" +docker compose build api migrate web + +echo "[8/11] Aplicando migración D5.3.8" +docker compose --profile tools run --rm migrate npm run migration:show 2>&1 | tee "$MIGRATION_LOG" +if grep -Fq 'Pending migrations: yes' "$MIGRATION_LOG"; then + docker compose --profile tools run --rm migrate npm run migration:run +fi +docker compose --profile tools run --rm migrate npm run migration:show 2>&1 | tee "$MIGRATION_AFTER_LOG" +grep -Fq 'Pending migrations: no' "$MIGRATION_AFTER_LOG" + +echo "[9/11] Recreando API y web" +docker compose up -d --force-recreate api web +for i in $(seq 1 40); do + if curl -fsS http://127.0.0.1:3101/api/v3/health >"$HEALTH_FILE" 2>/dev/null; then + break + fi + if [[ "$i" == "40" ]]; then + docker compose logs --tail=200 api + exit 1 + fi + sleep 2 +done +cat "$HEALTH_FILE"; echo +grep -Fq '"version":"0.19.3-8"' "$HEALTH_FILE" +grep -Fq '"phase":"D5.3.8"' "$HEALTH_FILE" +grep -Fq '"database":"ok"' "$HEALTH_FILE" + +echo "[10/11] Aceptación automática" +STRONG_BACKUP="$STRONG_BACKUP" bash "$ROOT/scripts/phase-d5-3-8-acceptance.sh" + +echo "[11/11] Backup POST" +mkdir -p "$POST_BACKUP" +chmod 700 "$POST_BACKUP" +docker compose exec -T db sh -lc 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > "$POST_BACKUP/database.dump" +docker compose ps -a > "$POST_BACKUP/docker-status.txt" +cp -a "$ZIP" "$POST_BACKUP/" +if [[ -f .env ]]; then + cp -a .env "$POST_BACKUP/.env" + chmod 600 "$POST_BACKUP/.env" +fi +sha256sum "$POST_BACKUP/$(basename "$ZIP")" > "$POST_BACKUP/SHA256SUMS.txt" + +cat > "$POST_BACKUP/INVENTARIO_A_REVISAR_REFERENCE.txt" </dev/null 2>&1 || true +} +trap cleanup EXIT +trap 'echo; echo "ERROR D5.3.9 en línea $LINENO: $BASH_COMMAND"' ERR + +cd "$ROOT" +test -f "$ZIP" + +echo "[1/10] Preflight y staging" +FREE_KB="$(df -Pk "$ROOT" | awk 'NR==2 {print $4}')" +if [[ ! "$FREE_KB" =~ ^[0-9]+$ ]] || (( FREE_KB < MIN_FREE_KB )); then + echo "ERROR: espacio libre insuficiente. Libre: $(( FREE_KB / 1024 )) MB" + exit 1 +fi +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}' 2>/dev/null || true)" == "healthy" ]] +docker compose exec -T db sh -lc 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' +mkdir -p "$STAGE" +unzip -q "$ZIP" -d "$STAGE" +[[ "$(node -p "require('$STAGE/api-v3/package.json').version")" == "0.19.3-9" ]] +[[ "$(node -p "require('$STAGE/web-v2/package.json').version")" == "0.19.3-9" ]] +grep -Fq "API_PHASE = 'D5.3.9'" "$STAGE/api-v3/src/version.ts" + +echo "[2/10] Compilando API en staging" +docker build --target builder -t dhv2-api:d539-test "$STAGE/api-v3" + +echo "[3/10] Ejecutando tests API en staging" +docker run --rm \ + -v "$STAGE/api-v3/test:/app/test:ro" \ + -v "$STAGE/api-v3/tsconfig.test.json:/app/tsconfig.test.json:ro" \ + dhv2-api:d539-test npm test + +echo "[4/10] Compilando web en staging" +docker build -t dhv2-web:d539-test "$STAGE/web-v2" + +echo "[5/10] Backup PRE D5.3.9" +mkdir -p "$PRE_BACKUP" +chmod 700 "$PRE_BACKUP" +docker compose exec -T db sh -lc 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > "$PRE_BACKUP/database.dump" +tar -czf "$PRE_BACKUP/source.tar.gz" \ + --exclude='api-v3/node_modules' --exclude='api-v3/dist' \ + --exclude='web-v2/node_modules' --exclude='web-v2/dist' \ + api-v3 web-v2 scripts docs docker-compose.yml .env.example .gitignore +if [[ -f .env ]]; then cp -a .env "$PRE_BACKUP/.env"; chmod 600 "$PRE_BACKUP/.env"; fi +STRONG_BACKUP="$(find "$BACKUP_ROOT" -maxdepth 1 -type d -name 'INVENTARIO_A_REVISAR_*' -printf '%T@ %p\n' | sort -nr | head -n1 | cut -d' ' -f2-)" +printf 'Backup fuerte conceptual previo: %s\nPalabra clave: INVENTARIO A REVISAR\n' "$STRONG_BACKUP" > "$PRE_BACKUP/INVENTARIO_A_REVISAR_REFERENCE.txt" +( + cd "$PRE_BACKUP" + sha256sum database.dump source.tar.gz INVENTARIO_A_REVISAR_REFERENCE.txt > SHA256SUMS.txt + [[ ! -f .env ]] || sha256sum .env >> SHA256SUMS.txt + sha256sum -c SHA256SUMS.txt +) + +echo "[6/10] Instalando source D5.3.9" +unzip -oq "$ZIP" -d "$ROOT" +chmod +x "$ROOT/scripts/deploy-phase-d5-3-9.sh" "$ROOT/scripts/phase-d5-3-9-acceptance.sh" +echo "API source: $(node -p "require('./api-v3/package.json').version")" +echo "WEB source: $(node -p "require('./web-v2/package.json').version")" + +echo "[7/10] Construyendo imágenes finales" +docker compose build api migrate web + +echo "[8/10] Verificando que D5.3.9 no tenga migraciones pendientes" +docker compose --profile tools run --rm migrate npm run migration:show 2>&1 | tee "$MIGRATION_LOG" +grep -Fq 'Pending migrations: no' "$MIGRATION_LOG" + +echo "[9/10] Recreando API/web y aceptación" +docker compose up -d --force-recreate api web +for i in $(seq 1 40); do + if curl -fsS http://127.0.0.1:3101/api/v3/health >"$HEALTH_FILE" 2>/dev/null; then break; fi + if [[ "$i" == "40" ]]; then docker compose logs --tail=200 api; exit 1; fi + sleep 2 +done +cat "$HEALTH_FILE"; echo +grep -Fq '"version":"0.19.3-9"' "$HEALTH_FILE" +grep -Fq '"phase":"D5.3.9"' "$HEALTH_FILE" +grep -Fq '"database":"ok"' "$HEALTH_FILE" +bash "$ROOT/scripts/phase-d5-3-9-acceptance.sh" + +echo "[10/10] Backup POST" +mkdir -p "$POST_BACKUP" +chmod 700 "$POST_BACKUP" +docker compose exec -T db sh -lc 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > "$POST_BACKUP/database.dump" +docker compose ps -a > "$POST_BACKUP/docker-status.txt" +cp -a "$ZIP" "$POST_BACKUP/" +if [[ -f .env ]]; then cp -a .env "$POST_BACKUP/.env"; chmod 600 "$POST_BACKUP/.env"; fi +printf 'PRE D5.3.9: %s\nINVENTARIO A REVISAR: %s\n' "$PRE_BACKUP" "$STRONG_BACKUP" > "$POST_BACKUP/BACKUP_REFERENCES.txt" +( + cd "$POST_BACKUP" + sha256sum database.dump "$(basename "$ZIP")" BACKUP_REFERENCES.txt > SHA256SUMS.txt + [[ ! -f .env ]] || sha256sum .env >> SHA256SUMS.txt +) + +echo +echo "D5.3.9 desplegada correctamente" +echo "Backup PRE: $PRE_BACKUP" +echo "Backup fuerte INVENTARIO A REVISAR: $STRONG_BACKUP" +echo "Backup POST: $POST_BACKUP" diff --git a/scripts/deploy-phase-d5-4-1.sh b/scripts/deploy-phase-d5-4-1.sh new file mode 100755 index 0000000..cf02587 --- /dev/null +++ b/scripts/deploy-phase-d5-4-1.sh @@ -0,0 +1,125 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT="${ROOT:-/var/www/dhv2.korexlabs.com}" +ZIP="${ZIP:-$ROOT/DH_V2_FASE_D5_4_1_SOURCE.zip}" +STAMP="$(date +%Y%m%d_%H%M%S)" +STAGE="/root/dhv2-d541-stage-$STAMP" +BACKUP_ROOT="/root/DH_V2_BACKUPS" +PRE_BACKUP="$BACKUP_ROOT/D5_4_1_PRE_$STAMP" +POST_BACKUP="$BACKUP_ROOT/D5_4_1_OK_$STAMP" +MIGRATION_LOG="/tmp/dhv2-d541-migration-$STAMP.log" +TEST_LOG="/tmp/dhv2-d541-tests-$STAMP.log" +HEALTH_FILE="/tmp/dhv2-d541-health-$STAMP.json" +MIN_FREE_KB=$((4 * 1024 * 1024)) + +cleanup() { + rm -rf "$STAGE" + rm -f "$MIGRATION_LOG" "$TEST_LOG" "$HEALTH_FILE" + docker image rm dhv2-api:d541-test dhv2-web:d541-test >/dev/null 2>&1 || true +} +trap cleanup EXIT +trap 'echo; echo "ERROR D5.4.1 en línea $LINENO: $BASH_COMMAND"' ERR + +cd "$ROOT" +test -f "$ZIP" + +CURRENT_API="$(node -p "require('./api-v3/package.json').version")" +CURRENT_WEB="$(node -p "require('./web-v2/package.json').version")" +[[ "$CURRENT_API" == "0.19.3-25" ]] +[[ "$CURRENT_WEB" == "0.19.3-25" ]] + +echo "[1/10] Preflight y staging" +FREE_KB="$(df -Pk "$ROOT" | awk 'NR==2 {print $4}')" +if [[ ! "$FREE_KB" =~ ^[0-9]+$ ]] || (( FREE_KB < MIN_FREE_KB )); then + echo "ERROR: espacio libre insuficiente. Libre: $(( FREE_KB / 1024 )) MB" + exit 1 +fi +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}' 2>/dev/null || true)" == "healthy" ]] +docker compose exec -T db sh -lc 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' +mkdir -p "$STAGE" +unzip -q "$ZIP" -d "$STAGE" +[[ "$(node -p "require('$STAGE/api-v3/package.json').version")" == "0.19.4-1" ]] +[[ "$(node -p "require('$STAGE/web-v2/package.json').version")" == "0.19.4-1" ]] +grep -Fq "API_PHASE = 'D5.4.1'" "$STAGE/api-v3/src/version.ts" +grep -Fq "APP_PHASE = 'Fase D5.4.1 · Hallazgos aplicables al Inventario'" "$STAGE/web-v2/src/config/version.ts" + +echo "[2/10] Compilando API en staging" +docker build --target builder -t dhv2-api:d541-test "$STAGE/api-v3" + +echo "[3/10] Ejecutando tests API en staging" +docker run --rm \ + -v "$STAGE/api-v3/test:/app/test:ro" \ + -v "$STAGE/api-v3/tsconfig.test.json:/app/tsconfig.test.json:ro" \ + dhv2-api:d541-test npm test 2>&1 | tee "$TEST_LOG" +grep -Eq 'tests[[:space:]]+246' "$TEST_LOG" +grep -Eq 'pass[[:space:]]+246' "$TEST_LOG" +grep -Eq 'fail[[:space:]]+0' "$TEST_LOG" + +echo "[4/10] Compilando web en staging" +docker build -t dhv2-web:d541-test "$STAGE/web-v2" + +echo "[5/10] Backup PRE D5.4.1" +mkdir -p "$PRE_BACKUP" +chmod 700 "$PRE_BACKUP" +docker compose exec -T db sh -lc 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > "$PRE_BACKUP/database.dump" +tar -czf "$PRE_BACKUP/source.tar.gz" \ + --exclude='api-v3/node_modules' --exclude='api-v3/dist' \ + --exclude='web-v2/node_modules' --exclude='web-v2/dist' \ + api-v3 web-v2 scripts docs docker-compose.yml .env.example .gitignore +if [[ -f .env ]]; then cp -a .env "$PRE_BACKUP/.env"; chmod 600 "$PRE_BACKUP/.env"; fi +STRONG_BACKUP="$(find "$BACKUP_ROOT" -maxdepth 1 -type d -name 'INVENTARIO_A_REVISAR_*' -printf '%T@ %p\n' | sort -nr | head -n1 | cut -d' ' -f2-)" +printf 'Backup fuerte conceptual previo: %s\nPalabra clave: INVENTARIO A REVISAR\n' "$STRONG_BACKUP" > "$PRE_BACKUP/INVENTARIO_A_REVISAR_REFERENCE.txt" +( + cd "$PRE_BACKUP" + sha256sum database.dump source.tar.gz INVENTARIO_A_REVISAR_REFERENCE.txt > SHA256SUMS.txt + [[ ! -f .env ]] || sha256sum .env >> SHA256SUMS.txt + sha256sum -c SHA256SUMS.txt +) + +echo "[6/10] Instalando source D5.4.1" +unzip -oq "$ZIP" -d "$ROOT" +chmod +x "$ROOT/scripts/deploy-phase-d5-4-1.sh" "$ROOT/scripts/phase-d5-4-1-acceptance.sh" +echo "API source: $(node -p "require('./api-v3/package.json').version")" +echo "WEB source: $(node -p "require('./web-v2/package.json').version")" + +echo "[7/10] Construyendo imágenes finales" +docker compose build api migrate web + +echo "[8/10] Aplicando migración D5.4" +docker compose --profile tools run --rm migrate npm run migration:run +docker compose --profile tools run --rm migrate npm run migration:show 2>&1 | tee "$MIGRATION_LOG" +grep -Fq 'Pending migrations: no' "$MIGRATION_LOG" + +echo "[9/10] Recreando API/web y aceptación" +docker compose up -d --force-recreate api web +for i in $(seq 1 40); do + if curl -fsS http://127.0.0.1:3101/api/v3/health >"$HEALTH_FILE" 2>/dev/null; then break; fi + if [[ "$i" == "40" ]]; then docker compose logs --tail=200 api; exit 1; fi + sleep 2 +done +cat "$HEALTH_FILE"; echo +grep -Fq '"version":"0.19.4-1"' "$HEALTH_FILE" +grep -Fq '"phase":"D5.4.1"' "$HEALTH_FILE" +grep -Fq '"database":"ok"' "$HEALTH_FILE" +bash "$ROOT/scripts/phase-d5-4-1-acceptance.sh" + +echo "[10/10] Backup POST" +mkdir -p "$POST_BACKUP" +chmod 700 "$POST_BACKUP" +docker compose exec -T db sh -lc 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > "$POST_BACKUP/database.dump" +docker compose ps -a > "$POST_BACKUP/docker-status.txt" +cp -a "$ZIP" "$POST_BACKUP/" +if [[ -f .env ]]; then cp -a .env "$POST_BACKUP/.env"; chmod 600 "$POST_BACKUP/.env"; fi +printf 'PRE D5.4.1: %s\nINVENTARIO A REVISAR: %s\n' "$PRE_BACKUP" "$STRONG_BACKUP" > "$POST_BACKUP/BACKUP_REFERENCES.txt" +( + cd "$POST_BACKUP" + sha256sum database.dump "$(basename "$ZIP")" BACKUP_REFERENCES.txt > SHA256SUMS.txt + [[ ! -f .env ]] || sha256sum .env >> SHA256SUMS.txt +) + +echo +echo "D5.4.1 desplegada correctamente" +echo "Backup PRE: $PRE_BACKUP" +echo "Backup fuerte INVENTARIO A REVISAR: $STRONG_BACKUP" +echo "Backup POST: $POST_BACKUP" diff --git a/scripts/deploy-phase-d5-5-1.sh b/scripts/deploy-phase-d5-5-1.sh new file mode 100755 index 0000000..8d07f37 --- /dev/null +++ b/scripts/deploy-phase-d5-5-1.sh @@ -0,0 +1,134 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT="${ROOT:-/var/www/dhv2.korexlabs.com}" +ZIP="${ZIP:-$ROOT/DH_V2_FASE_D5_5_1_SOURCE.zip}" +STAMP="$(date +%Y%m%d_%H%M%S)" +STAGE="/root/dhv2-d551-stage-$STAMP" +BACKUP_ROOT="/root/DH_V2_BACKUPS" +PRE_BACKUP="$BACKUP_ROOT/D5_5_1_PRE_$STAMP" +POST_BACKUP="$BACKUP_ROOT/D5_5_1_OK_$STAMP" +MIGRATION_LOG="/tmp/dhv2-d551-migration-$STAMP.log" +TEST_LOG="/tmp/dhv2-d551-tests-$STAMP.log" +HEALTH_FILE="/tmp/dhv2-d551-health-$STAMP.json" +MIN_FREE_KB=$((4 * 1024 * 1024)) + +cleanup() { + rm -rf "$STAGE" + rm -f "$MIGRATION_LOG" "$TEST_LOG" "$HEALTH_FILE" + docker image rm dhv2-api:d551-test dhv2-web:d551-test >/dev/null 2>&1 || true +} +trap cleanup EXIT +trap 'echo; echo "ERROR D5.5.1 en línea $LINENO: $BASH_COMMAND"' ERR + +cd "$ROOT" +test -f "$ZIP" + +CURRENT_API="$(node -p "require('./api-v3/package.json').version")" +CURRENT_WEB="$(node -p "require('./web-v2/package.json').version")" +[[ "$CURRENT_API" == "0.19.4-1" ]] +[[ "$CURRENT_WEB" == "0.19.4-1" ]] + +echo "[1/10] Preflight y staging" +FREE_KB="$(df -Pk "$ROOT" | awk 'NR==2 {print $4}')" +if [[ ! "$FREE_KB" =~ ^[0-9]+$ ]] || (( FREE_KB < MIN_FREE_KB )); then + echo "ERROR: espacio libre insuficiente. Libre: $(( FREE_KB / 1024 )) MB" + exit 1 +fi +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}' 2>/dev/null || true)" == "healthy" ]] +docker compose exec -T db sh -lc 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' +mkdir -p "$STAGE" +unzip -q "$ZIP" -d "$STAGE" +[[ "$(node -p "require('$STAGE/api-v3/package.json').version")" == "0.19.5-1" ]] +[[ "$(node -p "require('$STAGE/web-v2/package.json').version")" == "0.19.5-1" ]] +grep -Fq "API_PHASE = 'D5.5.1'" "$STAGE/api-v3/src/version.ts" +grep -Fq "APP_PHASE = 'Fase D5.5.1 · Corrección de staging de planificación'" "$STAGE/web-v2/src/config/version.ts" + +echo "[2/10] Compilando API en staging" +docker build --target builder -t dhv2-api:d551-test "$STAGE/api-v3" + +echo "[3/10] Ejecutando tests API en staging" +docker run --rm \ + -v "$STAGE/api-v3/test:/app/test:ro" \ + -v "$STAGE/api-v3/tsconfig.test.json:/app/tsconfig.test.json:ro" \ + dhv2-api:d551-test npm test 2>&1 | tee "$TEST_LOG" +grep -Eq 'tests[[:space:]]+252' "$TEST_LOG" +grep -Eq 'pass[[:space:]]+252' "$TEST_LOG" +grep -Eq 'fail[[:space:]]+0' "$TEST_LOG" + +echo "[4/10] Compilando web en staging" +docker build -t dhv2-web:d551-test "$STAGE/web-v2" + +grep -Fq 'Área → Operadora → fecha' "$STAGE/web-v2/src/pages/InspectionVisitEditorPage.tsx" +grep -Fq 'CHECKLIST AUTOMÁTICO' "$STAGE/web-v2/src/pages/InspectionVisitEditorPage.tsx" +grep -Fq 'Agregar registro sin pendiente previo' "$STAGE/web-v2/src/pages/InspectionVisitEditorPage.tsx" +grep -Fq 'Motivo de exclusión' "$STAGE/web-v2/src/pages/InspectionVisitEditorPage.tsx" +grep -Fq 'debe iniciar la visita desde la APK' "$STAGE/web-v2/src/pages/InspectionVisitEditorPage.tsx" +if grep -Fq 'startInspectionVisit' "$STAGE/web-v2/src/pages/InspectionVisitEditorPage.tsx"; then exit 1; fi +grep -Fq 'generateInspectionVisitChecklist' "$STAGE/web-v2/src/lib/api.ts" +grep -Fq 'excludeInspectionVisitAsset' "$STAGE/web-v2/src/lib/api.ts" + +echo "[5/10] Backup PRE D5.5.1" +mkdir -p "$PRE_BACKUP" +chmod 700 "$PRE_BACKUP" +docker compose exec -T db sh -lc 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > "$PRE_BACKUP/database.dump" +tar -czf "$PRE_BACKUP/source.tar.gz" \ + --exclude='api-v3/node_modules' --exclude='api-v3/dist' \ + --exclude='web-v2/node_modules' --exclude='web-v2/dist' \ + api-v3 web-v2 scripts docs docker-compose.yml .env.example .gitignore README.md +if [[ -f .env ]]; then cp -a .env "$PRE_BACKUP/.env"; chmod 600 "$PRE_BACKUP/.env"; fi +STRONG_BACKUP="$(find "$BACKUP_ROOT" -maxdepth 1 -type d -name 'INVENTARIO_A_REVISAR_*' -printf '%T@ %p\n' | sort -nr | head -n1 | cut -d' ' -f2-)" +printf 'Backup fuerte conceptual previo: %s\nPalabra clave: INVENTARIO A REVISAR\n' "$STRONG_BACKUP" > "$PRE_BACKUP/INVENTARIO_A_REVISAR_REFERENCE.txt" +( + cd "$PRE_BACKUP" + sha256sum database.dump source.tar.gz INVENTARIO_A_REVISAR_REFERENCE.txt > SHA256SUMS.txt + [[ ! -f .env ]] || sha256sum .env >> SHA256SUMS.txt + sha256sum -c SHA256SUMS.txt +) + +echo "[6/10] Instalando source D5.5.1" +unzip -oq "$ZIP" -d "$ROOT" +chmod +x "$ROOT/scripts/deploy-phase-d5-5-1.sh" "$ROOT/scripts/phase-d5-5-1-acceptance.sh" +echo "API source: $(node -p "require('./api-v3/package.json').version")" +echo "WEB source: $(node -p "require('./web-v2/package.json').version")" + +echo "[7/10] Construyendo imágenes finales" +docker compose build api migrate web + +echo "[8/10] Aplicando migración D5.5" +docker compose --profile tools run --rm migrate npm run migration:run +docker compose --profile tools run --rm migrate npm run migration:show 2>&1 | tee "$MIGRATION_LOG" +grep -Fq 'Pending migrations: no' "$MIGRATION_LOG" + +echo "[9/10] Recreando API/web y aceptación" +docker compose up -d --force-recreate api web +for i in $(seq 1 40); do + if curl -fsS http://127.0.0.1:3101/api/v3/health >"$HEALTH_FILE" 2>/dev/null; then break; fi + if [[ "$i" == "40" ]]; then docker compose logs --tail=200 api; exit 1; fi + sleep 2 +done +cat "$HEALTH_FILE"; echo +grep -Fq '"version":"0.19.5-1"' "$HEALTH_FILE" +grep -Fq '"phase":"D5.5.1"' "$HEALTH_FILE" +grep -Fq '"database":"ok"' "$HEALTH_FILE" +bash "$ROOT/scripts/phase-d5-5-1-acceptance.sh" + +echo "[10/10] Backup POST" +mkdir -p "$POST_BACKUP" +chmod 700 "$POST_BACKUP" +docker compose exec -T db sh -lc 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > "$POST_BACKUP/database.dump" +docker compose ps -a > "$POST_BACKUP/docker-status.txt" +cp -a "$ZIP" "$POST_BACKUP/" +if [[ -f .env ]]; then cp -a .env "$POST_BACKUP/.env"; chmod 600 "$POST_BACKUP/.env"; fi +printf 'PRE D5.5.1: %s\nINVENTARIO A REVISAR: %s\n' "$PRE_BACKUP" "$STRONG_BACKUP" > "$POST_BACKUP/BACKUP_REFERENCES.txt" +( + cd "$POST_BACKUP" + sha256sum database.dump "$(basename "$ZIP")" BACKUP_REFERENCES.txt > SHA256SUMS.txt + [[ ! -f .env ]] || sha256sum .env >> SHA256SUMS.txt +) + +echo +echo "D5.5.1 desplegada correctamente" +echo "Backup PRE: $PRE_BACKUP" +echo "Backup fuerte INVENTARIO A REVISAR: $STRONG_BACKUP" +echo "Backup POST: $POST_BACKUP" diff --git a/scripts/deploy-phase-d5-5-2.sh b/scripts/deploy-phase-d5-5-2.sh new file mode 100755 index 0000000..7876d3a --- /dev/null +++ b/scripts/deploy-phase-d5-5-2.sh @@ -0,0 +1,132 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT="${ROOT:-/var/www/dhv2.korexlabs.com}" +ZIP="${ZIP:-$ROOT/DH_V2_FASE_D5_5_2_SOURCE.zip}" +STAMP="$(date +%Y%m%d_%H%M%S)" +STAGE="/root/dhv2-d552-stage-$STAMP" +BACKUP_ROOT="/root/DH_V2_BACKUPS" +PRE_BACKUP="$BACKUP_ROOT/D5_5_2_PRE_$STAMP" +POST_BACKUP="$BACKUP_ROOT/D5_5_2_OK_$STAMP" +MIGRATION_LOG="/tmp/dhv2-d552-migration-$STAMP.log" +TEST_LOG="/tmp/dhv2-d552-tests-$STAMP.log" +HEALTH_FILE="/tmp/dhv2-d552-health-$STAMP.json" +MIN_FREE_KB=$((4 * 1024 * 1024)) + +cleanup() { + rm -rf "$STAGE" + rm -f "$MIGRATION_LOG" "$TEST_LOG" "$HEALTH_FILE" + docker image rm dhv2-api:d552-test dhv2-web:d552-test >/dev/null 2>&1 || true +} +trap cleanup EXIT +trap 'echo; echo "ERROR D5.5.2 en línea $LINENO: $BASH_COMMAND"' ERR + +cd "$ROOT" +test -f "$ZIP" + +CURRENT_API="$(node -p "require('./api-v3/package.json').version")" +CURRENT_WEB="$(node -p "require('./web-v2/package.json').version")" +[[ "$CURRENT_API" == "0.19.5-1" ]] +[[ "$CURRENT_WEB" == "0.19.5-1" ]] + +echo "[1/10] Preflight y staging" +FREE_KB="$(df -Pk "$ROOT" | awk 'NR==2 {print $4}')" +if [[ ! "$FREE_KB" =~ ^[0-9]+$ ]] || (( FREE_KB < MIN_FREE_KB )); then + echo "ERROR: espacio libre insuficiente. Libre: $(( FREE_KB / 1024 )) MB" + exit 1 +fi +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}' 2>/dev/null || true)" == "healthy" ]] +docker compose exec -T db sh -lc 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' +mkdir -p "$STAGE" +unzip -q "$ZIP" -d "$STAGE" +[[ "$(node -p "require('$STAGE/api-v3/package.json').version")" == "0.19.5-2" ]] +[[ "$(node -p "require('$STAGE/web-v2/package.json').version")" == "0.19.5-2" ]] +grep -Fq "API_PHASE = 'D5.5.2'" "$STAGE/api-v3/src/version.ts" +grep -Fq "APP_PHASE = 'Fase D5.5.2 · Planificación web y checklist'" "$STAGE/web-v2/src/config/version.ts" + +echo "[2/10] Compilando API en staging" +docker build --target builder -t dhv2-api:d552-test "$STAGE/api-v3" + +echo "[3/10] Ejecutando tests API en staging" +docker run --rm \ + -v "$STAGE/api-v3/test:/app/test:ro" \ + -v "$STAGE/api-v3/tsconfig.test.json:/app/tsconfig.test.json:ro" \ + dhv2-api:d552-test npm test 2>&1 | tee "$TEST_LOG" +grep -Eq 'tests[[:space:]]+252' "$TEST_LOG" +grep -Eq 'pass[[:space:]]+252' "$TEST_LOG" +grep -Eq 'fail[[:space:]]+0' "$TEST_LOG" + +echo "[4/10] Compilando web en staging" +docker build -t dhv2-web:d552-test "$STAGE/web-v2" +grep -Fq 'Área → Operadora → fecha' "$STAGE/web-v2/src/pages/InspectionVisitEditorPage.tsx" +grep -Fq 'CHECKLIST AUTOMÁTICO' "$STAGE/web-v2/src/pages/InspectionVisitEditorPage.tsx" +grep -Fq 'Agregar registro sin pendiente previo' "$STAGE/web-v2/src/pages/InspectionVisitEditorPage.tsx" +grep -Fq 'Motivo de exclusión' "$STAGE/web-v2/src/pages/InspectionVisitEditorPage.tsx" +grep -Fq 'debe iniciar la visita desde la APK' "$STAGE/web-v2/src/pages/InspectionVisitEditorPage.tsx" +if grep -Fq 'startInspectionVisit' "$STAGE/web-v2/src/pages/InspectionVisitEditorPage.tsx"; then exit 1; fi +grep -Fq 'generateInspectionVisitChecklist' "$STAGE/web-v2/src/lib/api.ts" +grep -Fq 'excludeInspectionVisitAsset' "$STAGE/web-v2/src/lib/api.ts" + +echo "[5/10] Backup PRE D5.5.2" +mkdir -p "$PRE_BACKUP" +chmod 700 "$PRE_BACKUP" +docker compose exec -T db sh -lc 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > "$PRE_BACKUP/database.dump" +tar -czf "$PRE_BACKUP/source.tar.gz" \ + --exclude='api-v3/node_modules' --exclude='api-v3/dist' \ + --exclude='web-v2/node_modules' --exclude='web-v2/dist' \ + api-v3 web-v2 scripts docs docker-compose.yml .env.example .gitignore README.md +if [[ -f .env ]]; then cp -a .env "$PRE_BACKUP/.env"; chmod 600 "$PRE_BACKUP/.env"; fi +STRONG_BACKUP="$(find "$BACKUP_ROOT" -maxdepth 1 -type d -name 'INVENTARIO_A_REVISAR_*' -printf '%T@ %p\n' | sort -nr | head -n1 | cut -d' ' -f2-)" +printf 'Backup fuerte conceptual previo: %s\nPalabra clave: INVENTARIO A REVISAR\n' "$STRONG_BACKUP" > "$PRE_BACKUP/INVENTARIO_A_REVISAR_REFERENCE.txt" +( + cd "$PRE_BACKUP" + sha256sum database.dump source.tar.gz INVENTARIO_A_REVISAR_REFERENCE.txt > SHA256SUMS.txt + [[ ! -f .env ]] || sha256sum .env >> SHA256SUMS.txt + sha256sum -c SHA256SUMS.txt +) + +echo "[6/10] Instalando source D5.5.2" +unzip -oq "$ZIP" -d "$ROOT" +chmod +x "$ROOT/scripts/deploy-phase-d5-5-2.sh" "$ROOT/scripts/phase-d5-5-2-acceptance.sh" +echo "API source: $(node -p "require('./api-v3/package.json').version")" +echo "WEB source: $(node -p "require('./web-v2/package.json').version")" + +echo "[7/10] Construyendo imágenes finales" +docker compose build api migrate web + +echo "[8/10] Verificando migración D5.5 ya aplicada" +docker compose --profile tools run --rm migrate npm run migration:show 2>&1 | tee "$MIGRATION_LOG" +grep -Fq 'Pending migrations: no' "$MIGRATION_LOG" + +echo "[9/10] Recreando API/web y aceptación" +docker compose up -d --force-recreate api web +for i in $(seq 1 40); do + if curl -fsS http://127.0.0.1:3101/api/v3/health >"$HEALTH_FILE" 2>/dev/null; then break; fi + if [[ "$i" == "40" ]]; then docker compose logs --tail=200 api; exit 1; fi + sleep 2 +done +cat "$HEALTH_FILE"; echo +grep -Fq '"version":"0.19.5-2"' "$HEALTH_FILE" +grep -Fq '"phase":"D5.5.2"' "$HEALTH_FILE" +grep -Fq '"database":"ok"' "$HEALTH_FILE" +bash "$ROOT/scripts/phase-d5-5-2-acceptance.sh" + +echo "[10/10] Backup POST" +mkdir -p "$POST_BACKUP" +chmod 700 "$POST_BACKUP" +docker compose exec -T db sh -lc 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > "$POST_BACKUP/database.dump" +docker compose ps -a > "$POST_BACKUP/docker-status.txt" +cp -a "$ZIP" "$POST_BACKUP/" +if [[ -f .env ]]; then cp -a .env "$POST_BACKUP/.env"; chmod 600 "$POST_BACKUP/.env"; fi +printf 'PRE D5.5.2: %s\nINVENTARIO A REVISAR: %s\n' "$PRE_BACKUP" "$STRONG_BACKUP" > "$POST_BACKUP/BACKUP_REFERENCES.txt" +( + cd "$POST_BACKUP" + sha256sum database.dump "$(basename "$ZIP")" BACKUP_REFERENCES.txt > SHA256SUMS.txt + [[ ! -f .env ]] || sha256sum .env >> SHA256SUMS.txt +) + +echo +echo "D5.5.2 desplegada correctamente" +echo "Backup PRE: $PRE_BACKUP" +echo "Backup fuerte INVENTARIO A REVISAR: $STRONG_BACKUP" +echo "Backup POST: $POST_BACKUP" diff --git a/scripts/deploy-phase-d5-5.sh b/scripts/deploy-phase-d5-5.sh new file mode 100755 index 0000000..2d9ba71 --- /dev/null +++ b/scripts/deploy-phase-d5-5.sh @@ -0,0 +1,125 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT="${ROOT:-/var/www/dhv2.korexlabs.com}" +ZIP="${ZIP:-$ROOT/DH_V2_FASE_D5_5_SOURCE.zip}" +STAMP="$(date +%Y%m%d_%H%M%S)" +STAGE="/root/dhv2-d55-stage-$STAMP" +BACKUP_ROOT="/root/DH_V2_BACKUPS" +PRE_BACKUP="$BACKUP_ROOT/D5_5_PRE_$STAMP" +POST_BACKUP="$BACKUP_ROOT/D5_5_OK_$STAMP" +MIGRATION_LOG="/tmp/dhv2-d55-migration-$STAMP.log" +TEST_LOG="/tmp/dhv2-d55-tests-$STAMP.log" +HEALTH_FILE="/tmp/dhv2-d55-health-$STAMP.json" +MIN_FREE_KB=$((4 * 1024 * 1024)) + +cleanup() { + rm -rf "$STAGE" + rm -f "$MIGRATION_LOG" "$TEST_LOG" "$HEALTH_FILE" + docker image rm dhv2-api:d55-test dhv2-web:d55-test >/dev/null 2>&1 || true +} +trap cleanup EXIT +trap 'echo; echo "ERROR D5.5 en línea $LINENO: $BASH_COMMAND"' ERR + +cd "$ROOT" +test -f "$ZIP" + +CURRENT_API="$(node -p "require('./api-v3/package.json').version")" +CURRENT_WEB="$(node -p "require('./web-v2/package.json').version")" +[[ "$CURRENT_API" == "0.19.4-1" ]] +[[ "$CURRENT_WEB" == "0.19.4-1" ]] + +echo "[1/10] Preflight y staging" +FREE_KB="$(df -Pk "$ROOT" | awk 'NR==2 {print $4}')" +if [[ ! "$FREE_KB" =~ ^[0-9]+$ ]] || (( FREE_KB < MIN_FREE_KB )); then + echo "ERROR: espacio libre insuficiente. Libre: $(( FREE_KB / 1024 )) MB" + exit 1 +fi +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}' 2>/dev/null || true)" == "healthy" ]] +docker compose exec -T db sh -lc 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' +mkdir -p "$STAGE" +unzip -q "$ZIP" -d "$STAGE" +[[ "$(node -p "require('$STAGE/api-v3/package.json').version")" == "0.19.5" ]] +[[ "$(node -p "require('$STAGE/web-v2/package.json').version")" == "0.19.5" ]] +grep -Fq "API_PHASE = 'D5.5'" "$STAGE/api-v3/src/version.ts" +grep -Fq "APP_PHASE = 'Fase D5.5 · Planificación web y checklist'" "$STAGE/web-v2/src/config/version.ts" + +echo "[2/10] Compilando API en staging" +docker build --target builder -t dhv2-api:d55-test "$STAGE/api-v3" + +echo "[3/10] Ejecutando tests API en staging" +docker run --rm \ + -v "$STAGE/api-v3/test:/app/test:ro" \ + -v "$STAGE/api-v3/tsconfig.test.json:/app/tsconfig.test.json:ro" \ + dhv2-api:d55-test npm test 2>&1 | tee "$TEST_LOG" +grep -Eq 'tests[[:space:]]+252' "$TEST_LOG" +grep -Eq 'pass[[:space:]]+252' "$TEST_LOG" +grep -Eq 'fail[[:space:]]+0' "$TEST_LOG" + +echo "[4/10] Compilando web en staging" +docker build -t dhv2-web:d55-test "$STAGE/web-v2" + +echo "[5/10] Backup PRE D5.5" +mkdir -p "$PRE_BACKUP" +chmod 700 "$PRE_BACKUP" +docker compose exec -T db sh -lc 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > "$PRE_BACKUP/database.dump" +tar -czf "$PRE_BACKUP/source.tar.gz" \ + --exclude='api-v3/node_modules' --exclude='api-v3/dist' \ + --exclude='web-v2/node_modules' --exclude='web-v2/dist' \ + api-v3 web-v2 scripts docs docker-compose.yml .env.example .gitignore README.md +if [[ -f .env ]]; then cp -a .env "$PRE_BACKUP/.env"; chmod 600 "$PRE_BACKUP/.env"; fi +STRONG_BACKUP="$(find "$BACKUP_ROOT" -maxdepth 1 -type d -name 'INVENTARIO_A_REVISAR_*' -printf '%T@ %p\n' | sort -nr | head -n1 | cut -d' ' -f2-)" +printf 'Backup fuerte conceptual previo: %s\nPalabra clave: INVENTARIO A REVISAR\n' "$STRONG_BACKUP" > "$PRE_BACKUP/INVENTARIO_A_REVISAR_REFERENCE.txt" +( + cd "$PRE_BACKUP" + sha256sum database.dump source.tar.gz INVENTARIO_A_REVISAR_REFERENCE.txt > SHA256SUMS.txt + [[ ! -f .env ]] || sha256sum .env >> SHA256SUMS.txt + sha256sum -c SHA256SUMS.txt +) + +echo "[6/10] Instalando source D5.5" +unzip -oq "$ZIP" -d "$ROOT" +chmod +x "$ROOT/scripts/deploy-phase-d5-5.sh" "$ROOT/scripts/phase-d5-5-acceptance.sh" +echo "API source: $(node -p "require('./api-v3/package.json').version")" +echo "WEB source: $(node -p "require('./web-v2/package.json').version")" + +echo "[7/10] Construyendo imágenes finales" +docker compose build api migrate web + +echo "[8/10] Aplicando migración D5.5" +docker compose --profile tools run --rm migrate npm run migration:run +docker compose --profile tools run --rm migrate npm run migration:show 2>&1 | tee "$MIGRATION_LOG" +grep -Fq 'Pending migrations: no' "$MIGRATION_LOG" + +echo "[9/10] Recreando API/web y aceptación" +docker compose up -d --force-recreate api web +for i in $(seq 1 40); do + if curl -fsS http://127.0.0.1:3101/api/v3/health >"$HEALTH_FILE" 2>/dev/null; then break; fi + if [[ "$i" == "40" ]]; then docker compose logs --tail=200 api; exit 1; fi + sleep 2 +done +cat "$HEALTH_FILE"; echo +grep -Fq '"version":"0.19.5"' "$HEALTH_FILE" +grep -Fq '"phase":"D5.5"' "$HEALTH_FILE" +grep -Fq '"database":"ok"' "$HEALTH_FILE" +bash "$ROOT/scripts/phase-d5-5-acceptance.sh" + +echo "[10/10] Backup POST" +mkdir -p "$POST_BACKUP" +chmod 700 "$POST_BACKUP" +docker compose exec -T db sh -lc 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > "$POST_BACKUP/database.dump" +docker compose ps -a > "$POST_BACKUP/docker-status.txt" +cp -a "$ZIP" "$POST_BACKUP/" +if [[ -f .env ]]; then cp -a .env "$POST_BACKUP/.env"; chmod 600 "$POST_BACKUP/.env"; fi +printf 'PRE D5.5: %s\nINVENTARIO A REVISAR: %s\n' "$PRE_BACKUP" "$STRONG_BACKUP" > "$POST_BACKUP/BACKUP_REFERENCES.txt" +( + cd "$POST_BACKUP" + sha256sum database.dump "$(basename "$ZIP")" BACKUP_REFERENCES.txt > SHA256SUMS.txt + [[ ! -f .env ]] || sha256sum .env >> SHA256SUMS.txt +) + +echo +echo "D5.5 desplegada correctamente" +echo "Backup PRE: $PRE_BACKUP" +echo "Backup fuerte INVENTARIO A REVISAR: $STRONG_BACKUP" +echo "Backup POST: $POST_BACKUP" diff --git a/scripts/deploy-phase-d5-6-2.sh b/scripts/deploy-phase-d5-6-2.sh new file mode 100755 index 0000000..a879708 --- /dev/null +++ b/scripts/deploy-phase-d5-6-2.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT="${ROOT:-/var/www/dhv2.korexlabs.com}" +ZIP="${ZIP:-$ROOT/DH_V2_FASE_D5_6_2_SOURCE.zip}" +STAMP="$(date +%Y%m%d_%H%M%S)" +STAGE="/root/dhv2-d562-stage-$STAMP" +BACKUP_ROOT="/root/DH_V2_BACKUPS" +PRE_BACKUP="$BACKUP_ROOT/D5_6_2_PRE_$STAMP" +POST_BACKUP="$BACKUP_ROOT/D5_6_2_OK_$STAMP" +CLOSURE_BACKUP="$BACKUP_ROOT/FASE_D_CERRADA_D5_6_2_$STAMP" +TEST_LOG="/tmp/dhv2-d562-tests-$STAMP.log" +MIGRATION_LOG="/tmp/dhv2-d562-migration-$STAMP.log" +HEALTH_FILE="/tmp/dhv2-d562-health-$STAMP.json" +MIN_FREE_KB=$((4 * 1024 * 1024)) + +cleanup() { + rm -rf "$STAGE" + rm -f "$TEST_LOG" "$MIGRATION_LOG" "$HEALTH_FILE" + docker image rm dhv2-api:d562-test dhv2-web:d562-test >/dev/null 2>&1 || true +} +trap cleanup EXIT +trap 'echo; echo "ERROR D5.6.2 en línea $LINENO: $BASH_COMMAND"' ERR + +cd "$ROOT" +test -f "$ZIP" +CURRENT_API="$(node -p "require('./api-v3/package.json').version")" +CURRENT_WEB="$(node -p "require('./web-v2/package.json').version")" +case "$CURRENT_API/$CURRENT_WEB" in + 0.19.5-2/0.19.5-2|0.19.6/0.19.6|0.19.6-1/0.19.6-1) ;; + *) echo "ERROR: base no soportada. API=$CURRENT_API WEB=$CURRENT_WEB"; exit 1 ;; +esac + +echo "[1/10] Preflight y staging" +FREE_KB="$(df -Pk "$ROOT" | awk 'NR==2 {print $4}')" +if [[ ! "$FREE_KB" =~ ^[0-9]+$ ]] || (( FREE_KB < MIN_FREE_KB )); then + echo "ERROR: espacio libre insuficiente. Libre: $(( FREE_KB / 1024 )) MB" + exit 1 +fi +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}' 2>/dev/null || true)" == "healthy" ]] +docker compose exec -T db sh -lc 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' +mkdir -p "$STAGE" +unzip -q "$ZIP" -d "$STAGE" +[[ "$(node -p "require('$STAGE/api-v3/package.json').version")" == "0.19.6-2" ]] +[[ "$(node -p "require('$STAGE/web-v2/package.json').version")" == "0.19.6-2" ]] +grep -Fq "API_PHASE = 'D5.6.2'" "$STAGE/api-v3/src/version.ts" +grep -Fq "APP_PHASE = 'Fase D5.6.2 · Accesos y compatibilidad Android'" "$STAGE/web-v2/src/config/version.ts" +if find "$STAGE/api-v3/src/database/migrations" -maxdepth 1 -type f -iname '*d5-6-2*' | grep -q .; then + echo "ERROR: D5.6.2 no debe agregar migración" + exit 1 +fi + +echo "[2/10] Compilando API en staging" +docker build --target builder -t dhv2-api:d562-test "$STAGE/api-v3" + +echo "[3/10] Ejecutando tests API en staging" +docker run --rm \ + -v "$STAGE/api-v3/test:/app/test:ro" \ + -v "$STAGE/api-v3/tsconfig.test.json:/app/tsconfig.test.json:ro" \ + dhv2-api:d562-test npm test 2>&1 | tee "$TEST_LOG" +grep -Eq 'tests[[:space:]]+262' "$TEST_LOG" +grep -Eq 'pass[[:space:]]+262' "$TEST_LOG" +grep -Eq 'fail[[:space:]]+0' "$TEST_LOG" + +echo "[4/10] Compilando web y validando administración" +docker build -t dhv2-web:d562-test "$STAGE/web-v2" +grep -Fq 'Restablecer contraseña' "$STAGE/web-v2/src/pages/UserDetailPage.tsx" +grep -Fq 'resetUserPassword' "$STAGE/web-v2/src/lib/api.ts" +grep -Fq 'Contraseña de usuario restablecida' "$STAGE/web-v2/src/lib/format.ts" + +echo "[5/10] Backup PRE D5.6.2" +mkdir -p "$PRE_BACKUP"; chmod 700 "$PRE_BACKUP" +docker compose exec -T db sh -lc 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > "$PRE_BACKUP/database.dump" +tar -czf "$PRE_BACKUP/source.tar.gz" \ + --exclude='api-v3/node_modules' --exclude='api-v3/dist' \ + --exclude='web-v2/node_modules' --exclude='web-v2/dist' \ + api-v3 web-v2 scripts docs docker-compose.yml .env.example .gitignore README.md +if [[ -f .env ]]; then cp -a .env "$PRE_BACKUP/.env"; chmod 600 "$PRE_BACKUP/.env"; fi +printf 'Base previa API=%s WEB=%s\n' "$CURRENT_API" "$CURRENT_WEB" > "$PRE_BACKUP/BASE_VERSION.txt" +( + cd "$PRE_BACKUP" + sha256sum database.dump source.tar.gz BASE_VERSION.txt > SHA256SUMS.txt + [[ ! -f .env ]] || sha256sum .env >> SHA256SUMS.txt + sha256sum -c SHA256SUMS.txt +) + +echo "[6/10] Instalando source D5.6.2" +unzip -oq "$ZIP" -d "$ROOT" +chmod +x "$ROOT/scripts/deploy-phase-d5-6-2.sh" "$ROOT/scripts/phase-d5-6-2-acceptance.sh" +echo "API source: $(node -p "require('./api-v3/package.json').version")" +echo "WEB source: $(node -p "require('./web-v2/package.json').version")" + +echo "[7/10] Construyendo imágenes finales" +docker compose build api migrate web + +echo "[8/10] Verificando migraciones" +docker compose --profile tools run --rm migrate npm run migration:show 2>&1 | tee "$MIGRATION_LOG" +grep -Fq 'Pending migrations: no' "$MIGRATION_LOG" + +echo "[9/10] Recreando API/web y aceptación" +docker compose up -d --force-recreate api web +for i in $(seq 1 40); do + if curl -fsS http://127.0.0.1:3101/api/v3/health >"$HEALTH_FILE" 2>/dev/null; then break; fi + if [[ "$i" == "40" ]]; then docker compose logs --tail=200 api; exit 1; fi + sleep 2 +done +cat "$HEALTH_FILE"; echo +grep -Fq '"version":"0.19.6-2"' "$HEALTH_FILE" +grep -Fq '"phase":"D5.6.2"' "$HEALTH_FILE" +grep -Fq '"database":"ok"' "$HEALTH_FILE" +bash "$ROOT/scripts/phase-d5-6-2-acceptance.sh" + +echo "[10/10] Backup POST" +mkdir -p "$POST_BACKUP" "$CLOSURE_BACKUP"; chmod 700 "$POST_BACKUP" "$CLOSURE_BACKUP" +docker compose exec -T db sh -lc 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > "$POST_BACKUP/database.dump" +tar -czf "$POST_BACKUP/source.tar.gz" \ + --exclude='api-v3/node_modules' --exclude='api-v3/dist' \ + --exclude='web-v2/node_modules' --exclude='web-v2/dist' \ + api-v3 web-v2 scripts docs docker-compose.yml .env.example .gitignore README.md +docker compose ps -a > "$POST_BACKUP/docker-status.txt" +cp -a "$ZIP" "$POST_BACKUP/" +printf 'D5.6.2 OK\nBase previa API=%s WEB=%s\nVersión final 0.19.6-2\nFASE D CERRADA + hotfix de acceso Android\n' "$CURRENT_API" "$CURRENT_WEB" > "$POST_BACKUP/BACKUP_OK.txt" +if [[ -f .env ]]; then cp -a .env "$POST_BACKUP/.env"; chmod 600 "$POST_BACKUP/.env"; fi +( + cd "$POST_BACKUP" + sha256sum database.dump source.tar.gz "$(basename "$ZIP")" BACKUP_OK.txt > SHA256SUMS.txt + [[ ! -f .env ]] || sha256sum .env >> SHA256SUMS.txt + sha256sum -c SHA256SUMS.txt +) +ln "$POST_BACKUP/database.dump" "$CLOSURE_BACKUP/database.dump" +ln "$POST_BACKUP/source.tar.gz" "$CLOSURE_BACKUP/source.tar.gz" +ln "$POST_BACKUP/$(basename "$ZIP")" "$CLOSURE_BACKUP/$(basename "$ZIP")" +printf 'FASE D CERRADA\nHotfix D5.6.2 aplicado\nBackup canónico: %s\nVersión: 0.19.6-2\n' "$POST_BACKUP" > "$CLOSURE_BACKUP/BACKUP_OK.txt" + +echo +echo "ACEPTACIÓN DE FASE D5.6.2: OK" +echo "D5.6.2 desplegada correctamente" +echo "Backup PRE: $PRE_BACKUP" +echo "Backup POST: $POST_BACKUP" +echo "Backup fuerte FASE D CERRADA: $CLOSURE_BACKUP" diff --git a/scripts/deploy-phase-d5-6-4.sh b/scripts/deploy-phase-d5-6-4.sh new file mode 100755 index 0000000..38860d0 --- /dev/null +++ b/scripts/deploy-phase-d5-6-4.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT="${ROOT:-/var/www/dhv2.korexlabs.com}" +ZIP="${ZIP:-$ROOT/DH_V2_PATCH_D5_6_4.zip}" +STAMP="$(date +%Y%m%d_%H%M%S)" +STAGE="/root/dhv2-d564-stage-$STAMP" +BACKUP_ROOT="/root/DH_V2_BACKUPS" +PRE_BACKUP="$BACKUP_ROOT/D5_6_4_PRE_$STAMP" +POST_BACKUP="$BACKUP_ROOT/D5_6_4_OK_$STAMP" +API_TEST_IMAGE='dhv2-api:d564-test' +WEB_TEST_IMAGE='dhv2-web:d564-test' +TEST_LOG="/tmp/dhv2-d564-tests-$STAMP.log" +MIGRATION_LOG="/tmp/dhv2-d564-migration-$STAMP.log" +HEALTH_FILE="/tmp/dhv2-d564-health-$STAMP.json" +MIN_FREE_KB=$((4 * 1024 * 1024)) + +cleanup() { + rm -rf "$STAGE" + rm -f "$TEST_LOG" "$MIGRATION_LOG" "$HEALTH_FILE" + docker image rm "$API_TEST_IMAGE" "$WEB_TEST_IMAGE" >/dev/null 2>&1 || true +} +trap cleanup EXIT +trap 'echo; echo "ERROR D5.6.4 en línea $LINENO: $BASH_COMMAND"' ERR + +cd "$ROOT" +test -f "$ZIP" +CURRENT_API="$(node -p "require('./api-v3/package.json').version")" +CURRENT_WEB="$(node -p "require('./web-v2/package.json').version")" +[[ "$CURRENT_API" == '0.19.6-2' ]] || { echo "ERROR: D5.6.4 requiere API 0.19.6-2. Actual: $CURRENT_API"; exit 1; } +[[ "$CURRENT_WEB" == '0.19.6-3' ]] || { echo "ERROR: D5.6.4 requiere WEB 0.19.6-3. Actual: $CURRENT_WEB"; exit 1; } + +FREE_KB="$(df -Pk "$ROOT" | awk 'NR==2 {print $4}')" +if [[ ! "$FREE_KB" =~ ^[0-9]+$ ]] || (( FREE_KB < MIN_FREE_KB )); then + echo "ERROR: espacio libre insuficiente. Libre: $(( FREE_KB / 1024 )) MB" + exit 1 +fi +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}' 2>/dev/null || true)" == 'healthy' ]] +docker compose exec -T db sh -lc 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' + +echo '[1/10] Staging incremental' +mkdir -p "$STAGE" +cp -a api-v3 "$STAGE/api-v3" +cp -a web-v2 "$STAGE/web-v2" +mkdir -p "$STAGE/scripts" +unzip -oq "$ZIP" 'api-v3/*' 'web-v2/*' 'scripts/*' -d "$STAGE" +[[ "$(node -p "require('$STAGE/api-v3/package.json').version")" == '0.19.6-4' ]] +[[ "$(node -p "require('$STAGE/web-v2/package.json').version")" == '0.19.6-4' ]] +bash "$STAGE/scripts/check-phase-d5-6-4.sh" "$STAGE" + +echo '[2/10] Build API en staging' +docker build --target builder -t "$API_TEST_IMAGE" "$STAGE/api-v3" + +echo '[3/10] Tests API en staging' +docker run --rm \ + -v "$STAGE/api-v3/test:/app/test:ro" \ + -v "$STAGE/api-v3/tsconfig.test.json:/app/tsconfig.test.json:ro" \ + "$API_TEST_IMAGE" npm test 2>&1 | tee "$TEST_LOG" +grep -Eq 'tests[[:space:]]+266' "$TEST_LOG" +grep -Eq 'pass[[:space:]]+266' "$TEST_LOG" +grep -Eq 'fail[[:space:]]+0' "$TEST_LOG" + +echo '[4/10] Build WEB en staging' +docker build -t "$WEB_TEST_IMAGE" "$STAGE/web-v2" + +echo '[5/10] Backup PRE' +mkdir -p "$PRE_BACKUP"; chmod 700 "$PRE_BACKUP" +docker compose exec -T db sh -lc 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > "$PRE_BACKUP/database.dump" +tar -czf "$PRE_BACKUP/source.tar.gz" \ + --exclude='api-v3/node_modules' --exclude='api-v3/dist' \ + --exclude='web-v2/node_modules' --exclude='web-v2/dist' \ + api-v3 web-v2 scripts docs docker-compose.yml .env.example .gitignore README.md +if [[ -f .env ]]; then cp -a .env "$PRE_BACKUP/.env"; chmod 600 "$PRE_BACKUP/.env"; fi +printf 'Base previa API=%s WEB=%s\n' "$CURRENT_API" "$CURRENT_WEB" > "$PRE_BACKUP/BASE_VERSION.txt" +( + cd "$PRE_BACKUP" + sha256sum database.dump source.tar.gz BASE_VERSION.txt > SHA256SUMS.txt + [[ ! -f .env ]] || sha256sum .env >> SHA256SUMS.txt + sha256sum -c SHA256SUMS.txt +) + +echo '[6/10] Aplicando patch incremental' +unzip -oq "$ZIP" 'api-v3/*' 'web-v2/*' 'scripts/*' -d "$ROOT" +chmod +x scripts/check-phase-d5-6-4.sh scripts/phase-d5-6-4-acceptance.sh scripts/deploy-phase-d5-6-4.sh +bash scripts/check-phase-d5-6-4.sh "$ROOT" + +echo '[7/10] Construyendo imágenes finales' +docker compose build api migrate web + +echo '[8/10] Verificando migraciones' +docker compose --profile tools run --rm migrate npm run migration:show 2>&1 | tee "$MIGRATION_LOG" +grep -Fq 'Pending migrations: no' "$MIGRATION_LOG" + +echo '[9/10] Recreando API/web y aceptación' +docker compose up -d --force-recreate api web +for i in $(seq 1 40); do + if curl -fsS http://127.0.0.1:3101/api/v3/health >"$HEALTH_FILE" 2>/dev/null; then break; fi + if [[ "$i" == '40' ]]; then docker compose logs --tail=200 api web; exit 1; fi + sleep 2 +done +cat "$HEALTH_FILE"; echo +grep -Fq '"version":"0.19.6-4"' "$HEALTH_FILE" +grep -Fq '"phase":"D5.6.4"' "$HEALTH_FILE" +grep -Fq '"database":"ok"' "$HEALTH_FILE" +bash scripts/phase-d5-6-4-acceptance.sh + +echo '[10/10] Backup POST' +mkdir -p "$POST_BACKUP"; chmod 700 "$POST_BACKUP" +docker compose exec -T db sh -lc 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > "$POST_BACKUP/database.dump" +tar -czf "$POST_BACKUP/source.tar.gz" \ + --exclude='api-v3/node_modules' --exclude='api-v3/dist' \ + --exclude='web-v2/node_modules' --exclude='web-v2/dist' \ + api-v3 web-v2 scripts docs docker-compose.yml .env.example .gitignore README.md +cp -a "$ZIP" "$POST_BACKUP/" +docker compose ps -a > "$POST_BACKUP/docker-status.txt" +printf 'D5.6.4 OK\nBase previa API=%s WEB=%s\nVersión final API/WEB=0.19.6-4\n' "$CURRENT_API" "$CURRENT_WEB" > "$POST_BACKUP/BACKUP_OK.txt" +if [[ -f .env ]]; then cp -a .env "$POST_BACKUP/.env"; chmod 600 "$POST_BACKUP/.env"; fi +( + cd "$POST_BACKUP" + sha256sum database.dump source.tar.gz "$(basename "$ZIP")" BACKUP_OK.txt > SHA256SUMS.txt + [[ ! -f .env ]] || sha256sum .env >> SHA256SUMS.txt + sha256sum -c SHA256SUMS.txt +) + +echo +echo 'ACEPTACIÓN DE FASE D5.6.4: OK' +echo 'D5.6.4 desplegada correctamente' +echo 'API: 0.19.6-4' +echo 'WEB: 0.19.6-4' +echo "Backup PRE: $PRE_BACKUP" +echo "Backup POST: $POST_BACKUP" diff --git a/scripts/deploy-phase-d5-6.sh b/scripts/deploy-phase-d5-6.sh new file mode 100755 index 0000000..80e1dfe --- /dev/null +++ b/scripts/deploy-phase-d5-6.sh @@ -0,0 +1,146 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT="${ROOT:-/var/www/dhv2.korexlabs.com}" +ZIP="${ZIP:-$ROOT/DH_V2_FASE_D5_6_SOURCE.zip}" +STAMP="$(date +%Y%m%d_%H%M%S)" +STAGE="/root/dhv2-d56-stage-$STAMP" +BACKUP_ROOT="/root/DH_V2_BACKUPS" +PRE_BACKUP="$BACKUP_ROOT/D5_6_PRE_$STAMP" +POST_BACKUP="$BACKUP_ROOT/D5_6_OK_$STAMP" +CLOSURE_BACKUP="$BACKUP_ROOT/FASE_D_CERRADA_$STAMP" +MIGRATION_LOG="/tmp/dhv2-d56-migration-$STAMP.log" +TEST_LOG="/tmp/dhv2-d56-tests-$STAMP.log" +HEALTH_FILE="/tmp/dhv2-d56-health-$STAMP.json" +MIN_FREE_KB=$((4 * 1024 * 1024)) + +cleanup() { + rm -rf "$STAGE" + rm -f "$MIGRATION_LOG" "$TEST_LOG" "$HEALTH_FILE" + docker image rm dhv2-api:d56-test dhv2-web:d56-test >/dev/null 2>&1 || true +} +trap cleanup EXIT +trap 'echo; echo "ERROR D5.6 en línea $LINENO: $BASH_COMMAND"' ERR + +cd "$ROOT" +test -f "$ZIP" +CURRENT_API="$(node -p "require('./api-v3/package.json').version")" +CURRENT_WEB="$(node -p "require('./web-v2/package.json').version")" +[[ "$CURRENT_API" == "0.19.5-2" ]] +[[ "$CURRENT_WEB" == "0.19.5-2" ]] + +echo "[1/10] Preflight y staging" +FREE_KB="$(df -Pk "$ROOT" | awk 'NR==2 {print $4}')" +if [[ ! "$FREE_KB" =~ ^[0-9]+$ ]] || (( FREE_KB < MIN_FREE_KB )); then + echo "ERROR: espacio libre insuficiente. Libre: $(( FREE_KB / 1024 )) MB" + exit 1 +fi +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}' 2>/dev/null || true)" == "healthy" ]] +docker compose exec -T db sh -lc 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' +mkdir -p "$STAGE" +unzip -q "$ZIP" -d "$STAGE" +[[ "$(node -p "require('$STAGE/api-v3/package.json').version")" == "0.19.6" ]] +[[ "$(node -p "require('$STAGE/web-v2/package.json').version")" == "0.19.6" ]] +grep -Fq "API_PHASE = 'D5.6'" "$STAGE/api-v3/src/version.ts" +grep -Fq "APP_PHASE = 'Fase D5.6 · Cierre integral de Fase D'" "$STAGE/web-v2/src/config/version.ts" +if find "$STAGE/api-v3/src/database/migrations" -maxdepth 1 -type f -iname '*d5-6*' | grep -q .; then + echo "ERROR: D5.6 no debe agregar una migración" + exit 1 +fi + +echo "[2/10] Compilando API en staging" +docker build --target builder -t dhv2-api:d56-test "$STAGE/api-v3" + +echo "[3/10] Ejecutando suite integral en staging" +docker run --rm \ + -v "$STAGE/api-v3/test:/app/test:ro" \ + -v "$STAGE/api-v3/tsconfig.test.json:/app/tsconfig.test.json:ro" \ + dhv2-api:d56-test npm test 2>&1 | tee "$TEST_LOG" +grep -Eq 'tests[[:space:]]+260' "$TEST_LOG" +grep -Eq 'pass[[:space:]]+260' "$TEST_LOG" +grep -Eq 'fail[[:space:]]+0' "$TEST_LOG" + +echo "[4/10] Compilando web y auditando terminología" +docker build -t dhv2-web:d56-test "$STAGE/web-v2" +grep -Fq 'ARCHIVOS DEL INVENTARIO' "$STAGE/web-v2/src/features/assets/AssetMediaPanel.tsx" +grep -Fq 'INVENTARIO DEL ACTA' "$STAGE/web-v2/src/pages/InspectionActEditorPage.tsx" +if grep -RnhE --include='*.tsx' 'ARCHIVOS DEL ACTIVO|ACTIVOS DEL ACTA' "$STAGE/web-v2/src"; then exit 1; fi +grep -Fq 'debe iniciar la visita desde la APK' "$STAGE/web-v2/src/pages/InspectionVisitEditorPage.tsx" + +echo "[5/10] Backup PRE D5.6" +mkdir -p "$PRE_BACKUP" +chmod 700 "$PRE_BACKUP" +docker compose exec -T db sh -lc 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > "$PRE_BACKUP/database.dump" +tar -czf "$PRE_BACKUP/source.tar.gz" \ + --exclude='api-v3/node_modules' --exclude='api-v3/dist' \ + --exclude='web-v2/node_modules' --exclude='web-v2/dist' \ + api-v3 web-v2 scripts docs docker-compose.yml .env.example .gitignore README.md +if [[ -f .env ]]; then cp -a .env "$PRE_BACKUP/.env"; chmod 600 "$PRE_BACKUP/.env"; fi +STRONG_BACKUP="$(find "$BACKUP_ROOT" -maxdepth 1 -type d -name 'INVENTARIO_A_REVISAR_*' -printf '%T@ %p\n' | sort -nr | head -n1 | cut -d' ' -f2-)" +printf 'Backup fuerte conceptual previo: %s\nPalabra clave: INVENTARIO A REVISAR\n' "$STRONG_BACKUP" > "$PRE_BACKUP/INVENTARIO_A_REVISAR_REFERENCE.txt" +( + cd "$PRE_BACKUP" + sha256sum database.dump source.tar.gz INVENTARIO_A_REVISAR_REFERENCE.txt > SHA256SUMS.txt + [[ ! -f .env ]] || sha256sum .env >> SHA256SUMS.txt + sha256sum -c SHA256SUMS.txt +) + +echo "[6/10] Instalando source D5.6" +unzip -oq "$ZIP" -d "$ROOT" +chmod +x "$ROOT/scripts/deploy-phase-d5-6.sh" "$ROOT/scripts/phase-d5-6-acceptance.sh" +echo "API source: $(node -p "require('./api-v3/package.json').version")" +echo "WEB source: $(node -p "require('./web-v2/package.json').version")" + +echo "[7/10] Construyendo imágenes finales" +docker compose build api migrate web + +echo "[8/10] Verificando que D5.6 no requiere migración" +docker compose --profile tools run --rm migrate npm run migration:show 2>&1 | tee "$MIGRATION_LOG" +grep -Fq 'Pending migrations: no' "$MIGRATION_LOG" + +echo "[9/10] Recreando API/web y aceptación integral" +docker compose up -d --force-recreate api web +for i in $(seq 1 40); do + if curl -fsS http://127.0.0.1:3101/api/v3/health >"$HEALTH_FILE" 2>/dev/null; then break; fi + if [[ "$i" == "40" ]]; then docker compose logs --tail=200 api; exit 1; fi + sleep 2 +done +cat "$HEALTH_FILE"; echo +grep -Fq '"version":"0.19.6"' "$HEALTH_FILE" +grep -Fq '"phase":"D5.6"' "$HEALTH_FILE" +grep -Fq '"database":"ok"' "$HEALTH_FILE" +bash "$ROOT/scripts/phase-d5-6-acceptance.sh" + +echo "[10/10] Backup POST y cierre formal de Fase D" +mkdir -p "$POST_BACKUP" "$CLOSURE_BACKUP" +chmod 700 "$POST_BACKUP" "$CLOSURE_BACKUP" +docker compose exec -T db sh -lc 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > "$POST_BACKUP/database.dump" +tar -czf "$POST_BACKUP/source.tar.gz" \ + --exclude='api-v3/node_modules' --exclude='api-v3/dist' \ + --exclude='web-v2/node_modules' --exclude='web-v2/dist' \ + api-v3 web-v2 scripts docs docker-compose.yml .env.example .gitignore README.md +docker compose ps -a > "$POST_BACKUP/docker-status.txt" +cp -a "$ZIP" "$POST_BACKUP/" +if [[ -f .env ]]; then cp -a .env "$POST_BACKUP/.env"; chmod 600 "$POST_BACKUP/.env"; fi +printf 'PRE D5.6: %s\nINVENTARIO A REVISAR: %s\nFASE D: CERRADA\n' "$PRE_BACKUP" "$STRONG_BACKUP" > "$POST_BACKUP/BACKUP_REFERENCES.txt" +printf 'FASE D CERRADA\nVersión API/WEB: 0.19.6\nBackup fuente: %s\nFecha: %s\n' "$POST_BACKUP" "$(date -Is)" > "$POST_BACKUP/FASE_D_CERRADA.txt" +( + cd "$POST_BACKUP" + sha256sum database.dump source.tar.gz "$(basename "$ZIP")" BACKUP_REFERENCES.txt FASE_D_CERRADA.txt > SHA256SUMS.txt + [[ ! -f .env ]] || sha256sum .env >> SHA256SUMS.txt + sha256sum -c SHA256SUMS.txt +) +# Hard links preserve a second strong closure label without duplicating large files. +ln "$POST_BACKUP/database.dump" "$CLOSURE_BACKUP/database.dump" +ln "$POST_BACKUP/source.tar.gz" "$CLOSURE_BACKUP/source.tar.gz" +ln "$POST_BACKUP/$(basename "$ZIP")" "$CLOSURE_BACKUP/$(basename "$ZIP")" +cp "$POST_BACKUP/SHA256SUMS.txt" "$CLOSURE_BACKUP/SHA256SUMS.txt" +printf 'FASE D CERRADA\nBackup canónico: %s\nVersión: 0.19.6\n' "$POST_BACKUP" > "$CLOSURE_BACKUP/BACKUP_OK.txt" + +echo +echo "ACEPTACIÓN INTEGRAL DE FASE D: OK" +echo "FASE D: CERRADA" +echo "D5.6 desplegada correctamente" +echo "Backup PRE: $PRE_BACKUP" +echo "Backup POST: $POST_BACKUP" +echo "Backup fuerte FASE D CERRADA: $CLOSURE_BACKUP" diff --git a/scripts/dev-clean-demo.sh b/scripts/dev-clean-demo.sh new file mode 100755 index 0000000..4f69c82 --- /dev/null +++ b/scripts/dev-clean-demo.sh @@ -0,0 +1,276 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT="${ROOT:-/var/www/dhv2.korexlabs.com}" +TOKEN="DH-DEV-DEMO" +APPLY=false +if [[ "${1:-}" == "--apply" && "${2:-}" == "$TOKEN" ]]; then APPLY=true; fi +cd "$ROOT" + +DB_SERVICE="${DB_SERVICE:-db}" +psql_owner() { + docker compose exec -T "$DB_SERVICE" sh -lc \ + 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -At -v ON_ERROR_STOP=1' <<<"$1" +} + +DEMO_CTE="WITH demo_assets AS ( + SELECT id FROM assets + WHERE code LIKE 'DEV-DEMO-%' OR source_reference LIKE 'DH-DEV-DEMO:%' +), demo_documents AS ( + SELECT id FROM source_documents WHERE external_reference LIKE 'DH-DEV-DEMO:%' OR document_number LIKE '%DH-DEV-DEMO%' +), demo_visits AS ( + SELECT DISTINCT v.id + FROM inspection_visits v + LEFT JOIN inspection_visit_assets va ON va.visit_id = v.id + WHERE v.scope_asset_id IN (SELECT id FROM demo_assets) + OR va.asset_id IN (SELECT id FROM demo_assets) +), demo_acts AS ( + SELECT id FROM inspection_acts WHERE visit_id IN (SELECT id FROM demo_visits) +), demo_campaigns AS ( + SELECT DISTINCT c.id + FROM survey_campaigns c + LEFT JOIN survey_campaign_targets t ON t.campaign_id = c.id + WHERE c.scope_asset_id IN (SELECT id FROM demo_assets) + OR t.asset_id IN (SELECT id FROM demo_assets) +)" + +asset_count="$(psql_owner "$DEMO_CTE SELECT COUNT(*) FROM demo_assets;")" +visit_count="$(psql_owner "$DEMO_CTE SELECT COUNT(*) FROM demo_visits;")" +act_count="$(psql_owner "$DEMO_CTE SELECT COUNT(*) FROM demo_acts;")" +campaign_count="$(psql_owner "$DEMO_CTE SELECT COUNT(*) FROM demo_campaigns;")" +document_count="$(psql_owner "$DEMO_CTE SELECT COUNT(*) FROM demo_documents;")" + +echo "========== DH · LIMPIEZA DE DATOS DE PRUEBA ==========" +echo "Activos DEV-DEMO detectados : $asset_count" +echo "Visitas relacionadas : $visit_count" +echo "Actas relacionadas : $act_count" +echo "Relevamientos relacionados : $campaign_count" +echo "Documentos DEV-DEMO : $document_count" +echo + +if [[ "$asset_count" == "0" ]]; then + echo "No hay datos DEV-DEMO para limpiar." + exit 0 +fi + +# Nunca ampliamos implícitamente el conjunto a registros no marcados como prueba. +# Si un dato no demo depende de uno demo, o un flujo mezcla ambos entornos, se aborta. +MIXED_SQL="$DEMO_CTE +SELECT + (SELECT COUNT(*) FROM assets a + WHERE a.id NOT IN (SELECT id FROM demo_assets) + AND (a.parent_id IN (SELECT id FROM demo_assets) + OR a.operational_area_id IN (SELECT id FROM demo_assets) + OR a.operator_company_id IN (SELECT id FROM demo_assets))) ++ (SELECT COUNT(*) FROM area_company_relations r + WHERE (r.area_id IN (SELECT id FROM demo_assets)) <> (r.company_id IN (SELECT id FROM demo_assets))) ++ (SELECT COUNT(*) FROM inspection_visits v + WHERE v.id IN (SELECT id FROM demo_visits) + AND v.scope_asset_id IS NOT NULL + AND v.scope_asset_id NOT IN (SELECT id FROM demo_assets)) ++ (SELECT COUNT(*) FROM inspection_visit_assets va + WHERE va.visit_id IN (SELECT id FROM demo_visits) + AND va.asset_id NOT IN (SELECT id FROM demo_assets)) ++ (SELECT COUNT(*) FROM inspection_act_assets aa + WHERE aa.act_id IN (SELECT id FROM demo_acts) + AND aa.asset_id NOT IN (SELECT id FROM demo_assets)) ++ (SELECT COUNT(*) FROM inspection_findings f + WHERE f.act_id IN (SELECT id FROM demo_acts) + AND f.asset_id NOT IN (SELECT id FROM demo_assets)) ++ (SELECT COUNT(*) FROM inspection_findings f + WHERE f.asset_id IN (SELECT id FROM demo_assets) + AND f.act_id NOT IN (SELECT id FROM demo_acts)) ++ (SELECT COUNT(*) FROM survey_campaigns c + WHERE c.id IN (SELECT id FROM demo_campaigns) + AND c.scope_asset_id IS NOT NULL + AND c.scope_asset_id NOT IN (SELECT id FROM demo_assets)) ++ (SELECT COUNT(*) FROM survey_campaign_targets t + WHERE t.campaign_id IN (SELECT id FROM demo_campaigns) + AND t.asset_id NOT IN (SELECT id FROM demo_assets)) ++ (SELECT COUNT(*) FROM organization_memberships m + WHERE (m.parent_organization_id IN (SELECT id FROM demo_assets)) <> (m.member_organization_id IN (SELECT id FROM demo_assets))) ++ (SELECT COUNT(*) FROM asset_source_documents l + WHERE l.document_id IN (SELECT id FROM demo_documents) AND l.asset_id NOT IN (SELECT id FROM demo_assets)) ++ (SELECT COUNT(*) FROM area_legal_right_organizations o + JOIN area_legal_rights r ON r.id=o.right_id + WHERE (r.area_id IN (SELECT id FROM demo_assets)) <> (o.organization_id IN (SELECT id FROM demo_assets)));" + +mixed="$(psql_owner "$MIXED_SQL")" +if [[ "$mixed" != "0" ]]; then + echo "ABORTADO: se detectaron $mixed vínculo(s) entre DEV-DEMO y datos no marcados como prueba." + echo "El limpiador no borrará registros ambiguos. Revisá esas relaciones manualmente." + exit 2 +fi + +if [[ "$APPLY" != true ]]; then + echo "MODO SIMULACIÓN: no se modificó nada." + echo "La limpieza real queda reservada para reiniciar el demo o preparar la futura entrega." + echo "Comando protegido:" + echo " bash scripts/dev-clean-demo.sh --apply DH-DEV-DEMO" + exit 0 +fi + +STAMP="$(date +%Y%m%d_%H%M%S)" +BACKUP="/root/DH_V2_BACKUPS/DEV_CLEAN_PRE_${STAMP}" +mkdir -p "$BACKUP" +echo "Creando backup obligatorio en $BACKUP ..." +docker compose exec -T "$DB_SERVICE" sh -lc \ + 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > "$BACKUP/database.dump" + +ASSET_FILES="$BACKUP/asset-files.txt" +EVIDENCE_FILES="$BACKUP/evidence-files.txt" +SIGNATURE_FILES="$BACKUP/signature-files.txt" + +psql_owner "$DEMO_CTE SELECT stored_name FROM asset_media WHERE asset_id IN (SELECT id FROM demo_assets);" > "$ASSET_FILES" +psql_owner "$DEMO_CTE + SELECT e.stored_name + FROM inspection_finding_evidence e + JOIN inspection_findings f ON f.id=e.finding_id + WHERE f.act_id IN (SELECT id FROM demo_acts) OR f.asset_id IN (SELECT id FROM demo_assets);" > "$EVIDENCE_FILES" +psql_owner "$DEMO_CTE + SELECT s.stored_name + FROM inspection_act_signatures s + WHERE s.act_id IN (SELECT id FROM demo_acts) AND s.stored_name IS NOT NULL;" > "$SIGNATURE_FILES" + +cat > "$BACKUP/cleanup.sql" <<'SQL' +BEGIN; +-- Sólo el usuario dueño de la base ejecuta este script. Replica desactiva temporalmente +-- los triggers append-only para poder retirar datos de desarrollo conservando backup previo. +SET LOCAL session_replication_role = replica; + +CREATE TEMP TABLE demo_assets(id uuid PRIMARY KEY) ON COMMIT DROP; +INSERT INTO demo_assets +SELECT id FROM assets +WHERE code LIKE 'DEV-DEMO-%' OR source_reference LIKE 'DH-DEV-DEMO:%'; + +CREATE TEMP TABLE demo_documents(id uuid PRIMARY KEY) ON COMMIT DROP; +INSERT INTO demo_documents SELECT id FROM source_documents WHERE external_reference LIKE 'DH-DEV-DEMO:%' OR document_number LIKE '%DH-DEV-DEMO%'; + +CREATE TEMP TABLE demo_visits(id uuid PRIMARY KEY) ON COMMIT DROP; +INSERT INTO demo_visits +SELECT DISTINCT v.id +FROM inspection_visits v +LEFT JOIN inspection_visit_assets va ON va.visit_id=v.id +WHERE v.scope_asset_id IN (SELECT id FROM demo_assets) + OR va.asset_id IN (SELECT id FROM demo_assets); + +CREATE TEMP TABLE demo_acts(id uuid PRIMARY KEY) ON COMMIT DROP; +INSERT INTO demo_acts SELECT id FROM inspection_acts WHERE visit_id IN (SELECT id FROM demo_visits); + +CREATE TEMP TABLE demo_findings(id uuid PRIMARY KEY) ON COMMIT DROP; +INSERT INTO demo_findings +SELECT id FROM inspection_findings +WHERE act_id IN (SELECT id FROM demo_acts) OR asset_id IN (SELECT id FROM demo_assets); + +CREATE TEMP TABLE demo_evidence(id uuid PRIMARY KEY) ON COMMIT DROP; +INSERT INTO demo_evidence SELECT id FROM inspection_finding_evidence WHERE finding_id IN (SELECT id FROM demo_findings); + +CREATE TEMP TABLE demo_communications(id uuid PRIMARY KEY) ON COMMIT DROP; +INSERT INTO demo_communications SELECT id FROM inspection_finding_communications WHERE finding_id IN (SELECT id FROM demo_findings); + +CREATE TEMP TABLE demo_signatures(id uuid PRIMARY KEY) ON COMMIT DROP; +INSERT INTO demo_signatures SELECT id FROM inspection_act_signatures WHERE act_id IN (SELECT id FROM demo_acts); + +CREATE TEMP TABLE demo_campaigns(id uuid PRIMARY KEY) ON COMMIT DROP; +INSERT INTO demo_campaigns +SELECT DISTINCT c.id +FROM survey_campaigns c +LEFT JOIN survey_campaign_targets t ON t.campaign_id=c.id +WHERE c.scope_asset_id IN (SELECT id FROM demo_assets) + OR t.asset_id IN (SELECT id FROM demo_assets); + +CREATE TEMP TABLE demo_targets(id uuid PRIMARY KEY) ON COMMIT DROP; +INSERT INTO demo_targets SELECT id FROM survey_campaign_targets WHERE campaign_id IN (SELECT id FROM demo_campaigns); + +CREATE TEMP TABLE demo_reports(id uuid PRIMARY KEY) ON COMMIT DROP; +INSERT INTO demo_reports SELECT id FROM survey_target_reports WHERE target_id IN (SELECT id FROM demo_targets); + +CREATE TEMP TABLE demo_relations(id uuid PRIMARY KEY) ON COMMIT DROP; +INSERT INTO demo_relations +SELECT id FROM area_company_relations +WHERE area_id IN (SELECT id FROM demo_assets) AND company_id IN (SELECT id FROM demo_assets); + +DELETE FROM survey_target_report_media WHERE report_id IN (SELECT id FROM demo_reports); +DELETE FROM survey_target_report_versions WHERE report_id IN (SELECT id FROM demo_reports); +DELETE FROM survey_target_reports WHERE id IN (SELECT id FROM demo_reports); +DELETE FROM survey_campaign_targets WHERE id IN (SELECT id FROM demo_targets); +DELETE FROM survey_campaigns WHERE id IN (SELECT id FROM demo_campaigns); + +DELETE FROM inspection_finding_evidence WHERE id IN (SELECT id FROM demo_evidence); +DELETE FROM inspection_finding_communications WHERE id IN (SELECT id FROM demo_communications); +DELETE FROM inspection_finding_versions WHERE finding_id IN (SELECT id FROM demo_findings); +DELETE FROM inspection_findings WHERE id IN (SELECT id FROM demo_findings); +DELETE FROM inspection_act_signatures WHERE id IN (SELECT id FROM demo_signatures); +DELETE FROM inspection_act_closures WHERE act_id IN (SELECT id FROM demo_acts); +DELETE FROM inspection_act_responsibles WHERE act_id IN (SELECT id FROM demo_acts); +DELETE FROM inspection_act_versions WHERE act_id IN (SELECT id FROM demo_acts); +DELETE FROM inspection_act_assets WHERE act_id IN (SELECT id FROM demo_acts); +DELETE FROM inspection_acts WHERE id IN (SELECT id FROM demo_acts); +DELETE FROM inspection_visit_members WHERE visit_id IN (SELECT id FROM demo_visits); +DELETE FROM inspection_visit_assets WHERE visit_id IN (SELECT id FROM demo_visits); +DELETE FROM inspection_visits WHERE id IN (SELECT id FROM demo_visits); + +DELETE FROM area_legal_right_organizations WHERE organization_id IN (SELECT id FROM demo_assets) OR right_id IN (SELECT id FROM area_legal_rights WHERE area_id IN (SELECT id FROM demo_assets)); +DELETE FROM area_legal_rights WHERE area_id IN (SELECT id FROM demo_assets); +DELETE FROM organization_memberships WHERE parent_organization_id IN (SELECT id FROM demo_assets) OR member_organization_id IN (SELECT id FROM demo_assets); +DELETE FROM asset_external_identifiers WHERE asset_id IN (SELECT id FROM demo_assets); +DELETE FROM asset_source_documents WHERE asset_id IN (SELECT id FROM demo_assets) OR document_id IN (SELECT id FROM demo_documents); +DELETE FROM area_company_relations WHERE id IN (SELECT id FROM demo_relations); +DELETE FROM organization_profiles WHERE asset_id IN (SELECT id FROM demo_assets); +DELETE FROM asset_media WHERE asset_id IN (SELECT id FROM demo_assets); +DELETE FROM asset_geometries WHERE asset_id IN (SELECT id FROM demo_assets); +DELETE FROM asset_attribute_values WHERE asset_id IN (SELECT id FROM demo_assets); +DELETE FROM asset_versions WHERE asset_id IN (SELECT id FROM demo_assets); + +DELETE FROM audit_events +WHERE request_id LIKE 'dev-seed-%' + OR entity_id IN ( + SELECT id::text FROM demo_assets + UNION SELECT id::text FROM demo_visits + UNION SELECT id::text FROM demo_acts + UNION SELECT id::text FROM demo_findings + UNION SELECT id::text FROM demo_evidence + UNION SELECT id::text FROM demo_communications + UNION SELECT id::text FROM demo_signatures + UNION SELECT id::text FROM demo_campaigns + UNION SELECT id::text FROM demo_targets + UNION SELECT id::text FROM demo_reports + UNION SELECT id::text FROM demo_relations + UNION SELECT id::text FROM demo_documents + ); + +DELETE FROM assets WHERE id IN (SELECT id FROM demo_assets); +DELETE FROM source_documents d WHERE d.id IN (SELECT id FROM demo_documents) + AND NOT EXISTS (SELECT 1 FROM asset_source_documents l WHERE l.document_id=d.id) + AND NOT EXISTS (SELECT 1 FROM area_company_relations r WHERE r.source_document_id=d.id) + AND NOT EXISTS (SELECT 1 FROM organization_memberships m WHERE m.source_document_id=d.id) + AND NOT EXISTS (SELECT 1 FROM area_legal_rights r WHERE r.source_document_id=d.id) + AND NOT EXISTS (SELECT 1 FROM asset_external_identifiers i WHERE i.source_document_id=d.id); + +-- La numeración de actas vuelve al máximo real remanente. Si no queda ninguna acta +-- de un año, se elimina la secuencia para que ese año pueda volver a comenzar en 1. +DELETE FROM document_annual_sequences s +WHERE s.document_type='ACT' + AND NOT EXISTS (SELECT 1 FROM inspection_acts a WHERE a.act_year=s.year); +UPDATE document_annual_sequences s +SET last_number=q.max_number, updated_at=CURRENT_TIMESTAMP +FROM ( + SELECT act_year, MAX(act_number) AS max_number + FROM inspection_acts + GROUP BY act_year +) q +WHERE s.document_type='ACT' AND s.year=q.act_year; + +COMMIT; +SQL + +docker compose exec -T "$DB_SERVICE" sh -lc \ + 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -v ON_ERROR_STOP=1' < "$BACKUP/cleanup.sql" + +while IFS= read -r f; do [[ -z "$f" ]] || docker compose exec -T api rm -f -- "/app/storage/asset-media/$f"; done < "$ASSET_FILES" +while IFS= read -r f; do [[ -z "$f" ]] || docker compose exec -T api rm -f -- "/app/storage/asset-media/inspection-findings/$f"; done < "$EVIDENCE_FILES" +while IFS= read -r f; do [[ -z "$f" ]] || docker compose exec -T api rm -f -- "/app/storage/asset-media/inspection-signatures/$f"; done < "$SIGNATURE_FILES" + +echo "Limpieza DEV-DEMO finalizada." +echo "Se conservaron tipos, atributos, jerarquías, usuarios, roles, permisos y catálogo de hallazgos." +echo "Backup previo: $BACKUP/database.dump" diff --git a/scripts/dev-seed-mendoza-demo.sh b/scripts/dev-seed-mendoza-demo.sh new file mode 100755 index 0000000..8335ba7 --- /dev/null +++ b/scripts/dev-seed-mendoza-demo.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +cd /var/www/dhv2.korexlabs.com +echo "Creando caso de prueba DH DEV DEMO..." +docker compose exec -T api npm run dev:seed:mendoza-demo diff --git a/scripts/phase-a-acceptance.sh b/scripts/phase-a-acceptance.sh new file mode 100755 index 0000000..35aa2ba --- /dev/null +++ b/scripts/phase-a-acceptance.sh @@ -0,0 +1,159 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail + +dhv2_base_url="${DHV2_BASE_URL:-https://dhv2.korexlabs.com}" +dhv2_base_url="${dhv2_base_url%/}" +dhv2_tmp="$(mktemp -d)" +umask 077 + +cleanup() { + rm -rf "$dhv2_tmp" + unset dhv2_username dhv2_password dhv2_csrf +} +trap cleanup EXIT + +fail() { + echo "ERROR: $*" >&2 + exit 1 +} + +command -v curl >/dev/null || fail "curl no está instalado" +command -v python3 >/dev/null || fail "python3 no está instalado" + +echo "========== A7 · PRUEBAS SIN SESIÓN ==========" +for dhv2_path in dashboard/summary users roles audit; do + dhv2_code="$(curl -sS --connect-timeout 10 --max-time 30 \ + -o "$dhv2_tmp/public.json" -w '%{http_code}' \ + "$dhv2_base_url/api/v3/$dhv2_path")" + test "$dhv2_code" = "401" || fail "/api/v3/$dhv2_path devolvió HTTP $dhv2_code; se esperaba 401" + echo "/api/v3/$dhv2_path sin sesión: 401 OK" +done + +echo +echo "========== A7 · LOGIN INVÁLIDO GENÉRICO ==========" +DHV2_PAYLOAD_PATH="$dhv2_tmp/invalid-payload.json" python3 - <<'PY' +import json +import os +from pathlib import Path + +Path(os.environ['DHV2_PAYLOAD_PATH']).write_text(json.dumps({ + 'identifier': 'a7_usuario_inexistente', + 'password': 'A7-invalida-2026', + 'deviceLabel': 'Prueba A7', +}), encoding='utf-8') +PY + +dhv2_invalid_code="$(curl -sS --connect-timeout 10 --max-time 30 \ + -o "$dhv2_tmp/invalid.json" -w '%{http_code}' \ + -H 'Content-Type: application/json' \ + --data-binary "@$dhv2_tmp/invalid-payload.json" \ + "$dhv2_base_url/api/v3/auth/login")" +test "$dhv2_invalid_code" = "401" || fail "El login inválido devolvió HTTP $dhv2_invalid_code; se esperaba 401" +grep -q '"code":"INVALID_CREDENTIALS"' "$dhv2_tmp/invalid.json" || fail "El login inválido no devolvió el error genérico esperado" +echo "Login inválido genérico: 401 OK" + +echo +echo "========== A7 · LOGIN ADMINISTRATIVO ==========" +dhv2_username="${DHV2_TEST_USERNAME:-}" +dhv2_password="${DHV2_TEST_PASSWORD:-}" + +if [ -z "$dhv2_username" ]; then + read -r -p "Usuario administrador: " dhv2_username +fi +if [ -z "$dhv2_password" ]; then + read -r -s -p "Contraseña: " dhv2_password + echo +fi +test -n "$dhv2_username" || fail "El usuario es obligatorio" +test -n "$dhv2_password" || fail "La contraseña es obligatoria" + +DHV2_USERNAME="$dhv2_username" \ +DHV2_PASSWORD="$dhv2_password" \ +DHV2_PAYLOAD_PATH="$dhv2_tmp/login-payload.json" \ +python3 - <<'PY' +import json +import os +from pathlib import Path + +Path(os.environ['DHV2_PAYLOAD_PATH']).write_text(json.dumps({ + 'identifier': os.environ['DHV2_USERNAME'], + 'password': os.environ['DHV2_PASSWORD'], + 'deviceLabel': 'Aceptación Fase A7', +}), encoding='utf-8') +PY + +dhv2_login_code="$(curl -sS --connect-timeout 10 --max-time 30 \ + -D "$dhv2_tmp/login.headers" \ + -c "$dhv2_tmp/cookies.txt" \ + -o "$dhv2_tmp/login.json" \ + -w '%{http_code}' \ + -H 'Content-Type: application/json' \ + --data-binary "@$dhv2_tmp/login-payload.json" \ + "$dhv2_base_url/api/v3/auth/login")" + +rm -f "$dhv2_tmp/login-payload.json" +unset dhv2_password +test "$dhv2_login_code" = "200" || fail "El login administrativo devolvió HTTP $dhv2_login_code" +grep -q '"mustChangePassword":false' "$dhv2_tmp/login.json" || fail "La cuenta todavía exige cambiar la contraseña desde el panel" + +dhv2_csrf="$(DHV2_RESPONSE_PATH="$dhv2_tmp/login.json" python3 - <<'PY' +import json +import os +from pathlib import Path + +data = json.loads(Path(os.environ['DHV2_RESPONSE_PATH']).read_text(encoding='utf-8')) +print(data.get('csrfToken', '')) +PY +)" +test -n "$dhv2_csrf" || fail "El login no devolvió un token CSRF" + +dhv2_secure_cookie_count="$(grep -Ei '^set-cookie:' "$dhv2_tmp/login.headers" | grep -Eic 'Secure.*SameSite=Strict' || true)" +dhv2_http_only_count="$(grep -Ei '^set-cookie:' "$dhv2_tmp/login.headers" | grep -Eic 'HttpOnly.*Secure.*SameSite=Strict' || true)" +test "$dhv2_secure_cookie_count" -ge 3 || fail "No se detectaron las tres cookies Secure + SameSite=Strict" +test "$dhv2_http_only_count" -ge 2 || fail "Las cookies de acceso y refresh no tienen todos los atributos requeridos" +echo "Login y atributos de cookies: OK" + +echo +echo "========== A7 · ENDPOINTS AUTENTICADOS ==========" +for dhv2_path in auth/me dashboard/summary 'users?page=1&pageSize=1' roles 'audit?page=1&pageSize=1'; do + dhv2_safe_name="${dhv2_path//[^a-zA-Z0-9]/_}" + dhv2_code="$(curl -sS --connect-timeout 10 --max-time 30 \ + -b "$dhv2_tmp/cookies.txt" \ + -o "$dhv2_tmp/$dhv2_safe_name.json" \ + -w '%{http_code}' \ + "$dhv2_base_url/api/v3/$dhv2_path")" + test "$dhv2_code" = "200" || fail "/api/v3/$dhv2_path devolvió HTTP $dhv2_code; se esperaba 200" + echo "/api/v3/$dhv2_path autenticado: 200 OK" +done + +echo +echo "========== A7 · CSRF Y LOGOUT ==========" +dhv2_csrf_failure="$(curl -sS --connect-timeout 10 --max-time 30 \ + -b "$dhv2_tmp/cookies.txt" \ + -o "$dhv2_tmp/csrf-failure.json" \ + -w '%{http_code}' \ + -X POST "$dhv2_base_url/api/v3/auth/logout")" +test "$dhv2_csrf_failure" = "403" || fail "Una escritura sin CSRF devolvió HTTP $dhv2_csrf_failure; se esperaba 403" +grep -q '"code":"CSRF_INVALID"' "$dhv2_tmp/csrf-failure.json" || fail "No se devolvió CSRF_INVALID" +echo "Escritura sin CSRF: 403 OK" + +dhv2_logout_code="$(curl -sS --connect-timeout 10 --max-time 30 \ + -b "$dhv2_tmp/cookies.txt" \ + -c "$dhv2_tmp/cookies.txt" \ + -o "$dhv2_tmp/logout.json" \ + -w '%{http_code}' \ + -H "x-csrf-token: $dhv2_csrf" \ + -X POST "$dhv2_base_url/api/v3/auth/logout")" +test "$dhv2_logout_code" = "200" || fail "Logout devolvió HTTP $dhv2_logout_code" + +dhv2_after_logout="$(curl -sS --connect-timeout 10 --max-time 30 \ + -b "$dhv2_tmp/cookies.txt" \ + -o "$dhv2_tmp/after-logout.json" \ + -w '%{http_code}' \ + "$dhv2_base_url/api/v3/auth/me")" +test "$dhv2_after_logout" = "401" || fail "La sesión siguió activa después del logout" +echo "Logout y revocación de sesión: OK" + +echo +echo "ACEPTACIÓN AUTENTICADA DE FASE A: OK" diff --git a/scripts/phase-b1-acceptance.sh b/scripts/phase-b1-acceptance.sh new file mode 100755 index 0000000..6af9146 --- /dev/null +++ b/scripts/phase-b1-acceptance.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail + +dhv2_base_url="${DHV2_BASE_URL:-https://dhv2.korexlabs.com}" +dhv2_base_url="${dhv2_base_url%/}" + +fail() { + echo "ERROR: $*" >&2 + exit 1 +} + +echo "========== B1 · MIGRACIONES ==========" +dhv2_migration_status="$(docker compose --profile tools run --rm migrate npm run migration:show 2>&1)" +grep -q 'Pending migrations: no' <<<"$dhv2_migration_status" \ + || fail "Quedaron migraciones pendientes" +echo "Migraciones pendientes: no" + +echo +echo "========== B1 · HEALTH ==========" +dhv2_internal="$(curl -fsS --connect-timeout 10 --max-time 30 http://127.0.0.1:3101/api/v3/health)" +dhv2_external="$(curl -fsS --connect-timeout 10 --max-time 30 "$dhv2_base_url/api/v3/health")" +grep -q '"version":"0.8.0"' <<<"$dhv2_internal" || fail "Health interno no informa v0.8.0" +grep -q '"phase":"B1"' <<<"$dhv2_internal" || fail "Health interno no informa B1" +grep -q '"version":"0.8.0"' <<<"$dhv2_external" || fail "Health externo no informa v0.8.0" +grep -q '"database":"ok"' <<<"$dhv2_external" || fail "La base no está saludable" +echo "$dhv2_external" + +echo +echo "========== B1 · RUTAS PROTEGIDAS ==========" +for dhv2_path in assets asset-types; do + dhv2_code="$(curl -sS --connect-timeout 10 --max-time 30 \ + -o /tmp/dhv2-b1-response.json -w '%{http_code}' \ + "$dhv2_base_url/api/v3/$dhv2_path")" + test "$dhv2_code" = "401" || fail "/api/v3/$dhv2_path devolvió HTTP $dhv2_code; se esperaba 401" + echo "/api/v3/$dhv2_path sin sesión: 401 OK" +done +rm -f /tmp/dhv2-b1-response.json + +echo +echo "========== B1 · ESQUEMA Y PERMISOS ==========" +dhv2_db_check="$(docker compose exec -T db sh -lc \ + 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -v ON_ERROR_STOP=1 -At' <<'SQL' +SELECT 'tables', COUNT(*) +FROM information_schema.tables +WHERE table_schema = 'public' + AND table_name IN ( + 'asset_types', + 'asset_type_parent_rules', + 'asset_attribute_definitions', + 'assets', + 'asset_attribute_values' + ); + +SELECT 'permissions', COUNT(*) +FROM permissions +WHERE code IN ( + 'asset_types.read', + 'asset_types.manage', + 'assets.read', + 'assets.create', + 'assets.update', + 'assets.change_status' +); + +SELECT 'admin_permissions', COUNT(*) +FROM role_permissions role_permission +INNER JOIN roles role ON role.id = role_permission.role_id +INNER JOIN permissions permission ON permission.id = role_permission.permission_id +WHERE role.code = 'admin' + AND permission.code IN ( + 'asset_types.read', + 'asset_types.manage', + 'assets.read', + 'assets.create', + 'assets.update', + 'assets.change_status' + ); +SQL +)" +grep -q 'tables|5' <<<"$dhv2_db_check" || fail "No se encontraron las cinco tablas de B1" +grep -q 'permissions|6' <<<"$dhv2_db_check" || fail "No se encontraron los seis permisos de B1" +grep -q 'admin_permissions|6' <<<"$dhv2_db_check" || fail "El rol admin no recibió todos los permisos de B1" +echo "$dhv2_db_check" + +echo +echo "========== B1 · FRONTEND ==========" +docker compose exec -T web sh -lc \ + 'grep -R "0.8.0" /usr/share/nginx/html/assets >/dev/null && grep -R "Base del Maestro de Activos" /usr/share/nginx/html/assets >/dev/null' +echo "Frontend y footer v0.8.0: OK" + +echo +echo "ACEPTACIÓN DE FASE B1: OK" diff --git a/scripts/phase-b2-acceptance.sh b/scripts/phase-b2-acceptance.sh new file mode 100755 index 0000000..5adf49d --- /dev/null +++ b/scripts/phase-b2-acceptance.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail + +dhv2_base_url="${DHV2_BASE_URL:-https://dhv2.korexlabs.com}" +dhv2_base_url="${dhv2_base_url%/}" + +fail() { + echo "ERROR: $*" >&2 + exit 1 +} + +echo "========== B2 · MIGRACIONES ==========" +dhv2_migration_status="$(docker compose --profile tools run --rm migrate npm run migration:show 2>&1)" +grep -q 'Pending migrations: no' <<<"$dhv2_migration_status" \ + || fail "Quedaron migraciones pendientes" +echo "Migraciones pendientes: no" + +echo +echo "========== B2 · HEALTH ==========" +dhv2_internal="$(curl -fsS --connect-timeout 10 --max-time 30 http://127.0.0.1:3101/api/v3/health)" +dhv2_external="$(curl -fsS --connect-timeout 10 --max-time 30 "$dhv2_base_url/api/v3/health")" +grep -q '"version":"0.9.0"' <<<"$dhv2_internal" || fail "Health interno no informa v0.9.0" +grep -q '"phase":"B2"' <<<"$dhv2_internal" || fail "Health interno no informa B2" +grep -q '"version":"0.9.0"' <<<"$dhv2_external" || fail "Health externo no informa v0.9.0" +grep -q '"database":"ok"' <<<"$dhv2_external" || fail "La base no está saludable" +echo "$dhv2_external" + +echo +echo "========== B2 · RUTAS PROTEGIDAS ==========" +for dhv2_path in map/assets assets; do + dhv2_code="$(curl -sS --connect-timeout 10 --max-time 30 \ + -o /tmp/dhv2-b2-response.json -w '%{http_code}' \ + "$dhv2_base_url/api/v3/$dhv2_path")" + test "$dhv2_code" = "401" || fail "/api/v3/$dhv2_path devolvió HTTP $dhv2_code; se esperaba 401" + echo "/api/v3/$dhv2_path sin sesión: 401 OK" +done +rm -f /tmp/dhv2-b2-response.json + +echo +echo "========== B2 · POSTGIS Y PERMISOS ==========" +dhv2_db_check="$(docker compose exec -T db sh -lc \ + 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -v ON_ERROR_STOP=1 -At' <<'SQL' +SELECT 'postgis', COUNT(*) +FROM pg_extension +WHERE extname = 'postgis'; + +SELECT 'geometry_table', COUNT(*) +FROM information_schema.tables +WHERE table_schema = 'public' + AND table_name = 'asset_geometries'; + +SELECT 'geometry_column', COUNT(*) +FROM geometry_columns +WHERE f_table_schema = 'public' + AND f_table_name = 'asset_geometries' + AND f_geometry_column = 'geometry' + AND srid = 4326; + +SELECT 'gist_index', COUNT(*) +FROM pg_indexes +WHERE schemaname = 'public' + AND tablename = 'asset_geometries' + AND indexname = 'idx_asset_geometries_geometry'; + +SELECT 'permission', COUNT(*) +FROM permissions +WHERE code = 'assets.update_geometry'; + +SELECT 'admin_permission', COUNT(*) +FROM role_permissions role_permission +INNER JOIN roles role ON role.id = role_permission.role_id +INNER JOIN permissions permission ON permission.id = role_permission.permission_id +WHERE role.code = 'admin' + AND permission.code = 'assets.update_geometry'; +SQL +)" +grep -q 'postgis|1' <<<"$dhv2_db_check" || fail "PostGIS no está disponible" +grep -q 'geometry_table|1' <<<"$dhv2_db_check" || fail "No se creó asset_geometries" +grep -q 'geometry_column|1' <<<"$dhv2_db_check" || fail "La geometría EPSG:4326 no fue registrada" +grep -q 'gist_index|1' <<<"$dhv2_db_check" || fail "No se encontró el índice espacial GIST" +grep -q 'permission|1' <<<"$dhv2_db_check" || fail "No se creó assets.update_geometry" +grep -q 'admin_permission|1' <<<"$dhv2_db_check" || fail "Admin no recibió assets.update_geometry" +echo "$dhv2_db_check" + +echo +echo "========== B2 · FRONTEND ==========" +docker compose exec -T web sh -lc \ + 'grep -R "0.9.0" /usr/share/nginx/html/assets >/dev/null && grep -R "Geometrías PostGIS y mapa operativo" /usr/share/nginx/html/assets >/dev/null && grep -R "Mapa de activos" /usr/share/nginx/html/assets >/dev/null' +echo "Mapa y footer v0.9.0: OK" + +echo +echo "ACEPTACIÓN DE FASE B2: OK" diff --git a/scripts/phase-b3-acceptance.sh b/scripts/phase-b3-acceptance.sh new file mode 100755 index 0000000..23094c8 --- /dev/null +++ b/scripts/phase-b3-acceptance.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail + +dhv2_base_url="${DHV2_BASE_URL:-https://dhv2.korexlabs.com}" +dhv2_base_url="${dhv2_base_url%/}" + +fail() { + echo "ERROR: $*" >&2 + exit 1 +} + +echo "========== B3 · MIGRACIONES ==========" +dhv2_migration_status="$(docker compose --profile tools run --rm migrate npm run migration:show 2>&1)" +grep -q 'Pending migrations: no' <<<"$dhv2_migration_status" \ + || fail "Quedaron migraciones pendientes" +echo "Migraciones pendientes: no" + +echo +echo "========== B3 · HEALTH ==========" +dhv2_internal="$(curl -fsS --connect-timeout 10 --max-time 30 http://127.0.0.1:3101/api/v3/health)" +dhv2_external="$(curl -fsS --connect-timeout 10 --max-time 30 "$dhv2_base_url/api/v3/health")" +grep -q '"version":"0.10.0"' <<<"$dhv2_internal" || fail "Health interno no informa v0.10.0" +grep -q '"phase":"B3"' <<<"$dhv2_internal" || fail "Health interno no informa B3" +grep -q '"version":"0.10.0"' <<<"$dhv2_external" || fail "Health externo no informa v0.10.0" +grep -q '"database":"ok"' <<<"$dhv2_external" || fail "La base no está saludable" +echo "$dhv2_external" + +echo +echo "========== B3 · RUTAS PROTEGIDAS ==========" +for dhv2_path in asset-versions assets; do + dhv2_code="$(curl -sS --connect-timeout 10 --max-time 30 \ + -o /tmp/dhv2-b3-response.json -w '%{http_code}' \ + "$dhv2_base_url/api/v3/$dhv2_path")" + test "$dhv2_code" = "401" || fail "/api/v3/$dhv2_path devolvió HTTP $dhv2_code; se esperaba 401" + echo "/api/v3/$dhv2_path sin sesión: 401 OK" +done +rm -f /tmp/dhv2-b3-response.json + +echo +echo "========== B3 · VERSIONADO Y PERMISOS ==========" +dhv2_db_check="$(docker compose exec -T db sh -lc \ + 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -v ON_ERROR_STOP=1 -v app_user="$APP_DB_USER" -At' <<'SQL' +SELECT 'versions_table', COUNT(*) +FROM information_schema.tables +WHERE table_schema = 'public' + AND table_name = 'asset_versions'; + +SELECT 'current_version_column', COUNT(*) +FROM information_schema.columns +WHERE table_schema = 'public' + AND table_name = 'assets' + AND column_name = 'current_version'; + +SELECT 'history_index', COUNT(*) +FROM pg_indexes +WHERE schemaname = 'public' + AND tablename = 'asset_versions' + AND indexname = 'idx_asset_versions_asset_number'; + +SELECT 'backfill_mismatches', COUNT(*) +FROM assets asset +WHERE asset.current_version < 1 + OR NOT EXISTS ( + SELECT 1 FROM asset_versions version + WHERE version.asset_id = asset.id + AND version.version_number = asset.current_version + ); + +SELECT 'permission', COUNT(*) +FROM permissions +WHERE code = 'assets.read_history'; + +SELECT 'role_permissions', COUNT(*) +FROM role_permissions role_permission +INNER JOIN roles role ON role.id = role_permission.role_id +INNER JOIN permissions permission ON permission.id = role_permission.permission_id +WHERE role.code IN ('admin', 'director', 'supervisor', 'inspector', 'auditor') + AND permission.code = 'assets.read_history'; + +SELECT 'app_select', has_table_privilege(:'app_user', 'asset_versions', 'SELECT'); +SELECT 'app_insert', has_table_privilege(:'app_user', 'asset_versions', 'INSERT'); +SELECT 'app_update', has_table_privilege(:'app_user', 'asset_versions', 'UPDATE'); +SELECT 'app_delete', has_table_privilege(:'app_user', 'asset_versions', 'DELETE'); +SQL +)" +grep -q 'versions_table|1' <<<"$dhv2_db_check" || fail "No se creó asset_versions" +grep -q 'current_version_column|1' <<<"$dhv2_db_check" || fail "assets no tiene current_version" +grep -q 'history_index|1' <<<"$dhv2_db_check" || fail "No se encontró el índice temporal" +grep -q 'backfill_mismatches|0' <<<"$dhv2_db_check" || fail "Hay activos sin versión vigente" +grep -q 'permission|1' <<<"$dhv2_db_check" || fail "No se creó assets.read_history" +grep -q 'role_permissions|5' <<<"$dhv2_db_check" || fail "Los cinco roles base no recibieron lectura histórica" +grep -q 'app_select|t' <<<"$dhv2_db_check" || fail "La API no puede consultar versiones" +grep -q 'app_insert|t' <<<"$dhv2_db_check" || fail "La API no puede crear versiones" +grep -q 'app_update|f' <<<"$dhv2_db_check" || fail "La API no debe poder modificar versiones" +grep -q 'app_delete|f' <<<"$dhv2_db_check" || fail "La API no debe poder borrar versiones" +echo "$dhv2_db_check" + +echo +echo "========== B3 · FRONTEND ==========" +docker compose exec -T web sh -lc \ + 'grep -R "0.10.0" /usr/share/nginx/html/assets >/dev/null && grep -R "Historial y versiones de activos" /usr/share/nginx/html/assets >/dev/null && grep -R "Historial de activos" /usr/share/nginx/html/assets >/dev/null && grep -R "Snapshot técnico exacto" /usr/share/nginx/html/assets >/dev/null' +echo "Historial y footer v0.10.0: OK" + +echo +echo "ACEPTACIÓN DE FASE B3: OK" diff --git a/scripts/phase-b4-acceptance.sh b/scripts/phase-b4-acceptance.sh new file mode 100755 index 0000000..5836074 --- /dev/null +++ b/scripts/phase-b4-acceptance.sh @@ -0,0 +1,132 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail + +dhv2_base_url="${DHV2_BASE_URL:-https://dhv2.korexlabs.com}" +dhv2_base_url="${dhv2_base_url%/}" +dhv2_nil_uuid="00000000-0000-4000-8000-000000000000" + +fail() { + echo "ERROR: $*" >&2 + exit 1 +} + +echo "========== B4 · MIGRACIONES ==========" +dhv2_migration_status="$(docker compose --profile tools run --rm migrate npm run migration:show 2>&1)" +grep -q 'Pending migrations: no' <<<"$dhv2_migration_status" \ + || fail "Quedaron migraciones pendientes" +echo "Migraciones pendientes: no" + +echo +echo "========== B4 · HEALTH ==========" +dhv2_internal="$(curl -fsS --connect-timeout 10 --max-time 30 http://127.0.0.1:3101/api/v3/health)" +dhv2_external="$(curl -fsS --connect-timeout 10 --max-time 30 "$dhv2_base_url/api/v3/health")" +grep -q '"version":"0.11.0"' <<<"$dhv2_internal" || fail "Health interno no informa v0.11.0" +grep -q '"phase":"B4"' <<<"$dhv2_internal" || fail "Health interno no informa B4" +grep -q '"version":"0.11.0"' <<<"$dhv2_external" || fail "Health externo no informa v0.11.0" +grep -q '"database":"ok"' <<<"$dhv2_external" || fail "La base no está saludable" +echo "$dhv2_external" + +echo +echo "========== B4 · RUTAS PROTEGIDAS ==========" +for dhv2_path in \ + "assets/$dhv2_nil_uuid/media" \ + "asset-media/$dhv2_nil_uuid/content"; do + dhv2_code="$(curl -sS --connect-timeout 10 --max-time 30 \ + -o /tmp/dhv2-b4-response.json -w '%{http_code}' \ + "$dhv2_base_url/api/v3/$dhv2_path")" + test "$dhv2_code" = "401" || fail "/api/v3/$dhv2_path devolvió HTTP $dhv2_code; se esperaba 401" + echo "/api/v3/$dhv2_path sin sesión: 401 OK" +done +rm -f /tmp/dhv2-b4-response.json + +echo +echo "========== B4 · MODELO Y PERMISOS ==========" +dhv2_db_check="$(docker compose exec -T db sh -lc \ + 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -v ON_ERROR_STOP=1 -v app_user="$APP_DB_USER" -At' <<'SQL' +SELECT 'media_table', COUNT(*) +FROM information_schema.tables +WHERE table_schema = 'public' AND table_name = 'asset_media'; + +SELECT 'media_columns', COUNT(*) +FROM information_schema.columns +WHERE table_schema = 'public' + AND table_name = 'asset_media' + AND column_name IN ( + 'id', 'asset_id', 'kind', 'original_name', 'stored_name', 'mime_type', + 'size_bytes', 'sha256', 'title', 'description', 'captured_at', 'latitude', + 'longitude', 'accuracy_m', 'source', 'uploaded_by', 'created_at', + 'updated_at', 'deleted_at', 'deleted_by' + ); + +SELECT 'media_indexes', COUNT(*) +FROM pg_indexes +WHERE schemaname = 'public' + AND tablename = 'asset_media' + AND indexname IN ( + 'idx_asset_media_asset_created', + 'idx_asset_media_sha256', + 'idx_asset_media_active' + ); + +SELECT 'history_media_changes', COUNT(*) +FROM pg_constraint +WHERE conname = 'chk_asset_versions_change_type' + AND pg_get_constraintdef(oid) LIKE '%MEDIA_UPLOADED%' + AND pg_get_constraintdef(oid) LIKE '%MEDIA_UPDATED%' + AND pg_get_constraintdef(oid) LIKE '%MEDIA_REMOVED%'; + +SELECT 'permissions', COUNT(*) +FROM permissions +WHERE code IN ('assets.read_media', 'assets.manage_media'); + +SELECT 'read_roles', COUNT(*) +FROM role_permissions role_permission +INNER JOIN roles role ON role.id = role_permission.role_id +INNER JOIN permissions permission ON permission.id = role_permission.permission_id +WHERE role.code IN ('admin', 'director', 'supervisor', 'inspector', 'auditor') + AND permission.code = 'assets.read_media'; + +SELECT 'manage_roles', COUNT(*) +FROM role_permissions role_permission +INNER JOIN roles role ON role.id = role_permission.role_id +INNER JOIN permissions permission ON permission.id = role_permission.permission_id +WHERE role.code IN ('admin', 'supervisor', 'inspector') + AND permission.code = 'assets.manage_media'; + +SELECT 'app_select', has_table_privilege(:'app_user', 'asset_media', 'SELECT'); +SELECT 'app_insert', has_table_privilege(:'app_user', 'asset_media', 'INSERT'); +SELECT 'app_update', has_table_privilege(:'app_user', 'asset_media', 'UPDATE'); +SELECT 'app_delete', has_table_privilege(:'app_user', 'asset_media', 'DELETE'); +SQL +)" +grep -q 'media_table|1' <<<"$dhv2_db_check" || fail "No se creó asset_media" +grep -q 'media_columns|20' <<<"$dhv2_db_check" || fail "asset_media no tiene el modelo esperado" +grep -q 'media_indexes|3' <<<"$dhv2_db_check" || fail "Faltan índices de archivos" +grep -q 'history_media_changes|1' <<<"$dhv2_db_check" || fail "El historial no reconoce cambios de archivos" +grep -q 'permissions|2' <<<"$dhv2_db_check" || fail "No se crearon los dos permisos de archivos" +grep -q 'read_roles|5' <<<"$dhv2_db_check" || fail "Los cinco roles base no recibieron lectura de archivos" +grep -q 'manage_roles|3' <<<"$dhv2_db_check" || fail "Los tres roles operativos no recibieron gestión de archivos" +grep -q 'app_select|t' <<<"$dhv2_db_check" || fail "La API no puede consultar archivos" +grep -q 'app_insert|t' <<<"$dhv2_db_check" || fail "La API no puede registrar archivos" +grep -q 'app_update|t' <<<"$dhv2_db_check" || fail "La API no puede retirar archivos" +grep -q 'app_delete|f' <<<"$dhv2_db_check" || fail "La API no debe borrar físicamente registros de archivos" +echo "$dhv2_db_check" + +echo +echo "========== B4 · ALMACENAMIENTO ==========" +docker volume inspect dhv2_asset_media >/dev/null \ + || fail "No existe el volumen dhv2_asset_media" +docker compose exec -T api sh -lc \ + 'test "$ASSET_MEDIA_ROOT" = "/app/storage/asset-media" && test -d "$ASSET_MEDIA_ROOT" && test -w "$ASSET_MEDIA_ROOT"' \ + || fail "El almacenamiento de archivos no está montado y escribible" +echo "Volumen dhv2_asset_media montado y escribible: OK" + +echo +echo "========== B4 · FRONTEND ==========" +docker compose exec -T web sh -lc \ + 'grep -R "0.11.0" /usr/share/nginx/html/assets >/dev/null && grep -R "Fotografías y documentos de activos" /usr/share/nginx/html/assets >/dev/null && grep -R "Fotografías y documentos" /usr/share/nginx/html/assets >/dev/null && grep -R "Incorporar archivo" /usr/share/nginx/html/assets >/dev/null' +echo "Galería y footer v0.11.0: OK" + +echo +echo "ACEPTACIÓN DE FASE B4: OK" diff --git a/scripts/phase-b5-acceptance.sh b/scripts/phase-b5-acceptance.sh new file mode 100755 index 0000000..bb85b1e --- /dev/null +++ b/scripts/phase-b5-acceptance.sh @@ -0,0 +1,144 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail + +dhv2_base_url="${DHV2_BASE_URL:-https://dhv2.korexlabs.com}" +dhv2_base_url="${dhv2_base_url%/}" +dhv2_nil_uuid="00000000-0000-4000-8000-000000000000" +dhv2_now="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + +fail() { + echo "ERROR: $*" >&2 + exit 1 +} + +echo "========== B5 · MIGRACIONES ==========" +dhv2_migration_status="$(docker compose --profile tools run --rm migrate npm run migration:show 2>&1)" +grep -q 'Pending migrations: no' <<<"$dhv2_migration_status" \ + || fail "Quedaron migraciones pendientes" +echo "Migraciones pendientes: no" + +echo +echo "========== B5 · HEALTH ==========" +dhv2_internal="$(curl -fsS --connect-timeout 10 --max-time 30 http://127.0.0.1:3101/api/v3/health)" +dhv2_external="$(curl -fsS --connect-timeout 10 --max-time 30 "$dhv2_base_url/api/v3/health")" +grep -q '"version":"0.12.0"' <<<"$dhv2_internal" || fail "Health interno no informa v0.12.0" +grep -q '"phase":"B5"' <<<"$dhv2_internal" || fail "Health interno no informa B5" +grep -q '"version":"0.12.0"' <<<"$dhv2_external" || fail "Health externo no informa v0.12.0" +grep -q '"database":"ok"' <<<"$dhv2_external" || fail "La base no está saludable" +echo "$dhv2_external" + +echo +echo "========== B5 · RUTAS PROTEGIDAS ==========" +for dhv2_path in \ + "assets/$dhv2_nil_uuid/provenance" \ + "temporal-assets?at=$dhv2_now"; do + dhv2_code="$(curl -sS --connect-timeout 10 --max-time 30 \ + -o /tmp/dhv2-b5-response.json -w '%{http_code}' \ + "$dhv2_base_url/api/v3/$dhv2_path")" + test "$dhv2_code" = "401" || fail "/api/v3/$dhv2_path devolvió HTTP $dhv2_code; se esperaba 401" + echo "/api/v3/$dhv2_path sin sesión: 401 OK" +done +rm -f /tmp/dhv2-b5-response.json + +echo +echo "========== B5 · PROCEDENCIA Y TEMPORALIDAD ==========" +dhv2_db_check="$(docker compose exec -T db sh -lc \ + 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -v ON_ERROR_STOP=1 -v app_user="$APP_DB_USER" -At' <<'SQL' +SELECT 'provenance_columns', COUNT(*) +FROM information_schema.columns +WHERE table_schema = 'public' + AND table_name = 'assets' + AND column_name IN ( + 'data_origin', 'source_name', 'source_reference', 'source_observed_at', + 'source_notes', 'provenance_verified_at', 'provenance_verified_by', + 'provenance_updated_at', 'provenance_updated_by' + ); + +SELECT 'provenance_indexes', COUNT(*) +FROM pg_indexes +WHERE schemaname = 'public' + AND tablename = 'assets' + AND indexname IN ( + 'idx_assets_data_origin', 'idx_assets_provenance_verified_at' + ); + +SELECT 'history_provenance_changes', COUNT(*) +FROM pg_constraint +WHERE conname = 'chk_asset_versions_change_type' + AND pg_get_constraintdef(oid) LIKE '%PROVENANCE_BASELINE%' + AND pg_get_constraintdef(oid) LIKE '%PROVENANCE_UPDATED%' + AND pg_get_constraintdef(oid) LIKE '%PROVENANCE_VERIFIED%'; + +SELECT 'current_snapshot_mismatches', COUNT(*) +FROM assets asset +WHERE NOT EXISTS ( + SELECT 1 + FROM asset_versions version + WHERE version.asset_id = asset.id + AND version.version_number = asset.current_version + AND version.snapshot ? 'provenance' +); + +SELECT 'permissions', COUNT(*) +FROM permissions +WHERE code IN ( + 'assets.read_provenance', 'assets.manage_provenance', + 'assets.verify_provenance', 'assets.read_temporal' +); + +SELECT 'read_provenance_roles', COUNT(*) +FROM role_permissions role_permission +INNER JOIN roles role ON role.id = role_permission.role_id +INNER JOIN permissions permission ON permission.id = role_permission.permission_id +WHERE role.code IN ('admin', 'director', 'supervisor', 'inspector', 'auditor') + AND permission.code = 'assets.read_provenance'; + +SELECT 'read_temporal_roles', COUNT(*) +FROM role_permissions role_permission +INNER JOIN roles role ON role.id = role_permission.role_id +INNER JOIN permissions permission ON permission.id = role_permission.permission_id +WHERE role.code IN ('admin', 'director', 'supervisor', 'inspector', 'auditor') + AND permission.code = 'assets.read_temporal'; + +SELECT 'manage_provenance_roles', COUNT(*) +FROM role_permissions role_permission +INNER JOIN roles role ON role.id = role_permission.role_id +INNER JOIN permissions permission ON permission.id = role_permission.permission_id +WHERE role.code IN ('admin', 'supervisor', 'inspector') + AND permission.code = 'assets.manage_provenance'; + +SELECT 'verify_provenance_roles', COUNT(*) +FROM role_permissions role_permission +INNER JOIN roles role ON role.id = role_permission.role_id +INNER JOIN permissions permission ON permission.id = role_permission.permission_id +WHERE role.code IN ('admin', 'director', 'supervisor') + AND permission.code = 'assets.verify_provenance'; + +SELECT 'app_assets_update', has_table_privilege(:'app_user', 'assets', 'UPDATE'); +SELECT 'app_versions_update', has_table_privilege(:'app_user', 'asset_versions', 'UPDATE'); +SELECT 'app_versions_delete', has_table_privilege(:'app_user', 'asset_versions', 'DELETE'); +SQL +)" +grep -q 'provenance_columns|9' <<<"$dhv2_db_check" || fail "Faltan columnas de procedencia" +grep -q 'provenance_indexes|2' <<<"$dhv2_db_check" || fail "Faltan índices de procedencia" +grep -q 'history_provenance_changes|1' <<<"$dhv2_db_check" || fail "El historial no reconoce cambios de procedencia" +grep -q 'current_snapshot_mismatches|0' <<<"$dhv2_db_check" || fail "Hay activos cuya versión vigente no contiene procedencia" +grep -q 'permissions|4' <<<"$dhv2_db_check" || fail "No se crearon los cuatro permisos B5" +grep -q 'read_provenance_roles|5' <<<"$dhv2_db_check" || fail "Los cinco roles base no recibieron lectura de procedencia" +grep -q 'read_temporal_roles|5' <<<"$dhv2_db_check" || fail "Los cinco roles base no recibieron consulta temporal" +grep -q 'manage_provenance_roles|3' <<<"$dhv2_db_check" || fail "Los tres roles operativos no recibieron gestión de procedencia" +grep -q 'verify_provenance_roles|3' <<<"$dhv2_db_check" || fail "Los tres roles de control no recibieron verificación" +grep -q 'app_assets_update|t' <<<"$dhv2_db_check" || fail "La API no puede actualizar procedencia" +grep -q 'app_versions_update|f' <<<"$dhv2_db_check" || fail "La API no debe modificar versiones históricas" +grep -q 'app_versions_delete|f' <<<"$dhv2_db_check" || fail "La API no debe borrar versiones históricas" +echo "$dhv2_db_check" + +echo +echo "========== B5 · FRONTEND ==========" +docker compose exec -T web sh -lc \ + 'grep -R "0.12.0" /usr/share/nginx/html/assets >/dev/null && grep -R "Procedencia y consulta temporal" /usr/share/nginx/html/assets >/dev/null && grep -R "Procedencia de datos" /usr/share/nginx/html/assets >/dev/null && grep -R "Consulta temporal" /usr/share/nginx/html/assets >/dev/null && grep -R "Reconstruir" /usr/share/nginx/html/assets >/dev/null' +echo "Procedencia, consulta temporal y footer v0.12.0: OK" + +echo +echo "ACEPTACIÓN DE FASE B5: OK" diff --git a/scripts/phase-c1-acceptance.sh b/scripts/phase-c1-acceptance.sh new file mode 100755 index 0000000..66d6a22 --- /dev/null +++ b/scripts/phase-c1-acceptance.sh @@ -0,0 +1,127 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail + +dhv2_base_url="${DHV2_BASE_URL:-https://dhv2.korexlabs.com}" +dhv2_base_url="${dhv2_base_url%/}" +dhv2_nil_uuid="00000000-0000-4000-8000-000000000000" + +fail() { + echo "ERROR: $*" >&2 + exit 1 +} + +echo "========== C1 · MIGRACIONES ==========" +dhv2_migration_status="$(docker compose --profile tools run --rm migrate npm run migration:show 2>&1)" +grep -q 'Pending migrations: no' <<<"$dhv2_migration_status" \ + || fail "Quedaron migraciones pendientes" +echo "Migraciones pendientes: no" + +echo +echo "========== C1 · HEALTH ==========" +dhv2_internal="$(curl -fsS --connect-timeout 10 --max-time 30 http://127.0.0.1:3101/api/v3/health)" +dhv2_external="$(curl -fsS --connect-timeout 10 --max-time 30 "$dhv2_base_url/api/v3/health")" +grep -q '"version":"0.13.0"' <<<"$dhv2_internal" || fail "Health interno no informa v0.13.0" +grep -q '"phase":"C1"' <<<"$dhv2_internal" || fail "Health interno no informa C1" +grep -q '"version":"0.13.0"' <<<"$dhv2_external" || fail "Health externo no informa v0.13.0" +grep -q '"database":"ok"' <<<"$dhv2_external" || fail "La base no está saludable" +echo "$dhv2_external" + +echo +echo "========== C1 · RUTAS PROTEGIDAS ==========" +for dhv2_path in \ + "survey-campaigns" \ + "survey-campaigns/$dhv2_nil_uuid" \ + "survey-campaigns/assignees"; do + dhv2_code="$(curl -sS --connect-timeout 10 --max-time 30 \ + -o /tmp/dhv2-c1-response.json -w '%{http_code}' \ + "$dhv2_base_url/api/v3/$dhv2_path")" + test "$dhv2_code" = "401" || fail "/api/v3/$dhv2_path devolvió HTTP $dhv2_code; se esperaba 401" + echo "/api/v3/$dhv2_path sin sesión: 401 OK" +done +rm -f /tmp/dhv2-c1-response.json + +echo +echo "========== C1 · MODELO Y PERMISOS ==========" +dhv2_db_check="$(docker compose exec -T db sh -lc \ + 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -v ON_ERROR_STOP=1 -v app_user="$APP_DB_USER" -At' <<'SQL' +SELECT 'tables', COUNT(*) +FROM information_schema.tables +WHERE table_schema = 'public' + AND table_name IN ('survey_campaigns', 'survey_campaign_targets'); + +SELECT 'status_checks', COUNT(*) +FROM pg_constraint +WHERE conname IN ('chk_survey_campaigns_status', 'chk_survey_campaign_targets_status'); + +SELECT 'target_identity', COUNT(*) +FROM pg_constraint +WHERE conname = 'uq_survey_campaign_targets_campaign_asset'; + +SELECT 'asset_references', COUNT(*) +FROM pg_constraint +WHERE conname IN ('fk_survey_campaigns_scope_asset', 'fk_survey_campaign_targets_asset'); + +SELECT 'permissions', COUNT(*) +FROM permissions +WHERE code IN ('surveys.read', 'surveys.manage', 'surveys.assign', 'surveys.execute'); + +SELECT 'read_roles', COUNT(*) +FROM role_permissions role_permission +INNER JOIN roles role ON role.id = role_permission.role_id +INNER JOIN permissions permission ON permission.id = role_permission.permission_id +WHERE role.code IN ('admin', 'director', 'supervisor', 'inspector', 'auditor') + AND permission.code = 'surveys.read'; + +SELECT 'manage_roles', COUNT(*) +FROM role_permissions role_permission +INNER JOIN roles role ON role.id = role_permission.role_id +INNER JOIN permissions permission ON permission.id = role_permission.permission_id +WHERE role.code IN ('admin', 'supervisor') + AND permission.code = 'surveys.manage'; + +SELECT 'assign_roles', COUNT(*) +FROM role_permissions role_permission +INNER JOIN roles role ON role.id = role_permission.role_id +INNER JOIN permissions permission ON permission.id = role_permission.permission_id +WHERE role.code IN ('admin', 'supervisor') + AND permission.code = 'surveys.assign'; + +SELECT 'execute_roles', COUNT(*) +FROM role_permissions role_permission +INNER JOIN roles role ON role.id = role_permission.role_id +INNER JOIN permissions permission ON permission.id = role_permission.permission_id +WHERE role.code IN ('admin', 'supervisor', 'inspector') + AND permission.code = 'surveys.execute'; + +SELECT 'app_campaigns_select', has_table_privilege(:'app_user', 'survey_campaigns', 'SELECT'); +SELECT 'app_campaigns_update', has_table_privilege(:'app_user', 'survey_campaigns', 'UPDATE'); +SELECT 'app_campaigns_delete', has_table_privilege(:'app_user', 'survey_campaigns', 'DELETE'); +SELECT 'app_targets_update', has_table_privilege(:'app_user', 'survey_campaign_targets', 'UPDATE'); +SELECT 'app_targets_delete', has_table_privilege(:'app_user', 'survey_campaign_targets', 'DELETE'); +SQL +)" +grep -q 'tables|2' <<<"$dhv2_db_check" || fail "Faltan tablas de planificación" +grep -q 'status_checks|2' <<<"$dhv2_db_check" || fail "Faltan controles de estado" +grep -q 'target_identity|1' <<<"$dhv2_db_check" || fail "No se evita duplicar un activo en la campaña" +grep -q 'asset_references|2' <<<"$dhv2_db_check" || fail "La planificación no referencia correctamente al Maestro" +grep -q 'permissions|4' <<<"$dhv2_db_check" || fail "No se crearon los cuatro permisos C1" +grep -q 'read_roles|5' <<<"$dhv2_db_check" || fail "Los cinco roles base no recibieron lectura" +grep -q 'manage_roles|2' <<<"$dhv2_db_check" || fail "Admin y supervisor no recibieron gestión" +grep -q 'assign_roles|2' <<<"$dhv2_db_check" || fail "Admin y supervisor no recibieron asignación" +grep -q 'execute_roles|3' <<<"$dhv2_db_check" || fail "Faltan roles de ejecución" +grep -q 'app_campaigns_select|t' <<<"$dhv2_db_check" || fail "La API no puede leer campañas" +grep -q 'app_campaigns_update|t' <<<"$dhv2_db_check" || fail "La API no puede actualizar campañas" +grep -q 'app_campaigns_delete|f' <<<"$dhv2_db_check" || fail "La API no debe borrar campañas físicamente" +grep -q 'app_targets_update|t' <<<"$dhv2_db_check" || fail "La API no puede actualizar objetivos" +grep -q 'app_targets_delete|f' <<<"$dhv2_db_check" || fail "La API no debe borrar objetivos físicamente" +echo "$dhv2_db_check" + +echo +echo "========== C1 · FRONTEND ==========" +docker compose exec -T web sh -lc \ + 'grep -R "0.13.0" /usr/share/nginx/html/assets >/dev/null && grep -R "Planificación de relevamientos" /usr/share/nginx/html/assets >/dev/null && grep -R "Nueva campaña" /usr/share/nginx/html/assets >/dev/null && grep -R "Un único Maestro de Activos" /usr/share/nginx/html/assets >/dev/null' +echo "Campañas, Maestro único y footer v0.13.0: OK" + +echo +echo "ACEPTACIÓN DE FASE C1: OK" diff --git a/scripts/phase-c2-acceptance.sh b/scripts/phase-c2-acceptance.sh new file mode 100755 index 0000000..5eb681b --- /dev/null +++ b/scripts/phase-c2-acceptance.sh @@ -0,0 +1,133 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail + +dhv2_base_url="${DHV2_BASE_URL:-https://dhv2.korexlabs.com}" +dhv2_base_url="${dhv2_base_url%/}" +dhv2_nil_uuid="00000000-0000-4000-8000-000000000000" + +fail() { + echo "ERROR: $*" >&2 + exit 1 +} + +echo "========== C2 · MIGRACIONES ==========" +dhv2_migration_status="$(docker compose --profile tools run --rm migrate npm run migration:show 2>&1)" +grep -q 'Pending migrations: no' <<<"$dhv2_migration_status" \ + || fail "Quedaron migraciones pendientes" +echo "Migraciones pendientes: no" + +echo +echo "========== C2 · HEALTH ==========" +dhv2_internal="$(curl -fsS --connect-timeout 10 --max-time 30 http://127.0.0.1:3101/api/v3/health)" +dhv2_external="$(curl -fsS --connect-timeout 10 --max-time 30 "$dhv2_base_url/api/v3/health")" +grep -q '"version":"0.14.0"' <<<"$dhv2_internal" || fail "Health interno no informa v0.14.0" +grep -q '"phase":"C2"' <<<"$dhv2_internal" || fail "Health interno no informa C2" +grep -q '"version":"0.14.0"' <<<"$dhv2_external" || fail "Health externo no informa v0.14.0" +grep -q '"database":"ok"' <<<"$dhv2_external" || fail "La base no está saludable" +echo "$dhv2_external" + +echo +echo "========== C2 · RUTA PROTEGIDA ==========" +dhv2_code="$(curl -sS --connect-timeout 10 --max-time 30 \ + -o /tmp/dhv2-c2-response.json -w '%{http_code}' \ + "$dhv2_base_url/api/v3/survey-campaign-targets/$dhv2_nil_uuid/report")" +test "$dhv2_code" = "401" \ + || fail "/api/v3/survey-campaign-targets/:id/report devolvió HTTP $dhv2_code; se esperaba 401" +rm -f /tmp/dhv2-c2-response.json +echo "/api/v3/survey-campaign-targets/:id/report sin sesión: 401 OK" + +echo +echo "========== C2 · MODELO Y PERMISOS ==========" +dhv2_db_check="$(docker compose exec -T db sh -lc \ + 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -v ON_ERROR_STOP=1 -v app_user="$APP_DB_USER" -At' <<'SQL' +SELECT 'tables', COUNT(*) +FROM information_schema.tables +WHERE table_schema = 'public' + AND table_name IN ( + 'survey_target_reports', + 'survey_target_report_media', + 'survey_target_report_versions' + ); + +SELECT 'submitted_status', COUNT(*) +FROM pg_constraint +WHERE conname = 'chk_survey_campaign_targets_status' + AND pg_get_constraintdef(oid) LIKE '%SUBMITTED%'; + +SELECT 'report_checks', COUNT(*) +FROM pg_constraint +WHERE conname IN ( + 'chk_survey_target_reports_outcome', + 'chk_survey_target_reports_status', + 'chk_survey_target_reports_coordinates', + 'chk_survey_target_reports_accuracy', + 'chk_survey_target_report_versions_event' +); + +SELECT 'report_per_target', COUNT(*) +FROM pg_constraint +WHERE conname = 'uq_survey_target_reports_target_id'; + +SELECT 'immutable_version_identity', COUNT(*) +FROM pg_constraint +WHERE conname = 'uq_survey_target_report_versions_number'; + +SELECT 'permissions', COUNT(*) +FROM permissions +WHERE code IN ('surveys.read_reports', 'surveys.capture', 'surveys.review'); + +SELECT 'read_roles', COUNT(*) +FROM role_permissions role_permission +INNER JOIN roles role ON role.id = role_permission.role_id +INNER JOIN permissions permission ON permission.id = role_permission.permission_id +WHERE role.code IN ('admin', 'director', 'supervisor', 'inspector', 'auditor') + AND permission.code = 'surveys.read_reports'; + +SELECT 'capture_roles', COUNT(*) +FROM role_permissions role_permission +INNER JOIN roles role ON role.id = role_permission.role_id +INNER JOIN permissions permission ON permission.id = role_permission.permission_id +WHERE role.code IN ('admin', 'supervisor', 'inspector') + AND permission.code = 'surveys.capture'; + +SELECT 'review_roles', COUNT(*) +FROM role_permissions role_permission +INNER JOIN roles role ON role.id = role_permission.role_id +INNER JOIN permissions permission ON permission.id = role_permission.permission_id +WHERE role.code IN ('admin', 'director', 'supervisor') + AND permission.code = 'surveys.review'; + +SELECT 'app_reports_select', has_table_privilege(:'app_user', 'survey_target_reports', 'SELECT'); +SELECT 'app_reports_update', has_table_privilege(:'app_user', 'survey_target_reports', 'UPDATE'); +SELECT 'app_reports_delete', has_table_privilege(:'app_user', 'survey_target_reports', 'DELETE'); +SELECT 'app_versions_insert', has_table_privilege(:'app_user', 'survey_target_report_versions', 'INSERT'); +SELECT 'app_versions_update', has_table_privilege(:'app_user', 'survey_target_report_versions', 'UPDATE'); +SELECT 'app_versions_delete', has_table_privilege(:'app_user', 'survey_target_report_versions', 'DELETE'); +SQL +)" +grep -q 'tables|3' <<<"$dhv2_db_check" || fail "Faltan tablas de ejecución" +grep -q 'submitted_status|1' <<<"$dhv2_db_check" || fail "El objetivo no admite el estado de revisión" +grep -q 'report_checks|5' <<<"$dhv2_db_check" || fail "Faltan controles de integridad" +grep -q 'report_per_target|1' <<<"$dhv2_db_check" || fail "No se garantiza un informe actual por objetivo" +grep -q 'immutable_version_identity|1' <<<"$dhv2_db_check" || fail "No se garantiza la identidad de versiones" +grep -q 'permissions|3' <<<"$dhv2_db_check" || fail "No se crearon los tres permisos C2" +grep -q 'read_roles|5' <<<"$dhv2_db_check" || fail "Faltan roles de lectura" +grep -q 'capture_roles|3' <<<"$dhv2_db_check" || fail "Faltan roles de captura" +grep -q 'review_roles|3' <<<"$dhv2_db_check" || fail "Faltan roles de revisión" +grep -q 'app_reports_select|t' <<<"$dhv2_db_check" || fail "La API no puede leer informes" +grep -q 'app_reports_update|t' <<<"$dhv2_db_check" || fail "La API no puede actualizar informes" +grep -q 'app_reports_delete|f' <<<"$dhv2_db_check" || fail "La API no debe borrar informes físicamente" +grep -q 'app_versions_insert|t' <<<"$dhv2_db_check" || fail "La API no puede congelar versiones" +grep -q 'app_versions_update|f' <<<"$dhv2_db_check" || fail "La API no debe alterar versiones congeladas" +grep -q 'app_versions_delete|f' <<<"$dhv2_db_check" || fail "La API no debe borrar versiones congeladas" +echo "$dhv2_db_check" + +echo +echo "========== C2 · FRONTEND ==========" +docker compose exec -T web sh -lc \ + 'grep -R "0.14.0" /usr/share/nginx/html/assets >/dev/null && grep -R "Ejecución y validación de relevamientos" /usr/share/nginx/html/assets >/dev/null && grep -R "Informe de campo" /usr/share/nginx/html/assets >/dev/null && grep -R "Capturar GPS actual" /usr/share/nginx/html/assets >/dev/null && grep -R "VERSIONES INMUTABLES" /usr/share/nginx/html/assets >/dev/null' +echo "Ejecución, GPS, versiones inmutables y footer v0.14.0: OK" + +echo +echo "ACEPTACIÓN DE FASE C2: OK" diff --git a/scripts/phase-d1-acceptance.sh b/scripts/phase-d1-acceptance.sh new file mode 100755 index 0000000..cccd7ba --- /dev/null +++ b/scripts/phase-d1-acceptance.sh @@ -0,0 +1,160 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail + +dhv2_base_url="${DHV2_BASE_URL:-https://dhv2.korexlabs.com}" +dhv2_base_url="${dhv2_base_url%/}" +dhv2_nil_uuid="00000000-0000-4000-8000-000000000000" + +fail() { + echo "ERROR: $*" >&2 + exit 1 +} + +echo "========== D1 · MIGRACIONES ==========" +dhv2_migration_status="$(docker compose --profile tools run --rm migrate npm run migration:show 2>&1)" +grep -q 'Pending migrations: no' <<<"$dhv2_migration_status" \ + || fail "Quedaron migraciones pendientes" +echo "Migraciones pendientes: no" + +echo +echo "========== D1 · HEALTH ==========" +dhv2_internal="$(curl -fsS --connect-timeout 10 --max-time 30 http://127.0.0.1:3101/api/v3/health)" +dhv2_external="$(curl -fsS --connect-timeout 10 --max-time 30 "$dhv2_base_url/api/v3/health")" +grep -q '"version":"0.15.0"' <<<"$dhv2_internal" || fail "Health interno no informa v0.15.0" +grep -q '"phase":"D1"' <<<"$dhv2_internal" || fail "Health interno no informa D1" +grep -q '"version":"0.15.0"' <<<"$dhv2_external" || fail "Health externo no informa v0.15.0" +grep -q '"database":"ok"' <<<"$dhv2_external" || fail "La base no está saludable" +echo "$dhv2_external" + +echo +echo "========== D1 · RUTAS PROTEGIDAS ==========" +for dhv2_path in \ + "inspection-visits" \ + "inspection-visits/$dhv2_nil_uuid" \ + "inspection-visits/assignees"; do + dhv2_code="$(curl -sS --connect-timeout 10 --max-time 30 \ + -o /tmp/dhv2-d1-response.json -w '%{http_code}' \ + "$dhv2_base_url/api/v3/$dhv2_path")" + test "$dhv2_code" = "401" \ + || fail "/api/v3/$dhv2_path devolvió HTTP $dhv2_code; se esperaba 401" + echo "/api/v3/$dhv2_path sin sesión: 401 OK" +done +rm -f /tmp/dhv2-d1-response.json + +echo +echo "========== D1 · MODELO Y PERMISOS ==========" +dhv2_db_check="$(docker compose exec -T db sh -lc \ + 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -v ON_ERROR_STOP=1 -v app_user="$APP_DB_USER" -At' <<'SQL' +SELECT 'tables', COUNT(*) +FROM information_schema.tables +WHERE table_schema = 'public' + AND table_name IN ( + 'inspection_visits', + 'inspection_visit_assets', + 'inspection_visit_members' + ); + +SELECT 'visit_checks', COUNT(*) +FROM pg_constraint +WHERE conname IN ( + 'chk_inspection_visits_code', + 'chk_inspection_visits_status', + 'chk_inspection_visits_planned_dates', + 'chk_inspection_visits_actual_dates', + 'chk_inspection_visits_cancellation' +); + +SELECT 'closed_reserved', COUNT(*) +FROM pg_constraint +WHERE conname = 'chk_inspection_visits_status' + AND pg_get_constraintdef(oid) LIKE '%CLOSED%'; + +SELECT 'visit_identity', COUNT(*) +FROM pg_constraint +WHERE conname = 'uq_inspection_visits_code'; + +SELECT 'asset_references', COUNT(*) +FROM pg_constraint +WHERE conname IN ( + 'fk_inspection_visits_scope_asset', + 'fk_inspection_visit_assets_asset' +); + +SELECT 'team_references', COUNT(*) +FROM pg_constraint +WHERE conname IN ( + 'fk_inspection_visits_lead_inspector', + 'fk_inspection_visit_members_user' +); + +SELECT 'permissions', COUNT(*) +FROM permissions +WHERE code IN ( + 'inspections.read', + 'inspections.manage', + 'inspections.assign', + 'inspections.execute' +); + +SELECT 'read_roles', COUNT(*) +FROM role_permissions role_permission +INNER JOIN roles role ON role.id = role_permission.role_id +INNER JOIN permissions permission ON permission.id = role_permission.permission_id +WHERE role.code IN ('admin', 'director', 'supervisor', 'inspector', 'auditor') + AND permission.code = 'inspections.read'; + +SELECT 'manage_roles', COUNT(*) +FROM role_permissions role_permission +INNER JOIN roles role ON role.id = role_permission.role_id +INNER JOIN permissions permission ON permission.id = role_permission.permission_id +WHERE role.code IN ('admin', 'supervisor') + AND permission.code = 'inspections.manage'; + +SELECT 'assign_roles', COUNT(*) +FROM role_permissions role_permission +INNER JOIN roles role ON role.id = role_permission.role_id +INNER JOIN permissions permission ON permission.id = role_permission.permission_id +WHERE role.code IN ('admin', 'supervisor') + AND permission.code = 'inspections.assign'; + +SELECT 'execute_roles', COUNT(*) +FROM role_permissions role_permission +INNER JOIN roles role ON role.id = role_permission.role_id +INNER JOIN permissions permission ON permission.id = role_permission.permission_id +WHERE role.code IN ('admin', 'supervisor', 'inspector') + AND permission.code = 'inspections.execute'; + +SELECT 'app_visits_select', has_table_privilege(:'app_user', 'inspection_visits', 'SELECT'); +SELECT 'app_visits_update', has_table_privilege(:'app_user', 'inspection_visits', 'UPDATE'); +SELECT 'app_visits_delete', has_table_privilege(:'app_user', 'inspection_visits', 'DELETE'); +SELECT 'app_assets_delete', has_table_privilege(:'app_user', 'inspection_visit_assets', 'DELETE'); +SELECT 'app_members_delete', has_table_privilege(:'app_user', 'inspection_visit_members', 'DELETE'); +SQL +)" +grep -q 'tables|3' <<<"$dhv2_db_check" || fail "Faltan tablas de visitas" +grep -q 'visit_checks|5' <<<"$dhv2_db_check" || fail "Faltan controles de integridad" +grep -q 'closed_reserved|1' <<<"$dhv2_db_check" || fail "No quedó reservado el cierre documental" +grep -q 'visit_identity|1' <<<"$dhv2_db_check" || fail "No se garantiza la identidad de visita" +grep -q 'asset_references|2' <<<"$dhv2_db_check" || fail "La visita no referencia correctamente al Maestro" +grep -q 'team_references|2' <<<"$dhv2_db_check" || fail "El equipo no referencia usuarios existentes" +grep -q 'permissions|4' <<<"$dhv2_db_check" || fail "No se crearon los cuatro permisos D1" +grep -q 'read_roles|5' <<<"$dhv2_db_check" || fail "Faltan roles de lectura" +grep -q 'manage_roles|2' <<<"$dhv2_db_check" || fail "Faltan roles de gestión" +grep -q 'assign_roles|2' <<<"$dhv2_db_check" || fail "Faltan roles de asignación" +grep -q 'execute_roles|3' <<<"$dhv2_db_check" || fail "Faltan roles de ejecución" +grep -q 'app_visits_select|t' <<<"$dhv2_db_check" || fail "La API no puede leer visitas" +grep -q 'app_visits_update|t' <<<"$dhv2_db_check" || fail "La API no puede actualizar visitas" +grep -q 'app_visits_delete|f' <<<"$dhv2_db_check" || fail "La API no debe borrar visitas físicamente" +grep -q 'app_assets_delete|f' <<<"$dhv2_db_check" || fail "La API no debe borrar vínculos a activos" +grep -q 'app_members_delete|f' <<<"$dhv2_db_check" || fail "La API no debe borrar vínculos de equipo" +echo "$dhv2_db_check" + +echo +echo "========== D1 · FRONTEND ==========" +docker compose exec -T web sh -lc \ + 'grep -R "0.15.0" /usr/share/nginx/html/assets >/dev/null && grep -R "Planificación y apertura de visitas" /usr/share/nginx/html/assets >/dev/null && grep -R "Una inspección es una visita" /usr/share/nginx/html/assets >/dev/null && grep -R "Nueva visita" /usr/share/nginx/html/assets >/dev/null && grep -R "ACTIVOS A INSPECCIONAR" /usr/share/nginx/html/assets >/dev/null' +echo "Visitas, Maestro único, equipo y footer v0.15.0: OK" + +echo +echo "ACEPTACIÓN DE FASE D1: OK" diff --git a/scripts/phase-d2-1-acceptance.sh b/scripts/phase-d2-1-acceptance.sh new file mode 100755 index 0000000..15d3d25 --- /dev/null +++ b/scripts/phase-d2-1-acceptance.sh @@ -0,0 +1,138 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail + +dhv2_base_url="${DHV2_BASE_URL:-https://dhv2.korexlabs.com}" +dhv2_base_url="${dhv2_base_url%/}" +dhv2_nil_uuid="00000000-0000-4000-8000-000000000000" + +fail() { + echo "ERROR: $*" >&2 + exit 1 +} + +echo "========== D2.1 · MIGRACIONES ==========" +dhv2_migration_status="$(docker compose --profile tools run --rm migrate npm run migration:show 2>&1)" +grep -q 'Pending migrations: no' <<<"$dhv2_migration_status" \ + || fail "Quedaron migraciones pendientes" +echo "Migraciones pendientes: no" + +echo +echo "========== D2.1 · HEALTH ==========" +dhv2_internal="$(curl -fsS --connect-timeout 10 --max-time 30 http://127.0.0.1:3101/api/v3/health)" +dhv2_external="$(curl -fsS --connect-timeout 10 --max-time 30 "$dhv2_base_url/api/v3/health")" +grep -q '"version":"0.16.1"' <<<"$dhv2_internal" || fail "Health interno no informa v0.16.1" +grep -q '"phase":"D2.1"' <<<"$dhv2_internal" || fail "Health interno no informa D2.1" +grep -q '"version":"0.16.1"' <<<"$dhv2_external" || fail "Health externo no informa v0.16.1" +grep -q '"database":"ok"' <<<"$dhv2_external" || fail "La base no está saludable" +echo "$dhv2_external" + +echo +echo "========== D2.1 · RUTAS PROTEGIDAS ==========" +for dhv2_path in \ + "inspection-visits/$dhv2_nil_uuid/acts" \ + "inspection-acts/$dhv2_nil_uuid"; do + dhv2_code="$(curl -sS --connect-timeout 10 --max-time 30 \ + -o /tmp/dhv2-d2-1-response.json -w '%{http_code}' \ + "$dhv2_base_url/api/v3/$dhv2_path")" + test "$dhv2_code" = "401" \ + || fail "/api/v3/$dhv2_path devolvió HTTP $dhv2_code; se esperaba 401" + echo "/api/v3/$dhv2_path sin sesión: 401 OK" +done +rm -f /tmp/dhv2-d2-1-response.json + +echo +echo "========== D2.1 · UN ACTA POR VISITA ==========" +dhv2_db_check="$(docker compose exec -T db sh -lc \ + 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -v ON_ERROR_STOP=1 -At' <<'SQL' +SELECT 'unique_visit', COUNT(*) +FROM pg_constraint +WHERE conname = 'uq_inspection_acts_visit' + AND contype = 'u'; + +SELECT 'duplicate_visits', COUNT(*) +FROM ( + SELECT visit_id + FROM inspection_acts + GROUP BY visit_id + HAVING COUNT(*) > 1 +) duplicate; + +SELECT 'd2_tables', COUNT(*) +FROM information_schema.tables +WHERE table_schema = 'public' + AND table_name IN ( + 'document_annual_sequences', + 'inspection_acts', + 'inspection_act_assets', + 'inspection_act_versions' + ); + +SELECT 'permissions', COUNT(*) +FROM permissions +WHERE code IN ( + 'inspection_acts.read', + 'inspection_acts.create', + 'inspection_acts.update', + 'inspection_acts.cancel' +); + +SELECT 'read_roles', COUNT(*) +FROM role_permissions role_permission +INNER JOIN roles role ON role.id = role_permission.role_id +INNER JOIN permissions permission ON permission.id = role_permission.permission_id +WHERE role.code IN ('admin', 'director', 'supervisor', 'inspector', 'auditor') + AND permission.code = 'inspection_acts.read'; + +SELECT 'write_roles', COUNT(*) +FROM role_permissions role_permission +INNER JOIN roles role ON role.id = role_permission.role_id +INNER JOIN permissions permission ON permission.id = role_permission.permission_id +WHERE role.code IN ('admin', 'supervisor', 'inspector') + AND permission.code IN ('inspection_acts.create', 'inspection_acts.update'); + +SELECT 'cancel_roles', COUNT(*) +FROM role_permissions role_permission +INNER JOIN roles role ON role.id = role_permission.role_id +INNER JOIN permissions permission ON permission.id = role_permission.permission_id +WHERE role.code IN ('admin', 'supervisor') + AND permission.code = 'inspection_acts.cancel'; +SQL +)" +grep -q 'unique_visit|1' <<<"$dhv2_db_check" || fail "No se garantiza un acta por visita" +grep -q 'duplicate_visits|0' <<<"$dhv2_db_check" || fail "Existen visitas con más de un acta" +grep -q 'd2_tables|4' <<<"$dhv2_db_check" || fail "Faltan tablas base de actas" +grep -q 'permissions|4' <<<"$dhv2_db_check" || fail "Faltan permisos de actas" +grep -q 'read_roles|5' <<<"$dhv2_db_check" || fail "Faltan roles de lectura" +grep -q 'write_roles|6' <<<"$dhv2_db_check" || fail "Faltan roles de creación o edición" +grep -q 'cancel_roles|2' <<<"$dhv2_db_check" || fail "Faltan roles de cancelación" +echo "$dhv2_db_check" + +echo +echo "========== D2.1 · PRIVILEGIOS RUNTIME ==========" +dhv2_privileges="$(docker compose exec -T db sh -lc \ + 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -v ON_ERROR_STOP=1 -v app_user="$APP_DB_USER" -At' <<'SQL' +SELECT 'acts_select', has_table_privilege(:'app_user', 'inspection_acts', 'SELECT'); +SELECT 'acts_update', has_table_privilege(:'app_user', 'inspection_acts', 'UPDATE'); +SELECT 'acts_delete', has_table_privilege(:'app_user', 'inspection_acts', 'DELETE'); +SELECT 'versions_insert', has_table_privilege(:'app_user', 'inspection_act_versions', 'INSERT'); +SELECT 'versions_update', has_table_privilege(:'app_user', 'inspection_act_versions', 'UPDATE'); +SELECT 'versions_delete', has_table_privilege(:'app_user', 'inspection_act_versions', 'DELETE'); +SQL +)" +grep -q 'acts_select|t' <<<"$dhv2_privileges" || fail "La API no puede leer actas" +grep -q 'acts_update|t' <<<"$dhv2_privileges" || fail "La API no puede actualizar actas" +grep -q 'acts_delete|f' <<<"$dhv2_privileges" || fail "La API no debe borrar actas" +grep -q 'versions_insert|t' <<<"$dhv2_privileges" || fail "La API no puede crear versiones" +grep -q 'versions_update|f' <<<"$dhv2_privileges" || fail "La API no debe modificar versiones" +grep -q 'versions_delete|f' <<<"$dhv2_privileges" || fail "La API no debe borrar versiones" +echo "$dhv2_privileges" + +echo +echo "========== D2.1 · FRONTEND ==========" +docker compose exec -T web sh -lc \ + 'grep -R "0.16.1" /usr/share/nginx/html/assets >/dev/null && grep -R "Un acta por visita" /usr/share/nginx/html/assets >/dev/null && grep -R "Acta única" /usr/share/nginx/html/assets >/dev/null && grep -R "Crear acta" /usr/share/nginx/html/assets >/dev/null' +echo "Acta única y footer v0.16.1: OK" + +echo +echo "ACEPTACIÓN DE FASE D2.1: OK" diff --git a/scripts/phase-d2-acceptance.sh b/scripts/phase-d2-acceptance.sh new file mode 100755 index 0000000..adcb967 --- /dev/null +++ b/scripts/phase-d2-acceptance.sh @@ -0,0 +1,172 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail + +dhv2_base_url="${DHV2_BASE_URL:-https://dhv2.korexlabs.com}" +dhv2_base_url="${dhv2_base_url%/}" +dhv2_nil_uuid="00000000-0000-4000-8000-000000000000" + +fail() { + echo "ERROR: $*" >&2 + exit 1 +} + +echo "========== D2 · MIGRACIONES ==========" +dhv2_migration_status="$(docker compose --profile tools run --rm migrate npm run migration:show 2>&1)" +grep -q 'Pending migrations: no' <<<"$dhv2_migration_status" \ + || fail "Quedaron migraciones pendientes" +echo "Migraciones pendientes: no" + +echo +echo "========== D2 · HEALTH ==========" +dhv2_internal="$(curl -fsS --connect-timeout 10 --max-time 30 http://127.0.0.1:3101/api/v3/health)" +dhv2_external="$(curl -fsS --connect-timeout 10 --max-time 30 "$dhv2_base_url/api/v3/health")" +grep -q '"version":"0.16.0"' <<<"$dhv2_internal" || fail "Health interno no informa v0.16.0" +grep -q '"phase":"D2"' <<<"$dhv2_internal" || fail "Health interno no informa D2" +grep -q '"version":"0.16.0"' <<<"$dhv2_external" || fail "Health externo no informa v0.16.0" +grep -q '"database":"ok"' <<<"$dhv2_external" || fail "La base no está saludable" +echo "$dhv2_external" + +echo +echo "========== D2 · RUTAS PROTEGIDAS ==========" +for dhv2_path in \ + "inspection-visits/$dhv2_nil_uuid/acts" \ + "inspection-acts/$dhv2_nil_uuid"; do + dhv2_code="$(curl -sS --connect-timeout 10 --max-time 30 \ + -o /tmp/dhv2-d2-response.json -w '%{http_code}' \ + "$dhv2_base_url/api/v3/$dhv2_path")" + test "$dhv2_code" = "401" \ + || fail "/api/v3/$dhv2_path devolvió HTTP $dhv2_code; se esperaba 401" + echo "/api/v3/$dhv2_path sin sesión: 401 OK" +done +rm -f /tmp/dhv2-d2-response.json + +echo +echo "========== D2 · MODELO, ROLES Y PRIVILEGIOS ==========" +dhv2_db_check="$(docker compose exec -T db sh -lc \ + 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -v ON_ERROR_STOP=1 -v app_user="$APP_DB_USER" -At' <<'SQL' +SELECT 'tables', COUNT(*) +FROM information_schema.tables +WHERE table_schema = 'public' + AND table_name IN ( + 'document_annual_sequences', + 'inspection_acts', + 'inspection_act_assets', + 'inspection_act_versions' + ); + +SELECT 'act_checks', COUNT(*) +FROM pg_constraint +WHERE conname IN ( + 'chk_inspection_acts_year', + 'chk_inspection_acts_number', + 'chk_inspection_acts_code', + 'chk_inspection_acts_status', + 'chk_inspection_acts_current_version', + 'chk_inspection_acts_cancellation' +); + +SELECT 'annual_identity', COUNT(*) +FROM pg_constraint +WHERE conname IN ( + 'uq_inspection_acts_year_number', + 'uq_inspection_acts_code' +); + +SELECT 'reserved_states', COUNT(*) +FROM pg_constraint +WHERE conname = 'chk_inspection_acts_status' + AND pg_get_constraintdef(oid) LIKE '%READY%' + AND pg_get_constraintdef(oid) LIKE '%CLOSED%' + AND pg_get_constraintdef(oid) LIKE '%RECTIFIED%'; + +SELECT 'sequence_types', COUNT(*) +FROM pg_constraint +WHERE conname = 'chk_document_annual_sequences_type' + AND pg_get_constraintdef(oid) LIKE '%ACT%' + AND pg_get_constraintdef(oid) LIKE '%REPORT%'; + +SELECT 'immutable_versions', COUNT(*) +FROM pg_constraint +WHERE conname IN ( + 'uq_inspection_act_versions_number', + 'chk_inspection_act_versions_number', + 'chk_inspection_act_versions_event' +); + +SELECT 'permissions', COUNT(*) +FROM permissions +WHERE code IN ( + 'inspection_acts.read', + 'inspection_acts.create', + 'inspection_acts.update', + 'inspection_acts.cancel' +); + +SELECT 'read_roles', COUNT(*) +FROM role_permissions role_permission +INNER JOIN roles role ON role.id = role_permission.role_id +INNER JOIN permissions permission ON permission.id = role_permission.permission_id +WHERE role.code IN ('admin', 'director', 'supervisor', 'inspector', 'auditor') + AND permission.code = 'inspection_acts.read'; + +SELECT 'create_roles', COUNT(*) +FROM role_permissions role_permission +INNER JOIN roles role ON role.id = role_permission.role_id +INNER JOIN permissions permission ON permission.id = role_permission.permission_id +WHERE role.code IN ('admin', 'supervisor', 'inspector') + AND permission.code = 'inspection_acts.create'; + +SELECT 'update_roles', COUNT(*) +FROM role_permissions role_permission +INNER JOIN roles role ON role.id = role_permission.role_id +INNER JOIN permissions permission ON permission.id = role_permission.permission_id +WHERE role.code IN ('admin', 'supervisor', 'inspector') + AND permission.code = 'inspection_acts.update'; + +SELECT 'cancel_roles', COUNT(*) +FROM role_permissions role_permission +INNER JOIN roles role ON role.id = role_permission.role_id +INNER JOIN permissions permission ON permission.id = role_permission.permission_id +WHERE role.code IN ('admin', 'supervisor') + AND permission.code = 'inspection_acts.cancel'; + +SELECT 'app_acts_select', has_table_privilege(:'app_user', 'inspection_acts', 'SELECT'); +SELECT 'app_acts_update', has_table_privilege(:'app_user', 'inspection_acts', 'UPDATE'); +SELECT 'app_acts_delete', has_table_privilege(:'app_user', 'inspection_acts', 'DELETE'); +SELECT 'app_versions_insert', has_table_privilege(:'app_user', 'inspection_act_versions', 'INSERT'); +SELECT 'app_versions_update', has_table_privilege(:'app_user', 'inspection_act_versions', 'UPDATE'); +SELECT 'app_versions_delete', has_table_privilege(:'app_user', 'inspection_act_versions', 'DELETE'); +SELECT 'app_sequence_update', has_table_privilege(:'app_user', 'document_annual_sequences', 'UPDATE'); +SELECT 'app_sequence_delete', has_table_privilege(:'app_user', 'document_annual_sequences', 'DELETE'); +SQL +)" +grep -q 'tables|4' <<<"$dhv2_db_check" || fail "Faltan tablas D2" +grep -q 'act_checks|6' <<<"$dhv2_db_check" || fail "Faltan controles del acta" +grep -q 'annual_identity|2' <<<"$dhv2_db_check" || fail "No se garantiza la identidad anual" +grep -q 'reserved_states|1' <<<"$dhv2_db_check" || fail "Faltan estados documentales reservados" +grep -q 'sequence_types|1' <<<"$dhv2_db_check" || fail "La secuencia no reserva ACT y REPORT" +grep -q 'immutable_versions|3' <<<"$dhv2_db_check" || fail "Faltan controles de versiones" +grep -q 'permissions|4' <<<"$dhv2_db_check" || fail "No se crearon los cuatro permisos D2" +grep -q 'read_roles|5' <<<"$dhv2_db_check" || fail "Faltan roles de lectura" +grep -q 'create_roles|3' <<<"$dhv2_db_check" || fail "Faltan roles de creación" +grep -q 'update_roles|3' <<<"$dhv2_db_check" || fail "Faltan roles de actualización" +grep -q 'cancel_roles|2' <<<"$dhv2_db_check" || fail "Faltan roles de cancelación" +grep -q 'app_acts_select|t' <<<"$dhv2_db_check" || fail "La API no puede leer actas" +grep -q 'app_acts_update|t' <<<"$dhv2_db_check" || fail "La API no puede actualizar actas" +grep -q 'app_acts_delete|f' <<<"$dhv2_db_check" || fail "La API no debe borrar actas" +grep -q 'app_versions_insert|t' <<<"$dhv2_db_check" || fail "La API no puede registrar versiones" +grep -q 'app_versions_update|f' <<<"$dhv2_db_check" || fail "La API no debe modificar versiones" +grep -q 'app_versions_delete|f' <<<"$dhv2_db_check" || fail "La API no debe borrar versiones" +grep -q 'app_sequence_update|t' <<<"$dhv2_db_check" || fail "La API no puede incrementar la secuencia" +grep -q 'app_sequence_delete|f' <<<"$dhv2_db_check" || fail "La API no debe borrar secuencias" +echo "$dhv2_db_check" + +echo +echo "========== D2 · FRONTEND ==========" +docker compose exec -T web sh -lc \ + 'grep -R "0.16.0" /usr/share/nginx/html/assets >/dev/null && grep -R "Múltiples actas por visita" /usr/share/nginx/html/assets >/dev/null && grep -R "Nueva acta" /usr/share/nginx/html/assets >/dev/null && grep -R "VERSIONES INMUTABLES" /usr/share/nginx/html/assets >/dev/null && grep -R "Crear y numerar acta" /usr/share/nginx/html/assets >/dev/null' +echo "Actas múltiples, versiones y footer v0.16.0: OK" + +echo +echo "ACEPTACIÓN DE FASE D2: OK" diff --git a/scripts/phase-d3-acceptance.sh b/scripts/phase-d3-acceptance.sh new file mode 100755 index 0000000..d611beb --- /dev/null +++ b/scripts/phase-d3-acceptance.sh @@ -0,0 +1,198 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail + +dhv2_base_url="${DHV2_BASE_URL:-https://dhv2.korexlabs.com}" +dhv2_base_url="${dhv2_base_url%/}" +dhv2_nil_uuid="00000000-0000-4000-8000-000000000000" + +fail() { + echo "ERROR: $*" >&2 + exit 1 +} + +echo "========== D3 · MIGRACIONES ==========" +dhv2_migration_status="$(docker compose --profile tools run --rm migrate npm run migration:show 2>&1)" +grep -q 'Pending migrations: no' <<<"$dhv2_migration_status" \ + || fail "Quedaron migraciones pendientes" +echo "Migraciones pendientes: no" + +echo +echo "========== D3 · HEALTH ==========" +dhv2_internal="$(curl -fsS --connect-timeout 10 --max-time 30 http://127.0.0.1:3101/api/v3/health)" +dhv2_external="$(curl -fsS --connect-timeout 10 --max-time 30 "$dhv2_base_url/api/v3/health")" +grep -q '"version":"0.17.0"' <<<"$dhv2_internal" || fail "Health interno no informa v0.17.0" +grep -q '"phase":"D3"' <<<"$dhv2_internal" || fail "Health interno no informa D3" +grep -q '"version":"0.17.0"' <<<"$dhv2_external" || fail "Health externo no informa v0.17.0" +grep -q '"database":"ok"' <<<"$dhv2_external" || fail "La base no está saludable" +echo "$dhv2_external" + +echo +echo "========== D3 · RUTAS PROTEGIDAS ==========" +for dhv2_path in \ + "finding-catalog" \ + "inspection-acts/$dhv2_nil_uuid/findings" \ + "inspection-findings/$dhv2_nil_uuid" \ + "dashboard/summary"; do + dhv2_code="$(curl -sS --connect-timeout 10 --max-time 30 \ + -o /tmp/dhv2-d3-response.json -w '%{http_code}' \ + "$dhv2_base_url/api/v3/$dhv2_path")" + test "$dhv2_code" = "401" \ + || fail "/api/v3/$dhv2_path devolvió HTTP $dhv2_code; se esperaba 401" + echo "/api/v3/$dhv2_path sin sesión: 401 OK" +done +rm -f /tmp/dhv2-d3-response.json + +echo +echo "========== D3 · MODELO Y CATÁLOGO ==========" +dhv2_db_check="$(docker compose exec -T db sh -lc \ + 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -v ON_ERROR_STOP=1 -At' <<'SQL' +SELECT 'tables', COUNT(*) +FROM information_schema.tables +WHERE table_schema = 'public' + AND table_name IN ( + 'finding_categories', + 'finding_catalog_items', + 'inspection_findings', + 'inspection_finding_versions' + ); + +SELECT 'categories', COUNT(*) FROM finding_categories WHERE is_active = true; +SELECT 'catalog_items', COUNT(*) FROM finding_catalog_items WHERE is_active = true; + +SELECT 'normalized_row', COUNT(*) +FROM finding_catalog_items +WHERE code = 'BOMBAS-003' + AND title = 'PUESTA A TIERRA' + AND import_note IS NOT NULL; + +SELECT 'finding_checks', COUNT(*) +FROM pg_constraint +WHERE conname IN ( + 'chk_inspection_findings_number', + 'chk_inspection_findings_code', + 'chk_inspection_findings_status', + 'chk_inspection_findings_content', + 'chk_inspection_findings_response', + 'chk_inspection_findings_commitment', + 'chk_inspection_findings_closure' +); + +SELECT 'open_state', COUNT(*) +FROM pg_constraint +WHERE conname = 'chk_inspection_findings_status' + AND pg_get_constraintdef(oid) LIKE '%OPEN%' + AND pg_get_constraintdef(oid) LIKE '%CLOSED%'; + +SELECT 'immutable_versions', COUNT(*) +FROM pg_constraint +WHERE conname IN ( + 'uq_inspection_finding_versions_number', + 'chk_inspection_finding_versions_number', + 'chk_inspection_finding_versions_event' +); + +SELECT 'permissions', COUNT(*) +FROM permissions +WHERE code IN ( + 'finding_catalog.read', + 'finding_catalog.manage', + 'inspection_findings.read', + 'inspection_findings.create', + 'inspection_findings.update', + 'inspection_findings.follow_up' +); + +SELECT 'catalog_read_roles', COUNT(*) +FROM role_permissions rp +INNER JOIN roles role ON role.id = rp.role_id +INNER JOIN permissions permission ON permission.id = rp.permission_id +WHERE role.code IN ('admin', 'director', 'supervisor', 'inspector', 'auditor') + AND permission.code = 'finding_catalog.read'; + +SELECT 'catalog_manage_roles', COUNT(*) +FROM role_permissions rp +INNER JOIN roles role ON role.id = rp.role_id +INNER JOIN permissions permission ON permission.id = rp.permission_id +WHERE role.code IN ('admin', 'supervisor') + AND permission.code = 'finding_catalog.manage'; + +SELECT 'finding_read_roles', COUNT(*) +FROM role_permissions rp +INNER JOIN roles role ON role.id = rp.role_id +INNER JOIN permissions permission ON permission.id = rp.permission_id +WHERE role.code IN ('admin', 'director', 'supervisor', 'inspector', 'auditor') + AND permission.code = 'inspection_findings.read'; + +SELECT 'finding_create_roles', COUNT(*) +FROM role_permissions rp +INNER JOIN roles role ON role.id = rp.role_id +INNER JOIN permissions permission ON permission.id = rp.permission_id +WHERE role.code IN ('admin', 'supervisor', 'inspector') + AND permission.code = 'inspection_findings.create'; + +SELECT 'finding_update_roles', COUNT(*) +FROM role_permissions rp +INNER JOIN roles role ON role.id = rp.role_id +INNER JOIN permissions permission ON permission.id = rp.permission_id +WHERE role.code IN ('admin', 'supervisor', 'inspector') + AND permission.code = 'inspection_findings.update'; + +SELECT 'follow_up_roles', COUNT(*) +FROM role_permissions rp +INNER JOIN roles role ON role.id = rp.role_id +INNER JOIN permissions permission ON permission.id = rp.permission_id +WHERE role.code IN ('admin', 'director', 'supervisor', 'inspector') + AND permission.code = 'inspection_findings.follow_up'; +SQL +)" +grep -q 'tables|4' <<<"$dhv2_db_check" || fail "Faltan tablas D3" +grep -q 'categories|19' <<<"$dhv2_db_check" || fail "El catálogo no tiene 19 categorías" +grep -q 'catalog_items|170' <<<"$dhv2_db_check" || fail "El catálogo no tiene 170 modelos" +grep -q 'normalized_row|1' <<<"$dhv2_db_check" || fail "No se registró la normalización BOMBAS-003" +grep -q 'finding_checks|7' <<<"$dhv2_db_check" || fail "Faltan restricciones de hallazgos" +grep -q 'open_state|1' <<<"$dhv2_db_check" || fail "No está definido el estado abierto" +grep -q 'immutable_versions|3' <<<"$dhv2_db_check" || fail "Faltan controles de versiones" +grep -q 'permissions|6' <<<"$dhv2_db_check" || fail "Faltan permisos D3" +grep -q 'catalog_read_roles|5' <<<"$dhv2_db_check" || fail "Faltan roles de lectura de catálogo" +grep -q 'catalog_manage_roles|2' <<<"$dhv2_db_check" || fail "Faltan roles de gestión de catálogo" +grep -q 'finding_read_roles|5' <<<"$dhv2_db_check" || fail "Faltan roles de lectura de hallazgos" +grep -q 'finding_create_roles|3' <<<"$dhv2_db_check" || fail "Faltan roles de creación de hallazgos" +grep -q 'finding_update_roles|3' <<<"$dhv2_db_check" || fail "Faltan roles de actualización de hallazgos" +grep -q 'follow_up_roles|4' <<<"$dhv2_db_check" || fail "Faltan roles de seguimiento" +echo "$dhv2_db_check" + +echo +echo "========== D3 · PRIVILEGIOS RUNTIME ==========" +dhv2_privileges="$(docker compose exec -T db sh -lc \ + 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -v ON_ERROR_STOP=1 -v app_user="$APP_DB_USER" -At' <<'SQL' +SELECT 'catalog_select', has_table_privilege(:'app_user', 'finding_catalog_items', 'SELECT'); +SELECT 'catalog_update', has_table_privilege(:'app_user', 'finding_catalog_items', 'UPDATE'); +SELECT 'catalog_delete', has_table_privilege(:'app_user', 'finding_catalog_items', 'DELETE'); +SELECT 'findings_insert', has_table_privilege(:'app_user', 'inspection_findings', 'INSERT'); +SELECT 'findings_update', has_table_privilege(:'app_user', 'inspection_findings', 'UPDATE'); +SELECT 'findings_delete', has_table_privilege(:'app_user', 'inspection_findings', 'DELETE'); +SELECT 'versions_insert', has_table_privilege(:'app_user', 'inspection_finding_versions', 'INSERT'); +SELECT 'versions_update', has_table_privilege(:'app_user', 'inspection_finding_versions', 'UPDATE'); +SELECT 'versions_delete', has_table_privilege(:'app_user', 'inspection_finding_versions', 'DELETE'); +SQL +)" +grep -q 'catalog_select|t' <<<"$dhv2_privileges" || fail "La API no puede leer el catálogo" +grep -q 'catalog_update|t' <<<"$dhv2_privileges" || fail "La API no puede administrar el catálogo" +grep -q 'catalog_delete|f' <<<"$dhv2_privileges" || fail "La API no debe borrar el catálogo" +grep -q 'findings_insert|t' <<<"$dhv2_privileges" || fail "La API no puede crear hallazgos" +grep -q 'findings_update|t' <<<"$dhv2_privileges" || fail "La API no puede actualizar hallazgos" +grep -q 'findings_delete|f' <<<"$dhv2_privileges" || fail "La API no debe borrar hallazgos" +grep -q 'versions_insert|t' <<<"$dhv2_privileges" || fail "La API no puede crear versiones" +grep -q 'versions_update|f' <<<"$dhv2_privileges" || fail "La API no debe modificar versiones" +grep -q 'versions_delete|f' <<<"$dhv2_privileges" || fail "La API no debe borrar versiones" +echo "$dhv2_privileges" + +echo +echo "========== D3 · FRONTEND ==========" +docker compose exec -T web sh -lc \ + 'grep -R "0.17.0" /usr/share/nginx/html/assets >/dev/null && grep -R "Hallazgos y seguimiento" /usr/share/nginx/html/assets >/dev/null && grep -R "Múltiples hallazgos" /usr/share/nginx/html/assets >/dev/null && grep -R "Respuesta de la empresa" /usr/share/nginx/html/assets >/dev/null && grep -R "Próximos controles de hallazgos" /usr/share/nginx/html/assets >/dev/null' +echo "Hallazgos, seguimiento, dashboard y footer v0.17.0: OK" + +echo +echo "ACEPTACIÓN DE FASE D3: OK" diff --git a/scripts/phase-d4-acceptance.sh b/scripts/phase-d4-acceptance.sh new file mode 100755 index 0000000..4c8f0a1 --- /dev/null +++ b/scripts/phase-d4-acceptance.sh @@ -0,0 +1,177 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail + +dhv2_base_url="${DHV2_BASE_URL:-https://dhv2.korexlabs.com}" +dhv2_base_url="${dhv2_base_url%/}" +dhv2_nil_uuid="00000000-0000-4000-8000-000000000000" + +fail() { + echo "ERROR: $*" >&2 + exit 1 +} + +echo "========== D4 · MIGRACIONES ==========" +dhv2_migration_status="$(docker compose --profile tools run --rm migrate npm run migration:show 2>&1)" +grep -q 'Pending migrations: no' <<<"$dhv2_migration_status" \ + || fail "Quedaron migraciones pendientes" +echo "Migraciones pendientes: no" + +echo +echo "========== D4 · HEALTH ==========" +dhv2_internal="$(curl -fsS --connect-timeout 10 --max-time 30 http://127.0.0.1:3101/api/v3/health)" +dhv2_external="$(curl -fsS --connect-timeout 10 --max-time 30 "$dhv2_base_url/api/v3/health")" +grep -q '"version":"0.18.0"' <<<"$dhv2_internal" || fail "Health interno no informa v0.18.0" +grep -q '"phase":"D4"' <<<"$dhv2_internal" || fail "Health interno no informa D4" +grep -q '"version":"0.18.0"' <<<"$dhv2_external" || fail "Health externo no informa v0.18.0" +grep -q '"database":"ok"' <<<"$dhv2_external" || fail "La base no está saludable" +echo "$dhv2_external" + +echo +echo "========== D4 · RUTAS PROTEGIDAS ==========" +for dhv2_path in \ + "inspection-findings/$dhv2_nil_uuid/evidence" \ + "inspection-findings/$dhv2_nil_uuid/communications" \ + "inspection-finding-evidence/$dhv2_nil_uuid/content"; do + dhv2_code="$(curl -sS --connect-timeout 10 --max-time 30 \ + -o /tmp/dhv2-d4-response.json -w '%{http_code}' \ + "$dhv2_base_url/api/v3/$dhv2_path")" + test "$dhv2_code" = "401" \ + || fail "/api/v3/$dhv2_path devolvió HTTP $dhv2_code; se esperaba 401" + echo "/api/v3/$dhv2_path sin sesión: 401 OK" +done +rm -f /tmp/dhv2-d4-response.json + +echo +echo "========== D4 · MODELO INMUTABLE ==========" +dhv2_db_check="$(docker compose exec -T db sh -lc \ + 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -v ON_ERROR_STOP=1 -At' <<'SQL' +SELECT 'tables', COUNT(*) +FROM information_schema.tables +WHERE table_schema = 'public' + AND table_name IN ( + 'inspection_finding_evidence', + 'inspection_finding_communications' + ); + +SELECT 'communication_checks', COUNT(*) +FROM pg_constraint +WHERE conname IN ( + 'chk_inspection_finding_communications_direction', + 'chk_inspection_finding_communications_channel', + 'chk_inspection_finding_communications_type', + 'chk_inspection_finding_communications_subject', + 'chk_inspection_finding_company_response_direction' +); + +SELECT 'evidence_checks', COUNT(*) +FROM pg_constraint +WHERE conname IN ( + 'chk_inspection_finding_evidence_kind', + 'chk_inspection_finding_evidence_purpose', + 'chk_inspection_finding_evidence_source', + 'chk_inspection_finding_evidence_mime', + 'chk_inspection_finding_evidence_size', + 'chk_inspection_finding_evidence_sha256', + 'chk_inspection_finding_evidence_coordinates', + 'chk_inspection_finding_evidence_accuracy', + 'chk_inspection_finding_evidence_kind_mime', + 'chk_inspection_finding_company_response_pdf', + 'chk_inspection_finding_attachment_link' +); + +SELECT 'protected_links', COUNT(*) +FROM pg_constraint +WHERE conname IN ( + 'uq_inspection_finding_communications_id_finding', + 'fk_inspection_finding_evidence_communication', + 'uq_inspection_finding_evidence_stored_name' +); + +SELECT 'permissions', COUNT(*) +FROM permissions +WHERE code IN ( + 'inspection_evidence.read', + 'inspection_evidence.create', + 'inspection_communications.read', + 'inspection_communications.create' +); + +SELECT 'evidence_read_roles', COUNT(*) +FROM role_permissions rp +INNER JOIN roles role ON role.id = rp.role_id +INNER JOIN permissions permission ON permission.id = rp.permission_id +WHERE role.code IN ('admin', 'director', 'supervisor', 'inspector', 'auditor') + AND permission.code = 'inspection_evidence.read'; + +SELECT 'evidence_create_roles', COUNT(*) +FROM role_permissions rp +INNER JOIN roles role ON role.id = rp.role_id +INNER JOIN permissions permission ON permission.id = rp.permission_id +WHERE role.code IN ('admin', 'director', 'supervisor', 'inspector') + AND permission.code = 'inspection_evidence.create'; + +SELECT 'communication_read_roles', COUNT(*) +FROM role_permissions rp +INNER JOIN roles role ON role.id = rp.role_id +INNER JOIN permissions permission ON permission.id = rp.permission_id +WHERE role.code IN ('admin', 'director', 'supervisor', 'inspector', 'auditor') + AND permission.code = 'inspection_communications.read'; + +SELECT 'communication_create_roles', COUNT(*) +FROM role_permissions rp +INNER JOIN roles role ON role.id = rp.role_id +INNER JOIN permissions permission ON permission.id = rp.permission_id +WHERE role.code IN ('admin', 'director', 'supervisor', 'inspector') + AND permission.code = 'inspection_communications.create'; +SQL +)" +grep -q 'tables|2' <<<"$dhv2_db_check" || fail "Faltan tablas D4" +grep -q 'communication_checks|5' <<<"$dhv2_db_check" || fail "Faltan controles de comunicaciones" +grep -q 'evidence_checks|11' <<<"$dhv2_db_check" || fail "Faltan controles de evidencias" +grep -q 'protected_links|3' <<<"$dhv2_db_check" || fail "Faltan vínculos protegidos" +grep -q 'permissions|4' <<<"$dhv2_db_check" || fail "Faltan permisos D4" +grep -q 'evidence_read_roles|5' <<<"$dhv2_db_check" || fail "Faltan roles de lectura de evidencias" +grep -q 'evidence_create_roles|4' <<<"$dhv2_db_check" || fail "Faltan roles de carga de evidencias" +grep -q 'communication_read_roles|5' <<<"$dhv2_db_check" || fail "Faltan roles de lectura de comunicaciones" +grep -q 'communication_create_roles|4' <<<"$dhv2_db_check" || fail "Faltan roles de carga de comunicaciones" +echo "$dhv2_db_check" + +echo +echo "========== D4 · PRIVILEGIOS RUNTIME ==========" +dhv2_privileges="$(docker compose exec -T db sh -lc \ + 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -v ON_ERROR_STOP=1 -v app_user="$APP_DB_USER" -At' <<'SQL' +SELECT 'evidence_select', has_table_privilege(:'app_user', 'inspection_finding_evidence', 'SELECT'); +SELECT 'evidence_insert', has_table_privilege(:'app_user', 'inspection_finding_evidence', 'INSERT'); +SELECT 'evidence_update', has_table_privilege(:'app_user', 'inspection_finding_evidence', 'UPDATE'); +SELECT 'evidence_delete', has_table_privilege(:'app_user', 'inspection_finding_evidence', 'DELETE'); +SELECT 'communications_select', has_table_privilege(:'app_user', 'inspection_finding_communications', 'SELECT'); +SELECT 'communications_insert', has_table_privilege(:'app_user', 'inspection_finding_communications', 'INSERT'); +SELECT 'communications_update', has_table_privilege(:'app_user', 'inspection_finding_communications', 'UPDATE'); +SELECT 'communications_delete', has_table_privilege(:'app_user', 'inspection_finding_communications', 'DELETE'); +SQL +)" +grep -q 'evidence_select|t' <<<"$dhv2_privileges" || fail "La API no puede leer evidencias" +grep -q 'evidence_insert|t' <<<"$dhv2_privileges" || fail "La API no puede crear evidencias" +grep -q 'evidence_update|f' <<<"$dhv2_privileges" || fail "La API no debe modificar evidencias" +grep -q 'evidence_delete|f' <<<"$dhv2_privileges" || fail "La API no debe borrar evidencias" +grep -q 'communications_select|t' <<<"$dhv2_privileges" || fail "La API no puede leer comunicaciones" +grep -q 'communications_insert|t' <<<"$dhv2_privileges" || fail "La API no puede crear comunicaciones" +grep -q 'communications_update|f' <<<"$dhv2_privileges" || fail "La API no debe modificar comunicaciones" +grep -q 'communications_delete|f' <<<"$dhv2_privileges" || fail "La API no debe borrar comunicaciones" +echo "$dhv2_privileges" + +echo +echo "========== D4 · ALMACENAMIENTO ==========" +docker compose exec -T api sh -lc \ + 'test -d "$ASSET_MEDIA_ROOT" && test -w "$ASSET_MEDIA_ROOT"' +echo "Volumen persistente protegido y escribible: OK" + +echo +echo "========== D4 · FRONTEND ==========" +docker compose exec -T web sh -lc \ + 'grep -R "0.18.0" /usr/share/nginx/html/assets >/dev/null && grep -R "Evidencias y comunicaciones" /usr/share/nginx/html/assets >/dev/null && grep -R "PDF de respuesta de la empresa" /usr/share/nginx/html/assets >/dev/null && grep -R "Historial de comunicaciones" /usr/share/nginx/html/assets >/dev/null && grep -R "SHA-256" /usr/share/nginx/html/assets >/dev/null' +echo "Evidencias, comunicaciones y footer v0.18.0: OK" + +echo +echo "ACEPTACIÓN DE FASE D4: OK" diff --git a/scripts/phase-d5-1-acceptance.sh b/scripts/phase-d5-1-acceptance.sh new file mode 100755 index 0000000..6747196 --- /dev/null +++ b/scripts/phase-d5-1-acceptance.sh @@ -0,0 +1,141 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail + +dhv2_base_url="${DHV2_BASE_URL:-https://dhv2.korexlabs.com}" +dhv2_base_url="${dhv2_base_url%/}" +dhv2_nil_uuid="00000000-0000-4000-8000-000000000000" + +fail() { + echo "ERROR: $*" >&2 + exit 1 +} + +echo "========== D5.1 · MIGRACIONES ==========" +dhv2_migration_status="$(docker compose --profile tools run --rm migrate npm run migration:show 2>&1)" +grep -q 'Pending migrations: no' <<<"$dhv2_migration_status" \ + || fail "Quedaron migraciones pendientes" +echo "Migraciones pendientes: no" + +echo +echo "========== D5.1 · HEALTH ==========" +dhv2_internal="$(curl -fsS --connect-timeout 10 --max-time 30 http://127.0.0.1:3101/api/v3/health)" +dhv2_external="$(curl -fsS --connect-timeout 10 --max-time 30 "$dhv2_base_url/api/v3/health")" +grep -q '"version":"0.19.1"' <<<"$dhv2_internal" || fail "Health interno no informa v0.19.1" +grep -q '"phase":"D5.1"' <<<"$dhv2_internal" || fail "Health interno no informa D5.1" +grep -q '"version":"0.19.1"' <<<"$dhv2_external" || fail "Health externo no informa v0.19.1" +grep -q '"database":"ok"' <<<"$dhv2_external" || fail "La base no está saludable" +echo "$dhv2_external" + +echo +echo "========== D5.1 · RUTAS PROTEGIDAS ==========" +dhv2_post_paths=( + "inspection-visits/$dhv2_nil_uuid/start" + "inspection-visits/$dhv2_nil_uuid/acts" + "inspection-acts/$dhv2_nil_uuid/findings" + "inspection-acts/$dhv2_nil_uuid/ready" + "inspection-acts/$dhv2_nil_uuid/close" +) +for dhv2_path in "${dhv2_post_paths[@]}"; do + dhv2_code="$(curl -sS --connect-timeout 10 --max-time 30 \ + -X POST -H 'Content-Type: application/json' -d '{}' \ + -o /tmp/dhv2-d5-1-response.json -w '%{http_code}' \ + "$dhv2_base_url/api/v3/$dhv2_path")" + test "$dhv2_code" = "401" \ + || fail "/api/v3/$dhv2_path devolvió HTTP $dhv2_code; se esperaba 401" + echo "/api/v3/$dhv2_path sin sesión: 401 OK" +done +rm -f /tmp/dhv2-d5-1-response.json + +echo +echo "========== D5.1 · POLÍTICA DE ROLES ==========" +dhv2_db_check="$(docker compose exec -T db sh -lc \ + 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -v ON_ERROR_STOP=1 -At' <<'SQL' +WITH operational(code) AS (VALUES + ('inspections.execute'), + ('inspection_acts.create'), + ('inspection_acts.update'), + ('inspection_acts.cancel'), + ('inspection_findings.create'), + ('inspection_findings.update'), + ('inspection_closure.prepare'), + ('inspection_closure.sign'), + ('inspection_closure.close') +) +SELECT 'operational_permissions', COUNT(*) +FROM permissions permission +INNER JOIN operational ON operational.code = permission.code; + +WITH operational(code) AS (VALUES + ('inspections.execute'), + ('inspection_acts.create'), + ('inspection_acts.update'), + ('inspection_acts.cancel'), + ('inspection_findings.create'), + ('inspection_findings.update'), + ('inspection_closure.prepare'), + ('inspection_closure.sign'), + ('inspection_closure.close') +) +SELECT 'inspector_grants', COUNT(*) +FROM role_permissions role_permission +INNER JOIN roles role ON role.id = role_permission.role_id +INNER JOIN permissions permission ON permission.id = role_permission.permission_id +INNER JOIN operational ON operational.code = permission.code +WHERE role.code = 'inspector'; + +WITH operational(code) AS (VALUES + ('inspections.execute'), + ('inspection_acts.create'), + ('inspection_acts.update'), + ('inspection_acts.cancel'), + ('inspection_findings.create'), + ('inspection_findings.update'), + ('inspection_closure.prepare'), + ('inspection_closure.sign'), + ('inspection_closure.close') +) +SELECT 'non_inspector_grants', COUNT(*) +FROM role_permissions role_permission +INNER JOIN roles role ON role.id = role_permission.role_id +INNER JOIN permissions permission ON permission.id = role_permission.permission_id +INNER JOIN operational ON operational.code = permission.code +WHERE role.code <> 'inspector'; + +SELECT 'follow_up_roles', COUNT(*) +FROM role_permissions role_permission +INNER JOIN roles role ON role.id = role_permission.role_id +INNER JOIN permissions permission ON permission.id = role_permission.permission_id +WHERE role.code IN ('admin', 'director', 'supervisor', 'inspector') + AND permission.code = 'inspection_findings.follow_up'; + +SELECT 'migration_applied', COUNT(*) +FROM typeorm_migrations +WHERE name = 'PhaseD51MobileInspectionPolicy1787331600000'; +SQL +)" +grep -q 'operational_permissions|9' <<<"$dhv2_db_check" || fail "No se encontraron los nueve permisos operativos" +grep -q 'inspector_grants|9' <<<"$dhv2_db_check" || fail "El inspector no tiene los nueve permisos operativos" +grep -q 'non_inspector_grants|0' <<<"$dhv2_db_check" || fail "Un rol no inspector conserva permisos operativos" +grep -q 'follow_up_roles|4' <<<"$dhv2_db_check" || fail "Se alteró el seguimiento posterior del dashboard" +grep -q 'migration_applied|1' <<<"$dhv2_db_check" || fail "La migración D5.1 no figura aplicada" +echo "$dhv2_db_check" + +echo +echo "========== D5.1 · BLOQUEO EN API ==========" +docker compose exec -T api sh -lc \ + 'grep -R "INSPECTION_MOBILE_APP_REQUIRED" /app/dist >/dev/null && grep -R "INSPECTION_INSPECTOR_ROLE_REQUIRED" /app/dist >/dev/null' +echo "Bearer móvil + rol inspector exigidos en runtime: OK" + +echo +echo "========== D5.1 · DASHBOARD ==========" +docker compose exec -T web sh -lc \ + 'grep -R "0.19.1" /usr/share/nginx/html/assets >/dev/null && grep -R "Operación exclusiva APK" /usr/share/nginx/html/assets >/dev/null && grep -R "El inspector asignado debe iniciar la visita desde la APK" /usr/share/nginx/html/assets >/dev/null && grep -R "Actas y hallazgos se registran desde la APK" /usr/share/nginx/html/assets >/dev/null && grep -R "respuesta de la empresa, PDF y próximo control" /usr/share/nginx/html/assets >/dev/null' +echo "Dashboard administrativo y footer v0.19.1: OK" + +echo +echo "========== D5.1 · CONTENEDORES ==========" +docker compose ps + +echo +echo "ACEPTACIÓN DE FASE D5.1: OK" diff --git a/scripts/phase-d5-2-acceptance.sh b/scripts/phase-d5-2-acceptance.sh new file mode 100755 index 0000000..a6aaf96 --- /dev/null +++ b/scripts/phase-d5-2-acceptance.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail + +dhv2_base_url="${DHV2_BASE_URL:-https://dhv2.korexlabs.com}" +dhv2_base_url="${dhv2_base_url%/}" + +fail() { + echo "ERROR: $*" >&2 + exit 1 +} + +echo "========== D5.2 · MIGRACIONES ==========" +dhv2_migration_status="$(docker compose --profile tools run --rm migrate npm run migration:show 2>&1)" +grep -q 'Pending migrations: no' <<<"$dhv2_migration_status" || fail "Quedaron migraciones pendientes" +echo "Migraciones pendientes: no" + +echo +echo "========== D5.2 · HEALTH ==========" +dhv2_internal="$(curl -fsS --connect-timeout 10 --max-time 30 http://127.0.0.1:3101/api/v3/health)" +dhv2_external="$(curl -fsS --connect-timeout 10 --max-time 30 "$dhv2_base_url/api/v3/health")" +grep -q '"version":"0.19.2"' <<<"$dhv2_internal" || fail "Health interno no informa v0.19.2" +grep -q '"phase":"D5.2"' <<<"$dhv2_internal" || fail "Health interno no informa D5.2" +grep -q '"version":"0.19.2"' <<<"$dhv2_external" || fail "Health externo no informa v0.19.2" +grep -q '"database":"ok"' <<<"$dhv2_external" || fail "La base no está saludable" +echo "$dhv2_external" + +echo +echo "========== D5.2 · RUTA ADMINISTRATIVA PROTEGIDA ==========" +dhv2_code="$(curl -sS --connect-timeout 10 --max-time 30 \ + -o /tmp/dhv2-d5-2-response.json -w '%{http_code}' \ + "$dhv2_base_url/api/v3/finding-catalog/admin")" +test "$dhv2_code" = "401" || fail "/api/v3/finding-catalog/admin devolvió HTTP $dhv2_code; se esperaba 401" +rm -f /tmp/dhv2-d5-2-response.json +echo "/api/v3/finding-catalog/admin sin sesión: 401 OK" + +echo +echo "========== D5.2 · CATÁLOGO Y REVISIONES ==========" +dhv2_db_check="$(docker compose exec -T db sh -lc \ + 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -v ON_ERROR_STOP=1 -v app_user="$APP_DB_USER" -At' <<'SQL' +SELECT 'categories_baseline', COUNT(*) >= 19 FROM finding_categories; +SELECT 'catalog_items_baseline', COUNT(*) >= 170 FROM finding_catalog_items; +SELECT 'versions_table', COUNT(*) +FROM information_schema.tables +WHERE table_schema = 'public' AND table_name = 'finding_catalog_item_versions'; + +SELECT 'version_rows_match', NOT EXISTS ( + SELECT 1 + FROM finding_catalog_items item + WHERE NOT EXISTS ( + SELECT 1 FROM finding_catalog_item_versions version + WHERE version.item_id = item.id AND version.revision = item.revision + ) +); + +SELECT 'revision_constraints', COUNT(*) +FROM information_schema.table_constraints +WHERE table_schema = 'public' + AND table_name = 'finding_catalog_item_versions' + AND constraint_name IN ( + 'uq_finding_catalog_item_versions_revision', + 'chk_finding_catalog_item_versions_revision', + 'chk_finding_catalog_item_versions_snapshot', + 'fk_finding_catalog_item_versions_item', + 'fk_finding_catalog_item_versions_actor' + ); + +SELECT 'catalog_manage_roles', COUNT(*) +FROM role_permissions role_permission +INNER JOIN roles role ON role.id = role_permission.role_id +INNER JOIN permissions permission ON permission.id = role_permission.permission_id +WHERE role.code IN ('admin', 'supervisor') + AND permission.code = 'finding_catalog.manage'; + +SELECT 'version_select', has_table_privilege(:'app_user', 'finding_catalog_item_versions', 'SELECT'); +SELECT 'version_insert', has_table_privilege(:'app_user', 'finding_catalog_item_versions', 'INSERT'); +SELECT 'version_update', has_table_privilege(:'app_user', 'finding_catalog_item_versions', 'UPDATE'); +SELECT 'version_delete', has_table_privilege(:'app_user', 'finding_catalog_item_versions', 'DELETE'); + +SELECT 'migration_applied', COUNT(*) +FROM typeorm_migrations +WHERE name = 'PhaseD52FindingCatalogAdministration1787335200000'; +SQL +)" +grep -q 'categories_baseline|t' <<<"$dhv2_db_check" || fail "Faltan categorías de la línea base" +grep -q 'catalog_items_baseline|t' <<<"$dhv2_db_check" || fail "Faltan tipos de hallazgo de la línea base" +grep -q 'versions_table|1' <<<"$dhv2_db_check" || fail "No se creó la tabla de revisiones" +grep -q 'version_rows_match|t' <<<"$dhv2_db_check" || fail "Hay tipos sin snapshot de su revisión vigente" +grep -q 'revision_constraints|5' <<<"$dhv2_db_check" || fail "Faltan restricciones de integridad del historial" +grep -q 'catalog_manage_roles|2' <<<"$dhv2_db_check" || fail "Admin y supervisor no conservan gestión del catálogo" +grep -q 'version_select|t' <<<"$dhv2_db_check" || fail "La API no puede leer revisiones" +grep -q 'version_insert|t' <<<"$dhv2_db_check" || fail "La API no puede crear revisiones" +grep -q 'version_update|f' <<<"$dhv2_db_check" || fail "La API no debe modificar revisiones" +grep -q 'version_delete|f' <<<"$dhv2_db_check" || fail "La API no debe borrar revisiones" +grep -q 'migration_applied|1' <<<"$dhv2_db_check" || fail "La migración D5.2 no figura aplicada" +echo "$dhv2_db_check" + +echo +echo "========== D5.2 · API COMPILADA ==========" +docker compose exec -T api sh -lc \ + 'grep -R "FindingCatalogController" /app/dist >/dev/null && grep -R "FINDING_CATALOG_ITEM_UPDATED" /app/dist >/dev/null' +echo "Administración, auditoría y versionado cargados: OK" + +echo +echo "========== D5.2 · DASHBOARD ==========" +docker compose exec -T web sh -lc \ + 'grep -R "0.19.2" /usr/share/nginx/html/assets >/dev/null && grep -R "Catálogo administrable" /usr/share/nginx/html/assets >/dev/null && grep -R "Catálogo de hallazgos" /usr/share/nginx/html/assets >/dev/null && grep -R "Nueva categoría" /usr/share/nginx/html/assets >/dev/null && grep -R "Nuevo tipo de hallazgo" /usr/share/nginx/html/assets >/dev/null && grep -R "Los cambios crean una nueva revisión" /usr/share/nginx/html/assets >/dev/null' +echo "Catálogo administrativo y footer v0.19.2: OK" + +echo +echo "========== D5.2 · CONTENEDORES ==========" +docker compose ps + +echo +echo "ACEPTACIÓN DE FASE D5.2: OK" diff --git a/scripts/phase-d5-3-1-acceptance.sh b/scripts/phase-d5-3-1-acceptance.sh new file mode 100755 index 0000000..80cb261 --- /dev/null +++ b/scripts/phase-d5-3-1-acceptance.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail + +ROOT="${ROOT:-/var/www/dhv2.korexlabs.com}" +cd "$ROOT" + +DB_SERVICE="${DB_SERVICE:-db}" +API_URL="${API_URL:-http://127.0.0.1:3101/api/v3}" + +psql_app() { + docker compose exec -T "$DB_SERVICE" sh -lc \ + 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -At -v ON_ERROR_STOP=1' <<<"$1" +} + +echo "========== D5.3.1 · VERSIONES ==========" +API_VERSION="$(node -p "require('./api-v3/package.json').version")" +WEB_VERSION="$(node -p "require('./web-v2/package.json').version")" +[[ "$API_VERSION" == "0.19.3-1" ]] +[[ "$WEB_VERSION" == "0.19.3-1" ]] +echo "API: $API_VERSION" +echo "WEB: $WEB_VERSION" + +echo +echo "========== D5.3.1 · MIGRACIONES ==========" +MIGRATION_OUTPUT="$(docker compose --profile tools run --rm migrate npm run migration:show 2>&1)" +echo "$MIGRATION_OUTPUT" +grep -q "Pending migrations: no" <<<"$MIGRATION_OUTPUT" + +echo +echo "========== D5.3.1 · HEALTH ==========" +HEALTH="$(curl -fsS "$API_URL/health")" +echo "$HEALTH" +grep -q '"version":"0.19.3-1"' <<<"$HEALTH" +grep -q '"phase":"D5.3.1"' <<<"$HEALTH" + +echo +echo "========== D5.3.1 · BOOTSTRAP PROTEGIDO ==========" +STATUS_CODE="$(curl -sS -o /tmp/dhv2-d531-bootstrap-status.txt -w '%{http_code}' "$API_URL/asset-types/bootstrap-status")" +[[ "$STATUS_CODE" == "401" ]] +echo "/asset-types/bootstrap-status sin sesión: 401 OK" + +POST_CODE="$(curl -sS -o /tmp/dhv2-d531-bootstrap-post.txt -w '%{http_code}' -X POST "$API_URL/asset-types/bootstrap-defaults")" +[[ "$POST_CODE" == "401" ]] +echo "/asset-types/bootstrap-defaults sin sesión: 401 OK" + +echo +echo "========== D5.3.1 · PRESET EN SOURCE ==========" +grep -q "mendoza-hidrocarburos-v1" api-v3/src/asset-master/asset-master-bootstrap.ts +grep -q "Área / Concesión" api-v3/src/asset-master/asset-master-bootstrap.ts +grep -q "Empresa / Operadora" api-v3/src/asset-master/asset-master-bootstrap.ts +grep -q "Instalación de superficie" api-v3/src/asset-master/asset-master-bootstrap.ts +grep -q "Ducto / Cañería" api-v3/src/asset-master/asset-master-bootstrap.ts +grep -q "MASTER_BOOTSTRAP_REQUIRES_EMPTY_MASTER" api-v3/src/asset-master/asset-types.service.ts +grep -q "ASSET_MASTER_BOOTSTRAPPED" api-v3/src/database/entities/audit-event.entity.ts +echo "Preset, bloqueo de Maestro no vacío y auditoría: OK" + +echo +echo "========== D5.3.1 · DASHBOARD ==========" +grep -q "Instalar configuración base" web-v2/src/pages/AssetTypesPage.tsx +grep -q "No carga datos reales automáticamente" web-v2/src/pages/AssetTypesPage.tsx +grep -q "0.19.3-1" web-v2/src/config/version.ts +echo "Configuración inicial visible y footer correcto: OK" + +echo +echo "========== D5.3.1 · ESTADO DEL MAESTRO ==========" +TYPE_COUNT="$(psql_app 'SELECT COUNT(*) FROM asset_types;')" +ASSET_COUNT="$(psql_app 'SELECT COUNT(*) FROM assets;')" +echo "Tipos actuales: $TYPE_COUNT" +echo "Activos actuales: $ASSET_COUNT" +if [[ "$TYPE_COUNT" == "0" ]]; then + echo "Maestro vacío: el botón de configuración inicial quedará habilitado." +else + echo "Maestro ya configurado: el backend rechazará una nueva inicialización automática." +fi + +echo +echo "========== D5.3.1 · CONTENEDORES ==========" +docker compose ps -a + +echo +echo "ACEPTACIÓN DE FASE D5.3.1: OK" diff --git a/scripts/phase-d5-3-10-2-acceptance.sh b/scripts/phase-d5-3-10-2-acceptance.sh new file mode 100755 index 0000000..4ba7773 --- /dev/null +++ b/scripts/phase-d5-3-10-2-acceptance.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT="${ROOT:-/var/www/dhv2.korexlabs.com}" +API_URL="${API_URL:-http://127.0.0.1:3101/api/v3}" +TMP_MIGRATION="/tmp/dhv2-d53102-accept-migration-$$.log" +trap 'rm -f "$TMP_MIGRATION"' EXIT +cd "$ROOT" + +echo "========== D5.3.10.2 · VERSIONES ==========" +[[ "$(node -p "require('./api-v3/package.json').version")" == "0.19.3-10.2" ]] +[[ "$(node -p "require('./web-v2/package.json').version")" == "0.19.3-10.2" ]] +grep -Fq "API_PHASE = 'D5.3.10.2'" api-v3/src/version.ts +grep -Fq "APP_PHASE = 'Fase D5.3.10.2 · Navegación del Maestro'" web-v2/src/config/version.ts +echo "API: 0.19.3-10.2" +echo "WEB: 0.19.3-10.2" + +echo +echo "========== D5.3.10.2 · DB Y MIGRACIONES ==========" +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}')" == "healthy" ]] +docker compose exec -T db sh -lc 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' +docker compose --profile tools run --rm migrate npm run migration:show 2>&1 | tee "$TMP_MIGRATION" +grep -Fq 'Pending migrations: no' "$TMP_MIGRATION" + +echo +echo "========== D5.3.10.2 · HEALTH ==========" +HEALTH="$(curl -fsS "$API_URL/health")" +echo "$HEALTH" +grep -Fq '"version":"0.19.3-10.2"' <<<"$HEALTH" +grep -Fq '"phase":"D5.3.10.2"' <<<"$HEALTH" +grep -Fq '"database":"ok"' <<<"$HEALTH" + +echo +echo "========== D5.3.10.2 · NAVEGACIÓN DEL MAESTRO ==========" +grep -Fq "@Get(':id/lineage')" api-v3/src/asset-master/assets.controller.ts +grep -Fq 'WITH RECURSIVE lineage AS' api-v3/src/asset-master/assets.service.ts +grep -Fq "label: 'Navegar'" web-v2/src/features/assets/AssetCenterTabs.tsx +grep -Fq 'asset-browser-entry-grid' web-v2/src/features/assets/AssetHierarchyView.tsx +grep -Fq 'Áreas y yacimientos' web-v2/src/features/assets/AssetHierarchyView.tsx +grep -Fq 'Instalaciones y estructura' web-v2/src/features/assets/AssetHierarchyView.tsx +grep -Fq 'Equipos y otros activos' web-v2/src/features/assets/AssetHierarchyView.tsx +grep -Fq 'asset-browser-breadcrumb' web-v2/src/features/assets/AssetHierarchyView.tsx +grep -Fq 'getAssetLineage' web-v2/src/pages/AssetEditorPage.tsx +grep -Fq "Omit[0]>, 'parentId' | 'limit'>" web-v2/src/features/assets/AssetHierarchyView.tsx +echo "Navegación por niveles y breadcrumb: OK" + +echo +echo "========== D5.3.10.2 · HIGIENE DE CÓDIGO ==========" +if grep -RnhE --include='*.ts' --include='*.tsx' '^[[:space:]]*(//|/\*|\*)' api-v3/src web-v2/src; then + echo "ERROR: quedaron comentarios narrativos en código de producción" + exit 1 +fi +if grep -nE '/\*|\*/' web-v2/src/styles.css; then + echo "ERROR: quedaron comentarios internos en estilos de producción" + exit 1 +fi +if grep -Fq 'Flujo seguro D5.3.9' web-v2/src/pages/AssetImportsPage.tsx; then + echo "ERROR: quedó texto interno de fase en la interfaz" + exit 1 +fi +echo "Código de producción preparado para revisión externa: OK" + +echo +echo "========== D5.3.10.2 · SEGURIDAD HEREDADA ==========" +grep -Fq 'PLAN_DEPENDENCY_REVIEW_CODES' api-v3/src/asset-imports/asset-import-plan.ts +grep -Fq 'directReviewItems' api-v3/src/asset-imports/asset-import-plan.ts +grep -Fq 'private async masterStateFingerprint' api-v3/src/asset-imports/asset-imports.service.ts +STRONG_BACKUP="$(find /root/DH_V2_BACKUPS -maxdepth 1 -type d -name 'INVENTARIO_A_REVISAR_*' -printf '%T@ %p\n' | sort -nr | head -n1 | cut -d' ' -f2-)" +[[ -n "$STRONG_BACKUP" && -s "$STRONG_BACKUP/BACKUP_OK.txt" ]] +grep -Fq 'INVENTARIO A REVISAR' "$STRONG_BACKUP/BACKUP_OK.txt" +echo "Backup fuerte de referencia: $STRONG_BACKUP" + +echo +echo "========== D5.3.10.2 · CONTENEDORES ==========" +docker compose ps -a +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}')" == "healthy" ]] +[[ "$(docker inspect dhv2-api --format '{{.State.Status}}')" == "running" ]] +[[ "$(docker inspect dhv2-web --format '{{.State.Status}}')" == "running" ]] + +echo +echo "ACEPTACIÓN DE FASE D5.3.10.2: OK" diff --git a/scripts/phase-d5-3-11-1-acceptance.sh b/scripts/phase-d5-3-11-1-acceptance.sh new file mode 100755 index 0000000..56d8642 --- /dev/null +++ b/scripts/phase-d5-3-11-1-acceptance.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT="${ROOT:-/var/www/dhv2.korexlabs.com}" +API_URL="${API_URL:-http://127.0.0.1:3101/api/v3}" +TMP_MIGRATION="/tmp/dhv2-d53111-accept-migration-$$.log" +trap 'rm -f "$TMP_MIGRATION"' EXIT +cd "$ROOT" + +echo "========== D5.3.11.1 · VERSIONES ==========" +[[ "$(node -p "require('./api-v3/package.json').version")" == "0.19.3-11.1" ]] +[[ "$(node -p "require('./web-v2/package.json').version")" == "0.19.3-11.1" ]] +grep -Fq "API_PHASE = 'D5.3.11.1'" api-v3/src/version.ts +grep -Fq "APP_PHASE = 'Fase D5.3.11.1 · Navegación de importados'" web-v2/src/config/version.ts +echo "API: 0.19.3-11.1" +echo "WEB: 0.19.3-11.1" + +echo +echo "========== D5.3.11.1 · DB Y MIGRACIONES ==========" +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}')" == "healthy" ]] +docker compose exec -T db sh -lc 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' +docker compose --profile tools run --rm migrate npm run migration:show 2>&1 | tee "$TMP_MIGRATION" +grep -Fq 'Pending migrations: no' "$TMP_MIGRATION" + +echo +echo "========== D5.3.11.1 · HEALTH ==========" +HEALTH="$(curl -fsS "$API_URL/health")" +echo "$HEALTH" +grep -Fq '"version":"0.19.3-11.1"' <<<"$HEALTH" +grep -Fq '"phase":"D5.3.11.1"' <<<"$HEALTH" +grep -Fq '"database":"ok"' <<<"$HEALTH" + +echo +echo "========== D5.3.11.1 · IMPORTACIÓN PARCIAL ==========" +grep -Fq "@Get('reviews')" api-v3/src/asset-imports/asset-imports.controller.ts +grep -Fq "@Post(':id/apply-safe')" api-v3/src/asset-imports/asset-imports.controller.ts +grep -Fq 'safePlanItems(allItems)' api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq "status: 'PARTIAL'" api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq 'batchApplicationProgress' api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq "history.batch_id=\$1 AND item.action='CREATE' AND item.status='APPLIED'" api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq 'Revisiones pendientes' web-v2/src/features/assets/AssetImportReviewsPanel.tsx +grep -Fq 'Importar registros seguros' web-v2/src/pages/AssetImportsPage.tsx +grep -Fq 'Importación parcial en curso' web-v2/src/pages/AssetImportsPage.tsx +echo "Aplicación segura y bandeja de revisiones: OK" + +echo +echo "========== D5.3.11.1 · MAESTRO HEREDADO ==========" +grep -Fq "@Get(':id/lineage')" api-v3/src/asset-master/assets.controller.ts +grep -Fq 'asset-browser-entry-grid' web-v2/src/features/assets/AssetHierarchyView.tsx +grep -Fq 'asset-browser-breadcrumb' web-v2/src/features/assets/AssetHierarchyView.tsx +echo "Navegación y breadcrumb del Maestro: OK" + +echo +echo "========== D5.3.11.1 · ACTIVOS IMPORTADOS EN NAVEGACIÓN ==========" +grep -Fq 'SELECT asset.operator_company_id AS company_id' api-v3/src/asset-master/asset-operational-relations.service.ts +grep -Fq 'WHERE asset.operational_area_id = $1' api-v3/src/asset-master/asset-operational-relations.service.ts +grep -Fq 'SELECT asset.operational_area_id AS area_id' api-v3/src/asset-master/asset-operational-relations.service.ts +grep -Fq 'WHERE asset.operator_company_id = $1' api-v3/src/asset-master/asset-operational-relations.service.ts +grep -Fq 'Áreas con relación registrada o activos asignados' web-v2/src/features/assets/AssetHierarchyView.tsx +echo "Contexto importado visible sin crear relaciones formales: OK" + +echo +echo "========== D5.3.11.1 · HIGIENE DE CÓDIGO ==========" +if grep -RnhE --include='*.ts' --include='*.tsx' '^[[:space:]]*(//|/\*|\*)' api-v3/src web-v2/src; then + echo "ERROR: quedaron comentarios narrativos en código de producción" + exit 1 +fi +if grep -nE '/\*|\*/' web-v2/src/styles.css; then + echo "ERROR: quedaron comentarios internos en estilos de producción" + exit 1 +fi +if grep -Fq 'Flujo seguro D5.3.9' web-v2/src/pages/AssetImportsPage.tsx; then + echo "ERROR: quedó texto interno de fase en la interfaz" + exit 1 +fi +echo "Código de producción preparado para revisión externa: OK" + +echo +echo "========== D5.3.11.1 · SEGURIDAD HEREDADA ==========" +grep -Fq 'PLAN_DEPENDENCY_REVIEW_CODES' api-v3/src/asset-imports/asset-import-plan.ts +grep -Fq 'private async masterStateFingerprint' api-v3/src/asset-imports/asset-imports.service.ts +STRONG_BACKUP="$(find /root/DH_V2_BACKUPS -maxdepth 1 -type d -name 'INVENTARIO_A_REVISAR_*' -printf '%T@ %p\n' | sort -nr | head -n1 | cut -d' ' -f2-)" +[[ -n "$STRONG_BACKUP" && -s "$STRONG_BACKUP/BACKUP_OK.txt" ]] +grep -Fq 'INVENTARIO A REVISAR' "$STRONG_BACKUP/BACKUP_OK.txt" +echo "Backup fuerte de referencia: $STRONG_BACKUP" + +echo +echo "========== D5.3.11.1 · CONTENEDORES ==========" +docker compose ps -a +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}')" == "healthy" ]] +[[ "$(docker inspect dhv2-api --format '{{.State.Status}}')" == "running" ]] +[[ "$(docker inspect dhv2-web --format '{{.State.Status}}')" == "running" ]] + +echo +echo "ACEPTACIÓN DE FASE D5.3.11.1: OK" diff --git a/scripts/phase-d5-3-11-acceptance.sh b/scripts/phase-d5-3-11-acceptance.sh new file mode 100755 index 0000000..66a7587 --- /dev/null +++ b/scripts/phase-d5-3-11-acceptance.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT="${ROOT:-/var/www/dhv2.korexlabs.com}" +API_URL="${API_URL:-http://127.0.0.1:3101/api/v3}" +TMP_MIGRATION="/tmp/dhv2-d5311-accept-migration-$$.log" +trap 'rm -f "$TMP_MIGRATION"' EXIT +cd "$ROOT" + +echo "========== D5.3.11 · VERSIONES ==========" +[[ "$(node -p "require('./api-v3/package.json').version")" == "0.19.3-11" ]] +[[ "$(node -p "require('./web-v2/package.json').version")" == "0.19.3-11" ]] +grep -Fq "API_PHASE = 'D5.3.11'" api-v3/src/version.ts +grep -Fq "APP_PHASE = 'Fase D5.3.11 · Importación parcial y revisiones'" web-v2/src/config/version.ts +echo "API: 0.19.3-11" +echo "WEB: 0.19.3-11" + +echo +echo "========== D5.3.11 · DB Y MIGRACIONES ==========" +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}')" == "healthy" ]] +docker compose exec -T db sh -lc 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' +docker compose --profile tools run --rm migrate npm run migration:show 2>&1 | tee "$TMP_MIGRATION" +grep -Fq 'Pending migrations: no' "$TMP_MIGRATION" + +echo +echo "========== D5.3.11 · HEALTH ==========" +HEALTH="$(curl -fsS "$API_URL/health")" +echo "$HEALTH" +grep -Fq '"version":"0.19.3-11"' <<<"$HEALTH" +grep -Fq '"phase":"D5.3.11"' <<<"$HEALTH" +grep -Fq '"database":"ok"' <<<"$HEALTH" + +echo +echo "========== D5.3.11 · IMPORTACIÓN PARCIAL ==========" +grep -Fq "@Get('reviews')" api-v3/src/asset-imports/asset-imports.controller.ts +grep -Fq "@Post(':id/apply-safe')" api-v3/src/asset-imports/asset-imports.controller.ts +grep -Fq 'safePlanItems(allItems)' api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq "status: 'PARTIAL'" api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq 'batchApplicationProgress' api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq "history.batch_id=\$1 AND item.action='CREATE' AND item.status='APPLIED'" api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq 'Revisiones pendientes' web-v2/src/features/assets/AssetImportReviewsPanel.tsx +grep -Fq 'Importar registros seguros' web-v2/src/pages/AssetImportsPage.tsx +grep -Fq 'Importación parcial en curso' web-v2/src/pages/AssetImportsPage.tsx +echo "Aplicación segura y bandeja de revisiones: OK" + +echo +echo "========== D5.3.11 · MAESTRO HEREDADO ==========" +grep -Fq "@Get(':id/lineage')" api-v3/src/asset-master/assets.controller.ts +grep -Fq 'asset-browser-entry-grid' web-v2/src/features/assets/AssetHierarchyView.tsx +grep -Fq 'asset-browser-breadcrumb' web-v2/src/features/assets/AssetHierarchyView.tsx +echo "Navegación y breadcrumb del Maestro: OK" + +echo +echo "========== D5.3.11 · HIGIENE DE CÓDIGO ==========" +if grep -RnhE --include='*.ts' --include='*.tsx' '^[[:space:]]*(//|/\*|\*)' api-v3/src web-v2/src; then + echo "ERROR: quedaron comentarios narrativos en código de producción" + exit 1 +fi +if grep -nE '/\*|\*/' web-v2/src/styles.css; then + echo "ERROR: quedaron comentarios internos en estilos de producción" + exit 1 +fi +if grep -Fq 'Flujo seguro D5.3.9' web-v2/src/pages/AssetImportsPage.tsx; then + echo "ERROR: quedó texto interno de fase en la interfaz" + exit 1 +fi +echo "Código de producción preparado para revisión externa: OK" + +echo +echo "========== D5.3.11 · SEGURIDAD HEREDADA ==========" +grep -Fq 'PLAN_DEPENDENCY_REVIEW_CODES' api-v3/src/asset-imports/asset-import-plan.ts +grep -Fq 'private async masterStateFingerprint' api-v3/src/asset-imports/asset-imports.service.ts +STRONG_BACKUP="$(find /root/DH_V2_BACKUPS -maxdepth 1 -type d -name 'INVENTARIO_A_REVISAR_*' -printf '%T@ %p\n' | sort -nr | head -n1 | cut -d' ' -f2-)" +[[ -n "$STRONG_BACKUP" && -s "$STRONG_BACKUP/BACKUP_OK.txt" ]] +grep -Fq 'INVENTARIO A REVISAR' "$STRONG_BACKUP/BACKUP_OK.txt" +echo "Backup fuerte de referencia: $STRONG_BACKUP" + +echo +echo "========== D5.3.11 · CONTENEDORES ==========" +docker compose ps -a +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}')" == "healthy" ]] +[[ "$(docker inspect dhv2-api --format '{{.State.Status}}')" == "running" ]] +[[ "$(docker inspect dhv2-web --format '{{.State.Status}}')" == "running" ]] + +echo +echo "ACEPTACIÓN DE FASE D5.3.11: OK" diff --git a/scripts/phase-d5-3-12-acceptance.sh b/scripts/phase-d5-3-12-acceptance.sh new file mode 100755 index 0000000..e0d1a54 --- /dev/null +++ b/scripts/phase-d5-3-12-acceptance.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT="${ROOT:-/var/www/dhv2.korexlabs.com}" +API_URL="${API_URL:-http://127.0.0.1:3101/api/v3}" +TMP_MIGRATION="/tmp/dhv2-d5312-accept-migration-$$.log" +trap 'rm -f "$TMP_MIGRATION"' EXIT +cd "$ROOT" + +echo "========== D5.3.12 · VERSIONES ==========" +[[ "$(node -p "require('./api-v3/package.json').version")" == "0.19.3-12" ]] +[[ "$(node -p "require('./web-v2/package.json').version")" == "0.19.3-12" ]] +grep -Fq "API_PHASE = 'D5.3.12'" api-v3/src/version.ts +grep -Fq "APP_PHASE = 'Fase D5.3.12 · Inventarios por empresa'" web-v2/src/config/version.ts +echo "API: 0.19.3-12" +echo "WEB: 0.19.3-12" + +echo +echo "========== D5.3.12 · DB Y MIGRACIONES ==========" +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}')" == "healthy" ]] +docker compose exec -T db sh -lc 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' +docker compose --profile tools run --rm migrate npm run migration:show 2>&1 | tee "$TMP_MIGRATION" +grep -Fq 'Pending migrations: no' "$TMP_MIGRATION" + +echo +echo "========== D5.3.12 · HEALTH ==========" +HEALTH="$(curl -fsS "$API_URL/health")" +echo "$HEALTH" +grep -Fq '"version":"0.19.3-12"' <<<"$HEALTH" +grep -Fq '"phase":"D5.3.12"' <<<"$HEALTH" +grep -Fq '"database":"ok"' <<<"$HEALTH" + +echo +echo "========== D5.3.12 · INVENTARIOS POR EMPRESA ==========" +grep -Fq "to: '/inventarios', label: 'Inventarios'" web-v2/src/layout/AppLayout.tsx +grep -Fq 'INVENTARIOS

Inventarios

' web-v2/src/pages/AssetsPage.tsx +grep -Fq 'INVENTARIOS POR EMPRESA' web-v2/src/features/assets/AssetHierarchyView.tsx +grep -Fq 'Cada empresa tiene su propio inventario.' web-v2/src/features/assets/AssetHierarchyView.tsx +grep -Fq 'INVENTARIO DE EMPRESA' web-v2/src/features/assets/AssetHierarchyView.tsx +grep -Fq 'Áreas del inventario' web-v2/src/features/assets/AssetHierarchyView.tsx +grep -Fq 'Ruta del inventario' web-v2/src/features/assets/AssetHierarchyView.tsx +grep -Fq 'path="/inventarios"' web-v2/src/app/App.tsx +grep -Fq 'path="/activos"' web-v2/src/app/App.tsx +if grep -RIn --include='*.ts' --include='*.tsx' 'Maestro' web-v2/src; then + echo "ERROR: quedó terminología anterior visible en el frontend" + exit 1 +fi +echo "Concepto Inventarios aplicado en navegación y presentación: OK" + +echo +echo "========== D5.3.12 · IMPORTACIÓN PARCIAL HEREDADA ==========" +grep -Fq "@Get('reviews')" api-v3/src/asset-imports/asset-imports.controller.ts +grep -Fq "@Post(':id/apply-safe')" api-v3/src/asset-imports/asset-imports.controller.ts +grep -Fq 'safePlanItems(allItems)' api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq 'Revisiones pendientes' web-v2/src/features/assets/AssetImportReviewsPanel.tsx +grep -Fq 'Importar registros seguros' web-v2/src/pages/AssetImportsPage.tsx +echo "Importación parcial y revisiones: OK" + +echo +echo "========== D5.3.12 · NAVEGACIÓN DE IMPORTADOS HEREDADA ==========" +grep -Fq 'SELECT asset.operator_company_id AS company_id' api-v3/src/asset-master/asset-operational-relations.service.ts +grep -Fq 'WHERE asset.operational_area_id = $1' api-v3/src/asset-master/asset-operational-relations.service.ts +grep -Fq 'SELECT asset.operational_area_id AS area_id' api-v3/src/asset-master/asset-operational-relations.service.ts +grep -Fq 'WHERE asset.operator_company_id = $1' api-v3/src/asset-master/asset-operational-relations.service.ts +echo "Inventarios importados navegables por empresa: OK" + +echo +echo "========== D5.3.12 · HIGIENE DE CÓDIGO ==========" +if grep -RnhE --include='*.ts' --include='*.tsx' '^[[:space:]]*(//|/\*|\*)' api-v3/src web-v2/src; then + echo "ERROR: quedaron comentarios narrativos en código de producción" + exit 1 +fi +if grep -nE '/\*|\*/' web-v2/src/styles.css; then + echo "ERROR: quedaron comentarios internos en estilos de producción" + exit 1 +fi +echo "Código de producción preparado para revisión externa: OK" + +echo +echo "========== D5.3.12 · SEGURIDAD HEREDADA ==========" +grep -Fq 'private async masterStateFingerprint' api-v3/src/asset-imports/asset-imports.service.ts +STRONG_BACKUP="$(find /root/DH_V2_BACKUPS -maxdepth 1 -type d -name 'INVENTARIO_A_REVISAR_*' -printf '%T@ %p\n' | sort -nr | head -n1 | cut -d' ' -f2-)" +[[ -n "$STRONG_BACKUP" && -s "$STRONG_BACKUP/BACKUP_OK.txt" ]] +grep -Fq 'INVENTARIO A REVISAR' "$STRONG_BACKUP/BACKUP_OK.txt" +echo "Backup fuerte de referencia: $STRONG_BACKUP" + +echo +echo "========== D5.3.12 · CONTENEDORES ==========" +docker compose ps -a +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}')" == "healthy" ]] +[[ "$(docker inspect dhv2-api --format '{{.State.Status}}')" == "running" ]] +[[ "$(docker inspect dhv2-web --format '{{.State.Status}}')" == "running" ]] + +echo +echo "ACEPTACIÓN DE FASE D5.3.12: OK" diff --git a/scripts/phase-d5-3-13-acceptance.sh b/scripts/phase-d5-3-13-acceptance.sh new file mode 100755 index 0000000..994625e --- /dev/null +++ b/scripts/phase-d5-3-13-acceptance.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT="${ROOT:-/var/www/dhv2.korexlabs.com}" +API_URL="${API_URL:-http://127.0.0.1:3101/api/v3}" +TMP_MIGRATION="/tmp/dhv2-d5313-accept-migration-$$.log" +trap 'rm -f "$TMP_MIGRATION"' EXIT +cd "$ROOT" + +echo "========== D5.3.13 · VERSIONES ==========" +[[ "$(node -p "require('./api-v3/package.json').version")" == "0.19.3-13" ]] +[[ "$(node -p "require('./web-v2/package.json').version")" == "0.19.3-13" ]] +grep -Fq "API_PHASE = 'D5.3.13'" api-v3/src/version.ts +grep -Fq "APP_PHASE = 'Fase D5.3.13 · Seguimiento de hallazgos'" web-v2/src/config/version.ts +echo "API: 0.19.3-13" +echo "WEB: 0.19.3-13" + +echo +echo "========== D5.3.13 · DB Y MIGRACIONES ==========" +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}')" == "healthy" ]] +docker compose exec -T db sh -lc 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' +docker compose --profile tools run --rm migrate npm run migration:show 2>&1 | tee "$TMP_MIGRATION" +grep -Fq 'Pending migrations: no' "$TMP_MIGRATION" + +echo +echo "========== D5.3.13 · HEALTH ==========" +HEALTH="$(curl -fsS "$API_URL/health")" +echo "$HEALTH" +grep -Fq '"version":"0.19.3-13"' <<<"$HEALTH" +grep -Fq '"phase":"D5.3.13"' <<<"$HEALTH" +grep -Fq '"database":"ok"' <<<"$HEALTH" + +echo +echo "========== D5.3.13 · DOS VENCIMIENTOS ==========" +grep -Fq "COMPANY_OVERDUE" api-v3/src/inspection-findings/dto/list-inspection-findings-query.dto.ts +grep -Fq "VERIFICATION_OVERDUE" api-v3/src/inspection-findings/dto/list-inspection-findings-query.dto.ts +grep -Fq 'companyOverdue' api-v3/src/inspection-findings/inspection-findings.service.ts +grep -Fq 'verificationOverdue' api-v3/src/inspection-findings/inspection-findings.service.ts +grep -Fq 'Vencimiento administrativo' web-v2/src/pages/FindingsPage.tsx +grep -Fq 'Vencimiento de verificación' web-v2/src/pages/FindingsPage.tsx +grep -Fq 'Fecha de verificación / resolución' web-v2/src/pages/FindingDetailPage.tsx +echo "Vencimiento administrativo y vencimiento de verificación separados: OK" + +echo +echo "========== D5.3.13 · BANDEJA DE HALLAZGOS ==========" +grep -Fq "this.findings.listGlobal(query)" api-v3/src/inspection-findings/inspection-findings.controller.ts +grep -Fq 'listInspectionFindingsGlobal' web-v2/src/lib/api.ts +grep -Fq 'Bandeja de trabajo para respuestas de empresas' web-v2/src/pages/FindingsPage.tsx +grep -Fq 'path="/hallazgos/:id"' web-v2/src/app/App.tsx +grep -Fq 'PROGRAMAR VERIFICACIÓN' web-v2/src/pages/FindingsPage.tsx +echo "Bandeja global de seguimiento: OK" + +echo +echo "========== D5.3.13 · RESPUESTA, DOCUMENTACIÓN Y CIERRE ==========" +grep -Fq "@Patch(':id/close')" api-v3/src/inspection-findings/inspection-findings.controller.ts +grep -Fq 'finding.status = InspectionFindingStatus.CLOSED' api-v3/src/inspection-findings/inspection-findings.service.ts +grep -Fq 'INSPECTION_FINDING_CLOSED' api-v3/src/database/entities/audit-event.entity.ts +grep -Fq 'closeInspectionFinding' web-v2/src/pages/FindingDetailPage.tsx +grep -Fq 'InspectionEvidencePanel' web-v2/src/pages/FindingDetailPage.tsx +echo "Seguimiento de oficina y cierre auditado: OK" + +echo +echo "========== D5.3.13 · DASHBOARD ==========" +grep -Fq 'overdueCompanyResponses' api-v3/src/dashboard/dashboard.service.ts +grep -Fq 'awaitingVerificationSchedule' api-v3/src/dashboard/dashboard.service.ts +grep -Fq 'VERIFICACIONES VENCIDAS' web-v2/src/pages/DashboardPage.tsx +grep -Fq 'ESPERANDO RESPUESTA' web-v2/src/pages/DashboardPage.tsx +echo "Dashboard administrativo y operativo: OK" + +echo +echo "========== D5.3.13 · IMPORTACIÓN E INVENTARIOS HEREDADOS ==========" +grep -Fq "to: '/inventarios', label: 'Inventarios'" web-v2/src/layout/AppLayout.tsx +grep -Fq "@Get('reviews')" api-v3/src/asset-imports/asset-imports.controller.ts +grep -Fq "@Post(':id/apply-safe')" api-v3/src/asset-imports/asset-imports.controller.ts +echo "Inventarios e importación parcial preservados: OK" + +echo +echo "========== D5.3.13 · HIGIENE DE CÓDIGO ==========" +if grep -RnhE --include='*.ts' --include='*.tsx' '^[[:space:]]*(//|/\*|\*)' api-v3/src web-v2/src; then + echo "ERROR: quedaron comentarios narrativos en código de producción" + exit 1 +fi +if grep -nE '/\*|\*/' web-v2/src/styles.css; then + echo "ERROR: quedaron comentarios internos en estilos de producción" + exit 1 +fi +echo "Código de producción preparado para revisión externa: OK" + +echo +echo "========== D5.3.13 · SEGURIDAD HEREDADA ==========" +STRONG_BACKUP="$(find /root/DH_V2_BACKUPS -maxdepth 1 -type d -name 'INVENTARIO_A_REVISAR_*' -printf '%T@ %p\n' | sort -nr | head -n1 | cut -d' ' -f2-)" +[[ -n "$STRONG_BACKUP" && -s "$STRONG_BACKUP/BACKUP_OK.txt" ]] +grep -Fq 'INVENTARIO A REVISAR' "$STRONG_BACKUP/BACKUP_OK.txt" +echo "Backup fuerte de referencia: $STRONG_BACKUP" + +echo +echo "========== D5.3.13 · CONTENEDORES ==========" +docker compose ps -a +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}')" == "healthy" ]] +[[ "$(docker inspect dhv2-api --format '{{.State.Status}}')" == "running" ]] +[[ "$(docker inspect dhv2-web --format '{{.State.Status}}')" == "running" ]] + +echo +echo "ACEPTACIÓN DE FASE D5.3.13: OK" diff --git a/scripts/phase-d5-3-14-acceptance.sh b/scripts/phase-d5-3-14-acceptance.sh new file mode 100755 index 0000000..f972884 --- /dev/null +++ b/scripts/phase-d5-3-14-acceptance.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT="${ROOT:-/var/www/dhv2.korexlabs.com}" +API_URL="${API_URL:-http://127.0.0.1:3101/api/v3}" +TMP_MIGRATION="/tmp/dhv2-d5314-accept-migration-$$.log" +trap 'rm -f "$TMP_MIGRATION"' EXIT +cd "$ROOT" + +echo "========== D5.3.14 · VERSIONES ==========" +[[ "$(node -p "require('./api-v3/package.json').version")" == "0.19.3-14" ]] +[[ "$(node -p "require('./web-v2/package.json').version")" == "0.19.3-14" ]] +grep -Fq "API_PHASE = 'D5.3.14'" api-v3/src/version.ts +grep -Fq "APP_PHASE = 'Fase D5.3.14 · Expediente técnico'" web-v2/src/config/version.ts +echo "API: 0.19.3-14" +echo "WEB: 0.19.3-14" + +echo +echo "========== D5.3.14 · DB Y MIGRACIONES ==========" +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}')" == "healthy" ]] +docker compose exec -T db sh -lc 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' +docker compose --profile tools run --rm migrate npm run migration:show 2>&1 | tee "$TMP_MIGRATION" +grep -Fq 'Pending migrations: no' "$TMP_MIGRATION" + +echo +echo "========== D5.3.14 · HEALTH ==========" +HEALTH="$(curl -fsS "$API_URL/health")" +echo "$HEALTH" +grep -Fq '"version":"0.19.3-14"' <<<"$HEALTH" +grep -Fq '"phase":"D5.3.14"' <<<"$HEALTH" +grep -Fq '"database":"ok"' <<<"$HEALTH" + +echo +echo "========== D5.3.14 · EXPEDIENTE TÉCNICO ==========" +grep -Fq "@Get(':id/dossier')" api-v3/src/asset-master/assets.controller.ts +grep -Fq 'async dossier(id: string)' api-v3/src/asset-master/assets.service.ts +grep -Fq 'inspection_finding_evidence' api-v3/src/asset-master/assets.service.ts +grep -Fq 'inspection_finding_communications' api-v3/src/asset-master/assets.service.ts +grep -Fq 'asset_source_documents' api-v3/src/asset-master/assets.service.ts +grep -Fq 'asset_versions' api-v3/src/asset-master/assets.service.ts +grep -Fq "label: 'Expediente'" web-v2/src/pages/AssetEditorPage.tsx +grep -Fq 'Historia completa del elemento' web-v2/src/features/assets/AssetDossierPanel.tsx +grep -Fq 'Línea de tiempo' web-v2/src/features/assets/AssetDossierPanel.tsx +echo "Expediente técnico y cronología unificada: OK" + +echo +echo "========== D5.3.14 · NAVEGACIÓN CRUZADA ==========" +grep -Fq 'to={`/inspecciones/${visit.id}`}' web-v2/src/features/assets/AssetDossierPanel.tsx +grep -Fq 'to={`/inspecciones/actas/${act.id}`}' web-v2/src/features/assets/AssetDossierPanel.tsx +grep -Fq 'to={`/hallazgos/${finding.id}`}' web-v2/src/features/assets/AssetDossierPanel.tsx +grep -Fq 'Vencimiento empresa' web-v2/src/features/assets/AssetDossierPanel.tsx +grep -Fq 'Verificación' web-v2/src/features/assets/AssetDossierPanel.tsx +echo "Inventario, inspecciones, actas y hallazgos conectados: OK" + +echo +echo "========== D5.3.14 · SEGUIMIENTO HEREDADO ==========" +grep -Fq 'companyOverdue' api-v3/src/inspection-findings/inspection-findings.service.ts +grep -Fq 'verificationOverdue' api-v3/src/inspection-findings/inspection-findings.service.ts +grep -Fq 'Fecha de verificación / resolución' web-v2/src/pages/FindingDetailPage.tsx +echo "Dos vencimientos del hallazgo preservados: OK" + +echo +echo "========== D5.3.14 · HIGIENE DE CÓDIGO ==========" +if grep -RnhE --include='*.ts' --include='*.tsx' '^[[:space:]]*(//|/\*|\*)' api-v3/src web-v2/src; then + echo "ERROR: quedaron comentarios narrativos en código de producción" + exit 1 +fi +if grep -nE '/\*|\*/' web-v2/src/styles.css; then + echo "ERROR: quedaron comentarios internos en estilos de producción" + exit 1 +fi +echo "Código de producción preparado para revisión externa: OK" + +echo +echo "========== D5.3.14 · SEGURIDAD HEREDADA ==========" +STRONG_BACKUP="$(find /root/DH_V2_BACKUPS -maxdepth 1 -type d -name 'INVENTARIO_A_REVISAR_*' -printf '%T@ %p\n' | sort -nr | head -n1 | cut -d' ' -f2-)" +[[ -n "$STRONG_BACKUP" && -s "$STRONG_BACKUP/BACKUP_OK.txt" ]] +grep -Fq 'INVENTARIO A REVISAR' "$STRONG_BACKUP/BACKUP_OK.txt" +echo "Backup fuerte de referencia: $STRONG_BACKUP" + +echo +echo "========== D5.3.14 · CONTENEDORES ==========" +docker compose ps -a +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}')" == "healthy" ]] +[[ "$(docker inspect dhv2-api --format '{{.State.Status}}')" == "running" ]] +[[ "$(docker inspect dhv2-web --format '{{.State.Status}}')" == "running" ]] + +echo +echo "ACEPTACIÓN DE FASE D5.3.14: OK" diff --git a/scripts/phase-d5-3-15-1-acceptance.sh b/scripts/phase-d5-3-15-1-acceptance.sh new file mode 100755 index 0000000..301bf9c --- /dev/null +++ b/scripts/phase-d5-3-15-1-acceptance.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT="${ROOT:-/var/www/dhv2.korexlabs.com}" +API_URL="${API_URL:-http://127.0.0.1:3101/api/v3}" +TMP_MIGRATION="/tmp/dhv2-d53151-accept-migration-$$.log" +TMP_SCHEMA="/tmp/dhv2-d53151-schema-$$.log" +trap 'rm -f "$TMP_MIGRATION" "$TMP_SCHEMA"' EXIT +cd "$ROOT" + +echo "========== D5.3.15.1 · VERSIONES ==========" +[[ "$(node -p "require('./api-v3/package.json').version")" == "0.19.3-15.1" ]] +[[ "$(node -p "require('./web-v2/package.json').version")" == "0.19.3-15.1" ]] +grep -Fq "API_PHASE = 'D5.3.15.1'" api-v3/src/version.ts +grep -Fq "APP_PHASE = 'Fase D5.3.15.1 · Centro documental'" web-v2/src/config/version.ts +echo "API: 0.19.3-15.1" +echo "WEB: 0.19.3-15.1" + +echo +echo "========== D5.3.15.1 · DB Y MIGRACIONES ==========" +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}')" == "healthy" ]] +docker compose exec -T db sh -lc 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' +docker compose --profile tools run --rm migrate npm run migration:show 2>&1 | tee "$TMP_MIGRATION" +grep -Fq 'Pending migrations: no' "$TMP_MIGRATION" +docker compose exec -T db sh -lc 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Atc "SELECT table_name FROM information_schema.tables WHERE table_schema='\''public'\'' AND table_name='\''inspection_reports'\''; SELECT code FROM permissions WHERE code IN ('\''inspection_reports.read'\'','\''inspection_reports.generate'\'') ORDER BY code;"' | tee "$TMP_SCHEMA" +grep -Fxq 'inspection_reports' "$TMP_SCHEMA" +grep -Fxq 'inspection_reports.generate' "$TMP_SCHEMA" +grep -Fxq 'inspection_reports.read' "$TMP_SCHEMA" + +echo +echo "========== D5.3.15.1 · HEALTH ==========" +HEALTH="$(curl -fsS "$API_URL/health")" +echo "$HEALTH" +grep -Fq '"version":"0.19.3-15.1"' <<<"$HEALTH" +grep -Fq '"phase":"D5.3.15.1"' <<<"$HEALTH" +grep -Fq '"database":"ok"' <<<"$HEALTH" + +echo +echo "========== D5.3.15.1 · CENTRO DOCUMENTAL ==========" +grep -Fq 'async listGlobal(query: ListInspectionActsQueryDto)' api-v3/src/inspection-acts/inspection-acts.service.ts +grep -Fq "@Controller('inspection-reports')" api-v3/src/inspection-reports/inspection-reports.controller.ts +grep -Fq 'async listPending(query: ListInspectionReportsQueryDto)' api-v3/src/inspection-reports/inspection-reports.service.ts +grep -Fq 'DocumentSequenceType.REPORT' api-v3/src/inspection-reports/inspection-reports.service.ts +grep -Fq 'sha256CanonicalJson(frozenSnapshot)' api-v3/src/inspection-reports/inspection-reports.service.ts +grep -Fq 'path="/actas"' web-v2/src/app/App.tsx +grep -Fq 'path="/informes"' web-v2/src/app/App.tsx +grep -Fq "label: 'Actas'" web-v2/src/layout/AppLayout.tsx +grep -Fq "label: 'Informes'" web-v2/src/layout/AppLayout.tsx +grep -Fq 'inspectionReports,' api-v3/src/asset-master/assets.service.ts +grep -Fq 'dossier.inspectionReports' web-v2/src/features/assets/AssetDossierPanel.tsx +echo "Actas e informes globales conectados: OK" + +echo +echo "========== D5.3.15.1 · EMISIÓN CONTROLADA ==========" +grep -Fq 'assertMobileInspector(principal)' api-v3/src/inspection-reports/inspection-reports.service.ts +grep -Fq 'INSPECTION_REPORT_ACT_NOT_CLOSED' api-v3/src/inspection-reports/inspection-reports.service.ts +grep -Fq 'dhv2_guard_inspection_report_frozen' api-v3/src/database/migrations/1788026400000-phase-d5-3-15-document-center.ts +grep -Fq 'Pendientes de emisión' web-v2/src/pages/ReportsPage.tsx +echo "Numeración y congelado del informe protegidos: OK" + +echo +echo "========== D5.3.15.1 · HIGIENE DE CÓDIGO ==========" +if grep -RnhE --include='*.ts' --include='*.tsx' '^[[:space:]]*(//|/\*|\*)' api-v3/src web-v2/src; then + echo "ERROR: quedaron comentarios narrativos en código de producción" + exit 1 +fi +if grep -nE '/\*|\*/' web-v2/src/styles.css; then + echo "ERROR: quedaron comentarios internos en estilos de producción" + exit 1 +fi +echo "Código de producción preparado para revisión externa: OK" + +echo +echo "========== D5.3.15.1 · SEGURIDAD HEREDADA ==========" +STRONG_BACKUP="$(find /root/DH_V2_BACKUPS -maxdepth 1 -type d -name 'INVENTARIO_A_REVISAR_*' -printf '%T@ %p\n' | sort -nr | head -n1 | cut -d' ' -f2-)" +[[ -n "$STRONG_BACKUP" && -s "$STRONG_BACKUP/BACKUP_OK.txt" ]] +grep -Fq 'INVENTARIO A REVISAR' "$STRONG_BACKUP/BACKUP_OK.txt" +echo "Backup fuerte de referencia: $STRONG_BACKUP" + +echo +echo "========== D5.3.15.1 · CONTENEDORES ==========" +docker compose ps -a +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}')" == "healthy" ]] +[[ "$(docker inspect dhv2-api --format '{{.State.Status}}')" == "running" ]] +[[ "$(docker inspect dhv2-web --format '{{.State.Status}}')" == "running" ]] + +echo +echo "ACEPTACIÓN DE FASE D5.3.15.1: OK" diff --git a/scripts/phase-d5-3-16-acceptance.sh b/scripts/phase-d5-3-16-acceptance.sh new file mode 100755 index 0000000..b1238c5 --- /dev/null +++ b/scripts/phase-d5-3-16-acceptance.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT="${ROOT:-/var/www/dhv2.korexlabs.com}" +API_URL="${API_URL:-http://127.0.0.1:3101/api/v3}" +TMP_MIGRATION="/tmp/dhv2-d5316-accept-migration-$$.log" +TMP_SCHEMA="/tmp/dhv2-d5316-schema-$$.log" +trap 'rm -f "$TMP_MIGRATION" "$TMP_SCHEMA"' EXIT +cd "$ROOT" + +echo "========== D5.3.16 · VERSIONES ==========" +[[ "$(node -p "require('./api-v3/package.json').version")" == "0.19.3-16" ]] +[[ "$(node -p "require('./web-v2/package.json').version")" == "0.19.3-16" ]] +grep -Fq "API_PHASE = 'D5.3.16'" api-v3/src/version.ts +grep -Fq "APP_PHASE = 'Fase D5.3.16 · Planificación de verificaciones'" web-v2/src/config/version.ts +echo "API: 0.19.3-16" +echo "WEB: 0.19.3-16" + +echo +echo "========== D5.3.16 · DB Y MIGRACIONES ==========" +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}')" == "healthy" ]] +docker compose exec -T db sh -lc 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' +docker compose --profile tools run --rm migrate npm run migration:show 2>&1 | tee "$TMP_MIGRATION" +grep -Fq 'Pending migrations: no' "$TMP_MIGRATION" +docker compose exec -T db sh -lc 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Atc "SELECT table_name FROM information_schema.tables WHERE table_schema='\''public'\'' AND table_name='\''inspection_finding_verification_visits'\''; SELECT code FROM permissions WHERE code='\''inspection_verifications.plan'\'';"' | tee "$TMP_SCHEMA" +grep -Fxq 'inspection_finding_verification_visits' "$TMP_SCHEMA" +grep -Fxq 'inspection_verifications.plan' "$TMP_SCHEMA" + +echo +echo "========== D5.3.16 · HEALTH ==========" +HEALTH="$(curl -fsS "$API_URL/health")" +echo "$HEALTH" +grep -Fq '"version":"0.19.3-16"' <<<"$HEALTH" +grep -Fq '"phase":"D5.3.16"' <<<"$HEALTH" +grep -Fq '"database":"ok"' <<<"$HEALTH" + +echo +echo "========== D5.3.16 · DOS VENCIMIENTOS ==========" +grep -Fq 'finding.company_response_received_on IS NOT NULL' api-v3/src/inspection-verifications/inspection-verifications.service.ts +grep -Fq 'finding.next_control_on IS NOT NULL' api-v3/src/inspection-verifications/inspection-verifications.service.ts +grep -Fq 'La fecha de verificación no es el vencimiento administrativo.' web-v2/src/pages/VerificationPlanningPage.tsx +echo "Seguimiento administrativo y planificación operativa separados: OK" + +echo +echo "========== D5.3.16 · PLANIFICACIÓN ==========" +grep -Fq "@Controller('inspection-verifications')" api-v3/src/inspection-verifications/inspection-verifications.controller.ts +grep -Fq 'VERIFICATION_CONTEXT_MIXED' api-v3/src/inspection-verifications/inspection-verifications.service.ts +grep -Fq 'VERIFICATION_ALREADY_PLANNED' api-v3/src/inspection-verifications/inspection-verifications.service.ts +grep -Fq 'inspection_finding_verification_visits' api-v3/src/inspection-verifications/inspection-verifications.service.ts +grep -Fq 'path="/hallazgos/planificacion"' web-v2/src/app/App.tsx +grep -Fq 'Crear visita de verificación' web-v2/src/pages/VerificationPlanningPage.tsx +echo "Bandeja y creación trazable de visitas de verificación: OK" + +echo +echo "========== D5.3.16 · HIGIENE DE CÓDIGO ==========" +if grep -RnhE --include='*.ts' --include='*.tsx' '^[[:space:]]*(//|/\*|\*)' api-v3/src web-v2/src; then + echo "ERROR: quedaron comentarios narrativos en código de producción" + exit 1 +fi +if grep -nE '/\*|\*/' web-v2/src/styles.css; then + echo "ERROR: quedaron comentarios internos en estilos de producción" + exit 1 +fi +echo "Código de producción preparado para revisión externa: OK" + +echo +echo "========== D5.3.16 · SEGURIDAD HEREDADA ==========" +STRONG_BACKUP="$(find /root/DH_V2_BACKUPS -maxdepth 1 -type d -name 'INVENTARIO_A_REVISAR_*' -printf '%T@ %p\n' | sort -nr | head -n1 | cut -d' ' -f2-)" +[[ -n "$STRONG_BACKUP" && -s "$STRONG_BACKUP/BACKUP_OK.txt" ]] +grep -Fq 'INVENTARIO A REVISAR' "$STRONG_BACKUP/BACKUP_OK.txt" +echo "Backup fuerte de referencia: $STRONG_BACKUP" + +echo +echo "========== D5.3.16 · CONTENEDORES ==========" +docker compose ps -a +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}')" == "healthy" ]] +[[ "$(docker inspect dhv2-api --format '{{.State.Status}}')" == "running" ]] +[[ "$(docker inspect dhv2-web --format '{{.State.Status}}')" == "running" ]] + +echo +echo "ACEPTACIÓN DE FASE D5.3.16: OK" diff --git a/scripts/phase-d5-3-17-1-acceptance.sh b/scripts/phase-d5-3-17-1-acceptance.sh new file mode 100755 index 0000000..5741a1d --- /dev/null +++ b/scripts/phase-d5-3-17-1-acceptance.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT="${ROOT:-/var/www/dhv2.korexlabs.com}" +API_URL="${API_URL:-http://127.0.0.1:3101/api/v3}" +TMP_MIGRATION="/tmp/dhv2-d53171-accept-migration-$$.log" +TMP_SCHEMA="/tmp/dhv2-d53171-schema-$$.log" +trap 'rm -f "$TMP_MIGRATION" "$TMP_SCHEMA"' EXIT +cd "$ROOT" + +echo "========== D5.3.17.1 · VERSIONES ==========" +[[ "$(node -p "require('./api-v3/package.json').version")" == "0.19.3-17.1" ]] +[[ "$(node -p "require('./web-v2/package.json').version")" == "0.19.3-17.1" ]] +grep -Fq "API_PHASE = 'D5.3.17.1'" api-v3/src/version.ts +grep -Fq "APP_PHASE = 'Fase D5.3.17.1 · Verificación de campo'" web-v2/src/config/version.ts +echo "API: 0.19.3-17.1" +echo "WEB: 0.19.3-17.1" + +echo +echo "========== D5.3.17.1 · DB Y MIGRACIONES ==========" +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}')" == "healthy" ]] +docker compose exec -T db sh -lc 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' +docker compose --profile tools run --rm migrate npm run migration:show 2>&1 | tee "$TMP_MIGRATION" +grep -Fq 'Pending migrations: no' "$TMP_MIGRATION" +docker compose exec -T db sh -lc 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Atc "SELECT table_name FROM information_schema.tables WHERE table_schema='\''public'\'' AND table_name='\''inspection_finding_verification_visits'\''; SELECT column_name FROM information_schema.columns WHERE table_schema='\''public'\'' AND table_name='\''inspection_finding_verification_visits'\'' AND column_name IN ('\''outcome'\'','\''verified_at'\'','\''target_control_on'\'','\''rescheduled_control_on'\'') ORDER BY column_name;"' | tee "$TMP_SCHEMA" +grep -Fxq 'inspection_finding_verification_visits' "$TMP_SCHEMA" +grep -Fxq 'outcome' "$TMP_SCHEMA" +grep -Fxq 'rescheduled_control_on' "$TMP_SCHEMA" +grep -Fxq 'target_control_on' "$TMP_SCHEMA" +grep -Fxq 'verified_at' "$TMP_SCHEMA" + +echo +echo "========== D5.3.17.1 · HEALTH ==========" +HEALTH="$(curl -fsS "$API_URL/health")" +echo "$HEALTH" +grep -Fq '"version":"0.19.3-17.1"' <<<"$HEALTH" +grep -Fq '"phase":"D5.3.17.1"' <<<"$HEALTH" +grep -Fq '"database":"ok"' <<<"$HEALTH" + +echo +echo "========== D5.3.17.1 · RESULTADO DE CAMPO ==========" +grep -Fq "@Patch('visits/:visitId/findings/:findingId/result')" api-v3/src/inspection-verifications/inspection-verifications.controller.ts +grep -Fq 'InspectionVerificationOutcome.RESOLVED' api-v3/src/inspection-verifications/inspection-verifications.service.ts +grep -Fq 'InspectionVerificationOutcome.NOT_RESOLVED' api-v3/src/inspection-verifications/inspection-verifications.service.ts +grep -Fq 'InspectionVerificationOutcome.REQUIRES_NEW_DATE' api-v3/src/inspection-verifications/inspection-verifications.service.ts +grep -Fq 'INSPECTION_VERIFICATION_RESULTS_REQUIRED' api-v3/src/inspection-closing/inspection-closing.service.ts +echo "Resultado de verificación controlado: OK" + +echo +echo "========== D5.3.17.1 · EVIDENCIA Y BANDEJA ==========" +grep -Fq "VERIFICATION = 'VERIFICATION'" api-v3/src/database/entities/inspection-finding-evidence.entity.ts +grep -Fq 'verification_visit_id' api-v3/src/inspection-findings/inspection-evidence.service.ts +grep -Fq 'READY_TO_CLOSE' api-v3/src/inspection-findings/inspection-findings.service.ts +grep -Fq 'Listos para cerrar' web-v2/src/pages/FindingsPage.tsx +echo "Evidencia de verificación y cierre administrativo: OK" + +echo +echo "========== D5.3.17.1 · HIGIENE DE CÓDIGO ==========" +if grep -RnhE --include='*.ts' --include='*.tsx' '^[[:space:]]*(//|/\*|\*)' api-v3/src web-v2/src; then + echo "ERROR: quedaron comentarios narrativos en código de producción" + exit 1 +fi +if grep -nE '/\*|\*/' web-v2/src/styles.css; then + echo "ERROR: quedaron comentarios internos en estilos de producción" + exit 1 +fi +echo "Código de producción preparado para revisión externa: OK" + +echo +echo "========== D5.3.17.1 · SEGURIDAD HEREDADA ==========" +STRONG_BACKUP="$(find /root/DH_V2_BACKUPS -maxdepth 1 -type d -name 'INVENTARIO_A_REVISAR_*' -printf '%T@ %p\n' | sort -nr | head -n1 | cut -d' ' -f2-)" +[[ -n "$STRONG_BACKUP" && -s "$STRONG_BACKUP/BACKUP_OK.txt" ]] +grep -Fq 'INVENTARIO A REVISAR' "$STRONG_BACKUP/BACKUP_OK.txt" +echo "Backup fuerte de referencia: $STRONG_BACKUP" + +echo +echo "========== D5.3.17.1 · CONTENEDORES ==========" +docker compose ps -a +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}')" == "healthy" ]] +[[ "$(docker inspect dhv2-api --format '{{.State.Status}}')" == "running" ]] +[[ "$(docker inspect dhv2-web --format '{{.State.Status}}')" == "running" ]] + +echo +echo "ACEPTACIÓN DE FASE D5.3.17.1: OK" diff --git a/scripts/phase-d5-3-17-acceptance.sh b/scripts/phase-d5-3-17-acceptance.sh new file mode 100755 index 0000000..fb1a982 --- /dev/null +++ b/scripts/phase-d5-3-17-acceptance.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT="${ROOT:-/var/www/dhv2.korexlabs.com}" +API_URL="${API_URL:-http://127.0.0.1:3101/api/v3}" +TMP_MIGRATION="/tmp/dhv2-d5317-accept-migration-$$.log" +TMP_SCHEMA="/tmp/dhv2-d5317-schema-$$.log" +trap 'rm -f "$TMP_MIGRATION" "$TMP_SCHEMA"' EXIT +cd "$ROOT" + +echo "========== D5.3.17 · VERSIONES ==========" +[[ "$(node -p "require('./api-v3/package.json').version")" == "0.19.3-17" ]] +[[ "$(node -p "require('./web-v2/package.json').version")" == "0.19.3-17" ]] +grep -Fq "API_PHASE = 'D5.3.17'" api-v3/src/version.ts +grep -Fq "APP_PHASE = 'Fase D5.3.17 · Verificación de campo'" web-v2/src/config/version.ts +echo "API: 0.19.3-17" +echo "WEB: 0.19.3-17" + +echo +echo "========== D5.3.17 · DB Y MIGRACIONES ==========" +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}')" == "healthy" ]] +docker compose exec -T db sh -lc 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' +docker compose --profile tools run --rm migrate npm run migration:show 2>&1 | tee "$TMP_MIGRATION" +grep -Fq 'Pending migrations: no' "$TMP_MIGRATION" +docker compose exec -T db sh -lc 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Atc "SELECT table_name FROM information_schema.tables WHERE table_schema='\''public'\'' AND table_name='\''inspection_finding_verification_visits'\''; SELECT column_name FROM information_schema.columns WHERE table_schema='\''public'\'' AND table_name='\''inspection_finding_verification_visits'\'' AND column_name IN ('\''outcome'\'','\''verified_at'\'','\''target_control_on'\'','\''rescheduled_control_on'\'') ORDER BY column_name;"' | tee "$TMP_SCHEMA" +grep -Fxq 'inspection_finding_verification_visits' "$TMP_SCHEMA" +grep -Fxq 'outcome' "$TMP_SCHEMA" +grep -Fxq 'rescheduled_control_on' "$TMP_SCHEMA" +grep -Fxq 'target_control_on' "$TMP_SCHEMA" +grep -Fxq 'verified_at' "$TMP_SCHEMA" + +echo +echo "========== D5.3.17 · HEALTH ==========" +HEALTH="$(curl -fsS "$API_URL/health")" +echo "$HEALTH" +grep -Fq '"version":"0.19.3-17"' <<<"$HEALTH" +grep -Fq '"phase":"D5.3.17"' <<<"$HEALTH" +grep -Fq '"database":"ok"' <<<"$HEALTH" + +echo +echo "========== D5.3.17 · RESULTADO DE CAMPO ==========" +grep -Fq "@Patch('visits/:visitId/findings/:findingId/result')" api-v3/src/inspection-verifications/inspection-verifications.controller.ts +grep -Fq 'InspectionVerificationOutcome.RESOLVED' api-v3/src/inspection-verifications/inspection-verifications.service.ts +grep -Fq 'InspectionVerificationOutcome.NOT_RESOLVED' api-v3/src/inspection-verifications/inspection-verifications.service.ts +grep -Fq 'InspectionVerificationOutcome.REQUIRES_NEW_DATE' api-v3/src/inspection-verifications/inspection-verifications.service.ts +grep -Fq 'INSPECTION_VERIFICATION_RESULTS_REQUIRED' api-v3/src/inspection-closing/inspection-closing.service.ts +echo "Resultado de verificación controlado: OK" + +echo +echo "========== D5.3.17 · EVIDENCIA Y BANDEJA ==========" +grep -Fq "VERIFICATION = 'VERIFICATION'" api-v3/src/database/entities/inspection-finding-evidence.entity.ts +grep -Fq 'verification_visit_id' api-v3/src/inspection-findings/inspection-evidence.service.ts +grep -Fq 'READY_TO_CLOSE' api-v3/src/inspection-findings/inspection-findings.service.ts +grep -Fq 'Listos para cerrar' web-v2/src/pages/FindingsPage.tsx +echo "Evidencia de verificación y cierre administrativo: OK" + +echo +echo "========== D5.3.17 · HIGIENE DE CÓDIGO ==========" +if grep -RnhE --include='*.ts' --include='*.tsx' '^[[:space:]]*(//|/\*|\*)' api-v3/src web-v2/src; then + echo "ERROR: quedaron comentarios narrativos en código de producción" + exit 1 +fi +if grep -nE '/\*|\*/' web-v2/src/styles.css; then + echo "ERROR: quedaron comentarios internos en estilos de producción" + exit 1 +fi +echo "Código de producción preparado para revisión externa: OK" + +echo +echo "========== D5.3.17 · SEGURIDAD HEREDADA ==========" +STRONG_BACKUP="$(find /root/DH_V2_BACKUPS -maxdepth 1 -type d -name 'INVENTARIO_A_REVISAR_*' -printf '%T@ %p\n' | sort -nr | head -n1 | cut -d' ' -f2-)" +[[ -n "$STRONG_BACKUP" && -s "$STRONG_BACKUP/BACKUP_OK.txt" ]] +grep -Fq 'INVENTARIO A REVISAR' "$STRONG_BACKUP/BACKUP_OK.txt" +echo "Backup fuerte de referencia: $STRONG_BACKUP" + +echo +echo "========== D5.3.17 · CONTENEDORES ==========" +docker compose ps -a +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}')" == "healthy" ]] +[[ "$(docker inspect dhv2-api --format '{{.State.Status}}')" == "running" ]] +[[ "$(docker inspect dhv2-web --format '{{.State.Status}}')" == "running" ]] + +echo +echo "ACEPTACIÓN DE FASE D5.3.17: OK" diff --git a/scripts/phase-d5-3-18-2-acceptance.sh b/scripts/phase-d5-3-18-2-acceptance.sh new file mode 100755 index 0000000..654b6d0 --- /dev/null +++ b/scripts/phase-d5-3-18-2-acceptance.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT="${ROOT:-/var/www/dhv2.korexlabs.com}" +API_URL="${API_URL:-http://127.0.0.1:3101/api/v3}" +TMP_MIGRATION="/tmp/dhv2-d53182-accept-migration-$$.log" +trap 'rm -f "$TMP_MIGRATION"' EXIT +cd "$ROOT" + +echo "========== D5.3.18.2 · VERSIONES ==========" +[[ "$(node -p "require('./api-v3/package.json').version")" == "0.19.3-18.2" ]] +[[ "$(node -p "require('./web-v2/package.json').version")" == "0.19.3-18.2" ]] +grep -Fq "API_PHASE = 'D5.3.18.2'" api-v3/src/version.ts +grep -Fq "APP_PHASE = 'Fase D5.3.18.2 · Expediente operativo'" web-v2/src/config/version.ts +echo "API: 0.19.3-18.2" +echo "WEB: 0.19.3-18.2" + +echo +echo "========== D5.3.18.2 · DB Y MIGRACIONES ==========" +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}')" == "healthy" ]] +docker compose exec -T db sh -lc 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' +docker compose --profile tools run --rm migrate npm run migration:show 2>&1 | tee "$TMP_MIGRATION" +grep -Fq 'Pending migrations: no' "$TMP_MIGRATION" +docker compose exec -T db sh -lc 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Atc "SELECT code FROM permissions WHERE code = '\''inspection_findings.close'\'';"' | grep -Fxq 'inspection_findings.close' +CLOSE_ROLES="$(docker compose exec -T db sh -lc 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Atc "SELECT role.code FROM role_permissions rp JOIN roles role ON role.id=rp.role_id JOIN permissions permission ON permission.id=rp.permission_id WHERE permission.code='\''inspection_findings.close'\'' ORDER BY role.code;"')" +[[ "$CLOSE_ROLES" == $'director\ninspector\nsupervisor' ]] +echo "Cierre habilitado sólo para Director, Inspector y Supervisor: OK" + +echo +echo "========== D5.3.18.2 · HEALTH ==========" +HEALTH="$(curl -fsS "$API_URL/health")" +echo "$HEALTH" +grep -Fq '"version":"0.19.3-18.2"' <<<"$HEALTH" +grep -Fq '"phase":"D5.3.18.2"' <<<"$HEALTH" +grep -Fq '"database":"ok"' <<<"$HEALTH" + +echo +echo "========== D5.3.18.2 · EXPEDIENTE OPERATIVO ==========" +grep -Fq 'officeWorkflow' api-v3/src/inspection-findings/inspection-findings.service.ts +grep -Fq 'FindingOfficeWorkspace' web-v2/src/pages/FindingDetailPage.tsx +grep -Fq 'Dar curso al hallazgo' web-v2/src/features/inspections/FindingOfficeWorkspace.tsx +grep -Fq 'ACCIÓN RECOMENDADA' web-v2/src/features/inspections/FindingOfficeWorkspace.tsx +echo "Ficha única y acción recomendada: OK" + +echo +echo "========== D5.3.18.2 · PRESENTACIONES APPEND-ONLY ==========" +grep -Fq 'INSPECTION_FINDING_RESPONSE_IMMUTABLE' api-v3/src/inspection-findings/inspection-findings.service.ts +grep -Fq 'Nueva presentación / ampliación de la empresa' web-v2/src/features/inspections/FindingOfficeWorkspace.tsx +grep -Fq 'Primera respuesta registrada · inmutable' web-v2/src/features/inspections/FindingOfficeWorkspace.tsx +grep -Fq 'Agregar presentación' web-v2/src/features/inspections/FindingOfficeWorkspace.tsx +grep -Fq "type: 'COMPANY_RESPONSE'" web-v2/src/features/inspections/FindingOfficeWorkspace.tsx +echo "Respuesta original protegida y presentaciones sucesivas históricas: OK" + +echo +echo "========== D5.3.18.2 · CIERRE CONTROLADO ==========" +grep -Fq "@RequirePermissions('inspection_findings.close')" api-v3/src/inspection-findings/inspection-findings.controller.ts +grep -Fq "hasPermission('inspection_findings.close')" web-v2/src/pages/FindingDetailPage.tsx +grep -Fq 'El cierre está reservado a Inspector, Supervisor o Director.' web-v2/src/features/inspections/FindingOfficeWorkspace.tsx +echo "Cierre separado del seguimiento general: OK" + +echo +echo "========== D5.3.18.2 · HIGIENE DE CÓDIGO ==========" +if grep -RnhE --include='*.ts' --include='*.tsx' '^[[:space:]]*(//|/\*|\*)' api-v3/src web-v2/src; then + echo "ERROR: quedaron comentarios narrativos en código de producción" + exit 1 +fi +if grep -nE '/\*|\*/' web-v2/src/styles.css; then + echo "ERROR: quedaron comentarios internos en estilos de producción" + exit 1 +fi +echo "Código de producción preparado para revisión externa: OK" + +echo +echo "========== D5.3.18.2 · SEGURIDAD HEREDADA ==========" +STRONG_BACKUP="$(find /root/DH_V2_BACKUPS -maxdepth 1 -type d -name 'INVENTARIO_A_REVISAR_*' -printf '%T@ %p\n' | sort -nr | head -n1 | cut -d' ' -f2-)" +[[ -n "$STRONG_BACKUP" && -s "$STRONG_BACKUP/BACKUP_OK.txt" ]] +grep -Fq 'INVENTARIO A REVISAR' "$STRONG_BACKUP/BACKUP_OK.txt" +echo "Backup fuerte de referencia: $STRONG_BACKUP" + +echo +echo "========== D5.3.18.2 · CONTENEDORES ==========" +docker compose ps -a +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}')" == "healthy" ]] +[[ "$(docker inspect dhv2-api --format '{{.State.Status}}')" == "running" ]] +[[ "$(docker inspect dhv2-web --format '{{.State.Status}}')" == "running" ]] + +echo +echo "ACEPTACIÓN DE FASE D5.3.18.2: OK" diff --git a/scripts/phase-d5-3-18-3-acceptance.sh b/scripts/phase-d5-3-18-3-acceptance.sh new file mode 100755 index 0000000..c2de0d3 --- /dev/null +++ b/scripts/phase-d5-3-18-3-acceptance.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT="${ROOT:-/var/www/dhv2.korexlabs.com}" +API_URL="${API_URL:-http://127.0.0.1:3101/api/v3}" +TMP_MIGRATION="/tmp/dhv2-d53183-accept-migration-$$.log" +trap 'rm -f "$TMP_MIGRATION"' EXIT +cd "$ROOT" + +echo "========== D5.3.18.3 · VERSIONES ==========" +[[ "$(node -p "require('./api-v3/package.json').version")" == "0.19.3-18.3" ]] +[[ "$(node -p "require('./web-v2/package.json').version")" == "0.19.3-18.3" ]] +grep -Fq "API_PHASE = 'D5.3.18.3'" api-v3/src/version.ts +grep -Fq "APP_PHASE = 'Fase D5.3.18.3 · Expediente operativo'" web-v2/src/config/version.ts +echo "API: 0.19.3-18.3" +echo "WEB: 0.19.3-18.3" + +echo +echo "========== D5.3.18.3 · DB Y MIGRACIONES ==========" +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}')" == "healthy" ]] +docker compose exec -T db sh -lc 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' +docker compose --profile tools run --rm migrate npm run migration:show 2>&1 | tee "$TMP_MIGRATION" +grep -Fq 'Pending migrations: no' "$TMP_MIGRATION" +docker compose exec -T db sh -lc 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Atc "SELECT code FROM permissions WHERE code = '\''inspection_findings.close'\'';"' | grep -Fxq 'inspection_findings.close' +CLOSE_ROLES="$(docker compose exec -T db sh -lc 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Atc "SELECT role.code FROM role_permissions rp JOIN roles role ON role.id=rp.role_id JOIN permissions permission ON permission.id=rp.permission_id WHERE permission.code='\''inspection_findings.close'\'' ORDER BY role.code;"')" +[[ "$CLOSE_ROLES" == $'director\ninspector\nsupervisor' ]] +echo "Cierre habilitado sólo para Director, Inspector y Supervisor: OK" + +echo +echo "========== D5.3.18.3 · HEALTH ==========" +HEALTH="$(curl -fsS "$API_URL/health")" +echo "$HEALTH" +grep -Fq '"version":"0.19.3-18.3"' <<<"$HEALTH" +grep -Fq '"phase":"D5.3.18.3"' <<<"$HEALTH" +grep -Fq '"database":"ok"' <<<"$HEALTH" + +echo +echo "========== D5.3.18.3 · EXPEDIENTE OPERATIVO ==========" +grep -Fq 'officeWorkflow' api-v3/src/inspection-findings/inspection-findings.service.ts +grep -Fq 'FindingOfficeWorkspace' web-v2/src/pages/FindingDetailPage.tsx +grep -Fq 'Dar curso al hallazgo' web-v2/src/features/inspections/FindingOfficeWorkspace.tsx +grep -Fq 'ACCIÓN RECOMENDADA' web-v2/src/features/inspections/FindingOfficeWorkspace.tsx +echo "Ficha única y acción recomendada: OK" + +echo +echo "========== D5.3.18.3 · PRESENTACIONES APPEND-ONLY ==========" +grep -Fq 'INSPECTION_FINDING_RESPONSE_IMMUTABLE' api-v3/src/inspection-findings/inspection-findings.service.ts +grep -Fq 'Nueva presentación / ampliación de la empresa' web-v2/src/features/inspections/FindingOfficeWorkspace.tsx +grep -Fq 'Primera respuesta registrada · inmutable' web-v2/src/features/inspections/FindingOfficeWorkspace.tsx +grep -Fq 'Agregar presentación' web-v2/src/features/inspections/FindingOfficeWorkspace.tsx +grep -Fq "type: 'COMPANY_RESPONSE'" web-v2/src/features/inspections/FindingOfficeWorkspace.tsx +echo "Respuesta original protegida y presentaciones sucesivas históricas: OK" + +echo +echo "========== D5.3.18.3 · CIERRE CONTROLADO ==========" +grep -Fq "@RequirePermissions('inspection_findings.close')" api-v3/src/inspection-findings/inspection-findings.controller.ts +grep -Fq "hasPermission('inspection_findings.close')" web-v2/src/pages/FindingDetailPage.tsx +grep -Fq 'El cierre está reservado a Inspector, Supervisor o Director.' web-v2/src/features/inspections/FindingOfficeWorkspace.tsx +echo "Cierre separado del seguimiento general: OK" + +echo +echo "========== D5.3.18.3 · HIGIENE DE CÓDIGO ==========" +if grep -RnhE --include='*.ts' --include='*.tsx' '^[[:space:]]*(//|/\*|\*)' api-v3/src web-v2/src; then + echo "ERROR: quedaron comentarios narrativos en código de producción" + exit 1 +fi +if grep -nE '/\*|\*/' web-v2/src/styles.css; then + echo "ERROR: quedaron comentarios internos en estilos de producción" + exit 1 +fi +echo "Código de producción preparado para revisión externa: OK" + +echo +echo "========== D5.3.18.3 · SEGURIDAD HEREDADA ==========" +STRONG_BACKUP="$(find /root/DH_V2_BACKUPS -maxdepth 1 -type d -name 'INVENTARIO_A_REVISAR_*' -printf '%T@ %p\n' | sort -nr | head -n1 | cut -d' ' -f2-)" +[[ -n "$STRONG_BACKUP" && -s "$STRONG_BACKUP/BACKUP_OK.txt" ]] +grep -Fq 'INVENTARIO A REVISAR' "$STRONG_BACKUP/BACKUP_OK.txt" +echo "Backup fuerte de referencia: $STRONG_BACKUP" + +echo +echo "========== D5.3.18.3 · CONTENEDORES ==========" +docker compose ps -a +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}')" == "healthy" ]] +[[ "$(docker inspect dhv2-api --format '{{.State.Status}}')" == "running" ]] +[[ "$(docker inspect dhv2-web --format '{{.State.Status}}')" == "running" ]] + +echo +echo "ACEPTACIÓN DE FASE D5.3.18.3: OK" diff --git a/scripts/phase-d5-3-18-acceptance.sh b/scripts/phase-d5-3-18-acceptance.sh new file mode 100755 index 0000000..40315e0 --- /dev/null +++ b/scripts/phase-d5-3-18-acceptance.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT="${ROOT:-/var/www/dhv2.korexlabs.com}" +API_URL="${API_URL:-http://127.0.0.1:3101/api/v3}" +TMP_MIGRATION="/tmp/dhv2-d5318-accept-migration-$$.log" +trap 'rm -f "$TMP_MIGRATION"' EXIT +cd "$ROOT" + +echo "========== D5.3.18 · VERSIONES ==========" +[[ "$(node -p "require('./api-v3/package.json').version")" == "0.19.3-18" ]] +[[ "$(node -p "require('./web-v2/package.json').version")" == "0.19.3-18" ]] +grep -Fq "API_PHASE = 'D5.3.18'" api-v3/src/version.ts +grep -Fq "APP_PHASE = 'Fase D5.3.18 · Expediente operativo'" web-v2/src/config/version.ts +echo "API: 0.19.3-18" +echo "WEB: 0.19.3-18" + +echo +echo "========== D5.3.18 · DB Y MIGRACIONES ==========" +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}')" == "healthy" ]] +docker compose exec -T db sh -lc 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' +docker compose --profile tools run --rm migrate npm run migration:show 2>&1 | tee "$TMP_MIGRATION" +grep -Fq 'Pending migrations: no' "$TMP_MIGRATION" + +echo +echo "========== D5.3.18 · HEALTH ==========" +HEALTH="$(curl -fsS "$API_URL/health")" +echo "$HEALTH" +grep -Fq '"version":"0.19.3-18"' <<<"$HEALTH" +grep -Fq '"phase":"D5.3.18"' <<<"$HEALTH" +grep -Fq '"database":"ok"' <<<"$HEALTH" + +echo +echo "========== D5.3.18 · EXPEDIENTE OPERATIVO ==========" +grep -Fq 'officeWorkflow' api-v3/src/inspection-findings/inspection-findings.service.ts +grep -Fq "'WAITING_COMPANY'" api-v3/src/inspection-findings/inspection-findings.service.ts +grep -Fq "stage: 'READY_TO_CLOSE'" api-v3/src/inspection-findings/inspection-findings.service.ts +grep -Fq 'FindingOfficeWorkspace' web-v2/src/pages/FindingDetailPage.tsx +grep -Fq 'Dar curso al hallazgo' web-v2/src/features/inspections/FindingOfficeWorkspace.tsx +grep -Fq 'ACCIÓN RECOMENDADA' web-v2/src/features/inspections/FindingOfficeWorkspace.tsx +echo "Ficha única y acción recomendada: OK" + +echo +echo "========== D5.3.18 · RESPUESTA Y DOCUMENTOS ==========" +grep -Fq 'Registrar respuesta' web-v2/src/features/inspections/FindingOfficeWorkspace.tsx +grep -Fq "type: 'COMPANY_RESPONSE'" web-v2/src/features/inspections/FindingOfficeWorkspace.tsx +grep -Fq "purpose: 'COMPANY_RESPONSE'" web-v2/src/features/inspections/FindingOfficeWorkspace.tsx +grep -Fq 'companyCommittedCorrectionOn' web-v2/src/features/inspections/FindingOfficeWorkspace.tsx +grep -Fq 'nextControlOn' web-v2/src/features/inspections/FindingOfficeWorkspace.tsx +grep -Fq 'managedCompanyResponse' web-v2/src/features/inspections/InspectionEvidencePanel.tsx +echo "Respuesta, PDF y fechas independientes integrados: OK" + +echo +echo "========== D5.3.18 · VERIFICACIÓN Y CIERRE ==========" +grep -Fq 'Planificar verificación' web-v2/src/features/inspections/FindingOfficeWorkspace.tsx +grep -Fq 'Abrir verificación' web-v2/src/features/inspections/FindingOfficeWorkspace.tsx +grep -Fq 'Decisión administrativa' web-v2/src/features/inspections/FindingOfficeWorkspace.tsx +grep -Fq 'closeInspectionFinding' web-v2/src/features/inspections/FindingOfficeWorkspace.tsx +echo "Planificación, resultado y cierre conectados: OK" + +echo +echo "========== D5.3.18 · HIGIENE DE CÓDIGO ==========" +if grep -RnhE --include='*.ts' --include='*.tsx' '^[[:space:]]*(//|/\*|\*)' api-v3/src web-v2/src; then + echo "ERROR: quedaron comentarios narrativos en código de producción" + exit 1 +fi +if grep -nE '/\*|\*/' web-v2/src/styles.css; then + echo "ERROR: quedaron comentarios internos en estilos de producción" + exit 1 +fi +echo "Código de producción preparado para revisión externa: OK" + +echo +echo "========== D5.3.18 · SEGURIDAD HEREDADA ==========" +STRONG_BACKUP="$(find /root/DH_V2_BACKUPS -maxdepth 1 -type d -name 'INVENTARIO_A_REVISAR_*' -printf '%T@ %p\n' | sort -nr | head -n1 | cut -d' ' -f2-)" +[[ -n "$STRONG_BACKUP" && -s "$STRONG_BACKUP/BACKUP_OK.txt" ]] +grep -Fq 'INVENTARIO A REVISAR' "$STRONG_BACKUP/BACKUP_OK.txt" +echo "Backup fuerte de referencia: $STRONG_BACKUP" + +echo +echo "========== D5.3.18 · CONTENEDORES ==========" +docker compose ps -a +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}')" == "healthy" ]] +[[ "$(docker inspect dhv2-api --format '{{.State.Status}}')" == "running" ]] +[[ "$(docker inspect dhv2-web --format '{{.State.Status}}')" == "running" ]] + +echo +echo "ACEPTACIÓN DE FASE D5.3.18: OK" diff --git a/scripts/phase-d5-3-19-1-acceptance.sh b/scripts/phase-d5-3-19-1-acceptance.sh new file mode 100755 index 0000000..d78febe --- /dev/null +++ b/scripts/phase-d5-3-19-1-acceptance.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT="${ROOT:-/var/www/dhv2.korexlabs.com}" +API_URL="${API_URL:-http://127.0.0.1:3101/api/v3}" +TMP_MIGRATION="/tmp/dhv2-d53191-accept-migration-$$.log" +trap 'rm -f "$TMP_MIGRATION"' EXIT +cd "$ROOT" + +echo "========== D5.3.19.1 · VERSIONES ==========" +[[ "$(node -p "require('./api-v3/package.json').version")" == "0.19.3-19.1" ]] +[[ "$(node -p "require('./web-v2/package.json').version")" == "0.19.3-19.1" ]] +grep -Fq "API_PHASE = 'D5.3.19.1'" api-v3/src/version.ts +grep -Fq "APP_PHASE = 'Fase D5.3.19.1 · Inventarios y filtros operativos'" web-v2/src/config/version.ts +echo "API: 0.19.3-19.1" +echo "WEB: 0.19.3-19.1" + +echo +echo "========== D5.3.19.1 · DB Y MIGRACIONES ==========" +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}')" == "healthy" ]] +docker compose exec -T db sh -lc 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' +docker compose --profile tools run --rm migrate npm run migration:show 2>&1 | tee "$TMP_MIGRATION" +grep -Fq 'Pending migrations: no' "$TMP_MIGRATION" +for column in common_name; do + docker compose exec -T db sh -lc "psql -U \"\$POSTGRES_USER\" -d \"\$POSTGRES_DB\" -Atc \"SELECT column_name FROM information_schema.columns WHERE table_name='assets' AND column_name='$column';\"" | grep -Fxq "$column" +done +for column in response_due_basis response_due_days response_due_base_on report_notified_on; do + docker compose exec -T db sh -lc "psql -U \"\$POSTGRES_USER\" -d \"\$POSTGRES_DB\" -Atc \"SELECT column_name FROM information_schema.columns WHERE table_name='inspection_findings' AND column_name='$column';\"" | grep -Fxq "$column" +done +echo "Nombre habitual y metadatos de vencimiento presentes: OK" + +echo +echo "========== D5.3.19.1 · HEALTH ==========" +HEALTH="$(curl -fsS "$API_URL/health")" +echo "$HEALTH" +grep -Fq '"version":"0.19.3-19.1"' <<<"$HEALTH" +grep -Fq '"phase":"D5.3.19.1"' <<<"$HEALTH" +grep -Fq '"database":"ok"' <<<"$HEALTH" + +echo +echo "========== D5.3.19.1 · NOMBRE HABITUAL ==========" +grep -Fq 'commonName' api-v3/src/asset-master/dto/create-asset.dto.ts +grep -A6 -F 'export function createAsset(input: {' web-v2/src/lib/api.ts | grep -Fq 'commonName?: string | null;' +grep -Fq 'asset.common_name ILIKE' api-v3/src/asset-master/assets.service.ts +grep -Fq 'Nombre habitual / sobrenombre' web-v2/src/pages/AssetEditorPage.tsx +grep -Fq 'Nombre habitual:' web-v2/src/features/assets/AssetDossierPanel.tsx +echo "Alias visible, editable, versionado y buscable: OK" + +echo +echo "========== D5.3.19.1 · FILTROS TRANSVERSALES ==========" +for file in \ + web-v2/src/pages/InspectionVisitsPage.tsx \ + web-v2/src/pages/ActsPage.tsx \ + web-v2/src/pages/ReportsPage.tsx \ + web-v2/src/pages/FindingsPage.tsx; do + grep -Fq 'OperationalFilters' "$file" +done +for key in companyId areaId inspectorId dateFrom dateTo; do + grep -Fq "$key" api-v3/src/inspection-visits/dto/list-inspection-visits-query.dto.ts + grep -Fq "$key" api-v3/src/inspection-acts/dto/list-inspection-acts-query.dto.ts + grep -Fq "$key" api-v3/src/inspection-reports/dto/list-inspection-reports-query.dto.ts + grep -Fq "$key" api-v3/src/inspection-findings/dto/list-inspection-findings-query.dto.ts +done +echo "Empresa, Área, Inspector y rango de fechas disponibles: OK" + +echo +echo "========== D5.3.19.1 · PLAZO ADMINISTRATIVO ==========" +grep -Fq 'InspectionFindingResponseDueBasis.FINDING_DATE' api-v3/src/inspection-findings/inspection-findings.service.ts +grep -Fq 'INSPECTION_FINDING_REPORT_NOTIFICATION_REQUIRED' api-v3/src/inspection-findings/inspection-findings.service.ts +grep -Fq 'INSPECTION_FINDING_RESPONSE_DEADLINE_LOCKED' api-v3/src/inspection-findings/inspection-findings.service.ts +grep -Fq 'Calcular vencimiento' web-v2/src/features/inspections/FindingOfficeWorkspace.tsx +grep -Fq 'Desde la notificación del informe' web-v2/src/features/inspections/FindingOfficeWorkspace.tsx +echo "Origen, días, fecha base y vencimiento administrativo separados: OK" + +echo +echo "========== D5.3.19.1 · REGLAS PRESERVADAS ==========" +grep -Fq 'assertVisitHasNoAct' api-v3/src/inspection-acts/inspection-acts.service.ts +grep -Fq 'INSPECTION_VISIT_ACT_ALREADY_EXISTS' api-v3/src/inspection-acts/inspection-acts.service.ts +grep -Fq 'INSPECTION_FINDING_RESPONSE_IMMUTABLE' api-v3/src/inspection-findings/inspection-findings.service.ts +grep -Fq "@RequirePermissions('inspection_findings.close')" api-v3/src/inspection-findings/inspection-findings.controller.ts +echo "Una visita = una acta y seguimiento append-only preservados: OK" + +echo +echo "========== D5.3.19.1 · HIGIENE DE CÓDIGO ==========" +if grep -RnhE --include='*.ts' --include='*.tsx' '^[[:space:]]*(//|/\*|\*)' api-v3/src web-v2/src; then + echo "ERROR: quedaron comentarios narrativos en código de producción" + exit 1 +fi +if grep -nE '/\*|\*/' web-v2/src/styles.css; then + echo "ERROR: quedaron comentarios internos en estilos de producción" + exit 1 +fi +echo "Código de producción preparado para revisión externa: OK" + +echo +echo "========== D5.3.19.1 · SEGURIDAD HEREDADA ==========" +STRONG_BACKUP="$(find /root/DH_V2_BACKUPS -maxdepth 1 -type d -name 'INVENTARIO_A_REVISAR_*' -printf '%T@ %p\n' | sort -nr | head -n1 | cut -d' ' -f2-)" +[[ -n "$STRONG_BACKUP" && -s "$STRONG_BACKUP/BACKUP_OK.txt" ]] +grep -Fq 'INVENTARIO A REVISAR' "$STRONG_BACKUP/BACKUP_OK.txt" +echo "Backup fuerte de referencia: $STRONG_BACKUP" + +echo +echo "========== D5.3.19.1 · CONTENEDORES ==========" +docker compose ps -a +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}')" == "healthy" ]] +[[ "$(docker inspect dhv2-api --format '{{.State.Status}}')" == "running" ]] +[[ "$(docker inspect dhv2-web --format '{{.State.Status}}')" == "running" ]] + +echo +echo "ACEPTACIÓN DE FASE D5.3.19.1: OK" diff --git a/scripts/phase-d5-3-2-1-acceptance.sh b/scripts/phase-d5-3-2-1-acceptance.sh new file mode 100755 index 0000000..2896ad9 --- /dev/null +++ b/scripts/phase-d5-3-2-1-acceptance.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT="${ROOT:-/var/www/dhv2.korexlabs.com}" +cd "$ROOT" +API_URL="${API_URL:-http://127.0.0.1:3101/api/v3}" + +echo "========== D5.3.2.1 · VERSIONES ==========" +API_VERSION="$(node -p "require('./api-v3/package.json').version")" +WEB_VERSION="$(node -p "require('./web-v2/package.json').version")" +[[ "$API_VERSION" == "0.19.3-2.1" ]] +[[ "$WEB_VERSION" == "0.19.3-2.1" ]] +echo "API: $API_VERSION" +echo "WEB: $WEB_VERSION" + +echo +echo "========== D5.3.2.1 · MIGRACIONES ==========" +MIGRATION_OUTPUT="$(docker compose --profile tools run --rm migrate npm run migration:show 2>&1)" +echo "$MIGRATION_OUTPUT" +grep -q "Pending migrations: no" <<<"$MIGRATION_OUTPUT" + +echo +echo "========== D5.3.2.1 · HEALTH ==========" +HEALTH="$(curl -fsS "$API_URL/health")" +echo "$HEALTH" +grep -q '"version":"0.19.3-2.1"' <<<"$HEALTH" +grep -q '"phase":"D5.3.2.1"' <<<"$HEALTH" + +echo +echo "========== D5.3.2.1 · HOTFIX VERSIONADO ==========" +grep -q 'SET current_version = COALESCE(current_version, 0) + 1' api-v3/src/asset-master/asset-history.service.ts +grep -q 'SELECT current_version' api-v3/src/asset-master/asset-history.service.ts +test -f api-v3/test/unit/asset-history-capture.test.ts +grep -q 'asset history capture increments then reads current_version' api-v3/test/unit/asset-history-capture.test.ts +echo "Incremento + lectura explícita de current_version y test de regresión: OK" + +echo +echo "========== D5.3.2.1 · DEMO ==========" +bash -n scripts/dev-seed-mendoza-demo.sh +bash -n scripts/dev-clean-demo.sh +grep -q 'DH-DEV-DEMO:ITN124/2026' api-v3/src/cli/dev-seed-mendoza-demo.ts +echo "Seed y limpiador disponibles: OK" + +echo +echo "========== D5.3.2.1 · CONTENEDORES ==========" +docker compose ps -a + +echo +echo "ACEPTACIÓN DE FASE D5.3.2.1: OK" diff --git a/scripts/phase-d5-3-2-acceptance.sh b/scripts/phase-d5-3-2-acceptance.sh new file mode 100755 index 0000000..66ab75c --- /dev/null +++ b/scripts/phase-d5-3-2-acceptance.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT="${ROOT:-/var/www/dhv2.korexlabs.com}" +cd "$ROOT" +DB_SERVICE="${DB_SERVICE:-db}" +API_URL="${API_URL:-http://127.0.0.1:3101/api/v3}" + +psql_app() { + docker compose exec -T "$DB_SERVICE" sh -lc \ + 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -At -v ON_ERROR_STOP=1' <<<"$1" +} + +echo "========== D5.3.2 · VERSIONES ==========" +API_VERSION="$(node -p "require('./api-v3/package.json').version")" +WEB_VERSION="$(node -p "require('./web-v2/package.json').version")" +[[ "$API_VERSION" == "0.19.3-2" ]] +[[ "$WEB_VERSION" == "0.19.3-2" ]] +echo "API: $API_VERSION" +echo "WEB: $WEB_VERSION" + +echo +echo "========== D5.3.2 · MIGRACIONES ==========" +MIGRATION_OUTPUT="$(docker compose --profile tools run --rm migrate npm run migration:show 2>&1)" +echo "$MIGRATION_OUTPUT" +grep -q "Pending migrations: no" <<<"$MIGRATION_OUTPUT" + +echo +echo "========== D5.3.2 · HEALTH ==========" +HEALTH="$(curl -fsS "$API_URL/health")" +echo "$HEALTH" +grep -q '"version":"0.19.3-2"' <<<"$HEALTH" +grep -q '"phase":"D5.3.2"' <<<"$HEALTH" + +echo +echo "========== D5.3.2 · ENDPOINTS PROTEGIDOS ==========" +CODE="$(curl -sS -o /tmp/dhv2-d532-enrichment.txt -w '%{http_code}' "$API_URL/asset-types/enrichment-status")" +[[ "$CODE" == "401" ]] +echo "/asset-types/enrichment-status sin sesión: 401 OK" +CODE="$(curl -sS -o /tmp/dhv2-d532-enrich-post.txt -w '%{http_code}' -X POST "$API_URL/asset-types/enrich-defaults")" +[[ "$CODE" == "401" ]] +echo "/asset-types/enrich-defaults sin sesión: 401 OK" + +echo +echo "========== D5.3.2 · NOMENCLATURA TÉCNICA ==========" +grep -q "code: 'planta'" api-v3/src/asset-master/asset-master-bootstrap.ts +grep -q "code: 'bateria'" api-v3/src/asset-master/asset-master-bootstrap.ts +grep -q "code: 'tanque'" api-v3/src/asset-master/asset-master-bootstrap.ts +grep -q "code: 'separador'" api-v3/src/asset-master/asset-master-bootstrap.ts +grep -q "code: 'bomba'" api-v3/src/asset-master/asset-master-bootstrap.ts +grep -q "code: 'caldera'" api-v3/src/asset-master/asset-master-bootstrap.ts +grep -q "code: 'antorcha'" api-v3/src/asset-master/asset-master-bootstrap.ts +grep -q "code: 'pozo'" api-v3/src/asset-master/asset-master-bootstrap.ts +grep -q "metodo_extraccion" api-v3/src/asset-master/asset-master-bootstrap.ts +grep -q "Bombeo mecánico" api-v3/src/asset-master/asset-master-bootstrap.ts +grep -q "Inyector de agua" api-v3/src/asset-master/asset-master-bootstrap.ts +echo "Familias técnicas y Pozo único con método/función: OK" + +echo +echo "========== D5.3.2 · DASHBOARD ==========" +grep -q "Completar catálogo técnico" web-v2/src/pages/AssetTypesPage.tsx +grep -q "ampliación no destructiva" web-v2/src/pages/AssetTypesPage.tsx +grep -q "0.19.3-2" web-v2/src/config/version.ts +echo "Ampliación técnica visible y footer correcto: OK" + +echo +echo "========== D5.3.2 · DEMO Y LIMPIEZA ==========" +grep -q "DH-DEV-DEMO:ITN124/2026" api-v3/src/cli/dev-seed-mendoza-demo.ts +grep -q "DEV-DEMO-TK-57" api-v3/src/cli/dev-seed-mendoza-demo.ts +grep -q "DEV-DEMO-TK-58" api-v3/src/cli/dev-seed-mendoza-demo.ts +grep -q -- "--apply DH-DEV-DEMO" scripts/dev-clean-demo.sh +grep -q "MODO SIMULACIÓN" scripts/dev-clean-demo.sh +bash -n scripts/dev-seed-mendoza-demo.sh +bash -n scripts/dev-clean-demo.sh +echo "Seed marcado + limpiador protegido en dry-run: OK" + +echo +echo "========== D5.3.2 · ESTADO DEL MAESTRO ==========" +TYPE_COUNT="$(psql_app 'SELECT COUNT(*) FROM asset_types;')" +ASSET_COUNT="$(psql_app 'SELECT COUNT(*) FROM assets;')" +TECH_COUNT="$(psql_app "SELECT COUNT(*) FROM asset_types WHERE code IN ('planta','bateria','tanque','separador','bomba','caldera','antorcha','pozo');")" +echo "Tipos actuales: $TYPE_COUNT" +echo "Activos actuales: $ASSET_COUNT" +echo "Tipos técnicos principales ya instalados: $TECH_COUNT / 8" +if [[ "$TECH_COUNT" -lt "8" ]]; then + echo "Pendiente funcional esperado: entrar a Maestro > Tipos y atributos y pulsar 'Completar catálogo técnico'." +fi + +echo +echo "========== D5.3.2 · CONTENEDORES ==========" +docker compose ps -a + +echo +echo "ACEPTACIÓN DE FASE D5.3.2: OK" diff --git a/scripts/phase-d5-3-20-acceptance.sh b/scripts/phase-d5-3-20-acceptance.sh new file mode 100755 index 0000000..2f7a3eb --- /dev/null +++ b/scripts/phase-d5-3-20-acceptance.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT="${ROOT:-/var/www/dhv2.korexlabs.com}" +API_URL="${API_URL:-http://127.0.0.1:3101/api/v3}" +TMP_MIGRATION="/tmp/dhv2-d5320-accept-migration-$$.log" +trap 'rm -f "$TMP_MIGRATION"' EXIT +cd "$ROOT" + +echo "========== D5.3.20 · VERSIONES ==========" +[[ "$(node -p "require('./api-v3/package.json').version")" == "0.19.3-20" ]] +[[ "$(node -p "require('./web-v2/package.json').version")" == "0.19.3-20" ]] +grep -Fq "API_PHASE = 'D5.3.20'" api-v3/src/version.ts +grep -Fq "APP_PHASE = 'Fase D5.3.20 · Informe Word automático'" web-v2/src/config/version.ts +echo "API: 0.19.3-20" +echo "WEB: 0.19.3-20" + +echo +echo "========== D5.3.20 · DB Y MIGRACIONES ==========" +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}')" == "healthy" ]] +docker compose exec -T db sh -lc 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' +docker compose --profile tools run --rm migrate npm run migration:show 2>&1 | tee "$TMP_MIGRATION" +grep -Fq 'Pending migrations: no' "$TMP_MIGRATION" +for column in word_status word_original_name word_stored_name word_mime_type word_size_bytes word_sha256 word_generated_at word_error; do + docker compose exec -T db sh -lc "psql -U \"\$POSTGRES_USER\" -d \"\$POSTGRES_DB\" -Atc \"SELECT column_name FROM information_schema.columns WHERE table_name='inspection_reports' AND column_name='$column';\"" | grep -Fxq "$column" +done +echo "Metadatos del Word automático presentes: OK" + +echo +echo "========== D5.3.20 · HEALTH ==========" +HEALTH="$(curl -fsS "$API_URL/health")" +echo "$HEALTH" +grep -Fq '"version":"0.19.3-20"' <<<"$HEALTH" +grep -Fq '"phase":"D5.3.20"' <<<"$HEALTH" +grep -Fq '"database":"ok"' <<<"$HEALTH" + +echo +echo "========== D5.3.20 · CIERRE DOCUMENTAL ==========" +grep -Fq 'await this.reports.ensureFrozenReport(manager, actId, principal, request)' api-v3/src/inspection-closing/inspection-closing.service.ts +grep -Fq 'await this.reports.ensureWordForAct(actId)' api-v3/src/inspection-closing/inspection-closing.service.ts +grep -Fq 'async ensureFrozenReport(' api-v3/src/inspection-reports/inspection-reports.service.ts +echo "Cierre de acta crea y congela automáticamente el informe: OK" + +echo +echo "========== D5.3.20 · WORD ==========" +grep -Fq "@Get(':id/word')" api-v3/src/inspection-reports/inspection-reports.controller.ts +grep -Fq 'buildInspectionReportWord' api-v3/src/inspection-reports/inspection-report-word.service.ts +grep -Fq 'Descargar Word' web-v2/src/pages/ReportDetailPage.tsx +grep -Fq 'Word disponible' web-v2/src/pages/ReportsPage.tsx +echo "Word de revisión generado desde snapshot congelado y descargable: OK" + +echo +echo "========== D5.3.20 · REGLAS PRESERVADAS ==========" +grep -Fq 'assertVisitHasNoAct' api-v3/src/inspection-acts/inspection-acts.service.ts +grep -Fq 'INSPECTION_VISIT_ACT_ALREADY_EXISTS' api-v3/src/inspection-acts/inspection-acts.service.ts +grep -Fq 'INSPECTION_FINDING_RESPONSE_IMMUTABLE' api-v3/src/inspection-findings/inspection-findings.service.ts +echo "Una visita = una acta y hallazgos append-only preservados: OK" + +echo +echo "========== D5.3.20 · ALCANCE DE CORREO ==========" +if grep -RniE --include='*.ts' 'nodemailer|smtp_transport|email_outbox' api-v3/src; then + echo "ERROR: D5.3.20 no debe activar correo sin destinatarios institucionales configurados" + exit 1 +fi +echo "Correo automático reservado para la fase de destinatarios y entrega: OK" + +echo +echo "========== D5.3.20 · HIGIENE DE CÓDIGO ==========" +if grep -RnhE --include='*.ts' --include='*.tsx' '^[[:space:]]*(//|/\*|\*)' api-v3/src web-v2/src; then + echo "ERROR: quedaron comentarios narrativos en código de producción" + exit 1 +fi +if grep -nE '/\*|\*/' web-v2/src/styles.css; then + echo "ERROR: quedaron comentarios internos en estilos de producción" + exit 1 +fi +echo "Código de producción preparado para revisión externa: OK" + +echo +echo "========== D5.3.20 · SEGURIDAD HEREDADA ==========" +STRONG_BACKUP="$(find /root/DH_V2_BACKUPS -maxdepth 1 -type d -name 'INVENTARIO_A_REVISAR_*' -printf '%T@ %p\n' | sort -nr | head -n1 | cut -d' ' -f2-)" +[[ -n "$STRONG_BACKUP" && -s "$STRONG_BACKUP/BACKUP_OK.txt" ]] +grep -Fq 'INVENTARIO A REVISAR' "$STRONG_BACKUP/BACKUP_OK.txt" +echo "Backup fuerte de referencia: $STRONG_BACKUP" + +echo +echo "========== D5.3.20 · CONTENEDORES ==========" +docker compose ps -a +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}')" == "healthy" ]] +[[ "$(docker inspect dhv2-api --format '{{.State.Status}}')" == "running" ]] +[[ "$(docker inspect dhv2-web --format '{{.State.Status}}')" == "running" ]] + +echo +echo "ACEPTACIÓN DE FASE D5.3.20: OK" diff --git a/scripts/phase-d5-3-21-1-acceptance.sh b/scripts/phase-d5-3-21-1-acceptance.sh new file mode 100755 index 0000000..9b7f8d1 --- /dev/null +++ b/scripts/phase-d5-3-21-1-acceptance.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT="${ROOT:-/var/www/dhv2.korexlabs.com}" +API_URL="${API_URL:-http://127.0.0.1:3101/api/v3}" +TMP_MIGRATION="/tmp/dhv2-d53211-accept-migration-$$.log" +trap 'rm -f "$TMP_MIGRATION"' EXIT +cd "$ROOT" + +echo "========== D5.3.21.1 · VERSIONES ==========" +[[ "$(node -p "require('./api-v3/package.json').version")" == "0.19.3-21.1" ]] +[[ "$(node -p "require('./web-v2/package.json').version")" == "0.19.3-21.1" ]] +grep -Fq "API_PHASE = 'D5.3.21.1'" api-v3/src/version.ts +grep -Fq "APP_PHASE = 'Fase D5.3.21.1 · Entrega documental'" web-v2/src/config/version.ts +echo "API: 0.19.3-21.1" +echo "WEB: 0.19.3-21.1" + +echo +echo "========== D5.3.21.1 · DB Y MIGRACIONES ==========" +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}')" == "healthy" ]] +docker compose exec -T db sh -lc 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' +docker compose --profile tools run --rm migrate npm run migration:show 2>&1 | tee "$TMP_MIGRATION" +grep -Fq 'Pending migrations: no' "$TMP_MIGRATION" +for table in institutional_delivery_settings inspection_act_pdf_artifacts inspection_document_deliveries; do + docker compose exec -T db sh -lc "psql -U \"\$POSTGRES_USER\" -d \"\$POSTGRES_DB\" -Atc \"SELECT to_regclass('public.$table');\"" | grep -Fxq "$table" +done +docker compose exec -T db sh -lc 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Atc "SELECT column_name FROM information_schema.columns WHERE table_name='"'"'organization_profiles'"'"' AND column_name='"'"'notification_email'"'"';"' | grep -Fxq 'notification_email' +echo "Destinatarios institucionales, email oficial de empresa y outbox presentes: OK" + +echo +echo "========== D5.3.21.1 · PERMISOS ==========" +for permission in document_delivery.read document_delivery.manage; do + docker compose exec -T db sh -lc "psql -U \"\$POSTGRES_USER\" -d \"\$POSTGRES_DB\" -Atc \"SELECT code FROM permissions WHERE code='$permission';\"" | grep -Fxq "$permission" +done +for role in admin director; do + docker compose exec -T db sh -lc "psql -U \"\$POSTGRES_USER\" -d \"\$POSTGRES_DB\" -Atc \"SELECT r.code FROM roles r JOIN role_permissions rp ON rp.role_id=r.id JOIN permissions p ON p.id=rp.permission_id WHERE r.code='$role' AND p.code='document_delivery.manage';\"" | grep -Fxq "$role" +done +echo "Configuración y reintentos documentales protegidos por permisos: OK" + +echo +echo "========== D5.3.21.1 · HEALTH ==========" +HEALTH="$(curl -fsS "$API_URL/health")" +echo "$HEALTH" +grep -Fq '"version":"0.19.3-21.1"' <<<"$HEALTH" +grep -Fq '"phase":"D5.3.21.1"' <<<"$HEALTH" +grep -Fq '"database":"ok"' <<<"$HEALTH" + +echo +echo "========== D5.3.21.1 · ACTA PDF Y ENTREGA ==========" +grep -Fq 'buildInspectionActPdf' api-v3/src/inspection-reports/inspection-act-pdf.service.ts +grep -Fq "'ACT_PDF'" api-v3/src/inspection-reports/inspection-document-delivery.service.ts +grep -Fq "'REPORT_WORD'" api-v3/src/inspection-reports/inspection-document-delivery.service.ts +grep -Fq 'await this.delivery.dispatchForAct(actId)' api-v3/src/inspection-reports/inspection-reports.service.ts +echo "Acta PDF para empresa/oficina e Informe Word para Director preparados automáticamente: OK" + +echo +echo "========== D5.3.21.1 · ESTADOS Y REINTENTOS ==========" +for status in WAITING_RECIPIENT WAITING_TRANSPORT WAITING_ARTIFACT SENT FAILED; do + grep -Fq "$status" api-v3/src/inspection-reports/inspection-document-delivery.service.ts +done +grep -Fq "@Post('outbox/:id/retry')" api-v3/src/inspection-reports/document-delivery.controller.ts +grep -Fq "@Post('outbox/retry-pending')" api-v3/src/inspection-reports/document-delivery.controller.ts +echo "Entrega auditable, no bloqueante y reintentable: OK" + +echo +echo "========== D5.3.21.1 · CONFIGURACIÓN WEB ==========" +grep -Fq 'Email oficial para actas' web-v2/src/features/assets/AssetRegistryPanel.tsx +grep -Fq 'Entrega documental' web-v2/src/pages/DocumentDeliveryPage.tsx +grep -Fq '/admin/document-delivery' web-v2/src/app/App.tsx +echo "Empresa, oficina y Director pueden configurarse sin direcciones hardcodeadas: OK" + +echo +echo "========== D5.3.21.1 · SMTP ==========" +grep -Fq 'SMTP_HOST' api-v3/src/inspection-reports/smtp-delivery.service.ts +grep -Fq 'STARTTLS' api-v3/src/inspection-reports/smtp-delivery.service.ts +grep -Fq 'MAIL_FROM' docker-compose.yml +SMTP_STATE="$(docker compose exec -T api sh -lc 'if [ -n "$SMTP_HOST" ] && [ -n "$MAIL_FROM" ]; then echo configured; else echo pending; fi')" +if [[ "$SMTP_STATE" == "configured" ]]; then + echo "SMTP disponible en el contenedor API: configuración activa" +else + echo "SMTP no configurado todavía: las entregas quedarán pendientes sin bloquear el cierre" +fi + +echo +echo "========== D5.3.21.1 · REGLAS PRESERVADAS ==========" +grep -Fq 'assertVisitHasNoAct' api-v3/src/inspection-acts/inspection-acts.service.ts +grep -Fq 'INSPECTION_VISIT_ACT_ALREADY_EXISTS' api-v3/src/inspection-acts/inspection-acts.service.ts +grep -Fq 'INSPECTION_FINDING_RESPONSE_IMMUTABLE' api-v3/src/inspection-findings/inspection-findings.service.ts +echo "Una visita = una acta; Acta cerrada y Hallazgos originales permanecen inmutables: OK" + +echo +echo "========== D5.3.21.1 · HIGIENE DE CÓDIGO ==========" +if grep -RnhE --include='*.ts' --include='*.tsx' '^[[:space:]]*(//|/\*|\*)' api-v3/src web-v2/src; then + echo "ERROR: quedaron comentarios narrativos en código de producción" + exit 1 +fi +if grep -nE '/\*|\*/' web-v2/src/styles.css; then + echo "ERROR: quedaron comentarios internos en estilos de producción" + exit 1 +fi +echo "Código de producción preparado para revisión externa: OK" + +echo +echo "========== D5.3.21.1 · SEGURIDAD HEREDADA ==========" +STRONG_BACKUP="$(find /root/DH_V2_BACKUPS -maxdepth 1 -type d -name 'INVENTARIO_A_REVISAR_*' -printf '%T@ %p\n' | sort -nr | head -n1 | cut -d' ' -f2-)" +[[ -n "$STRONG_BACKUP" && -s "$STRONG_BACKUP/BACKUP_OK.txt" ]] +grep -Fq 'INVENTARIO A REVISAR' "$STRONG_BACKUP/BACKUP_OK.txt" +echo "Backup fuerte de referencia: $STRONG_BACKUP" + +echo +echo "========== D5.3.21.1 · CONTENEDORES ==========" +docker compose ps -a +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}')" == "healthy" ]] +[[ "$(docker inspect dhv2-api --format '{{.State.Status}}')" == "running" ]] +[[ "$(docker inspect dhv2-web --format '{{.State.Status}}')" == "running" ]] + +echo +echo "ACEPTACIÓN DE FASE D5.3.21.1: OK" diff --git a/scripts/phase-d5-3-22-acceptance.sh b/scripts/phase-d5-3-22-acceptance.sh new file mode 100755 index 0000000..3fa120e --- /dev/null +++ b/scripts/phase-d5-3-22-acceptance.sh @@ -0,0 +1,119 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT="${ROOT:-/var/www/dhv2.korexlabs.com}" +API_URL="${API_URL:-http://127.0.0.1:3101/api/v3}" +TMP_MIGRATION="/tmp/dhv2-d5322-accept-migration-$$.log" +trap 'rm -f "$TMP_MIGRATION"' EXIT +cd "$ROOT" + +echo "========== D5.3.22 · VERSIONES ==========" +[[ "$(node -p "require('./api-v3/package.json').version")" == "0.19.3-22" ]] +[[ "$(node -p "require('./web-v2/package.json').version")" == "0.19.3-22" ]] +grep -Fq "API_PHASE = 'D5.3.22'" api-v3/src/version.ts +grep -Fq "APP_PHASE = 'Fase D5.3.22 · Revisión directiva de Informes'" web-v2/src/config/version.ts +echo "API: 0.19.3-22" +echo "WEB: 0.19.3-22" + +echo +echo "========== D5.3.22 · DB Y MIGRACIONES ==========" +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}')" == "healthy" ]] +docker compose exec -T db sh -lc 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' +docker compose --profile tools run --rm migrate npm run migration:show 2>&1 | tee "$TMP_MIGRATION" +grep -Fq 'Pending migrations: no' "$TMP_MIGRATION" +for table in inspection_report_revisions inspection_report_signatures; do + docker compose exec -T db sh -lc "psql -U \"\$POSTGRES_USER\" -d \"\$POSTGRES_DB\" -Atc \"SELECT to_regclass('public.$table');\"" | grep -Fxq "$table" +done +for column in review_status current_revision_number approved_revision_id approved_by approved_at review_note signed_at; do + docker compose exec -T db sh -lc "psql -U \"\$POSTGRES_USER\" -d \"\$POSTGRES_DB\" -Atc \"SELECT column_name FROM information_schema.columns WHERE table_name='inspection_reports' AND column_name='$column';\"" | grep -Fxq "$column" +done +echo "Revisiones versionadas, aprobación y firma final presentes: OK" + +echo +echo "========== D5.3.22 · PERMISOS DIRECTOR ==========" +for permission in inspection_reports.revise inspection_reports.review inspection_reports.sign_final; do + docker compose exec -T db sh -lc "psql -U \"\$POSTGRES_USER\" -d \"\$POSTGRES_DB\" -Atc \"SELECT code FROM permissions WHERE code='$permission';\"" | grep -Fxq "$permission" + SEEDED_ROLES="$(docker compose exec -T db sh -lc "psql -U \"\$POSTGRES_USER\" -d \"\$POSTGRES_DB\" -Atc \"SELECT COALESCE(string_agg(r.code,',' ORDER BY r.code),'') FROM roles r JOIN role_permissions rp ON rp.role_id=r.id JOIN permissions p ON p.id=rp.permission_id WHERE p.code='$permission' AND r.code IN ('admin','director','supervisor','inspector','auditor');\"")" + [[ "$SEEDED_ROLES" == "director" ]] +done +grep -Fq "role.code = 'director'" api-v3/src/inspection-reports/inspection-report-review.service.ts +echo "Revisión, aprobación y firma final reservadas al Director: OK" + +echo +echo "========== D5.3.22 · HEALTH ==========" +HEALTH="$(curl -fsS "$API_URL/health")" +echo "$HEALTH" +grep -Fq '"version":"0.19.3-22"' <<<"$HEALTH" +grep -Fq '"phase":"D5.3.22"' <<<"$HEALTH" +grep -Fq '"database":"ok"' <<<"$HEALTH" + +echo +echo "========== D5.3.22 · VERSIONADO WORD ==========" +grep -Fq "'AUTO'" api-v3/src/inspection-reports/inspection-report-word.service.ts +grep -Fq "'DIRECTOR_UPLOAD'" api-v3/src/inspection-reports/inspection-report-review.service.ts +grep -Fq 'validateInspectionReportRevisionContainer' api-v3/src/inspection-reports/inspection-report-review.service.ts +grep -Fq 'vbaProject' api-v3/src/inspection-reports/inspection-report-revision-file.ts +echo "Word automático como versión 1 y correcciones append-only con integridad: OK" + +echo +echo "========== D5.3.22 · APROBACIÓN Y FIRMA ==========" +grep -Fq "review_status = 'APPROVED'" api-v3/src/inspection-reports/inspection-report-review.service.ts +grep -Fq "review_status = 'SIGNED'" api-v3/src/inspection-reports/inspection-report-review.service.ts +grep -Fq 'DH-INSPECTION-REPORT-SIGNATURE-V1' api-v3/src/inspection-reports/inspection-report-review.service.ts +grep -Fq 'sha256CanonicalJson(payload)' api-v3/src/inspection-reports/inspection-report-review.service.ts +echo "Firma electrónica vinculada a Informe congelado y revisión aprobada: OK" + +echo +echo "========== D5.3.22 · FLUJO WEB ==========" +grep -Fq 'Agregar Word corregido' web-v2/src/pages/ReportDetailPage.tsx +grep -Fq 'Aprobar versión actual' web-v2/src/pages/ReportDetailPage.tsx +grep -Fq 'Firmar informe final' web-v2/src/pages/ReportDetailPage.tsx +grep -Fq 'inspection_reports.sign_final' web-v2/src/pages/ReportDetailPage.tsx +echo "Director puede versionar, aprobar y firmar desde el expediente del Informe: OK" + +echo +echo "========== D5.3.22 · REGLAS PRESERVADAS ==========" +grep -Fq 'assertVisitHasNoAct' api-v3/src/inspection-acts/inspection-acts.service.ts +grep -Fq 'INSPECTION_VISIT_ACT_ALREADY_EXISTS' api-v3/src/inspection-acts/inspection-acts.service.ts +grep -Fq 'INSPECTION_FINDING_RESPONSE_IMMUTABLE' api-v3/src/inspection-findings/inspection-findings.service.ts +if grep -Fq 'ALTER TABLE inspection_acts' api-v3/src/database/migrations/1788631200000-phase-d5-3-22-director-report-review.ts; then exit 1; fi +if grep -Fq 'ALTER TABLE inspection_findings' api-v3/src/database/migrations/1788631200000-phase-d5-3-22-director-report-review.ts; then exit 1; fi +echo "Una visita = una Acta; Acta y Hallazgos originales siguen inmutables: OK" + +echo +echo "========== D5.3.22 · SMTP ==========" +SMTP_STATE="$(docker compose exec -T api sh -lc 'if [ -n "$SMTP_HOST" ] && [ -n "$MAIL_FROM" ]; then echo configured; else echo pending; fi')" +if [[ "$SMTP_STATE" == "configured" ]]; then + echo "SMTP configurado: la entrega documental puede operar" +else + echo "SMTP sigue sin configurar: no bloquea revisión, aprobación ni firma" +fi + +echo +echo "========== D5.3.22 · HIGIENE DE CÓDIGO ==========" +if grep -RnhE --include='*.ts' --include='*.tsx' '^[[:space:]]*(//|/\*|\*)' api-v3/src web-v2/src; then + echo "ERROR: quedaron comentarios narrativos en código de producción" + exit 1 +fi +if grep -nE '/\*|\*/' web-v2/src/styles.css; then + echo "ERROR: quedaron comentarios internos en estilos de producción" + exit 1 +fi +echo "Código de producción preparado para revisión externa: OK" + +echo +echo "========== D5.3.22 · SEGURIDAD HEREDADA ==========" +STRONG_BACKUP="$(find /root/DH_V2_BACKUPS -maxdepth 1 -type d -name 'INVENTARIO_A_REVISAR_*' -printf '%T@ %p\n' | sort -nr | head -n1 | cut -d' ' -f2-)" +[[ -n "$STRONG_BACKUP" && -s "$STRONG_BACKUP/BACKUP_OK.txt" ]] +grep -Fq 'INVENTARIO A REVISAR' "$STRONG_BACKUP/BACKUP_OK.txt" +echo "Backup fuerte de referencia: $STRONG_BACKUP" + +echo +echo "========== D5.3.22 · CONTENEDORES ==========" +docker compose ps -a +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}')" == "healthy" ]] +[[ "$(docker inspect dhv2-api --format '{{.State.Status}}')" == "running" ]] +[[ "$(docker inspect dhv2-web --format '{{.State.Status}}')" == "running" ]] + +echo +echo "ACEPTACIÓN DE FASE D5.3.22: OK" diff --git a/scripts/phase-d5-3-23-1-acceptance.sh b/scripts/phase-d5-3-23-1-acceptance.sh new file mode 100755 index 0000000..633373c --- /dev/null +++ b/scripts/phase-d5-3-23-1-acceptance.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT="${ROOT:-/var/www/dhv2.korexlabs.com}" +API_URL="${API_URL:-http://127.0.0.1:3101/api/v3}" +TMP_MIGRATION="/tmp/dhv2-d5323-accept-migration-$$.log" +trap 'rm -f "$TMP_MIGRATION"' EXIT +cd "$ROOT" + +echo "========== D5.3.23.1 · VERSIONES ==========" +[[ "$(node -p "require('./api-v3/package.json').version")" == "0.19.3-23.1" ]] +[[ "$(node -p "require('./web-v2/package.json').version")" == "0.19.3-23.1" ]] +grep -Fq "API_PHASE = 'D5.3.23.1'" api-v3/src/version.ts +grep -Fq "APP_PHASE = 'Fase D5.3.23.1 · Altas de campo y revisión de Inventarios'" web-v2/src/config/version.ts +echo "API: 0.19.3-23.1" +echo "WEB: 0.19.3-23.1" + +echo +echo "========== D5.3.23.1 · DB Y MIGRACIONES ==========" +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}')" == "healthy" ]] +docker compose exec -T db sh -lc 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' +docker compose --profile tools run --rm migrate npm run migration:show 2>&1 | tee "$TMP_MIGRATION" +grep -Fq 'Pending migrations: no' "$TMP_MIGRATION" +docker compose exec -T db sh -lc 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Atc "SELECT to_regclass('"'"'public.asset_field_discoveries'"'"');"' | grep -Fxq 'asset_field_discoveries' +for column in asset_id visit_id status discovery_notes observed_at created_by reviewed_at reviewed_by review_notes matched_asset_id; do + docker compose exec -T db sh -lc "psql -U \"\$POSTGRES_USER\" -d \"\$POSTGRES_DB\" -Atc \"SELECT column_name FROM information_schema.columns WHERE table_name='asset_field_discoveries' AND column_name='$column';\"" | grep -Fxq "$column" +done +echo "Bandeja auditable de altas de campo presente: OK" + +echo +echo "========== D5.3.23.1 · HEALTH ==========" +HEALTH="$(curl -fsS "$API_URL/health")" +echo "$HEALTH" +grep -Fq '"version":"0.19.3-23.1"' <<<"$HEALTH" +grep -Fq '"phase":"D5.3.23.1"' <<<"$HEALTH" +grep -Fq '"database":"ok"' <<<"$HEALTH" + +echo +echo "========== D5.3.23.1 · ALTA DE CAMPO ==========" +grep -Fq 'assertMobileInspector(principal)' api-v3/src/asset-master/assets.service.ts +grep -Fq "visit.status !== 'IN_PROGRESS'" api-v3/src/asset-master/assets.service.ts +grep -Fq 'AssetDataOrigin.FIELD_SURVEY' api-v3/src/asset-master/assets.service.ts +grep -Fq 'AssetInformationStatus.DRAFT' api-v3/src/asset-master/assets.service.ts +grep -Fq 'fieldDiscoveryInspectionLinks.attach' api-v3/src/asset-master/assets.service.ts +if grep -Fq 'INSERT INTO inspection_' api-v3/src/asset-master/assets.service.ts; then exit 1; fi +grep -Fq 'INSERT INTO inspection_visit_assets' api-v3/src/inspection-operations/field-discovery-inspection-link.service.ts +grep -Fq 'INSERT INTO inspection_act_assets' api-v3/src/inspection-operations/field-discovery-inspection-link.service.ts +grep -Fq 'FIELD_DISCOVERY_ACT_NOT_EDITABLE' api-v3/src/inspection-operations/field-discovery-inspection-link.service.ts +echo "Inspector móvil puede registrar un elemento provisional y usarlo en la visita/Acta: OK" + +echo +echo "========== D5.3.23.1 · REVISIÓN DE OFICINA ==========" +grep -Fq "field-discoveries/:id/approve" api-v3/src/asset-master/assets.controller.ts +grep -Fq "field-discoveries/:id/match" api-v3/src/asset-master/assets.controller.ts +grep -Fq "field-discoveries/:id/reject" api-v3/src/asset-master/assets.controller.ts +grep -Fq "@RequirePermissions('assets.change_status')" api-v3/src/asset-master/assets.controller.ts +grep -Fq 'FIELD_DISCOVERY_MINIMUM_CONTEXT_MISSING' api-v3/src/asset-master/assets.service.ts +grep -Fq 'FIELD_DISCOVERY_MATCH_CONTEXT_MISMATCH' api-v3/src/asset-master/assets.service.ts +echo "Oficina puede corregir, aprobar, conciliar o rechazar sin borrar historia: OK" + +echo +echo "========== D5.3.23.1 · FLUJO WEB ==========" +grep -Fq 'Altas encontradas en campo' web-v2/src/pages/FieldDiscoveriesPage.tsx +grep -Fq 'Corregir ficha' web-v2/src/pages/FieldDiscoveriesPage.tsx +grep -Fq 'Conciliar' web-v2/src/pages/FieldDiscoveriesPage.tsx +grep -Fq '/inventarios/revision-campo' web-v2/src/app/App.tsx +grep -Fq 'assets.change_status' web-v2/src/app/App.tsx +echo "Bandeja de revisión de campo disponible en Inventarios: OK" + +echo +echo "========== D5.3.23.1 · INMUTABILIDAD ==========" +if grep -Fq 'UPDATE inspection_findings SET asset_id' api-v3/src/asset-master/assets.service.ts; then exit 1; fi +if grep -Fq 'UPDATE inspection_act_assets SET asset_id' api-v3/src/asset-master/assets.service.ts; then exit 1; fi +grep -Fq 'matched_asset_id' api-v3/src/database/migrations/1788717600000-phase-d5-3-23-field-discoveries.ts +grep -Fq 'INSPECTION_FINDING_RESPONSE_IMMUTABLE' api-v3/src/inspection-findings/inspection-findings.service.ts +grep -Fq 'INSPECTION_VISIT_ACT_ALREADY_EXISTS' api-v3/src/inspection-acts/inspection-acts.service.ts +echo "Conciliar no reescribe Actas ni Hallazgos históricos: OK" + +echo +echo "========== D5.3.23.1 · SMTP ==========" +SMTP_STATE="$(docker compose exec -T api sh -lc 'if [ -n "$SMTP_HOST" ] && [ -n "$MAIL_FROM" ]; then echo configured; else echo pending; fi')" +if [[ "$SMTP_STATE" == "configured" ]]; then + echo "SMTP configurado" +else + echo "SMTP sigue pendiente y no bloquea esta fase" +fi + +echo +echo "========== D5.3.23.1 · HIGIENE DE CÓDIGO ==========" +if grep -RnhE --include='*.ts' --include='*.tsx' '^[[:space:]]*(//|/\*|\*)' api-v3/src web-v2/src; then + echo "ERROR: quedaron comentarios narrativos en código de producción" + exit 1 +fi +if grep -nE '/\*|\*/' web-v2/src/styles.css; then + echo "ERROR: quedaron comentarios internos en estilos de producción" + exit 1 +fi +echo "Código de producción preparado para revisión externa: OK" + +echo +echo "========== D5.3.23.1 · SEGURIDAD HEREDADA ==========" +STRONG_BACKUP="$(find /root/DH_V2_BACKUPS -maxdepth 1 -type d -name 'INVENTARIO_A_REVISAR_*' -printf '%T@ %p\n' | sort -nr | head -n1 | cut -d' ' -f2-)" +[[ -n "$STRONG_BACKUP" && -s "$STRONG_BACKUP/BACKUP_OK.txt" ]] +grep -Fq 'INVENTARIO A REVISAR' "$STRONG_BACKUP/BACKUP_OK.txt" +echo "Backup fuerte de referencia: $STRONG_BACKUP" + +echo +echo "========== D5.3.23.1 · CONTENEDORES ==========" +docker compose ps -a +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}')" == "healthy" ]] +[[ "$(docker inspect dhv2-api --format '{{.State.Status}}')" == "running" ]] +[[ "$(docker inspect dhv2-web --format '{{.State.Status}}')" == "running" ]] + +echo +echo "ACEPTACIÓN DE FASE D5.3.23.1: OK" diff --git a/scripts/phase-d5-3-24-acceptance.sh b/scripts/phase-d5-3-24-acceptance.sh new file mode 100755 index 0000000..fd5a1ea --- /dev/null +++ b/scripts/phase-d5-3-24-acceptance.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT="${ROOT:-/var/www/dhv2.korexlabs.com}" +API_URL="${API_URL:-http://127.0.0.1:3101/api/v3}" +TMP_MIGRATION="/tmp/dhv2-d5324-accept-migration-$$.log" +trap 'rm -f "$TMP_MIGRATION"' EXIT +cd "$ROOT" + +echo "========== D5.3.24 · VERSIONES ==========" +[[ "$(node -p "require('./api-v3/package.json').version")" == "0.19.3-24" ]] +[[ "$(node -p "require('./web-v2/package.json').version")" == "0.19.3-24" ]] +grep -Fq "API_PHASE = 'D5.3.24'" api-v3/src/version.ts +grep -Fq "APP_PHASE = 'Fase D5.3.24 · Historial de contexto operativo'" web-v2/src/config/version.ts +echo "API: 0.19.3-24" +echo "WEB: 0.19.3-24" + +echo +echo "========== D5.3.24 · DB Y MIGRACIONES ==========" +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}')" == "healthy" ]] +docker compose exec -T db sh -lc 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' +docker compose --profile tools run --rm migrate npm run migration:show 2>&1 | tee "$TMP_MIGRATION" +grep -Fq 'Pending migrations: no' "$TMP_MIGRATION" +docker compose exec -T db sh -lc 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Atc "SELECT to_regclass('"'"'public.asset_context_history'"'"');"' | grep -Fxq 'asset_context_history' +for column in asset_id parent_id operational_area_id operator_company_id valid_from valid_until change_reason end_reason asset_version_number source request_id created_by ended_by; do + docker compose exec -T db sh -lc "psql -U \"\$POSTGRES_USER\" -d \"\$POSTGRES_DB\" -Atc \"SELECT column_name FROM information_schema.columns WHERE table_name='asset_context_history' AND column_name='$column';\"" | grep -Fxq "$column" +done +ACTIVE_DUPLICATES="$(docker compose exec -T db sh -lc 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Atc "SELECT COUNT(*) FROM (SELECT asset_id FROM asset_context_history WHERE valid_until IS NULL GROUP BY asset_id HAVING COUNT(*)>1) duplicated;"')" +[[ "$ACTIVE_DUPLICATES" == "0" ]] +ASSET_COUNT="$(docker compose exec -T db sh -lc 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Atc "SELECT COUNT(*) FROM assets;"')" +ACTIVE_CONTEXT_COUNT="$(docker compose exec -T db sh -lc 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Atc "SELECT COUNT(*) FROM asset_context_history WHERE valid_until IS NULL;"')" +[[ "$ACTIVE_CONTEXT_COUNT" == "$ASSET_COUNT" ]] +echo "Historial temporal de jerarquía, Área y Operadora presente: OK" + +echo +echo "========== D5.3.24 · PERMISO DE CAMBIO ==========" +docker compose exec -T db sh -lc 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Atc "SELECT code FROM permissions WHERE code='"'"'assets.manage_context'"'"';"' | grep -Fxq 'assets.manage_context' +for role in admin supervisor; do + docker compose exec -T db sh -lc "psql -U \"\$POSTGRES_USER\" -d \"\$POSTGRES_DB\" -Atc \"SELECT r.code FROM role_permissions rp JOIN roles r ON r.id=rp.role_id JOIN permissions p ON p.id=rp.permission_id WHERE p.code='assets.manage_context' AND r.code='$role';\"" | grep -Fxq "$role" +done +if docker compose exec -T db sh -lc 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Atc "SELECT r.code FROM role_permissions rp JOIN roles r ON r.id=rp.role_id JOIN permissions p ON p.id=rp.permission_id WHERE p.code='"'"'assets.manage_context'"'"' AND r.code='"'"'inspector'"'"';"' | grep -q .; then + echo "ERROR: Inspector no debe poder transferir el contexto de un Inventario consolidado" + exit 1 +fi +echo "Cambio formal de contexto reservado a administración de oficina: OK" + +echo +echo "========== D5.3.24 · HEALTH ==========" +HEALTH="$(curl -fsS "$API_URL/health")" +echo "$HEALTH" +grep -Fq '"version":"0.19.3-24"' <<<"$HEALTH" +grep -Fq '"phase":"D5.3.24"' <<<"$HEALTH" +grep -Fq '"database":"ok"' <<<"$HEALTH" + +echo +echo "========== D5.3.24 · HISTORIA NO DESTRUCTIVA ==========" +grep -Fq 'FROM asset_versions version' api-v3/src/database/migrations/1788804000000-phase-d5-3-24-asset-context-history.ts +grep -Fq 'ASSET_CONTEXT_CHANGE_REQUIRES_TRANSFER' api-v3/src/asset-master/assets.service.ts +grep -Fq 'AssetVersionChangeType.CONTEXT_CHANGED' api-v3/src/asset-master/assets.service.ts +grep -Fq 'AuditAction.ASSET_CONTEXT_CHANGED' api-v3/src/asset-master/assets.service.ts +if grep -Fq 'DELETE FROM asset_context_history' api-v3/src/asset-master/assets.service.ts; then exit 1; fi +echo "Cambio actual conserva contexto anterior y genera versión/auditoría: OK" + +echo +echo "========== D5.3.24 · RECONSTRUCCIÓN TEMPORAL ==========" +grep -Fq 'FROM asset_context_history history' api-v3/src/asset-master/asset-temporal.service.ts +grep -Fq 'history.valid_from <= $2' api-v3/src/asset-master/asset-temporal.service.ts +grep -Fq 'history.valid_until IS NULL OR history.valid_until > $2' api-v3/src/asset-master/asset-temporal.service.ts +grep -Fq "@Get(':id/context-history')" api-v3/src/asset-master/assets.controller.ts +grep -Fq "@Post(':id/context')" api-v3/src/asset-master/assets.controller.ts +echo "La consulta histórica resuelve el contexto vigente en la fecha solicitada: OK" + +echo +echo "========== D5.3.24 · FLUJO WEB ==========" +grep -Fq 'Jerarquía, Área y Operadora' web-v2/src/features/assets/AssetContextHistoryPanel.tsx +grep -Fq 'Cambiar contexto' web-v2/src/features/assets/AssetContextHistoryPanel.tsx +grep -Fq 'Historial de contexto' web-v2/src/features/assets/AssetContextHistoryPanel.tsx +grep -Fq 'canDirectContextEdit' web-v2/src/pages/AssetEditorPage.tsx +grep -Fq "hasPermission('assets.manage_context')" web-v2/src/pages/AssetEditorPage.tsx +echo "Inventario muestra contexto vigente, historial y cambio con motivo/fecha: OK" + +echo +echo "========== D5.3.24 · REGLAS PRESERVADAS ==========" +grep -Fq 'INSPECTION_VISIT_ACT_ALREADY_EXISTS' api-v3/src/inspection-acts/inspection-acts.service.ts +grep -Fq 'INSPECTION_FINDING_RESPONSE_IMMUTABLE' api-v3/src/inspection-findings/inspection-findings.service.ts +if grep -Fq 'UPDATE inspection_findings SET asset_id' api-v3/src/asset-master/assets.service.ts; then exit 1; fi +echo "Una visita = una Acta y documentos/hallazgos históricos no se reescriben: OK" + +echo +echo "========== D5.3.24 · SMTP ==========" +SMTP_STATE="$(docker compose exec -T api sh -lc 'if [ -n "$SMTP_HOST" ] && [ -n "$MAIL_FROM" ]; then echo configured; else echo pending; fi')" +if [[ "$SMTP_STATE" == "configured" ]]; then echo "SMTP configurado"; else echo "SMTP sigue pendiente y no bloquea esta fase"; fi + +echo +echo "========== D5.3.24 · HIGIENE DE CÓDIGO ==========" +if grep -RnhE --include='*.ts' --include='*.tsx' '^[[:space:]]*(//|/\*|\*)' api-v3/src web-v2/src; then + echo "ERROR: quedaron comentarios narrativos en código de producción" + exit 1 +fi +if grep -nE '/\*|\*/' web-v2/src/styles.css; then + echo "ERROR: quedaron comentarios internos en estilos de producción" + exit 1 +fi +echo "Código de producción preparado para revisión externa: OK" + +echo +echo "========== D5.3.24 · SEGURIDAD HEREDADA ==========" +STRONG_BACKUP="$(find /root/DH_V2_BACKUPS -maxdepth 1 -type d -name 'INVENTARIO_A_REVISAR_*' -printf '%T@ %p\n' | sort -nr | head -n1 | cut -d' ' -f2-)" +[[ -n "$STRONG_BACKUP" && -s "$STRONG_BACKUP/BACKUP_OK.txt" ]] +grep -Fq 'INVENTARIO A REVISAR' "$STRONG_BACKUP/BACKUP_OK.txt" +echo "Backup fuerte de referencia: $STRONG_BACKUP" + +echo +echo "========== D5.3.24 · CONTENEDORES ==========" +docker compose ps -a +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}')" == "healthy" ]] +[[ "$(docker inspect dhv2-api --format '{{.State.Status}}')" == "running" ]] +[[ "$(docker inspect dhv2-web --format '{{.State.Status}}')" == "running" ]] + +echo +echo "ACEPTACIÓN DE FASE D5.3.24: OK" diff --git a/scripts/phase-d5-3-25-acceptance.sh b/scripts/phase-d5-3-25-acceptance.sh new file mode 100755 index 0000000..c733071 --- /dev/null +++ b/scripts/phase-d5-3-25-acceptance.sh @@ -0,0 +1,119 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT="${ROOT:-/var/www/dhv2.korexlabs.com}" +API_URL="${API_URL:-http://127.0.0.1:3101/api/v3}" +TMP_MIGRATION="/tmp/dhv2-d5325-accept-migration-$$.log" +trap 'rm -f "$TMP_MIGRATION"' EXIT +cd "$ROOT" + +echo "========== D5.3.25 · VERSIONES ==========" +[[ "$(node -p "require('./api-v3/package.json').version")" == "0.19.3-25" ]] +[[ "$(node -p "require('./web-v2/package.json').version")" == "0.19.3-25" ]] +grep -Fq "API_PHASE = 'D5.3.25'" api-v3/src/version.ts +grep -Fq "APP_PHASE = 'Fase D5.3.25 · Historial de verificaciones'" web-v2/src/config/version.ts +echo "API: 0.19.3-25" +echo "WEB: 0.19.3-25" + +echo +echo "========== D5.3.25 · DB Y MIGRACIONES ==========" +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}')" == "healthy" ]] +docker compose exec -T db sh -lc 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' +docker compose --profile tools run --rm migrate npm run migration:show 2>&1 | tee "$TMP_MIGRATION" +grep -Fq 'Pending migrations: no' "$TMP_MIGRATION" +docker compose exec -T db sh -lc 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Atc "SELECT to_regclass('"'"'public.inspection_finding_verification_events'"'"');"' | grep -Fxq 'inspection_finding_verification_events' +for column in finding_id verification_visit_id event_type occurred_at target_control_on previous_control_on next_control_on outcome notes actor_user_id actor_username created_at; do + docker compose exec -T db sh -lc "psql -U \"\$POSTGRES_USER\" -d \"\$POSTGRES_DB\" -Atc \"SELECT column_name FROM information_schema.columns WHERE table_name='inspection_finding_verification_events' AND column_name='$column';\"" | grep -Fxq "$column" +done +TRIGGER_COUNT="$(docker compose exec -T db sh -lc 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Atc "SELECT COUNT(*) FROM pg_trigger WHERE tgname='"'"'trg_inspection_verification_result_immutable'"'"' AND NOT tgisinternal;"')" +[[ "$TRIGGER_COUNT" == "1" ]] +echo "Ledger append-only y protección de resultados presentes: OK" + +echo +echo "========== D5.3.25 · BACKFILL HISTÓRICO ==========" +LINK_COUNT="$(docker compose exec -T db sh -lc 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Atc "SELECT COUNT(*) FROM inspection_finding_verification_visits;"')" +PLANNED_EVENT_COUNT="$(docker compose exec -T db sh -lc 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Atc "SELECT COUNT(*) FROM inspection_finding_verification_events WHERE event_type='"'"'VISIT_PLANNED'"'"';"')" +RESULT_LINK_COUNT="$(docker compose exec -T db sh -lc 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Atc "SELECT COUNT(*) FROM inspection_finding_verification_visits WHERE outcome IS NOT NULL;"')" +RESULT_EVENT_COUNT="$(docker compose exec -T db sh -lc 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Atc "SELECT COUNT(*) FROM inspection_finding_verification_events WHERE event_type='"'"'RESULT_RECORDED'"'"';"')" +(( PLANNED_EVENT_COUNT >= LINK_COUNT )) +(( RESULT_EVENT_COUNT >= RESULT_LINK_COUNT )) +echo "Antecedentes de visitas y resultados reconstruidos sin borrar historia: OK" + +echo +echo "========== D5.3.25 · HEALTH ==========" +HEALTH="$(curl -fsS "$API_URL/health")" +echo "$HEALTH" +grep -Fq '"version":"0.19.3-25"' <<<"$HEALTH" +grep -Fq '"phase":"D5.3.25"' <<<"$HEALTH" +grep -Fq '"database":"ok"' <<<"$HEALTH" + +echo +echo "========== D5.3.25 · RESULTADOS INMUTABLES ==========" +grep -Fq 'VERIFICATION_RESULT_IMMUTABLE' api-v3/src/inspection-verifications/inspection-verifications.service.ts +grep -Fq 'protect_inspection_verification_result_immutability' api-v3/src/database/migrations/1788890400000-phase-d5-3-25-verification-event-history.ts +grep -Fq 'InspectionFindingVerificationEventType.RESULT_RECORDED' api-v3/src/inspection-verifications/inspection-verifications.service.ts +echo "Un resultado de campo registrado no puede sobrescribirse: OK" + +echo +echo "========== D5.3.25 · REPROGRAMACIONES APPEND-ONLY ==========" +grep -Fq 'InspectionFindingVerificationEventType.CONTROL_DATE_DEFINED' api-v3/src/inspection-findings/inspection-findings.service.ts +grep -Fq 'InspectionFindingVerificationEventType.CONTROL_DATE_CHANGED' api-v3/src/inspection-findings/inspection-findings.service.ts +grep -Fq 'InspectionFindingVerificationEventType.CONTROL_DATE_CLEARED' api-v3/src/inspection-findings/inspection-findings.service.ts +if grep -Rnh 'DELETE FROM inspection_finding_verification_events' api-v3/src/inspection-findings api-v3/src/inspection-verifications; then exit 1; fi +echo "Cada cambio de fecha conserva la fecha anterior como evento: OK" + +echo +echo "========== D5.3.25 · VERIFICACIONES SUCESIVAS ==========" +grep -Fq 'InspectionFindingVerificationEventType.VISIT_PLANNED' api-v3/src/inspection-verifications/inspection-verifications.service.ts +grep -Fq "visit.status IN ('DRAFT', 'PLANNED', 'IN_PROGRESS')" api-v3/src/inspection-verifications/inspection-verifications.service.ts +if grep -Fq 'DELETE FROM inspection_finding_verification_visits' api-v3/src/inspection-verifications/inspection-verifications.service.ts; then exit 1; fi +echo "Múltiples verificaciones sucesivas quedan como intentos históricos independientes: OK" + +echo +echo "========== D5.3.25 · EXPEDIENTE WEB ==========" +grep -Fq "@Get(':id/verification-history')" api-v3/src/inspection-findings/inspection-findings.controller.ts +grep -Fq 'inspection_finding_verification_events event' api-v3/src/inspection-findings/inspection-findings.service.ts +grep -Fq 'Historial completo de verificaciones' web-v2/src/features/inspections/FindingOfficeWorkspace.tsx +grep -Fq 'verificationHistory.map' web-v2/src/features/inspections/FindingOfficeWorkspace.tsx +echo "Hallazgo muestra cronología completa de fechas, visitas y resultados: OK" + +echo +echo "========== D5.3.25 · REGLAS PRESERVADAS ==========" +grep -Fq 'INSPECTION_VISIT_ACT_ALREADY_EXISTS' api-v3/src/inspection-acts/inspection-acts.service.ts +grep -Fq 'INSPECTION_FINDING_RESPONSE_IMMUTABLE' api-v3/src/inspection-findings/inspection-findings.service.ts +if grep -Fq 'UPDATE inspection_findings SET asset_id' api-v3/src/asset-master/assets.service.ts; then exit 1; fi +echo "Una visita = una Acta; respuesta original, hallazgos y documentos históricos siguen protegidos: OK" + +echo +echo "========== D5.3.25 · SMTP ==========" +SMTP_STATE="$(docker compose exec -T api sh -lc 'if [ -n "$SMTP_HOST" ] && [ -n "$MAIL_FROM" ]; then echo configured; else echo pending; fi')" +if [[ "$SMTP_STATE" == "configured" ]]; then echo "SMTP configurado"; else echo "SMTP sigue pendiente y no bloquea esta fase"; fi + +echo +echo "========== D5.3.25 · HIGIENE DE CÓDIGO ==========" +if grep -RnhE --include='*.ts' --include='*.tsx' '^[[:space:]]*(//|/\*|\*)' api-v3/src web-v2/src; then + echo "ERROR: quedaron comentarios narrativos en código de producción" + exit 1 +fi +if grep -nE '/\*|\*/' web-v2/src/styles.css; then + echo "ERROR: quedaron comentarios internos en estilos de producción" + exit 1 +fi +echo "Código de producción preparado para revisión externa: OK" + +echo +echo "========== D5.3.25 · SEGURIDAD HEREDADA ==========" +STRONG_BACKUP="$(find /root/DH_V2_BACKUPS -maxdepth 1 -type d -name 'INVENTARIO_A_REVISAR_*' -printf '%T@ %p\n' | sort -nr | head -n1 | cut -d' ' -f2-)" +[[ -n "$STRONG_BACKUP" && -s "$STRONG_BACKUP/BACKUP_OK.txt" ]] +grep -Fq 'INVENTARIO A REVISAR' "$STRONG_BACKUP/BACKUP_OK.txt" +echo "Backup fuerte de referencia: $STRONG_BACKUP" + +echo +echo "========== D5.3.25 · CONTENEDORES ==========" +docker compose ps -a +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}')" == "healthy" ]] +[[ "$(docker inspect dhv2-api --format '{{.State.Status}}')" == "running" ]] +[[ "$(docker inspect dhv2-web --format '{{.State.Status}}')" == "running" ]] + +echo +echo "ACEPTACIÓN DE FASE D5.3.25: OK" diff --git a/scripts/phase-d5-3-3-acceptance.sh b/scripts/phase-d5-3-3-acceptance.sh new file mode 100755 index 0000000..15fd31d --- /dev/null +++ b/scripts/phase-d5-3-3-acceptance.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT="${ROOT:-/var/www/dhv2.korexlabs.com}" +cd "$ROOT" +DB_SERVICE="${DB_SERVICE:-db}" +API_URL="${API_URL:-http://127.0.0.1:3101/api/v3}" + +psql_app() { + docker compose exec -T "$DB_SERVICE" sh -lc \ + 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -At -v ON_ERROR_STOP=1' <<<"$1" +} + +echo "========== D5.3.3 · VERSIONES ==========" +API_VERSION="$(node -p "require('./api-v3/package.json').version")" +WEB_VERSION="$(node -p "require('./web-v2/package.json').version")" +[[ "$API_VERSION" == "0.19.3-3" ]] +[[ "$WEB_VERSION" == "0.19.3-3" ]] +echo "API: $API_VERSION" +echo "WEB: $WEB_VERSION" + +echo +echo "========== D5.3.3 · MIGRACIONES ==========" +MIGRATION_OUTPUT="$(docker compose --profile tools run --rm migrate npm run migration:show 2>&1)" +echo "$MIGRATION_OUTPUT" +grep -q "Pending migrations: no" <<<"$MIGRATION_OUTPUT" + +echo +echo "========== D5.3.3 · HEALTH ==========" +HEALTH="$(curl -fsS "$API_URL/health")" +echo "$HEALTH" +grep -q '"version":"0.19.3-3"' <<<"$HEALTH" +grep -q '"phase":"D5.3.3"' <<<"$HEALTH" + +echo +echo "========== D5.3.3 · ENDPOINTS PROTEGIDOS ==========" +CODE="$(curl -sS -o /tmp/dhv2-d533-registry.txt -w '%{http_code}' "$API_URL/source-documents")" +[[ "$CODE" == "401" ]] +echo "/source-documents sin sesión: 401 OK" +CODE="$(curl -sS -o /tmp/dhv2-d533-assets-registry.txt -w '%{http_code}' "$API_URL/assets/550e8400-e29b-41d4-a716-446655440000/registry")" +[[ "$CODE" == "401" ]] +echo "/assets/:id/registry sin sesión: 401 OK" + +echo +echo "========== D5.3.3 · MODELO ==========" +echo "operational_status_type|$(psql_app "SELECT COUNT(*) FROM pg_type WHERE typname='asset_operational_status';")" +echo "assets_operational_status|$(psql_app "SELECT COUNT(*) FROM information_schema.columns WHERE table_name='assets' AND column_name='operational_status';")" +echo "organization_profiles|$(psql_app "SELECT COUNT(*) FROM information_schema.tables WHERE table_name='organization_profiles';")" +echo "organization_memberships|$(psql_app "SELECT COUNT(*) FROM information_schema.tables WHERE table_name='organization_memberships';")" +echo "source_documents|$(psql_app "SELECT COUNT(*) FROM information_schema.tables WHERE table_name='source_documents';")" +echo "asset_source_documents|$(psql_app "SELECT COUNT(*) FROM information_schema.tables WHERE table_name='asset_source_documents';")" +echo "external_identifiers|$(psql_app "SELECT COUNT(*) FROM information_schema.tables WHERE table_name='asset_external_identifiers';")" +echo "legal_rights|$(psql_app "SELECT COUNT(*) FROM information_schema.tables WHERE table_name='area_legal_rights';")" +echo "legal_right_organizations|$(psql_app "SELECT COUNT(*) FROM information_schema.tables WHERE table_name='area_legal_right_organizations';")" +echo "area_relation_role|$(psql_app "SELECT COUNT(*) FROM information_schema.columns WHERE table_name='area_company_relations' AND column_name='relation_role';")" + +[[ "$(psql_app "SELECT COUNT(*) FROM pg_type WHERE typname='asset_operational_status';")" == "1" ]] +[[ "$(psql_app "SELECT COUNT(*) FROM information_schema.tables WHERE table_name IN ('organization_profiles','organization_memberships','source_documents','asset_source_documents','asset_external_identifiers','area_legal_rights','area_legal_right_organizations');")" == "7" ]] +[[ "$(psql_app "SELECT COUNT(*) FROM information_schema.columns WHERE table_name='area_company_relations' AND column_name IN ('relation_role','participation_percent','legal_instrument','source_document_id');")" == "4" ]] +[[ "$(psql_app "SELECT COUNT(*) FROM permissions WHERE code IN ('asset_registry.read','asset_registry.manage','assets.change_operational_status');")" == "3" ]] +[[ "$(psql_app "SELECT COUNT(*) FROM asset_types WHERE code='area' AND name='Área';")" == "1" ]] +[[ "$(psql_app "SELECT COUNT(*) FROM asset_types WHERE code='empresa' AND name='Organización';")" == "1" ]] + +echo +echo "========== D5.3.3 · INTEGRIDAD ==========" +[[ "$(psql_app "SELECT COUNT(*) FROM pg_trigger WHERE tgname IN ('trg_organization_profiles_role','trg_organization_memberships_role','trg_area_legal_rights_role','trg_area_legal_right_org_role') AND NOT tgisinternal;")" == "4" ]] +[[ "$(psql_app "SELECT COUNT(*) FROM pg_indexes WHERE indexname IN ('uq_organization_memberships_active_role','uq_area_company_relations_active_role','uq_area_legal_right_org_active_role','uq_asset_external_identifiers_active_namespace_value');")" == "4" ]] +echo "Roles, vigencias e identificadores protegidos: OK" + +echo +echo "========== D5.3.3 · SOURCE / DASHBOARD ==========" +grep -q "OrganizationKind.UTE" api-v3/src/asset-master/asset-registry.service.ts +grep -q "relation_role = 'OPERATOR'" api-v3/src/asset-master/assets.service.ts +grep -q "Estado operativo" web-v2/src/pages/AssetEditorPage.tsx +grep -q "Organización operadora" web-v2/src/pages/AssetEditorPage.tsx +grep -q "0.19.3-3" web-v2/src/config/version.ts +bash -n scripts/dev-clean-demo.sh +echo "Semántica Área / Organización y estado operativo: OK" + +echo +echo "========== D5.3.3 · DEMO ==========" +DEMO_COUNT="$(psql_app "SELECT COUNT(*) FROM assets WHERE code LIKE 'DEV-DEMO-%';")" +echo "Activos DEV-DEMO actuales: $DEMO_COUNT" +if [[ "$DEMO_COUNT" -gt "0" ]]; then + echo "El demo previo fue preservado por la migración. Puede limpiarse y resembrarse después del deploy para aplicar documento fuente y estado operativo." +fi + +echo +echo "========== D5.3.3 · CONTENEDORES ==========" +docker compose ps -a + +echo +echo "ACEPTACIÓN DE FASE D5.3.3: OK" diff --git a/scripts/phase-d5-3-4-acceptance.sh b/scripts/phase-d5-3-4-acceptance.sh new file mode 100755 index 0000000..6b6d260 --- /dev/null +++ b/scripts/phase-d5-3-4-acceptance.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT="${ROOT:-/var/www/dhv2.korexlabs.com}" +cd "$ROOT" +API_URL="${API_URL:-http://127.0.0.1:3101/api/v3}" +DB_SERVICE="${DB_SERVICE:-db}" + +psql_app() { + docker compose exec -T "$DB_SERVICE" sh -lc \ + 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -At -v ON_ERROR_STOP=1' <<<"$1" +} + +echo "========== D5.3.4 · VERSIONES ==========" +API_VERSION="$(node -p "require('./api-v3/package.json').version")" +WEB_VERSION="$(node -p "require('./web-v2/package.json').version")" +[[ "$API_VERSION" == "0.19.3-4" ]] +[[ "$WEB_VERSION" == "0.19.3-4" ]] +echo "API: $API_VERSION" +echo "WEB: $WEB_VERSION" + +echo +echo "========== D5.3.4 · MIGRACIONES ==========" +MIGRATION_OUTPUT="$(docker compose --profile tools run --rm migrate npm run migration:show 2>&1)" +echo "$MIGRATION_OUTPUT" +grep -q "Pending migrations: no" <<<"$MIGRATION_OUTPUT" + +echo +echo "========== D5.3.4 · HEALTH ==========" +HEALTH="$(curl -fsS "$API_URL/health")" +echo "$HEALTH" +grep -q '"version":"0.19.3-4"' <<<"$HEALTH" +grep -q '"phase":"D5.3.4"' <<<"$HEALTH" + +echo +echo "========== D5.3.4 · ASSET CENTER PROTEGIDO ==========" +CODE="$(curl -sS -o /tmp/dhv2-d534-tree.txt -w '%{http_code}' "$API_URL/assets/tree")" +[[ "$CODE" == "401" ]] +echo "/assets/tree sin sesión: 401 OK" + +echo +echo "========== D5.3.4 · MODELO D5.3.3 DISPONIBLE ==========" +[[ "$(psql_app "SELECT COUNT(*) FROM information_schema.tables WHERE table_name IN ('organization_profiles','organization_memberships','source_documents','asset_source_documents','asset_external_identifiers','area_legal_rights','area_legal_right_organizations');")" == "7" ]] +[[ "$(psql_app "SELECT COUNT(*) FROM information_schema.columns WHERE table_name='assets' AND column_name='operational_status';")" == "1" ]] +echo "Registro documental, Organizaciones y estado operativo: OK" + +echo +echo "========== D5.3.4 · API ==========" +grep -q "@Get('tree')" api-v3/src/asset-master/assets.controller.ts +grep -q "asset_external_identifiers external_id" api-v3/src/asset-master/assets.service.ts +grep -q 'assetsNeedValidation' api-v3/src/dashboard/dashboard.service.ts +grep -q 'plannedInspections' api-v3/src/dashboard/dashboard.service.ts +echo "Jerarquía, búsqueda por identificador y resumen operativo: OK" + +echo +echo "========== D5.3.4 · DASHBOARD / UX ==========" +grep -q 'AssetCenterTabs' web-v2/src/pages/AssetsPage.tsx +grep -q 'Pendientes de validar' web-v2/src/pages/AssetsPage.tsx +grep -q 'Agregar activo aquí' web-v2/src/pages/AssetEditorPage.tsx +grep -q 'Documentos y registro' web-v2/src/pages/AssetEditorPage.tsx +grep -q 'Configuración avanzada' web-v2/src/pages/AssetTypesPage.tsx +grep -q 'Roles y permisos' web-v2/src/layout/AppLayout.tsx +grep -q 'Qué requiere atención' web-v2/src/pages/DashboardPage.tsx +grep -q '0.19.3-4' web-v2/src/config/version.ts +echo "Asset Center, alta contextual y administración simplificada: OK" + +echo +echo "========== D5.3.4 · CONTENEDORES ==========" +docker compose ps -a + +echo +echo "ACEPTACIÓN DE FASE D5.3.4: OK" diff --git a/scripts/phase-d5-3-5-1-acceptance.sh b/scripts/phase-d5-3-5-1-acceptance.sh new file mode 100755 index 0000000..176bbf9 --- /dev/null +++ b/scripts/phase-d5-3-5-1-acceptance.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT="${ROOT:-/var/www/dhv2.korexlabs.com}" +cd "$ROOT" +API_URL="${API_URL:-http://127.0.0.1:3101/api/v3}" +DB_SERVICE="${DB_SERVICE:-db}" + +psql_app() { + docker compose exec -T "$DB_SERVICE" sh -lc \ + 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -At -v ON_ERROR_STOP=1 -v app_user="$APP_DB_USER"' <<<"$1" +} + +echo "========== D5.3.5.1 · VERSIONES ==========" +API_VERSION="$(node -p "require('./api-v3/package.json').version")" +WEB_VERSION="$(node -p "require('./web-v2/package.json').version")" +[[ "$API_VERSION" == "0.19.3-5.1" ]] +[[ "$WEB_VERSION" == "0.19.3-5.1" ]] +echo "API: $API_VERSION" +echo "WEB: $WEB_VERSION" + +echo +echo "========== D5.3.5.1 · MIGRACIONES ==========" +MIGRATION_OUTPUT="$(docker compose --profile tools run --rm migrate npm run migration:show 2>&1)" +echo "$MIGRATION_OUTPUT" +grep -q "Pending migrations: no" <<<"$MIGRATION_OUTPUT" + +echo +echo "========== D5.3.5.1 · HEALTH ==========" +HEALTH="$(curl -fsS "$API_URL/health")" +echo "$HEALTH" +grep -q '"version":"0.19.3-5.1"' <<<"$HEALTH" +grep -q '"phase":"D5.3.5.1"' <<<"$HEALTH" + +echo +echo "========== D5.3.5.1 · RUTAS PROTEGIDAS ==========" +CODE="$(curl -sS -o /tmp/dhv2-d535-imports.txt -w '%{http_code}' "$API_URL/asset-imports")" +[[ "$CODE" == "401" ]] +echo "/asset-imports sin sesión: 401 OK" +CODE="$(curl -sS -o /tmp/dhv2-d535-tree.txt -w '%{http_code}' "$API_URL/assets/tree-children")" +[[ "$CODE" == "401" ]] +echo "/assets/tree-children sin sesión: 401 OK" + +echo +echo "========== D5.3.5.1 · MODELO DE IMPORTACIÓN ==========" +grep -q 'export interface ImportBatchRow' api-v3/src/asset-imports/asset-imports.service.ts +echo "Contrato público ImportBatchRow: OK" + +[[ "$(psql_app "SELECT COUNT(*) FROM information_schema.tables WHERE table_name IN ('asset_import_batches','asset_import_rows');")" == "2" ]] +[[ "$(psql_app "SELECT COUNT(*) FROM pg_type WHERE typname IN ('asset_import_batch_status','asset_import_row_status','asset_import_suggested_action');")" == "3" ]] +[[ "$(psql_app "SELECT COUNT(*) FROM permissions WHERE code IN ('asset_imports.read','asset_imports.manage');")" == "2" ]] +[[ "$(psql_app "SELECT COUNT(*) FROM pg_indexes WHERE indexname IN ('idx_assets_code_trgm','idx_assets_name_trgm','idx_asset_external_identifiers_value_trgm','idx_asset_attribute_values_text_trgm');")" == "4" ]] +[[ "$(psql_app "SELECT COUNT(*) FROM pg_extension WHERE extname='pg_trgm';")" == "1" ]] +[[ "$(psql_app "SELECT has_table_privilege(:'app_user','asset_import_batches','SELECT') AND has_table_privilege(:'app_user','asset_import_batches','INSERT') AND has_table_privilege(:'app_user','asset_import_batches','UPDATE');")" == "t" ]] +[[ "$(psql_app "SELECT has_table_privilege(:'app_user','asset_import_batches','DELETE');")" == "f" ]] +[[ "$(psql_app "SELECT COUNT(*) FROM typeorm_migrations WHERE name='PhaseD535ImportCenterScalability1787594400000';")" == "1" ]] +echo "Staging, permisos, pg_trgm y protección contra DELETE: OK" + +echo +echo "========== D5.3.5.1 · PARSER XLSX ==========" +docker compose exec -T api sh -lc 'command -v unzip >/dev/null && unzip -v >/dev/null' +grep -q 'MAX_ASSET_IMPORT_BYTES' api-v3/src/asset-imports/asset-import-parser.ts +grep -q 'MENDOZA_INVENTORY_V1' api-v3/src/asset-imports/asset-import-profiles.ts +grep -q 'MENDOZA_YACIMIENTOS_V1' api-v3/src/asset-imports/asset-import-profiles.ts +grep -q 'INVENTORY_ID_MULTIPLE_LOCATIONS' api-v3/src/asset-imports/asset-import-profiles.ts +echo "Parser XLSX/CSV, perfiles y conflictos: OK" + +echo +echo "========== D5.3.5.1 · NO MODIFICA EL MAESTRO ==========" +grep -q 'INSERT INTO asset_import_batches' api-v3/src/asset-imports/asset-imports.service.ts +! grep -Eq 'INSERT INTO assets[[:space:]]*\\(' api-v3/src/asset-imports/asset-imports.service.ts +! grep -Eq 'UPDATE assets[[:space:]]+SET' api-v3/src/asset-imports/asset-imports.service.ts +echo "El Centro de Importaciones sólo analiza/stagea: OK" + +echo +echo "========== D5.3.5.1 · ASSET CENTER ESCALABLE ==========" +grep -q "@Get('tree-children')" api-v3/src/asset-master/assets.controller.ts +grep -q 'listAssetTreeChildren' web-v2/src/features/assets/AssetHierarchyView.tsx +! grep -q 'listAssetTree(' web-v2/src/features/assets/AssetHierarchyView.tsx +grep -q 'Los hijos se cargan recién al expandir' web-v2/src/features/assets/AssetHierarchyView.tsx +echo "Jerarquía lazy-loading: OK" + +echo +echo "========== D5.3.5.1 · DASHBOARD ==========" +grep -q 'Centro de importaciones' web-v2/src/pages/AssetImportsPage.tsx +grep -q 'No crea ni modifica activos' web-v2/src/pages/AssetImportsPage.tsx +grep -q "path=\"/importaciones\"" web-v2/src/app/App.tsx +grep -q "label: 'Importaciones'" web-v2/src/layout/AppLayout.tsx +grep -q '0.19.3-5.1' web-v2/src/config/version.ts +echo "Centro de Importaciones y navegación: OK" + +echo +echo "========== D5.3.5.1 · CONTENEDORES ==========" +docker compose ps -a + +echo +echo "ACEPTACIÓN DE FASE D5.3.5.1: OK" diff --git a/scripts/phase-d5-3-5-acceptance.sh b/scripts/phase-d5-3-5-acceptance.sh new file mode 100755 index 0000000..a99bf15 --- /dev/null +++ b/scripts/phase-d5-3-5-acceptance.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT="${ROOT:-/var/www/dhv2.korexlabs.com}" +cd "$ROOT" +API_URL="${API_URL:-http://127.0.0.1:3101/api/v3}" +DB_SERVICE="${DB_SERVICE:-db}" + +psql_app() { + docker compose exec -T "$DB_SERVICE" sh -lc \ + 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -At -v ON_ERROR_STOP=1 -v app_user="$APP_DB_USER"' <<<"$1" +} + +echo "========== D5.3.5 · VERSIONES ==========" +API_VERSION="$(node -p "require('./api-v3/package.json').version")" +WEB_VERSION="$(node -p "require('./web-v2/package.json').version")" +[[ "$API_VERSION" == "0.19.3-5" ]] +[[ "$WEB_VERSION" == "0.19.3-5" ]] +echo "API: $API_VERSION" +echo "WEB: $WEB_VERSION" + +echo +echo "========== D5.3.5 · MIGRACIONES ==========" +MIGRATION_OUTPUT="$(docker compose --profile tools run --rm migrate npm run migration:show 2>&1)" +echo "$MIGRATION_OUTPUT" +grep -q "Pending migrations: no" <<<"$MIGRATION_OUTPUT" + +echo +echo "========== D5.3.5 · HEALTH ==========" +HEALTH="$(curl -fsS "$API_URL/health")" +echo "$HEALTH" +grep -q '"version":"0.19.3-5"' <<<"$HEALTH" +grep -q '"phase":"D5.3.5"' <<<"$HEALTH" + +echo +echo "========== D5.3.5 · RUTAS PROTEGIDAS ==========" +CODE="$(curl -sS -o /tmp/dhv2-d535-imports.txt -w '%{http_code}' "$API_URL/asset-imports")" +[[ "$CODE" == "401" ]] +echo "/asset-imports sin sesión: 401 OK" +CODE="$(curl -sS -o /tmp/dhv2-d535-tree.txt -w '%{http_code}' "$API_URL/assets/tree-children")" +[[ "$CODE" == "401" ]] +echo "/assets/tree-children sin sesión: 401 OK" + +echo +echo "========== D5.3.5 · MODELO DE IMPORTACIÓN ==========" +[[ "$(psql_app "SELECT COUNT(*) FROM information_schema.tables WHERE table_name IN ('asset_import_batches','asset_import_rows');")" == "2" ]] +[[ "$(psql_app "SELECT COUNT(*) FROM pg_type WHERE typname IN ('asset_import_batch_status','asset_import_row_status','asset_import_suggested_action');")" == "3" ]] +[[ "$(psql_app "SELECT COUNT(*) FROM permissions WHERE code IN ('asset_imports.read','asset_imports.manage');")" == "2" ]] +[[ "$(psql_app "SELECT COUNT(*) FROM pg_indexes WHERE indexname IN ('idx_assets_code_trgm','idx_assets_name_trgm','idx_asset_external_identifiers_value_trgm','idx_asset_attribute_values_text_trgm');")" == "4" ]] +[[ "$(psql_app "SELECT COUNT(*) FROM pg_extension WHERE extname='pg_trgm';")" == "1" ]] +[[ "$(psql_app "SELECT has_table_privilege(:'app_user','asset_import_batches','SELECT') AND has_table_privilege(:'app_user','asset_import_batches','INSERT') AND has_table_privilege(:'app_user','asset_import_batches','UPDATE');")" == "t" ]] +[[ "$(psql_app "SELECT has_table_privilege(:'app_user','asset_import_batches','DELETE');")" == "f" ]] +[[ "$(psql_app "SELECT COUNT(*) FROM typeorm_migrations WHERE name='PhaseD535ImportCenterScalability1787594400000';")" == "1" ]] +echo "Staging, permisos, pg_trgm y protección contra DELETE: OK" + +echo +echo "========== D5.3.5 · PARSER XLSX ==========" +docker compose exec -T api sh -lc 'command -v unzip >/dev/null && unzip -v >/dev/null' +grep -q 'MAX_ASSET_IMPORT_BYTES' api-v3/src/asset-imports/asset-import-parser.ts +grep -q 'MENDOZA_INVENTORY_V1' api-v3/src/asset-imports/asset-import-profiles.ts +grep -q 'MENDOZA_YACIMIENTOS_V1' api-v3/src/asset-imports/asset-import-profiles.ts +grep -q 'INVENTORY_ID_MULTIPLE_LOCATIONS' api-v3/src/asset-imports/asset-import-profiles.ts +echo "Parser XLSX/CSV, perfiles y conflictos: OK" + +echo +echo "========== D5.3.5 · NO MODIFICA EL MAESTRO ==========" +grep -q 'INSERT INTO asset_import_batches' api-v3/src/asset-imports/asset-imports.service.ts +! grep -Eq 'INSERT INTO assets[[:space:]]*\\(' api-v3/src/asset-imports/asset-imports.service.ts +! grep -Eq 'UPDATE assets[[:space:]]+SET' api-v3/src/asset-imports/asset-imports.service.ts +echo "El Centro de Importaciones sólo analiza/stagea: OK" + +echo +echo "========== D5.3.5 · ASSET CENTER ESCALABLE ==========" +grep -q "@Get('tree-children')" api-v3/src/asset-master/assets.controller.ts +grep -q 'listAssetTreeChildren' web-v2/src/features/assets/AssetHierarchyView.tsx +! grep -q 'listAssetTree(' web-v2/src/features/assets/AssetHierarchyView.tsx +grep -q 'Los hijos se cargan recién al expandir' web-v2/src/features/assets/AssetHierarchyView.tsx +echo "Jerarquía lazy-loading: OK" + +echo +echo "========== D5.3.5 · DASHBOARD ==========" +grep -q 'Centro de importaciones' web-v2/src/pages/AssetImportsPage.tsx +grep -q 'No crea ni modifica activos' web-v2/src/pages/AssetImportsPage.tsx +grep -q "path=\"/importaciones\"" web-v2/src/app/App.tsx +grep -q "label: 'Importaciones'" web-v2/src/layout/AppLayout.tsx +grep -q '0.19.3-5' web-v2/src/config/version.ts +echo "Centro de Importaciones y navegación: OK" + +echo +echo "========== D5.3.5 · CONTENEDORES ==========" +docker compose ps -a + +echo +echo "ACEPTACIÓN DE FASE D5.3.5: OK" diff --git a/scripts/phase-d5-3-6-3-acceptance.sh b/scripts/phase-d5-3-6-3-acceptance.sh new file mode 100755 index 0000000..dcabadc --- /dev/null +++ b/scripts/phase-d5-3-6-3-acceptance.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +ROOT="${ROOT:-/var/www/dhv2.korexlabs.com}" +cd "$ROOT" +API_URL="${API_URL:-http://127.0.0.1:3101/api/v3}" + +echo "========== D5.3.6.3 · VERSIONES ==========" +[[ "$(node -p "require('./api-v3/package.json').version")" == "0.19.3-6.3" ]] +[[ "$(node -p "require('./web-v2/package.json').version")" == "0.19.3-6.3" ]] +echo "API: 0.19.3-6.3" +echo "WEB: 0.19.3-6.3" + +echo +echo "========== D5.3.6.3 · MIGRACIONES ==========" +MIGRATION_OUTPUT="$(docker compose --profile tools run --rm migrate npm run migration:show 2>&1)" +echo "$MIGRATION_OUTPUT" +grep -Fq 'Pending migrations: no' <<<"$MIGRATION_OUTPUT" + +echo +echo "========== D5.3.6.3 · HEALTH ==========" +HEALTH="$(curl -fsS "$API_URL/health")" +echo "$HEALTH" +grep -Fq '"version":"0.19.3-6.3"' <<<"$HEALTH" +grep -Fq '"phase":"D5.3.6.3"' <<<"$HEALTH" + +echo +echo "========== D5.3.6.3 · RUTAS PROTEGIDAS ==========" +CODE="$(curl -sS -o /tmp/dhv2-d5363-reconcile.txt -w '%{http_code}' -X POST "$API_URL/asset-imports/00000000-0000-4000-8000-000000000000/reconcile")" +[[ "$CODE" == "401" ]] +echo "/asset-imports/:id/reconcile sin sesión: 401 OK" + +echo +echo "========== D5.3.6.3 · NORMALIZACIÓN ==========" +grep -Fq 'technicalNormalizationSuggestion' api-v3/src/asset-imports/asset-import-profiles.ts +grep -Fq 'normalizedFamily' api-v3/src/asset-imports/asset-import-profiles.ts +grep -Fq 'normalizedSubtype' api-v3/src/asset-imports/asset-import-profiles.ts +grep -Fq 'conditionStatusSuggestion' api-v3/src/asset-imports/asset-import-profiles.ts +echo "Familia/subtipo y estados separados: OK" + +echo +echo "========== D5.3.6.3 · MATCHING CONSERVADOR ==========" +grep -Fq 'INVENTORY_ID_OR_CODE_EXACT' api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq 'AREA_FIELD_EXACT' api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq 'ORGANIZATION_NAME_EXACT' api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq 'MATCH_MULTIPLE_ASSETS' api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq 'masterModified: false' api-v3/src/asset-imports/asset-imports.service.ts +if grep -Eq 'INSERT INTO assets[[:space:]]*\(' api-v3/src/asset-imports/asset-imports.service.ts; then + echo "ERROR: D5.3.6 no debe insertar activos"; exit 1 +fi +if grep -Eq 'UPDATE assets[[:space:]]+SET' api-v3/src/asset-imports/asset-imports.service.ts; then + echo "ERROR: D5.3.6 no debe actualizar activos"; exit 1 +fi +echo "Conciliación sin escrituras al Maestro: OK" +grep -Fq 'afterData: { ...summary }' api-v3/src/asset-imports/asset-imports.service.ts +echo "Resumen de conciliación compatible con auditoría: OK" + +echo +echo "========== D5.3.6.3 · DASHBOARD ==========" +grep -Fq 'Conciliar con Maestro' web-v2/src/pages/AssetImportsPage.tsx +grep -Fq 'Boolean(detail.analysis?.reconciliation)' web-v2/src/pages/AssetImportsPage.tsx +grep -Fq 'Analizables' web-v2/src/pages/AssetImportsPage.tsx +grep -Fq "detail.profileCode === 'MENDOZA_YACIMIENTOS_V1'" web-v2/src/pages/AssetImportsPage.tsx +grep -Fq '1 · Análisis' web-v2/src/pages/AssetImportsPage.tsx +grep -Fq '4 · Importación' web-v2/src/pages/AssetImportsPage.tsx +echo "Preview por perfil y flujo por etapas: OK" + +echo +echo "========== D5.3.6.3 · CONTENEDORES ==========" +docker compose ps -a + +echo +echo "ACEPTACIÓN DE FASE D5.3.6.3: OK" diff --git a/scripts/phase-d5-3-6-acceptance.sh b/scripts/phase-d5-3-6-acceptance.sh new file mode 100755 index 0000000..8f94bbf --- /dev/null +++ b/scripts/phase-d5-3-6-acceptance.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +ROOT="${ROOT:-/var/www/dhv2.korexlabs.com}" +cd "$ROOT" +API_URL="${API_URL:-http://127.0.0.1:3101/api/v3}" + +echo "========== D5.3.6 · VERSIONES ==========" +[[ "$(node -p "require('./api-v3/package.json').version")" == "0.19.3-6" ]] +[[ "$(node -p "require('./web-v2/package.json').version")" == "0.19.3-6" ]] +echo "API: 0.19.3-6" +echo "WEB: 0.19.3-6" + +echo +echo "========== D5.3.6 · MIGRACIONES ==========" +MIGRATION_OUTPUT="$(docker compose --profile tools run --rm migrate npm run migration:show 2>&1)" +echo "$MIGRATION_OUTPUT" +grep -Fq 'Pending migrations: no' <<<"$MIGRATION_OUTPUT" + +echo +echo "========== D5.3.6 · HEALTH ==========" +HEALTH="$(curl -fsS "$API_URL/health")" +echo "$HEALTH" +grep -Fq '"version":"0.19.3-6"' <<<"$HEALTH" +grep -Fq '"phase":"D5.3.6"' <<<"$HEALTH" + +echo +echo "========== D5.3.6 · RUTAS PROTEGIDAS ==========" +CODE="$(curl -sS -o /tmp/dhv2-d536-reconcile.txt -w '%{http_code}' -X POST "$API_URL/asset-imports/00000000-0000-4000-8000-000000000000/reconcile")" +[[ "$CODE" == "401" ]] +echo "/asset-imports/:id/reconcile sin sesión: 401 OK" + +echo +echo "========== D5.3.6 · NORMALIZACIÓN ==========" +grep -Fq 'technicalNormalizationSuggestion' api-v3/src/asset-imports/asset-import-profiles.ts +grep -Fq 'normalizedFamily' api-v3/src/asset-imports/asset-import-profiles.ts +grep -Fq 'normalizedSubtype' api-v3/src/asset-imports/asset-import-profiles.ts +grep -Fq 'conditionStatusSuggestion' api-v3/src/asset-imports/asset-import-profiles.ts +echo "Familia/subtipo y estados separados: OK" + +echo +echo "========== D5.3.6 · MATCHING CONSERVADOR ==========" +grep -Fq 'INVENTORY_ID_OR_CODE_EXACT' api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq 'AREA_FIELD_EXACT' api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq 'ORGANIZATION_NAME_EXACT' api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq 'MATCH_MULTIPLE_ASSETS' api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq 'masterModified: false' api-v3/src/asset-imports/asset-imports.service.ts +if grep -Eq 'INSERT INTO assets[[:space:]]*\(' api-v3/src/asset-imports/asset-imports.service.ts; then + echo "ERROR: D5.3.6 no debe insertar activos"; exit 1 +fi +if grep -Eq 'UPDATE assets[[:space:]]+SET' api-v3/src/asset-imports/asset-imports.service.ts; then + echo "ERROR: D5.3.6 no debe actualizar activos"; exit 1 +fi +echo "Conciliación sin escrituras al Maestro: OK" + +echo +echo "========== D5.3.6 · DASHBOARD ==========" +grep -Fq 'Conciliar con Maestro' web-v2/src/pages/AssetImportsPage.tsx +grep -Fq 'Analizables' web-v2/src/pages/AssetImportsPage.tsx +grep -Fq "detail.profileCode === 'MENDOZA_YACIMIENTOS_V1'" web-v2/src/pages/AssetImportsPage.tsx +grep -Fq '1 · Análisis' web-v2/src/pages/AssetImportsPage.tsx +grep -Fq '4 · Importación' web-v2/src/pages/AssetImportsPage.tsx +echo "Preview por perfil y flujo por etapas: OK" + +echo +echo "========== D5.3.6 · CONTENEDORES ==========" +docker compose ps -a + +echo +echo "ACEPTACIÓN DE FASE D5.3.6: OK" diff --git a/scripts/phase-d5-3-7-1-acceptance.sh b/scripts/phase-d5-3-7-1-acceptance.sh new file mode 100755 index 0000000..09234d9 --- /dev/null +++ b/scripts/phase-d5-3-7-1-acceptance.sh @@ -0,0 +1,136 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT="${ROOT:-/var/www/dhv2.korexlabs.com}" +API_URL="${API_URL:-http://127.0.0.1:3101/api/v3}" +DUMMY_UUID="00000000-0000-4000-8000-000000000000" +TMP_MIGRATION="/tmp/dhv2-d537-accept-migration-$$.log" +trap 'rm -f "$TMP_MIGRATION"' EXIT +cd "$ROOT" + +echo "========== D5.3.7.1 · VERSIONES ==========" +[[ "$(node -p "require('./api-v3/package.json').version")" == "0.19.3-7.1" ]] +[[ "$(node -p "require('./web-v2/package.json').version")" == "0.19.3-7.1" ]] +grep -Fq "API_PHASE = 'D5.3.7.1'" api-v3/src/version.ts +echo "API: 0.19.3-7.1" +echo "WEB: 0.19.3-7.1" + +echo +echo "========== D5.3.7.1 · DISCO Y DB ==========" +FREE_KB="$(df -Pk "$ROOT" | awk 'NR==2 {print $4}')" +(( FREE_KB >= 2 * 1024 * 1024 )) +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}')" == "healthy" ]] +docker compose exec -T db sh -lc 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' +echo "Libre: $(( FREE_KB / 1024 / 1024 )) GB · PostgreSQL healthy" + +echo +echo "========== D5.3.7.1 · MIGRACIONES ==========" +docker compose --profile tools run --rm migrate npm run migration:show 2>&1 | tee "$TMP_MIGRATION" +grep -Fq 'Pending migrations: no' "$TMP_MIGRATION" + +echo +echo "========== D5.3.7.1 · HEALTH ==========" +HEALTH="$(curl -fsS "$API_URL/health")" +echo "$HEALTH" +grep -Fq '"version":"0.19.3-7.1"' <<<"$HEALTH" +grep -Fq '"phase":"D5.3.7.1"' <<<"$HEALTH" +grep -Fq '"database":"ok"' <<<"$HEALTH" + +echo +echo "========== D5.3.7.1 · RUTAS PROTEGIDAS ==========" +CODE="$(curl -sS -o /tmp/dhv2-d537-orgs-$$.txt -w '%{http_code}' "$API_URL/asset-imports/context/organizations")" +[[ "$CODE" == "401" ]] +CODE="$(curl -sS -o /tmp/dhv2-d537-plan-$$.txt -w '%{http_code}' "$API_URL/asset-imports/$DUMMY_UUID/plan")" +[[ "$CODE" == "401" ]] +CODE="$(curl -sS -o /tmp/dhv2-d537-apply-$$.txt -w '%{http_code}' -X POST -H 'Content-Type: application/json' -d '{"planHash":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}' "$API_URL/asset-imports/$DUMMY_UUID/apply")" +[[ "$CODE" == "401" ]] +rm -f /tmp/dhv2-d537-orgs-$$.txt /tmp/dhv2-d537-plan-$$.txt /tmp/dhv2-d537-apply-$$.txt +echo "Contexto, plan y aplicación requieren sesión: 401 OK" + +echo +echo "========== D5.3.7.1 · MODELO Y PERMISOS ==========" +DB_CHECK="$( + cat <<'SQL' | docker compose exec -T db sh -c 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -At -v ON_ERROR_STOP=1 -v app_user="$APP_DB_USER"' +SELECT CASE WHEN to_regclass('public.asset_import_plans') IS NOT NULL AND to_regclass('public.asset_import_plan_items') IS NOT NULL THEN 'TABLES_OK' ELSE 'TABLES_BAD' END; +SELECT CASE WHEN EXISTS (SELECT 1 FROM permissions WHERE code='asset_imports.apply') THEN 'PERMISSION_OK' ELSE 'PERMISSION_BAD' END; +SELECT CASE WHEN NOT EXISTS ( + SELECT role.code FROM roles role + WHERE role.code IN ('admin','director') + AND NOT EXISTS ( + SELECT 1 FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id + WHERE rp.role_id=role.id AND p.code='asset_imports.apply' + ) +) THEN 'ROLE_GRANTS_OK' ELSE 'ROLE_GRANTS_BAD' END; +SELECT CASE WHEN + has_table_privilege(:'app_user','asset_import_plans','SELECT') AND + has_table_privilege(:'app_user','asset_import_plans','INSERT') AND + has_table_privilege(:'app_user','asset_import_plans','UPDATE') AND + NOT has_table_privilege(:'app_user','asset_import_plans','DELETE') AND + has_table_privilege(:'app_user','asset_import_plan_items','SELECT') AND + has_table_privilege(:'app_user','asset_import_plan_items','INSERT') AND + has_table_privilege(:'app_user','asset_import_plan_items','UPDATE') AND + NOT has_table_privilege(:'app_user','asset_import_plan_items','DELETE') +THEN 'APP_PRIVILEGES_OK' ELSE 'APP_PRIVILEGES_BAD' END; +SELECT CASE WHEN EXISTS (SELECT 1 FROM typeorm_migrations WHERE name='PhaseD537ImportPlanningApplication1787680800000') THEN 'MIGRATION_OK' ELSE 'MIGRATION_BAD' END; +SQL +)" +echo "$DB_CHECK" +grep -Fq 'TABLES_OK' <<<"$DB_CHECK" +grep -Fq 'PERMISSION_OK' <<<"$DB_CHECK" +grep -Fq 'ROLE_GRANTS_OK' <<<"$DB_CHECK" +grep -Fq 'APP_PRIVILEGES_OK' <<<"$DB_CHECK" +grep -Fq 'MIGRATION_OK' <<<"$DB_CHECK" + +echo +echo "========== D5.3.7.1 · PLAN SEGURO ==========" +grep -Fq 'export interface PlanItemRow' api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq "status === 'CONFLICT' || status === 'WARNING'" api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq 'PLAN_SOURCE_GROUP_REQUIRES_DECISION' api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq "organizationKind:/^UTE\\b/i" api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq "operationalAreaEntityKey:explicitlyUnassigned?null:areaItem.entityKey" api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq "const operatorUnassigned = isExplicitlyUnassignedOperator(operator)" api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq "operatorUnassigned ? '' : plainImportKey(operator)" api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq 'generatedImportCode(plan.id, item.assetTypeCode, item.entityKey)' api-v3/src/asset-imports/asset-imports.service.ts +echo "Warnings bloqueantes, entidades únicas, UTE, operador no asignado y jerarquía controlada: OK" + +echo +echo "========== D5.3.7.1 · APLICACIÓN TRANSACCIONAL ==========" +grep -Fq 'async applyPlan' api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq 'this.dataSource.transaction' api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq 'ASSET_IMPORT_PLAN_STALE' api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq 'ASSET_IMPORT_PARENT_RULE_INVALID' api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq 'ASSET_IMPORT_OPERATIONAL_CONTEXT_INCOMPLETE' api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq 'const areaKey=String(item.payload.operationalAreaEntityKey' api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq 'PLAN_MATCH_OPERATIONAL_CONTEXT_MISMATCH' api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq 'revalidateMatchedOperationalContext' api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq "permission: 'asset_imports.apply'" web-v2/src 2>/dev/null || grep -Fq "asset_imports.apply" web-v2/src/pages/AssetImportsPage.tsx +echo "Hash, transacción, jerarquía, contexto operativo y permiso de aplicación: OK" + +echo +echo "========== D5.3.7.1 · REVERSIÓN LÓGICA ==========" +grep -Fq "information_status='INACTIVE'" api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq 'imported_asset_id=NULL' api-v3/src/asset-imports/asset-imports.service.ts +if grep -Fq 'DELETE FROM assets' api-v3/src/asset-imports/asset-imports.service.ts; then + echo "ERROR: la reversión no debe borrar assets"; exit 1 +fi +echo "Rollback lógico, trazable y sin DELETE de activos: OK" + +echo +echo "========== D5.3.7.1 · FRONTEND ==========" +grep -Fq '4 · Plan' web-v2/src/pages/AssetImportsPage.tsx +grep -Fq '5 · Confirmación' web-v2/src/pages/AssetImportsPage.tsx +grep -Fq '6 · Aplicación' web-v2/src/pages/AssetImportsPage.tsx +grep -Fq 'APLICAR' web-v2/src/pages/AssetImportsPage.tsx +grep -Fq 'operatorAssetId' web-v2/src/pages/AssetImportsPage.tsx +grep -Fq 'Revertir lote' web-v2/src/pages/AssetImportsPage.tsx +echo "Plan, confirmación, operador obligatorio y reversión visibles: OK" + +echo +echo "========== D5.3.7.1 · CONTENEDORES ==========" +docker compose ps -a +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}')" == "healthy" ]] +[[ "$(docker inspect dhv2-api --format '{{.State.Status}}')" == "running" ]] +[[ "$(docker inspect dhv2-web --format '{{.State.Status}}')" == "running" ]] + +echo +echo "ACEPTACIÓN DE FASE D5.3.7.1: OK" diff --git a/scripts/phase-d5-3-7-2-1-acceptance.sh b/scripts/phase-d5-3-7-2-1-acceptance.sh new file mode 100755 index 0000000..19f84ed --- /dev/null +++ b/scripts/phase-d5-3-7-2-1-acceptance.sh @@ -0,0 +1,126 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT="${ROOT:-/var/www/dhv2.korexlabs.com}" +API_URL="${API_URL:-http://127.0.0.1:3101/api/v3}" +DUMMY_UUID="00000000-0000-4000-8000-000000000000" +TMP_MIGRATION="/tmp/dhv2-d53721-accept-migration-$$.log" +TMP_ROUTE="/tmp/dhv2-d5372-route-$$.txt" +trap 'rm -f "$TMP_MIGRATION" "$TMP_ROUTE"' EXIT +cd "$ROOT" + +echo "========== D5.3.7.2.1 · VERSIONES ==========" +[[ "$(node -p "require('./api-v3/package.json').version")" == "0.19.3-7.2.1" ]] +[[ "$(node -p "require('./web-v2/package.json').version")" == "0.19.3-7.2.1" ]] +grep -Fq "API_PHASE = 'D5.3.7.2.1'" api-v3/src/version.ts +echo "API: 0.19.3-7.2.1" +echo "WEB: 0.19.3-7.2.1" + +echo +echo "========== D5.3.7.2.1 · DISCO Y DB ==========" +FREE_KB="$(df -Pk "$ROOT" | awk 'NR==2 {print $4}')" +(( FREE_KB >= 2 * 1024 * 1024 )) +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}')" == "healthy" ]] +docker compose exec -T db sh -lc 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' +echo "Libre: $(( FREE_KB / 1024 / 1024 )) GB · PostgreSQL healthy" + +echo +echo "========== D5.3.7.2.1 · MIGRACIONES ==========" +docker compose --profile tools run --rm migrate npm run migration:show 2>&1 | tee "$TMP_MIGRATION" +grep -Fq 'Pending migrations: no' "$TMP_MIGRATION" + +echo +echo "========== D5.3.7.2.1 · HEALTH ==========" +HEALTH="$(curl -fsS "$API_URL/health")" +echo "$HEALTH" +grep -Fq '"version":"0.19.3-7.2.1"' <<<"$HEALTH" +grep -Fq '"phase":"D5.3.7.2.1"' <<<"$HEALTH" +grep -Fq '"database":"ok"' <<<"$HEALTH" + +echo +echo "========== D5.3.7.2.1 · RUTA DE DETALLE PROTEGIDA ==========" +CODE="$(curl -sS -o "$TMP_ROUTE" -w '%{http_code}' "$API_URL/asset-imports/$DUMMY_UUID/plan/items")" +[[ "$CODE" == "401" ]] +echo "/asset-imports/:id/plan/items sin sesión: 401 OK" + +echo +echo "========== D5.3.7.2.1 · MODELO RELACIONAL ==========" +DB_CHECK="$( + cat <<'SQL' | docker compose exec -T db sh -c 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -At -v ON_ERROR_STOP=1 -v app_user="$APP_DB_USER"' +SELECT CASE WHEN to_regclass('public.administrative_departments') IS NOT NULL AND to_regclass('public.area_department_relations') IS NOT NULL THEN 'RELATIONAL_TABLES_OK' ELSE 'RELATIONAL_TABLES_BAD' END; +SELECT CASE WHEN + has_table_privilege(:'app_user','administrative_departments','SELECT') AND + has_table_privilege(:'app_user','administrative_departments','INSERT') AND + has_table_privilege(:'app_user','administrative_departments','UPDATE') AND + NOT has_table_privilege(:'app_user','administrative_departments','DELETE') AND + has_table_privilege(:'app_user','area_department_relations','SELECT') AND + has_table_privilege(:'app_user','area_department_relations','INSERT') AND + has_table_privilege(:'app_user','area_department_relations','UPDATE') AND + NOT has_table_privilege(:'app_user','area_department_relations','DELETE') +THEN 'RELATIONAL_PRIVILEGES_OK' ELSE 'RELATIONAL_PRIVILEGES_BAD' END; +SELECT CASE WHEN EXISTS (SELECT 1 FROM typeorm_migrations WHERE name='PhaseD5372RelationalTerritoryPlan1787767200000') THEN 'MIGRATION_OK' ELSE 'MIGRATION_BAD' END; +SELECT CASE WHEN EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname='chk_asset_import_plan_item_kind' + AND pg_get_constraintdef(oid) LIKE '%DEPARTMENT%' + AND pg_get_constraintdef(oid) LIKE '%AREA_DEPARTMENT_RELATION%' + AND pg_get_constraintdef(oid) LIKE '%LEGAL_RIGHT%' + AND pg_get_constraintdef(oid) LIKE '%LEGAL_RIGHT_ORGANIZATION%' +) THEN 'PLAN_KINDS_OK' ELSE 'PLAN_KINDS_BAD' END; +SQL +)" +echo "$DB_CHECK" +grep -Fq 'RELATIONAL_TABLES_OK' <<<"$DB_CHECK" +grep -Fq 'RELATIONAL_PRIVILEGES_OK' <<<"$DB_CHECK" +grep -Fq 'MIGRATION_OK' <<<"$DB_CHECK" +grep -Fq 'PLAN_KINDS_OK' <<<"$DB_CHECK" + +echo +echo "========== D5.3.7.2.1 · NORMALIZACIÓN Y DEDUPLICACIÓN ==========" +grep -Fq 'organizationImportKey' api-v3/src/asset-imports/asset-import-plan.ts +grep -Fq 'normalizedLegalRightType' api-v3/src/asset-imports/asset-import-plan.ts +grep -Fq "entityKind: 'DEPARTMENT'" api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq "entityKind:'AREA_DEPARTMENT_RELATION'" api-v3/src/asset-imports/asset-imports.service.ts || grep -Fq "entityKind: 'AREA_DEPARTMENT_RELATION'" api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq "entityKind:'LEGAL_RIGHT'" api-v3/src/asset-imports/asset-imports.service.ts || grep -Fq "entityKind: 'LEGAL_RIGHT'" api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq "entityKind:'LEGAL_RIGHT_ORGANIZATION'" api-v3/src/asset-imports/asset-imports.service.ts || grep -Fq "entityKind: 'LEGAL_RIGHT_ORGANIZATION'" api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq "operatorUnassigned ? '' : organizationImportKey(operator)" api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq "organizationImportKey\(operator\)" api-v3/test/unit/asset-imports-d5-3-7.test.ts || grep -Fq "organizationImportKey(operator)" api-v3/test/unit/asset-imports-d5-3-7.test.ts +if grep -Fq "add('departamento'" api-v3/src/asset-imports/asset-imports.service.ts; then + echo "ERROR: Departamento no debe persistirse como atributo textual duplicado"; exit 1 +fi +grep -Fq "status:'PENDING'" api-v3/src/asset-imports/asset-imports.service.ts || grep -Fq "status: 'PENDING'" api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq 'no se infiere titularidad' api-v3/src/asset-imports/asset-imports.service.ts +echo "Departamentos únicos, siglas de organizaciones, derechos normalizados y relaciones explícitas: OK" + +echo +echo "========== D5.3.7.2.1 · APLICACIÓN Y ROLLBACK ==========" +grep -Fq 'INSERT INTO administrative_departments' api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq 'INSERT INTO area_department_relations' api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq 'INSERT INTO area_legal_rights' api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq 'INSERT INTO area_legal_right_organizations' api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq "area_department_relations SET valid_until=CURRENT_DATE" api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq "area_legal_rights SET status='REVOKED'" api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq "administrative_departments SET is_active=false" api-v3/src/asset-imports/asset-imports.service.ts +if grep -Eq 'DELETE FROM (assets|administrative_departments|area_department_relations|area_legal_rights)' api-v3/src/asset-imports/asset-imports.service.ts; then + echo "ERROR: rollback relacional no debe hacer DELETE físico"; exit 1 +fi +echo "Aplicación transaccional y reversión lógica relacional: OK" + +echo +echo "========== D5.3.7.2.1 · FRONTEND ==========" +grep -Fq 'Plan relacional por entidad' web-v2/src/pages/AssetImportsPage.tsx +grep -Fq 'Ver detalle' web-v2/src/pages/AssetImportsPage.tsx +grep -Fq 'Departamentos' web-v2/src/pages/AssetImportsPage.tsx +grep -Fq 'Marco jurídico' web-v2/src/pages/AssetImportsPage.tsx +grep -Fq 'listAssetImportPlanItems' web-v2/src/lib/api.ts +echo "Desglose por tipo y detalle paginado: OK" + +echo +echo "========== D5.3.7.2.1 · CONTENEDORES ==========" +docker compose ps -a +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}')" == "healthy" ]] +[[ "$(docker inspect dhv2-api --format '{{.State.Status}}')" == "running" ]] +[[ "$(docker inspect dhv2-web --format '{{.State.Status}}')" == "running" ]] + +echo +echo "ACEPTACIÓN DE FASE D5.3.7.2.1: OK" diff --git a/scripts/phase-d5-3-7-2-2-acceptance.sh b/scripts/phase-d5-3-7-2-2-acceptance.sh new file mode 100755 index 0000000..e81f75e --- /dev/null +++ b/scripts/phase-d5-3-7-2-2-acceptance.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT="${ROOT:-/var/www/dhv2.korexlabs.com}" +API_URL="${API_URL:-http://127.0.0.1:3101/api/v3}" +DUMMY_UUID="00000000-0000-4000-8000-000000000000" +TMP_MIGRATION="/tmp/dhv2-d53722-accept-migration-$$.log" +TMP_ROUTE="/tmp/dhv2-d5372-route-$$.txt" +trap 'rm -f "$TMP_MIGRATION" "$TMP_ROUTE"' EXIT +cd "$ROOT" + +echo "========== D5.3.7.2.2 · VERSIONES ==========" +[[ "$(node -p "require('./api-v3/package.json').version")" == "0.19.3-7.2.2" ]] +[[ "$(node -p "require('./web-v2/package.json').version")" == "0.19.3-7.2.2" ]] +grep -Fq "API_PHASE = 'D5.3.7.2.2'" api-v3/src/version.ts +echo "API: 0.19.3-7.2.2" +echo "WEB: 0.19.3-7.2.2" + +echo +echo "========== D5.3.7.2.2 · DISCO Y DB ==========" +FREE_KB="$(df -Pk "$ROOT" | awk 'NR==2 {print $4}')" +(( FREE_KB >= 2 * 1024 * 1024 )) +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}')" == "healthy" ]] +docker compose exec -T db sh -lc 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' +echo "Libre: $(( FREE_KB / 1024 / 1024 )) GB · PostgreSQL healthy" + +echo +echo "========== D5.3.7.2.2 · MIGRACIONES ==========" +docker compose --profile tools run --rm migrate npm run migration:show 2>&1 | tee "$TMP_MIGRATION" +grep -Fq 'Pending migrations: no' "$TMP_MIGRATION" + +echo +echo "========== D5.3.7.2.2 · HEALTH ==========" +HEALTH="$(curl -fsS "$API_URL/health")" +echo "$HEALTH" +grep -Fq '"version":"0.19.3-7.2.2"' <<<"$HEALTH" +grep -Fq '"phase":"D5.3.7.2.2"' <<<"$HEALTH" +grep -Fq '"database":"ok"' <<<"$HEALTH" + +echo +echo "========== D5.3.7.2.2 · RUTA DE DETALLE PROTEGIDA ==========" +CODE="$(curl -sS -o "$TMP_ROUTE" -w '%{http_code}' "$API_URL/asset-imports/$DUMMY_UUID/plan/items")" +[[ "$CODE" == "401" ]] +echo "/asset-imports/:id/plan/items sin sesión: 401 OK" + +echo +echo "========== D5.3.7.2.2 · MODELO RELACIONAL ==========" +DB_CHECK="$( + cat <<'SQL' | docker compose exec -T db sh -c 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -At -v ON_ERROR_STOP=1 -v app_user="$APP_DB_USER"' +SELECT CASE WHEN to_regclass('public.administrative_departments') IS NOT NULL AND to_regclass('public.area_department_relations') IS NOT NULL THEN 'RELATIONAL_TABLES_OK' ELSE 'RELATIONAL_TABLES_BAD' END; +SELECT CASE WHEN + has_table_privilege(:'app_user','administrative_departments','SELECT') AND + has_table_privilege(:'app_user','administrative_departments','INSERT') AND + has_table_privilege(:'app_user','administrative_departments','UPDATE') AND + NOT has_table_privilege(:'app_user','administrative_departments','DELETE') AND + has_table_privilege(:'app_user','area_department_relations','SELECT') AND + has_table_privilege(:'app_user','area_department_relations','INSERT') AND + has_table_privilege(:'app_user','area_department_relations','UPDATE') AND + NOT has_table_privilege(:'app_user','area_department_relations','DELETE') +THEN 'RELATIONAL_PRIVILEGES_OK' ELSE 'RELATIONAL_PRIVILEGES_BAD' END; +SELECT CASE WHEN EXISTS (SELECT 1 FROM typeorm_migrations WHERE name='PhaseD5372RelationalTerritoryPlan1787767200000') THEN 'MIGRATION_OK' ELSE 'MIGRATION_BAD' END; +SELECT CASE WHEN EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname='chk_asset_import_plan_item_kind' + AND pg_get_constraintdef(oid) LIKE '%DEPARTMENT%' + AND pg_get_constraintdef(oid) LIKE '%AREA_DEPARTMENT_RELATION%' + AND pg_get_constraintdef(oid) LIKE '%LEGAL_RIGHT%' + AND pg_get_constraintdef(oid) LIKE '%LEGAL_RIGHT_ORGANIZATION%' +) THEN 'PLAN_KINDS_OK' ELSE 'PLAN_KINDS_BAD' END; +SQL +)" +echo "$DB_CHECK" +grep -Fq 'RELATIONAL_TABLES_OK' <<<"$DB_CHECK" +grep -Fq 'RELATIONAL_PRIVILEGES_OK' <<<"$DB_CHECK" +grep -Fq 'MIGRATION_OK' <<<"$DB_CHECK" +grep -Fq 'PLAN_KINDS_OK' <<<"$DB_CHECK" + +echo +echo "========== D5.3.7.2.2 · NORMALIZACIÓN Y DEDUPLICACIÓN ==========" +grep -Fq 'organizationImportKey' api-v3/src/asset-imports/asset-import-plan.ts +grep -Fq 'normalizedLegalRightType' api-v3/src/asset-imports/asset-import-plan.ts +grep -Fq "entityKind: 'DEPARTMENT'" api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq "entityKind:'AREA_DEPARTMENT_RELATION'" api-v3/src/asset-imports/asset-imports.service.ts || grep -Fq "entityKind: 'AREA_DEPARTMENT_RELATION'" api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq "entityKind:'LEGAL_RIGHT'" api-v3/src/asset-imports/asset-imports.service.ts || grep -Fq "entityKind: 'LEGAL_RIGHT'" api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq "entityKind:'LEGAL_RIGHT_ORGANIZATION'" api-v3/src/asset-imports/asset-imports.service.ts || grep -Fq "entityKind: 'LEGAL_RIGHT_ORGANIZATION'" api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq "operatorUnassigned ? '' : organizationImportKey(operator)" api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq "organizationImportKey\(operator\)" api-v3/test/unit/asset-imports-d5-3-7.test.ts || grep -Fq "organizationImportKey(operator)" api-v3/test/unit/asset-imports-d5-3-7.test.ts +if grep -Fq "add('departamento'" api-v3/src/asset-imports/asset-imports.service.ts; then + echo "ERROR: Departamento no debe persistirse como atributo textual duplicado"; exit 1 +fi +grep -Fq "status:'PENDING'" api-v3/src/asset-imports/asset-imports.service.ts || grep -Fq "status: 'PENDING'" api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq 'no se infiere titularidad' api-v3/src/asset-imports/asset-imports.service.ts +echo "Departamentos únicos, siglas de organizaciones, derechos normalizados y relaciones explícitas: OK" + +echo +echo "========== D5.3.7.2.2 · APLICACIÓN Y ROLLBACK ==========" +grep -Fq 'INSERT INTO administrative_departments' api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq 'INSERT INTO area_department_relations' api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq 'INSERT INTO area_legal_rights' api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq 'INSERT INTO area_legal_right_organizations' api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq "area_department_relations SET valid_until=CURRENT_DATE" api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq "area_legal_rights SET status='REVOKED'" api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq "administrative_departments SET is_active=false" api-v3/src/asset-imports/asset-imports.service.ts +if grep -Eq 'DELETE FROM (assets|administrative_departments|area_department_relations|area_legal_rights)' api-v3/src/asset-imports/asset-imports.service.ts; then + echo "ERROR: rollback relacional no debe hacer DELETE físico"; exit 1 +fi +grep -Fq '::integer,$${base+2}::uuid' api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq 'row.row_number=v.row_number' api-v3/src/asset-imports/asset-imports.service.ts +echo "Aplicación transaccional, casteo row_number y reversión lógica relacional: OK" + +echo +echo "========== D5.3.7.2.2 · FRONTEND ==========" +grep -Fq 'Plan relacional por entidad' web-v2/src/pages/AssetImportsPage.tsx +grep -Fq 'Ver detalle' web-v2/src/pages/AssetImportsPage.tsx +grep -Fq 'Departamentos' web-v2/src/pages/AssetImportsPage.tsx +grep -Fq 'Marco jurídico' web-v2/src/pages/AssetImportsPage.tsx +grep -Fq 'listAssetImportPlanItems' web-v2/src/lib/api.ts +echo "Desglose por tipo y detalle paginado: OK" + +echo +echo "========== D5.3.7.2.2 · CONTENEDORES ==========" +docker compose ps -a +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}')" == "healthy" ]] +[[ "$(docker inspect dhv2-api --format '{{.State.Status}}')" == "running" ]] +[[ "$(docker inspect dhv2-web --format '{{.State.Status}}')" == "running" ]] + +echo +echo "ACEPTACIÓN DE FASE D5.3.7.2.2: OK" diff --git a/scripts/phase-d5-3-7-2-3-acceptance.sh b/scripts/phase-d5-3-7-2-3-acceptance.sh new file mode 100755 index 0000000..6eb825a --- /dev/null +++ b/scripts/phase-d5-3-7-2-3-acceptance.sh @@ -0,0 +1,129 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT="${ROOT:-/var/www/dhv2.korexlabs.com}" +API_URL="${API_URL:-http://127.0.0.1:3101/api/v3}" +DUMMY_UUID="00000000-0000-4000-8000-000000000000" +TMP_MIGRATION="/tmp/dhv2-d53723-accept-migration-$$.log" +TMP_ROUTE="/tmp/dhv2-d5372-route-$$.txt" +trap 'rm -f "$TMP_MIGRATION" "$TMP_ROUTE"' EXIT +cd "$ROOT" + +echo "========== D5.3.7.2.3 · VERSIONES ==========" +[[ "$(node -p "require('./api-v3/package.json').version")" == "0.19.3-7.2.3" ]] +[[ "$(node -p "require('./web-v2/package.json').version")" == "0.19.3-7.2.3" ]] +grep -Fq "API_PHASE = 'D5.3.7.2.3'" api-v3/src/version.ts +echo "API: 0.19.3-7.2.3" +echo "WEB: 0.19.3-7.2.3" + +echo +echo "========== D5.3.7.2.3 · DISCO Y DB ==========" +FREE_KB="$(df -Pk "$ROOT" | awk 'NR==2 {print $4}')" +(( FREE_KB >= 2 * 1024 * 1024 )) +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}')" == "healthy" ]] +docker compose exec -T db sh -lc 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' +echo "Libre: $(( FREE_KB / 1024 / 1024 )) GB · PostgreSQL healthy" + +echo +echo "========== D5.3.7.2.3 · MIGRACIONES ==========" +docker compose --profile tools run --rm migrate npm run migration:show 2>&1 | tee "$TMP_MIGRATION" +grep -Fq 'Pending migrations: no' "$TMP_MIGRATION" + +echo +echo "========== D5.3.7.2.3 · HEALTH ==========" +HEALTH="$(curl -fsS "$API_URL/health")" +echo "$HEALTH" +grep -Fq '"version":"0.19.3-7.2.3"' <<<"$HEALTH" +grep -Fq '"phase":"D5.3.7.2.3"' <<<"$HEALTH" +grep -Fq '"database":"ok"' <<<"$HEALTH" + +echo +echo "========== D5.3.7.2.3 · RUTA DE DETALLE PROTEGIDA ==========" +CODE="$(curl -sS -o "$TMP_ROUTE" -w '%{http_code}' "$API_URL/asset-imports/$DUMMY_UUID/plan/items")" +[[ "$CODE" == "401" ]] +echo "/asset-imports/:id/plan/items sin sesión: 401 OK" + +echo +echo "========== D5.3.7.2.3 · MODELO RELACIONAL ==========" +DB_CHECK="$( + cat <<'SQL' | docker compose exec -T db sh -c 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -At -v ON_ERROR_STOP=1 -v app_user="$APP_DB_USER"' +SELECT CASE WHEN to_regclass('public.administrative_departments') IS NOT NULL AND to_regclass('public.area_department_relations') IS NOT NULL THEN 'RELATIONAL_TABLES_OK' ELSE 'RELATIONAL_TABLES_BAD' END; +SELECT CASE WHEN + has_table_privilege(:'app_user','administrative_departments','SELECT') AND + has_table_privilege(:'app_user','administrative_departments','INSERT') AND + has_table_privilege(:'app_user','administrative_departments','UPDATE') AND + NOT has_table_privilege(:'app_user','administrative_departments','DELETE') AND + has_table_privilege(:'app_user','area_department_relations','SELECT') AND + has_table_privilege(:'app_user','area_department_relations','INSERT') AND + has_table_privilege(:'app_user','area_department_relations','UPDATE') AND + NOT has_table_privilege(:'app_user','area_department_relations','DELETE') +THEN 'RELATIONAL_PRIVILEGES_OK' ELSE 'RELATIONAL_PRIVILEGES_BAD' END; +SELECT CASE WHEN EXISTS (SELECT 1 FROM typeorm_migrations WHERE name='PhaseD5372RelationalTerritoryPlan1787767200000') THEN 'MIGRATION_OK' ELSE 'MIGRATION_BAD' END; +SELECT CASE WHEN EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname='chk_asset_import_plan_item_kind' + AND pg_get_constraintdef(oid) LIKE '%DEPARTMENT%' + AND pg_get_constraintdef(oid) LIKE '%AREA_DEPARTMENT_RELATION%' + AND pg_get_constraintdef(oid) LIKE '%LEGAL_RIGHT%' + AND pg_get_constraintdef(oid) LIKE '%LEGAL_RIGHT_ORGANIZATION%' +) THEN 'PLAN_KINDS_OK' ELSE 'PLAN_KINDS_BAD' END; +SQL +)" +echo "$DB_CHECK" +grep -Fq 'RELATIONAL_TABLES_OK' <<<"$DB_CHECK" +grep -Fq 'RELATIONAL_PRIVILEGES_OK' <<<"$DB_CHECK" +grep -Fq 'MIGRATION_OK' <<<"$DB_CHECK" +grep -Fq 'PLAN_KINDS_OK' <<<"$DB_CHECK" + +echo +echo "========== D5.3.7.2.3 · NORMALIZACIÓN Y DEDUPLICACIÓN ==========" +grep -Fq 'organizationImportKey' api-v3/src/asset-imports/asset-import-plan.ts +grep -Fq 'normalizedLegalRightType' api-v3/src/asset-imports/asset-import-plan.ts +grep -Fq "entityKind: 'DEPARTMENT'" api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq "entityKind:'AREA_DEPARTMENT_RELATION'" api-v3/src/asset-imports/asset-imports.service.ts || grep -Fq "entityKind: 'AREA_DEPARTMENT_RELATION'" api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq "entityKind:'LEGAL_RIGHT'" api-v3/src/asset-imports/asset-imports.service.ts || grep -Fq "entityKind: 'LEGAL_RIGHT'" api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq "entityKind:'LEGAL_RIGHT_ORGANIZATION'" api-v3/src/asset-imports/asset-imports.service.ts || grep -Fq "entityKind: 'LEGAL_RIGHT_ORGANIZATION'" api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq "operatorUnassigned ? '' : organizationImportKey(operator)" api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq "organizationImportKey\(operator\)" api-v3/test/unit/asset-imports-d5-3-7.test.ts || grep -Fq "organizationImportKey(operator)" api-v3/test/unit/asset-imports-d5-3-7.test.ts +if grep -Fq "add('departamento'" api-v3/src/asset-imports/asset-imports.service.ts; then + echo "ERROR: Departamento no debe persistirse como atributo textual duplicado"; exit 1 +fi +grep -Fq "status:'PENDING'" api-v3/src/asset-imports/asset-imports.service.ts || grep -Fq "status: 'PENDING'" api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq 'no se infiere titularidad' api-v3/src/asset-imports/asset-imports.service.ts +echo "Departamentos únicos, siglas de organizaciones, derechos normalizados y relaciones explícitas: OK" + +echo +echo "========== D5.3.7.2.3 · APLICACIÓN Y ROLLBACK ==========" +grep -Fq 'INSERT INTO administrative_departments' api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq 'INSERT INTO area_department_relations' api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq 'INSERT INTO area_legal_rights' api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq 'INSERT INTO area_legal_right_organizations' api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq "area_department_relations SET valid_until=CURRENT_DATE" api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq "area_legal_rights SET status='REVOKED'" api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq "administrative_departments SET is_active=false" api-v3/src/asset-imports/asset-imports.service.ts +if grep -Eq 'DELETE FROM (assets|administrative_departments|area_department_relations|area_legal_rights)' api-v3/src/asset-imports/asset-imports.service.ts; then + echo "ERROR: rollback relacional no debe hacer DELETE físico"; exit 1 +fi +grep -Fq '::integer,$${base+2}::uuid' api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq 'row.row_number=v.row_number' api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq 'applied_asset_id=$2::uuid,applied_object_id=($2::uuid)::text' api-v3/src/asset-imports/asset-imports.service.ts +echo "Aplicación transaccional, casteos SQL seguros y reversión lógica relacional: OK" + +echo +echo "========== D5.3.7.2.3 · FRONTEND ==========" +grep -Fq 'Plan relacional por entidad' web-v2/src/pages/AssetImportsPage.tsx +grep -Fq 'Ver detalle' web-v2/src/pages/AssetImportsPage.tsx +grep -Fq 'Departamentos' web-v2/src/pages/AssetImportsPage.tsx +grep -Fq 'Marco jurídico' web-v2/src/pages/AssetImportsPage.tsx +grep -Fq 'listAssetImportPlanItems' web-v2/src/lib/api.ts +echo "Desglose por tipo y detalle paginado: OK" + +echo +echo "========== D5.3.7.2.3 · CONTENEDORES ==========" +docker compose ps -a +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}')" == "healthy" ]] +[[ "$(docker inspect dhv2-api --format '{{.State.Status}}')" == "running" ]] +[[ "$(docker inspect dhv2-web --format '{{.State.Status}}')" == "running" ]] + +echo +echo "ACEPTACIÓN DE FASE D5.3.7.2.3: OK" diff --git a/scripts/phase-d5-3-7-2-4-acceptance.sh b/scripts/phase-d5-3-7-2-4-acceptance.sh new file mode 100755 index 0000000..2d2c273 --- /dev/null +++ b/scripts/phase-d5-3-7-2-4-acceptance.sh @@ -0,0 +1,141 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT="${ROOT:-/var/www/dhv2.korexlabs.com}" +API_URL="${API_URL:-http://127.0.0.1:3101/api/v3}" +DUMMY_UUID="00000000-0000-4000-8000-000000000000" +TMP_MIGRATION="/tmp/dhv2-d53724-accept-migration-$$.log" +TMP_ROUTE="/tmp/dhv2-d5372-route-$$.txt" +trap 'rm -f "$TMP_MIGRATION" "$TMP_ROUTE"' EXIT +cd "$ROOT" + +echo "========== D5.3.7.2.4 · VERSIONES ==========" +[[ "$(node -p "require('./api-v3/package.json').version")" == "0.19.3-7.2.4" ]] +[[ "$(node -p "require('./web-v2/package.json').version")" == "0.19.3-7.2.4" ]] +grep -Fq "API_PHASE = 'D5.3.7.2.4'" api-v3/src/version.ts +echo "API: 0.19.3-7.2.4" +echo "WEB: 0.19.3-7.2.4" + +echo +echo "========== D5.3.7.2.4 · DISCO Y DB ==========" +FREE_KB="$(df -Pk "$ROOT" | awk 'NR==2 {print $4}')" +(( FREE_KB >= 2 * 1024 * 1024 )) +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}')" == "healthy" ]] +docker compose exec -T db sh -lc 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' +echo "Libre: $(( FREE_KB / 1024 / 1024 )) GB · PostgreSQL healthy" + +echo +echo "========== D5.3.7.2.4 · MIGRACIONES ==========" +docker compose --profile tools run --rm migrate npm run migration:show 2>&1 | tee "$TMP_MIGRATION" +grep -Fq 'Pending migrations: no' "$TMP_MIGRATION" + +echo +echo "========== D5.3.7.2.4 · HEALTH ==========" +HEALTH="$(curl -fsS "$API_URL/health")" +echo "$HEALTH" +grep -Fq '"version":"0.19.3-7.2.4"' <<<"$HEALTH" +grep -Fq '"phase":"D5.3.7.2.4"' <<<"$HEALTH" +grep -Fq '"database":"ok"' <<<"$HEALTH" + +echo +echo "========== D5.3.7.2.4 · RUTA DE DETALLE PROTEGIDA ==========" +CODE="$(curl -sS -o "$TMP_ROUTE" -w '%{http_code}' "$API_URL/asset-imports/$DUMMY_UUID/plan/items")" +[[ "$CODE" == "401" ]] +echo "/asset-imports/:id/plan/items sin sesión: 401 OK" + +echo +echo "========== D5.3.7.2.4 · MODELO RELACIONAL ==========" +DB_CHECK="$( + cat <<'SQL' | docker compose exec -T db sh -c 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -At -v ON_ERROR_STOP=1 -v app_user="$APP_DB_USER"' +SELECT CASE WHEN to_regclass('public.administrative_departments') IS NOT NULL AND to_regclass('public.area_department_relations') IS NOT NULL THEN 'RELATIONAL_TABLES_OK' ELSE 'RELATIONAL_TABLES_BAD' END; +SELECT CASE WHEN + has_table_privilege(:'app_user','administrative_departments','SELECT') AND + has_table_privilege(:'app_user','administrative_departments','INSERT') AND + has_table_privilege(:'app_user','administrative_departments','UPDATE') AND + NOT has_table_privilege(:'app_user','administrative_departments','DELETE') AND + has_table_privilege(:'app_user','area_department_relations','SELECT') AND + has_table_privilege(:'app_user','area_department_relations','INSERT') AND + has_table_privilege(:'app_user','area_department_relations','UPDATE') AND + NOT has_table_privilege(:'app_user','area_department_relations','DELETE') +THEN 'RELATIONAL_PRIVILEGES_OK' ELSE 'RELATIONAL_PRIVILEGES_BAD' END; +SELECT CASE WHEN EXISTS (SELECT 1 FROM typeorm_migrations WHERE name='PhaseD5372RelationalTerritoryPlan1787767200000') THEN 'MIGRATION_OK' ELSE 'MIGRATION_BAD' END; +SELECT CASE WHEN EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname='chk_asset_import_plan_item_kind' + AND pg_get_constraintdef(oid) LIKE '%DEPARTMENT%' + AND pg_get_constraintdef(oid) LIKE '%AREA_DEPARTMENT_RELATION%' + AND pg_get_constraintdef(oid) LIKE '%LEGAL_RIGHT%' + AND pg_get_constraintdef(oid) LIKE '%LEGAL_RIGHT_ORGANIZATION%' +) THEN 'PLAN_KINDS_OK' ELSE 'PLAN_KINDS_BAD' END; +SQL +)" +echo "$DB_CHECK" +grep -Fq 'RELATIONAL_TABLES_OK' <<<"$DB_CHECK" +grep -Fq 'RELATIONAL_PRIVILEGES_OK' <<<"$DB_CHECK" +grep -Fq 'MIGRATION_OK' <<<"$DB_CHECK" +grep -Fq 'PLAN_KINDS_OK' <<<"$DB_CHECK" + +echo +echo "========== D5.3.7.2.4 · NORMALIZACIÓN Y DEDUPLICACIÓN ==========" +grep -Fq 'organizationImportKey' api-v3/src/asset-imports/asset-import-plan.ts +grep -Fq 'normalizedLegalRightType' api-v3/src/asset-imports/asset-import-plan.ts +grep -Fq "entityKind: 'DEPARTMENT'" api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq "entityKind:'AREA_DEPARTMENT_RELATION'" api-v3/src/asset-imports/asset-imports.service.ts || grep -Fq "entityKind: 'AREA_DEPARTMENT_RELATION'" api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq "entityKind:'LEGAL_RIGHT'" api-v3/src/asset-imports/asset-imports.service.ts || grep -Fq "entityKind: 'LEGAL_RIGHT'" api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq "entityKind:'LEGAL_RIGHT_ORGANIZATION'" api-v3/src/asset-imports/asset-imports.service.ts || grep -Fq "entityKind: 'LEGAL_RIGHT_ORGANIZATION'" api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq "operatorUnassigned ? '' : organizationImportKey(operator)" api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq "organizationImportKey\(operator\)" api-v3/test/unit/asset-imports-d5-3-7.test.ts || grep -Fq "organizationImportKey(operator)" api-v3/test/unit/asset-imports-d5-3-7.test.ts +if grep -Fq "add('departamento'" api-v3/src/asset-imports/asset-imports.service.ts; then + echo "ERROR: Departamento no debe persistirse como atributo textual duplicado"; exit 1 +fi +grep -Fq "status:'PENDING'" api-v3/src/asset-imports/asset-imports.service.ts || grep -Fq "status: 'PENDING'" api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq 'no se infiere titularidad' api-v3/src/asset-imports/asset-imports.service.ts +echo "Departamentos únicos, siglas de organizaciones, derechos normalizados y relaciones explícitas: OK" + +echo +echo "========== D5.3.7.2.4 · APLICACIÓN Y ROLLBACK ==========" +grep -Fq 'INSERT INTO administrative_departments' api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq 'INSERT INTO area_department_relations' api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq 'INSERT INTO area_legal_rights' api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq 'INSERT INTO area_legal_right_organizations' api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq "area_department_relations SET valid_until=CURRENT_DATE" api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq "area_legal_rights SET status='REVOKED'" api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq "administrative_departments SET is_active=false" api-v3/src/asset-imports/asset-imports.service.ts +if grep -Eq 'DELETE FROM (assets|administrative_departments|area_department_relations|area_legal_rights)' api-v3/src/asset-imports/asset-imports.service.ts; then + echo "ERROR: rollback relacional no debe hacer DELETE físico"; exit 1 +fi +grep -Fq '::integer,$${base+2}::uuid' api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq 'row.row_number=v.row_number' api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq 'applied_asset_id=$2::uuid,applied_object_id=($2::uuid)::text' api-v3/src/asset-imports/asset-imports.service.ts +echo "Aplicación transaccional, casteos SQL seguros y reversión lógica relacional: OK" + +echo +echo "========== D5.3.7.2.4 · PLAN STALE / IDEMPOTENCIA ==========" +grep -Fq 'private async masterStateFingerprint' api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq 'masterStateBefore = await this.masterStateFingerprint' api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq 'masterStateHash = await this.masterStateFingerprint' api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq 'plannedMasterStateHash' api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq 'currentMasterStateHash' api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq 'El Maestro cambió desde que se generó este plan' api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq 'Plan obsoleto' web-v2/src/pages/AssetImportsPage.tsx +grep -Fq 'planMasterStale' web-v2/src/pages/AssetImportsPage.tsx +echo "Huella de Maestro, bloqueo preventivo y señal visual de plan obsoleto: OK" + +echo +echo "========== D5.3.7.2.4 · FRONTEND ==========" +grep -Fq 'Plan relacional por entidad' web-v2/src/pages/AssetImportsPage.tsx +grep -Fq 'Ver detalle' web-v2/src/pages/AssetImportsPage.tsx +grep -Fq 'Departamentos' web-v2/src/pages/AssetImportsPage.tsx +grep -Fq 'Marco jurídico' web-v2/src/pages/AssetImportsPage.tsx +grep -Fq 'listAssetImportPlanItems' web-v2/src/lib/api.ts +echo "Desglose por tipo y detalle paginado: OK" + +echo +echo "========== D5.3.7.2.4 · CONTENEDORES ==========" +docker compose ps -a +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}')" == "healthy" ]] +[[ "$(docker inspect dhv2-api --format '{{.State.Status}}')" == "running" ]] +[[ "$(docker inspect dhv2-web --format '{{.State.Status}}')" == "running" ]] + +echo +echo "ACEPTACIÓN DE FASE D5.3.7.2.4: OK" diff --git a/scripts/phase-d5-3-7-2-5-acceptance.sh b/scripts/phase-d5-3-7-2-5-acceptance.sh new file mode 100755 index 0000000..bdbf073 --- /dev/null +++ b/scripts/phase-d5-3-7-2-5-acceptance.sh @@ -0,0 +1,152 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT="${ROOT:-/var/www/dhv2.korexlabs.com}" +API_URL="${API_URL:-http://127.0.0.1:3101/api/v3}" +DUMMY_UUID="00000000-0000-4000-8000-000000000000" +TMP_MIGRATION="/tmp/dhv2-d53725-accept-migration-$$.log" +TMP_ROUTE="/tmp/dhv2-d5372-route-$$.txt" +trap 'rm -f "$TMP_MIGRATION" "$TMP_ROUTE"' EXIT +cd "$ROOT" + +echo "========== D5.3.7.2.5 · VERSIONES ==========" +[[ "$(node -p "require('./api-v3/package.json').version")" == "0.19.3-7.2.5" ]] +[[ "$(node -p "require('./web-v2/package.json').version")" == "0.19.3-7.2.5" ]] +grep -Fq "API_PHASE = 'D5.3.7.2.5'" api-v3/src/version.ts +echo "API: 0.19.3-7.2.5" +echo "WEB: 0.19.3-7.2.5" + +echo +echo "========== D5.3.7.2.5 · DISCO Y DB ==========" +FREE_KB="$(df -Pk "$ROOT" | awk 'NR==2 {print $4}')" +(( FREE_KB >= 2 * 1024 * 1024 )) +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}')" == "healthy" ]] +docker compose exec -T db sh -lc 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' +echo "Libre: $(( FREE_KB / 1024 / 1024 )) GB · PostgreSQL healthy" + +echo +echo "========== D5.3.7.2.5 · MIGRACIONES ==========" +docker compose --profile tools run --rm migrate npm run migration:show 2>&1 | tee "$TMP_MIGRATION" +grep -Fq 'Pending migrations: no' "$TMP_MIGRATION" + +echo +echo "========== D5.3.7.2.5 · HEALTH ==========" +HEALTH="$(curl -fsS "$API_URL/health")" +echo "$HEALTH" +grep -Fq '"version":"0.19.3-7.2.5"' <<<"$HEALTH" +grep -Fq '"phase":"D5.3.7.2.5"' <<<"$HEALTH" +grep -Fq '"database":"ok"' <<<"$HEALTH" + +echo +echo "========== D5.3.7.2.5 · RUTA DE DETALLE PROTEGIDA ==========" +CODE="$(curl -sS -o "$TMP_ROUTE" -w '%{http_code}' "$API_URL/asset-imports/$DUMMY_UUID/plan/items")" +[[ "$CODE" == "401" ]] +echo "/asset-imports/:id/plan/items sin sesión: 401 OK" + +echo +echo "========== D5.3.7.2.5 · MODELO RELACIONAL ==========" +DB_CHECK="$( + cat <<'SQL' | docker compose exec -T db sh -c 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -At -v ON_ERROR_STOP=1 -v app_user="$APP_DB_USER"' +SELECT CASE WHEN to_regclass('public.administrative_departments') IS NOT NULL AND to_regclass('public.area_department_relations') IS NOT NULL THEN 'RELATIONAL_TABLES_OK' ELSE 'RELATIONAL_TABLES_BAD' END; +SELECT CASE WHEN + has_table_privilege(:'app_user','administrative_departments','SELECT') AND + has_table_privilege(:'app_user','administrative_departments','INSERT') AND + has_table_privilege(:'app_user','administrative_departments','UPDATE') AND + NOT has_table_privilege(:'app_user','administrative_departments','DELETE') AND + has_table_privilege(:'app_user','area_department_relations','SELECT') AND + has_table_privilege(:'app_user','area_department_relations','INSERT') AND + has_table_privilege(:'app_user','area_department_relations','UPDATE') AND + NOT has_table_privilege(:'app_user','area_department_relations','DELETE') +THEN 'RELATIONAL_PRIVILEGES_OK' ELSE 'RELATIONAL_PRIVILEGES_BAD' END; +SELECT CASE WHEN EXISTS (SELECT 1 FROM typeorm_migrations WHERE name='PhaseD5372RelationalTerritoryPlan1787767200000') THEN 'MIGRATION_OK' ELSE 'MIGRATION_BAD' END; +SELECT CASE WHEN EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname='chk_asset_import_plan_item_kind' + AND pg_get_constraintdef(oid) LIKE '%DEPARTMENT%' + AND pg_get_constraintdef(oid) LIKE '%AREA_DEPARTMENT_RELATION%' + AND pg_get_constraintdef(oid) LIKE '%LEGAL_RIGHT%' + AND pg_get_constraintdef(oid) LIKE '%LEGAL_RIGHT_ORGANIZATION%' +) THEN 'PLAN_KINDS_OK' ELSE 'PLAN_KINDS_BAD' END; +SQL +)" +echo "$DB_CHECK" +grep -Fq 'RELATIONAL_TABLES_OK' <<<"$DB_CHECK" +grep -Fq 'RELATIONAL_PRIVILEGES_OK' <<<"$DB_CHECK" +grep -Fq 'MIGRATION_OK' <<<"$DB_CHECK" +grep -Fq 'PLAN_KINDS_OK' <<<"$DB_CHECK" + +echo +echo "========== D5.3.7.2.5 · NORMALIZACIÓN Y DEDUPLICACIÓN ==========" +grep -Fq 'organizationImportKey' api-v3/src/asset-imports/asset-import-plan.ts +grep -Fq 'normalizedLegalRightType' api-v3/src/asset-imports/asset-import-plan.ts +grep -Fq "entityKind: 'DEPARTMENT'" api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq "entityKind:'AREA_DEPARTMENT_RELATION'" api-v3/src/asset-imports/asset-imports.service.ts || grep -Fq "entityKind: 'AREA_DEPARTMENT_RELATION'" api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq "entityKind:'LEGAL_RIGHT'" api-v3/src/asset-imports/asset-imports.service.ts || grep -Fq "entityKind: 'LEGAL_RIGHT'" api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq "entityKind:'LEGAL_RIGHT_ORGANIZATION'" api-v3/src/asset-imports/asset-imports.service.ts || grep -Fq "entityKind: 'LEGAL_RIGHT_ORGANIZATION'" api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq "operatorUnassigned ? '' : organizationImportKey(operator)" api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq "organizationImportKey\(operator\)" api-v3/test/unit/asset-imports-d5-3-7.test.ts || grep -Fq "organizationImportKey(operator)" api-v3/test/unit/asset-imports-d5-3-7.test.ts +if grep -Fq "add('departamento'" api-v3/src/asset-imports/asset-imports.service.ts; then + echo "ERROR: Departamento no debe persistirse como atributo textual duplicado"; exit 1 +fi +grep -Fq "status:'PENDING'" api-v3/src/asset-imports/asset-imports.service.ts || grep -Fq "status: 'PENDING'" api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq 'no se infiere titularidad' api-v3/src/asset-imports/asset-imports.service.ts +echo "Departamentos únicos, siglas de organizaciones, derechos normalizados y relaciones explícitas: OK" + +echo +echo "========== D5.3.7.2.5 · APLICACIÓN Y ROLLBACK ==========" +grep -Fq 'INSERT INTO administrative_departments' api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq 'INSERT INTO area_department_relations' api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq 'INSERT INTO area_legal_rights' api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq 'INSERT INTO area_legal_right_organizations' api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq "area_department_relations SET valid_until=CURRENT_DATE" api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq "area_legal_rights SET status='REVOKED'" api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq "administrative_departments SET is_active=false" api-v3/src/asset-imports/asset-imports.service.ts +if grep -Eq 'DELETE FROM (assets|administrative_departments|area_department_relations|area_legal_rights)' api-v3/src/asset-imports/asset-imports.service.ts; then + echo "ERROR: rollback relacional no debe hacer DELETE físico"; exit 1 +fi +grep -Fq '::integer,$${base+2}::uuid' api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq 'row.row_number=v.row_number' api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq 'applied_asset_id=$2::uuid,applied_object_id=($2::uuid)::text' api-v3/src/asset-imports/asset-imports.service.ts +echo "Aplicación transaccional, casteos SQL seguros y reversión lógica relacional: OK" + +echo +echo "========== D5.3.7.2.5 · PLAN STALE / IDEMPOTENCIA ==========" +grep -Fq 'private async masterStateFingerprint' api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq 'masterStateBefore = await this.masterStateFingerprint' api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq 'masterStateHash = await this.masterStateFingerprint' api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq 'plannedMasterStateHash' api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq 'currentMasterStateHash' api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq 'El Maestro cambió desde que se generó este plan' api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq 'Plan obsoleto' web-v2/src/pages/AssetImportsPage.tsx +grep -Fq 'planMasterStale' web-v2/src/pages/AssetImportsPage.tsx +echo "Huella de Maestro, bloqueo preventivo y señal visual de plan obsoleto: OK" + +echo +echo "========== D5.3.7.2.5 · FRONTEND CLARO Y AUDITABLE ==========" +grep -Fq 'Plan relacional por entidad' web-v2/src/pages/AssetImportsPage.tsx +grep -Fq 'Usar existentes' web-v2/src/pages/AssetImportsPage.tsx +grep -Fq 'Ya existen; no se duplican' web-v2/src/pages/AssetImportsPage.tsx +grep -Fq 'MATCH_CRITERIA' web-v2/src/pages/AssetImportsPage.tsx +grep -Fq 'Se reutiliza un registro del Maestro' web-v2/src/pages/AssetImportsPage.tsx +grep -Fq 'Decisiones pendientes antes de importar' web-v2/src/pages/AssetImportsPage.tsx +grep -Fq 'Decisiones directas' web-v2/src/pages/AssetImportsPage.tsx +grep -Fq 'Dependencias' web-v2/src/pages/AssetImportsPage.tsx +grep -Fq 'REVIEW_GUIDANCE' web-v2/src/pages/AssetImportsPage.tsx +grep -Fq 'Vincular con existente' web-v2/src/pages/AssetImportsPage.tsx +grep -Fq 'Crear como nuevo' web-v2/src/pages/AssetImportsPage.tsx +grep -Fq 'Excluir de esta importación' web-v2/src/pages/AssetImportsPage.tsx +grep -Fq 'action: action ?? undefined' web-v2/src/pages/AssetImportsPage.tsx +grep -Fq 'import-review-overview' web-v2/src/styles.css +grep -Fq 'import-match-explanation' web-v2/src/styles.css +grep -Fq 'listAssetImportPlanItems' web-v2/src/lib/api.ts +echo "Coincidencias explicadas, KPI navegables y revisión guiada: OK" + +echo +echo "========== D5.3.7.2.5 · CONTENEDORES ==========" +docker compose ps -a +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}')" == "healthy" ]] +[[ "$(docker inspect dhv2-api --format '{{.State.Status}}')" == "running" ]] +[[ "$(docker inspect dhv2-web --format '{{.State.Status}}')" == "running" ]] + +echo +echo "ACEPTACIÓN DE FASE D5.3.7.2.5: OK" diff --git a/scripts/phase-d5-3-7-2-acceptance.sh b/scripts/phase-d5-3-7-2-acceptance.sh new file mode 100755 index 0000000..dcd2df5 --- /dev/null +++ b/scripts/phase-d5-3-7-2-acceptance.sh @@ -0,0 +1,125 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT="${ROOT:-/var/www/dhv2.korexlabs.com}" +API_URL="${API_URL:-http://127.0.0.1:3101/api/v3}" +DUMMY_UUID="00000000-0000-4000-8000-000000000000" +TMP_MIGRATION="/tmp/dhv2-d5372-accept-migration-$$.log" +TMP_ROUTE="/tmp/dhv2-d5372-route-$$.txt" +trap 'rm -f "$TMP_MIGRATION" "$TMP_ROUTE"' EXIT +cd "$ROOT" + +echo "========== D5.3.7.2 · VERSIONES ==========" +[[ "$(node -p "require('./api-v3/package.json').version")" == "0.19.3-7.2" ]] +[[ "$(node -p "require('./web-v2/package.json').version")" == "0.19.3-7.2" ]] +grep -Fq "API_PHASE = 'D5.3.7.2'" api-v3/src/version.ts +echo "API: 0.19.3-7.2" +echo "WEB: 0.19.3-7.2" + +echo +echo "========== D5.3.7.2 · DISCO Y DB ==========" +FREE_KB="$(df -Pk "$ROOT" | awk 'NR==2 {print $4}')" +(( FREE_KB >= 2 * 1024 * 1024 )) +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}')" == "healthy" ]] +docker compose exec -T db sh -lc 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' +echo "Libre: $(( FREE_KB / 1024 / 1024 )) GB · PostgreSQL healthy" + +echo +echo "========== D5.3.7.2 · MIGRACIONES ==========" +docker compose --profile tools run --rm migrate npm run migration:show 2>&1 | tee "$TMP_MIGRATION" +grep -Fq 'Pending migrations: no' "$TMP_MIGRATION" + +echo +echo "========== D5.3.7.2 · HEALTH ==========" +HEALTH="$(curl -fsS "$API_URL/health")" +echo "$HEALTH" +grep -Fq '"version":"0.19.3-7.2"' <<<"$HEALTH" +grep -Fq '"phase":"D5.3.7.2"' <<<"$HEALTH" +grep -Fq '"database":"ok"' <<<"$HEALTH" + +echo +echo "========== D5.3.7.2 · RUTA DE DETALLE PROTEGIDA ==========" +CODE="$(curl -sS -o "$TMP_ROUTE" -w '%{http_code}' "$API_URL/asset-imports/$DUMMY_UUID/plan/items")" +[[ "$CODE" == "401" ]] +echo "/asset-imports/:id/plan/items sin sesión: 401 OK" + +echo +echo "========== D5.3.7.2 · MODELO RELACIONAL ==========" +DB_CHECK="$( + cat <<'SQL' | docker compose exec -T db sh -c 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -At -v ON_ERROR_STOP=1 -v app_user="$APP_DB_USER"' +SELECT CASE WHEN to_regclass('public.administrative_departments') IS NOT NULL AND to_regclass('public.area_department_relations') IS NOT NULL THEN 'RELATIONAL_TABLES_OK' ELSE 'RELATIONAL_TABLES_BAD' END; +SELECT CASE WHEN + has_table_privilege(:'app_user','administrative_departments','SELECT') AND + has_table_privilege(:'app_user','administrative_departments','INSERT') AND + has_table_privilege(:'app_user','administrative_departments','UPDATE') AND + NOT has_table_privilege(:'app_user','administrative_departments','DELETE') AND + has_table_privilege(:'app_user','area_department_relations','SELECT') AND + has_table_privilege(:'app_user','area_department_relations','INSERT') AND + has_table_privilege(:'app_user','area_department_relations','UPDATE') AND + NOT has_table_privilege(:'app_user','area_department_relations','DELETE') +THEN 'RELATIONAL_PRIVILEGES_OK' ELSE 'RELATIONAL_PRIVILEGES_BAD' END; +SELECT CASE WHEN EXISTS (SELECT 1 FROM typeorm_migrations WHERE name='PhaseD5372RelationalTerritoryPlan1787767200000') THEN 'MIGRATION_OK' ELSE 'MIGRATION_BAD' END; +SELECT CASE WHEN EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname='chk_asset_import_plan_item_kind' + AND pg_get_constraintdef(oid) LIKE '%DEPARTMENT%' + AND pg_get_constraintdef(oid) LIKE '%AREA_DEPARTMENT_RELATION%' + AND pg_get_constraintdef(oid) LIKE '%LEGAL_RIGHT%' + AND pg_get_constraintdef(oid) LIKE '%LEGAL_RIGHT_ORGANIZATION%' +) THEN 'PLAN_KINDS_OK' ELSE 'PLAN_KINDS_BAD' END; +SQL +)" +echo "$DB_CHECK" +grep -Fq 'RELATIONAL_TABLES_OK' <<<"$DB_CHECK" +grep -Fq 'RELATIONAL_PRIVILEGES_OK' <<<"$DB_CHECK" +grep -Fq 'MIGRATION_OK' <<<"$DB_CHECK" +grep -Fq 'PLAN_KINDS_OK' <<<"$DB_CHECK" + +echo +echo "========== D5.3.7.2 · NORMALIZACIÓN Y DEDUPLICACIÓN ==========" +grep -Fq 'organizationImportKey' api-v3/src/asset-imports/asset-import-plan.ts +grep -Fq 'normalizedLegalRightType' api-v3/src/asset-imports/asset-import-plan.ts +grep -Fq "entityKind: 'DEPARTMENT'" api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq "entityKind:'AREA_DEPARTMENT_RELATION'" api-v3/src/asset-imports/asset-imports.service.ts || grep -Fq "entityKind: 'AREA_DEPARTMENT_RELATION'" api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq "entityKind:'LEGAL_RIGHT'" api-v3/src/asset-imports/asset-imports.service.ts || grep -Fq "entityKind: 'LEGAL_RIGHT'" api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq "entityKind:'LEGAL_RIGHT_ORGANIZATION'" api-v3/src/asset-imports/asset-imports.service.ts || grep -Fq "entityKind: 'LEGAL_RIGHT_ORGANIZATION'" api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq "operatorUnassigned ? '' : organizationImportKey(operator)" api-v3/src/asset-imports/asset-imports.service.ts +if grep -Fq "add('departamento'" api-v3/src/asset-imports/asset-imports.service.ts; then + echo "ERROR: Departamento no debe persistirse como atributo textual duplicado"; exit 1 +fi +grep -Fq "status:'PENDING'" api-v3/src/asset-imports/asset-imports.service.ts || grep -Fq "status: 'PENDING'" api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq 'no se infiere titularidad' api-v3/src/asset-imports/asset-imports.service.ts +echo "Departamentos únicos, siglas de organizaciones, derechos normalizados y relaciones explícitas: OK" + +echo +echo "========== D5.3.7.2 · APLICACIÓN Y ROLLBACK ==========" +grep -Fq 'INSERT INTO administrative_departments' api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq 'INSERT INTO area_department_relations' api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq 'INSERT INTO area_legal_rights' api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq 'INSERT INTO area_legal_right_organizations' api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq "area_department_relations SET valid_until=CURRENT_DATE" api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq "area_legal_rights SET status='REVOKED'" api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq "administrative_departments SET is_active=false" api-v3/src/asset-imports/asset-imports.service.ts +if grep -Eq 'DELETE FROM (assets|administrative_departments|area_department_relations|area_legal_rights)' api-v3/src/asset-imports/asset-imports.service.ts; then + echo "ERROR: rollback relacional no debe hacer DELETE físico"; exit 1 +fi +echo "Aplicación transaccional y reversión lógica relacional: OK" + +echo +echo "========== D5.3.7.2 · FRONTEND ==========" +grep -Fq 'Plan relacional por entidad' web-v2/src/pages/AssetImportsPage.tsx +grep -Fq 'Ver detalle' web-v2/src/pages/AssetImportsPage.tsx +grep -Fq 'Departamentos' web-v2/src/pages/AssetImportsPage.tsx +grep -Fq 'Marco jurídico' web-v2/src/pages/AssetImportsPage.tsx +grep -Fq 'listAssetImportPlanItems' web-v2/src/lib/api.ts +echo "Desglose por tipo y detalle paginado: OK" + +echo +echo "========== D5.3.7.2 · CONTENEDORES ==========" +docker compose ps -a +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}')" == "healthy" ]] +[[ "$(docker inspect dhv2-api --format '{{.State.Status}}')" == "running" ]] +[[ "$(docker inspect dhv2-web --format '{{.State.Status}}')" == "running" ]] + +echo +echo "ACEPTACIÓN DE FASE D5.3.7.2: OK" diff --git a/scripts/phase-d5-3-8-acceptance.sh b/scripts/phase-d5-3-8-acceptance.sh new file mode 100755 index 0000000..5597853 --- /dev/null +++ b/scripts/phase-d5-3-8-acceptance.sh @@ -0,0 +1,132 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT="${ROOT:-/var/www/dhv2.korexlabs.com}" +API_URL="${API_URL:-http://127.0.0.1:3101/api/v3}" +TMP_MIGRATION="/tmp/dhv2-d538-accept-migration-$$.log" +trap 'rm -f "$TMP_MIGRATION"' EXIT +cd "$ROOT" + +echo "========== D5.3.8 · VERSIONES ==========" +[[ "$(node -p "require('./api-v3/package.json').version")" == "0.19.3-8" ]] +[[ "$(node -p "require('./web-v2/package.json').version")" == "0.19.3-8" ]] +grep -Fq "API_PHASE = 'D5.3.8'" api-v3/src/version.ts +echo "API: 0.19.3-8" +echo "WEB: 0.19.3-8" + +echo +echo "========== D5.3.8 · DISCO Y DB ==========" +FREE_KB="$(df -Pk "$ROOT" | awk 'NR==2 {print $4}')" +(( FREE_KB >= 2 * 1024 * 1024 )) +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}')" == "healthy" ]] +docker compose exec -T db sh -lc 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' +echo "Libre: $(( FREE_KB / 1024 / 1024 )) GB · PostgreSQL healthy" + +echo +echo "========== D5.3.8 · MIGRACIONES ==========" +docker compose --profile tools run --rm migrate npm run migration:show 2>&1 | tee "$TMP_MIGRATION" +grep -Fq 'Pending migrations: no' "$TMP_MIGRATION" + +echo +echo "========== D5.3.8 · HEALTH ==========" +HEALTH="$(curl -fsS "$API_URL/health")" +echo "$HEALTH" +grep -Fq '"version":"0.19.3-8"' <<<"$HEALTH" +grep -Fq '"phase":"D5.3.8"' <<<"$HEALTH" +grep -Fq '"database":"ok"' <<<"$HEALTH" + +echo +echo "========== D5.3.8 · BACKUP INVENTARIO A REVISAR ==========" +if [[ -z "${STRONG_BACKUP:-}" ]]; then + STRONG_BACKUP="$(find /root/DH_V2_BACKUPS -maxdepth 1 -type d -name 'INVENTARIO_A_REVISAR_*' -printf '%T@ %p\n' | sort -nr | head -n1 | cut -d' ' -f2-)" +fi +[[ -n "$STRONG_BACKUP" && -d "$STRONG_BACKUP" ]] +test -s "$STRONG_BACKUP/INVENTARIO A REVISAR.txt" +test -s "$STRONG_BACKUP/BACKUP_OK.txt" +test -s "$STRONG_BACKUP/database.dump" +test -s "$STRONG_BACKUP/source.tar.gz" +test -s "$STRONG_BACKUP/asset-media.tar.gz" +test -s "$STRONG_BACKUP/docker-images.tar.gz" +test -s "$STRONG_BACKUP/SHA256SUMS.txt" +grep -Fq 'INVENTARIO A REVISAR' "$STRONG_BACKUP/BACKUP_OK.txt" +( + cd "$STRONG_BACKUP" + sha256sum -c SHA256SUMS.txt >/dev/null +) +echo "Backup fuerte validado: $STRONG_BACKUP" + +echo +echo "========== D5.3.8 · MODELO ESTRUCTURA LOCAL ==========" +DB_CHECK="$( + cat <<'SQL' | docker compose exec -T db sh -c 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -At -v ON_ERROR_STOP=1' +SELECT CASE WHEN EXISTS (SELECT 1 FROM typeorm_migrations WHERE name='PhaseD538LocalInventoryStructure1787853600000') THEN 'MIGRATION_D538_OK' ELSE 'MIGRATION_D538_BAD' END; +SELECT CASE WHEN EXISTS (SELECT 1 FROM asset_types WHERE code='estructura_local' AND name='Estructura local (fuente)' AND operational_role='GENERIC' AND is_active=true) THEN 'LOCAL_TYPE_OK' ELSE 'LOCAL_TYPE_BAD' END; +SELECT CASE WHEN (SELECT count(*) FROM asset_attribute_definitions d JOIN asset_types t ON t.id=d.asset_type_id WHERE t.code='estructura_local' AND d.code IN ('nivel_fuente','clasificacion_fuente','ruta_fuente') AND d.is_active=true)=3 THEN 'LOCAL_ATTRIBUTES_OK' ELSE 'LOCAL_ATTRIBUTES_BAD' END; +SELECT CASE WHEN EXISTS ( + SELECT 1 FROM asset_type_parent_rules r + JOIN asset_types child ON child.id=r.child_type_id + JOIN asset_types parent ON parent.id=r.parent_type_id + WHERE child.code='estructura_local' AND parent.code='yacimiento' +) AND EXISTS ( + SELECT 1 FROM asset_type_parent_rules r + JOIN asset_types child ON child.id=r.child_type_id + JOIN asset_types parent ON parent.id=r.parent_type_id + WHERE child.code='estructura_local' AND parent.code='area' +) THEN 'LOCAL_PARENT_RULES_OK' ELSE 'LOCAL_PARENT_RULES_BAD' END; +SELECT CASE WHEN EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname='chk_asset_import_plan_item_kind' + AND pg_get_constraintdef(oid) LIKE '%LOCAL_STRUCTURE%' +) THEN 'PLAN_LOCAL_KIND_OK' ELSE 'PLAN_LOCAL_KIND_BAD' END; +SQL +)" +echo "$DB_CHECK" +grep -Fq 'MIGRATION_D538_OK' <<<"$DB_CHECK" +grep -Fq 'LOCAL_TYPE_OK' <<<"$DB_CHECK" +grep -Fq 'LOCAL_ATTRIBUTES_OK' <<<"$DB_CHECK" +grep -Fq 'LOCAL_PARENT_RULES_OK' <<<"$DB_CHECK" +grep -Fq 'PLAN_LOCAL_KIND_OK' <<<"$DB_CHECK" + +echo +echo "========== D5.3.8 · IMPORTADOR FLEXIBLE ==========" +grep -Fq 'sourceLocalStructureSuggestion' api-v3/src/asset-imports/asset-import-plan.ts +grep -Fq "entityKind:'LOCAL_STRUCTURE'" api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq "assetTypeCode:'estructura_local'" api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq "classificationStatus:'SOURCE_PRESERVED'" api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq 'inventoryContextOverrides' api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq 'inventoryContextDecision:true' api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq 'PLAN_CONTEXT_DECISION_REQUIRED' api-v3/src/asset-imports/asset-imports.service.ts +if grep -Fq '[A VALIDAR]' api-v3/src/asset-imports/asset-imports.service.ts; then + echo "ERROR: D5.3.8 no debe inventar nombres [A VALIDAR]"; exit 1 +fi +echo "Nomenclatura local preservada y ambigüedad territorial colapsada por causa raíz: OK" + +echo +echo "========== D5.3.8 · UI DE RESOLUCIÓN ==========" +grep -Fq 'Primero resolvé la causa raíz' web-v2/src/pages/AssetImportsPage.tsx +grep -Fq 'Nomenclatura local preservada' web-v2/src/pages/AssetImportsPage.tsx +grep -Fq 'Elegí el Área/Yacimiento correcto' web-v2/src/pages/AssetImportsPage.tsx +grep -Fq 'inventoryContextCandidates' web-v2/src/pages/AssetImportsPage.tsx +grep -Fq 'chooseContextCandidate' web-v2/src/pages/AssetImportsPage.tsx +grep -Fq 'Se resuelve automáticamente después' web-v2/src/pages/AssetImportsPage.tsx +grep -Fq 'Estructura local según fuente' web-v2/src/pages/AssetImportsPage.tsx +grep -Fq 'import-context-candidates' web-v2/src/styles.css +echo "Decisión raíz, alternativas territoriales y dependencias automáticas: OK" + +echo +echo "========== D5.3.8 · SEGURIDAD HEREDADA ==========" +grep -Fq 'private async masterStateFingerprint' api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq 'El Maestro cambió desde que se generó este plan' api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq 'applied_asset_id=$2::uuid,applied_object_id=($2::uuid)::text' api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq '::integer,$${base+2}::uuid' api-v3/src/asset-imports/asset-imports.service.ts +echo "Plan stale, idempotencia, transacción y casteos seguros preservados: OK" + +echo +echo "========== D5.3.8 · CONTENEDORES ==========" +docker compose ps -a +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}')" == "healthy" ]] +[[ "$(docker inspect dhv2-api --format '{{.State.Status}}')" == "running" ]] +[[ "$(docker inspect dhv2-web --format '{{.State.Status}}')" == "running" ]] + +echo +echo "ACEPTACIÓN DE FASE D5.3.8: OK" diff --git a/scripts/phase-d5-3-9-acceptance.sh b/scripts/phase-d5-3-9-acceptance.sh new file mode 100755 index 0000000..0aced1f --- /dev/null +++ b/scripts/phase-d5-3-9-acceptance.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT="${ROOT:-/var/www/dhv2.korexlabs.com}" +API_URL="${API_URL:-http://127.0.0.1:3101/api/v3}" +TMP_MIGRATION="/tmp/dhv2-d539-accept-migration-$$.log" +trap 'rm -f "$TMP_MIGRATION"' EXIT +cd "$ROOT" + +echo "========== D5.3.9 · VERSIONES ==========" +[[ "$(node -p "require('./api-v3/package.json').version")" == "0.19.3-9" ]] +[[ "$(node -p "require('./web-v2/package.json').version")" == "0.19.3-9" ]] +grep -Fq "API_PHASE = 'D5.3.9'" api-v3/src/version.ts +grep -Fq "APP_PHASE = 'Fase D5.3.9 · Revisión obligatoria clara'" web-v2/src/config/version.ts +echo "API: 0.19.3-9" +echo "WEB: 0.19.3-9" + +echo +echo "========== D5.3.9 · DB Y MIGRACIONES ==========" +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}')" == "healthy" ]] +docker compose exec -T db sh -lc 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' +docker compose --profile tools run --rm migrate npm run migration:show 2>&1 | tee "$TMP_MIGRATION" +grep -Fq 'Pending migrations: no' "$TMP_MIGRATION" + +echo +echo "========== D5.3.9 · HEALTH ==========" +HEALTH="$(curl -fsS "$API_URL/health")" +echo "$HEALTH" +grep -Fq '"version":"0.19.3-9"' <<<"$HEALTH" +grep -Fq '"phase":"D5.3.9"' <<<"$HEALTH" +grep -Fq '"database":"ok"' <<<"$HEALTH" + +echo +echo "========== D5.3.9 · REVISION OBLIGATORIA ==========" +grep -Fq 'PLAN_DEPENDENCY_REVIEW_CODES' api-v3/src/asset-imports/asset-import-plan.ts +grep -Fq 'directReviewItems' api-v3/src/asset-imports/asset-import-plan.ts +grep -Fq 'dependencyReviewItems' api-v3/src/asset-imports/asset-import-plan.ts +grep -Fq 'isPlanDependencyReview' api-v3/src/asset-imports/asset-import-plan.ts +grep -Fq "review_codes <@" api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq 'REVISIÓN OBLIGATORIA' web-v2/src/pages/AssetImportsPage.tsx +grep -Fq 'Estas son las únicas tarjetas que tenés que resolver' web-v2/src/pages/AssetImportsPage.tsx +grep -Fq 'dependencias automáticas' web-v2/src/pages/AssetImportsPage.tsx +grep -Fq 'el plan se regenera automáticamente' web-v2/src/pages/AssetImportsPage.tsx +grep -Fq 'const regenerated = await generateAssetImportPlan' web-v2/src/pages/AssetImportsPage.tsx +grep -Fq 'import-review-mandatory-banner' web-v2/src/styles.css +grep -Fq 'import-review-dependencies' web-v2/src/styles.css +echo "Separación decisión humana / dependencia automática: OK" + +echo +echo "========== D5.3.9 · SEGURIDAD HEREDADA ==========" +grep -Fq 'private async masterStateFingerprint' api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq 'ASSET_IMPORT_CONTEXT_MATCH_INVALID' api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq "classificationStatus:'SOURCE_PRESERVED'" api-v3/src/asset-imports/asset-imports.service.ts +grep -Fq 'inventoryContextOverrides' api-v3/src/asset-imports/asset-imports.service.ts +if grep -Fq '[A VALIDAR]' api-v3/src/asset-imports/asset-imports.service.ts; then + echo "ERROR: no debe reaparecer la jerarquía inventada [A VALIDAR]"; exit 1 +fi +STRONG_BACKUP="$(find /root/DH_V2_BACKUPS -maxdepth 1 -type d -name 'INVENTARIO_A_REVISAR_*' -printf '%T@ %p\n' | sort -nr | head -n1 | cut -d' ' -f2-)" +[[ -n "$STRONG_BACKUP" && -s "$STRONG_BACKUP/BACKUP_OK.txt" ]] +grep -Fq 'INVENTARIO A REVISAR' "$STRONG_BACKUP/BACKUP_OK.txt" +echo "Backup fuerte de referencia: $STRONG_BACKUP" + +echo +echo "========== D5.3.9 · CONTENEDORES ==========" +docker compose ps -a +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}')" == "healthy" ]] +[[ "$(docker inspect dhv2-api --format '{{.State.Status}}')" == "running" ]] +[[ "$(docker inspect dhv2-web --format '{{.State.Status}}')" == "running" ]] + +echo +echo "ACEPTACIÓN DE FASE D5.3.9: OK" diff --git a/scripts/phase-d5-3-acceptance.sh b/scripts/phase-d5-3-acceptance.sh new file mode 100755 index 0000000..59945fa --- /dev/null +++ b/scripts/phase-d5-3-acceptance.sh @@ -0,0 +1,166 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail + +dhv2_base_url="${DHV2_BASE_URL:-https://dhv2.korexlabs.com}" +dhv2_base_url="${dhv2_base_url%/}" + +fail() { + echo "ERROR: $*" >&2 + exit 1 +} + +echo "========== D5.3 · MIGRACIONES ==========" +dhv2_migration_status="$(docker compose --profile tools run --rm migrate npm run migration:show 2>&1)" +grep -q 'Pending migrations: no' <<<"$dhv2_migration_status" || fail "Quedaron migraciones pendientes" +echo "Migraciones pendientes: no" + +echo +echo "========== D5.3 · HEALTH ==========" +dhv2_internal="$(curl -fsS --connect-timeout 10 --max-time 30 http://127.0.0.1:3101/api/v3/health)" +dhv2_external="$(curl -fsS --connect-timeout 10 --max-time 30 "$dhv2_base_url/api/v3/health")" +grep -q '"version":"0.19.3"' <<<"$dhv2_internal" || fail "Health interno no informa v0.19.3" +grep -q '"phase":"D5.3"' <<<"$dhv2_internal" || fail "Health interno no informa D5.3" +grep -q '"version":"0.19.3"' <<<"$dhv2_external" || fail "Health externo no informa v0.19.3" +grep -q '"database":"ok"' <<<"$dhv2_external" || fail "La base no está saludable" +echo "$dhv2_external" + +echo +echo "========== D5.3 · RUTA OPERATIVA PROTEGIDA ==========" +dhv2_code="$(curl -sS --connect-timeout 10 --max-time 30 \ + -o /tmp/dhv2-d5-3-response.json -w '%{http_code}' \ + "$dhv2_base_url/api/v3/asset-operational-relations/areas")" +test "$dhv2_code" = "401" || fail "/api/v3/asset-operational-relations/areas devolvió HTTP $dhv2_code; se esperaba 401" +rm -f /tmp/dhv2-d5-3-response.json +echo "/api/v3/asset-operational-relations/areas sin sesión: 401 OK" + +echo +echo "========== D5.3 · MODELO OPERATIVO ==========" +dhv2_db_check="$(docker compose exec -T db sh -lc \ + 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -v ON_ERROR_STOP=1 -v app_user="$APP_DB_USER" -At' <<'SQL' +SELECT 'operational_role_enum', COUNT(*) +FROM pg_enum enum_value +INNER JOIN pg_type enum_type ON enum_type.oid = enum_value.enumtypid +WHERE enum_type.typname = 'asset_type_operational_role'; + +SELECT 'asset_type_role_column', COUNT(*) +FROM information_schema.columns +WHERE table_schema = 'public' + AND table_name = 'asset_types' + AND column_name = 'operational_role'; + +SELECT 'asset_operational_columns', COUNT(*) +FROM information_schema.columns +WHERE table_schema = 'public' + AND table_name = 'assets' + AND column_name IN ('operational_area_id', 'operator_company_id'); + +SELECT 'relations_table', COUNT(*) +FROM information_schema.tables +WHERE table_schema = 'public' + AND table_name = 'area_company_relations'; + +SELECT 'relations_constraints', COUNT(*) +FROM information_schema.table_constraints +WHERE table_schema = 'public' + AND table_name = 'area_company_relations' + AND constraint_name IN ( + 'chk_area_company_relation_dates', + 'chk_area_company_relation_start_reason', + 'chk_area_company_relation_end_reason', + 'fk_area_company_relation_area', + 'fk_area_company_relation_company', + 'fk_area_company_relation_created_by', + 'fk_area_company_relation_ended_by' + ); + +SELECT 'active_pair_unique_index', COUNT(*) +FROM pg_indexes +WHERE schemaname = 'public' + AND tablename = 'area_company_relations' + AND indexname = 'uq_area_company_relations_active_pair'; + +SELECT 'operational_triggers', COUNT(DISTINCT trigger_name) +FROM information_schema.triggers +WHERE event_object_schema = 'public' + AND trigger_name IN ( + 'trg_asset_types_protect_operational_changes', + 'trg_area_company_relation_roles', + 'trg_assets_operational_context', + 'trg_assets_operational_descendants', + 'trg_assets_protect_operational_anchor_inactivation' + ); + +SELECT 'relation_read_roles', COUNT(*) +FROM role_permissions role_permission +INNER JOIN roles role ON role.id = role_permission.role_id +INNER JOIN permissions permission ON permission.id = role_permission.permission_id +WHERE role.code IN ('admin', 'director', 'supervisor', 'inspector', 'auditor') + AND permission.code = 'asset_relations.read'; + +SELECT 'relation_manage_roles', COUNT(*) +FROM role_permissions role_permission +INNER JOIN roles role ON role.id = role_permission.role_id +INNER JOIN permissions permission ON permission.id = role_permission.permission_id +WHERE role.code IN ('admin', 'supervisor') + AND permission.code = 'asset_relations.manage'; + +SELECT 'relation_select', has_table_privilege(:'app_user', 'area_company_relations', 'SELECT'); +SELECT 'relation_insert', has_table_privilege(:'app_user', 'area_company_relations', 'INSERT'); +SELECT 'relation_update', has_table_privilege(:'app_user', 'area_company_relations', 'UPDATE'); +SELECT 'relation_delete', has_table_privilege(:'app_user', 'area_company_relations', 'DELETE'); + +SELECT 'asset_versions_backfilled', NOT EXISTS ( + SELECT 1 + FROM asset_versions + WHERE NOT (snapshot->'type' ? 'operationalRole') + OR NOT (snapshot ? 'operationalArea') + OR NOT (snapshot ? 'operatorCompany') +); + +SELECT 'operational_pair_integrity', NOT EXISTS ( + SELECT 1 FROM assets + WHERE (operational_area_id IS NULL) <> (operator_company_id IS NULL) +); + +SELECT 'migration_applied', COUNT(*) +FROM typeorm_migrations +WHERE name = 'PhaseD53OperationalContext1787421600000'; +SQL +)" +grep -q 'operational_role_enum|3' <<<"$dhv2_db_check" || fail "El enum operativo no tiene los tres roles esperados" +grep -q 'asset_type_role_column|1' <<<"$dhv2_db_check" || fail "Falta operational_role en asset_types" +grep -q 'asset_operational_columns|2' <<<"$dhv2_db_check" || fail "Faltan columnas de contexto operativo en assets" +grep -q 'relations_table|1' <<<"$dhv2_db_check" || fail "No se creó area_company_relations" +grep -q 'relations_constraints|7' <<<"$dhv2_db_check" || fail "Faltan restricciones de integridad en relaciones Área–Empresa" +grep -q 'active_pair_unique_index|1' <<<"$dhv2_db_check" || fail "Falta unicidad del vínculo activo Área–Empresa" +grep -q 'operational_triggers|5' <<<"$dhv2_db_check" || fail "Faltan triggers de integridad operativa" +grep -q 'relation_read_roles|5' <<<"$dhv2_db_check" || fail "Los roles de consulta no recibieron asset_relations.read" +grep -q 'relation_manage_roles|2' <<<"$dhv2_db_check" || fail "Admin y supervisor no recibieron asset_relations.manage" +grep -q 'relation_select|t' <<<"$dhv2_db_check" || fail "La API no puede leer relaciones operativas" +grep -q 'relation_insert|t' <<<"$dhv2_db_check" || fail "La API no puede crear relaciones operativas" +grep -q 'relation_update|t' <<<"$dhv2_db_check" || fail "La API no puede finalizar relaciones operativas" +grep -q 'relation_delete|f' <<<"$dhv2_db_check" || fail "La API no debe borrar relaciones operativas" +grep -q 'asset_versions_backfilled|t' <<<"$dhv2_db_check" || fail "Hay snapshots históricos sin los campos de D5.3" +grep -q 'operational_pair_integrity|t' <<<"$dhv2_db_check" || fail "Hay activos con contexto operativo incompleto" +grep -q 'migration_applied|1' <<<"$dhv2_db_check" || fail "La migración D5.3 no figura aplicada" +echo "$dhv2_db_check" + +echo +echo "========== D5.3 · API COMPILADA ==========" +docker compose exec -T api sh -lc \ + 'grep -R "AssetOperationalRelationsController" /app/dist >/dev/null && grep -R "ASSET_AREA_COMPANY_RELATION_CREATED" /app/dist >/dev/null && grep -R "ASSET_HIERARCHY_MOVE_BREAKS_OPERATIONAL_CONTEXT" /app/dist >/dev/null' +echo "Relaciones, auditoría y validaciones operativas cargadas: OK" + +echo +echo "========== D5.3 · DASHBOARD ==========" +docker compose exec -T web sh -lc \ + 'grep -R "0.19.3" /usr/share/nginx/html/assets >/dev/null && grep -R "Contexto operativo" /usr/share/nginx/html/assets >/dev/null && grep -R "Relaciones operativas" /usr/share/nginx/html/assets >/dev/null && grep -R "Empresa explotadora" /usr/share/nginx/html/assets >/dev/null' +echo "Contexto Área–Empresa y footer v0.19.3: OK" + +echo +echo "========== D5.3 · CONTENEDORES ==========" +docker compose ps + +echo +echo "ACEPTACIÓN DE FASE D5.3: OK" diff --git a/scripts/phase-d5-4-1-acceptance.sh b/scripts/phase-d5-4-1-acceptance.sh new file mode 100755 index 0000000..2ae470b --- /dev/null +++ b/scripts/phase-d5-4-1-acceptance.sh @@ -0,0 +1,138 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT="${ROOT:-/var/www/dhv2.korexlabs.com}" +API_URL="${API_URL:-http://127.0.0.1:3101/api/v3}" +TMP_MIGRATION="/tmp/dhv2-d541-accept-migration-$$.log" +trap 'rm -f "$TMP_MIGRATION"' EXIT +cd "$ROOT" + +echo "========== D5.4.1 · VERSIONES ==========" +[[ "$(node -p "require('./api-v3/package.json').version")" == "0.19.4-1" ]] +[[ "$(node -p "require('./web-v2/package.json').version")" == "0.19.4-1" ]] +grep -Fq "API_PHASE = 'D5.4.1'" api-v3/src/version.ts +grep -Fq "APP_PHASE = 'Fase D5.4.1 · Hallazgos aplicables al Inventario'" web-v2/src/config/version.ts +echo "API: 0.19.4-1" +echo "WEB: 0.19.4-1" + +echo +echo "========== D5.4.1 · DB Y MIGRACIONES ==========" +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}')" == "healthy" ]] +docker compose exec -T db sh -lc 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' +docker compose --profile tools run --rm migrate npm run migration:show 2>&1 | tee "$TMP_MIGRATION" +grep -Fq 'Pending migrations: no' "$TMP_MIGRATION" +for table in finding_catalog_asset_type_profiles finding_catalog_item_asset_types finding_catalog_asset_overrides finding_catalog_proposals; do + docker compose exec -T db sh -lc "psql -U \"\$POSTGRES_USER\" -d \"\$POSTGRES_DB\" -Atc \"SELECT to_regclass('public.$table');\"" | grep -Fxq "$table" +done +for spec in 'finding_catalog_items:suggested_severity' 'inspection_findings:suggested_severity' 'inspection_findings:severity'; do + table="${spec%%:*}"; column="${spec##*:}" + docker compose exec -T db sh -lc "psql -U \"\$POSTGRES_USER\" -d \"\$POSTGRES_DB\" -Atc \"SELECT column_name FROM information_schema.columns WHERE table_name='$table' AND column_name='$column';\"" | grep -Fxq "$column" +done +echo "Aplicabilidad contextual, excepciones, propuestas y gravedad presentes: OK" + +echo +echo "========== D5.4.1 · HEALTH ==========" +HEALTH="$(curl -fsS "$API_URL/health")" +echo "$HEALTH" +grep -Fq '"version":"0.19.4-1"' <<<"$HEALTH" +grep -Fq '"phase":"D5.4.1"' <<<"$HEALTH" +grep -Fq '"database":"ok"' <<<"$HEALTH" + +echo +echo "========== D5.4.1 · CATÁLOGO POR TIPO ==========" +grep -Fq "@Get('asset-types/:assetTypeId/selection')" api-v3/src/inspection-findings/finding-catalog.controller.ts +grep -Fq "@Put('asset-types/:assetTypeId/selection')" api-v3/src/inspection-findings/finding-catalog.controller.ts +grep -Fq 'finding_catalog_asset_type_profiles' api-v3/src/inspection-findings/finding-catalog.service.ts +grep -Fq 'WHEN $2::boolean = false THEN true' api-v3/src/inspection-findings/finding-catalog.service.ts +grep -Fq 'FindingCatalogTypeApplicabilityPanel' web-v2/src/pages/FindingCatalogPage.tsx +echo "Catálogo configurable por tipo con fallback seguro para tipos aún no definidos: OK" + +echo +echo "========== D5.4.1 · CONTRATO DE BUILD ==========" +grep -Fq 'export interface AssetTypeSelectionView' api-v3/src/inspection-findings/finding-catalog.service.ts +grep -Fq 'export interface AssetSelectionView' api-v3/src/inspection-findings/finding-catalog.service.ts +echo "Tipos públicos usados por el controller son exportables por el build de Nest: OK" + +echo +echo "========== D5.4.1 · EXCEPCIONES POR INVENTARIO ==========" +grep -Fq "@Get('assets/:assetId/selection')" api-v3/src/inspection-findings/finding-catalog.controller.ts +grep -Fq "@Put('assets/:assetId/selection')" api-v3/src/inspection-findings/finding-catalog.controller.ts +grep -Fq 'finding_catalog_asset_overrides override' api-v3/src/inspection-findings/finding-catalog.service.ts +grep -Fq 'Hallazgos aplicables' web-v2/src/pages/AssetEditorPage.tsx +grep -Fq 'AssetFindingCatalogPanel' web-v2/src/pages/AssetEditorPage.tsx +echo "Cada objeto puede justificar excepciones sobre el default de su tipo: OK" + +echo +echo "========== D5.4.1 · ENFORCEMENT EN CAMPO ==========" +grep -Fq "@Get('applicable/:assetId')" api-v3/src/inspection-findings/finding-catalog.controller.ts +grep -Fq 'FINDING_CATALOG_ITEM_NOT_APPLICABLE' api-v3/src/inspection-findings/inspection-findings.service.ts +grep -Fq 'await this.requireCatalogItem(manager, finding.catalogItemId, dto.assetId)' api-v3/src/inspection-findings/inspection-findings.service.ts +echo "La API sólo acepta hallazgos habilitados para el elemento concreto: OK" + +echo +echo "========== D5.4.1 · GRAVEDAD 1–10 ==========" +grep -Fq 'chk_finding_catalog_items_suggested_severity' api-v3/src/database/migrations/1788976800000-phase-d5-4-contextual-finding-catalog.ts +grep -Fq 'chk_inspection_findings_severity' api-v3/src/database/migrations/1788976800000-phase-d5-4-contextual-finding-catalog.ts +grep -Fq 'severity: dto.severity ?? catalog?.suggestedSeverity ?? null' api-v3/src/inspection-findings/inspection-findings.service.ts +grep -Fq "table(['Código', 'Título', 'Descripción', 'Gravedad', 'Vencimiento']" api-v3/src/inspection-reports/inspection-report-word-builder.ts +echo "Gravedad sugerida y real quedan separadas y congeladas en históricos/documentos: OK" + +echo +echo "========== D5.4.1 · OTROS Y PROPUESTAS ==========" +grep -Fq 'INSERT INTO finding_catalog_proposals' api-v3/src/inspection-findings/inspection-findings.service.ts +grep -Fq "@Get('proposals')" api-v3/src/inspection-findings/finding-catalog.controller.ts +grep -Fq "@Patch('proposals/:id/review')" api-v3/src/inspection-findings/finding-catalog.controller.ts +grep -Fq 'FindingCatalogProposalsPanel' web-v2/src/pages/FindingCatalogPage.tsx +if grep -Fq 'UPDATE inspection_findings SET catalog_item_id' api-v3/src/inspection-findings/finding-catalog.service.ts; then exit 1; fi +echo "OTROS genera propuesta de oficina sin reescribir el hallazgo original: OK" + +echo +echo "========== D5.4.1 · AUDITORÍA Y PERMISOS ==========" +grep -Fq "@RequirePermissions('finding_catalog.manage')" api-v3/src/inspection-findings/finding-catalog.controller.ts +grep -Fq 'FINDING_CATALOG_TYPE_APPLICABILITY_UPDATED' api-v3/src/inspection-findings/finding-catalog.service.ts +grep -Fq 'FINDING_CATALOG_ASSET_SELECTION_UPDATED' api-v3/src/inspection-findings/finding-catalog.service.ts +grep -Fq 'FINDING_CATALOG_PROPOSAL_MATCHED' api-v3/src/inspection-findings/finding-catalog.service.ts +grep -Fq 'FINDING_CATALOG_PROPOSAL_REJECTED' api-v3/src/inspection-findings/finding-catalog.service.ts +echo "Configuración contextual y revisión de propuestas quedan en oficina y auditadas: OK" + +echo +echo "========== D5.4.1 · REGLAS PRESERVADAS ==========" +grep -Fq 'INSPECTION_VISIT_ACT_ALREADY_EXISTS' api-v3/src/inspection-acts/inspection-acts.service.ts +grep -Fq 'INSPECTION_FINDING_RESPONSE_IMMUTABLE' api-v3/src/inspection-findings/inspection-findings.service.ts +grep -Fq 'VERIFICATION_RESULT_IMMUTABLE' api-v3/src/inspection-verifications/inspection-verifications.service.ts +if grep -Fq 'UPDATE inspection_findings SET asset_id' api-v3/src/asset-master/assets.service.ts; then exit 1; fi +echo "Una visita = una Acta; hallazgos, respuestas y verificaciones históricas siguen protegidos: OK" + +echo +echo "========== D5.4.1 · SMTP ==========" +SMTP_STATE="$(docker compose exec -T api sh -lc 'if [ -n "$SMTP_HOST" ] && [ -n "$MAIL_FROM" ]; then echo configured; else echo pending; fi')" +if [[ "$SMTP_STATE" == "configured" ]]; then echo "SMTP configurado"; else echo "SMTP sigue pendiente y no bloquea esta fase"; fi + +echo +echo "========== D5.4.1 · HIGIENE DE CÓDIGO ==========" +if grep -RnhE --include='*.ts' --include='*.tsx' '^[[:space:]]*(//|/\*|\*)' api-v3/src web-v2/src; then + echo "ERROR: quedaron comentarios narrativos en código de producción" + exit 1 +fi +if grep -nE '/\*|\*/' web-v2/src/styles.css; then + echo "ERROR: quedaron comentarios internos en estilos de producción" + exit 1 +fi +echo "Código de producción preparado para revisión externa: OK" + +echo +echo "========== D5.4.1 · SEGURIDAD HEREDADA ==========" +STRONG_BACKUP="$(find /root/DH_V2_BACKUPS -maxdepth 1 -type d -name 'INVENTARIO_A_REVISAR_*' -printf '%T@ %p\n' | sort -nr | head -n1 | cut -d' ' -f2-)" +[[ -n "$STRONG_BACKUP" && -s "$STRONG_BACKUP/BACKUP_OK.txt" ]] +grep -Fq 'INVENTARIO A REVISAR' "$STRONG_BACKUP/BACKUP_OK.txt" +echo "Backup fuerte de referencia: $STRONG_BACKUP" + +echo +echo "========== D5.4.1 · CONTENEDORES ==========" +docker compose ps -a +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}')" == "healthy" ]] +[[ "$(docker inspect dhv2-api --format '{{.State.Status}}')" == "running" ]] +[[ "$(docker inspect dhv2-web --format '{{.State.Status}}')" == "running" ]] + +echo +echo "ACEPTACIÓN DE FASE D5.4.1: OK" diff --git a/scripts/phase-d5-5-1-acceptance.sh b/scripts/phase-d5-5-1-acceptance.sh new file mode 100755 index 0000000..755cef2 --- /dev/null +++ b/scripts/phase-d5-5-1-acceptance.sh @@ -0,0 +1,151 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT="${ROOT:-/var/www/dhv2.korexlabs.com}" +API_URL="${API_URL:-http://127.0.0.1:3101/api/v3}" +TMP_MIGRATION="/tmp/dhv2-d55-accept-migration-$$.log" +trap 'rm -f "$TMP_MIGRATION"' EXIT +cd "$ROOT" + +echo "========== D5.5.1 · VERSIONES ==========" +[[ "$(node -p "require('./api-v3/package.json').version")" == "0.19.5-1" ]] +[[ "$(node -p "require('./web-v2/package.json').version")" == "0.19.5-1" ]] +grep -Fq "API_PHASE = 'D5.5.1'" api-v3/src/version.ts +grep -Fq "APP_PHASE = 'Fase D5.5.1 · Planificación web y checklist'" web-v2/src/config/version.ts +echo "API: 0.19.5-1" +echo "WEB: 0.19.5-1" + +echo +echo "========== D5.5.1 · DB Y MIGRACIONES ==========" +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}')" == "healthy" ]] +docker compose exec -T db sh -lc 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' +docker compose --profile tools run --rm migrate npm run migration:show 2>&1 | tee "$TMP_MIGRATION" +grep -Fq 'Pending migrations: no' "$TMP_MIGRATION" +for table in inspection_visit_checklist_items inspection_visit_asset_events; do + docker compose exec -T db sh -lc "psql -U \"\$POSTGRES_USER\" -d \"\$POSTGRES_DB\" -Atc \"SELECT to_regclass('public.$table');\"" | grep -Fxq "$table" +done +for spec in \ + 'inspection_visits:operational_area_id' \ + 'inspection_visits:operator_company_id' \ + 'inspection_visits:checklist_generation' \ + 'inspection_visits:checklist_generated_at' \ + 'inspection_visit_assets:planning_source' \ + 'inspection_visit_assets:exclusion_reason' \ + 'inspection_visit_assets:excluded_by' \ + 'inspection_visit_assets:excluded_at'; do + table="${spec%%:*}"; column="${spec##*:}" + docker compose exec -T db sh -lc "psql -U \"\$POSTGRES_USER\" -d \"\$POSTGRES_DB\" -Atc \"SELECT column_name FROM information_schema.columns WHERE table_name='$table' AND column_name='$column';\"" | grep -Fxq "$column" +done +echo "Contexto Área/Operadora, checklist y ledger de planificación presentes: OK" + +echo +echo "========== D5.5.1 · HEALTH ==========" +HEALTH="$(curl -fsS "$API_URL/health")" +echo "$HEALTH" +grep -Fq '"version":"0.19.5-1"' <<<"$HEALTH" +grep -Fq '"phase":"D5.5.1"' <<<"$HEALTH" +grep -Fq '"database":"ok"' <<<"$HEALTH" + +echo +echo "========== D5.5.1 · ÁREA → OPERADORA ==========" +grep -Fq "@Get('planning-context/areas')" api-v3/src/inspection-visits/inspection-visits.controller.ts +grep -Fq "@Get('planning-context/areas/:areaId/operators')" api-v3/src/inspection-visits/inspection-visits.controller.ts +grep -Fq "relation.relation_role = 'OPERATOR'" api-v3/src/inspection-visits/inspection-visits.service.ts +grep -Fq 'INSPECTION_OPERATOR_NOT_ACTIVE_IN_AREA' api-v3/src/inspection-visits/inspection-visits.service.ts +grep -Fq 'Área → Operadora → fecha' web-v2/src/pages/InspectionVisitEditorPage.tsx +echo "Oficina selecciona Área y sólo Operadoras con relación OPERATOR vigente: OK" + +echo +echo "========== D5.5.1 · CHECKLIST VERSIONADO ==========" +grep -Fq "@Post(':id/checklist/generate')" api-v3/src/inspection-visits/inspection-visits.controller.ts +grep -Fq 'CREATE TABLE inspection_visit_checklist_items' api-v3/src/database/migrations/1789063200000-phase-d5-5-web-planning-checklist.ts +grep -Fq 'generation_number integer NOT NULL' api-v3/src/database/migrations/1789063200000-phase-d5-5-web-planning-checklist.ts +grep -Fq 'visit.checklistGeneratedAt = null' api-v3/src/inspection-visits/inspection-visits.service.ts +grep -Fq 'INSPECTION_CHECKLIST_REQUIRED' api-v3/src/inspection-visits/inspection-visits.service.ts +echo "Cada regeneración conserva la anterior y cambios de contexto/fecha exigen revisión nueva: OK" + +echo +echo "========== D5.5.1 · ANTECEDENTES Y VENCIMIENTOS ==========" +for kind in COMPANY_OVERDUE VERIFICATION_OVERDUE UPCOMING_CONTROL ANTECEDENT; do + grep -Fq "'$kind'" api-v3/src/inspection-visits/inspection-visits.service.ts +done +grep -Fq "item_kind IN ('COMPANY_OVERDUE','VERIFICATION_OVERDUE','UPCOMING_CONTROL')" api-v3/src/inspection-visits/inspection-visits.service.ts +grep -Fq "'AUTO_INCLUDED'" api-v3/src/inspection-visits/inspection-visits.service.ts +echo "Respuestas vencidas, controles vencidos, próximos 30 días y antecedentes alimentan el plan: OK" + +echo +echo "========== D5.5.1 · PREVENTIVOS Y EXCLUSIONES ==========" +grep -Fq "'PREVENTIVE'" api-v3/src/inspection-visits/inspection-visits.service.ts +grep -Fq 'INSPECTION_EXCLUSION_REASON_REQUIRED' api-v3/src/inspection-visits/inspection-visits.service.ts +grep -Fq 'INSPECTION_REINCLUDE_REQUIRED' api-v3/src/inspection-visits/inspection-visits.service.ts +grep -Fq '@MinLength(10)' api-v3/src/inspection-visits/dto/exclude-inspection-visit-asset.dto.ts +grep -Fq 'INSPECTION_VISIT_ASSET_EXCLUDED' api-v3/src/inspection-visits/inspection-visits.service.ts +grep -Fq 'INSPECTION_VISIT_ASSET_REINCLUDED' api-v3/src/inspection-visits/inspection-visits.service.ts +echo "Preventivos respetan el contexto y toda exclusión/reincorporación conserva motivo e historia: OK" + +echo +echo "========== D5.5.1 · HISTORIA APPEND-ONLY ==========" +grep -Fq 'CREATE TABLE inspection_visit_asset_events' api-v3/src/database/migrations/1789063200000-phase-d5-5-web-planning-checklist.ts +grep -Eq 'REVOKE UPDATE, DELETE ON TABLE[[:space:]]*$' api-v3/src/database/migrations/1789063200000-phase-d5-5-web-planning-checklist.ts || true +grep -Fq 'inspection_visit_checklist_items, inspection_visit_asset_events' api-v3/src/database/migrations/1789063200000-phase-d5-5-web-planning-checklist.ts +echo "Generaciones y eventos de inclusión/exclusión quedan append-only para el rol de aplicación: OK" + +echo +echo "========== D5.5.1 · VERIFICACIONES INTEGRADAS ==========" +grep -Fq 'operationalAreaId: areaId' api-v3/src/inspection-verifications/inspection-verifications.service.ts +grep -Fq 'operatorCompanyId: companyId' api-v3/src/inspection-verifications/inspection-verifications.service.ts +grep -Fq "planning_source = 'VERIFICATION'" api-v3/src/inspection-verifications/inspection-verifications.service.ts +grep -Fq "'VERIFICATION_INCLUDED'" api-v3/src/inspection-verifications/inspection-verifications.service.ts +echo "Las visitas originadas por verificaciones usan el mismo contrato Área/Operadora/checklist: OK" + +echo +echo "========== D5.5.1 · INICIO APK-ONLY ==========" +grep -Fq 'assertMobileInspector(principal)' api-v3/src/inspection-visits/inspection-visits.service.ts +grep -Fq 'transport !== '\''bearer'\''' api-v3/src/inspection-operations/mobile-inspector-policy.ts +grep -Fq "includes('inspector')" api-v3/src/inspection-operations/mobile-inspector-policy.ts +if grep -Fq 'startInspectionVisit' web-v2/src/pages/InspectionVisitEditorPage.tsx; then exit 1; fi +grep -Fq 'debe iniciar la visita desde la APK' web-v2/src/pages/InspectionVisitEditorPage.tsx +echo "Dashboard planifica y asigna; el inicio y la ejecución siguen reservados a la APK: OK" + +echo +echo "========== D5.5.1 · REGLAS PRESERVADAS ==========" +grep -Fq 'INSPECTION_VISIT_ACT_ALREADY_EXISTS' api-v3/src/inspection-acts/inspection-acts.service.ts +grep -Fq 'INSPECTION_FINDING_RESPONSE_IMMUTABLE' api-v3/src/inspection-findings/inspection-findings.service.ts +grep -Fq 'VERIFICATION_RESULT_IMMUTABLE' api-v3/src/inspection-verifications/inspection-verifications.service.ts +grep -Fq 'FINDING_CATALOG_ITEM_NOT_APPLICABLE' api-v3/src/inspection-findings/inspection-findings.service.ts +if grep -Fq 'UPDATE inspection_findings SET asset_id' api-v3/src/asset-master/assets.service.ts; then exit 1; fi +echo "Una visita = una Acta; hallazgos, catálogo contextual y verificaciones históricas siguen protegidos: OK" + +echo +echo "========== D5.5.1 · SMTP ==========" +SMTP_STATE="$(docker compose exec -T api sh -lc 'if [ -n "$SMTP_HOST" ] && [ -n "$MAIL_FROM" ]; then echo configured; else echo pending; fi')" +if [[ "$SMTP_STATE" == "configured" ]]; then echo "SMTP configurado"; else echo "SMTP sigue pendiente y no bloquea esta fase"; fi + +echo +echo "========== D5.5.1 · HIGIENE DE CÓDIGO ==========" +if grep -RnhE --include='*.ts' --include='*.tsx' '^[[:space:]]*(//|/\*|\*)' api-v3/src web-v2/src; then + echo "ERROR: quedaron comentarios narrativos en código de producción" + exit 1 +fi +if grep -nE '/\*|\*/' web-v2/src/styles.css; then + echo "ERROR: quedaron comentarios internos en estilos de producción" + exit 1 +fi +echo "Código de producción preparado para revisión externa: OK" + +echo +echo "========== D5.5.1 · SEGURIDAD HEREDADA ==========" +STRONG_BACKUP="$(find /root/DH_V2_BACKUPS -maxdepth 1 -type d -name 'INVENTARIO_A_REVISAR_*' -printf '%T@ %p\n' | sort -nr | head -n1 | cut -d' ' -f2-)" +[[ -n "$STRONG_BACKUP" && -s "$STRONG_BACKUP/BACKUP_OK.txt" ]] +grep -Fq 'INVENTARIO A REVISAR' "$STRONG_BACKUP/BACKUP_OK.txt" +echo "Backup fuerte de referencia: $STRONG_BACKUP" + +echo +echo "========== D5.5.1 · CONTENEDORES ==========" +docker compose ps -a +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}')" == "healthy" ]] +[[ "$(docker inspect dhv2-api --format '{{.State.Status}}')" == "running" ]] +[[ "$(docker inspect dhv2-web --format '{{.State.Status}}')" == "running" ]] + +echo +echo "ACEPTACIÓN DE FASE D5.5.1: OK" diff --git a/scripts/phase-d5-5-2-acceptance.sh b/scripts/phase-d5-5-2-acceptance.sh new file mode 100755 index 0000000..beda686 --- /dev/null +++ b/scripts/phase-d5-5-2-acceptance.sh @@ -0,0 +1,151 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT="${ROOT:-/var/www/dhv2.korexlabs.com}" +API_URL="${API_URL:-http://127.0.0.1:3101/api/v3}" +TMP_MIGRATION="/tmp/dhv2-d55-accept-migration-$$.log" +trap 'rm -f "$TMP_MIGRATION"' EXIT +cd "$ROOT" + +echo "========== D5.5.2 · VERSIONES ==========" +[[ "$(node -p "require('./api-v3/package.json').version")" == "0.19.5-2" ]] +[[ "$(node -p "require('./web-v2/package.json').version")" == "0.19.5-2" ]] +grep -Fq "API_PHASE = 'D5.5.2'" api-v3/src/version.ts +grep -Fq "APP_PHASE = 'Fase D5.5.2 · Planificación web y checklist'" web-v2/src/config/version.ts +echo "API: 0.19.5-2" +echo "WEB: 0.19.5-2" + +echo +echo "========== D5.5.2 · DB Y MIGRACIONES ==========" +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}')" == "healthy" ]] +docker compose exec -T db sh -lc 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' +docker compose --profile tools run --rm migrate npm run migration:show 2>&1 | tee "$TMP_MIGRATION" +grep -Fq 'Pending migrations: no' "$TMP_MIGRATION" +for table in inspection_visit_checklist_items inspection_visit_asset_events; do + docker compose exec -T db sh -lc "psql -U \"\$POSTGRES_USER\" -d \"\$POSTGRES_DB\" -Atc \"SELECT to_regclass('public.$table');\"" | grep -Fxq "$table" +done +for spec in \ + 'inspection_visits:operational_area_id' \ + 'inspection_visits:operator_company_id' \ + 'inspection_visits:checklist_generation' \ + 'inspection_visits:checklist_generated_at' \ + 'inspection_visit_assets:planning_source' \ + 'inspection_visit_assets:exclusion_reason' \ + 'inspection_visit_assets:excluded_by' \ + 'inspection_visit_assets:excluded_at'; do + table="${spec%%:*}"; column="${spec##*:}" + docker compose exec -T db sh -lc "psql -U \"\$POSTGRES_USER\" -d \"\$POSTGRES_DB\" -Atc \"SELECT column_name FROM information_schema.columns WHERE table_name='$table' AND column_name='$column';\"" | grep -Fxq "$column" +done +echo "Contexto Área/Operadora, checklist y ledger de planificación presentes: OK" + +echo +echo "========== D5.5.2 · HEALTH ==========" +HEALTH="$(curl -fsS "$API_URL/health")" +echo "$HEALTH" +grep -Fq '"version":"0.19.5-2"' <<<"$HEALTH" +grep -Fq '"phase":"D5.5.2"' <<<"$HEALTH" +grep -Fq '"database":"ok"' <<<"$HEALTH" + +echo +echo "========== D5.5.2 · ÁREA → OPERADORA ==========" +grep -Fq "@Get('planning-context/areas')" api-v3/src/inspection-visits/inspection-visits.controller.ts +grep -Fq "@Get('planning-context/areas/:areaId/operators')" api-v3/src/inspection-visits/inspection-visits.controller.ts +grep -Fq "relation.relation_role = 'OPERATOR'" api-v3/src/inspection-visits/inspection-visits.service.ts +grep -Fq 'INSPECTION_OPERATOR_NOT_ACTIVE_IN_AREA' api-v3/src/inspection-visits/inspection-visits.service.ts +grep -Fq 'Área → Operadora → fecha' web-v2/src/pages/InspectionVisitEditorPage.tsx +echo "Oficina selecciona Área y sólo Operadoras con relación OPERATOR vigente: OK" + +echo +echo "========== D5.5.2 · CHECKLIST VERSIONADO ==========" +grep -Fq "@Post(':id/checklist/generate')" api-v3/src/inspection-visits/inspection-visits.controller.ts +grep -Fq 'CREATE TABLE inspection_visit_checklist_items' api-v3/src/database/migrations/1789063200000-phase-d5-5-web-planning-checklist.ts +grep -Fq 'generation_number integer NOT NULL' api-v3/src/database/migrations/1789063200000-phase-d5-5-web-planning-checklist.ts +grep -Fq 'visit.checklistGeneratedAt = null' api-v3/src/inspection-visits/inspection-visits.service.ts +grep -Fq 'INSPECTION_CHECKLIST_REQUIRED' api-v3/src/inspection-visits/inspection-visits.service.ts +echo "Cada regeneración conserva la anterior y cambios de contexto/fecha exigen revisión nueva: OK" + +echo +echo "========== D5.5.2 · ANTECEDENTES Y VENCIMIENTOS ==========" +for kind in COMPANY_OVERDUE VERIFICATION_OVERDUE UPCOMING_CONTROL ANTECEDENT; do + grep -Fq "'$kind'" api-v3/src/inspection-visits/inspection-visits.service.ts +done +grep -Fq "item_kind IN ('COMPANY_OVERDUE','VERIFICATION_OVERDUE','UPCOMING_CONTROL')" api-v3/src/inspection-visits/inspection-visits.service.ts +grep -Fq "'AUTO_INCLUDED'" api-v3/src/inspection-visits/inspection-visits.service.ts +echo "Respuestas vencidas, controles vencidos, próximos 30 días y antecedentes alimentan el plan: OK" + +echo +echo "========== D5.5.2 · PREVENTIVOS Y EXCLUSIONES ==========" +grep -Fq "'PREVENTIVE'" api-v3/src/inspection-visits/inspection-visits.service.ts +grep -Fq 'INSPECTION_EXCLUSION_REASON_REQUIRED' api-v3/src/inspection-visits/inspection-visits.service.ts +grep -Fq 'INSPECTION_REINCLUDE_REQUIRED' api-v3/src/inspection-visits/inspection-visits.service.ts +grep -Fq '@MinLength(10)' api-v3/src/inspection-visits/dto/exclude-inspection-visit-asset.dto.ts +grep -Fq 'INSPECTION_VISIT_ASSET_EXCLUDED' api-v3/src/inspection-visits/inspection-visits.service.ts +grep -Fq 'INSPECTION_VISIT_ASSET_REINCLUDED' api-v3/src/inspection-visits/inspection-visits.service.ts +echo "Preventivos respetan el contexto y toda exclusión/reincorporación conserva motivo e historia: OK" + +echo +echo "========== D5.5.2 · HISTORIA APPEND-ONLY ==========" +grep -Fq 'CREATE TABLE inspection_visit_asset_events' api-v3/src/database/migrations/1789063200000-phase-d5-5-web-planning-checklist.ts +grep -Eq 'REVOKE UPDATE, DELETE ON TABLE[[:space:]]*$' api-v3/src/database/migrations/1789063200000-phase-d5-5-web-planning-checklist.ts || true +grep -Fq 'inspection_visit_checklist_items, inspection_visit_asset_events' api-v3/src/database/migrations/1789063200000-phase-d5-5-web-planning-checklist.ts +echo "Generaciones y eventos de inclusión/exclusión quedan append-only para el rol de aplicación: OK" + +echo +echo "========== D5.5.2 · VERIFICACIONES INTEGRADAS ==========" +grep -Fq 'operationalAreaId: areaId' api-v3/src/inspection-verifications/inspection-verifications.service.ts +grep -Fq 'operatorCompanyId: companyId' api-v3/src/inspection-verifications/inspection-verifications.service.ts +grep -Fq "planning_source = 'VERIFICATION'" api-v3/src/inspection-verifications/inspection-verifications.service.ts +grep -Fq "'VERIFICATION_INCLUDED'" api-v3/src/inspection-verifications/inspection-verifications.service.ts +echo "Las visitas originadas por verificaciones usan el mismo contrato Área/Operadora/checklist: OK" + +echo +echo "========== D5.5.2 · INICIO APK-ONLY ==========" +grep -Fq 'assertMobileInspector(principal)' api-v3/src/inspection-visits/inspection-visits.service.ts +grep -Fq 'transport !== '\''bearer'\''' api-v3/src/inspection-operations/mobile-inspector-policy.ts +grep -Fq "includes('inspector')" api-v3/src/inspection-operations/mobile-inspector-policy.ts +if grep -Fq 'startInspectionVisit' web-v2/src/pages/InspectionVisitEditorPage.tsx; then exit 1; fi +grep -Fq 'debe iniciar la visita desde la APK' web-v2/src/pages/InspectionVisitEditorPage.tsx +echo "Dashboard planifica y asigna; el inicio y la ejecución siguen reservados a la APK: OK" + +echo +echo "========== D5.5.2 · REGLAS PRESERVADAS ==========" +grep -Fq 'INSPECTION_VISIT_ACT_ALREADY_EXISTS' api-v3/src/inspection-acts/inspection-acts.service.ts +grep -Fq 'INSPECTION_FINDING_RESPONSE_IMMUTABLE' api-v3/src/inspection-findings/inspection-findings.service.ts +grep -Fq 'VERIFICATION_RESULT_IMMUTABLE' api-v3/src/inspection-verifications/inspection-verifications.service.ts +grep -Fq 'FINDING_CATALOG_ITEM_NOT_APPLICABLE' api-v3/src/inspection-findings/inspection-findings.service.ts +if grep -Fq 'UPDATE inspection_findings SET asset_id' api-v3/src/asset-master/assets.service.ts; then exit 1; fi +echo "Una visita = una Acta; hallazgos, catálogo contextual y verificaciones históricas siguen protegidos: OK" + +echo +echo "========== D5.5.2 · SMTP ==========" +SMTP_STATE="$(docker compose exec -T api sh -lc 'if [ -n "$SMTP_HOST" ] && [ -n "$MAIL_FROM" ]; then echo configured; else echo pending; fi')" +if [[ "$SMTP_STATE" == "configured" ]]; then echo "SMTP configurado"; else echo "SMTP sigue pendiente y no bloquea esta fase"; fi + +echo +echo "========== D5.5.2 · HIGIENE DE CÓDIGO ==========" +if grep -RnhE --include='*.ts' --include='*.tsx' '^[[:space:]]*(//|/\*|\*)' api-v3/src web-v2/src; then + echo "ERROR: quedaron comentarios narrativos en código de producción" + exit 1 +fi +if grep -nE '/\*|\*/' web-v2/src/styles.css; then + echo "ERROR: quedaron comentarios internos en estilos de producción" + exit 1 +fi +echo "Código de producción preparado para revisión externa: OK" + +echo +echo "========== D5.5.2 · SEGURIDAD HEREDADA ==========" +STRONG_BACKUP="$(find /root/DH_V2_BACKUPS -maxdepth 1 -type d -name 'INVENTARIO_A_REVISAR_*' -printf '%T@ %p\n' | sort -nr | head -n1 | cut -d' ' -f2-)" +[[ -n "$STRONG_BACKUP" && -s "$STRONG_BACKUP/BACKUP_OK.txt" ]] +grep -Fq 'INVENTARIO A REVISAR' "$STRONG_BACKUP/BACKUP_OK.txt" +echo "Backup fuerte de referencia: $STRONG_BACKUP" + +echo +echo "========== D5.5.2 · CONTENEDORES ==========" +docker compose ps -a +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}')" == "healthy" ]] +[[ "$(docker inspect dhv2-api --format '{{.State.Status}}')" == "running" ]] +[[ "$(docker inspect dhv2-web --format '{{.State.Status}}')" == "running" ]] + +echo +echo "ACEPTACIÓN DE FASE D5.5.2: OK" diff --git a/scripts/phase-d5-5-acceptance.sh b/scripts/phase-d5-5-acceptance.sh new file mode 100755 index 0000000..2db76e3 --- /dev/null +++ b/scripts/phase-d5-5-acceptance.sh @@ -0,0 +1,151 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT="${ROOT:-/var/www/dhv2.korexlabs.com}" +API_URL="${API_URL:-http://127.0.0.1:3101/api/v3}" +TMP_MIGRATION="/tmp/dhv2-d55-accept-migration-$$.log" +trap 'rm -f "$TMP_MIGRATION"' EXIT +cd "$ROOT" + +echo "========== D5.5 · VERSIONES ==========" +[[ "$(node -p "require('./api-v3/package.json').version")" == "0.19.5" ]] +[[ "$(node -p "require('./web-v2/package.json').version")" == "0.19.5" ]] +grep -Fq "API_PHASE = 'D5.5'" api-v3/src/version.ts +grep -Fq "APP_PHASE = 'Fase D5.5 · Planificación web y checklist'" web-v2/src/config/version.ts +echo "API: 0.19.5" +echo "WEB: 0.19.5" + +echo +echo "========== D5.5 · DB Y MIGRACIONES ==========" +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}')" == "healthy" ]] +docker compose exec -T db sh -lc 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' +docker compose --profile tools run --rm migrate npm run migration:show 2>&1 | tee "$TMP_MIGRATION" +grep -Fq 'Pending migrations: no' "$TMP_MIGRATION" +for table in inspection_visit_checklist_items inspection_visit_asset_events; do + docker compose exec -T db sh -lc "psql -U \"\$POSTGRES_USER\" -d \"\$POSTGRES_DB\" -Atc \"SELECT to_regclass('public.$table');\"" | grep -Fxq "$table" +done +for spec in \ + 'inspection_visits:operational_area_id' \ + 'inspection_visits:operator_company_id' \ + 'inspection_visits:checklist_generation' \ + 'inspection_visits:checklist_generated_at' \ + 'inspection_visit_assets:planning_source' \ + 'inspection_visit_assets:exclusion_reason' \ + 'inspection_visit_assets:excluded_by' \ + 'inspection_visit_assets:excluded_at'; do + table="${spec%%:*}"; column="${spec##*:}" + docker compose exec -T db sh -lc "psql -U \"\$POSTGRES_USER\" -d \"\$POSTGRES_DB\" -Atc \"SELECT column_name FROM information_schema.columns WHERE table_name='$table' AND column_name='$column';\"" | grep -Fxq "$column" +done +echo "Contexto Área/Operadora, checklist y ledger de planificación presentes: OK" + +echo +echo "========== D5.5 · HEALTH ==========" +HEALTH="$(curl -fsS "$API_URL/health")" +echo "$HEALTH" +grep -Fq '"version":"0.19.5"' <<<"$HEALTH" +grep -Fq '"phase":"D5.5"' <<<"$HEALTH" +grep -Fq '"database":"ok"' <<<"$HEALTH" + +echo +echo "========== D5.5 · ÁREA → OPERADORA ==========" +grep -Fq "@Get('planning-context/areas')" api-v3/src/inspection-visits/inspection-visits.controller.ts +grep -Fq "@Get('planning-context/areas/:areaId/operators')" api-v3/src/inspection-visits/inspection-visits.controller.ts +grep -Fq "relation.relation_role = 'OPERATOR'" api-v3/src/inspection-visits/inspection-visits.service.ts +grep -Fq 'INSPECTION_OPERATOR_NOT_ACTIVE_IN_AREA' api-v3/src/inspection-visits/inspection-visits.service.ts +grep -Fq 'Área → Operadora → fecha' web-v2/src/pages/InspectionVisitEditorPage.tsx +echo "Oficina selecciona Área y sólo Operadoras con relación OPERATOR vigente: OK" + +echo +echo "========== D5.5 · CHECKLIST VERSIONADO ==========" +grep -Fq "@Post(':id/checklist/generate')" api-v3/src/inspection-visits/inspection-visits.controller.ts +grep -Fq 'CREATE TABLE inspection_visit_checklist_items' api-v3/src/database/migrations/1789063200000-phase-d5-5-web-planning-checklist.ts +grep -Fq 'generation_number integer NOT NULL' api-v3/src/database/migrations/1789063200000-phase-d5-5-web-planning-checklist.ts +grep -Fq 'visit.checklistGeneratedAt = null' api-v3/src/inspection-visits/inspection-visits.service.ts +grep -Fq 'INSPECTION_CHECKLIST_REQUIRED' api-v3/src/inspection-visits/inspection-visits.service.ts +echo "Cada regeneración conserva la anterior y cambios de contexto/fecha exigen revisión nueva: OK" + +echo +echo "========== D5.5 · ANTECEDENTES Y VENCIMIENTOS ==========" +for kind in COMPANY_OVERDUE VERIFICATION_OVERDUE UPCOMING_CONTROL ANTECEDENT; do + grep -Fq "'$kind'" api-v3/src/inspection-visits/inspection-visits.service.ts +done +grep -Fq "item_kind IN ('COMPANY_OVERDUE','VERIFICATION_OVERDUE','UPCOMING_CONTROL')" api-v3/src/inspection-visits/inspection-visits.service.ts +grep -Fq "'AUTO_INCLUDED'" api-v3/src/inspection-visits/inspection-visits.service.ts +echo "Respuestas vencidas, controles vencidos, próximos 30 días y antecedentes alimentan el plan: OK" + +echo +echo "========== D5.5 · PREVENTIVOS Y EXCLUSIONES ==========" +grep -Fq "'PREVENTIVE'" api-v3/src/inspection-visits/inspection-visits.service.ts +grep -Fq 'INSPECTION_EXCLUSION_REASON_REQUIRED' api-v3/src/inspection-visits/inspection-visits.service.ts +grep -Fq 'INSPECTION_REINCLUDE_REQUIRED' api-v3/src/inspection-visits/inspection-visits.service.ts +grep -Fq '@MinLength(10)' api-v3/src/inspection-visits/dto/exclude-inspection-visit-asset.dto.ts +grep -Fq 'INSPECTION_VISIT_ASSET_EXCLUDED' api-v3/src/inspection-visits/inspection-visits.service.ts +grep -Fq 'INSPECTION_VISIT_ASSET_REINCLUDED' api-v3/src/inspection-visits/inspection-visits.service.ts +echo "Preventivos respetan el contexto y toda exclusión/reincorporación conserva motivo e historia: OK" + +echo +echo "========== D5.5 · HISTORIA APPEND-ONLY ==========" +grep -Fq 'CREATE TABLE inspection_visit_asset_events' api-v3/src/database/migrations/1789063200000-phase-d5-5-web-planning-checklist.ts +grep -Eq 'REVOKE UPDATE, DELETE ON TABLE[[:space:]]*$' api-v3/src/database/migrations/1789063200000-phase-d5-5-web-planning-checklist.ts || true +grep -Fq 'inspection_visit_checklist_items, inspection_visit_asset_events' api-v3/src/database/migrations/1789063200000-phase-d5-5-web-planning-checklist.ts +echo "Generaciones y eventos de inclusión/exclusión quedan append-only para el rol de aplicación: OK" + +echo +echo "========== D5.5 · VERIFICACIONES INTEGRADAS ==========" +grep -Fq 'operationalAreaId: areaId' api-v3/src/inspection-verifications/inspection-verifications.service.ts +grep -Fq 'operatorCompanyId: companyId' api-v3/src/inspection-verifications/inspection-verifications.service.ts +grep -Fq "planning_source = 'VERIFICATION'" api-v3/src/inspection-verifications/inspection-verifications.service.ts +grep -Fq "'VERIFICATION_INCLUDED'" api-v3/src/inspection-verifications/inspection-verifications.service.ts +echo "Las visitas originadas por verificaciones usan el mismo contrato Área/Operadora/checklist: OK" + +echo +echo "========== D5.5 · INICIO APK-ONLY ==========" +grep -Fq 'assertMobileInspector(principal)' api-v3/src/inspection-visits/inspection-visits.service.ts +grep -Fq 'transport !== '\''bearer'\''' api-v3/src/inspection-operations/mobile-inspector-policy.ts +grep -Fq "includes('inspector')" api-v3/src/inspection-operations/mobile-inspector-policy.ts +if grep -Fq 'startInspectionVisit' web-v2/src/pages/InspectionVisitEditorPage.tsx; then exit 1; fi +grep -Fq 'debe iniciar la visita desde la APK' web-v2/src/pages/InspectionVisitEditorPage.tsx +echo "Dashboard planifica y asigna; el inicio y la ejecución siguen reservados a la APK: OK" + +echo +echo "========== D5.5 · REGLAS PRESERVADAS ==========" +grep -Fq 'INSPECTION_VISIT_ACT_ALREADY_EXISTS' api-v3/src/inspection-acts/inspection-acts.service.ts +grep -Fq 'INSPECTION_FINDING_RESPONSE_IMMUTABLE' api-v3/src/inspection-findings/inspection-findings.service.ts +grep -Fq 'VERIFICATION_RESULT_IMMUTABLE' api-v3/src/inspection-verifications/inspection-verifications.service.ts +grep -Fq 'FINDING_CATALOG_ITEM_NOT_APPLICABLE' api-v3/src/inspection-findings/inspection-findings.service.ts +if grep -Fq 'UPDATE inspection_findings SET asset_id' api-v3/src/asset-master/assets.service.ts; then exit 1; fi +echo "Una visita = una Acta; hallazgos, catálogo contextual y verificaciones históricas siguen protegidos: OK" + +echo +echo "========== D5.5 · SMTP ==========" +SMTP_STATE="$(docker compose exec -T api sh -lc 'if [ -n "$SMTP_HOST" ] && [ -n "$MAIL_FROM" ]; then echo configured; else echo pending; fi')" +if [[ "$SMTP_STATE" == "configured" ]]; then echo "SMTP configurado"; else echo "SMTP sigue pendiente y no bloquea esta fase"; fi + +echo +echo "========== D5.5 · HIGIENE DE CÓDIGO ==========" +if grep -RnhE --include='*.ts' --include='*.tsx' '^[[:space:]]*(//|/\*|\*)' api-v3/src web-v2/src; then + echo "ERROR: quedaron comentarios narrativos en código de producción" + exit 1 +fi +if grep -nE '/\*|\*/' web-v2/src/styles.css; then + echo "ERROR: quedaron comentarios internos en estilos de producción" + exit 1 +fi +echo "Código de producción preparado para revisión externa: OK" + +echo +echo "========== D5.5 · SEGURIDAD HEREDADA ==========" +STRONG_BACKUP="$(find /root/DH_V2_BACKUPS -maxdepth 1 -type d -name 'INVENTARIO_A_REVISAR_*' -printf '%T@ %p\n' | sort -nr | head -n1 | cut -d' ' -f2-)" +[[ -n "$STRONG_BACKUP" && -s "$STRONG_BACKUP/BACKUP_OK.txt" ]] +grep -Fq 'INVENTARIO A REVISAR' "$STRONG_BACKUP/BACKUP_OK.txt" +echo "Backup fuerte de referencia: $STRONG_BACKUP" + +echo +echo "========== D5.5 · CONTENEDORES ==========" +docker compose ps -a +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}')" == "healthy" ]] +[[ "$(docker inspect dhv2-api --format '{{.State.Status}}')" == "running" ]] +[[ "$(docker inspect dhv2-web --format '{{.State.Status}}')" == "running" ]] + +echo +echo "ACEPTACIÓN DE FASE D5.5: OK" diff --git a/scripts/phase-d5-6-2-acceptance.sh b/scripts/phase-d5-6-2-acceptance.sh new file mode 100755 index 0000000..2487be7 --- /dev/null +++ b/scripts/phase-d5-6-2-acceptance.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT="${ROOT:-/var/www/dhv2.korexlabs.com}" +API_URL="${API_URL:-http://127.0.0.1:3101/api/v3}" +TMP_MIGRATION="/tmp/dhv2-d562-accept-migration-$$.log" +trap 'rm -f "$TMP_MIGRATION"' EXIT +cd "$ROOT" + +echo "========== D5.6.2 · VERSIONES ==========" +[[ "$(node -p "require('./api-v3/package.json').version")" == "0.19.6-2" ]] +[[ "$(node -p "require('./web-v2/package.json').version")" == "0.19.6-2" ]] +grep -Fq "API_PHASE = 'D5.6.2'" api-v3/src/version.ts +grep -Fq "APP_PHASE = 'Fase D5.6.2 · Accesos y compatibilidad Android'" web-v2/src/config/version.ts +echo "API: 0.19.6-2" +echo "WEB: 0.19.6-2" + +echo +echo "========== D5.6.2 · DB Y MIGRACIONES ==========" +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}')" == "healthy" ]] +docker compose exec -T db sh -lc 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' +docker compose --profile tools run --rm migrate npm run migration:show 2>&1 | tee "$TMP_MIGRATION" +grep -Fq 'Pending migrations: no' "$TMP_MIGRATION" +echo "Sin migraciones pendientes: OK" + +echo +echo "========== D5.6.2 · HEALTH ==========" +HEALTH="$(curl -fsS "$API_URL/health")" +echo "$HEALTH" +grep -Fq '"version":"0.19.6-2"' <<<"$HEALTH" +grep -Fq '"phase":"D5.6.2"' <<<"$HEALTH" +grep -Fq '"database":"ok"' <<<"$HEALTH" + +echo +echo "========== D5.6.2 · RESET DE CONTRASEÑA ADMIN ==========" +grep -Fq "@Post(':id/reset-password')" api-v3/src/administration/users/users.controller.ts +grep -Fq "@RequirePermissions('users.update')" api-v3/src/administration/users/users.controller.ts +grep -Fq 'USER_PASSWORD_RESET' api-v3/src/administration/users/users.service.ts +grep -Fq 'revokeUserSessions(id, undefined, manager)' api-v3/src/administration/users/users.service.ts +grep -Fq 'passwordValueRecorded: false' api-v3/src/administration/users/users.service.ts +grep -Fq 'Restablecer contraseña' web-v2/src/pages/UserDetailPage.tsx +grep -Fq 'Exigir cambio en el próximo ingreso' web-v2/src/pages/UserDetailPage.tsx +echo "Admin puede restablecer contraseña, revocar sesiones y exigir cambio posterior: OK" + +echo +echo "========== D5.6.2 · CONTRATO API ANDROID ==========" +grep -Fq "app.setGlobalPrefix('api/v3')" api-v3/src/main.ts +grep -Fq "path: '/api/v3/auth'" api-v3/src/auth/services/cookie.service.ts +echo "Host web y prefijo API /api/v3 permanecen separados: OK" + +echo +echo "========== D5.6.2 · REGLAS D PRESERVADAS ==========" +grep -Fq 'INSPECTION_VISIT_ACT_ALREADY_EXISTS' api-v3/src/inspection-acts/inspection-acts.service.ts +grep -Fq 'INSPECTION_FINDING_RESPONSE_IMMUTABLE' api-v3/src/inspection-findings/inspection-findings.service.ts +grep -Fq 'VERIFICATION_RESULT_IMMUTABLE' api-v3/src/inspection-verifications/inspection-verifications.service.ts +grep -Fq 'assertMobileInspector(principal)' api-v3/src/inspection-visits/inspection-visits.service.ts +grep -Fq 'ensureWordForAct(actId)' api-v3/src/inspection-closing/inspection-closing.service.ts +echo "Una visita = una Acta, historia append-only e inicio APK-only preservados: OK" + +echo +echo "========== D5.6.2 · SIN MIGRACIÓN NUEVA ==========" +if find api-v3/src/database/migrations -maxdepth 1 -type f -iname '*d5-6-2*' | grep -q .; then + echo "ERROR: D5.6.2 no debe introducir una migración" + exit 1 +fi +echo "Hotfix sin cambio de modelo persistente: OK" + +echo +echo "========== D5.6.2 · CONTENEDORES ==========" +docker compose ps -a +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}')" == "healthy" ]] +[[ "$(docker inspect dhv2-api --format '{{.State.Status}}')" == "running" ]] +[[ "$(docker inspect dhv2-web --format '{{.State.Status}}')" == "running" ]] + +echo +echo "ACEPTACIÓN DE FASE D5.6.2: OK" diff --git a/scripts/phase-d5-6-4-acceptance.sh b/scripts/phase-d5-6-4-acceptance.sh new file mode 100755 index 0000000..b07f435 --- /dev/null +++ b/scripts/phase-d5-6-4-acceptance.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +ROOT="${ROOT:-/var/www/dhv2.korexlabs.com}" +cd "$ROOT" + +echo '========== D5.6.4 · VERSIONES ==========' +echo "API: $(node -p "require('./api-v3/package.json').version")" +echo "WEB: $(node -p "require('./web-v2/package.json').version")" +[[ "$(node -p "require('./api-v3/package.json').version")" == '0.19.6-4' ]] +[[ "$(node -p "require('./web-v2/package.json').version")" == '0.19.6-4' ]] + +echo +echo '========== D5.6.4 · SOURCE ==========' +bash scripts/check-phase-d5-6-4.sh "$ROOT" + +echo +echo '========== D5.6.4 · MIGRACIONES ==========' +MIGRATION_OUTPUT="$(docker compose --profile tools run --rm migrate npm run migration:show 2>&1)" +echo "$MIGRATION_OUTPUT" +grep -Fq 'Pending migrations: no' <<<"$MIGRATION_OUTPUT" + +echo +echo '========== D5.6.4 · HEALTH ==========' +HEALTH="$(curl -fsS http://127.0.0.1:3101/api/v3/health)" +echo "$HEALTH" +grep -Fq '"version":"0.19.6-4"' <<<"$HEALTH" +grep -Fq '"phase":"D5.6.4"' <<<"$HEALTH" +grep -Fq '"database":"ok"' <<<"$HEALTH" + +echo +echo '========== D5.6.4 · REGLAS ==========' +echo 'Código automático + creación rápida + inicio único + combobox buscables: OK' +echo 'Una inspección = una Acta, inicio APK-only e historia append-only preservados: OK' + +echo +echo 'ACEPTACIÓN DE FASE D5.6.4: OK' diff --git a/scripts/phase-d5-6-acceptance.sh b/scripts/phase-d5-6-acceptance.sh new file mode 100755 index 0000000..25e5c39 --- /dev/null +++ b/scripts/phase-d5-6-acceptance.sh @@ -0,0 +1,153 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT="${ROOT:-/var/www/dhv2.korexlabs.com}" +API_URL="${API_URL:-http://127.0.0.1:3101/api/v3}" +TMP_MIGRATION="/tmp/dhv2-d56-accept-migration-$$.log" +trap 'rm -f "$TMP_MIGRATION"' EXIT +cd "$ROOT" + +echo "========== D5.6 · VERSIONES ==========" +[[ "$(node -p "require('./api-v3/package.json').version")" == "0.19.6" ]] +[[ "$(node -p "require('./web-v2/package.json').version")" == "0.19.6" ]] +grep -Fq "API_PHASE = 'D5.6'" api-v3/src/version.ts +grep -Fq "APP_PHASE = 'Fase D5.6 · Cierre integral de Fase D'" web-v2/src/config/version.ts +echo "API: 0.19.6" +echo "WEB: 0.19.6" + +echo +echo "========== D5.6 · DB Y MIGRACIONES ==========" +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}')" == "healthy" ]] +docker compose exec -T db sh -lc 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' +docker compose --profile tools run --rm migrate npm run migration:show 2>&1 | tee "$TMP_MIGRATION" +grep -Fq 'Pending migrations: no' "$TMP_MIGRATION" +for table in \ + asset_context_history \ + inspection_finding_verification_events \ + finding_catalog_asset_type_profiles \ + finding_catalog_item_asset_types \ + finding_catalog_asset_overrides \ + inspection_visit_checklist_items \ + inspection_visit_asset_events \ + inspection_reports \ + inspection_report_revisions \ + inspection_report_signatures \ + inspection_document_deliveries; do + docker compose exec -T db sh -lc "psql -U \"\$POSTGRES_USER\" -d \"\$POSTGRES_DB\" -Atc \"SELECT to_regclass('public.$table');\"" | grep -Fxq "$table" +done +echo "Núcleo persistente de Inventario, Hallazgos, planificación y documentos presente: OK" + +echo +echo "========== D5.6 · HEALTH ==========" +HEALTH="$(curl -fsS "$API_URL/health")" +echo "$HEALTH" +grep -Fq '"version":"0.19.6"' <<<"$HEALTH" +grep -Fq '"phase":"D5.6"' <<<"$HEALTH" +grep -Fq '"database":"ok"' <<<"$HEALTH" + +echo +echo "========== D5.6 · INVENTARIO Y TRAZABILIDAD ==========" +grep -Fq 'asset_context_history' api-v3/src/asset-master/asset-temporal.service.ts +grep -Fq 'asset_context_history' api-v3/src/asset-master/assets.service.ts +grep -Fq 'FINDING_CATALOG_ITEM_NOT_APPLICABLE' api-v3/src/inspection-findings/inspection-findings.service.ts +grep -Fq 'finding_catalog_asset_overrides' api-v3/src/inspection-findings/inspection-findings.service.ts +echo "Contexto temporal y catálogo contextual del Inventario preservados: OK" + +echo +echo "========== D5.6 · HALLAZGOS APPEND-ONLY ==========" +grep -Fq 'INSPECTION_FINDING_RESPONSE_IMMUTABLE' api-v3/src/inspection-findings/inspection-findings.service.ts +grep -Fq 'VERIFICATION_RESULT_IMMUTABLE' api-v3/src/inspection-verifications/inspection-verifications.service.ts +grep -Fq 'inspection_finding_verification_events' api-v3/src/inspection-verifications/verification-event-ledger.ts +grep -Fq 'immutable: true' api-v3/src/inspection-findings/inspection-evidence.service.ts +echo "Hallazgo original, respuestas, evidencias y verificaciones conservan historia no destructiva: OK" + +echo +echo "========== D5.6 · PLANIFICACIÓN ==========" +grep -Fq 'INSPECTION_CHECKLIST_REQUIRED' api-v3/src/inspection-visits/inspection-visits.service.ts +grep -Fq 'inspection_visit_checklist_items' api-v3/src/inspection-visits/inspection-visits.service.ts +grep -Fq 'assertMobileInspector(principal)' api-v3/src/inspection-visits/inspection-visits.service.ts +grep -Fq "transport !== 'bearer'" api-v3/src/inspection-operations/mobile-inspector-policy.ts +grep -Fq "includes('inspector')" api-v3/src/inspection-operations/mobile-inspector-policy.ts +if grep -Fq 'startInspectionVisit' web-v2/src/pages/InspectionVisitEditorPage.tsx; then exit 1; fi +echo "Dashboard planifica y checklist queda revisado; inicio sigue APK-only: OK" + +echo +echo "========== D5.6 · ACTA ÚNICA E INMUTABLE ==========" +grep -Fq 'INSPECTION_VISIT_ACT_ALREADY_EXISTS' api-v3/src/inspection-acts/inspection-acts.service.ts +grep -Fq 'final_snapshot = $2' api-v3/src/inspection-closing/inspection-closing.service.ts +grep -Fq 'final_sha256 = $3' api-v3/src/inspection-closing/inspection-closing.service.ts +grep -Fq 'InspectionActVersionEvent.CLOSED' api-v3/src/inspection-closing/inspection-closing.service.ts +echo "Una visita = una Acta y el cierre queda congelado con hash/versionado: OK" + +echo +echo "========== D5.6 · INFORME Y DIRECTOR ==========" +grep -Fq 'ensureFrozenReport(manager, actId, principal, request)' api-v3/src/inspection-closing/inspection-closing.service.ts +grep -Fq 'ensureWordForAct(actId)' api-v3/src/inspection-closing/inspection-closing.service.ts +grep -Fq 'buildInspectionReportWord' api-v3/src/inspection-reports/inspection-report-word.service.ts +grep -Fq 'await this.assertDirector(principal.userId' api-v3/src/inspection-reports/inspection-report-review.service.ts +grep -Fq "review_status = 'APPROVED'" api-v3/src/inspection-reports/inspection-report-review.service.ts +grep -Fq 'INSPECTION_REPORT_NOT_APPROVED' api-v3/src/inspection-reports/inspection-report-review.service.ts +grep -Fq 'inspection_report_signatures' api-v3/src/inspection-reports/inspection-report-review.service.ts +echo "Informe Word automático, revisiones y firma final exclusiva del Director: OK" + +echo +echo "========== D5.6 · ENTREGA DOCUMENTAL ==========" +grep -Fq 'WAITING_TRANSPORT' api-v3/src/inspection-reports/inspection-document-delivery.service.ts +grep -Fq 'ACT_PDF' api-v3/src/inspection-reports/inspection-document-delivery.service.ts +grep -Fq 'REPORT_WORD' api-v3/src/inspection-reports/inspection-document-delivery.service.ts +grep -Fq "SMTP_HOST" api-v3/src/inspection-reports/smtp-delivery.service.ts +grep -Fq "MAIL_FROM" api-v3/src/inspection-reports/smtp-delivery.service.ts +SMTP_STATE="$(docker compose exec -T api sh -lc 'if [ -n "$SMTP_HOST" ] && [ -n "$MAIL_FROM" ]; then echo configured; else echo pending; fi')" +if [[ "$SMTP_STATE" == "configured" ]]; then + echo "Cola documental auditable y SMTP configurado: OK" +else + echo "Cola documental auditable: OK · SMTP pendiente no bloqueante" +fi + +echo +echo "========== D5.6 · TERMINOLOGÍA CLIENTE ==========" +grep -Fq 'ARCHIVOS DEL INVENTARIO' web-v2/src/features/assets/AssetMediaPanel.tsx +grep -Fq 'INVENTARIO DEL ACTA' web-v2/src/pages/InspectionActEditorPage.tsx +if grep -RnhE --include='*.tsx' 'ARCHIVOS DEL ACTIVO|ACTIVOS DEL ACTA' web-v2/src; then + echo "ERROR: quedan rótulos históricos de Activo en superficies de Inventario" + exit 1 +fi +echo "Rótulos principales normalizados a Inventario sin alterar estados Activo/Inactivo: OK" + +echo +echo "========== D5.6 · SIN MIGRACIÓN NUEVA ==========" +if find api-v3/src/database/migrations -maxdepth 1 -type f -iname '*d5-6*' | grep -q .; then + echo "ERROR: D5.6 no debe introducir una migración funcional" + exit 1 +fi +echo "D5.6 consolida contrato sin modificar el modelo persistente: OK" + +echo +echo "========== D5.6 · HIGIENE DE CÓDIGO ==========" +if grep -RnhE --include='*.ts' --include='*.tsx' '^[[:space:]]*(//|/\*|\*)' api-v3/src web-v2/src; then + echo "ERROR: quedaron comentarios narrativos en código de producción" + exit 1 +fi +if grep -nE '/\*|\*/' web-v2/src/styles.css; then + echo "ERROR: quedaron comentarios internos en estilos de producción" + exit 1 +fi +echo "Código de producción preparado para revisión externa: OK" + +echo +echo "========== D5.6 · BACKUPS HEREDADOS ==========" +STRONG_BACKUP="$(find /root/DH_V2_BACKUPS -maxdepth 1 -type d -name 'INVENTARIO_A_REVISAR_*' -printf '%T@ %p\n' | sort -nr | head -n1 | cut -d' ' -f2-)" +[[ -n "$STRONG_BACKUP" && -s "$STRONG_BACKUP/BACKUP_OK.txt" ]] +grep -Fq 'INVENTARIO A REVISAR' "$STRONG_BACKUP/BACKUP_OK.txt" +echo "Backup fuerte Inventario: $STRONG_BACKUP" + +echo +echo "========== D5.6 · CONTENEDORES ==========" +docker compose ps -a +[[ "$(docker inspect dhv2-db --format '{{.State.Health.Status}}')" == "healthy" ]] +[[ "$(docker inspect dhv2-api --format '{{.State.Status}}')" == "running" ]] +[[ "$(docker inspect dhv2-web --format '{{.State.Status}}')" == "running" ]] + +echo +echo "ACEPTACIÓN INTEGRAL DE FASE D5.6: OK" +echo "FASE D: LISTA PARA CIERRE FORMAL" diff --git a/scripts/phase-d5-acceptance.sh b/scripts/phase-d5-acceptance.sh new file mode 100755 index 0000000..39983f8 --- /dev/null +++ b/scripts/phase-d5-acceptance.sh @@ -0,0 +1,238 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail + +dhv2_base_url="${DHV2_BASE_URL:-https://dhv2.korexlabs.com}" +dhv2_base_url="${dhv2_base_url%/}" +dhv2_nil_uuid="00000000-0000-4000-8000-000000000000" + +fail() { + echo "ERROR: $*" >&2 + exit 1 +} + +echo "========== D5 · MIGRACIONES ==========" +dhv2_migration_status="$(docker compose --profile tools run --rm migrate npm run migration:show 2>&1)" +grep -q 'Pending migrations: no' <<<"$dhv2_migration_status" \ + || fail "Quedaron migraciones pendientes" +echo "Migraciones pendientes: no" + +echo +echo "========== D5 · HEALTH ==========" +dhv2_internal="$(curl -fsS --connect-timeout 10 --max-time 30 http://127.0.0.1:3101/api/v3/health)" +dhv2_external="$(curl -fsS --connect-timeout 10 --max-time 30 "$dhv2_base_url/api/v3/health")" +grep -q '"version":"0.19.0"' <<<"$dhv2_internal" || fail "Health interno no informa v0.19.0" +grep -q '"phase":"D5"' <<<"$dhv2_internal" || fail "Health interno no informa D5" +grep -q '"version":"0.19.0"' <<<"$dhv2_external" || fail "Health externo no informa v0.19.0" +grep -q '"database":"ok"' <<<"$dhv2_external" || fail "La base no está saludable" +echo "$dhv2_external" + +echo +echo "========== D5 · RUTAS PROTEGIDAS ==========" +dhv2_get_paths=( + "inspection-acts/$dhv2_nil_uuid/closure" + "inspection-act-signatures/$dhv2_nil_uuid/content" +) +for dhv2_path in "${dhv2_get_paths[@]}"; do + dhv2_code="$(curl -sS --connect-timeout 10 --max-time 30 \ + -o /tmp/dhv2-d5-response.json -w '%{http_code}' \ + "$dhv2_base_url/api/v3/$dhv2_path")" + test "$dhv2_code" = "401" \ + || fail "/api/v3/$dhv2_path devolvió HTTP $dhv2_code; se esperaba 401" + echo "/api/v3/$dhv2_path sin sesión: 401 OK" +done +dhv2_post_paths=( + "inspection-acts/$dhv2_nil_uuid/ready" + "inspection-acts/$dhv2_nil_uuid/company-outcome" + "inspection-acts/$dhv2_nil_uuid/close" +) +for dhv2_path in "${dhv2_post_paths[@]}"; do + dhv2_code="$(curl -sS --connect-timeout 10 --max-time 30 \ + -X POST -H 'Content-Type: application/json' -d '{}' \ + -o /tmp/dhv2-d5-response.json -w '%{http_code}' \ + "$dhv2_base_url/api/v3/$dhv2_path")" + test "$dhv2_code" = "401" \ + || fail "/api/v3/$dhv2_path devolvió HTTP $dhv2_code; se esperaba 401" + echo "/api/v3/$dhv2_path sin sesión: 401 OK" +done +rm -f /tmp/dhv2-d5-response.json + +echo +echo "========== D5 · MODELO E INTEGRIDAD ==========" +dhv2_db_check="$(docker compose exec -T db sh -lc \ + 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -v ON_ERROR_STOP=1 -At' <<'SQL' +SELECT 'tables', COUNT(*) +FROM information_schema.tables +WHERE table_schema = 'public' + AND table_name IN ( + 'inspection_act_responsibles', + 'inspection_act_closures', + 'inspection_act_signatures' + ); + +SELECT 'act_closure_columns', COUNT(*) +FROM information_schema.columns +WHERE table_schema = 'public' + AND table_name = 'inspection_acts' + AND column_name IN ('closed_at', 'closed_by', 'closure_sha256'); + +SELECT 'responsible_checks', COUNT(*) +FROM pg_constraint +WHERE conname IN ( + 'chk_inspection_act_responsibles_attendance', + 'chk_inspection_act_responsibles_document_type', + 'chk_inspection_act_responsibles_details' +); + +SELECT 'closure_checks', COUNT(*) +FROM pg_constraint +WHERE conname IN ( + 'chk_inspection_act_closures_schema', + 'chk_inspection_act_closures_prepared_sha', + 'chk_inspection_act_closures_final_sha', + 'chk_inspection_act_closures_upload_mode', + 'chk_inspection_act_closures_final' +); + +SELECT 'signature_checks', COUNT(*) +FROM pg_constraint +WHERE conname IN ( + 'chk_inspection_act_signatures_signer_type', + 'chk_inspection_act_signatures_status', + 'chk_inspection_act_signatures_document_type', + 'chk_inspection_act_signatures_source', + 'chk_inspection_act_signatures_sha', + 'chk_inspection_act_signatures_coordinates', + 'chk_inspection_act_signatures_accuracy', + 'chk_inspection_act_signatures_inspector', + 'chk_inspection_act_signatures_company', + 'chk_inspection_act_signatures_signed' +); + +SELECT 'unique_guards', COUNT(*) +FROM pg_indexes +WHERE schemaname = 'public' + AND indexname IN ( + 'uq_inspection_act_inspector_signature', + 'uq_inspection_act_company_outcome', + 'uq_inspection_act_signatures_stored_name', + 'uq_inspection_act_closures_final_sha' + ); + +SELECT 'integrity_triggers', COUNT(*) +FROM pg_trigger +WHERE NOT tgisinternal + AND tgname IN ( + 'trg_inspection_act_responsibles_draft', + 'trg_inspection_act_signatures_ready', + 'trg_inspection_acts_closed_immutable', + 'trg_inspection_act_closures_immutable', + 'trg_inspection_act_assets_draft', + 'trg_inspection_findings_frozen_fields', + 'trg_inspection_evidence_observation_draft' + ); + +SELECT 'permissions', COUNT(*) +FROM permissions +WHERE code IN ( + 'inspection_closure.read', + 'inspection_closure.prepare', + 'inspection_closure.sign', + 'inspection_closure.close' +); + +SELECT 'read_roles', COUNT(*) +FROM role_permissions rp +INNER JOIN roles role ON role.id = rp.role_id +INNER JOIN permissions permission ON permission.id = rp.permission_id +WHERE role.code IN ('admin', 'director', 'supervisor', 'inspector', 'auditor') + AND permission.code = 'inspection_closure.read'; + +SELECT 'prepare_roles', COUNT(*) +FROM role_permissions rp +INNER JOIN roles role ON role.id = rp.role_id +INNER JOIN permissions permission ON permission.id = rp.permission_id +WHERE role.code IN ('admin', 'supervisor', 'inspector') + AND permission.code = 'inspection_closure.prepare'; + +SELECT 'sign_roles', COUNT(*) +FROM role_permissions rp +INNER JOIN roles role ON role.id = rp.role_id +INNER JOIN permissions permission ON permission.id = rp.permission_id +WHERE role.code IN ('admin', 'supervisor', 'inspector') + AND permission.code = 'inspection_closure.sign'; + +SELECT 'close_roles', COUNT(*) +FROM role_permissions rp +INNER JOIN roles role ON role.id = rp.role_id +INNER JOIN permissions permission ON permission.id = rp.permission_id +WHERE role.code IN ('admin', 'director', 'supervisor', 'inspector') + AND permission.code = 'inspection_closure.close'; +SQL +)" +grep -q 'tables|3' <<<"$dhv2_db_check" || fail "Faltan tablas D5" +grep -q 'act_closure_columns|3' <<<"$dhv2_db_check" || fail "Faltan columnas de cierre en el acta" +grep -q 'responsible_checks|3' <<<"$dhv2_db_check" || fail "Faltan controles del responsable" +grep -q 'closure_checks|5' <<<"$dhv2_db_check" || fail "Faltan controles del cierre" +grep -q 'signature_checks|10' <<<"$dhv2_db_check" || fail "Faltan controles de firmas" +grep -q 'unique_guards|4' <<<"$dhv2_db_check" || fail "Faltan claves únicas D5" +grep -q 'integrity_triggers|7' <<<"$dhv2_db_check" || fail "Faltan triggers de integridad D5" +grep -q 'permissions|4' <<<"$dhv2_db_check" || fail "Faltan permisos D5" +grep -q 'read_roles|5' <<<"$dhv2_db_check" || fail "Faltan roles de lectura" +grep -q 'prepare_roles|3' <<<"$dhv2_db_check" || fail "Faltan roles de preparación" +grep -q 'sign_roles|3' <<<"$dhv2_db_check" || fail "Faltan roles de firma" +grep -q 'close_roles|4' <<<"$dhv2_db_check" || fail "Faltan roles de cierre" +echo "$dhv2_db_check" + +echo +echo "========== D5 · PRIVILEGIOS RUNTIME ==========" +dhv2_privileges="$(docker compose exec -T db sh -lc \ + 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -v ON_ERROR_STOP=1 -v app_user="$APP_DB_USER" -At' <<'SQL' +SELECT 'responsible_select', has_table_privilege(:'app_user', 'inspection_act_responsibles', 'SELECT'); +SELECT 'responsible_insert', has_table_privilege(:'app_user', 'inspection_act_responsibles', 'INSERT'); +SELECT 'responsible_update', has_table_privilege(:'app_user', 'inspection_act_responsibles', 'UPDATE'); +SELECT 'responsible_delete', has_table_privilege(:'app_user', 'inspection_act_responsibles', 'DELETE'); +SELECT 'closure_select', has_table_privilege(:'app_user', 'inspection_act_closures', 'SELECT'); +SELECT 'closure_insert', has_table_privilege(:'app_user', 'inspection_act_closures', 'INSERT'); +SELECT 'closure_update', has_table_privilege(:'app_user', 'inspection_act_closures', 'UPDATE'); +SELECT 'closure_delete', has_table_privilege(:'app_user', 'inspection_act_closures', 'DELETE'); +SELECT 'signature_select', has_table_privilege(:'app_user', 'inspection_act_signatures', 'SELECT'); +SELECT 'signature_insert', has_table_privilege(:'app_user', 'inspection_act_signatures', 'INSERT'); +SELECT 'signature_update', has_table_privilege(:'app_user', 'inspection_act_signatures', 'UPDATE'); +SELECT 'signature_delete', has_table_privilege(:'app_user', 'inspection_act_signatures', 'DELETE'); +SQL +)" +for dhv2_expected in \ + responsible_select responsible_insert responsible_update \ + closure_select closure_insert closure_update \ + signature_select signature_insert; do + grep -q "${dhv2_expected}|t" <<<"$dhv2_privileges" \ + || fail "Falta privilegio runtime ${dhv2_expected}" +done +for dhv2_forbidden in \ + responsible_delete closure_delete signature_update signature_delete; do + grep -q "${dhv2_forbidden}|f" <<<"$dhv2_privileges" \ + || fail "Privilegio runtime indebido ${dhv2_forbidden}" +done +echo "$dhv2_privileges" + +echo +echo "========== D5 · ALMACENAMIENTO ==========" +docker compose exec -T api sh -lc \ + 'mkdir -p /app/storage/asset-media/inspection-signatures && chmod 700 /app/storage/asset-media/inspection-signatures && test -w /app/storage/asset-media/inspection-signatures' +echo "Directorio persistente de firmas protegido y escribible: OK" + +echo +echo "========== D5 · FRONTEND ==========" +docker compose exec -T web sh -lc \ + 'grep -R "0.19.0" /usr/share/nginx/html/assets >/dev/null && grep -R "Cierre y firmas" /usr/share/nginx/html/assets >/dev/null && grep -R "Cerrar y sellar" /usr/share/nginx/html/assets >/dev/null && grep -R "firma manuscrita" /usr/share/nginx/html/assets >/dev/null && grep -R "Los hallazgos" /usr/share/nginx/html/assets >/dev/null' +echo "Flujo de cierre, firma y footer v0.19.0: OK" + +echo +echo "========== D5 · CONTROLADOR ==========" +docker compose logs api | grep 'InspectionClosingController' >/dev/null \ + || fail "InspectionClosingController no aparece en las rutas cargadas" +echo "InspectionClosingController cargado: OK" + +echo +echo "ACEPTACIÓN DE FASE D5: OK" diff --git a/scripts/verify-backup.sh b/scripts/verify-backup.sh new file mode 100755 index 0000000..98a285f --- /dev/null +++ b/scripts/verify-backup.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail + +dhv2_project="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +dhv2_requested="${1:-}" + +if [ -z "$dhv2_requested" ]; then + echo "Uso: bash scripts/verify-backup.sh /var/www/dh-backup/dhv2-..." >&2 + exit 1 +fi + +dhv2_backup="$(realpath -e "$dhv2_requested")" +case "$dhv2_backup" in + /var/www/dh-backup/dhv2-*) ;; + *) echo "Ruta de backup no permitida: $dhv2_backup" >&2; exit 1 ;; +esac + +for dhv2_file in source.tar.gz database.dump .env MANIFEST.txt SHA256SUMS; do + test -s "$dhv2_backup/$dhv2_file" || { echo "Falta $dhv2_file" >&2; exit 1; } +done + +test "$(stat -c '%a' "$dhv2_backup/.env")" = "600" || { + echo "El backup de .env no tiene permisos 600" >&2 + exit 1 +} + +( + cd "$dhv2_backup" + sha256sum -c SHA256SUMS +) + +dhv2_archive_listing="$(tar -tzf "$dhv2_backup/source.tar.gz")" +if grep -Eq '(^|/)\.env$|(^|/)(node_modules|dist)/' <<< "$dhv2_archive_listing"; then + echo "El archivo de fuentes contiene elementos excluidos" >&2 + exit 1 +fi + +if [ -f "$dhv2_backup/asset-media.tar.gz" ]; then + tar -tzf "$dhv2_backup/asset-media.tar.gz" >/dev/null +fi + +cd "$dhv2_project" +docker compose exec -T db pg_restore --list < "$dhv2_backup/database.dump" >/dev/null + +echo "Backup verificable y legible: OK" +echo "$dhv2_backup" diff --git a/web-v2/.dockerignore b/web-v2/.dockerignore new file mode 100644 index 0000000..26c8f00 --- /dev/null +++ b/web-v2/.dockerignore @@ -0,0 +1,5 @@ +node_modules +dist +.env* +.git +*.log diff --git a/web-v2/Dockerfile b/web-v2/Dockerfile new file mode 100644 index 0000000..a38247f --- /dev/null +++ b/web-v2/Dockerfile @@ -0,0 +1,12 @@ +FROM node:24-alpine AS builder +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY tsconfig*.json vite.config.ts index.html ./ +COPY src ./src +RUN npm run build + +FROM nginx:alpine +COPY nginx/default.conf /etc/nginx/conf.d/default.conf +COPY --from=builder /app/dist /usr/share/nginx/html +EXPOSE 80 diff --git a/web-v2/index.html b/web-v2/index.html new file mode 100644 index 0000000..10e2225 --- /dev/null +++ b/web-v2/index.html @@ -0,0 +1,13 @@ + + + + + + + DH Inspección V2 + + +
+ + + diff --git a/web-v2/nginx/default.conf b/web-v2/nginx/default.conf new file mode 100644 index 0000000..7ec3c2f --- /dev/null +++ b/web-v2/nginx/default.conf @@ -0,0 +1,14 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } + + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header X-Frame-Options "DENY" always; +} diff --git a/web-v2/package-lock.json b/web-v2/package-lock.json new file mode 100644 index 0000000..dfab0fe --- /dev/null +++ b/web-v2/package-lock.json @@ -0,0 +1,2081 @@ +{ + "name": "dhv2-web", + "version": "0.19.6-4", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "dhv2-web", + "version": "0.19.6-4", + "dependencies": { + "maplibre-gl": "^6.0.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router": "^8.0.0" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^5.0.0", + "typescript": "^5.9.0", + "vite": "^7.0.0" + }, + "engines": { + "node": ">=24 <25" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mapbox/jsonlint-lines-primitives": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.3.tgz", + "integrity": "sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg==", + "license": "MIT", + "engines": { + "node": ">= 22" + } + }, + "node_modules/@mapbox/point-geometry": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz", + "integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==", + "license": "ISC" + }, + "node_modules/@mapbox/tiny-sdf": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.2.0.tgz", + "integrity": "sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/unitbezier": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-1.0.0.tgz", + "integrity": "sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/vector-tile": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-3.0.0.tgz", + "integrity": "sha512-Qf10S1uIHMk20ri/IVBnpS+esUEkVaR5Hftmz88jTInrpmWgPGJfPe3LVjjlE77trLx8tH6qjTG7uWH9hIq/0Q==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/point-geometry": "~1.1.0", + "@types/geojson": "^7946.0.16", + "pbf": "^5.0.0" + } + }, + "node_modules/@maplibre/geojson-vt": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-6.1.1.tgz", + "integrity": "sha512-FVMOcmSP/yqol45t7StApEyTL5/vmqBCuFhH9n+fFuINenhaX+YgHHIt1yJ86S8kln3uJLcMvmEU2cfn6E2eCQ==", + "license": "ISC", + "dependencies": { + "kdbush": "^4.1.0" + } + }, + "node_modules/@maplibre/maplibre-gl-style-spec": { + "version": "26.2.1", + "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-26.2.1.tgz", + "integrity": "sha512-QFKCXkOeSzOr8jF75jm6kySOg+dUvOehPhRi68gcOYPHb7U5JloUq0dJW0Y5/fZV8ygfT0Vp2RWodvq+fyxFWA==", + "license": "ISC", + "dependencies": { + "@mapbox/jsonlint-lines-primitives": "^2.0.3", + "@mapbox/unitbezier": "^1.0.0", + "json-stringify-pretty-compact": "^4.0.0", + "minimist": "^1.2.8", + "quickselect": "^3.0.0", + "tinyqueue": "^3.0.0" + }, + "bin": { + "gl-style-format": "dist/gl-style-format.mjs", + "gl-style-migrate": "dist/gl-style-migrate.mjs", + "gl-style-validate": "dist/gl-style-validate.mjs" + } + }, + "node_modules/@maplibre/mlt": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.1.12.tgz", + "integrity": "sha512-ZeK5w2TTeHOajcLaEQs1KZXw2V9wIKo1PmThlxlsHoXsQsYlBqLJzPOd6tJHRtGTChUY3DPPmjXRArYVvAbmZw==", + "license": "(MIT OR Apache-2.0)", + "dependencies": { + "@mapbox/point-geometry": "^1.1.0" + } + }, + "node_modules/@maplibre/vt-pbf": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.3.2.tgz", + "integrity": "sha512-j6p0AdjvAR19Z3XaCysle7A4ZSo08tYOzxD0Y9NQylwPAkwJJeYub5b2eVucdeDh7erhv69DahoLOevDRERRUw==", + "license": "MIT", + "dependencies": { + "@mapbox/point-geometry": "^1.1.0", + "@types/geojson": "^7946.0.16", + "pbf": "^5.1.0" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", + "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz", + "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz", + "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz", + "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz", + "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz", + "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz", + "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz", + "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz", + "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz", + "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz", + "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz", + "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz", + "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz", + "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz", + "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz", + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz", + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz", + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz", + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz", + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz", + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz", + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz", + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz", + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz", + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", + "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.13", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.13.tgz", + "integrity": "sha512-k9HNuUVMlqVjQ9UHzfPjIqiDbWw7WqT1AoT7GL8VwvF3r0ZfArtgiSPAlmupyNquNgOJHTuH4CKYf8ttMTWBTQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001809", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz", + "integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie-es": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-3.1.1.tgz", + "integrity": "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/earcut": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.2.3.tgz", + "integrity": "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==", + "license": "ISC" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.405", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.405.tgz", + "integrity": "sha512-bNglH7lPH5l+yHOes7Zr4VqxhOy4BQ9ZBUX4VdoFgxMpzJk7W1ZoO3Vgd9Pxa9PyjQ76sfm2aKH/nzEcCNRlew==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/gl-matrix": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz", + "integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==", + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-stringify-pretty-compact": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz", + "integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==", + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/kdbush": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.1.0.tgz", + "integrity": "sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ==", + "license": "ISC" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/maplibre-gl": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-6.3.0.tgz", + "integrity": "sha512-F0Is48MTzn3DvOEidPjh68E0kuSA7hdzY1YIR0ypPtFgcif3WPtzI1oZFgsv9WtHmarQnbwIXfsf+EfQYvM00A==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/point-geometry": "^1.1.0", + "@mapbox/tiny-sdf": "^2.2.0", + "@mapbox/unitbezier": "^1.0.0", + "@mapbox/vector-tile": "^3.0.0", + "@maplibre/geojson-vt": "^6.1.1", + "@maplibre/maplibre-gl-style-spec": "^26.2.1", + "@maplibre/mlt": "^1.1.12", + "@maplibre/vt-pbf": "^4.3.2", + "@types/geojson": "^7946.0.16", + "earcut": "^3.2.3", + "gl-matrix": "^3.4.4", + "kdbush": "^4.1.0", + "murmurhash-js": "^1.0.0", + "pbf": "^5.1.2", + "potpack": "^2.1.0", + "quickselect": "^3.0.0", + "tinyqueue": "^3.0.0" + }, + "engines": { + "node": ">=16.14.0", + "npm": ">=8.1.0" + }, + "funding": { + "url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/murmurhash-js": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz", + "integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.53", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", + "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/pbf": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-5.1.2.tgz", + "integrity": "sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w==", + "license": "BSD-3-Clause", + "dependencies": { + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/potpack": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz", + "integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==", + "license": "ISC" + }, + "node_modules/protocol-buffers-schema": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz", + "integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==", + "license": "MIT" + }, + "node_modules/quickselect": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz", + "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==", + "license": "ISC" + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-8.3.0.tgz", + "integrity": "sha512-qyPMvW83jGIct3yiieisxdk9M745anqhpIMKN5m1t6yBMfgVPpt77aHOqs5fUlEJRMCGffg9BaQLH9oPVOL7xQ==", + "license": "MIT", + "dependencies": { + "cookie-es": "^3.1.1" + }, + "engines": { + "node": ">=22.22.0" + }, + "peerDependencies": { + "react": ">=19.2.7", + "react-dom": ">=19.2.7" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/resolve-protobuf-schema": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz", + "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==", + "license": "MIT", + "dependencies": { + "protocol-buffers-schema": "^3.3.1" + } + }, + "node_modules/rollup": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", + "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.4", + "@rollup/rollup-android-arm64": "4.62.4", + "@rollup/rollup-darwin-arm64": "4.62.4", + "@rollup/rollup-darwin-x64": "4.62.4", + "@rollup/rollup-freebsd-arm64": "4.62.4", + "@rollup/rollup-freebsd-x64": "4.62.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", + "@rollup/rollup-linux-arm-musleabihf": "4.62.4", + "@rollup/rollup-linux-arm64-gnu": "4.62.4", + "@rollup/rollup-linux-arm64-musl": "4.62.4", + "@rollup/rollup-linux-loong64-gnu": "4.62.4", + "@rollup/rollup-linux-loong64-musl": "4.62.4", + "@rollup/rollup-linux-ppc64-gnu": "4.62.4", + "@rollup/rollup-linux-ppc64-musl": "4.62.4", + "@rollup/rollup-linux-riscv64-gnu": "4.62.4", + "@rollup/rollup-linux-riscv64-musl": "4.62.4", + "@rollup/rollup-linux-s390x-gnu": "4.62.4", + "@rollup/rollup-linux-x64-gnu": "4.62.4", + "@rollup/rollup-linux-x64-musl": "4.62.4", + "@rollup/rollup-openbsd-x64": "4.62.4", + "@rollup/rollup-openharmony-arm64": "4.62.4", + "@rollup/rollup-win32-arm64-msvc": "4.62.4", + "@rollup/rollup-win32-ia32-msvc": "4.62.4", + "@rollup/rollup-win32-x64-gnu": "4.62.4", + "@rollup/rollup-win32-x64-msvc": "4.62.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyqueue": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz", + "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==", + "license": "ISC" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz", + "integrity": "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/web-v2/package.json b/web-v2/package.json new file mode 100644 index 0000000..f6088fa --- /dev/null +++ b/web-v2/package.json @@ -0,0 +1,27 @@ +{ + "name": "dhv2-web", + "version": "0.19.6-4", + "private": true, + "type": "module", + "engines": { + "node": ">=24 <25" + }, + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "maplibre-gl": "^6.0.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router": "^8.0.0" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^5.0.0", + "typescript": "^5.9.0", + "vite": "^7.0.0" + } +} diff --git a/web-v2/src/app/App.tsx b/web-v2/src/app/App.tsx new file mode 100644 index 0000000..fb4b27a --- /dev/null +++ b/web-v2/src/app/App.tsx @@ -0,0 +1,93 @@ +import { lazy, Suspense } from 'react'; +import { Navigate, Route, Routes } from 'react-router'; +import { PermissionRoute, ProtectedRoute } from '../auth/ProtectedRoute'; +import { AppLayout } from '../layout/AppLayout'; +import { OperationalContextProvider } from '../context/OperationalContext'; +import { AuditPage } from '../pages/AuditPage'; +import { ChangePasswordPage } from '../pages/ChangePasswordPage'; +import { DashboardPage } from '../pages/DashboardPage'; +import { LoginPage } from '../pages/LoginPage'; +import { NewUserPage } from '../pages/NewUserPage'; +import { RolesPage } from '../pages/RolesPage'; +import { AccessDeniedPage, NotFoundPage } from '../pages/SystemPages'; +import { UserDetailPage } from '../pages/UserDetailPage'; +import { UsersPage } from '../pages/UsersPage'; +import { AssetsPage } from '../pages/AssetsPage'; +import { AssetEditorPage } from '../pages/AssetEditorPage'; +import { FieldDiscoveriesPage } from '../pages/FieldDiscoveriesPage'; +import { AssetTypesPage } from '../pages/AssetTypesPage'; +import { HistoryPage } from '../pages/HistoryPage'; +import { TemporalAssetsPage } from '../pages/TemporalAssetsPage'; +import { SurveyCampaignsPage } from '../pages/SurveyCampaignsPage'; +import { SurveyCampaignEditorPage } from '../pages/SurveyCampaignEditorPage'; +import { SurveyExecutionPage } from '../pages/SurveyExecutionPage'; +import { InspectionVisitsPage } from '../pages/InspectionVisitsPage'; +import { InspectionVisitEditorPage } from '../pages/InspectionVisitEditorPage'; +import { InspectionActEditorPage } from '../pages/InspectionActEditorPage'; +import { FindingCatalogPage } from '../pages/FindingCatalogPage'; +import { FindingDetailPage } from '../pages/FindingDetailPage'; +import { FindingsPage } from '../pages/FindingsPage'; +import { AssetImportsPage } from '../pages/AssetImportsPage'; +import { ActsPage } from '../pages/ActsPage'; +import { ReportsPage } from '../pages/ReportsPage'; +import { ReportDetailPage } from '../pages/ReportDetailPage'; +import { VerificationPlanningPage } from '../pages/VerificationPlanningPage'; + +const MapPage = lazy(() => import('../pages/MapPage').then((module) => ({ default: module.MapPage }))); + +const DocumentDeliveryPage = lazy(() => import('../pages/DocumentDeliveryPage').then((module) => ({ default: module.DocumentDeliveryPage }))); +export function App() { + return Cargando módulo…}> + } /> + }> + } /> + }> + }>} /> + }>} /> + }> + } /> + } /> + } /> + } /> + + }>} /> + }>} /> + }>} />} /> + } /> + }> + } /> + } /> + + }>} /> + }>} /> + }> + } /> + } /> + + }>} /> + }> + } /> + } /> + + }>} />} /> + }>} /> + }>} /> + }>} />} /> + }>} /> + }>} /> + }> + } /> + } /> + + }>} /> + }>} /> + }>} /> + }>} /> + }>} /> + }>} /> + } /> + } /> + + + ; +} diff --git a/web-v2/src/auth/AuthContext.tsx b/web-v2/src/auth/AuthContext.tsx new file mode 100644 index 0000000..f57a113 --- /dev/null +++ b/web-v2/src/auth/AuthContext.tsx @@ -0,0 +1,66 @@ +import { createContext, useContext, useEffect, useMemo, useState } from 'react'; +import type { PropsWithChildren } from 'react'; +import { + changePassword as requestPasswordChange, + getMe, + login as requestLogin, + logout as requestLogout, +} from '../lib/api'; +import type { AuthUser } from '../lib/api'; + +interface AuthContextValue { + loading: boolean; + user: AuthUser | null; + login: (identifier: string, password: string) => Promise; + logout: () => Promise; + changePassword: (currentPassword: string, newPassword: string) => Promise; + hasPermission: (permission: string) => boolean; +} + +const AuthContext = createContext(null); + +export function AuthProvider({ children }: PropsWithChildren) { + const [loading, setLoading] = useState(true); + const [user, setUser] = useState(null); + + useEffect(() => { + let active = true; + getMe() + .then((current) => { if (active) setUser(current); }) + .catch(() => { if (active) setUser(null); }) + .finally(() => { if (active) setLoading(false); }); + + const unauthorized = () => setUser(null); + window.addEventListener('dhv2:unauthorized', unauthorized); + return () => { + active = false; + window.removeEventListener('dhv2:unauthorized', unauthorized); + }; + }, []); + + const value = useMemo(() => ({ + loading, + user, + login: async (identifier, password) => { + const response = await requestLogin(identifier, password); + setUser(response.user); + return response.user; + }, + logout: async () => { + try { await requestLogout(); } finally { setUser(null); } + }, + changePassword: async (currentPassword, newPassword) => { + await requestPasswordChange(currentPassword, newPassword); + setUser((current) => current ? { ...current, mustChangePassword: false } : current); + }, + hasPermission: (permission) => Boolean(user?.permissions.includes(permission)), + }), [loading, user]); + + return {children}; +} + +export function useAuth() { + const context = useContext(AuthContext); + if (!context) throw new Error('useAuth debe utilizarse dentro de AuthProvider'); + return context; +} diff --git a/web-v2/src/auth/PermissionGate.tsx b/web-v2/src/auth/PermissionGate.tsx new file mode 100644 index 0000000..37653a3 --- /dev/null +++ b/web-v2/src/auth/PermissionGate.tsx @@ -0,0 +1,14 @@ +import type { ReactNode } from 'react'; +import { useAuth } from './AuthContext'; + +export function PermissionGate({ + permission, + children, + fallback = null, +}: { + permission: string; + children: ReactNode; + fallback?: ReactNode; +}) { + return useAuth().hasPermission(permission) ? children : fallback; +} diff --git a/web-v2/src/auth/ProtectedRoute.tsx b/web-v2/src/auth/ProtectedRoute.tsx new file mode 100644 index 0000000..6b1fe0f --- /dev/null +++ b/web-v2/src/auth/ProtectedRoute.tsx @@ -0,0 +1,21 @@ +import { Navigate, Outlet, useLocation } from 'react-router'; +import { useAuth } from './AuthContext'; + +export function ProtectedRoute() { + const { loading, user } = useAuth(); + const location = useLocation(); + + if (loading) { + return
Verificando sesión…
; + } + if (!user) return ; + if (user.mustChangePassword && location.pathname !== '/change-password') { + return ; + } + return ; +} + +export function PermissionRoute({ permission }: { permission: string }) { + const { hasPermission } = useAuth(); + return hasPermission(permission) ? : ; +} diff --git a/web-v2/src/components/Feedback.tsx b/web-v2/src/components/Feedback.tsx new file mode 100644 index 0000000..13bc4d1 --- /dev/null +++ b/web-v2/src/components/Feedback.tsx @@ -0,0 +1,17 @@ +import { ApiError } from '../lib/api'; + +export function errorMessage(error: unknown) { + return error instanceof ApiError ? error.message : 'No se pudo completar la operación'; +} + +export function LoadingBlock({ label = 'Cargando…' }: { label?: string }) { + return
{label}
; +} + +export function EmptyState({ title, text }: { title: string; text: string }) { + return
{title}

{text}

; +} + +export function Alert({ type = 'error', children }: { type?: 'error' | 'success' | 'info'; children: React.ReactNode }) { + return
{children}
; +} diff --git a/web-v2/src/components/Icon.tsx b/web-v2/src/components/Icon.tsx new file mode 100644 index 0000000..fd729af --- /dev/null +++ b/web-v2/src/components/Icon.tsx @@ -0,0 +1,33 @@ +export type IconName = + | 'home' | 'map' | 'layers' | 'calendar' | 'clipboard' | 'alert' + | 'history' | 'users' | 'shield' | 'audit' | 'logout' | 'menu' + | 'plus' | 'search' | 'edit' | 'chevron' | 'check' | 'key' | 'upload'; + +export function Icon({ name, size = 18 }: { name: IconName; size?: number }) { + const paths: Record = { + home: <>, + map: <>, + layers: <>, + calendar: <>, + clipboard: <>, + alert: <>, + history: <>, + users: <>, + shield: <>, + audit: <>, + logout: <>, + menu: <>, + plus: <>, + search: <>, + edit: <>, + chevron: , + check: , + key: <>, + upload: <>, + }; + return ( + + ); +} diff --git a/web-v2/src/components/OperationalContextBar.tsx b/web-v2/src/components/OperationalContextBar.tsx new file mode 100644 index 0000000..05f5380 --- /dev/null +++ b/web-v2/src/components/OperationalContextBar.tsx @@ -0,0 +1,91 @@ +import { SearchableSelect } from './SearchableSelect'; +import { useEffect, useMemo, useState } from 'react'; +import type { FormEvent } from 'react'; +import { Link, useNavigate } from 'react-router'; +import { useAuth } from '../auth/AuthContext'; +import { useOperationalContext } from '../context/OperationalContext'; +import { listCompaniesForArea, listOperationalAreas } from '../lib/api'; +import type { OperationalAssetSummary } from '../lib/api'; +import { Icon } from './Icon'; + +export function OperationalContextBar() { + const navigate = useNavigate(); + const { hasPermission } = useAuth(); + const { areaId, companyId, setAreaId, setCompanyId, clearContext } = useOperationalContext(); + const [areas, setAreas] = useState([]); + const [companies, setCompanies] = useState([]); + const [search, setSearch] = useState(''); + + const canReadInventory = hasPermission('assets.read'); + const canReadInspections = hasPermission('inspections.read'); + + useEffect(() => { + if (!canReadInventory && !canReadInspections) return; + listOperationalAreas().then(setAreas).catch(() => setAreas([])); + }, [canReadInventory, canReadInspections]); + + useEffect(() => { + if (!areaId) { + setCompanies([]); + if (companyId) setCompanyId(''); + return; + } + listCompaniesForArea(areaId) + .then((rows) => { + setCompanies(rows); + if (companyId && !rows.some((item) => item.id === companyId)) setCompanyId(''); + }) + .catch(() => setCompanies([])); + }, [areaId, companyId, setCompanyId]); + + const selectedArea = useMemo(() => areas.find((item) => item.id === areaId) ?? null, [areas, areaId]); + const selectedCompany = useMemo(() => companies.find((item) => item.id === companyId) ?? null, [companies, companyId]); + + const submitSearch = (event: FormEvent) => { + event.preventDefault(); + const term = search.trim(); + if (!term || !canReadInventory) return; + navigate(`/inventarios?view=list&search=${encodeURIComponent(term)}`); + }; + + if (!canReadInventory && !canReadInspections) return null; + + return
+
+ +
+ CONTEXTO DE TRABAJO + {selectedArea ? selectedArea.name : 'Todas las Áreas'}{selectedCompany ? ` · ${selectedCompany.name}` : ''} +
+
+ +
+ + +
+ + {canReadInventory &&
+ + setSearch(event.target.value)} placeholder="Buscar en el Inventario…" /> + + } + +
+ {canReadInventory && Inventario} + {canReadInspections && Inspecciones} + {(areaId || companyId) && } +
+
; +} diff --git a/web-v2/src/components/SearchableSelect.tsx b/web-v2/src/components/SearchableSelect.tsx new file mode 100644 index 0000000..e6226f3 --- /dev/null +++ b/web-v2/src/components/SearchableSelect.tsx @@ -0,0 +1,228 @@ +import { + Children, + Fragment, + isValidElement, + useEffect, + useId, + useMemo, + useRef, + useState, +} from 'react'; +import type { + ReactElement, + ReactNode, + SelectHTMLAttributes, +} from 'react'; +import { createPortal } from 'react-dom'; +import { Icon } from './Icon'; + +interface FlatOption { + value: string; + label: string; + disabled: boolean; +} + +type SearchableSelectProps = SelectHTMLAttributes & { + searchPlaceholder?: string; +}; + +function textContent(node: ReactNode): string { + if (node == null || typeof node === 'boolean') return ''; + if (typeof node === 'string' || typeof node === 'number') return String(node); + if (Array.isArray(node)) return node.map(textContent).join(''); + if (isValidElement(node)) { + return textContent((node.props as { children?: ReactNode }).children); + } + return ''; +} + +function flattenOptions(node: ReactNode, target: FlatOption[]): void { + Children.forEach(node, (child) => { + if (!isValidElement(child)) return; + const element = child as ReactElement<{ + children?: ReactNode; + value?: string | number; + disabled?: boolean; + }>; + if (element.type === 'option') { + target.push({ + value: element.props.value == null ? textContent(element.props.children) : String(element.props.value), + label: textContent(element.props.children).trim(), + disabled: Boolean(element.props.disabled), + }); + return; + } + if (element.type === Fragment || element.props.children != null) { + flattenOptions(element.props.children, target); + } + }); +} + +function normalized(value: string): string { + return value + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .toLocaleLowerCase('es-AR') + .trim(); +} + +export function SearchableSelect({ + children, + className, + disabled, + value, + defaultValue, + onChange, + onBlur, + onFocus, + searchPlaceholder = 'Buscar…', + id, + ...selectProps +}: SearchableSelectProps) { + const selectRef = useRef(null); + const buttonRef = useRef(null); + const searchRef = useRef(null); + const popupRef = useRef(null); + const generatedId = useId(); + const resolvedId = id || generatedId; + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(''); + const [popupStyle, setPopupStyle] = useState({ top: 0, left: 0, width: 240 }); + + const options = useMemo(() => { + const result: FlatOption[] = []; + flattenOptions(children, result); + return result; + }, [children]); + + const selectedValue = value == null + ? defaultValue == null ? '' : String(defaultValue) + : String(value); + const selected = options.find((option) => option.value === selectedValue); + const placeholder = options.find((option) => option.value === '')?.label || 'Seleccionar…'; + const queryNormalized = normalized(query); + const filtered = queryNormalized + ? options.filter((option) => normalized(option.label).includes(queryNormalized)) + : options; + + const positionPopup = () => { + const rect = buttonRef.current?.getBoundingClientRect(); + if (!rect) return; + const margin = 8; + const desiredWidth = Math.max(rect.width, 260); + const width = Math.min(desiredWidth, window.innerWidth - margin * 2); + const left = Math.max(margin, Math.min(rect.left, window.innerWidth - width - margin)); + const roomBelow = window.innerHeight - rect.bottom; + const top = roomBelow >= 320 + ? rect.bottom + 5 + : Math.max(margin, rect.top - Math.min(315, rect.top - margin)); + setPopupStyle({ top, left, width }); + }; + + const close = () => { + setOpen(false); + setQuery(''); + }; + + const choose = (nextValue: string) => { + const native = selectRef.current; + if (!native) return; + const descriptor = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value'); + descriptor?.set?.call(native, nextValue); + native.dispatchEvent(new Event('change', { bubbles: true })); + close(); + window.setTimeout(() => buttonRef.current?.focus(), 0); + }; + + useEffect(() => { + if (!open) return; + positionPopup(); + const reposition = () => positionPopup(); + const outside = (event: MouseEvent) => { + const node = event.target as Node; + if (popupRef.current?.contains(node) || buttonRef.current?.contains(node)) return; + close(); + }; + window.addEventListener('resize', reposition); + window.addEventListener('scroll', reposition, true); + document.addEventListener('mousedown', outside); + window.setTimeout(() => searchRef.current?.focus(), 0); + return () => { + window.removeEventListener('resize', reposition); + window.removeEventListener('scroll', reposition, true); + document.removeEventListener('mousedown', outside); + }; + }, [open]); + + return
+ + + {open && createPortal(
+
setQuery(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Escape') { + event.preventDefault(); + close(); + buttonRef.current?.focus(); + } + if (event.key === 'Enter') { + const first = filtered.find((option) => !option.disabled); + if (first) { + event.preventDefault(); + choose(first.value); + } + } + }} + placeholder={searchPlaceholder} + aria-label={searchPlaceholder} + />
+
+ {filtered.length === 0 ?
Sin coincidencias
: filtered.map((option, index) => )} +
+
, document.body)} +
; +} diff --git a/web-v2/src/config/version.ts b/web-v2/src/config/version.ts new file mode 100644 index 0000000..85668f7 --- /dev/null +++ b/web-v2/src/config/version.ts @@ -0,0 +1,2 @@ +export const APP_VERSION = '0.19.6-4'; +export const APP_PHASE = 'Fase D5.6.4 · Inspecciones simples y combobox buscables'; diff --git a/web-v2/src/context/OperationalContext.tsx b/web-v2/src/context/OperationalContext.tsx new file mode 100644 index 0000000..bb515f1 --- /dev/null +++ b/web-v2/src/context/OperationalContext.tsx @@ -0,0 +1,64 @@ +import { createContext, useContext, useEffect, useMemo, useState } from 'react'; +import type { PropsWithChildren } from 'react'; + +interface OperationalContextValue { + areaId: string; + companyId: string; + setAreaId: (areaId: string) => void; + setCompanyId: (companyId: string) => void; + setContext: (areaId: string, companyId?: string) => void; + clearContext: () => void; +} + +const STORAGE_KEY = 'dhv2.operational-context'; +const OperationalContext = createContext(null); + +function readStoredContext(): { areaId: string; companyId: string } { + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) return { areaId: '', companyId: '' }; + const parsed = JSON.parse(raw) as { areaId?: unknown; companyId?: unknown }; + return { + areaId: typeof parsed.areaId === 'string' ? parsed.areaId : '', + companyId: typeof parsed.companyId === 'string' ? parsed.companyId : '', + }; + } catch { + return { areaId: '', companyId: '' }; + } +} + +export function OperationalContextProvider({ children }: PropsWithChildren) { + const initial = useMemo(readStoredContext, []); + const [areaId, setAreaState] = useState(initial.areaId); + const [companyId, setCompanyState] = useState(initial.companyId); + + useEffect(() => { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify({ areaId, companyId })); + }, [areaId, companyId]); + + const value = useMemo(() => ({ + areaId, + companyId, + setAreaId: (nextAreaId) => { + setAreaState(nextAreaId); + setCompanyState(''); + }, + setCompanyId: setCompanyState, + setContext: (nextAreaId, nextCompanyId = '') => { + setAreaState(nextAreaId); + setCompanyState(nextAreaId ? nextCompanyId : ''); + }, + clearContext: () => { + setAreaState(''); + setCompanyState(''); + }, + }), [areaId, companyId]); + + return {children}; +} + +export function useOperationalContext() { + const value = useContext(OperationalContext); + if (!value) throw new Error('useOperationalContext debe usarse dentro de OperationalContextProvider'); + return value; +} diff --git a/web-v2/src/features/assets/AssetCenterTabs.tsx b/web-v2/src/features/assets/AssetCenterTabs.tsx new file mode 100644 index 0000000..3384398 --- /dev/null +++ b/web-v2/src/features/assets/AssetCenterTabs.tsx @@ -0,0 +1,22 @@ +import { NavLink } from 'react-router'; +import { useAuth } from '../../auth/AuthContext'; +import { Icon } from '../../components/Icon'; + +export function AssetCenterTabs({ active }: { active: 'navigate' | 'list' | 'field' | 'map' | 'history' | 'temporal' }) { + const { hasPermission } = useAuth(); + const items = [ + { key: 'navigate', to: '/inventarios', label: 'Navegar', icon: 'layers' as const, show: true }, + { key: 'list', to: '/inventarios?view=list', label: 'Listado', icon: 'layers' as const, show: true }, + { key: 'field', to: '/inventarios/revision-campo', label: 'Altas de campo', icon: 'layers' as const, show: hasPermission('assets.change_status') }, + { key: 'map', to: '/mapa', label: 'Mapa', icon: 'map' as const, show: true }, + { key: 'history', to: '/historial', label: 'Historial', icon: 'history' as const, show: hasPermission('assets.read_history') }, + { key: 'temporal', to: '/consulta-temporal', label: 'Consulta temporal', icon: 'history' as const, show: hasPermission('assets.read_temporal') }, + ]; + return ; +} diff --git a/web-v2/src/features/assets/AssetContextHistoryPanel.tsx b/web-v2/src/features/assets/AssetContextHistoryPanel.tsx new file mode 100644 index 0000000..5cce57f --- /dev/null +++ b/web-v2/src/features/assets/AssetContextHistoryPanel.tsx @@ -0,0 +1,180 @@ +import { SearchableSelect } from '../../components/SearchableSelect'; +import { useEffect, useMemo, useState } from 'react'; +import type { FormEvent } from 'react'; +import { Alert, LoadingBlock, errorMessage } from '../../components/Feedback'; +import { Icon } from '../../components/Icon'; +import { + changeAssetContext, + listAssetContextHistory, + listAssetParentOptions, + listCompaniesForArea, + listOperationalAreas, +} from '../../lib/api'; +import type { + AssetContextHistoryItem, + AssetDetail, + AssetListItem, + AssetType, + OperationalAssetSummary, +} from '../../lib/api'; +import { formatDate } from '../../lib/format'; + +function localDateTimeNow(): string { + const now = new Date(); + const local = new Date(now.getTime() - now.getTimezoneOffset() * 60_000); + return local.toISOString().slice(0, 16); +} + +function contextLabel(item: AssetContextHistoryItem) { + const parts = [ + item.parent ? `Padre: ${item.parent.name}` : 'Sin padre', + item.operationalArea ? `Área: ${item.operationalArea.name}` : null, + item.operatorCompany ? `Operadora: ${item.operatorCompany.name}` : null, + ].filter(Boolean); + return parts.join(' · '); +} + +export function AssetContextHistoryPanel({ + asset, + type, + canManage, + onChanged, +}: { + asset: AssetDetail; + type: AssetType; + canManage: boolean; + onChanged: (asset: AssetDetail) => void; +}) { + const [history, setHistory] = useState([]); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [editing, setEditing] = useState(false); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(''); + const [parentId, setParentId] = useState(asset.parent?.id ?? ''); + const [parentSearch, setParentSearch] = useState(''); + const [parents, setParents] = useState([]); + const [operationalAreaId, setOperationalAreaId] = useState(asset.operationalArea?.id ?? ''); + const [operatorCompanyId, setOperatorCompanyId] = useState(asset.operatorCompany?.id ?? ''); + const [areas, setAreas] = useState([]); + const [companies, setCompanies] = useState([]); + const [effectiveAt, setEffectiveAt] = useState(localDateTimeNow()); + const [reason, setReason] = useState(''); + + const genericContext = type.operationalRole === 'GENERIC'; + const current = useMemo(() => history.find((item) => item.isCurrent) ?? history[0] ?? null, [history]); + + const loadHistory = () => { + setLoading(true); + listAssetContextHistory(asset.id) + .then(setHistory) + .catch((requestError) => setError(errorMessage(requestError))) + .finally(() => setLoading(false)); + }; + + useEffect(loadHistory, [asset.id]); + + useEffect(() => { + if (!editing) return; + const timer = window.setTimeout(() => { + listAssetParentOptions(type.id, asset.id, parentSearch) + .then(setParents) + .catch((requestError) => setError(errorMessage(requestError))); + }, 180); + return () => window.clearTimeout(timer); + }, [editing, type.id, asset.id, parentSearch]); + + useEffect(() => { + if (!editing || !genericContext || !parentId) { + setAreas([]); + return; + } + listOperationalAreas(parentId) + .then((items) => { + setAreas(items); + if (operationalAreaId && !items.some((item) => item.id === operationalAreaId)) { + setOperationalAreaId(''); + setOperatorCompanyId(''); + } + }) + .catch((requestError) => setError(errorMessage(requestError))); + }, [editing, genericContext, parentId]); + + useEffect(() => { + if (!editing || !genericContext || !operationalAreaId) { + setCompanies([]); + return; + } + listCompaniesForArea(operationalAreaId) + .then((items) => { + setCompanies(items); + if (operatorCompanyId && !items.some((item) => item.id === operatorCompanyId)) setOperatorCompanyId(''); + }) + .catch((requestError) => setError(errorMessage(requestError))); + }, [editing, genericContext, operationalAreaId]); + + const beginEdit = () => { + setParentId(asset.parent?.id ?? ''); + setOperationalAreaId(asset.operationalArea?.id ?? ''); + setOperatorCompanyId(asset.operatorCompany?.id ?? ''); + setEffectiveAt(localDateTimeNow()); + setReason(''); + setError(''); + setSuccess(''); + setEditing(true); + }; + + const save = async (event: FormEvent) => { + event.preventDefault(); + setSaving(true); + setError(''); + setSuccess(''); + try { + const saved = await changeAssetContext(asset.id, { + parentId: parentId || null, + operationalAreaId: genericContext ? operationalAreaId || null : asset.operationalArea?.id ?? null, + operatorCompanyId: genericContext ? operatorCompanyId || null : asset.operatorCompany?.id ?? null, + effectiveAt: effectiveAt ? new Date(effectiveAt).toISOString() : undefined, + reason, + }); + onChanged(saved); + setEditing(false); + setSuccess('Contexto actualizado. La asignación anterior quedó preservada en el historial.'); + loadHistory(); + } catch (requestError) { + setError(errorMessage(requestError)); + } finally { + setSaving(false); + } + }; + + return
+
+
CONTEXTO TEMPORAL

Jerarquía, Área y Operadora

+ {canManage && !editing && } +
+

Los cambios no reemplazan la historia. Cada asignación conserva desde cuándo fue válida y qué relación la reemplazó.

+ {error && {error}} + {success && {success}} + + {current &&

Contexto vigente. {contextLabel(current)}

} + + {editing &&
+

Cambiar contexto vigente

Indicá el nuevo lugar dentro del Inventario y el motivo. La relación anterior se cierra automáticamente.

+
+
Registro padre setParentSearch(event.target.value)} placeholder="Buscar registro padre…" /> { setParentId(event.target.value); setParentSearch(''); }} required={!type.canBeRoot}>{parents.map((parent) => )}
+ +
+ {genericContext &&
+ + +
} +