Migrate FeedbackFunction + TenantOffboardFunction to repositories

- FeedbackFunction → IConfigRepository (feedback CRUD)
- TenantOffboardFunction → IMaintenanceRepository.DeleteAllTenantDataAsync()

Zero functions/services now create their own TableServiceClient.
All table access goes through repository interfaces.

https://claude.ai/code/session_01NHWJfPaa8TQHuPzMD7gG63
This commit is contained in:
Claude
2026-03-27 07:38:19 +00:00
parent c8f308c60b
commit f5ec4247e9
2 changed files with 35 additions and 224 deletions
@@ -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<TenantOffboardFunction> 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);
}
/// <summary>
@@ -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
}
}
/// <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 = _tableServiceClient.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}");
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 = _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<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 {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 = _tableServiceClient.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 {normalizedTenantId}");
return 0;
}
}
}
public class OffboardResult
@@ -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<FeedbackFunction> 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;
}
/// <summary>
@@ -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<TableEntity>("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<object>();
await foreach (var entity in _feedbackTableClient.QueryAsync<TableEntity>(
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 });
}