Compare commits
7 Commits
8458d9eae1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 27705440cd | |||
| 6d22d5d97a | |||
| 8636283695 | |||
| 9db3d3fa61 | |||
| 84305493de | |||
| d84d41b0e0 | |||
| ff2867d916 |
@@ -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;
|
||||
}
|
||||
@@ -80,7 +80,8 @@ public sealed class AuthController : ControllerBase
|
||||
var pending = _pendingLoginStore.Create(
|
||||
prepared.Username,
|
||||
request.Password,
|
||||
prepared.FinalPassword);
|
||||
prepared.FinalPassword,
|
||||
prepared.TokenAnswer);
|
||||
|
||||
return Ok(new ApiLoginPrepareResponse(pending.Id, pending.Username, pending.ExpiresAtUtc));
|
||||
}
|
||||
@@ -149,6 +150,7 @@ public sealed class AuthController : ControllerBase
|
||||
var session = await _globalLeaksClient.CompleteLoginAsync(
|
||||
pending.Username,
|
||||
pending.FinalPassword,
|
||||
pending.TokenAnswer,
|
||||
request.Authcode.Trim(),
|
||||
loginCancellation.Token);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -189,6 +255,7 @@ 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();
|
||||
@@ -200,7 +267,7 @@ public sealed class InboxController : ControllerBase
|
||||
|
||||
try
|
||||
{
|
||||
return Ok(await _globalLeaksClient.GetReportDetailAsync(session.SessionId!, reportId, cancellationToken));
|
||||
return Ok(await _globalLeaksClient.GetReportDetailAsync(session.SessionId!, reportId, lastAccess, cancellationToken));
|
||||
}
|
||||
catch (GlobalLeaksSessionExpiredException)
|
||||
{
|
||||
|
||||
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();
|
||||
@@ -34,11 +35,16 @@ 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) =>
|
||||
{
|
||||
@@ -111,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;
|
||||
@@ -52,14 +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(
|
||||
MediaTypeWithQualityHeaderValue.Parse("application/vnd.gestiona.file-document+json; version=4"));
|
||||
|
||||
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)
|
||||
@@ -105,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)
|
||||
@@ -211,10 +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.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)
|
||||
{
|
||||
@@ -236,6 +223,7 @@ public sealed class GestionaDocumentWorkflowService
|
||||
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)
|
||||
{
|
||||
@@ -291,4 +279,15 @@ public sealed class GestionaDocumentWorkflowService
|
||||
return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -119,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}");
|
||||
@@ -187,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}");
|
||||
@@ -205,9 +208,11 @@ namespace ApiDenuncias.Services
|
||||
Content = content
|
||||
};
|
||||
req.Headers.TryAddWithoutValidation("Prefer", "return=minimal");
|
||||
AddTokenAndAccept(req, "application/vnd.gestiona.file-folder+json", "1");
|
||||
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}");
|
||||
@@ -225,10 +230,12 @@ namespace ApiDenuncias.Services
|
||||
{
|
||||
using var createReq = new HttpRequestMessage(HttpMethod.Post, "/rest/uploads");
|
||||
createReq.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", _opts.AccessToken);
|
||||
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}");
|
||||
@@ -254,6 +261,7 @@ namespace ApiDenuncias.Services
|
||||
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}");
|
||||
@@ -294,10 +302,9 @@ namespace ApiDenuncias.Services
|
||||
metaReq.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", _opts.AccessToken);
|
||||
metaReq.Headers.TryAddWithoutValidation("Prefer", "return=minimal");
|
||||
metaReq.Headers.Accept.Clear();
|
||||
metaReq.Headers.Accept.Add(
|
||||
MediaTypeWithQualityHeaderValue.Parse("application/vnd.gestiona.file-document+json; version=4"));
|
||||
|
||||
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}");
|
||||
@@ -513,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)
|
||||
@@ -529,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
|
||||
@@ -546,6 +581,25 @@ namespace ApiDenuncias.Services
|
||||
};
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -559,7 +613,7 @@ namespace ApiDenuncias.Services
|
||||
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);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
@@ -13,7 +13,7 @@ using Microsoft.Extensions.Options;
|
||||
|
||||
namespace ApiDenuncias.Services;
|
||||
|
||||
public sealed record PreparedGlobalLeaksCredentials(string Username, string FinalPassword);
|
||||
public sealed record PreparedGlobalLeaksCredentials(string Username, string FinalPassword, string TokenAnswer);
|
||||
|
||||
public sealed class GlobalLeaksClient
|
||||
{
|
||||
@@ -54,7 +54,12 @@ public sealed class GlobalLeaksClient
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var prepared = await PrepareLoginAsync(username, password, cancellationToken);
|
||||
return await CompleteLoginAsync(prepared.Username, prepared.FinalPassword, authcode, cancellationToken);
|
||||
return await CompleteLoginAsync(
|
||||
prepared.Username,
|
||||
prepared.FinalPassword,
|
||||
prepared.TokenAnswer,
|
||||
authcode,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<PreparedGlobalLeaksCredentials> PrepareLoginAsync(
|
||||
@@ -90,12 +95,14 @@ public sealed class GlobalLeaksClient
|
||||
username,
|
||||
passwordWatch.ElapsedMilliseconds);
|
||||
|
||||
var tokenAnswer = await PrepareProofOfWorkAsync(username, cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"GlobalLeaks login: credenciales preparadas para {Username}. Tiempo total={ElapsedMs} ms",
|
||||
"GlobalLeaks login: credenciales y proof-of-work preparados para {Username}. Tiempo total={ElapsedMs} ms",
|
||||
username,
|
||||
loginWatch.ElapsedMilliseconds);
|
||||
|
||||
return new PreparedGlobalLeaksCredentials(username, finalPassword);
|
||||
return new PreparedGlobalLeaksCredentials(username, finalPassword, tokenAnswer);
|
||||
}
|
||||
|
||||
public async Task<GlSession> CompleteLoginAsync(
|
||||
@@ -103,13 +110,87 @@ public sealed class GlobalLeaksClient
|
||||
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: completando autenticacion para {Username}. AuthcodeLength={AuthcodeLength}",
|
||||
"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 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 (codigo {(int)authResponse.StatusCode})."
|
||||
: $"Login fallido (codigo {(int)authResponse.StatusCode}): {body}",
|
||||
(int)authResponse.StatusCode),
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -123,51 +204,7 @@ public sealed class GlobalLeaksClient
|
||||
"GlobalLeaks login: proof-of-work resuelto para {Username} en {ElapsedMs} ms",
|
||||
username,
|
||||
proofWatch.ElapsedMilliseconds);
|
||||
|
||||
using var authRequest = CreateRequest(
|
||||
HttpMethod.Post,
|
||||
$"/api/auth/authentication?token={Uri.EscapeDataString(tokenAnswer)}");
|
||||
authRequest.Content = CreateJsonContent(new
|
||||
{
|
||||
tid = 1,
|
||||
username,
|
||||
password = finalPassword,
|
||||
authcode = authcode?.Trim() ?? string.Empty,
|
||||
});
|
||||
authRequest.Headers.TryAddWithoutValidation("X-Token", tokenAnswer);
|
||||
|
||||
using var authResponse = await SendLoginRequestAsync(authRequest, "/api/auth/authentication", cancellationToken);
|
||||
if (!authResponse.IsSuccessStatusCode)
|
||||
{
|
||||
var body = await ReadBodySafeAsync(authResponse, cancellationToken);
|
||||
throw authResponse.StatusCode switch
|
||||
{
|
||||
HttpStatusCode.Unauthorized => new GlobalLeaksValidationException(
|
||||
"Credenciales incorrectas o código 2FA inválido.",
|
||||
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}",
|
||||
(int)authResponse.StatusCode),
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
return tokenAnswer;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ContextDto>> GetContextsAsync(
|
||||
@@ -187,7 +224,8 @@ public sealed class GlobalLeaksClient
|
||||
string? filter,
|
||||
string? dateFrom,
|
||||
string? dateTo,
|
||||
CancellationToken cancellationToken)
|
||||
CancellationToken cancellationToken,
|
||||
IReadOnlyList<ContextDto>? contexts = null)
|
||||
{
|
||||
filter ??= "all";
|
||||
|
||||
@@ -197,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;
|
||||
|
||||
@@ -263,21 +301,11 @@ public sealed class GlobalLeaksClient
|
||||
public async Task<ReportDetailDto> GetReportDetailAsync(
|
||||
string sessionId,
|
||||
string reportId,
|
||||
string? lastAccess,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ValidateUuid(reportId);
|
||||
|
||||
using var listRequest = CreateAuthenticatedRequest(HttpMethod.Get, "/api/recipient/rtips", sessionId);
|
||||
using var listResponse = await SendGlRequestAsync(listRequest, cancellationToken);
|
||||
var listBody = await listResponse.Content.ReadAsStringAsync(cancellationToken);
|
||||
var metadata = ParseReports(listBody)
|
||||
.FirstOrDefault(report => string.Equals(report.Id, reportId, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (metadata is null)
|
||||
{
|
||||
throw new GlobalLeaksValidationException("Denuncia no encontrada en GlobalLeaks.", 404);
|
||||
}
|
||||
|
||||
using var detailRequest = CreateAuthenticatedRequest(HttpMethod.Get, $"/api/recipient/rtips/{reportId}", sessionId);
|
||||
using var detailResponse = await SendGlRequestAsync(detailRequest, cancellationToken);
|
||||
|
||||
@@ -291,7 +319,7 @@ public sealed class GlobalLeaksClient
|
||||
}
|
||||
|
||||
using var document = JsonDocument.Parse(content);
|
||||
return ParseReportDetail(reportId, metadata.LastAccess, document.RootElement);
|
||||
return ParseReportDetail(reportId, lastAccess, document.RootElement);
|
||||
}
|
||||
|
||||
public async Task<FileDownloadResult> DownloadReportZipAsync(
|
||||
@@ -887,6 +915,13 @@ public sealed class GlobalLeaksClient
|
||||
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);
|
||||
|
||||
@@ -8,6 +8,7 @@ public sealed record PendingGlobalLeaksLogin(
|
||||
string Username,
|
||||
string Password,
|
||||
string FinalPassword,
|
||||
string TokenAnswer,
|
||||
DateTimeOffset ExpiresAtUtc);
|
||||
|
||||
public sealed class PendingGlobalLeaksLoginStore
|
||||
@@ -15,7 +16,7 @@ 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)
|
||||
public PendingGlobalLeaksLogin Create(string username, string password, string finalPassword, string tokenAnswer)
|
||||
{
|
||||
CleanupExpired();
|
||||
|
||||
@@ -25,6 +26,7 @@ public sealed class PendingGlobalLeaksLoginStore
|
||||
username,
|
||||
password,
|
||||
finalPassword,
|
||||
tokenAnswer,
|
||||
DateTimeOffset.UtcNow.Add(Lifetime));
|
||||
|
||||
_items[id] = pending;
|
||||
|
||||
@@ -21,6 +21,15 @@
|
||||
"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"
|
||||
},
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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))
|
||||
{
|
||||
@@ -149,7 +149,30 @@
|
||||
<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>
|
||||
@@ -192,7 +215,13 @@
|
||||
</BodyTemplate>
|
||||
<FooterTemplate>
|
||||
<Button Color="ButtonColor.Secondary" @onclick="cerrarPopupModificacion">Cerrar</Button>
|
||||
<Button Type="ButtonType.Submit" Color="ButtonColor.Primary" @onclick="GestionarDatos">Modificar</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>
|
||||
|
||||
@@ -203,7 +232,10 @@
|
||||
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; }
|
||||
@@ -211,14 +243,31 @@
|
||||
// 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()
|
||||
@@ -229,14 +278,11 @@
|
||||
{
|
||||
try
|
||||
{
|
||||
itmList.Clear();
|
||||
annos.Clear();
|
||||
var listnom = Persona.INCIDENCIAS.Where(x => x.ESDELIQUIDACION == false).ToList();
|
||||
var nominaIncidenciadelcarajo = listnom.FirstOrDefault(x => x.IDINCIDENCIA == 56543);
|
||||
foreach (INCIDENCIAS i in listnom)
|
||||
{
|
||||
itmList.Add(i);
|
||||
}
|
||||
|
||||
|
||||
foreach (INCIDENCIAS i in listnom){ itmList.Add(i); }
|
||||
for (var i = 0; i < itmList.Count; i++)
|
||||
{
|
||||
int? year = itmList[i].AÑONOMINA;
|
||||
@@ -249,7 +295,10 @@
|
||||
}
|
||||
}
|
||||
annos = annos.OrderDescending().ToList();
|
||||
Task.Delay(1);
|
||||
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();
|
||||
|
||||
}
|
||||
@@ -259,10 +308,22 @@
|
||||
}
|
||||
}
|
||||
// GUARDAR
|
||||
|
||||
private async Task GestionarDatos()
|
||||
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";
|
||||
@@ -276,9 +337,13 @@
|
||||
string chbNominaSS = "chbNominaSS";
|
||||
inci.NOMINASEGURIDADSOCIAL = await JS.InvokeAsync<bool>("obtenerCheck", chbNominaSS);
|
||||
|
||||
|
||||
var cliente = Utilidades.ObtenerCliente(UserState.Token, HttpClientFactory);
|
||||
var response = await Utilidades.ActualizarObjeto(cliente, "/api/INCIDENCIAS/" + inci.IDINCIDENCIA, inci);
|
||||
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)
|
||||
@@ -289,11 +354,6 @@
|
||||
var resultContent = await response1.Content.ReadAsStringAsync();
|
||||
Persona = JsonConvert.DeserializeObject<PERSONAS>(resultContent) ?? throw new Exception("Error al deserializar los datos de la persona.");
|
||||
await CargarListas();
|
||||
// itmList.First(x => x.IDINCIDENCIA == inci.IDINCIDENCIA).IDNOMINANavigation = inci.IDNOMINANavigation;
|
||||
// itmList.First(x => x.IDINCIDENCIA == inci.IDINCIDENCIA).IDCONCEPTONOMINANavigation= inci.IDCONCEPTONOMINANavigation;
|
||||
|
||||
|
||||
|
||||
// await InvokeAsync(StateHasChanged);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -116,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."),
|
||||
|
||||
@@ -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
|
||||
@@ -58,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;
|
||||
}
|
||||
@@ -73,7 +73,7 @@
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* === Estética de modal igual que en Pendientes === */
|
||||
/* === Est<EFBFBD>tica de modal igual que en Pendientes === */
|
||||
|
||||
.custom-modal {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
@@ -119,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..."
|
||||
@@ -153,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">
|
||||
@@ -169,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
|
||||
@@ -195,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))
|
||||
@@ -215,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) ||
|
||||
@@ -229,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))
|
||||
@@ -239,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))
|
||||
@@ -264,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))
|
||||
{
|
||||
@@ -280,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))
|
||||
@@ -297,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>
|
||||
@@ -308,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>
|
||||
}
|
||||
@@ -327,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>
|
||||
@@ -352,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>
|
||||
@@ -410,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>
|
||||
@@ -431,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
|
||||
@@ -440,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>
|
||||
@@ -448,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>
|
||||
|
||||
@@ -458,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"
|
||||
@@ -471,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"
|
||||
@@ -491,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>
|
||||
}
|
||||
|
||||
@@ -526,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>
|
||||
@@ -539,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>
|
||||
@@ -553,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>
|
||||
|
||||
@@ -575,12 +626,15 @@ else
|
||||
}
|
||||
|
||||
@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;
|
||||
@@ -592,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;
|
||||
@@ -608,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();
|
||||
@@ -634,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)
|
||||
@@ -648,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;
|
||||
@@ -662,7 +737,7 @@ else
|
||||
|
||||
try
|
||||
{
|
||||
// Búsqueda automática desactivada temporalmente
|
||||
// B<EFBFBD>squeda autom<EFBFBD>tica desactivada temporalmente
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -693,6 +768,7 @@ else
|
||||
selectedThirdParty = null;
|
||||
nuevoAsunto = "";
|
||||
nombreDocumentos = "";
|
||||
operationError = string.Empty;
|
||||
autoFoundFileUrl = null;
|
||||
autoFoundTitle = null;
|
||||
autoSearchLoading = false;
|
||||
@@ -779,6 +855,8 @@ else
|
||||
try
|
||||
{
|
||||
isUploading = true;
|
||||
operationError = string.Empty;
|
||||
operationNotice = string.Empty;
|
||||
using var busy = Busy.Show(
|
||||
"Enviando actualizacion",
|
||||
"Preparando expediente, carpeta de actualizacion y documentos.");
|
||||
@@ -787,20 +865,34 @@ else
|
||||
|
||||
// 1) Ficheros a subir
|
||||
Busy.Update(message: "Cargando ficheros pendientes de esta actualizacion.", detail: "Paso 1 de 8");
|
||||
var existentesF = await CargarFicherosJsonAsync();
|
||||
var fDenuncia = GetPendingUpdateFiles(
|
||||
existentesF
|
||||
.Where(f => f.Id_Denuncia == selectedDenuncias.Id_Denuncia)
|
||||
.ToList());
|
||||
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;
|
||||
@@ -844,8 +936,9 @@ else
|
||||
selectedDenuncias.EnGestiona = true;
|
||||
}
|
||||
|
||||
Busy.Update(message: "Sincronizando numero de expediente de Gestiona.", detail: "Paso 3 de 8");
|
||||
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);
|
||||
|
||||
@@ -954,12 +1047,9 @@ else
|
||||
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;
|
||||
@@ -978,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
|
||||
{
|
||||
@@ -993,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
|
||||
@@ -1014,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)
|
||||
{
|
||||
@@ -1073,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))
|
||||
@@ -1096,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);
|
||||
@@ -1111,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -120,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>
|
||||
@@ -427,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;
|
||||
@@ -445,8 +447,10 @@
|
||||
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
|
||||
@@ -454,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";
|
||||
@@ -485,6 +492,12 @@
|
||||
SessionInfo = string.IsNullOrWhiteSpace(CurrentUsername)
|
||||
? null
|
||||
: await ApiDenuncias.GetGlobalLeaksSessionAsync();
|
||||
|
||||
if (CanUseGlobalLeaks)
|
||||
{
|
||||
RenewPendingLoginId = string.Empty;
|
||||
RenewAuthcode = string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RenewSessionAsync()
|
||||
@@ -495,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");
|
||||
@@ -505,11 +524,15 @@
|
||||
try
|
||||
{
|
||||
using var busy = Busy.Show(
|
||||
"Renovando sesion GlobalLeaks",
|
||||
"Validando el nuevo codigo 2FA para continuar descargando denuncias.");
|
||||
"Validando 2FA",
|
||||
"Completando la renovacion con el codigo actual de GlobalLeaks.");
|
||||
|
||||
SessionInfo = await ApiDenuncias.RenewGlobalLeaksSessionAsync(RenewAuthcode.Trim(), CancellationToken.None);
|
||||
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");
|
||||
@@ -524,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)
|
||||
@@ -545,6 +592,7 @@
|
||||
|
||||
Reports.Clear();
|
||||
Reports.AddRange(inbox.Reports);
|
||||
DetailCache.Clear();
|
||||
UserInboxState = inbox.UserState;
|
||||
ApplyFilters();
|
||||
}
|
||||
@@ -577,6 +625,7 @@
|
||||
ImportBusy = true;
|
||||
var importedCount = 0;
|
||||
var errors = new List<string>();
|
||||
var importWarnings = new List<string>();
|
||||
|
||||
try
|
||||
{
|
||||
@@ -605,6 +654,10 @@
|
||||
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)
|
||||
{
|
||||
@@ -626,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)
|
||||
{
|
||||
@@ -678,7 +732,13 @@
|
||||
"Leyendo detalle de denuncia",
|
||||
"Consultando mensajes y ficheros para identificar que contenido es nuevo.");
|
||||
|
||||
DetailData = await ApiDenuncias.GetReportDetailAsync(report.Id, CancellationToken.None);
|
||||
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)
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
@@ -1,4 +1,4 @@
|
||||
@page "/Pendientes"
|
||||
@page "/Pendientes"
|
||||
@rendermode InteractiveServer
|
||||
@attribute [Authorize]
|
||||
|
||||
@@ -160,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"
|
||||
@@ -243,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)
|
||||
@@ -283,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))
|
||||
@@ -293,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))
|
||||
@@ -318,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>
|
||||
@@ -335,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>
|
||||
@@ -346,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))
|
||||
@@ -356,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>
|
||||
@@ -368,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))
|
||||
@@ -378,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))
|
||||
@@ -402,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>
|
||||
@@ -422,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))
|
||||
{
|
||||
@@ -437,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>
|
||||
}
|
||||
@@ -456,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>
|
||||
@@ -527,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"
|
||||
@@ -540,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>
|
||||
|
||||
@@ -555,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">
|
||||
@@ -579,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">
|
||||
@@ -590,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">
|
||||
@@ -612,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>
|
||||
}
|
||||
|
||||
@@ -647,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>
|
||||
@@ -660,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>
|
||||
@@ -674,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))
|
||||
@@ -754,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))
|
||||
@@ -774,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>
|
||||
@@ -816,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 = "";
|
||||
@@ -870,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)
|
||||
{
|
||||
@@ -890,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)
|
||||
@@ -940,6 +959,8 @@ 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.");
|
||||
@@ -947,14 +968,29 @@ else
|
||||
await Task.Yield();
|
||||
|
||||
Busy.Update(message: "Cargando ficheros de la denuncia.", detail: "Paso 1 de 7");
|
||||
var existentesF = await CargarFicherosJsonAsync();
|
||||
var todos = existentesF
|
||||
.Where(f => f.Id_Denuncia == selectedDenuncias.Id_Denuncia)
|
||||
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))
|
||||
@@ -991,8 +1027,9 @@ else
|
||||
selectedDenuncias.EnGestiona = true;
|
||||
}
|
||||
|
||||
Busy.Update(message: "Sincronizando numero de expediente de Gestiona.", detail: "Paso 3 de 7");
|
||||
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");
|
||||
@@ -1120,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
|
||||
{
|
||||
@@ -1208,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;
|
||||
}
|
||||
@@ -1229,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)
|
||||
{
|
||||
@@ -1261,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)
|
||||
{
|
||||
@@ -1331,7 +1449,7 @@ else
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
// ========= LÓGICA BUSCADOR DE EXPEDIENTES POR TERCERO =========
|
||||
// ========= L<EFBFBD>GICA BUSCADOR DE EXPEDIENTES POR TERCERO =========
|
||||
|
||||
private void CloseExpedientesModal()
|
||||
{
|
||||
@@ -1350,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();
|
||||
|
||||
@@ -77,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();
|
||||
});
|
||||
|
||||
@@ -403,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)) ?? [];
|
||||
|
||||
|
||||
@@ -47,11 +47,22 @@ public sealed class ApiDenunciasClient
|
||||
public Task<ApiGlobalLeaksSessionDto?> GetGlobalLeaksSessionAsync(CancellationToken cancellationToken = default)
|
||||
=> SendAsync<ApiGlobalLeaksSessionDto?>(HttpMethod.Get, "api/inbox/session", body: null, authorize: true, cancellationToken, allowNull: true);
|
||||
|
||||
public Task<ApiGlobalLeaksSessionDto> 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);
|
||||
|
||||
@@ -69,13 +80,24 @@ public sealed class ApiDenunciasClient
|
||||
authorize: true,
|
||||
cancellationToken);
|
||||
|
||||
public Task<ReportDetailDto> GetReportDetailAsync(string reportId, CancellationToken cancellationToken = default)
|
||||
=> SendAsync<ReportDetailDto>(
|
||||
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,
|
||||
$"api/inbox/reports/{Uri.EscapeDataString(reportId)}/detail",
|
||||
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);
|
||||
@@ -199,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);
|
||||
|
||||
|
||||
@@ -138,7 +138,6 @@
|
||||
mostrar = false;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
private EditContext editContext = default!;
|
||||
private ValidationMessageStore? messageStore;
|
||||
|
||||
@@ -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