Compare commits

..

2 Commits

Author SHA1 Message Date
1fec0ae0f5 Merge branch 'main' of https://gitea.tecnosis.net/Antifraude/Antifraude.Net 2026-07-09 14:55:57 +02:00
b6c61238a5 Añadir endpoint de campos para Gestiona y adaptar login GlobalLeaks a DPoP
- Añade GET /api/denuncias/{id}/gestiona-fields para exponer los campos diarios requeridos por la integración con Gestiona.
- Devuelve un DTO cerrado con fecha, canal, resumen, datos de hechos, protección, sexo y preferencias de notificación.
- Adapta el login contra GlobalLeaks al nuevo flujo DPoP exigido desde la versión 5.0.94.
- Genera proof DPoP con clave EC P-256 efímera y lo envía en la cabecera DPoP junto a X-Token.
- Mejora el mensaje de error cuando GlobalLeaks rechaza el proof DPoP.
2026-07-09 14:55:29 +02:00
47 changed files with 16592 additions and 719 deletions

View File

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

View File

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

View File

@@ -25,6 +25,7 @@ public sealed class ConfigurationController : ControllerBase
}
[HttpPut("external-update-cutoff")]
[Authorize(Policy = "ConfigurationAdministrators")]
public async Task<ActionResult<AppConfigurationDto>> SetExternalUpdateCutoff(
UpdateExternalUpdateCutoffRequest request,
CancellationToken cancellationToken)

View File

@@ -1,3 +1,5 @@
using System.Globalization;
using System.Text;
using ApiDenuncias.Services;
using GestionaDenuncias.Shared.Models;
using Microsoft.AspNetCore.Authorization;
@@ -13,15 +15,21 @@ public sealed class DenunciasController : ControllerBase
private readonly IDenunciaStore _denunciaStore;
private readonly IFilteredDenunciaStore _filteredDenunciaStore;
private readonly UserComplaintAccessService _accessService;
private readonly IInboxTrackingService _trackingService;
private readonly ILogger<DenunciasController> _logger;
public DenunciasController(
IDenunciaStore denunciaStore,
IFilteredDenunciaStore filteredDenunciaStore,
UserComplaintAccessService accessService)
UserComplaintAccessService accessService,
IInboxTrackingService trackingService,
ILogger<DenunciasController> logger)
{
_denunciaStore = denunciaStore;
_filteredDenunciaStore = filteredDenunciaStore;
_accessService = accessService;
_trackingService = trackingService;
_logger = logger;
}
[HttpPost("schema/ensure")]
@@ -56,6 +64,25 @@ public sealed class DenunciasController : ControllerBase
return Ok(await _denunciaStore.GetDenunciaByIdAsync(denunciaId, cancellationToken));
}
[HttpGet("{denunciaId:int}/gestiona-fields")]
public async Task<ActionResult<GestionaComplaintFieldsResponse>> GetGestionaFields(
int denunciaId,
CancellationToken cancellationToken)
{
if (denunciaId <= 0)
{
return BadRequest(new ApiError("Debes indicar un numero de denuncia valido."));
}
var denuncia = await _denunciaStore.GetDenunciaByIdAsync(denunciaId, cancellationToken);
if (denuncia is null)
{
return NotFound(new ApiError("No se ha encontrado la denuncia solicitada."));
}
return Ok(ToGestionaComplaintFields(denuncia));
}
[HttpPost]
public async Task<IActionResult> Upsert(DenunciasGestiona denuncia, CancellationToken cancellationToken)
{
@@ -65,6 +92,7 @@ public sealed class DenunciasController : ControllerBase
}
await _denunciaStore.UpsertDenunciaAsync(denuncia, cancellationToken);
await TryRegisterGestionaHistoryFromComplaintAsync(denuncia, cancellationToken);
return Ok(new { ok = true });
}
@@ -91,6 +119,32 @@ public sealed class DenunciasController : ControllerBase
return Ok(await _denunciaStore.GetFicherosByDenunciaAsync(denunciaId, cancellationToken));
}
[HttpGet("gestiona-history")]
public async Task<ActionResult<List<GestionaUploadHistoryEntry>>> GetGestionaHistory(CancellationToken cancellationToken)
{
await _denunciaStore.EnsureSchemaAsync(cancellationToken);
return Ok(await _denunciaStore.GetGestionaUploadHistoryAsync(cancellationToken));
}
[HttpPost("gestiona-history")]
public async Task<IActionResult> AddGestionaHistory(
GestionaUploadHistoryCreateRequest request,
CancellationToken cancellationToken)
{
if (!await CanAccessAsync(request.DenunciaId, cancellationToken))
{
return Forbid();
}
await _denunciaStore.AddGestionaUploadHistoryAsync(request, GetUsername(), cancellationToken);
await _trackingService.MarkReportHandledInGestionaAsync(
GetUsername(),
request.DenunciaId,
request.UploadedAtUtc,
cancellationToken);
return Ok(new { ok = true });
}
[HttpGet("{denunciaId:int}/ficheros/content")]
public async Task<IActionResult> GetFicheroContent(
int denunciaId,
@@ -178,6 +232,243 @@ public sealed class DenunciasController : ControllerBase
private string GetUsername()
=> User.Identity?.Name ?? throw new InvalidOperationException("No hay usuario autenticado.");
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");
return new GestionaComplaintFieldsResponse(
FechaDenuncia: ToNullableDate(denuncia.Fecha),
NumeroDenunciaCanal: denuncia.Id_Denuncia,
AQuienDenuncia: ResolveReportField(denuncia, denuncia.A_Quien_Denuncia, "a quien denuncia"),
ResumenDenuncia: FirstNonEmpty(
ResolveReportField(denuncia, string.Empty, "resumen de la denuncia", "resumen denuncia"),
ResolveReportField(denuncia, denuncia.Descripcion_Denuncia, "describa su denuncia", "descripcion de la denuncia")),
FechaHechos: ResolveFechaHechos(denuncia),
LugarHechos: 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"),
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"),
PreferenciaNotificacionSeguimientoDenuncia: JoinDistinct(preferenciaNotificacion, seguimiento));
}
private async Task TryRegisterGestionaHistoryFromComplaintAsync(
DenunciasGestiona denuncia,
CancellationToken cancellationToken)
{
if (!denuncia.EnGestiona || denuncia.FechaSubidaAGestiona == DateTime.MinValue)
{
return;
}
try
{
await _denunciaStore.AddGestionaUploadHistoryAsync(
new GestionaUploadHistoryCreateRequest(
denuncia.Id_Denuncia,
denuncia.Expediente_Gestiona ?? string.Empty,
denuncia.ExpedienteGestionaMostrable,
denuncia.UltimaSubidaGestionaTipoMostrable,
denuncia.UltimoGrupoAsignadoGestionaMostrable,
denuncia.FechaSubidaAGestiona,
denuncia.NombreDenuncia ?? string.Empty,
denuncia.ArchivoElegido ?? string.Empty),
GetUsername(),
cancellationToken);
await _trackingService.MarkReportHandledInGestionaAsync(
GetUsername(),
denuncia.Id_Denuncia,
denuncia.FechaSubidaAGestiona,
cancellationToken);
}
catch (Exception ex)
{
_logger.LogWarning(
ex,
"No se ha podido registrar el historico de subida a Gestiona para la denuncia {DenunciaId}.",
denuncia.Id_Denuncia);
}
}
private static DateTime? ToNullableDate(DateTime value)
=> value == DateTime.MinValue ? null : value;
private static string ResolveFechaHechos(DenunciasGestiona denuncia)
{
var literalValue = ResolveReportField(
denuncia,
string.Empty,
"fecha de los hechos que denuncia",
"fecha de los hechos");
if (!string.IsNullOrWhiteSpace(literalValue))
{
return literalValue;
}
return denuncia.Fecha_Hechos == DateTime.MinValue
? string.Empty
: denuncia.Fecha_Hechos.ToString("dd/MM/yyyy", CultureInfo.InvariantCulture);
}
private static string ResolveAmbitoCompetencias(DenunciasGestiona denuncia)
{
var directValue = ResolveReportField(
denuncia,
denuncia.Modalidad_Informacion,
"ambito de competencias",
"ambito competencial",
"ambito",
"competencias",
"modalidad de informacion",
"modalidad informacion");
if (!string.IsNullOrWhiteSpace(directValue))
{
return directValue;
}
return FindReportFieldValue(denuncia, normalizedLabel =>
normalizedLabel.Contains("ambito", StringComparison.Ordinal) &&
normalizedLabel.Contains("compet", StringComparison.Ordinal));
}
private static string ResolveReportField(
DenunciasGestiona denuncia,
string? currentValue,
params string[] candidateLabels)
{
if (!string.IsNullOrWhiteSpace(currentValue))
{
return currentValue.Trim();
}
var labels = candidateLabels
.Select(NormalizeLabel)
.Where(label => !string.IsNullOrWhiteSpace(label))
.ToHashSet(StringComparer.Ordinal);
if (labels.Count == 0)
{
return string.Empty;
}
foreach (var field in denuncia.GetCamposFormulario())
{
if (!string.IsNullOrWhiteSpace(field.Value) &&
labels.Contains(NormalizeLabel(field.Label)))
{
return field.Value.Trim();
}
}
return string.Empty;
}
private static string FindReportFieldValue(
DenunciasGestiona denuncia,
Func<string, bool> labelPredicate)
{
foreach (var field in denuncia.GetCamposFormulario())
{
if (!string.IsNullOrWhiteSpace(field.Value) &&
labelPredicate(NormalizeLabel(field.Label)))
{
return field.Value.Trim();
}
}
return string.Empty;
}
private static string JoinDistinct(params string[] values)
{
var result = new List<string>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var value in values)
{
var trimmed = value?.Trim();
if (!string.IsNullOrWhiteSpace(trimmed) && seen.Add(trimmed))
{
result.Add(trimmed);
}
}
return string.Join("; ", result);
}
private static string FirstNonEmpty(params string[] values)
{
foreach (var value in values)
{
if (!string.IsNullOrWhiteSpace(value))
{
return value.Trim();
}
}
return string.Empty;
}
private static string NormalizeLabel(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return string.Empty;
}
var normalized = value.Normalize(NormalizationForm.FormD);
var builder = new StringBuilder(normalized.Length);
foreach (var ch in normalized)
{
if (CharUnicodeInfo.GetUnicodeCategory(ch) == UnicodeCategory.NonSpacingMark)
{
continue;
}
if (ch is '\u00BA' or '\u00AA')
{
continue;
}
builder.Append(char.IsLetterOrDigit(ch) ? char.ToLowerInvariant(ch) : ' ');
}
return string.Join(
' ',
builder
.ToString()
.Normalize(NormalizationForm.FormC)
.Split(' ', StringSplitOptions.RemoveEmptyEntries));
}
private static string GetAttachmentContentType(string? fileName)
{
return Path.GetExtension(fileName ?? string.Empty).ToLowerInvariant() switch

View File

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

View File

@@ -10,6 +10,13 @@ namespace ApiDenuncias.Controllers;
[Route("api/inbox")]
public sealed class InboxController : ControllerBase
{
private static readonly TimeSpan[] ExportRetryDelays =
[
TimeSpan.FromSeconds(2),
TimeSpan.FromSeconds(5),
TimeSpan.FromSeconds(10)
];
private readonly GlobalLeaksSessionStore _sessionStore;
private readonly PendingGlobalLeaksLoginStore _pendingLoginStore;
private readonly GlobalLeaksClient _globalLeaksClient;
@@ -74,7 +81,7 @@ public sealed class InboxController : ControllerBase
_logger.LogError(ex, "No se ha podido preparar la renovacion GlobalLeaks para {Username}.", username);
return StatusCode(
StatusCodes.Status500InternalServerError,
new ApiError($"No se ha podido preparar la renovacion: {ex.GetType().Name}: {ex.Message}"));
new ApiError("No se ha podido preparar la renovacion de GlobalLeaks. Intentalo de nuevo en unos segundos."));
}
}
@@ -141,7 +148,7 @@ public sealed class InboxController : ControllerBase
_logger.LogError(ex, "No se ha podido cargar la bandeja GlobalLeaks para {Username}.", username);
return StatusCode(
StatusCodes.Status500InternalServerError,
new ApiError($"No se ha podido cargar la bandeja: {ex.GetType().Name}: {ex.Message}"));
new ApiError("No se ha podido renovar la sesion de GlobalLeaks. Intentalo de nuevo en unos segundos."));
}
}
@@ -164,12 +171,17 @@ public sealed class InboxController : ControllerBase
try
{
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 enrichedReports = await _trackingService.RegisterSnapshotAsync(username, reports, cancellationToken);
var state = await _trackingService.GetUserStateAsync(username, cancellationToken);
var activityReports = await _globalLeaksClient.EnrichReportsWithActivityAsync(
session.SessionId!,
enrichedReports,
state.LastDownloadedReportMomentUtc,
cancellationToken);
return Ok(new InboxSnapshotResponse(contexts, enrichedReports, state));
return Ok(new InboxSnapshotResponse(contexts, activityReports, state));
}
catch (GlobalLeaksSessionExpiredException)
{
@@ -178,14 +190,14 @@ public sealed class InboxController : ControllerBase
}
catch (GlobalLeaksValidationException ex)
{
return StatusCode(ex.StatusCode, new ApiError(ex.Message));
return ToGlobalLeaksApiError(ex, "cargar la bandeja de GlobalLeaks");
}
catch (Exception ex)
{
_logger.LogError(ex, "No se ha podido cargar la bandeja GlobalLeaks para {Username}.", username);
return StatusCode(
StatusCodes.Status500InternalServerError,
new ApiError($"No se ha podido cargar la bandeja: {ex.GetType().Name}: {ex.Message}"));
new ApiError("No se ha podido cargar la bandeja de GlobalLeaks. Intentalo de nuevo en unos segundos."));
}
}
@@ -210,7 +222,25 @@ public sealed class InboxController : ControllerBase
{
await _trackingService.EnsureReportCanBeImportedByUserAsync(username, report, cancellationToken);
var zip = await _globalLeaksClient.DownloadReportZipAsync(session.SessionId!, report.Id, cancellationToken);
ReportDetailDto? reportDetail = null;
try
{
reportDetail = await _globalLeaksClient.GetReportDetailAsync(
session.SessionId!,
report.Id,
report.LastAccess,
cancellationToken);
}
catch (GlobalLeaksValidationException ex) when (ex.StatusCode == StatusCodes.Status422UnprocessableEntity ||
ex.StatusCode is >= 500 and <= 504)
{
_logger.LogWarning(
ex,
"No se ha podido obtener el detalle de fechas de adjuntos para la denuncia {ReportId}. Se usara la fecha del paquete exportado.",
report.Id);
}
var reportPackage = await DownloadReportPackageWithRetryAsync(session.SessionId!, report, cancellationToken);
FileDownloadResult? json = null;
try
@@ -222,7 +252,7 @@ public sealed class InboxController : ControllerBase
json = null;
}
var result = await _inboxService.ImportFromGlobalLeaksAsync(zip, json, cancellationToken);
var result = await _inboxService.ImportFromGlobalLeaksAsync(reportPackage, json, reportDetail, cancellationToken);
if (result.ImportedCount > 0)
{
await _trackingService.MarkReportImportedAsync(
@@ -241,14 +271,14 @@ public sealed class InboxController : ControllerBase
}
catch (GlobalLeaksValidationException ex)
{
return StatusCode(ex.StatusCode, new ApiError(ex.Message));
return ToGlobalLeaksApiError(ex, "importar la denuncia");
}
catch (Exception ex)
{
_logger.LogError(ex, "No se ha podido importar la denuncia {ReportId} para {Username}.", reportId, username);
return StatusCode(
StatusCodes.Status500InternalServerError,
new ApiError($"No se ha podido importar la denuncia: {ex.GetType().Name}: {ex.Message}"));
new ApiError("No se ha podido importar la denuncia. Intentalo de nuevo en unos segundos."));
}
}
@@ -276,37 +306,78 @@ public sealed class InboxController : ControllerBase
}
catch (GlobalLeaksValidationException ex)
{
return StatusCode(ex.StatusCode, new ApiError(ex.Message));
return ToGlobalLeaksApiError(ex, "leer el detalle de la denuncia");
}
catch (Exception ex)
{
_logger.LogError(ex, "No se ha podido leer el detalle de la denuncia {ReportId} para {Username}.", reportId, username);
return StatusCode(
StatusCodes.Status500InternalServerError,
new ApiError($"No se ha podido leer el detalle de la denuncia: {ex.GetType().Name}: {ex.Message}"));
new ApiError("No se ha podido abrir el detalle de la denuncia. Intentalo de nuevo en unos segundos."));
}
}
[HttpPost("local/ensure-storage")]
public async Task<IActionResult> EnsureStorage(CancellationToken cancellationToken)
private async Task<FileDownloadResult> DownloadReportPackageWithRetryAsync(
string sessionId,
ReportDto report,
CancellationToken cancellationToken)
{
await _inboxService.EnsureStorageReadyAsync(cancellationToken);
return Ok(new { ok = true });
for (var attempt = 0; ; attempt++)
{
try
{
return await _globalLeaksClient.DownloadReportPackageAsync(sessionId, report.Id, cancellationToken);
}
catch (GlobalLeaksValidationException ex) when (IsExportNotReady(ex) && attempt < ExportRetryDelays.Length)
{
var delay = ExportRetryDelays[attempt];
_logger.LogWarning(
ex,
"GlobalLeaks aun no ha preparado la exportacion de la denuncia {ReportId}. Reintento {Attempt}/{Total} en {DelaySeconds}s.",
report.Id,
attempt + 1,
ExportRetryDelays.Length,
delay.TotalSeconds);
await Task.Delay(delay, cancellationToken);
}
catch (GlobalLeaksValidationException ex) when (IsExportNotReady(ex))
{
throw new GlobalLeaksValidationException(
"GlobalLeaks todavia esta preparando la exportacion de esta denuncia. Espera unos segundos y vuelve a importarla.",
StatusCodes.Status503ServiceUnavailable);
}
}
}
[HttpPost("local/process")]
public async Task<ActionResult<ImportSummary>> ProcessLocalZips(CancellationToken cancellationToken)
=> Ok(await _inboxService.ProcessPendingFolderZipsAsync(cancellationToken));
private static bool IsExportNotReady(GlobalLeaksValidationException ex)
=> ex.StatusCode is >= 500 and <= 504;
[HttpGet("local/zips")]
public async Task<ActionResult<IReadOnlyList<string>>> GetLocalZips(CancellationToken cancellationToken)
=> Ok(await _inboxService.GetExistingZipNamesAsync(cancellationToken));
[HttpDelete("local/zips/{zipName}")]
public async Task<IActionResult> DeleteLocalZip(string zipName, CancellationToken cancellationToken)
private static ActionResult ToGlobalLeaksApiError(GlobalLeaksValidationException ex, string operation)
{
await _inboxService.DeleteZipAsync(zipName, cancellationToken);
return Ok(new { ok = true });
if (ex.StatusCode == StatusCodes.Status401Unauthorized ||
ex.StatusCode == StatusCodes.Status403Forbidden)
{
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."))
{
StatusCode = StatusCodes.Status403Forbidden
};
}
if (ex.StatusCode is >= 500 and <= 504)
{
return new ObjectResult(new ApiError(
$"GlobalLeaks no ha podido {operation} en este momento. Intentalo de nuevo en unos segundos."))
{
StatusCode = StatusCodes.Status503ServiceUnavailable
};
}
return new ObjectResult(new ApiError(ex.Message))
{
StatusCode = ex.StatusCode
};
}
private async Task<GlobalLeaksStoredSession?> RequireActiveSessionAsync(string username, CancellationToken cancellationToken)
@@ -322,4 +393,7 @@ public sealed class InboxController : ControllerBase
=> session is null
? null
: new ApiGlobalLeaksSessionDto(session.Username, session.Role, session.HasActiveSession, session.UpdatedAt);
}

