From c8f308c60bcce20e95c9cc1afb1ceffca9d565bf Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 27 Mar 2026 07:36:27 +0000 Subject: [PATCH] Add Feedback + TenantOffboard repository methods (implementations ready) - IConfigRepository: Add GetFeedbackEntryAsync, SaveFeedbackEntryAsync, GetAllFeedbackEntriesAsync - IMaintenanceRepository: Add DeleteAllTenantDataAsync - TableConfigRepository: Implement feedback CRUD against PreviewConfig table - TableMaintenanceRepository: Implement bulk tenant data deletion with batch deletes Function migrations (FeedbackFunction, TenantOffboardFunction) in progress. https://claude.ai/code/session_01NHWJfPaa8TQHuPzMD7gG63 --- .../TableStorage/TableConfigRepository.cs | 82 ++++++++ .../TableMaintenanceRepository.cs | 184 +++++++++++++++++- .../Functions/Feedback/FeedbackFunction.cs | 3 - .../DataAccess/IConfigRepository.cs | 17 ++ .../DataAccess/IMaintenanceRepository.cs | 3 + 5 files changed, 285 insertions(+), 4 deletions(-) diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs index 006c1a40..7681e3db 100644 --- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs @@ -262,6 +262,71 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage } } + // --- Feedback --- + + public async Task GetFeedbackEntryAsync(string upn) + { + try + { + var entity = await _previewConfigTableClient.GetEntityAsync("Feedback", upn.ToLowerInvariant()); + return ConvertFromFeedbackTableEntity(entity.Value); + } + catch (RequestFailedException ex) when (ex.Status == 404) + { + return null; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error loading feedback entry for {Upn}", upn); + throw; + } + } + + public async Task SaveFeedbackEntryAsync(FeedbackEntry entry) + { + try + { + var entity = new TableEntity("Feedback", entry.Upn.ToLowerInvariant()) + { + { "TenantId", entry.TenantId }, + { "DisplayName", entry.DisplayName }, + { "Rating", entry.Rating }, + { "Comment", entry.Comment }, + { "Dismissed", entry.Dismissed }, + { "Submitted", entry.Submitted }, + { "InteractedAt", entry.InteractedAt } + }; + + await _previewConfigTableClient.UpsertEntityAsync(entity, TableUpdateMode.Replace); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error saving feedback entry for {Upn}", entry.Upn); + throw; + } + } + + public async Task> GetAllFeedbackEntriesAsync() + { + try + { + var entries = new List(); + + await foreach (var entity in _previewConfigTableClient.QueryAsync( + filter: "PartitionKey eq 'Feedback'")) + { + entries.Add(ConvertFromFeedbackTableEntity(entity)); + } + + return entries; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error loading all feedback entries"); + throw; + } + } + // --- Preview Notification Email --- public async Task GetNotificationEmailAsync(string tenantId) @@ -467,6 +532,23 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage VulnerabilityDataLastSyncUtc = entity.GetString("VulnerabilityDataLastSyncUtc") }; } + + // --- Feedback Entity Mapping --- + + private FeedbackEntry ConvertFromFeedbackTableEntity(TableEntity entity) + { + return new FeedbackEntry + { + Upn = entity.RowKey, + TenantId = entity.GetString("TenantId") ?? string.Empty, + DisplayName = entity.GetString("DisplayName") ?? string.Empty, + Rating = entity.GetInt32("Rating"), + Comment = entity.GetString("Comment"), + Dismissed = entity.GetBoolean("Dismissed") ?? false, + Submitted = entity.GetBoolean("Submitted") ?? false, + InteractedAt = entity.GetDateTime("InteractedAt") + }; + } } /// diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableMaintenanceRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableMaintenanceRepository.cs index d4492572..3bd54a8f 100644 --- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableMaintenanceRepository.cs +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableMaintenanceRepository.cs @@ -1,6 +1,9 @@ +using AutopilotMonitor.Shared; using AutopilotMonitor.Shared.DataAccess; using AutopilotMonitor.Shared.Models; using AutopilotMonitor.Functions.Services; +using Azure.Data.Tables; +using Microsoft.Extensions.Logging; namespace AutopilotMonitor.Functions.DataAccess.TableStorage { @@ -11,10 +14,12 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage public class TableMaintenanceRepository : IMaintenanceRepository { private readonly TableStorageService _storage; + private readonly ILogger _logger; - public TableMaintenanceRepository(TableStorageService storage) + public TableMaintenanceRepository(TableStorageService storage, ILogger logger) { _storage = storage; + _logger = logger; } public Task LogAuditEntryAsync(string tenantId, string action, string entityType, @@ -59,5 +64,182 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage public Task IsSessionIndexEmptyAsync() => _storage.IsSessionIndexEmptyAsync(); + + // --- Tenant Offboarding --- + + public async Task> DeleteAllTenantDataAsync(string tenantId) + { + var deletedCounts = new Dictionary(); + var normalizedTenantId = tenantId.ToLowerInvariant(); + + // Tables where PartitionKey = tenantId (normalized lowercase) + var tenantPartitionTables = new[] + { + Constants.TableNames.Sessions, + Constants.TableNames.SessionsIndex, + Constants.TableNames.AuditLogs, + Constants.TableNames.UsageMetrics, + Constants.TableNames.UserActivity, + Constants.TableNames.GatherRules, + Constants.TableNames.AnalyzeRules, + Constants.TableNames.AppInstallSummaries, + Constants.TableNames.TenantConfiguration, + Constants.TableNames.TenantAdmins, + Constants.TableNames.BootstrapSessions, + Constants.TableNames.BlockedDevices, + Constants.TableNames.ImeLogPatterns, + Constants.TableNames.RuleStates, + }; + + foreach (var tableName in tenantPartitionTables) + { + var deleted = await DeleteAllRowsByPartitionKeyAsync(tableName, normalizedTenantId); + deletedCounts[tableName] = deleted; + _logger.LogInformation("Offboard [{TenantId}] {TableName}: deleted {Deleted} rows", tenantId, tableName, deleted); + } + + // Tables with composite PartitionKey = "{tenantId}_{sessionId}" – query by prefix + var eventsDeleted = await DeleteByTenantPrefixAsync(Constants.TableNames.Events, normalizedTenantId); + deletedCounts[Constants.TableNames.Events] = eventsDeleted; + _logger.LogInformation("Offboard [{TenantId}] Events: deleted {Deleted} rows", tenantId, eventsDeleted); + + var ruleResultsDeleted = await DeleteByTenantPrefixAsync(Constants.TableNames.RuleResults, normalizedTenantId); + deletedCounts[Constants.TableNames.RuleResults] = ruleResultsDeleted; + _logger.LogInformation("Offboard [{TenantId}] RuleResults: deleted {Deleted} rows", tenantId, ruleResultsDeleted); + + // BootstrapSessions CodeLookup entries (PartitionKey = "CodeLookup", TenantId property = tenantId) + var codeLookupDeleted = await DeleteCodeLookupEntriesAsync(normalizedTenantId); + deletedCounts["BootstrapSessions_CodeLookup"] = codeLookupDeleted; + _logger.LogInformation("Offboard [{TenantId}] BootstrapSessions CodeLookup: deleted {Deleted} rows", tenantId, codeLookupDeleted); + + return deletedCounts; + } + + /// + /// Deletes all rows in a table where PartitionKey equals the given value. + /// Uses batched deletes (max 100 per transaction, same PartitionKey required). + /// + private async Task DeleteAllRowsByPartitionKeyAsync(string tableName, string partitionKey) + { + try + { + var tableClient = _storage.GetTableClient(tableName); + var filter = $"PartitionKey eq '{partitionKey}'"; + var entities = tableClient.QueryAsync(filter, select: new[] { "PartitionKey", "RowKey" }); + + int deleted = 0; + var batch = new List(); + + await foreach (var entity in entities) + { + batch.Add(new TableTransactionAction(TableTransactionActionType.Delete, entity)); + deleted++; + + if (batch.Count >= 100) + { + await tableClient.SubmitTransactionAsync(batch); + batch.Clear(); + } + } + + if (batch.Count > 0) + { + await tableClient.SubmitTransactionAsync(batch); + } + + return deleted; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to delete rows in {TableName} for partition {PartitionKey}", tableName, partitionKey); + return 0; + } + } + + /// + /// Deletes all rows for a tenant from a table that uses composite PartitionKey = "{tenantId}_{sessionId}". + /// Used for Events and RuleResults tables. + /// + private async Task DeleteByTenantPrefixAsync(string tableName, string normalizedTenantId) + { + try + { + var tableClient = _storage.GetTableClient(tableName); + // OData startsWith: PartitionKey ge 'prefix' and PartitionKey lt 'prefix~' + var prefix = normalizedTenantId + "_"; + var filter = $"PartitionKey ge '{prefix}' and PartitionKey lt '{prefix}~'"; + var entities = tableClient.QueryAsync(filter, select: new[] { "PartitionKey", "RowKey" }); + + int deleted = 0; + // Group by PartitionKey because Azure Table batch transactions require same PartitionKey + var groups = new Dictionary>(); + + await foreach (var entity in entities) + { + if (!groups.ContainsKey(entity.PartitionKey)) + groups[entity.PartitionKey] = new List(); + groups[entity.PartitionKey].Add(entity); + deleted++; + } + + foreach (var group in groups.Values) + { + for (int i = 0; i < group.Count; i += 100) + { + var chunk = group.Skip(i).Take(100).ToList(); + var batch = chunk.Select(e => new TableTransactionAction(TableTransactionActionType.Delete, e)).ToList(); + await tableClient.SubmitTransactionAsync(batch); + } + } + + return deleted; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to delete {TableName} for tenant {TenantId}", tableName, normalizedTenantId); + return 0; + } + } + + /// + /// Deletes BootstrapSessions CodeLookup entries for a tenant. + /// These use PartitionKey = "CodeLookup" with a TenantId property matching the tenant. + /// + private async Task DeleteCodeLookupEntriesAsync(string normalizedTenantId) + { + try + { + var tableClient = _storage.GetTableClient(Constants.TableNames.BootstrapSessions); + var filter = $"PartitionKey eq 'CodeLookup' and TenantId eq '{normalizedTenantId}'"; + var entities = tableClient.QueryAsync(filter, select: new[] { "PartitionKey", "RowKey" }); + + int deleted = 0; + var batch = new List(); + + await foreach (var entity in entities) + { + batch.Add(new TableTransactionAction(TableTransactionActionType.Delete, entity)); + deleted++; + + if (batch.Count >= 100) + { + await tableClient.SubmitTransactionAsync(batch); + batch.Clear(); + } + } + + if (batch.Count > 0) + { + await tableClient.SubmitTransactionAsync(batch); + } + + return deleted; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to delete CodeLookup entries for tenant {TenantId}", normalizedTenantId); + return 0; + } + } } } diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Feedback/FeedbackFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Feedback/FeedbackFunction.cs index 26f94f91..a3aa8484 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Feedback/FeedbackFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Feedback/FeedbackFunction.cs @@ -8,11 +8,8 @@ using AutopilotMonitor.Functions.Services; using AutopilotMonitor.Shared; using AutopilotMonitor.Shared.DataAccess; using AutopilotMonitor.Shared.Models; -using Azure; -using Azure.Data.Tables; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; -using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; namespace AutopilotMonitor.Functions.Functions.Feedback diff --git a/src/Shared/AutopilotMonitor.Shared/DataAccess/IConfigRepository.cs b/src/Shared/AutopilotMonitor.Shared/DataAccess/IConfigRepository.cs index 0655e606..7f7d4eea 100644 --- a/src/Shared/AutopilotMonitor.Shared/DataAccess/IConfigRepository.cs +++ b/src/Shared/AutopilotMonitor.Shared/DataAccess/IConfigRepository.cs @@ -30,5 +30,22 @@ namespace AutopilotMonitor.Shared.DataAccess // --- Preview Notification Email --- Task GetNotificationEmailAsync(string tenantId); Task SaveNotificationEmailAsync(string tenantId, string? email); + + // --- Feedback (stored in PreviewConfig table, PK="Feedback") --- + Task GetFeedbackEntryAsync(string upn); + Task SaveFeedbackEntryAsync(FeedbackEntry entry); + Task> GetAllFeedbackEntriesAsync(); + } + + public class FeedbackEntry + { + public string Upn { get; set; } = string.Empty; + public string TenantId { get; set; } = string.Empty; + public string DisplayName { get; set; } = string.Empty; + public int? Rating { get; set; } + public string? Comment { get; set; } + public bool Dismissed { get; set; } + public bool Submitted { get; set; } + public DateTime? InteractedAt { get; set; } } } diff --git a/src/Shared/AutopilotMonitor.Shared/DataAccess/IMaintenanceRepository.cs b/src/Shared/AutopilotMonitor.Shared/DataAccess/IMaintenanceRepository.cs index 8f0240dd..d99ac548 100644 --- a/src/Shared/AutopilotMonitor.Shared/DataAccess/IMaintenanceRepository.cs +++ b/src/Shared/AutopilotMonitor.Shared/DataAccess/IMaintenanceRepository.cs @@ -32,5 +32,8 @@ namespace AutopilotMonitor.Shared.DataAccess Task BackfillSessionIndexAsync(); Task CleanupGhostSessionIndexEntriesAsync(); Task IsSessionIndexEmptyAsync(); + + // --- Tenant Offboarding --- + Task> DeleteAllTenantDataAsync(string tenantId); } }