cambios de denuncias

This commit is contained in:
2026-04-06 17:12:06 +02:00
parent ec76b9a8ae
commit df0e7c0c15
62 changed files with 11751 additions and 1471 deletions

View File

@@ -11,14 +11,16 @@
<link rel="stylesheet" href="app.css" />
<link rel="stylesheet" href="GestionaDenunciasAN.styles.css" />
<link rel="icon" type="image/png" href="favicon.png" />
<HeadOutlet />
<HeadOutlet @rendermode="@(new InteractiveServerRenderMode(prerender: false))" />
</head>
<body>
<Routes />
<Routes @rendermode="@(new InteractiveServerRenderMode(prerender: false))" />
<script src="Scripts/bootstrap.bundle.min.js"></script>
<script src="js/appAuth.js"></script>
<script src="_framework/blazor.web.js"></script>
</body>
</html>

View File

@@ -0,0 +1,22 @@
@inject NavigationManager Navigation
<p class="m-4 text-muted">Redirigiendo al inicio...</p>
@code {
protected override void OnAfterRender(bool firstRender)
{
if (firstRender)
{
var relativePath = Navigation.ToBaseRelativePath(Navigation.Uri);
var targetPath = string.IsNullOrWhiteSpace(relativePath)
? "/"
: $"/{relativePath}";
var loginUrl = targetPath == "/"
? "/"
: $"/?returnUrl={Uri.EscapeDataString(targetPath)}";
Navigation.NavigateTo(loginUrl, true);
}
}
}

View File

@@ -1,88 +1,40 @@
@inherits LayoutComponentBase
@inherits LayoutComponentBase
@implements IDisposable
@inject GestionaDenunciasAN.Models.UserState userState
@inject IHttpContextAccessor HttpContextAccessor
@inject IJSRuntime JSRuntime
@inject NavigationManager Navigation
<style>
/* Barra superior con gradiente de azul: izquierda azul clarito, derecha azul oscuro */
.top-row {
position: relative;
width: 100%;
height: 60px;
background: linear-gradient(to right, #5a9bd5, #1f497d);
color: #fff;
}
/* Título del portal posicionado a la izquierda */
.portal-title {
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
font-weight: bold;
font-size: 1.2rem;
margin-left: 1rem;
}
/* Contenedor del enlace de usuario posicionado a la derecha */
.logout-container {
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
margin-right: 1rem;
}
/* Enlace que luce como texto, sin decoraciones */
.logout-link {
text-decoration: none;
color: #fff;
display: flex;
align-items: center;
border: none;
background: transparent;
cursor: pointer;
font: inherit;
padding: 0;
}
/* Icono SVG más grande */
.logout-link img {
height: 1.5em;
width: auto;
margin-right: 0.5rem;
}
/* Al pasar el ratón, se pone en negrita y mantiene el color blanco */
.logout-link:hover,
.logout-link:focus,
.logout-link:active {
font-weight: bold;
color: #fff !important;
text-decoration: none;
}
</style>
<div class="page">
<div class="sidebar">
<div class="app-shell">
<aside class="app-sidebar">
<NavMenu />
</div>
</aside>
<main>
<!-- Barra superior completa con gradiente de azul -->
<div class="top-row">
<div class="portal-title">
Portal Gestion Denuncias
<main class="app-main">
<header class="app-header">
<div class="app-header__intro">
<span class="app-header__eyebrow">Portal interno</span>
<h1 class="app-header__title">@CurrentPageTitle</h1>
<p class="app-header__copy mb-0">@CurrentPageDescription</p>
</div>
<div class="logout-container">
<a class="logout-link" href="/">
<img src="Content/icon/person-fill.svg" alt="User Icon" />
<span>@userState?.NombreUsu</span>
</a>
</div>
</div>
<article class="content">
<div class="app-header__actions">
<div class="app-session-pill">
<span class="app-session-pill__dot"></span>
Sesion interna activa
</div>
<button type="button" class="app-user-chip" @onclick="CerrarSesionAsync">
<span class="bi bi-person-circle app-user-chip__icon" aria-hidden="true"></span>
<span class="app-user-chip__text">
<strong>@DisplayUsername</strong>
<small>Cerrar sesion</small>
</span>
</button>
</div>
</header>
<article class="app-content">
@Body
</article>
</main>
@@ -91,5 +43,106 @@
<div id="blazor-error-ui">
An unhandled error has occurred.
<a href="" class="reload">Reload</a>
<a class="dismiss">🗙</a>
<a class="dismiss">x</a>
</div>
@code {
private string CurrentPageTitle { get; set; } = "Portal de gestion";
private string CurrentPageDescription { get; set; } =
"Entrada, revision y tramitacion coordinada de denuncias y actualizaciones.";
private string DisplayUsername =>
string.IsNullOrWhiteSpace(userState?.NombreUsu)
? "Usuario"
: userState.NombreUsu;
protected override void OnInitialized()
{
Navigation.LocationChanged += HandleLocationChanged;
RefreshLayoutState();
}
protected override void OnParametersSet()
{
RefreshLayoutState();
}
public void Dispose()
{
Navigation.LocationChanged -= HandleLocationChanged;
}
private void HandleLocationChanged(object? sender, LocationChangedEventArgs args)
{
RefreshLayoutState();
_ = InvokeAsync(StateHasChanged);
}
private void RefreshLayoutState()
{
SincronizarUsuario();
var pageInfo = ResolvePageInfo();
CurrentPageTitle = pageInfo.Title;
CurrentPageDescription = pageInfo.Description;
}
private (string Title, string Description) ResolvePageInfo()
{
var relative = Navigation.ToBaseRelativePath(Navigation.Uri);
var path = string.IsNullOrWhiteSpace(relative)
? string.Empty
: relative.Split('?', '#')[0].Trim('/');
return path.ToLowerInvariant() switch
{
"" or "gestionzip" => (
"Entrada de denuncias",
"Importa lo nuevo desde GlobalLeaks, revisa el seguimiento por usuario y decide si habra expediente nuevo o actualizacion."),
"pendientes" => (
"Denuncias pendientes",
"Prepara expedientes nuevos con todos los datos del formulario, documentos y tercero ya resueltos."),
"actualizaciones" => (
"Actualizaciones",
"Gestiona ampliaciones sobre expedientes existentes y sube solo los adjuntos realmente nuevos."),
"gestiona" => (
"Expedientes en Gestiona",
"Consulta el estado de los expedientes ya enviados y continua su seguimiento operativo."),
"rechazados" => (
"Denuncias rechazadas",
"Mantiene trazabilidad de los descartes y de los motivos aplicados en la revision."),
"buscador" => (
"Buscador de terceros",
"Localiza terceros y expedientes relacionados para validar identidades antes de tramitar."),
"instrucciones" => (
"Instrucciones",
"Referencia rapida de uso para el equipo gestor y para las operaciones mas frecuentes."),
_ => (
"Portal de gestion",
"Entrada, revision y tramitacion coordinada de denuncias y actualizaciones.")
};
}
private void SincronizarUsuario()
{
var user = HttpContextAccessor.HttpContext?.User;
if (user is null)
{
return;
}
var isAuthenticated = user.Identity?.IsAuthenticated == true;
userState.Token = isAuthenticated ? "authenticated" : string.Empty;
userState.NombreUsu = isAuthenticated
? (user.Identity?.Name ?? string.Empty)
: string.Empty;
}
private async Task CerrarSesionAsync()
{
await JSRuntime.InvokeAsync<object>("appAuthPost", "/api/auth/logout");
userState.Token = string.Empty;
userState.NombreUsu = string.Empty;
Navigation.NavigateTo("/", true);
}
}

View File

@@ -1,96 +1 @@
.page {
position: relative;
display: flex;
flex-direction: column;
}
main {
flex: 1;
}
.sidebar {
background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%);
}
.top-row {
background-color: #f7f7f7;
border-bottom: 1px solid #d6d5d5;
justify-content: flex-end;
height: 3.5rem;
display: flex;
align-items: center;
}
.top-row ::deep a, .top-row ::deep .btn-link {
white-space: nowrap;
margin-left: 1.5rem;
text-decoration: none;
}
.top-row ::deep a:hover, .top-row ::deep .btn-link:hover {
text-decoration: underline;
}
.top-row ::deep a:first-child {
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 640.98px) {
.top-row {
justify-content: space-between;
}
.top-row ::deep a, .top-row ::deep .btn-link {
margin-left: 0;
}
}
@media (min-width: 641px) {
.page {
flex-direction: row;
}
.sidebar {
width: 250px;
height: 100vh;
position: sticky;
top: 0;
}
.top-row {
position: sticky;
top: 0;
z-index: 1;
}
.top-row.auth ::deep a:first-child {
flex: 1;
text-align: right;
width: 0;
}
.top-row, article {
padding-left: 2rem !important;
padding-right: 1.5rem !important;
}
}
#blazor-error-ui {
background: lightyellow;
bottom: 0;
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
display: none;
left: 0;
padding: 0.6rem 1.25rem 0.7rem 1.25rem;
position: fixed;
width: 100%;
z-index: 1000;
}
#blazor-error-ui .dismiss {
cursor: pointer;
position: absolute;
right: 0.75rem;
top: 0.5rem;
}
/* Layout styles moved to wwwroot/app.css for a shared app-wide theme. */

View File

@@ -1,99 +1,82 @@
<!-- NavMenu.razor -->
<style>
.nav-menu-container {
height: 100vh;
overflow: hidden;
}
/* Parte superior con el logo */
.nav-top {
background-color: #5a9bd5 !important;
padding: 0.5rem;
height: 5em;
display: flex;
align-items: center;
justify-content: center;
}
.nav-top img {
height: 4em;
width: auto;
}
/* Sección scrollable para el contenido del menú */
.nav-scrollable {
background: linear-gradient(to bottom, #5a9bd5, #1f497d);
height: calc(100vh - 5em);
overflow-y: auto;
padding-top: 1rem;
}
/* Empujar el grupo inferior (Instrucciones) al fondo, con un margen inferior */
.bottom-group {
margin-top: auto;
margin-bottom: 1rem;
}
/* Ajuste de los enlaces para icono y texto en la misma línea */
.nav-link {
display: inline-flex;
align-items: center;
font-size: 1rem;
}
.nav-link .bi {
display: inline-block;
font-size: 1.25rem;
margin-right: 0.5rem;
line-height: 1;
vertical-align: middle;
}
</style>
<div class="nav-menu-container">
<!-- Parte superior: logo -->
<div class="nav-top">
<div class="container-fluid" style="display: flex; justify-content: center; align-items: center;">
<img src="Content/imagenes/logo-oaaf-negativo-transparente.svg" alt="logo" />
<div class="nav-shell">
<div class="nav-brand">
<img class="nav-brand__logo"
src="Content/imagenes/logo-oaaf-negativo-transparente.svg"
alt="Logo Oficina Antifraude" />
<div class="nav-brand__copy">
<span class="nav-brand__eyebrow">OAAF</span>
<strong class="nav-brand__title">Gestion de denuncias</strong>
<span class="nav-brand__subtitle">Entrada, revision y tramitacion unificada</span>
</div>
</div>
<!-- Contenido scrollable: menú de navegación -->
<div class="nav-scrollable" onclick="document.querySelector('.navbar-toggler').click()">
<nav class="flex-column" style="display: flex; height: 100%;">
<!-- Grupo superior: Pendientes y Finalizados -->
<div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="GestionZip" Match="NavLinkMatch.All">
<span class="bi bi-list-task" aria-hidden="true"></span> Gestion ZIP
</NavLink>
</div>
<nav class="nav-sections" aria-label="Navegacion principal">
<div class="nav-section">
<span class="nav-section__label">Operativa diaria</span>
<div class="nav-item px-3">
<NavLink class="nav-link" href="Pendientes" Match="NavLinkMatch.All">
<span class="bi bi-list-task" aria-hidden="true"></span> Pendientes
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="Gestiona" Match="NavLinkMatch.All">
<span class="bi bi-journal-check" aria-hidden="true"></span> Gestiona
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="Rechazados" Match="NavLinkMatch.All">
<span class="bi bi-journal-x" aria-hidden="true"></span> Rechazados
</NavLink>
</div>
</div>
<NavLink class="menu-link" href="/GestionZip" Match="NavLinkMatch.All">
<span class="menu-link__icon bi bi-box-seam" aria-hidden="true"></span>
<span class="menu-link__content">
<span class="menu-link__title">Entrada</span>
<span class="menu-link__meta">Importar denuncias y renovar 2FA</span>
</span>
</NavLink>
<!-- Grupo inferior: Instrucciones, empujado al fondo -->
<div class="bottom-group">
<div class="nav-item px-3">
<NavLink class="nav-link" href="Instrucciones">
<span class="bi bi-book-half" aria-hidden="true"></span> Instrucciones
</NavLink>
</div>
</div>
</nav>
</div>
<NavLink class="menu-link" href="/Pendientes" Match="NavLinkMatch.All">
<span class="menu-link__icon bi bi-inbox" aria-hidden="true"></span>
<span class="menu-link__content">
<span class="menu-link__title">Pendientes</span>
<span class="menu-link__meta">Expedientes nuevos listos para tramitar</span>
</span>
</NavLink>
<NavLink class="menu-link" href="/Actualizaciones" Match="NavLinkMatch.All">
<span class="menu-link__icon bi bi-arrow-repeat" aria-hidden="true"></span>
<span class="menu-link__content">
<span class="menu-link__title">Actualizaciones</span>
<span class="menu-link__meta">Nuevos documentos sobre expedientes ya abiertos</span>
</span>
</NavLink>
</div>
<div class="nav-section">
<span class="nav-section__label">Consulta y control</span>
<NavLink class="menu-link" href="/Gestiona" Match="NavLinkMatch.All">
<span class="menu-link__icon bi bi-journal-check" aria-hidden="true"></span>
<span class="menu-link__content">
<span class="menu-link__title">Gestiona</span>
<span class="menu-link__meta">Seguimiento de expedientes enviados</span>
</span>
</NavLink>
<NavLink class="menu-link" href="/Rechazados" Match="NavLinkMatch.All">
<span class="menu-link__icon bi bi-journal-x" aria-hidden="true"></span>
<span class="menu-link__content">
<span class="menu-link__title">Rechazados</span>
<span class="menu-link__meta">Historico de descartes y motivos</span>
</span>
</NavLink>
<NavLink class="menu-link" href="/Buscador" Match="NavLinkMatch.All">
<span class="menu-link__icon bi bi-search" aria-hidden="true"></span>
<span class="menu-link__content">
<span class="menu-link__title">Buscador de terceros</span>
<span class="menu-link__meta">Consulta identidades y expedientes vinculados</span>
</span>
</NavLink>
</div>
<div class="nav-section nav-section--footer">
<span class="nav-section__label">Ayuda</span>
<NavLink class="menu-link" href="/Instrucciones" Match="NavLinkMatch.All">
<span class="menu-link__icon bi bi-book-half" aria-hidden="true"></span>
<span class="menu-link__content">
<span class="menu-link__title">Instrucciones</span>
<span class="menu-link__meta">Guia rapida para el equipo gestor</span>
</span>
</NavLink>
</div>
</nav>
</div>

View File

@@ -1,105 +1 @@
.navbar-toggler {
appearance: none;
cursor: pointer;
width: 3.5rem;
height: 2.5rem;
color: white;
position: absolute;
top: 0.5rem;
right: 1rem;
border: 1px solid rgba(255, 255, 255, 0.1);
background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e") no-repeat center/1.75rem rgba(255, 255, 255, 0.1);
}
.navbar-toggler:checked {
background-color: rgba(255, 255, 255, 0.5);
}
.top-row {
height: 3.5rem;
background-color: rgba(0,0,0,0.4);
}
.navbar-brand {
font-size: 1.1rem;
}
.bi {
display: inline-block;
position: relative;
width: 1.25rem;
height: 1.25rem;
margin-right: 0.75rem;
top: -1px;
background-size: cover;
}
.bi-house-door-fill-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-house-door-fill' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5Z'/%3E%3C/svg%3E");
}
.bi-plus-square-fill-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-plus-square-fill' viewBox='0 0 16 16'%3E%3Cpath d='M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2zm6.5 4.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3a.5.5 0 0 1 1 0z'/%3E%3C/svg%3E");
}
.bi-list-nested-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-list-nested' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M4.5 11.5A.5.5 0 0 1 5 11h10a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 3 7h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 1 3h10a.5.5 0 0 1 0 1H1a.5.5 0 0 1-.5-.5z'/%3E%3C/svg%3E");
}
.nav-item {
font-size: 0.9rem;
padding-bottom: 0.5rem;
}
.nav-item:first-of-type {
padding-top: 1rem;
}
.nav-item:last-of-type {
padding-bottom: 1rem;
}
.nav-item ::deep .nav-link {
color: #d7d7d7;
background: none;
border: none;
border-radius: 4px;
height: 3rem;
display: flex;
align-items: center;
line-height: 3rem;
width: 100%;
}
.nav-item ::deep a.active {
background-color: rgba(255,255,255,0.37);
color: white;
}
.nav-item ::deep .nav-link:hover {
background-color: rgba(255,255,255,0.1);
color: white;
}
.nav-scrollable {
display: none;
}
.navbar-toggler:checked ~ .nav-scrollable {
display: block;
}
@media (min-width: 641px) {
.navbar-toggler {
display: none;
}
.nav-scrollable {
/* Never collapse the sidebar for wide screens */
display: block;
/* Allow sidebar to scroll for tall menus */
height: calc(100vh - 3.5rem);
overflow-y: auto;
}
}
/* Navigation styles moved to wwwroot/app.css for a shared app-wide theme. */

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,593 @@
@page "/Buscador"
@rendermode InteractiveServer
@attribute [Authorize]
@using System.Net.Http
@using System.Net.Http.Headers
@using System.Text
@using System.Text.Json
@using System.Linq
@using GestionaDenunciasAN.Models
@inject HttpClient Http
@inject IConfiguration Configuration
<h3>Buscador de expedientes por tercero</h3>
<div class="card mt-3">
<div class="card-body">
<!-- NIF -->
<div class="row g-2">
<div class="col-md-4">
<label class="form-label">DNI / NIF</label>
<input class="form-control"
placeholder="Ej.: 12345678Z"
@bind="nifBuscado"
@bind:event="oninput" />
</div>
</div>
<hr />
<!-- Modo de fechas -->
<div class="row g-2">
<div class="col-12">
<label class="form-label d-block">Rango de fechas</label>
<div class="form-check form-check-inline">
<input class="form-check-input"
type="radio"
id="modoTodas"
name="modoFecha"
checked="@IsModo(ModoTodas)"
@onchange="@(() => SetModo(ModoTodas))" />
<label class="form-check-label" for="modoTodas">
Todas las fechas
</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input"
type="radio"
id="modoRango"
name="modoFecha"
checked="@IsModo(ModoRango)"
@onchange="@(() => SetModo(ModoRango))" />
<label class="form-check-label" for="modoRango">
Entre fechas
</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input"
type="radio"
id="modoUltimos"
name="modoFecha"
checked="@IsModo(ModoUltimos)"
@onchange="@(() => SetModo(ModoUltimos))" />
<label class="form-check-label" for="modoUltimos">
Últimos X meses
</label>
</div>
</div>
</div>
<!-- Rango de fechas -->
@if (modoFecha == ModoRango)
{
<div class="row g-2 mt-2">
<div class="col-md-3">
<label class="form-label">Desde</label>
<input class="form-control"
type="date"
@bind="fechaDesde" />
</div>
<div class="col-md-3">
<label class="form-label">Hasta</label>
<input class="form-control"
type="date"
@bind="fechaHasta" />
</div>
</div>
}
else if (modoFecha == ModoUltimos)
{
<div class="row g-2 mt-2">
<div class="col-md-3">
<label class="form-label">Últimos meses</label>
<select class="form-select" @bind="mesesUltimos">
<option value="3">3 meses</option>
<option value="6">6 meses</option>
<option value="9">9 meses</option>
</select>
</div>
</div>
}
<!-- Botón buscar -->
<div class="row g-2 mt-3">
<div class="col-md-3">
<button class="btn btn-primary"
@onclick="BuscarAsync"
disabled="@isSearching">
@if (isSearching)
{
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
<span class="ms-2">Buscando…</span>
}
else
{
<i class="bi bi-search"></i>
<span class="ms-1">Buscar</span>
}
</button>
</div>
</div>
@if (!string.IsNullOrWhiteSpace(errorMessage))
{
<div class="alert alert-danger mt-3">@errorMessage</div>
}
</div>
</div>
@if (haBuscado)
{
<div class="mt-4">
<h5>Resultados para <strong>@nifMostrado</strong></h5>
@if (expedientes == null || !expedientes.Any())
{
<div class="alert alert-warning mt-3">
No se han encontrado expedientes asociados a este tercero con los filtros seleccionados.
</div>
}
else
{
<table class="table table-striped table-hover mt-3">
<thead>
<tr>
<th>Expediente</th>
<th>Asunto</th>
<th>Fecha creación</th>
<th>Estado</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var exp in expedientes)
{
<tr>
<td>@exp.CodigoExpediente</td>
<td>@exp.Asunto</td>
<td>@(exp.FechaCreacion?.ToLocalTime().ToString("dd/MM/yyyy HH:mm"))</td>
<td>@exp.Estado</td>
<td class="text-end">
<button class="btn btn-sm btn-outline-primary"
@onclick="() => ToggleDetalle(exp)">
@(expedienteSeleccionado == exp ? "Ocultar" : "Abrir expediente")
</button>
</td>
</tr>
@if (expedienteSeleccionado == exp)
{
<tr class="table-active">
<td colspan="5">
<dl class="row mb-0">
<dt class="col-sm-2">Expediente</dt>
<dd class="col-sm-10">@exp.CodigoExpediente</dd>
<dt class="col-sm-2">Asunto</dt>
<dd class="col-sm-10">@exp.Asunto</dd>
<dt class="col-sm-2">Fecha creación</dt>
<dd class="col-sm-10">
@exp.FechaCreacion?.ToLocalTime().ToString("dd/MM/yyyy HH:mm")
</dd>
<dt class="col-sm-2">Estado</dt>
<dd class="col-sm-10">@exp.Estado</dd>
@if (!string.IsNullOrWhiteSpace(exp.FileUrl))
{
<dt class="col-sm-2">Enlace Gestiona</dt>
<dd class="col-sm-10">
<a href="@exp.FileUrl" target="_blank">@exp.FileUrl</a>
</dd>
}
</dl>
</td>
</tr>
}
}
</tbody>
</table>
}
</div>
}
@code {
// ============================================================
// CONFIG (mismo origen que GestionaService)
// ============================================================
private string ApiBase => Configuration["Gestiona:ApiBase"] ?? "";
private string AccessToken => Configuration["Gestiona:AccessToken"] ?? "";
private string RestBaseUrl =>
string.IsNullOrWhiteSpace(ApiBase)
? ""
: $"{ApiBase.TrimEnd('/')}/rest";
private readonly JsonSerializerOptions jsonOpts = new()
{
PropertyNameCaseInsensitive = true
};
// ============================================================
// ESTADO UI
// ============================================================
private string nifBuscado = string.Empty;
private string nifMostrado = string.Empty;
private const string ModoTodas = "todas";
private const string ModoRango = "rango";
private const string ModoUltimos = "ultimos";
private string modoFecha = ModoTodas;
private DateTime? fechaDesde;
private DateTime? fechaHasta;
private int mesesUltimos = 3;
private bool isSearching;
private bool haBuscado;
private string errorMessage = string.Empty;
private List<ExpedienteTerceroDto> expedientes = new();
// expediente cuyo detalle está abierto
private ExpedienteTerceroDto? expedienteSeleccionado;
private bool IsModo(string valor) => string.Equals(modoFecha, valor, StringComparison.Ordinal);
private void SetModo(string valor) => modoFecha = valor;
private void ToggleDetalle(ExpedienteTerceroDto exp)
{
if (expedienteSeleccionado == exp)
expedienteSeleccionado = null;
else
expedienteSeleccionado = exp;
}
// ============================================================
// MODELOS AUXILIARES (similares a tu servicio)
// ============================================================
private class LinkDto
{
public string Rel { get; set; } = string.Empty;
public string Href { get; set; } = string.Empty;
}
private class ThirdDto
{
public string Id { get; set; } = string.Empty;
public string Nif { get; set; } = string.Empty;
public List<LinkDto> Links { get; set; } = new();
}
// ============================================================
// BÚSQUEDA PRINCIPAL
// ============================================================
private async Task BuscarAsync()
{
errorMessage = string.Empty;
haBuscado = false;
expedienteSeleccionado = null;
expedientes.Clear();
var nif = (nifBuscado ?? string.Empty).Trim().ToUpperInvariant();
if (string.IsNullOrWhiteSpace(nif))
{
errorMessage = "Introduce un NIF para buscar.";
return;
}
if (string.IsNullOrWhiteSpace(RestBaseUrl) || string.IsNullOrWhiteSpace(AccessToken))
{
errorMessage = "No está configurada la conexión a Gestiona (ApiBase / AccessToken).";
return;
}
DateTimeOffset? desde = null;
DateTimeOffset? hasta = null;
// Calcular rango según el modo
if (modoFecha == ModoRango)
{
if (!fechaDesde.HasValue || !fechaHasta.HasValue)
{
errorMessage = "Debes indicar las dos fechas (Desde y Hasta).";
return;
}
desde = new DateTimeOffset(fechaDesde.Value.Date, TimeSpan.Zero);
hasta = new DateTimeOffset(fechaHasta.Value.Date.AddDays(1).AddTicks(-1), TimeSpan.Zero);
if (desde > hasta)
{
errorMessage = "La fecha 'Desde' no puede ser mayor que 'Hasta'.";
return;
}
}
else if (modoFecha == ModoUltimos)
{
if (mesesUltimos <= 0)
{
errorMessage = "El número de meses debe ser mayor que 0.";
return;
}
var ahora = DateTimeOffset.UtcNow;
desde = ahora.AddMonths(-mesesUltimos);
hasta = ahora;
}
try
{
isSearching = true;
// 1) Buscar el tercero por NIF
var tercero = await BuscarTerceroPorNifAsync(nif);
if (string.IsNullOrEmpty(tercero.Id) || string.IsNullOrEmpty(tercero.SelfHref))
{
errorMessage = "No se ha encontrado ningún tercero con ese NIF.";
return;
}
// 2) Listar expedientes usando third_rest_link
expedientes = await ObtenerExpedientesPorTerceroFiltradoAsync(
tercero.SelfHref,
desde,
hasta
);
nifMostrado = nif;
haBuscado = true;
}
catch (Exception ex)
{
errorMessage = $"Error al buscar expedientes: {ex.Message}";
}
finally
{
isSearching = false;
StateHasChanged();
}
}
// ============================================================
// LLAMADAS A GESTIONA
// ============================================================
private void AddBasicHeaders(HttpRequestMessage req)
{
req.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", AccessToken);
req.Headers.TryAddWithoutValidation("Accept", "application/vnd.gestiona.files-page+json");
}
private async Task<(string Id, string SelfHref)> BuscarTerceroPorNifAsync(string nif)
{
var filtro = new
{
result = new { max_results = 25 },
filter = new { nif }
};
var jsonFiltro = JsonSerializer.Serialize(filtro);
var b64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(jsonFiltro));
var url = $"{ApiBase.TrimEnd('/')}/rest/thirds?filter-view={Uri.EscapeDataString(b64)}";
using var req = new HttpRequestMessage(HttpMethod.Get, url);
req.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", AccessToken);
req.Headers.TryAddWithoutValidation("Accept", "application/vnd.gestiona.thirds-page+json");
using var resp = await Http.SendAsync(req);
resp.EnsureSuccessStatusCode();
var body = await resp.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(body);
if (!doc.RootElement.TryGetProperty("content", out var content) ||
content.ValueKind != JsonValueKind.Array)
{
return default;
}
var match = content.EnumerateArray().FirstOrDefault(e =>
e.TryGetProperty("nif", out var nifProp) &&
string.Equals(nifProp.GetString(), nif, StringComparison.OrdinalIgnoreCase));
if (match.ValueKind == JsonValueKind.Undefined)
return default;
var id = match.GetProperty("id").GetString() ?? string.Empty;
string selfHref = string.Empty;
if (match.TryGetProperty("links", out var linksEl) && linksEl.ValueKind == JsonValueKind.Array)
{
var selfLink = linksEl.EnumerateArray().FirstOrDefault(l =>
l.TryGetProperty("rel", out var rel) &&
string.Equals(rel.GetString(), "self", StringComparison.OrdinalIgnoreCase));
if (selfLink.ValueKind != JsonValueKind.Undefined &&
selfLink.TryGetProperty("href", out var hrefEl))
{
selfHref = hrefEl.GetString() ?? string.Empty;
}
}
return (id, selfHref);
}
private async Task<List<ExpedienteTerceroDto>> ObtenerExpedientesPorTerceroFiltradoAsync(
string thirdSelfHref,
DateTimeOffset? desde,
DateTimeOffset? hasta)
{
var url = $"{ApiBase.TrimEnd('/')}/rest/files";
var bodyObj = new
{
third_rest_link = new
{
rel = "third",
href = thirdSelfHref
}
};
var json = JsonSerializer.Serialize(bodyObj, jsonOpts);
using var req = new HttpRequestMessage(HttpMethod.Get, url)
{
Content = new StringContent(json, Encoding.UTF8, "application/vnd.gestiona.filter.files")
};
AddBasicHeaders(req);
using var resp = await Http.SendAsync(req);
var respBody = await resp.Content.ReadAsStringAsync();
if (!resp.IsSuccessStatusCode)
{
throw new InvalidOperationException(
$"GET /rest/files filtrado: {(int)resp.StatusCode} {resp.ReasonPhrase}\n{respBody}");
}
using var doc = JsonDocument.Parse(respBody);
JsonElement content;
if (doc.RootElement.TryGetProperty("content", out var contentEl) &&
contentEl.ValueKind == JsonValueKind.Array)
{
content = contentEl;
}
else if (doc.RootElement.ValueKind == JsonValueKind.Array)
{
content = doc.RootElement;
}
else
{
return new List<ExpedienteTerceroDto>();
}
var lista = new List<ExpedienteTerceroDto>();
foreach (var item in content.EnumerateArray())
{
// ===== Fecha creación (string o número) =====
DateTimeOffset? creation = null;
if (item.TryGetProperty("creation_date", out var pCreation))
{
if (pCreation.ValueKind == JsonValueKind.Number &&
pCreation.TryGetInt64(out var tsNum))
{
creation = DateTimeOffset.FromUnixTimeSeconds(tsNum);
}
else if (pCreation.ValueKind == JsonValueKind.String &&
long.TryParse(pCreation.GetString(), out var tsStr))
{
creation = DateTimeOffset.FromUnixTimeSeconds(tsStr);
}
}
if (desde.HasValue && creation.HasValue && creation.Value < desde.Value)
continue;
if (hasta.HasValue && creation.HasValue && creation.Value > hasta.Value)
continue;
// ===== URLs =====
string? selfHref = null;
if (item.TryGetProperty("links", out var linksEl) &&
linksEl.ValueKind == JsonValueKind.Array)
{
foreach (var l in linksEl.EnumerateArray())
{
if (l.TryGetProperty("rel", out var relProp) &&
string.Equals(relProp.GetString(), "self", StringComparison.OrdinalIgnoreCase) &&
l.TryGetProperty("href", out var hrefProp))
{
selfHref = hrefProp.GetString();
break;
}
}
}
string? ehomeUrl = null;
if (item.TryGetProperty("ehome_url", out var pEhome) &&
pEhome.ValueKind == JsonValueKind.String)
{
ehomeUrl = pEhome.GetString();
}
var fileUrl = ehomeUrl ?? selfHref;
if (string.IsNullOrWhiteSpace(fileUrl))
continue;
var code = GetJsonString(item, "code");
var subject = GetJsonString(item, "subject");
var freeTitle = GetJsonString(item, "free_title");
var selectableTitle = GetJsonString(item, "selectable_title");
var procedureName = GetJsonString(item, "procedure_name");
var asunto = FirstNonEmpty(freeTitle, subject, selectableTitle, procedureName) ?? string.Empty;
// ===== Estado (state / status / full_state) =====
string? state = null;
if (item.TryGetProperty("state", out var pState) && pState.ValueKind == JsonValueKind.String)
state = pState.GetString();
else if (item.TryGetProperty("status", out var pStatus) && pStatus.ValueKind == JsonValueKind.String)
state = pStatus.GetString();
else if (item.TryGetProperty("full_state", out var pFullState) && pFullState.ValueKind == JsonValueKind.String)
state = pFullState.GetString();
var dto = new ExpedienteTerceroDto
{
FileUrl = fileUrl!,
CodigoExpediente = code ?? string.Empty,
Asunto = asunto,
FechaCreacion = creation,
Estado = state ?? string.Empty
};
lista.Add(dto);
}
return lista;
}
private static string? GetJsonString(JsonElement item, string propertyName)
{
return item.TryGetProperty(propertyName, out var property) &&
property.ValueKind == JsonValueKind.String
? property.GetString()
: null;
}
private static string? FirstNonEmpty(params string?[] values)
{
foreach (var value in values)
{
if (!string.IsNullOrWhiteSpace(value))
{
return value;
}
}
return null;
}
}

