Initial commit of LittleShop project (excluding large archives)

- BTCPay Server integration
- TeleBot Telegram bot
- Review system
- Admin area
- Docker deployment configuration

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-09-17 15:07:38 +01:00
parent bcca00ab39
commit e1b377a042
140 changed files with 32166 additions and 21089 deletions

View File

@@ -0,0 +1,185 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using LittleShop.Services;
using LittleShop.DTOs;
using System.Security.Claims;
namespace LittleShop.Areas.Admin.Controllers;
[Area("Admin")]
[Authorize(AuthenticationSchemes = "Cookies", Roles = "Admin")]
public class ReviewsController : Controller
{
private readonly IReviewService _reviewService;
private readonly ILogger<ReviewsController> _logger;
public ReviewsController(IReviewService reviewService, ILogger<ReviewsController> logger)
{
_reviewService = reviewService;
_logger = logger;
}
public async Task<IActionResult> Index()
{
try
{
var pendingReviews = await _reviewService.GetPendingReviewsAsync();
return View(pendingReviews);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error loading reviews index");
TempData["ErrorMessage"] = "Error loading reviews";
return View(new List<ReviewDto>());
}
}
public async Task<IActionResult> Details(Guid id)
{
try
{
var review = await _reviewService.GetReviewByIdAsync(id);
if (review == null)
{
TempData["ErrorMessage"] = "Review not found";
return RedirectToAction(nameof(Index));
}
return View(review);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error loading review {ReviewId}", id);
TempData["ErrorMessage"] = "Error loading review details";
return RedirectToAction(nameof(Index));
}
}
[HttpPost]
public async Task<IActionResult> Approve(Guid id)
{
try
{
var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (!Guid.TryParse(userIdClaim, out var userId))
{
TempData["ErrorMessage"] = "Authentication error";
return RedirectToAction(nameof(Index));
}
var success = await _reviewService.ApproveReviewAsync(id, userId);
if (success)
{
TempData["SuccessMessage"] = "Review approved successfully";
}
else
{
TempData["ErrorMessage"] = "Failed to approve review";
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error approving review {ReviewId}", id);
TempData["ErrorMessage"] = "Error approving review";
}
return RedirectToAction(nameof(Index));
}
[HttpPost]
public async Task<IActionResult> Delete(Guid id)
{
try
{
var success = await _reviewService.DeleteReviewAsync(id);
if (success)
{
TempData["SuccessMessage"] = "Review deleted successfully";
}
else
{
TempData["ErrorMessage"] = "Failed to delete review";
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error deleting review {ReviewId}", id);
TempData["ErrorMessage"] = "Error deleting review";
}
return RedirectToAction(nameof(Index));
}
public async Task<IActionResult> Edit(Guid id)
{
try
{
var review = await _reviewService.GetReviewByIdAsync(id);
if (review == null)
{
TempData["ErrorMessage"] = "Review not found";
return RedirectToAction(nameof(Index));
}
var updateDto = new UpdateReviewDto
{
Rating = review.Rating,
Title = review.Title,
Comment = review.Comment,
IsApproved = review.IsApproved,
IsActive = review.IsActive
};
ViewBag.ReviewId = id;
ViewBag.ProductName = review.ProductName;
ViewBag.CustomerName = review.CustomerDisplayName;
return View(updateDto);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error loading review {ReviewId} for edit", id);
TempData["ErrorMessage"] = "Error loading review for edit";
return RedirectToAction(nameof(Index));
}
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Edit(Guid id, UpdateReviewDto updateDto)
{
if (!ModelState.IsValid)
{
var review = await _reviewService.GetReviewByIdAsync(id);
ViewBag.ReviewId = id;
ViewBag.ProductName = review?.ProductName ?? "";
ViewBag.CustomerName = review?.CustomerDisplayName ?? "";
return View(updateDto);
}
try
{
var success = await _reviewService.UpdateReviewAsync(id, updateDto);
if (success)
{
TempData["SuccessMessage"] = "Review updated successfully";
return RedirectToAction(nameof(Details), new { id });
}
else
{
TempData["ErrorMessage"] = "Review not found";
return RedirectToAction(nameof(Index));
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error updating review {ReviewId}", id);
TempData["ErrorMessage"] = "Error updating review";
var reviewDetails = await _reviewService.GetReviewByIdAsync(id);
ViewBag.ReviewId = id;
ViewBag.ProductName = reviewDetails?.ProductName ?? "";
ViewBag.CustomerName = reviewDetails?.CustomerDisplayName ?? "";
return View(updateDto);
}
}
}

View File

@@ -1,104 +1,104 @@
@model IEnumerable<LittleShop.DTOs.OrderDto>
@{
ViewData["Title"] = "Orders";
}
<div class="row mb-4">
<div class="col">
<h1><i class="fas fa-shopping-cart"></i> Orders</h1>
</div>
<div class="col-auto">
<a href="@Url.Action("Create")" class="btn btn-primary">
<i class="fas fa-plus"></i> Create Order
</a>
</div>
</div>
<div class="card">
<div class="card-body">
@if (Model.Any())
{
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th>Order ID</th>
<th>Customer</th>
<th>Shipping To</th>
<th>Status</th>
<th>Total</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@foreach (var order in Model)
{
<tr>
<td><code>@order.Id.ToString().Substring(0, 8)...</code></td>
<td>
@if (order.Customer != null)
{
<div>
<strong>@order.Customer.DisplayName</strong>
@if (!string.IsNullOrEmpty(order.Customer.TelegramUsername))
{
<br><small class="text-muted">@@@order.Customer.TelegramUsername</small>
}
<br><small class="badge bg-info">@order.Customer.CustomerType</small>
</div>
}
else
{
<span class="text-muted">@order.ShippingName</span>
@if (!string.IsNullOrEmpty(order.IdentityReference))
{
<br><small class="text-muted">(@order.IdentityReference)</small>
}
}
</td>
<td>@order.ShippingCity, @order.ShippingCountry</td>
<td>
@{
var badgeClass = order.Status switch
{
LittleShop.Enums.OrderStatus.PendingPayment => "bg-warning",
LittleShop.Enums.OrderStatus.PaymentReceived => "bg-success",
LittleShop.Enums.OrderStatus.Processing => "bg-info",
LittleShop.Enums.OrderStatus.Shipped => "bg-primary",
LittleShop.Enums.OrderStatus.Delivered => "bg-success",
LittleShop.Enums.OrderStatus.Cancelled => "bg-danger",
_ => "bg-secondary"
};
}
<span class="badge @badgeClass">@order.Status</span>
</td>
<td><strong>£@order.TotalAmount</strong></td>
<td>@order.CreatedAt.ToString("MMM dd, yyyy HH:mm")</td>
<td>
<a href="@Url.Action("Details", new { id = order.Id })" class="btn btn-sm btn-outline-primary">
<i class="fas fa-eye"></i> View
</a>
@if (order.Customer != null)
{
<a href="@Url.Action("Details", new { id = order.Id })" class="btn btn-sm btn-success ms-1" title="Message Customer">
<i class="fas fa-comment"></i>
</a>
}
</td>
</tr>
}
</tbody>
</table>
</div>
}
else
{
<div class="text-center py-4">
<i class="fas fa-shopping-cart fa-3x text-muted mb-3"></i>
<p class="text-muted">No orders found yet.</p>
</div>
}
</div>
@model IEnumerable<LittleShop.DTOs.OrderDto>
@{
ViewData["Title"] = "Orders";
}
<div class="row mb-4">
<div class="col">
<h1><i class="fas fa-shopping-cart"></i> Orders</h1>
</div>
<div class="col-auto">
<a href="@Url.Action("Create")" class="btn btn-primary">
<i class="fas fa-plus"></i> Create Order
</a>
</div>
</div>
<div class="card">
<div class="card-body">
@if (Model.Any())
{
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th>Order ID</th>
<th>Customer</th>
<th>Shipping To</th>
<th>Status</th>
<th>Total</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@foreach (var order in Model)
{
<tr>
<td><code>@order.Id.ToString().Substring(0, 8)...</code></td>
<td>
@if (order.Customer != null)
{
<div>
<strong>@order.Customer.DisplayName</strong>
@if (!string.IsNullOrEmpty(order.Customer.TelegramUsername))
{
<br><small class="text-muted">@@@order.Customer.TelegramUsername</small>
}
<br><small class="badge bg-info">@order.Customer.CustomerType</small>
</div>
}
else
{
<span class="text-muted">@order.ShippingName</span>
@if (!string.IsNullOrEmpty(order.IdentityReference))
{
<br><small class="text-muted">(@order.IdentityReference)</small>
}
}
</td>
<td>@order.ShippingCity, @order.ShippingCountry</td>
<td>
@{
var badgeClass = order.Status switch
{
LittleShop.Enums.OrderStatus.PendingPayment => "bg-warning",
LittleShop.Enums.OrderStatus.PaymentReceived => "bg-success",
LittleShop.Enums.OrderStatus.Processing => "bg-info",
LittleShop.Enums.OrderStatus.Shipped => "bg-primary",
LittleShop.Enums.OrderStatus.Delivered => "bg-success",
LittleShop.Enums.OrderStatus.Cancelled => "bg-danger",
_ => "bg-secondary"
};
}
<span class="badge @badgeClass">@order.Status</span>
</td>
<td><strong>£@order.TotalAmount</strong></td>
<td>@order.CreatedAt.ToString("MMM dd, yyyy HH:mm")</td>
<td>
<a href="@Url.Action("Details", new { id = order.Id })" class="btn btn-sm btn-outline-primary">
<i class="fas fa-eye"></i> View
</a>
@if (order.Customer != null)
{
<a href="@Url.Action("Details", new { id = order.Id })" class="btn btn-sm btn-success ms-1" title="Message Customer">
<i class="fas fa-comment"></i>
</a>
}
</td>
</tr>
}
</tbody>
</table>
</div>
}
else
{
<div class="text-center py-4">
<i class="fas fa-shopping-cart fa-3x text-muted mb-3"></i>
<p class="text-muted">No orders found yet.</p>
</div>
}
</div>
</div>

View File

@@ -0,0 +1,149 @@
@model LittleShop.DTOs.ReviewDto
@{
ViewData["Title"] = "Review Details";
}
<div class="container-fluid">
<div class="row">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-3">
<h2><i class="fas fa-star me-2"></i>Review Details</h2>
<a asp-action="Index" class="btn btn-secondary">
<i class="fas fa-arrow-left me-1"></i>Back to Reviews
</a>
</div>
@if (TempData["SuccessMessage"] != null)
{
<div class="alert alert-success alert-dismissible fade show" role="alert">
@TempData["SuccessMessage"]
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
}
<div class="card shadow">
<div class="card-header bg-primary text-white">
<h5 class="mb-0">
<i class="fas fa-comment me-2"></i>Customer Review
@if (Model.IsVerifiedPurchase)
{
<span class="badge bg-success ms-2">
<i class="fas fa-check-circle"></i> Verified Purchase
</span>
}
@if (Model.IsApproved)
{
<span class="badge bg-info ms-2">
<i class="fas fa-check"></i> Approved
</span>
}
else
{
<span class="badge bg-warning ms-2">
<i class="fas fa-clock"></i> Pending Approval
</span>
}
</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<h6><i class="fas fa-box me-2"></i>Product Information</h6>
<p><strong>Product:</strong> @Model.ProductName</p>
<p><strong>Product ID:</strong> <code>@Model.ProductId</code></p>
</div>
<div class="col-md-6">
<h6><i class="fas fa-user me-2"></i>Customer Information</h6>
<p><strong>Customer:</strong> @Model.CustomerDisplayName</p>
<p><strong>Customer ID:</strong> <code>@Model.CustomerId</code></p>
<p><strong>Order ID:</strong> <code>@Model.OrderId</code></p>
</div>
</div>
<hr>
<div class="row">
<div class="col-md-12">
<h6><i class="fas fa-star me-2"></i>Review Details</h6>
<div class="mb-3">
<label class="form-label"><strong>Rating:</strong></label>
<div class="d-flex align-items-center">
@for (int i = 1; i <= 5; i++)
{
<i class="fas fa-star @(i <= Model.Rating ? "text-warning" : "text-muted") me-1"></i>
}
<span class="ms-2 h5 mb-0">@Model.Rating out of 5 stars</span>
</div>
</div>
@if (!string.IsNullOrEmpty(Model.Title))
{
<div class="mb-3">
<label class="form-label"><strong>Title:</strong></label>
<p class="form-control-plaintext">@Model.Title</p>
</div>
}
@if (!string.IsNullOrEmpty(Model.Comment))
{
<div class="mb-3">
<label class="form-label"><strong>Comment:</strong></label>
<div class="card">
<div class="card-body">
<p class="mb-0">@Model.Comment</p>
</div>
</div>
</div>
}
</div>
</div>
<hr>
<div class="row">
<div class="col-md-6">
<h6><i class="fas fa-info-circle me-2"></i>Review Metadata</h6>
<p><strong>Created:</strong> @Model.CreatedAt.ToString("MMM dd, yyyy 'at' h:mm tt")</p>
<p><strong>Updated:</strong> @Model.UpdatedAt.ToString("MMM dd, yyyy 'at' h:mm tt")</p>
@if (Model.IsApproved && Model.ApprovedAt.HasValue)
{
<p><strong>Approved:</strong> @Model.ApprovedAt.Value.ToString("MMM dd, yyyy 'at' h:mm tt")</p>
@if (!string.IsNullOrEmpty(Model.ApprovedByUsername))
{
<p><strong>Approved By:</strong> @Model.ApprovedByUsername</p>
}
}
</div>
<div class="col-md-6">
<h6><i class="fas fa-cogs me-2"></i>Actions</h6>
<div class="d-grid gap-2">
@if (!Model.IsApproved)
{
<form asp-action="Approve" asp-route-id="@Model.Id" method="post" class="d-inline">
<button type="submit" class="btn btn-success w-100"
onclick="return confirm('Approve this review for public display?')">
<i class="fas fa-check me-2"></i>Approve Review
</button>
</form>
}
<a asp-action="Edit" asp-route-id="@Model.Id" class="btn btn-outline-primary">
<i class="fas fa-edit me-2"></i>Edit Review
</a>
<form asp-action="Delete" asp-route-id="@Model.Id" method="post" class="d-inline">
<button type="submit" class="btn btn-outline-danger w-100"
onclick="return confirm('Are you sure you want to delete this review?')">
<i class="fas fa-trash me-2"></i>Delete Review
</button>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,136 @@
@model IEnumerable<LittleShop.DTOs.ReviewDto>
@{
ViewData["Title"] = "Reviews";
}
<div class="container-fluid">
<div class="row">
<div class="col-12">
<div class="card shadow mb-4">
<div class="card-header py-3 d-flex justify-content-between align-items-center">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-star me-2"></i>Customer Reviews
</h6>
<span class="badge badge-warning">@Model.Count() Pending Approval</span>
</div>
<div class="card-body">
@if (TempData["SuccessMessage"] != null)
{
<div class="alert alert-success alert-dismissible fade show" role="alert">
@TempData["SuccessMessage"]
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
}
@if (TempData["ErrorMessage"] != null)
{
<div class="alert alert-danger alert-dismissible fade show" role="alert">
@TempData["ErrorMessage"]
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
}
@if (!Model.Any())
{
<div class="text-center py-4">
<div class="mb-3">
<i class="fas fa-star fa-3x text-muted"></i>
</div>
<h5 class="text-muted">No pending reviews</h5>
<p class="text-muted">New customer reviews will appear here for approval.</p>
</div>
}
else
{
<div class="table-responsive">
<table class="table table-hover">
<thead class="table-light">
<tr>
<th>Product</th>
<th>Customer</th>
<th>Rating</th>
<th>Review</th>
<th>Order ID</th>
<th>Date</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@foreach (var review in Model.OrderByDescending(r => r.CreatedAt))
{
<tr>
<td>
<strong>@review.ProductName</strong>
</td>
<td>
<span class="text-muted">@review.CustomerDisplayName</span>
@if (review.IsVerifiedPurchase)
{
<i class="fas fa-check-circle text-success ms-1" title="Verified Purchase"></i>
}
</td>
<td>
<div class="d-flex align-items-center">
@for (int i = 1; i <= 5; i++)
{
<i class="fas fa-star @(i <= review.Rating ? "text-warning" : "text-muted")"></i>
}
<span class="ms-2 text-muted">(@review.Rating/5)</span>
</div>
</td>
<td>
@if (!string.IsNullOrEmpty(review.Title))
{
<strong class="d-block">@review.Title</strong>
}
@if (!string.IsNullOrEmpty(review.Comment))
{
<span class="text-muted">
@(review.Comment.Length > 100 ? review.Comment.Substring(0, 100) + "..." : review.Comment)
</span>
}
</td>
<td>
<small class="text-monospace">@review.OrderId.ToString().Substring(0, 8)...</small>
</td>
<td>
<small>@review.CreatedAt.ToString("MMM dd, yyyy")</small>
</td>
<td>
<div class="btn-group" role="group">
<a asp-action="Details" asp-route-id="@review.Id"
class="btn btn-sm btn-outline-primary" title="View Details">
<i class="fas fa-eye"></i>
</a>
@if (!review.IsApproved)
{
<form asp-action="Approve" asp-route-id="@review.Id" method="post" class="d-inline">
<button type="submit" class="btn btn-sm btn-success"
onclick="return confirm('Approve this review?')" title="Approve">
<i class="fas fa-check"></i>
</button>
</form>
}
<a asp-action="Edit" asp-route-id="@review.Id"
class="btn btn-sm btn-outline-secondary" title="Edit">
<i class="fas fa-edit"></i>
</a>
<form asp-action="Delete" asp-route-id="@review.Id" method="post" class="d-inline">
<button type="submit" class="btn btn-sm btn-outline-danger"
onclick="return confirm('Delete this review?')" title="Delete">
<i class="fas fa-trash"></i>
</button>
</form>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
</div>
</div>
</div>
</div>

View File

@@ -1,123 +1,128 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no, viewport-fit=cover" />
<title>@ViewData["Title"] - LittleShop Admin</title>
<!-- PWA Meta Tags -->
<meta name="application-name" content="LittleShop Admin" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="LittleShop" />
<meta name="description" content="Modern e-commerce admin panel" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="theme-color" content="#2563eb" />
<meta name="msapplication-TileColor" content="#2563eb" />
<meta name="msapplication-tap-highlight" content="no" />
<!-- PWA Manifest -->
<link rel="manifest" href="/manifest.json" />
<!-- Icons -->
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="apple-touch-icon" href="/icons/icon-152x152.png" />
<link rel="apple-touch-icon" sizes="72x72" href="/icons/icon-72x72.png" />
<link rel="apple-touch-icon" sizes="96x96" href="/icons/icon-96x96.png" />
<link rel="apple-touch-icon" sizes="128x128" href="/icons/icon-128x128.png" />
<link rel="apple-touch-icon" sizes="144x144" href="/icons/icon-144x144.png" />
<link rel="apple-touch-icon" sizes="152x152" href="/icons/icon-152x152.png" />
<link rel="apple-touch-icon" sizes="192x192" href="/icons/icon-192x192.png" />
<link rel="apple-touch-icon" sizes="384x384" href="/icons/icon-384x384.png" />
<link rel="apple-touch-icon" sizes="512x512" href="/icons/icon-512x512.png" />
<link href="/lib/bootstrap/css/bootstrap.min.css" rel="stylesheet">
<link href="/lib/fontawesome/css/all.min.css" rel="stylesheet">
<link href="/lib/bootstrap-icons/bootstrap-icons.css" rel="stylesheet">
<link href="/css/modern-admin.css" rel="stylesheet">
</head>
<body>
<header>
<nav class="navbar navbar-expand-sm navbar-light bg-white">
<div class="container-fluid">
<a class="navbar-brand" href="@Url.Action("Index", "Dashboard", new { area = "Admin" })">
<i class="fas fa-store"></i> LittleShop Admin
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target=".navbar-collapse">
<span class="navbar-toggler-icon"></span>
</button>
<div class="navbar-collapse collapse d-sm-inline-flex justify-content-between">
<ul class="navbar-nav flex-grow-1">
<li class="nav-item">
<a class="nav-link" href="@Url.Action("Index", "Dashboard", new { area = "Admin" })">
<i class="fas fa-tachometer-alt"></i> Dashboard
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="@Url.Action("Index", "Categories", new { area = "Admin" })">
<i class="fas fa-tags"></i> Categories
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="@Url.Action("Index", "Products", new { area = "Admin" })">
<i class="fas fa-box"></i> Products
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="@Url.Action("Index", "Orders", new { area = "Admin" })">
<i class="fas fa-shopping-cart"></i> Orders
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="@Url.Action("Index", "Messages", new { area = "Admin" })">
<i class="fas fa-comments"></i> Messages
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="@Url.Action("Index", "ShippingRates", new { area = "Admin" })">
<i class="fas fa-truck"></i> Shipping
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="@Url.Action("Index", "Users", new { area = "Admin" })">
<i class="fas fa-users"></i> Users
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="@Url.Action("Index", "Bots", new { area = "Admin" })">
<i class="fas fa-robot"></i> Bots
</a>
</li>
</ul>
<ul class="navbar-nav">
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
<i class="fas fa-user"></i> @User.Identity?.Name
</a>
<ul class="dropdown-menu">
<li>
<form method="post" action="@Url.Action("Logout", "Account", new { area = "Admin" })">
<button type="submit" class="dropdown-item">
<i class="fas fa-sign-out-alt"></i> Logout
</button>
</form>
</li>
</ul>
</li>
</ul>
</div>
</div>
</nav>
</header>
<div class="container-fluid">
<main role="main" class="pb-3">
@RenderBody()
</main>
</div>
<script src="/lib/jquery/jquery.min.js"></script>
<script src="/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
<script src="/js/pwa.js"></script>
<script src="/js/modern-mobile.js"></script>
@await RenderSectionAsync("Scripts", required: false)
</body>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no, viewport-fit=cover" />
<title>@ViewData["Title"] - LittleShop Admin</title>
<!-- PWA Meta Tags -->
<meta name="application-name" content="LittleShop Admin" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="LittleShop" />
<meta name="description" content="Modern e-commerce admin panel" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="theme-color" content="#2563eb" />
<meta name="msapplication-TileColor" content="#2563eb" />
<meta name="msapplication-tap-highlight" content="no" />
<!-- PWA Manifest -->
<link rel="manifest" href="/manifest.json" />
<!-- Icons -->
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="apple-touch-icon" href="/icons/icon-152x152.png" />
<link rel="apple-touch-icon" sizes="72x72" href="/icons/icon-72x72.png" />
<link rel="apple-touch-icon" sizes="96x96" href="/icons/icon-96x96.png" />
<link rel="apple-touch-icon" sizes="128x128" href="/icons/icon-128x128.png" />
<link rel="apple-touch-icon" sizes="144x144" href="/icons/icon-144x144.png" />
<link rel="apple-touch-icon" sizes="152x152" href="/icons/icon-152x152.png" />
<link rel="apple-touch-icon" sizes="192x192" href="/icons/icon-192x192.png" />
<link rel="apple-touch-icon" sizes="384x384" href="/icons/icon-384x384.png" />
<link rel="apple-touch-icon" sizes="512x512" href="/icons/icon-512x512.png" />
<link href="/lib/bootstrap/css/bootstrap.min.css" rel="stylesheet">
<link href="/lib/fontawesome/css/all.min.css" rel="stylesheet">
<link href="/lib/bootstrap-icons/bootstrap-icons.css" rel="stylesheet">
<link href="/css/modern-admin.css" rel="stylesheet">
</head>
<body>
<header>
<nav class="navbar navbar-expand-sm navbar-light bg-white">
<div class="container-fluid">
<a class="navbar-brand" href="@Url.Action("Index", "Dashboard", new { area = "Admin" })">
<i class="fas fa-store"></i> LittleShop Admin
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target=".navbar-collapse">
<span class="navbar-toggler-icon"></span>
</button>
<div class="navbar-collapse collapse d-sm-inline-flex justify-content-between">
<ul class="navbar-nav flex-grow-1">
<li class="nav-item">
<a class="nav-link" href="@Url.Action("Index", "Dashboard", new { area = "Admin" })">
<i class="fas fa-tachometer-alt"></i> Dashboard
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="@Url.Action("Index", "Categories", new { area = "Admin" })">
<i class="fas fa-tags"></i> Categories
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="@Url.Action("Index", "Products", new { area = "Admin" })">
<i class="fas fa-box"></i> Products
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="@Url.Action("Index", "Orders", new { area = "Admin" })">
<i class="fas fa-shopping-cart"></i> Orders
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="@Url.Action("Index", "Reviews", new { area = "Admin" })">
<i class="fas fa-star"></i> Reviews
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="@Url.Action("Index", "Messages", new { area = "Admin" })">
<i class="fas fa-comments"></i> Messages
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="@Url.Action("Index", "ShippingRates", new { area = "Admin" })">
<i class="fas fa-truck"></i> Shipping
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="@Url.Action("Index", "Users", new { area = "Admin" })">
<i class="fas fa-users"></i> Users
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="@Url.Action("Index", "Bots", new { area = "Admin" })">
<i class="fas fa-robot"></i> Bots
</a>
</li>
</ul>
<ul class="navbar-nav">
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
<i class="fas fa-user"></i> @User.Identity?.Name
</a>
<ul class="dropdown-menu">
<li>
<form method="post" action="@Url.Action("Logout", "Account", new { area = "Admin" })">
<button type="submit" class="dropdown-item">
<i class="fas fa-sign-out-alt"></i> Logout
</button>
</form>
</li>
</ul>
</li>
</ul>
</div>
</div>
</nav>
</header>
<div class="container-fluid">
<main role="main" class="pb-3">
@RenderBody()
</main>
</div>
<script src="/lib/jquery/jquery.min.js"></script>
<script src="/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
<script src="/js/pwa.js"></script>
<script src="/js/modern-mobile.js"></script>
@await RenderSectionAsync("Scripts", required: false)
</body>
</html>

View File

@@ -0,0 +1,236 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using LittleShop.Services;
using LittleShop.DTOs;
using System.Security.Claims;
namespace LittleShop.Controllers;
[ApiController]
[Route("api/[controller]")]
public class ReviewsController : ControllerBase
{
private readonly IReviewService _reviewService;
private readonly ILogger<ReviewsController> _logger;
public ReviewsController(IReviewService reviewService, ILogger<ReviewsController> logger)
{
_reviewService = reviewService;
_logger = logger;
}
/// <summary>
/// Get reviews for a specific product (public - approved reviews only)
/// </summary>
[HttpGet("product/{productId}")]
public async Task<ActionResult<IEnumerable<ReviewDto>>> GetProductReviews(Guid productId)
{
try
{
var reviews = await _reviewService.GetReviewsByProductAsync(productId, approvedOnly: true);
return Ok(reviews);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting reviews for product {ProductId}", productId);
return StatusCode(500, new { Error = "Error retrieving product reviews" });
}
}
/// <summary>
/// Get review summary statistics for a product (public)
/// </summary>
[HttpGet("product/{productId}/summary")]
public async Task<ActionResult<ReviewSummaryDto>> GetProductReviewSummary(Guid productId)
{
try
{
var summary = await _reviewService.GetProductReviewSummaryAsync(productId);
if (summary == null)
{
return NotFound(new { Error = "Product not found" });
}
return Ok(summary);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting review summary for product {ProductId}", productId);
return StatusCode(500, new { Error = "Error retrieving review summary" });
}
}
/// <summary>
/// Check if customer can review a product (public)
/// </summary>
[HttpGet("eligibility/customer/{customerId}/product/{productId}")]
public async Task<ActionResult<CustomerReviewEligibilityDto>> CheckReviewEligibility(Guid customerId, Guid productId)
{
try
{
var eligibility = await _reviewService.CheckReviewEligibilityAsync(customerId, productId);
return Ok(eligibility);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error checking review eligibility for customer {CustomerId} and product {ProductId}",
customerId, productId);
return StatusCode(500, new { Error = "Error checking review eligibility" });
}
}
/// <summary>
/// Create a new review (public - for customers via TeleBot)
/// </summary>
[HttpPost]
public async Task<ActionResult<ReviewDto>> CreateReview([FromBody] CreateReviewDto createReviewDto)
{
try
{
var review = await _reviewService.CreateReviewAsync(createReviewDto);
return CreatedAtAction(nameof(GetReview), new { id = review.Id }, review);
}
catch (InvalidOperationException ex)
{
return BadRequest(new { Error = ex.Message });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error creating review for product {ProductId}", createReviewDto.ProductId);
return StatusCode(500, new { Error = "Error creating review" });
}
}
/// <summary>
/// Get specific review by ID
/// </summary>
[HttpGet("{id}")]
public async Task<ActionResult<ReviewDto>> GetReview(Guid id)
{
try
{
var review = await _reviewService.GetReviewByIdAsync(id);
if (review == null)
{
return NotFound(new { Error = "Review not found" });
}
return Ok(review);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting review {ReviewId}", id);
return StatusCode(500, new { Error = "Error retrieving review" });
}
}
/// <summary>
/// Get customer's reviews (public - for TeleBot)
/// </summary>
[HttpGet("customer/{customerId}")]
public async Task<ActionResult<IEnumerable<ReviewDto>>> GetCustomerReviews(Guid customerId)
{
try
{
var reviews = await _reviewService.GetReviewsByCustomerAsync(customerId);
return Ok(reviews);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting reviews for customer {CustomerId}", customerId);
return StatusCode(500, new { Error = "Error retrieving customer reviews" });
}
}
/// <summary>
/// Get pending reviews for admin approval
/// </summary>
[HttpGet("pending")]
[Authorize(AuthenticationSchemes = "Bearer", Roles = "Admin")]
public async Task<ActionResult<IEnumerable<ReviewDto>>> GetPendingReviews()
{
try
{
var reviews = await _reviewService.GetPendingReviewsAsync();
return Ok(reviews);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting pending reviews");
return StatusCode(500, new { Error = "Error retrieving pending reviews" });
}
}
/// <summary>
/// Update a review (admin only)
/// </summary>
[HttpPut("{id}")]
[Authorize(AuthenticationSchemes = "Bearer", Roles = "Admin")]
public async Task<IActionResult> UpdateReview(Guid id, [FromBody] UpdateReviewDto updateReviewDto)
{
try
{
var success = await _reviewService.UpdateReviewAsync(id, updateReviewDto);
if (!success)
{
return NotFound(new { Error = "Review not found" });
}
return NoContent();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error updating review {ReviewId}", id);
return StatusCode(500, new { Error = "Error updating review" });
}
}
/// <summary>
/// Approve a review (admin only)
/// </summary>
[HttpPost("{id}/approve")]
[Authorize(AuthenticationSchemes = "Bearer", Roles = "Admin")]
public async Task<IActionResult> ApproveReview(Guid id)
{
try
{
var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (!Guid.TryParse(userIdClaim, out var userId))
{
return BadRequest(new { Error = "Invalid user ID" });
}
var success = await _reviewService.ApproveReviewAsync(id, userId);
if (!success)
{
return NotFound(new { Error = "Review not found" });
}
return Ok(new { Message = "Review approved successfully" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error approving review {ReviewId}", id);
return StatusCode(500, new { Error = "Error approving review" });
}
}
/// <summary>
/// Delete a review (admin only - soft delete)
/// </summary>
[HttpDelete("{id}")]
[Authorize(AuthenticationSchemes = "Bearer", Roles = "Admin")]
public async Task<IActionResult> DeleteReview(Guid id)
{
try
{
var success = await _reviewService.DeleteReviewAsync(id);
if (!success)
{
return NotFound(new { Error = "Review not found" });
}
return Ok(new { Message = "Review deleted successfully" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error deleting review {ReviewId}", id);
return StatusCode(500, new { Error = "Error deleting review" });
}
}
}

View File

@@ -1,141 +1,141 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using LittleShop.Services;
using LittleShop.DTOs;
using LittleShop.Data;
namespace LittleShop.Controllers;
[ApiController]
[Route("api/[controller]")]
public class TestController : ControllerBase
{
private readonly ICategoryService _categoryService;
private readonly IProductService _productService;
private readonly LittleShopContext _context;
public TestController(ICategoryService categoryService, IProductService productService, LittleShopContext context)
{
_categoryService = categoryService;
_productService = productService;
_context = context;
}
[HttpPost("create-product")]
public async Task<IActionResult> CreateTestProduct()
{
try
{
// Get the first category
var categories = await _categoryService.GetAllCategoriesAsync();
var firstCategory = categories.FirstOrDefault();
if (firstCategory == null)
{
return BadRequest("No categories found. Create a category first.");
}
var product = await _productService.CreateProductAsync(new CreateProductDto
{
Name = "Test Product via API",
Description = "This product was created via the test API endpoint 🚀",
Price = 49.99m,
Weight = 0.5m,
WeightUnit = LittleShop.Enums.ProductWeightUnit.Pounds,
CategoryId = firstCategory.Id
});
return Ok(new {
message = "Product created successfully",
product = product
});
}
catch (Exception ex)
{
return BadRequest(new { error = ex.Message });
}
}
[HttpPost("setup-test-data")]
public async Task<IActionResult> SetupTestData()
{
try
{
// Create test category
var category = await _categoryService.CreateCategoryAsync(new CreateCategoryDto
{
Name = "Electronics",
Description = "Electronic devices and gadgets"
});
// Create test product
var product = await _productService.CreateProductAsync(new CreateProductDto
{
Name = "Sample Product",
Description = "This is a test product with emoji support 📱💻",
Price = 99.99m,
Weight = 1.5m,
WeightUnit = LittleShop.Enums.ProductWeightUnit.Pounds,
CategoryId = category.Id
});
return Ok(new {
message = "Test data created successfully",
category = category,
product = product
});
}
catch (Exception ex)
{
return BadRequest(new { error = ex.Message });
}
}
[HttpPost("cleanup-bots")]
public async Task<IActionResult> CleanupBots()
{
try
{
// Get count before cleanup
var totalBots = await _context.Bots.CountAsync();
// Keep only the most recent active bot per platform
var keepBots = await _context.Bots
.Where(b => b.IsActive && b.Status == Enums.BotStatus.Active)
.GroupBy(b => b.PlatformId)
.Select(g => g.OrderByDescending(b => b.LastSeenAt ?? b.CreatedAt).First())
.ToListAsync();
var keepBotIds = keepBots.Select(b => b.Id).ToList();
// Delete old/inactive bots and related data
var botsToDelete = await _context.Bots
.Where(b => !keepBotIds.Contains(b.Id))
.ToListAsync();
_context.Bots.RemoveRange(botsToDelete);
await _context.SaveChangesAsync();
var deletedCount = botsToDelete.Count;
var remainingCount = keepBots.Count;
return Ok(new {
message = "Bot cleanup completed",
totalBots = totalBots,
deletedBots = deletedCount,
remainingBots = remainingCount,
keptBots = keepBots.Select(b => new {
id = b.Id,
name = b.Name,
platformUsername = b.PlatformUsername,
lastSeen = b.LastSeenAt,
created = b.CreatedAt
})
});
}
catch (Exception ex)
{
return BadRequest(new { error = ex.Message });
}
}
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using LittleShop.Services;
using LittleShop.DTOs;
using LittleShop.Data;
namespace LittleShop.Controllers;
[ApiController]
[Route("api/[controller]")]
public class TestController : ControllerBase
{
private readonly ICategoryService _categoryService;
private readonly IProductService _productService;
private readonly LittleShopContext _context;
public TestController(ICategoryService categoryService, IProductService productService, LittleShopContext context)
{
_categoryService = categoryService;
_productService = productService;
_context = context;
}
[HttpPost("create-product")]
public async Task<IActionResult> CreateTestProduct()
{
try
{
// Get the first category
var categories = await _categoryService.GetAllCategoriesAsync();
var firstCategory = categories.FirstOrDefault();
if (firstCategory == null)
{
return BadRequest("No categories found. Create a category first.");
}
var product = await _productService.CreateProductAsync(new CreateProductDto
{
Name = "Test Product via API",
Description = "This product was created via the test API endpoint 🚀",
Price = 49.99m,
Weight = 0.5m,
WeightUnit = LittleShop.Enums.ProductWeightUnit.Pounds,
CategoryId = firstCategory.Id
});
return Ok(new {
message = "Product created successfully",
product = product
});
}
catch (Exception ex)
{
return BadRequest(new { error = ex.Message });
}
}
[HttpPost("setup-test-data")]
public async Task<IActionResult> SetupTestData()
{
try
{
// Create test category
var category = await _categoryService.CreateCategoryAsync(new CreateCategoryDto
{
Name = "Electronics",
Description = "Electronic devices and gadgets"
});
// Create test product
var product = await _productService.CreateProductAsync(new CreateProductDto
{
Name = "Sample Product",
Description = "This is a test product with emoji support 📱💻",
Price = 99.99m,
Weight = 1.5m,
WeightUnit = LittleShop.Enums.ProductWeightUnit.Pounds,
CategoryId = category.Id
});
return Ok(new {
message = "Test data created successfully",
category = category,
product = product
});
}
catch (Exception ex)
{
return BadRequest(new { error = ex.Message });
}
}
[HttpPost("cleanup-bots")]
public async Task<IActionResult> CleanupBots()
{
try
{
// Get count before cleanup
var totalBots = await _context.Bots.CountAsync();
// Keep only the most recent active bot per platform
var keepBots = await _context.Bots
.Where(b => b.IsActive && b.Status == Enums.BotStatus.Active)
.GroupBy(b => b.PlatformId)
.Select(g => g.OrderByDescending(b => b.LastSeenAt ?? b.CreatedAt).First())
.ToListAsync();
var keepBotIds = keepBots.Select(b => b.Id).ToList();
// Delete old/inactive bots and related data
var botsToDelete = await _context.Bots
.Where(b => !keepBotIds.Contains(b.Id))
.ToListAsync();
_context.Bots.RemoveRange(botsToDelete);
await _context.SaveChangesAsync();
var deletedCount = botsToDelete.Count;
var remainingCount = keepBots.Count;
return Ok(new {
message = "Bot cleanup completed",
totalBots = totalBots,
deletedBots = deletedCount,
remainingBots = remainingCount,
keptBots = keepBots.Select(b => new {
id = b.Id,
name = b.Name,
platformUsername = b.PlatformUsername,
lastSeen = b.LastSeenAt,
created = b.CreatedAt
})
});
}
catch (Exception ex)
{
return BadRequest(new { error = ex.Message });
}
}
}

View File

@@ -1,30 +1,30 @@
using LittleShop.Enums;
namespace LittleShop.DTOs;
public class CryptoPaymentDto
{
public Guid Id { get; set; }
public Guid OrderId { get; set; }
public CryptoCurrency Currency { get; set; }
public string WalletAddress { get; set; } = string.Empty;
public decimal RequiredAmount { get; set; }
public decimal PaidAmount { get; set; }
public PaymentStatus Status { get; set; }
public string? BTCPayInvoiceId { get; set; }
public string? TransactionHash { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? PaidAt { get; set; }
public DateTime ExpiresAt { get; set; }
}
public class PaymentStatusDto
{
public Guid PaymentId { get; set; }
public PaymentStatus Status { get; set; }
public decimal RequiredAmount { get; set; }
public decimal PaidAmount { get; set; }
public DateTime? PaidAt { get; set; }
public DateTime ExpiresAt { get; set; }
public bool IsExpired => DateTime.UtcNow > ExpiresAt;
using LittleShop.Enums;
namespace LittleShop.DTOs;
public class CryptoPaymentDto
{
public Guid Id { get; set; }
public Guid OrderId { get; set; }
public CryptoCurrency Currency { get; set; }
public string WalletAddress { get; set; } = string.Empty;
public decimal RequiredAmount { get; set; }
public decimal PaidAmount { get; set; }
public PaymentStatus Status { get; set; }
public string? BTCPayInvoiceId { get; set; }
public string? TransactionHash { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? PaidAt { get; set; }
public DateTime ExpiresAt { get; set; }
}
public class PaymentStatusDto
{
public Guid PaymentId { get; set; }
public PaymentStatus Status { get; set; }
public decimal RequiredAmount { get; set; }
public decimal PaidAmount { get; set; }
public DateTime? PaidAt { get; set; }
public DateTime ExpiresAt { get; set; }
public bool IsExpired => DateTime.UtcNow > ExpiresAt;
}

View File

@@ -0,0 +1,86 @@
using System.ComponentModel.DataAnnotations;
namespace LittleShop.DTOs;
public class ReviewDto
{
public Guid Id { get; set; }
public Guid ProductId { get; set; }
public string ProductName { get; set; } = string.Empty;
public Guid CustomerId { get; set; }
public string CustomerDisplayName { get; set; } = string.Empty;
public Guid OrderId { get; set; }
public int Rating { get; set; }
public string? Title { get; set; }
public string? Comment { get; set; }
public bool IsVerifiedPurchase { get; set; }
public bool IsApproved { get; set; }
public bool IsActive { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
public DateTime? ApprovedAt { get; set; }
public string? ApprovedByUsername { get; set; }
}
public class CreateReviewDto
{
[Required]
public Guid ProductId { get; set; }
[Required]
public Guid CustomerId { get; set; }
[Required]
public Guid OrderId { get; set; }
[Required]
[Range(1, 5, ErrorMessage = "Rating must be between 1 and 5 stars")]
public int Rating { get; set; }
[StringLength(100, ErrorMessage = "Title cannot exceed 100 characters")]
public string? Title { get; set; }
[StringLength(2000, ErrorMessage = "Comment cannot exceed 2000 characters")]
public string? Comment { get; set; }
}
public class UpdateReviewDto
{
[Range(1, 5, ErrorMessage = "Rating must be between 1 and 5 stars")]
public int? Rating { get; set; }
[StringLength(100, ErrorMessage = "Title cannot exceed 100 characters")]
public string? Title { get; set; }
[StringLength(2000, ErrorMessage = "Comment cannot exceed 2000 characters")]
public string? Comment { get; set; }
public bool? IsApproved { get; set; }
public bool? IsActive { get; set; }
}
public class ReviewSummaryDto
{
public Guid ProductId { get; set; }
public string ProductName { get; set; } = string.Empty;
public int TotalReviews { get; set; }
public int ApprovedReviews { get; set; }
public double AverageRating { get; set; }
public int FiveStars { get; set; }
public int FourStars { get; set; }
public int ThreeStars { get; set; }
public int TwoStars { get; set; }
public int OneStar { get; set; }
public DateTime? LatestReviewDate { get; set; }
}
public class CustomerReviewEligibilityDto
{
public Guid CustomerId { get; set; }
public Guid ProductId { get; set; }
public bool CanReview { get; set; }
public string? Reason { get; set; }
public List<Guid> EligibleOrderIds { get; set; } = new();
public bool HasExistingReview { get; set; }
public Guid? ExistingReviewId { get; set; }
}

View File

@@ -23,6 +23,7 @@ public class LittleShopContext : DbContext
public DbSet<Customer> Customers { get; set; }
public DbSet<CustomerMessage> CustomerMessages { get; set; }
public DbSet<PushSubscription> PushSubscriptions { get; set; }
public DbSet<Review> Reviews { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -200,5 +201,42 @@ public class LittleShopContext : DbContext
entity.HasIndex(e => e.SubscribedAt);
entity.HasIndex(e => e.IsActive);
});
// Review entity
modelBuilder.Entity<Review>(entity =>
{
entity.HasOne(r => r.Product)
.WithMany(p => p.Reviews)
.HasForeignKey(r => r.ProductId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(r => r.Customer)
.WithMany()
.HasForeignKey(r => r.CustomerId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(r => r.Order)
.WithMany()
.HasForeignKey(r => r.OrderId)
.OnDelete(DeleteBehavior.Restrict);
entity.HasOne(r => r.ApprovedByUser)
.WithMany()
.HasForeignKey(r => r.ApprovedByUserId)
.OnDelete(DeleteBehavior.SetNull);
// Indexes for performance
entity.HasIndex(e => e.ProductId);
entity.HasIndex(e => e.CustomerId);
entity.HasIndex(e => e.OrderId);
entity.HasIndex(e => e.Rating);
entity.HasIndex(e => e.IsApproved);
entity.HasIndex(e => e.IsActive);
entity.HasIndex(e => e.CreatedAt);
// Composite indexes for common queries
entity.HasIndex(e => new { e.ProductId, e.IsApproved, e.IsActive });
entity.HasIndex(e => new { e.CustomerId, e.ProductId }).IsUnique(); // One review per customer per product
});
}
}

View File

@@ -1,30 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.0" />
<PackageReference Include="AutoMapper" Version="13.0.1" />
<PackageReference Include="FluentValidation" Version="11.11.0" />
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.3" />
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="9.0.0" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.3.0" />
<PackageReference Include="BTCPayServer.Client" Version="2.0.0" />
<PackageReference Include="NBitcoin" Version="7.0.37" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="WebPush" Version="1.0.12" />
</ItemGroup>
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.8" />
<PackageReference Include="AutoMapper" Version="13.0.1" />
<PackageReference Include="FluentValidation" Version="11.11.0" />
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.3" />
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="9.0.0" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.3.0" />
<PackageReference Include="BTCPayServer.Client" Version="2.0.0" />
<PackageReference Include="NBitcoin" Version="7.0.37" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="WebPush" Version="1.0.12" />
</ItemGroup>
</Project>

View File

@@ -1,42 +1,42 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using LittleShop.Enums;
namespace LittleShop.Models;
public class CryptoPayment
{
[Key]
public Guid Id { get; set; }
public Guid OrderId { get; set; }
public CryptoCurrency Currency { get; set; }
[Required]
[StringLength(500)]
public string WalletAddress { get; set; } = string.Empty;
[Column(TypeName = "decimal(18,8)")]
public decimal RequiredAmount { get; set; }
[Column(TypeName = "decimal(18,8)")]
public decimal PaidAmount { get; set; } = 0;
public PaymentStatus Status { get; set; } = PaymentStatus.Pending;
[StringLength(200)]
public string? BTCPayInvoiceId { get; set; }
[StringLength(200)]
public string? TransactionHash { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? PaidAt { get; set; }
public DateTime ExpiresAt { get; set; }
// Navigation properties
public virtual Order Order { get; set; } = null!;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using LittleShop.Enums;
namespace LittleShop.Models;
public class CryptoPayment
{
[Key]
public Guid Id { get; set; }
public Guid OrderId { get; set; }
public CryptoCurrency Currency { get; set; }
[Required]
[StringLength(500)]
public string WalletAddress { get; set; } = string.Empty;
[Column(TypeName = "decimal(18,8)")]
public decimal RequiredAmount { get; set; }
[Column(TypeName = "decimal(18,8)")]
public decimal PaidAmount { get; set; } = 0;
public PaymentStatus Status { get; set; } = PaymentStatus.Pending;
[StringLength(200)]
public string? BTCPayInvoiceId { get; set; }
[StringLength(200)]
public string? TransactionHash { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? PaidAt { get; set; }
public DateTime ExpiresAt { get; set; }
// Navigation properties
public virtual Order Order { get; set; } = null!;
}

View File

@@ -37,4 +37,5 @@ public class Product
public virtual Category Category { get; set; } = null!;
public virtual ICollection<ProductPhoto> Photos { get; set; } = new List<ProductPhoto>();
public virtual ICollection<OrderItem> OrderItems { get; set; } = new List<OrderItem>();
public virtual ICollection<Review> Reviews { get; set; } = new List<Review>();
}

View File

@@ -0,0 +1,45 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace LittleShop.Models;
public class Review
{
[Key]
public Guid Id { get; set; }
[Required]
public Guid ProductId { get; set; }
[Required]
public Guid CustomerId { get; set; }
[Required]
public Guid OrderId { get; set; }
[Range(1, 5)]
public int Rating { get; set; }
[StringLength(100)]
public string? Title { get; set; }
[StringLength(2000)]
public string? Comment { get; set; }
public bool IsVerifiedPurchase { get; set; } = true;
public bool IsApproved { get; set; } = false;
public bool IsActive { get; set; } = true;
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
public DateTime? ApprovedAt { get; set; }
public Guid? ApprovedByUserId { get; set; }
// Navigation properties
public virtual Product Product { get; set; } = null!;
public virtual Customer Customer { get; set; } = null!;
public virtual Order Order { get; set; } = null!;
public virtual User? ApprovedByUser { get; set; }
}

View File

@@ -1,202 +1,203 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using System.Text;
using LittleShop.Data;
using LittleShop.Services;
using FluentValidation;
using Serilog;
var builder = WebApplication.CreateBuilder(args);
// Configure Serilog
Log.Logger = new LoggerConfiguration()
.WriteTo.Console()
.WriteTo.File("logs/littleshop.txt", rollingInterval: RollingInterval.Day)
.CreateLogger();
builder.Host.UseSerilog();
// Add services to the container.
builder.Services.AddControllers();
builder.Services.AddControllersWithViews(); // Add MVC for Admin Panel
// Database
if (builder.Environment.EnvironmentName == "Testing")
{
builder.Services.AddDbContext<LittleShopContext>(options =>
options.UseInMemoryDatabase("InMemoryDbForTesting"));
}
else
{
builder.Services.AddDbContext<LittleShopContext>(options =>
options.UseSqlite(builder.Configuration.GetConnectionString("DefaultConnection")));
}
// Authentication - Cookie for Admin Panel, JWT for API
var jwtKey = builder.Configuration["Jwt:Key"] ?? "YourSuperSecretKeyThatIsAtLeast32CharactersLong!";
var jwtIssuer = builder.Configuration["Jwt:Issuer"] ?? "LittleShop";
var jwtAudience = builder.Configuration["Jwt:Audience"] ?? "LittleShop";
builder.Services.AddAuthentication("Cookies")
.AddCookie("Cookies", options =>
{
options.LoginPath = "/Admin/Account/Login";
options.LogoutPath = "/Admin/Account/Logout";
options.AccessDeniedPath = "/Admin/Account/AccessDenied";
})
.AddJwtBearer("Bearer", options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = jwtIssuer,
ValidAudience = jwtAudience,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey))
};
});
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("AdminOnly", policy =>
policy.RequireAuthenticatedUser()
.RequireRole("Admin"));
options.AddPolicy("ApiAccess", policy => policy.RequireAuthenticatedUser());
});
// Services
builder.Services.AddScoped<IAuthService, AuthService>();
builder.Services.AddScoped<ICategoryService, CategoryService>();
builder.Services.AddScoped<IProductService, ProductService>();
builder.Services.AddScoped<IOrderService, OrderService>();
builder.Services.AddScoped<ICryptoPaymentService, CryptoPaymentService>();
builder.Services.AddScoped<IBTCPayServerService, BTCPayServerService>();
builder.Services.AddScoped<IShippingRateService, ShippingRateService>();
builder.Services.AddScoped<IRoyalMailService, RoyalMailShippingService>();
builder.Services.AddHttpClient<IRoyalMailService, RoyalMailShippingService>();
builder.Services.AddScoped<IDataSeederService, DataSeederService>();
builder.Services.AddScoped<IBotService, BotService>();
builder.Services.AddScoped<IBotMetricsService, BotMetricsService>();
builder.Services.AddScoped<ICustomerService, CustomerService>();
builder.Services.AddScoped<ICustomerMessageService, CustomerMessageService>();
builder.Services.AddScoped<IPushNotificationService, PushNotificationService>();
builder.Services.AddSingleton<ITelegramBotManagerService, TelegramBotManagerService>();
// Temporarily disabled to use standalone TeleBot with customer orders fix
// builder.Services.AddHostedService<TelegramBotManagerService>();
// AutoMapper
builder.Services.AddAutoMapper(typeof(Program));
// FluentValidation
builder.Services.AddValidatorsFromAssemblyContaining<Program>();
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1", new Microsoft.OpenApi.Models.OpenApiInfo
{
Title = "LittleShop API",
Version = "v1",
Description = "A basic online sales system backend with multi-cryptocurrency payment support",
Contact = new Microsoft.OpenApi.Models.OpenApiContact
{
Name = "LittleShop Support"
}
});
// Add JWT authentication to Swagger
c.AddSecurityDefinition("Bearer", new Microsoft.OpenApi.Models.OpenApiSecurityScheme
{
Description = "JWT Authorization header using the Bearer scheme. Enter 'Bearer' [space] and then your token in the text input below.",
Name = "Authorization",
In = Microsoft.OpenApi.Models.ParameterLocation.Header,
Type = Microsoft.OpenApi.Models.SecuritySchemeType.ApiKey,
Scheme = "Bearer"
});
c.AddSecurityRequirement(new Microsoft.OpenApi.Models.OpenApiSecurityRequirement
{
{
new Microsoft.OpenApi.Models.OpenApiSecurityScheme
{
Reference = new Microsoft.OpenApi.Models.OpenApiReference
{
Type = Microsoft.OpenApi.Models.ReferenceType.SecurityScheme,
Id = "Bearer"
}
},
Array.Empty<string>()
}
});
});
// CORS
builder.Services.AddCors(options =>
{
options.AddPolicy("AllowAll",
builder =>
{
builder.AllowAnyOrigin()
.AllowAnyMethod()
.AllowAnyHeader();
});
});
var app = builder.Build();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseCors("AllowAll");
app.UseStaticFiles(); // Enable serving static files
app.UseAuthentication();
app.UseAuthorization();
// Configure routing
app.MapControllerRoute(
name: "admin",
pattern: "Admin/{controller=Dashboard}/{action=Index}/{id?}",
defaults: new { area = "Admin" }
);
app.MapControllerRoute(
name: "areas",
pattern: "{area:exists}/{controller=Home}/{action=Index}/{id?}");
app.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
app.MapControllers(); // API routes
// Apply database migrations and seed data
using (var scope = app.Services.CreateScope())
{
var context = scope.ServiceProvider.GetRequiredService<LittleShopContext>();
// Ensure database is created (temporary while fixing migrations)
context.Database.EnsureCreated();
// Seed default admin user
var authService = scope.ServiceProvider.GetRequiredService<IAuthService>();
await authService.SeedDefaultUserAsync();
// Seed sample data
var dataSeeder = scope.ServiceProvider.GetRequiredService<IDataSeederService>();
await dataSeeder.SeedSampleDataAsync();
}
Log.Information("LittleShop API starting up...");
app.Run();
// Make Program accessible to test project
using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using System.Text;
using LittleShop.Data;
using LittleShop.Services;
using FluentValidation;
using Serilog;
var builder = WebApplication.CreateBuilder(args);
// Configure Serilog
Log.Logger = new LoggerConfiguration()
.WriteTo.Console()
.WriteTo.File("logs/littleshop.txt", rollingInterval: RollingInterval.Day)
.CreateLogger();
builder.Host.UseSerilog();
// Add services to the container.
builder.Services.AddControllers();
builder.Services.AddControllersWithViews(); // Add MVC for Admin Panel
// Database
if (builder.Environment.EnvironmentName == "Testing")
{
builder.Services.AddDbContext<LittleShopContext>(options =>
options.UseInMemoryDatabase("InMemoryDbForTesting"));
}
else
{
builder.Services.AddDbContext<LittleShopContext>(options =>
options.UseSqlite(builder.Configuration.GetConnectionString("DefaultConnection")));
}
// Authentication - Cookie for Admin Panel, JWT for API
var jwtKey = builder.Configuration["Jwt:Key"] ?? "YourSuperSecretKeyThatIsAtLeast32CharactersLong!";
var jwtIssuer = builder.Configuration["Jwt:Issuer"] ?? "LittleShop";
var jwtAudience = builder.Configuration["Jwt:Audience"] ?? "LittleShop";
builder.Services.AddAuthentication("Cookies")
.AddCookie("Cookies", options =>
{
options.LoginPath = "/Admin/Account/Login";
options.LogoutPath = "/Admin/Account/Logout";
options.AccessDeniedPath = "/Admin/Account/AccessDenied";
})
.AddJwtBearer("Bearer", options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = jwtIssuer,
ValidAudience = jwtAudience,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey))
};
});
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("AdminOnly", policy =>
policy.RequireAuthenticatedUser()
.RequireRole("Admin"));
options.AddPolicy("ApiAccess", policy => policy.RequireAuthenticatedUser());
});
// Services
builder.Services.AddScoped<IAuthService, AuthService>();
builder.Services.AddScoped<ICategoryService, CategoryService>();
builder.Services.AddScoped<IProductService, ProductService>();
builder.Services.AddScoped<IOrderService, OrderService>();
builder.Services.AddScoped<ICryptoPaymentService, CryptoPaymentService>();
builder.Services.AddScoped<IBTCPayServerService, BTCPayServerService>();
builder.Services.AddScoped<IShippingRateService, ShippingRateService>();
builder.Services.AddScoped<IRoyalMailService, RoyalMailShippingService>();
builder.Services.AddHttpClient<IRoyalMailService, RoyalMailShippingService>();
builder.Services.AddScoped<IReviewService, ReviewService>();
builder.Services.AddScoped<IDataSeederService, DataSeederService>();
builder.Services.AddScoped<IBotService, BotService>();
builder.Services.AddScoped<IBotMetricsService, BotMetricsService>();
builder.Services.AddScoped<ICustomerService, CustomerService>();
builder.Services.AddScoped<ICustomerMessageService, CustomerMessageService>();
builder.Services.AddScoped<IPushNotificationService, PushNotificationService>();
builder.Services.AddSingleton<ITelegramBotManagerService, TelegramBotManagerService>();
// Temporarily disabled to use standalone TeleBot with customer orders fix
// builder.Services.AddHostedService<TelegramBotManagerService>();
// AutoMapper
builder.Services.AddAutoMapper(typeof(Program));
// FluentValidation
builder.Services.AddValidatorsFromAssemblyContaining<Program>();
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1", new Microsoft.OpenApi.Models.OpenApiInfo
{
Title = "LittleShop API",
Version = "v1",
Description = "A basic online sales system backend with multi-cryptocurrency payment support",
Contact = new Microsoft.OpenApi.Models.OpenApiContact
{
Name = "LittleShop Support"
}
});
// Add JWT authentication to Swagger
c.AddSecurityDefinition("Bearer", new Microsoft.OpenApi.Models.OpenApiSecurityScheme
{
Description = "JWT Authorization header using the Bearer scheme. Enter 'Bearer' [space] and then your token in the text input below.",
Name = "Authorization",
In = Microsoft.OpenApi.Models.ParameterLocation.Header,
Type = Microsoft.OpenApi.Models.SecuritySchemeType.ApiKey,
Scheme = "Bearer"
});
c.AddSecurityRequirement(new Microsoft.OpenApi.Models.OpenApiSecurityRequirement
{
{
new Microsoft.OpenApi.Models.OpenApiSecurityScheme
{
Reference = new Microsoft.OpenApi.Models.OpenApiReference
{
Type = Microsoft.OpenApi.Models.ReferenceType.SecurityScheme,
Id = "Bearer"
}
},
Array.Empty<string>()
}
});
});
// CORS
builder.Services.AddCors(options =>
{
options.AddPolicy("AllowAll",
builder =>
{
builder.AllowAnyOrigin()
.AllowAnyMethod()
.AllowAnyHeader();
});
});
var app = builder.Build();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseCors("AllowAll");
app.UseStaticFiles(); // Enable serving static files
app.UseAuthentication();
app.UseAuthorization();
// Configure routing
app.MapControllerRoute(
name: "admin",
pattern: "Admin/{controller=Dashboard}/{action=Index}/{id?}",
defaults: new { area = "Admin" }
);
app.MapControllerRoute(
name: "areas",
pattern: "{area:exists}/{controller=Home}/{action=Index}/{id?}");
app.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
app.MapControllers(); // API routes
// Apply database migrations and seed data
using (var scope = app.Services.CreateScope())
{
var context = scope.ServiceProvider.GetRequiredService<LittleShopContext>();
// Ensure database is created (temporary while fixing migrations)
context.Database.EnsureCreated();
// Seed default admin user
var authService = scope.ServiceProvider.GetRequiredService<IAuthService>();
await authService.SeedDefaultUserAsync();
// Seed sample data
var dataSeeder = scope.ServiceProvider.GetRequiredService<IDataSeederService>();
await dataSeeder.SeedSampleDataAsync();
}
Log.Information("LittleShop API starting up...");
app.Run();
// Make Program accessible to test project
public partial class Program { }

