Enhance purchase and contribution management
Build and Push Docker Image / build-and-push (push) Successful in 41s

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.
This commit is contained in:
Arne Moerman
2026-05-19 18:18:02 +02:00
parent 09349cb7b7
commit 89c439f80b
4 changed files with 720 additions and 168 deletions
@@ -175,21 +175,16 @@ else
{
<button class="btn btn-primary btn-sm" @onclick="() => OpenPurchasePrompt(item.Id, openTab: true)">Purchase</button>
}
@if (item.CurrentUserPurchasedQuantity == 0)
{
<button class="btn btn-success btn-sm" @onclick="() => OpenPurchasePrompt(item.Id)">Mark purchased</button>
}
@if (item.CurrentUserPurchasedQuantity > 0 || (Registry.IsAdmin && item.Purchasers.Count > 0))
{
@if (item.CurrentUserPurchasedQuantity > 0)
{
<span class="badge bg-success me-2">You purchased @item.CurrentUserPurchasedQuantity</span>
}
<button class="btn btn-warning btn-sm" @onclick="() => UnmarkPurchaseAsync(item.Id)">Unmark purchase</button>
}
<button class="btn btn-success btn-sm" @onclick="() => OpenPurchaseManagementPromptAsync(item.Id)">
@(Registry.IsAdmin ? "Manage purchases" : (item.CurrentUserPurchasedQuantity > 0 ? "Edit purchase" : "Mark purchased"))
</button>
@if (item.ParticipationAllowed)
{
<button class="btn btn-secondary btn-sm" @onclick="() => OpenContributionPrompt(item.Id)">Partially fulfill</button>
<button class="btn btn-secondary btn-sm" @onclick="() => OpenContributionActionAsync(item.Id, item.CurrentUserContributionAmount)">
@(Registry.IsAdmin ? "Manage participations" : (item.CurrentUserContributionAmount > 0 ? "Edit participation" : "Partially fulfill"))
</button>
}
</div>
}
@@ -353,3 +348,68 @@ else
</div>
</div>
}
@if (ShowPurchaseManagementPrompt)
{
<div class="prompt-overlay">
<div class="prompt-card">
<h3>Manage purchase</h3>
@if (Registry?.IsAdmin == true)
{
<label class="form-label">User</label>
<InputText class="form-control mb-2" @bind-Value="UserFilterText" placeholder="Search user" />
<select class="form-select mb-3" value="@SelectedPurchaseUserId" @onchange="OnPurchaseManagedUserChanged">
<option value="">Select user</option>
@foreach (var user in FilteredSelectableUsers)
{
<option value="@user.UserId">@user.DisplayName</option>
}
</select>
}
<label class="form-label">Quantity</label>
<InputNumber class="form-control" @bind-Value="ManagedPurchaseQuantity" />
<div class="d-flex gap-2 mt-3">
<button class="btn btn-success" @onclick="SavePurchaseManagementAsync">Save</button>
<button class="btn btn-outline-danger" @onclick="RemoveManagedPurchaseAsync">Remove</button>
<button class="btn btn-outline-secondary" @onclick="ClosePurchaseManagementPrompt">Cancel</button>
</div>
</div>
</div>
}
@if (ShowContributionManagementPrompt)
{
<div class="prompt-overlay">
<div class="prompt-card">
<h3>Manage participation</h3>
@if (Registry?.IsAdmin == true)
{
<label class="form-label">User</label>
<InputText class="form-control mb-2" @bind-Value="UserFilterText" placeholder="Search user" />
<select class="form-select mb-3" value="@SelectedContributionUserId" @onchange="OnContributionManagedUserChanged">
<option value="">Select user</option>
@foreach (var user in FilteredSelectableUsers)
{
<option value="@user.UserId">@user.DisplayName</option>
}
</select>
}
<label class="form-label">Amount</label>
<InputNumber class="form-control" @bind-Value="ManagedContributionAmount" />
<label class="form-label mt-2">Message</label>
<InputText class="form-control" @bind-Value="ManagedContributionMessage" />
<div class="d-flex gap-2 mt-3">
<button class="btn btn-success" @onclick="SaveContributionManagementAsync">Save</button>
<button class="btn btn-outline-danger" @onclick="RemoveManagedContributionAsync">Remove</button>
<button class="btn btn-outline-secondary" @onclick="CloseContributionManagementPrompt">Cancel</button>
</div>
</div>
</div>
}
@@ -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<RegistrySelectableUserViewModel> SelectableUsers { get; private set; } = [];
private readonly HashSet<Guid> _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<RegistrySelectableUserViewModel> 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;
}
}
@@ -128,6 +128,7 @@ public sealed class RegistryPublicItemViewModel
public IReadOnlyList<ItemContributorViewModel> Purchasers { get; init; } = [];
public IReadOnlyList<ItemContributorViewModel> 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<RegistryItemEditModel> Items { get; init; } = [];
}
public sealed class RegistrySelectableUserViewModel
{
public string UserId { get; init; } = string.Empty;
public string DisplayName { get; init; } = string.Empty;
}
@@ -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<IReadOnlyList<RegistryActionLogViewModel>> 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<Guid> CreateAdminInviteAsync(Guid registryId, string? email, TimeSpan validFor, CancellationToken cancellationToken)
public async Task<IReadOnlyList<RegistrySelectableUserViewModel>> 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<string>(StringComparer.Ordinal);
registryDbContext.RegistryAdminInvites.Add(invite);
await registryDbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
return invite.Id;
}
public async Task<string?> 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<bool> 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<string> 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<string> 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<IReadOnlyList<RegistryActionLogViewModel>> 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<Guid> 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<string?> 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<bool> 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);
}
}
}