feat: Add customer management, payments, and push notifications with security enhancements
Some checks failed
Build and Deploy LittleShop / Build TeleBot Docker Image (push) Failing after 11s
Build and Deploy LittleShop / Build LittleShop Docker Image (push) Failing after 15s
Build and Deploy LittleShop / Deploy to Production VPS (Manual Only) (push) Has been skipped
Build and Deploy LittleShop / Deploy to Pre-Production (CT109) (push) Has been skipped
Some checks failed
Build and Deploy LittleShop / Build TeleBot Docker Image (push) Failing after 11s
Build and Deploy LittleShop / Build LittleShop Docker Image (push) Failing after 15s
Build and Deploy LittleShop / Deploy to Production VPS (Manual Only) (push) Has been skipped
Build and Deploy LittleShop / Deploy to Pre-Production (CT109) (push) Has been skipped
Major Feature Additions: - Customer management: Full CRUD with data export and privacy compliance - Payment management: Centralized payment tracking and administration - Push notification subscriptions: Manage and track web push subscriptions Security Enhancements: - IP whitelist middleware for administrative endpoints - Data retention service with configurable policies - Enhanced push notification security documentation - Security fixes progress tracking (2025-11-14) UI/UX Improvements: - Enhanced navigation with improved mobile responsiveness - Updated admin dashboard with order status counts - Improved product CRUD forms - New customer and payment management interfaces Backend Improvements: - Extended customer service with data export capabilities - Enhanced order service with status count queries - Improved crypto payment service with better error handling - Updated validators and configuration Documentation: - DEPLOYMENT_NGINX_GUIDE.md: Nginx deployment instructions - IP_STORAGE_ANALYSIS.md: IP storage security analysis - PUSH_NOTIFICATION_SECURITY.md: Push notification security guide - UI_UX_IMPROVEMENT_PLAN.md: Planned UI/UX enhancements - UI_UX_IMPROVEMENTS_COMPLETED.md: Completed improvements Cleanup: - Removed temporary database WAL files - Removed stale commit message file 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -102,6 +102,16 @@ public class CryptoPaymentService : ICryptoPaymentService
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<CryptoPaymentDto>> GetAllPaymentsAsync()
|
||||
{
|
||||
var payments = await _context.CryptoPayments
|
||||
.Include(cp => cp.Order)
|
||||
.OrderByDescending(cp => cp.CreatedAt)
|
||||
.ToListAsync();
|
||||
|
||||
return payments.Select(MapToDto);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<CryptoPaymentDto>> GetPaymentsByOrderAsync(Guid orderId)
|
||||
{
|
||||
var payments = await _context.CryptoPayments
|
||||
|
||||
@@ -293,4 +293,136 @@ public class CustomerService : ICustomerService
|
||||
_logger.LogInformation("Unblocked customer {CustomerId}", customerId);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<CustomerDataExportDto?> GetCustomerDataForExportAsync(Guid customerId)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Get customer with all related data
|
||||
// Note: EF Core requires AsSplitQuery for multiple ThenInclude on same level
|
||||
var customer = await _context.Customers
|
||||
.Include(c => c.Orders)
|
||||
.ThenInclude(o => o.Items)
|
||||
.ThenInclude(oi => oi.Product)
|
||||
.Include(c => c.Orders)
|
||||
.ThenInclude(o => o.Items)
|
||||
.ThenInclude(oi => oi.ProductVariant)
|
||||
.Include(c => c.Messages)
|
||||
.AsSplitQuery()
|
||||
.FirstOrDefaultAsync(c => c.Id == customerId);
|
||||
|
||||
if (customer == null)
|
||||
{
|
||||
_logger.LogWarning("Customer {CustomerId} not found for data export", customerId);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get customer reviews separately (no direct navigation property from Customer to Review)
|
||||
var reviews = await _context.Reviews
|
||||
.Include(r => r.Product)
|
||||
.Where(r => r.CustomerId == customerId)
|
||||
.ToListAsync();
|
||||
|
||||
// Build export DTO
|
||||
var exportDto = new CustomerDataExportDto
|
||||
{
|
||||
// Customer Profile
|
||||
CustomerId = customer.Id,
|
||||
TelegramUserId = customer.TelegramUserId,
|
||||
TelegramUsername = customer.TelegramUsername,
|
||||
TelegramDisplayName = customer.TelegramDisplayName,
|
||||
TelegramFirstName = customer.TelegramFirstName,
|
||||
TelegramLastName = customer.TelegramLastName,
|
||||
Email = customer.Email,
|
||||
PhoneNumber = customer.PhoneNumber,
|
||||
|
||||
// Preferences
|
||||
AllowMarketing = customer.AllowMarketing,
|
||||
AllowOrderUpdates = customer.AllowOrderUpdates,
|
||||
Language = customer.Language,
|
||||
Timezone = customer.Timezone,
|
||||
|
||||
// Metrics
|
||||
TotalOrders = customer.TotalOrders,
|
||||
TotalSpent = customer.TotalSpent,
|
||||
AverageOrderValue = customer.AverageOrderValue,
|
||||
FirstOrderDate = customer.FirstOrderDate == DateTime.MinValue ? null : customer.FirstOrderDate,
|
||||
LastOrderDate = customer.LastOrderDate == DateTime.MinValue ? null : customer.LastOrderDate,
|
||||
|
||||
// Account Status
|
||||
IsBlocked = customer.IsBlocked,
|
||||
BlockReason = customer.BlockReason,
|
||||
RiskScore = customer.RiskScore,
|
||||
CustomerNotes = customer.CustomerNotes,
|
||||
|
||||
// Timestamps
|
||||
CreatedAt = customer.CreatedAt,
|
||||
UpdatedAt = customer.UpdatedAt,
|
||||
LastActiveAt = customer.LastActiveAt,
|
||||
DataRetentionDate = customer.DataRetentionDate,
|
||||
|
||||
// Orders
|
||||
Orders = customer.Orders.Select(o => new CustomerOrderExportDto
|
||||
{
|
||||
OrderId = o.Id,
|
||||
Status = o.Status.ToString(),
|
||||
TotalAmount = o.TotalAmount,
|
||||
Currency = o.Currency,
|
||||
OrderDate = o.CreatedAt,
|
||||
|
||||
ShippingName = o.ShippingName,
|
||||
ShippingAddress = o.ShippingAddress,
|
||||
ShippingCity = o.ShippingCity,
|
||||
ShippingPostCode = o.ShippingPostCode,
|
||||
ShippingCountry = o.ShippingCountry,
|
||||
|
||||
TrackingNumber = o.TrackingNumber,
|
||||
EstimatedDeliveryDate = o.ExpectedDeliveryDate,
|
||||
ActualDeliveryDate = o.ActualDeliveryDate,
|
||||
Notes = o.Notes,
|
||||
|
||||
Items = o.Items.Select(oi => new CustomerOrderItemExportDto
|
||||
{
|
||||
ProductName = oi.Product?.Name ?? "Unknown Product",
|
||||
VariantName = oi.ProductVariant?.Name,
|
||||
Quantity = oi.Quantity,
|
||||
UnitPrice = oi.UnitPrice,
|
||||
TotalPrice = oi.TotalPrice
|
||||
}).ToList()
|
||||
}).ToList(),
|
||||
|
||||
// Messages
|
||||
Messages = customer.Messages.Select(m => new CustomerMessageExportDto
|
||||
{
|
||||
SentAt = m.SentAt ?? m.CreatedAt,
|
||||
MessageType = m.Type.ToString(),
|
||||
Content = m.Content,
|
||||
WasRead = m.Status == LittleShop.Models.MessageStatus.Read,
|
||||
ReadAt = m.ReadAt
|
||||
}).ToList(),
|
||||
|
||||
// Reviews
|
||||
Reviews = reviews.Select(r => new CustomerReviewExportDto
|
||||
{
|
||||
ProductId = r.ProductId,
|
||||
ProductName = r.Product?.Name ?? "Unknown Product",
|
||||
Rating = r.Rating,
|
||||
Comment = r.Comment,
|
||||
CreatedAt = r.CreatedAt,
|
||||
IsApproved = r.IsApproved,
|
||||
IsVerifiedPurchase = r.IsVerifiedPurchase
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
_logger.LogInformation("Generated data export for customer {CustomerId} with {OrderCount} orders, {MessageCount} messages, {ReviewCount} reviews",
|
||||
customerId, exportDto.Orders.Count, exportDto.Messages.Count, exportDto.Reviews.Count);
|
||||
|
||||
return exportDto;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error generating data export for customer {CustomerId}", customerId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
263
LittleShop/Services/DataRetentionService.cs
Normal file
263
LittleShop/Services/DataRetentionService.cs
Normal file
@@ -0,0 +1,263 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using LittleShop.Configuration;
|
||||
using LittleShop.Data;
|
||||
using LittleShop.Models;
|
||||
|
||||
namespace LittleShop.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Background service that enforces GDPR data retention policies
|
||||
/// Automatically deletes customer data after retention period expires
|
||||
/// </summary>
|
||||
public class DataRetentionService : BackgroundService
|
||||
{
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly ILogger<DataRetentionService> _logger;
|
||||
private readonly DataRetentionOptions _options;
|
||||
private Timer? _timer;
|
||||
|
||||
public DataRetentionService(
|
||||
IServiceProvider serviceProvider,
|
||||
ILogger<DataRetentionService> logger,
|
||||
IOptions<DataRetentionOptions> 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<LittleShopContext>();
|
||||
|
||||
// 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<INotificationService>();
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ namespace LittleShop.Services;
|
||||
public interface ICryptoPaymentService
|
||||
{
|
||||
Task<CryptoPaymentDto> CreatePaymentAsync(Guid orderId, CryptoCurrency currency);
|
||||
Task<IEnumerable<CryptoPaymentDto>> GetAllPaymentsAsync();
|
||||
Task<IEnumerable<CryptoPaymentDto>> GetPaymentsByOrderAsync(Guid orderId);
|
||||
Task<bool> ProcessPaymentWebhookAsync(string invoiceId, PaymentStatus status, decimal amount, string? transactionHash = null, int confirmations = 0);
|
||||
Task<bool> ProcessSilverPayWebhookAsync(string orderId, PaymentStatus status, decimal amount, string? transactionHash = null, int confirmations = 0);
|
||||
|
||||
@@ -16,4 +16,7 @@ public interface ICustomerService
|
||||
Task UpdateCustomerMetricsAsync(Guid customerId);
|
||||
Task<bool> BlockCustomerAsync(Guid customerId, string reason);
|
||||
Task<bool> UnblockCustomerAsync(Guid customerId);
|
||||
|
||||
// GDPR Data Export
|
||||
Task<CustomerDataExportDto?> GetCustomerDataForExportAsync(Guid customerId);
|
||||
}
|
||||
@@ -26,4 +26,7 @@ public interface IOrderService
|
||||
Task<IEnumerable<OrderDto>> GetOrdersRequiringActionAsync(); // PaymentReceived orders needing acceptance
|
||||
Task<IEnumerable<OrderDto>> GetOrdersForPackingAsync(); // Accepted orders ready for packing
|
||||
Task<IEnumerable<OrderDto>> GetOrdersOnHoldAsync(); // Orders on hold
|
||||
|
||||
// Performance optimization - get all status counts in single query
|
||||
Task<OrderStatusCountsDto> GetOrderStatusCountsAsync();
|
||||
}
|
||||
@@ -607,6 +607,27 @@ public class OrderService : IOrderService
|
||||
return await GetOrdersByStatusAsync(OrderStatus.OnHold);
|
||||
}
|
||||
|
||||
public async Task<OrderStatusCountsDto> GetOrderStatusCountsAsync()
|
||||
{
|
||||
// Single efficient query to get all status counts
|
||||
var orders = await _context.Orders
|
||||
.Select(o => new { o.Status })
|
||||
.ToListAsync();
|
||||
|
||||
var statusCounts = new OrderStatusCountsDto
|
||||
{
|
||||
PendingPaymentCount = orders.Count(o => o.Status == OrderStatus.PendingPayment),
|
||||
RequiringActionCount = orders.Count(o => o.Status == OrderStatus.PaymentReceived),
|
||||
ForPackingCount = orders.Count(o => o.Status == OrderStatus.Accepted),
|
||||
DispatchedCount = orders.Count(o => o.Status == OrderStatus.Dispatched),
|
||||
OnHoldCount = orders.Count(o => o.Status == OrderStatus.OnHold),
|
||||
DeliveredCount = orders.Count(o => o.Status == OrderStatus.Delivered),
|
||||
CancelledCount = orders.Count(o => o.Status == OrderStatus.Cancelled)
|
||||
};
|
||||
|
||||
return statusCounts;
|
||||
}
|
||||
|
||||
private async Task SendNewOrderNotification(Order order)
|
||||
{
|
||||
try
|
||||
|
||||
Reference in New Issue
Block a user