littleshop/LittleShop/Areas/Admin/Controllers/CustomersController.cs
SysAdmin a2247d7c02
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
feat: Add customer management, payments, and push notifications with security enhancements
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>
2025-11-16 19:33:02 +00:00

340 lines
13 KiB
C#

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using LittleShop.Services;
using LittleShop.DTOs;
namespace LittleShop.Areas.Admin.Controllers;
[Area("Admin")]
[Authorize(AuthenticationSchemes = "Cookies", Roles = "Admin")]
public class CustomersController : Controller
{
private readonly ICustomerService _customerService;
private readonly IOrderService _orderService;
private readonly ILogger<CustomersController> _logger;
public CustomersController(
ICustomerService customerService,
IOrderService orderService,
ILogger<CustomersController> logger)
{
_customerService = customerService;
_orderService = orderService;
_logger = logger;
}
public async Task<IActionResult> Index(string searchTerm = "")
{
try
{
IEnumerable<CustomerDto> customers;
if (!string.IsNullOrWhiteSpace(searchTerm))
{
customers = await _customerService.SearchCustomersAsync(searchTerm);
ViewData["SearchTerm"] = searchTerm;
}
else
{
customers = await _customerService.GetAllCustomersAsync();
}
return View(customers.OrderByDescending(c => c.CreatedAt));
}
catch (Exception ex)
{
_logger.LogError(ex, "Error loading customers index");
TempData["ErrorMessage"] = "Error loading customers";
return View(new List<CustomerDto>());
}
}
public async Task<IActionResult> Details(Guid id)
{
try
{
var customer = await _customerService.GetCustomerByIdAsync(id);
if (customer == null)
{
TempData["ErrorMessage"] = "Customer not found";
return RedirectToAction(nameof(Index));
}
// Get customer's order history
var allOrders = await _orderService.GetAllOrdersAsync();
var customerOrders = allOrders
.Where(o => o.CustomerId == id)
.OrderByDescending(o => o.CreatedAt)
.ToList();
ViewData["CustomerOrders"] = customerOrders;
return View(customer);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error loading customer {CustomerId}", id);
TempData["ErrorMessage"] = "Error loading customer details";
return RedirectToAction(nameof(Index));
}
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Block(Guid id, string reason)
{
try
{
if (string.IsNullOrWhiteSpace(reason))
{
TempData["ErrorMessage"] = "Block reason is required";
return RedirectToAction(nameof(Details), new { id });
}
var success = await _customerService.BlockCustomerAsync(id, reason);
if (success)
{
_logger.LogWarning("Customer {CustomerId} blocked by admin. Reason: {Reason}", id, reason);
TempData["SuccessMessage"] = "Customer blocked successfully";
}
else
{
TempData["ErrorMessage"] = "Failed to block customer";
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error blocking customer {CustomerId}", id);
TempData["ErrorMessage"] = "Error blocking customer";
}
return RedirectToAction(nameof(Details), new { id });
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Unblock(Guid id)
{
try
{
var success = await _customerService.UnblockCustomerAsync(id);
if (success)
{
_logger.LogInformation("Customer {CustomerId} unblocked by admin", id);
TempData["SuccessMessage"] = "Customer unblocked successfully";
}
else
{
TempData["ErrorMessage"] = "Failed to unblock customer";
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error unblocking customer {CustomerId}", id);
TempData["ErrorMessage"] = "Error unblocking customer";
}
return RedirectToAction(nameof(Details), new { id });
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> RefreshRiskScore(Guid id)
{
try
{
await _customerService.UpdateCustomerMetricsAsync(id);
TempData["SuccessMessage"] = "Risk score recalculated successfully";
}
catch (Exception ex)
{
_logger.LogError(ex, "Error refreshing risk score for customer {CustomerId}", id);
TempData["ErrorMessage"] = "Error refreshing risk score";
}
return RedirectToAction(nameof(Details), new { id });
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Delete(Guid id)
{
try
{
var success = await _customerService.DeleteCustomerAsync(id);
if (success)
{
_logger.LogWarning("Customer {CustomerId} deleted by admin", id);
TempData["SuccessMessage"] = "Customer deleted successfully (soft delete - data retained)";
return RedirectToAction(nameof(Index));
}
else
{
TempData["ErrorMessage"] = "Failed to delete customer";
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error deleting customer {CustomerId}", id);
TempData["ErrorMessage"] = "Error deleting customer";
}
return RedirectToAction(nameof(Details), new { id });
}
/// <summary>
/// Export customer data as JSON (GDPR "Right to Data Portability")
/// </summary>
[HttpGet]
public async Task<IActionResult> ExportJson(Guid id)
{
try
{
var exportData = await _customerService.GetCustomerDataForExportAsync(id);
if (exportData == null)
{
return NotFound();
}
var fileName = $"customer-data-{exportData.TelegramUsername ?? exportData.TelegramUserId.ToString()}-{DateTime.UtcNow:yyyyMMdd-HHmmss}.json";
var jsonOptions = new System.Text.Json.JsonSerializerOptions
{
WriteIndented = true,
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase
};
var jsonData = System.Text.Json.JsonSerializer.Serialize(exportData, jsonOptions);
_logger.LogInformation("Customer {CustomerId} data exported as JSON by admin", id);
return File(System.Text.Encoding.UTF8.GetBytes(jsonData), "application/json", fileName);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error exporting customer {CustomerId} data as JSON", id);
TempData["ErrorMessage"] = "Error generating JSON export";
return RedirectToAction(nameof(Details), new { id });
}
}
/// <summary>
/// Export customer data as CSV (GDPR "Right to Data Portability")
/// </summary>
[HttpGet]
public async Task<IActionResult> ExportCsv(Guid id)
{
try
{
var exportData = await _customerService.GetCustomerDataForExportAsync(id);
if (exportData == null)
{
return NotFound();
}
var fileName = $"customer-data-{exportData.TelegramUsername ?? exportData.TelegramUserId.ToString()}-{DateTime.UtcNow:yyyyMMdd-HHmmss}.csv";
var csvData = GenerateCsvExport(exportData);
_logger.LogInformation("Customer {CustomerId} data exported as CSV by admin", id);
return File(System.Text.Encoding.UTF8.GetBytes(csvData), "text/csv", fileName);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error exporting customer {CustomerId} data as CSV", id);
TempData["ErrorMessage"] = "Error generating CSV export";
return RedirectToAction(nameof(Details), new { id });
}
}
/// <summary>
/// Helper method to generate CSV export from customer data
/// </summary>
private string GenerateCsvExport(LittleShop.DTOs.CustomerDataExportDto exportData)
{
var csv = new System.Text.StringBuilder();
// Customer Profile Section
csv.AppendLine("CUSTOMER PROFILE");
csv.AppendLine("Field,Value");
csv.AppendLine($"Customer ID,{exportData.CustomerId}");
csv.AppendLine($"Telegram User ID,{exportData.TelegramUserId}");
csv.AppendLine($"Telegram Username,\"{EscapeCsv(exportData.TelegramUsername)}\"");
csv.AppendLine($"Telegram Display Name,\"{EscapeCsv(exportData.TelegramDisplayName)}\"");
csv.AppendLine($"First Name,\"{EscapeCsv(exportData.TelegramFirstName)}\"");
csv.AppendLine($"Last Name,\"{EscapeCsv(exportData.TelegramLastName)}\"");
csv.AppendLine($"Email,\"{EscapeCsv(exportData.Email)}\"");
csv.AppendLine($"Phone,\"{EscapeCsv(exportData.PhoneNumber)}\"");
csv.AppendLine($"Allow Marketing,{exportData.AllowMarketing}");
csv.AppendLine($"Allow Order Updates,{exportData.AllowOrderUpdates}");
csv.AppendLine($"Language,{exportData.Language}");
csv.AppendLine($"Timezone,{exportData.Timezone}");
csv.AppendLine($"Total Orders,{exportData.TotalOrders}");
csv.AppendLine($"Total Spent,{exportData.TotalSpent:F2}");
csv.AppendLine($"Average Order Value,{exportData.AverageOrderValue:F2}");
csv.AppendLine($"Is Blocked,{exportData.IsBlocked}");
csv.AppendLine($"Block Reason,\"{EscapeCsv(exportData.BlockReason)}\"");
csv.AppendLine($"Risk Score,{exportData.RiskScore}");
csv.AppendLine($"Created At,{exportData.CreatedAt:yyyy-MM-dd HH:mm:ss}");
csv.AppendLine($"Updated At,{exportData.UpdatedAt:yyyy-MM-dd HH:mm:ss}");
csv.AppendLine();
// Orders Section
csv.AppendLine("ORDERS");
csv.AppendLine("Order ID,Status,Total Amount,Currency,Order Date,Shipping Name,Shipping Address,City,Post Code,Country,Tracking Number");
foreach (var order in exportData.Orders)
{
csv.AppendLine($"{order.OrderId},{order.Status},{order.TotalAmount:F2},{order.Currency},{order.OrderDate:yyyy-MM-dd HH:mm:ss}," +
$"\"{EscapeCsv(order.ShippingName)}\",\"{EscapeCsv(order.ShippingAddress)}\",\"{EscapeCsv(order.ShippingCity)}\"," +
$"\"{order.ShippingPostCode}\",\"{order.ShippingCountry}\",\"{EscapeCsv(order.TrackingNumber)}\"");
// Order Items sub-section
if (order.Items.Any())
{
csv.AppendLine(" Order Items:");
csv.AppendLine(" Product Name,Variant,Quantity,Unit Price,Total Price");
foreach (var item in order.Items)
{
csv.AppendLine($" \"{EscapeCsv(item.ProductName)}\",\"{EscapeCsv(item.VariantName)}\"," +
$"{item.Quantity},{item.UnitPrice:F2},{item.TotalPrice:F2}");
}
}
}
csv.AppendLine();
// Messages Section
if (exportData.Messages.Any())
{
csv.AppendLine("MESSAGES");
csv.AppendLine("Sent At,Type,Content,Was Read,Read At");
foreach (var message in exportData.Messages)
{
csv.AppendLine($"{message.SentAt:yyyy-MM-dd HH:mm:ss},{message.MessageType}," +
$"\"{EscapeCsv(message.Content)}\",{message.WasRead}," +
$"{(message.ReadAt.HasValue ? message.ReadAt.Value.ToString("yyyy-MM-dd HH:mm:ss") : "")}");
}
csv.AppendLine();
}
// Reviews Section
if (exportData.Reviews.Any())
{
csv.AppendLine("REVIEWS");
csv.AppendLine("Product Name,Rating,Comment,Created At,Is Approved,Is Verified Purchase");
foreach (var review in exportData.Reviews)
{
csv.AppendLine($"\"{EscapeCsv(review.ProductName)}\",{review.Rating}," +
$"\"{EscapeCsv(review.Comment)}\",{review.CreatedAt:yyyy-MM-dd HH:mm:ss}," +
$"{review.IsApproved},{review.IsVerifiedPurchase}");
}
}
return csv.ToString();
}
/// <summary>
/// Escape CSV values containing quotes, commas, or newlines
/// </summary>
private string EscapeCsv(string? value)
{
if (string.IsNullOrEmpty(value)) return string.Empty;
return value.Replace("\"", "\"\""); // Escape quotes by doubling them
}
}