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,111 @@
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace BirthList.Web.Features.Registries;
|
||||
|
||||
internal sealed class RegistryMetadataService(IHttpClientFactory httpClientFactory)
|
||||
{
|
||||
private static readonly Regex MetaTagRegex = new("<meta\\b[^>]*>", RegexOptions.IgnoreCase | RegexOptions.Compiled, TimeSpan.FromSeconds(1));
|
||||
private static readonly Regex AttributeRegex = new("(name|property|content)\\s*=\\s*['\"]([^'\"]*)['\"]", RegexOptions.IgnoreCase | RegexOptions.Compiled, TimeSpan.FromSeconds(1));
|
||||
|
||||
public async Task<UrlMetadataResult?> FetchAsync(string url, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var client = httpClientFactory.CreateClient("RegistryMetadata");
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, uri);
|
||||
request.Headers.UserAgent.ParseAdd("BirthListBot/1.0");
|
||||
|
||||
using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var html = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (string.IsNullOrWhiteSpace(html))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var meta = ParseMeta(html);
|
||||
meta.TryGetValue("product:price:amount", out var priceRaw);
|
||||
meta.TryGetValue("product:price:currency", out var currency);
|
||||
|
||||
decimal? price = null;
|
||||
if (!string.IsNullOrWhiteSpace(priceRaw) && decimal.TryParse(priceRaw, NumberStyles.Number, CultureInfo.InvariantCulture, out var parsedPrice))
|
||||
{
|
||||
price = parsedPrice;
|
||||
}
|
||||
|
||||
return new UrlMetadataResult
|
||||
{
|
||||
Title = First(meta, "og:title", "twitter:title", "title"),
|
||||
Description = First(meta, "og:description", "twitter:description", "description"),
|
||||
ImageUrl = First(meta, "og:image", "twitter:image"),
|
||||
PriceAmount = price,
|
||||
CurrencyCode = string.IsNullOrWhiteSpace(currency) ? null : currency.ToUpperInvariant()
|
||||
};
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> ParseMeta(string html)
|
||||
{
|
||||
var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (Match metaTag in MetaTagRegex.Matches(html))
|
||||
{
|
||||
var tag = metaTag.Value;
|
||||
string? key = null;
|
||||
string? value = null;
|
||||
|
||||
foreach (Match attributeMatch in AttributeRegex.Matches(tag))
|
||||
{
|
||||
var attribute = attributeMatch.Groups[1].Value;
|
||||
var attributeValue = attributeMatch.Groups[2].Value;
|
||||
|
||||
if (string.Equals(attribute, "name", StringComparison.OrdinalIgnoreCase) || string.Equals(attribute, "property", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
key = attributeValue;
|
||||
}
|
||||
else if (string.Equals(attribute, "content", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
value = attributeValue;
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(key) && !string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
map[key.Trim()] = value.Trim();
|
||||
}
|
||||
}
|
||||
|
||||
var titleStart = html.IndexOf("<title>", StringComparison.OrdinalIgnoreCase);
|
||||
var titleEnd = html.IndexOf("</title>", StringComparison.OrdinalIgnoreCase);
|
||||
if (titleStart >= 0 && titleEnd > titleStart)
|
||||
{
|
||||
var title = html.Substring(titleStart + 7, titleEnd - (titleStart + 7)).Trim();
|
||||
if (!string.IsNullOrWhiteSpace(title))
|
||||
{
|
||||
map["title"] = title;
|
||||
}
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
private static string? First(IDictionary<string, string> map, params string[] keys)
|
||||
{
|
||||
foreach (var key in keys)
|
||||
{
|
||||
if (map.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
using BirthList.Domain.Entities;
|
||||
|
||||
namespace BirthList.Web.Features.Registries;
|
||||
|
||||
public sealed class RegistryCreateModel
|
||||
{
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public RegistryType RegistryType { get; set; } = RegistryType.Birth;
|
||||
public string ThemeKey { get; set; } = "default";
|
||||
}
|
||||
|
||||
public sealed class RegistryItemEditModel
|
||||
{
|
||||
public Guid? Id { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? PictureUrl { get; set; }
|
||||
public string? ProductUrl { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public decimal? PriceAmount { get; set; }
|
||||
public string CurrencyCode { get; set; } = "EUR";
|
||||
public int DesiredQuantity { get; set; } = 1;
|
||||
public bool ParticipationAllowed { get; set; }
|
||||
public decimal? ParticipationTargetAmount { get; set; }
|
||||
public bool CanBeSecondHand { get; set; }
|
||||
public bool IsGiven { get; set; }
|
||||
}
|
||||
|
||||
public sealed class RegistrySettingsEditModel
|
||||
{
|
||||
public string? BabyName { get; set; }
|
||||
public DateOnly? BirthDate { get; set; }
|
||||
public string? HeaderContentHtml { get; set; }
|
||||
public string? ShippingAddress { get; set; }
|
||||
public string CurrencyCode { get; set; } = "EUR";
|
||||
public string ThemeKey { get; set; } = "default";
|
||||
public string? BankAccountIban { get; set; }
|
||||
public string? BankAccountBic { get; set; }
|
||||
public string? BankAccountDisplayName { get; set; }
|
||||
}
|
||||
|
||||
public sealed class RegistrySummaryViewModel
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public string Title { get; init; } = string.Empty;
|
||||
public string PublicLinkCode { get; init; } = string.Empty;
|
||||
public RegistryType RegistryType { get; init; }
|
||||
public string ThemeKey { get; init; } = "default";
|
||||
}
|
||||
|
||||
public sealed class RegistryPublicViewModel
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public string Title { get; init; } = string.Empty;
|
||||
public string PublicLinkCode { get; init; } = string.Empty;
|
||||
public string? BabyName { get; init; }
|
||||
public string? HeaderContentHtml { get; init; }
|
||||
public string? ShippingAddress { get; init; }
|
||||
public string CurrencyCode { get; init; } = "EUR";
|
||||
public string ThemeKey { get; init; } = "default";
|
||||
public RegistryType RegistryType { get; init; }
|
||||
public string? BankAccountIban { get; init; }
|
||||
public string? BankAccountBic { get; init; }
|
||||
public string? BankAccountDisplayName { get; init; }
|
||||
public IReadOnlyList<RegistryPublicItemViewModel> Items { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed class RegistryPublicItemViewModel
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public string Name { get; init; } = string.Empty;
|
||||
public string? PictureUrl { get; init; }
|
||||
public string? ProductUrl { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public decimal? PriceAmount { get; init; }
|
||||
public string CurrencyCode { get; init; } = "EUR";
|
||||
public int DesiredQuantity { get; init; }
|
||||
public int PurchasedQuantity { get; init; }
|
||||
public bool ParticipationAllowed { get; init; }
|
||||
public decimal? ParticipationTargetAmount { get; init; }
|
||||
public decimal MoneyFulfilledAmount { get; init; }
|
||||
public bool CanBeSecondHand { get; init; }
|
||||
public bool IsGiven { get; init; }
|
||||
}
|
||||
|
||||
public sealed class UrlMetadataResult
|
||||
{
|
||||
public string? Title { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public string? ImageUrl { get; init; }
|
||||
public decimal? PriceAmount { get; init; }
|
||||
public string? CurrencyCode { get; init; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using BirthList.Domain.Entities;
|
||||
|
||||
namespace BirthList.Web.Features.Registries;
|
||||
|
||||
internal sealed class RegistryThemeService
|
||||
{
|
||||
public string GetCssClass(RegistryType registryType, string? themeKey)
|
||||
{
|
||||
var normalized = string.IsNullOrWhiteSpace(themeKey) ? "default" : themeKey.Trim().ToLowerInvariant();
|
||||
var typeSegment = registryType.ToString().ToLowerInvariant();
|
||||
return $"theme-{typeSegment}-{normalized}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace BirthList.Web.Features.Registries;
|
||||
|
||||
internal sealed class RegistryUserContext(AuthenticationStateProvider authenticationStateProvider)
|
||||
{
|
||||
public async Task<string?> GetUserIdAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var state = await authenticationStateProvider.GetAuthenticationStateAsync().ConfigureAwait(false);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return state.User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
}
|
||||
|
||||
public async Task<bool> IsAuthenticatedAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var state = await authenticationStateProvider.GetAuthenticationStateAsync().ConfigureAwait(false);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return state.User.Identity?.IsAuthenticated == true;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user