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