Compare commits
12 Commits
b15a3a5a4f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 27705440cd | |||
| 6d22d5d97a | |||
| 8636283695 | |||
| 9db3d3fa61 | |||
| 84305493de | |||
| d84d41b0e0 | |||
| ff2867d916 | |||
| 8458d9eae1 | |||
| afa1863038 | |||
| 8163928623 | |||
| b62cfd46c1 | |||
| 693d950dfa |
@@ -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-->
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
71
Antifraude.Net/ApiDenuncias/Controllers/PurgeController.cs
Normal file
71
Antifraude.Net/ApiDenuncias/Controllers/PurgeController.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) : ' ');
|
||||
}
|
||||
|
||||
|
||||
@@ -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) : ' ');
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
-- ============================================================
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace ApiDenuncias.Services;
|
||||
|
||||
public interface IEnvelopeEncryptionKeyProvider
|
||||
{
|
||||
ValueTask<EncryptionKeyMaterial> GetCurrentKeyAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
ValueTask<EncryptionKeyMaterial> GetKeyForDateAsync(DateOnly keyDate, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
252
Antifraude.Net/ApiDenuncias/Services/ManualPurgeService.cs
Normal file
252
Antifraude.Net/ApiDenuncias/Services/ManualPurgeService.cs
Normal 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));
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 _);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = "";
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace GestionaDenuncias.Shared.Models;
|
||||
|
||||
public enum DenunciaListScope
|
||||
{
|
||||
All = 0,
|
||||
Pending = 1,
|
||||
Updates = 2,
|
||||
InGestiona = 3,
|
||||
Rejected = 4
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
185
Antifraude.Net/GestionaDenunciasAN/Components/BusyOverlay.razor
Normal file
185
Antifraude.Net/GestionaDenunciasAN/Components/BusyOverlay.razor
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -4,4 +4,6 @@
|
||||
<div class="">
|
||||
@Body
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BusyOverlay />
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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)) ?? [];
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
140
Antifraude.Net/GestionaDenunciasAN/Services/UiBusyService.cs
Normal file
140
Antifraude.Net/GestionaDenunciasAN/Services/UiBusyService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
"AllowedHosts": "*",
|
||||
"ForceHttpsRedirection": false,
|
||||
"ApiDenuncias": {
|
||||
"BaseUrl": "http://localhost:7093"
|
||||
"BaseUrl": "http://localhost:7093",
|
||||
"LoginTimeoutSeconds": 150
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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!;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user