Compare commits

..

4 Commits

Author SHA1 Message Date
2b4678c26d - Sustituidas las confirmaciones genéricas del navegador por una ventana propia, responsive y accesible.
- Adaptados los avisos al importar, tramitar, rechazar y cambiar la asignación de denuncias de otros usuarios o grupos.
- Añadidos textos y botones específicos para cada operación.
- Actualizadas las instrucciones, sustituyendo “modal” por “ventana de configuración de la subida”.
- Aclarado el funcionamiento del asunto, grupo de destino y tercero.
2026-07-30 12:35:50 +02:00
8dedafdc07 - Corrige la comparación del hash del report para detectar cambios y comentarios nuevos.
- Elimina la generación de documentos artificiales para comentarios.
- Añade los estados Nueva denuncia, Actualización ciudadano, Actividad OAAF y Actualizada.
- Incorpora circuitos específicos de comunicación SAJ y SDI.
- Registra correctamente el tipo de última subida en el histórico.
- Añade confirmación al asignar una denuncia a un grupo distinto al del usuario.
- Mejora etiquetas, mensajes de ayuda e instrucciones de la aplicación.
- Añade la persistencia técnica necesaria para identificar el origen de cada actualización.
2026-07-29 14:47:35 +02:00
6f9392f3d0 cambiso en denuncias, mejoras de 30 min en inicio de sesion e incorporacion de grupos
Cambiso de certificado en registro de personal para aañadir el origen
2026-07-27 08:46:41 +02:00
dd31460cf0 arreglo certificados de registro 2026-07-23 12:37:06 +02:00
47 changed files with 2729 additions and 236 deletions

View File

@@ -13,6 +13,8 @@ namespace ApiDenuncias.Configuration
public string? CircuitUpdateTemplateName { get; set; }
public string? CircuitUpdateSajTemplateName { get; set; }
public string? CircuitUpdateSdiTemplateName { get; set; }
public string? CircuitCommunicationSajTemplateName { get; set; }
public string? CircuitCommunicationSdiTemplateName { get; set; }
public string? CircuitSignerStampTitle { get; set; }
public string? CircuitVersion { get; set; }
public string? DocumentMetadataLanguage { get; set; }

View File

@@ -304,7 +304,15 @@ public sealed class AuthController : ControllerBase
: session.Username.Trim();
_logger.LogInformation("Login GlobalLeaks validado para {Username}. Guardando sesion cifrada.", username);
await _sessionStore.SaveAsync(username, password, session.Id, session.Role, session.DpopPrivateKey, cancellationToken);
await _sessionStore.SaveAsync(
username,
password,
session.Id,
session.Role,
session.DpopPrivateKey,
session.ProofOfWorkToken,
session.SessionExpiresAtUtc,
cancellationToken);
_logger.LogInformation("Sesion GlobalLeaks guardada para {Username}. Generando JWT.", username);
var expiresAtUtc = DateTimeOffset.UtcNow.AddMinutes(Math.Max(5, _jwtOptions.ExpirationMinutes));

View File

@@ -12,10 +12,14 @@ namespace ApiDenuncias.Controllers;
public sealed class ConfigurationController : ControllerBase
{
private readonly AppConfigurationService _configurationService;
private readonly WorkGroupAdministrationService _workGroupService;
public ConfigurationController(AppConfigurationService configurationService)
public ConfigurationController(
AppConfigurationService configurationService,
WorkGroupAdministrationService workGroupService)
{
_configurationService = configurationService;
_workGroupService = workGroupService;
}
[HttpGet]
@@ -48,4 +52,46 @@ public sealed class ConfigurationController : ControllerBase
return Ok(await _configurationService.SetExternalUpdateCutoffDateAsync(date, cancellationToken));
}
[HttpGet("work-groups")]
[Authorize(Policy = "ConfigurationAdministrators")]
public async Task<ActionResult<WorkGroupAdministrationDto>> GetWorkGroups(
CancellationToken cancellationToken)
{
return Ok(await _workGroupService.GetAsync(cancellationToken));
}
[HttpGet("work-groups/current")]
public async Task<ActionResult<CurrentUserWorkGroupsDto>> GetCurrentUserWorkGroups(
CancellationToken cancellationToken)
{
var username = User.Identity?.Name ??
throw new InvalidOperationException("No hay usuario autenticado.");
return Ok(await _workGroupService.GetUserGroupsAsync(username, cancellationToken));
}
[HttpPut("work-groups/users/{username}")]
[Authorize(Policy = "ConfigurationAdministrators")]
public async Task<ActionResult<WorkGroupAdministrationDto>> SetUserWorkGroups(
string username,
UpdateUserWorkGroupsRequest request,
CancellationToken cancellationToken)
{
var changedBy = User.Identity?.Name ??
throw new InvalidOperationException("No hay usuario autenticado.");
try
{
return Ok(await _workGroupService.UpdateUserGroupsAsync(
username,
request.GroupCodes ?? [],
changedBy,
cancellationToken));
}
catch (InvalidOperationException ex)
{
return BadRequest(new ApiError(ex.Message));
}
}
}

View File

@@ -45,13 +45,22 @@ public sealed class DenunciasController : ControllerBase
[FromQuery] DenunciaListScope scope,
CancellationToken cancellationToken)
{
var allowedIds = await GetAllowedIdsAsync(cancellationToken);
if (allowedIds.Count == 0)
await _denunciaStore.EnsureSchemaAsync(cancellationToken);
var access = await _accessService.GetComplaintAccessAsync(
GetUsername(),
null,
cancellationToken);
if (access.Count == 0)
{
return Ok(new List<DenunciasGestiona>());
}
return Ok(await _filteredDenunciaStore.GetDenunciasByIdsAsync(allowedIds, scope, cancellationToken));
var complaints = await _filteredDenunciaStore.GetDenunciasByIdsAsync(
access.Keys.ToArray(),
scope,
cancellationToken);
ApplyAccessMetadata(complaints, access);
return Ok(complaints);
}
[HttpGet("{denunciaId:int}")]
@@ -62,7 +71,18 @@ public sealed class DenunciasController : ControllerBase
return NotFound();
}
return Ok(await _denunciaStore.GetDenunciaByIdAsync(denunciaId, cancellationToken));
var complaint = await _denunciaStore.GetDenunciaByIdAsync(denunciaId, cancellationToken);
if (complaint is null)
{
return Ok(null);
}
var access = await _accessService.GetComplaintAccessAsync(
GetUsername(),
[denunciaId],
cancellationToken);
ApplyAccessMetadata([complaint], access);
return Ok(complaint);
}
[AllowAnonymous]
@@ -275,6 +295,24 @@ public sealed class DenunciasController : ControllerBase
private string GetUsername()
=> User.Identity?.Name ?? throw new InvalidOperationException("No hay usuario autenticado.");
private static void ApplyAccessMetadata(
IEnumerable<DenunciasGestiona> complaints,
IReadOnlyDictionary<int, ComplaintAccessInfo> access)
{
foreach (var complaint in complaints)
{
if (!access.TryGetValue(complaint.Id_Denuncia, out var item))
{
continue;
}
complaint.OwnerUsername = item.OwnerUsername;
complaint.OwnedByCurrentUser = item.OwnedByCurrentUser;
complaint.RequiresOwnerConfirmation = item.RequiresOwnerConfirmation;
complaint.OwnerWorkGroups = item.OwnerGroupCodes;
}
}
private static GestionaComplaintFieldsResponse ToGestionaComplaintFields(DenunciasGestiona denuncia)
{
var preferenciaNotificacion = ResolveNotificationPreference(denuncia);

View File

@@ -194,7 +194,8 @@ public sealed class GestionaController : ControllerBase
request.DocumentUrl,
request.AssignedGroupCode,
request.ComplaintId,
request.IsUpdate);
request.IsUpdate,
request.UpdateSource);
return Ok(new { ok = true });
}

View File

