Compare commits

...

9 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
51 changed files with 3604 additions and 340 deletions

View File

@@ -13,6 +13,8 @@ namespace ApiDenuncias.Configuration
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? CircuitVersion { get; set; }
public string? DocumentMetadataLanguage { get; set; }

View File

@@ -304,7 +304,15 @@ public sealed class AuthController : ControllerBase
: session.Username.Trim();
_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);
var expiresAtUtc = DateTimeOffset.UtcNow.AddMinutes(Math.Max(5, _jwtOptions.ExpirationMinutes));

View File

@@ -12,10 +12,14 @@ namespace ApiDenuncias.Controllers;
public sealed class ConfigurationController : ControllerBase
{
private readonly AppConfigurationService _configurationService;
private readonly WorkGroupAdministrationService _workGroupService;
public ConfigurationController(AppConfigurationService configurationService)
public ConfigurationController(
AppConfigurationService configurationService,
WorkGroupAdministrationService workGroupService)
{
_configurationService = configurationService;
_workGroupService = workGroupService;
}
[HttpGet]
@@ -48,4 +52,46 @@ public sealed class ConfigurationController : ControllerBase
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,5 +1,6 @@
using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;
using ApiDenuncias.Services;
using GestionaDenuncias.Shared.Models;
using Microsoft.AspNetCore.Authorization;
@@ -44,13 +45,22 @@ public sealed class DenunciasController : ControllerBase
[FromQuery] DenunciaListScope scope,
CancellationToken cancellationToken)
{
var allowedIds = await GetAllowedIdsAsync(cancellationToken);
if (allowedIds.Count == 0)
await _denunciaStore.EnsureSchemaAsync(cancellationToken);
var access = await _accessService.GetComplaintAccessAsync(
GetUsername(),
null,
cancellationToken);
if (access.Count == 0)
{
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}")]
@@ -61,11 +71,23 @@ public sealed class DenunciasController : ControllerBase
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<GestionaComplaintFieldsResponse>> GetGestionaFields(
public async Task<ActionResult<GestionaExternalFieldsResponse>> GetGestionaFields(
int denunciaId,
CancellationToken cancellationToken)
{
@@ -80,7 +102,48 @@ public sealed class DenunciasController : ControllerBase
return NotFound(new ApiError("No se ha encontrado la denuncia solicitada."));
}
return Ok(ToGestionaComplaintFields(denuncia));
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]
@@ -232,20 +295,29 @@ public sealed class DenunciasController : ControllerBase
private string GetUsername()
=> 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 = ResolveReportField(
denuncia,
denuncia.Notificacion_Preferencia,
"preferencia de notificacion",
"seleccione su preferencia de notificacion y seguimiento de su denuncia",
"notificaciones");
var seguimiento = ResolveReportField(
denuncia,
denuncia.SeguimientoOnline,
"seguimiento online",
"seguimiento de su denuncia");
var preferenciaNotificacion = ResolveNotificationPreference(denuncia);
var seguimiento = ResolveSeguimientoDenuncia(denuncia);
var sms = ResolveSmsNotification(denuncia);
return new GestionaComplaintFieldsResponse(
FechaDenuncia: ToNullableDate(denuncia.Fecha),
@@ -255,29 +327,38 @@ public sealed class DenunciasController : ControllerBase
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: 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"),
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: 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"),
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)
@@ -319,6 +400,32 @@ public sealed class DenunciasController : ControllerBase
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(
@@ -332,16 +439,125 @@ public sealed class DenunciasController : ControllerBase
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,
denuncia.Modalidad_Informacion,
FirstNonEmpty(denuncia.Modalidad_Informacion, denuncia.Asunto),
"asunto",
"categoria",
"tipo de denuncia",
"ambito de competencias",
"ambito competencial",
"ambito",
@@ -388,7 +604,39 @@ public sealed class DenunciasController : ControllerBase
}
}
return string.Empty;
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(
@@ -407,6 +655,176 @@ public sealed class DenunciasController : ControllerBase
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>();
@@ -435,7 +853,7 @@ public sealed class DenunciasController : ControllerBase
return string.Empty;
}
//pruabass
private static string NormalizeLabel(string? value)
{
if (string.IsNullOrWhiteSpace(value))

View File

@@ -194,7 +194,8 @@ public sealed class GestionaController : ControllerBase
request.DocumentUrl,
request.AssignedGroupCode,
request.ComplaintId,
request.IsUpdate);
request.IsUpdate,
request.UpdateSource);
return Ok(new { ok = true });
}

View File

