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

@@ -9,10 +9,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SwaggerAntifraude", "Swagge
EndProject EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "RegistroPersonalAN", "RegistroPersonalAN\RegistroPersonalAN.csproj", "{690BFF6A-F3FC-4D94-9E32-C689FBB69455}" Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "RegistroPersonalAN", "RegistroPersonalAN\RegistroPersonalAN.csproj", "{690BFF6A-F3FC-4D94-9E32-C689FBB69455}"
EndProject EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GestionaDenunciasAN", "GestionaDenunciasAN\GestionaDenunciasAN.csproj", "{27476EF0-284B-402C-ADBF-70A42220725F}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GestionPersonalWeb", "GestionPersonalWeb\GestionPersonalWeb.csproj", "{063515F3-D202-45DD-91DA-A494FBD005AD}" Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GestionPersonalWeb", "GestionPersonalWeb\GestionPersonalWeb.csproj", "{063515F3-D202-45DD-91DA-A494FBD005AD}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GestionaDenunciasAN", "GestionaDenunciasAN\GestionaDenunciasAN.csproj", "{77BE75E1-E1FD-AAE7-D897-398BED72CEB1}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
@@ -31,14 +31,14 @@ Global
{690BFF6A-F3FC-4D94-9E32-C689FBB69455}.Debug|Any CPU.Build.0 = Debug|Any CPU {690BFF6A-F3FC-4D94-9E32-C689FBB69455}.Debug|Any CPU.Build.0 = Debug|Any CPU
{690BFF6A-F3FC-4D94-9E32-C689FBB69455}.Release|Any CPU.ActiveCfg = Release|Any CPU {690BFF6A-F3FC-4D94-9E32-C689FBB69455}.Release|Any CPU.ActiveCfg = Release|Any CPU
{690BFF6A-F3FC-4D94-9E32-C689FBB69455}.Release|Any CPU.Build.0 = Release|Any CPU {690BFF6A-F3FC-4D94-9E32-C689FBB69455}.Release|Any CPU.Build.0 = Release|Any CPU
{27476EF0-284B-402C-ADBF-70A42220725F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{27476EF0-284B-402C-ADBF-70A42220725F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{27476EF0-284B-402C-ADBF-70A42220725F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{27476EF0-284B-402C-ADBF-70A42220725F}.Release|Any CPU.Build.0 = Release|Any CPU
{063515F3-D202-45DD-91DA-A494FBD005AD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {063515F3-D202-45DD-91DA-A494FBD005AD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{063515F3-D202-45DD-91DA-A494FBD005AD}.Debug|Any CPU.Build.0 = Debug|Any CPU {063515F3-D202-45DD-91DA-A494FBD005AD}.Debug|Any CPU.Build.0 = Debug|Any CPU
{063515F3-D202-45DD-91DA-A494FBD005AD}.Release|Any CPU.ActiveCfg = Release|Any CPU {063515F3-D202-45DD-91DA-A494FBD005AD}.Release|Any CPU.ActiveCfg = Release|Any CPU
{063515F3-D202-45DD-91DA-A494FBD005AD}.Release|Any CPU.Build.0 = Release|Any CPU {063515F3-D202-45DD-91DA-A494FBD005AD}.Release|Any CPU.Build.0 = Release|Any CPU
{77BE75E1-E1FD-AAE7-D897-398BED72CEB1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{77BE75E1-E1FD-AAE7-D897-398BED72CEB1}.Debug|Any CPU.Build.0 = Debug|Any CPU
{77BE75E1-E1FD-AAE7-D897-398BED72CEB1}.Release|Any CPU.ActiveCfg = Release|Any CPU
{77BE75E1-E1FD-AAE7-D897-398BED72CEB1}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE

View File

@@ -11,14 +11,16 @@
<link rel="stylesheet" href="app.css" /> <link rel="stylesheet" href="app.css" />
<link rel="stylesheet" href="GestionaDenunciasAN.styles.css" /> <link rel="stylesheet" href="GestionaDenunciasAN.styles.css" />
<link rel="icon" type="image/png" href="favicon.png" /> <link rel="icon" type="image/png" href="favicon.png" />
<HeadOutlet /> <HeadOutlet @rendermode="@(new InteractiveServerRenderMode(prerender: false))" />
</head> </head>
<body> <body>
<Routes /> <Routes @rendermode="@(new InteractiveServerRenderMode(prerender: false))" />
<script src="Scripts/bootstrap.bundle.min.js"></script> <script src="Scripts/bootstrap.bundle.min.js"></script>
<script src="js/appAuth.js"></script>
<script src="_framework/blazor.web.js"></script> <script src="_framework/blazor.web.js"></script>
</body> </body>
</html> </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 GestionaDenunciasAN.Models.UserState userState
@inject IHttpContextAccessor HttpContextAccessor
@inject IJSRuntime JSRuntime
@inject NavigationManager Navigation
<style> <div class="app-shell">
<aside class="app-sidebar">
/* 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">
<NavMenu /> <NavMenu />
</aside>
<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>
<main> <div class="app-header__actions">
<!-- Barra superior completa con gradiente de azul --> <div class="app-session-pill">
<div class="top-row"> <span class="app-session-pill__dot"></span>
<div class="portal-title"> Sesion interna activa
Portal Gestion Denuncias
</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> </div>
<article class="content"> <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 @Body
</article> </article>
</main> </main>
@@ -91,5 +43,106 @@
<div id="blazor-error-ui"> <div id="blazor-error-ui">
An unhandled error has occurred. An unhandled error has occurred.
<a href="" class="reload">Reload</a> <a href="" class="reload">Reload</a>
<a class="dismiss">🗙</a> <a class="dismiss">x</a>
</div> </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 { /* Layout styles moved to wwwroot/app.css for a shared app-wide theme. */
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;
}

View File

@@ -1,99 +1,82 @@
<!-- NavMenu.razor --> <div class="nav-shell">
<style> <div class="nav-brand">
.nav-menu-container { <img class="nav-brand__logo"
height: 100vh; src="Content/imagenes/logo-oaaf-negativo-transparente.svg"
overflow: hidden; alt="Logo Oficina Antifraude" />
} <div class="nav-brand__copy">
<span class="nav-brand__eyebrow">OAAF</span>
/* Parte superior con el logo */ <strong class="nav-brand__title">Gestion de denuncias</strong>
.nav-top { <span class="nav-brand__subtitle">Entrada, revision y tramitacion unificada</span>
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> </div>
</div> </div>
<!-- Contenido scrollable: menú de navegación --> <nav class="nav-sections" aria-label="Navegacion principal">
<div class="nav-scrollable" onclick="document.querySelector('.navbar-toggler').click()"> <div class="nav-section">
<nav class="flex-column" style="display: flex; height: 100%;"> <span class="nav-section__label">Operativa diaria</span>
<!-- Grupo superior: Pendientes y Finalizados -->
<div> <NavLink class="menu-link" href="/GestionZip" Match="NavLinkMatch.All">
<div class="nav-item px-3"> <span class="menu-link__icon bi bi-box-seam" aria-hidden="true"></span>
<NavLink class="nav-link" href="GestionZip" Match="NavLinkMatch.All"> <span class="menu-link__content">
<span class="bi bi-list-task" aria-hidden="true"></span> Gestion ZIP <span class="menu-link__title">Entrada</span>
<span class="menu-link__meta">Importar denuncias y renovar 2FA</span>
</span>
</NavLink>
<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> </NavLink>
</div> </div>
<div class="nav-item px-3"> <div class="nav-section">
<NavLink class="nav-link" href="Pendientes" Match="NavLinkMatch.All"> <span class="nav-section__label">Consulta y control</span>
<span class="bi bi-list-task" aria-hidden="true"></span> Pendientes
<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>
</div>
<div class="nav-item px-3"> <NavLink class="menu-link" href="/Rechazados" Match="NavLinkMatch.All">
<NavLink class="nav-link" href="Gestiona" Match="NavLinkMatch.All"> <span class="menu-link__icon bi bi-journal-x" aria-hidden="true"></span>
<span class="bi bi-journal-check" aria-hidden="true"></span> Gestiona <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>
</div>
<div class="nav-item px-3"> <NavLink class="menu-link" href="/Buscador" Match="NavLinkMatch.All">
<NavLink class="nav-link" href="Rechazados" Match="NavLinkMatch.All"> <span class="menu-link__icon bi bi-search" aria-hidden="true"></span>
<span class="bi bi-journal-x" aria-hidden="true"></span> Rechazados <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> </NavLink>
</div> </div>
</div>
<!-- Grupo inferior: Instrucciones, empujado al fondo --> <div class="nav-section nav-section--footer">
<div class="bottom-group"> <span class="nav-section__label">Ayuda</span>
<div class="nav-item px-3">
<NavLink class="nav-link" href="Instrucciones"> <NavLink class="menu-link" href="/Instrucciones" Match="NavLinkMatch.All">
<span class="bi bi-book-half" aria-hidden="true"></span> Instrucciones <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> </NavLink>
</div> </div>
</div>
</nav> </nav>
</div> </div>
</div>

View File

@@ -1,105 +1 @@
.navbar-toggler { /* Navigation styles moved to wwwroot/app.css for a shared app-wide theme. */
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;
}
}

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" @page "/Gestiona"
@rendermode InteractiveServer @rendermode InteractiveServer
@attribute [Authorize]
@using GestionaDenunciasAN.Models @using GestionaDenunciasAN.Models
@using System.Globalization @using System.Globalization
@attribute [StreamRendering] @attribute [StreamRendering]
@inject GestionaDenunciasAN.Models.UserState userState @inject GestionaDenunciasAN.Models.UserState userState
@inject NavigationManager Navigation @inject NavigationManager Navigation
@inject IHostEnvironment HostEnvironment @inject IHostEnvironment HostEnvironment
@inject IDenunciaStore DenunciaStore
@inject IGestionaService GestionaApi
<PageTitle>Denuncias Gestión</PageTitle> <PageTitle>Denuncias Gestión</PageTitle>
@@ -107,8 +110,7 @@ else
<h5 class="mb-0">Denuncia ID: @denuncia.Id_Denuncia</h5> <h5 class="mb-0">Denuncia ID: @denuncia.Id_Denuncia</h5>
<div class="header-info"> <div class="header-info">
<span><strong>Estado:</strong> @denuncia.Estado</span> <span><strong>Estado:</strong> @denuncia.Estado</span>
<span><strong>Nombre:</strong> @denuncia.NombreDenuncia</span> <span><strong>Asunto:</strong> @denuncia.NombreDenuncia</span>
<span><strong>Archivo:</strong> @denuncia.ArchivoElegido</span>
<span><strong>Fecha de Subida:</strong> @denuncia.FechaSubidaAGestiona.ToString("dd/MM/yyyy")</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> <span><strong>Hora de Subida:</strong> @denuncia.FechaSubidaAGestiona.ToString("HH:mm")</span>
</div> </div>
@@ -133,10 +135,10 @@ else
<dt class="col-sm-3">Fecha</dt> <dt class="col-sm-3">Fecha</dt>
<dd class="col-sm-9">@denuncia.Fecha.ToString("dd/MM/yyyy HH:mm")</dd> <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> <dt class="col-sm-3">Nº expediente Gestiona</dt>
<dd class="col-sm-9">@denuncia.Expediente_Gestiona</dd> <dd class="col-sm-9">@denuncia.ExpedienteGestionaMostrable</dd>
} }
@if (denuncia.Id_Persona_Gestiona != 0) @if (denuncia.Id_Persona_Gestiona != 0)
{ {
@@ -171,6 +173,7 @@ else
@if (!string.IsNullOrWhiteSpace(denuncia.Sexo)) @if (!string.IsNullOrWhiteSpace(denuncia.Sexo))
{ {
<dt class="col-sm-3">Sexo</dt> <dt class="col-sm-3">Sexo</dt>
<dd class="col-sm-9">@denuncia.Sexo</dd> <dd class="col-sm-9">@denuncia.Sexo</dd>
} }
@if (!string.IsNullOrWhiteSpace(denuncia.Dni)) @if (!string.IsNullOrWhiteSpace(denuncia.Dni))
@@ -308,8 +311,6 @@ else
// Variable para la búsqueda // Variable para la búsqueda
private string busqueda = ""; 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 isLoading = false;
private bool hasLoaded = false; private bool hasLoaded = false;
@@ -317,11 +318,6 @@ else
{ {
if (firstRender) if (firstRender)
{ {
if (string.IsNullOrEmpty(userState.Token))
{
Navigation.NavigateTo("/", true);
return;
}
isLoading = true; isLoading = true;
StateHasChanged(); StateHasChanged();
await CargarGestionaAsync(); await CargarGestionaAsync();
@@ -335,29 +331,22 @@ else
private async Task CargarGestionaAsync() private async Task CargarGestionaAsync()
{ {
var todas = await CargarDenunciasJsonAsync(); 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() private async Task<List<DenunciasGestiona>> CargarDenunciasJsonAsync()
{ {
if (File.Exists(DENUNCIAS_JSON)) return await DenunciaStore.GetAllDenunciasAsync();
{
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>();
} }
private async Task<List<FicherosDenuncias>> CargarFicherosJsonAsync() private async Task<List<FicherosDenuncias>> CargarFicherosJsonAsync()
{ {
if (File.Exists(FICHEROS_JSON)) return await DenunciaStore.GetAllFicherosAsync();
{
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>();
} }
private async Task CargarFicherosAdjuntosAsync() private async Task CargarFicherosAdjuntosAsync()
@@ -380,4 +369,49 @@ else
_ => "application/octet-stream", _ => "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" @page "/Instrucciones"
@attribute [Authorize]
@attribute [StreamRendering] @attribute [StreamRendering]
@inject GestionaDenunciasAN.Models.UserState userState @inject GestionaDenunciasAN.Models.UserState userState
@inject NavigationManager Navigation @inject NavigationManager Navigation
@@ -6,70 +7,116 @@
<PageTitle>Instrucciones</PageTitle> <PageTitle>Instrucciones</PageTitle>
<div class="container mt-4"> <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> <p>
Bienvenido a la aplicación de <strong>Gestión de Denuncias</strong>. Esta herramienta ha sido diseñada para Esta aplicación permite procesar denuncias desde archivos ZIP y gestionarlas en tres etapas:
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: <strong>Pendientes</strong>, <strong>Gestión</strong> (aceptadas) y <strong>Rechazadas</strong>.
</p> </p>
<h2>1. Carga y Procesamiento de Denuncias</h2> <h2>1. Carga de ZIPs</h2>
<ul> <ul>
<li> <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>
<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> </li>
</ul> </ul>
<h2>2. Visualización y Gestión de Denuncias</h2> <h2>2. Pestaña <strong>Pendientes</strong></h2>
<ul> <ul>
<li> <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>
<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> <ul>
<li>Las denuncias aceptadas se destacan con un fondo verde.</li>
<li>Las denuncias rechazadas se muestran con un fondo rojo.</li>
</ul>
</li>
<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. <strong>Configurar subida</strong> (verde): abre un modal donde puedes:
</li>
</ul>
<h2>3. Flujo de Trabajo</h2>
<p>
El flujo de trabajo de la aplicación es el siguiente:
</p>
<ol> <ol>
<li>Poner un nombre descriptivo.</li>
<li> <li>
Los archivos ZIP se depositan en la carpeta <code>Ejemplos</code>. 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>
<li> <li>
La aplicación descomprime los ZIP y parsea el archivo <code>report.txt</code> para extraer la información de cada denuncia. <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>
<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. 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>
<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>. 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>
En <strong>Gestión</strong> puedes revisar lo ya subido; en
<strong>Rechazadas</strong> ves los motivos.
</li> </li>
</ol> </ol>
<p> <p class="mt-4">
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. 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> </p>
</div> </div>
@code { @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 "/" @page "/"
@inject IHttpClientFactory HttpClientFactory @layout EmptyLayout
@inject IHttpContextAccessor HttpContextAccessor @rendermode @(new InteractiveServerRenderMode(prerender: false))
@inject NavigationManager Navigation @using System.Text.Json
@inject UserState UserState @using GestionaDenunciasAN.Models
@inject AuthenticationStateProvider AuthenticationStateProvider
@inject IJSRuntime JSRuntime @inject IJSRuntime JSRuntime
@inject NavigationManager Navigation
<PageTitle>Gestión Denuncias - Oficina Antifraude de Andalucía</PageTitle> <PageTitle>Portal de denuncias</PageTitle>
<style> <style>
/* Contenedor que ocupa toda la altura de la ventana */ .login-shell {
.full-height { min-height: 100vh;
height: 100vh; background:
margin: 0; radial-gradient(circle at top left, rgba(90, 155, 213, 0.3), transparent 35%),
padding: 0; linear-gradient(135deg, #f7fafc 0%, #dfe8f3 45%, #eef4fb 100%);
} }
/* Columna izquierda con un degradado en azul */ .login-panel {
.left-side { max-width: 440px;
background: linear-gradient(135deg, #5a9bd5, #2A5298);
color: #fff;
}
/* Columna derecha con fondo claro */
.right-side {
background: linear-gradient(135deg, #ffffff, #dddddd);
}
/* Contenedor para centrar contenido en ambas columnas */
.centered-content {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100%;
padding: 2rem;
}
/* Logo */
.logo-img {
max-width: 300px;
margin-bottom: 2rem;
}
/* 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%; width: 100%;
text-align: center; background: rgba(255, 255, 255, 0.92);
color: #fff; /* Para que el texto sea legible sobre el gradiente */ border-radius: 24px;
box-shadow: 0 25px 60px rgba(31, 73, 125, 0.18);
border: 1px solid rgba(90, 155, 213, 0.15);
} }
.login-box label { .brand-panel {
font-weight: 500; color: #12395f;
display: block; max-width: 560px;
text-align: left;
margin-bottom: 0.3rem;
color: #fff; /* Aseguramos que la etiqueta se vea bien */
} }
.login-box .form-control { .brand-kicker {
width: 100%; display: inline-block;
padding: 0.75rem; padding: 0.35rem 0.75rem;
margin-bottom: 1rem; border-radius: 999px;
border: 1px solid #ccc; background: rgba(42, 82, 152, 0.12);
border-radius: 5px; color: #1f497d;
font-weight: 600;
letter-spacing: 0.02em;
text-transform: uppercase;
font-size: 0.8rem;
} }
.error-message { .brand-title {
color: red; font-size: clamp(2rem, 4vw, 3.5rem);
margin-bottom: 1rem; line-height: 1.05;
font-weight: 700;
margin: 1rem 0;
} }
/* Botones personalizados */ .brand-copy {
.login-box .btnOAAFAzul { font-size: 1.05rem;
background-color: #fff !important; /* Fondo blanco */ color: #35597f;
color: #000 !important; /* Texto negro */ max-width: 34rem;
}
.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: none;
border-radius: 15px; font-weight: 600;
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;
} }
</style> </style>
<link rel="icon" type="image/x-icon" href="faviconParlamento.ico" /> <div class="container-fluid login-shell d-flex align-items-center">
<link href="~/bootstrap/bootstrap.min.css" rel="stylesheet" /> <div class="container py-5">
<link href="Content/Site.css" rel="stylesheet" /> <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>
<!-- Bloque de loading que se muestra mientras 'mostrar' es true --> <div class="col-lg-5 d-flex justify-content-center">
@if (mostrar) <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>
@if (!string.IsNullOrWhiteSpace(StatusMessage))
{ {
<div id="cargando" class="loadingFrame"> <div class="alert @StatusCss mb-4">@StatusMessage</div>
<div class="loadingDiv">
<div class="loadingImg"></div>
</div>
</div>
} }
<!-- Estructura en dos columnas --> <div class="mb-3">
<div class="container-fluid full-height px-0"> <label class="login-label mb-2">Usuario</label>
<div class="row no-gutters full-height"> <input class="form-control login-input"
<!-- Columna izquierda (más ancha) --> @bind="Username"
<div class="col-md-7 left-side"> autocomplete="username"
<div class="centered-content"> placeholder="usuario de GlobalLeaks" />
<!-- 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>
</div> </div>
<!-- Columna derecha (más estrecha) --> <div class="mb-3">
<div class="col-md-5 right-side"> <label class="login-label mb-2">Contraseña</label>
<div class="centered-content"> <input class="form-control login-input"
<form class="login-box"> type="password"
<h3 class="mb-4">Iniciar Sesión</h3> @bind="Password"
autocomplete="current-password" />
<p id="mensajeError" class="error-message">@mensaje</p>
<div class="form-group text-left">
<label for="Usu">Usuario</label>
<input id="Usu" type="text" class="form-control" @bind="Usu" />
</div> </div>
<div class="form-group text-left"> <div class="mb-4">
<label for="Contrasena">Contraseña</label> <label class="login-label mb-2">Código 2FA</label>
<input id="Contrasena" type="password" class="form-control" @bind="pass" /> <input class="form-control login-input"
@bind="Authcode"
@onkeydown="HandleAuthcodeKeyDown"
inputmode="numeric"
maxlength="6"
placeholder="123456" />
</div> </div>
<button class="btnOAAFAzul" type="button" @onclick="LogIn">ENTRAR</button> <button class="btn btn-primary login-button w-100"
<button class="btnOAAFBlack" type="button" @onclick="IniciarSesionConCertificado">INICIAR SESIÓN CON CERTIFICADO</button> @onclick="LoginAsync"
</form> disabled="@IsBusy">
@(IsBusy ? "Conectando..." : "Entrar")
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Iframe oculto para la autenticación con certificado -->
<iframe id="authCertIframe" style="display:none;"></iframe>
@code { @code {
public string? Usu { get; set; } [SupplyParameterFromQuery(Name = "returnUrl")]
public string? pass { get; set; } public string? ReturnUrl { get; set; }
private string? mensaje { get; set; }
public bool mostrar { get; set; } = false;
private DotNetObjectReference<Login>? dotNetRef;
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 Navigation.NavigateTo(GetTargetUrl(), true);
dotNetRef = DotNetObjectReference.Create(this);
await JSRuntime.InvokeVoidAsync("registerTokenReceiver", dotNetRef);
} }
} }
protected override void OnInitialized() private async Task LoginAsync()
{ {
LimpiarEstadoUsuario(); StatusMessage = string.Empty;
}
private void LimpiarEstadoUsuario() if (string.IsNullOrWhiteSpace(Username) ||
string.IsNullOrWhiteSpace(Password) ||
string.IsNullOrWhiteSpace(Authcode))
{ {
UserState.Token = ""; SetStatus("Debes rellenar usuario, contraseña y código 2FA.", "alert-warning");
UserState.NombreUsu = "";
HttpContextAccessor?.HttpContext?.Session?.Clear();
}
public async Task LogIn()
{
mostrar = true;
await Task.Delay(1);
if (string.IsNullOrWhiteSpace(Usu) || string.IsNullOrWhiteSpace(pass))
{
mostrar = false;
mensaje = "Por favor, ingrese su usuario y contraseña.";
return; return;
} }
IsBusy = true;
try try
{ {
var client = HttpClientFactory.CreateClient(); var response = await JSRuntime.InvokeAsync<ApiJsResponse>(
client.BaseAddress = new Uri(Utilidades.urlSwagger()); "appAuthPostJson",
var loginPayload = new { NombreUsuario = Usu, Contraseña = pass, Origen = "Denuncias" }; "/api/auth/login",
var loginContent = new StringContent(JsonConvert.SerializeObject(loginPayload), Encoding.UTF8, "application/json"); new LoginRequest(Username.Trim(), Password, Authcode.Trim()));
var loginResponse = await client.PostAsync("Auth/login", loginContent);
await ProcesarRespuesta(loginResponse); if (!response.Ok)
}
catch (Exception ex)
{ {
mostrar = false; var error = ReadData<ApiError>(response);
mensaje = $"Error inesperado: {ex.Message}"; SetStatus(error?.Error ?? "No se ha podido iniciar sesión.", "alert-danger");
} return;
} }
private async Task IniciarSesionConCertificado() Navigation.NavigateTo(GetTargetUrl(), true);
{
mostrar = true;
var url = Utilidades.urlSwagger() + "Auth/login-cert?iframe=true";
await JSRuntime.InvokeVoidAsync("iniciarSesionConCertificado", url);
} }
catch (JSException ex)
private async Task ProcesarRespuesta(HttpResponseMessage response)
{ {
var responseContent = await response.Content.ReadAsStringAsync(); SetStatus($"Fallo de comunicación con el navegador: {ex.Message}", "alert-danger");
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);
}
else
{
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()}";
} }
catch catch
{ {
UserState.NombreUsu = ""; SetStatus("No se ha podido conectar con el servidor.", "alert-danger");
} }
Navigation.NavigateTo("/GestionZip", true); finally
return Task.CompletedTask; {
IsBusy = false;
} }
} }
<script> private Task HandleAuthcodeKeyDown(KeyboardEventArgs args)
function registerTokenReceiver(dotnetRef) { => args.Key == "Enter" ? LoginAsync() : Task.CompletedTask;
window.dotnetTokenReceiver = dotnetRef;
private void SetStatus(string message, string cssClass)
{
StatusMessage = message;
StatusCss = cssClass;
} }
window.iniciarSesionConCertificado = function(url) { private string GetTargetUrl()
console.log("Se llamó iniciarSesionConCertificado con URL:", url); {
var iframe = document.getElementById("authCertIframe"); if (!string.IsNullOrWhiteSpace(ReturnUrl) &&
if (iframe) { ReturnUrl.StartsWith("/", StringComparison.Ordinal) &&
iframe.src = url; !ReturnUrl.StartsWith("//", StringComparison.Ordinal))
} else { {
console.error("No se encontró el iframe con id 'authCertIframe'"); return ReturnUrl;
} }
};
window.addEventListener("message", function(event) { return "/GestionZip";
var data = event.data; }
if (data && data.token) {
console.log("Mensaje recibido con token:", data.token); private static T? ReadData<T>(ApiJsResponse response)
if (window.dotnetTokenReceiver) { {
window.dotnetTokenReceiver.invokeMethodAsync("SetToken", data.token, JSON.stringify(data.user)); if (response.Data.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined)
} else { {
localStorage.setItem("token", data.token); return default;
localStorage.setItem("user", JSON.stringify(data.user)); }
window.location.href = "/GestionZip";
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>

View File

@@ -1,10 +1,12 @@
@page "/Rechazados" @page "/Rechazados"
@rendermode InteractiveServer @rendermode InteractiveServer
@attribute [Authorize]
@using GestionaDenunciasAN.Models @using GestionaDenunciasAN.Models
@using System.Globalization @using System.Globalization
@attribute [StreamRendering] @attribute [StreamRendering]
@inject GestionaDenunciasAN.Models.UserState userState @inject GestionaDenunciasAN.Models.UserState userState
@inject NavigationManager Navigation @inject NavigationManager Navigation
@inject IDenunciaStore DenunciaStore
<PageTitle>Denuncias Rechazadas</PageTitle> <PageTitle>Denuncias Rechazadas</PageTitle>
@@ -130,10 +132,10 @@ else
<dt class="col-sm-3">Fecha</dt> <dt class="col-sm-3">Fecha</dt>
<dd class="col-sm-9">@denuncia.Fecha.ToString("dd/MM/yyyy HH:mm")</dd> <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> <dt class="col-sm-3">Nº expediente Gestiona</dt>
<dd class="col-sm-9">@denuncia.Expediente_Gestiona</dd> <dd class="col-sm-9">@denuncia.ExpedienteGestionaMostrable</dd>
} }
@if (denuncia.Id_Persona_Gestiona != 0) @if (denuncia.Id_Persona_Gestiona != 0)
{ {
@@ -305,8 +307,6 @@ else
// Variable para la búsqueda // Variable para la búsqueda
private string busqueda = ""; 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 isLoading = false;
private bool hasLoaded = false; private bool hasLoaded = false;
@@ -314,11 +314,6 @@ else
{ {
if (firstRender) if (firstRender)
{ {
if (string.IsNullOrEmpty(userState.Token))
{
Navigation.NavigateTo("/", true);
return;
}
isLoading = true; isLoading = true;
StateHasChanged(); StateHasChanged();
await CargarRechazadasAsync(); await CargarRechazadasAsync();
@@ -332,29 +327,21 @@ else
private async Task CargarRechazadasAsync() private async Task CargarRechazadasAsync()
{ {
var todas = await CargarDenunciasJsonAsync(); 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() private async Task<List<DenunciasGestiona>> CargarDenunciasJsonAsync()
{ {
if (File.Exists(DENUNCIAS_JSON)) return await DenunciaStore.GetAllDenunciasAsync();
{
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>();
} }
private async Task<List<FicherosDenuncias>> CargarFicherosJsonAsync() private async Task<List<FicherosDenuncias>> CargarFicherosJsonAsync()
{ {
if (File.Exists(FICHEROS_JSON)) return await DenunciaStore.GetAllFicherosAsync();
{
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>();
} }
private async Task CargarFicherosAdjuntosAsync() private async Task CargarFicherosAdjuntosAsync()
@@ -378,3 +365,4 @@ else
}; };
} }
} }

View File

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

View File

@@ -1,5 +1,7 @@
@using System.Net.Http @using System.Net.Http
@using System.Net.Http.Json @using System.Net.Http.Json
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing @using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web @using Microsoft.AspNetCore.Components.Web
@@ -8,3 +10,6 @@
@using Microsoft.JSInterop @using Microsoft.JSInterop
@using GestionaDenunciasAN @using GestionaDenunciasAN
@using GestionaDenunciasAN.Components @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> <ItemGroup>
<PackageReference Include="Blazor.Bootstrap" Version="3.3.1" /> <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="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="PdfSharpCore" Version="1.3.67" /> <PackageReference Include="PdfSharpCore" Version="1.3.67" />
<PackageReference Include="Serialize.Linq" Version="4.0.167" /> <PackageReference Include="Serialize.Linq" Version="4.0.167" />
<PackageReference Include="System.Drawing.Common" Version="10.0.0-preview.3.25173.2" /> <PackageReference Include="System.Drawing.Common" Version="10.0.0-preview.3.25173.2" />
<PackageReference Include="Yarp.ReverseProxy" Version="2.3.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\bdAntifraude\bdAntifraude.csproj" /> <Content Include="Scripts\gestiondenuncias_schema.sql" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup> </ItemGroup>
</Project> </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); outputDoc.Save(ms);
return ms.ToArray(); 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
{ {
// Propiedades existentes
public int Id_RegistroDenuncia { get; set; } public int Id_RegistroDenuncia { get; set; }
public int Id_Denuncia { get; set; } public int Id_Denuncia { get; set; }
public DateTime Fecha { get; set; } public DateTime Fecha { get; set; } = DateTime.MinValue;
public string Expediente_Gestiona { get; set; } public string Expediente_Gestiona { get; set; } = string.Empty;
public string CodigoExpedienteGestiona { get; set; } = string.Empty;
public int Id_Persona_Gestiona { get; set; } public int Id_Persona_Gestiona { get; set; }
public string Etiqueta { get; set; } public string Etiqueta { get; set; } = string.Empty;
public string Estado { get; set; } public string Estado { get; set; } = string.Empty;
public string Tipo_Denuncia { get; set; }
public string? Nombre { get; set; } public string Tipo_Denuncia { get; set; } = string.Empty;
public string? Apellidos { get; set; } public string TipoDenunciante { get; set; } = string.Empty;
public string? Sexo { get; set; } public bool EsPersonaJuridica { get; set; }
public string? Dni { get; set; } public string Nombre { get; set; } = string.Empty;
public string Asunto { get; set; } public string PrimerApellido { get; set; } = string.Empty;
public string A_Quien_Denuncia { get; set; } public string SegundoApellido { get; set; } = string.Empty;
public string Descripcion_Denuncia { get; set; } public string Apellidos { get; set; } = string.Empty;
public string Denunciado_Ante_Inst { get; set; } public string RazonSocial { get; set; } = string.Empty;
public string? Modalidad_Informacion { get; set; } public string Sexo { get; set; } = string.Empty;
public string Lugar_Hechos { get; set; } public string Dni { get; set; } = string.Empty;
public DateTime Fecha_Hechos { get; set; } public string TipoDocumentoIdentificativo { get; set; } = string.Empty;
public string? Notificacion_Preferencia { get; set; } public string PaisOrigen { get; set; } = string.Empty;
public string? Notificacion_Electronica { get; set; } public string Asunto { get; set; } = string.Empty;
public string? Correo_Electronico { get; set; } public string A_Quien_Denuncia { get; set; } = string.Empty;
public string? Notificacion_Sms { get; set; } 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 bool Condiciones { get; set; }
public string? Comments { 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;
// Nuevas propiedades public bool Confidencial { get; set; }
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 [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public DenunciasGestiona() 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
{ {
get
{
if (!string.IsNullOrWhiteSpace(Apellidos))
{
return Apellidos.Trim();
} }
// Constructor parametrizado que inicializa todos los campos, incluida la nueva propiedad return string.Join(
public DenunciasGestiona( ' ',
int idRegistroDenuncia, new[] { PrimerApellidoResuelto, SegundoApellidoResuelto }
int idDenuncia, .Where(value => !string.IsNullOrWhiteSpace(value)));
DateTime fecha, }
string expedienteGestiona, }
int idPersonaGestiona,
string etiqueta, [JsonIgnore]
string estado, public string RazonSocialResuelta => ResolveValue(RazonSocial, "razon social");
string tipoDenuncia,
string? nombre, [JsonIgnore]
string? apellidos, public string DocumentoResuelto => ResolveValue(
string? sexo, Dni,
string? dni, "nif dni nie",
string asunto, "dni",
string aQuienDenuncia, "nif",
string descripcionDenuncia, "nie",
string denunciadoAnteInst, "cif",
string? modalidadInformacion, "otro documento identificativo");
string lugarHechos,
DateTime fechaHechos, [JsonIgnore]
string? notificacionPreferencia, public bool EsAnonima
string? notificacionElectronica,
string? correoElectronico,
string? notificacionSms,
bool condiciones,
string? comments,
string nombreDenuncia,
string estadoDenuncia,
string archivoElegido,
DateTime fechaSubidaAGestiona)
{ {
Id_RegistroDenuncia = idRegistroDenuncia; get
Id_Denuncia = idDenuncia; {
Fecha = fecha; if (IndicaDenunciaIdentificada(Tipo_Denuncia))
Expediente_Gestiona = expedienteGestiona; {
Id_Persona_Gestiona = idPersonaGestiona; return false;
Etiqueta = etiqueta; }
Estado = estado;
Tipo_Denuncia = tipoDenuncia; if (Confidencial)
Nombre = nombre; {
Apellidos = apellidos; return true;
Sexo = sexo; }
Dni = dni;
Asunto = asunto; if (IndicaDenunciaAnonima(Tipo_Denuncia))
A_Quien_Denuncia = aQuienDenuncia; {
Descripcion_Denuncia = descripcionDenuncia; return true;
Denunciado_Ante_Inst = denunciadoAnteInst; }
Modalidad_Informacion = modalidadInformacion;
Lugar_Hechos = lugarHechos; return string.IsNullOrWhiteSpace(DocumentoResuelto) &&
Fecha_Hechos = fechaHechos; string.IsNullOrWhiteSpace(NombreResuelto) &&
Notificacion_Preferencia = notificacionPreferencia; string.IsNullOrWhiteSpace(RazonSocialResuelta);
Notificacion_Electronica = notificacionElectronica;
Correo_Electronico = correoElectronico;
Notificacion_Sms = notificacionSms;
Condiciones = condiciones;
Comments = comments;
NombreDenuncia = nombreDenuncia;
EstadoDenuncia = estadoDenuncia;
ArchivoElegido = archivoElegido;
FechaSubidaAGestiona = fechaSubidaAGestiona;
} }
} }
[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 namespace GestionaDenunciasAN.Models
{ {
@@ -13,7 +14,7 @@ namespace GestionaDenunciasAN.Models
// Descripción del fichero (puede ser nula) // Descripción del fichero (puede ser nula)
public string? Descripcion { get; set; } public string? Descripcion { get; set; }
// Fecha de creación del fichero // Fecha de creación del fichero original
public DateTime Fecha { get; set; } public DateTime Fecha { get; set; }
// Observaciones // Observaciones
@@ -28,12 +29,20 @@ namespace GestionaDenunciasAN.Models
// Fichero completo en formato byte array (BLOB) // Fichero completo en formato byte array (BLOB)
public byte[] Fichero { get; set; } public byte[] Fichero { get; set; }
// Constructor por defecto // → Nuevo: marca si ya se subió a Gestión
public FicherosDenuncias() 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( public FicherosDenuncias(
int id_Fichero, int id_Fichero,
int id_Tipo, int id_Tipo,
@@ -42,7 +51,8 @@ namespace GestionaDenunciasAN.Models
string observaciones, string observaciones,
int id_Denuncia, int id_Denuncia,
string nombreFichero, string nombreFichero,
byte[] fichero) byte[] fichero
)
{ {
Id_Fichero = id_Fichero; Id_Fichero = id_Fichero;
Id_Tipo = id_Tipo; Id_Tipo = id_Tipo;
@@ -52,6 +62,8 @@ namespace GestionaDenunciasAN.Models
Id_Denuncia = id_Denuncia; Id_Denuncia = id_Denuncia;
NombreFichero = nombreFichero; NombreFichero = nombreFichero;
Fichero = fichero; 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 public class UserState
{ {
private readonly object _lock = new object(); private readonly object _lock = new object();
private string _token; private string _token = string.Empty;
private string _NombreUsu; private string _NombreUsu = string.Empty;
private bool _Mostrar; private bool _Mostrar;
public string Token public string Token

View File

@@ -1,54 +1,64 @@
using System.Globalization;
using Microsoft.AspNetCore.Authentication.Cookies; using System.Security.Claims;
using System.Text.RegularExpressions;
using GestionaDenunciasAN.Components; using GestionaDenunciasAN.Components;
using GestionaDenunciasAN.Configuration;
using GestionaDenunciasAN.Models; 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); 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() builder.Services.AddRazorComponents()
.AddInteractiveServerComponents(); .AddInteractiveServerComponents();
builder.Services.AddHttpClient("DefaultClient", client => builder.Services.AddCascadingAuthenticationState();
{
client.BaseAddress = new Uri(Utilidades.urlSwagger()); builder.Services
}); .AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
builder.Services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = CookieAuthenticationDefaults.AuthenticationScheme;
})
.AddCookie(options => .AddCookie(options =>
{ {
options.LoginPath = "/home"; options.Cookie.Name = "denuncias.auth";
options.AccessDeniedPath = "/AccessDenied"; options.Cookie.HttpOnly = true;
options.Cookie.SameSite = SameSiteMode.Lax;
options.LoginPath = "/";
options.LogoutPath = "/api/auth/logout";
options.ExpireTimeSpan = TimeSpan.FromHours(8);
options.SlidingExpiration = false;
}); });
// Necesario para ver porqu<71> est<73> fallando ciertas cosas que dan el error Circuit
builder.Services.AddAuthorization();
builder.Services.AddDataProtection();
builder.Services.AddServerSideBlazor().AddCircuitOptions(option => { option.DetailedErrors = true; }); builder.Services.AddServerSideBlazor().AddCircuitOptions(option => { option.DetailedErrors = true; });
builder.Services.AddHttpContextAccessor(); 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.AddBlazorBootstrap();
builder.Services.AddAntiforgery(); 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(); var app = builder.Build();
@@ -58,48 +68,189 @@ if (!app.Environment.IsDevelopment())
app.UseHsts(); app.UseHsts();
} }
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseSession();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseAntiforgery();
app.Use(async (context, next) => app.Use(async (context, next) =>
{ {
var userState = context.RequestServices.GetService<UserState>(); context.Response.Headers.XFrameOptions = "DENY";
var path = context.Request.Path; context.Response.Headers.XContentTypeOptions = "nosniff";
context.Response.Headers["Referrer-Policy"] = "no-referrer";
// Permitir solicitudes internas y recursos necesarios context.Response.Headers.ContentSecurityPolicy =
if (path == "/" || "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";
path.StartsWithSegments("/_blazor") ||
path.StartsWithSegments("/Content") ||
path.StartsWithSegments("/Scripts") ||
path.StartsWithSegments("/js") ||
path.StartsWithSegments("/favicon.ico") ||
path.StartsWithSegments("/_framework"))
{
await next();
return;
}
// Redirigir al home si no hay token y la ruta no es p<>blica
if (userState?.Token == null)
{
Console.WriteLine($"Redirigiendo al home desde: {path}");
context.Response.Redirect("/");
return;
}
// Continuar con la solicitud
await next(); await next();
}); });
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") ||
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;
}
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))
{
return Results.Json(
new ApiError("Demasiados intentos. Espera un minuto."),
statusCode: StatusCodes.Status429TooManyRequests);
}
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>() app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode(); .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": "*", "AllowedHosts": "*",
"SwaggerCC": "https://sw-antifraude.tecnosis.net/api/",
//SWAGGER DESARROLLO "Gestiona": {
//"SwaggerCC": "https://localhost:7135/api/", "ApiBase": "https://02.g3stiona.com",
//SWAGGER PUBLICADO //"AccessToken": "_mZcOqbvtCo2a8zIgjZZA9g__2",
"SwaggerCC": "https://sw-antifraude.tecnosis.net/api/" //"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 { :root {
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; --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 { html,
color: #006bb7; 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 { .btn-primary {
color: #fff; color: #fff;
background-color: #1b6ec2; background: linear-gradient(135deg, var(--app-accent), var(--app-accent-strong));
border-color: #1861ac; border: none;
} }
.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus { .btn-primary:hover,
box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb; .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 { .content {
padding-top: 1.1rem; padding-top: 0;
} }
h1:focus { h1:focus {
@@ -37,15 +489,89 @@ h1:focus {
} }
.blazor-error-boundary { .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; padding: 1rem 1rem 1rem 3.7rem;
color: white; color: white;
} }
.blazor-error-boundary::after { .blazor-error-boundary::after {
content: "An error has occurred." content: "An error has occurred.";
} }
.darker-border-checkbox.form-check-input { .darker-border-checkbox.form-check-input {
border-color: #929292; 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
};
};