@@ -20,6 +20,7 @@ public sealed class InboxController : ControllerBase
private readonly GlobalLeaksSessionStore _sessionStore;
private readonly PendingGlobalLeaksLoginStore _pendingLoginStore;
private readonly GlobalLeaksClient _globalLeaksClient;
private readonly GlobalLeaksSessionKeepAliveService _sessionKeepAliveService;
private readonly DenunciaInboxService _inboxService;
private readonly IInboxTrackingService _trackingService;
private readonly ILogger<InboxController> _logger;
@@ -28,6 +29,7 @@ public sealed class InboxController : ControllerBase
GlobalLeaksSessionStore sessionStore,
PendingGlobalLeaksLoginStore pendingLoginStore,
GlobalLeaksClient globalLeaksClient,
GlobalLeaksSessionKeepAliveService sessionKeepAliveService,
DenunciaInboxService inboxService,
IInboxTrackingService trackingService,
ILogger<InboxController> logger)
@@ -35,6 +37,7 @@ public sealed class InboxController : ControllerBase
_sessionStore = sessionStore;
_pendingLoginStore = pendingLoginStore;
_globalLeaksClient = globalLeaksClient;
_sessionKeepAliveService = sessionKeepAliveService;
_inboxService = inboxService;
_trackingService = trackingService;
_logger = logger;
@@ -131,7 +134,14 @@ public sealed class InboxController : ControllerBase
cancellationToken);
}
await _sessionStore.UpdateSessionAsync(username, session.Id, session.Role, session.DpopPrivateKey, cancellationToken);
await _sessionStore.UpdateSessionAsync(
username,
session.Id,
session.Role,
session.DpopPrivateKey,
session.ProofOfWorkToken,
session.SessionExpiresAtUtc,
cancellationToken);
var stored = await _sessionStore.GetAsync(username, cancellationToken);
return Ok(ToDto(stored));
}
@@ -152,6 +162,45 @@ public sealed class InboxController : ControllerBase
}
}
[HttpPost("session/keepalive")]
public async Task<ActionResult<ApiGlobalLeaksSessionDto?>> KeepSessionAlive(
CancellationToken cancellationToken)
{
var username = GetUsername();
try
{
var session = await _sessionKeepAliveService.KeepAliveAsync(username, cancellationToken);
return Ok(ToDto(session));
}
catch (GlobalLeaksSessionExpiredException)
{
var session = await _sessionStore.GetAsync(username, cancellationToken);
return Ok(ToDto(session));
}
catch (GlobalLeaksValidationException ex)
{
_logger.LogWarning(
"No se ha podido mantener activa la sesion GlobalLeaks de {Username}. Status={StatusCode}. Mensaje={Message}",
username,
ex.StatusCode,
ex.Message);
return StatusCode(
ex.StatusCode is >= 500 and <= 504
? StatusCodes.Status503ServiceUnavailable
: StatusCodes.Status502BadGateway,
new ApiError("No se ha podido renovar temporalmente la sesion de GlobalLeaks."));
}
catch (Exception ex)
{
_logger.LogError(ex, "Error renovando la sesion GlobalLeaks de {Username}.", username);
return StatusCode(
StatusCodes.Status503ServiceUnavailable,
new ApiError("No se ha podido renovar temporalmente la sesion de GlobalLeaks."));
}
}
[HttpPost("session/clear")]
public async Task<IActionResult> ClearSession(CancellationToken cancellationToken)
{
@@ -222,13 +271,15 @@ public sealed class InboxController : ControllerBase
return Unauthorized(new ApiError("La sesion de GlobalLeaks ha caducado. Renueva el 2FA.", SessionExpired: true));
}
var report = string.IsNullOrWhiteSpace(request.Report.Id)
? request.Report with { Id = reportId }
: request.Report;
var report = request.Report with { Id = reportId };
try
{
await _trackingService.EnsureReportCanBeImportedByUserAsync(username, report, cancellationToken);
await _trackingService.EnsureReportCanBeImportedByUserAsync(
username,
report,
request.ConfirmDifferentOwner,
cancellationToken);
ReportDetailDto? reportDetail = null;
try
@@ -265,7 +316,12 @@ public sealed class InboxController : ControllerBase
json = null;
}
var result = await _inboxService.ImportFromGlobalLeaksAsync(reportPackage, json, reportDetail, cancellationToken);
var result = await _inboxService.ImportFromGlobalLeaksAsync(
reportPackage,
json,
reportDetail,
report,
cancellationToken);
if (result.ImportedCount > 0)
{
await _trackingService.MarkReportImportedAsync(
@@ -286,6 +342,10 @@ public sealed class InboxController : ControllerBase
{
return ToGlobalLeaksApiError(ex, "importar la denuncia");
}
catch (ReportOwnershipException ex)
{
return Conflict(new ApiError(ex.Message));
}
catch (Exception ex)
{
_logger.LogError(ex, "No se ha podido importar la denuncia {ReportId} para {Username}.", reportId, username);

View File

@@ -52,8 +52,19 @@ public sealed class TrackingController : ControllerBase
TrackingImportPermissionRequest request,
CancellationToken cancellationToken)
{
await _trackingService.EnsureReportCanBeImportedByUserAsync(GetUsername(), request.Report, cancellationToken);
return Ok(new { ok = true });
try
{
await _trackingService.EnsureReportCanBeImportedByUserAsync(
GetUsername(),
request.Report,
request.ConfirmDifferentOwner,
cancellationToken);
return Ok(new { ok = true });
}
catch (ReportOwnershipException ex)
{
return Conflict(new ApiError(ex.Message));
}
}
private string GetUsername()

View File

@@ -32,6 +32,7 @@ builder.Services.AddSingleton<LoginRateLimiter>();
builder.Services.AddSingleton<GlobalLeaksSessionStore>();
builder.Services.AddSingleton<PendingGlobalLeaksLoginStore>();
builder.Services.AddScoped<GlobalLeaksClient>();
builder.Services.AddScoped<GlobalLeaksSessionKeepAliveService>();
builder.Services.AddSingleton<MySqlConnectionStringProvider>();
builder.Services.AddScoped<MySqlDenunciaStore>();
builder.Services.AddSingleton<IEncryptionKeyProvider, KeyVaultEncryptionKeyProvider>();
@@ -44,6 +45,7 @@ builder.Services.AddScoped<DenunciaInboxService>();
builder.Services.AddScoped<GestionaDocumentWorkflowService>();
builder.Services.AddScoped<GestionaExpedienteExceptionStore>();
builder.Services.AddScoped<UserComplaintAccessService>();
builder.Services.AddScoped<WorkGroupAdministrationService>();
builder.Services.AddHttpClient<ManualPurgeService>();
builder.Services.AddScoped<AppConfigurationService>();

View File

@@ -66,6 +66,7 @@ CREATE TABLE IF NOT EXISTS complaints (
gestiona_uploaded_at_utc DATETIME(6) NULL,
gestiona_last_upload_type TEXT NOT NULL,
gestiona_assigned_group TEXT NOT NULL,
pending_update_source VARCHAR(256) NOT NULL DEFAULT '',
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),
@@ -87,6 +88,58 @@ CREATE TABLE IF NOT EXISTS app_users (
UNIQUE KEY uq_app_users_username (username)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS work_groups (
id BIGINT NOT NULL AUTO_INCREMENT,
code VARCHAR(32) NOT NULL,
name VARCHAR(256) NOT NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
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_work_groups_code (code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
INSERT INTO work_groups (code, name)
VALUES
('600', 'Asuntos Juridicos y Proteccion a la Persona Denunciante'),
('510', 'SDI - Investigacion Entradas')
ON DUPLICATE KEY UPDATE
name = VALUES(name),
is_active = 1;
CREATE TABLE IF NOT EXISTS app_user_groups (
app_user_id BIGINT NOT NULL,
work_group_id BIGINT NOT NULL,
assigned_at_utc DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
assigned_by_username VARCHAR(256) NOT NULL DEFAULT '',
PRIMARY KEY (app_user_id, work_group_id),
KEY ix_app_user_groups_group (work_group_id),
CONSTRAINT fk_app_user_groups_user
FOREIGN KEY (app_user_id) REFERENCES app_users(id)
ON DELETE CASCADE,
CONSTRAINT fk_app_user_groups_group
FOREIGN KEY (work_group_id) REFERENCES work_groups(id)
ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS app_user_group_history (
id BIGINT NOT NULL AUTO_INCREMENT,
app_user_id BIGINT NOT NULL,
work_group_id BIGINT NOT NULL,
action VARCHAR(16) NOT NULL,
changed_by_username VARCHAR(256) NOT NULL,
changed_at_utc DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
PRIMARY KEY (id),
KEY ix_app_user_group_history_user (app_user_id),
KEY ix_app_user_group_history_group (work_group_id),
CONSTRAINT fk_app_user_group_history_user
FOREIGN KEY (app_user_id) REFERENCES app_users(id)
ON DELETE CASCADE,
CONSTRAINT fk_app_user_group_history_group
FOREIGN KEY (work_group_id) REFERENCES work_groups(id)
ON DELETE CASCADE
) 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,
@@ -104,6 +157,7 @@ CREATE TABLE IF NOT EXISTS inbox_reports (
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,
owner_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),
@@ -112,8 +166,12 @@ CREATE TABLE IF NOT EXISTS inbox_reports (
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),
KEY ix_inbox_reports_owner (owner_user_id),
CONSTRAINT fk_inbox_reports_last_user
FOREIGN KEY (last_downloaded_by_user_id) REFERENCES app_users(id)
ON DELETE SET NULL,
CONSTRAINT fk_inbox_reports_owner_user
FOREIGN KEY (owner_user_id) REFERENCES app_users(id)
ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

View File

@@ -84,6 +84,7 @@ public sealed class DenunciaInboxService
FileDownloadResult reportDownload,
FileDownloadResult? jsonDownload,
ReportDetailDto? reportDetail,
ReportDto inboxReport,
CancellationToken cancellationToken = default)
{
await EnsureStorageReadyAsync(cancellationToken);
@@ -102,7 +103,13 @@ public sealed class DenunciaInboxService
try
{
var result = await ProcessGlobalLeaksPackageAsync(reportDownload.Content, sourceName, json, reportDetail, cancellationToken);
var result = await ProcessGlobalLeaksPackageAsync(
reportDownload.Content,
sourceName,
json,
reportDetail,
inboxReport,
cancellationToken);
return new ImportSummary(
1,
result.ImportedCount,
@@ -164,6 +171,7 @@ public sealed class DenunciaInboxService
string sourceName,
string? globalLeaksJson,
ReportDetailDto? reportDetail,
ReportDto inboxReport,
CancellationToken cancellationToken)
{
using var packageStream = new MemoryStream(packageBytes, writable: false);
@@ -214,6 +222,12 @@ public sealed class DenunciaInboxService
$"No se ha podido determinar el identificador de la denuncia en {sourceName}.");
}
denuncia.PendingUpdateSource = inboxReport.CitizenHasNewActivity
? ComplaintUpdateSources.Citizen
: inboxReport.ReceiverHasNewActivity
? ComplaintUpdateSources.Receiver
: string.Empty;
if (reportIsPdf)
{
reportText = BuildSyntheticReportText(denuncia);
@@ -246,14 +260,17 @@ public sealed class DenunciaInboxService
var storedComplaint = await _denunciaStore.GetDenunciaByIdAsync(denuncia.Id_Denuncia, cancellationToken);
if (IsAlreadyUploadedToGestiona(storedComplaint))
{
var storedFiles = await GetFicherosMetadataByDenunciaForImportAsync(denuncia.Id_Denuncia, cancellationToken);
var storedFiles = await GetFicherosMetadataByDenunciaForImportAsync(
denuncia.Id_Denuncia,
cancellationToken);
if (!HasPendingFilesForGestiona(storedFiles))
{
storedComplaint!.EsActualizacion = false;
storedComplaint.EnGestiona = true;
storedComplaint.PendingUpdateSource = string.Empty;
await _denunciaStore.UpsertDenunciaAsync(storedComplaint, cancellationToken);
warnings.Add($"La denuncia #{denuncia.Id_Denuncia} ya esta en Gestiona y no tiene documentos nuevos pendientes de subir.");
warnings.Add(BuildNoCitizenUpdateWarning(inboxReport));
return new ProcessPackageResult(denuncia.Id_Denuncia, 0, warnings);
}
}
@@ -379,6 +396,13 @@ public sealed class DenunciaInboxService
}
}
private static string BuildNoCitizenUpdateWarning(ReportDto inboxReport)
{
return inboxReport.ReceiverHasNewActivity && !inboxReport.CitizenHasNewActivity
? "La actividad detectada procede de la OAAF, pero el report descargado no contiene cambios pendientes de subir a Gestiona."
: "La denuncia ya esta en Gestiona y el report no contiene documentos ni comentarios nuevos del ciudadano pendientes de subir.";
}
private static bool HasPendingFilesForGestiona(IReadOnlyList<FicherosDenuncias> files)
{
var plannedHashes = files
@@ -591,6 +615,7 @@ public sealed class DenunciaInboxService
target.Pais = source.Pais;
target.CamposFormularioJson = source.CamposFormularioJson;
target.TextoOriginalReport = source.TextoOriginalReport;
target.PendingUpdateSource = source.PendingUpdateSource;
}
private static bool IsSupportedAttachmentEntry(ZipArchiveEntry entry)

View File

@@ -280,6 +280,7 @@ public sealed class EncryptedDenunciaStore : IDenunciaStore, IFilteredDenunciaSt
FechaSubidaAGestiona = source.FechaSubidaAGestiona,
UltimaSubidaGestionaTipo = source.UltimaSubidaGestionaTipo,
UltimoGrupoAsignadoGestiona = source.UltimoGrupoAsignadoGestiona,
PendingUpdateSource = source.PendingUpdateSource,
EnGestiona = source.EnGestiona,
EnRechazada = source.EnRechazada,
@@ -361,6 +362,7 @@ public sealed class EncryptedDenunciaStore : IDenunciaStore, IFilteredDenunciaSt
target.FechaSubidaAGestiona = stored.FechaSubidaAGestiona;
target.UltimaSubidaGestionaTipo = stored.UltimaSubidaGestionaTipo;
target.UltimoGrupoAsignadoGestiona = stored.UltimoGrupoAsignadoGestiona;
target.PendingUpdateSource = stored.PendingUpdateSource;
target.EnGestiona = stored.EnGestiona;
target.EnRechazada = stored.EnRechazada;
}

View File

@@ -3,6 +3,7 @@ using System.Security.Cryptography;
using System.Globalization;
using System.Text;
using System.Text.Json;
using GestionaDenuncias.Shared.Models;
using Microsoft.Extensions.Logging;
namespace ApiDenuncias.Services;
@@ -124,11 +125,20 @@ public sealed class GestionaDocumentWorkflowService
string documentUrl,
string assignedGroupCode,
int? complaintId = null,
bool isUpdate = false)
bool isUpdate = false,
string? updateSource = null)
{
var docUrlAbs = EnsureAbsoluteGestionaUrl(documentUrl, GestionaApiBase);
var operationLabel = isUpdate ? $"actualizacion grupo {NormalizeGroupCode(assignedGroupCode)}" : "nueva denuncia";
var (templateHref, payload) = await ResolveCircuitTemplatePayloadAsync(docUrlAbs, isUpdate, assignedGroupCode);
var operationLabel = ComplaintUpdateSources.IsReceiver(updateSource)
? $"comunicacion OAAF grupo {NormalizeGroupCode(assignedGroupCode)}"
: isUpdate
? $"actualizacion grupo {NormalizeGroupCode(assignedGroupCode)}"
: "nueva denuncia";
var (templateHref, payload) = await ResolveCircuitTemplatePayloadAsync(
docUrlAbs,
isUpdate,
assignedGroupCode,
updateSource);
var (success, statusCode, body) = await TryPostCircuitAsync(docUrlAbs, payload);
if (success)
{
@@ -155,7 +165,8 @@ public sealed class GestionaDocumentWorkflowService
private async Task<(string TemplateHref, string Payload)> ResolveCircuitTemplatePayloadAsync(
string documentUrl,
bool isUpdate,
string? assignedGroupCode)
string? assignedGroupCode,
string? updateSource)
{
var templatesUrl = $"{documentUrl.TrimEnd('/')}/circuit/templates";
var templates = await GetCircuitTemplatesAsync(templatesUrl);
@@ -164,7 +175,7 @@ public sealed class GestionaDocumentWorkflowService
throw new InvalidOperationException("Gestiona no ha devuelto plantillas de circuito para el documento.");
}
var selection = GetOperationCircuitTemplateSelection(isUpdate, assignedGroupCode);
var selection = GetOperationCircuitTemplateSelection(isUpdate, assignedGroupCode, updateSource);
if (!string.IsNullOrWhiteSpace(selection.TemplateName))
{
var resolved = await TryResolveCircuitTemplatePayloadByNameAsync(templates, selection.TemplateName);
@@ -211,7 +222,10 @@ public sealed class GestionaDocumentWorkflowService
"No se puede seleccionar de forma inequivoca la plantilla de circuito. Configura Gestiona:CircuitTemplateName o Gestiona:CircuitSignerStampTitle.");
}
private CircuitTemplateSelection GetOperationCircuitTemplateSelection(bool isUpdate, string? assignedGroupCode)
private CircuitTemplateSelection GetOperationCircuitTemplateSelection(
bool isUpdate,
string? assignedGroupCode,
string? updateSource)
{
if (!isUpdate)
{
@@ -221,6 +235,25 @@ public sealed class GestionaDocumentWorkflowService
OperationLabel: "nueva denuncia");
}
if (ComplaintUpdateSources.IsReceiver(updateSource))
{
return NormalizeGroupCode(assignedGroupCode) switch
{
"510" => new CircuitTemplateSelection(
_configuration["Gestiona:CircuitCommunicationSdiTemplateName"],
Required: true,
OperationLabel: "comunicacion SDI a denunciante"),
"600" => new CircuitTemplateSelection(
_configuration["Gestiona:CircuitCommunicationSajTemplateName"],
Required: true,
OperationLabel: "comunicacion SAJ a denunciante"),
_ => throw new InvalidOperationException(
"Las comunicaciones de la OAAF solo pueden tramitarse con los grupos 510 o 600.")
};
}
var defaultUpdateTemplateName = FirstConfigured(
_configuration["Gestiona:CircuitTemplateName"],
_configuration["Gestiona:CircuitUpdateTemplateName"]);

View File

@@ -16,6 +16,10 @@ namespace ApiDenuncias.Services;
public sealed record PreparedGlobalLeaksCredentials(string Username, string FinalPassword, string TokenAnswer);
public sealed record RefreshedGlobalLeaksSession(
GlobalLeaksProofOfWorkToken ProofOfWorkToken,
DateTimeOffset? SessionExpiresAtUtc);
public sealed class GlobalLeaksClient
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
@@ -197,6 +201,30 @@ public sealed class GlobalLeaksClient
throw new GlobalLeaksValidationException("Login fallido: no se pudo completar la autenticacion.", 502);
}
public async Task<RefreshedGlobalLeaksSession> RefreshSessionAsync(
string sessionId,
string dpopPrivateKey,
GlobalLeaksProofOfWorkToken proofOfWorkToken,
CancellationToken cancellationToken = default)
{
const string path = "/api/auth/session";
var tokenAnswer = SolveProofOfWork(
proofOfWorkToken.Id,
proofOfWorkToken.Salt,
cancellationToken);
using var request = CreateAuthenticatedRequest(
HttpMethod.Post,
path,
sessionId,
dpopPrivateKey);
request.Content = CreateJsonContent(new { token = tokenAnswer });
using var response = await SendGlRequestAsync(request, cancellationToken);
var body = await response.Content.ReadAsStringAsync(cancellationToken);
return ParseSessionRefresh(body);
}
private async Task<string> PrepareProofOfWorkAsync(string username, CancellationToken cancellationToken)
{
using var tokenRequest = CreateRequest(HttpMethod.Post, "/api/auth/token");
@@ -558,6 +586,14 @@ public sealed class GlobalLeaksClient
reference,
defaultNewWhenNoReference: false,
"rfiles");
var receivers = ParseReportReceivers(document.RootElement);
var receiverNames = BuildReceiverNameLookup(receivers);
comments = comments
.Select(comment => WithAuthorName(comment, receiverNames))
.ToArray();
receiverFiles = receiverFiles
.Select(file => WithAuthorName(file, receiverNames))
.ToArray();
var citizenCommentDates = comments
.Where(comment => IsWhistleblowerActivityType(comment.Type))
@@ -590,7 +626,24 @@ public sealed class GlobalLeaksClient
: citizenCommentDates.Concat(citizenFileDates).Append(reportCreationDate.Value);
var citizenLast = MaxDate(citizenActivityDates);
var receiverLast = MaxDate(receiverCommentDates.Concat(receiverFileDates).Concat(unclassifiedCommentDates));
var receiverEvents = comments
.Where(comment =>
IsReceiverActivityType(comment.Type) ||
IsUnclassifiedActivityType(comment.Type))
.Select(comment => new ActivityActorEvent(
ParseDate(comment.CreationDate),
comment.AuthorId,
comment.AuthorName))
.Concat(receiverFiles.Select(file => new ActivityActorEvent(
ParseDate(file.CreationDate),
file.AuthorId,
file.AuthorName)))
.Where(item => item.Date is not null)
.OrderByDescending(item => item.Date)
.ToArray();
var latestReceiverEvent = receiverEvents.FirstOrDefault();
var receiverLast = latestReceiverEvent?.Date ??
MaxDate(receiverCommentDates.Concat(receiverFileDates).Concat(unclassifiedCommentDates));
return new ReportActivitySnapshot(
citizenLast,
@@ -605,7 +658,9 @@ public sealed class GlobalLeaksClient
comments.Any(comment =>
IsUnclassifiedActivityType(comment.Type) &&
comment.IsNew) ||
receiverFiles.Any(file => file.IsNew));
receiverFiles.Any(file => file.IsNew),
latestReceiverEvent?.AuthorId,
latestReceiverEvent?.AuthorName);
}
private static ReportDto ApplyActivity(ReportDto report, ReportActivitySnapshot activity)
@@ -618,7 +673,9 @@ public sealed class GlobalLeaksClient
CitizenHasNewComment = activity.HasNewCitizenComment,
CitizenHasNewFile = activity.HasNewCitizenFile,
ReceiverLastActivity = activity.ReceiverLastActivity?.ToString("O", CultureInfo.InvariantCulture),
ReceiverHasNewActivity = activity.HasNewReceiverActivity
ReceiverHasNewActivity = activity.HasNewReceiverActivity,
ReceiverLastActivityAuthorId = activity.ReceiverLastActivityAuthorId,
ReceiverLastActivityAuthorName = activity.ReceiverLastActivityAuthorName
};
}
@@ -893,6 +950,15 @@ public sealed class GlobalLeaksClient
private static DateTimeOffset? GetActivityReference(ReportDto report, DateTimeOffset? fallbackReference)
{
if (report.AlreadyInGestiona)
{
var lastGestionaUpload = ParseDate(report.LastGestionaUploadAt);
if (lastGestionaUpload is not null)
{
return lastGestionaUpload;
}
}
return ParseDate(report.LastDownloadedAt) ??
fallbackReference ??
ParseDate(report.LastAccess) ??
@@ -1043,10 +1109,32 @@ public sealed class GlobalLeaksClient
.Select(item => CreateReportComment(item, lastAccessDate, defaultNewWhenNoReference: true))
.ToArray();
var whistleblowerFiles = ParseReportFiles(root, lastAccessDate, defaultNewWhenNoReference: true, "wbfiles", "files");
var receiverFiles = ParseReportFiles(root, lastAccessDate, defaultNewWhenNoReference: true, "rfiles");
var receivers = ParseReportReceivers(root);
var receiverNames = BuildReceiverNameLookup(receivers);
comments = comments
.Select(comment => WithAuthorName(comment, receiverNames))
.ToArray();
var whistleblowerFiles = ParseReportFiles(
root,
lastAccessDate,
defaultNewWhenNoReference: true,
"wbfiles",
"files");
var receiverFiles = ParseReportFiles(
root,
lastAccessDate,
defaultNewWhenNoReference: true,
"rfiles")
.Select(file => WithAuthorName(file, receiverNames))
.ToArray();
return new ReportDetailDto(reportId, lastAccess, comments, whistleblowerFiles, receiverFiles);
return new ReportDetailDto(
reportId,
lastAccess,
comments,
whistleblowerFiles,
receiverFiles,
receivers);
}
private static ReportCommentDto CreateReportComment(
@@ -1055,12 +1143,25 @@ public sealed class GlobalLeaksClient
bool defaultNewWhenNoReference)
{
var creationDate = GetString(item, "creation_date", "creationDate", "date", "created_at", "createdAt");
var authorId = GetString(item, "author_id", "authorId");
var activityType = GetCommentActivityType(item);
if (!string.IsNullOrWhiteSpace(authorId))
{
activityType = "receiver";
}
else if (string.IsNullOrWhiteSpace(activityType) ||
IsUnclassifiedActivityType(activityType))
{
activityType = "whistleblower";
}
return new ReportCommentDto(
GetString(item, "id"),
GetCommentActivityType(item),
activityType,
GetString(item, "content", "text", "message"),
creationDate,
IsAfterReference(creationDate, reference, defaultNewWhenNoReference));
IsAfterReference(creationDate, reference, defaultNewWhenNoReference),
authorId);
}
private static string? GetCommentActivityType(JsonElement item)
@@ -1196,13 +1297,62 @@ public sealed class GlobalLeaksClient
GetString(item, "id"),
GetLocalizedString(item, "name", "file_name", "filename"),
GetInt64(item, "size"),
GetString(item, "content_type", "contentType", "mime_type", "mimetype"),
GetString(item, "content_type", "contentType", "mime_type", "mimetype", "type"),
creationDate,
IsAfterReference(creationDate, reference, defaultNewWhenNoReference));
IsAfterReference(creationDate, reference, defaultNewWhenNoReference),
GetString(item, "author_id", "authorId"));
})
.ToArray();
}
private static ReportReceiverDto[] ParseReportReceivers(JsonElement root)
{
return EnumerateArray(root, "receivers", "recipients")
.Select(item => new ReportReceiverDto(
GetString(item, "id") ?? string.Empty,
GetLocalizedString(item, "name", "display_name", "displayName", "username") ?? "Gestor",
GetBool(item, "active", "is_active", "isActive")))
.Where(receiver => !string.IsNullOrWhiteSpace(receiver.Id))
.ToArray();
}
private static IReadOnlyDictionary<string, string> BuildReceiverNameLookup(
IEnumerable<ReportReceiverDto> receivers)
{
return receivers
.GroupBy(receiver => receiver.Id, StringComparer.OrdinalIgnoreCase)
.ToDictionary(
group => group.Key,
group => group.First().Name,
StringComparer.OrdinalIgnoreCase);
}
private static ReportCommentDto WithAuthorName(
ReportCommentDto comment,
IReadOnlyDictionary<string, string> receiverNames)
{
if (string.IsNullOrWhiteSpace(comment.AuthorId) ||
!receiverNames.TryGetValue(comment.AuthorId, out var authorName))
{
return comment;
}
return comment with { AuthorName = authorName };
}
private static ReportFileDto WithAuthorName(
ReportFileDto file,
IReadOnlyDictionary<string, string> receiverNames)
{
if (string.IsNullOrWhiteSpace(file.AuthorId) ||
!receiverNames.TryGetValue(file.AuthorId, out var authorName))
{
return file;
}
return file with { AuthorName = authorName };
}
private static bool IsAfterReference(
string? value,
DateTimeOffset? reference,
@@ -1416,8 +1566,84 @@ public sealed class GlobalLeaksClient
}
var role = GetString(root, "role", "user_role", "userRole");
var proofOfWorkToken = ParseProofOfWorkToken(root);
var sessionExpiresAtUtc = ParseSessionExpiration(root);
return new GlSession(id, username, role, dpopPrivateKey);
return new GlSession(
id,
username,
role,
dpopPrivateKey,
proofOfWorkToken,
sessionExpiresAtUtc);
}
private static RefreshedGlobalLeaksSession ParseSessionRefresh(string body)
{
using var document = JsonDocument.Parse(body);
var root = document.RootElement;
var proofOfWorkToken = ParseProofOfWorkToken(root)
?? throw new GlobalLeaksValidationException(
"GlobalLeaks no devolvio el reto necesario para mantener activa la sesion.",
StatusCodes.Status502BadGateway);
return new RefreshedGlobalLeaksSession(
proofOfWorkToken,
ParseSessionExpiration(root));
}
private static GlobalLeaksProofOfWorkToken? ParseProofOfWorkToken(JsonElement root)
{
if (!root.TryGetProperty("token", out var token) ||
token.ValueKind != JsonValueKind.Object)
{
return null;
}
var id = GetString(token, "id");
var salt = GetString(token, "salt");
return string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(salt)
? null
: new GlobalLeaksProofOfWorkToken(id, salt);
}
private static DateTimeOffset? ParseSessionExpiration(JsonElement root)
{
if (!root.TryGetProperty("session_expiration", out var expiration))
{
return null;
}
double unixSeconds;
if (expiration.ValueKind == JsonValueKind.Number)
{
if (!expiration.TryGetDouble(out unixSeconds))
{
return null;
}
}
else if (expiration.ValueKind == JsonValueKind.String)
{
if (!double.TryParse(
expiration.GetString(),
NumberStyles.Float,
CultureInfo.InvariantCulture,
out unixSeconds))
{
return null;
}
}
else
{
return null;
}
if (unixSeconds <= 0 || unixSeconds > DateTimeOffset.MaxValue.ToUnixTimeSeconds())
{
return null;
}
return DateTimeOffset.FromUnixTimeMilliseconds((long)(unixSeconds * 1000));
}
private static async Task EnsureSuccessOrThrowAsync(
@@ -1482,7 +1708,14 @@ public sealed class GlobalLeaksClient
bool HasNewCitizenComment,
bool HasNewCitizenFile,
DateTimeOffset? ReceiverLastActivity,
bool HasNewReceiverActivity);
bool HasNewReceiverActivity,
string? ReceiverLastActivityAuthorId,
string? ReceiverLastActivityAuthorName);
private sealed record ActivityActorEvent(
DateTimeOffset? Date,
string? AuthorId,
string? AuthorName);
private sealed record RawReport
{

View File

@@ -0,0 +1,83 @@
using System.Collections.Concurrent;
using GestionaDenuncias.Shared.Models;
namespace ApiDenuncias.Services;
public sealed class GlobalLeaksSessionKeepAliveService
{
private static readonly TimeSpan MinimumRefreshInterval = TimeSpan.FromSeconds(20);
private static readonly ConcurrentDictionary<string, SemaphoreSlim> UserGates =
new(StringComparer.OrdinalIgnoreCase);
private readonly GlobalLeaksSessionStore _sessionStore;
private readonly GlobalLeaksClient _globalLeaksClient;
private readonly ILogger<GlobalLeaksSessionKeepAliveService> _logger;
public GlobalLeaksSessionKeepAliveService(
GlobalLeaksSessionStore sessionStore,
GlobalLeaksClient globalLeaksClient,
ILogger<GlobalLeaksSessionKeepAliveService> logger)
{
_sessionStore = sessionStore;
_globalLeaksClient = globalLeaksClient;
_logger = logger;
}
public async Task<GlobalLeaksStoredSession?> KeepAliveAsync(
string username,
CancellationToken cancellationToken = default)
{
var gate = UserGates.GetOrAdd(username, static _ => new SemaphoreSlim(1, 1));
await gate.WaitAsync(cancellationToken);
try
{
var session = await _sessionStore.GetAsync(username, cancellationToken);
if (session?.HasActiveSession != true)
{
return session;
}
if (session.LastKeepAliveAtUtc is { } lastKeepAlive &&
DateTimeOffset.UtcNow - lastKeepAlive < MinimumRefreshInterval)
{
return session;
}
var expectedSessionId = session.SessionId!;
try
{
var refreshed = await _globalLeaksClient.RefreshSessionAsync(
expectedSessionId,
session.DpopPrivateKey!,
session.ProofOfWorkToken!,
cancellationToken);
var updated = await _sessionStore.UpdateKeepAliveAsync(
username,
expectedSessionId,
refreshed.ProofOfWorkToken,
refreshed.SessionExpiresAtUtc,
cancellationToken);
if (updated)
{
_logger.LogDebug("Sesion GlobalLeaks renovada para {Username}.", username);
}
return await _sessionStore.GetAsync(username, cancellationToken);
}
catch (GlobalLeaksSessionExpiredException)
{
await _sessionStore.ClearSessionIfMatchesAsync(
username,
expectedSessionId,
cancellationToken);
throw;
}
}
finally
{
gate.Release();
}
}
}

View File

@@ -30,18 +30,10 @@ public sealed class GlobalLeaksSessionStore
}
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);
return await ReadUnsafeAsync(path, cancellationToken);
}
finally
{
@@ -55,8 +47,11 @@ public sealed class GlobalLeaksSessionStore
string sessionId,
string? role,
string? dpopPrivateKey,
GlobalLeaksProofOfWorkToken? proofOfWorkToken,
DateTimeOffset? sessionExpiresAtUtc,
CancellationToken cancellationToken = default)
{
var now = DateTimeOffset.UtcNow;
var data = new GlobalLeaksStoredSession
{
Username = username,
@@ -64,7 +59,10 @@ public sealed class GlobalLeaksSessionStore
SessionId = sessionId,
Role = role,
DpopPrivateKey = dpopPrivateKey,
UpdatedAt = DateTimeOffset.UtcNow,
ProofOfWorkToken = proofOfWorkToken,
SessionExpiresAtUtc = sessionExpiresAtUtc,
LastKeepAliveAtUtc = now,
UpdatedAt = now,
};
await WriteAsync(data, cancellationToken);
@@ -75,31 +73,115 @@ public sealed class GlobalLeaksSessionStore
string sessionId,
string? role,
string? dpopPrivateKey,
GlobalLeaksProofOfWorkToken? proofOfWorkToken,
DateTimeOffset? sessionExpiresAtUtc,
CancellationToken cancellationToken = default)
{
var current = await GetAsync(username, cancellationToken)
?? throw new InvalidOperationException("No hay credenciales guardadas para este usuario.");
var path = GetFilePath(username);
current.SessionId = sessionId;
current.Role = role;
current.DpopPrivateKey = dpopPrivateKey;
current.UpdatedAt = DateTimeOffset.UtcNow;
await _gate.WaitAsync(cancellationToken);
try
{
var current = await ReadUnsafeAsync(path, cancellationToken)
?? throw new InvalidOperationException("No hay credenciales guardadas para este usuario.");
var now = DateTimeOffset.UtcNow;
await WriteAsync(current, cancellationToken);
current.SessionId = sessionId;
current.Role = role;
current.DpopPrivateKey = dpopPrivateKey;
current.ProofOfWorkToken = proofOfWorkToken;
current.SessionExpiresAtUtc = sessionExpiresAtUtc;
current.LastKeepAliveAtUtc = now;
current.UpdatedAt = now;
await WriteUnsafeAsync(path, current, cancellationToken);
}
finally
{
_gate.Release();
}
}
public async Task<bool> UpdateKeepAliveAsync(
string username,
string expectedSessionId,
GlobalLeaksProofOfWorkToken proofOfWorkToken,
DateTimeOffset? sessionExpiresAtUtc,
CancellationToken cancellationToken = default)
{
var path = GetFilePath(username);
await _gate.WaitAsync(cancellationToken);
try
{
var current = await ReadUnsafeAsync(path, cancellationToken);
if (current is null ||
!string.Equals(current.SessionId, expectedSessionId, StringComparison.Ordinal))
{
return false;
}
var now = DateTimeOffset.UtcNow;
current.ProofOfWorkToken = proofOfWorkToken;
current.SessionExpiresAtUtc = sessionExpiresAtUtc;
current.LastKeepAliveAtUtc = now;
current.UpdatedAt = now;
await WriteUnsafeAsync(path, current, cancellationToken);
return true;
}
finally
{
_gate.Release();
}
}
public async Task ClearSessionAsync(string username, CancellationToken cancellationToken = default)
{
var current = await GetAsync(username, cancellationToken);
if (current is null)
{
return;
}
var path = GetFilePath(username);
current.SessionId = null;
current.DpopPrivateKey = null;
current.UpdatedAt = DateTimeOffset.UtcNow;
await WriteAsync(current, cancellationToken);
await _gate.WaitAsync(cancellationToken);
try
{
var current = await ReadUnsafeAsync(path, cancellationToken);
if (current is null)
{
return;
}
ClearSessionValues(current);
await WriteUnsafeAsync(path, current, cancellationToken);
}
finally
{
_gate.Release();
}
}
public async Task<bool> ClearSessionIfMatchesAsync(
string username,
string expectedSessionId,
CancellationToken cancellationToken = default)
{
var path = GetFilePath(username);
await _gate.WaitAsync(cancellationToken);
try
{
var current = await ReadUnsafeAsync(path, cancellationToken);
if (current is null ||
!string.Equals(current.SessionId, expectedSessionId, StringComparison.Ordinal))
{
return false;
}
ClearSessionValues(current);
await WriteUnsafeAsync(path, current, cancellationToken);
return true;
}
finally
{
_gate.Release();
}
}
public async Task DeleteAsync(string username, CancellationToken cancellationToken = default)
@@ -127,17 +209,12 @@ public sealed class GlobalLeaksSessionStore
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);
await WriteUnsafeAsync(path, data, cancellationToken);
}
finally
{
@@ -152,4 +229,41 @@ public sealed class GlobalLeaksSessionStore
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(normalized));
return Path.Combine(RootPath, $"{Convert.ToHexString(hash).ToLowerInvariant()}.bin");
}
private async Task<GlobalLeaksStoredSession?> ReadUnsafeAsync(
string path,
CancellationToken cancellationToken)
{
if (!File.Exists(path))
{
return null;
}
var protectedBytes = await File.ReadAllBytesAsync(path, cancellationToken);
var protectedBase64 = Encoding.UTF8.GetString(protectedBytes);
var json = _protector.Unprotect(protectedBase64);
return JsonSerializer.Deserialize<GlobalLeaksStoredSession>(json, JsonOptions);
}
private async Task WriteUnsafeAsync(
string path,
GlobalLeaksStoredSession data,
CancellationToken cancellationToken)
{
Directory.CreateDirectory(RootPath);
var json = JsonSerializer.Serialize(data, JsonOptions);
var protectedValue = _protector.Protect(json);
var protectedBytes = Encoding.UTF8.GetBytes(protectedValue);
await File.WriteAllBytesAsync(path, protectedBytes, cancellationToken);
}
private static void ClearSessionValues(GlobalLeaksStoredSession session)
{
session.SessionId = null;
session.DpopPrivateKey = null;
session.ProofOfWorkToken = null;
session.SessionExpiresAtUtc = null;
session.LastKeepAliveAtUtc = null;
session.UpdatedAt = DateTimeOffset.UtcNow;
}
}

