Add UserActionLog and enhance registry features
Build and Push Docker Image / build-and-push (push) Successful in 1m59s

- Introduced `UserActionLog` entity to track user actions.
- Replaced `CanBeSecondHand` with `PreferSecondHand` property.
- Added `ShowBankAccountName` to `RegistrySettings`.
- Updated models and migrations for new properties.
- Enhanced `RegistryService` with user action logging and item details.
- Redesigned `Home.razor` with a grid layout and modal for registries.
- Added `RegistryActionLog.razor` for admin action logs.
- Improved `RegistryPublic.razor` with purchaser/contributor details.
- Replaced sidebar with `TopBar.razor` for responsive navigation.
- Updated CSS for new components and improved responsiveness.
This commit is contained in:
Arne Moerman
2026-05-17 20:57:54 +02:00
parent 6b593828d7
commit 2bf0295508
32 changed files with 3811 additions and 435 deletions
@@ -92,7 +92,15 @@ internal sealed class RegistryMetadataService(IHttpClientFactory httpClientFacto
{
if (string.IsNullOrWhiteSpace(image) || IsGenericAmazonImage(image))
{
image = ExtractAmazonImage(html) ?? image;
var extractedImage = ExtractAmazonImage(html);
if (!string.IsNullOrWhiteSpace(extractedImage))
{
image = extractedImage;
}
else if (!string.IsNullOrWhiteSpace(image) && IsGenericAmazonImage(image))
{
image = null;
}
}
if (!price.HasValue)
@@ -127,11 +135,15 @@ internal sealed class RegistryMetadataService(IHttpClientFactory httpClientFacto
private static string? ExtractAmazonImage(string html)
{
var patterns = new []
var patterns = new[]
{
"\\\"landingImageUrl\\\"\\s*:\\s*\\\"(?<url>https?:\\\\/\\\\/[^\\\"]+)\\\"",
"\\\"hiRes\\\"\\s*:\\s*\\\"(?<url>https?:\\\\/\\\\/[^\\\"]+)\\\"",
"<img[^>]+id=['\"]landingImage['\"][^>]+src=['\"](?<url>[^'\"]+)['\"]"
"data-old-hires=['\"](?<url>https?://[^'\"]+)['\"]",
"\\\"mainUrl\\\"\\s*:\\s*\\\"(?<url>https?:\\\\/\\\\/[^\\\"]+)\\\"",
"<img[^>]+id=['\"]landingImage['\"][^>]+src=['\"](?<url>[^'\"]+)['\"]",
"<img[^>]+id=['\"]imgBlkFront['\"][^>]+src=['\"](?<url>[^'\"]+)['\"]",
"\\\"image\\\"\\s*:\\s*\\\"(?<url>https?:\\\\/\\\\/[^\\\"]+)\\\""
};
foreach (var pattern in patterns)
@@ -149,7 +161,7 @@ internal sealed class RegistryMetadataService(IHttpClientFactory httpClientFacto
}
value = value.Replace("\\/", "/").Replace("\\u0026", "&");
if (Uri.TryCreate(value, UriKind.Absolute, out _))
if (Uri.TryCreate(value, UriKind.Absolute, out _) && !IsGenericAmazonImage(value))
{
return value;
}
@@ -21,8 +21,18 @@ public sealed class RegistryItemEditModel
public int DesiredQuantity { get; set; } = 1;
public bool ParticipationAllowed { get; set; }
public decimal? ParticipationTargetAmount { get; set; }
public bool CanBeSecondHand { get; set; }
public bool? PreferSecondHand { get; set; }
public bool IsGiven { get; set; }
public IReadOnlyList<ItemContributorViewModel> Purchasers { get; init; } = [];
public IReadOnlyList<ItemContributorViewModel> Contributors { get; init; } = [];
}
public sealed class ItemContributorViewModel
{
public string UserId { get; init; } = string.Empty;
public string DisplayName { get; init; } = string.Empty;
public decimal Amount { get; init; }
public int Quantity { get; init; }
}
public sealed class RegistrySettingsEditModel
@@ -36,6 +46,7 @@ public sealed class RegistrySettingsEditModel
public string? BankAccountIban { get; set; }
public string? BankAccountBic { get; set; }
public string? BankAccountDisplayName { get; set; }
public bool ShowBankAccountName { get; set; }
}
public sealed class RegistrySummaryViewModel
@@ -61,6 +72,9 @@ public sealed class RegistryPublicViewModel
public string? BankAccountIban { get; init; }
public string? BankAccountBic { get; init; }
public string? BankAccountDisplayName { get; init; }
public bool ShowBankAccountName { get; init; }
public string? CurrentUserId { get; init; }
public bool IsAdmin { get; init; }
public IReadOnlyList<RegistryPublicItemViewModel> Items { get; init; } = [];
}
@@ -78,8 +92,12 @@ public sealed class RegistryPublicItemViewModel
public bool ParticipationAllowed { get; init; }
public decimal? ParticipationTargetAmount { get; init; }
public decimal MoneyFulfilledAmount { get; init; }
public bool CanBeSecondHand { get; init; }
public bool? PreferSecondHand { get; init; }
public bool IsGiven { get; init; }
public IReadOnlyList<ItemContributorViewModel> Purchasers { get; init; } = [];
public IReadOnlyList<ItemContributorViewModel> Contributors { get; init; } = [];
public int CurrentUserPurchasedQuantity { get; init; }
public bool CanViewPurchasers { get; init; }
}
public sealed class RegistryAdminDisplayModel
@@ -97,3 +115,15 @@ public sealed class UrlMetadataResult
public decimal? PriceAmount { get; init; }
public string? CurrencyCode { get; init; }
}
public sealed class RegistryActionLogViewModel
{
public Guid Id { get; init; }
public string UserDisplayName { get; init; } = string.Empty;
public string ActionType { get; init; } = string.Empty;
public string? ItemName { get; init; }
public int Quantity { get; init; }
public decimal Amount { get; init; }
public string? Details { get; init; }
public DateTimeOffset CreatedAtUtc { get; init; }
}
@@ -1,12 +1,13 @@
using BirthList.Domain.Entities;
using BirthList.Infrastructure.Persistence;
using BirthList.Web.Data;
using Microsoft.EntityFrameworkCore;
using System.Security.Cryptography;
using System.Text;
namespace BirthList.Web.Features.Registries;
internal sealed class RegistryService(RegistryDbContext registryDbContext)
internal sealed class RegistryService(RegistryDbContext registryDbContext, ApplicationDbContext applicationDbContext)
{
public async Task<Guid> CreateRegistryAsync(string userId, RegistryCreateModel model, CancellationToken cancellationToken)
{
@@ -86,16 +87,28 @@ internal sealed class RegistryService(RegistryDbContext registryDbContext)
public async Task<IReadOnlyList<RegistryAdminDisplayModel>> GetRegistryAdminsAsync(Guid registryId, CancellationToken cancellationToken)
{
return await registryDbContext.RegistryAdmins
var admins = await registryDbContext.RegistryAdmins
.Where(x => x.RegistryId == registryId)
.OrderBy(x => x.AddedAtUtc)
.Select(x => new RegistryAdminDisplayModel
{
UserId = x.UserId,
DisplayName = x.UserId
})
.Select(x => x.UserId)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
var usersById = await applicationDbContext.Users
.Where(x => admins.Contains(x.Id))
.Select(x => new { x.Id, x.Email })
.ToDictionaryAsync(x => x.Id, x => x.Email, cancellationToken)
.ConfigureAwait(false);
return admins
.Select(userId => new RegistryAdminDisplayModel
{
UserId = userId,
DisplayName = usersById.TryGetValue(userId, out var email) && !string.IsNullOrWhiteSpace(email)
? email
: userId
})
.ToList();
}
public async Task<RegistryPublicViewModel?> GetPublicRegistryByCodeAsync(string code, string? userId, CancellationToken cancellationToken)
@@ -144,6 +157,44 @@ internal sealed class RegistryService(RegistryDbContext registryDbContext)
await registryDbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
var itemIds = registry.Items.Select(x => x.Id).ToList();
var userIds = new HashSet<string>();
var purchases = await registryDbContext.ItemPurchases
.Where(x => itemIds.Contains(x.RegistryItemId))
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
var contributions = await registryDbContext.ItemContributions
.Where(x => itemIds.Contains(x.RegistryItemId))
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
foreach (var purchase in purchases)
{
userIds.Add(purchase.UserId);
}
foreach (var contribution in contributions)
{
userIds.Add(contribution.UserId);
}
var 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 purchasesByItemId = purchases
.GroupBy(x => x.RegistryItemId)
.ToDictionary(g => g.Key, g => g.ToList());
var contributionsByItemId = contributions
.GroupBy(x => x.RegistryItemId)
.ToDictionary(g => g.Key, g => g.ToList());
var isAdmin = !string.IsNullOrWhiteSpace(userId) && registry.Admins.Any(x => x.UserId == userId);
return new RegistryPublicViewModel
{
Id = registry.Id,
@@ -158,6 +209,9 @@ internal sealed class RegistryService(RegistryDbContext registryDbContext)
BankAccountIban = settings?.BankAccountIban,
BankAccountBic = settings?.BankAccountBic,
BankAccountDisplayName = settings?.BankAccountDisplayName,
ShowBankAccountName = settings?.ShowBankAccountName ?? false,
CurrentUserId = userId,
IsAdmin = isAdmin,
Items = registry.Items
.OrderBy(x => x.Name)
.Select(x => new RegistryPublicItemViewModel
@@ -174,8 +228,36 @@ internal sealed class RegistryService(RegistryDbContext registryDbContext)
ParticipationAllowed = x.ParticipationAllowed,
ParticipationTargetAmount = x.ParticipationTargetAmount,
MoneyFulfilledAmount = x.MoneyFulfilledAmount,
CanBeSecondHand = x.CanBeSecondHand,
IsGiven = x.IsGiven
PreferSecondHand = x.PreferSecondHand,
IsGiven = x.IsGiven,
CanViewPurchasers = isAdmin || (purchasesByItemId.TryGetValue(x.Id, out var itemPurchases) && itemPurchases.Any(p => p.UserId == userId)),
Purchasers = purchasesByItemId.TryGetValue(x.Id, out var itemPurchases2)
? itemPurchases2
.GroupBy(p => p.UserId)
.Select(g => new ItemContributorViewModel
{
UserId = g.Key,
DisplayName = usersById.TryGetValue(g.Key, out var displayName) ? displayName : g.Key,
Quantity = g.Sum(p => p.Quantity)
})
.ToList()
: [],
Contributors = contributionsByItemId.TryGetValue(x.Id, out var itemContributions)
? itemContributions
.GroupBy(c => c.UserId)
.Select(g => new ItemContributorViewModel
{
UserId = g.Key,
DisplayName = usersById.TryGetValue(g.Key, out var displayName) ? displayName : g.Key,
Amount = g.Sum(c => c.Amount)
})
.ToList()
: [],
CurrentUserPurchasedQuantity = purchasesByItemId.TryGetValue(x.Id, out var itemPurchases3)
? itemPurchases3
.Where(p => p.UserId == userId)
.Sum(p => p.Quantity)
: 0
})
.ToList()
};
@@ -206,7 +288,8 @@ internal sealed class RegistryService(RegistryDbContext registryDbContext)
ThemeKey = registry.ThemeKey,
BankAccountIban = settings?.BankAccountIban,
BankAccountBic = settings?.BankAccountBic,
BankAccountDisplayName = settings?.BankAccountDisplayName
BankAccountDisplayName = settings?.BankAccountDisplayName,
ShowBankAccountName = settings?.ShowBankAccountName ?? false
};
}
@@ -241,15 +324,56 @@ internal sealed class RegistryService(RegistryDbContext registryDbContext)
settings.BankAccountIban = string.IsNullOrWhiteSpace(model.BankAccountIban) ? null : model.BankAccountIban.Trim();
settings.BankAccountBic = string.IsNullOrWhiteSpace(model.BankAccountBic) ? null : model.BankAccountBic.Trim();
settings.BankAccountDisplayName = string.IsNullOrWhiteSpace(model.BankAccountDisplayName) ? null : model.BankAccountDisplayName.Trim();
settings.ShowBankAccountName = model.ShowBankAccountName;
await registryDbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<RegistryItemEditModel>> GetRegistryItemsAsync(Guid registryId, CancellationToken cancellationToken)
{
return await registryDbContext.RegistryItems
var items = await registryDbContext.RegistryItems
.Where(x => x.RegistryId == registryId)
.OrderBy(x => x.Name)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
var itemIds = items.Select(x => x.Id).ToList();
var userIds = new HashSet<string>();
var purchases = await registryDbContext.ItemPurchases
.Where(x => itemIds.Contains(x.RegistryItemId))
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
var contributions = await registryDbContext.ItemContributions
.Where(x => itemIds.Contains(x.RegistryItemId))
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
foreach (var purchase in purchases)
{
userIds.Add(purchase.UserId);
}
foreach (var contribution in contributions)
{
userIds.Add(contribution.UserId);
}
var 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 purchasesByItemId = purchases
.GroupBy(x => x.RegistryItemId)
.ToDictionary(g => g.Key, g => g.ToList());
var contributionsByItemId = contributions
.GroupBy(x => x.RegistryItemId)
.ToDictionary(g => g.Key, g => g.ToList());
return items
.Select(x => new RegistryItemEditModel
{
Id = x.Id,
@@ -262,34 +386,104 @@ internal sealed class RegistryService(RegistryDbContext registryDbContext)
DesiredQuantity = x.DesiredQuantity,
ParticipationAllowed = x.ParticipationAllowed,
ParticipationTargetAmount = x.ParticipationTargetAmount,
CanBeSecondHand = x.CanBeSecondHand,
IsGiven = x.IsGiven
PreferSecondHand = x.PreferSecondHand,
IsGiven = x.IsGiven,
Purchasers = purchasesByItemId.TryGetValue(x.Id, out var itemPurchases)
? itemPurchases
.GroupBy(p => p.UserId)
.Select(g => new ItemContributorViewModel
{
UserId = g.Key,
DisplayName = usersById.TryGetValue(g.Key, out var displayName) ? displayName : g.Key,
Quantity = g.Sum(p => p.Quantity)
})
.ToList()
: [],
Contributors = contributionsByItemId.TryGetValue(x.Id, out var itemContributions)
? itemContributions
.GroupBy(c => c.UserId)
.Select(g => new ItemContributorViewModel
{
UserId = g.Key,
DisplayName = usersById.TryGetValue(g.Key, out var displayName) ? displayName : g.Key,
Amount = g.Sum(c => c.Amount)
})
.ToList()
: []
})
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
.ToList();
}
public async Task<RegistryItemEditModel?> GetRegistryItemAsync(Guid registryId, Guid itemId, CancellationToken cancellationToken)
{
return await registryDbContext.RegistryItems
.Where(x => x.RegistryId == registryId && x.Id == itemId)
.Select(x => new RegistryItemEditModel
{
Id = x.Id,
Name = x.Name,
PictureUrl = x.PictureUrl,
ProductUrl = x.ProductUrl,
Description = x.Description,
PriceAmount = x.PriceAmount,
CurrencyCode = x.CurrencyCode,
DesiredQuantity = x.DesiredQuantity,
ParticipationAllowed = x.ParticipationAllowed,
ParticipationTargetAmount = x.ParticipationTargetAmount,
CanBeSecondHand = x.CanBeSecondHand,
IsGiven = x.IsGiven
})
.FirstOrDefaultAsync(cancellationToken)
var item = await registryDbContext.RegistryItems
.FirstOrDefaultAsync(x => x.RegistryId == registryId && x.Id == itemId, cancellationToken)
.ConfigureAwait(false);
if (item is null)
{
return null;
}
var purchases = await registryDbContext.ItemPurchases
.Where(x => x.RegistryItemId == itemId)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
var contributions = await registryDbContext.ItemContributions
.Where(x => x.RegistryItemId == itemId)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
var userIds = new HashSet<string>();
foreach (var purchase in purchases)
{
userIds.Add(purchase.UserId);
}
foreach (var contribution in contributions)
{
userIds.Add(contribution.UserId);
}
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);
return new RegistryItemEditModel
{
Id = item.Id,
Name = item.Name,
PictureUrl = item.PictureUrl,
ProductUrl = item.ProductUrl,
Description = item.Description,
PriceAmount = item.PriceAmount,
CurrencyCode = item.CurrencyCode,
DesiredQuantity = item.DesiredQuantity,
ParticipationAllowed = item.ParticipationAllowed,
ParticipationTargetAmount = item.ParticipationTargetAmount,
PreferSecondHand = item.PreferSecondHand,
IsGiven = item.IsGiven,
Purchasers = purchases
.GroupBy(p => p.UserId)
.Select(g => new ItemContributorViewModel
{
UserId = g.Key,
DisplayName = usersById.TryGetValue(g.Key, out var displayName) ? displayName : g.Key,
Quantity = g.Sum(p => p.Quantity)
})
.ToList(),
Contributors = contributions
.GroupBy(c => c.UserId)
.Select(g => new ItemContributorViewModel
{
UserId = g.Key,
DisplayName = usersById.TryGetValue(g.Key, out var displayName) ? displayName : g.Key,
Amount = g.Sum(c => c.Amount)
})
.ToList()
};
}
public async Task UpsertRegistryItemAsync(Guid registryId, RegistryItemEditModel model, CancellationToken cancellationToken)
@@ -327,7 +521,7 @@ internal sealed class RegistryService(RegistryDbContext registryDbContext)
entity.DesiredQuantity = model.DesiredQuantity < 1 ? 1 : model.DesiredQuantity;
entity.ParticipationAllowed = model.ParticipationAllowed;
entity.ParticipationTargetAmount = model.ParticipationAllowed ? model.ParticipationTargetAmount : null;
entity.CanBeSecondHand = model.CanBeSecondHand;
entity.PreferSecondHand = model.PreferSecondHand;
entity.IsGiven = model.IsGiven;
await registryDbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
@@ -370,6 +564,17 @@ internal sealed class RegistryService(RegistryDbContext registryDbContext)
item.PurchasedQuantity += normalizedQuantity;
item.IsGiven = item.PurchasedQuantity >= item.DesiredQuantity;
registryDbContext.UserActionLogs.Add(new UserActionLog
{
Id = Guid.NewGuid(),
RegistryId = item.RegistryId,
UserId = userId,
RegistryItemId = itemId,
ActionType = UserActionType.MarkPurchased,
Quantity = normalizedQuantity,
CreatedAtUtc = DateTimeOffset.UtcNow
});
await registryDbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
@@ -397,6 +602,153 @@ internal sealed class RegistryService(RegistryDbContext registryDbContext)
});
item.MoneyFulfilledAmount += amount;
registryDbContext.UserActionLogs.Add(new UserActionLog
{
Id = Guid.NewGuid(),
RegistryId = item.RegistryId,
UserId = userId,
RegistryItemId = itemId,
ActionType = UserActionType.LogContribution,
Amount = amount,
Details = transferMessage,
CreatedAtUtc = DateTimeOffset.UtcNow
});
await registryDbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
public async Task UnmarkPurchaseAsync(Guid itemId, string userId, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(userId);
var item = await registryDbContext.RegistryItems
.FirstOrDefaultAsync(x => x.Id == itemId, cancellationToken)
.ConfigureAwait(false) ?? throw new InvalidOperationException("Item not found.");
var purchase = await registryDbContext.ItemPurchases
.FirstOrDefaultAsync(x => x.RegistryItemId == itemId && x.UserId == userId, cancellationToken)
.ConfigureAwait(false);
if (purchase is null)
{
return;
}
item.PurchasedQuantity -= purchase.Quantity;
item.IsGiven = item.PurchasedQuantity >= item.DesiredQuantity;
registryDbContext.ItemPurchases.Remove(purchase);
registryDbContext.UserActionLogs.Add(new UserActionLog
{
Id = Guid.NewGuid(),
RegistryId = item.RegistryId,
UserId = userId,
RegistryItemId = itemId,
ActionType = UserActionType.UnmarkPurchased,
Quantity = purchase.Quantity,
CreatedAtUtc = DateTimeOffset.UtcNow
});
await registryDbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
public async Task UnmarkPurchaseByAdminAsync(Guid itemId, string purchaserUserId, string adminUserId, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(purchaserUserId);
ArgumentException.ThrowIfNullOrWhiteSpace(adminUserId);
var item = await registryDbContext.RegistryItems
.FirstOrDefaultAsync(x => x.Id == itemId, cancellationToken)
.ConfigureAwait(false) ?? throw new InvalidOperationException("Item not found.");
var purchase = await registryDbContext.ItemPurchases
.FirstOrDefaultAsync(x => x.RegistryItemId == itemId && x.UserId == purchaserUserId, cancellationToken)
.ConfigureAwait(false);
if (purchase is null)
{
return;
}
item.PurchasedQuantity -= purchase.Quantity;
item.IsGiven = item.PurchasedQuantity >= item.DesiredQuantity;
registryDbContext.ItemPurchases.Remove(purchase);
registryDbContext.UserActionLogs.Add(new UserActionLog
{
Id = Guid.NewGuid(),
RegistryId = item.RegistryId,
UserId = adminUserId,
RegistryItemId = itemId,
ActionType = UserActionType.UnmarkPurchased,
Quantity = purchase.Quantity,
Details = $"Unmarked purchase by {purchaserUserId}",
CreatedAtUtc = DateTimeOffset.UtcNow
});
await registryDbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
public async Task<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)
{
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);
}
@@ -458,6 +810,21 @@ internal sealed class RegistryService(RegistryDbContext registryDbContext)
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++)