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> <ItemGroup>
<Content Include="Scripts\gestiondenuncias_schema.sql" CopyToOutputDirectory="PreserveNewest" /> <Content Include="Scripts\gestiondenuncias_schema.sql" CopyToOutputDirectory="PreserveNewest" />
<Content Include="Scripts\gestiondenuncias_envelope_encryption.sql" CopyToOutputDirectory="PreserveNewest" /> <Content Include="Scripts\gestiondenuncias_envelope_encryption.sql" CopyToOutputDirectory="PreserveNewest" />
<Content Include="Scripts\gestiondenuncias_gestiona_expediente_excepciones.sql" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

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

View File

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

View File

@@ -1,3 +1,5 @@
using System.Globalization;
using System.Text;
using ApiDenuncias.Services; using ApiDenuncias.Services;
using GestionaDenuncias.Shared.Models; using GestionaDenuncias.Shared.Models;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
@@ -13,15 +15,21 @@ public sealed class DenunciasController : ControllerBase
private readonly IDenunciaStore _denunciaStore; private readonly IDenunciaStore _denunciaStore;
private readonly IFilteredDenunciaStore _filteredDenunciaStore; private readonly IFilteredDenunciaStore _filteredDenunciaStore;
private readonly UserComplaintAccessService _accessService; private readonly UserComplaintAccessService _accessService;
private readonly IInboxTrackingService _trackingService;
private readonly ILogger<DenunciasController> _logger;
public DenunciasController( public DenunciasController(
IDenunciaStore denunciaStore, IDenunciaStore denunciaStore,
IFilteredDenunciaStore filteredDenunciaStore, IFilteredDenunciaStore filteredDenunciaStore,
UserComplaintAccessService accessService) UserComplaintAccessService accessService,
IInboxTrackingService trackingService,
ILogger<DenunciasController> logger)
{ {
_denunciaStore = denunciaStore; _denunciaStore = denunciaStore;
_filteredDenunciaStore = filteredDenunciaStore; _filteredDenunciaStore = filteredDenunciaStore;
_accessService = accessService; _accessService = accessService;
_trackingService = trackingService;
_logger = logger;
} }
[HttpPost("schema/ensure")] [HttpPost("schema/ensure")]
@@ -56,6 +64,25 @@ public sealed class DenunciasController : ControllerBase
return Ok(await _denunciaStore.GetDenunciaByIdAsync(denunciaId, cancellationToken)); 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] [HttpPost]
public async Task<IActionResult> Upsert(DenunciasGestiona denuncia, CancellationToken cancellationToken) public async Task<IActionResult> Upsert(DenunciasGestiona denuncia, CancellationToken cancellationToken)
{ {
@@ -65,6 +92,7 @@ public sealed class DenunciasController : ControllerBase
} }
await _denunciaStore.UpsertDenunciaAsync(denuncia, cancellationToken); await _denunciaStore.UpsertDenunciaAsync(denuncia, cancellationToken);
await TryRegisterGestionaHistoryFromComplaintAsync(denuncia, cancellationToken);
return Ok(new { ok = true }); return Ok(new { ok = true });
} }
@@ -91,6 +119,32 @@ public sealed class DenunciasController : ControllerBase
return Ok(await _denunciaStore.GetFicherosByDenunciaAsync(denunciaId, cancellationToken)); return Ok(await _denunciaStore.GetFicherosByDenunciaAsync(denunciaId, cancellationToken));
} }
[HttpGet("gestiona-history")]
public async Task<ActionResult<List<GestionaUploadHistoryEntry>>> GetGestionaHistory(CancellationToken cancellationToken)
{
await _denunciaStore.EnsureSchemaAsync(cancellationToken);
return Ok(await _denunciaStore.GetGestionaUploadHistoryAsync(cancellationToken));
}
[HttpPost("gestiona-history")]
public async Task<IActionResult> AddGestionaHistory(
GestionaUploadHistoryCreateRequest request,
CancellationToken cancellationToken)
{
if (!await CanAccessAsync(request.DenunciaId, cancellationToken))
{
return Forbid();
}
await _denunciaStore.AddGestionaUploadHistoryAsync(request, GetUsername(), cancellationToken);
await _trackingService.MarkReportHandledInGestionaAsync(
GetUsername(),
request.DenunciaId,
request.UploadedAtUtc,
cancellationToken);
return Ok(new { ok = true });
}
[HttpGet("{denunciaId:int}/ficheros/content")] [HttpGet("{denunciaId:int}/ficheros/content")]
public async Task<IActionResult> GetFicheroContent( public async Task<IActionResult> GetFicheroContent(
int denunciaId, int denunciaId,
@@ -178,6 +232,243 @@ public sealed class DenunciasController : ControllerBase
private string GetUsername() private string GetUsername()
=> User.Identity?.Name ?? throw new InvalidOperationException("No hay usuario autenticado."); => User.Identity?.Name ?? throw new InvalidOperationException("No hay usuario autenticado.");
private static 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) private static string GetAttachmentContentType(string? fileName)
{ {
return Path.GetExtension(fileName ?? string.Empty).ToLowerInvariant() switch return Path.GetExtension(fileName ?? string.Empty).ToLowerInvariant() switch

View File

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

View File

@@ -10,6 +10,13 @@ namespace ApiDenuncias.Controllers;
[Route("api/inbox")] [Route("api/inbox")]
public sealed class InboxController : ControllerBase public sealed class InboxController : ControllerBase
{ {
private static readonly TimeSpan[] ExportRetryDelays =
[
TimeSpan.FromSeconds(2),
TimeSpan.FromSeconds(5),
TimeSpan.FromSeconds(10)
];
private readonly GlobalLeaksSessionStore _sessionStore; private readonly GlobalLeaksSessionStore _sessionStore;
private readonly PendingGlobalLeaksLoginStore _pendingLoginStore; private readonly PendingGlobalLeaksLoginStore _pendingLoginStore;
private readonly GlobalLeaksClient _globalLeaksClient; private readonly GlobalLeaksClient _globalLeaksClient;
@@ -74,7 +81,7 @@ public sealed class InboxController : ControllerBase
_logger.LogError(ex, "No se ha podido preparar la renovacion GlobalLeaks para {Username}.", username); _logger.LogError(ex, "No se ha podido preparar la renovacion GlobalLeaks para {Username}.", username);
return StatusCode( return StatusCode(
StatusCodes.Status500InternalServerError, StatusCodes.Status500InternalServerError,
new ApiError($"No se ha podido preparar la renovacion: {ex.GetType().Name}: {ex.Message}")); new ApiError("No se ha podido preparar la renovacion de GlobalLeaks. Intentalo de nuevo en unos segundos."));
} }
} }
@@ -141,7 +148,7 @@ public sealed class InboxController : ControllerBase
_logger.LogError(ex, "No se ha podido cargar la bandeja GlobalLeaks para {Username}.", username); _logger.LogError(ex, "No se ha podido cargar la bandeja GlobalLeaks para {Username}.", username);
return StatusCode( return StatusCode(
StatusCodes.Status500InternalServerError, StatusCodes.Status500InternalServerError,
new ApiError($"No se ha podido cargar la bandeja: {ex.GetType().Name}: {ex.Message}")); new ApiError("No se ha podido renovar la sesion de GlobalLeaks. Intentalo de nuevo en unos segundos."));
} }
} }
@@ -164,12 +171,17 @@ public sealed class InboxController : ControllerBase
try try
{ {
var state = await _trackingService.GetUserStateAsync(username, cancellationToken);
var contexts = await _globalLeaksClient.GetContextsAsync(session.SessionId!, cancellationToken); var contexts = await _globalLeaksClient.GetContextsAsync(session.SessionId!, cancellationToken);
var reports = await _globalLeaksClient.GetReportsAsync(session.SessionId!, "all", null, null, cancellationToken, contexts); var reports = await _globalLeaksClient.GetReportsAsync(session.SessionId!, "all", null, null, cancellationToken, contexts);
var enrichedReports = await _trackingService.RegisterSnapshotAsync(username, reports, cancellationToken); 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) catch (GlobalLeaksSessionExpiredException)
{ {
@@ -178,14 +190,14 @@ public sealed class InboxController : ControllerBase
} }
catch (GlobalLeaksValidationException ex) catch (GlobalLeaksValidationException ex)
{ {
return StatusCode(ex.StatusCode, new ApiError(ex.Message)); return ToGlobalLeaksApiError(ex, "cargar la bandeja de GlobalLeaks");
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "No se ha podido cargar la bandeja GlobalLeaks para {Username}.", username); _logger.LogError(ex, "No se ha podido cargar la bandeja GlobalLeaks para {Username}.", username);
return StatusCode( return StatusCode(
StatusCodes.Status500InternalServerError, StatusCodes.Status500InternalServerError,
new ApiError($"No se ha podido cargar la bandeja: {ex.GetType().Name}: {ex.Message}")); new ApiError("No se ha podido cargar la bandeja de GlobalLeaks. Intentalo de nuevo en unos segundos."));
} }
} }
@@ -210,7 +222,25 @@ public sealed class InboxController : ControllerBase
{ {
await _trackingService.EnsureReportCanBeImportedByUserAsync(username, report, cancellationToken); 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; FileDownloadResult? json = null;
try try
@@ -222,7 +252,7 @@ public sealed class InboxController : ControllerBase
json = null; json = null;
} }
var result = await _inboxService.ImportFromGlobalLeaksAsync(zip, json, cancellationToken); var result = await _inboxService.ImportFromGlobalLeaksAsync(reportPackage, json, reportDetail, cancellationToken);
if (result.ImportedCount > 0) if (result.ImportedCount > 0)
{ {
await _trackingService.MarkReportImportedAsync( await _trackingService.MarkReportImportedAsync(
@@ -241,14 +271,14 @@ public sealed class InboxController : ControllerBase
} }
catch (GlobalLeaksValidationException ex) catch (GlobalLeaksValidationException ex)
{ {
return StatusCode(ex.StatusCode, new ApiError(ex.Message)); return ToGlobalLeaksApiError(ex, "importar la denuncia");
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "No se ha podido importar la denuncia {ReportId} para {Username}.", reportId, username); _logger.LogError(ex, "No se ha podido importar la denuncia {ReportId} para {Username}.", reportId, username);
return StatusCode( return StatusCode(
StatusCodes.Status500InternalServerError, StatusCodes.Status500InternalServerError,
new ApiError($"No se ha podido importar la denuncia: {ex.GetType().Name}: {ex.Message}")); new ApiError("No se ha podido importar la denuncia. Intentalo de nuevo en unos segundos."));
} }
} }
@@ -276,37 +306,78 @@ public sealed class InboxController : ControllerBase
} }
catch (GlobalLeaksValidationException ex) catch (GlobalLeaksValidationException ex)
{ {
return StatusCode(ex.StatusCode, new ApiError(ex.Message)); return ToGlobalLeaksApiError(ex, "leer el detalle de la denuncia");
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "No se ha podido leer el detalle de la denuncia {ReportId} para {Username}.", reportId, username); _logger.LogError(ex, "No se ha podido leer el detalle de la denuncia {ReportId} para {Username}.", reportId, username);
return StatusCode( return StatusCode(
StatusCodes.Status500InternalServerError, StatusCodes.Status500InternalServerError,
new ApiError($"No se ha podido leer el detalle de la denuncia: {ex.GetType().Name}: {ex.Message}")); new ApiError("No se ha podido abrir el detalle de la denuncia. Intentalo de nuevo en unos segundos."));
} }
} }
[HttpPost("local/ensure-storage")] private async Task<FileDownloadResult> DownloadReportPackageWithRetryAsync(
public async Task<IActionResult> EnsureStorage(CancellationToken cancellationToken) string sessionId,
ReportDto report,
CancellationToken cancellationToken)
{ {
await _inboxService.EnsureStorageReadyAsync(cancellationToken); for (var attempt = 0; ; attempt++)
return Ok(new { ok = true }); {
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")] private static bool IsExportNotReady(GlobalLeaksValidationException ex)
public async Task<ActionResult<ImportSummary>> ProcessLocalZips(CancellationToken cancellationToken) => ex.StatusCode is >= 500 and <= 504;
=> Ok(await _inboxService.ProcessPendingFolderZipsAsync(cancellationToken));
[HttpGet("local/zips")] private static ActionResult ToGlobalLeaksApiError(GlobalLeaksValidationException ex, string operation)
public async Task<ActionResult<IReadOnlyList<string>>> GetLocalZips(CancellationToken cancellationToken)
=> Ok(await _inboxService.GetExistingZipNamesAsync(cancellationToken));
[HttpDelete("local/zips/{zipName}")]
public async Task<IActionResult> DeleteLocalZip(string zipName, CancellationToken cancellationToken)
{ {
await _inboxService.DeleteZipAsync(zipName, cancellationToken); if (ex.StatusCode == StatusCodes.Status401Unauthorized ||
return Ok(new { ok = true }); ex.StatusCode == StatusCodes.Status403Forbidden)
{
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) private async Task<GlobalLeaksStoredSession?> RequireActiveSessionAsync(string username, CancellationToken cancellationToken)
@@ -322,4 +393,7 @@ public sealed class InboxController : ControllerBase
=> session is null => session is null
? null ? null
: new ApiGlobalLeaksSessionDto(session.Username, session.Role, session.HasActiveSession, session.UpdatedAt); : new ApiGlobalLeaksSessionDto(session.Username, session.Role, session.HasActiveSession, session.UpdatedAt);
} }

View File

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

View File

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

View File

@@ -42,6 +42,7 @@ builder.Services.AddScoped<IFilteredDenunciaStore>(sp => sp.GetRequiredService<E
builder.Services.AddScoped<IInboxTrackingService, InboxTrackingService>(); builder.Services.AddScoped<IInboxTrackingService, InboxTrackingService>();
builder.Services.AddScoped<DenunciaInboxService>(); builder.Services.AddScoped<DenunciaInboxService>();
builder.Services.AddScoped<GestionaDocumentWorkflowService>(); builder.Services.AddScoped<GestionaDocumentWorkflowService>();
builder.Services.AddScoped<GestionaExpedienteExceptionStore>();
builder.Services.AddScoped<UserComplaintAccessService>(); builder.Services.AddScoped<UserComplaintAccessService>();
builder.Services.AddHttpClient<ManualPurgeService>(); builder.Services.AddHttpClient<ManualPurgeService>();
builder.Services.AddScoped<AppConfigurationService>(); 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(); var app = builder.Build();

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
using System.Net.Http.Headers; using System.Net.Http.Headers;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Globalization;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -35,6 +36,17 @@ public sealed class GestionaDocumentWorkflowService
_configuration["Gestiona:AccessToken"] _configuration["Gestiona:AccessToken"]
?? throw new InvalidOperationException("Falta Gestiona:AccessToken en appsettings."); ?? throw new InvalidOperationException("Falta Gestiona:AccessToken en appsettings.");
private string DocumentMetadataLanguage =>
_configuration["Gestiona:DocumentMetadataLanguage"] ?? "es";
private string DocumentMetadataType =>
_configuration["Gestiona:DocumentMetadataType"]
?? throw new InvalidOperationException("Falta Gestiona:DocumentMetadataType para informar el tipo documental.");
private string DocumentMetadataSubtype =>
_configuration["Gestiona:DocumentMetadataSubtype"]
?? throw new InvalidOperationException("Falta Gestiona:DocumentMetadataSubtype para informar el subtipo documental.");
public async Task<string> UploadDocumentAndReturnUrlAsync(string fileUrl, byte[] contentBytes, string fileName) public async Task<string> UploadDocumentAndReturnUrlAsync(string fileUrl, byte[] contentBytes, string fileName)
{ {
var fileUrlAbs = EnsureAbsoluteGestionaUrl(fileUrl, GestionaApiBase); var fileUrlAbs = EnsureAbsoluteGestionaUrl(fileUrl, GestionaApiBase);
@@ -47,7 +59,9 @@ public sealed class GestionaDocumentWorkflowService
name = fileName, name = fileName,
description = "Documento de denuncia", description = "Documento de denuncia",
elaboration_state = "EE01", elaboration_state = "EE01",
metadata_language = "ES", metadata_language = DocumentMetadataLanguage,
metadata_type = DocumentMetadataType,
metadata_subtype = DocumentMetadataSubtype,
links = new[] { new { rel = "content", href = uploadUri } } links = new[] { new { rel = "content", href = uploadUri } }
}; };
@@ -106,28 +120,24 @@ public sealed class GestionaDocumentWorkflowService
throw new InvalidOperationException("No se pudo obtener la URL del documento creado en Gestiona."); throw new InvalidOperationException("No se pudo obtener la URL del documento creado en Gestiona.");
} }
public async Task TramitarDocumentoAsync(string documentUrl, string assignedGroupHref, int? complaintId = null) public async Task TramitarDocumentoAsync(
string documentUrl,
string assignedGroupCode,
int? complaintId = null,
bool isUpdate = false)
{ {
_ = assignedGroupHref;
var docUrlAbs = EnsureAbsoluteGestionaUrl(documentUrl, GestionaApiBase); var docUrlAbs = EnsureAbsoluteGestionaUrl(documentUrl, GestionaApiBase);
var templateHref = GetConfiguredTemplateHref(docUrlAbs); var operationLabel = isUpdate ? $"actualizacion grupo {NormalizeGroupCode(assignedGroupCode)}" : "nueva denuncia";
var (templateHref, payload) = await ResolveCircuitTemplatePayloadAsync(docUrlAbs, isUpdate, assignedGroupCode);
if (string.IsNullOrWhiteSpace(templateHref))
{
throw new InvalidOperationException(
"Falta Gestiona:CircuitTemplateId. No se listan plantillas para evitar campos deprecated.");
}
var payload = await GetCircuitTemplatePayloadAsync(templateHref);
var (success, statusCode, body) = await TryPostCircuitAsync(docUrlAbs, payload); var (success, statusCode, body) = await TryPostCircuitAsync(docUrlAbs, payload);
if (success) if (success)
{ {
_logger.LogInformation( _logger.LogInformation(
"Documento {DocumentUrl} enviado a circuito con plantilla {TemplateHref}. Denuncia={ComplaintId}.", "Documento {DocumentUrl} enviado a circuito con plantilla {TemplateHref}. Denuncia={ComplaintId}. Tipo={OperationType}.",
docUrlAbs, docUrlAbs,
templateHref, templateHref,
complaintId); complaintId,
operationLabel);
return; return;
} }
@@ -142,6 +152,171 @@ public sealed class GestionaDocumentWorkflowService
$"TramitarDocumentoAsync: {(int)statusCode} {statusCode}\n{body}"); $"TramitarDocumentoAsync: {(int)statusCode} {statusCode}\n{body}");
} }
private async Task<(string TemplateHref, string Payload)> ResolveCircuitTemplatePayloadAsync(
string documentUrl,
bool isUpdate,
string? assignedGroupCode)
{
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) private async Task<string> GetCircuitTemplatePayloadAsync(string templateHref)
{ {
using var req = new HttpRequestMessage(HttpMethod.Get, templateHref); using var req = new HttpRequestMessage(HttpMethod.Get, templateHref);
@@ -182,14 +357,6 @@ public sealed class GestionaDocumentWorkflowService
return (resp.IsSuccessStatusCode, resp.StatusCode, body); return (resp.IsSuccessStatusCode, resp.StatusCode, body);
} }
private string? GetConfiguredTemplateHref(string documentUrl)
{
var templateId = _configuration["Gestiona:CircuitTemplateId"];
return string.IsNullOrWhiteSpace(templateId)
? null
: $"{documentUrl.TrimEnd('/')}/circuit/templates/{templateId.Trim()}";
}
private HttpClient CreateRawHttp() => _httpClientFactory.CreateClient(); private HttpClient CreateRawHttp() => _httpClientFactory.CreateClient();
private async Task<string> CreateUploadAsync(byte[] contentBytes, string fileName) private async Task<string> CreateUploadAsync(byte[] contentBytes, string fileName)
@@ -279,6 +446,65 @@ public sealed class GestionaDocumentWorkflowService
return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant(); return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
} }
private static string GetRequiredSelfHref(JsonElement item)
{
return GetLinkHref(item, "self")
?? throw new InvalidOperationException("La plantilla de circuito no contiene link 'self'.");
}
private static string? GetLinkHref(JsonElement item, string rel)
{
if (!item.TryGetProperty("links", out var links) || links.ValueKind != JsonValueKind.Array)
{
return null;
}
foreach (var link in links.EnumerateArray())
{
if (link.TryGetProperty("rel", out var relProp) &&
string.Equals(relProp.GetString(), rel, StringComparison.OrdinalIgnoreCase) &&
link.TryGetProperty("href", out var hrefProp) &&
hrefProp.ValueKind == JsonValueKind.String)
{
return hrefProp.GetString();
}
}
return null;
}
private static string? GetJsonString(JsonElement item, string propertyName)
{
return item.TryGetProperty(propertyName, out var property) &&
property.ValueKind == JsonValueKind.String
? property.GetString()
: null;
}
private static string NormalizeKey(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return string.Empty;
}
var normalized = value.Normalize(NormalizationForm.FormD);
var builder = new StringBuilder(normalized.Length);
foreach (var character in normalized)
{
var category = CharUnicodeInfo.GetUnicodeCategory(character);
if (category == UnicodeCategory.NonSpacingMark)
{
continue;
}
builder.Append(char.IsLetterOrDigit(character) ? char.ToUpperInvariant(character) : ' ');
}
return string.Join(' ', builder.ToString().Split(' ', StringSplitOptions.RemoveEmptyEntries));
}
private void LogDeprecatedHeaders(HttpResponseMessage response, string operation) private void LogDeprecatedHeaders(HttpResponseMessage response, string operation)
{ {
if (response.Headers.TryGetValues("X-Gestiona-Deprecated", out var deprecated)) if (response.Headers.TryGetValues("X-Gestiona-Deprecated", out var deprecated))

View File

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

View File

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

View File

@@ -3,6 +3,7 @@ using System.Globalization;
using System.Net; using System.Net;
using System.Net.Http.Headers; using System.Net.Http.Headers;
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
@@ -18,6 +19,8 @@ public sealed record PreparedGlobalLeaksCredentials(string Username, string Fina
public sealed class GlobalLeaksClient public sealed class GlobalLeaksClient
{ {
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); 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 HttpClient _httpClient;
private readonly ILogger<GlobalLeaksClient> _logger; private readonly ILogger<GlobalLeaksClient> _logger;
private readonly GlobalLeaksOptions _options; private readonly GlobalLeaksOptions _options;
@@ -129,11 +132,12 @@ public sealed class GlobalLeaksClient
authcode?.Length ?? 0); authcode?.Length ?? 0);
var currentTokenAnswer = tokenAnswer; var currentTokenAnswer = tokenAnswer;
using var dpopKey = ECDsa.Create(ECCurve.NamedCurves.nistP256);
for (var attempt = 0; attempt < 2; attempt++) for (var attempt = 0; attempt < 2; attempt++)
{ {
using var authRequest = CreateRequest( using var authRequest = CreateRequest(
HttpMethod.Post, HttpMethod.Post,
$"/api/auth/authentication?token={Uri.EscapeDataString(currentTokenAnswer)}"); AuthenticationPath);
authRequest.Content = CreateJsonContent(new authRequest.Content = CreateJsonContent(new
{ {
tid = 1, tid = 1,
@@ -142,8 +146,9 @@ public sealed class GlobalLeaksClient
authcode = authcode?.Trim() ?? string.Empty, authcode = authcode?.Trim() ?? string.Empty,
}); });
authRequest.Headers.TryAddWithoutValidation("X-Token", currentTokenAnswer); 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) if (authResponse.IsSuccessStatusCode)
{ {
var authBody = await authResponse.Content.ReadAsStringAsync(cancellationToken); var authBody = await authResponse.Content.ReadAsStringAsync(cancellationToken);
@@ -168,6 +173,9 @@ public sealed class GlobalLeaksClient
throw authResponse.StatusCode switch 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( HttpStatusCode.Unauthorized => new GlobalLeaksValidationException(
"Credenciales incorrectas o codigo 2FA invalido.", "Credenciales incorrectas o codigo 2FA invalido.",
StatusCodes.Status401Unauthorized), StatusCodes.Status401Unauthorized),
@@ -291,13 +299,65 @@ public sealed class GlobalLeaksClient
ReminderDate = t.ReminderDate, ReminderDate = t.ReminderDate,
AccessDate = t.AccessDate, AccessDate = t.AccessDate,
LastAccess = t.LastAccess, LastAccess = t.LastAccess,
WhistleblowerLastAccess = t.WhistleblowerLastAccess,
Status = t.Status, Status = t.Status,
Updated = t.Updated, Updated = t.Updated,
Accessible = t.Accessible,
Label = t.Label, Label = t.Label,
}) })
.ToArray(); .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( public async Task<ReportDetailDto> GetReportDetailAsync(
string sessionId, string sessionId,
string reportId, string reportId,
@@ -306,23 +366,25 @@ public sealed class GlobalLeaksClient
{ {
ValidateUuid(reportId); ValidateUuid(reportId);
using var detailRequest = CreateAuthenticatedRequest(HttpMethod.Get, $"/api/recipient/rtips/{reportId}", sessionId); var reference = ParseDate(lastAccess);
using var detailResponse = await SendGlRequestAsync(detailRequest, cancellationToken); using var document = await ReadReportDetailDocumentAsync(sessionId, reportId, cancellationToken);
IReadOnlyList<ReportCommentDto>? comments = null;
var contentType = detailResponse.Content.Headers.ContentType?.MediaType ?? string.Empty; try
var content = await detailResponse.Content.ReadAsByteArrayAsync(cancellationToken);
if (!contentType.Contains("json", StringComparison.OrdinalIgnoreCase) || content.Length == 0)
{ {
throw new GlobalLeaksValidationException( comments = await GetReportCommentsAsync(sessionId, reportId, reference, defaultNewWhenNoReference: true, cancellationToken);
"No se pudo leer el detalle de la denuncia. Puede estar cifrada sin clave disponible en el servidor.", }
422); 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, comments);
return ParseReportDetail(reportId, lastAccess, document.RootElement);
} }
public async Task<FileDownloadResult> DownloadReportZipAsync( public async Task<FileDownloadResult> DownloadReportPackageAsync(
string sessionId, string sessionId,
string reportId, string reportId,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@@ -347,7 +409,7 @@ public sealed class GlobalLeaksClient
var fileName = SanitizeFileName( var fileName = SanitizeFileName(
ExtractFileName(response.Content.Headers.ContentDisposition), ExtractFileName(response.Content.Headers.ContentDisposition),
$"report-{reportId}.zip"); $"report-{reportId}");
return new FileDownloadResult(content, fileName); return new FileDownloadResult(content, fileName);
} }
@@ -379,6 +441,164 @@ public sealed class GlobalLeaksClient
return new FileDownloadResult(content, $"report-{progressive}.json"); 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( private async Task<HttpResponseMessage> SendLoginRequestAsync(
HttpRequestMessage request, HttpRequestMessage request,
string endpoint, string endpoint,
@@ -497,6 +717,49 @@ public sealed class GlobalLeaksClient
return Convert.ToBase64String(hash); 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) private static byte[] ComputeArgon2(byte[] input, byte[] salt, int iterations, int memoryKb)
{ {
var argon2 = new Argon2id(input) var argon2 = new Argon2id(input)
@@ -530,6 +793,34 @@ public sealed class GlobalLeaksClient
: null; : 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) private static string ExtractName(JsonElement? name, string fallback)
{ {
if (name is null) if (name is null)
@@ -636,8 +927,10 @@ public sealed class GlobalLeaksClient
ReminderDate = GetString(item, "reminder_date", "reminderDate"), ReminderDate = GetString(item, "reminder_date", "reminderDate"),
AccessDate = GetString(item, "access_date", "accessDate"), AccessDate = GetString(item, "access_date", "accessDate"),
LastAccess = GetString(item, "last_access", "lastAccess"), LastAccess = GetString(item, "last_access", "lastAccess"),
WhistleblowerLastAccess = GetString(item, "wb_last_access", "wbLastAccess"),
Status = GetString(item, "status"), Status = GetString(item, "status"),
Updated = GetBool(item, "updated"), Updated = GetBool(item, "updated"),
Accessible = GetNullableBool(item, "accessible"),
Label = GetString(item, "label"), Label = GetString(item, "label"),
}); });
} }
@@ -645,51 +938,190 @@ public sealed class GlobalLeaksClient
return reports; 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); var lastAccessDate = ParseDate(lastAccess);
bool IsNew(string? value) var comments = commentsOverride ?? EnumerateArray(root, "comments")
.Select(item => CreateReportComment(item, lastAccessDate, defaultNewWhenNoReference: true))
.ToArray();
var whistleblowerFiles = ParseReportFiles(root, lastAccessDate, defaultNewWhenNoReference: true, "wbfiles", "files");
var receiverFiles = ParseReportFiles(root, lastAccessDate, defaultNewWhenNoReference: true, "rfiles");
return new ReportDetailDto(reportId, lastAccess, comments, whistleblowerFiles, receiverFiles);
}
private static ReportCommentDto CreateReportComment(
JsonElement item,
DateTimeOffset? reference,
bool defaultNewWhenNoReference)
{ {
if (lastAccessDate is null) 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)
{ {
return true; 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); var itemDate = ParseDate(value);
return itemDate is not null && itemDate > lastAccessDate; return itemDate is not null && itemDate > reference;
}
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"))))
.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();
return new ReportDetailDto(reportId, lastAccess, comments, whistleblowerFiles, receiverFiles);
} }
private static IEnumerable<JsonElement> EnumerateArray(JsonElement root, params string[] names) private static IEnumerable<JsonElement> EnumerateArray(JsonElement root, params string[] names)
@@ -839,6 +1271,28 @@ public sealed class GlobalLeaksClient
return false; 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) private static string SanitizeFileName(string? name, string fallback)
{ {
if (string.IsNullOrWhiteSpace(name)) if (string.IsNullOrWhiteSpace(name))
@@ -922,8 +1376,20 @@ public sealed class GlobalLeaksClient
body.Contains("Invalid request: No token", StringComparison.OrdinalIgnoreCase)); 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 TokenResponse(string Id, string Salt);
private sealed record AuthTypeResponse(string Type, 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 private sealed record RawReport
{ {
@@ -936,8 +1402,10 @@ public sealed class GlobalLeaksClient
public string? ReminderDate { get; init; } public string? ReminderDate { get; init; }
public string? AccessDate { get; init; } public string? AccessDate { get; init; }
public string? LastAccess { get; init; } public string? LastAccess { get; init; }
public string? WhistleblowerLastAccess { get; init; }
public string? Status { get; init; } public string? Status { get; init; }
public bool Updated { get; init; } public bool Updated { get; init; }
public bool? Accessible { get; init; }
public string? Label { get; init; } public string? Label { get; init; }
} }
} }