View File

@@ -102,18 +102,28 @@ public sealed class InboxTrackingService : IInboxTrackingService
DownloadedByAnotherUser = meta?.DownloadedByAnotherUser ?? false,
LastDownloadedByUsername = meta?.LastDownloadedByUsername,
LastDownloadedAt = meta?.LastDownloadedAtUtc?.ToString("O", CultureInfo.InvariantCulture),
LastGestionaUploadAt = meta?.LastGestionaUploadAtUtc?.ToString("O", CultureInfo.InvariantCulture),
AlreadyImported = meta?.AlreadyImported ?? false,
AlreadyInGestiona = meta?.AlreadyInGestiona ?? false,
OwnerUsername = meta?.OwnerUsername,
OwnedByCurrentUser = meta?.OwnedByCurrentUser ?? false,
AccessibleByWorkGroup = meta?.AccessibleByWorkGroup ?? false,
RequiresOwnerConfirmation = meta?.RequiresOwnerConfirmation ?? false,
OwnerWorkGroups = meta?.OwnerWorkGroups ?? [],
TrackingNote = BuildTrackingNote(meta)
};
})
.Where(report => !IsLockedByAnotherUser(report))
.Where(report =>
string.IsNullOrWhiteSpace(report.OwnerUsername) ||
report.OwnedByCurrentUser ||
report.AccessibleByWorkGroup)
.ToArray();
}
public async Task EnsureReportCanBeImportedByUserAsync(
string username,
ReportDto report,
bool confirmDifferentOwner = false,
CancellationToken cancellationToken = default)
{
await _denunciaStore.EnsureSchemaAsync(cancellationToken);
@@ -140,14 +150,23 @@ public sealed class InboxTrackingService : IInboxTrackingService
await EnsureConnectionOpenAsync(connection, cancellationToken);
var metadata = await LoadMetadataAsync(connection, userId, [report.Id], cancellationToken);
if (metadata.TryGetValue(report.Id, out var meta) && meta.LockedByAnotherUser)
if (!metadata.TryGetValue(report.Id, out var meta) ||
string.IsNullOrWhiteSpace(meta.OwnerUsername) ||
meta.OwnedByCurrentUser)
{
var owner = string.IsNullOrWhiteSpace(meta.LastDownloadedByUsername)
? "otro usuario"
: meta.LastDownloadedByUsername;
return;
}
throw new InvalidOperationException(
$"La denuncia ya fue importada por {owner}. Solo ese usuario puede ver e importar sus actualizaciones.");
if (!meta.AccessibleByWorkGroup)
{
throw new ReportOwnershipException(
$"La denuncia pertenece a {meta.OwnerUsername} y no compartis ningun grupo de trabajo.");
}
if (!confirmDifferentOwner)
{
throw new ReportOwnershipException(
$"La denuncia pertenece a {meta.OwnerUsername}. Confirma expresamente que deseas importarla como miembro de su grupo.");
}
}
@@ -180,6 +199,7 @@ public sealed class InboxTrackingService : IInboxTrackingService
SET
last_downloaded_at_utc = @nowUtc,
last_downloaded_by_user_id = @userId,
owner_user_id = COALESCE(owner_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)
@@ -298,6 +318,7 @@ public sealed class InboxTrackingService : IInboxTrackingService
ELSE last_downloaded_at_utc
END,
last_downloaded_by_user_id = @userId,
owner_user_id = COALESCE(owner_user_id, @userId),
imported_complaint_report_id = COALESCE(imported_complaint_report_id, @denunciaId),
imported_to_store_at_utc = COALESCE(imported_to_store_at_utc, @handledAtUtc),
updated_at_utc = CURRENT_TIMESTAMP(6)
@@ -512,11 +533,45 @@ public sealed class InboxTrackingService : IInboxTrackingService
ir.global_report_uuid,
ir.last_downloaded_at_utc,
downloader.username AS last_downloaded_by_username,
owner.username AS owner_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
COALESCE(
(
SELECT MAX(history.uploaded_at_utc)
FROM gestiona_upload_history history
WHERE history.external_report_id =
COALESCE(ir.imported_complaint_report_id, ir.progressive_id)
),
c.gestiona_uploaded_at_utc
) AS last_gestiona_upload_at_utc,
CASE WHEN uir.last_downloaded_at_utc IS NULL THEN 0 ELSE 1 END AS downloaded_by_current_user,
CASE WHEN ir.owner_user_id = @userId THEN 1 ELSE 0 END AS owned_by_current_user,
CASE
WHEN ir.owner_user_id IS NULL THEN 0
WHEN EXISTS (
SELECT 1
FROM app_user_groups shared_membership
INNER JOIN work_groups active_group
ON active_group.id = shared_membership.work_group_id
AND active_group.is_active = 1
WHERE shared_membership.app_user_id IN (@userId, ir.owner_user_id)
GROUP BY shared_membership.work_group_id
HAVING COUNT(DISTINCT shared_membership.app_user_id) = 2
) THEN 1
ELSE 0
END AS accessible_by_group,
(
SELECT GROUP_CONCAT(DISTINCT wg.code ORDER BY wg.code SEPARATOR ',')
FROM app_user_groups owner_membership
INNER JOIN work_groups wg
ON wg.id = owner_membership.work_group_id
AND wg.is_active = 1
WHERE owner_membership.app_user_id = ir.owner_user_id
) AS owner_group_codes
FROM inbox_reports ir
LEFT JOIN app_users downloader ON downloader.id = ir.last_downloaded_by_user_id
LEFT JOIN app_users owner ON owner.id = ir.owner_user_id
LEFT JOIN user_inbox_reports uir
ON uir.inbox_report_id = ir.id
AND uir.app_user_id = @userId
@@ -533,14 +588,20 @@ public sealed class InboxTrackingService : IInboxTrackingService
? null
: reader.GetString(reader.GetOrdinal("last_downloaded_by_username"));
var downloadedByCurrentUser = reader.GetInt32(reader.GetOrdinal("downloaded_by_current_user")) == 1;
var lockedByAnotherUser =
!downloadedByCurrentUser &&
!reader.IsDBNull(reader.GetOrdinal("imported_to_store_at_utc")) &&
!string.IsNullOrWhiteSpace(lastDownloadedByUsername);
var downloadedByAnotherUser =
!downloadedByCurrentUser &&
!string.IsNullOrWhiteSpace(lastDownloadedByUsername);
var downloadedByAnotherUser =
!downloadedByCurrentUser &&
!string.IsNullOrWhiteSpace(lastDownloadedByUsername);
var ownerUsername = reader.IsDBNull(reader.GetOrdinal("owner_username"))
? null
: reader.GetString(reader.GetOrdinal("owner_username"));
var ownedByCurrentUser =
reader.GetInt32(reader.GetOrdinal("owned_by_current_user")) == 1;
var accessibleByGroup =
reader.GetInt32(reader.GetOrdinal("accessible_by_group")) == 1;
var ownerWorkGroups = reader.IsDBNull(reader.GetOrdinal("owner_group_codes"))
? []
: reader.GetString(reader.GetOrdinal("owner_group_codes"))
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
metadata[reportId] = new ReportMetadata
{
@@ -548,9 +609,13 @@ public sealed class InboxTrackingService : IInboxTrackingService
DownloadedByAnotherUser = downloadedByAnotherUser,
LastDownloadedByUsername = lastDownloadedByUsername,
LastDownloadedAtUtc = GetDateTimeOffset(reader, "last_downloaded_at_utc"),
LastGestionaUploadAtUtc = GetDateTimeOffset(reader, "last_gestiona_upload_at_utc"),
AlreadyImported = !reader.IsDBNull(reader.GetOrdinal("imported_to_store_at_utc")),
AlreadyInGestiona = reader.GetInt32(reader.GetOrdinal("already_in_gestiona")) == 1,
LockedByAnotherUser = lockedByAnotherUser,
OwnerUsername = ownerUsername,
OwnedByCurrentUser = ownedByCurrentUser,
AccessibleByWorkGroup = accessibleByGroup,
OwnerWorkGroups = ownerWorkGroups,
};
}
@@ -654,11 +719,10 @@ public sealed class InboxTrackingService : IInboxTrackingService
return null;
}
if (metadata.LockedByAnotherUser)
if (!string.IsNullOrWhiteSpace(metadata.OwnerUsername) &&
!metadata.OwnedByCurrentUser)
{
return string.IsNullOrWhiteSpace(metadata.LastDownloadedByUsername)
? "Importada por otro usuario"
: $"Importada por {metadata.LastDownloadedByUsername}";
return $"Propiedad de {metadata.OwnerUsername}";
}
if (metadata.AlreadyInGestiona)
@@ -693,15 +757,26 @@ public sealed class InboxTrackingService : IInboxTrackingService
{
public bool DownloadedByCurrentUser { get; init; }
public bool DownloadedByAnotherUser { get; init; }
public bool LockedByAnotherUser { get; init; }
public string? LastDownloadedByUsername { get; init; }
public DateTimeOffset? LastDownloadedAtUtc { get; init; }
public DateTimeOffset? LastGestionaUploadAtUtc { get; init; }
public bool AlreadyImported { get; init; }
public bool AlreadyInGestiona { get; init; }
public string? OwnerUsername { get; init; }
public bool OwnedByCurrentUser { get; init; }
public bool AccessibleByWorkGroup { get; init; }
public IReadOnlyList<string> OwnerWorkGroups { get; init; } = [];
public bool RequiresOwnerConfirmation =>
!OwnedByCurrentUser &&
AccessibleByWorkGroup &&
!string.IsNullOrWhiteSpace(OwnerUsername);
}
}
public sealed class ReportOwnershipException : InvalidOperationException
{
public ReportOwnershipException(string message)
: base(message)
{
}
private static bool IsLockedByAnotherUser(ReportDto report)
=> report.AlreadyImported &&
report.DownloadedByAnotherUser &&
!report.DownloadedByCurrentUser;
}

