Compare commits

...

12 Commits

Author SHA1 Message Date
27705440cd 1 2026-06-18 10:48:56 +02:00
6d22d5d97a . 2026-06-17 08:09:39 +02:00
8636283695 . 2026-06-17 08:02:46 +02:00
9db3d3fa61 . 2026-06-16 14:29:46 +02:00
84305493de arreglo y gestionpersonal 2026-06-16 14:13:15 +02:00
d84d41b0e0 Merge branch 'main' of https://gitea.tecnosis.net/Antifraude/Antifraude.Net 2026-06-08 12:58:45 +02:00
ff2867d916 denuncias 2026-06-08 12:58:30 +02:00
8458d9eae1 . 2026-06-08 12:58:06 +02:00
afa1863038 . 2026-05-21 12:39:32 +02:00
8163928623 arreglo persona estatica 2026-05-21 12:27:23 +02:00
b62cfd46c1 denuncias 2026-05-21 12:07:51 +02:00
693d950dfa edicion Incidencias infoPers 2026-05-20 16:14:57 +02:00
81 changed files with 6569 additions and 1195 deletions

View File

@@ -21,6 +21,7 @@
<system.Web>
<httpRuntime targetFramework="4.8" />
</system.Web>
-->
<system.web>
<sessionState timeout="300000">
@@ -103,14 +104,8 @@
<errors callbackErrorRedirectUrl="" />
</devExpress>
<appSettings>
<add key="UrlCertLogin" value="https://cwe-antifraude.tecnosis.online/LoginCertBridge.aspx" />
<add key="CertHeaderName" value="X-ARR-ClientCert" />
<add key="AdditionalCertHeaders" value="X-Client-Cert|X-Client-Certificate|X-SSL-CERT|Ssl-Client-Cert" />
<add key="CertLoginEndpoint" value="api/Auth/login-cert-proxy" />
<add key="AllowedParentOrigins" value="https://we-antifraude.tecnosis.online" />
<add key="vs:EnableBrowserLink" value="false" />
<!--<add key="RutaRes" value="https://localhost:44300" />-->
<add key="vs:EnableBrowserLink" value="false" />
<add key="RutaRes" value="https://localhost:44300" />
<!--<add key="RutaRes" value="http://192.168.41.122:888" />-->
<add key="SwaggerVB" value="http://localhost:103/" />
<!--produccion-->

View File

@@ -8,6 +8,7 @@
<ItemGroup>
<PackageReference Include="Azure.Identity" Version="1.13.2" />
<PackageReference Include="Azure.Security.KeyVault.Keys" Version="4.7.0" />
<PackageReference Include="Azure.Security.KeyVault.Secrets" Version="4.7.0" />
<PackageReference Include="Konscious.Security.Cryptography.Argon2" Version="1.3.1" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.25" />
@@ -21,6 +22,7 @@
<ItemGroup>
<Content Include="Scripts\gestiondenuncias_schema.sql" CopyToOutputDirectory="PreserveNewest" />
<Content Include="Scripts\gestiondenuncias_envelope_encryption.sql" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>

View File

@@ -11,4 +11,6 @@ public sealed class KeyVaultOptions
public string EncryptionKeySecretName { get; set; } = "denuncias-encryption-key";
public bool AllowLocalEncryptionKeyFallback { get; set; }
public int EncryptionKeyTimeoutSeconds { get; set; } = 25;
}

View File

@@ -0,0 +1,20 @@
namespace ApiDenuncias.Configuration;
public sealed class ManualPurgeOptions
{
public const string SectionName = "ManualPurge";
public string BaseUrl { get; set; } = "";
public string FunctionUrl { get; set; } = "https://func-keymgmt-pre.azurewebsites.net/api/manual_purge";
public string ForceRotateUrl { get; set; } = "https://func-keymgmt-pre.azurewebsites.net/api/force_rotate";
public string FunctionKeySecretName { get; set; } = "purge-function-key";
public bool ReplaceOnManualPurge { get; set; } = true;
public bool RecoverPartialReplaceFailure { get; set; } = true;
public int TimeoutSeconds { get; set; } = 30;
}

View File

@@ -18,25 +18,186 @@ public sealed class AuthController : ControllerBase
{
private readonly GlobalLeaksClient _globalLeaksClient;
private readonly GlobalLeaksSessionStore _sessionStore;
private readonly PendingGlobalLeaksLoginStore _pendingLoginStore;
private readonly LoginRateLimiter _rateLimiter;
private readonly JwtOptions _jwtOptions;
private readonly GlobalLeaksOptions _globalLeaksOptions;
private readonly ILogger<AuthController> _logger;
public AuthController(
GlobalLeaksClient globalLeaksClient,
GlobalLeaksSessionStore sessionStore,
PendingGlobalLeaksLoginStore pendingLoginStore,
LoginRateLimiter rateLimiter,
IOptions<JwtOptions> jwtOptions)
IOptions<JwtOptions> jwtOptions,
IOptions<GlobalLeaksOptions> globalLeaksOptions,
ILogger<AuthController> logger)
{
_globalLeaksClient = globalLeaksClient;
_sessionStore = sessionStore;
_pendingLoginStore = pendingLoginStore;
_rateLimiter = rateLimiter;
_jwtOptions = jwtOptions.Value;
_globalLeaksOptions = globalLeaksOptions.Value;
_logger = logger;
}
[HttpPost("login/prepare")]
[AllowAnonymous]
public async Task<ActionResult<ApiLoginPrepareResponse>> PrepareLogin(
ApiLoginPrepareRequest request,
CancellationToken cancellationToken)
{
var usernameForLogs = string.IsNullOrWhiteSpace(request.Username) ? "(sin usuario)" : request.Username.Trim();
var ip = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
if (!_rateLimiter.AllowAttempt(ip))
{
return StatusCode(StatusCodes.Status429TooManyRequests, new ApiError("Demasiados intentos. Espera un minuto."));
}
if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password))
{
return BadRequest(new ApiError("Debes indicar usuario y contrasena."));
}
try
{
var loginTimeoutSeconds = Math.Clamp(_globalLeaksOptions.TimeoutSeconds + 20, 30, 140);
using var loginCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
loginCancellation.CancelAfter(TimeSpan.FromSeconds(loginTimeoutSeconds));
_logger.LogInformation(
"Preparando login GlobalLeaks para {Username} contra {BaseUrl}. Timeout total API={TimeoutSeconds}s",
usernameForLogs,
_globalLeaksOptions.BaseUrl,
loginTimeoutSeconds);
var prepared = await _globalLeaksClient.PrepareLoginAsync(
request.Username.Trim(),
request.Password,
loginCancellation.Token);
var pending = _pendingLoginStore.Create(
prepared.Username,
request.Password,
prepared.FinalPassword,
prepared.TokenAnswer);
return Ok(new ApiLoginPrepareResponse(pending.Id, pending.Username, pending.ExpiresAtUtc));
}
catch (GlobalLeaksValidationException ex)
{
_logger.LogWarning(
"GlobalLeaks rechazo la preparacion del login de {Username}. Status={StatusCode}. Mensaje={Message}",
usernameForLogs,
ex.StatusCode,
ex.Message);
return StatusCode(ex.StatusCode, new ApiError(ex.Message));
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
return StatusCode(
StatusCodes.Status504GatewayTimeout,
new ApiError($"GlobalLeaks no ha preparado el login en {Math.Clamp(_globalLeaksOptions.TimeoutSeconds + 20, 30, 140)} segundos ({_globalLeaksOptions.BaseUrl})."));
}
catch (HttpRequestException ex)
{
return StatusCode(
StatusCodes.Status502BadGateway,
new ApiError($"No se ha podido conectar con GlobalLeaks ({_globalLeaksOptions.BaseUrl}): {ex.Message}"));
}
catch (Exception ex)
{
_logger.LogError(ex, "Error no controlado preparando login de {Username}", usernameForLogs);
return StatusCode(
StatusCodes.Status500InternalServerError,
new ApiError($"No se ha podido preparar el inicio de sesion en la API: {ex.Message}"));
}
}
[HttpPost("login/complete")]
[AllowAnonymous]
public async Task<ActionResult<ApiLoginResponse>> CompleteLogin(
ApiLoginCompleteRequest request,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(request.PendingLoginId))
{
return BadRequest(new ApiError("La preparacion del login ha caducado. Vuelve a introducir usuario y contrasena."));
}
if (string.IsNullOrWhiteSpace(request.Authcode) || !Regex.IsMatch(request.Authcode.Trim(), @"^\d{6}$"))
{
return BadRequest(new ApiError("El codigo 2FA debe tener exactamente 6 digitos."));
}
PendingGlobalLeaksLogin pending;
try
{
pending = _pendingLoginStore.Get(request.PendingLoginId);
}
catch (InvalidOperationException ex)
{
return BadRequest(new ApiError(ex.Message));
}
try
{
var loginTimeoutSeconds = Math.Clamp(_globalLeaksOptions.TimeoutSeconds + 20, 30, 140);
using var loginCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
loginCancellation.CancelAfter(TimeSpan.FromSeconds(loginTimeoutSeconds));
var session = await _globalLeaksClient.CompleteLoginAsync(
pending.Username,
pending.FinalPassword,
pending.TokenAnswer,
request.Authcode.Trim(),
loginCancellation.Token);
var response = await CreateLoginResponseAsync(
pending.Username,
pending.Password,
session,
loginCancellation.Token);
_pendingLoginStore.Remove(pending.Id);
return Ok(response);
}
catch (GlobalLeaksValidationException ex)
{
_logger.LogWarning(
"GlobalLeaks rechazo el 2FA de {Username}. Status={StatusCode}. Mensaje={Message}",
pending.Username,
ex.StatusCode,
ex.Message);
return StatusCode(ex.StatusCode, new ApiError(ex.Message));
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
return StatusCode(
StatusCodes.Status504GatewayTimeout,
new ApiError($"GlobalLeaks no ha completado el login en {Math.Clamp(_globalLeaksOptions.TimeoutSeconds + 20, 30, 140)} segundos ({_globalLeaksOptions.BaseUrl})."));
}
catch (HttpRequestException ex)
{
return StatusCode(
StatusCodes.Status502BadGateway,
new ApiError($"No se ha podido conectar con GlobalLeaks ({_globalLeaksOptions.BaseUrl}): {ex.Message}"));
}
catch (Exception ex)
{
_logger.LogError(ex, "Error no controlado completando login de {Username}", pending.Username);
return StatusCode(
StatusCodes.Status500InternalServerError,
new ApiError($"No se ha podido completar el inicio de sesion en la API: {ex.Message}"));
}
}
[HttpPost("login")]
[AllowAnonymous]
public async Task<ActionResult<ApiLoginResponse>> Login(LoginRequest request, CancellationToken cancellationToken)
{
var usernameForLogs = string.IsNullOrWhiteSpace(request.Username) ? "(sin usuario)" : request.Username.Trim();
var ip = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
if (!_rateLimiter.AllowAttempt(ip))
{
@@ -57,30 +218,65 @@ public sealed class AuthController : ControllerBase
try
{
var loginTimeoutSeconds = Math.Clamp(_globalLeaksOptions.TimeoutSeconds + 20, 30, 140);
using var loginCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
loginCancellation.CancelAfter(TimeSpan.FromSeconds(loginTimeoutSeconds));
_logger.LogInformation(
"Iniciando login GlobalLeaks para {Username} contra {BaseUrl}. Timeout total API={TimeoutSeconds}s",
usernameForLogs,
_globalLeaksOptions.BaseUrl,
loginTimeoutSeconds);
var session = await _globalLeaksClient.LoginAsync(
request.Username.Trim(),
request.Password,
request.Authcode.Trim(),
cancellationToken);
loginCancellation.Token);
var username = string.IsNullOrWhiteSpace(session.Username)
? request.Username.Trim()
: session.Username.Trim();
await _sessionStore.SaveAsync(username, request.Password, session.Id, session.Role, cancellationToken);
var expiresAtUtc = DateTimeOffset.UtcNow.AddMinutes(Math.Max(5, _jwtOptions.ExpirationMinutes));
var token = CreateJwt(username, session.Role, expiresAtUtc);
return Ok(new ApiLoginResponse(username, token, expiresAtUtc, session.Role));
return Ok(await CreateLoginResponseAsync(
request.Username.Trim(),
request.Password,
session,
loginCancellation.Token));
}
catch (GlobalLeaksValidationException ex)
{
_logger.LogWarning(
"GlobalLeaks rechazo el login de {Username}. Status={StatusCode}. Mensaje={Message}",
usernameForLogs,
ex.StatusCode,
ex.Message);
return StatusCode(ex.StatusCode, new ApiError(ex.Message));
}
catch
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
return StatusCode(StatusCodes.Status502BadGateway, new ApiError("No se ha podido conectar con GlobalLeaks."));
_logger.LogWarning(
"Timeout de GlobalLeaks al iniciar sesion para {Username}. BaseUrl={BaseUrl}. Timeout={TimeoutSeconds}s",
usernameForLogs,
_globalLeaksOptions.BaseUrl,
Math.Clamp(_globalLeaksOptions.TimeoutSeconds + 20, 30, 140));
return StatusCode(
StatusCodes.Status504GatewayTimeout,
new ApiError($"GlobalLeaks no ha completado el login en {Math.Clamp(_globalLeaksOptions.TimeoutSeconds + 20, 30, 140)} segundos ({_globalLeaksOptions.BaseUrl}). Revisa el visor de eventos de ApiDenuncias para ver el paso exacto."));
}
catch (HttpRequestException ex)
{
_logger.LogError(
ex,
"No se ha podido conectar con GlobalLeaks para {Username}. BaseUrl={BaseUrl}",
usernameForLogs,
_globalLeaksOptions.BaseUrl);
return StatusCode(
StatusCodes.Status502BadGateway,
new ApiError($"No se ha podido conectar con GlobalLeaks ({_globalLeaksOptions.BaseUrl}): {ex.Message}"));
}
catch (Exception ex)
{
_logger.LogError(ex, "Error no controlado durante el login de {Username}", usernameForLogs);
return StatusCode(
StatusCodes.Status500InternalServerError,
new ApiError($"No se ha podido completar el inicio de sesion en la API: {ex.Message}"));
}
}
@@ -97,6 +293,25 @@ public sealed class AuthController : ControllerBase
return Ok(new { ok = true });
}
private async Task<ApiLoginResponse> CreateLoginResponseAsync(
string fallbackUsername,
string password,
GlSession session,
CancellationToken cancellationToken)
{
var username = string.IsNullOrWhiteSpace(session.Username)
? fallbackUsername.Trim()
: session.Username.Trim();
_logger.LogInformation("Login GlobalLeaks validado para {Username}. Guardando sesion cifrada.", username);
await _sessionStore.SaveAsync(username, password, session.Id, session.Role, cancellationToken);
_logger.LogInformation("Sesion GlobalLeaks guardada para {Username}. Generando JWT.", username);
var expiresAtUtc = DateTimeOffset.UtcNow.AddMinutes(Math.Max(5, _jwtOptions.ExpirationMinutes));
var token = CreateJwt(username, session.Role, expiresAtUtc);
return new ApiLoginResponse(username, token, expiresAtUtc, session.Role);
}
private string CreateJwt(string username, string? role, DateTimeOffset expiresAtUtc)
{
if (string.IsNullOrWhiteSpace(_jwtOptions.SigningKey))

View File

@@ -0,0 +1,50 @@
using System.Globalization;
using ApiDenuncias.Services;
using GestionaDenuncias.Shared.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ApiDenuncias.Controllers;
[ApiController]
[Authorize]
[Route("api/configuration")]
public sealed class ConfigurationController : ControllerBase
{
private readonly AppConfigurationService _configurationService;
public ConfigurationController(AppConfigurationService configurationService)
{
_configurationService = configurationService;
}
[HttpGet]
public async Task<ActionResult<AppConfigurationDto>> Get(CancellationToken cancellationToken)
{
return Ok(await _configurationService.GetAsync(cancellationToken));
}
[HttpPut("external-update-cutoff")]
public async Task<ActionResult<AppConfigurationDto>> SetExternalUpdateCutoff(
UpdateExternalUpdateCutoffRequest request,
CancellationToken cancellationToken)
{
DateOnly? date = null;
if (!string.IsNullOrWhiteSpace(request.Date))
{
if (!DateOnly.TryParseExact(
request.Date.Trim(),
["yyyy-MM-dd", "dd/MM/yyyy"],
CultureInfo.InvariantCulture,
DateTimeStyles.None,
out var parsed))
{
return BadRequest(new ApiError("Debes indicar una fecha valida con formato YYYY-MM-DD o DD/MM/AAAA."));
}
date = parsed;
}
return Ok(await _configurationService.SetExternalUpdateCutoffDateAsync(date, cancellationToken));
}
}

View File

@@ -1,6 +1,5 @@
using ApiDenuncias.Services;
using GestionaDenuncias.Shared.Models;
using ApiDenuncias.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@@ -12,11 +11,16 @@ namespace ApiDenuncias.Controllers;
public sealed class DenunciasController : ControllerBase
{
private readonly IDenunciaStore _denunciaStore;
private readonly IFilteredDenunciaStore _filteredDenunciaStore;
private readonly UserComplaintAccessService _accessService;
public DenunciasController(IDenunciaStore denunciaStore, UserComplaintAccessService accessService)
public DenunciasController(
IDenunciaStore denunciaStore,
IFilteredDenunciaStore filteredDenunciaStore,
UserComplaintAccessService accessService)
{
_denunciaStore = denunciaStore;
_filteredDenunciaStore = filteredDenunciaStore;
_accessService = accessService;
}
@@ -28,11 +32,17 @@ public sealed class DenunciasController : ControllerBase
}
[HttpGet]
public async Task<ActionResult<List<DenunciasGestiona>>> GetAll(CancellationToken cancellationToken)
public async Task<ActionResult<List<DenunciasGestiona>>> GetAll(
[FromQuery] DenunciaListScope scope,
CancellationToken cancellationToken)
{
var allowedIds = await GetAllowedIdsAsync(cancellationToken);
var denuncias = await _denunciaStore.GetAllDenunciasAsync(cancellationToken);
return Ok(denuncias.Where(d => allowedIds.Contains(d.Id_Denuncia)).ToList());
if (allowedIds.Count == 0)
{
return Ok(new List<DenunciasGestiona>());
}
return Ok(await _filteredDenunciaStore.GetDenunciasByIdsAsync(allowedIds, scope, cancellationToken));
}
[HttpGet("{denunciaId:int}")]
@@ -62,8 +72,12 @@ public sealed class DenunciasController : ControllerBase
public async Task<ActionResult<List<FicherosDenuncias>>> GetAllFicheros(CancellationToken cancellationToken)
{
var allowedIds = await GetAllowedIdsAsync(cancellationToken);
var ficheros = await _denunciaStore.GetAllFicherosAsync(cancellationToken);
return Ok(ficheros.Where(f => allowedIds.Contains(f.Id_Denuncia)).ToList());
if (allowedIds.Count == 0)
{
return Ok(new List<FicherosDenuncias>());
}
return Ok(await _filteredDenunciaStore.GetFicherosByDenunciaIdsAsync(allowedIds, cancellationToken));
}
[HttpGet("{denunciaId:int}/ficheros")]
@@ -77,6 +91,37 @@ public sealed class DenunciasController : ControllerBase
return Ok(await _denunciaStore.GetFicherosByDenunciaAsync(denunciaId, cancellationToken));
}
[HttpGet("{denunciaId:int}/ficheros/content")]
public async Task<IActionResult> GetFicheroContent(
int denunciaId,
[FromQuery] string fileName,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(fileName))
{
return BadRequest(new ApiError("Nombre de fichero obligatorio."));
}
if (!await CanAccessAsync(denunciaId, cancellationToken))
{
return NotFound();
}
var ficheros = await _denunciaStore.GetFicherosByDenunciaAsync(denunciaId, cancellationToken);
var fichero = ficheros.FirstOrDefault(file =>
string.Equals(file.NombreFichero, fileName, StringComparison.Ordinal));
if (fichero?.Fichero is not { Length: > 0 } bytes)
{
return NotFound();
}
return File(
bytes,
GetAttachmentContentType(fichero.NombreFichero),
enableRangeProcessing: true);
}
[HttpPost("ficheros")]
public async Task<IActionResult> UpsertFicheros(UpsertFicherosRequest request, CancellationToken cancellationToken)
{
@@ -132,4 +177,18 @@ public sealed class DenunciasController : ControllerBase
private string GetUsername()
=> User.Identity?.Name ?? throw new InvalidOperationException("No hay usuario autenticado.");
private static string GetAttachmentContentType(string? fileName)
{
return Path.GetExtension(fileName ?? string.Empty).ToLowerInvariant() switch
{
".pdf" => "application/pdf",
".txt" => "text/plain",
".jpg" or ".jpeg" => "image/jpeg",
".png" => "image/png",
".gif" => "image/gif",
".zip" => "application/zip",
_ => "application/octet-stream"
};
}
}

View File

@@ -101,6 +101,10 @@ public sealed class GestionaController : ControllerBase
await _gestiona.AsegurarTerceroYEnlazarAsync(request.FileUrl, request.ThirdParty);
return Ok(new { ok = true });
}
catch (ArgumentException ex)
{
return BadRequest(new ApiError(ex.Message));
}
catch (InvalidOperationException ex)
{
return BadRequest(new ApiError(ex.Message));

View File

@@ -11,6 +11,7 @@ namespace ApiDenuncias.Controllers;
public sealed class InboxController : ControllerBase
{
private readonly GlobalLeaksSessionStore _sessionStore;
private readonly PendingGlobalLeaksLoginStore _pendingLoginStore;
private readonly GlobalLeaksClient _globalLeaksClient;
private readonly DenunciaInboxService _inboxService;
private readonly IInboxTrackingService _trackingService;
@@ -18,12 +19,14 @@ public sealed class InboxController : ControllerBase
public InboxController(
GlobalLeaksSessionStore sessionStore,
PendingGlobalLeaksLoginStore pendingLoginStore,
GlobalLeaksClient globalLeaksClient,
DenunciaInboxService inboxService,
IInboxTrackingService trackingService,
ILogger<InboxController> logger)
{
_sessionStore = sessionStore;
_pendingLoginStore = pendingLoginStore;
_globalLeaksClient = globalLeaksClient;
_inboxService = inboxService;
_trackingService = trackingService;
@@ -37,6 +40,44 @@ public sealed class InboxController : ControllerBase
return Ok(ToDto(session));
}
[HttpPost("session/renew/prepare")]
public async Task<ActionResult<ApiLoginPrepareResponse>> PrepareRenewSession(CancellationToken cancellationToken)
{
var username = GetUsername();
var current = await _sessionStore.GetAsync(username, cancellationToken);
if (current is null || string.IsNullOrWhiteSpace(current.Password))
{
return BadRequest(new ApiError("No hay credenciales guardadas para este usuario. Cierra sesion y vuelve a entrar."));
}
try
{
var prepared = await _globalLeaksClient.PrepareLoginAsync(
current.Username,
current.Password,
cancellationToken);
var pending = _pendingLoginStore.Create(
prepared.Username,
current.Password,
prepared.FinalPassword,
prepared.TokenAnswer);
return Ok(new ApiLoginPrepareResponse(pending.Id, pending.Username, pending.ExpiresAtUtc));
}
catch (GlobalLeaksValidationException ex)
{
return StatusCode(ex.StatusCode, new ApiError(ex.Message));
}
catch (Exception ex)
{
_logger.LogError(ex, "No se ha podido preparar la renovacion GlobalLeaks para {Username}.", username);
return StatusCode(
StatusCodes.Status500InternalServerError,
new ApiError($"No se ha podido preparar la renovacion: {ex.GetType().Name}: {ex.Message}"));
}
}
[HttpPost("session/renew")]
public async Task<ActionResult<ApiGlobalLeaksSessionDto>> RenewSession(
RenewGlobalLeaksSessionRequest request,
@@ -56,11 +97,32 @@ public sealed class InboxController : ControllerBase
try
{
var session = await _globalLeaksClient.LoginAsync(
current.Username,
current.Password,
request.Authcode.Trim(),
cancellationToken);
GlSession session;
if (!string.IsNullOrWhiteSpace(request.PendingLoginId))
{
var pending = _pendingLoginStore.Get(request.PendingLoginId);
if (!string.Equals(pending.Username, current.Username, StringComparison.OrdinalIgnoreCase))
{
return BadRequest(new ApiError("La preparacion del login no corresponde al usuario actual."));
}
session = await _globalLeaksClient.CompleteLoginAsync(
pending.Username,
pending.FinalPassword,
pending.TokenAnswer,
request.Authcode.Trim(),
cancellationToken);
_pendingLoginStore.Remove(pending.Id);
}
else
{
session = await _globalLeaksClient.LoginAsync(
current.Username,
current.Password,
request.Authcode.Trim(),
cancellationToken);
}
await _sessionStore.UpdateSessionAsync(username, session.Id, session.Role, cancellationToken);
var stored = await _sessionStore.GetAsync(username, cancellationToken);
@@ -70,6 +132,10 @@ public sealed class InboxController : ControllerBase
{
return StatusCode(ex.StatusCode, new ApiError(ex.Message));
}
catch (InvalidOperationException ex)
{
return BadRequest(new ApiError(ex.Message));
}
catch (Exception ex)
{
_logger.LogError(ex, "No se ha podido cargar la bandeja GlobalLeaks para {Username}.", username);
@@ -99,7 +165,7 @@ public sealed class InboxController : ControllerBase
try
{
var contexts = await _globalLeaksClient.GetContextsAsync(session.SessionId!, cancellationToken);
var reports = await _globalLeaksClient.GetReportsAsync(session.SessionId!, "all", null, null, cancellationToken);
var reports = await _globalLeaksClient.GetReportsAsync(session.SessionId!, "all", null, null, cancellationToken, contexts);
var enrichedReports = await _trackingService.RegisterSnapshotAsync(username, reports, cancellationToken);
var state = await _trackingService.GetUserStateAsync(username, cancellationToken);
@@ -186,6 +252,41 @@ public sealed class InboxController : ControllerBase
}
}
[HttpGet("reports/{reportId}/detail")]
public async Task<ActionResult<ReportDetailDto>> GetReportDetail(
string reportId,
[FromQuery] string? lastAccess,
CancellationToken cancellationToken)
{
var username = GetUsername();
var session = await RequireActiveSessionAsync(username, cancellationToken);
if (session is null)
{
return Unauthorized(new ApiError("La sesion de GlobalLeaks ha caducado. Renueva el 2FA.", SessionExpired: true));
}
try
{
return Ok(await _globalLeaksClient.GetReportDetailAsync(session.SessionId!, reportId, lastAccess, cancellationToken));
}
catch (GlobalLeaksSessionExpiredException)
{
await _sessionStore.ClearSessionAsync(username, cancellationToken);
return Unauthorized(new ApiError("La sesion de GlobalLeaks ha caducado. Renueva el 2FA.", SessionExpired: true));
}
catch (GlobalLeaksValidationException ex)
{
return StatusCode(ex.StatusCode, new ApiError(ex.Message));
}
catch (Exception ex)
{
_logger.LogError(ex, "No se ha podido leer el detalle de la denuncia {ReportId} para {Username}.", reportId, username);
return StatusCode(
StatusCodes.Status500InternalServerError,
new ApiError($"No se ha podido leer el detalle de la denuncia: {ex.GetType().Name}: {ex.Message}"));
}
}
[HttpPost("local/ensure-storage")]
public async Task<IActionResult> EnsureStorage(CancellationToken cancellationToken)
{

View File

@@ -0,0 +1,71 @@
using System.Globalization;
using ApiDenuncias.Services;
using GestionaDenuncias.Shared.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ApiDenuncias.Controllers;
[ApiController]
[Authorize]
[Route("api/purge")]
public sealed class PurgeController : ControllerBase
{
private readonly ManualPurgeService _manualPurgeService;
private readonly ILogger<PurgeController> _logger;
public PurgeController(
ManualPurgeService manualPurgeService,
ILogger<PurgeController> logger)
{
_manualPurgeService = manualPurgeService;
_logger = logger;
}
[HttpPost("manual/current")]
public Task<ActionResult<ManualPurgeResponse>> ExecuteCurrentManualPurge(CancellationToken cancellationToken)
=> ExecuteManualPurgeCore(DateOnly.FromDateTime(DateTime.UtcNow), cancellationToken);
[HttpPost("manual")]
public async Task<ActionResult<ManualPurgeResponse>> ExecuteManualPurge(
ManualPurgeRequest request,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(request.Date) ||
!DateOnly.TryParseExact(
request.Date.Trim(),
"yyyy-MM-dd",
CultureInfo.InvariantCulture,
DateTimeStyles.None,
out var purgeDate))
{
return BadRequest(new ApiError("Debes indicar una fecha valida con formato YYYY-MM-DD."));
}
return await ExecuteManualPurgeCore(purgeDate, cancellationToken);
}
private async Task<ActionResult<ManualPurgeResponse>> ExecuteManualPurgeCore(
DateOnly purgeDate,
CancellationToken cancellationToken)
{
try
{
var result = await _manualPurgeService.ExecuteAsync(purgeDate, cancellationToken);
return Ok(result);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
return StatusCode(
StatusCodes.Status504GatewayTimeout,
new ApiError("La Function App de purga no ha respondido dentro del tiempo configurado."));
}
catch (InvalidOperationException ex)
{
_logger.LogError(ex, "No se ha podido ejecutar la purga manual para {Date}.", purgeDate);
return StatusCode(
StatusCodes.Status502BadGateway,
new ApiError(ex.Message));
}
}
}

View File

@@ -245,7 +245,13 @@ public static class GlobalLeaksJsonEnricher
SetIfMissing(() => denuncia.RazonSocial, value => denuncia.RazonSocial = value, answers, "razon social");
SetIfMissing(() => denuncia.Sexo, value => denuncia.Sexo = value, answers, "sexo");
SetIfMissing(() => denuncia.PaisOrigen, value => denuncia.PaisOrigen = value, answers, "pais de origen");
SetIfMissing(() => denuncia.Dni, value => denuncia.Dni = value, answers, "dni", "nif", "nie", "cif", "otro documento identificativo");
SetIfMissing(() => denuncia.Dni, value => denuncia.Dni = value, answers, "nif dni nie", "dni", "nif", "nie", "cif", "otro documento identificativo");
if (!string.IsNullOrWhiteSpace(denuncia.Dni) &&
string.IsNullOrWhiteSpace(denuncia.TipoDocumentoIdentificativo))
{
denuncia.TipoDocumentoIdentificativo = "NIF (DNI, NIE)";
}
SetIfMissing(() => denuncia.A_Quien_Denuncia, value => denuncia.A_Quien_Denuncia = value, answers, "a quien denuncia");
SetIfMissing(() => denuncia.DenunciadoDetalle, value => denuncia.DenunciadoDetalle = value, answers, "especifique a quien denuncia");
SetIfMissing(() => denuncia.Descripcion_Denuncia, value => denuncia.Descripcion_Denuncia = value, answers, "describa su denuncia", "descripcion de la denuncia");
@@ -261,7 +267,7 @@ public static class GlobalLeaksJsonEnricher
SetIfMissing(() => denuncia.SeguimientoOnline, value => denuncia.SeguimientoOnline = value, answers, "seguimiento online");
SetIfMissing(() => denuncia.NotificacionPostal, value => denuncia.NotificacionPostal = value, answers, "autorizo recibir notificaciones via correo postal");
SetIfMissing(() => denuncia.Correo_Electronico, value => denuncia.Correo_Electronico = value, answers, "correo electronico", "email");
SetIfMissing(() => denuncia.Telefono, value => denuncia.Telefono = value, answers, "telefono", "telefono movil");
SetIfMissing(() => denuncia.Telefono, value => denuncia.Telefono = value, answers, "contacto telefonico", "telefono", "telefono movil");
SetIfMissing(() => denuncia.Direccion, value => denuncia.Direccion = value, answers, "nombre de la via", "direccion", "domicilio");
SetIfMissing(() => denuncia.DireccionTipoVia, value => denuncia.DireccionTipoVia = value, answers, "tipo de via");
SetIfMissing(() => denuncia.DireccionNumero, value => denuncia.DireccionNumero = value, answers, "numero", "numero km");
@@ -383,6 +389,11 @@ public static class GlobalLeaksJsonEnricher
continue;
}
if (character is 'º' or 'ª')
{
continue;
}
builder.Append(char.IsLetterOrDigit(character) ? char.ToLowerInvariant(character) : ' ');
}

View File

@@ -7,6 +7,60 @@ namespace ApiDenuncias.Helpers;
public static class ReportParser
{
private static readonly HashSet<string> FlatReportSections = NormalizeAll(
"Datos del denunciante",
"Descripción",
"Preferencias de notificación",
"Condiciones y reglas de uso",
"Comments");
private static readonly HashSet<string> FlatReportLabels = NormalizeAll(
"Indique si actúa como persona física o en representación de una persona jurídica.",
"Nombre",
"1º Apellido",
"2º Apellido",
"SEXO",
"CONTACTO TELEFÓNICO",
"País de Origen",
"NIF (DNI, NIE)",
"Razón social",
"CIF",
"Otro documento identificativo",
"Asunto",
"¿A quién denuncia?",
"Especifique a quién denuncia",
"Describa su denuncia",
"¿Ha denunciado estos hechos ante otras instituciones u órganos?",
"POR FAVOR. INDIQUE EL ORGANISMO O LA INSTITUCION DONDE HA DENUNCIADO LOS HECHOS",
"¿Solicita medidas concretas de protección?",
"DESCRIBA LAS MEDIDAS DE PROTECCIÓN SOLICITADAS",
"Lugar en el que ocurrieron los hechos que denuncia",
"Fecha de los hechos que denuncia",
"Autorización para remitir su denuncia",
"En tal caso, ¿desea que su denuncia se remita anonimizada (sin sus datos personales)?",
"En tal caso, ¿desea que su denuncia se remita anonimizada (sin datos personales)?",
"Preferencia de notificación",
"Notificaciones Electrónicas",
"Correo electrónico",
"Seguimiento Online",
"Autorizo recibir notificaciones vía Correo Postal",
"Provincia",
"Tipo de vía",
"Nombre de la vía",
"Código Postal",
"Localidad",
"Municipio",
"Número/Km",
"Número",
"Bloque",
"Escalera",
"Planta",
"Piso",
"Puerta",
"Extra",
"Condiciones y reglas de uso del buzón de denuncias",
"TRATAMIENTO DE DATOS PERSONALES");
public static DenunciasGestiona ParseReport(string reportText)
{
var lines = NormalizeLines(reportText);
@@ -187,6 +241,96 @@ public static class ReportParser
i = j - 1;
}
comments = commentBuilder.ToString().Trim();
if (fields.Count == 0)
{
fields = ParseFlatFormFields(lines, out comments);
}
return fields;
}
private static List<ReportFieldEntry> ParseFlatFormFields(string[] lines, out string comments)
{
var fields = new List<ReportFieldEntry>();
var commentBuilder = new StringBuilder();
var currentSection = string.Empty;
var order = 0;
for (var i = 0; i < lines.Length; i++)
{
var trimmed = lines[i].Trim();
if (ShouldSkipFlatLine(trimmed) || IsMetadataLine(trimmed))
{
continue;
}
if (IsMessagesStart(trimmed))
{
for (var j = i + 1; j < lines.Length; j++)
{
var commentLine = lines[j].Trim();
if (ShouldSkipFlatLine(commentLine))
{
continue;
}
if (commentBuilder.Length > 0)
{
commentBuilder.AppendLine();
}
commentBuilder.Append(commentLine);
}
break;
}
if (IsFlatSection(trimmed))
{
currentSection = trimmed;
continue;
}
if (!IsFlatLabel(trimmed))
{
continue;
}
var valueLines = new List<string>();
var nextIndex = i + 1;
while (nextIndex < lines.Length)
{
var nextTrimmed = lines[nextIndex].Trim();
if (ShouldSkipFlatLine(nextTrimmed))
{
nextIndex++;
continue;
}
if (IsMetadataLine(nextTrimmed) ||
IsMessagesStart(nextTrimmed) ||
IsFlatSection(nextTrimmed) ||
IsFlatLabel(nextTrimmed))
{
break;
}
valueLines.Add(nextTrimmed);
nextIndex++;
}
fields.Add(new ReportFieldEntry
{
Order = ++order,
Section = currentSection,
Label = trimmed,
Value = string.Join(Environment.NewLine, valueLines)
});
i = nextIndex - 1;
}
comments = commentBuilder.ToString().Trim();
return fields;
}
@@ -415,6 +559,37 @@ public static class ReportParser
line.StartsWith("Estado:", StringComparison.OrdinalIgnoreCase);
}
private static HashSet<string> NormalizeAll(params string[] values)
=> values
.Select(Normalize)
.Where(value => !string.IsNullOrWhiteSpace(value))
.ToHashSet(StringComparer.Ordinal);
private static bool IsFlatSection(string value)
=> FlatReportSections.Contains(Normalize(value));
private static bool IsFlatLabel(string value)
=> FlatReportLabels.Contains(Normalize(value));
private static bool IsMessagesStart(string value)
{
var normalized = Normalize(value);
return normalized == "messages" ||
normalized == "comments" ||
normalized.StartsWith("comments ", StringComparison.Ordinal);
}
private static bool ShouldSkipFlatLine(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return true;
}
return Regex.IsMatch(value, @"^\d+/\d+$", RegexOptions.CultureInvariant) ||
Regex.IsMatch(value, @"^REPORT\s+\d+\b", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
}
private static int CountIndentation(string value)
{
var indent = 0;
@@ -456,6 +631,11 @@ public static class ReportParser
continue;
}
if (character is 'º' or 'ª')
{
continue;
}
builder.Append(char.IsLetterOrDigit(character) ? char.ToLowerInvariant(character) : ' ');
}

View File

@@ -19,6 +19,7 @@ builder.Services.Configure<KeyVaultOptions>(builder.Configuration.GetSection(Key
builder.Services.Configure<GestionaOptions>(builder.Configuration.GetSection("Gestiona"));
builder.Services.Configure<GlobalLeaksOptions>(builder.Configuration.GetSection(GlobalLeaksOptions.SectionName));
builder.Services.Configure<ComplaintStorageOptions>(builder.Configuration.GetSection(ComplaintStorageOptions.SectionName));
builder.Services.Configure<ManualPurgeOptions>(builder.Configuration.GetSection(ManualPurgeOptions.SectionName));
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
@@ -29,15 +30,21 @@ builder.Services.AddDataProtection()
builder.Services.AddSingleton<LoginRateLimiter>();
builder.Services.AddSingleton<GlobalLeaksSessionStore>();
builder.Services.AddSingleton<PendingGlobalLeaksLoginStore>();
builder.Services.AddScoped<GlobalLeaksClient>();
builder.Services.AddSingleton<MySqlConnectionStringProvider>();
builder.Services.AddScoped<MySqlDenunciaStore>();
builder.Services.AddSingleton<IEncryptionKeyProvider, KeyVaultEncryptionKeyProvider>();
builder.Services.AddScoped<IDenunciaStore, EncryptedDenunciaStore>();
builder.Services.AddSingleton<IEnvelopeEncryptionKeyProvider, EnvelopeEncryptionKeyProvider>();
builder.Services.AddScoped<EncryptedDenunciaStore>();
builder.Services.AddScoped<IDenunciaStore>(sp => sp.GetRequiredService<EncryptedDenunciaStore>());
builder.Services.AddScoped<IFilteredDenunciaStore>(sp => sp.GetRequiredService<EncryptedDenunciaStore>());
builder.Services.AddScoped<IInboxTrackingService, InboxTrackingService>();
builder.Services.AddScoped<DenunciaInboxService>();
builder.Services.AddScoped<GestionaDocumentWorkflowService>();
builder.Services.AddScoped<UserComplaintAccessService>();
builder.Services.AddHttpClient<ManualPurgeService>();
builder.Services.AddScoped<AppConfigurationService>();
builder.Services.AddHttpClient<IGestionaService, GestionaService>((sp, client) =>
{
@@ -110,6 +117,20 @@ app.UseExceptionHandler(errorApp =>
logger.LogError(feature.Error, "Error no controlado en {Path}", context.Request.Path);
}
if (feature?.Error is EncryptedDataPurgedException purgedException)
{
context.Response.StatusCode = StatusCodes.Status410Gone;
await context.Response.WriteAsJsonAsync(new ApiError(purgedException.Message));
return;
}
if (feature?.Error is EncryptionKeyUnavailableException keyException)
{
context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
await context.Response.WriteAsJsonAsync(new ApiError(keyException.Message));
return;
}
var detailedErrors = context.RequestServices
.GetRequiredService<IConfiguration>()
.GetValue("DetailedApiErrors", false);

View File

@@ -0,0 +1,281 @@
-- ============================================================
-- Migracion de base de datos - ApiDenuncias
-- Envelope Encryption adaptado al esquema real de la aplicacion
-- Entorno previsto: PRE / MySQL Flexible Server
-- BD prevista: gestiona
-- ============================================================
--
-- OBJETIVO
-- Incorporar el modelo de claves diarias propuesto por infraestructura
--
-- MODELO REAL DE LA APP
-- complaints
-- Guarda la denuncia, metadatos tecnicos, estado, expediente Gestiona
-- y payload sensible cifrado en columnas existentes como:
-- - raw_report_text
-- - form_fields_json
-- - campos textuales derivados del report
--
-- complaint_attachments
-- Guarda adjuntos y report, con hash en claro para deduplicacion:
-- - content
-- - description
-- - notes
-- - content_sha256
--
-- inbox_reports / user_inbox_reports / app_users
-- Trazabilidad de bandeja, usuario propietario y control de descargas.
-- No almacenan el contenido sensible de la denuncia.
--
-- ESTRATEGIA
-- 1. Crear encryption_keys, gestionada por Function App / infraestructura.
-- 2. Anadir key_date a complaints y complaint_attachments.
-- 3. Mantener key_date nullable para no romper datos legacy o entornos
-- con registros previos. La API debera rellenarlo en nuevas escrituras.
-- 4. No se crean columnas descripcion_cifrada/iv/auth_tag separadas porque
-- la API cifra el payload/contenido en el propio campo con AES-256-GCM,
-- empaquetando nonce + auth_tag + ciphertext en el valor persistido.
--
-- IMPORTANTE
-- Ejecutar dentro de la base de datos correcta:
-- USE gestiona;
-- ============================================================
USE gestiona;
-- ------------------------------------------------------------
-- TABLA: encryption_keys
-- Gestionada por la Function App de claves.
-- La API la lee para obtener la eDEK diaria y hacer unwrapKey en Key Vault.
-- ------------------------------------------------------------
CREATE TABLE IF NOT EXISTS encryption_keys (
id INT AUTO_INCREMENT PRIMARY KEY,
key_date DATE NOT NULL COMMENT 'Fecha de validez de la KEK/DEK diaria. Una fila por dia.',
key_name VARCHAR(100) NOT NULL COMMENT 'Nombre de la clave en Azure Key Vault. Ej: key-2026-05-19.',
key_version VARCHAR(500) NOT NULL COMMENT 'URI completa con version de la KEK en Key Vault. Se usa para unwrapKey.',
edek TEXT NOT NULL COMMENT 'DEK cifrada en Base64. La API hace unwrapKey y mantiene la DEK solo en memoria.',
status ENUM('active','purged') NOT NULL DEFAULT 'active' COMMENT 'active: operativa | purged: KEK deshabilitada y datos ilegibles.',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'Timestamp de creacion de la clave.',
purged_at DATETIME NULL COMMENT 'Timestamp de purga criptografica. NULL mientras status = active.',
CONSTRAINT uq_encryption_keys_date UNIQUE (key_date),
INDEX ix_encryption_keys_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- ------------------------------------------------------------
-- Helpers idempotentes para MySQL.
-- Evitamos ADD COLUMN IF NOT EXISTS / CREATE INDEX IF NOT EXISTS porque
-- no son seguros en todas las versiones/configuraciones de MySQL usadas.
-- ------------------------------------------------------------
DROP PROCEDURE IF EXISTS add_column_if_missing;
DROP PROCEDURE IF EXISTS add_index_if_missing;
DROP PROCEDURE IF EXISTS add_fk_if_missing;
DELIMITER //
CREATE PROCEDURE add_column_if_missing(
IN p_table_name VARCHAR(64),
IN p_column_name VARCHAR(64),
IN p_column_definition TEXT
)
BEGIN
IF NOT EXISTS (
SELECT 1
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = p_table_name
AND COLUMN_NAME = p_column_name
) THEN
SET @sql = CONCAT('ALTER TABLE `', p_table_name, '` ADD COLUMN ', p_column_definition);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
END IF;
END//
CREATE PROCEDURE add_index_if_missing(
IN p_table_name VARCHAR(64),
IN p_index_name VARCHAR(64),
IN p_index_definition TEXT
)
BEGIN
IF NOT EXISTS (
SELECT 1
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = p_table_name
AND INDEX_NAME = p_index_name
) THEN
SET @sql = CONCAT('ALTER TABLE `', p_table_name, '` ADD ', p_index_definition);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
END IF;
END//
CREATE PROCEDURE add_fk_if_missing(
IN p_table_name VARCHAR(64),
IN p_constraint_name VARCHAR(64),
IN p_constraint_definition TEXT
)
BEGIN
IF NOT EXISTS (
SELECT 1
FROM information_schema.TABLE_CONSTRAINTS
WHERE CONSTRAINT_SCHEMA = DATABASE()
AND TABLE_NAME = p_table_name
AND CONSTRAINT_NAME = p_constraint_name
AND CONSTRAINT_TYPE = 'FOREIGN KEY'
) THEN
SET @sql = CONCAT('ALTER TABLE `', p_table_name, '` ADD CONSTRAINT `', p_constraint_name, '` ', p_constraint_definition);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
END IF;
END//
DELIMITER ;
-- ------------------------------------------------------------
-- MODIFICACION: complaints
-- key_date indica con que DEK diaria se cifro la denuncia/payload sensible.
-- Queda nullable para permitir registros anteriores a la migracion.
-- ------------------------------------------------------------
CALL add_column_if_missing(
'complaints',
'key_date',
'`key_date` DATE NULL COMMENT ''FK a encryption_keys.key_date. Indica la clave diaria usada para cifrar los datos sensibles de la denuncia.'' AFTER `is_rejected`'
);
CALL add_column_if_missing(
'complaints',
'encryption_scheme',
'`encryption_scheme` VARCHAR(64) NOT NULL DEFAULT ''none'' COMMENT ''Formato de cifrado aplicado. Ej: aes-256-gcm-envelope-v1.'' AFTER `key_date`'
);
CALL add_column_if_missing(
'complaints',
'encrypted_at_utc',
'`encrypted_at_utc` DATETIME(6) NULL COMMENT ''Fecha UTC en la que la API cifro o recifro el payload sensible.'' AFTER `encryption_scheme`'
);
CALL add_index_if_missing(
'complaints',
'ix_complaints_key_date',
'INDEX `ix_complaints_key_date` (`key_date`)'
);
CALL add_fk_if_missing(
'complaints',
'fk_complaints_key_date',
'FOREIGN KEY (`key_date`) REFERENCES `encryption_keys` (`key_date`)'
);
-- ------------------------------------------------------------
-- MODIFICACION: complaint_attachments
-- key_date indica con que DEK diaria se cifro cada adjunto/report.
-- El hash content_sha256 permanece en claro para deduplicar actualizaciones.
-- ------------------------------------------------------------
CALL add_column_if_missing(
'complaint_attachments',
'key_date',
'`key_date` DATE NULL COMMENT ''FK a encryption_keys.key_date. Indica la clave diaria usada para cifrar el contenido del adjunto.'' AFTER `content_sha256`'
);
CALL add_column_if_missing(
'complaint_attachments',
'encryption_scheme',
'`encryption_scheme` VARCHAR(64) NOT NULL DEFAULT ''none'' COMMENT ''Formato de cifrado aplicado. Ej: aes-256-gcm-envelope-v1.'' AFTER `key_date`'
);
CALL add_column_if_missing(
'complaint_attachments',
'encrypted_at_utc',
'`encrypted_at_utc` DATETIME(6) NULL COMMENT ''Fecha UTC en la que la API cifro o recifro el adjunto.'' AFTER `encryption_scheme`'
);
CALL add_index_if_missing(
'complaint_attachments',
'ix_attachments_key_date',
'INDEX `ix_attachments_key_date` (`key_date`)'
);
CALL add_fk_if_missing(
'complaint_attachments',
'fk_attachments_key_date',
'FOREIGN KEY (`key_date`) REFERENCES `encryption_keys` (`key_date`)'
);
-- ------------------------------------------------------------
-- Limpieza de helpers.
-- ------------------------------------------------------------
DROP PROCEDURE IF EXISTS add_column_if_missing;
DROP PROCEDURE IF EXISTS add_index_if_missing;
DROP PROCEDURE IF EXISTS add_fk_if_missing;
-- ============================================================
-- CONSULTAS DE VERIFICACION
-- ============================================================
SELECT
TABLE_NAME,
ENGINE,
TABLE_COLLATION
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME IN ('encryption_keys', 'complaints', 'complaint_attachments')
ORDER BY TABLE_NAME;
SELECT
COLUMN_NAME,
DATA_TYPE,
IS_NULLABLE,
COLUMN_DEFAULT,
COLUMN_COMMENT
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'encryption_keys'
ORDER BY ORDINAL_POSITION;
SELECT
TABLE_NAME,
COLUMN_NAME,
DATA_TYPE,
IS_NULLABLE,
COLUMN_DEFAULT,
COLUMN_COMMENT
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME IN ('complaints', 'complaint_attachments')
AND COLUMN_NAME IN ('key_date', 'encryption_scheme', 'encrypted_at_utc')
ORDER BY TABLE_NAME, ORDINAL_POSITION;
SELECT
CONSTRAINT_NAME,
TABLE_NAME,
COLUMN_NAME,
REFERENCED_TABLE_NAME,
REFERENCED_COLUMN_NAME
FROM information_schema.KEY_COLUMN_USAGE
WHERE TABLE_SCHEMA = DATABASE()
AND REFERENCED_TABLE_NAME = 'encryption_keys'
ORDER BY TABLE_NAME, CONSTRAINT_NAME;
SELECT
TABLE_NAME,
INDEX_NAME,
COLUMN_NAME,
NON_UNIQUE
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME IN ('encryption_keys', 'complaints', 'complaint_attachments')
AND INDEX_NAME IN ('ix_encryption_keys_status', 'ix_complaints_key_date', 'ix_attachments_key_date')
ORDER BY TABLE_NAME, INDEX_NAME, SEQ_IN_INDEX;
-- ============================================================
-- FIN
-- ============================================================

View File

@@ -156,3 +156,14 @@ CREATE TABLE IF NOT EXISTS complaint_attachments (
FOREIGN KEY (complaint_id) REFERENCES complaints(id)
ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS complaint_attachment_chunks (
attachment_id BIGINT NOT NULL,
chunk_index INT NOT NULL,
content LONGBLOB NOT NULL,
created_at_utc DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
PRIMARY KEY (attachment_id, chunk_index),
CONSTRAINT fk_attachment_chunks_attachment
FOREIGN KEY (attachment_id) REFERENCES complaint_attachments(id)
ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

View File

@@ -0,0 +1,87 @@
using System.Globalization;
using GestionaDenuncias.Shared.Models;
using MySqlConnector;
namespace ApiDenuncias.Services;
public sealed class AppConfigurationService
{
private const string ExternalUpdateCutoffDateKey = "external_update_cutoff_date";
private readonly MySqlConnectionStringProvider _connectionStringProvider;
public AppConfigurationService(MySqlConnectionStringProvider connectionStringProvider)
{
_connectionStringProvider = connectionStringProvider;
}
public async Task<AppConfigurationDto> GetAsync(CancellationToken cancellationToken = default)
{
await using var connection = await OpenConnectionAsync(cancellationToken);
await EnsureTableAsync(connection, cancellationToken);
await using var command = new MySqlCommand(
"""
SELECT setting_value
FROM app_settings
WHERE setting_key = @settingKey
LIMIT 1;
""",
connection);
command.Parameters.AddWithValue("@settingKey", ExternalUpdateCutoffDateKey);
var value = await command.ExecuteScalarAsync(cancellationToken);
var dateText = value is null or DBNull
? null
: Convert.ToString(value, CultureInfo.InvariantCulture);
return new AppConfigurationDto(string.IsNullOrWhiteSpace(dateText) ? null : dateText);
}
public async Task<AppConfigurationDto> SetExternalUpdateCutoffDateAsync(
DateOnly? date,
CancellationToken cancellationToken = default)
{
await using var connection = await OpenConnectionAsync(cancellationToken);
await EnsureTableAsync(connection, cancellationToken);
var dateText = date?.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
await using var command = new MySqlCommand(
"""
INSERT INTO app_settings (setting_key, setting_value, updated_at_utc)
VALUES (@settingKey, @settingValue, UTC_TIMESTAMP(6))
ON DUPLICATE KEY UPDATE
setting_value = VALUES(setting_value),
updated_at_utc = UTC_TIMESTAMP(6);
""",
connection);
command.Parameters.AddWithValue("@settingKey", ExternalUpdateCutoffDateKey);
command.Parameters.AddWithValue("@settingValue", string.IsNullOrWhiteSpace(dateText) ? DBNull.Value : dateText);
await command.ExecuteNonQueryAsync(cancellationToken);
return new AppConfigurationDto(dateText);
}
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 app_settings (
setting_key VARCHAR(128) NOT NULL,
setting_value TEXT NULL,
updated_at_utc DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
PRIMARY KEY (setting_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
""",
connection);
await command.ExecuteNonQueryAsync(cancellationToken);
}
}

View File

@@ -9,6 +9,51 @@ public sealed class DenunciaInboxService
{
private const string RootPath = @"C:\ZipsDenuncias";
private static readonly HashSet<string> BlockedAttachmentExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".ade",
".adp",
".apk",
".app",
".appx",
".bat",
".cmd",
".com",
".cpl",
".dll",
".exe",
".gadget",
".hta",
".ins",
".iso",
".jar",
".js",
".jse",
".lnk",
".msc",
".msi",
".msp",
".mst",
".pif",
".ps1",
".ps1xml",
".ps2",
".ps2xml",
".psc1",
".psc2",
".reg",
".scr",
".sh",
".sys",
".vb",
".vbe",
".vbs",
".ws",
".wsc",
".wsf",
".wsh"
};
private readonly IGestionaService _gestionaService;
private readonly IDenunciaStore _denunciaStore;
private readonly ILogger<DenunciaInboxService> _logger;
@@ -60,6 +105,7 @@ public sealed class DenunciaInboxService
.ToList();
var errors = new List<string>();
var warnings = new List<string>();
var importedCount = 0;
var complaintIds = new List<int>();
@@ -68,10 +114,11 @@ public sealed class DenunciaInboxService
try
{
var zipBytes = await File.ReadAllBytesAsync(zipPath, cancellationToken);
var complaintId = await ProcessZipAsync(zipBytes, Path.GetFileName(zipPath), null, cancellationToken);
var result = await ProcessZipAsync(zipBytes, Path.GetFileName(zipPath), null, cancellationToken);
File.Delete(zipPath);
importedCount++;
complaintIds.Add(complaintId);
complaintIds.Add(result.ComplaintId);
warnings.AddRange(result.Warnings.Select(warning => $"{Path.GetFileName(zipPath)}: {warning}"));
}
catch (Exception ex)
{
@@ -80,7 +127,7 @@ public sealed class DenunciaInboxService
}
}
return new ImportSummary(zipPaths.Count, importedCount, errors, complaintIds);
return new ImportSummary(zipPaths.Count, importedCount, errors, complaintIds, warnings);
}
public async Task<ImportSummary> ImportFromGlobalLeaksAsync(
@@ -100,8 +147,8 @@ public sealed class DenunciaInboxService
try
{
var complaintId = await ProcessZipAsync(zipDownload.Content, fileName, json, cancellationToken);
return new ImportSummary(1, 1, [], [complaintId]);
var result = await ProcessZipAsync(zipDownload.Content, fileName, json, cancellationToken);
return new ImportSummary(1, 1, [], [result.ComplaintId], result.Warnings);
}
catch (Exception ex)
{
@@ -110,7 +157,7 @@ public sealed class DenunciaInboxService
}
}
private async Task<int> ProcessZipAsync(
private async Task<ProcessZipResult> ProcessZipAsync(
byte[] zipBytes,
string sourceName,
string? globalLeaksJson,
@@ -177,18 +224,19 @@ public sealed class DenunciaInboxService
denuncia.Expediente_Gestiona = "Pendiente";
}
var nuevosFicheros = await ReadFilesFromArchiveAsync(archive, reportEntry, denuncia.Id_Denuncia, cancellationToken);
var readFilesResult = await ReadFilesFromArchiveAsync(archive, reportEntry, denuncia.Id_Denuncia, cancellationToken);
await MergeComplaintAsync(denuncia, cancellationToken);
await MergeFilesAsync(nuevosFicheros, cancellationToken);
return denuncia.Id_Denuncia;
await MergeFilesAsync(readFilesResult.Files, cancellationToken);
return new ProcessZipResult(denuncia.Id_Denuncia, readFilesResult.Warnings);
}
private async Task<List<FicherosDenuncias>> ReadFilesFromArchiveAsync(
private async Task<ReadFilesResult> ReadFilesFromArchiveAsync(
ZipArchive archive,
ZipArchiveEntry reportEntry,
int denunciaId,
CancellationToken cancellationToken)
{
var warnings = new List<string>();
var files = new List<FicherosDenuncias>
{
new(
@@ -206,6 +254,12 @@ public sealed class DenunciaInboxService
foreach (var entry in archive.Entries.Where(entry => IsSupportedAttachmentEntry(entry) && !IsSameEntry(entry, reportEntry)))
{
if (IsBlockedAttachmentEntry(entry))
{
warnings.Add(BuildBlockedAttachmentWarning(entry));
continue;
}
files.Add(new FicherosDenuncias(
id_Fichero: 0,
id_Tipo: 1,
@@ -219,12 +273,23 @@ public sealed class DenunciaInboxService
fichero: await ReadEntryBytesAsync(entry, cancellationToken)));
}
return files;
return new ReadFilesResult(files, warnings);
}
private async Task MergeComplaintAsync(DenunciasGestiona denuncia, CancellationToken cancellationToken)
{
var existing = await _denunciaStore.GetDenunciaByIdAsync(denuncia.Id_Denuncia, cancellationToken);
DenunciasGestiona? existing = null;
try
{
existing = await _denunciaStore.GetDenunciaByIdAsync(denuncia.Id_Denuncia, cancellationToken);
}
catch (EncryptedDataPurgedException ex)
{
_logger.LogInformation(
ex,
"La denuncia {DenunciaId} existe con datos purgados. Se reimportara como nueva copia cifrada con la clave activa.",
denuncia.Id_Denuncia);
}
if (existing is not null)
{
@@ -409,6 +474,20 @@ public sealed class DenunciaInboxService
return IsDirectChildOf(normalized, "files") || IsDirectChildOf(normalized, "files_attached_from_recipients");
}
private static bool IsBlockedAttachmentEntry(ZipArchiveEntry entry)
{
var extension = Path.GetExtension(entry.Name);
return !string.IsNullOrWhiteSpace(extension) &&
BlockedAttachmentExtensions.Contains(extension);
}
private static string BuildBlockedAttachmentWarning(ZipArchiveEntry entry)
{
var fileName = Path.GetFileName(entry.FullName);
var extension = Path.GetExtension(entry.Name);
return $"Se ha detectado y eliminado el archivo '{fileName}' porque la extension '{extension}' no esta permitida.";
}
private static ZipArchiveEntry? FindReportEntry(ZipArchive archive)
{
return archive.Entries.FirstOrDefault(IsReportEntry);
@@ -587,5 +666,9 @@ public sealed class DenunciaInboxService
builder.AppendLine();
}
private sealed record ProcessZipResult(int ComplaintId, IReadOnlyList<string> Warnings);
private sealed record ReadFilesResult(List<FicherosDenuncias> Files, IReadOnlyList<string> Warnings);
}

View File

@@ -8,12 +8,13 @@ using Microsoft.AspNetCore.DataProtection;
namespace ApiDenuncias.Services;
public sealed class EncryptedDenunciaStore : IDenunciaStore
public sealed class EncryptedDenunciaStore : IDenunciaStore, IFilteredDenunciaStore
{
private const string DataProtectionStringPrefix = "enc:v1:";
private const string KeyVaultStringPrefix = "enc:v2:";
private static readonly byte[] DataProtectionBytesPrefix = Encoding.ASCII.GetBytes("enc:v1:");
private static readonly byte[] KeyVaultBytesPrefix = Encoding.ASCII.GetBytes("enc:v2:");
private static readonly byte[] KeyVaultRawBytesPrefix = Encoding.ASCII.GetBytes("enc:v3:");
private const int AesGcmNonceSize = 12;
private const int AesGcmTagSize = 16;
private static readonly PropertyInfo[] ComplaintProperties = typeof(DenunciasGestiona)
@@ -22,17 +23,23 @@ public sealed class EncryptedDenunciaStore : IDenunciaStore
.ToArray();
private readonly MySqlDenunciaStore _inner;
private readonly IEncryptionKeyProvider _encryptionKeyProvider;
private readonly IEnvelopeEncryptionKeyProvider _envelopeKeyProvider;
private readonly IEncryptionKeyProvider _legacyKeyProvider;
private readonly IDataProtector _protector;
private readonly ILogger<EncryptedDenunciaStore> _logger;
public EncryptedDenunciaStore(
MySqlDenunciaStore inner,
IEncryptionKeyProvider encryptionKeyProvider,
IDataProtectionProvider dataProtectionProvider)
IEnvelopeEncryptionKeyProvider envelopeKeyProvider,
IEncryptionKeyProvider legacyKeyProvider,
IDataProtectionProvider dataProtectionProvider,
ILogger<EncryptedDenunciaStore> logger)
{
_inner = inner;
_encryptionKeyProvider = encryptionKeyProvider;
_envelopeKeyProvider = envelopeKeyProvider;
_legacyKeyProvider = legacyKeyProvider;
_protector = dataProtectionProvider.CreateProtector("ApiDenuncias.DatabaseSensitiveData.v1");
_logger = logger;
}
public Task EnsureSchemaAsync(CancellationToken cancellationToken = default)
@@ -40,44 +47,121 @@ public sealed class EncryptedDenunciaStore : IDenunciaStore
public async Task<List<DenunciasGestiona>> GetAllDenunciasAsync(CancellationToken cancellationToken = default)
{
var key = await _encryptionKeyProvider.GetKeyAsync(cancellationToken);
return (await _inner.GetAllDenunciasAsync(cancellationToken))
.Select(denuncia => UnprotectComplaint(denuncia, key))
.ToList();
var denuncias = await _inner.GetAllDenunciasAsync(cancellationToken);
return await UnprotectComplaintsAsync(denuncias, skipPurgedRows: true, cancellationToken);
}
public async Task<List<DenunciasGestiona>> GetDenunciasByScopeAsync(
DenunciaListScope scope,
CancellationToken cancellationToken = default)
{
var denuncias = await _inner.GetDenunciasByScopeAsync(scope, cancellationToken);
return await UnprotectComplaintsAsync(denuncias, skipPurgedRows: true, cancellationToken);
}
public async Task<List<DenunciasGestiona>> GetDenunciasByIdsAsync(
IReadOnlyCollection<int> denunciaIds,
CancellationToken cancellationToken = default)
{
var denuncias = await _inner.GetDenunciasByIdsAsync(denunciaIds, cancellationToken);
return await UnprotectComplaintsAsync(denuncias, skipPurgedRows: true, cancellationToken);
}
public async Task<List<DenunciasGestiona>> GetDenunciasByIdsAsync(
IReadOnlyCollection<int> denunciaIds,
DenunciaListScope scope,
CancellationToken cancellationToken = default)
{
var denuncias = await _inner.GetDenunciasByIdsAsync(denunciaIds, scope, cancellationToken);
return await UnprotectComplaintsAsync(denuncias, skipPurgedRows: true, cancellationToken);
}
private async Task<List<DenunciasGestiona>> UnprotectComplaintsAsync(
List<DenunciasGestiona> denuncias,
bool skipPurgedRows,
CancellationToken cancellationToken)
{
var result = new List<DenunciasGestiona>(denuncias.Count);
var requestKeyCache = new Dictionary<DateOnly, byte[]>();
foreach (var denuncia in denuncias)
{
try
{
result.Add(await UnprotectComplaintAsync(denuncia, requestKeyCache, cancellationToken));
}
catch (EncryptedDataPurgedException ex) when (skipPurgedRows)
{
_logger.LogWarning(
"Se omite la denuncia {DenunciaId} en el listado porque sus datos estan purgados para la clave diaria {KeyDate}.",
denuncia.Id_Denuncia,
ex.KeyDate);
}
}
return result;
}
public async Task<List<FicherosDenuncias>> GetAllFicherosAsync(CancellationToken cancellationToken = default)
{
var key = await _encryptionKeyProvider.GetKeyAsync(cancellationToken);
return (await _inner.GetAllFicherosAsync(cancellationToken))
.Select(fichero => UnprotectAttachment(fichero, key))
.ToList();
var ficheros = await _inner.GetAllFicherosAsync(cancellationToken);
return await UnprotectAttachmentsAsync(ficheros, skipPurgedRows: true, cancellationToken);
}
public async Task<List<FicherosDenuncias>> GetFicherosByDenunciaIdsAsync(
IReadOnlyCollection<int> denunciaIds,
CancellationToken cancellationToken = default)
{
var ficheros = await _inner.GetFicherosByDenunciaIdsAsync(denunciaIds, cancellationToken);
return await UnprotectAttachmentsAsync(ficheros, skipPurgedRows: true, cancellationToken);
}
public async Task<List<FicherosDenuncias>> GetFicherosByDenunciaAsync(int denunciaId, CancellationToken cancellationToken = default)
{
var key = await _encryptionKeyProvider.GetKeyAsync(cancellationToken);
return (await _inner.GetFicherosByDenunciaAsync(denunciaId, cancellationToken))
.Select(fichero => UnprotectAttachment(fichero, key))
.ToList();
var ficheros = await _inner.GetFicherosByDenunciaAsync(denunciaId, cancellationToken);
return await UnprotectAttachmentsAsync(ficheros, skipPurgedRows: false, cancellationToken);
}
private async Task<List<FicherosDenuncias>> UnprotectAttachmentsAsync(
List<FicherosDenuncias> ficheros,
bool skipPurgedRows,
CancellationToken cancellationToken)
{
var result = new List<FicherosDenuncias>(ficheros.Count);
var requestKeyCache = new Dictionary<DateOnly, byte[]>();
foreach (var fichero in ficheros)
{
try
{
result.Add(await UnprotectAttachmentAsync(fichero, requestKeyCache, cancellationToken));
}
catch (EncryptedDataPurgedException ex) when (skipPurgedRows)
{
_logger.LogWarning(
"Se omite el adjunto {AttachmentId} de la denuncia {DenunciaId} en el listado porque sus datos estan purgados para la clave diaria {KeyDate}.",
fichero.Id_Fichero,
fichero.Id_Denuncia,
ex.KeyDate);
}
}
return result;
}
public async Task<DenunciasGestiona?> GetDenunciaByIdAsync(int denunciaId, CancellationToken cancellationToken = default)
{
var key = await _encryptionKeyProvider.GetKeyAsync(cancellationToken);
var denuncia = await _inner.GetDenunciaByIdAsync(denunciaId, cancellationToken);
return denuncia is null ? null : UnprotectComplaint(denuncia, key);
return denuncia is null ? null : await UnprotectComplaintAsync(denuncia, [], cancellationToken);
}
public async Task UpsertDenunciaAsync(DenunciasGestiona denuncia, CancellationToken cancellationToken = default)
{
var key = await _encryptionKeyProvider.GetKeyAsync(cancellationToken);
var key = await _envelopeKeyProvider.GetCurrentKeyAsync(cancellationToken);
await _inner.UpsertDenunciaAsync(ProtectComplaint(denuncia, key), cancellationToken);
}
public async Task UpsertFicherosAsync(IEnumerable<FicherosDenuncias> ficheros, CancellationToken cancellationToken = default)
{
var key = await _encryptionKeyProvider.GetKeyAsync(cancellationToken);
var key = await _envelopeKeyProvider.GetCurrentKeyAsync(cancellationToken);
await _inner.UpsertFicherosAsync(ficheros.Select(fichero => ProtectAttachment(fichero, key)).ToArray(), cancellationToken);
}
@@ -88,13 +172,30 @@ public sealed class EncryptedDenunciaStore : IDenunciaStore
CancellationToken cancellationToken = default)
=> _inner.MarkFicherosAsUploadedAsync(denunciaId, fileNames, uploadedAtUtc, cancellationToken);
private DenunciasGestiona ProtectComplaint(DenunciasGestiona source, byte[] key)
=> TransformComplaint(ToPersistentComplaint(source), value => ProtectString(value, key));
private DenunciasGestiona UnprotectComplaint(DenunciasGestiona source, byte[] key)
private DenunciasGestiona ProtectComplaint(DenunciasGestiona source, EncryptionKeyMaterial key)
{
var decrypted = TransformComplaint(source, value => UnprotectString(value, key));
return RebuildComplaintFromPayload(decrypted);
var protectedComplaint = TransformComplaint(
ToPersistentComplaint(source),
value => ProtectString(value, key.Key));
protectedComplaint.KeyDate = key.KeyDate;
protectedComplaint.EncryptionScheme = key.Scheme;
protectedComplaint.EncryptedAtUtc = DateTime.UtcNow;
return protectedComplaint;
}
private async Task<DenunciasGestiona> UnprotectComplaintAsync(
DenunciasGestiona source,
Dictionary<DateOnly, byte[]> requestKeyCache,
CancellationToken cancellationToken)
{
var key = await ResolveReadKeyAsync(source.KeyDate, requestKeyCache, cancellationToken);
var decrypted = TransformComplaint(source, value => UnprotectString(value, key, source.KeyDate));
var rebuilt = RebuildComplaintFromPayload(decrypted);
rebuilt.KeyDate = source.KeyDate;
rebuilt.EncryptionScheme = source.EncryptionScheme;
rebuilt.EncryptedAtUtc = source.EncryptedAtUtc;
return rebuilt;
}
private static DenunciasGestiona ToPersistentComplaint(DenunciasGestiona source)
@@ -209,7 +310,7 @@ public sealed class EncryptedDenunciaStore : IDenunciaStore
return target;
}
private FicherosDenuncias ProtectAttachment(FicherosDenuncias source, byte[] key)
private FicherosDenuncias ProtectAttachment(FicherosDenuncias source, EncryptionKeyMaterial key)
{
var content = source.Fichero ?? [];
var hash = string.IsNullOrWhiteSpace(source.ContentSha256)
@@ -220,36 +321,67 @@ public sealed class EncryptedDenunciaStore : IDenunciaStore
{
Id_Fichero = source.Id_Fichero,
Id_Tipo = source.Id_Tipo,
Descripcion = ProtectString(source.Descripcion ?? string.Empty, key),
Descripcion = ProtectString(source.Descripcion ?? string.Empty, key.Key),
Fecha = source.Fecha,
Observaciones = ProtectString(source.Observaciones ?? string.Empty, key),
Observaciones = ProtectString(source.Observaciones ?? string.Empty, key.Key),
Id_Denuncia = source.Id_Denuncia,
NombreFichero = source.NombreFichero,
Fichero = ProtectBytes(content, key),
Fichero = ProtectBytes(content, key.Key),
Subido = source.Subido,
FechaSubida = source.FechaSubida,
ContentSha256 = hash
ContentSha256 = hash,
KeyDate = key.KeyDate,
EncryptionScheme = key.Scheme,
EncryptedAtUtc = DateTime.UtcNow
};
}
private FicherosDenuncias UnprotectAttachment(FicherosDenuncias source, byte[] key)
private async Task<FicherosDenuncias> UnprotectAttachmentAsync(
FicherosDenuncias source,
Dictionary<DateOnly, byte[]> requestKeyCache,
CancellationToken cancellationToken)
{
var key = await ResolveReadKeyAsync(source.KeyDate, requestKeyCache, cancellationToken);
return new FicherosDenuncias
{
Id_Fichero = source.Id_Fichero,
Id_Tipo = source.Id_Tipo,
Descripcion = UnprotectString(source.Descripcion ?? string.Empty, key),
Descripcion = UnprotectString(source.Descripcion ?? string.Empty, key, source.KeyDate),
Fecha = source.Fecha,
Observaciones = UnprotectString(source.Observaciones ?? string.Empty, key),
Observaciones = UnprotectString(source.Observaciones ?? string.Empty, key, source.KeyDate),
Id_Denuncia = source.Id_Denuncia,
NombreFichero = source.NombreFichero,
Fichero = UnprotectBytes(source.Fichero ?? [], key),
Fichero = UnprotectBytes(source.Fichero ?? [], key, source.KeyDate),
Subido = source.Subido,
FechaSubida = source.FechaSubida,
ContentSha256 = source.ContentSha256
ContentSha256 = source.ContentSha256,
KeyDate = source.KeyDate,
EncryptionScheme = source.EncryptionScheme,
EncryptedAtUtc = source.EncryptedAtUtc
};
}
private async Task<byte[]> ResolveReadKeyAsync(
DateOnly? keyDate,
Dictionary<DateOnly, byte[]> requestKeyCache,
CancellationToken cancellationToken)
{
if (keyDate.HasValue)
{
if (requestKeyCache.TryGetValue(keyDate.Value, out var cached))
{
return cached;
}
var key = await _envelopeKeyProvider.GetKeyForDateAsync(keyDate.Value, cancellationToken);
var copy = key.CopyKey();
requestKeyCache[keyDate.Value] = copy;
return copy;
}
return await _legacyKeyProvider.GetKeyAsync(cancellationToken);
}
private string ProtectString(string value, byte[] key)
{
if (string.IsNullOrWhiteSpace(value) ||
@@ -263,7 +395,7 @@ public sealed class EncryptedDenunciaStore : IDenunciaStore
return KeyVaultStringPrefix + Convert.ToBase64String(encrypted);
}
private string UnprotectString(string value, byte[] key)
private string UnprotectString(string value, byte[] key, DateOnly? keyDate = null)
{
if (string.IsNullOrWhiteSpace(value))
{
@@ -277,8 +409,13 @@ public sealed class EncryptedDenunciaStore : IDenunciaStore
var encrypted = Convert.FromBase64String(value[KeyVaultStringPrefix.Length..]);
return Encoding.UTF8.GetString(DecryptBytes(encrypted, key));
}
catch
catch (Exception ex)
{
if (keyDate.HasValue)
{
throw CreatePurgedDataException(keyDate.Value, ex);
}
return value;
}
}
@@ -302,23 +439,40 @@ public sealed class EncryptedDenunciaStore : IDenunciaStore
{
if (value.Length == 0 ||
StartsWith(value, KeyVaultBytesPrefix) ||
StartsWith(value, KeyVaultRawBytesPrefix) ||
StartsWith(value, DataProtectionBytesPrefix))
{
return value;
}
var encrypted = EncryptBytes(value, key);
var base64Bytes = Encoding.ASCII.GetBytes(Convert.ToBase64String(encrypted));
return [.. KeyVaultBytesPrefix, .. base64Bytes];
return [.. KeyVaultRawBytesPrefix, .. encrypted];
}
private byte[] UnprotectBytes(byte[] value, byte[] key)
private byte[] UnprotectBytes(byte[] value, byte[] key, DateOnly? keyDate = null)
{
if (value.Length == 0)
{
return value;
}
if (StartsWith(value, KeyVaultRawBytesPrefix))
{
try
{
return DecryptBytes(value[KeyVaultRawBytesPrefix.Length..], key);
}
catch (Exception ex)
{
if (keyDate.HasValue)
{
throw CreatePurgedDataException(keyDate.Value, ex);
}
return value;
}
}
if (StartsWith(value, KeyVaultBytesPrefix))
{
try
@@ -326,8 +480,13 @@ public sealed class EncryptedDenunciaStore : IDenunciaStore
var base64 = Encoding.ASCII.GetString(value, KeyVaultBytesPrefix.Length, value.Length - KeyVaultBytesPrefix.Length);
return DecryptBytes(Convert.FromBase64String(base64), key);
}
catch
catch (Exception ex)
{
if (keyDate.HasValue)
{
throw CreatePurgedDataException(keyDate.Value, ex);
}
return value;
}
}
@@ -398,4 +557,7 @@ public sealed class EncryptedDenunciaStore : IDenunciaStore
private static string ComputeSha256Hex(byte[] content)
=> Convert.ToHexString(SHA256.HashData(content)).ToLowerInvariant();
private static EncryptedDataPurgedException CreatePurgedDataException(DateOnly keyDate, Exception innerException)
=> new(keyDate, innerException);
}

View File

@@ -0,0 +1,31 @@
namespace ApiDenuncias.Services;
public sealed class EncryptionKeyUnavailableException : Exception
{
public EncryptionKeyUnavailableException(string message)
: base(message)
{
}
public EncryptionKeyUnavailableException(string message, Exception innerException)
: base(message, innerException)
{
}
}
public sealed class EncryptedDataPurgedException : Exception
{
public EncryptedDataPurgedException(DateOnly keyDate)
: base($"Denuncia no disponible: datos purgados para la clave diaria {keyDate:yyyy-MM-dd}.")
{
KeyDate = keyDate;
}
public EncryptedDataPurgedException(DateOnly keyDate, Exception innerException)
: base($"Denuncia no disponible: datos purgados para la clave diaria {keyDate:yyyy-MM-dd}.", innerException)
{
KeyDate = keyDate;
}
public DateOnly KeyDate { get; }
}

View File

@@ -0,0 +1,12 @@
namespace ApiDenuncias.Services;
public sealed record EncryptionKeyMaterial(
DateOnly KeyDate,
byte[] Key,
string KeyVersion,
string Scheme)
{
public const string EnvelopeScheme = "aes-256-gcm-envelope-v1";
public byte[] CopyKey() => Key.ToArray();
}

View File

@@ -0,0 +1,321 @@
using System.Security.Cryptography;
using System.Text;
using System.Collections.Concurrent;
using ApiDenuncias.Configuration;
using Azure;
using Azure.Identity;
using Azure.Security.KeyVault.Keys.Cryptography;
using Microsoft.Extensions.Options;
using MySqlConnector;
namespace ApiDenuncias.Services;
public sealed class EnvelopeEncryptionKeyProvider : IEnvelopeEncryptionKeyProvider
{
private readonly KeyVaultOptions _keyVaultOptions;
private readonly IConfiguration _configuration;
private readonly MySqlConnectionStringProvider _connectionStringProvider;
private readonly ILogger<EnvelopeEncryptionKeyProvider> _logger;
private readonly SemaphoreSlim _unwrapGate = new(1, 1);
private readonly ConcurrentDictionary<DateOnly, CachedEncryptionKey> _cache = [];
public EnvelopeEncryptionKeyProvider(
IOptions<KeyVaultOptions> keyVaultOptions,
IConfiguration configuration,
MySqlConnectionStringProvider connectionStringProvider,
ILogger<EnvelopeEncryptionKeyProvider> logger)
{
_keyVaultOptions = keyVaultOptions.Value;
_configuration = configuration;
_connectionStringProvider = connectionStringProvider;
_logger = logger;
}
public async ValueTask<EncryptionKeyMaterial> GetCurrentKeyAsync(CancellationToken cancellationToken = default)
{
if (!_keyVaultOptions.Enabled)
{
var today = DateOnly.FromDateTime(DateTime.UtcNow);
return CreateLocalDevelopmentKey(today);
}
using var timeout = CreateKeyOperationTimeout(cancellationToken);
try
{
var row = await LoadCurrentKeyRowAsync(timeout.Token);
if (!string.Equals(row.Status, "active", StringComparison.OrdinalIgnoreCase))
{
throw new EncryptionKeyUnavailableException(
$"No hay clave activa para hoy ({row.KeyDate:yyyy-MM-dd}). Estado actual: {row.Status}.");
}
return await GetOrUnwrapAsync(row, timeout.Token);
}
catch (OperationCanceledException ex)
{
throw new EncryptionKeyUnavailableException(
$"Timeout cargando la clave diaria activa desde encryption_keys/Key Vault tras {_keyVaultOptions.EncryptionKeyTimeoutSeconds} segundos.",
ex);
}
}
public async ValueTask<EncryptionKeyMaterial> GetKeyForDateAsync(DateOnly keyDate, CancellationToken cancellationToken = default)
{
if (!_keyVaultOptions.Enabled)
{
return CreateLocalDevelopmentKey(keyDate);
}
using var timeout = CreateKeyOperationTimeout(cancellationToken);
try
{
var row = await LoadKeyRowAsync(keyDate, timeout.Token);
if (string.Equals(row.Status, "purged", StringComparison.OrdinalIgnoreCase))
{
_cache.TryRemove(keyDate, out _);
throw new EncryptedDataPurgedException(keyDate);
}
if (!string.Equals(row.Status, "active", StringComparison.OrdinalIgnoreCase))
{
throw new EncryptionKeyUnavailableException(
$"La clave diaria {keyDate:yyyy-MM-dd} no esta activa. Estado actual: {row.Status}.");
}
return await GetOrUnwrapAsync(row, timeout.Token);
}
catch (OperationCanceledException ex)
{
throw new EncryptionKeyUnavailableException(
$"Timeout cargando la clave diaria {keyDate:yyyy-MM-dd} desde encryption_keys/Key Vault tras {_keyVaultOptions.EncryptionKeyTimeoutSeconds} segundos.",
ex);
}
}
private CancellationTokenSource CreateKeyOperationTimeout(CancellationToken cancellationToken)
{
var timeoutSeconds = Math.Clamp(_keyVaultOptions.EncryptionKeyTimeoutSeconds, 5, 120);
var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(TimeSpan.FromSeconds(timeoutSeconds));
return timeout;
}
private async Task<EncryptionKeyRow> LoadCurrentKeyRowAsync(CancellationToken cancellationToken)
{
const string sql = """
SELECT key_date, key_version, edek, status
FROM encryption_keys
WHERE key_date = CURDATE()
LIMIT 1;
""";
return await LoadKeyRowCoreAsync(sql, keyDate: null, cancellationToken);
}
private async Task<EncryptionKeyRow> LoadKeyRowAsync(DateOnly keyDate, CancellationToken cancellationToken)
{
const string sql = """
SELECT key_date, key_version, edek, status
FROM encryption_keys
WHERE key_date = @keyDate
LIMIT 1;
""";
return await LoadKeyRowCoreAsync(sql, keyDate, cancellationToken);
}
private async Task<EncryptionKeyRow> LoadKeyRowCoreAsync(
string sql,
DateOnly? keyDate,
CancellationToken cancellationToken)
{
await using var connection = await OpenConnectionAsync(cancellationToken);
await using var command = new MySqlCommand(sql, connection);
if (keyDate.HasValue)
{
command.Parameters.AddWithValue("@keyDate", keyDate.Value.ToDateTime(TimeOnly.MinValue));
}
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
if (!await reader.ReadAsync(cancellationToken))
{
var dateMessage = keyDate.HasValue
? $"para {keyDate.Value:yyyy-MM-dd}"
: "para hoy";
throw new EncryptionKeyUnavailableException(
$"No existe fila en encryption_keys {dateMessage}. Ejecutar la Function App de claves antes de arrancar la API.");
}
return new EncryptionKeyRow(
DateOnly.FromDateTime(reader.GetDateTime("key_date")),
reader.GetString("key_version"),
reader.GetString("edek"),
reader.GetString("status"));
}
private async Task<MySqlConnection> OpenConnectionAsync(CancellationToken cancellationToken)
{
var connectionString = await _connectionStringProvider.GetConnectionStringAsync(cancellationToken);
var connection = new MySqlConnection(connectionString);
await connection.OpenAsync(cancellationToken);
await using var timeZoneCommand = new MySqlCommand("SET time_zone = '+00:00';", connection);
await timeZoneCommand.ExecuteNonQueryAsync(cancellationToken);
return connection;
}
private async Task<EncryptionKeyMaterial> GetOrUnwrapAsync(
EncryptionKeyRow row,
CancellationToken cancellationToken)
{
var edekHash = ComputeSha256Hex(row.Edek);
try
{
await _unwrapGate.WaitAsync(cancellationToken);
}
catch (OperationCanceledException ex)
{
throw new EncryptionKeyUnavailableException(
$"No se ha podido obtener el bloqueo de carga de clave en {_keyVaultOptions.EncryptionKeyTimeoutSeconds} segundos.",
ex);
}
try
{
if (_cache.TryGetValue(row.KeyDate, out var cached) &&
string.Equals(cached.KeyVersion, row.KeyVersion, StringComparison.Ordinal) &&
string.Equals(cached.EdekHash, edekHash, StringComparison.Ordinal))
{
return cached.ToMaterial();
}
_logger.LogInformation(
"Cargando DEK diaria desde Key Vault. KeyDate={KeyDate}; KeyVersion={KeyVersion}",
row.KeyDate,
row.KeyVersion);
var dek = await UnwrapDekAsync(row, cancellationToken);
var cachedKey = new CachedEncryptionKey(
row.KeyDate,
row.KeyVersion,
edekHash,
dek);
_cache[row.KeyDate] = cachedKey;
_logger.LogInformation(
"DEK diaria cargada en memoria desde encryption_keys. KeyDate={KeyDate}; KeyVersion={KeyVersion}",
row.KeyDate,
row.KeyVersion);
return cachedKey.ToMaterial();
}
finally
{
_unwrapGate.Release();
}
}
private static async Task<byte[]> UnwrapDekAsync(
EncryptionKeyRow row,
CancellationToken cancellationToken)
{
byte[] encryptedDek;
try
{
encryptedDek = Convert.FromBase64String(row.Edek.Trim());
}
catch (FormatException ex)
{
throw new EncryptionKeyUnavailableException(
$"La eDEK de encryption_keys para {row.KeyDate:yyyy-MM-dd} no es Base64 valida.",
ex);
}
try
{
var cryptographyClient = new CryptographyClient(new Uri(row.KeyVersion), new DefaultAzureCredential());
var response = await cryptographyClient.UnwrapKeyAsync(
KeyWrapAlgorithm.RsaOaep,
encryptedDek,
cancellationToken);
var dek = response.Key;
if (dek.Length != 32)
{
throw new EncryptionKeyUnavailableException(
$"La DEK de {row.KeyDate:yyyy-MM-dd} no tiene 32 bytes tras unwrapKey.");
}
return dek.ToArray();
}
catch (RequestFailedException ex)
{
throw new EncryptionKeyUnavailableException(
$"Key Vault no ha podido hacer unwrapKey para {row.KeyDate:yyyy-MM-dd}. Status={ex.Status}; Error={ex.ErrorCode}.",
ex);
}
catch (OperationCanceledException ex)
{
throw new EncryptionKeyUnavailableException(
$"Timeout obteniendo la DEK diaria {row.KeyDate:yyyy-MM-dd} desde encryption_keys/Key Vault. Revisa acceso a Key Vault, permisos unwrapKey y conectividad.",
ex);
}
}
private EncryptionKeyMaterial CreateLocalDevelopmentKey(DateOnly keyDate)
{
var configuredLocalKey = _configuration["Encryption:LocalDevelopmentKey"];
if (string.IsNullOrWhiteSpace(configuredLocalKey))
{
throw new EncryptionKeyUnavailableException(
"Key Vault esta deshabilitado y no se ha configurado Encryption:LocalDevelopmentKey.");
}
return new EncryptionKeyMaterial(
keyDate,
NormalizeKey(configuredLocalKey),
"local-development",
EncryptionKeyMaterial.EnvelopeScheme);
}
private static byte[] NormalizeKey(string secretValue)
{
var trimmed = secretValue.Trim();
try
{
var base64Key = Convert.FromBase64String(trimmed);
if (base64Key.Length is 16 or 24 or 32)
{
return base64Key.Length == 32 ? base64Key : SHA256.HashData(base64Key);
}
}
catch (FormatException)
{
// Si no es base64, derivamos una clave estable desde el valor textual.
}
return SHA256.HashData(Encoding.UTF8.GetBytes(trimmed));
}
private static string ComputeSha256Hex(string value)
=> Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant();
private sealed record EncryptionKeyRow(
DateOnly KeyDate,
string KeyVersion,
string Edek,
string Status);
private sealed record CachedEncryptionKey(
DateOnly KeyDate,
string KeyVersion,
string EdekHash,
byte[] Key)
{
public EncryptionKeyMaterial ToMaterial()
=> new(KeyDate, Key.ToArray(), KeyVersion, EncryptionKeyMaterial.EnvelopeScheme);
}
}

View File

@@ -2,13 +2,18 @@ using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.Extensions.Logging;
namespace ApiDenuncias.Services;
public sealed class GestionaDocumentWorkflowService
{
private const string CircuitTemplateFileDocContentType =
"application/vnd.gestiona.circuits.template-filedoc+json; version=7";
private const string FileDocumentContentType =
"application/vnd.gestiona.file-document+json; version=4";
private readonly IHttpClientFactory _httpClientFactory;
private readonly IConfiguration _configuration;
private readonly ILogger<GestionaDocumentWorkflowService> _logger;
@@ -30,9 +35,6 @@ public sealed class GestionaDocumentWorkflowService
_configuration["Gestiona:AccessToken"]
?? throw new InvalidOperationException("Falta Gestiona:AccessToken en appsettings.");
private string? PreferredCircuitTemplateName =>
_configuration["Gestiona:PreferredCircuitTemplateName"];
public async Task<string> UploadDocumentAndReturnUrlAsync(string fileUrl, byte[] contentBytes, string fileName)
{
var fileUrlAbs = EnsureAbsoluteGestionaUrl(fileUrl, GestionaApiBase);
@@ -55,13 +57,12 @@ public sealed class GestionaDocumentWorkflowService
metaReq.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", GestionaAccessToken);
metaReq.Headers.TryAddWithoutValidation("Prefer", "return=minimal");
metaReq.Headers.Accept.Clear();
metaReq.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
metaReq.Content = new StringContent(metaJson, Encoding.UTF8);
metaReq.Content.Headers.ContentType =
MediaTypeHeaderValue.Parse("application/vnd.gestiona.file-document+json; version=4");
metaReq.Content.Headers.ContentType = MediaTypeHeaderValue.Parse(FileDocumentContentType);
using var metaResp = await CreateRawHttp().SendAsync(metaReq);
LogDeprecatedHeaders(metaResp, "POST documento Gestiona");
var body = await metaResp.Content.ReadAsStringAsync();
if (!metaResp.IsSuccessStatusCode)
@@ -92,7 +93,7 @@ public sealed class GestionaDocumentWorkflowService
}
catch
{
// Si Gestiona no devuelve un JSON valido, intentamos con la cabecera Location.
// Si Gestiona devuelve cuerpo vacío por Prefer:return=minimal, usamos Location del documento.
}
}
@@ -107,96 +108,78 @@ public sealed class GestionaDocumentWorkflowService
public async Task TramitarDocumentoAsync(string documentUrl, string assignedGroupHref, int? complaintId = null)
{
var docUrlAbs = EnsureAbsoluteGestionaUrl(documentUrl, GestionaApiBase);
var payload = BuildConfiguredCircuitPayload(docUrlAbs, assignedGroupHref, complaintId);
string? templateNameForLog = "configurada";
string? templateHrefForLog = GetConfiguredTemplateHref(docUrlAbs);
_ = assignedGroupHref;
if (payload is null)
var docUrlAbs = EnsureAbsoluteGestionaUrl(documentUrl, GestionaApiBase);
var templateHref = GetConfiguredTemplateHref(docUrlAbs);
if (string.IsNullOrWhiteSpace(templateHref))
{
throw new InvalidOperationException(
"Faltan Gestiona:CircuitTemplateId o Gestiona:CircuitSignerStampHref. No se listan plantillas para evitar campos deprecated.");
"Falta Gestiona:CircuitTemplateId. No se listan plantillas para evitar campos deprecated.");
}
var json = payload.ToJsonString(new JsonSerializerOptions(JsonSerializerDefaults.Web));
var payload = await GetCircuitTemplatePayloadAsync(templateHref);
var (success, statusCode, body) = await TryPostCircuitAsync(docUrlAbs, payload);
if (success)
{
_logger.LogInformation(
"Documento {DocumentUrl} enviado a circuito con plantilla {TemplateHref}. Denuncia={ComplaintId}.",
docUrlAbs,
templateHref,
complaintId);
return;
}
using var req = new HttpRequestMessage(HttpMethod.Post, $"{docUrlAbs.TrimEnd('/')}/circuit");
_logger.LogError(
"Fallo al tramitar documento {DocumentUrl} con plantilla configurada ({TemplateHref}). Status: {StatusCode}. Body: {Body}",
docUrlAbs,
templateHref,
(int)statusCode,
body);
throw new InvalidOperationException(
$"TramitarDocumentoAsync: {(int)statusCode} {statusCode}\n{body}");
}
private async Task<string> GetCircuitTemplatePayloadAsync(string templateHref)
{
using var req = new HttpRequestMessage(HttpMethod.Get, templateHref);
req.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", GestionaAccessToken);
req.Headers.TryAddWithoutValidation("Accept", "application/json");
req.Content = new StringContent(json, Encoding.UTF8);
req.Content.Headers.ContentType =
MediaTypeHeaderValue.Parse("application/vnd.gestiona.circuits.template-filedoc+json; version=7");
req.Headers.Accept.Add(
MediaTypeWithQualityHeaderValue.Parse(CircuitTemplateFileDocContentType));
using var resp = await CreateRawHttp().SendAsync(req);
LogDeprecatedHeaders(resp, "GET plantilla circuito Gestiona");
var body = await resp.Content.ReadAsStringAsync();
if (!resp.IsSuccessStatusCode)
{
_logger.LogError(
"Fallo al tramitar documento {DocumentUrl} con plantilla {TemplateName} ({TemplateHref}). Status: {StatusCode}. Body: {Body}",
docUrlAbs,
templateNameForLog,
templateHrefForLog,
(int)resp.StatusCode,
body);
throw new InvalidOperationException(
$"TramitarDocumentoAsync: {(int)resp.StatusCode} {resp.StatusCode}\n{body}");
$"GetCircuitTemplatePayloadAsync: {(int)resp.StatusCode} {resp.StatusCode}\n{body}");
}
_logger.LogInformation(
"Documento {DocumentUrl} enviado a circuito {TemplateName} ({TemplateHref}) para denuncia {ComplaintId}.",
docUrlAbs,
templateNameForLog,
templateHrefForLog,
complaintId);
if (string.IsNullOrWhiteSpace(body))
{
throw new InvalidOperationException("Gestiona ha devuelto vacia la plantilla de circuito configurada.");
}
return body;
}
private JsonObject? BuildConfiguredCircuitPayload(string documentUrl, string assignedGroupHref, int? complaintId)
private async Task<(bool Success, System.Net.HttpStatusCode StatusCode, string Body)> TryPostCircuitAsync(
string documentUrl,
string payload)
{
_ = assignedGroupHref;
_ = complaintId;
using var req = new HttpRequestMessage(HttpMethod.Post, $"{documentUrl.TrimEnd('/')}/circuit");
req.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", GestionaAccessToken);
req.Headers.TryAddWithoutValidation("Accept", "application/json");
req.Content = new StringContent(payload, Encoding.UTF8);
req.Content.Headers.ContentType = MediaTypeHeaderValue.Parse(CircuitTemplateFileDocContentType);
var templateHref = GetConfiguredTemplateHref(documentUrl);
var signerHref = _configuration["Gestiona:CircuitSignerStampHref"];
if (string.IsNullOrWhiteSpace(templateHref) || string.IsNullOrWhiteSpace(signerHref))
{
return null;
}
var payload = new JsonObject
{
["block_edit"] = true,
["send_alerts"] = true,
["version"] = _configuration["Gestiona:CircuitVersion"] ?? "2",
["signers"] = new JsonArray
{
JsonSerializer.SerializeToNode(new
{
rel = "signer-stamp",
href = signerHref,
title = _configuration["Gestiona:CircuitSignerStampTitle"] ?? "oaaf-complaints-tramit"
})
},
["links"] = new JsonArray
{
JsonSerializer.SerializeToNode(new { rel = "self", href = templateHref })
}
};
var recipientGroupHref = _configuration["Gestiona:CircuitRecipientGroupHref"];
if (!string.IsNullOrWhiteSpace(recipientGroupHref))
{
payload["recipients"] = new JsonArray
{
JsonSerializer.SerializeToNode(new
{
rel = "group",
href = recipientGroupHref
})
};
}
return payload;
using var resp = await CreateRawHttp().SendAsync(req);
LogDeprecatedHeaders(resp, "POST circuito Gestiona");
var body = await resp.Content.ReadAsStringAsync();
return (resp.IsSuccessStatusCode, resp.StatusCode, body);
}
private string? GetConfiguredTemplateHref(string documentUrl)
@@ -213,9 +196,12 @@ public sealed class GestionaDocumentWorkflowService
{
using var createReq = new HttpRequestMessage(HttpMethod.Post, $"{GestionaApiBase}/rest/uploads");
createReq.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", GestionaAccessToken);
createReq.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
createReq.Headers.TryAddWithoutValidation("Prefer", "return=minimal");
createReq.Headers.Accept.Add(
MediaTypeWithQualityHeaderValue.Parse("application/vnd.gestiona.upload+json"));
using var createResp = await CreateRawHttp().SendAsync(createReq);
LogDeprecatedHeaders(createResp, "POST upload Gestiona");
var createBody = await createResp.Content.ReadAsStringAsync();
if (!createResp.IsSuccessStatusCode)
{
@@ -230,11 +216,14 @@ public sealed class GestionaDocumentWorkflowService
putReq.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", GestionaAccessToken);
putReq.Headers.TryAddWithoutValidation("X-Gestiona-Upload-MD5", GetMd5Hex(contentBytes));
putReq.Headers.TryAddWithoutValidation("Slug", fileName);
putReq.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
putReq.Headers.TryAddWithoutValidation("Prefer", "return=minimal");
putReq.Headers.Accept.Add(
MediaTypeWithQualityHeaderValue.Parse("application/vnd.gestiona.upload+json"));
putReq.Content = new ByteArrayContent(contentBytes);
putReq.Content.Headers.ContentType = new MediaTypeHeaderValue("application/pdf");
using var putResp = await CreateRawHttp().SendAsync(putReq);
LogDeprecatedHeaders(putResp, "PUT upload Gestiona");
var infoJson = await putResp.Content.ReadAsStringAsync();
if (!putResp.IsSuccessStatusCode)
{
@@ -242,11 +231,14 @@ public sealed class GestionaDocumentWorkflowService
$"CreateUploadAsync (PUT): {(int)putResp.StatusCode} {putResp.StatusCode}\n{infoJson}");
}
using var infoDoc = JsonDocument.Parse(infoJson);
var status = infoDoc.RootElement.TryGetProperty("status", out var pStatus) ? pStatus.GetString() : null;
if (!string.Equals(status, "READY", StringComparison.OrdinalIgnoreCase))
if (!string.IsNullOrWhiteSpace(infoJson))
{
throw new InvalidOperationException($"Upload no READY: {status}");
using var infoDoc = JsonDocument.Parse(infoJson);
var status = infoDoc.RootElement.TryGetProperty("status", out var pStatus) ? pStatus.GetString() : "READY";
if (!string.Equals(status, "READY", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException($"Upload no READY: {status}");
}
}
return uploadUri;
@@ -260,177 +252,6 @@ public sealed class GestionaDocumentWorkflowService
: $"{normalized}/documents-and-folders";
}
private async Task<CircuitTemplateCandidate> ObtenerTemplateCircuitoFirmaAsync(string documentUrl)
{
using var req = new HttpRequestMessage(HttpMethod.Get, $"{documentUrl.TrimEnd('/')}/circuit/templates");
req.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", GestionaAccessToken);
req.Headers.TryAddWithoutValidation("Accept", "application/vnd.gestiona.circuits.templates-filedoc-page");
using var resp = await CreateRawHttp().SendAsync(req);
var body = await resp.Content.ReadAsStringAsync();
if (!resp.IsSuccessStatusCode)
{
throw new InvalidOperationException(
$"ObtenerTemplateCircuitoFirmaAsync: {(int)resp.StatusCode} {resp.StatusCode}\n{body}");
}
using var doc = JsonDocument.Parse(body);
if (!doc.RootElement.TryGetProperty("content", out var content) || content.ValueKind != JsonValueKind.Array)
{
throw new InvalidOperationException("No se ha podido leer el listado de plantillas de circuito.");
}
var templates = new List<CircuitTemplateCandidate>();
foreach (var item in content.EnumerateArray())
{
var name = item.TryGetProperty("name", out var pName) ? pName.GetString() : null;
var href = GetSelfHref(item);
if (string.IsNullOrWhiteSpace(href))
{
continue;
}
var payload = JsonNode.Parse(item.GetRawText()) as JsonObject ?? new JsonObject();
templates.Add(new CircuitTemplateCandidate(
Name: name,
Href: href!,
Payload: payload,
SignersCount: CountArrayItems(payload, "signers"),
BlockEdit: GetBoolean(payload, "block_edit")));
}
if (templates.Count == 0)
{
throw new InvalidOperationException("No hay plantillas de circuito disponibles para el documento.");
}
_logger.LogInformation(
"Plantillas de circuito para {DocumentUrl}: {Templates}",
documentUrl,
string.Join(
" | ",
templates.Select(template =>
$"{template.Name ?? "(sin nombre)"} [firmantes={template.SignersCount}, candado={template.BlockEdit}]")));
if (!string.IsNullOrWhiteSpace(PreferredCircuitTemplateName))
{
var configuredExact = templates.FirstOrDefault(template =>
string.Equals(template.Name, PreferredCircuitTemplateName, StringComparison.OrdinalIgnoreCase));
if (configuredExact is not null)
{
return configuredExact;
}
var configuredContains = templates.FirstOrDefault(template =>
!string.IsNullOrWhiteSpace(template.Name) &&
template.Name.Contains(PreferredCircuitTemplateName, StringComparison.OrdinalIgnoreCase));
if (configuredContains is not null)
{
return configuredContains;
}
}
string[] preferredNames =
[
"CT-Actualizacion de denuncia",
"CT-Actualizaci\u00f3n de denuncia",
"Firma automatizada",
"Firma Sello de \u00D3rgano",
"Firma Sello de Organo"
];
var preferredTemplate = templates.FirstOrDefault(template =>
preferredNames.Any(preferred =>
string.Equals(template.Name, preferred, StringComparison.OrdinalIgnoreCase)));
if (preferredTemplate is not null)
{
return preferredTemplate;
}
var templatesWithSigners = templates
.Where(template => template.SignersCount > 0)
.OrderByDescending(template => template.BlockEdit)
.ThenByDescending(template => template.SignersCount)
.ToList();
if (templatesWithSigners.Count > 0)
{
return templatesWithSigners[0];
}
return templates[0];
}
private static JsonObject BuildCircuitPayloadFromTemplate(
CircuitTemplateCandidate template,
string assignedGroupHref,
int? complaintId)
{
_ = assignedGroupHref;
_ = complaintId;
var payload = (JsonObject)template.Payload.DeepClone();
EnsureTemplateSelfLink(payload, template.Href);
payload.Remove("assigneds_can_use");
return payload;
}
private static void EnsureTemplateSelfLink(JsonObject payload, string templateSelfHref)
{
if (payload["links"] is not JsonArray links)
{
links = new JsonArray();
payload["links"] = links;
}
foreach (var node in links)
{
if (node is not JsonObject link)
{
continue;
}
var rel = link["rel"]?.GetValue<string>();
var href = link["href"]?.GetValue<string>();
if (string.Equals(rel, "self", StringComparison.OrdinalIgnoreCase) &&
!string.IsNullOrWhiteSpace(href))
{
return;
}
}
links.Add(JsonSerializer.SerializeToNode(new { rel = "self", href = templateSelfHref }));
}
private static int CountArrayItems(JsonObject payload, string propertyName)
{
return payload[propertyName] is JsonArray array ? array.Count : 0;
}
private static bool GetBoolean(JsonObject payload, string propertyName)
{
return payload[propertyName]?.GetValue<bool?>() ?? false;
}
private static string? GetSelfHref(JsonElement item)
{
if (!item.TryGetProperty("links", out var links) || links.ValueKind != JsonValueKind.Array)
{
return null;
}
foreach (var link in links.EnumerateArray())
{
var rel = link.TryGetProperty("rel", out var pRel) ? pRel.GetString() : null;
var href = link.TryGetProperty("href", out var pHref) ? pHref.GetString() : null;
if (string.Equals(rel, "self", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(href))
{
return href;
}
}
return null;
}
private static string EnsureAbsoluteGestionaUrl(string url, string apiBase)
{
if (string.IsNullOrWhiteSpace(url))
@@ -458,10 +279,15 @@ public sealed class GestionaDocumentWorkflowService
return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
}
private sealed record CircuitTemplateCandidate(
string? Name,
string Href,
JsonObject Payload,
int SignersCount,
bool BlockEdit);
private void LogDeprecatedHeaders(HttpResponseMessage response, string operation)
{
if (response.Headers.TryGetValues("X-Gestiona-Deprecated", out var deprecated))
{
_logger.LogWarning(
"Gestiona devolvio X-Gestiona-Deprecated en {Operation}: {Deprecated}",
operation,
string.Join(" | ", deprecated));
}
}
}

View File

@@ -1,4 +1,5 @@
using GestionaDenuncias.Shared.Models;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System;
using System.Collections.Generic;
@@ -19,11 +20,16 @@ namespace ApiDenuncias.Services
{
private readonly HttpClient _http;
private readonly GestionaOptions _opts;
private readonly ILogger<GestionaService> _logger;
public GestionaService(HttpClient http, IOptions<GestionaOptions> optsAccessor)
public GestionaService(
HttpClient http,
IOptions<GestionaOptions> optsAccessor,
ILogger<GestionaService> logger)
{
_http = http;
_opts = optsAccessor.Value;
_logger = logger;
}
// =========================================================
@@ -58,7 +64,7 @@ namespace ApiDenuncias.Services
return null;
}
// Reemplaza este helper si quieres controlar la versi<EFBFBD>n en Accept:
// Reemplaza este helper si quieres controlar la versión en Accept:
private void AddTokenAndAccept(HttpRequestMessage req, string mediaType, string? version = null)
{
req.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", _opts.AccessToken);
@@ -83,6 +89,17 @@ namespace ApiDenuncias.Services
req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
}
private void LogDeprecatedHeaders(HttpResponseMessage response, string operation)
{
if (response.Headers.TryGetValues("X-Gestiona-Deprecated", out var deprecated))
{
_logger.LogWarning(
"Gestiona devolvio X-Gestiona-Deprecated en {Operation}: {Deprecated}",
operation,
string.Join(" | ", deprecated));
}
}
// =========================================================
@@ -102,10 +119,12 @@ namespace ApiDenuncias.Services
var url = await ResolveExternalProcedureCreateFileUrlAsync(effectiveProcedureId);
using var req = new HttpRequestMessage(HttpMethod.Post, url);
req.Headers.Accept.Clear();
req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
req.Headers.Accept.Add(
MediaTypeWithQualityHeaderValue.Parse("application/vnd.gestiona.file-opening+json; version=1"));
req.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", _opts.AccessToken);
using var resp = await _http.SendAsync(req);
LogDeprecatedHeaders(resp, "POST create-file");
var body = await resp.Content.ReadAsStringAsync();
if (!resp.IsSuccessStatusCode)
throw new InvalidOperationException($"CreateFileAsync: {(int)resp.StatusCode} {resp.StatusCode}\n{body}");
@@ -113,65 +132,20 @@ namespace ApiDenuncias.Services
using var doc = JsonDocument.Parse(body);
var fileUrl = GetLinkHref(doc.RootElement, "file")
?? throw new InvalidOperationException("CreateFileAsync: Gestiona no ha devuelto link 'file'.");
var fileOpenUrl = GetLinkHref(doc.RootElement, "file-open");
var fileOpenUrl = GetLinkHref(doc.RootElement, "file-open")
?? throw new InvalidOperationException("CreateFileAsync: Gestiona no ha devuelto link 'file-open'.");
return new GestionaCreateFileResponse(fileUrl, fileOpenUrl);
}
private async Task<string> ResolveExternalProcedureCreateFileUrlAsync(Guid procedureId)
private Task<string> ResolveExternalProcedureCreateFileUrlAsync(Guid procedureId)
{
if (Guid.TryParse(_opts.ExternalProcedureId, out var configuredExternalProcedureId))
{
return $"/rest/catalog-2015/procedures/{procedureId}/external-procedures/{configuredExternalProcedureId}/create-file";
}
var externalProcedureId = Guid.TryParse(_opts.ExternalProcedureId, out var configuredExternalProcedureId)
? configuredExternalProcedureId
: procedureId;
using var req = new HttpRequestMessage(HttpMethod.Get, $"/rest/catalog-2015/procedures/{procedureId}/external-procedures");
AddTokenAndAccept(req, "application/vnd.gestiona.external-procedures-page+json");
using var resp = await _http.SendAsync(req);
if (resp.StatusCode == System.Net.HttpStatusCode.NoContent)
{
throw new InvalidOperationException(
$"El procedimiento {procedureId} no tiene tramites externos configurados en Gestiona.");
}
var body = await resp.Content.ReadAsStringAsync();
if (!resp.IsSuccessStatusCode)
{
throw new InvalidOperationException(
$"ResolveExternalProcedureCreateFileUrlAsync: {(int)resp.StatusCode} {resp.StatusCode}\n{body}");
}
using var doc = JsonDocument.Parse(body);
if (!doc.RootElement.TryGetProperty("content", out var content) || content.ValueKind != JsonValueKind.Array)
{
throw new InvalidOperationException(
$"El procedimiento {procedureId} no ha devuelto tramites externos validos en Gestiona.");
}
var createFileCandidates = new List<(string? Id, string Href)>();
foreach (var item in content.EnumerateArray())
{
var createFileHref = GetLinkHref(item, "create-file");
if (!string.IsNullOrWhiteSpace(createFileHref))
{
var externalProcedureId = item.TryGetProperty("id", out var idProp) && idProp.ValueKind == JsonValueKind.String
? idProp.GetString()
: null;
createFileCandidates.Add((externalProcedureId, createFileHref!));
}
}
if (createFileCandidates.Count == 0)
{
throw new InvalidOperationException(
$"El procedimiento {procedureId} no tiene ningun tramite externo con link create-file.");
}
return createFileCandidates
.FirstOrDefault(candidate => string.Equals(candidate.Id, procedureId.ToString(), StringComparison.OrdinalIgnoreCase))
.Href
?? createFileCandidates[0].Href;
return Task.FromResult(
$"/rest/catalog-2015/procedures/{procedureId}/external-procedures/{externalProcedureId}/create-file");
}
public async Task OpenFileAsync(
@@ -183,9 +157,13 @@ namespace ApiDenuncias.Services
string freeTitle,
string siaCode)
{
var url = string.IsNullOrWhiteSpace(fileOpenUrl)
? $"{fileUrl.TrimEnd('/')}/open"
: fileOpenUrl;
if (string.IsNullOrWhiteSpace(fileOpenUrl))
{
throw new InvalidOperationException(
"OpenFileAsync: falta el link 'file-open' devuelto por Gestiona. No se usa el fallback /open para evitar la ruta deprecated.");
}
var url = fileOpenUrl;
var payload = new
{
@@ -211,6 +189,7 @@ namespace ApiDenuncias.Services
AddTokenAndAccept(req, "application/json");
using var resp = await _http.SendAsync(req);
LogDeprecatedHeaders(resp, "POST file-open");
var body = await resp.Content.ReadAsStringAsync();
if (!resp.IsSuccessStatusCode)
throw new InvalidOperationException($"OpenFileAsync: {(int)resp.StatusCode} {resp.StatusCode}\n{body}");
@@ -228,9 +207,12 @@ namespace ApiDenuncias.Services
{
Content = content
};
AddTokenAndAccept(req, "application/json");
req.Headers.TryAddWithoutValidation("Prefer", "return=minimal");
req.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", _opts.AccessToken);
req.Headers.Accept.Clear();
using var resp = await _http.SendAsync(req);
LogDeprecatedHeaders(resp, "POST file-folder");
var body = await resp.Content.ReadAsStringAsync();
if (!resp.IsSuccessStatusCode)
throw new InvalidOperationException($"CreateFolderAsync: {(int)resp.StatusCode} {resp.StatusCode}\n{body}");
@@ -248,15 +230,18 @@ namespace ApiDenuncias.Services
{
using var createReq = new HttpRequestMessage(HttpMethod.Post, "/rest/uploads");
createReq.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", _opts.AccessToken);
createReq.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
createReq.Headers.TryAddWithoutValidation("Prefer", "return=minimal");
createReq.Headers.Accept.Add(
MediaTypeWithQualityHeaderValue.Parse("application/vnd.gestiona.upload+json"));
using var createResp = await _http.SendAsync(createReq);
LogDeprecatedHeaders(createResp, "POST /rest/uploads");
var createBody = await createResp.Content.ReadAsStringAsync();
if (!createResp.IsSuccessStatusCode)
throw new InvalidOperationException($"CreateUpload (POST): {(int)createResp.StatusCode} {createResp.StatusCode}\n{createBody}");
var uploadUri = createResp.Headers.Location?.ToString()
?? throw new InvalidOperationException("No se devolvi<EFBFBD> Location en /rest/uploads");
?? throw new InvalidOperationException("No se devolvió Location en /rest/uploads");
string md5Hex;
using (var md5 = MD5.Create())
@@ -269,21 +254,27 @@ namespace ApiDenuncias.Services
putReq.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", _opts.AccessToken);
putReq.Headers.TryAddWithoutValidation("X-Gestiona-Upload-MD5", md5Hex);
putReq.Headers.TryAddWithoutValidation("Slug", fileName);
putReq.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
putReq.Headers.TryAddWithoutValidation("Prefer", "return=minimal");
putReq.Headers.Accept.Add(
MediaTypeWithQualityHeaderValue.Parse("application/vnd.gestiona.upload+json"));
putReq.Content = new ByteArrayContent(contentBytes);
putReq.Content.Headers.ContentType = new MediaTypeHeaderValue("application/pdf");
using var putResp = await _http.SendAsync(putReq);
LogDeprecatedHeaders(putResp, "PUT upload content");
var infoJson = await putResp.Content.ReadAsStringAsync();
if (!putResp.IsSuccessStatusCode)
throw new InvalidOperationException($"CreateUpload (PUT): {(int)putResp.StatusCode} {putResp.StatusCode}\n{infoJson}");
using var infoDoc = JsonDocument.Parse(infoJson);
var status = infoDoc.RootElement.GetProperty("status").GetString();
if (status != "READY")
throw new InvalidOperationException($"Upload no READY: {status}");
if (!string.IsNullOrWhiteSpace(infoJson))
{
using var infoDoc = JsonDocument.Parse(infoJson);
var status = infoDoc.RootElement.TryGetProperty("status", out var statusProp)
? statusProp.GetString()
: "READY";
if (!string.Equals(status, "READY", StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException($"Upload no READY: {status}");
}
return uploadUri;
}
@@ -309,10 +300,11 @@ namespace ApiDenuncias.Services
using var metaReq = new HttpRequestMessage(HttpMethod.Post, $"{fileUrl}/documents-and-folders")
{ Content = metaContent };
metaReq.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", _opts.AccessToken);
metaReq.Headers.TryAddWithoutValidation("Prefer", "return=minimal");
metaReq.Headers.Accept.Clear();
metaReq.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
using var metaResp = await _http.SendAsync(metaReq);
LogDeprecatedHeaders(metaResp, "POST file document");
var body = await metaResp.Content.ReadAsStringAsync();
if (!metaResp.IsSuccessStatusCode)
throw new InvalidOperationException($"UploadDocumentAsync: {(int)metaResp.StatusCode} {metaResp.StatusCode}\n{body}");
@@ -386,7 +378,7 @@ namespace ApiDenuncias.Services
if (thirdParty.IsLegalEntity)
{
if (string.IsNullOrWhiteSpace(thirdParty.BusinessName))
throw new ArgumentException("La raz<EFBFBD>n social es obligatoria para terceros jur<EFBFBD>dicos.", nameof(thirdParty));
throw new ArgumentException("La razón social es obligatoria para terceros jurídicos.", nameof(thirdParty));
}
else
{
@@ -485,7 +477,7 @@ namespace ApiDenuncias.Services
{
foreach (var item in content.EnumerateArray())
{
if (item.TryGetProperty("links", out var links))
if (item.TryGetProperty("links", out var links))
{
var third = links.EnumerateArray().FirstOrDefault(l => l.GetProperty("rel").GetString() == "third");
if (third.ValueKind != JsonValueKind.Undefined)
@@ -528,6 +520,14 @@ namespace ApiDenuncias.Services
if (string.IsNullOrEmpty(encontrado.SelfHref))
{
if (!CanCreateThirdParty(thirdParty))
{
_logger.LogWarning(
"Se omite la creacion/enlace del tercero en Gestiona para el expediente {FileUrl}: datos identificativos incompletos.",
fileUrl);
return;
}
encontrado = await CrearTerceroAsync(thirdParty);
}
else if (thirdParty.Address?.HasAnyValue == true)
@@ -544,7 +544,27 @@ namespace ApiDenuncias.Services
{
if (!thirdParty.IsAnonymous)
{
return thirdParty;
var documentId = (thirdParty.DocumentId ?? string.Empty).Trim().ToUpperInvariant();
var firstName = (thirdParty.FirstName ?? string.Empty).Trim();
var lastName = (thirdParty.LastName ?? string.Empty).Trim();
var businessName = (thirdParty.BusinessName ?? string.Empty).Trim();
var isLegalEntity = thirdParty.IsLegalEntity ||
LooksLikeLegalEntityDocument(documentId) ||
(!string.IsNullOrWhiteSpace(businessName) &&
string.IsNullOrWhiteSpace(firstName));
return new ThirdPartyIdentityData
{
IsAnonymous = false,
IsLegalEntity = isLegalEntity,
DocumentId = documentId,
FirstName = isLegalEntity ? string.Empty : firstName,
LastName = isLegalEntity ? string.Empty : lastName,
BusinessName = businessName,
Email = (thirdParty.Email ?? string.Empty).Trim(),
CountryCode = string.IsNullOrWhiteSpace(thirdParty.CountryCode) ? "ESP" : thirdParty.CountryCode.Trim(),
Address = thirdParty.Address
};
}
return new ThirdPartyIdentityData
@@ -561,20 +581,43 @@ namespace ApiDenuncias.Services
};
}
// --- CONSULTAS DE EXPEDIENTES (sin recorrer hist<73>rico paginado) ---
private static bool CanCreateThirdParty(ThirdPartyIdentityData thirdParty)
{
if (string.IsNullOrWhiteSpace(thirdParty.DocumentId))
{
return false;
}
return thirdParty.IsLegalEntity
? !string.IsNullOrWhiteSpace(thirdParty.BusinessName)
: !string.IsNullOrWhiteSpace(thirdParty.FirstName) &&
!string.IsNullOrWhiteSpace(thirdParty.LastName);
}
private static bool LooksLikeLegalEntityDocument(string documentId)
{
var value = (documentId ?? string.Empty).Trim().ToUpperInvariant();
return Regex.IsMatch(value, @"^[ABCDEFGHJKLMNPQRSUVW]\d{7}[A-Z0-9]$");
}
// --- CONSULTAS DE EXPEDIENTES (sin recorrer histórico paginado) ---
private async Task<string> GetFilesAsync(object? filter = null)
{
using var req = new HttpRequestMessage(HttpMethod.Get, "/rest/files");
AddBasicHeaders(req);
req.Headers.Accept.Clear();
req.Headers.Accept.Add(
MediaTypeWithQualityHeaderValue.Parse("application/vnd.gestiona.files-page+json"));
if (filter is not null)
{
var json = JsonSerializer.Serialize(filter);
req.Content = new StringContent(json, Encoding.UTF8, "application/vnd.gestiona.filter.files");
req.Content = new StringContent(json, Encoding.UTF8, "application/vnd.gestiona.filter.files+json");
}
using var resp = await _http.SendAsync(req);
LogDeprecatedHeaders(resp, "GET /rest/files");
if (resp.StatusCode == System.Net.HttpStatusCode.NoContent)
{
return "{\"content\":[]}";
@@ -612,7 +655,7 @@ namespace ApiDenuncias.Services
}
/// <summary>
/// Devuelve el JSON crudo de /rest/files acumulando hasta maxPages p<EFBFBD>ginas.
/// Devuelve el JSON crudo de /rest/files acumulando hasta maxPages páginas.
/// </summary>
public async Task<string> ListarExpedientesJsonAsyncBasico(int maxPages = 1)
{
@@ -683,9 +726,10 @@ namespace ApiDenuncias.Services
}
using var req = new HttpRequestMessage(HttpMethod.Get, fileUrl);
AddTokenAndAccept(req, "application/json");
AddTokenAndAccept(req, "application/vnd.gestiona.file+json", "2");
using var resp = await _http.SendAsync(req);
LogDeprecatedHeaders(resp, $"GET {fileUrl}");
if (resp.StatusCode == System.Net.HttpStatusCode.NoContent ||
resp.StatusCode == System.Net.HttpStatusCode.NotFound)
{
@@ -910,7 +954,7 @@ namespace ApiDenuncias.Services
using var resp = await _http.SendAsync(req);
var body = await resp.Content.ReadAsStringAsync();
if (!resp.IsSuccessStatusCode)
throw new InvalidOperationException($"Error actualizando direcci<EFBFBD>n del tercero: {(int)resp.StatusCode} {resp.ReasonPhrase}\n{body}");
throw new InvalidOperationException($"Error actualizando dirección del tercero: {(int)resp.StatusCode} {resp.ReasonPhrase}\n{body}");
}
private async Task<bool> ThirdHasAddressesAsync(string thirdSelfHref)
@@ -1160,7 +1204,7 @@ namespace ApiDenuncias.Services
return value switch
{
"" => "ESP",
"es" or "esp" or "espana" or "espa<EFBFBD>a" or "spain" => "ESP",
"es" or "esp" or "espana" or "españa" or "spain" => "ESP",
"prt" or "pt" or "portugal" => "PRT",
_ when country is { Length: >= 3 } => country.Trim().ToUpperInvariant()[..3],
_ => "ESP",

View File

@@ -1,3 +1,4 @@
using System.Diagnostics;
using System.Globalization;
using System.Net;
using System.Net.Http.Headers;
@@ -12,6 +13,8 @@ using Microsoft.Extensions.Options;
namespace ApiDenuncias.Services;
public sealed record PreparedGlobalLeaksCredentials(string Username, string FinalPassword, string TokenAnswer);
public sealed class GlobalLeaksClient
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
@@ -50,60 +53,158 @@ public sealed class GlobalLeaksClient
string authcode,
CancellationToken cancellationToken)
{
using var tokenRequest = CreateRequest(HttpMethod.Post, "/api/auth/token");
using var tokenResponse = await _httpClient.SendAsync(tokenRequest, cancellationToken);
await EnsureSuccessOrThrowAsync(tokenResponse, "/api/auth/token", cancellationToken);
var tokenData = await tokenResponse.Content.ReadFromJsonAsync<TokenResponse>(JsonOptions, cancellationToken)
?? throw new GlobalLeaksValidationException("No se pudo obtener el reto criptográfico.", 502);
var prepared = await PrepareLoginAsync(username, password, cancellationToken);
return await CompleteLoginAsync(
prepared.Username,
prepared.FinalPassword,
prepared.TokenAnswer,
authcode,
cancellationToken);
}
public async Task<PreparedGlobalLeaksCredentials> PrepareLoginAsync(
string username,
string password,
CancellationToken cancellationToken)
{
var loginWatch = Stopwatch.StartNew();
_logger.LogInformation(
"GlobalLeaks login: iniciando para {Username}. BaseUrl={BaseUrl}",
username,
_options.BaseUrl);
var tokenAnswer = SolveProofOfWork(tokenData.Id, tokenData.Salt);
using var typeRequest = CreateRequest(HttpMethod.Post, "/api/auth/type");
typeRequest.Content = CreateJsonContent(new { username });
using var typeResponse = await _httpClient.SendAsync(typeRequest, cancellationToken);
using var typeResponse = await SendLoginRequestAsync(typeRequest, "/api/auth/type", cancellationToken);
await EnsureSuccessOrThrowAsync(typeResponse, "/api/auth/type", cancellationToken);
var authType = await typeResponse.Content.ReadFromJsonAsync<AuthTypeResponse>(JsonOptions, cancellationToken)
?? throw new GlobalLeaksValidationException("No se pudo obtener el tipo de autenticación.", 502);
var passwordWatch = Stopwatch.StartNew();
_logger.LogInformation(
"GlobalLeaks login: preparando credenciales para {Username}. AuthType={AuthType}",
username,
authType.Type);
var finalPassword = authType.Type == "key"
? DerivePassword(password, authType.Salt)
: password;
using var authRequest = CreateRequest(HttpMethod.Post, "/api/auth/authentication");
authRequest.Content = CreateJsonContent(new
{
tid = 1,
_logger.LogInformation(
"GlobalLeaks login: credenciales preparadas para {Username} en {ElapsedMs} ms",
username,
password = finalPassword,
authcode,
});
authRequest.Headers.Add("X-Token", tokenAnswer);
passwordWatch.ElapsedMilliseconds);
using var authResponse = await _httpClient.SendAsync(authRequest, cancellationToken);
if (!authResponse.IsSuccessStatusCode)
var tokenAnswer = await PrepareProofOfWorkAsync(username, cancellationToken);
_logger.LogInformation(
"GlobalLeaks login: credenciales y proof-of-work preparados para {Username}. Tiempo total={ElapsedMs} ms",
username,
loginWatch.ElapsedMilliseconds);
return new PreparedGlobalLeaksCredentials(username, finalPassword, tokenAnswer);
}
public async Task<GlSession> CompleteLoginAsync(
string username,
string finalPassword,
string authcode,
CancellationToken cancellationToken)
{
var tokenAnswer = await PrepareProofOfWorkAsync(username, cancellationToken);
return await CompleteLoginAsync(username, finalPassword, tokenAnswer, authcode, cancellationToken);
}
public async Task<GlSession> CompleteLoginAsync(
string username,
string finalPassword,
string tokenAnswer,
string authcode,
CancellationToken cancellationToken)
{
var loginWatch = Stopwatch.StartNew();
_logger.LogInformation(
"GlobalLeaks login: enviando autenticacion final para {Username}. AuthcodeLength={AuthcodeLength}",
username,
authcode?.Length ?? 0);
var currentTokenAnswer = tokenAnswer;
for (var attempt = 0; attempt < 2; attempt++)
{
using var authRequest = CreateRequest(
HttpMethod.Post,
$"/api/auth/authentication?token={Uri.EscapeDataString(currentTokenAnswer)}");
authRequest.Content = CreateJsonContent(new
{
tid = 1,
username,
password = finalPassword,
authcode = authcode?.Trim() ?? string.Empty,
});
authRequest.Headers.TryAddWithoutValidation("X-Token", currentTokenAnswer);
using var authResponse = await SendLoginRequestAsync(authRequest, "/api/auth/authentication", cancellationToken);
if (authResponse.IsSuccessStatusCode)
{
var authBody = await authResponse.Content.ReadAsStringAsync(cancellationToken);
var session = ParseAuthSession(authBody, username);
_logger.LogInformation(
"Login GlobalLeaks correcto para {Username}. Rol: {Role}. Tiempo total={ElapsedMs} ms",
session.Username,
session.Role ?? "(sin rol)",
loginWatch.ElapsedMilliseconds);
return session;
}
var body = await ReadBodySafeAsync(authResponse, cancellationToken);
if (attempt == 0 && IsMissingTokenOrSessionError(body))
{
_logger.LogWarning(
"GlobalLeaks rechazo el token preparado para {Username}; se generara un token nuevo y se reintentara la autenticacion.",
username);
currentTokenAnswer = await PrepareProofOfWorkAsync(username, cancellationToken);
continue;
}
throw authResponse.StatusCode switch
{
HttpStatusCode.Unauthorized => new GlobalLeaksValidationException(
"Credenciales incorrectas o código 2FA inválido.",
"Credenciales incorrectas o codigo 2FA invalido.",
StatusCodes.Status401Unauthorized),
(HttpStatusCode)429 => new GlobalLeaksValidationException(
"Demasiados intentos en GlobalLeaks. Espera unos minutos.",
StatusCodes.Status429TooManyRequests),
HttpStatusCode.NotAcceptable when IsTwoFactorRequiredError(body) =>
new GlobalLeaksValidationException(
"Codigo 2FA invalido, caducado o ya utilizado. Introduce el codigo actual de la app autenticadora.",
StatusCodes.Status406NotAcceptable),
_ => new GlobalLeaksValidationException(
string.IsNullOrWhiteSpace(body)
? $"Login fallido (código {(int)authResponse.StatusCode})."
: $"Login fallido (código {(int)authResponse.StatusCode}): {body}",
? $"Login fallido (codigo {(int)authResponse.StatusCode})."
: $"Login fallido (codigo {(int)authResponse.StatusCode}): {body}",
(int)authResponse.StatusCode),
};
}
var authBody = await authResponse.Content.ReadAsStringAsync(cancellationToken);
var session = ParseAuthSession(authBody, username);
_logger.LogInformation("Login GlobalLeaks correcto para {Username}. Rol: {Role}", session.Username, session.Role ?? "(sin rol)");
return session;
throw new GlobalLeaksValidationException("Login fallido: no se pudo completar la autenticacion.", 502);
}
private async Task<string> PrepareProofOfWorkAsync(string username, CancellationToken cancellationToken)
{
using var tokenRequest = CreateRequest(HttpMethod.Post, "/api/auth/token");
using var tokenResponse = await SendLoginRequestAsync(tokenRequest, "/api/auth/token", cancellationToken);
await EnsureSuccessOrThrowAsync(tokenResponse, "/api/auth/token", cancellationToken);
var tokenData = await tokenResponse.Content.ReadFromJsonAsync<TokenResponse>(JsonOptions, cancellationToken)
?? throw new GlobalLeaksValidationException("No se pudo obtener el reto criptografico.", 502);
var proofWatch = Stopwatch.StartNew();
_logger.LogInformation("GlobalLeaks login: resolviendo proof-of-work para {Username}", username);
var tokenAnswer = SolveProofOfWork(tokenData.Id, tokenData.Salt, cancellationToken);
_logger.LogInformation(
"GlobalLeaks login: proof-of-work resuelto para {Username} en {ElapsedMs} ms",
username,
proofWatch.ElapsedMilliseconds);
return tokenAnswer;
}
public async Task<IReadOnlyList<ContextDto>> GetContextsAsync(
@@ -123,7 +224,8 @@ public sealed class GlobalLeaksClient
string? filter,
string? dateFrom,
string? dateTo,
CancellationToken cancellationToken)
CancellationToken cancellationToken,
IReadOnlyList<ContextDto>? contexts = null)
{
filter ??= "all";
@@ -133,8 +235,8 @@ public sealed class GlobalLeaksClient
var tips = ParseReports(body);
_logger.LogInformation("GlobalLeaks /api/recipient/rtips devolvió {Count} denuncias", tips.Count);
var contexts = await GetContextsAsync(sessionId, cancellationToken);
var contextNames = contexts.ToDictionary(c => c.Id, c => c.Name, StringComparer.OrdinalIgnoreCase);
var availableContexts = contexts ?? await GetContextsAsync(sessionId, cancellationToken);
var contextNames = availableContexts.ToDictionary(c => c.Id, c => c.Name, StringComparer.OrdinalIgnoreCase);
IEnumerable<RawReport> filtered = tips;
@@ -196,6 +298,30 @@ public sealed class GlobalLeaksClient
.ToArray();
}
public async Task<ReportDetailDto> GetReportDetailAsync(
string sessionId,
string reportId,
string? lastAccess,
CancellationToken cancellationToken)
{
ValidateUuid(reportId);
using var detailRequest = CreateAuthenticatedRequest(HttpMethod.Get, $"/api/recipient/rtips/{reportId}", sessionId);
using var detailResponse = await SendGlRequestAsync(detailRequest, cancellationToken);
var contentType = detailResponse.Content.Headers.ContentType?.MediaType ?? string.Empty;
var content = await detailResponse.Content.ReadAsByteArrayAsync(cancellationToken);
if (!contentType.Contains("json", StringComparison.OrdinalIgnoreCase) || content.Length == 0)
{
throw new GlobalLeaksValidationException(
"No se pudo leer el detalle de la denuncia. Puede estar cifrada sin clave disponible en el servidor.",
422);
}
using var document = JsonDocument.Parse(content);
return ParseReportDetail(reportId, lastAccess, document.RootElement);
}
public async Task<FileDownloadResult> DownloadReportZipAsync(
string sessionId,
string reportId,
@@ -253,6 +379,43 @@ public sealed class GlobalLeaksClient
return new FileDownloadResult(content, $"report-{progressive}.json");
}
private async Task<HttpResponseMessage> SendLoginRequestAsync(
HttpRequestMessage request,
string endpoint,
CancellationToken cancellationToken)
{
var stepWatch = Stopwatch.StartNew();
_logger.LogInformation("GlobalLeaks login: llamando a {Endpoint}", endpoint);
try
{
var response = await _httpClient.SendAsync(request, cancellationToken);
_logger.LogInformation(
"GlobalLeaks login: {Endpoint} respondio {StatusCode} en {ElapsedMs} ms",
endpoint,
(int)response.StatusCode,
stepWatch.ElapsedMilliseconds);
return response;
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
_logger.LogWarning(
"GlobalLeaks login: timeout en {Endpoint} tras {ElapsedMs} ms",
endpoint,
stepWatch.ElapsedMilliseconds);
throw;
}
catch (Exception ex)
{
_logger.LogError(
ex,
"GlobalLeaks login: error en {Endpoint} tras {ElapsedMs} ms",
endpoint,
stepWatch.ElapsedMilliseconds);
throw;
}
}
private async Task<HttpResponseMessage> SendGlRequestAsync(
HttpRequestMessage request,
CancellationToken cancellationToken,
@@ -303,7 +466,7 @@ public sealed class GlobalLeaksClient
return content;
}
private static string SolveProofOfWork(string tokenId, string tokenSalt)
private static string SolveProofOfWork(string tokenId, string tokenSalt, CancellationToken cancellationToken)
{
var idBytes = Encoding.UTF8.GetBytes(tokenId);
var saltBytes = Convert.FromBase64String(tokenSalt).Take(16).ToArray();
@@ -311,6 +474,11 @@ public sealed class GlobalLeaksClient
while (true)
{
if ((n & 15) == 0)
{
cancellationToken.ThrowIfCancellationRequested();
}
var input = idBytes.Concat(Encoding.UTF8.GetBytes(n.ToString(CultureInfo.InvariantCulture))).ToArray();
var hash = ComputeArgon2(input, saltBytes, iterations: 1, memoryKb: 1024);
if (hash[^1] == 0)
@@ -477,6 +645,74 @@ public sealed class GlobalLeaksClient
return reports;
}
private static ReportDetailDto ParseReportDetail(string reportId, string? lastAccess, JsonElement root)
{
var lastAccessDate = ParseDate(lastAccess);
bool IsNew(string? value)
{
if (lastAccessDate is null)
{
return true;
}
var itemDate = ParseDate(value);
return itemDate is not null && itemDate > lastAccessDate;
}
var comments = EnumerateArray(root, "comments")
.Select(item => new ReportCommentDto(
GetString(item, "id"),
GetString(item, "type"),
GetString(item, "content", "text", "message"),
GetString(item, "creation_date", "creationDate"),
IsNew(GetString(item, "creation_date", "creationDate"))))
.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)
{
if (root.ValueKind != JsonValueKind.Object)
{
yield break;
}
foreach (var name in names)
{
if (root.TryGetProperty(name, out var property) && property.ValueKind == JsonValueKind.Array)
{
foreach (var item in property.EnumerateArray())
{
yield return item;
}
yield break;
}
}
}
private static JsonElement? FindArrayProperty(JsonElement element, params string[] names)
{
if (element.ValueKind != JsonValueKind.Object)
@@ -513,6 +749,30 @@ public sealed class GlobalLeaksClient
return null;
}
private static string? GetLocalizedString(JsonElement element, params string[] names)
{
foreach (var name in names)
{
if (!element.TryGetProperty(name, out var property))
{
continue;
}
if (property.ValueKind == JsonValueKind.String)
{
return property.GetString();
}
if (property.ValueKind == JsonValueKind.Object)
{
var value = ExtractName(property, string.Empty);
return string.IsNullOrWhiteSpace(value) ? null : value;
}
}
return null;
}
private static int? GetInt32(JsonElement element, params string[] names)
{
foreach (var name in names)
@@ -535,6 +795,28 @@ public sealed class GlobalLeaksClient
return null;
}
private static long? GetInt64(JsonElement element, params string[] names)
{
foreach (var name in names)
{
if (element.TryGetProperty(name, out var property))
{
if (property.ValueKind == JsonValueKind.Number && property.TryGetInt64(out var value))
{
return value;
}
if (property.ValueKind == JsonValueKind.String &&
long.TryParse(property.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out value))
{
return value;
}
}
}
return null;
}
private static bool GetBool(JsonElement element, params string[] names)
{
foreach (var name in names)
@@ -621,6 +903,25 @@ public sealed class GlobalLeaksClient
}
}
private static bool IsTwoFactorRequiredError(string body)
{
if (string.IsNullOrWhiteSpace(body))
{
return false;
}
return body.Contains("\"error_code\":13", StringComparison.OrdinalIgnoreCase) ||
body.Contains("\"error_code\": 13", StringComparison.OrdinalIgnoreCase) ||
body.Contains("Two Factor authentication required", StringComparison.OrdinalIgnoreCase);
}
private static bool IsMissingTokenOrSessionError(string body)
{
return !string.IsNullOrWhiteSpace(body) &&
(body.Contains("No token and no session", StringComparison.OrdinalIgnoreCase) ||
body.Contains("Invalid request: No token", StringComparison.OrdinalIgnoreCase));
}
private sealed record TokenResponse(string Id, string Salt);
private sealed record AuthTypeResponse(string Type, string Salt);

View File

@@ -0,0 +1,8 @@
namespace ApiDenuncias.Services;
public interface IEnvelopeEncryptionKeyProvider
{
ValueTask<EncryptionKeyMaterial> GetCurrentKeyAsync(CancellationToken cancellationToken = default);
ValueTask<EncryptionKeyMaterial> GetKeyForDateAsync(DateOnly keyDate, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,19 @@
using GestionaDenuncias.Shared.Models;
namespace ApiDenuncias.Services;
public interface IFilteredDenunciaStore
{
Task<List<DenunciasGestiona>> GetDenunciasByIdsAsync(
IReadOnlyCollection<int> denunciaIds,
CancellationToken cancellationToken = default);
Task<List<DenunciasGestiona>> GetDenunciasByIdsAsync(
IReadOnlyCollection<int> denunciaIds,
DenunciaListScope scope,
CancellationToken cancellationToken = default);
Task<List<FicherosDenuncias>> GetFicherosByDenunciaIdsAsync(
IReadOnlyCollection<int> denunciaIds,
CancellationToken cancellationToken = default);
}

View File

@@ -1,4 +1,5 @@
using System.Globalization;
using System.Data;
using GestionaDenuncias.Shared.Models;
using MySqlConnector;
@@ -84,10 +85,11 @@ public sealed class InboxTrackingService : IInboxTrackingService
}
catch
{
await transaction.RollbackAsync(cancellationToken);
await SafeRollbackAsync(transaction, cancellationToken);
throw;
}
await EnsureConnectionOpenAsync(connection, cancellationToken);
var metadata = await LoadMetadataAsync(connection, userId, reportList.Select(r => r.Id).ToList(), cancellationToken);
return reportList
@@ -132,10 +134,11 @@ public sealed class InboxTrackingService : IInboxTrackingService
}
catch
{
await transaction.RollbackAsync(cancellationToken);
await SafeRollbackAsync(transaction, cancellationToken);
throw;
}
await EnsureConnectionOpenAsync(connection, cancellationToken);
var metadata = await LoadMetadataAsync(connection, userId, [report.Id], cancellationToken);
if (metadata.TryGetValue(report.Id, out var meta) && meta.LockedByAnotherUser)
{
@@ -254,7 +257,7 @@ public sealed class InboxTrackingService : IInboxTrackingService
}
catch
{
await transaction.RollbackAsync(cancellationToken);
await SafeRollbackAsync(transaction, cancellationToken);
throw;
}
}
@@ -386,6 +389,8 @@ public sealed class InboxTrackingService : IInboxTrackingService
List<string> reportIds,
CancellationToken cancellationToken)
{
await EnsureConnectionOpenAsync(connection, cancellationToken);
var metadata = new Dictionary<string, ReportMetadata>(StringComparer.OrdinalIgnoreCase);
if (reportIds.Count == 0)
{
@@ -462,6 +467,34 @@ public sealed class InboxTrackingService : IInboxTrackingService
return connection;
}
private static async Task EnsureConnectionOpenAsync(
MySqlConnection connection,
CancellationToken cancellationToken)
{
if (connection.State == ConnectionState.Open)
{
return;
}
await connection.OpenAsync(cancellationToken);
await using var timeZoneCommand = new MySqlCommand("SET time_zone = '+00:00';", connection);
await timeZoneCommand.ExecuteNonQueryAsync(cancellationToken);
}
private async Task SafeRollbackAsync(
MySqlTransaction transaction,
CancellationToken cancellationToken)
{
try
{
await transaction.RollbackAsync(cancellationToken);
}
catch
{
// No dejamos que un rollback sobre una conexion ya cerrada oculte el error real.
}
}
private static object ToDbString(string? value)
{
return string.IsNullOrWhiteSpace(value) ? DBNull.Value : value;

View File

@@ -0,0 +1,252 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using ApiDenuncias.Configuration;
using Azure;
using Azure.Identity;
using Azure.Security.KeyVault.Secrets;
using Microsoft.Extensions.Options;
namespace ApiDenuncias.Services;
public sealed class ManualPurgeService
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
private readonly HttpClient _httpClient;
private readonly KeyVaultOptions _keyVaultOptions;
private readonly ManualPurgeOptions _manualPurgeOptions;
private readonly ILogger<ManualPurgeService> _logger;
public ManualPurgeService(
HttpClient httpClient,
IOptions<KeyVaultOptions> keyVaultOptions,
IOptions<ManualPurgeOptions> manualPurgeOptions,
ILogger<ManualPurgeService> logger)
{
_httpClient = httpClient;
_keyVaultOptions = keyVaultOptions.Value;
_manualPurgeOptions = manualPurgeOptions.Value;
_logger = logger;
}
public async Task<ManualPurgeResponse> ExecuteAsync(DateOnly purgeDate, CancellationToken cancellationToken)
{
var configuredFunctionKey = Environment.GetEnvironmentVariable("KEYMGMT_FUNCTION_KEY");
var hasDirectFunctionKey = !string.IsNullOrWhiteSpace(configuredFunctionKey);
if (!_keyVaultOptions.Enabled && !hasDirectFunctionKey)
{
throw new InvalidOperationException("Key Vault debe estar habilitado o KEYMGMT_FUNCTION_KEY debe estar configurada para ejecutar una purga manual.");
}
if (!hasDirectFunctionKey && string.IsNullOrWhiteSpace(_keyVaultOptions.VaultUrl))
{
throw new InvalidOperationException("KeyVault:VaultUrl no esta configurado.");
}
var functionUri = ResolveEndpointUri(
_manualPurgeOptions.FunctionUrl,
"manual_purge",
"ManualPurge:FunctionUrl");
Uri? forceRotateUri = null;
if (_manualPurgeOptions.ReplaceOnManualPurge && _manualPurgeOptions.RecoverPartialReplaceFailure)
{
forceRotateUri = ResolveEndpointUri(
_manualPurgeOptions.ForceRotateUrl,
"force_rotate",
"ManualPurge:ForceRotateUrl");
}
if (!hasDirectFunctionKey && string.IsNullOrWhiteSpace(_manualPurgeOptions.FunctionKeySecretName))
{
throw new InvalidOperationException("ManualPurge:FunctionKeySecretName no esta configurado.");
}
var timeoutSeconds = Math.Clamp(_manualPurgeOptions.TimeoutSeconds, 5, 120);
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(TimeSpan.FromSeconds(timeoutSeconds));
var dateText = purgeDate.ToString("yyyy-MM-dd");
var functionKey = await ReadFunctionKeyAsync(timeout.Token);
using var request = new HttpRequestMessage(HttpMethod.Post, functionUri);
request.Headers.TryAddWithoutValidation("x-functions-key", functionKey);
request.Content = JsonContent.Create(
new { date = dateText, replace = _manualPurgeOptions.ReplaceOnManualPurge },
options: JsonOptions);
_logger.LogWarning(
"Ejecutando purga manual mediante Function App. Date={Date}; Replace={Replace}; FunctionUrl={FunctionUrl}; SecretName={SecretName}",
dateText,
_manualPurgeOptions.ReplaceOnManualPurge,
functionUri,
_manualPurgeOptions.FunctionKeySecretName);
using var response = await _httpClient.SendAsync(request, timeout.Token);
var body = await response.Content.ReadAsStringAsync(timeout.Token);
if (!response.IsSuccessStatusCode)
{
if (forceRotateUri is not null && IsPartialPurgeFailure(body))
{
return await RecoverWithForceRotateAsync(
forceRotateUri,
dateText,
functionKey,
body,
timeout.Token);
}
throw new InvalidOperationException(
$"La Function App de purga ha respondido con {(int)response.StatusCode} ({response.ReasonPhrase}). Body: {TrimBody(body)}");
}
_logger.LogWarning(
"Purga manual completada por Function App. Date={Date}; StatusCode={StatusCode}",
dateText,
(int)response.StatusCode);
return new ManualPurgeResponse(
dateText,
Success: true,
StatusCode: (int)response.StatusCode,
ResponseBody: body);
}
private async Task<ManualPurgeResponse> RecoverWithForceRotateAsync(
Uri forceRotateUri,
string dateText,
string functionKey,
string purgeBody,
CancellationToken cancellationToken)
{
_logger.LogWarning(
"La purga manual se completo, pero fallo la creacion de la nueva clave. Reintentando force_rotate. Date={Date}; ForceRotateUrl={ForceRotateUrl}",
dateText,
forceRotateUri);
using var request = new HttpRequestMessage(HttpMethod.Post, forceRotateUri);
request.Headers.TryAddWithoutValidation("x-functions-key", functionKey);
request.Content = JsonContent.Create(new { date = dateText }, options: JsonOptions);
using var response = await _httpClient.SendAsync(request, cancellationToken);
var body = await response.Content.ReadAsStringAsync(cancellationToken);
if (!response.IsSuccessStatusCode && response.StatusCode != HttpStatusCode.Conflict)
{
throw new InvalidOperationException(
"La purga se completo, pero no se ha podido crear la nueva clave con force_rotate. " +
$"Status={(int)response.StatusCode} ({response.ReasonPhrase}). Body: {TrimBody(body)}. " +
$"Respuesta original manual_purge: {TrimBody(purgeBody)}");
}
_logger.LogWarning(
"Recuperacion force_rotate completada tras purga manual parcial. Date={Date}; StatusCode={StatusCode}",
dateText,
(int)response.StatusCode);
var recoveryBody = JsonSerializer.Serialize(
new
{
message = "manual_purge purgo la clave, pero fallo al crear la nueva. La API ha reintentado force_rotate para la misma fecha.",
manual_purge_body = TrimBody(purgeBody),
force_rotate_status = (int)response.StatusCode,
force_rotate_body = TrimBody(body)
},
JsonOptions);
return new ManualPurgeResponse(
dateText,
Success: true,
StatusCode: (int)response.StatusCode,
ResponseBody: recoveryBody);
}
private async Task<string> ReadFunctionKeyAsync(CancellationToken cancellationToken)
{
var configuredFunctionKey = Environment.GetEnvironmentVariable("KEYMGMT_FUNCTION_KEY");
if (!string.IsNullOrWhiteSpace(configuredFunctionKey))
{
return configuredFunctionKey.Trim();
}
try
{
var client = new SecretClient(new Uri(_keyVaultOptions.VaultUrl), new DefaultAzureCredential());
var secret = await client.GetSecretAsync(
_manualPurgeOptions.FunctionKeySecretName.Trim(),
cancellationToken: cancellationToken);
if (string.IsNullOrWhiteSpace(secret.Value.Value))
{
throw new InvalidOperationException(
$"El secreto '{_manualPurgeOptions.FunctionKeySecretName}' de Key Vault esta vacio.");
}
return secret.Value.Value.Trim();
}
catch (RequestFailedException ex)
{
throw new InvalidOperationException(
$"No se ha podido leer el secreto '{_manualPurgeOptions.FunctionKeySecretName}' de Key Vault. Status={ex.Status}; Error={ex.ErrorCode}.",
ex);
}
}
private static string TrimBody(string body)
{
if (string.IsNullOrWhiteSpace(body))
{
return "(sin cuerpo)";
}
return body.Length <= 1200
? body
: body[..1200] + "...";
}
private static bool IsPartialPurgeFailure(string body)
{
if (string.IsNullOrWhiteSpace(body))
{
return false;
}
try
{
using var document = JsonDocument.Parse(body);
return document.RootElement.TryGetProperty("purged", out var purged) &&
purged.ValueKind == JsonValueKind.True;
}
catch (JsonException)
{
return false;
}
}
private Uri ResolveEndpointUri(string configuredUrl, string endpointName, string optionName)
{
var baseUrl = FirstNonWhiteSpace(
Environment.GetEnvironmentVariable("KEYMGMT_BASE_URL"),
_manualPurgeOptions.BaseUrl);
var candidateUrl = string.IsNullOrWhiteSpace(baseUrl)
? configuredUrl
: $"{baseUrl.Trim().TrimEnd('/')}/{endpointName}";
if (!Uri.TryCreate(candidateUrl, UriKind.Absolute, out var uri))
{
var sourceName = string.IsNullOrWhiteSpace(baseUrl)
? optionName
: "KEYMGMT_BASE_URL/ManualPurge:BaseUrl";
throw new InvalidOperationException($"{sourceName} no contiene una URL valida.");
}
return uri;
}
private static string? FirstNonWhiteSpace(params string?[] values)
=> values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value));
}

View File

@@ -12,6 +12,8 @@ public sealed class MySqlConnectionStringProvider
private readonly ComplaintStorageOptions _storageOptions;
private readonly KeyVaultOptions _keyVaultOptions;
private readonly ILogger<MySqlConnectionStringProvider> _logger;
private readonly SemaphoreSlim _loadGate = new(1, 1);
private string? _cachedConnectionString;
public MySqlConnectionStringProvider(
IOptions<ComplaintStorageOptions> storageOptions,
@@ -25,10 +27,28 @@ public sealed class MySqlConnectionStringProvider
public async ValueTask<string> GetConnectionStringAsync(CancellationToken cancellationToken = default)
{
return await LoadConnectionStringAsync().WaitAsync(cancellationToken);
if (!string.IsNullOrWhiteSpace(_cachedConnectionString))
{
return _cachedConnectionString;
}
await _loadGate.WaitAsync(cancellationToken);
try
{
if (string.IsNullOrWhiteSpace(_cachedConnectionString))
{
_cachedConnectionString = await LoadConnectionStringAsync(cancellationToken);
}
return _cachedConnectionString;
}
finally
{
_loadGate.Release();
}
}
private async Task<string> LoadConnectionStringAsync()
private async Task<string> LoadConnectionStringAsync(CancellationToken cancellationToken)
{
if (!_storageOptions.UseKeyVault || !_keyVaultOptions.Enabled)
{
@@ -48,12 +68,12 @@ public sealed class MySqlConnectionStringProvider
}
var client = new SecretClient(new Uri(_keyVaultOptions.VaultUrl), new DefaultAzureCredential());
var host = await GetRequiredSecretAsync(client, _storageOptions.HostSecretName);
var user = await GetRequiredSecretAsync(client, _storageOptions.UserSecretName);
var password = await GetRequiredSecretAsync(client, _storageOptions.PasswordSecretName);
var database = await GetRequiredSecretAsync(client, _storageOptions.DatabaseSecretName);
var port = await GetOptionalUIntSecretAsync(client, _storageOptions.PortSecretName, _storageOptions.DefaultPort);
var sslMode = await GetOptionalSecretAsync(client, _storageOptions.SslModeSecretName, _storageOptions.DefaultSslMode);
var host = await GetRequiredSecretAsync(client, _storageOptions.HostSecretName, cancellationToken);
var user = await GetRequiredSecretAsync(client, _storageOptions.UserSecretName, cancellationToken);
var password = await GetRequiredSecretAsync(client, _storageOptions.PasswordSecretName, cancellationToken);
var database = await GetRequiredSecretAsync(client, _storageOptions.DatabaseSecretName, cancellationToken);
var port = await GetOptionalUIntSecretAsync(client, _storageOptions.PortSecretName, _storageOptions.DefaultPort, cancellationToken);
var sslMode = await GetOptionalSecretAsync(client, _storageOptions.SslModeSecretName, _storageOptions.DefaultSslMode, cancellationToken);
var builder = new MySqlConnectionStringBuilder
{
@@ -76,9 +96,12 @@ public sealed class MySqlConnectionStringProvider
return builder.ConnectionString;
}
private static async Task<string> GetRequiredSecretAsync(SecretClient client, string secretName)
private static async Task<string> GetRequiredSecretAsync(
SecretClient client,
string secretName,
CancellationToken cancellationToken)
{
var value = await GetOptionalSecretAsync(client, secretName, null);
var value = await GetOptionalSecretAsync(client, secretName, null, cancellationToken);
if (string.IsNullOrWhiteSpace(value))
{
throw new InvalidOperationException($"El secreto obligatorio '{secretName}' de Key Vault no existe o esta vacio.");
@@ -87,7 +110,11 @@ public sealed class MySqlConnectionStringProvider
return value.Trim();
}
private static async Task<string> GetOptionalSecretAsync(SecretClient client, string secretName, string? fallback)
private static async Task<string> GetOptionalSecretAsync(
SecretClient client,
string secretName,
string? fallback,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(secretName))
{
@@ -96,7 +123,7 @@ public sealed class MySqlConnectionStringProvider
try
{
var secret = await client.GetSecretAsync(secretName.Trim());
var secret = await client.GetSecretAsync(secretName.Trim(), cancellationToken: cancellationToken);
return string.IsNullOrWhiteSpace(secret.Value.Value)
? fallback ?? string.Empty
: secret.Value.Value.Trim();
@@ -107,9 +134,13 @@ public sealed class MySqlConnectionStringProvider
}
}
private static async Task<uint> GetOptionalUIntSecretAsync(SecretClient client, string secretName, uint fallback)
private static async Task<uint> GetOptionalUIntSecretAsync(
SecretClient client,
string secretName,
uint fallback,
CancellationToken cancellationToken)
{
var value = await GetOptionalSecretAsync(client, secretName, null);
var value = await GetOptionalSecretAsync(client, secretName, null, cancellationToken);
return uint.TryParse(value, out var parsed) && parsed > 0
? parsed
: fallback;

View File

@@ -1,6 +1,7 @@
using System.Data;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using ApiDenuncias.Configuration;
using GestionaDenuncias.Shared.Models;
using Microsoft.Extensions.Options;
@@ -10,6 +11,113 @@ namespace ApiDenuncias.Services;
public sealed class MySqlDenunciaStore : IDenunciaStore
{
private const int AttachmentChunkSizeBytes = 8 * 1024 * 1024;
private const int AttachmentChunkThresholdBytes = 32 * 1024 * 1024;
private static readonly byte[] ChunkedAttachmentPrefix = Encoding.ASCII.GetBytes("chunked:v1:");
private const string AttachmentChunksTableSql = """
CREATE TABLE IF NOT EXISTS complaint_attachment_chunks (
attachment_id BIGINT NOT NULL,
chunk_index INT NOT NULL,
content LONGBLOB NOT NULL,
created_at_utc DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
PRIMARY KEY (attachment_id, chunk_index),
CONSTRAINT fk_attachment_chunks_attachment
FOREIGN KEY (attachment_id) REFERENCES complaint_attachments(id)
ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
""";
private const string ComplaintSelectColumns = """
external_registry_id,
external_report_id,
report_date_utc,
gestiona_file_url,
gestiona_file_code,
gestiona_person_id,
tag,
status_name,
complaint_type,
reporter_kind,
is_legal_entity,
reporter_first_name,
reporter_first_surname,
reporter_second_surname,
reporter_last_name,
reporter_business_name,
reporter_gender,
reporter_document_id,
reporter_document_type,
reporter_origin_country,
subject,
accused_party,
accused_party_details,
complaint_description,
reported_to_institution,
reported_institution_details,
requested_protection,
requested_protection_details,
information_mode,
facts_location,
facts_date_utc,
forwarding_authorization,
forwarding_personal_data_preference,
notification_preference,
electronic_notification,
online_tracking_preference,
postal_notification_preference,
email,
sms_notification,
accepted_terms,
comments,
phone,
address_line,
address_road_type,
address_number,
address_floor,
address_door,
address_block,
address_stair,
address_extra,
municipality,
province,
postal_code,
country_code,
form_fields_json,
raw_report_text,
is_confidential,
is_update,
gestiona_procedure_id,
gestiona_group_id,
display_name,
workflow_status,
selected_document_name,
gestiona_uploaded_at_utc,
is_in_gestiona,
is_rejected,
key_date,
encryption_scheme,
encrypted_at_utc
""";
private const string AttachmentSelectColumns = """
a.id,
a.attachment_type_id,
a.description,
a.attachment_date_utc,
a.notes,
c.external_report_id,
a.original_file_name,
a.content,
a.content_sha256,
a.uploaded_to_gestiona,
a.uploaded_at_utc,
a.key_date,
a.encryption_scheme,
a.encrypted_at_utc
""";
private static readonly (string Table, string Column, string Definition)[] SchemaColumnsToEnsure =
[
("complaints", "gestiona_file_code", "`gestiona_file_code` VARCHAR(128) NOT NULL DEFAULT ''"),
@@ -32,12 +140,21 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
("complaints", "address_extra", "`address_extra` VARCHAR(256) NOT NULL DEFAULT ''"),
("complaints", "form_fields_json", "`form_fields_json` LONGTEXT NULL"),
("complaints", "raw_report_text", "`raw_report_text` LONGTEXT NULL"),
("complaints", "key_date", "`key_date` DATE NULL"),
("complaints", "encryption_scheme", "`encryption_scheme` VARCHAR(64) NOT NULL DEFAULT 'none'"),
("complaints", "encrypted_at_utc", "`encrypted_at_utc` DATETIME(6) NULL"),
("complaint_attachments", "content_sha256", "`content_sha256` CHAR(64) NOT NULL DEFAULT ''"),
("complaint_attachments", "key_date", "`key_date` DATE NULL"),
("complaint_attachments", "encryption_scheme", "`encryption_scheme` VARCHAR(64) NOT NULL DEFAULT 'none'"),
("complaint_attachments", "encrypted_at_utc", "`encrypted_at_utc` DATETIME(6) NULL"),
];
private static readonly (string Table, string IndexName, string Definition)[] SchemaIndexesToEnsure =
[
("complaint_attachments", "ix_attachments_sha256", "INDEX `ix_attachments_sha256` (`content_sha256`)"),
("complaints", "ix_complaints_key_date", "INDEX `ix_complaints_key_date` (`key_date`)"),
("complaints", "ix_complaints_flags", "INDEX `ix_complaints_flags` (`is_update`, `is_in_gestiona`, `is_rejected`)"),
("complaint_attachments", "ix_attachments_key_date", "INDEX `ix_attachments_key_date` (`key_date`)"),
];
private static readonly (string Table, string Definition)[] SchemaEncryptedColumnsToEnsure =
@@ -123,85 +240,79 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
}
public async Task<List<DenunciasGestiona>> GetAllDenunciasAsync(CancellationToken cancellationToken = default)
{
return await GetDenunciasAsync(null, DenunciaListScope.All, cancellationToken);
}
public async Task<List<DenunciasGestiona>> GetDenunciasByScopeAsync(
DenunciaListScope scope,
CancellationToken cancellationToken = default)
{
return await GetDenunciasAsync(null, scope, cancellationToken);
}
public async Task<List<DenunciasGestiona>> GetDenunciasByIdsAsync(
IReadOnlyCollection<int> denunciaIds,
CancellationToken cancellationToken = default)
{
var ids = NormalizeIds(denunciaIds);
if (ids.Count == 0)
{
return [];
}
return await GetDenunciasAsync(ids, DenunciaListScope.All, cancellationToken);
}
public async Task<List<DenunciasGestiona>> GetDenunciasByIdsAsync(
IReadOnlyCollection<int> denunciaIds,
DenunciaListScope scope,
CancellationToken cancellationToken = default)
{
var ids = NormalizeIds(denunciaIds);
if (ids.Count == 0)
{
return [];
}
return await GetDenunciasAsync(ids, scope, cancellationToken);
}
private async Task<List<DenunciasGestiona>> GetDenunciasAsync(
IReadOnlyList<int>? denunciaIds,
DenunciaListScope scope,
CancellationToken cancellationToken)
{
await EnsureSchemaReadyAsync(cancellationToken);
const string sql = """
await using var connection = await OpenConnectionAsync(cancellationToken);
await using var command = connection.CreateCommand();
var whereClauses = new List<string>();
if (denunciaIds is { Count: > 0 })
{
var parameterNames = AddIntParameters(command, "id", denunciaIds);
whereClauses.Add($"external_report_id IN ({string.Join(", ", parameterNames)})");
}
var scopeWhereClause = GetScopeWhereClause(scope);
if (!string.IsNullOrWhiteSpace(scopeWhereClause))
{
whereClauses.Add(scopeWhereClause);
}
var whereSql = whereClauses.Count == 0
? string.Empty
: $"WHERE {string.Join(" AND ", whereClauses)}";
command.CommandText = $"""
SELECT
external_registry_id,
external_report_id,
report_date_utc,
gestiona_file_url,
gestiona_file_code,
gestiona_person_id,
tag,
status_name,
complaint_type,
reporter_kind,
is_legal_entity,
reporter_first_name,
reporter_first_surname,
reporter_second_surname,
reporter_last_name,
reporter_business_name,
reporter_gender,
reporter_document_id,
reporter_document_type,
reporter_origin_country,
subject,
accused_party,
accused_party_details,
complaint_description,
reported_to_institution,
reported_institution_details,
requested_protection,
requested_protection_details,
information_mode,
facts_location,
facts_date_utc,
forwarding_authorization,
forwarding_personal_data_preference,
notification_preference,
electronic_notification,
online_tracking_preference,
postal_notification_preference,
email,
sms_notification,
accepted_terms,
comments,
phone,
address_line,
address_road_type,
address_number,
address_floor,
address_door,
address_block,
address_stair,
address_extra,
municipality,
province,
postal_code,
country_code,
form_fields_json,
raw_report_text,
is_confidential,
is_update,
gestiona_procedure_id,
gestiona_group_id,
display_name,
workflow_status,
selected_document_name,
gestiona_uploaded_at_utc,
is_in_gestiona,
is_rejected
{ComplaintSelectColumns}
FROM complaints
{whereSql}
ORDER BY COALESCE(gestiona_uploaded_at_utc, report_date_utc) DESC, external_report_id DESC;
""";
await using var connection = await OpenConnectionAsync(cancellationToken);
await using var command = new MySqlCommand(sql, connection);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
var result = new List<DenunciasGestiona>();
while (await reader.ReadAsync(cancellationToken))
{
@@ -215,19 +326,9 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
{
await EnsureSchemaReadyAsync(cancellationToken);
const string sql = """
var sql = $"""
SELECT
a.id,
a.attachment_type_id,
a.description,
a.attachment_date_utc,
a.notes,
c.external_report_id,
a.original_file_name,
a.content,
a.content_sha256,
a.uploaded_to_gestiona,
a.uploaded_at_utc
{AttachmentSelectColumns}
FROM complaint_attachments a
INNER JOIN complaints c ON c.id = a.complaint_id
ORDER BY c.external_report_id DESC, a.original_file_name ASC;
@@ -235,14 +336,56 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
await using var connection = await OpenConnectionAsync(cancellationToken);
await using var command = new MySqlCommand(sql, connection);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
var result = new List<FicherosDenuncias>();
while (await reader.ReadAsync(cancellationToken))
await using (var reader = await command.ExecuteReaderAsync(cancellationToken))
{
result.Add(MapAttachment(reader));
while (await reader.ReadAsync(cancellationToken))
{
result.Add(MapAttachment(reader));
}
}
await HydrateChunkedAttachmentsAsync(connection, result, cancellationToken);
return result;
}
public async Task<List<FicherosDenuncias>> GetFicherosByDenunciaIdsAsync(
IReadOnlyCollection<int> denunciaIds,
CancellationToken cancellationToken = default)
{
await EnsureSchemaReadyAsync(cancellationToken);
var ids = NormalizeIds(denunciaIds);
if (ids.Count == 0)
{
return [];
}
await using var connection = await OpenConnectionAsync(cancellationToken);
await using var command = connection.CreateCommand();
var parameterNames = AddIntParameters(command, "id", ids);
command.CommandText = $"""
SELECT
{AttachmentSelectColumns}
FROM complaint_attachments a
INNER JOIN complaints c ON c.id = a.complaint_id
WHERE c.external_report_id IN ({string.Join(", ", parameterNames)})
ORDER BY c.external_report_id DESC, a.original_file_name ASC;
""";
var result = new List<FicherosDenuncias>();
await using (var reader = await command.ExecuteReaderAsync(cancellationToken))
{
while (await reader.ReadAsync(cancellationToken))
{
result.Add(MapAttachment(reader));
}
}
await HydrateChunkedAttachmentsAsync(connection, result, cancellationToken);
return result;
}
@@ -252,19 +395,9 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
{
await EnsureSchemaReadyAsync(cancellationToken);
const string sql = """
var sql = $"""
SELECT
a.id,
a.attachment_type_id,
a.description,
a.attachment_date_utc,
a.notes,
c.external_report_id,
a.original_file_name,
a.content,
a.content_sha256,
a.uploaded_to_gestiona,
a.uploaded_at_utc
{AttachmentSelectColumns}
FROM complaint_attachments a
INNER JOIN complaints c ON c.id = a.complaint_id
WHERE c.external_report_id = @denunciaId
@@ -275,13 +408,17 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
await using var command = new MySqlCommand(sql, connection);
command.Parameters.AddWithValue("@denunciaId", denunciaId);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
var result = new List<FicherosDenuncias>();
while (await reader.ReadAsync(cancellationToken))
await using (var reader = await command.ExecuteReaderAsync(cancellationToken))
{
result.Add(MapAttachment(reader));
while (await reader.ReadAsync(cancellationToken))
{
result.Add(MapAttachment(reader));
}
}
await HydrateChunkedAttachmentsAsync(connection, result, cancellationToken);
return result;
}
@@ -291,74 +428,9 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
{
await EnsureSchemaReadyAsync(cancellationToken);
const string sql = """
var sql = $"""
SELECT
external_registry_id,
external_report_id,
report_date_utc,
gestiona_file_url,
gestiona_file_code,
gestiona_person_id,
tag,
status_name,
complaint_type,
reporter_kind,
is_legal_entity,
reporter_first_name,
reporter_first_surname,
reporter_second_surname,
reporter_last_name,
reporter_business_name,
reporter_gender,
reporter_document_id,
reporter_document_type,
reporter_origin_country,
subject,
accused_party,
accused_party_details,
complaint_description,
reported_to_institution,
reported_institution_details,
requested_protection,
requested_protection_details,
information_mode,
facts_location,
facts_date_utc,
forwarding_authorization,
forwarding_personal_data_preference,
notification_preference,
electronic_notification,
online_tracking_preference,
postal_notification_preference,
email,
sms_notification,
accepted_terms,
comments,
phone,
address_line,
address_road_type,
address_number,
address_floor,
address_door,
address_block,
address_stair,
address_extra,
municipality,
province,
postal_code,
country_code,
form_fields_json,
raw_report_text,
is_confidential,
is_update,
gestiona_procedure_id,
gestiona_group_id,
display_name,
workflow_status,
selected_document_name,
gestiona_uploaded_at_utc,
is_in_gestiona,
is_rejected
{ComplaintSelectColumns}
FROM complaints
WHERE external_report_id = @denunciaId
LIMIT 1;
@@ -445,7 +517,10 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
selected_document_name,
gestiona_uploaded_at_utc,
is_in_gestiona,
is_rejected
is_rejected,
key_date,
encryption_scheme,
encrypted_at_utc
) VALUES (
@externalRegistryId,
@externalReportId,
@@ -512,7 +587,10 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
@selectedDocumentName,
@gestionaUploadedAtUtc,
@isInGestiona,
@isRejected
@isRejected,
@keyDate,
@encryptionScheme,
@encryptedAtUtc
)
ON DUPLICATE KEY UPDATE
external_registry_id = VALUES(external_registry_id),
@@ -580,6 +658,9 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
gestiona_uploaded_at_utc = VALUES(gestiona_uploaded_at_utc),
is_in_gestiona = VALUES(is_in_gestiona),
is_rejected = VALUES(is_rejected),
key_date = VALUES(key_date),
encryption_scheme = VALUES(encryption_scheme),
encrypted_at_utc = VALUES(encrypted_at_utc),
updated_at_utc = UTC_TIMESTAMP(6);
""";
@@ -652,6 +733,9 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
command.Parameters.AddWithValue("@gestionaUploadedAtUtc", ToDbDate(denuncia.FechaSubidaAGestiona));
command.Parameters.AddWithValue("@isInGestiona", denuncia.EnGestiona);
command.Parameters.AddWithValue("@isRejected", denuncia.EnRechazada);
command.Parameters.AddWithValue("@keyDate", ToDbDate(denuncia.KeyDate));
command.Parameters.AddWithValue("@encryptionScheme", denuncia.EncryptionScheme ?? string.Empty);
command.Parameters.AddWithValue("@encryptedAtUtc", ToDbDate(denuncia.EncryptedAtUtc));
await command.ExecuteNonQueryAsync(cancellationToken);
}
@@ -680,7 +764,10 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
content_mime_type,
content_sha256,
uploaded_to_gestiona,
uploaded_at_utc
uploaded_at_utc,
key_date,
encryption_scheme,
encrypted_at_utc
) VALUES (
(
SELECT c.id
@@ -697,9 +784,13 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
@contentMimeType,
@contentSha256,
@uploadedToGestiona,
@uploadedAtUtc
@uploadedAtUtc,
@keyDate,
@encryptionScheme,
@encryptedAtUtc
)
ON DUPLICATE KEY UPDATE
id = LAST_INSERT_ID(id),
attachment_type_id = @attachmentTypeId,
description = @description,
attachment_date_utc = @attachmentDateUtc,
@@ -717,6 +808,9 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
WHEN complaint_attachments.content_sha256 = @contentSha256 THEN complaint_attachments.uploaded_at_utc
ELSE @uploadedAtUtc
END,
key_date = @keyDate,
encryption_scheme = @encryptionScheme,
encrypted_at_utc = @encryptedAtUtc,
updated_at_utc = CURRENT_TIMESTAMP(6);
""";
@@ -729,6 +823,10 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
{
await using var command = new MySqlCommand(sql, connection, (MySqlTransaction)transaction);
var content = fichero.Fichero ?? [];
var useChunks = content.Length > AttachmentChunkThresholdBytes;
var contentToStore = useChunks
? CreateChunkedAttachmentMarker(content.LongLength, GetChunkCount(content.Length))
: content;
var sha256 = string.IsNullOrWhiteSpace(fichero.ContentSha256)
? ComputeSha256Hex(content)
: fichero.ContentSha256.Trim().ToLowerInvariant();
@@ -738,25 +836,151 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
command.Parameters.AddWithValue("@attachmentDateUtc", ToDbDate(fichero.Fecha));
command.Parameters.AddWithValue("@notes", fichero.Observaciones ?? string.Empty);
command.Parameters.AddWithValue("@originalFileName", fichero.NombreFichero ?? string.Empty);
command.Parameters.AddWithValue("@content", content);
command.Parameters.Add("@content", MySqlDbType.LongBlob).Value = contentToStore;
command.Parameters.AddWithValue("@contentMimeType", DetectMimeType(fichero.NombreFichero));
command.Parameters.AddWithValue("@contentSha256", sha256);
command.Parameters.AddWithValue("@uploadedToGestiona", fichero.Subido);
command.Parameters.AddWithValue("@uploadedAtUtc", ToDbDate(fichero.FechaSubida));
command.Parameters.AddWithValue("@keyDate", ToDbDate(fichero.KeyDate));
command.Parameters.AddWithValue("@encryptionScheme", fichero.EncryptionScheme ?? string.Empty);
command.Parameters.AddWithValue("@encryptedAtUtc", ToDbDate(fichero.EncryptedAtUtc));
command.Parameters.AddWithValue("@externalReportId", fichero.Id_Denuncia);
await command.ExecuteNonQueryAsync(cancellationToken);
var attachmentId = command.LastInsertedId;
if (attachmentId <= 0)
{
attachmentId = await GetAttachmentIdAsync(
connection,
(MySqlTransaction)transaction,
fichero.Id_Denuncia,
fichero.NombreFichero ?? string.Empty,
cancellationToken);
}
if (useChunks)
{
await ReplaceAttachmentChunksAsync(
connection,
(MySqlTransaction)transaction,
attachmentId,
content,
cancellationToken);
}
else
{
await DeleteAttachmentChunksAsync(
connection,
(MySqlTransaction)transaction,
attachmentId,
cancellationToken);
}
}
await transaction.CommitAsync(cancellationToken);
}
catch
{
await transaction.RollbackAsync(cancellationToken);
try
{
await transaction.RollbackAsync(cancellationToken);
}
catch (Exception rollbackException)
{
_logger.LogWarning(
rollbackException,
"No se ha podido deshacer la transaccion de adjuntos porque la conexion ya no estaba disponible.");
}
throw;
}
}
private static async Task<long> GetAttachmentIdAsync(
MySqlConnection connection,
MySqlTransaction transaction,
int externalReportId,
string originalFileName,
CancellationToken cancellationToken)
{
const string sql = """
SELECT a.id
FROM complaint_attachments a
INNER JOIN complaints c ON c.id = a.complaint_id
WHERE c.external_report_id = @externalReportId
AND a.original_file_name = @originalFileName
LIMIT 1;
""";
await using var command = new MySqlCommand(sql, connection, transaction);
command.Parameters.AddWithValue("@externalReportId", externalReportId);
command.Parameters.AddWithValue("@originalFileName", originalFileName);
var result = await command.ExecuteScalarAsync(cancellationToken);
if (result is null || result is DBNull)
{
throw new InvalidOperationException(
$"No se ha encontrado el adjunto '{originalFileName}' de la denuncia #{externalReportId} tras guardarlo.");
}
return Convert.ToInt64(result, CultureInfo.InvariantCulture);
}
private static async Task ReplaceAttachmentChunksAsync(
MySqlConnection connection,
MySqlTransaction transaction,
long attachmentId,
byte[] content,
CancellationToken cancellationToken)
{
await DeleteAttachmentChunksAsync(connection, transaction, attachmentId, cancellationToken);
const string sql = """
INSERT INTO complaint_attachment_chunks (
attachment_id,
chunk_index,
content
) VALUES (
@attachmentId,
@chunkIndex,
@content
);
""";
var chunkIndex = 0;
for (var offset = 0; offset < content.Length; offset += AttachmentChunkSizeBytes)
{
var length = Math.Min(AttachmentChunkSizeBytes, content.Length - offset);
var chunk = new byte[length];
Buffer.BlockCopy(content, offset, chunk, 0, length);
await using var command = new MySqlCommand(sql, connection, transaction);
command.Parameters.AddWithValue("@attachmentId", attachmentId);
command.Parameters.AddWithValue("@chunkIndex", chunkIndex);
command.Parameters.Add("@content", MySqlDbType.LongBlob).Value = chunk;
await command.ExecuteNonQueryAsync(cancellationToken);
chunkIndex++;
}
}
private static async Task DeleteAttachmentChunksAsync(
MySqlConnection connection,
MySqlTransaction transaction,
long attachmentId,
CancellationToken cancellationToken)
{
const string sql = """
DELETE FROM complaint_attachment_chunks
WHERE attachment_id = @attachmentId;
""";
await using var command = new MySqlCommand(sql, connection, transaction);
command.Parameters.AddWithValue("@attachmentId", attachmentId);
await command.ExecuteNonQueryAsync(cancellationToken);
}
public async Task MarkFicherosAsUploadedAsync(
int denunciaId,
IEnumerable<string> fileNames,
@@ -802,6 +1026,39 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
await command.ExecuteNonQueryAsync(cancellationToken);
}
private static List<int> NormalizeIds(IEnumerable<int> denunciaIds)
{
return denunciaIds
.Where(id => id > 0)
.Distinct()
.ToList();
}
private static string GetScopeWhereClause(DenunciaListScope scope)
{
return scope switch
{
DenunciaListScope.Pending => "is_in_gestiona = 0 AND is_rejected = 0 AND is_update = 0",
DenunciaListScope.Updates => "is_update = 1",
DenunciaListScope.InGestiona => "is_in_gestiona = 1",
DenunciaListScope.Rejected => "is_rejected = 1",
_ => string.Empty
};
}
private static List<string> AddIntParameters(MySqlCommand command, string prefix, IReadOnlyList<int> ids)
{
var parameterNames = new List<string>(ids.Count);
for (var i = 0; i < ids.Count; i++)
{
var parameterName = $"@{prefix}{i}";
command.Parameters.AddWithValue(parameterName, ids[i]);
parameterNames.Add(parameterName);
}
return parameterNames;
}
private async Task EnsureSchemaReadyAsync(CancellationToken cancellationToken)
{
if (SchemaEnsured)
@@ -867,6 +1124,8 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
MySqlConnection connection,
CancellationToken cancellationToken)
{
await EnsureAttachmentChunksTableAsync(connection, cancellationToken);
foreach (var (table, column, definition) in SchemaColumnsToEnsure)
{
if (!await ColumnExistsAsync(connection, table, column, cancellationToken))
@@ -899,6 +1158,15 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
}
}
private static async Task EnsureAttachmentChunksTableAsync(
MySqlConnection connection,
CancellationToken cancellationToken)
{
await using var command = connection.CreateCommand();
command.CommandText = AttachmentChunksTableSql;
await command.ExecuteNonQueryAsync(cancellationToken);
}
private static string ExtractColumnName(string definition)
{
var first = definition.IndexOf('`');
@@ -1008,6 +1276,124 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
return Path.Combine(AppContext.BaseDirectory, "Scripts", "gestiondenuncias_schema.sql");
}
private static async Task HydrateChunkedAttachmentsAsync(
MySqlConnection connection,
IEnumerable<FicherosDenuncias> attachments,
CancellationToken cancellationToken)
{
foreach (var attachment in attachments)
{
var content = attachment.Fichero;
if (content is null ||
!TryParseChunkedAttachmentMarker(content, out var totalLength, out var chunkCount))
{
continue;
}
attachment.Fichero = await LoadAttachmentChunksAsync(
connection,
attachment.Id_Fichero,
totalLength,
chunkCount,
cancellationToken);
}
}
private static async Task<byte[]> LoadAttachmentChunksAsync(
MySqlConnection connection,
long attachmentId,
long totalLength,
int expectedChunkCount,
CancellationToken cancellationToken)
{
if (totalLength < 0 || totalLength > int.MaxValue)
{
throw new InvalidOperationException(
$"El adjunto {attachmentId} no puede reconstruirse en memoria. Tamano declarado: {totalLength} bytes.");
}
if (expectedChunkCount <= 0)
{
throw new InvalidOperationException(
$"El adjunto {attachmentId} tiene un marcador de chunks invalido.");
}
const string sql = """
SELECT chunk_index, content
FROM complaint_attachment_chunks
WHERE attachment_id = @attachmentId
ORDER BY chunk_index ASC;
""";
var result = new byte[(int)totalLength];
var offset = 0;
var chunksRead = 0;
await using var command = new MySqlCommand(sql, connection);
command.Parameters.AddWithValue("@attachmentId", attachmentId);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
var chunkIndex = GetInt32(reader, "chunk_index");
if (chunkIndex != chunksRead)
{
throw new InvalidOperationException(
$"El adjunto {attachmentId} tiene chunks incompletos o desordenados.");
}
var chunk = GetBytes(reader, "content");
if (offset + chunk.Length > result.Length)
{
throw new InvalidOperationException(
$"El adjunto {attachmentId} tiene mas datos de chunks que los declarados.");
}
Buffer.BlockCopy(chunk, 0, result, offset, chunk.Length);
offset += chunk.Length;
chunksRead++;
}
if (chunksRead != expectedChunkCount || offset != result.Length)
{
throw new InvalidOperationException(
$"El adjunto {attachmentId} no tiene todos los chunks esperados.");
}
return result;
}
private static byte[] CreateChunkedAttachmentMarker(long totalLength, int chunkCount)
{
return Encoding.ASCII.GetBytes(
$"chunked:v1:{totalLength.ToString(CultureInfo.InvariantCulture)}:{chunkCount.ToString(CultureInfo.InvariantCulture)}");
}
private static bool TryParseChunkedAttachmentMarker(
byte[] content,
out long totalLength,
out int chunkCount)
{
totalLength = 0;
chunkCount = 0;
if (!content.AsSpan().StartsWith(ChunkedAttachmentPrefix))
{
return false;
}
var marker = Encoding.ASCII.GetString(content);
var parts = marker.Split(':');
return parts.Length == 4 &&
long.TryParse(parts[2], NumberStyles.None, CultureInfo.InvariantCulture, out totalLength) &&
int.TryParse(parts[3], NumberStyles.None, CultureInfo.InvariantCulture, out chunkCount);
}
private static int GetChunkCount(int length)
{
return (int)(((long)length + AttachmentChunkSizeBytes - 1) / AttachmentChunkSizeBytes);
}
private static DenunciasGestiona MapComplaint(IDataRecord record)
{
return new DenunciasGestiona
@@ -1078,6 +1464,9 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
FechaSubidaAGestiona = GetDateTime(record, "gestiona_uploaded_at_utc"),
EnGestiona = GetBoolean(record, "is_in_gestiona"),
EnRechazada = GetBoolean(record, "is_rejected"),
KeyDate = GetNullableDateOnly(record, "key_date"),
EncryptionScheme = GetString(record, "encryption_scheme"),
EncryptedAtUtc = GetNullableDateTime(record, "encrypted_at_utc"),
};
}
@@ -1096,6 +1485,9 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
ContentSha256 = GetString(record, "content_sha256"),
Subido = GetBoolean(record, "uploaded_to_gestiona"),
FechaSubida = GetNullableDateTime(record, "uploaded_at_utc"),
KeyDate = GetNullableDateOnly(record, "key_date"),
EncryptionScheme = GetString(record, "encryption_scheme"),
EncryptedAtUtc = GetNullableDateTime(record, "encrypted_at_utc"),
};
}
@@ -1109,6 +1501,11 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
return value is null || value == DateTime.MinValue ? DBNull.Value : value.Value;
}
private static object ToDbDate(DateOnly? value)
{
return value is null ? DBNull.Value : value.Value.ToDateTime(TimeOnly.MinValue);
}
private static object ToDbGuid(Guid value)
{
return value == Guid.Empty ? DBNull.Value : value.ToString();
@@ -1160,6 +1557,12 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
return record.IsDBNull(ordinal) ? null : record.GetDateTime(ordinal);
}
private static DateOnly? GetNullableDateOnly(IDataRecord record, string columnName)
{
var ordinal = record.GetOrdinal(columnName);
return record.IsDBNull(ordinal) ? null : DateOnly.FromDateTime(record.GetDateTime(ordinal));
}
private static Guid GetGuid(IDataRecord record, string columnName)
{
var ordinal = record.GetOrdinal(columnName);

View File

@@ -0,0 +1,73 @@
using System.Collections.Concurrent;
using System.Security.Cryptography;
namespace ApiDenuncias.Services;
public sealed record PendingGlobalLeaksLogin(
string Id,
string Username,
string Password,
string FinalPassword,
string TokenAnswer,
DateTimeOffset ExpiresAtUtc);
public sealed class PendingGlobalLeaksLoginStore
{
private static readonly TimeSpan Lifetime = TimeSpan.FromMinutes(5);
private readonly ConcurrentDictionary<string, PendingGlobalLeaksLogin> _items = new(StringComparer.Ordinal);
public PendingGlobalLeaksLogin Create(string username, string password, string finalPassword, string tokenAnswer)
{
CleanupExpired();
var id = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)).ToLowerInvariant();
var pending = new PendingGlobalLeaksLogin(
id,
username,
password,
finalPassword,
tokenAnswer,
DateTimeOffset.UtcNow.Add(Lifetime));
_items[id] = pending;
return pending;
}
public PendingGlobalLeaksLogin Get(string id)
{
CleanupExpired();
if (string.IsNullOrWhiteSpace(id) || !_items.TryGetValue(id, out var pending))
{
throw new InvalidOperationException("La preparacion del login ha caducado. Vuelve a introducir usuario y contrasena.");
}
if (pending.ExpiresAtUtc <= DateTimeOffset.UtcNow)
{
_items.TryRemove(id, out _);
throw new InvalidOperationException("La preparacion del login ha caducado. Vuelve a introducir usuario y contrasena.");
}
return pending;
}
public void Remove(string id)
{
if (!string.IsNullOrWhiteSpace(id))
{
_items.TryRemove(id, out _);
}
}
private void CleanupExpired()
{
var now = DateTimeOffset.UtcNow;
foreach (var item in _items)
{
if (item.Value.ExpiresAtUtc <= now)
{
_items.TryRemove(item.Key, out _);
}
}
}
}

View File

@@ -1,4 +1,4 @@
{
{
"Logging": {
"LogLevel": {
"Default": "Information",
@@ -21,18 +21,27 @@
"EncryptionKeySecretName": "denuncias-encryption-key",
"AllowLocalEncryptionKeyFallback": true
},
"ManualPurge": {
"BaseUrl": "https://func-keymgmt-pre.azurewebsites.net/api",
"FunctionUrl": "https://func-keymgmt-pre.azurewebsites.net/api/manual_purge",
"ForceRotateUrl": "https://func-keymgmt-pre.azurewebsites.net/api/force_rotate",
"FunctionKeySecretName": "purge-function-key",
"ReplaceOnManualPurge": true,
"RecoverPartialReplaceFailure": true,
"TimeoutSeconds": 30
},
"Encryption": {
"LocalDevelopmentKey": "presentacion-pre-denuncias-encryption-key-cambiar-antes-de-produccion"
},
"Gestiona": {
"ApiBase": "https://02.g3stiona.com",
"AccessToken": "_yr.xVvPOllsyd1TYZRxUxg__c",
"ExternalProcedureId": "82722c9b-cecc-4299-8a7b-ce5abeb8170b",
"CircuitTemplateId": "bb997758-7436-46ab-9dc3-50dce2e02cfa",
"CircuitSignerStampHref": "https://02.g3stiona.com/rest/organ-stamps/3c6eaab4-7fcd-4b21-8676-bf8719be5d36",
"CircuitSignerStampTitle": "oaaf-complaints-tramit",
"CircuitRecipientGroupHref": "https://02.g3stiona.com/rest/groups/454fa4ec-8b82-4240-9419-113f45d4b004",
"CircuitVersion": "2",
"PreferredCircuitTemplateName": "CT-Actualización de denuncia",
"UserLink": "https://02.g3stiona.com/rest/users/0c168833-8e27-4695-a301-b79924031f63",
"GroupLink": "https://02.g3stiona.com/rest/groups/6dbfc433-1eb6-4b9a-a533-bfebc652c101",
"Location": "2.02.01"

View File

@@ -190,8 +190,8 @@
@code {
[Parameter]
public string? cl { get; set; } = "";
private static bdAntifraude.db.CONCEPTOSGENERALES Concepto = new CONCEPTOSGENERALES();
private EditContext editContext = new EditContext(Concepto);
private bdAntifraude.db.CONCEPTOSGENERALES Concepto = new CONCEPTOSGENERALES();
private EditContext editContext = default!;
private HttpClient cliente = new HttpClient();
private ValidationMessageStore? messageStore;
@@ -199,11 +199,18 @@
List<ToastMessage> mensajes = new List<ToastMessage>();
public bool mostrar { get; set; } = true;
public bool mostrarBtn { get; set; } = false;
List<FieldIdentifier> listaIdentificadores = new List<FieldIdentifier>();
private void EstablecerEditContext(CONCEPTOSGENERALES nuevo)
{
Concepto = nuevo;
editContext = new EditContext(Concepto);
messageStore = new ValidationMessageStore(editContext);
listaIdentificadores.Clear();
}
protected override async Task OnInitializedAsync()
{
EstablecerEditContext(new CONCEPTOSGENERALES());
try
{
var url = NavigationManager.ToAbsoluteUri(NavigationManager.Uri);
@@ -216,6 +223,7 @@
if (string.IsNullOrEmpty(cl))
{
EstablecerEditContext(new CONCEPTOSGENERALES());
Concepto = new CONCEPTOSGENERALES();
mostrarBtn = true;
}
@@ -232,6 +240,7 @@
}
var resultContent = await response.Content.ReadAsStringAsync();
Concepto = JsonConvert.DeserializeObject<CONCEPTOSGENERALES>(resultContent) ?? throw new Exception("Error al deserializar los datos de la persona.");
EstablecerEditContext(Concepto);
}
editContext = new EditContext(Concepto);
messageStore = new ValidationMessageStore(editContext);

View File

@@ -204,8 +204,8 @@
@code {
[Parameter]
public string? cl { get; set; } = "";
private static bdAntifraude.db.CONTRATOS iContrato = new CONTRATOS();
private EditContext editContext = new EditContext(iContrato);
private bdAntifraude.db.CONTRATOS iContrato = new CONTRATOS();
private EditContext editContext = default!;
private HttpClient cliente = new HttpClient();
private ValidationMessageStore? messageStore;
@@ -213,11 +213,19 @@
List<ToastMessage> mensajes = new List<ToastMessage>();
public bool mostrar { get; set; } = true;
public bool mostrarBtn { get; set; } = false;
List<FieldIdentifier> listaIdentificadores = new List<FieldIdentifier>();
private void EstablecerEditContext(CONTRATOS nuevo)
{
iContrato = nuevo;
editContext = new EditContext(iContrato);
messageStore = new ValidationMessageStore(editContext);
listaIdentificadores.Clear();
}
protected override async Task OnInitializedAsync()
{
EstablecerEditContext(new CONTRATOS());
try
{
var url = NavigationManager.ToAbsoluteUri(NavigationManager.Uri);
@@ -230,7 +238,9 @@
if (string.IsNullOrEmpty(cl))
{
iContrato = new CONTRATOS();
// iContrato = new CONTRATOS();
EstablecerEditContext(new CONTRATOS());
mostrarBtn = true;
}
else
@@ -244,7 +254,8 @@
throw new Exception($"Error al obtener los datos del Contrato. Código: {response.StatusCode}");
}
var resultContent = await response.Content.ReadAsStringAsync();
iContrato = JsonConvert.DeserializeObject<CONTRATOS>(resultContent) ?? throw new Exception("Error al deserializar los datos de la persona.");
var iContrat = JsonConvert.DeserializeObject<CONTRATOS>(resultContent) ?? throw new Exception("Error al deserializar los datos de la persona.");
EstablecerEditContext(iContrat);
}
editContext = new EditContext(iContrato);
messageStore = new ValidationMessageStore(editContext);

View File

@@ -193,8 +193,8 @@
@code {
[Parameter]
public string? cl { get; set; } = "";
private static bdAntifraude.db.CUENTASCOTIZACIONPATRONAL iCuentaContizacion = new CUENTASCOTIZACIONPATRONAL();
private EditContext editContext = new EditContext(iCuentaContizacion);
private bdAntifraude.db.CUENTASCOTIZACIONPATRONAL iCuentaContizacion = new CUENTASCOTIZACIONPATRONAL();
private EditContext editContext = default!;
private HttpClient cliente = new HttpClient();
private ValidationMessageStore? messageStore;
@@ -202,13 +202,20 @@
List<ToastMessage> mensajes = new List<ToastMessage>();
public bool mostrar { get; set; } = true;
public bool mostrarBtn { get; set; } = false;
List<FieldIdentifier> listaIdentificadores = new List<FieldIdentifier>();
private List<ENUMERACIONES> lTipoIPF = new List<ENUMERACIONES>();
private void EstablecerEditContext(CUENTASCOTIZACIONPATRONAL nuevo)
{
iCuentaContizacion = nuevo;
editContext = new EditContext(iCuentaContizacion);
messageStore = new ValidationMessageStore(editContext);
listaIdentificadores.Clear();
}
protected override async Task OnInitializedAsync()
{
EstablecerEditContext(new CUENTASCOTIZACIONPATRONAL());
try
{
var url = NavigationManager.ToAbsoluteUri(NavigationManager.Uri);
@@ -222,6 +229,7 @@
if (string.IsNullOrEmpty(cl))
{
iCuentaContizacion = new CUENTASCOTIZACIONPATRONAL();
EstablecerEditContext(new CUENTASCOTIZACIONPATRONAL());
mostrarBtn = true;
}
else
@@ -235,7 +243,8 @@
throw new Exception($"Error al obtener los datos. Código: {response.StatusCode}");
}
var resultContent = await response.Content.ReadAsStringAsync();
iCuentaContizacion = JsonConvert.DeserializeObject<CUENTASCOTIZACIONPATRONAL>(resultContent) ?? throw new Exception("Error al deserializar los datos de la persona.");
var iCuentContizacion = JsonConvert.DeserializeObject<CUENTASCOTIZACIONPATRONAL>(resultContent) ?? throw new Exception("Error al deserializar los datos de la persona.");
EstablecerEditContext(iCuentContizacion);
}
editContext = new EditContext(iCuentaContizacion);
messageStore = new ValidationMessageStore(editContext);

View File

@@ -111,10 +111,10 @@
@code {
[Parameter]
public string? cl { get; set; } = "";
private static bdAntifraude.db.TIPOSPUESTOSTRABAJO puestoTrabajo = new TIPOSPUESTOSTRABAJO();
private bdAntifraude.db.TIPOSPUESTOSTRABAJO puestoTrabajo = new TIPOSPUESTOSTRABAJO();
private List<ENUMERACIONES> lGrupos = new List<ENUMERACIONES>();
private List<ENUMERACIONES> lTipoPersonal = new List<ENUMERACIONES>();
private EditContext editContext = new EditContext(puestoTrabajo);
private EditContext editContext = default!;
private Tabs tabsPuesTrab { get; set; } = new Tabs();
private HttpClient cliente = new HttpClient();
@@ -123,11 +123,19 @@
List<ToastMessage> mensajes = new List<ToastMessage>();
public bool mostrar { get; set; } = true;
public bool mostrarBtn { get; set; } = false;
List<FieldIdentifier> listaIdentificadores = new List<FieldIdentifier>();
private void EstablecerEditContext(TIPOSPUESTOSTRABAJO nuevo)
{
puestoTrabajo = nuevo;
editContext = new EditContext(puestoTrabajo);
messageStore = new ValidationMessageStore(editContext);
listaIdentificadores.Clear();
}
protected override async Task OnInitializedAsync()
{
EstablecerEditContext(new TIPOSPUESTOSTRABAJO());
try
{
var url = NavigationManager.ToAbsoluteUri(NavigationManager.Uri);
@@ -139,8 +147,9 @@
}
if (string.IsNullOrEmpty(cl))
{
{
puestoTrabajo = new TIPOSPUESTOSTRABAJO();
EstablecerEditContext(new TIPOSPUESTOSTRABAJO());
mostrarBtn = true;
}
else
@@ -156,7 +165,8 @@
}
var resultContent = await response.Content.ReadAsStringAsync();
puestoTrabajo = JsonConvert.DeserializeObject<TIPOSPUESTOSTRABAJO>(resultContent) ?? throw new Exception("Error al deserializar los datos de la persona.");
var puestTrabajo = JsonConvert.DeserializeObject<TIPOSPUESTOSTRABAJO>(resultContent) ?? throw new Exception("Error al deserializar los datos de la persona.");
EstablecerEditContext(puestTrabajo);
}
editContext = new EditContext(puestoTrabajo);
messageStore = new ValidationMessageStore(editContext);

View File

@@ -262,8 +262,8 @@
@code {
[Parameter]
public string? cl { get; set; } = "";
private static bdAntifraude.db.VALORESNOMINA oValorNomina = new VALORESNOMINA();
private EditContext editContext = new EditContext(oValorNomina);
private bdAntifraude.db.VALORESNOMINA oValorNomina = new VALORESNOMINA();
private EditContext editContext = default!;
private HttpClient cliente = new HttpClient();
private ValidationMessageStore? messageStore;
@@ -272,10 +272,18 @@
public bool mostrar { get; set; } = true;
public bool mostrarBtn { get; set; } = false;
List<FieldIdentifier> listaIdentificadores = new List<FieldIdentifier>();
private void EstablecerEditContext(VALORESNOMINA nuevo)
{
oValorNomina = nuevo;
editContext = new EditContext(oValorNomina);
messageStore = new ValidationMessageStore(editContext);
listaIdentificadores.Clear();
}
protected override async Task OnInitializedAsync()
{
EstablecerEditContext(new VALORESNOMINA());
try
{
var url = NavigationManager.ToAbsoluteUri(NavigationManager.Uri);
@@ -288,7 +296,8 @@
if (string.IsNullOrEmpty(cl))
{
oValorNomina = new VALORESNOMINA();
// oValorNomina = new VALORESNOMINA();
EstablecerEditContext(new VALORESNOMINA());
mostrarBtn = true;
}
else
@@ -303,7 +312,8 @@
throw new Exception($"Error al obtener los datos del Tipo de Trabajo. Código: {response.StatusCode}");
}
var resultContent = await response.Content.ReadAsStringAsync();
oValorNomina = JsonConvert.DeserializeObject<VALORESNOMINA>(resultContent) ?? throw new Exception("Error al deserializar los datos.");
var ValorNomina = JsonConvert.DeserializeObject<VALORESNOMINA>(resultContent) ?? throw new Exception("Error al deserializar los datos.");
EstablecerEditContext(ValorNomina);
}
editContext = new EditContext(oValorNomina);
messageStore = new ValidationMessageStore(editContext);

View File

@@ -233,7 +233,7 @@
public string idNom { get; set; } = "";
private Tabs tabsDtsNom { get; set; } = new Tabs();
List<ToastMessage> messages = new List<ToastMessage>();
private static NOMINAS Nomina { get; set; } = new NOMINAS();
private NOMINAS Nomina { get; set; } = new NOMINAS();
private List<ENUMERACIONES> listadoTipo = new List<ENUMERACIONES>();
private List<ENUMERACIONES> listadoSituacion = new List<ENUMERACIONES>();
private string tituloPopup = "";

View File

@@ -332,7 +332,7 @@
</Tab>
<Tab Title="Diferencia Pago Delegado" Name="tabDiferencia">
<Content>
<MaternidadesGrid Persona="persona"></MaternidadesGrid>
<DiferenciaGrid Persona="persona"></DiferenciaGrid>
</Content>
</Tab>
<Tab Title="Permisos sin Retribución" Name="tabPermSinRet">
@@ -833,7 +833,7 @@
}
cliente = Utilidades.ObtenerCliente(UserState.Token, HttpClientFactory);
// ListaSexos = await Utilidades.ObtenerObjeto<List<ENUMERACIONES>>(cliente, "/api/ENUMERACIONES/EnumeracionesGrupo/SEXO");
ListaSexos = await Utilidades.ObtenerObjeto<List<ENUMERACIONES>>(cliente, "/api/ENUMERACIONES/EnumeracionesGrupo/SEXO");
string nifDesencriptado = tsUtilidades.crypt.FEncS(cl,
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890.:/-*",
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890.:/-*",
@@ -907,7 +907,6 @@
Console.WriteLine($"Error al cargar la foto: {ex.Message}");
}
}
private string GetCssClass(string fieldName)
{
if (messageStore == null)
@@ -978,3 +977,10 @@
}
<script>
function obtenerCheck(id){
return $("#"+id)[0].checked
}
</script>

View File

@@ -15,6 +15,7 @@
@inject UserState UserState
<div class="tablaTabLateral">
@* <input type="button" value="Nueva maternidad/riesgo de embarazo" @onclick="@(() => abrirPopupModificacion(new MATERNIDADES(), true))" class="mb-2 btnOAAFBlack" /> *@
<div style="overflow-x:auto;" class="tablaDesk">
<Grid TItem="DIFERENCIAPAGODELEGADO"
@@ -49,6 +50,56 @@
</div>
</div>
@* <Modal @ref="popupGestionDatos" IsVerticallyCentered="true" UseStaticBackdrop="true" CloseOnEscape="false">
<BodyTemplate>
<div class="row">
<div class="col-md-6 mb-2">
<label class="fw-bold">Fecha inicio</label>
<input class="form-control" type="date" @bind-value="ItemEnEdicion.FECHAINICIO" />
</div>
<div class="col-md-6 mb-2">
<label class="fw-bold">Fecha fin</label>
<input class="form-control" type="date" @bind-value="ItemEnEdicion.FECHAFIN" />
</div>
<div class="col-md-12 mb-2">
<label for="txtEDesc" class="fw-bold">Base cotización seguridad social: </label>
<input class="form-control" type="number" @bind-value="@ItemEnEdicion.BASECOTIZACIONSEGURIDADSOCIAL" />
</div>
<div class="col-md-12 mb-2">
<label for="txtEDesc" class="fw-bold">Porcentaje reducción jornada: </label>
<input class="form-control" type="number" @bind-value="@ItemEnEdicion.PORCENTAJEREDUCCIONJORNADA" />
</div>
<div class="col-md-12" style="display:flex; justify-content:space-between">
<label for="txtEDesc" class="fw-bold">Riesgo embarazo: </label>
<input class="" type="checkbox" id="chbRiesgoEmbarazo" checked="@ItemEnEdicion.RIESGOEMBARAZO" />
<label for="txtEDesc" class="fw-bold">Nomina normal: </label>
<input class="" type="checkbox" id="chbNominaNormal" checked="@ItemEnEdicion.NOMINANORMAL" />
<label for="txtEDesc" class="fw-bold">Nomina seguridad social: </label>
<input class="" type="checkbox" id="chbNominaSS" checked="@ItemEnEdicion.NOMINASEGURIDADSOCIAL" />
</div>
</div>
</BodyTemplate>
<FooterTemplate>
<Button Color="ButtonColor.Secondary" @onclick="cerrarPopupModificacion">Cerrar</Button>
@if (ItemEnEdicion.IDMATERNIDADES != 0)
{
<Button Type="ButtonType.Submit" Color="ButtonColor.Primary" @onclick="@(() => GestionarDatos(false))">Modificar</Button>
}
else
{
<Button Type="ButtonType.Submit" Color="ButtonColor.Primary" @onclick="@(() => GestionarDatos(true))">Crear</Button>
}
</FooterTemplate>
</Modal>
*@
@code {
[Parameter]
public PERSONAS Persona { get; set; } = new PERSONAS();
@@ -57,8 +108,17 @@
public EventCallback OnPersonaActualizada { get; set; }
// private List<int?> meses = new List<int?>();
private List<DIFERENCIAPAGODELEGADO> itmList = new List<DIFERENCIAPAGODELEGADO>();
protected override async Task OnInitializedAsync()
{
cliente = Utilidades.ObtenerCliente(UserState.Token, HttpClientFactory);
await CargarListas();
}
private async Task CargarListas()
{
itmList.Clear();
try
{
var listnom = Persona.DIFERENCIAPAGODELEGADO;
@@ -69,4 +129,5 @@
Console.WriteLine($"Error al obtener los datos: {e.Message}");
}
}
}

View File

@@ -15,7 +15,7 @@
@inject UserState UserState
<div class="tablaTabLateral">
<input type="button" value="Nueva Enfermedad" @onclick="@(() => abrirPopupModificacion(new ENFERMEDADES(), true))" class="mb-2 btnOAAFBlack" />
<div style="overflow-x:auto;" class="tablaDesk">
<Grid TItem="ENFERMEDADES"
Class="table tablaRegPers"
@@ -32,6 +32,9 @@
PaginationItemsTextFormat="{0} - {1} de {2} elementos">
<GridColumns>
<GridColumn TItem="ENFERMEDADES" HeaderText="">
<button @onclick="@(() => abrirPopupModificacion(context, false))" class="btnOAAFAzul">Editar</button>
</GridColumn>
<GridColumn TItem="ENFERMEDADES" HeaderText="Fecha Inicio">
@context.FECHAINICIO?.ToString("dd/MM/yyyy")
</GridColumn>
@@ -42,7 +45,7 @@
@context.BASE
</GridColumn>
<GridColumn TItem="ENFERMEDADES" HeaderText="Tipo">
@context.IDTIPONavigation.DESCRIPCION
@if(@context.IDTIPONavigation != null){@context.IDTIPONavigation.DESCRIPCION}
</GridColumn>
<GridColumn TItem="ENFERMEDADES" HeaderText="Continuidad">
@if (context.CONTINUIDAD)
@@ -66,17 +69,109 @@
</Grid>
</div>
</div>
<Modal @ref="popupGestionDatos" IsVerticallyCentered="true" UseStaticBackdrop="true" CloseOnEscape="false">
<BodyTemplate>
<div class="row">
<div class="col-md-6 mb-2">
<label class="fw-bold">Fecha inicio</label>
<input class="form-control" type="date" @bind-value="ItemEnEdicion.FECHAINICIO" />
</div>
<div class="col-md-6 mb-2">
<label class="fw-bold">Fecha fin</label>
<input class="form-control" type="date" @bind-value="ItemEnEdicion.FECHAFIN" />
</div>
<div class="col-md-12 mb-2">
<label for="txtEDesc" class="fw-bold">Base: </label>
<input class="form-control" type="number" @bind-value="@ItemEnEdicion.BASE" />
</div>
<div class="col-md-12 mb-2">
<input list="listTip" id="selTip" @bind-value="@itmTipo" type="text" style="width:100%" class="form-control" placeholder="Tipo" />
<datalist id="listTip">
@foreach (ENUMERACIONES con in lTipo)
{
<option data-value="@con.IDENUMERACION">@con.DESCRIPCION</option>
}
</datalist>
</div>
<div class="col-md-12" style="display:flex; justify-content:space-between">
<label for="txtEDesc" class="fw-bold">Continuidad: </label>
<input class="" type="checkbox" id="chbCont" checked="@ItemEnEdicion.CONTINUIDAD" />
<label for="txtEDesc" class="fw-bold">Nomina normal: </label>
<input class="" type="checkbox" id="chbNominaNormal" checked="@ItemEnEdicion.NOMINANORMAL" />
<label for="txtEDesc" class="fw-bold">Nomina seguridad social: </label>
<input class="" type="checkbox" id="chbNominaSS" checked="@ItemEnEdicion.NOMINASEGURIDADSOCIAL" />
</div>
</div>
</BodyTemplate>
<FooterTemplate>
<Button Color="ButtonColor.Secondary" @onclick="cerrarPopupModificacion">Cerrar</Button>
@if (ItemEnEdicion.IDENFERMEDADES != 0)
{
<Button Type="ButtonType.Submit" Color="ButtonColor.Primary" @onclick="@(() => GestionarDatos(false))">Modificar</Button>
}
else
{
<Button Type="ButtonType.Submit" Color="ButtonColor.Primary" @onclick="@(() => GestionarDatos(true))">Crear</Button>
}
</FooterTemplate>
</Modal>
@code {
[Parameter]
public PERSONAS Persona { get; set; } = new PERSONAS();
private HttpClient cliente = new HttpClient();
private Modal popupGestionDatos = default;
[Parameter]
public EventCallback OnPersonaActualizada { get; set; }
private string itmTipo { get; set; }
private List<ENFERMEDADES> itmList = new List<ENFERMEDADES>();
private List<ENUMERACIONES> lTipo = new List<ENUMERACIONES>();
private ENFERMEDADES ItemEnEdicion { get; set; } = new ENFERMEDADES();
//TIPENF
//
private async Task abrirPopupModificacion(ENFERMEDADES objeto, bool esNuevo)
{
ItemEnEdicion = objeto;
if (objeto.IDENFERMEDADES != 0)
{
itmTipo = objeto.IDTIPONavigation.DESCRIPCION;
// itmNomina = objeto.IDNOMINANavigation.DESCRIPCION;
// itmConcepto = objeto.IDCONCEPTONOMINANavigation.DESCRIPCION;
// FECHANOM = DateTime.ParseExact(objeto.AÑONOMINA.ToString() + "-" + objeto.MESNOMINA.ToString() + "-01 00:00:00,000", "yyyy-MM-dd HH:mm:ss,fff",
// System.Globalization.CultureInfo.InvariantCulture);
}
else
{
// itmNomina = "";
// itmConcepto = "";
// FECHANOM = DateTime.ParseExact("0001-01-01 00:00:00,000", "yyyy-MM-dd HH:mm:ss,fff",
// System.Globalization.CultureInfo.InvariantCulture);
}
await popupGestionDatos.ShowAsync();
}
private async Task cerrarPopupModificacion()
{
await popupGestionDatos.HideAsync();
}
protected override async Task OnInitializedAsync()
{
cliente = Utilidades.ObtenerCliente(UserState.Token, HttpClientFactory);
await CargarListas();
}
private async Task CargarListas()
{
itmList.Clear();
lTipo.Clear();
try
{
foreach (ENFERMEDADES enf in Persona.ENFERMEDADES)
@@ -84,11 +179,51 @@
{
itmList.Add(enf);
}
lTipo = await Utilidades.ObtenerObjeto<List<ENUMERACIONES>>(cliente, "/api/ENUMERACIONES/EnumeracionesGrupo/TIPENF");
StateHasChanged();
}
catch (Exception e)
{
Console.WriteLine($"Error al obtener los datos: {e.Message}");
}
}
private async Task GestionarDatos(bool tipo)
{
var inci = ItemEnEdicion;
if (tipo == true)
{
inci.IDENFERMEDADES = 0;
}
inci.IDPERSONA = Persona.IDPERSONA;
string chbCont = "chbCont";
inci.CONTINUIDAD = await JS.InvokeAsync<bool>("obtenerCheck", chbCont);
string chbNominaNormal = "chbNominaNormal";
inci.NOMINANORMAL = await JS.InvokeAsync<bool>("obtenerCheck", chbNominaNormal);
string chbNominaSS = "chbNominaSS";
inci.NOMINASEGURIDADSOCIAL = await JS.InvokeAsync<bool>("obtenerCheck", chbNominaSS);
inci.IDTIPO = lTipo.FirstOrDefault(x => x.DESCRIPCION == itmTipo).IDENUMERACION;
if (inci.IDENFERMEDADES != 0)
{
var response = await Utilidades.ActualizarObjeto(cliente, "/api/ENFERMEDADES/" + inci.IDENFERMEDADES, inci);
}
else
{
var response = await Utilidades.NuevoObjeto(cliente, "/api/ENFERMEDADES/", inci);
}
await cerrarPopupModificacion();
var response1 = await cliente.GetAsync($"/api/PERSONAS/PersonaNominaNif/{Persona.NIF}");
if (!response1.IsSuccessStatusCode)
{
throw new Exception($"Error al obtener los datos de la persona. Código: {response1.StatusCode}");
}
var resultContent = await response1.Content.ReadAsStringAsync();
Persona = JsonConvert.DeserializeObject<PERSONAS>(resultContent) ?? throw new Exception("Error al deserializar los datos de la persona.");
await CargarListas();
}
}

View File

@@ -15,7 +15,7 @@
@inject UserState UserState
<div class="tablaTabLateral">
<input type="button" value="Nueva Incidencia" @onclick="@(() => abrirPopupModificacion(new INCIDENCIAS(), true))" class="mb-2 btnOAAFBlack" />
<Accordion>
@foreach (var año in annos.Take(annos.Count))
{
@@ -62,11 +62,21 @@
PaginationItemsTextFormat="{0} - {1} de {2} elementos">
<GridColumns>
<GridColumn TItem="INCIDENCIAS" HeaderText="">
<button @onclick="@(() => abrirPopupModificacion(context, false))" class="btnOAAFAzul">Editar</button>
</GridColumn>
<GridColumn TItem="INCIDENCIAS" HeaderText="Nómina">
@context.IDNOMINANavigation.DESCRIPCION
</GridColumn>
@if (context.IDNOMINANavigation != null){
@context.IDNOMINANavigation.DESCRIPCION
}
</GridColumn>
<GridColumn TItem="INCIDENCIAS" HeaderText="Concepto">
@if (context.IDCONCEPTONOMINANavigation != null){
@context.IDCONCEPTONOMINANavigation.DESCRIPCION
}
</GridColumn>
<GridColumn TItem="INCIDENCIAS" HeaderText="Sustituye Concepto">
@if (context.SUSTITUYECONCEPTO)
@@ -117,7 +127,9 @@
}
</GridColumn>
<GridColumn TItem="INCIDENCIAS" HeaderText="Nómina Origen">
@if (context.IDNOMINANavigation != null){
@context.IDNOMINANavigation.DESCRIPCION
}
</GridColumn>
<GridColumn TItem="INCIDENCIAS" HeaderText="Apl. Presupuestaria">
@context.APLICACIONPRESUPUESTARIA
@@ -134,15 +146,96 @@
}
</Accordion>
<Modal @ref="popupGestionDatos" IsVerticallyCentered="true" UseStaticBackdrop="true" CloseOnEscape="false">
<BodyTemplate>
<div class="row">
<div class="col-md-12 mb-2">
<input list="listNom" id="selNomina" @bind-value="@itmNomina" type="text" style="width:100%" class="form-control" placeholder="Nomina" />
<datalist id="listNom">
@foreach(NOMINAS nom in lNom)
{
<option data-value="@nom.IDNOMINAS">@nom.DESCRIPCION</option>
}
</datalist>
</div>
<div class="col-md-12 mb-2">
<input list="listCon" id="selCon" @bind-value="@itmConcepto" type="text" style="width:100%" class="form-control" placeholder="Concepto" />
<datalist id="listCon">
@foreach (CONCEPTOSGENERALES con in lConceptos)
{
<option data-value="@con.IDCONCEPTOSGENERALES">@con.DESCRIPCION</option>
}
</datalist>
</div>
<div class="col-md-12 mb-2">
<label class="fw-bold">Mes y año nomina</label>
<input class="form-control" type="month" @bind-value="FECHANOM" />
</div>
<div class="col-md-12" style="display:flex; justify-content:space-between">
<label for="txtEDesc" class="fw-bold">Sustituye concepto: </label>
<input class="" type="checkbox" id="chbSusConcepto" checked="@ItemEnEdicion.SUSTITUYECONCEPTO" />
<label for="txtEDesc" class="fw-bold">Cotiza seguridad social: </label>
<input class="" type="checkbox" id="chbCotizaSS" checked="@ItemEnEdicion.COTIZASEGURIDADSOCIAL" />
</div>
<div class="col-md-12">
<label for="txtEDesc" class="fw-bold">Texto: </label>
<input class="form-control" type="text" @bind-value="@ItemEnEdicion.TEXTO" />
</div>
<div class="col-md-12">
<label for="txtEDesc" class="fw-bold">Cantidad: </label>
<input class="form-control" type="number" @bind-value="@ItemEnEdicion.CANTIDAD" />
</div>
<div class="col-md-12">
<label for="txtEDesc" class="fw-bold">Importe: </label>
<input class="form-control" type="number" @bind-value="@ItemEnEdicion.IMPORTE" />
</div>
<div class="col-md-12" style="display:flex; justify-content:space-between">
<label for="txtEDesc" class="fw-bold">IRPF: </label>
<input class="" type="checkbox" id="chbIRPF" checked="@ItemEnEdicion.IRPF" />
<label for="txtEDesc" class="fw-bold">IRPF Ficha: </label>
<input class="" type="checkbox" id="chbIRPFficha" checked="@ItemEnEdicion.IRPFFICHA" />
<label for="txtEDesc" class="fw-bold">No para IRPF: </label>
<input class="" type="checkbox" id="chbNominaIRPF" checked="@ItemEnEdicion.NOPARAIRPF" />
</div>
<div class="col-md-12" style="display:flex; justify-content:space-between">
<label for="txtEDesc" class="fw-bold">Nómina normal: </label>
<input class="" type="checkbox" id="chbNominaNormal" checked="@ItemEnEdicion.NOMINANORMAL" />
<label for="txtEDesc" class="fw-bold">Nómina seguridad social: </label>
<input class="" type="checkbox" id="chbNominaSS" checked="@ItemEnEdicion.NOMINASEGURIDADSOCIAL" />
</div>
<div class="col-md-12">
<label for="txtEDesc" class="fw-bold">Apl. Presupuestaria: </label>
<input class="form-control" type="text" @bind-value="@ItemEnEdicion.APLICACIONPRESUPUESTARIA" />
</div>
</div>
</BodyTemplate>
<FooterTemplate>
<Button Color="ButtonColor.Secondary" @onclick="cerrarPopupModificacion">Cerrar</Button>
@if(ItemEnEdicion.IDINCIDENCIA != 0){
<Button Type="ButtonType.Submit" Color="ButtonColor.Primary" @onclick="@(() =>GestionarDatos(false))">Modificar</Button>
}
else
{
<Button Type="ButtonType.Submit" Color="ButtonColor.Primary" @onclick="@(() => GestionarDatos(true))">Crear</Button>
}
</FooterTemplate>
</Modal>
</div>
@code {
[Parameter]
public PERSONAS Persona { get; set; } = new PERSONAS();
private Modal popupGestionDatos = default;
public int? idNom { get; set; }
private DateTime FECHANOM { get; set; }
private string titulo { get; set; }
private string itmNomina { get; set; }
private string itmConcepto { get; set; }
private HttpClient cliente = new HttpClient();
[Parameter]
public EventCallback OnPersonaActualizada { get; set; }
@@ -150,23 +243,46 @@
// private List<int?> meses = new List<int?>();
private List<INCIDENCIAS> itmList = new List<INCIDENCIAS>();
private List<String> nombMeses = ["Enero", "Febrero", "Marzo", "Abril", "Mayo", "Junio", "Julio", "Agosto", "Septiembre", "Octubre", "Noviembre", "Diciembre"];
private List<NOMINAS> lNom = new List<NOMINAS>();
private List<CONCEPTOSGENERALES> lConceptos = new List<CONCEPTOSGENERALES>();
private INCIDENCIAS ItemEnEdicion { get; set; } = new INCIDENCIAS();
protected override async Task OnInitializedAsync()
{
cliente = Utilidades.ObtenerCliente(UserState.Token, HttpClientFactory);
CargarListas();
}
private async Task abrirPopupModificacion(INCIDENCIAS objeto, bool esNuevo)
{
ItemEnEdicion = objeto;
if(objeto.IDINCIDENCIA != 0)
{
itmNomina = objeto.IDNOMINANavigation.DESCRIPCION;
itmConcepto = objeto.IDCONCEPTONOMINANavigation.DESCRIPCION;
FECHANOM = DateTime.ParseExact( objeto.AÑONOMINA.ToString() + "-" + objeto.MESNOMINA.ToString() + "-01 00:00:00,000", "yyyy-MM-dd HH:mm:ss,fff",
System.Globalization.CultureInfo.InvariantCulture);
}
else
{
itmNomina = "";
itmConcepto = "";
FECHANOM = DateTime.ParseExact( "0001-01-01 00:00:00,000", "yyyy-MM-dd HH:mm:ss,fff",
System.Globalization.CultureInfo.InvariantCulture);
}
await popupGestionDatos.ShowAsync();
}
private async Task cerrarPopupModificacion()
{
await popupGestionDatos.HideAsync();
}
private async Task CargarListas()
{
try
{
// cliente = Utilidades.ObtenerCliente(UserState.Token, HttpClientFactory);
// Expression<Func<INCIDENCIAS, bool>> filtro = x => x.IDPERSONA == Persona.IDPERSONA;
// itmList = await Utilidades.ObtenerObjeto<List<INCIDENCIAS>>(cliente, "/api/INCIDENCIAS/filtrar", filtro);
itmList.Clear();
annos.Clear();
var listnom = Persona.INCIDENCIAS.Where(x => x.ESDELIQUIDACION == false).ToList();
foreach (INCIDENCIAS i in listnom)
{
itmList.Add(i);
}
var nominaIncidenciadelcarajo = listnom.FirstOrDefault(x => x.IDINCIDENCIA == 56543);
foreach (INCIDENCIAS i in listnom){ itmList.Add(i); }
for (var i = 0; i < itmList.Count; i++)
{
int? year = itmList[i].AÑONOMINA;
@@ -179,6 +295,11 @@
}
}
annos = annos.OrderDescending().ToList();
var lNomi = await Utilidades.ObtenerObjeto<List<NOMINAS>>(cliente, "/api/NOMINAS");
lNom = lNomi.OrderBy(x => x.FECHAINICIO).ToList();
lConceptos = await Utilidades.ObtenerObjeto<List<CONCEPTOSGENERALES>>(cliente, "/api/CONCEPTOSGENERALES/");
// Task.Delay(1);
StateHasChanged();
}
catch (Exception e)
@@ -186,4 +307,53 @@
Console.WriteLine($"Error al obtener los datos: {e.Message}");
}
}
// GUARDAR
private async Task GestionarDatos(bool tipo)
{
var inci = ItemEnEdicion;
if (tipo == true)
{
inci.IDINCIDENCIA = 0;
}
inci.IDPERSONA = Persona.IDPERSONA;
inci.MESNOMINA = FECHANOM.Month;
inci.AÑONOMINA = FECHANOM.Year;
var nomSelect = lNom.FirstOrDefault(x => x.DESCRIPCION == itmNomina);
var concSelect = lConceptos.FirstOrDefault(x => x.DESCRIPCION == itmConcepto);
inci.IDNOMINA = nomSelect.IDNOMINAS;
inci.IDCONCEPTONOMINA = concSelect.IDCONCEPTOSGENERALES;
string chbSusConcepto = "chbSusConcepto";
inci.SUSTITUYECONCEPTO = await JS.InvokeAsync<bool>("obtenerCheck", chbSusConcepto);
string chbCotizaSS = "chbCotizaSS";
inci.COTIZASEGURIDADSOCIAL = await JS.InvokeAsync<bool>("obtenerCheck", chbCotizaSS);
string chbIRPFficha = "chbIRPFficha";
inci.IRPFFICHA= await JS.InvokeAsync<bool>("obtenerCheck", chbIRPFficha);
string chbNominaIRPF = "chbNominaIRPF";
inci.NOPARAIRPF = await JS.InvokeAsync<bool>("obtenerCheck", chbNominaIRPF);
string chbNominaNormal = "chbNominaNormal";
inci.NOMINANORMAL = await JS.InvokeAsync<bool>("obtenerCheck", chbNominaNormal);
string chbNominaSS = "chbNominaSS";
inci.NOMINASEGURIDADSOCIAL = await JS.InvokeAsync<bool>("obtenerCheck", chbNominaSS);
if(inci.IDINCIDENCIA != 0){
var response = await Utilidades.ActualizarObjeto(cliente, "/api/INCIDENCIAS/" + inci.IDINCIDENCIA, inci);
}
else
{
var response = await Utilidades.NuevoObjeto(cliente, "/api/INCIDENCIAS/", inci);
}
await cerrarPopupModificacion();
var response1 = await cliente.GetAsync($"/api/PERSONAS/PersonaNominaNif/{Persona.NIF}");
if (!response1.IsSuccessStatusCode)
{
throw new Exception($"Error al obtener los datos de la persona. Código: {response1.StatusCode}");
}
var resultContent = await response1.Content.ReadAsStringAsync();
Persona = JsonConvert.DeserializeObject<PERSONAS>(resultContent) ?? throw new Exception("Error al deserializar los datos de la persona.");
await CargarListas();
}
}

View File

@@ -15,7 +15,7 @@
@inject UserState UserState
<div class="tablaTabLateral">
<input type="button" value="Nueva maternidad/riesgo de embarazo" @onclick="@(() => abrirPopupModificacion(new MATERNIDADES(), true))" class="mb-2 btnOAAFBlack" />
<div style="overflow-x:auto;" class="tablaDesk">
<Grid TItem="MATERNIDADES"
Class="table tablaRegPers"
@@ -32,11 +32,14 @@
PaginationItemsTextFormat="{0} - {1} de {2} elementos">
<GridColumns>
<GridColumn TItem="MATERNIDADES" HeaderText="">
<button @onclick="@(() => abrirPopupModificacion(context, false))" class="btnOAAFAzul">Editar</button>
</GridColumn>
<GridColumn TItem="MATERNIDADES" HeaderText="Fecha Inicio">
@context.FECHAINICIO?.ToString("dd/MM/yyyy")
</GridColumn>
<GridColumn TItem="MATERNIDADES" HeaderText="Fecha Fin">
@context.FECHAINICIO?.ToString("dd/MM/yyyy")
@context.FECHAFIN?.ToString("dd/MM/yyyy")
</GridColumn>
<GridColumn TItem="MATERNIDADES" HeaderText="Base Cotización Seguridad Social">
@context.BASECOTIZACIONSEGURIDADSOCIAL
@@ -67,24 +70,132 @@
</div>
</div>
<Modal @ref="popupGestionDatos" IsVerticallyCentered="true" UseStaticBackdrop="true" CloseOnEscape="false">
<BodyTemplate>
<div class="row">
<div class="col-md-6 mb-2">
<label class="fw-bold">Fecha inicio</label>
<input class="form-control" type="date" @bind-value="ItemEnEdicion.FECHAINICIO" />
</div>
<div class="col-md-6 mb-2">
<label class="fw-bold">Fecha fin</label>
<input class="form-control" type="date" @bind-value="ItemEnEdicion.FECHAFIN" />
</div>
<div class="col-md-12 mb-2">
<label for="txtEDesc" class="fw-bold">Base cotización seguridad social: </label>
<input class="form-control" type="number" @bind-value="@ItemEnEdicion.BASECOTIZACIONSEGURIDADSOCIAL" />
</div>
<div class="col-md-12 mb-2">
<label for="txtEDesc" class="fw-bold">Porcentaje reducción jornada: </label>
<input class="form-control" type="number" @bind-value="@ItemEnEdicion.PORCENTAJEREDUCCIONJORNADA" />
</div>
<div class="col-md-12" style="display:flex; justify-content:space-between">
<label for="txtEDesc" class="fw-bold">Riesgo embarazo: </label>
<input class="" type="checkbox" id="chbRiesgoEmbarazo" checked="@ItemEnEdicion.RIESGOEMBARAZO" />
<label for="txtEDesc" class="fw-bold">Nomina normal: </label>
<input class="" type="checkbox" id="chbNominaNormal" checked="@ItemEnEdicion.NOMINANORMAL" />
<label for="txtEDesc" class="fw-bold">Nomina seguridad social: </label>
<input class="" type="checkbox" id="chbNominaSS" checked="@ItemEnEdicion.NOMINASEGURIDADSOCIAL" />
</div>
</div>
</BodyTemplate>
<FooterTemplate>
<Button Color="ButtonColor.Secondary" @onclick="cerrarPopupModificacion">Cerrar</Button>
@if (ItemEnEdicion.IDMATERNIDADES != 0)
{
<Button Type="ButtonType.Submit" Color="ButtonColor.Primary" @onclick="@(() => GestionarDatos(false))">Modificar</Button>
}
else
{
<Button Type="ButtonType.Submit" Color="ButtonColor.Primary" @onclick="@(() => GestionarDatos(true))">Crear</Button>
}
</FooterTemplate>
</Modal>
@code {
[Parameter]
public PERSONAS Persona { get; set; } = new PERSONAS();
private HttpClient cliente = new HttpClient();
private Modal popupGestionDatos = default;
[Parameter]
public EventCallback OnPersonaActualizada { get; set; }
private MATERNIDADES ItemEnEdicion { get; set; } = new MATERNIDADES();
// private List<int?> meses = new List<int?>();
private List<MATERNIDADES> itmList = new List<MATERNIDADES>();
protected override async Task OnInitializedAsync()
{
cliente = Utilidades.ObtenerCliente(UserState.Token, HttpClientFactory);
await CargarListas();
}
private async Task CargarListas()
{
itmList.Clear();
try
{
var listnom = Persona.MATERNIDADES;
foreach (MATERNIDADES i in listnom){itmList.Add(i);}
foreach (MATERNIDADES enf in Persona.MATERNIDADES)
{
itmList.Add(enf);
}
StateHasChanged();
}
catch (Exception e)
{
Console.WriteLine($"Error al obtener los datos: {e.Message}");
}
}
private async Task GestionarDatos(bool tipo)
{
var inci = ItemEnEdicion;
if (tipo == true)
{
inci.IDMATERNIDADES = 0;
}
inci.IDPERSONA = Persona.IDPERSONA;
string chbRiesgoEmbarazo = "chbRiesgoEmbarazo";
inci.RIESGOEMBARAZO = await JS.InvokeAsync<bool>("obtenerCheck", chbRiesgoEmbarazo);
string chbNominaNormal = "chbNominaNormal";
inci.NOMINANORMAL = await JS.InvokeAsync<bool>("obtenerCheck", chbNominaNormal);
string chbNominaSS = "chbNominaSS";
inci.NOMINASEGURIDADSOCIAL = await JS.InvokeAsync<bool>("obtenerCheck", chbNominaSS);
if (inci.IDMATERNIDADES != 0)
{
var response = await Utilidades.ActualizarObjeto(cliente, "/api/MATERNIDADES/" + inci.IDMATERNIDADES, inci);
}
else
{
var response = await Utilidades.NuevoObjeto(cliente, "/api/MATERNIDADES/", inci);
}
await cerrarPopupModificacion();
var response1 = await cliente.GetAsync($"/api/PERSONAS/PersonaNominaNif/{Persona.NIF}");
if (!response1.IsSuccessStatusCode)
{
throw new Exception($"Error al obtener los datos de la persona. Código: {response1.StatusCode}");
}
var resultContent = await response1.Content.ReadAsStringAsync();
Persona = JsonConvert.DeserializeObject<PERSONAS>(resultContent) ?? throw new Exception("Error al deserializar los datos de la persona.");
await CargarListas();
}
private async Task abrirPopupModificacion(MATERNIDADES objeto, bool esNuevo)
{
ItemEnEdicion = objeto;
await popupGestionDatos.ShowAsync();
}
private async Task cerrarPopupModificacion()
{
await popupGestionDatos.HideAsync();
}
}

View File

@@ -607,7 +607,7 @@
@code {
[Parameter]
public PERSONAS Persona { get; set; } = new PERSONAS();
public static NOMINATRABAJADORCABECERA Nomina { get; set; } = new NOMINATRABAJADORCABECERA();
public NOMINATRABAJADORCABECERA Nomina { get; set; } = new NOMINATRABAJADORCABECERA();
private HttpClient cliente = new HttpClient();
[Parameter]
public EventCallback OnPersonaActualizada { get; set; }
@@ -629,7 +629,19 @@
private Tabs tabsDtsNom { get; set; } = new Tabs();
private Modal popupConfirmacion = default!;
List<ToastMessage> mensajes = new List<ToastMessage>();
private EditContext editContext = new EditContext(Nomina);
private EditContext editContext = default!;
List<FieldIdentifier> listaIdentificadores = new List<FieldIdentifier>();
private void EstablecerEditContext(NOMINATRABAJADORCABECERA nuevo)
{
Nomina = nuevo;
editContext = new EditContext(Nomina);
messageStore = new ValidationMessageStore(editContext);
listaIdentificadores.Clear();
}
private async Task abrirGuardar()
{
await popupConfirmacion.ShowAsync();
@@ -647,6 +659,7 @@
}
protected override async Task OnInitializedAsync()
{
EstablecerEditContext(new NOMINATRABAJADORCABECERA());
try
{
var url = NavigationManager.ToAbsoluteUri(NavigationManager.Uri);
@@ -665,14 +678,6 @@
var clCn = clDesencriptado.Split("-");
nifDesencriptado = clCn[0];
NominaDesencriptada = Int32.Parse(clCn[1]);
// int NominaDesencriptada = Int32.Parse(tsUtilidades.crypt.FEncS(cn,
// "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890.:/-*",
// "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890.:/-*",
// -875421649));
var response = await cliente.GetAsync($"/api/PERSONAS/PersonaNominaNif/{nifDesencriptado}");
if (!response.IsSuccessStatusCode)
{
@@ -681,7 +686,8 @@
var resultContent = await response.Content.ReadAsStringAsync();
Persona = JsonConvert.DeserializeObject<PERSONAS>(resultContent) ?? throw new Exception("Error al deserializar los datos de la persona.");
Nomina = Persona.NOMINATRABAJADORCABECERA?.FirstOrDefault(x => x.IDNOMINA == NominaDesencriptada);
var Nomi = Persona.NOMINATRABAJADORCABECERA?.FirstOrDefault(x => x.IDNOMINA == NominaDesencriptada);
EstablecerEditContext(Nomi);
lConceptos = new List<NOMINATRABAJADORLINEA>();
foreach (var lin in Nomina.NOMINATRABAJADORLINEA){lConceptos.Add(lin);}
lPagoEspecie = new List<NOMINATRABAJADORPAGOESPECIE>();

View File

@@ -189,7 +189,7 @@
</div>
@code {
private static PERIODOSSILTRA PeriodoSiltra = new PERIODOSSILTRA();
private PERIODOSSILTRA PeriodoSiltra = new PERIODOSSILTRA();
public EventCallback<string> OnValidationStateChanged { get; set; }
private List<int> annos = new List<int>();
private string cl { get; set; }
@@ -201,12 +201,14 @@
// private List<TIPOSTRAMOS> lTipPeriodo = new List<TIPOSTRAMOS>();
private List<ENUMERACIONES> lTipPeriodo = new List<ENUMERACIONES>();
private List<TRAMOSSILTRA> lTraSil = new List<TRAMOSSILTRA>();
private EditContext editContext = new EditContext(PeriodoSiltra);
private EditContext editContext = default!;
private HttpClient cliente = new HttpClient();
private ValidationMessageStore? messageStore;
private string errorMessage = "";
private Modal popupConfirmacion = default!;
List<ToastMessage> mensajes = new List<ToastMessage>();
protected override async Task OnInitializedAsync()
{
var token = UserState.Token;

View File

@@ -12,7 +12,7 @@ public sealed record ApiGlobalLeaksSessionDto(
bool HasActiveSession,
DateTimeOffset? UpdatedAt);
public sealed record RenewGlobalLeaksSessionRequest(string Authcode);
public sealed record RenewGlobalLeaksSessionRequest(string Authcode, string? PendingLoginId = null);
public sealed record InboxSnapshotResponse(
IReadOnlyList<ContextDto> Contexts,
@@ -82,3 +82,15 @@ public sealed record GestionaTramitarDocumentoRequest(
string DocumentUrl,
string AssignedGroupHref,
int? ComplaintId);
public sealed record ManualPurgeRequest(string Date);
public sealed record ManualPurgeResponse(
string Date,
bool Success,
int StatusCode,
string ResponseBody);
public sealed record AppConfigurationDto(string? ExternalUpdateCutoffDate);
public sealed record UpdateExternalUpdateCutoffRequest(string? Date);

View File

@@ -0,0 +1,10 @@
namespace GestionaDenuncias.Shared.Models;
public enum DenunciaListScope
{
All = 0,
Pending = 1,
Updates = 2,
InGestiona = 3,
Rejected = 4
}

View File

@@ -81,6 +81,15 @@ public class DenunciasGestiona
public bool EnGestiona { get; set; }
public bool EnRechazada { get; set; }
[JsonIgnore]
public DateOnly? KeyDate { get; set; }
[JsonIgnore]
public string EncryptionScheme { get; set; } = string.Empty;
[JsonIgnore]
public DateTime? EncryptedAtUtc { get; set; }
[JsonIgnore]
public string NombreResuelto => ResolveValue(Nombre, "nombre");
@@ -273,10 +282,20 @@ public class DenunciasGestiona
{
if (CharUnicodeInfo.GetUnicodeCategory(ch) != UnicodeCategory.NonSpacingMark)
{
builder.Append(char.ToLowerInvariant(ch));
if (ch is 'º' or 'ª')
{
continue;
}
builder.Append(char.IsLetterOrDigit(ch) ? char.ToLowerInvariant(ch) : ' ');
}
}
return builder.ToString().Normalize(NormalizationForm.FormC).Trim();
return string.Join(
' ',
builder
.ToString()
.Normalize(NormalizationForm.FormC)
.Split(' ', StringSplitOptions.RemoveEmptyEntries));
}
}

View File

@@ -1,5 +1,6 @@
// Models/FicherosDenuncias.cs
using System;
using System.Text.Json.Serialization;
namespace GestionaDenuncias.Shared.Models
{
@@ -38,6 +39,15 @@ namespace GestionaDenuncias.Shared.Models
// Hash SHA-256 del contenido, para evitar re-subir adjuntos repetidos.
public string ContentSha256 { get; set; } = string.Empty;
[JsonIgnore]
public DateOnly? KeyDate { get; set; }
[JsonIgnore]
public string EncryptionScheme { get; set; } = string.Empty;
[JsonIgnore]
public DateTime? EncryptedAtUtc { get; set; }
public bool EsReport
{
get

View File

@@ -4,4 +4,5 @@ public sealed record ImportSummary(
int TotalCandidates,
int ImportedCount,
IReadOnlyList<string> Errors,
IReadOnlyList<int>? ImportedComplaintIds = null);
IReadOnlyList<int>? ImportedComplaintIds = null,
IReadOnlyList<string>? Warnings = null);

View File

@@ -1,3 +1,12 @@
namespace GestionaDenuncias.Shared.Models;
public sealed record LoginRequest(string Username, string Password, string Authcode);
public sealed record ApiLoginPrepareRequest(string Username, string Password);
public sealed record ApiLoginPrepareResponse(
string PendingLoginId,
string Username,
DateTimeOffset ExpiresAtUtc);
public sealed record ApiLoginCompleteRequest(string PendingLoginId, string Authcode);

View File

@@ -0,0 +1,23 @@
namespace GestionaDenuncias.Shared.Models;
public sealed record ReportDetailDto(
string ReportId,
string? LastAccess,
IReadOnlyList<ReportCommentDto> Comments,
IReadOnlyList<ReportFileDto> WhistleblowerFiles,
IReadOnlyList<ReportFileDto> ReceiverFiles);
public sealed record ReportCommentDto(
string? Id,
string? Type,
string? Content,
string? CreationDate,
bool IsNew);
public sealed record ReportFileDto(
string? Id,
string? Name,
long? Size,
string? ContentType,
string? CreationDate,
bool IsNew);

View File

@@ -26,22 +26,31 @@ public sealed class ThirdPartyIdentityData
{
ArgumentNullException.ThrowIfNull(denuncia);
var documentId = (denuncia.DocumentoResuelto ?? string.Empty).Trim().ToUpperInvariant();
var firstName = denuncia.NombreResuelto.Trim();
var lastName = BuildLastName(denuncia);
var businessName = denuncia.RazonSocialResuelta.Trim();
var isAnonymous = denuncia.EsAnonima ||
string.Equals(
denuncia.DocumentoResuelto?.Trim(),
documentId,
"00000000T",
StringComparison.OrdinalIgnoreCase);
var isLegalEntity = !isAnonymous &&
(denuncia.EsPersonaJuridica ||
LooksLikeLegalEntityDocument(documentId) ||
(!string.IsNullOrWhiteSpace(businessName) &&
string.IsNullOrWhiteSpace(firstName)));
return new ThirdPartyIdentityData
{
IsAnonymous = isAnonymous,
IsLegalEntity = !isAnonymous && denuncia.EsPersonaJuridica,
IsLegalEntity = isLegalEntity,
DocumentId = isAnonymous
? "00000000T"
: (denuncia.DocumentoResuelto ?? string.Empty).Trim().ToUpperInvariant(),
FirstName = isAnonymous ? "Anonimo" : denuncia.NombreResuelto.Trim(),
LastName = isAnonymous ? "-" : BuildLastName(denuncia),
BusinessName = isAnonymous ? string.Empty : denuncia.RazonSocialResuelta.Trim(),
: documentId,
FirstName = isAnonymous ? "Anonimo" : isLegalEntity ? string.Empty : firstName,
LastName = isAnonymous ? "-" : isLegalEntity ? string.Empty : lastName,
BusinessName = isAnonymous ? string.Empty : businessName,
Email = (denuncia.Correo_Electronico ?? string.Empty).Trim(),
CountryCode = string.IsNullOrWhiteSpace(denuncia.Pais) ? denuncia.PaisOrigen : denuncia.Pais,
Address = isAnonymous ? null : ThirdPartyAddressData.FromComplaint(denuncia)
@@ -60,4 +69,17 @@ public sealed class ThirdPartyIdentityData
? denuncia.ApellidosResueltos.Trim()
: separated;
}
private static bool LooksLikeLegalEntityDocument(string documentId)
{
var value = (documentId ?? string.Empty).Trim().ToUpperInvariant();
if (value.Length != 9)
{
return false;
}
const string legalEntityPrefixes = "ABCDEFGHJKLMNPQRSUVW";
return legalEntityPrefixes.Contains(value[0]) &&
value.Skip(1).Take(7).All(char.IsDigit);
}
}

View File

@@ -6,6 +6,7 @@ public interface IDenunciaStore
{
Task EnsureSchemaAsync(CancellationToken cancellationToken = default);
Task<List<DenunciasGestiona>> GetAllDenunciasAsync(CancellationToken cancellationToken = default);
Task<List<DenunciasGestiona>> GetDenunciasByScopeAsync(DenunciaListScope scope, CancellationToken cancellationToken = default);
Task<List<FicherosDenuncias>> GetAllFicherosAsync(CancellationToken cancellationToken = default);
Task<List<FicherosDenuncias>> GetFicherosByDenunciaAsync(int denunciaId, CancellationToken cancellationToken = default);
Task<DenunciasGestiona?> GetDenunciaByIdAsync(int denunciaId, CancellationToken cancellationToken = default);

View File

@@ -0,0 +1,185 @@
@implements IDisposable
@inject UiBusyService Busy
@if (Busy.IsVisible)
{
<div class="busy-overlay" role="alert" aria-live="assertive">
<div class="busy-overlay__panel">
<div class="busy-overlay__spinner" aria-hidden="true"></div>
<div class="busy-overlay__content">
<div class="busy-overlay__eyebrow">Operacion en curso</div>
<h2 class="busy-overlay__title">@Busy.Title</h2>
<p class="busy-overlay__message">@Busy.Message</p>
@if (!string.IsNullOrWhiteSpace(Busy.Detail))
{
<div class="busy-overlay__detail">@Busy.Detail</div>
}
<div class="busy-overlay__progress" aria-hidden="true">
<div class="@ProgressBarCss" style="@ProgressStyle"></div>
</div>
@if (!Busy.IsIndeterminate && Busy.Total is > 0)
{
<div class="busy-overlay__counter">
@Busy.Current de @Busy.Total
</div>
}
</div>
</div>
</div>
}
<style>
.busy-overlay {
position: fixed;
inset: 0;
z-index: 5000;
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
background:
radial-gradient(circle at 20% 20%, rgba(41, 101, 194, 0.28), transparent 32%),
rgba(6, 22, 41, 0.46);
backdrop-filter: blur(8px);
}
.busy-overlay__panel {
display: flex;
gap: 1.25rem;
width: min(520px, 100%);
padding: 1.5rem;
border: 1px solid rgba(255, 255, 255, 0.55);
border-radius: 28px;
background: rgba(255, 255, 255, 0.94);
box-shadow: 0 26px 80px rgba(5, 27, 54, 0.28);
color: #0d345f;
}
.busy-overlay__spinner {
width: 3.25rem;
height: 3.25rem;
flex: 0 0 auto;
border: 4px solid rgba(42, 82, 152, 0.16);
border-top-color: #2865c2;
border-radius: 50%;
animation: busy-spin 0.9s linear infinite;
}
.busy-overlay__content {
min-width: 0;
flex: 1;
}
.busy-overlay__eyebrow {
margin-bottom: 0.35rem;
color: #4f6c8e;
font-size: 0.74rem;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.busy-overlay__title {
margin: 0;
color: #0a315c;
font-size: clamp(1.25rem, 3vw, 1.65rem);
font-weight: 800;
}
.busy-overlay__message {
margin: 0.4rem 0 0;
color: #385c80;
line-height: 1.45;
}
.busy-overlay__detail {
margin-top: 0.85rem;
padding: 0.55rem 0.7rem;
border-radius: 14px;
background: #eef5ff;
color: #264f79;
font-size: 0.9rem;
font-weight: 700;
}
.busy-overlay__progress {
position: relative;
overflow: hidden;
height: 0.55rem;
margin-top: 1.1rem;
border-radius: 999px;
background: #dbe8f7;
}
.busy-overlay__progress-bar {
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #2865c2, #1fa47f);
transition: width 0.24s ease;
}
.busy-overlay__progress-bar--indeterminate {
position: absolute;
width: 42%;
animation: busy-progress 1.15s ease-in-out infinite;
}
.busy-overlay__counter {
margin-top: 0.5rem;
color: #5d7187;
font-size: 0.86rem;
font-weight: 700;
text-align: right;
}
@@keyframes busy-spin {
to {
transform: rotate(360deg);
}
}
@@keyframes busy-progress {
0% {
left: -45%;
}
100% {
left: 105%;
}
}
@@media (max-width: 575.98px) {
.busy-overlay__panel {
flex-direction: column;
padding: 1.2rem;
}
}
</style>
@code {
private string ProgressBarCss => Busy.IsIndeterminate
? "busy-overlay__progress-bar busy-overlay__progress-bar--indeterminate"
: "busy-overlay__progress-bar";
private string ProgressStyle => Busy.IsIndeterminate
? string.Empty
: $"width: {Busy.ProgressPercent}%;";
protected override void OnInitialized()
{
Busy.Changed += HandleBusyChanged;
}
public void Dispose()
{
Busy.Changed -= HandleBusyChanged;
}
private void HandleBusyChanged()
{
_ = InvokeAsync(StateHasChanged);
}
}

View File

@@ -4,4 +4,6 @@
<div class="">
@Body
</div>
</div>
</div>
<BusyOverlay />

View File

@@ -4,6 +4,7 @@
@inject IHttpContextAccessor HttpContextAccessor
@inject IJSRuntime JSRuntime
@inject NavigationManager Navigation
@inject UiBusyService Busy
<div class="app-shell">
<aside class="app-sidebar">
@@ -40,6 +41,8 @@
</main>
</div>
<BusyOverlay />
<div id="blazor-error-ui">
An unhandled error has occurred.
<a href="" class="reload">Reload</a>
@@ -113,6 +116,9 @@
"buscador" => (
"Buscador de terceros",
"Localiza terceros y expedientes relacionados para validar identidades antes de tramitar."),
"configuracion" => (
"Configuracion",
"Ajusta criterios de actualizaciones externas y ejecuta operaciones tecnicas controladas."),
"instrucciones" => (
"Instrucciones",
"Referencia rapida de uso para el equipo gestor y para las operaciones mas frecuentes."),
@@ -140,9 +146,19 @@
private async Task CerrarSesionAsync()
{
await JSRuntime.InvokeAsync<object>("appAuthPost", "/api/auth/logout");
userState.Token = string.Empty;
userState.NombreUsu = string.Empty;
Navigation.NavigateTo("/", true);
using var busy = Busy.Show(
"Cerrando sesion",
"Invalidando la sesion interna y limpiando el acceso del usuario.");
try
{
await JSRuntime.InvokeAsync<object>("appAuthPost", "/api/auth/logout");
}
finally
{
userState.Token = string.Empty;
userState.NombreUsu = string.Empty;
Navigation.NavigateTo("/", true);
}
}
}

View File

@@ -65,6 +65,14 @@
<span class="menu-link__meta">Consulta identidades y expedientes vinculados</span>
</span>
</NavLink>
<NavLink class="menu-link" href="/Configuracion" Match="NavLinkMatch.All">
<span class="menu-link__icon bi bi-gear" aria-hidden="true"></span>
<span class="menu-link__content">
<span class="menu-link__title">Configuracion</span>
<span class="menu-link__meta">Fecha de corte y purga manual</span>
</span>
</NavLink>
</div>
<div class="nav-section nav-section--footer">

View File

@@ -1,4 +1,4 @@
@page "/Actualizaciones"
@page "/Actualizaciones"
@rendermode InteractiveServer
@attribute [Authorize]
@using GestionaDenuncias.Shared.Models
@@ -15,6 +15,7 @@
@inject IHostEnvironment HostEnvironment
@inject IDenunciaStore DenunciaStore
@inject ApiDenunciasClient ApiDenuncias
@inject UiBusyService Busy
<PageTitle>Actualizaciones</PageTitle>
@@ -57,7 +58,7 @@
margin: 1rem 0 0.5rem;
}
/* Tarjetas de actualización (azules) */
/* Tarjetas de actualizaci<EFBFBD>n (azules) */
.collapse-card.update-card {
background-color: #e3f2fd;
}
@@ -72,28 +73,7 @@
vertical-align: middle;
}
/* Overlay de carga */
.upload-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.35);
z-index: 2000;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(1px);
}
.upload-box {
background: #fff;
border-radius: .75rem;
padding: 1.25rem 1.5rem;
box-shadow: 0 10px 30px rgba(0,0,0,.25);
min-width: 260px;
text-align: center;
}
/* === Estética de modal igual que en Pendientes === */
/* === Est<73>tica de modal igual que en Pendientes === */
.custom-modal {
background: rgba(0, 0, 0, 0.5);
@@ -139,6 +119,15 @@
<h3>Actualizaciones</h3>
@if (!string.IsNullOrWhiteSpace(loadError))
{
<div class="alert alert-danger">@loadError</div>
}
@if (!string.IsNullOrWhiteSpace(operationNotice))
{
<div class="alert alert-warning">@operationNotice</div>
}
<input type="text"
class="form-control"
placeholder="Buscar actualizaciones..."
@@ -173,7 +162,7 @@ else
data-bs-target="#@collapseId"
aria-expanded="false"
aria-controls="@collapseId">
<h5 class="mb-0">Denuncia ID: @denuncia.Id_Denuncia (Actualización)</h5>
<h5 class="mb-0">Denuncia ID: @denuncia.Id_Denuncia (Actualizaci<EFBFBD>n)</h5>
<div class="d-flex align-items-center">
<div class="text-muted small me-3">
@@ -189,7 +178,7 @@ else
<button type="button"
class="btn btn-outline-secondary btn-sm me-2"
title="Marcar como pendiente (no actualización)"
title="Marcar como pendiente (no actualizaci<EFBFBD>n)"
@onclick:stopPropagation="true"
@onclick="() => MoverAPendientes(denuncia)">
Mover a Pendientes
@@ -215,7 +204,7 @@ else
}
@if (!string.IsNullOrWhiteSpace(denuncia.ExpedienteGestionaMostrable))
{
<dt class="col-sm-3">Nº expediente Gestiona</dt>
<dt class="col-sm-3">N<EFBFBD> expediente Gestiona</dt>
<dd class="col-sm-9">@denuncia.ExpedienteGestionaMostrable</dd>
}
@if (!string.IsNullOrWhiteSpace(denuncia.Etiqueta))
@@ -235,6 +224,13 @@ else
}
</dl>
@if (IsExternalGestionaUpdate(denuncia))
{
<div class="alert alert-warning" role="alert">
@GetExternalUpdateWarningText()
</div>
}
@if (!string.IsNullOrWhiteSpace(denuncia.NombreResuelto) ||
!string.IsNullOrWhiteSpace(denuncia.ApellidosResueltos) ||
!string.IsNullOrWhiteSpace(denuncia.RazonSocialResuelta) ||
@@ -249,7 +245,7 @@ else
}
@if (!string.IsNullOrWhiteSpace(denuncia.RazonSocialResuelta))
{
<dt class="col-sm-3">Razón social</dt>
<dt class="col-sm-3">Raz<EFBFBD>n social</dt>
<dd class="col-sm-9">@denuncia.RazonSocialResuelta</dd>
}
@if (!string.IsNullOrWhiteSpace(denuncia.NombreResuelto))
@@ -259,12 +255,12 @@ else
}
@if (!string.IsNullOrWhiteSpace(denuncia.PrimerApellidoResuelto))
{
<dt class="col-sm-3">1º apellido</dt>
<dt class="col-sm-3">1<EFBFBD> apellido</dt>
<dd class="col-sm-9">@denuncia.PrimerApellidoResuelto</dd>
}
@if (!string.IsNullOrWhiteSpace(denuncia.SegundoApellidoResuelto))
{
<dt class="col-sm-3">2º apellido</dt>
<dt class="col-sm-3">2<EFBFBD> apellido</dt>
<dd class="col-sm-9">@denuncia.SegundoApellidoResuelto</dd>
}
@if (!string.IsNullOrWhiteSpace(denuncia.ApellidosResueltos))
@@ -284,14 +280,14 @@ else
<dl class="row">
<dt class="col-sm-3">Asunto</dt>
<dd class="col-sm-9">@denuncia.Asunto</dd>
<dt class="col-sm-3">A Quién</dt>
<dt class="col-sm-3">A Qui<EFBFBD>n</dt>
<dd class="col-sm-9">@denuncia.A_Quien_Denuncia</dd>
@if (!string.IsNullOrWhiteSpace(denuncia.DenunciadoDetalle))
{
<dt class="col-sm-3">Detalle denunciado</dt>
<dd class="col-sm-9">@denuncia.DenunciadoDetalle</dd>
}
<dt class="col-sm-3">Descripción</dt>
<dt class="col-sm-3">Descripci<EFBFBD>n</dt>
<dd class="col-sm-9">@denuncia.Descripcion_Denuncia</dd>
@if (!string.IsNullOrWhiteSpace(denuncia.OrganismoDenunciado))
{
@@ -300,7 +296,7 @@ else
}
@if (!string.IsNullOrWhiteSpace(denuncia.SolicitaProteccion))
{
<dt class="col-sm-3">Solicita protección</dt>
<dt class="col-sm-3">Solicita protecci<EFBFBD>n</dt>
<dd class="col-sm-9">@denuncia.SolicitaProteccion</dd>
}
@if (!string.IsNullOrWhiteSpace(denuncia.MedidasProteccionSolicitadas))
@@ -317,7 +313,7 @@ else
}
@if (!string.IsNullOrWhiteSpace(denuncia.AutorizaRemision))
{
<dt class="col-sm-3">Autoriza remisión</dt>
<dt class="col-sm-3">Autoriza remisi<EFBFBD>n</dt>
<dd class="col-sm-9">@denuncia.AutorizaRemision</dd>
}
</dl>
@@ -328,14 +324,14 @@ else
@if (camposFormulario.Count > 0)
{
<h5 class="section-heading">Formulario Original</h5>
@foreach (var grupoCampos in camposFormulario.GroupBy(field => string.IsNullOrWhiteSpace(field.Section) ? "Sin sección" : field.Section))
@foreach (var grupoCampos in camposFormulario.GroupBy(field => string.IsNullOrWhiteSpace(field.Section) ? "Sin secci<EFBFBD>n" : field.Section))
{
<h6 class="mt-3">@grupoCampos.Key</h6>
<dl class="row">
@foreach (var campo in grupoCampos)
{
<dt class="col-sm-4">@campo.Label</dt>
<dd class="col-sm-8">@(string.IsNullOrWhiteSpace(campo.Value) ? "" : campo.Value)</dd>
<dd class="col-sm-8">@(string.IsNullOrWhiteSpace(campo.Value) ? "<EFBFBD>" : campo.Value)</dd>
}
</dl>
}
@@ -347,22 +343,44 @@ else
<table class="table table-striped">
<thead>
<tr>
<th>Subir</th>
<th>Nombre</th>
<th>Fecha</th>
<th>Motivo</th>
<th>Tamaño (bytes)</th>
<th>Tama<EFBFBD>o (bytes)</th>
<th>Ver</th>
</tr>
</thead>
<tbody>
@foreach (var f in fAdj)
{
var isReport = IsReportFileName(f.NombreFichero);
<tr>
<td>@f.NombreFichero</td>
<td>
<input class="form-check-input"
type="checkbox"
checked="@IsFileSelectedForUpload(denuncia.Id_Denuncia, f.NombreFichero)"
disabled="@isReport"
title='@(isReport ? "El report se sube siempre" : "Incluir este fichero en la subida")'
@onchange="args => ToggleFileSelection(denuncia.Id_Denuncia, f.NombreFichero, args.Value is bool selected && selected)" />
</td>
<td>
@f.NombreFichero
@if (isReport)
{
<span class="badge bg-primary ms-2">Obligatorio</span>
}
</td>
<td>@FormatFileDate(f.Fecha)</td>
<td>
@if (f.EsReport)
{
<span class="badge bg-primary">Siempre se sube para notificar</span>
}
else if (IsExternalGestionaUpdate(denuncia))
{
<span class="badge bg-warning text-dark">@GetExternalUpdateFileReason()</span>
}
else
{
<span class="badge bg-warning text-dark">Hash nuevo pendiente</span>
@@ -372,14 +390,13 @@ else
<td>
@if (f.Fichero != null && f.Fichero.Length > 0)
{
<a class="btn btn-primary btn-sm" href="#"
onclick="openFile(event, '@Convert.ToBase64String(f.Fichero)', '@GetContentType(f.NombreFichero)');">
<a class="btn btn-primary btn-sm" href="@BuildAttachmentContentUrl(denuncia.Id_Denuncia, f.NombreFichero)" target="_blank" rel="noopener">
<i class="bi bi-eye"></i> Ver
</a>
}
else
{
<span class="text-muted"></span>
<span class="text-muted"><EFBFBD></span>
}
</td>
</tr>
@@ -430,19 +447,19 @@ else
{
<div class="alert alert-info d-flex align-items-center" role="alert">
<div class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></div>
Buscando expediente existente en Gestiona
Buscando expediente existente en Gestiona<EFBFBD>
</div>
}
else if (autoSearchTried && !string.IsNullOrWhiteSpace(autoFoundFileUrl))
{
<div class="alert alert-success" role="alert">
<div class="fw-semibold">Expediente detectado en Gestiona.</div>
<div>Asunto: <strong>@(autoFoundTitle ?? "(sin título)")</strong></div>
<div>Asunto: <strong>@(autoFoundTitle ?? "(sin t<EFBFBD>tulo)")</strong></div>
<div class="text-muted small">@autoFoundFileUrl</div>
<div class="form-check mt-2">
<input class="form-check-input" type="checkbox" id="chkUsarDetectado" @bind="useAutoFoundExpediente" />
<label class="form-check-label" for="chkUsarDetectado">
Añadir documentos a este expediente.
A<EFBFBD>adir documentos a este expediente.
</label>
</div>
</div>
@@ -451,7 +468,7 @@ else
{
<div class="alert alert-warning" role="alert">
<div class="fw-semibold">No se ha detectado expediente en Gestiona por asunto.</div>
<div class="small">Puede que esto no sea una actualización del mismo caso.</div>
<div class="small">Puede que esto no sea una actualizaci<EFBFBD>n del mismo caso.</div>
<div class="mt-2">
<button class="btn btn-outline-secondary btn-sm" @onclick="MoverADenunciasPendientes">
Mover a Pendientes
@@ -460,7 +477,21 @@ else
</div>
}
<h6 class="modal-section-heading">Descripción</h6>
@if (!string.IsNullOrWhiteSpace(operationError))
{
<div class="alert alert-danger" role="alert">
@operationError
</div>
}
@if (IsExternalGestionaUpdate(selectedDenuncias))
{
<div class="alert alert-warning" role="alert">
@GetExternalUpdateWarningText()
</div>
}
<h6 class="modal-section-heading">Descripci<63>n</h6>
<div class="mb-3">
<input type="text" class="form-control" @bind="nuevoAsunto" placeholder="Ingrese el nombre de la denuncia" />
</div>
@@ -468,9 +499,9 @@ else
<h6 class="modal-section-heading">Nombre de los documentos</h6>
<div class="mb-3">
<input type="text" class="form-control" @bind="nombreDocumentos"
placeholder="Ej.: Gestión AN (Documento Adjunto 1 Gestión AN...)" />
placeholder="Ej.: Gesti<EFBFBD>n AN (Documento Adjunto 1 Gesti<EFBFBD>n AN...)" />
<small class="text-muted">
Se aplica al modo individual. <em>report.txt</em> se sube como <strong>Denuncia</strong> si entra en esta actualización.
Se aplica al modo individual. <em>report.txt</em> se sube como <strong>Denuncia</strong> si entra en esta actualizaci<EFBFBD>n.
</small>
</div>
@@ -478,7 +509,7 @@ else
<div class="form-check">
<input class="form-check-input" type="radio" name="uploadMode" id="modoMerge"
checked='@(uploadMode == "merge")' @onclick='() => uploadMode = "merge"' />
<label class="form-check-label" for="modoMerge">Unir todos los ficheros en un único PDF</label>
<label class="form-check-label" for="modoMerge">Unir todos los ficheros en un <EFBFBD>nico PDF</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="uploadMode" id="modoIndividual"
@@ -491,13 +522,13 @@ else
<input class="form-check-input" type="radio" name="selectedGroup" id="grupo600"
checked='@(selectedGroup == "600")' @onclick='() => selectedGroup = "600"' />
<label class="form-check-label" for="grupo600">
600. Asuntos Jurídicos y Protección a la Persona Denunciante
600. Asuntos Jur<EFBFBD>dicos y Protecci<EFBFBD>n a la Persona Denunciante
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="selectedGroup" id="grupo510"
checked='@(selectedGroup == "510")' @onclick='() => selectedGroup = "510"' />
<label class="form-check-label" for="grupo510">510. SDI Investigación Entradas</label>
<label class="form-check-label" for="grupo510">510. SDI <EFBFBD> Investigaci<EFBFBD>n Entradas</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="selectedGroup" id="grupo700"
@@ -511,13 +542,13 @@ else
<h6 class="modal-section-heading mt-3">Tercero (denunciante)</h6>
<div class="alert alert-light border mb-3">
Los datos del tercero se cargan automáticamente desde la denuncia y no se pueden editar aquí.
Los datos del tercero se cargan autom<EFBFBD>ticamente desde la denuncia y no se pueden editar aqu<EFBFBD>.
</div>
@if (modalThirdParty.IsAnonymous)
{
<div class="alert alert-warning mb-3">
Denuncia anónima. Se enlazará automáticamente el tercero <strong>00000000T</strong>.
Denuncia an<EFBFBD>nima. Se enlazar<EFBFBD> autom<EFBFBD>ticamente el tercero <strong>00000000T</strong>.
</div>
}
@@ -546,7 +577,7 @@ else
{
<div class="row g-2">
<div class="col-12 mb-2">
<label class="form-label">Razón social</label>
<label class="form-label">Raz<EFBFBD>n social</label>
<input class="form-control" value="@GetReadOnlyValue(selectedDenuncias.RazonSocialResuelta)" readonly />
</div>
</div>
@@ -559,11 +590,11 @@ else
<input class="form-control" value="@GetReadOnlyValue(selectedDenuncias.NombreResuelto)" readonly />
</div>
<div class="col-4 mb-2">
<label class="form-label">1º apellido</label>
<label class="form-label">1<EFBFBD> apellido</label>
<input class="form-control" value="@GetReadOnlyValue(selectedDenuncias.PrimerApellidoResuelto)" readonly />
</div>
<div class="col-4 mb-2">
<label class="form-label">2º apellido</label>
<label class="form-label">2<EFBFBD> apellido</label>
<input class="form-control" value="@GetReadOnlyValue(selectedDenuncias.SegundoApellidoResuelto)" readonly />
</div>
</div>
@@ -573,14 +604,14 @@ else
{
<div class="row g-2">
<div class="col-12 mb-2">
<label class="form-label">Dirección postal</label>
<label class="form-label">Direcci<EFBFBD>n postal</label>
<textarea class="form-control" rows="2" readonly>@BuildPostalAddressSummary(selectedDenuncias)</textarea>
</div>
</div>
}
<small class="text-muted">
Antes de subir la actualización se comprobará el tercero extraído del formulario y, si no está enlazado al expediente, se enlazará.
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>.
</small>
</div>
@@ -594,24 +625,16 @@ else
<div class="modal-backdrop fade show"></div>
}
@if (isUploading)
{
<div class="upload-overlay">
<div class="upload-box">
<div class="spinner-border" role="status" aria-hidden="true"></div>
<div class="mt-3 fw-semibold">Subiendo documentos…</div>
<div class="text-muted" style="font-size:.9rem">Por favor, espera</div>
</div>
</div>
}
@code {
private const string reportTxt = "report.txt";
private bool hasLoaded = false;
private string busqueda = "";
private List<DenunciasGestiona> actualizaciones = new();
private Dictionary<int, List<FicherosDenuncias>> ficherosAdjuntos = new();
private Dictionary<int, HashSet<string>> excludedUploadFiles = new();
private string loadError = string.Empty;
private string operationError = string.Empty;
private string operationNotice = string.Empty;
private DateOnly? externalUpdateCutoffDate;
// --- Modal / estado ---
private bool showModal = false;
@@ -623,7 +646,7 @@ else
private DenunciasGestiona? selectedDenuncias;
private ThirdPartyIdentityData? selectedThirdParty;
// --- Detección automática en Gestiona por asunto ---
// --- Detecci<EFBFBD>n autom<EFBFBD>tica en Gestiona por asunto ---
private bool autoSearchLoading = false;
private bool autoSearchTried = false;
private string? autoFoundFileUrl = null;
@@ -639,25 +662,32 @@ else
private async Task CargarDatosAsync()
{
var todas = await CargarDenunciasJsonAsync();
foreach (var d in todas.Where(x => x.ProcedureId == Guid.Empty))
try
{
d.ProcedureId = Guid.Parse("82722c9b-cecc-4299-8a7b-ce5abeb8170b");
d.GroupId = Guid.Parse("6dbfc433-1eb6-4b9a-a533-bfebc652c101");
loadError = string.Empty;
var config = await ApiDenuncias.GetAppConfigurationAsync();
externalUpdateCutoffDate = ParseConfiguredCutoffDate(config.ExternalUpdateCutoffDate);
var todas = await CargarDenunciasJsonAsync();
foreach (var d in todas.Where(x => x.ProcedureId == Guid.Empty))
{
d.ProcedureId = Guid.Parse("82722c9b-cecc-4299-8a7b-ce5abeb8170b");
d.GroupId = Guid.Parse("6dbfc433-1eb6-4b9a-a533-bfebc652c101");
}
actualizaciones = todas
.Where(d => d.EsActualizacion)
.OrderByDescending(d => d.FechaSubidaAGestiona != DateTime.MinValue ? d.FechaSubidaAGestiona : d.Fecha)
.ToList();
ficherosAdjuntos = await CargarFicherosPorDenunciaAsync(actualizaciones);
}
catch (Exception ex)
{
actualizaciones.Clear();
ficherosAdjuntos.Clear();
loadError = $"No se han podido cargar las actualizaciones: {ex.Message}";
}
actualizaciones = todas
.Where(d => d.EsActualizacion)
.OrderByDescending(d => d.FechaSubidaAGestiona != DateTime.MinValue ? d.FechaSubidaAGestiona : d.Fecha)
.ToList();
var listaF = await CargarFicherosJsonAsync();
var idsUpd = actualizaciones.Select(a => a.Id_Denuncia).ToHashSet();
ficherosAdjuntos = listaF
.Where(f => idsUpd.Contains(f.Id_Denuncia))
.GroupBy(f => f.Id_Denuncia)
.ToDictionary(g => g.Key, g => GetPendingUpdateFiles(g.ToList()));
hasLoaded = true;
StateHasChanged();
@@ -665,12 +695,24 @@ else
private async Task<List<DenunciasGestiona>> CargarDenunciasJsonAsync()
{
return await DenunciaStore.GetAllDenunciasAsync();
return await DenunciaStore.GetDenunciasByScopeAsync(DenunciaListScope.Updates);
}
private async Task<List<FicherosDenuncias>> CargarFicherosJsonAsync()
private async Task<Dictionary<int, List<FicherosDenuncias>>> CargarFicherosPorDenunciaAsync(IEnumerable<DenunciasGestiona> denuncias)
{
return await DenunciaStore.GetAllFicherosAsync();
var result = new Dictionary<int, List<FicherosDenuncias>>();
foreach (var denuncia in denuncias.Where(d => d.Id_Denuncia > 0).DistinctBy(d => d.Id_Denuncia))
{
var ficheros = GetPendingUpdateFilesForComplaint(
denuncia,
await DenunciaStore.GetFicherosByDenunciaAsync(denuncia.Id_Denuncia));
if (ficheros.Count > 0)
{
result[denuncia.Id_Denuncia] = ficheros;
}
}
return result;
}
private void OpenEnviarAGestionaModal(DenunciasGestiona d)
@@ -679,6 +721,8 @@ else
nuevoAsunto = string.IsNullOrWhiteSpace(d.NombreDenuncia) ? $"Denuncia-{d.Id_Denuncia}-CD" : d.NombreDenuncia;
nombreDocumentos = "";
selectedThirdParty = ThirdPartyIdentityData.FromComplaint(d);
operationError = string.Empty;
operationNotice = string.Empty;
autoFoundFileUrl = null;
autoFoundTitle = null;
@@ -693,7 +737,7 @@ else
try
{
// Búsqueda automática desactivada temporalmente
// B<EFBFBD>squeda autom<EFBFBD>tica desactivada temporalmente
}
catch
{
@@ -724,6 +768,7 @@ else
selectedThirdParty = null;
nuevoAsunto = "";
nombreDocumentos = "";
operationError = string.Empty;
autoFoundFileUrl = null;
autoFoundTitle = null;
autoSearchLoading = false;
@@ -810,24 +855,44 @@ else
try
{
isUploading = true;
operationError = string.Empty;
operationNotice = string.Empty;
using var busy = Busy.Show(
"Enviando actualizacion",
"Preparando expediente, carpeta de actualizacion y documentos.");
StateHasChanged();
await Task.Yield();
// 1) Ficheros a subir
var existentesF = await CargarFicherosJsonAsync();
var fDenuncia = GetPendingUpdateFiles(
existentesF
.Where(f => f.Id_Denuncia == selectedDenuncias.Id_Denuncia)
.ToList());
Busy.Update(message: "Cargando ficheros pendientes de esta actualizacion.", detail: "Paso 1 de 8");
var existentesF = await DenunciaStore.GetFicherosByDenunciaAsync(selectedDenuncias.Id_Denuncia);
var fDenuncia = GetPendingUpdateFilesForComplaint(selectedDenuncias, existentesF);
var ficherosNoSeleccionados = GetExcludedUploadFileNames(selectedDenuncias.Id_Denuncia, fDenuncia);
var ficherosSeleccionados = GetSelectedUploadFiles(selectedDenuncias.Id_Denuncia, fDenuncia);
var todos = new List<(string FileName, byte[] Content)>();
foreach (var f in fDenuncia)
foreach (var f in ficherosSeleccionados)
{
if (f.Fichero == null) continue;
todos.Add((f.NombreFichero ?? string.Empty, f.Fichero));
}
if (!todos.Any()) return;
var ficherosVacios = todos
.Where(t => t.Content.Length == 0)
.Select(t => string.IsNullOrWhiteSpace(t.FileName) ? "(sin nombre)" : t.FileName)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
todos = todos
.Where(t => t.Content.Length > 0)
.ToList();
if (!todos.Any())
{
operationError = ficherosVacios.Count == 0
? $"La denuncia #{selectedDenuncias.Id_Denuncia} no tiene ficheros pendientes para subir como actualizaci<63>n."
: $"La denuncia #{selectedDenuncias.Id_Denuncia} solo tiene ficheros vac<61>os y no se ha subido nada. Ficheros omitidos: {string.Join(", ", ficherosVacios)}.";
return;
}
// 2) Determinar expediente destino
string fileUrl;
@@ -843,6 +908,7 @@ else
}
else
{
Busy.Update(message: "Creando expediente nuevo en Gestiona.", detail: "Paso 2 de 8");
var createdFile = await ApiDenuncias.CreateGestionaFileAsync(
selectedDenuncias.ProcedureId,
nuevoAsunto,
@@ -850,6 +916,7 @@ else
"3109963"
);
fileUrl = createdFile.FileUrl;
Busy.Update(message: "Abriendo expediente y asignando el grupo elegido.", detail: "Paso 2 de 8");
await ApiDenuncias.OpenGestionaFileAsync(
fileUrl,
createdFile.FileOpenUrl,
@@ -869,14 +936,18 @@ else
selectedDenuncias.EnGestiona = true;
}
await SincronizarExpedienteGestionaAsync(selectedDenuncias, fileUrl);
Busy.Update(message: "Guardando referencia local del expediente.", detail: "Paso 3 de 8");
selectedDenuncias.Expediente_Gestiona = fileUrl;
selectedDenuncias.EnGestiona = true;
var thirdParty = selectedThirdParty ?? ThirdPartyIdentityData.FromComplaint(selectedDenuncias);
Busy.Update(message: "Resolviendo tercero y enlazandolo al expediente.", detail: "Paso 4 de 8");
await ApiDenuncias.EnsureGestionaThirdAndLinkAsync(fileUrl, thirdParty);
var ahoraUtc = DateTime.UtcNow;
var carpetaActualizacion = FixFileName($"Actualizacion {DateTime.Now:yyyy-MM-dd HH-mm-ss}");
Busy.Update(message: "Creando carpeta de actualizacion en Gestiona.", detail: "Paso 5 de 8");
var carpetaActualizacionGestiona = await ApiDenuncias.CreateGestionaFolderAsync(fileUrl, carpetaActualizacion);
var documentsTargetUrl = carpetaActualizacionGestiona.DocumentsTargetUrl;
var nombresOriginalesSubidos = new List<string>();
@@ -888,6 +959,7 @@ else
if (!string.IsNullOrWhiteSpace(report.FileName))
{
Busy.Update(message: "Preparando y subiendo el report para firma.", detail: "Paso 6 de 8");
var reportPdfBytes = PdfHelper.MergeFilesToPdf(
new (string FileName, byte[] Content)[] { (report.FileName, report.Content) });
var reportFinalName = FixFileName("Denuncia.pdf");
@@ -905,6 +977,7 @@ else
if (adjuntos.Count > 0 && uploadMode == "merge")
{
Busy.Update(message: "Uniendo adjuntos nuevos en un unico PDF y subiendolo.", detail: "Paso 7 de 8");
var pdfBytes = PdfHelper.MergeFilesToPdf(adjuntos);
var pdfName = FixFileName($"Adjuntos {selectedDenuncias.Id_Denuncia}_{ahoraUtc:yyyyMMddHHmmss}.pdf");
var docUrl = await ApiDenuncias.UploadGestionaDocumentAsync(documentsTargetUrl, pdfBytes, pdfName);
@@ -927,6 +1000,12 @@ else
foreach (var t in adjuntos)
{
Busy.Update(
message: $"Subiendo adjunto nuevo {i} de {adjuntos.Count}.",
detail: "Paso 7 de 8",
current: i,
total: adjuntos.Count);
var origName = t.FileName;
var content = t.Content;
var ext = Path.GetExtension(origName).ToLowerInvariant();
@@ -961,24 +1040,24 @@ else
if (!string.IsNullOrWhiteSpace(documentoParaTramitar))
{
Busy.Update(message: "Enviando el report al circuito de firma.", detail: "Paso 8 de 8");
await ApiDenuncias.TramitarGestionaDocumentAsync(
documentoParaTramitar,
GetAssignedGroupLinkBySelectedGroup(),
selectedDenuncias.Id_Denuncia);
}
var ficherosAll = await CargarFicherosJsonAsync();
foreach (var orig in nombresOriginalesSubidos)
{
var f = ficherosAll.FirstOrDefault(x =>
x.Id_Denuncia == selectedDenuncias.Id_Denuncia &&
x.NombreFichero == orig);
var f = existentesF.FirstOrDefault(x => x.NombreFichero == orig);
if (f != null)
{
f.Subido = true;
f.FechaSubida = ahoraUtc;
}
}
Busy.Update(message: "Actualizando trazabilidad en la base de datos.", detail: "Finalizando");
await DenunciaStore.MarkFicherosAsUploadedAsync(
selectedDenuncias.Id_Denuncia,
nombresOriginalesSubidos,
@@ -989,13 +1068,30 @@ else
selectedDenuncias.FechaSubidaAGestiona = ahoraUtc;
await ActualizarDenunciaAsync(selectedDenuncias);
actualizaciones.RemoveAll(x => x.Id_Denuncia == selectedDenuncias.Id_Denuncia);
var denunciaProcesadaId = selectedDenuncias.Id_Denuncia;
actualizaciones.RemoveAll(x => x.Id_Denuncia == denunciaProcesadaId);
excludedUploadFiles.Remove(denunciaProcesadaId);
CloseModal();
var avisos = new List<string>();
if (ficherosVacios.Count > 0)
{
avisos.Add($"Se omitieron ficheros vac<61>os: {string.Join(", ", ficherosVacios)}.");
}
if (ficherosNoSeleccionados.Count > 0)
{
avisos.Add($"No se subieron por selecci<63>n del usuario: {string.Join(", ", ficherosNoSeleccionados)}.");
}
if (avisos.Count > 0)
{
operationNotice = $"Actualizaci<63>n #{denunciaProcesadaId} completada. {string.Join(" ", avisos)}";
}
StateHasChanged();
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error al confirmar envío (Actualizaciones): {ex}");
Console.Error.WriteLine($"Error al confirmar env<EFBFBD>o (Actualizaciones): {ex}");
operationError = $"No se ha podido completar la actualizaci<63>n #{selectedDenuncias?.Id_Denuncia}: {ex.Message}";
}
finally
{
@@ -1004,6 +1100,71 @@ else
}
}
private bool IsFileSelectedForUpload(int denunciaId, string? fileName)
{
if (IsReportFileName(fileName))
{
return true;
}
return string.IsNullOrWhiteSpace(fileName) ||
!excludedUploadFiles.TryGetValue(denunciaId, out var excluded) ||
!excluded.Contains(fileName);
}
private void ToggleFileSelection(int denunciaId, string? fileName, bool selected)
{
if (string.IsNullOrWhiteSpace(fileName) || IsReportFileName(fileName))
{
return;
}
if (!excludedUploadFiles.TryGetValue(denunciaId, out var excluded))
{
excluded = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
excludedUploadFiles[denunciaId] = excluded;
}
if (selected)
{
excluded.Remove(fileName);
if (excluded.Count == 0)
{
excludedUploadFiles.Remove(denunciaId);
}
}
else
{
excluded.Add(fileName);
}
}
private List<FicherosDenuncias> GetSelectedUploadFiles(int denunciaId, IEnumerable<FicherosDenuncias> files)
{
return files
.Where(file => IsReportFileName(file.NombreFichero) || IsFileSelectedForUpload(denunciaId, file.NombreFichero))
.ToList();
}
private List<string> GetExcludedUploadFileNames(int denunciaId, IEnumerable<FicherosDenuncias> files)
{
return files
.Where(file => !IsReportFileName(file.NombreFichero) && !IsFileSelectedForUpload(denunciaId, file.NombreFichero))
.Select(file => string.IsNullOrWhiteSpace(file.NombreFichero) ? "(sin nombre)" : file.NombreFichero)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(name => name, StringComparer.OrdinalIgnoreCase)
.ToList();
}
private static string BuildAttachmentContentUrl(int denunciaId, string? fileName)
{
return $"/api/denuncias/{denunciaId}/ficheros/content?fileName={Uri.EscapeDataString(fileName ?? string.Empty)}";
}
private static string FormatFileDate(DateTime date)
=> date == DateTime.MinValue
? "-"
: date.ToLocalTime().ToString("dd/MM/yyyy HH:mm", CultureInfo.InvariantCulture);
private string GetContentType(string fileName)
{
return Path.GetExtension(fileName).ToLowerInvariant() switch
@@ -1025,7 +1186,14 @@ else
}
private static string GetReadOnlyValue(string? value) =>
string.IsNullOrWhiteSpace(value) ? "" : value;
string.IsNullOrWhiteSpace(value) ? "<EFBFBD>" : value;
private static bool IsExternalGestionaUpdate(DenunciasGestiona denuncia)
{
return denuncia.EsActualizacion &&
denuncia.EnGestiona &&
denuncia.FechaSubidaAGestiona == DateTime.MinValue;
}
private static bool HasPostalAddress(DenunciasGestiona denuncia)
{
@@ -1084,7 +1252,16 @@ else
return string.Join(" | ", parts);
}
private static List<FicherosDenuncias> GetPendingUpdateFiles(List<FicherosDenuncias> files)
private List<FicherosDenuncias> GetPendingUpdateFilesForComplaint(DenunciasGestiona denuncia, List<FicherosDenuncias> files)
{
var cutoffDate = IsExternalGestionaUpdate(denuncia)
? externalUpdateCutoffDate
: null;
return GetPendingUpdateFiles(files, cutoffDate);
}
private static List<FicherosDenuncias> GetPendingUpdateFiles(List<FicherosDenuncias> files, DateOnly? externalUpdateCutoffDate = null)
{
var uploadedHashes = files
.Where(file => file.Subido && !file.EsReport && !string.IsNullOrWhiteSpace(file.ContentSha256))
@@ -1107,6 +1284,11 @@ else
.Where(file => !file.EsReport && !file.Subido)
.OrderBy(file => file.NombreFichero, StringComparer.OrdinalIgnoreCase))
{
if (externalUpdateCutoffDate.HasValue && !IsFileAfterCutoff(file, externalUpdateCutoffDate.Value))
{
continue;
}
if (string.IsNullOrWhiteSpace(file.ContentSha256))
{
pending.Add(file);
@@ -1122,6 +1304,43 @@ else
return pending;
}
private static bool IsFileAfterCutoff(FicherosDenuncias file, DateOnly cutoffDate)
{
if (file.Fecha == DateTime.MinValue)
{
return true;
}
return DateOnly.FromDateTime(file.Fecha) > cutoffDate;
}
private static DateOnly? ParseConfiguredCutoffDate(string? value)
{
return DateOnly.TryParseExact(
value,
"yyyy-MM-dd",
CultureInfo.InvariantCulture,
DateTimeStyles.None,
out var parsed)
? parsed
: null;
}
private string GetExternalUpdateWarningText()
{
const string prefix = "Esta actualizacion corresponde a una denuncia que ya existia en Gestiona, pero no consta como subida desde esta app.";
return externalUpdateCutoffDate.HasValue
? $"{prefix} Como no tenemos hashes previos, solo se propondran como nuevos los adjuntos con fecha posterior a {externalUpdateCutoffDate.Value:dd/MM/yyyy}."
: $"{prefix} Configura una fecha de corte para que los adjuntos antiguos no aparezcan como nuevos.";
}
private string GetExternalUpdateFileReason()
{
return externalUpdateCutoffDate.HasValue
? $"Posterior al corte {externalUpdateCutoffDate.Value:dd/MM/yyyy}"
: "Sin historico local";
}
private string GetAssignedGroupLinkBySelectedGroup()
{
return selectedGroup switch

View File

@@ -0,0 +1,320 @@
@page "/Configuracion"
@rendermode InteractiveServer
@attribute [Authorize]
@using System.Globalization
@using GestionaDenunciasAN.Services
@inject ApiDenunciasClient ApiDenuncias
@inject UiBusyService Busy
<PageTitle>Configuracion</PageTitle>
<h3>Configuracion</h3>
<div class="card mt-3">
<div class="card-body">
<h5 class="mb-3">Fecha de actualizaciones externas</h5>
<div class="row g-3 align-items-start">
<div class="col-12 col-lg-4">
<label class="form-label" for="external-update-cutoff">Fecha de corte</label>
<input id="external-update-cutoff"
class="form-control"
type="text"
inputmode="numeric"
maxlength="10"
placeholder="dd/mm/aaaa"
@bind="externalUpdateCutoffDate"
@bind:event="oninput"
disabled="@isSavingConfiguration" />
<div class="form-text">Formato: dd/mm/aaaa. Ejemplo: 05/06/2026.</div>
</div>
<div class="col-12 col-lg-auto pt-lg-4">
<div class="d-flex flex-wrap gap-2">
<button type="button"
class="btn btn-primary"
disabled="@isSavingConfiguration"
@onclick="SaveExternalUpdateCutoffDateAsync">
@if (isSavingConfiguration)
{
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
<span class="ms-2">Guardando</span>
}
else
{
<span class="bi bi-floppy" aria-hidden="true"></span>
<span class="ms-1">Guardar fecha</span>
}
</button>
<button type="button"
class="btn btn-outline-secondary"
disabled="@isSavingConfiguration || string.IsNullOrWhiteSpace(externalUpdateCutoffDate)"
@onclick="ClearExternalUpdateCutoffDateAsync">
Limpiar
</button>
</div>
</div>
</div>
<div class="alert alert-info mt-3 mb-0">
La comparacion por hash se mantiene igual. Esta fecha solo se usa cuando la actualizacion pertenece a un expediente que ya estaba en Gestiona y no tenemos hashes previos en esta BBDD: en ese caso se propondran como nuevos solo los adjuntos posteriores a la fecha de corte.
</div>
@if (!string.IsNullOrWhiteSpace(configurationNotice))
{
<div class="alert alert-success mt-3 mb-0">@configurationNotice</div>
}
@if (!string.IsNullOrWhiteSpace(configurationError))
{
<div class="alert alert-danger mt-3 mb-0">@configurationError</div>
}
</div>
</div>
<div class="card mt-3">
<div class="card-body">
<h5 class="mb-3">Purga manual con reemplazo</h5>
<div class="alert alert-danger">
<div class="d-flex gap-3">
<span class="bi bi-exclamation-triangle-fill fs-3" aria-hidden="true"></span>
<div>
<strong>Operacion critica e irreversible.</strong>
<div>
Esta accion llama a la Function App de claves para purgar criptograficamente la clave diaria actual y crear una clave nueva inmediatamente.
Las denuncias y adjuntos cifrados antes de la purga dejaran de poder abrirse.
</div>
</div>
</div>
</div>
<div class="row g-3">
<div class="col-12">
<label class="form-label">Alcance</label>
<div class="border rounded px-3 py-2 bg-light">
<span class="bi bi-key me-2" aria-hidden="true"></span>
<strong>Clave diaria actual</strong>
</div>
</div>
<div class="col-12 col-lg-7">
<label class="form-label" for="purge-confirmation">Confirmacion escrita</label>
<input id="purge-confirmation"
class="form-control"
placeholder="@RequiredConfirmation"
@bind="confirmation"
@bind:event="oninput"
disabled="@isExecutingPurge" />
<div class="form-text">
Escribe exactamente: <code>@RequiredConfirmation</code>
</div>
</div>
</div>
<div class="form-check mt-3">
<input id="purge-risk"
class="form-check-input"
type="checkbox"
@bind="acceptedRisk"
disabled="@isExecutingPurge" />
<label class="form-check-label" for="purge-risk">
Entiendo que esta purga dejara ilegibles las denuncias cifradas antes del reemplazo de clave.
</label>
</div>
<div class="d-flex justify-content-start mt-3">
<button type="button"
class="btn btn-danger"
disabled="@(!CanExecutePurge)"
@onclick="ExecutePurgeAsync">
@if (isExecutingPurge)
{
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
<span class="ms-2">Ejecutando purga y reemplazo</span>
}
else
{
<span class="bi bi-trash" aria-hidden="true"></span>
<span class="ms-1">Ejecutar purga y reemplazo</span>
}
</button>
</div>
<div class="alert alert-warning mt-3 mb-0">
La API calculara la fecha actual de la clave diaria, obtendra la Function Key desde <code>KEYMGMT_FUNCTION_KEY</code> o desde el secreto <code>purge-function-key</code> de Key Vault y enviara la peticion a <code>manual_purge</code> con <code>replace: true</code>. Si la purga se completa pero falla la nueva clave, reintentara automaticamente con <code>force_rotate</code>.
</div>
</div>
</div>
@if (purgeResult is not null)
{
<div class="alert alert-success mt-3">
<strong>Purga con reemplazo solicitada correctamente para @purgeResult.Date.</strong>
<div>Function App respondio con codigo @purgeResult.StatusCode.</div>
@if (!string.IsNullOrWhiteSpace(purgeResult.ResponseBody))
{
<pre class="mt-2 mb-0 small">@purgeResult.ResponseBody</pre>
}
</div>
}
@if (!string.IsNullOrWhiteSpace(purgeErrorMessage))
{
<div class="alert alert-danger mt-3">
<strong>No se ha podido ejecutar la purga.</strong>
<div>@purgeErrorMessage</div>
</div>
}
@code {
private const string RequiredConfirmation = "PURGAR CLAVE ACTUAL";
private string externalUpdateCutoffDate = string.Empty;
private string? configurationNotice;
private string? configurationError;
private bool isSavingConfiguration;
private string confirmation = string.Empty;
private bool acceptedRisk;
private bool isExecutingPurge;
private ManualPurgeResponse? purgeResult;
private string? purgeErrorMessage;
private bool CanExecutePurge =>
acceptedRisk &&
!isExecutingPurge &&
string.Equals(confirmation.Trim(), RequiredConfirmation, StringComparison.Ordinal);
protected override async Task OnInitializedAsync()
{
await LoadConfigurationAsync();
}
private async Task LoadConfigurationAsync()
{
try
{
configurationError = null;
var config = await ApiDenuncias.GetAppConfigurationAsync();
externalUpdateCutoffDate = ToSpanishDateText(config.ExternalUpdateCutoffDate);
}
catch (Exception ex)
{
configurationError = $"No se ha podido cargar la configuracion: {ex.Message}";
}
}
private async Task SaveExternalUpdateCutoffDateAsync()
{
try
{
isSavingConfiguration = true;
configurationNotice = null;
configurationError = null;
var date = ToIsoDateText(externalUpdateCutoffDate);
var config = await ApiDenuncias.UpdateExternalUpdateCutoffDateAsync(date);
externalUpdateCutoffDate = ToSpanishDateText(config.ExternalUpdateCutoffDate);
configurationNotice = string.IsNullOrWhiteSpace(externalUpdateCutoffDate)
? "Fecha de corte eliminada."
: $"Fecha de corte guardada: {externalUpdateCutoffDate}.";
}
catch (Exception ex)
{
configurationError = $"No se ha podido guardar la fecha de corte: {ex.Message}";
}
finally
{
isSavingConfiguration = false;
}
}
private async Task ClearExternalUpdateCutoffDateAsync()
{
externalUpdateCutoffDate = string.Empty;
await SaveExternalUpdateCutoffDateAsync();
}
private static string? ToIsoDateText(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
var trimmed = value.Trim();
if (DateOnly.TryParseExact(
trimmed,
"dd/MM/yyyy",
CultureInfo.InvariantCulture,
DateTimeStyles.None,
out var spanishDate))
{
return spanishDate.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
}
if (DateOnly.TryParseExact(
trimmed,
"yyyy-MM-dd",
CultureInfo.InvariantCulture,
DateTimeStyles.None,
out var isoDate))
{
return isoDate.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
}
throw new InvalidOperationException("La fecha de corte debe tener formato dd/mm/aaaa. Ejemplo: 05/06/2026.");
}
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 async Task ExecutePurgeAsync()
{
if (!CanExecutePurge)
{
return;
}
isExecutingPurge = true;
purgeResult = null;
purgeErrorMessage = null;
using var busy = Busy.Show(
"Ejecutando purga manual",
"Solicitando la purga criptografica y la creacion inmediata de una nueva clave diaria.");
try
{
purgeResult = await ApiDenuncias.ExecuteCurrentManualPurgeAsync();
acceptedRisk = false;
confirmation = string.Empty;
}
catch (Exception ex)
{
purgeErrorMessage = ex.Message;
}
finally
{
isExecutingPurge = false;
}
}
}

View File

@@ -1,13 +1,80 @@
@page "/GestionZip"
@rendermode @(new InteractiveServerRenderMode(prerender: false))
@attribute [Authorize]
@implements IAsyncDisposable
@using System.Globalization
@using GestionaDenunciasAN.Models
@inject AuthenticationStateProvider AuthenticationStateProvider
@inject ApiDenunciasClient ApiDenuncias
@inject IJSRuntime JSRuntime
@inject UiBusyService Busy
<PageTitle>Entrada de denuncias</PageTitle>
<style>
.report-detail-overlay {
position: fixed;
inset: 0;
z-index: 4500;
display: flex;
align-items: center;
justify-content: center;
padding: clamp(1rem, 3vw, 2rem);
background: rgba(6, 22, 41, 0.62);
overflow: hidden;
}
.report-detail-dialog {
width: min(820px, 100%);
max-height: min(88vh, 860px);
display: flex;
}
.report-detail-card {
width: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
border: 1px solid rgba(215, 228, 241, 0.92);
border-radius: 22px;
background: #ffffff;
box-shadow: 0 28px 90px rgba(6, 22, 41, 0.38);
color: var(--app-ink, #12395f);
}
.report-detail-header,
.report-detail-footer {
flex: 0 0 auto;
background: #ffffff;
}
.report-detail-header {
display: flex;
justify-content: space-between;
gap: 1rem;
padding: 1.25rem 1.35rem 1rem;
border-bottom: 1px solid var(--app-border, #d7e4f1);
}
.report-detail-body {
flex: 1 1 auto;
overflow-y: auto;
padding: 1rem 1.35rem;
background: #ffffff;
}
.report-detail-footer {
display: flex;
justify-content: flex-end;
padding: 1rem 1.35rem;
border-top: 1px solid var(--app-border, #d7e4f1);
}
.report-detail-close {
flex: 0 0 auto;
}
</style>
<div class="container py-4">
<div class="d-flex flex-column flex-lg-row justify-content-between align-items-lg-center gap-3 mb-4">
<div>
@@ -53,11 +120,12 @@
@bind="RenewAuthcode"
maxlength="6"
inputmode="numeric"
disabled="@(!RenewPrepared || RenewBusy)"
placeholder="123456" />
</div>
<button type="button" class="btn btn-primary w-100" @onclick="RenewSessionAsync" disabled="@RenewBusy">
@(RenewBusy ? "Renovando..." : SessionRenewButtonText)
@(RenewBusy ? SessionRenewBusyText : SessionRenewButtonText)
</button>
</div>
</div>
@@ -166,13 +234,14 @@
<th>Ultima actualizacion</th>
<th>Estado</th>
<th>Seguimiento</th>
<th style="width: 7rem;">Detalle</th>
</tr>
</thead>
<tbody>
@if (!CanUseGlobalLeaks)
{
<tr>
<td colspan="7" class="text-muted">
<td colspan="8" class="text-muted">
Renueva la sesion de GlobalLeaks con un 2FA valido para cargar la bandeja.
</td>
</tr>
@@ -180,13 +249,13 @@
else if (ReportsBusy)
{
<tr>
<td colspan="7" class="text-muted">Cargando denuncias...</td>
<td colspan="8" class="text-muted">Cargando denuncias...</td>
</tr>
}
else if (!VisibleReports.Any())
{
<tr>
<td colspan="7" class="text-muted">No hay denuncias con los filtros actuales.</td>
<td colspan="8" class="text-muted">No hay denuncias con los filtros actuales.</td>
</tr>
}
else
@@ -217,6 +286,15 @@
<div class="small text-muted mt-1">@report.TrackingNote</div>
}
</td>
<td>
<button type="button"
class="btn btn-outline-secondary btn-sm"
title="Consulta mensajes y ficheros. GlobalLeaks puede marcar la denuncia como leida."
@onclick="@(() => OpenReportDetailAsync(report))"
disabled="@DetailBusy">
Ver detalle
</button>
</td>
</tr>
}
}
@@ -229,6 +307,117 @@
</div>
</div>
@if (DetailModalVisible)
{
<div class="report-detail-overlay" role="dialog" aria-modal="true" aria-labelledby="report-detail-title">
<div class="report-detail-dialog">
<div class="report-detail-card">
<div class="report-detail-header">
<div>
<h5 id="report-detail-title" class="mb-0">Contenido de denuncia #@(DetailReport?.Progressive ?? 0)</h5>
<small class="text-muted">Mensajes y ficheros comparados contra el ultimo acceso registrado.</small>
</div>
<button type="button" class="btn-close report-detail-close" aria-label="Cerrar" @onclick="CloseReportDetail"></button>
</div>
<div class="report-detail-body">
<div class="alert alert-warning small">
Esta consulta abre el detalle en GlobalLeaks y puede marcar la denuncia como leida.
</div>
@if (DetailBusy)
{
<div class="text-muted">Cargando detalle de GlobalLeaks...</div>
}
else if (!string.IsNullOrWhiteSpace(DetailError))
{
<div class="alert alert-danger">@DetailError</div>
}
else if (DetailData is not null)
{
<p class="small text-muted">
Ultimo acceso previo:
@(string.IsNullOrWhiteSpace(DetailData.LastAccess) ? "Sin acceso previo" : FormatDate(DetailData.LastAccess)).
Los elementos posteriores se marcan como nuevos.
</p>
<h6 class="text-uppercase text-muted small fw-bold mt-4">Mensajes (@DetailData.Comments.Count)</h6>
@if (DetailData.Comments.Count == 0)
{
<p class="text-muted small">Sin mensajes.</p>
}
else
{
@foreach (var comment in DetailData.Comments)
{
<div class="border rounded p-3 mb-2 @(comment.IsNew ? "border-success bg-success-subtle" : "bg-light")">
<div class="d-flex flex-column flex-md-row justify-content-between gap-2 small text-muted mb-2">
<strong>@GetCommentAuthorLabel(comment.Type)</strong>
<span>@FormatDate(comment.CreationDate)</span>
</div>
@if (comment.IsNew)
{
<span class="badge bg-success mb-2">Nuevo</span>
}
<div style="white-space: pre-wrap;">@(string.IsNullOrWhiteSpace(comment.Content) ? "-" : comment.Content)</div>
</div>
}
}
<h6 class="text-uppercase text-muted small fw-bold mt-4">Ficheros del denunciante (@DetailData.WhistleblowerFiles.Count)</h6>
@if (DetailData.WhistleblowerFiles.Count == 0)
{
<p class="text-muted small">Sin ficheros.</p>
}
else
{
@foreach (var file in DetailData.WhistleblowerFiles)
{
<div class="border rounded p-3 mb-2 @(file.IsNew ? "border-success bg-success-subtle" : "bg-light")">
<div class="d-flex flex-column flex-md-row justify-content-between gap-2">
<strong>@(string.IsNullOrWhiteSpace(file.Name) ? "Fichero sin nombre" : file.Name)</strong>
<span class="small text-muted">@FormatDate(file.CreationDate)</span>
</div>
<div class="small text-muted">@FormatBytes(file.Size) @(string.IsNullOrWhiteSpace(file.ContentType) ? string.Empty : $" - {file.ContentType}")</div>
@if (file.IsNew)
{
<span class="badge bg-success mt-2">Nuevo</span>
}
</div>
}
}
<h6 class="text-uppercase text-muted small fw-bold mt-4">Ficheros internos/receptor (@DetailData.ReceiverFiles.Count)</h6>
@if (DetailData.ReceiverFiles.Count == 0)
{
<p class="text-muted small">Sin ficheros.</p>
}
else
{
@foreach (var file in DetailData.ReceiverFiles)
{
<div class="border rounded p-3 mb-2 @(file.IsNew ? "border-success bg-success-subtle" : "bg-light")">
<div class="d-flex flex-column flex-md-row justify-content-between gap-2">
<strong>@(string.IsNullOrWhiteSpace(file.Name) ? "Fichero sin nombre" : file.Name)</strong>
<span class="small text-muted">@FormatDate(file.CreationDate)</span>
</div>
<div class="small text-muted">@FormatBytes(file.Size) @(string.IsNullOrWhiteSpace(file.ContentType) ? string.Empty : $" - {file.ContentType}")</div>
@if (file.IsNew)
{
<span class="badge bg-success mt-2">Nuevo</span>
}
</div>
}
}
}
</div>
<div class="report-detail-footer">
<button type="button" class="btn btn-secondary" @onclick="CloseReportDetail">Cerrar</button>
</div>
</div>
</div>
</div>
}
@code {
private readonly List<ContextDto> Contexts = [];
private readonly List<ReportDto> Reports = [];
@@ -239,6 +428,7 @@
private InboxUserState UserInboxState = new();
private string CurrentUsername { get; set; } = string.Empty;
private string RenewAuthcode { get; set; } = string.Empty;
private string RenewPendingLoginId { get; set; } = string.Empty;
private string Filter { get; set; } = "all";
private string DateScope { get; set; } = "all";
private string DateFrom { get; set; } = string.Empty;
@@ -252,8 +442,15 @@
private bool ReportsBusy { get; set; }
private bool RenewBusy { get; set; }
private bool ImportBusy { get; set; }
private bool DetailBusy { get; set; }
private bool DetailModalVisible { get; set; }
private string DetailError { get; set; } = string.Empty;
private ReportDto? DetailReport { get; set; }
private ReportDetailDto? DetailData { get; set; }
private readonly Dictionary<string, ReportDetailDto> DetailCache = new(StringComparer.OrdinalIgnoreCase);
private bool CanUseGlobalLeaks => SessionInfo?.HasActiveSession == true;
private bool RenewPrepared => !string.IsNullOrWhiteSpace(RenewPendingLoginId);
private int SelectedReportsCount => SelectedIds.Count;
private bool CanImportSelected => CanUseGlobalLeaks && SelectedReportsCount > 0 && !ImportBusy;
private string SessionStatusText => SessionInfo is null
@@ -261,9 +458,12 @@
: SessionInfo.HasActiveSession
? "Activa"
: "Pendiente de renovacion 2FA";
private string SessionRenewButtonText => SessionInfo?.HasActiveSession == true
? "Renovar sesion"
: "Activar sesion";
private string SessionRenewButtonText => RenewPrepared
? "Validar 2FA y activar"
: "Preparar renovacion";
private string SessionRenewBusyText => RenewPrepared
? "Validando 2FA..."
: "Preparando...";
private string SelectAllLabel => VisibleReports.Count > 0 && VisibleReports.All(report => SelectedIds.Contains(report.Id))
? "Deseleccionar todas"
: "Seleccionar todas";
@@ -292,6 +492,12 @@
SessionInfo = string.IsNullOrWhiteSpace(CurrentUsername)
? null
: await ApiDenuncias.GetGlobalLeaksSessionAsync();
if (CanUseGlobalLeaks)
{
RenewPendingLoginId = string.Empty;
RenewAuthcode = string.Empty;
}
}
private async Task RenewSessionAsync()
@@ -302,6 +508,12 @@
return;
}
if (!RenewPrepared)
{
await PrepareRenewSessionAsync();
return;
}
if (string.IsNullOrWhiteSpace(RenewAuthcode) || RenewAuthcode.Trim().Length != 6)
{
SetStatus("Introduce un codigo 2FA valido de 6 digitos.", "alert-warning");
@@ -311,8 +523,17 @@
RenewBusy = true;
try
{
SessionInfo = await ApiDenuncias.RenewGlobalLeaksSessionAsync(RenewAuthcode.Trim(), CancellationToken.None);
using var busy = Busy.Show(
"Validando 2FA",
"Completando la renovacion con el codigo actual de GlobalLeaks.");
SessionInfo = await ApiDenuncias.RenewGlobalLeaksSessionAsync(
RenewAuthcode.Trim(),
RenewPendingLoginId,
CancellationToken.None);
RenewAuthcode = string.Empty;
RenewPendingLoginId = string.Empty;
Busy.Update(message: "Sesion renovada. Actualizando la bandeja de entrada.");
await LoadReportsAsync();
SetStatus("Sesion de GlobalLeaks renovada correctamente.", "alert-success");
}
@@ -326,6 +547,30 @@
}
}
private async Task PrepareRenewSessionAsync()
{
RenewBusy = true;
try
{
using var busy = Busy.Show(
"Preparando renovacion",
"Validando credenciales guardadas y preparando GlobalLeaks antes de pedir el codigo 2FA.");
var prepared = await ApiDenuncias.PrepareGlobalLeaksSessionRenewalAsync(CancellationToken.None);
RenewPendingLoginId = prepared.PendingLoginId;
RenewAuthcode = string.Empty;
SetStatus("Renovacion preparada. Introduce ahora el codigo 2FA que este activo.", "alert-info");
}
catch (Exception ex)
{
SetStatus(ex.Message, "alert-danger");
}
finally
{
RenewBusy = false;
}
}
private async Task LoadReportsAsync()
{
if (!CanUseGlobalLeaks)
@@ -336,6 +581,10 @@
ReportsBusy = true;
try
{
using var busy = Busy.Show(
"Actualizando bandeja",
"Consultando GlobalLeaks y cruzando el seguimiento con la base de datos.");
var inbox = await ApiDenuncias.LoadInboxAsync(CancellationToken.None);
Contexts.Clear();
@@ -343,6 +592,7 @@
Reports.Clear();
Reports.AddRange(inbox.Reports);
DetailCache.Clear();
UserInboxState = inbox.UserState;
ApplyFilters();
}
@@ -375,6 +625,7 @@
ImportBusy = true;
var importedCount = 0;
var errors = new List<string>();
var importWarnings = new List<string>();
try
{
@@ -383,13 +634,30 @@
.OrderBy(report => report.Progressive ?? 0)
.ToList();
using var busy = Busy.Show(
"Importando denuncias",
$"Descargando y procesando {selectedReports.Count} denuncia(s) desde GlobalLeaks.",
selectedReports.Count);
var processed = 0;
foreach (var report in selectedReports)
{
processed++;
Busy.Update(
message: $"Procesando denuncia #{report.Progressive ?? 0}.",
detail: "Descarga, analisis del report y guardado seguro en la base de datos.",
current: processed,
total: selectedReports.Count);
try
{
var result = await ApiDenuncias.ImportReportAsync(report, CancellationToken.None);
importedCount += result.ImportedCount;
errors.AddRange(result.Errors.Select(error => $"#{report.Progressive ?? 0}: {error}"));
if (result.Warnings is not null)
{
importWarnings.AddRange(result.Warnings.Select(warning => $"#{report.Progressive ?? 0}: {warning}"));
}
}
catch (UnauthorizedAccessException ex)
{
@@ -411,6 +679,7 @@
.Select(report => $"#{report.Progressive ?? 0}: {report.TrackingNote}")
.Where(message => !string.IsNullOrWhiteSpace(message))
.ToList();
warnings.AddRange(importWarnings);
if (errors.Count == 0 && warnings.Count == 0)
{
@@ -442,6 +711,76 @@
}
}
private async Task OpenReportDetailAsync(ReportDto report)
{
if (!CanUseGlobalLeaks)
{
SetStatus("Renueva antes la sesion de GlobalLeaks para consultar el detalle.", "alert-warning");
return;
}
DetailModalVisible = true;
DetailReport = report;
DetailData = null;
DetailError = string.Empty;
DetailBusy = true;
await SetBodyScrollLockAsync(true);
try
{
using var busy = Busy.Show(
"Leyendo detalle de denuncia",
"Consultando mensajes y ficheros para identificar que contenido es nuevo.");
if (!DetailCache.TryGetValue(report.Id, out var cachedDetail))
{
cachedDetail = await ApiDenuncias.GetReportDetailAsync(report.Id, report.LastAccess, CancellationToken.None);
DetailCache[report.Id] = cachedDetail;
}
DetailData = cachedDetail;
}
catch (UnauthorizedAccessException ex)
{
await LoadSessionStateAsync();
DetailError = ex.Message;
}
catch (Exception ex)
{
DetailError = ex.Message;
}
finally
{
DetailBusy = false;
}
}
private async Task CloseReportDetail()
{
DetailModalVisible = false;
DetailReport = null;
DetailData = null;
DetailError = string.Empty;
await SetBodyScrollLockAsync(false);
}
public async ValueTask DisposeAsync()
{
await SetBodyScrollLockAsync(false);
}
private async Task SetBodyScrollLockAsync(bool locked)
{
try
{
await JSRuntime.InvokeVoidAsync("appSetBodyScrollLock", locked);
}
catch
{
// Si el circuito se esta cerrando, solo aseguramos que no reviente el componente.
}
}
private void ApplyFilters()
{
IEnumerable<ReportDto> filtered = Reports;
@@ -652,6 +991,34 @@
: "-";
}
private static string FormatBytes(long? value)
{
if (value is null or <= 0)
{
return "Tamano no disponible";
}
var bytes = value.Value;
if (bytes < 1024)
{
return $"{bytes} B";
}
if (bytes < 1024 * 1024)
{
return $"{bytes / 1024d:0.#} KB";
}
return $"{bytes / 1024d / 1024d:0.#} MB";
}
private static string GetCommentAuthorLabel(string? type)
=> string.Equals(type, "whistleblower", StringComparison.OrdinalIgnoreCase)
? "Denunciante"
: string.Equals(type, "receiver", StringComparison.OrdinalIgnoreCase)
? "Receptor"
: "Comentario";
private static DateTimeOffset? GetEffectiveMoment(ReportDto report)
{
return ParseDate(report.UpdateDate) ?? ParseDate(report.CreationDate);

View File

@@ -9,7 +9,6 @@
@inject NavigationManager Navigation
@inject IHostEnvironment HostEnvironment
@inject IDenunciaStore DenunciaStore
@inject ApiDenunciasClient ApiDenuncias
<PageTitle>Denuncias Gesti<74>n</PageTitle>
@@ -269,8 +268,7 @@ else
<td>@fichero.NombreFichero</td>
<td>@fichero.Fichero.Length</td>
<td>
<a class="btn btn-primary btn-sm" href="#"
onclick="openFile(event, '@Convert.ToBase64String(fichero.Fichero)', '@GetContentType(fichero.NombreFichero)');">
<a class="btn btn-primary btn-sm" href="@BuildAttachmentContentUrl(denuncia.Id_Denuncia, fichero.NombreFichero)" target="_blank" rel="noopener">
<i class="bi bi-eye"></i> Ver
</a>
</td>
@@ -319,7 +317,7 @@ else
if (firstRender)
{
await CargarGestionaAsync();
await CargarFicherosAdjuntosAsync();
await CargarFicherosAdjuntosAsync(denunciasGestiona.Select(d => d.Id_Denuncia));
hasLoaded = true;
StateHasChanged();
}
@@ -331,28 +329,31 @@ else
denunciasGestiona = todas
.Where(d => d.EnGestiona)
.ToList();
await SincronizarExpedientesGestionaAsync();
}
private async Task<List<DenunciasGestiona>> CargarDenunciasJsonAsync()
{
return await DenunciaStore.GetAllDenunciasAsync();
return await DenunciaStore.GetDenunciasByScopeAsync(DenunciaListScope.InGestiona);
}
private async Task<List<FicherosDenuncias>> CargarFicherosJsonAsync()
private async Task CargarFicherosAdjuntosAsync(IEnumerable<int> denunciaIds)
{
return await DenunciaStore.GetAllFicherosAsync();
ficherosAdjuntos.Clear();
foreach (var denunciaId in denunciaIds.Where(id => id > 0).Distinct())
{
var ficheros = await DenunciaStore.GetFicherosByDenunciaAsync(denunciaId);
if (ficheros.Count > 0)
{
ficherosAdjuntos[denunciaId] = ficheros;
}
}
}
private async Task CargarFicherosAdjuntosAsync()
private static string BuildAttachmentContentUrl(int denunciaId, string? fileName)
{
var listaFicheros = await CargarFicherosJsonAsync();
ficherosAdjuntos = listaFicheros.GroupBy(f => f.Id_Denuncia)
.ToDictionary(g => g.Key, g => g.ToList());
return $"/api/denuncias/{denunciaId}/ficheros/content?fileName={Uri.EscapeDataString(fileName ?? string.Empty)}";
}
private string GetContentType(string fileName)
{
var ext = Path.GetExtension(fileName).ToLowerInvariant();
@@ -367,48 +368,5 @@ else
};
}
private async Task SincronizarExpedientesGestionaAsync()
{
foreach (var denuncia in denunciasGestiona.Where(d =>
string.IsNullOrWhiteSpace(d.CodigoExpedienteGestiona) &&
!string.IsNullOrWhiteSpace(d.Expediente_Gestiona) &&
!string.Equals(d.Expediente_Gestiona, "Pendiente", StringComparison.OrdinalIgnoreCase)))
{
try
{
var expediente = await ApiDenuncias.GetGestionaExpedienteAsync(denuncia.Expediente_Gestiona);
if (expediente is null)
{
continue;
}
var changed = false;
denuncia.Expediente_Gestiona = expediente.FileUrl;
if (!string.IsNullOrWhiteSpace(expediente.CodigoExpediente) &&
!string.Equals(denuncia.CodigoExpedienteGestiona, expediente.CodigoExpediente, StringComparison.Ordinal))
{
denuncia.CodigoExpedienteGestiona = expediente.CodigoExpediente;
changed = true;
}
if (!string.IsNullOrWhiteSpace(expediente.FreeTitle) &&
!string.Equals(denuncia.NombreDenuncia, expediente.FreeTitle, StringComparison.Ordinal))
{
denuncia.NombreDenuncia = expediente.FreeTitle;
changed = true;
}
if (changed)
{
await DenunciaStore.UpsertDenunciaAsync(denuncia);
}
}
catch
{
// No bloqueamos la pantalla si Gestiona no devuelve metadatos.
}
}
}
}

View File

@@ -1,122 +1,168 @@
@page "/Instrucciones"
@attribute [Authorize]
@attribute [StreamRendering]
@inject GestionaDenunciasAN.Models.UserState userState
@inject NavigationManager Navigation
<PageTitle>Instrucciones</PageTitle>
<div class="container mt-4">
<h1 class="mb-4">Gu<EFBFBD>a de Uso <20> Gesti<74>n de Denuncias</h1>
<div class="container-fluid px-0">
<div class="mb-4">
<h1 class="mb-2">Instrucciones</h1>
<p class="text-muted mb-0">
Guia practica para usar la aplicacion en el trabajo diario con denuncias de GlobalLeaks y expedientes de Gestiona.
</p>
</div>
<p>
Esta aplicaci<63>n permite procesar denuncias desde archivos ZIP y gestionarlas en tres etapas:
<strong>Pendientes</strong>, <strong>Gesti<74>n</strong> (aceptadas) y <strong>Rechazadas</strong>.
</p>
<div class="card mb-3">
<div class="card-body">
<h2 class="h5">Flujo habitual</h2>
<ol class="mb-0">
<li>Entrar en <strong>Entrada</strong> y renovar el codigo 2FA si la sesion de GlobalLeaks lo pide.</li>
<li>Actualizar la bandeja y revisar las denuncias disponibles.</li>
<li>Importar las denuncias que se vayan a gestionar.</li>
<li>Tramitar las denuncias nuevas desde <strong>Pendientes</strong>.</li>
<li>Tramitar las ampliaciones desde <strong>Actualizaciones</strong>.</li>
<li>Consultar lo enviado en <strong>Gestiona</strong> o lo descartado en <strong>Rechazados</strong>.</li>
</ol>
</div>
</div>
<h2>1. Carga de ZIPs</h2>
<ul>
<li>
Sit<EFBFBD>ate en la pesta<74>a <strong>Gesti<74>n de ZIP</strong>. Haz clic en <em>Subir nuevo ZIP</em>,
selecciona uno o varios archivos <code>.zip</code> y espera a que se extraigan.
</li>
<li>
Cada ZIP debe incluir un <code>report.txt</code> con los campos de la denuncia, y opcionalmente
subcarpetas <code>files</code> o <code>files_attached_from_recipients</code> con PDF e im<69>genes.
</li>
<li>
Tras el procesado, la app lee los <code>report.txt</code> y actualiza la base de datos:
- El listado de <strong>Pendientes</strong>.
- El registro de denuncias.
- El registro de ficheros adjuntos.
</li>
</ul>
<div class="row g-3">
<div class="col-12 col-xl-6">
<div class="card h-100">
<div class="card-body">
<h2 class="h5">Entrada</h2>
<p>
Esta pantalla sirve para revisar la bandeja de GlobalLeaks e importar denuncias a la aplicacion.
</p>
<ul class="mb-0">
<li>Si aparece la sesion caducada, pulsa la opcion de renovacion e introduce el codigo 2FA activo.</li>
<li>Pulsa <strong>Actualizar denuncias</strong> para cargar la bandeja.</li>
<li>Usa el filtro para ver todo el buzon, solo lo posterior a tu ultima descarga o un intervalo de fechas.</li>
<li>Pulsa <strong>Ver detalle</strong> cuando necesites revisar mensajes o adjuntos antes de importar.</li>
<li>Marca una o varias denuncias y pulsa <strong>Importar seleccionadas</strong>.</li>
</ul>
</div>
</div>
</div>
<h2>2. Pesta<74>a <strong>Pendientes</strong></h2>
<ul>
<li>
Ver<EFBFBD>s cada denuncia en una tarjeta colapsable con sus datos y el listado de ficheros adjuntos.
</li>
<li>
Hay dos acciones:
<ul>
<li>
<strong>Configurar subida</strong> (verde): abre un modal donde puedes:
<ol>
<li>Poner un nombre descriptivo.</li>
<li>
Elegir el modo de subida:
<ul>
<li><em>Unir</em> todos los ficheros en un <20>nico PDF.</li>
<li><em>Subir</em> cada fichero de forma independiente.</li>
</ul>
</li>
<li>Seleccionar el grupo de destino (600, 510 o 700).</li>
<li>
Confirmar. La denuncia se crea y abre en Gesti<74>na, sube los documentos
y pasa a la pesta<74>a <strong>Gesti<74>n</strong>.
</li>
</ol>
</li>
<li>
<strong>Rechazar denuncia</strong> (rojo): abre un modal para poner el motivo.
Al confirmar, la denuncia se marca como rechazada y va a la pesta<74>a
<strong>Rechazados</strong>.
</li>
</ul>
</li>
</ul>
<div class="col-12 col-xl-6">
<div class="card h-100">
<div class="card-body">
<h2 class="h5">Pendientes</h2>
<p>
Aqui aparecen las denuncias nuevas que todavia no se han enviado a Gestiona.
</p>
<ul class="mb-0">
<li>Abre la denuncia para revisar sus datos y adjuntos.</li>
<li>En la lista de ficheros, deja marcado solo lo que quieras subir.</li>
<li>El report de la denuncia se sube siempre y no se puede desmarcar.</li>
<li>Pulsa <strong>Configurar subida</strong> para indicar asunto, grupo destino y modo de subida.</li>
<li>Confirma para crear el expediente, vincular el tercero y subir los documentos a Gestiona.</li>
<li>Si la denuncia no procede, usa <strong>Rechazar denuncia</strong> e indica el motivo.</li>
</ul>
</div>
</div>
</div>
<h2>3. Pesta<74>a <strong>Gesti<74>n</strong></h2>
<ul>
<li>
Aqu<EFBFBD> se listan las denuncias que ya han sido <em>enviadas a Gesti<EFBFBD>n</em>.
Aparecen con fondo verde.
</li>
<li>
Cada tarjeta muestra:
<ul>
<li>ID, nombre, archivo subido</li>
<li>Fecha y hora de subida</li>
<li>Detalles completos y enlaces <20>Ver<65> a los PDFs/im<69>genes</li>
</ul>
</li>
</ul>
<div class="col-12 col-xl-6">
<div class="card h-100">
<div class="card-body">
<h2 class="h5">Modal de subida a Gestiona</h2>
<p>
Antes de confirmar la subida, revisa estos puntos:
</p>
<ul class="mb-0">
<li><strong>Asunto</strong>: texto que identificara el expediente/documentos en Gestiona.</li>
<li><strong>Grupo destino</strong>: unidad a la que se asignara el expediente.</li>
<li><strong>Modo de subida</strong>: puedes unir adjuntos en un PDF o subirlos de forma independiente.</li>
<li><strong>Tercero</strong>: la app lo rellena desde la denuncia. Si es anonima, se usa el tercero anonimo configurado.</li>
<li><strong>Expedientes del tercero</strong>: puedes consultarlos antes de confirmar si necesitas contexto.</li>
</ul>
</div>
</div>
</div>
<h2>4. Pesta<74>a <strong>Rechazadas</strong></h2>
<ul>
<li>
Aqu<EFBFBD> ver<65>s todas las denuncias que han sido rechazadas. Fondo rojo.
</li>
<li>
Cada tarjeta muestra el motivo de rechazo y la fecha/hora en que se marc<72>.
</li>
</ul>
<div class="col-12 col-xl-6">
<div class="card h-100">
<div class="card-body">
<h2 class="h5">Actualizaciones</h2>
<p>
Esta pantalla recoge comunicaciones o adjuntos nuevos sobre denuncias que ya tienen expediente en Gestiona.
</p>
<ul class="mb-0">
<li>Revisa la actualizacion y sus ficheros.</li>
<li>La app propone los adjuntos que parecen nuevos.</li>
<li>Puedes desmarcar los adjuntos que no quieras subir.</li>
<li>El report de la actualizacion se mantiene obligatorio.</li>
<li>Confirma la subida para a<>adir los nuevos documentos al expediente existente.</li>
</ul>
</div>
</div>
</div>
<h2>5. Flujo completo</h2>
<ol>
<li>Subes uno o varios ZIP en la pesta<74>a <strong>Gesti<74>n de ZIP</strong>.</li>
<li>La aplicaci<63>n extrae y parsea informes, los a<>ade a <strong>Pendientes</strong>.</li>
<li>
En <strong>Pendientes</strong> eliges qu<71> hacer con cada denuncia:
<ul>
<li><strong>Configurar subida</strong> ? pasa a <strong>Gesti<74>n</strong>.</li>
<li><strong>Rechazar denuncia</strong> ? pasa a <strong>Rechazadas</strong>.</li>
</ul>
</li>
<li>
En <strong>Gesti<74>n</strong> puedes revisar lo ya subido; en
<strong>Rechazadas</strong> ves los motivos.
</li>
</ol>
<div class="col-12 col-xl-6">
<div class="card h-100">
<div class="card-body">
<h2 class="h5">Gestiona</h2>
<p>
Aqui se consultan las denuncias que ya se han enviado a Gestiona.
</p>
<ul class="mb-0">
<li>Comprueba el numero de expediente y la fecha de envio.</li>
<li>Accede al enlace del expediente cuando necesites revisar la tramitacion en Gestiona.</li>
<li>Usa esta pantalla como seguimiento de lo que ya salio de Pendientes o Actualizaciones.</li>
</ul>
</div>
</div>
</div>
<p class="mt-4">
Con este flujo tienes control total sobre:
<strong>nombre</strong>, <strong>modo de subida</strong>, <strong>grupo destino</strong> y
<strong>estado final</strong> de cada denuncia.
</p>
</div>
<div class="col-12 col-xl-6">
<div class="card h-100">
<div class="card-body">
<h2 class="h5">Rechazados</h2>
<p>
Aqui quedan las denuncias que se han descartado desde Pendientes.
</p>
<ul class="mb-0">
<li>Consulta el motivo indicado al rechazar.</li>
<li>Usa este listado para revisar descartes y trazabilidad interna.</li>
</ul>
</div>
</div>
</div>
@code {
}
<div class="col-12 col-xl-6">
<div class="card h-100">
<div class="card-body">
<h2 class="h5">Buscador de terceros</h2>
<p>
Permite consultar informacion de terceros y expedientes vinculados.
</p>
<ul class="mb-0">
<li>Introduce el documento o dato disponible.</li>
<li>Revisa los resultados antes de tomar decisiones sobre una nueva denuncia o actualizacion.</li>
</ul>
</div>
</div>
</div>
<div class="col-12 col-xl-6">
<div class="card h-100">
<div class="card-body">
<h2 class="h5">Configuracion</h2>
<p>
Esta pantalla contiene ajustes compartidos para todos los usuarios.
</p>
<ul class="mb-0">
<li><strong>Fecha de corte</strong>: escribela en formato <code>dd/mm/aaaa</code>. Se usa para actualizaciones de expedientes que no nacieron en esta app.</li>
<li><strong>Purga manual</strong>: accion critica. Usala solo cuando se indique expresamente.</li>
</ul>
</div>
</div>
</div>
</div>
<div class="alert alert-warning mt-3 mb-0">
Si una pantalla indica que una denuncia no esta disponible, no intentes tramitarla desde otra ruta: revisa la bandeja de Entrada o consulta con soporte del sistema.
</div>
</div>

View File

@@ -2,10 +2,12 @@
@layout EmptyLayout
@rendermode @(new InteractiveServerRenderMode(prerender: false))
@using System.Text.Json
@using GestionaDenuncias.Shared.Models
@using GestionaDenunciasAN.Models
@inject AuthenticationStateProvider AuthenticationStateProvider
@inject IJSRuntime JSRuntime
@inject NavigationManager Navigation
@inject UiBusyService Busy
<PageTitle>Portal de denuncias</PageTitle>
@@ -87,12 +89,12 @@
<div class="row g-5 align-items-center">
<div class="col-lg-7">
<div class="brand-panel">
<span class="brand-kicker">Oficina Antifraude de Andalucía</span>
<span class="brand-kicker">Oficina Antifraude de Andalucia</span>
<h1 class="brand-title">Una sola puerta para entrar, importar y tramitar denuncias.</h1>
<p class="brand-copy mb-4">
El acceso de la aplicación ya se apoya en GlobalLeaks. La sesión interna de esta app se mantiene
activa de forma persistente, y cuando caduque la sesión de obtención de denuncias solo habrá que
renovar el código 2FA desde la bandeja de entrada.
El acceso de la aplicacion ya se apoya en GlobalLeaks. La sesion interna de esta app se mantiene
activa de forma persistente, y cuando caduque la sesion de obtencion de denuncias solo habra que
renovar el codigo 2FA desde la bandeja de entrada.
</p>
<img src="Content/imagenes/logo-oaaf-negativo-transparente.svg"
alt="Logo Oficina Antifraude"
@@ -104,7 +106,7 @@
<div class="login-panel p-4 p-md-5">
<h2 class="h3 mb-2">Acceso con GlobalLeaks</h2>
<p class="text-muted mb-4">
Introduce tu usuario, contraseña y el código 2FA actual para dejar la app iniciada.
Primero validamos usuario y contrasena. Despues te pediremos un codigo 2FA fresco para completar el acceso.
</p>
@if (!string.IsNullOrWhiteSpace(StatusMessage))
@@ -116,33 +118,54 @@
<label class="login-label mb-2">Usuario</label>
<input class="form-control login-input"
@bind="Username"
@bind:event="oninput"
disabled="@LoginPrepared"
autocomplete="username"
placeholder="usuario de GlobalLeaks" />
</div>
<div class="mb-3">
<label class="login-label mb-2">Contraseña</label>
<label class="login-label mb-2">Contrasena</label>
<input class="form-control login-input"
type="password"
@bind="Password"
@bind:event="oninput"
disabled="@LoginPrepared"
autocomplete="current-password" />
</div>
<div class="mb-4">
<label class="login-label mb-2">Código 2FA</label>
<input class="form-control login-input"
@bind="Authcode"
@onkeydown="HandleAuthcodeKeyDown"
inputmode="numeric"
maxlength="6"
placeholder="123456" />
</div>
@if (LoginPrepared)
{
<div class="alert alert-info mb-4">
Credenciales preparadas. Introduce ahora el codigo 2FA actual para completar el acceso.
</div>
<div class="mb-4">
<label class="login-label mb-2">Codigo 2FA</label>
<input class="form-control login-input"
@bind="Authcode"
@bind:event="oninput"
@onkeydown="HandleAuthcodeKeyDown"
inputmode="numeric"
maxlength="6"
placeholder="123456" />
</div>
}
<button class="btn btn-primary login-button w-100"
@onclick="LoginAsync"
disabled="@IsBusy">
@(IsBusy ? "Conectando..." : "Entrar")
@LoginButtonText
</button>
@if (LoginPrepared)
{
<button class="btn btn-link w-100 mt-3"
type="button"
disabled="@IsBusy"
@onclick="ResetPreparedLogin">
Cambiar usuario o contrasena
</button>
}
</div>
</div>
</div>
@@ -156,9 +179,14 @@
private string Username { get; set; } = string.Empty;
private string Password { get; set; } = string.Empty;
private string Authcode { get; set; } = string.Empty;
private string PendingLoginId { get; set; } = string.Empty;
private string StatusMessage { get; set; } = string.Empty;
private string StatusCss { get; set; } = "alert-info";
private bool IsBusy { get; set; }
private bool LoginPrepared => !string.IsNullOrWhiteSpace(PendingLoginId);
private string LoginButtonText => IsBusy
? (LoginPrepared ? "Validando 2FA..." : "Preparando acceso...")
: (LoginPrepared ? "Validar 2FA y entrar" : "Preparar acceso");
protected override async Task OnInitializedAsync()
{
@@ -166,18 +194,28 @@
if (authState.User.Identity?.IsAuthenticated == true)
{
Navigation.NavigateTo(GetTargetUrl(), true);
}
}
}
private async Task LoginAsync()
{
StatusMessage = string.Empty;
if (string.IsNullOrWhiteSpace(Username) ||
string.IsNullOrWhiteSpace(Password) ||
string.IsNullOrWhiteSpace(Authcode))
if (!LoginPrepared)
{
SetStatus("Debes rellenar usuario, contraseña y código 2FA.", "alert-warning");
await PrepareLoginAsync();
return;
}
await CompleteLoginAsync();
}
private async Task PrepareLoginAsync()
{
if (string.IsNullOrWhiteSpace(Username) ||
string.IsNullOrWhiteSpace(Password))
{
SetStatus("Debes rellenar usuario y contrasena.", "alert-warning");
return;
}
@@ -185,27 +223,105 @@
try
{
using var busy = Busy.Show(
"Preparando acceso",
"Validando usuario y contrasena con GlobalLeaks. Cuando termine pediremos un codigo 2FA nuevo.");
using var loginTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(170));
var response = await JSRuntime.InvokeAsync<ApiJsResponse>(
"appAuthPostJson",
"/api/auth/login",
new LoginRequest(Username.Trim(), Password, Authcode.Trim()));
loginTimeout.Token,
[
"/api/auth/prepare",
new ApiLoginPrepareRequest(Username.Trim(), Password)
]);
if (!response.Ok)
{
var error = ReadData<ApiError>(response);
SetStatus(error?.Error ?? "No se ha podido iniciar sesión.", "alert-danger");
SetStatus(error?.Error ?? "No se ha podido preparar el acceso.", "alert-danger");
return;
}
var prepared = ReadData<ApiLoginPrepareResponse>(response);
if (prepared is null || string.IsNullOrWhiteSpace(prepared.PendingLoginId))
{
SetStatus("La API no ha devuelto una preparacion de login valida.", "alert-danger");
return;
}
PendingLoginId = prepared.PendingLoginId;
Username = prepared.Username;
Password = string.Empty;
Authcode = string.Empty;
SetStatus("Credenciales preparadas. Introduce el codigo 2FA que este activo ahora mismo.", "alert-info");
}
catch (JSException ex)
{
SetStatus($"Fallo de comunicacion con el navegador: {ex.Message}", "alert-danger");
}
catch (OperationCanceledException)
{
SetStatus("La preparacion del acceso ha tardado demasiado. Revisa si ApiDenuncias o GlobalLeaks estan respondiendo lento y vuelve a intentarlo.", "alert-danger");
}
catch (Exception ex)
{
SetStatus($"No se ha podido preparar el acceso: {ex.Message}", "alert-danger");
}
finally
{
IsBusy = false;
}
}
private async Task CompleteLoginAsync()
{
var authcode = Authcode.Trim();
if (authcode.Length != 6 || authcode.Any(ch => !char.IsDigit(ch)))
{
SetStatus("El codigo 2FA debe tener exactamente 6 digitos.", "alert-warning");
return;
}
IsBusy = true;
try
{
using var busy = Busy.Show(
"Validando 2FA",
"Completando el acceso con el codigo actual de GlobalLeaks.");
using var loginTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(170));
var response = await JSRuntime.InvokeAsync<ApiJsResponse>(
"appAuthPostJson",
loginTimeout.Token,
[
"/api/auth/complete",
new ApiLoginCompleteRequest(PendingLoginId, authcode)
]);
if (!response.Ok)
{
var error = ReadData<ApiError>(response);
SetStatus(error?.Error ?? "No se ha podido completar el inicio de sesion.", "alert-danger");
Authcode = string.Empty;
return;
}
Busy.Update(message: "Creando la sesion interna segura de la aplicacion.");
Navigation.NavigateTo(GetTargetUrl(), true);
}
catch (JSException ex)
{
SetStatus($"Fallo de comunicación con el navegador: {ex.Message}", "alert-danger");
SetStatus($"Fallo de comunicacion con el navegador: {ex.Message}", "alert-danger");
}
catch
catch (OperationCanceledException)
{
SetStatus("No se ha podido conectar con el servidor.", "alert-danger");
SetStatus("La validacion del 2FA ha tardado demasiado. Revisa si ApiDenuncias o GlobalLeaks estan respondiendo lento y vuelve a intentarlo.", "alert-danger");
}
catch (Exception ex)
{
SetStatus($"No se ha podido completar el inicio de sesion: {ex.Message}", "alert-danger");
}
finally
{
@@ -214,7 +330,15 @@
}
private Task HandleAuthcodeKeyDown(KeyboardEventArgs args)
=> args.Key == "Enter" ? LoginAsync() : Task.CompletedTask;
=> LoginPrepared && args.Key == "Enter" ? LoginAsync() : Task.CompletedTask;
private void ResetPreparedLogin()
{
PendingLoginId = string.Empty;
Password = string.Empty;
Authcode = string.Empty;
StatusMessage = string.Empty;
}
private void SetStatus(string message, string cssClass)
{

View File

@@ -1,4 +1,4 @@
@page "/Pendientes"
@page "/Pendientes"
@rendermode InteractiveServer
@attribute [Authorize]
@@ -18,30 +18,11 @@
@inject IHostEnvironment HostEnvironment
@inject IDenunciaStore DenunciaStore
@inject ApiDenunciasClient ApiDenuncias
@inject UiBusyService Busy
<PageTitle>Denuncias Pendientes</PageTitle>
<style>
.upload-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.35);
z-index: 2000; /* por encima de todo, también de los modales */
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(1px);
}
.upload-box {
background: #fff;
border-radius: .75rem;
padding: 1.25rem 1.5rem;
box-shadow: 0 10px 30px rgba(0,0,0,.25);
min-width: 260px;
text-align: center;
}
.seleccionar-col {
width: 50px;
text-align: center;
@@ -179,6 +160,10 @@
{
<div class="alert alert-danger">@loadError</div>
}
@if (!string.IsNullOrWhiteSpace(operationNotice))
{
<div class="alert alert-warning">@operationNotice</div>
}
<input type="text"
class="form-control"
@@ -262,7 +247,7 @@ else
}
@if (!string.IsNullOrWhiteSpace(denuncia.ExpedienteGestionaMostrable))
{
<dt class="col-sm-3">Nº expediente Gestiona</dt>
<dt class="col-sm-3">N<EFBFBD> expediente Gestiona</dt>
<dd class="col-sm-9">@denuncia.ExpedienteGestionaMostrable</dd>
}
@if (denuncia.Id_Persona_Gestiona != 0)
@@ -302,7 +287,7 @@ else
}
@if (!string.IsNullOrWhiteSpace(denuncia.RazonSocialResuelta))
{
<dt class="col-sm-3">Razón social</dt>
<dt class="col-sm-3">Raz<EFBFBD>n social</dt>
<dd class="col-sm-9">@denuncia.RazonSocialResuelta</dd>
}
@if (!string.IsNullOrWhiteSpace(denuncia.NombreResuelto))
@@ -312,12 +297,12 @@ else
}
@if (!string.IsNullOrWhiteSpace(denuncia.PrimerApellidoResuelto))
{
<dt class="col-sm-3">1º Apellido</dt>
<dt class="col-sm-3">1<EFBFBD> Apellido</dt>
<dd class="col-sm-9">@denuncia.PrimerApellidoResuelto</dd>
}
@if (!string.IsNullOrWhiteSpace(denuncia.SegundoApellidoResuelto))
{
<dt class="col-sm-3">2º Apellido</dt>
<dt class="col-sm-3">2<EFBFBD> Apellido</dt>
<dd class="col-sm-9">@denuncia.SegundoApellidoResuelto</dd>
}
@if (!string.IsNullOrWhiteSpace(denuncia.ApellidosResueltos))
@@ -337,7 +322,7 @@ else
}
@if (!string.IsNullOrWhiteSpace(denuncia.PaisOrigen))
{
<dt class="col-sm-3">País de origen</dt>
<dt class="col-sm-3">Pa<EFBFBD>s de origen</dt>
<dd class="col-sm-9">@denuncia.PaisOrigen</dd>
}
</dl>
@@ -354,7 +339,7 @@ else
<dt class="col-sm-3">Detalle denunciado</dt>
<dd class="col-sm-9">@denuncia.DenunciadoDetalle</dd>
}
<dt class="col-sm-3">Descripción Denuncia</dt>
<dt class="col-sm-3">Descripci<EFBFBD>n Denuncia</dt>
<dd class="col-sm-9">@denuncia.Descripcion_Denuncia</dd>
<dt class="col-sm-3">Denunciado Ante Inst</dt>
<dd class="col-sm-9">@denuncia.Denunciado_Ante_Inst</dd>
@@ -365,7 +350,7 @@ else
}
@if (!string.IsNullOrWhiteSpace(denuncia.SolicitaProteccion))
{
<dt class="col-sm-3">Solicita protección</dt>
<dt class="col-sm-3">Solicita protecci<EFBFBD>n</dt>
<dd class="col-sm-9">@denuncia.SolicitaProteccion</dd>
}
@if (!string.IsNullOrWhiteSpace(denuncia.MedidasProteccionSolicitadas))
@@ -375,7 +360,7 @@ else
}
@if (!string.IsNullOrWhiteSpace(denuncia.Modalidad_Informacion))
{
<dt class="col-sm-3">Modalidad Información</dt>
<dt class="col-sm-3">Modalidad Informaci<EFBFBD>n</dt>
<dd class="col-sm-9">@denuncia.Modalidad_Informacion</dd>
}
<dt class="col-sm-3">Lugar Hechos</dt>
@@ -387,7 +372,7 @@ else
}
@if (!string.IsNullOrWhiteSpace(denuncia.AutorizaRemision))
{
<dt class="col-sm-3">Autoriza remisión</dt>
<dt class="col-sm-3">Autoriza remisi<EFBFBD>n</dt>
<dd class="col-sm-9">@denuncia.AutorizaRemision</dd>
}
@if (!string.IsNullOrWhiteSpace(denuncia.PreferenciaRemision))
@@ -397,16 +382,16 @@ else
}
</dl>
<h5 class="section-heading">Datos de Notificación</h5>
<h5 class="section-heading">Datos de Notificaci<EFBFBD>n</h5>
<dl class="row">
@if (!string.IsNullOrWhiteSpace(denuncia.Notificacion_Preferencia))
{
<dt class="col-sm-3">Notificación Preferencia</dt>
<dt class="col-sm-3">Notificaci<EFBFBD>n Preferencia</dt>
<dd class="col-sm-9">@denuncia.Notificacion_Preferencia</dd>
}
@if (!string.IsNullOrWhiteSpace(denuncia.Notificacion_Electronica))
{
<dt class="col-sm-3">Notificación Electrónica</dt>
<dt class="col-sm-3">Notificaci<EFBFBD>n Electr<EFBFBD>nica</dt>
<dd class="col-sm-9">@denuncia.Notificacion_Electronica</dd>
}
@if (!string.IsNullOrWhiteSpace(denuncia.SeguimientoOnline))
@@ -421,17 +406,17 @@ else
}
@if (!string.IsNullOrWhiteSpace(denuncia.Correo_Electronico))
{
<dt class="col-sm-3">Correo Electrónico</dt>
<dt class="col-sm-3">Correo Electr<EFBFBD>nico</dt>
<dd class="col-sm-9">@denuncia.Correo_Electronico</dd>
}
@if (!string.IsNullOrWhiteSpace(denuncia.Notificacion_Sms))
{
<dt class="col-sm-3">Notificación SMS</dt>
<dt class="col-sm-3">Notificaci<EFBFBD>n SMS</dt>
<dd class="col-sm-9">@denuncia.Notificacion_Sms</dd>
}
@if (HasPostalAddress(denuncia))
{
<dt class="col-sm-3">Dirección postal</dt>
<dt class="col-sm-3">Direcci<EFBFBD>n postal</dt>
<dd class="col-sm-9">@BuildPostalAddressSummary(denuncia)</dd>
}
</dl>
@@ -441,7 +426,7 @@ else
@if (denuncia.Condiciones)
{
<dt class="col-sm-3">Condiciones</dt>
<dd class="col-sm-9">Sí</dd>
<dd class="col-sm-9">S<EFBFBD></dd>
}
@if (!string.IsNullOrWhiteSpace(denuncia.Comments))
{
@@ -456,14 +441,14 @@ else
@if (camposFormulario.Count > 0)
{
<h5 class="section-heading">Formulario Original</h5>
@foreach (var grupoCampos in camposFormulario.GroupBy(field => string.IsNullOrWhiteSpace(field.Section) ? "Sin sección" : field.Section))
@foreach (var grupoCampos in camposFormulario.GroupBy(field => string.IsNullOrWhiteSpace(field.Section) ? "Sin secci<EFBFBD>n" : field.Section))
{
<h6 class="mt-3">@grupoCampos.Key</h6>
<dl class="row">
@foreach (var campo in grupoCampos)
{
<dt class="col-sm-4">@campo.Label</dt>
<dd class="col-sm-8">@(string.IsNullOrWhiteSpace(campo.Value) ? "" : campo.Value)</dd>
<dd class="col-sm-8">@(string.IsNullOrWhiteSpace(campo.Value) ? "<EFBFBD>" : campo.Value)</dd>
}
</dl>
}
@@ -475,34 +460,37 @@ else
<table class="table table-striped">
<thead>
<tr>
<th class="seleccionar-col">Seleccionar</th>
<th class="seleccionar-col">Subir</th>
<th>Nombre</th>
<th>Tamaño (bytes)</th>
<th>Fecha</th>
<th>Tama<6D>o (bytes)</th>
<th>Ver</th>
</tr>
</thead>
<tbody>
@foreach (var fichero in ficherosAdjuntos[denuncia.Id_Denuncia])
{
var isReport = IsReportFileName(fichero.NombreFichero);
<tr>
<td class="seleccionar-col">
<button class="preselect-btn @(preselectedFicheros.ContainsKey(denuncia.Id_Denuncia) && preselectedFicheros[denuncia.Id_Denuncia] == fichero.NombreFichero ? "selected" : "")"
@onclick="() => PreselectFichero(denuncia.Id_Denuncia, fichero.NombreFichero)">
@if (preselectedFicheros.ContainsKey(denuncia.Id_Denuncia) && preselectedFicheros[denuncia.Id_Denuncia] == fichero.NombreFichero)
{
<i class="bi bi-check-circle-fill"></i>
}
else
{
<i class="bi bi-circle"></i>
}
</button>
<input class="form-check-input"
type="checkbox"
checked="@IsFileSelectedForUpload(denuncia.Id_Denuncia, fichero.NombreFichero)"
disabled="@isReport"
title='@(isReport ? "El report se sube siempre" : "Incluir este fichero en la subida")'
@onchange="args => ToggleFileSelection(denuncia.Id_Denuncia, fichero.NombreFichero, args.Value is bool selected && selected)" />
</td>
<td>@fichero.NombreFichero</td>
<td>
@fichero.NombreFichero
@if (isReport)
{
<span class="badge bg-primary ms-2">Obligatorio</span>
}
</td>
<td>@FormatFileDate(fichero.Fecha)</td>
<td>@fichero.Fichero.Length</td>
<td>
<a class="btn btn-primary btn-sm" href="#"
onclick="openFile(event, '@Convert.ToBase64String(fichero.Fichero)', '@GetContentType(fichero.NombreFichero)');">
<a class="btn btn-primary btn-sm" href="@BuildAttachmentContentUrl(denuncia.Id_Denuncia, fichero.NombreFichero)" target="_blank" rel="noopener">
<i class="bi bi-eye"></i> Ver
</a>
</td>
@@ -546,7 +534,14 @@ else
<button type="button" class="btn-close" @onclick="CloseModal"></button>
</div>
<div class="modal-body custom-modal-body">
<h6 class="modal-section-heading">Descripción</h6>
@if (!string.IsNullOrWhiteSpace(operationError))
{
<div class="alert alert-danger" role="alert">
@operationError
</div>
}
<h6 class="modal-section-heading">Descripci<63>n</h6>
<div class="mb-3">
<input type="text"
class="form-control"
@@ -559,9 +554,9 @@ else
<input type="text"
class="form-control"
@bind="nombreDocumentos"
placeholder="Ej.: Gestión AN (lo verás como: Documento Adjunto 1 Gestión AN, ...)" />
placeholder="Ej.: Gesti<EFBFBD>n AN (lo ver<EFBFBD>s como: Documento Adjunto 1 Gesti<EFBFBD>n AN, ...)" />
<small class="text-muted">
Se aplicará al subir en modo <strong>individual</strong>. El <em>report.txt</em> se subirá como <strong>Denuncia</strong>.
Se aplicar<EFBFBD> al subir en modo <strong>individual</strong>. El <em>report.txt</em> se subir<EFBFBD> como <strong>Denuncia</strong>.
</small>
</div>
@@ -574,7 +569,7 @@ else
checked='@(uploadMode == "merge")'
@onclick='() => uploadMode = "merge"' />
<label class="form-check-label" for="modoMerge">
Unir todos los ficheros en un único PDF
Unir todos los ficheros en un <EFBFBD>nico PDF
</label>
</div>
<div class="form-check">
@@ -598,7 +593,7 @@ else
checked='@(selectedGroup == "600")'
@onclick='() => selectedGroup = "600"' />
<label class="form-check-label" for="grupo600">
600. Asuntos Jurídicos y Protección a la Persona Denunciante
600. Asuntos Jur<EFBFBD>dicos y Protecci<EFBFBD>n a la Persona Denunciante
</label>
</div>
@* <div class="form-check">
@@ -609,7 +604,7 @@ else
checked='@(selectedGroup == "510")'
@onclick='() => selectedGroup = "510"' />
<label class="form-check-label" for="grupo510">
510. SDI Investigación Entradas
510. SDI <EFBFBD> Investigaci<EFBFBD>n Entradas
</label>
</div>
<div class="form-check">
@@ -631,13 +626,13 @@ else
<h6 class="modal-section-heading mt-3">Tercero (denunciante)</h6>
<div class="alert alert-light border mb-3">
Los datos del tercero se cargan automáticamente desde la denuncia y no se pueden editar aquí.
Los datos del tercero se cargan autom<EFBFBD>ticamente desde la denuncia y no se pueden editar aqu<EFBFBD>.
</div>
@if (modalThirdParty.IsAnonymous)
{
<div class="alert alert-warning mb-3">
Denuncia anónima. Se enlazará automáticamente el tercero <strong>00000000T</strong>.
Denuncia an<EFBFBD>nima. Se enlazar<EFBFBD> autom<EFBFBD>ticamente el tercero <strong>00000000T</strong>.
</div>
}
@@ -666,7 +661,7 @@ else
{
<div class="row g-2">
<div class="col-12 mb-2">
<label class="form-label">Razón social</label>
<label class="form-label">Raz<EFBFBD>n social</label>
<input class="form-control" value="@GetReadOnlyValue(selectedDenuncias.RazonSocialResuelta)" readonly />
</div>
</div>
@@ -679,11 +674,11 @@ else
<input class="form-control" value="@GetReadOnlyValue(selectedDenuncias.NombreResuelto)" readonly />
</div>
<div class="col-4 mb-2">
<label class="form-label">1º apellido</label>
<label class="form-label">1<EFBFBD> apellido</label>
<input class="form-control" value="@GetReadOnlyValue(selectedDenuncias.PrimerApellidoResuelto)" readonly />
</div>
<div class="col-4 mb-2">
<label class="form-label">2º apellido</label>
<label class="form-label">2<EFBFBD> apellido</label>
<input class="form-control" value="@GetReadOnlyValue(selectedDenuncias.SegundoApellidoResuelto)" readonly />
</div>
</div>
@@ -693,14 +688,14 @@ else
{
<div class="row g-2">
<div class="col-12 mb-2">
<label class="form-label">Dirección postal</label>
<label class="form-label">Direcci<EFBFBD>n postal</label>
<textarea class="form-control" rows="2" readonly>@BuildPostalAddressSummary(selectedDenuncias)</textarea>
</div>
</div>
}
<small class="text-muted">
Al confirmar, se enlazará en Gestiona el tercero obtenido del formulario de la denuncia.
Al confirmar, se enlazar<EFBFBD> en Gestiona el tercero obtenido del formulario de la denuncia.
</small>
@if (!modalThirdParty.IsAnonymous && !string.IsNullOrWhiteSpace(modalThirdParty.DocumentId))
@@ -720,7 +715,7 @@ else
<button type="button" class="btn btn-secondary" @onclick="CloseModal">
Cancelar
</button>
<button type="button" class="btn btn-primary" @onclick="ConfirmarEnvio">
<button type="button" class="btn btn-primary" disabled="@isUploading" @onclick="ConfirmarEnvio">
Confirmar
</button>
</div>
@@ -773,7 +768,7 @@ else
{
<div class="d-flex align-items-center">
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
<span>Cargando expedientes</span>
<span>Cargando expedientes<EFBFBD></span>
</div>
}
else if (!string.IsNullOrWhiteSpace(errorExpedientes))
@@ -793,7 +788,7 @@ else
<tr>
<th>Expediente</th>
<th>Asunto</th>
<th>Fecha creación</th>
<th>Fecha creaci<EFBFBD>n</th>
<th>Estado</th>
<th></th>
</tr>
@@ -828,17 +823,6 @@ else
</div>
}
@if (isUploading)
{
<div class="upload-overlay">
<div class="upload-box">
<div class="spinner-border" role="status" aria-hidden="true"></div>
<div class="mt-3 fw-semibold">Subiendo documentos…</div>
<div class="text-muted" style="font-size:.9rem">Por favor, espera</div>
</div>
</div>
}
@code {
private string nombreDocumentos = string.Empty;
private bool isUploading = false;
@@ -846,22 +830,22 @@ else
private string uploadMode = "merge";
private string selectedGroup = "600";
private const string reportTxt = "report.txt";
private string busqueda = "";
private List<DenunciasGestiona> pendientes = new();
private Dictionary<int, List<FicherosDenuncias>> ficherosAdjuntos = new();
private Dictionary<int, string> preselectedFicheros = new();
private Dictionary<int, HashSet<string>> excludedUploadFiles = new();
private bool hasLoaded = false;
private string loadError = string.Empty;
private string operationError = string.Empty;
private string operationNotice = string.Empty;
private bool showModal = false;
private bool showModalRechazo = false;
private DenunciasGestiona? selectedDenuncias;
private ThirdPartyIdentityData? selectedThirdParty;
private string nuevoAsunto = string.Empty;
private string archivoSeleccionado = reportTxt;
private string motivoRechazo = string.Empty;
private string terceroDni = "";
@@ -900,12 +884,7 @@ else
.ToList();
// Adjuntos SOLO de las pendientes visibles
var listaF = await CargarFicherosJsonAsync();
var idsPend = pendientes.Select(p => p.Id_Denuncia).ToHashSet();
ficherosAdjuntos = listaF
.Where(f => idsPend.Contains(f.Id_Denuncia))
.GroupBy(f => f.Id_Denuncia)
.ToDictionary(g => g.Key, g => g.ToList());
ficherosAdjuntos = await CargarFicherosPorDenunciaAsync(pendientes.Select(p => p.Id_Denuncia));
}
catch (Exception ex)
{
@@ -920,12 +899,22 @@ else
private async Task<List<DenunciasGestiona>> CargarDenunciasJsonAsync()
{
return await DenunciaStore.GetAllDenunciasAsync();
return await DenunciaStore.GetDenunciasByScopeAsync(DenunciaListScope.Pending);
}
private async Task<List<FicherosDenuncias>> CargarFicherosJsonAsync()
private async Task<Dictionary<int, List<FicherosDenuncias>>> CargarFicherosPorDenunciaAsync(IEnumerable<int> denunciaIds)
{
return await DenunciaStore.GetAllFicherosAsync();
var result = new Dictionary<int, List<FicherosDenuncias>>();
foreach (var denunciaId in denunciaIds.Where(id => id > 0).Distinct())
{
var ficheros = await DenunciaStore.GetFicherosByDenunciaAsync(denunciaId);
if (ficheros.Count > 0)
{
result[denunciaId] = ficheros;
}
}
return result;
}
private static bool IsReportFileName(string? fileName)
@@ -970,15 +959,38 @@ else
try
{
isUploading = true;
operationError = string.Empty;
operationNotice = string.Empty;
using var busy = Busy.Show(
"Enviando a Gestiona",
"Preparando expediente, tercero y documentos de la denuncia.");
StateHasChanged();
await Task.Yield();
var existentesF = await CargarFicherosJsonAsync();
var todos = existentesF
.Where(f => f.Id_Denuncia == selectedDenuncias.Id_Denuncia)
Busy.Update(message: "Cargando ficheros de la denuncia.", detail: "Paso 1 de 7");
var existentesF = await DenunciaStore.GetFicherosByDenunciaAsync(selectedDenuncias.Id_Denuncia);
var ficherosNoSeleccionados = GetExcludedUploadFileNames(selectedDenuncias.Id_Denuncia, existentesF);
var ficherosSeleccionados = GetSelectedUploadFiles(selectedDenuncias.Id_Denuncia, existentesF);
var todos = ficherosSeleccionados
.Select(f => (FileName: f.NombreFichero!, Content: f.Fichero!))
.ToList();
var ficherosVacios = todos
.Where(t => t.Content is null || t.Content.Length == 0)
.Select(t => string.IsNullOrWhiteSpace(t.FileName) ? "(sin nombre)" : t.FileName)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
todos = todos
.Where(t => t.Content is { Length: > 0 })
.ToList();
if (!todos.Any())
{
operationError = ficherosVacios.Count == 0
? $"La denuncia #{selectedDenuncias.Id_Denuncia} no tiene ficheros para subir."
: $"La denuncia #{selectedDenuncias.Id_Denuncia} solo tiene ficheros vac<61>os y no se ha subido nada. Ficheros omitidos: {string.Join(", ", ficherosVacios)}.";
return;
}
string fileUrl;
if (selectedDenuncias.EnGestiona && !string.IsNullOrWhiteSpace(selectedDenuncias.Expediente_Gestiona))
@@ -987,6 +999,7 @@ else
}
else
{
Busy.Update(message: "Creando expediente nuevo en Gestiona.", detail: "Paso 2 de 7");
var createdFile = await ApiDenuncias.CreateGestionaFileAsync(
selectedDenuncias.ProcedureId,
nuevoAsunto,
@@ -994,6 +1007,7 @@ else
"3109963"
);
fileUrl = createdFile.FileUrl;
Busy.Update(message: "Abriendo expediente y asignando el grupo elegido.", detail: "Paso 2 de 7");
await ApiDenuncias.OpenGestionaFileAsync(
fileUrl,
createdFile.FileOpenUrl,
@@ -1013,9 +1027,12 @@ else
selectedDenuncias.EnGestiona = true;
}
await SincronizarExpedienteGestionaAsync(selectedDenuncias, fileUrl);
Busy.Update(message: "Guardando referencia local del expediente.", detail: "Paso 3 de 7");
selectedDenuncias.Expediente_Gestiona = fileUrl;
selectedDenuncias.EnGestiona = true;
var thirdParty = selectedThirdParty ?? ThirdPartyIdentityData.FromComplaint(selectedDenuncias);
Busy.Update(message: "Resolviendo tercero y enlazandolo al expediente.", detail: "Paso 4 de 7");
await ApiDenuncias.EnsureGestionaThirdAndLinkAsync(fileUrl, thirdParty);
var nombresOriginalesSubidos = new List<string>();
@@ -1028,6 +1045,7 @@ else
if (!string.IsNullOrWhiteSpace(report.FileName))
{
Busy.Update(message: "Preparando y subiendo el report para firma.", detail: "Paso 5 de 7");
var reportPdfBytes = PdfHelper.MergeFilesToPdf(new[]
{
(FileName: report.FileName, Content: report.Content)
@@ -1047,6 +1065,7 @@ else
if (adjuntos.Count > 0 && uploadMode == "merge")
{
Busy.Update(message: "Uniendo adjuntos en un unico PDF y subiendolo.", detail: "Paso 6 de 7");
var pdfBytes = PdfHelper.MergeFilesToPdf(adjuntos);
var pdfName = FixFileName($"Adjuntos {selectedDenuncias.Id_Denuncia}_{ahoraUtc:yyyyMMddHHmmss}.pdf");
var docUrl = await ApiDenuncias.UploadGestionaDocumentAsync(fileUrl, pdfBytes, pdfName);
@@ -1069,6 +1088,12 @@ else
foreach (var (origName, content) in adjuntos)
{
Busy.Update(
message: $"Subiendo adjunto {i} de {adjuntos.Count}.",
detail: "Paso 6 de 7",
current: i,
total: adjuntos.Count);
var ext = Path.GetExtension(origName).ToLowerInvariant();
byte[] bytesParaSubir = content;
string finalName;
@@ -1102,6 +1127,7 @@ else
if (!string.IsNullOrWhiteSpace(documentoParaTramitar))
{
Busy.Update(message: "Enviando el report al circuito de firma.", detail: "Paso 7 de 7");
await ApiDenuncias.TramitarGestionaDocumentAsync(
documentoParaTramitar,
GetAssignedGroupLinkBySelectedGroup(),
@@ -1119,6 +1145,8 @@ else
f.FechaSubida = ahoraUtc;
}
}
Busy.Update(message: "Actualizando trazabilidad en la base de datos.", detail: "Finalizando");
await DenunciaStore.MarkFicherosAsUploadedAsync(
selectedDenuncias.Id_Denuncia,
nombresOriginalesSubidos,
@@ -1129,13 +1157,30 @@ else
selectedDenuncias.FechaSubidaAGestiona = ahoraUtc;
await ActualizarDenunciaAsync(selectedDenuncias);
var denunciaProcesadaId = selectedDenuncias.Id_Denuncia;
pendientes.Remove(selectedDenuncias);
excludedUploadFiles.Remove(denunciaProcesadaId);
CloseModal();
var avisos = new List<string>();
if (ficherosVacios.Count > 0)
{
avisos.Add($"Se omitieron ficheros vac<61>os: {string.Join(", ", ficherosVacios)}.");
}
if (ficherosNoSeleccionados.Count > 0)
{
avisos.Add($"No se subieron por selecci<63>n del usuario: {string.Join(", ", ficherosNoSeleccionados)}.");
}
if (avisos.Count > 0)
{
operationNotice = $"Denuncia #{denunciaProcesadaId} enviada. {string.Join(" ", avisos)}";
}
StateHasChanged();
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error al confirmar envío: {ex}");
Console.Error.WriteLine($"Error al confirmar env<EFBFBD>o: {ex}");
operationError = $"No se ha podido completar el env<6E>o de la denuncia #{selectedDenuncias?.Id_Denuncia}: {ex.Message}";
}
finally
{
@@ -1217,10 +1262,11 @@ else
{
selectedDenuncias = d;
nuevoAsunto = $"Denuncia {d.Id_Denuncia}-CD";
archivoSeleccionado = preselectedFicheros.GetValueOrDefault(d.Id_Denuncia, reportTxt);
selectedThirdParty = ThirdPartyIdentityData.FromComplaint(d);
terceroDni = selectedThirdParty.DocumentId;
operationError = string.Empty;
operationNotice = string.Empty;
showModal = true;
}
@@ -1238,16 +1284,79 @@ else
selectedDenuncias = null;
selectedThirdParty = null;
nuevoAsunto = string.Empty;
archivoSeleccionado = reportTxt;
terceroDni = string.Empty;
operationError = string.Empty;
}
private void CloseRechazoModal() =>
(showModalRechazo, selectedDenuncias, motivoRechazo) =
(false, null, string.Empty);
private void SelectArchivo(string f) => archivoSeleccionado = f;
private void PreselectFichero(int id, string f) => preselectedFicheros[id] = f;
private bool IsFileSelectedForUpload(int denunciaId, string? fileName)
{
if (IsReportFileName(fileName))
{
return true;
}
return string.IsNullOrWhiteSpace(fileName) ||
!excludedUploadFiles.TryGetValue(denunciaId, out var excluded) ||
!excluded.Contains(fileName);
}
private void ToggleFileSelection(int denunciaId, string? fileName, bool selected)
{
if (string.IsNullOrWhiteSpace(fileName) || IsReportFileName(fileName))
{
return;
}
if (!excludedUploadFiles.TryGetValue(denunciaId, out var excluded))
{
excluded = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
excludedUploadFiles[denunciaId] = excluded;
}
if (selected)
{
excluded.Remove(fileName);
if (excluded.Count == 0)
{
excludedUploadFiles.Remove(denunciaId);
}
}
else
{
excluded.Add(fileName);
}
}
private List<FicherosDenuncias> GetSelectedUploadFiles(int denunciaId, IEnumerable<FicherosDenuncias> files)
{
return files
.Where(file => IsReportFileName(file.NombreFichero) || IsFileSelectedForUpload(denunciaId, file.NombreFichero))
.ToList();
}
private List<string> GetExcludedUploadFileNames(int denunciaId, IEnumerable<FicherosDenuncias> files)
{
return files
.Where(file => !IsReportFileName(file.NombreFichero) && !IsFileSelectedForUpload(denunciaId, file.NombreFichero))
.Select(file => string.IsNullOrWhiteSpace(file.NombreFichero) ? "(sin nombre)" : file.NombreFichero)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(name => name, StringComparer.OrdinalIgnoreCase)
.ToList();
}
private static string BuildAttachmentContentUrl(int denunciaId, string? fileName)
{
return $"/api/denuncias/{denunciaId}/ficheros/content?fileName={Uri.EscapeDataString(fileName ?? string.Empty)}";
}
private static string FormatFileDate(DateTime date)
=> date == DateTime.MinValue
? "-"
: date.ToLocalTime().ToString("dd/MM/yyyy HH:mm", CultureInfo.InvariantCulture);
private string GetContentType(string fileName)
{
@@ -1270,7 +1379,7 @@ else
}
private static string GetReadOnlyValue(string? value) =>
string.IsNullOrWhiteSpace(value) ? "" : value;
string.IsNullOrWhiteSpace(value) ? "<EFBFBD>" : value;
private static bool HasPostalAddress(DenunciasGestiona denuncia)
{
@@ -1340,7 +1449,7 @@ else
StateHasChanged();
}
// ========= LÓGICA BUSCADOR DE EXPEDIENTES POR TERCERO =========
// ========= L<EFBFBD>GICA BUSCADOR DE EXPEDIENTES POR TERCERO =========
private void CloseExpedientesModal()
{
@@ -1359,7 +1468,7 @@ else
if (string.IsNullOrWhiteSpace(nif) || nif == "00000000T")
{
errorExpedientes = "NIF no válido para búsqueda (anónimo o vacío).";
errorExpedientes = "NIF no v<EFBFBD>lido para b<EFBFBD>squeda (an<EFBFBD>nimo o vac<EFBFBD>o).";
showExpedientesModal = true;
StateHasChanged();
return;

View File

@@ -264,8 +264,7 @@ else
<td>@fichero.NombreFichero</td>
<td>@fichero.Fichero.Length</td>
<td>
<a class="btn btn-primary btn-sm" href="#"
onclick="openFile(event, '@Convert.ToBase64String(fichero.Fichero)', '@GetContentType(fichero.NombreFichero)');">
<a class="btn btn-primary btn-sm" href="@BuildAttachmentContentUrl(denuncia.Id_Denuncia, fichero.NombreFichero)" target="_blank" rel="noopener">
<i class="bi bi-eye"></i> Ver
</a>
</td>
@@ -314,7 +313,7 @@ else
if (firstRender)
{
await CargarRechazadasAsync();
await CargarFicherosAdjuntosAsync();
await CargarFicherosAdjuntosAsync(denunciasRechazadas.Select(d => d.Id_Denuncia));
hasLoaded = true;
StateHasChanged();
}
@@ -332,21 +331,26 @@ else
private async Task<List<DenunciasGestiona>> CargarDenunciasJsonAsync()
{
return await DenunciaStore.GetAllDenunciasAsync();
return await DenunciaStore.GetDenunciasByScopeAsync(DenunciaListScope.Rejected);
}
private async Task<List<FicherosDenuncias>> CargarFicherosJsonAsync()
private async Task CargarFicherosAdjuntosAsync(IEnumerable<int> denunciaIds)
{
return await DenunciaStore.GetAllFicherosAsync();
ficherosAdjuntos.Clear();
foreach (var denunciaId in denunciaIds.Where(id => id > 0).Distinct())
{
var ficheros = await DenunciaStore.GetFicherosByDenunciaAsync(denunciaId);
if (ficheros.Count > 0)
{
ficherosAdjuntos[denunciaId] = ficheros;
}
}
}
private async Task CargarFicherosAdjuntosAsync()
private static string BuildAttachmentContentUrl(int denunciaId, string? fileName)
{
var listaFicheros = await CargarFicherosJsonAsync();
ficherosAdjuntos = listaFicheros.GroupBy(f => f.Id_Denuncia)
.ToDictionary(g => g.Key, g => g.ToList());
return $"/api/denuncias/{denunciaId}/ficheros/content?fileName={Uri.EscapeDataString(fileName ?? string.Empty)}";
}
private string GetContentType(string fileName)
{
var ext = Path.GetExtension(fileName).ToLowerInvariant();

View File

@@ -5,4 +5,5 @@ public sealed class ApiDenunciasOptions
public const string SectionName = "ApiDenuncias";
public string BaseUrl { get; set; } = "https://localhost:7093";
public int LoginTimeoutSeconds { get; set; } = 150;
}

View File

@@ -19,7 +19,11 @@ CultureInfo.DefaultThreadCurrentUICulture = CultureInfo.GetCultureInfo("es-ES");
builder.Services.Configure<ApiDenunciasOptions>(builder.Configuration.GetSection(ApiDenunciasOptions.SectionName));
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
.AddInteractiveServerComponents(options =>
{
options.DetailedErrors = true;
options.JSInteropDefaultCallTimeout = TimeSpan.FromSeconds(180);
});
builder.Services.AddCascadingAuthenticationState();
builder.Services
@@ -37,12 +41,17 @@ builder.Services
builder.Services.AddAuthorization();
builder.Services.AddDataProtection();
builder.Services.AddServerSideBlazor().AddCircuitOptions(option => { option.DetailedErrors = true; });
builder.Services.AddServerSideBlazor().AddCircuitOptions(option =>
{
option.DetailedErrors = true;
option.JSInteropDefaultCallTimeout = TimeSpan.FromSeconds(180);
});
builder.Services.AddHttpContextAccessor();
builder.Services.AddAntiforgery();
builder.Services.AddScoped<UserState>();
builder.Services.AddSingleton<AppSessionLifetime>();
builder.Services.AddSingleton<LoginRateLimiter>();
builder.Services.AddScoped<UiBusyService>();
builder.Services.AddScoped<ApiDenunciasClient>();
builder.Services.AddScoped<IDenunciaStore, ApiDenunciaStore>();
builder.Services.AddScoped<IInboxTrackingService, ApiInboxTrackingService>();
@@ -68,7 +77,7 @@ app.Use(async (context, next) =>
context.Response.Headers.XContentTypeOptions = "nosniff";
context.Response.Headers["Referrer-Policy"] = "no-referrer";
context.Response.Headers.ContentSecurityPolicy =
"default-src 'self'; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; img-src 'self' data:; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net";
"default-src 'self'; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; font-src 'self' data: https://cdn.jsdelivr.net; img-src 'self' data:; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net";
await next();
});
@@ -140,11 +149,60 @@ app.UseAntiforgery();
var api = app.MapGroup("/api");
api.MapPost("/auth/prepare", async (
ApiLoginPrepareRequest request,
ApiDenunciasClient apiClient,
IOptions<ApiDenunciasOptions> apiOptions,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(request.Username) ||
string.IsNullOrWhiteSpace(request.Password))
{
return Results.Json(
new ApiError("Debes indicar usuario y contrasena."),
statusCode: StatusCodes.Status400BadRequest);
}
try
{
var loginTimeoutSeconds = Math.Clamp(apiOptions.Value.LoginTimeoutSeconds, 15, 300);
using var loginTimeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
loginTimeout.CancelAfter(TimeSpan.FromSeconds(loginTimeoutSeconds));
var prepared = await apiClient.PrepareLoginAsync(
request with { Username = request.Username.Trim() },
loginTimeout.Token);
return Results.Ok(prepared);
}
catch (UnauthorizedAccessException ex)
{
return Results.Json(new ApiError(ex.Message), statusCode: StatusCodes.Status401Unauthorized);
}
catch (InvalidOperationException ex)
{
return Results.Json(new ApiError(ex.Message), statusCode: StatusCodes.Status400BadRequest);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
return Results.Json(
new ApiError($"La API de denuncias no ha respondido en {apiOptions.Value.LoginTimeoutSeconds} segundos ({apiOptions.Value.BaseUrl})."),
statusCode: StatusCodes.Status504GatewayTimeout);
}
catch (HttpRequestException ex)
{
return Results.Json(
new ApiError($"No se ha podido conectar con la API de denuncias ({apiOptions.Value.BaseUrl}). Detalle: {ex.Message}"),
statusCode: StatusCodes.Status503ServiceUnavailable);
}
}).DisableAntiforgery();
api.MapPost("/auth/login", async (
LoginRequest request,
HttpContext httpContext,
ApiDenunciasClient apiClient,
LoginRateLimiter rateLimiter,
IOptions<ApiDenunciasOptions> apiOptions,
CancellationToken cancellationToken) =>
{
var ip = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
@@ -174,13 +232,17 @@ api.MapPost("/auth/login", async (
try
{
var loginTimeoutSeconds = Math.Clamp(apiOptions.Value.LoginTimeoutSeconds, 15, 300);
using var loginTimeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
loginTimeout.CancelAfter(TimeSpan.FromSeconds(loginTimeoutSeconds));
var login = await apiClient.LoginAsync(
request with
{
Username = request.Username.Trim(),
Authcode = request.Authcode.Trim()
},
cancellationToken);
loginTimeout.Token);
var claims = new List<Claim>
{
@@ -220,6 +282,104 @@ api.MapPost("/auth/login", async (
new ApiError(ex.Message),
statusCode: StatusCodes.Status400BadRequest);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
return Results.Json(
new ApiError($"La API de denuncias no ha respondido en {apiOptions.Value.LoginTimeoutSeconds} segundos ({apiOptions.Value.BaseUrl}). Comprueba los logs de ApiDenuncias: probablemente esta esperando a GlobalLeaks o a una dependencia externa."),
statusCode: StatusCodes.Status504GatewayTimeout);
}
catch (HttpRequestException ex)
{
return Results.Json(
new ApiError($"No se ha podido conectar con la API de denuncias ({apiOptions.Value.BaseUrl}). Detalle: {ex.Message}"),
statusCode: StatusCodes.Status503ServiceUnavailable);
}
}).DisableAntiforgery();
api.MapPost("/auth/complete", async (
ApiLoginCompleteRequest request,
HttpContext httpContext,
ApiDenunciasClient apiClient,
IOptions<ApiDenunciasOptions> apiOptions,
CancellationToken cancellationToken) =>
{
var appSessionLifetime = httpContext.RequestServices.GetRequiredService<AppSessionLifetime>();
if (string.IsNullOrWhiteSpace(request.PendingLoginId) ||
string.IsNullOrWhiteSpace(request.Authcode))
{
return Results.Json(
new ApiError("Debes indicar el codigo 2FA."),
statusCode: StatusCodes.Status400BadRequest);
}
if (!Regex.IsMatch(request.Authcode.Trim(), @"^\d{6}$"))
{
return Results.Json(
new ApiError("El codigo 2FA debe tener exactamente 6 digitos."),
statusCode: StatusCodes.Status400BadRequest);
}
try
{
var loginTimeoutSeconds = Math.Clamp(apiOptions.Value.LoginTimeoutSeconds, 15, 300);
using var loginTimeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
loginTimeout.CancelAfter(TimeSpan.FromSeconds(loginTimeoutSeconds));
var login = await apiClient.CompleteLoginAsync(
request with { Authcode = request.Authcode.Trim() },
loginTimeout.Token);
var claims = new List<Claim>
{
new(ClaimTypes.Name, login.Username),
new("app_startup_stamp", appSessionLifetime.StartupStamp),
new(ApiDenunciasClient.AccessTokenClaim, login.AccessToken),
new(ApiDenunciasClient.TokenExpiresAtClaim, login.ExpiresAtUtc.ToString("O", CultureInfo.InvariantCulture)),
};
if (!string.IsNullOrWhiteSpace(login.Role))
{
claims.Add(new Claim("gl_role", login.Role));
}
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
var principal = new ClaimsPrincipal(identity);
var authProperties = new AuthenticationProperties
{
IsPersistent = false,
AllowRefresh = true,
};
await httpContext.SignInAsync(
CookieAuthenticationDefaults.AuthenticationScheme,
principal,
authProperties);
return Results.Ok(new LoginResponse(login.Username));
}
catch (UnauthorizedAccessException ex)
{
return Results.Json(new ApiError(ex.Message), statusCode: StatusCodes.Status401Unauthorized);
}
catch (InvalidOperationException ex)
{
return Results.Json(
new ApiError(ex.Message),
statusCode: StatusCodes.Status400BadRequest);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
return Results.Json(
new ApiError($"La API de denuncias no ha respondido en {apiOptions.Value.LoginTimeoutSeconds} segundos ({apiOptions.Value.BaseUrl})."),
statusCode: StatusCodes.Status504GatewayTimeout);
}
catch (HttpRequestException ex)
{
return Results.Json(
new ApiError($"No se ha podido conectar con la API de denuncias ({apiOptions.Value.BaseUrl}). Detalle: {ex.Message}"),
statusCode: StatusCodes.Status503ServiceUnavailable);
}
}).DisableAntiforgery();
api.MapPost("/auth/logout", async (
@@ -243,6 +403,48 @@ api.MapPost("/auth/logout", async (
return Results.Ok(new { ok = true });
}).DisableAntiforgery();
api.MapGet("/denuncias/{denunciaId:int}/ficheros/content", async (
int denunciaId,
string fileName,
HttpContext httpContext,
IHttpClientFactory httpClientFactory,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(fileName))
{
return Results.BadRequest(new ApiError("Nombre de fichero obligatorio."));
}
var token = httpContext.User.FindFirst(ApiDenunciasClient.AccessTokenClaim)?.Value;
if (string.IsNullOrWhiteSpace(token))
{
return Results.Unauthorized();
}
var client = httpClientFactory.CreateClient(ApiDenunciasClient.HttpClientName);
var path = $"api/denuncias/{denunciaId}/ficheros/content?fileName={Uri.EscapeDataString(fileName)}";
using var request = new HttpRequestMessage(HttpMethod.Get, path);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
using var response = await client.SendAsync(request, cancellationToken);
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
return Results.NotFound();
}
if (!response.IsSuccessStatusCode)
{
var message = await response.Content.ReadAsStringAsync(cancellationToken);
return Results.Problem(message, statusCode: (int)response.StatusCode);
}
var bytes = await response.Content.ReadAsByteArrayAsync(cancellationToken);
var contentType = response.Content.Headers.ContentType?.ToString() ?? "application/octet-stream";
httpContext.Response.Headers.CacheControl = "no-store";
return Results.File(bytes, contentType, enableRangeProcessing: true);
}).RequireAuthorization();
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode();

View File

@@ -15,7 +15,19 @@ public sealed class ApiDenunciaStore : IDenunciaStore
=> _api.PostAsync("api/denuncias/schema/ensure", body: null, cancellationToken);
public async Task<List<DenunciasGestiona>> GetAllDenunciasAsync(CancellationToken cancellationToken = default)
=> (await _api.GetAsync<List<DenunciasGestiona>>("api/denuncias", cancellationToken)) ?? [];
=> await GetDenunciasByScopeAsync(DenunciaListScope.All, cancellationToken);
public async Task<List<DenunciasGestiona>> GetDenunciasByScopeAsync(
DenunciaListScope scope,
CancellationToken cancellationToken = default)
{
var path = scope == DenunciaListScope.All
? "api/denuncias"
: $"api/denuncias?scope={Uri.EscapeDataString(scope.ToString())}";
return (await _api.GetAsync<List<DenunciasGestiona>>(path, cancellationToken)) ?? [];
}
public async Task<List<FicherosDenuncias>> GetAllFicherosAsync(CancellationToken cancellationToken = default)
=> (await _api.GetAsync<List<FicherosDenuncias>>("api/denuncias/ficheros", cancellationToken)) ?? [];

View File

@@ -35,17 +35,34 @@ public sealed class ApiDenunciasClient
public Task<ApiLoginResponse> LoginAsync(LoginRequest request, CancellationToken cancellationToken = default)
=> SendAsync<ApiLoginResponse>(HttpMethod.Post, "api/auth/login", request, authorize: false, cancellationToken);
public Task<ApiLoginPrepareResponse> PrepareLoginAsync(ApiLoginPrepareRequest request, CancellationToken cancellationToken = default)
=> SendAsync<ApiLoginPrepareResponse>(HttpMethod.Post, "api/auth/login/prepare", request, authorize: false, cancellationToken);
public Task<ApiLoginResponse> CompleteLoginAsync(ApiLoginCompleteRequest request, CancellationToken cancellationToken = default)
=> SendAsync<ApiLoginResponse>(HttpMethod.Post, "api/auth/login/complete", request, authorize: false, cancellationToken);
public Task LogoutAsync(CancellationToken cancellationToken = default)
=> SendAsync<object?>(HttpMethod.Post, "api/auth/logout", body: null, authorize: true, cancellationToken);
public Task<ApiGlobalLeaksSessionDto?> GetGlobalLeaksSessionAsync(CancellationToken cancellationToken = default)
=> SendAsync<ApiGlobalLeaksSessionDto?>(HttpMethod.Get, "api/inbox/session", body: null, authorize: true, cancellationToken, allowNull: true);
public Task<ApiGlobalLeaksSessionDto> RenewGlobalLeaksSessionAsync(string authcode, CancellationToken cancellationToken = default)
public Task<ApiLoginPrepareResponse> PrepareGlobalLeaksSessionRenewalAsync(CancellationToken cancellationToken = default)
=> SendAsync<ApiLoginPrepareResponse>(
HttpMethod.Post,
"api/inbox/session/renew/prepare",
body: null,
authorize: true,
cancellationToken);
public Task<ApiGlobalLeaksSessionDto> RenewGlobalLeaksSessionAsync(
string authcode,
string? pendingLoginId = null,
CancellationToken cancellationToken = default)
=> SendAsync<ApiGlobalLeaksSessionDto>(
HttpMethod.Post,
"api/inbox/session/renew",
new RenewGlobalLeaksSessionRequest(authcode),
new RenewGlobalLeaksSessionRequest(authcode, pendingLoginId),
authorize: true,
cancellationToken);
@@ -63,6 +80,25 @@ public sealed class ApiDenunciasClient
authorize: true,
cancellationToken);
public Task<ReportDetailDto> GetReportDetailAsync(
string reportId,
string? lastAccess = null,
CancellationToken cancellationToken = default)
{
var path = $"api/inbox/reports/{Uri.EscapeDataString(reportId)}/detail";
if (!string.IsNullOrWhiteSpace(lastAccess))
{
path += $"?lastAccess={Uri.EscapeDataString(lastAccess)}";
}
return SendAsync<ReportDetailDto>(
HttpMethod.Get,
path,
body: null,
authorize: true,
cancellationToken);
}
public Task EnsureStorageReadyAsync(CancellationToken cancellationToken = default)
=> SendAsync<object?>(HttpMethod.Post, "api/inbox/local/ensure-storage", body: null, authorize: true, cancellationToken);
@@ -185,6 +221,34 @@ public sealed class ApiDenunciasClient
return GetAsync<List<ExpedienteTerceroDto>>(path, cancellationToken);
}
public Task<ManualPurgeResponse> ExecuteManualPurgeAsync(
string date,
CancellationToken cancellationToken = default)
=> PostAsync<ManualPurgeResponse>(
"api/purge/manual",
new ManualPurgeRequest(date),
cancellationToken);
public Task<ManualPurgeResponse> ExecuteCurrentManualPurgeAsync(
CancellationToken cancellationToken = default)
=> PostAsync<ManualPurgeResponse>(
"api/purge/manual/current",
body: null,
cancellationToken);
public Task<AppConfigurationDto> GetAppConfigurationAsync(CancellationToken cancellationToken = default)
=> GetAsync<AppConfigurationDto>("api/configuration", cancellationToken);
public Task<AppConfigurationDto> UpdateExternalUpdateCutoffDateAsync(
string? date,
CancellationToken cancellationToken = default)
=> SendAsync<AppConfigurationDto>(
HttpMethod.Put,
"api/configuration/external-update-cutoff",
new UpdateExternalUpdateCutoffRequest(date),
authorize: true,
cancellationToken);
internal Task<T> GetAsync<T>(string path, CancellationToken cancellationToken = default, bool allowNull = false)
=> SendAsync<T>(HttpMethod.Get, path, body: null, authorize: true, cancellationToken, allowNull);

View File

@@ -0,0 +1,140 @@
namespace GestionaDenunciasAN.Services;
public sealed class UiBusyService
{
private readonly object _syncRoot = new();
private long _scopeCounter;
private long _activeScope;
public event Action? Changed;
public bool IsVisible { get; private set; }
public string Title { get; private set; } = string.Empty;
public string Message { get; private set; } = string.Empty;
public string? Detail { get; private set; }
public int? Current { get; private set; }
public int? Total { get; private set; }
public bool IsIndeterminate => Total is not > 0 || Current is null;
public int ProgressPercent
{
get
{
if (Total is not > 0 || Current is null)
{
return 0;
}
return Math.Clamp((int)Math.Round(Current.Value * 100d / Total.Value), 0, 100);
}
}
public IDisposable Show(string title, string message, int? total = null, string? detail = null)
{
var scopeId = Interlocked.Increment(ref _scopeCounter);
lock (_syncRoot)
{
_activeScope = scopeId;
IsVisible = true;
Title = title;
Message = message;
Detail = detail;
Total = total;
Current = total is > 0 ? 0 : null;
}
NotifyChanged();
return new BusyScope(this, scopeId);
}
public void Update(
string? title = null,
string? message = null,
string? detail = null,
int? current = null,
int? total = null)
{
lock (_syncRoot)
{
if (!IsVisible)
{
return;
}
if (title is not null)
{
Title = title;
}
if (message is not null)
{
Message = message;
}
if (detail is not null)
{
Detail = detail;
}
if (total is not null)
{
Total = total;
}
if (current is not null)
{
Current = current;
}
}
NotifyChanged();
}
public void Hide()
{
Hide(null);
}
private void Hide(long? scopeId)
{
lock (_syncRoot)
{
if (scopeId is not null && scopeId.Value != _activeScope)
{
return;
}
IsVisible = false;
Title = string.Empty;
Message = string.Empty;
Detail = null;
Current = null;
Total = null;
}
NotifyChanged();
}
private void NotifyChanged()
{
Changed?.Invoke();
}
private sealed class BusyScope(UiBusyService owner, long scopeId) : IDisposable
{
private bool _disposed;
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
owner.Hide(scopeId);
}
}
}

View File

@@ -8,6 +8,7 @@
"AllowedHosts": "*",
"ForceHttpsRedirection": false,
"ApiDenuncias": {
"BaseUrl": "http://localhost:7093"
"BaseUrl": "http://localhost:7093",
"LoginTimeoutSeconds": 150
}
}

View File

@@ -34,6 +34,11 @@ body {
margin: 0;
}
html.app-scroll-locked,
body.app-scroll-locked {
overflow: hidden;
}
a,
.btn-link {
color: var(--app-accent);
@@ -197,7 +202,6 @@ pre {
border: 1px solid rgba(90, 155, 213, 0.14);
box-shadow: var(--app-shadow-soft);
padding: 1.5rem;
backdrop-filter: blur(10px);
}
.app-content > .container,

View File

@@ -1,12 +1,23 @@
window.appAuthPostJson = async function (url, body) {
const response = await fetch(url, {
method: "POST",
credentials: "same-origin",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(body)
});
let response;
try {
response = await fetch(url, {
method: "POST",
credentials: "same-origin",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(body)
});
} catch (error) {
return {
ok: false,
status: 0,
data: {
error: "No se ha podido conectar con el servidor de la aplicacion. " + (error?.message ?? "")
}
};
}
let data = null;
try {
@@ -23,10 +34,21 @@ window.appAuthPostJson = async function (url, body) {
};
window.appAuthPost = async function (url) {
const response = await fetch(url, {
method: "POST",
credentials: "same-origin"
});
let response;
try {
response = await fetch(url, {
method: "POST",
credentials: "same-origin"
});
} catch (error) {
return {
ok: false,
status: 0,
data: {
error: "No se ha podido conectar con el servidor de la aplicacion. " + (error?.message ?? "")
}
};
}
let data = null;
try {
@@ -41,3 +63,8 @@ window.appAuthPost = async function (url) {
data
};
};
window.appSetBodyScrollLock = function (locked) {
document.documentElement.classList.toggle("app-scroll-locked", Boolean(locked));
document.body.classList.toggle("app-scroll-locked", Boolean(locked));
};

View File

@@ -96,7 +96,7 @@
public string? cl { get; set; } = "";
private bool mostrarGuardar = false;
private bool canRenderTabs = false;
private static PERSONAS persona = new PERSONAS();
private PERSONAS persona = new PERSONAS();
private string errorMessage = "";
private HttpClient cliente = new HttpClient();
private string lblNombre = "";
@@ -138,12 +138,22 @@
mostrar = false;
}
}
private EditContext editContext = new EditContext(persona);
private EditContext editContext = default!;
private ValidationMessageStore? messageStore;
private void EstablecerPersona(PERSONAS nuevaPersona)
{
persona = nuevaPersona;
editContext = new EditContext(persona);
messageStore = new ValidationMessageStore(editContext);
listaIdentificadores.Clear();
}
protected override async Task OnInitializedAsync()
{
EstablecerPersona(new PERSONAS());
try
{
var url = NavigationManager.ToAbsoluteUri(NavigationManager.Uri);
@@ -155,8 +165,11 @@
if (string.IsNullOrEmpty(cl))
{
persona = new PERSONAS();
EstablecerPersona(new PERSONAS());
lblNombre = "";
mostrarBtn = true;
mostrar = false;
return;
}
cliente = Utilidades.ObtenerCliente(UserState.Token, HttpClientFactory);
@@ -173,10 +186,9 @@
}
var resultContent = await response.Content.ReadAsStringAsync();
persona = JsonConvert.DeserializeObject<PERSONAS>(resultContent) ?? throw new Exception("Error al deserializar los datos de la persona.");
var personaCargada = JsonConvert.DeserializeObject<PERSONAS>(resultContent) ?? throw new Exception("Error al deserializar los datos de la persona.");
EstablecerPersona(personaCargada);
lblNombre = persona.NOMBRE + " " + persona.APELLIDOS;
editContext = new EditContext(persona);
messageStore = new ValidationMessageStore(editContext);
mostrar = false;
@@ -245,7 +257,9 @@
}
var resultContent = await response.Content.ReadAsStringAsync();
persona = JsonConvert.DeserializeObject<PERSONAS>(resultContent) ?? throw new Exception("Error al deserializar los datos de la persona.");
var personaActualizada = JsonConvert.DeserializeObject<PERSONAS>(resultContent) ?? throw new Exception("Error al deserializar los datos de la persona.");
EstablecerPersona(personaActualizada);
lblNombre = persona.NOMBRE + " " + persona.APELLIDOS;
mensajes.Add(new ToastMessage
{
Type = ToastType.Primary,

View File

@@ -205,6 +205,7 @@ else
<input class="form-control @GetCssClass(nameof(Model.DENOMINACION))"
value="@Model!.DENOMINACION"
placeholder="@GetPlaceholder(nameof(Model.DENOMINACION))"
maxlength="190"
@oninput="e => ValidarYActualizar(e, nameof(Model.DENOMINACION))" />
<div class="validation-message">@GetExternalValidationMessage(nameof(Model.DENOMINACION))</div>
</div>

View File

@@ -49,6 +49,18 @@
</div>
</div>
}
@if (mostrarMensajeEliminarLinea == true)
{
<div class="loadingFrameVida">
<div class="popupRPCard row">
<p>¿Esta seguro de eliminar esta linea de vida administrativa?</p>
<div class="col-12 d-flex gap-2 justify-content-end">
<input type="button" class="btnGris" value="Continuar" @onclick="BorrarLineaVida" />
<input type="button" class="btnOAAFAzul" value="Cancelar" @onclick="cerrarEliminarLineaVida" />
</div>
</div>
</div>
}
@if (mostrarAnadirLineaVida == true)
{
<div class="loadingFrameVida">
@@ -385,9 +397,9 @@
<span @onclick="@(() => abrirEditarLineaVida(context))" style="cursor: pointer;">
<Icon CustomIconName="fas fa-edit"></Icon>
</span>
@* <span @onclick="@(() => abrirPopupConfirmarBorrado(@context))" style="cursor: pointer;">
<span @onclick="@(() => mostrarAvisoEliminarLinea(context))" style="cursor: pointer;">
<Icon CustomIconName="fas fa-trash"></Icon>
</span> *@
</span>
</GridColumn>
</GridColumns>
@@ -408,6 +420,7 @@
private bool nueva = false;
private bool mostrarMensajeCuerpo = false;
private bool mostrarMensajeEliminar = false;
private bool mostrarMensajeEliminarLinea = false;
private bool mostrarAnadirLineaVida = false;
private bool mostrarEditarLineaVida = false;
private string usuarioVida { get; set; } = "";
@@ -862,9 +875,11 @@
protected void OcultarMenCuer() { mostrarMensajeCuerpo = false; }
protected void OcultarMenElim() { mostrarMensajeEliminar = false; }
protected void mostrarAvisoEliminar() { mostrarMensajeEliminar = true; }
protected void mostrarAvisoEliminarLinea(LINEASVIDAADMINISTRATIVA linea){lineaEditada = linea;mostrarMensajeEliminarLinea = true; }
protected void abrirAnadirLineaVida() { mostrarAnadirLineaVida = true; }
protected void abrirEditarLineaVida(LINEASVIDAADMINISTRATIVA linea) { mostrarEditarLineaVida = true; lineaEditada = linea; DESCRIPCIONLINEAVIDAED = linea.DESCRIPCION; IDTIPODOCUED = linea.IDTIPO; }
protected void cerrarAnadirLineaVida() { mostrarAnadirLineaVida = false; }
protected void cerrarEliminarLineaVida() { lineaEditada = null; mostrarAnadirLineaVida = false; }
protected void cerrarEditarLineaVida() { mostrarEditarLineaVida = false; }
protected void volver() { Navigation.NavigateTo(LINKPERSONA, forceLoad: true); }
protected string urlFich(int id)
@@ -1346,6 +1361,28 @@
if (dot.IsSuccessStatusCode) { Navigation.NavigateTo(LINKPERSONA, forceLoad: true); }
}
protected async void BorrarLineaVida()
{
var idlinea = lineaEditada.IDLINEAVIDAADMIN;
var dot = await client.DeleteAsync("/api/LINEASVIDAADMINISTRATIVA/" + idlinea);
var dotContent = await dot.Content.ReadAsStringAsync();
if (dot.IsSuccessStatusCode)
{
var fichTrans = new AlmacenaFicheroAtransmitir();
fichTrans.IdRegistro = idlinea;
fichTrans.Tabla = "LINEAVIDAADMINISTRATIVA";
var jsonConsulta = JsonConvert.SerializeObject(fichTrans);
var content = new StringContent(jsonConsulta, Encoding.UTF8, "application/json");
var ficherotrans = await client.PostAsync("/api/Almacenamiento/eliminar-fichero", content);
var fichContent = await ficherotrans.Content.ReadAsStringAsync();
}
cargarGridLineas();
cerrarEliminarLineaVida();
mostrar = false;
StateHasChanged();
}
private void abrirPopupGestionLinea(LINEASVIDAADMINISTRATIVA objeto)
{
DESCRIPCIONLINEAVIDAED = objeto.DESCRIPCION!;

View File

@@ -46,6 +46,23 @@ namespace SwaggerAntifraude.Controllers
return Ok(resultado);
}
return StatusCode(500, resultado);
}
[Authorize(Policy = "SupervisorPolicy")]
[HttpPost("eliminar-fichero")]
public IActionResult EliminarFichero(PeticionFichero solicitud)
{
if (string.IsNullOrEmpty(solicitud.Tabla) || solicitud.IdRegistro <= 0)
return BadRequest("Solicitud inválida. Asegúrese de enviar los parámetros requeridos.");
var resultado = _servicio.EliminarFicheroAtransmitir(solicitud.IdRegistro, solicitud.Tabla, solicitud.Nif);
if (resultado.Resultado == 0)
{
// Devolver el archivo como un PDF descargable
return Ok(resultado);
}
return StatusCode(500, resultado);
}
}

View File

@@ -0,0 +1,23 @@
using bdAntifraude.db;
using bdAntifraude.dbcontext;
using Microsoft.AspNetCore.Mvc;
using System.Diagnostics;
using Microsoft.AspNetCore.Authorization;
using Serialize.Linq.Serializers;
using System.Linq.Expressions;
using Microsoft.EntityFrameworkCore;
namespace SwaggerAntifraude.Controllers
{
[ApiController]
[Route("api/[controller]")]
public class ENFERMEDADESController : GenericoController<ENFERMEDADES, int>
{
public ENFERMEDADESController()
: base()
{
Debug.WriteLine("aqui");
}
}
}

View File

@@ -0,0 +1,23 @@
using bdAntifraude.db;
using bdAntifraude.dbcontext;
using Microsoft.AspNetCore.Mvc;
using System.Diagnostics;
using Microsoft.AspNetCore.Authorization;
using Serialize.Linq.Serializers;
using System.Linq.Expressions;
using Microsoft.EntityFrameworkCore;
namespace SwaggerAntifraude.Controllers
{
[ApiController]
[Route("api/[controller]")]
public class MATERNIDADESController : GenericoController<MATERNIDADES, int>
{
public MATERNIDADESController()
: base()
{
Debug.WriteLine("aqui");
}
}
}

View File

@@ -91,9 +91,6 @@ namespace SwaggerAntifraude.Servicios
return resultado;
}
public ResultadoObtenFicheroAtransmitir DevolverObtenFicheroAtransmitir(int idRegistro, string tabla, string nif)
{
var resultado = new ResultadoObtenFicheroAtransmitir();
@@ -181,11 +178,99 @@ namespace SwaggerAntifraude.Servicios
resultado.Mensaje += $" {ex.InnerException.Message}";
}
}
return resultado;
}
public ResultadoObtenFicheroAtransmitir EliminarFicheroAtransmitir(int idRegistro, string tabla, string nif)
{
var resultado = new ResultadoObtenFicheroAtransmitir();
try
{
// Obtener el nombre de la base de datos utilizando el contexto
string baseDeDatos;
using (var context = tsGestionAntifraude.NuevoContexto(SoloLectura: true))
{
baseDeDatos = context.Database.GetDbConnection().Database;
}
Conf = new ConfigurationBuilder()
.AddJsonFile("appsettings.json")
.Build();
baseDeDatos = Conf.GetSection("BaseDatos").Value; ;
//if (string.IsNullOrEmpty(baseDeDatos))
//{
// baseDeDatos = "preproduccion";
//}
// Generar la ruta base
string ruta = $"{baseDeDatos.ToLower()}/registrodepersonal";
switch (tabla.ToUpper())
{
case "LINEAVIDAADMINISTRATIVA":
ruta += "/lineavidaadministrativa/";
break;
case "FORMACION":
ruta += "/formacion/";
break;
case "TITULACION":
ruta += "/titulacion/";
break;
case "DOCENCIA":
ruta += "/docencia/";
break;
case "INCIDENCIA":
ruta = $"{baseDeDatos.ToLower()}/control_horario/";
break;
case "CSV":
using (var context = tsGestionAntifraude.NuevoContexto(SoloLectura: true))
{
//ruta += context.VALIDACIONDOCUMENTOS.First(x => x.CSV == nif).RUTAFICHERO;
}
break;
default:
resultado.Resultado = 1;
resultado.Mensaje = "Tabla no contemplada entre las posibles para tener ficheros";
return resultado;
}
if (tabla.ToUpper() != "CSV")
{
ruta += $"{idRegistro}.pdf";
}
resultado.Mensaje = $"Ruta: {ruta}";
// Descargar el archivo desde el servidor SFTP
using (var context = tsGestionAntifraude.NuevoContexto(SoloLectura: true))
using (var clienteSftp = ConectarServidorSftp(context))
{
clienteSftp.Connect();
//using var memoryStream = new MemoryStream();
clienteSftp.DeleteFile(ruta);
clienteSftp.Disconnect();
//resultado.Pdf = memoryStream.ToArray();
}
resultado.Resultado = 0;
}
catch (Exception ex)
{
resultado.Resultado = 1;
resultado.Mensaje = $"ERROR de generación: {ex.Message}";
if (ex.InnerException != null)
{
resultado.Mensaje += $" {ex.InnerException.Message}";
}
}
return resultado;
}
private SftpClient ConectarServidorSftp(tsGestionAntifraude context)
{
var configuraciones = context.ENUMERACIONES