View File

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

View File

@@ -34,6 +34,19 @@ public sealed class TrackingController : ControllerBase
return Ok(new { ok = true });
}
[HttpPost("handled-in-gestiona")]
public async Task<IActionResult> MarkHandledInGestiona(
MarkReportHandledInGestionaRequest request,
CancellationToken cancellationToken)
{
await _trackingService.MarkReportHandledInGestionaAsync(
GetUsername(),
request.DenunciaId,
request.UploadedAtUtc,
cancellationToken);
return Ok(new { ok = true });
}
[HttpPost("import-permission")]
public async Task<IActionResult> EnsureImportPermission(
TrackingImportPermissionRequest request,

View File

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

View File

@@ -64,6 +64,8 @@ CREATE TABLE IF NOT EXISTS complaints (
workflow_status TEXT NOT NULL,
selected_document_name TEXT NULL,
gestiona_uploaded_at_utc DATETIME(6) NULL,
gestiona_last_upload_type TEXT NOT NULL,
gestiona_assigned_group TEXT NOT NULL,
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),
@@ -133,6 +135,24 @@ CREATE TABLE IF NOT EXISTS user_inbox_reports (
ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS gestiona_upload_history (
id BIGINT NOT NULL AUTO_INCREMENT,
external_report_id INT NOT NULL,
gestiona_file_url TEXT NOT NULL,
gestiona_file_code VARCHAR(128) NOT NULL DEFAULT '',
upload_type VARCHAR(64) NOT NULL DEFAULT '',
assigned_group_code VARCHAR(32) NOT NULL DEFAULT '',
uploaded_by_username VARCHAR(256) NOT NULL DEFAULT '',
uploaded_at_utc DATETIME(6) NOT NULL,
subject TEXT NULL,
document_names TEXT NULL,
created_at_utc DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
PRIMARY KEY (id),
KEY ix_gestiona_upload_history_report (external_report_id),
KEY ix_gestiona_upload_history_uploaded_at (uploaded_at_utc),
KEY ix_gestiona_upload_history_user (uploaded_by_username)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS complaint_attachments (
id BIGINT NOT NULL AUTO_INCREMENT,
complaint_id BIGINT NOT NULL,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,6 +3,7 @@ using System.Globalization;
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
@@ -18,6 +19,8 @@ public sealed record PreparedGlobalLeaksCredentials(string Username, string Fina
public sealed class GlobalLeaksClient
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
private const int ActivityAnalysisMaxParallelism = 3;
private const string AuthenticationPath = "/api/auth/authentication";
private readonly HttpClient _httpClient;
private readonly ILogger<GlobalLeaksClient> _logger;
private readonly GlobalLeaksOptions _options;
@@ -129,11 +132,12 @@ public sealed class GlobalLeaksClient
authcode?.Length ?? 0);
var currentTokenAnswer = tokenAnswer;
using var dpopKey = ECDsa.Create(ECCurve.NamedCurves.nistP256);
for (var attempt = 0; attempt < 2; attempt++)
{
using var authRequest = CreateRequest(
HttpMethod.Post,
$"/api/auth/authentication?token={Uri.EscapeDataString(currentTokenAnswer)}");
AuthenticationPath);
authRequest.Content = CreateJsonContent(new
{
tid = 1,
@@ -142,8 +146,9 @@ public sealed class GlobalLeaksClient
authcode = authcode?.Trim() ?? string.Empty,
});
authRequest.Headers.TryAddWithoutValidation("X-Token", currentTokenAnswer);
authRequest.Headers.TryAddWithoutValidation("DPoP", CreateDpopProof(dpopKey, "POST", AuthenticationPath));
using var authResponse = await SendLoginRequestAsync(authRequest, "/api/auth/authentication", cancellationToken);
using var authResponse = await SendLoginRequestAsync(authRequest, AuthenticationPath, cancellationToken);
if (authResponse.IsSuccessStatusCode)
{
var authBody = await authResponse.Content.ReadAsStringAsync(cancellationToken);
@@ -168,6 +173,9 @@ public sealed class GlobalLeaksClient
throw authResponse.StatusCode switch
{
HttpStatusCode.Unauthorized when IsInvalidDpopProof(body) => new GlobalLeaksValidationException(
"GlobalLeaks ha rechazado la prueba DPoP del login. Revisa la version de GlobalLeaks y el formato del proof.",
StatusCodes.Status401Unauthorized),
HttpStatusCode.Unauthorized => new GlobalLeaksValidationException(
"Credenciales incorrectas o codigo 2FA invalido.",
StatusCodes.Status401Unauthorized),
@@ -291,13 +299,65 @@ public sealed class GlobalLeaksClient
ReminderDate = t.ReminderDate,
AccessDate = t.AccessDate,
LastAccess = t.LastAccess,
WhistleblowerLastAccess = t.WhistleblowerLastAccess,
Status = t.Status,
Updated = t.Updated,
Accessible = t.Accessible,
Label = t.Label,
})
.ToArray();
}
public async Task<IReadOnlyList<ReportDto>> EnrichReportsWithActivityAsync(
string sessionId,
IReadOnlyList<ReportDto> reports,
DateTimeOffset? fallbackReference,
CancellationToken cancellationToken)
{
if (reports.Count == 0)
{
return reports;
}
var enriched = reports.ToArray();
var candidates = reports
.Select((report, index) => (Report: report, Index: index))
.Where(item => ShouldAnalyzeCitizenActivity(item.Report))
.ToList();
if (candidates.Count == 0)
{
return enriched;
}
using var gate = new SemaphoreSlim(ActivityAnalysisMaxParallelism, ActivityAnalysisMaxParallelism);
var tasks = candidates.Select(async item =>
{
await gate.WaitAsync(cancellationToken);
try
{
var reference = GetActivityReference(item.Report, fallbackReference);
var activity = await AnalyzeReportActivityAsync(sessionId, item.Report, reference, cancellationToken);
enriched[item.Index] = ApplyActivity(item.Report, activity);
}
catch (Exception ex) when (ex is GlobalLeaksValidationException or HttpRequestException or TaskCanceledException)
{
_logger.LogWarning(
ex,
"No se ha podido analizar la actividad ciudadano/OAAF de la denuncia {Progressive} ({ReportId}).",
item.Report.Progressive,
item.Report.Id);
}
finally
{
gate.Release();
}
});
await Task.WhenAll(tasks);
return enriched;
}
public async Task<ReportDetailDto> GetReportDetailAsync(
string sessionId,
string reportId,
@@ -306,23 +366,25 @@ public sealed class GlobalLeaksClient
{
ValidateUuid(reportId);
using var detailRequest = CreateAuthenticatedRequest(HttpMethod.Get, $"/api/recipient/rtips/{reportId}", sessionId);
using var detailResponse = await SendGlRequestAsync(detailRequest, cancellationToken);
var contentType = detailResponse.Content.Headers.ContentType?.MediaType ?? string.Empty;
var content = await detailResponse.Content.ReadAsByteArrayAsync(cancellationToken);
if (!contentType.Contains("json", StringComparison.OrdinalIgnoreCase) || content.Length == 0)
var reference = ParseDate(lastAccess);
using var document = await ReadReportDetailDocumentAsync(sessionId, reportId, cancellationToken);
IReadOnlyList<ReportCommentDto>? comments = null;
try
{
throw new GlobalLeaksValidationException(
"No se pudo leer el detalle de la denuncia. Puede estar cifrada sin clave disponible en el servidor.",
422);
comments = await GetReportCommentsAsync(sessionId, reportId, reference, defaultNewWhenNoReference: true, cancellationToken);
}
catch (Exception ex) when (ex is GlobalLeaksValidationException or HttpRequestException or TaskCanceledException)
{
_logger.LogWarning(
ex,
"No se han podido leer los comentarios dedicados de la denuncia {ReportId}. Se intentara usar el detalle del tip.",
reportId);
}
using var document = JsonDocument.Parse(content);
return ParseReportDetail(reportId, lastAccess, document.RootElement);
return ParseReportDetail(reportId, lastAccess, document.RootElement, comments);
}
public async Task<FileDownloadResult> DownloadReportZipAsync(
public async Task<FileDownloadResult> DownloadReportPackageAsync(
string sessionId,
string reportId,
CancellationToken cancellationToken)
@@ -347,7 +409,7 @@ public sealed class GlobalLeaksClient
var fileName = SanitizeFileName(
ExtractFileName(response.Content.Headers.ContentDisposition),
$"report-{reportId}.zip");
$"report-{reportId}");
return new FileDownloadResult(content, fileName);
}
@@ -379,6 +441,164 @@ public sealed class GlobalLeaksClient
return new FileDownloadResult(content, $"report-{progressive}.json");
}
private async Task<JsonDocument> ReadReportDetailDocumentAsync(
string sessionId,
string reportId,
CancellationToken cancellationToken)
{
using var detailRequest = CreateAuthenticatedRequest(HttpMethod.Get, $"/api/recipient/rtips/{reportId}", sessionId);
using var detailResponse = await SendGlRequestAsync(detailRequest, cancellationToken);
var contentType = detailResponse.Content.Headers.ContentType?.MediaType ?? string.Empty;
var content = await detailResponse.Content.ReadAsByteArrayAsync(cancellationToken);
if (!contentType.Contains("json", StringComparison.OrdinalIgnoreCase) || content.Length == 0)
{
throw new GlobalLeaksValidationException(
"No se pudo leer el detalle de la denuncia. Puede estar cifrada sin clave disponible en el servidor.",
422);
}
return JsonDocument.Parse(content);
}
private async Task<IReadOnlyList<ReportCommentDto>> GetReportCommentsAsync(
string sessionId,
string reportId,
DateTimeOffset? reference,
bool defaultNewWhenNoReference,
CancellationToken cancellationToken)
{
using var request = CreateAuthenticatedRequest(HttpMethod.Get, $"/api/recipient/rtips/{reportId}/comments", sessionId);
using var response = await SendGlRequestAsync(request, cancellationToken);
var contentType = response.Content.Headers.ContentType?.MediaType ?? string.Empty;
var content = await response.Content.ReadAsByteArrayAsync(cancellationToken);
if (!contentType.Contains("json", StringComparison.OrdinalIgnoreCase) || content.Length == 0)
{
return [];
}
using var document = JsonDocument.Parse(content);
var root = document.RootElement;
var comments = root.ValueKind == JsonValueKind.Array
? root
: FindArrayProperty(root, "comments", "items", "data", "entries", "results");
if (comments is null || comments.Value.ValueKind != JsonValueKind.Array)
{
return [];
}
return comments.Value
.EnumerateArray()
.Select(item => CreateReportComment(item, reference, defaultNewWhenNoReference))
.ToArray();
}
private async Task<ReportActivitySnapshot> AnalyzeReportActivityAsync(
string sessionId,
ReportDto report,
DateTimeOffset? reference,
CancellationToken cancellationToken)
{
var reportId = report.Id;
using var document = await ReadReportDetailDocumentAsync(sessionId, reportId, cancellationToken);
IReadOnlyList<ReportCommentDto> comments;
try
{
comments = await GetReportCommentsAsync(
sessionId,
reportId,
reference,
defaultNewWhenNoReference: false,
cancellationToken);
}
catch (Exception ex) when (ex is GlobalLeaksValidationException or HttpRequestException or TaskCanceledException)
{
_logger.LogWarning(
ex,
"No se han podido leer los comentarios dedicados para analizar actividad de la denuncia {ReportId}. Se usara el detalle del tip si contiene comentarios.",
reportId);
comments = EnumerateArray(document.RootElement, "comments")
.Select(item => CreateReportComment(item, reference, defaultNewWhenNoReference: false))
.ToArray();
}
var whistleblowerFiles = ParseReportFiles(
document.RootElement,
reference,
defaultNewWhenNoReference: false,
"wbfiles",
"files");
var receiverFiles = ParseReportFiles(
document.RootElement,
reference,
defaultNewWhenNoReference: false,
"rfiles");
var citizenCommentDates = comments
.Where(comment => IsWhistleblowerActivityType(comment.Type))
.Select(comment => ParseDate(comment.CreationDate))
.Where(date => date is not null)
.Select(date => date!.Value);
var receiverCommentDates = comments
.Where(comment => IsReceiverActivityType(comment.Type))
.Select(comment => ParseDate(comment.CreationDate))
.Where(date => date is not null)
.Select(date => date!.Value);
var unclassifiedCommentDates = comments
.Where(comment => IsUnclassifiedActivityType(comment.Type))
.Select(comment => ParseDate(comment.CreationDate))
.Where(date => date is not null)
.Select(date => date!.Value);
var citizenFileDates = whistleblowerFiles
.Select(file => ParseDate(file.CreationDate))
.Where(date => date is not null)
.Select(date => date!.Value);
var receiverFileDates = receiverFiles
.Select(file => ParseDate(file.CreationDate))
.Where(date => date is not null)
.Select(date => date!.Value);
var reportCreationDate = ParseDate(report.CreationDate) ??
ParseDate(GetString(document.RootElement, "creation_date", "creationDate"));
var citizenActivityDates = reportCreationDate is null
? citizenCommentDates.Concat(citizenFileDates)
: citizenCommentDates.Concat(citizenFileDates).Append(reportCreationDate.Value);
var citizenLast = MaxDate(citizenActivityDates);
var receiverLast = MaxDate(receiverCommentDates.Concat(receiverFileDates).Concat(unclassifiedCommentDates));
return new ReportActivitySnapshot(
citizenLast,
comments.Any(comment =>
IsWhistleblowerActivityType(comment.Type) &&
comment.IsNew),
whistleblowerFiles.Any(file => file.IsNew),
receiverLast,
comments.Any(comment =>
IsReceiverActivityType(comment.Type) &&
comment.IsNew) ||
comments.Any(comment =>
IsUnclassifiedActivityType(comment.Type) &&
comment.IsNew) ||
receiverFiles.Any(file => file.IsNew));
}
private static ReportDto ApplyActivity(ReportDto report, ReportActivitySnapshot activity)
{
return report with
{
ActivityAnalyzed = true,
CitizenLastActivity = activity.CitizenLastActivity?.ToString("O", CultureInfo.InvariantCulture),
CitizenHasNewActivity = activity.HasNewCitizenComment || activity.HasNewCitizenFile,
CitizenHasNewComment = activity.HasNewCitizenComment,
CitizenHasNewFile = activity.HasNewCitizenFile,
ReceiverLastActivity = activity.ReceiverLastActivity?.ToString("O", CultureInfo.InvariantCulture),
ReceiverHasNewActivity = activity.HasNewReceiverActivity
};
}
private async Task<HttpResponseMessage> SendLoginRequestAsync(
HttpRequestMessage request,
string endpoint,
@@ -497,6 +717,49 @@ public sealed class GlobalLeaksClient
return Convert.ToBase64String(hash);
}
private static string CreateDpopProof(ECDsa privateKey, string method, string path)
{
var publicParameters = privateKey.ExportParameters(includePrivateParameters: false);
var jwk = new Dictionary<string, object?>
{
["kty"] = "EC",
["crv"] = "P-256",
["x"] = Base64UrlEncode(publicParameters.Q.X ?? []),
["y"] = Base64UrlEncode(publicParameters.Q.Y ?? []),
};
var header = new Dictionary<string, object?>
{
["typ"] = "dpop+jwt",
["alg"] = "ES256",
["jwk"] = jwk,
};
var payload = new Dictionary<string, object?>
{
["jti"] = Guid.NewGuid().ToString(),
["htm"] = method,
["htu"] = path,
["iat"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
};
var encodedHeader = Base64UrlEncode(JsonSerializer.SerializeToUtf8Bytes(header, JsonOptions));
var encodedPayload = Base64UrlEncode(JsonSerializer.SerializeToUtf8Bytes(payload, JsonOptions));
var signingInput = $"{encodedHeader}.{encodedPayload}";
var signature = privateKey.SignData(
Encoding.ASCII.GetBytes(signingInput),
HashAlgorithmName.SHA256,
DSASignatureFormat.IeeeP1363FixedFieldConcatenation);
return $"{signingInput}.{Base64UrlEncode(signature)}";
}
private static string Base64UrlEncode(byte[] bytes)
=> Convert.ToBase64String(bytes)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
private static byte[] ComputeArgon2(byte[] input, byte[] salt, int iterations, int memoryKb)
{
var argon2 = new Argon2id(input)
@@ -530,6 +793,34 @@ public sealed class GlobalLeaksClient
: null;
}
private static bool ShouldAnalyzeCitizenActivity(ReportDto report)
{
return report.Accessible != false && !string.IsNullOrWhiteSpace(report.Id);
}
private static DateTimeOffset? GetActivityReference(ReportDto report, DateTimeOffset? fallbackReference)
{
return ParseDate(report.LastDownloadedAt) ??
fallbackReference ??
ParseDate(report.LastAccess) ??
ParseDate(report.AccessDate) ??
ParseDate(report.CreationDate);
}
private static DateTimeOffset? MaxDate(IEnumerable<DateTimeOffset> values)
{
DateTimeOffset? max = null;
foreach (var value in values)
{
if (max is null || value > max.Value)
{
max = value;
}
}
return max;
}
private static string ExtractName(JsonElement? name, string fallback)
{
if (name is null)
@@ -636,8 +927,10 @@ public sealed class GlobalLeaksClient
ReminderDate = GetString(item, "reminder_date", "reminderDate"),
AccessDate = GetString(item, "access_date", "accessDate"),
LastAccess = GetString(item, "last_access", "lastAccess"),
WhistleblowerLastAccess = GetString(item, "wb_last_access", "wbLastAccess"),
Status = GetString(item, "status"),
Updated = GetBool(item, "updated"),
Accessible = GetNullableBool(item, "accessible"),
Label = GetString(item, "label"),
});
}
@@ -645,53 +938,192 @@ public sealed class GlobalLeaksClient
return reports;
}
private static ReportDetailDto ParseReportDetail(string reportId, string? lastAccess, JsonElement root)
private static ReportDetailDto ParseReportDetail(
string reportId,
string? lastAccess,
JsonElement root,
IReadOnlyList<ReportCommentDto>? commentsOverride = null)
{
var lastAccessDate = ParseDate(lastAccess);
bool IsNew(string? value)
{
if (lastAccessDate is null)
{
return true;
}
var itemDate = ParseDate(value);
return itemDate is not null && itemDate > lastAccessDate;
}
var comments = EnumerateArray(root, "comments")
.Select(item => new ReportCommentDto(
GetString(item, "id"),
GetString(item, "type"),
GetString(item, "content", "text", "message"),
GetString(item, "creation_date", "creationDate"),
IsNew(GetString(item, "creation_date", "creationDate"))))
var comments = commentsOverride ?? EnumerateArray(root, "comments")
.Select(item => CreateReportComment(item, lastAccessDate, defaultNewWhenNoReference: true))
.ToArray();
var whistleblowerFiles = EnumerateArray(root, "wbfiles", "files")
.Select(item => new ReportFileDto(
GetString(item, "id"),
GetLocalizedString(item, "name", "file_name", "filename"),
GetInt64(item, "size"),
GetString(item, "content_type", "contentType", "mime_type", "mimetype"),
GetString(item, "creation_date", "creationDate"),
IsNew(GetString(item, "creation_date", "creationDate"))))
.ToArray();
var receiverFiles = EnumerateArray(root, "rfiles")
.Select(item => new ReportFileDto(
GetString(item, "id"),
GetLocalizedString(item, "name", "file_name", "filename"),
GetInt64(item, "size"),
GetString(item, "content_type", "contentType", "mime_type", "mimetype"),
GetString(item, "creation_date", "creationDate"),
IsNew(GetString(item, "creation_date", "creationDate"))))
.ToArray();
var whistleblowerFiles = ParseReportFiles(root, lastAccessDate, defaultNewWhenNoReference: true, "wbfiles", "files");
var receiverFiles = ParseReportFiles(root, lastAccessDate, defaultNewWhenNoReference: true, "rfiles");
return new ReportDetailDto(reportId, lastAccess, comments, whistleblowerFiles, receiverFiles);
}
private static ReportCommentDto CreateReportComment(
JsonElement item,
DateTimeOffset? reference,
bool defaultNewWhenNoReference)
{
var creationDate = GetString(item, "creation_date", "creationDate", "date", "created_at", "createdAt");
return new ReportCommentDto(
GetString(item, "id"),
GetCommentActivityType(item),
GetString(item, "content", "text", "message"),
creationDate,
IsAfterReference(creationDate, reference, defaultNewWhenNoReference));
}
private static string? GetCommentActivityType(JsonElement item)
{
if (GetBool(item, "whistleblower", "from_whistleblower", "fromWhistleblower", "is_whistleblower", "isWhistleblower"))
{
return "whistleblower";
}
if (GetBool(item, "receiver", "recipient", "from_receiver", "fromReceiver", "is_receiver", "isReceiver", "admin", "operator", "staff"))
{
return "receiver";
}
var direct = GetString(
item,
"type",
"kind",
"author_type",
"authorType",
"author_role",
"authorRole",
"role",
"user_role",
"userRole",
"sender",
"sender_type",
"senderType",
"source",
"actor",
"creator",
"from");
if (!string.IsNullOrWhiteSpace(direct))
{
return direct;
}
return GetNestedCommentActivityType(item, "author", "user", "creator", "sender", "actor", "owner");
}
private static string? GetNestedCommentActivityType(JsonElement item, params string[] names)
{
foreach (var name in names)
{
if (!item.TryGetProperty(name, out var property))
{
continue;
}
if (property.ValueKind == JsonValueKind.String)
{
return property.GetString();
}
if (property.ValueKind != JsonValueKind.Object)
{
continue;
}
var nested = GetString(
property,
"type",
"kind",
"role",
"user_role",
"userRole",
"author_type",
"authorType",
"name",
"username",
"display_name",
"displayName");
if (!string.IsNullOrWhiteSpace(nested))
{
return nested;
}
}
return null;
}
private static bool IsWhistleblowerActivityType(string? value)
{
return MatchesAny(value, "whistleblower", "citizen", "source", "tipper", "submitter", "denunciante");
}
private static bool IsReceiverActivityType(string? value)
{
return MatchesAny(value, "receiver", "recipient", "admin", "administrator", "operator", "staff", "custodian", "moderator", "gestor", "oaaf");
}
private static bool IsUnclassifiedActivityType(string? value)
{
return !IsWhistleblowerActivityType(value) && !IsReceiverActivityType(value);
}
private static bool MatchesAny(string? value, params string[] candidates)
{
if (string.IsNullOrWhiteSpace(value))
{
return false;
}
var normalized = NormalizeActivityType(value);
return candidates.Any(candidate =>
{
var candidateToken = NormalizeActivityType(candidate);
return normalized == candidateToken ||
normalized.StartsWith(candidateToken + " ", StringComparison.Ordinal) ||
normalized.EndsWith(" " + candidateToken, StringComparison.Ordinal) ||
normalized.Contains(" " + candidateToken + " ", StringComparison.Ordinal);
});
}
private static string NormalizeActivityType(string value)
{
return Regex.Replace(value.Trim().ToLowerInvariant(), @"[^a-z0-9]+", " ").Trim();
}
private static ReportFileDto[] ParseReportFiles(
JsonElement root,
DateTimeOffset? reference,
bool defaultNewWhenNoReference,
params string[] propertyNames)
{
return EnumerateArray(root, propertyNames)
.Select(item =>
{
var creationDate = GetString(item, "creation_date", "creationDate");
return new ReportFileDto(
GetString(item, "id"),
GetLocalizedString(item, "name", "file_name", "filename"),
GetInt64(item, "size"),
GetString(item, "content_type", "contentType", "mime_type", "mimetype"),
creationDate,
IsAfterReference(creationDate, reference, defaultNewWhenNoReference));
})
.ToArray();
}
private static bool IsAfterReference(
string? value,
DateTimeOffset? reference,
bool defaultWhenNoReference)
{
if (reference is null)
{
return defaultWhenNoReference;
}
var itemDate = ParseDate(value);
return itemDate is not null && itemDate > reference;
}
private static IEnumerable<JsonElement> EnumerateArray(JsonElement root, params string[] names)
{
if (root.ValueKind != JsonValueKind.Object)
@@ -839,6 +1271,28 @@ public sealed class GlobalLeaksClient
return false;
}
private static bool? GetNullableBool(JsonElement element, params string[] names)
{
foreach (var name in names)
{
if (element.TryGetProperty(name, out var property))
{
if (property.ValueKind is JsonValueKind.True or JsonValueKind.False)
{
return property.GetBoolean();
}
if (property.ValueKind == JsonValueKind.String &&
bool.TryParse(property.GetString(), out var value))
{
return value;
}
}
}
return null;
}
private static string SanitizeFileName(string? name, string fallback)
{
if (string.IsNullOrWhiteSpace(name))
@@ -922,8 +1376,20 @@ public sealed class GlobalLeaksClient
body.Contains("Invalid request: No token", StringComparison.OrdinalIgnoreCase));
}
private static bool IsInvalidDpopProof(string body)
{
return !string.IsNullOrWhiteSpace(body) &&
body.Contains("Invalid DPoP proof", StringComparison.OrdinalIgnoreCase);
}
private sealed record TokenResponse(string Id, string Salt);
private sealed record AuthTypeResponse(string Type, string Salt);
private sealed record ReportActivitySnapshot(
DateTimeOffset? CitizenLastActivity,
bool HasNewCitizenComment,
bool HasNewCitizenFile,
DateTimeOffset? ReceiverLastActivity,
bool HasNewReceiverActivity);
private sealed record RawReport
{
@@ -936,8 +1402,10 @@ public sealed class GlobalLeaksClient
public string? ReminderDate { get; init; }
public string? AccessDate { get; init; }
public string? LastAccess { get; init; }
public string? WhistleblowerLastAccess { get; init; }
public string? Status { get; init; }
public bool Updated { get; init; }
public bool? Accessible { get; init; }
public string? Label { get; init; }
}
}