@@ -20,6 +20,7 @@ public sealed class InboxController : ControllerBase
private readonly GlobalLeaksSessionStore _sessionStore;
private readonly PendingGlobalLeaksLoginStore _pendingLoginStore;
private readonly GlobalLeaksClient _globalLeaksClient;
private readonly GlobalLeaksSessionKeepAliveService _sessionKeepAliveService;
private readonly DenunciaInboxService _inboxService;
private readonly IInboxTrackingService _trackingService;
private readonly ILogger<InboxController> _logger;
@@ -28,6 +29,7 @@ public sealed class InboxController : ControllerBase
GlobalLeaksSessionStore sessionStore,
PendingGlobalLeaksLoginStore pendingLoginStore,
GlobalLeaksClient globalLeaksClient,
GlobalLeaksSessionKeepAliveService sessionKeepAliveService,
DenunciaInboxService inboxService,
IInboxTrackingService trackingService,
ILogger<InboxController> logger)
@@ -35,6 +37,7 @@ public sealed class InboxController : ControllerBase
_sessionStore = sessionStore;
_pendingLoginStore = pendingLoginStore;
_globalLeaksClient = globalLeaksClient;
_sessionKeepAliveService = sessionKeepAliveService;
_inboxService = inboxService;
_trackingService = trackingService;
_logger = logger;
@@ -131,7 +134,14 @@ public sealed class InboxController : ControllerBase
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);
return Ok(ToDto(stored));
}
@@ -152,6 +162,45 @@ public sealed class InboxController : ControllerBase
}
}
[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."));
}
}
[HttpPost("session/clear")]
public async Task<IActionResult> ClearSession(CancellationToken cancellationToken)
{
@@ -173,13 +222,21 @@ public sealed class InboxController : ControllerBase
{
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);
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);
cancellationToken,
session.DpopPrivateKey);
return Ok(new InboxSnapshotResponse(contexts, activityReports, state));
}
@@ -214,13 +271,15 @@ public sealed class InboxController : ControllerBase
return Unauthorized(new ApiError("La sesion de GlobalLeaks ha caducado. Renueva el 2FA.", SessionExpired: true));
}
var report = string.IsNullOrWhiteSpace(request.Report.Id)
? request.Report with { Id = reportId }
: request.Report;
var report = request.Report with { Id = reportId };
try
{
await _trackingService.EnsureReportCanBeImportedByUserAsync(username, report, cancellationToken);
await _trackingService.EnsureReportCanBeImportedByUserAsync(
username,
report,
request.ConfirmDifferentOwner,
cancellationToken);
ReportDetailDto? reportDetail = null;
try
@@ -229,7 +288,8 @@ public sealed class InboxController : ControllerBase
session.SessionId!,
report.Id,
report.LastAccess,
cancellationToken);
cancellationToken,
session.DpopPrivateKey);
}
catch (GlobalLeaksValidationException ex) when (ex.StatusCode == StatusCodes.Status422UnprocessableEntity ||
ex.StatusCode is >= 500 and <= 504)
@@ -240,19 +300,28 @@ public sealed class InboxController : ControllerBase
report.Id);
}
var reportPackage = await DownloadReportPackageWithRetryAsync(session.SessionId!, report, cancellationToken);
var reportPackage = await DownloadReportPackageWithRetryAsync(session, report, cancellationToken);
FileDownloadResult? json = null;
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)
{
json = null;
}
var result = await _inboxService.ImportFromGlobalLeaksAsync(reportPackage, json, reportDetail, cancellationToken);
var result = await _inboxService.ImportFromGlobalLeaksAsync(
reportPackage,
json,
reportDetail,
report,
cancellationToken);
if (result.ImportedCount > 0)
{
await _trackingService.MarkReportImportedAsync(
@@ -273,6 +342,10 @@ public sealed class InboxController : ControllerBase
{
return ToGlobalLeaksApiError(ex, "importar la denuncia");
}
catch (ReportOwnershipException ex)
{
return Conflict(new ApiError(ex.Message));
}
catch (Exception ex)
{
_logger.LogError(ex, "No se ha podido importar la denuncia {ReportId} para {Username}.", reportId, username);
@@ -297,7 +370,12 @@ public sealed class InboxController : ControllerBase
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)
{
@@ -318,7 +396,7 @@ public sealed class InboxController : ControllerBase
}
private async Task<FileDownloadResult> DownloadReportPackageWithRetryAsync(
string sessionId,
GlobalLeaksStoredSession session,
ReportDto report,
CancellationToken cancellationToken)
{
@@ -326,7 +404,11 @@ public sealed class InboxController : ControllerBase
{
try
{
return await _globalLeaksClient.DownloadReportPackageAsync(sessionId, report.Id, cancellationToken);
return await _globalLeaksClient.DownloadReportPackageAsync(
session.SessionId!,
report.Id,
cancellationToken,
session.DpopPrivateKey);
}
catch (GlobalLeaksValidationException ex) when (IsExportNotReady(ex) && attempt < ExportRetryDelays.Length)
{
@@ -353,13 +435,51 @@ public sealed class InboxController : ControllerBase
private static bool IsExportNotReady(GlobalLeaksValidationException ex)
=> ex.StatusCode is >= 500 and <= 504;
private static ActionResult ToGlobalLeaksApiError(GlobalLeaksValidationException ex, string operation)
private ActionResult ToGlobalLeaksApiError(GlobalLeaksValidationException ex, string operation)
{
if (ex.StatusCode == StatusCodes.Status401Unauthorized ||
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(
"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."))
permissionMessage))
{
StatusCode = StatusCodes.Status403Forbidden
};
@@ -380,6 +500,22 @@ public sealed class InboxController : ControllerBase
};
}
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)
{
var session = await _sessionStore.GetAsync(username, cancellationToken);

View File

@@ -52,8 +52,19 @@ public sealed class TrackingController : ControllerBase
TrackingImportPermissionRequest request,
CancellationToken cancellationToken)
{
await _trackingService.EnsureReportCanBeImportedByUserAsync(GetUsername(), request.Report, cancellationToken);
return Ok(new { ok = true });
try
{
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()

View File

@@ -121,12 +121,23 @@ public static class GlobalLeaksJsonEnricher
}
}
if (element.TryGetProperty("children", out var children) &&
children.ValueKind == JsonValueKind.Array)
foreach (var property in element.EnumerateObject())
{
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)
{
if (answerArray.ValueKind == JsonValueKind.Object)
{
return ResolveAnswerObject(answerArray, definition);
}
if (answerArray.ValueKind != JsonValueKind.Array)
{
return string.Empty;
return ResolveValue(answerArray, definition.Options);
}
var values = new List<string>();
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;
}
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);
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);
}
values.Add(label);
}
}
@@ -213,10 +239,95 @@ public static class GlobalLeaksJsonEnricher
JsonValueKind.True => "Sí",
JsonValueKind.False => "No",
JsonValueKind.Number => valueElement.GetRawText(),
JsonValueKind.Array => ResolveArrayValue(valueElement, options),
JsonValueKind.Object => ResolveObjectValue(valueElement, options),
_ => 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)
{
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.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.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.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.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.Correo_Electronico, value => denuncia.Correo_Electronico = value, answers, "correo electronico", "email");
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");
if (denuncia.Fecha_Hechos == DateTime.MinValue &&
TryGetAnswer(answers, out var fechaHechos, "fecha de los hechos que denuncia") &&
DateTime.TryParse(fechaHechos, CultureInfo.CurrentCulture, DateTimeStyles.None, out var parsedDate))
TryGetAnswer(answers, out var fechaHechos, "fecha de los hechos que denuncia", "fecha de los hechos") &&
TryParseReportDate(fechaHechos, out var parsedDate))
{
denuncia.Fecha_Hechos = parsedDate;
}
@@ -371,6 +483,38 @@ public static class GlobalLeaksJsonEnricher
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)
{
if (string.IsNullOrWhiteSpace(text))

View File

@@ -32,6 +32,7 @@ builder.Services.AddSingleton<LoginRateLimiter>();
builder.Services.AddSingleton<GlobalLeaksSessionStore>();
builder.Services.AddSingleton<PendingGlobalLeaksLoginStore>();
builder.Services.AddScoped<GlobalLeaksClient>();
builder.Services.AddScoped<GlobalLeaksSessionKeepAliveService>();
builder.Services.AddSingleton<MySqlConnectionStringProvider>();
builder.Services.AddScoped<MySqlDenunciaStore>();
builder.Services.AddSingleton<IEncryptionKeyProvider, KeyVaultEncryptionKeyProvider>();
@@ -44,6 +45,7 @@ builder.Services.AddScoped<DenunciaInboxService>();
builder.Services.AddScoped<GestionaDocumentWorkflowService>();
builder.Services.AddScoped<GestionaExpedienteExceptionStore>();
builder.Services.AddScoped<UserComplaintAccessService>();
builder.Services.AddScoped<WorkGroupAdministrationService>();
builder.Services.AddHttpClient<ManualPurgeService>();
builder.Services.AddScoped<AppConfigurationService>();

View File

@@ -66,6 +66,7 @@ CREATE TABLE IF NOT EXISTS complaints (
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_rejected TINYINT(1) NOT NULL DEFAULT 0,
created_at_utc DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
@@ -87,6 +88,58 @@ CREATE TABLE IF NOT EXISTS app_users (
UNIQUE KEY uq_app_users_username (username)
) 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 (
id BIGINT NOT NULL AUTO_INCREMENT,
global_report_uuid CHAR(36) NOT NULL,
@@ -104,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_downloaded_at_utc DATETIME(6) NULL,
last_downloaded_by_user_id BIGINT NULL,
owner_user_id BIGINT NULL,
imported_complaint_report_id INT NULL,
imported_to_store_at_utc DATETIME(6) NULL,
created_at_utc DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
@@ -112,8 +166,12 @@ CREATE TABLE IF NOT EXISTS inbox_reports (
UNIQUE KEY uq_inbox_reports_uuid (global_report_uuid),
KEY ix_inbox_reports_progressive (progressive_id),
KEY ix_inbox_reports_downloaded (last_downloaded_at_utc),
KEY ix_inbox_reports_owner (owner_user_id),
CONSTRAINT fk_inbox_reports_last_user
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
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

View File

@@ -84,6 +84,7 @@ public sealed class DenunciaInboxService
FileDownloadResult reportDownload,
FileDownloadResult? jsonDownload,
ReportDetailDto? reportDetail,
ReportDto inboxReport,
CancellationToken cancellationToken = default)
{
await EnsureStorageReadyAsync(cancellationToken);
@@ -102,7 +103,13 @@ public sealed class DenunciaInboxService
try
{
var result = await ProcessGlobalLeaksPackageAsync(reportDownload.Content, sourceName, json, reportDetail, cancellationToken);
var result = await ProcessGlobalLeaksPackageAsync(
reportDownload.Content,
sourceName,
json,
reportDetail,
inboxReport,
cancellationToken);
return new ImportSummary(
1,
result.ImportedCount,
@@ -164,6 +171,7 @@ public sealed class DenunciaInboxService
string sourceName,
string? globalLeaksJson,
ReportDetailDto? reportDetail,
ReportDto inboxReport,
CancellationToken cancellationToken)
{
using var packageStream = new MemoryStream(packageBytes, writable: false);
@@ -214,6 +222,12 @@ public sealed class DenunciaInboxService
$"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)
{
reportText = BuildSyntheticReportText(denuncia);
@@ -246,14 +260,17 @@ public sealed class DenunciaInboxService
var storedComplaint = await _denunciaStore.GetDenunciaByIdAsync(denuncia.Id_Denuncia, cancellationToken);
if (IsAlreadyUploadedToGestiona(storedComplaint))
{
var storedFiles = await GetFicherosMetadataByDenunciaForImportAsync(denuncia.Id_Denuncia, cancellationToken);
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($"La denuncia #{denuncia.Id_Denuncia} ya esta en Gestiona y no tiene documentos nuevos pendientes de subir.");
warnings.Add(BuildNoCitizenUpdateWarning(inboxReport));
return new ProcessPackageResult(denuncia.Id_Denuncia, 0, warnings);
}
}
@@ -379,6 +396,13 @@ public sealed class DenunciaInboxService
}
}
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
@@ -591,6 +615,7 @@ public sealed class DenunciaInboxService
target.Pais = source.Pais;
target.CamposFormularioJson = source.CamposFormularioJson;
target.TextoOriginalReport = source.TextoOriginalReport;
target.PendingUpdateSource = source.PendingUpdateSource;
}
private static bool IsSupportedAttachmentEntry(ZipArchiveEntry entry)

View File

@@ -198,6 +198,14 @@ public sealed class EncryptedDenunciaStore : IDenunciaStore, IFilteredDenunciaSt
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)
{
var key = await _envelopeKeyProvider.GetCurrentKeyAsync(cancellationToken);
@@ -272,6 +280,7 @@ public sealed class EncryptedDenunciaStore : IDenunciaStore, IFilteredDenunciaSt
FechaSubidaAGestiona = source.FechaSubidaAGestiona,
UltimaSubidaGestionaTipo = source.UltimaSubidaGestionaTipo,
UltimoGrupoAsignadoGestiona = source.UltimoGrupoAsignadoGestiona,
PendingUpdateSource = source.PendingUpdateSource,
EnGestiona = source.EnGestiona,
EnRechazada = source.EnRechazada,
@@ -353,6 +362,7 @@ public sealed class EncryptedDenunciaStore : IDenunciaStore, IFilteredDenunciaSt
target.FechaSubidaAGestiona = stored.FechaSubidaAGestiona;
target.UltimaSubidaGestionaTipo = stored.UltimaSubidaGestionaTipo;
target.UltimoGrupoAsignadoGestiona = stored.UltimoGrupoAsignadoGestiona;
target.PendingUpdateSource = stored.PendingUpdateSource;
target.EnGestiona = stored.EnGestiona;
target.EnRechazada = stored.EnRechazada;
}

View File

@@ -3,6 +3,7 @@ using System.Security.Cryptography;
using System.Globalization;
using System.Text;
using System.Text.Json;
using GestionaDenuncias.Shared.Models;
using Microsoft.Extensions.Logging;
namespace ApiDenuncias.Services;
@@ -124,11 +125,20 @@ public sealed class GestionaDocumentWorkflowService
string documentUrl,
string assignedGroupCode,
int? complaintId = null,
bool isUpdate = false)
bool isUpdate = false,
string? updateSource = null)
{
var docUrlAbs = EnsureAbsoluteGestionaUrl(documentUrl, GestionaApiBase);
var operationLabel = isUpdate ? $"actualizacion grupo {NormalizeGroupCode(assignedGroupCode)}" : "nueva denuncia";
var (templateHref, payload) = await ResolveCircuitTemplatePayloadAsync(docUrlAbs, isUpdate, assignedGroupCode);
var operationLabel = ComplaintUpdateSources.IsReceiver(updateSource)
? $"comunicacion OAAF grupo {NormalizeGroupCode(assignedGroupCode)}"
: isUpdate
? $"actualizacion grupo {NormalizeGroupCode(assignedGroupCode)}"
: "nueva denuncia";
var (templateHref, payload) = await ResolveCircuitTemplatePayloadAsync(
docUrlAbs,
isUpdate,
assignedGroupCode,
updateSource);
var (success, statusCode, body) = await TryPostCircuitAsync(docUrlAbs, payload);
if (success)
{
@@ -155,7 +165,8 @@ public sealed class GestionaDocumentWorkflowService
private async Task<(string TemplateHref, string Payload)> ResolveCircuitTemplatePayloadAsync(
string documentUrl,
bool isUpdate,
string? assignedGroupCode)
string? assignedGroupCode,
string? updateSource)
{
var templatesUrl = $"{documentUrl.TrimEnd('/')}/circuit/templates";
var templates = await GetCircuitTemplatesAsync(templatesUrl);
@@ -164,7 +175,7 @@ public sealed class GestionaDocumentWorkflowService
throw new InvalidOperationException("Gestiona no ha devuelto plantillas de circuito para el documento.");
}
var selection = GetOperationCircuitTemplateSelection(isUpdate, assignedGroupCode);
var selection = GetOperationCircuitTemplateSelection(isUpdate, assignedGroupCode, updateSource);
if (!string.IsNullOrWhiteSpace(selection.TemplateName))
{
var resolved = await TryResolveCircuitTemplatePayloadByNameAsync(templates, selection.TemplateName);
@@ -211,7 +222,10 @@ public sealed class GestionaDocumentWorkflowService
"No se puede seleccionar de forma inequivoca la plantilla de circuito. Configura Gestiona:CircuitTemplateName o Gestiona:CircuitSignerStampTitle.");
}
private CircuitTemplateSelection GetOperationCircuitTemplateSelection(bool isUpdate, string? assignedGroupCode)
private CircuitTemplateSelection GetOperationCircuitTemplateSelection(
bool isUpdate,
string? assignedGroupCode,
string? updateSource)
{
if (!isUpdate)
{
@@ -221,6 +235,25 @@ public sealed class GestionaDocumentWorkflowService
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"]);

View File

@@ -648,9 +648,20 @@ namespace ApiDenuncias.Services
"application/vnd.gestiona.filter.thirds+json");
using var resp = await _http.SendAsync(req);
resp.EnsureSuccessStatusCode();
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);
if (!doc.RootElement.TryGetProperty("content", out var content) || content.ValueKind != JsonValueKind.Array)
return default;
@@ -768,8 +779,13 @@ namespace ApiDenuncias.Services
if (resp.StatusCode == System.Net.HttpStatusCode.NoContent)
return new HashSet<string>(StringComparer.Ordinal);
resp.EnsureSuccessStatusCode();
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);
var set = new HashSet<string>(StringComparer.Ordinal);

