diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/TenantOffboardFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/TenantOffboardFunction.cs index 2b895f07..c26bf65e 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/TenantOffboardFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/TenantOffboardFunction.cs @@ -5,11 +5,9 @@ using AutopilotMonitor.Functions.Security; using AutopilotMonitor.Functions.Services; using AutopilotMonitor.Shared.DataAccess; using AutopilotMonitor.Shared; -using Azure.Data.Tables; using Microsoft.AspNetCore.Authorization; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; -using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; namespace AutopilotMonitor.Functions.Functions.Admin; @@ -25,21 +23,17 @@ public class TenantOffboardFunction private readonly TenantAdminsService _tenantAdminsService; private readonly GlobalAdminService _globalAdminService; private readonly IMaintenanceRepository _maintenanceRepo; - private readonly TableServiceClient _tableServiceClient; public TenantOffboardFunction( ILogger logger, TenantAdminsService tenantAdminsService, GlobalAdminService globalAdminService, - IMaintenanceRepository maintenanceRepo, - IConfiguration configuration) + IMaintenanceRepository maintenanceRepo) { _logger = logger; _tenantAdminsService = tenantAdminsService; _globalAdminService = globalAdminService; _maintenanceRepo = maintenanceRepo; - var connectionString = configuration["AzureTableStorageConnectionString"]; - _tableServiceClient = new TableServiceClient(connectionString); } /// @@ -99,47 +93,8 @@ public class TenantOffboardFunction try { - // 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, - }; - - var normalizedTenantId = tenantId.ToLowerInvariant(); - - foreach (var tableName in tenantPartitionTables) - { - var deleted = await DeleteAllRowsByPartitionKeyAsync(tableName, normalizedTenantId); - result.DeletedCounts[tableName] = deleted; - _logger.LogInformation($"Offboard [{tenantId}] {tableName}: deleted {deleted} rows"); - } - - // Tables with composite PartitionKey = "{tenantId}_{sessionId}" – query by prefix - var eventsDeleted = await DeleteByTenantPrefixAsync(Constants.TableNames.Events, normalizedTenantId); - result.DeletedCounts[Constants.TableNames.Events] = eventsDeleted; - _logger.LogInformation($"Offboard [{tenantId}] Events: deleted {eventsDeleted} rows"); - - var ruleResultsDeleted = await DeleteByTenantPrefixAsync(Constants.TableNames.RuleResults, normalizedTenantId); - result.DeletedCounts[Constants.TableNames.RuleResults] = ruleResultsDeleted; - _logger.LogInformation($"Offboard [{tenantId}] RuleResults: deleted {ruleResultsDeleted} rows"); - - // BootstrapSessions CodeLookup entries (PartitionKey = "CodeLookup", TenantId property = tenantId) - var codeLookupDeleted = await DeleteCodeLookupEntriesAsync(normalizedTenantId); - result.DeletedCounts["BootstrapSessions_CodeLookup"] = codeLookupDeleted; - _logger.LogInformation($"Offboard [{tenantId}] BootstrapSessions CodeLookup: deleted {codeLookupDeleted} rows"); + var deletedCounts = await _maintenanceRepo.DeleteAllTenantDataAsync(tenantId); + result.DeletedCounts = deletedCounts; result.Success = true; _logger.LogWarning($"TENANT OFFBOARD completed for tenant {tenantId} by {upn}. " + @@ -174,132 +129,6 @@ public class TenantOffboardFunction } } - /// - /// 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 = _tableServiceClient.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}"); - 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 = _tableServiceClient.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 {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 = _tableServiceClient.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 {normalizedTenantId}"); - return 0; - } - } } public class OffboardResult diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Feedback/FeedbackFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Feedback/FeedbackFunction.cs index a3aa8484..13ed4553 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Feedback/FeedbackFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Feedback/FeedbackFunction.cs @@ -22,7 +22,7 @@ namespace AutopilotMonitor.Functions.Functions.Feedback private readonly TenantAdminsService _tenantAdminsService; private readonly TelegramNotificationService _telegramNotificationService; private readonly ISessionRepository _sessionRepo; - private readonly TableClient _feedbackTableClient; + private readonly IConfigRepository _configRepo; public FeedbackFunction( ILogger logger, @@ -31,7 +31,7 @@ namespace AutopilotMonitor.Functions.Functions.Feedback TenantAdminsService tenantAdminsService, TelegramNotificationService telegramNotificationService, ISessionRepository sessionRepo, - IConfiguration configuration) + IConfigRepository configRepo) { _logger = logger; _tenantConfigService = tenantConfigService; @@ -39,10 +39,7 @@ namespace AutopilotMonitor.Functions.Functions.Feedback _tenantAdminsService = tenantAdminsService; _telegramNotificationService = telegramNotificationService; _sessionRepo = sessionRepo; - - var connectionString = configuration["AzureTableStorageConnectionString"]; - var serviceClient = new TableServiceClient(connectionString); - _feedbackTableClient = serviceClient.GetTableClient(Constants.TableNames.PreviewConfig); + _configRepo = configRepo; } /// @@ -81,25 +78,16 @@ namespace AutopilotMonitor.Functions.Functions.Feedback return await WriteJson(req, new { eligible = false }); // 6. Cooldown check - try + var feedbackEntry = await _configRepo.GetFeedbackEntryAsync(upn); + if (feedbackEntry?.InteractedAt != null) { - var entity = await _feedbackTableClient.GetEntityAsync("Feedback", upn.ToLowerInvariant()); - var interactedAt = entity.Value.GetDateTime("InteractedAt"); + // Cooldown = 0 means single wave only + if (adminConfig.FeedbackCooldownDays == 0) + return await WriteJson(req, new { eligible = false }); - if (interactedAt.HasValue) - { - // Cooldown = 0 means single wave only - if (adminConfig.FeedbackCooldownDays == 0) - return await WriteJson(req, new { eligible = false }); - - var daysSinceInteraction = (DateTime.UtcNow - interactedAt.Value).TotalDays; - if (daysSinceInteraction < adminConfig.FeedbackCooldownDays) - return await WriteJson(req, new { eligible = false }); - } - } - catch (RequestFailedException ex) when (ex.Status == 404) - { - // No feedback record yet — user is eligible + var daysSinceInteraction = (DateTime.UtcNow - feedbackEntry.InteractedAt.Value).TotalDays; + if (daysSinceInteraction < adminConfig.FeedbackCooldownDays) + return await WriteJson(req, new { eligible = false }); } return await WriteJson(req, new { eligible = true }); @@ -165,18 +153,17 @@ namespace AutopilotMonitor.Functions.Functions.Feedback comment = comment.Substring(0, 500); // Upsert feedback record - var entity = new TableEntity("Feedback", upn.ToLowerInvariant()) + await _configRepo.SaveFeedbackEntryAsync(new FeedbackEntry { - { "TenantId", tenantId }, - { "DisplayName", displayName }, - { "Rating", body.Dismissed ? null : (int?)body.Rating }, - { "Comment", body.Dismissed ? null : comment }, - { "Dismissed", body.Dismissed }, - { "Submitted", !body.Dismissed }, - { "InteractedAt", DateTime.UtcNow } - }; - - await _feedbackTableClient.UpsertEntityAsync(entity, TableUpdateMode.Replace); + Upn = upn, + TenantId = tenantId, + DisplayName = displayName, + Rating = body.Dismissed ? null : (int?)body.Rating, + Comment = body.Dismissed ? null : comment, + Dismissed = body.Dismissed, + Submitted = !body.Dismissed, + InteractedAt = DateTime.UtcNow + }); // Telegram notification — only for actual submissions, fire-and-forget if (!body.Dismissed && body.Rating > 0) @@ -211,23 +198,18 @@ namespace AutopilotMonitor.Functions.Functions.Feedback { try { - var entries = new System.Collections.Generic.List(); - - await foreach (var entity in _feedbackTableClient.QueryAsync( - filter: "PartitionKey eq 'Feedback'")) + var allEntries = await _configRepo.GetAllFeedbackEntriesAsync(); + var entries = allEntries.Select(e => new { - entries.Add(new - { - upn = entity.RowKey, - tenantId = entity.GetString("TenantId"), - displayName = entity.GetString("DisplayName"), - rating = entity.GetInt32("Rating"), - comment = entity.GetString("Comment"), - dismissed = entity.GetBoolean("Dismissed") ?? false, - submitted = entity.GetBoolean("Submitted") ?? false, - interactedAt = entity.GetDateTime("InteractedAt")?.ToString("o") - }); - } + upn = e.Upn, + tenantId = e.TenantId, + displayName = e.DisplayName, + rating = e.Rating, + comment = e.Comment, + dismissed = e.Dismissed, + submitted = e.Submitted, + interactedAt = e.InteractedAt?.ToString("o") + }).ToList(); return await WriteJson(req, new { feedback = entries }); }