diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index b46ee22..6c834ea 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -1,5 +1,5 @@ name: Android CI / RC -# F6.1 presentation barrier: lint + real tests + debug artifact + release compile. +# F6.2 field closing barrier: lint + real tests + debug artifact + release compile. on: push: @@ -109,7 +109,7 @@ jobs: cp android-app/app/build/outputs/apk/debug/app-debug.apk "$apk" sha256sum "$apk" > "${apk}.sha256" { - echo "phase=F6.1" + echo "phase=F6.2" echo "version=$version" echo "versionCode=$code" echo "commit=$GITHUB_SHA" diff --git a/android-app/FINAL_RELEASE_CHECKLIST.md b/android-app/FINAL_RELEASE_CHECKLIST.md index 3cb3e0d..dc44c5b 100644 --- a/android-app/FINAL_RELEASE_CHECKLIST.md +++ b/android-app/FINAL_RELEASE_CHECKLIST.md @@ -1,4 +1,4 @@ -# Checklist final Android 0.19.2 +# Checklist final Android 0.19.3 La candidata sólo se promueve si el mismo SHA cumple: @@ -8,6 +8,10 @@ La candidata sólo se promueve si el mismo SHA cumple: - exigir GPS + fotografía para Inventario nacido en campo antes de Hallazgos; - registrar Hallazgos sobre el elemento seleccionado; - al cerrar el contenido, exigir Urgente/No urgente, persistir esa decisión y mostrar **Pendiente de firma**; +- exigir por cada Acta los datos del **Representante de la empresa**: nombres y apellidos, DNI, cargo/función y email; +- permitir precargar esos datos desde el Acta anterior de la misma Inspección, pero exigir confirmación y firma nueva en cada Acta; +- resolver por Acta **firma en conformidad**, **firma en disconformidad con motivo** o **negativa a firmar con motivo**; +- verificar que la urgencia existe sólo a nivel **Acta** y nunca a nivel Hallazgo; - completar firma del Inspector y manifestación/firma o negativa de la empresa; - mostrar el Acta como **Firmada y cerrada**; - permitir cerrar la Inspección sólo con todas las Actas activas firmadas y cerradas; diff --git a/android-app/RELEASE.md b/android-app/RELEASE.md index 2861bbb..42a9bae 100644 --- a/android-app/RELEASE.md +++ b/android-app/RELEASE.md @@ -1,20 +1,20 @@ -# DH Inspección Android · release final de campo 0.19.2 +# DH Inspección Android · release final de campo 0.19.3 ## Candidata vigente - Fase funcional: **Flujo final de campo · Inspección → Acta → Hallazgos → Firma**. -- `versionName`: **0.19.2**. -- `versionCode`: **30**. +- `versionName`: **0.19.3**. +- `versionCode`: **31**. - Application ID release: `com.korexlabs.dhinspeccion`. - Application ID debug/QA: `com.korexlabs.dhinspeccion.debug`. - API: `https://dhv2.korexlabs.com/api/v3/`. -- Servidor compatible de esta candidata: **API 0.29.0-2 / WEB 0.23.0-2**. +- Servidor compatible de esta candidata: **API 0.29.0-4 / WEB 0.23.0-3**. La variante debug es independiente de la app productiva y puede instalarse para QA/presentación sin sobrescribir una instalación release histórica. ## Procedimiento operativo validado -La APK 0.19.2 fija como recorrido principal de campo: +La APK 0.19.3 fija como recorrido principal de campo: 1. **Iniciar Inspección**. Al iniciarla se habilita el circuito de **Actas y Hallazgos**; Inventario no es una acción independiente de la Inspección. 2. **Crear o abrir un Acta**. Puede haber varias Actas dentro de una misma Inspección, pero sólo una puede permanecer en elaboración al mismo tiempo. La urgencia todavía no se define. @@ -22,9 +22,10 @@ La APK 0.19.2 fija como recorrido principal de campo: 4. Si el elemento ya existe, se lo selecciona desde el Inventario del Área/Yacimiento de la Inspección. 5. Si no existe, se da de alta desde campo sin abandonar el Acta. 6. Un elemento nuevo debe completar **GPS + fotografía** antes de poder recibir Hallazgos. -7. Al terminar el contenido del Acta se elige **Urgente / No urgente** y luego se usa **Cerrar Acta y dejar pendiente de firma**. La urgencia queda fijada con el cierre; desde ese momento el contenido queda inmutable y el estado visible es **Pendiente de firma**. -8. La firma del Inspector y la manifestación/firma o negativa de la empresa completan el Acta, que pasa a **Firmada y cerrada**. -9. La Inspección sólo puede cerrarse cuando todas sus Actas no canceladas están firmadas y cerradas. +7. Antes del cierre se identifican, para esa Acta, los datos del **Representante de la empresa** que acompañó el recorrido: nombres y apellidos, DNI, cargo/función y email. Si coincide con el Acta anterior, la APK puede precargarlos, pero deben confirmarse nuevamente. +8. Al terminar el contenido del Acta se elige **Urgente / No urgente** y luego se usa **Cerrar Acta y dejar pendiente de firma**. La urgencia pertenece al Acta completa, nunca a cada Hallazgo; desde ese momento el contenido queda inmutable y el estado visible es **Pendiente de firma**. +9. La firma del Inspector y la manifestación del representante completan el Acta: **conformidad**, **disconformidad con motivo obligatorio** o **negativa a firmar con motivo obligatorio**. La firma se registra Acta por Acta aunque sea la misma persona. +10. La Inspección sólo puede cerrarse cuando todas sus Actas no canceladas están firmadas y cerradas. La nomenclatura técnica interna de API (`DRAFT`, `LOCKED`, `SEALED`, etc.) no se muestra al inspector: la interfaz usa textos operativos en castellano. @@ -118,7 +119,7 @@ Esta candidata requiere conexión. No implementa trabajo offline ni cola persist - Se agregan siete pruebas para coordenadas y valores técnicos. -## Revisión 0.19.2 · flujo conceptual corregido +## Revisión 0.19.3 · flujo conceptual corregido - Se elimina **Inventario de campo** como acción independiente de la pantalla de Inspección. - El alta o selección de Inventario existe únicamente dentro de **Agregar Hallazgo**. diff --git a/android-app/app/build.gradle.kts b/android-app/app/build.gradle.kts index 323ade8..2a57c6d 100644 --- a/android-app/app/build.gradle.kts +++ b/android-app/app/build.gradle.kts @@ -12,8 +12,8 @@ android { applicationId = "com.korexlabs.dhinspeccion" minSdk = 26 targetSdk = 36 - versionCode = 30 - versionName = "0.19.2" + versionCode = 31 + versionName = "0.19.3" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables.useSupportLibrary = true diff --git a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/MainViewModel.kt b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/MainViewModel.kt index dfb0e71..8b825bd 100644 --- a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/MainViewModel.kt +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/MainViewModel.kt @@ -433,8 +433,10 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { 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." + val normalizedEmail = email?.trim().orEmpty() + val emailValid = Regex("^[^\s@]+@[^\s@]+\.[^\s@]+$").matches(normalizedEmail) + if (fullName.isBlank() || documentNumber.isBlank() || position.isBlank() || !emailValid) { + error = "Completá nombres y apellidos, DNI, cargo/función y un email válido del representante de la empresa." return } launchBusy(mutation = true) { @@ -446,11 +448,11 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { documentType = documentType, documentNumber = documentNumber.trim(), position = position.trim(), - email = email?.trim()?.takeIf { it.isNotBlank() }, + email = normalizedEmail.lowercase(), phone = phone?.trim()?.takeIf { it.isNotBlank() }, ), ) - notice = "Responsable de empresa registrado para el Acta." + notice = "Representante de la empresa registrado para esta Acta." } } @@ -468,7 +470,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { absenceReason = reason.trim(), ), ) - notice = "Ausencia del responsable registrada. La manifestación de empresa quedará pendiente y deberá resolverse antes de firmar y cerrar el Acta." + notice = "Ausencia del representante registrada. La manifestación de la empresa deberá resolverse antes de firmar y cerrar el Acta." } } @@ -482,7 +484,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { actClosure = actsRepository.lock(actId, urgency) refreshSelectedActInternal(actId) val urgencyLabel = if (urgency == "URGENT") "urgente" else "no urgente" - notice = "Acta cerrada como $urgencyLabel y pendiente de firma. Su contenido quedó inmutable." + notice = "Acta cerrada como $urgencyLabel. La urgencia quedó definida sobre el Acta y su contenido quedó inmutable, pendiente de firmas." } } @@ -517,9 +519,9 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { actId, png, latitude, longitude, accuracyM, manifestation, statement, ) notice = if (manifestation == "DISSENT") { - "Firma de empresa registrada con disidencia." + "Firma del representante registrada en disconformidad." } else { - "Firma de empresa registrada." + "Firma del representante de la empresa registrada." } } } diff --git a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/MobileActs.kt b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/MobileActs.kt index 1b060eb..c7c317c 100644 --- a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/MobileActs.kt +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/MobileActs.kt @@ -188,6 +188,7 @@ data class MobileActClosure( val act: MobileActClosureHeader, val visit: MobileVisitClosureHeader, val responsible: MobileResponsible? = null, + val representativeSuggestion: MobileResponsible? = null, val closure: MobileClosureRecord? = null, val signatures: List = emptyList(), val consents: MobileClosureConsents = MobileClosureConsents(), diff --git a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/MobileActsScreen.kt b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/MobileActsScreen.kt index 7a3295b..c8e4432 100644 --- a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/MobileActsScreen.kt +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/MobileActsScreen.kt @@ -57,30 +57,31 @@ fun MobileActsScreen( val visit = model.visit ?: return val selected = model.selectedAct val closure = model.actClosure + val representativeSeed = closure?.responsible ?: closure?.representativeSuggestion val context = LocalContext.current val scope = rememberCoroutineScope() var closingUrgency by rememberSaveable(selected?.id) { mutableStateOf("") } var attendance by rememberSaveable(selected?.id) { - mutableStateOf(closure?.responsible?.attendanceStatus ?: "PRESENT") + mutableStateOf(representativeSeed?.attendanceStatus ?: "PRESENT") } var fullName by rememberSaveable(selected?.id) { - mutableStateOf(closure?.responsible?.fullName.orEmpty()) + mutableStateOf(representativeSeed?.fullName.orEmpty()) } var documentType by rememberSaveable(selected?.id) { - mutableStateOf(closure?.responsible?.documentType ?: "DNI") + mutableStateOf("DNI") } var documentNumber by rememberSaveable(selected?.id) { - mutableStateOf(closure?.responsible?.documentNumber.orEmpty()) + mutableStateOf(representativeSeed?.documentNumber.orEmpty()) } var position by rememberSaveable(selected?.id) { - mutableStateOf(closure?.responsible?.position.orEmpty()) + mutableStateOf(representativeSeed?.position.orEmpty()) } var email by rememberSaveable(selected?.id) { - mutableStateOf(closure?.responsible?.email.orEmpty()) + mutableStateOf(representativeSeed?.email.orEmpty()) } var phone by rememberSaveable(selected?.id) { - mutableStateOf(closure?.responsible?.phone.orEmpty()) + mutableStateOf(representativeSeed?.phone.orEmpty()) } var absenceReason by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.absenceReason.orEmpty()) @@ -225,29 +226,26 @@ fun MobileActsScreen( } HorizontalDivider() - Text("Responsable de la empresa", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + Text("Representante 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(fullName, { fullName = it }, label = { Text("Nombres y apellidos *") }, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(documentNumber, { documentNumber = it.filter(Char::isDigit) }, label = { Text("DNI *") }, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(position, { position = it }, label = { Text("Cargo / función *") }, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(email, { email = it }, label = { Text("Email *") }, modifier = Modifier.fillMaxWidth()) + if (closure?.responsible == null && closure?.representativeSuggestion != null) { + Text("Datos precargados del Acta anterior. Confirmalos para esta Acta.", style = MaterialTheme.typography.bodySmall) } - 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(), + enabled = !model.busy && fullName.isNotBlank() && documentNumber.isNotBlank() && position.isNotBlank() && email.contains("@") && email.contains("."), modifier = Modifier.fillMaxWidth(), - ) { Text("Guardar responsable") } + ) { Text("Guardar representante") } } else { OutlinedTextField( absenceReason, @@ -265,7 +263,7 @@ fun MobileActsScreen( HorizontalDivider() Text("Finalizar contenido", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) - Text("Al cerrar el Acta, el contenido y los Hallazgos quedan inmutables. Definí ahora la urgencia según lo constatado.") + Text("Al cerrar el Acta, el contenido y los Hallazgos quedan inmutables. Definí ahora una única urgencia para el Acta completa; no para cada Hallazgo.") Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { AssistChip( onClick = { closingUrgency = "NON_URGENT" }, @@ -310,7 +308,7 @@ fun MobileActsScreen( Text("Manifestación de la empresa", fontWeight = FontWeight.Bold) if (companyOutcome != null && companyResolved) { val detail = when (companyOutcome.status) { - "SIGNED" -> if (companyOutcome.companyManifestation == "DISSENT") "Firma en disidencia" else "Firma en conformidad" + "SIGNED" -> if (companyOutcome.companyManifestation == "DISSENT") "Firma en disconformidad" else "Firma en conformidad" "REFUSED" -> "Negativa a firmar" else -> companyOutcome.status } @@ -319,26 +317,26 @@ fun MobileActsScreen( companyOutcome.companyStatement?.let { Text(it, style = MaterialTheme.typography.bodySmall) } } else if (closure?.responsible?.attendanceStatus == "ABSENT") { Text( - "El responsable fue registrado como ausente. La ausencia NO resuelve la manifestación: deberá obtenerse firma o negativa posteriormente antes de SELLAR el Acta.", + "El representante fue registrado como ausente. La ausencia NO resuelve la manifestación: deberá obtenerse firma o negativa posteriormente antes de SELLAR el Acta.", style = MaterialTheme.typography.bodyMedium, ) } 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") }) + AssistChip(onClick = { manifestation = "DISSENT" }, label = { Text(if (manifestation == "DISSENT") "✓ En disconformidad" else "En disconformidad") }) } if (manifestation == "DISSENT") { OutlinedTextField( dissentStatement, { dissentStatement = it }, - label = { Text("Manifestación de disidencia *") }, + label = { Text("Motivo de disconformidad *") }, minLines = 2, modifier = Modifier.fillMaxWidth(), ) } SignaturePad( - label = "Firma del responsable de empresa", + label = "Firma del representante de la empresa", enabled = !model.busy && inspectorSigned && (manifestation != "DISSENT" || dissentStatement.trim().length >= 10), onCaptured = { file -> signWithGeo(file, company = true) }, ) diff --git a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/ModernMobileActsScreen.kt b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/ModernMobileActsScreen.kt index dd295b4..14cd573 100644 --- a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/ModernMobileActsScreen.kt +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/ModernMobileActsScreen.kt @@ -72,17 +72,18 @@ fun ModernMobileActsScreen( val visit = model.visit ?: return val selected = model.selectedAct val closure = model.actClosure + val representativeSeed = closure?.responsible ?: closure?.representativeSuggestion val context = LocalContext.current val scope = rememberCoroutineScope() var closingUrgency by rememberSaveable(selected?.id) { mutableStateOf("") } - 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 attendance by rememberSaveable(selected?.id) { mutableStateOf(representativeSeed?.attendanceStatus ?: "PRESENT") } + var fullName by rememberSaveable(selected?.id) { mutableStateOf(representativeSeed?.fullName.orEmpty()) } + var documentType by rememberSaveable(selected?.id) { mutableStateOf("DNI") } + var documentNumber by rememberSaveable(selected?.id) { mutableStateOf(representativeSeed?.documentNumber.orEmpty()) } + var position by rememberSaveable(selected?.id) { mutableStateOf(representativeSeed?.position.orEmpty()) } + var email by rememberSaveable(selected?.id) { mutableStateOf(representativeSeed?.email.orEmpty()) } + var phone by rememberSaveable(selected?.id) { mutableStateOf(representativeSeed?.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") } @@ -293,28 +294,25 @@ fun ModernMobileActsScreen( Row(verticalAlignment = Alignment.CenterVertically) { Icon(Icons.Filled.Person, null, tint = MaterialTheme.colorScheme.primary) Spacer(Modifier.width(8.dp)) - Text("Responsable de la empresa", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + Text("Representante de la empresa", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) } Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { FilterChip(selected = attendance == "PRESENT", onClick = { attendance = "PRESENT" }, label = { Text("Presente") }) FilterChip(selected = attendance == "ABSENT", onClick = { attendance = "ABSENT" }, label = { Text("Ausente") }) } if (attendance == "PRESENT") { - OutlinedTextField(fullName, { fullName = it }, label = { Text("Nombre y apellido *") }, modifier = Modifier.fillMaxWidth(), singleLine = true) - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) { - listOf("DNI", "CUIL", "PASSPORT", "OTHER").forEach { kind -> - FilterChip(selected = documentType == kind, onClick = { documentType = kind }, label = { Text(documentTypeLabelEs(kind)) }) - } + OutlinedTextField(fullName, { fullName = it }, label = { Text("Nombres y apellidos *") }, modifier = Modifier.fillMaxWidth(), singleLine = true) + OutlinedTextField(documentNumber, { documentNumber = it.filter(Char::isDigit) }, label = { Text("DNI *") }, modifier = Modifier.fillMaxWidth(), singleLine = true) + OutlinedTextField(position, { position = it }, label = { Text("Cargo / función *") }, modifier = Modifier.fillMaxWidth(), singleLine = true) + OutlinedTextField(email, { email = it }, label = { Text("Email *") }, modifier = Modifier.fillMaxWidth(), singleLine = true) + if (closure?.responsible == null && closure?.representativeSuggestion != null) { + Text("Datos precargados del Acta anterior. Confirmalos antes de cerrar esta Acta.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) } - OutlinedTextField(documentNumber, { documentNumber = it }, label = { Text("Documento *") }, modifier = Modifier.fillMaxWidth(), singleLine = true) - OutlinedTextField(position, { position = it }, label = { Text("Cargo *") }, modifier = Modifier.fillMaxWidth(), singleLine = true) - OutlinedTextField(email, { email = it }, label = { Text("Correo electrónico") }, modifier = Modifier.fillMaxWidth(), singleLine = true) - OutlinedTextField(phone, { phone = it }, label = { Text("Teléfono") }, modifier = Modifier.fillMaxWidth(), singleLine = true) Button( onClick = { model.setCompanyResponsiblePresent(fullName, documentType, documentNumber, position, email, phone) }, - enabled = !model.busy && fullName.isNotBlank() && documentNumber.isNotBlank() && position.isNotBlank(), + enabled = !model.busy && fullName.isNotBlank() && documentNumber.isNotBlank() && position.isNotBlank() && email.contains("@") && email.contains("."), modifier = Modifier.fillMaxWidth(), - ) { Text("Guardar responsable") } + ) { Text("Guardar representante") } } else { OutlinedTextField( absenceReason, @@ -340,7 +338,7 @@ fun ModernMobileActsScreen( Text("Cerrar Acta", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) } Text( - "Al cerrar el contenido, el Acta queda inmutable y pasa a Pendiente de firma. En este momento definí la urgencia según lo constatado en campo.", + "Al cerrar el contenido, el Acta queda inmutable y pasa a Pendiente de firma. En este momento definí la urgencia del Acta completa. La urgencia no se asigna a cada Hallazgo.", color = MaterialTheme.colorScheme.onSurfaceVariant, ) Text("Urgencia del Acta", fontWeight = FontWeight.SemiBold) @@ -407,7 +405,7 @@ fun ModernMobileActsScreen( Text("Manifestación de la empresa", fontWeight = FontWeight.Bold) if (companyOutcome != null && companyResolved) { val detail = when (companyOutcome.status) { - "SIGNED" -> if (companyOutcome.companyManifestation == "DISSENT") "Firma en disidencia" else "Firma en conformidad" + "SIGNED" -> if (companyOutcome.companyManifestation == "DISSENT") "Firma en disconformidad" else "Firma en conformidad" "REFUSED" -> "Negativa a firmar" else -> humanBackendLabelEs(companyOutcome.status) } @@ -429,19 +427,19 @@ fun ModernMobileActsScreen( Text(closure?.consents?.company.orEmpty(), style = MaterialTheme.typography.bodySmall) Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { FilterChip(selected = manifestation == "CONFORMITY", onClick = { manifestation = "CONFORMITY" }, label = { Text("Conforme") }) - FilterChip(selected = manifestation == "DISSENT", onClick = { manifestation = "DISSENT" }, label = { Text("En disidencia") }) + FilterChip(selected = manifestation == "DISSENT", onClick = { manifestation = "DISSENT" }, label = { Text("En disconformidad") }) } if (manifestation == "DISSENT") { OutlinedTextField( dissentStatement, { dissentStatement = it }, - label = { Text("Manifestación de disidencia *") }, + label = { Text("Motivo de disconformidad *") }, minLines = 2, modifier = Modifier.fillMaxWidth(), ) } SignaturePad( - label = "Firma del responsable de empresa", + label = "Firma del representante de la empresa", enabled = !model.busy && inspectorSigned && (manifestation != "DISSENT" || dissentStatement.trim().length >= 10), onCaptured = { file -> signWithGeo(file, company = true) }, ) diff --git a/android-app/app/src/test/java/com/korexlabs/dhinspeccion/ReleaseMetadataTest.kt b/android-app/app/src/test/java/com/korexlabs/dhinspeccion/ReleaseMetadataTest.kt index e175eb9..8e263e2 100644 --- a/android-app/app/src/test/java/com/korexlabs/dhinspeccion/ReleaseMetadataTest.kt +++ b/android-app/app/src/test/java/com/korexlabs/dhinspeccion/ReleaseMetadataTest.kt @@ -8,8 +8,8 @@ class ReleaseMetadataTest { @Test fun debugBuildKeepsSeparateApplicationIdentity() { assertEquals("com.korexlabs.dhinspeccion.debug", BuildConfig.APPLICATION_ID) - assertEquals(30, BuildConfig.VERSION_CODE) - assertEquals("0.19.2-debug", BuildConfig.VERSION_NAME) + assertEquals(31, BuildConfig.VERSION_CODE) + assertEquals("0.19.3-debug", BuildConfig.VERSION_NAME) } @Test diff --git a/api-v3/package-lock.json b/api-v3/package-lock.json index 3dd3e47..a8c907e 100644 --- a/api-v3/package-lock.json +++ b/api-v3/package-lock.json @@ -1,12 +1,12 @@ { "name": "dhv2-api", - "version": "0.29.0-3", + "version": "0.29.0-4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dhv2-api", - "version": "0.29.0-3", + "version": "0.29.0-4", "license": "UNLICENSED", "dependencies": { "@nestjs/common": "^11.0.0", diff --git a/api-v3/package.json b/api-v3/package.json index 14ec496..22042f9 100644 --- a/api-v3/package.json +++ b/api-v3/package.json @@ -1,6 +1,6 @@ { "name": "dhv2-api", - "version": "0.29.0-3", + "version": "0.29.0-4", "private": true, "license": "UNLICENSED", "scripts": { diff --git a/api-v3/src/administration/administration.module.ts b/api-v3/src/administration/administration.module.ts index 1317782..d7f9c7e 100644 --- a/api-v3/src/administration/administration.module.ts +++ b/api-v3/src/administration/administration.module.ts @@ -2,13 +2,14 @@ import { Module } from '@nestjs/common'; import { AuditModule } from '../audit/audit.module'; import { AuthModule } from '../auth/auth.module'; import { PhaseADataModule } from '../core-data/phase-a-data.module'; +import { InspectionReportsModule } from '../inspection-reports/inspection-reports.module'; import { RolesController } from './roles/roles.controller'; import { RolesService } from './roles/roles.service'; import { UsersController } from './users/users.controller'; import { UsersService } from './users/users.service'; @Module({ - imports: [PhaseADataModule, AuditModule, AuthModule], + imports: [PhaseADataModule, AuditModule, AuthModule, InspectionReportsModule], controllers: [UsersController, RolesController], providers: [UsersService, RolesService], }) diff --git a/api-v3/src/administration/users/dto/create-user.dto.ts b/api-v3/src/administration/users/dto/create-user.dto.ts index fa5728f..ce591e3 100644 --- a/api-v3/src/administration/users/dto/create-user.dto.ts +++ b/api-v3/src/administration/users/dto/create-user.dto.ts @@ -28,10 +28,9 @@ export class CreateUserDto { @Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : null, ) - @IsOptional() @IsEmail() @MaxLength(320) - email?: string | null; + email!: string; @Transform(({ value }) => typeof value === 'string' && value.trim() ? value.replace(/\D/g, '') : null, diff --git a/api-v3/src/administration/users/dto/update-self-profile.dto.ts b/api-v3/src/administration/users/dto/update-self-profile.dto.ts new file mode 100644 index 0000000..fc7905f --- /dev/null +++ b/api-v3/src/administration/users/dto/update-self-profile.dto.ts @@ -0,0 +1,30 @@ +import { Transform } from 'class-transformer'; +import { + IsEmail, + IsOptional, + IsString, + MaxLength, + MinLength, +} from 'class-validator'; + +const optionalText = ({ value }: { value: unknown }) => + typeof value === 'string' && value.trim() ? value.trim() : null; + +export class UpdateSelfProfileDto { + @Transform(({ value }) => typeof value === 'string' ? value.trim().toLowerCase() : value) + @IsEmail() + @MaxLength(320) + email!: string; + + @Transform(optionalText) + @IsOptional() + @IsString() + @MaxLength(40) + phone?: string | null; + + @Transform(optionalText) + @IsOptional() + @IsString() + @MaxLength(160) + jobTitle?: string | null; +} diff --git a/api-v3/src/administration/users/dto/update-user-smtp-settings.dto.ts b/api-v3/src/administration/users/dto/update-user-smtp-settings.dto.ts new file mode 100644 index 0000000..cc00c6f --- /dev/null +++ b/api-v3/src/administration/users/dto/update-user-smtp-settings.dto.ts @@ -0,0 +1,65 @@ +import { Transform } from 'class-transformer'; +import { + IsBoolean, + IsEnum, + IsInt, + IsOptional, + IsString, + Max, + MaxLength, + Min, + MinLength, + ValidateIf, +} from 'class-validator'; +import { SmtpSecurityMode } from '../../../database/entities'; + +export enum UserSmtpMode { + SYSTEM = 'SYSTEM', + CUSTOM = 'CUSTOM', +} + +const trimmed = ({ value }: { value: unknown }) => + typeof value === 'string' ? value.trim() : value; + +export class UpdateUserSmtpSettingsDto { + @IsEnum(UserSmtpMode) + mode!: UserSmtpMode; + + @ValidateIf((dto: UpdateUserSmtpSettingsDto) => dto.mode === UserSmtpMode.CUSTOM) + @Transform(trimmed) + @IsString() + @MinLength(1) + @MaxLength(255) + host?: string; + + @ValidateIf((dto: UpdateUserSmtpSettingsDto) => dto.mode === UserSmtpMode.CUSTOM) + @IsInt() + @Min(1) + @Max(65535) + port?: number; + + @ValidateIf((dto: UpdateUserSmtpSettingsDto) => dto.mode === UserSmtpMode.CUSTOM) + @IsEnum(SmtpSecurityMode) + securityMode?: SmtpSecurityMode; + + @IsOptional() + @Transform(trimmed) + @IsString() + @MaxLength(255) + username?: string | null; + + @IsOptional() + @IsString() + @MaxLength(512) + password?: string | null; + + @IsOptional() + @Transform(trimmed) + @IsString() + @MaxLength(200) + fromName?: string | null; + + @IsOptional() + @IsBoolean() + enabled = true; +} diff --git a/api-v3/src/administration/users/users.controller.ts b/api-v3/src/administration/users/users.controller.ts index 1e9827c..d44ac77 100644 --- a/api-v3/src/administration/users/users.controller.ts +++ b/api-v3/src/administration/users/users.controller.ts @@ -10,23 +10,33 @@ import { Query, Req, } from '@nestjs/common'; +import { AuditService } from '../../audit/audit.service'; import { RequirePermissions } from '../../authorization/decorators/require-permissions.decorator'; import { CurrentAuth } from '../../auth/decorators/current-auth.decorator'; import type { AuthPrincipal, RequestWithContext, } from '../../common/http/request-context'; +import { AuditAction } from '../../database/entities'; +import { SmtpDeliveryService } from '../../inspection-reports/smtp-delivery.service'; +import { administrationAuditContext } from '../common/administration-audit'; import { ChangeUserStatusDto } from './dto/change-user-status.dto'; import { CreateUserDto } from './dto/create-user.dto'; import { ListUsersQueryDto } from './dto/list-users-query.dto'; import { ReplaceUserRolesDto } from './dto/replace-user-roles.dto'; import { ResetUserPasswordDto } from './dto/reset-user-password.dto'; +import { UpdateSelfProfileDto } from './dto/update-self-profile.dto'; +import { UpdateUserSmtpSettingsDto } from './dto/update-user-smtp-settings.dto'; import { UpdateUserDto } from './dto/update-user.dto'; import { UsersService } from './users.service'; @Controller('users') export class UsersController { - constructor(private readonly users: UsersService) {} + constructor( + private readonly users: UsersService, + private readonly smtp: SmtpDeliveryService, + private readonly audit: AuditService, + ) {} @Get() @RequirePermissions('users.read') @@ -34,6 +44,73 @@ export class UsersController { return this.users.list(query); } + @Get('self/profile') + selfProfile(@CurrentAuth() principal: AuthPrincipal) { + return this.users.getSelfProfile(principal.userId); + } + + @Patch('self/profile') + updateSelfProfile( + @Body() dto: UpdateSelfProfileDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.users.updateSelfProfile(dto, principal, request); + } + + @Get('self/smtp') + selfSmtp(@CurrentAuth() principal: AuthPrincipal) { + return this.smtp.publicUserSettings(principal.userId); + } + + @Put('self/smtp') + async updateSelfSmtp( + @Body() dto: UpdateUserSmtpSettingsDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + const before = await this.smtp.publicUserSettings(principal.userId); + const after = await this.smtp.saveUserSettings(principal.userId, dto); + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.USER_SMTP_SETTINGS_UPDATED, + entityType: 'user_smtp_settings', + entityId: principal.userId, + beforeData: before as Record, + afterData: after as Record, + metadata: { passwordNeverReturned: true, scope: 'SELF' }, + }); + return after; + } + + @Post('self/smtp/test') + async testSelfSmtp( + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + const profile = await this.users.getSelfProfile(principal.userId); + if (!profile.email) throw new Error('El usuario no tiene email configurado'); + const sent = await this.smtp.send({ + to: profile.email, + subject: 'DH Inspección · Prueba de correo personal', + text: 'Este correo confirma que tu configuración de correo en DH Inspección funciona correctamente.', + attachment: { + filename: 'dh-inspeccion-prueba-correo.txt', + mimeType: 'text/plain', + content: Buffer.from('DH Inspección · Correo personal OK\n', 'utf8'), + }, + }, principal.userId); + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.USER_SMTP_TEST_SENT, + entityType: 'user_smtp_settings', + entityId: principal.userId, + afterData: { recipient: profile.email, messageId: sent.messageId }, + metadata: { scope: 'SELF' }, + }); + return { ok: true, recipient: profile.email, messageId: sent.messageId }; + } + @Post() @RequirePermissions('users.create') create( diff --git a/api-v3/src/administration/users/users.service.ts b/api-v3/src/administration/users/users.service.ts index 6ab6099..0f22cf2 100644 --- a/api-v3/src/administration/users/users.service.ts +++ b/api-v3/src/administration/users/users.service.ts @@ -29,6 +29,7 @@ import type { CreateUserDto } from './dto/create-user.dto'; import type { ListUsersQueryDto } from './dto/list-users-query.dto'; import type { ReplaceUserRolesDto } from './dto/replace-user-roles.dto'; import type { ResetUserPasswordDto } from './dto/reset-user-password.dto'; +import type { UpdateSelfProfileDto } from './dto/update-self-profile.dto'; import type { UpdateUserDto } from './dto/update-user.dto'; export interface UserRoleView { @@ -56,6 +57,7 @@ export interface AdministrativeUserView { createdAt: Date; updatedAt: Date; roles: UserRoleView[]; + smtpMode: 'SYSTEM' | 'CUSTOM'; } interface UserViewRow extends AdministrativeUserView { @@ -76,10 +78,10 @@ function roleSelectionInvalid(): BadRequestException { }); } -function inspectorEmailRequired(): BadRequestException { +function userEmailRequired(): BadRequestException { return new BadRequestException({ - code: 'INSPECTOR_EMAIL_REQUIRED', - message: 'Los usuarios con rol Inspector deben tener un email válido para recibir la documentación de sus inspecciones', + code: 'USER_EMAIL_REQUIRED', + message: 'Cada usuario de Hidrocarburos debe tener un email válido', }); } @@ -144,6 +146,7 @@ export class UsersService { user_account.password_changed_at AS "passwordChangedAt", user_account.created_at AS "createdAt", user_account.updated_at AS "updatedAt", + COALESCE((SELECT mode FROM user_smtp_settings WHERE user_id=user_account.id),'SYSTEM') AS "smtpMode", COALESCE( JSONB_AGG( JSONB_BUILD_OBJECT( @@ -185,6 +188,24 @@ export class UsersService { ); } + async getSelfProfile(userId: string): Promise { + return this.getById(userId); + } + + async updateSelfProfile( + dto: UpdateSelfProfileDto, + principal: AuthPrincipal, + request: RequestWithContext, + ): Promise { + if (!dto.email?.trim()) throw userEmailRequired(); + return this.update( + principal.userId, + { email: dto.email, phone: dto.phone, jobTitle: dto.jobTitle }, + principal, + request, + ); + } + async create( dto: CreateUserDto, principal: AuthPrincipal, @@ -195,10 +216,10 @@ export class UsersService { try { return await this.dataSource.transaction(async (manager) => { const roles = await this.resolveRoles(manager, dto.roleIds); - this.assertInspectorHasEmail(roles, dto.email ?? null); + if (!dto.email?.trim()) throw userEmailRequired(); const user = manager.getRepository(User).create({ username: dto.username.trim().toLowerCase(), - email: dto.email?.trim().toLowerCase() || null, + email: dto.email.trim().toLowerCase(), dni: dto.dni ?? null, phone: dto.phone ?? null, jobTitle: dto.jobTitle ?? null, @@ -268,15 +289,13 @@ export class UsersService { return await this.dataSource.transaction(async (manager) => { const user = await this.lockUser(manager, id); const before = await this.loadUserView(manager, id); - if (dto.email !== undefined && !dto.email && before.roles.some((role) => role.code === 'inspector')) { - throw inspectorEmailRequired(); - } + if (dto.email !== undefined && !dto.email) throw userEmailRequired(); if (dto.username !== undefined) { user.username = dto.username.trim().toLowerCase(); } if (dto.email !== undefined) { - user.email = dto.email?.trim().toLowerCase() || null; + user.email = dto.email!.trim().toLowerCase(); } if (dto.dni !== undefined) user.dni = dto.dni ?? null; if (dto.phone !== undefined) user.phone = dto.phone ?? null; @@ -286,6 +305,13 @@ export class UsersService { if (dto.lastName !== undefined) user.lastName = dto.lastName.trim(); user.updatedBy = principal.userId; await manager.getRepository(User).save(user); + if (dto.email !== undefined && user.email) { + await manager.query(` + UPDATE user_smtp_settings + SET from_email=$2,reply_to=$2,updated_by=$1,updated_at=CURRENT_TIMESTAMP + WHERE user_id=$1 AND mode='CUSTOM' + `, [id, user.email]); + } const updated = await this.loadUserView(manager, id); await this.audit.record( @@ -414,7 +440,6 @@ export class UsersService { await this.lockUser(manager, id); const roles = await this.resolveRoles(manager, dto.roleIds); const before = await this.loadUserView(manager, id); - this.assertInspectorHasEmail(roles, before.email); const beforeIds = before.roles.map((role) => role.id).sort(); const afterIds = roles.map((role) => role.id).sort(); if (beforeIds.join(',') === afterIds.join(',')) return before; @@ -453,12 +478,6 @@ export class UsersService { return roles; } - private assertInspectorHasEmail(roles: Role[], email: string | null | undefined): void { - if (roles.some((role) => role.code === 'inspector') && !email?.trim()) { - throw inspectorEmailRequired(); - } - } - private async insertUserRoles( manager: EntityManager, userId: string, @@ -511,6 +530,7 @@ export class UsersService { user_account.password_changed_at AS "passwordChangedAt", user_account.created_at AS "createdAt", user_account.updated_at AS "updatedAt", + COALESCE((SELECT mode FROM user_smtp_settings WHERE user_id=user_account.id),'SYSTEM') AS "smtpMode", COALESCE( JSONB_AGG( JSONB_BUILD_OBJECT( diff --git a/api-v3/src/cli/bootstrap-admin.ts b/api-v3/src/cli/bootstrap-admin.ts index 67111c3..807f0c7 100644 --- a/api-v3/src/cli/bootstrap-admin.ts +++ b/api-v3/src/cli/bootstrap-admin.ts @@ -118,10 +118,9 @@ async function main(): Promise { ); } - email = (await prompt.question('Email (optional): ')).trim().toLowerCase(); + email = (await prompt.question('Email: ')).trim().toLowerCase(); if ( - email && - (email.length > 320 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) + !email || email.length > 320 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) ) { throw new Error('Email is invalid'); } @@ -189,7 +188,7 @@ async function main(): Promise { OR ($2::text IS NOT NULL AND LOWER(email) = $2) LIMIT 1 `, - [username, email || null], + [username, email], )) as unknown[]; if (duplicate.length > 0) { throw new Error('Username or email already exists'); @@ -202,7 +201,7 @@ async function main(): Promise { const user = manager.getRepository(User).create({ username, - email: email || null, + email, passwordHash, firstName, lastName, diff --git a/api-v3/src/database/entities/audit-event.entity.ts b/api-v3/src/database/entities/audit-event.entity.ts index 42a616e..df54aec 100644 --- a/api-v3/src/database/entities/audit-event.entity.ts +++ b/api-v3/src/database/entities/audit-event.entity.ts @@ -103,6 +103,8 @@ export enum AuditAction { INSPECTION_BUSINESS_CALENDAR_UPDATED = 'INSPECTION_BUSINESS_CALENDAR_UPDATED', SMTP_SETTINGS_UPDATED = 'SMTP_SETTINGS_UPDATED', SMTP_TEST_SENT = 'SMTP_TEST_SENT', + USER_SMTP_SETTINGS_UPDATED = 'USER_SMTP_SETTINGS_UPDATED', + USER_SMTP_TEST_SENT = 'USER_SMTP_TEST_SENT', DOCUMENT_DELIVERY_SETTINGS_UPDATED = 'DOCUMENT_DELIVERY_SETTINGS_UPDATED', DOCUMENT_DELIVERY_RETRY_REQUESTED = 'DOCUMENT_DELIVERY_RETRY_REQUESTED', DOCUMENT_DELIVERY_SENT = 'DOCUMENT_DELIVERY_SENT', diff --git a/api-v3/src/database/entities/user.entity.ts b/api-v3/src/database/entities/user.entity.ts index 85bf851..0dbc87f 100644 --- a/api-v3/src/database/entities/user.entity.ts +++ b/api-v3/src/database/entities/user.entity.ts @@ -16,8 +16,8 @@ export class User extends TimestampedEntity { @Column({ type: 'varchar', length: 80 }) username!: string; - @Column({ type: 'varchar', length: 320, nullable: true }) - email!: string | null; + @Column({ type: 'varchar', length: 320 }) + email!: string; @Column({ type: 'varchar', length: 32, nullable: true }) dni!: string | null; diff --git a/api-v3/src/database/migrations/1790117400000-f6-2-user-smtp-and-act-representative.ts b/api-v3/src/database/migrations/1790117400000-f6-2-user-smtp-and-act-representative.ts new file mode 100644 index 0000000..00f1fda --- /dev/null +++ b/api-v3/src/database/migrations/1790117400000-f6-2-user-smtp-and-act-representative.ts @@ -0,0 +1,105 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +function quoteIdentifier(identifier: string): string { + return `"${identifier.replaceAll('"', '""')}"`; +} + +export class F62UserSmtpAndActRepresentative1790117400000 implements MigrationInterface { + name = 'F62UserSmtpAndActRepresentative1790117400000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DO $$ + BEGIN + IF EXISTS (SELECT 1 FROM users WHERE email IS NULL OR btrim(email)='') THEN + RAISE EXCEPTION 'USER_EMAIL_REQUIRED_BEFORE_F62'; + END IF; + END $$ + `); + await queryRunner.query(`ALTER TABLE users ALTER COLUMN email SET NOT NULL`); + + await queryRunner.query(` + CREATE TABLE user_smtp_settings ( + user_id uuid PRIMARY KEY, + mode varchar(16) NOT NULL DEFAULT 'SYSTEM', + host varchar(255), + port integer, + security_mode varchar(24), + username varchar(255), + password_enc text, + from_name varchar(200), + from_email varchar(320), + reply_to varchar(320), + enabled boolean NOT NULL DEFAULT true, + updated_by uuid, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT chk_user_smtp_mode CHECK (mode IN ('SYSTEM','CUSTOM')), + CONSTRAINT chk_user_smtp_security CHECK (security_mode IS NULL OR security_mode IN ('NONE','STARTTLS','TLS')), + CONSTRAINT chk_user_smtp_port CHECK (port IS NULL OR (port > 0 AND port <= 65535)), + CONSTRAINT chk_user_smtp_custom_complete CHECK ( + mode='SYSTEM' OR ( + LENGTH(TRIM(COALESCE(host,''))) > 0 + AND port IS NOT NULL + AND security_mode IS NOT NULL + AND LENGTH(TRIM(COALESCE(from_email,''))) > 0 + ) + ), + CONSTRAINT fk_user_smtp_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + CONSTRAINT fk_user_smtp_updated_by FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL + ) + `); + await queryRunner.query(` + ALTER TABLE inspection_act_responsibles + DROP CONSTRAINT chk_inspection_act_responsibles_details + `); + await queryRunner.query(` + ALTER TABLE inspection_act_responsibles + ADD CONSTRAINT chk_inspection_act_responsibles_details CHECK ( + ( + attendance_status = 'PRESENT' + AND LENGTH(TRIM(COALESCE(full_name, ''))) > 0 + AND document_type IS NOT NULL + AND LENGTH(TRIM(COALESCE(document_number, ''))) > 0 + AND LENGTH(TRIM(COALESCE(position, ''))) > 0 + AND LENGTH(TRIM(COALESCE(email, ''))) > 0 + AND absence_reason IS NULL + ) OR ( + attendance_status = 'ABSENT' + AND LENGTH(TRIM(COALESCE(absence_reason, ''))) >= 10 + ) + ) + `); + + const appRole = process.env.DB_APP_USER; + if (!appRole) throw new Error('Missing required environment variable: DB_APP_USER'); + const applicationRole = quoteIdentifier(appRole); + await queryRunner.query(`GRANT SELECT, INSERT, UPDATE ON TABLE user_smtp_settings TO ${applicationRole}`); + await queryRunner.query(`REVOKE DELETE ON TABLE user_smtp_settings FROM ${applicationRole}`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE inspection_act_responsibles + DROP CONSTRAINT chk_inspection_act_responsibles_details + `); + await queryRunner.query(` + ALTER TABLE inspection_act_responsibles + ADD CONSTRAINT chk_inspection_act_responsibles_details CHECK ( + ( + attendance_status = 'PRESENT' + AND LENGTH(TRIM(COALESCE(full_name, ''))) > 0 + AND document_type IS NOT NULL + AND LENGTH(TRIM(COALESCE(document_number, ''))) > 0 + AND LENGTH(TRIM(COALESCE(position, ''))) > 0 + AND absence_reason IS NULL + ) OR ( + attendance_status = 'ABSENT' + AND LENGTH(TRIM(COALESCE(absence_reason, ''))) >= 10 + ) + ) + `); + await queryRunner.query(`DROP TABLE IF EXISTS user_smtp_settings`); + await queryRunner.query(`ALTER TABLE users ALTER COLUMN email DROP NOT NULL`); + } +} diff --git a/api-v3/src/inspection-closing/company-signature-invite.service.ts b/api-v3/src/inspection-closing/company-signature-invite.service.ts index 16bc4ab..081243b 100644 --- a/api-v3/src/inspection-closing/company-signature-invite.service.ts +++ b/api-v3/src/inspection-closing/company-signature-invite.service.ts @@ -213,14 +213,14 @@ export class CompanySignatureInviteService { if (!publicUrl) { deliveryError = 'COMPANY_SIGNATURE_PUBLIC_BASE_URL no configurada'; - } else if (!(await this.smtp.configured())) { + } else if (!(await this.smtp.configured(principal.userId))) { deliveryError = 'SMTP no configurado'; } else { const body = [ `Se solicita revisar y manifestarse sobre el Acta ${created.actCode}.`, `Inspección: ${created.inspectionCode}.`, '', - 'El enlace permite firmar en conformidad, firmar en disidencia o registrar una negativa a firmar.', + 'El enlace permite firmar en conformidad, firmar en disconformidad o registrar una negativa a firmar.', 'El contenido del Acta está bloqueado y no puede modificarse desde este enlace.', '', `Enlace seguro: ${publicUrl}`, @@ -236,7 +236,7 @@ export class CompanySignatureInviteService { mimeType: 'text/plain', content: Buffer.from(body, 'utf8'), }, - }); + }, principal.userId); emailSent = true; await this.dataSource.query(` UPDATE inspection_act_company_signature_invites diff --git a/api-v3/src/inspection-closing/dto/upsert-inspection-responsible.dto.ts b/api-v3/src/inspection-closing/dto/upsert-inspection-responsible.dto.ts index 35490c8..f07b7ce 100644 --- a/api-v3/src/inspection-closing/dto/upsert-inspection-responsible.dto.ts +++ b/api-v3/src/inspection-closing/dto/upsert-inspection-responsible.dto.ts @@ -54,7 +54,9 @@ export class UpsertInspectionResponsibleDto { @MaxLength(200) position?: string; - @IsOptional() + @ValidateIf((dto: UpsertInspectionResponsibleDto) => ( + dto.attendanceStatus === InspectionResponsibleAttendanceStatus.PRESENT + )) @Transform(trimOrUndefined) @IsEmail() @MaxLength(320) diff --git a/api-v3/src/inspection-closing/inspection-closing.service.ts b/api-v3/src/inspection-closing/inspection-closing.service.ts index 74241d9..409c3c7 100644 --- a/api-v3/src/inspection-closing/inspection-closing.service.ts +++ b/api-v3/src/inspection-closing/inspection-closing.service.ts @@ -152,6 +152,7 @@ export interface InspectionClosureView { actualClosedAt: Date | null; }; responsible: ResponsibleView | null; + representativeSuggestion: ResponsibleView | null; closure: null | { schemaVersion: string; preparedSha256: string; @@ -1022,6 +1023,7 @@ export class InspectionClosingService { }>; if (!context) throw actNotFound(); const responsible = await this.loadResponsible(manager, actId); + const representativeSuggestion = responsible ? null : await this.loadResponsibleSuggestion(manager, actId); const closure = await this.loadClosure(manager, actId); const signatures = await this.loadSignatures(manager, actId); return { @@ -1051,6 +1053,7 @@ export class InspectionClosingService { actualClosedAt: context.visitActualClosedAt, }, responsible, + representativeSuggestion, closure: closure ? { schemaVersion: closure.schemaVersion, preparedSha256: closure.preparedSha256, @@ -1083,12 +1086,30 @@ export class InspectionClosingService { return row ?? null; } + private async loadResponsibleSuggestion(manager: EntityManager, actId: string): Promise { + const [row] = await manager.query(` + SELECT responsible.act_id AS "actId",responsible.attendance_status AS "attendanceStatus", + responsible.full_name AS "fullName",responsible.document_type AS "documentType", + responsible.document_number AS "documentNumber",responsible.position,responsible.email,responsible.phone, + responsible.absence_reason AS "absenceReason",responsible.updated_by AS "updatedBy", + responsible.created_at AS "createdAt",responsible.updated_at AS "updatedAt" + FROM inspection_acts current_act + JOIN inspection_acts previous_act + ON previous_act.visit_id=current_act.visit_id AND previous_act.id<>current_act.id + JOIN inspection_act_responsibles responsible ON responsible.act_id=previous_act.id + WHERE current_act.id=$1 AND responsible.attendance_status='PRESENT' + ORDER BY responsible.updated_at DESC,previous_act.created_at DESC + LIMIT 1 + `, [actId]) as ResponsibleView[]; + return row ?? null; + } + private async requireResponsible(manager: EntityManager, actId: string): Promise { const responsible = await this.loadResponsible(manager, actId); if (!responsible) { throw new ConflictException({ code: 'INSPECTION_ACT_RESPONSIBLE_REQUIRED', - message: 'Debe identificarse al responsable o documentar su ausencia antes de finalizar el acta', + message: 'Debe identificarse al representante de la empresa o documentar su ausencia antes de cerrar el Acta', }); } return responsible; diff --git a/api-v3/src/inspection-reports/inspection-act-pdf-builder.ts b/api-v3/src/inspection-reports/inspection-act-pdf-builder.ts index e058911..b3126c8 100644 --- a/api-v3/src/inspection-reports/inspection-act-pdf-builder.ts +++ b/api-v3/src/inspection-reports/inspection-act-pdf-builder.ts @@ -99,8 +99,10 @@ function lines(snapshot: Record): string[] { `Fecha: ${date(act.occurredAt)}`, `Urgencia: ${urgencyLabel(act.urgency)}`, `Plazo: ${deadlineText}`, - `Responsable empresa: ${text(responsible.fullName)}`, - `Cargo: ${text(responsible.position)}`, + `Representante de la empresa: ${text(responsible.fullName)}`, + `DNI: ${text(responsible.documentNumber)}`, + `Cargo / funcion: ${text(responsible.position)}`, + `Email: ${text(responsible.email)}`, '', 'RESUMEN', ...wrap(text(act.summary)), @@ -136,7 +138,7 @@ function lines(snapshot: Record): string[] { out.push('Manifestacion de empresa: pendiente.'); } else if (text(companySignature.status, '') === 'SIGNED') { const manifestation = text(companySignature.companyManifestation, 'CONFORMITY'); - out.push(manifestation === 'DISSENT' ? 'Empresa: firma en disidencia' : 'Empresa: firma en conformidad'); + out.push(manifestation === 'DISSENT' ? 'Empresa: firma en disconformidad' : 'Empresa: firma en conformidad'); if (manifestation === 'DISSENT') out.push(...wrap(text(companySignature.companyStatement))); } else { out.push(...wrap(`Empresa: ${text(companySignature.status)} - ${text(companySignature.reason)}`)); diff --git a/api-v3/src/inspection-reports/inspection-document-delivery.service.ts b/api-v3/src/inspection-reports/inspection-document-delivery.service.ts index 7b92038..60e1b09 100644 --- a/api-v3/src/inspection-reports/inspection-document-delivery.service.ts +++ b/api-v3/src/inspection-reports/inspection-document-delivery.service.ts @@ -281,7 +281,7 @@ export class InspectionDocumentDeliveryService { 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 + SELECT email FROM users WHERE id=$1 AND status='ACTIVE' `, [row.recipientUserId]) as Array<{ email: string | null }>; email = inspector?.email ?? null; } else { @@ -305,7 +305,14 @@ export class InspectionDocumentDeliveryService { await this.setStatus(row.id,'WAITING_RECIPIENT','Destinatario no configurado'); return; } - if (!await this.smtp.configured()) { + const [sender] = await this.dataSource.query(` + SELECT visit.lead_inspector_user_id AS "userId" + FROM inspection_acts act + JOIN inspection_visits visit ON visit.id=act.visit_id + WHERE act.id=$1 + `, [row.actId]) as Array<{ userId: string | null }>; + const senderUserId = sender?.userId ?? undefined; + if (!await this.smtp.configured(senderUserId)) { await this.setStatus(row.id,'WAITING_TRANSPORT','SMTP no configurado'); return; } @@ -348,7 +355,7 @@ export class InspectionDocumentDeliveryService { : `Se adjunta el acta sellada e inmutable ${row.actCode}.`; const sent = await this.smtp.send({ to:row.recipientEmail,subject:`DH Inspección · ${label}`,text,attachment, - }); + }, senderUserId); await this.dataSource.query(` UPDATE inspection_document_deliveries SET status='SENT',sent_at=CURRENT_TIMESTAMP,provider_message_id=$2, diff --git a/api-v3/src/inspection-reports/inspection-report-word-builder.ts b/api-v3/src/inspection-reports/inspection-report-word-builder.ts index 27f30b8..b16aba1 100644 --- a/api-v3/src/inspection-reports/inspection-report-word-builder.ts +++ b/api-v3/src/inspection-reports/inspection-report-word-builder.ts @@ -147,8 +147,10 @@ function documentXml(input: ReportWordInput): string { paragraph('El bloque siguiente reproduce información proveniente del Acta sellada. Debe conservarse sin alterar su sentido ni sustituir los Hallazgos originales.'), labelValue('Resumen del Acta', text(snapshot.act.summary)), labelValue('Observaciones del Acta', text(snapshot.act.observations)), - labelValue('Responsable de empresa', text(snapshot.responsible.fullName)), - labelValue('Cargo', text(snapshot.responsible.position)), + labelValue('Representante de la empresa', text(snapshot.responsible.fullName)), + labelValue('DNI', text(snapshot.responsible.documentNumber)), + labelValue('Cargo / función', text(snapshot.responsible.position)), + labelValue('Email', text(snapshot.responsible.email)), paragraph('Inventario inspeccionado', 'Heading1'), inventoryRows.length ? table(['Código', 'Nombre', 'Tipo'], inventoryRows) diff --git a/api-v3/src/inspection-reports/smtp-delivery.service.ts b/api-v3/src/inspection-reports/smtp-delivery.service.ts index 0e54de4..bc61968 100644 --- a/api-v3/src/inspection-reports/smtp-delivery.service.ts +++ b/api-v3/src/inspection-reports/smtp-delivery.service.ts @@ -11,7 +11,7 @@ interface MailInput { to:string; subject:string; text:string; attachment:MailAtt interface Reply { code:number; text:string; } export interface EffectiveSmtpSettings { - source: 'DATABASE' | 'ENVIRONMENT'; + source: 'USER' | 'DATABASE' | 'ENVIRONMENT'; host: string; port: number; securityMode: SmtpSecurityMode; @@ -47,10 +47,10 @@ export class SmtpDeliveryService { private readonly config:ConfigService, ){} - async configured():Promise{return Boolean(await this.resolveSettings());} + async configured(userId?:string):Promise{return Boolean(await this.resolveSettings(userId));} - async fromAddress():Promise{ - const settings=await this.resolveSettings(); + async fromAddress(userId?:string):Promise{ + const settings=await this.resolveSettings(userId); if(!settings)return null; return settings.fromName?`${settings.fromName} <${settings.fromEmail}>`:settings.fromEmail; } @@ -73,6 +73,56 @@ export class SmtpDeliveryService { }:{source:'NONE',enabled:false}; } + async publicUserSettings(userId:string){ + const [user]=await this.dataSource.query(` + SELECT email,first_name AS "firstName",last_name AS "lastName" FROM users WHERE id=$1 + `,[userId]) as Array<{email:string;firstName:string;lastName:string}>; + if(!user)throw new Error('Usuario no encontrado'); + const [row]=await this.dataSource.query(` + SELECT mode,host,port,security_mode AS "securityMode",username, + (password_enc IS NOT NULL) AS "hasPassword",from_name AS "fromName", + from_email AS "fromEmail",enabled,updated_at AS "updatedAt" + FROM user_smtp_settings WHERE user_id=$1 + `,[userId]) as Array>; + return { + mode:row?.mode??'SYSTEM',email:user.email,generalConfigured:Boolean(await this.resolveSystemSettings()), + custom:row?{ + host:row.host??'',port:row.port??587,securityMode:row.securityMode??'STARTTLS', + username:row.username??'',hasPassword:Boolean(row.hasPassword), + fromName:row.fromName??`${user.firstName} ${user.lastName}`,fromEmail:row.fromEmail??user.email, + enabled:row.enabled!==false,updatedAt:row.updatedAt??null, + }:null, + }; + } + + async saveUserSettings(userId:string,input:{ + mode:'SYSTEM'|'CUSTOM';host?:string;port?:number;securityMode?:SmtpSecurityMode; + username?:string|null;password?:string|null;fromName?:string|null;enabled?:boolean; + }){ + const [user]=await this.dataSource.query(`SELECT email,first_name AS "firstName",last_name AS "lastName" FROM users WHERE id=$1`,[userId]) as Array<{email:string;firstName:string;lastName:string}>; + if(!user?.email)throw new Error('El usuario debe tener un email configurado'); + const [existing]=await this.dataSource.query(`SELECT password_enc AS "passwordEnc" FROM user_smtp_settings WHERE user_id=$1`,[userId]) as Array<{passwordEnc:string|null}>; + if(input.mode==='SYSTEM'){ + await this.dataSource.query(` + INSERT INTO user_smtp_settings(user_id,mode,updated_by) VALUES($1,'SYSTEM',$1) + ON CONFLICT(user_id) DO UPDATE SET mode='SYSTEM',updated_by=$1,updated_at=CURRENT_TIMESTAMP + `,[userId]); + return this.publicUserSettings(userId); + } + if(!input.host||!input.port||!input.securityMode)throw new Error('La configuración SMTP propia está incompleta'); + const passwordEnc=input.password===undefined?existing?.passwordEnc??null:input.password?this.encryptSecret(input.password):null; + const fromName=input.fromName?.trim()||`${user.firstName} ${user.lastName}`.trim(); + await this.dataSource.query(` + INSERT INTO user_smtp_settings(user_id,mode,host,port,security_mode,username,password_enc,from_name,from_email,reply_to,enabled,updated_by) + VALUES($1,'CUSTOM',$2,$3,$4,$5,$6,$7,$8,$8,$9,$1) + ON CONFLICT(user_id) DO UPDATE SET mode='CUSTOM',host=EXCLUDED.host,port=EXCLUDED.port, + security_mode=EXCLUDED.security_mode,username=EXCLUDED.username,password_enc=EXCLUDED.password_enc, + from_name=EXCLUDED.from_name,from_email=EXCLUDED.from_email,reply_to=EXCLUDED.reply_to, + enabled=EXCLUDED.enabled,updated_by=$1,updated_at=CURRENT_TIMESTAMP + `,[userId,input.host,input.port,input.securityMode,input.username?.trim()||null,passwordEnc,fromName,user.email,input.enabled!==false]); + return this.publicUserSettings(userId); + } + async saveSettings(input:{ host:string;port:number;securityMode:SmtpSecurityMode;username?:string|null; password?:string|null;fromName:string;fromEmail:string;replyTo?:string|null;enabled:boolean; @@ -96,8 +146,8 @@ export class SmtpDeliveryService { return this.publicSettings(); } - async send(input:MailInput):Promise<{messageId:string}>{ - const settings=await this.resolveSettings(); + async send(input:MailInput,userId?:string):Promise<{messageId:string}>{ + const settings=await this.resolveSettings(userId); if(!settings)throw new Error('SMTP no configurado'); const {host,port,securityMode,userName,password}= { host:settings.host,port:settings.port,securityMode:settings.securityMode, @@ -137,7 +187,33 @@ export class SmtpDeliveryService { return {messageId:match?.[1]??randomUUID()}; } - private async resolveSettings():Promise{ + private async resolveSettings(userId?:string):Promise{ + if(userId){ + const [row]=await this.dataSource.query(` + SELECT settings.mode,settings.host,settings.port,settings.security_mode AS "securityMode", + settings.username,settings.password_enc AS "passwordEnc",settings.from_name AS "fromName", + settings.enabled,user_account.email AS "userEmail" + FROM users user_account + LEFT JOIN user_smtp_settings settings ON settings.user_id=user_account.id + WHERE user_account.id=$1 + `,[userId]) as Array<{ + mode:'SYSTEM'|'CUSTOM'|null;host:string|null;port:number|null;securityMode:SmtpSecurityMode|null; + username:string|null;passwordEnc:string|null;fromName:string|null;enabled:boolean|null;userEmail:string; + }>; + if(row?.mode==='CUSTOM'&&row.enabled!==false&&row.host&&row.port&&row.securityMode&&row.userEmail){ + return { + source:'USER',host:row.host,port:Number(row.port),securityMode:row.securityMode, + username:row.username,password:row.passwordEnc?this.decryptSecret(row.passwordEnc):'', + fromName:row.fromName,fromEmail:row.userEmail,replyTo:row.userEmail, + }; + } + const general=await this.resolveSystemSettings(); + return general&&row?.userEmail?{...general,replyTo:row.userEmail}:general; + } + return this.resolveSystemSettings(); + } + + private async resolveSystemSettings():Promise{ const [row]=await this.dataSource.query(` SELECT host,port,security_mode AS "securityMode",username,password_enc AS "passwordEnc", from_name AS "fromName",from_email AS "fromEmail",reply_to AS "replyTo",enabled diff --git a/api-v3/src/version.ts b/api-v3/src/version.ts index 52d4458..3d33292 100644 --- a/api-v3/src/version.ts +++ b/api-v3/src/version.ts @@ -1,2 +1,2 @@ -export const API_VERSION = '0.29.0-3'; -export const API_PHASE = 'F6.1'; \ No newline at end of file +export const API_VERSION = '0.29.0-4'; +export const API_PHASE = 'F6.2'; diff --git a/api-v3/test/unit/f3-1-profile-dossier-other.test.ts b/api-v3/test/unit/f3-1-profile-dossier-other.test.ts index 8d5b695..e5e3b29 100644 --- a/api-v3/test/unit/f3-1-profile-dossier-other.test.ts +++ b/api-v3/test/unit/f3-1-profile-dossier-other.test.ts @@ -25,11 +25,11 @@ test('F3.1 amplía el perfil personal y protege email del Inspector también en assert.match(userMigration, /trg_users_inspector_email/); }); -test('F3.1 exige email al crear o asignar el rol Inspector', () => { - assert.match(usersService, /assertInspectorHasEmail\(roles, dto\.email/); - assert.match(usersService, /assertInspectorHasEmail\(roles, before\.email\)/); - assert.match(usersService, /Los usuarios con rol Inspector deben tener un email válido/); - assert.match(usersService, /dto\.email !== undefined && !dto\.email && before\.roles\.some/); +test('F3.1 email del Inspector sigue protegido y F6.2 endurece email para todo usuario', () => { + assert.match(usersService, /USER_EMAIL_REQUIRED/); + assert.match(usersService, /Cada usuario de Hidrocarburos debe tener un email válido/); + assert.match(usersService, /if \(!dto\.email\?\.trim\(\)\) throw userEmailRequired\(\)/); + assert.match(usersService, /dto\.email !== undefined && !dto\.email/); }); test('F3.1 dossier canónico agrega alias sin borrar identidad histórica', () => { diff --git a/api-v3/test/unit/f4-health-metadata.test.ts b/api-v3/test/unit/f4-health-metadata.test.ts index d164e12..e107c8f 100644 --- a/api-v3/test/unit/f4-health-metadata.test.ts +++ b/api-v3/test/unit/f4-health-metadata.test.ts @@ -5,8 +5,8 @@ import { resolve } from 'node:path'; import { API_PHASE, API_VERSION } from '../../src/version'; test('health metadata reports the current F6.1 release', () => { - assert.equal(API_PHASE, 'F6.1'); + assert.equal(API_PHASE, 'F6.2'); const pkg = JSON.parse(readFileSync(resolve(process.cwd(), 'package.json'), 'utf8')) as { version: string }; assert.equal(API_VERSION, pkg.version); - assert.equal(API_VERSION, '0.29.0-3'); + assert.equal(API_VERSION, '0.29.0-4'); }); \ No newline at end of file diff --git a/api-v3/test/unit/f5-android-test-cut.test.ts b/api-v3/test/unit/f5-android-test-cut.test.ts index e8deca0..0b2d424 100644 --- a/api-v3/test/unit/f5-android-test-cut.test.ts +++ b/api-v3/test/unit/f5-android-test-cut.test.ts @@ -10,8 +10,8 @@ function mountedRepoFile(path: string): string { test('F6.3 Android test cut targets production API and has a distinct installable debug version', () => { const gradle = mountedRepoFile('android-app/app/build.gradle.kts'); - assert.match(gradle, /versionCode = 30/); - assert.match(gradle, /versionName = "0\.19\.2"/); + assert.match(gradle, /versionCode = 31/); + assert.match(gradle, /versionName = "0\.19\.3"/); assert.match(gradle, /https:\/\/dhv2\.korexlabs\.com\/api\/v3\//); assert.match(gradle, /applicationIdSuffix = "\.debug"/); }); diff --git a/api-v3/test/unit/f6-1-presentation-ready-contract.test.ts b/api-v3/test/unit/f6-1-presentation-ready-contract.test.ts index 329346a..0ac1e7e 100644 --- a/api-v3/test/unit/f6-1-presentation-ready-contract.test.ts +++ b/api-v3/test/unit/f6-1-presentation-ready-contract.test.ts @@ -13,7 +13,7 @@ test('F6.1 presentation metadata keeps the visible WEB version aligned with pack const visibleVersion = version.match(/APP_VERSION\s*=\s*'([^']+)'/)?.[1]; assert.equal(visibleVersion, pkg.version); - assert.match(version, /APP_PHASE\s*=\s*'F6\.1 · Contexto operativo Área–Operadora consolidado'/); + assert.match(version, /APP_PHASE\s*=\s*'F6\.2 · Firma por Acta y correo de usuario'/); }); test('F6.1 presentation keeps Relevamientos retired from WEB navigation and routes', () => { @@ -33,7 +33,7 @@ test('F6.1 presentation keeps the complete Inspector profile and documentary-cop for (const field of ['dni', 'phone', 'jobTitle', 'employeeNumber']) { assert.match(user, new RegExp(`name="${field}"`)); } - assert.match(user, /email es obligatorio para un Inspector/i); + assert.match(user, /email es obligatorio para todos los usuarios de Hidrocarburos/i); assert.match(user, /la documentación se enviará también/i); assert.match(delivery, /recipientKind:'INSPECTOR'/); assert.match(delivery, /documentKind:'ACT_PDF'/); diff --git a/api-v3/test/unit/f6-2-act-representative-user-smtp.test.ts b/api-v3/test/unit/f6-2-act-representative-user-smtp.test.ts new file mode 100644 index 0000000..5b9d987 --- /dev/null +++ b/api-v3/test/unit/f6-2-act-representative-user-smtp.test.ts @@ -0,0 +1,72 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const read = (path: string) => readFileSync(resolve(process.cwd(), path), 'utf8'); +const migration = read('src/database/migrations/1790117400000-f6-2-user-smtp-and-act-representative.ts'); +const closing = read('src/inspection-closing/inspection-closing.service.ts'); +const responsibleDto = read('src/inspection-closing/dto/upsert-inspection-responsible.dto.ts'); +const prepareDto = read('src/inspection-closing/dto/prepare-inspection-act.dto.ts'); +const findingEntity = read('src/database/entities/inspection-finding.entity.ts'); +const smtp = read('src/inspection-reports/smtp-delivery.service.ts'); +const users = read('src/administration/users/users.controller.ts'); +const delivery = read('src/inspection-reports/inspection-document-delivery.service.ts'); + +const android = read('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/ModernMobileActsScreen.kt'); +const androidModel = read('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/MobileActs.kt'); +const profilePage = read('../web-v2/src/pages/MyProfilePage.tsx'); + +test('F6.2 keeps urgency exclusively at Act close, never on individual findings', () => { + assert.match(prepareDto, /InspectionActUrgency/); + assert.match(prepareDto, /urgency!/); + assert.doesNotMatch(findingEntity, /\burgency\b/i); + assert.match(android, /Urgencia del Acta/); + assert.match(android, /urgencia no se asigna a cada Hallazgo/i); +}); + +test('F6.2 requires the company representative identity and email on every Act', () => { + assert.match(responsibleDto, /attendanceStatus === InspectionResponsibleAttendanceStatus\.PRESENT/); + assert.match(responsibleDto, /@IsEmail\(\)/); + assert.match(migration, /LENGTH\(TRIM\(COALESCE\(email, ''\)\)\) > 0/); + assert.match(closing, /representativeSuggestion/); + assert.match(closing, /previous_act\.visit_id=current_act\.visit_id/); + assert.match(androidModel, /representativeSuggestion/); + assert.match(android, /Nombres y apellidos \*/); + assert.match(android, /DNI \*/); + assert.match(android, /Cargo \/ función \*/); + assert.match(android, /Email \*/); + assert.match(android, /Firma del representante de la empresa/); + assert.match(android, /En disconformidad/); + assert.match(android, /Motivo de disconformidad \*/); +}); + +test('F6.2 gives every authenticated user a general SMTP default and encrypted custom override', () => { + assert.match(migration, /CREATE TABLE user_smtp_settings/); + assert.match(migration, /ALTER COLUMN email SET NOT NULL/); + assert.match(migration, /mode IN \('SYSTEM','CUSTOM'\)/); + assert.match(smtp, /publicUserSettings/); + assert.match(smtp, /saveUserSettings/); + assert.match(smtp, /source:\s*["']USER["']/); + assert.match(smtp, /encryptSecret/); + assert.match(smtp, /password_enc/); +}); + +test('F6.2 exposes personal mail settings in Mi perfil without granting administration permissions', () => { + assert.match(users, /@Get\('self\/profile'\)/); + assert.match(users, /@Patch\('self\/profile'\)/); + assert.match(users, /@Get\('self\/smtp'\)/); + assert.match(users, /@Put\('self\/smtp'\)/); + assert.match(users, /@Post\('self\/smtp\/test'\)/); + assert.match(profilePage, /MI PERFIL/); + assert.match(profilePage, /Usar SMTP general/); + assert.match(profilePage, /Usar SMTP propio/); + assert.match(profilePage, /Enviar correo de prueba/); + assert.doesNotMatch(profilePage, /document_delivery\.manage/); +}); + +test('F6.2 sends Act-related mail through the lead inspector transport selection', () => { + assert.match(delivery, /lead_inspector_user_id AS "userId"/); + assert.match(delivery, /configured\(senderUserId\)/); + assert.match(delivery, /}, senderUserId\);/); +}); diff --git a/web-v2/package-lock.json b/web-v2/package-lock.json index 78ccd27..1917d9e 100644 --- a/web-v2/package-lock.json +++ b/web-v2/package-lock.json @@ -1,12 +1,12 @@ { "name": "dhv2-web", - "version": "0.23.0-2", + "version": "0.23.0-3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dhv2-web", - "version": "0.23.0-2", + "version": "0.23.0-3", "dependencies": { "maplibre-gl": "6.4.1", "react": "^19.0.0", diff --git a/web-v2/package.json b/web-v2/package.json index e2e225b..57e18ab 100644 --- a/web-v2/package.json +++ b/web-v2/package.json @@ -1,6 +1,6 @@ { "name": "dhv2-web", - "version": "0.23.0-2", + "version": "0.23.0-3", "private": true, "type": "module", "engines": { diff --git a/web-v2/src/app/App.tsx b/web-v2/src/app/App.tsx index cf4bee0..08bf1f9 100644 --- a/web-v2/src/app/App.tsx +++ b/web-v2/src/app/App.tsx @@ -9,6 +9,7 @@ import { CompanySignaturePage } from '../pages/CompanySignaturePage'; import { DashboardPage } from '../pages/DashboardPage'; import { LoginPage } from '../pages/LoginPage'; import { NewUserPage } from '../pages/NewUserPage'; +import { MyProfilePage } from '../pages/MyProfilePage'; import { RolesPage } from '../pages/RolesPage'; import { AccessDeniedPage, NotFoundPage } from '../pages/SystemPages'; import { UserDetailPage } from '../pages/UserDetailPage'; @@ -45,6 +46,7 @@ export function App() { }> } /> }> + } /> }>} /> }>} /> }> diff --git a/web-v2/src/config/version.ts b/web-v2/src/config/version.ts index 320e04c..fa58f0a 100644 --- a/web-v2/src/config/version.ts +++ b/web-v2/src/config/version.ts @@ -1,2 +1,2 @@ -export const APP_VERSION = '0.23.0-2'; -export const APP_PHASE = 'F6.1 · Contexto operativo Área–Operadora consolidado'; +export const APP_VERSION = '0.23.0-3'; +export const APP_PHASE = 'F6.2 · Firma por Acta y correo de usuario'; diff --git a/web-v2/src/layout/AppLayout.tsx b/web-v2/src/layout/AppLayout.tsx index 898e860..17876da 100644 --- a/web-v2/src/layout/AppLayout.tsx +++ b/web-v2/src/layout/AppLayout.tsx @@ -100,11 +100,13 @@ export function AppLayout() {
-
{user?.firstName?.[0]}{user?.lastName?.[0]}
-
- {user?.firstName} {user?.lastName} - {user?.roles.join(' · ') || user?.username} -
+ +
{user?.firstName?.[0]}{user?.lastName?.[0]}
+
+ {user?.firstName} {user?.lastName} + Mi perfil · {user?.roles.join(' · ') || user?.username} +
+
diff --git a/web-v2/src/lib/api.ts b/web-v2/src/lib/api.ts index e644809..940110d 100644 --- a/web-v2/src/lib/api.ts +++ b/web-v2/src/lib/api.ts @@ -98,6 +98,7 @@ export interface AdministrativeUser { createdAt: string; updatedAt: string; roles: RoleSummary[]; + smtpMode: 'SYSTEM' | 'CUSTOM'; } export interface Permission { id: string; code: string; description: string } diff --git a/web-v2/src/lib/myProfileApi.ts b/web-v2/src/lib/myProfileApi.ts new file mode 100644 index 0000000..8c9f2d8 --- /dev/null +++ b/web-v2/src/lib/myProfileApi.ts @@ -0,0 +1,67 @@ +import { apiRequest } from './api'; +import type { AdministrativeUserProfile } from './userProfileApi'; + +export type UserSmtpMode = 'SYSTEM' | 'CUSTOM'; +export type UserSmtpSecurityMode = 'NONE' | 'STARTTLS' | 'TLS'; + +export interface UserSmtpCustomSettings { + host: string; + port: number; + securityMode: UserSmtpSecurityMode; + username: string; + hasPassword: boolean; + fromName: string; + fromEmail: string; + enabled: boolean; + updatedAt: string | null; +} + +export interface UserSmtpSettings { + mode: UserSmtpMode; + email: string; + generalConfigured: boolean; + custom: UserSmtpCustomSettings | null; +} + +export interface UserSmtpSettingsInput { + mode: UserSmtpMode; + host?: string; + port?: number; + securityMode?: UserSmtpSecurityMode; + username?: string | null; + password?: string | null; + fromName?: string | null; + enabled?: boolean; +} + +export function getSelfProfile() { + return apiRequest('/users/self/profile'); +} + +export function updateSelfProfile(input: { + email: string; + phone?: string | null; + jobTitle?: string | null; +}) { + return apiRequest('/users/self/profile', { + method: 'PATCH', + body: JSON.stringify(input), + }); +} + +export function getSelfSmtpSettings() { + return apiRequest('/users/self/smtp'); +} + +export function saveSelfSmtpSettings(input: UserSmtpSettingsInput) { + return apiRequest('/users/self/smtp', { + method: 'PUT', + body: JSON.stringify(input), + }); +} + +export function testSelfSmtpSettings() { + return apiRequest<{ ok: boolean; recipient: string; messageId: string }>('/users/self/smtp/test', { + method: 'POST', + }); +} diff --git a/web-v2/src/lib/userProfileApi.ts b/web-v2/src/lib/userProfileApi.ts index 6b6b365..63a9745 100644 --- a/web-v2/src/lib/userProfileApi.ts +++ b/web-v2/src/lib/userProfileApi.ts @@ -21,6 +21,7 @@ export interface UserProfileInput { export interface CreateUserProfileInput extends UserProfileInput { username: string; + email: string; firstName: string; lastName: string; password: string; diff --git a/web-v2/src/pages/MyProfilePage.tsx b/web-v2/src/pages/MyProfilePage.tsx new file mode 100644 index 0000000..838cc48 --- /dev/null +++ b/web-v2/src/pages/MyProfilePage.tsx @@ -0,0 +1,193 @@ +import { useEffect, useState } from 'react'; +import type { FormEvent } from 'react'; +import { Alert, LoadingBlock, errorMessage } from '../components/Feedback'; +import { useAuth } from '../auth/AuthContext'; +import { initials } from '../lib/format'; +import { + getSelfProfile, + getSelfSmtpSettings, + saveSelfSmtpSettings, + testSelfSmtpSettings, + updateSelfProfile, +} from '../lib/myProfileApi'; +import type { + UserSmtpMode, + UserSmtpSecurityMode, + UserSmtpSettings, +} from '../lib/myProfileApi'; +import type { AdministrativeUserProfile } from '../lib/userProfileApi'; + +export function MyProfilePage() { + const { user } = useAuth(); + const [profile, setProfile] = useState(null); + const [smtp, setSmtp] = useState(null); + const [mode, setMode] = useState('SYSTEM'); + const [host, setHost] = useState(''); + const [port, setPort] = useState(587); + const [securityMode, setSecurityMode] = useState('STARTTLS'); + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [fromName, setFromName] = useState(''); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(''); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(''); + + const applySmtp = (settings: UserSmtpSettings) => { + setSmtp(settings); + setMode(settings.mode); + setHost(settings.custom?.host ?? ''); + setPort(settings.custom?.port ?? 587); + setSecurityMode(settings.custom?.securityMode ?? 'STARTTLS'); + setUsername(settings.custom?.username ?? ''); + setFromName(settings.custom?.fromName ?? ''); + setPassword(''); + }; + + const load = async () => { + setLoading(true); + try { + const [loadedProfile, loadedSmtp] = await Promise.all([ + getSelfProfile(), + getSelfSmtpSettings(), + ]); + setProfile(loadedProfile); + applySmtp(loadedSmtp); + } catch (requestError) { + setError(errorMessage(requestError)); + } finally { + setLoading(false); + } + }; + + useEffect(() => { void load(); }, []); + + const saveProfile = async (event: FormEvent) => { + event.preventDefault(); + if (!profile) return; + setError(''); setSuccess(''); setSaving('profile'); + try { + const form = new FormData(event.currentTarget); + const updated = await updateSelfProfile({ + email: String(form.get('email') ?? '').trim(), + phone: String(form.get('phone') ?? '').trim() || null, + jobTitle: String(form.get('jobTitle') ?? '').trim() || null, + }); + setProfile(updated); + setSuccess('Perfil actualizado correctamente.'); + } catch (requestError) { + setError(errorMessage(requestError)); + } finally { + setSaving(''); + } + }; + + const saveSmtp = async () => { + setError(''); setSuccess(''); setSaving('smtp'); + try { + const updated = await saveSelfSmtpSettings(mode === 'SYSTEM' ? { + mode: 'SYSTEM', + } : { + mode: 'CUSTOM', host: host.trim(), port, securityMode, + username: username.trim() || null, + password: password || undefined, + fromName: fromName.trim() || null, + enabled: true, + }); + applySmtp(updated); + setSuccess(mode === 'SYSTEM' ? 'Usarás el SMTP general.' : 'SMTP propio guardado.'); + } catch (requestError) { + setError(errorMessage(requestError)); + } finally { + setSaving(''); + } + }; + + const testSmtp = async () => { + setError(''); setSuccess(''); setSaving('test'); + try { + const result = await testSelfSmtpSettings(); + setSuccess(`Correo de prueba enviado a ${result.recipient}.`); + } catch (requestError) { + setError(errorMessage(requestError)); + } finally { + setSaving(''); + } + }; + + if (loading) return ; + if (!profile || !smtp) return {error || 'No se pudo cargar el perfil.'}; + + return
+
+
+ {initials(profile.firstName, profile.lastName)} +
+ MI PERFIL +

{profile.firstName} {profile.lastName}

+

@{profile.username} · {profile.jobTitle || 'Función sin informar'}

+
+
+
+ {error && {error}} + {success && {success}} + +
+
+
+
CONTACTO

Datos del usuario

+
+
+ + + + +
+
+ +
+
+ +
+
+
CORREO SALIENTE

SMTP personal

+
+

Por defecto, tus correos salen mediante el SMTP general. Podés cambiar a una cuenta SMTP propia con un clic.

+
+ + +
+ {mode === 'CUSTOM' && <> +
+ + + + + + +
+

La contraseña se cifra en el servidor y nunca vuelve a mostrarse. El remitente será {profile.email}.

+ } +
+ + +
+
+
+
+
SEGURIDAD

Cuenta

+

Tu email es obligatorio para operar en DH Inspección. La contraseña SMTP nunca se muestra ni se incluye en auditorías.

+

Usuario autenticado: @{user?.username ?? profile.username}

+
+
; +} diff --git a/web-v2/src/pages/NewUserPage.tsx b/web-v2/src/pages/NewUserPage.tsx index 69a918a..f68eeb8 100644 --- a/web-v2/src/pages/NewUserPage.tsx +++ b/web-v2/src/pages/NewUserPage.tsx @@ -30,15 +30,15 @@ export function NewUserPage() { setError(''); const data = new FormData(event.currentTarget); const email = String(data.get('email') ?? '').trim(); - if (inspectorSelected && !email) { - setError('El email es obligatorio para usuarios con rol Inspector porque allí recibirán la documentación de sus inspecciones.'); + if (!email) { + setError('El email es obligatorio para todos los usuarios de Hidrocarburos.'); return; } setSubmitting(true); try { const created = await createUserProfile({ username: String(data.get('username')), - email: email || null, + email, dni: String(data.get('dni') ?? '').trim() || null, phone: String(data.get('phone') ?? '').trim() || null, jobTitle: String(data.get('jobTitle') ?? '').trim() || null, @@ -75,10 +75,10 @@ export function NewUserPage() {
-

Acceso y contacto

El email del Inspector se utiliza también como destinatario de la documentación al cerrar la inspección.

+

Acceso y contacto

Cada usuario debe tener un email institucional. Los Inspectores también lo utilizan en la entrega documental de sus Actas.

- +
{inspectorSelected &&

Inspector: este email recibirá copia de las Actas/Informe correspondientes al cierre de la inspección.

}
diff --git a/web-v2/src/pages/UserDetailPage.tsx b/web-v2/src/pages/UserDetailPage.tsx index 9ad3f38..7c03ee1 100644 --- a/web-v2/src/pages/UserDetailPage.tsx +++ b/web-v2/src/pages/UserDetailPage.tsx @@ -57,8 +57,8 @@ export function UserDetailPage() { setError(''); setSuccess(''); const data = new FormData(event.currentTarget); const email = String(data.get('email') ?? '').trim(); - if (inspectorSelected && !email) { - setError('El email es obligatorio para un Inspector porque allí recibe la documentación de sus inspecciones.'); + if (!email) { + setError('El email es obligatorio para todos los usuarios de Hidrocarburos.'); return; } setSaving('profile'); @@ -139,6 +139,7 @@ export function UserDetailPage() {
PERFIL

Datos personales y contacto

{user.status === 'ACTIVE' ? 'Activo' : 'Inactivo'}
+

Correo saliente: {user.smtpMode === 'CUSTOM' ? 'SMTP propio' : 'SMTP general'}. Cada usuario administra su modalidad desde Mi perfil.

@@ -147,7 +148,7 @@ export function UserDetailPage() { - +
{inspectorSelected &&

Destinatario del Inspector: al finalizar una inspección, la documentación se enviará también a {user.email || 'este email cuando lo completes'}.

}
Último acceso{formatDate(user.lastLoginAt)}
Último cambio de clave{formatDate(user.passwordChangedAt)}
Intentos fallidos{user.failedLoginAttempts}
Bloqueado hasta{formatDate(user.lockedUntil)}
diff --git a/web-v2/src/styles.css b/web-v2/src/styles.css index 2fd064e..d84b876 100644 --- a/web-v2/src/styles.css +++ b/web-v2/src/styles.css @@ -46,6 +46,8 @@ a { color: inherit; } .nav-link.active .icon { color: #73a0ff; } .sidebar-user { display: grid; grid-template-columns: auto minmax(0,1fr) auto; gap: 10px; align-items: center; margin-top: auto; padding: 16px 6px 0; border-top: 1px solid rgba(255,255,255,.09); } +.sidebar-user-profile { display:grid; grid-template-columns:auto minmax(0,1fr); gap:10px; align-items:center; min-width:0; color:inherit; text-decoration:none; border-radius:10px; padding:4px; } +.sidebar-user-profile:hover { background:rgba(255,255,255,.06); } .user-avatar, .mini-avatar, .profile-avatar { display: grid; place-items: center; color: #2759c3; background: #dce8ff; font-weight: 800; } .user-avatar { width: 34px; height: 34px; border-radius: 50%; font-size: 11px; } .user-copy { min-width: 0; }