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