View File

@@ -8,7 +8,7 @@ namespace ApiDenuncias.Services;
public sealed class GlobalLeaksSessionStore
{
private const string RootPath = @"C:\ZipsDenuncias\.gl-auth";
private const string RootPath = @"C:\GestionaDenuncias\.gl-auth";
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = false,

View File

@@ -16,4 +16,8 @@ public interface IFilteredDenunciaStore
Task<List<FicherosDenuncias>> GetFicherosByDenunciaIdsAsync(
IReadOnlyCollection<int> denunciaIds,
CancellationToken cancellationToken = default);
Task<List<FicherosDenuncias>> GetFicherosMetadataByDenunciaAsync(
int denunciaId,
CancellationToken cancellationToken = default);
}

View File

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

View File

@@ -262,6 +262,106 @@ public sealed class InboxTrackingService : IInboxTrackingService
}
}
public async Task MarkReportHandledInGestionaAsync(
string username,
int denunciaId,
DateTime uploadedAtUtc,
CancellationToken cancellationToken = default)
{
await _denunciaStore.EnsureSchemaAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(username) || denunciaId <= 0)
{
return;
}
var handledAtUtc = uploadedAtUtc.Kind switch
{
DateTimeKind.Utc => uploadedAtUtc,
DateTimeKind.Local => uploadedAtUtc.ToUniversalTime(),
_ => DateTime.SpecifyKind(uploadedAtUtc, DateTimeKind.Utc)
};
await using var connection = await OpenConnectionAsync(cancellationToken);
var userId = await EnsureUserAsync(connection, username, cancellationToken);
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
try
{
const string updateInboxSql = """
UPDATE inbox_reports
SET
last_downloaded_at_utc =
CASE
WHEN last_downloaded_at_utc IS NULL THEN @handledAtUtc
WHEN @handledAtUtc > last_downloaded_at_utc THEN @handledAtUtc
ELSE last_downloaded_at_utc
END,
last_downloaded_by_user_id = @userId,
imported_complaint_report_id = COALESCE(imported_complaint_report_id, @denunciaId),
imported_to_store_at_utc = COALESCE(imported_to_store_at_utc, @handledAtUtc),
updated_at_utc = CURRENT_TIMESTAMP(6)
WHERE progressive_id = @denunciaId
OR imported_complaint_report_id = @denunciaId;
""";
await using (var updateInbox = new MySqlCommand(updateInboxSql, connection, (MySqlTransaction)transaction))
{
updateInbox.Parameters.AddWithValue("@handledAtUtc", handledAtUtc);
updateInbox.Parameters.AddWithValue("@userId", userId);
updateInbox.Parameters.AddWithValue("@denunciaId", denunciaId);
await updateInbox.ExecuteNonQueryAsync(cancellationToken);
}
const string updateUserReportSql = """
INSERT INTO user_inbox_reports (
app_user_id,
inbox_report_id,
first_seen_at_utc,
last_seen_at_utc,
first_downloaded_at_utc,
last_downloaded_at_utc,
download_count
)
SELECT
@userId,
ir.id,
CURRENT_TIMESTAMP(6),
CURRENT_TIMESTAMP(6),
@handledAtUtc,
@handledAtUtc,
1
FROM inbox_reports ir
WHERE ir.progressive_id = @denunciaId
OR ir.imported_complaint_report_id = @denunciaId
ON DUPLICATE KEY UPDATE
last_seen_at_utc = CURRENT_TIMESTAMP(6),
first_downloaded_at_utc = COALESCE(first_downloaded_at_utc, VALUES(first_downloaded_at_utc)),
last_downloaded_at_utc =
CASE
WHEN last_downloaded_at_utc IS NULL THEN VALUES(last_downloaded_at_utc)
WHEN VALUES(last_downloaded_at_utc) > last_downloaded_at_utc THEN VALUES(last_downloaded_at_utc)
ELSE last_downloaded_at_utc
END;
""";
await using (var updateUserReport = new MySqlCommand(updateUserReportSql, connection, (MySqlTransaction)transaction))
{
updateUserReport.Parameters.AddWithValue("@userId", userId);
updateUserReport.Parameters.AddWithValue("@handledAtUtc", handledAtUtc);
updateUserReport.Parameters.AddWithValue("@denunciaId", denunciaId);
await updateUserReport.ExecuteNonQueryAsync(cancellationToken);
}
await transaction.CommitAsync(cancellationToken);
}
catch
{
await SafeRollbackAsync(transaction, cancellationToken);
throw;
}
}
private async Task<long> EnsureUserAsync(MySqlConnection connection, string username, CancellationToken cancellationToken)
{
const string insertSql = """

View File

@@ -29,6 +29,26 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
""";
private const string GestionaUploadHistoryTableSql = """
CREATE TABLE IF NOT EXISTS gestiona_upload_history (
id BIGINT NOT NULL AUTO_INCREMENT,
external_report_id INT NOT NULL,
gestiona_file_url TEXT NOT NULL,
gestiona_file_code VARCHAR(128) NOT NULL DEFAULT '',
upload_type VARCHAR(64) NOT NULL DEFAULT '',
assigned_group_code VARCHAR(32) NOT NULL DEFAULT '',
uploaded_by_username VARCHAR(256) NOT NULL DEFAULT '',
uploaded_at_utc DATETIME(6) NOT NULL,
subject TEXT NULL,
document_names TEXT NULL,
created_at_utc DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
PRIMARY KEY (id),
KEY ix_gestiona_upload_history_report (external_report_id),
KEY ix_gestiona_upload_history_uploaded_at (uploaded_at_utc),
KEY ix_gestiona_upload_history_user (uploaded_by_username)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
""";
private const string ComplaintSelectColumns = """
external_registry_id,
external_report_id,
@@ -94,6 +114,8 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
workflow_status,
selected_document_name,
gestiona_uploaded_at_utc,
gestiona_last_upload_type,
gestiona_assigned_group,
is_in_gestiona,
is_rejected,
key_date,
@@ -118,6 +140,23 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
a.encrypted_at_utc
""";
private const string AttachmentMetadataSelectColumns = """
a.id,
a.attachment_type_id,
a.description,
a.attachment_date_utc,
a.notes,
c.external_report_id,
a.original_file_name,
NULL AS content,
a.content_sha256,
a.uploaded_to_gestiona,
a.uploaded_at_utc,
a.key_date,
a.encryption_scheme,
a.encrypted_at_utc
""";
private static readonly (string Table, string Column, string Definition)[] SchemaColumnsToEnsure =
[
("complaints", "gestiona_file_code", "`gestiona_file_code` VARCHAR(128) NOT NULL DEFAULT ''"),
@@ -143,6 +182,8 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
("complaints", "key_date", "`key_date` DATE NULL"),
("complaints", "encryption_scheme", "`encryption_scheme` VARCHAR(64) NOT NULL DEFAULT 'none'"),
("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 ''"),
("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'"),
@@ -211,6 +252,8 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
("complaints", "`display_name` TEXT NOT NULL"),
("complaints", "`workflow_status` TEXT NOT NULL"),
("complaints", "`selected_document_name` TEXT NULL"),
("complaints", "`gestiona_last_upload_type` TEXT NOT NULL"),
("complaints", "`gestiona_assigned_group` TEXT NOT NULL"),
("complaint_attachments", "`description` TEXT NULL"),
("complaint_attachments", "`notes` TEXT NOT NULL"),
];
@@ -422,6 +465,68 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
return result;
}
public async Task<List<FicherosDenuncias>> GetFicherosMetadataByDenunciaAsync(
int denunciaId,
CancellationToken cancellationToken = default)
{
await EnsureSchemaReadyAsync(cancellationToken);
var sql = $"""
SELECT
{AttachmentMetadataSelectColumns}
FROM complaint_attachments a
INNER JOIN complaints c ON c.id = a.complaint_id
WHERE c.external_report_id = @denunciaId
ORDER BY a.original_file_name ASC;
""";
await using var connection = await OpenConnectionAsync(cancellationToken);
await using var command = new MySqlCommand(sql, connection);
command.Parameters.AddWithValue("@denunciaId", denunciaId);
var result = new List<FicherosDenuncias>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
result.Add(MapAttachment(reader));
}
return result;
}
public async Task<List<GestionaUploadHistoryEntry>> GetGestionaUploadHistoryAsync(CancellationToken cancellationToken = default)
{
await EnsureSchemaReadyAsync(cancellationToken);
const string sql = """
SELECT
id,
external_report_id,
gestiona_file_url,
gestiona_file_code,
upload_type,
assigned_group_code,
uploaded_by_username,
uploaded_at_utc,
subject,
document_names
FROM gestiona_upload_history
ORDER BY uploaded_at_utc DESC, id DESC;
""";
await using var connection = await OpenConnectionAsync(cancellationToken);
await using var command = new MySqlCommand(sql, connection);
var result = new List<GestionaUploadHistoryEntry>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
result.Add(MapGestionaUploadHistory(reader));
}
return result;
}
public async Task<DenunciasGestiona?> GetDenunciaByIdAsync(
int denunciaId,
CancellationToken cancellationToken = default)
@@ -446,6 +551,60 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
: null;
}
public async Task AddGestionaUploadHistoryAsync(
GestionaUploadHistoryCreateRequest request,
string username,
CancellationToken cancellationToken = default)
{
await EnsureSchemaReadyAsync(cancellationToken);
const string sql = """
INSERT INTO gestiona_upload_history (
external_report_id,
gestiona_file_url,
gestiona_file_code,
upload_type,
assigned_group_code,
uploaded_by_username,
uploaded_at_utc,
subject,
document_names
)
SELECT
@externalReportId,
@gestionaFileUrl,
@gestionaFileCode,
@uploadType,
@assignedGroupCode,
@uploadedByUsername,
@uploadedAtUtc,
@subject,
@documentNames
WHERE NOT EXISTS (
SELECT 1
FROM gestiona_upload_history
WHERE external_report_id = @externalReportId
AND uploaded_at_utc = @uploadedAtUtc
AND upload_type = @uploadType
LIMIT 1
);
""";
await using var connection = await OpenConnectionAsync(cancellationToken);
await using var command = new MySqlCommand(sql, connection);
command.Parameters.AddWithValue("@externalReportId", request.DenunciaId);
command.Parameters.AddWithValue("@gestionaFileUrl", request.ExpedienteGestionaUrl ?? string.Empty);
command.Parameters.AddWithValue("@gestionaFileCode", request.CodigoExpedienteGestiona ?? string.Empty);
command.Parameters.AddWithValue("@uploadType", request.TipoSubida ?? string.Empty);
command.Parameters.AddWithValue("@assignedGroupCode", ExtractGroupCode(request.GrupoAsignado));
command.Parameters.AddWithValue("@uploadedByUsername", string.IsNullOrWhiteSpace(username) ? "(sin usuario)" : username.Trim());
command.Parameters.AddWithValue("@uploadedAtUtc", request.UploadedAtUtc == DateTime.MinValue ? DateTime.UtcNow : request.UploadedAtUtc);
command.Parameters.AddWithValue("@subject", ToDbStringOrNull(request.Asunto));
command.Parameters.AddWithValue("@documentNames", ToDbStringOrNull(request.Documentos));
await command.ExecuteNonQueryAsync(cancellationToken);
}
public async Task UpsertDenunciaAsync(DenunciasGestiona denuncia, CancellationToken cancellationToken = default)
{
await EnsureSchemaReadyAsync(cancellationToken);
@@ -516,6 +675,8 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
workflow_status,
selected_document_name,
gestiona_uploaded_at_utc,
gestiona_last_upload_type,
gestiona_assigned_group,
is_in_gestiona,
is_rejected,
key_date,
@@ -586,6 +747,8 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
@workflowStatus,
@selectedDocumentName,
@gestionaUploadedAtUtc,
@gestionaLastUploadType,
@gestionaAssignedGroup,
@isInGestiona,
@isRejected,
@keyDate,
@@ -656,6 +819,8 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
workflow_status = VALUES(workflow_status),
selected_document_name = VALUES(selected_document_name),
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),
is_in_gestiona = VALUES(is_in_gestiona),
is_rejected = VALUES(is_rejected),
key_date = VALUES(key_date),
@@ -731,6 +896,8 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
command.Parameters.AddWithValue("@workflowStatus", denuncia.EstadoDenuncia ?? string.Empty);
command.Parameters.AddWithValue("@selectedDocumentName", ToDbStringOrNull(denuncia.ArchivoElegido));
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("@isInGestiona", denuncia.EnGestiona);
command.Parameters.AddWithValue("@isRejected", denuncia.EnRechazada);
command.Parameters.AddWithValue("@keyDate", ToDbDate(denuncia.KeyDate));
@@ -799,12 +966,10 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
content_mime_type = @contentMimeType,
content_sha256 = @contentSha256,
uploaded_to_gestiona = CASE
WHEN LOWER(@originalFileName) = 'report.txt' OR LOWER(@originalFileName) = 'report.pdf' THEN @uploadedToGestiona
WHEN complaint_attachments.content_sha256 = @contentSha256 THEN complaint_attachments.uploaded_to_gestiona
ELSE @uploadedToGestiona
END,
uploaded_at_utc = CASE
WHEN LOWER(@originalFileName) = 'report.txt' OR LOWER(@originalFileName) = 'report.pdf' THEN @uploadedAtUtc
WHEN complaint_attachments.content_sha256 = @contentSha256 THEN complaint_attachments.uploaded_at_utc
ELSE @uploadedAtUtc
END,
@@ -1125,6 +1290,7 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
CancellationToken cancellationToken)
{
await EnsureAttachmentChunksTableAsync(connection, cancellationToken);
await EnsureGestionaUploadHistoryTableAsync(connection, cancellationToken);
foreach (var (table, column, definition) in SchemaColumnsToEnsure)
{
@@ -1167,6 +1333,15 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
await command.ExecuteNonQueryAsync(cancellationToken);
}
private static async Task EnsureGestionaUploadHistoryTableAsync(
MySqlConnection connection,
CancellationToken cancellationToken)
{
await using var command = connection.CreateCommand();
command.CommandText = GestionaUploadHistoryTableSql;
await command.ExecuteNonQueryAsync(cancellationToken);
}
private static string ExtractColumnName(string definition)
{
var first = definition.IndexOf('`');
@@ -1462,6 +1637,8 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
EstadoDenuncia = GetString(record, "workflow_status"),
ArchivoElegido = GetString(record, "selected_document_name"),
FechaSubidaAGestiona = GetDateTime(record, "gestiona_uploaded_at_utc"),
UltimaSubidaGestionaTipo = GetString(record, "gestiona_last_upload_type"),
UltimoGrupoAsignadoGestiona = GetString(record, "gestiona_assigned_group"),
EnGestiona = GetBoolean(record, "is_in_gestiona"),
EnRechazada = GetBoolean(record, "is_rejected"),
KeyDate = GetNullableDateOnly(record, "key_date"),
@@ -1491,6 +1668,33 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
};
}
private static GestionaUploadHistoryEntry MapGestionaUploadHistory(IDataRecord record)
{
return new GestionaUploadHistoryEntry(
Convert.ToInt64(record["id"]),
GetInt32(record, "external_report_id"),
GetString(record, "gestiona_file_url"),
GetString(record, "gestiona_file_code"),
GetString(record, "upload_type"),
ExtractGroupCode(GetString(record, "assigned_group_code")),
GetString(record, "uploaded_by_username"),
GetDateTime(record, "uploaded_at_utc"),
GetString(record, "subject"),
GetString(record, "document_names"));
}
private static string ExtractGroupCode(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return string.Empty;
}
var trimmed = value.Trim();
var code = new string(trimmed.TakeWhile(char.IsDigit).ToArray());
return string.IsNullOrWhiteSpace(code) ? trimmed : code;
}
private static object ToDbDate(DateTime value)
{
return value == DateTime.MinValue ? DBNull.Value : value;

View File

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

View File

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

View File

@@ -36,12 +36,16 @@ public sealed record MarkReportImportedRequest(
ReportDto Report,
int? ComplaintId);
public sealed record MarkReportHandledInGestionaRequest(
string Username,
int DenunciaId,
DateTime UploadedAtUtc);
public sealed record TrackingImportPermissionRequest(
string Username,
ReportDto Report);
public sealed record GestionaCreateFileRequest(
Guid ProcedureId,
string Subject,
string DocumentSeries,
string SiaCode);
@@ -53,16 +57,22 @@ public sealed record GestionaCreateFileResponse(
public sealed record GestionaOpenFileRequest(
string FileUrl,
string? FileOpenUrl,
Guid ManagementUnitGroupId,
Guid AssignedGroupId,
string AssignedGroupCode,
bool Confidential,
string FreeTitle,
string SiaCode);
string FreeTitle);
public sealed record GestionaAssignFileRequest(
string FileUrl,
string AssignedGroupCode);
public sealed record GestionaEnsureThirdRequest(
string FileUrl,
ThirdPartyIdentityData ThirdParty);
public sealed record GestionaEnsureThirdResponse(
bool Ok,
IReadOnlyList<string> Warnings);
public sealed record GestionaCreateFolderRequest(
string FileUrl,
string FolderName);
@@ -80,8 +90,9 @@ public sealed record GestionaUploadDocumentResponse(string DocumentUrl);
public sealed record GestionaTramitarDocumentoRequest(
string DocumentUrl,
string AssignedGroupHref,
int? ComplaintId);
string AssignedGroupCode,
int? ComplaintId,
bool IsUpdate = false);
public sealed record ManualPurgeRequest(string Date);
@@ -91,6 +102,23 @@ public sealed record ManualPurgeResponse(
int StatusCode,
string ResponseBody);
public sealed record AppConfigurationDto(string? ExternalUpdateCutoffDate);
public sealed record AppConfigurationDto(
string? ExternalUpdateCutoffDate,
string? LatestEncryptionKeyDate = null,
string? LatestEncryptionKeyStatus = null);
public sealed record UpdateExternalUpdateCutoffRequest(string? Date);
public sealed record GestionaComplaintFieldsResponse(
DateTime? FechaDenuncia,
int NumeroDenunciaCanal,
string AQuienDenuncia,
string ResumenDenuncia,
string FechaHechos,
string LugarHechos,
string AmbitoCompetencias,
string SolicitaProteccion,
string SexoDenunciante,
string AutorizaRemisionDenuncia,
string AutorizaNotificacionesViaSms,
string PreferenciaNotificacionSeguimientoDenuncia);

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,9 +12,18 @@ public sealed record ReportDto
public string? ReminderDate { get; init; }
public string? AccessDate { get; init; }
public string? LastAccess { get; init; }
public string? WhistleblowerLastAccess { get; init; }
public string? Status { get; init; }
public bool Updated { get; init; }
public bool? Accessible { get; init; }
public string? Label { get; init; }
public bool ActivityAnalyzed { get; init; }
public string? CitizenLastActivity { get; init; }
public bool CitizenHasNewActivity { get; init; }
public bool CitizenHasNewComment { get; init; }
public bool CitizenHasNewFile { get; init; }
public string? ReceiverLastActivity { get; init; }
public bool ReceiverHasNewActivity { get; init; }
public bool DownloadedByCurrentUser { get; init; }
public bool DownloadedByAnotherUser { get; init; }
public string? LastDownloadedByUsername { get; init; }

