Implement product multi-buys and variants system

Major restructuring of product variations:
- Renamed ProductVariation to ProductMultiBuy for quantity-based pricing (e.g., "3 for £25")
- Added new ProductVariant model for string-based options (colors, flavors)
- Complete separation of multi-buy pricing from variant selection

Features implemented:
- Multi-buy deals with automatic price-per-unit calculation
- Product variants for colors/flavors/sizes with stock tracking
- TeleBot checkout supports both multi-buys and variant selection
- Shopping cart correctly calculates multi-buy bundle prices
- Order system tracks selected variants and multi-buy choices
- Real-time bot activity monitoring with SignalR
- Public bot directory page with QR codes for Telegram launch
- Admin dashboard shows multi-buy and variant metrics

Technical changes:
- Updated all DTOs, services, and controllers
- Fixed cart total calculation for multi-buy bundles
- Comprehensive test coverage for new functionality
- All existing tests passing with new features

Database changes:
- Migrated ProductVariations to ProductMultiBuys
- Added ProductVariants table
- Updated OrderItems to track variants

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-09-21 00:30:12 +01:00
parent 7683b7dfe5
commit 034b8facee
46 changed files with 3190 additions and 332 deletions

View File

@@ -0,0 +1,152 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using QRCoder;
using LittleShop.Data;
using LittleShop.DTOs;
using LittleShop.Enums;
namespace LittleShop.Controllers;
public class BotDirectoryController : Controller
{
private readonly LittleShopContext _context;
private readonly ILogger<BotDirectoryController> _logger;
public BotDirectoryController(LittleShopContext context, ILogger<BotDirectoryController> logger)
{
_context = context;
_logger = logger;
}
// GET: /bots
[HttpGet("/bots")]
public async Task<IActionResult> Index()
{
var bots = await _context.Bots
.Where(b => b.Status == BotStatus.Active && b.IsActive)
.OrderBy(b => b.Name)
.Select(b => new BotDirectoryDto
{
Id = b.Id,
Name = b.Name,
Description = b.Description,
Type = b.Type.ToString(),
PersonalityName = b.PersonalityName,
TelegramUsername = b.Settings.Contains("\"telegram_username\":")
? ExtractTelegramUsername(b.Settings)
: b.PlatformUsername,
LastSeenAt = b.LastSeenAt,
CreatedAt = b.CreatedAt
})
.ToListAsync();
return View(bots);
}
// GET: /bots/qr/{id}
[HttpGet("/bots/qr/{id}")]
public async Task<IActionResult> GetQRCode(Guid id)
{
var bot = await _context.Bots
.Where(b => b.Id == id && b.Status == BotStatus.Active)
.FirstOrDefaultAsync();
if (bot == null)
{
return NotFound();
}
// Extract Telegram username from settings JSON or use platform username
string? telegramUsername = ExtractTelegramUsername(bot.Settings) ?? bot.PlatformUsername;
if (string.IsNullOrEmpty(telegramUsername))
{
return NotFound("Bot does not have a Telegram username configured");
}
// Generate Telegram deep link
string telegramUrl = $"https://t.me/{telegramUsername}";
// Generate QR code
using (QRCodeGenerator qrGenerator = new QRCodeGenerator())
{
QRCodeData qrCodeData = qrGenerator.CreateQrCode(telegramUrl, QRCodeGenerator.ECCLevel.Q);
using (PngByteQRCode qrCode = new PngByteQRCode(qrCodeData))
{
byte[] qrCodeImage = qrCode.GetGraphic(20);
return File(qrCodeImage, "image/png");
}
}
}
private static string? ExtractTelegramUsername(string settings)
{
try
{
var json = System.Text.Json.JsonDocument.Parse(settings);
if (json.RootElement.TryGetProperty("telegram_username", out var username))
{
return username.GetString();
}
}
catch
{
// JSON parsing failed
}
return null;
}
}
public class BotDirectoryDto
{
public Guid Id { get; set; }
public string Name { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public string Type { get; set; } = string.Empty;
public string PersonalityName { get; set; } = string.Empty;
public string? TelegramUsername { get; set; }
public DateTime? LastSeenAt { get; set; }
public DateTime CreatedAt { get; set; }
public string GetBadgeColor()
{
return Type.ToLower() switch
{
"sales" => "primary",
"support" => "success",
"marketing" => "warning",
"technical" => "info",
_ => "secondary"
};
}
public string GetStatusBadge()
{
if (!LastSeenAt.HasValue)
return "Never Connected";
var timeSinceLastSeen = DateTime.UtcNow - LastSeenAt.Value;
if (timeSinceLastSeen.TotalMinutes < 5)
return "Online";
else if (timeSinceLastSeen.TotalHours < 1)
return "Recently Active";
else if (timeSinceLastSeen.TotalDays < 1)
return "Active Today";
else
return $"Last seen {timeSinceLastSeen.Days} days ago";
}
public string GetStatusColor()
{
if (!LastSeenAt.HasValue)
return "secondary";
var timeSinceLastSeen = DateTime.UtcNow - LastSeenAt.Value;
if (timeSinceLastSeen.TotalMinutes < 5)
return "success";
else if (timeSinceLastSeen.TotalHours < 1)
return "info";
else
return "secondary";
}
}

View File

@@ -15,13 +15,13 @@ public class DevController : ControllerBase
_productService = productService;
}
[HttpPost("variations")]
public async Task<ActionResult<ProductVariationDto>> CreateVariationForDev(CreateProductVariationDto createVariationDto)
[HttpPost("multibuys")]
public async Task<ActionResult<ProductMultiBuyDto>> CreateMultiBuyForDev(CreateProductMultiBuyDto createMultiBuyDto)
{
try
{
var variation = await _productService.CreateProductVariationAsync(createVariationDto);
return CreatedAtAction("GetProductVariation", "ProductVariations", new { id = variation.Id }, variation);
var multiBuy = await _productService.CreateProductMultiBuyAsync(createMultiBuyDto);
return CreatedAtAction("GetProductMultiBuy", "ProductMultiBuys", new { id = multiBuy.Id }, multiBuy);
}
catch (ArgumentException ex)
{
@@ -37,7 +37,8 @@ public class DevController : ControllerBase
id = p.Id,
name = p.Name,
price = p.Price,
variationCount = p.Variations.Count
multiBuyCount = p.MultiBuys.Count,
variantCount = p.Variants.Count
});
return Ok(result);
}

