mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
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:
+82
@@ -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>
|
||||
|
||||
+183
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user