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:
185
LittleShop/Areas/Admin/Controllers/ReviewsController.cs
Normal file
185
LittleShop/Areas/Admin/Controllers/ReviewsController.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
149
LittleShop/Areas/Admin/Views/Reviews/Details.cshtml
Normal file
149
LittleShop/Areas/Admin/Views/Reviews/Details.cshtml
Normal 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>
|
||||
136
LittleShop/Areas/Admin/Views/Reviews/Index.cshtml
Normal file
136
LittleShop/Areas/Admin/Views/Reviews/Index.cshtml
Normal 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>
|
||||
@@ -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>
|
||||
236
LittleShop/Controllers/ReviewsController.cs
Normal file
236
LittleShop/Controllers/ReviewsController.cs
Normal 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" });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
86
LittleShop/DTOs/ReviewDto.cs
Normal file
86
LittleShop/DTOs/ReviewDto.cs
Normal 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; }
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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!;
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
45
LittleShop/Models/Review.cs
Normal file
45
LittleShop/Models/Review.cs
Normal 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; }
|
||||
}
|
||||
@@ -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 { }
|
||||
@@ -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"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
|
||||
300
LittleShop/Services/ReviewService.cs
Normal file
300
LittleShop/Services/ReviewService.cs
Normal 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
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -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": []
|
||||
}
|
||||
}
|
||||
5
LittleShop/admin-cookies.jar
Normal file
5
LittleShop/admin-cookies.jar
Normal 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
|
||||
5
LittleShop/admin-test.jar
Normal file
5
LittleShop/admin-test.jar
Normal 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
|
||||
52
LittleShop/appsettings.Production.json
Normal file
52
LittleShop/appsettings.Production.json
Normal 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
5
LittleShop/cookies.jar
Normal 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.
BIN
LittleShop/littleshop.db.backup
Normal file
BIN
LittleShop/littleshop.db.backup
Normal file
Binary file not shown.
4
LittleShop/new-admin.jar
Normal file
4
LittleShop/new-admin.jar
Normal 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.
|
||||
|
||||
4
LittleShop/test-new-admin.jar
Normal file
4
LittleShop/test-new-admin.jar
Normal 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.
|
||||
|
||||
6
LittleShop/test-session.jar
Normal file
6
LittleShop/test-session.jar
Normal 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 |
@@ -0,0 +1 @@
|
||||
test image content
|
||||
Reference in New Issue
Block a user