View File

@@ -116,6 +116,7 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
gestiona_uploaded_at_utc,
gestiona_last_upload_type,
gestiona_assigned_group,
pending_update_source,
is_in_gestiona,
is_rejected,
key_date,
@@ -184,6 +185,8 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
("complaints", "encrypted_at_utc", "`encrypted_at_utc` DATETIME(6) NULL"),
("complaints", "gestiona_last_upload_type", "`gestiona_last_upload_type` VARCHAR(64) NOT NULL DEFAULT ''"),
("complaints", "gestiona_assigned_group", "`gestiona_assigned_group` VARCHAR(255) NOT NULL DEFAULT ''"),
("complaints", "pending_update_source", "`pending_update_source` VARCHAR(256) NOT NULL DEFAULT ''"),
("inbox_reports", "owner_user_id", "`owner_user_id` BIGINT NULL"),
("complaint_attachments", "content_sha256", "`content_sha256` CHAR(64) NOT NULL DEFAULT ''"),
("complaint_attachments", "key_date", "`key_date` DATE NULL"),
("complaint_attachments", "encryption_scheme", "`encryption_scheme` VARCHAR(64) NOT NULL DEFAULT 'none'"),
@@ -195,6 +198,7 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
("complaint_attachments", "ix_attachments_sha256", "INDEX `ix_attachments_sha256` (`content_sha256`)"),
("complaints", "ix_complaints_key_date", "INDEX `ix_complaints_key_date` (`key_date`)"),
("complaints", "ix_complaints_flags", "INDEX `ix_complaints_flags` (`is_update`, `is_in_gestiona`, `is_rejected`)"),
("inbox_reports", "ix_inbox_reports_owner", "INDEX `ix_inbox_reports_owner` (`owner_user_id`)"),
("complaint_attachments", "ix_attachments_key_date", "INDEX `ix_attachments_key_date` (`key_date`)"),
];
@@ -709,6 +713,7 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
gestiona_uploaded_at_utc,
gestiona_last_upload_type,
gestiona_assigned_group,
pending_update_source,
is_in_gestiona,
is_rejected,
key_date,
@@ -781,6 +786,7 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
@gestionaUploadedAtUtc,
@gestionaLastUploadType,
@gestionaAssignedGroup,
@pendingUpdateSource,
@isInGestiona,
@isRejected,
@keyDate,
@@ -853,6 +859,7 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
gestiona_uploaded_at_utc = VALUES(gestiona_uploaded_at_utc),
gestiona_last_upload_type = VALUES(gestiona_last_upload_type),
gestiona_assigned_group = VALUES(gestiona_assigned_group),
pending_update_source = VALUES(pending_update_source),
is_in_gestiona = VALUES(is_in_gestiona),
is_rejected = VALUES(is_rejected),
key_date = VALUES(key_date),
@@ -930,6 +937,7 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
command.Parameters.AddWithValue("@gestionaUploadedAtUtc", ToDbDate(denuncia.FechaSubidaAGestiona));
command.Parameters.AddWithValue("@gestionaLastUploadType", denuncia.UltimaSubidaGestionaTipo ?? string.Empty);
command.Parameters.AddWithValue("@gestionaAssignedGroup", denuncia.UltimoGrupoAsignadoGestiona ?? string.Empty);
command.Parameters.AddWithValue("@pendingUpdateSource", denuncia.PendingUpdateSource ?? string.Empty);
command.Parameters.AddWithValue("@isInGestiona", denuncia.EnGestiona);
command.Parameters.AddWithValue("@isRejected", denuncia.EnRechazada);
command.Parameters.AddWithValue("@keyDate", ToDbDate(denuncia.KeyDate));
@@ -994,9 +1002,6 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
description = @description,
attachment_date_utc = @attachmentDateUtc,
notes = @notes,
content = @content,
content_mime_type = @contentMimeType,
content_sha256 = @contentSha256,
uploaded_to_gestiona = CASE
WHEN complaint_attachments.content_sha256 = @contentSha256 THEN complaint_attachments.uploaded_to_gestiona
ELSE @uploadedToGestiona
@@ -1005,6 +1010,9 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
WHEN complaint_attachments.content_sha256 = @contentSha256 THEN complaint_attachments.uploaded_at_utc
ELSE @uploadedAtUtc
END,
content = @content,
content_mime_type = @contentMimeType,
content_sha256 = @contentSha256,
key_date = @keyDate,
encryption_scheme = @encryptionScheme,
encrypted_at_utc = @encryptedAtUtc,
@@ -1354,6 +1362,54 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
await alterCommand.ExecuteNonQueryAsync(cancellationToken);
}
}
await BackfillReportOwnersAsync(connection, cancellationToken);
}
private static async Task BackfillReportOwnersAsync(
MySqlConnection connection,
CancellationToken cancellationToken)
{
const string sql = """
UPDATE inbox_reports ir
LEFT JOIN (
SELECT
uir.inbox_report_id,
CAST(
SUBSTRING_INDEX(
GROUP_CONCAT(
uir.app_user_id
ORDER BY
COALESCE(
uir.first_downloaded_at_utc,
uir.last_downloaded_at_utc,
uir.first_seen_at_utc
),
uir.app_user_id
SEPARATOR ','
),
',',
1
) AS UNSIGNED
) AS first_owner_user_id
FROM user_inbox_reports uir
WHERE uir.first_downloaded_at_utc IS NOT NULL
OR uir.last_downloaded_at_utc IS NOT NULL
GROUP BY uir.inbox_report_id
) first_owner ON first_owner.inbox_report_id = ir.id
SET ir.owner_user_id = COALESCE(
first_owner.first_owner_user_id,
ir.last_downloaded_by_user_id
)
WHERE ir.owner_user_id IS NULL
AND (
ir.imported_to_store_at_utc IS NOT NULL
OR ir.imported_complaint_report_id IS NOT NULL
);
""";
await using var command = new MySqlCommand(sql, connection);
await command.ExecuteNonQueryAsync(cancellationToken);
}
private static async Task EnsureAttachmentChunksTableAsync(
@@ -1671,6 +1727,7 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
FechaSubidaAGestiona = GetDateTime(record, "gestiona_uploaded_at_utc"),
UltimaSubidaGestionaTipo = GetString(record, "gestiona_last_upload_type"),
UltimoGrupoAsignadoGestiona = GetString(record, "gestiona_assigned_group"),
PendingUpdateSource = GetString(record, "pending_update_source"),
EnGestiona = GetBoolean(record, "is_in_gestiona"),
EnRechazada = GetBoolean(record, "is_rejected"),
KeyDate = GetNullableDateOnly(record, "key_date"),

View File

@@ -12,43 +12,163 @@ public sealed class UserComplaintAccessService
_connectionStringProvider = connectionStringProvider;
}
public async Task<HashSet<int>> GetAllowedComplaintIdsAsync(string username, CancellationToken cancellationToken = default)
public async Task<HashSet<int>> GetAllowedComplaintIdsAsync(
string username,
CancellationToken cancellationToken = default)
{
var access = await GetComplaintAccessAsync(username, null, cancellationToken);
return access.Keys.ToHashSet();
}
public async Task<IReadOnlyDictionary<int, ComplaintAccessInfo>> GetComplaintAccessAsync(
string username,
IReadOnlyCollection<int>? complaintIds = null,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(username))
{
return [];
return new Dictionary<int, ComplaintAccessInfo>();
}
const string sql = """
SELECT DISTINCT ir.imported_complaint_report_id
FROM inbox_reports ir
INNER JOIN user_inbox_reports uir ON uir.inbox_report_id = ir.id
INNER JOIN app_users au ON au.id = uir.app_user_id
WHERE au.username = @username
AND ir.imported_complaint_report_id IS NOT NULL
AND uir.download_count > 0;
""";
var connectionString = await _connectionStringProvider.GetConnectionStringAsync(cancellationToken);
await using var connection = new MySqlConnection(connectionString);
await connection.OpenAsync(cancellationToken);
await using var command = new MySqlCommand(sql, connection);
await using var command = connection.CreateCommand();
command.Parameters.AddWithValue("@username", username.Trim());
var result = new HashSet<int>();
var idFilter = string.Empty;
if (complaintIds is { Count: > 0 })
{
var parameters = new List<string>(complaintIds.Count);
var index = 0;
foreach (var complaintId in complaintIds.Where(id => id > 0).Distinct())
{
var parameterName = $"@complaintId{index++}";
parameters.Add(parameterName);
command.Parameters.AddWithValue(parameterName, complaintId);
}
if (parameters.Count == 0)
{
return new Dictionary<int, ComplaintAccessInfo>();
}
idFilter = $"AND COALESCE(ir.imported_complaint_report_id, ir.progressive_id) IN ({string.Join(", ", parameters)})";
}
command.CommandText = $"""
SELECT
COALESCE(ir.imported_complaint_report_id, ir.progressive_id) AS complaint_id,
owner.username AS owner_username,
CASE WHEN ir.owner_user_id = viewer.id THEN 1 ELSE 0 END AS owned_by_current_user,
CASE
WHEN ir.owner_user_id IS NULL THEN 0
WHEN EXISTS (
SELECT 1
FROM app_user_groups shared_membership
INNER JOIN work_groups active_group
ON active_group.id = shared_membership.work_group_id
AND active_group.is_active = 1
WHERE shared_membership.app_user_id IN (viewer.id, ir.owner_user_id)
GROUP BY shared_membership.work_group_id
HAVING COUNT(DISTINCT shared_membership.app_user_id) = 2
) THEN 1
ELSE 0
END AS accessible_by_group,
(
SELECT GROUP_CONCAT(DISTINCT wg.code ORDER BY wg.code SEPARATOR ',')
FROM app_user_groups owner_membership
INNER JOIN work_groups wg
ON wg.id = owner_membership.work_group_id
AND wg.is_active = 1
WHERE owner_membership.app_user_id = ir.owner_user_id
) AS owner_group_codes
FROM app_users viewer
INNER JOIN inbox_reports ir
ON COALESCE(ir.imported_complaint_report_id, ir.progressive_id) IS NOT NULL
LEFT JOIN app_users owner ON owner.id = ir.owner_user_id
LEFT JOIN user_inbox_reports current_tracking
ON current_tracking.inbox_report_id = ir.id
AND current_tracking.app_user_id = viewer.id
WHERE viewer.username = @username
{idFilter}
AND (
ir.owner_user_id = viewer.id
OR EXISTS (
SELECT 1
FROM app_user_groups shared_membership
INNER JOIN work_groups active_group
ON active_group.id = shared_membership.work_group_id
AND active_group.is_active = 1
WHERE shared_membership.app_user_id IN (viewer.id, ir.owner_user_id)
GROUP BY shared_membership.work_group_id
HAVING COUNT(DISTINCT shared_membership.app_user_id) = 2
)
OR (
ir.owner_user_id IS NULL
AND current_tracking.download_count > 0
)
);
""";
var result = new Dictionary<int, ComplaintAccessInfo>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
result.Add(Convert.ToInt32(reader.GetValue(0), CultureInfo.InvariantCulture));
var complaintId = Convert.ToInt32(
reader.GetValue(reader.GetOrdinal("complaint_id")),
CultureInfo.InvariantCulture);
var ownerUsername = reader.IsDBNull(reader.GetOrdinal("owner_username"))
? string.Empty
: reader.GetString(reader.GetOrdinal("owner_username"));
var ownedByCurrentUser =
reader.GetInt32(reader.GetOrdinal("owned_by_current_user")) == 1;
var accessibleByGroup =
reader.GetInt32(reader.GetOrdinal("accessible_by_group")) == 1;
var groupCodes = reader.IsDBNull(reader.GetOrdinal("owner_group_codes"))
? []
: reader.GetString(reader.GetOrdinal("owner_group_codes"))
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
result[complaintId] = new ComplaintAccessInfo(
complaintId,
ownerUsername,
ownedByCurrentUser,
accessibleByGroup,
groupCodes);
}
return result;
}
public async Task<bool> CanAccessComplaintAsync(string username, int complaintId, CancellationToken cancellationToken = default)
public async Task<bool> CanAccessComplaintAsync(
string username,
int complaintId,
CancellationToken cancellationToken = default)
{
var allowedIds = await GetAllowedComplaintIdsAsync(username, cancellationToken);
return allowedIds.Contains(complaintId);
if (complaintId <= 0)
{
return false;
}
var access = await GetComplaintAccessAsync(
username,
[complaintId],
cancellationToken);
return access.ContainsKey(complaintId);
}
}
public sealed record ComplaintAccessInfo(
int ComplaintId,
string OwnerUsername,
bool OwnedByCurrentUser,
bool AccessibleByGroup,
IReadOnlyList<string> OwnerGroupCodes)
{
public bool RequiresOwnerConfirmation =>
!OwnedByCurrentUser &&
AccessibleByGroup &&
!string.IsNullOrWhiteSpace(OwnerUsername);
}

View File

@@ -0,0 +1,414 @@
using System.Globalization;
using GestionaDenuncias.Shared.Models;
using MySqlConnector;
namespace ApiDenuncias.Services;
public sealed class WorkGroupAdministrationService
{
private readonly IDenunciaStore _denunciaStore;
private readonly MySqlConnectionStringProvider _connectionStringProvider;
public WorkGroupAdministrationService(
IDenunciaStore denunciaStore,
MySqlConnectionStringProvider connectionStringProvider)
{
_denunciaStore = denunciaStore;
_connectionStringProvider = connectionStringProvider;
}
public async Task<WorkGroupAdministrationDto> GetAsync(
CancellationToken cancellationToken = default)
{
await _denunciaStore.EnsureSchemaAsync(cancellationToken);
await using var connection = await OpenConnectionAsync(cancellationToken);
var groups = await LoadGroupsAsync(connection, cancellationToken);
var users = new Dictionary<long, UserGroupBuilder>();
const string sql = """
SELECT
au.id,
au.username,
wg.code AS group_code
FROM app_users au
LEFT JOIN app_user_groups aug ON aug.app_user_id = au.id
LEFT JOIN work_groups wg
ON wg.id = aug.work_group_id
AND wg.is_active = 1
ORDER BY au.username, wg.code;
""";
await using var command = new MySqlCommand(sql, connection);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
var userId = reader.GetInt64(reader.GetOrdinal("id"));
if (!users.TryGetValue(userId, out var user))
{
user = new UserGroupBuilder(
userId,
reader.GetString(reader.GetOrdinal("username")));
users[userId] = user;
}
var groupOrdinal = reader.GetOrdinal("group_code");
if (!reader.IsDBNull(groupOrdinal))
{
user.GroupCodes.Add(reader.GetString(groupOrdinal));
}
}
return new WorkGroupAdministrationDto(
groups,
users.Values
.Select(user => new UserWorkGroupDto(
user.UserId,
user.Username,
user.GroupCodes.OrderBy(code => code, StringComparer.Ordinal).ToArray()))
.ToArray());
}
public async Task<CurrentUserWorkGroupsDto> GetUserGroupsAsync(
string username,
CancellationToken cancellationToken = default)
{
await _denunciaStore.EnsureSchemaAsync(cancellationToken);
var normalizedUsername = username?.Trim();
if (string.IsNullOrWhiteSpace(normalizedUsername))
{
throw new InvalidOperationException("No hay usuario autenticado.");
}
await using var connection = await OpenConnectionAsync(cancellationToken);
const string sql = """
SELECT wg.code
FROM app_users au
INNER JOIN app_user_groups aug ON aug.app_user_id = au.id
INNER JOIN work_groups wg
ON wg.id = aug.work_group_id
AND wg.is_active = 1
WHERE LOWER(au.username) = LOWER(@username)
ORDER BY wg.code;
""";
var groupCodes = new List<string>();
await using var command = new MySqlCommand(sql, connection);
command.Parameters.AddWithValue("@username", normalizedUsername);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
groupCodes.Add(reader.GetString(reader.GetOrdinal("code")));
}
return new CurrentUserWorkGroupsDto(normalizedUsername, groupCodes);
}
public async Task<WorkGroupAdministrationDto> UpdateUserGroupsAsync(
string username,
IEnumerable<string> groupCodes,
string changedByUsername,
CancellationToken cancellationToken = default)
{
await _denunciaStore.EnsureSchemaAsync(cancellationToken);
var normalizedUsername = username?.Trim();
if (string.IsNullOrWhiteSpace(normalizedUsername))
{
throw new InvalidOperationException("Debes indicar el usuario que se va a configurar.");
}
var normalizedCodes = groupCodes
.Where(code => !string.IsNullOrWhiteSpace(code))
.Select(code => code.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
if (normalizedCodes.Length == 0)
{
throw new InvalidOperationException("Cada usuario debe pertenecer al menos a un grupo de trabajo.");
}
await using var connection = await OpenConnectionAsync(cancellationToken);
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
try
{
var validGroups = await LoadGroupIdsAsync(
connection,
(MySqlTransaction)transaction,
normalizedCodes,
cancellationToken);
if (validGroups.Count != normalizedCodes.Length)
{
throw new InvalidOperationException("Se ha indicado un grupo de trabajo que no existe o no esta activo.");
}
var userId = await EnsureUserAsync(
connection,
(MySqlTransaction)transaction,
normalizedUsername,
cancellationToken);
var existingGroupIds = await LoadUserGroupIdsAsync(
connection,
(MySqlTransaction)transaction,
userId,
cancellationToken);
var desiredGroupIds = validGroups.Values.ToHashSet();
foreach (var removedGroupId in existingGroupIds.Except(desiredGroupIds))
{
await DeleteMembershipAsync(
connection,
(MySqlTransaction)transaction,
userId,
removedGroupId,
cancellationToken);
await AddHistoryAsync(
connection,
(MySqlTransaction)transaction,
userId,
removedGroupId,
"removed",
changedByUsername,
cancellationToken);
}
foreach (var addedGroupId in desiredGroupIds.Except(existingGroupIds))
{
await AddMembershipAsync(
connection,
(MySqlTransaction)transaction,
userId,
addedGroupId,
changedByUsername,
cancellationToken);
await AddHistoryAsync(
connection,
(MySqlTransaction)transaction,
userId,
addedGroupId,
"added",
changedByUsername,
cancellationToken);
}
await transaction.CommitAsync(cancellationToken);
}
catch
{
await transaction.RollbackAsync(cancellationToken);
throw;
}
return await GetAsync(cancellationToken);
}
private static async Task<List<WorkGroupDto>> LoadGroupsAsync(
MySqlConnection connection,
CancellationToken cancellationToken)
{
const string sql = """
SELECT code, name
FROM work_groups
WHERE is_active = 1
ORDER BY code;
""";
var groups = new List<WorkGroupDto>();
await using var command = new MySqlCommand(sql, connection);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
groups.Add(new WorkGroupDto(
reader.GetString(reader.GetOrdinal("code")),
reader.GetString(reader.GetOrdinal("name"))));
}
return groups;
}
private static async Task<Dictionary<string, long>> LoadGroupIdsAsync(
MySqlConnection connection,
MySqlTransaction transaction,
IReadOnlyList<string> codes,
CancellationToken cancellationToken)
{
var result = new Dictionary<string, long>(StringComparer.OrdinalIgnoreCase);
await using var command = new MySqlCommand { Connection = connection, Transaction = transaction };
var parameters = new List<string>(codes.Count);
for (var index = 0; index < codes.Count; index++)
{
var parameterName = $"@code{index}";
parameters.Add(parameterName);
command.Parameters.AddWithValue(parameterName, codes[index]);
}
command.CommandText = $"""
SELECT id, code
FROM work_groups
WHERE is_active = 1
AND code IN ({string.Join(", ", parameters)});
""";
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
result[reader.GetString(reader.GetOrdinal("code"))] =
reader.GetInt64(reader.GetOrdinal("id"));
}
return result;
}
private static async Task<long> EnsureUserAsync(
MySqlConnection connection,
MySqlTransaction transaction,
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, transaction))
{
insert.Parameters.AddWithValue("@username", username);
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, transaction);
select.Parameters.AddWithValue("@username", username);
var result = await select.ExecuteScalarAsync(cancellationToken);
return Convert.ToInt64(result, CultureInfo.InvariantCulture);
}
private static async Task<HashSet<long>> LoadUserGroupIdsAsync(
MySqlConnection connection,
MySqlTransaction transaction,
long userId,
CancellationToken cancellationToken)
{
const string sql = """
SELECT work_group_id
FROM app_user_groups
WHERE app_user_id = @userId;
""";
var result = new HashSet<long>();
await using var command = new MySqlCommand(sql, connection, transaction);
command.Parameters.AddWithValue("@userId", userId);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
result.Add(reader.GetInt64(0));
}
return result;
}
private static async Task DeleteMembershipAsync(
MySqlConnection connection,
MySqlTransaction transaction,
long userId,
long groupId,
CancellationToken cancellationToken)
{
const string sql = """
DELETE FROM app_user_groups
WHERE app_user_id = @userId
AND work_group_id = @groupId;
""";
await using var command = new MySqlCommand(sql, connection, transaction);
command.Parameters.AddWithValue("@userId", userId);
command.Parameters.AddWithValue("@groupId", groupId);
await command.ExecuteNonQueryAsync(cancellationToken);
}
private static async Task AddMembershipAsync(
MySqlConnection connection,
MySqlTransaction transaction,
long userId,
long groupId,
string changedByUsername,
CancellationToken cancellationToken)
{
const string sql = """
INSERT INTO app_user_groups (
app_user_id,
work_group_id,
assigned_by_username
) VALUES (
@userId,
@groupId,
@changedByUsername
)
ON DUPLICATE KEY UPDATE
assigned_by_username = VALUES(assigned_by_username);
""";
await using var command = new MySqlCommand(sql, connection, transaction);
command.Parameters.AddWithValue("@userId", userId);
command.Parameters.AddWithValue("@groupId", groupId);
command.Parameters.AddWithValue("@changedByUsername", changedByUsername?.Trim() ?? string.Empty);
await command.ExecuteNonQueryAsync(cancellationToken);
}
private static async Task AddHistoryAsync(
MySqlConnection connection,
MySqlTransaction transaction,
long userId,
long groupId,
string action,
string changedByUsername,
CancellationToken cancellationToken)
{
const string sql = """
INSERT INTO app_user_group_history (
app_user_id,
work_group_id,
action,
changed_by_username
) VALUES (
@userId,
@groupId,
@action,
@changedByUsername
);
""";
await using var command = new MySqlCommand(sql, connection, transaction);
command.Parameters.AddWithValue("@userId", userId);
command.Parameters.AddWithValue("@groupId", groupId);
command.Parameters.AddWithValue("@action", action);
command.Parameters.AddWithValue("@changedByUsername", changedByUsername?.Trim() ?? string.Empty);
await command.ExecuteNonQueryAsync(cancellationToken);
}
private async Task<MySqlConnection> OpenConnectionAsync(CancellationToken cancellationToken)
{
var connectionString = await _connectionStringProvider.GetConnectionStringAsync(cancellationToken);
var connection = new MySqlConnection(connectionString);
await connection.OpenAsync(cancellationToken);
await using var command = new MySqlCommand("SET time_zone = '+00:00';", connection);
await command.ExecuteNonQueryAsync(cancellationToken);
return connection;
}
private sealed record UserGroupBuilder(long UserId, string Username)
{
public HashSet<string> GroupCodes { get; } = new(StringComparer.OrdinalIgnoreCase);
}
}

