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>
225 lines
7.3 KiB
C#
225 lines
7.3 KiB
C#
using System.Text.Json;
|
|
using Microsoft.AspNetCore.SignalR;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using LittleShop.Data;
|
|
using LittleShop.DTOs;
|
|
using LittleShop.Hubs;
|
|
using LittleShop.Models;
|
|
|
|
namespace LittleShop.Services;
|
|
|
|
public class BotActivityService : IBotActivityService
|
|
{
|
|
private readonly LittleShopContext _context;
|
|
private readonly IHubContext<ActivityHub> _hubContext;
|
|
private readonly ILogger<BotActivityService> _logger;
|
|
|
|
public BotActivityService(
|
|
LittleShopContext context,
|
|
IHubContext<ActivityHub> hubContext,
|
|
ILogger<BotActivityService> logger)
|
|
{
|
|
_context = context;
|
|
_hubContext = hubContext;
|
|
_logger = logger;
|
|
}
|
|
|
|
public async Task<BotActivity> LogActivityAsync(CreateBotActivityDto dto)
|
|
{
|
|
var activity = new BotActivity
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
BotId = dto.BotId,
|
|
SessionIdentifier = dto.SessionIdentifier,
|
|
UserDisplayName = dto.UserDisplayName,
|
|
ActivityType = dto.ActivityType,
|
|
ActivityDescription = dto.ActivityDescription,
|
|
ProductId = dto.ProductId,
|
|
ProductName = dto.ProductName,
|
|
OrderId = dto.OrderId,
|
|
CategoryName = dto.CategoryName,
|
|
Value = dto.Value,
|
|
Quantity = dto.Quantity,
|
|
Platform = dto.Platform,
|
|
DeviceType = dto.DeviceType,
|
|
Location = dto.Location,
|
|
Timestamp = DateTime.UtcNow,
|
|
Metadata = dto.Metadata
|
|
};
|
|
|
|
_context.BotActivities.Add(activity);
|
|
await _context.SaveChangesAsync();
|
|
|
|
// Broadcast the activity to connected clients
|
|
await BroadcastActivityAsync(activity);
|
|
|
|
_logger.LogInformation("Activity logged: {User} - {Type} - {Description}",
|
|
activity.UserDisplayName, activity.ActivityType, activity.ActivityDescription);
|
|
|
|
return activity;
|
|
}
|
|
|
|
public async Task<List<BotActivityDto>> GetRecentActivitiesAsync(int minutesBack = 5)
|
|
{
|
|
var cutoffTime = DateTime.UtcNow.AddMinutes(-minutesBack);
|
|
|
|
var activities = await _context.BotActivities
|
|
.Include(a => a.Bot)
|
|
.Where(a => a.Timestamp >= cutoffTime)
|
|
.OrderByDescending(a => a.Timestamp)
|
|
.Take(100)
|
|
.Select(a => MapToDto(a))
|
|
.ToListAsync();
|
|
|
|
return activities;
|
|
}
|
|
|
|
public async Task<List<BotActivityDto>> GetActivitiesBySessionAsync(string sessionIdentifier)
|
|
{
|
|
var activities = await _context.BotActivities
|
|
.Include(a => a.Bot)
|
|
.Where(a => a.SessionIdentifier == sessionIdentifier)
|
|
.OrderByDescending(a => a.Timestamp)
|
|
.Take(200)
|
|
.Select(a => MapToDto(a))
|
|
.ToListAsync();
|
|
|
|
return activities;
|
|
}
|
|
|
|
public async Task<List<BotActivityDto>> GetActivitiesByBotAsync(Guid botId, int limit = 100)
|
|
{
|
|
var activities = await _context.BotActivities
|
|
.Include(a => a.Bot)
|
|
.Where(a => a.BotId == botId)
|
|
.OrderByDescending(a => a.Timestamp)
|
|
.Take(limit)
|
|
.Select(a => MapToDto(a))
|
|
.ToListAsync();
|
|
|
|
return activities;
|
|
}
|
|
|
|
public async Task<LiveActivitySummaryDto> GetLiveActivitySummaryAsync()
|
|
{
|
|
var fiveMinutesAgo = DateTime.UtcNow.AddMinutes(-5);
|
|
var oneMinuteAgo = DateTime.UtcNow.AddMinutes(-1);
|
|
|
|
var recentActivities = await _context.BotActivities
|
|
.Include(a => a.Bot)
|
|
.Where(a => a.Timestamp >= fiveMinutesAgo)
|
|
.ToListAsync();
|
|
|
|
var activeUsers = recentActivities
|
|
.Where(a => a.Timestamp >= oneMinuteAgo)
|
|
.Select(a => a.SessionIdentifier)
|
|
.Distinct()
|
|
.Count();
|
|
|
|
var activeUserNames = recentActivities
|
|
.Where(a => a.Timestamp >= oneMinuteAgo)
|
|
.Select(a => a.UserDisplayName)
|
|
.Distinct()
|
|
.Take(10)
|
|
.ToList();
|
|
|
|
var productViews = recentActivities
|
|
.Where(a => a.ActivityType == "ViewProduct")
|
|
.Count();
|
|
|
|
var cartsActive = recentActivities
|
|
.Where(a => a.ActivityType == "AddToCart" || a.ActivityType == "UpdateCart")
|
|
.Select(a => a.SessionIdentifier)
|
|
.Distinct()
|
|
.Count();
|
|
|
|
var totalCartValue = recentActivities
|
|
.Where(a => a.ActivityType == "AddToCart" && a.Value.HasValue)
|
|
.Sum(a => a.Value ?? 0);
|
|
|
|
var summary = new LiveActivitySummaryDto
|
|
{
|
|
ActiveUsers = activeUsers,
|
|
TotalActivitiesLast5Min = recentActivities.Count,
|
|
ProductViewsLast5Min = productViews,
|
|
CartsActiveNow = cartsActive,
|
|
TotalValueInCartsNow = totalCartValue,
|
|
ActiveUserNames = activeUserNames,
|
|
RecentActivities = recentActivities
|
|
.OrderByDescending(a => a.Timestamp)
|
|
.Take(20)
|
|
.Select(a => MapToDto(a))
|
|
.ToList()
|
|
};
|
|
|
|
return summary;
|
|
}
|
|
|
|
public async Task<List<BotActivityDto>> GetProductActivitiesAsync(Guid productId, int limit = 50)
|
|
{
|
|
var activities = await _context.BotActivities
|
|
.Include(a => a.Bot)
|
|
.Where(a => a.ProductId == productId)
|
|
.OrderByDescending(a => a.Timestamp)
|
|
.Take(limit)
|
|
.Select(a => MapToDto(a))
|
|
.ToListAsync();
|
|
|
|
return activities;
|
|
}
|
|
|
|
public async Task<Dictionary<string, int>> GetActivityTypeStatsAsync(int hoursBack = 24)
|
|
{
|
|
var cutoffTime = DateTime.UtcNow.AddHours(-hoursBack);
|
|
|
|
var stats = await _context.BotActivities
|
|
.Where(a => a.Timestamp >= cutoffTime)
|
|
.GroupBy(a => a.ActivityType)
|
|
.Select(g => new { Type = g.Key, Count = g.Count() })
|
|
.ToDictionaryAsync(x => x.Type, x => x.Count);
|
|
|
|
return stats;
|
|
}
|
|
|
|
public async Task BroadcastActivityAsync(BotActivity activity)
|
|
{
|
|
try
|
|
{
|
|
var dto = MapToDto(activity);
|
|
await _hubContext.Clients.All.SendAsync("NewActivity", dto);
|
|
|
|
// Also send summary update
|
|
var summary = await GetLiveActivitySummaryAsync();
|
|
await _hubContext.Clients.All.SendAsync("SummaryUpdate", summary);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error broadcasting activity");
|
|
}
|
|
}
|
|
|
|
private static BotActivityDto MapToDto(BotActivity activity)
|
|
{
|
|
return new BotActivityDto
|
|
{
|
|
Id = activity.Id,
|
|
BotId = activity.BotId,
|
|
BotName = activity.Bot?.Name ?? "Unknown Bot",
|
|
SessionIdentifier = activity.SessionIdentifier,
|
|
UserDisplayName = activity.UserDisplayName,
|
|
ActivityType = activity.ActivityType,
|
|
ActivityDescription = activity.ActivityDescription,
|
|
ProductId = activity.ProductId,
|
|
ProductName = activity.ProductName,
|
|
OrderId = activity.OrderId,
|
|
CategoryName = activity.CategoryName,
|
|
Value = activity.Value,
|
|
Quantity = activity.Quantity,
|
|
Platform = activity.Platform,
|
|
DeviceType = activity.DeviceType,
|
|
Location = activity.Location,
|
|
Timestamp = activity.Timestamp,
|
|
Metadata = activity.Metadata
|
|
};
|
|
}
|
|
} |