Compare commits

...
Author SHA1 Message Date
admin ccca7a6ff1 F4.8 · pruebas de cierre de Inspección 2026-09-07 20:13:41 -03:00
admin 63671686a9 F4.8 · sellado físico del Acta 2026-09-07 20:13:28 -03:00
admin 8baba89ce6 F4.8 · registrar cierre autoritativo 2026-09-07 20:13:17 -03:00
admin ad65b44142 F4.8 · activar cierre autoritativo 2026-09-07 20:13:10 -03:00
admin d0cc7c6f9d F4.8 · cierre autoritativo de Inspección 2026-09-07 20:12:48 -03:00
admin b80f83ed5f F4.7 · retirar respuesta de empresa por Hallazgo 2026-09-07 20:11:46 -03:00
admin fbc63fafb5 F4.7 · pruebas de reincidencia 2026-09-07 20:11:16 -03:00
admin edc05a5f50 F4.7 · activar reincidencias 2026-09-07 20:11:06 -03:00
admin ae583a8e45 F4.7 · endpoints de reincidencia 2026-09-07 20:10:54 -03:00
admin b160e7344b F4.7 · servicio de reincidencias 2026-09-07 20:10:38 -03:00
admin 41b52d34bf F4.7 · DTO de reincidencia 2026-09-07 20:10:11 -03:00
admin 7dab175958 F4.6 · retirar pruebas históricas de Survey 2026-09-07 20:09:28 -03:00
admin f1dbb75834 F4.6 · eliminar código muerto de Campañas 2026-09-07 20:07:23 -03:00
admin e9b318fdb5 F4.6 · retirar Campañas del producto activo 2026-09-07 20:06:21 -03:00
admin 890b54f7c8 F4.5 · SMTP Superadmin y Report Word al Inspector 2026-09-07 20:00:53 -03:00
admin 3634768f9a F4.4 · numeración institucional ACT e INF 2026-09-07 19:56:40 -03:00
admin fbe8f2e8cf F4.3 · dossier INF, GEDO IF y seguimiento 2026-09-07 19:55:04 -03:00
admin 367c7df45a F4.2 · bloquear Acta con urgencia y plazo congelado 2026-09-07 19:51:24 -03:00
admin 0f9aa589b5 F4.1 · administración de plazos por JEFE 2026-09-07 19:46:57 -03:00
admin dac6c94370 F4.0 · actualizar contratos y pruebas del nuevo flujo 2026-09-07 19:43:26 -03:00
admin 1cd11e83ab F4.0 · activar entidades de plazo y seguimiento 2026-09-07 19:42:21 -03:00
admin eb89680c32 F4.0 · numeración INSP y lifecycle explícito 2026-09-07 19:41:29 -03:00
admin cbab839935 F4.0 foundation · urgencia, GEDO y seguimiento 2026-09-07 19:39:43 -03:00
admin ce18f5d1a3 F3.2 · Android multi-Acta y cierre operativo
Integra selección/creación explícita de Actas, flujo móvil de firmas/cierre, soporte de Actas READY paralelas, cierre validado por servidor y Actas de verificación sin Hallazgos nuevos.
2026-09-07 17:30:53 -03:00
admin 03a3a66c51 F3.2 Android: permitir preparar Actas de verificación sin Hallazgos nuevos 2026-09-07 16:50:17 -03:00
admin 2d4e8ae2a7 F3.2 Android: permitir Actas READY paralelas y delegar cierre al servidor 2026-09-07 16:43:24 -03:00
admin 3e5dfb78a2 F3.2 Android: corregir Card clickeable de mensajes 2026-09-07 16:35:37 -03:00
admin ecf6f71c58 F3.2 Android: corregir API de PointerInput en firma 2026-09-07 16:34:33 -03:00
admin bd0f22969c F3.2 API: restaurar manifiesto exacto de producción 2026-09-07 16:26:48 -03:00
admin 5f3987747d F3.2 API: conservar dependencias y cambiar sólo versión 2026-09-07 16:26:32 -03:00
admin a208788488 F3.2: agregar CI de integración multi-Acta 2026-09-07 16:25:23 -03:00
admin 2e5183cc3c F3.2 API: actualizar versión de paquete 2026-09-07 16:24:57 -03:00
admin 80ce9ced0f F3.2 API: versionar integración multi-Acta móvil 2026-09-07 16:24:42 -03:00
admin d49fb81bcf F3.2 Android: activar CI y artefacto 0.12.0 2026-09-07 16:24:31 -03:00
admin 9e50967089 F3.2 Android: versionar APK 0.12.0 2026-09-07 16:24:16 -03:00
admin a1450da51e F3.2 Android: integrar Actas en la navegación de inspección 2026-09-07 16:23:10 -03:00
admin 97a774c264 F3.2 Android: implementar gestión y cierre secuencial de Actas 2026-09-07 16:20:08 -03:00
admin c9f1434462 F3.2 Android: agregar pad manuscrito PNG para firmas 2026-09-07 16:18:28 -03:00
admin d14cf737c0 F3.2 Android: incorporar estado multi-Acta y cierre 2026-09-07 16:16:09 -03:00
admin 9f60e671d2 F3.2 Android: agregar cliente de Actas y cierre 2026-09-07 16:14:43 -03:00
admin 28f6fb3684 F3.2 Android: enviar Acta explícita en Hallazgos 2026-09-07 16:13:52 -03:00
admin d7184ed537 F3.2: fijar contrato multi-Acta móvil y cierre secuencial 2026-09-07 16:13:22 -03:00
admin 203443d9c6 F3.2: resolver Hallazgos contra Acta explícita 2026-09-07 16:12:03 -03:00
admin 45ca6a783e F3.2: recibir Acta explícita en Hallazgos de campo 2026-09-07 16:11:20 -03:00
admin d3bfdeac67 F3.2: permitir Acta explícita al crear Hallazgo de campo 2026-09-07 16:11:05 -03:00
admin 9479ae6d1a F3.2: agregar selección explícita de Acta en Hallazgos de campo 2026-09-07 16:10:49 -03:00
admin 4ca65c6a89 F3.1 hotfix · aislar preflight API del WEB (#19)
Separa el contrato visual F3.1 del test API y agrega a CI el mismo preflight Docker aislado que ejecuta el VPS antes de producción.
2026-09-07 15:53:27 -03:00
106 changed files with 6070 additions and 3023 deletions
+3 -2
View File
@@ -1,5 +1,5 @@
name: Android APK
# F3.1: este workflow genera la APK debug verificable de la rama antes de promoverla.
# F3.2: genera la APK debug verificable antes de promover la integración multi-Acta.
on:
push:
@@ -8,6 +8,7 @@ on:
- 'feature/f2-3*'
- 'feature/f2-4*'
- 'feature/f3-1*'
- 'feature/f3-2*'
paths:
- 'android-app/**'
- '.github/workflows/android.yml'
@@ -57,7 +58,7 @@ jobs:
- name: Upload APK
uses: actions/upload-artifact@v4
with:
name: DH-Inspeccion-F3.1-0.11.0-debug
name: DH-Inspeccion-F3.2-0.12.0-debug
path: android-app/app/build/outputs/apk/debug/app-debug.apk
if-no-files-found: error
retention-days: 14
+80
View File
@@ -0,0 +1,80 @@
name: F3.2 Multi-Acta CI
on:
push:
branches:
- 'feature/f3-2*'
workflow_dispatch:
permissions:
contents: read
jobs:
api:
name: API · F3.2
runs-on: ubuntu-latest
timeout-minutes: 25
defaults:
run:
working-directory: api-v3
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '24'
cache: npm
cache-dependency-path: api-v3/package-lock.json
- run: npm ci
- name: Typecheck
run: npm run typecheck
- name: Tests
run: npm test
- name: Build
run: npm run build
web:
name: WEB · regression
runs-on: ubuntu-latest
timeout-minutes: 20
defaults:
run:
working-directory: web-v2
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '24'
cache: npm
cache-dependency-path: web-v2/package-lock.json
- run: npm ci
- run: npm run typecheck
- name: F3.1 structural WEB contract
run: bash ../scripts/check-f3-1-web-contract.sh
- run: npm run build
deploy-preflight:
name: VPS-equivalent preflight / Docker
runs-on: ubuntu-latest
timeout-minutes: 30
needs: [api, web]
steps:
- uses: actions/checkout@v4
- name: Validate shell scripts
run: |
while IFS= read -r -d '' script; do
bash -n "$script"
done < <(find scripts -type f -name '*.sh' -print0)
- name: Validate Compose
run: docker compose --env-file .env.example config >/dev/null
- name: VPS-equivalent isolated API tests
run: |
set -Eeuo pipefail
image="dhv2-api:f3-2-preflight-${GITHUB_SHA::12}"
docker build --target builder -t "$image" api-v3
docker run --rm \
-v "$PWD/api-v3/test:/app/test:ro" \
-v "$PWD/api-v3/tsconfig.test.json:/app/tsconfig.test.json:ro" \
"$image" npm test
docker image rm "$image" >/dev/null 2>&1 || true
- name: Build production images
run: docker compose --env-file .env.example build api migrate web
+89
View File
@@ -0,0 +1,89 @@
name: F4 Document Flow CI
on:
push:
branches:
- 'feature/f4*'
pull_request:
branches:
- main
paths:
- 'api-v3/**'
- 'web-v2/**'
- 'android-app/**'
- 'scripts/**'
- '.github/workflows/f4-ci.yml'
workflow_dispatch:
permissions:
contents: read
jobs:
api:
name: API · F4
runs-on: ubuntu-latest
timeout-minutes: 25
defaults:
run:
working-directory: api-v3
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '24'
cache: npm
cache-dependency-path: api-v3/package-lock.json
- run: npm ci
- name: Typecheck
run: npm run typecheck
- name: Tests
run: npm test
- name: Build
run: npm run build
web:
name: WEB · regression
runs-on: ubuntu-latest
timeout-minutes: 20
defaults:
run:
working-directory: web-v2
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '24'
cache: npm
cache-dependency-path: web-v2/package-lock.json
- run: npm ci
- run: npm run typecheck
- name: F3.1 structural WEB contract
run: bash ../scripts/check-f3-1-web-contract.sh
- run: npm run build
deploy-preflight:
name: VPS-equivalent preflight / Docker
runs-on: ubuntu-latest
timeout-minutes: 30
needs: [api, web]
steps:
- uses: actions/checkout@v4
- name: Validate shell scripts
run: |
while IFS= read -r -d '' script; do
bash -n "$script"
done < <(find scripts -type f -name '*.sh' -print0)
- name: Validate Compose
run: docker compose --env-file .env.example config >/dev/null
- name: VPS-equivalent isolated API tests
run: |
set -Eeuo pipefail
image="dhv2-api:f4-preflight-${GITHUB_SHA::12}"
docker build --target builder -t "$image" api-v3
docker run --rm \
-v "$PWD/api-v3/test:/app/test:ro" \
-v "$PWD/api-v3/tsconfig.test.json:/app/tsconfig.test.json:ro" \
"$image" npm test
docker image rm "$image" >/dev/null 2>&1 || true
- name: Build production images
run: docker compose --env-file .env.example build api migrate web
+2 -2
View File
@@ -12,8 +12,8 @@ android {
applicationId = "com.korexlabs.dhinspeccion"
minSdk = 26
targetSdk = 36
versionCode = 18
versionName = "0.11.0"
versionCode = 19
versionName = "0.12.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true
@@ -16,6 +16,11 @@ import com.korexlabs.dhinspeccion.data.FieldFindingOptionsResponse
import com.korexlabs.dhinspeccion.data.FieldFindingsRepository
import com.korexlabs.dhinspeccion.data.FieldInventoryItem
import com.korexlabs.dhinspeccion.data.FieldType
import com.korexlabs.dhinspeccion.data.MobileActClosure
import com.korexlabs.dhinspeccion.data.MobileActDetail
import com.korexlabs.dhinspeccion.data.MobileActSummary
import com.korexlabs.dhinspeccion.data.MobileActsRepository
import com.korexlabs.dhinspeccion.data.MobileResponsibleRequest
import com.korexlabs.dhinspeccion.data.StoredSession
import com.korexlabs.dhinspeccion.data.VisitDetail
import com.korexlabs.dhinspeccion.data.VisitSummary
@@ -26,6 +31,7 @@ import java.time.Instant
class MainViewModel(application: Application) : AndroidViewModel(application) {
private val repository = DhRepository(application)
private val findingsRepository = FieldFindingsRepository(application)
private val actsRepository = MobileActsRepository(application)
var session: StoredSession? by mutableStateOf(repository.currentSession())
private set
@@ -48,6 +54,13 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
var selectedFieldAsset: FieldAssetDetail? by mutableStateOf(null)
private set
var acts: List<MobileActSummary> by mutableStateOf(emptyList())
private set
var selectedAct: MobileActDetail? by mutableStateOf(null)
private set
var actClosure: MobileActClosure? by mutableStateOf(null)
private set
var fieldFindingOptions: FieldFindingOptionsResponse? by mutableStateOf(null)
private set
var lastCreatedFinding: FieldFindingItem? by mutableStateOf(null)
@@ -85,6 +98,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
inventory = emptyList()
fieldTypes = emptyList()
selectedFieldAsset = null
clearActState()
clearFindingState()
}
}
@@ -101,6 +115,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fieldTypes = emptyList()
selectedFieldAsset = null
clearFindingState()
loadActsInternal(id, selectDraft = true)
}
fun closeVisitView() {
@@ -108,6 +123,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
inventory = emptyList()
fieldTypes = emptyList()
selectedFieldAsset = null
clearActState()
clearFindingState()
loadVisits()
}
@@ -117,10 +133,51 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
launchBusy {
visit = repository.startVisit(id)
notice = "Inspección iniciada."
loadActsInternal(id, selectDraft = true)
loadVisitsInternal()
}
}
fun reloadActs() {
val visitId = visit?.id ?: return
launchBusy { loadActsInternal(visitId, selectDraft = selectedAct == null) }
}
fun selectAct(actId: String) {
launchBusy {
selectedAct = actsRepository.get(actId)
actClosure = actsRepository.closure(actId)
clearFindingState()
}
}
fun createActForSelectedInventory() {
val currentVisit = visit ?: return
val asset = selectedFieldAsset?.asset
if (currentVisit.status != "IN_PROGRESS") {
error = "La inspección debe estar en curso para crear un Acta."
return
}
if (asset == null) {
error = "Seleccioná primero una Instalación o Subinstalación para iniciar el Acta."
return
}
if (acts.any { it.status == "DRAFT" }) {
error = "Ya existe un Acta en borrador. Cerrala o cancelala antes de crear la siguiente."
return
}
launchBusy {
val created = actsRepository.create(currentVisit.id, asset.id, currentVisit.code)
selectedAct = created
actClosure = actsRepository.closure(created.id)
loadActsInternal(currentVisit.id, selectDraft = false)
notice = "${created.code} creada. Los Hallazgos nuevos quedarán vinculados explícitamente a esta Acta."
if (selectedFieldAsset?.capture?.readyForFinding == true) {
loadFindingOptionsInternal(currentVisit.id, asset.id, created.id)
}
}
}
fun searchInventory(search: String, parentId: String? = null) {
val id = visit?.id ?: return
launchBusy {
@@ -141,8 +198,13 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
selectedFieldAsset = repository.selectFieldAsset(visitId, item.id)
notice = "Inventario agregado a la inspección."
inventory = repository.fieldInventory(visitId, null, null).data
if (selectedFieldAsset?.capture?.readyForFinding == true) {
loadFindingOptionsInternal(visitId, item.id)
val draft = selectedDraftAct()
if (selectedFieldAsset?.capture?.readyForFinding == true && draft != null) {
selectedAct = actsRepository.ensureAsset(draft.id, item.id)
loadActsInternal(visitId, selectDraft = false)
loadFindingOptionsInternal(visitId, item.id, draft.id)
} else if (selectedFieldAsset?.capture?.readyForFinding == true) {
notice = "Inventario listo. Creá o seleccioná un Acta antes de registrar Hallazgos."
}
}
}
@@ -207,8 +269,11 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
selectedFieldAsset = repository.selectFieldAsset(visitId, result.canonical.id)
notice = "Fusión registrada. Se conserva ${result.canonical.code} y la historia de ${result.source.code} permanece trazable."
inventory = repository.fieldInventory(visitId, null, null).data
if (selectedFieldAsset?.capture?.readyForFinding == true) {
loadFindingOptionsInternal(visitId, result.canonical.id)
val draft = selectedDraftAct()
if (selectedFieldAsset?.capture?.readyForFinding == true && draft != null) {
selectedAct = actsRepository.ensureAsset(draft.id, result.canonical.id)
loadActsInternal(visitId, selectDraft = false)
loadFindingOptionsInternal(visitId, result.canonical.id, draft.id)
}
}
}
@@ -237,8 +302,13 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
"Fotografía registrada."
}
inventory = repository.fieldInventory(visitId, null, null).data
if (response.capture.readyForFinding) {
loadFindingOptionsInternal(visitId, asset.id)
val draft = selectedDraftAct()
if (response.capture.readyForFinding && draft != null) {
selectedAct = actsRepository.ensureAsset(draft.id, asset.id)
loadActsInternal(visitId, selectDraft = false)
loadFindingOptionsInternal(visitId, asset.id, draft.id)
} else if (response.capture.readyForFinding) {
notice = "Inventario listo. Creá o seleccioná un Acta antes de registrar Hallazgos."
}
}
}
@@ -246,7 +316,16 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun openFindingForSelected() {
val visitId = visit?.id ?: return
val assetId = selectedFieldAsset?.asset?.id ?: return
launchBusy { loadFindingOptionsInternal(visitId, assetId) }
val draft = selectedDraftAct()
if (draft == null) {
error = "Creá o seleccioná el Acta en borrador antes de registrar Hallazgos."
return
}
launchBusy {
selectedAct = actsRepository.ensureAsset(draft.id, assetId)
loadActsInternal(visitId, selectDraft = false)
loadFindingOptionsInternal(visitId, assetId, draft.id)
}
}
fun createFieldFinding(
@@ -259,6 +338,11 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
) {
val visitId = visit?.id ?: return
val assetId = selectedFieldAsset?.asset?.id ?: return
val actId = selectedDraftAct()?.id
if (actId == null) {
error = "No hay un Acta en borrador seleccionada."
return
}
if (description.isBlank()) {
error = "Describí el Hallazgo antes de guardarlo."
return
@@ -272,10 +356,12 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
return
}
launchBusy {
selectedAct = actsRepository.ensureAsset(actId, assetId)
val response = findingsRepository.create(
visitId,
assetId,
CreateFieldFindingRequest(
actId = actId,
catalogItemId = catalogItemId,
customTitle = customTitle?.trim()?.takeIf { it.isNotBlank() },
customLegalBasis = customLegalBasis?.trim()?.takeIf { it.isNotBlank() },
@@ -285,8 +371,9 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
),
)
lastCreatedFinding = response.finding
notice = "Hallazgo ${response.finding.code} registrado. Podés agregar evidencia fotográfica."
loadFindingOptionsInternal(visitId, assetId, keepLastCreated = true)
notice = "Hallazgo ${response.finding.code} registrado en ${response.act.code}. Podés agregar evidencia fotográfica."
loadFindingOptionsInternal(visitId, assetId, actId, keepLastCreated = true)
loadActsInternal(visitId, selectDraft = false)
}
}
@@ -318,6 +405,139 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
launchBusy { loadEvidenceInternal(findingId) }
}
fun setCompanyResponsiblePresent(
fullName: String,
documentType: String,
documentNumber: String,
position: String,
email: String?,
phone: String?,
) {
val actId = selectedAct?.id ?: return
if (fullName.isBlank() || documentNumber.isBlank() || position.isBlank()) {
error = "Completá nombre, documento y cargo del responsable de la empresa."
return
}
launchBusy {
actClosure = actsRepository.setResponsible(
actId,
MobileResponsibleRequest(
attendanceStatus = "PRESENT",
fullName = fullName.trim(),
documentType = documentType,
documentNumber = documentNumber.trim(),
position = position.trim(),
email = email?.trim()?.takeIf { it.isNotBlank() },
phone = phone?.trim()?.takeIf { it.isNotBlank() },
),
)
notice = "Responsable de empresa registrado para el Acta."
}
}
fun setCompanyResponsibleAbsent(reason: String) {
val actId = selectedAct?.id ?: return
if (reason.trim().length < 10) {
error = "Indicá un motivo de ausencia de al menos 10 caracteres."
return
}
launchBusy {
actClosure = actsRepository.setResponsible(
actId,
MobileResponsibleRequest(
attendanceStatus = "ABSENT",
absenceReason = reason.trim(),
),
)
notice = "Ausencia del responsable registrada."
}
}
fun prepareSelectedAct() {
val actId = selectedAct?.id ?: return
launchBusy {
actClosure = actsRepository.prepare(actId)
refreshSelectedActInternal(actId)
notice = "Acta preparada. Su contenido quedó congelado para las firmas."
}
}
fun reopenSelectedAct() {
val actId = selectedAct?.id ?: return
launchBusy {
actClosure = actsRepository.reopen(actId)
refreshSelectedActInternal(actId)
notice = "Acta reabierta. Podés corregirla antes de volver a preparar."
}
}
fun signSelectedActAsInspector(
png: File,
latitude: Double?,
longitude: Double?,
accuracyM: Double?,
) {
val actId = selectedAct?.id ?: return
launchBusy {
actClosure = actsRepository.signInspector(actId, png, latitude, longitude, accuracyM)
notice = "Firma del inspector incorporada al Acta."
}
}
fun signSelectedActAsCompany(
png: File,
latitude: Double?,
longitude: Double?,
accuracyM: Double?,
manifestation: String,
statement: String?,
) {
val actId = selectedAct?.id ?: return
launchBusy {
actClosure = actsRepository.signCompany(
actId, png, latitude, longitude, accuracyM, manifestation, statement,
)
notice = if (manifestation == "DISSENT") {
"Firma de empresa registrada con disidencia."
} else {
"Firma de empresa registrada."
}
}
}
fun recordCompanyOutcome(status: String, reason: String) {
val actId = selectedAct?.id ?: return
if (reason.trim().length < 10) {
error = "Indicá un motivo de al menos 10 caracteres."
return
}
launchBusy {
actClosure = actsRepository.companyOutcome(actId, status, reason)
notice = if (status == "ABSENT") "Ausencia de empresa asentada." else "Negativa a firmar asentada."
}
}
fun closeSelectedAct() {
val currentVisit = visit ?: return
val actId = selectedAct?.id ?: return
launchBusy {
actClosure = actsRepository.closeAct(actId)
refreshSelectedActInternal(actId)
loadActsInternal(currentVisit.id, selectDraft = false)
clearFindingState()
notice = "${selectedAct?.code ?: "Acta"} cerrada e inmutable. Podés crear otra Acta o finalizar la inspección."
}
}
fun closeInspection() {
val visitId = visit?.id ?: return
launchBusy {
visit = actsRepository.closeVisit(visitId)
loadVisitsInternal()
notice = "Inspección cerrada. Las Actas y documentos quedan disponibles para oficina."
}
}
fun clearFindingFlow() {
fieldFindingOptions = null
lastCreatedFinding = null
@@ -330,12 +550,43 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
clearFindingState()
}
private fun selectedDraftAct(): MobileActDetail? =
selectedAct?.takeIf { it.status == "DRAFT" }
?: acts.firstOrNull { it.status == "DRAFT" }?.let { summary ->
selectedAct?.takeIf { it.id == summary.id && it.status == "DRAFT" }
}
private suspend fun loadActsInternal(visitId: String, selectDraft: Boolean) {
acts = actsRepository.list(visitId).data
val currentId = selectedAct?.id
val current = currentId?.let { id -> acts.firstOrNull { it.id == id } }
val target = when {
current != null -> current.id
selectDraft -> acts.firstOrNull { it.status == "DRAFT" }?.id
else -> null
}
if (target != null) {
selectedAct = actsRepository.get(target)
actClosure = actsRepository.closure(target)
} else if (current == null) {
selectedAct = null
actClosure = null
}
}
private suspend fun refreshSelectedActInternal(actId: String) {
selectedAct = actsRepository.get(actId)
actClosure = actsRepository.closure(actId)
visit?.id?.let { loadActsInternal(it, selectDraft = false) }
}
private suspend fun loadFindingOptionsInternal(
visitId: String,
assetId: String,
actId: String,
keepLastCreated: Boolean = false,
) {
val options = findingsRepository.options(visitId, assetId)
val options = findingsRepository.options(visitId, assetId, actId)
fieldFindingOptions = options
if (!keepLastCreated) lastCreatedFinding = null
val loaded = linkedMapOf<String, List<FieldFindingEvidence>>()
@@ -351,6 +602,12 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
)
}
private fun clearActState() {
acts = emptyList()
selectedAct = null
actClosure = null
}
private fun clearFindingState() {
fieldFindingOptions = null
lastCreatedFinding = null
@@ -22,6 +22,7 @@ import retrofit2.http.Multipart
import retrofit2.http.POST
import retrofit2.http.Part
import retrofit2.http.Path
import retrofit2.http.Query
import java.io.File
import java.time.Instant
@@ -100,12 +101,15 @@ data class FieldFindingEvidenceListResponse(
data class FieldFindingOptionsResponse(
val act: FieldFindingAct,
val capture: CaptureStatus = CaptureStatus(),
val assetIncludedInAct: Boolean = true,
val catalog: FieldFindingCatalog,
val findings: List<FieldFindingItem> = emptyList(),
val canAddAnother: Boolean = true,
val actSelectionMode: String = "EXPLICIT",
)
data class CreateFieldFindingRequest(
val actId: String,
val catalogItemId: String? = null,
val customTitle: String? = null,
val customLegalBasis: String? = null,
@@ -119,6 +123,7 @@ data class FieldFindingCreateResponse(
val capture: CaptureStatus = CaptureStatus(),
val finding: FieldFindingItem,
val canAddAnother: Boolean = true,
val actSelectionMode: String = "EXPLICIT",
)
private interface FieldFindingsApi {
@@ -127,6 +132,7 @@ private interface FieldFindingsApi {
@Header("Authorization") authorization: String,
@Path("visitId") visitId: String,
@Path("assetId") assetId: String,
@Query("actId") actId: String,
): FieldFindingOptionsResponse
@POST("inspection-visits/{visitId}/field-findings/{assetId}")
@@ -166,7 +172,7 @@ private interface FieldFindingsApi {
/**
* Cliente de campo para Hallazgos y sus evidencias append-only.
* Comparte el almacén cifrado de sesión y nunca persiste la contraseña.
* F3.2 exige que la APK identifique explícitamente el Acta activa.
*/
class FieldFindingsRepository(context: Context) {
private val store = SecureSessionStore(context.applicationContext)
@@ -179,9 +185,9 @@ class FieldFindingsRepository(context: Context) {
.build()
.create(FieldFindingsApi::class.java)
suspend fun options(visitId: String, assetId: String): FieldFindingOptionsResponse =
suspend fun options(visitId: String, assetId: String, actId: String): FieldFindingOptionsResponse =
authorized { session ->
api.options("Bearer ${session.accessToken}", visitId, assetId)
api.options("Bearer ${session.accessToken}", visitId, assetId, actId)
}
suspend fun create(
@@ -0,0 +1,447 @@
package com.korexlabs.dhinspeccion.data
import android.content.Context
import com.korexlabs.dhinspeccion.BuildConfig
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.asRequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import retrofit2.HttpException
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.Multipart
import retrofit2.http.PATCH
import retrofit2.http.POST
import retrofit2.http.PUT
import retrofit2.http.Part
import retrofit2.http.Path
import retrofit2.http.Query
import java.io.File
import java.time.Instant
data class MobileActSummary(
val id: String,
val visitId: String,
val code: String,
val status: String,
val occurredAt: String,
val title: String,
val summary: String,
val observations: String? = null,
val currentVersion: Int = 0,
val closedAt: String? = null,
val closureSha256: String? = null,
val assetCount: Int = 0,
val findingCount: Int = 0,
)
data class MobileActDetail(
val id: String,
val visitId: String,
val code: String,
val status: String,
val occurredAt: String,
val title: String,
val summary: String,
val observations: String? = null,
val currentVersion: Int = 0,
val closedAt: String? = null,
val closureSha256: String? = null,
val assetCount: Int = 0,
val findingCount: Int = 0,
val assets: List<AssetSummary> = emptyList(),
)
data class MobileActListMeta(
val page: Int = 1,
val pageSize: Int = 100,
val total: Int = 0,
val totalPages: Int = 0,
)
data class MobileActListResponse(
val data: List<MobileActSummary> = emptyList(),
val meta: MobileActListMeta = MobileActListMeta(),
)
data class CreateMobileActRequest(
val occurredAt: String,
val title: String,
val summary: String,
val observations: String? = null,
val assetIds: List<String>,
)
data class UpdateMobileActRequest(
val assetIds: List<String>,
)
data class MobileResponsibleRequest(
val attendanceStatus: String,
val fullName: String? = null,
val documentType: String? = null,
val documentNumber: String? = null,
val position: String? = null,
val email: String? = null,
val phone: String? = null,
val absenceReason: String? = null,
)
data class MobileResponsible(
val actId: String,
val attendanceStatus: String,
val fullName: String? = null,
val documentType: String? = null,
val documentNumber: String? = null,
val position: String? = null,
val email: String? = null,
val phone: String? = null,
val absenceReason: String? = null,
)
data class MobileActClosureHeader(
val id: String,
val code: String,
val status: String,
val visitId: String,
val currentVersion: Int = 0,
val closedAt: String? = null,
val closureSha256: String? = null,
)
data class MobileVisitClosureHeader(
val id: String,
val code: String,
val status: String,
val actualClosedAt: String? = null,
)
data class MobileSignature(
val id: String,
val signerType: String,
val signerUserId: String? = null,
val signerName: String,
val status: String,
val reason: String? = null,
val companyManifestation: String? = null,
val companyStatement: String? = null,
val signedAt: String? = null,
val imageSha256: String? = null,
)
data class MobileClosureRecord(
val schemaVersion: String,
val preparedSha256: String,
val preparedAt: String,
val finalSha256: String? = null,
val deviceClosedAt: String? = null,
val serverClosedAt: String? = null,
val uploadMode: String? = null,
val isCurrent: Boolean = true,
)
data class MobileClosureConsents(
val version: String = "",
val inspector: String = "",
val company: String = "",
)
data class MobileActClosure(
val act: MobileActClosureHeader,
val visit: MobileVisitClosureHeader,
val responsible: MobileResponsible? = null,
val closure: MobileClosureRecord? = null,
val signatures: List<MobileSignature> = emptyList(),
val consents: MobileClosureConsents = MobileClosureConsents(),
)
data class MobileCompanyOutcomeRequest(
val status: String,
val reason: String,
)
data class MobileCloseActRequest(
val clientClosedAt: String = Instant.now().toString(),
val uploadMode: String = "ONLINE",
)
data class MobileCloseVisitRequest(
val clientClosedAt: String = Instant.now().toString(),
)
private interface MobileActsApi {
@GET("inspection-visits/{visitId}/acts")
suspend fun listActs(
@Header("Authorization") authorization: String,
@Path("visitId") visitId: String,
@Query("pageSize") pageSize: Int = 100,
): MobileActListResponse
@GET("inspection-acts/{actId}")
suspend fun act(
@Header("Authorization") authorization: String,
@Path("actId") actId: String,
): MobileActDetail
@POST("inspection-visits/{visitId}/acts")
suspend fun createAct(
@Header("Authorization") authorization: String,
@Path("visitId") visitId: String,
@Body request: CreateMobileActRequest,
): MobileActDetail
@PATCH("inspection-acts/{actId}")
suspend fun updateAct(
@Header("Authorization") authorization: String,
@Path("actId") actId: String,
@Body request: UpdateMobileActRequest,
): MobileActDetail
@GET("inspection-acts/{actId}/closure")
suspend fun closure(
@Header("Authorization") authorization: String,
@Path("actId") actId: String,
): MobileActClosure
@PUT("inspection-acts/{actId}/responsible")
suspend fun responsible(
@Header("Authorization") authorization: String,
@Path("actId") actId: String,
@Body request: MobileResponsibleRequest,
): MobileActClosure
@POST("inspection-acts/{actId}/ready")
suspend fun ready(
@Header("Authorization") authorization: String,
@Path("actId") actId: String,
): MobileActClosure
@POST("inspection-acts/{actId}/reopen")
suspend fun reopen(
@Header("Authorization") authorization: String,
@Path("actId") actId: String,
): MobileActClosure
@Multipart
@POST("inspection-acts/{actId}/signatures/inspector")
suspend fun signInspector(
@Header("Authorization") authorization: String,
@Path("actId") actId: String,
@Part file: MultipartBody.Part,
@Part("consentAccepted") consentAccepted: RequestBody,
@Part("clientSignedAt") clientSignedAt: RequestBody,
@Part("latitude") latitude: RequestBody?,
@Part("longitude") longitude: RequestBody?,
@Part("accuracyM") accuracyM: RequestBody?,
@Part("deviceLabel") deviceLabel: RequestBody,
): MobileActClosure
@Multipart
@POST("inspection-acts/{actId}/signatures/company")
suspend fun signCompany(
@Header("Authorization") authorization: String,
@Path("actId") actId: String,
@Part file: MultipartBody.Part,
@Part("consentAccepted") consentAccepted: RequestBody,
@Part("clientSignedAt") clientSignedAt: RequestBody,
@Part("latitude") latitude: RequestBody?,
@Part("longitude") longitude: RequestBody?,
@Part("accuracyM") accuracyM: RequestBody?,
@Part("deviceLabel") deviceLabel: RequestBody,
@Part("manifestation") manifestation: RequestBody?,
@Part("statement") statement: RequestBody?,
): MobileActClosure
@POST("inspection-acts/{actId}/company-outcome")
suspend fun companyOutcome(
@Header("Authorization") authorization: String,
@Path("actId") actId: String,
@Body request: MobileCompanyOutcomeRequest,
): MobileActClosure
@POST("inspection-acts/{actId}/close")
suspend fun closeAct(
@Header("Authorization") authorization: String,
@Path("actId") actId: String,
@Body request: MobileCloseActRequest,
): MobileActClosure
@POST("inspection-visits/{visitId}/close")
suspend fun closeVisit(
@Header("Authorization") authorization: String,
@Path("visitId") visitId: String,
@Body request: MobileCloseVisitRequest,
): VisitDetail
@POST("auth/mobile/refresh")
suspend fun refresh(@Body request: RefreshRequest): MobileSessionResponse
}
class MobileActsRepository(context: Context) {
private val store = SecureSessionStore(context.applicationContext)
private val refreshMutex = Mutex()
private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
private val api: MobileActsApi = Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL)
.client(OkHttpClient.Builder().build())
.addConverterFactory(MoshiConverterFactory.create(moshi))
.build()
.create(MobileActsApi::class.java)
suspend fun list(visitId: String): MobileActListResponse = authorized { session ->
api.listActs("Bearer ${session.accessToken}", visitId)
}
suspend fun get(actId: String): MobileActDetail = authorized { session ->
api.act("Bearer ${session.accessToken}", actId)
}
suspend fun create(visitId: String, assetId: String, visitCode: String): MobileActDetail = authorized { session ->
api.createAct(
"Bearer ${session.accessToken}",
visitId,
CreateMobileActRequest(
occurredAt = Instant.now().toString(),
title = "Acta de inspección $visitCode",
summary = "Acta de inspección en curso. Los Hallazgos y observaciones se incorporan de forma trazable durante la visita.",
assetIds = listOf(assetId),
),
)
}
suspend fun ensureAsset(actId: String, assetId: String): MobileActDetail {
val detail = get(actId)
if (detail.assets.any { it.id == assetId }) return detail
val ids = (detail.assets.map { it.id } + assetId).distinct()
return authorized { session ->
api.updateAct("Bearer ${session.accessToken}", actId, UpdateMobileActRequest(ids))
}
}
suspend fun closure(actId: String): MobileActClosure = authorized { session ->
api.closure("Bearer ${session.accessToken}", actId)
}
suspend fun setResponsible(actId: String, request: MobileResponsibleRequest): MobileActClosure = authorized { session ->
api.responsible("Bearer ${session.accessToken}", actId, request)
}
suspend fun prepare(actId: String): MobileActClosure = authorized { session ->
api.ready("Bearer ${session.accessToken}", actId)
}
suspend fun reopen(actId: String): MobileActClosure = authorized { session ->
api.reopen("Bearer ${session.accessToken}", actId)
}
suspend fun signInspector(
actId: String,
png: File,
latitude: Double?,
longitude: Double?,
accuracyM: Double?,
): MobileActClosure = signature(actId, png, latitude, longitude, accuracyM, company = false)
suspend fun signCompany(
actId: String,
png: File,
latitude: Double?,
longitude: Double?,
accuracyM: Double?,
manifestation: String = "CONFORMITY",
statement: String? = null,
): MobileActClosure = signature(
actId = actId,
png = png,
latitude = latitude,
longitude = longitude,
accuracyM = accuracyM,
company = true,
manifestation = manifestation,
statement = statement,
)
suspend fun companyOutcome(actId: String, status: String, reason: String): MobileActClosure = authorized { session ->
api.companyOutcome(
"Bearer ${session.accessToken}",
actId,
MobileCompanyOutcomeRequest(status, reason.trim()),
)
}
suspend fun closeAct(actId: String): MobileActClosure = authorized { session ->
api.closeAct("Bearer ${session.accessToken}", actId, MobileCloseActRequest())
}
suspend fun closeVisit(visitId: String): VisitDetail = authorized { session ->
api.closeVisit("Bearer ${session.accessToken}", visitId, MobileCloseVisitRequest())
}
private suspend fun signature(
actId: String,
png: File,
latitude: Double?,
longitude: Double?,
accuracyM: Double?,
company: Boolean,
manifestation: String? = null,
statement: String? = null,
): MobileActClosure = authorized { session ->
val text = "text/plain".toMediaType()
val file = MultipartBody.Part.createFormData(
"file",
png.name,
png.asRequestBody("image/png".toMediaType()),
)
val consent = "true".toRequestBody(text)
val signedAt = Instant.now().toString().toRequestBody(text)
val device = "DH Android".toRequestBody(text)
val lat = latitude?.toString()?.toRequestBody(text)
val lon = longitude?.toString()?.toRequestBody(text)
val accuracy = accuracyM?.toString()?.toRequestBody(text)
if (company) {
api.signCompany(
"Bearer ${session.accessToken}", actId, file, consent, signedAt,
lat, lon, accuracy, device,
manifestation?.toRequestBody(text),
statement?.trim()?.takeIf { it.isNotBlank() }?.toRequestBody(text),
)
} else {
api.signInspector(
"Bearer ${session.accessToken}", actId, file, consent, signedAt,
lat, lon, accuracy, device,
)
}
}
private suspend fun <T> authorized(block: suspend (StoredSession) -> T): T {
var session = store.load() ?: throw IllegalStateException("Sesión no iniciada")
try {
return block(session)
} catch (error: HttpException) {
if (error.code() != 401) throw error
}
session = refresh(session.refreshToken)
return block(session)
}
private suspend fun refresh(previousRefreshToken: String): StoredSession = refreshMutex.withLock {
val latest = store.load() ?: throw IllegalStateException("Sesión no iniciada")
if (latest.refreshToken != previousRefreshToken) return@withLock latest
try {
store.save(api.refresh(RefreshRequest(previousRefreshToken)))
} catch (error: Throwable) {
store.clear()
throw error
}
}
}
@@ -76,15 +76,31 @@ private data class F3GeoSnapshot(
@Composable
fun F3VisitRoot(model: MainViewModel) {
var inventoryMode by rememberSaveable(model.visit?.id) { mutableStateOf(false) }
if (inventoryMode) {
F3FieldInventoryScreen(model, onBack = { inventoryMode = false })
} else {
F3VisitOverview(model, onInventory = { inventoryMode = true })
var actsMode by rememberSaveable(model.visit?.id) { mutableStateOf(false) }
when {
actsMode -> MobileActsScreen(
model = model,
onBack = { actsMode = false },
onGoInventory = {
actsMode = false
inventoryMode = true
},
)
inventoryMode -> F3FieldInventoryScreen(model, onBack = { inventoryMode = false })
else -> F3VisitOverview(
model = model,
onInventory = { inventoryMode = true },
onActs = { actsMode = true },
)
}
}
@Composable
private fun F3VisitOverview(model: MainViewModel, onInventory: () -> Unit) {
private fun F3VisitOverview(
model: MainViewModel,
onInventory: () -> Unit,
onActs: () -> Unit,
) {
val visit = model.visit ?: return
Column(
Modifier
@@ -137,9 +153,17 @@ private fun F3VisitOverview(model: MainViewModel, onInventory: () -> Unit) {
Button(onClick = onInventory, modifier = Modifier.fillMaxWidth()) {
Text("Abrir Inventario de campo")
}
OutlinedButton(onClick = onActs, modifier = Modifier.fillMaxWidth()) {
val open = model.acts.count { it.status == "DRAFT" || it.status == "READY" }
Text("Actas de la inspección · ${model.acts.size}${if (open > 0) " · $open abiertas" else ""}")
}
} else if (visit.status == "CLOSED") {
OutlinedButton(onClick = onActs, modifier = Modifier.fillMaxWidth()) {
Text("Ver Actas · ${model.acts.size}")
}
} else if (visit.status == "PLANNED") {
Text(
"Primero iniciá la inspección para habilitar altas, fotografías y Hallazgos.",
"Primero iniciá la inspección para habilitar altas, fotografías, Actas y Hallazgos.",
style = MaterialTheme.typography.bodySmall,
)
}
@@ -0,0 +1,425 @@
package com.korexlabs.dhinspeccion.ui
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import com.google.android.gms.location.LocationServices
import com.google.android.gms.location.Priority
import com.google.android.gms.tasks.CancellationTokenSource
import com.korexlabs.dhinspeccion.MainViewModel
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import java.io.File
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
private data class ActSignatureGeo(
val latitude: Double,
val longitude: Double,
val accuracyM: Double?,
)
@Composable
fun MobileActsScreen(
model: MainViewModel,
onBack: () -> Unit,
onGoInventory: () -> Unit,
) {
val visit = model.visit ?: return
val selected = model.selectedAct
val closure = model.actClosure
val context = LocalContext.current
val scope = rememberCoroutineScope()
var attendance by rememberSaveable(selected?.id) {
mutableStateOf(closure?.responsible?.attendanceStatus ?: "PRESENT")
}
var fullName by rememberSaveable(selected?.id) {
mutableStateOf(closure?.responsible?.fullName.orEmpty())
}
var documentType by rememberSaveable(selected?.id) {
mutableStateOf(closure?.responsible?.documentType ?: "DNI")
}
var documentNumber by rememberSaveable(selected?.id) {
mutableStateOf(closure?.responsible?.documentNumber.orEmpty())
}
var position by rememberSaveable(selected?.id) {
mutableStateOf(closure?.responsible?.position.orEmpty())
}
var email by rememberSaveable(selected?.id) {
mutableStateOf(closure?.responsible?.email.orEmpty())
}
var phone by rememberSaveable(selected?.id) {
mutableStateOf(closure?.responsible?.phone.orEmpty())
}
var absenceReason by rememberSaveable(selected?.id) {
mutableStateOf(closure?.responsible?.absenceReason.orEmpty())
}
var refusalReason by rememberSaveable(selected?.id) { mutableStateOf("") }
var manifestation by rememberSaveable(selected?.id) { mutableStateOf("CONFORMITY") }
var dissentStatement by rememberSaveable(selected?.id) { mutableStateOf("") }
LaunchedEffect(visit.id) { model.reloadActs() }
fun signWithGeo(file: File, company: Boolean) {
scope.launch {
val geo = runCatching { currentActSignatureGeo(context) }.getOrNull()
if (company) {
model.signSelectedActAsCompany(
png = file,
latitude = geo?.latitude,
longitude = geo?.longitude,
accuracyM = geo?.accuracyM,
manifestation = manifestation,
statement = dissentStatement.takeIf { manifestation == "DISSENT" },
)
} else {
model.signSelectedActAsInspector(
png = file,
latitude = geo?.latitude,
longitude = geo?.longitude,
accuracyM = geo?.accuracyM,
)
}
}
}
Column(
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(top = 28.dp, start = 16.dp, end = 16.dp, bottom = 36.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
OutlinedButton(onClick = onBack, enabled = !model.busy) { Text("Volver") }
Text("Actas", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
}
Text("${visit.code} · ${visit.operatorCompany?.name.orEmpty()}")
F32ActMessage(model)
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Actas de esta inspección", fontWeight = FontWeight.Bold)
if (model.acts.isEmpty()) {
Text("Todavía no hay Actas. La primera se inicia sobre una Instalación/Subinstalación seleccionada.")
}
model.acts.forEach { act ->
val active = selected?.id == act.id
OutlinedButton(
onClick = { model.selectAct(act.id) },
modifier = Modifier.fillMaxWidth(),
) {
Text(
(if (active) "" else "") +
"${act.code} · ${actStatusLabel(act.status)} · ${act.findingCount} Hallazgo${if (act.findingCount == 1) "" else "s"}",
)
}
}
}
}
val hasDraftAct = model.acts.any { it.status == "DRAFT" }
if (visit.status == "IN_PROGRESS" && !hasDraftAct) {
Card(
Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Nueva Acta", fontWeight = FontWeight.Bold)
if (model.acts.any { it.status == "READY" }) {
Text(
"Puede existir una nueva Acta en borrador aunque haya Actas preparadas pendientes de firma de empresa. Sólo se permite un borrador a la vez.",
style = MaterialTheme.typography.bodySmall,
)
}
val selectedInventory = model.selectedFieldAsset?.asset
if (selectedInventory == null) {
Text("Primero elegí una Instalación o Subinstalación desde Inventario de campo. Ese registro será el primer elemento del Acta.")
Button(onClick = onGoInventory, modifier = Modifier.fillMaxWidth()) {
Text("Ir a Inventario y elegir")
}
} else {
Text("Inventario inicial: ${selectedInventory.name} · ${selectedInventory.code}")
Button(
onClick = { model.createActForSelectedInventory() },
enabled = !model.busy,
modifier = Modifier.fillMaxWidth(),
) { Text("Crear nueva Acta") }
}
}
}
}
if (selected != null) {
HorizontalDivider()
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
Text(selected.code, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text(actStatusLabel(selected.status))
Text("${selected.findingCount} Hallazgo${if (selected.findingCount == 1) "" else "s"} · ${selected.assetCount} elemento${if (selected.assetCount == 1) "" else "s"} de Inventario")
Text(selected.summary, style = MaterialTheme.typography.bodySmall)
selected.closureSha256?.let { Text("Hash final: $it", style = MaterialTheme.typography.bodySmall) }
}
}
when (selected.status) {
"DRAFT" -> {
Text("1. Responsable de la empresa", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
AssistChip(onClick = { attendance = "PRESENT" }, label = { Text(if (attendance == "PRESENT") "✓ Presente" else "Presente") })
AssistChip(onClick = { attendance = "ABSENT" }, label = { Text(if (attendance == "ABSENT") "✓ Ausente" else "Ausente") })
}
if (attendance == "PRESENT") {
OutlinedTextField(fullName, { fullName = it }, label = { Text("Nombre y apellido *") }, modifier = Modifier.fillMaxWidth())
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
listOf("DNI", "CUIL", "PASSPORT", "OTHER").forEach { kind ->
AssistChip(onClick = { documentType = kind }, label = { Text(if (documentType == kind) "$kind" else kind) })
}
}
OutlinedTextField(documentNumber, { documentNumber = it }, label = { Text("Documento *") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(position, { position = it }, label = { Text("Cargo *") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(email, { email = it }, label = { Text("Email") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(phone, { phone = it }, label = { Text("Teléfono") }, modifier = Modifier.fillMaxWidth())
Button(
onClick = {
model.setCompanyResponsiblePresent(fullName, documentType, documentNumber, position, email, phone)
},
enabled = !model.busy && fullName.isNotBlank() && documentNumber.isNotBlank() && position.isNotBlank(),
modifier = Modifier.fillMaxWidth(),
) { Text("Guardar responsable") }
} else {
OutlinedTextField(
absenceReason,
{ absenceReason = it },
label = { Text("Motivo de ausencia *") },
minLines = 2,
modifier = Modifier.fillMaxWidth(),
)
Button(
onClick = { model.setCompanyResponsibleAbsent(absenceReason) },
enabled = !model.busy && absenceReason.trim().length >= 10,
modifier = Modifier.fillMaxWidth(),
) { Text("Guardar ausencia") }
}
HorizontalDivider()
Text("2. Hallazgos / verificaciones", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text("Los Hallazgos se cargan desde Inventario y quedan vinculados a ${selected.code} de forma explícita. Un Acta de verificación puede prepararse sin Hallazgos nuevos si la verificación ya fue registrada.")
Button(onClick = onGoInventory, modifier = Modifier.fillMaxWidth()) { Text("Ir a Inventario / Hallazgos") }
HorizontalDivider()
Text("3. Preparar Acta", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text("Al preparar, el contenido se congela y se calcula su hash. El servidor exige al menos un Hallazgo o una verificación registrada.")
Button(
onClick = { model.prepareSelectedAct() },
enabled = !model.busy && closure?.responsible != null,
modifier = Modifier.fillMaxWidth(),
) { Text("Preparar Acta para firmas") }
}
"READY" -> {
val signatures = closure?.signatures.orEmpty()
val inspectorSigned = signatures.any { it.signerType == "INSPECTOR" && it.status == "SIGNED" }
val companyOutcome = signatures.firstOrNull { it.signerType == "COMPANY_RESPONSIBLE" }
Text("Acta preparada", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
closure?.closure?.preparedSha256?.let { Text("Hash preparado: $it", style = MaterialTheme.typography.bodySmall) }
if (signatures.isEmpty()) {
OutlinedButton(onClick = { model.reopenSelectedAct() }, enabled = !model.busy, modifier = Modifier.fillMaxWidth()) {
Text("Volver a borrador")
}
}
HorizontalDivider()
Text("Firma del inspector", fontWeight = FontWeight.Bold)
if (inspectorSigned) {
Text("✓ Firma del inspector registrada", color = MaterialTheme.colorScheme.primary)
} else {
Text(closure?.consents?.inspector.orEmpty(), style = MaterialTheme.typography.bodySmall)
SignaturePad(
label = "Firmá como inspector/a",
enabled = !model.busy,
onCaptured = { file -> signWithGeo(file, company = false) },
)
}
HorizontalDivider()
Text("Recepción de la empresa", fontWeight = FontWeight.Bold)
if (companyOutcome != null) {
val detail = when (companyOutcome.status) {
"SIGNED" -> if (companyOutcome.companyManifestation == "DISSENT") "Firma en disidencia" else "Firma registrada"
"REFUSED" -> "Negativa a firmar"
"ABSENT" -> "Responsable ausente"
else -> companyOutcome.status
}
Text("$detail", color = MaterialTheme.colorScheme.primary)
companyOutcome.reason?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
companyOutcome.companyStatement?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
} else if (closure?.responsible?.attendanceStatus == "ABSENT") {
Text("El responsable fue registrado como ausente.")
Button(
onClick = {
model.recordCompanyOutcome(
"ABSENT",
closure.responsible.absenceReason ?: "Responsable de empresa ausente durante la inspección",
)
},
enabled = !model.busy && inspectorSigned,
modifier = Modifier.fillMaxWidth(),
) { Text("Asentar ausencia en el Acta") }
} else {
Text(closure?.consents?.company.orEmpty(), style = MaterialTheme.typography.bodySmall)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
AssistChip(onClick = { manifestation = "CONFORMITY" }, label = { Text(if (manifestation == "CONFORMITY") "✓ Conforme" else "Conforme") })
AssistChip(onClick = { manifestation = "DISSENT" }, label = { Text(if (manifestation == "DISSENT") "✓ En disidencia" else "En disidencia") })
}
if (manifestation == "DISSENT") {
OutlinedTextField(
dissentStatement,
{ dissentStatement = it },
label = { Text("Manifestación de disidencia *") },
minLines = 2,
modifier = Modifier.fillMaxWidth(),
)
}
SignaturePad(
label = "Firma del responsable de empresa",
enabled = !model.busy && inspectorSigned && (manifestation != "DISSENT" || dissentStatement.trim().length >= 10),
onCaptured = { file -> signWithGeo(file, company = true) },
)
Text("Si la persona presente se niega a firmar, asentá el motivo en lugar de dibujar una firma.", style = MaterialTheme.typography.bodySmall)
OutlinedTextField(
refusalReason,
{ refusalReason = it },
label = { Text("Motivo de negativa") },
minLines = 2,
modifier = Modifier.fillMaxWidth(),
)
OutlinedButton(
onClick = { model.recordCompanyOutcome("REFUSED", refusalReason) },
enabled = !model.busy && inspectorSigned && refusalReason.trim().length >= 10,
modifier = Modifier.fillMaxWidth(),
) { Text("Registrar negativa a firmar") }
}
HorizontalDivider()
if (inspectorSigned && companyOutcome != null) {
Button(
onClick = { model.closeSelectedAct() },
enabled = !model.busy,
modifier = Modifier.fillMaxWidth(),
) { Text("Cerrar Acta definitivamente") }
} else if (inspectorSigned) {
Text(
"La inspección puede finalizar con la firma de empresa pendiente; el Acta permanecerá preparada hasta registrar firma, negativa o ausencia.",
style = MaterialTheme.typography.bodySmall,
)
}
}
"CLOSED" -> {
Card(
Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
Text("Acta cerrada e inmutable", fontWeight = FontWeight.Bold)
Text("El PDF, informe Word y entregas documentales se generan desde este cierre.")
closure?.closure?.finalSha256?.let { Text("SHA-256: $it", style = MaterialTheme.typography.bodySmall) }
}
}
}
"CANCELLED" -> Text("Esta Acta fue cancelada y permanece sólo como antecedente.")
}
}
if (visit.status == "IN_PROGRESS" && model.acts.isNotEmpty()) {
HorizontalDivider()
val drafts = model.acts.count { it.status == "DRAFT" }
Text("Finalizar inspección", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text(
"No puede quedar ninguna Acta en borrador. Al cerrar, el servidor verifica que cada Acta preparada tenga firma de inspector; si falta alguna, indicará cuál debe completarse.",
)
Button(
onClick = { model.closeInspection() },
enabled = !model.busy && drafts == 0,
modifier = Modifier.fillMaxWidth(),
) { Text("Cerrar inspección y salir de la empresa") }
}
}
}
@Composable
private fun F32ActMessage(model: MainViewModel) {
val text = model.error ?: model.notice ?: return
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = if (model.error != null) MaterialTheme.colorScheme.errorContainer
else MaterialTheme.colorScheme.secondaryContainer,
),
onClick = { model.clearMessages() },
) {
Text(text, Modifier.padding(12.dp))
}
}
private fun actStatusLabel(status: String): String = when (status) {
"DRAFT" -> "Borrador"
"READY" -> "Preparada para firmas"
"CLOSED" -> "Cerrada"
"CANCELLED" -> "Cancelada"
else -> status
}
private fun hasActLocation(context: Context): Boolean =
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
private suspend fun currentActSignatureGeo(context: Context): ActSignatureGeo = suspendCancellableCoroutine { continuation ->
if (!hasActLocation(context)) {
continuation.resumeWithException(SecurityException("Ubicación no autorizada"))
return@suspendCancellableCoroutine
}
val source = CancellationTokenSource()
LocationServices.getFusedLocationProviderClient(context)
.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token)
.addOnSuccessListener { location ->
if (!continuation.isActive) return@addOnSuccessListener
if (location == null) continuation.resumeWithException(IllegalStateException("Ubicación no disponible"))
else continuation.resume(ActSignatureGeo(location.latitude, location.longitude, location.accuracy.toDouble()))
}
.addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
continuation.invokeOnCancellation { source.cancel() }
}
@@ -0,0 +1,137 @@
package com.korexlabs.dhinspeccion.ui
import android.graphics.Bitmap
import android.graphics.Canvas as AndroidCanvas
import android.graphics.Color as AndroidColor
import android.graphics.Paint
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import java.io.File
import java.io.FileOutputStream
@Composable
fun SignaturePad(
label: String,
enabled: Boolean = true,
onCaptured: (File) -> Unit,
) {
val context = LocalContext.current
val strokes = remember { mutableStateListOf<List<Offset>>() }
var currentStroke by remember { mutableStateOf<List<Offset>>(emptyList()) }
var canvasSize by remember { mutableStateOf(IntSize.Zero) }
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(label, fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold)
Text(
"Firmá dentro del recuadro. La imagen se guarda como PNG y se incorpora al hash del Acta.",
style = MaterialTheme.typography.bodySmall,
)
Canvas(
modifier = Modifier
.fillMaxWidth()
.height(190.dp)
.background(Color.White)
.onSizeChanged { canvasSize = it }
.pointerInput(enabled) {
if (!enabled) return@pointerInput
detectDragGestures(
onDragStart = { position -> currentStroke = listOf(position) },
onDrag = { change, _ ->
change.consume()
currentStroke = currentStroke + change.position
},
onDragEnd = {
if (currentStroke.size > 1) strokes.add(currentStroke)
currentStroke = emptyList()
},
onDragCancel = { currentStroke = emptyList() },
)
},
) {
val all = strokes + listOf(currentStroke)
all.forEach { stroke ->
stroke.zipWithNext().forEach { (start, end) ->
drawLine(
color = Color.Black,
start = start,
end = end,
strokeWidth = 4.dp.toPx(),
cap = StrokeCap.Round,
)
}
}
}
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(
onClick = { strokes.clear(); currentStroke = emptyList() },
enabled = enabled && (strokes.isNotEmpty() || currentStroke.isNotEmpty()),
modifier = Modifier.weight(1f),
) { Text("Limpiar") }
Button(
onClick = {
val width = canvasSize.width.coerceAtLeast(1)
val height = canvasSize.height.coerceAtLeast(1)
val targetWidth = 1000
val targetHeight = 400
val scaleX = targetWidth.toFloat() / width.toFloat()
val scaleY = targetHeight.toFloat() / height.toFloat()
val bitmap = Bitmap.createBitmap(targetWidth, targetHeight, Bitmap.Config.ARGB_8888)
val native = AndroidCanvas(bitmap)
native.drawColor(AndroidColor.WHITE)
val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = AndroidColor.BLACK
strokeWidth = 7f
strokeCap = Paint.Cap.ROUND
strokeJoin = Paint.Join.ROUND
style = Paint.Style.STROKE
}
strokes.forEach { stroke ->
stroke.zipWithNext().forEach { (start, end) ->
native.drawLine(
start.x * scaleX,
start.y * scaleY,
end.x * scaleX,
end.y * scaleY,
paint,
)
}
}
val file = File.createTempFile("DH_FIRMA_", ".png", context.cacheDir)
FileOutputStream(file).use { stream ->
check(bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream))
}
bitmap.recycle()
onCaptured(file)
},
enabled = enabled && strokes.isNotEmpty(),
modifier = Modifier.weight(1f),
) { Text("Usar firma") }
}
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "dhv2-api",
"version": "0.24.0-1",
"version": "0.25.0-1",
"private": true,
"license": "UNLICENSED",
"scripts": {
+13 -24
View File
@@ -3,28 +3,27 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
import { APP_GUARD } from '@nestjs/core';
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuditModule } from './audit/audit.module';
import { ActAdministrationModule } from './act-administration/act-administration.module';
import { AdministrationModule } from './administration/administration.module';
import { AuthorizationModule } from './authorization/authorization.module';
import { PermissionsGuard } from './authorization/guards/permissions.guard';
import { AssetImportsModule } from './asset-imports/asset-imports.module';
import { AssetMasterModule } from './asset-master/asset-master.module';
import { AuditModule } from './audit/audit.module';
import { AuthModule } from './auth/auth.module';
import { AccessTokenGuard } from './auth/guards/access-token.guard';
import { AuthorizationModule } from './authorization/authorization.module';
import { PermissionsGuard } from './authorization/guards/permissions.guard';
import { CsrfGuard } from './auth/guards/csrf.guard';
import { PhaseADataModule } from './core-data/phase-a-data.module';
import { DashboardModule } from './dashboard/dashboard.module';
import { HealthController } from './health.controller';
import { HealthService } from './health.service';
import { DashboardModule } from './dashboard/dashboard.module';
import { AssetMasterModule } from './asset-master/asset-master.module';
import { SurveyPlanningModule } from './survey-planning/survey-planning.module';
import { SurveyExecutionModule } from './survey-execution/survey-execution.module';
import { InspectionVisitsModule } from './inspection-visits/inspection-visits.module';
import { InspectionActsModule } from './inspection-acts/inspection-acts.module';
import { InspectionFindingsModule } from './inspection-findings/inspection-findings.module';
import { InspectionClosingModule } from './inspection-closing/inspection-closing.module';
import { AssetImportsModule } from './asset-imports/asset-imports.module';
import { InspectionDeadlinesModule } from './inspection-deadlines/inspection-deadlines.module';
import { InspectionFindingsModule } from './inspection-findings/inspection-findings.module';
import { InspectionReportsModule } from './inspection-reports/inspection-reports.module';
import { InspectionVerificationsModule } from './inspection-verifications/inspection-verifications.module';
import { ActAdministrationModule } from './act-administration/act-administration.module';
import { InspectionVisitsModule } from './inspection-visits/inspection-visits.module';
function required(config: ConfigService, key: string): string {
const value = config.get<string>(key);
@@ -34,10 +33,7 @@ function required(config: ConfigService, key: string): string {
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
ignoreEnvFile: true,
}),
ConfigModule.forRoot({ isGlobal: true, ignoreEnvFile: true }),
TypeOrmModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
@@ -55,13 +51,7 @@ function required(config: ConfigService, key: string): string {
connectTimeoutMS: 5000,
}),
}),
ThrottlerModule.forRoot([
{
name: 'default',
ttl: 60_000,
limit: 120,
},
]),
ThrottlerModule.forRoot([{ name: 'default', ttl: 60_000, limit: 120 }]),
PhaseADataModule,
AuditModule,
AuthorizationModule,
@@ -69,12 +59,11 @@ function required(config: ConfigService, key: string): string {
AdministrationModule,
DashboardModule,
AssetMasterModule,
SurveyPlanningModule,
SurveyExecutionModule,
InspectionVisitsModule,
InspectionActsModule,
InspectionFindingsModule,
InspectionClosingModule,
InspectionDeadlinesModule,
InspectionReportsModule,
InspectionVerificationsModule,
ActAdministrationModule,
+39 -160
View File
@@ -6,14 +6,8 @@ export { Role } from './role.entity';
export { UserRole } from './user-role.entity';
export { User, UserStatus } from './user.entity';
export { Asset, AssetDataOrigin, AssetInformationStatus, AssetOperationalStatus } from './asset.entity';
export {
AssetVersion,
AssetVersionChangeType,
} from './asset-version.entity';
export {
AssetAttributeDataType,
AssetAttributeDefinition,
} from './asset-attribute-definition.entity';
export { AssetVersion, AssetVersionChangeType } from './asset-version.entity';
export { AssetAttributeDataType, AssetAttributeDefinition } from './asset-attribute-definition.entity';
export { AssetAttributeValue } from './asset-attribute-value.entity';
export { AreaCompanyRelation, AreaOrganizationRole } from './area-company-relation.entity';
export { OrganizationProfile, OrganizationKind } from './organization-profile.entity';
@@ -25,111 +19,35 @@ export { AreaLegalRight, AreaLegalRightType, AreaLegalRightStatus } from './area
export { AreaLegalRightOrganization, AreaLegalRightOrganizationRole } from './area-legal-right-organization.entity';
export { AssetTypeParentRule } from './asset-type-parent-rule.entity';
export { AssetType, AssetTypeOperationalRole } from './asset-type.entity';
export {
AssetGeometry,
AssetGeometrySource,
AssetGeometryType,
} from './asset-geometry.entity';
export {
AssetMedia,
AssetMediaKind,
AssetMediaSource,
} from './asset-media.entity';
export {
SurveyCampaign,
SurveyCampaignStatus,
} from './survey-campaign.entity';
export {
SurveyCampaignTarget,
SurveyTargetStatus,
} from './survey-campaign-target.entity';
export {
SurveyReportOutcome,
SurveyReportStatus,
SurveyTargetReport,
} from './survey-target-report.entity';
export { SurveyTargetReportMedia } from './survey-target-report-media.entity';
export {
SurveyReportVersionEvent,
SurveyTargetReportVersion,
} from './survey-target-report-version.entity';
export {
InspectionVisit,
InspectionVisitStatus,
} from './inspection-visit.entity';
export { AssetGeometry, AssetGeometrySource, AssetGeometryType } from './asset-geometry.entity';
export { AssetMedia, AssetMediaKind, AssetMediaSource } from './asset-media.entity';
export { InspectionVisit, InspectionVisitStatus } from './inspection-visit.entity';
export { InspectionVisitAsset, InspectionVisitAssetPlanningSource } from './inspection-visit-asset.entity';
export { InspectionVisitMember } from './inspection-visit-member.entity';
export {
DocumentAnnualSequence,
DocumentSequenceType,
} from './document-annual-sequence.entity';
export {
InspectionAct,
InspectionActStatus,
} from './inspection-act.entity';
export { DocumentAnnualSequence, DocumentSequenceType } from './document-annual-sequence.entity';
export { InspectionAct, InspectionActStatus, InspectionActUrgency, InspectionDeadlineBasis, InspectionDeadlineDayType } from './inspection-act.entity';
export { InspectionActAsset } from './inspection-act-asset.entity';
export {
InspectionReport,
InspectionReportPdfStatus,
InspectionReportReviewStatus,
InspectionReportStatus,
InspectionReportWordStatus,
} from './inspection-report.entity';
export {
InspectionActVersion,
InspectionActVersionEvent,
} from './inspection-act-version.entity';
export {
InspectionActResponsible,
InspectionResponsibleAttendanceStatus,
InspectionResponsibleDocumentType,
} from './inspection-act-responsible.entity';
export {
InspectionActClosure,
InspectionActUploadMode,
} from './inspection-act-closure.entity';
export {
InspectionActSignature,
InspectionActSignatureSource,
InspectionActSignatureStatus,
InspectionActSignerType,
InspectionCompanySignatureManifestation,
} from './inspection-act-signature.entity';
export { InspectionDeadlinePolicy } from './inspection-deadline-policy.entity';
export { InspectionNonWorkingDay } from './inspection-non-working-day.entity';
export { InspectionReport, InspectionReportPdfStatus, InspectionReportReviewStatus, InspectionReportStatus, InspectionReportWordStatus } from './inspection-report.entity';
export { InspectionReportFollowUp, InspectionReportFollowUpType } from './inspection-report-follow-up.entity';
export { InspectionReportFollowUpFile } from './inspection-report-follow-up-file.entity';
export { InspectionActVersion, InspectionActVersionEvent } from './inspection-act-version.entity';
export { InspectionActResponsible, InspectionResponsibleAttendanceStatus, InspectionResponsibleDocumentType } from './inspection-act-responsible.entity';
export { InspectionActClosure, InspectionActUploadMode } from './inspection-act-closure.entity';
export { InspectionActSignature, InspectionActSignatureSource, InspectionActSignatureStatus, InspectionActSignerType, InspectionCompanySignatureManifestation } from './inspection-act-signature.entity';
export { FindingCategory } from './finding-category.entity';
export { FindingCatalogItem } from './finding-catalog-item.entity';
export { FindingCatalogItemAssetType } from './finding-catalog-item-asset-type.entity';
export { FindingCatalogAssetTypeProfile } from './finding-catalog-asset-type-profile.entity';
export { FindingCatalogAssetOverride } from './finding-catalog-asset-override.entity';
export { FindingCatalogProposal, FindingCatalogProposalStatus } from './finding-catalog-proposal.entity';
export {
InspectionFinding,
InspectionFindingResponseDueBasis,
InspectionFindingStatus,
} from './inspection-finding.entity';
export {
InspectionFindingVersion,
InspectionFindingVersionEvent,
} from './inspection-finding-version.entity';
export {
InspectionCommunicationChannel,
InspectionCommunicationDirection,
InspectionCommunicationType,
InspectionFindingCommunication,
} from './inspection-finding-communication.entity';
export {
InspectionEvidenceKind,
InspectionEvidencePurpose,
InspectionEvidenceSource,
InspectionFindingEvidence,
} from './inspection-finding-evidence.entity';
export {
InspectionFindingVerificationVisit,
InspectionVerificationOutcome,
} from './inspection-finding-verification-visit.entity';
export {
InspectionFindingVerificationEvent,
InspectionFindingVerificationEventType,
} from './inspection-finding-verification-event.entity';
export { InspectionFinding, InspectionFindingResponseDueBasis, InspectionFindingStatus } from './inspection-finding.entity';
export { InspectionFindingVersion, InspectionFindingVersionEvent } from './inspection-finding-version.entity';
export { InspectionCommunicationChannel, InspectionCommunicationDirection, InspectionCommunicationType, InspectionFindingCommunication } from './inspection-finding-communication.entity';
export { InspectionEvidenceKind, InspectionEvidencePurpose, InspectionEvidenceSource, InspectionFindingEvidence } from './inspection-finding-evidence.entity';
export { InspectionFindingVerificationVisit, InspectionVerificationOutcome } from './inspection-finding-verification-visit.entity';
export { InspectionFindingVerificationEvent, InspectionFindingVerificationEventType } from './inspection-finding-verification-event.entity';
import { AuditEvent } from './audit-event.entity';
import { AuthSession } from './auth-session.entity';
@@ -154,18 +72,17 @@ import { AreaLegalRightOrganization } from './area-legal-right-organization.enti
import { AssetGeometry } from './asset-geometry.entity';
import { AssetVersion } from './asset-version.entity';
import { AssetMedia } from './asset-media.entity';
import { SurveyCampaign } from './survey-campaign.entity';
import { SurveyCampaignTarget } from './survey-campaign-target.entity';
import { SurveyTargetReport } from './survey-target-report.entity';
import { SurveyTargetReportMedia } from './survey-target-report-media.entity';
import { SurveyTargetReportVersion } from './survey-target-report-version.entity';
import { InspectionVisit } from './inspection-visit.entity';
import { InspectionVisitAsset } from './inspection-visit-asset.entity';
import { InspectionVisitMember } from './inspection-visit-member.entity';
import { DocumentAnnualSequence } from './document-annual-sequence.entity';
import { InspectionAct } from './inspection-act.entity';
import { InspectionActAsset } from './inspection-act-asset.entity';
import { InspectionDeadlinePolicy } from './inspection-deadline-policy.entity';
import { InspectionNonWorkingDay } from './inspection-non-working-day.entity';
import { InspectionReport } from './inspection-report.entity';
import { InspectionReportFollowUp } from './inspection-report-follow-up.entity';
import { InspectionReportFollowUpFile } from './inspection-report-follow-up-file.entity';
import { InspectionActVersion } from './inspection-act-version.entity';
import { InspectionActResponsible } from './inspection-act-responsible.entity';
import { InspectionActClosure } from './inspection-act-closure.entity';
@@ -184,55 +101,17 @@ import { InspectionFindingVerificationVisit } from './inspection-finding-verific
import { InspectionFindingVerificationEvent } from './inspection-finding-verification-event.entity';
export const PHASE_A_ENTITIES = [
User,
Role,
Permission,
UserRole,
RolePermission,
AuthSession,
AuditEvent,
AssetType,
AssetTypeParentRule,
AssetAttributeDefinition,
Asset,
AssetAttributeValue,
AreaCompanyRelation,
OrganizationProfile,
OrganizationMembership,
SourceDocument,
AssetSourceDocument,
AssetExternalIdentifier,
AreaLegalRight,
AreaLegalRightOrganization,
AssetGeometry,
AssetVersion,
AssetMedia,
SurveyCampaign,
SurveyCampaignTarget,
SurveyTargetReport,
SurveyTargetReportMedia,
SurveyTargetReportVersion,
InspectionVisit,
InspectionVisitAsset,
InspectionVisitMember,
DocumentAnnualSequence,
InspectionAct,
InspectionActAsset,
InspectionReport,
InspectionActVersion,
InspectionActResponsible,
InspectionActClosure,
InspectionActSignature,
FindingCategory,
FindingCatalogItem,
FindingCatalogItemAssetType,
FindingCatalogAssetTypeProfile,
FindingCatalogAssetOverride,
FindingCatalogProposal,
InspectionFinding,
InspectionFindingVersion,
InspectionFindingCommunication,
InspectionFindingEvidence,
InspectionFindingVerificationVisit,
InspectionFindingVerificationEvent,
User, Role, Permission, UserRole, RolePermission, AuthSession, AuditEvent,
AssetType, AssetTypeParentRule, AssetAttributeDefinition, Asset, AssetAttributeValue,
AreaCompanyRelation, OrganizationProfile, OrganizationMembership, SourceDocument,
AssetSourceDocument, AssetExternalIdentifier, AreaLegalRight, AreaLegalRightOrganization,
AssetGeometry, AssetVersion, AssetMedia, InspectionVisit, InspectionVisitAsset,
InspectionVisitMember, DocumentAnnualSequence, InspectionAct, InspectionActAsset,
InspectionDeadlinePolicy, InspectionNonWorkingDay, InspectionReport,
InspectionReportFollowUp, InspectionReportFollowUpFile, InspectionActVersion,
InspectionActResponsible, InspectionActClosure, InspectionActSignature, FindingCategory,
FindingCatalogItem, FindingCatalogItemAssetType, FindingCatalogAssetTypeProfile,
FindingCatalogAssetOverride, FindingCatalogProposal, InspectionFinding,
InspectionFindingVersion, InspectionFindingCommunication, InspectionFindingEvidence,
InspectionFindingVerificationVisit, InspectionFindingVerificationEvent,
];
@@ -9,6 +9,21 @@ export enum InspectionActStatus {
RECTIFIED = 'RECTIFIED',
}
export enum InspectionActUrgency {
URGENT = 'URGENT',
NON_URGENT = 'NON_URGENT',
}
export enum InspectionDeadlineDayType {
BUSINESS = 'BUSINESS',
CALENDAR = 'CALENDAR',
}
export enum InspectionDeadlineBasis {
ACT_DATE = 'ACT_DATE',
GEDO_LOAD_DATE = 'GEDO_LOAD_DATE',
}
@Entity({ name: 'inspection_acts' })
@Index('uq_inspection_acts_one_draft_per_visit', ['visitId'], { unique: true, where: "status = 'DRAFT'" })
@Index('uq_inspection_acts_year_number', ['actYear', 'actNumber'], { unique: true })
@@ -47,6 +62,42 @@ export class InspectionAct extends TimestampedEntity {
@Column({ type: 'text', nullable: true })
observations!: string | null;
@Column({ type: 'varchar', length: 20, nullable: true })
urgency!: InspectionActUrgency | null;
@Column({ name: 'deadline_days', type: 'integer', nullable: true })
deadlineDays!: number | null;
@Column({ name: 'deadline_day_type', type: 'varchar', length: 20, nullable: true })
deadlineDayType!: InspectionDeadlineDayType | null;
@Column({ name: 'deadline_basis', type: 'varchar', length: 24, nullable: true })
deadlineBasis!: InspectionDeadlineBasis | null;
@Column({ name: 'deadline_base_on', type: 'date', nullable: true })
deadlineBaseOn!: string | null;
@Column({ name: 'deadline_due_on', type: 'date', nullable: true })
deadlineDueOn!: string | null;
@Column({ name: 'deadline_policy_snapshot', type: 'jsonb', nullable: true })
deadlinePolicySnapshot!: Record<string, unknown> | null;
@Column({ name: 'locked_at', type: 'timestamptz', nullable: true })
lockedAt!: Date | null;
@Column({ name: 'locked_by', type: 'uuid', nullable: true })
lockedBy!: string | null;
@Column({ name: 'locked_sha256', type: 'char', length: 64, nullable: true })
lockedSha256!: string | null;
@Column({ name: 'sealed_at', type: 'timestamptz', nullable: true })
sealedAt!: Date | null;
@Column({ name: 'sealed_by', type: 'uuid', nullable: true })
sealedBy!: string | null;
@Column({ name: 'current_version', type: 'integer', default: 0 })
currentVersion!: number;
@@ -0,0 +1,25 @@
import { Column, Entity, PrimaryColumn } from 'typeorm';
import { TimestampedEntity } from './timestamped.entity';
import {
InspectionActUrgency,
InspectionDeadlineBasis,
InspectionDeadlineDayType,
} from './inspection-act.entity';
@Entity({ name: 'inspection_deadline_policies' })
export class InspectionDeadlinePolicy extends TimestampedEntity {
@PrimaryColumn({ type: 'varchar', length: 20 })
urgency!: InspectionActUrgency;
@Column({ type: 'integer' })
days!: number;
@Column({ name: 'day_type', type: 'varchar', length: 20 })
dayType!: InspectionDeadlineDayType;
@Column({ type: 'varchar', length: 24 })
basis!: InspectionDeadlineBasis;
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
updatedBy!: string | null;
}
@@ -18,6 +18,7 @@ export enum InspectionFindingStatus {
@Index('idx_inspection_findings_status_control', ['status', 'nextControlOn'])
@Index('idx_inspection_findings_asset_status', ['assetId', 'status'])
@Index('idx_inspection_findings_catalog_item', ['catalogItemId'])
@Index('idx_inspection_findings_antecedent', ['antecedentFindingId'])
export class InspectionFinding extends TimestampedEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@@ -61,6 +62,12 @@ export class InspectionFinding extends TimestampedEntity {
@Column({ type: 'smallint', nullable: true })
severity!: number | null;
@Column({ name: 'is_recurrence', type: 'boolean', default: false })
isRecurrence!: boolean;
@Column({ name: 'antecedent_finding_id', type: 'uuid', nullable: true })
antecedentFindingId!: string | null;
@Column({ name: 'correction_due_on', type: 'date', nullable: true })
correctionDueOn!: string | null;
@@ -0,0 +1,20 @@
import { Column, Entity, PrimaryColumn } from 'typeorm';
import { TimestampedEntity } from './timestamped.entity';
@Entity({ name: 'inspection_non_working_days' })
export class InspectionNonWorkingDay extends TimestampedEntity {
@PrimaryColumn({ type: 'date' })
day!: string;
@Column({ type: 'varchar', length: 200 })
label!: string;
@Column({ type: 'boolean', default: true })
enabled!: boolean;
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdBy!: string | null;
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
updatedBy!: string | null;
}
@@ -0,0 +1,30 @@
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
import { TimestampedEntity } from './timestamped.entity';
@Entity({ name: 'inspection_report_follow_up_files' })
@Index('idx_inspection_report_follow_up_files_follow_up', ['followUpId'])
export class InspectionReportFollowUpFile extends TimestampedEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'follow_up_id', type: 'uuid' })
followUpId!: string;
@Column({ name: 'original_name', type: 'varchar', length: 255 })
originalName!: string;
@Column({ name: 'stored_name', type: 'varchar', length: 255 })
storedName!: string;
@Column({ name: 'mime_type', type: 'varchar', length: 120 })
mimeType!: string;
@Column({ name: 'size_bytes', type: 'integer' })
sizeBytes!: number;
@Column({ type: 'char', length: 64 })
sha256!: string;
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdBy!: string | null;
}
@@ -0,0 +1,36 @@
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
import { TimestampedEntity } from './timestamped.entity';
export enum InspectionReportFollowUpType {
COMPANY_NOTE = 'COMPANY_NOTE',
COMPANY_DOCUMENT = 'COMPANY_DOCUMENT',
INTERNAL_NOTE = 'INTERNAL_NOTE',
VERIFICATION = 'VERIFICATION',
OTHER = 'OTHER',
}
@Entity({ name: 'inspection_report_follow_ups' })
@Index('idx_inspection_report_follow_ups_report_occurred', ['reportId', 'occurredOn'])
@Index('idx_inspection_report_follow_ups_type', ['eventType'])
export class InspectionReportFollowUp extends TimestampedEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'report_id', type: 'uuid' })
reportId!: string;
@Column({ name: 'event_type', type: 'varchar', length: 32 })
eventType!: InspectionReportFollowUpType;
@Column({ name: 'reference_number', type: 'varchar', length: 255, nullable: true })
referenceNumber!: string | null;
@Column({ name: 'occurred_on', type: 'date' })
occurredOn!: string;
@Column({ type: 'text', nullable: true })
description!: string | null;
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdBy!: string | null;
}
@@ -31,6 +31,7 @@ export enum InspectionReportReviewStatus {
@Index('uq_inspection_reports_code', ['code'], { unique: true })
@Index('idx_inspection_reports_generated_at', ['generatedAt'])
@Index('idx_inspection_reports_status', ['status', 'pdfStatus'])
@Index('idx_inspection_reports_gedo_if_identifier', ['gedoIfIdentifier'])
export class InspectionReport extends TimestampedEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@@ -80,6 +81,57 @@ export class InspectionReport extends TimestampedEntity {
@Column({ name: 'word_error', type: 'varchar', length: 500, nullable: true })
wordError!: string | null;
@Column({ name: 'reference_text', type: 'text', nullable: true })
referenceText!: string | null;
@Column({ name: 'general_objective', type: 'text', nullable: true })
generalObjective!: string | null;
@Column({ name: 'specific_objective', type: 'text', nullable: true })
specificObjective!: string | null;
@Column({ type: 'text', nullable: true })
background!: string | null;
@Column({ name: 'legal_framework', type: 'text', nullable: true })
legalFramework!: string | null;
@Column({ name: 'executive_summary', type: 'text', nullable: true })
executiveSummary!: string | null;
@Column({ type: 'text', nullable: true })
description!: string | null;
@Column({ type: 'text', nullable: true })
conclusion!: string | null;
@Column({ name: 'gedo_if_identifier', type: 'varchar', length: 255, nullable: true })
gedoIfIdentifier!: string | null;
@Column({ name: 'gedo_officialized_on', type: 'date', nullable: true })
gedoOfficializedOn!: string | null;
@Column({ name: 'gedo_pdf_original_name', type: 'varchar', length: 255, nullable: true })
gedoPdfOriginalName!: string | null;
@Column({ name: 'gedo_pdf_stored_name', type: 'varchar', length: 255, nullable: true })
gedoPdfStoredName!: string | null;
@Column({ name: 'gedo_pdf_mime_type', type: 'varchar', length: 120, nullable: true })
gedoPdfMimeType!: string | null;
@Column({ name: 'gedo_pdf_size_bytes', type: 'integer', nullable: true })
gedoPdfSizeBytes!: number | null;
@Column({ name: 'gedo_pdf_sha256', type: 'char', length: 64, nullable: true })
gedoPdfSha256!: string | null;
@Column({ name: 'gedo_pdf_uploaded_at', type: 'timestamptz', nullable: true })
gedoPdfUploadedAt!: Date | null;
@Column({ name: 'gedo_pdf_uploaded_by', type: 'uuid', nullable: true })
gedoPdfUploadedBy!: string | null;
@Column({ name: 'review_status', type: 'varchar', length: 32, default: InspectionReportReviewStatus.PENDING_REVIEW })
reviewStatus!: InspectionReportReviewStatus;
@@ -1,44 +0,0 @@
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
import { TimestampedEntity } from './timestamped.entity';
export enum SurveyTargetStatus {
PENDING = 'PENDING',
IN_PROGRESS = 'IN_PROGRESS',
SUBMITTED = 'SUBMITTED',
COMPLETED = 'COMPLETED',
SKIPPED = 'SKIPPED',
}
@Entity({ name: 'survey_campaign_targets' })
@Index('uq_survey_campaign_targets_campaign_asset', ['campaignId', 'assetId'], { unique: true })
@Index('idx_survey_campaign_targets_campaign_status', ['campaignId', 'status'])
@Index('idx_survey_campaign_targets_asset_id', ['assetId'])
@Index('idx_survey_campaign_targets_assigned_user_id', ['assignedUserId'])
export class SurveyCampaignTarget extends TimestampedEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'campaign_id', type: 'uuid' })
campaignId!: string;
@Column({ name: 'asset_id', type: 'uuid' })
assetId!: string;
@Column({ name: 'assigned_user_id', type: 'uuid', nullable: true })
assignedUserId!: string | null;
@Column({ type: 'varchar', length: 24, default: SurveyTargetStatus.PENDING })
status!: SurveyTargetStatus;
@Column({ name: 'due_at', type: 'timestamptz', nullable: true })
dueAt!: Date | null;
@Column({ type: 'text', nullable: true })
instructions!: string | null;
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdBy!: string | null;
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
updatedBy!: string | null;
}
@@ -1,51 +0,0 @@
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
import { TimestampedEntity } from './timestamped.entity';
export enum SurveyCampaignStatus {
DRAFT = 'DRAFT',
PLANNED = 'PLANNED',
IN_PROGRESS = 'IN_PROGRESS',
COMPLETED = 'COMPLETED',
CANCELLED = 'CANCELLED',
}
@Entity({ name: 'survey_campaigns' })
@Index('uq_survey_campaigns_code', ['code'], { unique: true })
@Index('idx_survey_campaigns_status', ['status'])
@Index('idx_survey_campaigns_scope_asset_id', ['scopeAssetId'])
@Index('idx_survey_campaigns_coordinator_user_id', ['coordinatorUserId'])
@Index('idx_survey_campaigns_planned_dates', ['plannedStartAt', 'plannedEndAt'])
export class SurveyCampaign extends TimestampedEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ type: 'varchar', length: 80 })
code!: string;
@Column({ type: 'varchar', length: 200 })
name!: string;
@Column({ type: 'text', nullable: true })
description!: string | null;
@Column({ type: 'varchar', length: 24, default: SurveyCampaignStatus.DRAFT })
status!: SurveyCampaignStatus;
@Column({ name: 'planned_start_at', type: 'timestamptz', nullable: true })
plannedStartAt!: Date | null;
@Column({ name: 'planned_end_at', type: 'timestamptz', nullable: true })
plannedEndAt!: Date | null;
@Column({ name: 'scope_asset_id', type: 'uuid', nullable: true })
scopeAssetId!: string | null;
@Column({ name: 'coordinator_user_id', type: 'uuid', nullable: true })
coordinatorUserId!: string | null;
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdBy!: string | null;
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
updatedBy!: string | null;
}
@@ -1,19 +0,0 @@
import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
import { TimestampedEntity } from './timestamped.entity';
@Entity({ name: 'survey_target_report_media' })
@Index('idx_survey_target_report_media_media_id', ['mediaId'])
@Index('idx_survey_target_report_media_included', ['reportId', 'included'])
export class SurveyTargetReportMedia extends TimestampedEntity {
@PrimaryColumn({ name: 'report_id', type: 'uuid' })
reportId!: string;
@PrimaryColumn({ name: 'media_id', type: 'uuid' })
mediaId!: string;
@Column({ type: 'boolean', default: true })
included!: boolean;
@Column({ name: 'added_by', type: 'uuid', nullable: true })
addedBy!: string | null;
}
@@ -1,36 +0,0 @@
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
export enum SurveyReportVersionEvent {
SUBMITTED = 'SUBMITTED',
APPROVED = 'APPROVED',
REJECTED = 'REJECTED',
}
@Entity({ name: 'survey_target_report_versions' })
@Index('uq_survey_target_report_versions_number', ['reportId', 'versionNumber'], { unique: true })
@Index('idx_survey_target_report_versions_created_at', ['createdAt'])
export class SurveyTargetReportVersion {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'report_id', type: 'uuid' })
reportId!: string;
@Column({ name: 'version_number', type: 'integer' })
versionNumber!: number;
@Column({ type: 'varchar', length: 24 })
event!: SurveyReportVersionEvent;
@Column({ type: 'jsonb' })
snapshot!: Record<string, unknown>;
@Column({ name: 'actor_user_id', type: 'uuid', nullable: true })
actorUserId!: string | null;
@Column({ name: 'actor_username', type: 'varchar', length: 80, nullable: true })
actorUsername!: string | null;
@Column({ name: 'created_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' })
createdAt!: Date;
}
@@ -1,73 +0,0 @@
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
import { TimestampedEntity } from './timestamped.entity';
export enum SurveyReportOutcome {
CONFIRMED = 'CONFIRMED',
CHANGES_RECORDED = 'CHANGES_RECORDED',
NOT_LOCATED = 'NOT_LOCATED',
}
export enum SurveyReportStatus {
DRAFT = 'DRAFT',
SUBMITTED = 'SUBMITTED',
APPROVED = 'APPROVED',
REJECTED = 'REJECTED',
}
@Entity({ name: 'survey_target_reports' })
@Index('uq_survey_target_reports_target_id', ['targetId'], { unique: true })
@Index('idx_survey_target_reports_status', ['status'])
@Index('idx_survey_target_reports_submitted_by', ['submittedBy'])
@Index('idx_survey_target_reports_reviewed_by', ['reviewedBy'])
export class SurveyTargetReport extends TimestampedEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'target_id', type: 'uuid' })
targetId!: string;
@Column({ type: 'varchar', length: 32, nullable: true })
outcome!: SurveyReportOutcome | null;
@Column({ type: 'varchar', length: 24, default: SurveyReportStatus.DRAFT })
status!: SurveyReportStatus;
@Column({ name: 'observed_at', type: 'timestamptz', nullable: true })
observedAt!: Date | null;
@Column({ type: 'numeric', precision: 9, scale: 6, nullable: true })
latitude!: string | null;
@Column({ type: 'numeric', precision: 9, scale: 6, nullable: true })
longitude!: string | null;
@Column({ name: 'accuracy_m', type: 'numeric', precision: 12, scale: 3, nullable: true })
accuracyM!: string | null;
@Column({ type: 'text', nullable: true })
notes!: string | null;
@Column({ name: 'asset_version_at_submission', type: 'integer', nullable: true })
assetVersionAtSubmission!: number | null;
@Column({ name: 'submitted_at', type: 'timestamptz', nullable: true })
submittedAt!: Date | null;
@Column({ name: 'submitted_by', type: 'uuid', nullable: true })
submittedBy!: string | null;
@Column({ name: 'reviewed_at', type: 'timestamptz', nullable: true })
reviewedAt!: Date | null;
@Column({ name: 'reviewed_by', type: 'uuid', nullable: true })
reviewedBy!: string | null;
@Column({ name: 'review_notes', type: 'text', nullable: true })
reviewNotes!: string | null;
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdBy!: string | null;
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
updatedBy!: string | null;
}
@@ -0,0 +1,263 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class PhaseF4DocumentFlowFoundation1790035200000 implements MigrationInterface {
name = 'PhaseF4DocumentFlowFoundation1790035200000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE inspection_acts
ADD COLUMN urgency varchar(20),
ADD COLUMN deadline_days integer,
ADD COLUMN deadline_day_type varchar(20),
ADD COLUMN deadline_basis varchar(24),
ADD COLUMN deadline_base_on date,
ADD COLUMN deadline_due_on date,
ADD COLUMN deadline_policy_snapshot jsonb,
ADD COLUMN locked_at timestamptz,
ADD COLUMN locked_by uuid,
ADD COLUMN locked_sha256 char(64),
ADD COLUMN sealed_at timestamptz,
ADD COLUMN sealed_by uuid
`);
await queryRunner.query(`
ALTER TABLE inspection_acts
ADD CONSTRAINT chk_inspection_acts_urgency
CHECK (urgency IS NULL OR urgency IN ('URGENT','NON_URGENT')),
ADD CONSTRAINT chk_inspection_acts_deadline_days
CHECK (deadline_days IS NULL OR deadline_days > 0),
ADD CONSTRAINT chk_inspection_acts_deadline_day_type
CHECK (deadline_day_type IS NULL OR deadline_day_type IN ('BUSINESS','CALENDAR')),
ADD CONSTRAINT chk_inspection_acts_deadline_basis
CHECK (deadline_basis IS NULL OR deadline_basis IN ('ACT_DATE','GEDO_LOAD_DATE')),
ADD CONSTRAINT fk_inspection_acts_locked_by
FOREIGN KEY (locked_by) REFERENCES users(id) ON DELETE SET NULL,
ADD CONSTRAINT fk_inspection_acts_sealed_by
FOREIGN KEY (sealed_by) REFERENCES users(id) ON DELETE SET NULL
`);
await queryRunner.query(`
CREATE INDEX idx_inspection_acts_deadline_due_on
ON inspection_acts(deadline_due_on)
WHERE deadline_due_on IS NOT NULL
`);
await queryRunner.query(`
CREATE TABLE inspection_deadline_policies (
urgency varchar(20) PRIMARY KEY,
days integer NOT NULL,
day_type varchar(20) NOT NULL,
basis varchar(24) NOT NULL,
updated_by uuid,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT chk_inspection_deadline_policies_urgency
CHECK (urgency IN ('URGENT','NON_URGENT')),
CONSTRAINT chk_inspection_deadline_policies_days
CHECK (days > 0),
CONSTRAINT chk_inspection_deadline_policies_day_type
CHECK (day_type IN ('BUSINESS','CALENDAR')),
CONSTRAINT chk_inspection_deadline_policies_basis
CHECK (basis IN ('ACT_DATE','GEDO_LOAD_DATE')),
CONSTRAINT fk_inspection_deadline_policies_updated_by
FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL
)
`);
await queryRunner.query(`
INSERT INTO inspection_deadline_policies (urgency, days, day_type, basis)
VALUES
('URGENT', 5, 'BUSINESS', 'ACT_DATE'),
('NON_URGENT', 10, 'BUSINESS', 'GEDO_LOAD_DATE')
`);
await queryRunner.query(`
CREATE TABLE inspection_non_working_days (
day date PRIMARY KEY,
label varchar(200) NOT NULL,
enabled boolean NOT NULL DEFAULT true,
created_by uuid,
updated_by uuid,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT chk_inspection_non_working_days_label
CHECK (LENGTH(TRIM(label)) > 0),
CONSTRAINT fk_inspection_non_working_days_created_by
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT fk_inspection_non_working_days_updated_by
FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL
)
`);
await queryRunner.query(`
ALTER TABLE inspection_findings
ADD COLUMN is_recurrence boolean NOT NULL DEFAULT false,
ADD COLUMN antecedent_finding_id uuid
`);
await queryRunner.query(`
ALTER TABLE inspection_findings
ADD CONSTRAINT fk_inspection_findings_antecedent
FOREIGN KEY (antecedent_finding_id) REFERENCES inspection_findings(id) ON DELETE RESTRICT,
ADD CONSTRAINT chk_inspection_findings_recurrence_link
CHECK (
(is_recurrence = false AND antecedent_finding_id IS NULL)
OR (is_recurrence = true AND antecedent_finding_id IS NOT NULL)
),
ADD CONSTRAINT chk_inspection_findings_not_self_antecedent
CHECK (antecedent_finding_id IS NULL OR antecedent_finding_id <> id)
`);
await queryRunner.query(`
CREATE INDEX idx_inspection_findings_antecedent
ON inspection_findings(antecedent_finding_id)
WHERE antecedent_finding_id IS NOT NULL
`);
await queryRunner.query(`
ALTER TABLE inspection_reports
ADD COLUMN reference_text text,
ADD COLUMN general_objective text,
ADD COLUMN specific_objective text,
ADD COLUMN background text,
ADD COLUMN legal_framework text,
ADD COLUMN executive_summary text,
ADD COLUMN description text,
ADD COLUMN conclusion text,
ADD COLUMN gedo_if_identifier varchar(255),
ADD COLUMN gedo_officialized_on date,
ADD COLUMN gedo_pdf_original_name varchar(255),
ADD COLUMN gedo_pdf_stored_name varchar(255),
ADD COLUMN gedo_pdf_mime_type varchar(120),
ADD COLUMN gedo_pdf_size_bytes integer,
ADD COLUMN gedo_pdf_sha256 char(64),
ADD COLUMN gedo_pdf_uploaded_at timestamptz,
ADD COLUMN gedo_pdf_uploaded_by uuid
`);
await queryRunner.query(`
ALTER TABLE inspection_reports
ADD CONSTRAINT chk_inspection_reports_gedo_pdf_size
CHECK (gedo_pdf_size_bytes IS NULL OR gedo_pdf_size_bytes >= 0),
ADD CONSTRAINT fk_inspection_reports_gedo_pdf_uploaded_by
FOREIGN KEY (gedo_pdf_uploaded_by) REFERENCES users(id) ON DELETE SET NULL
`);
await queryRunner.query(`
CREATE INDEX idx_inspection_reports_gedo_if_identifier
ON inspection_reports(gedo_if_identifier)
WHERE gedo_if_identifier IS NOT NULL
`);
await queryRunner.query(`
CREATE TABLE inspection_report_follow_ups (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
report_id uuid NOT NULL,
event_type varchar(32) NOT NULL,
reference_number varchar(255),
occurred_on date NOT NULL,
description text,
created_by uuid,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT chk_inspection_report_follow_ups_type
CHECK (event_type IN ('COMPANY_NOTE','COMPANY_DOCUMENT','INTERNAL_NOTE','VERIFICATION','OTHER')),
CONSTRAINT fk_inspection_report_follow_ups_report
FOREIGN KEY (report_id) REFERENCES inspection_reports(id) ON DELETE RESTRICT,
CONSTRAINT fk_inspection_report_follow_ups_created_by
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
)
`);
await queryRunner.query(`
CREATE INDEX idx_inspection_report_follow_ups_report_occurred
ON inspection_report_follow_ups(report_id, occurred_on, created_at)
`);
await queryRunner.query(`
CREATE INDEX idx_inspection_report_follow_ups_type
ON inspection_report_follow_ups(event_type)
`);
await queryRunner.query(`
CREATE TABLE inspection_report_follow_up_files (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
follow_up_id uuid NOT NULL,
original_name varchar(255) NOT NULL,
stored_name varchar(255) NOT NULL,
mime_type varchar(120) NOT NULL,
size_bytes integer NOT NULL,
sha256 char(64) NOT NULL,
created_by uuid,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT chk_inspection_report_follow_up_files_size
CHECK (size_bytes >= 0),
CONSTRAINT fk_inspection_report_follow_up_files_follow_up
FOREIGN KEY (follow_up_id) REFERENCES inspection_report_follow_ups(id) ON DELETE RESTRICT,
CONSTRAINT fk_inspection_report_follow_up_files_created_by
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
)
`);
await queryRunner.query(`
CREATE INDEX idx_inspection_report_follow_up_files_follow_up
ON inspection_report_follow_up_files(follow_up_id)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS inspection_report_follow_up_files`);
await queryRunner.query(`DROP TABLE IF EXISTS inspection_report_follow_ups`);
await queryRunner.query(`DROP INDEX IF EXISTS idx_inspection_reports_gedo_if_identifier`);
await queryRunner.query(`
ALTER TABLE inspection_reports
DROP CONSTRAINT IF EXISTS fk_inspection_reports_gedo_pdf_uploaded_by,
DROP CONSTRAINT IF EXISTS chk_inspection_reports_gedo_pdf_size,
DROP COLUMN IF EXISTS gedo_pdf_uploaded_by,
DROP COLUMN IF EXISTS gedo_pdf_uploaded_at,
DROP COLUMN IF EXISTS gedo_pdf_sha256,
DROP COLUMN IF EXISTS gedo_pdf_size_bytes,
DROP COLUMN IF EXISTS gedo_pdf_mime_type,
DROP COLUMN IF EXISTS gedo_pdf_stored_name,
DROP COLUMN IF EXISTS gedo_pdf_original_name,
DROP COLUMN IF EXISTS gedo_officialized_on,
DROP COLUMN IF EXISTS gedo_if_identifier,
DROP COLUMN IF EXISTS conclusion,
DROP COLUMN IF EXISTS description,
DROP COLUMN IF EXISTS executive_summary,
DROP COLUMN IF EXISTS legal_framework,
DROP COLUMN IF EXISTS background,
DROP COLUMN IF EXISTS specific_objective,
DROP COLUMN IF EXISTS general_objective,
DROP COLUMN IF EXISTS reference_text
`);
await queryRunner.query(`DROP INDEX IF EXISTS idx_inspection_findings_antecedent`);
await queryRunner.query(`
ALTER TABLE inspection_findings
DROP CONSTRAINT IF EXISTS chk_inspection_findings_not_self_antecedent,
DROP CONSTRAINT IF EXISTS chk_inspection_findings_recurrence_link,
DROP CONSTRAINT IF EXISTS fk_inspection_findings_antecedent,
DROP COLUMN IF EXISTS antecedent_finding_id,
DROP COLUMN IF EXISTS is_recurrence
`);
await queryRunner.query(`DROP TABLE IF EXISTS inspection_non_working_days`);
await queryRunner.query(`DROP TABLE IF EXISTS inspection_deadline_policies`);
await queryRunner.query(`DROP INDEX IF EXISTS idx_inspection_acts_deadline_due_on`);
await queryRunner.query(`
ALTER TABLE inspection_acts
DROP CONSTRAINT IF EXISTS fk_inspection_acts_sealed_by,
DROP CONSTRAINT IF EXISTS fk_inspection_acts_locked_by,
DROP CONSTRAINT IF EXISTS chk_inspection_acts_deadline_basis,
DROP CONSTRAINT IF EXISTS chk_inspection_acts_deadline_day_type,
DROP CONSTRAINT IF EXISTS chk_inspection_acts_deadline_days,
DROP CONSTRAINT IF EXISTS chk_inspection_acts_urgency,
DROP COLUMN IF EXISTS sealed_by,
DROP COLUMN IF EXISTS sealed_at,
DROP COLUMN IF EXISTS locked_sha256,
DROP COLUMN IF EXISTS locked_by,
DROP COLUMN IF EXISTS locked_at,
DROP COLUMN IF EXISTS deadline_policy_snapshot,
DROP COLUMN IF EXISTS deadline_due_on,
DROP COLUMN IF EXISTS deadline_base_on,
DROP COLUMN IF EXISTS deadline_basis,
DROP COLUMN IF EXISTS deadline_day_type,
DROP COLUMN IF EXISTS deadline_days,
DROP COLUMN IF EXISTS urgency
`);
}
}
@@ -0,0 +1,38 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class PhaseF4DeadlineAdministration1790038800000 implements MigrationInterface {
name = 'PhaseF4DeadlineAdministration1790038800000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
INSERT INTO permissions (code, description)
VALUES ('inspection_deadlines.manage', 'Administrar plazos institucionales y calendario no laborable de Actas')
ON CONFLICT (code) DO UPDATE SET description = EXCLUDED.description
`);
await queryRunner.query(`
WITH mapping(role_code, permission_code) AS (
VALUES
('admin', 'inspection_deadlines.manage'),
('supervisor', 'inspection_deadlines.manage')
)
INSERT INTO role_permissions (role_id, permission_id)
SELECT role.id, permission.id
FROM mapping
INNER JOIN roles role ON role.code = mapping.role_code
INNER JOIN permissions permission ON permission.code = mapping.permission_code
ON CONFLICT DO NOTHING
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DELETE FROM role_permissions
WHERE permission_id IN (
SELECT id FROM permissions WHERE code = 'inspection_deadlines.manage'
)
`);
await queryRunner.query(`
DELETE FROM permissions WHERE code = 'inspection_deadlines.manage'
`);
}
}
@@ -0,0 +1,64 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class PhaseF4ReportDossierPermissions1790042400000 implements MigrationInterface {
name = 'PhaseF4ReportDossierPermissions1790042400000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
INSERT INTO permissions (code, description)
VALUES
('inspection_reports.edit', 'Editar el contenido de trabajo del Informe de inspección'),
('inspection_reports.officialize', 'Registrar el IF y PDF oficial generado por GEDO'),
('inspection_reports.follow_up', 'Agregar respuestas, notas y documentos al seguimiento del Informe')
ON CONFLICT (code) DO UPDATE SET description = EXCLUDED.description
`);
await queryRunner.query(`
WITH mapping(role_code, permission_code) AS (
VALUES
('admin', 'inspection_reports.edit'),
('admin', 'inspection_reports.officialize'),
('admin', 'inspection_reports.follow_up'),
('supervisor', 'inspection_reports.edit'),
('supervisor', 'inspection_reports.officialize'),
('supervisor', 'inspection_reports.follow_up'),
('inspector', 'inspection_reports.edit'),
('inspector', 'inspection_reports.officialize'),
('inspector', 'inspection_reports.follow_up')
)
INSERT INTO role_permissions (role_id, permission_id)
SELECT role.id, permission.id
FROM mapping
INNER JOIN roles role ON role.code = mapping.role_code
INNER JOIN permissions permission ON permission.code = mapping.permission_code
ON CONFLICT DO NOTHING
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS uq_inspection_reports_gedo_if_identifier
ON inspection_reports(gedo_if_identifier)
WHERE gedo_if_identifier IS NOT NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS uq_inspection_reports_gedo_if_identifier`);
await queryRunner.query(`
DELETE FROM role_permissions
WHERE permission_id IN (
SELECT id FROM permissions
WHERE code IN (
'inspection_reports.edit',
'inspection_reports.officialize',
'inspection_reports.follow_up'
)
)
`);
await queryRunner.query(`
DELETE FROM permissions
WHERE code IN (
'inspection_reports.edit',
'inspection_reports.officialize',
'inspection_reports.follow_up'
)
`);
}
}
@@ -0,0 +1,144 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class PhaseF4DocumentNumbering1790046000000 implements MigrationInterface {
name = 'PhaseF4DocumentNumbering1790046000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM inspection_acts WHERE act_number > 99999) THEN
RAISE EXCEPTION 'F4 numbering requires inspection act numbers <= 99999';
END IF;
IF EXISTS (
SELECT 1
FROM inspection_visits
WHERE code ~ '^INS-[0-9]{4}-[0-9]{6}$'
AND RIGHT(code, 6)::integer > 99999
) THEN
RAISE EXCEPTION 'F4 numbering requires inspection visit numbers <= 99999';
END IF;
END $$
`);
await queryRunner.query(`
UPDATE inspection_visits
SET code = 'INSP-'
|| LPAD(RIGHT(code, 6)::integer::text, 5, '0')
|| '-'
|| TO_CHAR(
COALESCE(planned_start_at, created_at) AT TIME ZONE 'America/Argentina/Mendoza',
'DD-MM-YY'
),
title = CASE
WHEN title = inspection_visits.code THEN 'INSP-'
|| LPAD(RIGHT(inspection_visits.code, 6)::integer::text, 5, '0')
|| '-'
|| TO_CHAR(
COALESCE(planned_start_at, created_at) AT TIME ZONE 'America/Argentina/Mendoza',
'DD-MM-YY'
)
ELSE title
END
WHERE code ~ '^INS-[0-9]{4}-[0-9]{6}$'
`);
await queryRunner.query(`
UPDATE inspection_acts
SET code = 'ACT-'
|| LPAD(act_number::text, 5, '0')
|| '-'
|| TO_CHAR(occurred_at AT TIME ZONE 'America/Argentina/Mendoza', 'DD-MM-YY')
`);
await queryRunner.query(`DROP INDEX IF EXISTS uq_inspection_reports_year_number`);
await queryRunner.query(`
UPDATE inspection_reports report
SET report_year = act.act_year,
report_number = act.act_number,
code = 'INF-'
|| LPAD(act.act_number::text, 5, '0')
|| '-'
|| TO_CHAR(act.occurred_at AT TIME ZONE 'America/Argentina/Mendoza', 'DD-MM-YY')
FROM inspection_acts act
WHERE act.id = report.act_id
`);
await queryRunner.query(`
CREATE UNIQUE INDEX uq_inspection_reports_year_number
ON inspection_reports(report_year, report_number)
`);
await queryRunner.query(`
CREATE OR REPLACE FUNCTION dhv2_f4_set_act_code()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
IF NEW.act_number > 99999 THEN
RAISE EXCEPTION 'Inspection Act sequence exhausted for F4 institutional format';
END IF;
NEW.code := 'ACT-'
|| LPAD(NEW.act_number::text, 5, '0')
|| '-'
|| TO_CHAR(NEW.occurred_at AT TIME ZONE 'America/Argentina/Mendoza', 'DD-MM-YY');
RETURN NEW;
END;
$$
`);
await queryRunner.query(`
DROP TRIGGER IF EXISTS trg_dhv2_f4_set_act_code ON inspection_acts
`);
await queryRunner.query(`
CREATE TRIGGER trg_dhv2_f4_set_act_code
BEFORE INSERT OR UPDATE OF act_number, occurred_at
ON inspection_acts
FOR EACH ROW
EXECUTE FUNCTION dhv2_f4_set_act_code()
`);
await queryRunner.query(`
CREATE OR REPLACE FUNCTION dhv2_f4_set_report_code()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
source_act inspection_acts%ROWTYPE;
BEGIN
SELECT * INTO source_act
FROM inspection_acts
WHERE id = NEW.act_id;
IF NOT FOUND THEN
RAISE EXCEPTION 'Inspection Act not found for report %', NEW.act_id;
END IF;
IF source_act.act_number > 99999 THEN
RAISE EXCEPTION 'Inspection Report sequence exhausted for F4 institutional format';
END IF;
NEW.report_year := source_act.act_year;
NEW.report_number := source_act.act_number;
NEW.code := 'INF-'
|| LPAD(source_act.act_number::text, 5, '0')
|| '-'
|| TO_CHAR(source_act.occurred_at AT TIME ZONE 'America/Argentina/Mendoza', 'DD-MM-YY');
RETURN NEW;
END;
$$
`);
await queryRunner.query(`
DROP TRIGGER IF EXISTS trg_dhv2_f4_set_report_code ON inspection_reports
`);
await queryRunner.query(`
CREATE TRIGGER trg_dhv2_f4_set_report_code
BEFORE INSERT OR UPDATE OF act_id
ON inspection_reports
FOR EACH ROW
EXECUTE FUNCTION dhv2_f4_set_report_code()
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TRIGGER IF EXISTS trg_dhv2_f4_set_report_code ON inspection_reports`);
await queryRunner.query(`DROP FUNCTION IF EXISTS dhv2_f4_set_report_code()`);
await queryRunner.query(`DROP TRIGGER IF EXISTS trg_dhv2_f4_set_act_code ON inspection_acts`);
await queryRunner.query(`DROP FUNCTION IF EXISTS dhv2_f4_set_act_code()`);
}
}
@@ -0,0 +1,87 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class PhaseF4SmtpSuperadmin1790049600000 implements MigrationInterface {
name = 'PhaseF4SmtpSuperadmin1790049600000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE smtp_settings (
id smallint PRIMARY KEY,
enabled boolean NOT NULL DEFAULT false,
host varchar(255),
port integer,
security_mode varchar(20),
username varchar(255),
password_encrypted text,
from_name varchar(160),
from_email varchar(255),
reply_to varchar(255),
updated_by uuid,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT chk_smtp_settings_singleton CHECK (id = 1),
CONSTRAINT chk_smtp_settings_port CHECK (port IS NULL OR port BETWEEN 1 AND 65535),
CONSTRAINT chk_smtp_settings_security CHECK (
security_mode IS NULL OR security_mode IN ('TLS','STARTTLS')
),
CONSTRAINT fk_smtp_settings_updated_by
FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL
)
`);
await queryRunner.query(`
INSERT INTO smtp_settings (id, enabled)
VALUES (1, false)
`);
await queryRunner.query(`
INSERT INTO permissions (code, description)
VALUES ('system_mail.manage', 'Configurar la salida SMTP del sistema y probar el transporte')
ON CONFLICT (code) DO UPDATE SET description = EXCLUDED.description
`);
await queryRunner.query(`
INSERT INTO role_permissions (role_id, permission_id)
SELECT role.id, permission.id
FROM roles role
CROSS JOIN permissions permission
WHERE role.code = 'admin'
AND permission.code = 'system_mail.manage'
ON CONFLICT DO NOTHING
`);
await queryRunner.query(`
UPDATE inspection_document_deliveries delivery
SET recipient_kind = 'INSPECTOR',
recipient_user_id = visit.lead_inspector_user_id,
recipient_key = visit.lead_inspector_user_id,
recipient_email = inspector.email,
status = CASE WHEN inspector.email IS NULL THEN 'WAITING_RECIPIENT' ELSE 'PENDING' END,
last_error = NULL,
updated_at = CURRENT_TIMESTAMP
FROM inspection_acts act
INNER JOIN inspection_visits visit ON visit.id = act.visit_id
INNER JOIN users inspector ON inspector.id = visit.lead_inspector_user_id
WHERE delivery.act_id = act.id
AND delivery.document_kind = 'REPORT_WORD'
AND delivery.recipient_kind = 'DIRECTOR'
AND delivery.status <> 'SENT'
AND NOT EXISTS (
SELECT 1
FROM inspection_document_deliveries existing
WHERE existing.act_id = delivery.act_id
AND existing.document_kind = 'REPORT_WORD'
AND existing.recipient_kind = 'INSPECTOR'
AND existing.recipient_key = visit.lead_inspector_user_id
)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DELETE FROM role_permissions
WHERE permission_id IN (
SELECT id FROM permissions WHERE code = 'system_mail.manage'
)
`);
await queryRunner.query(`DELETE FROM permissions WHERE code = 'system_mail.manage'`);
await queryRunner.query(`DROP TABLE IF EXISTS smtp_settings`);
}
}
@@ -0,0 +1,28 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class PhaseF4RemoveSurveySubsystem1790053200000 implements MigrationInterface {
name = 'PhaseF4RemoveSurveySubsystem1790053200000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DELETE FROM role_permissions
WHERE permission_id IN (
SELECT id FROM permissions WHERE code LIKE 'surveys.%'
)
`);
await queryRunner.query(`
DELETE FROM permissions WHERE code LIKE 'surveys.%'
`);
await queryRunner.query(`DROP TABLE IF EXISTS survey_target_report_media`);
await queryRunner.query(`DROP TABLE IF EXISTS survey_target_report_versions`);
await queryRunner.query(`DROP TABLE IF EXISTS survey_target_reports`);
await queryRunner.query(`DROP TABLE IF EXISTS survey_campaign_targets`);
await queryRunner.query(`DROP TABLE IF EXISTS survey_campaigns`);
}
public async down(_queryRunner: QueryRunner): Promise<void> {
// Intentionally irreversible: historical migrations still document the old
// Survey schema, but restoring dropped campaign/report data would be unsafe.
}
}
@@ -0,0 +1,43 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class PhaseF4SealedActs1790056800000 implements MigrationInterface {
name = 'PhaseF4SealedActs1790056800000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
UPDATE inspection_acts
SET sealed_at = COALESCE(closed_at, updated_at, CURRENT_TIMESTAMP),
sealed_by = closed_by
WHERE status = 'CLOSED'
AND sealed_at IS NULL
`);
await queryRunner.query(`
CREATE OR REPLACE FUNCTION dhv2_f4_stamp_act_seal()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
IF NEW.status = 'CLOSED' AND OLD.status IS DISTINCT FROM 'CLOSED' THEN
NEW.sealed_at := COALESCE(NEW.closed_at, CURRENT_TIMESTAMP);
NEW.sealed_by := NEW.closed_by;
END IF;
RETURN NEW;
END;
$$
`);
await queryRunner.query(`
DROP TRIGGER IF EXISTS trg_dhv2_f4_stamp_act_seal ON inspection_acts
`);
await queryRunner.query(`
CREATE TRIGGER trg_dhv2_f4_stamp_act_seal
BEFORE UPDATE OF status ON inspection_acts
FOR EACH ROW
EXECUTE FUNCTION dhv2_f4_stamp_act_seal()
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TRIGGER IF EXISTS trg_dhv2_f4_stamp_act_seal ON inspection_acts`);
await queryRunner.query(`DROP FUNCTION IF EXISTS dhv2_f4_stamp_act_seal()`);
}
}
@@ -0,0 +1,7 @@
import { IsEnum } from 'class-validator';
import { InspectionActUrgency } from '../../database/entities';
export class LockInspectionActDto {
@IsEnum(InspectionActUrgency)
urgency!: InspectionActUrgency;
}
@@ -0,0 +1,22 @@
import { Body, Controller, Param, ParseUUIDPipe, Post, Req } from '@nestjs/common';
import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import { LockInspectionActDto } from './dto/lock-inspection-act.dto';
import { InspectionActLifecycleService } from './inspection-act-lifecycle.service';
@Controller('inspection-acts/:actId')
export class InspectionActLifecycleController {
constructor(private readonly lifecycle: InspectionActLifecycleService) {}
@Post('lock')
@RequirePermissions('inspection_closure.prepare')
lock(
@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string,
@Body() dto: LockInspectionActDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.lifecycle.lock(actId, dto, principal, request);
}
}
@@ -0,0 +1,509 @@
import {
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { DataSource, EntityManager } from 'typeorm';
import { administrationAuditContext } from '../administration/common/administration-audit';
import { AuditService } from '../audit/audit.service';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import {
AuditAction,
InspectionActStatus,
InspectionActVersionEvent,
InspectionVisitStatus,
} from '../database/entities';
import { InspectionDeadlinesService } from '../inspection-deadlines/inspection-deadlines.service';
import { assertMobileInspector } from '../inspection-operations/mobile-inspector-policy';
import { sha256CanonicalJson } from './canonical-json';
import type { LockInspectionActDto } from './dto/lock-inspection-act.dto';
const CLOSURE_SCHEMA_VERSION = 'DH-ACT-CLOSURE-V2';
interface LockContext {
id: string;
code: string;
visitId: string;
status: InspectionActStatus;
occurredAt: Date;
currentVersion: number;
visitStatus: InspectionVisitStatus;
leadInspectorUserId: string;
}
@Injectable()
export class InspectionActLifecycleService {
constructor(
private readonly dataSource: DataSource,
private readonly audit: AuditService,
private readonly deadlines: InspectionDeadlinesService,
) {}
async lock(
actId: string,
dto: LockInspectionActDto,
principal: AuthPrincipal,
request: RequestWithContext,
) {
assertMobileInspector(principal);
return this.dataSource.transaction(async (manager) => {
const context = await this.lockContext(manager, actId);
if (context.status !== InspectionActStatus.DRAFT) {
throw new ConflictException({
code: 'INSPECTION_ACT_NOT_DRAFT',
message: 'Sólo puede finalizarse y bloquearse un Acta que esté en borrador',
});
}
if (context.visitStatus !== InspectionVisitStatus.IN_PROGRESS) {
throw new ConflictException({
code: 'INSPECTION_VISIT_NOT_IN_PROGRESS',
message: 'El Acta sólo puede bloquearse durante una inspección en curso',
});
}
await this.assertActorAssigned(manager, context, principal);
await this.requireResponsible(manager, actId);
await this.requireVerificationResults(manager, context.visitId);
const [signatureCount] = (await manager.query(`
SELECT COUNT(*)::integer AS total
FROM inspection_act_signatures
WHERE act_id = $1
`, [actId])) as Array<{ total: number }>;
if (Number(signatureCount?.total ?? 0) > 0) {
throw new ConflictException({
code: 'INSPECTION_ACT_ALREADY_SIGNED',
message: 'El Acta ya tiene una manifestación de firma y no puede volver a bloquearse',
});
}
const deadline = await this.deadlines.snapshotForLock(
manager,
dto.urgency,
new Date(context.occurredAt),
);
const lockedAt = new Date();
const [updated] = (await manager.query(`
UPDATE inspection_acts
SET status = 'READY',
urgency = $2,
deadline_days = $3,
deadline_day_type = $4,
deadline_basis = $5,
deadline_base_on = $6::date,
deadline_due_on = $7::date,
deadline_policy_snapshot = $8::jsonb,
locked_at = $9,
locked_by = $10,
current_version = current_version + 1,
updated_by = $10,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1
RETURNING current_version AS "versionNumber"
`, [
actId,
dto.urgency,
deadline.snapshot.days,
deadline.snapshot.dayType,
deadline.snapshot.basis,
deadline.baseOn,
deadline.dueOn,
deadline.snapshot,
lockedAt,
principal.userId,
])) as Array<{ versionNumber: number }>;
const preparedSnapshot = await this.buildPreparedSnapshot(manager, actId, lockedAt);
const preparedSha256 = sha256CanonicalJson(preparedSnapshot);
await manager.query(`
UPDATE inspection_acts
SET locked_sha256 = $2
WHERE id = $1
`, [actId, preparedSha256]);
await manager.query(`
INSERT INTO inspection_act_closures (
act_id, schema_version, prepared_snapshot, prepared_sha256,
prepared_at, prepared_by
) VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (act_id) DO UPDATE SET
schema_version = EXCLUDED.schema_version,
prepared_snapshot = EXCLUDED.prepared_snapshot,
prepared_sha256 = EXCLUDED.prepared_sha256,
prepared_at = EXCLUDED.prepared_at,
prepared_by = EXCLUDED.prepared_by
`, [
actId,
CLOSURE_SCHEMA_VERSION,
preparedSnapshot,
preparedSha256,
lockedAt,
principal.userId,
]);
await manager.query(`
INSERT INTO inspection_act_versions (
act_id, version_number, event, snapshot, actor_user_id, actor_username
) VALUES ($1, $2, $3, $4, $5, $6)
`, [
actId,
Number(updated.versionNumber),
InspectionActVersionEvent.READY,
preparedSnapshot,
principal.userId,
principal.username,
]);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.INSPECTION_ACT_READY,
entityType: 'inspection_act',
entityId: actId,
afterData: {
status: 'LOCKED_PENDING_SIGNATURE',
physicalStatus: InspectionActStatus.READY,
urgency: dto.urgency,
deadlineDays: deadline.snapshot.days,
deadlineDayType: deadline.snapshot.dayType,
deadlineBasis: deadline.snapshot.basis,
deadlineBaseOn: deadline.baseOn,
deadlineDueOn: deadline.dueOn,
lockedAt: lockedAt.toISOString(),
preparedSha256,
},
metadata: {
actId,
visitId: context.visitId,
versionNumber: Number(updated.versionNumber),
immutable: true,
},
}, manager);
return {
id: actId,
code: context.code,
status: 'LOCKED_PENDING_SIGNATURE' as const,
physicalStatus: InspectionActStatus.READY,
urgency: dto.urgency,
deadline: {
days: deadline.snapshot.days,
dayType: deadline.snapshot.dayType,
basis: deadline.snapshot.basis,
baseOn: deadline.baseOn,
dueOn: deadline.dueOn,
},
lockedAt,
preparedSha256,
currentVersion: Number(updated.versionNumber),
};
});
}
private async lockContext(manager: EntityManager, actId: string): Promise<LockContext> {
const [row] = (await manager.query(`
SELECT
act.id,
act.code,
act.visit_id AS "visitId",
act.status,
act.occurred_at AS "occurredAt",
act.current_version AS "currentVersion",
visit.status AS "visitStatus",
visit.lead_inspector_user_id AS "leadInspectorUserId"
FROM inspection_acts act
INNER JOIN inspection_visits visit ON visit.id = act.visit_id
WHERE act.id = $1
FOR UPDATE OF act, visit
`, [actId])) as LockContext[];
if (!row) {
throw new NotFoundException({
code: 'INSPECTION_ACT_NOT_FOUND',
message: 'Acta de inspección no encontrada',
});
}
return row;
}
private async assertActorAssigned(
manager: EntityManager,
context: LockContext,
principal: AuthPrincipal,
): Promise<void> {
if (context.leadInspectorUserId === principal.userId) return;
const [membership] = (await manager.query(`
SELECT 1 AS found
FROM inspection_visit_members
WHERE visit_id = $1
AND user_id = $2
AND included = true
LIMIT 1
`, [context.visitId, principal.userId])) as Array<{ found: number }>;
if (!membership) {
throw new ForbiddenException({
code: 'INSPECTION_ACT_ACTOR_NOT_ASSIGNED',
message: 'Sólo un inspector asignado a la inspección puede finalizar el Acta',
});
}
}
private async requireResponsible(manager: EntityManager, actId: string): Promise<void> {
const [row] = (await manager.query(`
SELECT act_id
FROM inspection_act_responsibles
WHERE act_id = $1
`, [actId])) as Array<{ act_id: string }>;
if (!row) {
throw new ConflictException({
code: 'INSPECTION_ACT_RESPONSIBLE_REQUIRED',
message: 'Antes de finalizar el Acta debe identificarse al responsable de la empresa o documentar su ausencia',
});
}
}
private async requireVerificationResults(manager: EntityManager, visitId: string): Promise<void> {
const [verification] = (await manager.query(`
SELECT
COUNT(*)::integer AS total,
COUNT(*) FILTER (WHERE outcome IS NOT NULL)::integer AS completed
FROM inspection_finding_verification_visits
WHERE visit_id = $1
`, [visitId])) as Array<{ total: number; completed: number }>;
const total = Number(verification?.total ?? 0);
const completed = Number(verification?.completed ?? 0);
if (total > 0 && completed !== total) {
throw new ConflictException({
code: 'INSPECTION_VERIFICATION_RESULTS_REQUIRED',
message: 'Registrá el resultado de todas las verificaciones antes de finalizar el Acta',
});
}
}
private async buildPreparedSnapshot(
manager: EntityManager,
actId: string,
preparedAt: Date,
): Promise<Record<string, unknown>> {
const [act] = (await manager.query(`
SELECT
act.id,
act.code,
act.act_year AS "actYear",
act.act_number AS "actNumber",
act.status,
act.occurred_at AS "occurredAt",
act.title,
act.summary,
act.observations,
act.urgency,
act.deadline_days AS "deadlineDays",
act.deadline_day_type AS "deadlineDayType",
act.deadline_basis AS "deadlineBasis",
act.deadline_base_on AS "deadlineBaseOn",
act.deadline_due_on AS "deadlineDueOn",
act.deadline_policy_snapshot AS "deadlinePolicySnapshot",
act.locked_at AS "lockedAt",
act.current_version AS "currentVersion",
act.created_at AS "createdAt",
act.updated_at AS "updatedAt",
JSONB_BUILD_OBJECT(
'id', visit.id,
'code', visit.code,
'title', visit.title,
'objective', visit.objective,
'status', visit.status,
'scopeAssetId', visit.scope_asset_id,
'leadInspectorUserId', visit.lead_inspector_user_id,
'plannedStartAt', visit.planned_start_at,
'plannedEndAt', visit.planned_end_at,
'actualStartedAt', visit.actual_started_at,
'instructions', visit.instructions
) AS visit
FROM inspection_acts act
INNER JOIN inspection_visits visit ON visit.id = act.visit_id
WHERE act.id = $1
`, [actId])) as Array<Record<string, unknown>>;
if (!act) {
throw new NotFoundException({
code: 'INSPECTION_ACT_NOT_FOUND',
message: 'Acta de inspección no encontrada',
});
}
const [responsible] = (await manager.query(`
SELECT
act_id AS "actId",
attendance_status AS "attendanceStatus",
full_name AS "fullName",
document_type AS "documentType",
document_number AS "documentNumber",
position,
email,
phone,
absence_reason AS "absenceReason",
updated_by AS "updatedBy",
created_at AS "createdAt",
updated_at AS "updatedAt"
FROM inspection_act_responsibles
WHERE act_id = $1
`, [actId])) as Array<Record<string, unknown>>;
const team = await manager.query(`
SELECT
member.user_id AS "userId",
user_account.username,
user_account.first_name AS "firstName",
user_account.last_name AS "lastName",
user_account.email,
(visit.lead_inspector_user_id = member.user_id) AS "isLead"
FROM inspection_visit_members member
INNER JOIN inspection_visits visit ON visit.id = member.visit_id
INNER JOIN users user_account ON user_account.id = member.user_id
WHERE member.visit_id = $1 AND member.included = true
ORDER BY "isLead" DESC, user_account.username, member.user_id
`, [(act.visit as { id: string }).id]) as Array<Record<string, unknown>>;
const assets = await manager.query(`
SELECT
asset.id,
asset.code,
asset.name,
asset.common_name AS "commonName",
asset.description,
asset.parent_id AS "parentId",
CASE WHEN company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id', company.id,
'code', company.code,
'name', company.name,
'commonName', company.common_name
) END AS "operatorCompany",
CASE WHEN area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id', area.id,
'code', area.code,
'name', area.name,
'commonName', area.common_name
) END AS "operationalArea",
asset.information_status AS "informationStatus",
asset.current_version AS "currentVersion",
asset.data_origin AS "dataOrigin",
asset.source_name AS "sourceName",
asset.source_reference AS "sourceReference",
asset.source_observed_at AS "sourceObservedAt",
asset_type.id AS "typeId",
asset_type.code AS "typeCode",
asset_type.name AS "typeName",
COALESCE((
SELECT JSONB_AGG(JSONB_BUILD_OBJECT(
'definitionId', definition.id,
'code', definition.code,
'name', definition.name,
'dataType', definition.data_type,
'value', attribute_value.value
) ORDER BY definition.sort_order, definition.code, definition.id)
FROM asset_attribute_values attribute_value
INNER JOIN asset_attribute_definitions definition
ON definition.id = attribute_value.definition_id
WHERE attribute_value.asset_id = asset.id
), '[]'::jsonb) AS attributes,
CASE WHEN geometry.asset_id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'type', geometry.geometry_type,
'geojson', ST_AsGeoJSON(geometry.geometry)::jsonb,
'source', geometry.source,
'accuracyM', geometry.accuracy_m,
'capturedAt', geometry.captured_at,
'deviceLabel', geometry.device_label
) END AS geometry
FROM inspection_act_assets link
INNER JOIN assets asset ON asset.id = link.asset_id
INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id
LEFT JOIN assets company ON company.id = asset.operator_company_id
LEFT JOIN assets area ON area.id = asset.operational_area_id
LEFT JOIN asset_geometries geometry ON geometry.asset_id = asset.id
WHERE link.act_id = $1 AND link.included = true
ORDER BY asset.code, asset.id
`, [actId]) as Array<Record<string, unknown>>;
const findings = await manager.query(`
SELECT
finding.id,
finding.finding_number AS "findingNumber",
finding.code,
finding.status,
finding.asset_id AS "assetId",
finding.catalog_item_id AS "catalogItemId",
finding.title,
finding.description,
finding.legal_basis AS "legalBasis",
finding.glossary,
finding.catalog_revision AS "catalogRevision",
finding.suggested_severity AS "suggestedSeverity",
finding.severity,
finding.is_recurrence AS "isRecurrence",
finding.antecedent_finding_id AS "antecedentFindingId",
finding.correction_due_on AS "correctionDueOn",
finding.next_control_on AS "nextControlOn",
finding.current_version AS "currentVersion",
JSONB_BUILD_OBJECT(
'code', catalog.code,
'sourceNumber', catalog.source_number,
'title', catalog.title,
'revision', catalog.revision,
'suggestedSeverity', finding.suggested_severity,
'categoryCode', category.code,
'categoryName', category.name
) AS catalog,
COALESCE((
SELECT JSONB_AGG(JSONB_BUILD_OBJECT(
'id', evidence.id,
'communicationId', evidence.communication_id,
'kind', evidence.kind,
'purpose', evidence.purpose,
'originalName', evidence.original_name,
'mimeType', evidence.mime_type,
'sizeBytes', evidence.size_bytes,
'sha256', evidence.sha256,
'title', evidence.title,
'description', evidence.description,
'capturedAt', evidence.captured_at,
'latitude', evidence.latitude,
'longitude', evidence.longitude,
'accuracyM', evidence.accuracy_m,
'deviceLabel', evidence.device_label,
'source', evidence.source,
'uploadedBy', evidence.uploaded_by,
'createdAt', evidence.created_at
) ORDER BY evidence.created_at, evidence.id)
FROM inspection_finding_evidence evidence
WHERE evidence.finding_id = finding.id
), '[]'::jsonb) AS evidence
FROM inspection_findings finding
LEFT JOIN finding_catalog_items catalog ON catalog.id = finding.catalog_item_id
LEFT JOIN finding_categories category ON category.id = catalog.category_id
WHERE finding.act_id = $1 AND finding.status <> 'VOIDED'
ORDER BY finding.finding_number, finding.id
`, [actId]) as Array<Record<string, unknown>>;
const verificationResults = await manager.query(`
SELECT
verification_link.finding_id AS "findingId",
finding.code AS "findingCode",
finding.title AS "findingTitle",
finding.description AS "findingDescription",
finding.asset_id AS "assetId",
asset.code AS "assetCode",
asset.name AS "assetName",
verification_link.target_control_on AS "targetControlOn",
verification_link.outcome,
verification_link.result_notes AS "resultNotes",
verification_link.verified_at AS "verifiedAt",
verification_link.result_recorded_at AS "resultRecordedAt",
verification_link.rescheduled_control_on AS "rescheduledControlOn"
FROM inspection_finding_verification_visits verification_link
INNER JOIN inspection_findings finding ON finding.id = verification_link.finding_id
INNER JOIN assets asset ON asset.id = finding.asset_id
WHERE verification_link.visit_id = $1
ORDER BY finding.code
`, [(act.visit as { id: string }).id]) as Array<Record<string, unknown>>;
return {
schemaVersion: CLOSURE_SCHEMA_VERSION,
preparedAt: preparedAt.toISOString(),
act,
responsible: responsible ?? null,
team,
assets,
findings,
verificationResults,
};
}
}
@@ -48,26 +48,6 @@ export class InspectionClosingController {
return this.closing.upsertResponsible(actId, dto, principal, request);
}
@Post('ready')
@RequirePermissions('inspection_closure.prepare')
ready(
@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.closing.prepare(actId, principal, request);
}
@Post('reopen')
@RequirePermissions('inspection_closure.prepare')
reopen(
@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.closing.reopen(actId, principal, request);
}
@Post('signatures/inspector')
@RequirePermissions('inspection_closure.sign')
@UseInterceptors(FileInterceptor('file', {
@@ -1,6 +1,9 @@
import { Module } from '@nestjs/common';
import { AuditModule } from '../audit/audit.module';
import { InspectionDeadlinesModule } from '../inspection-deadlines/inspection-deadlines.module';
import { InspectionReportsModule } from '../inspection-reports/inspection-reports.module';
import { InspectionActLifecycleController } from './inspection-act-lifecycle.controller';
import { InspectionActLifecycleService } from './inspection-act-lifecycle.service';
import {
InspectionClosingController,
InspectionSignatureContentController,
@@ -8,8 +11,12 @@ import {
import { InspectionClosingService } from './inspection-closing.service';
@Module({
imports: [AuditModule, InspectionReportsModule],
controllers: [InspectionClosingController, InspectionSignatureContentController],
providers: [InspectionClosingService],
imports: [AuditModule, InspectionDeadlinesModule, InspectionReportsModule],
controllers: [
InspectionActLifecycleController,
InspectionClosingController,
InspectionSignatureContentController,
],
providers: [InspectionActLifecycleService, InspectionClosingService],
})
export class InspectionClosingModule {}
@@ -0,0 +1,14 @@
import { Transform } from 'class-transformer';
import { IsEnum, IsInt, Max, Min } from 'class-validator';
import { InspectionDeadlineDayType } from '../../database/entities';
export class UpdateInspectionDeadlinePolicyDto {
@Transform(({ value }) => Number(value))
@IsInt()
@Min(1)
@Max(365)
days!: number;
@IsEnum(InspectionDeadlineDayType)
dayType!: InspectionDeadlineDayType;
}
@@ -0,0 +1,18 @@
import { Transform } from 'class-transformer';
import { IsBoolean, IsOptional, IsString, Matches, MaxLength, MinLength } from 'class-validator';
export class UpsertInspectionNonWorkingDayDto {
@Matches(/^\d{4}-\d{2}-\d{2}$/)
day!: string;
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString()
@MinLength(2)
@MaxLength(200)
label!: string;
@IsOptional()
@Transform(({ value }) => value === true || value === 'true')
@IsBoolean()
enabled?: boolean;
}
@@ -0,0 +1,46 @@
import { Body, Controller, Get, Param, ParseEnumPipe, Post, Query, Req } from '@nestjs/common';
import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import { InspectionActUrgency } from '../database/entities';
import { UpdateInspectionDeadlinePolicyDto } from './dto/update-inspection-deadline-policy.dto';
import { UpsertInspectionNonWorkingDayDto } from './dto/upsert-inspection-non-working-day.dto';
import { InspectionDeadlinesService } from './inspection-deadlines.service';
@Controller('inspection-deadlines')
export class InspectionDeadlinesController {
constructor(private readonly deadlines: InspectionDeadlinesService) {}
@Get('policies')
@RequirePermissions('inspection_deadlines.manage')
policies() {
return this.deadlines.listPolicies();
}
@Post('policies/:urgency')
@RequirePermissions('inspection_deadlines.manage')
updatePolicy(
@Param('urgency', new ParseEnumPipe(InspectionActUrgency)) urgency: InspectionActUrgency,
@Body() dto: UpdateInspectionDeadlinePolicyDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.deadlines.updatePolicy(urgency, dto, principal, request);
}
@Get('non-working-days')
@RequirePermissions('inspection_deadlines.manage')
nonWorkingDays(@Query('year') year?: string) {
return this.deadlines.listNonWorkingDays(year);
}
@Post('non-working-days')
@RequirePermissions('inspection_deadlines.manage')
upsertNonWorkingDay(
@Body() dto: UpsertInspectionNonWorkingDayDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.deadlines.upsertNonWorkingDay(dto, principal, request);
}
}
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { AuditModule } from '../audit/audit.module';
import { InspectionDeadlinesController } from './inspection-deadlines.controller';
import { InspectionDeadlinesService } from './inspection-deadlines.service';
@Module({
imports: [AuditModule],
controllers: [InspectionDeadlinesController],
providers: [InspectionDeadlinesService],
exports: [InspectionDeadlinesService],
})
export class InspectionDeadlinesModule {}
@@ -0,0 +1,302 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { DataSource, EntityManager } from 'typeorm';
import { administrationAuditContext } from '../administration/common/administration-audit';
import { AuditService } from '../audit/audit.service';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import {
InspectionActUrgency,
InspectionDeadlineBasis,
InspectionDeadlineDayType,
InspectionDeadlinePolicy,
InspectionNonWorkingDay,
} from '../database/entities';
import type { UpdateInspectionDeadlinePolicyDto } from './dto/update-inspection-deadline-policy.dto';
import type { UpsertInspectionNonWorkingDayDto } from './dto/upsert-inspection-non-working-day.dto';
export interface InspectionDeadlineSnapshot {
urgency: InspectionActUrgency;
days: number;
dayType: InspectionDeadlineDayType;
basis: InspectionDeadlineBasis;
capturedAt: string;
}
export interface InspectionDeadlineLockResult {
snapshot: InspectionDeadlineSnapshot;
baseOn: string | null;
dueOn: string | null;
}
@Injectable()
export class InspectionDeadlinesService {
constructor(
private readonly dataSource: DataSource,
private readonly audit: AuditService,
) {}
async listPolicies() {
const data = await this.dataSource.getRepository(InspectionDeadlinePolicy).find({
order: { urgency: 'ASC' },
});
return { data };
}
async updatePolicy(
urgency: InspectionActUrgency,
dto: UpdateInspectionDeadlinePolicyDto,
principal: AuthPrincipal,
request: RequestWithContext,
) {
return this.dataSource.transaction(async (manager) => {
const repository = manager.getRepository(InspectionDeadlinePolicy);
const policy = await repository.findOne({ where: { urgency } });
if (!policy) {
throw new NotFoundException({
code: 'INSPECTION_DEADLINE_POLICY_NOT_FOUND',
message: 'No se encontró la política de plazo solicitada',
});
}
const before = {
urgency: policy.urgency,
days: policy.days,
dayType: policy.dayType,
basis: policy.basis,
};
policy.days = dto.days;
policy.dayType = dto.dayType;
policy.updatedBy = principal.userId;
await repository.save(policy);
const after = {
urgency: policy.urgency,
days: policy.days,
dayType: policy.dayType,
basis: policy.basis,
};
await this.audit.record({
...administrationAuditContext(principal, request),
action: 'INSPECTION_DEADLINE_POLICY_UPDATED',
entityType: 'inspection_deadline_policy',
entityId: urgency,
beforeData: before,
afterData: after,
}, manager);
return policy;
});
}
async snapshotForLock(
manager: EntityManager,
urgency: InspectionActUrgency,
occurredAt: Date,
): Promise<InspectionDeadlineLockResult> {
const [policy] = (await manager.query(`
SELECT
urgency,
days,
day_type AS "dayType",
basis
FROM inspection_deadline_policies
WHERE urgency = $1
FOR SHARE
`, [urgency])) as Array<{
urgency: InspectionActUrgency;
days: number;
dayType: InspectionDeadlineDayType;
basis: InspectionDeadlineBasis;
}>;
if (!policy) {
throw new NotFoundException({
code: 'INSPECTION_DEADLINE_POLICY_NOT_FOUND',
message: 'No existe una política de plazo para la urgencia seleccionada',
});
}
const snapshot: InspectionDeadlineSnapshot = {
urgency: policy.urgency,
days: Number(policy.days),
dayType: policy.dayType,
basis: policy.basis,
capturedAt: new Date().toISOString(),
};
if (policy.basis === InspectionDeadlineBasis.GEDO_LOAD_DATE) {
return { snapshot, baseOn: null, dueOn: null };
}
const [base] = (await manager.query(`
SELECT TO_CHAR(
$1::timestamptz AT TIME ZONE 'America/Argentina/Mendoza',
'YYYY-MM-DD'
) AS day
`, [occurredAt])) as Array<{ day: string }>;
if (!base?.day) {
throw new BadRequestException({
code: 'INSPECTION_ACT_DATE_INVALID',
message: 'No se pudo determinar la fecha del Acta para calcular el plazo',
});
}
const dueOn = await this.calculateDueOn(
manager,
base.day,
snapshot.days,
snapshot.dayType,
);
return { snapshot, baseOn: base.day, dueOn };
}
async calculateDueOn(
manager: EntityManager,
baseOn: string,
days: number,
dayType: InspectionDeadlineDayType,
): Promise<string> {
if (!Number.isInteger(days) || days < 1 || days > 365) {
throw new BadRequestException({
code: 'INSPECTION_DEADLINE_DAYS_INVALID',
message: 'La cantidad de días del plazo no es válida',
});
}
if (dayType === InspectionDeadlineDayType.CALENDAR) {
const [row] = (await manager.query(`
SELECT ($1::date + $2::integer)::text AS "dueOn"
`, [baseOn, days])) as Array<{ dueOn: string }>;
return row.dueOn;
}
const [row] = (await manager.query(`
SELECT candidate.day::text AS "dueOn"
FROM (
SELECT generated::date AS day
FROM generate_series(
$1::date + 1,
$1::date + (($2::integer * 3) + 31),
interval '1 day'
) generated
WHERE EXTRACT(ISODOW FROM generated) BETWEEN 1 AND 5
AND NOT EXISTS (
SELECT 1
FROM inspection_non_working_days holiday
WHERE holiday.day = generated::date
AND holiday.enabled = true
)
ORDER BY generated
LIMIT 1 OFFSET ($2::integer - 1)
) candidate
`, [baseOn, days])) as Array<{ dueOn: string }>;
if (!row?.dueOn) {
throw new BadRequestException({
code: 'INSPECTION_DEADLINE_CALCULATION_FAILED',
message: 'No se pudo calcular el vencimiento con el calendario configurado',
});
}
return row.dueOn;
}
async applyGedoOfficialization(
manager: EntityManager,
actId: string,
officializedOn: string,
): Promise<string | null> {
const [act] = (await manager.query(`
SELECT
deadline_days AS "days",
deadline_day_type AS "dayType",
deadline_basis AS "basis"
FROM inspection_acts
WHERE id = $1
FOR UPDATE
`, [actId])) as Array<{
days: number | null;
dayType: InspectionDeadlineDayType | null;
basis: InspectionDeadlineBasis | null;
}>;
if (!act || act.basis !== InspectionDeadlineBasis.GEDO_LOAD_DATE) return null;
if (!act.days || !act.dayType) {
throw new BadRequestException({
code: 'INSPECTION_ACT_DEADLINE_SNAPSHOT_MISSING',
message: 'El Acta no conserva la política de plazo requerida',
});
}
const dueOn = await this.calculateDueOn(manager, officializedOn, Number(act.days), act.dayType);
await manager.query(`
UPDATE inspection_acts
SET deadline_base_on = $2::date,
deadline_due_on = $3::date,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1
`, [actId, officializedOn, dueOn]);
return dueOn;
}
async listNonWorkingDays(year?: string) {
const parsedYear = year === undefined ? null : Number(year);
if (parsedYear !== null && (!Number.isInteger(parsedYear) || parsedYear < 2000 || parsedYear > 2200)) {
throw new BadRequestException({
code: 'INVALID_NON_WORKING_DAY_YEAR',
message: 'El año del calendario no es válido',
});
}
const parameters: unknown[] = [];
const where = parsedYear === null
? ''
: `WHERE EXTRACT(YEAR FROM day)::integer = $1`;
if (parsedYear !== null) parameters.push(parsedYear);
const data = await this.dataSource.query(`
SELECT
day::text AS day,
label,
enabled,
created_at AS "createdAt",
updated_at AS "updatedAt"
FROM inspection_non_working_days
${where}
ORDER BY day ASC
`, parameters);
return { data };
}
async upsertNonWorkingDay(
dto: UpsertInspectionNonWorkingDayDto,
principal: AuthPrincipal,
request: RequestWithContext,
) {
const parsed = new Date(`${dto.day}T00:00:00Z`);
if (!Number.isFinite(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== dto.day) {
throw new BadRequestException({
code: 'INVALID_NON_WORKING_DAY',
message: 'La fecha no laborable no es válida',
});
}
return this.dataSource.transaction(async (manager) => {
const repository = manager.getRepository(InspectionNonWorkingDay);
const existing = await repository.findOne({ where: { day: dto.day } });
const before = existing
? { day: existing.day, label: existing.label, enabled: existing.enabled }
: null;
const row = existing ?? repository.create({
day: dto.day,
label: dto.label,
enabled: dto.enabled ?? true,
createdBy: principal.userId,
updatedBy: principal.userId,
});
row.label = dto.label;
row.enabled = dto.enabled ?? true;
row.updatedBy = principal.userId;
await repository.save(row);
const after = { day: row.day, label: row.label, enabled: row.enabled };
await this.audit.record({
...administrationAuditContext(principal, request),
action: existing
? 'INSPECTION_NON_WORKING_DAY_UPDATED'
: 'INSPECTION_NON_WORKING_DAY_CREATED',
entityType: 'inspection_non_working_day',
entityId: row.day,
beforeData: before,
afterData: after,
}, manager);
return row;
});
}
}
@@ -0,0 +1,38 @@
import { Transform, Type } from 'class-transformer';
import {
IsInt,
IsOptional,
IsString,
IsUUID,
Matches,
Max,
MaxLength,
Min,
MinLength,
} from 'class-validator';
const optionalText = ({ value }: { value: unknown }) =>
typeof value === 'string' && value.trim() ? value.trim() : null;
export class CreateRecurrentInspectionFindingDto {
@IsUUID('4')
antecedentFindingId!: string;
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString()
@MinLength(1)
@MaxLength(20000)
description!: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(10)
severity?: number;
@IsOptional()
@Transform(optionalText)
@Matches(/^\d{4}-\d{2}-\d{2}$/)
correctionDueOn?: string | null;
}
@@ -0,0 +1,31 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Post, Query, Req } from '@nestjs/common';
import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import { CreateRecurrentInspectionFindingDto } from './dto/create-recurrent-inspection-finding.dto';
import { InspectionFindingRecurrenceService } from './inspection-finding-recurrence.service';
@Controller('inspection-acts/:actId/findings')
export class InspectionFindingRecurrenceController {
constructor(private readonly recurrence: InspectionFindingRecurrenceService) {}
@Get('recurrence-candidates')
@RequirePermissions('inspection_findings.read')
candidates(
@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string,
@Query('assetId', new ParseUUIDPipe({ version: '4' })) assetId: string,
) {
return this.recurrence.candidates(actId, assetId);
}
@Post('recurrence')
@RequirePermissions('inspection_findings.create')
create(
@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string,
@Body() dto: CreateRecurrentInspectionFindingDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.recurrence.create(actId, dto, principal, request);
}
}
@@ -0,0 +1,301 @@
import {
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { DataSource } from 'typeorm';
import { administrationAuditContext } from '../administration/common/administration-audit';
import { AuditService } from '../audit/audit.service';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import { AuditAction } from '../database/entities';
import { assertMobileInspector } from '../inspection-operations/mobile-inspector-policy';
import type { CreateRecurrentInspectionFindingDto } from './dto/create-recurrent-inspection-finding.dto';
@Injectable()
export class InspectionFindingRecurrenceService {
constructor(
private readonly dataSource: DataSource,
private readonly audit: AuditService,
) {}
async candidates(actId: string, assetId: string) {
await this.requireDraftActAsset(actId, assetId);
const data = await this.dataSource.query(`
SELECT
finding.id,
finding.code,
finding.title,
finding.description,
finding.severity,
finding.catalog_item_id AS "catalogItemId",
finding.is_recurrence AS "isRecurrence",
finding.antecedent_finding_id AS "antecedentFindingId",
finding.created_at AS "createdAt",
act.id AS "actId",
act.code AS "actCode",
act.occurred_at AS "actOccurredAt",
visit.id AS "visitId",
visit.code AS "visitCode"
FROM inspection_findings finding
INNER JOIN inspection_acts act ON act.id = finding.act_id
INNER JOIN inspection_visits visit ON visit.id = act.visit_id
WHERE finding.asset_id = $1
AND finding.status = 'OPEN'
AND finding.act_id <> $2
ORDER BY act.occurred_at DESC, finding.created_at DESC, finding.id DESC
`, [assetId, actId]);
return { data };
}
async create(
actId: string,
dto: CreateRecurrentInspectionFindingDto,
principal: AuthPrincipal,
request: RequestWithContext,
) {
assertMobileInspector(principal);
return this.dataSource.transaction(async (manager) => {
const [act] = await manager.query(`
SELECT
act.id,
act.code,
act.status,
act.occurred_at AS "occurredAt",
act.visit_id AS "visitId",
visit.status AS "visitStatus"
FROM inspection_acts act
INNER JOIN inspection_visits visit ON visit.id = act.visit_id
WHERE act.id = $1
FOR UPDATE OF act, visit
`, [actId]) as Array<{
id: string;
code: string;
status: string;
occurredAt: Date;
visitId: string;
visitStatus: string;
}>;
if (!act) throw this.actNotFound();
if (act.status !== 'DRAFT' || act.visitStatus !== 'IN_PROGRESS') {
throw new ConflictException({
code: 'INSPECTION_FINDING_ACT_NOT_EDITABLE',
message: 'La reincidencia sólo puede registrarse mientras el Acta está en borrador y la inspección en curso',
});
}
if (!principal.permissions.includes('inspections.manage')) {
const [member] = await manager.query(`
SELECT 1 AS found
FROM inspection_visit_members
WHERE visit_id = $1
AND user_id = $2
AND included = true
LIMIT 1
`, [act.visitId, principal.userId]) as Array<{ found: number }>;
if (!member) {
throw new ForbiddenException({
code: 'INSPECTION_VISIT_NOT_ASSIGNED',
message: 'La inspección no está asignada al usuario actual',
});
}
}
const [antecedent] = await manager.query(`
SELECT
finding.id,
finding.act_id AS "actId",
finding.asset_id AS "assetId",
finding.catalog_item_id AS "catalogItemId",
finding.title,
finding.legal_basis AS "legalBasis",
finding.glossary,
finding.catalog_revision AS "catalogRevision",
finding.suggested_severity AS "suggestedSeverity",
finding.severity,
finding.status,
previous_act.code AS "actCode",
previous_act.occurred_at AS "occurredAt"
FROM inspection_findings finding
INNER JOIN inspection_acts previous_act ON previous_act.id = finding.act_id
WHERE finding.id = $1
FOR SHARE OF finding, previous_act
`, [dto.antecedentFindingId]) as Array<{
id: string;
actId: string;
assetId: string;
catalogItemId: string | null;
title: string;
legalBasis: string | null;
glossary: string | null;
catalogRevision: number | null;
suggestedSeverity: number | null;
severity: number | null;
status: string;
actCode: string;
occurredAt: Date;
}>;
if (!antecedent) {
throw new NotFoundException({
code: 'INSPECTION_FINDING_ANTECEDENT_NOT_FOUND',
message: 'No se encontró el Hallazgo antecedente seleccionado',
});
}
if (antecedent.status !== 'OPEN') {
throw new ConflictException({
code: 'INSPECTION_FINDING_ANTECEDENT_RESOLVED',
message: 'Sólo un Hallazgo anterior todavía abierto puede originar una reincidencia',
});
}
if (antecedent.actId === actId) {
throw new ConflictException({
code: 'INSPECTION_FINDING_RECURRENCE_SAME_ACT',
message: 'Una reincidencia debe referenciar un Hallazgo de un Acta anterior',
});
}
if (new Date(antecedent.occurredAt).getTime() > new Date(act.occurredAt).getTime()) {
throw new ConflictException({
code: 'INSPECTION_FINDING_ANTECEDENT_AFTER_ACT',
message: 'El antecedente no puede pertenecer a un Acta posterior a la actual',
});
}
const [assetLink] = await manager.query(`
SELECT 1 AS found
FROM inspection_act_assets
WHERE act_id = $1 AND asset_id = $2 AND included = true
LIMIT 1
`, [actId, antecedent.assetId]) as Array<{ found: number }>;
if (!assetLink) {
throw new ConflictException({
code: 'INSPECTION_FINDING_RECURRENCE_ASSET_NOT_IN_ACT',
message: 'El Inventario del Hallazgo antecedente no forma parte del Acta actual',
});
}
const [sequence] = await manager.query(`
SELECT COALESCE(MAX(finding_number), 0)::integer + 1 AS number
FROM inspection_findings
WHERE act_id = $1
`, [actId]) as Array<{ number: number }>;
const findingNumber = Number(sequence?.number ?? 1);
if (findingNumber > 999) {
throw new ConflictException({
code: 'INSPECTION_FINDING_SEQUENCE_EXHAUSTED',
message: 'El Acta alcanzó el máximo de 999 Hallazgos',
});
}
const code = `${act.code}-H${String(findingNumber).padStart(3, '0')}`;
const [created] = await manager.query(`
INSERT INTO inspection_findings (
act_id,
asset_id,
catalog_item_id,
finding_number,
code,
status,
title,
description,
legal_basis,
glossary,
catalog_revision,
suggested_severity,
severity,
is_recurrence,
antecedent_finding_id,
correction_due_on,
current_version,
created_by,
updated_by
) VALUES (
$1,$2,$3,$4,$5,'OPEN',$6,$7,$8,$9,$10,$11,$12,true,$13,$14,1,$15,$15
)
RETURNING id, created_at AS "createdAt", updated_at AS "updatedAt"
`, [
actId,
antecedent.assetId,
antecedent.catalogItemId,
findingNumber,
code,
antecedent.title,
dto.description,
antecedent.legalBasis,
antecedent.glossary,
antecedent.catalogRevision,
antecedent.suggestedSeverity,
dto.severity ?? antecedent.severity ?? antecedent.suggestedSeverity,
antecedent.id,
dto.correctionDueOn ?? null,
principal.userId,
]) as Array<{ id: string; createdAt: Date; updatedAt: Date }>;
const snapshot = {
id: created.id,
actId,
assetId: antecedent.assetId,
catalogItemId: antecedent.catalogItemId,
findingNumber,
code,
status: 'OPEN',
title: antecedent.title,
description: dto.description,
legalBasis: antecedent.legalBasis,
glossary: antecedent.glossary,
catalogRevision: antecedent.catalogRevision,
suggestedSeverity: antecedent.suggestedSeverity,
severity: dto.severity ?? antecedent.severity ?? antecedent.suggestedSeverity,
isRecurrence: true,
antecedentFindingId: antecedent.id,
antecedentCode: `${antecedent.actCode}`,
correctionDueOn: dto.correctionDueOn ?? null,
currentVersion: 1,
createdAt: created.createdAt,
updatedAt: created.updatedAt,
};
await manager.query(`
INSERT INTO inspection_finding_versions (
finding_id, version_number, event, snapshot, actor_user_id, actor_username
) VALUES ($1, 1, 'CREATED', $2::jsonb, $3, $4)
`, [created.id, snapshot, principal.userId, principal.username]);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.INSPECTION_FINDING_CREATED,
entityType: 'inspection_finding',
entityId: created.id,
afterData: snapshot,
metadata: {
actId,
visitId: act.visitId,
recurrence: true,
antecedentFindingId: antecedent.id,
},
}, manager);
return snapshot;
});
}
private async requireDraftActAsset(actId: string, assetId: string): Promise<void> {
const [row] = await this.dataSource.query(`
SELECT 1 AS found
FROM inspection_acts act
INNER JOIN inspection_visits visit ON visit.id = act.visit_id
INNER JOIN inspection_act_assets link
ON link.act_id = act.id AND link.asset_id = $2 AND link.included = true
WHERE act.id = $1
AND act.status = 'DRAFT'
AND visit.status = 'IN_PROGRESS'
LIMIT 1
`, [actId, assetId]) as Array<{ found: number }>;
if (!row) {
throw new ConflictException({
code: 'INSPECTION_FINDING_RECURRENCE_CONTEXT_INVALID',
message: 'El Inventario debe estar incluido en un Acta en borrador de una inspección en curso',
});
}
}
private actNotFound(): NotFoundException {
return new NotFoundException({
code: 'INSPECTION_ACT_NOT_FOUND',
message: 'Acta de inspección no encontrada',
});
}
}
@@ -15,7 +15,6 @@ import type { AuthPrincipal, RequestWithContext } from '../common/http/request-c
import { CloseInspectionFindingDto } from './dto/close-inspection-finding.dto';
import { CreateInspectionFindingDto } from './dto/create-inspection-finding.dto';
import { ListInspectionFindingsQueryDto } from './dto/list-inspection-findings-query.dto';
import { UpdateInspectionFindingFollowUpDto } from './dto/update-inspection-finding-follow-up.dto';
import { UpdateInspectionFindingDto } from './dto/update-inspection-finding.dto';
import { InspectionFindingsService } from './inspection-findings.service';
@@ -84,15 +83,4 @@ export class InspectionFindingsController {
) {
return this.findings.close(id, dto, principal, request);
}
@Patch(':id/follow-up')
@RequirePermissions('inspection_findings.follow_up')
updateFollowUp(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Body() dto: UpdateInspectionFindingFollowUpDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.findings.updateFollowUp(id, dto, principal, request);
}
}
@@ -4,6 +4,8 @@ import { FindingCatalogController } from './finding-catalog.controller';
import { FindingCatalogMergeService } from './finding-catalog-merge.service';
import { FindingCatalogService } from './finding-catalog.service';
import { F3FindingCatalogResolverService } from './f3-finding-catalog-resolver.service';
import { InspectionFindingRecurrenceController } from './inspection-finding-recurrence.controller';
import { InspectionFindingRecurrenceService } from './inspection-finding-recurrence.service';
import {
InspectionActFindingsController,
InspectionFindingsController,
@@ -21,6 +23,7 @@ import { InspectionEvidenceService } from './inspection-evidence.service';
controllers: [
FindingCatalogController,
InspectionActFindingsController,
InspectionFindingRecurrenceController,
InspectionFindingsController,
InspectionFindingEvidenceController,
InspectionFindingCommunicationsController,
@@ -31,6 +34,7 @@ import { InspectionEvidenceService } from './inspection-evidence.service';
F3FindingCatalogResolverService,
FindingCatalogMergeService,
InspectionFindingsService,
InspectionFindingRecurrenceService,
InspectionEvidenceService,
],
exports: [
@@ -38,6 +42,7 @@ import { InspectionEvidenceService } from './inspection-evidence.service';
F3FindingCatalogResolverService,
FindingCatalogMergeService,
InspectionFindingsService,
InspectionFindingRecurrenceService,
],
})
export class InspectionFindingsModule {}
@@ -0,0 +1,23 @@
import { Transform } from 'class-transformer';
import { IsEnum, IsOptional, IsString, Matches, MaxLength } from 'class-validator';
import { InspectionReportFollowUpType } from '../../database/entities';
export class CreateInspectionReportFollowUpDto {
@IsEnum(InspectionReportFollowUpType)
eventType!: InspectionReportFollowUpType;
@IsOptional()
@Transform(({ value }) => (typeof value === 'string' && value.trim() ? value.trim() : null))
@IsString()
@MaxLength(255)
referenceNumber?: string | null;
@Matches(/^\d{4}-\d{2}-\d{2}$/)
occurredOn!: string;
@IsOptional()
@Transform(({ value }) => (typeof value === 'string' && value.trim() ? value.trim() : null))
@IsString()
@MaxLength(20000)
description?: string | null;
}
@@ -0,0 +1,13 @@
import { Transform } from 'class-transformer';
import { IsString, Matches, MaxLength, MinLength } from 'class-validator';
export class OfficializeInspectionReportGedoDto {
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString()
@MinLength(3)
@MaxLength(255)
ifIdentifier!: string;
@Matches(/^\d{4}-\d{2}-\d{2}$/)
officializedOn!: string;
}
@@ -1,2 +1,12 @@
import { Transform } from 'class-transformer'; import { IsEmail,IsOptional,MaxLength } from 'class-validator';
export class UpdateDocumentDeliverySettingsDto { @IsOptional() @Transform(({value})=>typeof value==='string'&&value.trim()?value.trim().toLowerCase():null) @IsEmail() @MaxLength(320) officeEmail?:string|null; @IsOptional() @Transform(({value})=>typeof value==='string'&&value.trim()?value.trim().toLowerCase():null) @IsEmail() @MaxLength(320) directorEmail?:string|null; }
import { Transform } from 'class-transformer';
import { IsEmail, IsOptional, MaxLength } from 'class-validator';
export class UpdateDocumentDeliverySettingsDto {
@IsOptional()
@Transform(({ value }) =>
typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : null,
)
@IsEmail()
@MaxLength(320)
officeEmail?: string | null;
}
@@ -0,0 +1,58 @@
import { Transform } from 'class-transformer';
import { IsOptional, IsString, MaxLength } from 'class-validator';
function optionalText(value: unknown): unknown {
if (typeof value !== 'string') return value;
const trimmed = value.trim();
return trimmed.length ? trimmed : null;
}
export class UpdateInspectionReportContentDto {
@IsOptional()
@Transform(({ value }) => optionalText(value))
@IsString()
@MaxLength(10000)
referenceText?: string | null;
@IsOptional()
@Transform(({ value }) => optionalText(value))
@IsString()
@MaxLength(20000)
generalObjective?: string | null;
@IsOptional()
@Transform(({ value }) => optionalText(value))
@IsString()
@MaxLength(20000)
specificObjective?: string | null;
@IsOptional()
@Transform(({ value }) => optionalText(value))
@IsString()
@MaxLength(40000)
background?: string | null;
@IsOptional()
@Transform(({ value }) => optionalText(value))
@IsString()
@MaxLength(40000)
legalFramework?: string | null;
@IsOptional()
@Transform(({ value }) => optionalText(value))
@IsString()
@MaxLength(40000)
executiveSummary?: string | null;
@IsOptional()
@Transform(({ value }) => optionalText(value))
@IsString()
@MaxLength(80000)
description?: string | null;
@IsOptional()
@Transform(({ value }) => optionalText(value))
@IsString()
@MaxLength(40000)
conclusion?: string | null;
}
@@ -0,0 +1,79 @@
import { Transform } from 'class-transformer';
import {
IsBoolean,
IsEmail,
IsEnum,
IsInt,
IsOptional,
IsString,
Max,
MaxLength,
Min,
MinLength,
} from 'class-validator';
export enum SmtpSecurityModeDto {
TLS = 'TLS',
STARTTLS = 'STARTTLS',
}
export class UpdateSmtpSettingsDto {
@Transform(({ value }) => value === true || value === 'true')
@IsBoolean()
enabled!: boolean;
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString()
@MinLength(1)
@MaxLength(255)
host!: string;
@Transform(({ value }) => Number(value))
@IsInt()
@Min(1)
@Max(65535)
port!: number;
@IsEnum(SmtpSecurityModeDto)
securityMode!: SmtpSecurityModeDto;
@IsOptional()
@Transform(({ value }) => (typeof value === 'string' && value.trim() ? value.trim() : null))
@IsString()
@MaxLength(255)
username?: string | null;
@IsOptional()
@IsString()
@MaxLength(2048)
password?: string;
@IsOptional()
@Transform(({ value }) => value === true || value === 'true')
@IsBoolean()
clearPassword?: boolean;
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString()
@MinLength(1)
@MaxLength(160)
fromName!: string;
@Transform(({ value }) => (typeof value === 'string' ? value.trim().toLowerCase() : value))
@IsEmail()
@MaxLength(255)
fromEmail!: string;
@IsOptional()
@Transform(({ value }) => (typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : null))
@IsEmail()
@MaxLength(255)
replyTo?: string | null;
}
export class TestSmtpSettingsDto {
@Transform(({ value }) => (typeof value === 'string' ? value.trim().toLowerCase() : value))
@IsEmail()
@MaxLength(255)
to!: string;
}
@@ -1,5 +1,4 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { DataSource } from 'typeorm';
import { administrationAuditContext } from '../administration/common/administration-audit';
import { AuditService } from '../audit/audit.service';
@@ -10,7 +9,7 @@ import { InspectionActPdfService } from './inspection-act-pdf.service';
import { InspectionReportWordService } from './inspection-report-word.service';
import { SmtpDeliveryService } from './smtp-delivery.service';
export type DeliveryRecipientKind = 'COMPANY' | 'OFFICE' | 'DIRECTOR' | 'INSPECTOR';
export type DeliveryRecipientKind = 'COMPANY' | 'OFFICE' | 'INSPECTOR' | 'DIRECTOR';
export interface DeliveryRow {
id: string;
@@ -35,26 +34,18 @@ export class InspectionDocumentDeliveryService {
private readonly word: InspectionReportWordService,
private readonly smtp: SmtpDeliveryService,
private readonly audit: AuditService,
private readonly config: ConfigService,
) {}
async settings() {
const [row] = await this.dataSource.query(`
SELECT
office_email AS "officeEmail",
director_email AS "directorEmail",
updated_at AS "updatedAt"
SELECT office_email AS "officeEmail", updated_at AS "updatedAt"
FROM institutional_delivery_settings
WHERE id=1
`) as Array<{
officeEmail: string | null;
directorEmail: string | null;
updatedAt: Date;
}>;
WHERE id = 1
`) as Array<{ officeEmail: string | null; updatedAt: Date }>;
return {
...row,
smtpConfigured: this.smtp.configured(),
mailFrom: this.config.get<string>('MAIL_FROM') ?? null,
smtpSource: this.smtp.activeSource(),
};
}
@@ -66,9 +57,11 @@ export class InspectionDocumentDeliveryService {
const before = await this.settings();
await this.dataSource.query(`
UPDATE institutional_delivery_settings
SET office_email=$1,director_email=$2,updated_by=$3,updated_at=CURRENT_TIMESTAMP
WHERE id=1
`, [dto.officeEmail ?? null, dto.directorEmail ?? null, principal.userId]);
SET office_email = $1,
updated_by = $2,
updated_at = CURRENT_TIMESTAMP
WHERE id = 1
`, [dto.officeEmail ?? null, principal.userId]);
const after = await this.settings();
await this.audit.record({
...administrationAuditContext(principal, request),
@@ -84,34 +77,34 @@ export class InspectionDocumentDeliveryService {
async list() {
const data = await this.dataSource.query(`
SELECT
d.id,
d.act_id AS "actId",
d.report_id AS "reportId",
d.document_kind AS "documentKind",
d.recipient_kind AS "recipientKind",
d.recipient_asset_id AS "recipientAssetId",
d.recipient_user_id AS "recipientUserId",
d.recipient_email AS "recipientEmail",
d.status,
d.attempts,
d.last_attempt_at AS "lastAttemptAt",
d.sent_at AS "sentAt",
d.provider_message_id AS "providerMessageId",
d.last_error AS "lastError",
d.created_at AS "createdAt",
a.code AS "actCode",
r.code AS "reportCode",
delivery.id,
delivery.act_id AS "actId",
delivery.report_id AS "reportId",
delivery.document_kind AS "documentKind",
delivery.recipient_kind AS "recipientKind",
delivery.recipient_asset_id AS "recipientAssetId",
delivery.recipient_user_id AS "recipientUserId",
delivery.recipient_email AS "recipientEmail",
delivery.status,
delivery.attempts,
delivery.last_attempt_at AS "lastAttemptAt",
delivery.sent_at AS "sentAt",
delivery.provider_message_id AS "providerMessageId",
delivery.last_error AS "lastError",
delivery.created_at AS "createdAt",
act.code AS "actCode",
report.code AS "reportCode",
recipient.name AS "recipientAssetName",
CASE
WHEN recipient_user.id IS NULL THEN NULL
ELSE btrim(concat_ws(' ', recipient_user.first_name, recipient_user.last_name))
END AS "recipientUserName"
FROM inspection_document_deliveries d
JOIN inspection_acts a ON a.id=d.act_id
LEFT JOIN inspection_reports r ON r.id=d.report_id
LEFT JOIN assets recipient ON recipient.id=d.recipient_asset_id
LEFT JOIN users recipient_user ON recipient_user.id=d.recipient_user_id
ORDER BY d.created_at DESC
FROM inspection_document_deliveries delivery
JOIN inspection_acts act ON act.id = delivery.act_id
LEFT JOIN inspection_reports report ON report.id = delivery.report_id
LEFT JOIN assets recipient ON recipient.id = delivery.recipient_asset_id
LEFT JOIN users recipient_user ON recipient_user.id = delivery.recipient_user_id
ORDER BY delivery.created_at DESC
LIMIT 200
`);
return { data };
@@ -120,13 +113,14 @@ export class InspectionDocumentDeliveryService {
async dispatchForAct(actId: string): Promise<void> {
await this.pdf.ensure(actId).catch(() => undefined);
const [report] = await this.dataSource.query(
`SELECT id FROM inspection_reports WHERE act_id=$1`,
`SELECT id FROM inspection_reports WHERE act_id = $1`,
[actId],
) as Array<{ id: string }>;
if (report) await this.word.ensure(report.id);
await this.ensureRows(actId, report?.id ?? null);
const rows = await this.rowsForAct(actId);
for (const row of rows) await this.attempt(row).catch(() => undefined);
for (const row of await this.rowsForAct(actId)) {
await this.attempt(row).catch(() => undefined);
}
}
async retry(
@@ -156,42 +150,42 @@ export class InspectionDocumentDeliveryService {
const rows = await this.dataSource.query(`
SELECT id
FROM inspection_document_deliveries
WHERE status<>'SENT'
WHERE status <> 'SENT'
ORDER BY created_at ASC
LIMIT 100
`) as Array<{ id: string }>;
for (const item of rows) await this.retry(item.id, principal, request).catch(() => undefined);
for (const item of rows) {
await this.retry(item.id, principal, request).catch(() => undefined);
}
return { processed: rows.length };
}
private async ensureRows(actId: string, reportId: string | null) {
const [settings] = await this.dataSource.query(`
SELECT office_email AS "officeEmail",director_email AS "directorEmail"
SELECT office_email AS "officeEmail"
FROM institutional_delivery_settings
WHERE id=1
`) as Array<{ officeEmail: string | null; directorEmail: string | null }>;
WHERE id = 1
`) as Array<{ officeEmail: string | null }>;
const companies = await this.dataSource.query(`
SELECT DISTINCT company.id,profile.notification_email AS email
SELECT DISTINCT company.id, profile.notification_email AS email
FROM inspection_act_assets link
JOIN assets asset ON asset.id=link.asset_id
JOIN asset_types asset_type ON asset_type.id=asset.asset_type_id
JOIN assets company ON company.id=COALESCE(
JOIN assets asset ON asset.id = link.asset_id
JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id
JOIN assets company ON company.id = COALESCE(
asset.operator_company_id,
CASE WHEN asset_type.operational_role='COMPANY' THEN asset.id END
CASE WHEN asset_type.operational_role = 'COMPANY' THEN asset.id END
)
LEFT JOIN organization_profiles profile ON profile.asset_id=company.id
WHERE link.act_id=$1 AND link.included=true
LEFT JOIN organization_profiles profile ON profile.asset_id = company.id
WHERE link.act_id = $1 AND link.included = true
`, [actId]) as Array<{ id: string; email: string | null }>;
const [inspector] = await this.dataSource.query(`
SELECT
inspector.id,
inspector.email
SELECT inspector.id, inspector.email
FROM inspection_acts act
JOIN inspection_visits visit ON visit.id=act.visit_id
JOIN users inspector ON inspector.id=visit.lead_inspector_user_id
WHERE act.id=$1
JOIN inspection_visits visit ON visit.id = act.visit_id
JOIN users inspector ON inspector.id = visit.lead_inspector_user_id
WHERE act.id = $1
`, [actId]) as Array<{ id: string; email: string | null }>;
for (const company of companies) {
@@ -229,19 +223,18 @@ export class InspectionDocumentDeliveryService {
recipientKey: inspector.id,
recipientEmail: inspector.email,
});
}
if (reportId) {
await this.upsertRow({
actId,
reportId,
documentKind: 'REPORT_WORD',
recipientKind: 'DIRECTOR',
recipientAssetId: null,
recipientUserId: null,
recipientKey: '00000000-0000-0000-0000-000000000000',
recipientEmail: settings?.directorEmail ?? null,
});
if (reportId) {
await this.upsertRow({
actId,
reportId,
documentKind: 'REPORT_WORD',
recipientKind: 'INSPECTOR',
recipientAssetId: null,
recipientUserId: inspector.id,
recipientKey: inspector.id,
recipientEmail: inspector.email,
});
}
}
}
@@ -249,7 +242,7 @@ export class InspectionDocumentDeliveryService {
actId: string;
reportId: string | null;
documentKind: 'ACT_PDF' | 'REPORT_WORD';
recipientKind: DeliveryRecipientKind;
recipientKind: Exclude<DeliveryRecipientKind, 'DIRECTOR'>;
recipientAssetId: string | null;
recipientUserId: string | null;
recipientKey: string;
@@ -258,23 +251,23 @@ export class InspectionDocumentDeliveryService {
const initialStatus = input.recipientEmail ? 'PENDING' : 'WAITING_RECIPIENT';
await this.dataSource.query(`
INSERT INTO inspection_document_deliveries (
act_id,report_id,document_kind,recipient_kind,
recipient_asset_id,recipient_user_id,recipient_key,recipient_email,status
act_id, report_id, document_kind, recipient_kind,
recipient_asset_id, recipient_user_id, recipient_key, recipient_email, status
) VALUES ($1,$2,$3,$4,$5,$6,$7::uuid,$8,$9)
ON CONFLICT (act_id,document_kind,recipient_kind,recipient_key) DO UPDATE SET
report_id=COALESCE(EXCLUDED.report_id,inspection_document_deliveries.report_id),
recipient_asset_id=COALESCE(EXCLUDED.recipient_asset_id,inspection_document_deliveries.recipient_asset_id),
recipient_user_id=COALESCE(EXCLUDED.recipient_user_id,inspection_document_deliveries.recipient_user_id),
recipient_email=CASE
WHEN inspection_document_deliveries.status='SENT' THEN inspection_document_deliveries.recipient_email
ON CONFLICT (act_id, document_kind, recipient_kind, recipient_key) DO UPDATE SET
report_id = COALESCE(EXCLUDED.report_id, inspection_document_deliveries.report_id),
recipient_asset_id = COALESCE(EXCLUDED.recipient_asset_id, inspection_document_deliveries.recipient_asset_id),
recipient_user_id = COALESCE(EXCLUDED.recipient_user_id, inspection_document_deliveries.recipient_user_id),
recipient_email = CASE
WHEN inspection_document_deliveries.status = 'SENT' THEN inspection_document_deliveries.recipient_email
ELSE EXCLUDED.recipient_email
END,
status=CASE
WHEN inspection_document_deliveries.status='SENT' THEN 'SENT'
status = CASE
WHEN inspection_document_deliveries.status = 'SENT' THEN 'SENT'
WHEN EXCLUDED.recipient_email IS NULL THEN 'WAITING_RECIPIENT'
ELSE inspection_document_deliveries.status
END,
updated_at=CURRENT_TIMESTAMP
updated_at = CURRENT_TIMESTAMP
`, [
input.actId,
input.reportId,
@@ -291,31 +284,45 @@ export class InspectionDocumentDeliveryService {
private async rowsForAct(actId: string): Promise<DeliveryRow[]> {
return this.dataSource.query(`
SELECT
d.id,d.act_id AS "actId",d.report_id AS "reportId",
d.document_kind AS "documentKind",d.recipient_kind AS "recipientKind",
d.recipient_asset_id AS "recipientAssetId",d.recipient_user_id AS "recipientUserId",
d.recipient_email AS "recipientEmail",d.status,d.attempts,
a.code AS "actCode",r.code AS "reportCode"
FROM inspection_document_deliveries d
JOIN inspection_acts a ON a.id=d.act_id
LEFT JOIN inspection_reports r ON r.id=d.report_id
WHERE d.act_id=$1
ORDER BY d.created_at
delivery.id,
delivery.act_id AS "actId",
delivery.report_id AS "reportId",
delivery.document_kind AS "documentKind",
delivery.recipient_kind AS "recipientKind",
delivery.recipient_asset_id AS "recipientAssetId",
delivery.recipient_user_id AS "recipientUserId",
delivery.recipient_email AS "recipientEmail",
delivery.status,
delivery.attempts,
act.code AS "actCode",
report.code AS "reportCode"
FROM inspection_document_deliveries delivery
JOIN inspection_acts act ON act.id = delivery.act_id
LEFT JOIN inspection_reports report ON report.id = delivery.report_id
WHERE delivery.act_id = $1
ORDER BY delivery.created_at
`, [actId]) as Promise<DeliveryRow[]>;
}
private async load(id: string): Promise<DeliveryRow> {
const [row] = await this.dataSource.query(`
SELECT
d.id,d.act_id AS "actId",d.report_id AS "reportId",
d.document_kind AS "documentKind",d.recipient_kind AS "recipientKind",
d.recipient_asset_id AS "recipientAssetId",d.recipient_user_id AS "recipientUserId",
d.recipient_email AS "recipientEmail",d.status,d.attempts,
a.code AS "actCode",r.code AS "reportCode"
FROM inspection_document_deliveries d
JOIN inspection_acts a ON a.id=d.act_id
LEFT JOIN inspection_reports r ON r.id=d.report_id
WHERE d.id=$1
delivery.id,
delivery.act_id AS "actId",
delivery.report_id AS "reportId",
delivery.document_kind AS "documentKind",
delivery.recipient_kind AS "recipientKind",
delivery.recipient_asset_id AS "recipientAssetId",
delivery.recipient_user_id AS "recipientUserId",
delivery.recipient_email AS "recipientEmail",
delivery.status,
delivery.attempts,
act.code AS "actCode",
report.code AS "reportCode"
FROM inspection_document_deliveries delivery
JOIN inspection_acts act ON act.id = delivery.act_id
LEFT JOIN inspection_reports report ON report.id = delivery.report_id
WHERE delivery.id = $1
`, [id]) as DeliveryRow[];
if (!row) {
throw new NotFoundException({
@@ -332,34 +339,41 @@ export class InspectionDocumentDeliveryService {
const [company] = await this.dataSource.query(`
SELECT notification_email AS email
FROM organization_profiles
WHERE asset_id=$1
WHERE asset_id = $1
`, [row.recipientAssetId]) as Array<{ email: string | null }>;
email = company?.email ?? null;
} else if (row.recipientKind === 'INSPECTOR' && row.recipientUserId) {
const [inspector] = await this.dataSource.query(`
SELECT email
FROM users
WHERE id=$1 AND is_active=true
WHERE id = $1 AND is_active = true
`, [row.recipientUserId]) as Array<{ email: string | null }>;
email = inspector?.email ?? null;
} else {
} else if (row.recipientKind === 'OFFICE') {
const [settings] = await this.dataSource.query(`
SELECT office_email AS "officeEmail",director_email AS "directorEmail"
SELECT office_email AS "officeEmail"
FROM institutional_delivery_settings
WHERE id=1
`) as Array<{ officeEmail: string | null; directorEmail: string | null }>;
email = row.recipientKind === 'OFFICE'
? settings?.officeEmail ?? null
: settings?.directorEmail ?? null;
WHERE id = 1
`) as Array<{ officeEmail: string | null }>;
email = settings?.officeEmail ?? null;
} else if (row.recipientKind === 'DIRECTOR' && row.documentKind === 'REPORT_WORD') {
const [inspector] = await this.dataSource.query(`
SELECT user_account.email
FROM inspection_acts act
JOIN inspection_visits visit ON visit.id = act.visit_id
JOIN users user_account ON user_account.id = visit.lead_inspector_user_id
WHERE act.id = $1
`, [row.actId]) as Array<{ email: string | null }>;
email = inspector?.email ?? null;
}
await this.dataSource.query(`
UPDATE inspection_document_deliveries
SET recipient_email=$2,
status=CASE WHEN $2::text IS NULL THEN 'WAITING_RECIPIENT' ELSE 'PENDING' END,
last_error=NULL,
updated_at=CURRENT_TIMESTAMP
WHERE id=$1 AND status<>'SENT'
SET recipient_email = $2,
status = CASE WHEN $2::text IS NULL THEN 'WAITING_RECIPIENT' ELSE 'PENDING' END,
last_error = NULL,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1 AND status <> 'SENT'
`, [row.id, email]);
}
@@ -406,9 +420,12 @@ export class InspectionDocumentDeliveryService {
await this.dataSource.query(`
UPDATE inspection_document_deliveries
SET attempts=attempts+1,last_attempt_at=CURRENT_TIMESTAMP,status='PENDING',
last_error=NULL,updated_at=CURRENT_TIMESTAMP
WHERE id=$1
SET attempts = attempts + 1,
last_attempt_at = CURRENT_TIMESTAMP,
status = 'PENDING',
last_error = NULL,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1
`, [row.id]);
try {
@@ -416,10 +433,10 @@ export class InspectionDocumentDeliveryService {
? `Acta ${row.actCode}`
: `Informe ${row.reportCode ?? ''}`;
const text = row.documentKind === 'REPORT_WORD'
? `Se adjunta el informe Word automático ${row.reportCode ?? ''} para revisión del Director de Hidrocarburos.`
? `Se adjunta el Informe Word automático ${row.reportCode ?? ''} para revisión y edición del inspector responsable antes de su carga en GEDO.`
: row.recipientKind === 'INSPECTOR'
? `Se adjunta copia del acta cerrada e inmutable ${row.actCode} correspondiente a tu inspección.`
: `Se adjunta el acta cerrada e inmutable ${row.actCode}.`;
? `Se adjunta copia del Acta ${row.actCode} correspondiente a tu inspección.`
: `Se adjunta el Acta ${row.actCode}.`;
const sent = await this.smtp.send({
to: row.recipientEmail,
subject: `DH Inspección · ${label}`,
@@ -428,9 +445,12 @@ export class InspectionDocumentDeliveryService {
});
await this.dataSource.query(`
UPDATE inspection_document_deliveries
SET status='SENT',sent_at=CURRENT_TIMESTAMP,provider_message_id=$2,
last_error=NULL,updated_at=CURRENT_TIMESTAMP
WHERE id=$1
SET status = 'SENT',
sent_at = CURRENT_TIMESTAMP,
provider_message_id = $2,
last_error = NULL,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1
`, [row.id, sent.messageId]);
await this.audit.record({
action: AuditAction.DOCUMENT_DELIVERY_SENT,
@@ -463,8 +483,10 @@ export class InspectionDocumentDeliveryService {
private async setStatus(id: string, status: string, error: string) {
await this.dataSource.query(`
UPDATE inspection_document_deliveries
SET status=$2,last_error=$3,updated_at=CURRENT_TIMESTAMP
WHERE id=$1
SET status = $2,
last_error = $3,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1
`, [id, status, error.slice(0, 500)]);
}
}
@@ -0,0 +1,50 @@
import { BadRequestException } from '@nestjs/common';
export const MAX_REPORT_DOSSIER_PDF_BYTES = 25 * 1024 * 1024;
export interface UploadedReportDossierFile {
buffer: Buffer;
originalname: string;
mimetype?: string;
size: number;
}
export interface InspectedReportDossierPdf {
originalName: string;
mimeType: 'application/pdf';
extension: '.pdf';
}
export function inspectReportDossierPdf(
file: UploadedReportDossierFile | undefined,
): InspectedReportDossierPdf {
if (!file?.buffer?.length || file.size <= 0) {
throw new BadRequestException({
code: 'INSPECTION_REPORT_PDF_REQUIRED',
message: 'Debés adjuntar un archivo PDF no vacío',
});
}
if (file.size > MAX_REPORT_DOSSIER_PDF_BYTES || file.buffer.length > MAX_REPORT_DOSSIER_PDF_BYTES) {
throw new BadRequestException({
code: 'INSPECTION_REPORT_PDF_TOO_LARGE',
message: 'El PDF supera el máximo permitido de 25 MB',
});
}
if (file.buffer.length < 5 || file.buffer.subarray(0, 5).toString('ascii') !== '%PDF-') {
throw new BadRequestException({
code: 'INSPECTION_REPORT_PDF_INVALID',
message: 'El archivo adjunto no tiene una estructura PDF válida',
});
}
const originalName = file.originalname
.replace(/[\u0000-\u001f\u007f]/g, '')
.trim()
.slice(0, 255);
if (!originalName) {
throw new BadRequestException({
code: 'INSPECTION_REPORT_PDF_INVALID_NAME',
message: 'El nombre original del PDF no es válido',
});
}
return { originalName, mimeType: 'application/pdf', extension: '.pdf' };
}
@@ -0,0 +1,128 @@
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Patch,
Post,
Req,
Res,
UploadedFile,
UploadedFiles,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor, FilesInterceptor } from '@nestjs/platform-express';
import type { Response } from 'express';
import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import { CreateInspectionReportFollowUpDto } from './dto/create-inspection-report-follow-up.dto';
import { OfficializeInspectionReportGedoDto } from './dto/officialize-inspection-report-gedo.dto';
import { UpdateInspectionReportContentDto } from './dto/update-inspection-report-content.dto';
import {
MAX_REPORT_DOSSIER_PDF_BYTES,
type UploadedReportDossierFile,
} from './inspection-report-dossier-file';
import { InspectionReportDossierService } from './inspection-report-dossier.service';
@Controller('inspection-reports/:reportId/dossier')
export class InspectionReportDossierController {
constructor(private readonly dossier: InspectionReportDossierService) {}
@Get()
@RequirePermissions('inspection_reports.read')
get(@Param('reportId', new ParseUUIDPipe({ version: '4' })) reportId: string) {
return this.dossier.get(reportId);
}
@Patch('content')
@RequirePermissions('inspection_reports.edit')
updateContent(
@Param('reportId', new ParseUUIDPipe({ version: '4' })) reportId: string,
@Body() dto: UpdateInspectionReportContentDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.dossier.updateContent(reportId, dto, principal, request);
}
@Post('gedo')
@RequirePermissions('inspection_reports.officialize')
@UseInterceptors(FileInterceptor('file', {
limits: { fileSize: MAX_REPORT_DOSSIER_PDF_BYTES, files: 1 },
}))
officializeGedo(
@Param('reportId', new ParseUUIDPipe({ version: '4' })) reportId: string,
@Body() dto: OfficializeInspectionReportGedoDto,
@UploadedFile() file: UploadedReportDossierFile | undefined,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.dossier.officializeGedo(reportId, dto, file, principal, request);
}
@Get('gedo/pdf')
@RequirePermissions('inspection_reports.read')
async gedoPdf(
@Param('reportId', new ParseUUIDPipe({ version: '4' })) reportId: string,
@Res() response: Response,
): Promise<void> {
const content = await this.dossier.gedoPdfContent(reportId);
response.setHeader('Content-Type', 'application/pdf');
response.setHeader('Content-Disposition', `attachment; filename="${content.originalName.replaceAll('"', '')}"`);
response.setHeader('Cache-Control', 'private, no-store');
response.setHeader('X-Content-Type-Options', 'nosniff');
await new Promise<void>((resolveSend, rejectSend) => {
response.sendFile(content.filePath, (error) => {
if (error) rejectSend(error);
else resolveSend();
});
});
}
@Get('follow-ups')
@RequirePermissions('inspection_reports.read')
followUps(@Param('reportId', new ParseUUIDPipe({ version: '4' })) reportId: string) {
return this.dossier.listFollowUps(reportId);
}
@Post('follow-ups')
@RequirePermissions('inspection_reports.follow_up')
@UseInterceptors(FilesInterceptor('files', 10, {
limits: { fileSize: MAX_REPORT_DOSSIER_PDF_BYTES, files: 10 },
}))
createFollowUp(
@Param('reportId', new ParseUUIDPipe({ version: '4' })) reportId: string,
@Body() dto: CreateInspectionReportFollowUpDto,
@UploadedFiles() files: UploadedReportDossierFile[] | undefined,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.dossier.createFollowUp(reportId, dto, files ?? [], principal, request);
}
}
@Controller('inspection-report-follow-up-files')
export class InspectionReportFollowUpFileController {
constructor(private readonly dossier: InspectionReportDossierService) {}
@Get(':fileId/content')
@RequirePermissions('inspection_reports.read')
async content(
@Param('fileId', new ParseUUIDPipe({ version: '4' })) fileId: string,
@Res() response: Response,
): Promise<void> {
const content = await this.dossier.followUpFileContent(fileId);
response.setHeader('Content-Type', 'application/pdf');
response.setHeader('Content-Disposition', `attachment; filename="${content.originalName.replaceAll('"', '')}"`);
response.setHeader('Cache-Control', 'private, no-store');
response.setHeader('X-Content-Type-Options', 'nosniff');
await new Promise<void>((resolveSend, rejectSend) => {
response.sendFile(content.filePath, (error) => {
if (error) rejectSend(error);
else resolveSend();
});
});
}
}
@@ -0,0 +1,516 @@
import { createHash, randomUUID } from 'node:crypto';
import { mkdir, readFile, stat, unlink, writeFile } from 'node:fs/promises';
import { isAbsolute, parse, resolve } from 'node:path';
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
InternalServerErrorException,
NotFoundException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { DataSource, EntityManager } from 'typeorm';
import { administrationAuditContext } from '../administration/common/administration-audit';
import { AuditService } from '../audit/audit.service';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import {
InspectionReport,
InspectionReportFollowUp,
InspectionReportFollowUpFile,
InspectionReportFollowUpType,
} from '../database/entities';
import { InspectionDeadlinesService } from '../inspection-deadlines/inspection-deadlines.service';
import type { CreateInspectionReportFollowUpDto } from './dto/create-inspection-report-follow-up.dto';
import type { OfficializeInspectionReportGedoDto } from './dto/officialize-inspection-report-gedo.dto';
import type { UpdateInspectionReportContentDto } from './dto/update-inspection-report-content.dto';
import {
inspectReportDossierPdf,
type UploadedReportDossierFile,
} from './inspection-report-dossier-file';
interface ReportContext {
id: string;
code: string;
actId: string;
visitId: string;
actCode: string;
visitCode: string;
generatedBy: string;
gedoIfIdentifier: string | null;
gedoOfficializedOn: string | null;
gedoPdfStoredName: string | null;
gedoPdfSizeBytes: number | null;
gedoPdfSha256: string | null;
}
@Injectable()
export class InspectionReportDossierService {
private readonly root: string;
constructor(
private readonly dataSource: DataSource,
private readonly audit: AuditService,
private readonly deadlines: InspectionDeadlinesService,
config: ConfigService,
) {
const configured = config.get<string>('INSPECTION_REPORT_DOSSIER_ROOT')
?? '/app/storage/asset-media/inspection-report-dossier';
if (!isAbsolute(configured)) {
throw new Error('INSPECTION_REPORT_DOSSIER_ROOT must be an absolute path');
}
this.root = resolve(configured);
if (this.root === parse(this.root).root) {
throw new Error('INSPECTION_REPORT_DOSSIER_ROOT cannot be the filesystem root');
}
}
async get(reportId: string) {
await this.requireReport(this.dataSource.manager, reportId);
const [report] = await this.dataSource.query(`
SELECT
report.id,
report.code,
report.reference_text AS "referenceText",
report.general_objective AS "generalObjective",
report.specific_objective AS "specificObjective",
report.background,
report.legal_framework AS "legalFramework",
report.executive_summary AS "executiveSummary",
report.description,
report.conclusion,
report.gedo_if_identifier AS "gedoIfIdentifier",
report.gedo_officialized_on::text AS "gedoOfficializedOn",
report.gedo_pdf_original_name AS "gedoPdfOriginalName",
report.gedo_pdf_mime_type AS "gedoPdfMimeType",
report.gedo_pdf_size_bytes AS "gedoPdfSizeBytes",
report.gedo_pdf_sha256 AS "gedoPdfSha256",
report.gedo_pdf_uploaded_at AS "gedoPdfUploadedAt",
act.id AS "actId",
act.code AS "actCode",
act.urgency,
act.deadline_days AS "deadlineDays",
act.deadline_day_type AS "deadlineDayType",
act.deadline_basis AS "deadlineBasis",
act.deadline_base_on::text AS "deadlineBaseOn",
act.deadline_due_on::text AS "deadlineDueOn",
act.status AS "actStatus",
visit.id AS "visitId",
visit.code AS "visitCode"
FROM inspection_reports report
INNER JOIN inspection_acts act ON act.id = report.act_id
INNER JOIN inspection_visits visit ON visit.id = report.visit_id
WHERE report.id = $1
`, [reportId]) as Array<Record<string, unknown>>;
const followUps = await this.listFollowUps(reportId);
return { ...report, followUps: followUps.data };
}
async updateContent(
reportId: string,
dto: UpdateInspectionReportContentDto,
principal: AuthPrincipal,
request: RequestWithContext,
) {
return this.dataSource.transaction(async (manager) => {
const context = await this.requireReport(manager, reportId, true);
await this.assertCanWorkReport(manager, context, principal);
const repository = manager.getRepository(InspectionReport);
const report = await repository.findOne({ where: { id: reportId } });
if (!report) throw this.notFound();
const before = this.contentSnapshot(report);
const fields: Array<keyof UpdateInspectionReportContentDto> = [
'referenceText',
'generalObjective',
'specificObjective',
'background',
'legalFramework',
'executiveSummary',
'description',
'conclusion',
];
let changed = false;
for (const field of fields) {
if (dto[field] !== undefined) {
(report as unknown as Record<string, unknown>)[field] = dto[field] ?? null;
changed = true;
}
}
if (!changed) {
throw new BadRequestException({
code: 'INSPECTION_REPORT_CONTENT_EMPTY_UPDATE',
message: 'No se recibió ningún campo del Informe para actualizar',
});
}
await repository.save(report);
const after = this.contentSnapshot(report);
await this.audit.record({
...administrationAuditContext(principal, request),
action: 'INSPECTION_REPORT_CONTENT_UPDATED',
entityType: 'inspection_report',
entityId: reportId,
beforeData: before,
afterData: after,
metadata: { actId: context.actId, visitId: context.visitId },
}, manager);
return after;
});
}
async officializeGedo(
reportId: string,
dto: OfficializeInspectionReportGedoDto,
file: UploadedReportDossierFile | undefined,
principal: AuthPrincipal,
request: RequestWithContext,
) {
const inspected = inspectReportDossierPdf(file);
this.assertDate(dto.officializedOn, 'La fecha de carga en GEDO no es válida');
await mkdir(this.root, { recursive: true, mode: 0o700 });
const storedName = `gedo-${reportId}-${randomUUID()}.pdf`;
const filePath = this.safePath(storedName);
const sha256 = createHash('sha256').update(file!.buffer).digest('hex');
await writeFile(filePath, file!.buffer, { mode: 0o600 });
try {
return await this.dataSource.transaction(async (manager) => {
const context = await this.requireReport(manager, reportId, true);
await this.assertCanWorkReport(manager, context, principal);
if (context.gedoIfIdentifier || context.gedoOfficializedOn || context.gedoPdfStoredName) {
throw new ConflictException({
code: 'INSPECTION_REPORT_ALREADY_OFFICIALIZED',
message: 'Este Informe ya tiene un IF oficial de GEDO y no puede sobrescribirse',
});
}
await manager.query(`
UPDATE inspection_reports
SET gedo_if_identifier = $2,
gedo_officialized_on = $3::date,
gedo_pdf_original_name = $4,
gedo_pdf_stored_name = $5,
gedo_pdf_mime_type = 'application/pdf',
gedo_pdf_size_bytes = $6,
gedo_pdf_sha256 = $7,
gedo_pdf_uploaded_at = CURRENT_TIMESTAMP,
gedo_pdf_uploaded_by = $8,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1
`, [
reportId,
dto.ifIdentifier,
dto.officializedOn,
inspected.originalName,
storedName,
file!.buffer.length,
sha256,
principal.userId,
]);
const deadlineDueOn = await this.deadlines.applyGedoOfficialization(
manager,
context.actId,
dto.officializedOn,
);
const after = {
gedoIfIdentifier: dto.ifIdentifier,
gedoOfficializedOn: dto.officializedOn,
gedoPdfOriginalName: inspected.originalName,
gedoPdfSizeBytes: file!.buffer.length,
gedoPdfSha256: sha256,
deadlineDueOn,
};
await this.audit.record({
...administrationAuditContext(principal, request),
action: 'INSPECTION_REPORT_GEDO_OFFICIALIZED',
entityType: 'inspection_report',
entityId: reportId,
afterData: after,
metadata: {
actId: context.actId,
visitId: context.visitId,
officialLegalDocument: true,
},
}, manager);
return after;
});
} catch (error) {
await unlink(filePath).catch(() => undefined);
throw error;
}
}
async gedoPdfContent(reportId: string) {
const context = await this.requireReport(this.dataSource.manager, reportId);
if (!context.gedoPdfStoredName || !context.gedoPdfSizeBytes || !context.gedoPdfSha256) {
throw new NotFoundException({
code: 'INSPECTION_REPORT_GEDO_PDF_NOT_FOUND',
message: 'El Informe todavía no tiene un PDF oficial de GEDO',
});
}
const filePath = this.safePath(context.gedoPdfStoredName);
await this.verifyStoredFile(filePath, context.gedoPdfSizeBytes, context.gedoPdfSha256);
return { filePath, originalName: `${context.gedoIfIdentifier ?? context.code}.pdf` };
}
async listFollowUps(reportId: string) {
await this.requireReport(this.dataSource.manager, reportId);
const data = await this.dataSource.query(`
SELECT
follow_up.id,
follow_up.event_type AS "eventType",
follow_up.reference_number AS "referenceNumber",
follow_up.occurred_on::text AS "occurredOn",
follow_up.description,
follow_up.created_by AS "createdBy",
follow_up.created_at AS "createdAt",
COALESCE((
SELECT JSONB_AGG(JSONB_BUILD_OBJECT(
'id', file.id,
'originalName', file.original_name,
'mimeType', file.mime_type,
'sizeBytes', file.size_bytes,
'sha256', file.sha256,
'createdAt', file.created_at
) ORDER BY file.created_at, file.id)
FROM inspection_report_follow_up_files file
WHERE file.follow_up_id = follow_up.id
), '[]'::jsonb) AS files
FROM inspection_report_follow_ups follow_up
WHERE follow_up.report_id = $1
ORDER BY follow_up.occurred_on ASC, follow_up.created_at ASC, follow_up.id ASC
`, [reportId]);
return { data };
}
async createFollowUp(
reportId: string,
dto: CreateInspectionReportFollowUpDto,
files: UploadedReportDossierFile[],
principal: AuthPrincipal,
request: RequestWithContext,
) {
this.assertDate(dto.occurredOn, 'La fecha del seguimiento no es válida');
if (files.length > 10) {
throw new BadRequestException({
code: 'INSPECTION_REPORT_FOLLOW_UP_TOO_MANY_FILES',
message: 'Se permiten hasta 10 archivos PDF por registro de seguimiento',
});
}
if (dto.eventType === InspectionReportFollowUpType.COMPANY_NOTE && files.length < 1) {
throw new BadRequestException({
code: 'INSPECTION_REPORT_COMPANY_NOTE_FILE_REQUIRED',
message: 'La respuesta formal de la empresa debe conservar al menos el PDF de la Nota recibida',
});
}
const inspectedFiles = files.map((file) => ({ file, inspected: inspectReportDossierPdf(file) }));
const followUpId = randomUUID();
const stored: Array<{
id: string;
storedName: string;
filePath: string;
originalName: string;
sizeBytes: number;
sha256: string;
}> = [];
await mkdir(this.root, { recursive: true, mode: 0o700 });
try {
for (const item of inspectedFiles) {
const id = randomUUID();
const storedName = `follow-up-${followUpId}-${id}.pdf`;
const filePath = this.safePath(storedName);
const sha256 = createHash('sha256').update(item.file.buffer).digest('hex');
await writeFile(filePath, item.file.buffer, { mode: 0o600 });
stored.push({
id,
storedName,
filePath,
originalName: item.inspected.originalName,
sizeBytes: item.file.buffer.length,
sha256,
});
}
await this.dataSource.transaction(async (manager) => {
const context = await this.requireReport(manager, reportId, true);
await this.assertCanWorkReport(manager, context, principal);
await manager.getRepository(InspectionReportFollowUp).insert({
id: followUpId,
reportId,
eventType: dto.eventType,
referenceNumber: dto.referenceNumber ?? null,
occurredOn: dto.occurredOn,
description: dto.description ?? null,
createdBy: principal.userId,
});
for (const file of stored) {
await manager.getRepository(InspectionReportFollowUpFile).insert({
id: file.id,
followUpId,
originalName: file.originalName,
storedName: file.storedName,
mimeType: 'application/pdf',
sizeBytes: file.sizeBytes,
sha256: file.sha256,
createdBy: principal.userId,
});
}
await this.audit.record({
...administrationAuditContext(principal, request),
action: 'INSPECTION_REPORT_FOLLOW_UP_CREATED',
entityType: 'inspection_report_follow_up',
entityId: followUpId,
afterData: {
reportId,
eventType: dto.eventType,
referenceNumber: dto.referenceNumber ?? null,
occurredOn: dto.occurredOn,
description: dto.description ?? null,
files: stored.map((file) => ({
id: file.id,
originalName: file.originalName,
sizeBytes: file.sizeBytes,
sha256: file.sha256,
})),
},
metadata: {
actId: context.actId,
visitId: context.visitId,
appendOnly: true,
},
}, manager);
});
return this.listFollowUps(reportId);
} catch (error) {
await Promise.all(stored.map((file) => unlink(file.filePath).catch(() => undefined)));
throw error;
}
}
async followUpFileContent(fileId: string) {
const [file] = (await this.dataSource.query(`
SELECT
file.original_name AS "originalName",
file.stored_name AS "storedName",
file.size_bytes AS "sizeBytes",
file.sha256
FROM inspection_report_follow_up_files file
WHERE file.id = $1
`, [fileId])) as Array<{
originalName: string;
storedName: string;
sizeBytes: number;
sha256: string;
}>;
if (!file) {
throw new NotFoundException({
code: 'INSPECTION_REPORT_FOLLOW_UP_FILE_NOT_FOUND',
message: 'Documento de seguimiento no encontrado',
});
}
const filePath = this.safePath(file.storedName);
await this.verifyStoredFile(filePath, Number(file.sizeBytes), file.sha256);
return { filePath, originalName: file.originalName };
}
private async requireReport(
manager: EntityManager,
reportId: string,
lock = false,
): Promise<ReportContext> {
const suffix = lock ? 'FOR UPDATE OF report' : '';
const [row] = (await manager.query(`
SELECT
report.id,
report.code,
report.act_id AS "actId",
report.visit_id AS "visitId",
report.generated_by AS "generatedBy",
report.gedo_if_identifier AS "gedoIfIdentifier",
report.gedo_officialized_on::text AS "gedoOfficializedOn",
report.gedo_pdf_stored_name AS "gedoPdfStoredName",
report.gedo_pdf_size_bytes AS "gedoPdfSizeBytes",
report.gedo_pdf_sha256 AS "gedoPdfSha256",
act.code AS "actCode",
visit.code AS "visitCode"
FROM inspection_reports report
INNER JOIN inspection_acts act ON act.id = report.act_id
INNER JOIN inspection_visits visit ON visit.id = report.visit_id
WHERE report.id = $1
${suffix}
`, [reportId])) as ReportContext[];
if (!row) throw this.notFound();
return row;
}
private async assertCanWorkReport(
manager: EntityManager,
report: ReportContext,
principal: AuthPrincipal,
): Promise<void> {
if (principal.roles.includes('admin') || principal.roles.includes('supervisor')) return;
if (report.generatedBy === principal.userId) return;
const [member] = (await manager.query(`
SELECT 1 AS found
FROM inspection_visit_members
WHERE visit_id = $1
AND user_id = $2
AND included = true
LIMIT 1
`, [report.visitId, principal.userId])) as Array<{ found: number }>;
if (member) return;
throw new ForbiddenException({
code: 'INSPECTION_REPORT_NOT_ASSIGNED',
message: 'Sólo el inspector asignado a la inspección o su jefatura puede trabajar este Informe',
});
}
private contentSnapshot(report: InspectionReport): Record<string, unknown> {
return {
referenceText: report.referenceText,
generalObjective: report.generalObjective,
specificObjective: report.specificObjective,
background: report.background,
legalFramework: report.legalFramework,
executiveSummary: report.executiveSummary,
description: report.description,
conclusion: report.conclusion,
};
}
private assertDate(value: string, message: string): void {
const parsed = new Date(`${value}T00:00:00Z`);
if (!Number.isFinite(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== value) {
throw new BadRequestException({ code: 'INSPECTION_REPORT_DATE_INVALID', message });
}
}
private safePath(storedName: string): string {
const filePath = resolve(this.root, storedName);
if (!filePath.startsWith(`${this.root}/`)) {
throw new InternalServerErrorException({
code: 'INSPECTION_REPORT_STORAGE_INVALID_PATH',
message: 'Ruta de almacenamiento inválida',
});
}
return filePath;
}
private async verifyStoredFile(filePath: string, expectedSize: number, expectedSha256: string): Promise<void> {
const metadata = await stat(filePath).catch(() => null);
if (!metadata?.isFile() || metadata.size !== expectedSize) throw this.storageError();
const buffer = await readFile(filePath);
const actualSha256 = createHash('sha256').update(buffer).digest('hex');
if (actualSha256 !== expectedSha256) throw this.storageError();
}
private storageError(): InternalServerErrorException {
return new InternalServerErrorException({
code: 'INSPECTION_REPORT_STORAGE_INTEGRITY_ERROR',
message: 'El archivo almacenado no supera la verificación de integridad',
});
}
private notFound(): NotFoundException {
return new NotFoundException({
code: 'INSPECTION_REPORT_NOT_FOUND',
message: 'Informe de inspección no encontrado',
});
}
}
@@ -1,31 +1,39 @@
import { Module } from '@nestjs/common';
import { AuditModule } from '../audit/audit.module';
import { InspectionDeadlinesModule } from '../inspection-deadlines/inspection-deadlines.module';
import { DocumentDeliveryController } from './document-delivery.controller';
import { InspectionActPdfService } from './inspection-act-pdf.service';
import { InspectionDocumentDeliveryService } from './inspection-document-delivery.service';
import { InspectionReportReviewController, InspectionReportRevisionContentController } from './inspection-report-review.controller';
import { InspectionReportReviewService } from './inspection-report-review.service';
import {
InspectionReportDossierController,
InspectionReportFollowUpFileController,
} from './inspection-report-dossier.controller';
import { InspectionReportDossierService } from './inspection-report-dossier.service';
import { InspectionReportWordService } from './inspection-report-word.service';
import { InspectionActReportController, InspectionReportsController } from './inspection-reports.controller';
import { InspectionReportsService } from './inspection-reports.service';
import { SmtpDeliveryService } from './smtp-delivery.service';
import { SmtpSettingsController } from './smtp-settings.controller';
import { SmtpSettingsService } from './smtp-settings.service';
@Module({
imports: [AuditModule],
imports: [AuditModule, InspectionDeadlinesModule],
controllers: [
InspectionReportsController,
InspectionActReportController,
InspectionReportDossierController,
InspectionReportFollowUpFileController,
DocumentDeliveryController,
InspectionReportReviewController,
InspectionReportRevisionContentController,
SmtpSettingsController,
],
providers: [
InspectionReportsService,
InspectionReportWordService,
InspectionReportDossierService,
InspectionActPdfService,
InspectionDocumentDeliveryService,
SmtpDeliveryService,
InspectionReportReviewService,
SmtpSettingsService,
],
exports: [InspectionReportsService],
})
@@ -1,37 +1,328 @@
import { randomUUID } from 'node:crypto';
import { connect as connectNet, Socket } from 'node:net';
import { connect as connectTls, TLSSocket } from 'node:tls';
import { randomUUID } from 'node:crypto';
import { Injectable, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { decryptSmtpSecret } from './smtp-settings-crypto';
interface MailAttachment { filename:string; mimeType:string; content:Buffer; }
interface MailInput { to:string; subject:string; text:string; attachment:MailAttachment; }
interface Reply { code:number; text:string; }
interface MailAttachment {
filename: string;
mimeType: string;
content: Buffer;
}
interface MailInput {
to: string;
subject: string;
text: string;
attachment?: MailAttachment;
}
interface Reply {
code: number;
text: string;
}
interface RuntimeSmtpConfig {
source: 'DATABASE' | 'ENV';
host: string;
port: number;
securityMode: 'TLS' | 'STARTTLS';
user: string;
pass: string;
fromHeader: string;
fromEmail: string;
replyTo: string | null;
}
class SmtpSession {
private buffer=''; private waiters:Array<(reply:Reply)=>void>=[]; private replyLines:string[]=[];
private readonly dataHandler=(chunk:Buffer|string)=>this.onData(typeof chunk==='string'?chunk:chunk.toString('utf8'));
constructor(private socket:Socket|TLSSocket){ this.attach(socket); }
private attach(socket:Socket|TLSSocket){ socket.setEncoding('utf8'); socket.setTimeout(15000,()=>socket.destroy(new Error('SMTP timeout'))); socket.on('data',this.dataHandler); }
detachForUpgrade(){ this.socket.off('data',this.dataHandler); this.socket.setTimeout(0); return this.socket; }
replaceSocket(socket:Socket|TLSSocket){ this.socket=socket; this.buffer=''; this.attach(socket); }
private onData(chunk:string){ this.buffer+=chunk; const lines=this.buffer.split(/\r?\n/); this.buffer=lines.pop()??''; for(const line of lines){ if(!line)continue; this.replyLines.push(line); if(/^\d{3} /.test(line)){ const code=Number(line.slice(0,3)); const reply={code,text:this.replyLines.join('\n')}; this.replyLines=[]; const waiter=this.waiters.shift(); if(waiter)waiter(reply); } } }
reply():Promise<Reply>{return new Promise((resolve,reject)=>{ const onError=(e:Error)=>{this.socket.off('error',onError);reject(e)}; this.socket.once('error',onError); this.waiters.push((reply)=>{this.socket.off('error',onError);resolve(reply)}); });}
async command(command:string, expected:number|number[]){ this.socket.write(`${command}\r\n`); const reply=await this.reply(); const allowed=Array.isArray(expected)?expected:[expected]; if(!allowed.includes(reply.code))throw new Error(`SMTP ${reply.code}: ${reply.text}`); return reply; }
write(data:string){this.socket.write(data);}
end(){this.socket.end();}
current(){return this.socket;}
private buffer = '';
private waiters: Array<(reply: Reply) => void> = [];
private replyLines: string[] = [];
private readonly dataHandler = (chunk: Buffer | string) =>
this.onData(typeof chunk === 'string' ? chunk : chunk.toString('utf8'));
constructor(private socket: Socket | TLSSocket) {
this.attach(socket);
}
private attach(socket: Socket | TLSSocket) {
socket.setEncoding('utf8');
socket.setTimeout(15000, () => socket.destroy(new Error('SMTP timeout')));
socket.on('data', this.dataHandler);
}
detachForUpgrade() {
this.socket.off('data', this.dataHandler);
this.socket.setTimeout(0);
return this.socket;
}
replaceSocket(socket: Socket | TLSSocket) {
this.socket = socket;
this.buffer = '';
this.attach(socket);
}
private onData(chunk: string) {
this.buffer += chunk;
const lines = this.buffer.split(/\r?\n/);
this.buffer = lines.pop() ?? '';
for (const line of lines) {
if (!line) continue;
this.replyLines.push(line);
if (/^\d{3} /.test(line)) {
const code = Number(line.slice(0, 3));
const reply = { code, text: this.replyLines.join('\n') };
this.replyLines = [];
this.waiters.shift()?.(reply);
}
}
}
reply(): Promise<Reply> {
return new Promise((resolve, reject) => {
const onError = (error: Error) => {
this.socket.off('error', onError);
reject(error);
};
this.socket.once('error', onError);
this.waiters.push((reply) => {
this.socket.off('error', onError);
resolve(reply);
});
});
}
async command(command: string, expected: number | number[]) {
this.socket.write(`${command}\r\n`);
const reply = await this.reply();
const allowed = Array.isArray(expected) ? expected : [expected];
if (!allowed.includes(reply.code)) {
throw new Error(`SMTP ${reply.code}: ${reply.text}`);
}
return reply;
}
write(data: string) {
this.socket.write(data);
}
end() {
this.socket.end();
}
}
function subject(value:string){return `=?UTF-8?B?${Buffer.from(value,'utf8').toString('base64')}?=`;}
function envelope(value:string){const m=value.match(/<([^>]+)>/);return (m?.[1]??value).trim();}
function mime(input:MailInput,from:string){ const boundary=`dh-${randomUUID()}`; const body=[`From: ${from}`,`To: ${input.to}`,`Subject: ${subject(input.subject)}`,'MIME-Version: 1.0',`Content-Type: multipart/mixed; boundary="${boundary}"`,'',`--${boundary}`,'Content-Type: text/plain; charset=utf-8','Content-Transfer-Encoding: base64','',Buffer.from(input.text,'utf8').toString('base64'),`--${boundary}`,`Content-Type: ${input.attachment.mimeType}; name="${input.attachment.filename.replaceAll('"','')}"`,'Content-Transfer-Encoding: base64',`Content-Disposition: attachment; filename="${input.attachment.filename.replaceAll('"','')}"`,'',input.attachment.content.toString('base64').replace(/(.{76})/g,'$1\r\n'),`--${boundary}--`,''].join('\r\n'); return body.replace(/^\./gm,'..'); }
function encodedSubject(value: string) {
return `=?UTF-8?B?${Buffer.from(value, 'utf8').toString('base64')}?=`;
}
function escapeHeader(value: string) {
return value.replace(/[\r\n]/g, ' ').replaceAll('"', "'");
}
function mime(input: MailInput, config: RuntimeSmtpConfig) {
const headers = [
`From: ${config.fromHeader}`,
`To: ${input.to}`,
`Subject: ${encodedSubject(input.subject)}`,
...(config.replyTo ? [`Reply-To: ${config.replyTo}`] : []),
'MIME-Version: 1.0',
];
if (!input.attachment) {
return [
...headers,
'Content-Type: text/plain; charset=utf-8',
'Content-Transfer-Encoding: base64',
'',
Buffer.from(input.text, 'utf8').toString('base64'),
'',
].join('\r\n').replace(/^\./gm, '..');
}
const boundary = `dh-${randomUUID()}`;
const filename = escapeHeader(input.attachment.filename);
return [
...headers,
`Content-Type: multipart/mixed; boundary="${boundary}"`,
'',
`--${boundary}`,
'Content-Type: text/plain; charset=utf-8',
'Content-Transfer-Encoding: base64',
'',
Buffer.from(input.text, 'utf8').toString('base64'),
`--${boundary}`,
`Content-Type: ${input.attachment.mimeType}; name="${filename}"`,
'Content-Transfer-Encoding: base64',
`Content-Disposition: attachment; filename="${filename}"`,
'',
input.attachment.content.toString('base64').replace(/(.{76})/g, '$1\r\n'),
`--${boundary}--`,
'',
].join('\r\n').replace(/^\./gm, '..');
}
@Injectable()
export class SmtpDeliveryService {
constructor(private readonly config:ConfigService){}
configured(){return Boolean(this.config.get<string>('SMTP_HOST')&&this.config.get<string>('MAIL_FROM'));}
async send(input:MailInput):Promise<{messageId:string}>{ const host=this.config.get<string>('SMTP_HOST'); const from=this.config.get<string>('MAIL_FROM'); if(!host||!from)throw new Error('SMTP no configurado'); const port=Number(this.config.get<string>('SMTP_PORT')??587); const secure=String(this.config.get<string>('SMTP_SECURE')??'false').toLowerCase()==='true'; const user=this.config.get<string>('SMTP_USER')??''; const pass=this.config.get<string>('SMTP_PASS')??''; const base=await new Promise<Socket|TLSSocket>((resolve,reject)=>{ if(secure){ const tls=connectTls({host,port,servername:host,rejectUnauthorized:true},()=>resolve(tls)); tls.once('error',reject); } else { const raw=connectNet({host,port},()=>resolve(raw)); raw.once('error',reject); } }); const session=new SmtpSession(base); const welcome=await session.reply(); if(welcome.code!==220)throw new Error(`SMTP ${welcome.code}: ${welcome.text}`); let ehlo=await session.command(`EHLO dh-inspeccion`,250); if(!secure){ if(!/STARTTLS/i.test(ehlo.text))throw new Error('El servidor SMTP no ofrece STARTTLS'); await session.command('STARTTLS',220); const rawForTls=session.detachForUpgrade() as Socket; const upgraded=await new Promise<TLSSocket>((resolve,reject)=>{ const tls=connectTls({socket:rawForTls,servername:host,rejectUnauthorized:true},()=>resolve(tls)); tls.once('error',reject); }); session.replaceSocket(upgraded); ehlo=await session.command('EHLO dh-inspeccion',250); }
if(user){ if(/AUTH[^\n]*PLAIN/i.test(ehlo.text)){ const token=Buffer.from(`\u0000${user}\u0000${pass}`,'utf8').toString('base64'); await session.command(`AUTH PLAIN ${token}`,235); } else { await session.command('AUTH LOGIN',334); await session.command(Buffer.from(user).toString('base64'),334); await session.command(Buffer.from(pass).toString('base64'),235); } }
await session.command(`MAIL FROM:<${envelope(from)}>`,250); await session.command(`RCPT TO:<${input.to}>`,[250,251]); await session.command('DATA',354); session.write(`${mime(input,from)}\r\n.\r\n`); const sent=await session.reply(); if(sent.code!==250)throw new Error(`SMTP ${sent.code}: ${sent.text}`); await session.command('QUIT',221).catch(()=>undefined); session.end(); const match=sent.text.match(/(?:queued as|id=|message-id[=:]?)[\s<]*([^\s>]+)/i); return {messageId:match?.[1]??randomUUID()}; }
export class SmtpDeliveryService implements OnModuleInit {
private runtime: RuntimeSmtpConfig | null;
private runtimeError: string | null = null;
constructor(
private readonly dataSource: DataSource,
private readonly config: ConfigService,
) {
this.runtime = this.environmentConfig();
}
async onModuleInit(): Promise<void> {
await this.reload().catch((error) => {
this.runtime = null;
this.runtimeError = error instanceof Error ? error.message : 'SMTP configuration error';
});
}
configured() {
return Boolean(this.runtime);
}
activeSource() {
return this.runtime?.source ?? null;
}
async reload(): Promise<void> {
const [row] = (await this.dataSource.query(`
SELECT
enabled,
host,
port,
security_mode AS "securityMode",
username,
password_encrypted AS "passwordEncrypted",
from_name AS "fromName",
from_email AS "fromEmail",
reply_to AS "replyTo"
FROM smtp_settings
WHERE id = 1
`).catch(() => [])) as Array<{
enabled: boolean;
host: string | null;
port: number | null;
securityMode: 'TLS' | 'STARTTLS' | null;
username: string | null;
passwordEncrypted: string | null;
fromName: string | null;
fromEmail: string | null;
replyTo: string | null;
}>;
if (!row?.enabled) {
this.runtime = this.environmentConfig();
this.runtimeError = null;
return;
}
if (!row.host || !row.port || !row.securityMode || !row.fromName || !row.fromEmail) {
this.runtime = null;
this.runtimeError = 'La configuración SMTP del Superadmin está incompleta';
return;
}
let pass = '';
if (row.passwordEncrypted) {
const key = this.config.get<string>('SMTP_SETTINGS_ENCRYPTION_KEY');
if (!key) {
this.runtime = null;
this.runtimeError = 'Falta SMTP_SETTINGS_ENCRYPTION_KEY para descifrar la contraseña SMTP';
return;
}
pass = decryptSmtpSecret(row.passwordEncrypted, key);
}
this.runtime = {
source: 'DATABASE',
host: row.host,
port: Number(row.port),
securityMode: row.securityMode,
user: row.username ?? '',
pass,
fromHeader: `"${escapeHeader(row.fromName)}" <${row.fromEmail}>`,
fromEmail: row.fromEmail,
replyTo: row.replyTo,
};
this.runtimeError = null;
}
async send(input: MailInput): Promise<{ messageId: string }> {
if (!this.runtime) await this.reload();
const config = this.runtime;
if (!config) throw new Error(this.runtimeError ?? 'SMTP no configurado');
const base = await new Promise<Socket | TLSSocket>((resolve, reject) => {
if (config.securityMode === 'TLS') {
const tls = connectTls({
host: config.host,
port: config.port,
servername: config.host,
rejectUnauthorized: true,
}, () => resolve(tls));
tls.once('error', reject);
} else {
const raw = connectNet({ host: config.host, port: config.port }, () => resolve(raw));
raw.once('error', reject);
}
});
const session = new SmtpSession(base);
const welcome = await session.reply();
if (welcome.code !== 220) throw new Error(`SMTP ${welcome.code}: ${welcome.text}`);
let ehlo = await session.command('EHLO dh-inspeccion', 250);
if (config.securityMode === 'STARTTLS') {
if (!/STARTTLS/i.test(ehlo.text)) throw new Error('El servidor SMTP no ofrece STARTTLS');
await session.command('STARTTLS', 220);
const raw = session.detachForUpgrade() as Socket;
const upgraded = await new Promise<TLSSocket>((resolve, reject) => {
const tls = connectTls({ socket: raw, servername: config.host, rejectUnauthorized: true }, () => resolve(tls));
tls.once('error', reject);
});
session.replaceSocket(upgraded);
ehlo = await session.command('EHLO dh-inspeccion', 250);
}
if (config.user) {
if (/AUTH[^\n]*PLAIN/i.test(ehlo.text)) {
const token = Buffer.from(`\u0000${config.user}\u0000${config.pass}`, 'utf8').toString('base64');
await session.command(`AUTH PLAIN ${token}`, 235);
} else {
await session.command('AUTH LOGIN', 334);
await session.command(Buffer.from(config.user).toString('base64'), 334);
await session.command(Buffer.from(config.pass).toString('base64'), 235);
}
}
await session.command(`MAIL FROM:<${config.fromEmail}>`, 250);
await session.command(`RCPT TO:<${input.to}>`, [250, 251]);
await session.command('DATA', 354);
session.write(`${mime(input, config)}\r\n.\r\n`);
const sent = await session.reply();
if (sent.code !== 250) throw new Error(`SMTP ${sent.code}: ${sent.text}`);
await session.command('QUIT', 221).catch(() => undefined);
session.end();
const match = sent.text.match(/(?:queued as|id=|message-id[=:]?)[\s<]*([^\s>]+)/i);
return { messageId: match?.[1] ?? randomUUID() };
}
private environmentConfig(): RuntimeSmtpConfig | null {
const host = this.config.get<string>('SMTP_HOST');
const from = this.config.get<string>('MAIL_FROM');
if (!host || !from) return null;
const match = from.match(/^(?:\s*"?([^"<]+)"?\s*)?<([^>]+)>\s*$/);
const fromEmail = (match?.[2] ?? from).trim();
return {
source: 'ENV',
host,
port: Number(this.config.get<string>('SMTP_PORT') ?? 587),
securityMode: String(this.config.get<string>('SMTP_SECURE') ?? 'false').toLowerCase() === 'true'
? 'TLS'
: 'STARTTLS',
user: this.config.get<string>('SMTP_USER') ?? '',
pass: this.config.get<string>('SMTP_PASS') ?? '',
fromHeader: from,
fromEmail,
replyTo: null,
};
}
}
@@ -0,0 +1,38 @@
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto';
function keyFromSecret(secret: string): Buffer {
if (secret.trim().length < 24) {
throw new Error('SMTP_SETTINGS_ENCRYPTION_KEY must contain at least 24 characters');
}
return createHash('sha256').update(secret, 'utf8').digest();
}
export function encryptSmtpSecret(value: string, secret: string): string {
const iv = randomBytes(12);
const cipher = createCipheriv('aes-256-gcm', keyFromSecret(secret), iv);
const encrypted = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
return [
'v1',
iv.toString('base64'),
tag.toString('base64'),
encrypted.toString('base64'),
].join(':');
}
export function decryptSmtpSecret(value: string, secret: string): string {
const [version, ivEncoded, tagEncoded, encryptedEncoded] = value.split(':');
if (version !== 'v1' || !ivEncoded || !tagEncoded || encryptedEncoded === undefined) {
throw new Error('Invalid encrypted SMTP password payload');
}
const decipher = createDecipheriv(
'aes-256-gcm',
keyFromSecret(secret),
Buffer.from(ivEncoded, 'base64'),
);
decipher.setAuthTag(Buffer.from(tagEncoded, 'base64'));
return Buffer.concat([
decipher.update(Buffer.from(encryptedEncoded, 'base64')),
decipher.final(),
]).toString('utf8');
}
@@ -0,0 +1,52 @@
import { Body, Controller, Get, Put, Post, Req } from '@nestjs/common';
import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import { TestSmtpSettingsDto, UpdateSmtpSettingsDto } from './dto/update-smtp-settings.dto';
import { SmtpDeliveryService } from './smtp-delivery.service';
import { SmtpSettingsService } from './smtp-settings.service';
@Controller('system-mail-settings')
export class SmtpSettingsController {
constructor(
private readonly settings: SmtpSettingsService,
private readonly smtp: SmtpDeliveryService,
) {}
@Get()
@RequirePermissions('system_mail.manage')
async get() {
return {
...(await this.settings.getPublic()),
transportConfigured: this.smtp.configured(),
activeSource: this.smtp.activeSource(),
};
}
@Put()
@RequirePermissions('system_mail.manage')
async update(
@Body() dto: UpdateSmtpSettingsDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
const result = await this.settings.update(dto, principal, request);
await this.smtp.reload();
return {
...result,
transportConfigured: this.smtp.configured(),
activeSource: this.smtp.activeSource(),
};
}
@Post('test')
@RequirePermissions('system_mail.manage')
async test(@Body() dto: TestSmtpSettingsDto) {
const sent = await this.smtp.send({
to: dto.to,
subject: 'DH Inspección · Prueba SMTP',
text: 'Este correo confirma que la salida SMTP configurada en Superadmin funciona correctamente.',
});
return { sent: true, messageId: sent.messageId };
}
}
@@ -0,0 +1,170 @@
import { ConflictException, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { DataSource } from 'typeorm';
import { administrationAuditContext } from '../administration/common/administration-audit';
import { AuditService } from '../audit/audit.service';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import type { UpdateSmtpSettingsDto } from './dto/update-smtp-settings.dto';
import { encryptSmtpSecret } from './smtp-settings-crypto';
interface SmtpSettingsRow {
enabled: boolean;
host: string | null;
port: number | null;
securityMode: string | null;
username: string | null;
passwordEncrypted: string | null;
fromName: string | null;
fromEmail: string | null;
replyTo: string | null;
updatedAt: Date;
}
@Injectable()
export class SmtpSettingsService {
constructor(
private readonly dataSource: DataSource,
private readonly config: ConfigService,
private readonly audit: AuditService,
) {}
async getPublic() {
const row = await this.load();
return this.toPublic(row);
}
async update(
dto: UpdateSmtpSettingsDto,
principal: AuthPrincipal,
request: RequestWithContext,
) {
const encryptionKey = this.config.get<string>('SMTP_SETTINGS_ENCRYPTION_KEY');
if (dto.password !== undefined && !encryptionKey) {
throw new ConflictException({
code: 'SMTP_SETTINGS_ENCRYPTION_KEY_MISSING',
message: 'Falta configurar la clave maestra del servidor para proteger la contraseña SMTP',
});
}
return this.dataSource.transaction(async (manager) => {
const [current] = (await manager.query(`
SELECT
enabled,
host,
port,
security_mode AS "securityMode",
username,
password_encrypted AS "passwordEncrypted",
from_name AS "fromName",
from_email AS "fromEmail",
reply_to AS "replyTo",
updated_at AS "updatedAt"
FROM smtp_settings
WHERE id = 1
FOR UPDATE
`)) as SmtpSettingsRow[];
let passwordEncrypted = current?.passwordEncrypted ?? null;
if (dto.clearPassword) passwordEncrypted = null;
if (dto.password !== undefined) {
passwordEncrypted = encryptSmtpSecret(dto.password, encryptionKey!);
}
await manager.query(`
UPDATE smtp_settings
SET enabled = $1,
host = $2,
port = $3,
security_mode = $4,
username = $5,
password_encrypted = $6,
from_name = $7,
from_email = $8,
reply_to = $9,
updated_by = $10,
updated_at = CURRENT_TIMESTAMP
WHERE id = 1
`, [
dto.enabled,
dto.host,
dto.port,
dto.securityMode,
dto.username ?? null,
passwordEncrypted,
dto.fromName,
dto.fromEmail,
dto.replyTo ?? null,
principal.userId,
]);
const [after] = (await manager.query(`
SELECT
enabled,
host,
port,
security_mode AS "securityMode",
username,
password_encrypted AS "passwordEncrypted",
from_name AS "fromName",
from_email AS "fromEmail",
reply_to AS "replyTo",
updated_at AS "updatedAt"
FROM smtp_settings
WHERE id = 1
`)) as SmtpSettingsRow[];
const beforePublic = current ? this.toPublic(current) : null;
const afterPublic = this.toPublic(after);
await this.audit.record({
...administrationAuditContext(principal, request),
action: 'SMTP_SETTINGS_UPDATED',
entityType: 'smtp_settings',
entityId: '1',
beforeData: beforePublic,
afterData: afterPublic,
metadata: { passwordChanged: dto.password !== undefined || Boolean(dto.clearPassword) },
}, manager);
return afterPublic;
});
}
private async load(): Promise<SmtpSettingsRow> {
const [row] = (await this.dataSource.query(`
SELECT
enabled,
host,
port,
security_mode AS "securityMode",
username,
password_encrypted AS "passwordEncrypted",
from_name AS "fromName",
from_email AS "fromEmail",
reply_to AS "replyTo",
updated_at AS "updatedAt"
FROM smtp_settings
WHERE id = 1
`)) as SmtpSettingsRow[];
return row ?? {
enabled: false,
host: null,
port: null,
securityMode: null,
username: null,
passwordEncrypted: null,
fromName: null,
fromEmail: null,
replyTo: null,
updatedAt: new Date(0),
};
}
private toPublic(row: SmtpSettingsRow): Record<string, unknown> {
return {
enabled: row.enabled,
host: row.host,
port: row.port,
securityMode: row.securityMode,
username: row.username,
passwordConfigured: Boolean(row.passwordEncrypted),
fromName: row.fromName,
fromEmail: row.fromEmail,
replyTo: row.replyTo,
updatedAt: row.updatedAt,
};
}
}
@@ -18,8 +18,13 @@ const optionalText = ({ value }: { value: unknown }) =>
/**
* Hallazgo capturado desde la APK sobre un Inventario ya seleccionado.
* El assetId se toma de la URL para evitar inconsistencias entre pantalla y payload.
* `actId` queda opcional sólo para compatibilidad con Android 0.11.0; F3.2 siempre lo envía.
*/
export class CreateFieldFindingDto {
@IsOptional()
@IsUUID('4')
actId?: string;
@IsOptional()
@Transform(optionalText)
@IsUUID('4')
@@ -0,0 +1,12 @@
import { IsOptional, IsUUID } from 'class-validator';
/**
* Selección explícita del Acta activa desde la APK.
* `actId` queda opcional sólo durante la transición desde Android 0.11.0;
* F3.2 móvil siempre lo informa.
*/
export class FieldFindingActQueryDto {
@IsOptional()
@IsUUID('4')
actId?: string;
}
@@ -5,12 +5,14 @@ import {
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 { CreateFieldFindingDto } from './dto/create-field-finding.dto';
import { FieldFindingActQueryDto } from './dto/field-finding-act-query.dto';
import { FieldFindingsService } from './field-findings.service';
@Controller('inspection-visits/:visitId/field-findings')
@@ -22,9 +24,10 @@ export class FieldFindingsController {
options(
@Param('visitId', new ParseUUIDPipe({ version: '4' })) visitId: string,
@Param('assetId', new ParseUUIDPipe({ version: '4' })) assetId: string,
@Query() query: FieldFindingActQueryDto,
@CurrentAuth() principal: AuthPrincipal,
): Promise<unknown> {
return this.fieldFindings.options(visitId, assetId, principal);
return this.fieldFindings.options(visitId, assetId, query.actId, principal);
}
@Get(':assetId')
@@ -32,9 +35,10 @@ export class FieldFindingsController {
list(
@Param('visitId', new ParseUUIDPipe({ version: '4' })) visitId: string,
@Param('assetId', new ParseUUIDPipe({ version: '4' })) assetId: string,
@Query() query: FieldFindingActQueryDto,
@CurrentAuth() principal: AuthPrincipal,
): Promise<unknown> {
return this.fieldFindings.list(visitId, assetId, principal);
return this.fieldFindings.list(visitId, assetId, query.actId, principal);
}
@Post(':assetId')
@@ -36,9 +36,15 @@ export class FieldFindingsService {
private readonly findings: InspectionFindingsService,
) {}
async options(visitId: string, assetId: string, principal: AuthPrincipal) {
async options(
visitId: string,
assetId: string,
actId: string | undefined,
principal: AuthPrincipal,
) {
const gate = await this.requireGate(visitId, assetId, principal);
const act = await this.requireDraftAct(visitId);
const act = await this.requireDraftAct(visitId, actId);
const assetIncludedInAct = await this.actContainsAsset(act.id, assetId);
const [catalog, findings] = await Promise.all([
this.catalog.listApplicableForAsset(assetId, {}),
this.findings.listForAct(act.id),
@@ -48,21 +54,30 @@ export class FieldFindingsService {
context: gate.context,
act,
capture: gate.capture,
assetIncludedInAct,
catalog,
findings: findings.data.filter((finding) => finding.assetId === assetId),
canAddAnother: true,
canAddAnother: assetIncludedInAct,
actSelectionMode: actId ? 'EXPLICIT' : 'LEGACY_SINGLE_DRAFT',
};
}
async list(visitId: string, assetId: string, principal: AuthPrincipal) {
async list(
visitId: string,
assetId: string,
actId: string | undefined,
principal: AuthPrincipal,
) {
const gate = await this.requireGate(visitId, assetId, principal);
const act = await this.requireDraftAct(visitId);
const act = await this.requireDraftAct(visitId, actId);
const findings = await this.findings.listForAct(act.id);
return {
context: gate.context,
act,
capture: gate.capture,
assetIncludedInAct: await this.actContainsAsset(act.id, assetId),
data: findings.data.filter((finding) => finding.assetId === assetId),
actSelectionMode: actId ? 'EXPLICIT' : 'LEGACY_SINGLE_DRAFT',
};
}
@@ -74,7 +89,15 @@ export class FieldFindingsService {
request: RequestWithContext,
) {
const gate = await this.requireGate(visitId, assetId, principal);
const act = await this.requireDraftAct(visitId);
const act = await this.requireDraftAct(visitId, dto.actId);
if (!await this.actContainsAsset(act.id, assetId)) {
throw new ConflictException({
code: 'FIELD_FINDING_ASSET_NOT_IN_ACT',
message: 'Agregá este Inventario al Acta seleccionada antes de registrar el Hallazgo',
actId: act.id,
assetId,
});
}
const payload: CreateInspectionFindingDto = {
assetId,
catalogItemId: dto.catalogItemId ?? null,
@@ -91,6 +114,7 @@ export class FieldFindingsService {
capture: gate.capture,
finding,
canAddAnother: true,
actSelectionMode: dto.actId ? 'EXPLICIT' : 'LEGACY_SINGLE_DRAFT',
};
}
@@ -268,7 +292,40 @@ export class FieldFindingsService {
};
}
private async requireDraftAct(visitId: string): Promise<DraftActRow> {
private async actContainsAsset(actId: string, assetId: string): Promise<boolean> {
const [row] = await this.dataSource.query(`
SELECT EXISTS (
SELECT 1 FROM inspection_act_assets
WHERE act_id=$1::uuid AND asset_id=$2::uuid AND included=true
) AS included
`, [actId, assetId]) as Array<{ included: boolean }>;
return Boolean(row?.included);
}
private async requireDraftAct(visitId: string, requestedActId?: string): Promise<DraftActRow> {
if (requestedActId) {
const [act] = await this.dataSource.query(`
SELECT id,code,status
FROM inspection_acts
WHERE id=$1::uuid AND visit_id=$2::uuid
`, [requestedActId, visitId]) as DraftActRow[];
if (!act) {
throw new NotFoundException({
code: 'FIELD_FINDING_ACT_NOT_FOUND',
message: 'El Acta seleccionada no pertenece a esta inspección',
});
}
if (act.status !== 'DRAFT') {
throw new ConflictException({
code: 'FIELD_FINDING_ACT_NOT_DRAFT',
message: 'Los Hallazgos nuevos sólo pueden agregarse a un Acta en borrador',
actId: act.id,
actStatus: act.status,
});
}
return act;
}
const rows = await this.dataSource.query(`
SELECT id, code, status
FROM inspection_acts
@@ -0,0 +1,186 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { DataSource } from 'typeorm';
import { administrationAuditContext } from '../administration/common/administration-audit';
import { AuditService } from '../audit/audit.service';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import { AuditAction, InspectionVisitStatus } from '../database/entities';
import { assertMobileInspector } from '../inspection-operations/mobile-inspector-policy';
import type { CloseInspectionVisitDto } from './dto/close-inspection-visit.dto';
import { InspectionVisitsService } from './inspection-visits.service';
@Injectable()
export class InspectionVisitClosureService {
constructor(
private readonly dataSource: DataSource,
private readonly audit: AuditService,
private readonly visits: InspectionVisitsService,
) {}
async close(
id: string,
dto: CloseInspectionVisitDto,
principal: AuthPrincipal,
request: RequestWithContext,
) {
assertMobileInspector(principal);
await this.dataSource.transaction(async (manager) => {
const [visit] = await manager.query(`
SELECT
id,
status,
actual_started_at AS "actualStartedAt"
FROM inspection_visits
WHERE id = $1
FOR UPDATE
`, [id]) as Array<{
id: string;
status: InspectionVisitStatus;
actualStartedAt: Date | null;
}>;
if (!visit) {
throw new NotFoundException({
code: 'INSPECTION_VISIT_NOT_FOUND',
message: 'Inspección no encontrada',
});
}
if (visit.status !== InspectionVisitStatus.IN_PROGRESS) {
throw new ConflictException({
code: 'INSPECTION_VISIT_NOT_IN_PROGRESS',
message: 'La Inspección debe estar en curso para cerrarse',
});
}
if (!principal.permissions.includes('inspections.manage')) {
const [member] = await manager.query(`
SELECT 1 AS found
FROM inspection_visit_members
WHERE visit_id = $1
AND user_id = $2
AND included = true
LIMIT 1
`, [id, principal.userId]) as Array<{ found: number }>;
if (!member) {
throw new ForbiddenException({
code: 'INSPECTION_VISIT_NOT_ASSIGNED',
message: 'La Inspección no está asignada al usuario actual',
});
}
}
const [actState] = await manager.query(`
SELECT
COUNT(*) FILTER (WHERE act.status <> 'CANCELLED')::integer AS total,
COUNT(*) FILTER (WHERE act.status = 'DRAFT')::integer AS drafts,
COUNT(*) FILTER (WHERE act.status = 'READY')::integer AS "pendingSignature",
COUNT(*) FILTER (
WHERE act.status NOT IN ('CLOSED', 'CANCELLED')
)::integer AS "notSealed",
COUNT(*) FILTER (
WHERE act.status = 'CLOSED'
AND (
act.closure_sha256 IS NULL
OR NOT EXISTS (
SELECT 1
FROM inspection_act_signatures signature
WHERE signature.act_id = act.id
AND signature.signer_type = 'INSPECTOR'
AND signature.status = 'SIGNED'
)
OR 1 <> (
SELECT COUNT(*)
FROM inspection_act_signatures signature
WHERE signature.act_id = act.id
AND signature.signer_type = 'COMPANY_RESPONSIBLE'
AND signature.status IN ('SIGNED', 'REFUSED', 'ABSENT')
)
)
)::integer AS "sealedInvalid"
FROM inspection_acts act
WHERE act.visit_id = $1
`, [id]) as Array<{
total: number;
drafts: number;
pendingSignature: number;
notSealed: number;
sealedInvalid: number;
}>;
if (Number(actState?.total ?? 0) < 1) {
throw new ConflictException({
code: 'INSPECTION_VISIT_ACT_REQUIRED',
message: 'La Inspección debe contener al menos un Acta antes de cerrarse',
});
}
if (Number(actState?.drafts ?? 0) > 0) {
throw new ConflictException({
code: 'INSPECTION_VISIT_DRAFT_ACTS_PENDING',
message: 'Finalizá o cancelá todas las Actas en borrador antes de cerrar la Inspección',
});
}
if (Number(actState?.pendingSignature ?? 0) > 0) {
throw new ConflictException({
code: 'INSPECTION_VISIT_COMPANY_OUTCOME_PENDING',
message: 'No se puede cerrar la Inspección mientras exista un Acta pendiente de firma, disidencia, negativa o constancia de ausencia de la empresa',
});
}
if (Number(actState?.notSealed ?? 0) > 0 || Number(actState?.sealedInvalid ?? 0) > 0) {
throw new ConflictException({
code: 'INSPECTION_VISIT_ACTS_NOT_SEALED',
message: 'Todas las Actas deben estar selladas con su evidencia de firma antes de cerrar la Inspección',
});
}
const serverClosedAt = new Date();
const clientClosedAt = new Date(dto.clientClosedAt);
if (!Number.isFinite(clientClosedAt.getTime())) {
throw new BadRequestException({
code: 'INSPECTION_VISIT_INVALID_CLOSE_TIME',
message: 'La fecha de cierre informada por el dispositivo no es válida',
});
}
if (visit.actualStartedAt && clientClosedAt.getTime() < new Date(visit.actualStartedAt).getTime()) {
throw new BadRequestException({
code: 'INSPECTION_VISIT_INVALID_CLOSE_TIME',
message: 'La fecha de cierre del dispositivo no puede ser anterior al inicio de la Inspección',
});
}
if (clientClosedAt.getTime() > serverClosedAt.getTime() + 24 * 60 * 60 * 1000) {
throw new BadRequestException({
code: 'INSPECTION_VISIT_INVALID_DEVICE_TIME',
message: 'La fecha informada por el dispositivo no puede estar más de 24 horas en el futuro',
});
}
await manager.query(`
UPDATE inspection_visits
SET status = 'CLOSED',
actual_closed_at = $2,
updated_by = $3,
updated_at = $2
WHERE id = $1
`, [id, serverClosedAt, principal.userId]);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.INSPECTION_VISIT_STATUS_CHANGED,
entityType: 'inspection_visit',
entityId: id,
beforeData: { status: InspectionVisitStatus.IN_PROGRESS },
afterData: {
status: InspectionVisitStatus.CLOSED,
clientClosedAt: clientClosedAt.toISOString(),
serverClosedAt: serverClosedAt.toISOString(),
allActsSealed: true,
},
metadata: {
closeSource: 'ANDROID',
companyOutcomeRequiredBeforeClose: true,
},
}, manager);
});
return this.visits.getById(id);
}
}
@@ -4,14 +4,15 @@ export async function nextInspectionVisitCode(
manager: EntityManager,
plannedStartAt: Date,
): Promise<string> {
const [yearRow] = (await manager.query(`
SELECT EXTRACT(
YEAR FROM $1::timestamptz AT TIME ZONE 'America/Argentina/Mendoza'
)::integer AS year
`, [plannedStartAt])) as Array<{ year: number }>;
const year = Number(yearRow?.year);
if (!Number.isInteger(year) || year < 2000 || year > 9999) {
throw new Error('Unable to derive inspection visit year');
const [dateRow] = (await manager.query(`
SELECT
EXTRACT(YEAR FROM $1::timestamptz AT TIME ZONE 'America/Argentina/Mendoza')::integer AS year,
TO_CHAR($1::timestamptz AT TIME ZONE 'America/Argentina/Mendoza', 'DD-MM-YY') AS "datePart"
`, [plannedStartAt])) as Array<{ year: number; datePart: string }>;
const year = Number(dateRow?.year);
const datePart = dateRow?.datePart;
if (!Number.isInteger(year) || year < 2000 || year > 9999 || !datePart) {
throw new Error('Unable to derive inspection visit date');
}
await manager.query(
@@ -20,15 +21,25 @@ export async function nextInspectionVisitCode(
);
const [sequenceRow] = (await manager.query(`
SELECT COALESCE(MAX(RIGHT(code, 6)::integer), 0)::integer AS sequence
FROM inspection_visits
WHERE code LIKE $1
AND code ~ ('^INS-' || $2::text || '-[0-9]{6}$')
`, [`INS-${year}-%`, year])) as Array<{ sequence: number }>;
SELECT COALESCE(MAX(candidate.sequence), 0)::integer AS sequence
FROM (
SELECT SUBSTRING(code FROM '^INSP-([0-9]{5})-[0-9]{2}-[0-9]{2}-[0-9]{2}$')::integer AS sequence
FROM inspection_visits
WHERE EXTRACT(
YEAR FROM COALESCE(planned_start_at, created_at) AT TIME ZONE 'America/Argentina/Mendoza'
)::integer = $1
AND code ~ '^INSP-[0-9]{5}-[0-9]{2}-[0-9]{2}-[0-9]{2}$'
UNION ALL
SELECT RIGHT(code, 6)::integer AS sequence
FROM inspection_visits
WHERE code LIKE $2
AND code ~ ('^INS-' || $1::text || '-[0-9]{6}$')
) candidate
`, [year, `INS-${year}-%`])) as Array<{ sequence: number }>;
const sequence = Number(sequenceRow?.sequence ?? 0) + 1;
if (sequence > 999999) {
if (sequence > 99999) {
throw new Error(`Inspection visit sequence exhausted for ${year}`);
}
return `INS-${year}-${String(sequence).padStart(6, '0')}`;
return `INSP-${String(sequence).padStart(5, '0')}-${datePart}`;
}
@@ -13,7 +13,6 @@ import {
import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import { ChangeInspectionVisitStatusDto } from './dto/change-inspection-visit-status.dto';
import { CloseInspectionVisitDto } from './dto/close-inspection-visit.dto';
import { CreateInspectionVisitDto } from './dto/create-inspection-visit.dto';
import { ExcludeInspectionVisitAssetDto } from './dto/exclude-inspection-visit-asset.dto';
@@ -21,11 +20,15 @@ import { ListInspectionVisitsQueryDto } from './dto/list-inspection-visits-query
import { ReplaceInspectionVisitAssetsDto } from './dto/replace-inspection-visit-assets.dto';
import { ReplaceInspectionVisitTeamDto } from './dto/replace-inspection-visit-team.dto';
import { UpdateInspectionVisitDto } from './dto/update-inspection-visit.dto';
import { InspectionVisitClosureService } from './inspection-visit-closure.service';
import { InspectionVisitsService } from './inspection-visits.service';
@Controller('inspection-visits')
export class InspectionVisitsController {
constructor(private readonly visits: InspectionVisitsService) {}
constructor(
private readonly visits: InspectionVisitsService,
private readonly closure: InspectionVisitClosureService,
) {}
@Get()
@RequirePermissions('inspections.read')
@@ -47,9 +50,7 @@ export class InspectionVisitsController {
@Get('planning-context/areas/:areaId/operators')
@RequirePermissions('inspections.read')
planningOperators(
@Param('areaId', new ParseUUIDPipe({ version: '4' })) areaId: string,
) {
planningOperators(@Param('areaId', new ParseUUIDPipe({ version: '4' })) areaId: string) {
return this.visits.listPlanningOperators(areaId);
}
@@ -135,17 +136,6 @@ export class InspectionVisitsController {
return this.visits.replaceTeam(id, dto, principal, request);
}
@Put(':id/status')
@RequirePermissions('inspections.manage')
changeStatus(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Body() dto: ChangeInspectionVisitStatusDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.visits.changeStatus(id, dto, principal, request);
}
@Post(':id/start')
@RequirePermissions('inspections.execute')
start(
@@ -164,6 +154,6 @@ export class InspectionVisitsController {
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.visits.close(id, dto, principal, request);
return this.closure.close(id, dto, principal, request);
}
}
@@ -7,6 +7,7 @@ import { FieldFindingsController } from './field-findings.controller';
import { FieldFindingsService } from './field-findings.service';
import { FieldInventoryController } from './field-inventory.controller';
import { FieldInventoryService } from './field-inventory.service';
import { InspectionVisitClosureService } from './inspection-visit-closure.service';
import { InspectionVisitsController } from './inspection-visits.controller';
import { InspectionVisitsService } from './inspection-visits.service';
@@ -15,6 +16,7 @@ import { InspectionVisitsService } from './inspection-visits.service';
controllers: [InspectionVisitsController, FieldInventoryController, FieldFindingsController],
providers: [
InspectionVisitsService,
InspectionVisitClosureService,
FieldInventoryService,
F3FieldInventoryStructureService,
FieldFindingsService,
@@ -1,16 +0,0 @@
import { IsEnum, IsOptional, IsString, MaxLength } from 'class-validator';
export enum SurveyReviewDecision {
APPROVE = 'APPROVE',
REJECT = 'REJECT',
}
export class ReviewSurveyReportDto {
@IsEnum(SurveyReviewDecision)
decision!: SurveyReviewDecision;
@IsOptional()
@IsString()
@MaxLength(4000)
notes?: string | null;
}
@@ -1,58 +0,0 @@
import { Type } from 'class-transformer';
import {
ArrayMaxSize,
ArrayUnique,
IsArray,
IsEnum,
IsISO8601,
IsNumber,
IsOptional,
IsString,
IsUUID,
Max,
MaxLength,
Min,
} from 'class-validator';
import { SurveyReportOutcome } from '../../database/entities';
export class SaveSurveyReportDto {
@IsOptional()
@IsEnum(SurveyReportOutcome)
outcome?: SurveyReportOutcome | null;
@IsOptional()
@IsISO8601({ strict: true })
observedAt?: string | null;
@IsOptional()
@Type(() => Number)
@IsNumber({ maxDecimalPlaces: 6 })
@Min(-90)
@Max(90)
latitude?: number | null;
@IsOptional()
@Type(() => Number)
@IsNumber({ maxDecimalPlaces: 6 })
@Min(-180)
@Max(180)
longitude?: number | null;
@IsOptional()
@Type(() => Number)
@IsNumber({ maxDecimalPlaces: 3 })
@Min(0)
@Max(100000)
accuracyM?: number | null;
@IsOptional()
@IsString()
@MaxLength(8000)
notes?: string | null;
@IsArray()
@ArrayMaxSize(50)
@ArrayUnique()
@IsUUID('4', { each: true })
mediaIds!: string[];
}
@@ -1,61 +0,0 @@
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Post,
Put,
Req,
} from '@nestjs/common';
import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import { ReviewSurveyReportDto } from './dto/review-survey-report.dto';
import { SaveSurveyReportDto } from './dto/save-survey-report.dto';
import { SurveyExecutionService } from './survey-execution.service';
@Controller('survey-campaign-targets/:targetId/report')
export class SurveyExecutionController {
constructor(private readonly execution: SurveyExecutionService) {}
@Get()
@RequirePermissions('surveys.read_reports')
get(
@Param('targetId', new ParseUUIDPipe({ version: '4' })) targetId: string,
) {
return this.execution.get(targetId);
}
@Put()
@RequirePermissions('surveys.capture')
save(
@Param('targetId', new ParseUUIDPipe({ version: '4' })) targetId: string,
@Body() dto: SaveSurveyReportDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.execution.save(targetId, dto, principal, request);
}
@Post('submit')
@RequirePermissions('surveys.capture')
submit(
@Param('targetId', new ParseUUIDPipe({ version: '4' })) targetId: string,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.execution.submit(targetId, principal, request);
}
@Post('review')
@RequirePermissions('surveys.review')
review(
@Param('targetId', new ParseUUIDPipe({ version: '4' })) targetId: string,
@Body() dto: ReviewSurveyReportDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.execution.review(targetId, dto, principal, request);
}
}
@@ -1,12 +0,0 @@
import { Module } from '@nestjs/common';
import { AssetMasterModule } from '../asset-master/asset-master.module';
import { AuditModule } from '../audit/audit.module';
import { SurveyExecutionController } from './survey-execution.controller';
import { SurveyExecutionService } from './survey-execution.service';
@Module({
imports: [AuditModule, AssetMasterModule],
controllers: [SurveyExecutionController],
providers: [SurveyExecutionService],
})
export class SurveyExecutionModule {}
@@ -1,924 +0,0 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { DataSource, EntityManager } from 'typeorm';
import { administrationAuditContext } from '../administration/common/administration-audit';
import { AssetHistoryService } from '../asset-master/asset-history.service';
import { AuditService } from '../audit/audit.service';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import {
Asset,
AssetDataOrigin,
AssetInformationStatus,
AssetVersionChangeType,
AuditAction,
SurveyCampaign,
SurveyCampaignStatus,
SurveyCampaignTarget,
SurveyReportOutcome,
SurveyReportStatus,
SurveyReportVersionEvent,
SurveyTargetReport,
SurveyTargetStatus,
} from '../database/entities';
import {
SurveyReviewDecision,
type ReviewSurveyReportDto,
} from './dto/review-survey-report.dto';
import type { SaveSurveyReportDto } from './dto/save-survey-report.dto';
interface ExecutionPerson {
id: string;
username: string;
firstName: string;
lastName: string;
}
interface ExecutionContextView {
target: {
id: string;
status: SurveyTargetStatus;
dueAt: Date | null;
instructions: string | null;
assignedUser: ExecutionPerson | null;
};
campaign: {
id: string;
code: string;
name: string;
status: SurveyCampaignStatus;
};
asset: {
id: string;
code: string;
name: string;
typeName: string;
informationStatus: AssetInformationStatus;
currentVersion: number;
};
}
interface SurveyReportView {
id: string;
targetId: string;
outcome: SurveyReportOutcome | null;
status: SurveyReportStatus;
observedAt: Date | null;
latitude: number | null;
longitude: number | null;
accuracyM: number | null;
notes: string | null;
assetVersionAtSubmission: number | null;
submittedAt: Date | null;
submittedBy: ExecutionPerson | null;
reviewedAt: Date | null;
reviewedBy: ExecutionPerson | null;
reviewNotes: string | null;
selectedMediaIds: string[];
createdAt: Date;
updatedAt: Date;
}
interface ReportMediaView {
id: string;
kind: 'PHOTO';
originalName: string;
mimeType: string;
sizeBytes: number;
sha256: string;
title: string | null;
description: string | null;
capturedAt: Date | null;
latitude: number | null;
longitude: number | null;
accuracyM: number | null;
source: string;
createdAt: Date;
selected: boolean;
}
interface ReportVersionView {
id: string;
versionNumber: number;
event: SurveyReportVersionEvent;
snapshot: Record<string, unknown>;
actorUserId: string | null;
actorUsername: string | null;
createdAt: Date;
}
export interface SurveyExecutionView extends ExecutionContextView {
report: SurveyReportView | null;
availableMedia: ReportMediaView[];
versions: ReportVersionView[];
}
function targetNotFound(): NotFoundException {
return new NotFoundException({
code: 'SURVEY_TARGET_NOT_FOUND',
message: 'Objetivo de relevamiento no encontrado',
});
}
function reportNotFound(): NotFoundException {
return new NotFoundException({
code: 'SURVEY_REPORT_NOT_FOUND',
message: 'El objetivo todavía no tiene un informe de campo',
});
}
@Injectable()
export class SurveyExecutionService {
constructor(
private readonly dataSource: DataSource,
private readonly audit: AuditService,
private readonly history: AssetHistoryService,
) {}
async get(targetId: string): Promise<SurveyExecutionView> {
return this.dataSource.transaction((manager) => this.loadView(manager, targetId));
}
async save(
targetId: string,
dto: SaveSurveyReportDto,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<SurveyExecutionView> {
return this.dataSource.transaction(async (manager) => {
const { target, campaign } = await this.lockTargetContext(manager, targetId);
this.assertCaptureAllowed(target, campaign, principal);
const repository = manager.getRepository(SurveyTargetReport);
let report = await this.lockReport(manager, targetId, false);
const before = report ? this.reportAuditView(report) : null;
if (!report) {
report = repository.create({
targetId,
outcome: null,
status: SurveyReportStatus.DRAFT,
observedAt: null,
latitude: null,
longitude: null,
accuracyM: null,
notes: null,
assetVersionAtSubmission: null,
submittedAt: null,
submittedBy: null,
reviewedAt: null,
reviewedBy: null,
reviewNotes: null,
createdBy: principal.userId,
updatedBy: principal.userId,
});
} else if (
report.status === SurveyReportStatus.SUBMITTED ||
report.status === SurveyReportStatus.APPROVED
) {
throw new ConflictException({
code: 'SURVEY_REPORT_LOCKED',
message: 'El informe enviado o aprobado ya no puede editarse',
});
}
const nextLatitude = dto.latitude === undefined
? this.numberOrNull(report.latitude)
: dto.latitude;
const nextLongitude = dto.longitude === undefined
? this.numberOrNull(report.longitude)
: dto.longitude;
const nextAccuracy = dto.accuracyM === undefined
? this.numberOrNull(report.accuracyM)
: dto.accuracyM;
this.assertCoordinatePair(nextLatitude, nextLongitude, nextAccuracy);
await this.validateMedia(manager, target.assetId, dto.mediaIds);
if (dto.outcome !== undefined) report.outcome = dto.outcome;
if (dto.observedAt !== undefined) {
report.observedAt = dto.observedAt ? new Date(dto.observedAt) : null;
}
if (dto.latitude !== undefined) {
report.latitude = dto.latitude == null ? null : String(dto.latitude);
}
if (dto.longitude !== undefined) {
report.longitude = dto.longitude == null ? null : String(dto.longitude);
}
if (dto.accuracyM !== undefined) {
report.accuracyM = dto.accuracyM == null ? null : String(dto.accuracyM);
}
if (dto.notes !== undefined) report.notes = dto.notes?.trim() || null;
if (report.status === SurveyReportStatus.REJECTED) {
report.status = SurveyReportStatus.DRAFT;
report.assetVersionAtSubmission = null;
report.submittedAt = null;
report.submittedBy = null;
report.reviewedAt = null;
report.reviewedBy = null;
report.reviewNotes = null;
}
report.updatedBy = principal.userId;
await repository.save(report);
await this.replaceMediaSelection(manager, report.id, dto.mediaIds, principal.userId);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.SURVEY_REPORT_SAVED,
entityType: 'survey_target_report',
entityId: report.id,
beforeData: before,
afterData: this.reportAuditView(report),
metadata: { targetId, mediaCount: dto.mediaIds.length },
}, manager);
return this.loadView(manager, targetId);
});
}
async submit(
targetId: string,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<SurveyExecutionView> {
return this.dataSource.transaction(async (manager) => {
const { target, campaign } = await this.lockTargetContext(manager, targetId);
this.assertCaptureAllowed(target, campaign, principal);
const report = await this.lockReport(manager, targetId, true);
if (!report) throw reportNotFound();
if (report.status !== SurveyReportStatus.DRAFT) {
throw new ConflictException({
code: 'SURVEY_REPORT_NOT_DRAFT',
message: 'Sólo se puede enviar un informe guardado como borrador',
});
}
await this.assertComplete(manager, report);
const asset = await this.lockAsset(manager, target.assetId);
const beforeAsset = this.assetAuditView(asset);
asset.informationStatus = report.outcome === SurveyReportOutcome.NOT_LOCATED
? AssetInformationStatus.OBSERVED
: AssetInformationStatus.SURVEYED;
asset.dataOrigin = AssetDataOrigin.FIELD_SURVEY;
asset.sourceName = `Relevamiento ${campaign.code}`;
asset.sourceReference = `survey-report:${report.id}`;
asset.sourceObservedAt = report.observedAt;
asset.sourceNotes = report.notes;
asset.provenanceVerifiedAt = null;
asset.provenanceVerifiedBy = null;
asset.provenanceUpdatedAt = new Date();
asset.provenanceUpdatedBy = principal.userId;
asset.updatedBy = principal.userId;
await manager.getRepository(Asset).save(asset);
const assetVersion = await this.history.capture(
manager,
asset.id,
AssetVersionChangeType.PROVENANCE_UPDATED,
principal,
request,
);
asset.currentVersion = assetVersion;
report.status = SurveyReportStatus.SUBMITTED;
report.assetVersionAtSubmission = assetVersion;
report.submittedAt = new Date();
report.submittedBy = principal.userId;
report.reviewedAt = null;
report.reviewedBy = null;
report.reviewNotes = null;
report.updatedBy = principal.userId;
await manager.getRepository(SurveyTargetReport).save(report);
target.status = SurveyTargetStatus.SUBMITTED;
target.updatedBy = principal.userId;
await manager.getRepository(SurveyCampaignTarget).save(target);
campaign.updatedBy = principal.userId;
await manager.getRepository(SurveyCampaign).save(campaign);
const reportVersion = await this.captureReportVersion(
manager,
report,
SurveyReportVersionEvent.SUBMITTED,
principal,
);
await this.recordAssetChange(
manager,
asset,
beforeAsset,
AuditAction.ASSET_PROVENANCE_UPDATED,
principal,
request,
targetId,
assetVersion,
);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.SURVEY_REPORT_SUBMITTED,
entityType: 'survey_target_report',
entityId: report.id,
afterData: this.reportAuditView(report),
metadata: { targetId, assetId: asset.id, assetVersion, reportVersion },
}, manager);
return this.loadView(manager, targetId);
});
}
async review(
targetId: string,
dto: ReviewSurveyReportDto,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<SurveyExecutionView> {
return this.dataSource.transaction(async (manager) => {
const { target, campaign } = await this.lockTargetContext(manager, targetId);
if (campaign.status !== SurveyCampaignStatus.IN_PROGRESS) {
throw new ConflictException({
code: 'SURVEY_CAMPAIGN_NOT_IN_PROGRESS',
message: 'La campaña debe estar en curso para revisar informes',
});
}
const report = await this.lockReport(manager, targetId, true);
if (!report) throw reportNotFound();
if (report.status !== SurveyReportStatus.SUBMITTED || target.status !== SurveyTargetStatus.SUBMITTED) {
throw new ConflictException({
code: 'SURVEY_REPORT_NOT_SUBMITTED',
message: 'El informe no está pendiente de revisión',
});
}
const notes = dto.notes?.trim() || null;
if (dto.decision === SurveyReviewDecision.REJECT && (!notes || notes.length < 10)) {
throw new BadRequestException({
code: 'SURVEY_REJECTION_REASON_REQUIRED',
message: 'Indicá un motivo de rechazo de al menos 10 caracteres',
});
}
const asset = await this.lockAsset(manager, target.assetId);
if (
dto.decision === SurveyReviewDecision.APPROVE &&
asset.currentVersion !== report.assetVersionAtSubmission
) {
throw new ConflictException({
code: 'SURVEY_ASSET_CHANGED_AFTER_SUBMISSION',
message: 'El activo cambió después del envío; rechazá el informe para que sea revisado nuevamente',
});
}
const beforeAsset = this.assetAuditView(asset);
let assetVersion = asset.currentVersion;
if (dto.decision === SurveyReviewDecision.APPROVE) {
if (report.outcome !== SurveyReportOutcome.NOT_LOCATED) {
asset.informationStatus = AssetInformationStatus.VALIDATED;
asset.provenanceVerifiedAt = new Date();
asset.provenanceVerifiedBy = principal.userId;
asset.provenanceUpdatedAt = new Date();
asset.provenanceUpdatedBy = principal.userId;
asset.updatedBy = principal.userId;
await manager.getRepository(Asset).save(asset);
assetVersion = await this.history.capture(
manager,
asset.id,
AssetVersionChangeType.PROVENANCE_VERIFIED,
principal,
request,
);
asset.currentVersion = assetVersion;
await this.recordAssetChange(
manager,
asset,
beforeAsset,
AuditAction.ASSET_PROVENANCE_VERIFIED,
principal,
request,
targetId,
assetVersion,
);
}
report.status = SurveyReportStatus.APPROVED;
target.status = SurveyTargetStatus.COMPLETED;
} else {
const assetStillMatchesSubmission =
asset.currentVersion === report.assetVersionAtSubmission;
const assetNeedsUpdate = assetStillMatchesSubmission && (
asset.informationStatus !== AssetInformationStatus.OBSERVED ||
asset.provenanceVerifiedAt !== null ||
asset.provenanceVerifiedBy !== null
);
if (assetNeedsUpdate) {
asset.informationStatus = AssetInformationStatus.OBSERVED;
asset.provenanceVerifiedAt = null;
asset.provenanceVerifiedBy = null;
asset.provenanceUpdatedAt = new Date();
asset.provenanceUpdatedBy = principal.userId;
asset.updatedBy = principal.userId;
await manager.getRepository(Asset).save(asset);
assetVersion = await this.history.capture(
manager,
asset.id,
AssetVersionChangeType.STATUS_CHANGED,
principal,
request,
);
asset.currentVersion = assetVersion;
await this.recordAssetChange(
manager,
asset,
beforeAsset,
AuditAction.ASSET_INFORMATION_STATUS_CHANGED,
principal,
request,
targetId,
assetVersion,
);
}
report.status = SurveyReportStatus.REJECTED;
target.status = SurveyTargetStatus.IN_PROGRESS;
}
report.reviewedAt = new Date();
report.reviewedBy = principal.userId;
report.reviewNotes = notes;
report.updatedBy = principal.userId;
await manager.getRepository(SurveyTargetReport).save(report);
target.updatedBy = principal.userId;
await manager.getRepository(SurveyCampaignTarget).save(target);
campaign.updatedBy = principal.userId;
await manager.getRepository(SurveyCampaign).save(campaign);
const event = dto.decision === SurveyReviewDecision.APPROVE
? SurveyReportVersionEvent.APPROVED
: SurveyReportVersionEvent.REJECTED;
const reportVersion = await this.captureReportVersion(manager, report, event, principal);
await this.audit.record({
...administrationAuditContext(principal, request),
action: dto.decision === SurveyReviewDecision.APPROVE
? AuditAction.SURVEY_REPORT_APPROVED
: AuditAction.SURVEY_REPORT_REJECTED,
entityType: 'survey_target_report',
entityId: report.id,
afterData: this.reportAuditView(report),
metadata: { targetId, assetId: asset.id, assetVersion, reportVersion },
}, manager);
return this.loadView(manager, targetId);
});
}
private async loadView(manager: EntityManager, targetId: string): Promise<SurveyExecutionView> {
const [context] = (await manager.query(`
SELECT
JSONB_BUILD_OBJECT(
'id', target.id,
'status', target.status,
'dueAt', target.due_at,
'instructions', target.instructions,
'assignedUser', CASE WHEN assignee.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id', assignee.id,
'username', assignee.username,
'firstName', assignee.first_name,
'lastName', assignee.last_name
) END
) AS target,
JSONB_BUILD_OBJECT(
'id', campaign.id,
'code', campaign.code,
'name', campaign.name,
'status', campaign.status
) AS campaign,
JSONB_BUILD_OBJECT(
'id', asset.id,
'code', asset.code,
'name', asset.name,
'typeName', asset_type.name,
'informationStatus', asset.information_status,
'currentVersion', asset.current_version
) AS asset
FROM survey_campaign_targets target
INNER JOIN survey_campaigns campaign ON campaign.id = target.campaign_id
INNER JOIN assets asset ON asset.id = target.asset_id
INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id
LEFT JOIN users assignee ON assignee.id = target.assigned_user_id
WHERE target.id = $1
`, [targetId])) as ExecutionContextView[];
if (!context) throw targetNotFound();
const [report] = (await manager.query(`
SELECT
report.id,
report.target_id AS "targetId",
report.outcome,
report.status,
report.observed_at AS "observedAt",
report.latitude::double precision AS latitude,
report.longitude::double precision AS longitude,
report.accuracy_m::double precision AS "accuracyM",
report.notes,
report.asset_version_at_submission AS "assetVersionAtSubmission",
report.submitted_at AS "submittedAt",
CASE WHEN submitter.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id', submitter.id,
'username', submitter.username,
'firstName', submitter.first_name,
'lastName', submitter.last_name
) END AS "submittedBy",
report.reviewed_at AS "reviewedAt",
CASE WHEN reviewer.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id', reviewer.id,
'username', reviewer.username,
'firstName', reviewer.first_name,
'lastName', reviewer.last_name
) END AS "reviewedBy",
report.review_notes AS "reviewNotes",
COALESCE((
SELECT ARRAY_AGG(link.media_id ORDER BY link.created_at)
FROM survey_target_report_media link
INNER JOIN asset_media media ON media.id = link.media_id
WHERE link.report_id = report.id
AND link.included = true
AND media.deleted_at IS NULL
), ARRAY[]::uuid[]) AS "selectedMediaIds",
report.created_at AS "createdAt",
report.updated_at AS "updatedAt"
FROM survey_target_reports report
LEFT JOIN users submitter ON submitter.id = report.submitted_by
LEFT JOIN users reviewer ON reviewer.id = report.reviewed_by
WHERE report.target_id = $1
`, [targetId])) as SurveyReportView[];
const availableMedia = (await manager.query(`
SELECT
media.id,
media.kind,
media.original_name AS "originalName",
media.mime_type AS "mimeType",
media.size_bytes::double precision AS "sizeBytes",
media.sha256,
media.title,
media.description,
media.captured_at AS "capturedAt",
media.latitude::double precision AS latitude,
media.longitude::double precision AS longitude,
media.accuracy_m::double precision AS "accuracyM",
media.source,
media.created_at AS "createdAt",
EXISTS (
SELECT 1 FROM survey_target_report_media link
WHERE link.report_id = $2 AND link.media_id = media.id AND link.included = true
) AS selected
FROM asset_media media
WHERE media.asset_id = $1
AND media.kind = 'PHOTO'
AND media.deleted_at IS NULL
ORDER BY media.created_at DESC
`, [context.asset.id, report?.id ?? null])) as ReportMediaView[];
const versions = report
? (await manager.query(`
SELECT
version.id,
version.version_number AS "versionNumber",
version.event,
version.snapshot,
version.actor_user_id AS "actorUserId",
version.actor_username AS "actorUsername",
version.created_at AS "createdAt"
FROM survey_target_report_versions version
WHERE version.report_id = $1
ORDER BY version.version_number DESC
`, [report.id])) as ReportVersionView[]
: [];
return { ...context, report: report ?? null, availableMedia, versions };
}
private async lockTargetContext(
manager: EntityManager,
targetId: string,
): Promise<{ target: SurveyCampaignTarget; campaign: SurveyCampaign }> {
const target = await manager.getRepository(SurveyCampaignTarget)
.createQueryBuilder('target')
.where('target.id = :targetId', { targetId })
.setLock('pessimistic_write')
.getOne();
if (!target) throw targetNotFound();
const campaign = await manager.getRepository(SurveyCampaign)
.createQueryBuilder('campaign')
.where('campaign.id = :campaignId', { campaignId: target.campaignId })
.setLock('pessimistic_write')
.getOne();
if (!campaign) throw targetNotFound();
return { target, campaign };
}
private async lockReport(
manager: EntityManager,
targetId: string,
required: boolean,
): Promise<SurveyTargetReport | null> {
const report = await manager.getRepository(SurveyTargetReport)
.createQueryBuilder('report')
.where('report.targetId = :targetId', { targetId })
.setLock('pessimistic_write')
.getOne();
if (!report && required) throw reportNotFound();
return report;
}
private async lockAsset(manager: EntityManager, assetId: string): Promise<Asset> {
const asset = await manager.getRepository(Asset)
.createQueryBuilder('asset')
.where('asset.id = :assetId', { assetId })
.setLock('pessimistic_write')
.getOne();
if (!asset) throw targetNotFound();
return asset;
}
private assertCaptureAllowed(
target: SurveyCampaignTarget,
campaign: SurveyCampaign,
principal: AuthPrincipal,
): void {
if (campaign.status !== SurveyCampaignStatus.IN_PROGRESS) {
throw new ConflictException({
code: 'SURVEY_CAMPAIGN_NOT_IN_PROGRESS',
message: 'La campaña debe estar en curso para capturar el relevamiento',
});
}
if (target.status !== SurveyTargetStatus.IN_PROGRESS) {
throw new ConflictException({
code: 'SURVEY_TARGET_NOT_IN_PROGRESS',
message: 'El objetivo debe estar en ejecución para editar o enviar el informe',
});
}
if (
!principal.permissions.includes('surveys.manage') &&
target.assignedUserId !== principal.userId
) {
throw new ForbiddenException({
code: 'SURVEY_TARGET_NOT_ASSIGNED',
message: 'El objetivo no está asignado al usuario actual',
});
}
}
private assertCoordinatePair(
latitude: number | null | undefined,
longitude: number | null | undefined,
accuracyM: number | null | undefined,
): void {
if ((latitude == null) !== (longitude == null)) {
throw new BadRequestException({
code: 'SURVEY_COORDINATES_INCOMPLETE',
message: 'Latitud y longitud deben informarse juntas',
});
}
if (accuracyM != null && latitude == null) {
throw new BadRequestException({
code: 'SURVEY_ACCURACY_WITHOUT_LOCATION',
message: 'La precisión requiere coordenadas GPS',
});
}
}
private async validateMedia(
manager: EntityManager,
assetId: string,
mediaIds: string[],
): Promise<void> {
if (mediaIds.length === 0) return;
const [row] = (await manager.query(`
SELECT COUNT(*)::integer AS count
FROM asset_media
WHERE asset_id = $1
AND id = ANY($2::uuid[])
AND kind = 'PHOTO'
AND deleted_at IS NULL
`, [assetId, mediaIds])) as Array<{ count: number }>;
if (Number(row?.count ?? 0) !== mediaIds.length) {
throw new BadRequestException({
code: 'SURVEY_MEDIA_INVALID',
message: 'Una o más evidencias no son fotografías activas del activo relevado',
});
}
}
private async replaceMediaSelection(
manager: EntityManager,
reportId: string,
mediaIds: string[],
userId: string,
): Promise<void> {
await manager.query(`
UPDATE survey_target_report_media
SET included = false, updated_at = CURRENT_TIMESTAMP
WHERE report_id = $1 AND included = true
`, [reportId]);
for (const mediaId of mediaIds) {
await manager.query(`
INSERT INTO survey_target_report_media (
report_id, media_id, included, added_by
) VALUES ($1, $2, true, $3)
ON CONFLICT (report_id, media_id) DO UPDATE SET
included = true,
added_by = EXCLUDED.added_by,
updated_at = CURRENT_TIMESTAMP
`, [reportId, mediaId, userId]);
}
}
private async assertComplete(
manager: EntityManager,
report: SurveyTargetReport,
): Promise<void> {
if (!report.outcome) {
throw new BadRequestException({
code: 'SURVEY_OUTCOME_REQUIRED',
message: 'Seleccioná el resultado de la verificación',
});
}
if (!report.observedAt) {
throw new BadRequestException({
code: 'SURVEY_OBSERVED_AT_REQUIRED',
message: 'Indicá la fecha y hora de observación',
});
}
if (report.latitude == null || report.longitude == null || report.accuracyM == null) {
throw new BadRequestException({
code: 'SURVEY_GPS_REQUIRED',
message: 'Capturá ubicación y precisión GPS antes de enviar',
});
}
if (
report.outcome !== SurveyReportOutcome.CONFIRMED &&
(!report.notes || report.notes.trim().length < 10)
) {
throw new BadRequestException({
code: 'SURVEY_NOTES_REQUIRED',
message: 'Describí los cambios o la imposibilidad de localizar el activo',
});
}
const [row] = (await manager.query(`
SELECT COUNT(*)::integer AS count
FROM survey_target_report_media link
INNER JOIN asset_media media ON media.id = link.media_id
WHERE link.report_id = $1
AND link.included = true
AND media.kind = 'PHOTO'
AND media.deleted_at IS NULL
`, [report.id])) as Array<{ count: number }>;
if (Number(row?.count ?? 0) < 1) {
throw new BadRequestException({
code: 'SURVEY_PHOTO_REQUIRED',
message: 'Seleccioná al menos una fotografía como evidencia',
});
}
}
private async captureReportVersion(
manager: EntityManager,
report: SurveyTargetReport,
event: SurveyReportVersionEvent,
principal: AuthPrincipal,
): Promise<number> {
const [row] = (await manager.query(`
SELECT COALESCE(MAX(version_number), 0)::integer + 1 AS "versionNumber"
FROM survey_target_report_versions
WHERE report_id = $1
`, [report.id])) as Array<{ versionNumber: number }>;
const versionNumber = Number(row?.versionNumber ?? 1);
const snapshot = await this.buildReportSnapshot(manager, report.id);
await manager.query(`
INSERT INTO survey_target_report_versions (
report_id, version_number, event, snapshot, actor_user_id, actor_username
) VALUES ($1, $2, $3, $4, $5, $6)
`, [report.id, versionNumber, event, snapshot, principal.userId, principal.username]);
return versionNumber;
}
private async buildReportSnapshot(
manager: EntityManager,
reportId: string,
): Promise<Record<string, unknown>> {
const [row] = (await manager.query(`
SELECT JSONB_BUILD_OBJECT(
'id', report.id,
'status', report.status,
'outcome', report.outcome,
'observedAt', report.observed_at,
'location', JSONB_BUILD_OBJECT(
'latitude', report.latitude,
'longitude', report.longitude,
'accuracyM', report.accuracy_m
),
'notes', report.notes,
'assetVersionAtSubmission', report.asset_version_at_submission,
'submittedAt', report.submitted_at,
'submittedBy', report.submitted_by,
'reviewedAt', report.reviewed_at,
'reviewedBy', report.reviewed_by,
'reviewNotes', report.review_notes,
'target', JSONB_BUILD_OBJECT(
'id', target.id,
'status', target.status,
'dueAt', target.due_at,
'instructions', target.instructions
),
'campaign', JSONB_BUILD_OBJECT(
'id', campaign.id,
'code', campaign.code,
'name', campaign.name
),
'asset', JSONB_BUILD_OBJECT(
'id', asset.id,
'code', asset.code,
'name', asset.name,
'informationStatus', asset.information_status,
'currentVersion', asset.current_version
),
'evidence', COALESCE((
SELECT JSONB_AGG(JSONB_BUILD_OBJECT(
'id', media.id,
'originalName', media.original_name,
'mimeType', media.mime_type,
'sizeBytes', media.size_bytes,
'sha256', media.sha256,
'title', media.title,
'description', media.description,
'capturedAt', media.captured_at,
'latitude', media.latitude,
'longitude', media.longitude,
'accuracyM', media.accuracy_m,
'source', media.source,
'createdAt', media.created_at
) ORDER BY media.created_at, media.id)
FROM survey_target_report_media link
INNER JOIN asset_media media ON media.id = link.media_id
WHERE link.report_id = report.id AND link.included = true
), '[]'::jsonb)
) AS snapshot
FROM survey_target_reports report
INNER JOIN survey_campaign_targets target ON target.id = report.target_id
INNER JOIN survey_campaigns campaign ON campaign.id = target.campaign_id
INNER JOIN assets asset ON asset.id = target.asset_id
WHERE report.id = $1
`, [reportId])) as Array<{ snapshot: Record<string, unknown> }>;
if (!row) throw reportNotFound();
return row.snapshot;
}
private async recordAssetChange(
manager: EntityManager,
asset: Asset,
beforeData: Record<string, unknown>,
action: AuditAction,
principal: AuthPrincipal,
request: RequestWithContext,
targetId: string,
versionNumber: number,
): Promise<void> {
await this.audit.record({
...administrationAuditContext(principal, request),
action,
entityType: 'asset',
entityId: asset.id,
beforeData,
afterData: this.assetAuditView(asset),
metadata: { targetId, versionNumber, source: 'survey_report' },
}, manager);
}
private assetAuditView(asset: Asset): Record<string, unknown> {
return {
informationStatus: asset.informationStatus,
dataOrigin: asset.dataOrigin,
sourceName: asset.sourceName,
sourceReference: asset.sourceReference,
sourceObservedAt: asset.sourceObservedAt,
provenanceVerifiedAt: asset.provenanceVerifiedAt,
provenanceVerifiedBy: asset.provenanceVerifiedBy,
currentVersion: asset.currentVersion,
};
}
private reportAuditView(report: SurveyTargetReport): Record<string, unknown> {
return {
targetId: report.targetId,
outcome: report.outcome,
status: report.status,
observedAt: report.observedAt,
latitude: this.numberOrNull(report.latitude),
longitude: this.numberOrNull(report.longitude),
accuracyM: this.numberOrNull(report.accuracyM),
notes: report.notes,
assetVersionAtSubmission: report.assetVersionAtSubmission,
submittedAt: report.submittedAt,
submittedBy: report.submittedBy,
reviewedAt: report.reviewedAt,
reviewedBy: report.reviewedBy,
reviewNotes: report.reviewNotes,
};
}
private numberOrNull(value: string | number | null): number | null {
return value == null ? null : Number(value);
}
}
@@ -1,23 +0,0 @@
import { Transform } from 'class-transformer';
import { IsISO8601, IsOptional, IsString, IsUUID, MaxLength } from 'class-validator';
export class AddSurveyTargetDto {
@IsUUID('4')
assetId!: string;
@IsOptional()
@IsUUID('4')
assignedUserId?: string | null;
@IsOptional()
@IsISO8601({ strict: true })
dueAt?: string | null;
@IsOptional()
@Transform(({ value }) =>
typeof value === 'string' && value.trim() ? value.trim() : null,
)
@IsString()
@MaxLength(4000)
instructions?: string | null;
}
@@ -1,7 +0,0 @@
import { IsOptional, IsUUID } from 'class-validator';
export class AssignSurveyTargetDto {
@IsOptional()
@IsUUID('4')
assignedUserId!: string | null;
}
@@ -1,7 +0,0 @@
import { IsEnum } from 'class-validator';
import { SurveyCampaignStatus } from '../../database/entities';
export class ChangeSurveyCampaignStatusDto {
@IsEnum(SurveyCampaignStatus)
status!: SurveyCampaignStatus;
}
@@ -1,7 +0,0 @@
import { IsEnum } from 'class-validator';
import { SurveyTargetStatus } from '../../database/entities';
export class ChangeSurveyTargetStatusDto {
@IsEnum(SurveyTargetStatus)
status!: SurveyTargetStatus;
}
@@ -1,51 +0,0 @@
import { Transform } from 'class-transformer';
import {
IsISO8601,
IsOptional,
IsString,
IsUUID,
Matches,
MaxLength,
MinLength,
} from 'class-validator';
export class CreateSurveyCampaignDto {
@Transform(({ value }) =>
typeof value === 'string' ? value.trim().toUpperCase() : value,
)
@IsString()
@MinLength(1)
@MaxLength(80)
@Matches(/^[A-Z0-9][A-Z0-9._/-]*$/)
code!: string;
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString()
@MinLength(1)
@MaxLength(200)
name!: string;
@IsOptional()
@Transform(({ value }) =>
typeof value === 'string' && value.trim() ? value.trim() : null,
)
@IsString()
@MaxLength(4000)
description?: string | null;
@IsOptional()
@IsISO8601({ strict: true })
plannedStartAt?: string | null;
@IsOptional()
@IsISO8601({ strict: true })
plannedEndAt?: string | null;
@IsOptional()
@IsUUID('4')
scopeAssetId?: string | null;
@IsOptional()
@IsUUID('4')
coordinatorUserId?: string | null;
}
@@ -1,40 +0,0 @@
import { Type } from 'class-transformer';
import {
IsEnum,
IsInt,
IsOptional,
IsString,
IsUUID,
Max,
MaxLength,
Min,
} from 'class-validator';
import { SurveyCampaignStatus } from '../../database/entities';
export class ListSurveyCampaignsQueryDto {
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page = 1;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
pageSize = 25;
@IsOptional()
@IsString()
@MaxLength(200)
search?: string;
@IsOptional()
@IsEnum(SurveyCampaignStatus)
status?: SurveyCampaignStatus;
@IsOptional()
@IsUUID('4')
coordinatorUserId?: string;
}
@@ -1,53 +0,0 @@
import { Transform } from 'class-transformer';
import {
IsISO8601,
IsOptional,
IsString,
IsUUID,
Matches,
MaxLength,
MinLength,
} from 'class-validator';
export class UpdateSurveyCampaignDto {
@IsOptional()
@Transform(({ value }) =>
typeof value === 'string' ? value.trim().toUpperCase() : value,
)
@IsString()
@MinLength(1)
@MaxLength(80)
@Matches(/^[A-Z0-9][A-Z0-9._/-]*$/)
code?: string;
@IsOptional()
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString()
@MinLength(1)
@MaxLength(200)
name?: string;
@IsOptional()
@Transform(({ value }) =>
typeof value === 'string' && value.trim() ? value.trim() : null,
)
@IsString()
@MaxLength(4000)
description?: string | null;
@IsOptional()
@IsISO8601({ strict: true })
plannedStartAt?: string | null;
@IsOptional()
@IsISO8601({ strict: true })
plannedEndAt?: string | null;
@IsOptional()
@IsUUID('4')
scopeAssetId?: string | null;
@IsOptional()
@IsUUID('4')
coordinatorUserId?: string | null;
}
@@ -1,16 +0,0 @@
import { Transform } from 'class-transformer';
import { IsISO8601, IsOptional, IsString, MaxLength } from 'class-validator';
export class UpdateSurveyTargetDto {
@IsOptional()
@IsISO8601({ strict: true })
dueAt?: string | null;
@IsOptional()
@Transform(({ value }) =>
typeof value === 'string' && value.trim() ? value.trim() : null,
)
@IsString()
@MaxLength(4000)
instructions?: string | null;
}
@@ -1,131 +0,0 @@
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Patch,
Post,
Put,
Query,
Req,
} from '@nestjs/common';
import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
import type {
AuthPrincipal,
RequestWithContext,
} from '../common/http/request-context';
import { AddSurveyTargetDto } from './dto/add-survey-target.dto';
import { AssignSurveyTargetDto } from './dto/assign-survey-target.dto';
import { ChangeSurveyCampaignStatusDto } from './dto/change-survey-campaign-status.dto';
import { ChangeSurveyTargetStatusDto } from './dto/change-survey-target-status.dto';
import { CreateSurveyCampaignDto } from './dto/create-survey-campaign.dto';
import { ListSurveyCampaignsQueryDto } from './dto/list-survey-campaigns-query.dto';
import { UpdateSurveyCampaignDto } from './dto/update-survey-campaign.dto';
import { UpdateSurveyTargetDto } from './dto/update-survey-target.dto';
import { SurveyPlanningService } from './survey-planning.service';
@Controller('survey-campaigns')
export class SurveyCampaignsController {
constructor(private readonly planning: SurveyPlanningService) {}
@Get()
@RequirePermissions('surveys.read')
list(@Query() query: ListSurveyCampaignsQueryDto) {
return this.planning.list(query);
}
@Get('assignees')
@RequirePermissions('surveys.assign')
assignees() {
return this.planning.listAssignees();
}
@Post()
@RequirePermissions('surveys.manage')
create(
@Body() dto: CreateSurveyCampaignDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.planning.create(dto, principal, request);
}
@Get(':id')
@RequirePermissions('surveys.read')
get(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
return this.planning.getById(id);
}
@Patch(':id')
@RequirePermissions('surveys.manage')
update(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Body() dto: UpdateSurveyCampaignDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.planning.update(id, dto, principal, request);
}
@Put(':id/status')
@RequirePermissions('surveys.manage')
changeStatus(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Body() dto: ChangeSurveyCampaignStatusDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.planning.changeCampaignStatus(id, dto, principal, request);
}
@Post(':id/targets')
@RequirePermissions('surveys.manage')
addTarget(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Body() dto: AddSurveyTargetDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.planning.addTarget(id, dto, principal, request);
}
}
@Controller('survey-campaign-targets')
export class SurveyCampaignTargetsController {
constructor(private readonly planning: SurveyPlanningService) {}
@Patch(':id')
@RequirePermissions('surveys.manage')
update(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Body() dto: UpdateSurveyTargetDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.planning.updateTarget(id, dto, principal, request);
}
@Put(':id/assignment')
@RequirePermissions('surveys.assign')
assign(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Body() dto: AssignSurveyTargetDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.planning.assignTarget(id, dto, principal, request);
}
@Put(':id/status')
@RequirePermissions('surveys.execute')
changeStatus(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Body() dto: ChangeSurveyTargetStatusDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.planning.changeTargetStatus(id, dto, principal, request);
}
}
@@ -1,14 +0,0 @@
import { Module } from '@nestjs/common';
import { AuditModule } from '../audit/audit.module';
import {
SurveyCampaignsController,
SurveyCampaignTargetsController,
} from './survey-campaigns.controller';
import { SurveyPlanningService } from './survey-planning.service';
@Module({
imports: [AuditModule],
controllers: [SurveyCampaignsController, SurveyCampaignTargetsController],
providers: [SurveyPlanningService],
})
export class SurveyPlanningModule {}
@@ -1,791 +0,0 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { DataSource, EntityManager } from 'typeorm';
import { administrationAuditContext, isUniqueViolation } from '../administration/common/administration-audit';
import { AuditService } from '../audit/audit.service';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import {
AuditAction,
SurveyCampaign,
SurveyCampaignStatus,
SurveyCampaignTarget,
SurveyTargetStatus,
} from '../database/entities';
import type { AddSurveyTargetDto } from './dto/add-survey-target.dto';
import type { AssignSurveyTargetDto } from './dto/assign-survey-target.dto';
import type { ChangeSurveyCampaignStatusDto } from './dto/change-survey-campaign-status.dto';
import type { ChangeSurveyTargetStatusDto } from './dto/change-survey-target-status.dto';
import type { CreateSurveyCampaignDto } from './dto/create-survey-campaign.dto';
import type { ListSurveyCampaignsQueryDto } from './dto/list-survey-campaigns-query.dto';
import type { UpdateSurveyCampaignDto } from './dto/update-survey-campaign.dto';
import type { UpdateSurveyTargetDto } from './dto/update-survey-target.dto';
export interface PersonSummary {
id: string;
username: string;
firstName: string;
lastName: string;
}
interface AssetSummary {
id: string;
code: string;
name: string;
}
export interface SurveyCampaignListItem {
id: string;
code: string;
name: string;
description: string | null;
status: SurveyCampaignStatus;
plannedStartAt: Date | null;
plannedEndAt: Date | null;
scopeAsset: AssetSummary | null;
coordinator: PersonSummary | null;
targetCount: number;
pendingCount: number;
inProgressCount: number;
submittedCount: number;
completedCount: number;
skippedCount: number;
createdAt: Date;
updatedAt: Date;
}
export interface SurveyTargetView {
id: string;
campaignId: string;
asset: AssetSummary & { typeName: string };
assignedUser: PersonSummary | null;
status: SurveyTargetStatus;
dueAt: Date | null;
instructions: string | null;
createdAt: Date;
updatedAt: Date;
}
export interface SurveyCampaignView extends SurveyCampaignListItem {
targets: SurveyTargetView[];
}
const campaignTransitions: Record<SurveyCampaignStatus, SurveyCampaignStatus[]> = {
[SurveyCampaignStatus.DRAFT]: [SurveyCampaignStatus.PLANNED, SurveyCampaignStatus.CANCELLED],
[SurveyCampaignStatus.PLANNED]: [
SurveyCampaignStatus.DRAFT,
SurveyCampaignStatus.IN_PROGRESS,
SurveyCampaignStatus.CANCELLED,
],
[SurveyCampaignStatus.IN_PROGRESS]: [
SurveyCampaignStatus.COMPLETED,
SurveyCampaignStatus.CANCELLED,
],
[SurveyCampaignStatus.COMPLETED]: [],
[SurveyCampaignStatus.CANCELLED]: [],
};
const targetTransitions: Record<SurveyTargetStatus, SurveyTargetStatus[]> = {
[SurveyTargetStatus.PENDING]: [SurveyTargetStatus.IN_PROGRESS, SurveyTargetStatus.SKIPPED],
[SurveyTargetStatus.IN_PROGRESS]: [
SurveyTargetStatus.PENDING,
SurveyTargetStatus.SKIPPED,
],
[SurveyTargetStatus.SUBMITTED]: [],
[SurveyTargetStatus.COMPLETED]: [],
[SurveyTargetStatus.SKIPPED]: [],
};
function campaignNotFound(): NotFoundException {
return new NotFoundException({
code: 'SURVEY_CAMPAIGN_NOT_FOUND',
message: 'Campaña de relevamiento no encontrada',
});
}
function targetNotFound(): NotFoundException {
return new NotFoundException({
code: 'SURVEY_TARGET_NOT_FOUND',
message: 'Objetivo de relevamiento no encontrado',
});
}
function dateValue(value: string | null | undefined): Date | null {
return value ? new Date(value) : null;
}
@Injectable()
export class SurveyPlanningService {
constructor(
private readonly dataSource: DataSource,
private readonly audit: AuditService,
) {}
async list(query: ListSurveyCampaignsQueryDto) {
const conditions: string[] = [];
const parameters: unknown[] = [];
const addParameter = (value: unknown) => {
parameters.push(value);
return `$${parameters.length}`;
};
if (query.search?.trim()) {
const search = addParameter(`%${query.search.trim()}%`);
conditions.push(`(campaign.code ILIKE ${search} OR campaign.name ILIKE ${search})`);
}
if (query.status) conditions.push(`campaign.status = ${addParameter(query.status)}`);
if (query.coordinatorUserId) {
conditions.push(`campaign.coordinator_user_id = ${addParameter(query.coordinatorUserId)}`);
}
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
const [countRow] = (await this.dataSource.query(
`SELECT COUNT(*)::integer AS total FROM survey_campaigns campaign ${where}`,
parameters,
)) as Array<{ total: number }>;
const total = Number(countRow?.total ?? 0);
const offset = (query.page - 1) * query.pageSize;
const limitParameter = addParameter(query.pageSize);
const offsetParameter = addParameter(offset);
const data = (await this.dataSource.query(
`${this.campaignSelect(where)}
ORDER BY campaign.updated_at DESC, campaign.code ASC
LIMIT ${limitParameter} OFFSET ${offsetParameter}`,
parameters,
)) as SurveyCampaignListItem[];
return {
data,
meta: {
page: query.page,
pageSize: query.pageSize,
total,
totalPages: total === 0 ? 0 : Math.ceil(total / query.pageSize),
},
};
}
async listAssignees(): Promise<{ data: PersonSummary[] }> {
const data = (await this.dataSource.query(`
SELECT DISTINCT
user_account.id,
user_account.username,
user_account.first_name AS "firstName",
user_account.last_name AS "lastName"
FROM users user_account
INNER JOIN user_roles user_role ON user_role.user_id = user_account.id
INNER JOIN role_permissions role_permission ON role_permission.role_id = user_role.role_id
INNER JOIN permissions permission ON permission.id = role_permission.permission_id
WHERE user_account.status = 'ACTIVE'
AND permission.code = 'surveys.execute'
ORDER BY user_account.last_name, user_account.first_name, user_account.username
`)) as PersonSummary[];
return { data };
}
async getById(id: string): Promise<SurveyCampaignView> {
return this.dataSource.transaction((manager) => this.loadCampaignView(manager, id));
}
async create(
dto: CreateSurveyCampaignDto,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<SurveyCampaignView> {
try {
return await this.dataSource.transaction(async (manager) => {
await this.validateDates(dto.plannedStartAt ?? null, dto.plannedEndAt ?? null);
await this.requireAsset(manager, dto.scopeAssetId ?? null);
await this.requireActiveUser(manager, dto.coordinatorUserId ?? null);
const campaign = manager.getRepository(SurveyCampaign).create({
code: dto.code,
name: dto.name,
description: dto.description ?? null,
status: SurveyCampaignStatus.DRAFT,
plannedStartAt: dateValue(dto.plannedStartAt),
plannedEndAt: dateValue(dto.plannedEndAt),
scopeAssetId: dto.scopeAssetId ?? null,
coordinatorUserId: dto.coordinatorUserId ?? null,
createdBy: principal.userId,
updatedBy: principal.userId,
});
await manager.getRepository(SurveyCampaign).save(campaign);
const created = await this.loadCampaignView(manager, campaign.id);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.SURVEY_CAMPAIGN_CREATED,
entityType: 'survey_campaign',
entityId: campaign.id,
afterData: this.auditCampaign(created),
}, manager);
return created;
});
} catch (error) {
if (isUniqueViolation(error)) throw this.campaignConflict();
throw error;
}
}
async update(
id: string,
dto: UpdateSurveyCampaignDto,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<SurveyCampaignView> {
if (Object.keys(dto).length === 0) {
throw new BadRequestException({ code: 'NO_CHANGES', message: 'No se recibieron cambios' });
}
try {
return await this.dataSource.transaction(async (manager) => {
const campaign = await this.lockCampaign(manager, id);
this.assertCampaignEditable(campaign);
const before = await this.loadCampaignView(manager, id);
const nextStart = dto.plannedStartAt === undefined
? campaign.plannedStartAt
: dateValue(dto.plannedStartAt);
const nextEnd = dto.plannedEndAt === undefined
? campaign.plannedEndAt
: dateValue(dto.plannedEndAt);
await this.validateDates(nextStart, nextEnd);
if (dto.scopeAssetId !== undefined) {
await this.requireAsset(manager, dto.scopeAssetId);
await this.assertCampaignTargetsInScope(manager, id, dto.scopeAssetId);
}
if (dto.coordinatorUserId !== undefined) {
await this.requireActiveUser(manager, dto.coordinatorUserId);
}
if (dto.code !== undefined) campaign.code = dto.code;
if (dto.name !== undefined) campaign.name = dto.name;
if (dto.description !== undefined) campaign.description = dto.description;
if (dto.plannedStartAt !== undefined) campaign.plannedStartAt = nextStart;
if (dto.plannedEndAt !== undefined) campaign.plannedEndAt = nextEnd;
if (dto.scopeAssetId !== undefined) campaign.scopeAssetId = dto.scopeAssetId;
if (dto.coordinatorUserId !== undefined) {
campaign.coordinatorUserId = dto.coordinatorUserId;
}
campaign.updatedBy = principal.userId;
await manager.getRepository(SurveyCampaign).save(campaign);
const updated = await this.loadCampaignView(manager, id);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.SURVEY_CAMPAIGN_UPDATED,
entityType: 'survey_campaign',
entityId: id,
beforeData: this.auditCampaign(before),
afterData: this.auditCampaign(updated),
}, manager);
return updated;
});
} catch (error) {
if (isUniqueViolation(error)) throw this.campaignConflict();
throw error;
}
}
async changeCampaignStatus(
id: string,
dto: ChangeSurveyCampaignStatusDto,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<SurveyCampaignView> {
return this.dataSource.transaction(async (manager) => {
const campaign = await this.lockCampaign(manager, id);
if (campaign.status === dto.status) return this.loadCampaignView(manager, id);
if (!campaignTransitions[campaign.status].includes(dto.status)) {
throw new ConflictException({
code: 'SURVEY_CAMPAIGN_INVALID_TRANSITION',
message: `No se puede pasar la campaña de ${campaign.status} a ${dto.status}`,
});
}
const [counts] = (await manager.query(`
SELECT
COUNT(*)::integer AS total,
COUNT(*) FILTER (WHERE status IN ('PENDING', 'IN_PROGRESS', 'SUBMITTED'))::integer AS outstanding
FROM survey_campaign_targets WHERE campaign_id = $1
`, [id])) as Array<{ total: number; outstanding: number }>;
if (dto.status === SurveyCampaignStatus.IN_PROGRESS && Number(counts?.total ?? 0) === 0) {
throw new ConflictException({
code: 'SURVEY_CAMPAIGN_EMPTY',
message: 'Agregá al menos un activo antes de iniciar la campaña',
});
}
if (
dto.status === SurveyCampaignStatus.COMPLETED &&
Number(counts?.outstanding ?? 0) > 0
) {
throw new ConflictException({
code: 'SURVEY_CAMPAIGN_HAS_OPEN_TARGETS',
message: 'Todos los objetivos deben estar completados u omitidos',
});
}
const beforeStatus = campaign.status;
campaign.status = dto.status;
campaign.updatedBy = principal.userId;
await manager.getRepository(SurveyCampaign).save(campaign);
const updated = await this.loadCampaignView(manager, id);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.SURVEY_CAMPAIGN_STATUS_CHANGED,
entityType: 'survey_campaign',
entityId: id,
beforeData: { status: beforeStatus },
afterData: { status: dto.status },
}, manager);
return updated;
});
}
async addTarget(
campaignId: string,
dto: AddSurveyTargetDto,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<SurveyCampaignView> {
try {
return await this.dataSource.transaction(async (manager) => {
const campaign = await this.lockCampaign(manager, campaignId);
this.assertCampaignEditable(campaign);
await this.requireAsset(manager, dto.assetId);
await this.assertAssetInScope(manager, dto.assetId, campaign.scopeAssetId);
await this.requireAssignee(manager, dto.assignedUserId ?? null);
const target = manager.getRepository(SurveyCampaignTarget).create({
campaignId,
assetId: dto.assetId,
assignedUserId: dto.assignedUserId ?? null,
status: SurveyTargetStatus.PENDING,
dueAt: dateValue(dto.dueAt),
instructions: dto.instructions ?? null,
createdBy: principal.userId,
updatedBy: principal.userId,
});
await manager.getRepository(SurveyCampaignTarget).save(target);
campaign.updatedBy = principal.userId;
await manager.getRepository(SurveyCampaign).save(campaign);
const targetView = await this.loadTargetView(manager, target.id);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.SURVEY_TARGET_ADDED,
entityType: 'survey_campaign_target',
entityId: target.id,
afterData: targetView as unknown as Record<string, unknown>,
metadata: { campaignId },
}, manager);
return this.loadCampaignView(manager, campaignId);
});
} catch (error) {
if (isUniqueViolation(error)) {
throw new ConflictException({
code: 'SURVEY_TARGET_ALREADY_EXISTS',
message: 'El activo ya forma parte de esta campaña',
});
}
throw error;
}
}
async updateTarget(
id: string,
dto: UpdateSurveyTargetDto,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<SurveyCampaignView> {
if (Object.keys(dto).length === 0) {
throw new BadRequestException({ code: 'NO_CHANGES', message: 'No se recibieron cambios' });
}
return this.dataSource.transaction(async (manager) => {
const target = await this.lockTarget(manager, id);
const campaign = await this.lockCampaign(manager, target.campaignId);
this.assertCampaignEditable(campaign);
this.assertTargetPlanningEditable(target);
const before = await this.loadTargetView(manager, id);
if (dto.dueAt !== undefined) target.dueAt = dateValue(dto.dueAt);
if (dto.instructions !== undefined) target.instructions = dto.instructions;
target.updatedBy = principal.userId;
await manager.getRepository(SurveyCampaignTarget).save(target);
campaign.updatedBy = principal.userId;
await manager.getRepository(SurveyCampaign).save(campaign);
const updated = await this.loadTargetView(manager, id);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.SURVEY_TARGET_UPDATED,
entityType: 'survey_campaign_target',
entityId: id,
beforeData: before as unknown as Record<string, unknown>,
afterData: updated as unknown as Record<string, unknown>,
metadata: { campaignId: target.campaignId },
}, manager);
return this.loadCampaignView(manager, target.campaignId);
});
}
async assignTarget(
id: string,
dto: AssignSurveyTargetDto,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<SurveyCampaignView> {
return this.dataSource.transaction(async (manager) => {
const target = await this.lockTarget(manager, id);
const campaign = await this.lockCampaign(manager, target.campaignId);
this.assertCampaignEditable(campaign);
this.assertTargetPlanningEditable(target);
await this.requireAssignee(manager, dto.assignedUserId);
const beforeUserId = target.assignedUserId;
if (beforeUserId === dto.assignedUserId) {
return this.loadCampaignView(manager, target.campaignId);
}
target.assignedUserId = dto.assignedUserId;
target.updatedBy = principal.userId;
await manager.getRepository(SurveyCampaignTarget).save(target);
campaign.updatedBy = principal.userId;
await manager.getRepository(SurveyCampaign).save(campaign);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.SURVEY_TARGET_ASSIGNED,
entityType: 'survey_campaign_target',
entityId: id,
beforeData: { assignedUserId: beforeUserId },
afterData: { assignedUserId: dto.assignedUserId },
metadata: { campaignId: target.campaignId },
}, manager);
return this.loadCampaignView(manager, target.campaignId);
});
}
async changeTargetStatus(
id: string,
dto: ChangeSurveyTargetStatusDto,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<SurveyCampaignView> {
return this.dataSource.transaction(async (manager) => {
const target = await this.lockTarget(manager, id);
const campaign = await this.lockCampaign(manager, target.campaignId);
const planningOmission =
principal.permissions.includes('surveys.manage') &&
[SurveyCampaignStatus.DRAFT, SurveyCampaignStatus.PLANNED].includes(campaign.status) &&
(
(target.status === SurveyTargetStatus.PENDING && dto.status === SurveyTargetStatus.SKIPPED) ||
(target.status === SurveyTargetStatus.SKIPPED && dto.status === SurveyTargetStatus.PENDING)
);
if (campaign.status !== SurveyCampaignStatus.IN_PROGRESS && !planningOmission) {
throw new ConflictException({
code: 'SURVEY_CAMPAIGN_NOT_IN_PROGRESS',
message: 'La campaña debe estar en curso para registrar avance; durante la planificación sólo se puede omitir o restaurar un objetivo',
});
}
if (
!principal.permissions.includes('surveys.manage') &&
target.assignedUserId !== principal.userId
) {
throw new ForbiddenException({
code: 'SURVEY_TARGET_NOT_ASSIGNED',
message: 'El objetivo no está asignado al usuario actual',
});
}
if (target.status === dto.status) return this.loadCampaignView(manager, target.campaignId);
if (!planningOmission && !targetTransitions[target.status].includes(dto.status)) {
throw new ConflictException({
code: 'SURVEY_TARGET_INVALID_TRANSITION',
message: `No se puede pasar el objetivo de ${target.status} a ${dto.status}`,
});
}
const beforeStatus = target.status;
target.status = dto.status;
target.updatedBy = principal.userId;
await manager.getRepository(SurveyCampaignTarget).save(target);
campaign.updatedBy = principal.userId;
await manager.getRepository(SurveyCampaign).save(campaign);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.SURVEY_TARGET_STATUS_CHANGED,
entityType: 'survey_campaign_target',
entityId: id,
beforeData: { status: beforeStatus },
afterData: { status: dto.status },
metadata: { campaignId: target.campaignId },
}, manager);
return this.loadCampaignView(manager, target.campaignId);
});
}
private campaignSelect(where: string): string {
return `
SELECT
campaign.id,
campaign.code,
campaign.name,
campaign.description,
campaign.status,
campaign.planned_start_at AS "plannedStartAt",
campaign.planned_end_at AS "plannedEndAt",
CASE WHEN scope_asset.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id', scope_asset.id, 'code', scope_asset.code, 'name', scope_asset.name
) END AS "scopeAsset",
CASE WHEN coordinator.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id', coordinator.id,
'username', coordinator.username,
'firstName', coordinator.first_name,
'lastName', coordinator.last_name
) END AS coordinator,
COALESCE(target_counts.total, 0)::integer AS "targetCount",
COALESCE(target_counts.pending, 0)::integer AS "pendingCount",
COALESCE(target_counts.in_progress, 0)::integer AS "inProgressCount",
COALESCE(target_counts.submitted, 0)::integer AS "submittedCount",
COALESCE(target_counts.completed, 0)::integer AS "completedCount",
COALESCE(target_counts.skipped, 0)::integer AS "skippedCount",
campaign.created_at AS "createdAt",
campaign.updated_at AS "updatedAt"
FROM survey_campaigns campaign
LEFT JOIN assets scope_asset ON scope_asset.id = campaign.scope_asset_id
LEFT JOIN users coordinator ON coordinator.id = campaign.coordinator_user_id
LEFT JOIN LATERAL (
SELECT
COUNT(*) AS total,
COUNT(*) FILTER (WHERE target.status = 'PENDING') AS pending,
COUNT(*) FILTER (WHERE target.status = 'IN_PROGRESS') AS in_progress,
COUNT(*) FILTER (WHERE target.status = 'SUBMITTED') AS submitted,
COUNT(*) FILTER (WHERE target.status = 'COMPLETED') AS completed,
COUNT(*) FILTER (WHERE target.status = 'SKIPPED') AS skipped
FROM survey_campaign_targets target
WHERE target.campaign_id = campaign.id
) target_counts ON true
${where}
`;
}
private async loadCampaignView(
manager: EntityManager,
id: string,
): Promise<SurveyCampaignView> {
const [campaign] = (await manager.query(
this.campaignSelect('WHERE campaign.id = $1'),
[id],
)) as SurveyCampaignListItem[];
if (!campaign) throw campaignNotFound();
const targets = (await manager.query(`
${this.targetSelect()}
WHERE target.campaign_id = $1
ORDER BY
CASE target.status
WHEN 'SUBMITTED' THEN 1 WHEN 'IN_PROGRESS' THEN 2 WHEN 'PENDING' THEN 3
WHEN 'COMPLETED' THEN 4 ELSE 5
END,
target.due_at NULLS LAST,
asset.code
`, [id])) as SurveyTargetView[];
return { ...campaign, targets };
}
private async loadTargetView(manager: EntityManager, id: string): Promise<SurveyTargetView> {
const [target] = (await manager.query(`
${this.targetSelect()} WHERE target.id = $1
`, [id])) as SurveyTargetView[];
if (!target) throw targetNotFound();
return target;
}
private targetSelect(): string {
return `
SELECT
target.id,
target.campaign_id AS "campaignId",
JSONB_BUILD_OBJECT(
'id', asset.id,
'code', asset.code,
'name', asset.name,
'typeName', asset_type.name
) AS asset,
CASE WHEN assignee.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id', assignee.id,
'username', assignee.username,
'firstName', assignee.first_name,
'lastName', assignee.last_name
) END AS "assignedUser",
target.status,
target.due_at AS "dueAt",
target.instructions,
target.created_at AS "createdAt",
target.updated_at AS "updatedAt"
FROM survey_campaign_targets target
INNER JOIN assets asset ON asset.id = target.asset_id
INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id
LEFT JOIN users assignee ON assignee.id = target.assigned_user_id
`;
}
private async lockCampaign(manager: EntityManager, id: string): Promise<SurveyCampaign> {
const campaign = await manager.getRepository(SurveyCampaign)
.createQueryBuilder('campaign')
.where('campaign.id = :id', { id })
.setLock('pessimistic_write')
.getOne();
if (!campaign) throw campaignNotFound();
return campaign;
}
private async lockTarget(manager: EntityManager, id: string): Promise<SurveyCampaignTarget> {
const target = await manager.getRepository(SurveyCampaignTarget)
.createQueryBuilder('target')
.where('target.id = :id', { id })
.setLock('pessimistic_write')
.getOne();
if (!target) throw targetNotFound();
return target;
}
private async requireAsset(manager: EntityManager, id: string | null): Promise<void> {
if (!id) return;
const rows = await manager.query(`SELECT 1 FROM assets WHERE id = $1`, [id]) as unknown[];
if (rows.length === 0) {
throw new BadRequestException({
code: 'SURVEY_ASSET_NOT_FOUND',
message: 'El activo seleccionado no existe',
});
}
}
private async requireActiveUser(manager: EntityManager, id: string | null): Promise<void> {
if (!id) return;
const rows = await manager.query(
`SELECT 1 FROM users WHERE id = $1 AND status = 'ACTIVE'`,
[id],
) as unknown[];
if (rows.length === 0) {
throw new BadRequestException({
code: 'SURVEY_USER_NOT_ACTIVE',
message: 'El usuario seleccionado no existe o está inactivo',
});
}
}
private async requireAssignee(manager: EntityManager, id: string | null): Promise<void> {
if (!id) return;
const rows = await manager.query(`
SELECT 1
FROM users user_account
WHERE user_account.id = $1
AND user_account.status = 'ACTIVE'
AND EXISTS (
SELECT 1
FROM user_roles user_role
INNER JOIN role_permissions role_permission ON role_permission.role_id = user_role.role_id
INNER JOIN permissions permission ON permission.id = role_permission.permission_id
WHERE user_role.user_id = user_account.id
AND permission.code = 'surveys.execute'
)
`, [id]) as unknown[];
if (rows.length === 0) {
throw new BadRequestException({
code: 'SURVEY_ASSIGNEE_INVALID',
message: 'El responsable debe ser un usuario activo con permiso de ejecución',
});
}
}
private async assertAssetInScope(
manager: EntityManager,
assetId: string,
scopeAssetId: string | null,
): Promise<void> {
if (!scopeAssetId) return;
const rows = await manager.query(`
WITH RECURSIVE ancestors AS (
SELECT id, parent_id FROM assets WHERE id = $1
UNION ALL
SELECT parent.id, parent.parent_id
FROM assets parent
INNER JOIN ancestors child ON child.parent_id = parent.id
)
SELECT 1 FROM ancestors WHERE id = $2
`, [assetId, scopeAssetId]) as unknown[];
if (rows.length === 0) {
throw new BadRequestException({
code: 'SURVEY_TARGET_OUTSIDE_SCOPE',
message: 'El activo no pertenece al alcance jerárquico de la campaña',
});
}
}
private async assertCampaignTargetsInScope(
manager: EntityManager,
campaignId: string,
scopeAssetId: string | null,
): Promise<void> {
if (!scopeAssetId) return;
const [row] = (await manager.query(`
SELECT COUNT(*)::integer AS outside
FROM survey_campaign_targets target
WHERE target.campaign_id = $1
AND NOT EXISTS (
WITH RECURSIVE ancestors AS (
SELECT id, parent_id FROM assets WHERE id = target.asset_id
UNION ALL
SELECT parent.id, parent.parent_id
FROM assets parent
INNER JOIN ancestors child ON child.parent_id = parent.id
)
SELECT 1 FROM ancestors WHERE id = $2
)
`, [campaignId, scopeAssetId])) as Array<{ outside: number }>;
if (Number(row?.outside ?? 0) > 0) {
throw new ConflictException({
code: 'SURVEY_SCOPE_EXCLUDES_TARGETS',
message: 'El nuevo alcance dejaría objetivos existentes fuera de la campaña',
});
}
}
private async validateDates(
start: string | Date | null,
end: string | Date | null,
): Promise<void> {
if (start && end && new Date(end).getTime() < new Date(start).getTime()) {
throw new BadRequestException({
code: 'SURVEY_INVALID_DATES',
message: 'La fecha de finalización no puede ser anterior al inicio',
});
}
}
private assertCampaignEditable(campaign: SurveyCampaign): void {
if (
campaign.status === SurveyCampaignStatus.COMPLETED ||
campaign.status === SurveyCampaignStatus.CANCELLED
) {
throw new ConflictException({
code: 'SURVEY_CAMPAIGN_CLOSED',
message: 'Una campaña completada o cancelada ya no puede modificarse',
});
}
}
private assertTargetPlanningEditable(target: SurveyCampaignTarget): void {
if (
target.status === SurveyTargetStatus.SUBMITTED ||
target.status === SurveyTargetStatus.COMPLETED
) {
throw new ConflictException({
code: 'SURVEY_TARGET_PLANNING_LOCKED',
message: 'Un objetivo enviado o completado ya no puede replanificarse',
});
}
}
private campaignConflict(): ConflictException {
return new ConflictException({
code: 'SURVEY_CAMPAIGN_CODE_EXISTS',
message: 'Ya existe una campaña con ese código',
});
}
private auditCampaign(campaign: SurveyCampaignView): Record<string, unknown> {
const { targets: _targets, ...summary } = campaign;
return summary as unknown as Record<string, unknown>;
}
}
+2 -2
View File
@@ -1,2 +1,2 @@
export const API_VERSION = '0.24.0-1';
export const API_PHASE = 'F3.1';
export const API_VERSION = '0.25.0-1';
export const API_PHASE = 'F3.2';
@@ -0,0 +1,42 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
const read = (path: string) => readFileSync(path, 'utf8');
test('F3.2 mantiene múltiples Actas con un solo borrador simultáneo por inspección', () => {
const migration = read('src/database/migrations/1789495200000-phase-f1-1-multi-act-inspections.ts');
assert.match(migration, /DROP CONSTRAINT IF EXISTS uq_inspection_acts_visit/);
assert.match(migration, /CREATE UNIQUE INDEX uq_inspection_acts_one_draft_per_visit/);
assert.match(migration, /WHERE status = 'DRAFT'/);
});
test('F3.2 permite seleccionar el Acta explícitamente al trabajar Hallazgos desde la APK', () => {
const controller = read('src/inspection-visits/field-findings.controller.ts');
const service = read('src/inspection-visits/field-findings.service.ts');
const dto = read('src/inspection-visits/dto/create-field-finding.dto.ts');
assert.match(controller, /FieldFindingActQueryDto/);
assert.match(controller, /query\.actId/);
assert.match(dto, /actId\?: string/);
assert.match(service, /actSelectionMode: actId \? 'EXPLICIT' : 'LEGACY_SINGLE_DRAFT'/);
assert.match(service, /WHERE id=\$1::uuid AND visit_id=\$2::uuid/);
assert.match(service, /FIELD_FINDING_ACT_NOT_DRAFT/);
assert.match(service, /FIELD_FINDING_ASSET_NOT_IN_ACT/);
});
test('F3.2 conserva el cierre documental inmutable de cada Acta', () => {
const closing = read('src/inspection-closing/inspection-closing.service.ts');
assert.match(closing, /INSPECTION_ACT_INSPECTOR_SIGNATURE_REQUIRED/);
assert.match(closing, /INSPECTION_ACT_COMPANY_OUTCOME_REQUIRED/);
assert.match(closing, /finalSha256 = sha256CanonicalJson\(finalSnapshot\)/);
assert.match(closing, /status = 'CLOSED'/);
assert.match(closing, /ensureFrozenReport/);
assert.match(closing, /ensureWordForAct/);
});
test('F3.2 cierra la inspección sólo cuando no quedan borradores ni Actas sin firma de inspector', () => {
const visits = read('src/inspection-visits/inspection-visits.service.ts');
assert.match(visits, /INSPECTION_VISIT_DRAFT_ACTS_PENDING/);
assert.match(visits, /INSPECTION_VISIT_INSPECTOR_SIGNATURE_PENDING/);
assert.match(visits, /actsMayCompleteCompanySignatureLater: true/);
});
@@ -2,6 +2,7 @@ import 'reflect-metadata';
import assert from 'node:assert/strict';
import test from 'node:test';
import { REQUIRED_PERMISSIONS_KEY } from '../../src/authorization/decorators/require-permissions.decorator';
import { InspectionActLifecycleController } from '../../src/inspection-closing/inspection-act-lifecycle.controller';
import {
InspectionClosingController,
InspectionSignatureContentController,
@@ -12,11 +13,12 @@ function permissionFor(controller: object, method: string): string[] {
return Reflect.getMetadata(REQUIRED_PERMISSIONS_KEY, handler) as string[];
}
test('D5 closing endpoints separate read, prepare, sign and close permissions', () => {
test('F4 closing endpoints separate responsible capture, immutable lock, signing and sealing', () => {
assert.deepEqual(permissionFor(InspectionClosingController.prototype, 'get'), ['inspection_closure.read']);
assert.deepEqual(permissionFor(InspectionClosingController.prototype, 'responsible'), ['inspection_closure.prepare']);
assert.deepEqual(permissionFor(InspectionClosingController.prototype, 'ready'), ['inspection_closure.prepare']);
assert.deepEqual(permissionFor(InspectionClosingController.prototype, 'reopen'), ['inspection_closure.prepare']);
assert.deepEqual(permissionFor(InspectionActLifecycleController.prototype, 'lock'), ['inspection_closure.prepare']);
assert.equal('ready' in InspectionClosingController.prototype, false);
assert.equal('reopen' in InspectionClosingController.prototype, false);
assert.deepEqual(permissionFor(InspectionClosingController.prototype, 'inspectorSignature'), ['inspection_closure.sign']);
assert.deepEqual(permissionFor(InspectionClosingController.prototype, 'companySignature'), ['inspection_closure.sign']);
assert.deepEqual(permissionFor(InspectionClosingController.prototype, 'companyOutcome'), ['inspection_closure.sign']);
@@ -10,7 +10,7 @@ function permissionFor(method: string): string[] {
return Reflect.getMetadata(REQUIRED_PERMISSIONS_KEY, handler) as string[];
}
test('inspection visit endpoints separate reading, planning, assignment and execution', () => {
test('inspection visit endpoints separate reading, planning, assignment and explicit execution', () => {
assert.deepEqual(permissionFor('list'), ['inspections.read']);
assert.deepEqual(permissionFor('get'), ['inspections.read']);
assert.deepEqual(permissionFor('assignees'), ['inspections.assign']);
@@ -18,7 +18,7 @@ test('inspection visit endpoints separate reading, planning, assignment and exec
assert.deepEqual(permissionFor('update'), ['inspections.manage']);
assert.deepEqual(permissionFor('replaceAssets'), ['inspections.manage']);
assert.deepEqual(permissionFor('replaceTeam'), ['inspections.assign']);
assert.deepEqual(permissionFor('changeStatus'), ['inspections.manage']);
assert.equal('changeStatus' in InspectionVisitsController.prototype, false);
assert.deepEqual(permissionFor('start'), ['inspections.execute']);
assert.deepEqual(permissionFor('close'), ['inspections.execute']);
});
@@ -6,11 +6,13 @@ import test from 'node:test';
const root = process.cwd();
const read = (relative: string) => fs.readFileSync(path.join(root, relative), 'utf8');
test('D5.6.4 generates inspection codes server-side with an annual transactional sequence', () => {
test('F4 keeps server-side annual numbering but emits the institutional INSP-date format', () => {
const generator = read('src/inspection-visits/inspection-visit-code.ts');
assert.match(generator, /INS-\$\{year\}-\$\{String\(sequence\)\.padStart\(6, '0'\)\}/);
assert.match(generator, /INSP-\$\{String\(sequence\)\.padStart\(5, '0'\)\}-\$\{datePart\}/);
assert.match(generator, /DD-MM-YY/);
assert.match(generator, /pg_advisory_xact_lock/);
assert.match(generator, /America\/Argentina\/Mendoza/);
assert.match(generator, /INS-\$\{year\}-%/);
});
test('D5.6.4 creates one inspection from Area Operator start and lead inspector without manual title or end date', () => {
@@ -0,0 +1,30 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
const root = process.cwd();
const read = (relative: string) => fs.readFileSync(path.join(root, relative), 'utf8');
test('F4 locks an Act without requiring an artificial Finding', () => {
const service = read('src/inspection-closing/inspection-act-lifecycle.service.ts');
assert.doesNotMatch(service, /INSPECTION_ACT_FINDING_REQUIRED/);
assert.doesNotMatch(service, /COUNT\(\*\).*inspection_findings[\s\S]*< 1/);
assert.match(service, /requireVerificationResults/);
});
test('F4 freezes urgency and the exact deadline policy at lock time', () => {
const service = read('src/inspection-closing/inspection-act-lifecycle.service.ts');
assert.match(service, /snapshotForLock/);
assert.match(service, /deadline_policy_snapshot/);
assert.match(service, /locked_sha256/);
assert.match(service, /LOCKED_PENDING_SIGNATURE/);
});
test('F4 removes the reopen route once an Act has been blocked', () => {
const controller = read('src/inspection-closing/inspection-closing.controller.ts');
assert.doesNotMatch(controller, /@Post\('reopen'\)/);
assert.doesNotMatch(controller, /@Post\('ready'\)/);
const lifecycle = read('src/inspection-closing/inspection-act-lifecycle.controller.ts');
assert.match(lifecycle, /@Post\('lock'\)/);
});
@@ -0,0 +1,15 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
const root = process.cwd();
const read = (relative: string) => fs.readFileSync(path.join(root, relative), 'utf8');
test('F4 grants deadline administration to system admin and the current JEFE-equivalent supervisor role', () => {
const migration = read('src/database/migrations/1790038800000-phase-f4-deadline-administration.ts');
assert.match(migration, /inspection_deadlines\.manage/);
assert.match(migration, /\('admin', 'inspection_deadlines\.manage'\)/);
assert.match(migration, /\('supervisor', 'inspection_deadlines\.manage'\)/);
assert.doesNotMatch(migration, /\('inspector', 'inspection_deadlines\.manage'\)/);
});
@@ -0,0 +1,43 @@
import 'reflect-metadata';
import assert from 'node:assert/strict';
import test from 'node:test';
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { REQUIRED_PERMISSIONS_KEY } from '../../src/authorization/decorators/require-permissions.decorator';
import { InspectionDeadlineDayType } from '../../src/database/entities';
import { UpdateInspectionDeadlinePolicyDto } from '../../src/inspection-deadlines/dto/update-inspection-deadline-policy.dto';
import { InspectionDeadlinesController } from '../../src/inspection-deadlines/inspection-deadlines.controller';
function permissions(method: string): string[] {
const handler = InspectionDeadlinesController.prototype[
method as keyof InspectionDeadlinesController
];
return Reflect.getMetadata(REQUIRED_PERMISSIONS_KEY, handler) as string[];
}
test('F4 deadline administration is protected by one dedicated JEFE permission', () => {
assert.deepEqual(permissions('policies'), ['inspection_deadlines.manage']);
assert.deepEqual(permissions('updatePolicy'), ['inspection_deadlines.manage']);
assert.deepEqual(permissions('nonWorkingDays'), ['inspection_deadlines.manage']);
assert.deepEqual(permissions('upsertNonWorkingDay'), ['inspection_deadlines.manage']);
});
test('F4 deadline policy accepts business or calendar days with a bounded amount', async () => {
const valid = plainToInstance(UpdateInspectionDeadlinePolicyDto, {
days: 5,
dayType: InspectionDeadlineDayType.BUSINESS,
});
assert.equal((await validate(valid)).length, 0);
const calendar = plainToInstance(UpdateInspectionDeadlinePolicyDto, {
days: 10,
dayType: InspectionDeadlineDayType.CALENDAR,
});
assert.equal((await validate(calendar)).length, 0);
const invalid = plainToInstance(UpdateInspectionDeadlinePolicyDto, {
days: 0,
dayType: 'UNKNOWN',
});
assert.ok((await validate(invalid)).length >= 2);
});
@@ -0,0 +1,36 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
const root = process.cwd();
const read = (relative: string) => fs.readFileSync(path.join(root, relative), 'utf8');
test('F4 foundation snapshots Act deadlines and seeds the institutional 5/10 business-day policy', () => {
const migration = read('src/database/migrations/1790035200000-phase-f4-document-flow-foundation.ts');
assert.match(migration, /deadline_policy_snapshot jsonb/);
assert.match(migration, /'URGENT', 5, 'BUSINESS', 'ACT_DATE'/);
assert.match(migration, /'NON_URGENT', 10, 'BUSINESS', 'GEDO_LOAD_DATE'/);
assert.match(migration, /inspection_non_working_days/);
});
test('F4 foundation models recurrence as a new Finding linked to its antecedent', () => {
const finding = read('src/database/entities/inspection-finding.entity.ts');
const migration = read('src/database/migrations/1790035200000-phase-f4-document-flow-foundation.ts');
assert.match(finding, /isRecurrence!/);
assert.match(finding, /antecedentFindingId!/);
assert.match(migration, /fk_inspection_findings_antecedent/);
assert.match(migration, /chk_inspection_findings_recurrence_link/);
});
test('F4 foundation separates editable INF content, GEDO IF officialization and report-level follow-up', () => {
const report = read('src/database/entities/inspection-report.entity.ts');
const migration = read('src/database/migrations/1790035200000-phase-f4-document-flow-foundation.ts');
assert.match(report, /executiveSummary!/);
assert.match(report, /description!/);
assert.match(report, /gedoIfIdentifier!/);
assert.match(report, /gedoOfficializedOn!/);
assert.match(migration, /inspection_report_follow_ups/);
assert.match(migration, /COMPANY_NOTE/);
assert.doesNotMatch(migration, /finding_id uuid NOT NULL[\s\S]*inspection_report_follow_ups/);
});
@@ -0,0 +1,24 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
const root = process.cwd();
const read = (relative: string) => fs.readFileSync(path.join(root, relative), 'utf8');
test('F4 institutional numbering keeps Inspection, Act and INF date-readable and coherent', () => {
const migration = read('src/database/migrations/1790046000000-phase-f4-document-numbering.ts');
assert.match(migration, /INSP-/);
assert.match(migration, /ACT-/);
assert.match(migration, /INF-/);
assert.match(migration, /DD-MM-YY/);
assert.match(migration, /LPAD\(act_number::text, 5, '0'\)/);
});
test('F4 gives one INF the same annual correlativo as its one Act', () => {
const migration = read('src/database/migrations/1790046000000-phase-f4-document-numbering.ts');
assert.match(migration, /report_number = act\.act_number/);
assert.match(migration, /NEW\.report_number := source_act\.act_number/);
assert.match(migration, /NEW\.report_year := source_act\.act_year/);
assert.match(migration, /trg_dhv2_f4_set_report_code/);
});
@@ -0,0 +1,28 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
const root = process.cwd();
const read = (relative: string) => fs.readFileSync(path.join(root, relative), 'utf8');
test('F4 forbids closing an Inspection while any Act still awaits company outcome', () => {
const service = read('src/inspection-visits/inspection-visit-closure.service.ts');
assert.match(service, /INSPECTION_VISIT_COMPANY_OUTCOME_PENDING/);
assert.match(service, /signature\.status IN \('SIGNED', 'REFUSED', 'ABSENT'\)/);
assert.match(service, /recipient|signature|empresa/i);
});
test('F4 requires all non-cancelled Acts to be sealed before closing the Inspection', () => {
const service = read('src/inspection-visits/inspection-visit-closure.service.ts');
assert.match(service, /INSPECTION_VISIT_ACTS_NOT_SEALED/);
assert.match(service, /act\.closure_sha256 IS NULL/);
assert.match(service, /allActsSealed: true/);
});
test('F4 stamps sealed_at when an Act reaches the physical CLOSED state', () => {
const migration = read('src/database/migrations/1790056800000-phase-f4-sealed-acts.ts');
assert.match(migration, /sealed_at/);
assert.match(migration, /dhv2_f4_stamp_act_seal/);
assert.match(migration, /NEW\.status = 'CLOSED'/);
});
@@ -0,0 +1,39 @@
import 'reflect-metadata';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import { REQUIRED_PERMISSIONS_KEY } from '../../src/authorization/decorators/require-permissions.decorator';
import { InspectionFindingRecurrenceController } from '../../src/inspection-findings/inspection-finding-recurrence.controller';
const root = process.cwd();
const read = (relative: string) => fs.readFileSync(path.join(root, relative), 'utf8');
function permissions(method: string): string[] {
const handler = InspectionFindingRecurrenceController.prototype[
method as keyof InspectionFindingRecurrenceController
];
return Reflect.getMetadata(REQUIRED_PERMISSIONS_KEY, handler) as string[];
}
test('F4 exposes recurrence candidates for the selected Inventory and creates a new Finding', () => {
assert.deepEqual(permissions('candidates'), ['inspection_findings.read']);
assert.deepEqual(permissions('create'), ['inspection_findings.create']);
});
test('F4 recurrence only accepts an unresolved prior Finding from the same Inventory context', () => {
const service = read('src/inspection-findings/inspection-finding-recurrence.service.ts');
assert.match(service, /finding\.status = 'OPEN'/);
assert.match(service, /INSPECTION_FINDING_ANTECEDENT_RESOLVED/);
assert.match(service, /INSPECTION_FINDING_RECURRENCE_ASSET_NOT_IN_ACT/);
assert.match(service, /finding\.act_id <> \$2/);
});
test('F4 creates recurrence append-only as a new Finding linked to its antecedent', () => {
const service = read('src/inspection-findings/inspection-finding-recurrence.service.ts');
assert.match(service, /is_recurrence/);
assert.match(service, /antecedent_finding_id/);
assert.match(service, /INSERT INTO inspection_finding_versions/);
assert.match(service, /recurrence: true/);
assert.doesNotMatch(service, /UPDATE inspection_findings[\s\S]*antecedentFindingId/);
});
@@ -0,0 +1,25 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
const root = process.cwd();
const read = (relative: string) => fs.readFileSync(path.join(root, relative), 'utf8');
test('F4 removes Survey modules from the active API', () => {
const app = read('src/app.module.ts');
assert.doesNotMatch(app, /SurveyPlanningModule/);
assert.doesNotMatch(app, /SurveyExecutionModule/);
const entities = read('src/database/entities/index.ts');
assert.doesNotMatch(entities, /SurveyCampaign|SurveyTargetReport|survey-campaign|survey-target-report/);
});
test('F4 removes survey permissions and survey-specific tables through a new cleanup migration', () => {
const migration = read('src/database/migrations/1790053200000-phase-f4-remove-survey-subsystem.ts');
assert.match(migration, /code LIKE 'surveys\.\%'/);
assert.match(migration, /DROP TABLE IF EXISTS survey_target_report_media/);
assert.match(migration, /DROP TABLE IF EXISTS survey_target_report_versions/);
assert.match(migration, /DROP TABLE IF EXISTS survey_target_reports/);
assert.match(migration, /DROP TABLE IF EXISTS survey_campaign_targets/);
assert.match(migration, /DROP TABLE IF EXISTS survey_campaigns/);
});
@@ -0,0 +1,23 @@
import 'reflect-metadata';
import assert from 'node:assert/strict';
import test from 'node:test';
import { REQUIRED_PERMISSIONS_KEY } from '../../src/authorization/decorators/require-permissions.decorator';
import {
InspectionReportDossierController,
InspectionReportFollowUpFileController,
} from '../../src/inspection-reports/inspection-report-dossier.controller';
function permissions(controller: object, method: string): string[] {
const handler = controller[method as keyof typeof controller];
return Reflect.getMetadata(REQUIRED_PERMISSIONS_KEY, handler) as string[];
}
test('F4 report dossier separates read, edit, GEDO officialization and append-only follow-up', () => {
assert.deepEqual(permissions(InspectionReportDossierController.prototype, 'get'), ['inspection_reports.read']);
assert.deepEqual(permissions(InspectionReportDossierController.prototype, 'updateContent'), ['inspection_reports.edit']);
assert.deepEqual(permissions(InspectionReportDossierController.prototype, 'officializeGedo'), ['inspection_reports.officialize']);
assert.deepEqual(permissions(InspectionReportDossierController.prototype, 'gedoPdf'), ['inspection_reports.read']);
assert.deepEqual(permissions(InspectionReportDossierController.prototype, 'followUps'), ['inspection_reports.read']);
assert.deepEqual(permissions(InspectionReportDossierController.prototype, 'createFollowUp'), ['inspection_reports.follow_up']);
assert.deepEqual(permissions(InspectionReportFollowUpFileController.prototype, 'content'), ['inspection_reports.read']);
});
@@ -0,0 +1,30 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
const root = process.cwd();
const read = (relative: string) => fs.readFileSync(path.join(root, relative), 'utf8');
test('F4 keeps company responses at INF level and never asks for a Finding selector', () => {
const dto = read('src/inspection-reports/dto/create-inspection-report-follow-up.dto.ts');
const service = read('src/inspection-reports/inspection-report-dossier.service.ts');
assert.doesNotMatch(dto, /findingId|findingIds/);
assert.match(service, /InspectionReportFollowUpType\.COMPANY_NOTE/);
assert.doesNotMatch(service, /inspection_findings.*follow_up_id/);
});
test('F4 treats GEDO IF as the legal officialization of the existing INF and starts non-urgent deadline there', () => {
const service = read('src/inspection-reports/inspection-report-dossier.service.ts');
assert.match(service, /gedo_if_identifier/);
assert.match(service, /gedo_officialized_on/);
assert.match(service, /applyGedoOfficialization/);
assert.match(service, /INSPECTION_REPORT_ALREADY_OFFICIALIZED/);
});
test('F4 removes the active Director review controller from the report module', () => {
const moduleSource = read('src/inspection-reports/inspection-reports.module.ts');
assert.doesNotMatch(moduleSource, /InspectionReportReviewController/);
assert.doesNotMatch(moduleSource, /InspectionReportReviewService/);
assert.match(moduleSource, /InspectionReportDossierController/);
});

Some files were not shown because too many files have changed in this diff Show More