View File

@@ -0,0 +1,81 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using LittleShop.DTOs;
using LittleShop.Services;
namespace LittleShop.Controllers;
[ApiController]
[Route("api/[controller]")]
[Authorize(AuthenticationSchemes = "Bearer")]
public class ProductMultiBuysController : ControllerBase
{
private readonly IProductService _productService;
public ProductMultiBuysController(IProductService productService)
{
_productService = productService;
}
[HttpGet("product/{productId}")]
public async Task<ActionResult<IEnumerable<ProductMultiBuyDto>>> GetProductMultiBuys(Guid productId)
{
var multiBuys = await _productService.GetProductMultiBuysAsync(productId);
return Ok(multiBuys);
}
[HttpGet("{id}")]
public async Task<ActionResult<ProductMultiBuyDto>> GetProductMultiBuy(Guid id)
{
var multiBuy = await _productService.GetProductMultiBuyByIdAsync(id);
if (multiBuy == null)
return NotFound();
return Ok(multiBuy);
}
[HttpPost]
[Authorize(Roles = "Admin")]
public async Task<ActionResult<ProductMultiBuyDto>> CreateProductMultiBuy(CreateProductMultiBuyDto createMultiBuyDto)
{
try
{
var multiBuy = await _productService.CreateProductMultiBuyAsync(createMultiBuyDto);
return CreatedAtAction(nameof(GetProductMultiBuy), new { id = multiBuy.Id }, multiBuy);
}
catch (ArgumentException ex)
{
return BadRequest(ex.Message);
}
}
[HttpPut("{id}")]
[Authorize(Roles = "Admin")]
public async Task<IActionResult> UpdateProductMultiBuy(Guid id, UpdateProductMultiBuyDto updateMultiBuyDto)
{
try
{
await _productService.UpdateProductMultiBuyAsync(id, updateMultiBuyDto);
return NoContent();
}
catch (ArgumentException ex)
{
return BadRequest(ex.Message);
}
}
[HttpDelete("{id}")]
[Authorize(Roles = "Admin")]
public async Task<IActionResult> DeleteProductMultiBuy(Guid id)
{
try
{
await _productService.DeleteProductMultiBuyAsync(id);
return NoContent();
}
catch (ArgumentException ex)
{
return BadRequest(ex.Message);
}
}
}