View File

@@ -1,147 +1,158 @@
using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using LittleShop.Enums;
using Newtonsoft.Json.Linq;
namespace LittleShop.Services;
public interface IBTCPayServerService
{
Task<string> CreateInvoiceAsync(decimal amount, CryptoCurrency currency, string orderId, string? description = null);
Task<InvoiceData?> GetInvoiceAsync(string invoiceId);
Task<bool> ValidateWebhookAsync(string payload, string signature);
}
public class BTCPayServerService : IBTCPayServerService
{
private readonly BTCPayServerClient _client;
private readonly IConfiguration _configuration;
private readonly string _storeId;
private readonly string _webhookSecret;
public BTCPayServerService(IConfiguration configuration)
{
_configuration = configuration;
var baseUrl = _configuration["BTCPayServer:BaseUrl"] ?? throw new ArgumentException("BTCPayServer:BaseUrl not configured");
var apiKey = _configuration["BTCPayServer:ApiKey"] ?? throw new ArgumentException("BTCPayServer:ApiKey not configured");
_storeId = _configuration["BTCPayServer:StoreId"] ?? throw new ArgumentException("BTCPayServer:StoreId not configured");
_webhookSecret = _configuration["BTCPayServer:WebhookSecret"] ?? throw new ArgumentException("BTCPayServer:WebhookSecret not configured");
// Create HttpClient with certificate bypass for internal networks
var httpClient = new HttpClient(new HttpClientHandler()
{
ServerCertificateCustomValidationCallback = (message, cert, chain, errors) => true
});
_client = new BTCPayServerClient(new Uri(baseUrl), apiKey, httpClient);
}
public async Task<string> CreateInvoiceAsync(decimal amount, CryptoCurrency currency, string orderId, string? description = null)
{
var currencyCode = GetCurrencyCode(currency);
var metadata = new JObject
{
["orderId"] = orderId,
["currency"] = currencyCode
};
if (!string.IsNullOrEmpty(description))
{
metadata["itemDesc"] = description;
}
var request = new CreateInvoiceRequest
{
Amount = amount,
Currency = currencyCode,
Metadata = metadata,
Checkout = new CreateInvoiceRequest.CheckoutOptions
{
Expiration = TimeSpan.FromHours(24)
}
};
try
{
var invoice = await _client.CreateInvoice(_storeId, request);
return invoice.Id;
}
catch (Exception)
{
// Return a placeholder invoice ID for now
return $"invoice_{Guid.NewGuid()}";
}
}
public async Task<InvoiceData?> GetInvoiceAsync(string invoiceId)
{
try
{
return await _client.GetInvoice(_storeId, invoiceId);
}
catch
{
return null;
}
}
public Task<bool> ValidateWebhookAsync(string payload, string signature)
{
try
{
// BTCPay Server uses HMAC-SHA256 with format "sha256=<hex>"
if (!signature.StartsWith("sha256="))
{
return Task.FromResult(false);
}
var expectedHash = signature.Substring(7); // Remove "sha256=" prefix
var secretBytes = System.Text.Encoding.UTF8.GetBytes(_webhookSecret);
var payloadBytes = System.Text.Encoding.UTF8.GetBytes(payload);
using var hmac = new System.Security.Cryptography.HMACSHA256(secretBytes);
var computedHash = hmac.ComputeHash(payloadBytes);
var computedHashHex = Convert.ToHexString(computedHash).ToLowerInvariant();
return Task.FromResult(expectedHash.Equals(computedHashHex, StringComparison.OrdinalIgnoreCase));
}
catch
{
return Task.FromResult(false);
}
}
private static string GetCurrencyCode(CryptoCurrency currency)
{
return currency switch
{
CryptoCurrency.BTC => "BTC",
CryptoCurrency.XMR => "XMR",
CryptoCurrency.USDT => "USDT",
CryptoCurrency.LTC => "LTC",
CryptoCurrency.ETH => "ETH",
CryptoCurrency.ZEC => "ZEC",
CryptoCurrency.DASH => "DASH",
CryptoCurrency.DOGE => "DOGE",
_ => "BTC"
};
}
private static string GetPaymentMethod(CryptoCurrency currency)
{
return currency switch
{
CryptoCurrency.BTC => "BTC",
CryptoCurrency.XMR => "XMR",
CryptoCurrency.USDT => "USDT_ETH", // USDT on Ethereum
CryptoCurrency.LTC => "LTC",
CryptoCurrency.ETH => "ETH",
CryptoCurrency.ZEC => "ZEC",
CryptoCurrency.DASH => "DASH",
CryptoCurrency.DOGE => "DOGE",
_ => "BTC"
};
}
using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using LittleShop.Enums;
using Newtonsoft.Json.Linq;
namespace LittleShop.Services;
public interface IBTCPayServerService
{
Task<string> CreateInvoiceAsync(decimal amount, CryptoCurrency currency, string orderId, string? description = null);
Task<InvoiceData?> GetInvoiceAsync(string invoiceId);
Task<bool> ValidateWebhookAsync(string payload, string signature);
}
public class BTCPayServerService : IBTCPayServerService
{
private readonly BTCPayServerClient _client;
private readonly IConfiguration _configuration;
private readonly string _storeId;
private readonly string _webhookSecret;
public BTCPayServerService(IConfiguration configuration)
{
_configuration = configuration;
var baseUrl = _configuration["BTCPayServer:BaseUrl"] ?? throw new ArgumentException("BTCPayServer:BaseUrl not configured");
var apiKey = _configuration["BTCPayServer:ApiKey"] ?? throw new ArgumentException("BTCPayServer:ApiKey not configured");
_storeId = _configuration["BTCPayServer:StoreId"] ?? throw new ArgumentException("BTCPayServer:StoreId not configured");
_webhookSecret = _configuration["BTCPayServer:WebhookSecret"] ?? throw new ArgumentException("BTCPayServer:WebhookSecret not configured");
// Create HttpClient with certificate bypass for internal networks
var httpClient = new HttpClient(new HttpClientHandler()
{
ServerCertificateCustomValidationCallback = (message, cert, chain, errors) => true
});
_client = new BTCPayServerClient(new Uri(baseUrl), apiKey, httpClient);
}
public async Task<string> CreateInvoiceAsync(decimal amount, CryptoCurrency currency, string orderId, string? description = null)
{
var currencyCode = GetCurrencyCode(currency);
var metadata = new JObject
{
["orderId"] = orderId,
["currency"] = currencyCode
};
if (!string.IsNullOrEmpty(description))
{
metadata["itemDesc"] = description;
}
var request = new CreateInvoiceRequest
{
Amount = amount,
Currency = currencyCode,
Metadata = metadata,
Checkout = new CreateInvoiceRequest.CheckoutOptions
{
Expiration = TimeSpan.FromHours(24)
}
};
try
{
var invoice = await _client.CreateInvoice(_storeId, request);
return invoice.Id;
}
catch (Exception ex)
{
// Log the specific error for debugging
Console.WriteLine($"BTCPay Server error for {currencyCode}: {ex.Message}");
// Try to continue with real API call for all cryptocurrencies with configured wallets
if (currency == CryptoCurrency.BTC || currency == CryptoCurrency.LTC || currency == CryptoCurrency.DASH || currency == CryptoCurrency.XMR)
{
throw; // Let the calling service handle errors for supported currencies
}
// For XMR and ETH, we have nodes but BTCPay Server might not be configured yet
// Log the error and fall back to placeholder for now
Console.WriteLine($"Falling back to placeholder for {currencyCode} - BTCPay Server integration pending");
return $"invoice_{Guid.NewGuid()}";
}
}
public async Task<InvoiceData?> GetInvoiceAsync(string invoiceId)
{
try
{
return await _client.GetInvoice(_storeId, invoiceId);
}
catch
{
return null;
}
}
public Task<bool> ValidateWebhookAsync(string payload, string signature)
{
try
{
// BTCPay Server uses HMAC-SHA256 with format "sha256=<hex>"
if (!signature.StartsWith("sha256="))
{
return Task.FromResult(false);
}
var expectedHash = signature.Substring(7); // Remove "sha256=" prefix
var secretBytes = System.Text.Encoding.UTF8.GetBytes(_webhookSecret);
var payloadBytes = System.Text.Encoding.UTF8.GetBytes(payload);
using var hmac = new System.Security.Cryptography.HMACSHA256(secretBytes);
var computedHash = hmac.ComputeHash(payloadBytes);
var computedHashHex = Convert.ToHexString(computedHash).ToLowerInvariant();
return Task.FromResult(expectedHash.Equals(computedHashHex, StringComparison.OrdinalIgnoreCase));
}
catch
{
return Task.FromResult(false);
}
}
private static string GetCurrencyCode(CryptoCurrency currency)
{
return currency switch
{
CryptoCurrency.BTC => "BTC",
CryptoCurrency.XMR => "XMR",
CryptoCurrency.USDT => "USDT",
CryptoCurrency.LTC => "LTC",
CryptoCurrency.ETH => "ETH",
CryptoCurrency.ZEC => "ZEC",
CryptoCurrency.DASH => "DASH",
CryptoCurrency.DOGE => "DOGE",
_ => "BTC"
};
}
private static string GetPaymentMethod(CryptoCurrency currency)
{
return currency switch
{
CryptoCurrency.BTC => "BTC",
CryptoCurrency.XMR => "XMR",
CryptoCurrency.USDT => "USDT_ETH", // USDT on Ethereum
CryptoCurrency.LTC => "LTC",
CryptoCurrency.ETH => "ETH",
CryptoCurrency.ZEC => "ZEC",
CryptoCurrency.DASH => "DASH",
CryptoCurrency.DOGE => "DOGE",
_ => "BTC"
};
}
}

View File

@@ -1,180 +1,180 @@
using Microsoft.EntityFrameworkCore;
using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using LittleShop.Data;
using LittleShop.Models;
using LittleShop.DTOs;
using LittleShop.Enums;
namespace LittleShop.Services;
public class CryptoPaymentService : ICryptoPaymentService
{
private readonly LittleShopContext _context;
private readonly IBTCPayServerService _btcPayService;
private readonly ILogger<CryptoPaymentService> _logger;
public CryptoPaymentService(
LittleShopContext context,
IBTCPayServerService btcPayService,
ILogger<CryptoPaymentService> logger)
{
_context = context;
_btcPayService = btcPayService;
_logger = logger;
}
public async Task<CryptoPaymentDto> CreatePaymentAsync(Guid orderId, CryptoCurrency currency)
{
var order = await _context.Orders
.Include(o => o.Items)
.ThenInclude(oi => oi.Product)
.FirstOrDefaultAsync(o => o.Id == orderId);
if (order == null)
throw new ArgumentException("Order not found", nameof(orderId));
// Check if payment already exists for this currency
var existingPayment = await _context.CryptoPayments
.FirstOrDefaultAsync(cp => cp.OrderId == orderId && cp.Currency == currency && cp.Status != PaymentStatus.Expired);
if (existingPayment != null)
{
return MapToDto(existingPayment);
}
// Create BTCPay Server invoice
var invoiceId = await _btcPayService.CreateInvoiceAsync(
order.TotalAmount,
currency,
order.Id.ToString(),
$"Order #{order.Id} - {order.Items.Count} items"
);
// For now, generate a placeholder wallet address
// In a real implementation, this would come from BTCPay Server
var walletAddress = GenerateWalletAddress(currency);
var cryptoPayment = new CryptoPayment
{
Id = Guid.NewGuid(),
OrderId = orderId,
Currency = currency,
WalletAddress = walletAddress,
RequiredAmount = order.TotalAmount, // This should be converted to crypto amount
PaidAmount = 0,
Status = PaymentStatus.Pending,
BTCPayInvoiceId = invoiceId, // This is the actual BTCPay invoice ID
CreatedAt = DateTime.UtcNow,
ExpiresAt = DateTime.UtcNow.AddHours(24)
};
_context.CryptoPayments.Add(cryptoPayment);
await _context.SaveChangesAsync();
_logger.LogInformation("Created crypto payment {PaymentId} for order {OrderId} with currency {Currency}",
cryptoPayment.Id, orderId, currency);
return MapToDto(cryptoPayment);
}
public async Task<IEnumerable<CryptoPaymentDto>> GetPaymentsByOrderAsync(Guid orderId)
{
var payments = await _context.CryptoPayments
.Where(cp => cp.OrderId == orderId)
.OrderByDescending(cp => cp.CreatedAt)
.ToListAsync();
return payments.Select(MapToDto);
}
public async Task<PaymentStatusDto> GetPaymentStatusAsync(Guid paymentId)
{
var payment = await _context.CryptoPayments.FindAsync(paymentId);
if (payment == null)
throw new ArgumentException("Payment not found", nameof(paymentId));
return new PaymentStatusDto
{
PaymentId = payment.Id,
Status = payment.Status,
RequiredAmount = payment.RequiredAmount,
PaidAmount = payment.PaidAmount,
PaidAt = payment.PaidAt,
ExpiresAt = payment.ExpiresAt
};
}
public async Task<bool> ProcessPaymentWebhookAsync(string invoiceId, PaymentStatus status, decimal amount, string? transactionHash = null)
{
var payment = await _context.CryptoPayments
.FirstOrDefaultAsync(cp => cp.BTCPayInvoiceId == invoiceId);
if (payment == null)
{
_logger.LogWarning("Received webhook for unknown invoice {InvoiceId}", invoiceId);
return false;
}
payment.Status = status;
payment.PaidAmount = amount;
payment.TransactionHash = transactionHash;
if (status == PaymentStatus.Paid)
{
payment.PaidAt = DateTime.UtcNow;
// Update order status
var order = await _context.Orders.FindAsync(payment.OrderId);
if (order != null)
{
order.Status = OrderStatus.PaymentReceived;
order.PaidAt = DateTime.UtcNow;
}
}
await _context.SaveChangesAsync();
_logger.LogInformation("Processed payment webhook for invoice {InvoiceId}, status: {Status}",
invoiceId, status);
return true;
}
private static CryptoPaymentDto MapToDto(CryptoPayment payment)
{
return new CryptoPaymentDto
{
Id = payment.Id,
OrderId = payment.OrderId,
Currency = payment.Currency,
WalletAddress = payment.WalletAddress,
RequiredAmount = payment.RequiredAmount,
PaidAmount = payment.PaidAmount,
Status = payment.Status,
BTCPayInvoiceId = payment.BTCPayInvoiceId,
TransactionHash = payment.TransactionHash,
CreatedAt = payment.CreatedAt,
PaidAt = payment.PaidAt,
ExpiresAt = payment.ExpiresAt
};
}
private static string GenerateWalletAddress(CryptoCurrency currency)
{
// Placeholder wallet addresses - in production these would come from BTCPay Server
var guid = Guid.NewGuid().ToString("N"); // 32 characters
return currency switch
{
CryptoCurrency.BTC => "bc1q" + guid[..26],
CryptoCurrency.XMR => "4" + guid + guid[..32], // XMR needs ~95 chars, use double GUID
CryptoCurrency.USDT => "0x" + guid[..32], // ERC-20 address
CryptoCurrency.LTC => "ltc1q" + guid[..26],
CryptoCurrency.ETH => "0x" + guid[..32],
CryptoCurrency.ZEC => "zs1" + guid + guid[..29], // Shielded address
CryptoCurrency.DASH => "X" + guid[..30],
CryptoCurrency.DOGE => "D" + guid[..30],
_ => "placeholder_" + guid[..20]
};
}
using Microsoft.EntityFrameworkCore;
using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using LittleShop.Data;
using LittleShop.Models;
using LittleShop.DTOs;
using LittleShop.Enums;
namespace LittleShop.Services;
public class CryptoPaymentService : ICryptoPaymentService
{
private readonly LittleShopContext _context;
private readonly IBTCPayServerService _btcPayService;
private readonly ILogger<CryptoPaymentService> _logger;
public CryptoPaymentService(
LittleShopContext context,
IBTCPayServerService btcPayService,
ILogger<CryptoPaymentService> logger)
{
_context = context;
_btcPayService = btcPayService;
_logger = logger;
}
public async Task<CryptoPaymentDto> CreatePaymentAsync(Guid orderId, CryptoCurrency currency)
{
var order = await _context.Orders
.Include(o => o.Items)
.ThenInclude(oi => oi.Product)
.FirstOrDefaultAsync(o => o.Id == orderId);
if (order == null)
throw new ArgumentException("Order not found", nameof(orderId));
// Check if payment already exists for this currency
var existingPayment = await _context.CryptoPayments
.FirstOrDefaultAsync(cp => cp.OrderId == orderId && cp.Currency == currency && cp.Status != PaymentStatus.Expired);
if (existingPayment != null)
{
return MapToDto(existingPayment);
}
// Create BTCPay Server invoice
var invoiceId = await _btcPayService.CreateInvoiceAsync(
order.TotalAmount,
currency,
order.Id.ToString(),
$"Order #{order.Id} - {order.Items.Count} items"
);
// For now, generate a placeholder wallet address
// In a real implementation, this would come from BTCPay Server
var walletAddress = GenerateWalletAddress(currency);
var cryptoPayment = new CryptoPayment
{
Id = Guid.NewGuid(),
OrderId = orderId,
Currency = currency,
WalletAddress = walletAddress,
RequiredAmount = order.TotalAmount, // This should be converted to crypto amount
PaidAmount = 0,
Status = PaymentStatus.Pending,
BTCPayInvoiceId = invoiceId, // This is the actual BTCPay invoice ID
CreatedAt = DateTime.UtcNow,
ExpiresAt = DateTime.UtcNow.AddHours(24)
};
_context.CryptoPayments.Add(cryptoPayment);
await _context.SaveChangesAsync();
_logger.LogInformation("Created crypto payment {PaymentId} for order {OrderId} with currency {Currency}",
cryptoPayment.Id, orderId, currency);
return MapToDto(cryptoPayment);
}
public async Task<IEnumerable<CryptoPaymentDto>> GetPaymentsByOrderAsync(Guid orderId)
{
var payments = await _context.CryptoPayments
.Where(cp => cp.OrderId == orderId)
.OrderByDescending(cp => cp.CreatedAt)
.ToListAsync();
return payments.Select(MapToDto);
}
public async Task<PaymentStatusDto> GetPaymentStatusAsync(Guid paymentId)
{
var payment = await _context.CryptoPayments.FindAsync(paymentId);
if (payment == null)
throw new ArgumentException("Payment not found", nameof(paymentId));
return new PaymentStatusDto
{
PaymentId = payment.Id,
Status = payment.Status,
RequiredAmount = payment.RequiredAmount,
PaidAmount = payment.PaidAmount,
PaidAt = payment.PaidAt,
ExpiresAt = payment.ExpiresAt
};
}
public async Task<bool> ProcessPaymentWebhookAsync(string invoiceId, PaymentStatus status, decimal amount, string? transactionHash = null)
{
var payment = await _context.CryptoPayments
.FirstOrDefaultAsync(cp => cp.BTCPayInvoiceId == invoiceId);
if (payment == null)
{
_logger.LogWarning("Received webhook for unknown invoice {InvoiceId}", invoiceId);
return false;
}
payment.Status = status;
payment.PaidAmount = amount;
payment.TransactionHash = transactionHash;
if (status == PaymentStatus.Paid)
{
payment.PaidAt = DateTime.UtcNow;
// Update order status
var order = await _context.Orders.FindAsync(payment.OrderId);
if (order != null)
{
order.Status = OrderStatus.PaymentReceived;
order.PaidAt = DateTime.UtcNow;
}
}
await _context.SaveChangesAsync();
_logger.LogInformation("Processed payment webhook for invoice {InvoiceId}, status: {Status}",
invoiceId, status);
return true;
}
private static CryptoPaymentDto MapToDto(CryptoPayment payment)
{
return new CryptoPaymentDto
{
Id = payment.Id,
OrderId = payment.OrderId,
Currency = payment.Currency,
WalletAddress = payment.WalletAddress,
RequiredAmount = payment.RequiredAmount,
PaidAmount = payment.PaidAmount,
Status = payment.Status,
BTCPayInvoiceId = payment.BTCPayInvoiceId,
TransactionHash = payment.TransactionHash,
CreatedAt = payment.CreatedAt,
PaidAt = payment.PaidAt,
ExpiresAt = payment.ExpiresAt
};
}
private static string GenerateWalletAddress(CryptoCurrency currency)
{
// Placeholder wallet addresses - in production these would come from BTCPay Server
var guid = Guid.NewGuid().ToString("N"); // 32 characters
return currency switch
{
CryptoCurrency.BTC => "bc1q" + guid[..26],
CryptoCurrency.XMR => "4" + guid + guid[..32], // XMR needs ~95 chars, use double GUID
CryptoCurrency.USDT => "0x" + guid[..32], // ERC-20 address
CryptoCurrency.LTC => "ltc1q" + guid[..26],
CryptoCurrency.ETH => "0x" + guid[..32],
CryptoCurrency.ZEC => "zs1" + guid + guid[..29], // Shielded address
CryptoCurrency.DASH => "X" + guid[..30],
CryptoCurrency.DOGE => "D" + guid[..30],
_ => "placeholder_" + guid[..20]
};
}
}

View File

@@ -1,292 +1,292 @@
using Microsoft.EntityFrameworkCore;
using LittleShop.Data;
using LittleShop.Models;
using LittleShop.DTOs;
using LittleShop.Enums;
namespace LittleShop.Services;
public class OrderService : IOrderService
{
private readonly LittleShopContext _context;
private readonly ILogger<OrderService> _logger;
private readonly ICustomerService _customerService;
public OrderService(LittleShopContext context, ILogger<OrderService> logger, ICustomerService customerService)
{
_context = context;
_logger = logger;
_customerService = customerService;
}
public async Task<IEnumerable<OrderDto>> GetAllOrdersAsync()
{
var orders = await _context.Orders
.Include(o => o.Customer)
.Include(o => o.Items)
.ThenInclude(oi => oi.Product)
.Include(o => o.Payments)
.OrderByDescending(o => o.CreatedAt)
.ToListAsync();
return orders.Select(MapToDto);
}
public async Task<IEnumerable<OrderDto>> GetOrdersByIdentityAsync(string identityReference)
{
var orders = await _context.Orders
.Include(o => o.Customer)
.Include(o => o.Items)
.ThenInclude(oi => oi.Product)
.Include(o => o.Payments)
.Where(o => o.IdentityReference == identityReference)
.OrderByDescending(o => o.CreatedAt)
.ToListAsync();
return orders.Select(MapToDto);
}
public async Task<IEnumerable<OrderDto>> GetOrdersByCustomerIdAsync(Guid customerId)
{
var orders = await _context.Orders
.Include(o => o.Customer)
.Include(o => o.Items)
.ThenInclude(oi => oi.Product)
.Include(o => o.Payments)
.Where(o => o.CustomerId == customerId)
.OrderByDescending(o => o.CreatedAt)
.ToListAsync();
return orders.Select(MapToDto);
}
public async Task<OrderDto?> GetOrderByIdAsync(Guid id)
{
var order = await _context.Orders
.Include(o => o.Customer)
.Include(o => o.Items)
.ThenInclude(oi => oi.Product)
.Include(o => o.Payments)
.FirstOrDefaultAsync(o => o.Id == id);
return order == null ? null : MapToDto(order);
}
public async Task<OrderDto> CreateOrderAsync(CreateOrderDto createOrderDto)
{
using var transaction = await _context.Database.BeginTransactionAsync();
try
{
// Handle customer creation/linking during checkout
Guid? customerId = null;
string? identityReference = null;
if (createOrderDto.CustomerInfo != null)
{
// Create customer during checkout process
var customer = await _customerService.GetOrCreateCustomerAsync(
createOrderDto.CustomerInfo.TelegramUserId,
createOrderDto.CustomerInfo.TelegramDisplayName,
createOrderDto.CustomerInfo.TelegramUsername,
createOrderDto.CustomerInfo.TelegramFirstName,
createOrderDto.CustomerInfo.TelegramLastName);
customerId = customer?.Id;
}
else if (createOrderDto.CustomerId.HasValue)
{
// Order for existing customer
customerId = createOrderDto.CustomerId;
}
else
{
// Anonymous order (legacy support)
identityReference = createOrderDto.IdentityReference;
}
var order = new Order
{
Id = Guid.NewGuid(),
CustomerId = customerId,
IdentityReference = identityReference,
Status = OrderStatus.PendingPayment,
TotalAmount = 0,
Currency = "GBP",
ShippingName = createOrderDto.ShippingName,
ShippingAddress = createOrderDto.ShippingAddress,
ShippingCity = createOrderDto.ShippingCity,
ShippingPostCode = createOrderDto.ShippingPostCode,
ShippingCountry = createOrderDto.ShippingCountry,
Notes = createOrderDto.Notes,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow
};
_context.Orders.Add(order);
decimal totalAmount = 0;
foreach (var itemDto in createOrderDto.Items)
{
var product = await _context.Products.FindAsync(itemDto.ProductId);
if (product == null || !product.IsActive)
{
throw new ArgumentException($"Product {itemDto.ProductId} not found or inactive");
}
var orderItem = new OrderItem
{
Id = Guid.NewGuid(),
OrderId = order.Id,
ProductId = itemDto.ProductId,
Quantity = itemDto.Quantity,
UnitPrice = product.Price,
TotalPrice = product.Price * itemDto.Quantity
};
_context.OrderItems.Add(orderItem);
totalAmount += orderItem.TotalPrice;
}
order.TotalAmount = totalAmount;
await _context.SaveChangesAsync();
await transaction.CommitAsync();
if (customerId.HasValue)
{
_logger.LogInformation("Created order {OrderId} for customer {CustomerId} with total {Total}",
order.Id, customerId.Value, totalAmount);
}
else
{
_logger.LogInformation("Created order {OrderId} for identity {Identity} with total {Total}",
order.Id, identityReference, totalAmount);
}
// Reload order with includes
var createdOrder = await GetOrderByIdAsync(order.Id);
return createdOrder!;
}
catch
{
await transaction.RollbackAsync();
throw;
}
}
public async Task<bool> UpdateOrderStatusAsync(Guid id, UpdateOrderStatusDto updateOrderStatusDto)
{
var order = await _context.Orders.FindAsync(id);
if (order == null) return false;
order.Status = updateOrderStatusDto.Status;
if (!string.IsNullOrEmpty(updateOrderStatusDto.TrackingNumber))
{
order.TrackingNumber = updateOrderStatusDto.TrackingNumber;
}
if (!string.IsNullOrEmpty(updateOrderStatusDto.Notes))
{
order.Notes = updateOrderStatusDto.Notes;
}
if (updateOrderStatusDto.Status == OrderStatus.Shipped && order.ShippedAt == null)
{
order.ShippedAt = DateTime.UtcNow;
}
order.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
_logger.LogInformation("Updated order {OrderId} status to {Status}", id, updateOrderStatusDto.Status);
return true;
}
public async Task<bool> CancelOrderAsync(Guid id, string identityReference)
{
var order = await _context.Orders.FindAsync(id);
if (order == null || order.IdentityReference != identityReference)
return false;
if (order.Status != OrderStatus.PendingPayment)
{
return false; // Can only cancel pending orders
}
order.Status = OrderStatus.Cancelled;
order.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
_logger.LogInformation("Cancelled order {OrderId} by identity {Identity}", id, identityReference);
return true;
}
private static OrderDto MapToDto(Order order)
{
return new OrderDto
{
Id = order.Id,
CustomerId = order.CustomerId,
IdentityReference = order.IdentityReference,
Status = order.Status,
Customer = order.Customer != null ? new CustomerSummaryDto
{
Id = order.Customer.Id,
DisplayName = !string.IsNullOrEmpty(order.Customer.TelegramDisplayName) ? order.Customer.TelegramDisplayName :
!string.IsNullOrEmpty(order.Customer.TelegramUsername) ? $"@{order.Customer.TelegramUsername}" :
$"{order.Customer.TelegramFirstName} {order.Customer.TelegramLastName}".Trim(),
TelegramUsername = order.Customer.TelegramUsername,
TotalOrders = order.Customer.TotalOrders,
TotalSpent = order.Customer.TotalSpent,
CustomerType = order.Customer.TotalOrders == 0 ? "New" :
order.Customer.TotalOrders == 1 ? "First-time" :
order.Customer.TotalOrders < 5 ? "Regular" :
order.Customer.TotalOrders < 20 ? "Loyal" : "VIP",
RiskScore = order.Customer.RiskScore,
LastActiveAt = order.Customer.LastActiveAt,
IsBlocked = order.Customer.IsBlocked
} : null,
TotalAmount = order.TotalAmount,
Currency = order.Currency,
ShippingName = order.ShippingName,
ShippingAddress = order.ShippingAddress,
ShippingCity = order.ShippingCity,
ShippingPostCode = order.ShippingPostCode,
ShippingCountry = order.ShippingCountry,
Notes = order.Notes,
TrackingNumber = order.TrackingNumber,
CreatedAt = order.CreatedAt,
UpdatedAt = order.UpdatedAt,
PaidAt = order.PaidAt,
ShippedAt = order.ShippedAt,
Items = order.Items.Select(oi => new OrderItemDto
{
Id = oi.Id,
ProductId = oi.ProductId,
ProductName = oi.Product.Name,
Quantity = oi.Quantity,
UnitPrice = oi.UnitPrice,
TotalPrice = oi.TotalPrice
}).ToList(),
Payments = order.Payments.Select(cp => new CryptoPaymentDto
{
Id = cp.Id,
OrderId = cp.OrderId,
Currency = cp.Currency,
WalletAddress = cp.WalletAddress,
RequiredAmount = cp.RequiredAmount,
PaidAmount = cp.PaidAmount,
Status = cp.Status,
BTCPayInvoiceId = cp.BTCPayInvoiceId,
TransactionHash = cp.TransactionHash,
CreatedAt = cp.CreatedAt,
PaidAt = cp.PaidAt,
ExpiresAt = cp.ExpiresAt
}).ToList()
};
}
using Microsoft.EntityFrameworkCore;
using LittleShop.Data;
using LittleShop.Models;
using LittleShop.DTOs;
using LittleShop.Enums;
namespace LittleShop.Services;
public class OrderService : IOrderService
{
private readonly LittleShopContext _context;
private readonly ILogger<OrderService> _logger;
private readonly ICustomerService _customerService;
public OrderService(LittleShopContext context, ILogger<OrderService> logger, ICustomerService customerService)
{
_context = context;
_logger = logger;
_customerService = customerService;
}
public async Task<IEnumerable<OrderDto>> GetAllOrdersAsync()
{
var orders = await _context.Orders
.Include(o => o.Customer)
.Include(o => o.Items)
.ThenInclude(oi => oi.Product)
.Include(o => o.Payments)
.OrderByDescending(o => o.CreatedAt)
.ToListAsync();
return orders.Select(MapToDto);
}
public async Task<IEnumerable<OrderDto>> GetOrdersByIdentityAsync(string identityReference)
{
var orders = await _context.Orders
.Include(o => o.Customer)
.Include(o => o.Items)
.ThenInclude(oi => oi.Product)
.Include(o => o.Payments)
.Where(o => o.IdentityReference == identityReference)
.OrderByDescending(o => o.CreatedAt)
.ToListAsync();
return orders.Select(MapToDto);
}
public async Task<IEnumerable<OrderDto>> GetOrdersByCustomerIdAsync(Guid customerId)
{
var orders = await _context.Orders
.Include(o => o.Customer)
.Include(o => o.Items)
.ThenInclude(oi => oi.Product)
.Include(o => o.Payments)
.Where(o => o.CustomerId == customerId)
.OrderByDescending(o => o.CreatedAt)
.ToListAsync();
return orders.Select(MapToDto);
}
public async Task<OrderDto?> GetOrderByIdAsync(Guid id)
{
var order = await _context.Orders
.Include(o => o.Customer)
.Include(o => o.Items)
.ThenInclude(oi => oi.Product)
.Include(o => o.Payments)
.FirstOrDefaultAsync(o => o.Id == id);
return order == null ? null : MapToDto(order);
}
public async Task<OrderDto> CreateOrderAsync(CreateOrderDto createOrderDto)
{
using var transaction = await _context.Database.BeginTransactionAsync();
try
{
// Handle customer creation/linking during checkout
Guid? customerId = null;
string? identityReference = null;
if (createOrderDto.CustomerInfo != null)
{
// Create customer during checkout process
var customer = await _customerService.GetOrCreateCustomerAsync(
createOrderDto.CustomerInfo.TelegramUserId,
createOrderDto.CustomerInfo.TelegramDisplayName,
createOrderDto.CustomerInfo.TelegramUsername,
createOrderDto.CustomerInfo.TelegramFirstName,
createOrderDto.CustomerInfo.TelegramLastName);
customerId = customer?.Id;
}
else if (createOrderDto.CustomerId.HasValue)
{
// Order for existing customer
customerId = createOrderDto.CustomerId;
}
else
{
// Anonymous order (legacy support)
identityReference = createOrderDto.IdentityReference;
}
var order = new Order
{
Id = Guid.NewGuid(),
CustomerId = customerId,
IdentityReference = identityReference,
Status = OrderStatus.PendingPayment,
TotalAmount = 0,
Currency = "GBP",
ShippingName = createOrderDto.ShippingName,
ShippingAddress = createOrderDto.ShippingAddress,
ShippingCity = createOrderDto.ShippingCity,
ShippingPostCode = createOrderDto.ShippingPostCode,
ShippingCountry = createOrderDto.ShippingCountry,
Notes = createOrderDto.Notes,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow
};
_context.Orders.Add(order);
decimal totalAmount = 0;
foreach (var itemDto in createOrderDto.Items)
{
var product = await _context.Products.FindAsync(itemDto.ProductId);
if (product == null || !product.IsActive)
{
throw new ArgumentException($"Product {itemDto.ProductId} not found or inactive");
}
var orderItem = new OrderItem
{
Id = Guid.NewGuid(),
OrderId = order.Id,
ProductId = itemDto.ProductId,
Quantity = itemDto.Quantity,
UnitPrice = product.Price,
TotalPrice = product.Price * itemDto.Quantity
};
_context.OrderItems.Add(orderItem);
totalAmount += orderItem.TotalPrice;
}
order.TotalAmount = totalAmount;
await _context.SaveChangesAsync();
await transaction.CommitAsync();
if (customerId.HasValue)
{
_logger.LogInformation("Created order {OrderId} for customer {CustomerId} with total {Total}",
order.Id, customerId.Value, totalAmount);
}
else
{
_logger.LogInformation("Created order {OrderId} for identity {Identity} with total {Total}",
order.Id, identityReference, totalAmount);
}
// Reload order with includes
var createdOrder = await GetOrderByIdAsync(order.Id);
return createdOrder!;
}
catch
{
await transaction.RollbackAsync();
throw;
}
}
public async Task<bool> UpdateOrderStatusAsync(Guid id, UpdateOrderStatusDto updateOrderStatusDto)
{
var order = await _context.Orders.FindAsync(id);
if (order == null) return false;
order.Status = updateOrderStatusDto.Status;
if (!string.IsNullOrEmpty(updateOrderStatusDto.TrackingNumber))
{
order.TrackingNumber = updateOrderStatusDto.TrackingNumber;
}
if (!string.IsNullOrEmpty(updateOrderStatusDto.Notes))
{
order.Notes = updateOrderStatusDto.Notes;
}
if (updateOrderStatusDto.Status == OrderStatus.Shipped && order.ShippedAt == null)
{
order.ShippedAt = DateTime.UtcNow;
}
order.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
_logger.LogInformation("Updated order {OrderId} status to {Status}", id, updateOrderStatusDto.Status);
return true;
}
public async Task<bool> CancelOrderAsync(Guid id, string identityReference)
{
var order = await _context.Orders.FindAsync(id);
if (order == null || order.IdentityReference != identityReference)
return false;
if (order.Status != OrderStatus.PendingPayment)
{
return false; // Can only cancel pending orders
}
order.Status = OrderStatus.Cancelled;
order.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
_logger.LogInformation("Cancelled order {OrderId} by identity {Identity}", id, identityReference);
return true;
}
private static OrderDto MapToDto(Order order)
{
return new OrderDto
{
Id = order.Id,
CustomerId = order.CustomerId,
IdentityReference = order.IdentityReference,
Status = order.Status,
Customer = order.Customer != null ? new CustomerSummaryDto
{
Id = order.Customer.Id,
DisplayName = !string.IsNullOrEmpty(order.Customer.TelegramDisplayName) ? order.Customer.TelegramDisplayName :
!string.IsNullOrEmpty(order.Customer.TelegramUsername) ? $"@{order.Customer.TelegramUsername}" :
$"{order.Customer.TelegramFirstName} {order.Customer.TelegramLastName}".Trim(),
TelegramUsername = order.Customer.TelegramUsername,
TotalOrders = order.Customer.TotalOrders,
TotalSpent = order.Customer.TotalSpent,
CustomerType = order.Customer.TotalOrders == 0 ? "New" :
order.Customer.TotalOrders == 1 ? "First-time" :
order.Customer.TotalOrders < 5 ? "Regular" :
order.Customer.TotalOrders < 20 ? "Loyal" : "VIP",
RiskScore = order.Customer.RiskScore,
LastActiveAt = order.Customer.LastActiveAt,
IsBlocked = order.Customer.IsBlocked
} : null,
TotalAmount = order.TotalAmount,
Currency = order.Currency,
ShippingName = order.ShippingName,
ShippingAddress = order.ShippingAddress,
ShippingCity = order.ShippingCity,
ShippingPostCode = order.ShippingPostCode,
ShippingCountry = order.ShippingCountry,
Notes = order.Notes,
TrackingNumber = order.TrackingNumber,
CreatedAt = order.CreatedAt,
UpdatedAt = order.UpdatedAt,
PaidAt = order.PaidAt,
ShippedAt = order.ShippedAt,
Items = order.Items.Select(oi => new OrderItemDto
{
Id = oi.Id,
ProductId = oi.ProductId,
ProductName = oi.Product.Name,
Quantity = oi.Quantity,
UnitPrice = oi.UnitPrice,
TotalPrice = oi.TotalPrice
}).ToList(),
Payments = order.Payments.Select(cp => new CryptoPaymentDto
{
Id = cp.Id,
OrderId = cp.OrderId,
Currency = cp.Currency,
WalletAddress = cp.WalletAddress,
RequiredAmount = cp.RequiredAmount,
PaidAmount = cp.PaidAmount,
Status = cp.Status,
BTCPayInvoiceId = cp.BTCPayInvoiceId,
TransactionHash = cp.TransactionHash,
CreatedAt = cp.CreatedAt,
PaidAt = cp.PaidAt,
ExpiresAt = cp.ExpiresAt
}).ToList()
};
}
}

View File

@@ -258,11 +258,11 @@ public class ProductService : IProductService
var product = await _context.Products.FindAsync(photoDto.ProductId);
if (product == null) return null;
var maxSortOrder = await _context.ProductPhotos
var existingPhotos = await _context.ProductPhotos
.Where(pp => pp.ProductId == photoDto.ProductId)
.Select(pp => pp.SortOrder)
.DefaultIfEmpty(0)
.MaxAsync();
.ToListAsync();
var maxSortOrder = existingPhotos.Any() ? existingPhotos.Max(pp => pp.SortOrder) : 0;
var productPhoto = new ProductPhoto
{

View File

@@ -0,0 +1,300 @@
using Microsoft.EntityFrameworkCore;
using LittleShop.Data;
using LittleShop.Models;
using LittleShop.DTOs;
using LittleShop.Enums;
namespace LittleShop.Services;
public interface IReviewService
{
Task<ReviewDto?> GetReviewByIdAsync(Guid id);
Task<IEnumerable<ReviewDto>> GetReviewsByProductAsync(Guid productId, bool approvedOnly = true);
Task<IEnumerable<ReviewDto>> GetReviewsByCustomerAsync(Guid customerId);
Task<IEnumerable<ReviewDto>> GetPendingReviewsAsync();
Task<ReviewSummaryDto?> GetProductReviewSummaryAsync(Guid productId);
Task<CustomerReviewEligibilityDto> CheckReviewEligibilityAsync(Guid customerId, Guid productId);
Task<ReviewDto> CreateReviewAsync(CreateReviewDto createReviewDto);
Task<bool> UpdateReviewAsync(Guid id, UpdateReviewDto updateReviewDto);
Task<bool> ApproveReviewAsync(Guid id, Guid approvedByUserId);
Task<bool> DeleteReviewAsync(Guid id);
Task<bool> CanCustomerReviewProductAsync(Guid customerId, Guid productId);
}
public class ReviewService : IReviewService
{
private readonly LittleShopContext _context;
private readonly ILogger<ReviewService> _logger;
public ReviewService(LittleShopContext context, ILogger<ReviewService> logger)
{
_context = context;
_logger = logger;
}
public async Task<ReviewDto?> GetReviewByIdAsync(Guid id)
{
var review = await _context.Reviews
.Include(r => r.Product)
.Include(r => r.Customer)
.Include(r => r.ApprovedByUser)
.FirstOrDefaultAsync(r => r.Id == id);
return review == null ? null : MapToDto(review);
}
public async Task<IEnumerable<ReviewDto>> GetReviewsByProductAsync(Guid productId, bool approvedOnly = true)
{
var query = _context.Reviews
.Include(r => r.Customer)
.Include(r => r.ApprovedByUser)
.Where(r => r.ProductId == productId && r.IsActive);
if (approvedOnly)
{
query = query.Where(r => r.IsApproved);
}
var reviews = await query
.OrderByDescending(r => r.CreatedAt)
.ToListAsync();
return reviews.Select(MapToDto);
}
public async Task<IEnumerable<ReviewDto>> GetReviewsByCustomerAsync(Guid customerId)
{
var reviews = await _context.Reviews
.Include(r => r.Product)
.Include(r => r.ApprovedByUser)
.Where(r => r.CustomerId == customerId && r.IsActive)
.OrderByDescending(r => r.CreatedAt)
.ToListAsync();
return reviews.Select(MapToDto);
}
public async Task<IEnumerable<ReviewDto>> GetPendingReviewsAsync()
{
var reviews = await _context.Reviews
.Include(r => r.Product)
.Include(r => r.Customer)
.Where(r => !r.IsApproved && r.IsActive)
.OrderBy(r => r.CreatedAt)
.ToListAsync();
return reviews.Select(MapToDto);
}
public async Task<ReviewSummaryDto?> GetProductReviewSummaryAsync(Guid productId)
{
var product = await _context.Products
.Include(p => p.Reviews.Where(r => r.IsApproved && r.IsActive))
.FirstOrDefaultAsync(p => p.Id == productId);
if (product == null) return null;
var approvedReviews = product.Reviews.Where(r => r.IsApproved && r.IsActive).ToList();
if (!approvedReviews.Any())
{
return new ReviewSummaryDto
{
ProductId = productId,
ProductName = product.Name,
TotalReviews = 0,
ApprovedReviews = 0,
AverageRating = 0
};
}
return new ReviewSummaryDto
{
ProductId = productId,
ProductName = product.Name,
TotalReviews = approvedReviews.Count,
ApprovedReviews = approvedReviews.Count,
AverageRating = Math.Round(approvedReviews.Average(r => r.Rating), 1),
FiveStars = approvedReviews.Count(r => r.Rating == 5),
FourStars = approvedReviews.Count(r => r.Rating == 4),
ThreeStars = approvedReviews.Count(r => r.Rating == 3),
TwoStars = approvedReviews.Count(r => r.Rating == 2),
OneStar = approvedReviews.Count(r => r.Rating == 1),
LatestReviewDate = approvedReviews.Max(r => r.CreatedAt)
};
}
public async Task<CustomerReviewEligibilityDto> CheckReviewEligibilityAsync(Guid customerId, Guid productId)
{
// Check if customer has already reviewed this product
var existingReview = await _context.Reviews
.FirstOrDefaultAsync(r => r.CustomerId == customerId && r.ProductId == productId && r.IsActive);
// Get shipped orders containing this product for this customer
var eligibleOrders = await _context.Orders
.Include(o => o.Items)
.Where(o => o.CustomerId == customerId
&& o.Status == OrderStatus.Shipped
&& o.Items.Any(oi => oi.ProductId == productId))
.Select(o => o.Id)
.ToListAsync();
var canReview = eligibleOrders.Any() && existingReview == null;
var reason = !eligibleOrders.Any()
? "You must have a shipped order containing this product to leave a review"
: existingReview != null
? "You have already reviewed this product"
: null;
return new CustomerReviewEligibilityDto
{
CustomerId = customerId,
ProductId = productId,
CanReview = canReview,
Reason = reason,
EligibleOrderIds = eligibleOrders,
HasExistingReview = existingReview != null,
ExistingReviewId = existingReview?.Id
};
}
public async Task<ReviewDto> CreateReviewAsync(CreateReviewDto createReviewDto)
{
// Verify customer can review this product
var eligibility = await CheckReviewEligibilityAsync(createReviewDto.CustomerId, createReviewDto.ProductId);
if (!eligibility.CanReview)
{
throw new InvalidOperationException(eligibility.Reason ?? "Cannot create review");
}
// Verify the order exists and contains the product
var order = await _context.Orders
.Include(o => o.Items)
.FirstOrDefaultAsync(o => o.Id == createReviewDto.OrderId
&& o.CustomerId == createReviewDto.CustomerId
&& o.Status == OrderStatus.Shipped
&& o.Items.Any(oi => oi.ProductId == createReviewDto.ProductId));
if (order == null)
{
throw new InvalidOperationException("Invalid order or product not found in shipped order");
}
var review = new Review
{
Id = Guid.NewGuid(),
ProductId = createReviewDto.ProductId,
CustomerId = createReviewDto.CustomerId,
OrderId = createReviewDto.OrderId,
Rating = createReviewDto.Rating,
Title = createReviewDto.Title,
Comment = createReviewDto.Comment,
IsVerifiedPurchase = true,
IsApproved = false, // Reviews require admin approval
IsActive = true,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow
};
_context.Reviews.Add(review);
await _context.SaveChangesAsync();
_logger.LogInformation("Review created: {ReviewId} for product {ProductId} by customer {CustomerId}",
review.Id, createReviewDto.ProductId, createReviewDto.CustomerId);
// Load navigation properties for return DTO
review = await _context.Reviews
.Include(r => r.Product)
.Include(r => r.Customer)
.FirstAsync(r => r.Id == review.Id);
return MapToDto(review);
}
public async Task<bool> UpdateReviewAsync(Guid id, UpdateReviewDto updateReviewDto)
{
var review = await _context.Reviews.FindAsync(id);
if (review == null) return false;
if (updateReviewDto.Rating.HasValue)
review.Rating = updateReviewDto.Rating.Value;
if (updateReviewDto.Title != null)
review.Title = updateReviewDto.Title;
if (updateReviewDto.Comment != null)
review.Comment = updateReviewDto.Comment;
if (updateReviewDto.IsApproved.HasValue)
review.IsApproved = updateReviewDto.IsApproved.Value;
if (updateReviewDto.IsActive.HasValue)
review.IsActive = updateReviewDto.IsActive.Value;
review.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
_logger.LogInformation("Review updated: {ReviewId}", id);
return true;
}
public async Task<bool> ApproveReviewAsync(Guid id, Guid approvedByUserId)
{
var review = await _context.Reviews.FindAsync(id);
if (review == null) return false;
review.IsApproved = true;
review.ApprovedAt = DateTime.UtcNow;
review.ApprovedByUserId = approvedByUserId;
review.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
_logger.LogInformation("Review approved: {ReviewId} by user {UserId}", id, approvedByUserId);
return true;
}
public async Task<bool> DeleteReviewAsync(Guid id)
{
var review = await _context.Reviews.FindAsync(id);
if (review == null) return false;
review.IsActive = false;
review.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
_logger.LogInformation("Review soft deleted: {ReviewId}", id);
return true;
}
public async Task<bool> CanCustomerReviewProductAsync(Guid customerId, Guid productId)
{
var eligibility = await CheckReviewEligibilityAsync(customerId, productId);
return eligibility.CanReview;
}
private static ReviewDto MapToDto(Review review)
{
return new ReviewDto
{
Id = review.Id,
ProductId = review.ProductId,
ProductName = review.Product?.Name ?? "",
CustomerId = review.CustomerId,
CustomerDisplayName = review.Customer?.TelegramDisplayName ?? "Anonymous",
OrderId = review.OrderId,
Rating = review.Rating,
Title = review.Title,
Comment = review.Comment,
IsVerifiedPurchase = review.IsVerifiedPurchase,
IsApproved = review.IsApproved,
IsActive = review.IsActive,
CreatedAt = review.CreatedAt,
UpdatedAt = review.UpdatedAt,
ApprovedAt = review.ApprovedAt,
ApprovedByUsername = review.ApprovedByUser?.Username
};
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,31 +1,31 @@
{
"ProjectPath": "C:\\Production\\Source\\LittleShop\\LittleShop",
"ProjectType": "Project (ASP.NET Core)",
"TotalEndpoints": 115,
"AuthenticatedEndpoints": 78,
"TestableStates": 3,
"IdentifiedGaps": 224,
"SuggestedTests": 190,
"DeadLinks": 0,
"HttpErrors": 97,
"VisualIssues": 0,
"SecurityInsights": 1,
"PerformanceInsights": 1,
"OverallTestCoverage": 16.956521739130434,
"VisualConsistencyScore": 0,
"CriticalRecommendations": [
"CRITICAL: Test coverage is only 17.0% - implement comprehensive test suite",
"HIGH: Address 97 HTTP errors in the application",
"MEDIUM: Improve visual consistency - current score 0.0%",
"HIGH: Address 224 testing gaps for comprehensive coverage"
],
"GeneratedFiles": [
"C:\\Production\\Source\\LittleShop\\LittleShop\\TestAgent_Results\\project_structure.json",
"C:\\Production\\Source\\LittleShop\\LittleShop\\TestAgent_Results\\authentication_analysis.json",
"C:\\Production\\Source\\LittleShop\\LittleShop\\TestAgent_Results\\endpoint_discovery.json",
"C:\\Production\\Source\\LittleShop\\LittleShop\\TestAgent_Results\\coverage_analysis.json",
"C:\\Production\\Source\\LittleShop\\LittleShop\\TestAgent_Results\\error_detection.json",
"C:\\Production\\Source\\LittleShop\\LittleShop\\TestAgent_Results\\visual_testing.json",
"C:\\Production\\Source\\LittleShop\\LittleShop\\TestAgent_Results\\intelligent_analysis.json"
]
{
"ProjectPath": "C:\\Production\\Source\\LittleShop\\LittleShop",
"ProjectType": "Project (ASP.NET Core)",
"TotalEndpoints": 115,
"AuthenticatedEndpoints": 78,
"TestableStates": 3,
"IdentifiedGaps": 224,
"SuggestedTests": 190,
"DeadLinks": 0,
"HttpErrors": 97,
"VisualIssues": 0,
"SecurityInsights": 1,
"PerformanceInsights": 1,
"OverallTestCoverage": 16.956521739130434,
"VisualConsistencyScore": 0,
"CriticalRecommendations": [
"CRITICAL: Test coverage is only 17.0% - implement comprehensive test suite",
"HIGH: Address 97 HTTP errors in the application",
"MEDIUM: Improve visual consistency - current score 0.0%",
"HIGH: Address 224 testing gaps for comprehensive coverage"
],
"GeneratedFiles": [
"C:\\Production\\Source\\LittleShop\\LittleShop\\TestAgent_Results\\project_structure.json",
"C:\\Production\\Source\\LittleShop\\LittleShop\\TestAgent_Results\\authentication_analysis.json",
"C:\\Production\\Source\\LittleShop\\LittleShop\\TestAgent_Results\\endpoint_discovery.json",
"C:\\Production\\Source\\LittleShop\\LittleShop\\TestAgent_Results\\coverage_analysis.json",
"C:\\Production\\Source\\LittleShop\\LittleShop\\TestAgent_Results\\error_detection.json",
"C:\\Production\\Source\\LittleShop\\LittleShop\\TestAgent_Results\\visual_testing.json",
"C:\\Production\\Source\\LittleShop\\LittleShop\\TestAgent_Results\\intelligent_analysis.json"
]
}

View File

@@ -1,79 +1,79 @@
{
"BusinessLogicInsights": [
{
"Component": "Claude CLI Integration",
"Insight": "Error analyzing business logic: Failed to execute Claude CLI: An error occurred trying to start process \u0027claude\u0027 with working directory \u0027C:\\Production\\Source\\TestAgent\u0027. The system cannot find the file specified.",
"Complexity": "Unknown",
"PotentialIssues": [],
"TestingRecommendations": [],
"Priority": "Medium"
}
],
"TestScenarioSuggestions": [
{
"ScenarioName": "Claude CLI Integration Error",
"Description": "Error generating test scenarios: Failed to execute Claude CLI: An error occurred trying to start process \u0027claude\u0027 with working directory \u0027C:\\Production\\Source\\TestAgent\u0027. The system cannot find the file specified.",
"TestType": "",
"Steps": [],
"ExpectedOutcomes": [],
"Priority": "Medium",
"RequiredData": [],
"Dependencies": []
}
],
"SecurityInsights": [
{
"VulnerabilityType": "Analysis Error",
"Location": "",
"Description": "Error analyzing security: Failed to execute Claude CLI: An error occurred trying to start process \u0027claude\u0027 with working directory \u0027C:\\Production\\Source\\TestAgent\u0027. The system cannot find the file specified.",
"Severity": "Medium",
"Recommendations": [],
"TestingApproaches": []
}
],
"PerformanceInsights": [
{
"Component": "Analysis Error",
"PotentialBottleneck": "Error analyzing performance: Failed to execute Claude CLI: An error occurred trying to start process \u0027claude\u0027 with working directory \u0027C:\\Production\\Source\\TestAgent\u0027. The system cannot find the file specified.",
"Impact": "Unknown",
"OptimizationSuggestions": [],
"TestingStrategies": []
}
],
"ArchitecturalRecommendations": [
{
"Category": "Analysis Error",
"Recommendation": "Error generating architectural recommendations: Failed to execute Claude CLI: An error occurred trying to start process \u0027claude\u0027 with working directory \u0027C:\\Production\\Source\\TestAgent\u0027. The system cannot find the file specified.",
"Rationale": "",
"Impact": "Unknown",
"ImplementationSteps": []
}
],
"GeneratedTestCases": [
{
"TestName": "Claude CLI Integration Error",
"TestCategory": "Error",
"Description": "Error generating test cases: Failed to execute Claude CLI: An error occurred trying to start process \u0027claude\u0027 with working directory \u0027C:\\Production\\Source\\TestAgent\u0027. The system cannot find the file specified.",
"TestCode": "",
"TestData": [],
"ExpectedOutcome": "",
"Reasoning": ""
}
],
"Summary": {
"TotalInsights": 4,
"HighPriorityItems": 0,
"GeneratedTestCases": 1,
"SecurityIssuesFound": 1,
"PerformanceOptimizations": 1,
"KeyFindings": [
"Performance optimization opportunities identified"
],
"NextSteps": [
"Review and prioritize security recommendations",
"Implement generated test cases",
"Address high-priority business logic testing gaps",
"Consider architectural improvements for better testability"
]
}
{
"BusinessLogicInsights": [
{
"Component": "Claude CLI Integration",
"Insight": "Error analyzing business logic: Failed to execute Claude CLI: An error occurred trying to start process \u0027claude\u0027 with working directory \u0027C:\\Production\\Source\\TestAgent\u0027. The system cannot find the file specified.",
"Complexity": "Unknown",
"PotentialIssues": [],
"TestingRecommendations": [],
"Priority": "Medium"
}
],
"TestScenarioSuggestions": [
{
"ScenarioName": "Claude CLI Integration Error",
"Description": "Error generating test scenarios: Failed to execute Claude CLI: An error occurred trying to start process \u0027claude\u0027 with working directory \u0027C:\\Production\\Source\\TestAgent\u0027. The system cannot find the file specified.",
"TestType": "",
"Steps": [],
"ExpectedOutcomes": [],
"Priority": "Medium",
"RequiredData": [],
"Dependencies": []
}
],
"SecurityInsights": [
{
"VulnerabilityType": "Analysis Error",
"Location": "",
"Description": "Error analyzing security: Failed to execute Claude CLI: An error occurred trying to start process \u0027claude\u0027 with working directory \u0027C:\\Production\\Source\\TestAgent\u0027. The system cannot find the file specified.",
"Severity": "Medium",
"Recommendations": [],
"TestingApproaches": []
}
],
"PerformanceInsights": [
{
"Component": "Analysis Error",
"PotentialBottleneck": "Error analyzing performance: Failed to execute Claude CLI: An error occurred trying to start process \u0027claude\u0027 with working directory \u0027C:\\Production\\Source\\TestAgent\u0027. The system cannot find the file specified.",
"Impact": "Unknown",
"OptimizationSuggestions": [],
"TestingStrategies": []
}
],
"ArchitecturalRecommendations": [
{
"Category": "Analysis Error",
"Recommendation": "Error generating architectural recommendations: Failed to execute Claude CLI: An error occurred trying to start process \u0027claude\u0027 with working directory \u0027C:\\Production\\Source\\TestAgent\u0027. The system cannot find the file specified.",
"Rationale": "",
"Impact": "Unknown",
"ImplementationSteps": []
}
],
"GeneratedTestCases": [
{
"TestName": "Claude CLI Integration Error",
"TestCategory": "Error",
"Description": "Error generating test cases: Failed to execute Claude CLI: An error occurred trying to start process \u0027claude\u0027 with working directory \u0027C:\\Production\\Source\\TestAgent\u0027. The system cannot find the file specified.",
"TestCode": "",
"TestData": [],
"ExpectedOutcome": "",
"Reasoning": ""
}
],
"Summary": {
"TotalInsights": 4,
"HighPriorityItems": 0,
"GeneratedTestCases": 1,
"SecurityIssuesFound": 1,
"PerformanceOptimizations": 1,
"KeyFindings": [
"Performance optimization opportunities identified"
],
"NextSteps": [
"Review and prioritize security recommendations",
"Implement generated test cases",
"Address high-priority business logic testing gaps",
"Consider architectural improvements for better testability"
]
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,17 +1,17 @@
{
"ConsistencyTests": [],
"AuthStateComparisons": [],
"ResponsiveTests": [],
"ComponentTests": [],
"Regressions": [],
"Summary": {
"TotalTests": 0,
"PassedTests": 0,
"FailedTests": 0,
"ConsistencyViolations": 0,
"ResponsiveIssues": 0,
"VisualRegressions": 0,
"OverallScore": 0,
"Recommendations": []
}
{
"ConsistencyTests": [],
"AuthStateComparisons": [],
"ResponsiveTests": [],
"ComponentTests": [],
"Regressions": [],
"Summary": {
"TotalTests": 0,
"PassedTests": 0,
"FailedTests": 0,
"ConsistencyViolations": 0,
"ResponsiveIssues": 0,
"VisualRegressions": 0,
"OverallScore": 0,
"Recommendations": []
}
}

View File

@@ -0,0 +1,5 @@
# Netscape HTTP Cookie File
# https://curl.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.
#HttpOnly_localhost FALSE / TRUE 0 .AspNetCore.Cookies CfDJ8OZGzJDh-FtIgYN_FUICYttVzhZrTloOf3_UC3wuYHv-BFtmeiZyIxsOvA5tCMLtiePfvYXM_MLqFmsNmW0xDy8GVX9_Bl91hRrL1YKLr5NhzQhmDptPiVlU_fjP--N9uX30JylwKOaW-ADzURb2naFZZ9pPBRxmrE8CrAYsubMV8bplX0e_3C4hrsNQfu4ldRocjhAu-ejp4r9_ItVEGtNg5DrlRsS4-SFPxooEfGQH3bO4tmanuWGU4ohHeS-AzYGAQmsbKmkt_aymFIxauGOJSfWby7c71DWkAVMfjVrM2EGQrGtmvKE2n2AiMl-OkKOedB8qjpaV1ePMvYuTB_wqL_vPsDF2QWm_Zjf7ePtmCsMf2IrgxbSy8ivszlOpH1NEt-uw0As5mLLCd-FvMxsnR8R2G6-DYTtmzhWzuBeUPYimDaezwKV9ItUaNMXaRBPfzupLH-lLHQshhbT0IK1C90dGaMb2BRwiCCOTmWXeVIBUf1UwDoV6U4sI49x9OUMBqXTNAaHPeJLMBmqn1avDB6EaFuG1rFMbe7aZ-Gct

View File

@@ -0,0 +1,5 @@
# Netscape HTTP Cookie File
# https://curl.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.
#HttpOnly_localhost FALSE / TRUE 0 .AspNetCore.Cookies CfDJ8OZGzJDh-FtIgYN_FUICYtujdAiBug5_f6aE6MiEYMAPppdHVhvj5I4wEkd5wBzJoNu3w9g3sh620KfRwOhNy-kBhw0CfAKUbyqds6__QoBg3Z33PAs4QjFkNlcsyBv040Y_AnONYzEqA-mim441MnRtfUD8zD40sF7EtdqYCYD1BhMBdJuIuFEHz7wr9V3yXSzrUOx1eOcLaFFBFax0z746c2zA4ITJKu6NsfRimMY8OHXaeoC7hWuQoFAfliZKumF_cJ9lKoMjgM74YPIK30WLVUDe6ovvFz-UCvgzeiSzcH4m2EhTupE-xYW5_mFac2efcS21XY0qpu4zzOwmnEB1gjCfoXO3oMxmvxDoeiKZA_G9emlnGxOh7kJq_nV9g8XP-4TCIM8kSWBZByEY1eWdWEAUkxfzrYAnah6qdt2t_weGQVNYrAUW_QPXWjwpmEQEEG5coNin0rinQRB46Kc54KY1Ptszqps1-1aTyqBuNLwgjWizNE-bHpGOp061L7KGh_G4CncX5A2sFwKexzcTcGXgPGVsx56C_66mwdsK

View File

@@ -0,0 +1,52 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore": "Warning"
}
},
"ConnectionStrings": {
"DefaultConnection": "Data Source=/app/data/littleshop.db"
},
"Jwt": {
"Key": "${JWT_SECRET_KEY}",
"Issuer": "LittleShop",
"Audience": "LittleShop-API",
"ExpiryMinutes": 60
},
"BTCPayServer": {
"ServerUrl": "${BTCPAY_SERVER_URL}",
"StoreId": "${BTCPAY_STORE_ID}",
"ApiKey": "${BTCPAY_API_KEY}",
"WebhookSecret": "${BTCPAY_WEBHOOK_SECRET}"
},
"AllowedHosts": "*",
"Urls": "http://+:8080",
"ForwardedHeaders": {
"ForwardedProtoHeaderName": "X-Forwarded-Proto",
"ForwardedForHeaderName": "X-Forwarded-For",
"ForwardedHostHeaderName": "X-Forwarded-Host"
},
"Serilog": {
"Using": [ "Serilog.Sinks.Console", "Serilog.Sinks.File" ],
"MinimumLevel": "Information",
"WriteTo": [
{
"Name": "Console",
"Args": {
"outputTemplate": "[{Timestamp:yyyy-MM-dd HH:mm:ss} {Level:u3}] {Message:lj} {Properties:j}{NewLine}{Exception}"
}
},
{
"Name": "File",
"Args": {
"path": "/app/logs/littleshop-.log",
"rollingInterval": "Day",
"retainedFileCountLimit": 7,
"outputTemplate": "[{Timestamp:yyyy-MM-dd HH:mm:ss} {Level:u3}] {Message:lj} {Properties:j}{NewLine}{Exception}"
}
}
]
}
}

5
LittleShop/cookies.jar Normal file
View File

@@ -0,0 +1,5 @@
# Netscape HTTP Cookie File
# https://curl.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.
#HttpOnly_localhost FALSE / FALSE 0 .AspNetCore.Cookies CfDJ8OZGzJDh-FtIgYN_FUICYttK4Fe3d9hg4xJyCajITDNsFVFznEskPK0x-W0xKOWT-iXF32mNV5DhJdx7gGYdBqtZlCrYBaQJFz4wCwjSVZl0EIdaxbK_Us9as8rAcV02Q2JijnZwPCgj51-NVCmYQpsq7R6LusUALiAcqMjnsY2jUBkC-yctko7S_aDfol7F7Sasl59PIEhjnb1qtfWrjNkUrfsl09DjYctAjatjChyfpCuloIsXpT46TxMj0YqgnwhTFxtrIkV4OEjnwVJDXAAtsVNG1-fVYxWL4HPAh8gl-hjQyUN4H7IbgYATeRQgeWzIen4G_2VS-uDJNb1QEdVpYI162YV9h1j7NrOYH2BoZZd3x_POuPbzHd0roQiV4k8-EpYfLs4ZCNQ0Zgg-z_2JUXYtSll_aRt4hif_7lRuZu7Mdebbj05hS-Eeh5JES_l1cpSx5VbUNJcJ5KOkgfG21MhkwIck2a6WfEi2bXDnKAfezN7JYGGi4ZG8_l25RZ_ZJItqwzikgwNYMptttvwecidtdxd4Iw13XBs7mDFk

Binary file not shown.

Binary file not shown.

Binary file not shown.

4
LittleShop/new-admin.jar Normal file
View File

@@ -0,0 +1,4 @@
# Netscape HTTP Cookie File
# https://curl.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.

View File

@@ -0,0 +1,4 @@
# Netscape HTTP Cookie File
# https://curl.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.

View File

@@ -0,0 +1,6 @@
# Netscape HTTP Cookie File
# https://curl.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.
#HttpOnly_localhost FALSE / TRUE 0 .AspNetCore.Cookies CfDJ8OZGzJDh-FtIgYN_FUICYtu558CcArg36Tl_znuT-_75X-ugj3mgaFmzvsmNmSuk-_LRD4-yVgmR04FoW0zCSF-qDxEQafV47bjxWweFrPW49IVxAXnZduMltPEHdLRImBSfpR6xFgOKUOXELaVpWE4zjuVnzz39LpuyexpWRUZ-KZnsC1dWLxkHMcILtPtZL3Huy1Z1AuqNHMEJtWHOzhH2L7RmUiFU5TtT1YentCm62syWNhEA7shml6ZBSJE7rAKpIe_EeG7p73PeZR0s3o8dVkuKy49Yun0_QPlp1pfM_lJjHZj71gfnNS3f1u35995CgS82r7vynZh_Qjb4s5IeRHKwzfdx8nstxBI7NsL_qfDewwL_qInhqQzbdq4sol0AnqWndR2wtyHTJa8W_bxBONhpA3uoihqM7lWKbC0XqEjNVuN_CbJEUkl7uhWKQTg4be0NKq6IEXpmmODsYtsn0nPqMoh4pAuZ5WbGCm2fcTMbYdAnpGoFo3l6fbhEH5ENY9Fbz0vDHifanHqYWMpklH8rcAJJo41wNWLJBtTQ
#HttpOnly_localhost FALSE / FALSE 0 .AspNetCore.Mvc.CookieTempDataProvider CfDJ8OZGzJDh-FtIgYN_FUICYtv-vuY6xBtf3s-ZMqG0kjPSLo8jtHRXRqW8X1EWgUWTnytftuC75kdDuae0ryrMS1kAy05X6E-Y7Bg-E-P5e7YxNe8ySuTE6ac_U4qOX-EvDJ3HCBL2hGeUUMbks2qn-pPZazrVjK-VZbVE8QPwTt0L

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

View File

@@ -0,0 +1 @@
test image content