using BirthList.Domain.Entities; using BirthList.Infrastructure.Persistence; using BirthList.Web.Data; using Microsoft.EntityFrameworkCore; using System.Security.Cryptography; using System.Text; using System.Text.Json; namespace BirthList.Web.Features.Registries; internal sealed class RegistryService(RegistryDbContext registryDbContext, ApplicationDbContext applicationDbContext) { private const string DefaultCategoryName = "General"; public async Task CreateRegistryAsync(string userId, RegistryCreateModel model, CancellationToken cancellationToken) { ArgumentException.ThrowIfNullOrWhiteSpace(userId); ArgumentNullException.ThrowIfNull(model); if (string.IsNullOrWhiteSpace(model.Title)) { throw new ArgumentException("Title is required.", nameof(model)); } var publicCode = await CreateUniquePublicCodeAsync(cancellationToken).ConfigureAwait(false); var registry = new Registry { Id = Guid.NewGuid(), Title = model.Title.Trim(), RegistryType = model.RegistryType, ThemeKey = string.IsNullOrWhiteSpace(model.ThemeKey) ? "default" : model.ThemeKey.Trim(), PublicLinkCode = publicCode, CreatedAtUtc = DateTimeOffset.UtcNow, CurrencyCode = "EUR" }; registryDbContext.Registries.Add(registry); registryDbContext.RegistryAdmins.Add(new RegistryAdmin { RegistryId = registry.Id, UserId = userId, AddedAtUtc = DateTimeOffset.UtcNow }); registryDbContext.RegistrySettings.Add(new RegistrySettings { RegistryId = registry.Id }); registryDbContext.RegistryItemCategories.Add(new RegistryItemCategory { Id = Guid.NewGuid(), RegistryId = registry.Id, Name = DefaultCategoryName, SortOrder = 0, CreatedAtUtc = DateTimeOffset.UtcNow }); await registryDbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); return registry.Id; } public async Task> GetVisitedRegistriesAsync(string userId, CancellationToken cancellationToken) { ArgumentException.ThrowIfNullOrWhiteSpace(userId); return await registryDbContext.RegistryVisits .Where(x => x.UserId == userId) .OrderByDescending(x => x.LastVisitedAtUtc) .Select(x => new RegistrySummaryViewModel { Id = x.RegistryId, Title = x.Registry.Title, PublicLinkCode = x.Registry.PublicLinkCode, RegistryType = x.Registry.RegistryType, ThemeKey = x.Registry.ThemeKey }) .ToListAsync(cancellationToken) .ConfigureAwait(false); } public async Task> GetAdminRegistriesAsync(string userId, CancellationToken cancellationToken) { ArgumentException.ThrowIfNullOrWhiteSpace(userId); return await registryDbContext.RegistryAdmins .Where(x => x.UserId == userId) .OrderByDescending(x => x.AddedAtUtc) .Select(x => new RegistrySummaryViewModel { Id = x.RegistryId, Title = x.Registry.Title, PublicLinkCode = x.Registry.PublicLinkCode, RegistryType = x.Registry.RegistryType, ThemeKey = x.Registry.ThemeKey }) .ToListAsync(cancellationToken) .ConfigureAwait(false); } public async Task> GetRegistryAdminsAsync(Guid registryId, CancellationToken cancellationToken) { var admins = await registryDbContext.RegistryAdmins .Where(x => x.RegistryId == registryId) .OrderBy(x => x.AddedAtUtc) .Select(x => x.UserId) .ToListAsync(cancellationToken) .ConfigureAwait(false); var users = await applicationDbContext.Users .Where(x => admins.Contains(x.Id)) .Select(x => new { x.Id, x.Email, x.FirstName, x.LastName }) .ToListAsync(cancellationToken) .ConfigureAwait(false); var usersById = users.ToDictionary( x => x.Id, x => BuildUserDisplayName(x.FirstName, x.LastName, x.Email, x.Id)); return admins .Select(userId => new RegistryAdminDisplayModel { UserId = userId, DisplayName = usersById.TryGetValue(userId, out var displayName) ? displayName : userId }) .ToList(); } public async Task GetPublicRegistryByCodeAsync(string code, string? userId, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(code)) { return null; } var registry = await registryDbContext.Registries .Include(x => x.Items) .ThenInclude(x => x.Category) .Include(x => x.ItemCategories) .Include(x => x.Visits) .Include(x => x.Admins) .FirstOrDefaultAsync(x => x.PublicLinkCode == code.Trim(), cancellationToken) .ConfigureAwait(false); if (registry is null) { return null; } var settings = await registryDbContext.RegistrySettings .FirstOrDefaultAsync(x => x.RegistryId == registry.Id, cancellationToken) .ConfigureAwait(false); if (!string.IsNullOrWhiteSpace(userId)) { var existingVisit = await registryDbContext.RegistryVisits .FirstOrDefaultAsync(x => x.RegistryId == registry.Id && x.UserId == userId, cancellationToken) .ConfigureAwait(false); if (existingVisit is null) { registryDbContext.RegistryVisits.Add(new RegistryVisit { RegistryId = registry.Id, UserId = userId, LastVisitedAtUtc = DateTimeOffset.UtcNow }); } else { existingVisit.LastVisitedAtUtc = DateTimeOffset.UtcNow; } await registryDbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); } var itemIds = registry.Items.Select(x => x.Id).ToList(); var userIds = new HashSet(); var purchases = await registryDbContext.ItemPurchases .Where(x => itemIds.Contains(x.RegistryItemId)) .ToListAsync(cancellationToken) .ConfigureAwait(false); var contributions = await registryDbContext.ItemContributions .Where(x => itemIds.Contains(x.RegistryItemId)) .ToListAsync(cancellationToken) .ConfigureAwait(false); foreach (var purchase in purchases) { userIds.Add(purchase.UserId); } foreach (var contribution in contributions) { userIds.Add(contribution.UserId); } var users = await applicationDbContext.Users .Where(x => userIds.Contains(x.Id)) .Select(x => new { x.Id, x.Email, x.FirstName, x.LastName }) .ToListAsync(cancellationToken) .ConfigureAwait(false); var usersById = users.ToDictionary( x => x.Id, x => BuildUserDisplayName(x.FirstName, x.LastName, x.Email, x.Id)); var purchasesByItemId = purchases .GroupBy(x => x.RegistryItemId) .ToDictionary(g => g.Key, g => g.ToList()); var contributionsByItemId = contributions .GroupBy(x => x.RegistryItemId) .ToDictionary(g => g.Key, g => g.ToList()); var isAdmin = !string.IsNullOrWhiteSpace(userId) && registry.Admins.Any(x => x.UserId == userId); var mappedItems = registry.Items .OrderBy(x => x.Category.SortOrder) .ThenBy(x => x.SortOrder) .ThenBy(x => x.Name) .Select(x => new RegistryPublicItemViewModel { Id = x.Id, CategoryId = x.CategoryId, CategoryName = x.Category.Name, CategorySortOrder = x.Category.SortOrder, SortOrder = x.SortOrder, Name = x.Name, PictureUrl = x.PictureUrl, ProductUrl = x.ProductUrl, Description = x.Description, PriceAmount = x.PriceAmount, CurrencyCode = x.CurrencyCode, DesiredQuantity = x.DesiredQuantity, PurchasedQuantity = x.PurchasedQuantity, ParticipationAllowed = x.ParticipationAllowed, ParticipationTargetAmount = x.ParticipationTargetAmount, MoneyFulfilledAmount = x.MoneyFulfilledAmount, PreferSecondHand = x.PreferSecondHand, IsGiven = x.IsGiven, CanViewPurchasers = isAdmin || (purchasesByItemId.TryGetValue(x.Id, out var itemPurchases) && itemPurchases.Any(p => p.UserId == userId)), Purchasers = purchasesByItemId.TryGetValue(x.Id, out var itemPurchases2) ? itemPurchases2 .GroupBy(p => p.UserId) .Select(g => new ItemContributorViewModel { UserId = g.Key, DisplayName = usersById.TryGetValue(g.Key, out var displayName) ? displayName : g.Key, Quantity = g.Sum(p => p.Quantity) }) .ToList() : [], Contributors = contributionsByItemId.TryGetValue(x.Id, out var itemContributions) ? itemContributions .GroupBy(c => c.UserId) .Select(g => new ItemContributorViewModel { UserId = g.Key, DisplayName = usersById.TryGetValue(g.Key, out var displayName) ? displayName : g.Key, Amount = g.Sum(c => c.Amount) }) .ToList() : [], CurrentUserPurchasedQuantity = purchasesByItemId.TryGetValue(x.Id, out var itemPurchases3) ? itemPurchases3 .Where(p => p.UserId == userId) .Sum(p => p.Quantity) : 0 }) .ToList(); var mappedCategories = registry.ItemCategories .OrderBy(x => x.SortOrder) .ThenBy(x => x.Name) .Select(category => new RegistryPublicCategoryViewModel { Id = category.Id, Name = category.Name, SortOrder = category.SortOrder, Items = mappedItems .Where(item => item.CategoryId == category.Id) .OrderBy(item => item.SortOrder) .ThenBy(item => item.Name) .ToList() }) .ToList(); return new RegistryPublicViewModel { Id = registry.Id, Title = registry.Title, PublicLinkCode = registry.PublicLinkCode, BabyName = registry.BabyName, HeaderContentHtml = registry.HeaderContentHtml, ShippingAddress = registry.ShippingAddress, CurrencyCode = registry.CurrencyCode, RegistryType = registry.RegistryType, ThemeKey = registry.ThemeKey, BankAccountIban = settings?.BankAccountIban, BankAccountBic = settings?.BankAccountBic, BankAccountDisplayName = settings?.BankAccountDisplayName, ShowBankAccountName = settings?.ShowBankAccountName ?? false, ContributionQrCodeUrl = settings?.ContributionQrCodeUrl, ContributionAmountQrCodes = ParseContributionAmountQrCodes(settings?.ContributionAmountQrCodesJson), CurrentUserId = userId, IsAdmin = isAdmin, Categories = mappedCategories, Items = mappedItems }; } public async Task GetRegistrySettingsAsync(Guid registryId, CancellationToken cancellationToken) { var registry = await registryDbContext.Registries .FirstOrDefaultAsync(x => x.Id == registryId, cancellationToken) .ConfigureAwait(false); if (registry is null) { return null; } var settings = await registryDbContext.RegistrySettings .FirstOrDefaultAsync(x => x.RegistryId == registryId, cancellationToken) .ConfigureAwait(false); return new RegistrySettingsEditModel { BabyName = registry.BabyName, BirthDate = registry.BirthDate, HeaderContentHtml = registry.HeaderContentHtml, ShippingAddress = registry.ShippingAddress, CurrencyCode = registry.CurrencyCode, ThemeKey = registry.ThemeKey, BankAccountIban = settings?.BankAccountIban, BankAccountBic = settings?.BankAccountBic, BankAccountDisplayName = settings?.BankAccountDisplayName, ShowBankAccountName = settings?.ShowBankAccountName ?? false, ContributionQrCodeUrl = settings?.ContributionQrCodeUrl, ContributionAmountQrCodes = ParseContributionAmountQrCodes(settings?.ContributionAmountQrCodesJson).ToList() }; } public async Task UpdateRegistrySettingsAsync(Guid registryId, RegistrySettingsEditModel model, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(model); var registry = await registryDbContext.Registries .FirstOrDefaultAsync(x => x.Id == registryId, cancellationToken) .ConfigureAwait(false) ?? throw new InvalidOperationException("Registry not found."); var settings = await registryDbContext.RegistrySettings .FirstOrDefaultAsync(x => x.RegistryId == registryId, cancellationToken) .ConfigureAwait(false); if (settings is null) { settings = new RegistrySettings { RegistryId = registryId }; registryDbContext.RegistrySettings.Add(settings); } registry.BabyName = string.IsNullOrWhiteSpace(model.BabyName) ? null : model.BabyName.Trim(); registry.BirthDate = model.BirthDate; registry.HeaderContentHtml = string.IsNullOrWhiteSpace(model.HeaderContentHtml) ? null : model.HeaderContentHtml; registry.ShippingAddress = string.IsNullOrWhiteSpace(model.ShippingAddress) ? null : model.ShippingAddress.Trim(); registry.CurrencyCode = string.IsNullOrWhiteSpace(model.CurrencyCode) ? "EUR" : model.CurrencyCode.Trim().ToUpperInvariant(); registry.ThemeKey = string.IsNullOrWhiteSpace(model.ThemeKey) ? "default" : model.ThemeKey.Trim(); settings.BankAccountIban = string.IsNullOrWhiteSpace(model.BankAccountIban) ? null : model.BankAccountIban.Trim(); settings.BankAccountBic = string.IsNullOrWhiteSpace(model.BankAccountBic) ? null : model.BankAccountBic.Trim(); settings.BankAccountDisplayName = string.IsNullOrWhiteSpace(model.BankAccountDisplayName) ? null : model.BankAccountDisplayName.Trim(); settings.ShowBankAccountName = model.ShowBankAccountName; settings.ContributionQrCodeUrl = string.IsNullOrWhiteSpace(model.ContributionQrCodeUrl) ? null : model.ContributionQrCodeUrl.Trim(); settings.ContributionAmountQrCodesJson = SerializeContributionAmountQrCodes(model.ContributionAmountQrCodes); await registryDbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); } private static IReadOnlyList ParseContributionAmountQrCodes(string? json) { if (string.IsNullOrWhiteSpace(json)) { return []; } try { var items = JsonSerializer.Deserialize>(json); if (items is null) { return []; } return items .Where(x => x.Amount > 0 && !string.IsNullOrWhiteSpace(x.QrCodeUrl)) .Select(x => new ContributionAmountQrCodeModel { Amount = x.Amount, QrCodeUrl = x.QrCodeUrl.Trim() }) .OrderBy(x => x.Amount) .ToList(); } catch (JsonException) { return []; } } private static string? SerializeContributionAmountQrCodes(IEnumerable? amountQrCodes) { if (amountQrCodes is null) { return null; } var normalized = amountQrCodes .Where(x => x.Amount > 0 && !string.IsNullOrWhiteSpace(x.QrCodeUrl)) .Select(x => new ContributionAmountQrCodeModel { Amount = x.Amount, QrCodeUrl = x.QrCodeUrl.Trim() }) .OrderBy(x => x.Amount) .ToList(); if (normalized.Count == 0) { return null; } return JsonSerializer.Serialize(normalized); } public async Task> GetRegistryItemsAsync(Guid registryId, CancellationToken cancellationToken) { var defaultCategory = await EnsureDefaultCategoryAsync(registryId, cancellationToken).ConfigureAwait(false); var items = await registryDbContext.RegistryItems .Where(x => x.RegistryId == registryId) .Include(x => x.Category) .OrderBy(x => x.Category.SortOrder) .ThenBy(x => x.SortOrder) .ThenBy(x => x.Name) .ToListAsync(cancellationToken) .ConfigureAwait(false); foreach (var item in items.Where(x => x.CategoryId == Guid.Empty)) { item.CategoryId = defaultCategory.Id; } var itemIds = items.Select(x => x.Id).ToList(); var userIds = new HashSet(); var purchases = await registryDbContext.ItemPurchases .Where(x => itemIds.Contains(x.RegistryItemId)) .ToListAsync(cancellationToken) .ConfigureAwait(false); var contributions = await registryDbContext.ItemContributions .Where(x => itemIds.Contains(x.RegistryItemId)) .ToListAsync(cancellationToken) .ConfigureAwait(false); foreach (var purchase in purchases) { userIds.Add(purchase.UserId); } foreach (var contribution in contributions) { userIds.Add(contribution.UserId); } var users = await applicationDbContext.Users .Where(x => userIds.Contains(x.Id)) .Select(x => new { x.Id, x.Email, x.FirstName, x.LastName }) .ToListAsync(cancellationToken) .ConfigureAwait(false); var usersById = users.ToDictionary( x => x.Id, x => BuildUserDisplayName(x.FirstName, x.LastName, x.Email, x.Id)); var purchasesByItemId = purchases .GroupBy(x => x.RegistryItemId) .ToDictionary(g => g.Key, g => g.ToList()); var contributionsByItemId = contributions .GroupBy(x => x.RegistryItemId) .ToDictionary(g => g.Key, g => g.ToList()); return items .Select(x => new RegistryItemEditModel { Id = x.Id, CategoryId = x.CategoryId, Name = x.Name, PictureUrl = x.PictureUrl, ProductUrl = x.ProductUrl, Description = x.Description, PriceAmount = x.PriceAmount, CurrencyCode = x.CurrencyCode, DesiredQuantity = x.DesiredQuantity, ParticipationAllowed = x.ParticipationAllowed, ParticipationTargetAmount = x.ParticipationTargetAmount, PreferSecondHand = x.PreferSecondHand, IsGiven = x.IsGiven, Purchasers = purchasesByItemId.TryGetValue(x.Id, out var itemPurchases) ? itemPurchases .GroupBy(p => p.UserId) .Select(g => new ItemContributorViewModel { UserId = g.Key, DisplayName = usersById.TryGetValue(g.Key, out var displayName) ? displayName : g.Key, Quantity = g.Sum(p => p.Quantity) }) .ToList() : [], Contributors = contributionsByItemId.TryGetValue(x.Id, out var itemContributions) ? itemContributions .GroupBy(c => c.UserId) .Select(g => new ItemContributorViewModel { UserId = g.Key, DisplayName = usersById.TryGetValue(g.Key, out var displayName) ? displayName : g.Key, Amount = g.Sum(c => c.Amount) }) .ToList() : [] }) .ToList(); } public async Task GetRegistryItemAsync(Guid registryId, Guid itemId, CancellationToken cancellationToken) { var item = await registryDbContext.RegistryItems .FirstOrDefaultAsync(x => x.RegistryId == registryId && x.Id == itemId, cancellationToken) .ConfigureAwait(false); if (item is null) { return null; } var purchases = await registryDbContext.ItemPurchases .Where(x => x.RegistryItemId == itemId) .ToListAsync(cancellationToken) .ConfigureAwait(false); var contributions = await registryDbContext.ItemContributions .Where(x => x.RegistryItemId == itemId) .ToListAsync(cancellationToken) .ConfigureAwait(false); var userIds = new HashSet(); foreach (var purchase in purchases) { userIds.Add(purchase.UserId); } foreach (var contribution in contributions) { userIds.Add(contribution.UserId); } var users = await applicationDbContext.Users .Where(x => userIds.Contains(x.Id)) .Select(x => new { x.Id, x.Email, x.FirstName, x.LastName }) .ToListAsync(cancellationToken) .ConfigureAwait(false); var usersById = users.ToDictionary( x => x.Id, x => BuildUserDisplayName(x.FirstName, x.LastName, x.Email, x.Id)); return new RegistryItemEditModel { Id = item.Id, CategoryId = item.CategoryId, Name = item.Name, PictureUrl = item.PictureUrl, ProductUrl = item.ProductUrl, Description = item.Description, PriceAmount = item.PriceAmount, CurrencyCode = item.CurrencyCode, DesiredQuantity = item.DesiredQuantity, ParticipationAllowed = item.ParticipationAllowed, ParticipationTargetAmount = item.ParticipationTargetAmount, PreferSecondHand = item.PreferSecondHand, IsGiven = item.IsGiven, Purchasers = purchases .GroupBy(p => p.UserId) .Select(g => new ItemContributorViewModel { UserId = g.Key, DisplayName = usersById.TryGetValue(g.Key, out var displayName) ? displayName : g.Key, Quantity = g.Sum(p => p.Quantity) }) .ToList(), Contributors = contributions .GroupBy(c => c.UserId) .Select(g => new ItemContributorViewModel { UserId = g.Key, DisplayName = usersById.TryGetValue(g.Key, out var displayName) ? displayName : g.Key, Amount = g.Sum(c => c.Amount) }) .ToList() }; } public async Task UpsertRegistryItemAsync(Guid registryId, RegistryItemEditModel model, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(model); if (string.IsNullOrWhiteSpace(model.Name)) { throw new ArgumentException("Item name is required.", nameof(model)); } var defaultCategory = await EnsureDefaultCategoryAsync(registryId, cancellationToken).ConfigureAwait(false); RegistryItem entity; if (model.Id is { } itemId) { entity = await registryDbContext.RegistryItems .FirstOrDefaultAsync(x => x.RegistryId == registryId && x.Id == itemId, cancellationToken) .ConfigureAwait(false) ?? throw new InvalidOperationException("Item not found."); } else { var categoryId = model.CategoryId ?? defaultCategory.Id; var nextSortOrder = await registryDbContext.RegistryItems .Where(x => x.RegistryId == registryId && x.CategoryId == categoryId) .Select(x => (int?)x.SortOrder) .MaxAsync(cancellationToken) .ConfigureAwait(false); entity = new RegistryItem { Id = Guid.NewGuid(), RegistryId = registryId, CategoryId = categoryId, SortOrder = (nextSortOrder ?? -1) + 1, CreatedAtUtc = DateTimeOffset.UtcNow }; registryDbContext.RegistryItems.Add(entity); } if (model.CategoryId.HasValue && model.CategoryId.Value != entity.CategoryId) { var categoryExists = await registryDbContext.RegistryItemCategories .AnyAsync(x => x.RegistryId == registryId && x.Id == model.CategoryId.Value, cancellationToken) .ConfigureAwait(false); if (!categoryExists) { throw new InvalidOperationException("Category not found."); } var nextSortOrder = await registryDbContext.RegistryItems .Where(x => x.RegistryId == registryId && x.CategoryId == model.CategoryId.Value && x.Id != entity.Id) .Select(x => (int?)x.SortOrder) .MaxAsync(cancellationToken) .ConfigureAwait(false); entity.CategoryId = model.CategoryId.Value; entity.SortOrder = (nextSortOrder ?? -1) + 1; } entity.Name = model.Name.Trim(); entity.PictureUrl = string.IsNullOrWhiteSpace(model.PictureUrl) ? null : model.PictureUrl.Trim(); entity.ProductUrl = string.IsNullOrWhiteSpace(model.ProductUrl) ? null : model.ProductUrl.Trim(); entity.Description = string.IsNullOrWhiteSpace(model.Description) ? null : model.Description.Trim(); entity.PriceAmount = model.PriceAmount; entity.CurrencyCode = string.IsNullOrWhiteSpace(model.CurrencyCode) ? "EUR" : model.CurrencyCode.Trim().ToUpperInvariant(); entity.DesiredQuantity = model.DesiredQuantity < 1 ? 1 : model.DesiredQuantity; entity.ParticipationAllowed = model.ParticipationAllowed; entity.ParticipationTargetAmount = model.ParticipationAllowed ? model.ParticipationTargetAmount : null; entity.PreferSecondHand = model.PreferSecondHand; entity.IsGiven = model.IsGiven; await registryDbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); } public async Task DeleteRegistryItemAsync(Guid registryId, Guid itemId, CancellationToken cancellationToken) { var entity = await registryDbContext.RegistryItems .FirstOrDefaultAsync(x => x.RegistryId == registryId && x.Id == itemId, cancellationToken) .ConfigureAwait(false); if (entity is null) { return; } registryDbContext.RegistryItems.Remove(entity); await registryDbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); } public async Task AddPurchaseAsync(Guid itemId, string userId, int quantity, CancellationToken cancellationToken) { ArgumentException.ThrowIfNullOrWhiteSpace(userId); var item = await registryDbContext.RegistryItems .FirstOrDefaultAsync(x => x.Id == itemId, cancellationToken) .ConfigureAwait(false) ?? throw new InvalidOperationException("Item not found."); var normalizedQuantity = quantity < 1 ? 1 : quantity; registryDbContext.ItemPurchases.Add(new ItemPurchase { Id = Guid.NewGuid(), RegistryItemId = itemId, UserId = userId, Quantity = normalizedQuantity, PurchasedAtUtc = DateTimeOffset.UtcNow }); item.PurchasedQuantity += normalizedQuantity; item.IsGiven = item.PurchasedQuantity >= item.DesiredQuantity; registryDbContext.UserActionLogs.Add(new UserActionLog { Id = Guid.NewGuid(), RegistryId = item.RegistryId, UserId = userId, RegistryItemId = itemId, ActionType = UserActionType.MarkPurchased, Quantity = normalizedQuantity, CreatedAtUtc = DateTimeOffset.UtcNow }); await registryDbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); } public async Task AddContributionAsync(Guid itemId, string userId, decimal amount, string transferMessage, CancellationToken cancellationToken) { ArgumentException.ThrowIfNullOrWhiteSpace(userId); if (amount <= 0) { throw new ArgumentException("Amount must be positive.", nameof(amount)); } var item = await registryDbContext.RegistryItems .FirstOrDefaultAsync(x => x.Id == itemId, cancellationToken) .ConfigureAwait(false) ?? throw new InvalidOperationException("Item not found."); registryDbContext.ItemContributions.Add(new ItemContribution { Id = Guid.NewGuid(), RegistryItemId = itemId, UserId = userId, Amount = amount, CurrencyCode = item.CurrencyCode, TransferMessage = transferMessage, ContributedAtUtc = DateTimeOffset.UtcNow }); item.MoneyFulfilledAmount += amount; registryDbContext.UserActionLogs.Add(new UserActionLog { Id = Guid.NewGuid(), RegistryId = item.RegistryId, UserId = userId, RegistryItemId = itemId, ActionType = UserActionType.LogContribution, Amount = amount, Details = transferMessage, CreatedAtUtc = DateTimeOffset.UtcNow }); await registryDbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); } public async Task UnmarkPurchaseAsync(Guid itemId, string userId, CancellationToken cancellationToken) { ArgumentException.ThrowIfNullOrWhiteSpace(userId); var item = await registryDbContext.RegistryItems .FirstOrDefaultAsync(x => x.Id == itemId, cancellationToken) .ConfigureAwait(false) ?? throw new InvalidOperationException("Item not found."); var purchase = await registryDbContext.ItemPurchases .FirstOrDefaultAsync(x => x.RegistryItemId == itemId && x.UserId == userId, cancellationToken) .ConfigureAwait(false); if (purchase is null) { return; } item.PurchasedQuantity -= purchase.Quantity; item.IsGiven = item.PurchasedQuantity >= item.DesiredQuantity; registryDbContext.ItemPurchases.Remove(purchase); registryDbContext.UserActionLogs.Add(new UserActionLog { Id = Guid.NewGuid(), RegistryId = item.RegistryId, UserId = userId, RegistryItemId = itemId, ActionType = UserActionType.UnmarkPurchased, Quantity = purchase.Quantity, CreatedAtUtc = DateTimeOffset.UtcNow }); await registryDbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); } public async Task UnmarkPurchaseByAdminAsync(Guid itemId, string purchaserUserId, string adminUserId, CancellationToken cancellationToken) { ArgumentException.ThrowIfNullOrWhiteSpace(purchaserUserId); ArgumentException.ThrowIfNullOrWhiteSpace(adminUserId); var item = await registryDbContext.RegistryItems .FirstOrDefaultAsync(x => x.Id == itemId, cancellationToken) .ConfigureAwait(false) ?? throw new InvalidOperationException("Item not found."); var purchase = await registryDbContext.ItemPurchases .FirstOrDefaultAsync(x => x.RegistryItemId == itemId && x.UserId == purchaserUserId, cancellationToken) .ConfigureAwait(false); if (purchase is null) { return; } item.PurchasedQuantity -= purchase.Quantity; item.IsGiven = item.PurchasedQuantity >= item.DesiredQuantity; registryDbContext.ItemPurchases.Remove(purchase); registryDbContext.UserActionLogs.Add(new UserActionLog { Id = Guid.NewGuid(), RegistryId = item.RegistryId, UserId = adminUserId, RegistryItemId = itemId, ActionType = UserActionType.UnmarkPurchased, Quantity = purchase.Quantity, Details = $"Unmarked purchase by {purchaserUserId}", CreatedAtUtc = DateTimeOffset.UtcNow }); await registryDbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); } public async Task> GetRegistryActionLogsAsync(Guid registryId, CancellationToken cancellationToken) { var actionLogs = await registryDbContext.UserActionLogs .Where(x => x.RegistryId == registryId) .OrderByDescending(x => x.CreatedAtUtc) .ToListAsync(cancellationToken) .ConfigureAwait(false); var userIds = actionLogs.Select(x => x.UserId).Distinct().ToList(); var usersById = await applicationDbContext.Users .Where(x => userIds.Contains(x.Id)) .Select(x => new { x.Id, x.Email }) .ToDictionaryAsync(x => x.Id, x => x.Email ?? x.Id, cancellationToken) .ConfigureAwait(false); var itemIds = actionLogs .Where(x => x.RegistryItemId.HasValue) .Select(x => x.RegistryItemId!.Value) .Distinct() .ToList(); var itemsById = await registryDbContext.RegistryItems .Where(x => itemIds.Contains(x.Id)) .Select(x => new { x.Id, x.Name }) .ToDictionaryAsync(x => x.Id, x => x.Name, cancellationToken) .ConfigureAwait(false); return actionLogs .Select(log => new RegistryActionLogViewModel { Id = log.Id, UserDisplayName = usersById.TryGetValue(log.UserId, out var displayName) ? displayName : log.UserId, ActionType = log.ActionType.ToString(), ItemName = log.RegistryItemId.HasValue && itemsById.TryGetValue(log.RegistryItemId.Value, out var itemName) ? itemName : null, Quantity = log.Quantity, Amount = log.Amount, Details = log.Details, CreatedAtUtc = log.CreatedAtUtc }) .ToList(); } public async Task LogUserActionAsync(Guid registryId, string userId, UserActionType actionType, Guid? itemId, string? details, CancellationToken cancellationToken) { ArgumentException.ThrowIfNullOrWhiteSpace(userId); registryDbContext.UserActionLogs.Add(new UserActionLog { Id = Guid.NewGuid(), RegistryId = registryId, UserId = userId, RegistryItemId = itemId, ActionType = actionType, Details = details, CreatedAtUtc = DateTimeOffset.UtcNow }); await registryDbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); } public async Task CreateAdminInviteAsync(Guid registryId, string? email, TimeSpan validFor, CancellationToken cancellationToken) { var invite = new RegistryAdminInvite { Id = Guid.NewGuid(), RegistryId = registryId, Token = Convert.ToBase64String(RandomNumberGenerator.GetBytes(36)).Replace('+', '-').Replace('/', '_').TrimEnd('='), SentToEmail = string.IsNullOrWhiteSpace(email) ? null : email.Trim(), ExpiresAtUtc = DateTimeOffset.UtcNow.Add(validFor) }; registryDbContext.RegistryAdminInvites.Add(invite); await registryDbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); return invite.Id; } public async Task GetInviteTokenAsync(Guid inviteId, CancellationToken cancellationToken) { return await registryDbContext.RegistryAdminInvites .Where(x => x.Id == inviteId) .Select(x => x.Token) .FirstOrDefaultAsync(cancellationToken) .ConfigureAwait(false); } public async Task RedeemAdminInviteAsync(string token, string userId, CancellationToken cancellationToken) { ArgumentException.ThrowIfNullOrWhiteSpace(token); ArgumentException.ThrowIfNullOrWhiteSpace(userId); var invite = await registryDbContext.RegistryAdminInvites .FirstOrDefaultAsync(x => x.Token == token, cancellationToken) .ConfigureAwait(false); if (invite is null || invite.RedeemedAtUtc.HasValue || invite.ExpiresAtUtc < DateTimeOffset.UtcNow) { return false; } var isAdmin = await registryDbContext.RegistryAdmins .AnyAsync(x => x.RegistryId == invite.RegistryId && x.UserId == userId, cancellationToken) .ConfigureAwait(false); if (!isAdmin) { registryDbContext.RegistryAdmins.Add(new RegistryAdmin { RegistryId = invite.RegistryId, UserId = userId, AddedAtUtc = DateTimeOffset.UtcNow }); } invite.RedeemedAtUtc = DateTimeOffset.UtcNow; await registryDbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); return true; } public async Task RemoveRegistryAdminAsync(Guid registryId, string userId, CancellationToken cancellationToken) { ArgumentException.ThrowIfNullOrWhiteSpace(userId); var admin = await registryDbContext.RegistryAdmins .FirstOrDefaultAsync(x => x.RegistryId == registryId && x.UserId == userId, cancellationToken) .ConfigureAwait(false); if (admin is not null) { registryDbContext.RegistryAdmins.Remove(admin); await registryDbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); } } private async Task CreateUniquePublicCodeAsync(CancellationToken cancellationToken) { for (var attempt = 0; attempt < 10; attempt++) { var codeBytes = RandomNumberGenerator.GetBytes(6); var code = Convert.ToHexString(codeBytes).ToLowerInvariant(); var exists = await registryDbContext.Registries .AnyAsync(x => x.PublicLinkCode == code, cancellationToken) .ConfigureAwait(false); if (!exists) { return code; } } var fallback = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(Guid.NewGuid().ToString("N")))).Substring(0, 16).ToLowerInvariant(); return fallback; } private static string BuildUserDisplayName(string? firstName, string? lastName, string? email, string userId) { var fullName = $"{firstName} {lastName}".Trim(); if (!string.IsNullOrWhiteSpace(fullName)) { return fullName; } if (!string.IsNullOrWhiteSpace(email)) { return email; } return userId; } public async Task> GetRegistryAccessibleUserAddressesAsync(Guid registryId, CancellationToken cancellationToken) { var userIds = new HashSet(StringComparer.Ordinal); var adminIds = await registryDbContext.RegistryAdmins .Where(x => x.RegistryId == registryId) .Select(x => x.UserId) .ToListAsync(cancellationToken) .ConfigureAwait(false); foreach (var adminId in adminIds) { userIds.Add(adminId); } var visitorIds = await registryDbContext.RegistryVisits .Where(x => x.RegistryId == registryId) .Select(x => x.UserId) .ToListAsync(cancellationToken) .ConfigureAwait(false); foreach (var visitorId in visitorIds) { userIds.Add(visitorId); } var itemIds = await registryDbContext.RegistryItems .Where(x => x.RegistryId == registryId) .Select(x => x.Id) .ToListAsync(cancellationToken) .ConfigureAwait(false); if (itemIds.Count > 0) { var purchaserIds = await registryDbContext.ItemPurchases .Where(x => itemIds.Contains(x.RegistryItemId)) .Select(x => x.UserId) .Distinct() .ToListAsync(cancellationToken) .ConfigureAwait(false); foreach (var purchaserId in purchaserIds) { userIds.Add(purchaserId); } var contributorIds = await registryDbContext.ItemContributions .Where(x => itemIds.Contains(x.RegistryItemId)) .Select(x => x.UserId) .Distinct() .ToListAsync(cancellationToken) .ConfigureAwait(false); foreach (var contributorId in contributorIds) { userIds.Add(contributorId); } } if (userIds.Count == 0) { return []; } var users = await applicationDbContext.Users .Where(x => userIds.Contains(x.Id)) .Select(x => new { x.Id, x.Email, x.FirstName, x.LastName, x.Address }) .ToListAsync(cancellationToken) .ConfigureAwait(false); return users .Select(x => new RegistryAccessibleUserAddressViewModel { UserId = x.Id, DisplayName = BuildUserDisplayName(x.FirstName, x.LastName, x.Email, x.Id), Email = x.Email, Address = string.IsNullOrWhiteSpace(x.Address) ? null : x.Address.Trim() }) .OrderBy(x => x.DisplayName) .ToList(); } public async Task> GetRegistryItemCategoriesAsync(Guid registryId, CancellationToken cancellationToken) { var categories = await registryDbContext.RegistryItemCategories .Where(x => x.RegistryId == registryId) .OrderBy(x => x.SortOrder) .ThenBy(x => x.Name) .Select(x => new RegistryItemCategoryEditModel { Id = x.Id, Name = x.Name, SortOrder = x.SortOrder }) .ToListAsync(cancellationToken) .ConfigureAwait(false); if (categories.Count > 0) { return categories; } var createdCategory = await EnsureDefaultCategoryAsync(registryId, cancellationToken).ConfigureAwait(false); return [ new RegistryItemCategoryEditModel { Id = createdCategory.Id, Name = createdCategory.Name, SortOrder = createdCategory.SortOrder, Items = [] } ]; } public async Task AddRegistryItemCategoryAsync(Guid registryId, string categoryName, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(categoryName)) { throw new ArgumentException("Category name is required.", nameof(categoryName)); } var normalizedName = categoryName.Trim(); var exists = await registryDbContext.RegistryItemCategories .AnyAsync(x => x.RegistryId == registryId && x.Name == normalizedName, cancellationToken) .ConfigureAwait(false); if (exists) { throw new InvalidOperationException("A category with this name already exists."); } var sortOrder = await registryDbContext.RegistryItemCategories .Where(x => x.RegistryId == registryId) .Select(x => (int?)x.SortOrder) .MaxAsync(cancellationToken) .ConfigureAwait(false); registryDbContext.RegistryItemCategories.Add(new RegistryItemCategory { Id = Guid.NewGuid(), RegistryId = registryId, Name = normalizedName, SortOrder = (sortOrder ?? -1) + 1, CreatedAtUtc = DateTimeOffset.UtcNow }); await registryDbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); } public async Task RenameRegistryItemCategoryAsync(Guid registryId, Guid categoryId, string categoryName, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(categoryName)) { throw new ArgumentException("Category name is required.", nameof(categoryName)); } var normalizedName = categoryName.Trim(); var category = await registryDbContext.RegistryItemCategories .FirstOrDefaultAsync(x => x.RegistryId == registryId && x.Id == categoryId, cancellationToken) .ConfigureAwait(false) ?? throw new InvalidOperationException("Category not found."); var exists = await registryDbContext.RegistryItemCategories .AnyAsync(x => x.RegistryId == registryId && x.Id != categoryId && x.Name == normalizedName, cancellationToken) .ConfigureAwait(false); if (exists) { throw new InvalidOperationException("A category with this name already exists."); } category.Name = normalizedName; await registryDbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); } public async Task RemoveRegistryItemCategoryAsync(Guid registryId, Guid categoryId, CancellationToken cancellationToken) { var categoryCount = await registryDbContext.RegistryItemCategories .Where(x => x.RegistryId == registryId) .CountAsync(cancellationToken) .ConfigureAwait(false); if (categoryCount <= 1) { throw new InvalidOperationException("At least one category is required."); } var category = await registryDbContext.RegistryItemCategories .FirstOrDefaultAsync(x => x.RegistryId == registryId && x.Id == categoryId, cancellationToken) .ConfigureAwait(false); if (category is null) { return; } var fallbackCategory = await registryDbContext.RegistryItemCategories .Where(x => x.RegistryId == registryId && x.Id != categoryId) .OrderBy(x => x.SortOrder) .FirstOrDefaultAsync(cancellationToken) .ConfigureAwait(false); if (fallbackCategory is null) { throw new InvalidOperationException("At least one category is required."); } var categoryItems = await registryDbContext.RegistryItems .Where(x => x.RegistryId == registryId && x.CategoryId == categoryId) .OrderBy(x => x.SortOrder) .ToListAsync(cancellationToken) .ConfigureAwait(false); var destinationSortOrder = await registryDbContext.RegistryItems .Where(x => x.RegistryId == registryId && x.CategoryId == fallbackCategory.Id) .Select(x => (int?)x.SortOrder) .MaxAsync(cancellationToken) .ConfigureAwait(false); var nextSort = (destinationSortOrder ?? -1) + 1; foreach (var item in categoryItems) { item.CategoryId = fallbackCategory.Id; item.SortOrder = nextSort; nextSort++; } registryDbContext.RegistryItemCategories.Remove(category); await NormalizeCategoryOrderAsync(registryId, cancellationToken).ConfigureAwait(false); await NormalizeItemOrderAsync(registryId, fallbackCategory.Id, cancellationToken).ConfigureAwait(false); await registryDbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); } public async Task MoveRegistryItemAsync(Guid registryId, Guid itemId, Guid categoryId, int targetIndex, CancellationToken cancellationToken) { var item = await registryDbContext.RegistryItems .FirstOrDefaultAsync(x => x.RegistryId == registryId && x.Id == itemId, cancellationToken) .ConfigureAwait(false) ?? throw new InvalidOperationException("Item not found."); var categoryExists = await registryDbContext.RegistryItemCategories .AnyAsync(x => x.RegistryId == registryId && x.Id == categoryId, cancellationToken) .ConfigureAwait(false); if (!categoryExists) { throw new InvalidOperationException("Category not found."); } var sourceCategoryId = item.CategoryId; var targetItems = await registryDbContext.RegistryItems .Where(x => x.RegistryId == registryId && x.CategoryId == categoryId && x.Id != itemId) .OrderBy(x => x.SortOrder) .ToListAsync(cancellationToken) .ConfigureAwait(false); if (targetIndex < 0) { targetIndex = 0; } if (targetIndex > targetItems.Count) { targetIndex = targetItems.Count; } targetItems.Insert(targetIndex, item); for (var i = 0; i < targetItems.Count; i++) { targetItems[i].CategoryId = categoryId; targetItems[i].SortOrder = i; } if (sourceCategoryId != categoryId) { await NormalizeItemOrderAsync(registryId, sourceCategoryId, cancellationToken).ConfigureAwait(false); } await registryDbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); } public async Task MoveRegistryCategoryAsync(Guid registryId, Guid categoryId, int targetIndex, CancellationToken cancellationToken) { var categories = await registryDbContext.RegistryItemCategories .Where(x => x.RegistryId == registryId) .OrderBy(x => x.SortOrder) .ToListAsync(cancellationToken) .ConfigureAwait(false); var category = categories.FirstOrDefault(x => x.Id == categoryId); if (category is null) { return; } categories.Remove(category); if (targetIndex < 0) { targetIndex = 0; } if (targetIndex > categories.Count) { targetIndex = categories.Count; } categories.Insert(targetIndex, category); for (var i = 0; i < categories.Count; i++) { categories[i].SortOrder = i; } await registryDbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); } private async Task EnsureDefaultCategoryAsync(Guid registryId, CancellationToken cancellationToken) { var categories = await registryDbContext.RegistryItemCategories .Where(x => x.RegistryId == registryId) .OrderBy(x => x.SortOrder) .ToListAsync(cancellationToken) .ConfigureAwait(false); RegistryItemCategory defaultCategory; if (categories.Count == 0) { defaultCategory = new RegistryItemCategory { Id = Guid.NewGuid(), RegistryId = registryId, Name = DefaultCategoryName, SortOrder = 0, CreatedAtUtc = DateTimeOffset.UtcNow }; registryDbContext.RegistryItemCategories.Add(defaultCategory); } else { defaultCategory = categories.FirstOrDefault(x => x.Name == DefaultCategoryName) ?? categories[0]; } var unassignedItems = await registryDbContext.RegistryItems .Where(x => x.RegistryId == registryId && x.CategoryId == Guid.Empty) .OrderBy(x => x.SortOrder) .ThenBy(x => x.Name) .ToListAsync(cancellationToken) .ConfigureAwait(false); if (unassignedItems.Count > 0) { var nextSortOrder = await registryDbContext.RegistryItems .Where(x => x.RegistryId == registryId && x.CategoryId == defaultCategory.Id) .Select(x => (int?)x.SortOrder) .MaxAsync(cancellationToken) .ConfigureAwait(false); var next = (nextSortOrder ?? -1) + 1; foreach (var item in unassignedItems) { item.CategoryId = defaultCategory.Id; item.SortOrder = next; next++; } } await NormalizeCategoryOrderAsync(registryId, cancellationToken).ConfigureAwait(false); await NormalizeItemOrderAsync(registryId, defaultCategory.Id, cancellationToken).ConfigureAwait(false); if (registryDbContext.ChangeTracker.HasChanges()) { await registryDbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); } return defaultCategory; } private async Task NormalizeCategoryOrderAsync(Guid registryId, CancellationToken cancellationToken) { var categories = await registryDbContext.RegistryItemCategories .Where(x => x.RegistryId == registryId) .OrderBy(x => x.SortOrder) .ThenBy(x => x.CreatedAtUtc) .ToListAsync(cancellationToken) .ConfigureAwait(false); for (var i = 0; i < categories.Count; i++) { categories[i].SortOrder = i; } } private async Task NormalizeItemOrderAsync(Guid registryId, Guid categoryId, CancellationToken cancellationToken) { var items = await registryDbContext.RegistryItems .Where(x => x.RegistryId == registryId && x.CategoryId == categoryId) .OrderBy(x => x.SortOrder) .ThenBy(x => x.CreatedAtUtc) .ToListAsync(cancellationToken) .ConfigureAwait(false); for (var i = 0; i < items.Count; i++) { items[i].SortOrder = i; } } }