View File

@@ -16,6 +16,10 @@ namespace ApiDenuncias.Services;
public sealed record PreparedGlobalLeaksCredentials(string Username, string FinalPassword, string TokenAnswer);
public sealed record RefreshedGlobalLeaksSession(
GlobalLeaksProofOfWorkToken ProofOfWorkToken,
DateTimeOffset? SessionExpiresAtUtc);
public sealed class GlobalLeaksClient
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
@@ -152,7 +156,7 @@ public sealed class GlobalLeaksClient
if (authResponse.IsSuccessStatusCode)
{
var authBody = await authResponse.Content.ReadAsStringAsync(cancellationToken);
var session = ParseAuthSession(authBody, username);
var session = ParseAuthSession(authBody, username, ExportDpopPrivateKey(dpopKey));
_logger.LogInformation(
"Login GlobalLeaks correcto para {Username}. Rol: {Role}. Tiempo total={ElapsedMs} ms",
session.Username,
@@ -197,6 +201,30 @@ public sealed class GlobalLeaksClient
throw new GlobalLeaksValidationException("Login fallido: no se pudo completar la autenticacion.", 502);
}
public async Task<RefreshedGlobalLeaksSession> RefreshSessionAsync(
string sessionId,
string dpopPrivateKey,
GlobalLeaksProofOfWorkToken proofOfWorkToken,
CancellationToken cancellationToken = default)
{
const string path = "/api/auth/session";
var tokenAnswer = SolveProofOfWork(
proofOfWorkToken.Id,
proofOfWorkToken.Salt,
cancellationToken);
using var request = CreateAuthenticatedRequest(
HttpMethod.Post,
path,
sessionId,
dpopPrivateKey);
request.Content = CreateJsonContent(new { token = tokenAnswer });
using var response = await SendGlRequestAsync(request, cancellationToken);
var body = await response.Content.ReadAsStringAsync(cancellationToken);
return ParseSessionRefresh(body);
}
private async Task<string> PrepareProofOfWorkAsync(string username, CancellationToken cancellationToken)
{
using var tokenRequest = CreateRequest(HttpMethod.Post, "/api/auth/token");
@@ -233,12 +261,17 @@ public sealed class GlobalLeaksClient
string? dateFrom,
string? dateTo,
CancellationToken cancellationToken,
IReadOnlyList<ContextDto>? contexts = null)
IReadOnlyList<ContextDto>? contexts = null,
string? dpopPrivateKey = null)
{
filter ??= "all";
using var reportsRequest = CreateAuthenticatedRequest(HttpMethod.Get, "/api/recipient/rtips", sessionId);
using var reportsResponse = await SendGlRequestAsync(reportsRequest, cancellationToken);
using var reportsResponse = await SendAuthenticatedGlRequestAsync(
HttpMethod.Get,
"/api/recipient/rtips",
sessionId,
dpopPrivateKey,
cancellationToken);
var body = await reportsResponse.Content.ReadAsStringAsync(cancellationToken);
var tips = ParseReports(body);
_logger.LogInformation("GlobalLeaks /api/recipient/rtips devolvió {Count} denuncias", tips.Count);
@@ -312,7 +345,8 @@ public sealed class GlobalLeaksClient
string sessionId,
IReadOnlyList<ReportDto> reports,
DateTimeOffset? fallbackReference,
CancellationToken cancellationToken)
CancellationToken cancellationToken,
string? dpopPrivateKey = null)
{
if (reports.Count == 0)
{
@@ -337,7 +371,7 @@ public sealed class GlobalLeaksClient
try
{
var reference = GetActivityReference(item.Report, fallbackReference);
var activity = await AnalyzeReportActivityAsync(sessionId, item.Report, reference, cancellationToken);
var activity = await AnalyzeReportActivityAsync(sessionId, item.Report, reference, dpopPrivateKey, cancellationToken);
enriched[item.Index] = ApplyActivity(item.Report, activity);
}
catch (Exception ex) when (ex is GlobalLeaksValidationException or HttpRequestException or TaskCanceledException)
@@ -362,16 +396,17 @@ public sealed class GlobalLeaksClient
string sessionId,
string reportId,
string? lastAccess,
CancellationToken cancellationToken)
CancellationToken cancellationToken,
string? dpopPrivateKey = null)
{
ValidateUuid(reportId);
var reference = ParseDate(lastAccess);
using var document = await ReadReportDetailDocumentAsync(sessionId, reportId, cancellationToken);
using var document = await ReadReportDetailDocumentAsync(sessionId, reportId, dpopPrivateKey, cancellationToken);
IReadOnlyList<ReportCommentDto>? comments = null;
try
{
comments = await GetReportCommentsAsync(sessionId, reportId, reference, defaultNewWhenNoReference: true, cancellationToken);
comments = await GetReportCommentsAsync(sessionId, reportId, reference, defaultNewWhenNoReference: true, dpopPrivateKey, cancellationToken);
}
catch (Exception ex) when (ex is GlobalLeaksValidationException or HttpRequestException or TaskCanceledException)
{
@@ -387,17 +422,16 @@ public sealed class GlobalLeaksClient
public async Task<FileDownloadResult> DownloadReportPackageAsync(
string sessionId,
string reportId,
CancellationToken cancellationToken)
CancellationToken cancellationToken,
string? dpopPrivateKey = null)
{
ValidateUuid(reportId);
using var request = CreateAuthenticatedRequest(
using var response = await SendAuthenticatedGlRequestAsync(
HttpMethod.Get,
$"/api/recipient/rtips/{reportId}/export",
sessionId);
using var response = await SendGlRequestAsync(
request,
sessionId,
dpopPrivateKey,
cancellationToken,
HttpCompletionOption.ResponseHeadersRead);
@@ -417,12 +451,17 @@ public sealed class GlobalLeaksClient
public async Task<FileDownloadResult> ExportReportJsonAsync(
string sessionId,
string reportId,
CancellationToken cancellationToken)
CancellationToken cancellationToken,
string? dpopPrivateKey = null)
{
ValidateUuid(reportId);
using var request = CreateAuthenticatedRequest(HttpMethod.Get, $"/api/recipient/rtips/{reportId}", sessionId);
using var response = await SendGlRequestAsync(request, cancellationToken);
using var response = await SendAuthenticatedGlRequestAsync(
HttpMethod.Get,
$"/api/recipient/rtips/{reportId}",
sessionId,
dpopPrivateKey,
cancellationToken);
var contentType = response.Content.Headers.ContentType?.MediaType ?? string.Empty;
var content = await response.Content.ReadAsByteArrayAsync(cancellationToken);
@@ -444,10 +483,15 @@ public sealed class GlobalLeaksClient
private async Task<JsonDocument> ReadReportDetailDocumentAsync(
string sessionId,
string reportId,
string? dpopPrivateKey,
CancellationToken cancellationToken)
{
using var detailRequest = CreateAuthenticatedRequest(HttpMethod.Get, $"/api/recipient/rtips/{reportId}", sessionId);
using var detailResponse = await SendGlRequestAsync(detailRequest, cancellationToken);
using var detailResponse = await SendAuthenticatedGlRequestAsync(
HttpMethod.Get,
$"/api/recipient/rtips/{reportId}",
sessionId,
dpopPrivateKey,
cancellationToken);
var contentType = detailResponse.Content.Headers.ContentType?.MediaType ?? string.Empty;
var content = await detailResponse.Content.ReadAsByteArrayAsync(cancellationToken);
@@ -466,10 +510,15 @@ public sealed class GlobalLeaksClient
string reportId,
DateTimeOffset? reference,
bool defaultNewWhenNoReference,
string? dpopPrivateKey,
CancellationToken cancellationToken)
{
using var request = CreateAuthenticatedRequest(HttpMethod.Get, $"/api/recipient/rtips/{reportId}/comments", sessionId);
using var response = await SendGlRequestAsync(request, cancellationToken);
using var response = await SendAuthenticatedGlRequestAsync(
HttpMethod.Get,
$"/api/recipient/rtips/{reportId}/comments",
sessionId,
dpopPrivateKey,
cancellationToken);
var contentType = response.Content.Headers.ContentType?.MediaType ?? string.Empty;
var content = await response.Content.ReadAsByteArrayAsync(cancellationToken);
@@ -499,10 +548,11 @@ public sealed class GlobalLeaksClient
string sessionId,
ReportDto report,
DateTimeOffset? reference,
string? dpopPrivateKey,
CancellationToken cancellationToken)
{
var reportId = report.Id;
using var document = await ReadReportDetailDocumentAsync(sessionId, reportId, cancellationToken);
using var document = await ReadReportDetailDocumentAsync(sessionId, reportId, dpopPrivateKey, cancellationToken);
IReadOnlyList<ReportCommentDto> comments;
try
{
@@ -511,6 +561,7 @@ public sealed class GlobalLeaksClient
reportId,
reference,
defaultNewWhenNoReference: false,
dpopPrivateKey,
cancellationToken);
}
catch (Exception ex) when (ex is GlobalLeaksValidationException or HttpRequestException or TaskCanceledException)
@@ -535,6 +586,14 @@ public sealed class GlobalLeaksClient
reference,
defaultNewWhenNoReference: false,
"rfiles");
var receivers = ParseReportReceivers(document.RootElement);
var receiverNames = BuildReceiverNameLookup(receivers);
comments = comments
.Select(comment => WithAuthorName(comment, receiverNames))
.ToArray();
receiverFiles = receiverFiles
.Select(file => WithAuthorName(file, receiverNames))
.ToArray();
var citizenCommentDates = comments
.Where(comment => IsWhistleblowerActivityType(comment.Type))
@@ -567,7 +626,24 @@ public sealed class GlobalLeaksClient
: citizenCommentDates.Concat(citizenFileDates).Append(reportCreationDate.Value);
var citizenLast = MaxDate(citizenActivityDates);
var receiverLast = MaxDate(receiverCommentDates.Concat(receiverFileDates).Concat(unclassifiedCommentDates));
var receiverEvents = comments
.Where(comment =>
IsReceiverActivityType(comment.Type) ||
IsUnclassifiedActivityType(comment.Type))
.Select(comment => new ActivityActorEvent(
ParseDate(comment.CreationDate),
comment.AuthorId,
comment.AuthorName))
.Concat(receiverFiles.Select(file => new ActivityActorEvent(
ParseDate(file.CreationDate),
file.AuthorId,
file.AuthorName)))
.Where(item => item.Date is not null)
.OrderByDescending(item => item.Date)
.ToArray();
var latestReceiverEvent = receiverEvents.FirstOrDefault();
var receiverLast = latestReceiverEvent?.Date ??
MaxDate(receiverCommentDates.Concat(receiverFileDates).Concat(unclassifiedCommentDates));
return new ReportActivitySnapshot(
citizenLast,
@@ -582,7 +658,9 @@ public sealed class GlobalLeaksClient
comments.Any(comment =>
IsUnclassifiedActivityType(comment.Type) &&
comment.IsNew) ||
receiverFiles.Any(file => file.IsNew));
receiverFiles.Any(file => file.IsNew),
latestReceiverEvent?.AuthorId,
latestReceiverEvent?.AuthorName);
}
private static ReportDto ApplyActivity(ReportDto report, ReportActivitySnapshot activity)
@@ -595,7 +673,9 @@ public sealed class GlobalLeaksClient
CitizenHasNewComment = activity.HasNewCitizenComment,
CitizenHasNewFile = activity.HasNewCitizenFile,
ReceiverLastActivity = activity.ReceiverLastActivity?.ToString("O", CultureInfo.InvariantCulture),
ReceiverHasNewActivity = activity.HasNewReceiverActivity
ReceiverHasNewActivity = activity.HasNewReceiverActivity,
ReceiverLastActivityAuthorId = activity.ReceiverLastActivityAuthorId,
ReceiverLastActivityAuthorName = activity.ReceiverLastActivityAuthorName
};
}
@@ -644,13 +724,26 @@ public sealed class GlobalLeaksClient
var response = await _httpClient.SendAsync(request, completionOption, cancellationToken);
if ((int)response.StatusCode == 412)
{
_logger.LogWarning(
"GlobalLeaks ha indicado sesion expirada (412) en {Method} {Path}.",
request.Method.Method,
request.RequestUri?.OriginalString);
response.Dispose();
throw new GlobalLeaksSessionExpiredException();
}
if (!response.IsSuccessStatusCode)
{
var message = $"Error de GlobalLeaks (código {(int)response.StatusCode}).";
var body = await ReadBodySafeAsync(response, cancellationToken);
_logger.LogWarning(
"GlobalLeaks devolvio {StatusCode} en {Method} {Path}. Body={Body}",
(int)response.StatusCode,
request.Method.Method,
request.RequestUri?.OriginalString,
string.IsNullOrWhiteSpace(body) ? "(sin cuerpo)" : body);
var message = string.IsNullOrWhiteSpace(body)
? $"Error de GlobalLeaks (código {(int)response.StatusCode})."
: $"Error de GlobalLeaks (código {(int)response.StatusCode}): {body}";
response.Dispose();
throw new GlobalLeaksValidationException(message, (int)response.StatusCode);
}
@@ -658,6 +751,24 @@ public sealed class GlobalLeaksClient
return response;
}
private async Task<HttpResponseMessage> SendAuthenticatedGlRequestAsync(
HttpMethod method,
string path,
string sessionId,
string? dpopPrivateKey,
CancellationToken cancellationToken,
HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead)
{
if (string.IsNullOrWhiteSpace(dpopPrivateKey))
{
using var requestWithoutDpop = CreateAuthenticatedRequest(method, path, sessionId, dpopPrivateKey: null);
return await SendGlRequestAsync(requestWithoutDpop, cancellationToken, completionOption);
}
using var requestWithDpop = CreateAuthenticatedRequest(method, path, sessionId, dpopPrivateKey);
return await SendGlRequestAsync(requestWithDpop, cancellationToken, completionOption);
}
private static HttpRequestMessage CreateRequest(HttpMethod method, string path)
{
var request = new HttpRequestMessage(method, path)
@@ -671,10 +782,26 @@ public sealed class GlobalLeaksClient
return request;
}
private static HttpRequestMessage CreateAuthenticatedRequest(HttpMethod method, string path, string sessionId)
private static HttpRequestMessage CreateAuthenticatedRequest(
HttpMethod method,
string path,
string sessionId,
string? dpopPrivateKey)
{
var request = CreateRequest(method, path);
request.Headers.Add("X-Session", sessionId);
if (!string.IsNullOrWhiteSpace(dpopPrivateKey))
{
using var key = ImportDpopPrivateKey(dpopPrivateKey);
request.Headers.TryAddWithoutValidation(
"DPoP",
CreateDpopProof(
key,
method.Method.ToUpperInvariant(),
GetDpopPath(path),
ComputeAth(sessionId)));
}
return request;
}
@@ -717,7 +844,7 @@ public sealed class GlobalLeaksClient
return Convert.ToBase64String(hash);
}
private static string CreateDpopProof(ECDsa privateKey, string method, string path)
private static string CreateDpopProof(ECDsa privateKey, string method, string path, string? ath = null)
{
var publicParameters = privateKey.ExportParameters(includePrivateParameters: false);
var jwk = new Dictionary<string, object?>
@@ -742,6 +869,10 @@ public sealed class GlobalLeaksClient
["htu"] = path,
["iat"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
};
if (!string.IsNullOrWhiteSpace(ath))
{
payload["ath"] = ath;
}
var encodedHeader = Base64UrlEncode(JsonSerializer.SerializeToUtf8Bytes(header, JsonOptions));
var encodedPayload = Base64UrlEncode(JsonSerializer.SerializeToUtf8Bytes(payload, JsonOptions));
@@ -754,6 +885,25 @@ public sealed class GlobalLeaksClient
return $"{signingInput}.{Base64UrlEncode(signature)}";
}
private static string ExportDpopPrivateKey(ECDsa key)
=> Convert.ToBase64String(key.ExportECPrivateKey());
private static ECDsa ImportDpopPrivateKey(string privateKey)
{
var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
key.ImportECPrivateKey(Convert.FromBase64String(privateKey), out _);
return key;
}
private static string ComputeAth(string sessionId)
=> Base64UrlEncode(SHA256.HashData(Encoding.UTF8.GetBytes(sessionId)));
private static string GetDpopPath(string path)
{
var queryIndex = path.IndexOf('?', StringComparison.Ordinal);
return queryIndex < 0 ? path : path[..queryIndex];
}
private static string Base64UrlEncode(byte[] bytes)
=> Convert.ToBase64String(bytes)
.TrimEnd('=')
@@ -800,6 +950,15 @@ public sealed class GlobalLeaksClient
private static DateTimeOffset? GetActivityReference(ReportDto report, DateTimeOffset? fallbackReference)
{
if (report.AlreadyInGestiona)
{
var lastGestionaUpload = ParseDate(report.LastGestionaUploadAt);
if (lastGestionaUpload is not null)
{
return lastGestionaUpload;
}
}
return ParseDate(report.LastDownloadedAt) ??
fallbackReference ??
ParseDate(report.LastAccess) ??
@@ -950,10 +1109,32 @@ public sealed class GlobalLeaksClient
.Select(item => CreateReportComment(item, lastAccessDate, defaultNewWhenNoReference: true))
.ToArray();
var whistleblowerFiles = ParseReportFiles(root, lastAccessDate, defaultNewWhenNoReference: true, "wbfiles", "files");
var receiverFiles = ParseReportFiles(root, lastAccessDate, defaultNewWhenNoReference: true, "rfiles");
var receivers = ParseReportReceivers(root);
var receiverNames = BuildReceiverNameLookup(receivers);
comments = comments
.Select(comment => WithAuthorName(comment, receiverNames))
.ToArray();
var whistleblowerFiles = ParseReportFiles(
root,
lastAccessDate,
defaultNewWhenNoReference: true,
"wbfiles",
"files");
var receiverFiles = ParseReportFiles(
root,
lastAccessDate,
defaultNewWhenNoReference: true,
"rfiles")
.Select(file => WithAuthorName(file, receiverNames))
.ToArray();
return new ReportDetailDto(reportId, lastAccess, comments, whistleblowerFiles, receiverFiles);
return new ReportDetailDto(
reportId,
lastAccess,
comments,
whistleblowerFiles,
receiverFiles,
receivers);
}
private static ReportCommentDto CreateReportComment(
@@ -962,12 +1143,25 @@ public sealed class GlobalLeaksClient
bool defaultNewWhenNoReference)
{
var creationDate = GetString(item, "creation_date", "creationDate", "date", "created_at", "createdAt");
var authorId = GetString(item, "author_id", "authorId");
var activityType = GetCommentActivityType(item);
if (!string.IsNullOrWhiteSpace(authorId))
{
activityType = "receiver";
}
else if (string.IsNullOrWhiteSpace(activityType) ||
IsUnclassifiedActivityType(activityType))
{
activityType = "whistleblower";
}
return new ReportCommentDto(
GetString(item, "id"),
GetCommentActivityType(item),
activityType,
GetString(item, "content", "text", "message"),
creationDate,
IsAfterReference(creationDate, reference, defaultNewWhenNoReference));
IsAfterReference(creationDate, reference, defaultNewWhenNoReference),
authorId);
}
private static string? GetCommentActivityType(JsonElement item)
@@ -1103,13 +1297,62 @@ public sealed class GlobalLeaksClient
GetString(item, "id"),
GetLocalizedString(item, "name", "file_name", "filename"),
GetInt64(item, "size"),
GetString(item, "content_type", "contentType", "mime_type", "mimetype"),
GetString(item, "content_type", "contentType", "mime_type", "mimetype", "type"),
creationDate,
IsAfterReference(creationDate, reference, defaultNewWhenNoReference));
IsAfterReference(creationDate, reference, defaultNewWhenNoReference),
GetString(item, "author_id", "authorId"));
})
.ToArray();
}
private static ReportReceiverDto[] ParseReportReceivers(JsonElement root)
{
return EnumerateArray(root, "receivers", "recipients")
.Select(item => new ReportReceiverDto(
GetString(item, "id") ?? string.Empty,
GetLocalizedString(item, "name", "display_name", "displayName", "username") ?? "Gestor",
GetBool(item, "active", "is_active", "isActive")))
.Where(receiver => !string.IsNullOrWhiteSpace(receiver.Id))
.ToArray();
}
private static IReadOnlyDictionary<string, string> BuildReceiverNameLookup(
IEnumerable<ReportReceiverDto> receivers)
{
return receivers
.GroupBy(receiver => receiver.Id, StringComparer.OrdinalIgnoreCase)
.ToDictionary(
group => group.Key,
group => group.First().Name,
StringComparer.OrdinalIgnoreCase);
}
private static ReportCommentDto WithAuthorName(
ReportCommentDto comment,
IReadOnlyDictionary<string, string> receiverNames)
{
if (string.IsNullOrWhiteSpace(comment.AuthorId) ||
!receiverNames.TryGetValue(comment.AuthorId, out var authorName))
{
return comment;
}
return comment with { AuthorName = authorName };
}
private static ReportFileDto WithAuthorName(
ReportFileDto file,
IReadOnlyDictionary<string, string> receiverNames)
{
if (string.IsNullOrWhiteSpace(file.AuthorId) ||
!receiverNames.TryGetValue(file.AuthorId, out var authorName))
{
return file;
}
return file with { AuthorName = authorName };
}
private static bool IsAfterReference(
string? value,
DateTimeOffset? reference,
@@ -1309,12 +1552,12 @@ public sealed class GlobalLeaksClient
private static string? ExtractFileName(ContentDispositionHeaderValue? contentDisposition)
=> contentDisposition?.FileNameStar ?? contentDisposition?.FileName?.Trim('"');
private static GlSession ParseAuthSession(string body, string fallbackUsername)
private static GlSession ParseAuthSession(string body, string fallbackUsername, string? dpopPrivateKey)
{
using var document = JsonDocument.Parse(body);
var root = document.RootElement;
var id = GetString(root, "id")
var id = GetString(root, "session_id", "sessionId", "session", "sid", "id")
?? throw new GlobalLeaksValidationException("GlobalLeaks no devolvió una sesión válida.", 502);
var username = GetString(root, "username", "name");
if (string.IsNullOrWhiteSpace(username))
@@ -1323,8 +1566,84 @@ public sealed class GlobalLeaksClient
}
var role = GetString(root, "role", "user_role", "userRole");
var proofOfWorkToken = ParseProofOfWorkToken(root);
var sessionExpiresAtUtc = ParseSessionExpiration(root);
return new GlSession(id, username, role);
return new GlSession(
id,
username,
role,
dpopPrivateKey,
proofOfWorkToken,
sessionExpiresAtUtc);
}
private static RefreshedGlobalLeaksSession ParseSessionRefresh(string body)
{
using var document = JsonDocument.Parse(body);
var root = document.RootElement;
var proofOfWorkToken = ParseProofOfWorkToken(root)
?? throw new GlobalLeaksValidationException(
"GlobalLeaks no devolvio el reto necesario para mantener activa la sesion.",
StatusCodes.Status502BadGateway);
return new RefreshedGlobalLeaksSession(
proofOfWorkToken,
ParseSessionExpiration(root));
}
private static GlobalLeaksProofOfWorkToken? ParseProofOfWorkToken(JsonElement root)
{
if (!root.TryGetProperty("token", out var token) ||
token.ValueKind != JsonValueKind.Object)
{
return null;
}
var id = GetString(token, "id");
var salt = GetString(token, "salt");
return string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(salt)
? null
: new GlobalLeaksProofOfWorkToken(id, salt);
}
private static DateTimeOffset? ParseSessionExpiration(JsonElement root)
{
if (!root.TryGetProperty("session_expiration", out var expiration))
{
return null;
}
double unixSeconds;
if (expiration.ValueKind == JsonValueKind.Number)
{
if (!expiration.TryGetDouble(out unixSeconds))
{
return null;
}
}
else if (expiration.ValueKind == JsonValueKind.String)
{
if (!double.TryParse(
expiration.GetString(),
NumberStyles.Float,
CultureInfo.InvariantCulture,
out unixSeconds))
{
return null;
}
}
else
{
return null;
}
if (unixSeconds <= 0 || unixSeconds > DateTimeOffset.MaxValue.ToUnixTimeSeconds())
{
return null;
}
return DateTimeOffset.FromUnixTimeMilliseconds((long)(unixSeconds * 1000));
}
private static async Task EnsureSuccessOrThrowAsync(
@@ -1389,7 +1708,14 @@ public sealed class GlobalLeaksClient
bool HasNewCitizenComment,
bool HasNewCitizenFile,
DateTimeOffset? ReceiverLastActivity,
bool HasNewReceiverActivity);
bool HasNewReceiverActivity,
string? ReceiverLastActivityAuthorId,
string? ReceiverLastActivityAuthorName);
private sealed record ActivityActorEvent(
DateTimeOffset? Date,
string? AuthorId,
string? AuthorName);
private sealed record RawReport
{

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

@@ -30,18 +30,10 @@ public sealed class GlobalLeaksSessionStore
}
var path = GetFilePath(username);
if (!File.Exists(path))
{
return null;
}
await _gate.WaitAsync(cancellationToken);
try
{
var protectedBytes = await File.ReadAllBytesAsync(path, cancellationToken);
var protectedBase64 = Encoding.UTF8.GetString(protectedBytes);
var json = _protector.Unprotect(protectedBase64);
return JsonSerializer.Deserialize<GlobalLeaksStoredSession>(json, JsonOptions);
return await ReadUnsafeAsync(path, cancellationToken);
}
finally
{
@@ -54,15 +46,23 @@ public sealed class GlobalLeaksSessionStore
string password,
string sessionId,
string? role,
string? dpopPrivateKey,
GlobalLeaksProofOfWorkToken? proofOfWorkToken,
DateTimeOffset? sessionExpiresAtUtc,
CancellationToken cancellationToken = default)
{
var now = DateTimeOffset.UtcNow;
var data = new GlobalLeaksStoredSession
{
Username = username,
Password = password,
SessionId = sessionId,
Role = role,
UpdatedAt = DateTimeOffset.UtcNow,
DpopPrivateKey = dpopPrivateKey,
ProofOfWorkToken = proofOfWorkToken,
SessionExpiresAtUtc = sessionExpiresAtUtc,
LastKeepAliveAtUtc = now,
UpdatedAt = now,
};
await WriteAsync(data, cancellationToken);
@@ -72,29 +72,116 @@ public sealed class GlobalLeaksSessionStore
string username,
string sessionId,
string? role,
string? dpopPrivateKey,
GlobalLeaksProofOfWorkToken? proofOfWorkToken,
DateTimeOffset? sessionExpiresAtUtc,
CancellationToken cancellationToken = default)
{
var current = await GetAsync(username, cancellationToken)
?? throw new InvalidOperationException("No hay credenciales guardadas para este usuario.");
var path = GetFilePath(username);
current.SessionId = sessionId;
current.Role = role;
current.UpdatedAt = DateTimeOffset.UtcNow;
await _gate.WaitAsync(cancellationToken);
try
{
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)
{
var current = await GetAsync(username, cancellationToken);
if (current is null)
{
return;
}
var path = GetFilePath(username);
current.SessionId = null;
current.UpdatedAt = DateTimeOffset.UtcNow;
await WriteAsync(current, cancellationToken);
await _gate.WaitAsync(cancellationToken);
try
{
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)
@@ -122,17 +209,12 @@ public sealed class GlobalLeaksSessionStore
private async Task WriteAsync(GlobalLeaksStoredSession data, CancellationToken cancellationToken)
{
Directory.CreateDirectory(RootPath);
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);
try
{
await File.WriteAllBytesAsync(path, protectedBytes, cancellationToken);
await WriteUnsafeAsync(path, data, cancellationToken);
}
finally
{
@@ -147,4 +229,41 @@ public sealed class GlobalLeaksSessionStore
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(normalized));
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
{
Task<DenunciasGestiona?> GetDenunciaByGestionaFileCodeAsync(
string gestionaFileCode,
CancellationToken cancellationToken = default);
Task<List<DenunciasGestiona>> GetDenunciasByIdsAsync(
IReadOnlyCollection<int> denunciaIds,
CancellationToken cancellationToken = default);

View File

@@ -102,18 +102,28 @@ public sealed class InboxTrackingService : IInboxTrackingService
DownloadedByAnotherUser = meta?.DownloadedByAnotherUser ?? false,
LastDownloadedByUsername = meta?.LastDownloadedByUsername,
LastDownloadedAt = meta?.LastDownloadedAtUtc?.ToString("O", CultureInfo.InvariantCulture),
LastGestionaUploadAt = meta?.LastGestionaUploadAtUtc?.ToString("O", CultureInfo.InvariantCulture),
AlreadyImported = meta?.AlreadyImported ?? 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)
};
})
.Where(report => !IsLockedByAnotherUser(report))
.Where(report =>
string.IsNullOrWhiteSpace(report.OwnerUsername) ||
report.OwnedByCurrentUser ||
report.AccessibleByWorkGroup)
.ToArray();
}
public async Task EnsureReportCanBeImportedByUserAsync(
string username,
ReportDto report,
bool confirmDifferentOwner = false,
CancellationToken cancellationToken = default)
{
await _denunciaStore.EnsureSchemaAsync(cancellationToken);
@@ -140,14 +150,23 @@ public sealed class InboxTrackingService : IInboxTrackingService
await EnsureConnectionOpenAsync(connection, 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)
? "otro usuario"
: meta.LastDownloadedByUsername;
return;
}
throw new InvalidOperationException(
$"La denuncia ya fue importada por {owner}. Solo ese usuario puede ver e importar sus actualizaciones.");
if (!meta.AccessibleByWorkGroup)
{
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
last_downloaded_at_utc = @nowUtc,
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_to_store_at_utc = COALESCE(imported_to_store_at_utc, @nowUtc),
updated_at_utc = CURRENT_TIMESTAMP(6)
@@ -298,6 +318,7 @@ public sealed class InboxTrackingService : IInboxTrackingService
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)
@@ -512,11 +533,45 @@ public sealed class InboxTrackingService : IInboxTrackingService
ir.global_report_uuid,
ir.last_downloaded_at_utc,
downloader.username AS last_downloaded_by_username,
owner.username AS owner_username,
ir.imported_to_store_at_utc,
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
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
ON uir.inbox_report_id = ir.id
AND uir.app_user_id = @userId
@@ -533,14 +588,20 @@ public sealed class InboxTrackingService : IInboxTrackingService
? null
: reader.GetString(reader.GetOrdinal("last_downloaded_by_username"));
var downloadedByCurrentUser = reader.GetInt32(reader.GetOrdinal("downloaded_by_current_user")) == 1;
var lockedByAnotherUser =
!downloadedByCurrentUser &&
!reader.IsDBNull(reader.GetOrdinal("imported_to_store_at_utc")) &&
!string.IsNullOrWhiteSpace(lastDownloadedByUsername);
var downloadedByAnotherUser =
!downloadedByCurrentUser &&
!string.IsNullOrWhiteSpace(lastDownloadedByUsername);
var downloadedByAnotherUser =
!downloadedByCurrentUser &&
!string.IsNullOrWhiteSpace(lastDownloadedByUsername);
var ownerUsername = reader.IsDBNull(reader.GetOrdinal("owner_username"))
? null
: 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 ownerWorkGroups = reader.IsDBNull(reader.GetOrdinal("owner_group_codes"))
? []
: reader.GetString(reader.GetOrdinal("owner_group_codes"))
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
metadata[reportId] = new ReportMetadata
{
@@ -548,9 +609,13 @@ public sealed class InboxTrackingService : IInboxTrackingService
DownloadedByAnotherUser = downloadedByAnotherUser,
LastDownloadedByUsername = lastDownloadedByUsername,
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")),
AlreadyInGestiona = reader.GetInt32(reader.GetOrdinal("already_in_gestiona")) == 1,
LockedByAnotherUser = lockedByAnotherUser,
OwnerUsername = ownerUsername,
OwnedByCurrentUser = ownedByCurrentUser,
AccessibleByWorkGroup = accessibleByGroup,
OwnerWorkGroups = ownerWorkGroups,
};
}
@@ -654,11 +719,10 @@ public sealed class InboxTrackingService : IInboxTrackingService
return null;
}
if (metadata.LockedByAnotherUser)
if (!string.IsNullOrWhiteSpace(metadata.OwnerUsername) &&
!metadata.OwnedByCurrentUser)
{
return string.IsNullOrWhiteSpace(metadata.LastDownloadedByUsername)
? "Importada por otro usuario"
: $"Importada por {metadata.LastDownloadedByUsername}";
return $"Propiedad de {metadata.OwnerUsername}";
}
if (metadata.AlreadyInGestiona)
@@ -693,15 +757,26 @@ public sealed class InboxTrackingService : IInboxTrackingService
{
public bool DownloadedByCurrentUser { get; init; }
public bool DownloadedByAnotherUser { get; init; }
public bool LockedByAnotherUser { get; init; }
public string? LastDownloadedByUsername { get; init; }
public DateTimeOffset? LastDownloadedAtUtc { get; init; }
public DateTimeOffset? LastGestionaUploadAtUtc { get; init; }
public bool AlreadyImported { 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

@@ -116,6 +116,7 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
gestiona_uploaded_at_utc,
gestiona_last_upload_type,
gestiona_assigned_group,
pending_update_source,
is_in_gestiona,
is_rejected,
key_date,
@@ -184,6 +185,8 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
("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", "key_date", "`key_date` DATE NULL"),
("complaint_attachments", "encryption_scheme", "`encryption_scheme` VARCHAR(64) NOT NULL DEFAULT 'none'"),
@@ -195,6 +198,7 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
("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_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`)"),
];
@@ -551,6 +555,38 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
: 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,
@@ -677,6 +713,7 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
gestiona_uploaded_at_utc,
gestiona_last_upload_type,
gestiona_assigned_group,
pending_update_source,
is_in_gestiona,
is_rejected,
key_date,
@@ -749,6 +786,7 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
@gestionaUploadedAtUtc,
@gestionaLastUploadType,
@gestionaAssignedGroup,
@pendingUpdateSource,
@isInGestiona,
@isRejected,
@keyDate,
@@ -821,6 +859,7 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
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_rejected = VALUES(is_rejected),
key_date = VALUES(key_date),
@@ -898,6 +937,7 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
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("@isRejected", denuncia.EnRechazada);
command.Parameters.AddWithValue("@keyDate", ToDbDate(denuncia.KeyDate));
@@ -962,9 +1002,6 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
description = @description,
attachment_date_utc = @attachmentDateUtc,
notes = @notes,
content = @content,
content_mime_type = @contentMimeType,
content_sha256 = @contentSha256,
uploaded_to_gestiona = CASE
WHEN complaint_attachments.content_sha256 = @contentSha256 THEN complaint_attachments.uploaded_to_gestiona
ELSE @uploadedToGestiona
@@ -973,6 +1010,9 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
WHEN complaint_attachments.content_sha256 = @contentSha256 THEN complaint_attachments.uploaded_at_utc
ELSE @uploadedAtUtc
END,
content = @content,
content_mime_type = @contentMimeType,
content_sha256 = @contentSha256,
key_date = @keyDate,
encryption_scheme = @encryptionScheme,
encrypted_at_utc = @encryptedAtUtc,
@@ -1322,6 +1362,54 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
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(
@@ -1639,6 +1727,7 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
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"),
EnRechazada = GetBoolean(record, "is_rejected"),
KeyDate = GetNullableDateOnly(record, "key_date"),

View File

@@ -12,43 +12,163 @@ public sealed class UserComplaintAccessService
_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))
{
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);
await using var connection = new MySqlConnection(connectionString);
await connection.OpenAsync(cancellationToken);
await using var command = new MySqlCommand(sql, connection);
await using var command = connection.CreateCommand();
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);
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;
}
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);
return allowedIds.Contains(complaintId);
if (complaintId <= 0)
{
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

@@ -44,6 +44,8 @@
"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",
"CircuitVersion": "2",
"DocumentMetadataLanguage": "es",

View File

@@ -19,7 +19,9 @@ public sealed record InboxSnapshotResponse(
IReadOnlyList<ReportDto> Reports,
InboxUserState UserState);
public sealed record ImportReportRequest(ReportDto Report);
public sealed record ImportReportRequest(
ReportDto Report,
bool ConfirmDifferentOwner = false);
public sealed record MarkFicherosUploadedRequest(
IReadOnlyList<string> FileNames,
@@ -43,7 +45,8 @@ public sealed record MarkReportHandledInGestionaRequest(
public sealed record TrackingImportPermissionRequest(
string Username,
ReportDto Report);
ReportDto Report,
bool ConfirmDifferentOwner = false);
public sealed record GestionaCreateFileRequest(
string Subject,
@@ -92,7 +95,8 @@ public sealed record GestionaTramitarDocumentoRequest(
string DocumentUrl,
string AssignedGroupCode,
int? ComplaintId,
bool IsUpdate = false);
bool IsUpdate = false,
string? UpdateSource = null);
public sealed record ManualPurgeRequest(string Date);
@@ -109,6 +113,26 @@ public sealed record AppConfigurationDto(
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,
@@ -122,3 +146,10 @@ public sealed record GestionaComplaintFieldsResponse(
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

@@ -79,10 +79,16 @@ public class DenunciasGestiona
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 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]
public DateOnly? KeyDate { get; set; }

View File

@@ -1,3 +1,11 @@
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? SessionId { 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 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,
IReadOnlyList<ReportCommentDto> Comments,
IReadOnlyList<ReportFileDto> WhistleblowerFiles,
IReadOnlyList<ReportFileDto> ReceiverFiles);
IReadOnlyList<ReportFileDto> ReceiverFiles,
IReadOnlyList<ReportReceiverDto>? Receivers = null);
public sealed record ReportCommentDto(
string? Id,
string? Type,
string? Content,
string? CreationDate,
bool IsNew);
bool IsNew,
string? AuthorId = null,
string? AuthorName = null);
public sealed record ReportFileDto(
string? Id,
@@ -20,4 +23,11 @@ public sealed record ReportFileDto(
long? Size,
string? ContentType,
string? CreationDate,
bool IsNew);
bool IsNew,
string? AuthorId = null,
string? AuthorName = null);
public sealed record ReportReceiverDto(
string Id,
string Name,
bool Active);

View File

@@ -24,11 +24,19 @@ public sealed record ReportDto
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 DownloadedByAnotherUser { get; init; }
public string? LastDownloadedByUsername { get; init; }
public string? LastDownloadedAt { get; init; }
public string? LastGestionaUploadAt { get; init; }
public bool AlreadyImported { 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; }
}