View File

@@ -1,11 +1,14 @@
@page "/Gestiona"
@rendermode InteractiveServer
@attribute [Authorize]
@using GestionaDenunciasAN.Models
@using System.Globalization
@attribute [StreamRendering]
@inject GestionaDenunciasAN.Models.UserState userState
@inject NavigationManager Navigation
@inject IHostEnvironment HostEnvironment
@inject IDenunciaStore DenunciaStore
@inject IGestionaService GestionaApi
<PageTitle>Denuncias Gestión</PageTitle>
@@ -107,8 +110,7 @@ else
<h5 class="mb-0">Denuncia ID: @denuncia.Id_Denuncia</h5>
<div class="header-info">
<span><strong>Estado:</strong> @denuncia.Estado</span>
<span><strong>Nombre:</strong> @denuncia.NombreDenuncia</span>
<span><strong>Archivo:</strong> @denuncia.ArchivoElegido</span>
<span><strong>Asunto:</strong> @denuncia.NombreDenuncia</span>
<span><strong>Fecha de Subida:</strong> @denuncia.FechaSubidaAGestiona.ToString("dd/MM/yyyy")</span>
<span><strong>Hora de Subida:</strong> @denuncia.FechaSubidaAGestiona.ToString("HH:mm")</span>
</div>
@@ -133,10 +135,10 @@ else
<dt class="col-sm-3">Fecha</dt>
<dd class="col-sm-9">@denuncia.Fecha.ToString("dd/MM/yyyy HH:mm")</dd>
}
@if (!string.IsNullOrWhiteSpace(denuncia.Expediente_Gestiona))
@if (!string.IsNullOrWhiteSpace(denuncia.ExpedienteGestionaMostrable))
{
<dt class="col-sm-3">Expediente Gestión</dt>
<dd class="col-sm-9">@denuncia.Expediente_Gestiona</dd>
<dt class="col-sm-3">Nº expediente Gestiona</dt>
<dd class="col-sm-9">@denuncia.ExpedienteGestionaMostrable</dd>
}
@if (denuncia.Id_Persona_Gestiona != 0)
{
@@ -171,6 +173,7 @@ else
@if (!string.IsNullOrWhiteSpace(denuncia.Sexo))
{
<dt class="col-sm-3">Sexo</dt>
<dd class="col-sm-9">@denuncia.Sexo</dd>
}
@if (!string.IsNullOrWhiteSpace(denuncia.Dni))
@@ -179,7 +182,7 @@ else
<dd class="col-sm-9">@denuncia.Dni</dd>
}
</dl>
}
}
<!-- Detalles de la Denuncia -->
<h5 class="section-heading">Detalles de la Denuncia</h5>
@@ -308,8 +311,6 @@ else
// Variable para la búsqueda
private string busqueda = "";
private const string DENUNCIAS_JSON = @"C:\ZipsDenuncias\denuncias.json";
private const string FICHEROS_JSON = @"C:\ZipsDenuncias\ficheros.json";
private bool isLoading = false;
private bool hasLoaded = false;
@@ -317,11 +318,6 @@ else
{
if (firstRender)
{
if (string.IsNullOrEmpty(userState.Token))
{
Navigation.NavigateTo("/", true);
return;
}
isLoading = true;
StateHasChanged();
await CargarGestionaAsync();
@@ -335,29 +331,22 @@ else
private async Task CargarGestionaAsync()
{
var todas = await CargarDenunciasJsonAsync();
denunciasGestiona = todas.Where(d => d.Expediente_Gestiona == "Gestiona").ToList();
denunciasGestiona = todas
.Where(d => d.EnGestiona)
.ToList();
await SincronizarExpedientesGestionaAsync();
}
private async Task<List<DenunciasGestiona>> CargarDenunciasJsonAsync()
{
if (File.Exists(DENUNCIAS_JSON))
{
var json = await File.ReadAllTextAsync(DENUNCIAS_JSON);
var lista = Newtonsoft.Json.JsonConvert.DeserializeObject<List<DenunciasGestiona>>(json);
return lista ?? new List<DenunciasGestiona>();
}
return new List<DenunciasGestiona>();
return await DenunciaStore.GetAllDenunciasAsync();
}
private async Task<List<FicherosDenuncias>> CargarFicherosJsonAsync()
{
if (File.Exists(FICHEROS_JSON))
{
var json = await File.ReadAllTextAsync(FICHEROS_JSON);
var lista = Newtonsoft.Json.JsonConvert.DeserializeObject<List<FicherosDenuncias>>(json);
return lista ?? new List<FicherosDenuncias>();
}
return new List<FicherosDenuncias>();
return await DenunciaStore.GetAllFicherosAsync();
}
private async Task CargarFicherosAdjuntosAsync()
@@ -368,7 +357,7 @@ else
}
private string GetContentType(string fileName)
{
{
var ext = Path.GetExtension(fileName).ToLowerInvariant();
return ext switch
{
@@ -380,4 +369,49 @@ else
_ => "application/octet-stream",
};
}
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 GestionaApi.ObtenerExpedienteAsync(denuncia.Expediente_Gestiona);
if (expediente is null)
{
continue;
}
var changed = false;
denuncia.Expediente_Gestiona = expediente.FileUrl;
if (!string.IsNullOrWhiteSpace(expediente.CodigoExpediente) &&
!string.Equals(denuncia.CodigoExpedienteGestiona, expediente.CodigoExpediente, StringComparison.Ordinal))
{
denuncia.CodigoExpedienteGestiona = expediente.CodigoExpediente;
changed = true;
}
if (!string.IsNullOrWhiteSpace(expediente.FreeTitle) &&
!string.Equals(denuncia.NombreDenuncia, expediente.FreeTitle, StringComparison.Ordinal))
{
denuncia.NombreDenuncia = expediente.FreeTitle;
changed = true;
}
if (changed)
{
await DenunciaStore.UpsertDenunciaAsync(denuncia);
}
}
catch
{
// No bloqueamos la pantalla si Gestiona no devuelve metadatos.
}
}
}
}

View File

@@ -1,4 +1,5 @@
@page "/Instrucciones"
@attribute [Authorize]
@attribute [StreamRendering]
@inject GestionaDenunciasAN.Models.UserState userState
@inject NavigationManager Navigation
@@ -6,70 +7,116 @@
<PageTitle>Instrucciones</PageTitle>
<div class="container mt-4">
<h1 class="mb-4">Instrucciones de la Aplicación</h1>
<h1 class="mb-4">Guía de Uso — Gestión de Denuncias</h1>
<p>
Bienvenido a la aplicación de <strong>Gestión de Denuncias</strong>. Esta herramienta ha sido diseñada para
cargar, procesar y gestionar denuncias que se reciben a través de archivos ZIP. A continuación, se explica el funcionamiento y las principales funcionalidades:
Esta aplicación permite procesar denuncias desde archivos ZIP y gestionarlas en tres etapas:
<strong>Pendientes</strong>, <strong>Gestión</strong> (aceptadas) y <strong>Rechazadas</strong>.
</p>
<h2>1. Carga y Procesamiento de Denuncias</h2>
<h2>1. Carga de ZIPs</h2>
<ul>
<li>
<strong>Carga de archivos:</strong> Los archivos ZIP se encuentran en la carpeta <code>Ejemplos</code>. Cada ZIP contiene un archivo <code>report.txt</code> con los datos de la denuncia, y en algunos casos, ficheros asociados.
Sitúate en la pestaña <strong>Gestió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>
<strong>Descompresión y parseo:</strong> La aplicación descomprime cada ZIP y extrae la información del <code>report.txt</code> para crear una representación estructurada de la denuncia.
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á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>
<h2>2. Visualización y Gestión de Denuncias</h2>
<h2>2. Pestaña <strong>Pendientes</strong></h2>
<ul>
<li>
<strong>Pendientes:</strong> En esta pestaña se listan todas las denuncias recién cargadas. Cada denuncia se muestra en una tarjeta colapsable que permite ver sus detalles y los ficheros asociados. Además, se disponen de botones para <em>Enviar a gestiona</em> (verde) y <em>Rechazar denuncia</em> (rojo). Actualmente, estos botones no realizan ninguna acción, pero en futuras versiones registrarán el estado de la denuncia.
Verás cada denuncia en una tarjeta colapsable con sus datos y el listado de ficheros adjuntos.
</li>
<li>
<strong>Finalizados:</strong> Una vez procesadas, las denuncias se trasladan a la pestaña <strong>Finalizados</strong>, donde se muestran diferenciadas:
Hay dos acciones:
<ul>
<li>Las denuncias aceptadas se destacan con un fondo verde.</li>
<li>Las denuncias rechazadas se muestran con un fondo rojo.</li>
<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 ú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óna, sube los documentos
y pasa a la pestaña <strong>Gestió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ña
<strong>Rechazados</strong>.
</li>
</ul>
</li>
</ul>
<h2>3. Pestaña <strong>Gestión</strong></h2>
<ul>
<li>
Aquí se listan las denuncias que ya han sido <em>enviadas a Gestió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 “Ver” a los PDFs/imágenes</li>
</ul>
</li>
</ul>
<h2>4. Pestaña <strong>Rechazadas</strong></h2>
<ul>
<li>
Aquí verá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ó.
</li>
</ul>
<h2>5. Flujo completo</h2>
<ol>
<li>Subes uno o varios ZIP en la pestaña <strong>Gestión de ZIP</strong>.</li>
<li>La aplicación extrae y parsea informes, los añade a <strong>Pendientes</strong>.</li>
<li>
En <strong>Pendientes</strong> eliges qué hacer con cada denuncia:
<ul>
<li><strong>Configurar subida</strong> → pasa a <strong>Gestión</strong>.</li>
<li><strong>Rechazar denuncia</strong> → pasa a <strong>Rechazadas</strong>.</li>
</ul>
</li>
<li>
<strong>Visualización de ficheros:</strong> En cada tarjeta, el botón <em>Ver</em> abre una nueva pestaña para mostrar el contenido del fichero asociado.
</li>
</ul>
<h2>3. Flujo de Trabajo</h2>
<p>
El flujo de trabajo de la aplicación es el siguiente:
</p>
<ol>
<li>
Los archivos ZIP se depositan en la carpeta <code>Ejemplos</code>.
</li>
<li>
La aplicación descomprime los ZIP y parsea el archivo <code>report.txt</code> para extraer la información de cada denuncia.
</li>
<li>
Las denuncias se muestran en la pestaña <strong>Pendientes</strong> en forma de tarjetas colapsables, donde puedes ver detalles y ficheros asociados.
</li>
<li>
Las acciones para enviar o rechazar la denuncia se registrarán y, en consecuencia, se reflejará su estado en la pestaña <strong>Finalizados</strong>.
En <strong>Gestión</strong> puedes revisar lo ya subido; en
<strong>Rechazadas</strong> ves los motivos.
</li>
</ol>
<p>
Esta aplicación está diseñada para facilitar la gestión de denuncias de forma intuitiva. Si tienes cualquier duda o sugerencia, no dudes en ponerte en contacto con el equipo de soporte.
<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>
@code {
protected override void OnInitialized()
{
// Si no hay token, redirige al login
if (string.IsNullOrEmpty(userState.Token))
{
Navigation.NavigateTo("/", true);
}
}
}

View File

