using Microsoft.Extensions.Options; using Microsoft.EntityFrameworkCore; using LittleShop.Configuration; using LittleShop.Data; using LittleShop.Models; namespace LittleShop.Services; /// /// Background service that enforces GDPR data retention policies /// Automatically deletes customer data after retention period expires /// public class DataRetentionService : BackgroundService { private readonly IServiceProvider _serviceProvider; private readonly ILogger _logger; private readonly DataRetentionOptions _options; private Timer? _timer; public DataRetentionService( IServiceProvider serviceProvider, ILogger logger, IOptions options) { _serviceProvider = serviceProvider; _logger = logger; _options = options.Value; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { if (!_options.Enabled) { _logger.LogInformation("๐Ÿ“‹ Data retention enforcement is disabled in configuration"); return; } _logger.LogInformation("๐Ÿ”’ Data retention service started. Cleanup time: {CleanupTime}, Interval: {IntervalHours}h", _options.CleanupTime, _options.CheckIntervalHours); if (_options.DryRunMode) { _logger.LogWarning("โš ๏ธ DRY RUN MODE: Data will be logged but not actually deleted"); } // Calculate initial delay to run at configured cleanup time var initialDelay = CalculateInitialDelay(); _logger.LogInformation("โฐ Next cleanup scheduled in {Hours:F2} hours at {NextRun}", initialDelay.TotalHours, DateTime.UtcNow.Add(initialDelay)); await Task.Delay(initialDelay, stoppingToken); // Run cleanup immediately after initial delay await PerformCleanupAsync(stoppingToken); // Set up periodic timer var interval = TimeSpan.FromHours(_options.CheckIntervalHours); _timer = new Timer( async _ => await PerformCleanupAsync(stoppingToken), null, interval, interval); // Keep service running await Task.Delay(Timeout.Infinite, stoppingToken); } private TimeSpan CalculateInitialDelay() { try { var now = DateTime.UtcNow; var cleanupTime = TimeSpan.Parse(_options.CleanupTime); var nextRun = now.Date.Add(cleanupTime); // If cleanup time already passed today, schedule for tomorrow if (nextRun <= now) { nextRun = nextRun.AddDays(1); } return nextRun - now; } catch (Exception ex) { _logger.LogError(ex, "โŒ Invalid CleanupTime format: {CleanupTime}. Using 1 hour delay.", _options.CleanupTime); return TimeSpan.FromHours(1); } } private async Task PerformCleanupAsync(CancellationToken cancellationToken) { try { _logger.LogInformation("๐Ÿงน Starting data retention cleanup..."); using var scope = _serviceProvider.CreateScope(); var context = scope.ServiceProvider.GetRequiredService(); // Find customers eligible for deletion var now = DateTime.UtcNow; var customersToDelete = await context.Customers .Where(c => c.DataRetentionDate.HasValue && c.DataRetentionDate.Value <= now) .Take(_options.MaxCustomersPerRun) .Include(c => c.Orders) .ThenInclude(o => o.Items) .Include(c => c.Messages) .ToListAsync(cancellationToken); if (customersToDelete.Count == 0) { _logger.LogInformation("โœ… No customers eligible for deletion at this time"); return; } _logger.LogInformation("๐Ÿ“Š Found {Count} customer(s) eligible for deletion", customersToDelete.Count); // Check for customers approaching deletion (notification period) await CheckUpcomingDeletionsAsync(context, cancellationToken); // Process each customer int deletedCount = 0; int failedCount = 0; foreach (var customer in customersToDelete) { try { // DataRetentionDate is guaranteed non-null by LINQ query filter var daysOverdue = (now - customer.DataRetentionDate!.Value).TotalDays; if (_options.DryRunMode) { _logger.LogWarning("๐Ÿ” DRY RUN: Would delete customer {CustomerId} ({DisplayName}) - {DaysOverdue:F1} days overdue", customer.Id, customer.DisplayName, daysOverdue); deletedCount++; } else { await DeleteCustomerDataAsync(context, customer, cancellationToken); deletedCount++; _logger.LogWarning("๐Ÿ—‘๏ธ DELETED: Customer {CustomerId} ({DisplayName}) - {DaysOverdue:F1} days after retention date. Orders: {OrderCount}, Messages: {MessageCount}", customer.Id, customer.DisplayName, daysOverdue, customer.Orders.Count, customer.Messages.Count); } } catch (Exception ex) { failedCount++; _logger.LogError(ex, "โŒ Failed to delete customer {CustomerId} ({DisplayName})", customer.Id, customer.DisplayName); } } if (!_options.DryRunMode) { await context.SaveChangesAsync(cancellationToken); } _logger.LogInformation("โœ… Cleanup completed: {Deleted} deleted, {Failed} failed", deletedCount, failedCount); // Log next run time var nextRun = DateTime.UtcNow.AddHours(_options.CheckIntervalHours); _logger.LogInformation("โฐ Next cleanup scheduled for {NextRun}", nextRun); } catch (Exception ex) { _logger.LogError(ex, "โŒ Data retention cleanup failed"); } } private async Task CheckUpcomingDeletionsAsync(LittleShopContext context, CancellationToken cancellationToken) { if (!_options.NotifyAdminBeforeDeletion) { return; } try { var now = DateTime.UtcNow; var notificationDate = now.AddDays(_options.NotificationDaysBeforeDeletion); var upcomingDeletions = await context.Customers .Where(c => c.DataRetentionDate.HasValue && c.DataRetentionDate.Value > now && c.DataRetentionDate.Value <= notificationDate) .Select(c => new { c.Id, c.DisplayName, c.TelegramUserId, c.DataRetentionDate, c.TotalOrders, c.TotalSpent }) .ToListAsync(cancellationToken); if (upcomingDeletions.Any()) { _logger.LogWarning("โš ๏ธ UPCOMING DELETIONS: {Count} customer(s) scheduled for deletion within {Days} days:", upcomingDeletions.Count, _options.NotificationDaysBeforeDeletion); foreach (var customer in upcomingDeletions) { var daysUntilDeletion = (customer.DataRetentionDate!.Value - now).TotalDays; _logger.LogWarning(" ๐Ÿ“‹ Customer {CustomerId} ({DisplayName}) - {Days:F1} days until deletion. Orders: {Orders}, Spent: ยฃ{Spent:F2}", customer.Id, customer.DisplayName, daysUntilDeletion, customer.TotalOrders, customer.TotalSpent); } // TODO: Integrate with notification service to email admins // var notificationService = scope.ServiceProvider.GetService(); // await notificationService?.SendAdminNotificationAsync("Upcoming Customer Data Deletions", ...); } } catch (Exception ex) { _logger.LogError(ex, "โŒ Failed to check upcoming deletions"); } } private async Task DeleteCustomerDataAsync(LittleShopContext context, Customer customer, CancellationToken cancellationToken) { // Delete related data first (cascade delete might not be configured) // Delete reviews var reviews = await context.Reviews .Where(r => r.CustomerId == customer.Id) .ToListAsync(cancellationToken); context.Reviews.RemoveRange(reviews); // Delete order items (via orders) foreach (var order in customer.Orders) { context.OrderItems.RemoveRange(order.Items); } // Delete orders context.Orders.RemoveRange(customer.Orders); // Delete messages context.CustomerMessages.RemoveRange(customer.Messages); // Delete push subscriptions var subscriptions = await context.PushSubscriptions .Where(s => s.CustomerId == customer.Id) .ToListAsync(cancellationToken); context.PushSubscriptions.RemoveRange(subscriptions); // Finally, delete customer record context.Customers.Remove(customer); _logger.LogInformation("๐Ÿ—‘๏ธ Deleted all data for customer {CustomerId}: {Reviews} reviews, {Orders} orders with items, {Messages} messages, {Subscriptions} push subscriptions", customer.Id, reviews.Count, customer.Orders.Count, customer.Messages.Count, subscriptions.Count); } public override void Dispose() { _timer?.Dispose(); base.Dispose(); } }