View File

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

View File

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

View File

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

View File

@@ -1,10 +1,12 @@
@inherits LayoutComponentBase
@implements IDisposable
@using System.Globalization
@inject GestionaDenunciasAN.Models.UserState userState
@inject IHttpContextAccessor HttpContextAccessor
@inject IJSRuntime JSRuntime
@inject NavigationManager Navigation
@inject UiBusyService Busy
@inject ApiDenunciasClient ApiDenuncias
<div class="app-shell">
<aside class="app-sidebar">
@@ -20,9 +22,16 @@
</div>
<div class="app-header__actions">
<div class="app-session-pill">
<span class="app-session-pill__dot"></span>
Sesion interna activa
<div class="app-status-pills">
<div class="app-session-pill">
<span class="app-session-pill__dot"></span>
Sesion interna activa
</div>
<div class="@EncryptionKeyPillCss" title="@EncryptionKeyTooltip">
<span class="app-session-pill__dot"></span>
@EncryptionKeyText
</div>
</div>
<button type="button" class="app-user-chip" @onclick="CerrarSesionAsync">
@@ -53,6 +62,9 @@
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 string DisplayUsername =>
string.IsNullOrWhiteSpace(userState?.NombreUsu)
@@ -65,6 +77,11 @@
RefreshLayoutState();
}
protected override async Task OnInitializedAsync()
{
await LoadEncryptionKeyStatusAsync();
}
protected override void OnParametersSet()
{
RefreshLayoutState();
@@ -78,7 +95,75 @@
private void HandleLocationChanged(object? sender, LocationChangedEventArgs args)
{
RefreshLayoutState();
_ = InvokeAsync(StateHasChanged);
_ = InvokeAsync(async () =>
{
await LoadEncryptionKeyStatusAsync();
StateHasChanged();
});
}
private async Task LoadEncryptionKeyStatusAsync()
{
try
{
var config = await ApiDenuncias.GetAppConfigurationAsync();
var keyDate = ToSpanishDateText(config.LatestEncryptionKeyDate);
var keyStatus = ToSpanishKeyStatus(config.LatestEncryptionKeyStatus);
if (string.IsNullOrWhiteSpace(keyDate))
{
EncryptionKeyText = "Clave diaria: no disponible";
EncryptionKeyTooltip = "No se ha encontrado informacion de la clave diaria de cifrado.";
EncryptionKeyPillCss = "app-session-pill app-key-pill app-key-pill--warning";
return;
}
EncryptionKeyText = string.IsNullOrWhiteSpace(keyStatus)
? $"Clave diaria: {keyDate}"
: $"Clave diaria: {keyDate} ({keyStatus})";
EncryptionKeyTooltip = "Ultima clave diaria de cifrado registrada por la API.";
EncryptionKeyPillCss = string.Equals(config.LatestEncryptionKeyStatus, "active", StringComparison.OrdinalIgnoreCase)
? "app-session-pill app-key-pill"
: "app-session-pill app-key-pill app-key-pill--warning";
}
catch
{
EncryptionKeyText = "Clave diaria: no disponible";
EncryptionKeyTooltip = "No se ha podido consultar la clave diaria de cifrado.";
EncryptionKeyPillCss = "app-session-pill app-key-pill app-key-pill--warning";
}
}
private static string ToSpanishDateText(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return string.Empty;
}
return DateOnly.TryParseExact(
value.Trim(),
"yyyy-MM-dd",
CultureInfo.InvariantCulture,
DateTimeStyles.None,
out var date)
? date.ToString("dd/MM/yyyy", CultureInfo.InvariantCulture)
: value.Trim();
}
private static string ToSpanishKeyStatus(string? status)
{
if (string.IsNullOrWhiteSpace(status))
{
return string.Empty;
}
return status.Trim().ToLowerInvariant() switch
{
"active" => "activa",
"purged" => "purgada",
_ => status.Trim()
};
}
private void RefreshLayoutState()
@@ -98,7 +183,7 @@
return path.ToLowerInvariant() switch
{
"" or "gestionzip" => (
"" or "entrada" => (
"Entrada de denuncias",
"Importa lo nuevo desde GlobalLeaks, revisa el seguimiento por usuario y decide si habra expediente nuevo o actualizacion."),
"pendientes" => (

View File

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

View File

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

View File

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

View File

@@ -4,14 +4,34 @@
@using System.Globalization
@using GestionaDenunciasAN.Services
@using Microsoft.AspNetCore.Components.Authorization
@inject ApiDenunciasClient ApiDenuncias
@inject AuthenticationStateProvider AuthenticationStateProvider
@inject UiBusyService Busy
<PageTitle>Configuracion</PageTitle>
<h3>Configuracion</h3>
@if (!hasCheckedConfigurationAccess)
{
<div class="card mt-3">
<div class="card-body">
<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
Cargando configuracion...
</div>
</div>
}
else if (!hasConfigurationAccess)
{
<div class="alert alert-warning mt-3">
<strong>Configuracion restringida.</strong>
<div>Esta pantalla solo esta disponible para los usuarios autorizados del sistema.</div>
</div>
}
else
{
<div class="card mt-3">
<div class="card-body">
<h5 class="mb-3">Fecha de actualizaciones externas</h5>
@@ -79,6 +99,21 @@
<div class="card-body">
<h5 class="mb-3">Purga manual con reemplazo</h5>
<div class="border rounded px-3 py-2 bg-light mb-3">
<div class="d-flex flex-wrap align-items-center gap-2">
<span class="bi bi-key-fill text-primary" aria-hidden="true"></span>
<strong>Ultima clave diaria registrada</strong>
<span class="text-muted">@(string.IsNullOrWhiteSpace(latestEncryptionKeyDate) ? "No disponible" : latestEncryptionKeyDate)</span>
@if (!string.IsNullOrWhiteSpace(latestEncryptionKeyStatus))
{
<span class="badge rounded-pill text-bg-secondary">@latestEncryptionKeyStatus</span>
}
</div>
<div class="form-text mt-1">
Es la fecha de la fila mas reciente en <code>encryption_keys</code>; sirve para comprobar rapidamente que la rotacion diaria esta llegando a la base de datos.
</div>
</div>
<div class="alert alert-danger">
<div class="d-flex gap-3">
<span class="bi bi-exclamation-triangle-fill fs-3" aria-hidden="true"></span>
@@ -169,11 +204,22 @@
<div>@purgeErrorMessage</div>
</div>
}
}
@code {
private const string RequiredConfirmation = "PURGAR CLAVE ACTUAL";
private static readonly HashSet<string> ConfigurationUsers = new(StringComparer.OrdinalIgnoreCase)
{
"pcornejo",
"Rgarciaglbk",
"eaguilarGestor"
};
private bool hasCheckedConfigurationAccess;
private bool hasConfigurationAccess;
private string externalUpdateCutoffDate = string.Empty;
private string latestEncryptionKeyDate = string.Empty;
private string latestEncryptionKeyStatus = string.Empty;
private string? configurationNotice;
private string? configurationError;
private bool isSavingConfiguration;
@@ -191,6 +237,14 @@
protected override async Task OnInitializedAsync()
{
var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync();
hasConfigurationAccess = IsConfigurationUser(authState.User.Identity?.Name);
hasCheckedConfigurationAccess = true;
if (!hasConfigurationAccess)
{
return;
}
await LoadConfigurationAsync();
}
@@ -201,6 +255,8 @@
configurationError = null;
var config = await ApiDenuncias.GetAppConfigurationAsync();
externalUpdateCutoffDate = ToSpanishDateText(config.ExternalUpdateCutoffDate);
latestEncryptionKeyDate = ToSpanishDateText(config.LatestEncryptionKeyDate);
latestEncryptionKeyStatus = ToSpanishKeyStatus(config.LatestEncryptionKeyStatus);
}
catch (Exception ex)
{
@@ -219,6 +275,8 @@
var config = await ApiDenuncias.UpdateExternalUpdateCutoffDateAsync(date);
externalUpdateCutoffDate = ToSpanishDateText(config.ExternalUpdateCutoffDate);
latestEncryptionKeyDate = ToSpanishDateText(config.LatestEncryptionKeyDate);
latestEncryptionKeyStatus = ToSpanishKeyStatus(config.LatestEncryptionKeyStatus);
configurationNotice = string.IsNullOrWhiteSpace(externalUpdateCutoffDate)
? "Fecha de corte eliminada."
: $"Fecha de corte guardada: {externalUpdateCutoffDate}.";
@@ -287,6 +345,25 @@
: value.Trim();
}
private static string ToSpanishKeyStatus(string? status)
{
if (string.IsNullOrWhiteSpace(status))
{
return string.Empty;
}
return status.Trim().ToLowerInvariant() switch
{
"active" => "activa",
"purged" => "purgada",
_ => status.Trim()
};
}
private static bool IsConfigurationUser(string? username)
=> !string.IsNullOrWhiteSpace(username) &&
ConfigurationUsers.Contains(username.Trim());
private async Task ExecutePurgeAsync()
{
if (!CanExecutePurge)
@@ -305,6 +382,7 @@
try
{
purgeResult = await ApiDenuncias.ExecuteCurrentManualPurgeAsync();
await LoadConfigurationAsync();
acceptedRisk = false;
confirmation = string.Empty;
}

View File

@@ -1,8 +1,9 @@
@page "/GestionZip"
@page "/Entrada"
@rendermode @(new InteractiveServerRenderMode(prerender: false))
@attribute [Authorize]
@implements IAsyncDisposable
@using System.Globalization
@using System.Text.RegularExpressions
@using GestionaDenunciasAN.Models
@inject AuthenticationStateProvider AuthenticationStateProvider
@inject ApiDenunciasClient ApiDenuncias
@@ -73,9 +74,81 @@
.report-detail-close {
flex: 0 0 auto;
}
.report-row-disabled {
opacity: 0.62;
}
.report-row-disabled td {
cursor: not-allowed;
}
.inbox-table-wrap {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.inbox-table {
min-width: 1080px;
font-size: 0.84rem;
white-space: nowrap;
}
.inbox-table th,
.inbox-table td {
vertical-align: middle;
padding: 0.45rem 0.5rem;
}
.inbox-report-cell {
width: 4.5rem;
}
.inbox-channel-cell {
width: 10.5rem;
max-width: 10.5rem;
}
.inbox-channel-cell .text-truncate {
max-width: 100%;
}
.inbox-tracking-cell {
width: 11rem;
max-width: 11rem;
overflow: hidden;
text-overflow: ellipsis;
}
.inbox-activity-cell {
width: 9.25rem;
}
.inbox-action-cell {
width: 5.5rem;
text-align: right;
}
.inbox-detail-button {
padding-inline: 0.55rem;
white-space: nowrap;
}
@@media (max-width: 768px) {
.inbox-table {
min-width: 1020px;
font-size: 0.8rem;
}
.inbox-table th,
.inbox-table td {
padding: 0.4rem 0.42rem;
}
}
</style>
<div class="container py-4">
<div class="container-fluid py-4 px-3 px-xl-4">
<div class="d-flex flex-column flex-lg-row justify-content-between align-items-lg-center gap-3 mb-4">
<div>
<h3 class="mb-1">Entrada de denuncias</h3>
@@ -96,43 +169,43 @@
<div class="alert @FilterWarningCss mb-4">@FilterWarningMessage</div>
}
<div class="row g-4">
<div class="col-lg-4">
<div class="card shadow-sm h-100">
<div class="card-body">
<h5 class="card-title">Sesion GlobalLeaks</h5>
<p class="mb-2"><strong>Usuario:</strong> @CurrentUsername</p>
<p class="mb-2"><strong>Estado:</strong> @SessionStatusText</p>
<p class="mb-2">
<strong>Ultima descarga registrada:</strong>
@(UserInboxState.LastDownloadedReportMomentUtc is null
? "Sin descargas previas"
: UserInboxState.LastDownloadedReportMomentUtc.Value.ToLocalTime().ToString("dd/MM/yyyy HH:mm"))
</p>
<p class="text-muted small mb-4">
La base de datos lleva la cuenta de lo que ya ha descargado este usuario, de lo que han descargado otros
y de si el expediente ya esta creado en Gestiona.
</p>
<div class="mb-3">
<label class="form-label">Nuevo codigo 2FA</label>
<input class="form-control"
@bind="RenewAuthcode"
maxlength="6"
inputmode="numeric"
disabled="@(!RenewPrepared || RenewBusy)"
placeholder="123456" />
<div class="card shadow-sm mb-3">
<div class="card-body py-3">
<div class="row g-3 align-items-end">
<div class="col-xl-5 col-lg-6">
<h5 class="card-title mb-2">Sesion GlobalLeaks</h5>
<div class="d-flex flex-wrap gap-3 small">
<span><strong>Usuario:</strong> @CurrentUsername</span>
<span><strong>Estado:</strong> @SessionStatusText</span>
<span>
<strong>Ultima descarga:</strong>
@(UserInboxState.LastDownloadedReportMomentUtc is null
? "Sin descargas previas"
: UserInboxState.LastDownloadedReportMomentUtc.Value.ToLocalTime().ToString("dd/MM/yyyy HH:mm"))
</span>
</div>
</div>
<div class="col-xl-5 col-lg-4">
<label class="form-label mb-1">Nuevo codigo 2FA</label>
<input class="form-control"
@bind="RenewAuthcode"
maxlength="6"
inputmode="numeric"
disabled="@(!RenewPrepared || RenewBusy)"
placeholder="123456" />
</div>
<div class="col-xl-2 col-lg-2">
<button type="button" class="btn btn-primary w-100" @onclick="RenewSessionAsync" disabled="@RenewBusy">
@(RenewBusy ? SessionRenewBusyText : SessionRenewButtonText)
</button>
</div>
</div>
</div>
</div>
<div class="col-lg-8">
<div class="card shadow-sm">
<div class="card shadow-sm">
<div class="card-body">
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-3 mb-3">
<div>
@@ -152,7 +225,7 @@
<select class="form-select" value="@Filter" @onchange="OnFilterChanged">
<option value="all">Todas</option>
<option value="new">Nuevas / sin leer</option>
<option value="updated">Actualizaciones</option>
<option value="updated">Actualizaciones del ciudadano</option>
</select>
</div>
@@ -206,6 +279,12 @@
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-3 mb-3">
<div class="text-muted">
@VisibleReports.Count denuncia(s) visibles, @SelectedReportsCount seleccionada(s).
@if (VisibleReports.Any(report => report.Accessible == false))
{
<span class="d-block small text-danger">
@VisibleReports.Count(report => report.Accessible == false) denuncia(s) marcadas por GlobalLeaks como no accesibles para este usuario.
</span>
}
@if (UserInboxState.LastDownloadedReportMomentUtc is not null)
{
<span class="d-block small">
@@ -214,7 +293,7 @@
}
</div>
<div class="d-flex gap-2">
<button type="button" class="btn btn-outline-secondary btn-sm" @onclick="ToggleSelectAll" disabled="@(!VisibleReports.Any())">
<button type="button" class="btn btn-outline-secondary btn-sm" @onclick="ToggleSelectAll" disabled="@(!VisibleReports.Any(CanUseReport))">
@SelectAllLabel
</button>
<button type="button" class="btn btn-success btn-sm" @onclick="ImportSelectedAsync" disabled="@(!CanImportSelected)">
@@ -223,39 +302,41 @@
</div>
</div>
<div class="table-responsive">
<table class="table table-hover align-middle">
<div class="table-responsive inbox-table-wrap">
<table class="table table-hover table-sm align-middle inbox-table">
<thead>
<tr>
<th style="width: 3rem;"></th>
<th>#</th>
<th>Canal</th>
<th>Presentacion</th>
<th>Ultima actualizacion</th>
<th>Actividad ciudadano</th>
<th>Actividad OAAF</th>
<th>Estado</th>
<th>Acceso</th>
<th>Seguimiento</th>
<th style="width: 7rem;">Detalle</th>
</tr>
<th class="inbox-action-cell">Detalle</th>
</tr>
</thead>
<tbody>
@if (!CanUseGlobalLeaks)
{
<tr>
<td colspan="8" class="text-muted">
<tr>
<td colspan="10" class="text-muted">
Renueva la sesion de GlobalLeaks con un 2FA valido para cargar la bandeja.
</td>
</tr>
}
else if (ReportsBusy)
{
<tr>
<td colspan="8" class="text-muted">Cargando denuncias...</td>
<tr>
<td colspan="10" class="text-muted">Cargando denuncias...</td>
</tr>
}
else if (!VisibleReports.Any())
{
<tr>
<td colspan="8" class="text-muted">No hay denuncias con los filtros actuales.</td>
<td colspan="10" class="text-muted">No hay denuncias con los filtros actuales.</td>
</tr>
}
else
@@ -266,33 +347,35 @@
<td>
<input type="checkbox"
checked="@SelectedIds.Contains(report.Id)"
@onchange="@((ChangeEventArgs args) => ToggleSelection(report.Id, args))" />
disabled="@(!CanUseReport(report) || ImportBusy)"
title="@GetReportActionBlockReason(report)"
@onchange="@((ChangeEventArgs args) => ToggleSelection(report, args))" />
</td>
<td><strong>#@(report.Progressive ?? 0)</strong></td>
<td>@(report.ContextName ?? report.ContextId ?? "-")</td>
<td class="inbox-report-cell"><strong>#@(report.Progressive ?? 0)</strong></td>
<td class="inbox-channel-cell" title="@(report.ContextName ?? report.ContextId ?? string.Empty)"><span class="d-inline-block text-truncate">@(report.ContextName ?? report.ContextId ?? "-")</span></td>
<td>@FormatDate(report.CreationDate)</td>
<td>@FormatDate(report.UpdateDate)</td>
<td class="inbox-activity-cell">@FormatCitizenActivity(report)</td>
<td class="inbox-activity-cell">@FormatReceiverActivity(report)</td>
<td>
<span class="badge @GetStatusBadgeCss(report)">
@GetStatusLabel(report)
</span>
</td>
<td>
<span class="badge @GetAccessBadgeCss(report)">
@GetAccessLabel(report)
</span>
</td>
<td>
<span class="badge @GetTrackingBadgeCss(report)">
@GetTrackingLabel(report)
</span>
@if (!string.IsNullOrWhiteSpace(report.TrackingNote))
{
<div class="small text-muted mt-1">@report.TrackingNote</div>
}
<td class="inbox-tracking-cell" title="@(report.TrackingNote ?? string.Empty)">
<span class="badge @GetTrackingBadgeCss(report)">@GetTrackingLabel(report)</span>
</td>
<td>
<td class="inbox-action-cell">
<button type="button"
class="btn btn-outline-secondary btn-sm"
title="Consulta mensajes y ficheros. GlobalLeaks puede marcar la denuncia como leida."
class="btn btn-outline-secondary btn-sm inbox-detail-button"
title="@GetReportDetailTitle(report)"
@onclick="@(() => OpenReportDetailAsync(report))"
disabled="@DetailBusy">
Ver detalle
disabled="@(DetailBusy || report.Accessible == false)">
Detalle
</button>
</td>
</tr>
@@ -303,8 +386,6 @@
</div>
</div>
</div>
</div>
</div>
</div>
@if (DetailModalVisible)
@@ -451,7 +532,7 @@
private bool CanUseGlobalLeaks => SessionInfo?.HasActiveSession == true;
private bool RenewPrepared => !string.IsNullOrWhiteSpace(RenewPendingLoginId);
private int SelectedReportsCount => SelectedIds.Count;
private int SelectedReportsCount => Reports.Count(report => SelectedIds.Contains(report.Id) && CanUseReport(report));
private bool CanImportSelected => CanUseGlobalLeaks && SelectedReportsCount > 0 && !ImportBusy;
private string SessionStatusText => SessionInfo is null
? "Sin credenciales guardadas"
@@ -464,7 +545,7 @@
private string SessionRenewBusyText => RenewPrepared
? "Validando 2FA..."
: "Preparando...";
private string SelectAllLabel => VisibleReports.Count > 0 && VisibleReports.All(report => SelectedIds.Contains(report.Id))
private string SelectAllLabel => VisibleReports.Any(CanUseReport) && VisibleReports.Where(CanUseReport).All(report => SelectedIds.Contains(report.Id))
? "Deseleccionar todas"
: "Seleccionar todas";
@@ -631,9 +712,16 @@
{
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.",
@@ -685,6 +773,20 @@
{
SetStatus($"Se han importado {importedCount} denuncia(s) desde GlobalLeaks.", "alert-success");
}
else if (errors.Count == 0 && importedCount == 0)
{
var parts = new List<string>
{
"No se ha incorporado ninguna denuncia nueva."
};
if (warnings.Count > 0)
{
parts.Add($"Avisos: {string.Join(" | ", warnings)}");
}
SetStatus(string.Join(" ", parts), "alert-warning");
}
else
{
var parts = new List<string>
@@ -713,6 +815,12 @@
private async Task OpenReportDetailAsync(ReportDto report)
{
if (report.Accessible == false)
{
SetStatus($"La denuncia #{report.Progressive ?? 0} no es accesible para este usuario en GlobalLeaks.", "alert-warning");
return;
}
if (!CanUseGlobalLeaks)
{
SetStatus("Renueva antes la sesion de GlobalLeaks para consultar el detalle.", "alert-warning");
@@ -790,7 +898,7 @@
filtered = Filter switch
{
"new" => filtered.Where(report => string.IsNullOrWhiteSpace(report.AccessDate) || string.Equals(report.Status, "new", StringComparison.OrdinalIgnoreCase)),
"updated" => filtered.Where(report => report.Updated),
"updated" => filtered.Where(report => report.CitizenHasNewActivity || report.Updated),
_ => filtered
};
@@ -813,7 +921,10 @@
.OrderByDescending(report => report.Progressive ?? 0)
.ToList();
var validIds = Reports.Select(report => report.Id).ToHashSet(StringComparer.OrdinalIgnoreCase);
var validIds = Reports
.Where(CanUseReport)
.Select(report => report.Id)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
SelectedIds.RemoveWhere(id => !validIds.Contains(id));
}
@@ -887,16 +998,23 @@
return reports;
}
private void ToggleSelection(string reportId, ChangeEventArgs args)
private void ToggleSelection(ReportDto report, ChangeEventArgs args)
{
if (report.Accessible == false)
{
SelectedIds.Remove(report.Id);
StateHasChanged();
return;
}
var isChecked = GetCheckedValue(args);
if (isChecked)
{
SelectedIds.Add(reportId);
SelectedIds.Add(report.Id);
}
else
{
SelectedIds.Remove(reportId);
SelectedIds.Remove(report.Id);
}
StateHasChanged();
@@ -904,9 +1022,12 @@
private void ToggleSelectAll()
{
var shouldSelect = !VisibleReports.All(report => SelectedIds.Contains(report.Id));
var selectableReports = VisibleReports
.Where(CanUseReport)
.ToList();
var shouldSelect = selectableReports.Count > 0 && !selectableReports.All(report => SelectedIds.Contains(report.Id));
foreach (var report in VisibleReports)
foreach (var report in selectableReports)
{
if (shouldSelect)
{
@@ -980,15 +1101,58 @@
}
private static string FormatDate(string? value)
{
return FormatOptionalDate(value) ?? "-";
}
private static string? FormatOptionalDate(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return "-";
return null;
}
return DateTimeOffset.TryParse(value, out var parsed)
? parsed.ToLocalTime().ToString("dd/MM/yyyy HH:mm")
: "-";
: null;
}
private static string FormatCitizenActivity(ReportDto report)
{
var citizenDate = FormatOptionalDate(report.CitizenLastActivity);
if (!string.IsNullOrWhiteSpace(citizenDate))
{
return citizenDate;
}
if (!report.ActivityAnalyzed)
{
return "No analizada";
}
var accessDate = FormatOptionalDate(report.WhistleblowerLastAccess);
if (!string.IsNullOrWhiteSpace(accessDate))
{
return accessDate;
}
return "Sin fecha";
}
private static string FormatReceiverActivity(ReportDto report)
{
var receiverDate = FormatOptionalDate(report.ReceiverLastActivity);
if (!string.IsNullOrWhiteSpace(receiverDate))
{
return receiverDate;
}
if (!report.ActivityAnalyzed)
{
return "No analizada";
}
return "Sin actividad";
}
private static string FormatBytes(long? value)
@@ -1013,15 +1177,42 @@
}
private static string GetCommentAuthorLabel(string? type)
=> string.Equals(type, "whistleblower", StringComparison.OrdinalIgnoreCase)
=> IsWhistleblowerActivityType(type)
? "Denunciante"
: string.Equals(type, "receiver", StringComparison.OrdinalIgnoreCase)
: IsReceiverActivityType(type)
? "Receptor"
: "Comentario";
private static bool IsWhistleblowerActivityType(string? value)
=> MatchesAny(value, "whistleblower", "citizen", "source", "tipper", "submitter", "denunciante");
private static bool IsReceiverActivityType(string? value)
=> MatchesAny(value, "receiver", "recipient", "admin", "administrator", "operator", "staff", "custodian", "moderator", "gestor", "oaaf");
private static bool MatchesAny(string? value, params string[] candidates)
{
if (string.IsNullOrWhiteSpace(value))
{
return false;
}
var normalized = NormalizeActivityType(value);
return candidates.Any(candidate =>
{
var candidateToken = NormalizeActivityType(candidate);
return normalized == candidateToken ||
normalized.StartsWith(candidateToken + " ", StringComparison.Ordinal) ||
normalized.EndsWith(" " + candidateToken, StringComparison.Ordinal) ||
normalized.Contains(" " + candidateToken + " ", StringComparison.Ordinal);
});
}
private static string NormalizeActivityType(string value)
=> Regex.Replace(value.Trim().ToLowerInvariant(), @"[^a-z0-9]+", " ").Trim();
private static DateTimeOffset? GetEffectiveMoment(ReportDto report)
{
return ParseDate(report.UpdateDate) ?? ParseDate(report.CreationDate);
return ParseDate(report.CitizenLastActivity) ?? ParseDate(report.ReceiverLastActivity) ?? ParseDate(report.UpdateDate) ?? ParseDate(report.CreationDate);
}
private static DateTimeOffset? ParseDate(string? value)
@@ -1043,6 +1234,16 @@
return "Sin leer";
}
if (report.CitizenHasNewActivity)
{
return "Actualizacion ciudadano";
}
if (IsReceiverOnlyUpdate(report))
{
return "Actividad OAAF";
}
if (report.Updated)
{
return "Actualizada";
@@ -1060,16 +1261,81 @@
return "bg-warning text-dark";
}
if (report.Updated)
if (report.CitizenHasNewActivity)
{
return "bg-info text-dark";
}
if (IsReceiverOnlyUpdate(report))
{
return "bg-secondary";
}
if (report.Updated)
{
return "bg-light text-dark";
}
return string.Equals(report.Status, "closed", StringComparison.OrdinalIgnoreCase)
? "bg-secondary"
: "bg-primary";
}
private static string GetAccessLabel(ReportDto report)
=> report.Accessible switch
{
true => "Accesible",
false => "Sin acceso",
_ => "Sin dato"
};
private static string GetAccessBadgeCss(ReportDto report)
=> report.Accessible switch
{
true => "bg-success",
false => "bg-danger",
_ => "bg-light text-dark"
};
private static bool CanUseReport(ReportDto report)
=> report.Accessible != false && !IsReceiverOnlyUpdate(report);
private static bool IsReceiverOnlyUpdate(ReportDto report)
=> report.AlreadyInGestiona &&
report.ActivityAnalyzed &&
!report.CitizenHasNewActivity &&
report.ReceiverHasNewActivity;
private static string? GetReportActionBlockReason(ReportDto report)
{
if (report.Accessible == false)
{
return "GlobalLeaks indica que esta denuncia no es accesible para este usuario.";
}
if (IsReceiverOnlyUpdate(report))
{
return "La actividad nueva procede de OAAF/gestor; no se importa como actualizacion del ciudadano.";
}
return null;
}
private static string GetReportDetailTitle(ReportDto report)
{
if (report.Accessible == false)
{
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.";
}
private static string GetTrackingLabel(ReportDto report)
{
if (report.AlreadyInGestiona)
@@ -1122,6 +1388,16 @@
private static string? GetReportRowCss(ReportDto report)
{
if (report.Accessible == false)
{
return "table-danger report-row-disabled";
}
if (IsReceiverOnlyUpdate(report))
{
return "table-secondary report-row-disabled";
}
if (report.AlreadyInGestiona)
{
return "table-success";

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
@page "/Pendientes"
@page "/Pendientes"
@rendermode InteractiveServer
@attribute [Authorize]
@@ -205,16 +205,7 @@ else
class="btn btn-success btn-sm me-2"
@onclick:stopPropagation="true"
@onclick="() => OpenEnviarAGestionaModal(denuncia)">
Configurar subida
</button>
<!-- Enviar a Actualizaciones -->
<button type="button"
class="btn btn-outline-warning btn-sm me-2"
title="Mover esta denuncia a la cola de Actualizaciones"
@onclick:stopPropagation="true"
@onclick="() => MoverAActualizaciones(denuncia)">
Enviar a Actualizaciones
Configurar apertura expediente
</button>
<button type="button"
@@ -247,7 +238,7 @@ else
}
@if (!string.IsNullOrWhiteSpace(denuncia.ExpedienteGestionaMostrable))
{
<dt class="col-sm-3">N<EFBFBD> expediente Gestiona</dt>
<dt class="col-sm-3">Nº expediente Gestiona</dt>
<dd class="col-sm-9">@denuncia.ExpedienteGestionaMostrable</dd>
}
@if (denuncia.Id_Persona_Gestiona != 0)
@@ -287,7 +278,7 @@ else
}
@if (!string.IsNullOrWhiteSpace(denuncia.RazonSocialResuelta))
{
<dt class="col-sm-3">Raz<EFBFBD>n social</dt>
<dt class="col-sm-3">Razón social</dt>
<dd class="col-sm-9">@denuncia.RazonSocialResuelta</dd>
}
@if (!string.IsNullOrWhiteSpace(denuncia.NombreResuelto))
@@ -297,12 +288,12 @@ else
}
@if (!string.IsNullOrWhiteSpace(denuncia.PrimerApellidoResuelto))
{
<dt class="col-sm-3">1<EFBFBD> Apellido</dt>
<dt class="col-sm-3">1º Apellido</dt>
<dd class="col-sm-9">@denuncia.PrimerApellidoResuelto</dd>
}
@if (!string.IsNullOrWhiteSpace(denuncia.SegundoApellidoResuelto))
{
<dt class="col-sm-3">2<EFBFBD> Apellido</dt>
<dt class="col-sm-3">2º Apellido</dt>
<dd class="col-sm-9">@denuncia.SegundoApellidoResuelto</dd>
}
@if (!string.IsNullOrWhiteSpace(denuncia.ApellidosResueltos))
@@ -322,7 +313,7 @@ else
}
@if (!string.IsNullOrWhiteSpace(denuncia.PaisOrigen))
{
<dt class="col-sm-3">Pa<EFBFBD>s de origen</dt>
<dt class="col-sm-3">País de origen</dt>
<dd class="col-sm-9">@denuncia.PaisOrigen</dd>
}
</dl>
@@ -339,7 +330,7 @@ else
<dt class="col-sm-3">Detalle denunciado</dt>
<dd class="col-sm-9">@denuncia.DenunciadoDetalle</dd>
}
<dt class="col-sm-3">Descripci<EFBFBD>n Denuncia</dt>
<dt class="col-sm-3">Descripción Denuncia</dt>
<dd class="col-sm-9">@denuncia.Descripcion_Denuncia</dd>
<dt class="col-sm-3">Denunciado Ante Inst</dt>
<dd class="col-sm-9">@denuncia.Denunciado_Ante_Inst</dd>
@@ -350,7 +341,7 @@ else
}
@if (!string.IsNullOrWhiteSpace(denuncia.SolicitaProteccion))
{
<dt class="col-sm-3">Solicita protecci<EFBFBD>n</dt>
<dt class="col-sm-3">Solicita protección</dt>
<dd class="col-sm-9">@denuncia.SolicitaProteccion</dd>
}
@if (!string.IsNullOrWhiteSpace(denuncia.MedidasProteccionSolicitadas))
@@ -360,7 +351,7 @@ else
}
@if (!string.IsNullOrWhiteSpace(denuncia.Modalidad_Informacion))
{
<dt class="col-sm-3">Modalidad Informaci<EFBFBD>n</dt>
<dt class="col-sm-3">Modalidad Información</dt>
<dd class="col-sm-9">@denuncia.Modalidad_Informacion</dd>
}
<dt class="col-sm-3">Lugar Hechos</dt>
@@ -372,7 +363,7 @@ else
}
@if (!string.IsNullOrWhiteSpace(denuncia.AutorizaRemision))
{
<dt class="col-sm-3">Autoriza remisi<EFBFBD>n</dt>
<dt class="col-sm-3">Autoriza remisión</dt>
<dd class="col-sm-9">@denuncia.AutorizaRemision</dd>
}
@if (!string.IsNullOrWhiteSpace(denuncia.PreferenciaRemision))
@@ -382,16 +373,16 @@ else
}
</dl>
<h5 class="section-heading">Datos de Notificaci<EFBFBD>n</h5>
<h5 class="section-heading">Datos de Notificación</h5>
<dl class="row">
@if (!string.IsNullOrWhiteSpace(denuncia.Notificacion_Preferencia))
{
<dt class="col-sm-3">Notificaci<EFBFBD>n Preferencia</dt>
<dt class="col-sm-3">Notificación Preferencia</dt>
<dd class="col-sm-9">@denuncia.Notificacion_Preferencia</dd>
}
@if (!string.IsNullOrWhiteSpace(denuncia.Notificacion_Electronica))
{
<dt class="col-sm-3">Notificaci<EFBFBD>n Electr<EFBFBD>nica</dt>
<dt class="col-sm-3">Notificación Electrónica</dt>
<dd class="col-sm-9">@denuncia.Notificacion_Electronica</dd>
}
@if (!string.IsNullOrWhiteSpace(denuncia.SeguimientoOnline))
@@ -406,17 +397,17 @@ else
}
@if (!string.IsNullOrWhiteSpace(denuncia.Correo_Electronico))
{
<dt class="col-sm-3">Correo Electr<EFBFBD>nico</dt>
<dt class="col-sm-3">Correo Electrónico</dt>
<dd class="col-sm-9">@denuncia.Correo_Electronico</dd>
}
@if (!string.IsNullOrWhiteSpace(denuncia.Notificacion_Sms))
{
<dt class="col-sm-3">Notificaci<EFBFBD>n SMS</dt>
<dt class="col-sm-3">Notificación SMS</dt>
<dd class="col-sm-9">@denuncia.Notificacion_Sms</dd>
}
@if (HasPostalAddress(denuncia))
{
<dt class="col-sm-3">Direcci<EFBFBD>n postal</dt>
<dt class="col-sm-3">Dirección postal</dt>
<dd class="col-sm-9">@BuildPostalAddressSummary(denuncia)</dd>
}
</dl>
@@ -426,7 +417,7 @@ else
@if (denuncia.Condiciones)
{
<dt class="col-sm-3">Condiciones</dt>
<dd class="col-sm-9">S<EFBFBD></dd>
<dd class="col-sm-9">Sí</dd>
}
@if (!string.IsNullOrWhiteSpace(denuncia.Comments))
{
@@ -441,14 +432,14 @@ else
@if (camposFormulario.Count > 0)
{
<h5 class="section-heading">Formulario Original</h5>
@foreach (var grupoCampos in camposFormulario.GroupBy(field => string.IsNullOrWhiteSpace(field.Section) ? "Sin secci<EFBFBD>n" : field.Section))
@foreach (var grupoCampos in camposFormulario.GroupBy(field => string.IsNullOrWhiteSpace(field.Section) ? "Sin sección" : field.Section))
{
<h6 class="mt-3">@grupoCampos.Key</h6>
<dl class="row">
@foreach (var campo in grupoCampos)
{
<dt class="col-sm-4">@campo.Label</dt>
<dd class="col-sm-8">@(string.IsNullOrWhiteSpace(campo.Value) ? "<EFBFBD>" : campo.Value)</dd>
<dd class="col-sm-8">@(string.IsNullOrWhiteSpace(campo.Value) ? "" : campo.Value)</dd>
}
</dl>
}
@@ -463,7 +454,7 @@ else
<th class="seleccionar-col">Subir</th>
<th>Nombre</th>
<th>Fecha</th>
<th>Tama<EFBFBD>o (bytes)</th>
<th>Tamaño (bytes)</th>
<th>Ver</th>
</tr>
</thead>
@@ -541,7 +532,7 @@ else
</div>
}
<h6 class="modal-section-heading">Descripci<EFBFBD>n</h6>
<h6 class="modal-section-heading">Descripción</h6>
<div class="mb-3">
<input type="text"
class="form-control"
@@ -554,9 +545,9 @@ else
<input type="text"
class="form-control"
@bind="nombreDocumentos"
placeholder="Ej.: Gesti<EFBFBD>n AN (lo ver<EFBFBD>s como: Documento Adjunto 1 Gesti<EFBFBD>n AN, ...)" />
placeholder="Ej.: Gestión AN (lo verás como: Documento Adjunto 1 Gestión AN, ...)" />
<small class="text-muted">
Se aplicar<EFBFBD> al subir en modo <strong>individual</strong>. El <em>report.txt</em> se subir<EFBFBD> como <strong>Denuncia</strong>.
Se aplicará al subir en modo <strong>individual</strong>. El <em>report.txt</em> se subirá como <strong>Denuncia</strong>.
</small>
</div>
@@ -569,7 +560,7 @@ else
checked='@(uploadMode == "merge")'
@onclick='() => uploadMode = "merge"' />
<label class="form-check-label" for="modoMerge">
Unir todos los ficheros en un <EFBFBD>nico PDF
Unir todos los ficheros en un único PDF
</label>
</div>
<div class="form-check">
@@ -593,7 +584,7 @@ else
checked='@(selectedGroup == "600")'
@onclick='() => selectedGroup = "600"' />
<label class="form-check-label" for="grupo600">
600. Asuntos Jur<EFBFBD>dicos y Protecci<EFBFBD>n a la Persona Denunciante
600. Asuntos Jurídicos y Protección a la Persona Denunciante
</label>
</div>
@* <div class="form-check">
@@ -604,7 +595,7 @@ else
checked='@(selectedGroup == "510")'
@onclick='() => selectedGroup = "510"' />
<label class="form-check-label" for="grupo510">
510. SDI <EFBFBD> Investigaci<EFBFBD>n Entradas
510. SDI Investigación Entradas
</label>
</div>
<div class="form-check">
@@ -626,13 +617,13 @@ else
<h6 class="modal-section-heading mt-3">Tercero (denunciante)</h6>
<div class="alert alert-light border mb-3">
Los datos del tercero se cargan autom<EFBFBD>ticamente desde la denuncia y no se pueden editar aqu<EFBFBD>.
Los datos del tercero se cargan automáticamente desde la denuncia y no se pueden editar aquí.
</div>
@if (modalThirdParty.IsAnonymous)
{
<div class="alert alert-warning mb-3">
Denuncia an<EFBFBD>nima. Se enlazar<EFBFBD> autom<EFBFBD>ticamente el tercero <strong>00000000T</strong>.
Denuncia anónima. Se enlazará automáticamente el tercero <strong>00000000T</strong>.
</div>
}
@@ -661,7 +652,7 @@ else
{
<div class="row g-2">
<div class="col-12 mb-2">
<label class="form-label">Raz<EFBFBD>n social</label>
<label class="form-label">Razón social</label>
<input class="form-control" value="@GetReadOnlyValue(selectedDenuncias.RazonSocialResuelta)" readonly />
</div>
</div>
@@ -674,11 +665,11 @@ else
<input class="form-control" value="@GetReadOnlyValue(selectedDenuncias.NombreResuelto)" readonly />
</div>
<div class="col-4 mb-2">
<label class="form-label">1<EFBFBD> apellido</label>
<label class="form-label">1º apellido</label>
<input class="form-control" value="@GetReadOnlyValue(selectedDenuncias.PrimerApellidoResuelto)" readonly />
</div>
<div class="col-4 mb-2">
<label class="form-label">2<EFBFBD> apellido</label>
<label class="form-label">2º apellido</label>
<input class="form-control" value="@GetReadOnlyValue(selectedDenuncias.SegundoApellidoResuelto)" readonly />
</div>
</div>
@@ -688,14 +679,14 @@ else
{
<div class="row g-2">
<div class="col-12 mb-2">
<label class="form-label">Direcci<EFBFBD>n postal</label>
<label class="form-label">Dirección postal</label>
<textarea class="form-control" rows="2" readonly>@BuildPostalAddressSummary(selectedDenuncias)</textarea>
</div>
</div>
}
<small class="text-muted">
Al confirmar, se enlazar<EFBFBD> en Gestiona el tercero obtenido del formulario de la denuncia.
Al confirmar, se enlazará en Gestiona el tercero obtenido del formulario de la denuncia.
</small>
@if (!modalThirdParty.IsAnonymous && !string.IsNullOrWhiteSpace(modalThirdParty.DocumentId))
@@ -768,7 +759,7 @@ else
{
<div class="d-flex align-items-center">
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
<span>Cargando expedientes<EFBFBD></span>
<span>Cargando expedientes</span>
</div>
}
else if (!string.IsNullOrWhiteSpace(errorExpedientes))
@@ -788,7 +779,7 @@ else
<tr>
<th>Expediente</th>
<th>Asunto</th>
<th>Fecha creaci<EFBFBD>n</th>
<th>Fecha creación</th>
<th>Estado</th>
<th></th>
</tr>
@@ -827,7 +818,7 @@ else
private string nombreDocumentos = string.Empty;
private bool isUploading = false;
private string uploadMode = "merge";
private string uploadMode = "individual";
private string selectedGroup = "600";
@@ -870,14 +861,6 @@ else
{
loadError = string.Empty;
var todas = await CargarDenunciasJsonAsync();
// Asegura ProcedureId/GroupId por si faltan
foreach (var d in todas.Where(x => x.ProcedureId == Guid.Empty))
{
d.ProcedureId = Guid.Parse("82722c9b-cecc-4299-8a7b-ce5abeb8170b");
d.GroupId = Guid.Parse("6dbfc433-1eb6-4b9a-a533-bfebc652c101");
}
// SOLO pendientes
pendientes = todas
.Where(d => !d.EnGestiona && !d.EnRechazada && !d.EsActualizacion)
@@ -944,6 +927,15 @@ else
return new string(clean.Where(ch => ch <= 127).ToArray());
}
private string BuildMergedAttachmentsFileName(int denunciaId, DateTime timestampUtc)
{
var baseName = string.IsNullOrWhiteSpace(nombreDocumentos)
? $"Adjuntos {denunciaId}_{timestampUtc:yyyyMMddHHmmss}"
: nombreDocumentos.Trim();
return FixFileName($"{Path.GetFileNameWithoutExtension(baseName)}.pdf");
}
private async Task ConfirmarEnvio()
{
if (selectedDenuncias == null) return;
@@ -988,10 +980,11 @@ else
{
operationError = ficherosVacios.Count == 0
? $"La denuncia #{selectedDenuncias.Id_Denuncia} no tiene ficheros para subir."
: $"La denuncia #{selectedDenuncias.Id_Denuncia} solo tiene ficheros vac<EFBFBD>os y no se ha subido nada. Ficheros omitidos: {string.Join(", ", ficherosVacios)}.";
: $"La denuncia #{selectedDenuncias.Id_Denuncia} solo tiene ficheros vacíos y no se ha subido nada. Ficheros omitidos: {string.Join(", ", ficherosVacios)}.";
return;
}
var expedienteCreadoEnGestiona = false;
string fileUrl;
if (selectedDenuncias.EnGestiona && !string.IsNullOrWhiteSpace(selectedDenuncias.Expediente_Gestiona))
{
@@ -1001,7 +994,6 @@ else
{
Busy.Update(message: "Creando expediente nuevo en Gestiona.", detail: "Paso 2 de 7");
var createdFile = await ApiDenuncias.CreateGestionaFileAsync(
selectedDenuncias.ProcedureId,
nuevoAsunto,
"RQ2ZLC - Expediente de Denuncias",
"3109963"
@@ -1011,20 +1003,13 @@ else
await ApiDenuncias.OpenGestionaFileAsync(
fileUrl,
createdFile.FileOpenUrl,
managementUnitGroupId: Guid.Parse("a4ad4dfb-70dc-4219-8ee3-4dcc939f0955"),
assignedGroupId: selectedGroup switch
{
"510" => Guid.Parse("6dbfc433-1eb6-4b9a-a533-bfebc652c101"),
"600" => Guid.Parse("454fa4ec-8b82-4240-9419-113f45d4b004"),
"700" => Guid.Parse("a4ad4dfb-70dc-4219-8ee3-4dcc939f0955"),
_ => throw new InvalidOperationException($"Grupo desconocido: {selectedGroup}")
},
assignedGroupCode: selectedGroup,
confidential: selectedDenuncias.Confidencial,
freeTitle: nuevoAsunto,
siaCode: "3109963"
freeTitle: nuevoAsunto
);
selectedDenuncias.Expediente_Gestiona = fileUrl;
selectedDenuncias.EnGestiona = true;
expedienteCreadoEnGestiona = true;
}
Busy.Update(message: "Guardando referencia local del expediente.", detail: "Paso 3 de 7");
@@ -1033,7 +1018,7 @@ else
var thirdParty = selectedThirdParty ?? ThirdPartyIdentityData.FromComplaint(selectedDenuncias);
Busy.Update(message: "Resolviendo tercero y enlazandolo al expediente.", detail: "Paso 4 de 7");
await ApiDenuncias.EnsureGestionaThirdAndLinkAsync(fileUrl, thirdParty);
var thirdResult = await ApiDenuncias.EnsureGestionaThirdAndLinkAsync(fileUrl, thirdParty);
var nombresOriginalesSubidos = new List<string>();
var nombresFinalesSubidos = new List<string>();
@@ -1045,8 +1030,8 @@ else
if (!string.IsNullOrWhiteSpace(report.FileName))
{
Busy.Update(message: "Preparando y subiendo el report para firma.", detail: "Paso 5 de 7");
var reportPdfBytes = PdfHelper.MergeFilesToPdf(new[]
Busy.Update(message: "Preparando y subiendo el documento principal.", detail: "Paso 5 de 7");
var reportPdfBytes = PdfHelper.MergeReportToPdf(new[]
{
(FileName: report.FileName, Content: report.Content)
});
@@ -1067,7 +1052,7 @@ else
{
Busy.Update(message: "Uniendo adjuntos en un unico PDF y subiendolo.", detail: "Paso 6 de 7");
var pdfBytes = PdfHelper.MergeFilesToPdf(adjuntos);
var pdfName = FixFileName($"Adjuntos {selectedDenuncias.Id_Denuncia}_{ahoraUtc:yyyyMMddHHmmss}.pdf");
var pdfName = BuildMergedAttachmentsFileName(selectedDenuncias.Id_Denuncia, ahoraUtc);
var docUrl = await ApiDenuncias.UploadGestionaDocumentAsync(fileUrl, pdfBytes, pdfName);
if (string.IsNullOrWhiteSpace(documentoParaTramitar))
{
@@ -1127,11 +1112,12 @@ else
if (!string.IsNullOrWhiteSpace(documentoParaTramitar))
{
Busy.Update(message: "Enviando el report al circuito de firma.", detail: "Paso 7 de 7");
Busy.Update(message: "Finalizando el documento principal en Gestiona.", detail: "Paso 7 de 7");
await ApiDenuncias.TramitarGestionaDocumentAsync(
documentoParaTramitar,
GetAssignedGroupLinkBySelectedGroup(),
selectedDenuncias.Id_Denuncia);
selectedGroup,
selectedDenuncias.Id_Denuncia,
isUpdate: false);
}
foreach (var origName in nombresOriginalesSubidos)
@@ -1155,7 +1141,16 @@ else
selectedDenuncias.EsActualizacion = false;
selectedDenuncias.NombreDenuncia = nuevoAsunto;
selectedDenuncias.FechaSubidaAGestiona = ahoraUtc;
selectedDenuncias.UltimaSubidaGestionaTipo = "Nueva denuncia";
selectedDenuncias.UltimoGrupoAsignadoGestiona = GetGestionaGroupDisplay(selectedGroup);
await SincronizarExpedienteGestionaAsync(selectedDenuncias, fileUrl);
await ActualizarDenunciaAsync(selectedDenuncias);
var historialAviso = await RegistrarHistorialGestionaAsync(
selectedDenuncias,
"Nueva denuncia",
selectedGroup,
ahoraUtc,
string.Join("; ", nombresFinalesSubidos));
var denunciaProcesadaId = selectedDenuncias.Id_Denuncia;
pendientes.Remove(selectedDenuncias);
@@ -1165,22 +1160,35 @@ else
var avisos = new List<string>();
if (ficherosVacios.Count > 0)
{
avisos.Add($"Se omitieron ficheros vac<EFBFBD>os: {string.Join(", ", ficherosVacios)}.");
avisos.Add($"Se omitieron ficheros vacíos: {string.Join(", ", ficherosVacios)}.");
}
if (ficherosNoSeleccionados.Count > 0)
{
avisos.Add($"No se subieron por selecci<EFBFBD>n del usuario: {string.Join(", ", ficherosNoSeleccionados)}.");
avisos.Add($"No se subieron por selección del usuario: {string.Join(", ", ficherosNoSeleccionados)}.");
}
if (avisos.Count > 0)
if (thirdResult.Warnings.Count > 0)
{
operationNotice = $"Denuncia #{denunciaProcesadaId} enviada. {string.Join(" ", avisos)}";
avisos.AddRange(thirdResult.Warnings);
}
if (!string.IsNullOrWhiteSpace(historialAviso))
{
avisos.Add(historialAviso);
}
var expedienteInfo = string.IsNullOrWhiteSpace(selectedDenuncias.ExpedienteGestionaMostrable)
? string.Empty
: $" Nº expediente Gestiona: {selectedDenuncias.ExpedienteGestionaMostrable}.";
var resumen = expedienteCreadoEnGestiona
? $"Denuncia #{denunciaProcesadaId}: se ha creado el expediente en Gestiona y se han subido los documentos.{expedienteInfo}"
: $"Denuncia #{denunciaProcesadaId}: se han subido los documentos al expediente de Gestiona.{expedienteInfo}";
operationNotice = avisos.Count > 0
? $"{resumen} {string.Join(" ", avisos)}"
: resumen;
StateHasChanged();
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error al confirmar env<EFBFBD>o: {ex}");
operationError = $"No se ha podido completar el env<EFBFBD>o de la denuncia #{selectedDenuncias?.Id_Denuncia}: {ex.Message}";
Console.Error.WriteLine($"Error al confirmar envío: {ex}");
operationError = $"No se ha podido completar el envío de la denuncia #{selectedDenuncias?.Id_Denuncia}: {ex.Message}";
}
finally
{
@@ -1226,15 +1234,37 @@ else
}
}
private string GetAssignedGroupLinkBySelectedGroup()
private static string GetGestionaGroupDisplay(string? groupCode)
{
return selectedGroup switch
return string.IsNullOrWhiteSpace(groupCode) ? string.Empty : groupCode.Trim();
}
private async Task<string?> RegistrarHistorialGestionaAsync(
DenunciasGestiona denuncia,
string tipoSubida,
string grupo,
DateTime uploadedAtUtc,
string documentos)
{
try
{
"510" => "https://02.g3stiona.com/rest/groups/6dbfc433-1eb6-4b9a-a533-bfebc652c101",
"600" => "https://02.g3stiona.com/rest/groups/454fa4ec-8b82-4240-9419-113f45d4b004",
"700" => "https://02.g3stiona.com/rest/groups/a4ad4dfb-70dc-4219-8ee3-4dcc939f0955",
_ => throw new InvalidOperationException($"Grupo desconocido: {selectedGroup}")
};
await ApiDenuncias.RegisterGestionaUploadHistoryAsync(
new GestionaUploadHistoryCreateRequest(
denuncia.Id_Denuncia,
denuncia.Expediente_Gestiona ?? string.Empty,
denuncia.ExpedienteGestionaMostrable,
tipoSubida,
grupo,
uploadedAtUtc,
denuncia.NombreDenuncia ?? string.Empty,
documentos));
return null;
}
catch (Exception ex)
{
Console.Error.WriteLine($"No se ha podido registrar el historico de subida a Gestiona: {ex}");
return "La subida se ha completado, pero no se ha podido registrar el histórico operativo en la aplicación.";
}
}
private async Task ConfirmarRechazo()
@@ -1379,7 +1409,7 @@ else
}
private static string GetReadOnlyValue(string? value) =>
string.IsNullOrWhiteSpace(value) ? "<EFBFBD>" : value;
string.IsNullOrWhiteSpace(value) ? "" : value;
private static bool HasPostalAddress(DenunciasGestiona denuncia)
{
@@ -1438,18 +1468,7 @@ else
return string.Join(" | ", parts);
}
// mover a Actualizaciones
private async Task MoverAActualizaciones(DenunciasGestiona d)
{
d.EsActualizacion = true;
d.EnRechazada = false;
await ActualizarDenunciaAsync(d);
pendientes.RemoveAll(x => x.Id_Denuncia == d.Id_Denuncia);
StateHasChanged();
}
// ========= L<>GICA BUSCADOR DE EXPEDIENTES POR TERCERO =========
// ========= LÓGICA BUSCADOR DE EXPEDIENTES POR TERCERO =========
private void CloseExpedientesModal()
{
@@ -1468,7 +1487,7 @@ else
if (string.IsNullOrWhiteSpace(nif) || nif == "00000000T")
{
errorExpedientes = "NIF no v<EFBFBD>lido para b<EFBFBD>squeda (an<EFBFBD>nimo o vac<EFBFBD>o).";
errorExpedientes = "NIF no válido para búsqueda (anónimo o vacío).";
showExpedientesModal = true;
StateHasChanged();
return;
@@ -1499,3 +1518,4 @@ else
}
}

View File

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

View File

@@ -99,46 +99,35 @@ public sealed class ApiDenunciasClient
cancellationToken);
}
public Task EnsureStorageReadyAsync(CancellationToken cancellationToken = default)
=> SendAsync<object?>(HttpMethod.Post, "api/inbox/local/ensure-storage", body: null, authorize: true, cancellationToken);
public Task<ImportSummary> ProcessLocalZipsAsync(CancellationToken cancellationToken = default)
=> SendAsync<ImportSummary>(HttpMethod.Post, "api/inbox/local/process", body: null, authorize: true, cancellationToken);
public Task<IReadOnlyList<string>> GetExistingZipNamesAsync(CancellationToken cancellationToken = default)
=> SendAsync<IReadOnlyList<string>>(HttpMethod.Get, "api/inbox/local/zips", body: null, authorize: true, cancellationToken);
public Task DeleteZipAsync(string zipName, CancellationToken cancellationToken = default)
=> SendAsync<object?>(
HttpMethod.Delete,
$"api/inbox/local/zips/{Uri.EscapeDataString(zipName)}",
body: null,
authorize: true,
cancellationToken);
public Task<GestionaCreateFileResponse> CreateGestionaFileAsync(
Guid procedureId,
string subject,
string documentSeries,
string siaCode,
CancellationToken cancellationToken = default)
=> PostAsync<GestionaCreateFileResponse>(
"api/gestiona/files",
new GestionaCreateFileRequest(procedureId, subject, documentSeries, siaCode),
new GestionaCreateFileRequest(subject, documentSeries, siaCode),
cancellationToken);
public Task OpenGestionaFileAsync(
string fileUrl,
string? fileOpenUrl,
Guid managementUnitGroupId,
Guid assignedGroupId,
string assignedGroupCode,
bool confidential,
string freeTitle,
string siaCode,
CancellationToken cancellationToken = default)
=> PostAsync(
"api/gestiona/files/open",
new GestionaOpenFileRequest(fileUrl, fileOpenUrl, managementUnitGroupId, assignedGroupId, confidential, freeTitle, siaCode),
new GestionaOpenFileRequest(fileUrl, fileOpenUrl, assignedGroupCode, confidential, freeTitle),
cancellationToken);
public Task AssignGestionaFileAsync(
string fileUrl,
string assignedGroupCode,
CancellationToken cancellationToken = default)
=> PostAsync(
"api/gestiona/files/assignees",
new GestionaAssignFileRequest(fileUrl, assignedGroupCode),
cancellationToken);
public Task<GestionaExpedienteInfo?> GetGestionaExpedienteAsync(
@@ -149,11 +138,27 @@ public sealed class ApiDenunciasClient
cancellationToken,
allowNull: true);
public Task EnsureGestionaThirdAndLinkAsync(
public Task<GestionaAuditInfo?> GetGestionaFileLatestAuditAsync(
string fileUrl,
CancellationToken cancellationToken = default)
=> GetAsync<GestionaAuditInfo?>(
$"api/gestiona/files/audit/latest?fileUrl={Uri.EscapeDataString(fileUrl)}",
cancellationToken,
allowNull: true);
public Task<List<GestionaUploadHistoryEntry>> GetGestionaUploadHistoryAsync(CancellationToken cancellationToken = default)
=> GetAsync<List<GestionaUploadHistoryEntry>>("api/denuncias/gestiona-history", cancellationToken);
public Task RegisterGestionaUploadHistoryAsync(
GestionaUploadHistoryCreateRequest request,
CancellationToken cancellationToken = default)
=> PostAsync("api/denuncias/gestiona-history", request, cancellationToken);
public Task<GestionaEnsureThirdResponse> EnsureGestionaThirdAndLinkAsync(
string fileUrl,
ThirdPartyIdentityData thirdParty,
CancellationToken cancellationToken = default)
=> PostAsync(
=> PostAsync<GestionaEnsureThirdResponse>(
"api/gestiona/thirds/ensure-link",
new GestionaEnsureThirdRequest(fileUrl, thirdParty),
cancellationToken);
@@ -183,12 +188,13 @@ public sealed class ApiDenunciasClient
public Task TramitarGestionaDocumentAsync(
string documentUrl,
string assignedGroupHref,
string assignedGroupCode,
int? complaintId,
bool isUpdate = false,
CancellationToken cancellationToken = default)
=> PostAsync(
"api/gestiona/documents/tramitar",
new GestionaTramitarDocumentoRequest(documentUrl, assignedGroupHref, complaintId),
new GestionaTramitarDocumentoRequest(documentUrl, assignedGroupCode, complaintId, isUpdate),
cancellationToken);
public Task<List<ExpedienteTerceroDto>> GetGestionaExpedientesPorTerceroAsync(
@@ -292,13 +298,16 @@ public sealed class ApiDenunciasClient
using var response = await client.SendAsync(request, cancellationToken);
if (response.StatusCode == HttpStatusCode.Unauthorized)
{
if (authorize)
var message = await ReadErrorMessageAsync(response, cancellationToken);
if (!string.IsNullOrWhiteSpace(message) &&
!message.StartsWith("La API de denuncias ha respondido con", StringComparison.OrdinalIgnoreCase))
{
throw new UnauthorizedAccessException($"La sesion de API ha caducado al llamar a {path}. Vuelve a iniciar sesion.");
throw new UnauthorizedAccessException(message);
}
var message = await ReadErrorMessageAsync(response, cancellationToken);
throw new UnauthorizedAccessException(message);
throw new UnauthorizedAccessException(authorize
? "La sesion de la aplicacion ha caducado. Vuelve a iniciar sesion."
: "No se ha podido autorizar la peticion. Vuelve a iniciar sesion.");
}
if (!response.IsSuccessStatusCode)

View File

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

View File

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