@@ -1,344 +1,253 @@
@using BlazorBootstrap
@using Layout
@using Microsoft.AspNetCore.Mvc
@using Newtonsoft.Json
@using Newtonsoft.Json.Linq
@using GestionaDenunciasAN.Models
@using System.Net.Http.Headers
@using System.Text
@using System.Linq.Expressions
@using Serialize.Linq.Serializers
@using System.Security.Cryptography.X509Certificates
@using System.Security.Cryptography
@using bdAntifraude.db
@rendermode InteractiveServer
@layout EmptyLayout
@page "/"
@inject IHttpClientFactory HttpClientFactory
@inject IHttpContextAccessor HttpContextAccessor
@inject NavigationManager Navigation
@inject UserState UserState
@layout EmptyLayout
@rendermode @(new InteractiveServerRenderMode(prerender: false))
@using System.Text.Json
@using GestionaDenunciasAN.Models
@inject AuthenticationStateProvider AuthenticationStateProvider
@inject IJSRuntime JSRuntime
@inject NavigationManager Navigation
<PageTitle>Gestión Denuncias - Oficina Antifraude de Andalucía</PageTitle>
<PageTitle>Portal de denuncias</PageTitle>
<style>
/* Contenedor que ocupa toda la altura de la ventana */
.full-height {
height: 100vh;
margin: 0;
padding: 0;
.login-shell {
min-height: 100vh;
background:
radial-gradient(circle at top left, rgba(90, 155, 213, 0.3), transparent 35%),
linear-gradient(135deg, #f7fafc 0%, #dfe8f3 45%, #eef4fb 100%);
}
/* Columna izquierda con un degradado en azul */
.left-side {
background: linear-gradient(135deg, #5a9bd5, #2A5298);
color: #fff;
.login-panel {
max-width: 440px;
width: 100%;
background: rgba(255, 255, 255, 0.92);
border-radius: 24px;
box-shadow: 0 25px 60px rgba(31, 73, 125, 0.18);
border: 1px solid rgba(90, 155, 213, 0.15);
}
/* Columna derecha con fondo claro */
.right-side {
background: linear-gradient(135deg, #ffffff, #dddddd);
.brand-panel {
color: #12395f;
max-width: 560px;
}
/* Contenedor para centrar contenido en ambas columnas */
.centered-content {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100%;
padding: 2rem;
.brand-kicker {
display: inline-block;
padding: 0.35rem 0.75rem;
border-radius: 999px;
background: rgba(42, 82, 152, 0.12);
color: #1f497d;
font-weight: 600;
letter-spacing: 0.02em;
text-transform: uppercase;
font-size: 0.8rem;
}
/* Logo */
.logo-img {
max-width: 300px;
margin-bottom: 2rem;
.brand-title {
font-size: clamp(2rem, 4vw, 3.5rem);
line-height: 1.05;
font-weight: 700;
margin: 1rem 0;
}
/* Caja de login con degradado y sombra */
.login-box {
background: linear-gradient(135deg, #5a9bd5, #2A5298);
padding: 2rem;
border-radius: 10px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
max-width: 400px;
width: 100%;
text-align: center;
color: #fff; /* Para que el texto sea legible sobre el gradiente */
}
.login-box label {
font-weight: 500;
display: block;
text-align: left;
margin-bottom: 0.3rem;
color: #fff; /* Aseguramos que la etiqueta se vea bien */
}
.login-box .form-control {
width: 100%;
padding: 0.75rem;
margin-bottom: 1rem;
border: 1px solid #ccc;
border-radius: 5px;
}
.error-message {
color: red;
margin-bottom: 1rem;
.brand-copy {
font-size: 1.05rem;
color: #35597f;
max-width: 34rem;
}
/* Botones personalizados */
.login-box .btnOAAFAzul {
background-color: #fff !important; /* Fondo blanco */
color: #000 !important; /* Texto negro */
.login-label {
font-size: 0.92rem;
font-weight: 600;
color: #244e79;
}
.login-input {
border-radius: 14px;
min-height: 3rem;
border-color: #c7d6e7;
}
.login-input:focus {
border-color: #2a5298;
box-shadow: 0 0 0 0.2rem rgba(42, 82, 152, 0.12);
}
.login-button {
min-height: 3rem;
border-radius: 14px;
background: linear-gradient(135deg, #2a5298, #1f497d);
border: none;
border-radius: 15px;
padding: 10px;
width: 100%;
margin-top: 10px;
cursor: pointer;
font-size: 16px;
}
.login-box .btnOAAFAzul:hover {
opacity: 0.8;
}
.btnOAAFBlack {
background-color: #343334;
border: none;
border-radius: 15px;
color: white;
padding: 10px;
width: 100%;
margin-top: 10px;
cursor: pointer;
font-size: 16px;
}
.btnOAAFBlack:hover {
opacity: 0.8;
}
/* Estilos para el loading */
.loadingFrame {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(255, 255, 255, 0.8);
display: flex;
justify-content: center;
align-items: center;
z-index: 9999;
}
.loadingDiv {
text-align: center;
}
.loadingImg {
background: url('Content/imagenes/loading.gif') no-repeat center center;
background-size: contain;
width: 100px;
height: 100px;
font-weight: 600;
}
</style>
<link rel="icon" type="image/x-icon" href="faviconParlamento.ico" />
<link href="~/bootstrap/bootstrap.min.css" rel="stylesheet" />
<link href="Content/Site.css" rel="stylesheet" />
<!-- Bloque de loading que se muestra mientras 'mostrar' es true -->
@if (mostrar)
{
<div id="cargando" class="loadingFrame">
<div class="loadingDiv">
<div class="loadingImg"></div>
</div>
</div>
}
<!-- Estructura en dos columnas -->
<div class="container-fluid full-height px-0">
<div class="row no-gutters full-height">
<!-- Columna izquierda (más ancha) -->
<div class="col-md-7 left-side">
<div class="centered-content">
<!-- Tu logo en grande -->
<img src="Content/imagenes/logo-oaaf-negativo-transparente.svg" alt="Logo Oficina Antifraude" class="logo-img" />
<h1>Oficina Antifraude de Andalucía</h1>
<p style="max-width: 500px; text-align: center;">
Bienvenido/a a la plataforma de gestión de denuncias.
Aquí podrás autenticarte para revisar y tramitar las denuncias recibidas.
</p>
<div class="container-fluid login-shell d-flex align-items-center">
<div class="container py-5">
<div class="row g-5 align-items-center">
<div class="col-lg-7">
<div class="brand-panel">
<span class="brand-kicker">Oficina Antifraude de Andalucía</span>
<h1 class="brand-title">Una sola puerta para entrar, importar y tramitar denuncias.</h1>
<p class="brand-copy mb-4">
El acceso de la aplicación ya se apoya en GlobalLeaks. La sesión interna de esta app se mantiene
activa de forma persistente, y cuando caduque la sesión de obtención de denuncias solo habrá que
renovar el código 2FA desde la bandeja de entrada.
</p>
<img src="Content/imagenes/logo-oaaf-negativo-transparente.svg"
alt="Logo Oficina Antifraude"
style="max-width: 320px; width: 100%;" />
</div>
</div>
</div>
<!-- Columna derecha (más estrecha) -->
<div class="col-md-5 right-side">
<div class="centered-content">
<form class="login-box">
<h3 class="mb-4">Iniciar Sesión</h3>
<div class="col-lg-5 d-flex justify-content-center">
<div class="login-panel p-4 p-md-5">
<h2 class="h3 mb-2">Acceso con GlobalLeaks</h2>
<p class="text-muted mb-4">
Introduce tu usuario, contraseña y el código 2FA actual para dejar la app iniciada.
</p>
<p id="mensajeError" class="error-message">@mensaje</p>
@if (!string.IsNullOrWhiteSpace(StatusMessage))
{
<div class="alert @StatusCss mb-4">@StatusMessage</div>
}
<div class="form-group text-left">
<label for="Usu">Usuario</label>
<input id="Usu" type="text" class="form-control" @bind="Usu" />
<div class="mb-3">
<label class="login-label mb-2">Usuario</label>
<input class="form-control login-input"
@bind="Username"
autocomplete="username"
placeholder="usuario de GlobalLeaks" />
</div>
<div class="form-group text-left">
<label for="Contrasena">Contraseña</label>
<input id="Contrasena" type="password" class="form-control" @bind="pass" />
<div class="mb-3">
<label class="login-label mb-2">Contraseña</label>
<input class="form-control login-input"
type="password"
@bind="Password"
autocomplete="current-password" />
</div>
<button class="btnOAAFAzul" type="button" @onclick="LogIn">ENTRAR</button>
<button class="btnOAAFBlack" type="button" @onclick="IniciarSesionConCertificado">INICIAR SESIÓN CON CERTIFICADO</button>
</form>
<div class="mb-4">
<label class="login-label mb-2">Código 2FA</label>
<input class="form-control login-input"
@bind="Authcode"
@onkeydown="HandleAuthcodeKeyDown"
inputmode="numeric"
maxlength="6"
placeholder="123456" />
</div>
<button class="btn btn-primary login-button w-100"
@onclick="LoginAsync"
disabled="@IsBusy">
@(IsBusy ? "Conectando..." : "Entrar")
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Iframe oculto para la autenticación con certificado -->
<iframe id="authCertIframe" style="display:none;"></iframe>
@code {
public string? Usu { get; set; }
public string? pass { get; set; }
private string? mensaje { get; set; }
public bool mostrar { get; set; } = false;
private DotNetObjectReference<Login>? dotNetRef;
[SupplyParameterFromQuery(Name = "returnUrl")]
public string? ReturnUrl { get; set; }
protected override async Task OnAfterRenderAsync(bool firstRender)
private string Username { get; set; } = string.Empty;
private string Password { get; set; } = string.Empty;
private string Authcode { get; set; } = string.Empty;
private string StatusMessage { get; set; } = string.Empty;
private string StatusCss { get; set; } = "alert-info";
private bool IsBusy { get; set; }
protected override async Task OnInitializedAsync()
{
if (firstRender)
var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync();
if (authState.User.Identity?.IsAuthenticated == true)
{
// Se crea una referencia a este componente para que JS pueda invocar SetToken
dotNetRef = DotNetObjectReference.Create(this);
await JSRuntime.InvokeVoidAsync("registerTokenReceiver", dotNetRef);
}
Navigation.NavigateTo(GetTargetUrl(), true);
}
}
protected override void OnInitialized()
private async Task LoginAsync()
{
LimpiarEstadoUsuario();
}
StatusMessage = string.Empty;
private void LimpiarEstadoUsuario()
{
UserState.Token = "";
UserState.NombreUsu = "";
HttpContextAccessor?.HttpContext?.Session?.Clear();
}
public async Task LogIn()
{
mostrar = true;
await Task.Delay(1);
if (string.IsNullOrWhiteSpace(Usu) || string.IsNullOrWhiteSpace(pass))
if (string.IsNullOrWhiteSpace(Username) ||
string.IsNullOrWhiteSpace(Password) ||
string.IsNullOrWhiteSpace(Authcode))
{
mostrar = false;
mensaje = "Por favor, ingrese su usuario y contraseña.";
SetStatus("Debes rellenar usuario, contraseña y código 2FA.", "alert-warning");
return;
}
IsBusy = true;
try
{
var client = HttpClientFactory.CreateClient();
client.BaseAddress = new Uri(Utilidades.urlSwagger());
var loginPayload = new { NombreUsuario = Usu, Contraseña = pass, Origen = "Denuncias" };
var loginContent = new StringContent(JsonConvert.SerializeObject(loginPayload), Encoding.UTF8, "application/json");
var loginResponse = await client.PostAsync("Auth/login", loginContent);
await ProcesarRespuesta(loginResponse);
}
catch (Exception ex)
{
mostrar = false;
mensaje = $"Error inesperado: {ex.Message}";
}
}
var response = await JSRuntime.InvokeAsync<ApiJsResponse>(
"appAuthPostJson",
"/api/auth/login",
new LoginRequest(Username.Trim(), Password, Authcode.Trim()));
private async Task IniciarSesionConCertificado()
{
mostrar = true;
var url = Utilidades.urlSwagger() + "Auth/login-cert?iframe=true";
await JSRuntime.InvokeVoidAsync("iniciarSesionConCertificado", url);
}
if (!response.Ok)
{
var error = ReadData<ApiError>(response);
SetStatus(error?.Error ?? "No se ha podido iniciar sesión.", "alert-danger");
return;
}
private async Task ProcesarRespuesta(HttpResponseMessage response)
{
var responseContent = await response.Content.ReadAsStringAsync();
if (response.IsSuccessStatusCode)
{
var parsedJson = JObject.Parse(responseContent);
UserState.Token = parsedJson["token"]?.ToString() ?? "";
// Actualizamos el nombre del usuario (formateado como "APELLIDOS, NOMBRE")
UserState.NombreUsu = $"{parsedJson["user"]?["apellidos"]?.ToString()}, {parsedJson["user"]?["nombre"]?.ToString()}";
Navigation.NavigateTo("/GestionZip", true);
Navigation.NavigateTo(GetTargetUrl(), true);
}
else
catch (JSException ex)
{
mostrar = false;
mensaje = "Error de autenticación. Verifique sus credenciales o el certificado.";
}
}
[JSInvokable]
public Task SetToken(string token, string userJson)
{
// Actualizamos el token en UserState
UserState.Token = token;
try
{
var userObj = JObject.Parse(userJson);
UserState.NombreUsu = $"{userObj["APELLIDOS"]?.ToString()}, {userObj["NOMBRE"]?.ToString()}";
SetStatus($"Fallo de comunicación con el navegador: {ex.Message}", "alert-danger");
}
catch
{
UserState.NombreUsu = "";
SetStatus("No se ha podido conectar con el servidor.", "alert-danger");
}
Navigation.NavigateTo("/GestionZip", true);
return Task.CompletedTask;
finally
{
IsBusy = false;
}
}
private Task HandleAuthcodeKeyDown(KeyboardEventArgs args)
=> args.Key == "Enter" ? LoginAsync() : Task.CompletedTask;
private void SetStatus(string message, string cssClass)
{
StatusMessage = message;
StatusCss = cssClass;
}
private string GetTargetUrl()
{
if (!string.IsNullOrWhiteSpace(ReturnUrl) &&
ReturnUrl.StartsWith("/", StringComparison.Ordinal) &&
!ReturnUrl.StartsWith("//", StringComparison.Ordinal))
{
return ReturnUrl;
}
return "/GestionZip";
}
private static T? ReadData<T>(ApiJsResponse response)
{
if (response.Data.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined)
{
return default;
}
return response.Data.Deserialize<T>(new JsonSerializerOptions(JsonSerializerDefaults.Web));
}
private sealed class ApiJsResponse
{
public bool Ok { get; set; }
public int Status { get; set; }
public JsonElement Data { get; set; }
}
}
<script>
function registerTokenReceiver(dotnetRef) {
window.dotnetTokenReceiver = dotnetRef;
}
window.iniciarSesionConCertificado = function(url) {
console.log("Se llamó iniciarSesionConCertificado con URL:", url);
var iframe = document.getElementById("authCertIframe");
if (iframe) {
iframe.src = url;
} else {
console.error("No se encontró el iframe con id 'authCertIframe'");
}
};
window.addEventListener("message", function(event) {
var data = event.data;
if (data && data.token) {
console.log("Mensaje recibido con token:", data.token);
if (window.dotnetTokenReceiver) {
window.dotnetTokenReceiver.invokeMethodAsync("SetToken", data.token, JSON.stringify(data.user));
} else {
localStorage.setItem("token", data.token);
localStorage.setItem("user", JSON.stringify(data.user));
window.location.href = "/GestionZip";
}
}
});
</script>

View File

@@ -1,10 +1,12 @@
@page "/Rechazados"
@rendermode InteractiveServer
@attribute [Authorize]
@using GestionaDenunciasAN.Models
@using System.Globalization
@attribute [StreamRendering]
@inject GestionaDenunciasAN.Models.UserState userState
@inject NavigationManager Navigation
@inject IDenunciaStore DenunciaStore
<PageTitle>Denuncias Rechazadas</PageTitle>
@@ -130,10 +132,10 @@ else
<dt class="col-sm-3">Fecha</dt>
<dd class="col-sm-9">@denuncia.Fecha.ToString("dd/MM/yyyy HH:mm")</dd>
}
@if (!string.IsNullOrWhiteSpace(denuncia.Expediente_Gestiona))
@if (!string.IsNullOrWhiteSpace(denuncia.ExpedienteGestionaMostrable))
{
<dt class="col-sm-3">Expediente Gestión</dt>
<dd class="col-sm-9">@denuncia.Expediente_Gestiona</dd>
<dt class="col-sm-3">Nº expediente Gestiona</dt>
<dd class="col-sm-9">@denuncia.ExpedienteGestionaMostrable</dd>
}
@if (denuncia.Id_Persona_Gestiona != 0)
{
@@ -305,8 +307,6 @@ else
// Variable para la búsqueda
private string busqueda = "";
private const string DENUNCIAS_JSON = @"C:\ZipsDenuncias\denuncias.json";
private const string FICHEROS_JSON = @"C:\ZipsDenuncias\ficheros.json";
private bool isLoading = false;
private bool hasLoaded = false;
@@ -314,11 +314,6 @@ else
{
if (firstRender)
{
if (string.IsNullOrEmpty(userState.Token))
{
Navigation.NavigateTo("/", true);
return;
}
isLoading = true;
StateHasChanged();
await CargarRechazadasAsync();
@@ -332,29 +327,21 @@ else
private async Task CargarRechazadasAsync()
{
var todas = await CargarDenunciasJsonAsync();
denunciasRechazadas = todas.Where(d => d.Expediente_Gestiona == "Rechazada").ToList();
// Ahora filtramos por la bandera EnRechazada
denunciasRechazadas = todas
.Where(d => d.EnRechazada)
.ToList();
}
private async Task<List<DenunciasGestiona>> CargarDenunciasJsonAsync()
{
if (File.Exists(DENUNCIAS_JSON))
{
var json = await File.ReadAllTextAsync(DENUNCIAS_JSON);
var lista = Newtonsoft.Json.JsonConvert.DeserializeObject<List<DenunciasGestiona>>(json);
return lista ?? new List<DenunciasGestiona>();
}
return new List<DenunciasGestiona>();
return await DenunciaStore.GetAllDenunciasAsync();
}
private async Task<List<FicherosDenuncias>> CargarFicherosJsonAsync()
{
if (File.Exists(FICHEROS_JSON))
{
var json = await File.ReadAllTextAsync(FICHEROS_JSON);
var lista = Newtonsoft.Json.JsonConvert.DeserializeObject<List<FicherosDenuncias>>(json);
return lista ?? new List<FicherosDenuncias>();
}
return new List<FicherosDenuncias>();
return await DenunciaStore.GetAllFicherosAsync();
}
private async Task CargarFicherosAdjuntosAsync()
@@ -378,3 +365,4 @@ else
};
}
}

View File

@@ -1,6 +1,12 @@
<Router AppAssembly="typeof(Program).Assembly">
<Found Context="routeData">
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)" />
<FocusOnNavigate RouteData="routeData" Selector="h1" />
</Found>
</Router>
<CascadingAuthenticationState>
<Router AppAssembly="typeof(Program).Assembly">
<Found Context="routeData">
<AuthorizeRouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)">
<NotAuthorized>
<AuthRedirect />
</NotAuthorized>
</AuthorizeRouteView>
<FocusOnNavigate RouteData="routeData" Selector="h1" />
</Found>
</Router>
</CascadingAuthenticationState>

View File

@@ -1,5 +1,7 @@
@using System.Net.Http
@using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@@ -8,3 +10,6 @@
@using Microsoft.JSInterop
@using GestionaDenunciasAN
@using GestionaDenunciasAN.Components
@using GestionaDenunciasAN.Components.Layout
@using GestionaDenunciasAN.Models
@using GestionaDenunciasAN.Services

View File

@@ -0,0 +1,9 @@
namespace GestionaDenunciasAN.Configuration;
public sealed class ComplaintStorageOptions
{
public const string SectionName = "ComplaintStorage";
public string ConnectionString { get; set; } = string.Empty;
public bool AutoCreateSchema { get; set; }
}

View File

@@ -0,0 +1,10 @@
namespace GestionaDenunciasAN.Configuration;
public sealed class GlobalLeaksOptions
{
public const string SectionName = "GlobalLeaks";
public string BaseUrl { get; set; } = "https://prebuzon.antifraudeandalucia.es";
public int TimeoutSeconds { get; set; } = 120;
public int MaxDownloadBytes { get; set; } = 500 * 1024 * 1024;
}

View File

@@ -15,14 +15,17 @@
<ItemGroup>
<PackageReference Include="Blazor.Bootstrap" Version="3.3.1" />
<PackageReference Include="Konscious.Security.Cryptography.Argon2" Version="1.3.1" />
<PackageReference Include="MySqlConnector" Version="2.4.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="PdfSharpCore" Version="1.3.67" />
<PackageReference Include="Serialize.Linq" Version="4.0.167" />
<PackageReference Include="System.Drawing.Common" Version="10.0.0-preview.3.25173.2" />
<PackageReference Include="Yarp.ReverseProxy" Version="2.3.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\bdAntifraude\bdAntifraude.csproj" />
<Content Include="Scripts\gestiondenuncias_schema.sql" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,34 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GestionaDenunciasAN", "GestionaDenunciasAN.csproj", "{45DE522E-CB7F-4865-8644-D1916065F48E}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{45DE522E-CB7F-4865-8644-D1916065F48E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{45DE522E-CB7F-4865-8644-D1916065F48E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{45DE522E-CB7F-4865-8644-D1916065F48E}.Debug|x64.ActiveCfg = Debug|Any CPU
{45DE522E-CB7F-4865-8644-D1916065F48E}.Debug|x64.Build.0 = Debug|Any CPU
{45DE522E-CB7F-4865-8644-D1916065F48E}.Debug|x86.ActiveCfg = Debug|Any CPU
{45DE522E-CB7F-4865-8644-D1916065F48E}.Debug|x86.Build.0 = Debug|Any CPU
{45DE522E-CB7F-4865-8644-D1916065F48E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{45DE522E-CB7F-4865-8644-D1916065F48E}.Release|Any CPU.Build.0 = Release|Any CPU
{45DE522E-CB7F-4865-8644-D1916065F48E}.Release|x64.ActiveCfg = Release|Any CPU
{45DE522E-CB7F-4865-8644-D1916065F48E}.Release|x64.Build.0 = Release|Any CPU
{45DE522E-CB7F-4865-8644-D1916065F48E}.Release|x86.ActiveCfg = Release|Any CPU
{45DE522E-CB7F-4865-8644-D1916065F48E}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
EndGlobal

View File

@@ -0,0 +1,393 @@
using System.Globalization;
using System.Text;
using System.Text.Json;
using GestionaDenunciasAN.Models;
namespace GestionaDenunciasAN.Helpers;
public static class GlobalLeaksJsonEnricher
{
public static void Enrich(DenunciasGestiona denuncia, string exportJson)
{
using var document = JsonDocument.Parse(exportJson);
var root = document.RootElement;
if (denuncia.Id_Denuncia == 0 &&
root.TryGetProperty("progressive", out var progressiveElement) &&
progressiveElement.TryGetInt32(out var progressive))
{
denuncia.Id_Denuncia = progressive;
}
if (root.TryGetProperty("creation_date", out var creationDateElement) &&
DateTimeOffset.TryParse(creationDateElement.GetString(), out var creationDate))
{
denuncia.Fecha = creationDate.LocalDateTime;
}
if (root.TryGetProperty("status", out var statusElement) &&
string.IsNullOrWhiteSpace(denuncia.Estado))
{
denuncia.Estado = statusElement.GetString() ?? string.Empty;
}
if (root.TryGetProperty("label", out var labelElement) &&
string.IsNullOrWhiteSpace(denuncia.Etiqueta))
{
denuncia.Etiqueta = labelElement.GetString() ?? string.Empty;
}
if (root.TryGetProperty("identity_provided", out var identityProvidedElement) &&
identityProvidedElement.ValueKind is JsonValueKind.True or JsonValueKind.False)
{
denuncia.Confidencial = !identityProvidedElement.GetBoolean();
if (string.IsNullOrWhiteSpace(denuncia.Tipo_Denuncia))
{
denuncia.Tipo_Denuncia = denuncia.Confidencial
? "Anónima"
: "No anónima, deseo identificarme";
}
}
var answers = ReadAnswers(root);
ApplyAnswers(denuncia, answers);
MergeComments(denuncia, root);
}
private static Dictionary<string, string> ReadAnswers(JsonElement root)
{
var definitions = new Dictionary<string, FieldDefinition>(StringComparer.OrdinalIgnoreCase);
var answersByLabel = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (!root.TryGetProperty("questionnaires", out var questionnaires) ||
questionnaires.ValueKind != JsonValueKind.Array)
{
return answersByLabel;
}
foreach (var questionnaire in questionnaires.EnumerateArray())
{
if (questionnaire.TryGetProperty("steps", out var steps) &&
steps.ValueKind == JsonValueKind.Array)
{
foreach (var step in steps.EnumerateArray())
{
CollectDefinitions(step, definitions);
}
}
if (!questionnaire.TryGetProperty("answers", out var answers) ||
answers.ValueKind != JsonValueKind.Object)
{
continue;
}
foreach (var answerEntry in answers.EnumerateObject())
{
if (!definitions.TryGetValue(answerEntry.Name, out var definition))
{
continue;
}
var resolved = ResolveAnswer(answerEntry.Value, definition);
if (!string.IsNullOrWhiteSpace(resolved))
{
answersByLabel[Normalize(definition.Label)] = resolved;
}
}
}
return answersByLabel;
}
private static void CollectDefinitions(JsonElement element, Dictionary<string, FieldDefinition> definitions)
{
if (element.ValueKind != JsonValueKind.Object)
{
return;
}
if (element.TryGetProperty("id", out var idElement) &&
element.TryGetProperty("type", out var typeElement) &&
element.TryGetProperty("label", out var labelElement))
{
var id = idElement.GetString();
if (!string.IsNullOrWhiteSpace(id))
{
definitions[id] = new FieldDefinition(
labelElement.GetString() ?? id,
typeElement.GetString() ?? string.Empty,
ParseOptions(element));
}
}
if (element.TryGetProperty("children", out var children) &&
children.ValueKind == JsonValueKind.Array)
{
foreach (var child in children.EnumerateArray())
{
CollectDefinitions(child, definitions);
}
}
}
private static Dictionary<string, string> ParseOptions(JsonElement element)
{
var options = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (!element.TryGetProperty("options", out var optionsElement) ||
optionsElement.ValueKind != JsonValueKind.Array)
{
return options;
}
foreach (var option in optionsElement.EnumerateArray())
{
if (!option.TryGetProperty("id", out var idElement) ||
!option.TryGetProperty("label", out var labelElement))
{
continue;
}
var id = idElement.GetString();
if (string.IsNullOrWhiteSpace(id))
{
continue;
}
options[id] = labelElement.GetString() ?? string.Empty;
}
return options;
}
private static string ResolveAnswer(JsonElement answerArray, FieldDefinition definition)
{
if (answerArray.ValueKind != JsonValueKind.Array)
{
return string.Empty;
}
var values = new List<string>();
foreach (var answer in answerArray.EnumerateArray())
{
if (answer.ValueKind != JsonValueKind.Object)
{
continue;
}
if (answer.TryGetProperty("value", out var valueElement))
{
var resolvedValue = ResolveValue(valueElement, definition.Options);
if (!string.IsNullOrWhiteSpace(resolvedValue))
{
values.Add(resolvedValue);
}
}
foreach (var property in answer.EnumerateObject())
{
if (property.NameEquals("value") || property.NameEquals("index") || property.NameEquals("required_status"))
{
continue;
}
if (property.Value.ValueKind is JsonValueKind.True or JsonValueKind.False &&
property.Value.GetBoolean() &&
definition.Options.TryGetValue(property.Name, out var label))
{
values.Add(label);
}
}
}
return string.Join("; ", values.Distinct(StringComparer.OrdinalIgnoreCase));
}
private static string ResolveValue(JsonElement valueElement, Dictionary<string, string> options)
{
return valueElement.ValueKind switch
{
JsonValueKind.String => ResolveStringValue(valueElement.GetString(), options),
JsonValueKind.True => "Sí",
JsonValueKind.False => "No",
JsonValueKind.Number => valueElement.GetRawText(),
_ => string.Empty,
};
}
private static string ResolveStringValue(string? rawValue, Dictionary<string, string> options)
{
if (string.IsNullOrWhiteSpace(rawValue))
{
return string.Empty;
}
return options.TryGetValue(rawValue, out var optionLabel)
? optionLabel
: rawValue;
}
private static void ApplyAnswers(DenunciasGestiona denuncia, Dictionary<string, string> answers)
{
SetIfMissing(() => denuncia.TipoDenunciante, value => denuncia.TipoDenunciante = value, answers, "indique si actua como persona fisica o en representacion de una persona juridica");
if (!string.IsNullOrWhiteSpace(denuncia.TipoDenunciante))
{
denuncia.EsPersonaJuridica = Normalize(denuncia.TipoDenunciante).Contains("juridica", StringComparison.OrdinalIgnoreCase);
}
SetIfMissing(() => denuncia.Asunto, value => denuncia.Asunto = value, answers, "asunto");
SetIfMissing(() => denuncia.Nombre, value => denuncia.Nombre = value, answers, "nombre");
SetIfMissing(() => denuncia.PrimerApellido, value => denuncia.PrimerApellido = value, answers, "1 apellido", "primer apellido");
SetIfMissing(() => denuncia.SegundoApellido, value => denuncia.SegundoApellido = value, answers, "2 apellido", "segundo apellido");
SetIfMissing(() => denuncia.Apellidos, value => denuncia.Apellidos = value, answers, "apellidos");
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.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");
SetIfMissing(() => denuncia.Denunciado_Ante_Inst, value => denuncia.Denunciado_Ante_Inst = value, answers, "ha denunciado estos hechos ante otras instituciones u organos");
SetIfMissing(() => denuncia.OrganismoDenunciado, value => denuncia.OrganismoDenunciado = value, answers, "por favor indique el organismo o la institucion donde ha denunciado los hechos");
SetIfMissing(() => denuncia.SolicitaProteccion, value => denuncia.SolicitaProteccion = value, answers, "solicita medidas concretas de proteccion");
SetIfMissing(() => denuncia.MedidasProteccionSolicitadas, value => denuncia.MedidasProteccionSolicitadas = value, answers, "describa las medidas de proteccion solicitadas");
SetIfMissing(() => denuncia.Lugar_Hechos, value => denuncia.Lugar_Hechos = value, answers, "lugar en la que ocurrieron los hechos que denuncia");
SetIfMissing(() => denuncia.AutorizaRemision, value => denuncia.AutorizaRemision = value, answers, "autorizacion para remitir su denuncia");
SetIfMissing(() => denuncia.PreferenciaRemision, value => denuncia.PreferenciaRemision = value, answers, "en tal caso desea que su denuncia se remita anonimizada sin datos personales");
SetIfMissing(() => denuncia.Notificacion_Preferencia, value => denuncia.Notificacion_Preferencia = value, answers, "seleccione su preferencia de notificacion y seguimiento de su denuncia", "notificaciones");
SetIfMissing(() => denuncia.Notificacion_Electronica, value => denuncia.Notificacion_Electronica = value, answers, "notificaciones electronicas");
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.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");
SetIfMissing(() => denuncia.DireccionPiso, value => denuncia.DireccionPiso = value, answers, "piso", "planta");
SetIfMissing(() => denuncia.DireccionPuerta, value => denuncia.DireccionPuerta = value, answers, "puerta");
SetIfMissing(() => denuncia.DireccionBloque, value => denuncia.DireccionBloque = value, answers, "bloque");
SetIfMissing(() => denuncia.DireccionEscalera, value => denuncia.DireccionEscalera = value, answers, "escalera");
SetIfMissing(() => denuncia.DireccionExtra, value => denuncia.DireccionExtra = value, answers, "extra");
SetIfMissing(() => denuncia.Municipio, value => denuncia.Municipio = value, answers, "municipio", "localidad", "poblacion");
SetIfMissing(() => denuncia.Provincia, value => denuncia.Provincia = value, answers, "provincia");
SetIfMissing(() => denuncia.CodigoPostal, value => denuncia.CodigoPostal = value, answers, "codigo postal", "c p");
SetIfMissing(() => denuncia.Pais, value => denuncia.Pais = value, answers, "pais");
if (denuncia.Fecha_Hechos == DateTime.MinValue &&
TryGetAnswer(answers, out var fechaHechos, "fecha de los hechos que denuncia") &&
DateTime.TryParse(fechaHechos, CultureInfo.CurrentCulture, DateTimeStyles.None, out var parsedDate))
{
denuncia.Fecha_Hechos = parsedDate;
}
if (string.IsNullOrWhiteSpace(denuncia.Apellidos))
{
denuncia.Apellidos = string.Join(
' ',
new[] { denuncia.PrimerApellido, denuncia.SegundoApellido }
.Where(value => !string.IsNullOrWhiteSpace(value)));
}
}
private static void MergeComments(DenunciasGestiona denuncia, JsonElement root)
{
if (!root.TryGetProperty("comments", out var commentsElement) ||
commentsElement.ValueKind != JsonValueKind.Array)
{
return;
}
var builder = new StringBuilder();
foreach (var comment in commentsElement.EnumerateArray())
{
if (!comment.TryGetProperty("content", out var contentElement))
{
continue;
}
var content = contentElement.GetString();
if (string.IsNullOrWhiteSpace(content))
{
continue;
}
if (builder.Length > 0)
{
builder.AppendLine().AppendLine();
}
builder.Append(content.Trim());
}
var merged = builder.ToString().Trim();
if (!string.IsNullOrWhiteSpace(merged) && !denuncia.Comments.Contains(merged, StringComparison.OrdinalIgnoreCase))
{
denuncia.Comments = string.IsNullOrWhiteSpace(denuncia.Comments)
? merged
: $"{denuncia.Comments}{Environment.NewLine}{Environment.NewLine}{merged}";
}
}
private static void SetIfMissing(
Func<string> currentValue,
Action<string> assign,
Dictionary<string, string> answers,
params string[] candidateLabels)
{
if (!string.IsNullOrWhiteSpace(currentValue()))
{
return;
}
if (TryGetAnswer(answers, out var value, candidateLabels))
{
assign(value);
}
}
private static bool TryGetAnswer(
Dictionary<string, string> answers,
out string value,
params string[] candidateLabels)
{
foreach (var label in candidateLabels)
{
if (answers.TryGetValue(Normalize(label), out value!))
{
return true;
}
}
value = string.Empty;
return false;
}
private static string Normalize(string? text)
{
if (string.IsNullOrWhiteSpace(text))
{
return string.Empty;
}
var normalized = text.Normalize(NormalizationForm.FormD);
var builder = new StringBuilder(normalized.Length);
foreach (var character in normalized)
{
var category = CharUnicodeInfo.GetUnicodeCategory(character);
if (category == UnicodeCategory.NonSpacingMark)
{
continue;
}
builder.Append(char.IsLetterOrDigit(character) ? char.ToLowerInvariant(character) : ' ');
}
return string.Join(' ', builder.ToString().Split(' ', StringSplitOptions.RemoveEmptyEntries));
}
private sealed record FieldDefinition(string Label, string Type, Dictionary<string, string> Options);
}

View File

@@ -153,5 +153,8 @@ namespace GestionaDenunciasAN.Helpers
outputDoc.Save(ms);
return ms.ToArray();
}
}
}

View File

@@ -0,0 +1,464 @@
using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;
using GestionaDenunciasAN.Models;
namespace GestionaDenunciasAN.Helpers;
public static class ReportParser
{
public static DenunciasGestiona ParseReport(string reportText)
{
var lines = NormalizeLines(reportText);
var denuncia = new DenunciasGestiona
{
TextoOriginalReport = reportText ?? string.Empty
};
ParseMetadata(lines, denuncia);
var fields = ParseFormFields(lines, out var comments);
denuncia.SetCamposFormulario(fields);
if (!string.IsNullOrWhiteSpace(comments))
{
denuncia.Comments = comments;
}
ApplyFieldMappings(denuncia, fields);
FinalizeReporterData(denuncia);
return denuncia;
}
private static string[] NormalizeLines(string? reportText)
{
return (reportText ?? string.Empty)
.Replace("\r\n", "\n")
.Replace('\r', '\n')
.Split('\n');
}
private static void ParseMetadata(string[] lines, DenunciasGestiona denuncia)
{
foreach (var rawLine in lines)
{
var line = rawLine.Trim();
if (string.IsNullOrWhiteSpace(line))
{
continue;
}
if (line.StartsWith("ID:", StringComparison.OrdinalIgnoreCase))
{
if (int.TryParse(line[3..].Trim(), out var id))
{
denuncia.Id_Denuncia = id;
}
continue;
}
if (line.StartsWith("Fecha:", StringComparison.OrdinalIgnoreCase))
{
var value = line[6..].Trim();
if (value.EndsWith("(UTC)", StringComparison.OrdinalIgnoreCase))
{
value = value[..^5].Trim();
}
if (DateTime.TryParseExact(
value,
"dddd dd MMMM yyyy HH:mm",
CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
out var parsedDate))
{
denuncia.Fecha = parsedDate;
}
else if (DateTime.TryParse(
value,
CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
out parsedDate))
{
denuncia.Fecha = parsedDate;
}
continue;
}
if (line.StartsWith("Etiqueta:", StringComparison.OrdinalIgnoreCase))
{
denuncia.Etiqueta = line[9..].Trim();
continue;
}
if (line.StartsWith("Estado:", StringComparison.OrdinalIgnoreCase))
{
denuncia.Estado = line[7..].Trim();
}
}
}
private static List<ReportFieldEntry> ParseFormFields(string[] lines, out string comments)
{
var fields = new List<ReportFieldEntry>();
var commentBuilder = new StringBuilder();
var currentSection = string.Empty;
var order = 0;
var messagesSection = false;
for (var i = 0; i < lines.Length; i++)
{
var rawLine = lines[i];
var trimmed = rawLine.Trim();
if (messagesSection)
{
if (!string.IsNullOrWhiteSpace(trimmed))
{
if (commentBuilder.Length > 0)
{
commentBuilder.AppendLine();
}
commentBuilder.Append(trimmed);
}
continue;
}
if (string.IsNullOrWhiteSpace(trimmed) || IsMetadataLine(trimmed))
{
continue;
}
if (string.Equals(trimmed, "{Messages}", StringComparison.OrdinalIgnoreCase))
{
messagesSection = true;
continue;
}
var indent = CountIndentation(rawLine);
if (indent == 0)
{
currentSection = trimmed;
continue;
}
var label = trimmed;
var valueLines = new List<string>();
var j = i + 1;
while (j < lines.Length)
{
var nextRaw = lines[j];
var nextTrimmed = nextRaw.Trim();
if (string.Equals(nextTrimmed, "{Messages}", StringComparison.OrdinalIgnoreCase))
{
break;
}
if (string.IsNullOrWhiteSpace(nextTrimmed))
{
j++;
continue;
}
var nextIndent = CountIndentation(nextRaw);
if (nextIndent <= indent)
{
break;
}
valueLines.Add(nextTrimmed);
j++;
}
fields.Add(new ReportFieldEntry
{
Order = ++order,
Section = currentSection,
Label = label,
Value = string.Join(Environment.NewLine, valueLines)
});
i = j - 1;
}
comments = commentBuilder.ToString().Trim();
return fields;
}
private static void ApplyFieldMappings(DenunciasGestiona denuncia, IReadOnlyList<ReportFieldEntry> fields)
{
var lookup = fields
.GroupBy(field => Normalize(field.Label))
.ToDictionary(
group => group.Key,
group => group
.Select(entry => entry.Value.Trim())
.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)) ?? string.Empty,
StringComparer.OrdinalIgnoreCase);
denuncia.TipoDenunciante = FindValue(
lookup,
"indique si actua como persona fisica o en representacion de una persona juridica");
denuncia.EsPersonaJuridica = denuncia.TipoDenunciante.Contains("jurid", StringComparison.OrdinalIgnoreCase);
denuncia.Nombre = FindValue(lookup, "nombre");
denuncia.PrimerApellido = FindValue(lookup, "1 apellido", "primer apellido");
denuncia.SegundoApellido = FindValue(lookup, "2 apellido", "segundo apellido");
denuncia.Apellidos = string.Join(
' ',
new[] { denuncia.PrimerApellido, denuncia.SegundoApellido }
.Where(value => !string.IsNullOrWhiteSpace(value)));
denuncia.RazonSocial = FindValue(lookup, "razon social");
denuncia.Sexo = NormalizeGender(FindValue(lookup, "sexo"));
denuncia.Telefono = FindValue(lookup, "contacto telefonico", "telefono", "telefono movil");
denuncia.PaisOrigen = FindValue(lookup, "pais de origen");
if (TryGetValue(lookup, out var identifier, "nif dni nie"))
{
denuncia.Dni = identifier;
denuncia.TipoDocumentoIdentificativo = "NIF (DNI, NIE)";
}
else if (TryGetValue(lookup, out identifier, "cif"))
{
denuncia.Dni = identifier;
denuncia.TipoDocumentoIdentificativo = "CIF";
}
else if (TryGetValue(lookup, out identifier, "otro documento identificativo"))
{
denuncia.Dni = identifier;
denuncia.TipoDocumentoIdentificativo = "Otro documento identificativo";
}
denuncia.Asunto = FindValue(lookup, "asunto");
denuncia.A_Quien_Denuncia = FindValue(lookup, "a quien denuncia");
denuncia.DenunciadoDetalle = FindValue(lookup, "especifique a quien denuncia");
denuncia.Descripcion_Denuncia = FindValue(lookup, "describa su denuncia");
denuncia.Denunciado_Ante_Inst = FindValue(
lookup,
"ha denunciado estos hechos ante otras instituciones u organos");
denuncia.OrganismoDenunciado = FindValue(
lookup,
"por favor indique el organismo o la institucion donde ha denunciado los hechos",
"por favor indique el organismo o la institucion donde ha denunciado los hechos");
denuncia.SolicitaProteccion = FindValue(
lookup,
"solicita medidas concretas de proteccion");
denuncia.MedidasProteccionSolicitadas = FindValue(
lookup,
"describa las medidas de proteccion solicitadas");
denuncia.Lugar_Hechos = FindValue(
lookup,
"lugar en el que ocurrieron los hechos que denuncia",
"lugar en la que ocurrieron los hechos que denuncia");
var fechaHechos = FindValue(lookup, "fecha de los hechos que denuncia");
if (TryParseSpanishDate(fechaHechos, out var parsedFactsDate))
{
denuncia.Fecha_Hechos = parsedFactsDate;
}
denuncia.AutorizaRemision = FindValue(lookup, "autorizacion para remitir su denuncia");
denuncia.PreferenciaRemision = FindValue(
lookup,
"en tal caso desea que su denuncia se remita anonimizada sin datos personales");
denuncia.Notificacion_Preferencia = FindValue(lookup, "preferencia de notificacion");
denuncia.Notificacion_Electronica = FindValue(lookup, "notificaciones electronicas");
denuncia.SeguimientoOnline = FindValue(lookup, "seguimiento online");
denuncia.NotificacionPostal = FindValue(lookup, "autorizo recibir notificaciones via correo postal");
denuncia.Correo_Electronico = FindValue(lookup, "correo electronico");
denuncia.Provincia = FindValue(lookup, "provincia");
denuncia.DireccionTipoVia = NormalizeRoadType(FindValue(lookup, "tipo de via"));
denuncia.Direccion = FindValue(lookup, "nombre de la via", "direccion", "domicilio");
denuncia.CodigoPostal = FindValue(lookup, "codigo postal", "c p");
denuncia.Municipio = FindValue(lookup, "localidad", "municipio", "poblacion");
denuncia.DireccionNumero = FindValue(lookup, "numero km", "numero");
denuncia.DireccionBloque = FindValue(lookup, "bloque");
denuncia.DireccionEscalera = FindValue(lookup, "escalera");
denuncia.DireccionPiso = FindValue(lookup, "planta", "piso");
denuncia.DireccionPuerta = FindValue(lookup, "puerta");
denuncia.DireccionExtra = FindValue(lookup, "extra");
denuncia.Condiciones = fields.Any(field =>
field.Section.Contains("Condiciones", StringComparison.OrdinalIgnoreCase) &&
(field.Value.Contains("☑", StringComparison.Ordinal) ||
field.Value.Contains("Acepto", StringComparison.OrdinalIgnoreCase)));
if (string.IsNullOrWhiteSpace(denuncia.Pais))
{
denuncia.Pais = denuncia.PaisOrigen;
}
}
private static void FinalizeReporterData(DenunciasGestiona denuncia)
{
if (string.IsNullOrWhiteSpace(denuncia.TipoDenunciante))
{
denuncia.TipoDenunciante = string.IsNullOrWhiteSpace(denuncia.RazonSocial) &&
string.IsNullOrWhiteSpace(denuncia.Nombre)
? "Anonima"
: "Persona fisica";
}
if (denuncia.EsPersonaJuridica && string.IsNullOrWhiteSpace(denuncia.RazonSocial))
{
denuncia.RazonSocial = denuncia.NombreDenuncianteMostrar;
}
if (string.IsNullOrWhiteSpace(denuncia.Tipo_Denuncia))
{
denuncia.Tipo_Denuncia = string.IsNullOrWhiteSpace(denuncia.RazonSocial) &&
string.IsNullOrWhiteSpace(denuncia.Nombre)
? "Anonima"
: "No anonima, deseo identificarme";
}
denuncia.Confidencial = DenunciasGestiona.IndicaDenunciaAnonima(denuncia.Tipo_Denuncia) ||
!DenunciasGestiona.IndicaDenunciaIdentificada(denuncia.Tipo_Denuncia) &&
string.IsNullOrWhiteSpace(denuncia.RazonSocialResuelta) &&
string.IsNullOrWhiteSpace(denuncia.NombreResuelto) &&
string.IsNullOrWhiteSpace(denuncia.DocumentoResuelto);
}
private static string FindValue(
IReadOnlyDictionary<string, string> lookup,
params string[] candidateLabels)
{
return TryGetValue(lookup, out var value, candidateLabels) ? value : string.Empty;
}
private static bool TryGetValue(
IReadOnlyDictionary<string, string> lookup,
out string value,
params string[] candidateLabels)
{
foreach (var candidateLabel in candidateLabels)
{
if (lookup.TryGetValue(Normalize(candidateLabel), out value!) &&
!string.IsNullOrWhiteSpace(value))
{
return true;
}
}
value = string.Empty;
return false;
}
private static bool TryParseSpanishDate(string? value, out DateTime result)
{
if (string.IsNullOrWhiteSpace(value))
{
result = DateTime.MinValue;
return false;
}
if (DateTime.TryParseExact(
value,
new[] { "dd/MM/yyyy", "d/M/yyyy", "dd-MM-yyyy", "d-M-yyyy" },
CultureInfo.GetCultureInfo("es-ES"),
DateTimeStyles.None,
out result))
{
return true;
}
return DateTime.TryParse(value, CultureInfo.GetCultureInfo("es-ES"), DateTimeStyles.None, out result);
}
private static string NormalizeGender(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return string.Empty;
}
return Normalize(value) switch
{
"masculino" or "hombre" => "Hombre",
"femenino" or "mujer" => "Mujer",
_ => value.Trim()
};
}
private static string NormalizeRoadType(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return string.Empty;
}
var normalized = Normalize(value);
return normalized switch
{
"calle" => "CL",
"avenida" => "AV",
"plaza" => "PZ",
"carretera" => "CR",
"camino" => "CM",
"poligono" => "PG",
_ => value.Trim().ToUpperInvariant()
};
}
private static bool IsMetadataLine(string line)
{
return line.StartsWith("ID:", StringComparison.OrdinalIgnoreCase) ||
line.StartsWith("Fecha:", StringComparison.OrdinalIgnoreCase) ||
line.StartsWith("Etiqueta:", StringComparison.OrdinalIgnoreCase) ||
line.StartsWith("Estado:", StringComparison.OrdinalIgnoreCase);
}
private static int CountIndentation(string value)
{
var indent = 0;
foreach (var character in value)
{
if (character == ' ')
{
indent++;
continue;
}
if (character == '\t')
{
indent += 4;
continue;
}
break;
}
return indent;
}
private static string Normalize(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return string.Empty;
}
var normalized = value.Normalize(NormalizationForm.FormD);
var builder = new StringBuilder(normalized.Length);
foreach (var character in normalized)
{
var category = CharUnicodeInfo.GetUnicodeCategory(character);
if (category == UnicodeCategory.NonSpacingMark)
{
continue;
}
builder.Append(char.IsLetterOrDigit(character) ? char.ToLowerInvariant(character) : ' ');
}
return Regex.Replace(builder.ToString(), @"\s+", " ").Trim();
}
}

View File

@@ -0,0 +1,3 @@
namespace GestionaDenunciasAN.Models;
public sealed record ApiError(string Error, bool SessionExpired = false);

View File

@@ -0,0 +1,3 @@
namespace GestionaDenunciasAN.Models;
public sealed record ContextDto(string Id, string Name);

View File

@@ -1,108 +1,282 @@
using System;
using System.Globalization;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace GestionaDenunciasAN.Models
namespace GestionaDenunciasAN.Models;
public class DenunciasGestiona
{
public class DenunciasGestiona
public int Id_RegistroDenuncia { get; set; }
public int Id_Denuncia { get; set; }
public DateTime Fecha { get; set; } = DateTime.MinValue;
public string Expediente_Gestiona { get; set; } = string.Empty;
public string CodigoExpedienteGestiona { get; set; } = string.Empty;
public int Id_Persona_Gestiona { get; set; }
public string Etiqueta { get; set; } = string.Empty;
public string Estado { get; set; } = string.Empty;
public string Tipo_Denuncia { get; set; } = string.Empty;
public string TipoDenunciante { get; set; } = string.Empty;
public bool EsPersonaJuridica { get; set; }
public string Nombre { get; set; } = string.Empty;
public string PrimerApellido { get; set; } = string.Empty;
public string SegundoApellido { get; set; } = string.Empty;
public string Apellidos { get; set; } = string.Empty;
public string RazonSocial { get; set; } = string.Empty;
public string Sexo { get; set; } = string.Empty;
public string Dni { get; set; } = string.Empty;
public string TipoDocumentoIdentificativo { get; set; } = string.Empty;
public string PaisOrigen { get; set; } = string.Empty;
public string Asunto { get; set; } = string.Empty;
public string A_Quien_Denuncia { get; set; } = string.Empty;
public string DenunciadoDetalle { get; set; } = string.Empty;
public string Descripcion_Denuncia { get; set; } = string.Empty;
public string Denunciado_Ante_Inst { get; set; } = string.Empty;
public string OrganismoDenunciado { get; set; } = string.Empty;
public string SolicitaProteccion { get; set; } = string.Empty;
public string MedidasProteccionSolicitadas { get; set; } = string.Empty;
public string Modalidad_Informacion { get; set; } = string.Empty;
public string Lugar_Hechos { get; set; } = string.Empty;
public DateTime Fecha_Hechos { get; set; } = DateTime.MinValue;
public string AutorizaRemision { get; set; } = string.Empty;
public string PreferenciaRemision { get; set; } = string.Empty;
public string Notificacion_Preferencia { get; set; } = string.Empty;
public string Notificacion_Electronica { get; set; } = string.Empty;
public string SeguimientoOnline { get; set; } = string.Empty;
public string NotificacionPostal { get; set; } = string.Empty;
public string Correo_Electronico { get; set; } = string.Empty;
public string Notificacion_Sms { get; set; } = string.Empty;
public bool Condiciones { get; set; }
public string Comments { get; set; } = string.Empty;
public string Telefono { get; set; } = string.Empty;
public string Direccion { get; set; } = string.Empty;
public string DireccionTipoVia { get; set; } = string.Empty;
public string DireccionNumero { get; set; } = string.Empty;
public string DireccionPiso { get; set; } = string.Empty;
public string DireccionPuerta { get; set; } = string.Empty;
public string DireccionBloque { get; set; } = string.Empty;
public string DireccionEscalera { get; set; } = string.Empty;
public string DireccionExtra { get; set; } = string.Empty;
public string Municipio { get; set; } = string.Empty;
public string Provincia { get; set; } = string.Empty;
public string CodigoPostal { get; set; } = string.Empty;
public string Pais { get; set; } = string.Empty;
public string CamposFormularioJson { get; set; } = string.Empty;
public string TextoOriginalReport { get; set; } = string.Empty;
public bool Confidencial { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public bool EsActualizacion { get; set; }
public Guid ProcedureId { get; set; } = Guid.Empty;
public Guid GroupId { get; set; } = Guid.Empty;
public string NombreDenuncia { get; set; } = string.Empty;
public string EstadoDenuncia { get; set; } = string.Empty;
public string ArchivoElegido { get; set; } = string.Empty;
public DateTime FechaSubidaAGestiona { get; set; } = DateTime.MinValue;
public bool EnGestiona { get; set; }
public bool EnRechazada { get; set; }
[JsonIgnore]
public string NombreResuelto => ResolveValue(Nombre, "nombre");
[JsonIgnore]
public string PrimerApellidoResuelto => ResolveValue(PrimerApellido, "1 apellido", "primer apellido");
[JsonIgnore]
public string SegundoApellidoResuelto => ResolveValue(SegundoApellido, "2 apellido", "segundo apellido");
[JsonIgnore]
public string ApellidosResueltos
{
// Propiedades existentes
public int Id_RegistroDenuncia { get; set; }
public int Id_Denuncia { get; set; }
public DateTime Fecha { get; set; }
public string Expediente_Gestiona { get; set; }
public int Id_Persona_Gestiona { get; set; }
public string Etiqueta { get; set; }
public string Estado { get; set; }
public string Tipo_Denuncia { get; set; }
public string? Nombre { get; set; }
public string? Apellidos { get; set; }
public string? Sexo { get; set; }
public string? Dni { get; set; }
public string Asunto { get; set; }
public string A_Quien_Denuncia { get; set; }
public string Descripcion_Denuncia { get; set; }
public string Denunciado_Ante_Inst { get; set; }
public string? Modalidad_Informacion { get; set; }
public string Lugar_Hechos { get; set; }
public DateTime Fecha_Hechos { get; set; }
public string? Notificacion_Preferencia { get; set; }
public string? Notificacion_Electronica { get; set; }
public string? Correo_Electronico { get; set; }
public string? Notificacion_Sms { get; set; }
public bool Condiciones { get; set; }
public string? Comments { get; set; }
// Nuevas propiedades
public string NombreDenuncia { get; set; }
public string EstadoDenuncia { get; set; }
public string ArchivoElegido { get; set; }
public DateTime FechaSubidaAGestiona { get; set; } // Nueva propiedad
// Constructor por defecto
public DenunciasGestiona()
get
{
}
if (!string.IsNullOrWhiteSpace(Apellidos))
{
return Apellidos.Trim();
}
// Constructor parametrizado que inicializa todos los campos, incluida la nueva propiedad
public DenunciasGestiona(
int idRegistroDenuncia,
int idDenuncia,
DateTime fecha,
string expedienteGestiona,
int idPersonaGestiona,
string etiqueta,
string estado,
string tipoDenuncia,
string? nombre,
string? apellidos,
string? sexo,
string? dni,
string asunto,
string aQuienDenuncia,
string descripcionDenuncia,
string denunciadoAnteInst,
string? modalidadInformacion,
string lugarHechos,
DateTime fechaHechos,
string? notificacionPreferencia,
string? notificacionElectronica,
string? correoElectronico,
string? notificacionSms,
bool condiciones,
string? comments,
string nombreDenuncia,
string estadoDenuncia,
string archivoElegido,
DateTime fechaSubidaAGestiona)
{
Id_RegistroDenuncia = idRegistroDenuncia;
Id_Denuncia = idDenuncia;
Fecha = fecha;
Expediente_Gestiona = expedienteGestiona;
Id_Persona_Gestiona = idPersonaGestiona;
Etiqueta = etiqueta;
Estado = estado;
Tipo_Denuncia = tipoDenuncia;
Nombre = nombre;
Apellidos = apellidos;
Sexo = sexo;
Dni = dni;
Asunto = asunto;
A_Quien_Denuncia = aQuienDenuncia;
Descripcion_Denuncia = descripcionDenuncia;
Denunciado_Ante_Inst = denunciadoAnteInst;
Modalidad_Informacion = modalidadInformacion;
Lugar_Hechos = lugarHechos;
Fecha_Hechos = fechaHechos;
Notificacion_Preferencia = notificacionPreferencia;
Notificacion_Electronica = notificacionElectronica;
Correo_Electronico = correoElectronico;
Notificacion_Sms = notificacionSms;
Condiciones = condiciones;
Comments = comments;
NombreDenuncia = nombreDenuncia;
EstadoDenuncia = estadoDenuncia;
ArchivoElegido = archivoElegido;
FechaSubidaAGestiona = fechaSubidaAGestiona;
return string.Join(
' ',
new[] { PrimerApellidoResuelto, SegundoApellidoResuelto }
.Where(value => !string.IsNullOrWhiteSpace(value)));
}
}
[JsonIgnore]
public string RazonSocialResuelta => ResolveValue(RazonSocial, "razon social");
[JsonIgnore]
public string DocumentoResuelto => ResolveValue(
Dni,
"nif dni nie",
"dni",
"nif",
"nie",
"cif",
"otro documento identificativo");
[JsonIgnore]
public bool EsAnonima
{
get
{
if (IndicaDenunciaIdentificada(Tipo_Denuncia))
{
return false;
}
if (Confidencial)
{
return true;
}
if (IndicaDenunciaAnonima(Tipo_Denuncia))
{
return true;
}
return string.IsNullOrWhiteSpace(DocumentoResuelto) &&
string.IsNullOrWhiteSpace(NombreResuelto) &&
string.IsNullOrWhiteSpace(RazonSocialResuelta);
}
}
[JsonIgnore]
public string NombreDenuncianteMostrar =>
EsPersonaJuridica
? RazonSocialResuelta
: string.Join(
' ',
new[] { NombreResuelto, PrimerApellidoResuelto, SegundoApellidoResuelto }
.Where(value => !string.IsNullOrWhiteSpace(value)));
[JsonIgnore]
public string ExpedienteGestionaMostrable
{
get
{
if (!string.IsNullOrWhiteSpace(CodigoExpedienteGestiona))
{
return CodigoExpedienteGestiona.Trim();
}
if (string.IsNullOrWhiteSpace(Expediente_Gestiona))
{
return string.Empty;
}
return string.Equals(Expediente_Gestiona, "Pendiente", StringComparison.OrdinalIgnoreCase)
? "Pendiente"
: "Pendiente de sincronizar";
}
}
public IReadOnlyList<ReportFieldEntry> GetCamposFormulario()
{
if (string.IsNullOrWhiteSpace(CamposFormularioJson))
{
return [];
}
try
{
return JsonSerializer.Deserialize<List<ReportFieldEntry>>(CamposFormularioJson) ?? [];
}
catch
{
return [];
}
}
public void SetCamposFormulario(IEnumerable<ReportFieldEntry> fields)
{
var list = fields?.ToList() ?? [];
CamposFormularioJson = list.Count == 0
? string.Empty
: JsonSerializer.Serialize(list);
}
public static bool IndicaDenunciaAnonima(string? tipoDenuncia)
{
var normalized = NormalizeLabel(tipoDenuncia);
if (string.IsNullOrWhiteSpace(normalized))
{
return false;
}
if (IndicaDenunciaIdentificada(tipoDenuncia))
{
return false;
}
return normalized == "anonima" ||
normalized == "anonimo" ||
normalized.Contains("denuncia anonima", StringComparison.Ordinal) ||
normalized.Contains("anonima", StringComparison.Ordinal) ||
normalized.Contains("anonimo", StringComparison.Ordinal);
}
public static bool IndicaDenunciaIdentificada(string? tipoDenuncia)
{
var normalized = NormalizeLabel(tipoDenuncia);
if (string.IsNullOrWhiteSpace(normalized))
{
return false;
}
return normalized.Contains("no anonima", StringComparison.Ordinal) ||
normalized.Contains("no anonimo", StringComparison.Ordinal) ||
normalized.Contains("deseo identificarme", StringComparison.Ordinal) ||
normalized.Contains("quiero identificarme", StringComparison.Ordinal) ||
normalized.Contains("identificarme", StringComparison.Ordinal);
}
private string ResolveValue(string currentValue, params string[] fallbackLabels)
{
if (!string.IsNullOrWhiteSpace(currentValue))
{
return currentValue.Trim();
}
var labels = fallbackLabels
.Select(NormalizeLabel)
.ToHashSet(StringComparer.Ordinal);
foreach (var field in GetCamposFormulario())
{
if (labels.Contains(NormalizeLabel(field.Label)) &&
!string.IsNullOrWhiteSpace(field.Value))
{
return field.Value.Trim();
}
}
return string.Empty;
}
private static string NormalizeLabel(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return string.Empty;
}
var normalized = value.Normalize(NormalizationForm.FormD);
var builder = new StringBuilder(normalized.Length);
foreach (var ch in normalized)
{
if (CharUnicodeInfo.GetUnicodeCategory(ch) != UnicodeCategory.NonSpacingMark)
{
builder.Append(char.ToLowerInvariant(ch));
}
}
return builder.ToString().Normalize(NormalizationForm.FormC).Trim();
}
}

View File

@@ -0,0 +1,11 @@
namespace GestionaDenunciasAN.Models
{
public class ExpedienteTerceroDto
{
public string FileUrl { get; set; } = "";
public string? CodigoExpediente { get; set; } // name / code / etc.
public string? Asunto { get; set; } // subject / free_title
public DateTimeOffset? FechaCreacion { get; set; }
public string? Estado { get; set; }
}
}

View File

@@ -1,4 +1,5 @@
using System;
// Models/FicherosDenuncias.cs
using System;
namespace GestionaDenunciasAN.Models
{
@@ -13,7 +14,7 @@ namespace GestionaDenunciasAN.Models
// Descripción del fichero (puede ser nula)
public string? Descripcion { get; set; }
// Fecha de creación del fichero
// Fecha de creación del fichero original
public DateTime Fecha { get; set; }
// Observaciones
@@ -28,12 +29,20 @@ namespace GestionaDenunciasAN.Models
// Fichero completo en formato byte array (BLOB)
public byte[] Fichero { get; set; }
// Constructor por defecto
public FicherosDenuncias()
{
}
// → Nuevo: marca si ya se subió a Gestión
public bool Subido { get; set; }
// → Nuevo: fecha en que se subió por última vez
public DateTime? FechaSubida { get; set; }
// Hash SHA-256 del contenido, para evitar re-subir adjuntos repetidos.
public string ContentSha256 { get; set; } = string.Empty;
public bool EsReport =>
string.Equals(NombreFichero, "report.txt", StringComparison.OrdinalIgnoreCase);
public FicherosDenuncias() { }
// Constructor parametrizado que inicializa todos los campos
public FicherosDenuncias(
int id_Fichero,
int id_Tipo,
@@ -42,7 +51,8 @@ namespace GestionaDenunciasAN.Models
string observaciones,
int id_Denuncia,
string nombreFichero,
byte[] fichero)
byte[] fichero
)
{
Id_Fichero = id_Fichero;
Id_Tipo = id_Tipo;
@@ -52,6 +62,8 @@ namespace GestionaDenunciasAN.Models
Id_Denuncia = id_Denuncia;
NombreFichero = nombreFichero;
Fichero = fichero;
Subido = false;
FechaSubida = null;
}
}
}

View File

@@ -0,0 +1,3 @@
namespace GestionaDenunciasAN.Models;
public sealed record FileDownloadResult(byte[] Content, string FileName);

View File

@@ -0,0 +1,8 @@
namespace GestionaDenunciasAN.Models;
public sealed class GestionaExpedienteInfo
{
public string FileUrl { get; set; } = string.Empty;
public string? CodigoExpediente { get; set; }
public string? FreeTitle { get; set; }
}

View File

@@ -0,0 +1,11 @@
namespace GestionaDenunciasAN.Models
{
public class GestionaOptions
{
public string ApiBase { get; set; } = null!;
public string AccessToken { get; set; } = null!;
public string UserLink { get; set; } = null!;
public string GroupLink { get; set; } = null!;
public string Location { get; set; } = null!;
}
}

View File

@@ -0,0 +1,3 @@
namespace GestionaDenunciasAN.Models;
public sealed record GlSession(string Id, string Username, string? Role = null);

View File

@@ -0,0 +1,12 @@
namespace GestionaDenunciasAN.Models;
public sealed class GlobalLeaksStoredSession
{
public string Username { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
public string? SessionId { get; set; }
public string? Role { get; set; }
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
public bool HasActiveSession => !string.IsNullOrWhiteSpace(SessionId);
}

View File

@@ -0,0 +1,7 @@
namespace GestionaDenunciasAN.Models;
public sealed record ImportSummary(
int TotalCandidates,
int ImportedCount,
IReadOnlyList<string> Errors,
IReadOnlyList<int>? ImportedComplaintIds = null);

View File

@@ -0,0 +1,9 @@
namespace GestionaDenunciasAN.Models;
public sealed record InboxUserState
{
public string Username { get; init; } = string.Empty;
public DateTimeOffset? LastSuccessfulDownloadAtUtc { get; init; }
public DateTimeOffset? LastDownloadedReportMomentUtc { get; init; }
public bool HasPreviousDownloads => LastDownloadedReportMomentUtc is not null;
}

View File

@@ -0,0 +1,3 @@
namespace GestionaDenunciasAN.Models;
public sealed record LoginRequest(string Username, string Password, string Authcode);

View File

@@ -0,0 +1,3 @@
namespace GestionaDenunciasAN.Models;
public sealed record LoginResponse(string Username);

View File

@@ -0,0 +1,25 @@
namespace GestionaDenunciasAN.Models;
public sealed record ReportDto
{
public string Id { get; init; } = string.Empty;
public int? Progressive { get; init; }
public string? ContextId { get; init; }
public string? ContextName { get; init; }
public string? CreationDate { get; init; }
public string? UpdateDate { get; init; }
public string? ExpirationDate { get; init; }
public string? ReminderDate { get; init; }
public string? AccessDate { get; init; }
public string? LastAccess { get; init; }
public string? Status { get; init; }
public bool Updated { get; init; }
public string? Label { get; init; }
public bool DownloadedByCurrentUser { get; init; }
public bool DownloadedByAnotherUser { get; init; }
public string? LastDownloadedByUsername { get; init; }
public string? LastDownloadedAt { get; init; }
public bool AlreadyImported { get; init; }
public bool AlreadyInGestiona { get; init; }
public string? TrackingNote { get; init; }
}

View File

@@ -0,0 +1,9 @@
namespace GestionaDenunciasAN.Models;
public sealed class ReportFieldEntry
{
public int Order { get; set; }
public string Section { get; set; } = string.Empty;
public string Label { get; set; } = string.Empty;
public string Value { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,15 @@
namespace GestionaDenunciasAN.Models;
public sealed record ReportSummaryDto
{
public string Id { get; init; } = string.Empty;
public int? Progressive { get; init; }
public string? CreationDate { get; init; }
public string? UpdateDate { get; init; }
public string? LastAccess { get; init; }
public string? AccessDate { get; init; }
public int? DaysSinceCreation { get; init; }
public int? DaysSinceAccess { get; init; }
public string? Status { get; init; }
public string? Label { get; init; }
}

View File

@@ -0,0 +1,43 @@
namespace GestionaDenunciasAN.Models;
public sealed class ThirdPartyAddressData
{
public string? Street { get; set; }
public string? Number { get; set; }
public string? Floor { get; set; }
public string? Door { get; set; }
public string? Block { get; set; }
public string? Stair { get; set; }
public string? Municipality { get; set; }
public string? Province { get; set; }
public string? ZipCode { get; set; }
public string? CountryCode { get; set; }
public string? RoadTypeCode { get; set; } = "CL";
public bool HasAnyValue =>
!string.IsNullOrWhiteSpace(Street) ||
!string.IsNullOrWhiteSpace(Number) ||
!string.IsNullOrWhiteSpace(Municipality) ||
!string.IsNullOrWhiteSpace(Province) ||
!string.IsNullOrWhiteSpace(ZipCode);
public static ThirdPartyAddressData? FromComplaint(DenunciasGestiona denuncia)
{
var data = new ThirdPartyAddressData
{
Street = denuncia.Direccion,
RoadTypeCode = denuncia.DireccionTipoVia,
Number = denuncia.DireccionNumero,
Floor = denuncia.DireccionPiso,
Door = denuncia.DireccionPuerta,
Block = denuncia.DireccionBloque,
Stair = denuncia.DireccionEscalera,
Municipality = denuncia.Municipio,
Province = denuncia.Provincia,
ZipCode = denuncia.CodigoPostal,
CountryCode = string.IsNullOrWhiteSpace(denuncia.Pais) ? denuncia.PaisOrigen : denuncia.Pais,
};
return data.HasAnyValue ? data : null;
}
}

View File

@@ -0,0 +1,63 @@
using System;
namespace GestionaDenunciasAN.Models;
public sealed class ThirdPartyIdentityData
{
public bool IsAnonymous { get; set; }
public bool IsLegalEntity { get; set; }
public string DocumentId { get; set; } = string.Empty;
public string FirstName { get; set; } = string.Empty;
public string LastName { get; set; } = string.Empty;
public string BusinessName { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
public string CountryCode { get; set; } = string.Empty;
public ThirdPartyAddressData? Address { get; set; }
public string DisplayName =>
IsLegalEntity
? BusinessName
: string.Join(
' ',
new[] { FirstName, LastName }
.Where(value => !string.IsNullOrWhiteSpace(value)));
public static ThirdPartyIdentityData FromComplaint(DenunciasGestiona denuncia)
{
ArgumentNullException.ThrowIfNull(denuncia);
var isAnonymous = denuncia.EsAnonima ||
string.Equals(
denuncia.DocumentoResuelto?.Trim(),
"00000000T",
StringComparison.OrdinalIgnoreCase);
return new ThirdPartyIdentityData
{
IsAnonymous = isAnonymous,
IsLegalEntity = !isAnonymous && denuncia.EsPersonaJuridica,
DocumentId = isAnonymous
? "00000000T"
: denuncia.DocumentoResuelto.Trim().ToUpperInvariant(),
FirstName = isAnonymous ? "Anonimo" : denuncia.NombreResuelto.Trim(),
LastName = isAnonymous ? "-" : BuildLastName(denuncia),
BusinessName = isAnonymous ? string.Empty : denuncia.RazonSocialResuelta.Trim(),
Email = (denuncia.Correo_Electronico ?? string.Empty).Trim(),
CountryCode = string.IsNullOrWhiteSpace(denuncia.Pais) ? denuncia.PaisOrigen : denuncia.Pais,
Address = isAnonymous ? null : ThirdPartyAddressData.FromComplaint(denuncia)
};
}
private static string BuildLastName(DenunciasGestiona denuncia)
{
var separated = string.Join(
' ',
new[] { denuncia.PrimerApellidoResuelto, denuncia.SegundoApellidoResuelto }
.Where(value => !string.IsNullOrWhiteSpace(value))
.Select(value => value.Trim()));
return string.IsNullOrWhiteSpace(separated)
? denuncia.ApellidosResueltos.Trim()
: separated;
}
}

View File

@@ -3,8 +3,8 @@
public class UserState
{
private readonly object _lock = new object();
private string _token;
private string _NombreUsu;
private string _token = string.Empty;
private string _NombreUsu = string.Empty;
private bool _Mostrar;
public string Token

View File

@@ -1,54 +1,64 @@
using Microsoft.AspNetCore.Authentication.Cookies;
using System.Globalization;
using System.Security.Claims;
using System.Text.RegularExpressions;
using GestionaDenunciasAN.Components;
using GestionaDenunciasAN.Configuration;
using GestionaDenunciasAN.Models;
using GestionaDenunciasAN.Services;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.Extensions.Options;
using System.Net.Http.Headers;
var builder = WebApplication.CreateBuilder(args);
CultureInfo.DefaultThreadCurrentCulture = CultureInfo.GetCultureInfo("es-ES");
CultureInfo.DefaultThreadCurrentUICulture = CultureInfo.GetCultureInfo("es-ES");
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));
// Configurar servicios
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
builder.Services.AddHttpClient("DefaultClient", client =>
{
client.BaseAddress = new Uri(Utilidades.urlSwagger());
});
builder.Services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = CookieAuthenticationDefaults.AuthenticationScheme;
})
.AddCookie(options =>
{
options.LoginPath = "/home";
options.AccessDeniedPath = "/AccessDenied";
});
// Necesario para ver porqu<71> est<73> fallando ciertas cosas que dan el error Circuit
builder.Services.AddCascadingAuthenticationState();
builder.Services
.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options =>
{
options.Cookie.Name = "denuncias.auth";
options.Cookie.HttpOnly = true;
options.Cookie.SameSite = SameSiteMode.Lax;
options.LoginPath = "/";
options.LogoutPath = "/api/auth/logout";
options.ExpireTimeSpan = TimeSpan.FromHours(8);
options.SlidingExpiration = false;
});
builder.Services.AddAuthorization();
builder.Services.AddDataProtection();
builder.Services.AddServerSideBlazor().AddCircuitOptions(option => { option.DetailedErrors = true; });
builder.Services.AddHttpContextAccessor();
builder.Services.AddDistributedMemoryCache();
builder.Services.AddSession(options =>
{
options.IdleTimeout = TimeSpan.FromHours(1);
options.Cookie.HttpOnly = true;
options.Cookie.IsEssential = true;
});
builder.Services.AddHttpClient("CertClient").ConfigurePrimaryHttpMessageHandler(() =>
{
return new HttpClientHandler
{
ClientCertificateOptions = ClientCertificateOption.Manual // Forzar la selecci<63>n del certificado
};
});
builder.Services.AddBlazorBootstrap();
builder.Services.AddAntiforgery();
builder.Services.AddSingleton<UserState>();
builder.Services.AddScoped<UserState>();
builder.Services.AddSingleton<AppSessionLifetime>();
builder.Services.AddSingleton<LoginRateLimiter>();
builder.Services.AddSingleton<GlobalLeaksSessionStore>();
builder.Services.AddScoped<GlobalLeaksClient>();
builder.Services.AddScoped<IDenunciaStore, MySqlDenunciaStore>();
builder.Services.AddScoped<IInboxTrackingService, InboxTrackingService>();
builder.Services.AddScoped<DenunciaInboxService>();
builder.Services.AddScoped<GestionaDocumentWorkflowService>();
builder.Services.AddHttpClient<IGestionaService, GestionaService>((sp, client) =>
{
var opts = sp.GetRequiredService<IOptions<GestionaOptions>>().Value;
client.BaseAddress = new Uri(opts.ApiBase);
client.DefaultRequestHeaders.Add("X-Gestiona-Access-Token", opts.AccessToken);
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
});
var app = builder.Build();
@@ -58,48 +68,189 @@ if (!app.Environment.IsDevelopment())
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseSession();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseAntiforgery();
app.Use(async (context, next) =>
{
var userState = context.RequestServices.GetService<UserState>();
var path = context.Request.Path;
context.Response.Headers.XFrameOptions = "DENY";
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";
await next();
});
// Permitir solicitudes internas y recursos necesarios
if (path == "/" ||
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.Use(async (context, next) =>
{
var path = context.Request.Path;
var isPublic =
path == "/" ||
path.StartsWithSegments("/api") ||
path.StartsWithSegments("/_blazor") ||
path.StartsWithSegments("/_framework") ||
path.StartsWithSegments("/bootstrap") ||
path.StartsWithSegments("/Content") ||
path.StartsWithSegments("/Scripts") ||
path.StartsWithSegments("/css") ||
path.StartsWithSegments("/js") ||
path.StartsWithSegments("/favicon.ico") ||
path.StartsWithSegments("/_framework"))
path.StartsWithSegments("/favicon") ||
Path.HasExtension(path.Value);
if (context.User.Identity?.IsAuthenticated == true)
{
var username = context.User.Identity?.Name?.Trim();
var appSessionLifetime = context.RequestServices.GetRequiredService<AppSessionLifetime>();
var cookieStartupStamp = context.User.FindFirst("app_startup_stamp")?.Value;
var hasStoredCredentials = false;
var cookieBelongsToCurrentStartup =
!string.IsNullOrWhiteSpace(cookieStartupStamp) &&
string.Equals(cookieStartupStamp, appSessionLifetime.StartupStamp, StringComparison.Ordinal);
if (!string.IsNullOrWhiteSpace(username))
{
var sessionStore = context.RequestServices.GetRequiredService<GlobalLeaksSessionStore>();
var storedSession = await sessionStore.GetAsync(username, context.RequestAborted);
hasStoredCredentials =
storedSession is not null &&
!string.IsNullOrWhiteSpace(storedSession.Username) &&
!string.IsNullOrWhiteSpace(storedSession.Password);
}
if (!hasStoredCredentials || !cookieBelongsToCurrentStartup)
{
await context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
if (!isPublic)
{
var returnUrl = $"{context.Request.Path}{context.Request.QueryString}";
context.Response.Redirect($"/?returnUrl={Uri.EscapeDataString(returnUrl)}");
return;
}
}
}
if (isPublic || context.User.Identity?.IsAuthenticated == true)
{
await next();
return;
}
// Redirigir al home si no hay token y la ruta no es p<>blica
if (userState?.Token == null)
var loginReturnUrl = $"{context.Request.Path}{context.Request.QueryString}";
context.Response.Redirect($"/?returnUrl={Uri.EscapeDataString(loginReturnUrl)}");
});
app.UseAntiforgery();
var api = app.MapGroup("/api");
api.MapPost("/auth/login", async (
LoginRequest request,
HttpContext httpContext,
GlobalLeaksClient globalLeaksClient,
GlobalLeaksSessionStore sessionStore,
LoginRateLimiter rateLimiter,
CancellationToken cancellationToken) =>
{
var ip = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
var appSessionLifetime = httpContext.RequestServices.GetRequiredService<AppSessionLifetime>();
if (!rateLimiter.AllowAttempt(ip))
{
Console.WriteLine($"Redirigiendo al home desde: {path}");
context.Response.Redirect("/");
return;
return Results.Json(
new ApiError("Demasiados intentos. Espera un minuto."),
statusCode: StatusCodes.Status429TooManyRequests);
}
// Continuar con la solicitud
await next();
});
if (string.IsNullOrWhiteSpace(request.Username) ||
string.IsNullOrWhiteSpace(request.Password) ||
string.IsNullOrWhiteSpace(request.Authcode))
{
return Results.Json(
new ApiError("Debes indicar usuario, contraseña y código 2FA."),
statusCode: StatusCodes.Status400BadRequest);
}
if (!Regex.IsMatch(request.Authcode.Trim(), @"^\d{6}$"))
{
return Results.Json(
new ApiError("El código 2FA debe tener exactamente 6 dígitos."),
statusCode: StatusCodes.Status400BadRequest);
}
try
{
var session = await globalLeaksClient.LoginAsync(
request.Username.Trim(),
request.Password,
request.Authcode.Trim(),
cancellationToken);
var resolvedUsername = string.IsNullOrWhiteSpace(session.Username)
? request.Username.Trim()
: session.Username.Trim();
session = new GlSession(session.Id, resolvedUsername, session.Role);
await sessionStore.SaveAsync(
session.Username,
request.Password,
session.Id,
session.Role,
cancellationToken);
var claims = new List<Claim>
{
new(ClaimTypes.Name, session.Username),
new("app_startup_stamp", appSessionLifetime.StartupStamp),
};
if (!string.IsNullOrWhiteSpace(session.Role))
{
claims.Add(new Claim("gl_role", session.Role));
}
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
var principal = new ClaimsPrincipal(identity);
var authProperties = new AuthenticationProperties
{
IsPersistent = false,
AllowRefresh = true,
};
await httpContext.SignInAsync(
CookieAuthenticationDefaults.AuthenticationScheme,
principal,
authProperties);
return Results.Ok(new LoginResponse(session.Username));
}
catch (GlobalLeaksValidationException ex)
{
return Results.Json(new ApiError(ex.Message), statusCode: ex.StatusCode);
}
catch
{
return Results.Json(
new ApiError("No se ha podido conectar con GlobalLeaks."),
statusCode: StatusCodes.Status502BadGateway);
}
}).DisableAntiforgery();
api.MapPost("/auth/logout", async (
HttpContext httpContext,
GlobalLeaksSessionStore sessionStore,
CancellationToken cancellationToken) =>
{
var username = httpContext.User.Identity?.Name;
if (!string.IsNullOrWhiteSpace(username))
{
await sessionStore.DeleteAsync(username, cancellationToken);
}
await httpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
return Results.Ok(new { ok = true });
}).DisableAntiforgery();
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode();

