09349cb7b7
Introduced `RegistryItemCategory` entity for grouping and ordering items within registries. Updated `RegistryItem` and `Registry` entities to support categorization. Added database migrations for `RegistryItemCategories` and updated `RegistryItems` with `CategoryId` and `SortOrder`. Implemented drag-and-drop functionality for reordering categories and items using JavaScript and Blazor. Enhanced `RegistryAdmin` and `RegistryPublic` components to manage and display categories with collapsible sections. Updated `RegistryService` to handle category operations, including adding, renaming, removing, and reordering. Added new view models and updated CSS for category styling. Refactored logic to ensure proper ordering and fallback for unassigned items.
1445 lines
55 KiB
C#
1445 lines
55 KiB
C#
using BirthList.Domain.Entities;
|
|
using BirthList.Infrastructure.Persistence;
|
|
using BirthList.Web.Data;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
|
|
namespace BirthList.Web.Features.Registries;
|
|
|
|
internal sealed class RegistryService(RegistryDbContext registryDbContext, ApplicationDbContext applicationDbContext)
|
|
{
|
|
private const string DefaultCategoryName = "General";
|
|
|
|
public async Task<Guid> CreateRegistryAsync(string userId, RegistryCreateModel model, CancellationToken cancellationToken)
|
|
{
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(userId);
|
|
ArgumentNullException.ThrowIfNull(model);
|
|
if (string.IsNullOrWhiteSpace(model.Title))
|
|
{
|
|
throw new ArgumentException("Title is required.", nameof(model));
|
|
}
|
|
|
|
var publicCode = await CreateUniquePublicCodeAsync(cancellationToken).ConfigureAwait(false);
|
|
|
|
var registry = new Registry
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
Title = model.Title.Trim(),
|
|
RegistryType = model.RegistryType,
|
|
ThemeKey = string.IsNullOrWhiteSpace(model.ThemeKey) ? "default" : model.ThemeKey.Trim(),
|
|
PublicLinkCode = publicCode,
|
|
CreatedAtUtc = DateTimeOffset.UtcNow,
|
|
CurrencyCode = "EUR"
|
|
};
|
|
|
|
registryDbContext.Registries.Add(registry);
|
|
registryDbContext.RegistryAdmins.Add(new RegistryAdmin
|
|
{
|
|
RegistryId = registry.Id,
|
|
UserId = userId,
|
|
AddedAtUtc = DateTimeOffset.UtcNow
|
|
});
|
|
registryDbContext.RegistrySettings.Add(new RegistrySettings
|
|
{
|
|
RegistryId = registry.Id
|
|
});
|
|
registryDbContext.RegistryItemCategories.Add(new RegistryItemCategory
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
RegistryId = registry.Id,
|
|
Name = DefaultCategoryName,
|
|
SortOrder = 0,
|
|
CreatedAtUtc = DateTimeOffset.UtcNow
|
|
});
|
|
|
|
await registryDbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
|
return registry.Id;
|
|
}
|
|
|
|
public async Task<IReadOnlyList<RegistrySummaryViewModel>> GetVisitedRegistriesAsync(string userId, CancellationToken cancellationToken)
|
|
{
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(userId);
|
|
|
|
return await registryDbContext.RegistryVisits
|
|
.Where(x => x.UserId == userId)
|
|
.OrderByDescending(x => x.LastVisitedAtUtc)
|
|
.Select(x => new RegistrySummaryViewModel
|
|
{
|
|
Id = x.RegistryId,
|
|
Title = x.Registry.Title,
|
|
PublicLinkCode = x.Registry.PublicLinkCode,
|
|
RegistryType = x.Registry.RegistryType,
|
|
ThemeKey = x.Registry.ThemeKey
|
|
})
|
|
.ToListAsync(cancellationToken)
|
|
.ConfigureAwait(false);
|
|
}
|
|
|
|
public async Task<IReadOnlyList<RegistrySummaryViewModel>> GetAdminRegistriesAsync(string userId, CancellationToken cancellationToken)
|
|
{
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(userId);
|
|
|
|
return await registryDbContext.RegistryAdmins
|
|
.Where(x => x.UserId == userId)
|
|
.OrderByDescending(x => x.AddedAtUtc)
|
|
.Select(x => new RegistrySummaryViewModel
|
|
{
|
|
Id = x.RegistryId,
|
|
Title = x.Registry.Title,
|
|
PublicLinkCode = x.Registry.PublicLinkCode,
|
|
RegistryType = x.Registry.RegistryType,
|
|
ThemeKey = x.Registry.ThemeKey
|
|
})
|
|
.ToListAsync(cancellationToken)
|
|
.ConfigureAwait(false);
|
|
}
|
|
|
|
public async Task<IReadOnlyList<RegistryAdminDisplayModel>> GetRegistryAdminsAsync(Guid registryId, CancellationToken cancellationToken)
|
|
{
|
|
var admins = await registryDbContext.RegistryAdmins
|
|
.Where(x => x.RegistryId == registryId)
|
|
.OrderBy(x => x.AddedAtUtc)
|
|
.Select(x => x.UserId)
|
|
.ToListAsync(cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
var users = await applicationDbContext.Users
|
|
.Where(x => admins.Contains(x.Id))
|
|
.Select(x => new { x.Id, x.Email, x.FirstName, x.LastName })
|
|
.ToListAsync(cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
var usersById = users.ToDictionary(
|
|
x => x.Id,
|
|
x => BuildUserDisplayName(x.FirstName, x.LastName, x.Email, x.Id));
|
|
|
|
return admins
|
|
.Select(userId => new RegistryAdminDisplayModel
|
|
{
|
|
UserId = userId,
|
|
DisplayName = usersById.TryGetValue(userId, out var displayName)
|
|
? displayName
|
|
: userId
|
|
})
|
|
.ToList();
|
|
}
|
|
|
|
public async Task<RegistryPublicViewModel?> GetPublicRegistryByCodeAsync(string code, string? userId, CancellationToken cancellationToken)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(code))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var registry = await registryDbContext.Registries
|
|
.Include(x => x.Items)
|
|
.ThenInclude(x => x.Category)
|
|
.Include(x => x.ItemCategories)
|
|
.Include(x => x.Visits)
|
|
.Include(x => x.Admins)
|
|
.FirstOrDefaultAsync(x => x.PublicLinkCode == code.Trim(), cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
if (registry is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var settings = await registryDbContext.RegistrySettings
|
|
.FirstOrDefaultAsync(x => x.RegistryId == registry.Id, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
if (!string.IsNullOrWhiteSpace(userId))
|
|
{
|
|
var existingVisit = await registryDbContext.RegistryVisits
|
|
.FirstOrDefaultAsync(x => x.RegistryId == registry.Id && x.UserId == userId, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
if (existingVisit is null)
|
|
{
|
|
registryDbContext.RegistryVisits.Add(new RegistryVisit
|
|
{
|
|
RegistryId = registry.Id,
|
|
UserId = userId,
|
|
LastVisitedAtUtc = DateTimeOffset.UtcNow
|
|
});
|
|
}
|
|
else
|
|
{
|
|
existingVisit.LastVisitedAtUtc = DateTimeOffset.UtcNow;
|
|
}
|
|
|
|
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 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);
|
|
|
|
var usersById = users.ToDictionary(
|
|
x => x.Id,
|
|
x => BuildUserDisplayName(x.FirstName, x.LastName, x.Email, x.Id));
|
|
|
|
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);
|
|
|
|
var mappedItems = registry.Items
|
|
.OrderBy(x => x.Category.SortOrder)
|
|
.ThenBy(x => x.SortOrder)
|
|
.ThenBy(x => x.Name)
|
|
.Select(x => new RegistryPublicItemViewModel
|
|
{
|
|
Id = x.Id,
|
|
CategoryId = x.CategoryId,
|
|
CategoryName = x.Category.Name,
|
|
CategorySortOrder = x.Category.SortOrder,
|
|
SortOrder = x.SortOrder,
|
|
Name = x.Name,
|
|
PictureUrl = x.PictureUrl,
|
|
ProductUrl = x.ProductUrl,
|
|
Description = x.Description,
|
|
PriceAmount = x.PriceAmount,
|
|
CurrencyCode = x.CurrencyCode,
|
|
DesiredQuantity = x.DesiredQuantity,
|
|
PurchasedQuantity = x.PurchasedQuantity,
|
|
ParticipationAllowed = x.ParticipationAllowed,
|
|
ParticipationTargetAmount = x.ParticipationTargetAmount,
|
|
MoneyFulfilledAmount = x.MoneyFulfilledAmount,
|
|
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();
|
|
|
|
var mappedCategories = registry.ItemCategories
|
|
.OrderBy(x => x.SortOrder)
|
|
.ThenBy(x => x.Name)
|
|
.Select(category => new RegistryPublicCategoryViewModel
|
|
{
|
|
Id = category.Id,
|
|
Name = category.Name,
|
|
SortOrder = category.SortOrder,
|
|
Items = mappedItems
|
|
.Where(item => item.CategoryId == category.Id)
|
|
.OrderBy(item => item.SortOrder)
|
|
.ThenBy(item => item.Name)
|
|
.ToList()
|
|
})
|
|
.ToList();
|
|
|
|
return new RegistryPublicViewModel
|
|
{
|
|
Id = registry.Id,
|
|
Title = registry.Title,
|
|
PublicLinkCode = registry.PublicLinkCode,
|
|
BabyName = registry.BabyName,
|
|
HeaderContentHtml = registry.HeaderContentHtml,
|
|
ShippingAddress = registry.ShippingAddress,
|
|
CurrencyCode = registry.CurrencyCode,
|
|
RegistryType = registry.RegistryType,
|
|
ThemeKey = registry.ThemeKey,
|
|
BankAccountIban = settings?.BankAccountIban,
|
|
BankAccountBic = settings?.BankAccountBic,
|
|
BankAccountDisplayName = settings?.BankAccountDisplayName,
|
|
ShowBankAccountName = settings?.ShowBankAccountName ?? false,
|
|
ContributionQrCodeUrl = settings?.ContributionQrCodeUrl,
|
|
ContributionAmountQrCodes = ParseContributionAmountQrCodes(settings?.ContributionAmountQrCodesJson),
|
|
CurrentUserId = userId,
|
|
IsAdmin = isAdmin,
|
|
Categories = mappedCategories,
|
|
Items = mappedItems
|
|
};
|
|
}
|
|
|
|
public async Task<RegistrySettingsEditModel?> GetRegistrySettingsAsync(Guid registryId, CancellationToken cancellationToken)
|
|
{
|
|
var registry = await registryDbContext.Registries
|
|
.FirstOrDefaultAsync(x => x.Id == registryId, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
if (registry is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var settings = await registryDbContext.RegistrySettings
|
|
.FirstOrDefaultAsync(x => x.RegistryId == registryId, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
return new RegistrySettingsEditModel
|
|
{
|
|
BabyName = registry.BabyName,
|
|
BirthDate = registry.BirthDate,
|
|
HeaderContentHtml = registry.HeaderContentHtml,
|
|
ShippingAddress = registry.ShippingAddress,
|
|
CurrencyCode = registry.CurrencyCode,
|
|
ThemeKey = registry.ThemeKey,
|
|
BankAccountIban = settings?.BankAccountIban,
|
|
BankAccountBic = settings?.BankAccountBic,
|
|
BankAccountDisplayName = settings?.BankAccountDisplayName,
|
|
ShowBankAccountName = settings?.ShowBankAccountName ?? false,
|
|
ContributionQrCodeUrl = settings?.ContributionQrCodeUrl,
|
|
ContributionAmountQrCodes = ParseContributionAmountQrCodes(settings?.ContributionAmountQrCodesJson).ToList()
|
|
};
|
|
}
|
|
|
|
public async Task UpdateRegistrySettingsAsync(Guid registryId, RegistrySettingsEditModel model, CancellationToken cancellationToken)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(model);
|
|
|
|
var registry = await registryDbContext.Registries
|
|
.FirstOrDefaultAsync(x => x.Id == registryId, cancellationToken)
|
|
.ConfigureAwait(false) ?? throw new InvalidOperationException("Registry not found.");
|
|
|
|
var settings = await registryDbContext.RegistrySettings
|
|
.FirstOrDefaultAsync(x => x.RegistryId == registryId, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
if (settings is null)
|
|
{
|
|
settings = new RegistrySettings
|
|
{
|
|
RegistryId = registryId
|
|
};
|
|
registryDbContext.RegistrySettings.Add(settings);
|
|
}
|
|
|
|
registry.BabyName = string.IsNullOrWhiteSpace(model.BabyName) ? null : model.BabyName.Trim();
|
|
registry.BirthDate = model.BirthDate;
|
|
registry.HeaderContentHtml = string.IsNullOrWhiteSpace(model.HeaderContentHtml) ? null : model.HeaderContentHtml;
|
|
registry.ShippingAddress = string.IsNullOrWhiteSpace(model.ShippingAddress) ? null : model.ShippingAddress.Trim();
|
|
registry.CurrencyCode = string.IsNullOrWhiteSpace(model.CurrencyCode) ? "EUR" : model.CurrencyCode.Trim().ToUpperInvariant();
|
|
registry.ThemeKey = string.IsNullOrWhiteSpace(model.ThemeKey) ? "default" : model.ThemeKey.Trim();
|
|
|
|
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;
|
|
settings.ContributionQrCodeUrl = string.IsNullOrWhiteSpace(model.ContributionQrCodeUrl) ? null : model.ContributionQrCodeUrl.Trim();
|
|
settings.ContributionAmountQrCodesJson = SerializeContributionAmountQrCodes(model.ContributionAmountQrCodes);
|
|
|
|
await registryDbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
private static IReadOnlyList<ContributionAmountQrCodeModel> ParseContributionAmountQrCodes(string? json)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(json))
|
|
{
|
|
return [];
|
|
}
|
|
|
|
try
|
|
{
|
|
var items = JsonSerializer.Deserialize<List<ContributionAmountQrCodeModel>>(json);
|
|
if (items is null)
|
|
{
|
|
return [];
|
|
}
|
|
|
|
return items
|
|
.Where(x => x.Amount > 0 && !string.IsNullOrWhiteSpace(x.QrCodeUrl))
|
|
.Select(x => new ContributionAmountQrCodeModel
|
|
{
|
|
Amount = x.Amount,
|
|
QrCodeUrl = x.QrCodeUrl.Trim()
|
|
})
|
|
.OrderBy(x => x.Amount)
|
|
.ToList();
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
return [];
|
|
}
|
|
}
|
|
|
|
private static string? SerializeContributionAmountQrCodes(IEnumerable<ContributionAmountQrCodeModel>? amountQrCodes)
|
|
{
|
|
if (amountQrCodes is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var normalized = amountQrCodes
|
|
.Where(x => x.Amount > 0 && !string.IsNullOrWhiteSpace(x.QrCodeUrl))
|
|
.Select(x => new ContributionAmountQrCodeModel
|
|
{
|
|
Amount = x.Amount,
|
|
QrCodeUrl = x.QrCodeUrl.Trim()
|
|
})
|
|
.OrderBy(x => x.Amount)
|
|
.ToList();
|
|
|
|
if (normalized.Count == 0)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return JsonSerializer.Serialize(normalized);
|
|
}
|
|
|
|
public async Task<IReadOnlyList<RegistryItemEditModel>> GetRegistryItemsAsync(Guid registryId, CancellationToken cancellationToken)
|
|
{
|
|
var defaultCategory = await EnsureDefaultCategoryAsync(registryId, cancellationToken).ConfigureAwait(false);
|
|
|
|
var items = await registryDbContext.RegistryItems
|
|
.Where(x => x.RegistryId == registryId)
|
|
.Include(x => x.Category)
|
|
.OrderBy(x => x.Category.SortOrder)
|
|
.ThenBy(x => x.SortOrder)
|
|
.ThenBy(x => x.Name)
|
|
.ToListAsync(cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
foreach (var item in items.Where(x => x.CategoryId == Guid.Empty))
|
|
{
|
|
item.CategoryId = defaultCategory.Id;
|
|
}
|
|
|
|
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 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);
|
|
|
|
var usersById = users.ToDictionary(
|
|
x => x.Id,
|
|
x => BuildUserDisplayName(x.FirstName, x.LastName, x.Email, x.Id));
|
|
|
|
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,
|
|
CategoryId = x.CategoryId,
|
|
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,
|
|
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()
|
|
: []
|
|
})
|
|
.ToList();
|
|
}
|
|
|
|
public async Task<RegistryItemEditModel?> GetRegistryItemAsync(Guid registryId, Guid itemId, CancellationToken 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 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);
|
|
|
|
var usersById = users.ToDictionary(
|
|
x => x.Id,
|
|
x => BuildUserDisplayName(x.FirstName, x.LastName, x.Email, x.Id));
|
|
|
|
return new RegistryItemEditModel
|
|
{
|
|
Id = item.Id,
|
|
CategoryId = item.CategoryId,
|
|
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)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(model);
|
|
if (string.IsNullOrWhiteSpace(model.Name))
|
|
{
|
|
throw new ArgumentException("Item name is required.", nameof(model));
|
|
}
|
|
|
|
var defaultCategory = await EnsureDefaultCategoryAsync(registryId, cancellationToken).ConfigureAwait(false);
|
|
|
|
RegistryItem entity;
|
|
if (model.Id is { } itemId)
|
|
{
|
|
entity = await registryDbContext.RegistryItems
|
|
.FirstOrDefaultAsync(x => x.RegistryId == registryId && x.Id == itemId, cancellationToken)
|
|
.ConfigureAwait(false) ?? throw new InvalidOperationException("Item not found.");
|
|
}
|
|
else
|
|
{
|
|
var categoryId = model.CategoryId ?? defaultCategory.Id;
|
|
var nextSortOrder = await registryDbContext.RegistryItems
|
|
.Where(x => x.RegistryId == registryId && x.CategoryId == categoryId)
|
|
.Select(x => (int?)x.SortOrder)
|
|
.MaxAsync(cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
entity = new RegistryItem
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
RegistryId = registryId,
|
|
CategoryId = categoryId,
|
|
SortOrder = (nextSortOrder ?? -1) + 1,
|
|
CreatedAtUtc = DateTimeOffset.UtcNow
|
|
};
|
|
registryDbContext.RegistryItems.Add(entity);
|
|
}
|
|
|
|
if (model.CategoryId.HasValue && model.CategoryId.Value != entity.CategoryId)
|
|
{
|
|
var categoryExists = await registryDbContext.RegistryItemCategories
|
|
.AnyAsync(x => x.RegistryId == registryId && x.Id == model.CategoryId.Value, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
if (!categoryExists)
|
|
{
|
|
throw new InvalidOperationException("Category not found.");
|
|
}
|
|
|
|
var nextSortOrder = await registryDbContext.RegistryItems
|
|
.Where(x => x.RegistryId == registryId && x.CategoryId == model.CategoryId.Value && x.Id != entity.Id)
|
|
.Select(x => (int?)x.SortOrder)
|
|
.MaxAsync(cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
entity.CategoryId = model.CategoryId.Value;
|
|
entity.SortOrder = (nextSortOrder ?? -1) + 1;
|
|
}
|
|
|
|
entity.Name = model.Name.Trim();
|
|
entity.PictureUrl = string.IsNullOrWhiteSpace(model.PictureUrl) ? null : model.PictureUrl.Trim();
|
|
entity.ProductUrl = string.IsNullOrWhiteSpace(model.ProductUrl) ? null : model.ProductUrl.Trim();
|
|
entity.Description = string.IsNullOrWhiteSpace(model.Description) ? null : model.Description.Trim();
|
|
entity.PriceAmount = model.PriceAmount;
|
|
entity.CurrencyCode = string.IsNullOrWhiteSpace(model.CurrencyCode) ? "EUR" : model.CurrencyCode.Trim().ToUpperInvariant();
|
|
entity.DesiredQuantity = model.DesiredQuantity < 1 ? 1 : model.DesiredQuantity;
|
|
entity.ParticipationAllowed = model.ParticipationAllowed;
|
|
entity.ParticipationTargetAmount = model.ParticipationAllowed ? model.ParticipationTargetAmount : null;
|
|
entity.PreferSecondHand = model.PreferSecondHand;
|
|
entity.IsGiven = model.IsGiven;
|
|
|
|
await registryDbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
public async Task DeleteRegistryItemAsync(Guid registryId, Guid itemId, CancellationToken cancellationToken)
|
|
{
|
|
var entity = await registryDbContext.RegistryItems
|
|
.FirstOrDefaultAsync(x => x.RegistryId == registryId && x.Id == itemId, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
if (entity is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
registryDbContext.RegistryItems.Remove(entity);
|
|
await registryDbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
public async Task AddPurchaseAsync(Guid itemId, string userId, int quantity, 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 normalizedQuantity = quantity < 1 ? 1 : quantity;
|
|
|
|
registryDbContext.ItemPurchases.Add(new ItemPurchase
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
RegistryItemId = itemId,
|
|
UserId = userId,
|
|
Quantity = normalizedQuantity,
|
|
PurchasedAtUtc = DateTimeOffset.UtcNow
|
|
});
|
|
|
|
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);
|
|
}
|
|
|
|
public async Task AddContributionAsync(Guid itemId, string userId, decimal amount, string transferMessage, CancellationToken cancellationToken)
|
|
{
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(userId);
|
|
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.");
|
|
|
|
registryDbContext.ItemContributions.Add(new ItemContribution
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
RegistryItemId = itemId,
|
|
UserId = userId,
|
|
Amount = amount,
|
|
CurrencyCode = item.CurrencyCode,
|
|
TransferMessage = transferMessage,
|
|
ContributedAtUtc = DateTimeOffset.UtcNow
|
|
});
|
|
|
|
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);
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
private static string BuildUserDisplayName(string? firstName, string? lastName, string? email, string userId)
|
|
{
|
|
var fullName = $"{firstName} {lastName}".Trim();
|
|
if (!string.IsNullOrWhiteSpace(fullName))
|
|
{
|
|
return fullName;
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(email))
|
|
{
|
|
return email;
|
|
}
|
|
|
|
return userId;
|
|
}
|
|
|
|
public async Task<IReadOnlyList<RegistryAccessibleUserAddressViewModel>> GetRegistryAccessibleUserAddressesAsync(Guid registryId, CancellationToken cancellationToken)
|
|
{
|
|
var userIds = new HashSet<string>(StringComparer.Ordinal);
|
|
|
|
var adminIds = await registryDbContext.RegistryAdmins
|
|
.Where(x => x.RegistryId == registryId)
|
|
.Select(x => x.UserId)
|
|
.ToListAsync(cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
foreach (var adminId in adminIds)
|
|
{
|
|
userIds.Add(adminId);
|
|
}
|
|
|
|
var visitorIds = await registryDbContext.RegistryVisits
|
|
.Where(x => x.RegistryId == registryId)
|
|
.Select(x => x.UserId)
|
|
.ToListAsync(cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
foreach (var visitorId in visitorIds)
|
|
{
|
|
userIds.Add(visitorId);
|
|
}
|
|
|
|
var itemIds = await registryDbContext.RegistryItems
|
|
.Where(x => x.RegistryId == registryId)
|
|
.Select(x => x.Id)
|
|
.ToListAsync(cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
if (itemIds.Count > 0)
|
|
{
|
|
var purchaserIds = await registryDbContext.ItemPurchases
|
|
.Where(x => itemIds.Contains(x.RegistryItemId))
|
|
.Select(x => x.UserId)
|
|
.Distinct()
|
|
.ToListAsync(cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
foreach (var purchaserId in purchaserIds)
|
|
{
|
|
userIds.Add(purchaserId);
|
|
}
|
|
|
|
var contributorIds = await registryDbContext.ItemContributions
|
|
.Where(x => itemIds.Contains(x.RegistryItemId))
|
|
.Select(x => x.UserId)
|
|
.Distinct()
|
|
.ToListAsync(cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
foreach (var contributorId in contributorIds)
|
|
{
|
|
userIds.Add(contributorId);
|
|
}
|
|
}
|
|
|
|
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,
|
|
x.Address
|
|
})
|
|
.ToListAsync(cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
return users
|
|
.Select(x => new RegistryAccessibleUserAddressViewModel
|
|
{
|
|
UserId = x.Id,
|
|
DisplayName = BuildUserDisplayName(x.FirstName, x.LastName, x.Email, x.Id),
|
|
Email = x.Email,
|
|
Address = string.IsNullOrWhiteSpace(x.Address) ? null : x.Address.Trim()
|
|
})
|
|
.OrderBy(x => x.DisplayName)
|
|
.ToList();
|
|
}
|
|
|
|
public async Task<IReadOnlyList<RegistryItemCategoryEditModel>> GetRegistryItemCategoriesAsync(Guid registryId, CancellationToken cancellationToken)
|
|
{
|
|
var categories = await registryDbContext.RegistryItemCategories
|
|
.Where(x => x.RegistryId == registryId)
|
|
.OrderBy(x => x.SortOrder)
|
|
.ThenBy(x => x.Name)
|
|
.Select(x => new RegistryItemCategoryEditModel
|
|
{
|
|
Id = x.Id,
|
|
Name = x.Name,
|
|
SortOrder = x.SortOrder
|
|
})
|
|
.ToListAsync(cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
if (categories.Count > 0)
|
|
{
|
|
return categories;
|
|
}
|
|
|
|
var createdCategory = await EnsureDefaultCategoryAsync(registryId, cancellationToken).ConfigureAwait(false);
|
|
|
|
return
|
|
[
|
|
new RegistryItemCategoryEditModel
|
|
{
|
|
Id = createdCategory.Id,
|
|
Name = createdCategory.Name,
|
|
SortOrder = createdCategory.SortOrder,
|
|
Items = []
|
|
}
|
|
];
|
|
}
|
|
|
|
public async Task AddRegistryItemCategoryAsync(Guid registryId, string categoryName, CancellationToken cancellationToken)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(categoryName))
|
|
{
|
|
throw new ArgumentException("Category name is required.", nameof(categoryName));
|
|
}
|
|
|
|
var normalizedName = categoryName.Trim();
|
|
|
|
var exists = await registryDbContext.RegistryItemCategories
|
|
.AnyAsync(x => x.RegistryId == registryId && x.Name == normalizedName, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
if (exists)
|
|
{
|
|
throw new InvalidOperationException("A category with this name already exists.");
|
|
}
|
|
|
|
var sortOrder = await registryDbContext.RegistryItemCategories
|
|
.Where(x => x.RegistryId == registryId)
|
|
.Select(x => (int?)x.SortOrder)
|
|
.MaxAsync(cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
registryDbContext.RegistryItemCategories.Add(new RegistryItemCategory
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
RegistryId = registryId,
|
|
Name = normalizedName,
|
|
SortOrder = (sortOrder ?? -1) + 1,
|
|
CreatedAtUtc = DateTimeOffset.UtcNow
|
|
});
|
|
|
|
await registryDbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
public async Task RenameRegistryItemCategoryAsync(Guid registryId, Guid categoryId, string categoryName, CancellationToken cancellationToken)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(categoryName))
|
|
{
|
|
throw new ArgumentException("Category name is required.", nameof(categoryName));
|
|
}
|
|
|
|
var normalizedName = categoryName.Trim();
|
|
|
|
var category = await registryDbContext.RegistryItemCategories
|
|
.FirstOrDefaultAsync(x => x.RegistryId == registryId && x.Id == categoryId, cancellationToken)
|
|
.ConfigureAwait(false) ?? throw new InvalidOperationException("Category not found.");
|
|
|
|
var exists = await registryDbContext.RegistryItemCategories
|
|
.AnyAsync(x => x.RegistryId == registryId && x.Id != categoryId && x.Name == normalizedName, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
if (exists)
|
|
{
|
|
throw new InvalidOperationException("A category with this name already exists.");
|
|
}
|
|
|
|
category.Name = normalizedName;
|
|
await registryDbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
public async Task RemoveRegistryItemCategoryAsync(Guid registryId, Guid categoryId, CancellationToken cancellationToken)
|
|
{
|
|
var categoryCount = await registryDbContext.RegistryItemCategories
|
|
.Where(x => x.RegistryId == registryId)
|
|
.CountAsync(cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
if (categoryCount <= 1)
|
|
{
|
|
throw new InvalidOperationException("At least one category is required.");
|
|
}
|
|
|
|
var category = await registryDbContext.RegistryItemCategories
|
|
.FirstOrDefaultAsync(x => x.RegistryId == registryId && x.Id == categoryId, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
if (category is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var fallbackCategory = await registryDbContext.RegistryItemCategories
|
|
.Where(x => x.RegistryId == registryId && x.Id != categoryId)
|
|
.OrderBy(x => x.SortOrder)
|
|
.FirstOrDefaultAsync(cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
if (fallbackCategory is null)
|
|
{
|
|
throw new InvalidOperationException("At least one category is required.");
|
|
}
|
|
|
|
var categoryItems = await registryDbContext.RegistryItems
|
|
.Where(x => x.RegistryId == registryId && x.CategoryId == categoryId)
|
|
.OrderBy(x => x.SortOrder)
|
|
.ToListAsync(cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
var destinationSortOrder = await registryDbContext.RegistryItems
|
|
.Where(x => x.RegistryId == registryId && x.CategoryId == fallbackCategory.Id)
|
|
.Select(x => (int?)x.SortOrder)
|
|
.MaxAsync(cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
var nextSort = (destinationSortOrder ?? -1) + 1;
|
|
foreach (var item in categoryItems)
|
|
{
|
|
item.CategoryId = fallbackCategory.Id;
|
|
item.SortOrder = nextSort;
|
|
nextSort++;
|
|
}
|
|
|
|
registryDbContext.RegistryItemCategories.Remove(category);
|
|
|
|
await NormalizeCategoryOrderAsync(registryId, cancellationToken).ConfigureAwait(false);
|
|
await NormalizeItemOrderAsync(registryId, fallbackCategory.Id, cancellationToken).ConfigureAwait(false);
|
|
|
|
await registryDbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
public async Task MoveRegistryItemAsync(Guid registryId, Guid itemId, Guid categoryId, int targetIndex, CancellationToken cancellationToken)
|
|
{
|
|
var item = await registryDbContext.RegistryItems
|
|
.FirstOrDefaultAsync(x => x.RegistryId == registryId && x.Id == itemId, cancellationToken)
|
|
.ConfigureAwait(false) ?? throw new InvalidOperationException("Item not found.");
|
|
|
|
var categoryExists = await registryDbContext.RegistryItemCategories
|
|
.AnyAsync(x => x.RegistryId == registryId && x.Id == categoryId, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
if (!categoryExists)
|
|
{
|
|
throw new InvalidOperationException("Category not found.");
|
|
}
|
|
|
|
var sourceCategoryId = item.CategoryId;
|
|
|
|
var targetItems = await registryDbContext.RegistryItems
|
|
.Where(x => x.RegistryId == registryId && x.CategoryId == categoryId && x.Id != itemId)
|
|
.OrderBy(x => x.SortOrder)
|
|
.ToListAsync(cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
if (targetIndex < 0)
|
|
{
|
|
targetIndex = 0;
|
|
}
|
|
|
|
if (targetIndex > targetItems.Count)
|
|
{
|
|
targetIndex = targetItems.Count;
|
|
}
|
|
|
|
targetItems.Insert(targetIndex, item);
|
|
|
|
for (var i = 0; i < targetItems.Count; i++)
|
|
{
|
|
targetItems[i].CategoryId = categoryId;
|
|
targetItems[i].SortOrder = i;
|
|
}
|
|
|
|
if (sourceCategoryId != categoryId)
|
|
{
|
|
await NormalizeItemOrderAsync(registryId, sourceCategoryId, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
await registryDbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
public async Task MoveRegistryCategoryAsync(Guid registryId, Guid categoryId, int targetIndex, CancellationToken cancellationToken)
|
|
{
|
|
var categories = await registryDbContext.RegistryItemCategories
|
|
.Where(x => x.RegistryId == registryId)
|
|
.OrderBy(x => x.SortOrder)
|
|
.ToListAsync(cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
var category = categories.FirstOrDefault(x => x.Id == categoryId);
|
|
if (category is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
categories.Remove(category);
|
|
|
|
if (targetIndex < 0)
|
|
{
|
|
targetIndex = 0;
|
|
}
|
|
|
|
if (targetIndex > categories.Count)
|
|
{
|
|
targetIndex = categories.Count;
|
|
}
|
|
|
|
categories.Insert(targetIndex, category);
|
|
|
|
for (var i = 0; i < categories.Count; i++)
|
|
{
|
|
categories[i].SortOrder = i;
|
|
}
|
|
|
|
await registryDbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
private async Task<RegistryItemCategory> EnsureDefaultCategoryAsync(Guid registryId, CancellationToken cancellationToken)
|
|
{
|
|
var categories = await registryDbContext.RegistryItemCategories
|
|
.Where(x => x.RegistryId == registryId)
|
|
.OrderBy(x => x.SortOrder)
|
|
.ToListAsync(cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
RegistryItemCategory defaultCategory;
|
|
if (categories.Count == 0)
|
|
{
|
|
defaultCategory = new RegistryItemCategory
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
RegistryId = registryId,
|
|
Name = DefaultCategoryName,
|
|
SortOrder = 0,
|
|
CreatedAtUtc = DateTimeOffset.UtcNow
|
|
};
|
|
registryDbContext.RegistryItemCategories.Add(defaultCategory);
|
|
}
|
|
else
|
|
{
|
|
defaultCategory = categories.FirstOrDefault(x => x.Name == DefaultCategoryName) ?? categories[0];
|
|
}
|
|
|
|
var unassignedItems = await registryDbContext.RegistryItems
|
|
.Where(x => x.RegistryId == registryId && x.CategoryId == Guid.Empty)
|
|
.OrderBy(x => x.SortOrder)
|
|
.ThenBy(x => x.Name)
|
|
.ToListAsync(cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
if (unassignedItems.Count > 0)
|
|
{
|
|
var nextSortOrder = await registryDbContext.RegistryItems
|
|
.Where(x => x.RegistryId == registryId && x.CategoryId == defaultCategory.Id)
|
|
.Select(x => (int?)x.SortOrder)
|
|
.MaxAsync(cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
var next = (nextSortOrder ?? -1) + 1;
|
|
foreach (var item in unassignedItems)
|
|
{
|
|
item.CategoryId = defaultCategory.Id;
|
|
item.SortOrder = next;
|
|
next++;
|
|
}
|
|
}
|
|
|
|
await NormalizeCategoryOrderAsync(registryId, cancellationToken).ConfigureAwait(false);
|
|
await NormalizeItemOrderAsync(registryId, defaultCategory.Id, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (registryDbContext.ChangeTracker.HasChanges())
|
|
{
|
|
await registryDbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
return defaultCategory;
|
|
}
|
|
|
|
private async Task NormalizeCategoryOrderAsync(Guid registryId, CancellationToken cancellationToken)
|
|
{
|
|
var categories = await registryDbContext.RegistryItemCategories
|
|
.Where(x => x.RegistryId == registryId)
|
|
.OrderBy(x => x.SortOrder)
|
|
.ThenBy(x => x.CreatedAtUtc)
|
|
.ToListAsync(cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
for (var i = 0; i < categories.Count; i++)
|
|
{
|
|
categories[i].SortOrder = i;
|
|
}
|
|
}
|
|
|
|
private async Task NormalizeItemOrderAsync(Guid registryId, Guid categoryId, CancellationToken cancellationToken)
|
|
{
|
|
var items = await registryDbContext.RegistryItems
|
|
.Where(x => x.RegistryId == registryId && x.CategoryId == categoryId)
|
|
.OrderBy(x => x.SortOrder)
|
|
.ThenBy(x => x.CreatedAtUtc)
|
|
.ToListAsync(cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
for (var i = 0; i < items.Count; i++)
|
|
{
|
|
items[i].SortOrder = i;
|
|
}
|
|
}
|
|
}
|