From 89c439f80b114691d6c8fb16c08cdc407b4cda2d Mon Sep 17 00:00:00 2001 From: Arne Moerman Date: Tue, 19 May 2026 18:18:02 +0200 Subject: [PATCH] Enhance purchase and contribution management Refactored UI to consolidate purchase and contribution actions into dynamic buttons. Added modals for managing purchases and contributions with admin-specific functionality. Introduced new properties and methods in `RegistryPublic.razor.cs` for state management and user filtering. Updated `RegistryService` with methods for handling contributions and purchases, including admin-specific logic, validation, and logging. Improved database operations for robust handling of item totals and user actions. Added utility methods for generating unique codes and managing admin invites. Refactored user filtering and display logic for admin actions, ensuring dynamic population of selectable users. Optimized handling of user actions and logs for consistency. --- .../Components/Pages/RegistryPublic.razor | 86 ++- .../Components/Pages/RegistryPublic.razor.cs | 299 ++++++++++- .../Features/Registries/RegistryModels.cs | 7 + .../Features/Registries/RegistryService.cs | 496 ++++++++++++------ 4 files changed, 720 insertions(+), 168 deletions(-) diff --git a/src/BirthList.Web/Components/Pages/RegistryPublic.razor b/src/BirthList.Web/Components/Pages/RegistryPublic.razor index 3d4e356..e5407de 100644 --- a/src/BirthList.Web/Components/Pages/RegistryPublic.razor +++ b/src/BirthList.Web/Components/Pages/RegistryPublic.razor @@ -175,21 +175,16 @@ else { } - @if (item.CurrentUserPurchasedQuantity == 0) - { - - } - @if (item.CurrentUserPurchasedQuantity > 0 || (Registry.IsAdmin && item.Purchasers.Count > 0)) - { - @if (item.CurrentUserPurchasedQuantity > 0) - { - You purchased @item.CurrentUserPurchasedQuantity - } - - } + + + @if (item.ParticipationAllowed) { - + } } @@ -353,3 +348,68 @@ else } + +@if (ShowPurchaseManagementPrompt) +{ +
+
+

Manage purchase

+ + @if (Registry?.IsAdmin == true) + { + + + + } + + + + +
+ + + +
+
+
+} + +@if (ShowContributionManagementPrompt) +{ +
+
+

Manage participation

+ + @if (Registry?.IsAdmin == true) + { + + + + } + + + + + + + +
+ + + +
+
+
+} diff --git a/src/BirthList.Web/Components/Pages/RegistryPublic.razor.cs b/src/BirthList.Web/Components/Pages/RegistryPublic.razor.cs index 689111b..39cb1aa 100644 --- a/src/BirthList.Web/Components/Pages/RegistryPublic.razor.cs +++ b/src/BirthList.Web/Components/Pages/RegistryPublic.razor.cs @@ -32,6 +32,16 @@ public partial class RegistryPublic : ComponentBase protected ContributionPaymentMethodType? SelectedPaymentMethod { get; set; } protected string? SelectedPaymentQrCodeUrl { get; private set; } + protected bool ShowPurchaseManagementPrompt { get; private set; } + protected bool ShowContributionManagementPrompt { get; private set; } + protected int ManagedPurchaseQuantity { get; set; } = 1; + protected decimal ManagedContributionAmount { get; set; } + protected string ManagedContributionMessage { get; set; } = string.Empty; + protected string? SelectedPurchaseUserId { get; set; } + protected string? SelectedContributionUserId { get; set; } + protected string UserFilterText { get; set; } = string.Empty; + protected IReadOnlyList SelectableUsers { get; private set; } = []; + private readonly HashSet _collapsedCategoryIds = []; protected override async Task OnParametersSetAsync() @@ -274,7 +284,7 @@ public partial class RegistryPublic : ComponentBase return; } - await RegistryService.AddContributionAsync(ActiveItemId, userId, ContributionAmount, ContributionMessage, CancellationToken.None).ConfigureAwait(false); + await RegistryService.AddContributionAsync(ActiveItemId, userId, ContributionAmount, ContributionMessage, CancellationToken.None). ConfigureAwait(false); ShowPartialFulfillWizard = false; PartialFulfillStep = 1; SelectedPaymentMethod = null; @@ -310,4 +320,291 @@ public partial class RegistryPublic : ComponentBase ArgumentException.ThrowIfNullOrWhiteSpace(paymentLink); return $"https://api.qrserver.com/v1/create-qr-code/?size=320x320&data={Uri.EscapeDataString(paymentLink)}"; } + + + protected IEnumerable FilteredSelectableUsers => + string.IsNullOrWhiteSpace(UserFilterText) + ? SelectableUsers + : SelectableUsers.Where(x => x.DisplayName.Contains(UserFilterText, StringComparison.OrdinalIgnoreCase)); + + protected async Task OpenPurchaseManagementPromptAsync(Guid itemId) + { + var userId = await RegistryUserContext.GetUserIdAsync(CancellationToken.None).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(userId)) + { + return; + } + + var item = Registry?.Items.FirstOrDefault(x => x.Id == itemId); + if (item is null) + { + return; + } + + ActiveItemId = itemId; + ShowPurchaseManagementPrompt = true; + ManagedPurchaseQuantity = item.CurrentUserPurchasedQuantity > 0 ? item.CurrentUserPurchasedQuantity : 1; + UserFilterText = string.Empty; + + if (Registry?.IsAdmin == true) + { + SelectableUsers = await RegistryService.GetRegistrySelectableUsersAsync(Registry.Id, CancellationToken.None).ConfigureAwait(false); + SelectedPurchaseUserId = item.Purchasers.FirstOrDefault()?.UserId ?? userId; + + if (!SelectableUsers.Any(x => x.UserId == SelectedPurchaseUserId) && !string.IsNullOrWhiteSpace(SelectedPurchaseUserId)) + { + SelectableUsers = + [ + .. SelectableUsers, + new RegistrySelectableUserViewModel { UserId = SelectedPurchaseUserId, DisplayName = SelectedPurchaseUserId } + ]; + } + } + else + { + SelectedPurchaseUserId = userId; + } + } + + protected void ClosePurchaseManagementPrompt() + { + ShowPurchaseManagementPrompt = false; + SelectedPurchaseUserId = null; + UserFilterText = string.Empty; + SelectableUsers = []; + ActiveItemId = Guid.Empty; + } + + protected async Task SavePurchaseManagementAsync() + { + var actorUserId = await RegistryUserContext.GetUserIdAsync(CancellationToken.None).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(actorUserId) || ActiveItemId == Guid.Empty) + { + return; + } + + var targetUserId = Registry?.IsAdmin == true + ? SelectedPurchaseUserId + : actorUserId; + + if (string.IsNullOrWhiteSpace(targetUserId)) + { + return; + } + + var item = Registry?.Items.FirstOrDefault(x => x.Id == ActiveItemId); + if (item is null) + { + return; + } + + var existingPurchase = item.Purchasers.FirstOrDefault(x => x.UserId == targetUserId); + if (existingPurchase is null) + { + await RegistryService.AddPurchaseAsync(ActiveItemId, targetUserId, ManagedPurchaseQuantity, CancellationToken.None).ConfigureAwait(false); + } + else + { + await RegistryService.UpdatePurchaseAsync(ActiveItemId, targetUserId, ManagedPurchaseQuantity, actorUserId, CancellationToken.None).ConfigureAwait(false); + } + + await ReloadRegistryAsync(actorUserId).ConfigureAwait(false); + ClosePurchaseManagementPrompt(); + } + + protected async Task RemoveManagedPurchaseAsync() + { + var actorUserId = await RegistryUserContext.GetUserIdAsync(CancellationToken.None).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(actorUserId) || ActiveItemId == Guid.Empty) + { + return; + } + + var targetUserId = Registry?.IsAdmin == true + ? SelectedPurchaseUserId + : actorUserId; + + if (string.IsNullOrWhiteSpace(targetUserId)) + { + return; + } + + if (Registry?.IsAdmin == true) + { + await RegistryService.UnmarkPurchaseByAdminAsync(ActiveItemId, targetUserId, actorUserId, CancellationToken.None).ConfigureAwait(false); + } + else + { + await RegistryService.UnmarkPurchaseAsync(ActiveItemId, targetUserId, CancellationToken.None).ConfigureAwait(false); + } + + await ReloadRegistryAsync(actorUserId).ConfigureAwait(false); + ClosePurchaseManagementPrompt(); + } + + protected async Task OpenContributionManagementPromptAsync(Guid itemId) + { + var userId = await RegistryUserContext.GetUserIdAsync(CancellationToken.None).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(userId)) + { + return; + } + + var item = Registry?.Items.FirstOrDefault(x => x.Id == itemId); + if (item is null) + { + return; + } + + ActiveItemId = itemId; + ShowContributionManagementPrompt = true; + UserFilterText = string.Empty; + + if (Registry?.IsAdmin == true) + { + SelectableUsers = await RegistryService.GetRegistrySelectableUsersAsync(Registry.Id, CancellationToken.None).ConfigureAwait(false); + SelectedContributionUserId = item.Contributors.FirstOrDefault()?.UserId ?? userId; + } + else + { + SelectedContributionUserId = userId; + } + + var currentContribution = item.Contributors.FirstOrDefault(x => x.UserId == SelectedContributionUserId); + ManagedContributionAmount = currentContribution?.Amount ?? item.CurrentUserContributionAmount; + ManagedContributionMessage = $"Participation for item: {item.Name}"; + } + + protected void CloseContributionManagementPrompt() + { + ShowContributionManagementPrompt = false; + SelectedContributionUserId = null; + UserFilterText = string.Empty; + SelectableUsers = []; + ManagedContributionAmount = 0; + ManagedContributionMessage = string.Empty; + ActiveItemId = Guid.Empty; + } + + protected async Task SaveContributionManagementAsync() + { + var actorUserId = await RegistryUserContext.GetUserIdAsync(CancellationToken.None).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(actorUserId) || ActiveItemId == Guid.Empty || ManagedContributionAmount <= 0) + { + return; + } + + var targetUserId = Registry?.IsAdmin == true + ? SelectedContributionUserId + : actorUserId; + + if (string.IsNullOrWhiteSpace(targetUserId)) + { + return; + } + + var item = Registry?.Items.FirstOrDefault(x => x.Id == ActiveItemId); + if (item is null) + { + return; + } + + var existingContribution = item.Contributors.FirstOrDefault(x => x.UserId == targetUserId); + if (existingContribution is null) + { + await RegistryService.AddContributionAsync(ActiveItemId, targetUserId, ManagedContributionAmount, ManagedContributionMessage, CancellationToken.None).ConfigureAwait(false); + } + else + { + await RegistryService.UpdateContributionAsync(ActiveItemId, targetUserId, ManagedContributionAmount, ManagedContributionMessage, actorUserId, CancellationToken.None).ConfigureAwait(false); + } + + await ReloadRegistryAsync(actorUserId).ConfigureAwait(false); + CloseContributionManagementPrompt(); + } + + protected async Task RemoveManagedContributionAsync() + { + var actorUserId = await RegistryUserContext.GetUserIdAsync(CancellationToken.None).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(actorUserId) || ActiveItemId == Guid.Empty) + { + return; + } + + var targetUserId = Registry?.IsAdmin == true + ? SelectedContributionUserId + : actorUserId; + + if (string.IsNullOrWhiteSpace(targetUserId)) + { + return; + } + + await RegistryService.RemoveContributionAsync(ActiveItemId, targetUserId, actorUserId, CancellationToken.None).ConfigureAwait(false); + await ReloadRegistryAsync(actorUserId).ConfigureAwait(false); + CloseContributionManagementPrompt(); + } + + protected void SelectManagedPurchaseUser(string userId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(userId); + SelectedPurchaseUserId = userId; + + var item = Registry?.Items.FirstOrDefault(x => x.Id == ActiveItemId); + var existing = item?.Purchasers.FirstOrDefault(x => x.UserId == userId); + ManagedPurchaseQuantity = existing?.Quantity ?? 1; + } + + protected void SelectManagedContributionUser(string userId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(userId); + SelectedContributionUserId = userId; + + var item = Registry?.Items.FirstOrDefault(x => x.Id == ActiveItemId); + var existing = item?.Contributors.FirstOrDefault(x => x.UserId == userId); + ManagedContributionAmount = existing?.Amount ?? 0; + } + + protected void OnPurchaseManagedUserChanged(ChangeEventArgs args) + { + var userId = args.Value?.ToString(); + if (string.IsNullOrWhiteSpace(userId)) + { + return; + } + + SelectManagedPurchaseUser(userId); + } + + protected void OnContributionManagedUserChanged(ChangeEventArgs args) + { + var userId = args.Value?.ToString(); + if (string.IsNullOrWhiteSpace(userId)) + { + return; + } + + SelectManagedContributionUser(userId); + } + + private async Task ReloadRegistryAsync(string userId) + { + Registry = await RegistryService.GetPublicRegistryByCodeAsync(Code, userId, CancellationToken.None).ConfigureAwait(false); + if (Registry is not null) + { + var categoryIds = Registry.Categories.Select(x => x.Id).ToHashSet(); + _collapsedCategoryIds.RemoveWhere(id => !categoryIds.Contains(id)); + } + } + + protected Task OpenContributionActionAsync(Guid itemId, decimal currentUserContributionAmount) + { + if (Registry?.IsAdmin == true || currentUserContributionAmount > 0) + { + return OpenContributionManagementPromptAsync(itemId); + } + + OpenContributionPrompt(itemId); + return Task.CompletedTask; + } } diff --git a/src/BirthList.Web/Features/Registries/RegistryModels.cs b/src/BirthList.Web/Features/Registries/RegistryModels.cs index 74505f1..ae79153 100644 --- a/src/BirthList.Web/Features/Registries/RegistryModels.cs +++ b/src/BirthList.Web/Features/Registries/RegistryModels.cs @@ -128,6 +128,7 @@ public sealed class RegistryPublicItemViewModel public IReadOnlyList Purchasers { get; init; } = []; public IReadOnlyList Contributors { get; init; } = []; public int CurrentUserPurchasedQuantity { get; init; } + public decimal CurrentUserContributionAmount { get; init; } public bool CanViewPurchasers { get; init; } } @@ -174,3 +175,9 @@ public sealed class RegistryItemCategoryEditModel public int SortOrder { get; init; } public IReadOnlyList Items { get; init; } = []; } + +public sealed class RegistrySelectableUserViewModel +{ + public string UserId { get; init; } = string.Empty; + public string DisplayName { get; init; } = string.Empty; +} diff --git a/src/BirthList.Web/Features/Registries/RegistryService.cs b/src/BirthList.Web/Features/Registries/RegistryService.cs index b6212da..e69a122 100644 --- a/src/BirthList.Web/Features/Registries/RegistryService.cs +++ b/src/BirthList.Web/Features/Registries/RegistryService.cs @@ -267,6 +267,11 @@ internal sealed class RegistryService(RegistryDbContext registryDbContext, Appli ? itemPurchases3 .Where(p => p.UserId == userId) .Sum(p => p.Quantity) + : 0, + CurrentUserContributionAmount = contributionsByItemId.TryGetValue(x.Id, out var itemContributions2) + ? itemContributions2 + .Where(c => c.UserId == userId) + .Sum(c => c.Amount) : 0 }) .ToList(); @@ -778,9 +783,10 @@ internal sealed class RegistryService(RegistryDbContext registryDbContext, Appli await registryDbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); } - public async Task UnmarkPurchaseAsync(Guid itemId, string userId, CancellationToken cancellationToken) + public async Task UpdatePurchaseAsync(Guid itemId, string userId, int quantity, string actorUserId, CancellationToken cancellationToken) { ArgumentException.ThrowIfNullOrWhiteSpace(userId); + ArgumentException.ThrowIfNullOrWhiteSpace(actorUserId); var item = await registryDbContext.RegistryItems .FirstOrDefaultAsync(x => x.Id == itemId, cancellationToken) @@ -788,221 +794,166 @@ internal sealed class RegistryService(RegistryDbContext registryDbContext, Appli var purchase = await registryDbContext.ItemPurchases .FirstOrDefaultAsync(x => x.RegistryItemId == itemId && x.UserId == userId, cancellationToken) - .ConfigureAwait(false); + .ConfigureAwait(false) ?? throw new InvalidOperationException("Purchase not found."); - if (purchase is null) + var normalizedQuantity = quantity < 1 ? 1 : quantity; + var delta = normalizedQuantity - purchase.Quantity; + + purchase.Quantity = normalizedQuantity; + item.PurchasedQuantity += delta; + if (item.PurchasedQuantity < 0) { - return; + item.PurchasedQuantity = 0; } - 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, + UserId = actorUserId, RegistryItemId = itemId, - ActionType = UserActionType.UnmarkPurchased, - Quantity = purchase.Quantity, + ActionType = UserActionType.MarkPurchased, + Quantity = normalizedQuantity, + Details = $"Updated purchase quantity for {userId}", CreatedAtUtc = DateTimeOffset.UtcNow }); await registryDbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); } - public async Task UnmarkPurchaseByAdminAsync(Guid itemId, string purchaserUserId, string adminUserId, CancellationToken cancellationToken) + public async Task UpdateContributionAsync(Guid itemId, string userId, decimal amount, string transferMessage, string actorUserId, CancellationToken cancellationToken) { - ArgumentException.ThrowIfNullOrWhiteSpace(purchaserUserId); - ArgumentException.ThrowIfNullOrWhiteSpace(adminUserId); + ArgumentException.ThrowIfNullOrWhiteSpace(userId); + ArgumentException.ThrowIfNullOrWhiteSpace(actorUserId); + 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."); - var purchase = await registryDbContext.ItemPurchases - .FirstOrDefaultAsync(x => x.RegistryItemId == itemId && x.UserId == purchaserUserId, cancellationToken) - .ConfigureAwait(false); + var contribution = await registryDbContext.ItemContributions + .FirstOrDefaultAsync(x => x.RegistryItemId == itemId && x.UserId == userId, cancellationToken) + .ConfigureAwait(false) ?? throw new InvalidOperationException("Contribution not found."); - if (purchase is null) + var previousAmount = contribution.Amount; + contribution.Amount = amount; + contribution.TransferMessage = transferMessage; + contribution.CurrencyCode = item.CurrencyCode; + + item.MoneyFulfilledAmount += amount - previousAmount; + if (item.MoneyFulfilledAmount < 0) { - return; + item.MoneyFulfilledAmount = 0; } - 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, + UserId = actorUserId, RegistryItemId = itemId, - ActionType = UserActionType.UnmarkPurchased, - Quantity = purchase.Quantity, - Details = $"Unmarked purchase by {purchaserUserId}", + ActionType = UserActionType.LogContribution, + Amount = amount, + Details = string.IsNullOrWhiteSpace(transferMessage) + ? $"Updated contribution amount for {userId}" + : transferMessage, 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) + public async Task RemoveContributionAsync(Guid itemId, string userId, string actorUserId, CancellationToken cancellationToken) { ArgumentException.ThrowIfNullOrWhiteSpace(userId); + ArgumentException.ThrowIfNullOrWhiteSpace(actorUserId); + + var item = await registryDbContext.RegistryItems + .FirstOrDefaultAsync(x => x.Id == itemId, cancellationToken) + .ConfigureAwait(false) ?? throw new InvalidOperationException("Item not found."); + + var contribution = await registryDbContext.ItemContributions + .FirstOrDefaultAsync(x => x.RegistryItemId == itemId && x.UserId == userId, cancellationToken) + .ConfigureAwait(false); + + if (contribution is null) + { + return; + } + + item.MoneyFulfilledAmount -= contribution.Amount; + if (item.MoneyFulfilledAmount < 0) + { + item.MoneyFulfilledAmount = 0; + } + + registryDbContext.ItemContributions.Remove(contribution); registryDbContext.UserActionLogs.Add(new UserActionLog { Id = Guid.NewGuid(), - RegistryId = registryId, - UserId = userId, + RegistryId = item.RegistryId, + UserId = actorUserId, RegistryItemId = itemId, - ActionType = actionType, - Details = details, + ActionType = UserActionType.LogContribution, + Amount = 0, + Details = $"Removed contribution for {userId}", CreatedAtUtc = DateTimeOffset.UtcNow }); await registryDbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); } - public async Task CreateAdminInviteAsync(Guid registryId, string? email, TimeSpan validFor, CancellationToken cancellationToken) + public async Task> GetRegistrySelectableUsersAsync(Guid registryId, 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) - }; + var userIds = new HashSet(StringComparer.Ordinal); - 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) + var adminIds = await registryDbContext.RegistryAdmins + .Where(x => x.RegistryId == registryId) + .Select(x => x.UserId) + .ToListAsync(cancellationToken) .ConfigureAwait(false); - if (invite is null || invite.RedeemedAtUtc.HasValue || invite.ExpiresAtUtc < DateTimeOffset.UtcNow) + foreach (var adminId in adminIds) { - return false; + userIds.Add(adminId); } - var isAdmin = await registryDbContext.RegistryAdmins - .AnyAsync(x => x.RegistryId == invite.RegistryId && x.UserId == userId, cancellationToken) + var visitorIds = await registryDbContext.RegistryVisits + .Where(x => x.RegistryId == registryId) + .Select(x => x.UserId) + .ToListAsync(cancellationToken) .ConfigureAwait(false); - if (!isAdmin) + foreach (var visitorId in visitorIds) { - registryDbContext.RegistryAdmins.Add(new RegistryAdmin + userIds.Add(visitorId); + } + + 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 }) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + return users + .Select(x => new RegistrySelectableUserViewModel { - 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; + UserId = x.Id, + DisplayName = BuildUserDisplayName(x.FirstName, x.LastName, x.Email, x.Id) + }) + .OrderBy(x => x.DisplayName) + .ToList(); } private static string BuildUserDisplayName(string? firstName, string? lastName, string? email, string userId) @@ -1441,4 +1392,241 @@ internal sealed class RegistryService(RegistryDbContext registryDbContext, Appli items[i].SortOrder = i; } } + + 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; + } + + 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; + if (item.PurchasedQuantity < 0) + { + item.PurchasedQuantity = 0; + } + + 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; + if (item.PurchasedQuantity < 0) + { + item.PurchasedQuantity = 0; + } + + 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 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> 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 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); + } + } }