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); + } + } }