View File

@@ -24,5 +24,6 @@ public interface IInboxTrackingService
Task EnsureReportCanBeImportedByUserAsync(
string username,
ReportDto report,
bool confirmDifferentOwner = false,
CancellationToken cancellationToken = default);
}

View File

@@ -17,7 +17,7 @@
<body>
<Routes @rendermode="@(new InteractiveServerRenderMode(prerender: false))" />
<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>
</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,5 +1,5 @@
@inherits LayoutComponentBase
@implements IDisposable
@implements IAsyncDisposable
@using System.Globalization
@inject GestionaDenunciasAN.Models.UserState userState
@inject IHttpContextAccessor HttpContextAccessor
@@ -7,6 +7,7 @@
@inject NavigationManager Navigation
@inject UiBusyService Busy
@inject ApiDenunciasClient ApiDenuncias
@inject ILogger<MainLayout> Logger
<div class="app-shell">
<aside class="app-sidebar">
@@ -51,6 +52,7 @@
</div>
<BusyOverlay />
<AppConfirmationDialog />
<div id="blazor-error-ui">
An unhandled error has occurred.
@@ -59,12 +61,20 @@
</div>
@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 CurrentPageDescription { get; set; } =
"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 =>
string.IsNullOrWhiteSpace(userState?.NombreUsu)
@@ -87,9 +97,58 @@
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;
_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)
@@ -134,6 +193,69 @@
}
}
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))