View File

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

View File

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

View File

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

View File

@@ -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) private async Task<long> EnsureUserAsync(MySqlConnection connection, string username, CancellationToken cancellationToken)
{ {
const string insertSql = """ const string insertSql = """

View File

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

View File

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

View File

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

View File

@@ -36,12 +36,16 @@ public sealed record MarkReportImportedRequest(
ReportDto Report, ReportDto Report,
int? ComplaintId); int? ComplaintId);
public sealed record MarkReportHandledInGestionaRequest(
string Username,
int DenunciaId,
DateTime UploadedAtUtc);
public sealed record TrackingImportPermissionRequest( public sealed record TrackingImportPermissionRequest(
string Username, string Username,
ReportDto Report); ReportDto Report);
public sealed record GestionaCreateFileRequest( public sealed record GestionaCreateFileRequest(
Guid ProcedureId,
string Subject, string Subject,
string DocumentSeries, string DocumentSeries,
string SiaCode); string SiaCode);
@@ -53,16 +57,22 @@ public sealed record GestionaCreateFileResponse(
public sealed record GestionaOpenFileRequest( public sealed record GestionaOpenFileRequest(
string FileUrl, string FileUrl,
string? FileOpenUrl, string? FileOpenUrl,
Guid ManagementUnitGroupId, string AssignedGroupCode,
Guid AssignedGroupId,
bool Confidential, bool Confidential,
string FreeTitle, string FreeTitle);
string SiaCode);
public sealed record GestionaAssignFileRequest(
string FileUrl,
string AssignedGroupCode);
public sealed record GestionaEnsureThirdRequest( public sealed record GestionaEnsureThirdRequest(
string FileUrl, string FileUrl,
ThirdPartyIdentityData ThirdParty); ThirdPartyIdentityData ThirdParty);
public sealed record GestionaEnsureThirdResponse(
bool Ok,
IReadOnlyList<string> Warnings);
public sealed record GestionaCreateFolderRequest( public sealed record GestionaCreateFolderRequest(
string FileUrl, string FileUrl,
string FolderName); string FolderName);
@@ -80,8 +90,9 @@ public sealed record GestionaUploadDocumentResponse(string DocumentUrl);
public sealed record GestionaTramitarDocumentoRequest( public sealed record GestionaTramitarDocumentoRequest(
string DocumentUrl, string DocumentUrl,
string AssignedGroupHref, string AssignedGroupCode,
int? ComplaintId); int? ComplaintId,
bool IsUpdate = false);
public sealed record ManualPurgeRequest(string Date); public sealed record ManualPurgeRequest(string Date);
@@ -91,6 +102,23 @@ public sealed record ManualPurgeResponse(
int StatusCode, int StatusCode,
string ResponseBody); string ResponseBody);
public sealed record AppConfigurationDto(string? ExternalUpdateCutoffDate); public sealed record AppConfigurationDto(
string? ExternalUpdateCutoffDate,
string? LatestEncryptionKeyDate = null,
string? LatestEncryptionKeyStatus = null);
public sealed record UpdateExternalUpdateCutoffRequest(string? Date); public sealed record UpdateExternalUpdateCutoffRequest(string? Date);
public sealed record 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 EstadoDenuncia { get; set; } = string.Empty;
public string ArchivoElegido { get; set; } = string.Empty; public string ArchivoElegido { get; set; } = string.Empty;
public DateTime FechaSubidaAGestiona { get; set; } = DateTime.MinValue; public DateTime FechaSubidaAGestiona { get; set; } = DateTime.MinValue;
public string UltimaSubidaGestionaTipo { get; set; } = string.Empty;
public string UltimoGrupoAsignadoGestiona { get; set; } = string.Empty;
public bool EnGestiona { get; set; } public bool EnGestiona { get; set; }
public bool EnRechazada { 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() public IReadOnlyList<ReportFieldEntry> GetCamposFormulario()
{ {
if (string.IsNullOrWhiteSpace(CamposFormularioJson)) if (string.IsNullOrWhiteSpace(CamposFormularioJson))

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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