View File

@@ -0,0 +1,158 @@
CREATE TABLE IF NOT EXISTS complaints (
id BIGINT NOT NULL AUTO_INCREMENT,
external_registry_id INT NOT NULL DEFAULT 0,
external_report_id INT NOT NULL,
report_date_utc DATETIME(6) NULL,
gestiona_file_url VARCHAR(768) NOT NULL DEFAULT '',
gestiona_file_code VARCHAR(128) NOT NULL DEFAULT '',
gestiona_person_id INT NOT NULL DEFAULT 0,
tag VARCHAR(128) NOT NULL DEFAULT '',
status_name VARCHAR(128) NOT NULL DEFAULT '',
complaint_type VARCHAR(256) NOT NULL DEFAULT '',
reporter_kind VARCHAR(128) NOT NULL DEFAULT '',
is_legal_entity TINYINT(1) NOT NULL DEFAULT 0,
reporter_first_name VARCHAR(256) NOT NULL DEFAULT '',
reporter_first_surname VARCHAR(256) NOT NULL DEFAULT '',
reporter_second_surname VARCHAR(256) NOT NULL DEFAULT '',
reporter_last_name VARCHAR(256) NOT NULL DEFAULT '',
reporter_business_name VARCHAR(256) NOT NULL DEFAULT '',
reporter_gender VARCHAR(32) NOT NULL DEFAULT '',
reporter_document_id VARCHAR(64) NOT NULL DEFAULT '',
reporter_document_type VARCHAR(128) NOT NULL DEFAULT '',
reporter_origin_country VARCHAR(128) NOT NULL DEFAULT '',
subject VARCHAR(512) NOT NULL DEFAULT '',
accused_party VARCHAR(512) NOT NULL DEFAULT '',
accused_party_details VARCHAR(512) NOT NULL DEFAULT '',
complaint_description LONGTEXT NULL,
reported_to_institution VARCHAR(512) NOT NULL DEFAULT '',
reported_institution_details VARCHAR(512) NOT NULL DEFAULT '',
requested_protection VARCHAR(128) NOT NULL DEFAULT '',
requested_protection_details VARCHAR(512) NOT NULL DEFAULT '',
information_mode VARCHAR(128) NOT NULL DEFAULT '',
facts_location VARCHAR(512) NOT NULL DEFAULT '',
facts_date_utc DATETIME(6) NULL,
forwarding_authorization VARCHAR(128) NOT NULL DEFAULT '',
forwarding_personal_data_preference VARCHAR(256) NOT NULL DEFAULT '',
notification_preference VARCHAR(128) NOT NULL DEFAULT '',
electronic_notification VARCHAR(128) NOT NULL DEFAULT '',
online_tracking_preference VARCHAR(256) NOT NULL DEFAULT '',
postal_notification_preference VARCHAR(256) NOT NULL DEFAULT '',
email VARCHAR(256) NOT NULL DEFAULT '',
sms_notification VARCHAR(128) NOT NULL DEFAULT '',
accepted_terms TINYINT(1) NOT NULL DEFAULT 0,
comments LONGTEXT NULL,
phone VARCHAR(64) NOT NULL DEFAULT '',
address_line VARCHAR(256) NOT NULL DEFAULT '',
address_road_type VARCHAR(32) NOT NULL DEFAULT '',
address_number VARCHAR(64) NOT NULL DEFAULT '',
address_floor VARCHAR(64) NOT NULL DEFAULT '',
address_door VARCHAR(64) NOT NULL DEFAULT '',
address_block VARCHAR(64) NOT NULL DEFAULT '',
address_stair VARCHAR(64) NOT NULL DEFAULT '',
address_extra VARCHAR(256) NOT NULL DEFAULT '',
municipality VARCHAR(128) NOT NULL DEFAULT '',
province VARCHAR(128) NOT NULL DEFAULT '',
postal_code VARCHAR(32) NOT NULL DEFAULT '',
country_code VARCHAR(32) NOT NULL DEFAULT '',
form_fields_json LONGTEXT NULL,
raw_report_text LONGTEXT NULL,
is_confidential TINYINT(1) NOT NULL DEFAULT 0,
is_update TINYINT(1) NOT NULL DEFAULT 0,
gestiona_procedure_id CHAR(36) NULL,
gestiona_group_id CHAR(36) NULL,
display_name VARCHAR(512) NOT NULL DEFAULT '',
workflow_status VARCHAR(512) NOT NULL DEFAULT '',
selected_document_name TEXT NULL,
gestiona_uploaded_at_utc DATETIME(6) NULL,
is_in_gestiona TINYINT(1) NOT NULL DEFAULT 0,
is_rejected TINYINT(1) NOT NULL DEFAULT 0,
created_at_utc DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
updated_at_utc DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
PRIMARY KEY (id),
UNIQUE KEY uq_complaints_external_report_id (external_report_id),
KEY ix_complaints_flags (is_update, is_in_gestiona, is_rejected),
KEY ix_complaints_uploaded_at (gestiona_uploaded_at_utc)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS app_users (
id BIGINT NOT NULL AUTO_INCREMENT,
username VARCHAR(256) NOT NULL,
last_successful_download_at_utc DATETIME(6) NULL,
last_downloaded_report_moment_utc DATETIME(6) NULL,
created_at_utc DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
updated_at_utc DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
PRIMARY KEY (id),
UNIQUE KEY uq_app_users_username (username)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS inbox_reports (
id BIGINT NOT NULL AUTO_INCREMENT,
global_report_uuid CHAR(36) NOT NULL,
progressive_id INT NULL,
context_id VARCHAR(128) NULL,
context_name VARCHAR(256) NULL,
creation_date_utc DATETIME(6) NULL,
update_date_utc DATETIME(6) NULL,
access_date_utc DATETIME(6) NULL,
last_access_utc DATETIME(6) NULL,
gl_status VARCHAR(64) NULL,
gl_label VARCHAR(128) NULL,
is_updated TINYINT(1) NOT NULL DEFAULT 0,
first_seen_at_utc DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
last_seen_at_utc DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
last_downloaded_at_utc DATETIME(6) NULL,
last_downloaded_by_user_id BIGINT NULL,
imported_complaint_report_id INT NULL,
imported_to_store_at_utc DATETIME(6) NULL,
created_at_utc DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
updated_at_utc DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
PRIMARY KEY (id),
UNIQUE KEY uq_inbox_reports_uuid (global_report_uuid),
KEY ix_inbox_reports_progressive (progressive_id),
KEY ix_inbox_reports_downloaded (last_downloaded_at_utc),
CONSTRAINT fk_inbox_reports_last_user
FOREIGN KEY (last_downloaded_by_user_id) REFERENCES app_users(id)
ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS user_inbox_reports (
app_user_id BIGINT NOT NULL,
inbox_report_id BIGINT NOT NULL,
first_seen_at_utc DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
last_seen_at_utc DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
first_downloaded_at_utc DATETIME(6) NULL,
last_downloaded_at_utc DATETIME(6) NULL,
download_count INT NOT NULL DEFAULT 0,
PRIMARY KEY (app_user_id, inbox_report_id),
KEY ix_user_inbox_reports_last_downloaded (last_downloaded_at_utc),
CONSTRAINT fk_user_inbox_reports_user
FOREIGN KEY (app_user_id) REFERENCES app_users(id)
ON DELETE CASCADE,
CONSTRAINT fk_user_inbox_reports_report
FOREIGN KEY (inbox_report_id) REFERENCES inbox_reports(id)
ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS complaint_attachments (
id BIGINT NOT NULL AUTO_INCREMENT,
complaint_id BIGINT NOT NULL,
attachment_type_id INT NOT NULL DEFAULT 0,
description VARCHAR(512) NULL,
attachment_date_utc DATETIME(6) NULL,
notes VARCHAR(512) NOT NULL DEFAULT '',
original_file_name VARCHAR(512) NOT NULL,
content LONGBLOB NOT NULL,
content_mime_type VARCHAR(128) NOT NULL DEFAULT '',
content_sha256 CHAR(64) NOT NULL,
uploaded_to_gestiona TINYINT(1) NOT NULL DEFAULT 0,
uploaded_at_utc DATETIME(6) NULL,
created_at_utc DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
updated_at_utc DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
PRIMARY KEY (id),
UNIQUE KEY uq_attachments_complaint_filename (complaint_id, original_file_name),
KEY ix_attachments_uploaded (uploaded_to_gestiona, uploaded_at_utc),
KEY ix_attachments_sha256 (content_sha256),
CONSTRAINT fk_attachments_complaint
FOREIGN KEY (complaint_id) REFERENCES complaints(id)
ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

View File

@@ -0,0 +1,6 @@
namespace GestionaDenunciasAN.Services;
public sealed class AppSessionLifetime
{
public string StartupStamp { get; } = Guid.NewGuid().ToString("N");
}

View File

@@ -0,0 +1,440 @@
using System.IO.Compression;
using System.Text;
using GestionaDenunciasAN.Helpers;
using GestionaDenunciasAN.Models;
namespace GestionaDenunciasAN.Services;
public sealed class DenunciaInboxService
{
private const string RootPath = @"C:\ZipsDenuncias";
private readonly IGestionaService _gestionaService;
private readonly IDenunciaStore _denunciaStore;
private readonly ILogger<DenunciaInboxService> _logger;
public DenunciaInboxService(
IGestionaService gestionaService,
IDenunciaStore denunciaStore,
ILogger<DenunciaInboxService> logger)
{
_gestionaService = gestionaService;
_denunciaStore = denunciaStore;
_logger = logger;
}
public async Task EnsureStorageReadyAsync(CancellationToken cancellationToken = default)
{
Directory.CreateDirectory(RootPath);
await _denunciaStore.EnsureSchemaAsync(cancellationToken);
}
public async Task<IReadOnlyList<string>> GetExistingZipNamesAsync(CancellationToken cancellationToken = default)
{
await EnsureStorageReadyAsync(cancellationToken);
return Directory
.GetFiles(RootPath, "*.zip")
.Select(Path.GetFileName)
.Where(name => !string.IsNullOrWhiteSpace(name))
.OrderBy(name => name, StringComparer.OrdinalIgnoreCase)
.ToArray()!;
}
public async Task DeleteZipAsync(string zipName, CancellationToken cancellationToken = default)
{
await EnsureStorageReadyAsync(cancellationToken);
var fullPath = Path.Combine(RootPath, zipName);
if (File.Exists(fullPath))
{
File.Delete(fullPath);
}
}
public async Task<ImportSummary> ProcessPendingFolderZipsAsync(CancellationToken cancellationToken = default)
{
await EnsureStorageReadyAsync(cancellationToken);
var zipPaths = Directory.GetFiles(RootPath, "*.zip")
.OrderBy(path => path, StringComparer.OrdinalIgnoreCase)
.ToList();
var errors = new List<string>();
var importedCount = 0;
var complaintIds = new List<int>();
foreach (var zipPath in zipPaths)
{
try
{
var zipBytes = await File.ReadAllBytesAsync(zipPath, cancellationToken);
var complaintId = await ProcessZipAsync(zipBytes, Path.GetFileName(zipPath), null, cancellationToken);
File.Delete(zipPath);
importedCount++;
complaintIds.Add(complaintId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error procesando el ZIP local {ZipPath}", zipPath);
errors.Add($"{Path.GetFileName(zipPath)}: {ex.Message}");
}
}
return new ImportSummary(zipPaths.Count, importedCount, errors, complaintIds);
}
public async Task<ImportSummary> ImportFromGlobalLeaksAsync(
FileDownloadResult zipDownload,
FileDownloadResult? jsonDownload,
CancellationToken cancellationToken = default)
{
await EnsureStorageReadyAsync(cancellationToken);
var fileName = string.IsNullOrWhiteSpace(zipDownload.FileName)
? $"report-{Guid.NewGuid():N}.zip"
: zipDownload.FileName;
var json = jsonDownload is null
? null
: Encoding.UTF8.GetString(jsonDownload.Content);
try
{
var complaintId = await ProcessZipAsync(zipDownload.Content, fileName, json, cancellationToken);
return new ImportSummary(1, 1, [], [complaintId]);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error importando denuncia desde GlobalLeaks {FileName}", fileName);
return new ImportSummary(1, 0, [$"{fileName}: {ex.Message}"]);
}
}
private async Task<int> ProcessZipAsync(
byte[] zipBytes,
string sourceName,
string? globalLeaksJson,
CancellationToken cancellationToken)
{
using var zipStream = new MemoryStream(zipBytes, writable: false);
using var archive = new ZipArchive(zipStream, ZipArchiveMode.Read, leaveOpen: false);
var reportEntry = archive.Entries.FirstOrDefault(entry =>
string.Equals(NormalizeEntryPath(entry.FullName), "report.txt", StringComparison.OrdinalIgnoreCase));
if (reportEntry is null)
{
throw new InvalidOperationException("El ZIP no contiene el fichero report.txt.");
}
var reportText = await ReadEntryTextAsync(reportEntry, cancellationToken);
var denuncia = ReportParser.ParseReport(reportText);
if (!string.IsNullOrWhiteSpace(globalLeaksJson))
{
GlobalLeaksJsonEnricher.Enrich(denuncia, globalLeaksJson);
}
if (denuncia.Id_Denuncia == 0)
{
denuncia.Id_Denuncia = TryReadIdFromReport(reportText);
}
if (denuncia.Id_Denuncia == 0)
{
throw new InvalidOperationException(
$"No se ha podido determinar el identificador de la denuncia en {sourceName}.");
}
denuncia.ProcedureId = Guid.Empty;
denuncia.GroupId = Guid.Empty;
if (string.IsNullOrWhiteSpace(denuncia.Expediente_Gestiona))
{
denuncia.Expediente_Gestiona = "Pendiente";
}
var nuevosFicheros = await ReadFilesFromArchiveAsync(archive, reportEntry, denuncia.Id_Denuncia, cancellationToken);
await MergeComplaintAsync(denuncia, cancellationToken);
await MergeFilesAsync(nuevosFicheros, cancellationToken);
return denuncia.Id_Denuncia;
}
private async Task<List<FicherosDenuncias>> ReadFilesFromArchiveAsync(
ZipArchive archive,
ZipArchiveEntry reportEntry,
int denunciaId,
CancellationToken cancellationToken)
{
var files = new List<FicherosDenuncias>
{
new(
id_Fichero: 0,
id_Tipo: 1,
descripcion: "report.txt original",
fecha: reportEntry.LastWriteTime.UtcDateTime == DateTime.MinValue
? DateTime.UtcNow
: reportEntry.LastWriteTime.UtcDateTime,
observaciones: "",
id_Denuncia: denunciaId,
nombreFichero: "report.txt",
fichero: await ReadEntryBytesAsync(reportEntry, cancellationToken))
};
foreach (var entry in archive.Entries.Where(IsSupportedAttachmentEntry))
{
files.Add(new FicherosDenuncias(
id_Fichero: 0,
id_Tipo: 1,
descripcion: null,
fecha: entry.LastWriteTime.UtcDateTime == DateTime.MinValue
? DateTime.UtcNow
: entry.LastWriteTime.UtcDateTime,
observaciones: "",
id_Denuncia: denunciaId,
nombreFichero: Path.GetFileName(entry.FullName),
fichero: await ReadEntryBytesAsync(entry, cancellationToken)));
}
return files;
}
private async Task MergeComplaintAsync(DenunciasGestiona denuncia, CancellationToken cancellationToken)
{
var existing = await _denunciaStore.GetDenunciaByIdAsync(denuncia.Id_Denuncia, cancellationToken);
if (existing is not null)
{
CopyComplaintData(existing, denuncia);
await CompleteGestionaStatusAsync(existing, existing, cancellationToken);
await _denunciaStore.UpsertDenunciaAsync(existing, cancellationToken);
return;
}
await CompleteGestionaStatusAsync(denuncia, null, cancellationToken);
await _denunciaStore.UpsertDenunciaAsync(denuncia, cancellationToken);
}
private Task MergeFilesAsync(List<FicherosDenuncias> nuevosFicheros, CancellationToken cancellationToken)
{
return _denunciaStore.UpsertFicherosAsync(nuevosFicheros, cancellationToken);
}
private async Task CompleteGestionaStatusAsync(
DenunciasGestiona denuncia,
CancellationToken cancellationToken)
{
await CompleteGestionaStatusAsync(denuncia, null, cancellationToken);
}
private async Task CompleteGestionaStatusAsync(
DenunciasGestiona denuncia,
DenunciasGestiona? storedComplaint,
CancellationToken cancellationToken)
{
if (TryApplyGestionaStatusFromStore(denuncia, storedComplaint))
{
return;
}
try
{
var match = await _gestionaService.BuscarExpedientePorIdEnAsuntoAsync(denuncia.Id_Denuncia);
if (match is null)
{
ApplyPendingGestionaStatus(denuncia);
return;
}
denuncia.EsActualizacion = true;
denuncia.EnGestiona = true;
denuncia.Expediente_Gestiona = match.FileUrl;
denuncia.CodigoExpedienteGestiona = match.CodigoExpediente ?? string.Empty;
denuncia.NombreDenuncia = match.FreeTitle ?? $"Denuncia {denuncia.Id_Denuncia}-CD";
}
catch (Exception ex)
{
ApplyPendingGestionaStatus(denuncia);
_logger.LogWarning(
ex,
"No se ha podido comprobar si la denuncia {DenunciaId} ya existia en Gestiona",
denuncia.Id_Denuncia);
}
}
private static bool TryApplyGestionaStatusFromStore(
DenunciasGestiona target,
DenunciasGestiona? storedComplaint)
{
if (!IsAlreadyUploadedToGestiona(storedComplaint))
{
return false;
}
target.EsActualizacion = true;
target.EnGestiona = true;
target.Expediente_Gestiona = string.IsNullOrWhiteSpace(storedComplaint!.Expediente_Gestiona)
? "Pendiente"
: storedComplaint.Expediente_Gestiona;
target.CodigoExpedienteGestiona = storedComplaint.CodigoExpedienteGestiona ?? string.Empty;
if (!string.IsNullOrWhiteSpace(storedComplaint.NombreDenuncia))
{
target.NombreDenuncia = storedComplaint.NombreDenuncia;
}
if (storedComplaint.FechaSubidaAGestiona != DateTime.MinValue)
{
target.FechaSubidaAGestiona = storedComplaint.FechaSubidaAGestiona;
}
return !string.IsNullOrWhiteSpace(target.CodigoExpedienteGestiona);
}
private static void ApplyPendingGestionaStatus(DenunciasGestiona denuncia)
{
denuncia.EsActualizacion = false;
denuncia.EnGestiona = false;
denuncia.Expediente_Gestiona = "Pendiente";
denuncia.CodigoExpedienteGestiona = string.Empty;
if (string.IsNullOrWhiteSpace(denuncia.NombreDenuncia))
{
denuncia.NombreDenuncia = $"Denuncia {denuncia.Id_Denuncia}-CD";
}
}
private static bool IsAlreadyUploadedToGestiona(DenunciasGestiona? denuncia)
{
if (denuncia is null)
{
return false;
}
if (denuncia.EnGestiona || denuncia.FechaSubidaAGestiona != DateTime.MinValue)
{
return true;
}
return !string.IsNullOrWhiteSpace(denuncia.Expediente_Gestiona) &&
!string.Equals(denuncia.Expediente_Gestiona, "Pendiente", StringComparison.OrdinalIgnoreCase);
}
private static void CopyComplaintData(DenunciasGestiona target, DenunciasGestiona source)
{
target.Fecha = source.Fecha;
target.Etiqueta = source.Etiqueta;
target.Estado = source.Estado;
target.Tipo_Denuncia = source.Tipo_Denuncia;
target.TipoDenunciante = source.TipoDenunciante;
target.EsPersonaJuridica = source.EsPersonaJuridica;
target.Confidencial = source.Confidencial;
target.Nombre = source.Nombre;
target.PrimerApellido = source.PrimerApellido;
target.SegundoApellido = source.SegundoApellido;
target.Apellidos = source.Apellidos;
target.RazonSocial = source.RazonSocial;
target.Sexo = source.Sexo;
target.Dni = source.Dni;
target.TipoDocumentoIdentificativo = source.TipoDocumentoIdentificativo;
target.PaisOrigen = source.PaisOrigen;
target.Asunto = source.Asunto;
target.A_Quien_Denuncia = source.A_Quien_Denuncia;
target.DenunciadoDetalle = source.DenunciadoDetalle;
target.Descripcion_Denuncia = source.Descripcion_Denuncia;
target.Denunciado_Ante_Inst = source.Denunciado_Ante_Inst;
target.OrganismoDenunciado = source.OrganismoDenunciado;
target.SolicitaProteccion = source.SolicitaProteccion;
target.MedidasProteccionSolicitadas = source.MedidasProteccionSolicitadas;
target.Modalidad_Informacion = source.Modalidad_Informacion;
target.Lugar_Hechos = source.Lugar_Hechos;
target.Fecha_Hechos = source.Fecha_Hechos;
target.AutorizaRemision = source.AutorizaRemision;
target.PreferenciaRemision = source.PreferenciaRemision;
target.Notificacion_Preferencia = source.Notificacion_Preferencia;
target.Notificacion_Electronica = source.Notificacion_Electronica;
target.SeguimientoOnline = source.SeguimientoOnline;
target.NotificacionPostal = source.NotificacionPostal;
target.Correo_Electronico = source.Correo_Electronico;
target.Notificacion_Sms = source.Notificacion_Sms;
target.Condiciones = source.Condiciones;
target.Comments = source.Comments;
target.Telefono = source.Telefono;
target.Direccion = source.Direccion;
target.DireccionTipoVia = source.DireccionTipoVia;
target.DireccionNumero = source.DireccionNumero;
target.DireccionPiso = source.DireccionPiso;
target.DireccionPuerta = source.DireccionPuerta;
target.DireccionBloque = source.DireccionBloque;
target.DireccionEscalera = source.DireccionEscalera;
target.DireccionExtra = source.DireccionExtra;
target.Municipio = source.Municipio;
target.Provincia = source.Provincia;
target.CodigoPostal = source.CodigoPostal;
target.Pais = source.Pais;
target.CamposFormularioJson = source.CamposFormularioJson;
target.TextoOriginalReport = source.TextoOriginalReport;
}
private static bool IsSupportedAttachmentEntry(ZipArchiveEntry entry)
{
if (string.IsNullOrWhiteSpace(entry.Name))
{
return false;
}
var normalized = NormalizeEntryPath(entry.FullName);
return IsDirectChildOf(normalized, "files") || IsDirectChildOf(normalized, "files_attached_from_recipients");
}
private static bool IsDirectChildOf(string normalizedEntryPath, string rootFolder)
{
if (!normalizedEntryPath.StartsWith(rootFolder + "/", StringComparison.OrdinalIgnoreCase))
{
return false;
}
var remaining = normalizedEntryPath[(rootFolder.Length + 1)..];
return !remaining.Contains('/');
}
private static string NormalizeEntryPath(string path)
{
return path.Replace('\\', '/').Trim('/');
}
private static async Task<string> ReadEntryTextAsync(
ZipArchiveEntry entry,
CancellationToken cancellationToken)
{
var bytes = await ReadEntryBytesAsync(entry, cancellationToken);
return Encoding.UTF8.GetString(bytes);
}
private static async Task<byte[]> ReadEntryBytesAsync(
ZipArchiveEntry entry,
CancellationToken cancellationToken)
{
await using var entryStream = entry.Open();
using var memory = new MemoryStream();
await entryStream.CopyToAsync(memory, cancellationToken);
return memory.ToArray();
}
private static int TryReadIdFromReport(string reportText)
{
using var reader = new StringReader(reportText);
string? line;
while ((line = reader.ReadLine()) is not null)
{
line = line.Trim();
if (line.StartsWith("ID:", StringComparison.OrdinalIgnoreCase) &&
int.TryParse(line[3..].Trim(), out var id))
{
return id;
}
}
return 0;
}
}

View File

@@ -0,0 +1,460 @@
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 GestionaDenunciasAN.Services;
public sealed class GestionaDocumentWorkflowService
{
private readonly IHttpClientFactory _httpClientFactory;
private readonly IConfiguration _configuration;
private readonly ILogger<GestionaDocumentWorkflowService> _logger;
public GestionaDocumentWorkflowService(
IHttpClientFactory httpClientFactory,
IConfiguration configuration,
ILogger<GestionaDocumentWorkflowService> logger)
{
_httpClientFactory = httpClientFactory;
_configuration = configuration;
_logger = logger;
}
private string GestionaApiBase =>
(_configuration["Gestiona:ApiBase"] ?? "https://02.g3stiona.com").TrimEnd('/');
private string GestionaAccessToken =>
_configuration["Gestiona:AccessToken"]
?? throw new InvalidOperationException("Falta Gestiona:AccessToken en appsettings.");
private string? PreferredCircuitTemplateName =>
_configuration["Gestiona:PreferredCircuitTemplateName"];
public async Task<string> UploadDocumentAndReturnUrlAsync(string fileUrl, byte[] contentBytes, string fileName)
{
var fileUrlAbs = EnsureAbsoluteGestionaUrl(fileUrl, GestionaApiBase);
var documentsTargetUrl = ResolveDocumentsContainerUrl(fileUrlAbs);
var uploadUri = await CreateUploadAsync(contentBytes, fileName);
var metaPayload = new
{
type = "DIGITAL",
name = fileName,
description = "Documento de denuncia",
elaboration_state = "EE01",
metadata_language = "ES",
links = new[] { new { rel = "content", href = uploadUri } }
};
var metaJson = JsonSerializer.Serialize(metaPayload);
using var metaReq = new HttpRequestMessage(HttpMethod.Post, documentsTargetUrl);
metaReq.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", GestionaAccessToken);
metaReq.Headers.Accept.Clear();
metaReq.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.gestiona.file-document+json")
{
Parameters = { new NameValueHeaderValue("version", "4") }
});
metaReq.Content = new StringContent(metaJson, Encoding.UTF8);
metaReq.Content.Headers.ContentType =
MediaTypeHeaderValue.Parse("application/vnd.gestiona.file-document+json; version=4");
using var metaResp = await CreateRawHttp().SendAsync(metaReq);
var body = await metaResp.Content.ReadAsStringAsync();
if (!metaResp.IsSuccessStatusCode)
{
throw new InvalidOperationException(
$"UploadDocumentAndReturnUrlAsync: {(int)metaResp.StatusCode} {metaResp.StatusCode}\n{body}");
}
var location = metaResp.Headers.Location?.ToString();
if (!string.IsNullOrWhiteSpace(location))
{
return location!;
}
if (!string.IsNullOrWhiteSpace(body))
{
try
{
using var doc = JsonDocument.Parse(body);
if (doc.RootElement.TryGetProperty("links", out var links) && links.ValueKind == JsonValueKind.Array)
{
foreach (var link in links.EnumerateArray())
{
var rel = link.TryGetProperty("rel", out var pRel) ? pRel.GetString() : null;
var href = link.TryGetProperty("href", out var pHref) ? pHref.GetString() : null;
if (string.Equals(rel, "self", StringComparison.OrdinalIgnoreCase) &&
!string.IsNullOrWhiteSpace(href) &&
href!.Contains("/documents/", StringComparison.OrdinalIgnoreCase))
{
return href;
}
}
}
}
catch
{
// Fallback a busqueda por nombre.
}
}
var found = await BuscarDocumentoEnExpedientePorNombreAsync(documentsTargetUrl, fileName);
if (!string.IsNullOrWhiteSpace(found))
{
return found!;
}
throw new InvalidOperationException("No se pudo obtener la URL del documento creado en Gestiona.");
}
public async Task TramitarDocumentoAsync(string documentUrl, string assignedGroupHref, int? complaintId = null)
{
var docUrlAbs = EnsureAbsoluteGestionaUrl(documentUrl, GestionaApiBase);
var template = await ObtenerTemplateCircuitoFirmaAsync(docUrlAbs);
var payload = BuildCircuitPayloadFromTemplate(template, assignedGroupHref, complaintId);
var json = payload.ToJsonString(new JsonSerializerOptions(JsonSerializerDefaults.Web));
using var req = new HttpRequestMessage(HttpMethod.Post, $"{docUrlAbs.TrimEnd('/')}/circuit");
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");
using var resp = await CreateRawHttp().SendAsync(req);
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,
template.Name ?? "(sin nombre)",
template.Href,
(int)resp.StatusCode,
body);
throw new InvalidOperationException(
$"TramitarDocumentoAsync: {(int)resp.StatusCode} {resp.StatusCode}\n{body}");
}
_logger.LogInformation(
"Documento {DocumentUrl} enviado a circuito {TemplateName} ({TemplateHref}) para denuncia {ComplaintId}.",
docUrlAbs,
template.Name ?? "(sin nombre)",
template.Href,
complaintId);
}
private HttpClient CreateRawHttp() => _httpClientFactory.CreateClient();
private async Task<string> CreateUploadAsync(byte[] contentBytes, string fileName)
{
using var createReq = new HttpRequestMessage(HttpMethod.Post, $"{GestionaApiBase}/rest/uploads");
createReq.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", GestionaAccessToken);
createReq.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
using var createResp = await CreateRawHttp().SendAsync(createReq);
var createBody = await createResp.Content.ReadAsStringAsync();
if (!createResp.IsSuccessStatusCode)
{
throw new InvalidOperationException(
$"CreateUploadAsync (POST): {(int)createResp.StatusCode} {createResp.StatusCode}\n{createBody}");
}
var uploadUri = createResp.Headers.Location?.ToString()
?? throw new InvalidOperationException("No se devolvio Location en /rest/uploads");
using var putReq = new HttpRequestMessage(HttpMethod.Put, uploadUri);
putReq.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", GestionaAccessToken);
putReq.Headers.TryAddWithoutValidation("X-Gestiona-Upload-MD5", GetMd5Hex(contentBytes));
putReq.Headers.TryAddWithoutValidation("Slug", fileName);
putReq.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
putReq.Content = new ByteArrayContent(contentBytes);
putReq.Content.Headers.ContentType = new MediaTypeHeaderValue("application/pdf");
using var putResp = await CreateRawHttp().SendAsync(putReq);
var infoJson = await putResp.Content.ReadAsStringAsync();
if (!putResp.IsSuccessStatusCode)
{
throw new InvalidOperationException(
$"CreateUploadAsync (PUT): {(int)putResp.StatusCode} {putResp.StatusCode}\n{infoJson}");
}
using var infoDoc = JsonDocument.Parse(infoJson);
var status = infoDoc.RootElement.TryGetProperty("status", out var pStatus) ? pStatus.GetString() : null;
if (!string.Equals(status, "READY", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException($"Upload no READY: {status}");
}
return uploadUri;
}
private async Task<string?> BuscarDocumentoEnExpedientePorNombreAsync(string documentsTargetUrl, string fileName)
{
using var req = new HttpRequestMessage(HttpMethod.Get, documentsTargetUrl);
req.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", GestionaAccessToken);
req.Headers.TryAddWithoutValidation("Accept", "*/*");
using var resp = await CreateRawHttp().SendAsync(req);
var body = await resp.Content.ReadAsStringAsync();
if (!resp.IsSuccessStatusCode)
{
throw new InvalidOperationException(
$"BuscarDocumentoEnExpedientePorNombreAsync: {(int)resp.StatusCode} {resp.StatusCode}\n{body}");
}
using var doc = JsonDocument.Parse(body);
if (!doc.RootElement.TryGetProperty("content", out var content) || content.ValueKind != JsonValueKind.Array)
{
return null;
}
var items = content.EnumerateArray().ToList();
for (var idx = items.Count - 1; idx >= 0; idx--)
{
var item = items[idx];
var name = item.TryGetProperty("name", out var pName) ? pName.GetString() : null;
if (!string.Equals(name, fileName, StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (item.TryGetProperty("links", out var links) && links.ValueKind == JsonValueKind.Array)
{
foreach (var link in links.EnumerateArray())
{
var rel = link.TryGetProperty("rel", out var pRel) ? pRel.GetString() : null;
var href = link.TryGetProperty("href", out var pHref) ? pHref.GetString() : null;
if (string.Equals(rel, "self", StringComparison.OrdinalIgnoreCase) &&
!string.IsNullOrWhiteSpace(href) &&
href!.Contains("/documents/", StringComparison.OrdinalIgnoreCase))
{
return href;
}
}
}
}
return null;
}
private static string ResolveDocumentsContainerUrl(string url)
{
var normalized = url.TrimEnd('/');
return normalized.Contains("/documents-and-folders", StringComparison.OrdinalIgnoreCase)
? normalized
: $"{normalized}/documents-and-folders";
}
private async Task<CircuitTemplateCandidate> ObtenerTemplateCircuitoFirmaAsync(string documentUrl)
{
using var req = new HttpRequestMessage(HttpMethod.Get, $"{documentUrl.TrimEnd('/')}/circuit/templates");
req.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", GestionaAccessToken);
req.Headers.TryAddWithoutValidation("Accept", "application/vnd.gestiona.circuits.templates-filedoc-page");
using var resp = await CreateRawHttp().SendAsync(req);
var body = await resp.Content.ReadAsStringAsync();
if (!resp.IsSuccessStatusCode)
{
throw new InvalidOperationException(
$"ObtenerTemplateCircuitoFirmaAsync: {(int)resp.StatusCode} {resp.StatusCode}\n{body}");
}
using var doc = JsonDocument.Parse(body);
if (!doc.RootElement.TryGetProperty("content", out var content) || content.ValueKind != JsonValueKind.Array)
{
throw new InvalidOperationException("No se ha podido leer el listado de plantillas de circuito.");
}
var templates = new List<CircuitTemplateCandidate>();
foreach (var item in content.EnumerateArray())
{
var name = item.TryGetProperty("name", out var pName) ? pName.GetString() : null;
var href = GetSelfHref(item);
if (string.IsNullOrWhiteSpace(href))
{
continue;
}
var payload = JsonNode.Parse(item.GetRawText()) as JsonObject ?? new JsonObject();
templates.Add(new CircuitTemplateCandidate(
Name: name,
Href: href!,
Payload: payload,
SignersCount: CountArrayItems(payload, "signers"),
BlockEdit: GetBoolean(payload, "block_edit")));
}
if (templates.Count == 0)
{
throw new InvalidOperationException("No hay plantillas de circuito disponibles para el documento.");
}
_logger.LogInformation(
"Plantillas de circuito para {DocumentUrl}: {Templates}",
documentUrl,
string.Join(
" | ",
templates.Select(template =>
$"{template.Name ?? "(sin nombre)"} [firmantes={template.SignersCount}, candado={template.BlockEdit}]")));
if (!string.IsNullOrWhiteSpace(PreferredCircuitTemplateName))
{
var configuredExact = templates.FirstOrDefault(template =>
string.Equals(template.Name, PreferredCircuitTemplateName, StringComparison.OrdinalIgnoreCase));
if (configuredExact is not null)
{
return configuredExact;
}
var configuredContains = templates.FirstOrDefault(template =>
!string.IsNullOrWhiteSpace(template.Name) &&
template.Name.Contains(PreferredCircuitTemplateName, StringComparison.OrdinalIgnoreCase));
if (configuredContains is not null)
{
return configuredContains;
}
}
string[] preferredNames =
[
"CT-Actualizacion de denuncia",
"CT-Actualizaci\u00f3n de denuncia",
"Firma automatizada",
"Firma Sello de \u00D3rgano",
"Firma Sello de Organo"
];
var preferredTemplate = templates.FirstOrDefault(template =>
preferredNames.Any(preferred =>
string.Equals(template.Name, preferred, StringComparison.OrdinalIgnoreCase)));
if (preferredTemplate is not null)
{
return preferredTemplate;
}
var templatesWithSigners = templates
.Where(template => template.SignersCount > 0)
.OrderByDescending(template => template.BlockEdit)
.ThenByDescending(template => template.SignersCount)
.ToList();
if (templatesWithSigners.Count > 0)
{
return templatesWithSigners[0];
}
return templates[0];
}
private static JsonObject BuildCircuitPayloadFromTemplate(
CircuitTemplateCandidate template,
string assignedGroupHref,
int? complaintId)
{
_ = assignedGroupHref;
_ = complaintId;
var payload = (JsonObject)template.Payload.DeepClone();
EnsureTemplateSelfLink(payload, template.Href);
payload.Remove("assigneds_can_use");
return payload;
}
private static void EnsureTemplateSelfLink(JsonObject payload, string templateSelfHref)
{
if (payload["links"] is not JsonArray links)
{
links = new JsonArray();
payload["links"] = links;
}
foreach (var node in links)
{
if (node is not JsonObject link)
{
continue;
}
var rel = link["rel"]?.GetValue<string>();
var href = link["href"]?.GetValue<string>();
if (string.Equals(rel, "self", StringComparison.OrdinalIgnoreCase) &&
!string.IsNullOrWhiteSpace(href))
{
return;
}
}
links.Add(JsonSerializer.SerializeToNode(new { rel = "self", href = templateSelfHref }));
}
private static int CountArrayItems(JsonObject payload, string propertyName)
{
return payload[propertyName] is JsonArray array ? array.Count : 0;
}
private static bool GetBoolean(JsonObject payload, string propertyName)
{
return payload[propertyName]?.GetValue<bool?>() ?? false;
}
private static string? GetSelfHref(JsonElement item)
{
if (!item.TryGetProperty("links", out var links) || links.ValueKind != JsonValueKind.Array)
{
return null;
}
foreach (var link in links.EnumerateArray())
{
var rel = link.TryGetProperty("rel", out var pRel) ? pRel.GetString() : null;
var href = link.TryGetProperty("href", out var pHref) ? pHref.GetString() : null;
if (string.Equals(rel, "self", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(href))
{
return href;
}
}
return null;
}
private static string EnsureAbsoluteGestionaUrl(string url, string apiBase)
{
if (string.IsNullOrWhiteSpace(url))
{
throw new InvalidOperationException("URL vacia.");
}
if (Uri.TryCreate(url, UriKind.Absolute, out _))
{
return url;
}
if (!url.StartsWith('/'))
{
url = "/" + url;
}
return apiBase.TrimEnd('/') + url;
}
private static string GetMd5Hex(byte[] bytes)
{
using var md5 = MD5.Create();
var hash = md5.ComputeHash(bytes);
return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
}
private sealed record CircuitTemplateCandidate(
string? Name,
string Href,
JsonObject Payload,
int SignersCount,
bool BlockEdit);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,662 @@
using System.Globalization;
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using GestionaDenunciasAN.Configuration;
using GestionaDenunciasAN.Models;
using Konscious.Security.Cryptography;
using Microsoft.Extensions.Options;
namespace GestionaDenunciasAN.Services;
public sealed class GlobalLeaksClient
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
private readonly HttpClient _httpClient;
private readonly ILogger<GlobalLeaksClient> _logger;
private readonly GlobalLeaksOptions _options;
public GlobalLeaksClient(IOptions<GlobalLeaksOptions> options, ILogger<GlobalLeaksClient> logger)
{
_options = options.Value;
_logger = logger;
_httpClient = new HttpClient
{
BaseAddress = new Uri(_options.BaseUrl.TrimEnd('/')),
Timeout = TimeSpan.FromSeconds(_options.TimeoutSeconds),
};
}
public async Task<GlSession> LoginAsync(
string username,
string password,
string authcode,
CancellationToken cancellationToken)
{
using var tokenRequest = CreateRequest(HttpMethod.Post, "/api/auth/token");
using var tokenResponse = await _httpClient.SendAsync(tokenRequest, cancellationToken);
await EnsureSuccessOrThrowAsync(tokenResponse, "/api/auth/token", cancellationToken);
var tokenData = await tokenResponse.Content.ReadFromJsonAsync<TokenResponse>(JsonOptions, cancellationToken)
?? throw new GlobalLeaksValidationException("No se pudo obtener el reto criptográfico.", 502);
var tokenAnswer = SolveProofOfWork(tokenData.Id, tokenData.Salt);
using var typeRequest = CreateRequest(HttpMethod.Post, "/api/auth/type");
typeRequest.Content = CreateJsonContent(new { username });
using var typeResponse = await _httpClient.SendAsync(typeRequest, cancellationToken);
await EnsureSuccessOrThrowAsync(typeResponse, "/api/auth/type", cancellationToken);
var authType = await typeResponse.Content.ReadFromJsonAsync<AuthTypeResponse>(JsonOptions, cancellationToken)
?? throw new GlobalLeaksValidationException("No se pudo obtener el tipo de autenticación.", 502);
var finalPassword = authType.Type == "key"
? DerivePassword(password, authType.Salt)
: password;
using var authRequest = CreateRequest(HttpMethod.Post, "/api/auth/authentication");
authRequest.Content = CreateJsonContent(new
{
tid = 1,
username,
password = finalPassword,
authcode,
});
authRequest.Headers.Add("X-Token", tokenAnswer);
using var authResponse = await _httpClient.SendAsync(authRequest, 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),
_ => 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}", session.Username, session.Role ?? "(sin rol)");
return session;
}
public async Task<IReadOnlyList<ContextDto>> GetContextsAsync(
string sessionId,
CancellationToken cancellationToken)
{
using var request = CreateRequest(HttpMethod.Get, "/api/public");
using var response = await SendGlRequestAsync(request, cancellationToken);
var body = await response.Content.ReadAsStringAsync(cancellationToken);
var contexts = ParseContexts(body);
_logger.LogInformation("GlobalLeaks /api/public devolvió {Count} contextos", contexts.Count);
return contexts;
}
public async Task<IReadOnlyList<ReportDto>> GetReportsAsync(
string sessionId,
string? filter,
string? dateFrom,
string? dateTo,
CancellationToken cancellationToken)
{
filter ??= "all";
using var reportsRequest = CreateAuthenticatedRequest(HttpMethod.Get, "/api/recipient/rtips", sessionId);
using var reportsResponse = await SendGlRequestAsync(reportsRequest, cancellationToken);
var body = await reportsResponse.Content.ReadAsStringAsync(cancellationToken);
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);
IEnumerable<RawReport> filtered = tips;
filtered = filter switch
{
"new" => filtered.Where(t => string.IsNullOrWhiteSpace(t.AccessDate) || t.Status == "new"),
"updated" or "updated_citizen" or "updated_receiver" => filtered.Where(t => t.Updated),
_ => filtered,
};
if (!string.IsNullOrWhiteSpace(dateFrom))
{
if (!DateOnly.TryParseExact(dateFrom, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var from))
{
throw new GlobalLeaksValidationException("Formato de fecha inválido para date_from (use YYYY-MM-DD)");
}
filtered = filtered.Where(t =>
{
var date = ParseDate(t.CreationDate);
return date is not null && DateOnly.FromDateTime(date.Value.DateTime) >= from;
});
}
if (!string.IsNullOrWhiteSpace(dateTo))
{
if (!DateOnly.TryParseExact(dateTo, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var to))
{
throw new GlobalLeaksValidationException("Formato de fecha inválido para date_to (use YYYY-MM-DD)");
}
filtered = filtered.Where(t =>
{
var date = ParseDate(t.CreationDate);
return date is not null && DateOnly.FromDateTime(date.Value.DateTime) <= to;
});
}
return filtered
.OrderByDescending(t => t.Progressive ?? 0)
.Select(t => new ReportDto
{
Id = t.Id,
Progressive = t.Progressive,
ContextId = t.ContextId,
ContextName = t.ContextId is not null && contextNames.TryGetValue(t.ContextId, out var name)
? name
: t.ContextId,
CreationDate = t.CreationDate,
UpdateDate = t.UpdateDate,
ExpirationDate = t.ExpirationDate,
ReminderDate = t.ReminderDate,
AccessDate = t.AccessDate,
LastAccess = t.LastAccess,
Status = t.Status,
Updated = t.Updated,
Label = t.Label,
})
.ToArray();
}
public async Task<ReportSummaryDto> GetReportSummaryAsync(
string sessionId,
string reportId,
CancellationToken cancellationToken)
{
ValidateUuid(reportId);
var reports = await GetReportsAsync(sessionId, "all", null, null, cancellationToken);
var tip = reports.FirstOrDefault(report => report.Id == reportId)
?? throw new GlobalLeaksValidationException("Tip no encontrado", StatusCodes.Status404NotFound);
var created = ParseDate(tip.CreationDate);
var updated = ParseDate(tip.UpdateDate);
var lastAccess = ParseDate(tip.LastAccess);
return new ReportSummaryDto
{
Id = tip.Id,
Progressive = tip.Progressive,
CreationDate = tip.CreationDate,
UpdateDate = tip.UpdateDate,
LastAccess = tip.LastAccess,
AccessDate = tip.AccessDate,
DaysSinceCreation = created is not null && updated is not null
? (updated.Value - created.Value).Days
: null,
DaysSinceAccess = lastAccess is not null && updated is not null
? (updated.Value - lastAccess.Value).Days
: null,
Status = tip.Status,
Label = tip.Label,
};
}
public async Task<FileDownloadResult> DownloadReportZipAsync(
string sessionId,
string reportId,
CancellationToken cancellationToken)
{
ValidateUuid(reportId);
using var request = CreateAuthenticatedRequest(
HttpMethod.Get,
$"/api/recipient/rtips/{reportId}/export",
sessionId);
using var response = await SendGlRequestAsync(
request,
cancellationToken,
HttpCompletionOption.ResponseHeadersRead);
var content = await response.Content.ReadAsByteArrayAsync(cancellationToken);
if (content.Length > _options.MaxDownloadBytes)
{
throw new GlobalLeaksValidationException("El fichero supera el límite de 500 MB.", 413);
}
var fileName = SanitizeFileName(
ExtractFileName(response.Content.Headers.ContentDisposition),
$"report-{reportId}.zip");
return new FileDownloadResult(content, fileName);
}
public async Task<FileDownloadResult> ExportReportJsonAsync(
string sessionId,
string reportId,
CancellationToken cancellationToken)
{
ValidateUuid(reportId);
using var request = CreateAuthenticatedRequest(HttpMethod.Get, $"/api/recipient/rtips/{reportId}", sessionId);
using var response = await SendGlRequestAsync(request, cancellationToken);
var contentType = response.Content.Headers.ContentType?.MediaType ?? string.Empty;
var content = await response.Content.ReadAsByteArrayAsync(cancellationToken);
if (!contentType.Contains("json", StringComparison.OrdinalIgnoreCase) || content.Length == 0)
{
throw new GlobalLeaksValidationException(
"GlobalLeaks no devolvió JSON (tip sin clave de descifrado).",
422);
}
using var document = JsonDocument.Parse(content);
var progressive = document.RootElement.TryGetProperty("progressive", out var value) && value.TryGetInt32(out var number)
? number.ToString(CultureInfo.InvariantCulture)
: reportId[..8];
return new FileDownloadResult(content, $"report-{progressive}.json");
}
private async Task<HttpResponseMessage> SendGlRequestAsync(
HttpRequestMessage request,
CancellationToken cancellationToken,
HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead)
{
var response = await _httpClient.SendAsync(request, completionOption, cancellationToken);
if ((int)response.StatusCode == 412)
{
response.Dispose();
throw new GlobalLeaksSessionExpiredException();
}
if (!response.IsSuccessStatusCode)
{
var message = $"Error de GlobalLeaks (código {(int)response.StatusCode}).";
response.Dispose();
throw new GlobalLeaksValidationException(message, (int)response.StatusCode);
}
return response;
}
private static HttpRequestMessage CreateRequest(HttpMethod method, string path)
{
var request = new HttpRequestMessage(method, path)
{
Version = HttpVersion.Version11,
VersionPolicy = HttpVersionPolicy.RequestVersionOrLower,
};
request.Headers.TryAddWithoutValidation("Accept", "*/*");
request.Headers.TryAddWithoutValidation("User-Agent", "python-requests/2.32.5");
return request;
}
private static HttpRequestMessage CreateAuthenticatedRequest(HttpMethod method, string path, string sessionId)
{
var request = CreateRequest(method, path);
request.Headers.Add("X-Session", sessionId);
return request;
}
private static ByteArrayContent CreateJsonContent<T>(T payload)
{
var bytes = JsonSerializer.SerializeToUtf8Bytes(payload, JsonOptions);
var content = new ByteArrayContent(bytes);
content.Headers.ContentType = new MediaTypeHeaderValue("application/json");
return content;
}
private static string SolveProofOfWork(string tokenId, string tokenSalt)
{
var idBytes = Encoding.UTF8.GetBytes(tokenId);
var saltBytes = Convert.FromBase64String(tokenSalt).Take(16).ToArray();
var n = 0;
while (true)
{
var input = idBytes.Concat(Encoding.UTF8.GetBytes(n.ToString(CultureInfo.InvariantCulture))).ToArray();
var hash = ComputeArgon2(input, saltBytes, iterations: 1, memoryKb: 1024);
if (hash[^1] == 0)
{
return $"{tokenId}:{n}";
}
n++;
}
}
private static string DerivePassword(string password, string salt)
{
var saltBytes = Convert.FromBase64String(salt).Take(16).ToArray();
var hash = ComputeArgon2(Encoding.UTF8.GetBytes(password), saltBytes, iterations: 16, memoryKb: 131072);
return Convert.ToBase64String(hash);
}
private static byte[] ComputeArgon2(byte[] input, byte[] salt, int iterations, int memoryKb)
{
var argon2 = new Argon2id(input)
{
Salt = salt,
DegreeOfParallelism = 1,
Iterations = iterations,
MemorySize = memoryKb,
};
return argon2.GetBytes(32);
}
private static void ValidateUuid(string value)
{
if (!Guid.TryParse(value, out _))
{
throw new GlobalLeaksValidationException("ID de denuncia inválido");
}
}
private static DateTimeOffset? ParseDate(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
return DateTimeOffset.TryParse(value.Replace("Z", "+00:00", StringComparison.Ordinal), out var parsed)
? parsed
: null;
}
private static string ExtractName(JsonElement? name, string fallback)
{
if (name is null)
{
return fallback;
}
if (name.Value.ValueKind == JsonValueKind.String)
{
return name.Value.GetString() ?? fallback;
}
if (name.Value.ValueKind == JsonValueKind.Object)
{
if (name.Value.TryGetProperty("es", out var es) && es.GetString() is { Length: > 0 } esName)
{
return esName;
}
if (name.Value.TryGetProperty("en", out var en) && en.GetString() is { Length: > 0 } enName)
{
return enName;
}
foreach (var property in name.Value.EnumerateObject())
{
if (!string.IsNullOrWhiteSpace(property.Value.GetString()))
{
return property.Value.GetString()!;
}
}
}
return fallback;
}
private static List<ContextDto> ParseContexts(string body)
{
using var document = JsonDocument.Parse(body);
var contextsElement = document.RootElement;
if (contextsElement.ValueKind == JsonValueKind.Object &&
contextsElement.TryGetProperty("contexts", out var contextsProperty))
{
contextsElement = contextsProperty;
}
if (contextsElement.ValueKind != JsonValueKind.Array)
{
return [];
}
var contexts = new List<ContextDto>();
foreach (var item in contextsElement.EnumerateArray())
{
var id = GetString(item, "id") ?? string.Empty;
if (string.IsNullOrWhiteSpace(id))
{
continue;
}
JsonElement? name = null;
if (item.TryGetProperty("name", out var nameProp))
{
name = nameProp;
}
contexts.Add(new ContextDto(id, ExtractName(name, id)));
}
return contexts;
}
private static List<RawReport> ParseReports(string body)
{
using var document = JsonDocument.Parse(body);
var root = document.RootElement;
var array = root.ValueKind == JsonValueKind.Array
? root
: FindArrayProperty(root, "rtips", "tips", "items", "data", "entries", "results");
if (array is null || array.Value.ValueKind != JsonValueKind.Array)
{
return [];
}
var reports = new List<RawReport>();
foreach (var item in array.Value.EnumerateArray())
{
var id = GetString(item, "id") ?? string.Empty;
if (string.IsNullOrWhiteSpace(id))
{
continue;
}
reports.Add(new RawReport
{
Id = id,
Progressive = GetInt32(item, "progressive"),
ContextId = GetString(item, "context_id", "contextId"),
CreationDate = GetString(item, "creation_date", "creationDate"),
UpdateDate = GetString(item, "update_date", "updateDate"),
ExpirationDate = GetString(item, "expiration_date", "expirationDate"),
ReminderDate = GetString(item, "reminder_date", "reminderDate"),
AccessDate = GetString(item, "access_date", "accessDate"),
LastAccess = GetString(item, "last_access", "lastAccess"),
Status = GetString(item, "status"),
Updated = GetBool(item, "updated"),
Label = GetString(item, "label"),
});
}
return reports;
}
private static JsonElement? FindArrayProperty(JsonElement element, params string[] names)
{
if (element.ValueKind != JsonValueKind.Object)
{
return null;
}
foreach (var name in names)
{
if (element.TryGetProperty(name, out var property) && property.ValueKind == JsonValueKind.Array)
{
return property;
}
}
return null;
}
private static string? GetString(JsonElement element, params string[] names)
{
foreach (var name in names)
{
if (element.TryGetProperty(name, out var property))
{
return property.ValueKind switch
{
JsonValueKind.String => property.GetString(),
JsonValueKind.Number => property.GetRawText(),
_ => null,
};
}
}
return null;
}
private static int? GetInt32(JsonElement element, params string[] names)
{
foreach (var name in names)
{
if (element.TryGetProperty(name, out var property))
{
if (property.ValueKind == JsonValueKind.Number && property.TryGetInt32(out var value))
{
return value;
}
if (property.ValueKind == JsonValueKind.String &&
int.TryParse(property.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out value))
{
return value;
}
}
}
return null;
}
private static bool GetBool(JsonElement element, params string[] names)
{
foreach (var name in names)
{
if (element.TryGetProperty(name, out var property))
{
if (property.ValueKind is JsonValueKind.True or JsonValueKind.False)
{
return property.GetBoolean();
}
if (property.ValueKind == JsonValueKind.String &&
bool.TryParse(property.GetString(), out var value))
{
return value;
}
}
}
return false;
}
private static string SanitizeFileName(string? name, string fallback)
{
if (string.IsNullOrWhiteSpace(name))
{
return fallback;
}
var sanitized = Regex.Replace(name, "[\\r\\n\\0\"\\\\]", "_").Trim();
return string.IsNullOrWhiteSpace(sanitized)
? fallback
: sanitized[..Math.Min(200, sanitized.Length)];
}
private static string? ExtractFileName(ContentDispositionHeaderValue? contentDisposition)
=> contentDisposition?.FileNameStar ?? contentDisposition?.FileName?.Trim('"');
private static GlSession ParseAuthSession(string body, string fallbackUsername)
{
using var document = JsonDocument.Parse(body);
var root = document.RootElement;
var id = GetString(root, "id")
?? throw new GlobalLeaksValidationException("GlobalLeaks no devolvió una sesión válida.", 502);
var username = GetString(root, "username", "name");
if (string.IsNullOrWhiteSpace(username))
{
username = fallbackUsername.Trim();
}
var role = GetString(root, "role", "user_role", "userRole");
return new GlSession(id, username, role);
}
private static async Task EnsureSuccessOrThrowAsync(
HttpResponseMessage response,
string endpoint,
CancellationToken cancellationToken)
{
if (response.IsSuccessStatusCode)
{
return;
}
var body = await ReadBodySafeAsync(response, cancellationToken);
throw new GlobalLeaksValidationException(
string.IsNullOrWhiteSpace(body)
? $"Error en {endpoint} (código {(int)response.StatusCode})."
: $"Error en {endpoint} (código {(int)response.StatusCode}): {body}",
(int)response.StatusCode);
}
private static async Task<string> ReadBodySafeAsync(HttpResponseMessage response, CancellationToken cancellationToken)
{
try
{
return (await response.Content.ReadAsStringAsync(cancellationToken)).Trim();
}
catch
{
return string.Empty;
}
}
private sealed record TokenResponse(string Id, string Salt);
private sealed record AuthTypeResponse(string Type, string Salt);
private sealed record RawReport
{
public string Id { get; init; } = string.Empty;
public int? Progressive { get; init; }
public string? ContextId { get; init; }
public string? CreationDate { get; init; }
public string? UpdateDate { get; init; }
public string? ExpirationDate { get; init; }
public string? ReminderDate { get; init; }
public string? AccessDate { get; init; }
public string? LastAccess { get; init; }
public string? Status { get; init; }
public bool Updated { get; init; }
public string? Label { get; init; }
}
}

View File

@@ -0,0 +1,8 @@
namespace GestionaDenunciasAN.Services;
public sealed class GlobalLeaksValidationException(string message, int statusCode = 400) : Exception(message)
{
public int StatusCode { get; } = statusCode;
}
public sealed class GlobalLeaksSessionExpiredException() : Exception("GlobaLeaks session expired.");

View File

@@ -0,0 +1,150 @@
using System.Text;
using System.Text.Json;
using System.Security.Cryptography;
using GestionaDenunciasAN.Models;
using Microsoft.AspNetCore.DataProtection;
namespace GestionaDenunciasAN.Services;
public sealed class GlobalLeaksSessionStore
{
private const string RootPath = @"C:\ZipsDenuncias\.gl-auth";
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = false,
};
private readonly SemaphoreSlim _gate = new(1, 1);
private readonly IDataProtector _protector;
public GlobalLeaksSessionStore(IDataProtectionProvider dataProtectionProvider)
{
_protector = dataProtectionProvider.CreateProtector("GestionaDenunciasAN.GlobalLeaksSessionStore");
}
public async Task<GlobalLeaksStoredSession?> GetAsync(string username, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(username))
{
return null;
}
var path = GetFilePath(username);
if (!File.Exists(path))
{
return null;
}
await _gate.WaitAsync(cancellationToken);
try
{
var protectedBytes = await File.ReadAllBytesAsync(path, cancellationToken);
var protectedBase64 = Encoding.UTF8.GetString(protectedBytes);
var json = _protector.Unprotect(protectedBase64);
return JsonSerializer.Deserialize<GlobalLeaksStoredSession>(json, JsonOptions);
}
finally
{
_gate.Release();
}
}
public async Task SaveAsync(
string username,
string password,
string sessionId,
string? role,
CancellationToken cancellationToken = default)
{
var data = new GlobalLeaksStoredSession
{
Username = username,
Password = password,
SessionId = sessionId,
Role = role,
UpdatedAt = DateTimeOffset.UtcNow,
};
await WriteAsync(data, cancellationToken);
}
public async Task UpdateSessionAsync(
string username,
string sessionId,
string? role,
CancellationToken cancellationToken = default)
{
var current = await GetAsync(username, cancellationToken)
?? throw new InvalidOperationException("No hay credenciales guardadas para este usuario.");
current.SessionId = sessionId;
current.Role = role;
current.UpdatedAt = DateTimeOffset.UtcNow;
await WriteAsync(current, cancellationToken);
}
public async Task ClearSessionAsync(string username, CancellationToken cancellationToken = default)
{
var current = await GetAsync(username, cancellationToken);
if (current is null)
{
return;
}
current.SessionId = null;
current.UpdatedAt = DateTimeOffset.UtcNow;
await WriteAsync(current, cancellationToken);
}
public async Task DeleteAsync(string username, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(username))
{
return;
}
var path = GetFilePath(username);
await _gate.WaitAsync(cancellationToken);
try
{
if (File.Exists(path))
{
File.Delete(path);
}
}
finally
{
_gate.Release();
}
}
private async Task WriteAsync(GlobalLeaksStoredSession data, CancellationToken cancellationToken)
{
Directory.CreateDirectory(RootPath);
var path = GetFilePath(data.Username);
var json = JsonSerializer.Serialize(data, JsonOptions);
var protectedValue = _protector.Protect(json);
var protectedBytes = Encoding.UTF8.GetBytes(protectedValue);
await _gate.WaitAsync(cancellationToken);
try
{
await File.WriteAllBytesAsync(path, protectedBytes, cancellationToken);
}
finally
{
_gate.Release();
}
}
private static string GetFilePath(string username)
{
Directory.CreateDirectory(RootPath);
var normalized = username.Trim().ToLowerInvariant();
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(normalized));
return Path.Combine(RootPath, $"{Convert.ToHexString(hash).ToLowerInvariant()}.bin");
}
}

View File

@@ -0,0 +1,19 @@
using GestionaDenunciasAN.Models;
namespace GestionaDenunciasAN.Services;
public interface IDenunciaStore
{
Task EnsureSchemaAsync(CancellationToken cancellationToken = default);
Task<List<DenunciasGestiona>> GetAllDenunciasAsync(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);
Task UpsertDenunciaAsync(DenunciasGestiona denuncia, CancellationToken cancellationToken = default);
Task UpsertFicherosAsync(IEnumerable<FicherosDenuncias> ficheros, CancellationToken cancellationToken = default);
Task MarkFicherosAsUploadedAsync(
int denunciaId,
IEnumerable<string> fileNames,
DateTime uploadedAtUtc,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,131 @@
using GestionaDenunciasAN.Models;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace GestionaDenunciasAN.Services
{
public interface IGestionaService
{
// =========================
// Expedientes (files)
// =========================
/// <summary>
/// Crea un expediente para el procedimiento indicado y devuelve la URL del recurso 'file'.
/// </summary>
Task<string> CreateFileAsync(
Guid procedureId,
string subject,
string documentSeries,
string siaCode
);
/// <summary>
/// Abre el expediente (lo pone en OPEN_EDITABLE), asigna título, clasificación y lo vincula al grupo indicado.
/// </summary>
Task OpenFileAsync(
string fileUrl,
Guid managementUnitGroupId,
Guid assignedGroupId,
bool confidential,
string freeTitle,
string siaCode
);
// =========================
// Documentos
// =========================
/// <summary>
/// Crea un recurso de upload, sube bytes y devuelve la URI de upload.
/// </summary>
Task<string> CreateUploadAsync(
byte[] contentBytes,
string fileName
);
/// <summary>
/// Crea el documento (metadata) y sube el contenido PDF a la raíz o a una carpeta.
/// </summary>
Task UploadDocumentAsync(
string fileUrl,
byte[] contentBytes,
string fileName
);
/// <summary>
/// Crea una carpeta de nombre 'folderName' en la raíz del expediente y devuelve su GUID.
/// </summary>
Task<Guid> CreateFolderAsync(
string fileUrl,
string folderName
);
// =========================
// Terceros
// =========================
/// <summary>
/// Busca un tercero por NIF. Devuelve su ID y href si existe, null si no existe.
/// </summary>
Task<(string Id, string SelfHref)> BuscarTerceroPorNifAsync(string nif);
/// <summary>
/// Crea un tercero en Gestiona y devuelve su ID y href.
/// </summary>
Task<(string Id, string SelfHref)> CrearTerceroAsync(ThirdPartyIdentityData thirdParty);
/// <summary>
/// Obtiene la lista de terceros ya enlazados a un expediente.
/// </summary>
Task<HashSet<string>> ObtenerTercerosEnlazadosAsync(string fileUrl);
/// <summary>
/// Enlaza un tercero ya existente (por su href) a un expediente.
/// </summary>
Task EnlazarTerceroExistenteAsync(string fileUrl, string thirdSelfHref);
/// <summary>
/// Usa el NIF tal cual viene.
/// Si es anónimo o vacío → no crea ni enlaza.
/// Si no existe, lo crea.
/// Si no está enlazado al expediente, lo enlaza.
/// </summary>
Task AsegurarTerceroYEnlazarAsync(string fileUrl, ThirdPartyIdentityData thirdParty);
// =========================
// CONSULTAS OPERATIVAS
// =========================
/// <summary>
/// Devuelve el JSON crudo del listado operativo de expedientes, sin recorrer histórico paginado.
/// </summary>
Task<string> ListarExpedientesJsonAsyncBasico(int maxPages = 1);
/// <summary>
/// Busca directamente un expediente cuyo asunto sea "Denuncia {idDenuncia}-CD".
/// Devuelve URL, número de expediente y título si lo encuentra; null si no.
/// </summary>
Task<GestionaExpedienteInfo?> BuscarExpedientePorIdEnAsuntoAsync(int idDenuncia);
/// <summary>
/// Obtiene los metadatos visibles de un expediente concreto.
/// </summary>
Task<GestionaExpedienteInfo?> ObtenerExpedienteAsync(string fileUrl);
Task<List<ExpedienteTerceroDto>> ObtenerExpedientesPorTerceroAsync(
string nif,
DateTimeOffset? desde = null,
DateTimeOffset? hasta = null,
int maxPages = 1,
int maxResults = 30,
int maxParallel = 6
);
}
}

View File

@@ -0,0 +1,17 @@
using GestionaDenunciasAN.Models;
namespace GestionaDenunciasAN.Services;
public interface IInboxTrackingService
{
Task<InboxUserState> GetUserStateAsync(string username, CancellationToken cancellationToken = default);
Task<IReadOnlyList<ReportDto>> RegisterSnapshotAsync(
string username,
IEnumerable<ReportDto> reports,
CancellationToken cancellationToken = default);
Task MarkReportImportedAsync(
string username,
ReportDto report,
int? complaintId,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,522 @@
using System.Globalization;
using GestionaDenunciasAN.Configuration;
using GestionaDenunciasAN.Models;
using Microsoft.Extensions.Options;
using MySqlConnector;
namespace GestionaDenunciasAN.Services;
public sealed class InboxTrackingService : IInboxTrackingService
{
private readonly ComplaintStorageOptions _options;
private readonly IDenunciaStore _denunciaStore;
public InboxTrackingService(
IOptions<ComplaintStorageOptions> options,
IDenunciaStore denunciaStore)
{
_options = options.Value;
_denunciaStore = denunciaStore;
}
public async Task<InboxUserState> GetUserStateAsync(string username, CancellationToken cancellationToken = default)
{
await _denunciaStore.EnsureSchemaAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(username))
{
return new InboxUserState();
}
await using var connection = await OpenConnectionAsync(cancellationToken);
var userId = await EnsureUserAsync(connection, username, cancellationToken);
const string sql = """
SELECT
username,
last_successful_download_at_utc,
last_downloaded_report_moment_utc
FROM app_users
WHERE id = @userId;
""";
await using var command = new MySqlCommand(sql, connection);
command.Parameters.AddWithValue("@userId", userId);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
if (!await reader.ReadAsync(cancellationToken))
{
return new InboxUserState { Username = username };
}
return new InboxUserState
{
Username = reader.GetString(reader.GetOrdinal("username")),
LastSuccessfulDownloadAtUtc = GetDateTimeOffset(reader, "last_successful_download_at_utc"),
LastDownloadedReportMomentUtc = GetDateTimeOffset(reader, "last_downloaded_report_moment_utc"),
};
}
public async Task<IReadOnlyList<ReportDto>> RegisterSnapshotAsync(
string username,
IEnumerable<ReportDto> reports,
CancellationToken cancellationToken = default)
{
await _denunciaStore.EnsureSchemaAsync(cancellationToken);
var reportList = reports.ToList();
if (string.IsNullOrWhiteSpace(username) || reportList.Count == 0)
{
return reportList;
}
await using var connection = await OpenConnectionAsync(cancellationToken);
var userId = await EnsureUserAsync(connection, username, cancellationToken);
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
try
{
foreach (var report in reportList)
{
await UpsertInboxReportAsync(connection, transaction, report, cancellationToken);
await UpsertUserSnapshotAsync(connection, transaction, userId, report.Id, cancellationToken);
}
await transaction.CommitAsync(cancellationToken);
}
catch
{
await transaction.RollbackAsync(cancellationToken);
throw;
}
var metadata = await LoadMetadataAsync(connection, userId, reportList.Select(r => r.Id).ToList(), cancellationToken);
return reportList
.Select(report =>
{
metadata.TryGetValue(report.Id, out var meta);
return report with
{
DownloadedByCurrentUser = meta?.DownloadedByCurrentUser ?? false,
DownloadedByAnotherUser = meta?.DownloadedByAnotherUser ?? false,
LastDownloadedByUsername = meta?.LastDownloadedByUsername,
LastDownloadedAt = meta?.LastDownloadedAtUtc?.ToString("O", CultureInfo.InvariantCulture),
AlreadyImported = meta?.AlreadyImported ?? false,
AlreadyInGestiona = meta?.AlreadyInGestiona ?? false,
TrackingNote = BuildTrackingNote(meta)
};
})
.ToArray();
}
public async Task MarkReportImportedAsync(
string username,
ReportDto report,
int? complaintId,
CancellationToken cancellationToken = default)
{
await _denunciaStore.EnsureSchemaAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(report.Id))
{
return;
}
var reportMoment = ResolveReportMoment(report);
var nowUtc = DateTime.UtcNow;
await using var connection = await OpenConnectionAsync(cancellationToken);
var userId = await EnsureUserAsync(connection, username, cancellationToken);
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
try
{
await UpsertInboxReportAsync(connection, transaction, report, cancellationToken);
const string updateInboxSql = """
UPDATE inbox_reports
SET
last_downloaded_at_utc = @nowUtc,
last_downloaded_by_user_id = @userId,
imported_complaint_report_id = COALESCE(@complaintId, imported_complaint_report_id),
imported_to_store_at_utc = COALESCE(imported_to_store_at_utc, @nowUtc),
updated_at_utc = CURRENT_TIMESTAMP(6)
WHERE global_report_uuid = @reportId;
""";
await using (var updateInbox = new MySqlCommand(updateInboxSql, connection, (MySqlTransaction)transaction))
{
updateInbox.Parameters.AddWithValue("@nowUtc", nowUtc);
updateInbox.Parameters.AddWithValue("@userId", userId);
updateInbox.Parameters.AddWithValue("@complaintId", complaintId.HasValue ? complaintId.Value : DBNull.Value);
updateInbox.Parameters.AddWithValue("@reportId", report.Id);
await updateInbox.ExecuteNonQueryAsync(cancellationToken);
}
const string updateUserReportSql = """
INSERT INTO user_inbox_reports (
app_user_id,
inbox_report_id,
first_seen_at_utc,
last_seen_at_utc,
first_downloaded_at_utc,
last_downloaded_at_utc,
download_count
)
SELECT
@userId,
ir.id,
CURRENT_TIMESTAMP(6),
CURRENT_TIMESTAMP(6),
@nowUtc,
@nowUtc,
1
FROM inbox_reports ir
WHERE ir.global_report_uuid = @reportId
ON DUPLICATE KEY UPDATE
last_seen_at_utc = CURRENT_TIMESTAMP(6),
first_downloaded_at_utc = COALESCE(first_downloaded_at_utc, VALUES(first_downloaded_at_utc)),
last_downloaded_at_utc = VALUES(last_downloaded_at_utc),
download_count = download_count + 1;
""";
await using (var updateUserReport = new MySqlCommand(updateUserReportSql, connection, (MySqlTransaction)transaction))
{
updateUserReport.Parameters.AddWithValue("@userId", userId);
updateUserReport.Parameters.AddWithValue("@nowUtc", nowUtc);
updateUserReport.Parameters.AddWithValue("@reportId", report.Id);
await updateUserReport.ExecuteNonQueryAsync(cancellationToken);
}
const string updateUserSql = """
UPDATE app_users
SET
last_successful_download_at_utc = @nowUtc,
last_downloaded_report_moment_utc =
CASE
WHEN @reportMoment IS NULL THEN last_downloaded_report_moment_utc
WHEN last_downloaded_report_moment_utc IS NULL THEN @reportMoment
WHEN @reportMoment > last_downloaded_report_moment_utc THEN @reportMoment
ELSE last_downloaded_report_moment_utc
END,
updated_at_utc = CURRENT_TIMESTAMP(6)
WHERE id = @userId;
""";
await using (var updateUser = new MySqlCommand(updateUserSql, connection, (MySqlTransaction)transaction))
{
updateUser.Parameters.AddWithValue("@nowUtc", nowUtc);
updateUser.Parameters.AddWithValue("@reportMoment", ToDbDate(reportMoment));
updateUser.Parameters.AddWithValue("@userId", userId);
await updateUser.ExecuteNonQueryAsync(cancellationToken);
}
await transaction.CommitAsync(cancellationToken);
}
catch
{
await transaction.RollbackAsync(cancellationToken);
throw;
}
}
private async Task<long> EnsureUserAsync(MySqlConnection connection, string username, CancellationToken cancellationToken)
{
const string insertSql = """
INSERT INTO app_users (username)
VALUES (@username)
ON DUPLICATE KEY UPDATE
updated_at_utc = CURRENT_TIMESTAMP(6);
""";
await using (var insert = new MySqlCommand(insertSql, connection))
{
insert.Parameters.AddWithValue("@username", username.Trim());
await insert.ExecuteNonQueryAsync(cancellationToken);
}
const string selectSql = """
SELECT id
FROM app_users
WHERE username = @username
LIMIT 1;
""";
await using var select = new MySqlCommand(selectSql, connection);
select.Parameters.AddWithValue("@username", username.Trim());
var result = await select.ExecuteScalarAsync(cancellationToken);
return Convert.ToInt64(result, CultureInfo.InvariantCulture);
}
private static async Task UpsertInboxReportAsync(
MySqlConnection connection,
MySqlTransaction transaction,
ReportDto report,
CancellationToken cancellationToken)
{
const string sql = """
INSERT INTO inbox_reports (
global_report_uuid,
progressive_id,
context_id,
context_name,
creation_date_utc,
update_date_utc,
access_date_utc,
last_access_utc,
gl_status,
gl_label,
is_updated
) VALUES (
@reportId,
@progressiveId,
@contextId,
@contextName,
@creationDateUtc,
@updateDateUtc,
@accessDateUtc,
@lastAccessUtc,
@glStatus,
@glLabel,
@isUpdated
)
ON DUPLICATE KEY UPDATE
progressive_id = VALUES(progressive_id),
context_id = VALUES(context_id),
context_name = VALUES(context_name),
creation_date_utc = VALUES(creation_date_utc),
update_date_utc = VALUES(update_date_utc),
access_date_utc = VALUES(access_date_utc),
last_access_utc = VALUES(last_access_utc),
gl_status = VALUES(gl_status),
gl_label = VALUES(gl_label),
is_updated = VALUES(is_updated),
last_seen_at_utc = CURRENT_TIMESTAMP(6),
updated_at_utc = CURRENT_TIMESTAMP(6);
""";
await using var command = new MySqlCommand(sql, connection, transaction);
command.Parameters.AddWithValue("@reportId", report.Id);
command.Parameters.AddWithValue("@progressiveId", report.Progressive.HasValue ? report.Progressive.Value : DBNull.Value);
command.Parameters.AddWithValue("@contextId", ToDbString(report.ContextId));
command.Parameters.AddWithValue("@contextName", ToDbString(report.ContextName));
command.Parameters.AddWithValue("@creationDateUtc", ToDbDate(ParseDate(report.CreationDate)));
command.Parameters.AddWithValue("@updateDateUtc", ToDbDate(ParseDate(report.UpdateDate)));
command.Parameters.AddWithValue("@accessDateUtc", ToDbDate(ParseDate(report.AccessDate)));
command.Parameters.AddWithValue("@lastAccessUtc", ToDbDate(ParseDate(report.LastAccess)));
command.Parameters.AddWithValue("@glStatus", ToDbString(report.Status));
command.Parameters.AddWithValue("@glLabel", ToDbString(report.Label));
command.Parameters.AddWithValue("@isUpdated", report.Updated);
await command.ExecuteNonQueryAsync(cancellationToken);
}
private static async Task UpsertUserSnapshotAsync(
MySqlConnection connection,
MySqlTransaction transaction,
long userId,
string reportId,
CancellationToken cancellationToken)
{
const string sql = """
INSERT INTO user_inbox_reports (
app_user_id,
inbox_report_id,
first_seen_at_utc,
last_seen_at_utc
)
SELECT
@userId,
ir.id,
CURRENT_TIMESTAMP(6),
CURRENT_TIMESTAMP(6)
FROM inbox_reports ir
WHERE ir.global_report_uuid = @reportId
ON DUPLICATE KEY UPDATE
last_seen_at_utc = CURRENT_TIMESTAMP(6);
""";
await using var command = new MySqlCommand(sql, connection, transaction);
command.Parameters.AddWithValue("@userId", userId);
command.Parameters.AddWithValue("@reportId", reportId);
await command.ExecuteNonQueryAsync(cancellationToken);
}
private async Task<Dictionary<string, ReportMetadata>> LoadMetadataAsync(
MySqlConnection connection,
long userId,
List<string> reportIds,
CancellationToken cancellationToken)
{
var metadata = new Dictionary<string, ReportMetadata>(StringComparer.OrdinalIgnoreCase);
if (reportIds.Count == 0)
{
return metadata;
}
await using var command = connection.CreateCommand();
var parameterNames = new List<string>(reportIds.Count);
for (var i = 0; i < reportIds.Count; i++)
{
var parameterName = $"@reportId{i}";
parameterNames.Add(parameterName);
command.Parameters.AddWithValue(parameterName, reportIds[i]);
}
command.Parameters.AddWithValue("@userId", userId);
command.CommandText = $"""
SELECT
ir.global_report_uuid,
ir.last_downloaded_at_utc,
downloader.username AS last_downloaded_by_username,
ir.imported_to_store_at_utc,
COALESCE(c.is_in_gestiona, 0) AS already_in_gestiona,
CASE WHEN uir.last_downloaded_at_utc IS NULL THEN 0 ELSE 1 END AS downloaded_by_current_user
FROM inbox_reports ir
LEFT JOIN app_users downloader ON downloader.id = ir.last_downloaded_by_user_id
LEFT JOIN user_inbox_reports uir
ON uir.inbox_report_id = ir.id
AND uir.app_user_id = @userId
LEFT JOIN complaints c
ON c.external_report_id = COALESCE(ir.imported_complaint_report_id, ir.progressive_id)
WHERE ir.global_report_uuid IN ({string.Join(", ", parameterNames)});
""";
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
var reportId = GetStringValue(reader, "global_report_uuid");
var lastDownloadedByUsername = reader.IsDBNull(reader.GetOrdinal("last_downloaded_by_username"))
? null
: reader.GetString(reader.GetOrdinal("last_downloaded_by_username"));
var downloadedByCurrentUser = reader.GetInt32(reader.GetOrdinal("downloaded_by_current_user")) == 1;
var downloadedByAnotherUser =
!downloadedByCurrentUser &&
!string.IsNullOrWhiteSpace(lastDownloadedByUsername);
metadata[reportId] = new ReportMetadata
{
DownloadedByCurrentUser = downloadedByCurrentUser,
DownloadedByAnotherUser = downloadedByAnotherUser,
LastDownloadedByUsername = lastDownloadedByUsername,
LastDownloadedAtUtc = GetDateTimeOffset(reader, "last_downloaded_at_utc"),
AlreadyImported = !reader.IsDBNull(reader.GetOrdinal("imported_to_store_at_utc")),
AlreadyInGestiona = reader.GetInt32(reader.GetOrdinal("already_in_gestiona")) == 1,
};
}
return metadata;
}
private async Task<MySqlConnection> OpenConnectionAsync(CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(_options.ConnectionString))
{
throw new InvalidOperationException(
"Falta configurar ComplaintStorage:ConnectionString en appsettings.json.");
}
var connection = new MySqlConnection(_options.ConnectionString);
await connection.OpenAsync(cancellationToken);
await using var timeZoneCommand = new MySqlCommand("SET time_zone = '+00:00';", connection);
await timeZoneCommand.ExecuteNonQueryAsync(cancellationToken);
return connection;
}
private static object ToDbString(string? value)
{
return string.IsNullOrWhiteSpace(value) ? DBNull.Value : value;
}
private static object ToDbDate(DateTimeOffset? value)
{
return value is null ? DBNull.Value : value.Value.UtcDateTime;
}
private static DateTimeOffset? ParseDate(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
return DateTimeOffset.TryParse(value.Replace("Z", "+00:00", StringComparison.Ordinal), out var parsed)
? parsed
: null;
}
private static DateTimeOffset? ResolveReportMoment(ReportDto report)
{
return ParseDate(report.UpdateDate) ?? ParseDate(report.CreationDate);
}
private static DateTimeOffset? GetDateTimeOffset(MySqlDataReader reader, string columnName)
{
var ordinal = reader.GetOrdinal(columnName);
return reader.IsDBNull(ordinal)
? null
: new DateTimeOffset(DateTime.SpecifyKind(reader.GetDateTime(ordinal), DateTimeKind.Utc));
}
private static string GetStringValue(MySqlDataReader reader, string columnName)
{
var ordinal = reader.GetOrdinal(columnName);
if (reader.IsDBNull(ordinal))
{
return string.Empty;
}
var value = reader.GetValue(ordinal);
return value switch
{
Guid guid => guid.ToString("D"),
string text => text,
_ => Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty
};
}
private static string? BuildTrackingNote(ReportMetadata? metadata)
{
if (metadata is null)
{
return null;
}
if (metadata.AlreadyInGestiona)
{
return "Ya existe expediente en Gestiona";
}
if (metadata.DownloadedByAnotherUser)
{
if (!string.IsNullOrWhiteSpace(metadata.LastDownloadedByUsername) && metadata.LastDownloadedAtUtc is not null)
{
return $"La descargó {metadata.LastDownloadedByUsername} el {metadata.LastDownloadedAtUtc.Value.ToLocalTime():dd/MM/yyyy HH:mm}";
}
return "Ya la descargó otro usuario";
}
if (metadata.DownloadedByCurrentUser && metadata.LastDownloadedAtUtc is not null)
{
return $"Ya la descargaste el {metadata.LastDownloadedAtUtc.Value.ToLocalTime():dd/MM/yyyy HH:mm}";
}
if (metadata.AlreadyImported)
{
return "Ya está incorporada a la base de trabajo";
}
return null;
}
private sealed record ReportMetadata
{
public bool DownloadedByCurrentUser { get; init; }
public bool DownloadedByAnotherUser { get; init; }
public string? LastDownloadedByUsername { get; init; }
public DateTimeOffset? LastDownloadedAtUtc { get; init; }
public bool AlreadyImported { get; init; }
public bool AlreadyInGestiona { get; init; }
}
}

View File

@@ -0,0 +1,30 @@
using System.Collections.Concurrent;
namespace GestionaDenunciasAN.Services;
public sealed class LoginRateLimiter
{
private const int MaxAttempts = 5;
private static readonly TimeSpan Window = TimeSpan.FromMinutes(1);
private readonly ConcurrentDictionary<string, List<DateTimeOffset>> _attempts = new();
private readonly object _gate = new();
public bool AllowAttempt(string key)
{
var now = DateTimeOffset.UtcNow;
lock (_gate)
{
var entries = _attempts.GetOrAdd(key, _ => []);
entries.RemoveAll(t => now - t > Window);
if (entries.Count >= MaxAttempts)
{
return false;
}
entries.Add(now);
return true;
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -6,9 +6,52 @@
}
},
"AllowedHosts": "*",
//SWAGGER DESARROLLO
//"SwaggerCC": "https://localhost:7135/api/",
//SWAGGER PUBLICADO
"SwaggerCC": "https://sw-antifraude.tecnosis.net/api/"
"SwaggerCC": "https://sw-antifraude.tecnosis.net/api/",
"Gestiona": {
"ApiBase": "https://02.g3stiona.com",
//"AccessToken": "_mZcOqbvtCo2a8zIgjZZA9g__2",
//"AccessToken": "_PuH9tvEZqkhBplTMZkQmAw__1",
"AccessToken": "_yr.xVvPOllsyd1TYZRxUxg__c",
"PreferredCircuitTemplateName": "CT-Actualización de denuncia",
"UserLink": "https://02.g3stiona.com/rest/users/0c168833-8e27-4695-a301-b79924031f63",
"GroupLink": "https://02.g3stiona.com/rest/groups/6dbfc433-1eb6-4b9a-a533-bfebc652c101",
"Location": "2.02.01"
},
"GlobalLeaks": {
"BaseUrl": "https://prebuzon.antifraudeandalucia.es",
"TimeoutSeconds": 120,
"MaxDownloadBytes": 524288000
},
"ComplaintStorage": {
"ConnectionString": "Server=192.168.41.25;Port=13306;Database=gestiondenuncias;Uid=tecnosis;Pwd=tsl4net.Ts87;",
"AutoCreateSchema": true
},
"ReverseProxy": {
"Routes": {
"PortalRoute": {
"ClusterId": "PortalCluster",
"Match": { "Path": "/portal/{**catchall}" },
"Transforms": [
{ "PathRemovePrefix": "/portal" },
{ "ResponseHeaderRemove": "X-Frame-Options" },
{ "ResponseHeaderRemove": "Content-Security-Policy" },
{
"ResponseHeader": "Content-Security-Policy",
"Set": "frame-ancestors 'self'"
},
{ "RequestHeaderOriginalHost": "true" }
]
}
},
"Clusters": {
"PortalCluster": {
"Destinations": {
"d1": { "Address": "https://prebuzon.antifraudeandalucia.es/" }
},
"HttpRequest": {
"Version": "2.0"
}
}
}
}
}

View File

@@ -1,23 +1,475 @@
html, body {
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
:root {
--app-ink: #12395f;
--app-ink-soft: #3a5e83;
--app-accent: #2a5298;
--app-accent-strong: #173b6d;
--app-accent-soft: #e9f1fb;
--app-sky: #5a9bd5;
--app-border: #d7e4f1;
--app-panel: rgba(255, 255, 255, 0.88);
--app-panel-strong: #ffffff;
--app-shadow: 0 24px 60px rgba(18, 57, 95, 0.12);
--app-shadow-soft: 0 12px 30px rgba(18, 57, 95, 0.08);
--app-success: #1f7a55;
--app-warning: #b7791f;
--app-danger: #b33a3a;
--app-radius-xl: 28px;
--app-radius-lg: 22px;
--app-radius-md: 16px;
--app-radius-sm: 12px;
}
a, .btn-link {
color: #006bb7;
html,
body {
font-family: "Aptos", "Segoe UI", Tahoma, sans-serif;
background:
radial-gradient(circle at top left, rgba(90, 155, 213, 0.24), transparent 30%),
radial-gradient(circle at bottom right, rgba(42, 82, 152, 0.16), transparent 28%),
linear-gradient(135deg, #f4f8fc 0%, #e8eff7 48%, #f7fbff 100%);
color: var(--app-ink);
min-height: 100%;
}
body {
margin: 0;
}
a,
.btn-link {
color: var(--app-accent);
}
a:hover,
.btn-link:hover {
color: var(--app-accent-strong);
}
h1,
h2,
h3,
h4,
h5,
h6 {
color: var(--app-ink);
font-weight: 700;
letter-spacing: -0.02em;
}
code {
color: var(--app-accent-strong);
background: rgba(42, 82, 152, 0.08);
padding: 0.15rem 0.4rem;
border-radius: 8px;
}
pre {
background: #f7fbff;
border: 1px solid var(--app-border);
border-radius: var(--app-radius-sm);
padding: 1rem;
color: var(--app-ink);
}
.app-shell {
display: grid;
grid-template-columns: 320px minmax(0, 1fr);
min-height: 100vh;
gap: 1rem;
padding: 1rem;
}
.app-sidebar {
min-width: 0;
}
.app-main {
min-width: 0;
display: flex;
flex-direction: column;
gap: 1rem;
}
.app-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1.5rem;
padding: 1.5rem 1.75rem;
border-radius: var(--app-radius-xl);
background: linear-gradient(135deg, rgba(255, 255, 255, 0.92), rgba(243, 248, 255, 0.96));
border: 1px solid rgba(90, 155, 213, 0.14);
box-shadow: var(--app-shadow);
}
.app-header__intro {
max-width: 58rem;
}
.app-header__eyebrow {
display: inline-flex;
align-items: center;
padding: 0.4rem 0.8rem;
border-radius: 999px;
background: rgba(42, 82, 152, 0.1);
color: var(--app-accent-strong);
font-weight: 700;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.app-header__title {
margin: 0.8rem 0 0.35rem;
font-size: clamp(1.8rem, 2vw, 2.5rem);
}
.app-header__copy {
color: var(--app-ink-soft);
max-width: 48rem;
font-size: 1rem;
}
.app-header__actions {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.85rem;
}
.app-session-pill {
display: inline-flex;
align-items: center;
gap: 0.55rem;
padding: 0.55rem 0.9rem;
border-radius: 999px;
background: rgba(31, 122, 85, 0.12);
color: var(--app-success);
font-weight: 600;
}
.app-session-pill__dot {
width: 0.65rem;
height: 0.65rem;
border-radius: 999px;
background: currentColor;
box-shadow: 0 0 0 0.2rem rgba(31, 122, 85, 0.12);
}
.app-user-chip {
display: inline-flex;
align-items: center;
gap: 0.85rem;
padding: 0.75rem 1rem;
border: 1px solid var(--app-border);
border-radius: 18px;
background: var(--app-panel-strong);
box-shadow: var(--app-shadow-soft);
color: var(--app-ink);
}
.app-user-chip:hover,
.app-user-chip:focus-visible {
border-color: rgba(42, 82, 152, 0.35);
background: #f7fbff;
}
.app-user-chip__icon {
font-size: 1.8rem;
color: var(--app-accent);
}
.app-user-chip__text {
display: flex;
flex-direction: column;
align-items: flex-start;
line-height: 1.1;
}
.app-user-chip__text small {
color: var(--app-ink-soft);
font-size: 0.8rem;
}
.app-content {
flex: 1;
border-radius: var(--app-radius-xl);
background: rgba(255, 255, 255, 0.58);
border: 1px solid rgba(90, 155, 213, 0.14);
box-shadow: var(--app-shadow-soft);
padding: 1.5rem;
backdrop-filter: blur(10px);
}
.app-content > .container,
.app-content > .container-fluid {
padding-left: 0;
padding-right: 0;
}
.nav-shell {
position: sticky;
top: 1rem;
display: flex;
flex-direction: column;
gap: 1.2rem;
min-height: calc(100vh - 2rem);
padding: 1.25rem;
border-radius: var(--app-radius-xl);
background:
linear-gradient(180deg, rgba(18, 57, 95, 0.98) 0%, rgba(23, 59, 109, 0.98) 55%, rgba(16, 44, 81, 0.98) 100%);
box-shadow: var(--app-shadow);
color: #f5fbff;
}
.nav-brand {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.9rem;
border-radius: var(--app-radius-lg);
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.nav-brand__logo {
width: 64px;
height: 64px;
object-fit: contain;
flex-shrink: 0;
}
.nav-brand__copy {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.nav-brand__eyebrow {
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: rgba(230, 244, 255, 0.72);
}
.nav-brand__title {
font-size: 1.05rem;
color: #ffffff;
}
.nav-brand__subtitle {
font-size: 0.88rem;
color: rgba(230, 244, 255, 0.78);
}
.nav-sections {
display: flex;
flex: 1;
flex-direction: column;
gap: 1rem;
}
.nav-section {
display: flex;
flex-direction: column;
gap: 0.45rem;
}
.nav-section--footer {
margin-top: auto;
}
.nav-section__label {
padding: 0 0.35rem;
font-size: 0.76rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: rgba(224, 239, 255, 0.58);
}
.menu-link {
display: flex;
align-items: flex-start;
gap: 0.85rem;
padding: 0.9rem 0.95rem;
border-radius: 18px;
color: rgba(244, 250, 255, 0.88);
text-decoration: none;
transition: transform 0.2s ease, background-color 0.2s ease, border-color 0.2s ease;
border: 1px solid transparent;
}
.menu-link:hover,
.menu-link:focus-visible {
color: #ffffff;
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.08);
transform: translateX(2px);
}
.menu-link.active {
color: #0f325d;
background: linear-gradient(135deg, #ffffff 0%, #edf5ff 100%);
border-color: rgba(90, 155, 213, 0.28);
box-shadow: 0 16px 28px rgba(7, 25, 45, 0.22);
}
.menu-link.active .menu-link__meta {
color: #53708e;
}
.menu-link__icon {
width: 2.25rem;
height: 2.25rem;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 14px;
background: rgba(255, 255, 255, 0.08);
font-size: 1.1rem;
flex-shrink: 0;
}
.menu-link.active .menu-link__icon {
background: rgba(42, 82, 152, 0.1);
color: var(--app-accent-strong);
}
.menu-link__content {
display: flex;
flex-direction: column;
gap: 0.2rem;
min-width: 0;
}
.menu-link__title {
font-weight: 700;
line-height: 1.15;
}
.menu-link__meta {
font-size: 0.83rem;
line-height: 1.2;
color: rgba(226, 240, 255, 0.72);
}
.card,
.modal-content {
border: 1px solid var(--app-border);
border-radius: var(--app-radius-lg);
background: var(--app-panel);
box-shadow: var(--app-shadow-soft);
}
.card-title {
color: var(--app-ink);
}
.alert {
border: none;
border-radius: 18px;
box-shadow: var(--app-shadow-soft);
}
.table {
--bs-table-bg: transparent;
--bs-table-striped-bg: rgba(42, 82, 152, 0.04);
--bs-table-hover-bg: rgba(42, 82, 152, 0.06);
color: var(--app-ink);
vertical-align: middle;
}
.table > :not(caption) > * > * {
border-bottom-color: rgba(18, 57, 95, 0.08);
}
.table thead th {
font-size: 0.82rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--app-ink-soft);
border-bottom-width: 1px;
}
.table-responsive {
border-radius: 18px;
}
.form-control,
.form-select {
min-height: 2.95rem;
border-radius: 14px;
border-color: #cbd9e7;
background: rgba(255, 255, 255, 0.92);
color: var(--app-ink);
}
textarea.form-control {
min-height: auto;
}
.form-control:focus,
.form-select:focus,
.form-check-input:focus,
.btn:focus,
.btn:active:focus,
.btn-link.nav-link:focus {
border-color: rgba(42, 82, 152, 0.45);
box-shadow: 0 0 0 0.2rem rgba(42, 82, 152, 0.12);
}
.btn {
border-radius: 14px;
font-weight: 600;
}
.btn-primary {
color: #fff;
background-color: #1b6ec2;
border-color: #1861ac;
background: linear-gradient(135deg, var(--app-accent), var(--app-accent-strong));
border: none;
}
.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus {
box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb;
.btn-primary:hover,
.btn-primary:focus-visible {
color: #fff;
background: linear-gradient(135deg, #315ea9, #1a4278);
}
.btn-outline-primary {
color: var(--app-accent);
border-color: rgba(42, 82, 152, 0.28);
background: rgba(255, 255, 255, 0.72);
}
.btn-outline-primary:hover,
.btn-outline-primary:focus-visible {
background: var(--app-accent-soft);
color: var(--app-accent-strong);
border-color: rgba(42, 82, 152, 0.36);
}
.btn-outline-secondary {
color: var(--app-ink);
border-color: rgba(18, 57, 95, 0.14);
background: rgba(255, 255, 255, 0.72);
}
.btn-outline-secondary:hover,
.btn-outline-secondary:focus-visible {
color: var(--app-ink);
background: rgba(18, 57, 95, 0.06);
border-color: rgba(18, 57, 95, 0.18);
}
.badge {
padding: 0.5rem 0.7rem;
border-radius: 999px;
font-weight: 700;
}
.content {
padding-top: 1.1rem;
padding-top: 0;
}
h1:focus {
@@ -37,15 +489,89 @@ h1:focus {
}
.blazor-error-boundary {
background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121;
background: #b32121;
padding: 1rem 1rem 1rem 3.7rem;
color: white;
}
.blazor-error-boundary::after {
content: "An error has occurred."
}
.blazor-error-boundary::after {
content: "An error has occurred.";
}
.darker-border-checkbox.form-check-input {
border-color: #929292;
}
#blazor-error-ui {
background: #fff6d7;
color: #6c5400;
bottom: 1rem;
left: 1rem;
right: 1rem;
box-shadow: var(--app-shadow-soft);
display: none;
padding: 0.85rem 1.2rem;
position: fixed;
border-radius: 18px;
border: 1px solid rgba(183, 121, 31, 0.18);
z-index: 1000;
}
#blazor-error-ui .dismiss {
cursor: pointer;
position: absolute;
right: 0.9rem;
top: 0.7rem;
}
@media (max-width: 1100px) {
.app-shell {
grid-template-columns: 1fr;
}
.nav-shell {
position: static;
min-height: auto;
}
.app-header {
flex-direction: column;
align-items: flex-start;
}
.app-header__actions {
width: 100%;
align-items: stretch;
}
.app-user-chip,
.app-session-pill {
justify-content: center;
}
}
@media (max-width: 768px) {
.app-shell {
padding: 0.75rem;
gap: 0.75rem;
}
.app-content,
.app-header,
.nav-shell {
border-radius: 22px;
}
.nav-brand {
align-items: flex-start;
}
.nav-brand__logo {
width: 52px;
height: 52px;
}
.menu-link {
padding: 0.85rem;
}
}

View File

@@ -0,0 +1,43 @@
window.appAuthPostJson = async function (url, body) {
const response = await fetch(url, {
method: "POST",
credentials: "same-origin",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(body)
});
let data = null;
try {
data = await response.json();
} catch {
data = null;
}
return {
ok: response.ok,
status: response.status,
data
};
};
window.appAuthPost = async function (url) {
const response = await fetch(url, {
method: "POST",
credentials: "same-origin"
});
let data = null;
try {
data = await response.json();
} catch {
data = null;
}
return {
ok: response.ok,
status: response.status,
data
};
};