Compare commits

...

2 Commits

Author SHA1 Message Date
Arne Moerman fa704ab996 Add user profile fields and completion prompt
Build and Push Docker Image / build-and-push (push) Successful in 23s
Added `FirstName`, `LastName`, and `Address` fields to `ApplicationUser` for extended user profiles. Updated `Index.razor` to include these fields in the profile form with validation. Introduced `ProfileCompletionService` to check profile completeness and added a prompt in `TopBar.razor` for incomplete profiles.

Created migration `20260518213355_AddUserProfileFields` to update the database schema. Updated `RegistryService` to use new fields for user display names. Registered `ProfileCompletionService` and `DbContextFactory` in `Program.cs`. Styled the profile completion banner in `TopBar.razor.css`.
2026-05-18 23:56:28 +02:00
Arne Moerman c36e04029b Add contribution payment options to registries
- Added support for single and amount-specific QR codes.
- Updated `RegistrySettings` model and database schema.
- Enhanced `RegistryAdmin` to configure payment options.
- Introduced `RegistryContributionAmount` for partial fulfillment.
- Updated `RegistryPublic` to display contribution progress.
- Added new models for payment methods and QR code handling.
- Improved privacy by limiting contributor visibility.
2026-05-18 23:33:06 +02:00
23 changed files with 1752 additions and 39 deletions
+1
View File
@@ -6,6 +6,7 @@
- Authentication must support local accounts plus Microsoft and Google login; admin invite links are single-use and not email-locked.
- Money participation defaults to EUR and tracks fulfilled amount per item via bank-transfer contributions with item-linked messages.
- Registry visibility is public-by-link initially, but logged-in users who visited a registry should be able to rediscover it in-app.
- Identities must be hidden from non-admin users for privacy, only admins and the user who purchased/contributed can see that he did so. Other users may see a generic indication that a contrubution/purchase was made but never a name/email of another user.
- Use SQL Server as the default provider and set `RequireConfirmedAccount=false` for MVP testing; the first registered account should be the owner with full access.
## Technical Specifications
@@ -7,6 +7,8 @@ public class RegistrySettings
public string? BankAccountBic { get; set; }
public string? BankAccountDisplayName { get; set; }
public bool ShowBankAccountName { get; set; }
public string? ContributionQrCodeUrl { get; set; }
public string? ContributionAmountQrCodesJson { get; set; }
public Registry Registry { get; set; } = null!;
}
@@ -0,0 +1,489 @@
// <auto-generated />
using System;
using BirthList.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace BirthList.Infrastructure.Migrations
{
[DbContext(typeof(RegistryDbContext))]
[Migration("20260518194616_AddContributionPaymentOptionsToRegistrySettings")]
partial class AddContributionPaymentOptionsToRegistrySettings
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.26")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("BirthList.Domain.Entities.ItemContribution", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<decimal>("Amount")
.HasPrecision(18, 2)
.HasColumnType("decimal(18,2)");
b.Property<DateTimeOffset>("ContributedAtUtc")
.HasColumnType("datetimeoffset");
b.Property<string>("CurrencyCode")
.IsRequired()
.HasMaxLength(3)
.HasColumnType("nvarchar(3)");
b.Property<Guid>("RegistryItemId")
.HasColumnType("uniqueidentifier");
b.Property<string>("TransferMessage")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<string>("UserId")
.IsRequired()
.HasMaxLength(450)
.HasColumnType("nvarchar(450)");
b.HasKey("Id");
b.HasIndex("RegistryItemId");
b.ToTable("ItemContributions");
});
modelBuilder.Entity("BirthList.Domain.Entities.ItemPurchase", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTimeOffset>("PurchasedAtUtc")
.HasColumnType("datetimeoffset");
b.Property<int>("Quantity")
.HasColumnType("int");
b.Property<Guid>("RegistryItemId")
.HasColumnType("uniqueidentifier");
b.Property<string>("UserId")
.IsRequired()
.HasMaxLength(450)
.HasColumnType("nvarchar(450)");
b.HasKey("Id");
b.HasIndex("RegistryItemId");
b.ToTable("ItemPurchases");
});
modelBuilder.Entity("BirthList.Domain.Entities.PlatformOwner", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("AssignedAtUtc")
.HasColumnType("datetimeoffset");
b.Property<string>("UserId")
.IsRequired()
.HasMaxLength(450)
.HasColumnType("nvarchar(450)");
b.HasKey("Id");
b.HasIndex("UserId")
.IsUnique();
b.ToTable("PlatformOwners");
});
modelBuilder.Entity("BirthList.Domain.Entities.Registry", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("BabyName")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<DateOnly?>("BirthDate")
.HasColumnType("date");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("datetimeoffset");
b.Property<string>("CurrencyCode")
.IsRequired()
.HasMaxLength(3)
.HasColumnType("nvarchar(3)");
b.Property<string>("HeaderContentHtml")
.HasColumnType("nvarchar(max)");
b.Property<string>("PublicLinkCode")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<int>("RegistryType")
.HasColumnType("int");
b.Property<string>("ShippingAddress")
.HasColumnType("nvarchar(max)");
b.Property<string>("ThemeKey")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(250)
.HasColumnType("nvarchar(250)");
b.HasKey("Id");
b.HasIndex("PublicLinkCode")
.IsUnique();
b.ToTable("Registries");
});
modelBuilder.Entity("BirthList.Domain.Entities.RegistryAdmin", b =>
{
b.Property<Guid>("RegistryId")
.HasColumnType("uniqueidentifier");
b.Property<string>("UserId")
.HasMaxLength(450)
.HasColumnType("nvarchar(450)");
b.Property<DateTimeOffset>("AddedAtUtc")
.HasColumnType("datetimeoffset");
b.HasKey("RegistryId", "UserId");
b.ToTable("RegistryAdmins");
});
modelBuilder.Entity("BirthList.Domain.Entities.RegistryAdminInvite", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTimeOffset>("ExpiresAtUtc")
.HasColumnType("datetimeoffset");
b.Property<DateTimeOffset?>("RedeemedAtUtc")
.HasColumnType("datetimeoffset");
b.Property<Guid>("RegistryId")
.HasColumnType("uniqueidentifier");
b.Property<string>("SentToEmail")
.HasMaxLength(320)
.HasColumnType("nvarchar(320)");
b.Property<string>("Token")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("nvarchar(120)");
b.HasKey("Id");
b.HasIndex("RegistryId");
b.HasIndex("Token")
.IsUnique();
b.ToTable("RegistryAdminInvites");
});
modelBuilder.Entity("BirthList.Domain.Entities.RegistryItem", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("datetimeoffset");
b.Property<string>("CurrencyCode")
.IsRequired()
.HasMaxLength(3)
.HasColumnType("nvarchar(3)");
b.Property<string>("Description")
.HasColumnType("nvarchar(max)");
b.Property<int>("DesiredQuantity")
.HasColumnType("int");
b.Property<bool>("IsGiven")
.HasColumnType("bit");
b.Property<decimal>("MoneyFulfilledAmount")
.HasPrecision(18, 2)
.HasColumnType("decimal(18,2)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("nvarchar(300)");
b.Property<bool>("ParticipationAllowed")
.HasColumnType("bit");
b.Property<decimal?>("ParticipationTargetAmount")
.HasPrecision(18, 2)
.HasColumnType("decimal(18,2)");
b.Property<string>("PictureUrl")
.HasMaxLength(2048)
.HasColumnType("nvarchar(2048)");
b.Property<bool?>("PreferSecondHand")
.HasColumnType("bit");
b.Property<decimal?>("PriceAmount")
.HasPrecision(18, 2)
.HasColumnType("decimal(18,2)");
b.Property<string>("ProductUrl")
.HasMaxLength(2048)
.HasColumnType("nvarchar(2048)");
b.Property<int>("PurchasedQuantity")
.HasColumnType("int");
b.Property<Guid>("RegistryId")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("RegistryId");
b.ToTable("RegistryItems");
});
modelBuilder.Entity("BirthList.Domain.Entities.RegistrySettings", b =>
{
b.Property<Guid>("RegistryId")
.HasColumnType("uniqueidentifier");
b.Property<string>("BankAccountBic")
.HasMaxLength(11)
.HasColumnType("nvarchar(11)");
b.Property<string>("BankAccountDisplayName")
.HasMaxLength(120)
.HasColumnType("nvarchar(120)");
b.Property<string>("BankAccountIban")
.HasMaxLength(34)
.HasColumnType("nvarchar(34)");
b.Property<string>("ContributionAmountQrCodesJson")
.HasMaxLength(4000)
.HasColumnType("nvarchar(4000)");
b.Property<string>("ContributionQrCodeUrl")
.HasMaxLength(2048)
.HasColumnType("nvarchar(2048)");
b.Property<bool>("ShowBankAccountName")
.HasColumnType("bit");
b.HasKey("RegistryId");
b.ToTable("RegistrySettings");
});
modelBuilder.Entity("BirthList.Domain.Entities.RegistryVisit", b =>
{
b.Property<Guid>("RegistryId")
.HasColumnType("uniqueidentifier");
b.Property<string>("UserId")
.HasMaxLength(450)
.HasColumnType("nvarchar(450)");
b.Property<DateTimeOffset>("LastVisitedAtUtc")
.HasColumnType("datetimeoffset");
b.HasKey("RegistryId", "UserId");
b.ToTable("RegistryVisits");
});
modelBuilder.Entity("BirthList.Domain.Entities.UserActionLog", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<int>("ActionType")
.HasColumnType("int");
b.Property<decimal>("Amount")
.HasPrecision(18, 2)
.HasColumnType("decimal(18,2)");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("datetimeoffset");
b.Property<string>("Details")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<int>("Quantity")
.HasColumnType("int");
b.Property<Guid>("RegistryId")
.HasColumnType("uniqueidentifier");
b.Property<Guid?>("RegistryItemId")
.HasColumnType("uniqueidentifier");
b.Property<string>("UserId")
.IsRequired()
.HasMaxLength(450)
.HasColumnType("nvarchar(450)");
b.HasKey("Id");
b.HasIndex("RegistryId");
b.ToTable("UserActionLogs");
});
modelBuilder.Entity("BirthList.Domain.Entities.ItemContribution", b =>
{
b.HasOne("BirthList.Domain.Entities.RegistryItem", "RegistryItem")
.WithMany("Contributions")
.HasForeignKey("RegistryItemId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("RegistryItem");
});
modelBuilder.Entity("BirthList.Domain.Entities.ItemPurchase", b =>
{
b.HasOne("BirthList.Domain.Entities.RegistryItem", "RegistryItem")
.WithMany("Purchases")
.HasForeignKey("RegistryItemId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("RegistryItem");
});
modelBuilder.Entity("BirthList.Domain.Entities.RegistryAdmin", b =>
{
b.HasOne("BirthList.Domain.Entities.Registry", "Registry")
.WithMany("Admins")
.HasForeignKey("RegistryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Registry");
});
modelBuilder.Entity("BirthList.Domain.Entities.RegistryAdminInvite", b =>
{
b.HasOne("BirthList.Domain.Entities.Registry", "Registry")
.WithMany("AdminInvites")
.HasForeignKey("RegistryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Registry");
});
modelBuilder.Entity("BirthList.Domain.Entities.RegistryItem", b =>
{
b.HasOne("BirthList.Domain.Entities.Registry", "Registry")
.WithMany("Items")
.HasForeignKey("RegistryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Registry");
});
modelBuilder.Entity("BirthList.Domain.Entities.RegistrySettings", b =>
{
b.HasOne("BirthList.Domain.Entities.Registry", "Registry")
.WithOne()
.HasForeignKey("BirthList.Domain.Entities.RegistrySettings", "RegistryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Registry");
});
modelBuilder.Entity("BirthList.Domain.Entities.RegistryVisit", b =>
{
b.HasOne("BirthList.Domain.Entities.Registry", "Registry")
.WithMany("Visits")
.HasForeignKey("RegistryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Registry");
});
modelBuilder.Entity("BirthList.Domain.Entities.UserActionLog", b =>
{
b.HasOne("BirthList.Domain.Entities.Registry", "Registry")
.WithMany("ActionLogs")
.HasForeignKey("RegistryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Registry");
});
modelBuilder.Entity("BirthList.Domain.Entities.Registry", b =>
{
b.Navigation("ActionLogs");
b.Navigation("AdminInvites");
b.Navigation("Admins");
b.Navigation("Items");
b.Navigation("Visits");
});
modelBuilder.Entity("BirthList.Domain.Entities.RegistryItem", b =>
{
b.Navigation("Contributions");
b.Navigation("Purchases");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,40 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace BirthList.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddContributionPaymentOptionsToRegistrySettings : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "ContributionAmountQrCodesJson",
table: "RegistrySettings",
type: "nvarchar(4000)",
maxLength: 4000,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "ContributionQrCodeUrl",
table: "RegistrySettings",
type: "nvarchar(2048)",
maxLength: 2048,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ContributionAmountQrCodesJson",
table: "RegistrySettings");
migrationBuilder.DropColumn(
name: "ContributionQrCodeUrl",
table: "RegistrySettings");
}
}
}
@@ -299,6 +299,14 @@ namespace BirthList.Infrastructure.Migrations
.HasMaxLength(34)
.HasColumnType("nvarchar(34)");
b.Property<string>("ContributionAmountQrCodesJson")
.HasMaxLength(4000)
.HasColumnType("nvarchar(4000)");
b.Property<string>("ContributionQrCodeUrl")
.HasMaxLength(2048)
.HasColumnType("nvarchar(2048)");
b.Property<bool>("ShowBankAccountName")
.HasColumnType("bit");
@@ -57,6 +57,8 @@ public class RegistryDbContext(DbContextOptions<RegistryDbContext> options) : Db
entity.Property(x => x.BankAccountIban).HasMaxLength(34);
entity.Property(x => x.BankAccountBic).HasMaxLength(11);
entity.Property(x => x.BankAccountDisplayName).HasMaxLength(120);
entity.Property(x => x.ContributionQrCodeUrl).HasMaxLength(2048);
entity.Property(x => x.ContributionAmountQrCodesJson).HasMaxLength(4000);
entity.HasOne(x => x.Registry)
.WithOne()
.HasForeignKey<RegistrySettings>(x => x.RegistryId)
@@ -23,6 +23,21 @@
<input type="text" value="@username" class="form-control" placeholder="Please choose your username." disabled />
<label for="username" class="form-label">Username</label>
</div>
<div class="form-floating mb-3">
<InputText @bind-Value="Input.FirstName" class="form-control" placeholder="Please enter your first name." />
<label class="form-label">First name</label>
<ValidationMessage For="() => Input.FirstName" class="text-danger" />
</div>
<div class="form-floating mb-3">
<InputText @bind-Value="Input.LastName" class="form-control" placeholder="Please enter your last name." />
<label class="form-label">Last name</label>
<ValidationMessage For="() => Input.LastName" class="text-danger" />
</div>
<div class="mb-3">
<label class="form-label">Address</label>
<InputTextArea @bind-Value="Input.Address" class="form-control" rows="3" placeholder="Please enter your address." />
<ValidationMessage For="() => Input.Address" class="text-danger" />
</div>
<div class="form-floating mb-3">
<InputText @bind-Value="Input.PhoneNumber" class="form-control" placeholder="Please enter your phone number." />
<label for="phone-number" class="form-label">Phone number</label>
@@ -51,6 +66,9 @@
phoneNumber = await UserManager.GetPhoneNumberAsync(user);
Input.PhoneNumber ??= phoneNumber;
Input.FirstName ??= user.FirstName;
Input.LastName ??= user.LastName;
Input.Address ??= user.Address;
}
private async Task OnValidSubmitAsync()
@@ -64,12 +82,34 @@
}
}
user.FirstName = string.IsNullOrWhiteSpace(Input.FirstName) ? null : Input.FirstName.Trim();
user.LastName = string.IsNullOrWhiteSpace(Input.LastName) ? null : Input.LastName.Trim();
user.Address = string.IsNullOrWhiteSpace(Input.Address) ? null : Input.Address.Trim();
var updateResult = await UserManager.UpdateAsync(user);
if (!updateResult.Succeeded)
{
RedirectManager.RedirectToCurrentPageWithStatus("Error: Failed to update profile.", HttpContext);
}
await SignInManager.RefreshSignInAsync(user);
RedirectManager.RedirectToCurrentPageWithStatus("Your profile has been updated", HttpContext);
}
private sealed class InputModel
{
[StringLength(100)]
[Display(Name = "First name")]
public string? FirstName { get; set; }
[StringLength(100)]
[Display(Name = "Last name")]
public string? LastName { get; set; }
[StringLength(500)]
[Display(Name = "Address")]
public string? Address { get; set; }
[Phone]
[Display(Name = "Phone number")]
public string? PhoneNumber { get; set; }
@@ -1,7 +1,19 @@
@using BirthList.Web.Features.Registries
@using BirthList.Web.Services
@implements IDisposable
@inject NavigationManager NavigationManager
@inject AuthenticationStateProvider AuthenticationStateProvider
@inject RegistryUserContext RegistryUserContext
@inject ProfileCompletionService ProfileCompletionService
@if (ShowProfileCompletionPrompt)
{
<div class="profile-completion-banner">
<span>Please complete your profile (first name, last name, and address).</span>
<a href="/Account/Manage" class="profile-completion-link">Complete profile</a>
</div>
}
<nav class="top-bar">
<div class="top-bar-left">
@@ -40,16 +52,34 @@
@code {
private string currentUrl = "";
private bool ShowProfileCompletionPrompt { get; set; }
protected override void OnInitialized()
protected override async Task OnInitializedAsync()
{
currentUrl = GetRelativePath(NavigationManager.Uri);
NavigationManager.LocationChanged += OnLocationChanged;
await RefreshProfileCompletionPromptAsync().ConfigureAwait(false);
}
private void OnLocationChanged(object? sender, LocationChangedEventArgs e)
private async void OnLocationChanged(object? sender, LocationChangedEventArgs e)
{
currentUrl = GetRelativePath(e.Location);
await RefreshProfileCompletionPromptAsync().ConfigureAwait(false);
await InvokeAsync(StateHasChanged).ConfigureAwait(false);
}
private async Task RefreshProfileCompletionPromptAsync()
{
var userId = await RegistryUserContext.GetUserIdAsync(CancellationToken.None).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(userId))
{
ShowProfileCompletionPrompt = false;
return;
}
var isComplete = await ProfileCompletionService.IsProfileCompleteAsync(userId).ConfigureAwait(false);
ShowProfileCompletionPrompt = !isComplete;
}
private string GetRelativePath(string absoluteUri)
@@ -1,3 +1,25 @@
.profile-completion-banner {
display: flex;
justify-content: center;
align-items: center;
gap: 0.75rem;
background-color: #fff3cd;
color: #664d03;
border-bottom: 1px solid #ffecb5;
padding: 0.5rem 1rem;
font-size: 0.9rem;
}
.profile-completion-link {
color: #664d03;
font-weight: 600;
text-decoration: underline;
}
.profile-completion-link:hover {
color: #523d02;
}
.top-bar {
display: flex;
justify-content: space-between;
@@ -247,6 +247,50 @@ else
</div>
</div>
<div class="mt-3">
<h3>Contribution Payment Options</h3>
<div class="row g-3 mt-2">
<div class="col-md-12">
<label class="form-label">Single QR code URL</label>
<InputText class="form-control" @bind-Value="SettingsModel.ContributionQrCodeUrl" />
<small class="form-text text-muted">Optional: one QR code that donors can scan for any amount.</small>
</div>
</div>
<div class="mt-3">
<div class="d-flex justify-content-between align-items-center">
<h4 class="mb-0">Amount-specific QR codes</h4>
<button type="button" class="btn btn-outline-secondary btn-sm" @onclick="AddContributionAmountQrCode">Add QR amount</button>
</div>
@if (SettingsModel.ContributionAmountQrCodes.Count == 0)
{
<p class="text-muted mt-2 mb-0">No amount-specific QR codes configured.</p>
}
else
{
<div class="mt-2 d-flex flex-column gap-2">
@foreach (var amountQr in SettingsModel.ContributionAmountQrCodes)
{
<div class="row g-2 align-items-end">
<div class="col-md-3">
<label class="form-label">Amount</label>
<InputNumber class="form-control" @bind-Value="amountQr.Amount" />
</div>
<div class="col-md-7">
<label class="form-label">QR code URL</label>
<InputText class="form-control" @bind-Value="amountQr.QrCodeUrl" />
</div>
<div class="col-md-2">
<button type="button" class="btn btn-outline-danger w-100" @onclick="() => RemoveContributionAmountQrCode(amountQr)">Remove</button>
</div>
</div>
}
</div>
}
</div>
</div>
<button class="btn btn-primary mt-4" type="submit">Save settings</button>
</EditForm>
</section>
@@ -39,6 +39,9 @@ public partial class RegistryAdmin : ComponentBase
builder.AddMarkupContent(3, "<span class='ql-formats'><button class='ql-link'></button><button class='ql-clean'></button></span>");
};
private bool _pendingEditorLoad;
private string? _lastLoadedHeaderContentHtml;
protected override async Task OnParametersSetAsync()
{
var userId = await RegistryUserContext.GetUserIdAsync(CancellationToken.None).ConfigureAwait(false);
@@ -60,14 +63,16 @@ public partial class RegistryAdmin : ComponentBase
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender || TextEditor is null || string.IsNullOrWhiteSpace(SettingsModel.HeaderContentHtml))
if (TextEditor is null || !_pendingEditorLoad)
{
return;
}
try
{
await TextEditor.LoadHTMLContent(SettingsModel.HeaderContentHtml).ConfigureAwait(false);
await TextEditor.LoadHTMLContent(SettingsModel.HeaderContentHtml ?? string.Empty).ConfigureAwait(false);
_lastLoadedHeaderContentHtml = SettingsModel.HeaderContentHtml ?? string.Empty;
_pendingEditorLoad = false;
}
catch (JSException)
{
@@ -229,6 +234,17 @@ public partial class RegistryAdmin : ComponentBase
Admins = await RegistryService.GetRegistryAdminsAsync(RegistryId, CancellationToken.None).ConfigureAwait(false);
}
protected void AddContributionAmountQrCode()
{
SettingsModel.ContributionAmountQrCodes.Add(new ContributionAmountQrCodeModel());
}
protected void RemoveContributionAmountQrCode(ContributionAmountQrCodeModel model)
{
ArgumentNullException.ThrowIfNull(model);
SettingsModel.ContributionAmountQrCodes.Remove(model);
}
private async Task LoadAsync()
{
var settings = await RegistryService.GetRegistrySettingsAsync(RegistryId, CancellationToken.None).ConfigureAwait(false);
@@ -244,6 +260,11 @@ public partial class RegistryAdmin : ComponentBase
SettingsModel.BankAccountIban = settings.BankAccountIban;
SettingsModel.BankAccountBic = settings.BankAccountBic;
SettingsModel.ShowBankAccountName = settings.ShowBankAccountName;
SettingsModel.ContributionQrCodeUrl = settings.ContributionQrCodeUrl;
SettingsModel.ContributionAmountQrCodes = settings.ContributionAmountQrCodes;
var currentHeaderContent = SettingsModel.HeaderContentHtml ?? string.Empty;
_pendingEditorLoad = currentHeaderContent != _lastLoadedHeaderContentHtml;
}
Admins = await RegistryService.GetRegistryAdminsAsync(RegistryId, CancellationToken.None).ConfigureAwait(false);
@@ -0,0 +1,75 @@
@page "/registry/{Code}/item/{ItemId:guid}/contribution-amount"
@rendermode InteractiveServer
@using BirthList.Web.Features.Registries
<PageTitle>Contribution Amount</PageTitle>
@if (Registry is null || Item is null)
{
<p>Item not found.</p>
}
else if (!IsAuthenticated)
{
<p>Please log in first.</p>
}
else
{
<div class="registry-shell @RegistryThemeService.GetCssClass(Registry.RegistryType, Registry.ThemeKey)">
<h1>Partially fulfill: @Item.Name</h1>
<div class="card p-3">
@if (RepresentableAmounts.Count == 0)
{
<p class="text-muted mb-0">No representable amount can be formed from configured QR codes up to €200.</p>
}
else
{
<label class="form-label">Select amount: €@SelectedAmount</label>
<input type="range"
class="form-range"
min="0"
max="@(RepresentableAmounts.Count - 1)"
step="1"
@bind="SliderIndex"
@bind:after="RecalculateSuggestions" />
}
@if (Suggestions.Count == 0)
{
<p class="text-muted mt-2">No QR combination available for this amount.</p>
}
else
{
<p class="mt-3 mb-2"><strong>Suggested QR combination:</strong></p>
<ul class="mb-3">
@foreach (var suggestion in Suggestions)
{
<li>@suggestion.RepeatCount x €@suggestion.Amount</li>
}
</ul>
<div class="d-flex flex-column gap-3">
@foreach (var suggestion in Suggestions)
{
@for (var i = 0; i < suggestion.RepeatCount; i++)
{
<div>
<div class="small mb-1">€@suggestion.Amount</div>
<img src="@BuildQrImageUrl(suggestion.QrCodeUrl)" alt="QR code @suggestion.Amount" class="img-fluid" style="max-height: 220px;" />
<div class="small text-muted text-break">
<a href="@suggestion.QrCodeUrl" target="_blank" rel="noopener noreferrer">Open payment link</a>
</div>
</div>
}
}
</div>
}
<div class="d-flex gap-2 mt-3">
<button class="btn btn-success" @onclick="ConfirmAsync" disabled="@(SelectedAmount <= 0)">I transferred this amount</button>
<button class="btn btn-outline-secondary" @onclick="BackToRegistry">Back</button>
</div>
</div>
</div>
}
@@ -0,0 +1,210 @@
using BirthList.Web.Features.Registries;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Components;
namespace BirthList.Web.Components.Pages;
[Authorize]
public partial class RegistryContributionAmount : ComponentBase
{
[Parameter] public string Code { get; set; } = string.Empty;
[Parameter] public Guid ItemId { get; set; }
[Inject] private RegistryService RegistryService { get; set; } = null!;
[Inject] private RegistryUserContext RegistryUserContext { get; set; } = null!;
[Inject] private RegistryThemeService RegistryThemeService { get; set; } = null!;
[Inject] private NavigationManager NavigationManager { get; set; } = null!;
protected RegistryPublicViewModel? Registry { get; private set; }
protected RegistryPublicItemViewModel? Item { get; private set; }
protected bool IsAuthenticated { get; private set; }
protected int SelectedAmount { get; private set; }
protected int SliderMin { get; private set; } = 1;
protected int SliderIndex { get; set; }
protected IReadOnlyList<int> RepresentableAmounts { get; private set; } = [];
protected List<QrCombinationSuggestion> Suggestions { get; private set; } = [];
protected override async Task OnParametersSetAsync()
{
var userId = await RegistryUserContext.GetUserIdAsync(CancellationToken.None).ConfigureAwait(false);
IsAuthenticated = !string.IsNullOrWhiteSpace(userId);
if (!IsAuthenticated)
{
return;
}
Registry = await RegistryService.GetPublicRegistryByCodeAsync(Code, userId, CancellationToken.None).ConfigureAwait(false);
Item = Registry?.Items.FirstOrDefault(x => x.Id == ItemId);
if (Registry is null || Item is null)
{
return;
}
var configuredAmounts = Registry.ContributionAmountQrCodes
.Select(x => (int)Math.Round(x.Amount, MidpointRounding.AwayFromZero))
.Where(x => x > 0)
.Distinct()
.OrderBy(x => x)
.ToList();
SliderMin = configuredAmounts.Count > 0 ? configuredAmounts[0] : 1;
RepresentableAmounts = Enumerable.Range(SliderMin, 201 - SliderMin)
.Where(x => BuildSuggestions(x).Count > 0)
.ToList();
if (RepresentableAmounts.Count == 0)
{
SelectedAmount = 0;
SliderIndex = 0;
Suggestions = [];
return;
}
SliderIndex = 0;
SelectedAmount = RepresentableAmounts[SliderIndex];
RecalculateSuggestions();
}
protected void RecalculateSuggestions()
{
if (RepresentableAmounts.Count == 0)
{
Suggestions = [];
SelectedAmount = 0;
return;
}
var maxIndex = RepresentableAmounts.Count - 1;
if (SliderIndex < 0)
{
SliderIndex = 0;
}
else if (SliderIndex > maxIndex)
{
SliderIndex = maxIndex;
}
SelectedAmount = RepresentableAmounts[SliderIndex];
Suggestions = BuildSuggestions(SelectedAmount);
}
protected async Task ConfirmAsync()
{
var userId = await RegistryUserContext.GetUserIdAsync(CancellationToken.None).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(userId) || Item is null || SelectedAmount <= 0)
{
return;
}
await RegistryService.AddContributionAsync(Item.Id, userId, SelectedAmount, $"Participation for item: {Item.Name}", CancellationToken.None).ConfigureAwait(false);
BackToRegistry();
}
protected void BackToRegistry()
{
NavigationManager.NavigateTo($"/registry/{Code}");
}
private List<QrCombinationSuggestion> BuildSuggestions(decimal requestedAmount)
{
if (Registry is null || requestedAmount <= 0)
{
return [];
}
var target = (int)Math.Round(requestedAmount, MidpointRounding.AwayFromZero);
var options = Registry.ContributionAmountQrCodes
.Where(x => x.Amount > 0 && !string.IsNullOrWhiteSpace(x.QrCodeUrl))
.Select(x => new
{
Amount = (int)Math.Round(x.Amount, MidpointRounding.AwayFromZero),
QrCodeUrl = x.QrCodeUrl.Trim()
})
.Where(x => x.Amount > 0)
.DistinctBy(x => x.Amount)
.OrderByDescending(x => x.Amount)
.ToList();
if (options.Count == 0 || target <= 0)
{
return [];
}
var max = int.MaxValue / 4;
var minCoins = Enumerable.Repeat(max, target + 1).ToArray();
var choice = Enumerable.Repeat(-1, target + 1).ToArray();
minCoins[0] = 0;
for (var amount = 1; amount <= target; amount++)
{
for (var i = 0; i < options.Count; i++)
{
var option = options[i];
if (option.Amount > amount)
{
continue;
}
var candidate = minCoins[amount - option.Amount];
if (candidate == max)
{
continue;
}
candidate += 1;
if (candidate < minCoins[amount])
{
minCoins[amount] = candidate;
choice[amount] = i;
}
}
}
if (minCoins[target] == max)
{
return [];
}
var counts = new Dictionary<int, int>();
var current = target;
while (current > 0)
{
var optionIndex = choice[current];
if (optionIndex < 0)
{
return [];
}
var optionAmount = options[optionIndex].Amount;
counts[optionIndex] = counts.TryGetValue(optionIndex, out var count) ? count + 1 : 1;
current -= optionAmount;
}
return counts
.Select(x => new QrCombinationSuggestion
{
Amount = options[x.Key].Amount,
QrCodeUrl = options[x.Key].QrCodeUrl,
RepeatCount = x.Value
})
.OrderBy(x => x.Amount)
.ToList();
}
protected static string BuildQrImageUrl(string paymentLink)
{
ArgumentException.ThrowIfNullOrWhiteSpace(paymentLink);
return $"https://api.qrserver.com/v1/create-qr-code/?size=320x320&data={Uri.EscapeDataString(paymentLink)}";
}
protected sealed class QrCombinationSuggestion
{
public int Amount { get; init; }
public string QrCodeUrl { get; init; } = string.Empty;
public int RepeatCount { get; init; }
}
}
@@ -64,14 +64,17 @@ else
{
<p class="card-text item-description-text">@item.Description</p>
}
<p class="mb-1"><strong>Qty:</strong> @item.PurchasedQuantity/@item.DesiredQuantity purchased</p>
@if (item.DesiredQuantity > 1)
{
<p class="mb-1"><strong>Qty:</strong> @item.PurchasedQuantity/@item.DesiredQuantity purchased</p>
}
@if (item.PriceAmount.HasValue)
{
<p class="mb-1"><strong>Price:</strong> @item.PriceAmount.Value.ToString("0.00") @item.CurrencyCode</p>
}
@if (item.ParticipationAllowed && item.ParticipationTargetAmount.HasValue)
@if (item.ParticipationAllowed && GetParticipationTotalAmount(item).HasValue)
{
<p class="mb-2"><strong>Participation:</strong> @item.MoneyFulfilledAmount.ToString("0.00") / @item.ParticipationTargetAmount.Value.ToString("0.00") @item.CurrencyCode</p>
<p class="mb-2"><strong>Participation:</strong> @item.MoneyFulfilledAmount.ToString("0.00") out of €@GetParticipationTotalAmount(item)!.Value.ToString("0.00") fulfilled</p>
}
@if (item.Purchasers.Count > 0 || item.Contributors.Count > 0)
@@ -110,13 +113,39 @@ else
@if (item.Contributors.Count > 0)
{
<div class="mb-2">
<strong class="text-sm">Contributed by:</strong>
<div class="contributor-list">
@foreach (var contributor in item.Contributors)
@if (Registry.IsAdmin)
{
<strong class="text-sm">Contributed by:</strong>
<div class="contributor-list">
@foreach (var contributor in item.Contributors)
{
<span class="contributor-badge">@contributor.DisplayName (@contributor.Amount.ToString("0.00") @item.CurrencyCode)</span>
}
</div>
}
else if (!string.IsNullOrWhiteSpace(Registry.CurrentUserId))
{
var currentUserContribution = item.Contributors.FirstOrDefault(x => x.UserId == Registry.CurrentUserId);
if (currentUserContribution is not null)
{
<span class="contributor-badge">@contributor.DisplayName (@contributor.Amount.ToString("0.00") @item.CurrencyCode)</span>
<strong class="text-sm">Contributed by:</strong>
<div class="contributor-list">
<span class="contributor-badge">@currentUserContribution.DisplayName (@currentUserContribution.Amount.ToString("0.00") @item.CurrencyCode)</span>
@if (item.Contributors.Count > 1)
{
<span class="contributor-badge">and others</span>
}
</div>
}
</div>
else
{
<span class="text-muted small">Contributed</span>
}
}
else
{
<span class="text-muted small">Contributed</span>
}
</div>
}
</div>
@@ -147,7 +176,7 @@ else
}
@if (item.ParticipationAllowed)
{
<button class="btn btn-secondary btn-sm" @onclick="() => OpenContributionPrompt(item.Id)">I transferred money</button>
<button class="btn btn-secondary btn-sm" @onclick="() => OpenContributionPrompt(item.Id)">Partially fulfill</button>
}
</div>
}
@@ -219,3 +248,91 @@ else
</div>
</div>
}
@if (ShowPartialFulfillWizard)
{
<div class="prompt-overlay">
<div class="prompt-card">
<h3>Partially fulfill item</h3>
@if (PartialFulfillStep == 1)
{
<p>Select how you want to donate:</p>
<div class="d-flex flex-column gap-2">
@if (HasIbanPaymentOption())
{
<button class="btn btn-outline-primary text-start" @onclick="() => SelectPaymentMethod(ContributionPaymentMethodType.Iban)">
IBAN transfer
</button>
}
@if (HasSingleQrPaymentOption())
{
<button class="btn btn-outline-primary text-start" @onclick="() => SelectPaymentMethod(ContributionPaymentMethodType.SingleQrCode)">
Single QR code
</button>
}
@if (HasAmountSpecificQrPaymentOption())
{
<button class="btn btn-outline-primary text-start" @onclick="() => SelectPaymentMethod(ContributionPaymentMethodType.AmountSpecificQrCode)">
QR code per amount
</button>
}
</div>
@if (SelectedPaymentMethod == ContributionPaymentMethodType.Iban && !string.IsNullOrWhiteSpace(Registry?.BankAccountIban))
{
<div class="alert alert-secondary mt-3 mb-0">
<strong>IBAN:</strong> @Registry.BankAccountIban
@if (!string.IsNullOrWhiteSpace(Registry.BankAccountBic))
{
<span> | <strong>BIC:</strong> @Registry.BankAccountBic</span>
}
</div>
}
@if (!string.IsNullOrWhiteSpace(SelectedPaymentQrCodeUrl))
{
<div class="mt-3 text-center">
<img src="@BuildQrImageUrl(SelectedPaymentQrCodeUrl)" alt="Payment QR code" class="img-fluid" style="max-height: 240px;" />
<div class="mt-2">
<a href="@SelectedPaymentQrCodeUrl" target="_blank" rel="noopener noreferrer">Open payment link</a>
</div>
</div>
}
<div class="d-flex gap-2 mt-3">
<button class="btn btn-primary" @onclick="ContinueContributionWizard" disabled="@(SelectedPaymentMethod is null)">Next</button>
<button class="btn btn-outline-secondary" @onclick="CloseContributionPrompt">Cancel</button>
</div>
}
else if (PartialFulfillStep == 2)
{
<p>How much did you add for this item?</p>
<InputNumber class="form-control" @bind-Value="ContributionAmount" @bind-Value:after="OnContributionAmountChanged" />
<p class="mt-2">Message: @ContributionMessage</p>
@if (SelectedPaymentMethod == ContributionPaymentMethodType.AmountSpecificQrCode && ContributionAmount > 0 && string.IsNullOrWhiteSpace(SelectedPaymentQrCodeUrl))
{
<p class="text-muted mt-2 mb-0">No QR code configured for this exact amount.</p>
}
@if (!string.IsNullOrWhiteSpace(SelectedPaymentQrCodeUrl))
{
<div class="mt-3 text-center">
<img src="@BuildQrImageUrl(SelectedPaymentQrCodeUrl)" alt="Payment QR code" class="img-fluid" style="max-height: 240px;" />
<div class="mt-2">
<a href="@SelectedPaymentQrCodeUrl" target="_blank" rel="noopener noreferrer">Open payment link</a>
</div>
</div>
}
<div class="d-flex gap-2 mt-3">
<button class="btn btn-success" @onclick="ConfirmContributionAsync">Confirm</button>
<button class="btn btn-outline-secondary" @onclick="BackContributionWizard">Back</button>
<button class="btn btn-outline-secondary" @onclick="CloseContributionPrompt">Cancel</button>
</div>
}
</div>
</div>
}
@@ -27,6 +27,11 @@ public partial class RegistryPublic : ComponentBase
protected string? PurchaseItemUrl { get; private set; }
protected IReadOnlyList<ItemContributorViewModel> PurchasersToUnmark { get; private set; } = [];
protected bool ShowPartialFulfillWizard { get; private set; }
protected int PartialFulfillStep { get; private set; } = 1;
protected ContributionPaymentMethodType? SelectedPaymentMethod { get; set; }
protected string? SelectedPaymentQrCodeUrl { get; private set; }
protected override async Task OnParametersSetAsync()
{
var userId = await RegistryUserContext.GetUserIdAsync(CancellationToken.None).ConfigureAwait(false);
@@ -97,27 +102,95 @@ public partial class RegistryPublic : ComponentBase
ContributionMessage = Registry?.Items.FirstOrDefault(x => x.Id == itemId) is { } item
? $"Participation for item: {item.Name}"
: string.Empty;
ShowContributionPrompt = true;
ShowPartialFulfillWizard = true;
PartialFulfillStep = 1;
SelectedPaymentMethod = null;
SelectedPaymentQrCodeUrl = null;
}
protected void CloseContributionPrompt()
{
ShowContributionPrompt = false;
ShowPartialFulfillWizard = false;
PartialFulfillStep = 1;
SelectedPaymentMethod = null;
SelectedPaymentQrCodeUrl = null;
ActiveItemId = Guid.Empty;
ContributionMessage = string.Empty;
}
protected async Task ConfirmContributionAsync()
protected bool HasIbanPaymentOption()
{
var userId = await RegistryUserContext.GetUserIdAsync(CancellationToken.None).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(userId) || ActiveItemId == Guid.Empty || ContributionAmount <= 0)
return Registry is not null && !string.IsNullOrWhiteSpace(Registry.BankAccountIban);
}
protected bool HasSingleQrPaymentOption()
{
return Registry is not null && !string.IsNullOrWhiteSpace(Registry.ContributionQrCodeUrl);
}
protected bool HasAmountSpecificQrPaymentOption()
{
return Registry is not null && Registry.ContributionAmountQrCodes.Count > 0;
}
protected void SelectPaymentMethod(ContributionPaymentMethodType method)
{
SelectedPaymentMethod = method;
SelectedPaymentQrCodeUrl = method switch
{
return;
ContributionPaymentMethodType.SingleQrCode => Registry?.ContributionQrCodeUrl,
ContributionPaymentMethodType.AmountSpecificQrCode => FindAmountSpecificQrCode(ContributionAmount),
_ => null
};
}
protected void ContinueContributionWizard()
{
if (PartialFulfillStep == 1)
{
if (SelectedPaymentMethod is null)
{
return;
}
if (SelectedPaymentMethod == ContributionPaymentMethodType.AmountSpecificQrCode && ActiveItemId != Guid.Empty)
{
ShowPartialFulfillWizard = false;
NavigationManager.NavigateTo($"/registry/{Code}/item/{ActiveItemId}/contribution-amount");
return;
}
PartialFulfillStep = 2;
}
}
protected void BackContributionWizard()
{
if (PartialFulfillStep > 1)
{
PartialFulfillStep--;
}
}
protected void OnContributionAmountChanged()
{
if (SelectedPaymentMethod == ContributionPaymentMethodType.AmountSpecificQrCode)
{
SelectedPaymentQrCodeUrl = FindAmountSpecificQrCode(ContributionAmount);
}
}
private string? FindAmountSpecificQrCode(decimal amount)
{
if (Registry is null || amount <= 0)
{
return null;
}
await RegistryService.AddContributionAsync(ActiveItemId, userId, ContributionAmount, ContributionMessage, CancellationToken.None).ConfigureAwait(false);
ShowContributionPrompt = false;
Registry = await RegistryService.GetPublicRegistryByCodeAsync(Code, userId, CancellationToken.None).ConfigureAwait(false);
var match = Registry.ContributionAmountQrCodes
.FirstOrDefault(x => x.Amount == amount);
return match?.QrCodeUrl;
}
protected async Task UnmarkPurchaseAsync(Guid itemId)
@@ -184,4 +257,32 @@ public partial class RegistryPublic : ComponentBase
ShowPurchaserSelectionPrompt = false;
Registry = await RegistryService.GetPublicRegistryByCodeAsync(Code, userId, CancellationToken.None).ConfigureAwait(false);
}
protected async Task ConfirmContributionAsync()
{
var userId = await RegistryUserContext.GetUserIdAsync(CancellationToken.None).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(userId) || ActiveItemId == Guid.Empty || ContributionAmount <= 0)
{
return;
}
await RegistryService.AddContributionAsync(ActiveItemId, userId, ContributionAmount, ContributionMessage, CancellationToken.None).ConfigureAwait(false);
ShowPartialFulfillWizard = false;
PartialFulfillStep = 1;
SelectedPaymentMethod = null;
SelectedPaymentQrCodeUrl = null;
Registry = await RegistryService.GetPublicRegistryByCodeAsync(Code, userId, CancellationToken.None).ConfigureAwait(false);
}
protected static decimal? GetParticipationTotalAmount(RegistryPublicItemViewModel item)
{
ArgumentNullException.ThrowIfNull(item);
return item.ParticipationTargetAmount ?? item.PriceAmount;
}
protected static string BuildQrImageUrl(string paymentLink)
{
ArgumentException.ThrowIfNullOrWhiteSpace(paymentLink);
return $"https://api.qrserver.com/v1/create-qr-code/?size=320x320&data={Uri.EscapeDataString(paymentLink)}";
}
}
@@ -5,5 +5,8 @@ namespace BirthList.Web.Data;
// Add profile data for application users by adding properties to the ApplicationUser class
public class ApplicationUser : IdentityUser
{
public string? FirstName { get; set; }
public string? LastName { get; set; }
public string? Address { get; set; }
}
@@ -0,0 +1,288 @@
// <auto-generated />
using System;
using BirthList.Web.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace BirthList.Web.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20260518213355_AddUserProfileFields")]
partial class AddUserProfileFields
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.26")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("BirthList.Web.Data.ApplicationUser", b =>
{
b.Property<string>("Id")
.HasColumnType("nvarchar(450)");
b.Property<int>("AccessFailedCount")
.HasColumnType("int");
b.Property<string>("Address")
.HasColumnType("nvarchar(max)");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("nvarchar(max)");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("bit");
b.Property<string>("FirstName")
.HasColumnType("nvarchar(max)");
b.Property<string>("LastName")
.HasColumnType("nvarchar(max)");
b.Property<bool>("LockoutEnabled")
.HasColumnType("bit");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("datetimeoffset");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("PasswordHash")
.HasColumnType("nvarchar(max)");
b.Property<string>("PhoneNumber")
.HasColumnType("nvarchar(max)");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("bit");
b.Property<string>("SecurityStamp")
.HasColumnType("nvarchar(max)");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("bit");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex")
.HasFilter("[NormalizedUserName] IS NOT NULL");
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
{
b.Property<string>("Id")
.HasColumnType("nvarchar(450)");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("nvarchar(max)");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex")
.HasFilter("[NormalizedName] IS NOT NULL");
b.ToTable("AspNetRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("nvarchar(max)");
b.Property<string>("ClaimValue")
.HasColumnType("nvarchar(max)");
b.Property<string>("RoleId")
.IsRequired()
.HasColumnType("nvarchar(450)");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("nvarchar(max)");
b.Property<string>("ClaimValue")
.HasColumnType("nvarchar(max)");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("nvarchar(450)");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("nvarchar(450)");
b.Property<string>("ProviderKey")
.HasColumnType("nvarchar(450)");
b.Property<string>("ProviderDisplayName")
.HasColumnType("nvarchar(max)");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("nvarchar(450)");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("nvarchar(450)");
b.Property<string>("RoleId")
.HasColumnType("nvarchar(450)");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("nvarchar(450)");
b.Property<string>("LoginProvider")
.HasColumnType("nvarchar(450)");
b.Property<string>("Name")
.HasColumnType("nvarchar(450)");
b.Property<string>("Value")
.HasColumnType("nvarchar(max)");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.HasOne("BirthList.Web.Data.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.HasOne("BirthList.Web.Data.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("BirthList.Web.Data.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.HasOne("BirthList.Web.Data.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,48 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace BirthList.Web.Migrations
{
/// <inheritdoc />
public partial class AddUserProfileFields : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Address",
table: "AspNetUsers",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "FirstName",
table: "AspNetUsers",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "LastName",
table: "AspNetUsers",
type: "nvarchar(max)",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Address",
table: "AspNetUsers");
migrationBuilder.DropColumn(
name: "FirstName",
table: "AspNetUsers");
migrationBuilder.DropColumn(
name: "LastName",
table: "AspNetUsers");
}
}
}
@@ -1,8 +1,9 @@
// <auto-generated />
// <auto-generated />
using System;
using BirthList.Web.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
@@ -15,7 +16,11 @@ namespace BirthList.Web.Migrations
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.0");
modelBuilder
.HasAnnotation("ProductVersion", "8.0.26")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("BirthList.Web.Data.ApplicationUser", b =>
{
@@ -25,6 +30,9 @@ namespace BirthList.Web.Migrations
b.Property<int>("AccessFailedCount")
.HasColumnType("int");
b.Property<string>("Address")
.HasColumnType("nvarchar(max)");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("nvarchar(max)");
@@ -36,6 +44,12 @@ namespace BirthList.Web.Migrations
b.Property<bool>("EmailConfirmed")
.HasColumnType("bit");
b.Property<string>("FirstName")
.HasColumnType("nvarchar(max)");
b.Property<string>("LastName")
.HasColumnType("nvarchar(max)");
b.Property<bool>("LockoutEnabled")
.HasColumnType("bit");
@@ -115,6 +129,8 @@ namespace BirthList.Web.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("nvarchar(max)");
@@ -138,6 +154,8 @@ namespace BirthList.Web.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("nvarchar(max)");
@@ -2,6 +2,19 @@ using BirthList.Domain.Entities;
namespace BirthList.Web.Features.Registries;
public enum ContributionPaymentMethodType
{
Iban = 1,
SingleQrCode = 2,
AmountSpecificQrCode = 3
}
public sealed class ContributionAmountQrCodeModel
{
public decimal Amount { get; set; }
public string QrCodeUrl { get; set; } = string.Empty;
}
public sealed class RegistryCreateModel
{
public string Title { get; set; } = string.Empty;
@@ -47,6 +60,8 @@ public sealed class RegistrySettingsEditModel
public string? BankAccountBic { get; set; }
public string? BankAccountDisplayName { get; set; }
public bool ShowBankAccountName { get; set; }
public string? ContributionQrCodeUrl { get; set; }
public List<ContributionAmountQrCodeModel> ContributionAmountQrCodes { get; set; } = [];
}
public sealed class RegistrySummaryViewModel
@@ -73,6 +88,8 @@ public sealed class RegistryPublicViewModel
public string? BankAccountBic { get; init; }
public string? BankAccountDisplayName { get; init; }
public bool ShowBankAccountName { get; init; }
public string? ContributionQrCodeUrl { get; init; }
public IReadOnlyList<ContributionAmountQrCodeModel> ContributionAmountQrCodes { get; init; } = [];
public string? CurrentUserId { get; init; }
public bool IsAdmin { get; init; }
public IReadOnlyList<RegistryPublicItemViewModel> Items { get; init; } = [];
@@ -4,6 +4,7 @@ using BirthList.Web.Data;
using Microsoft.EntityFrameworkCore;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace BirthList.Web.Features.Registries;
@@ -94,18 +95,22 @@ internal sealed class RegistryService(RegistryDbContext registryDbContext, Appli
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
var usersById = await applicationDbContext.Users
var users = await applicationDbContext.Users
.Where(x => admins.Contains(x.Id))
.Select(x => new { x.Id, x.Email })
.ToDictionaryAsync(x => x.Id, x => x.Email, cancellationToken)
.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 email) && !string.IsNullOrWhiteSpace(email)
? email
DisplayName = usersById.TryGetValue(userId, out var displayName)
? displayName
: userId
})
.ToList();
@@ -179,12 +184,16 @@ internal sealed class RegistryService(RegistryDbContext registryDbContext, Appli
userIds.Add(contribution.UserId);
}
var usersById = await applicationDbContext.Users
var users = 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)
.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());
@@ -210,6 +219,8 @@ internal sealed class RegistryService(RegistryDbContext registryDbContext, Appli
BankAccountBic = settings?.BankAccountBic,
BankAccountDisplayName = settings?.BankAccountDisplayName,
ShowBankAccountName = settings?.ShowBankAccountName ?? false,
ContributionQrCodeUrl = settings?.ContributionQrCodeUrl,
ContributionAmountQrCodes = ParseContributionAmountQrCodes(settings?.ContributionAmountQrCodesJson),
CurrentUserId = userId,
IsAdmin = isAdmin,
Items = registry.Items
@@ -289,7 +300,9 @@ internal sealed class RegistryService(RegistryDbContext registryDbContext, Appli
BankAccountIban = settings?.BankAccountIban,
BankAccountBic = settings?.BankAccountBic,
BankAccountDisplayName = settings?.BankAccountDisplayName,
ShowBankAccountName = settings?.ShowBankAccountName ?? false
ShowBankAccountName = settings?.ShowBankAccountName ?? false,
ContributionQrCodeUrl = settings?.ContributionQrCodeUrl,
ContributionAmountQrCodes = ParseContributionAmountQrCodes(settings?.ContributionAmountQrCodesJson).ToList()
};
}
@@ -325,10 +338,68 @@ internal sealed class RegistryService(RegistryDbContext registryDbContext, Appli
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 items = await registryDbContext.RegistryItems
@@ -359,12 +430,16 @@ internal sealed class RegistryService(RegistryDbContext registryDbContext, Appli
userIds.Add(contribution.UserId);
}
var usersById = await applicationDbContext.Users
var users = 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)
.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());
@@ -445,12 +520,16 @@ internal sealed class RegistryService(RegistryDbContext registryDbContext, Appli
userIds.Add(contribution.UserId);
}
var usersById = await applicationDbContext.Users
var users = 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)
.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,
@@ -844,4 +923,20 @@ internal sealed class RegistryService(RegistryDbContext registryDbContext, Appli
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;
}
}
+12
View File
@@ -90,6 +90,17 @@ builder.Services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlite(connectionString);
});
builder.Services.AddDbContextFactory<ApplicationDbContext>(options =>
{
if (string.Equals(dataProvider, "SqlServer", StringComparison.OrdinalIgnoreCase))
{
options.UseSqlServer(connectionString);
return;
}
options.UseSqlite(connectionString);
}, ServiceLifetime.Scoped);
builder.Services.AddDbContext<RegistryDbContext>(options =>
{
if (string.Equals(dataProvider, "SqlServer", StringComparison.OrdinalIgnoreCase))
@@ -113,6 +124,7 @@ builder.Services.AddIdentityCore<ApplicationUser>(options =>
builder.Services.AddScoped<SmtpEmailSender>();
builder.Services.AddScoped<IEmailSender<ApplicationUser>>(serviceProvider => serviceProvider.GetRequiredService<SmtpEmailSender>());
builder.Services.AddScoped<ProfileCompletionService>();
var app = builder.Build();
@@ -0,0 +1,30 @@
using BirthList.Web.Data;
using Microsoft.EntityFrameworkCore;
namespace BirthList.Web.Services;
internal sealed class ProfileCompletionService(IDbContextFactory<ApplicationDbContext> dbContextFactory)
{
public async Task<bool> IsProfileCompleteAsync(string userId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(userId);
await using var dbContext = await dbContextFactory.CreateDbContextAsync().ConfigureAwait(false);
var user = await dbContext.Users
.AsNoTracking()
.Where(x => x.Id == userId)
.Select(x => new { x.FirstName, x.LastName, x.Address })
.FirstOrDefaultAsync()
.ConfigureAwait(false);
if (user is null)
{
return true;
}
return !string.IsNullOrWhiteSpace(user.FirstName)
&& !string.IsNullOrWhiteSpace(user.LastName)
&& !string.IsNullOrWhiteSpace(user.Address);
}
}