View File

@@ -44,6 +44,8 @@
"CircuitNewComplaintTemplateName": "CT-Nueva denuncia",
"CircuitUpdateSajTemplateName": "CT-Actualización denuncia SAJ",
"CircuitUpdateSdiTemplateName": "CT-Actualización denuncia SDI",
"CircuitCommunicationSajTemplateName": "CT-Comunicación SAJ a denunciante",
"CircuitCommunicationSdiTemplateName": "CT-Comunicación SDI a denunciante",
"CircuitSignerStampTitle": "oaaf-complaints-tramit",
"CircuitVersion": "2",
"DocumentMetadataLanguage": "es",

View File

@@ -19,7 +19,9 @@ public sealed record InboxSnapshotResponse(
IReadOnlyList<ReportDto> Reports,
InboxUserState UserState);
public sealed record ImportReportRequest(ReportDto Report);
public sealed record ImportReportRequest(
ReportDto Report,
bool ConfirmDifferentOwner = false);
public sealed record MarkFicherosUploadedRequest(
IReadOnlyList<string> FileNames,
@@ -43,7 +45,8 @@ public sealed record MarkReportHandledInGestionaRequest(
public sealed record TrackingImportPermissionRequest(
string Username,
ReportDto Report);
ReportDto Report,
bool ConfirmDifferentOwner = false);
public sealed record GestionaCreateFileRequest(
string Subject,
@@ -92,7 +95,8 @@ public sealed record GestionaTramitarDocumentoRequest(
string DocumentUrl,
string AssignedGroupCode,
int? ComplaintId,
bool IsUpdate = false);
bool IsUpdate = false,
string? UpdateSource = null);
public sealed record ManualPurgeRequest(string Date);
@@ -109,6 +113,26 @@ public sealed record AppConfigurationDto(
public sealed record UpdateExternalUpdateCutoffRequest(string? Date);
public sealed record WorkGroupDto(
string Code,
string Name);
public sealed record UserWorkGroupDto(
long UserId,
string Username,
IReadOnlyList<string> GroupCodes);
public sealed record WorkGroupAdministrationDto(
IReadOnlyList<WorkGroupDto> Groups,
IReadOnlyList<UserWorkGroupDto> Users);
public sealed record CurrentUserWorkGroupsDto(
string Username,
IReadOnlyList<string> GroupCodes);
public sealed record UpdateUserWorkGroupsRequest(
IReadOnlyList<string> GroupCodes);
public sealed record GestionaComplaintFieldsResponse(
DateTime? FechaDenuncia,
int NumeroDenunciaCanal,

View File

@@ -0,0 +1,13 @@
namespace GestionaDenuncias.Shared.Models;
public static class ComplaintUpdateSources
{
public const string Citizen = "citizen";
public const string Receiver = "receiver";
public static bool IsCitizen(string? value)
=> string.Equals(value, Citizen, StringComparison.OrdinalIgnoreCase);
public static bool IsReceiver(string? value)
=> string.Equals(value, Receiver, StringComparison.OrdinalIgnoreCase);
}

View File

@@ -79,10 +79,16 @@ public class DenunciasGestiona
public DateTime FechaSubidaAGestiona { get; set; } = DateTime.MinValue;
public string UltimaSubidaGestionaTipo { get; set; } = string.Empty;
public string UltimoGrupoAsignadoGestiona { get; set; } = string.Empty;
public string PendingUpdateSource { get; set; } = string.Empty;
public bool EnGestiona { get; set; }
public bool EnRechazada { get; set; }
public string OwnerUsername { get; set; } = string.Empty;
public bool OwnedByCurrentUser { get; set; }
public bool RequiresOwnerConfirmation { get; set; }
public IReadOnlyList<string> OwnerWorkGroups { get; set; } = [];
[JsonIgnore]
public DateOnly? KeyDate { get; set; }

View File

@@ -1,3 +1,11 @@
namespace GestionaDenuncias.Shared.Models;
public sealed record GlSession(string Id, string Username, string? Role = null, string? DpopPrivateKey = null);
public sealed record GlobalLeaksProofOfWorkToken(string Id, string Salt);
public sealed record GlSession(
string Id,
string Username,
string? Role = null,
string? DpopPrivateKey = null,
GlobalLeaksProofOfWorkToken? ProofOfWorkToken = null,
DateTimeOffset? SessionExpiresAtUtc = null);

View File

@@ -7,9 +7,14 @@ public sealed class GlobalLeaksStoredSession
public string? SessionId { get; set; }
public string? Role { get; set; }
public string? DpopPrivateKey { get; set; }
public GlobalLeaksProofOfWorkToken? ProofOfWorkToken { get; set; }
public DateTimeOffset? SessionExpiresAtUtc { get; set; }
public DateTimeOffset? LastKeepAliveAtUtc { get; set; }
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
public bool HasActiveSession =>
!string.IsNullOrWhiteSpace(SessionId) &&
!string.IsNullOrWhiteSpace(DpopPrivateKey);
!string.IsNullOrWhiteSpace(DpopPrivateKey) &&
!string.IsNullOrWhiteSpace(ProofOfWorkToken?.Id) &&
!string.IsNullOrWhiteSpace(ProofOfWorkToken?.Salt);
}

View File

@@ -5,14 +5,17 @@ public sealed record ReportDetailDto(
string? LastAccess,
IReadOnlyList<ReportCommentDto> Comments,
IReadOnlyList<ReportFileDto> WhistleblowerFiles,
IReadOnlyList<ReportFileDto> ReceiverFiles);
IReadOnlyList<ReportFileDto> ReceiverFiles,
IReadOnlyList<ReportReceiverDto>? Receivers = null);
public sealed record ReportCommentDto(
string? Id,
string? Type,
string? Content,
string? CreationDate,
bool IsNew);
bool IsNew,
string? AuthorId = null,
string? AuthorName = null);
public sealed record ReportFileDto(
string? Id,
@@ -20,4 +23,11 @@ public sealed record ReportFileDto(
long? Size,
string? ContentType,
string? CreationDate,
bool IsNew);
bool IsNew,
string? AuthorId = null,
string? AuthorName = null);
public sealed record ReportReceiverDto(
string Id,
string Name,
bool Active);

View File

@@ -24,11 +24,19 @@ public sealed record ReportDto
public bool CitizenHasNewFile { get; init; }
public string? ReceiverLastActivity { get; init; }
public bool ReceiverHasNewActivity { get; init; }
public string? ReceiverLastActivityAuthorId { get; init; }
public string? ReceiverLastActivityAuthorName { get; init; }
public bool DownloadedByCurrentUser { get; init; }
public bool DownloadedByAnotherUser { get; init; }
public string? LastDownloadedByUsername { get; init; }
public string? LastDownloadedAt { get; init; }
public string? LastGestionaUploadAt { get; init; }
public bool AlreadyImported { get; init; }
public bool AlreadyInGestiona { get; init; }
public string? OwnerUsername { get; init; }
public bool OwnedByCurrentUser { get; init; }
public bool AccessibleByWorkGroup { get; init; }
public bool RequiresOwnerConfirmation { get; init; }
public IReadOnlyList<string> OwnerWorkGroups { get; init; } = [];
public string? TrackingNote { get; init; }
}

View File

@@ -24,5 +24,6 @@ public interface IInboxTrackingService
Task EnsureReportCanBeImportedByUserAsync(
string username,
ReportDto report,
bool confirmDifferentOwner = false,
CancellationToken cancellationToken = default);
}

View File

@@ -17,7 +17,7 @@
<body>
<Routes @rendermode="@(new InteractiveServerRenderMode(prerender: false))" />
<script src="Scripts/bootstrap.bundle.min.js"></script>
<script src="js/appAuth.js"></script>
<script src="js/appAuth.js?v=20260724-session-keepalive"></script>
<script src="_framework/blazor.web.js"></script>
</body>

View File

@@ -0,0 +1,110 @@
@implements IDisposable
@inject UiDialogService Dialog
@if (Dialog.IsVisible)
{
<div class="app-confirmation-backdrop"
role="presentation"
@onkeydown="HandleKeyDown">
<section class="@DialogCss"
role="alertdialog"
aria-modal="true"
aria-labelledby="app-confirmation-title"
aria-describedby="app-confirmation-message">
<div class="app-confirmation__accent" aria-hidden="true"></div>
<div class="app-confirmation__content">
<div class="@IconCss" aria-hidden="true">!</div>
<div class="app-confirmation__copy">
<div class="app-confirmation__eyebrow">@Eyebrow</div>
<h2 id="app-confirmation-title" class="app-confirmation__title">
@Dialog.Title
</h2>
<p id="app-confirmation-message" class="app-confirmation__message">
@Dialog.Message
</p>
</div>
</div>
<div class="app-confirmation__actions">
<button type="button"
class="btn btn-outline-secondary app-confirmation__button"
@ref="_cancelButton"
@onclick="Dialog.Cancel">
@Dialog.CancelText
</button>
<button type="button"
class="@ConfirmButtonCss"
@onclick="Dialog.Confirm">
@Dialog.ConfirmText
</button>
</div>
</section>
</div>
}
@code {
private ElementReference _cancelButton;
private bool _focusPending;
private string DialogCss =>
$"app-confirmation app-confirmation--{ToneName}";
private string IconCss =>
$"app-confirmation__icon app-confirmation__icon--{ToneName}";
private string ConfirmButtonCss =>
Dialog.Tone == UiDialogTone.Danger
? "btn app-confirmation__button app-confirmation__button--danger"
: "btn app-confirmation__button app-confirmation__button--primary";
private string ToneName => Dialog.Tone switch
{
UiDialogTone.Danger => "danger",
UiDialogTone.Information => "information",
_ => "warning"
};
private string Eyebrow => Dialog.Tone switch
{
UiDialogTone.Danger => "Acción irreversible",
UiDialogTone.Information => "Confirmación",
_ => "Revisa antes de continuar"
};
protected override void OnInitialized()
{
Dialog.Changed += HandleDialogChanged;
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!_focusPending || !Dialog.IsVisible)
{
return;
}
_focusPending = false;
await _cancelButton.FocusAsync();
}
public void Dispose()
{
Dialog.Changed -= HandleDialogChanged;
}
private void HandleDialogChanged()
{
_focusPending = Dialog.IsVisible;
_ = InvokeAsync(StateHasChanged);
}
private void HandleKeyDown(KeyboardEventArgs args)
{
if (string.Equals(args.Key, "Escape", StringComparison.Ordinal))
{
Dialog.Cancel();
}
}
}

View File

@@ -0,0 +1,163 @@
.app-confirmation-backdrop {
position: fixed;
inset: 0;
z-index: 5100;
display: grid;
place-items: center;
padding: 1.25rem;
background: rgba(6, 22, 41, 0.64);
backdrop-filter: blur(5px);
overscroll-behavior: contain;
}
.app-confirmation {
position: relative;
overflow: hidden;
width: min(560px, 100%);
border: 1px solid rgba(255, 255, 255, 0.65);
border-radius: 8px;
background: #fff;
box-shadow: 0 28px 80px rgba(5, 27, 54, 0.34);
color: #12395f;
}
.app-confirmation__accent {
height: 0.35rem;
background: #d49620;
}
.app-confirmation--danger .app-confirmation__accent {
background: #c93f4f;
}
.app-confirmation--information .app-confirmation__accent {
background: #2a5caa;
}
.app-confirmation__content {
display: grid;
grid-template-columns: 3.25rem minmax(0, 1fr);
gap: 1rem;
padding: 1.5rem 1.5rem 1.1rem;
}
.app-confirmation__icon {
display: grid;
place-items: center;
width: 3.25rem;
height: 3.25rem;
border: 1px solid #efd8a8;
border-radius: 50%;
background: #fff6df;
color: #8d5e08;
font-size: 1.45rem;
font-weight: 800;
}
.app-confirmation__icon--danger {
border-color: #efc2c8;
background: #fff0f2;
color: #a82d3b;
}
.app-confirmation__icon--information {
border-color: #c4d6ef;
background: #eef5ff;
color: #214f91;
}
.app-confirmation__copy {
min-width: 0;
}
.app-confirmation__eyebrow {
margin-bottom: 0.3rem;
color: #6a7786;
font-size: 0.74rem;
font-weight: 800;
letter-spacing: 0;
text-transform: uppercase;
}
.app-confirmation__title {
margin: 0;
color: #0a315c;
font-size: 1.35rem;
font-weight: 800;
line-height: 1.25;
}
.app-confirmation__message {
margin: 0.65rem 0 0;
color: #405f7d;
line-height: 1.55;
white-space: pre-line;
}
.app-confirmation__actions {
display: flex;
justify-content: flex-end;
gap: 0.65rem;
padding: 1rem 1.5rem 1.35rem;
border-top: 1px solid #e4ebf2;
background: #f8fafc;
}
.app-confirmation__button {
min-width: 8.5rem;
min-height: 2.7rem;
border-radius: 6px;
}
.app-confirmation__button--primary {
border-color: #24539a;
background: #24539a;
color: #fff;
}
.app-confirmation__button--primary:hover,
.app-confirmation__button--primary:focus-visible {
border-color: #193f79;
background: #193f79;
color: #fff;
}
.app-confirmation__button--danger {
border-color: #b93343;
background: #b93343;
color: #fff;
}
.app-confirmation__button--danger:hover,
.app-confirmation__button--danger:focus-visible {
border-color: #922936;
background: #922936;
color: #fff;
}
@media (max-width: 575.98px) {
.app-confirmation-backdrop {
align-items: end;
padding: 0.75rem;
}
.app-confirmation__content {
grid-template-columns: 2.75rem minmax(0, 1fr);
gap: 0.8rem;
padding: 1.2rem 1rem 1rem;
}
.app-confirmation__icon {
width: 2.75rem;
height: 2.75rem;
}
.app-confirmation__actions {
flex-direction: column-reverse;
padding: 0.9rem 1rem 1rem;
}
.app-confirmation__button {
width: 100%;
}
}

View File

