From dd31460cf094bfdc3dc74021ee0528cebaf25f5a Mon Sep 17 00:00:00 2001 From: Pedro Date: Thu, 23 Jul 2026 12:37:06 +0200 Subject: [PATCH] arreglo certificados de registro --- .../Controllers/ConfigurationController.cs | 38 +- .../Controllers/DenunciasController.cs | 46 ++- .../Controllers/InboxController.cs | 14 +- .../Controllers/TrackingController.cs | 15 +- Antifraude.Net/ApiDenuncias/Program.cs | 1 + .../Scripts/gestiondenuncias_schema.sql | 57 +++ .../Services/GlobalLeaksClient.cs | 142 ++++++- .../Services/InboxTrackingService.cs | 118 ++++-- .../Services/MySqlDenunciaStore.cs | 50 +++ .../Services/UserComplaintAccessService.cs | 158 +++++++- .../WorkGroupAdministrationService.cs | 378 ++++++++++++++++++ .../Models/ApiDenunciasDtos.cs | 23 +- .../Models/DenunciasGestiona.cs | 5 + .../Models/ReportDetailDto.cs | 16 +- .../Models/ReportDto.cs | 7 + .../Services/IInboxTrackingService.cs | 1 + .../Components/Pages/Actualizaciones.razor | 29 +- .../Components/Pages/Configuracion.razor | 198 +++++++++ .../Components/Pages/Entrada.razor | 102 +++-- .../Components/Pages/Pendientes.razor | 46 ++- .../Services/ApiDenunciasClient.cs | 24 +- .../Services/ApiInboxTrackingService.cs | 3 +- .../Components/Pages/Home.razor | 5 +- .../Controllers/AuthController.cs | 11 +- .../DTOs/CertificateProxyLoginDto.cs | 2 + 25 files changed, 1370 insertions(+), 119 deletions(-) create mode 100644 Antifraude.Net/ApiDenuncias/Services/WorkGroupAdministrationService.cs diff --git a/Antifraude.Net/ApiDenuncias/Controllers/ConfigurationController.cs b/Antifraude.Net/ApiDenuncias/Controllers/ConfigurationController.cs index 4f8f719..4e98c79 100644 --- a/Antifraude.Net/ApiDenuncias/Controllers/ConfigurationController.cs +++ b/Antifraude.Net/ApiDenuncias/Controllers/ConfigurationController.cs @@ -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,36 @@ public sealed class ConfigurationController : ControllerBase return Ok(await _configurationService.SetExternalUpdateCutoffDateAsync(date, cancellationToken)); } + + [HttpGet("work-groups")] + [Authorize(Policy = "ConfigurationAdministrators")] + public async Task> GetWorkGroups( + CancellationToken cancellationToken) + { + return Ok(await _workGroupService.GetAsync(cancellationToken)); + } + + [HttpPut("work-groups/users/{username}")] + [Authorize(Policy = "ConfigurationAdministrators")] + public async Task> 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)); + } + } } diff --git a/Antifraude.Net/ApiDenuncias/Controllers/DenunciasController.cs b/Antifraude.Net/ApiDenuncias/Controllers/DenunciasController.cs index f9b266c..b62a387 100644 --- a/Antifraude.Net/ApiDenuncias/Controllers/DenunciasController.cs +++ b/Antifraude.Net/ApiDenuncias/Controllers/DenunciasController.cs @@ -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()); } - 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 complaints, + IReadOnlyDictionary 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); diff --git a/Antifraude.Net/ApiDenuncias/Controllers/InboxController.cs b/Antifraude.Net/ApiDenuncias/Controllers/InboxController.cs index 7dc6461..cac6263 100644 --- a/Antifraude.Net/ApiDenuncias/Controllers/InboxController.cs +++ b/Antifraude.Net/ApiDenuncias/Controllers/InboxController.cs @@ -222,13 +222,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 @@ -286,6 +288,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); diff --git a/Antifraude.Net/ApiDenuncias/Controllers/TrackingController.cs b/Antifraude.Net/ApiDenuncias/Controllers/TrackingController.cs index 7414655..597411e 100644 --- a/Antifraude.Net/ApiDenuncias/Controllers/TrackingController.cs +++ b/Antifraude.Net/ApiDenuncias/Controllers/TrackingController.cs @@ -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() diff --git a/Antifraude.Net/ApiDenuncias/Program.cs b/Antifraude.Net/ApiDenuncias/Program.cs index 18900a0..d3a249d 100644 --- a/Antifraude.Net/ApiDenuncias/Program.cs +++ b/Antifraude.Net/ApiDenuncias/Program.cs @@ -44,6 +44,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddHttpClient(); builder.Services.AddScoped(); diff --git a/Antifraude.Net/ApiDenuncias/Scripts/gestiondenuncias_schema.sql b/Antifraude.Net/ApiDenuncias/Scripts/gestiondenuncias_schema.sql index fa2d54a..86e16bb 100644 --- a/Antifraude.Net/ApiDenuncias/Scripts/gestiondenuncias_schema.sql +++ b/Antifraude.Net/ApiDenuncias/Scripts/gestiondenuncias_schema.sql @@ -87,6 +87,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 +156,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 +165,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; diff --git a/Antifraude.Net/ApiDenuncias/Services/GlobalLeaksClient.cs b/Antifraude.Net/ApiDenuncias/Services/GlobalLeaksClient.cs index 7e7d588..1e98698 100644 --- a/Antifraude.Net/ApiDenuncias/Services/GlobalLeaksClient.cs +++ b/Antifraude.Net/ApiDenuncias/Services/GlobalLeaksClient.cs @@ -558,6 +558,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 +598,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 +630,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 +645,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 }; } @@ -1043,10 +1072,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 +1106,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 +1260,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 BuildReceiverNameLookup( + IEnumerable 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 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 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, @@ -1482,7 +1595,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 { diff --git a/Antifraude.Net/ApiDenuncias/Services/InboxTrackingService.cs b/Antifraude.Net/ApiDenuncias/Services/InboxTrackingService.cs index 3072e70..2d4bc32 100644 --- a/Antifraude.Net/ApiDenuncias/Services/InboxTrackingService.cs +++ b/Antifraude.Net/ApiDenuncias/Services/InboxTrackingService.cs @@ -104,16 +104,25 @@ public sealed class InboxTrackingService : IInboxTrackingService LastDownloadedAt = meta?.LastDownloadedAtUtc?.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 +149,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 +198,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 +317,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 +532,37 @@ 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 + 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 current_group + INNER JOIN app_user_groups owner_group + ON owner_group.work_group_id = current_group.work_group_id + INNER JOIN work_groups active_group + ON active_group.id = current_group.work_group_id + AND active_group.is_active = 1 + WHERE current_group.app_user_id = @userId + AND owner_group.app_user_id = ir.owner_user_id + ) 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 +579,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 { @@ -550,7 +602,10 @@ public sealed class InboxTrackingService : IInboxTrackingService LastDownloadedAtUtc = GetDateTimeOffset(reader, "last_downloaded_at_utc"), AlreadyImported = !reader.IsDBNull(reader.GetOrdinal("imported_to_store_at_utc")), AlreadyInGestiona = reader.GetInt32(reader.GetOrdinal("already_in_gestiona")) == 1, - LockedByAnotherUser = lockedByAnotherUser, + OwnerUsername = ownerUsername, + OwnedByCurrentUser = ownedByCurrentUser, + AccessibleByWorkGroup = accessibleByGroup, + OwnerWorkGroups = ownerWorkGroups, }; } @@ -654,11 +709,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 +747,25 @@ 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 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 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; } diff --git a/Antifraude.Net/ApiDenuncias/Services/MySqlDenunciaStore.cs b/Antifraude.Net/ApiDenuncias/Services/MySqlDenunciaStore.cs index d897429..aef4034 100644 --- a/Antifraude.Net/ApiDenuncias/Services/MySqlDenunciaStore.cs +++ b/Antifraude.Net/ApiDenuncias/Services/MySqlDenunciaStore.cs @@ -184,6 +184,7 @@ 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 ''"), + ("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 +196,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`)"), ]; @@ -1354,6 +1356,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( diff --git a/Antifraude.Net/ApiDenuncias/Services/UserComplaintAccessService.cs b/Antifraude.Net/ApiDenuncias/Services/UserComplaintAccessService.cs index 0adde92..891ec97 100644 --- a/Antifraude.Net/ApiDenuncias/Services/UserComplaintAccessService.cs +++ b/Antifraude.Net/ApiDenuncias/Services/UserComplaintAccessService.cs @@ -12,43 +12,165 @@ public sealed class UserComplaintAccessService _connectionStringProvider = connectionStringProvider; } - public async Task> GetAllowedComplaintIdsAsync(string username, CancellationToken cancellationToken = default) + public async Task> GetAllowedComplaintIdsAsync( + string username, + CancellationToken cancellationToken = default) + { + var access = await GetComplaintAccessAsync(username, null, cancellationToken); + return access.Keys.ToHashSet(); + } + + public async Task> GetComplaintAccessAsync( + string username, + IReadOnlyCollection? complaintIds = null, + CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(username)) { - return []; + return new Dictionary(); } - 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(); + var idFilter = string.Empty; + if (complaintIds is { Count: > 0 }) + { + var parameters = new List(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(); + } + + 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 = current_user.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 current_group + INNER JOIN app_user_groups owner_group + ON owner_group.work_group_id = current_group.work_group_id + INNER JOIN work_groups active_group + ON active_group.id = current_group.work_group_id + AND active_group.is_active = 1 + WHERE current_group.app_user_id = current_user.id + AND owner_group.app_user_id = ir.owner_user_id + ) 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 current_user + 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 = current_user.id + WHERE current_user.username = @username + {idFilter} + AND ( + ir.owner_user_id = current_user.id + OR EXISTS ( + SELECT 1 + FROM app_user_groups current_group + INNER JOIN app_user_groups owner_group + ON owner_group.work_group_id = current_group.work_group_id + INNER JOIN work_groups active_group + ON active_group.id = current_group.work_group_id + AND active_group.is_active = 1 + WHERE current_group.app_user_id = current_user.id + AND owner_group.app_user_id = ir.owner_user_id + ) + OR ( + ir.owner_user_id IS NULL + AND current_tracking.download_count > 0 + ) + ); + """; + + var result = new Dictionary(); 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 CanAccessComplaintAsync(string username, int complaintId, CancellationToken cancellationToken = default) + public async Task 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 OwnerGroupCodes) +{ + public bool RequiresOwnerConfirmation => + !OwnedByCurrentUser && + AccessibleByGroup && + !string.IsNullOrWhiteSpace(OwnerUsername); +} diff --git a/Antifraude.Net/ApiDenuncias/Services/WorkGroupAdministrationService.cs b/Antifraude.Net/ApiDenuncias/Services/WorkGroupAdministrationService.cs new file mode 100644 index 0000000..d663e72 --- /dev/null +++ b/Antifraude.Net/ApiDenuncias/Services/WorkGroupAdministrationService.cs @@ -0,0 +1,378 @@ +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 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(); + + 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 UpdateUserGroupsAsync( + string username, + IEnumerable 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> 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(); + 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> LoadGroupIdsAsync( + MySqlConnection connection, + MySqlTransaction transaction, + IReadOnlyList codes, + CancellationToken cancellationToken) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + await using var command = new MySqlCommand { Connection = connection, Transaction = transaction }; + var parameters = new List(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 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> 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(); + 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 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 GroupCodes { get; } = new(StringComparer.OrdinalIgnoreCase); + } +} diff --git a/Antifraude.Net/GestionaDenuncias.Shared/Models/ApiDenunciasDtos.cs b/Antifraude.Net/GestionaDenuncias.Shared/Models/ApiDenunciasDtos.cs index 586d48f..2b24c17 100644 --- a/Antifraude.Net/GestionaDenuncias.Shared/Models/ApiDenunciasDtos.cs +++ b/Antifraude.Net/GestionaDenuncias.Shared/Models/ApiDenunciasDtos.cs @@ -19,7 +19,9 @@ public sealed record InboxSnapshotResponse( IReadOnlyList Reports, InboxUserState UserState); -public sealed record ImportReportRequest(ReportDto Report); +public sealed record ImportReportRequest( + ReportDto Report, + bool ConfirmDifferentOwner = false); public sealed record MarkFicherosUploadedRequest( IReadOnlyList 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, @@ -109,6 +112,22 @@ 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 GroupCodes); + +public sealed record WorkGroupAdministrationDto( + IReadOnlyList Groups, + IReadOnlyList Users); + +public sealed record UpdateUserWorkGroupsRequest( + IReadOnlyList GroupCodes); + public sealed record GestionaComplaintFieldsResponse( DateTime? FechaDenuncia, int NumeroDenunciaCanal, diff --git a/Antifraude.Net/GestionaDenuncias.Shared/Models/DenunciasGestiona.cs b/Antifraude.Net/GestionaDenuncias.Shared/Models/DenunciasGestiona.cs index 2e44ba8..55cc9a3 100644 --- a/Antifraude.Net/GestionaDenuncias.Shared/Models/DenunciasGestiona.cs +++ b/Antifraude.Net/GestionaDenuncias.Shared/Models/DenunciasGestiona.cs @@ -83,6 +83,11 @@ public class DenunciasGestiona 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 OwnerWorkGroups { get; set; } = []; + [JsonIgnore] public DateOnly? KeyDate { get; set; } diff --git a/Antifraude.Net/GestionaDenuncias.Shared/Models/ReportDetailDto.cs b/Antifraude.Net/GestionaDenuncias.Shared/Models/ReportDetailDto.cs index d1e53ad..fdb9a4e 100644 --- a/Antifraude.Net/GestionaDenuncias.Shared/Models/ReportDetailDto.cs +++ b/Antifraude.Net/GestionaDenuncias.Shared/Models/ReportDetailDto.cs @@ -5,14 +5,17 @@ public sealed record ReportDetailDto( string? LastAccess, IReadOnlyList Comments, IReadOnlyList WhistleblowerFiles, - IReadOnlyList ReceiverFiles); + IReadOnlyList ReceiverFiles, + IReadOnlyList? 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); diff --git a/Antifraude.Net/GestionaDenuncias.Shared/Models/ReportDto.cs b/Antifraude.Net/GestionaDenuncias.Shared/Models/ReportDto.cs index 88ff0a1..852a08e 100644 --- a/Antifraude.Net/GestionaDenuncias.Shared/Models/ReportDto.cs +++ b/Antifraude.Net/GestionaDenuncias.Shared/Models/ReportDto.cs @@ -24,11 +24,18 @@ 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 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 OwnerWorkGroups { get; init; } = []; public string? TrackingNote { get; init; } } diff --git a/Antifraude.Net/GestionaDenuncias.Shared/Services/IInboxTrackingService.cs b/Antifraude.Net/GestionaDenuncias.Shared/Services/IInboxTrackingService.cs index 96ab5e2..dcd3bc6 100644 --- a/Antifraude.Net/GestionaDenuncias.Shared/Services/IInboxTrackingService.cs +++ b/Antifraude.Net/GestionaDenuncias.Shared/Services/IInboxTrackingService.cs @@ -24,5 +24,6 @@ public interface IInboxTrackingService Task EnsureReportCanBeImportedByUserAsync( string username, ReportDto report, + bool confirmDifferentOwner = false, CancellationToken cancellationToken = default); } diff --git a/Antifraude.Net/GestionaDenunciasAN/Components/Pages/Actualizaciones.razor b/Antifraude.Net/GestionaDenunciasAN/Components/Pages/Actualizaciones.razor index 47f6c00..eb0d20f 100644 --- a/Antifraude.Net/GestionaDenunciasAN/Components/Pages/Actualizaciones.razor +++ b/Antifraude.Net/GestionaDenunciasAN/Components/Pages/Actualizaciones.razor @@ -16,6 +16,7 @@ @inject IDenunciaStore DenunciaStore @inject ApiDenunciasClient ApiDenuncias @inject UiBusyService Busy +@inject IJSRuntime JSRuntime Actualizaciones @@ -162,7 +163,21 @@ else data-bs-target="#@collapseId" aria-expanded="false" aria-controls="@collapseId"> -
Denuncia ID: @denuncia.Id_Denuncia (Actualización)
+
+
Denuncia ID: @denuncia.Id_Denuncia (Actualización)
+ @if (!string.IsNullOrWhiteSpace(denuncia.OwnerUsername)) + { + + Responsable: @denuncia.OwnerUsername + + } + @if (denuncia.OwnerWorkGroups.Count > 0) + { + + Grupo: @string.Join(" / ", denuncia.OwnerWorkGroups) + + } +
@@ -703,6 +718,18 @@ else private async Task OpenEnviarAGestionaModal(DenunciasGestiona d) { + if (d.RequiresOwnerConfirmation) + { + var confirmed = await JSRuntime.InvokeAsync( + "confirm", + $"La denuncia #{d.Id_Denuncia} es propiedad de {d.OwnerUsername}. " + + "Puedes actualizarla porque pertenece a un usuario de tu grupo. Deseas continuar?"); + if (!confirmed) + { + return; + } + } + selectedDenuncias = d; nuevoAsunto = string.IsNullOrWhiteSpace(d.NombreDenuncia) ? $"Denuncia-{d.Id_Denuncia}-CD" : d.NombreDenuncia; nombreDocumentos = ""; diff --git a/Antifraude.Net/GestionaDenunciasAN/Components/Pages/Configuracion.razor b/Antifraude.Net/GestionaDenunciasAN/Components/Pages/Configuracion.razor index 7151c25..a611d39 100644 --- a/Antifraude.Net/GestionaDenunciasAN/Components/Pages/Configuracion.razor +++ b/Antifraude.Net/GestionaDenunciasAN/Components/Pages/Configuracion.razor @@ -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
+
+
+
+
+
Grupos de trabajo
+

+ Los usuarios pueden consultar y tratar denuncias de otros propietarios cuando comparten al menos un grupo. +

+
+ +
+ + @if (isLoadingWorkGroups) + { +
+ + Cargando usuarios y grupos... +
+ } + else if (workGroupAdministration is null) + { +
No se ha podido cargar la configuracion de grupos.
+ } + else + { +
+ + + + + @foreach (var group in workGroupAdministration.Groups) + { + + } + + + + + @foreach (var user in workGroupAdministration.Users) + { + + + @foreach (var group in workGroupAdministration.Groups) + { + var checkboxId = $"user-group-{user.UserId}-{group.Code}"; + + } + + + } + +
Usuario + @group.Code + @group.Name + Accion
+ @user.Username + @if (!GetSelectedGroups(user.UserId).Any()) + { + Sin grupo + } + +
+ + +
+
+ +
+
+ } + + @if (!string.IsNullOrWhiteSpace(workGroupNotice)) + { +
@workGroupNotice
+ } + + @if (!string.IsNullOrWhiteSpace(workGroupError)) + { +
@workGroupError
+ } +
+
+
Purga manual con reemplazo
@@ -223,6 +322,12 @@ else private string? configurationNotice; private string? configurationError; private bool isSavingConfiguration; + private WorkGroupAdministrationDto? workGroupAdministration; + private readonly Dictionary> selectedWorkGroups = []; + private readonly HashSet 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 GetSelectedGroups(long userId) + { + if (!selectedWorkGroups.TryGetValue(userId, out var groups)) + { + groups = new HashSet(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)) diff --git a/Antifraude.Net/GestionaDenunciasAN/Components/Pages/Entrada.razor b/Antifraude.Net/GestionaDenunciasAN/Components/Pages/Entrada.razor index f99baa0..3099b64 100644 --- a/Antifraude.Net/GestionaDenunciasAN/Components/Pages/Entrada.razor +++ b/Antifraude.Net/GestionaDenunciasAN/Components/Pages/Entrada.razor @@ -122,6 +122,9 @@ .inbox-activity-cell { width: 9.25rem; + max-width: 12rem; + overflow: hidden; + text-overflow: ellipsis; } .inbox-action-cell { @@ -226,6 +229,7 @@ +
@@ -355,7 +359,7 @@ @(report.ContextName ?? report.ContextId ?? "-") @FormatDate(report.CreationDate) @FormatCitizenActivity(report) - @FormatReceiverActivity(report) + @FormatReceiverActivity(report) @GetStatusLabel(report) @@ -432,7 +436,7 @@ {
- @GetCommentAuthorLabel(comment.Type) + @GetCommentAuthorLabel(comment) @FormatDate(comment.CreationDate)
@if (comment.IsNew) @@ -482,6 +486,10 @@ @FormatDate(file.CreationDate)
@FormatBytes(file.Size) @(string.IsNullOrWhiteSpace(file.ContentType) ? string.Empty : $" - {file.ContentType}")
+ @if (!string.IsNullOrWhiteSpace(file.AuthorName)) + { +
Añadido por @file.AuthorName
+ } @if (file.IsNew) { Nuevo @@ -703,6 +711,36 @@ 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 JSRuntime.InvokeAsync( + "confirm", + $"Vas a importar denuncias propiedad de otros usuarios de tu grupo:{Environment.NewLine}{Environment.NewLine}{ownerSummary}{Environment.NewLine}{Environment.NewLine}¿Deseas continuar?"); + if (!confirmed) + { + return; + } + } + ImportBusy = true; var importedCount = 0; var errors = new List(); @@ -710,18 +748,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 +765,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 +927,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 +1176,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 +1189,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 +1220,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) @@ -1298,7 +1344,7 @@ }; private static bool CanUseReport(ReportDto report) - => report.Accessible != false && !IsReceiverOnlyUpdate(report); + => report.Accessible != false; private static bool IsReceiverOnlyUpdate(ReportDto report) => report.AlreadyInGestiona && @@ -1313,11 +1359,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 +1369,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."; } @@ -1395,7 +1431,7 @@ if (IsReceiverOnlyUpdate(report)) { - return "table-secondary report-row-disabled"; + return "table-secondary"; } if (report.AlreadyInGestiona) diff --git a/Antifraude.Net/GestionaDenunciasAN/Components/Pages/Pendientes.razor b/Antifraude.Net/GestionaDenunciasAN/Components/Pages/Pendientes.razor index a3a29c1..f7579b3 100644 --- a/Antifraude.Net/GestionaDenunciasAN/Components/Pages/Pendientes.razor +++ b/Antifraude.Net/GestionaDenunciasAN/Components/Pages/Pendientes.razor @@ -19,6 +19,7 @@ @inject IDenunciaStore DenunciaStore @inject ApiDenunciasClient ApiDenuncias @inject UiBusyService Busy +@inject IJSRuntime JSRuntime Denuncias Pendientes @@ -199,7 +200,21 @@ else data-bs-target="#@collapseId" aria-expanded="false" aria-controls="@collapseId"> -
Denuncia ID: @denuncia.Id_Denuncia
+
+
Denuncia ID: @denuncia.Id_Denuncia
+ @if (!string.IsNullOrWhiteSpace(denuncia.OwnerUsername)) + { + + Responsable: @denuncia.OwnerUsername + + } + @if (denuncia.OwnerWorkGroups.Count > 0) + { + + Grupo: @string.Join(" / ", denuncia.OwnerWorkGroups) + + } +