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>
428 lines
16 KiB
C#
428 lines
16 KiB
C#
using AutoMapper;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using LittleShop.Data;
|
|
using LittleShop.DTOs;
|
|
using LittleShop.Models;
|
|
|
|
namespace LittleShop.Services;
|
|
|
|
public class CustomerService : ICustomerService
|
|
{
|
|
private readonly LittleShopContext _context;
|
|
private readonly IMapper _mapper;
|
|
private readonly ILogger<CustomerService> _logger;
|
|
|
|
public CustomerService(LittleShopContext context, IMapper mapper, ILogger<CustomerService> logger)
|
|
{
|
|
_context = context;
|
|
_mapper = mapper;
|
|
_logger = logger;
|
|
}
|
|
|
|
public async Task<CustomerDto?> GetCustomerByIdAsync(Guid id)
|
|
{
|
|
var customer = await _context.Customers
|
|
.Include(c => c.Orders)
|
|
.FirstOrDefaultAsync(c => c.Id == id);
|
|
|
|
if (customer == null) return null;
|
|
|
|
var dto = _mapper.Map<CustomerDto>(customer);
|
|
dto.DisplayName = customer.DisplayName;
|
|
dto.CustomerType = customer.CustomerType;
|
|
return dto;
|
|
}
|
|
|
|
public async Task<CustomerDto?> GetCustomerByTelegramUserIdAsync(long telegramUserId)
|
|
{
|
|
var customer = await _context.Customers
|
|
.Include(c => c.Orders)
|
|
.FirstOrDefaultAsync(c => c.TelegramUserId == telegramUserId);
|
|
|
|
if (customer == null) return null;
|
|
|
|
var dto = _mapper.Map<CustomerDto>(customer);
|
|
dto.DisplayName = customer.DisplayName;
|
|
dto.CustomerType = customer.CustomerType;
|
|
return dto;
|
|
}
|
|
|
|
public async Task<CustomerDto> CreateCustomerAsync(CreateCustomerDto createCustomerDto)
|
|
{
|
|
// Check if customer already exists
|
|
var existingCustomer = await _context.Customers
|
|
.FirstOrDefaultAsync(c => c.TelegramUserId == createCustomerDto.TelegramUserId);
|
|
|
|
if (existingCustomer != null)
|
|
{
|
|
throw new InvalidOperationException($"Customer with Telegram ID {createCustomerDto.TelegramUserId} already exists");
|
|
}
|
|
|
|
var customer = _mapper.Map<Customer>(createCustomerDto);
|
|
customer.Id = Guid.NewGuid();
|
|
customer.CreatedAt = DateTime.UtcNow;
|
|
customer.UpdatedAt = DateTime.UtcNow;
|
|
customer.LastActiveAt = DateTime.UtcNow;
|
|
customer.IsActive = true;
|
|
|
|
// Set data retention date (default: 2 years after creation)
|
|
customer.DataRetentionDate = DateTime.UtcNow.AddYears(2);
|
|
|
|
_context.Customers.Add(customer);
|
|
await _context.SaveChangesAsync();
|
|
|
|
_logger.LogInformation("Created new customer {CustomerId} for Telegram user {TelegramUserId}",
|
|
customer.Id, customer.TelegramUserId);
|
|
|
|
var dto = _mapper.Map<CustomerDto>(customer);
|
|
dto.DisplayName = customer.DisplayName;
|
|
dto.CustomerType = customer.CustomerType;
|
|
return dto;
|
|
}
|
|
|
|
public async Task<CustomerDto?> UpdateCustomerAsync(Guid id, UpdateCustomerDto updateCustomerDto)
|
|
{
|
|
var customer = await _context.Customers.FindAsync(id);
|
|
if (customer == null) return null;
|
|
|
|
_mapper.Map(updateCustomerDto, customer);
|
|
customer.UpdatedAt = DateTime.UtcNow;
|
|
|
|
await _context.SaveChangesAsync();
|
|
|
|
_logger.LogInformation("Updated customer {CustomerId}", id);
|
|
|
|
var dto = _mapper.Map<CustomerDto>(customer);
|
|
dto.DisplayName = customer.DisplayName;
|
|
dto.CustomerType = customer.CustomerType;
|
|
return dto;
|
|
}
|
|
|
|
public async Task<bool> DeleteCustomerAsync(Guid id)
|
|
{
|
|
var customer = await _context.Customers.FindAsync(id);
|
|
if (customer == null) return false;
|
|
|
|
// Instead of hard delete, mark as inactive for data retention compliance
|
|
customer.IsActive = false;
|
|
customer.DataRetentionDate = DateTime.UtcNow.AddDays(30); // Delete in 30 days
|
|
customer.UpdatedAt = DateTime.UtcNow;
|
|
|
|
await _context.SaveChangesAsync();
|
|
|
|
_logger.LogInformation("Marked customer {CustomerId} for deletion", id);
|
|
return true;
|
|
}
|
|
|
|
public async Task<IEnumerable<CustomerDto>> GetAllCustomersAsync()
|
|
{
|
|
var customers = await _context.Customers
|
|
.Where(c => c.IsActive)
|
|
.Include(c => c.Orders)
|
|
.OrderByDescending(c => c.LastActiveAt)
|
|
.ToListAsync();
|
|
|
|
return customers.Select(c =>
|
|
{
|
|
var dto = _mapper.Map<CustomerDto>(c);
|
|
dto.DisplayName = c.DisplayName;
|
|
dto.CustomerType = c.CustomerType;
|
|
return dto;
|
|
});
|
|
}
|
|
|
|
public async Task<IEnumerable<CustomerDto>> SearchCustomersAsync(string searchTerm)
|
|
{
|
|
var query = _context.Customers
|
|
.Where(c => c.IsActive)
|
|
.Include(c => c.Orders)
|
|
.AsQueryable();
|
|
|
|
if (!string.IsNullOrWhiteSpace(searchTerm))
|
|
{
|
|
searchTerm = searchTerm.ToLower();
|
|
query = query.Where(c =>
|
|
c.TelegramUsername.ToLower().Contains(searchTerm) ||
|
|
c.TelegramDisplayName.ToLower().Contains(searchTerm) ||
|
|
c.TelegramFirstName.ToLower().Contains(searchTerm) ||
|
|
c.TelegramLastName.ToLower().Contains(searchTerm) ||
|
|
(c.Email != null && c.Email.ToLower().Contains(searchTerm)));
|
|
}
|
|
|
|
var customers = await query
|
|
.OrderByDescending(c => c.LastActiveAt)
|
|
.Take(50) // Limit search results
|
|
.ToListAsync();
|
|
|
|
return customers.Select(c =>
|
|
{
|
|
var dto = _mapper.Map<CustomerDto>(c);
|
|
dto.DisplayName = c.DisplayName;
|
|
dto.CustomerType = c.CustomerType;
|
|
return dto;
|
|
});
|
|
}
|
|
|
|
public async Task<CustomerDto?> GetOrCreateCustomerAsync(long telegramUserId, string displayName, string username = "", string firstName = "", string lastName = "")
|
|
{
|
|
// Try to find existing customer
|
|
var customer = await _context.Customers
|
|
.Include(c => c.Orders)
|
|
.FirstOrDefaultAsync(c => c.TelegramUserId == telegramUserId);
|
|
|
|
if (customer != null)
|
|
{
|
|
// Update customer information if provided
|
|
bool updated = false;
|
|
|
|
if (!string.IsNullOrEmpty(displayName) && customer.TelegramDisplayName != displayName)
|
|
{
|
|
customer.TelegramDisplayName = displayName;
|
|
updated = true;
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(username) && customer.TelegramUsername != username)
|
|
{
|
|
customer.TelegramUsername = username;
|
|
updated = true;
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(firstName) && customer.TelegramFirstName != firstName)
|
|
{
|
|
customer.TelegramFirstName = firstName;
|
|
updated = true;
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(lastName) && customer.TelegramLastName != lastName)
|
|
{
|
|
customer.TelegramLastName = lastName;
|
|
updated = true;
|
|
}
|
|
|
|
customer.LastActiveAt = DateTime.UtcNow;
|
|
|
|
if (updated)
|
|
{
|
|
customer.UpdatedAt = DateTime.UtcNow;
|
|
await _context.SaveChangesAsync();
|
|
_logger.LogInformation("Updated existing customer {CustomerId} information", customer.Id);
|
|
}
|
|
else
|
|
{
|
|
await _context.SaveChangesAsync(); // Just update LastActiveAt
|
|
}
|
|
|
|
var existingDto = _mapper.Map<CustomerDto>(customer);
|
|
existingDto.DisplayName = customer.DisplayName;
|
|
existingDto.CustomerType = customer.CustomerType;
|
|
return existingDto;
|
|
}
|
|
|
|
// Create new customer
|
|
customer = new Customer
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
TelegramUserId = telegramUserId,
|
|
TelegramUsername = username,
|
|
TelegramDisplayName = displayName,
|
|
TelegramFirstName = firstName,
|
|
TelegramLastName = lastName,
|
|
CreatedAt = DateTime.UtcNow,
|
|
UpdatedAt = DateTime.UtcNow,
|
|
LastActiveAt = DateTime.UtcNow,
|
|
DataRetentionDate = DateTime.UtcNow.AddYears(2),
|
|
IsActive = true,
|
|
AllowOrderUpdates = true,
|
|
AllowMarketing = false,
|
|
Language = "en",
|
|
Timezone = "UTC"
|
|
};
|
|
|
|
_context.Customers.Add(customer);
|
|
await _context.SaveChangesAsync();
|
|
|
|
_logger.LogInformation("Created new customer {CustomerId} for Telegram user {TelegramUserId} ({DisplayName})",
|
|
customer.Id, telegramUserId, displayName);
|
|
|
|
var dto = _mapper.Map<CustomerDto>(customer);
|
|
dto.DisplayName = customer.DisplayName;
|
|
dto.CustomerType = customer.CustomerType;
|
|
return dto;
|
|
}
|
|
|
|
public async Task UpdateCustomerMetricsAsync(Guid customerId)
|
|
{
|
|
var customer = await _context.Customers
|
|
.Include(c => c.Orders)
|
|
.FirstOrDefaultAsync(c => c.Id == customerId);
|
|
|
|
if (customer == null) return;
|
|
|
|
customer.UpdateMetrics();
|
|
await _context.SaveChangesAsync();
|
|
|
|
_logger.LogInformation("Updated metrics for customer {CustomerId}", customerId);
|
|
}
|
|
|
|
public async Task<bool> BlockCustomerAsync(Guid customerId, string reason)
|
|
{
|
|
var customer = await _context.Customers.FindAsync(customerId);
|
|
if (customer == null) return false;
|
|
|
|
customer.IsBlocked = true;
|
|
customer.BlockReason = reason;
|
|
customer.UpdatedAt = DateTime.UtcNow;
|
|
|
|
await _context.SaveChangesAsync();
|
|
|
|
_logger.LogWarning("Blocked customer {CustomerId} - Reason: {Reason}", customerId, reason);
|
|
return true;
|
|
}
|
|
|
|
public async Task<bool> UnblockCustomerAsync(Guid customerId)
|
|
{
|
|
var customer = await _context.Customers.FindAsync(customerId);
|
|
if (customer == null) return false;
|
|
|
|
customer.IsBlocked = false;
|
|
customer.BlockReason = null;
|
|
customer.UpdatedAt = DateTime.UtcNow;
|
|
|
|
await _context.SaveChangesAsync();
|
|
|
|
_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;
|
|
}
|
|
}
|
|
} |