View File

@@ -16,6 +16,7 @@
@inject IDenunciaStore DenunciaStore
@inject ApiDenunciasClient ApiDenuncias
@inject UiBusyService Busy
@inject UiDialogService Dialogs
<PageTitle>Actualizaciones</PageTitle>
@@ -162,7 +163,21 @@ else
data-bs-target="#@collapseId"
aria-expanded="false"
aria-controls="@collapseId">
<h5 class="mb-0">Denuncia ID: @denuncia.Id_Denuncia (Actualizació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="text-muted small me-3">
@@ -703,11 +718,24 @@ else
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;
nuevoAsunto = string.IsNullOrWhiteSpace(d.NombreDenuncia) ? $"Denuncia-{d.Id_Denuncia}-CD" : d.NombreDenuncia;
nombreDocumentos = "";
selectedThirdParty = ThirdPartyIdentityData.FromComplaint(d);
selectedGroup = NormalizeUpdateGroup(selectedGroup);
selectedGroup = NormalizeUpdateGroup(d.UltimoGrupoAsignadoGestiona);
operationError = string.Empty;
operationNotice = string.Empty;
@@ -817,12 +845,19 @@ else
return;
}
if (!await ConfirmSelectedGroupAsync())
{
return;
}
try
{
isUploading = true;
operationError = string.Empty;
operationNotice = string.Empty;
selectedGroup = NormalizeUpdateGroup(selectedGroup);
var pendingUpdateSource = selectedDenuncias.PendingUpdateSource;
var uploadType = GetUpdateUploadType(pendingUpdateSource, selectedGroup);
using var busy = Busy.Show(
"Enviando actualizacion",
"Preparando expediente, carpeta de actualizacion y documentos.");
@@ -1008,7 +1043,8 @@ else
documentoParaTramitar,
selectedGroup,
selectedDenuncias.Id_Denuncia,
isUpdate: true);
isUpdate: true,
updateSource: pendingUpdateSource);
}
foreach (var orig in nombresOriginalesSubidos)
@@ -1030,13 +1066,14 @@ else
selectedDenuncias.EsActualizacion = false;
selectedDenuncias.NombreDenuncia = nuevoAsunto;
selectedDenuncias.FechaSubidaAGestiona = ahoraUtc;
selectedDenuncias.UltimaSubidaGestionaTipo = "Actualización";
selectedDenuncias.UltimaSubidaGestionaTipo = uploadType;
selectedDenuncias.UltimoGrupoAsignadoGestiona = GetGestionaGroupDisplay(selectedGroup);
selectedDenuncias.PendingUpdateSource = string.Empty;
await SincronizarExpedienteGestionaAsync(selectedDenuncias, fileUrl);
await ActualizarDenunciaAsync(selectedDenuncias);
var historialAviso = await RegistrarHistorialGestionaAsync(
selectedDenuncias,
"Actualización",
uploadType,
selectedGroup,
ahoraUtc,
string.Join("; ", nombresFinalesSubidos));
@@ -1331,13 +1368,53 @@ else
}
private static string NormalizeUpdateGroup(string? groupCode)
=> groupCode == "510" ? "510" : "600";
=> groupCode?.Trim().StartsWith("510", StringComparison.Ordinal) == true
? "510"
: "600";
private static string GetGestionaGroupDisplay(string? groupCode)
{
return string.IsNullOrWhiteSpace(groupCode) ? string.Empty : groupCode.Trim();
}
private static string GetUpdateUploadType(string? updateSource, string groupCode)
{
if (!ComplaintUpdateSources.IsReceiver(updateSource))
{
return "Actualización";
}
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,

View File

@@ -4,6 +4,7 @@
@using System.Globalization
@using GestionaDenunciasAN.Services
@using GestionaDenuncias.Shared.Models
@using Microsoft.AspNetCore.Components.Authorization
@inject ApiDenunciasClient ApiDenuncias
@@ -95,6 +96,104 @@ else
</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-body">
<h5 class="mb-3">Purga manual con reemplazo</h5>
@@ -223,6 +322,12 @@ else
private string? configurationNotice;
private string? configurationError;
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 bool acceptedRisk;
@@ -246,6 +351,7 @@ else
}
await LoadConfigurationAsync();
await LoadWorkGroupsAsync();
}
private async Task LoadConfigurationAsync()
@@ -297,6 +403,98 @@ else
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)
{
if (string.IsNullOrWhiteSpace(value))

View File

@@ -9,6 +9,7 @@
@inject ApiDenunciasClient ApiDenuncias
@inject IJSRuntime JSRuntime
@inject UiBusyService Busy
@inject UiDialogService Dialogs
<PageTitle>Entrada de denuncias</PageTitle>
@@ -122,6 +123,9 @@
.inbox-activity-cell {
width: 9.25rem;
max-width: 12rem;
overflow: hidden;
text-overflow: ellipsis;
}
.inbox-action-cell {
@@ -226,6 +230,7 @@
<option value="all">Todas</option>
<option value="new">Nuevas / sin leer</option>
<option value="updated">Actualizaciones del ciudadano</option>
<option value="receiver">Actividad OAAF</option>
</select>
</div>
@@ -310,12 +315,12 @@
<th>#</th>
<th>Canal</th>
<th>Presentacion</th>
<th>Actividad ciudadano</th>
<th>Actividad OAAF</th>
<th>Estado</th>
<th>Acceso</th>
<th>Seguimiento</th>
<th class="inbox-action-cell">Detalle</th>
<th title="Fecha de la última aportación o comentario realizado por el ciudadano.">Actividad ciudadano</th>
<th title="Fecha de la última comunicación o fichero incorporado por un gestor de la OAAF.">Actividad OAAF</th>
<th title="Resume si la denuncia es nueva, tiene cambios pendientes o ya está actualizada en Gestiona.">Estado</th>
<th title="Indica si tu usuario gestor del buzón puede acceder a esta denuncia.">Acceso</th>
<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>
<tbody>
@@ -355,19 +360,19 @@
<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 class="inbox-activity-cell">@FormatCitizenActivity(report)</td>
<td class="inbox-activity-cell">@FormatReceiverActivity(report)</td>
<td class="inbox-activity-cell" title="@GetReceiverActivityTitle(report)">@FormatReceiverActivity(report)</td>
<td>
<span class="badge @GetStatusBadgeCss(report)">
<span class="badge @GetStatusBadgeCss(report)" title="@GetStatusHelp(report)">
@GetStatusLabel(report)
</span>
</td>
<td>
<span class="badge @GetAccessBadgeCss(report)">
<span class="badge @GetAccessBadgeCss(report)" title="@GetAccessHelp(report)">
@GetAccessLabel(report)
</span>
</td>
<td class="inbox-tracking-cell" title="@(report.TrackingNote ?? string.Empty)">
<span class="badge @GetTrackingBadgeCss(report)">@GetTrackingLabel(report)</span>
<td class="inbox-tracking-cell">
<span class="badge @GetTrackingBadgeCss(report)" title="@GetTrackingHelp(report)">@GetTrackingLabel(report)</span>
</td>
<td class="inbox-action-cell">
<button type="button"
@@ -432,7 +437,7 @@
{
<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">
<strong>@GetCommentAuthorLabel(comment.Type)</strong>
<strong>@GetCommentAuthorLabel(comment)</strong>
<span>@FormatDate(comment.CreationDate)</span>
</div>
@if (comment.IsNew)
@@ -482,6 +487,10 @@
<span class="small text-muted">@FormatDate(file.CreationDate)</span>
</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)
{
<span class="badge bg-success mt-2">Nuevo</span>
@@ -703,6 +712,37 @@
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;
var importedCount = 0;
var errors = new List<string>();
@@ -710,18 +750,6 @@
try
{
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;
}
using var busy = Busy.Show(
"Importando denuncias",
$"Descargando y procesando {selectedReports.Count} denuncia(s) desde GlobalLeaks.",
@@ -739,7 +767,10 @@
try
{
var result = await ApiDenuncias.ImportReportAsync(report, CancellationToken.None);
var result = await ApiDenuncias.ImportReportAsync(
report,
report.RequiresOwnerConfirmation,
CancellationToken.None);
importedCount += result.ImportedCount;
errors.AddRange(result.Errors.Select(error => $"#{report.Progressive ?? 0}: {error}"));
if (result.Warnings is not null)
@@ -898,7 +929,10 @@
filtered = Filter switch
{
"new" => filtered.Where(report => string.IsNullOrWhiteSpace(report.AccessDate) || string.Equals(report.Status, "new", StringComparison.OrdinalIgnoreCase)),
"updated" => filtered.Where(report => report.CitizenHasNewActivity || report.Updated),
"updated" => filtered.Where(report =>
report.CitizenHasNewActivity ||
(!report.ActivityAnalyzed && report.Updated)),
"receiver" => filtered.Where(report => report.ReceiverHasNewActivity),
_ => filtered
};
@@ -1144,7 +1178,9 @@
var receiverDate = FormatOptionalDate(report.ReceiverLastActivity);
if (!string.IsNullOrWhiteSpace(receiverDate))
{
return receiverDate;
return string.IsNullOrWhiteSpace(report.ReceiverLastActivityAuthorName)
? receiverDate
: $"{receiverDate} · {report.ReceiverLastActivityAuthorName}";
}
if (!report.ActivityAnalyzed)
@@ -1155,6 +1191,16 @@
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)
{
if (value is null or <= 0)
@@ -1176,11 +1222,13 @@
return $"{bytes / 1024d / 1024d:0.#} MB";
}
private static string GetCommentAuthorLabel(string? type)
=> IsWhistleblowerActivityType(type)
private static string GetCommentAuthorLabel(ReportCommentDto comment)
=> IsWhistleblowerActivityType(comment.Type)
? "Denunciante"
: IsReceiverActivityType(type)
? "Receptor"
: IsReceiverActivityType(comment.Type)
? string.IsNullOrWhiteSpace(comment.AuthorName)
? "Gestor OAAF"
: $"Gestor OAAF: {comment.AuthorName}"
: "Comentario";
private static bool IsWhistleblowerActivityType(string? value)
@@ -1236,7 +1284,7 @@
if (report.CitizenHasNewActivity)
{
return "Actualizacion ciudadano";
return "Actualización ciudadano";
}
if (IsReceiverOnlyUpdate(report))
@@ -1244,14 +1292,19 @@
return "Actividad OAAF";
}
if (report.Updated)
if (report.AlreadyInGestiona && !report.ActivityAnalyzed)
{
return "Sin comprobar";
}
if (IsUpToDateInGestiona(report))
{
return "Actualizada";
}
return string.Equals(report.Status, "closed", StringComparison.OrdinalIgnoreCase)
? "Cerrada"
: "Abierta";
: "Nueva denuncia";
}
private static string GetStatusBadgeCss(ReportDto report)
@@ -1271,7 +1324,12 @@
return "bg-secondary";
}
if (report.Updated)
if (report.AlreadyInGestiona && !report.ActivityAnalyzed)
{
return "bg-warning text-dark";
}
if (IsUpToDateInGestiona(report))
{
return "bg-light text-dark";
}
@@ -1281,6 +1339,47 @@
: "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
{
@@ -1297,8 +1396,16 @@
_ => "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 && !IsReceiverOnlyUpdate(report);
=> report.Accessible != false;
private static bool IsReceiverOnlyUpdate(ReportDto report)
=> report.AlreadyInGestiona &&
@@ -1313,11 +1420,6 @@
return "GlobalLeaks indica que esta denuncia no es accesible para este usuario.";
}
if (IsReceiverOnlyUpdate(report))
{
return "La actividad nueva procede de OAAF/gestor; no se importa como actualizacion del ciudadano.";
}
return null;
}
@@ -1328,11 +1430,6 @@
return "GlobalLeaks indica que esta denuncia no es accesible para este usuario.";
}
if (IsReceiverOnlyUpdate(report))
{
return "Actividad interna detectada; puedes consultar el detalle, pero no importarla como actualizacion.";
}
return "Consulta mensajes y ficheros. GlobalLeaks puede marcar la denuncia como leida.";
}
@@ -1386,6 +1483,35 @@
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)
{
if (report.Accessible == false)
@@ -1395,7 +1521,7 @@
if (IsReceiverOnlyUpdate(report))
{
return "table-secondary report-row-disabled";
return "table-secondary";
}
if (report.AlreadyInGestiona)

View File

@@ -114,28 +114,28 @@ else
<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>
<div class="header-info">
<span><strong>Asunto:</strong> @GetHeaderSubject(denuncia, latestHistory)</span>
<span title="Asunto con el que se identifica el expediente en Gestiona."><strong>Asunto:</strong> @GetHeaderSubject(denuncia, latestHistory)</span>
@if (!string.IsNullOrWhiteSpace(denuncia.ExpedienteGestionaMostrable))
{
<span><strong>Nº expediente:</strong> @denuncia.ExpedienteGestionaMostrable</span>
<span title="Número asignado al expediente por Gestiona."><strong>Nº expediente:</strong> @denuncia.ExpedienteGestionaMostrable</span>
}
@if (!string.IsNullOrWhiteSpace(GetUploadType(denuncia, latestHistory)))
{
<span><strong>Última subida:</strong> @GetUploadType(denuncia, latestHistory)</span>
<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><strong>Grupo asignado:</strong> @FormatGroupCode(GetAssignedGroup(denuncia, latestHistory))</span>
<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><strong>Usuario subida:</strong> @GetUploadUser(latestHistory)</span>
<span title="Usuario de la aplicación que realizó la última subida."><strong>Usuario subida:</strong> @GetUploadUser(latestHistory)</span>
}
<span><strong>Fecha de Subida:</strong> @FormatUploadDate(uploadMoment)</span>
<span><strong>Hora de Subida:</strong> @FormatUploadTime(uploadMoment)</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><strong>Última actividad Gestiona:</strong> @GetAuditDateText(denuncia)</span>
<span title="Último movimiento registrado en la auditoría del expediente de Gestiona."><strong>Última actividad Gestiona:</strong> @GetAuditDateText(denuncia)</span>
}
</div>
</div>
@@ -331,16 +331,16 @@ else
<div class="card-header">
<h5 class="mb-0">Denuncia ID: @item.DenunciaId</h5>
<div class="header-info">
<span><strong>Asunto:</strong> @DisplayOrDash(item.Asunto)</span>
<span><strong>Nº expediente:</strong> @DisplayOrDash(item.CodigoExpedienteGestiona)</span>
<span><strong>Última subida:</strong> @DisplayOrDash(item.TipoSubida)</span>
<span><strong>Grupo asignado:</strong> @DisplayOrDash(FormatGroupCode(item.GrupoAsignado))</span>
<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><strong>Usuario subida:</strong> @GetUploadUser(item)</span>
<span title="Usuario de la aplicación que realizó la última subida."><strong>Usuario subida:</strong> @GetUploadUser(item)</span>
}
<span><strong>Fecha de Subida:</strong> @FormatUploadDate(uploadMoment)</span>
<span><strong>Hora de Subida:</strong> @FormatUploadTime(uploadMoment)</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>

