Compare commits

...

12 Commits

Author SHA1 Message Date
2b4678c26d - Sustituidas las confirmaciones genéricas del navegador por una ventana propia, responsive y accesible.
- Adaptados los avisos al importar, tramitar, rechazar y cambiar la asignación de denuncias de otros usuarios o grupos.
- Añadidos textos y botones específicos para cada operación.
- Actualizadas las instrucciones, sustituyendo “modal” por “ventana de configuración de la subida”.
- Aclarado el funcionamiento del asunto, grupo de destino y tercero.
2026-07-30 12:35:50 +02:00
8dedafdc07 - Corrige la comparación del hash del report para detectar cambios y comentarios nuevos.
- Elimina la generación de documentos artificiales para comentarios.
- Añade los estados Nueva denuncia, Actualización ciudadano, Actividad OAAF y Actualizada.
- Incorpora circuitos específicos de comunicación SAJ y SDI.
- Registra correctamente el tipo de última subida en el histórico.
- Añade confirmación al asignar una denuncia a un grupo distinto al del usuario.
- Mejora etiquetas, mensajes de ayuda e instrucciones de la aplicación.
- Añade la persistencia técnica necesaria para identificar el origen de cada actualización.
2026-07-29 14:47:35 +02:00
6f9392f3d0 cambiso en denuncias, mejoras de 30 min en inicio de sesion e incorporacion de grupos
Cambiso de certificado en registro de personal para aañadir el origen
2026-07-27 08:46:41 +02:00
dd31460cf0 arreglo certificados de registro 2026-07-23 12:37:06 +02:00
33c9c03822 comprobacion administrador registro cuando es con certificado 2026-07-23 10:16:56 +02:00
6636d9bed7 DEPARTAMENT 2026-07-23 10:16:56 +02:00
781fecbf42 - Añade el endpoint gestiona-fields por número de expediente de Gestiona.
- Mantiene disponible la consulta existente por número de denuncia.
- Resuelve la asociación mediante el histórico de subidas para conservar el cifrado de complaints.
- Elimina el reintento de llamadas a GlobalLeaks sin DPoP.
- Evita solicitar renovación 2FA ante errores de permisos o validación DPoP.
- Limita la renovación a señales reales de sesión inválida o expirada.
- Añade una traza específica para respuestas 412 de GlobalLeaks.
- Mejora los mensajes de error diferenciando permisos, autenticación y DPoP.
2026-07-13 15:01:38 +02:00
10214805e3 Implementa endpoint externo de campos de denuncia para Gestiona
Añade respuesta en formato data con campos tipados STRING.
Mejora la extracción de campos desde JSON de GlobalLeaks para reports PDF.
Protege respuestas vacías de Gestiona al buscar/enlazar terceros.
Mantiene trazas seguras para campos externos vacíos sin registrar datos sensibles.
2026-07-10 14:02:27 +02:00
2414fbe80c Mejoras en elevaciones, equilibrado y callejero inverso
Añadida visualización de pendientes en los tramos a pie con puntos coloreados: llano, subida y bajada.
Añadidos umbrales configurables para subida y bajada junto al interruptor de elevaciones.
Añadido callejero inverso en el detalle de la solución, mostrando el lugar del callejero más cercano al origen y al destino.
Ajustado el modo Equilib. A para limpiar la tarjeta visualmente y guardar/restaurar el factor usado desde el historial.
Mejoras menores en el refresco del resumen técnico de rutas y validación de compilación.
2026-07-10 14:00:22 +02:00
1fec0ae0f5 Merge branch 'main' of https://gitea.tecnosis.net/Antifraude/Antifraude.Net 2026-07-09 14:55:57 +02:00
b6c61238a5 Añadir endpoint de campos para Gestiona y adaptar login GlobalLeaks a DPoP
- Añade GET /api/denuncias/{id}/gestiona-fields para exponer los campos diarios requeridos por la integración con Gestiona.
- Devuelve un DTO cerrado con fecha, canal, resumen, datos de hechos, protección, sexo y preferencias de notificación.
- Adapta el login contra GlobalLeaks al nuevo flujo DPoP exigido desde la versión 5.0.94.
- Genera proof DPoP con clave EC P-256 efímera y lo envía en la cabecera DPoP junto a X-Token.
- Mejora el mensaje de error cuando GlobalLeaks rechaza el proof DPoP.
2026-07-09 14:55:29 +02:00
087e439b54 Arreglo Departamento datos administrativos 2026-07-09 11:40:13 +02:00
77 changed files with 20455 additions and 956 deletions

View File

@@ -23,6 +23,7 @@
<ItemGroup> <ItemGroup>
<Content Include="Scripts\gestiondenuncias_schema.sql" CopyToOutputDirectory="PreserveNewest" /> <Content Include="Scripts\gestiondenuncias_schema.sql" CopyToOutputDirectory="PreserveNewest" />
<Content Include="Scripts\gestiondenuncias_envelope_encryption.sql" CopyToOutputDirectory="PreserveNewest" /> <Content Include="Scripts\gestiondenuncias_envelope_encryption.sql" CopyToOutputDirectory="PreserveNewest" />
<Content Include="Scripts\gestiondenuncias_gestiona_expediente_excepciones.sql" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -4,14 +4,21 @@ namespace ApiDenuncias.Configuration
{ {
public string ApiBase { get; set; } = null!; public string ApiBase { get; set; } = null!;
public string AccessToken { get; set; } = null!; public string AccessToken { get; set; } = null!;
public string UserLink { get; set; } = null!; public string? ProcedureName { get; set; }
public string GroupLink { get; set; } = null!; public string? ExternalProcedureName { get; set; }
public string Location { get; set; } = null!; public string? ExternalProcedureSiaCode { get; set; }
public string? ExternalProcedureId { get; set; } public string? ManagementUnitGroupCode { get; set; }
public string? CircuitTemplateId { get; set; } public string? CircuitTemplateName { get; set; }
public string? CircuitSignerStampHref { get; set; } public string? CircuitNewComplaintTemplateName { get; set; }
public string? CircuitUpdateTemplateName { get; set; }
public string? CircuitUpdateSajTemplateName { get; set; }
public string? CircuitUpdateSdiTemplateName { get; set; }
public string? CircuitCommunicationSajTemplateName { get; set; }
public string? CircuitCommunicationSdiTemplateName { get; set; }
public string? CircuitSignerStampTitle { get; set; } public string? CircuitSignerStampTitle { get; set; }
public string? CircuitRecipientGroupHref { get; set; }
public string? CircuitVersion { get; set; } public string? CircuitVersion { get; set; }
public string? DocumentMetadataLanguage { get; set; }
public string? DocumentMetadataType { get; set; }
public string? DocumentMetadataSubtype { get; set; }
} }
} }

View File

@@ -304,7 +304,15 @@ public sealed class AuthController : ControllerBase
: session.Username.Trim(); : session.Username.Trim();
_logger.LogInformation("Login GlobalLeaks validado para {Username}. Guardando sesion cifrada.", username); _logger.LogInformation("Login GlobalLeaks validado para {Username}. Guardando sesion cifrada.", username);
await _sessionStore.SaveAsync(username, password, session.Id, session.Role, cancellationToken); await _sessionStore.SaveAsync(
username,
password,
session.Id,
session.Role,
session.DpopPrivateKey,
session.ProofOfWorkToken,
session.SessionExpiresAtUtc,
cancellationToken);
_logger.LogInformation("Sesion GlobalLeaks guardada para {Username}. Generando JWT.", username); _logger.LogInformation("Sesion GlobalLeaks guardada para {Username}. Generando JWT.", username);
var expiresAtUtc = DateTimeOffset.UtcNow.AddMinutes(Math.Max(5, _jwtOptions.ExpirationMinutes)); var expiresAtUtc = DateTimeOffset.UtcNow.AddMinutes(Math.Max(5, _jwtOptions.ExpirationMinutes));

View File

@@ -12,10 +12,14 @@ namespace ApiDenuncias.Controllers;
public sealed class ConfigurationController : ControllerBase public sealed class ConfigurationController : ControllerBase
{ {
private readonly AppConfigurationService _configurationService; private readonly AppConfigurationService _configurationService;
private readonly WorkGroupAdministrationService _workGroupService;
public ConfigurationController(AppConfigurationService configurationService) public ConfigurationController(
AppConfigurationService configurationService,
WorkGroupAdministrationService workGroupService)
{ {
_configurationService = configurationService; _configurationService = configurationService;
_workGroupService = workGroupService;
} }
[HttpGet] [HttpGet]
@@ -25,6 +29,7 @@ public sealed class ConfigurationController : ControllerBase
} }
[HttpPut("external-update-cutoff")] [HttpPut("external-update-cutoff")]
[Authorize(Policy = "ConfigurationAdministrators")]
public async Task<ActionResult<AppConfigurationDto>> SetExternalUpdateCutoff( public async Task<ActionResult<AppConfigurationDto>> SetExternalUpdateCutoff(
UpdateExternalUpdateCutoffRequest request, UpdateExternalUpdateCutoffRequest request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@@ -47,4 +52,46 @@ public sealed class ConfigurationController : ControllerBase
return Ok(await _configurationService.SetExternalUpdateCutoffDateAsync(date, cancellationToken)); return Ok(await _configurationService.SetExternalUpdateCutoffDateAsync(date, cancellationToken));
} }
[HttpGet("work-groups")]
[Authorize(Policy = "ConfigurationAdministrators")]
public async Task<ActionResult<WorkGroupAdministrationDto>> GetWorkGroups(
CancellationToken cancellationToken)
{
return Ok(await _workGroupService.GetAsync(cancellationToken));
}
[HttpGet("work-groups/current")]
public async Task<ActionResult<CurrentUserWorkGroupsDto>> GetCurrentUserWorkGroups(
CancellationToken cancellationToken)
{
var username = User.Identity?.Name ??
throw new InvalidOperationException("No hay usuario autenticado.");
return Ok(await _workGroupService.GetUserGroupsAsync(username, cancellationToken));
}
[HttpPut("work-groups/users/{username}")]
[Authorize(Policy = "ConfigurationAdministrators")]
public async Task<ActionResult<WorkGroupAdministrationDto>> SetUserWorkGroups(
string username,
UpdateUserWorkGroupsRequest request,
CancellationToken cancellationToken)
{
var changedBy = User.Identity?.Name ??
throw new InvalidOperationException("No hay usuario autenticado.");
try
{
return Ok(await _workGroupService.UpdateUserGroupsAsync(
username,
request.GroupCodes ?? [],
changedBy,
cancellationToken));
}
catch (InvalidOperationException ex)
{
return BadRequest(new ApiError(ex.Message));
}
}
} }

View File

@@ -1,3 +1,6 @@
using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;
using ApiDenuncias.Services; using ApiDenuncias.Services;
using GestionaDenuncias.Shared.Models; using GestionaDenuncias.Shared.Models;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
@@ -13,15 +16,21 @@ public sealed class DenunciasController : ControllerBase
private readonly IDenunciaStore _denunciaStore; private readonly IDenunciaStore _denunciaStore;
private readonly IFilteredDenunciaStore _filteredDenunciaStore; private readonly IFilteredDenunciaStore _filteredDenunciaStore;
private readonly UserComplaintAccessService _accessService; private readonly UserComplaintAccessService _accessService;
private readonly IInboxTrackingService _trackingService;
private readonly ILogger<DenunciasController> _logger;
public DenunciasController( public DenunciasController(
IDenunciaStore denunciaStore, IDenunciaStore denunciaStore,
IFilteredDenunciaStore filteredDenunciaStore, IFilteredDenunciaStore filteredDenunciaStore,
UserComplaintAccessService accessService) UserComplaintAccessService accessService,
IInboxTrackingService trackingService,
ILogger<DenunciasController> logger)
{ {
_denunciaStore = denunciaStore; _denunciaStore = denunciaStore;
_filteredDenunciaStore = filteredDenunciaStore; _filteredDenunciaStore = filteredDenunciaStore;
_accessService = accessService; _accessService = accessService;
_trackingService = trackingService;
_logger = logger;
} }
[HttpPost("schema/ensure")] [HttpPost("schema/ensure")]
@@ -36,13 +45,22 @@ public sealed class DenunciasController : ControllerBase
[FromQuery] DenunciaListScope scope, [FromQuery] DenunciaListScope scope,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var allowedIds = await GetAllowedIdsAsync(cancellationToken); await _denunciaStore.EnsureSchemaAsync(cancellationToken);
if (allowedIds.Count == 0) var access = await _accessService.GetComplaintAccessAsync(
GetUsername(),
null,
cancellationToken);
if (access.Count == 0)
{ {
return Ok(new List<DenunciasGestiona>()); return Ok(new List<DenunciasGestiona>());
} }
return Ok(await _filteredDenunciaStore.GetDenunciasByIdsAsync(allowedIds, scope, cancellationToken)); var complaints = await _filteredDenunciaStore.GetDenunciasByIdsAsync(
access.Keys.ToArray(),
scope,
cancellationToken);
ApplyAccessMetadata(complaints, access);
return Ok(complaints);
} }
[HttpGet("{denunciaId:int}")] [HttpGet("{denunciaId:int}")]
@@ -53,7 +71,79 @@ public sealed class DenunciasController : ControllerBase
return NotFound(); return NotFound();
} }
return Ok(await _denunciaStore.GetDenunciaByIdAsync(denunciaId, cancellationToken)); var complaint = await _denunciaStore.GetDenunciaByIdAsync(denunciaId, cancellationToken);
if (complaint is null)
{
return Ok(null);
}
var access = await _accessService.GetComplaintAccessAsync(
GetUsername(),
[denunciaId],
cancellationToken);
ApplyAccessMetadata([complaint], access);
return Ok(complaint);
}
[AllowAnonymous]
[HttpGet("{denunciaId:int}/gestiona-fields")]
public async Task<ActionResult<GestionaExternalFieldsResponse>> GetGestionaFields(
int denunciaId,
CancellationToken cancellationToken)
{
if (denunciaId <= 0)
{
return BadRequest(new ApiError("Debes indicar un numero de denuncia valido."));
}
var denuncia = await _denunciaStore.GetDenunciaByIdAsync(denunciaId, cancellationToken);
if (denuncia is null)
{
return NotFound(new ApiError("No se ha encontrado la denuncia solicitada."));
}
return BuildGestionaFieldsResponse(denuncia);
}
[AllowAnonymous]
[HttpGet("{numeroExpediente:int}/{anioExpediente:int}/gestiona-fields")]
public async Task<ActionResult<GestionaExternalFieldsResponse>> GetGestionaFieldsByExpediente(
int numeroExpediente,
int anioExpediente,
CancellationToken cancellationToken)
{
if (numeroExpediente <= 0 || anioExpediente <= 0)
{
return BadRequest(new ApiError("Debes indicar un numero de expediente de Gestiona valido."));
}
var expedienteGestiona = $"{numeroExpediente.ToString(CultureInfo.InvariantCulture)}/{anioExpediente.ToString(CultureInfo.InvariantCulture)}";
var denuncia = await _filteredDenunciaStore.GetDenunciaByGestionaFileCodeAsync(
expedienteGestiona,
cancellationToken);
if (denuncia is null)
{
return NotFound(new ApiError("No se ha encontrado una denuncia asociada al expediente de Gestiona solicitado."));
}
return BuildGestionaFieldsResponse(denuncia);
}
private ActionResult<GestionaExternalFieldsResponse> BuildGestionaFieldsResponse(DenunciasGestiona denuncia)
{
var response = ToGestionaComplaintFields(denuncia);
var missingFields = GetMissingGestionaFields(response);
if (missingFields.Count > 0)
{
_logger.LogInformation(
"Campos Gestiona denuncia {DenunciaId}: campos vacios={MissingFields}; rawReportLength={RawReportLength}; formFieldsCount={FormFieldsCount}.",
denuncia.Id_Denuncia,
string.Join(", ", missingFields),
denuncia.TextoOriginalReport?.Length ?? 0,
denuncia.GetCamposFormulario().Count);
}
return Ok(ToExternalGestionaFields(response));
} }
[HttpPost] [HttpPost]
@@ -65,6 +155,7 @@ public sealed class DenunciasController : ControllerBase
} }
await _denunciaStore.UpsertDenunciaAsync(denuncia, cancellationToken); await _denunciaStore.UpsertDenunciaAsync(denuncia, cancellationToken);
await TryRegisterGestionaHistoryFromComplaintAsync(denuncia, cancellationToken);
return Ok(new { ok = true }); return Ok(new { ok = true });
} }
@@ -91,6 +182,32 @@ public sealed class DenunciasController : ControllerBase
return Ok(await _denunciaStore.GetFicherosByDenunciaAsync(denunciaId, cancellationToken)); return Ok(await _denunciaStore.GetFicherosByDenunciaAsync(denunciaId, cancellationToken));
} }
[HttpGet("gestiona-history")]
public async Task<ActionResult<List<GestionaUploadHistoryEntry>>> GetGestionaHistory(CancellationToken cancellationToken)
{
await _denunciaStore.EnsureSchemaAsync(cancellationToken);
return Ok(await _denunciaStore.GetGestionaUploadHistoryAsync(cancellationToken));
}
[HttpPost("gestiona-history")]
public async Task<IActionResult> AddGestionaHistory(
GestionaUploadHistoryCreateRequest request,
CancellationToken cancellationToken)
{
if (!await CanAccessAsync(request.DenunciaId, cancellationToken))
{
return Forbid();
}
await _denunciaStore.AddGestionaUploadHistoryAsync(request, GetUsername(), cancellationToken);
await _trackingService.MarkReportHandledInGestionaAsync(
GetUsername(),
request.DenunciaId,
request.UploadedAtUtc,
cancellationToken);
return Ok(new { ok = true });
}
[HttpGet("{denunciaId:int}/ficheros/content")] [HttpGet("{denunciaId:int}/ficheros/content")]
public async Task<IActionResult> GetFicheroContent( public async Task<IActionResult> GetFicheroContent(
int denunciaId, int denunciaId,
@@ -178,6 +295,598 @@ public sealed class DenunciasController : ControllerBase
private string GetUsername() private string GetUsername()
=> User.Identity?.Name ?? throw new InvalidOperationException("No hay usuario autenticado."); => User.Identity?.Name ?? throw new InvalidOperationException("No hay usuario autenticado.");
private static void ApplyAccessMetadata(
IEnumerable<DenunciasGestiona> complaints,
IReadOnlyDictionary<int, ComplaintAccessInfo> access)
{
foreach (var complaint in complaints)
{
if (!access.TryGetValue(complaint.Id_Denuncia, out var item))
{
continue;
}
complaint.OwnerUsername = item.OwnerUsername;
complaint.OwnedByCurrentUser = item.OwnedByCurrentUser;
complaint.RequiresOwnerConfirmation = item.RequiresOwnerConfirmation;
complaint.OwnerWorkGroups = item.OwnerGroupCodes;
}
}
private static GestionaComplaintFieldsResponse ToGestionaComplaintFields(DenunciasGestiona denuncia)
{
var preferenciaNotificacion = ResolveNotificationPreference(denuncia);
var seguimiento = ResolveSeguimientoDenuncia(denuncia);
var sms = ResolveSmsNotification(denuncia);
return new GestionaComplaintFieldsResponse(
FechaDenuncia: ToNullableDate(denuncia.Fecha),
NumeroDenunciaCanal: denuncia.Id_Denuncia,
AQuienDenuncia: ResolveReportField(denuncia, denuncia.A_Quien_Denuncia, "a quien denuncia"),
ResumenDenuncia: FirstNonEmpty(
ResolveReportField(denuncia, string.Empty, "resumen de la denuncia", "resumen denuncia"),
ResolveReportField(denuncia, denuncia.Descripcion_Denuncia, "describa su denuncia", "descripcion de la denuncia")),
FechaHechos: ResolveFechaHechos(denuncia),
LugarHechos: ResolveLugarHechos(denuncia),
AmbitoCompetencias: ResolveAmbitoCompetencias(denuncia),
SolicitaProteccion: ResolveReportField(denuncia, denuncia.SolicitaProteccion, "solicita medidas concretas de proteccion", "solicita proteccion"),
SexoDenunciante: ResolveReportField(denuncia, denuncia.Sexo, "sexo"),
AutorizaRemisionDenuncia: ResolveReportField(denuncia, denuncia.AutorizaRemision, "autorizacion para remitir su denuncia", "autoriza remision de la denuncia"),
AutorizaNotificacionesViaSms: sms,
PreferenciaNotificacionSeguimientoDenuncia: JoinDistinct(preferenciaNotificacion, seguimiento));
}
private static GestionaExternalFieldsResponse ToExternalGestionaFields(GestionaComplaintFieldsResponse source)
{
return new GestionaExternalFieldsResponse(
new Dictionary<string, GestionaExternalFieldValue>(StringComparer.Ordinal)
{
["fechaDenuncia"] = StringField(source.FechaDenuncia?.ToString("O", CultureInfo.InvariantCulture) ?? string.Empty),
["numeroDenunciaCanal"] = StringField(source.NumeroDenunciaCanal.ToString(CultureInfo.InvariantCulture)),
["aQuienDenuncia"] = StringField(source.AQuienDenuncia),
["resumenDenuncia"] = StringField(source.ResumenDenuncia),
["fechaHechos"] = StringField(source.FechaHechos),
["lugarHechos"] = StringField(source.LugarHechos),
["ambitoCompetencias"] = StringField(source.AmbitoCompetencias),
["solicitaProteccion"] = StringField(source.SolicitaProteccion),
["sexoDenunciante"] = StringField(source.SexoDenunciante),
["autorizaRemisionDenuncia"] = StringField(source.AutorizaRemisionDenuncia),
["autorizaNotificacionesViaSms"] = StringField(source.AutorizaNotificacionesViaSms),
["preferenciaNotificacionSeguimientoDenuncia"] = StringField(source.PreferenciaNotificacionSeguimientoDenuncia)
});
}
private static GestionaExternalFieldValue StringField(string? value)
=> new("STRING", value?.Trim() ?? string.Empty);
private async Task TryRegisterGestionaHistoryFromComplaintAsync(
DenunciasGestiona denuncia,
CancellationToken cancellationToken)
{
if (!denuncia.EnGestiona || denuncia.FechaSubidaAGestiona == DateTime.MinValue)
{
return;
}
try
{
await _denunciaStore.AddGestionaUploadHistoryAsync(
new GestionaUploadHistoryCreateRequest(
denuncia.Id_Denuncia,
denuncia.Expediente_Gestiona ?? string.Empty,
denuncia.ExpedienteGestionaMostrable,
denuncia.UltimaSubidaGestionaTipoMostrable,
denuncia.UltimoGrupoAsignadoGestionaMostrable,
denuncia.FechaSubidaAGestiona,
denuncia.NombreDenuncia ?? string.Empty,
denuncia.ArchivoElegido ?? string.Empty),
GetUsername(),
cancellationToken);
await _trackingService.MarkReportHandledInGestionaAsync(
GetUsername(),
denuncia.Id_Denuncia,
denuncia.FechaSubidaAGestiona,
cancellationToken);
}
catch (Exception ex)
{
_logger.LogWarning(
ex,
"No se ha podido registrar el historico de subida a Gestiona para la denuncia {DenunciaId}.",
denuncia.Id_Denuncia);
}
}
private static DateTime? ToNullableDate(DateTime value)
=> value == DateTime.MinValue ? null : value;
private static IReadOnlyList<string> GetMissingGestionaFields(GestionaComplaintFieldsResponse response)
{
var missing = new List<string>();
AddIfMissing(missing, response.AQuienDenuncia, nameof(response.AQuienDenuncia));
AddIfMissing(missing, response.ResumenDenuncia, nameof(response.ResumenDenuncia));
AddIfMissing(missing, response.FechaHechos, nameof(response.FechaHechos));
AddIfMissing(missing, response.LugarHechos, nameof(response.LugarHechos));
AddIfMissing(missing, response.AmbitoCompetencias, nameof(response.AmbitoCompetencias));
AddIfMissing(missing, response.SolicitaProteccion, nameof(response.SolicitaProteccion));
AddIfMissing(missing, response.SexoDenunciante, nameof(response.SexoDenunciante));
AddIfMissing(missing, response.AutorizaRemisionDenuncia, nameof(response.AutorizaRemisionDenuncia));
AddIfMissing(missing, response.AutorizaNotificacionesViaSms, nameof(response.AutorizaNotificacionesViaSms));
AddIfMissing(missing, response.PreferenciaNotificacionSeguimientoDenuncia, nameof(response.PreferenciaNotificacionSeguimientoDenuncia));
return missing;
}
private static void AddIfMissing(List<string> missing, string? value, string fieldName)
{
if (string.IsNullOrWhiteSpace(value))
{
missing.Add(fieldName);
}
}
private static string ResolveFechaHechos(DenunciasGestiona denuncia)
{
var literalValue = ResolveReportField(
denuncia,
string.Empty,
"fecha de los hechos que denuncia",
"fecha de los hechos");
if (!string.IsNullOrWhiteSpace(literalValue))
{
return literalValue;
}
var textDate = ExtractDateAfterReportLabel(
denuncia.TextoOriginalReport,
normalizedLabel => normalizedLabel.Contains("fecha", StringComparison.Ordinal) &&
normalizedLabel.Contains("hechos", StringComparison.Ordinal));
if (!string.IsNullOrWhiteSpace(textDate))
{
return textDate;
}
return denuncia.Fecha_Hechos == DateTime.MinValue
? string.Empty
: denuncia.Fecha_Hechos.ToString("dd/MM/yyyy", CultureInfo.InvariantCulture);
}
private static string ResolveLugarHechos(DenunciasGestiona denuncia)
{
var directValue = ResolveReportField(
denuncia,
denuncia.Lugar_Hechos,
"lugar en el que ocurrieron los hechos que denuncia",
"lugar en la que ocurrieron los hechos que denuncia",
"lugar de los hechos");
if (!string.IsNullOrWhiteSpace(directValue))
{
return directValue;
}
return ExtractReportTextValue(
denuncia.TextoOriginalReport,
normalizedLabel => normalizedLabel.Contains("lugar", StringComparison.Ordinal) &&
normalizedLabel.Contains("hechos", StringComparison.Ordinal));
}
private static string ResolveSeguimientoDenuncia(DenunciasGestiona denuncia)
{
var directValue = ResolveReportField(
denuncia,
denuncia.SeguimientoOnline,
"seguimiento online",
"seguimiento de su denuncia");
if (!string.IsNullOrWhiteSpace(directValue))
{
return directValue;
}
return ReportContainsLabel(denuncia.TextoOriginalReport, "seguimiento online")
? "Seguimiento Online"
: string.Empty;
}
private static string ResolveNotificationPreference(DenunciasGestiona denuncia)
{
var directValue = ResolveReportField(
denuncia,
denuncia.Notificacion_Preferencia,
"preferencia de notificacion",
"seleccione su preferencia de notificacion y seguimiento de su denuncia",
"preferencia de notificacion y seguimiento de su denuncia",
"notificaciones");
if (!string.IsNullOrWhiteSpace(directValue))
{
return directValue;
}
directValue = ResolveReportFieldAllowingLabelValues(
denuncia,
string.Empty,
"preferencia de notificacion",
"seleccione su preferencia de notificacion y seguimiento de su denuncia",
"preferencia de notificacion y seguimiento de su denuncia");
if (!string.IsNullOrWhiteSpace(directValue))
{
return directValue;
}
var electronic = ResolveReportField(
denuncia,
denuncia.Notificacion_Electronica,
"notificaciones electronicas",
"notificacion electronica");
if (!string.IsNullOrWhiteSpace(electronic) ||
ReportContainsLabel(denuncia.TextoOriginalReport, "notificaciones electronicas"))
{
return "Notificaciones electronicas";
}
var postal = ResolveReportField(
denuncia,
denuncia.NotificacionPostal,
"autorizo recibir notificaciones via correo postal",
"notificaciones via correo postal",
"correo postal");
return string.IsNullOrWhiteSpace(postal) ? string.Empty : "Correo postal";
}
private static string ResolveSmsNotification(DenunciasGestiona denuncia)
{
return ResolveReportField(
denuncia,
denuncia.Notificacion_Sms,
"autorizo recibir notificaciones via sms",
"autorizacion notificaciones via sms",
"autorizacion para recibir notificaciones via sms",
"autoriza notificaciones via sms",
"autorizo recibir notificaciones sms",
"notificaciones via sms",
"sms");
}
private static string ResolveAmbitoCompetencias(DenunciasGestiona denuncia)
{
var directValue = ResolveReportField(
denuncia,
FirstNonEmpty(denuncia.Modalidad_Informacion, denuncia.Asunto),
"asunto",
"categoria",
"tipo de denuncia",
"ambito de competencias",
"ambito competencial",
"ambito",
"competencias",
"modalidad de informacion",
"modalidad informacion");
if (!string.IsNullOrWhiteSpace(directValue))
{
return directValue;
}
return FindReportFieldValue(denuncia, normalizedLabel =>
normalizedLabel.Contains("ambito", StringComparison.Ordinal) &&
normalizedLabel.Contains("compet", StringComparison.Ordinal));
}
private static string ResolveReportField(
DenunciasGestiona denuncia,
string? currentValue,
params string[] candidateLabels)
{
if (!string.IsNullOrWhiteSpace(currentValue))
{
return currentValue.Trim();
}
var labels = candidateLabels
.Select(NormalizeLabel)
.Where(label => !string.IsNullOrWhiteSpace(label))
.ToHashSet(StringComparer.Ordinal);
if (labels.Count == 0)
{
return string.Empty;
}
foreach (var field in denuncia.GetCamposFormulario())
{
if (!string.IsNullOrWhiteSpace(field.Value) &&
labels.Contains(NormalizeLabel(field.Label)))
{
return field.Value.Trim();
}
}
return ExtractReportTextValue(denuncia.TextoOriginalReport, labels);
}
private static string ResolveReportFieldAllowingLabelValues(
DenunciasGestiona denuncia,
string? currentValue,
params string[] candidateLabels)
{
if (!string.IsNullOrWhiteSpace(currentValue))
{
return currentValue.Trim();
}
var labels = candidateLabels
.Select(NormalizeLabel)
.Where(label => !string.IsNullOrWhiteSpace(label))
.ToHashSet(StringComparer.Ordinal);
if (labels.Count == 0)
{
return string.Empty;
}
foreach (var field in denuncia.GetCamposFormulario())
{
if (!string.IsNullOrWhiteSpace(field.Value) &&
labels.Contains(NormalizeLabel(field.Label)))
{
return field.Value.Trim();
}
}
return ExtractReportTextValue(denuncia.TextoOriginalReport, labels, stopAtLikelyLabels: false);
}
private static string FindReportFieldValue(
DenunciasGestiona denuncia,
Func<string, bool> labelPredicate)
{
foreach (var field in denuncia.GetCamposFormulario())
{
if (!string.IsNullOrWhiteSpace(field.Value) &&
labelPredicate(NormalizeLabel(field.Label)))
{
return field.Value.Trim();
}
}
return string.Empty;
}
private static string ExtractDateAfterReportLabel(string? reportText, Func<string, bool> labelPredicate)
{
if (string.IsNullOrWhiteSpace(reportText))
{
return string.Empty;
}
var lines = reportText
.Replace("\r\n", "\n", StringComparison.Ordinal)
.Replace('\r', '\n')
.Split('\n')
.Select(line => line.Trim())
.ToArray();
for (var index = 0; index < lines.Length; index++)
{
var normalized = NormalizeLabel(lines[index]);
if (!labelPredicate(normalized))
{
continue;
}
for (var valueIndex = index + 1; valueIndex < lines.Length && valueIndex <= index + 12; valueIndex++)
{
var candidate = lines[valueIndex].Trim();
if (string.IsNullOrWhiteSpace(candidate) || IsReportStructuralLine(candidate))
{
continue;
}
var dateMatch = Regex.Match(
candidate,
@"\b\d{1,2}/\d{1,2}/\d{4}\b",
RegexOptions.CultureInvariant);
if (dateMatch.Success)
{
return dateMatch.Value;
}
if (IsLikelyReportLabel(candidate))
{
break;
}
}
}
var rawMatch = Regex.Match(
reportText,
@"fecha\s+de\s+los\s+hechos[\s\S]{0,600}?(\b\d{1,2}/\d{1,2}/\d{4}\b)",
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
return rawMatch.Success ? rawMatch.Groups[1].Value : string.Empty;
}
private static string ExtractReportTextValue(string? reportText, IReadOnlySet<string> normalizedLabels)
=> ExtractReportTextValue(
reportText,
normalizedLabel => normalizedLabels.Contains(normalizedLabel));
private static string ExtractReportTextValue(
string? reportText,
IReadOnlySet<string> normalizedLabels,
bool stopAtLikelyLabels)
=> ExtractReportTextValue(
reportText,
normalizedLabel => normalizedLabels.Contains(normalizedLabel),
stopAtLikelyLabels);
private static string ExtractReportTextValue(string? reportText, Func<string, bool> labelPredicate)
=> ExtractReportTextValue(reportText, labelPredicate, stopAtLikelyLabels: true);
private static string ExtractReportTextValue(
string? reportText,
Func<string, bool> labelPredicate,
bool stopAtLikelyLabels)
{
if (string.IsNullOrWhiteSpace(reportText))
{
return string.Empty;
}
var lines = reportText
.Replace("\r\n", "\n", StringComparison.Ordinal)
.Replace('\r', '\n')
.Split('\n')
.Select(line => line.Trim())
.ToArray();
for (var index = 0; index < lines.Length; index++)
{
if (!labelPredicate(NormalizeLabel(lines[index])))
{
continue;
}
for (var valueIndex = index + 1; valueIndex < lines.Length; valueIndex++)
{
var candidate = lines[valueIndex].Trim();
if (string.IsNullOrWhiteSpace(candidate) || IsReportStructuralLine(candidate))
{
continue;
}
if (stopAtLikelyLabels && IsLikelyReportLabel(candidate))
{
break;
}
return candidate;
}
}
return string.Empty;
}
private static bool ReportContainsLabel(string? reportText, string label)
{
if (string.IsNullOrWhiteSpace(reportText))
{
return false;
}
var normalizedLabel = NormalizeLabel(label);
return reportText
.Replace("\r\n", "\n", StringComparison.Ordinal)
.Replace('\r', '\n')
.Split('\n')
.Any(line => string.Equals(NormalizeLabel(line.Trim()), normalizedLabel, StringComparison.Ordinal));
}
private static bool IsReportStructuralLine(string value)
{
if (Regex.IsMatch(value, @"^\d+/\d+$", RegexOptions.CultureInvariant))
{
return true;
}
return value.StartsWith("REPORT ", StringComparison.OrdinalIgnoreCase) ||
value.Equals("[CONFIDENTIAL]", StringComparison.OrdinalIgnoreCase) ||
value.Equals("{Messages}", StringComparison.OrdinalIgnoreCase) ||
value.StartsWith("Comments", StringComparison.OrdinalIgnoreCase) ||
value.StartsWith("De:", StringComparison.OrdinalIgnoreCase) ||
value.StartsWith("Fecha:", StringComparison.OrdinalIgnoreCase);
}
private static bool IsLikelyReportLabel(string value)
{
var normalized = NormalizeLabel(value);
if (string.IsNullOrWhiteSpace(normalized))
{
return false;
}
return normalized is "descripcion"
or "datos del denunciante"
or "condiciones y reglas de uso"
or "tratamiento de datos personales"
or "preferencias de notificacion"
or "notificaciones electronicas" ||
normalized.StartsWith("indique ", StringComparison.Ordinal) ||
normalized.StartsWith("describa ", StringComparison.Ordinal) ||
normalized.StartsWith("autoriza ", StringComparison.Ordinal) ||
normalized.StartsWith("autorizacion ", StringComparison.Ordinal) ||
normalized.StartsWith("ha denunciado ", StringComparison.Ordinal) ||
normalized.StartsWith("solicita ", StringComparison.Ordinal) ||
normalized.StartsWith("por favor ", StringComparison.Ordinal) ||
normalized.Contains(" denuncia", StringComparison.Ordinal) ||
normalized.Contains(" hechos", StringComparison.Ordinal) ||
normalized.Contains(" notificacion", StringComparison.Ordinal);
}
private static string JoinDistinct(params string[] values)
{
var result = new List<string>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var value in values)
{
var trimmed = value?.Trim();
if (!string.IsNullOrWhiteSpace(trimmed) && seen.Add(trimmed))
{
result.Add(trimmed);
}
}
return string.Join("; ", result);
}
private static string FirstNonEmpty(params string[] values)
{
foreach (var value in values)
{
if (!string.IsNullOrWhiteSpace(value))
{
return value.Trim();
}
}
return string.Empty;
}
//pruabass
private static string NormalizeLabel(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return string.Empty;
}
var normalized = value.Normalize(NormalizationForm.FormD);
var builder = new StringBuilder(normalized.Length);
foreach (var ch in normalized)
{
if (CharUnicodeInfo.GetUnicodeCategory(ch) == UnicodeCategory.NonSpacingMark)
{
continue;
}
if (ch is '\u00BA' or '\u00AA')
{
continue;
}
builder.Append(char.IsLetterOrDigit(ch) ? char.ToLowerInvariant(ch) : ' ');
}
return string.Join(
' ',
builder
.ToString()
.Normalize(NormalizationForm.FormC)
.Split(' ', StringSplitOptions.RemoveEmptyEntries));
}
private static string GetAttachmentContentType(string? fileName) private static string GetAttachmentContentType(string? fileName)
{ {
return Path.GetExtension(fileName ?? string.Empty).ToLowerInvariant() switch return Path.GetExtension(fileName ?? string.Empty).ToLowerInvariant() switch

View File

@@ -29,7 +29,6 @@ public sealed class GestionaController : ControllerBase
try try
{ {
var file = await _gestiona.CreateFileAsync( var file = await _gestiona.CreateFileAsync(
request.ProcedureId,
request.Subject, request.Subject,
request.DocumentSeries, request.DocumentSeries,
request.SiaCode); request.SiaCode);
@@ -52,11 +51,28 @@ public sealed class GestionaController : ControllerBase
await _gestiona.OpenFileAsync( await _gestiona.OpenFileAsync(
request.FileUrl, request.FileUrl,
request.FileOpenUrl, request.FileOpenUrl,
request.ManagementUnitGroupId, request.AssignedGroupCode,
request.AssignedGroupId,
request.Confidential, request.Confidential,
request.FreeTitle, request.FreeTitle);
request.SiaCode);
return Ok(new { ok = true });
}
catch (InvalidOperationException ex)
{
return BadRequest(new ApiError(ex.Message));
}
}
[HttpPost("files/assignees")]
public async Task<IActionResult> AssignFile(
GestionaAssignFileRequest request,
CancellationToken cancellationToken)
{
try
{
await _gestiona.AssignFileAsync(
request.FileUrl,
request.AssignedGroupCode);
return Ok(new { ok = true }); return Ok(new { ok = true });
} }
@@ -86,6 +102,26 @@ public sealed class GestionaController : ControllerBase
} }
} }
[HttpGet("files/audit/latest")]
public async Task<ActionResult<GestionaAuditInfo?>> GetLatestFileAudit(
[FromQuery] string fileUrl,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(fileUrl))
{
return BadRequest(new ApiError("Debes indicar la URL del expediente de Gestiona."));
}
try
{
return Ok(await _gestiona.ObtenerUltimaAuditoriaExpedienteAsync(fileUrl));
}
catch (InvalidOperationException ex)
{
return BadRequest(new ApiError(ex.Message));
}
}
[HttpPost("thirds/ensure-link")] [HttpPost("thirds/ensure-link")]
public async Task<IActionResult> EnsureThirdAndLink( public async Task<IActionResult> EnsureThirdAndLink(
GestionaEnsureThirdRequest request, GestionaEnsureThirdRequest request,
@@ -98,8 +134,7 @@ public sealed class GestionaController : ControllerBase
try try
{ {
await _gestiona.AsegurarTerceroYEnlazarAsync(request.FileUrl, request.ThirdParty); return Ok(await _gestiona.AsegurarTerceroYEnlazarAsync(request.FileUrl, request.ThirdParty));
return Ok(new { ok = true });
} }
catch (ArgumentException ex) catch (ArgumentException ex)
{ {
@@ -157,8 +192,10 @@ public sealed class GestionaController : ControllerBase
{ {
await _workflow.TramitarDocumentoAsync( await _workflow.TramitarDocumentoAsync(
request.DocumentUrl, request.DocumentUrl,
request.AssignedGroupHref, request.AssignedGroupCode,
request.ComplaintId); request.ComplaintId,
request.IsUpdate,
request.UpdateSource);
return Ok(new { ok = true }); return Ok(new { ok = true });
} }

View File

@@ -10,9 +10,17 @@ namespace ApiDenuncias.Controllers;
[Route("api/inbox")] [Route("api/inbox")]
public sealed class InboxController : ControllerBase public sealed class InboxController : ControllerBase
{ {
private static readonly TimeSpan[] ExportRetryDelays =
[
TimeSpan.FromSeconds(2),
TimeSpan.FromSeconds(5),
TimeSpan.FromSeconds(10)
];
private readonly GlobalLeaksSessionStore _sessionStore; private readonly GlobalLeaksSessionStore _sessionStore;
private readonly PendingGlobalLeaksLoginStore _pendingLoginStore; private readonly PendingGlobalLeaksLoginStore _pendingLoginStore;
private readonly GlobalLeaksClient _globalLeaksClient; private readonly GlobalLeaksClient _globalLeaksClient;
private readonly GlobalLeaksSessionKeepAliveService _sessionKeepAliveService;
private readonly DenunciaInboxService _inboxService; private readonly DenunciaInboxService _inboxService;
private readonly IInboxTrackingService _trackingService; private readonly IInboxTrackingService _trackingService;
private readonly ILogger<InboxController> _logger; private readonly ILogger<InboxController> _logger;
@@ -21,6 +29,7 @@ public sealed class InboxController : ControllerBase
GlobalLeaksSessionStore sessionStore, GlobalLeaksSessionStore sessionStore,
PendingGlobalLeaksLoginStore pendingLoginStore, PendingGlobalLeaksLoginStore pendingLoginStore,
GlobalLeaksClient globalLeaksClient, GlobalLeaksClient globalLeaksClient,
GlobalLeaksSessionKeepAliveService sessionKeepAliveService,
DenunciaInboxService inboxService, DenunciaInboxService inboxService,
IInboxTrackingService trackingService, IInboxTrackingService trackingService,
ILogger<InboxController> logger) ILogger<InboxController> logger)
@@ -28,6 +37,7 @@ public sealed class InboxController : ControllerBase
_sessionStore = sessionStore; _sessionStore = sessionStore;
_pendingLoginStore = pendingLoginStore; _pendingLoginStore = pendingLoginStore;
_globalLeaksClient = globalLeaksClient; _globalLeaksClient = globalLeaksClient;
_sessionKeepAliveService = sessionKeepAliveService;
_inboxService = inboxService; _inboxService = inboxService;
_trackingService = trackingService; _trackingService = trackingService;
_logger = logger; _logger = logger;
@@ -74,7 +84,7 @@ public sealed class InboxController : ControllerBase
_logger.LogError(ex, "No se ha podido preparar la renovacion GlobalLeaks para {Username}.", username); _logger.LogError(ex, "No se ha podido preparar la renovacion GlobalLeaks para {Username}.", username);
return StatusCode( return StatusCode(
StatusCodes.Status500InternalServerError, StatusCodes.Status500InternalServerError,
new ApiError($"No se ha podido preparar la renovacion: {ex.GetType().Name}: {ex.Message}")); new ApiError("No se ha podido preparar la renovacion de GlobalLeaks. Intentalo de nuevo en unos segundos."));
} }
} }
@@ -124,7 +134,14 @@ public sealed class InboxController : ControllerBase
cancellationToken); cancellationToken);
} }
await _sessionStore.UpdateSessionAsync(username, session.Id, session.Role, cancellationToken); await _sessionStore.UpdateSessionAsync(
username,
session.Id,
session.Role,
session.DpopPrivateKey,
session.ProofOfWorkToken,
session.SessionExpiresAtUtc,
cancellationToken);
var stored = await _sessionStore.GetAsync(username, cancellationToken); var stored = await _sessionStore.GetAsync(username, cancellationToken);
return Ok(ToDto(stored)); return Ok(ToDto(stored));
} }
@@ -141,7 +158,46 @@ public sealed class InboxController : ControllerBase
_logger.LogError(ex, "No se ha podido cargar la bandeja GlobalLeaks para {Username}.", username); _logger.LogError(ex, "No se ha podido cargar la bandeja GlobalLeaks para {Username}.", username);
return StatusCode( return StatusCode(
StatusCodes.Status500InternalServerError, StatusCodes.Status500InternalServerError,
new ApiError($"No se ha podido cargar la bandeja: {ex.GetType().Name}: {ex.Message}")); new ApiError("No se ha podido renovar la sesion de GlobalLeaks. Intentalo de nuevo en unos segundos."));
}
}
[HttpPost("session/keepalive")]
public async Task<ActionResult<ApiGlobalLeaksSessionDto?>> KeepSessionAlive(
CancellationToken cancellationToken)
{
var username = GetUsername();
try
{
var session = await _sessionKeepAliveService.KeepAliveAsync(username, cancellationToken);
return Ok(ToDto(session));
}
catch (GlobalLeaksSessionExpiredException)
{
var session = await _sessionStore.GetAsync(username, cancellationToken);
return Ok(ToDto(session));
}
catch (GlobalLeaksValidationException ex)
{
_logger.LogWarning(
"No se ha podido mantener activa la sesion GlobalLeaks de {Username}. Status={StatusCode}. Mensaje={Message}",
username,
ex.StatusCode,
ex.Message);
return StatusCode(
ex.StatusCode is >= 500 and <= 504
? StatusCodes.Status503ServiceUnavailable
: StatusCodes.Status502BadGateway,
new ApiError("No se ha podido renovar temporalmente la sesion de GlobalLeaks."));
}
catch (Exception ex)
{
_logger.LogError(ex, "Error renovando la sesion GlobalLeaks de {Username}.", username);
return StatusCode(
StatusCodes.Status503ServiceUnavailable,
new ApiError("No se ha podido renovar temporalmente la sesion de GlobalLeaks."));
} }
} }
@@ -164,12 +220,25 @@ public sealed class InboxController : ControllerBase
try try
{ {
var contexts = await _globalLeaksClient.GetContextsAsync(session.SessionId!, cancellationToken);
var reports = await _globalLeaksClient.GetReportsAsync(session.SessionId!, "all", null, null, cancellationToken, contexts);
var enrichedReports = await _trackingService.RegisterSnapshotAsync(username, reports, cancellationToken);
var state = await _trackingService.GetUserStateAsync(username, cancellationToken); var state = await _trackingService.GetUserStateAsync(username, cancellationToken);
var contexts = await _globalLeaksClient.GetContextsAsync(session.SessionId!, cancellationToken);
var reports = await _globalLeaksClient.GetReportsAsync(
session.SessionId!,
"all",
null,
null,
cancellationToken,
contexts,
session.DpopPrivateKey);
var enrichedReports = await _trackingService.RegisterSnapshotAsync(username, reports, cancellationToken);
var activityReports = await _globalLeaksClient.EnrichReportsWithActivityAsync(
session.SessionId!,
enrichedReports,
state.LastDownloadedReportMomentUtc,
cancellationToken,
session.DpopPrivateKey);
return Ok(new InboxSnapshotResponse(contexts, enrichedReports, state)); return Ok(new InboxSnapshotResponse(contexts, activityReports, state));
} }
catch (GlobalLeaksSessionExpiredException) catch (GlobalLeaksSessionExpiredException)
{ {
@@ -178,14 +247,14 @@ public sealed class InboxController : ControllerBase
} }
catch (GlobalLeaksValidationException ex) catch (GlobalLeaksValidationException ex)
{ {
return StatusCode(ex.StatusCode, new ApiError(ex.Message)); return ToGlobalLeaksApiError(ex, "cargar la bandeja de GlobalLeaks");
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "No se ha podido cargar la bandeja GlobalLeaks para {Username}.", username); _logger.LogError(ex, "No se ha podido cargar la bandeja GlobalLeaks para {Username}.", username);
return StatusCode( return StatusCode(
StatusCodes.Status500InternalServerError, StatusCodes.Status500InternalServerError,
new ApiError($"No se ha podido cargar la bandeja: {ex.GetType().Name}: {ex.Message}")); new ApiError("No se ha podido cargar la bandeja de GlobalLeaks. Intentalo de nuevo en unos segundos."));
} }
} }
@@ -202,27 +271,57 @@ public sealed class InboxController : ControllerBase
return Unauthorized(new ApiError("La sesion de GlobalLeaks ha caducado. Renueva el 2FA.", SessionExpired: true)); return Unauthorized(new ApiError("La sesion de GlobalLeaks ha caducado. Renueva el 2FA.", SessionExpired: true));
} }
var report = string.IsNullOrWhiteSpace(request.Report.Id) var report = request.Report with { Id = reportId };
? request.Report with { Id = reportId }
: request.Report;
try try
{ {
await _trackingService.EnsureReportCanBeImportedByUserAsync(username, report, cancellationToken); await _trackingService.EnsureReportCanBeImportedByUserAsync(
username,
report,
request.ConfirmDifferentOwner,
cancellationToken);
var zip = await _globalLeaksClient.DownloadReportZipAsync(session.SessionId!, report.Id, cancellationToken); ReportDetailDto? reportDetail = null;
try
{
reportDetail = await _globalLeaksClient.GetReportDetailAsync(
session.SessionId!,
report.Id,
report.LastAccess,
cancellationToken,
session.DpopPrivateKey);
}
catch (GlobalLeaksValidationException ex) when (ex.StatusCode == StatusCodes.Status422UnprocessableEntity ||
ex.StatusCode is >= 500 and <= 504)
{
_logger.LogWarning(
ex,
"No se ha podido obtener el detalle de fechas de adjuntos para la denuncia {ReportId}. Se usara la fecha del paquete exportado.",
report.Id);
}
var reportPackage = await DownloadReportPackageWithRetryAsync(session, report, cancellationToken);
FileDownloadResult? json = null; FileDownloadResult? json = null;
try try
{ {
json = await _globalLeaksClient.ExportReportJsonAsync(session.SessionId!, report.Id, cancellationToken); json = await _globalLeaksClient.ExportReportJsonAsync(
session.SessionId!,
report.Id,
cancellationToken,
session.DpopPrivateKey);
} }
catch (GlobalLeaksValidationException ex) when (ex.StatusCode == 422) catch (GlobalLeaksValidationException ex) when (ex.StatusCode == 422)
{ {
json = null; json = null;
} }
var result = await _inboxService.ImportFromGlobalLeaksAsync(zip, json, cancellationToken); var result = await _inboxService.ImportFromGlobalLeaksAsync(
reportPackage,
json,
reportDetail,
report,
cancellationToken);
if (result.ImportedCount > 0) if (result.ImportedCount > 0)
{ {
await _trackingService.MarkReportImportedAsync( await _trackingService.MarkReportImportedAsync(
@@ -241,14 +340,18 @@ public sealed class InboxController : ControllerBase
} }
catch (GlobalLeaksValidationException ex) catch (GlobalLeaksValidationException ex)
{ {
return StatusCode(ex.StatusCode, new ApiError(ex.Message)); return ToGlobalLeaksApiError(ex, "importar la denuncia");
}
catch (ReportOwnershipException ex)
{
return Conflict(new ApiError(ex.Message));
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "No se ha podido importar la denuncia {ReportId} para {Username}.", reportId, username); _logger.LogError(ex, "No se ha podido importar la denuncia {ReportId} para {Username}.", reportId, username);
return StatusCode( return StatusCode(
StatusCodes.Status500InternalServerError, StatusCodes.Status500InternalServerError,
new ApiError($"No se ha podido importar la denuncia: {ex.GetType().Name}: {ex.Message}")); new ApiError("No se ha podido importar la denuncia. Intentalo de nuevo en unos segundos."));
} }
} }
@@ -267,7 +370,12 @@ public sealed class InboxController : ControllerBase
try try
{ {
return Ok(await _globalLeaksClient.GetReportDetailAsync(session.SessionId!, reportId, lastAccess, cancellationToken)); return Ok(await _globalLeaksClient.GetReportDetailAsync(
session.SessionId!,
reportId,
lastAccess,
cancellationToken,
session.DpopPrivateKey));
} }
catch (GlobalLeaksSessionExpiredException) catch (GlobalLeaksSessionExpiredException)
{ {
@@ -276,39 +384,138 @@ public sealed class InboxController : ControllerBase
} }
catch (GlobalLeaksValidationException ex) catch (GlobalLeaksValidationException ex)
{ {
return StatusCode(ex.StatusCode, new ApiError(ex.Message)); return ToGlobalLeaksApiError(ex, "leer el detalle de la denuncia");
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "No se ha podido leer el detalle de la denuncia {ReportId} para {Username}.", reportId, username); _logger.LogError(ex, "No se ha podido leer el detalle de la denuncia {ReportId} para {Username}.", reportId, username);
return StatusCode( return StatusCode(
StatusCodes.Status500InternalServerError, StatusCodes.Status500InternalServerError,
new ApiError($"No se ha podido leer el detalle de la denuncia: {ex.GetType().Name}: {ex.Message}")); new ApiError("No se ha podido abrir el detalle de la denuncia. Intentalo de nuevo en unos segundos."));
} }
} }
[HttpPost("local/ensure-storage")] private async Task<FileDownloadResult> DownloadReportPackageWithRetryAsync(
public async Task<IActionResult> EnsureStorage(CancellationToken cancellationToken) GlobalLeaksStoredSession session,
ReportDto report,
CancellationToken cancellationToken)
{ {
await _inboxService.EnsureStorageReadyAsync(cancellationToken); for (var attempt = 0; ; attempt++)
return Ok(new { ok = true }); {
try
{
return await _globalLeaksClient.DownloadReportPackageAsync(
session.SessionId!,
report.Id,
cancellationToken,
session.DpopPrivateKey);
}
catch (GlobalLeaksValidationException ex) when (IsExportNotReady(ex) && attempt < ExportRetryDelays.Length)
{
var delay = ExportRetryDelays[attempt];
_logger.LogWarning(
ex,
"GlobalLeaks aun no ha preparado la exportacion de la denuncia {ReportId}. Reintento {Attempt}/{Total} en {DelaySeconds}s.",
report.Id,
attempt + 1,
ExportRetryDelays.Length,
delay.TotalSeconds);
await Task.Delay(delay, cancellationToken);
}
catch (GlobalLeaksValidationException ex) when (IsExportNotReady(ex))
{
throw new GlobalLeaksValidationException(
"GlobalLeaks todavia esta preparando la exportacion de esta denuncia. Espera unos segundos y vuelve a importarla.",
StatusCodes.Status503ServiceUnavailable);
}
}
} }
[HttpPost("local/process")] private static bool IsExportNotReady(GlobalLeaksValidationException ex)
public async Task<ActionResult<ImportSummary>> ProcessLocalZips(CancellationToken cancellationToken) => ex.StatusCode is >= 500 and <= 504;
=> Ok(await _inboxService.ProcessPendingFolderZipsAsync(cancellationToken));
[HttpGet("local/zips")] private ActionResult ToGlobalLeaksApiError(GlobalLeaksValidationException ex, string operation)
public async Task<ActionResult<IReadOnlyList<string>>> GetLocalZips(CancellationToken cancellationToken)
=> Ok(await _inboxService.GetExistingZipNamesAsync(cancellationToken));
[HttpDelete("local/zips/{zipName}")]
public async Task<IActionResult> DeleteLocalZip(string zipName, CancellationToken cancellationToken)
{ {
await _inboxService.DeleteZipAsync(zipName, cancellationToken); if (ex.StatusCode == StatusCodes.Status401Unauthorized ||
return Ok(new { ok = true }); ex.StatusCode == StatusCodes.Status403Forbidden)
{
_logger.LogWarning(
"GlobalLeaks rechazo la operacion '{Operation}'. Status={StatusCode}. Mensaje={Message}",
operation,
ex.StatusCode,
ex.Message);
if (IsSessionAuthorizationProblem(ex.Message))
{
return new ObjectResult(new ApiError(
"La sesion de GlobalLeaks no es valida o ha caducado. Renueva el 2FA y vuelve a intentarlo.",
SessionExpired: true))
{
StatusCode = StatusCodes.Status401Unauthorized
};
}
if (IsDpopValidationProblem(ex.Message))
{
return new ObjectResult(new ApiError(
"GlobalLeaks no ha podido validar la proteccion de la sesion (DPoP). La sesion no se ha marcado como caducada. Vuelve a intentarlo y, si se repite, avisa al equipo tecnico."))
{
StatusCode = StatusCodes.Status502BadGateway
};
}
if (IsMissingAuthenticationHeadersProblem(ex.Message))
{
return new ObjectResult(new ApiError(
"No se ha podido completar la autenticacion de la llamada a GlobalLeaks. La sesion no se ha marcado como caducada. Vuelve a intentarlo y, si se repite, avisa al equipo tecnico."))
{
StatusCode = StatusCodes.Status502BadGateway
};
}
var permissionMessage = operation.Contains("bandeja", StringComparison.OrdinalIgnoreCase)
? "GlobalLeaks no ha autorizado la carga de la bandeja para este usuario. La sesion sigue activa; comprueba los permisos del usuario en el buzon."
: "No tienes permiso en GlobalLeaks para acceder a esa denuncia. Puede ser una denuncia anterior a la creacion de tu usuario; consulta con el administrador del buzon.";
return new ObjectResult(new ApiError(
permissionMessage))
{
StatusCode = StatusCodes.Status403Forbidden
};
}
if (ex.StatusCode is >= 500 and <= 504)
{
return new ObjectResult(new ApiError(
$"GlobalLeaks no ha podido {operation} en este momento. Intentalo de nuevo en unos segundos."))
{
StatusCode = StatusCodes.Status503ServiceUnavailable
};
}
return new ObjectResult(new ApiError(ex.Message))
{
StatusCode = ex.StatusCode
};
} }
private static bool IsSessionAuthorizationProblem(string? message)
=> !string.IsNullOrWhiteSpace(message) &&
(message.Contains("NotAuthenticated", StringComparison.OrdinalIgnoreCase) ||
message.Contains("Invalid token", StringComparison.OrdinalIgnoreCase) ||
message.Contains("Invalid session", StringComparison.OrdinalIgnoreCase) ||
message.Contains("session", StringComparison.OrdinalIgnoreCase) && message.Contains("expired", StringComparison.OrdinalIgnoreCase) ||
message.Contains("token", StringComparison.OrdinalIgnoreCase) && message.Contains("expired", StringComparison.OrdinalIgnoreCase));
private static bool IsDpopValidationProblem(string? message)
=> !string.IsNullOrWhiteSpace(message) &&
message.Contains("Invalid DPoP proof", StringComparison.OrdinalIgnoreCase);
private static bool IsMissingAuthenticationHeadersProblem(string? message)
=> !string.IsNullOrWhiteSpace(message) &&
message.Contains("No token and no session", StringComparison.OrdinalIgnoreCase);
private async Task<GlobalLeaksStoredSession?> RequireActiveSessionAsync(string username, CancellationToken cancellationToken) private async Task<GlobalLeaksStoredSession?> RequireActiveSessionAsync(string username, CancellationToken cancellationToken)
{ {
var session = await _sessionStore.GetAsync(username, cancellationToken); var session = await _sessionStore.GetAsync(username, cancellationToken);
@@ -322,4 +529,7 @@ public sealed class InboxController : ControllerBase
=> session is null => session is null
? null ? null
: new ApiGlobalLeaksSessionDto(session.Username, session.Role, session.HasActiveSession, session.UpdatedAt); : new ApiGlobalLeaksSessionDto(session.Username, session.Role, session.HasActiveSession, session.UpdatedAt);
} }

View File

@@ -7,7 +7,7 @@ using Microsoft.AspNetCore.Mvc;
namespace ApiDenuncias.Controllers; namespace ApiDenuncias.Controllers;
[ApiController] [ApiController]
[Authorize] [Authorize(Policy = "ConfigurationAdministrators")]
[Route("api/purge")] [Route("api/purge")]
public sealed class PurgeController : ControllerBase public sealed class PurgeController : ControllerBase
{ {

View File

@@ -34,13 +34,37 @@ public sealed class TrackingController : ControllerBase
return Ok(new { ok = true }); return Ok(new { ok = true });
} }
[HttpPost("handled-in-gestiona")]
public async Task<IActionResult> MarkHandledInGestiona(
MarkReportHandledInGestionaRequest request,
CancellationToken cancellationToken)
{
await _trackingService.MarkReportHandledInGestionaAsync(
GetUsername(),
request.DenunciaId,
request.UploadedAtUtc,
cancellationToken);
return Ok(new { ok = true });
}
[HttpPost("import-permission")] [HttpPost("import-permission")]
public async Task<IActionResult> EnsureImportPermission( public async Task<IActionResult> EnsureImportPermission(
TrackingImportPermissionRequest request, TrackingImportPermissionRequest request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
await _trackingService.EnsureReportCanBeImportedByUserAsync(GetUsername(), request.Report, cancellationToken); try
return Ok(new { ok = true }); {
await _trackingService.EnsureReportCanBeImportedByUserAsync(
GetUsername(),
request.Report,
request.ConfirmDifferentOwner,
cancellationToken);
return Ok(new { ok = true });
}
catch (ReportOwnershipException ex)
{
return Conflict(new ApiError(ex.Message));
}
} }
private string GetUsername() private string GetUsername()

View File

@@ -121,12 +121,23 @@ public static class GlobalLeaksJsonEnricher
} }
} }
if (element.TryGetProperty("children", out var children) && foreach (var property in element.EnumerateObject())
children.ValueKind == JsonValueKind.Array)
{ {
foreach (var child in children.EnumerateArray()) if (property.NameEquals("options"))
{ {
CollectDefinitions(child, definitions); continue;
}
if (property.Value.ValueKind == JsonValueKind.Object)
{
CollectDefinitions(property.Value, definitions);
}
else if (property.Value.ValueKind == JsonValueKind.Array)
{
foreach (var child in property.Value.EnumerateArray())
{
CollectDefinitions(child, definitions);
}
} }
} }
} }
@@ -163,42 +174,57 @@ public static class GlobalLeaksJsonEnricher
private static string ResolveAnswer(JsonElement answerArray, FieldDefinition definition) private static string ResolveAnswer(JsonElement answerArray, FieldDefinition definition)
{ {
if (answerArray.ValueKind == JsonValueKind.Object)
{
return ResolveAnswerObject(answerArray, definition);
}
if (answerArray.ValueKind != JsonValueKind.Array) if (answerArray.ValueKind != JsonValueKind.Array)
{ {
return string.Empty; return ResolveValue(answerArray, definition.Options);
} }
var values = new List<string>(); var values = new List<string>();
foreach (var answer in answerArray.EnumerateArray()) foreach (var answer in answerArray.EnumerateArray())
{ {
if (answer.ValueKind != JsonValueKind.Object) var resolved = answer.ValueKind == JsonValueKind.Object
? ResolveAnswerObject(answer, definition)
: ResolveValue(answer, definition.Options);
if (!string.IsNullOrWhiteSpace(resolved))
{
values.Add(resolved);
}
}
return string.Join("; ", values.Distinct(StringComparer.OrdinalIgnoreCase));
}
private static string ResolveAnswerObject(JsonElement answer, FieldDefinition definition)
{
var values = new List<string>();
if (answer.TryGetProperty("value", out var valueElement))
{
var resolvedValue = ResolveValue(valueElement, definition.Options);
if (!string.IsNullOrWhiteSpace(resolvedValue))
{
values.Add(resolvedValue);
}
}
foreach (var property in answer.EnumerateObject())
{
if (property.NameEquals("value") || property.NameEquals("index") || property.NameEquals("required_status"))
{ {
continue; continue;
} }
if (answer.TryGetProperty("value", out var valueElement)) if (property.Value.ValueKind is JsonValueKind.True or JsonValueKind.False &&
property.Value.GetBoolean() &&
definition.Options.TryGetValue(property.Name, out var label))
{ {
var resolvedValue = ResolveValue(valueElement, definition.Options); values.Add(label);
if (!string.IsNullOrWhiteSpace(resolvedValue))
{
values.Add(resolvedValue);
}
}
foreach (var property in answer.EnumerateObject())
{
if (property.NameEquals("value") || property.NameEquals("index") || property.NameEquals("required_status"))
{
continue;
}
if (property.Value.ValueKind is JsonValueKind.True or JsonValueKind.False &&
property.Value.GetBoolean() &&
definition.Options.TryGetValue(property.Name, out var label))
{
values.Add(label);
}
} }
} }
@@ -213,10 +239,95 @@ public static class GlobalLeaksJsonEnricher
JsonValueKind.True => "Sí", JsonValueKind.True => "Sí",
JsonValueKind.False => "No", JsonValueKind.False => "No",
JsonValueKind.Number => valueElement.GetRawText(), JsonValueKind.Number => valueElement.GetRawText(),
JsonValueKind.Array => ResolveArrayValue(valueElement, options),
JsonValueKind.Object => ResolveObjectValue(valueElement, options),
_ => string.Empty, _ => string.Empty,
}; };
} }
private static string ResolveArrayValue(JsonElement valueElement, Dictionary<string, string> options)
{
var values = valueElement
.EnumerateArray()
.Select(item => ResolveValue(item, options))
.Where(value => !string.IsNullOrWhiteSpace(value))
.Distinct(StringComparer.OrdinalIgnoreCase);
return string.Join("; ", values);
}
private static string ResolveObjectValue(JsonElement valueElement, Dictionary<string, string> options)
{
foreach (var propertyName in new[] { "label", "text", "value", "answer", "date", "formatted", "name" })
{
if (!valueElement.TryGetProperty(propertyName, out var propertyValue))
{
continue;
}
var resolved = ResolveValue(propertyValue, options);
if (!string.IsNullOrWhiteSpace(resolved))
{
return resolved;
}
}
if (TryReadDateParts(valueElement, out var dateText))
{
return dateText;
}
var values = new List<string>();
foreach (var property in valueElement.EnumerateObject())
{
if (property.Value.ValueKind is JsonValueKind.True or JsonValueKind.False &&
property.Value.GetBoolean() &&
options.TryGetValue(property.Name, out var label))
{
values.Add(label);
}
}
return string.Join("; ", values.Distinct(StringComparer.OrdinalIgnoreCase));
}
private static bool TryReadDateParts(JsonElement valueElement, out string dateText)
{
dateText = string.Empty;
if (!TryGetIntProperty(valueElement, "day", out var day) ||
!TryGetIntProperty(valueElement, "month", out var month) ||
!TryGetIntProperty(valueElement, "year", out var year))
{
return false;
}
try
{
dateText = new DateTime(year, month, day).ToString("dd/MM/yyyy", CultureInfo.InvariantCulture);
return true;
}
catch
{
return false;
}
}
private static bool TryGetIntProperty(JsonElement valueElement, string propertyName, out int value)
{
value = 0;
if (!valueElement.TryGetProperty(propertyName, out var property))
{
return false;
}
return property.ValueKind switch
{
JsonValueKind.Number => property.TryGetInt32(out value),
JsonValueKind.String => int.TryParse(property.GetString(), CultureInfo.InvariantCulture, out value),
_ => false,
};
}
private static string ResolveStringValue(string? rawValue, Dictionary<string, string> options) private static string ResolveStringValue(string? rawValue, Dictionary<string, string> options)
{ {
if (string.IsNullOrWhiteSpace(rawValue)) if (string.IsNullOrWhiteSpace(rawValue))
@@ -259,12 +370,13 @@ public static class GlobalLeaksJsonEnricher
SetIfMissing(() => denuncia.OrganismoDenunciado, value => denuncia.OrganismoDenunciado = value, answers, "por favor indique el organismo o la institucion donde ha denunciado los hechos"); SetIfMissing(() => denuncia.OrganismoDenunciado, value => denuncia.OrganismoDenunciado = value, answers, "por favor indique el organismo o la institucion donde ha denunciado los hechos");
SetIfMissing(() => denuncia.SolicitaProteccion, value => denuncia.SolicitaProteccion = value, answers, "solicita medidas concretas de proteccion"); SetIfMissing(() => denuncia.SolicitaProteccion, value => denuncia.SolicitaProteccion = value, answers, "solicita medidas concretas de proteccion");
SetIfMissing(() => denuncia.MedidasProteccionSolicitadas, value => denuncia.MedidasProteccionSolicitadas = value, answers, "describa las medidas de proteccion solicitadas"); SetIfMissing(() => denuncia.MedidasProteccionSolicitadas, value => denuncia.MedidasProteccionSolicitadas = value, answers, "describa las medidas de proteccion solicitadas");
SetIfMissing(() => denuncia.Lugar_Hechos, value => denuncia.Lugar_Hechos = value, answers, "lugar en la que ocurrieron los hechos que denuncia"); SetIfMissing(() => denuncia.Lugar_Hechos, value => denuncia.Lugar_Hechos = value, answers, "lugar en el que ocurrieron los hechos que denuncia", "lugar en la que ocurrieron los hechos que denuncia", "lugar de los hechos");
SetIfMissing(() => denuncia.AutorizaRemision, value => denuncia.AutorizaRemision = value, answers, "autorizacion para remitir su denuncia"); SetIfMissing(() => denuncia.AutorizaRemision, value => denuncia.AutorizaRemision = value, answers, "autorizacion para remitir su denuncia");
SetIfMissing(() => denuncia.PreferenciaRemision, value => denuncia.PreferenciaRemision = value, answers, "en tal caso desea que su denuncia se remita anonimizada sin datos personales"); SetIfMissing(() => denuncia.PreferenciaRemision, value => denuncia.PreferenciaRemision = value, answers, "en tal caso desea que su denuncia se remita anonimizada sin datos personales");
SetIfMissing(() => denuncia.Notificacion_Preferencia, value => denuncia.Notificacion_Preferencia = value, answers, "seleccione su preferencia de notificacion y seguimiento de su denuncia", "notificaciones"); SetIfMissing(() => denuncia.Notificacion_Preferencia, value => denuncia.Notificacion_Preferencia = value, answers, "preferencia de notificacion", "seleccione su preferencia de notificacion y seguimiento de su denuncia", "preferencia de notificacion y seguimiento de su denuncia", "notificaciones");
SetIfMissing(() => denuncia.Notificacion_Electronica, value => denuncia.Notificacion_Electronica = value, answers, "notificaciones electronicas"); SetIfMissing(() => denuncia.Notificacion_Electronica, value => denuncia.Notificacion_Electronica = value, answers, "notificaciones electronicas");
SetIfMissing(() => denuncia.SeguimientoOnline, value => denuncia.SeguimientoOnline = value, answers, "seguimiento online"); SetIfMissing(() => denuncia.SeguimientoOnline, value => denuncia.SeguimientoOnline = value, answers, "seguimiento online", "seguimiento de su denuncia");
SetIfMissing(() => denuncia.Notificacion_Sms, value => denuncia.Notificacion_Sms = value, answers, "autorizo recibir notificaciones via sms", "autorizacion notificaciones via sms", "autoriza notificaciones via sms", "notificaciones via sms", "sms");
SetIfMissing(() => denuncia.NotificacionPostal, value => denuncia.NotificacionPostal = value, answers, "autorizo recibir notificaciones via correo postal"); SetIfMissing(() => denuncia.NotificacionPostal, value => denuncia.NotificacionPostal = value, answers, "autorizo recibir notificaciones via correo postal");
SetIfMissing(() => denuncia.Correo_Electronico, value => denuncia.Correo_Electronico = value, answers, "correo electronico", "email"); SetIfMissing(() => denuncia.Correo_Electronico, value => denuncia.Correo_Electronico = value, answers, "correo electronico", "email");
SetIfMissing(() => denuncia.Telefono, value => denuncia.Telefono = value, answers, "contacto telefonico", "telefono", "telefono movil"); SetIfMissing(() => denuncia.Telefono, value => denuncia.Telefono = value, answers, "contacto telefonico", "telefono", "telefono movil");
@@ -282,8 +394,8 @@ public static class GlobalLeaksJsonEnricher
SetIfMissing(() => denuncia.Pais, value => denuncia.Pais = value, answers, "pais"); SetIfMissing(() => denuncia.Pais, value => denuncia.Pais = value, answers, "pais");
if (denuncia.Fecha_Hechos == DateTime.MinValue && if (denuncia.Fecha_Hechos == DateTime.MinValue &&
TryGetAnswer(answers, out var fechaHechos, "fecha de los hechos que denuncia") && TryGetAnswer(answers, out var fechaHechos, "fecha de los hechos que denuncia", "fecha de los hechos") &&
DateTime.TryParse(fechaHechos, CultureInfo.CurrentCulture, DateTimeStyles.None, out var parsedDate)) TryParseReportDate(fechaHechos, out var parsedDate))
{ {
denuncia.Fecha_Hechos = parsedDate; denuncia.Fecha_Hechos = parsedDate;
} }
@@ -371,6 +483,38 @@ public static class GlobalLeaksJsonEnricher
return false; return false;
} }
private static bool TryParseReportDate(string? value, out DateTime parsedDate)
{
parsedDate = DateTime.MinValue;
if (string.IsNullOrWhiteSpace(value))
{
return false;
}
foreach (var culture in new[]
{
CultureInfo.GetCultureInfo("es-ES"),
CultureInfo.InvariantCulture,
CultureInfo.CurrentCulture
})
{
if (DateTime.TryParse(value, culture, DateTimeStyles.None, out parsedDate))
{
return true;
}
}
foreach (var format in new[] { "dd/MM/yyyy", "d/M/yyyy", "yyyy-MM-dd" })
{
if (DateTime.TryParseExact(value, format, CultureInfo.InvariantCulture, DateTimeStyles.None, out parsedDate))
{
return true;
}
}
return false;
}
private static string Normalize(string? text) private static string Normalize(string? text)
{ {
if (string.IsNullOrWhiteSpace(text)) if (string.IsNullOrWhiteSpace(text))

View File

@@ -32,6 +32,7 @@ builder.Services.AddSingleton<LoginRateLimiter>();
builder.Services.AddSingleton<GlobalLeaksSessionStore>(); builder.Services.AddSingleton<GlobalLeaksSessionStore>();
builder.Services.AddSingleton<PendingGlobalLeaksLoginStore>(); builder.Services.AddSingleton<PendingGlobalLeaksLoginStore>();
builder.Services.AddScoped<GlobalLeaksClient>(); builder.Services.AddScoped<GlobalLeaksClient>();
builder.Services.AddScoped<GlobalLeaksSessionKeepAliveService>();
builder.Services.AddSingleton<MySqlConnectionStringProvider>(); builder.Services.AddSingleton<MySqlConnectionStringProvider>();
builder.Services.AddScoped<MySqlDenunciaStore>(); builder.Services.AddScoped<MySqlDenunciaStore>();
builder.Services.AddSingleton<IEncryptionKeyProvider, KeyVaultEncryptionKeyProvider>(); builder.Services.AddSingleton<IEncryptionKeyProvider, KeyVaultEncryptionKeyProvider>();
@@ -42,7 +43,9 @@ builder.Services.AddScoped<IFilteredDenunciaStore>(sp => sp.GetRequiredService<E
builder.Services.AddScoped<IInboxTrackingService, InboxTrackingService>(); builder.Services.AddScoped<IInboxTrackingService, InboxTrackingService>();
builder.Services.AddScoped<DenunciaInboxService>(); builder.Services.AddScoped<DenunciaInboxService>();
builder.Services.AddScoped<GestionaDocumentWorkflowService>(); builder.Services.AddScoped<GestionaDocumentWorkflowService>();
builder.Services.AddScoped<GestionaExpedienteExceptionStore>();
builder.Services.AddScoped<UserComplaintAccessService>(); builder.Services.AddScoped<UserComplaintAccessService>();
builder.Services.AddScoped<WorkGroupAdministrationService>();
builder.Services.AddHttpClient<ManualPurgeService>(); builder.Services.AddHttpClient<ManualPurgeService>();
builder.Services.AddScoped<AppConfigurationService>(); builder.Services.AddScoped<AppConfigurationService>();
@@ -99,7 +102,26 @@ builder.Services
}; };
}); });
builder.Services.AddAuthorization(); var configurationAdministrators = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"pcornejo",
"Rgarciaglbk",
"eaguilarGestor"
};
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("ConfigurationAdministrators", policy =>
{
policy.RequireAssertion(context =>
{
var username = context.User.Identity?.Name?.Trim();
return context.User.Identity?.IsAuthenticated == true &&
!string.IsNullOrWhiteSpace(username) &&
configurationAdministrators.Contains(username);
});
});
});
var app = builder.Build(); var app = builder.Build();

View File

@@ -64,6 +64,9 @@ CREATE TABLE IF NOT EXISTS complaints (
workflow_status TEXT NOT NULL, workflow_status TEXT NOT NULL,
selected_document_name TEXT NULL, selected_document_name TEXT NULL,
gestiona_uploaded_at_utc DATETIME(6) NULL, gestiona_uploaded_at_utc DATETIME(6) NULL,
gestiona_last_upload_type TEXT NOT NULL,
gestiona_assigned_group TEXT NOT NULL,
pending_update_source VARCHAR(256) NOT NULL DEFAULT '',
is_in_gestiona TINYINT(1) NOT NULL DEFAULT 0, is_in_gestiona TINYINT(1) NOT NULL DEFAULT 0,
is_rejected TINYINT(1) NOT NULL DEFAULT 0, is_rejected TINYINT(1) NOT NULL DEFAULT 0,
created_at_utc DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), created_at_utc DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
@@ -85,6 +88,58 @@ CREATE TABLE IF NOT EXISTS app_users (
UNIQUE KEY uq_app_users_username (username) UNIQUE KEY uq_app_users_username (username)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS work_groups (
id BIGINT NOT NULL AUTO_INCREMENT,
code VARCHAR(32) NOT NULL,
name VARCHAR(256) NOT NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at_utc DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
updated_at_utc DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
PRIMARY KEY (id),
UNIQUE KEY uq_work_groups_code (code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
INSERT INTO work_groups (code, name)
VALUES
('600', 'Asuntos Juridicos y Proteccion a la Persona Denunciante'),
('510', 'SDI - Investigacion Entradas')
ON DUPLICATE KEY UPDATE
name = VALUES(name),
is_active = 1;
CREATE TABLE IF NOT EXISTS app_user_groups (
app_user_id BIGINT NOT NULL,
work_group_id BIGINT NOT NULL,
assigned_at_utc DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
assigned_by_username VARCHAR(256) NOT NULL DEFAULT '',
PRIMARY KEY (app_user_id, work_group_id),
KEY ix_app_user_groups_group (work_group_id),
CONSTRAINT fk_app_user_groups_user
FOREIGN KEY (app_user_id) REFERENCES app_users(id)
ON DELETE CASCADE,
CONSTRAINT fk_app_user_groups_group
FOREIGN KEY (work_group_id) REFERENCES work_groups(id)
ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS app_user_group_history (
id BIGINT NOT NULL AUTO_INCREMENT,
app_user_id BIGINT NOT NULL,
work_group_id BIGINT NOT NULL,
action VARCHAR(16) NOT NULL,
changed_by_username VARCHAR(256) NOT NULL,
changed_at_utc DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
PRIMARY KEY (id),
KEY ix_app_user_group_history_user (app_user_id),
KEY ix_app_user_group_history_group (work_group_id),
CONSTRAINT fk_app_user_group_history_user
FOREIGN KEY (app_user_id) REFERENCES app_users(id)
ON DELETE CASCADE,
CONSTRAINT fk_app_user_group_history_group
FOREIGN KEY (work_group_id) REFERENCES work_groups(id)
ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS inbox_reports ( CREATE TABLE IF NOT EXISTS inbox_reports (
id BIGINT NOT NULL AUTO_INCREMENT, id BIGINT NOT NULL AUTO_INCREMENT,
global_report_uuid CHAR(36) NOT NULL, global_report_uuid CHAR(36) NOT NULL,
@@ -102,6 +157,7 @@ CREATE TABLE IF NOT EXISTS inbox_reports (
last_seen_at_utc DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), last_seen_at_utc DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
last_downloaded_at_utc DATETIME(6) NULL, last_downloaded_at_utc DATETIME(6) NULL,
last_downloaded_by_user_id BIGINT NULL, last_downloaded_by_user_id BIGINT NULL,
owner_user_id BIGINT NULL,
imported_complaint_report_id INT NULL, imported_complaint_report_id INT NULL,
imported_to_store_at_utc DATETIME(6) NULL, imported_to_store_at_utc DATETIME(6) NULL,
created_at_utc DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), created_at_utc DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
@@ -110,8 +166,12 @@ CREATE TABLE IF NOT EXISTS inbox_reports (
UNIQUE KEY uq_inbox_reports_uuid (global_report_uuid), UNIQUE KEY uq_inbox_reports_uuid (global_report_uuid),
KEY ix_inbox_reports_progressive (progressive_id), KEY ix_inbox_reports_progressive (progressive_id),
KEY ix_inbox_reports_downloaded (last_downloaded_at_utc), KEY ix_inbox_reports_downloaded (last_downloaded_at_utc),
KEY ix_inbox_reports_owner (owner_user_id),
CONSTRAINT fk_inbox_reports_last_user CONSTRAINT fk_inbox_reports_last_user
FOREIGN KEY (last_downloaded_by_user_id) REFERENCES app_users(id) FOREIGN KEY (last_downloaded_by_user_id) REFERENCES app_users(id)
ON DELETE SET NULL,
CONSTRAINT fk_inbox_reports_owner_user
FOREIGN KEY (owner_user_id) REFERENCES app_users(id)
ON DELETE SET NULL ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
@@ -133,6 +193,24 @@ CREATE TABLE IF NOT EXISTS user_inbox_reports (
ON DELETE CASCADE ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS gestiona_upload_history (
id BIGINT NOT NULL AUTO_INCREMENT,
external_report_id INT NOT NULL,
gestiona_file_url TEXT NOT NULL,
gestiona_file_code VARCHAR(128) NOT NULL DEFAULT '',
upload_type VARCHAR(64) NOT NULL DEFAULT '',
assigned_group_code VARCHAR(32) NOT NULL DEFAULT '',
uploaded_by_username VARCHAR(256) NOT NULL DEFAULT '',
uploaded_at_utc DATETIME(6) NOT NULL,
subject TEXT NULL,
document_names TEXT NULL,
created_at_utc DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
PRIMARY KEY (id),
KEY ix_gestiona_upload_history_report (external_report_id),
KEY ix_gestiona_upload_history_uploaded_at (uploaded_at_utc),
KEY ix_gestiona_upload_history_user (uploaded_by_username)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS complaint_attachments ( CREATE TABLE IF NOT EXISTS complaint_attachments (
id BIGINT NOT NULL AUTO_INCREMENT, id BIGINT NOT NULL AUTO_INCREMENT,
complaint_id BIGINT NOT NULL, complaint_id BIGINT NOT NULL,

View File

@@ -35,7 +35,12 @@ public sealed class AppConfigurationService
? null ? null
: Convert.ToString(value, CultureInfo.InvariantCulture); : Convert.ToString(value, CultureInfo.InvariantCulture);
return new AppConfigurationDto(string.IsNullOrWhiteSpace(dateText) ? null : dateText); var latestEncryptionKey = await GetLatestEncryptionKeyAsync(connection, cancellationToken);
return new AppConfigurationDto(
string.IsNullOrWhiteSpace(dateText) ? null : dateText,
latestEncryptionKey?.KeyDate.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
latestEncryptionKey?.Status);
} }
public async Task<AppConfigurationDto> SetExternalUpdateCutoffDateAsync( public async Task<AppConfigurationDto> SetExternalUpdateCutoffDateAsync(
@@ -59,7 +64,42 @@ public sealed class AppConfigurationService
command.Parameters.AddWithValue("@settingValue", string.IsNullOrWhiteSpace(dateText) ? DBNull.Value : dateText); command.Parameters.AddWithValue("@settingValue", string.IsNullOrWhiteSpace(dateText) ? DBNull.Value : dateText);
await command.ExecuteNonQueryAsync(cancellationToken); await command.ExecuteNonQueryAsync(cancellationToken);
return new AppConfigurationDto(dateText); var latestEncryptionKey = await GetLatestEncryptionKeyAsync(connection, cancellationToken);
return new AppConfigurationDto(
dateText,
latestEncryptionKey?.KeyDate.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
latestEncryptionKey?.Status);
}
private static async Task<LatestEncryptionKey?> GetLatestEncryptionKeyAsync(
MySqlConnection connection,
CancellationToken cancellationToken)
{
try
{
await using var command = new MySqlCommand(
"""
SELECT key_date, status
FROM encryption_keys
ORDER BY key_date DESC
LIMIT 1;
""",
connection);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
if (!await reader.ReadAsync(cancellationToken))
{
return null;
}
return new LatestEncryptionKey(
DateOnly.FromDateTime(reader.GetDateTime("key_date")),
reader.GetString("status"));
}
catch (MySqlException ex) when (ex.Number == 1146)
{
return null;
}
} }
private async Task<MySqlConnection> OpenConnectionAsync(CancellationToken cancellationToken) private async Task<MySqlConnection> OpenConnectionAsync(CancellationToken cancellationToken)
@@ -84,4 +124,6 @@ public sealed class AppConfigurationService
connection); connection);
await command.ExecuteNonQueryAsync(cancellationToken); await command.ExecuteNonQueryAsync(cancellationToken);
} }
private sealed record LatestEncryptionKey(DateOnly KeyDate, string Status);
} }

View File

@@ -1,4 +1,6 @@
using System.IO.Compression; using System.IO.Compression;
using System.Globalization;
using System.Security.Cryptography;
using System.Text; using System.Text;
using ApiDenuncias.Helpers; using ApiDenuncias.Helpers;
using GestionaDenuncias.Shared.Models; using GestionaDenuncias.Shared.Models;
@@ -7,8 +9,6 @@ namespace ApiDenuncias.Services;
public sealed class DenunciaInboxService public sealed class DenunciaInboxService
{ {
private const string RootPath = @"C:\ZipsDenuncias";
private static readonly HashSet<string> BlockedAttachmentExtensions = new(StringComparer.OrdinalIgnoreCase) private static readonly HashSet<string> BlockedAttachmentExtensions = new(StringComparer.OrdinalIgnoreCase)
{ {
".ade", ".ade",
@@ -55,91 +55,47 @@ public sealed class DenunciaInboxService
}; };
private readonly IGestionaService _gestionaService; private readonly IGestionaService _gestionaService;
private readonly GestionaExpedienteExceptionStore _gestionaExceptionStore;
private readonly IDenunciaStore _denunciaStore; private readonly IDenunciaStore _denunciaStore;
private readonly IFilteredDenunciaStore _filteredDenunciaStore;
private readonly ILogger<DenunciaInboxService> _logger; private readonly ILogger<DenunciaInboxService> _logger;
public DenunciaInboxService( public DenunciaInboxService(
IGestionaService gestionaService, IGestionaService gestionaService,
GestionaExpedienteExceptionStore gestionaExceptionStore,
IDenunciaStore denunciaStore, IDenunciaStore denunciaStore,
IFilteredDenunciaStore filteredDenunciaStore,
ILogger<DenunciaInboxService> logger) ILogger<DenunciaInboxService> logger)
{ {
_gestionaService = gestionaService; _gestionaService = gestionaService;
_gestionaExceptionStore = gestionaExceptionStore;
_denunciaStore = denunciaStore; _denunciaStore = denunciaStore;
_filteredDenunciaStore = filteredDenunciaStore;
_logger = logger; _logger = logger;
} }
public async Task EnsureStorageReadyAsync(CancellationToken cancellationToken = default) public async Task EnsureStorageReadyAsync(CancellationToken cancellationToken = default)
{ {
Directory.CreateDirectory(RootPath);
await _denunciaStore.EnsureSchemaAsync(cancellationToken); await _denunciaStore.EnsureSchemaAsync(cancellationToken);
} await _gestionaExceptionStore.EnsureSchemaAsync(cancellationToken);
public async Task<IReadOnlyList<string>> GetExistingZipNamesAsync(CancellationToken cancellationToken = default)
{
await EnsureStorageReadyAsync(cancellationToken);
return Directory
.GetFiles(RootPath, "*.zip")
.Select(Path.GetFileName)
.Where(name => !string.IsNullOrWhiteSpace(name))
.OrderBy(name => name, StringComparer.OrdinalIgnoreCase)
.ToArray()!;
}
public async Task DeleteZipAsync(string zipName, CancellationToken cancellationToken = default)
{
await EnsureStorageReadyAsync(cancellationToken);
var fullPath = Path.Combine(RootPath, zipName);
if (File.Exists(fullPath))
{
File.Delete(fullPath);
}
}
public async Task<ImportSummary> ProcessPendingFolderZipsAsync(CancellationToken cancellationToken = default)
{
await EnsureStorageReadyAsync(cancellationToken);
var zipPaths = Directory.GetFiles(RootPath, "*.zip")
.OrderBy(path => path, StringComparer.OrdinalIgnoreCase)
.ToList();
var errors = new List<string>();
var warnings = new List<string>();
var importedCount = 0;
var complaintIds = new List<int>();
foreach (var zipPath in zipPaths)
{
try
{
var zipBytes = await File.ReadAllBytesAsync(zipPath, cancellationToken);
var result = await ProcessZipAsync(zipBytes, Path.GetFileName(zipPath), null, cancellationToken);
File.Delete(zipPath);
importedCount++;
complaintIds.Add(result.ComplaintId);
warnings.AddRange(result.Warnings.Select(warning => $"{Path.GetFileName(zipPath)}: {warning}"));
}
catch (Exception ex)
{
_logger.LogError(ex, "Error procesando el ZIP local {ZipPath}", zipPath);
errors.Add($"{Path.GetFileName(zipPath)}: {ex.Message}");
}
}
return new ImportSummary(zipPaths.Count, importedCount, errors, complaintIds, warnings);
} }
public async Task<ImportSummary> ImportFromGlobalLeaksAsync( public async Task<ImportSummary> ImportFromGlobalLeaksAsync(
FileDownloadResult zipDownload, FileDownloadResult reportDownload,
FileDownloadResult? jsonDownload, FileDownloadResult? jsonDownload,
ReportDetailDto? reportDetail,
ReportDto inboxReport,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
await EnsureStorageReadyAsync(cancellationToken); await EnsureStorageReadyAsync(cancellationToken);
var fileName = string.IsNullOrWhiteSpace(zipDownload.FileName) var sourceName = string.IsNullOrWhiteSpace(reportDownload.FileName)
? $"report-{Guid.NewGuid():N}.zip" ? $"report-{Guid.NewGuid():N}"
: zipDownload.FileName; : reportDownload.FileName;
if (sourceName.EndsWith(".zip", StringComparison.OrdinalIgnoreCase))
{
sourceName = Path.GetFileNameWithoutExtension(sourceName);
}
var json = jsonDownload is null var json = jsonDownload is null
? null ? null
@@ -147,24 +103,79 @@ public sealed class DenunciaInboxService
try try
{ {
var result = await ProcessZipAsync(zipDownload.Content, fileName, json, cancellationToken); var result = await ProcessGlobalLeaksPackageAsync(
return new ImportSummary(1, 1, [], [result.ComplaintId], result.Warnings); reportDownload.Content,
sourceName,
json,
reportDetail,
inboxReport,
cancellationToken);
return new ImportSummary(
1,
result.ImportedCount,
[],
result.ImportedCount > 0 ? [result.ComplaintId] : [],
result.Warnings);
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Error importando denuncia desde GlobalLeaks {FileName}", fileName); _logger.LogError(ex, "Error importando denuncia desde GlobalLeaks {SourceName}", sourceName);
return new ImportSummary(1, 0, [$"{fileName}: {ex.Message}"]); return new ImportSummary(1, 0, [ToUserImportErrorMessage(sourceName, ex)]);
} }
} }
private async Task<ProcessZipResult> ProcessZipAsync( private static string ToUserImportErrorMessage(string sourceName, Exception ex)
byte[] zipBytes, {
var message = ex.Message ?? string.Empty;
if (ex is EncryptionKeyUnavailableException ||
message.Contains("encryption_keys", StringComparison.OrdinalIgnoreCase) ||
message.Contains("clave de cifrado", StringComparison.OrdinalIgnoreCase))
{
return $"{sourceName}: no hay una clave de cifrado activa para guardar la denuncia. Avisa al equipo tecnico.";
}
if (message.Contains("max_allowed_packet", StringComparison.OrdinalIgnoreCase) ||
message.Contains("Error submitting", StringComparison.OrdinalIgnoreCase))
{
return $"{sourceName}: la denuncia contiene un adjunto demasiado grande para guardarse. Revisa los adjuntos o consulta con soporte.";
}
if (message.Contains("Connection must be Open", StringComparison.OrdinalIgnoreCase) ||
message.Contains("current state is Closed", StringComparison.OrdinalIgnoreCase))
{
return $"{sourceName}: se ha perdido la conexion mientras se guardaba la denuncia. Vuelve a intentarlo.";
}
if (ex is InvalidDataException)
{
return $"{sourceName}: el paquete descargado de GlobalLeaks no se ha podido leer correctamente. Vuelve a intentarlo.";
}
if (ex is InvalidOperationException && IsUserFacingImportMessage(message))
{
return $"{sourceName}: {message}";
}
return $"{sourceName}: no se ha podido importar la denuncia. Vuelve a intentarlo; si se repite, avisa al equipo tecnico.";
}
private static bool IsUserFacingImportMessage(string message)
=> message.StartsWith("El paquete de GlobalLeaks", StringComparison.OrdinalIgnoreCase) ||
message.StartsWith("El report viene", StringComparison.OrdinalIgnoreCase) ||
message.StartsWith("El archivo", StringComparison.OrdinalIgnoreCase) ||
message.StartsWith("Denuncia no disponible", StringComparison.OrdinalIgnoreCase);
private async Task<ProcessPackageResult> ProcessGlobalLeaksPackageAsync(
byte[] packageBytes,
string sourceName, string sourceName,
string? globalLeaksJson, string? globalLeaksJson,
ReportDetailDto? reportDetail,
ReportDto inboxReport,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
using var zipStream = new MemoryStream(zipBytes, writable: false); using var packageStream = new MemoryStream(packageBytes, writable: false);
using var archive = new ZipArchive(zipStream, ZipArchiveMode.Read, leaveOpen: false); using var archive = new ZipArchive(packageStream, ZipArchiveMode.Read, leaveOpen: false);
var reportEntry = FindReportEntry(archive); var reportEntry = FindReportEntry(archive);
@@ -178,8 +189,8 @@ public sealed class DenunciaInboxService
throw new InvalidOperationException( throw new InvalidOperationException(
entries.Length == 0 entries.Length == 0
? "El ZIP no contiene ficheros." ? "El paquete de GlobalLeaks no contiene ficheros."
: $"El ZIP no contiene un report reconocible. Ficheros encontrados: {string.Join(", ", entries)}"); : $"El paquete de GlobalLeaks no contiene un report reconocible. Ficheros encontrados: {string.Join(", ", entries)}");
} }
var reportIsPdf = IsPdfEntry(reportEntry); var reportIsPdf = IsPdfEntry(reportEntry);
@@ -211,6 +222,12 @@ public sealed class DenunciaInboxService
$"No se ha podido determinar el identificador de la denuncia en {sourceName}."); $"No se ha podido determinar el identificador de la denuncia en {sourceName}.");
} }
denuncia.PendingUpdateSource = inboxReport.CitizenHasNewActivity
? ComplaintUpdateSources.Citizen
: inboxReport.ReceiverHasNewActivity
? ComplaintUpdateSources.Receiver
: string.Empty;
if (reportIsPdf) if (reportIsPdf)
{ {
reportText = BuildSyntheticReportText(denuncia); reportText = BuildSyntheticReportText(denuncia);
@@ -224,16 +241,54 @@ public sealed class DenunciaInboxService
denuncia.Expediente_Gestiona = "Pendiente"; denuncia.Expediente_Gestiona = "Pendiente";
} }
var readFilesResult = await ReadFilesFromArchiveAsync(archive, reportEntry, denuncia.Id_Denuncia, cancellationToken); var readFilesResult = await ReadFilesFromArchiveAsync(
archive,
reportEntry,
denuncia.Id_Denuncia,
denuncia.Fecha,
reportDetail,
cancellationToken);
await MergeComplaintAsync(denuncia, cancellationToken); await MergeComplaintAsync(denuncia, cancellationToken);
var storedComplaintBeforeFiles = await _denunciaStore.GetDenunciaByIdAsync(denuncia.Id_Denuncia, cancellationToken);
var existingFiles = IsAlreadyUploadedToGestiona(storedComplaintBeforeFiles)
? await GetFicherosMetadataByDenunciaForImportAsync(denuncia.Id_Denuncia, cancellationToken)
: [];
PreserveKnownUploadedReportStatus(readFilesResult.Files, existingFiles, storedComplaintBeforeFiles);
await MergeFilesAsync(readFilesResult.Files, cancellationToken); await MergeFilesAsync(readFilesResult.Files, cancellationToken);
return new ProcessZipResult(denuncia.Id_Denuncia, readFilesResult.Warnings);
var warnings = readFilesResult.Warnings.ToList();
var storedComplaint = await _denunciaStore.GetDenunciaByIdAsync(denuncia.Id_Denuncia, cancellationToken);
if (IsAlreadyUploadedToGestiona(storedComplaint))
{
var storedFiles = await GetFicherosMetadataByDenunciaForImportAsync(
denuncia.Id_Denuncia,
cancellationToken);
if (!HasPendingFilesForGestiona(storedFiles))
{
storedComplaint!.EsActualizacion = false;
storedComplaint.EnGestiona = true;
storedComplaint.PendingUpdateSource = string.Empty;
await _denunciaStore.UpsertDenunciaAsync(storedComplaint, cancellationToken);
warnings.Add(BuildNoCitizenUpdateWarning(inboxReport));
return new ProcessPackageResult(denuncia.Id_Denuncia, 0, warnings);
}
}
return new ProcessPackageResult(denuncia.Id_Denuncia, 1, warnings);
} }
private Task<List<FicherosDenuncias>> GetFicherosMetadataByDenunciaForImportAsync(
int denunciaId,
CancellationToken cancellationToken)
=> _filteredDenunciaStore.GetFicherosMetadataByDenunciaAsync(denunciaId, cancellationToken);
private async Task<ReadFilesResult> ReadFilesFromArchiveAsync( private async Task<ReadFilesResult> ReadFilesFromArchiveAsync(
ZipArchive archive, ZipArchive archive,
ZipArchiveEntry reportEntry, ZipArchiveEntry reportEntry,
int denunciaId, int denunciaId,
DateTime reportDateUtc,
ReportDetailDto? reportDetail,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var warnings = new List<string>(); var warnings = new List<string>();
@@ -243,9 +298,9 @@ public sealed class DenunciaInboxService
id_Fichero: 0, id_Fichero: 0,
id_Tipo: 1, id_Tipo: 1,
descripcion: IsPdfEntry(reportEntry) ? "report.pdf original" : "report.txt original", descripcion: IsPdfEntry(reportEntry) ? "report.pdf original" : "report.txt original",
fecha: reportEntry.LastWriteTime.UtcDateTime == DateTime.MinValue fecha: reportDateUtc == DateTime.MinValue
? DateTime.UtcNow ? GetEntryFallbackDateUtc(reportEntry)
: reportEntry.LastWriteTime.UtcDateTime, : NormalizeUtc(reportDateUtc),
observaciones: "", observaciones: "",
id_Denuncia: denunciaId, id_Denuncia: denunciaId,
nombreFichero: IsPdfEntry(reportEntry) ? "report.pdf" : "report.txt", nombreFichero: IsPdfEntry(reportEntry) ? "report.pdf" : "report.txt",
@@ -264,9 +319,7 @@ public sealed class DenunciaInboxService
id_Fichero: 0, id_Fichero: 0,
id_Tipo: 1, id_Tipo: 1,
descripcion: null, descripcion: null,
fecha: entry.LastWriteTime.UtcDateTime == DateTime.MinValue fecha: ResolveAttachmentDateUtc(entry, reportDetail),
? DateTime.UtcNow
: entry.LastWriteTime.UtcDateTime,
observaciones: "", observaciones: "",
id_Denuncia: denunciaId, id_Denuncia: denunciaId,
nombreFichero: Path.GetFileName(entry.FullName), nombreFichero: Path.GetFileName(entry.FullName),
@@ -308,6 +361,74 @@ public sealed class DenunciaInboxService
return _denunciaStore.UpsertFicherosAsync(nuevosFicheros, cancellationToken); return _denunciaStore.UpsertFicherosAsync(nuevosFicheros, cancellationToken);
} }
private static void PreserveKnownUploadedReportStatus(
List<FicherosDenuncias> newFiles,
IReadOnlyList<FicherosDenuncias> existingFiles,
DenunciasGestiona? storedComplaint)
{
if (!IsAlreadyUploadedToGestiona(storedComplaint) || existingFiles.Count == 0)
{
return;
}
var existingReportHashes = existingFiles
.Where(file => file.EsReport && !string.IsNullOrWhiteSpace(file.ContentSha256))
.GroupBy(file => file.ContentSha256, StringComparer.OrdinalIgnoreCase)
.ToDictionary(group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase);
foreach (var report in newFiles.Where(file => file.EsReport))
{
var hash = string.IsNullOrWhiteSpace(report.ContentSha256)
? ComputeSha256Hex(report.Fichero ?? [])
: report.ContentSha256.Trim().ToLowerInvariant();
report.ContentSha256 = hash;
if (!existingReportHashes.TryGetValue(hash, out var existingReport))
{
continue;
}
report.Subido = true;
report.FechaSubida = existingReport.FechaSubida ??
(storedComplaint!.FechaSubidaAGestiona == DateTime.MinValue
? null
: storedComplaint.FechaSubidaAGestiona);
}
}
private static string BuildNoCitizenUpdateWarning(ReportDto inboxReport)
{
return inboxReport.ReceiverHasNewActivity && !inboxReport.CitizenHasNewActivity
? "La actividad detectada procede de la OAAF, pero el report descargado no contiene cambios pendientes de subir a Gestiona."
: "La denuncia ya esta en Gestiona y el report no contiene documentos ni comentarios nuevos del ciudadano pendientes de subir.";
}
private static bool HasPendingFilesForGestiona(IReadOnlyList<FicherosDenuncias> files)
{
var plannedHashes = files
.Where(file => file.Subido && !string.IsNullOrWhiteSpace(file.ContentSha256))
.Select(file => file.ContentSha256)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
foreach (var file in files.Where(file => !file.Subido))
{
if (string.IsNullOrWhiteSpace(file.ContentSha256))
{
return true;
}
if (plannedHashes.Add(file.ContentSha256))
{
return true;
}
}
return false;
}
private static string ComputeSha256Hex(byte[] content)
=> Convert.ToHexString(SHA256.HashData(content)).ToLowerInvariant();
private async Task CompleteGestionaStatusAsync( private async Task CompleteGestionaStatusAsync(
DenunciasGestiona denuncia, DenunciasGestiona denuncia,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@@ -330,15 +451,15 @@ public sealed class DenunciaInboxService
var match = await _gestionaService.BuscarExpedientePorIdEnAsuntoAsync(denuncia.Id_Denuncia); var match = await _gestionaService.BuscarExpedientePorIdEnAsuntoAsync(denuncia.Id_Denuncia);
if (match is null) if (match is null)
{ {
ApplyPendingGestionaStatus(denuncia); match = await TryFindGestionaExceptionMatchAsync(denuncia, cancellationToken);
return; if (match is null)
{
ApplyPendingGestionaStatus(denuncia);
return;
}
} }
denuncia.EsActualizacion = true; ApplyGestionaMatch(denuncia, match);
denuncia.EnGestiona = true;
denuncia.Expediente_Gestiona = match.FileUrl;
denuncia.CodigoExpedienteGestiona = match.CodigoExpediente ?? string.Empty;
denuncia.NombreDenuncia = match.FreeTitle ?? $"Denuncia {denuncia.Id_Denuncia}-CD";
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -350,6 +471,39 @@ public sealed class DenunciaInboxService
} }
} }
private async Task<GestionaExpedienteInfo?> TryFindGestionaExceptionMatchAsync(
DenunciasGestiona denuncia,
CancellationToken cancellationToken)
{
var exception = await _gestionaExceptionStore.FindAsync(denuncia, cancellationToken);
if (exception is null)
{
return null;
}
var match = await _gestionaService.BuscarExpedientePorCodigoAsync(exception.GestionaFileCode);
if (match is null)
{
_logger.LogWarning(
"La denuncia {DenunciaId} tiene excepcion Gestiona con codigo {GestionaFileCode}, pero no se ha encontrado expediente en Gestiona.",
denuncia.Id_Denuncia,
exception.GestionaFileCode);
}
return match;
}
private static void ApplyGestionaMatch(
DenunciasGestiona denuncia,
GestionaExpedienteInfo match)
{
denuncia.EsActualizacion = true;
denuncia.EnGestiona = true;
denuncia.Expediente_Gestiona = match.FileUrl;
denuncia.CodigoExpedienteGestiona = match.CodigoExpediente ?? string.Empty;
denuncia.NombreDenuncia = match.FreeTitle ?? $"Denuncia {denuncia.Id_Denuncia}-CD";
}
private static bool TryApplyGestionaStatusFromStore( private static bool TryApplyGestionaStatusFromStore(
DenunciasGestiona target, DenunciasGestiona target,
DenunciasGestiona? storedComplaint) DenunciasGestiona? storedComplaint)
@@ -461,6 +615,7 @@ public sealed class DenunciaInboxService
target.Pais = source.Pais; target.Pais = source.Pais;
target.CamposFormularioJson = source.CamposFormularioJson; target.CamposFormularioJson = source.CamposFormularioJson;
target.TextoOriginalReport = source.TextoOriginalReport; target.TextoOriginalReport = source.TextoOriginalReport;
target.PendingUpdateSource = source.PendingUpdateSource;
} }
private static bool IsSupportedAttachmentEntry(ZipArchiveEntry entry) private static bool IsSupportedAttachmentEntry(ZipArchiveEntry entry)
@@ -474,6 +629,83 @@ public sealed class DenunciaInboxService
return IsDirectChildOf(normalized, "files") || IsDirectChildOf(normalized, "files_attached_from_recipients"); return IsDirectChildOf(normalized, "files") || IsDirectChildOf(normalized, "files_attached_from_recipients");
} }
private static DateTime ResolveAttachmentDateUtc(ZipArchiveEntry entry, ReportDetailDto? reportDetail)
{
if (reportDetail is not null)
{
var normalized = NormalizeEntryPath(entry.FullName);
var preferredFiles = IsDirectChildOf(normalized, "files_attached_from_recipients")
? reportDetail.ReceiverFiles
: reportDetail.WhistleblowerFiles;
var detailDate = TryFindReportFileDateUtc(entry, preferredFiles) ??
TryFindReportFileDateUtc(entry, reportDetail.WhistleblowerFiles.Concat(reportDetail.ReceiverFiles));
if (detailDate is not null)
{
return detailDate.Value;
}
}
return GetEntryFallbackDateUtc(entry);
}
private static DateTime? TryFindReportFileDateUtc(ZipArchiveEntry entry, IEnumerable<ReportFileDto> reportFiles)
{
var entryName = NormalizeFileName(entry.Name);
var candidates = reportFiles
.Where(file => !string.IsNullOrWhiteSpace(file.Name) &&
string.Equals(NormalizeFileName(file.Name), entryName, StringComparison.OrdinalIgnoreCase))
.OrderByDescending(file => file.Size is null || file.Size == entry.Length)
.ToList();
foreach (var candidate in candidates)
{
var parsed = TryParseGlobalLeaksDateUtc(candidate.CreationDate);
if (parsed is not null)
{
return parsed;
}
}
return null;
}
private static DateTime? TryParseGlobalLeaksDateUtc(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
var normalized = value.Replace("Z", "+00:00", StringComparison.Ordinal);
return DateTimeOffset.TryParse(
normalized,
CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
out var parsed)
? parsed.UtcDateTime
: null;
}
private static DateTime GetEntryFallbackDateUtc(ZipArchiveEntry entry)
{
var utcDate = entry.LastWriteTime.UtcDateTime;
return utcDate == DateTime.MinValue
? DateTime.UtcNow
: utcDate;
}
private static DateTime NormalizeUtc(DateTime value)
=> value.Kind switch
{
DateTimeKind.Utc => value,
DateTimeKind.Local => value.ToUniversalTime(),
_ => DateTime.SpecifyKind(value, DateTimeKind.Utc)
};
private static string NormalizeFileName(string fileName)
=> Path.GetFileName(fileName.Replace('\\', '/')).Trim();
private static bool IsBlockedAttachmentEntry(ZipArchiveEntry entry) private static bool IsBlockedAttachmentEntry(ZipArchiveEntry entry)
{ {
var extension = Path.GetExtension(entry.Name); var extension = Path.GetExtension(entry.Name);
@@ -667,7 +899,7 @@ public sealed class DenunciaInboxService
builder.AppendLine(); builder.AppendLine();
} }
private sealed record ProcessZipResult(int ComplaintId, IReadOnlyList<string> Warnings); private sealed record ProcessPackageResult(int ComplaintId, int ImportedCount, IReadOnlyList<string> Warnings);
private sealed record ReadFilesResult(List<FicherosDenuncias> Files, IReadOnlyList<string> Warnings); private sealed record ReadFilesResult(List<FicherosDenuncias> Files, IReadOnlyList<string> Warnings);
} }

View File

@@ -121,6 +121,51 @@ public sealed class EncryptedDenunciaStore : IDenunciaStore, IFilteredDenunciaSt
return await UnprotectAttachmentsAsync(ficheros, skipPurgedRows: false, cancellationToken); return await UnprotectAttachmentsAsync(ficheros, skipPurgedRows: false, cancellationToken);
} }
public Task<List<FicherosDenuncias>> GetFicherosMetadataByDenunciaAsync(
int denunciaId,
CancellationToken cancellationToken = default)
=> _inner.GetFicherosMetadataByDenunciaAsync(denunciaId, cancellationToken);
public async Task<List<GestionaUploadHistoryEntry>> GetGestionaUploadHistoryAsync(CancellationToken cancellationToken = default)
{
var history = await _inner.GetGestionaUploadHistoryAsync(cancellationToken);
var knownIds = history
.Select(item => item.DenunciaId)
.ToHashSet();
List<GestionaUploadHistoryEntry> fallbackHistory = [];
try
{
var storedComplaints = await _inner.GetDenunciasByScopeAsync(DenunciaListScope.InGestiona, cancellationToken);
var fallbackComplaints = await UnprotectComplaintsAsync(storedComplaints, skipPurgedRows: true, cancellationToken);
fallbackHistory = fallbackComplaints
.Where(complaint => complaint.FechaSubidaAGestiona != DateTime.MinValue &&
!knownIds.Contains(complaint.Id_Denuncia))
.Select(complaint => new GestionaUploadHistoryEntry(
0,
complaint.Id_Denuncia,
complaint.Expediente_Gestiona ?? string.Empty,
complaint.ExpedienteGestionaMostrable,
complaint.UltimaSubidaGestionaTipoMostrable,
ExtractGroupCode(complaint.UltimoGrupoAsignadoGestionaMostrable),
"No registrado",
complaint.FechaSubidaAGestiona,
complaint.NombreDenuncia ?? string.Empty,
complaint.ArchivoElegido ?? string.Empty))
.ToList();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "No se ha podido completar el historico de Gestiona con denuncias antiguas descifrables.");
}
return history
.Concat(fallbackHistory)
.OrderByDescending(item => item.UploadedAtUtc)
.ThenByDescending(item => item.Id)
.ToList();
}
private async Task<List<FicherosDenuncias>> UnprotectAttachmentsAsync( private async Task<List<FicherosDenuncias>> UnprotectAttachmentsAsync(
List<FicherosDenuncias> ficheros, List<FicherosDenuncias> ficheros,
bool skipPurgedRows, bool skipPurgedRows,
@@ -153,6 +198,14 @@ public sealed class EncryptedDenunciaStore : IDenunciaStore, IFilteredDenunciaSt
return denuncia is null ? null : await UnprotectComplaintAsync(denuncia, [], cancellationToken); return denuncia is null ? null : await UnprotectComplaintAsync(denuncia, [], cancellationToken);
} }
public async Task<DenunciasGestiona?> GetDenunciaByGestionaFileCodeAsync(
string gestionaFileCode,
CancellationToken cancellationToken = default)
{
var denuncia = await _inner.GetDenunciaByGestionaFileCodeAsync(gestionaFileCode, cancellationToken);
return denuncia is null ? null : await UnprotectComplaintAsync(denuncia, [], cancellationToken);
}
public async Task UpsertDenunciaAsync(DenunciasGestiona denuncia, CancellationToken cancellationToken = default) public async Task UpsertDenunciaAsync(DenunciasGestiona denuncia, CancellationToken cancellationToken = default)
{ {
var key = await _envelopeKeyProvider.GetCurrentKeyAsync(cancellationToken); var key = await _envelopeKeyProvider.GetCurrentKeyAsync(cancellationToken);
@@ -165,6 +218,12 @@ public sealed class EncryptedDenunciaStore : IDenunciaStore, IFilteredDenunciaSt
await _inner.UpsertFicherosAsync(ficheros.Select(fichero => ProtectAttachment(fichero, key)).ToArray(), cancellationToken); await _inner.UpsertFicherosAsync(ficheros.Select(fichero => ProtectAttachment(fichero, key)).ToArray(), cancellationToken);
} }
public Task AddGestionaUploadHistoryAsync(
GestionaUploadHistoryCreateRequest request,
string username,
CancellationToken cancellationToken = default)
=> _inner.AddGestionaUploadHistoryAsync(request, username, cancellationToken);
public Task MarkFicherosAsUploadedAsync( public Task MarkFicherosAsUploadedAsync(
int denunciaId, int denunciaId,
IEnumerable<string> fileNames, IEnumerable<string> fileNames,
@@ -219,6 +278,9 @@ public sealed class EncryptedDenunciaStore : IDenunciaStore, IFilteredDenunciaSt
EstadoDenuncia = source.EstadoDenuncia, EstadoDenuncia = source.EstadoDenuncia,
ArchivoElegido = source.ArchivoElegido, ArchivoElegido = source.ArchivoElegido,
FechaSubidaAGestiona = source.FechaSubidaAGestiona, FechaSubidaAGestiona = source.FechaSubidaAGestiona,
UltimaSubidaGestionaTipo = source.UltimaSubidaGestionaTipo,
UltimoGrupoAsignadoGestiona = source.UltimoGrupoAsignadoGestiona,
PendingUpdateSource = source.PendingUpdateSource,
EnGestiona = source.EnGestiona, EnGestiona = source.EnGestiona,
EnRechazada = source.EnRechazada, EnRechazada = source.EnRechazada,
@@ -252,6 +314,18 @@ public sealed class EncryptedDenunciaStore : IDenunciaStore, IFilteredDenunciaSt
return rebuilt; return rebuilt;
} }
private static string ExtractGroupCode(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return string.Empty;
}
var trimmed = value.Trim();
var code = new string(trimmed.TakeWhile(char.IsDigit).ToArray());
return string.IsNullOrWhiteSpace(code) ? trimmed : code;
}
private static DenunciasGestiona? TryParseStoredReport(DenunciasGestiona stored) private static DenunciasGestiona? TryParseStoredReport(DenunciasGestiona stored)
{ {
if (string.IsNullOrWhiteSpace(stored.TextoOriginalReport)) if (string.IsNullOrWhiteSpace(stored.TextoOriginalReport))
@@ -286,6 +360,9 @@ public sealed class EncryptedDenunciaStore : IDenunciaStore, IFilteredDenunciaSt
target.EstadoDenuncia = stored.EstadoDenuncia; target.EstadoDenuncia = stored.EstadoDenuncia;
target.ArchivoElegido = stored.ArchivoElegido; target.ArchivoElegido = stored.ArchivoElegido;
target.FechaSubidaAGestiona = stored.FechaSubidaAGestiona; target.FechaSubidaAGestiona = stored.FechaSubidaAGestiona;
target.UltimaSubidaGestionaTipo = stored.UltimaSubidaGestionaTipo;
target.UltimoGrupoAsignadoGestiona = stored.UltimoGrupoAsignadoGestiona;
target.PendingUpdateSource = stored.PendingUpdateSource;
target.EnGestiona = stored.EnGestiona; target.EnGestiona = stored.EnGestiona;
target.EnRechazada = stored.EnRechazada; target.EnRechazada = stored.EnRechazada;
} }

View File

@@ -1,7 +1,9 @@
using System.Net.Http.Headers; using System.Net.Http.Headers;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Globalization;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using GestionaDenuncias.Shared.Models;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace ApiDenuncias.Services; namespace ApiDenuncias.Services;
@@ -35,6 +37,17 @@ public sealed class GestionaDocumentWorkflowService
_configuration["Gestiona:AccessToken"] _configuration["Gestiona:AccessToken"]
?? throw new InvalidOperationException("Falta Gestiona:AccessToken en appsettings."); ?? throw new InvalidOperationException("Falta Gestiona:AccessToken en appsettings.");
private string DocumentMetadataLanguage =>
_configuration["Gestiona:DocumentMetadataLanguage"] ?? "es";
private string DocumentMetadataType =>
_configuration["Gestiona:DocumentMetadataType"]
?? throw new InvalidOperationException("Falta Gestiona:DocumentMetadataType para informar el tipo documental.");
private string DocumentMetadataSubtype =>
_configuration["Gestiona:DocumentMetadataSubtype"]
?? throw new InvalidOperationException("Falta Gestiona:DocumentMetadataSubtype para informar el subtipo documental.");
public async Task<string> UploadDocumentAndReturnUrlAsync(string fileUrl, byte[] contentBytes, string fileName) public async Task<string> UploadDocumentAndReturnUrlAsync(string fileUrl, byte[] contentBytes, string fileName)
{ {
var fileUrlAbs = EnsureAbsoluteGestionaUrl(fileUrl, GestionaApiBase); var fileUrlAbs = EnsureAbsoluteGestionaUrl(fileUrl, GestionaApiBase);
@@ -47,7 +60,9 @@ public sealed class GestionaDocumentWorkflowService
name = fileName, name = fileName,
description = "Documento de denuncia", description = "Documento de denuncia",
elaboration_state = "EE01", elaboration_state = "EE01",
metadata_language = "ES", metadata_language = DocumentMetadataLanguage,
metadata_type = DocumentMetadataType,
metadata_subtype = DocumentMetadataSubtype,
links = new[] { new { rel = "content", href = uploadUri } } links = new[] { new { rel = "content", href = uploadUri } }
}; };
@@ -106,28 +121,33 @@ public sealed class GestionaDocumentWorkflowService
throw new InvalidOperationException("No se pudo obtener la URL del documento creado en Gestiona."); throw new InvalidOperationException("No se pudo obtener la URL del documento creado en Gestiona.");
} }
public async Task TramitarDocumentoAsync(string documentUrl, string assignedGroupHref, int? complaintId = null) public async Task TramitarDocumentoAsync(
string documentUrl,
string assignedGroupCode,
int? complaintId = null,
bool isUpdate = false,
string? updateSource = null)
{ {
_ = assignedGroupHref;
var docUrlAbs = EnsureAbsoluteGestionaUrl(documentUrl, GestionaApiBase); var docUrlAbs = EnsureAbsoluteGestionaUrl(documentUrl, GestionaApiBase);
var templateHref = GetConfiguredTemplateHref(docUrlAbs); var operationLabel = ComplaintUpdateSources.IsReceiver(updateSource)
? $"comunicacion OAAF grupo {NormalizeGroupCode(assignedGroupCode)}"
if (string.IsNullOrWhiteSpace(templateHref)) : isUpdate
{ ? $"actualizacion grupo {NormalizeGroupCode(assignedGroupCode)}"
throw new InvalidOperationException( : "nueva denuncia";
"Falta Gestiona:CircuitTemplateId. No se listan plantillas para evitar campos deprecated."); var (templateHref, payload) = await ResolveCircuitTemplatePayloadAsync(
} docUrlAbs,
isUpdate,
var payload = await GetCircuitTemplatePayloadAsync(templateHref); assignedGroupCode,
updateSource);
var (success, statusCode, body) = await TryPostCircuitAsync(docUrlAbs, payload); var (success, statusCode, body) = await TryPostCircuitAsync(docUrlAbs, payload);
if (success) if (success)
{ {
_logger.LogInformation( _logger.LogInformation(
"Documento {DocumentUrl} enviado a circuito con plantilla {TemplateHref}. Denuncia={ComplaintId}.", "Documento {DocumentUrl} enviado a circuito con plantilla {TemplateHref}. Denuncia={ComplaintId}. Tipo={OperationType}.",
docUrlAbs, docUrlAbs,
templateHref, templateHref,
complaintId); complaintId,
operationLabel);
return; return;
} }
@@ -142,6 +162,194 @@ public sealed class GestionaDocumentWorkflowService
$"TramitarDocumentoAsync: {(int)statusCode} {statusCode}\n{body}"); $"TramitarDocumentoAsync: {(int)statusCode} {statusCode}\n{body}");
} }
private async Task<(string TemplateHref, string Payload)> ResolveCircuitTemplatePayloadAsync(
string documentUrl,
bool isUpdate,
string? assignedGroupCode,
string? updateSource)
{
var templatesUrl = $"{documentUrl.TrimEnd('/')}/circuit/templates";
var templates = await GetCircuitTemplatesAsync(templatesUrl);
if (templates.Count == 0)
{
throw new InvalidOperationException("Gestiona no ha devuelto plantillas de circuito para el documento.");
}
var selection = GetOperationCircuitTemplateSelection(isUpdate, assignedGroupCode, updateSource);
if (!string.IsNullOrWhiteSpace(selection.TemplateName))
{
var resolved = await TryResolveCircuitTemplatePayloadByNameAsync(templates, selection.TemplateName);
if (resolved is not null)
{
return resolved.Value;
}
if (selection.Required)
{
throw new InvalidOperationException(
$"No se ha encontrado la plantilla de circuito '{selection.TemplateName}' para {selection.OperationLabel}.");
}
}
var configuredName = _configuration["Gestiona:CircuitTemplateName"];
var configuredResolved = await TryResolveCircuitTemplatePayloadByNameAsync(templates, configuredName);
if (configuredResolved is not null)
{
return configuredResolved.Value;
}
var signerTitle = _configuration["Gestiona:CircuitSignerStampTitle"];
if (!string.IsNullOrWhiteSpace(signerTitle))
{
foreach (var template in templates)
{
var href = GetRequiredSelfHref(template);
var payload = await GetCircuitTemplatePayloadAsync(href);
if (payload.Contains(signerTitle, StringComparison.OrdinalIgnoreCase))
{
return (href, payload);
}
}
}
if (templates.Count == 1)
{
var href = GetRequiredSelfHref(templates[0]);
return (href, await GetCircuitTemplatePayloadAsync(href));
}
throw new InvalidOperationException(
"No se puede seleccionar de forma inequivoca la plantilla de circuito. Configura Gestiona:CircuitTemplateName o Gestiona:CircuitSignerStampTitle.");
}
private CircuitTemplateSelection GetOperationCircuitTemplateSelection(
bool isUpdate,
string? assignedGroupCode,
string? updateSource)
{
if (!isUpdate)
{
return new CircuitTemplateSelection(
_configuration["Gestiona:CircuitNewComplaintTemplateName"],
Required: true,
OperationLabel: "nueva denuncia");
}
if (ComplaintUpdateSources.IsReceiver(updateSource))
{
return NormalizeGroupCode(assignedGroupCode) switch
{
"510" => new CircuitTemplateSelection(
_configuration["Gestiona:CircuitCommunicationSdiTemplateName"],
Required: true,
OperationLabel: "comunicacion SDI a denunciante"),
"600" => new CircuitTemplateSelection(
_configuration["Gestiona:CircuitCommunicationSajTemplateName"],
Required: true,
OperationLabel: "comunicacion SAJ a denunciante"),
_ => throw new InvalidOperationException(
"Las comunicaciones de la OAAF solo pueden tramitarse con los grupos 510 o 600.")
};
}
var defaultUpdateTemplateName = FirstConfigured(
_configuration["Gestiona:CircuitTemplateName"],
_configuration["Gestiona:CircuitUpdateTemplateName"]);
return NormalizeGroupCode(assignedGroupCode) switch
{
"510" => new CircuitTemplateSelection(
FirstConfigured(
_configuration["Gestiona:CircuitUpdateSdiTemplateName"],
_configuration["Gestiona:CircuitUpdateTemplateNameSdi"]),
Required: true,
OperationLabel: "actualizacion de denuncia SDI"),
"600" => new CircuitTemplateSelection(
FirstConfigured(
_configuration["Gestiona:CircuitUpdateSajTemplateName"],
_configuration["Gestiona:CircuitUpdateTemplateNameSaj"]),
Required: true,
OperationLabel: "actualizacion de denuncia SAJ"),
_ => new CircuitTemplateSelection(
defaultUpdateTemplateName,
Required: !string.IsNullOrWhiteSpace(defaultUpdateTemplateName),
OperationLabel: "actualizacion de denuncia")
};
}
private static string? FirstConfigured(params string?[] values)
=> values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value));
private static string NormalizeGroupCode(string? assignedGroupCode)
{
if (string.IsNullOrWhiteSpace(assignedGroupCode))
{
return string.Empty;
}
var digits = new string(assignedGroupCode.Where(char.IsDigit).ToArray());
return string.IsNullOrWhiteSpace(digits) ? assignedGroupCode.Trim() : digits;
}
private sealed record CircuitTemplateSelection(string? TemplateName, bool Required, string OperationLabel);
private async Task<(string TemplateHref, string Payload)?> TryResolveCircuitTemplatePayloadByNameAsync(
List<JsonElement> templates,
string? templateName)
{
var configuredName = NormalizeKey(templateName);
if (string.IsNullOrWhiteSpace(configuredName))
{
return null;
}
foreach (var template in templates)
{
if (NormalizeKey(GetJsonString(template, "name")).Contains(configuredName, StringComparison.Ordinal) ||
NormalizeKey(GetJsonString(template, "title")).Contains(configuredName, StringComparison.Ordinal))
{
var href = GetRequiredSelfHref(template);
return (href, await GetCircuitTemplatePayloadAsync(href));
}
}
return null;
}
private async Task<List<JsonElement>> GetCircuitTemplatesAsync(string templatesUrl)
{
using var req = new HttpRequestMessage(HttpMethod.Get, templatesUrl);
req.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", GestionaAccessToken);
req.Headers.Accept.Add(
MediaTypeWithQualityHeaderValue.Parse("application/vnd.gestiona.circuits.templates-filedoc-page+json"));
using var resp = await CreateRawHttp().SendAsync(req);
LogDeprecatedHeaders(resp, "GET plantillas circuito Gestiona");
var body = await resp.Content.ReadAsStringAsync();
if (!resp.IsSuccessStatusCode)
{
throw new InvalidOperationException(
$"GetCircuitTemplatesAsync: {(int)resp.StatusCode} {resp.StatusCode}\n{body}");
}
using var doc = JsonDocument.Parse(body);
var source = doc.RootElement.TryGetProperty("content", out var content) &&
content.ValueKind == JsonValueKind.Array
? content
: doc.RootElement;
if (source.ValueKind != JsonValueKind.Array)
{
return [];
}
return source.EnumerateArray().Select(item => item.Clone()).ToList();
}
private async Task<string> GetCircuitTemplatePayloadAsync(string templateHref) private async Task<string> GetCircuitTemplatePayloadAsync(string templateHref)
{ {
using var req = new HttpRequestMessage(HttpMethod.Get, templateHref); using var req = new HttpRequestMessage(HttpMethod.Get, templateHref);
@@ -182,14 +390,6 @@ public sealed class GestionaDocumentWorkflowService
return (resp.IsSuccessStatusCode, resp.StatusCode, body); return (resp.IsSuccessStatusCode, resp.StatusCode, body);
} }
private string? GetConfiguredTemplateHref(string documentUrl)
{
var templateId = _configuration["Gestiona:CircuitTemplateId"];
return string.IsNullOrWhiteSpace(templateId)
? null
: $"{documentUrl.TrimEnd('/')}/circuit/templates/{templateId.Trim()}";
}
private HttpClient CreateRawHttp() => _httpClientFactory.CreateClient(); private HttpClient CreateRawHttp() => _httpClientFactory.CreateClient();
private async Task<string> CreateUploadAsync(byte[] contentBytes, string fileName) private async Task<string> CreateUploadAsync(byte[] contentBytes, string fileName)
@@ -279,6 +479,65 @@ public sealed class GestionaDocumentWorkflowService
return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant(); return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
} }
private static string GetRequiredSelfHref(JsonElement item)
{
return GetLinkHref(item, "self")
?? throw new InvalidOperationException("La plantilla de circuito no contiene link 'self'.");
}
private static string? GetLinkHref(JsonElement item, string rel)
{
if (!item.TryGetProperty("links", out var links) || links.ValueKind != JsonValueKind.Array)
{
return null;
}
foreach (var link in links.EnumerateArray())
{
if (link.TryGetProperty("rel", out var relProp) &&
string.Equals(relProp.GetString(), rel, StringComparison.OrdinalIgnoreCase) &&
link.TryGetProperty("href", out var hrefProp) &&
hrefProp.ValueKind == JsonValueKind.String)
{
return hrefProp.GetString();
}
}
return null;
}
private static string? GetJsonString(JsonElement item, string propertyName)
{
return item.TryGetProperty(propertyName, out var property) &&
property.ValueKind == JsonValueKind.String
? property.GetString()
: null;
}
private static string NormalizeKey(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return string.Empty;
}
var normalized = value.Normalize(NormalizationForm.FormD);
var builder = new StringBuilder(normalized.Length);
foreach (var character in normalized)
{
var category = CharUnicodeInfo.GetUnicodeCategory(character);
if (category == UnicodeCategory.NonSpacingMark)
{
continue;
}
builder.Append(char.IsLetterOrDigit(character) ? char.ToUpperInvariant(character) : ' ');
}
return string.Join(' ', builder.ToString().Split(' ', StringSplitOptions.RemoveEmptyEntries));
}
private void LogDeprecatedHeaders(HttpResponseMessage response, string operation) private void LogDeprecatedHeaders(HttpResponseMessage response, string operation)
{ {
if (response.Headers.TryGetValues("X-Gestiona-Deprecated", out var deprecated)) if (response.Headers.TryGetValues("X-Gestiona-Deprecated", out var deprecated))

View File

@@ -0,0 +1,135 @@
using System.Globalization;
using GestionaDenuncias.Shared.Models;
using MySqlConnector;
namespace ApiDenuncias.Services;
public sealed class GestionaExpedienteExceptionStore
{
private readonly MySqlConnectionStringProvider _connectionStringProvider;
public GestionaExpedienteExceptionStore(MySqlConnectionStringProvider connectionStringProvider)
{
_connectionStringProvider = connectionStringProvider;
}
public async Task EnsureSchemaAsync(CancellationToken cancellationToken = default)
{
await using var connection = await OpenConnectionAsync(cancellationToken);
await EnsureTableAsync(connection, cancellationToken);
}
public async Task<GestionaExpedienteException?> FindAsync(
DenunciasGestiona denuncia,
CancellationToken cancellationToken = default)
{
await using var connection = await OpenConnectionAsync(cancellationToken);
await EnsureTableAsync(connection, cancellationToken);
var references = BuildReferences(denuncia);
await using var command = connection.CreateCommand();
command.Parameters.AddWithValue("@externalReportId", denuncia.Id_Denuncia);
var referenceFilter = string.Empty;
if (references.Count > 0)
{
var parameterNames = new List<string>(references.Count);
for (var i = 0; i < references.Count; i++)
{
var parameterName = $"@reference{i}";
parameterNames.Add(parameterName);
command.Parameters.AddWithValue(parameterName, references[i]);
}
referenceFilter = $" OR external_reference IN ({string.Join(", ", parameterNames)})";
}
command.CommandText = $"""
SELECT external_report_id, external_reference, gestiona_file_code
FROM gestiona_file_exceptions
WHERE external_report_id = @externalReportId
{referenceFilter}
ORDER BY
CASE WHEN external_report_id = @externalReportId THEN 0 ELSE 1 END,
source_row DESC,
id DESC
LIMIT 1;
""";
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
if (!await reader.ReadAsync(cancellationToken))
{
return null;
}
return new GestionaExpedienteException(
reader.IsDBNull(reader.GetOrdinal("external_report_id"))
? null
: reader.GetInt32(reader.GetOrdinal("external_report_id")),
reader.IsDBNull(reader.GetOrdinal("external_reference"))
? null
: reader.GetString(reader.GetOrdinal("external_reference")),
reader.GetString(reader.GetOrdinal("gestiona_file_code")));
}
private static List<string> BuildReferences(DenunciasGestiona denuncia)
{
var references = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
if (denuncia.Id_RegistroDenuncia > 0)
{
references.Add(denuncia.Id_RegistroDenuncia.ToString(CultureInfo.InvariantCulture));
}
AddReference(references, denuncia.Etiqueta);
AddReference(references, denuncia.Asunto);
return references.ToList();
}
private static void AddReference(HashSet<string> references, string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return;
}
references.Add(value.Trim());
}
private async Task<MySqlConnection> OpenConnectionAsync(CancellationToken cancellationToken)
{
var connectionString = await _connectionStringProvider.GetConnectionStringAsync(cancellationToken);
var connection = new MySqlConnection(connectionString);
await connection.OpenAsync(cancellationToken);
return connection;
}
private static async Task EnsureTableAsync(MySqlConnection connection, CancellationToken cancellationToken)
{
await using var command = new MySqlCommand(
"""
CREATE TABLE IF NOT EXISTS gestiona_file_exceptions (
id INT AUTO_INCREMENT PRIMARY KEY,
external_report_id INT NULL,
external_reference VARCHAR(100) NOT NULL,
gestiona_file_code VARCHAR(50) NOT NULL,
notes VARCHAR(255) NULL,
source_row INT NULL,
created_at_utc DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
updated_at_utc DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
UNIQUE KEY uq_gestiona_file_exceptions_ref_code (external_reference, gestiona_file_code),
KEY ix_gestiona_file_exceptions_report (external_report_id),
KEY ix_gestiona_file_exceptions_reference (external_reference),
KEY ix_gestiona_file_exceptions_code (gestiona_file_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
""",
connection);
await command.ExecuteNonQueryAsync(cancellationToken);
}
}
public sealed record GestionaExpedienteException(
int? ExternalReportId,
string? ExternalReference,
string GestionaFileCode);

View File

@@ -12,6 +12,7 @@ using System.Text;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
namespace ApiDenuncias.Services namespace ApiDenuncias.Services
@@ -106,17 +107,12 @@ namespace ApiDenuncias.Services
// Expedientes (file) // Expedientes (file)
// ========================================================= // =========================================================
public async Task<GestionaCreateFileResponse> CreateFileAsync(Guid procedureId, string subject, string documentSeries, string siaCode) public async Task<GestionaCreateFileResponse> CreateFileAsync(string subject, string documentSeries, string siaCode)
{ {
_ = subject; _ = subject;
_ = documentSeries; _ = documentSeries;
_ = siaCode;
var effectiveProcedureId = procedureId == Guid.Empty var url = await ResolveExternalProcedureCreateFileUrlAsync(siaCode);
? Guid.Parse("82722c9b-cecc-4299-8a7b-ce5abeb8170b")
: procedureId;
var url = await ResolveExternalProcedureCreateFileUrlAsync(effectiveProcedureId);
using var req = new HttpRequestMessage(HttpMethod.Post, url); using var req = new HttpRequestMessage(HttpMethod.Post, url);
req.Headers.Accept.Clear(); req.Headers.Accept.Clear();
req.Headers.Accept.Add( req.Headers.Accept.Add(
@@ -138,24 +134,263 @@ namespace ApiDenuncias.Services
return new GestionaCreateFileResponse(fileUrl, fileOpenUrl); return new GestionaCreateFileResponse(fileUrl, fileOpenUrl);
} }
private Task<string> ResolveExternalProcedureCreateFileUrlAsync(Guid procedureId) private async Task<string> ResolveExternalProcedureCreateFileUrlAsync(string siaCode)
{ {
var externalProcedureId = Guid.TryParse(_opts.ExternalProcedureId, out var configuredExternalProcedureId) var proceduresUrl = await ResolveRootLinkHrefAsync("vnd.gestiona.catalog-2015")
? configuredExternalProcedureId ?? "/rest/catalog-2015/procedures";
: procedureId; var procedures = await GetContentArrayAsync(
proceduresUrl,
"application/vnd.gestiona.procedures-2015-page+json",
"GET catalogo de procedimientos Gestiona");
return Task.FromResult( var procedure = SelectProcedure(procedures, siaCode);
$"/rest/catalog-2015/procedures/{procedureId}/external-procedures/{externalProcedureId}/create-file"); var externalProceduresUrl = GetLinkHref(procedure, "external-procedures")
?? throw new InvalidOperationException(
"El procedimiento seleccionado no contiene link 'external-procedures'.");
var externalProcedures = await GetContentArrayAsync(
externalProceduresUrl,
"application/vnd.gestiona.external-procedures-2015-page+json",
"GET procedimientos externos Gestiona");
var externalProcedure = SelectExternalProcedure(externalProcedures, procedure, siaCode);
return GetLinkHref(externalProcedure, "create-file")
?? throw new InvalidOperationException(
"El procedimiento externo seleccionado no contiene link 'create-file'.");
}
private async Task<string?> ResolveRootLinkHrefAsync(string rel)
{
using var req = new HttpRequestMessage(HttpMethod.Get, "/rest");
AddBasicHeaders(req);
using var resp = await _http.SendAsync(req);
LogDeprecatedHeaders(resp, "GET /rest");
var body = await resp.Content.ReadAsStringAsync();
if (!resp.IsSuccessStatusCode)
{
throw new InvalidOperationException($"ResolveRootLinkHrefAsync: {(int)resp.StatusCode} {resp.StatusCode}\n{body}");
}
using var doc = JsonDocument.Parse(body);
return ToAbsoluteGestionaHref(GetLinkHref(doc.RootElement, rel));
}
private async Task<List<JsonElement>> GetContentArrayAsync(string url, string accept, string operation)
{
using var req = new HttpRequestMessage(HttpMethod.Get, url);
req.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", _opts.AccessToken);
req.Headers.Accept.Clear();
req.Headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse(accept));
using var resp = await _http.SendAsync(req);
LogDeprecatedHeaders(resp, operation);
var body = await resp.Content.ReadAsStringAsync();
if (!resp.IsSuccessStatusCode)
{
throw new InvalidOperationException($"{operation}: {(int)resp.StatusCode} {resp.StatusCode}\n{body}");
}
using var doc = JsonDocument.Parse(body);
var source = doc.RootElement.TryGetProperty("content", out var content) &&
content.ValueKind == JsonValueKind.Array
? content
: doc.RootElement;
if (source.ValueKind != JsonValueKind.Array)
{
return [];
}
return source.EnumerateArray().Select(item => item.Clone()).ToList();
}
private JsonElement SelectProcedure(IReadOnlyList<JsonElement> procedures, string siaCode)
{
if (procedures.Count == 0)
{
throw new InvalidOperationException("Gestiona no ha devuelto procedimientos en el catalogo.");
}
var configuredName = NormalizeKey(_opts.ProcedureName);
if (!string.IsNullOrWhiteSpace(configuredName))
{
var match = procedures.FirstOrDefault(item =>
NormalizeKey(GetJsonString(item, "name")).Contains(configuredName, StringComparison.Ordinal) ||
NormalizeKey(GetJsonString(item, "title")).Contains(configuredName, StringComparison.Ordinal) ||
NormalizeKey(GetJsonString(item, "description")).Contains(configuredName, StringComparison.Ordinal));
if (match.ValueKind != JsonValueKind.Undefined)
{
return match;
}
throw new InvalidOperationException(
$"No se ha encontrado en Gestiona el procedimiento configurado '{_opts.ProcedureName}'. Procedimientos disponibles: {DescribeCatalogItems(procedures)}.");
}
var normalizedSia = NormalizeKey(siaCode);
if (!string.IsNullOrWhiteSpace(normalizedSia))
{
var match = procedures.FirstOrDefault(item =>
NormalizeKey(GetJsonString(item, "sia_code")) == normalizedSia ||
NormalizeKey(GetJsonString(item, "code")) == normalizedSia);
if (match.ValueKind != JsonValueKind.Undefined)
{
return match;
}
}
if (procedures.Count == 1)
{
return procedures[0];
}
throw new InvalidOperationException(
"Falta configurar Gestiona:ProcedureName para seleccionar el procedimiento sin usar IDs fijos.");
}
private JsonElement SelectExternalProcedure(
IReadOnlyList<JsonElement> externalProcedures,
JsonElement selectedProcedure,
string siaCode)
{
var withCreateFile = externalProcedures
.Where(item => !string.IsNullOrWhiteSpace(GetLinkHref(item, "create-file")))
.ToList();
if (withCreateFile.Count == 0)
{
throw new InvalidOperationException("El procedimiento seleccionado no tiene procedimientos externos con link 'create-file'.");
}
var configuredSia = NormalizeKey(
string.IsNullOrWhiteSpace(_opts.ExternalProcedureSiaCode)
? siaCode
: _opts.ExternalProcedureSiaCode);
if (!string.IsNullOrWhiteSpace(configuredSia))
{
var match = withCreateFile.FirstOrDefault(item =>
NormalizeKey(GetJsonString(item, "sia_code")) == configuredSia ||
NormalizeKey(GetJsonString(item, "code")) == configuredSia);
if (match.ValueKind != JsonValueKind.Undefined)
{
return match;
}
}
var configuredName = NormalizeKey(_opts.ExternalProcedureName);
if (!string.IsNullOrWhiteSpace(configuredName))
{
var match = withCreateFile.FirstOrDefault(item =>
NormalizeKey(GetJsonString(item, "name")).Contains(configuredName, StringComparison.Ordinal) ||
NormalizeKey(GetJsonString(item, "title")).Contains(configuredName, StringComparison.Ordinal));
if (match.ValueKind != JsonValueKind.Undefined)
{
return match;
}
}
var procedureId = GetJsonString(selectedProcedure, "id");
if (!string.IsNullOrWhiteSpace(procedureId))
{
var sameId = withCreateFile.FirstOrDefault(item =>
string.Equals(GetJsonString(item, "id"), procedureId, StringComparison.OrdinalIgnoreCase));
if (sameId.ValueKind != JsonValueKind.Undefined)
{
return sameId;
}
}
if (withCreateFile.Count == 1)
{
return withCreateFile[0];
}
throw new InvalidOperationException(
$"No se puede seleccionar de forma inequivoca el procedimiento externo. Configura Gestiona:ExternalProcedureName o Gestiona:ExternalProcedureSiaCode. Disponibles: {DescribeCatalogItems(withCreateFile)}.");
}
private async Task<string> ResolveAssignableGroupHrefAsync(string groupCode)
{
if (string.IsNullOrWhiteSpace(groupCode))
{
throw new InvalidOperationException("Debe indicarse el codigo funcional del grupo de Gestiona.");
}
var groupsUrl = await ResolveRootLinkHrefAsync("vnd.gestiona.files.assignees.groups")
?? "/rest/files/assignees/groups";
var groups = await GetContentArrayAsync(
groupsUrl,
"application/vnd.gestiona.groups-page+json",
"GET grupos asignables Gestiona");
var normalizedCode = NormalizeKey(groupCode);
foreach (var group in groups)
{
var code = NormalizeKey(GetJsonString(group, "code"));
var name = NormalizeKey(GetJsonString(group, "name"));
var title = NormalizeKey(GetJsonString(group, "title"));
if (code == normalizedCode ||
name == normalizedCode ||
title == normalizedCode ||
name.StartsWith(normalizedCode + " ", StringComparison.Ordinal) ||
title.StartsWith(normalizedCode + " ", StringComparison.Ordinal))
{
var href = ToAbsoluteGestionaHref(GetLinkHref(group, "self"));
if (!string.IsNullOrWhiteSpace(href))
{
return href!;
}
}
}
throw new InvalidOperationException(
$"No se ha encontrado en Gestiona el grupo asignable '{groupCode}'. Grupos disponibles: {DescribeCatalogItems(groups)}.");
}
private string? ToAbsoluteGestionaHref(string? href)
{
if (string.IsNullOrWhiteSpace(href))
{
return null;
}
if (Uri.TryCreate(href, UriKind.Absolute, out _))
{
return href;
}
return href.StartsWith("/", StringComparison.Ordinal)
? $"{_opts.ApiBase.TrimEnd('/')}{href}"
: href;
}
private static string DescribeCatalogItems(IEnumerable<JsonElement> items)
{
var values = items
.Select(item => FirstNonEmpty(
GetJsonString(item, "name"),
GetJsonString(item, "title"),
GetJsonString(item, "code"),
GetJsonString(item, "sia_code"),
GetJsonString(item, "id")))
.Where(value => !string.IsNullOrWhiteSpace(value))
.Take(10)
.ToList();
return values.Count == 0 ? "(sin nombres disponibles)" : string.Join("; ", values);
} }
public async Task OpenFileAsync( public async Task OpenFileAsync(
string fileUrl, string fileUrl,
string? fileOpenUrl, string? fileOpenUrl,
Guid managementUnitGroupId, string assignedGroupCode,
Guid assignedGroupId,
bool confidential, bool confidential,
string freeTitle, string freeTitle)
string siaCode)
{ {
if (string.IsNullOrWhiteSpace(fileOpenUrl)) if (string.IsNullOrWhiteSpace(fileOpenUrl))
{ {
@@ -164,20 +399,24 @@ namespace ApiDenuncias.Services
} }
var url = fileOpenUrl; var url = fileOpenUrl;
var assignedGroupHref = await ResolveAssignableGroupHrefAsync(assignedGroupCode);
var managementGroupCode = string.IsNullOrWhiteSpace(_opts.ManagementUnitGroupCode)
? "700"
: _opts.ManagementUnitGroupCode.Trim();
var managementGroupHref = await ResolveAssignableGroupHrefAsync(managementGroupCode);
var payload = new var payload = new
{ {
free_title = freeTitle, free_title = freeTitle,
location = siaCode,
entry_date = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(), entry_date = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(),
confidential, confidential,
initial_assignation = new[] initial_assignation = new[]
{ {
new { rel = "group", href = $"{_opts.ApiBase}/rest/groups/{assignedGroupId}" } new { rel = "group", href = assignedGroupHref }
}, },
links = new[] links = new[]
{ {
new { rel = "management-unit-group", href = $"{_opts.ApiBase}/rest/groups/{managementUnitGroupId}" } new { rel = "management-unit-group", href = managementGroupHref }
} }
}; };
@@ -195,6 +434,67 @@ namespace ApiDenuncias.Services
throw new InvalidOperationException($"OpenFileAsync: {(int)resp.StatusCode} {resp.StatusCode}\n{body}"); throw new InvalidOperationException($"OpenFileAsync: {(int)resp.StatusCode} {resp.StatusCode}\n{body}");
} }
public async Task AssignFileAsync(string fileUrl, string assignedGroupCode)
{
if (string.IsNullOrWhiteSpace(fileUrl))
{
throw new InvalidOperationException("AssignFileAsync: falta la URL del expediente.");
}
if (string.IsNullOrWhiteSpace(assignedGroupCode))
{
throw new InvalidOperationException("AssignFileAsync: falta el grupo asignado.");
}
var assigneesUrl = await ResolveFileAssigneesUrlAsync(fileUrl);
var assignedGroupHref = await ResolveAssignableGroupHrefAsync(assignedGroupCode);
var payload = new
{
links = new[]
{
new { rel = "group", href = assignedGroupHref }
}
};
var json = JsonSerializer.Serialize(payload);
using var content = new StringContent(json, Encoding.UTF8, "application/vnd.gestiona.links");
using var req = new HttpRequestMessage(HttpMethod.Put, assigneesUrl)
{
Content = content
};
req.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", _opts.AccessToken);
req.Headers.Accept.Clear();
req.Headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("application/vnd.gestiona.links"));
using var resp = await _http.SendAsync(req);
LogDeprecatedHeaders(resp, "PUT file assignees");
var body = await resp.Content.ReadAsStringAsync();
if (!resp.IsSuccessStatusCode)
{
throw new InvalidOperationException($"AssignFileAsync: {(int)resp.StatusCode} {resp.StatusCode}\n{body}");
}
}
private async Task<string> ResolveFileAssigneesUrlAsync(string fileUrl)
{
var normalizedFileUrl = fileUrl.TrimEnd('/');
using var req = new HttpRequestMessage(HttpMethod.Get, normalizedFileUrl);
AddTokenAndAccept(req, "application/vnd.gestiona.file+json", "2");
using var resp = await _http.SendAsync(req);
LogDeprecatedHeaders(resp, $"GET {normalizedFileUrl}");
var body = await resp.Content.ReadAsStringAsync();
if (!resp.IsSuccessStatusCode)
{
throw new InvalidOperationException(
$"ResolveFileAssigneesUrlAsync: {(int)resp.StatusCode} {resp.StatusCode}\n{body}");
}
using var doc = JsonDocument.Parse(body);
return ToAbsoluteGestionaHref(GetLinkHref(doc.RootElement, "assignees"))
?? $"{normalizedFileUrl}/assignees";
}
public async Task<Guid> CreateFolderAsync(string fileUrl, string folderName) public async Task<Guid> CreateFolderAsync(string fileUrl, string folderName)
{ {
var endpoint = $"{fileUrl}/documents-and-folders"; var endpoint = $"{fileUrl}/documents-and-folders";
@@ -336,21 +636,32 @@ namespace ApiDenuncias.Services
{ {
var filtro = new var filtro = new
{ {
result = new { max_results = 25 }, nif
filter = new { nif }
}; };
var jsonFiltro = JsonSerializer.Serialize(filtro);
var b64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(jsonFiltro));
var url = $"{_opts.ApiBase}/rest/thirds?filter-view={Uri.EscapeDataString(b64)}";
using var req = new HttpRequestMessage(HttpMethod.Get, url); using var req = new HttpRequestMessage(HttpMethod.Get, "/rest/thirds");
req.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", _opts.AccessToken); req.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", _opts.AccessToken);
req.Headers.TryAddWithoutValidation("Accept", "application/vnd.gestiona.thirds-page+json"); req.Headers.TryAddWithoutValidation("Accept", "application/vnd.gestiona.thirds-page+json");
req.Content = new StringContent(
JsonSerializer.Serialize(filtro),
Encoding.UTF8,
"application/vnd.gestiona.filter.thirds+json");
using var resp = await _http.SendAsync(req); using var resp = await _http.SendAsync(req);
resp.EnsureSuccessStatusCode();
var body = await resp.Content.ReadAsStringAsync(); var body = await resp.Content.ReadAsStringAsync();
if (resp.StatusCode == System.Net.HttpStatusCode.NoContent ||
string.IsNullOrWhiteSpace(body))
{
return default;
}
if (!resp.IsSuccessStatusCode)
{
throw new InvalidOperationException(
$"Error BuscarTerceroPorNifAsync: {(int)resp.StatusCode} {resp.ReasonPhrase}\n{body}");
}
using var doc = JsonDocument.Parse(body); using var doc = JsonDocument.Parse(body);
if (!doc.RootElement.TryGetProperty("content", out var content) || content.ValueKind != JsonValueKind.Array) if (!doc.RootElement.TryGetProperty("content", out var content) || content.ValueKind != JsonValueKind.Array)
return default; return default;
@@ -468,8 +779,13 @@ namespace ApiDenuncias.Services
if (resp.StatusCode == System.Net.HttpStatusCode.NoContent) if (resp.StatusCode == System.Net.HttpStatusCode.NoContent)
return new HashSet<string>(StringComparer.Ordinal); return new HashSet<string>(StringComparer.Ordinal);
resp.EnsureSuccessStatusCode();
var body = await resp.Content.ReadAsStringAsync(); var body = await resp.Content.ReadAsStringAsync();
if (!resp.IsSuccessStatusCode)
throw new InvalidOperationException(
$"Error ObtenerTercerosEnlazadosAsync: {(int)resp.StatusCode} {resp.ReasonPhrase}\n{body}");
if (string.IsNullOrWhiteSpace(body))
return new HashSet<string>(StringComparer.Ordinal);
using var doc = JsonDocument.Parse(body); using var doc = JsonDocument.Parse(body);
var set = new HashSet<string>(StringComparer.Ordinal); var set = new HashSet<string>(StringComparer.Ordinal);
@@ -507,14 +823,18 @@ namespace ApiDenuncias.Services
throw new InvalidOperationException($"Error EnlazarTerceroExistenteAsync: {resp.StatusCode}\n{body}"); throw new InvalidOperationException($"Error EnlazarTerceroExistenteAsync: {resp.StatusCode}\n{body}");
} }
public async Task AsegurarTerceroYEnlazarAsync(string fileUrl, ThirdPartyIdentityData thirdParty) public async Task<GestionaEnsureThirdResponse> AsegurarTerceroYEnlazarAsync(string fileUrl, ThirdPartyIdentityData thirdParty)
{ {
if (thirdParty is null) if (thirdParty is null)
throw new ArgumentNullException(nameof(thirdParty)); throw new ArgumentNullException(nameof(thirdParty));
thirdParty = NormalizeThirdParty(thirdParty); thirdParty = NormalizeThirdParty(thirdParty);
var warnings = new List<string>();
if (string.IsNullOrWhiteSpace(thirdParty.DocumentId)) return; if (string.IsNullOrWhiteSpace(thirdParty.DocumentId))
{
return new GestionaEnsureThirdResponse(true, warnings);
}
var encontrado = await BuscarTerceroPorNifAsync(thirdParty.DocumentId); var encontrado = await BuscarTerceroPorNifAsync(thirdParty.DocumentId);
@@ -525,19 +845,175 @@ namespace ApiDenuncias.Services
_logger.LogWarning( _logger.LogWarning(
"Se omite la creacion/enlace del tercero en Gestiona para el expediente {FileUrl}: datos identificativos incompletos.", "Se omite la creacion/enlace del tercero en Gestiona para el expediente {FileUrl}: datos identificativos incompletos.",
fileUrl); fileUrl);
return; return new GestionaEnsureThirdResponse(true, warnings);
} }
encontrado = await CrearTerceroAsync(thirdParty); encontrado = await CrearTerceroAsync(thirdParty);
} }
else if (thirdParty.Address?.HasAnyValue == true) else
{ {
await TryEnsureThirdAddressAsync(encontrado.SelfHref, thirdParty.Address); warnings.AddRange(await BuildThirdPartyDifferenceWarningsAsync(encontrado.SelfHref, thirdParty));
if (thirdParty.Address?.HasAnyValue == true)
{
await TryEnsureThirdAddressAsync(encontrado.SelfHref, thirdParty.Address);
}
} }
var yaEnlazados = await ObtenerTercerosEnlazadosAsync(fileUrl); var yaEnlazados = await ObtenerTercerosEnlazadosAsync(fileUrl);
if (!yaEnlazados.Contains(encontrado.SelfHref)) if (!yaEnlazados.Contains(encontrado.SelfHref))
await EnlazarTerceroExistenteAsync(fileUrl, encontrado.SelfHref); await EnlazarTerceroExistenteAsync(fileUrl, encontrado.SelfHref);
return new GestionaEnsureThirdResponse(true, warnings);
}
private async Task<List<string>> BuildThirdPartyDifferenceWarningsAsync(
string thirdSelfHref,
ThirdPartyIdentityData thirdParty)
{
var warnings = new List<string>();
var details = await GetThirdPartyDetailsAsync(thirdSelfHref);
if (details is null)
{
return warnings;
}
var gestionaType = GetJsonString(details.Value, "type");
var expectedType = thirdParty.IsLegalEntity ? "JURIDICAL" : "PHISIC";
AddThirdDifferenceWarning(warnings, "tipo de tercero", expectedType, gestionaType, normalizeAsCode: true);
if (thirdParty.IsLegalEntity)
{
AddThirdDifferenceWarning(
warnings,
"razon social",
thirdParty.BusinessName,
GetJsonString(details.Value, "business_name"));
}
else
{
AddThirdDifferenceWarning(
warnings,
"nombre",
thirdParty.FirstName,
GetJsonString(details.Value, "first_name"));
var gestionaLastName = string.Join(
' ',
new[]
{
GetJsonString(details.Value, "first_surname"),
GetJsonString(details.Value, "second_surname")
}.Where(value => !string.IsNullOrWhiteSpace(value)));
AddThirdDifferenceWarning(warnings, "apellidos", thirdParty.LastName, gestionaLastName);
}
AddThirdDifferenceWarning(warnings, "email", thirdParty.Email, GetJsonString(details.Value, "email"));
AddThirdDifferenceWarning(
warnings,
"pais del documento",
NormalizeCountryCode(thirdParty.CountryCode),
GetJsonString(details.Value, "nif_country"),
normalizeAsCode: true);
AddThirdDifferenceWarning(
warnings,
"canal de notificacion",
BuildNotificationChannel(thirdParty),
GetJsonString(details.Value, "notification_channel"),
normalizeAsCode: true);
if (warnings.Count > 0)
{
warnings.Insert(
0,
"El tercero ya existia en Gestiona y no se han sustituido sus datos. Si alguno debe cambiarse, actualizalo manualmente en Gestiona.");
}
return warnings;
}
private async Task<JsonElement?> GetThirdPartyDetailsAsync(string thirdSelfHref)
{
if (string.IsNullOrWhiteSpace(thirdSelfHref))
{
return null;
}
using var req = new HttpRequestMessage(HttpMethod.Get, thirdSelfHref);
req.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", _opts.AccessToken);
req.Headers.Accept.Clear();
req.Headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("application/vnd.gestiona.third+json; version=3"));
using var resp = await _http.SendAsync(req);
LogDeprecatedHeaders(resp, "GET third");
if (!resp.IsSuccessStatusCode)
{
return null;
}
var body = await resp.Content.ReadAsStringAsync();
if (string.IsNullOrWhiteSpace(body))
{
return null;
}
using var doc = JsonDocument.Parse(body);
return doc.RootElement.Clone();
}
private static void AddThirdDifferenceWarning(
List<string> warnings,
string fieldName,
string? complaintValue,
string? gestionaValue,
bool normalizeAsCode = false)
{
if (string.IsNullOrWhiteSpace(complaintValue))
{
return;
}
var left = normalizeAsCode
? NormalizeKey(complaintValue)
: NormalizeForComparison(complaintValue);
var right = normalizeAsCode
? NormalizeKey(gestionaValue)
: NormalizeForComparison(gestionaValue);
if (string.Equals(left, right, StringComparison.Ordinal))
{
return;
}
warnings.Add(
$"El campo {fieldName} no coincide con Gestiona (denuncia: '{FormatWarningValue(complaintValue)}'; Gestiona: '{FormatWarningValue(gestionaValue)}').");
}
private static string NormalizeForComparison(string? value)
{
return string.Join(
' ',
(value ?? string.Empty)
.Trim()
.Split(' ', StringSplitOptions.RemoveEmptyEntries))
.ToUpperInvariant();
}
private static string FormatWarningValue(string? value)
{
var normalized = string.Join(
' ',
(value ?? string.Empty)
.Trim()
.Split(' ', StringSplitOptions.RemoveEmptyEntries));
if (string.IsNullOrWhiteSpace(normalized))
{
return "sin dato";
}
const int maxLength = 80;
return normalized.Length <= maxLength
? normalized
: normalized[..maxLength] + "...";
} }
private static ThirdPartyIdentityData NormalizeThirdParty(ThirdPartyIdentityData thirdParty) private static ThirdPartyIdentityData NormalizeThirdParty(ThirdPartyIdentityData thirdParty)
@@ -563,6 +1039,9 @@ namespace ApiDenuncias.Services
BusinessName = businessName, BusinessName = businessName,
Email = (thirdParty.Email ?? string.Empty).Trim(), Email = (thirdParty.Email ?? string.Empty).Trim(),
CountryCode = string.IsNullOrWhiteSpace(thirdParty.CountryCode) ? "ESP" : thirdParty.CountryCode.Trim(), CountryCode = string.IsNullOrWhiteSpace(thirdParty.CountryCode) ? "ESP" : thirdParty.CountryCode.Trim(),
NotificationPreference = thirdParty.NotificationPreference ?? string.Empty,
ElectronicNotification = thirdParty.ElectronicNotification ?? string.Empty,
PostalNotificationPreference = thirdParty.PostalNotificationPreference ?? string.Empty,
Address = thirdParty.Address Address = thirdParty.Address
}; };
} }
@@ -577,6 +1056,9 @@ namespace ApiDenuncias.Services
BusinessName = string.Empty, BusinessName = string.Empty,
Email = string.Empty, Email = string.Empty,
CountryCode = string.IsNullOrWhiteSpace(thirdParty.CountryCode) ? "ESP" : thirdParty.CountryCode, CountryCode = string.IsNullOrWhiteSpace(thirdParty.CountryCode) ? "ESP" : thirdParty.CountryCode,
NotificationPreference = thirdParty.NotificationPreference ?? string.Empty,
ElectronicNotification = thirdParty.ElectronicNotification ?? string.Empty,
PostalNotificationPreference = thirdParty.PostalNotificationPreference ?? string.Empty,
Address = null Address = null
}; };
} }
@@ -718,6 +1200,51 @@ namespace ApiDenuncias.Services
return null; return null;
} }
public async Task<GestionaExpedienteInfo?> BuscarExpedientePorCodigoAsync(string codigoExpediente)
{
if (string.IsNullOrWhiteSpace(codigoExpediente))
{
return null;
}
var normalizedCode = codigoExpediente.Trim();
var json = await GetFilesAsync(new
{
code = normalizedCode
});
using var doc = JsonDocument.Parse(json);
if (!TryGetFilesContent(doc, out var content) || content.GetArrayLength() == 0)
{
return null;
}
GestionaExpedienteInfo? first = null;
foreach (var item in content.EnumerateArray())
{
var expediente = BuildExpedienteInfo(item);
if (expediente is null)
{
continue;
}
if (first is null)
{
first = expediente;
}
if (string.Equals(
expediente.CodigoExpediente?.Trim(),
normalizedCode,
StringComparison.OrdinalIgnoreCase))
{
return expediente;
}
}
return first;
}
public async Task<GestionaExpedienteInfo?> ObtenerExpedienteAsync(string fileUrl) public async Task<GestionaExpedienteInfo?> ObtenerExpedienteAsync(string fileUrl)
{ {
if (string.IsNullOrWhiteSpace(fileUrl)) if (string.IsNullOrWhiteSpace(fileUrl))
@@ -797,13 +1324,78 @@ namespace ApiDenuncias.Services
} }
public async Task<GestionaAuditInfo?> ObtenerUltimaAuditoriaExpedienteAsync(string fileUrl)
{
if (string.IsNullOrWhiteSpace(fileUrl))
{
return null;
}
var auditUrl = $"{fileUrl.TrimEnd('/')}/audit";
using var req = new HttpRequestMessage(HttpMethod.Get, auditUrl);
req.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", _opts.AccessToken);
req.Headers.Accept.Clear();
req.Headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("application/vnd.gestiona.audit.entries-page"));
using var resp = await _http.SendAsync(req);
LogDeprecatedHeaders(resp, $"GET {auditUrl}");
if (resp.StatusCode == System.Net.HttpStatusCode.NoContent ||
resp.StatusCode == System.Net.HttpStatusCode.NotFound)
{
return null;
}
var body = await resp.Content.ReadAsStringAsync();
if (!resp.IsSuccessStatusCode)
{
throw new InvalidOperationException(
$"ObtenerUltimaAuditoriaExpedienteAsync: {(int)resp.StatusCode} {resp.ReasonPhrase}\n{body}");
}
if (string.IsNullOrWhiteSpace(body))
{
return null;
}
using var doc = JsonDocument.Parse(body);
if (!doc.RootElement.TryGetProperty("content", out var content) ||
content.ValueKind != JsonValueKind.Array ||
content.GetArrayLength() == 0)
{
return null;
}
GestionaAuditInfo? latest = null;
foreach (var item in content.EnumerateArray())
{
var entryDate = GetJsonDateTimeOffset(item, "entry_date");
if (entryDate is null)
{
continue;
}
if (latest?.Fecha is not null && entryDate.Value <= latest.Fecha.Value)
{
continue;
}
latest = new GestionaAuditInfo
{
Fecha = entryDate,
Mensaje = GetJsonString(item, "message")
};
}
return latest;
}
public async Task<List<ExpedienteTerceroDto>> ObtenerExpedientesPorTerceroAsync( public async Task<List<ExpedienteTerceroDto>> ObtenerExpedientesPorTerceroAsync(
string nif, string nif,
DateTimeOffset? desde = null, DateTimeOffset? desde = null,
DateTimeOffset? hasta = null, DateTimeOffset? hasta = null,
int maxPages = 1, int maxPages = 1,
int maxResults = 30, int maxResults = 30,
int maxParallel = 6 // de momento NO se usa, dejamos la firma por compatibilidad int maxParallel = 6
) )
{ {
if (string.IsNullOrWhiteSpace(nif)) if (string.IsNullOrWhiteSpace(nif))
@@ -811,21 +1403,11 @@ namespace ApiDenuncias.Services
nif = nif.Trim().ToUpperInvariant(); nif = nif.Trim().ToUpperInvariant();
_ = maxPages; _ = maxPages;
_ = maxParallel;
// 1) Localizar el tercero por NIF
var tercero = await BuscarTerceroPorNifAsync(nif);
if (string.IsNullOrEmpty(tercero.SelfHref))
return new List<ExpedienteTerceroDto>(); // no hay tercero => no hay expedientes
var resultados = new List<ExpedienteTerceroDto>(); var resultados = new List<ExpedienteTerceroDto>();
var json = await GetFilesAsync(new var json = await GetFilesAsync(new
{ {
third_rest_link = new dni = nif
{
rel = "third",
href = tercero.SelfHref
}
}); });
using var doc = JsonDocument.Parse(json); using var doc = JsonDocument.Parse(json);
@@ -841,20 +1423,15 @@ namespace ApiDenuncias.Services
break; break;
} }
DateTimeOffset? creation = null; var creation = GetJsonDateTimeOffset(item, "creation_date");
if (item.TryGetProperty("creation_date", out var pCreation)) var updated = GetFirstJsonDateTimeOffset(
{ item,
if (pCreation.ValueKind == JsonValueKind.Number && "update_date",
pCreation.TryGetInt64(out var ts)) "updated_at",
{ "modification_date",
creation = DateTimeOffset.FromUnixTimeSeconds(ts); "modified_at",
} "last_update",
else if (pCreation.ValueKind == JsonValueKind.String && "last_modified");
long.TryParse(pCreation.GetString(), out var tsString))
{
creation = DateTimeOffset.FromUnixTimeSeconds(tsString);
}
}
if (desde.HasValue && creation.HasValue && creation.Value < desde.Value) if (desde.HasValue && creation.HasValue && creation.Value < desde.Value)
continue; continue;
@@ -905,14 +1482,67 @@ namespace ApiDenuncias.Services
FileUrl = fileUrl, FileUrl = fileUrl,
CodigoExpediente = code, CodigoExpediente = code,
Asunto = asunto, Asunto = asunto,
Procedimiento = procedureName,
FechaCreacion = creation, FechaCreacion = creation,
FechaUltimaModificacion = updated ?? creation,
Estado = state Estado = state
}); });
} }
await EnrichExpedientesWithAuditAsync(resultados, maxParallel);
return resultados; return resultados;
} }
private async Task EnrichExpedientesWithAuditAsync(
IReadOnlyList<ExpedienteTerceroDto> expedientes,
int maxParallel)
{
if (expedientes.Count == 0)
{
return;
}
var parallelism = Math.Clamp(maxParallel, 1, 6);
using var gate = new SemaphoreSlim(parallelism, parallelism);
var tasks = expedientes
.Where(expediente => !string.IsNullOrWhiteSpace(expediente.FileUrl))
.Select(async expediente =>
{
await gate.WaitAsync();
try
{
var audit = await ObtenerUltimaAuditoriaExpedienteAsync(expediente.FileUrl);
if (audit is null)
{
return;
}
if (audit.Fecha is not null)
{
expediente.FechaUltimaModificacion = audit.Fecha;
}
if (!string.IsNullOrWhiteSpace(audit.Mensaje))
{
expediente.UltimaAuditoriaMensaje = audit.Mensaje;
}
}
catch (Exception ex)
{
_logger.LogWarning(
ex,
"No se ha podido consultar la auditoria del expediente {FileUrl}.",
expediente.FileUrl);
}
finally
{
gate.Release();
}
});
await Task.WhenAll(tasks);
}
private async Task TryEnsureThirdAddressAsync(string thirdSelfHref, ThirdPartyAddressData address) private async Task TryEnsureThirdAddressAsync(string thirdSelfHref, ThirdPartyAddressData address)
{ {
if (!address.HasAnyValue) if (!address.HasAnyValue)
@@ -1138,6 +1768,58 @@ namespace ApiDenuncias.Services
: null; : null;
} }
private static DateTimeOffset? GetFirstJsonDateTimeOffset(JsonElement item, params string[] propertyNames)
{
foreach (var propertyName in propertyNames)
{
var value = GetJsonDateTimeOffset(item, propertyName);
if (value is not null)
{
return value;
}
}
return null;
}
private static DateTimeOffset? GetJsonDateTimeOffset(JsonElement item, string propertyName)
{
if (!item.TryGetProperty(propertyName, out var property))
{
return null;
}
if (property.ValueKind == JsonValueKind.Number &&
property.TryGetInt64(out var timestamp))
{
return DateTimeOffset.FromUnixTimeSeconds(timestamp);
}
if (property.ValueKind != JsonValueKind.String)
{
return null;
}
var raw = property.GetString();
if (string.IsNullOrWhiteSpace(raw))
{
return null;
}
if (long.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var timestampString))
{
return DateTimeOffset.FromUnixTimeSeconds(timestampString);
}
return DateTimeOffset.TryParse(
raw.Replace("Z", "+00:00", StringComparison.Ordinal),
CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
out var parsed)
? parsed
: null;
}
private static string? FirstNonEmpty(params string?[] values) private static string? FirstNonEmpty(params string?[] values)
{ {
foreach (var value in values) foreach (var value in values)
@@ -1171,7 +1853,25 @@ namespace ApiDenuncias.Services
private static string BuildNotificationChannel(ThirdPartyIdentityData thirdParty) private static string BuildNotificationChannel(ThirdPartyIdentityData thirdParty)
{ {
_ = thirdParty; var notificationText = NormalizeKey(string.Join(
' ',
thirdParty.NotificationPreference,
thirdParty.ElectronicNotification,
thirdParty.PostalNotificationPreference));
var requestsPostal =
notificationText.Contains("CORREO POSTAL", StringComparison.Ordinal) ||
notificationText.Contains("POSTAL", StringComparison.Ordinal);
var requestsElectronic =
notificationText.Contains("ELECTRONICA", StringComparison.Ordinal) ||
notificationText.Contains("TELEMATICA", StringComparison.Ordinal) ||
notificationText.Contains("ONLINE", StringComparison.Ordinal);
if (requestsPostal && thirdParty.Address?.HasAnyValue == true && !requestsElectronic)
{
return "PAPER";
}
return "TELEMATIC"; return "TELEMATIC";
} }

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,83 @@
using System.Collections.Concurrent;
using GestionaDenuncias.Shared.Models;
namespace ApiDenuncias.Services;
public sealed class GlobalLeaksSessionKeepAliveService
{
private static readonly TimeSpan MinimumRefreshInterval = TimeSpan.FromSeconds(20);
private static readonly ConcurrentDictionary<string, SemaphoreSlim> UserGates =
new(StringComparer.OrdinalIgnoreCase);
private readonly GlobalLeaksSessionStore _sessionStore;
private readonly GlobalLeaksClient _globalLeaksClient;
private readonly ILogger<GlobalLeaksSessionKeepAliveService> _logger;
public GlobalLeaksSessionKeepAliveService(
GlobalLeaksSessionStore sessionStore,
GlobalLeaksClient globalLeaksClient,
ILogger<GlobalLeaksSessionKeepAliveService> logger)
{
_sessionStore = sessionStore;
_globalLeaksClient = globalLeaksClient;
_logger = logger;
}
public async Task<GlobalLeaksStoredSession?> KeepAliveAsync(
string username,
CancellationToken cancellationToken = default)
{
var gate = UserGates.GetOrAdd(username, static _ => new SemaphoreSlim(1, 1));
await gate.WaitAsync(cancellationToken);
try
{
var session = await _sessionStore.GetAsync(username, cancellationToken);
if (session?.HasActiveSession != true)
{
return session;
}
if (session.LastKeepAliveAtUtc is { } lastKeepAlive &&
DateTimeOffset.UtcNow - lastKeepAlive < MinimumRefreshInterval)
{
return session;
}
var expectedSessionId = session.SessionId!;
try
{
var refreshed = await _globalLeaksClient.RefreshSessionAsync(
expectedSessionId,
session.DpopPrivateKey!,
session.ProofOfWorkToken!,
cancellationToken);
var updated = await _sessionStore.UpdateKeepAliveAsync(
username,
expectedSessionId,
refreshed.ProofOfWorkToken,
refreshed.SessionExpiresAtUtc,
cancellationToken);
if (updated)
{
_logger.LogDebug("Sesion GlobalLeaks renovada para {Username}.", username);
}
return await _sessionStore.GetAsync(username, cancellationToken);
}
catch (GlobalLeaksSessionExpiredException)
{
await _sessionStore.ClearSessionIfMatchesAsync(
username,
expectedSessionId,
cancellationToken);
throw;
}
}
finally
{
gate.Release();
}
}
}

View File

@@ -8,7 +8,7 @@ namespace ApiDenuncias.Services;
public sealed class GlobalLeaksSessionStore public sealed class GlobalLeaksSessionStore
{ {
private const string RootPath = @"C:\ZipsDenuncias\.gl-auth"; private const string RootPath = @"C:\GestionaDenuncias\.gl-auth";
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{ {
WriteIndented = false, WriteIndented = false,
@@ -30,18 +30,10 @@ public sealed class GlobalLeaksSessionStore
} }
var path = GetFilePath(username); var path = GetFilePath(username);
if (!File.Exists(path))
{
return null;
}
await _gate.WaitAsync(cancellationToken); await _gate.WaitAsync(cancellationToken);
try try
{ {
var protectedBytes = await File.ReadAllBytesAsync(path, cancellationToken); return await ReadUnsafeAsync(path, cancellationToken);
var protectedBase64 = Encoding.UTF8.GetString(protectedBytes);
var json = _protector.Unprotect(protectedBase64);
return JsonSerializer.Deserialize<GlobalLeaksStoredSession>(json, JsonOptions);
} }
finally finally
{ {
@@ -54,15 +46,23 @@ public sealed class GlobalLeaksSessionStore
string password, string password,
string sessionId, string sessionId,
string? role, string? role,
string? dpopPrivateKey,
GlobalLeaksProofOfWorkToken? proofOfWorkToken,
DateTimeOffset? sessionExpiresAtUtc,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
var now = DateTimeOffset.UtcNow;
var data = new GlobalLeaksStoredSession var data = new GlobalLeaksStoredSession
{ {
Username = username, Username = username,
Password = password, Password = password,
SessionId = sessionId, SessionId = sessionId,
Role = role, Role = role,
UpdatedAt = DateTimeOffset.UtcNow, DpopPrivateKey = dpopPrivateKey,
ProofOfWorkToken = proofOfWorkToken,
SessionExpiresAtUtc = sessionExpiresAtUtc,
LastKeepAliveAtUtc = now,
UpdatedAt = now,
}; };
await WriteAsync(data, cancellationToken); await WriteAsync(data, cancellationToken);
@@ -72,29 +72,116 @@ public sealed class GlobalLeaksSessionStore
string username, string username,
string sessionId, string sessionId,
string? role, string? role,
string? dpopPrivateKey,
GlobalLeaksProofOfWorkToken? proofOfWorkToken,
DateTimeOffset? sessionExpiresAtUtc,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
var current = await GetAsync(username, cancellationToken) var path = GetFilePath(username);
?? throw new InvalidOperationException("No hay credenciales guardadas para este usuario.");
current.SessionId = sessionId; await _gate.WaitAsync(cancellationToken);
current.Role = role; try
current.UpdatedAt = DateTimeOffset.UtcNow; {
var current = await ReadUnsafeAsync(path, cancellationToken)
?? throw new InvalidOperationException("No hay credenciales guardadas para este usuario.");
var now = DateTimeOffset.UtcNow;
await WriteAsync(current, cancellationToken); current.SessionId = sessionId;
current.Role = role;
current.DpopPrivateKey = dpopPrivateKey;
current.ProofOfWorkToken = proofOfWorkToken;
current.SessionExpiresAtUtc = sessionExpiresAtUtc;
current.LastKeepAliveAtUtc = now;
current.UpdatedAt = now;
await WriteUnsafeAsync(path, current, cancellationToken);
}
finally
{
_gate.Release();
}
}
public async Task<bool> UpdateKeepAliveAsync(
string username,
string expectedSessionId,
GlobalLeaksProofOfWorkToken proofOfWorkToken,
DateTimeOffset? sessionExpiresAtUtc,
CancellationToken cancellationToken = default)
{
var path = GetFilePath(username);
await _gate.WaitAsync(cancellationToken);
try
{
var current = await ReadUnsafeAsync(path, cancellationToken);
if (current is null ||
!string.Equals(current.SessionId, expectedSessionId, StringComparison.Ordinal))
{
return false;
}
var now = DateTimeOffset.UtcNow;
current.ProofOfWorkToken = proofOfWorkToken;
current.SessionExpiresAtUtc = sessionExpiresAtUtc;
current.LastKeepAliveAtUtc = now;
current.UpdatedAt = now;
await WriteUnsafeAsync(path, current, cancellationToken);
return true;
}
finally
{
_gate.Release();
}
} }
public async Task ClearSessionAsync(string username, CancellationToken cancellationToken = default) public async Task ClearSessionAsync(string username, CancellationToken cancellationToken = default)
{ {
var current = await GetAsync(username, cancellationToken); var path = GetFilePath(username);
if (current is null)
{
return;
}
current.SessionId = null; await _gate.WaitAsync(cancellationToken);
current.UpdatedAt = DateTimeOffset.UtcNow; try
await WriteAsync(current, cancellationToken); {
var current = await ReadUnsafeAsync(path, cancellationToken);
if (current is null)
{
return;
}
ClearSessionValues(current);
await WriteUnsafeAsync(path, current, cancellationToken);
}
finally
{
_gate.Release();
}
}
public async Task<bool> ClearSessionIfMatchesAsync(
string username,
string expectedSessionId,
CancellationToken cancellationToken = default)
{
var path = GetFilePath(username);
await _gate.WaitAsync(cancellationToken);
try
{
var current = await ReadUnsafeAsync(path, cancellationToken);
if (current is null ||
!string.Equals(current.SessionId, expectedSessionId, StringComparison.Ordinal))
{
return false;
}
ClearSessionValues(current);
await WriteUnsafeAsync(path, current, cancellationToken);
return true;
}
finally
{
_gate.Release();
}
} }
public async Task DeleteAsync(string username, CancellationToken cancellationToken = default) public async Task DeleteAsync(string username, CancellationToken cancellationToken = default)
@@ -122,17 +209,12 @@ public sealed class GlobalLeaksSessionStore
private async Task WriteAsync(GlobalLeaksStoredSession data, CancellationToken cancellationToken) private async Task WriteAsync(GlobalLeaksStoredSession data, CancellationToken cancellationToken)
{ {
Directory.CreateDirectory(RootPath);
var path = GetFilePath(data.Username); var path = GetFilePath(data.Username);
var json = JsonSerializer.Serialize(data, JsonOptions);
var protectedValue = _protector.Protect(json);
var protectedBytes = Encoding.UTF8.GetBytes(protectedValue);
await _gate.WaitAsync(cancellationToken); await _gate.WaitAsync(cancellationToken);
try try
{ {
await File.WriteAllBytesAsync(path, protectedBytes, cancellationToken); await WriteUnsafeAsync(path, data, cancellationToken);
} }
finally finally
{ {
@@ -147,4 +229,41 @@ public sealed class GlobalLeaksSessionStore
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(normalized)); var hash = SHA256.HashData(Encoding.UTF8.GetBytes(normalized));
return Path.Combine(RootPath, $"{Convert.ToHexString(hash).ToLowerInvariant()}.bin"); return Path.Combine(RootPath, $"{Convert.ToHexString(hash).ToLowerInvariant()}.bin");
} }
private async Task<GlobalLeaksStoredSession?> ReadUnsafeAsync(
string path,
CancellationToken cancellationToken)
{
if (!File.Exists(path))
{
return null;
}
var protectedBytes = await File.ReadAllBytesAsync(path, cancellationToken);
var protectedBase64 = Encoding.UTF8.GetString(protectedBytes);
var json = _protector.Unprotect(protectedBase64);
return JsonSerializer.Deserialize<GlobalLeaksStoredSession>(json, JsonOptions);
}
private async Task WriteUnsafeAsync(
string path,
GlobalLeaksStoredSession data,
CancellationToken cancellationToken)
{
Directory.CreateDirectory(RootPath);
var json = JsonSerializer.Serialize(data, JsonOptions);
var protectedValue = _protector.Protect(json);
var protectedBytes = Encoding.UTF8.GetBytes(protectedValue);
await File.WriteAllBytesAsync(path, protectedBytes, cancellationToken);
}
private static void ClearSessionValues(GlobalLeaksStoredSession session)
{
session.SessionId = null;
session.DpopPrivateKey = null;
session.ProofOfWorkToken = null;
session.SessionExpiresAtUtc = null;
session.LastKeepAliveAtUtc = null;
session.UpdatedAt = DateTimeOffset.UtcNow;
}
} }

View File

@@ -4,6 +4,10 @@ namespace ApiDenuncias.Services;
public interface IFilteredDenunciaStore public interface IFilteredDenunciaStore
{ {
Task<DenunciasGestiona?> GetDenunciaByGestionaFileCodeAsync(
string gestionaFileCode,
CancellationToken cancellationToken = default);
Task<List<DenunciasGestiona>> GetDenunciasByIdsAsync( Task<List<DenunciasGestiona>> GetDenunciasByIdsAsync(
IReadOnlyCollection<int> denunciaIds, IReadOnlyCollection<int> denunciaIds,
CancellationToken cancellationToken = default); CancellationToken cancellationToken = default);
@@ -16,4 +20,8 @@ public interface IFilteredDenunciaStore
Task<List<FicherosDenuncias>> GetFicherosByDenunciaIdsAsync( Task<List<FicherosDenuncias>> GetFicherosByDenunciaIdsAsync(
IReadOnlyCollection<int> denunciaIds, IReadOnlyCollection<int> denunciaIds,
CancellationToken cancellationToken = default); CancellationToken cancellationToken = default);
Task<List<FicherosDenuncias>> GetFicherosMetadataByDenunciaAsync(
int denunciaId,
CancellationToken cancellationToken = default);
} }

View File

@@ -1,4 +1,4 @@
using GestionaDenuncias.Shared.Models; using GestionaDenuncias.Shared.Models;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading.Tasks; using System.Threading.Tasks;
@@ -12,26 +12,31 @@ namespace ApiDenuncias.Services
// ========================= // =========================
/// <summary> /// <summary>
/// Crea un expediente para el procedimiento indicado y devuelve los links 'file' y 'file-open'. /// Crea un expediente resolviendo el procedimiento externo desde el catalogo de Gestiona y devuelve los links 'file' y 'file-open'.
/// </summary> /// </summary>
Task<GestionaCreateFileResponse> CreateFileAsync( Task<GestionaCreateFileResponse> CreateFileAsync(
Guid procedureId,
string subject, string subject,
string documentSeries, string documentSeries,
string siaCode string siaCode
); );
/// <summary> /// <summary>
/// Abre el expediente (lo pone en OPEN_EDITABLE), asigna t<EFBFBD>tulo, clasificaci<EFBFBD>n y lo vincula al grupo indicado. /// Abre el expediente (lo pone en OPEN_EDITABLE), asigna título, clasificación y lo vincula al grupo indicado.
/// </summary> /// </summary>
Task OpenFileAsync( Task OpenFileAsync(
string fileUrl, string fileUrl,
string? fileOpenUrl, string? fileOpenUrl,
Guid managementUnitGroupId, string assignedGroupCode,
Guid assignedGroupId,
bool confidential, bool confidential,
string freeTitle, string freeTitle
string siaCode );
/// <summary>
/// Reemplaza los grupos/usuarios asignados al expediente. No modifica la unidad gestora.
/// </summary>
Task AssignFileAsync(
string fileUrl,
string assignedGroupCode
); );
// ========================= // =========================
@@ -47,7 +52,7 @@ namespace ApiDenuncias.Services
); );
/// <summary> /// <summary>
/// Crea el documento (metadata) y sube el contenido PDF a la ra<EFBFBD>z o a una carpeta. /// Crea el documento (metadata) y sube el contenido PDF a la raíz o a una carpeta.
/// </summary> /// </summary>
Task UploadDocumentAsync( Task UploadDocumentAsync(
string fileUrl, string fileUrl,
@@ -56,7 +61,7 @@ namespace ApiDenuncias.Services
); );
/// <summary> /// <summary>
/// Crea una carpeta de nombre 'folderName' en la ra<EFBFBD>z del expediente y devuelve su GUID. /// Crea una carpeta de nombre 'folderName' en la raíz del expediente y devuelve su GUID.
/// </summary> /// </summary>
Task<Guid> CreateFolderAsync( Task<Guid> CreateFolderAsync(
string fileUrl, string fileUrl,
@@ -89,11 +94,11 @@ namespace ApiDenuncias.Services
/// <summary> /// <summary>
/// Usa el NIF tal cual viene. /// Usa el NIF tal cual viene.
/// Si es an<EFBFBD>nimo o vac<EFBFBD>o ? no crea ni enlaza. /// Si es anónimo o vacío ? no crea ni enlaza.
/// Si no existe, lo crea. /// Si no existe, lo crea.
/// Si no est<EFBFBD> enlazado al expediente, lo enlaza. /// Si no está enlazado al expediente, lo enlaza.
/// </summary> /// </summary>
Task AsegurarTerceroYEnlazarAsync(string fileUrl, ThirdPartyIdentityData thirdParty); Task<GestionaEnsureThirdResponse> AsegurarTerceroYEnlazarAsync(string fileUrl, ThirdPartyIdentityData thirdParty);
@@ -102,21 +107,31 @@ namespace ApiDenuncias.Services
// ========================= // =========================
/// <summary> /// <summary>
/// Devuelve el JSON crudo del listado operativo de expedientes, sin recorrer hist<EFBFBD>rico paginado. /// Devuelve el JSON crudo del listado operativo de expedientes, sin recorrer histórico paginado.
/// </summary> /// </summary>
Task<string> ListarExpedientesJsonAsyncBasico(int maxPages = 1); Task<string> ListarExpedientesJsonAsyncBasico(int maxPages = 1);
/// <summary> /// <summary>
/// Busca directamente un expediente cuyo asunto sea "Denuncia {idDenuncia}-CD". /// Busca directamente un expediente cuyo asunto sea "Denuncia {idDenuncia}-CD".
/// Devuelve URL, n<EFBFBD>mero de expediente y t<EFBFBD>tulo si lo encuentra; null si no. /// Devuelve URL, número de expediente y título si lo encuentra; null si no.
/// </summary> /// </summary>
Task<GestionaExpedienteInfo?> BuscarExpedientePorIdEnAsuntoAsync(int idDenuncia); Task<GestionaExpedienteInfo?> BuscarExpedientePorIdEnAsuntoAsync(int idDenuncia);
/// <summary>
/// Busca un expediente por su numero/codigo visible de Gestiona, por ejemplo "151/2025".
/// </summary>
Task<GestionaExpedienteInfo?> BuscarExpedientePorCodigoAsync(string codigoExpediente);
/// <summary> /// <summary>
/// Obtiene los metadatos visibles de un expediente concreto. /// Obtiene los metadatos visibles de un expediente concreto.
/// </summary> /// </summary>
Task<GestionaExpedienteInfo?> ObtenerExpedienteAsync(string fileUrl); Task<GestionaExpedienteInfo?> ObtenerExpedienteAsync(string fileUrl);
/// <summary>
/// Obtiene la última entrada de auditoría del expediente, si Gestiona devuelve contenido.
/// </summary>
Task<GestionaAuditInfo?> ObtenerUltimaAuditoriaExpedienteAsync(string fileUrl);
Task<List<ExpedienteTerceroDto>> ObtenerExpedientesPorTerceroAsync( Task<List<ExpedienteTerceroDto>> ObtenerExpedientesPorTerceroAsync(
string nif, string nif,
DateTimeOffset? desde = null, DateTimeOffset? desde = null,
@@ -130,3 +145,5 @@ namespace ApiDenuncias.Services
} }
} }

View File

@@ -102,18 +102,28 @@ public sealed class InboxTrackingService : IInboxTrackingService
DownloadedByAnotherUser = meta?.DownloadedByAnotherUser ?? false, DownloadedByAnotherUser = meta?.DownloadedByAnotherUser ?? false,
LastDownloadedByUsername = meta?.LastDownloadedByUsername, LastDownloadedByUsername = meta?.LastDownloadedByUsername,
LastDownloadedAt = meta?.LastDownloadedAtUtc?.ToString("O", CultureInfo.InvariantCulture), LastDownloadedAt = meta?.LastDownloadedAtUtc?.ToString("O", CultureInfo.InvariantCulture),
LastGestionaUploadAt = meta?.LastGestionaUploadAtUtc?.ToString("O", CultureInfo.InvariantCulture),
AlreadyImported = meta?.AlreadyImported ?? false, AlreadyImported = meta?.AlreadyImported ?? false,
AlreadyInGestiona = meta?.AlreadyInGestiona ?? false, AlreadyInGestiona = meta?.AlreadyInGestiona ?? false,
OwnerUsername = meta?.OwnerUsername,
OwnedByCurrentUser = meta?.OwnedByCurrentUser ?? false,
AccessibleByWorkGroup = meta?.AccessibleByWorkGroup ?? false,
RequiresOwnerConfirmation = meta?.RequiresOwnerConfirmation ?? false,
OwnerWorkGroups = meta?.OwnerWorkGroups ?? [],
TrackingNote = BuildTrackingNote(meta) TrackingNote = BuildTrackingNote(meta)
}; };
}) })
.Where(report => !IsLockedByAnotherUser(report)) .Where(report =>
string.IsNullOrWhiteSpace(report.OwnerUsername) ||
report.OwnedByCurrentUser ||
report.AccessibleByWorkGroup)
.ToArray(); .ToArray();
} }
public async Task EnsureReportCanBeImportedByUserAsync( public async Task EnsureReportCanBeImportedByUserAsync(
string username, string username,
ReportDto report, ReportDto report,
bool confirmDifferentOwner = false,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
await _denunciaStore.EnsureSchemaAsync(cancellationToken); await _denunciaStore.EnsureSchemaAsync(cancellationToken);
@@ -140,14 +150,23 @@ public sealed class InboxTrackingService : IInboxTrackingService
await EnsureConnectionOpenAsync(connection, cancellationToken); await EnsureConnectionOpenAsync(connection, cancellationToken);
var metadata = await LoadMetadataAsync(connection, userId, [report.Id], cancellationToken); var metadata = await LoadMetadataAsync(connection, userId, [report.Id], cancellationToken);
if (metadata.TryGetValue(report.Id, out var meta) && meta.LockedByAnotherUser) if (!metadata.TryGetValue(report.Id, out var meta) ||
string.IsNullOrWhiteSpace(meta.OwnerUsername) ||
meta.OwnedByCurrentUser)
{ {
var owner = string.IsNullOrWhiteSpace(meta.LastDownloadedByUsername) return;
? "otro usuario" }
: meta.LastDownloadedByUsername;
throw new InvalidOperationException( if (!meta.AccessibleByWorkGroup)
$"La denuncia ya fue importada por {owner}. Solo ese usuario puede ver e importar sus actualizaciones."); {
throw new ReportOwnershipException(
$"La denuncia pertenece a {meta.OwnerUsername} y no compartis ningun grupo de trabajo.");
}
if (!confirmDifferentOwner)
{
throw new ReportOwnershipException(
$"La denuncia pertenece a {meta.OwnerUsername}. Confirma expresamente que deseas importarla como miembro de su grupo.");
} }
} }
@@ -180,6 +199,7 @@ public sealed class InboxTrackingService : IInboxTrackingService
SET SET
last_downloaded_at_utc = @nowUtc, last_downloaded_at_utc = @nowUtc,
last_downloaded_by_user_id = @userId, last_downloaded_by_user_id = @userId,
owner_user_id = COALESCE(owner_user_id, @userId),
imported_complaint_report_id = COALESCE(@complaintId, imported_complaint_report_id), imported_complaint_report_id = COALESCE(@complaintId, imported_complaint_report_id),
imported_to_store_at_utc = COALESCE(imported_to_store_at_utc, @nowUtc), imported_to_store_at_utc = COALESCE(imported_to_store_at_utc, @nowUtc),
updated_at_utc = CURRENT_TIMESTAMP(6) updated_at_utc = CURRENT_TIMESTAMP(6)
@@ -262,6 +282,107 @@ public sealed class InboxTrackingService : IInboxTrackingService
} }
} }
public async Task MarkReportHandledInGestionaAsync(
string username,
int denunciaId,
DateTime uploadedAtUtc,
CancellationToken cancellationToken = default)
{
await _denunciaStore.EnsureSchemaAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(username) || denunciaId <= 0)
{
return;
}
var handledAtUtc = uploadedAtUtc.Kind switch
{
DateTimeKind.Utc => uploadedAtUtc,
DateTimeKind.Local => uploadedAtUtc.ToUniversalTime(),
_ => DateTime.SpecifyKind(uploadedAtUtc, DateTimeKind.Utc)
};
await using var connection = await OpenConnectionAsync(cancellationToken);
var userId = await EnsureUserAsync(connection, username, cancellationToken);
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
try
{
const string updateInboxSql = """
UPDATE inbox_reports
SET
last_downloaded_at_utc =
CASE
WHEN last_downloaded_at_utc IS NULL THEN @handledAtUtc
WHEN @handledAtUtc > last_downloaded_at_utc THEN @handledAtUtc
ELSE last_downloaded_at_utc
END,
last_downloaded_by_user_id = @userId,
owner_user_id = COALESCE(owner_user_id, @userId),
imported_complaint_report_id = COALESCE(imported_complaint_report_id, @denunciaId),
imported_to_store_at_utc = COALESCE(imported_to_store_at_utc, @handledAtUtc),
updated_at_utc = CURRENT_TIMESTAMP(6)
WHERE progressive_id = @denunciaId
OR imported_complaint_report_id = @denunciaId;
""";
await using (var updateInbox = new MySqlCommand(updateInboxSql, connection, (MySqlTransaction)transaction))
{
updateInbox.Parameters.AddWithValue("@handledAtUtc", handledAtUtc);
updateInbox.Parameters.AddWithValue("@userId", userId);
updateInbox.Parameters.AddWithValue("@denunciaId", denunciaId);
await updateInbox.ExecuteNonQueryAsync(cancellationToken);
}
const string updateUserReportSql = """
INSERT INTO user_inbox_reports (
app_user_id,
inbox_report_id,
first_seen_at_utc,
last_seen_at_utc,
first_downloaded_at_utc,
last_downloaded_at_utc,
download_count
)
SELECT
@userId,
ir.id,
CURRENT_TIMESTAMP(6),
CURRENT_TIMESTAMP(6),
@handledAtUtc,
@handledAtUtc,
1
FROM inbox_reports ir
WHERE ir.progressive_id = @denunciaId
OR ir.imported_complaint_report_id = @denunciaId
ON DUPLICATE KEY UPDATE
last_seen_at_utc = CURRENT_TIMESTAMP(6),
first_downloaded_at_utc = COALESCE(first_downloaded_at_utc, VALUES(first_downloaded_at_utc)),
last_downloaded_at_utc =
CASE
WHEN last_downloaded_at_utc IS NULL THEN VALUES(last_downloaded_at_utc)
WHEN VALUES(last_downloaded_at_utc) > last_downloaded_at_utc THEN VALUES(last_downloaded_at_utc)
ELSE last_downloaded_at_utc
END;
""";
await using (var updateUserReport = new MySqlCommand(updateUserReportSql, connection, (MySqlTransaction)transaction))
{
updateUserReport.Parameters.AddWithValue("@userId", userId);
updateUserReport.Parameters.AddWithValue("@handledAtUtc", handledAtUtc);
updateUserReport.Parameters.AddWithValue("@denunciaId", denunciaId);
await updateUserReport.ExecuteNonQueryAsync(cancellationToken);
}
await transaction.CommitAsync(cancellationToken);
}
catch
{
await SafeRollbackAsync(transaction, cancellationToken);
throw;
}
}
private async Task<long> EnsureUserAsync(MySqlConnection connection, string username, CancellationToken cancellationToken) private async Task<long> EnsureUserAsync(MySqlConnection connection, string username, CancellationToken cancellationToken)
{ {
const string insertSql = """ const string insertSql = """
@@ -412,11 +533,45 @@ public sealed class InboxTrackingService : IInboxTrackingService
ir.global_report_uuid, ir.global_report_uuid,
ir.last_downloaded_at_utc, ir.last_downloaded_at_utc,
downloader.username AS last_downloaded_by_username, downloader.username AS last_downloaded_by_username,
owner.username AS owner_username,
ir.imported_to_store_at_utc, ir.imported_to_store_at_utc,
COALESCE(c.is_in_gestiona, 0) AS already_in_gestiona, COALESCE(c.is_in_gestiona, 0) AS already_in_gestiona,
CASE WHEN uir.last_downloaded_at_utc IS NULL THEN 0 ELSE 1 END AS downloaded_by_current_user COALESCE(
(
SELECT MAX(history.uploaded_at_utc)
FROM gestiona_upload_history history
WHERE history.external_report_id =
COALESCE(ir.imported_complaint_report_id, ir.progressive_id)
),
c.gestiona_uploaded_at_utc
) AS last_gestiona_upload_at_utc,
CASE WHEN uir.last_downloaded_at_utc IS NULL THEN 0 ELSE 1 END AS downloaded_by_current_user,
CASE WHEN ir.owner_user_id = @userId THEN 1 ELSE 0 END AS owned_by_current_user,
CASE
WHEN ir.owner_user_id IS NULL THEN 0
WHEN EXISTS (
SELECT 1
FROM app_user_groups shared_membership
INNER JOIN work_groups active_group
ON active_group.id = shared_membership.work_group_id
AND active_group.is_active = 1
WHERE shared_membership.app_user_id IN (@userId, ir.owner_user_id)
GROUP BY shared_membership.work_group_id
HAVING COUNT(DISTINCT shared_membership.app_user_id) = 2
) THEN 1
ELSE 0
END AS accessible_by_group,
(
SELECT GROUP_CONCAT(DISTINCT wg.code ORDER BY wg.code SEPARATOR ',')
FROM app_user_groups owner_membership
INNER JOIN work_groups wg
ON wg.id = owner_membership.work_group_id
AND wg.is_active = 1
WHERE owner_membership.app_user_id = ir.owner_user_id
) AS owner_group_codes
FROM inbox_reports ir FROM inbox_reports ir
LEFT JOIN app_users downloader ON downloader.id = ir.last_downloaded_by_user_id LEFT JOIN app_users downloader ON downloader.id = ir.last_downloaded_by_user_id
LEFT JOIN app_users owner ON owner.id = ir.owner_user_id
LEFT JOIN user_inbox_reports uir LEFT JOIN user_inbox_reports uir
ON uir.inbox_report_id = ir.id ON uir.inbox_report_id = ir.id
AND uir.app_user_id = @userId AND uir.app_user_id = @userId
@@ -433,14 +588,20 @@ public sealed class InboxTrackingService : IInboxTrackingService
? null ? null
: reader.GetString(reader.GetOrdinal("last_downloaded_by_username")); : reader.GetString(reader.GetOrdinal("last_downloaded_by_username"));
var downloadedByCurrentUser = reader.GetInt32(reader.GetOrdinal("downloaded_by_current_user")) == 1; var downloadedByCurrentUser = reader.GetInt32(reader.GetOrdinal("downloaded_by_current_user")) == 1;
var lockedByAnotherUser = var downloadedByAnotherUser =
!downloadedByCurrentUser && !downloadedByCurrentUser &&
!reader.IsDBNull(reader.GetOrdinal("imported_to_store_at_utc")) && !string.IsNullOrWhiteSpace(lastDownloadedByUsername);
!string.IsNullOrWhiteSpace(lastDownloadedByUsername); var ownerUsername = reader.IsDBNull(reader.GetOrdinal("owner_username"))
? null
var downloadedByAnotherUser = : reader.GetString(reader.GetOrdinal("owner_username"));
!downloadedByCurrentUser && var ownedByCurrentUser =
!string.IsNullOrWhiteSpace(lastDownloadedByUsername); reader.GetInt32(reader.GetOrdinal("owned_by_current_user")) == 1;
var accessibleByGroup =
reader.GetInt32(reader.GetOrdinal("accessible_by_group")) == 1;
var ownerWorkGroups = reader.IsDBNull(reader.GetOrdinal("owner_group_codes"))
? []
: reader.GetString(reader.GetOrdinal("owner_group_codes"))
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
metadata[reportId] = new ReportMetadata metadata[reportId] = new ReportMetadata
{ {
@@ -448,9 +609,13 @@ public sealed class InboxTrackingService : IInboxTrackingService
DownloadedByAnotherUser = downloadedByAnotherUser, DownloadedByAnotherUser = downloadedByAnotherUser,
LastDownloadedByUsername = lastDownloadedByUsername, LastDownloadedByUsername = lastDownloadedByUsername,
LastDownloadedAtUtc = GetDateTimeOffset(reader, "last_downloaded_at_utc"), LastDownloadedAtUtc = GetDateTimeOffset(reader, "last_downloaded_at_utc"),
LastGestionaUploadAtUtc = GetDateTimeOffset(reader, "last_gestiona_upload_at_utc"),
AlreadyImported = !reader.IsDBNull(reader.GetOrdinal("imported_to_store_at_utc")), AlreadyImported = !reader.IsDBNull(reader.GetOrdinal("imported_to_store_at_utc")),
AlreadyInGestiona = reader.GetInt32(reader.GetOrdinal("already_in_gestiona")) == 1, AlreadyInGestiona = reader.GetInt32(reader.GetOrdinal("already_in_gestiona")) == 1,
LockedByAnotherUser = lockedByAnotherUser, OwnerUsername = ownerUsername,
OwnedByCurrentUser = ownedByCurrentUser,
AccessibleByWorkGroup = accessibleByGroup,
OwnerWorkGroups = ownerWorkGroups,
}; };
} }
@@ -554,11 +719,10 @@ public sealed class InboxTrackingService : IInboxTrackingService
return null; return null;
} }
if (metadata.LockedByAnotherUser) if (!string.IsNullOrWhiteSpace(metadata.OwnerUsername) &&
!metadata.OwnedByCurrentUser)
{ {
return string.IsNullOrWhiteSpace(metadata.LastDownloadedByUsername) return $"Propiedad de {metadata.OwnerUsername}";
? "Importada por otro usuario"
: $"Importada por {metadata.LastDownloadedByUsername}";
} }
if (metadata.AlreadyInGestiona) if (metadata.AlreadyInGestiona)
@@ -593,15 +757,26 @@ public sealed class InboxTrackingService : IInboxTrackingService
{ {
public bool DownloadedByCurrentUser { get; init; } public bool DownloadedByCurrentUser { get; init; }
public bool DownloadedByAnotherUser { get; init; } public bool DownloadedByAnotherUser { get; init; }
public bool LockedByAnotherUser { get; init; }
public string? LastDownloadedByUsername { get; init; } public string? LastDownloadedByUsername { get; init; }
public DateTimeOffset? LastDownloadedAtUtc { get; init; } public DateTimeOffset? LastDownloadedAtUtc { get; init; }
public DateTimeOffset? LastGestionaUploadAtUtc { get; init; }
public bool AlreadyImported { get; init; } public bool AlreadyImported { get; init; }
public bool AlreadyInGestiona { get; init; } public bool AlreadyInGestiona { get; init; }
public string? OwnerUsername { get; init; }
public bool OwnedByCurrentUser { get; init; }
public bool AccessibleByWorkGroup { get; init; }
public IReadOnlyList<string> OwnerWorkGroups { get; init; } = [];
public bool RequiresOwnerConfirmation =>
!OwnedByCurrentUser &&
AccessibleByWorkGroup &&
!string.IsNullOrWhiteSpace(OwnerUsername);
}
}
public sealed class ReportOwnershipException : InvalidOperationException
{
public ReportOwnershipException(string message)
: base(message)
{
} }
private static bool IsLockedByAnotherUser(ReportDto report)
=> report.AlreadyImported &&
report.DownloadedByAnotherUser &&
!report.DownloadedByCurrentUser;
} }

View File

@@ -29,6 +29,26 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
"""; """;
private const string GestionaUploadHistoryTableSql = """
CREATE TABLE IF NOT EXISTS gestiona_upload_history (
id BIGINT NOT NULL AUTO_INCREMENT,
external_report_id INT NOT NULL,
gestiona_file_url TEXT NOT NULL,
gestiona_file_code VARCHAR(128) NOT NULL DEFAULT '',
upload_type VARCHAR(64) NOT NULL DEFAULT '',
assigned_group_code VARCHAR(32) NOT NULL DEFAULT '',
uploaded_by_username VARCHAR(256) NOT NULL DEFAULT '',
uploaded_at_utc DATETIME(6) NOT NULL,
subject TEXT NULL,
document_names TEXT NULL,
created_at_utc DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
PRIMARY KEY (id),
KEY ix_gestiona_upload_history_report (external_report_id),
KEY ix_gestiona_upload_history_uploaded_at (uploaded_at_utc),
KEY ix_gestiona_upload_history_user (uploaded_by_username)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
""";
private const string ComplaintSelectColumns = """ private const string ComplaintSelectColumns = """
external_registry_id, external_registry_id,
external_report_id, external_report_id,
@@ -94,6 +114,9 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
workflow_status, workflow_status,
selected_document_name, selected_document_name,
gestiona_uploaded_at_utc, gestiona_uploaded_at_utc,
gestiona_last_upload_type,
gestiona_assigned_group,
pending_update_source,
is_in_gestiona, is_in_gestiona,
is_rejected, is_rejected,
key_date, key_date,
@@ -118,6 +141,23 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
a.encrypted_at_utc a.encrypted_at_utc
"""; """;
private const string AttachmentMetadataSelectColumns = """
a.id,
a.attachment_type_id,
a.description,
a.attachment_date_utc,
a.notes,
c.external_report_id,
a.original_file_name,
NULL AS content,
a.content_sha256,
a.uploaded_to_gestiona,
a.uploaded_at_utc,
a.key_date,
a.encryption_scheme,
a.encrypted_at_utc
""";
private static readonly (string Table, string Column, string Definition)[] SchemaColumnsToEnsure = private static readonly (string Table, string Column, string Definition)[] SchemaColumnsToEnsure =
[ [
("complaints", "gestiona_file_code", "`gestiona_file_code` VARCHAR(128) NOT NULL DEFAULT ''"), ("complaints", "gestiona_file_code", "`gestiona_file_code` VARCHAR(128) NOT NULL DEFAULT ''"),
@@ -143,6 +183,10 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
("complaints", "key_date", "`key_date` DATE NULL"), ("complaints", "key_date", "`key_date` DATE NULL"),
("complaints", "encryption_scheme", "`encryption_scheme` VARCHAR(64) NOT NULL DEFAULT 'none'"), ("complaints", "encryption_scheme", "`encryption_scheme` VARCHAR(64) NOT NULL DEFAULT 'none'"),
("complaints", "encrypted_at_utc", "`encrypted_at_utc` DATETIME(6) NULL"), ("complaints", "encrypted_at_utc", "`encrypted_at_utc` DATETIME(6) NULL"),
("complaints", "gestiona_last_upload_type", "`gestiona_last_upload_type` VARCHAR(64) NOT NULL DEFAULT ''"),
("complaints", "gestiona_assigned_group", "`gestiona_assigned_group` VARCHAR(255) NOT NULL DEFAULT ''"),
("complaints", "pending_update_source", "`pending_update_source` VARCHAR(256) NOT NULL DEFAULT ''"),
("inbox_reports", "owner_user_id", "`owner_user_id` BIGINT NULL"),
("complaint_attachments", "content_sha256", "`content_sha256` CHAR(64) NOT NULL DEFAULT ''"), ("complaint_attachments", "content_sha256", "`content_sha256` CHAR(64) NOT NULL DEFAULT ''"),
("complaint_attachments", "key_date", "`key_date` DATE NULL"), ("complaint_attachments", "key_date", "`key_date` DATE NULL"),
("complaint_attachments", "encryption_scheme", "`encryption_scheme` VARCHAR(64) NOT NULL DEFAULT 'none'"), ("complaint_attachments", "encryption_scheme", "`encryption_scheme` VARCHAR(64) NOT NULL DEFAULT 'none'"),
@@ -154,6 +198,7 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
("complaint_attachments", "ix_attachments_sha256", "INDEX `ix_attachments_sha256` (`content_sha256`)"), ("complaint_attachments", "ix_attachments_sha256", "INDEX `ix_attachments_sha256` (`content_sha256`)"),
("complaints", "ix_complaints_key_date", "INDEX `ix_complaints_key_date` (`key_date`)"), ("complaints", "ix_complaints_key_date", "INDEX `ix_complaints_key_date` (`key_date`)"),
("complaints", "ix_complaints_flags", "INDEX `ix_complaints_flags` (`is_update`, `is_in_gestiona`, `is_rejected`)"), ("complaints", "ix_complaints_flags", "INDEX `ix_complaints_flags` (`is_update`, `is_in_gestiona`, `is_rejected`)"),
("inbox_reports", "ix_inbox_reports_owner", "INDEX `ix_inbox_reports_owner` (`owner_user_id`)"),
("complaint_attachments", "ix_attachments_key_date", "INDEX `ix_attachments_key_date` (`key_date`)"), ("complaint_attachments", "ix_attachments_key_date", "INDEX `ix_attachments_key_date` (`key_date`)"),
]; ];
@@ -211,6 +256,8 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
("complaints", "`display_name` TEXT NOT NULL"), ("complaints", "`display_name` TEXT NOT NULL"),
("complaints", "`workflow_status` TEXT NOT NULL"), ("complaints", "`workflow_status` TEXT NOT NULL"),
("complaints", "`selected_document_name` TEXT NULL"), ("complaints", "`selected_document_name` TEXT NULL"),
("complaints", "`gestiona_last_upload_type` TEXT NOT NULL"),
("complaints", "`gestiona_assigned_group` TEXT NOT NULL"),
("complaint_attachments", "`description` TEXT NULL"), ("complaint_attachments", "`description` TEXT NULL"),
("complaint_attachments", "`notes` TEXT NOT NULL"), ("complaint_attachments", "`notes` TEXT NOT NULL"),
]; ];
@@ -422,6 +469,68 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
return result; return result;
} }
public async Task<List<FicherosDenuncias>> GetFicherosMetadataByDenunciaAsync(
int denunciaId,
CancellationToken cancellationToken = default)
{
await EnsureSchemaReadyAsync(cancellationToken);
var sql = $"""
SELECT
{AttachmentMetadataSelectColumns}
FROM complaint_attachments a
INNER JOIN complaints c ON c.id = a.complaint_id
WHERE c.external_report_id = @denunciaId
ORDER BY a.original_file_name ASC;
""";
await using var connection = await OpenConnectionAsync(cancellationToken);
await using var command = new MySqlCommand(sql, connection);
command.Parameters.AddWithValue("@denunciaId", denunciaId);
var result = new List<FicherosDenuncias>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
result.Add(MapAttachment(reader));
}
return result;
}
public async Task<List<GestionaUploadHistoryEntry>> GetGestionaUploadHistoryAsync(CancellationToken cancellationToken = default)
{
await EnsureSchemaReadyAsync(cancellationToken);
const string sql = """
SELECT
id,
external_report_id,
gestiona_file_url,
gestiona_file_code,
upload_type,
assigned_group_code,
uploaded_by_username,
uploaded_at_utc,
subject,
document_names
FROM gestiona_upload_history
ORDER BY uploaded_at_utc DESC, id DESC;
""";
await using var connection = await OpenConnectionAsync(cancellationToken);
await using var command = new MySqlCommand(sql, connection);
var result = new List<GestionaUploadHistoryEntry>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
result.Add(MapGestionaUploadHistory(reader));
}
return result;
}
public async Task<DenunciasGestiona?> GetDenunciaByIdAsync( public async Task<DenunciasGestiona?> GetDenunciaByIdAsync(
int denunciaId, int denunciaId,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
@@ -446,6 +555,92 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
: null; : null;
} }
public async Task<DenunciasGestiona?> GetDenunciaByGestionaFileCodeAsync(
string gestionaFileCode,
CancellationToken cancellationToken = default)
{
await EnsureSchemaReadyAsync(cancellationToken);
var sql = $"""
SELECT
{ComplaintSelectColumns}
FROM complaints
WHERE external_report_id = (
SELECT history.external_report_id
FROM gestiona_upload_history history
WHERE history.gestiona_file_code = @gestionaFileCode
ORDER BY history.uploaded_at_utc DESC, history.id DESC
LIMIT 1
)
OR gestiona_file_code = @gestionaFileCode
ORDER BY COALESCE(gestiona_uploaded_at_utc, report_date_utc) DESC, external_report_id DESC
LIMIT 1;
""";
await using var connection = await OpenConnectionAsync(cancellationToken);
await using var command = new MySqlCommand(sql, connection);
command.Parameters.AddWithValue("@gestionaFileCode", gestionaFileCode.Trim());
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
return await reader.ReadAsync(cancellationToken)
? MapComplaint(reader)
: null;
}
public async Task AddGestionaUploadHistoryAsync(
GestionaUploadHistoryCreateRequest request,
string username,
CancellationToken cancellationToken = default)
{
await EnsureSchemaReadyAsync(cancellationToken);
const string sql = """
INSERT INTO gestiona_upload_history (
external_report_id,
gestiona_file_url,
gestiona_file_code,
upload_type,
assigned_group_code,
uploaded_by_username,
uploaded_at_utc,
subject,
document_names
)
SELECT
@externalReportId,
@gestionaFileUrl,
@gestionaFileCode,
@uploadType,
@assignedGroupCode,
@uploadedByUsername,
@uploadedAtUtc,
@subject,
@documentNames
WHERE NOT EXISTS (
SELECT 1
FROM gestiona_upload_history
WHERE external_report_id = @externalReportId
AND uploaded_at_utc = @uploadedAtUtc
AND upload_type = @uploadType
LIMIT 1
);
""";
await using var connection = await OpenConnectionAsync(cancellationToken);
await using var command = new MySqlCommand(sql, connection);
command.Parameters.AddWithValue("@externalReportId", request.DenunciaId);
command.Parameters.AddWithValue("@gestionaFileUrl", request.ExpedienteGestionaUrl ?? string.Empty);
command.Parameters.AddWithValue("@gestionaFileCode", request.CodigoExpedienteGestiona ?? string.Empty);
command.Parameters.AddWithValue("@uploadType", request.TipoSubida ?? string.Empty);
command.Parameters.AddWithValue("@assignedGroupCode", ExtractGroupCode(request.GrupoAsignado));
command.Parameters.AddWithValue("@uploadedByUsername", string.IsNullOrWhiteSpace(username) ? "(sin usuario)" : username.Trim());
command.Parameters.AddWithValue("@uploadedAtUtc", request.UploadedAtUtc == DateTime.MinValue ? DateTime.UtcNow : request.UploadedAtUtc);
command.Parameters.AddWithValue("@subject", ToDbStringOrNull(request.Asunto));
command.Parameters.AddWithValue("@documentNames", ToDbStringOrNull(request.Documentos));
await command.ExecuteNonQueryAsync(cancellationToken);
}
public async Task UpsertDenunciaAsync(DenunciasGestiona denuncia, CancellationToken cancellationToken = default) public async Task UpsertDenunciaAsync(DenunciasGestiona denuncia, CancellationToken cancellationToken = default)
{ {
await EnsureSchemaReadyAsync(cancellationToken); await EnsureSchemaReadyAsync(cancellationToken);
@@ -516,6 +711,9 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
workflow_status, workflow_status,
selected_document_name, selected_document_name,
gestiona_uploaded_at_utc, gestiona_uploaded_at_utc,
gestiona_last_upload_type,
gestiona_assigned_group,
pending_update_source,
is_in_gestiona, is_in_gestiona,
is_rejected, is_rejected,
key_date, key_date,
@@ -586,6 +784,9 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
@workflowStatus, @workflowStatus,
@selectedDocumentName, @selectedDocumentName,
@gestionaUploadedAtUtc, @gestionaUploadedAtUtc,
@gestionaLastUploadType,
@gestionaAssignedGroup,
@pendingUpdateSource,
@isInGestiona, @isInGestiona,
@isRejected, @isRejected,
@keyDate, @keyDate,
@@ -656,6 +857,9 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
workflow_status = VALUES(workflow_status), workflow_status = VALUES(workflow_status),
selected_document_name = VALUES(selected_document_name), selected_document_name = VALUES(selected_document_name),
gestiona_uploaded_at_utc = VALUES(gestiona_uploaded_at_utc), gestiona_uploaded_at_utc = VALUES(gestiona_uploaded_at_utc),
gestiona_last_upload_type = VALUES(gestiona_last_upload_type),
gestiona_assigned_group = VALUES(gestiona_assigned_group),
pending_update_source = VALUES(pending_update_source),
is_in_gestiona = VALUES(is_in_gestiona), is_in_gestiona = VALUES(is_in_gestiona),
is_rejected = VALUES(is_rejected), is_rejected = VALUES(is_rejected),
key_date = VALUES(key_date), key_date = VALUES(key_date),
@@ -731,6 +935,9 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
command.Parameters.AddWithValue("@workflowStatus", denuncia.EstadoDenuncia ?? string.Empty); command.Parameters.AddWithValue("@workflowStatus", denuncia.EstadoDenuncia ?? string.Empty);
command.Parameters.AddWithValue("@selectedDocumentName", ToDbStringOrNull(denuncia.ArchivoElegido)); command.Parameters.AddWithValue("@selectedDocumentName", ToDbStringOrNull(denuncia.ArchivoElegido));
command.Parameters.AddWithValue("@gestionaUploadedAtUtc", ToDbDate(denuncia.FechaSubidaAGestiona)); command.Parameters.AddWithValue("@gestionaUploadedAtUtc", ToDbDate(denuncia.FechaSubidaAGestiona));
command.Parameters.AddWithValue("@gestionaLastUploadType", denuncia.UltimaSubidaGestionaTipo ?? string.Empty);
command.Parameters.AddWithValue("@gestionaAssignedGroup", denuncia.UltimoGrupoAsignadoGestiona ?? string.Empty);
command.Parameters.AddWithValue("@pendingUpdateSource", denuncia.PendingUpdateSource ?? string.Empty);
command.Parameters.AddWithValue("@isInGestiona", denuncia.EnGestiona); command.Parameters.AddWithValue("@isInGestiona", denuncia.EnGestiona);
command.Parameters.AddWithValue("@isRejected", denuncia.EnRechazada); command.Parameters.AddWithValue("@isRejected", denuncia.EnRechazada);
command.Parameters.AddWithValue("@keyDate", ToDbDate(denuncia.KeyDate)); command.Parameters.AddWithValue("@keyDate", ToDbDate(denuncia.KeyDate));
@@ -795,19 +1002,17 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
description = @description, description = @description,
attachment_date_utc = @attachmentDateUtc, attachment_date_utc = @attachmentDateUtc,
notes = @notes, notes = @notes,
content = @content,
content_mime_type = @contentMimeType,
content_sha256 = @contentSha256,
uploaded_to_gestiona = CASE uploaded_to_gestiona = CASE
WHEN LOWER(@originalFileName) = 'report.txt' OR LOWER(@originalFileName) = 'report.pdf' THEN @uploadedToGestiona
WHEN complaint_attachments.content_sha256 = @contentSha256 THEN complaint_attachments.uploaded_to_gestiona WHEN complaint_attachments.content_sha256 = @contentSha256 THEN complaint_attachments.uploaded_to_gestiona
ELSE @uploadedToGestiona ELSE @uploadedToGestiona
END, END,
uploaded_at_utc = CASE uploaded_at_utc = CASE
WHEN LOWER(@originalFileName) = 'report.txt' OR LOWER(@originalFileName) = 'report.pdf' THEN @uploadedAtUtc
WHEN complaint_attachments.content_sha256 = @contentSha256 THEN complaint_attachments.uploaded_at_utc WHEN complaint_attachments.content_sha256 = @contentSha256 THEN complaint_attachments.uploaded_at_utc
ELSE @uploadedAtUtc ELSE @uploadedAtUtc
END, END,
content = @content,
content_mime_type = @contentMimeType,
content_sha256 = @contentSha256,
key_date = @keyDate, key_date = @keyDate,
encryption_scheme = @encryptionScheme, encryption_scheme = @encryptionScheme,
encrypted_at_utc = @encryptedAtUtc, encrypted_at_utc = @encryptedAtUtc,
@@ -1125,6 +1330,7 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
await EnsureAttachmentChunksTableAsync(connection, cancellationToken); await EnsureAttachmentChunksTableAsync(connection, cancellationToken);
await EnsureGestionaUploadHistoryTableAsync(connection, cancellationToken);
foreach (var (table, column, definition) in SchemaColumnsToEnsure) foreach (var (table, column, definition) in SchemaColumnsToEnsure)
{ {
@@ -1156,6 +1362,54 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
await alterCommand.ExecuteNonQueryAsync(cancellationToken); await alterCommand.ExecuteNonQueryAsync(cancellationToken);
} }
} }
await BackfillReportOwnersAsync(connection, cancellationToken);
}
private static async Task BackfillReportOwnersAsync(
MySqlConnection connection,
CancellationToken cancellationToken)
{
const string sql = """
UPDATE inbox_reports ir
LEFT JOIN (
SELECT
uir.inbox_report_id,
CAST(
SUBSTRING_INDEX(
GROUP_CONCAT(
uir.app_user_id
ORDER BY
COALESCE(
uir.first_downloaded_at_utc,
uir.last_downloaded_at_utc,
uir.first_seen_at_utc
),
uir.app_user_id
SEPARATOR ','
),
',',
1
) AS UNSIGNED
) AS first_owner_user_id
FROM user_inbox_reports uir
WHERE uir.first_downloaded_at_utc IS NOT NULL
OR uir.last_downloaded_at_utc IS NOT NULL
GROUP BY uir.inbox_report_id
) first_owner ON first_owner.inbox_report_id = ir.id
SET ir.owner_user_id = COALESCE(
first_owner.first_owner_user_id,
ir.last_downloaded_by_user_id
)
WHERE ir.owner_user_id IS NULL
AND (
ir.imported_to_store_at_utc IS NOT NULL
OR ir.imported_complaint_report_id IS NOT NULL
);
""";
await using var command = new MySqlCommand(sql, connection);
await command.ExecuteNonQueryAsync(cancellationToken);
} }
private static async Task EnsureAttachmentChunksTableAsync( private static async Task EnsureAttachmentChunksTableAsync(
@@ -1167,6 +1421,15 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
await command.ExecuteNonQueryAsync(cancellationToken); await command.ExecuteNonQueryAsync(cancellationToken);
} }
private static async Task EnsureGestionaUploadHistoryTableAsync(
MySqlConnection connection,
CancellationToken cancellationToken)
{
await using var command = connection.CreateCommand();
command.CommandText = GestionaUploadHistoryTableSql;
await command.ExecuteNonQueryAsync(cancellationToken);
}
private static string ExtractColumnName(string definition) private static string ExtractColumnName(string definition)
{ {
var first = definition.IndexOf('`'); var first = definition.IndexOf('`');
@@ -1462,6 +1725,9 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
EstadoDenuncia = GetString(record, "workflow_status"), EstadoDenuncia = GetString(record, "workflow_status"),
ArchivoElegido = GetString(record, "selected_document_name"), ArchivoElegido = GetString(record, "selected_document_name"),
FechaSubidaAGestiona = GetDateTime(record, "gestiona_uploaded_at_utc"), FechaSubidaAGestiona = GetDateTime(record, "gestiona_uploaded_at_utc"),
UltimaSubidaGestionaTipo = GetString(record, "gestiona_last_upload_type"),
UltimoGrupoAsignadoGestiona = GetString(record, "gestiona_assigned_group"),
PendingUpdateSource = GetString(record, "pending_update_source"),
EnGestiona = GetBoolean(record, "is_in_gestiona"), EnGestiona = GetBoolean(record, "is_in_gestiona"),
EnRechazada = GetBoolean(record, "is_rejected"), EnRechazada = GetBoolean(record, "is_rejected"),
KeyDate = GetNullableDateOnly(record, "key_date"), KeyDate = GetNullableDateOnly(record, "key_date"),
@@ -1491,6 +1757,33 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
}; };
} }
private static GestionaUploadHistoryEntry MapGestionaUploadHistory(IDataRecord record)
{
return new GestionaUploadHistoryEntry(
Convert.ToInt64(record["id"]),
GetInt32(record, "external_report_id"),
GetString(record, "gestiona_file_url"),
GetString(record, "gestiona_file_code"),
GetString(record, "upload_type"),
ExtractGroupCode(GetString(record, "assigned_group_code")),
GetString(record, "uploaded_by_username"),
GetDateTime(record, "uploaded_at_utc"),
GetString(record, "subject"),
GetString(record, "document_names"));
}
private static string ExtractGroupCode(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return string.Empty;
}
var trimmed = value.Trim();
var code = new string(trimmed.TakeWhile(char.IsDigit).ToArray());
return string.IsNullOrWhiteSpace(code) ? trimmed : code;
}
private static object ToDbDate(DateTime value) private static object ToDbDate(DateTime value)
{ {
return value == DateTime.MinValue ? DBNull.Value : value; return value == DateTime.MinValue ? DBNull.Value : value;

View File

@@ -12,43 +12,163 @@ public sealed class UserComplaintAccessService
_connectionStringProvider = connectionStringProvider; _connectionStringProvider = connectionStringProvider;
} }
public async Task<HashSet<int>> GetAllowedComplaintIdsAsync(string username, CancellationToken cancellationToken = default) public async Task<HashSet<int>> GetAllowedComplaintIdsAsync(
string username,
CancellationToken cancellationToken = default)
{
var access = await GetComplaintAccessAsync(username, null, cancellationToken);
return access.Keys.ToHashSet();
}
public async Task<IReadOnlyDictionary<int, ComplaintAccessInfo>> GetComplaintAccessAsync(
string username,
IReadOnlyCollection<int>? complaintIds = null,
CancellationToken cancellationToken = default)
{ {
if (string.IsNullOrWhiteSpace(username)) if (string.IsNullOrWhiteSpace(username))
{ {
return []; return new Dictionary<int, ComplaintAccessInfo>();
} }
const string sql = """
SELECT DISTINCT ir.imported_complaint_report_id
FROM inbox_reports ir
INNER JOIN user_inbox_reports uir ON uir.inbox_report_id = ir.id
INNER JOIN app_users au ON au.id = uir.app_user_id
WHERE au.username = @username
AND ir.imported_complaint_report_id IS NOT NULL
AND uir.download_count > 0;
""";
var connectionString = await _connectionStringProvider.GetConnectionStringAsync(cancellationToken); var connectionString = await _connectionStringProvider.GetConnectionStringAsync(cancellationToken);
await using var connection = new MySqlConnection(connectionString); await using var connection = new MySqlConnection(connectionString);
await connection.OpenAsync(cancellationToken); await connection.OpenAsync(cancellationToken);
await using var command = new MySqlCommand(sql, connection); await using var command = connection.CreateCommand();
command.Parameters.AddWithValue("@username", username.Trim()); command.Parameters.AddWithValue("@username", username.Trim());
var result = new HashSet<int>(); var idFilter = string.Empty;
if (complaintIds is { Count: > 0 })
{
var parameters = new List<string>(complaintIds.Count);
var index = 0;
foreach (var complaintId in complaintIds.Where(id => id > 0).Distinct())
{
var parameterName = $"@complaintId{index++}";
parameters.Add(parameterName);
command.Parameters.AddWithValue(parameterName, complaintId);
}
if (parameters.Count == 0)
{
return new Dictionary<int, ComplaintAccessInfo>();
}
idFilter = $"AND COALESCE(ir.imported_complaint_report_id, ir.progressive_id) IN ({string.Join(", ", parameters)})";
}
command.CommandText = $"""
SELECT
COALESCE(ir.imported_complaint_report_id, ir.progressive_id) AS complaint_id,
owner.username AS owner_username,
CASE WHEN ir.owner_user_id = viewer.id THEN 1 ELSE 0 END AS owned_by_current_user,
CASE
WHEN ir.owner_user_id IS NULL THEN 0
WHEN EXISTS (
SELECT 1
FROM app_user_groups shared_membership
INNER JOIN work_groups active_group
ON active_group.id = shared_membership.work_group_id
AND active_group.is_active = 1
WHERE shared_membership.app_user_id IN (viewer.id, ir.owner_user_id)
GROUP BY shared_membership.work_group_id
HAVING COUNT(DISTINCT shared_membership.app_user_id) = 2
) THEN 1
ELSE 0
END AS accessible_by_group,
(
SELECT GROUP_CONCAT(DISTINCT wg.code ORDER BY wg.code SEPARATOR ',')
FROM app_user_groups owner_membership
INNER JOIN work_groups wg
ON wg.id = owner_membership.work_group_id
AND wg.is_active = 1
WHERE owner_membership.app_user_id = ir.owner_user_id
) AS owner_group_codes
FROM app_users viewer
INNER JOIN inbox_reports ir
ON COALESCE(ir.imported_complaint_report_id, ir.progressive_id) IS NOT NULL
LEFT JOIN app_users owner ON owner.id = ir.owner_user_id
LEFT JOIN user_inbox_reports current_tracking
ON current_tracking.inbox_report_id = ir.id
AND current_tracking.app_user_id = viewer.id
WHERE viewer.username = @username
{idFilter}
AND (
ir.owner_user_id = viewer.id
OR EXISTS (
SELECT 1
FROM app_user_groups shared_membership
INNER JOIN work_groups active_group
ON active_group.id = shared_membership.work_group_id
AND active_group.is_active = 1
WHERE shared_membership.app_user_id IN (viewer.id, ir.owner_user_id)
GROUP BY shared_membership.work_group_id
HAVING COUNT(DISTINCT shared_membership.app_user_id) = 2
)
OR (
ir.owner_user_id IS NULL
AND current_tracking.download_count > 0
)
);
""";
var result = new Dictionary<int, ComplaintAccessInfo>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken); await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken)) while (await reader.ReadAsync(cancellationToken))
{ {
result.Add(Convert.ToInt32(reader.GetValue(0), CultureInfo.InvariantCulture)); var complaintId = Convert.ToInt32(
reader.GetValue(reader.GetOrdinal("complaint_id")),
CultureInfo.InvariantCulture);
var ownerUsername = reader.IsDBNull(reader.GetOrdinal("owner_username"))
? string.Empty
: reader.GetString(reader.GetOrdinal("owner_username"));
var ownedByCurrentUser =
reader.GetInt32(reader.GetOrdinal("owned_by_current_user")) == 1;
var accessibleByGroup =
reader.GetInt32(reader.GetOrdinal("accessible_by_group")) == 1;
var groupCodes = reader.IsDBNull(reader.GetOrdinal("owner_group_codes"))
? []
: reader.GetString(reader.GetOrdinal("owner_group_codes"))
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
result[complaintId] = new ComplaintAccessInfo(
complaintId,
ownerUsername,
ownedByCurrentUser,
accessibleByGroup,
groupCodes);
} }
return result; return result;
} }
public async Task<bool> CanAccessComplaintAsync(string username, int complaintId, CancellationToken cancellationToken = default) public async Task<bool> CanAccessComplaintAsync(
string username,
int complaintId,
CancellationToken cancellationToken = default)
{ {
var allowedIds = await GetAllowedComplaintIdsAsync(username, cancellationToken); if (complaintId <= 0)
return allowedIds.Contains(complaintId); {
return false;
}
var access = await GetComplaintAccessAsync(
username,
[complaintId],
cancellationToken);
return access.ContainsKey(complaintId);
} }
} }
public sealed record ComplaintAccessInfo(
int ComplaintId,
string OwnerUsername,
bool OwnedByCurrentUser,
bool AccessibleByGroup,
IReadOnlyList<string> OwnerGroupCodes)
{
public bool RequiresOwnerConfirmation =>
!OwnedByCurrentUser &&
AccessibleByGroup &&
!string.IsNullOrWhiteSpace(OwnerUsername);
}

View File

@@ -0,0 +1,414 @@
using System.Globalization;
using GestionaDenuncias.Shared.Models;
using MySqlConnector;
namespace ApiDenuncias.Services;
public sealed class WorkGroupAdministrationService
{
private readonly IDenunciaStore _denunciaStore;
private readonly MySqlConnectionStringProvider _connectionStringProvider;
public WorkGroupAdministrationService(
IDenunciaStore denunciaStore,
MySqlConnectionStringProvider connectionStringProvider)
{
_denunciaStore = denunciaStore;
_connectionStringProvider = connectionStringProvider;
}
public async Task<WorkGroupAdministrationDto> GetAsync(
CancellationToken cancellationToken = default)
{
await _denunciaStore.EnsureSchemaAsync(cancellationToken);
await using var connection = await OpenConnectionAsync(cancellationToken);
var groups = await LoadGroupsAsync(connection, cancellationToken);
var users = new Dictionary<long, UserGroupBuilder>();
const string sql = """
SELECT
au.id,
au.username,
wg.code AS group_code
FROM app_users au
LEFT JOIN app_user_groups aug ON aug.app_user_id = au.id
LEFT JOIN work_groups wg
ON wg.id = aug.work_group_id
AND wg.is_active = 1
ORDER BY au.username, wg.code;
""";
await using var command = new MySqlCommand(sql, connection);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
var userId = reader.GetInt64(reader.GetOrdinal("id"));
if (!users.TryGetValue(userId, out var user))
{
user = new UserGroupBuilder(
userId,
reader.GetString(reader.GetOrdinal("username")));
users[userId] = user;
}
var groupOrdinal = reader.GetOrdinal("group_code");
if (!reader.IsDBNull(groupOrdinal))
{
user.GroupCodes.Add(reader.GetString(groupOrdinal));
}
}
return new WorkGroupAdministrationDto(
groups,
users.Values
.Select(user => new UserWorkGroupDto(
user.UserId,
user.Username,
user.GroupCodes.OrderBy(code => code, StringComparer.Ordinal).ToArray()))
.ToArray());
}
public async Task<CurrentUserWorkGroupsDto> GetUserGroupsAsync(
string username,
CancellationToken cancellationToken = default)
{
await _denunciaStore.EnsureSchemaAsync(cancellationToken);
var normalizedUsername = username?.Trim();
if (string.IsNullOrWhiteSpace(normalizedUsername))
{
throw new InvalidOperationException("No hay usuario autenticado.");
}
await using var connection = await OpenConnectionAsync(cancellationToken);
const string sql = """
SELECT wg.code
FROM app_users au
INNER JOIN app_user_groups aug ON aug.app_user_id = au.id
INNER JOIN work_groups wg
ON wg.id = aug.work_group_id
AND wg.is_active = 1
WHERE LOWER(au.username) = LOWER(@username)
ORDER BY wg.code;
""";
var groupCodes = new List<string>();
await using var command = new MySqlCommand(sql, connection);
command.Parameters.AddWithValue("@username", normalizedUsername);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
groupCodes.Add(reader.GetString(reader.GetOrdinal("code")));
}
return new CurrentUserWorkGroupsDto(normalizedUsername, groupCodes);
}
public async Task<WorkGroupAdministrationDto> UpdateUserGroupsAsync(
string username,
IEnumerable<string> groupCodes,
string changedByUsername,
CancellationToken cancellationToken = default)
{
await _denunciaStore.EnsureSchemaAsync(cancellationToken);
var normalizedUsername = username?.Trim();
if (string.IsNullOrWhiteSpace(normalizedUsername))
{
throw new InvalidOperationException("Debes indicar el usuario que se va a configurar.");
}
var normalizedCodes = groupCodes
.Where(code => !string.IsNullOrWhiteSpace(code))
.Select(code => code.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
if (normalizedCodes.Length == 0)
{
throw new InvalidOperationException("Cada usuario debe pertenecer al menos a un grupo de trabajo.");
}
await using var connection = await OpenConnectionAsync(cancellationToken);
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
try
{
var validGroups = await LoadGroupIdsAsync(
connection,
(MySqlTransaction)transaction,
normalizedCodes,
cancellationToken);
if (validGroups.Count != normalizedCodes.Length)
{
throw new InvalidOperationException("Se ha indicado un grupo de trabajo que no existe o no esta activo.");
}
var userId = await EnsureUserAsync(
connection,
(MySqlTransaction)transaction,
normalizedUsername,
cancellationToken);
var existingGroupIds = await LoadUserGroupIdsAsync(
connection,
(MySqlTransaction)transaction,
userId,
cancellationToken);
var desiredGroupIds = validGroups.Values.ToHashSet();
foreach (var removedGroupId in existingGroupIds.Except(desiredGroupIds))
{
await DeleteMembershipAsync(
connection,
(MySqlTransaction)transaction,
userId,
removedGroupId,
cancellationToken);
await AddHistoryAsync(
connection,
(MySqlTransaction)transaction,
userId,
removedGroupId,
"removed",
changedByUsername,
cancellationToken);
}
foreach (var addedGroupId in desiredGroupIds.Except(existingGroupIds))
{
await AddMembershipAsync(
connection,
(MySqlTransaction)transaction,
userId,
addedGroupId,
changedByUsername,
cancellationToken);
await AddHistoryAsync(
connection,
(MySqlTransaction)transaction,
userId,
addedGroupId,
"added",
changedByUsername,
cancellationToken);
}
await transaction.CommitAsync(cancellationToken);
}
catch
{
await transaction.RollbackAsync(cancellationToken);
throw;
}
return await GetAsync(cancellationToken);
}
private static async Task<List<WorkGroupDto>> LoadGroupsAsync(
MySqlConnection connection,
CancellationToken cancellationToken)
{
const string sql = """
SELECT code, name
FROM work_groups
WHERE is_active = 1
ORDER BY code;
""";
var groups = new List<WorkGroupDto>();
await using var command = new MySqlCommand(sql, connection);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
groups.Add(new WorkGroupDto(
reader.GetString(reader.GetOrdinal("code")),
reader.GetString(reader.GetOrdinal("name"))));
}
return groups;
}
private static async Task<Dictionary<string, long>> LoadGroupIdsAsync(
MySqlConnection connection,
MySqlTransaction transaction,
IReadOnlyList<string> codes,
CancellationToken cancellationToken)
{
var result = new Dictionary<string, long>(StringComparer.OrdinalIgnoreCase);
await using var command = new MySqlCommand { Connection = connection, Transaction = transaction };
var parameters = new List<string>(codes.Count);
for (var index = 0; index < codes.Count; index++)
{
var parameterName = $"@code{index}";
parameters.Add(parameterName);
command.Parameters.AddWithValue(parameterName, codes[index]);
}
command.CommandText = $"""
SELECT id, code
FROM work_groups
WHERE is_active = 1
AND code IN ({string.Join(", ", parameters)});
""";
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
result[reader.GetString(reader.GetOrdinal("code"))] =
reader.GetInt64(reader.GetOrdinal("id"));
}
return result;
}
private static async Task<long> EnsureUserAsync(
MySqlConnection connection,
MySqlTransaction transaction,
string username,
CancellationToken cancellationToken)
{
const string insertSql = """
INSERT INTO app_users (username)
VALUES (@username)
ON DUPLICATE KEY UPDATE
updated_at_utc = CURRENT_TIMESTAMP(6);
""";
await using (var insert = new MySqlCommand(insertSql, connection, transaction))
{
insert.Parameters.AddWithValue("@username", username);
await insert.ExecuteNonQueryAsync(cancellationToken);
}
const string selectSql = """
SELECT id
FROM app_users
WHERE username = @username
LIMIT 1;
""";
await using var select = new MySqlCommand(selectSql, connection, transaction);
select.Parameters.AddWithValue("@username", username);
var result = await select.ExecuteScalarAsync(cancellationToken);
return Convert.ToInt64(result, CultureInfo.InvariantCulture);
}
private static async Task<HashSet<long>> LoadUserGroupIdsAsync(
MySqlConnection connection,
MySqlTransaction transaction,
long userId,
CancellationToken cancellationToken)
{
const string sql = """
SELECT work_group_id
FROM app_user_groups
WHERE app_user_id = @userId;
""";
var result = new HashSet<long>();
await using var command = new MySqlCommand(sql, connection, transaction);
command.Parameters.AddWithValue("@userId", userId);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
result.Add(reader.GetInt64(0));
}
return result;
}
private static async Task DeleteMembershipAsync(
MySqlConnection connection,
MySqlTransaction transaction,
long userId,
long groupId,
CancellationToken cancellationToken)
{
const string sql = """
DELETE FROM app_user_groups
WHERE app_user_id = @userId
AND work_group_id = @groupId;
""";
await using var command = new MySqlCommand(sql, connection, transaction);
command.Parameters.AddWithValue("@userId", userId);
command.Parameters.AddWithValue("@groupId", groupId);
await command.ExecuteNonQueryAsync(cancellationToken);
}
private static async Task AddMembershipAsync(
MySqlConnection connection,
MySqlTransaction transaction,
long userId,
long groupId,
string changedByUsername,
CancellationToken cancellationToken)
{
const string sql = """
INSERT INTO app_user_groups (
app_user_id,
work_group_id,
assigned_by_username
) VALUES (
@userId,
@groupId,
@changedByUsername
)
ON DUPLICATE KEY UPDATE
assigned_by_username = VALUES(assigned_by_username);
""";
await using var command = new MySqlCommand(sql, connection, transaction);
command.Parameters.AddWithValue("@userId", userId);
command.Parameters.AddWithValue("@groupId", groupId);
command.Parameters.AddWithValue("@changedByUsername", changedByUsername?.Trim() ?? string.Empty);
await command.ExecuteNonQueryAsync(cancellationToken);
}
private static async Task AddHistoryAsync(
MySqlConnection connection,
MySqlTransaction transaction,
long userId,
long groupId,
string action,
string changedByUsername,
CancellationToken cancellationToken)
{
const string sql = """
INSERT INTO app_user_group_history (
app_user_id,
work_group_id,
action,
changed_by_username
) VALUES (
@userId,
@groupId,
@action,
@changedByUsername
);
""";
await using var command = new MySqlCommand(sql, connection, transaction);
command.Parameters.AddWithValue("@userId", userId);
command.Parameters.AddWithValue("@groupId", groupId);
command.Parameters.AddWithValue("@action", action);
command.Parameters.AddWithValue("@changedByUsername", changedByUsername?.Trim() ?? string.Empty);
await command.ExecuteNonQueryAsync(cancellationToken);
}
private async Task<MySqlConnection> OpenConnectionAsync(CancellationToken cancellationToken)
{
var connectionString = await _connectionStringProvider.GetConnectionStringAsync(cancellationToken);
var connection = new MySqlConnection(connectionString);
await connection.OpenAsync(cancellationToken);
await using var command = new MySqlCommand("SET time_zone = '+00:00';", connection);
await command.ExecuteNonQueryAsync(cancellationToken);
return connection;
}
private sealed record UserGroupBuilder(long UserId, string Username)
{
public HashSet<string> GroupCodes { get; } = new(StringComparer.OrdinalIgnoreCase);
}
}

View File

@@ -36,15 +36,21 @@
"Gestiona": { "Gestiona": {
"ApiBase": "https://02.g3stiona.com", "ApiBase": "https://02.g3stiona.com",
"AccessToken": "_yr.xVvPOllsyd1TYZRxUxg__c", "AccessToken": "_yr.xVvPOllsyd1TYZRxUxg__c",
"ExternalProcedureId": "82722c9b-cecc-4299-8a7b-ce5abeb8170b", "ProcedureName": "Procedimiento test 2",
"CircuitTemplateId": "bb997758-7436-46ab-9dc3-50dce2e02cfa", "ExternalProcedureName": "",
"CircuitSignerStampHref": "https://02.g3stiona.com/rest/organ-stamps/3c6eaab4-7fcd-4b21-8676-bf8719be5d36", "ExternalProcedureSiaCode": "3109963",
"ManagementUnitGroupCode": "700",
"CircuitTemplateName": "Firma automatizada",
"CircuitNewComplaintTemplateName": "CT-Nueva denuncia",
"CircuitUpdateSajTemplateName": "CT-Actualización denuncia SAJ",
"CircuitUpdateSdiTemplateName": "CT-Actualización denuncia SDI",
"CircuitCommunicationSajTemplateName": "CT-Comunicación SAJ a denunciante",
"CircuitCommunicationSdiTemplateName": "CT-Comunicación SDI a denunciante",
"CircuitSignerStampTitle": "oaaf-complaints-tramit", "CircuitSignerStampTitle": "oaaf-complaints-tramit",
"CircuitRecipientGroupHref": "https://02.g3stiona.com/rest/groups/454fa4ec-8b82-4240-9419-113f45d4b004",
"CircuitVersion": "2", "CircuitVersion": "2",
"UserLink": "https://02.g3stiona.com/rest/users/0c168833-8e27-4695-a301-b79924031f63", "DocumentMetadataLanguage": "es",
"GroupLink": "https://02.g3stiona.com/rest/groups/6dbfc433-1eb6-4b9a-a533-bfebc652c101", "DocumentMetadataType": "TD15",
"Location": "2.02.01" "DocumentMetadataSubtype": "TD15_011"
}, },
"GlobalLeaks": { "GlobalLeaks": {
"BaseUrl": "https://prebuzon.antifraudeandalucia.es", "BaseUrl": "https://prebuzon.antifraudeandalucia.es",

View File

@@ -308,7 +308,7 @@
</div> </div>
</div> </div>
<div class="row" style="max-height:415px; overflow-y:auto; overflow-x:hidden"> <div class="row">
<Tabs @ref=tabsDtsPer NavStyle="NavStyle.VerticalUnderline" Class="col-md-2" style="margin-top:25px; font-size:12px; gap:0; width:165px; overflow-y:auto" OnShown="@(args => OnTabShownAsync(args))"> <Tabs @ref=tabsDtsPer NavStyle="NavStyle.VerticalUnderline" Class="col-md-2" style="margin-top:25px; font-size:12px; gap:0; width:165px; overflow-y:auto" OnShown="@(args => OnTabShownAsync(args))">
<Tab Title="Nóminas" Name="tabNominas"> <Tab Title="Nóminas" Name="tabNominas">
<Content> <Content>

View File

@@ -15,7 +15,7 @@
@inject UserState UserState @inject UserState UserState
<div class="tablaTabLateral"> <div class="tablaTabLateral">
@* <input type="button" value="Nueva maternidad/riesgo de embarazo" @onclick="@(() => abrirPopupModificacion(new MATERNIDADES(), true))" class="mb-2 btnOAAFBlack" /> *@ <input type="button" value="Nueva diferencia Pago Delegado" @onclick="@(() => abrirPopupModificacion(new DIFERENCIAPAGODELEGADO(), true))" class="mb-2 btnOAAFBlack" />
<div style="overflow-x:auto;" class="tablaDesk"> <div style="overflow-x:auto;" class="tablaDesk">
<Grid TItem="DIFERENCIAPAGODELEGADO" <Grid TItem="DIFERENCIAPAGODELEGADO"
@@ -33,6 +33,9 @@
PaginationItemsTextFormat="{0} - {1} de {2} elementos"> PaginationItemsTextFormat="{0} - {1} de {2} elementos">
<GridColumns> <GridColumns>
<GridColumn TItem="DIFERENCIAPAGODELEGADO" HeaderText="">
<button @onclick="@(() => abrirPopupModificacion(context, false))" class="btnOAAFAzul">Editar</button>
</GridColumn>
<GridColumn TItem="DIFERENCIAPAGODELEGADO" HeaderText="Fecha Inicio"> <GridColumn TItem="DIFERENCIAPAGODELEGADO" HeaderText="Fecha Inicio">
@context.FECHAINICIO?.ToString("dd/MM/yyyy") @context.FECHAINICIO?.ToString("dd/MM/yyyy")
</GridColumn> </GridColumn>
@@ -50,8 +53,7 @@
</div> </div>
</div> </div>
<Modal @ref="popupGestionDatos" IsVerticallyCentered="true" UseStaticBackdrop="true" CloseOnEscape="false">
@* <Modal @ref="popupGestionDatos" IsVerticallyCentered="true" UseStaticBackdrop="true" CloseOnEscape="false">
<BodyTemplate> <BodyTemplate>
<div class="row"> <div class="row">
@@ -64,28 +66,19 @@
<input class="form-control" type="date" @bind-value="ItemEnEdicion.FECHAFIN" /> <input class="form-control" type="date" @bind-value="ItemEnEdicion.FECHAFIN" />
</div> </div>
<div class="col-md-12 mb-2"> <div class="col-md-12 mb-2">
<label for="txtEDesc" class="fw-bold">Base cotización seguridad social: </label> <label for="txtEDesc" class="fw-bold">Base Diaria Seguridad Social: </label>
<input class="form-control" type="number" @bind-value="@ItemEnEdicion.BASECOTIZACIONSEGURIDADSOCIAL" /> <input class="form-control" type="number" @bind-value="@ItemEnEdicion.BASEDIARIASEGURIDADSOCIAL" />
</div> </div>
<div class="col-md-12 mb-2"> <div class="col-md-12 mb-2">
<label for="txtEDesc" class="fw-bold">Porcentaje reducción jornada: </label> <label for="txtEDesc" class="fw-bold">Base Pago Directo: </label>
<input class="form-control" type="number" @bind-value="@ItemEnEdicion.PORCENTAJEREDUCCIONJORNADA" /> <input class="form-control" type="number" @bind-value="@ItemEnEdicion.BASEPAGODIRECTO" />
</div>
<div class="col-md-12" style="display:flex; justify-content:space-between">
<label for="txtEDesc" class="fw-bold">Riesgo embarazo: </label>
<input class="" type="checkbox" id="chbRiesgoEmbarazo" checked="@ItemEnEdicion.RIESGOEMBARAZO" />
<label for="txtEDesc" class="fw-bold">Nomina normal: </label>
<input class="" type="checkbox" id="chbNominaNormal" checked="@ItemEnEdicion.NOMINANORMAL" />
<label for="txtEDesc" class="fw-bold">Nomina seguridad social: </label>
<input class="" type="checkbox" id="chbNominaSS" checked="@ItemEnEdicion.NOMINASEGURIDADSOCIAL" />
</div> </div>
</div> </div>
</BodyTemplate> </BodyTemplate>
<FooterTemplate> <FooterTemplate>
<Button Color="ButtonColor.Secondary" @onclick="cerrarPopupModificacion">Cerrar</Button> <Button Color="ButtonColor.Secondary" @onclick="cerrarPopupModificacion">Cerrar</Button>
@if (ItemEnEdicion.IDMATERNIDADES != 0) @if (ItemEnEdicion.IDDIFERENCIAPAGODELEGADO != 0)
{ {
<Button Type="ButtonType.Submit" Color="ButtonColor.Primary" @onclick="@(() => GestionarDatos(false))">Modificar</Button> <Button Type="ButtonType.Submit" Color="ButtonColor.Primary" @onclick="@(() => GestionarDatos(false))">Modificar</Button>
} }
@@ -95,8 +88,6 @@
} }
</FooterTemplate> </FooterTemplate>
</Modal> </Modal>
*@
@@ -104,8 +95,10 @@
[Parameter] [Parameter]
public PERSONAS Persona { get; set; } = new PERSONAS(); public PERSONAS Persona { get; set; } = new PERSONAS();
private HttpClient cliente = new HttpClient(); private HttpClient cliente = new HttpClient();
private Modal popupGestionDatos = default;
[Parameter] [Parameter]
public EventCallback OnPersonaActualizada { get; set; } public EventCallback OnPersonaActualizada { get; set; }
private DIFERENCIAPAGODELEGADO ItemEnEdicion { get; set; } = new DIFERENCIAPAGODELEGADO();
// private List<int?> meses = new List<int?>(); // private List<int?> meses = new List<int?>();
private List<DIFERENCIAPAGODELEGADO> itmList = new List<DIFERENCIAPAGODELEGADO>(); private List<DIFERENCIAPAGODELEGADO> itmList = new List<DIFERENCIAPAGODELEGADO>();
@@ -129,5 +122,45 @@
Console.WriteLine($"Error al obtener los datos: {e.Message}"); Console.WriteLine($"Error al obtener los datos: {e.Message}");
} }
} }
private async Task cerrarPopupModificacion()
{
await popupGestionDatos.HideAsync();
}
private async Task GestionarDatos(bool tipo)
{
var inci = ItemEnEdicion;
if (tipo == true)
{
inci.IDDIFERENCIAPAGODELEGADO = 0;
}
inci.IDPERSONA = Persona.IDPERSONA;
if (inci.IDDIFERENCIAPAGODELEGADO != 0)
{
var response = await Utilidades.ActualizarObjeto(cliente, "/api/DIFERENCIAPAGODELEGADO/" + inci.IDDIFERENCIAPAGODELEGADO, inci);
}
else
{
var response = await Utilidades.NuevoObjeto(cliente, "/api/DIFERENCIAPAGODELEGADO/", inci);
}
await cerrarPopupModificacion();
var response1 = await cliente.GetAsync($"/api/PERSONAS/PersonaNominaNif/{Persona.NIF}");
if (!response1.IsSuccessStatusCode)
{
throw new Exception($"Error al obtener los datos de la persona. Código: {response1.StatusCode}");
}
var resultContent = await response1.Content.ReadAsStringAsync();
Persona = JsonConvert.DeserializeObject<PERSONAS>(resultContent) ?? throw new Exception("Error al deserializar los datos de la persona.");
await CargarListas();
}
private async Task abrirPopupModificacion(DIFERENCIAPAGODELEGADO objeto, bool esNuevo)
{
ItemEnEdicion = objeto;
await popupGestionDatos.ShowAsync();
}
} }

View File

@@ -15,6 +15,7 @@
@inject UserState UserState @inject UserState UserState
<div class="tablaTabLateral"> <div class="tablaTabLateral">
<input type="button" value="Nueva Huelga" @onclick="@(() => abrirPopupModificacion(new HUELGAS(), true))" class="mb-2 btnOAAFBlack" />
<div style="overflow-x:auto;" class="tablaDesk"> <div style="overflow-x:auto;" class="tablaDesk">
<Grid TItem="HUELGAS" <Grid TItem="HUELGAS"
Class="table tablaRegPers" Class="table tablaRegPers"
@@ -65,23 +66,158 @@
</Grid> </Grid>
</div> </div>
</div> </div>
<Modal @ref="popupGestionDatos" IsVerticallyCentered="true" UseStaticBackdrop="true" CloseOnEscape="false">
<BodyTemplate>
<div class="row">
<div class="col-md-6 mb-2">
<label class="fw-bold">Fecha inicio</label>
<input class="form-control" type="date" @bind-value="ItemEnEdicion.FECHAINICIO" />
</div>
<div class="col-md-6 mb-2">
<label class="fw-bold">Fecha fin</label>
<input class="form-control" type="date" @bind-value="ItemEnEdicion.FECHAFIN" />
</div>
<div class="col-md-12 mb-2">
<label for="txtEDesc" class="fw-bold">Nómina Origen de Datos: </label>
<input list="listOD" id="selCon" @bind-value="@itmNomOD" type="text" style="width:100%" class="form-control" placeholder="Nómina Origen de Datos" />
<datalist id="listOD">
@foreach (NOMINAS con in lNominas)
{
<option data-value="@con.IDNOMINAS">@con.DESCRIPCION</option>
}
</datalist>
</div>
<div class="col-md-12 mb-2">
<label for="txtEDesc" class="fw-bold">Nómina Aplicación: </label>
<input list="listCon" id="selCon" @bind-value="@itmNomAp" type="text" style="width:100%" class="form-control" placeholder="Nómina Aplicación" />
<datalist id="listCon">
@foreach (NOMINAS con in lNominas)
{
<option data-value="@con.IDNOMINAS">@con.DESCRIPCION</option>
}
</datalist>
</div>
<div class="col-md-12 mb-2">
<label for="txtEDesc" class="fw-bold">Observaciones: </label>
<input class="form-control" type="text" @bind-value="ItemEnEdicion.OBSERVACIONES" />
</div>
<div class="col-md-12" style="display:flex; justify-content:space-between">
<label for="txtEDesc" class="fw-bold">Nomina normal: </label>
<input class="" type="checkbox" id="chbNominaNormal" checked="@ItemEnEdicion.NOMINANORMAL" />
<label for="txtEDesc" class="fw-bold">Nomina seguridad social: </label>
<input class="" type="checkbox" id="chbNominaSS" checked="@ItemEnEdicion.NOMINASEGURIDADSOCIAL" />
</div>
</div>
</BodyTemplate>
<FooterTemplate>
<Button Color="ButtonColor.Secondary" @onclick="cerrarPopupModificacion">Cerrar</Button>
@if (ItemEnEdicion.IDHUELGA != 0)
{
<Button Type="ButtonType.Submit" Color="ButtonColor.Primary" @onclick="@(() => GestionarDatos(false))">Modificar</Button>
}
else
{
<Button Type="ButtonType.Submit" Color="ButtonColor.Primary" @onclick="@(() => GestionarDatos(true))">Crear</Button>
}
</FooterTemplate>
</Modal>
@code { @code {
[Parameter] [Parameter]
public PERSONAS Persona { get; set; } = new PERSONAS(); public PERSONAS Persona { get; set; } = new PERSONAS();
private Modal popupGestionDatos = default;
private HttpClient cliente = new HttpClient(); private HttpClient cliente = new HttpClient();
private string itmNomOD { get; set; }
private string itmNomAp { get; set; }
[Parameter] [Parameter]
public EventCallback OnPersonaActualizada { get; set; } public EventCallback OnPersonaActualizada { get; set; }
private List<HUELGAS> itmList = new List<HUELGAS>(); private List<HUELGAS> itmList = new List<HUELGAS>();
protected override async Task OnInitializedAsync() private List<NOMINAS> lNominas = new List<NOMINAS>();
private HUELGAS ItemEnEdicion { get; set; } = new HUELGAS();
protected override async Task OnInitializedAsync()
{ {
cliente = Utilidades.ObtenerCliente(UserState.Token, HttpClientFactory);
await CargarListas();
}
private async Task CargarListas()
{
itmList.Clear();
try try
{ {
var listnom = Persona.HUELGAS; var listnom = Persona.HUELGAS;
foreach (HUELGAS i in listnom) { itmList.Add(i); } foreach (HUELGAS i in listnom) { itmList.Add(i); }
var lNomi = await Utilidades.ObtenerObjeto<List<NOMINAS>>(cliente, "/api/NOMINAS");
lNominas = lNomi.OrderBy(x => x.FECHAINICIO).ToList();
} }
catch (Exception e) catch (Exception e)
{ {
Console.WriteLine($"Error al obtener los datos: {e.Message}"); Console.WriteLine($"Error al obtener los datos: {e.Message}");
} }
} }
private async Task GestionarDatos(bool tipo)
{
var inci = ItemEnEdicion;
inci.IDNOMINAAPLICACION = lNominas.FirstOrDefault(x => x.DESCRIPCION == itmNomAp).IDNOMINAS;
inci.IDNOMINAORIGENDATOS = lNominas.FirstOrDefault(x => x.DESCRIPCION == itmNomOD).IDNOMINAS;
if (tipo == true)
{
inci.IDHUELGA = 0;
}
inci.IDPERSONA = Persona.IDPERSONA;
string chbNominaNormal = "chbNominaNormal";
inci.NOMINANORMAL = await JS.InvokeAsync<bool>("obtenerCheck", chbNominaNormal);
string chbNominaSS = "chbNominaSS";
inci.NOMINASEGURIDADSOCIAL = await JS.InvokeAsync<bool>("obtenerCheck", chbNominaSS);
if (inci.IDHUELGA != 0)
{
var response = await Utilidades.ActualizarObjeto(cliente, "/api/HUELGAS/" + inci.IDHUELGA, inci);
}
else
{
var response = await Utilidades.NuevoObjeto(cliente, "/api/HUELGAS/", inci);
}
await cerrarPopupModificacion();
var response1 = await cliente.GetAsync($"/api/PERSONAS/PersonaNominaNif/{Persona.NIF}");
if (!response1.IsSuccessStatusCode)
{
throw new Exception($"Error al obtener los datos de la persona. Código: {response1.StatusCode}");
}
var resultContent = await response1.Content.ReadAsStringAsync();
Persona = JsonConvert.DeserializeObject<PERSONAS>(resultContent) ?? throw new Exception("Error al deserializar los datos de la persona.");
await CargarListas();
}
private async Task abrirPopupModificacion(HUELGAS objeto, bool esNuevo)
{
if (objeto.IDHUELGA != 0)
{
itmNomOD = objeto.IDNOMINAORIGENDATOSNavigation.DESCRIPCION;
itmNomAp = objeto.IDNOMINAAPLICACIONNavigation?.DESCRIPCION;
}
else
{
itmNomAp = "";
itmNomOD = "";
}
ItemEnEdicion = objeto;
await popupGestionDatos.ShowAsync();
}
private async Task cerrarPopupModificacion()
{
await popupGestionDatos.HideAsync();
}
} }

View File

@@ -15,6 +15,7 @@
@inject UserState UserState @inject UserState UserState
<div class="tablaTabLateral"> <div class="tablaTabLateral">
<input type="button" value="Nuevo Permiso sin retribución" @onclick="@(() => abrirPopupModificacion(new PERMISOSSINRETRIBUCION(), true))" class="mb-2 btnOAAFBlack" />
<div style="overflow-x:auto;" class="tablaDesk"> <div style="overflow-x:auto;" class="tablaDesk">
<Grid TItem="PERMISOSSINRETRIBUCION" <Grid TItem="PERMISOSSINRETRIBUCION"
Class="table tablaRegPers" Class="table tablaRegPers"
@@ -31,6 +32,9 @@
PaginationItemsTextFormat="{0} - {1} de {2} elementos"> PaginationItemsTextFormat="{0} - {1} de {2} elementos">
<GridColumns> <GridColumns>
<GridColumn TItem="PERMISOSSINRETRIBUCION" HeaderText="">
<button @onclick="@(() => abrirPopupModificacion(context, false))" class="btnOAAFAzul">Editar</button>
</GridColumn>
<GridColumn TItem="PERMISOSSINRETRIBUCION" HeaderText="Fecha Inicio"> <GridColumn TItem="PERMISOSSINRETRIBUCION" HeaderText="Fecha Inicio">
@context.FECHAINICIO?.ToString("dd/MM/yyyy") @context.FECHAINICIO?.ToString("dd/MM/yyyy")
</GridColumn> </GridColumn>
@@ -62,24 +66,162 @@
</Grid> </Grid>
</div> </div>
</div> </div>
<Modal @ref="popupGestionDatos" IsVerticallyCentered="true" UseStaticBackdrop="true" CloseOnEscape="false">
<BodyTemplate>
<div class="row">
<div class="col-md-6 mb-2">
<label class="fw-bold">Fecha inicio</label>
<input class="form-control" type="date" @bind-value="ItemEnEdicion.FECHAINICIO" />
</div>
<div class="col-md-6 mb-2">
<label class="fw-bold">Fecha fin</label>
<input class="form-control" type="date" @bind-value="ItemEnEdicion.FECHAFIN" />
</div>
<div class="col-md-12 mb-2">
<label for="txtEDesc" class="fw-bold">Nómina Origen de Datos: </label>
<input list="listOD" id="selCon" @bind-value="@itmNomOD" type="text" style="width:100%" class="form-control" placeholder="Nómina Origen de Datos" />
<datalist id="listOD">
@foreach (NOMINAS con in lNominas)
{
<option data-value="@con.IDNOMINAS">@con.DESCRIPCION</option>
}
</datalist>
</div>
<div class="col-md-12 mb-2">
<label for="txtEDesc" class="fw-bold">Nómina Aplicación: </label>
<input list="listCon" id="selCon" @bind-value="@itmNomAp" type="text" style="width:100%" class="form-control" placeholder="Nómina Aplicación" />
<datalist id="listCon">
@foreach (NOMINAS con in lNominas)
{
<option data-value="@con.IDNOMINAS">@con.DESCRIPCION</option>
}
</datalist>
</div>
<div class="col-md-12 mb-2">
<label for="txtEDesc" class="fw-bold">Observaciones: </label>
<input class="form-control" type="text" @bind-value="ItemEnEdicion.OBSERVACIONES" />
</div>
<div class="col-md-12" style="display:flex; justify-content:space-between">
<label for="txtEDesc" class="fw-bold">Nomina normal: </label>
<input class="" type="checkbox" id="chbNominaNormal" checked="@ItemEnEdicion.NOMINANORMAL" />
<label for="txtEDesc" class="fw-bold">Nomina seguridad social: </label>
<input class="" type="checkbox" id="chbNominaSS" checked="@ItemEnEdicion.NOMINASEGURIDADSOCIAL" />
</div>
</div>
</BodyTemplate>
<FooterTemplate>
<Button Color="ButtonColor.Secondary" @onclick="cerrarPopupModificacion">Cerrar</Button>
@if (ItemEnEdicion.IDPERMISOSDERETRIBUCION != 0)
{
<Button Type="ButtonType.Submit" Color="ButtonColor.Primary" @onclick="@(() => GestionarDatos(false))">Modificar</Button>
}
else
{
<Button Type="ButtonType.Submit" Color="ButtonColor.Primary" @onclick="@(() => GestionarDatos(true))">Crear</Button>
}
</FooterTemplate>
</Modal>
@code { @code {
[Parameter] [Parameter]
public PERSONAS Persona { get; set; } = new PERSONAS(); public PERSONAS Persona { get; set; } = new PERSONAS();
private HttpClient cliente = new HttpClient(); private HttpClient cliente = new HttpClient();
private Modal popupGestionDatos = default;
private string itmNomOD { get; set; }
private string itmNomAp { get; set; }
[Parameter] [Parameter]
public EventCallback OnPersonaActualizada { get; set; } public EventCallback OnPersonaActualizada { get; set; }
private PERMISOSSINRETRIBUCION ItemEnEdicion { get; set; } = new PERMISOSSINRETRIBUCION();
private List<PERMISOSSINRETRIBUCION> itmList = new List<PERMISOSSINRETRIBUCION>(); private List<PERMISOSSINRETRIBUCION> itmList = new List<PERMISOSSINRETRIBUCION>();
private List<NOMINAS> lNominas = new List<NOMINAS>();
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
cliente = Utilidades.ObtenerCliente(UserState.Token, HttpClientFactory);
await CargarListas();
}
private async Task CargarListas()
{
itmList.Clear();
try try
{ {
var listnom = Persona.PERMISOSSINRETRIBUCION; var listnom = Persona.PERMISOSSINRETRIBUCION;
foreach (PERMISOSSINRETRIBUCION i in listnom) { itmList.Add(i); } foreach (PERMISOSSINRETRIBUCION i in listnom) { itmList.Add(i); }
var lNomi = await Utilidades.ObtenerObjeto<List<NOMINAS>>(cliente, "/api/NOMINAS");
lNominas = lNomi.OrderBy(x => x.FECHAINICIO).ToList();
} }
catch (Exception e) catch (Exception e)
{ {
Console.WriteLine($"Error al obtener los datos: {e.Message}"); Console.WriteLine($"Error al obtener los datos: {e.Message}");
} }
} }
private async Task GestionarDatos(bool tipo)
{
var inci = ItemEnEdicion;
inci.IDNOMINAAPLICACION = lNominas.FirstOrDefault(x => x.DESCRIPCION == itmNomAp).IDNOMINAS;
inci.IDNOMINAORIGENDEDATOS = lNominas.FirstOrDefault(x => x.DESCRIPCION == itmNomOD).IDNOMINAS;
if (tipo == true)
{
inci.IDPERMISOSDERETRIBUCION = 0;
}
inci.IDPERSONA = Persona.IDPERSONA;
string chbNominaNormal = "chbNominaNormal";
inci.NOMINANORMAL = await JS.InvokeAsync<bool>("obtenerCheck", chbNominaNormal);
string chbNominaSS = "chbNominaSS";
inci.NOMINASEGURIDADSOCIAL = await JS.InvokeAsync<bool>("obtenerCheck", chbNominaSS);
if (inci.IDPERMISOSDERETRIBUCION != 0)
{
var response = await Utilidades.ActualizarObjeto(cliente, "/api/PERMISOSSINRETRIBUCION/" + inci.IDPERMISOSDERETRIBUCION, inci);
}
else
{
var response = await Utilidades.NuevoObjeto(cliente, "/api/PERMISOSSINRETRIBUCION/", inci);
}
await cerrarPopupModificacion();
var response1 = await cliente.GetAsync($"/api/PERSONAS/PersonaNominaNif/{Persona.NIF}");
if (!response1.IsSuccessStatusCode)
{
throw new Exception($"Error al obtener los datos de la persona. Código: {response1.StatusCode}");
}
var resultContent = await response1.Content.ReadAsStringAsync();
Persona = JsonConvert.DeserializeObject<PERSONAS>(resultContent) ?? throw new Exception("Error al deserializar los datos de la persona.");
await CargarListas();
}
private async Task abrirPopupModificacion(PERMISOSSINRETRIBUCION objeto, bool esNuevo)
{
if(objeto.IDPERMISOSDERETRIBUCION != 0)
{
itmNomOD = objeto.IDNOMINAORIGENDEDATOSNavigation?.DESCRIPCION;
itmNomAp = objeto.IDNOMINAAPLICACIONNavigation?.DESCRIPCION;
}
else
{
itmNomAp = "";
itmNomOD = "";
}
ItemEnEdicion = objeto;
await popupGestionDatos.ShowAsync();
}
private async Task cerrarPopupModificacion()
{
await popupGestionDatos.HideAsync();
}
} }

View File

@@ -1,5 +1,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization;
using System.IO; using System.IO;
using System.Text; using System.Text;
using PdfSharpCore.Drawing; using PdfSharpCore.Drawing;
@@ -10,14 +11,30 @@ namespace GestionaDenuncias.Shared.Helpers
{ {
public static class PdfHelper public static class PdfHelper
{ {
private const double ReportTextMarginLeft = 95;
private const double ReportPdfSignatureGutter = 70;
private const double ReportPdfRightMargin = 20;
/// <summary> /// <summary>
/// Fusiona varios ficheros (PDF, im<EFBFBD>genes, TXT) en un <EFBFBD>nico PDF. /// Fusiona varios ficheros (PDF, imágenes, TXT) en un único PDF.
/// Los .txt se renderizan con m<EFBFBD>rgenes iguales, alineaci<EFBFBD>n a la izquierda y ajuste de l<EFBFBD>neas, /// Los .txt se renderizan con márgenes iguales, alineación a la izquierda y ajuste de líneas,
/// preservando l<EFBFBD>neas en blanco. /// preservando líneas en blanco.
/// </summary> /// </summary>
/// <param name="files">Secuencia de tuplas (FileName, ContentBytes)</param> /// <param name="files">Secuencia de tuplas (FileName, ContentBytes)</param>
/// <returns>Bytes del PDF combinado</returns> /// <returns>Bytes del PDF combinado</returns>
public static byte[] MergeFilesToPdf(IEnumerable<(string FileName, byte[] Content)> files) public static byte[] MergeFilesToPdf(IEnumerable<(string FileName, byte[] Content)> files)
=> MergeFilesToPdf(files, textMarginLeft: 40, insetPdfPagesForSignature: false);
public static byte[] MergeReportToPdf(IEnumerable<(string FileName, byte[] Content)> files)
=> MergeFilesToPdf(
files,
textMarginLeft: ReportTextMarginLeft,
insetPdfPagesForSignature: true);
private static byte[] MergeFilesToPdf(
IEnumerable<(string FileName, byte[] Content)> files,
double textMarginLeft,
bool insetPdfPagesForSignature)
{ {
if (files == null) throw new ArgumentNullException(nameof(files)); if (files == null) throw new ArgumentNullException(nameof(files));
@@ -33,8 +50,16 @@ namespace GestionaDenuncias.Shared.Helpers
{ {
case ".pdf": case ".pdf":
using (var src = PdfReader.Open(new MemoryStream(content), PdfDocumentOpenMode.Import)) using (var src = PdfReader.Open(new MemoryStream(content), PdfDocumentOpenMode.Import))
{
foreach (var page in src.Pages) foreach (var page in src.Pages)
outputDoc.AddPage(page); {
var importedPage = outputDoc.AddPage(page);
if (insetPdfPagesForSignature)
{
InsetPdfPageForSignature(importedPage);
}
}
}
break; break;
case ".jpg": case ".jpg":
@@ -52,12 +77,12 @@ namespace GestionaDenuncias.Shared.Helpers
break; break;
case ".txt": case ".txt":
// Renderizado de TXT con margen y ajuste de l<EFBFBD>neas, preservando l<EFBFBD>neas en blanco // Renderizado de TXT con margen y ajuste de líneas, preservando líneas en blanco
var text = Encoding.UTF8.GetString(content); var text = Encoding.UTF8.GetString(content);
PdfPage pageTxt = outputDoc.AddPage(); PdfPage pageTxt = outputDoc.AddPage();
XGraphics gfxTxt = XGraphics.FromPdfPage(pageTxt); XGraphics gfxTxt = XGraphics.FromPdfPage(pageTxt);
const double marginLeft = 40; var marginLeft = textMarginLeft;
const double marginRight = 40; const double marginRight = 40;
const double marginTop = 40; const double marginTop = 40;
const double marginBottom = 40; const double marginBottom = 40;
@@ -72,7 +97,7 @@ namespace GestionaDenuncias.Shared.Helpers
foreach (var origLine in text.Replace("\r\n", "\n").Replace('\r', '\n').Split('\n')) foreach (var origLine in text.Replace("\r\n", "\n").Replace('\r', '\n').Split('\n'))
{ {
// L<EFBFBD>nea en blanco: preservarla // Línea en blanco: preservarla
if (string.IsNullOrWhiteSpace(origLine)) if (string.IsNullOrWhiteSpace(origLine))
{ {
y += lineHeight; y += lineHeight;
@@ -99,7 +124,7 @@ namespace GestionaDenuncias.Shared.Helpers
} }
else else
{ {
// Dibujar la l<EFBFBD>nea acumulada // Dibujar la línea acumulada
gfxTxt.DrawString( gfxTxt.DrawString(
currentLine, currentLine,
font, font,
@@ -109,7 +134,7 @@ namespace GestionaDenuncias.Shared.Helpers
y += lineHeight; y += lineHeight;
currentLine = word; currentLine = word;
// Paginaci<EFBFBD>n si se sale por abajo // Paginación si se sale por abajo
if (y + lineHeight > pageHeight - marginBottom) if (y + lineHeight > pageHeight - marginBottom)
{ {
gfxTxt.Dispose(); gfxTxt.Dispose();
@@ -120,7 +145,7 @@ namespace GestionaDenuncias.Shared.Helpers
} }
} }
// Dibujar la <EFBFBD>ltima l<EFBFBD>nea del p<EFBFBD>rrafo // Dibujar la última línea del párrafo
if (!string.IsNullOrEmpty(currentLine)) if (!string.IsNullOrEmpty(currentLine))
{ {
gfxTxt.DrawString( gfxTxt.DrawString(
@@ -145,7 +170,7 @@ namespace GestionaDenuncias.Shared.Helpers
break; break;
default: default:
throw new NotSupportedException($"Extensi<EFBFBD>n no soportada: {ext}"); throw new NotSupportedException($"Extensión no soportada: {ext}");
} }
} }
@@ -154,7 +179,24 @@ namespace GestionaDenuncias.Shared.Helpers
return ms.ToArray(); return ms.ToArray();
} }
private static void InsetPdfPageForSignature(PdfPage page)
{
var pageWidth = page.Width.Point;
var availableWidth = Math.Max(1, pageWidth - ReportPdfSignatureGutter - ReportPdfRightMargin);
var scaleX = Math.Min(1, availableWidth / pageWidth);
var command = string.Create(
CultureInfo.InvariantCulture,
$"q {scaleX:0.#####} 0 0 1 {ReportPdfSignatureGutter:0.#####} 0 cm\n");
var prepend = page.Contents.PrependContent();
prepend.Stream = prepend.CreateStream(Encoding.ASCII.GetBytes(command));
var append = page.Contents.AppendContent();
append.Stream = append.CreateStream(Encoding.ASCII.GetBytes("\nQ\n"));
}
} }
} }

View File

@@ -19,7 +19,9 @@ public sealed record InboxSnapshotResponse(
IReadOnlyList<ReportDto> Reports, IReadOnlyList<ReportDto> Reports,
InboxUserState UserState); InboxUserState UserState);
public sealed record ImportReportRequest(ReportDto Report); public sealed record ImportReportRequest(
ReportDto Report,
bool ConfirmDifferentOwner = false);
public sealed record MarkFicherosUploadedRequest( public sealed record MarkFicherosUploadedRequest(
IReadOnlyList<string> FileNames, IReadOnlyList<string> FileNames,
@@ -36,12 +38,17 @@ public sealed record MarkReportImportedRequest(
ReportDto Report, ReportDto Report,
int? ComplaintId); int? ComplaintId);
public sealed record MarkReportHandledInGestionaRequest(
string Username,
int DenunciaId,
DateTime UploadedAtUtc);
public sealed record TrackingImportPermissionRequest( public sealed record TrackingImportPermissionRequest(
string Username, string Username,
ReportDto Report); ReportDto Report,
bool ConfirmDifferentOwner = false);
public sealed record GestionaCreateFileRequest( public sealed record GestionaCreateFileRequest(
Guid ProcedureId,
string Subject, string Subject,
string DocumentSeries, string DocumentSeries,
string SiaCode); string SiaCode);
@@ -53,16 +60,22 @@ public sealed record GestionaCreateFileResponse(
public sealed record GestionaOpenFileRequest( public sealed record GestionaOpenFileRequest(
string FileUrl, string FileUrl,
string? FileOpenUrl, string? FileOpenUrl,
Guid ManagementUnitGroupId, string AssignedGroupCode,
Guid AssignedGroupId,
bool Confidential, bool Confidential,
string FreeTitle, string FreeTitle);
string SiaCode);
public sealed record GestionaAssignFileRequest(
string FileUrl,
string AssignedGroupCode);
public sealed record GestionaEnsureThirdRequest( public sealed record GestionaEnsureThirdRequest(
string FileUrl, string FileUrl,
ThirdPartyIdentityData ThirdParty); ThirdPartyIdentityData ThirdParty);
public sealed record GestionaEnsureThirdResponse(
bool Ok,
IReadOnlyList<string> Warnings);
public sealed record GestionaCreateFolderRequest( public sealed record GestionaCreateFolderRequest(
string FileUrl, string FileUrl,
string FolderName); string FolderName);
@@ -80,8 +93,10 @@ public sealed record GestionaUploadDocumentResponse(string DocumentUrl);
public sealed record GestionaTramitarDocumentoRequest( public sealed record GestionaTramitarDocumentoRequest(
string DocumentUrl, string DocumentUrl,
string AssignedGroupHref, string AssignedGroupCode,
int? ComplaintId); int? ComplaintId,
bool IsUpdate = false,
string? UpdateSource = null);
public sealed record ManualPurgeRequest(string Date); public sealed record ManualPurgeRequest(string Date);
@@ -91,6 +106,50 @@ public sealed record ManualPurgeResponse(
int StatusCode, int StatusCode,
string ResponseBody); string ResponseBody);
public sealed record AppConfigurationDto(string? ExternalUpdateCutoffDate); public sealed record AppConfigurationDto(
string? ExternalUpdateCutoffDate,
string? LatestEncryptionKeyDate = null,
string? LatestEncryptionKeyStatus = null);
public sealed record UpdateExternalUpdateCutoffRequest(string? Date); public sealed record UpdateExternalUpdateCutoffRequest(string? Date);
public sealed record WorkGroupDto(
string Code,
string Name);
public sealed record UserWorkGroupDto(
long UserId,
string Username,
IReadOnlyList<string> GroupCodes);
public sealed record WorkGroupAdministrationDto(
IReadOnlyList<WorkGroupDto> Groups,
IReadOnlyList<UserWorkGroupDto> Users);
public sealed record CurrentUserWorkGroupsDto(
string Username,
IReadOnlyList<string> GroupCodes);
public sealed record UpdateUserWorkGroupsRequest(
IReadOnlyList<string> GroupCodes);
public sealed record GestionaComplaintFieldsResponse(
DateTime? FechaDenuncia,
int NumeroDenunciaCanal,
string AQuienDenuncia,
string ResumenDenuncia,
string FechaHechos,
string LugarHechos,
string AmbitoCompetencias,
string SolicitaProteccion,
string SexoDenunciante,
string AutorizaRemisionDenuncia,
string AutorizaNotificacionesViaSms,
string PreferenciaNotificacionSeguimientoDenuncia);
public sealed record GestionaExternalFieldValue(
string Type,
string Value);
public sealed record GestionaExternalFieldsResponse(
IReadOnlyDictionary<string, GestionaExternalFieldValue> Data);

View File

@@ -0,0 +1,13 @@
namespace GestionaDenuncias.Shared.Models;
public static class ComplaintUpdateSources
{
public const string Citizen = "citizen";
public const string Receiver = "receiver";
public static bool IsCitizen(string? value)
=> string.Equals(value, Citizen, StringComparison.OrdinalIgnoreCase);
public static bool IsReceiver(string? value)
=> string.Equals(value, Receiver, StringComparison.OrdinalIgnoreCase);
}

View File

@@ -77,10 +77,18 @@ public class DenunciasGestiona
public string EstadoDenuncia { get; set; } = string.Empty; public string EstadoDenuncia { get; set; } = string.Empty;
public string ArchivoElegido { get; set; } = string.Empty; public string ArchivoElegido { get; set; } = string.Empty;
public DateTime FechaSubidaAGestiona { get; set; } = DateTime.MinValue; public DateTime FechaSubidaAGestiona { get; set; } = DateTime.MinValue;
public string UltimaSubidaGestionaTipo { get; set; } = string.Empty;
public string UltimoGrupoAsignadoGestiona { get; set; } = string.Empty;
public string PendingUpdateSource { get; set; } = string.Empty;
public bool EnGestiona { get; set; } public bool EnGestiona { get; set; }
public bool EnRechazada { get; set; } public bool EnRechazada { get; set; }
public string OwnerUsername { get; set; } = string.Empty;
public bool OwnedByCurrentUser { get; set; }
public bool RequiresOwnerConfirmation { get; set; }
public IReadOnlyList<string> OwnerWorkGroups { get; set; } = [];
[JsonIgnore] [JsonIgnore]
public DateOnly? KeyDate { get; set; } public DateOnly? KeyDate { get; set; }
@@ -185,6 +193,18 @@ public class DenunciasGestiona
} }
} }
[JsonIgnore]
public string UltimaSubidaGestionaTipoMostrable =>
string.IsNullOrWhiteSpace(UltimaSubidaGestionaTipo)
? string.Empty
: UltimaSubidaGestionaTipo.Trim();
[JsonIgnore]
public string UltimoGrupoAsignadoGestionaMostrable =>
string.IsNullOrWhiteSpace(UltimoGrupoAsignadoGestiona)
? string.Empty
: UltimoGrupoAsignadoGestiona.Trim();
public IReadOnlyList<ReportFieldEntry> GetCamposFormulario() public IReadOnlyList<ReportFieldEntry> GetCamposFormulario()
{ {
if (string.IsNullOrWhiteSpace(CamposFormularioJson)) if (string.IsNullOrWhiteSpace(CamposFormularioJson))

View File

@@ -5,7 +5,10 @@ namespace GestionaDenuncias.Shared.Models
public string FileUrl { get; set; } = ""; public string FileUrl { get; set; } = "";
public string? CodigoExpediente { get; set; } // name / code / etc. public string? CodigoExpediente { get; set; } // name / code / etc.
public string? Asunto { get; set; } // subject / free_title public string? Asunto { get; set; } // subject / free_title
public string? Procedimiento { get; set; }
public DateTimeOffset? FechaCreacion { get; set; } public DateTimeOffset? FechaCreacion { get; set; }
public DateTimeOffset? FechaUltimaModificacion { get; set; }
public string? UltimaAuditoriaMensaje { get; set; }
public string? Estado { get; set; } public string? Estado { get; set; }
} }
} }

View File

@@ -0,0 +1,7 @@
namespace GestionaDenuncias.Shared.Models;
public sealed class GestionaAuditInfo
{
public DateTimeOffset? Fecha { get; set; }
public string? Mensaje { get; set; }
}

View File

@@ -0,0 +1,23 @@
namespace GestionaDenuncias.Shared.Models;
public sealed record GestionaUploadHistoryEntry(
long Id,
int DenunciaId,
string ExpedienteGestionaUrl,
string CodigoExpedienteGestiona,
string TipoSubida,
string GrupoAsignado,
string Usuario,
DateTime UploadedAtUtc,
string Asunto,
string Documentos);
public sealed record GestionaUploadHistoryCreateRequest(
int DenunciaId,
string ExpedienteGestionaUrl,
string CodigoExpedienteGestiona,
string TipoSubida,
string GrupoAsignado,
DateTime UploadedAtUtc,
string Asunto,
string Documentos);

View File

@@ -1,3 +1,11 @@
namespace GestionaDenuncias.Shared.Models; namespace GestionaDenuncias.Shared.Models;
public sealed record GlSession(string Id, string Username, string? Role = null); public sealed record GlobalLeaksProofOfWorkToken(string Id, string Salt);
public sealed record GlSession(
string Id,
string Username,
string? Role = null,
string? DpopPrivateKey = null,
GlobalLeaksProofOfWorkToken? ProofOfWorkToken = null,
DateTimeOffset? SessionExpiresAtUtc = null);

View File

@@ -6,7 +6,15 @@ public sealed class GlobalLeaksStoredSession
public string Password { get; set; } = string.Empty; public string Password { get; set; } = string.Empty;
public string? SessionId { get; set; } public string? SessionId { get; set; }
public string? Role { get; set; } public string? Role { get; set; }
public string? DpopPrivateKey { get; set; }
public GlobalLeaksProofOfWorkToken? ProofOfWorkToken { get; set; }
public DateTimeOffset? SessionExpiresAtUtc { get; set; }
public DateTimeOffset? LastKeepAliveAtUtc { get; set; }
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
public bool HasActiveSession => !string.IsNullOrWhiteSpace(SessionId); public bool HasActiveSession =>
!string.IsNullOrWhiteSpace(SessionId) &&
!string.IsNullOrWhiteSpace(DpopPrivateKey) &&
!string.IsNullOrWhiteSpace(ProofOfWorkToken?.Id) &&
!string.IsNullOrWhiteSpace(ProofOfWorkToken?.Salt);
} }

View File

@@ -5,14 +5,17 @@ public sealed record ReportDetailDto(
string? LastAccess, string? LastAccess,
IReadOnlyList<ReportCommentDto> Comments, IReadOnlyList<ReportCommentDto> Comments,
IReadOnlyList<ReportFileDto> WhistleblowerFiles, IReadOnlyList<ReportFileDto> WhistleblowerFiles,
IReadOnlyList<ReportFileDto> ReceiverFiles); IReadOnlyList<ReportFileDto> ReceiverFiles,
IReadOnlyList<ReportReceiverDto>? Receivers = null);
public sealed record ReportCommentDto( public sealed record ReportCommentDto(
string? Id, string? Id,
string? Type, string? Type,
string? Content, string? Content,
string? CreationDate, string? CreationDate,
bool IsNew); bool IsNew,
string? AuthorId = null,
string? AuthorName = null);
public sealed record ReportFileDto( public sealed record ReportFileDto(
string? Id, string? Id,
@@ -20,4 +23,11 @@ public sealed record ReportFileDto(
long? Size, long? Size,
string? ContentType, string? ContentType,
string? CreationDate, string? CreationDate,
bool IsNew); bool IsNew,
string? AuthorId = null,
string? AuthorName = null);
public sealed record ReportReceiverDto(
string Id,
string Name,
bool Active);

View File

@@ -12,14 +12,31 @@ public sealed record ReportDto
public string? ReminderDate { get; init; } public string? ReminderDate { get; init; }
public string? AccessDate { get; init; } public string? AccessDate { get; init; }
public string? LastAccess { get; init; } public string? LastAccess { get; init; }
public string? WhistleblowerLastAccess { get; init; }
public string? Status { get; init; } public string? Status { get; init; }
public bool Updated { get; init; } public bool Updated { get; init; }
public bool? Accessible { get; init; }
public string? Label { get; init; } public string? Label { get; init; }
public bool ActivityAnalyzed { get; init; }
public string? CitizenLastActivity { get; init; }
public bool CitizenHasNewActivity { get; init; }
public bool CitizenHasNewComment { get; init; }
public bool CitizenHasNewFile { get; init; }
public string? ReceiverLastActivity { get; init; }
public bool ReceiverHasNewActivity { get; init; }
public string? ReceiverLastActivityAuthorId { get; init; }
public string? ReceiverLastActivityAuthorName { get; init; }
public bool DownloadedByCurrentUser { get; init; } public bool DownloadedByCurrentUser { get; init; }
public bool DownloadedByAnotherUser { get; init; } public bool DownloadedByAnotherUser { get; init; }
public string? LastDownloadedByUsername { get; init; } public string? LastDownloadedByUsername { get; init; }
public string? LastDownloadedAt { get; init; } public string? LastDownloadedAt { get; init; }
public string? LastGestionaUploadAt { get; init; }
public bool AlreadyImported { get; init; } public bool AlreadyImported { get; init; }
public bool AlreadyInGestiona { get; init; } public bool AlreadyInGestiona { get; init; }
public string? OwnerUsername { get; init; }
public bool OwnedByCurrentUser { get; init; }
public bool AccessibleByWorkGroup { get; init; }
public bool RequiresOwnerConfirmation { get; init; }
public IReadOnlyList<string> OwnerWorkGroups { get; init; } = [];
public string? TrackingNote { get; init; } public string? TrackingNote { get; init; }
} }

View File

@@ -12,6 +12,9 @@ public sealed class ThirdPartyIdentityData
public string BusinessName { get; set; } = string.Empty; public string BusinessName { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty; public string Email { get; set; } = string.Empty;
public string CountryCode { get; set; } = string.Empty; public string CountryCode { get; set; } = string.Empty;
public string NotificationPreference { get; set; } = string.Empty;
public string ElectronicNotification { get; set; } = string.Empty;
public string PostalNotificationPreference { get; set; } = string.Empty;
public ThirdPartyAddressData? Address { get; set; } public ThirdPartyAddressData? Address { get; set; }
public string DisplayName => public string DisplayName =>
@@ -53,6 +56,9 @@ public sealed class ThirdPartyIdentityData
BusinessName = isAnonymous ? string.Empty : businessName, BusinessName = isAnonymous ? string.Empty : businessName,
Email = (denuncia.Correo_Electronico ?? string.Empty).Trim(), Email = (denuncia.Correo_Electronico ?? string.Empty).Trim(),
CountryCode = string.IsNullOrWhiteSpace(denuncia.Pais) ? denuncia.PaisOrigen : denuncia.Pais, CountryCode = string.IsNullOrWhiteSpace(denuncia.Pais) ? denuncia.PaisOrigen : denuncia.Pais,
NotificationPreference = denuncia.Notificacion_Preferencia ?? string.Empty,
ElectronicNotification = denuncia.Notificacion_Electronica ?? string.Empty,
PostalNotificationPreference = denuncia.NotificacionPostal ?? string.Empty,
Address = isAnonymous ? null : ThirdPartyAddressData.FromComplaint(denuncia) Address = isAnonymous ? null : ThirdPartyAddressData.FromComplaint(denuncia)
}; };
} }

View File

@@ -9,9 +9,14 @@ public interface IDenunciaStore
Task<List<DenunciasGestiona>> GetDenunciasByScopeAsync(DenunciaListScope scope, CancellationToken cancellationToken = default); Task<List<DenunciasGestiona>> GetDenunciasByScopeAsync(DenunciaListScope scope, CancellationToken cancellationToken = default);
Task<List<FicherosDenuncias>> GetAllFicherosAsync(CancellationToken cancellationToken = default); Task<List<FicherosDenuncias>> GetAllFicherosAsync(CancellationToken cancellationToken = default);
Task<List<FicherosDenuncias>> GetFicherosByDenunciaAsync(int denunciaId, CancellationToken cancellationToken = default); Task<List<FicherosDenuncias>> GetFicherosByDenunciaAsync(int denunciaId, CancellationToken cancellationToken = default);
Task<List<GestionaUploadHistoryEntry>> GetGestionaUploadHistoryAsync(CancellationToken cancellationToken = default);
Task<DenunciasGestiona?> GetDenunciaByIdAsync(int denunciaId, CancellationToken cancellationToken = default); Task<DenunciasGestiona?> GetDenunciaByIdAsync(int denunciaId, CancellationToken cancellationToken = default);
Task UpsertDenunciaAsync(DenunciasGestiona denuncia, CancellationToken cancellationToken = default); Task UpsertDenunciaAsync(DenunciasGestiona denuncia, CancellationToken cancellationToken = default);
Task UpsertFicherosAsync(IEnumerable<FicherosDenuncias> ficheros, CancellationToken cancellationToken = default); Task UpsertFicherosAsync(IEnumerable<FicherosDenuncias> ficheros, CancellationToken cancellationToken = default);
Task AddGestionaUploadHistoryAsync(
GestionaUploadHistoryCreateRequest request,
string username,
CancellationToken cancellationToken = default);
Task MarkFicherosAsUploadedAsync( Task MarkFicherosAsUploadedAsync(
int denunciaId, int denunciaId,
IEnumerable<string> fileNames, IEnumerable<string> fileNames,

View File

@@ -15,8 +15,15 @@ public interface IInboxTrackingService
int? complaintId, int? complaintId,
CancellationToken cancellationToken = default); CancellationToken cancellationToken = default);
Task MarkReportHandledInGestionaAsync(
string username,
int denunciaId,
DateTime uploadedAtUtc,
CancellationToken cancellationToken = default);
Task EnsureReportCanBeImportedByUserAsync( Task EnsureReportCanBeImportedByUserAsync(
string username, string username,
ReportDto report, ReportDto report,
bool confirmDifferentOwner = false,
CancellationToken cancellationToken = default); CancellationToken cancellationToken = default);
} }

View File

@@ -17,7 +17,7 @@
<body> <body>
<Routes @rendermode="@(new InteractiveServerRenderMode(prerender: false))" /> <Routes @rendermode="@(new InteractiveServerRenderMode(prerender: false))" />
<script src="Scripts/bootstrap.bundle.min.js"></script> <script src="Scripts/bootstrap.bundle.min.js"></script>
<script src="js/appAuth.js"></script> <script src="js/appAuth.js?v=20260724-session-keepalive"></script>
<script src="_framework/blazor.web.js"></script> <script src="_framework/blazor.web.js"></script>
</body> </body>

View File

@@ -0,0 +1,110 @@
@implements IDisposable
@inject UiDialogService Dialog
@if (Dialog.IsVisible)
{
<div class="app-confirmation-backdrop"
role="presentation"
@onkeydown="HandleKeyDown">
<section class="@DialogCss"
role="alertdialog"
aria-modal="true"
aria-labelledby="app-confirmation-title"
aria-describedby="app-confirmation-message">
<div class="app-confirmation__accent" aria-hidden="true"></div>
<div class="app-confirmation__content">
<div class="@IconCss" aria-hidden="true">!</div>
<div class="app-confirmation__copy">
<div class="app-confirmation__eyebrow">@Eyebrow</div>
<h2 id="app-confirmation-title" class="app-confirmation__title">
@Dialog.Title
</h2>
<p id="app-confirmation-message" class="app-confirmation__message">
@Dialog.Message
</p>
</div>
</div>
<div class="app-confirmation__actions">
<button type="button"
class="btn btn-outline-secondary app-confirmation__button"
@ref="_cancelButton"
@onclick="Dialog.Cancel">
@Dialog.CancelText
</button>
<button type="button"
class="@ConfirmButtonCss"
@onclick="Dialog.Confirm">
@Dialog.ConfirmText
</button>
</div>
</section>
</div>
}
@code {
private ElementReference _cancelButton;
private bool _focusPending;
private string DialogCss =>
$"app-confirmation app-confirmation--{ToneName}";
private string IconCss =>
$"app-confirmation__icon app-confirmation__icon--{ToneName}";
private string ConfirmButtonCss =>
Dialog.Tone == UiDialogTone.Danger
? "btn app-confirmation__button app-confirmation__button--danger"
: "btn app-confirmation__button app-confirmation__button--primary";
private string ToneName => Dialog.Tone switch
{
UiDialogTone.Danger => "danger",
UiDialogTone.Information => "information",
_ => "warning"
};
private string Eyebrow => Dialog.Tone switch
{
UiDialogTone.Danger => "Acción irreversible",
UiDialogTone.Information => "Confirmación",
_ => "Revisa antes de continuar"
};
protected override void OnInitialized()
{
Dialog.Changed += HandleDialogChanged;
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!_focusPending || !Dialog.IsVisible)
{
return;
}
_focusPending = false;
await _cancelButton.FocusAsync();
}
public void Dispose()
{
Dialog.Changed -= HandleDialogChanged;
}
private void HandleDialogChanged()
{
_focusPending = Dialog.IsVisible;
_ = InvokeAsync(StateHasChanged);
}
private void HandleKeyDown(KeyboardEventArgs args)
{
if (string.Equals(args.Key, "Escape", StringComparison.Ordinal))
{
Dialog.Cancel();
}
}
}

View File

@@ -0,0 +1,163 @@
.app-confirmation-backdrop {
position: fixed;
inset: 0;
z-index: 5100;
display: grid;
place-items: center;
padding: 1.25rem;
background: rgba(6, 22, 41, 0.64);
backdrop-filter: blur(5px);
overscroll-behavior: contain;
}
.app-confirmation {
position: relative;
overflow: hidden;
width: min(560px, 100%);
border: 1px solid rgba(255, 255, 255, 0.65);
border-radius: 8px;
background: #fff;
box-shadow: 0 28px 80px rgba(5, 27, 54, 0.34);
color: #12395f;
}
.app-confirmation__accent {
height: 0.35rem;
background: #d49620;
}
.app-confirmation--danger .app-confirmation__accent {
background: #c93f4f;
}
.app-confirmation--information .app-confirmation__accent {
background: #2a5caa;
}
.app-confirmation__content {
display: grid;
grid-template-columns: 3.25rem minmax(0, 1fr);
gap: 1rem;
padding: 1.5rem 1.5rem 1.1rem;
}
.app-confirmation__icon {
display: grid;
place-items: center;
width: 3.25rem;
height: 3.25rem;
border: 1px solid #efd8a8;
border-radius: 50%;
background: #fff6df;
color: #8d5e08;
font-size: 1.45rem;
font-weight: 800;
}
.app-confirmation__icon--danger {
border-color: #efc2c8;
background: #fff0f2;
color: #a82d3b;
}
.app-confirmation__icon--information {
border-color: #c4d6ef;
background: #eef5ff;
color: #214f91;
}
.app-confirmation__copy {
min-width: 0;
}
.app-confirmation__eyebrow {
margin-bottom: 0.3rem;
color: #6a7786;
font-size: 0.74rem;
font-weight: 800;
letter-spacing: 0;
text-transform: uppercase;
}
.app-confirmation__title {
margin: 0;
color: #0a315c;
font-size: 1.35rem;
font-weight: 800;
line-height: 1.25;
}
.app-confirmation__message {
margin: 0.65rem 0 0;
color: #405f7d;
line-height: 1.55;
white-space: pre-line;
}
.app-confirmation__actions {
display: flex;
justify-content: flex-end;
gap: 0.65rem;
padding: 1rem 1.5rem 1.35rem;
border-top: 1px solid #e4ebf2;
background: #f8fafc;
}
.app-confirmation__button {
min-width: 8.5rem;
min-height: 2.7rem;
border-radius: 6px;
}
.app-confirmation__button--primary {
border-color: #24539a;
background: #24539a;
color: #fff;
}
.app-confirmation__button--primary:hover,
.app-confirmation__button--primary:focus-visible {
border-color: #193f79;
background: #193f79;
color: #fff;
}
.app-confirmation__button--danger {
border-color: #b93343;
background: #b93343;
color: #fff;
}
.app-confirmation__button--danger:hover,
.app-confirmation__button--danger:focus-visible {
border-color: #922936;
background: #922936;
color: #fff;
}
@media (max-width: 575.98px) {
.app-confirmation-backdrop {
align-items: end;
padding: 0.75rem;
}
.app-confirmation__content {
grid-template-columns: 2.75rem minmax(0, 1fr);
gap: 0.8rem;
padding: 1.2rem 1rem 1rem;
}
.app-confirmation__icon {
width: 2.75rem;
height: 2.75rem;
}
.app-confirmation__actions {
flex-direction: column-reverse;
padding: 0.9rem 1rem 1rem;
}
.app-confirmation__button {
width: 100%;
}
}

View File

@@ -1,10 +1,13 @@
@inherits LayoutComponentBase @inherits LayoutComponentBase
@implements IDisposable @implements IAsyncDisposable
@using System.Globalization
@inject GestionaDenunciasAN.Models.UserState userState @inject GestionaDenunciasAN.Models.UserState userState
@inject IHttpContextAccessor HttpContextAccessor @inject IHttpContextAccessor HttpContextAccessor
@inject IJSRuntime JSRuntime @inject IJSRuntime JSRuntime
@inject NavigationManager Navigation @inject NavigationManager Navigation
@inject UiBusyService Busy @inject UiBusyService Busy
@inject ApiDenunciasClient ApiDenuncias
@inject ILogger<MainLayout> Logger
<div class="app-shell"> <div class="app-shell">
<aside class="app-sidebar"> <aside class="app-sidebar">
@@ -20,9 +23,16 @@
</div> </div>
<div class="app-header__actions"> <div class="app-header__actions">
<div class="app-session-pill"> <div class="app-status-pills">
<span class="app-session-pill__dot"></span> <div class="app-session-pill">
Sesion interna activa <span class="app-session-pill__dot"></span>
Sesion interna activa
</div>
<div class="@EncryptionKeyPillCss" title="@EncryptionKeyTooltip">
<span class="app-session-pill__dot"></span>
@EncryptionKeyText
</div>
</div> </div>
<button type="button" class="app-user-chip" @onclick="CerrarSesionAsync"> <button type="button" class="app-user-chip" @onclick="CerrarSesionAsync">
@@ -42,6 +52,7 @@
</div> </div>
<BusyOverlay /> <BusyOverlay />
<AppConfirmationDialog />
<div id="blazor-error-ui"> <div id="blazor-error-ui">
An unhandled error has occurred. An unhandled error has occurred.
@@ -50,9 +61,20 @@
</div> </div>
@code { @code {
private static readonly TimeSpan GlobalLeaksHeartbeatInterval = TimeSpan.FromSeconds(30);
private static readonly TimeSpan GlobalLeaksIdleTimeout = TimeSpan.FromMinutes(30);
private string CurrentPageTitle { get; set; } = "Portal de gestion"; private string CurrentPageTitle { get; set; } = "Portal de gestion";
private string CurrentPageDescription { get; set; } = private string CurrentPageDescription { get; set; } =
"Entrada, revision y tramitacion coordinada de denuncias y actualizaciones."; "Entrada, revision y tramitacion coordinada de denuncias y actualizaciones.";
private string EncryptionKeyText { get; set; } = "Clave diaria: cargando...";
private string EncryptionKeyTooltip { get; set; } = "Consultando la ultima clave diaria de cifrado activa en la API.";
private string EncryptionKeyPillCss { get; set; } = "app-session-pill app-key-pill";
private readonly CancellationTokenSource _heartbeatCancellation = new();
private PeriodicTimer? _heartbeatTimer;
private Task? _heartbeatTask;
private bool _globalLeaksSessionClearedForIdle;
private DateTimeOffset _lastHeartbeatWarningAtUtc = DateTimeOffset.MinValue;
private string DisplayUsername => private string DisplayUsername =>
string.IsNullOrWhiteSpace(userState?.NombreUsu) string.IsNullOrWhiteSpace(userState?.NombreUsu)
@@ -65,20 +87,205 @@
RefreshLayoutState(); RefreshLayoutState();
} }
protected override async Task OnInitializedAsync()
{
await LoadEncryptionKeyStatusAsync();
}
protected override void OnParametersSet() protected override void OnParametersSet()
{ {
RefreshLayoutState(); RefreshLayoutState();
} }
public void Dispose() protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender)
{
return;
}
try
{
await JSRuntime.InvokeVoidAsync("appGlobalLeaksActivity.start");
}
catch (JSException ex)
{
Logger.LogError(
ex,
"No se ha podido iniciar el control de actividad para la sesion GlobalLeaks.");
return;
}
_heartbeatTimer = new PeriodicTimer(GlobalLeaksHeartbeatInterval);
_heartbeatTask = RunGlobalLeaksHeartbeatAsync(_heartbeatCancellation.Token);
}
public async ValueTask DisposeAsync()
{ {
Navigation.LocationChanged -= HandleLocationChanged; Navigation.LocationChanged -= HandleLocationChanged;
_heartbeatCancellation.Cancel();
_heartbeatTimer?.Dispose();
if (_heartbeatTask is not null)
{
try
{
await _heartbeatTask;
}
catch (OperationCanceledException)
{
}
}
try
{
await JSRuntime.InvokeVoidAsync("appGlobalLeaksActivity.stop");
}
catch (JSDisconnectedException)
{
}
catch (ObjectDisposedException)
{
}
_heartbeatCancellation.Dispose();
} }
private void HandleLocationChanged(object? sender, LocationChangedEventArgs args) private void HandleLocationChanged(object? sender, LocationChangedEventArgs args)
{ {
RefreshLayoutState(); RefreshLayoutState();
_ = InvokeAsync(StateHasChanged); _ = InvokeAsync(async () =>
{
await LoadEncryptionKeyStatusAsync();
StateHasChanged();
});
}
private async Task LoadEncryptionKeyStatusAsync()
{
try
{
var config = await ApiDenuncias.GetAppConfigurationAsync();
var keyDate = ToSpanishDateText(config.LatestEncryptionKeyDate);
var keyStatus = ToSpanishKeyStatus(config.LatestEncryptionKeyStatus);
if (string.IsNullOrWhiteSpace(keyDate))
{
EncryptionKeyText = "Clave diaria: no disponible";
EncryptionKeyTooltip = "No se ha encontrado informacion de la clave diaria de cifrado.";
EncryptionKeyPillCss = "app-session-pill app-key-pill app-key-pill--warning";
return;
}
EncryptionKeyText = string.IsNullOrWhiteSpace(keyStatus)
? $"Clave diaria: {keyDate}"
: $"Clave diaria: {keyDate} ({keyStatus})";
EncryptionKeyTooltip = "Ultima clave diaria de cifrado registrada por la API.";
EncryptionKeyPillCss = string.Equals(config.LatestEncryptionKeyStatus, "active", StringComparison.OrdinalIgnoreCase)
? "app-session-pill app-key-pill"
: "app-session-pill app-key-pill app-key-pill--warning";
}
catch
{
EncryptionKeyText = "Clave diaria: no disponible";
EncryptionKeyTooltip = "No se ha podido consultar la clave diaria de cifrado.";
EncryptionKeyPillCss = "app-session-pill app-key-pill app-key-pill--warning";
}
}
private async Task RunGlobalLeaksHeartbeatAsync(CancellationToken cancellationToken)
{
try
{
while (_heartbeatTimer is not null &&
await _heartbeatTimer.WaitForNextTickAsync(cancellationToken))
{
await InvokeAsync(() => MaintainGlobalLeaksSessionAsync(cancellationToken));
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
}
catch (ObjectDisposedException)
{
}
}
private async Task MaintainGlobalLeaksSessionAsync(CancellationToken cancellationToken)
{
try
{
var idleMilliseconds = await JSRuntime.InvokeAsync<double>(
"appGlobalLeaksActivity.getIdleMilliseconds",
cancellationToken);
if (idleMilliseconds >= GlobalLeaksIdleTimeout.TotalMilliseconds)
{
if (!_globalLeaksSessionClearedForIdle)
{
await ApiDenuncias.ClearGlobalLeaksSessionAsync(cancellationToken);
_globalLeaksSessionClearedForIdle = true;
}
return;
}
_globalLeaksSessionClearedForIdle = false;
await ApiDenuncias.KeepGlobalLeaksSessionAliveAsync(cancellationToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
}
catch (JSDisconnectedException)
{
}
catch (UnauthorizedAccessException)
{
_heartbeatCancellation.Cancel();
}
catch (Exception ex)
{
var now = DateTimeOffset.UtcNow;
if (now - _lastHeartbeatWarningAtUtc >= TimeSpan.FromMinutes(5))
{
_lastHeartbeatWarningAtUtc = now;
Logger.LogWarning(
ex,
"No se ha podido ejecutar el mantenimiento de la sesion GlobalLeaks.");
}
}
}
private static string ToSpanishDateText(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return string.Empty;
}
return DateOnly.TryParseExact(
value.Trim(),
"yyyy-MM-dd",
CultureInfo.InvariantCulture,
DateTimeStyles.None,
out var date)
? date.ToString("dd/MM/yyyy", CultureInfo.InvariantCulture)
: value.Trim();
}
private static string ToSpanishKeyStatus(string? status)
{
if (string.IsNullOrWhiteSpace(status))
{
return string.Empty;
}
return status.Trim().ToLowerInvariant() switch
{
"active" => "activa",
"purged" => "purgada",
_ => status.Trim()
};
} }
private void RefreshLayoutState() private void RefreshLayoutState()
@@ -98,7 +305,7 @@
return path.ToLowerInvariant() switch return path.ToLowerInvariant() switch
{ {
"" or "gestionzip" => ( "" or "entrada" => (
"Entrada de denuncias", "Entrada de denuncias",
"Importa lo nuevo desde GlobalLeaks, revisa el seguimiento por usuario y decide si habra expediente nuevo o actualizacion."), "Importa lo nuevo desde GlobalLeaks, revisa el seguimiento por usuario y decide si habra expediente nuevo o actualizacion."),
"pendientes" => ( "pendientes" => (

View File

@@ -1,3 +1,5 @@
@using System.Security.Claims
<div class="nav-shell"> <div class="nav-shell">
<div class="nav-brand"> <div class="nav-brand">
<img class="nav-brand__logo" <img class="nav-brand__logo"
@@ -14,7 +16,7 @@
<div class="nav-section"> <div class="nav-section">
<span class="nav-section__label">Operativa diaria</span> <span class="nav-section__label">Operativa diaria</span>
<NavLink class="menu-link" href="/GestionZip" Match="NavLinkMatch.All"> <NavLink class="menu-link" href="/Entrada" Match="NavLinkMatch.All">
<span class="menu-link__icon bi bi-box-seam" aria-hidden="true"></span> <span class="menu-link__icon bi bi-box-seam" aria-hidden="true"></span>
<span class="menu-link__content"> <span class="menu-link__content">
<span class="menu-link__title">Entrada</span> <span class="menu-link__title">Entrada</span>
@@ -66,13 +68,20 @@
</span> </span>
</NavLink> </NavLink>
<NavLink class="menu-link" href="/Configuracion" Match="NavLinkMatch.All"> <AuthorizeView>
<span class="menu-link__icon bi bi-gear" aria-hidden="true"></span> <Authorized Context="authContext">
<span class="menu-link__content"> @if (IsConfigurationUser(authContext.User))
<span class="menu-link__title">Configuracion</span> {
<span class="menu-link__meta">Fecha de corte y purga manual</span> <NavLink class="menu-link" href="/Configuracion" Match="NavLinkMatch.All">
</span> <span class="menu-link__icon bi bi-gear" aria-hidden="true"></span>
</NavLink> <span class="menu-link__content">
<span class="menu-link__title">Configuracion</span>
<span class="menu-link__meta">Fecha de corte y purga manual</span>
</span>
</NavLink>
}
</Authorized>
</AuthorizeView>
</div> </div>
<div class="nav-section nav-section--footer"> <div class="nav-section nav-section--footer">
@@ -88,3 +97,19 @@
</div> </div>
</nav> </nav>
</div> </div>
@code {
private static readonly HashSet<string> ConfigurationUsers = new(StringComparer.OrdinalIgnoreCase)
{
"pcornejo",
"Rgarciaglbk",
"eaguilarGestor"
};
private static bool IsConfigurationUser(ClaimsPrincipal user)
{
var username = user.Identity?.Name?.Trim();
return !string.IsNullOrWhiteSpace(username) &&
ConfigurationUsers.Contains(username);
}
}

View File

@@ -1,4 +1,4 @@
@page "/Actualizaciones" @page "/Actualizaciones"
@rendermode InteractiveServer @rendermode InteractiveServer
@attribute [Authorize] @attribute [Authorize]
@using GestionaDenuncias.Shared.Models @using GestionaDenuncias.Shared.Models
@@ -16,6 +16,7 @@
@inject IDenunciaStore DenunciaStore @inject IDenunciaStore DenunciaStore
@inject ApiDenunciasClient ApiDenuncias @inject ApiDenunciasClient ApiDenuncias
@inject UiBusyService Busy @inject UiBusyService Busy
@inject UiDialogService Dialogs
<PageTitle>Actualizaciones</PageTitle> <PageTitle>Actualizaciones</PageTitle>
@@ -58,7 +59,7 @@
margin: 1rem 0 0.5rem; margin: 1rem 0 0.5rem;
} }
/* Tarjetas de actualizaci<EFBFBD>n (azules) */ /* Tarjetas de actualización (azules) */
.collapse-card.update-card { .collapse-card.update-card {
background-color: #e3f2fd; background-color: #e3f2fd;
} }
@@ -73,7 +74,7 @@
vertical-align: middle; vertical-align: middle;
} }
/* === Est<EFBFBD>tica de modal igual que en Pendientes === */ /* === Estética de modal igual que en Pendientes === */
.custom-modal { .custom-modal {
background: rgba(0, 0, 0, 0.5); background: rgba(0, 0, 0, 0.5);
@@ -162,7 +163,21 @@ else
data-bs-target="#@collapseId" data-bs-target="#@collapseId"
aria-expanded="false" aria-expanded="false"
aria-controls="@collapseId"> aria-controls="@collapseId">
<h5 class="mb-0">Denuncia ID: @denuncia.Id_Denuncia (Actualizaci<63>n)</h5> <div class="d-flex flex-wrap align-items-center gap-2">
<h5 class="mb-0">Denuncia ID: @denuncia.Id_Denuncia (Actualización)</h5>
@if (!string.IsNullOrWhiteSpace(denuncia.OwnerUsername))
{
<span class="badge @(denuncia.OwnedByCurrentUser ? "text-bg-secondary" : "text-bg-warning")">
Responsable: @denuncia.OwnerUsername
</span>
}
@if (denuncia.OwnerWorkGroups.Count > 0)
{
<span class="badge text-bg-light">
Grupo: @string.Join(" / ", denuncia.OwnerWorkGroups)
</span>
}
</div>
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<div class="text-muted small me-3"> <div class="text-muted small me-3">
@@ -176,19 +191,11 @@ else
} }
</div> </div>
<button type="button"
class="btn btn-outline-secondary btn-sm me-2"
title="Marcar como pendiente (no actualizaci<63>n)"
@onclick:stopPropagation="true"
@onclick="() => MoverAPendientes(denuncia)">
Mover a Pendientes
</button>
<button type="button" <button type="button"
class="btn btn-success btn-sm" class="btn btn-success btn-sm"
@onclick:stopPropagation="true" @onclick:stopPropagation="true"
@onclick="() => OpenEnviarAGestionaModal(denuncia)"> @onclick="() => OpenEnviarAGestionaModal(denuncia)">
Configurar subida Configurar actualizacion expediente
</button> </button>
</div> </div>
</div> </div>
@@ -204,7 +211,7 @@ else
} }
@if (!string.IsNullOrWhiteSpace(denuncia.ExpedienteGestionaMostrable)) @if (!string.IsNullOrWhiteSpace(denuncia.ExpedienteGestionaMostrable))
{ {
<dt class="col-sm-3">N<EFBFBD> expediente Gestiona</dt> <dt class="col-sm-3">Nº expediente Gestiona</dt>
<dd class="col-sm-9">@denuncia.ExpedienteGestionaMostrable</dd> <dd class="col-sm-9">@denuncia.ExpedienteGestionaMostrable</dd>
} }
@if (!string.IsNullOrWhiteSpace(denuncia.Etiqueta)) @if (!string.IsNullOrWhiteSpace(denuncia.Etiqueta))
@@ -245,7 +252,7 @@ else
} }
@if (!string.IsNullOrWhiteSpace(denuncia.RazonSocialResuelta)) @if (!string.IsNullOrWhiteSpace(denuncia.RazonSocialResuelta))
{ {
<dt class="col-sm-3">Raz<EFBFBD>n social</dt> <dt class="col-sm-3">Razón social</dt>
<dd class="col-sm-9">@denuncia.RazonSocialResuelta</dd> <dd class="col-sm-9">@denuncia.RazonSocialResuelta</dd>
} }
@if (!string.IsNullOrWhiteSpace(denuncia.NombreResuelto)) @if (!string.IsNullOrWhiteSpace(denuncia.NombreResuelto))
@@ -255,12 +262,12 @@ else
} }
@if (!string.IsNullOrWhiteSpace(denuncia.PrimerApellidoResuelto)) @if (!string.IsNullOrWhiteSpace(denuncia.PrimerApellidoResuelto))
{ {
<dt class="col-sm-3">1<EFBFBD> apellido</dt> <dt class="col-sm-3">1º apellido</dt>
<dd class="col-sm-9">@denuncia.PrimerApellidoResuelto</dd> <dd class="col-sm-9">@denuncia.PrimerApellidoResuelto</dd>
} }
@if (!string.IsNullOrWhiteSpace(denuncia.SegundoApellidoResuelto)) @if (!string.IsNullOrWhiteSpace(denuncia.SegundoApellidoResuelto))
{ {
<dt class="col-sm-3">2<EFBFBD> apellido</dt> <dt class="col-sm-3">2º apellido</dt>
<dd class="col-sm-9">@denuncia.SegundoApellidoResuelto</dd> <dd class="col-sm-9">@denuncia.SegundoApellidoResuelto</dd>
} }
@if (!string.IsNullOrWhiteSpace(denuncia.ApellidosResueltos)) @if (!string.IsNullOrWhiteSpace(denuncia.ApellidosResueltos))
@@ -280,14 +287,14 @@ else
<dl class="row"> <dl class="row">
<dt class="col-sm-3">Asunto</dt> <dt class="col-sm-3">Asunto</dt>
<dd class="col-sm-9">@denuncia.Asunto</dd> <dd class="col-sm-9">@denuncia.Asunto</dd>
<dt class="col-sm-3">A Qui<EFBFBD>n</dt> <dt class="col-sm-3">A Quién</dt>
<dd class="col-sm-9">@denuncia.A_Quien_Denuncia</dd> <dd class="col-sm-9">@denuncia.A_Quien_Denuncia</dd>
@if (!string.IsNullOrWhiteSpace(denuncia.DenunciadoDetalle)) @if (!string.IsNullOrWhiteSpace(denuncia.DenunciadoDetalle))
{ {
<dt class="col-sm-3">Detalle denunciado</dt> <dt class="col-sm-3">Detalle denunciado</dt>
<dd class="col-sm-9">@denuncia.DenunciadoDetalle</dd> <dd class="col-sm-9">@denuncia.DenunciadoDetalle</dd>
} }
<dt class="col-sm-3">Descripci<EFBFBD>n</dt> <dt class="col-sm-3">Descripción</dt>
<dd class="col-sm-9">@denuncia.Descripcion_Denuncia</dd> <dd class="col-sm-9">@denuncia.Descripcion_Denuncia</dd>
@if (!string.IsNullOrWhiteSpace(denuncia.OrganismoDenunciado)) @if (!string.IsNullOrWhiteSpace(denuncia.OrganismoDenunciado))
{ {
@@ -296,7 +303,7 @@ else
} }
@if (!string.IsNullOrWhiteSpace(denuncia.SolicitaProteccion)) @if (!string.IsNullOrWhiteSpace(denuncia.SolicitaProteccion))
{ {
<dt class="col-sm-3">Solicita protecci<EFBFBD>n</dt> <dt class="col-sm-3">Solicita protección</dt>
<dd class="col-sm-9">@denuncia.SolicitaProteccion</dd> <dd class="col-sm-9">@denuncia.SolicitaProteccion</dd>
} }
@if (!string.IsNullOrWhiteSpace(denuncia.MedidasProteccionSolicitadas)) @if (!string.IsNullOrWhiteSpace(denuncia.MedidasProteccionSolicitadas))
@@ -313,7 +320,7 @@ else
} }
@if (!string.IsNullOrWhiteSpace(denuncia.AutorizaRemision)) @if (!string.IsNullOrWhiteSpace(denuncia.AutorizaRemision))
{ {
<dt class="col-sm-3">Autoriza remisi<EFBFBD>n</dt> <dt class="col-sm-3">Autoriza remisión</dt>
<dd class="col-sm-9">@denuncia.AutorizaRemision</dd> <dd class="col-sm-9">@denuncia.AutorizaRemision</dd>
} }
</dl> </dl>
@@ -324,14 +331,14 @@ else
@if (camposFormulario.Count > 0) @if (camposFormulario.Count > 0)
{ {
<h5 class="section-heading">Formulario Original</h5> <h5 class="section-heading">Formulario Original</h5>
@foreach (var grupoCampos in camposFormulario.GroupBy(field => string.IsNullOrWhiteSpace(field.Section) ? "Sin secci<EFBFBD>n" : field.Section)) @foreach (var grupoCampos in camposFormulario.GroupBy(field => string.IsNullOrWhiteSpace(field.Section) ? "Sin sección" : field.Section))
{ {
<h6 class="mt-3">@grupoCampos.Key</h6> <h6 class="mt-3">@grupoCampos.Key</h6>
<dl class="row"> <dl class="row">
@foreach (var campo in grupoCampos) @foreach (var campo in grupoCampos)
{ {
<dt class="col-sm-4">@campo.Label</dt> <dt class="col-sm-4">@campo.Label</dt>
<dd class="col-sm-8">@(string.IsNullOrWhiteSpace(campo.Value) ? "<EFBFBD>" : campo.Value)</dd> <dd class="col-sm-8">@(string.IsNullOrWhiteSpace(campo.Value) ? "" : campo.Value)</dd>
} }
</dl> </dl>
} }
@@ -347,7 +354,7 @@ else
<th>Nombre</th> <th>Nombre</th>
<th>Fecha</th> <th>Fecha</th>
<th>Motivo</th> <th>Motivo</th>
<th>Tama<EFBFBD>o (bytes)</th> <th>Tamaño (bytes)</th>
<th>Ver</th> <th>Ver</th>
</tr> </tr>
</thead> </thead>
@@ -396,7 +403,7 @@ else
} }
else else
{ {
<span class="text-muted"><EFBFBD></span> <span class="text-muted"></span>
} }
</td> </td>
</tr> </tr>
@@ -447,19 +454,19 @@ else
{ {
<div class="alert alert-info d-flex align-items-center" role="alert"> <div class="alert alert-info d-flex align-items-center" role="alert">
<div class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></div> <div class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></div>
Buscando expediente existente en Gestiona<EFBFBD> Buscando expediente existente en Gestiona
</div> </div>
} }
else if (autoSearchTried && !string.IsNullOrWhiteSpace(autoFoundFileUrl)) else if (autoSearchTried && !string.IsNullOrWhiteSpace(autoFoundFileUrl))
{ {
<div class="alert alert-success" role="alert"> <div class="alert alert-success" role="alert">
<div class="fw-semibold">Expediente detectado en Gestiona.</div> <div class="fw-semibold">Expediente detectado en Gestiona.</div>
<div>Asunto: <strong>@(autoFoundTitle ?? "(sin t<EFBFBD>tulo)")</strong></div> <div>Asunto: <strong>@(autoFoundTitle ?? "(sin título)")</strong></div>
<div class="text-muted small">@autoFoundFileUrl</div> <div class="text-muted small">@autoFoundFileUrl</div>
<div class="form-check mt-2"> <div class="form-check mt-2">
<input class="form-check-input" type="checkbox" id="chkUsarDetectado" @bind="useAutoFoundExpediente" /> <input class="form-check-input" type="checkbox" id="chkUsarDetectado" @bind="useAutoFoundExpediente" />
<label class="form-check-label" for="chkUsarDetectado"> <label class="form-check-label" for="chkUsarDetectado">
A<EFBFBD>adir documentos a este expediente. Añadir documentos a este expediente.
</label> </label>
</div> </div>
</div> </div>
@@ -468,12 +475,7 @@ else
{ {
<div class="alert alert-warning" role="alert"> <div class="alert alert-warning" role="alert">
<div class="fw-semibold">No se ha detectado expediente en Gestiona por asunto.</div> <div class="fw-semibold">No se ha detectado expediente en Gestiona por asunto.</div>
<div class="small">Puede que esto no sea una actualizaci<EFBFBD>n del mismo caso.</div> <div class="small">Puede que esto no sea una actualización del mismo caso.</div>
<div class="mt-2">
<button class="btn btn-outline-secondary btn-sm" @onclick="MoverADenunciasPendientes">
Mover a Pendientes
</button>
</div>
</div> </div>
} }
@@ -491,17 +493,25 @@ else
</div> </div>
} }
<h6 class="modal-section-heading">Descripci<63>n</h6> @if (!string.IsNullOrWhiteSpace(selectedDenuncias.ExpedienteGestionaMostrable))
{
<div class="alert alert-info" role="alert">
<div class="fw-semibold">Expediente de Gestiona donde se hará la actualización</div>
<div>Nº expediente: <strong>@selectedDenuncias.ExpedienteGestionaMostrable</strong></div>
</div>
}
<h6 class="modal-section-heading">Descripción</h6>
<div class="mb-3"> <div class="mb-3">
<input type="text" class="form-control" @bind="nuevoAsunto" placeholder="Ingrese el nombre de la denuncia" /> <input type="text" class="form-control" @bind="nuevoAsunto" readonly />
</div> </div>
<h6 class="modal-section-heading">Nombre de los documentos</h6> <h6 class="modal-section-heading">Nombre de los documentos</h6>
<div class="mb-3"> <div class="mb-3">
<input type="text" class="form-control" @bind="nombreDocumentos" <input type="text" class="form-control" @bind="nombreDocumentos"
placeholder="Ej.: Gesti<EFBFBD>n AN (Documento Adjunto 1 Gesti<EFBFBD>n AN...)" /> placeholder="Ej.: Gestión AN (Documento Adjunto 1 Gestión AN...)" />
<small class="text-muted"> <small class="text-muted">
Se aplica al modo individual. <em>report.txt</em> se sube como <strong>Denuncia</strong> si entra en esta actualizaci<EFBFBD>n. Se aplica al modo individual. <em>report.txt</em> se sube como <strong>Denuncia</strong> si entra en esta actualización.
</small> </small>
</div> </div>
@@ -509,7 +519,7 @@ else
<div class="form-check"> <div class="form-check">
<input class="form-check-input" type="radio" name="uploadMode" id="modoMerge" <input class="form-check-input" type="radio" name="uploadMode" id="modoMerge"
checked='@(uploadMode == "merge")' @onclick='() => uploadMode = "merge"' /> checked='@(uploadMode == "merge")' @onclick='() => uploadMode = "merge"' />
<label class="form-check-label" for="modoMerge">Unir todos los ficheros en un <EFBFBD>nico PDF</label> <label class="form-check-label" for="modoMerge">Unir todos los ficheros en un único PDF</label>
</div> </div>
<div class="form-check"> <div class="form-check">
<input class="form-check-input" type="radio" name="uploadMode" id="modoIndividual" <input class="form-check-input" type="radio" name="uploadMode" id="modoIndividual"
@@ -522,18 +532,13 @@ else
<input class="form-check-input" type="radio" name="selectedGroup" id="grupo600" <input class="form-check-input" type="radio" name="selectedGroup" id="grupo600"
checked='@(selectedGroup == "600")' @onclick='() => selectedGroup = "600"' /> checked='@(selectedGroup == "600")' @onclick='() => selectedGroup = "600"' />
<label class="form-check-label" for="grupo600"> <label class="form-check-label" for="grupo600">
600. Asuntos Jur<EFBFBD>dicos y Protecci<EFBFBD>n a la Persona Denunciante 600. Asuntos Jurídicos y Protección a la Persona Denunciante
</label> </label>
</div> </div>
<div class="form-check"> <div class="form-check">
<input class="form-check-input" type="radio" name="selectedGroup" id="grupo510" <input class="form-check-input" type="radio" name="selectedGroup" id="grupo510"
checked='@(selectedGroup == "510")' @onclick='() => selectedGroup = "510"' /> checked='@(selectedGroup == "510")' @onclick='() => selectedGroup = "510"' />
<label class="form-check-label" for="grupo510">510. SDI <EFBFBD> Investigaci<EFBFBD>n Entradas</label> <label class="form-check-label" for="grupo510">510. SDI Investigación Entradas</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="selectedGroup" id="grupo700"
checked='@(selectedGroup == "700")' @onclick='() => selectedGroup = "700"' />
<label class="form-check-label" for="grupo700">700. RESPONSABLE DEL SERVICIO</label>
</div> </div>
@{ @{
@@ -542,13 +547,13 @@ else
<h6 class="modal-section-heading mt-3">Tercero (denunciante)</h6> <h6 class="modal-section-heading mt-3">Tercero (denunciante)</h6>
<div class="alert alert-light border mb-3"> <div class="alert alert-light border mb-3">
Los datos del tercero se cargan autom<EFBFBD>ticamente desde la denuncia y no se pueden editar aqu<EFBFBD>. Los datos del tercero se cargan automáticamente desde la denuncia y no se pueden editar aquí.
</div> </div>
@if (modalThirdParty.IsAnonymous) @if (modalThirdParty.IsAnonymous)
{ {
<div class="alert alert-warning mb-3"> <div class="alert alert-warning mb-3">
Denuncia an<EFBFBD>nima. Se enlazar<EFBFBD> autom<EFBFBD>ticamente el tercero <strong>00000000T</strong>. Denuncia anónima. Se enlazará automáticamente el tercero <strong>00000000T</strong>.
</div> </div>
} }
@@ -577,7 +582,7 @@ else
{ {
<div class="row g-2"> <div class="row g-2">
<div class="col-12 mb-2"> <div class="col-12 mb-2">
<label class="form-label">Raz<EFBFBD>n social</label> <label class="form-label">Razón social</label>
<input class="form-control" value="@GetReadOnlyValue(selectedDenuncias.RazonSocialResuelta)" readonly /> <input class="form-control" value="@GetReadOnlyValue(selectedDenuncias.RazonSocialResuelta)" readonly />
</div> </div>
</div> </div>
@@ -590,11 +595,11 @@ else
<input class="form-control" value="@GetReadOnlyValue(selectedDenuncias.NombreResuelto)" readonly /> <input class="form-control" value="@GetReadOnlyValue(selectedDenuncias.NombreResuelto)" readonly />
</div> </div>
<div class="col-4 mb-2"> <div class="col-4 mb-2">
<label class="form-label">1<EFBFBD> apellido</label> <label class="form-label">1º apellido</label>
<input class="form-control" value="@GetReadOnlyValue(selectedDenuncias.PrimerApellidoResuelto)" readonly /> <input class="form-control" value="@GetReadOnlyValue(selectedDenuncias.PrimerApellidoResuelto)" readonly />
</div> </div>
<div class="col-4 mb-2"> <div class="col-4 mb-2">
<label class="form-label">2<EFBFBD> apellido</label> <label class="form-label">2º apellido</label>
<input class="form-control" value="@GetReadOnlyValue(selectedDenuncias.SegundoApellidoResuelto)" readonly /> <input class="form-control" value="@GetReadOnlyValue(selectedDenuncias.SegundoApellidoResuelto)" readonly />
</div> </div>
</div> </div>
@@ -604,14 +609,14 @@ else
{ {
<div class="row g-2"> <div class="row g-2">
<div class="col-12 mb-2"> <div class="col-12 mb-2">
<label class="form-label">Direcci<EFBFBD>n postal</label> <label class="form-label">Dirección postal</label>
<textarea class="form-control" rows="2" readonly>@BuildPostalAddressSummary(selectedDenuncias)</textarea> <textarea class="form-control" rows="2" readonly>@BuildPostalAddressSummary(selectedDenuncias)</textarea>
</div> </div>
</div> </div>
} }
<small class="text-muted"> <small class="text-muted">
Antes de subir la actualizaci<EFBFBD>n se comprobar<EFBFBD> el tercero extra<EFBFBD>do del formulario y, si no est<EFBFBD> enlazado al expediente, se enlazar<EFBFBD>. Antes de subir la actualización se comprobará el tercero extraído del formulario y, si no está enlazado al expediente, se enlazará.
</small> </small>
</div> </div>
@@ -639,14 +644,14 @@ else
// --- Modal / estado --- // --- Modal / estado ---
private bool showModal = false; private bool showModal = false;
private bool isUploading = false; private bool isUploading = false;
private string uploadMode = "merge"; private string uploadMode = "individual";
private string selectedGroup = "600"; private string selectedGroup = "600";
private string nuevoAsunto = ""; private string nuevoAsunto = "";
private string nombreDocumentos = ""; private string nombreDocumentos = "";
private DenunciasGestiona? selectedDenuncias; private DenunciasGestiona? selectedDenuncias;
private ThirdPartyIdentityData? selectedThirdParty; private ThirdPartyIdentityData? selectedThirdParty;
// --- Detecci<EFBFBD>n autom<EFBFBD>tica en Gestiona por asunto --- // --- Detección automática en Gestiona por asunto ---
private bool autoSearchLoading = false; private bool autoSearchLoading = false;
private bool autoSearchTried = false; private bool autoSearchTried = false;
private string? autoFoundFileUrl = null; private string? autoFoundFileUrl = null;
@@ -668,19 +673,15 @@ else
var config = await ApiDenuncias.GetAppConfigurationAsync(); var config = await ApiDenuncias.GetAppConfigurationAsync();
externalUpdateCutoffDate = ParseConfiguredCutoffDate(config.ExternalUpdateCutoffDate); externalUpdateCutoffDate = ParseConfiguredCutoffDate(config.ExternalUpdateCutoffDate);
var todas = await CargarDenunciasJsonAsync(); var todas = await CargarDenunciasJsonAsync();
foreach (var d in todas.Where(x => x.ProcedureId == Guid.Empty))
{
d.ProcedureId = Guid.Parse("82722c9b-cecc-4299-8a7b-ce5abeb8170b");
d.GroupId = Guid.Parse("6dbfc433-1eb6-4b9a-a533-bfebc652c101");
}
actualizaciones = todas actualizaciones = todas
.Where(d => d.EsActualizacion) .Where(d => d.EsActualizacion)
.OrderByDescending(d => d.FechaSubidaAGestiona != DateTime.MinValue ? d.FechaSubidaAGestiona : d.Fecha) .OrderByDescending(d => d.FechaSubidaAGestiona != DateTime.MinValue ? d.FechaSubidaAGestiona : d.Fecha)
.ToList(); .ToList();
ficherosAdjuntos = await CargarFicherosPorDenunciaAsync(actualizaciones); ficherosAdjuntos = await CargarFicherosPorDenunciaAsync(actualizaciones);
actualizaciones = actualizaciones
.Where(d => ficherosAdjuntos.ContainsKey(d.Id_Denuncia))
.ToList();
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -715,12 +716,26 @@ else
return result; return result;
} }
private void OpenEnviarAGestionaModal(DenunciasGestiona d) private async Task OpenEnviarAGestionaModal(DenunciasGestiona d)
{ {
if (d.RequiresOwnerConfirmation)
{
var confirmed = await Dialogs.ConfirmAsync(
"Denuncia asignada a otro usuario",
$"La denuncia #{d.Id_Denuncia} está asignada a {d.OwnerUsername}. " +
"Puedes actualizarla porque pertenece a un usuario de tu grupo. La acción quedará registrada con tu usuario.",
"Configurar actualización");
if (!confirmed)
{
return;
}
}
selectedDenuncias = d; selectedDenuncias = d;
nuevoAsunto = string.IsNullOrWhiteSpace(d.NombreDenuncia) ? $"Denuncia-{d.Id_Denuncia}-CD" : d.NombreDenuncia; nuevoAsunto = string.IsNullOrWhiteSpace(d.NombreDenuncia) ? $"Denuncia-{d.Id_Denuncia}-CD" : d.NombreDenuncia;
nombreDocumentos = ""; nombreDocumentos = "";
selectedThirdParty = ThirdPartyIdentityData.FromComplaint(d); selectedThirdParty = ThirdPartyIdentityData.FromComplaint(d);
selectedGroup = NormalizeUpdateGroup(d.UltimoGrupoAsignadoGestiona);
operationError = string.Empty; operationError = string.Empty;
operationNotice = string.Empty; operationNotice = string.Empty;
@@ -729,6 +744,12 @@ else
useAutoFoundExpediente = true; useAutoFoundExpediente = true;
autoSearchTried = false; autoSearchTried = false;
if (!string.IsNullOrWhiteSpace(d.Expediente_Gestiona) &&
string.IsNullOrWhiteSpace(d.CodigoExpedienteGestiona))
{
await SincronizarExpedienteGestionaAsync(d, d.Expediente_Gestiona);
}
if (string.IsNullOrWhiteSpace(d.Expediente_Gestiona)) if (string.IsNullOrWhiteSpace(d.Expediente_Gestiona))
{ {
autoSearchLoading = true; autoSearchLoading = true;
@@ -737,7 +758,7 @@ else
try try
{ {
// B<EFBFBD>squeda autom<EFBFBD>tica desactivada temporalmente // Búsqueda automática desactivada temporalmente
} }
catch catch
{ {
@@ -799,48 +820,20 @@ else
return new string(clean.Where(ch => ch <= 127).ToArray()); return new string(clean.Where(ch => ch <= 127).ToArray());
} }
private string BuildMergedAttachmentsFileName(int denunciaId, DateTime timestampUtc)
{
var baseName = string.IsNullOrWhiteSpace(nombreDocumentos)
? $"Adjuntos {denunciaId}_{timestampUtc:yyyyMMddHHmmss}"
: nombreDocumentos.Trim();
return FixFileName($"{Path.GetFileNameWithoutExtension(baseName)}.pdf");
}
private async Task ActualizarDenunciaAsync(DenunciasGestiona d) private async Task ActualizarDenunciaAsync(DenunciasGestiona d)
{ {
await DenunciaStore.UpsertDenunciaAsync(d); await DenunciaStore.UpsertDenunciaAsync(d);
} }
private async Task MoverADenunciasPendientes()
{
if (selectedDenuncias is null) return;
selectedDenuncias.EsActualizacion = false;
selectedDenuncias.EnGestiona = false;
selectedDenuncias.EnRechazada = false;
selectedDenuncias.Expediente_Gestiona = "Pendiente";
selectedDenuncias.FechaSubidaAGestiona = DateTime.MinValue;
selectedDenuncias.ArchivoElegido = string.Empty;
await ActualizarDenunciaAsync(selectedDenuncias);
actualizaciones.RemoveAll(x => x.Id_Denuncia == selectedDenuncias.Id_Denuncia);
CloseModal();
StateHasChanged();
}
private async Task MoverAPendientes(DenunciasGestiona d)
{
if (d is null) return;
d.EsActualizacion = false;
d.EnGestiona = false;
d.EnRechazada = false;
d.Expediente_Gestiona = "Pendiente";
d.FechaSubidaAGestiona = DateTime.MinValue;
d.ArchivoElegido = string.Empty;
await ActualizarDenunciaAsync(d);
actualizaciones.RemoveAll(x => x.Id_Denuncia == d.Id_Denuncia);
StateHasChanged();
}
private async Task ConfirmarEnvio() private async Task ConfirmarEnvio()
{ {
if (selectedDenuncias == null) return; if (selectedDenuncias == null) return;
@@ -852,11 +845,19 @@ else
return; return;
} }
if (!await ConfirmSelectedGroupAsync())
{
return;
}
try try
{ {
isUploading = true; isUploading = true;
operationError = string.Empty; operationError = string.Empty;
operationNotice = string.Empty; operationNotice = string.Empty;
selectedGroup = NormalizeUpdateGroup(selectedGroup);
var pendingUpdateSource = selectedDenuncias.PendingUpdateSource;
var uploadType = GetUpdateUploadType(pendingUpdateSource, selectedGroup);
using var busy = Busy.Show( using var busy = Busy.Show(
"Enviando actualizacion", "Enviando actualizacion",
"Preparando expediente, carpeta de actualizacion y documentos."); "Preparando expediente, carpeta de actualizacion y documentos.");
@@ -889,12 +890,13 @@ else
if (!todos.Any()) if (!todos.Any())
{ {
operationError = ficherosVacios.Count == 0 operationError = ficherosVacios.Count == 0
? $"La denuncia #{selectedDenuncias.Id_Denuncia} no tiene ficheros pendientes para subir como actualizaci<EFBFBD>n." ? $"La denuncia #{selectedDenuncias.Id_Denuncia} no tiene ficheros pendientes para subir como actualización."
: $"La denuncia #{selectedDenuncias.Id_Denuncia} solo tiene ficheros vac<EFBFBD>os y no se ha subido nada. Ficheros omitidos: {string.Join(", ", ficherosVacios)}."; : $"La denuncia #{selectedDenuncias.Id_Denuncia} solo tiene ficheros vacíos y no se ha subido nada. Ficheros omitidos: {string.Join(", ", ficherosVacios)}.";
return; return;
} }
// 2) Determinar expediente destino // 2) Determinar expediente destino
var expedienteCreadoEnGestiona = false;
string fileUrl; string fileUrl;
if (selectedDenuncias.EnGestiona && !string.IsNullOrWhiteSpace(selectedDenuncias.Expediente_Gestiona)) if (selectedDenuncias.EnGestiona && !string.IsNullOrWhiteSpace(selectedDenuncias.Expediente_Gestiona))
{ {
@@ -910,7 +912,6 @@ else
{ {
Busy.Update(message: "Creando expediente nuevo en Gestiona.", detail: "Paso 2 de 8"); Busy.Update(message: "Creando expediente nuevo en Gestiona.", detail: "Paso 2 de 8");
var createdFile = await ApiDenuncias.CreateGestionaFileAsync( var createdFile = await ApiDenuncias.CreateGestionaFileAsync(
selectedDenuncias.ProcedureId,
nuevoAsunto, nuevoAsunto,
"RQ2ZLC - Expediente de Denuncias", "RQ2ZLC - Expediente de Denuncias",
"3109963" "3109963"
@@ -920,33 +921,29 @@ else
await ApiDenuncias.OpenGestionaFileAsync( await ApiDenuncias.OpenGestionaFileAsync(
fileUrl, fileUrl,
createdFile.FileOpenUrl, createdFile.FileOpenUrl,
managementUnitGroupId: Guid.Parse("a4ad4dfb-70dc-4219-8ee3-4dcc939f0955"), assignedGroupCode: selectedGroup,
assignedGroupId: selectedGroup switch
{
"510" => Guid.Parse("6dbfc433-1eb6-4b9a-a533-bfebc652c101"),
"600" => Guid.Parse("454fa4ec-8b82-4240-9419-113f45d4b004"),
"700" => Guid.Parse("a4ad4dfb-70dc-4219-8ee3-4dcc939f0955"),
_ => throw new InvalidOperationException($"Grupo desconocido: {selectedGroup}")
},
confidential: selectedDenuncias.Confidencial, confidential: selectedDenuncias.Confidencial,
freeTitle: nuevoAsunto, freeTitle: nuevoAsunto
siaCode: "3109963"
); );
selectedDenuncias.Expediente_Gestiona = fileUrl; selectedDenuncias.Expediente_Gestiona = fileUrl;
selectedDenuncias.EnGestiona = true; selectedDenuncias.EnGestiona = true;
expedienteCreadoEnGestiona = true;
} }
Busy.Update(message: "Guardando referencia local del expediente.", detail: "Paso 3 de 8"); Busy.Update(message: "Guardando referencia local del expediente.", detail: "Paso 3 de 8");
selectedDenuncias.Expediente_Gestiona = fileUrl; selectedDenuncias.Expediente_Gestiona = fileUrl;
selectedDenuncias.EnGestiona = true; selectedDenuncias.EnGestiona = true;
Busy.Update(message: "Asignando el expediente al grupo elegido.", detail: "Paso 3 de 8");
await ApiDenuncias.AssignGestionaFileAsync(fileUrl, selectedGroup);
var thirdParty = selectedThirdParty ?? ThirdPartyIdentityData.FromComplaint(selectedDenuncias); var thirdParty = selectedThirdParty ?? ThirdPartyIdentityData.FromComplaint(selectedDenuncias);
Busy.Update(message: "Resolviendo tercero y enlazandolo al expediente.", detail: "Paso 4 de 8"); Busy.Update(message: "Resolviendo tercero y enlazandolo al expediente.", detail: "Paso 4 de 8");
await ApiDenuncias.EnsureGestionaThirdAndLinkAsync(fileUrl, thirdParty); var thirdResult = await ApiDenuncias.EnsureGestionaThirdAndLinkAsync(fileUrl, thirdParty);
var ahoraUtc = DateTime.UtcNow; var ahoraUtc = DateTime.UtcNow;
var carpetaActualizacion = FixFileName($"Actualizacion {DateTime.Now:yyyy-MM-dd HH-mm-ss}"); var carpetaActualizacion = FixFileName($"Actualizacion {DateTime.Now:yyyy-MM-dd HH-mm}");
Busy.Update(message: "Creando carpeta de actualizacion en Gestiona.", detail: "Paso 5 de 8"); Busy.Update(message: "Creando carpeta de actualizacion en Gestiona.", detail: "Paso 5 de 8");
var carpetaActualizacionGestiona = await ApiDenuncias.CreateGestionaFolderAsync(fileUrl, carpetaActualizacion); var carpetaActualizacionGestiona = await ApiDenuncias.CreateGestionaFolderAsync(fileUrl, carpetaActualizacion);
var documentsTargetUrl = carpetaActualizacionGestiona.DocumentsTargetUrl; var documentsTargetUrl = carpetaActualizacionGestiona.DocumentsTargetUrl;
@@ -959,8 +956,8 @@ else
if (!string.IsNullOrWhiteSpace(report.FileName)) if (!string.IsNullOrWhiteSpace(report.FileName))
{ {
Busy.Update(message: "Preparando y subiendo el report para firma.", detail: "Paso 6 de 8"); Busy.Update(message: "Preparando y subiendo el documento principal.", detail: "Paso 6 de 8");
var reportPdfBytes = PdfHelper.MergeFilesToPdf( var reportPdfBytes = PdfHelper.MergeReportToPdf(
new (string FileName, byte[] Content)[] { (report.FileName, report.Content) }); new (string FileName, byte[] Content)[] { (report.FileName, report.Content) });
var reportFinalName = FixFileName("Denuncia.pdf"); var reportFinalName = FixFileName("Denuncia.pdf");
documentoParaTramitar = await ApiDenuncias.UploadGestionaDocumentAsync( documentoParaTramitar = await ApiDenuncias.UploadGestionaDocumentAsync(
@@ -979,7 +976,7 @@ else
{ {
Busy.Update(message: "Uniendo adjuntos nuevos en un unico PDF y subiendolo.", detail: "Paso 7 de 8"); Busy.Update(message: "Uniendo adjuntos nuevos en un unico PDF y subiendolo.", detail: "Paso 7 de 8");
var pdfBytes = PdfHelper.MergeFilesToPdf(adjuntos); var pdfBytes = PdfHelper.MergeFilesToPdf(adjuntos);
var pdfName = FixFileName($"Adjuntos {selectedDenuncias.Id_Denuncia}_{ahoraUtc:yyyyMMddHHmmss}.pdf"); var pdfName = BuildMergedAttachmentsFileName(selectedDenuncias.Id_Denuncia, ahoraUtc);
var docUrl = await ApiDenuncias.UploadGestionaDocumentAsync(documentsTargetUrl, pdfBytes, pdfName); var docUrl = await ApiDenuncias.UploadGestionaDocumentAsync(documentsTargetUrl, pdfBytes, pdfName);
if (string.IsNullOrWhiteSpace(documentoParaTramitar)) if (string.IsNullOrWhiteSpace(documentoParaTramitar))
{ {
@@ -1040,11 +1037,14 @@ else
if (!string.IsNullOrWhiteSpace(documentoParaTramitar)) if (!string.IsNullOrWhiteSpace(documentoParaTramitar))
{ {
Busy.Update(message: "Enviando el report al circuito de firma.", detail: "Paso 8 de 8"); Busy.Update(message: "Finalizando el documento principal en Gestiona.", detail: "Paso 8 de 8");
selectedGroup = NormalizeUpdateGroup(selectedGroup);
await ApiDenuncias.TramitarGestionaDocumentAsync( await ApiDenuncias.TramitarGestionaDocumentAsync(
documentoParaTramitar, documentoParaTramitar,
GetAssignedGroupLinkBySelectedGroup(), selectedGroup,
selectedDenuncias.Id_Denuncia); selectedDenuncias.Id_Denuncia,
isUpdate: true,
updateSource: pendingUpdateSource);
} }
foreach (var orig in nombresOriginalesSubidos) foreach (var orig in nombresOriginalesSubidos)
@@ -1066,7 +1066,17 @@ else
selectedDenuncias.EsActualizacion = false; selectedDenuncias.EsActualizacion = false;
selectedDenuncias.NombreDenuncia = nuevoAsunto; selectedDenuncias.NombreDenuncia = nuevoAsunto;
selectedDenuncias.FechaSubidaAGestiona = ahoraUtc; selectedDenuncias.FechaSubidaAGestiona = ahoraUtc;
selectedDenuncias.UltimaSubidaGestionaTipo = uploadType;
selectedDenuncias.UltimoGrupoAsignadoGestiona = GetGestionaGroupDisplay(selectedGroup);
selectedDenuncias.PendingUpdateSource = string.Empty;
await SincronizarExpedienteGestionaAsync(selectedDenuncias, fileUrl);
await ActualizarDenunciaAsync(selectedDenuncias); await ActualizarDenunciaAsync(selectedDenuncias);
var historialAviso = await RegistrarHistorialGestionaAsync(
selectedDenuncias,
uploadType,
selectedGroup,
ahoraUtc,
string.Join("; ", nombresFinalesSubidos));
var denunciaProcesadaId = selectedDenuncias.Id_Denuncia; var denunciaProcesadaId = selectedDenuncias.Id_Denuncia;
actualizaciones.RemoveAll(x => x.Id_Denuncia == denunciaProcesadaId); actualizaciones.RemoveAll(x => x.Id_Denuncia == denunciaProcesadaId);
@@ -1076,22 +1086,35 @@ else
var avisos = new List<string>(); var avisos = new List<string>();
if (ficherosVacios.Count > 0) if (ficherosVacios.Count > 0)
{ {
avisos.Add($"Se omitieron ficheros vac<EFBFBD>os: {string.Join(", ", ficherosVacios)}."); avisos.Add($"Se omitieron ficheros vacíos: {string.Join(", ", ficherosVacios)}.");
} }
if (ficherosNoSeleccionados.Count > 0) if (ficherosNoSeleccionados.Count > 0)
{ {
avisos.Add($"No se subieron por selecci<EFBFBD>n del usuario: {string.Join(", ", ficherosNoSeleccionados)}."); avisos.Add($"No se subieron por selección del usuario: {string.Join(", ", ficherosNoSeleccionados)}.");
} }
if (avisos.Count > 0) if (thirdResult.Warnings.Count > 0)
{ {
operationNotice = $"Actualizaci<63>n #{denunciaProcesadaId} completada. {string.Join(" ", avisos)}"; avisos.AddRange(thirdResult.Warnings);
} }
if (!string.IsNullOrWhiteSpace(historialAviso))
{
avisos.Add(historialAviso);
}
var expedienteInfo = string.IsNullOrWhiteSpace(selectedDenuncias.ExpedienteGestionaMostrable)
? string.Empty
: $" Nº expediente Gestiona: {selectedDenuncias.ExpedienteGestionaMostrable}.";
var resumen = expedienteCreadoEnGestiona
? $"Actualización #{denunciaProcesadaId}: se ha creado el expediente en Gestiona y se han subido los documentos.{expedienteInfo}"
: $"Actualización #{denunciaProcesadaId}: se han añadido los documentos al expediente de Gestiona.{expedienteInfo}";
operationNotice = avisos.Count > 0
? $"{resumen} {string.Join(" ", avisos)}"
: resumen;
StateHasChanged(); StateHasChanged();
} }
catch (Exception ex) catch (Exception ex)
{ {
Console.Error.WriteLine($"Error al confirmar env<EFBFBD>o (Actualizaciones): {ex}"); Console.Error.WriteLine($"Error al confirmar envío (Actualizaciones): {ex}");
operationError = $"No se ha podido completar la actualizaci<EFBFBD>n #{selectedDenuncias?.Id_Denuncia}: {ex.Message}"; operationError = $"No se ha podido completar la actualización #{selectedDenuncias?.Id_Denuncia}: {ex.Message}";
} }
finally finally
{ {
@@ -1186,7 +1209,7 @@ else
} }
private static string GetReadOnlyValue(string? value) => private static string GetReadOnlyValue(string? value) =>
string.IsNullOrWhiteSpace(value) ? "<EFBFBD>" : value; string.IsNullOrWhiteSpace(value) ? "" : value;
private static bool IsExternalGestionaUpdate(DenunciasGestiona denuncia) private static bool IsExternalGestionaUpdate(DenunciasGestiona denuncia)
{ {
@@ -1264,7 +1287,7 @@ else
private static List<FicherosDenuncias> GetPendingUpdateFiles(List<FicherosDenuncias> files, DateOnly? externalUpdateCutoffDate = null) private static List<FicherosDenuncias> GetPendingUpdateFiles(List<FicherosDenuncias> files, DateOnly? externalUpdateCutoffDate = null)
{ {
var uploadedHashes = files var uploadedHashes = files
.Where(file => file.Subido && !file.EsReport && !string.IsNullOrWhiteSpace(file.ContentSha256)) .Where(file => file.Subido && !string.IsNullOrWhiteSpace(file.ContentSha256))
.Select(file => file.ContentSha256) .Select(file => file.ContentSha256)
.ToHashSet(StringComparer.OrdinalIgnoreCase); .ToHashSet(StringComparer.OrdinalIgnoreCase);
@@ -1275,9 +1298,12 @@ else
.Where(file => file.EsReport) .Where(file => file.EsReport)
.OrderByDescending(file => file.Fecha) .OrderByDescending(file => file.Fecha)
.FirstOrDefault(); .FirstOrDefault();
if (report is not null) if (report is not null && !report.Subido)
{ {
pending.Add(report); if (string.IsNullOrWhiteSpace(report.ContentSha256) || plannedHashes.Add(report.ContentSha256))
{
pending.Add(report);
}
} }
foreach (var file in files foreach (var file in files
@@ -1341,15 +1367,80 @@ else
: "Sin historico local"; : "Sin historico local";
} }
private string GetAssignedGroupLinkBySelectedGroup() private static string NormalizeUpdateGroup(string? groupCode)
=> groupCode?.Trim().StartsWith("510", StringComparison.Ordinal) == true
? "510"
: "600";
private static string GetGestionaGroupDisplay(string? groupCode)
{ {
return selectedGroup switch return string.IsNullOrWhiteSpace(groupCode) ? string.Empty : groupCode.Trim();
}
private static string GetUpdateUploadType(string? updateSource, string groupCode)
{
if (!ComplaintUpdateSources.IsReceiver(updateSource))
{ {
"510" => "https://02.g3stiona.com/rest/groups/6dbfc433-1eb6-4b9a-a533-bfebc652c101", return "Actualización";
"600" => "https://02.g3stiona.com/rest/groups/454fa4ec-8b82-4240-9419-113f45d4b004", }
"700" => "https://02.g3stiona.com/rest/groups/a4ad4dfb-70dc-4219-8ee3-4dcc939f0955",
_ => throw new InvalidOperationException($"Grupo desconocido: {selectedGroup}") return NormalizeUpdateGroup(groupCode) == "510"
}; ? "Comunic. SDI a denunciante"
: "Comunic. SAJ a denunciante";
}
private async Task<bool> ConfirmSelectedGroupAsync()
{
try
{
var currentGroups = await ApiDenuncias.GetCurrentUserWorkGroupsAsync();
if (currentGroups.GroupCodes.Contains(selectedGroup, StringComparer.OrdinalIgnoreCase))
{
return true;
}
var configuredGroups = currentGroups.GroupCodes.Count == 0
? "ningún grupo"
: string.Join(", ", currentGroups.GroupCodes);
return await Dialogs.ConfirmAsync(
"Cambio de asignación en Gestiona",
$"El grupo de destino {selectedGroup} no pertenece a tus grupos configurados ({configuredGroups}). " +
"Si continúas, la denuncia cambiará su asignación en Gestiona a un grupo distinto de los tuyos.",
"Cambiar asignación");
}
catch (Exception ex)
{
operationError = $"No se han podido comprobar tus grupos antes de la subida: {ex.Message}";
return false;
}
}
private async Task<string?> RegistrarHistorialGestionaAsync(
DenunciasGestiona denuncia,
string tipoSubida,
string grupo,
DateTime uploadedAtUtc,
string documentos)
{
try
{
await ApiDenuncias.RegisterGestionaUploadHistoryAsync(
new GestionaUploadHistoryCreateRequest(
denuncia.Id_Denuncia,
denuncia.Expediente_Gestiona ?? string.Empty,
denuncia.ExpedienteGestionaMostrable,
tipoSubida,
grupo,
uploadedAtUtc,
denuncia.NombreDenuncia ?? string.Empty,
documentos));
return null;
}
catch (Exception ex)
{
Console.Error.WriteLine($"No se ha podido registrar el historico de subida a Gestiona: {ex}");
return "La subida se ha completado, pero no se ha podido registrar el histórico operativo en la aplicación.";
}
} }
private async Task SincronizarExpedienteGestionaAsync(DenunciasGestiona denuncia, string fileUrl) private async Task SincronizarExpedienteGestionaAsync(DenunciasGestiona denuncia, string fileUrl)
@@ -1385,3 +1476,4 @@ else
} }
} }

View File

@@ -144,9 +144,9 @@
<thead> <thead>
<tr> <tr>
<th>Expediente</th> <th>Expediente</th>
<th>Asunto</th> <th>T<EFBFBD>tulo expediente</th>
<th>Fecha creaci<63>n</th> <th>Fecha creaci<63>n</th>
<th>Estado</th> <th><EFBFBD>ltima actividad Gestiona</th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
@@ -157,11 +157,11 @@
<td>@exp.CodigoExpediente</td> <td>@exp.CodigoExpediente</td>
<td>@exp.Asunto</td> <td>@exp.Asunto</td>
<td>@(exp.FechaCreacion?.ToLocalTime().ToString("dd/MM/yyyy HH:mm"))</td> <td>@(exp.FechaCreacion?.ToLocalTime().ToString("dd/MM/yyyy HH:mm"))</td>
<td>@exp.Estado</td> <td>@(exp.FechaUltimaModificacion?.ToLocalTime().ToString("dd/MM/yyyy HH:mm") ?? "-")</td>
<td class="text-end"> <td class="text-end">
<button class="btn btn-sm btn-outline-primary" <button class="btn btn-sm btn-outline-primary"
@onclick="() => ToggleDetalle(exp)"> @onclick="() => ToggleDetalle(exp)">
@(expedienteSeleccionado == exp ? "Ocultar" : "Abrir expediente") @(expedienteSeleccionado == exp ? "Ocultar" : "Consultar expediente Gestiona")
</button> </button>
</td> </td>
</tr> </tr>
@@ -172,26 +172,30 @@
<td colspan="5"> <td colspan="5">
<dl class="row mb-0"> <dl class="row mb-0">
<dt class="col-sm-2">Expediente</dt> <dt class="col-sm-2">Expediente</dt>
<dd class="col-sm-10">@exp.CodigoExpediente</dd> <dd class="col-sm-10">@FormatDetailValue(exp.CodigoExpediente)</dd>
<dt class="col-sm-2">Asunto</dt> <dt class="col-sm-2">T<EFBFBD>tulo expediente</dt>
<dd class="col-sm-10">@exp.Asunto</dd> <dd class="col-sm-10">@FormatDetailValue(exp.Asunto)</dd>
<dt class="col-sm-2">Procedimiento</dt>
<dd class="col-sm-10">@FormatDetailValue(exp.Procedimiento)</dd>
<dt class="col-sm-2">Fecha creaci<63>n</dt> <dt class="col-sm-2">Fecha creaci<63>n</dt>
<dd class="col-sm-10"> <dd class="col-sm-10">
@exp.FechaCreacion?.ToLocalTime().ToString("dd/MM/yyyy HH:mm") @(exp.FechaCreacion?.ToLocalTime().ToString("dd/MM/yyyy HH:mm") ?? "-")
</dd> </dd>
<dt class="col-sm-2">Estado</dt> <dt class="col-sm-2"><EFBFBD>ltima actividad en Gestiona</dt>
<dd class="col-sm-10">@exp.Estado</dd> <dd class="col-sm-10">
@(exp.FechaUltimaModificacion?.ToLocalTime().ToString("dd/MM/yyyy HH:mm") ?? "-")
</dd>
@if (!string.IsNullOrWhiteSpace(exp.FileUrl)) @if (!string.IsNullOrWhiteSpace(exp.UltimaAuditoriaMensaje))
{ {
<dt class="col-sm-2">Enlace Gestiona</dt> <dt class="col-sm-2"><EFBFBD>ltima acci<63>n</dt>
<dd class="col-sm-10"> <dd class="col-sm-10">@exp.UltimaAuditoriaMensaje</dd>
<a href="@exp.FileUrl" target="_blank">@exp.FileUrl</a>
</dd>
} }
</dl> </dl>
</td> </td>
</tr> </tr>
@@ -233,6 +237,8 @@
private bool IsModo(string valor) => string.Equals(modoFecha, valor, StringComparison.Ordinal); private bool IsModo(string valor) => string.Equals(modoFecha, valor, StringComparison.Ordinal);
private void SetModo(string valor) => modoFecha = valor; private void SetModo(string valor) => modoFecha = valor;
private static string FormatDetailValue(string? value)
=> string.IsNullOrWhiteSpace(value) ? "-" : value;
private void ToggleDetalle(ExpedienteTerceroDto exp) private void ToggleDetalle(ExpedienteTerceroDto exp)
{ {

View File

@@ -4,14 +4,35 @@
@using System.Globalization @using System.Globalization
@using GestionaDenunciasAN.Services @using GestionaDenunciasAN.Services
@using GestionaDenuncias.Shared.Models
@using Microsoft.AspNetCore.Components.Authorization
@inject ApiDenunciasClient ApiDenuncias @inject ApiDenunciasClient ApiDenuncias
@inject AuthenticationStateProvider AuthenticationStateProvider
@inject UiBusyService Busy @inject UiBusyService Busy
<PageTitle>Configuracion</PageTitle> <PageTitle>Configuracion</PageTitle>
<h3>Configuracion</h3> <h3>Configuracion</h3>
@if (!hasCheckedConfigurationAccess)
{
<div class="card mt-3">
<div class="card-body">
<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
Cargando configuracion...
</div>
</div>
}
else if (!hasConfigurationAccess)
{
<div class="alert alert-warning mt-3">
<strong>Configuracion restringida.</strong>
<div>Esta pantalla solo esta disponible para los usuarios autorizados del sistema.</div>
</div>
}
else
{
<div class="card mt-3"> <div class="card mt-3">
<div class="card-body"> <div class="card-body">
<h5 class="mb-3">Fecha de actualizaciones externas</h5> <h5 class="mb-3">Fecha de actualizaciones externas</h5>
@@ -75,10 +96,123 @@
</div> </div>
</div> </div>
<div class="card mt-3">
<div class="card-body">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
<div>
<h5 class="mb-1">Grupos de trabajo</h5>
<p class="text-muted mb-0">
Los usuarios pueden consultar y tratar denuncias de otros propietarios cuando comparten al menos un grupo.
</p>
</div>
<button type="button"
class="btn btn-outline-secondary btn-sm"
disabled="@isLoadingWorkGroups"
@onclick="LoadWorkGroupsAsync">
Actualizar usuarios
</button>
</div>
@if (isLoadingWorkGroups)
{
<div class="text-muted">
<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
Cargando usuarios y grupos...
</div>
}
else if (workGroupAdministration is null)
{
<div class="alert alert-warning mb-0">No se ha podido cargar la configuracion de grupos.</div>
}
else
{
<div class="table-responsive">
<table class="table table-sm align-middle mb-0">
<thead>
<tr>
<th>Usuario</th>
@foreach (var group in workGroupAdministration.Groups)
{
<th>
<span class="d-block">@group.Code</span>
<small class="text-muted fw-normal">@group.Name</small>
</th>
}
<th class="text-end">Accion</th>
</tr>
</thead>
<tbody>
@foreach (var user in workGroupAdministration.Users)
{
<tr @key="user.UserId">
<td>
<strong>@user.Username</strong>
@if (!GetSelectedGroups(user.UserId).Any())
{
<span class="badge text-bg-warning ms-2">Sin grupo</span>
}
</td>
@foreach (var group in workGroupAdministration.Groups)
{
var checkboxId = $"user-group-{user.UserId}-{group.Code}";
<td>
<div class="form-check">
<input id="@checkboxId"
class="form-check-input"
type="checkbox"
checked="@GetSelectedGroups(user.UserId).Contains(group.Code)"
disabled="@savingWorkGroupUsers.Contains(user.UserId)"
@onchange="args => ToggleUserGroup(user.UserId, group.Code, args)" />
<label class="form-check-label" for="@checkboxId">@group.Code</label>
</div>
</td>
}
<td class="text-end">
<button type="button"
class="btn btn-primary btn-sm"
disabled="@savingWorkGroupUsers.Contains(user.UserId)"
@onclick="() => SaveUserGroupsAsync(user)">
@(savingWorkGroupUsers.Contains(user.UserId) ? "Guardando..." : "Guardar")
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
}
@if (!string.IsNullOrWhiteSpace(workGroupNotice))
{
<div class="alert alert-success mt-3 mb-0">@workGroupNotice</div>
}
@if (!string.IsNullOrWhiteSpace(workGroupError))
{
<div class="alert alert-danger mt-3 mb-0">@workGroupError</div>
}
</div>
</div>
<div class="card mt-3"> <div class="card mt-3">
<div class="card-body"> <div class="card-body">
<h5 class="mb-3">Purga manual con reemplazo</h5> <h5 class="mb-3">Purga manual con reemplazo</h5>
<div class="border rounded px-3 py-2 bg-light mb-3">
<div class="d-flex flex-wrap align-items-center gap-2">
<span class="bi bi-key-fill text-primary" aria-hidden="true"></span>
<strong>Ultima clave diaria registrada</strong>
<span class="text-muted">@(string.IsNullOrWhiteSpace(latestEncryptionKeyDate) ? "No disponible" : latestEncryptionKeyDate)</span>
@if (!string.IsNullOrWhiteSpace(latestEncryptionKeyStatus))
{
<span class="badge rounded-pill text-bg-secondary">@latestEncryptionKeyStatus</span>
}
</div>
<div class="form-text mt-1">
Es la fecha de la fila mas reciente en <code>encryption_keys</code>; sirve para comprobar rapidamente que la rotacion diaria esta llegando a la base de datos.
</div>
</div>
<div class="alert alert-danger"> <div class="alert alert-danger">
<div class="d-flex gap-3"> <div class="d-flex gap-3">
<span class="bi bi-exclamation-triangle-fill fs-3" aria-hidden="true"></span> <span class="bi bi-exclamation-triangle-fill fs-3" aria-hidden="true"></span>
@@ -169,14 +303,31 @@
<div>@purgeErrorMessage</div> <div>@purgeErrorMessage</div>
</div> </div>
} }
}
@code { @code {
private const string RequiredConfirmation = "PURGAR CLAVE ACTUAL"; private const string RequiredConfirmation = "PURGAR CLAVE ACTUAL";
private static readonly HashSet<string> ConfigurationUsers = new(StringComparer.OrdinalIgnoreCase)
{
"pcornejo",
"Rgarciaglbk",
"eaguilarGestor"
};
private bool hasCheckedConfigurationAccess;
private bool hasConfigurationAccess;
private string externalUpdateCutoffDate = string.Empty; private string externalUpdateCutoffDate = string.Empty;
private string latestEncryptionKeyDate = string.Empty;
private string latestEncryptionKeyStatus = string.Empty;
private string? configurationNotice; private string? configurationNotice;
private string? configurationError; private string? configurationError;
private bool isSavingConfiguration; private bool isSavingConfiguration;
private WorkGroupAdministrationDto? workGroupAdministration;
private readonly Dictionary<long, HashSet<string>> selectedWorkGroups = [];
private readonly HashSet<long> savingWorkGroupUsers = [];
private bool isLoadingWorkGroups;
private string? workGroupNotice;
private string? workGroupError;
private string confirmation = string.Empty; private string confirmation = string.Empty;
private bool acceptedRisk; private bool acceptedRisk;
@@ -191,7 +342,16 @@
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync();
hasConfigurationAccess = IsConfigurationUser(authState.User.Identity?.Name);
hasCheckedConfigurationAccess = true;
if (!hasConfigurationAccess)
{
return;
}
await LoadConfigurationAsync(); await LoadConfigurationAsync();
await LoadWorkGroupsAsync();
} }
private async Task LoadConfigurationAsync() private async Task LoadConfigurationAsync()
@@ -201,6 +361,8 @@
configurationError = null; configurationError = null;
var config = await ApiDenuncias.GetAppConfigurationAsync(); var config = await ApiDenuncias.GetAppConfigurationAsync();
externalUpdateCutoffDate = ToSpanishDateText(config.ExternalUpdateCutoffDate); externalUpdateCutoffDate = ToSpanishDateText(config.ExternalUpdateCutoffDate);
latestEncryptionKeyDate = ToSpanishDateText(config.LatestEncryptionKeyDate);
latestEncryptionKeyStatus = ToSpanishKeyStatus(config.LatestEncryptionKeyStatus);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -219,6 +381,8 @@
var config = await ApiDenuncias.UpdateExternalUpdateCutoffDateAsync(date); var config = await ApiDenuncias.UpdateExternalUpdateCutoffDateAsync(date);
externalUpdateCutoffDate = ToSpanishDateText(config.ExternalUpdateCutoffDate); externalUpdateCutoffDate = ToSpanishDateText(config.ExternalUpdateCutoffDate);
latestEncryptionKeyDate = ToSpanishDateText(config.LatestEncryptionKeyDate);
latestEncryptionKeyStatus = ToSpanishKeyStatus(config.LatestEncryptionKeyStatus);
configurationNotice = string.IsNullOrWhiteSpace(externalUpdateCutoffDate) configurationNotice = string.IsNullOrWhiteSpace(externalUpdateCutoffDate)
? "Fecha de corte eliminada." ? "Fecha de corte eliminada."
: $"Fecha de corte guardada: {externalUpdateCutoffDate}."; : $"Fecha de corte guardada: {externalUpdateCutoffDate}.";
@@ -239,6 +403,98 @@
await SaveExternalUpdateCutoffDateAsync(); await SaveExternalUpdateCutoffDateAsync();
} }
private async Task LoadWorkGroupsAsync()
{
try
{
isLoadingWorkGroups = true;
workGroupError = null;
var response = await ApiDenuncias.GetWorkGroupAdministrationAsync();
ApplyWorkGroupAdministration(response);
}
catch (Exception ex)
{
workGroupError = $"No se han podido cargar los grupos de trabajo: {ex.Message}";
}
finally
{
isLoadingWorkGroups = false;
}
}
private void ApplyWorkGroupAdministration(WorkGroupAdministrationDto response)
{
workGroupAdministration = response;
selectedWorkGroups.Clear();
foreach (var user in response.Users)
{
selectedWorkGroups[user.UserId] = user.GroupCodes.ToHashSet(
StringComparer.OrdinalIgnoreCase);
}
}
private HashSet<string> GetSelectedGroups(long userId)
{
if (!selectedWorkGroups.TryGetValue(userId, out var groups))
{
groups = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
selectedWorkGroups[userId] = groups;
}
return groups;
}
private void ToggleUserGroup(
long userId,
string groupCode,
ChangeEventArgs args)
{
var groups = GetSelectedGroups(userId);
var enabled = args.Value is bool boolValue
? boolValue
: bool.TryParse(args.Value?.ToString(), out var parsed) && parsed;
if (enabled)
{
groups.Add(groupCode);
}
else
{
groups.Remove(groupCode);
}
}
private async Task SaveUserGroupsAsync(UserWorkGroupDto user)
{
var groups = GetSelectedGroups(user.UserId);
if (groups.Count == 0)
{
workGroupNotice = null;
workGroupError = $"El usuario {user.Username} debe pertenecer al menos a un grupo.";
return;
}
try
{
savingWorkGroupUsers.Add(user.UserId);
workGroupNotice = null;
workGroupError = null;
var response = await ApiDenuncias.UpdateUserWorkGroupsAsync(
user.Username,
groups.OrderBy(code => code, StringComparer.Ordinal).ToArray());
ApplyWorkGroupAdministration(response);
workGroupNotice = $"Grupos de {user.Username} actualizados correctamente.";
}
catch (Exception ex)
{
workGroupError = $"No se han podido guardar los grupos de {user.Username}: {ex.Message}";
}
finally
{
savingWorkGroupUsers.Remove(user.UserId);
}
}
private static string? ToIsoDateText(string? value) private static string? ToIsoDateText(string? value)
{ {
if (string.IsNullOrWhiteSpace(value)) if (string.IsNullOrWhiteSpace(value))
@@ -287,6 +543,25 @@
: value.Trim(); : value.Trim();
} }
private static string ToSpanishKeyStatus(string? status)
{
if (string.IsNullOrWhiteSpace(status))
{
return string.Empty;
}
return status.Trim().ToLowerInvariant() switch
{
"active" => "activa",
"purged" => "purgada",
_ => status.Trim()
};
}
private static bool IsConfigurationUser(string? username)
=> !string.IsNullOrWhiteSpace(username) &&
ConfigurationUsers.Contains(username.Trim());
private async Task ExecutePurgeAsync() private async Task ExecutePurgeAsync()
{ {
if (!CanExecutePurge) if (!CanExecutePurge)
@@ -305,6 +580,7 @@
try try
{ {
purgeResult = await ApiDenuncias.ExecuteCurrentManualPurgeAsync(); purgeResult = await ApiDenuncias.ExecuteCurrentManualPurgeAsync();
await LoadConfigurationAsync();
acceptedRisk = false; acceptedRisk = false;
confirmation = string.Empty; confirmation = string.Empty;
} }

View File

@@ -1,13 +1,15 @@
@page "/GestionZip" @page "/Entrada"
@rendermode @(new InteractiveServerRenderMode(prerender: false)) @rendermode @(new InteractiveServerRenderMode(prerender: false))
@attribute [Authorize] @attribute [Authorize]
@implements IAsyncDisposable @implements IAsyncDisposable
@using System.Globalization @using System.Globalization
@using System.Text.RegularExpressions
@using GestionaDenunciasAN.Models @using GestionaDenunciasAN.Models
@inject AuthenticationStateProvider AuthenticationStateProvider @inject AuthenticationStateProvider AuthenticationStateProvider
@inject ApiDenunciasClient ApiDenuncias @inject ApiDenunciasClient ApiDenuncias
@inject IJSRuntime JSRuntime @inject IJSRuntime JSRuntime
@inject UiBusyService Busy @inject UiBusyService Busy
@inject UiDialogService Dialogs
<PageTitle>Entrada de denuncias</PageTitle> <PageTitle>Entrada de denuncias</PageTitle>
@@ -73,9 +75,84 @@
.report-detail-close { .report-detail-close {
flex: 0 0 auto; flex: 0 0 auto;
} }
.report-row-disabled {
opacity: 0.62;
}
.report-row-disabled td {
cursor: not-allowed;
}
.inbox-table-wrap {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.inbox-table {
min-width: 1080px;
font-size: 0.84rem;
white-space: nowrap;
}
.inbox-table th,
.inbox-table td {
vertical-align: middle;
padding: 0.45rem 0.5rem;
}
.inbox-report-cell {
width: 4.5rem;
}
.inbox-channel-cell {
width: 10.5rem;
max-width: 10.5rem;
}
.inbox-channel-cell .text-truncate {
max-width: 100%;
}
.inbox-tracking-cell {
width: 11rem;
max-width: 11rem;
overflow: hidden;
text-overflow: ellipsis;
}
.inbox-activity-cell {
width: 9.25rem;
max-width: 12rem;
overflow: hidden;
text-overflow: ellipsis;
}
.inbox-action-cell {
width: 5.5rem;
text-align: right;
}
.inbox-detail-button {
padding-inline: 0.55rem;
white-space: nowrap;
}
@@media (max-width: 768px) {
.inbox-table {
min-width: 1020px;
font-size: 0.8rem;
}
.inbox-table th,
.inbox-table td {
padding: 0.4rem 0.42rem;
}
}
</style> </style>
<div class="container py-4"> <div class="container-fluid py-4 px-3 px-xl-4">
<div class="d-flex flex-column flex-lg-row justify-content-between align-items-lg-center gap-3 mb-4"> <div class="d-flex flex-column flex-lg-row justify-content-between align-items-lg-center gap-3 mb-4">
<div> <div>
<h3 class="mb-1">Entrada de denuncias</h3> <h3 class="mb-1">Entrada de denuncias</h3>
@@ -96,43 +173,43 @@
<div class="alert @FilterWarningCss mb-4">@FilterWarningMessage</div> <div class="alert @FilterWarningCss mb-4">@FilterWarningMessage</div>
} }
<div class="row g-4"> <div class="card shadow-sm mb-3">
<div class="col-lg-4"> <div class="card-body py-3">
<div class="card shadow-sm h-100"> <div class="row g-3 align-items-end">
<div class="card-body"> <div class="col-xl-5 col-lg-6">
<h5 class="card-title">Sesion GlobalLeaks</h5> <h5 class="card-title mb-2">Sesion GlobalLeaks</h5>
<p class="mb-2"><strong>Usuario:</strong> @CurrentUsername</p> <div class="d-flex flex-wrap gap-3 small">
<p class="mb-2"><strong>Estado:</strong> @SessionStatusText</p> <span><strong>Usuario:</strong> @CurrentUsername</span>
<p class="mb-2"> <span><strong>Estado:</strong> @SessionStatusText</span>
<strong>Ultima descarga registrada:</strong> <span>
@(UserInboxState.LastDownloadedReportMomentUtc is null <strong>Ultima descarga:</strong>
? "Sin descargas previas" @(UserInboxState.LastDownloadedReportMomentUtc is null
: UserInboxState.LastDownloadedReportMomentUtc.Value.ToLocalTime().ToString("dd/MM/yyyy HH:mm")) ? "Sin descargas previas"
</p> : UserInboxState.LastDownloadedReportMomentUtc.Value.ToLocalTime().ToString("dd/MM/yyyy HH:mm"))
<p class="text-muted small mb-4"> </span>
La base de datos lleva la cuenta de lo que ya ha descargado este usuario, de lo que han descargado otros
y de si el expediente ya esta creado en Gestiona.
</p>
<div class="mb-3">
<label class="form-label">Nuevo codigo 2FA</label>
<input class="form-control"
@bind="RenewAuthcode"
maxlength="6"
inputmode="numeric"
disabled="@(!RenewPrepared || RenewBusy)"
placeholder="123456" />
</div> </div>
</div>
<div class="col-xl-5 col-lg-4">
<label class="form-label mb-1">Nuevo codigo 2FA</label>
<input class="form-control"
@bind="RenewAuthcode"
maxlength="6"
inputmode="numeric"
disabled="@(!RenewPrepared || RenewBusy)"
placeholder="123456" />
</div>
<div class="col-xl-2 col-lg-2">
<button type="button" class="btn btn-primary w-100" @onclick="RenewSessionAsync" disabled="@RenewBusy"> <button type="button" class="btn btn-primary w-100" @onclick="RenewSessionAsync" disabled="@RenewBusy">
@(RenewBusy ? SessionRenewBusyText : SessionRenewButtonText) @(RenewBusy ? SessionRenewBusyText : SessionRenewButtonText)
</button> </button>
</div> </div>
</div> </div>
</div> </div>
</div>
<div class="col-lg-8"> <div class="card shadow-sm">
<div class="card shadow-sm">
<div class="card-body"> <div class="card-body">
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-3 mb-3"> <div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-3 mb-3">
<div> <div>
@@ -152,7 +229,8 @@
<select class="form-select" value="@Filter" @onchange="OnFilterChanged"> <select class="form-select" value="@Filter" @onchange="OnFilterChanged">
<option value="all">Todas</option> <option value="all">Todas</option>
<option value="new">Nuevas / sin leer</option> <option value="new">Nuevas / sin leer</option>
<option value="updated">Actualizaciones</option> <option value="updated">Actualizaciones del ciudadano</option>
<option value="receiver">Actividad OAAF</option>
</select> </select>
</div> </div>
@@ -206,6 +284,12 @@
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-3 mb-3"> <div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-3 mb-3">
<div class="text-muted"> <div class="text-muted">
@VisibleReports.Count denuncia(s) visibles, @SelectedReportsCount seleccionada(s). @VisibleReports.Count denuncia(s) visibles, @SelectedReportsCount seleccionada(s).
@if (VisibleReports.Any(report => report.Accessible == false))
{
<span class="d-block small text-danger">
@VisibleReports.Count(report => report.Accessible == false) denuncia(s) marcadas por GlobalLeaks como no accesibles para este usuario.
</span>
}
@if (UserInboxState.LastDownloadedReportMomentUtc is not null) @if (UserInboxState.LastDownloadedReportMomentUtc is not null)
{ {
<span class="d-block small"> <span class="d-block small">
@@ -214,7 +298,7 @@
} }
</div> </div>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<button type="button" class="btn btn-outline-secondary btn-sm" @onclick="ToggleSelectAll" disabled="@(!VisibleReports.Any())"> <button type="button" class="btn btn-outline-secondary btn-sm" @onclick="ToggleSelectAll" disabled="@(!VisibleReports.Any(CanUseReport))">
@SelectAllLabel @SelectAllLabel
</button> </button>
<button type="button" class="btn btn-success btn-sm" @onclick="ImportSelectedAsync" disabled="@(!CanImportSelected)"> <button type="button" class="btn btn-success btn-sm" @onclick="ImportSelectedAsync" disabled="@(!CanImportSelected)">
@@ -223,39 +307,41 @@
</div> </div>
</div> </div>
<div class="table-responsive"> <div class="table-responsive inbox-table-wrap">
<table class="table table-hover align-middle"> <table class="table table-hover table-sm align-middle inbox-table">
<thead> <thead>
<tr> <tr>
<th style="width: 3rem;"></th> <th style="width: 3rem;"></th>
<th>#</th> <th>#</th>
<th>Canal</th> <th>Canal</th>
<th>Presentacion</th> <th>Presentacion</th>
<th>Ultima actualizacion</th> <th title="Fecha de la última aportación o comentario realizado por el ciudadano.">Actividad ciudadano</th>
<th>Estado</th> <th title="Fecha de la última comunicación o fichero incorporado por un gestor de la OAAF.">Actividad OAAF</th>
<th>Seguimiento</th> <th title="Resume si la denuncia es nueva, tiene cambios pendientes o ya está actualizada en Gestiona.">Estado</th>
<th style="width: 7rem;">Detalle</th> <th title="Indica si tu usuario gestor del buzón puede acceder a esta denuncia.">Acceso</th>
</tr> <th title="Indica si el cambio está pendiente, descargado en la aplicación o ya incorporado a Gestiona.">Gestiona</th>
<th class="inbox-action-cell" title="Abre el detalle disponible en GlobalLeaks.">Detalle</th>
</tr>
</thead> </thead>
<tbody> <tbody>
@if (!CanUseGlobalLeaks) @if (!CanUseGlobalLeaks)
{ {
<tr> <tr>
<td colspan="8" class="text-muted"> <td colspan="10" class="text-muted">
Renueva la sesion de GlobalLeaks con un 2FA valido para cargar la bandeja. Renueva la sesion de GlobalLeaks con un 2FA valido para cargar la bandeja.
</td> </td>
</tr> </tr>
} }
else if (ReportsBusy) else if (ReportsBusy)
{ {
<tr> <tr>
<td colspan="8" class="text-muted">Cargando denuncias...</td> <td colspan="10" class="text-muted">Cargando denuncias...</td>
</tr> </tr>
} }
else if (!VisibleReports.Any()) else if (!VisibleReports.Any())
{ {
<tr> <tr>
<td colspan="8" class="text-muted">No hay denuncias con los filtros actuales.</td> <td colspan="10" class="text-muted">No hay denuncias con los filtros actuales.</td>
</tr> </tr>
} }
else else
@@ -266,33 +352,35 @@
<td> <td>
<input type="checkbox" <input type="checkbox"
checked="@SelectedIds.Contains(report.Id)" checked="@SelectedIds.Contains(report.Id)"
@onchange="@((ChangeEventArgs args) => ToggleSelection(report.Id, args))" /> disabled="@(!CanUseReport(report) || ImportBusy)"
title="@GetReportActionBlockReason(report)"
@onchange="@((ChangeEventArgs args) => ToggleSelection(report, args))" />
</td> </td>
<td><strong>#@(report.Progressive ?? 0)</strong></td> <td class="inbox-report-cell"><strong>#@(report.Progressive ?? 0)</strong></td>
<td>@(report.ContextName ?? report.ContextId ?? "-")</td> <td class="inbox-channel-cell" title="@(report.ContextName ?? report.ContextId ?? string.Empty)"><span class="d-inline-block text-truncate">@(report.ContextName ?? report.ContextId ?? "-")</span></td>
<td>@FormatDate(report.CreationDate)</td> <td>@FormatDate(report.CreationDate)</td>
<td>@FormatDate(report.UpdateDate)</td> <td class="inbox-activity-cell">@FormatCitizenActivity(report)</td>
<td class="inbox-activity-cell" title="@GetReceiverActivityTitle(report)">@FormatReceiverActivity(report)</td>
<td> <td>
<span class="badge @GetStatusBadgeCss(report)"> <span class="badge @GetStatusBadgeCss(report)" title="@GetStatusHelp(report)">
@GetStatusLabel(report) @GetStatusLabel(report)
</span>
</td>
<td>
<span class="badge @GetAccessBadgeCss(report)" title="@GetAccessHelp(report)">
@GetAccessLabel(report)
</span> </span>
</td> </td>
<td> <td class="inbox-tracking-cell">
<span class="badge @GetTrackingBadgeCss(report)"> <span class="badge @GetTrackingBadgeCss(report)" title="@GetTrackingHelp(report)">@GetTrackingLabel(report)</span>
@GetTrackingLabel(report)
</span>
@if (!string.IsNullOrWhiteSpace(report.TrackingNote))
{
<div class="small text-muted mt-1">@report.TrackingNote</div>
}
</td> </td>
<td> <td class="inbox-action-cell">
<button type="button" <button type="button"
class="btn btn-outline-secondary btn-sm" class="btn btn-outline-secondary btn-sm inbox-detail-button"
title="Consulta mensajes y ficheros. GlobalLeaks puede marcar la denuncia como leida." title="@GetReportDetailTitle(report)"
@onclick="@(() => OpenReportDetailAsync(report))" @onclick="@(() => OpenReportDetailAsync(report))"
disabled="@DetailBusy"> disabled="@(DetailBusy || report.Accessible == false)">
Ver detalle Detalle
</button> </button>
</td> </td>
</tr> </tr>
@@ -303,8 +391,6 @@
</div> </div>
</div> </div>
</div> </div>
</div>
</div>
</div> </div>
@if (DetailModalVisible) @if (DetailModalVisible)
@@ -351,7 +437,7 @@
{ {
<div class="border rounded p-3 mb-2 @(comment.IsNew ? "border-success bg-success-subtle" : "bg-light")"> <div class="border rounded p-3 mb-2 @(comment.IsNew ? "border-success bg-success-subtle" : "bg-light")">
<div class="d-flex flex-column flex-md-row justify-content-between gap-2 small text-muted mb-2"> <div class="d-flex flex-column flex-md-row justify-content-between gap-2 small text-muted mb-2">
<strong>@GetCommentAuthorLabel(comment.Type)</strong> <strong>@GetCommentAuthorLabel(comment)</strong>
<span>@FormatDate(comment.CreationDate)</span> <span>@FormatDate(comment.CreationDate)</span>
</div> </div>
@if (comment.IsNew) @if (comment.IsNew)
@@ -401,6 +487,10 @@
<span class="small text-muted">@FormatDate(file.CreationDate)</span> <span class="small text-muted">@FormatDate(file.CreationDate)</span>
</div> </div>
<div class="small text-muted">@FormatBytes(file.Size) @(string.IsNullOrWhiteSpace(file.ContentType) ? string.Empty : $" - {file.ContentType}")</div> <div class="small text-muted">@FormatBytes(file.Size) @(string.IsNullOrWhiteSpace(file.ContentType) ? string.Empty : $" - {file.ContentType}")</div>
@if (!string.IsNullOrWhiteSpace(file.AuthorName))
{
<div class="small text-muted">Añadido por @file.AuthorName</div>
}
@if (file.IsNew) @if (file.IsNew)
{ {
<span class="badge bg-success mt-2">Nuevo</span> <span class="badge bg-success mt-2">Nuevo</span>
@@ -451,7 +541,7 @@
private bool CanUseGlobalLeaks => SessionInfo?.HasActiveSession == true; private bool CanUseGlobalLeaks => SessionInfo?.HasActiveSession == true;
private bool RenewPrepared => !string.IsNullOrWhiteSpace(RenewPendingLoginId); private bool RenewPrepared => !string.IsNullOrWhiteSpace(RenewPendingLoginId);
private int SelectedReportsCount => SelectedIds.Count; private int SelectedReportsCount => Reports.Count(report => SelectedIds.Contains(report.Id) && CanUseReport(report));
private bool CanImportSelected => CanUseGlobalLeaks && SelectedReportsCount > 0 && !ImportBusy; private bool CanImportSelected => CanUseGlobalLeaks && SelectedReportsCount > 0 && !ImportBusy;
private string SessionStatusText => SessionInfo is null private string SessionStatusText => SessionInfo is null
? "Sin credenciales guardadas" ? "Sin credenciales guardadas"
@@ -464,7 +554,7 @@
private string SessionRenewBusyText => RenewPrepared private string SessionRenewBusyText => RenewPrepared
? "Validando 2FA..." ? "Validando 2FA..."
: "Preparando..."; : "Preparando...";
private string SelectAllLabel => VisibleReports.Count > 0 && VisibleReports.All(report => SelectedIds.Contains(report.Id)) private string SelectAllLabel => VisibleReports.Any(CanUseReport) && VisibleReports.Where(CanUseReport).All(report => SelectedIds.Contains(report.Id))
? "Deseleccionar todas" ? "Deseleccionar todas"
: "Seleccionar todas"; : "Seleccionar todas";
@@ -622,6 +712,37 @@
return; return;
} }
var selectedReports = Reports
.Where(report => SelectedIds.Contains(report.Id))
.Where(CanUseReport)
.OrderBy(report => report.Progressive ?? 0)
.ToList();
if (selectedReports.Count == 0)
{
SetStatus("No hay denuncias accesibles seleccionadas para importar.", "alert-warning");
return;
}
var reportsFromOtherOwners = selectedReports
.Where(report => report.RequiresOwnerConfirmation)
.ToArray();
if (reportsFromOtherOwners.Length > 0)
{
var ownerSummary = string.Join(
Environment.NewLine,
reportsFromOtherOwners.Select(report =>
$"Denuncia #{report.Progressive ?? 0}: propiedad de {report.OwnerUsername}."));
var confirmed = await Dialogs.ConfirmAsync(
"Denuncias asignadas a otros usuarios",
$"Vas a importar denuncias asignadas a otros usuarios de tu grupo:{Environment.NewLine}{Environment.NewLine}{ownerSummary}{Environment.NewLine}{Environment.NewLine}La importación quedará registrada con tu usuario.",
"Importar denuncias");
if (!confirmed)
{
return;
}
}
ImportBusy = true; ImportBusy = true;
var importedCount = 0; var importedCount = 0;
var errors = new List<string>(); var errors = new List<string>();
@@ -629,11 +750,6 @@
try try
{ {
var selectedReports = Reports
.Where(report => SelectedIds.Contains(report.Id))
.OrderBy(report => report.Progressive ?? 0)
.ToList();
using var busy = Busy.Show( using var busy = Busy.Show(
"Importando denuncias", "Importando denuncias",
$"Descargando y procesando {selectedReports.Count} denuncia(s) desde GlobalLeaks.", $"Descargando y procesando {selectedReports.Count} denuncia(s) desde GlobalLeaks.",
@@ -651,7 +767,10 @@
try try
{ {
var result = await ApiDenuncias.ImportReportAsync(report, CancellationToken.None); var result = await ApiDenuncias.ImportReportAsync(
report,
report.RequiresOwnerConfirmation,
CancellationToken.None);
importedCount += result.ImportedCount; importedCount += result.ImportedCount;
errors.AddRange(result.Errors.Select(error => $"#{report.Progressive ?? 0}: {error}")); errors.AddRange(result.Errors.Select(error => $"#{report.Progressive ?? 0}: {error}"));
if (result.Warnings is not null) if (result.Warnings is not null)
@@ -685,6 +804,20 @@
{ {
SetStatus($"Se han importado {importedCount} denuncia(s) desde GlobalLeaks.", "alert-success"); SetStatus($"Se han importado {importedCount} denuncia(s) desde GlobalLeaks.", "alert-success");
} }
else if (errors.Count == 0 && importedCount == 0)
{
var parts = new List<string>
{
"No se ha incorporado ninguna denuncia nueva."
};
if (warnings.Count > 0)
{
parts.Add($"Avisos: {string.Join(" | ", warnings)}");
}
SetStatus(string.Join(" ", parts), "alert-warning");
}
else else
{ {
var parts = new List<string> var parts = new List<string>
@@ -713,6 +846,12 @@
private async Task OpenReportDetailAsync(ReportDto report) private async Task OpenReportDetailAsync(ReportDto report)
{ {
if (report.Accessible == false)
{
SetStatus($"La denuncia #{report.Progressive ?? 0} no es accesible para este usuario en GlobalLeaks.", "alert-warning");
return;
}
if (!CanUseGlobalLeaks) if (!CanUseGlobalLeaks)
{ {
SetStatus("Renueva antes la sesion de GlobalLeaks para consultar el detalle.", "alert-warning"); SetStatus("Renueva antes la sesion de GlobalLeaks para consultar el detalle.", "alert-warning");
@@ -790,7 +929,10 @@
filtered = Filter switch filtered = Filter switch
{ {
"new" => filtered.Where(report => string.IsNullOrWhiteSpace(report.AccessDate) || string.Equals(report.Status, "new", StringComparison.OrdinalIgnoreCase)), "new" => filtered.Where(report => string.IsNullOrWhiteSpace(report.AccessDate) || string.Equals(report.Status, "new", StringComparison.OrdinalIgnoreCase)),
"updated" => filtered.Where(report => report.Updated), "updated" => filtered.Where(report =>
report.CitizenHasNewActivity ||
(!report.ActivityAnalyzed && report.Updated)),
"receiver" => filtered.Where(report => report.ReceiverHasNewActivity),
_ => filtered _ => filtered
}; };
@@ -813,7 +955,10 @@
.OrderByDescending(report => report.Progressive ?? 0) .OrderByDescending(report => report.Progressive ?? 0)
.ToList(); .ToList();
var validIds = Reports.Select(report => report.Id).ToHashSet(StringComparer.OrdinalIgnoreCase); var validIds = Reports
.Where(CanUseReport)
.Select(report => report.Id)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
SelectedIds.RemoveWhere(id => !validIds.Contains(id)); SelectedIds.RemoveWhere(id => !validIds.Contains(id));
} }
@@ -887,16 +1032,23 @@
return reports; return reports;
} }
private void ToggleSelection(string reportId, ChangeEventArgs args) private void ToggleSelection(ReportDto report, ChangeEventArgs args)
{ {
if (report.Accessible == false)
{
SelectedIds.Remove(report.Id);
StateHasChanged();
return;
}
var isChecked = GetCheckedValue(args); var isChecked = GetCheckedValue(args);
if (isChecked) if (isChecked)
{ {
SelectedIds.Add(reportId); SelectedIds.Add(report.Id);
} }
else else
{ {
SelectedIds.Remove(reportId); SelectedIds.Remove(report.Id);
} }
StateHasChanged(); StateHasChanged();
@@ -904,9 +1056,12 @@
private void ToggleSelectAll() private void ToggleSelectAll()
{ {
var shouldSelect = !VisibleReports.All(report => SelectedIds.Contains(report.Id)); var selectableReports = VisibleReports
.Where(CanUseReport)
.ToList();
var shouldSelect = selectableReports.Count > 0 && !selectableReports.All(report => SelectedIds.Contains(report.Id));
foreach (var report in VisibleReports) foreach (var report in selectableReports)
{ {
if (shouldSelect) if (shouldSelect)
{ {
@@ -980,15 +1135,70 @@
} }
private static string FormatDate(string? value) private static string FormatDate(string? value)
{
return FormatOptionalDate(value) ?? "-";
}
private static string? FormatOptionalDate(string? value)
{ {
if (string.IsNullOrWhiteSpace(value)) if (string.IsNullOrWhiteSpace(value))
{ {
return "-"; return null;
} }
return DateTimeOffset.TryParse(value, out var parsed) return DateTimeOffset.TryParse(value, out var parsed)
? parsed.ToLocalTime().ToString("dd/MM/yyyy HH:mm") ? parsed.ToLocalTime().ToString("dd/MM/yyyy HH:mm")
: "-"; : null;
}
private static string FormatCitizenActivity(ReportDto report)
{
var citizenDate = FormatOptionalDate(report.CitizenLastActivity);
if (!string.IsNullOrWhiteSpace(citizenDate))
{
return citizenDate;
}
if (!report.ActivityAnalyzed)
{
return "No analizada";
}
var accessDate = FormatOptionalDate(report.WhistleblowerLastAccess);
if (!string.IsNullOrWhiteSpace(accessDate))
{
return accessDate;
}
return "Sin fecha";
}
private static string FormatReceiverActivity(ReportDto report)
{
var receiverDate = FormatOptionalDate(report.ReceiverLastActivity);
if (!string.IsNullOrWhiteSpace(receiverDate))
{
return string.IsNullOrWhiteSpace(report.ReceiverLastActivityAuthorName)
? receiverDate
: $"{receiverDate} · {report.ReceiverLastActivityAuthorName}";
}
if (!report.ActivityAnalyzed)
{
return "No analizada";
}
return "Sin actividad";
}
private static string GetReceiverActivityTitle(ReportDto report)
{
if (string.IsNullOrWhiteSpace(report.ReceiverLastActivityAuthorName))
{
return FormatReceiverActivity(report);
}
return $"Ultima actividad OAAF realizada por {report.ReceiverLastActivityAuthorName}.";
} }
private static string FormatBytes(long? value) private static string FormatBytes(long? value)
@@ -1012,16 +1222,45 @@
return $"{bytes / 1024d / 1024d:0.#} MB"; return $"{bytes / 1024d / 1024d:0.#} MB";
} }
private static string GetCommentAuthorLabel(string? type) private static string GetCommentAuthorLabel(ReportCommentDto comment)
=> string.Equals(type, "whistleblower", StringComparison.OrdinalIgnoreCase) => IsWhistleblowerActivityType(comment.Type)
? "Denunciante" ? "Denunciante"
: string.Equals(type, "receiver", StringComparison.OrdinalIgnoreCase) : IsReceiverActivityType(comment.Type)
? "Receptor" ? string.IsNullOrWhiteSpace(comment.AuthorName)
? "Gestor OAAF"
: $"Gestor OAAF: {comment.AuthorName}"
: "Comentario"; : "Comentario";
private static bool IsWhistleblowerActivityType(string? value)
=> MatchesAny(value, "whistleblower", "citizen", "source", "tipper", "submitter", "denunciante");
private static bool IsReceiverActivityType(string? value)
=> MatchesAny(value, "receiver", "recipient", "admin", "administrator", "operator", "staff", "custodian", "moderator", "gestor", "oaaf");
private static bool MatchesAny(string? value, params string[] candidates)
{
if (string.IsNullOrWhiteSpace(value))
{
return false;
}
var normalized = NormalizeActivityType(value);
return candidates.Any(candidate =>
{
var candidateToken = NormalizeActivityType(candidate);
return normalized == candidateToken ||
normalized.StartsWith(candidateToken + " ", StringComparison.Ordinal) ||
normalized.EndsWith(" " + candidateToken, StringComparison.Ordinal) ||
normalized.Contains(" " + candidateToken + " ", StringComparison.Ordinal);
});
}
private static string NormalizeActivityType(string value)
=> Regex.Replace(value.Trim().ToLowerInvariant(), @"[^a-z0-9]+", " ").Trim();
private static DateTimeOffset? GetEffectiveMoment(ReportDto report) private static DateTimeOffset? GetEffectiveMoment(ReportDto report)
{ {
return ParseDate(report.UpdateDate) ?? ParseDate(report.CreationDate); return ParseDate(report.CitizenLastActivity) ?? ParseDate(report.ReceiverLastActivity) ?? ParseDate(report.UpdateDate) ?? ParseDate(report.CreationDate);
} }
private static DateTimeOffset? ParseDate(string? value) private static DateTimeOffset? ParseDate(string? value)
@@ -1043,14 +1282,29 @@
return "Sin leer"; return "Sin leer";
} }
if (report.Updated) if (report.CitizenHasNewActivity)
{
return "Actualización ciudadano";
}
if (IsReceiverOnlyUpdate(report))
{
return "Actividad OAAF";
}
if (report.AlreadyInGestiona && !report.ActivityAnalyzed)
{
return "Sin comprobar";
}
if (IsUpToDateInGestiona(report))
{ {
return "Actualizada"; return "Actualizada";
} }
return string.Equals(report.Status, "closed", StringComparison.OrdinalIgnoreCase) return string.Equals(report.Status, "closed", StringComparison.OrdinalIgnoreCase)
? "Cerrada" ? "Cerrada"
: "Abierta"; : "Nueva denuncia";
} }
private static string GetStatusBadgeCss(ReportDto report) private static string GetStatusBadgeCss(ReportDto report)
@@ -1060,16 +1314,125 @@
return "bg-warning text-dark"; return "bg-warning text-dark";
} }
if (report.Updated) if (report.CitizenHasNewActivity)
{ {
return "bg-info text-dark"; return "bg-info text-dark";
} }
if (IsReceiverOnlyUpdate(report))
{
return "bg-secondary";
}
if (report.AlreadyInGestiona && !report.ActivityAnalyzed)
{
return "bg-warning text-dark";
}
if (IsUpToDateInGestiona(report))
{
return "bg-light text-dark";
}
return string.Equals(report.Status, "closed", StringComparison.OrdinalIgnoreCase) return string.Equals(report.Status, "closed", StringComparison.OrdinalIgnoreCase)
? "bg-secondary" ? "bg-secondary"
: "bg-primary"; : "bg-primary";
} }
private static bool IsUpToDateInGestiona(ReportDto report)
{
if (!report.AlreadyInGestiona)
{
return false;
}
var lastUpload = ParseDate(report.LastGestionaUploadAt);
var latestActivity = new[]
{
ParseDate(report.CitizenLastActivity),
ParseDate(report.ReceiverLastActivity)
}
.Where(value => value is not null)
.Select(value => value!.Value)
.DefaultIfEmpty()
.Max();
if (lastUpload is not null && latestActivity != default)
{
return lastUpload.Value >= latestActivity;
}
return report.ActivityAnalyzed &&
!report.CitizenHasNewActivity &&
!report.ReceiverHasNewActivity;
}
private static string GetStatusHelp(ReportDto report)
=> GetStatusLabel(report) switch
{
"Nueva denuncia" => "La denuncia todavía no tiene expediente creado desde la aplicación y está pendiente de actuación.",
"Actualización ciudadano" => "El ciudadano ha añadido un comentario, un fichero o una modificación posterior a la última subida a Gestiona.",
"Actividad OAAF" => "Un gestor de la OAAF ha realizado una comunicación o añadido un fichero posterior a la última subida a Gestiona.",
"Actualizada" => "La última subida a Gestiona es igual o posterior a la actividad del ciudadano y de la OAAF detectada en el buzón.",
"Sin comprobar" => "No se ha podido comparar en este momento la actividad del buzón con la última subida a Gestiona.",
"Cerrada" => "La denuncia figura cerrada en GlobalLeaks.",
"Sin leer" => "La denuncia todavía no se ha abierto con este usuario en GlobalLeaks.",
_ => string.Empty
};
private static string GetAccessLabel(ReportDto report)
=> report.Accessible switch
{
true => "Accesible",
false => "Sin acceso",
_ => "Sin dato"
};
private static string GetAccessBadgeCss(ReportDto report)
=> report.Accessible switch
{
true => "bg-success",
false => "bg-danger",
_ => "bg-light text-dark"
};
private static string GetAccessHelp(ReportDto report)
=> report.Accessible switch
{
true => "Tu usuario gestor del buzón tiene acceso a esta denuncia.",
false => "GlobalLeaks indica que tu usuario gestor del buzón no tiene acceso a esta denuncia.",
_ => "GlobalLeaks no ha informado si tu usuario puede acceder a esta denuncia."
};
private static bool CanUseReport(ReportDto report)
=> report.Accessible != false;
private static bool IsReceiverOnlyUpdate(ReportDto report)
=> report.AlreadyInGestiona &&
report.ActivityAnalyzed &&
!report.CitizenHasNewActivity &&
report.ReceiverHasNewActivity;
private static string? GetReportActionBlockReason(ReportDto report)
{
if (report.Accessible == false)
{
return "GlobalLeaks indica que esta denuncia no es accesible para este usuario.";
}
return null;
}
private static string GetReportDetailTitle(ReportDto report)
{
if (report.Accessible == false)
{
return "GlobalLeaks indica que esta denuncia no es accesible para este usuario.";
}
return "Consulta mensajes y ficheros. GlobalLeaks puede marcar la denuncia como leida.";
}
private static string GetTrackingLabel(ReportDto report) private static string GetTrackingLabel(ReportDto report)
{ {
if (report.AlreadyInGestiona) if (report.AlreadyInGestiona)
@@ -1120,8 +1483,47 @@
return "bg-light text-dark"; return "bg-light text-dark";
} }
private static string GetTrackingHelp(ReportDto report)
{
if (report.AlreadyInGestiona)
{
return "La denuncia ya tiene un expediente creado en Gestiona. El estado indica si existe actividad posterior pendiente.";
}
if (report.DownloadedByAnotherUser)
{
return string.IsNullOrWhiteSpace(report.TrackingNote)
? "Otro usuario ya la descargó en la aplicación, pero todavía no se ha materializado la subida a Gestiona."
: $"{report.TrackingNote}. Todavía no se ha materializado la subida a Gestiona.";
}
if (report.DownloadedByCurrentUser)
{
return string.IsNullOrWhiteSpace(report.TrackingNote)
? "Ya la descargaste en la aplicación, pero todavía no se ha materializado la subida a Gestiona."
: $"{report.TrackingNote}. Todavía no se ha materializado la subida a Gestiona.";
}
if (report.AlreadyImported)
{
return "La denuncia está incorporada a la aplicación, pero todavía no se ha subido a Gestiona.";
}
return "La denuncia todavía no se ha descargado ni subido a Gestiona.";
}
private static string? GetReportRowCss(ReportDto report) private static string? GetReportRowCss(ReportDto report)
{ {
if (report.Accessible == false)
{
return "table-danger report-row-disabled";
}
if (IsReceiverOnlyUpdate(report))
{
return "table-secondary";
}
if (report.AlreadyInGestiona) if (report.AlreadyInGestiona)
{ {
return "table-success"; return "table-success";

View File

@@ -3,14 +3,16 @@
@attribute [Authorize] @attribute [Authorize]
@using GestionaDenunciasAN.Models @using GestionaDenunciasAN.Models
@using GestionaDenunciasAN.Services @using GestionaDenunciasAN.Services
@using GestionaDenuncias.Shared.Models
@using System.Globalization @using System.Globalization
@attribute [StreamRendering] @attribute [StreamRendering]
@inject GestionaDenunciasAN.Models.UserState userState @inject GestionaDenunciasAN.Models.UserState userState
@inject NavigationManager Navigation @inject NavigationManager Navigation
@inject IHostEnvironment HostEnvironment @inject IHostEnvironment HostEnvironment
@inject IDenunciaStore DenunciaStore @inject IDenunciaStore DenunciaStore
@inject ApiDenunciasClient ApiDenuncias
<PageTitle>Denuncias Gesti<EFBFBD>n</PageTitle> <PageTitle>Denuncias Gestión</PageTitle>
<style> <style>
/* Contenedor para la lista de denuncias */ /* Contenedor para la lista de denuncias */
@@ -64,7 +66,7 @@
.card-body { .card-body {
padding: 1.25rem; padding: 1.25rem;
} }
/* Estilos para los t<EFBFBD>tulos de secci<EFBFBD>n dentro de la card */ /* Estilos para los títulos de sección dentro de la card */
.section-heading { .section-heading {
text-align: center; text-align: center;
font-weight: bold; font-weight: bold;
@@ -75,9 +77,9 @@
} }
</style> </style>
<h1>Denuncias en Gesti<EFBFBD>n</h1> <h1>Denuncias en Gestión</h1>
<!-- Campo de b<EFBFBD>squeda --> <!-- Campo de búsqueda -->
<input type="text" <input type="text"
class="form-control" class="form-control"
placeholder="Buscar denuncias..." placeholder="Buscar denuncias..."
@@ -89,9 +91,10 @@
{ {
<div class="alert alert-info">Cargando datos...</div> <div class="alert alert-info">Cargando datos...</div>
} }
else if (denunciasGestiona == null || !denunciasGestiona.Any()) else if ((historialGestiona == null || !historialGestiona.Any()) &&
(denunciasGestiona == null || !denunciasGestiona.Any()))
{ {
<p>No hay denuncias en gesti<EFBFBD>n.</p> <p>No hay denuncias en gestión.</p>
} }
else else
{ {
@@ -102,21 +105,50 @@ else
(!string.IsNullOrEmpty(d.NombreDenuncia) && d.NombreDenuncia.Contains(busqueda, StringComparison.OrdinalIgnoreCase)) || (!string.IsNullOrEmpty(d.NombreDenuncia) && d.NombreDenuncia.Contains(busqueda, StringComparison.OrdinalIgnoreCase)) ||
(!string.IsNullOrEmpty(d.ArchivoElegido) && d.ArchivoElegido.Contains(busqueda, StringComparison.OrdinalIgnoreCase)) || (!string.IsNullOrEmpty(d.ArchivoElegido) && d.ArchivoElegido.Contains(busqueda, StringComparison.OrdinalIgnoreCase)) ||
(!string.IsNullOrEmpty(d.Estado) && d.Estado.Contains(busqueda, StringComparison.OrdinalIgnoreCase)) (!string.IsNullOrEmpty(d.Estado) && d.Estado.Contains(busqueda, StringComparison.OrdinalIgnoreCase))
)) ))
{ {
var collapseId = $"collapse{denuncia.Id_Denuncia}"; var collapseId = $"collapse{denuncia.Id_Denuncia}";
var latestHistory = GetLatestHistory(denuncia.Id_Denuncia);
var uploadMoment = GetUploadMoment(denuncia, latestHistory);
<div class="card collapse-card Aceptada"> <div class="card collapse-card Aceptada">
<div class="card-header" data-bs-toggle="collapse" data-bs-target="#@collapseId" aria-expanded="false" aria-controls="@collapseId"> <div class="card-header" data-bs-toggle="collapse" data-bs-target="#@collapseId" aria-expanded="false" aria-controls="@collapseId">
<h5 class="mb-0">Denuncia ID: @denuncia.Id_Denuncia</h5> <h5 class="mb-0">Denuncia ID: @denuncia.Id_Denuncia</h5>
<div class="header-info"> <div class="header-info">
<span><strong>Estado:</strong> @denuncia.Estado</span> <span title="Asunto con el que se identifica el expediente en Gestiona."><strong>Asunto:</strong> @GetHeaderSubject(denuncia, latestHistory)</span>
<span><strong>Asunto:</strong> @denuncia.NombreDenuncia</span> @if (!string.IsNullOrWhiteSpace(denuncia.ExpedienteGestionaMostrable))
<span><strong>Fecha de Subida:</strong> @denuncia.FechaSubidaAGestiona.ToString("dd/MM/yyyy")</span> {
<span><strong>Hora de Subida:</strong> @denuncia.FechaSubidaAGestiona.ToString("HH:mm")</span> <span title="Número asignado al expediente por Gestiona."><strong>Nº expediente:</strong> @denuncia.ExpedienteGestionaMostrable</span>
}
@if (!string.IsNullOrWhiteSpace(GetUploadType(denuncia, latestHistory)))
{
<span title="Tipo de la última operación enviada desde la aplicación a Gestiona."><strong>Última subida:</strong> @GetUploadType(denuncia, latestHistory)</span>
}
@if (!string.IsNullOrWhiteSpace(GetAssignedGroup(denuncia, latestHistory)))
{
<span title="Grupo al que quedó asignado el expediente tras la última subida."><strong>Asignación en Gestiona:</strong> @FormatGroupCode(GetAssignedGroup(denuncia, latestHistory))</span>
}
@if (!string.IsNullOrWhiteSpace(GetUploadUser(latestHistory)))
{
<span title="Usuario de la aplicación que realizó la última subida."><strong>Usuario subida:</strong> @GetUploadUser(latestHistory)</span>
}
<span title="Fecha de la última operación enviada a Gestiona."><strong>Fecha de Subida:</strong> @FormatUploadDate(uploadMoment)</span>
<span title="Hora local de la última operación enviada a Gestiona."><strong>Hora de Subida:</strong> @FormatUploadTime(uploadMoment)</span>
@if (!string.IsNullOrWhiteSpace(GetAuditDateText(denuncia)))
{
<span title="Último movimiento registrado en la auditoría del expediente de Gestiona."><strong>Última actividad Gestiona:</strong> @GetAuditDateText(denuncia)</span>
}
</div> </div>
</div> </div>
<div id="@collapseId" class="collapse"> <div id="@collapseId" class="collapse">
<div class="card-body"> <div class="card-body">
@if (ficherosAdjuntosPurgados.Contains(denuncia.Id_Denuncia))
{
<div class="alert alert-warning mt-3">
La informacion de detalle de esta denuncia no se puede mostrar porque sus datos estan purgados criptograficamente. Se mantiene visible la cabecera operativa del expediente.
</div>
}
else
{
<!-- Datos Generales --> <!-- Datos Generales -->
<h5 class="section-heading">Datos Generales</h5> <h5 class="section-heading">Datos Generales</h5>
<dl class="row"> <dl class="row">
@@ -137,12 +169,22 @@ else
} }
@if (!string.IsNullOrWhiteSpace(denuncia.ExpedienteGestionaMostrable)) @if (!string.IsNullOrWhiteSpace(denuncia.ExpedienteGestionaMostrable))
{ {
<dt class="col-sm-3">N<EFBFBD> expediente Gestiona</dt> <dt class="col-sm-3">Nº expediente Gestiona</dt>
<dd class="col-sm-9">@denuncia.ExpedienteGestionaMostrable</dd> <dd class="col-sm-9">@denuncia.ExpedienteGestionaMostrable</dd>
} }
@if (!string.IsNullOrWhiteSpace(GetAuditDateText(denuncia)))
{
<dt class="col-sm-3">Última actividad Gestiona</dt>
<dd class="col-sm-9">@GetAuditDateText(denuncia)</dd>
}
@if (!string.IsNullOrWhiteSpace(GetAuditMessage(denuncia)))
{
<dt class="col-sm-3">Última acción Gestiona</dt>
<dd class="col-sm-9">@GetAuditMessage(denuncia)</dd>
}
@if (denuncia.Id_Persona_Gestiona != 0) @if (denuncia.Id_Persona_Gestiona != 0)
{ {
<dt class="col-sm-3">ID Persona Gesti<EFBFBD>n</dt> <dt class="col-sm-3">ID Persona Gestión</dt>
<dd class="col-sm-9">@denuncia.Id_Persona_Gestiona</dd> <dd class="col-sm-9">@denuncia.Id_Persona_Gestiona</dd>
} }
@if (!string.IsNullOrWhiteSpace(denuncia.Etiqueta)) @if (!string.IsNullOrWhiteSpace(denuncia.Etiqueta))
@@ -191,13 +233,13 @@ else
<dd class="col-sm-9">@denuncia.Asunto</dd> <dd class="col-sm-9">@denuncia.Asunto</dd>
<dt class="col-sm-3">A Quien Denuncia</dt> <dt class="col-sm-3">A Quien Denuncia</dt>
<dd class="col-sm-9">@denuncia.A_Quien_Denuncia</dd> <dd class="col-sm-9">@denuncia.A_Quien_Denuncia</dd>
<dt class="col-sm-3">Descripci<EFBFBD>n Denuncia</dt> <dt class="col-sm-3">Descripción Denuncia</dt>
<dd class="col-sm-9">@denuncia.Descripcion_Denuncia</dd> <dd class="col-sm-9">@denuncia.Descripcion_Denuncia</dd>
<dt class="col-sm-3">Denunciado Ante Inst</dt> <dt class="col-sm-3">Denunciado Ante Inst</dt>
<dd class="col-sm-9">@denuncia.Denunciado_Ante_Inst</dd> <dd class="col-sm-9">@denuncia.Denunciado_Ante_Inst</dd>
@if (!string.IsNullOrWhiteSpace(denuncia.Modalidad_Informacion)) @if (!string.IsNullOrWhiteSpace(denuncia.Modalidad_Informacion))
{ {
<dt class="col-sm-3">Modalidad Informaci<EFBFBD>n</dt> <dt class="col-sm-3">Modalidad Información</dt>
<dd class="col-sm-9">@denuncia.Modalidad_Informacion</dd> <dd class="col-sm-9">@denuncia.Modalidad_Informacion</dd>
} }
<dt class="col-sm-3">Lugar Hechos</dt> <dt class="col-sm-3">Lugar Hechos</dt>
@@ -209,27 +251,27 @@ else
} }
</dl> </dl>
<!-- Datos de Notificaci<EFBFBD>n --> <!-- Datos de Notificación -->
<h5 class="section-heading">Datos de Notificaci<EFBFBD>n</h5> <h5 class="section-heading">Datos de Notificación</h5>
<dl class="row"> <dl class="row">
@if (!string.IsNullOrWhiteSpace(denuncia.Notificacion_Preferencia)) @if (!string.IsNullOrWhiteSpace(denuncia.Notificacion_Preferencia))
{ {
<dt class="col-sm-3">Notificaci<EFBFBD>n Preferencia</dt> <dt class="col-sm-3">Notificación Preferencia</dt>
<dd class="col-sm-9">@denuncia.Notificacion_Preferencia</dd> <dd class="col-sm-9">@denuncia.Notificacion_Preferencia</dd>
} }
@if (!string.IsNullOrWhiteSpace(denuncia.Notificacion_Electronica)) @if (!string.IsNullOrWhiteSpace(denuncia.Notificacion_Electronica))
{ {
<dt class="col-sm-3">Notificaci<EFBFBD>n Electr<EFBFBD>nica</dt> <dt class="col-sm-3">Notificación Electrónica</dt>
<dd class="col-sm-9">@denuncia.Notificacion_Electronica</dd> <dd class="col-sm-9">@denuncia.Notificacion_Electronica</dd>
} }
@if (!string.IsNullOrWhiteSpace(denuncia.Correo_Electronico)) @if (!string.IsNullOrWhiteSpace(denuncia.Correo_Electronico))
{ {
<dt class="col-sm-3">Correo Electr<EFBFBD>nico</dt> <dt class="col-sm-3">Correo Electrónico</dt>
<dd class="col-sm-9">@denuncia.Correo_Electronico</dd> <dd class="col-sm-9">@denuncia.Correo_Electronico</dd>
} }
@if (!string.IsNullOrWhiteSpace(denuncia.Notificacion_Sms)) @if (!string.IsNullOrWhiteSpace(denuncia.Notificacion_Sms))
{ {
<dt class="col-sm-3">Notificaci<EFBFBD>n SMS</dt> <dt class="col-sm-3">Notificación SMS</dt>
<dd class="col-sm-9">@denuncia.Notificacion_Sms</dd> <dd class="col-sm-9">@denuncia.Notificacion_Sms</dd>
} }
</dl> </dl>
@@ -240,7 +282,7 @@ else
@if (denuncia.Condiciones) @if (denuncia.Condiciones)
{ {
<dt class="col-sm-3">Condiciones</dt> <dt class="col-sm-3">Condiciones</dt>
<dd class="col-sm-9">S<EFBFBD></dd> <dd class="col-sm-9">Sí</dd>
} }
@if (!string.IsNullOrWhiteSpace(denuncia.Comments)) @if (!string.IsNullOrWhiteSpace(denuncia.Comments))
{ {
@@ -257,7 +299,7 @@ else
<thead> <thead>
<tr> <tr>
<th>Nombre</th> <th>Nombre</th>
<th>Tama<EFBFBD>o (bytes)</th> <th>Tamaño (bytes)</th>
<th>Ver</th> <th>Ver</th>
</tr> </tr>
</thead> </thead>
@@ -277,6 +319,29 @@ else
</tbody> </tbody>
</table> </table>
} }
}
</div>
</div>
</div>
}
@foreach (var item in GetHeaderOnlyHistoryItems())
{
var uploadMoment = item.UploadedAtUtc;
<div class="card collapse-card Aceptada">
<div class="card-header">
<h5 class="mb-0">Denuncia ID: @item.DenunciaId</h5>
<div class="header-info">
<span title="Asunto con el que se identifica el expediente en Gestiona."><strong>Asunto:</strong> @DisplayOrDash(item.Asunto)</span>
<span title="Número asignado al expediente por Gestiona."><strong>Nº expediente:</strong> @DisplayOrDash(item.CodigoExpedienteGestiona)</span>
<span title="Tipo de la última operación enviada desde la aplicación a Gestiona."><strong>Última subida:</strong> @DisplayOrDash(item.TipoSubida)</span>
<span title="Grupo al que quedó asignado el expediente tras la última subida."><strong>Asignación en Gestiona:</strong> @DisplayOrDash(FormatGroupCode(item.GrupoAsignado))</span>
@if (!string.IsNullOrWhiteSpace(GetUploadUser(item)))
{
<span title="Usuario de la aplicación que realizó la última subida."><strong>Usuario subida:</strong> @GetUploadUser(item)</span>
}
<span title="Fecha de la última operación enviada a Gestiona."><strong>Fecha de Subida:</strong> @FormatUploadDate(uploadMoment)</span>
<span title="Hora local de la última operación enviada a Gestiona."><strong>Hora de Subida:</strong> @FormatUploadTime(uploadMoment)</span>
<span class="text-muted">Detalle no disponible por purga criptográfica.</span>
</div> </div>
</div> </div>
</div> </div>
@@ -305,9 +370,12 @@ else
@code { @code {
private List<DenunciasGestiona> denunciasGestiona = new(); private List<DenunciasGestiona> denunciasGestiona = new();
private List<GestionaUploadHistoryEntry> historialGestiona = new();
private Dictionary<int, List<FicherosDenuncias>> ficherosAdjuntos = new(); private Dictionary<int, List<FicherosDenuncias>> ficherosAdjuntos = new();
private HashSet<int> ficherosAdjuntosPurgados = new();
private Dictionary<int, GestionaAuditInfo> auditoriasGestiona = new();
// Variable para la b<EFBFBD>squeda // Variable para la búsqueda
private string busqueda = ""; private string busqueda = "";
private bool hasLoaded = false; private bool hasLoaded = false;
@@ -316,8 +384,10 @@ else
{ {
if (firstRender) if (firstRender)
{ {
await CargarHistorialGestionaAsync();
await CargarGestionaAsync(); await CargarGestionaAsync();
await CargarFicherosAdjuntosAsync(denunciasGestiona.Select(d => d.Id_Denuncia)); await CargarFicherosAdjuntosAsync(denunciasGestiona.Select(d => d.Id_Denuncia));
await CargarAuditoriasGestionaAsync(denunciasGestiona);
hasLoaded = true; hasLoaded = true;
StateHasChanged(); StateHasChanged();
} }
@@ -331,25 +401,228 @@ else
.ToList(); .ToList();
} }
private async Task CargarHistorialGestionaAsync()
{
try
{
historialGestiona = await ApiDenuncias.GetGestionaUploadHistoryAsync();
}
catch (Exception ex)
{
Console.Error.WriteLine($"No se ha podido cargar el histórico de subidas a Gestiona: {ex}");
historialGestiona = new List<GestionaUploadHistoryEntry>();
}
}
private async Task<List<DenunciasGestiona>> CargarDenunciasJsonAsync() private async Task<List<DenunciasGestiona>> CargarDenunciasJsonAsync()
{ {
return await DenunciaStore.GetDenunciasByScopeAsync(DenunciaListScope.InGestiona); return await DenunciaStore.GetDenunciasByScopeAsync(DenunciaListScope.InGestiona);
} }
private async Task CargarAuditoriasGestionaAsync(IEnumerable<DenunciasGestiona> denuncias)
{
auditoriasGestiona.Clear();
var items = denuncias
.Where(d => d.Id_Denuncia > 0 && !string.IsNullOrWhiteSpace(d.Expediente_Gestiona) && !string.Equals(d.Expediente_Gestiona, "Pendiente", StringComparison.OrdinalIgnoreCase))
.DistinctBy(d => d.Id_Denuncia)
.ToList();
using var gate = new System.Threading.SemaphoreSlim(4, 4);
var tasks = items.Select(async denuncia =>
{
await gate.WaitAsync();
try
{
var audit = await ApiDenuncias.GetGestionaFileLatestAuditAsync(denuncia.Expediente_Gestiona);
if (audit?.Fecha is null && string.IsNullOrWhiteSpace(audit?.Mensaje))
{
return;
}
lock (auditoriasGestiona)
{
auditoriasGestiona[denuncia.Id_Denuncia] = audit;
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"No se ha podido cargar la auditoría de Gestiona para la denuncia {denuncia.Id_Denuncia}: {ex}");
}
finally
{
gate.Release();
}
});
await Task.WhenAll(tasks);
}
private string GetAuditDateText(DenunciasGestiona denuncia)
{
return auditoriasGestiona.TryGetValue(denuncia.Id_Denuncia, out var audit) && audit.Fecha is not null
? audit.Fecha.Value.ToLocalTime().ToString("dd/MM/yyyy HH:mm")
: string.Empty;
}
private string GetAuditMessage(DenunciasGestiona denuncia)
{
return auditoriasGestiona.TryGetValue(denuncia.Id_Denuncia, out var audit)
? audit.Mensaje ?? string.Empty
: string.Empty;
}
private GestionaUploadHistoryEntry? GetLatestHistory(int denunciaId)
{
return historialGestiona
.Where(item => item.DenunciaId == denunciaId)
.OrderByDescending(item => item.UploadedAtUtc)
.ThenByDescending(item => item.Id)
.FirstOrDefault();
}
private IEnumerable<GestionaUploadHistoryEntry> GetHeaderOnlyHistoryItems()
{
var visibleComplaintIds = denunciasGestiona
.Select(denuncia => denuncia.Id_Denuncia)
.ToHashSet();
return historialGestiona
.Where(item => !visibleComplaintIds.Contains(item.DenunciaId))
.Where(MatchesHistorySearch)
.GroupBy(item => item.DenunciaId)
.Select(group => group
.OrderByDescending(item => item.UploadedAtUtc)
.ThenByDescending(item => item.Id)
.First())
.OrderByDescending(item => item.UploadedAtUtc)
.ThenByDescending(item => item.Id);
}
private static DateTime GetUploadMoment(DenunciasGestiona denuncia, GestionaUploadHistoryEntry? history)
{
if (history?.UploadedAtUtc is DateTime historyDate && historyDate != DateTime.MinValue)
{
return historyDate;
}
return denuncia.FechaSubidaAGestiona;
}
private static string GetHeaderSubject(DenunciasGestiona denuncia, GestionaUploadHistoryEntry? history)
{
return !string.IsNullOrWhiteSpace(history?.Asunto)
? history.Asunto.Trim()
: DisplayOrDash(denuncia.NombreDenuncia);
}
private static string GetUploadType(DenunciasGestiona denuncia, GestionaUploadHistoryEntry? history)
{
return !string.IsNullOrWhiteSpace(history?.TipoSubida)
? history.TipoSubida.Trim()
: denuncia.UltimaSubidaGestionaTipoMostrable;
}
private static string GetAssignedGroup(DenunciasGestiona denuncia, GestionaUploadHistoryEntry? history)
{
return !string.IsNullOrWhiteSpace(history?.GrupoAsignado)
? history.GrupoAsignado.Trim()
: denuncia.UltimoGrupoAsignadoGestionaMostrable;
}
private static string GetUploadUser(GestionaUploadHistoryEntry? history)
{
return string.IsNullOrWhiteSpace(history?.Usuario) ||
string.Equals(history.Usuario, "No registrado", StringComparison.OrdinalIgnoreCase)
? string.Empty
: history.Usuario.Trim();
}
private static string FormatUploadDate(DateTime value)
{
var local = ToLocalUploadTime(value);
return local == DateTime.MinValue ? "-" : local.ToString("dd/MM/yyyy");
}
private static string FormatUploadTime(DateTime value)
{
var local = ToLocalUploadTime(value);
return local == DateTime.MinValue ? "-" : local.ToString("HH:mm");
}
private static DateTime ToLocalUploadTime(DateTime value)
{
return value == DateTime.MinValue
? DateTime.MinValue
: DateTime.SpecifyKind(value, DateTimeKind.Utc).ToLocalTime();
}
private bool MatchesHistorySearch(GestionaUploadHistoryEntry item)
{
if (string.IsNullOrWhiteSpace(busqueda))
{
return true;
}
var search = busqueda.Trim();
return item.DenunciaId.ToString(CultureInfo.InvariantCulture).Contains(search, StringComparison.OrdinalIgnoreCase) ||
(item.CodigoExpedienteGestiona ?? string.Empty).Contains(search, StringComparison.OrdinalIgnoreCase) ||
(item.TipoSubida ?? string.Empty).Contains(search, StringComparison.OrdinalIgnoreCase) ||
(item.GrupoAsignado ?? string.Empty).Contains(search, StringComparison.OrdinalIgnoreCase) ||
(item.Usuario ?? string.Empty).Contains(search, StringComparison.OrdinalIgnoreCase) ||
(item.Asunto ?? string.Empty).Contains(search, StringComparison.OrdinalIgnoreCase) ||
(item.Documentos ?? string.Empty).Contains(search, StringComparison.OrdinalIgnoreCase);
}
private static string FormatHistoryDate(DateTime value)
{
var local = ToLocalUploadTime(value);
return local == DateTime.MinValue
? "-"
: local.ToString("dd/MM/yyyy HH:mm");
}
private static string DisplayOrDash(string? value)
=> string.IsNullOrWhiteSpace(value) ? "-" : value.Trim();
private static string FormatGroupCode(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return string.Empty;
}
var trimmed = value.Trim();
var code = new string(trimmed.TakeWhile(char.IsDigit).ToArray());
return string.IsNullOrWhiteSpace(code) ? trimmed : code;
}
private async Task CargarFicherosAdjuntosAsync(IEnumerable<int> denunciaIds) private async Task CargarFicherosAdjuntosAsync(IEnumerable<int> denunciaIds)
{ {
ficherosAdjuntos.Clear(); ficherosAdjuntos.Clear();
ficherosAdjuntosPurgados.Clear();
foreach (var denunciaId in denunciaIds.Where(id => id > 0).Distinct()) foreach (var denunciaId in denunciaIds.Where(id => id > 0).Distinct())
{ {
var ficheros = await DenunciaStore.GetFicherosByDenunciaAsync(denunciaId); try
if (ficheros.Count > 0)
{ {
ficherosAdjuntos[denunciaId] = ficheros; var ficheros = await DenunciaStore.GetFicherosByDenunciaAsync(denunciaId);
if (ficheros.Count > 0)
{
ficherosAdjuntos[denunciaId] = ficheros;
}
}
catch (InvalidOperationException ex) when (IsPurgedDataError(ex))
{
ficherosAdjuntosPurgados.Add(denunciaId);
Console.Error.WriteLine($"No se han podido cargar los adjuntos de la denuncia {denunciaId} porque estan purgados: {ex.Message}");
} }
} }
} }
private static bool IsPurgedDataError(Exception ex)
{
var message = ex.Message ?? string.Empty;
return message.Contains("datos purgados", StringComparison.OrdinalIgnoreCase) ||
message.Contains("Denuncia no disponible", StringComparison.OrdinalIgnoreCase);
}
private static string BuildAttachmentContentUrl(int denunciaId, string? fileName) private static string BuildAttachmentContentUrl(int denunciaId, string? fileName)
{ {
return $"/api/denuncias/{denunciaId}/ficheros/content?fileName={Uri.EscapeDataString(fileName ?? string.Empty)}"; return $"/api/denuncias/{denunciaId}/ficheros/content?fileName={Uri.EscapeDataString(fileName ?? string.Empty)}";

View File

@@ -56,7 +56,7 @@
<li>Abre la denuncia para revisar sus datos y adjuntos.</li> <li>Abre la denuncia para revisar sus datos y adjuntos.</li>
<li>En la lista de ficheros, deja marcado solo lo que quieras subir.</li> <li>En la lista de ficheros, deja marcado solo lo que quieras subir.</li>
<li>El report de la denuncia se sube siempre y no se puede desmarcar.</li> <li>El report de la denuncia se sube siempre y no se puede desmarcar.</li>
<li>Pulsa <strong>Configurar subida</strong> para indicar asunto, grupo destino y modo de subida.</li> <li>Pulsa <strong>Configurar apertura expediente</strong> para indicar asunto, grupo destino y modo de subida.</li>
<li>Confirma para crear el expediente, vincular el tercero y subir los documentos a Gestiona.</li> <li>Confirma para crear el expediente, vincular el tercero y subir los documentos a Gestiona.</li>
<li>Si la denuncia no procede, usa <strong>Rechazar denuncia</strong> e indica el motivo.</li> <li>Si la denuncia no procede, usa <strong>Rechazar denuncia</strong> e indica el motivo.</li>
</ul> </ul>
@@ -67,15 +67,15 @@
<div class="col-12 col-xl-6"> <div class="col-12 col-xl-6">
<div class="card h-100"> <div class="card h-100">
<div class="card-body"> <div class="card-body">
<h2 class="h5">Modal de subida a Gestiona</h2> <h2 class="h5">Ventana de configuración de la subida</h2>
<p> <p>
Antes de confirmar la subida, revisa estos puntos: Al configurar un expediente nuevo o una actualización, revisa estos puntos antes de confirmar:
</p> </p>
<ul class="mb-0"> <ul class="mb-0">
<li><strong>Asunto</strong>: texto que identificara el expediente/documentos en Gestiona.</li> <li><strong>Asunto</strong>: texto que identifica el expediente en Gestiona. En las actualizaciones se muestra en modo de solo lectura.</li>
<li><strong>Grupo destino</strong>: unidad a la que se asignara el expediente.</li> <li><strong>Grupo destino</strong>: grupo al que quedará asignado el expediente en Gestiona.</li>
<li><strong>Modo de subida</strong>: puedes unir adjuntos en un PDF o subirlos de forma independiente.</li> <li><strong>Modo de subida</strong>: puedes unir adjuntos en un PDF o subirlos de forma independiente.</li>
<li><strong>Tercero</strong>: la app lo rellena desde la denuncia. Si es anonima, se usa el tercero anonimo configurado.</li> <li><strong>Tercero</strong>: la aplicación lo completa con los datos de la denuncia. Si es anónima, se utiliza el tercero anónimo configurado.</li>
<li><strong>Expedientes del tercero</strong>: puedes consultarlos antes de confirmar si necesitas contexto.</li> <li><strong>Expedientes del tercero</strong>: puedes consultarlos antes de confirmar si necesitas contexto.</li>
</ul> </ul>
</div> </div>
@@ -87,14 +87,15 @@
<div class="card-body"> <div class="card-body">
<h2 class="h5">Actualizaciones</h2> <h2 class="h5">Actualizaciones</h2>
<p> <p>
Esta pantalla recoge comunicaciones o adjuntos nuevos sobre denuncias que ya tienen expediente en Gestiona. Esta pantalla recoge comentarios, comunicaciones o adjuntos nuevos sobre denuncias que ya tienen expediente en Gestiona.
</p> </p>
<ul class="mb-0"> <ul class="mb-0">
<li>Revisa la actualizacion y sus ficheros.</li> <li>Revisa la actualizacion y sus ficheros.</li>
<li>La app propone los adjuntos que parecen nuevos.</li> <li>La app propone los adjuntos que parecen nuevos.</li>
<li>Puedes desmarcar los adjuntos que no quieras subir.</li> <li>Puedes desmarcar los adjuntos que no quieras subir.</li>
<li>El report de la actualizacion se mantiene obligatorio.</li> <li>El report de la actualizacion se mantiene obligatorio.</li>
<li>Confirma la subida para a<EFBFBD>adir los nuevos documentos al expediente existente.</li> <li>Pulsa <strong>Configurar actualización expediente</strong> y confirma para añadir el cambio al expediente existente.</li>
<li>Si la actividad procede de la OAAF, la aplicación utiliza el aviso correspondiente al grupo SAJ o SDI.</li>
</ul> </ul>
</div> </div>
</div> </div>
@@ -105,12 +106,12 @@
<div class="card-body"> <div class="card-body">
<h2 class="h5">Gestiona</h2> <h2 class="h5">Gestiona</h2>
<p> <p>
Aqui se consultan las denuncias que ya se han enviado a Gestiona. Aquí se almacena el histórico permanente de los movimientos enviados desde esta aplicación a Gestiona.
</p> </p>
<ul class="mb-0"> <ul class="mb-0">
<li>Comprueba el numero de expediente y la fecha de envio.</li> <li>Comprueba el número de expediente, el tipo de subida, el usuario que la realizó y la asignación en Gestiona.</li>
<li>Accede al enlace del expediente cuando necesites revisar la tramitacion en Gestiona.</li> <li>Despliega una operación del día para consultar sus datos y documentos mientras estén disponibles.</li>
<li>Usa esta pantalla como seguimiento de lo que ya salio de Pendientes o Actualizaciones.</li> <li>Por requisitos de seguridad ENS, los detalles sensibles de días anteriores dejan de estar disponibles; la cabecera operativa y la trazabilidad permanecen visibles.</li>
</ul> </ul>
</div> </div>
</div> </div>
@@ -121,7 +122,7 @@
<div class="card-body"> <div class="card-body">
<h2 class="h5">Rechazados</h2> <h2 class="h5">Rechazados</h2>
<p> <p>
Aqui quedan las denuncias que se han descartado desde Pendientes. Aquí quedan las denuncias o actualizaciones que no se han subido a Gestiona porque se descartaron desde la pantalla de trabajo.
</p> </p>
<ul class="mb-0"> <ul class="mb-0">
<li>Consulta el motivo indicado al rechazar.</li> <li>Consulta el motivo indicado al rechazar.</li>
@@ -165,4 +166,4 @@
<div class="alert alert-warning mt-3 mb-0"> <div class="alert alert-warning mt-3 mb-0">
Si una pantalla indica que una denuncia no esta disponible, no intentes tramitarla desde otra ruta: revisa la bandeja de Entrada o consulta con soporte del sistema. Si una pantalla indica que una denuncia no esta disponible, no intentes tramitarla desde otra ruta: revisa la bandeja de Entrada o consulta con soporte del sistema.
</div> </div>
</div> </div>

View File

@@ -355,7 +355,7 @@
return ReturnUrl; return ReturnUrl;
} }
return "/GestionZip"; return "/Entrada";
} }
private static T? ReadData<T>(ApiJsResponse response) private static T? ReadData<T>(ApiJsResponse response)

View File

@@ -1,4 +1,4 @@
@page "/Pendientes" @page "/Pendientes"
@rendermode InteractiveServer @rendermode InteractiveServer
@attribute [Authorize] @attribute [Authorize]
@@ -19,6 +19,7 @@
@inject IDenunciaStore DenunciaStore @inject IDenunciaStore DenunciaStore
@inject ApiDenunciasClient ApiDenuncias @inject ApiDenunciasClient ApiDenuncias
@inject UiBusyService Busy @inject UiBusyService Busy
@inject UiDialogService Dialogs
<PageTitle>Denuncias Pendientes</PageTitle> <PageTitle>Denuncias Pendientes</PageTitle>
@@ -199,22 +200,27 @@ else
data-bs-target="#@collapseId" data-bs-target="#@collapseId"
aria-expanded="false" aria-expanded="false"
aria-controls="@collapseId"> aria-controls="@collapseId">
<h5 class="mb-0">Denuncia ID: @denuncia.Id_Denuncia</h5> <div class="d-flex flex-wrap align-items-center gap-2">
<h5 class="mb-0">Denuncia ID: @denuncia.Id_Denuncia</h5>
@if (!string.IsNullOrWhiteSpace(denuncia.OwnerUsername))
{
<span class="badge @(denuncia.OwnedByCurrentUser ? "text-bg-secondary" : "text-bg-warning")">
Responsable: @denuncia.OwnerUsername
</span>
}
@if (denuncia.OwnerWorkGroups.Count > 0)
{
<span class="badge text-bg-light">
Grupo: @string.Join(" / ", denuncia.OwnerWorkGroups)
</span>
}
</div>
<div> <div>
<button type="button" <button type="button"
class="btn btn-success btn-sm me-2" class="btn btn-success btn-sm me-2"
@onclick:stopPropagation="true" @onclick:stopPropagation="true"
@onclick="() => OpenEnviarAGestionaModal(denuncia)"> @onclick="() => OpenEnviarAGestionaModal(denuncia)">
Configurar subida Configurar apertura expediente
</button>
<!-- Enviar a Actualizaciones -->
<button type="button"
class="btn btn-outline-warning btn-sm me-2"
title="Mover esta denuncia a la cola de Actualizaciones"
@onclick:stopPropagation="true"
@onclick="() => MoverAActualizaciones(denuncia)">
Enviar a Actualizaciones
</button> </button>
<button type="button" <button type="button"
@@ -247,7 +253,7 @@ else
} }
@if (!string.IsNullOrWhiteSpace(denuncia.ExpedienteGestionaMostrable)) @if (!string.IsNullOrWhiteSpace(denuncia.ExpedienteGestionaMostrable))
{ {
<dt class="col-sm-3">N<EFBFBD> expediente Gestiona</dt> <dt class="col-sm-3">Nº expediente Gestiona</dt>
<dd class="col-sm-9">@denuncia.ExpedienteGestionaMostrable</dd> <dd class="col-sm-9">@denuncia.ExpedienteGestionaMostrable</dd>
} }
@if (denuncia.Id_Persona_Gestiona != 0) @if (denuncia.Id_Persona_Gestiona != 0)
@@ -287,7 +293,7 @@ else
} }
@if (!string.IsNullOrWhiteSpace(denuncia.RazonSocialResuelta)) @if (!string.IsNullOrWhiteSpace(denuncia.RazonSocialResuelta))
{ {
<dt class="col-sm-3">Raz<EFBFBD>n social</dt> <dt class="col-sm-3">Razón social</dt>
<dd class="col-sm-9">@denuncia.RazonSocialResuelta</dd> <dd class="col-sm-9">@denuncia.RazonSocialResuelta</dd>
} }
@if (!string.IsNullOrWhiteSpace(denuncia.NombreResuelto)) @if (!string.IsNullOrWhiteSpace(denuncia.NombreResuelto))
@@ -297,12 +303,12 @@ else
} }
@if (!string.IsNullOrWhiteSpace(denuncia.PrimerApellidoResuelto)) @if (!string.IsNullOrWhiteSpace(denuncia.PrimerApellidoResuelto))
{ {
<dt class="col-sm-3">1<EFBFBD> Apellido</dt> <dt class="col-sm-3">1º Apellido</dt>
<dd class="col-sm-9">@denuncia.PrimerApellidoResuelto</dd> <dd class="col-sm-9">@denuncia.PrimerApellidoResuelto</dd>
} }
@if (!string.IsNullOrWhiteSpace(denuncia.SegundoApellidoResuelto)) @if (!string.IsNullOrWhiteSpace(denuncia.SegundoApellidoResuelto))
{ {
<dt class="col-sm-3">2<EFBFBD> Apellido</dt> <dt class="col-sm-3">2º Apellido</dt>
<dd class="col-sm-9">@denuncia.SegundoApellidoResuelto</dd> <dd class="col-sm-9">@denuncia.SegundoApellidoResuelto</dd>
} }
@if (!string.IsNullOrWhiteSpace(denuncia.ApellidosResueltos)) @if (!string.IsNullOrWhiteSpace(denuncia.ApellidosResueltos))
@@ -322,7 +328,7 @@ else
} }
@if (!string.IsNullOrWhiteSpace(denuncia.PaisOrigen)) @if (!string.IsNullOrWhiteSpace(denuncia.PaisOrigen))
{ {
<dt class="col-sm-3">Pa<EFBFBD>s de origen</dt> <dt class="col-sm-3">País de origen</dt>
<dd class="col-sm-9">@denuncia.PaisOrigen</dd> <dd class="col-sm-9">@denuncia.PaisOrigen</dd>
} }
</dl> </dl>
@@ -339,7 +345,7 @@ else
<dt class="col-sm-3">Detalle denunciado</dt> <dt class="col-sm-3">Detalle denunciado</dt>
<dd class="col-sm-9">@denuncia.DenunciadoDetalle</dd> <dd class="col-sm-9">@denuncia.DenunciadoDetalle</dd>
} }
<dt class="col-sm-3">Descripci<EFBFBD>n Denuncia</dt> <dt class="col-sm-3">Descripción Denuncia</dt>
<dd class="col-sm-9">@denuncia.Descripcion_Denuncia</dd> <dd class="col-sm-9">@denuncia.Descripcion_Denuncia</dd>
<dt class="col-sm-3">Denunciado Ante Inst</dt> <dt class="col-sm-3">Denunciado Ante Inst</dt>
<dd class="col-sm-9">@denuncia.Denunciado_Ante_Inst</dd> <dd class="col-sm-9">@denuncia.Denunciado_Ante_Inst</dd>
@@ -350,7 +356,7 @@ else
} }
@if (!string.IsNullOrWhiteSpace(denuncia.SolicitaProteccion)) @if (!string.IsNullOrWhiteSpace(denuncia.SolicitaProteccion))
{ {
<dt class="col-sm-3">Solicita protecci<EFBFBD>n</dt> <dt class="col-sm-3">Solicita protección</dt>
<dd class="col-sm-9">@denuncia.SolicitaProteccion</dd> <dd class="col-sm-9">@denuncia.SolicitaProteccion</dd>
} }
@if (!string.IsNullOrWhiteSpace(denuncia.MedidasProteccionSolicitadas)) @if (!string.IsNullOrWhiteSpace(denuncia.MedidasProteccionSolicitadas))
@@ -360,7 +366,7 @@ else
} }
@if (!string.IsNullOrWhiteSpace(denuncia.Modalidad_Informacion)) @if (!string.IsNullOrWhiteSpace(denuncia.Modalidad_Informacion))
{ {
<dt class="col-sm-3">Modalidad Informaci<EFBFBD>n</dt> <dt class="col-sm-3">Modalidad Información</dt>
<dd class="col-sm-9">@denuncia.Modalidad_Informacion</dd> <dd class="col-sm-9">@denuncia.Modalidad_Informacion</dd>
} }
<dt class="col-sm-3">Lugar Hechos</dt> <dt class="col-sm-3">Lugar Hechos</dt>
@@ -372,7 +378,7 @@ else
} }
@if (!string.IsNullOrWhiteSpace(denuncia.AutorizaRemision)) @if (!string.IsNullOrWhiteSpace(denuncia.AutorizaRemision))
{ {
<dt class="col-sm-3">Autoriza remisi<EFBFBD>n</dt> <dt class="col-sm-3">Autoriza remisión</dt>
<dd class="col-sm-9">@denuncia.AutorizaRemision</dd> <dd class="col-sm-9">@denuncia.AutorizaRemision</dd>
} }
@if (!string.IsNullOrWhiteSpace(denuncia.PreferenciaRemision)) @if (!string.IsNullOrWhiteSpace(denuncia.PreferenciaRemision))
@@ -382,16 +388,16 @@ else
} }
</dl> </dl>
<h5 class="section-heading">Datos de Notificaci<EFBFBD>n</h5> <h5 class="section-heading">Datos de Notificación</h5>
<dl class="row"> <dl class="row">
@if (!string.IsNullOrWhiteSpace(denuncia.Notificacion_Preferencia)) @if (!string.IsNullOrWhiteSpace(denuncia.Notificacion_Preferencia))
{ {
<dt class="col-sm-3">Notificaci<EFBFBD>n Preferencia</dt> <dt class="col-sm-3">Notificación Preferencia</dt>
<dd class="col-sm-9">@denuncia.Notificacion_Preferencia</dd> <dd class="col-sm-9">@denuncia.Notificacion_Preferencia</dd>
} }
@if (!string.IsNullOrWhiteSpace(denuncia.Notificacion_Electronica)) @if (!string.IsNullOrWhiteSpace(denuncia.Notificacion_Electronica))
{ {
<dt class="col-sm-3">Notificaci<EFBFBD>n Electr<EFBFBD>nica</dt> <dt class="col-sm-3">Notificación Electrónica</dt>
<dd class="col-sm-9">@denuncia.Notificacion_Electronica</dd> <dd class="col-sm-9">@denuncia.Notificacion_Electronica</dd>
} }
@if (!string.IsNullOrWhiteSpace(denuncia.SeguimientoOnline)) @if (!string.IsNullOrWhiteSpace(denuncia.SeguimientoOnline))
@@ -406,17 +412,17 @@ else
} }
@if (!string.IsNullOrWhiteSpace(denuncia.Correo_Electronico)) @if (!string.IsNullOrWhiteSpace(denuncia.Correo_Electronico))
{ {
<dt class="col-sm-3">Correo Electr<EFBFBD>nico</dt> <dt class="col-sm-3">Correo Electrónico</dt>
<dd class="col-sm-9">@denuncia.Correo_Electronico</dd> <dd class="col-sm-9">@denuncia.Correo_Electronico</dd>
} }
@if (!string.IsNullOrWhiteSpace(denuncia.Notificacion_Sms)) @if (!string.IsNullOrWhiteSpace(denuncia.Notificacion_Sms))
{ {
<dt class="col-sm-3">Notificaci<EFBFBD>n SMS</dt> <dt class="col-sm-3">Notificación SMS</dt>
<dd class="col-sm-9">@denuncia.Notificacion_Sms</dd> <dd class="col-sm-9">@denuncia.Notificacion_Sms</dd>
} }
@if (HasPostalAddress(denuncia)) @if (HasPostalAddress(denuncia))
{ {
<dt class="col-sm-3">Direcci<EFBFBD>n postal</dt> <dt class="col-sm-3">Dirección postal</dt>
<dd class="col-sm-9">@BuildPostalAddressSummary(denuncia)</dd> <dd class="col-sm-9">@BuildPostalAddressSummary(denuncia)</dd>
} }
</dl> </dl>
@@ -426,7 +432,7 @@ else
@if (denuncia.Condiciones) @if (denuncia.Condiciones)
{ {
<dt class="col-sm-3">Condiciones</dt> <dt class="col-sm-3">Condiciones</dt>
<dd class="col-sm-9">S<EFBFBD></dd> <dd class="col-sm-9">Sí</dd>
} }
@if (!string.IsNullOrWhiteSpace(denuncia.Comments)) @if (!string.IsNullOrWhiteSpace(denuncia.Comments))
{ {
@@ -441,14 +447,14 @@ else
@if (camposFormulario.Count > 0) @if (camposFormulario.Count > 0)
{ {
<h5 class="section-heading">Formulario Original</h5> <h5 class="section-heading">Formulario Original</h5>
@foreach (var grupoCampos in camposFormulario.GroupBy(field => string.IsNullOrWhiteSpace(field.Section) ? "Sin secci<EFBFBD>n" : field.Section)) @foreach (var grupoCampos in camposFormulario.GroupBy(field => string.IsNullOrWhiteSpace(field.Section) ? "Sin sección" : field.Section))
{ {
<h6 class="mt-3">@grupoCampos.Key</h6> <h6 class="mt-3">@grupoCampos.Key</h6>
<dl class="row"> <dl class="row">
@foreach (var campo in grupoCampos) @foreach (var campo in grupoCampos)
{ {
<dt class="col-sm-4">@campo.Label</dt> <dt class="col-sm-4">@campo.Label</dt>
<dd class="col-sm-8">@(string.IsNullOrWhiteSpace(campo.Value) ? "<EFBFBD>" : campo.Value)</dd> <dd class="col-sm-8">@(string.IsNullOrWhiteSpace(campo.Value) ? "" : campo.Value)</dd>
} }
</dl> </dl>
} }
@@ -463,7 +469,7 @@ else
<th class="seleccionar-col">Subir</th> <th class="seleccionar-col">Subir</th>
<th>Nombre</th> <th>Nombre</th>
<th>Fecha</th> <th>Fecha</th>
<th>Tama<EFBFBD>o (bytes)</th> <th>Tamaño (bytes)</th>
<th>Ver</th> <th>Ver</th>
</tr> </tr>
</thead> </thead>
@@ -541,7 +547,7 @@ else
</div> </div>
} }
<h6 class="modal-section-heading">Descripci<EFBFBD>n</h6> <h6 class="modal-section-heading">Descripción</h6>
<div class="mb-3"> <div class="mb-3">
<input type="text" <input type="text"
class="form-control" class="form-control"
@@ -554,9 +560,9 @@ else
<input type="text" <input type="text"
class="form-control" class="form-control"
@bind="nombreDocumentos" @bind="nombreDocumentos"
placeholder="Ej.: Gesti<EFBFBD>n AN (lo ver<EFBFBD>s como: Documento Adjunto 1 Gesti<EFBFBD>n AN, ...)" /> placeholder="Ej.: Gestión AN (lo verás como: Documento Adjunto 1 Gestión AN, ...)" />
<small class="text-muted"> <small class="text-muted">
Se aplicar<EFBFBD> al subir en modo <strong>individual</strong>. El <em>report.txt</em> se subir<EFBFBD> como <strong>Denuncia</strong>. Se aplicará al subir en modo <strong>individual</strong>. El <em>report.txt</em> se subirá como <strong>Denuncia</strong>.
</small> </small>
</div> </div>
@@ -569,7 +575,7 @@ else
checked='@(uploadMode == "merge")' checked='@(uploadMode == "merge")'
@onclick='() => uploadMode = "merge"' /> @onclick='() => uploadMode = "merge"' />
<label class="form-check-label" for="modoMerge"> <label class="form-check-label" for="modoMerge">
Unir todos los ficheros en un <EFBFBD>nico PDF Unir todos los ficheros en un único PDF
</label> </label>
</div> </div>
<div class="form-check"> <div class="form-check">
@@ -593,10 +599,10 @@ else
checked='@(selectedGroup == "600")' checked='@(selectedGroup == "600")'
@onclick='() => selectedGroup = "600"' /> @onclick='() => selectedGroup = "600"' />
<label class="form-check-label" for="grupo600"> <label class="form-check-label" for="grupo600">
600. Asuntos Jur<EFBFBD>dicos y Protecci<EFBFBD>n a la Persona Denunciante 600. Asuntos Jurídicos y Protección a la Persona Denunciante
</label> </label>
</div> </div>
@* <div class="form-check"> <div class="form-check">
<input class="form-check-input" <input class="form-check-input"
type="radio" type="radio"
name="selectedGroup" name="selectedGroup"
@@ -604,20 +610,9 @@ else
checked='@(selectedGroup == "510")' checked='@(selectedGroup == "510")'
@onclick='() => selectedGroup = "510"' /> @onclick='() => selectedGroup = "510"' />
<label class="form-check-label" for="grupo510"> <label class="form-check-label" for="grupo510">
510. SDI <EFBFBD> Investigaci<EFBFBD>n Entradas 510. SDI Investigación Entradas
</label> </label>
</div> </div>
<div class="form-check">
<input class="form-check-input"
type="radio"
name="selectedGroup"
id="grupo700"
checked='@(selectedGroup == "700")'
@onclick='() => selectedGroup = "700"' />
<label class="form-check-label" for="grupo700">
700. RESPONSABLE DEL SERVICIO
</label>
</div> *@
<!-- DATOS DEL TERCERO --> <!-- DATOS DEL TERCERO -->
@{ @{
@@ -626,13 +621,13 @@ else
<h6 class="modal-section-heading mt-3">Tercero (denunciante)</h6> <h6 class="modal-section-heading mt-3">Tercero (denunciante)</h6>
<div class="alert alert-light border mb-3"> <div class="alert alert-light border mb-3">
Los datos del tercero se cargan autom<EFBFBD>ticamente desde la denuncia y no se pueden editar aqu<EFBFBD>. Los datos del tercero se cargan automáticamente desde la denuncia y no se pueden editar aquí.
</div> </div>
@if (modalThirdParty.IsAnonymous) @if (modalThirdParty.IsAnonymous)
{ {
<div class="alert alert-warning mb-3"> <div class="alert alert-warning mb-3">
Denuncia an<EFBFBD>nima. Se enlazar<EFBFBD> autom<EFBFBD>ticamente el tercero <strong>00000000T</strong>. Denuncia anónima. Se enlazará automáticamente el tercero <strong>00000000T</strong>.
</div> </div>
} }
@@ -661,7 +656,7 @@ else
{ {
<div class="row g-2"> <div class="row g-2">
<div class="col-12 mb-2"> <div class="col-12 mb-2">
<label class="form-label">Raz<EFBFBD>n social</label> <label class="form-label">Razón social</label>
<input class="form-control" value="@GetReadOnlyValue(selectedDenuncias.RazonSocialResuelta)" readonly /> <input class="form-control" value="@GetReadOnlyValue(selectedDenuncias.RazonSocialResuelta)" readonly />
</div> </div>
</div> </div>
@@ -674,11 +669,11 @@ else
<input class="form-control" value="@GetReadOnlyValue(selectedDenuncias.NombreResuelto)" readonly /> <input class="form-control" value="@GetReadOnlyValue(selectedDenuncias.NombreResuelto)" readonly />
</div> </div>
<div class="col-4 mb-2"> <div class="col-4 mb-2">
<label class="form-label">1<EFBFBD> apellido</label> <label class="form-label">1º apellido</label>
<input class="form-control" value="@GetReadOnlyValue(selectedDenuncias.PrimerApellidoResuelto)" readonly /> <input class="form-control" value="@GetReadOnlyValue(selectedDenuncias.PrimerApellidoResuelto)" readonly />
</div> </div>
<div class="col-4 mb-2"> <div class="col-4 mb-2">
<label class="form-label">2<EFBFBD> apellido</label> <label class="form-label">2º apellido</label>
<input class="form-control" value="@GetReadOnlyValue(selectedDenuncias.SegundoApellidoResuelto)" readonly /> <input class="form-control" value="@GetReadOnlyValue(selectedDenuncias.SegundoApellidoResuelto)" readonly />
</div> </div>
</div> </div>
@@ -688,14 +683,14 @@ else
{ {
<div class="row g-2"> <div class="row g-2">
<div class="col-12 mb-2"> <div class="col-12 mb-2">
<label class="form-label">Direcci<EFBFBD>n postal</label> <label class="form-label">Dirección postal</label>
<textarea class="form-control" rows="2" readonly>@BuildPostalAddressSummary(selectedDenuncias)</textarea> <textarea class="form-control" rows="2" readonly>@BuildPostalAddressSummary(selectedDenuncias)</textarea>
</div> </div>
</div> </div>
} }
<small class="text-muted"> <small class="text-muted">
Al confirmar, se enlazar<EFBFBD> en Gestiona el tercero obtenido del formulario de la denuncia. Al confirmar, se enlazará en Gestiona el tercero obtenido del formulario de la denuncia.
</small> </small>
@if (!modalThirdParty.IsAnonymous && !string.IsNullOrWhiteSpace(modalThirdParty.DocumentId)) @if (!modalThirdParty.IsAnonymous && !string.IsNullOrWhiteSpace(modalThirdParty.DocumentId))
@@ -768,7 +763,7 @@ else
{ {
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<div class="spinner-border spinner-border-sm me-2" role="status"></div> <div class="spinner-border spinner-border-sm me-2" role="status"></div>
<span>Cargando expedientes<EFBFBD></span> <span>Cargando expedientes</span>
</div> </div>
} }
else if (!string.IsNullOrWhiteSpace(errorExpedientes)) else if (!string.IsNullOrWhiteSpace(errorExpedientes))
@@ -788,7 +783,7 @@ else
<tr> <tr>
<th>Expediente</th> <th>Expediente</th>
<th>Asunto</th> <th>Asunto</th>
<th>Fecha creaci<EFBFBD>n</th> <th>Fecha creación</th>
<th>Estado</th> <th>Estado</th>
<th></th> <th></th>
</tr> </tr>
@@ -827,7 +822,7 @@ else
private string nombreDocumentos = string.Empty; private string nombreDocumentos = string.Empty;
private bool isUploading = false; private bool isUploading = false;
private string uploadMode = "merge"; private string uploadMode = "individual";
private string selectedGroup = "600"; private string selectedGroup = "600";
@@ -870,14 +865,6 @@ else
{ {
loadError = string.Empty; loadError = string.Empty;
var todas = await CargarDenunciasJsonAsync(); var todas = await CargarDenunciasJsonAsync();
// Asegura ProcedureId/GroupId por si faltan
foreach (var d in todas.Where(x => x.ProcedureId == Guid.Empty))
{
d.ProcedureId = Guid.Parse("82722c9b-cecc-4299-8a7b-ce5abeb8170b");
d.GroupId = Guid.Parse("6dbfc433-1eb6-4b9a-a533-bfebc652c101");
}
// SOLO pendientes // SOLO pendientes
pendientes = todas pendientes = todas
.Where(d => !d.EnGestiona && !d.EnRechazada && !d.EsActualizacion) .Where(d => !d.EnGestiona && !d.EnRechazada && !d.EsActualizacion)
@@ -944,6 +931,15 @@ else
return new string(clean.Where(ch => ch <= 127).ToArray()); return new string(clean.Where(ch => ch <= 127).ToArray());
} }
private string BuildMergedAttachmentsFileName(int denunciaId, DateTime timestampUtc)
{
var baseName = string.IsNullOrWhiteSpace(nombreDocumentos)
? $"Adjuntos {denunciaId}_{timestampUtc:yyyyMMddHHmmss}"
: nombreDocumentos.Trim();
return FixFileName($"{Path.GetFileNameWithoutExtension(baseName)}.pdf");
}
private async Task ConfirmarEnvio() private async Task ConfirmarEnvio()
{ {
if (selectedDenuncias == null) return; if (selectedDenuncias == null) return;
@@ -956,6 +952,11 @@ else
return; return;
} }
if (!await ConfirmSelectedGroupAsync())
{
return;
}
try try
{ {
isUploading = true; isUploading = true;
@@ -988,10 +989,11 @@ else
{ {
operationError = ficherosVacios.Count == 0 operationError = ficherosVacios.Count == 0
? $"La denuncia #{selectedDenuncias.Id_Denuncia} no tiene ficheros para subir." ? $"La denuncia #{selectedDenuncias.Id_Denuncia} no tiene ficheros para subir."
: $"La denuncia #{selectedDenuncias.Id_Denuncia} solo tiene ficheros vac<EFBFBD>os y no se ha subido nada. Ficheros omitidos: {string.Join(", ", ficherosVacios)}."; : $"La denuncia #{selectedDenuncias.Id_Denuncia} solo tiene ficheros vacíos y no se ha subido nada. Ficheros omitidos: {string.Join(", ", ficherosVacios)}.";
return; return;
} }
var expedienteCreadoEnGestiona = false;
string fileUrl; string fileUrl;
if (selectedDenuncias.EnGestiona && !string.IsNullOrWhiteSpace(selectedDenuncias.Expediente_Gestiona)) if (selectedDenuncias.EnGestiona && !string.IsNullOrWhiteSpace(selectedDenuncias.Expediente_Gestiona))
{ {
@@ -1001,7 +1003,6 @@ else
{ {
Busy.Update(message: "Creando expediente nuevo en Gestiona.", detail: "Paso 2 de 7"); Busy.Update(message: "Creando expediente nuevo en Gestiona.", detail: "Paso 2 de 7");
var createdFile = await ApiDenuncias.CreateGestionaFileAsync( var createdFile = await ApiDenuncias.CreateGestionaFileAsync(
selectedDenuncias.ProcedureId,
nuevoAsunto, nuevoAsunto,
"RQ2ZLC - Expediente de Denuncias", "RQ2ZLC - Expediente de Denuncias",
"3109963" "3109963"
@@ -1011,20 +1012,13 @@ else
await ApiDenuncias.OpenGestionaFileAsync( await ApiDenuncias.OpenGestionaFileAsync(
fileUrl, fileUrl,
createdFile.FileOpenUrl, createdFile.FileOpenUrl,
managementUnitGroupId: Guid.Parse("a4ad4dfb-70dc-4219-8ee3-4dcc939f0955"), assignedGroupCode: selectedGroup,
assignedGroupId: selectedGroup switch
{
"510" => Guid.Parse("6dbfc433-1eb6-4b9a-a533-bfebc652c101"),
"600" => Guid.Parse("454fa4ec-8b82-4240-9419-113f45d4b004"),
"700" => Guid.Parse("a4ad4dfb-70dc-4219-8ee3-4dcc939f0955"),
_ => throw new InvalidOperationException($"Grupo desconocido: {selectedGroup}")
},
confidential: selectedDenuncias.Confidencial, confidential: selectedDenuncias.Confidencial,
freeTitle: nuevoAsunto, freeTitle: nuevoAsunto
siaCode: "3109963"
); );
selectedDenuncias.Expediente_Gestiona = fileUrl; selectedDenuncias.Expediente_Gestiona = fileUrl;
selectedDenuncias.EnGestiona = true; selectedDenuncias.EnGestiona = true;
expedienteCreadoEnGestiona = true;
} }
Busy.Update(message: "Guardando referencia local del expediente.", detail: "Paso 3 de 7"); Busy.Update(message: "Guardando referencia local del expediente.", detail: "Paso 3 de 7");
@@ -1033,7 +1027,7 @@ else
var thirdParty = selectedThirdParty ?? ThirdPartyIdentityData.FromComplaint(selectedDenuncias); var thirdParty = selectedThirdParty ?? ThirdPartyIdentityData.FromComplaint(selectedDenuncias);
Busy.Update(message: "Resolviendo tercero y enlazandolo al expediente.", detail: "Paso 4 de 7"); Busy.Update(message: "Resolviendo tercero y enlazandolo al expediente.", detail: "Paso 4 de 7");
await ApiDenuncias.EnsureGestionaThirdAndLinkAsync(fileUrl, thirdParty); var thirdResult = await ApiDenuncias.EnsureGestionaThirdAndLinkAsync(fileUrl, thirdParty);
var nombresOriginalesSubidos = new List<string>(); var nombresOriginalesSubidos = new List<string>();
var nombresFinalesSubidos = new List<string>(); var nombresFinalesSubidos = new List<string>();
@@ -1045,8 +1039,8 @@ else
if (!string.IsNullOrWhiteSpace(report.FileName)) if (!string.IsNullOrWhiteSpace(report.FileName))
{ {
Busy.Update(message: "Preparando y subiendo el report para firma.", detail: "Paso 5 de 7"); Busy.Update(message: "Preparando y subiendo el documento principal.", detail: "Paso 5 de 7");
var reportPdfBytes = PdfHelper.MergeFilesToPdf(new[] var reportPdfBytes = PdfHelper.MergeReportToPdf(new[]
{ {
(FileName: report.FileName, Content: report.Content) (FileName: report.FileName, Content: report.Content)
}); });
@@ -1067,7 +1061,7 @@ else
{ {
Busy.Update(message: "Uniendo adjuntos en un unico PDF y subiendolo.", detail: "Paso 6 de 7"); Busy.Update(message: "Uniendo adjuntos en un unico PDF y subiendolo.", detail: "Paso 6 de 7");
var pdfBytes = PdfHelper.MergeFilesToPdf(adjuntos); var pdfBytes = PdfHelper.MergeFilesToPdf(adjuntos);
var pdfName = FixFileName($"Adjuntos {selectedDenuncias.Id_Denuncia}_{ahoraUtc:yyyyMMddHHmmss}.pdf"); var pdfName = BuildMergedAttachmentsFileName(selectedDenuncias.Id_Denuncia, ahoraUtc);
var docUrl = await ApiDenuncias.UploadGestionaDocumentAsync(fileUrl, pdfBytes, pdfName); var docUrl = await ApiDenuncias.UploadGestionaDocumentAsync(fileUrl, pdfBytes, pdfName);
if (string.IsNullOrWhiteSpace(documentoParaTramitar)) if (string.IsNullOrWhiteSpace(documentoParaTramitar))
{ {
@@ -1127,11 +1121,12 @@ else
if (!string.IsNullOrWhiteSpace(documentoParaTramitar)) if (!string.IsNullOrWhiteSpace(documentoParaTramitar))
{ {
Busy.Update(message: "Enviando el report al circuito de firma.", detail: "Paso 7 de 7"); Busy.Update(message: "Finalizando el documento principal en Gestiona.", detail: "Paso 7 de 7");
await ApiDenuncias.TramitarGestionaDocumentAsync( await ApiDenuncias.TramitarGestionaDocumentAsync(
documentoParaTramitar, documentoParaTramitar,
GetAssignedGroupLinkBySelectedGroup(), selectedGroup,
selectedDenuncias.Id_Denuncia); selectedDenuncias.Id_Denuncia,
isUpdate: false);
} }
foreach (var origName in nombresOriginalesSubidos) foreach (var origName in nombresOriginalesSubidos)
@@ -1155,7 +1150,16 @@ else
selectedDenuncias.EsActualizacion = false; selectedDenuncias.EsActualizacion = false;
selectedDenuncias.NombreDenuncia = nuevoAsunto; selectedDenuncias.NombreDenuncia = nuevoAsunto;
selectedDenuncias.FechaSubidaAGestiona = ahoraUtc; selectedDenuncias.FechaSubidaAGestiona = ahoraUtc;
selectedDenuncias.UltimaSubidaGestionaTipo = "Nueva denuncia";
selectedDenuncias.UltimoGrupoAsignadoGestiona = GetGestionaGroupDisplay(selectedGroup);
await SincronizarExpedienteGestionaAsync(selectedDenuncias, fileUrl);
await ActualizarDenunciaAsync(selectedDenuncias); await ActualizarDenunciaAsync(selectedDenuncias);
var historialAviso = await RegistrarHistorialGestionaAsync(
selectedDenuncias,
"Nueva denuncia",
selectedGroup,
ahoraUtc,
string.Join("; ", nombresFinalesSubidos));
var denunciaProcesadaId = selectedDenuncias.Id_Denuncia; var denunciaProcesadaId = selectedDenuncias.Id_Denuncia;
pendientes.Remove(selectedDenuncias); pendientes.Remove(selectedDenuncias);
@@ -1165,22 +1169,35 @@ else
var avisos = new List<string>(); var avisos = new List<string>();
if (ficherosVacios.Count > 0) if (ficherosVacios.Count > 0)
{ {
avisos.Add($"Se omitieron ficheros vac<EFBFBD>os: {string.Join(", ", ficherosVacios)}."); avisos.Add($"Se omitieron ficheros vacíos: {string.Join(", ", ficherosVacios)}.");
} }
if (ficherosNoSeleccionados.Count > 0) if (ficherosNoSeleccionados.Count > 0)
{ {
avisos.Add($"No se subieron por selecci<EFBFBD>n del usuario: {string.Join(", ", ficherosNoSeleccionados)}."); avisos.Add($"No se subieron por selección del usuario: {string.Join(", ", ficherosNoSeleccionados)}.");
} }
if (avisos.Count > 0) if (thirdResult.Warnings.Count > 0)
{ {
operationNotice = $"Denuncia #{denunciaProcesadaId} enviada. {string.Join(" ", avisos)}"; avisos.AddRange(thirdResult.Warnings);
} }
if (!string.IsNullOrWhiteSpace(historialAviso))
{
avisos.Add(historialAviso);
}
var expedienteInfo = string.IsNullOrWhiteSpace(selectedDenuncias.ExpedienteGestionaMostrable)
? string.Empty
: $" Nº expediente Gestiona: {selectedDenuncias.ExpedienteGestionaMostrable}.";
var resumen = expedienteCreadoEnGestiona
? $"Denuncia #{denunciaProcesadaId}: se ha creado el expediente en Gestiona y se han subido los documentos.{expedienteInfo}"
: $"Denuncia #{denunciaProcesadaId}: se han subido los documentos al expediente de Gestiona.{expedienteInfo}";
operationNotice = avisos.Count > 0
? $"{resumen} {string.Join(" ", avisos)}"
: resumen;
StateHasChanged(); StateHasChanged();
} }
catch (Exception ex) catch (Exception ex)
{ {
Console.Error.WriteLine($"Error al confirmar env<EFBFBD>o: {ex}"); Console.Error.WriteLine($"Error al confirmar envío: {ex}");
operationError = $"No se ha podido completar el env<EFBFBD>o de la denuncia #{selectedDenuncias?.Id_Denuncia}: {ex.Message}"; operationError = $"No se ha podido completar el envío de la denuncia #{selectedDenuncias?.Id_Denuncia}: {ex.Message}";
} }
finally finally
{ {
@@ -1226,15 +1243,37 @@ else
} }
} }
private string GetAssignedGroupLinkBySelectedGroup() private static string GetGestionaGroupDisplay(string? groupCode)
{ {
return selectedGroup switch return string.IsNullOrWhiteSpace(groupCode) ? string.Empty : groupCode.Trim();
}
private async Task<string?> RegistrarHistorialGestionaAsync(
DenunciasGestiona denuncia,
string tipoSubida,
string grupo,
DateTime uploadedAtUtc,
string documentos)
{
try
{ {
"510" => "https://02.g3stiona.com/rest/groups/6dbfc433-1eb6-4b9a-a533-bfebc652c101", await ApiDenuncias.RegisterGestionaUploadHistoryAsync(
"600" => "https://02.g3stiona.com/rest/groups/454fa4ec-8b82-4240-9419-113f45d4b004", new GestionaUploadHistoryCreateRequest(
"700" => "https://02.g3stiona.com/rest/groups/a4ad4dfb-70dc-4219-8ee3-4dcc939f0955", denuncia.Id_Denuncia,
_ => throw new InvalidOperationException($"Grupo desconocido: {selectedGroup}") denuncia.Expediente_Gestiona ?? string.Empty,
}; denuncia.ExpedienteGestionaMostrable,
tipoSubida,
grupo,
uploadedAtUtc,
denuncia.NombreDenuncia ?? string.Empty,
documentos));
return null;
}
catch (Exception ex)
{
Console.Error.WriteLine($"No se ha podido registrar el historico de subida a Gestiona: {ex}");
return "La subida se ha completado, pero no se ha podido registrar el histórico operativo en la aplicación.";
}
} }
private async Task ConfirmarRechazo() private async Task ConfirmarRechazo()
@@ -1258,8 +1297,13 @@ else
await DenunciaStore.UpsertDenunciaAsync(d); await DenunciaStore.UpsertDenunciaAsync(d);
} }
private void OpenEnviarAGestionaModal(DenunciasGestiona d) private async Task OpenEnviarAGestionaModal(DenunciasGestiona d)
{ {
if (!await ConfirmDifferentOwnerAsync(d, "tramitar"))
{
return;
}
selectedDenuncias = d; selectedDenuncias = d;
nuevoAsunto = $"Denuncia {d.Id_Denuncia}-CD"; nuevoAsunto = $"Denuncia {d.Id_Denuncia}-CD";
@@ -1271,13 +1315,64 @@ else
showModal = true; showModal = true;
} }
private void OpenRechazarModal(DenunciasGestiona d) private async Task OpenRechazarModal(DenunciasGestiona d)
{ {
if (!await ConfirmDifferentOwnerAsync(d, "rechazar"))
{
return;
}
selectedDenuncias = d; selectedDenuncias = d;
motivoRechazo = string.Empty; motivoRechazo = string.Empty;
showModalRechazo = true; showModalRechazo = true;
} }
private async Task<bool> ConfirmDifferentOwnerAsync(
DenunciasGestiona denuncia,
string action)
{
if (!denuncia.RequiresOwnerConfirmation)
{
return true;
}
var confirmText = string.Equals(action, "rechazar", StringComparison.OrdinalIgnoreCase)
? "Continuar con el rechazo"
: "Continuar con la apertura";
return await Dialogs.ConfirmAsync(
"Denuncia asignada a otro usuario",
$"La denuncia #{denuncia.Id_Denuncia} está asignada a {denuncia.OwnerUsername}. " +
$"Puedes {action}la porque pertenece a un usuario de tu grupo. La acción quedará registrada con tu usuario.",
confirmText);
}
private async Task<bool> ConfirmSelectedGroupAsync()
{
try
{
var currentGroups = await ApiDenuncias.GetCurrentUserWorkGroupsAsync();
if (currentGroups.GroupCodes.Contains(selectedGroup, StringComparer.OrdinalIgnoreCase))
{
return true;
}
var configuredGroups = currentGroups.GroupCodes.Count == 0
? "ningún grupo"
: string.Join(", ", currentGroups.GroupCodes);
return await Dialogs.ConfirmAsync(
"Cambio de asignación en Gestiona",
$"El grupo de destino {selectedGroup} no pertenece a tus grupos configurados ({configuredGroups}). " +
"Si continúas, la denuncia quedará asignada en Gestiona a un grupo distinto de los tuyos.",
"Cambiar asignación");
}
catch (Exception ex)
{
operationError = $"No se han podido comprobar tus grupos antes de la subida: {ex.Message}";
return false;
}
}
private void CloseModal() private void CloseModal()
{ {
showModal = false; showModal = false;
@@ -1379,7 +1474,7 @@ else
} }
private static string GetReadOnlyValue(string? value) => private static string GetReadOnlyValue(string? value) =>
string.IsNullOrWhiteSpace(value) ? "<EFBFBD>" : value; string.IsNullOrWhiteSpace(value) ? "" : value;
private static bool HasPostalAddress(DenunciasGestiona denuncia) private static bool HasPostalAddress(DenunciasGestiona denuncia)
{ {
@@ -1438,18 +1533,7 @@ else
return string.Join(" | ", parts); return string.Join(" | ", parts);
} }
// mover a Actualizaciones // ========= LÓGICA BUSCADOR DE EXPEDIENTES POR TERCERO =========
private async Task MoverAActualizaciones(DenunciasGestiona d)
{
d.EsActualizacion = true;
d.EnRechazada = false;
await ActualizarDenunciaAsync(d);
pendientes.RemoveAll(x => x.Id_Denuncia == d.Id_Denuncia);
StateHasChanged();
}
// ========= L<>GICA BUSCADOR DE EXPEDIENTES POR TERCERO =========
private void CloseExpedientesModal() private void CloseExpedientesModal()
{ {
@@ -1468,7 +1552,7 @@ else
if (string.IsNullOrWhiteSpace(nif) || nif == "00000000T") if (string.IsNullOrWhiteSpace(nif) || nif == "00000000T")
{ {
errorExpedientes = "NIF no v<EFBFBD>lido para b<EFBFBD>squeda (an<EFBFBD>nimo o vac<EFBFBD>o)."; errorExpedientes = "NIF no válido para búsqueda (anónimo o vacío).";
showExpedientesModal = true; showExpedientesModal = true;
StateHasChanged(); StateHasChanged();
return; return;
@@ -1499,3 +1583,4 @@ else
} }
} }

View File

@@ -52,6 +52,7 @@ builder.Services.AddScoped<UserState>();
builder.Services.AddSingleton<AppSessionLifetime>(); builder.Services.AddSingleton<AppSessionLifetime>();
builder.Services.AddSingleton<LoginRateLimiter>(); builder.Services.AddSingleton<LoginRateLimiter>();
builder.Services.AddScoped<UiBusyService>(); builder.Services.AddScoped<UiBusyService>();
builder.Services.AddScoped<UiDialogService>();
builder.Services.AddScoped<ApiDenunciasClient>(); builder.Services.AddScoped<ApiDenunciasClient>();
builder.Services.AddScoped<IDenunciaStore, ApiDenunciaStore>(); builder.Services.AddScoped<IDenunciaStore, ApiDenunciaStore>();
builder.Services.AddScoped<IInboxTrackingService, ApiInboxTrackingService>(); builder.Services.AddScoped<IInboxTrackingService, ApiInboxTrackingService>();

View File

@@ -34,6 +34,9 @@ public sealed class ApiDenunciaStore : IDenunciaStore
public async Task<List<FicherosDenuncias>> GetFicherosByDenunciaAsync(int denunciaId, CancellationToken cancellationToken = default) public async Task<List<FicherosDenuncias>> GetFicherosByDenunciaAsync(int denunciaId, CancellationToken cancellationToken = default)
=> (await _api.GetAsync<List<FicherosDenuncias>>($"api/denuncias/{denunciaId}/ficheros", cancellationToken)) ?? []; => (await _api.GetAsync<List<FicherosDenuncias>>($"api/denuncias/{denunciaId}/ficheros", cancellationToken)) ?? [];
public async Task<List<GestionaUploadHistoryEntry>> GetGestionaUploadHistoryAsync(CancellationToken cancellationToken = default)
=> (await _api.GetAsync<List<GestionaUploadHistoryEntry>>("api/denuncias/gestiona-history", cancellationToken)) ?? [];
public Task<DenunciasGestiona?> GetDenunciaByIdAsync(int denunciaId, CancellationToken cancellationToken = default) public Task<DenunciasGestiona?> GetDenunciaByIdAsync(int denunciaId, CancellationToken cancellationToken = default)
=> _api.GetAsync<DenunciasGestiona?>($"api/denuncias/{denunciaId}", cancellationToken); => _api.GetAsync<DenunciasGestiona?>($"api/denuncias/{denunciaId}", cancellationToken);
@@ -43,6 +46,12 @@ public sealed class ApiDenunciaStore : IDenunciaStore
public Task UpsertFicherosAsync(IEnumerable<FicherosDenuncias> ficheros, CancellationToken cancellationToken = default) public Task UpsertFicherosAsync(IEnumerable<FicherosDenuncias> ficheros, CancellationToken cancellationToken = default)
=> _api.PostAsync("api/denuncias/ficheros", new UpsertFicherosRequest(ficheros.ToArray()), cancellationToken); => _api.PostAsync("api/denuncias/ficheros", new UpsertFicherosRequest(ficheros.ToArray()), cancellationToken);
public Task AddGestionaUploadHistoryAsync(
GestionaUploadHistoryCreateRequest request,
string username,
CancellationToken cancellationToken = default)
=> _api.PostAsync("api/denuncias/gestiona-history", request, cancellationToken);
public Task MarkFicherosAsUploadedAsync( public Task MarkFicherosAsUploadedAsync(
int denunciaId, int denunciaId,
IEnumerable<string> fileNames, IEnumerable<string> fileNames,

View File

@@ -47,6 +47,15 @@ public sealed class ApiDenunciasClient
public Task<ApiGlobalLeaksSessionDto?> GetGlobalLeaksSessionAsync(CancellationToken cancellationToken = default) public Task<ApiGlobalLeaksSessionDto?> GetGlobalLeaksSessionAsync(CancellationToken cancellationToken = default)
=> SendAsync<ApiGlobalLeaksSessionDto?>(HttpMethod.Get, "api/inbox/session", body: null, authorize: true, cancellationToken, allowNull: true); => SendAsync<ApiGlobalLeaksSessionDto?>(HttpMethod.Get, "api/inbox/session", body: null, authorize: true, cancellationToken, allowNull: true);
public Task<ApiGlobalLeaksSessionDto?> KeepGlobalLeaksSessionAliveAsync(CancellationToken cancellationToken = default)
=> SendAsync<ApiGlobalLeaksSessionDto?>(
HttpMethod.Post,
"api/inbox/session/keepalive",
body: null,
authorize: true,
cancellationToken,
allowNull: true);
public Task<ApiLoginPrepareResponse> PrepareGlobalLeaksSessionRenewalAsync(CancellationToken cancellationToken = default) public Task<ApiLoginPrepareResponse> PrepareGlobalLeaksSessionRenewalAsync(CancellationToken cancellationToken = default)
=> SendAsync<ApiLoginPrepareResponse>( => SendAsync<ApiLoginPrepareResponse>(
HttpMethod.Post, HttpMethod.Post,
@@ -72,11 +81,14 @@ public sealed class ApiDenunciasClient
public Task<InboxSnapshotResponse> LoadInboxAsync(CancellationToken cancellationToken = default) public Task<InboxSnapshotResponse> LoadInboxAsync(CancellationToken cancellationToken = default)
=> SendAsync<InboxSnapshotResponse>(HttpMethod.Get, "api/inbox/reports", body: null, authorize: true, cancellationToken); => SendAsync<InboxSnapshotResponse>(HttpMethod.Get, "api/inbox/reports", body: null, authorize: true, cancellationToken);
public Task<ImportSummary> ImportReportAsync(ReportDto report, CancellationToken cancellationToken = default) public Task<ImportSummary> ImportReportAsync(
ReportDto report,
bool confirmDifferentOwner = false,
CancellationToken cancellationToken = default)
=> SendAsync<ImportSummary>( => SendAsync<ImportSummary>(
HttpMethod.Post, HttpMethod.Post,
$"api/inbox/reports/{Uri.EscapeDataString(report.Id)}/import", $"api/inbox/reports/{Uri.EscapeDataString(report.Id)}/import",
new ImportReportRequest(report), new ImportReportRequest(report, confirmDifferentOwner),
authorize: true, authorize: true,
cancellationToken); cancellationToken);
@@ -99,46 +111,35 @@ public sealed class ApiDenunciasClient
cancellationToken); cancellationToken);
} }
public Task EnsureStorageReadyAsync(CancellationToken cancellationToken = default)
=> SendAsync<object?>(HttpMethod.Post, "api/inbox/local/ensure-storage", body: null, authorize: true, cancellationToken);
public Task<ImportSummary> ProcessLocalZipsAsync(CancellationToken cancellationToken = default)
=> SendAsync<ImportSummary>(HttpMethod.Post, "api/inbox/local/process", body: null, authorize: true, cancellationToken);
public Task<IReadOnlyList<string>> GetExistingZipNamesAsync(CancellationToken cancellationToken = default)
=> SendAsync<IReadOnlyList<string>>(HttpMethod.Get, "api/inbox/local/zips", body: null, authorize: true, cancellationToken);
public Task DeleteZipAsync(string zipName, CancellationToken cancellationToken = default)
=> SendAsync<object?>(
HttpMethod.Delete,
$"api/inbox/local/zips/{Uri.EscapeDataString(zipName)}",
body: null,
authorize: true,
cancellationToken);
public Task<GestionaCreateFileResponse> CreateGestionaFileAsync( public Task<GestionaCreateFileResponse> CreateGestionaFileAsync(
Guid procedureId,
string subject, string subject,
string documentSeries, string documentSeries,
string siaCode, string siaCode,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
=> PostAsync<GestionaCreateFileResponse>( => PostAsync<GestionaCreateFileResponse>(
"api/gestiona/files", "api/gestiona/files",
new GestionaCreateFileRequest(procedureId, subject, documentSeries, siaCode), new GestionaCreateFileRequest(subject, documentSeries, siaCode),
cancellationToken); cancellationToken);
public Task OpenGestionaFileAsync( public Task OpenGestionaFileAsync(
string fileUrl, string fileUrl,
string? fileOpenUrl, string? fileOpenUrl,
Guid managementUnitGroupId, string assignedGroupCode,
Guid assignedGroupId,
bool confidential, bool confidential,
string freeTitle, string freeTitle,
string siaCode,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
=> PostAsync( => PostAsync(
"api/gestiona/files/open", "api/gestiona/files/open",
new GestionaOpenFileRequest(fileUrl, fileOpenUrl, managementUnitGroupId, assignedGroupId, confidential, freeTitle, siaCode), new GestionaOpenFileRequest(fileUrl, fileOpenUrl, assignedGroupCode, confidential, freeTitle),
cancellationToken);
public Task AssignGestionaFileAsync(
string fileUrl,
string assignedGroupCode,
CancellationToken cancellationToken = default)
=> PostAsync(
"api/gestiona/files/assignees",
new GestionaAssignFileRequest(fileUrl, assignedGroupCode),
cancellationToken); cancellationToken);
public Task<GestionaExpedienteInfo?> GetGestionaExpedienteAsync( public Task<GestionaExpedienteInfo?> GetGestionaExpedienteAsync(
@@ -149,11 +150,27 @@ public sealed class ApiDenunciasClient
cancellationToken, cancellationToken,
allowNull: true); allowNull: true);
public Task EnsureGestionaThirdAndLinkAsync( public Task<GestionaAuditInfo?> GetGestionaFileLatestAuditAsync(
string fileUrl,
CancellationToken cancellationToken = default)
=> GetAsync<GestionaAuditInfo?>(
$"api/gestiona/files/audit/latest?fileUrl={Uri.EscapeDataString(fileUrl)}",
cancellationToken,
allowNull: true);
public Task<List<GestionaUploadHistoryEntry>> GetGestionaUploadHistoryAsync(CancellationToken cancellationToken = default)
=> GetAsync<List<GestionaUploadHistoryEntry>>("api/denuncias/gestiona-history", cancellationToken);
public Task RegisterGestionaUploadHistoryAsync(
GestionaUploadHistoryCreateRequest request,
CancellationToken cancellationToken = default)
=> PostAsync("api/denuncias/gestiona-history", request, cancellationToken);
public Task<GestionaEnsureThirdResponse> EnsureGestionaThirdAndLinkAsync(
string fileUrl, string fileUrl,
ThirdPartyIdentityData thirdParty, ThirdPartyIdentityData thirdParty,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
=> PostAsync( => PostAsync<GestionaEnsureThirdResponse>(
"api/gestiona/thirds/ensure-link", "api/gestiona/thirds/ensure-link",
new GestionaEnsureThirdRequest(fileUrl, thirdParty), new GestionaEnsureThirdRequest(fileUrl, thirdParty),
cancellationToken); cancellationToken);
@@ -183,12 +200,19 @@ public sealed class ApiDenunciasClient
public Task TramitarGestionaDocumentAsync( public Task TramitarGestionaDocumentAsync(
string documentUrl, string documentUrl,
string assignedGroupHref, string assignedGroupCode,
int? complaintId, int? complaintId,
bool isUpdate = false,
string? updateSource = null,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
=> PostAsync( => PostAsync(
"api/gestiona/documents/tramitar", "api/gestiona/documents/tramitar",
new GestionaTramitarDocumentoRequest(documentUrl, assignedGroupHref, complaintId), new GestionaTramitarDocumentoRequest(
documentUrl,
assignedGroupCode,
complaintId,
isUpdate,
updateSource),
cancellationToken); cancellationToken);
public Task<List<ExpedienteTerceroDto>> GetGestionaExpedientesPorTerceroAsync( public Task<List<ExpedienteTerceroDto>> GetGestionaExpedientesPorTerceroAsync(
@@ -249,6 +273,29 @@ public sealed class ApiDenunciasClient
authorize: true, authorize: true,
cancellationToken); cancellationToken);
public Task<WorkGroupAdministrationDto> GetWorkGroupAdministrationAsync(
CancellationToken cancellationToken = default)
=> GetAsync<WorkGroupAdministrationDto>(
"api/configuration/work-groups",
cancellationToken);
public Task<CurrentUserWorkGroupsDto> GetCurrentUserWorkGroupsAsync(
CancellationToken cancellationToken = default)
=> GetAsync<CurrentUserWorkGroupsDto>(
"api/configuration/work-groups/current",
cancellationToken);
public Task<WorkGroupAdministrationDto> UpdateUserWorkGroupsAsync(
string username,
IReadOnlyList<string> groupCodes,
CancellationToken cancellationToken = default)
=> SendAsync<WorkGroupAdministrationDto>(
HttpMethod.Put,
$"api/configuration/work-groups/users/{Uri.EscapeDataString(username)}",
new UpdateUserWorkGroupsRequest(groupCodes),
authorize: true,
cancellationToken);
internal Task<T> GetAsync<T>(string path, CancellationToken cancellationToken = default, bool allowNull = false) internal Task<T> GetAsync<T>(string path, CancellationToken cancellationToken = default, bool allowNull = false)
=> SendAsync<T>(HttpMethod.Get, path, body: null, authorize: true, cancellationToken, allowNull); => SendAsync<T>(HttpMethod.Get, path, body: null, authorize: true, cancellationToken, allowNull);
@@ -292,13 +339,16 @@ public sealed class ApiDenunciasClient
using var response = await client.SendAsync(request, cancellationToken); using var response = await client.SendAsync(request, cancellationToken);
if (response.StatusCode == HttpStatusCode.Unauthorized) if (response.StatusCode == HttpStatusCode.Unauthorized)
{ {
if (authorize) var message = await ReadErrorMessageAsync(response, cancellationToken);
if (!string.IsNullOrWhiteSpace(message) &&
!message.StartsWith("La API de denuncias ha respondido con", StringComparison.OrdinalIgnoreCase))
{ {
throw new UnauthorizedAccessException($"La sesion de API ha caducado al llamar a {path}. Vuelve a iniciar sesion."); throw new UnauthorizedAccessException(message);
} }
var message = await ReadErrorMessageAsync(response, cancellationToken); throw new UnauthorizedAccessException(authorize
throw new UnauthorizedAccessException(message); ? "La sesion de la aplicacion ha caducado. Vuelve a iniciar sesion."
: "No se ha podido autorizar la peticion. Vuelve a iniciar sesion.");
} }
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)

View File

@@ -33,12 +33,23 @@ public sealed class ApiInboxTrackingService : IInboxTrackingService
new MarkReportImportedRequest(username, report, complaintId), new MarkReportImportedRequest(username, report, complaintId),
cancellationToken); cancellationToken);
public Task MarkReportHandledInGestionaAsync(
string username,
int denunciaId,
DateTime uploadedAtUtc,
CancellationToken cancellationToken = default)
=> _api.PostAsync(
"api/tracking/handled-in-gestiona",
new MarkReportHandledInGestionaRequest(username, denunciaId, uploadedAtUtc),
cancellationToken);
public Task EnsureReportCanBeImportedByUserAsync( public Task EnsureReportCanBeImportedByUserAsync(
string username, string username,
ReportDto report, ReportDto report,
bool confirmDifferentOwner = false,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
=> _api.PostAsync( => _api.PostAsync(
"api/tracking/import-permission", "api/tracking/import-permission",
new TrackingImportPermissionRequest(username, report), new TrackingImportPermissionRequest(username, report, confirmDifferentOwner),
cancellationToken); cancellationToken);
} }

View File

@@ -0,0 +1,64 @@
namespace GestionaDenunciasAN.Services;
public enum UiDialogTone
{
Information,
Warning,
Danger
}
public sealed class UiDialogService
{
private TaskCompletionSource<bool>? _pendingConfirmation;
public event Action? Changed;
public bool IsVisible { get; private set; }
public string Title { get; private set; } = string.Empty;
public string Message { get; private set; } = string.Empty;
public string ConfirmText { get; private set; } = "Continuar";
public string CancelText { get; private set; } = "Cancelar";
public UiDialogTone Tone { get; private set; } = UiDialogTone.Warning;
public Task<bool> ConfirmAsync(
string title,
string message,
string confirmText = "Continuar",
string cancelText = "Cancelar",
UiDialogTone tone = UiDialogTone.Warning)
{
_pendingConfirmation?.TrySetResult(false);
Title = title.Trim();
Message = message.Trim();
ConfirmText = confirmText.Trim();
CancelText = cancelText.Trim();
Tone = tone;
IsVisible = true;
_pendingConfirmation = new TaskCompletionSource<bool>(
TaskCreationOptions.RunContinuationsAsynchronously);
Changed?.Invoke();
return _pendingConfirmation.Task;
}
public void Confirm()
{
Complete(true);
}
public void Cancel()
{
Complete(false);
}
private void Complete(bool confirmed)
{
var pendingConfirmation = _pendingConfirmation;
_pendingConfirmation = null;
IsVisible = false;
Changed?.Invoke();
pendingConfirmation?.TrySetResult(confirmed);
}
}

View File

@@ -141,6 +141,13 @@ pre {
gap: 0.85rem; gap: 0.85rem;
} }
.app-status-pills {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.65rem;
}
.app-session-pill { .app-session-pill {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -160,6 +167,24 @@ pre {
box-shadow: 0 0 0 0.2rem rgba(31, 122, 85, 0.12); box-shadow: 0 0 0 0.2rem rgba(31, 122, 85, 0.12);
} }
.app-key-pill {
background: rgba(42, 82, 152, 0.1);
color: var(--app-accent-strong);
}
.app-key-pill .app-session-pill__dot {
box-shadow: 0 0 0 0.2rem rgba(42, 82, 152, 0.12);
}
.app-key-pill--warning {
background: rgba(184, 117, 18, 0.14);
color: #8a5a12;
}
.app-key-pill--warning .app-session-pill__dot {
box-shadow: 0 0 0 0.2rem rgba(184, 117, 18, 0.14);
}
.app-user-chip { .app-user-chip {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -548,6 +573,14 @@ h1:focus {
align-items: stretch; align-items: stretch;
} }
.app-status-pills {
justify-content: stretch;
}
.app-status-pills .app-session-pill {
flex: 1 1 14rem;
}
.app-user-chip, .app-user-chip,
.app-session-pill { .app-session-pill {
justify-content: center; justify-content: center;

View File

@@ -68,3 +68,62 @@ window.appSetBodyScrollLock = function (locked) {
document.documentElement.classList.toggle("app-scroll-locked", Boolean(locked)); document.documentElement.classList.toggle("app-scroll-locked", Boolean(locked));
document.body.classList.toggle("app-scroll-locked", Boolean(locked)); document.body.classList.toggle("app-scroll-locked", Boolean(locked));
}; };
window.appGlobalLeaksActivity = (function () {
const storageKey = "gestiona-denuncias:last-user-activity";
const events = ["pointerdown", "pointermove", "keydown", "touchstart", "scroll"];
let lastActivity = Date.now();
let tracking = false;
function readSharedActivity() {
try {
const stored = Number(window.localStorage.getItem(storageKey));
return Number.isFinite(stored) && stored > 0 ? stored : 0;
} catch {
return 0;
}
}
function markActivity() {
lastActivity = Date.now();
try {
window.localStorage.setItem(storageKey, String(lastActivity));
} catch {
// The in-memory timestamp still works when local storage is unavailable.
}
}
function start() {
if (tracking) {
return;
}
tracking = true;
markActivity();
for (const eventName of events) {
window.addEventListener(eventName, markActivity, { passive: true });
}
}
function stop() {
if (!tracking) {
return;
}
tracking = false;
for (const eventName of events) {
window.removeEventListener(eventName, markActivity);
}
}
function getIdleMilliseconds() {
const latestActivity = Math.max(lastActivity, readSharedActivity());
return Math.max(0, Date.now() - latestActivity);
}
return {
start,
stop,
getIdleMilliseconds
};
})();

View File

@@ -170,8 +170,9 @@
} }
var separator = certLoginBaseUrl.Contains('?') ? "&" : "?"; var separator = certLoginBaseUrl.Contains('?') ? "&" : "?";
var url = $"{certLoginBaseUrl}{separator}iframe=true&parentOrigin={Uri.EscapeDataString(parentOrigin)}"; var url =
await JSRuntime.InvokeVoidAsync("iniciarSesionConCertificado", url); $"{certLoginBaseUrl}{separator}iframe=true&origen=Registro&parentOrigin={Uri.EscapeDataString(parentOrigin)}";
await JSRuntime.InvokeVoidAsync("iniciarSesionConCertificado", url);
} }

View File

@@ -189,7 +189,7 @@
</div> </div>
<div class="col-md-4"> <div class="col-md-4">
<label class="lblInput" for="unidad-administrativa">Departamento:</label> <label class="lblInput" for="unidad-administrativa">Departamento:</label>
<input class="form-control" id="unidad-administrativa" value="@Persona.IDDEPARTAMENTONavigation?.DESCRIPCION" disabled /> <input class="form-control" id="unidad-administrativa" value="@Persona.DEPARTAMENTOACTUAL" disabled />
</div> </div>
@* <div class="col-md-2"> @* <div class="col-md-2">
<label class="lblInput" for="unidad-administrativa">Unidad administrativa:</label> <label class="lblInput" for="unidad-administrativa">Unidad administrativa:</label>
@@ -301,7 +301,7 @@
private List<ENUMERACIONES> listadoMutua = new List<ENUMERACIONES>(); private List<ENUMERACIONES> listadoMutua = new List<ENUMERACIONES>();
private List<ENUMERACIONES> listadoEscala = new List<ENUMERACIONES>(); private List<ENUMERACIONES> listadoEscala = new List<ENUMERACIONES>();
private List<ENUMERACIONES> listadoEspecialidad = new List<ENUMERACIONES>(); private List<ENUMERACIONES> listadoEspecialidad = new List<ENUMERACIONES>();
private PUESTOS puestoQueOcupa { get; set; } = new PUESTOS();
private HttpClient cliente = new HttpClient(); private HttpClient cliente = new HttpClient();
private int NumeroTrienios { get; set; } private int NumeroTrienios { get; set; }
@@ -352,7 +352,7 @@
listadoEspecialidad = await rellenarLitadoEnum("ESPECIALIDADES"); listadoEspecialidad = await rellenarLitadoEnum("ESPECIALIDADES");
listadoMutua = await rellenarLitadoEnum("MUT"); listadoMutua = await rellenarLitadoEnum("MUT");
listadotramos = await rellenarLitadoEnum("TRAMCOMCARR"); listadotramos = await rellenarLitadoEnum("TRAMCOMCARR");
await CalcularNumeroTrienios(); await CalcularNumeroTrienios();

View File

@@ -55,7 +55,8 @@ namespace RegistroPersonalAN.Services
var client = _httpClientFactory.CreateClient("DefaultClient"); var client = _httpClientFactory.CreateClient("DefaultClient");
using var response = await client.PostAsJsonAsync("Auth/login-cert-proxy", new CertificateProxyLoginRequest using var response = await client.PostAsJsonAsync("Auth/login-cert-proxy", new CertificateProxyLoginRequest
{ {
Dni = dni Dni = dni,
Origen = "Registro"
}); });
var responseContent = await response.Content.ReadAsStringAsync(); var responseContent = await response.Content.ReadAsStringAsync();
@@ -96,6 +97,7 @@ namespace RegistroPersonalAN.Services
public sealed class CertificateProxyLoginRequest public sealed class CertificateProxyLoginRequest
{ {
public string Dni { get; set; } = string.Empty; public string Dni { get; set; } = string.Empty;
public string Origen { get; set; } = string.Empty;
} }
public sealed class CertificateProxyLoginResponse public sealed class CertificateProxyLoginResponse

View File

@@ -46,7 +46,7 @@ namespace SwaggerAntifraude.Controllers
[AllowAnonymous] [AllowAnonymous]
[HttpGet("login-cert")] [HttpGet("login-cert")]
public IActionResult LoginWithCertificateGet([FromQuery] bool iframe = false) public IActionResult LoginWithCertificateGet([FromQuery] bool iframe = false, [FromQuery] string? origen = null)
{ {
var clientCert = HttpContext.Connection.ClientCertificate; var clientCert = HttpContext.Connection.ClientCertificate;
if (clientCert == null) if (clientCert == null)
@@ -56,7 +56,7 @@ namespace SwaggerAntifraude.Controllers
if (string.IsNullOrWhiteSpace(dni)) if (string.IsNullOrWhiteSpace(dni))
return Unauthorized("No se pudo obtener un DNI válido del certificado."); return Unauthorized("No se pudo obtener un DNI válido del certificado.");
var result = AuthenticateCertificateDni(dni); var result = AuthenticateCertificateDni(dni, origen);
if (result.Token == null || result.Persona == null) if (result.Token == null || result.Persona == null)
return Unauthorized(result.Error ?? "No se pudo autenticar el certificado."); return Unauthorized(result.Error ?? "No se pudo autenticar el certificado.");
@@ -82,14 +82,14 @@ namespace SwaggerAntifraude.Controllers
return BadRequest("Debe indicarse un DNI."); return BadRequest("Debe indicarse un DNI.");
} }
var result = AuthenticateCertificateDni(request.Dni); var result = AuthenticateCertificateDni(request.Dni, request.Origen);
if (result.Token == null || result.Persona == null) if (result.Token == null || result.Persona == null)
return Unauthorized(result.Error ?? "No se pudo autenticar el certificado."); return Unauthorized(result.Error ?? "No se pudo autenticar el certificado.");
return Ok(BuildLoginResponse(result.Persona, result.Token)); return Ok(BuildLoginResponse(result.Persona, result.Token));
} }
private (string? Token, PERSONAS? Persona, string? Error) AuthenticateCertificateDni(string dni) private (string? Token, PERSONAS? Persona, string? Error) AuthenticateCertificateDni(string dni, string? origen)
{ {
using var context = tsGestionAntifraude.NuevoContexto(SoloLectura: true); using var context = tsGestionAntifraude.NuevoContexto(SoloLectura: true);
@@ -100,7 +100,11 @@ namespace SwaggerAntifraude.Controllers
{ {
return (null, null, "Usuario no encontrado en la base de datos."); return (null, null, "Usuario no encontrado en la base de datos.");
} }
if (string.Equals(origen, "Registro", StringComparison.OrdinalIgnoreCase)
&& persona.ADMINISTRARPTYREGISTRO != true)
{
return (null, null, "Usuario no autorizado.");
}
var jwtToken = GenerateJwtToken(persona); var jwtToken = GenerateJwtToken(persona);
return (jwtToken, persona, null); return (jwtToken, persona, null);
} }

View File

@@ -0,0 +1,15 @@
using bdAntifraude.db;
using Microsoft.AspNetCore.Mvc;
namespace SwaggerAntifraude.Controllers
{
[ApiController]
[Route("api/[controller]")]
public class DIFERENCIAPAGODELEGADOController : GenericoController<DIFERENCIAPAGODELEGADO, int>
{
public DIFERENCIAPAGODELEGADOController()
: base()
{
}
}
}

View File

@@ -0,0 +1,15 @@
using bdAntifraude.db;
using Microsoft.AspNetCore.Mvc;
namespace SwaggerAntifraude.Controllers
{
[ApiController]
[Route("api/[controller]")]
public class HUELGASController : GenericoController<HUELGAS, int>
{
public HUELGASController()
: base()
{
}
}
}

View File

@@ -0,0 +1,15 @@
using bdAntifraude.db;
using Microsoft.AspNetCore.Mvc;
namespace SwaggerAntifraude.Controllers
{
[ApiController]
[Route("api/[controller]")]
public class PERMISOSSINRETRIBUCIONController : GenericoController<PERMISOSSINRETRIBUCION, int>
{
public PERMISOSSINRETRIBUCIONController()
: base()
{
}
}
}

View File

@@ -155,6 +155,9 @@ namespace SwaggerAntifraude.Controllers
.Include(pue => pue.PUESTOSIDPERSONALNavigation) .Include(pue => pue.PUESTOSIDPERSONALNavigation)
.ThenInclude(rpt => rpt.IDRPTDESNavigation) .ThenInclude(rpt => rpt.IDRPTDESNavigation)
.ThenInclude(rpt => rpt.IDUNIDADADMINISTRATIVANavigation) .ThenInclude(rpt => rpt.IDUNIDADADMINISTRATIVANavigation)
.Include(pue => pue.PUESTOSIDPERSONALNavigation)
.ThenInclude(rpt => rpt.IDRPTDESNavigation)
.ThenInclude(x => x.IDDEPARTAMENTONavigation)
.Include(pue => pue.PUESTOSIDPERSONALNavigation) .Include(pue => pue.PUESTOSIDPERSONALNavigation)
.Include(pue => pue.EXCEPCIONESPERMISOSIDPERSONANavigation) .Include(pue => pue.EXCEPCIONESPERMISOSIDPERSONANavigation)
.ThenInclude(exc=>exc.IDDEPARTAMENTONavigation) .ThenInclude(exc=>exc.IDDEPARTAMENTONavigation)
@@ -201,6 +204,9 @@ namespace SwaggerAntifraude.Controllers
.Include(cp => cp.IDDEPARTAMENTONavigation) .Include(cp => cp.IDDEPARTAMENTONavigation)
.Include(cp => cp.CODIGOMUNICIPIONavigation) .Include(cp => cp.CODIGOMUNICIPIONavigation)
.ThenInclude(cpro => cpro.CODIGOPROVINCIANavigation) .ThenInclude(cpro => cpro.CODIGOPROVINCIANavigation)
.Include(pue => pue.PUESTOSIDPERSONALNavigation)
.ThenInclude(rpt => rpt.IDRPTDESNavigation)
.ThenInclude(x => x.IDDEPARTAMENTONavigation)
.AsNoTracking() .AsNoTracking()
.FirstOrDefault(p => p.NIF == nif); .FirstOrDefault(p => p.NIF == nif);
if (persona == null) if (persona == null)

View File

@@ -3,5 +3,7 @@ namespace SwaggerAntifraude.DTOs
public class CertificateProxyLoginDto public class CertificateProxyLoginDto
{ {
public string Dni { get; set; } = string.Empty; public string Dni { get; set; } = string.Empty;
public string? Origen { get; set; }
} }
} }

View File

@@ -41,8 +41,8 @@ namespace bdAntifraude.db
var aa = this; var aa = this;
var puesto = PUESTOSIDPERSONALNavigation?.FirstOrDefault(x => x.IDRPTNavigation?.IDSITUACIONNavigation?.DESCRIPCION == "ACTIVA"); var puesto = PUESTOSIDPERSONALNavigation?.FirstOrDefault(x => x.IDRPTNavigation?.IDSITUACIONNavigation?.DESCRIPCION == "ACTIVA");
return puesto?.IDRPTDESNavigation?.DESDEP ?? ""; return puesto?.IDRPTDESNavigation?.IDDEPARTAMENTONavigation?.VALORALFABETICOLARGO ?? "";
} }
} }