Initial Blazor birth registry app, theming, and services
Build and Push Docker Image / build-and-push (push) Successful in 1m14s
Build and Push Docker Image / build-and-push (push) Successful in 1m14s
Implemented a Blazor Web App (.NET 8) for a public-by-link birth registry platform, following project guidelines. Added domain entities, EF Core context, and Blazor components for authentication, registry management, and public views. Introduced core services for registries, theming, user context, platform owner bootstrapping, and SMTP email. Included static assets (Bootstrap, favicon), launch settings, Dockerfile, CI workflow, and deployment configs. Added bootstrap.min.css.map for improved CSS debugging.
This commit is contained in:
@@ -0,0 +1,466 @@
|
||||
using BirthList.Domain.Entities;
|
||||
using BirthList.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace BirthList.Web.Features.Registries;
|
||||
|
||||
internal sealed class RegistryService(RegistryDbContext registryDbContext)
|
||||
{
|
||||
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
|
||||
});
|
||||
|
||||
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<RegistryPublicViewModel?> GetPublicRegistryByCodeAsync(string code, string? userId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(code))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var registry = await registryDbContext.Registries
|
||||
.Include(x => x.Items)
|
||||
.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);
|
||||
}
|
||||
|
||||
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,
|
||||
Items = registry.Items
|
||||
.OrderBy(x => x.Name)
|
||||
.Select(x => new RegistryPublicItemViewModel
|
||||
{
|
||||
Id = x.Id,
|
||||
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,
|
||||
CanBeSecondHand = x.CanBeSecondHand,
|
||||
IsGiven = x.IsGiven
|
||||
})
|
||||
.ToList()
|
||||
};
|
||||
}
|
||||
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
await registryDbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<RegistryItemEditModel>> GetRegistryItemsAsync(Guid registryId, CancellationToken cancellationToken)
|
||||
{
|
||||
return await registryDbContext.RegistryItems
|
||||
.Where(x => x.RegistryId == registryId)
|
||||
.OrderBy(x => x.Name)
|
||||
.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
|
||||
})
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
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)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
entity = new RegistryItem
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
RegistryId = registryId,
|
||||
CreatedAtUtc = DateTimeOffset.UtcNow
|
||||
};
|
||||
registryDbContext.RegistryItems.Add(entity);
|
||||
}
|
||||
|
||||
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.CanBeSecondHand = model.CanBeSecondHand;
|
||||
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;
|
||||
|
||||
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;
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user