using System.Security.Cryptography; using System.Text; using System.Text.Json; using D3ROVoice.Api.Data; using Microsoft.EntityFrameworkCore; namespace D3ROVoice.Api.Services; public sealed class AdminOperationException : Exception { public AdminOperationException(string message) : base(message) { } } public interface IAdminOperationService { Task ExecuteAsync( string actorEmail, string operation, string idempotencyKey, object request, string targetType, Func targetId, string memo, Func> readBefore, Func> mutate); } public sealed class AdminOperationService : IAdminOperationService { private readonly AppDbContext _db; public AdminOperationService(AppDbContext db) { _db = db; } public async Task ExecuteAsync( string actorEmail, string operation, string idempotencyKey, object request, string targetType, Func targetId, string memo, Func> readBefore, Func> mutate) { actorEmail = actorEmail.Trim().ToLowerInvariant(); memo = memo.Trim(); if (actorEmail.Length is < 3 or > 150) throw new AdminOperationException("invalid_actor"); if (!Guid.TryParseExact(idempotencyKey, "D", out _)) throw new AdminOperationException("invalid_idempotency_key"); if (memo.Length is < 3 or > 1000) throw new AdminOperationException("invalid_audit_memo"); var requestJson = JsonSerializer.Serialize(request); var requestHash = Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes($"{operation}:{requestJson}"))); await using var transaction = await _db.Database.BeginTransactionAsync(System.Data.IsolationLevel.Serializable); var existing = await _db.AdminOperationRequests.AsNoTracking().SingleOrDefaultAsync(entry => entry.ActorEmail == actorEmail && entry.IdempotencyKey == idempotencyKey); if (existing != null) { if (existing.Operation != operation || existing.RequestHash != requestHash) throw new AdminOperationException("idempotency_key_reused_with_different_request"); using var replay = JsonDocument.Parse(existing.ResponseJson); return replay.RootElement.Clone(); } var before = await readBefore(); var result = await mutate(); var responseJson = JsonSerializer.Serialize(result); var resolvedTargetId = targetId(result); if (string.IsNullOrWhiteSpace(resolvedTargetId) || resolvedTargetId.Length > 200) throw new AdminOperationException("invalid_audit_target"); _db.AdminOperationRequests.Add(new AdminOperationRequest { ActorEmail = actorEmail, IdempotencyKey = idempotencyKey, Operation = operation, RequestHash = requestHash, ResponseJson = responseJson, CreatedAt = DateTime.UtcNow }); _db.AdminAuditEntries.Add(new AdminAuditEntry { ActorEmail = actorEmail, Action = operation, TargetType = targetType, TargetId = resolvedTargetId, BeforeJson = before == null ? null : JsonSerializer.Serialize(before), AfterJson = responseJson, Memo = memo, IdempotencyKey = idempotencyKey, CreatedAt = DateTime.UtcNow }); await _db.SaveChangesAsync(); await transaction.CommitAsync(); using var response = JsonDocument.Parse(responseJson); return response.RootElement.Clone(); } }