View File

@@ -56,7 +56,7 @@
<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>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>Si la denuncia no procede, usa <strong>Rechazar denuncia</strong> e indica el motivo.</li>
</ul>
@@ -67,15 +67,15 @@
<div class="col-12 col-xl-6">
<div class="card h-100">
<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>
Antes de confirmar la subida, revisa estos puntos:
Al configurar un expediente nuevo o una actualización, revisa estos puntos antes de confirmar:
</p>
<ul class="mb-0">
<li><strong>Asunto</strong>: texto que identificara el expediente/documentos en Gestiona.</li>
<li><strong>Grupo destino</strong>: unidad a la que se asignara el expediente.</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>: 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>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>
</ul>
</div>
@@ -87,14 +87,15 @@
<div class="card-body">
<h2 class="h5">Actualizaciones</h2>
<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>
<ul class="mb-0">
<li>Revisa la actualizacion y sus ficheros.</li>
<li>La app propone los adjuntos que parecen nuevos.</li>
<li>Puedes desmarcar los adjuntos que no quieras subir.</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>
</div>
</div>
@@ -105,12 +106,12 @@
<div class="card-body">
<h2 class="h5">Gestiona</h2>
<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>
<ul class="mb-0">
<li>Comprueba el numero de expediente y la fecha de envio.</li>
<li>Accede al enlace del expediente cuando necesites revisar la tramitacion en Gestiona.</li>
<li>Usa esta pantalla como seguimiento de lo que ya salio de Pendientes o Actualizaciones.</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>Despliega una operación del día para consultar sus datos y documentos mientras estén disponibles.</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>
</div>
</div>
@@ -121,7 +122,7 @@
<div class="card-body">
<h2 class="h5">Rechazados</h2>
<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>
<ul class="mb-0">
<li>Consulta el motivo indicado al rechazar.</li>
@@ -165,4 +166,4 @@
<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.
</div>
</div>
</div>

