- 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>
300 lines
11 KiB
C#
300 lines
11 KiB
C#
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
|
|
};
|
|
}
|
|
} |