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
This commit is contained in:
Claude
2026-03-27 07:36:27 +00:00
parent 878b762ca5
commit c8f308c60b
5 changed files with 285 additions and 4 deletions
@@ -262,6 +262,71 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
}
}
// --- Feedback ---
public async Task<FeedbackEntry?> GetFeedbackEntryAsync(string upn)
{
try
{
var entity = await _previewConfigTableClient.GetEntityAsync<TableEntity>("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<List<FeedbackEntry>> GetAllFeedbackEntriesAsync()
{
try
{
var entries = new List<FeedbackEntry>();
await foreach (var entity in _previewConfigTableClient.QueryAsync<TableEntity>(
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<string?> 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")
};
}
}
/// <summary>
@@ -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<TableMaintenanceRepository> _logger;
public TableMaintenanceRepository(TableStorageService storage)
public TableMaintenanceRepository(TableStorageService storage, ILogger<TableMaintenanceRepository> logger)
{
_storage = storage;
_logger = logger;
}
public Task<bool> LogAuditEntryAsync(string tenantId, string action, string entityType,
@@ -59,5 +64,182 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
public Task<bool> IsSessionIndexEmptyAsync()
=> _storage.IsSessionIndexEmptyAsync();
// --- Tenant Offboarding ---
public async Task<Dictionary<string, int>> DeleteAllTenantDataAsync(string tenantId)
{
var deletedCounts = new Dictionary<string, int>();
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;
}
/// <summary>
/// Deletes all rows in a table where PartitionKey equals the given value.
/// Uses batched deletes (max 100 per transaction, same PartitionKey required).
/// </summary>
private async Task<int> DeleteAllRowsByPartitionKeyAsync(string tableName, string partitionKey)
{
try
{
var tableClient = _storage.GetTableClient(tableName);
var filter = $"PartitionKey eq '{partitionKey}'";
var entities = tableClient.QueryAsync<TableEntity>(filter, select: new[] { "PartitionKey", "RowKey" });
int deleted = 0;
var batch = new List<TableTransactionAction>();
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;
}
}
/// <summary>
/// Deletes all rows for a tenant from a table that uses composite PartitionKey = "{tenantId}_{sessionId}".
/// Used for Events and RuleResults tables.
/// </summary>
private async Task<int> 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<TableEntity>(filter, select: new[] { "PartitionKey", "RowKey" });
int deleted = 0;
// Group by PartitionKey because Azure Table batch transactions require same PartitionKey
var groups = new Dictionary<string, List<TableEntity>>();
await foreach (var entity in entities)
{
if (!groups.ContainsKey(entity.PartitionKey))
groups[entity.PartitionKey] = new List<TableEntity>();
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;
}
}
/// <summary>
/// Deletes BootstrapSessions CodeLookup entries for a tenant.
/// These use PartitionKey = "CodeLookup" with a TenantId property matching the tenant.
/// </summary>
private async Task<int> DeleteCodeLookupEntriesAsync(string normalizedTenantId)
{
try
{
var tableClient = _storage.GetTableClient(Constants.TableNames.BootstrapSessions);
var filter = $"PartitionKey eq 'CodeLookup' and TenantId eq '{normalizedTenantId}'";
var entities = tableClient.QueryAsync<TableEntity>(filter, select: new[] { "PartitionKey", "RowKey" });
int deleted = 0;
var batch = new List<TableTransactionAction>();
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;
}
}
}
}
@@ -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
@@ -30,5 +30,22 @@ namespace AutopilotMonitor.Shared.DataAccess
// --- Preview Notification Email ---
Task<string?> GetNotificationEmailAsync(string tenantId);
Task SaveNotificationEmailAsync(string tenantId, string? email);
// --- Feedback (stored in PreviewConfig table, PK="Feedback") ---
Task<FeedbackEntry?> GetFeedbackEntryAsync(string upn);
Task SaveFeedbackEntryAsync(FeedbackEntry entry);
Task<List<FeedbackEntry>> 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; }
}
}
@@ -32,5 +32,8 @@ namespace AutopilotMonitor.Shared.DataAccess
Task<int> BackfillSessionIndexAsync();
Task<int> CleanupGhostSessionIndexEntriesAsync();
Task<bool> IsSessionIndexEmptyAsync();
// --- Tenant Offboarding ---
Task<Dictionary<string, int>> DeleteAllTenantDataAsync(string tenantId);
}
}