View File

@@ -19,6 +19,7 @@
@inject IDenunciaStore DenunciaStore
@inject ApiDenunciasClient ApiDenuncias
@inject UiBusyService Busy
@inject UiDialogService Dialogs
<PageTitle>Denuncias Pendientes</PageTitle>
@@ -199,7 +200,21 @@ else
data-bs-target="#@collapseId"
aria-expanded="false"
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>
<button type="button"
class="btn btn-success btn-sm me-2"
@@ -587,7 +602,7 @@ else
600. Asuntos Jurídicos y Protección a la Persona Denunciante
</label>
</div>
@* <div class="form-check">
<div class="form-check">
<input class="form-check-input"
type="radio"
name="selectedGroup"
@@ -598,17 +613,6 @@ else
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> *@
<!-- DATOS DEL TERCERO -->
@{
@@ -948,6 +952,11 @@ else
return;
}
if (!await ConfirmSelectedGroupAsync())
{
return;
}
try
{
isUploading = true;
@@ -1288,8 +1297,13 @@ else
await DenunciaStore.UpsertDenunciaAsync(d);
}
private void OpenEnviarAGestionaModal(DenunciasGestiona d)
private async Task OpenEnviarAGestionaModal(DenunciasGestiona d)
{
if (!await ConfirmDifferentOwnerAsync(d, "tramitar"))
{
return;
}
selectedDenuncias = d;
nuevoAsunto = $"Denuncia {d.Id_Denuncia}-CD";
@@ -1301,13 +1315,64 @@ else
showModal = true;
}
private void OpenRechazarModal(DenunciasGestiona d)
private async Task OpenRechazarModal(DenunciasGestiona d)
{
if (!await ConfirmDifferentOwnerAsync(d, "rechazar"))
{
return;
}
selectedDenuncias = d;
motivoRechazo = string.Empty;
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()
{
showModal = false;

View File

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

View File

@@ -47,6 +47,15 @@ public sealed class ApiDenunciasClient
public Task<ApiGlobalLeaksSessionDto?> GetGlobalLeaksSessionAsync(CancellationToken cancellationToken = default)
=> 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)
=> SendAsync<ApiLoginPrepareResponse>(
HttpMethod.Post,
@@ -72,11 +81,14 @@ public sealed class ApiDenunciasClient
public Task<InboxSnapshotResponse> LoadInboxAsync(CancellationToken cancellationToken = default)
=> 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>(
HttpMethod.Post,
$"api/inbox/reports/{Uri.EscapeDataString(report.Id)}/import",
new ImportReportRequest(report),
new ImportReportRequest(report, confirmDifferentOwner),
authorize: true,
cancellationToken);
@@ -191,10 +203,16 @@ public sealed class ApiDenunciasClient
string assignedGroupCode,
int? complaintId,
bool isUpdate = false,
string? updateSource = null,
CancellationToken cancellationToken = default)
=> PostAsync(
"api/gestiona/documents/tramitar",
new GestionaTramitarDocumentoRequest(documentUrl, assignedGroupCode, complaintId, isUpdate),
new GestionaTramitarDocumentoRequest(
documentUrl,
assignedGroupCode,
complaintId,
isUpdate,
updateSource),
cancellationToken);
public Task<List<ExpedienteTerceroDto>> GetGestionaExpedientesPorTerceroAsync(
@@ -255,6 +273,29 @@ public sealed class ApiDenunciasClient
authorize: true,
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)
=> SendAsync<T>(HttpMethod.Get, path, body: null, authorize: true, cancellationToken, allowNull);

View File

@@ -46,9 +46,10 @@ public sealed class ApiInboxTrackingService : IInboxTrackingService
public Task EnsureReportCanBeImportedByUserAsync(
string username,
ReportDto report,
bool confirmDifferentOwner = false,
CancellationToken cancellationToken = default)
=> _api.PostAsync(
"api/tracking/import-permission",
new TrackingImportPermissionRequest(username, report),
new TrackingImportPermissionRequest(username, report, confirmDifferentOwner),
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

@@ -68,3 +68,62 @@ window.appSetBodyScrollLock = function (locked) {
document.documentElement.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 url = $"{certLoginBaseUrl}{separator}iframe=true&parentOrigin={Uri.EscapeDataString(parentOrigin)}";
await JSRuntime.InvokeVoidAsync("iniciarSesionConCertificado", url);
var url =
$"{certLoginBaseUrl}{separator}iframe=true&origen=Registro&parentOrigin={Uri.EscapeDataString(parentOrigin)}";
await JSRuntime.InvokeVoidAsync("iniciarSesionConCertificado", url);
}

View File

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

View File

@@ -46,7 +46,7 @@ namespace SwaggerAntifraude.Controllers
[AllowAnonymous]
[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;
if (clientCert == null)
@@ -56,7 +56,7 @@ namespace SwaggerAntifraude.Controllers
if (string.IsNullOrWhiteSpace(dni))
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)
return Unauthorized(result.Error ?? "No se pudo autenticar el certificado.");
@@ -82,14 +82,14 @@ namespace SwaggerAntifraude.Controllers
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)
return Unauthorized(result.Error ?? "No se pudo autenticar el certificado.");
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);
@@ -100,7 +100,11 @@ namespace SwaggerAntifraude.Controllers
{
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);
return (jwtToken, persona, null);
}

View File

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

View File

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