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();
}
}