View File

@@ -0,0 +1,81 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using LittleShop.DTOs;
using LittleShop.Services;
namespace LittleShop.Controllers;
[ApiController]
[Route("api/[controller]")]
[Authorize(AuthenticationSchemes = "Bearer")]
public class ProductVariantsController : ControllerBase
{
private readonly IProductService _productService;
public ProductVariantsController(IProductService productService)
{
_productService = productService;
}
[HttpGet("product/{productId}")]
public async Task<ActionResult<IEnumerable<ProductVariantDto>>> GetProductVariants(Guid productId)
{
var variants = await _productService.GetProductVariantsAsync(productId);
return Ok(variants);
}
[HttpGet("{id}")]
public async Task<ActionResult<ProductVariantDto>> GetProductVariant(Guid id)
{
var variant = await _productService.GetProductVariantByIdAsync(id);
if (variant == null)
return NotFound();
return Ok(variant);
}
[HttpPost]
[Authorize(Roles = "Admin")]
public async Task<ActionResult<ProductVariantDto>> CreateProductVariant(CreateProductVariantDto createVariantDto)
{
try
{
var variant = await _productService.CreateProductVariantAsync(createVariantDto);
return CreatedAtAction(nameof(GetProductVariant), new { id = variant.Id }, variant);
}
catch (ArgumentException ex)
{
return BadRequest(ex.Message);
}
}
[HttpPut("{id}")]
[Authorize(Roles = "Admin")]
public async Task<IActionResult> UpdateProductVariant(Guid id, UpdateProductVariantDto updateVariantDto)
{
try
{
await _productService.UpdateProductVariantAsync(id, updateVariantDto);
return NoContent();
}
catch (ArgumentException ex)
{
return BadRequest(ex.Message);
}
}
[HttpDelete("{id}")]
[Authorize(Roles = "Admin")]
public async Task<IActionResult> DeleteProductVariant(Guid id)
{
try
{
await _productService.DeleteProductVariantAsync(id);
return NoContent();
}
catch (ArgumentException ex)
{
return BadRequest(ex.Message);
}
}
}

View File

@@ -1,73 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using LittleShop.DTOs;
using LittleShop.Services;
namespace LittleShop.Controllers;
[ApiController]
[Route("api/[controller]")]
[Authorize(AuthenticationSchemes = "Bearer")]
public class ProductVariationsController : ControllerBase
{
private readonly IProductService _productService;
public ProductVariationsController(IProductService productService)
{
_productService = productService;
}
[HttpGet("product/{productId}")]
public async Task<ActionResult<IEnumerable<ProductVariationDto>>> GetProductVariations(Guid productId)
{
var variations = await _productService.GetProductVariationsAsync(productId);
return Ok(variations);
}
[HttpGet("{id}")]
public async Task<ActionResult<ProductVariationDto>> GetProductVariation(Guid id)
{
var variation = await _productService.GetProductVariationByIdAsync(id);
if (variation == null)
return NotFound();
return Ok(variation);
}
[HttpPost]
[Authorize(Roles = "Admin")]
public async Task<ActionResult<ProductVariationDto>> CreateProductVariation(CreateProductVariationDto createVariationDto)
{
try
{
var variation = await _productService.CreateProductVariationAsync(createVariationDto);
return CreatedAtAction(nameof(GetProductVariation), new { id = variation.Id }, variation);
}
catch (ArgumentException ex)
{
return BadRequest(ex.Message);
}
}
[HttpPut("{id}")]
[Authorize(Roles = "Admin")]
public async Task<IActionResult> UpdateProductVariation(Guid id, UpdateProductVariationDto updateVariationDto)
{
var success = await _productService.UpdateProductVariationAsync(id, updateVariationDto);
if (!success)
return NotFound();
return NoContent();
}
[HttpDelete("{id}")]
[Authorize(Roles = "Admin")]
public async Task<IActionResult> DeleteProductVariation(Guid id)
{
var success = await _productService.DeleteProductVariationAsync(id);
if (!success)
return NotFound();
return NoContent();
}
}