@@ -1,5 +1,5 @@
@inherits LayoutComponentBase
@implements IDisposable
@implements IAsyncDisposable
@using System.Globalization
@inject GestionaDenunciasAN.Models.UserState userState
@inject IHttpContextAccessor HttpContextAccessor
@@ -7,6 +7,7 @@
@inject NavigationManager Navigation
@inject UiBusyService Busy
@inject ApiDenunciasClient ApiDenuncias
@inject ILogger<MainLayout> Logger
<div class="app-shell">
<aside class="app-sidebar">
@@ -51,6 +52,7 @@
</div>
<BusyOverlay />
<AppConfirmationDialog />
<div id="blazor-error-ui">
An unhandled error has occurred.
@@ -59,12 +61,20 @@
</div>
@code {
private static readonly TimeSpan GlobalLeaksHeartbeatInterval = TimeSpan.FromSeconds(30);
private static readonly TimeSpan GlobalLeaksIdleTimeout = TimeSpan.FromMinutes(30);
private string CurrentPageTitle { get; set; } = "Portal de gestion";
private string CurrentPageDescription { get; set; } =
"Entrada, revision y tramitacion coordinada de denuncias y actualizaciones.";
private string EncryptionKeyText { get; set; } = "Clave diaria: cargando...";
private string EncryptionKeyTooltip { get; set; } = "Consultando la ultima clave diaria de cifrado activa en la API.";
private string EncryptionKeyPillCss { get; set; } = "app-session-pill app-key-pill";
private readonly CancellationTokenSource _heartbeatCancellation = new();
private PeriodicTimer? _heartbeatTimer;
private Task? _heartbeatTask;
private bool _globalLeaksSessionClearedForIdle;
private DateTimeOffset _lastHeartbeatWarningAtUtc = DateTimeOffset.MinValue;
private string DisplayUsername =>
string.IsNullOrWhiteSpace(userState?.NombreUsu)
@@ -87,9 +97,58 @@
RefreshLayoutState();
}
public void Dispose()
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender)
{
return;
}
try
{
await JSRuntime.InvokeVoidAsync("appGlobalLeaksActivity.start");
}
catch (JSException ex)
{
Logger.LogError(
ex,
"No se ha podido iniciar el control de actividad para la sesion GlobalLeaks.");
return;
}
_heartbeatTimer = new PeriodicTimer(GlobalLeaksHeartbeatInterval);
_heartbeatTask = RunGlobalLeaksHeartbeatAsync(_heartbeatCancellation.Token);
}
public async ValueTask DisposeAsync()
{
Navigation.LocationChanged -= HandleLocationChanged;
_heartbeatCancellation.Cancel();
_heartbeatTimer?.Dispose();
if (_heartbeatTask is not null)
{
try
{
await _heartbeatTask;
}
catch (OperationCanceledException)
{
}
}
try
{
await JSRuntime.InvokeVoidAsync("appGlobalLeaksActivity.stop");
}
catch (JSDisconnectedException)
{
}
catch (ObjectDisposedException)
{
}
_heartbeatCancellation.Dispose();
}
private void HandleLocationChanged(object? sender, LocationChangedEventArgs args)
@@ -134,6 +193,69 @@
}
}
private async Task RunGlobalLeaksHeartbeatAsync(CancellationToken cancellationToken)
{
try
{
while (_heartbeatTimer is not null &&
await _heartbeatTimer.WaitForNextTickAsync(cancellationToken))
{
await InvokeAsync(() => MaintainGlobalLeaksSessionAsync(cancellationToken));
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
}
catch (ObjectDisposedException)
{
}
}
private async Task MaintainGlobalLeaksSessionAsync(CancellationToken cancellationToken)
{
try
{
var idleMilliseconds = await JSRuntime.InvokeAsync<double>(
"appGlobalLeaksActivity.getIdleMilliseconds",
cancellationToken);
if (idleMilliseconds >= GlobalLeaksIdleTimeout.TotalMilliseconds)
{
if (!_globalLeaksSessionClearedForIdle)
{
await ApiDenuncias.ClearGlobalLeaksSessionAsync(cancellationToken);
_globalLeaksSessionClearedForIdle = true;
}
return;
}
_globalLeaksSessionClearedForIdle = false;
await ApiDenuncias.KeepGlobalLeaksSessionAliveAsync(cancellationToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
}
catch (JSDisconnectedException)
{
}
catch (UnauthorizedAccessException)
{
_heartbeatCancellation.Cancel();
}
catch (Exception ex)
{
var now = DateTimeOffset.UtcNow;
if (now - _lastHeartbeatWarningAtUtc >= TimeSpan.FromMinutes(5))
{
_lastHeartbeatWarningAtUtc = now;
Logger.LogWarning(
ex,
"No se ha podido ejecutar el mantenimiento de la sesion GlobalLeaks.");
}
}
}
private static string ToSpanishDateText(string? value)
{
if (string.IsNullOrWhiteSpace(value))

View File

@@ -16,6 +16,7 @@
@inject IDenunciaStore DenunciaStore
@inject ApiDenunciasClient ApiDenuncias
@inject UiBusyService Busy
@inject UiDialogService Dialogs
<PageTitle>Actualizaciones</PageTitle>
@@ -162,7 +163,21 @@ else
data-bs-target="#@collapseId"
aria-expanded="false"
aria-controls="@collapseId">
<h5 class="mb-0">Denuncia ID: @denuncia.Id_Denuncia (Actualización)</h5>
<div class="d-flex flex-wrap align-items-center gap-2">
<h5 class="mb-0">Denuncia ID: @denuncia.Id_Denuncia (Actualización)</h5>
@if (!string.IsNullOrWhiteSpace(denuncia.OwnerUsername))
{
<span class="badge @(denuncia.OwnedByCurrentUser ? "text-bg-secondary" : "text-bg-warning")">
Responsable: @denuncia.OwnerUsername
</span>
}
@if (denuncia.OwnerWorkGroups.Count > 0)
{
<span class="badge text-bg-light">
Grupo: @string.Join(" / ", denuncia.OwnerWorkGroups)
</span>
}
</div>
<div class="d-flex align-items-center">
<div class="text-muted small me-3">
@@ -703,11 +718,24 @@ else
private async Task OpenEnviarAGestionaModal(DenunciasGestiona d)
{
if (d.RequiresOwnerConfirmation)
{
var confirmed = await Dialogs.ConfirmAsync(
"Denuncia asignada a otro usuario",
$"La denuncia #{d.Id_Denuncia} está asignada a {d.OwnerUsername}. " +
"Puedes actualizarla porque pertenece a un usuario de tu grupo. La acción quedará registrada con tu usuario.",
"Configurar actualización");
if (!confirmed)
{
return;
}
}
selectedDenuncias = d;
nuevoAsunto = string.IsNullOrWhiteSpace(d.NombreDenuncia) ? $"Denuncia-{d.Id_Denuncia}-CD" : d.NombreDenuncia;
nombreDocumentos = "";
selectedThirdParty = ThirdPartyIdentityData.FromComplaint(d);
selectedGroup = NormalizeUpdateGroup(selectedGroup);
selectedGroup = NormalizeUpdateGroup(d.UltimoGrupoAsignadoGestiona);
operationError = string.Empty;
operationNotice = string.Empty;
@@ -817,12 +845,19 @@ else
return;
}
if (!await ConfirmSelectedGroupAsync())
{
return;
}
try
{
isUploading = true;
operationError = string.Empty;
operationNotice = string.Empty;
selectedGroup = NormalizeUpdateGroup(selectedGroup);
var pendingUpdateSource = selectedDenuncias.PendingUpdateSource;
var uploadType = GetUpdateUploadType(pendingUpdateSource, selectedGroup);
using var busy = Busy.Show(
"Enviando actualizacion",
"Preparando expediente, carpeta de actualizacion y documentos.");
@@ -1008,7 +1043,8 @@ else
documentoParaTramitar,
selectedGroup,
selectedDenuncias.Id_Denuncia,
isUpdate: true);
isUpdate: true,
updateSource: pendingUpdateSource);
}
foreach (var orig in nombresOriginalesSubidos)
@@ -1030,13 +1066,14 @@ else
selectedDenuncias.EsActualizacion = false;
selectedDenuncias.NombreDenuncia = nuevoAsunto;
selectedDenuncias.FechaSubidaAGestiona = ahoraUtc;
selectedDenuncias.UltimaSubidaGestionaTipo = "Actualización";
selectedDenuncias.UltimaSubidaGestionaTipo = uploadType;
selectedDenuncias.UltimoGrupoAsignadoGestiona = GetGestionaGroupDisplay(selectedGroup);
selectedDenuncias.PendingUpdateSource = string.Empty;
await SincronizarExpedienteGestionaAsync(selectedDenuncias, fileUrl);
await ActualizarDenunciaAsync(selectedDenuncias);
var historialAviso = await RegistrarHistorialGestionaAsync(
selectedDenuncias,
"Actualización",
uploadType,
selectedGroup,
ahoraUtc,
string.Join("; ", nombresFinalesSubidos));
@@ -1331,13 +1368,53 @@ else
}
private static string NormalizeUpdateGroup(string? groupCode)
=> groupCode == "510" ? "510" : "600";
=> groupCode?.Trim().StartsWith("510", StringComparison.Ordinal) == true
? "510"
: "600";
private static string GetGestionaGroupDisplay(string? groupCode)
{
return string.IsNullOrWhiteSpace(groupCode) ? string.Empty : groupCode.Trim();
}
private static string GetUpdateUploadType(string? updateSource, string groupCode)
{
if (!ComplaintUpdateSources.IsReceiver(updateSource))
{
return "Actualización";
}
return NormalizeUpdateGroup(groupCode) == "510"
? "Comunic. SDI a denunciante"
: "Comunic. SAJ a denunciante";
}
private async Task<bool> ConfirmSelectedGroupAsync()
{
try
{
var currentGroups = await ApiDenuncias.GetCurrentUserWorkGroupsAsync();
if (currentGroups.GroupCodes.Contains(selectedGroup, StringComparer.OrdinalIgnoreCase))
{
return true;
}
var configuredGroups = currentGroups.GroupCodes.Count == 0
? "ningún grupo"
: string.Join(", ", currentGroups.GroupCodes);
return await Dialogs.ConfirmAsync(
"Cambio de asignación en Gestiona",
$"El grupo de destino {selectedGroup} no pertenece a tus grupos configurados ({configuredGroups}). " +
"Si continúas, la denuncia cambiará su asignación en Gestiona a un grupo distinto de los tuyos.",
"Cambiar asignación");
}
catch (Exception ex)
{
operationError = $"No se han podido comprobar tus grupos antes de la subida: {ex.Message}";
return false;
}
}
private async Task<string?> RegistrarHistorialGestionaAsync(
DenunciasGestiona denuncia,
string tipoSubida,

View File

@@ -4,6 +4,7 @@
@using System.Globalization
@using GestionaDenunciasAN.Services
@using GestionaDenuncias.Shared.Models
@using Microsoft.AspNetCore.Components.Authorization
@inject ApiDenunciasClient ApiDenuncias
@@ -95,6 +96,104 @@ else
</div>
</div>
<div class="card mt-3">
<div class="card-body">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
<div>
<h5 class="mb-1">Grupos de trabajo</h5>
<p class="text-muted mb-0">
Los usuarios pueden consultar y tratar denuncias de otros propietarios cuando comparten al menos un grupo.
</p>
</div>
<button type="button"
class="btn btn-outline-secondary btn-sm"
disabled="@isLoadingWorkGroups"
@onclick="LoadWorkGroupsAsync">
Actualizar usuarios
</button>
</div>
@if (isLoadingWorkGroups)
{
<div class="text-muted">
<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
Cargando usuarios y grupos...
</div>
}
else if (workGroupAdministration is null)
{
<div class="alert alert-warning mb-0">No se ha podido cargar la configuracion de grupos.</div>
}
else
{
<div class="table-responsive">
<table class="table table-sm align-middle mb-0">
<thead>
<tr>
<th>Usuario</th>
@foreach (var group in workGroupAdministration.Groups)
{
<th>
<span class="d-block">@group.Code</span>
<small class="text-muted fw-normal">@group.Name</small>
</th>
}
<th class="text-end">Accion</th>
</tr>
</thead>
<tbody>
@foreach (var user in workGroupAdministration.Users)
{
<tr @key="user.UserId">
<td>
<strong>@user.Username</strong>
@if (!GetSelectedGroups(user.UserId).Any())
{
<span class="badge text-bg-warning ms-2">Sin grupo</span>
}
</td>
@foreach (var group in workGroupAdministration.Groups)
{
var checkboxId = $"user-group-{user.UserId}-{group.Code}";
<td>
<div class="form-check">
<input id="@checkboxId"
class="form-check-input"
type="checkbox"
checked="@GetSelectedGroups(user.UserId).Contains(group.Code)"
disabled="@savingWorkGroupUsers.Contains(user.UserId)"
@onchange="args => ToggleUserGroup(user.UserId, group.Code, args)" />
<label class="form-check-label" for="@checkboxId">@group.Code</label>
</div>
</td>
}
<td class="text-end">
<button type="button"
class="btn btn-primary btn-sm"
disabled="@savingWorkGroupUsers.Contains(user.UserId)"
@onclick="() => SaveUserGroupsAsync(user)">
@(savingWorkGroupUsers.Contains(user.UserId) ? "Guardando..." : "Guardar")
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
}
@if (!string.IsNullOrWhiteSpace(workGroupNotice))
{
<div class="alert alert-success mt-3 mb-0">@workGroupNotice</div>
}
@if (!string.IsNullOrWhiteSpace(workGroupError))
{
<div class="alert alert-danger mt-3 mb-0">@workGroupError</div>
}
</div>
</div>
<div class="card mt-3">
<div class="card-body">
<h5 class="mb-3">Purga manual con reemplazo</h5>
@@ -223,6 +322,12 @@ else
private string? configurationNotice;
private string? configurationError;
private bool isSavingConfiguration;
private WorkGroupAdministrationDto? workGroupAdministration;
private readonly Dictionary<long, HashSet<string>> selectedWorkGroups = [];
private readonly HashSet<long> savingWorkGroupUsers = [];
private bool isLoadingWorkGroups;
private string? workGroupNotice;
private string? workGroupError;
private string confirmation = string.Empty;
private bool acceptedRisk;
@@ -246,6 +351,7 @@ else
}
await LoadConfigurationAsync();
await LoadWorkGroupsAsync();
}
private async Task LoadConfigurationAsync()
@@ -297,6 +403,98 @@ else
await SaveExternalUpdateCutoffDateAsync();
}
private async Task LoadWorkGroupsAsync()
{
try
{
isLoadingWorkGroups = true;
workGroupError = null;
var response = await ApiDenuncias.GetWorkGroupAdministrationAsync();
ApplyWorkGroupAdministration(response);
}
catch (Exception ex)
{
workGroupError = $"No se han podido cargar los grupos de trabajo: {ex.Message}";
}
finally
{
isLoadingWorkGroups = false;
}
}
private void ApplyWorkGroupAdministration(WorkGroupAdministrationDto response)
{
workGroupAdministration = response;
selectedWorkGroups.Clear();
foreach (var user in response.Users)
{
selectedWorkGroups[user.UserId] = user.GroupCodes.ToHashSet(
StringComparer.OrdinalIgnoreCase);
}
}
private HashSet<string> GetSelectedGroups(long userId)
{
if (!selectedWorkGroups.TryGetValue(userId, out var groups))
{
groups = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
selectedWorkGroups[userId] = groups;
}
return groups;
}
private void ToggleUserGroup(
long userId,
string groupCode,
ChangeEventArgs args)
{
var groups = GetSelectedGroups(userId);
var enabled = args.Value is bool boolValue
? boolValue
: bool.TryParse(args.Value?.ToString(), out var parsed) && parsed;
if (enabled)
{
groups.Add(groupCode);
}
else
{
groups.Remove(groupCode);
}
}
private async Task SaveUserGroupsAsync(UserWorkGroupDto user)
{
var groups = GetSelectedGroups(user.UserId);
if (groups.Count == 0)
{
workGroupNotice = null;
workGroupError = $"El usuario {user.Username} debe pertenecer al menos a un grupo.";
return;
}
try
{
savingWorkGroupUsers.Add(user.UserId);
workGroupNotice = null;
workGroupError = null;
var response = await ApiDenuncias.UpdateUserWorkGroupsAsync(
user.Username,
groups.OrderBy(code => code, StringComparer.Ordinal).ToArray());
ApplyWorkGroupAdministration(response);
workGroupNotice = $"Grupos de {user.Username} actualizados correctamente.";
}
catch (Exception ex)
{
workGroupError = $"No se han podido guardar los grupos de {user.Username}: {ex.Message}";
}
finally
{
savingWorkGroupUsers.Remove(user.UserId);
}
}
private static string? ToIsoDateText(string? value)
{
if (string.IsNullOrWhiteSpace(value))

View File

@@ -9,6 +9,7 @@
@inject ApiDenunciasClient ApiDenuncias
@inject IJSRuntime JSRuntime
@inject UiBusyService Busy
@inject UiDialogService Dialogs
<PageTitle>Entrada de denuncias</PageTitle>
@@ -122,6 +123,9 @@
.inbox-activity-cell {
width: 9.25rem;
max-width: 12rem;
overflow: hidden;
text-overflow: ellipsis;
}
.inbox-action-cell {
@@ -226,6 +230,7 @@
<option value="all">Todas</option>
<option value="new">Nuevas / sin leer</option>
<option value="updated">Actualizaciones del ciudadano</option>
<option value="receiver">Actividad OAAF</option>
</select>
</div>
@@ -310,12 +315,12 @@
<th>#</th>
<th>Canal</th>
<th>Presentacion</th>
<th>Actividad ciudadano</th>
<th>Actividad OAAF</th>
<th>Estado</th>
<th>Acceso</th>
<th>Seguimiento</th>
<th class="inbox-action-cell">Detalle</th>
<th title="Fecha de la última aportación o comentario realizado por el ciudadano.">Actividad ciudadano</th>
<th title="Fecha de la última comunicación o fichero incorporado por un gestor de la OAAF.">Actividad OAAF</th>
<th title="Resume si la denuncia es nueva, tiene cambios pendientes o ya está actualizada en Gestiona.">Estado</th>
<th title="Indica si tu usuario gestor del buzón puede acceder a esta denuncia.">Acceso</th>
<th title="Indica si el cambio está pendiente, descargado en la aplicación o ya incorporado a Gestiona.">Gestiona</th>
<th class="inbox-action-cell" title="Abre el detalle disponible en GlobalLeaks.">Detalle</th>
</tr>
</thead>
<tbody>
@@ -355,19 +360,19 @@
<td class="inbox-channel-cell" title="@(report.ContextName ?? report.ContextId ?? string.Empty)"><span class="d-inline-block text-truncate">@(report.ContextName ?? report.ContextId ?? "-")</span></td>
<td>@FormatDate(report.CreationDate)</td>
<td class="inbox-activity-cell">@FormatCitizenActivity(report)</td>
<td class="inbox-activity-cell">@FormatReceiverActivity(report)</td>
<td class="inbox-activity-cell" title="@GetReceiverActivityTitle(report)">@FormatReceiverActivity(report)</td>
<td>
<span class="badge @GetStatusBadgeCss(report)">
<span class="badge @GetStatusBadgeCss(report)" title="@GetStatusHelp(report)">
@GetStatusLabel(report)
</span>
</td>
<td>
<span class="badge @GetAccessBadgeCss(report)">
<span class="badge @GetAccessBadgeCss(report)" title="@GetAccessHelp(report)">
@GetAccessLabel(report)
</span>
</td>
<td class="inbox-tracking-cell" title="@(report.TrackingNote ?? string.Empty)">
<span class="badge @GetTrackingBadgeCss(report)">@GetTrackingLabel(report)</span>
<td class="inbox-tracking-cell">
<span class="badge @GetTrackingBadgeCss(report)" title="@GetTrackingHelp(report)">@GetTrackingLabel(report)</span>
</td>
<td class="inbox-action-cell">
<button type="button"
@@ -432,7 +437,7 @@
{
<div class="border rounded p-3 mb-2 @(comment.IsNew ? "border-success bg-success-subtle" : "bg-light")">
<div class="d-flex flex-column flex-md-row justify-content-between gap-2 small text-muted mb-2">
<strong>@GetCommentAuthorLabel(comment.Type)</strong>
<strong>@GetCommentAuthorLabel(comment)</strong>
<span>@FormatDate(comment.CreationDate)</span>
</div>
@if (comment.IsNew)
@@ -482,6 +487,10 @@
<span class="small text-muted">@FormatDate(file.CreationDate)</span>
</div>
<div class="small text-muted">@FormatBytes(file.Size) @(string.IsNullOrWhiteSpace(file.ContentType) ? string.Empty : $" - {file.ContentType}")</div>
@if (!string.IsNullOrWhiteSpace(file.AuthorName))
{
<div class="small text-muted">Añadido por @file.AuthorName</div>
}
@if (file.IsNew)
{
<span class="badge bg-success mt-2">Nuevo</span>
@@ -703,6 +712,37 @@
return;
}
var selectedReports = Reports
.Where(report => SelectedIds.Contains(report.Id))
.Where(CanUseReport)
.OrderBy(report => report.Progressive ?? 0)
.ToList();
if (selectedReports.Count == 0)
{
SetStatus("No hay denuncias accesibles seleccionadas para importar.", "alert-warning");
return;
}
var reportsFromOtherOwners = selectedReports
.Where(report => report.RequiresOwnerConfirmation)
.ToArray();
if (reportsFromOtherOwners.Length > 0)
{
var ownerSummary = string.Join(
Environment.NewLine,
reportsFromOtherOwners.Select(report =>
$"Denuncia #{report.Progressive ?? 0}: propiedad de {report.OwnerUsername}."));
var confirmed = await Dialogs.ConfirmAsync(
"Denuncias asignadas a otros usuarios",
$"Vas a importar denuncias asignadas a otros usuarios de tu grupo:{Environment.NewLine}{Environment.NewLine}{ownerSummary}{Environment.NewLine}{Environment.NewLine}La importación quedará registrada con tu usuario.",
"Importar denuncias");
if (!confirmed)
{
return;
}
}
ImportBusy = true;
var importedCount = 0;
var errors = new List<string>();
@@ -710,18 +750,6 @@
try
{
var selectedReports = Reports
.Where(report => SelectedIds.Contains(report.Id))
.Where(CanUseReport)
.OrderBy(report => report.Progressive ?? 0)
.ToList();
if (selectedReports.Count == 0)
{
SetStatus("No hay denuncias accesibles seleccionadas para importar.", "alert-warning");
return;
}
using var busy = Busy.Show(
"Importando denuncias",
$"Descargando y procesando {selectedReports.Count} denuncia(s) desde GlobalLeaks.",
@@ -739,7 +767,10 @@
try
{
var result = await ApiDenuncias.ImportReportAsync(report, CancellationToken.None);
var result = await ApiDenuncias.ImportReportAsync(
report,
report.RequiresOwnerConfirmation,
CancellationToken.None);
importedCount += result.ImportedCount;
errors.AddRange(result.Errors.Select(error => $"#{report.Progressive ?? 0}: {error}"));
if (result.Warnings is not null)
@@ -898,7 +929,10 @@
filtered = Filter switch
{
"new" => filtered.Where(report => string.IsNullOrWhiteSpace(report.AccessDate) || string.Equals(report.Status, "new", StringComparison.OrdinalIgnoreCase)),
"updated" => filtered.Where(report => report.CitizenHasNewActivity || report.Updated),
"updated" => filtered.Where(report =>
report.CitizenHasNewActivity ||
(!report.ActivityAnalyzed && report.Updated)),
"receiver" => filtered.Where(report => report.ReceiverHasNewActivity),
_ => filtered
};
@@ -1144,7 +1178,9 @@
var receiverDate = FormatOptionalDate(report.ReceiverLastActivity);
if (!string.IsNullOrWhiteSpace(receiverDate))
{
return receiverDate;
return string.IsNullOrWhiteSpace(report.ReceiverLastActivityAuthorName)
? receiverDate
: $"{receiverDate} · {report.ReceiverLastActivityAuthorName}";
}
if (!report.ActivityAnalyzed)
@@ -1155,6 +1191,16 @@
return "Sin actividad";
}
private static string GetReceiverActivityTitle(ReportDto report)
{
if (string.IsNullOrWhiteSpace(report.ReceiverLastActivityAuthorName))
{
return FormatReceiverActivity(report);
}
return $"Ultima actividad OAAF realizada por {report.ReceiverLastActivityAuthorName}.";
}
private static string FormatBytes(long? value)
{
if (value is null or <= 0)
@@ -1176,11 +1222,13 @@
return $"{bytes / 1024d / 1024d:0.#} MB";
}
private static string GetCommentAuthorLabel(string? type)
=> IsWhistleblowerActivityType(type)
private static string GetCommentAuthorLabel(ReportCommentDto comment)
=> IsWhistleblowerActivityType(comment.Type)
? "Denunciante"
: IsReceiverActivityType(type)
? "Receptor"
: IsReceiverActivityType(comment.Type)
? string.IsNullOrWhiteSpace(comment.AuthorName)
? "Gestor OAAF"
: $"Gestor OAAF: {comment.AuthorName}"
: "Comentario";
private static bool IsWhistleblowerActivityType(string? value)
@@ -1236,7 +1284,7 @@
if (report.CitizenHasNewActivity)
{
return "Actualizacion ciudadano";
return "Actualización ciudadano";
}
if (IsReceiverOnlyUpdate(report))
@@ -1244,14 +1292,19 @@
return "Actividad OAAF";
}
if (report.Updated)
if (report.AlreadyInGestiona && !report.ActivityAnalyzed)
{
return "Sin comprobar";
}
if (IsUpToDateInGestiona(report))
{
return "Actualizada";
}
return string.Equals(report.Status, "closed", StringComparison.OrdinalIgnoreCase)
? "Cerrada"
: "Abierta";
: "Nueva denuncia";
}
private static string GetStatusBadgeCss(ReportDto report)
@@ -1271,7 +1324,12 @@
return "bg-secondary";
}
if (report.Updated)
if (report.AlreadyInGestiona && !report.ActivityAnalyzed)
{
return "bg-warning text-dark";
}
if (IsUpToDateInGestiona(report))
{
return "bg-light text-dark";
}
@@ -1281,6 +1339,47 @@
: "bg-primary";
}
private static bool IsUpToDateInGestiona(ReportDto report)
{
if (!report.AlreadyInGestiona)
{
return false;
}
var lastUpload = ParseDate(report.LastGestionaUploadAt);
var latestActivity = new[]
{
ParseDate(report.CitizenLastActivity),
ParseDate(report.ReceiverLastActivity)
}
.Where(value => value is not null)
.Select(value => value!.Value)
.DefaultIfEmpty()
.Max();
if (lastUpload is not null && latestActivity != default)
{
return lastUpload.Value >= latestActivity;
}
return report.ActivityAnalyzed &&
!report.CitizenHasNewActivity &&
!report.ReceiverHasNewActivity;
}
private static string GetStatusHelp(ReportDto report)
=> GetStatusLabel(report) switch
{
"Nueva denuncia" => "La denuncia todavía no tiene expediente creado desde la aplicación y está pendiente de actuación.",
"Actualización ciudadano" => "El ciudadano ha añadido un comentario, un fichero o una modificación posterior a la última subida a Gestiona.",
"Actividad OAAF" => "Un gestor de la OAAF ha realizado una comunicación o añadido un fichero posterior a la última subida a Gestiona.",
"Actualizada" => "La última subida a Gestiona es igual o posterior a la actividad del ciudadano y de la OAAF detectada en el buzón.",
"Sin comprobar" => "No se ha podido comparar en este momento la actividad del buzón con la última subida a Gestiona.",
"Cerrada" => "La denuncia figura cerrada en GlobalLeaks.",
"Sin leer" => "La denuncia todavía no se ha abierto con este usuario en GlobalLeaks.",
_ => string.Empty
};
private static string GetAccessLabel(ReportDto report)
=> report.Accessible switch
{
@@ -1297,8 +1396,16 @@
_ => "bg-light text-dark"
};
private static string GetAccessHelp(ReportDto report)
=> report.Accessible switch
{
true => "Tu usuario gestor del buzón tiene acceso a esta denuncia.",
false => "GlobalLeaks indica que tu usuario gestor del buzón no tiene acceso a esta denuncia.",
_ => "GlobalLeaks no ha informado si tu usuario puede acceder a esta denuncia."
};
private static bool CanUseReport(ReportDto report)
=> report.Accessible != false && !IsReceiverOnlyUpdate(report);
=> report.Accessible != false;
private static bool IsReceiverOnlyUpdate(ReportDto report)
=> report.AlreadyInGestiona &&
@@ -1313,11 +1420,6 @@
return "GlobalLeaks indica que esta denuncia no es accesible para este usuario.";
}
if (IsReceiverOnlyUpdate(report))
{
return "La actividad nueva procede de OAAF/gestor; no se importa como actualizacion del ciudadano.";
}
return null;
}
@@ -1328,11 +1430,6 @@
return "GlobalLeaks indica que esta denuncia no es accesible para este usuario.";
}
if (IsReceiverOnlyUpdate(report))
{
return "Actividad interna detectada; puedes consultar el detalle, pero no importarla como actualizacion.";
}
return "Consulta mensajes y ficheros. GlobalLeaks puede marcar la denuncia como leida.";
}
@@ -1386,6 +1483,35 @@
return "bg-light text-dark";
}
private static string GetTrackingHelp(ReportDto report)
{
if (report.AlreadyInGestiona)
{
return "La denuncia ya tiene un expediente creado en Gestiona. El estado indica si existe actividad posterior pendiente.";
}
if (report.DownloadedByAnotherUser)
{
return string.IsNullOrWhiteSpace(report.TrackingNote)
? "Otro usuario ya la descargó en la aplicación, pero todavía no se ha materializado la subida a Gestiona."
: $"{report.TrackingNote}. Todavía no se ha materializado la subida a Gestiona.";
}
if (report.DownloadedByCurrentUser)
{
return string.IsNullOrWhiteSpace(report.TrackingNote)
? "Ya la descargaste en la aplicación, pero todavía no se ha materializado la subida a Gestiona."
: $"{report.TrackingNote}. Todavía no se ha materializado la subida a Gestiona.";
}
if (report.AlreadyImported)
{
return "La denuncia está incorporada a la aplicación, pero todavía no se ha subido a Gestiona.";
}
return "La denuncia todavía no se ha descargado ni subido a Gestiona.";
}
private static string? GetReportRowCss(ReportDto report)
{
if (report.Accessible == false)
@@ -1395,7 +1521,7 @@
if (IsReceiverOnlyUpdate(report))
{
return "table-secondary report-row-disabled";
return "table-secondary";
}
if (report.AlreadyInGestiona)

View File

@@ -114,28 +114,28 @@ else
<div class="card-header" data-bs-toggle="collapse" data-bs-target="#@collapseId" aria-expanded="false" aria-controls="@collapseId">
<h5 class="mb-0">Denuncia ID: @denuncia.Id_Denuncia</h5>
<div class="header-info">
<span><strong>Asunto:</strong> @GetHeaderSubject(denuncia, latestHistory)</span>
<span title="Asunto con el que se identifica el expediente en Gestiona."><strong>Asunto:</strong> @GetHeaderSubject(denuncia, latestHistory)</span>
@if (!string.IsNullOrWhiteSpace(denuncia.ExpedienteGestionaMostrable))
{
<span><strong>Nº expediente:</strong> @denuncia.ExpedienteGestionaMostrable</span>
<span title="Número asignado al expediente por Gestiona."><strong>Nº expediente:</strong> @denuncia.ExpedienteGestionaMostrable</span>
}
@if (!string.IsNullOrWhiteSpace(GetUploadType(denuncia, latestHistory)))
{
<span><strong>Última subida:</strong> @GetUploadType(denuncia, latestHistory)</span>
<span title="Tipo de la última operación enviada desde la aplicación a Gestiona."><strong>Última subida:</strong> @GetUploadType(denuncia, latestHistory)</span>
}
@if (!string.IsNullOrWhiteSpace(GetAssignedGroup(denuncia, latestHistory)))
{
<span><strong>Grupo asignado:</strong> @FormatGroupCode(GetAssignedGroup(denuncia, latestHistory))</span>
<span title="Grupo al que quedó asignado el expediente tras la última subida."><strong>Asignación en Gestiona:</strong> @FormatGroupCode(GetAssignedGroup(denuncia, latestHistory))</span>
}
@if (!string.IsNullOrWhiteSpace(GetUploadUser(latestHistory)))
{
<span><strong>Usuario subida:</strong> @GetUploadUser(latestHistory)</span>
<span title="Usuario de la aplicación que realizó la última subida."><strong>Usuario subida:</strong> @GetUploadUser(latestHistory)</span>
}
<span><strong>Fecha de Subida:</strong> @FormatUploadDate(uploadMoment)</span>
<span><strong>Hora de Subida:</strong> @FormatUploadTime(uploadMoment)</span>
<span title="Fecha de la última operación enviada a Gestiona."><strong>Fecha de Subida:</strong> @FormatUploadDate(uploadMoment)</span>
<span title="Hora local de la última operación enviada a Gestiona."><strong>Hora de Subida:</strong> @FormatUploadTime(uploadMoment)</span>
@if (!string.IsNullOrWhiteSpace(GetAuditDateText(denuncia)))
{
<span><strong>Última actividad Gestiona:</strong> @GetAuditDateText(denuncia)</span>
<span title="Último movimiento registrado en la auditoría del expediente de Gestiona."><strong>Última actividad Gestiona:</strong> @GetAuditDateText(denuncia)</span>
}
</div>
</div>
@@ -331,16 +331,16 @@ else
<div class="card-header">
<h5 class="mb-0">Denuncia ID: @item.DenunciaId</h5>
<div class="header-info">
<span><strong>Asunto:</strong> @DisplayOrDash(item.Asunto)</span>
<span><strong>Nº expediente:</strong> @DisplayOrDash(item.CodigoExpedienteGestiona)</span>
<span><strong>Última subida:</strong> @DisplayOrDash(item.TipoSubida)</span>
<span><strong>Grupo asignado:</strong> @DisplayOrDash(FormatGroupCode(item.GrupoAsignado))</span>
<span title="Asunto con el que se identifica el expediente en Gestiona."><strong>Asunto:</strong> @DisplayOrDash(item.Asunto)</span>
<span title="Número asignado al expediente por Gestiona."><strong>Nº expediente:</strong> @DisplayOrDash(item.CodigoExpedienteGestiona)</span>
<span title="Tipo de la última operación enviada desde la aplicación a Gestiona."><strong>Última subida:</strong> @DisplayOrDash(item.TipoSubida)</span>
<span title="Grupo al que quedó asignado el expediente tras la última subida."><strong>Asignación en Gestiona:</strong> @DisplayOrDash(FormatGroupCode(item.GrupoAsignado))</span>
@if (!string.IsNullOrWhiteSpace(GetUploadUser(item)))
{
<span><strong>Usuario subida:</strong> @GetUploadUser(item)</span>
<span title="Usuario de la aplicación que realizó la última subida."><strong>Usuario subida:</strong> @GetUploadUser(item)</span>
}
<span><strong>Fecha de Subida:</strong> @FormatUploadDate(uploadMoment)</span>
<span><strong>Hora de Subida:</strong> @FormatUploadTime(uploadMoment)</span>
<span title="Fecha de la última operación enviada a Gestiona."><strong>Fecha de Subida:</strong> @FormatUploadDate(uploadMoment)</span>
<span title="Hora local de la última operación enviada a Gestiona."><strong>Hora de Subida:</strong> @FormatUploadTime(uploadMoment)</span>
<span class="text-muted">Detalle no disponible por purga criptográfica.</span>
</div>
</div>

View File

@@ -56,7 +56,7 @@
<li>Abre la denuncia para revisar sus datos y adjuntos.</li>
<li>En la lista de ficheros, deja marcado solo lo que quieras subir.</li>
<li>El report de la denuncia se sube siempre y no se puede desmarcar.</li>
<li>Pulsa <strong>Configurar subida</strong> para indicar asunto, grupo destino y modo de subida.</li>
<li>Pulsa <strong>Configurar apertura expediente</strong> para indicar asunto, grupo destino y modo de subida.</li>
<li>Confirma para crear el expediente, vincular el tercero y subir los documentos a Gestiona.</li>
<li>Si la denuncia no procede, usa <strong>Rechazar denuncia</strong> e indica el motivo.</li>
</ul>
@@ -67,15 +67,15 @@
<div class="col-12 col-xl-6">
<div class="card h-100">
<div class="card-body">
<h2 class="h5">Modal de subida a Gestiona</h2>
<h2 class="h5">Ventana de configuración de la subida</h2>
<p>
Antes de confirmar la subida, revisa estos puntos:
Al configurar un expediente nuevo o una actualización, revisa estos puntos antes de confirmar:
</p>
<ul class="mb-0">
<li><strong>Asunto</strong>: texto que identificara el expediente/documentos en Gestiona.</li>
<li><strong>Grupo destino</strong>: unidad a la que se asignara el expediente.</li>
<li><strong>Asunto</strong>: texto que identifica el expediente en Gestiona. En las actualizaciones se muestra en modo de solo lectura.</li>
<li><strong>Grupo destino</strong>: grupo al que quedará asignado el expediente en Gestiona.</li>
<li><strong>Modo de subida</strong>: puedes unir adjuntos en un PDF o subirlos de forma independiente.</li>
<li><strong>Tercero</strong>: la app lo rellena desde la denuncia. Si es anonima, se usa el tercero anonimo configurado.</li>
<li><strong>Tercero</strong>: la aplicación lo completa con los datos de la denuncia. Si es anónima, se utiliza el tercero anónimo configurado.</li>
<li><strong>Expedientes del tercero</strong>: puedes consultarlos antes de confirmar si necesitas contexto.</li>
</ul>
</div>
@@ -87,14 +87,15 @@
<div class="card-body">
<h2 class="h5">Actualizaciones</h2>
<p>
Esta pantalla recoge comunicaciones o adjuntos nuevos sobre denuncias que ya tienen expediente en Gestiona.
Esta pantalla recoge comentarios, comunicaciones o adjuntos nuevos sobre denuncias que ya tienen expediente en Gestiona.
</p>
<ul class="mb-0">
<li>Revisa la actualizacion y sus ficheros.</li>
<li>La app propone los adjuntos que parecen nuevos.</li>
<li>Puedes desmarcar los adjuntos que no quieras subir.</li>
<li>El report de la actualizacion se mantiene obligatorio.</li>
<li>Confirma la subida para a<EFBFBD>adir los nuevos documentos al expediente existente.</li>
<li>Pulsa <strong>Configurar actualización expediente</strong> y confirma para añadir el cambio al expediente existente.</li>
<li>Si la actividad procede de la OAAF, la aplicación utiliza el aviso correspondiente al grupo SAJ o SDI.</li>
</ul>
</div>
</div>
@@ -105,12 +106,12 @@
<div class="card-body">
<h2 class="h5">Gestiona</h2>
<p>
Aqui se consultan las denuncias que ya se han enviado a Gestiona.
Aquí se almacena el histórico permanente de los movimientos enviados desde esta aplicación a Gestiona.
</p>
<ul class="mb-0">
<li>Comprueba el numero de expediente y la fecha de envio.</li>
<li>Accede al enlace del expediente cuando necesites revisar la tramitacion en Gestiona.</li>
<li>Usa esta pantalla como seguimiento de lo que ya salio de Pendientes o Actualizaciones.</li>
<li>Comprueba el número de expediente, el tipo de subida, el usuario que la realizó y la asignación en Gestiona.</li>
<li>Despliega una operación del día para consultar sus datos y documentos mientras estén disponibles.</li>
<li>Por requisitos de seguridad ENS, los detalles sensibles de días anteriores dejan de estar disponibles; la cabecera operativa y la trazabilidad permanecen visibles.</li>
</ul>
</div>
</div>
@@ -121,7 +122,7 @@
<div class="card-body">
<h2 class="h5">Rechazados</h2>
<p>
Aqui quedan las denuncias que se han descartado desde Pendientes.
Aquí quedan las denuncias o actualizaciones que no se han subido a Gestiona porque se descartaron desde la pantalla de trabajo.
</p>
<ul class="mb-0">
<li>Consulta el motivo indicado al rechazar.</li>
@@ -165,4 +166,4 @@
<div class="alert alert-warning mt-3 mb-0">
Si una pantalla indica que una denuncia no esta disponible, no intentes tramitarla desde otra ruta: revisa la bandeja de Entrada o consulta con soporte del sistema.
</div>
</div>
</div>

View File

@@ -19,6 +19,7 @@
@inject IDenunciaStore DenunciaStore
@inject ApiDenunciasClient ApiDenuncias
@inject UiBusyService Busy
@inject UiDialogService Dialogs
<PageTitle>Denuncias Pendientes</PageTitle>
@@ -199,7 +200,21 @@ else
data-bs-target="#@collapseId"
aria-expanded="false"
aria-controls="@collapseId">
<h5 class="mb-0">Denuncia ID: @denuncia.Id_Denuncia</h5>
<div class="d-flex flex-wrap align-items-center gap-2">
<h5 class="mb-0">Denuncia ID: @denuncia.Id_Denuncia</h5>
@if (!string.IsNullOrWhiteSpace(denuncia.OwnerUsername))
{
<span class="badge @(denuncia.OwnedByCurrentUser ? "text-bg-secondary" : "text-bg-warning")">
Responsable: @denuncia.OwnerUsername
</span>
}
@if (denuncia.OwnerWorkGroups.Count > 0)
{
<span class="badge text-bg-light">
Grupo: @string.Join(" / ", denuncia.OwnerWorkGroups)
</span>
}
</div>
<div>
<button type="button"
class="btn btn-success btn-sm me-2"
@@ -587,7 +602,7 @@ else
600. Asuntos Jurídicos y Protección a la Persona Denunciante
</label>
</div>
@* <div class="form-check">
<div class="form-check">
<input class="form-check-input"
type="radio"
name="selectedGroup"
@@ -598,17 +613,6 @@ else
510. SDI Investigación Entradas
</label>
</div>
<div class="form-check">
<input class="form-check-input"
type="radio"
name="selectedGroup"
id="grupo700"
checked='@(selectedGroup == "700")'
@onclick='() => selectedGroup = "700"' />
<label class="form-check-label" for="grupo700">
700. RESPONSABLE DEL SERVICIO
</label>
</div> *@
<!-- DATOS DEL TERCERO -->
@{
@@ -948,6 +952,11 @@ else
return;
}
if (!await ConfirmSelectedGroupAsync())
{
return;
}
try
{
isUploading = true;
@@ -1288,8 +1297,13 @@ else
await DenunciaStore.UpsertDenunciaAsync(d);
}
private void OpenEnviarAGestionaModal(DenunciasGestiona d)
private async Task OpenEnviarAGestionaModal(DenunciasGestiona d)
{
if (!await ConfirmDifferentOwnerAsync(d, "tramitar"))
{
return;
}
selectedDenuncias = d;
nuevoAsunto = $"Denuncia {d.Id_Denuncia}-CD";
@@ -1301,13 +1315,64 @@ else
showModal = true;
}
private void OpenRechazarModal(DenunciasGestiona d)
private async Task OpenRechazarModal(DenunciasGestiona d)
{
if (!await ConfirmDifferentOwnerAsync(d, "rechazar"))
{
return;
}
selectedDenuncias = d;
motivoRechazo = string.Empty;
showModalRechazo = true;
}
private async Task<bool> ConfirmDifferentOwnerAsync(
DenunciasGestiona denuncia,
string action)
{
if (!denuncia.RequiresOwnerConfirmation)
{
return true;
}
var confirmText = string.Equals(action, "rechazar", StringComparison.OrdinalIgnoreCase)
? "Continuar con el rechazo"
: "Continuar con la apertura";
return await Dialogs.ConfirmAsync(
"Denuncia asignada a otro usuario",
$"La denuncia #{denuncia.Id_Denuncia} está asignada a {denuncia.OwnerUsername}. " +
$"Puedes {action}la porque pertenece a un usuario de tu grupo. La acción quedará registrada con tu usuario.",
confirmText);
}
private async Task<bool> ConfirmSelectedGroupAsync()
{
try
{
var currentGroups = await ApiDenuncias.GetCurrentUserWorkGroupsAsync();
if (currentGroups.GroupCodes.Contains(selectedGroup, StringComparer.OrdinalIgnoreCase))
{
return true;
}
var configuredGroups = currentGroups.GroupCodes.Count == 0
? "ningún grupo"
: string.Join(", ", currentGroups.GroupCodes);
return await Dialogs.ConfirmAsync(
"Cambio de asignación en Gestiona",
$"El grupo de destino {selectedGroup} no pertenece a tus grupos configurados ({configuredGroups}). " +
"Si continúas, la denuncia quedará asignada en Gestiona a un grupo distinto de los tuyos.",
"Cambiar asignación");
}
catch (Exception ex)
{
operationError = $"No se han podido comprobar tus grupos antes de la subida: {ex.Message}";
return false;
}
}
private void CloseModal()
{
showModal = false;

View File

@@ -52,6 +52,7 @@ builder.Services.AddScoped<UserState>();
builder.Services.AddSingleton<AppSessionLifetime>();
builder.Services.AddSingleton<LoginRateLimiter>();
builder.Services.AddScoped<UiBusyService>();
builder.Services.AddScoped<UiDialogService>();
builder.Services.AddScoped<ApiDenunciasClient>();
builder.Services.AddScoped<IDenunciaStore, ApiDenunciaStore>();
builder.Services.AddScoped<IInboxTrackingService, ApiInboxTrackingService>();

View File

@@ -47,6 +47,15 @@ public sealed class ApiDenunciasClient
public Task<ApiGlobalLeaksSessionDto?> GetGlobalLeaksSessionAsync(CancellationToken cancellationToken = default)
=> SendAsync<ApiGlobalLeaksSessionDto?>(HttpMethod.Get, "api/inbox/session", body: null, authorize: true, cancellationToken, allowNull: true);
public Task<ApiGlobalLeaksSessionDto?> KeepGlobalLeaksSessionAliveAsync(CancellationToken cancellationToken = default)
=> SendAsync<ApiGlobalLeaksSessionDto?>(
HttpMethod.Post,
"api/inbox/session/keepalive",
body: null,
authorize: true,
cancellationToken,
allowNull: true);
public Task<ApiLoginPrepareResponse> PrepareGlobalLeaksSessionRenewalAsync(CancellationToken cancellationToken = default)
=> SendAsync<ApiLoginPrepareResponse>(
HttpMethod.Post,
@@ -72,11 +81,14 @@ public sealed class ApiDenunciasClient
public Task<InboxSnapshotResponse> LoadInboxAsync(CancellationToken cancellationToken = default)
=> SendAsync<InboxSnapshotResponse>(HttpMethod.Get, "api/inbox/reports", body: null, authorize: true, cancellationToken);
public Task<ImportSummary> ImportReportAsync(ReportDto report, CancellationToken cancellationToken = default)
public Task<ImportSummary> ImportReportAsync(
ReportDto report,
bool confirmDifferentOwner = false,
CancellationToken cancellationToken = default)
=> SendAsync<ImportSummary>(
HttpMethod.Post,
$"api/inbox/reports/{Uri.EscapeDataString(report.Id)}/import",
new ImportReportRequest(report),
new ImportReportRequest(report, confirmDifferentOwner),
authorize: true,
cancellationToken);
@@ -191,10 +203,16 @@ public sealed class ApiDenunciasClient
string assignedGroupCode,
int? complaintId,
bool isUpdate = false,
string? updateSource = null,
CancellationToken cancellationToken = default)
=> PostAsync(
"api/gestiona/documents/tramitar",
new GestionaTramitarDocumentoRequest(documentUrl, assignedGroupCode, complaintId, isUpdate),
new GestionaTramitarDocumentoRequest(
documentUrl,
assignedGroupCode,
complaintId,
isUpdate,
updateSource),
cancellationToken);
public Task<List<ExpedienteTerceroDto>> GetGestionaExpedientesPorTerceroAsync(
@@ -255,6 +273,29 @@ public sealed class ApiDenunciasClient
authorize: true,
cancellationToken);
public Task<WorkGroupAdministrationDto> GetWorkGroupAdministrationAsync(
CancellationToken cancellationToken = default)
=> GetAsync<WorkGroupAdministrationDto>(
"api/configuration/work-groups",
cancellationToken);
public Task<CurrentUserWorkGroupsDto> GetCurrentUserWorkGroupsAsync(
CancellationToken cancellationToken = default)
=> GetAsync<CurrentUserWorkGroupsDto>(
"api/configuration/work-groups/current",
cancellationToken);
public Task<WorkGroupAdministrationDto> UpdateUserWorkGroupsAsync(
string username,
IReadOnlyList<string> groupCodes,
CancellationToken cancellationToken = default)
=> SendAsync<WorkGroupAdministrationDto>(
HttpMethod.Put,
$"api/configuration/work-groups/users/{Uri.EscapeDataString(username)}",
new UpdateUserWorkGroupsRequest(groupCodes),
authorize: true,
cancellationToken);
internal Task<T> GetAsync<T>(string path, CancellationToken cancellationToken = default, bool allowNull = false)
=> SendAsync<T>(HttpMethod.Get, path, body: null, authorize: true, cancellationToken, allowNull);

View File

@@ -46,9 +46,10 @@ public sealed class ApiInboxTrackingService : IInboxTrackingService
public Task EnsureReportCanBeImportedByUserAsync(
string username,
ReportDto report,
bool confirmDifferentOwner = false,
CancellationToken cancellationToken = default)
=> _api.PostAsync(
"api/tracking/import-permission",
new TrackingImportPermissionRequest(username, report),
new TrackingImportPermissionRequest(username, report, confirmDifferentOwner),
cancellationToken);
}

View File

@@ -0,0 +1,64 @@
namespace GestionaDenunciasAN.Services;
public enum UiDialogTone
{
Information,
Warning,
Danger
}
public sealed class UiDialogService
{
private TaskCompletionSource<bool>? _pendingConfirmation;
public event Action? Changed;
public bool IsVisible { get; private set; }
public string Title { get; private set; } = string.Empty;
public string Message { get; private set; } = string.Empty;
public string ConfirmText { get; private set; } = "Continuar";
public string CancelText { get; private set; } = "Cancelar";
public UiDialogTone Tone { get; private set; } = UiDialogTone.Warning;
public Task<bool> ConfirmAsync(
string title,
string message,
string confirmText = "Continuar",
string cancelText = "Cancelar",
UiDialogTone tone = UiDialogTone.Warning)
{
_pendingConfirmation?.TrySetResult(false);
Title = title.Trim();
Message = message.Trim();
ConfirmText = confirmText.Trim();
CancelText = cancelText.Trim();
Tone = tone;
IsVisible = true;
_pendingConfirmation = new TaskCompletionSource<bool>(
TaskCreationOptions.RunContinuationsAsynchronously);
Changed?.Invoke();
return _pendingConfirmation.Task;
}
public void Confirm()
{
Complete(true);
}
public void Cancel()
{
Complete(false);
}
private void Complete(bool confirmed)
{
var pendingConfirmation = _pendingConfirmation;
_pendingConfirmation = null;
IsVisible = false;
Changed?.Invoke();
pendingConfirmation?.TrySetResult(confirmed);
}
}

View File

@@ -68,3 +68,62 @@ window.appSetBodyScrollLock = function (locked) {
document.documentElement.classList.toggle("app-scroll-locked", Boolean(locked));
document.body.classList.toggle("app-scroll-locked", Boolean(locked));
};
window.appGlobalLeaksActivity = (function () {
const storageKey = "gestiona-denuncias:last-user-activity";
const events = ["pointerdown", "pointermove", "keydown", "touchstart", "scroll"];
let lastActivity = Date.now();
let tracking = false;
function readSharedActivity() {
try {
const stored = Number(window.localStorage.getItem(storageKey));
return Number.isFinite(stored) && stored > 0 ? stored : 0;
} catch {
return 0;
}
}
function markActivity() {
lastActivity = Date.now();
try {
window.localStorage.setItem(storageKey, String(lastActivity));
} catch {
// The in-memory timestamp still works when local storage is unavailable.
}
}
function start() {
if (tracking) {
return;
}
tracking = true;
markActivity();
for (const eventName of events) {
window.addEventListener(eventName, markActivity, { passive: true });
}
}
function stop() {
if (!tracking) {
return;
}
tracking = false;
for (const eventName of events) {
window.removeEventListener(eventName, markActivity);
}
}
function getIdleMilliseconds() {
const latestActivity = Math.max(lastActivity, readSharedActivity());
return Math.max(0, Date.now() - latestActivity);
}
return {
start,
stop,
getIdleMilliseconds
};
})();

View File

@@ -170,8 +170,9 @@
}
var separator = certLoginBaseUrl.Contains('?') ? "&" : "?";
var url = $"{certLoginBaseUrl}{separator}iframe=true&parentOrigin={Uri.EscapeDataString(parentOrigin)}";
await JSRuntime.InvokeVoidAsync("iniciarSesionConCertificado", url);
var url =
$"{certLoginBaseUrl}{separator}iframe=true&origen=Registro&parentOrigin={Uri.EscapeDataString(parentOrigin)}";
await JSRuntime.InvokeVoidAsync("iniciarSesionConCertificado", url);
}

View File

@@ -55,7 +55,8 @@ namespace RegistroPersonalAN.Services
var client = _httpClientFactory.CreateClient("DefaultClient");
using var response = await client.PostAsJsonAsync("Auth/login-cert-proxy", new CertificateProxyLoginRequest
{
Dni = dni
Dni = dni,
Origen = "Registro"
});
var responseContent = await response.Content.ReadAsStringAsync();
@@ -96,6 +97,7 @@ namespace RegistroPersonalAN.Services
public sealed class CertificateProxyLoginRequest
{
public string Dni { get; set; } = string.Empty;
public string Origen { get; set; } = string.Empty;
}
public sealed class CertificateProxyLoginResponse

View File

@@ -46,7 +46,7 @@ namespace SwaggerAntifraude.Controllers
[AllowAnonymous]
[HttpGet("login-cert")]
public IActionResult LoginWithCertificateGet([FromQuery] bool iframe = false)
public IActionResult LoginWithCertificateGet([FromQuery] bool iframe = false, [FromQuery] string? origen = null)
{
var clientCert = HttpContext.Connection.ClientCertificate;
if (clientCert == null)
@@ -56,7 +56,7 @@ namespace SwaggerAntifraude.Controllers
if (string.IsNullOrWhiteSpace(dni))
return Unauthorized("No se pudo obtener un DNI válido del certificado.");
var result = AuthenticateCertificateDni(dni);
var result = AuthenticateCertificateDni(dni, origen);
if (result.Token == null || result.Persona == null)
return Unauthorized(result.Error ?? "No se pudo autenticar el certificado.");
@@ -82,14 +82,14 @@ namespace SwaggerAntifraude.Controllers
return BadRequest("Debe indicarse un DNI.");
}
var result = AuthenticateCertificateDni(request.Dni);
var result = AuthenticateCertificateDni(request.Dni, request.Origen);
if (result.Token == null || result.Persona == null)
return Unauthorized(result.Error ?? "No se pudo autenticar el certificado.");
return Ok(BuildLoginResponse(result.Persona, result.Token));
}
private (string? Token, PERSONAS? Persona, string? Error) AuthenticateCertificateDni(string dni)
private (string? Token, PERSONAS? Persona, string? Error) AuthenticateCertificateDni(string dni, string? origen)
{
using var context = tsGestionAntifraude.NuevoContexto(SoloLectura: true);
@@ -100,7 +100,8 @@ namespace SwaggerAntifraude.Controllers
{
return (null, null, "Usuario no encontrado en la base de datos.");
}
if (persona.ADMINISTRARPTYREGISTRO != true)
if (string.Equals(origen, "Registro", StringComparison.OrdinalIgnoreCase)
&& persona.ADMINISTRARPTYREGISTRO != true)
{
return (null, null, "Usuario no autorizado.");
}

View File

@@ -3,5 +3,7 @@ namespace SwaggerAntifraude.DTOs
public class CertificateProxyLoginDto
{
public string Dni { get; set; } = string.Empty;
public string? Origen { get; set; }
}
}