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

- Introduced `UserActionLog` entity to track user actions.
- Replaced `CanBeSecondHand` with `PreferSecondHand` property.
- Added `ShowBankAccountName` to `RegistrySettings`.
- Updated models and migrations for new properties.
- Enhanced `RegistryService` with user action logging and item details.
- Redesigned `Home.razor` with a grid layout and modal for registries.
- Added `RegistryActionLog.razor` for admin action logs.
- Improved `RegistryPublic.razor` with purchaser/contributor details.
- Replaced sidebar with `TopBar.razor` for responsive navigation.
- Updated CSS for new components and improved responsiveness.
This commit is contained in:
Arne Moerman
2026-05-17 20:57:54 +02:00
parent 6b593828d7
commit 2bf0295508
32 changed files with 3811 additions and 435 deletions
@@ -18,4 +18,5 @@ public class Registry
public ICollection<RegistryItem> Items { get; set; } = [];
public ICollection<RegistryVisit> Visits { get; set; } = [];
public ICollection<RegistryAdminInvite> AdminInvites { get; set; } = [];
public ICollection<UserActionLog> ActionLogs { get; set; } = [];
}
@@ -13,7 +13,7 @@ public class RegistryItem
public int DesiredQuantity { get; set; } = 1;
public bool ParticipationAllowed { get; set; }
public decimal? ParticipationTargetAmount { get; set; }
public bool CanBeSecondHand { get; set; }
public bool? PreferSecondHand { get; set; }
public bool IsGiven { get; set; }
public int PurchasedQuantity { get; set; }
public decimal MoneyFulfilledAmount { get; set; }
@@ -6,6 +6,7 @@ public class RegistrySettings
public string? BankAccountIban { get; set; }
public string? BankAccountBic { get; set; }
public string? BankAccountDisplayName { get; set; }
public bool ShowBankAccountName { get; set; }
public Registry Registry { get; set; } = null!;
}
@@ -0,0 +1,26 @@
namespace BirthList.Domain.Entities;
public class UserActionLog
{
public Guid Id { get; set; }
public Guid RegistryId { get; set; }
public string UserId { get; set; } = string.Empty;
public Guid? RegistryItemId { get; set; }
public UserActionType ActionType { get; set; }
public int Quantity { get; set; }
public decimal Amount { get; set; }
public string? Details { get; set; }
public DateTimeOffset CreatedAtUtc { get; set; }
public Registry Registry { get; set; } = null!;
}
public enum UserActionType
{
RegistryLinkOpened = 1,
ItemLinkOpened = 2,
MarkPurchased = 3,
UnmarkPurchased = 4,
MarkPartialPurchase = 5,
LogContribution = 6
}
@@ -6,6 +6,17 @@
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.26" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="8.0.26" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.26">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.26" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.26" />
</ItemGroup>
<ItemGroup>
<Folder Include="Migrations\" />
</ItemGroup>
<PropertyGroup>
@@ -0,0 +1,427 @@
// <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("20260517141758_AddShowBankAccountNameToRegistrySettings")]
partial class AddShowBankAccountNameToRegistrySettings
{
/// <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<bool>("CanBeSecondHand")
.HasColumnType("bit");
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<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<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.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.Registry", b =>
{
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,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace BirthList.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddShowBankAccountNameToRegistrySettings : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "ShowBankAccountName",
table: "RegistrySettings",
type: "bit",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ShowBankAccountName",
table: "RegistrySettings");
}
}
}
@@ -0,0 +1,427 @@
// <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("20260517154344_RenameCanBeSecondHandToPreferSecondHand")]
partial class RenameCanBeSecondHandToPreferSecondHand
{
/// <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<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.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.Registry", b =>
{
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,46 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace BirthList.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class RenameCanBeSecondHandToPreferSecondHand : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.RenameColumn(
name: "CanBeSecondHand",
table: "RegistryItems",
newName: "PreferSecondHand");
migrationBuilder.AlterColumn<bool>(
name: "PreferSecondHand",
table: "RegistryItems",
type: "bit",
nullable: true,
oldClrType: typeof(bool),
oldType: "bit");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<bool>(
name: "PreferSecondHand",
table: "RegistryItems",
type: "bit",
nullable: false,
defaultValue: false,
oldClrType: typeof(bool),
oldType: "bit",
oldNullable: true);
migrationBuilder.RenameColumn(
name: "PreferSecondHand",
table: "RegistryItems",
newName: "CanBeSecondHand");
}
}
}
@@ -0,0 +1,481 @@
// <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("20260517162502_AddUserActionLog")]
partial class AddUserActionLog
{
/// <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<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,52 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace BirthList.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddUserActionLog : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "UserActionLogs",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
RegistryId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
UserId = table.Column<string>(type: "nvarchar(450)", maxLength: 450, nullable: false),
RegistryItemId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
ActionType = table.Column<int>(type: "int", nullable: false),
Quantity = table.Column<int>(type: "int", nullable: false),
Amount = table.Column<decimal>(type: "decimal(18,2)", precision: 18, scale: 2, nullable: false),
Details = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
CreatedAtUtc = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_UserActionLogs", x => x.Id);
table.ForeignKey(
name: "FK_UserActionLogs_Registries_RegistryId",
column: x => x.RegistryId,
principalTable: "Registries",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_UserActionLogs_RegistryId",
table: "UserActionLogs",
column: "RegistryId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "UserActionLogs");
}
}
}
@@ -0,0 +1,478 @@
// <auto-generated />
using System;
using BirthList.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace BirthList.Infrastructure.Migrations
{
[DbContext(typeof(RegistryDbContext))]
partial class RegistryDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(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<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
}
}
}
@@ -13,6 +13,7 @@ public class RegistryDbContext(DbContextOptions<RegistryDbContext> options) : Db
public DbSet<ItemPurchase> ItemPurchases => Set<ItemPurchase>();
public DbSet<ItemContribution> ItemContributions => Set<ItemContribution>();
public DbSet<RegistryVisit> RegistryVisits => Set<RegistryVisit>();
public DbSet<UserActionLog> UserActionLogs => Set<UserActionLog>();
public DbSet<PlatformOwner> PlatformOwners => Set<PlatformOwner>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
@@ -111,6 +112,18 @@ public class RegistryDbContext(DbContextOptions<RegistryDbContext> options) : Db
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<UserActionLog>(entity =>
{
entity.HasKey(x => x.Id);
entity.Property(x => x.UserId).HasMaxLength(450);
entity.Property(x => x.Amount).HasPrecision(18, 2);
entity.Property(x => x.Details).HasMaxLength(500);
entity.HasOne(x => x.Registry)
.WithMany(x => x.ActionLogs)
.HasForeignKey(x => x.RegistryId)
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<PlatformOwner>(entity =>
{
entity.HasKey(x => x.Id);
+2 -1
View File
@@ -6,11 +6,12 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<base href="/" />
<link rel="stylesheet" href="bootstrap/bootstrap.min.css" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" />
<link rel="stylesheet" href="app.css" />
<link rel="stylesheet" href="BirthList.Web.styles.css" />
<link href="https://cdn.quilljs.com/1.3.6/quill.snow.css" rel="stylesheet" />
<link href="https://cdn.quilljs.com/1.3.6/quill.bubble.css" rel="stylesheet" />
<link rel="icon" type="image/png" href="favicon.png" />
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath fill='%230d6efd' d='M3 2.5a2.5 2.5 0 0 1 5 0 2.5 2.5 0 0 1 5 0v.006c0 .07 0 .27-.038.494H15a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1v7.5a1.5 1.5 0 0 1-1.5 1.5h-11A1.5 1.5 0 0 1 1 14.5V7a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h2.038A.968.968 0 0 1 3 2.506V2.5zM4 3h3V2.5a1.5 1.5 0 1 0-3 0V3zm5 0h3V2.5a1.5 1.5 0 0 0-3 0V3zM1 6h14V4H1v2zm1 1v7.5a.5.5 0 0 0 .5.5H7V7H2zm6 8h5.5a.5.5 0 0 0 .5-.5V7H8v8z'/%3E%3C/svg%3E" />
<HeadOutlet />
</head>
@@ -1,20 +1,12 @@
@inherits LayoutComponentBase
<div class="page">
<div class="sidebar">
<NavMenu />
</div>
<TopBar />
<main>
<div class="top-row px-4">
<a href="https://learn.microsoft.com/aspnet/core/" target="_blank">About</a>
</div>
<article class="content px-4">
@Body
</article>
</main>
</div>
<main class="main-content">
<article class="content px-4">
@Body
</article>
</main>
<div id="blazor-error-ui">
An unhandled error has occurred.
@@ -2,78 +2,18 @@
position: relative;
display: flex;
flex-direction: column;
min-height: 100vh;
}
main {
.main-content {
flex: 1;
}
.sidebar {
background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%);
}
.top-row {
background-color: #f7f7f7;
border-bottom: 1px solid #d6d5d5;
justify-content: flex-end;
height: 3.5rem;
display: flex;
align-items: center;
flex-direction: column;
}
.top-row ::deep a, .top-row ::deep .btn-link {
white-space: nowrap;
margin-left: 1.5rem;
text-decoration: none;
}
.top-row ::deep a:hover, .top-row ::deep .btn-link:hover {
text-decoration: underline;
}
.top-row ::deep a:first-child {
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 640.98px) {
.top-row {
justify-content: space-between;
}
.top-row ::deep a, .top-row ::deep .btn-link {
margin-left: 0;
}
}
@media (min-width: 641px) {
.page {
flex-direction: row;
}
.sidebar {
width: 250px;
height: 100vh;
position: sticky;
top: 0;
}
.top-row {
position: sticky;
top: 0;
z-index: 1;
}
.top-row.auth ::deep a:first-child {
flex: 1;
text-align: right;
width: 0;
}
.top-row, article {
padding-left: 2rem !important;
padding-right: 1.5rem !important;
}
.content {
padding-top: 2rem;
padding-bottom: 2rem;
}
#blazor-error-ui {
@@ -82,15 +22,19 @@ main {
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
display: none;
left: 0;
padding: 0.6rem 1.25rem 0.7rem 1.25rem;
padding: 20px 40px;
position: fixed;
width: 100%;
right: 0;
z-index: 1000;
}
#blazor-error-ui .dismiss {
cursor: pointer;
position: absolute;
right: 0.75rem;
right: 3.5rem;
top: 0.5rem;
}
#blazor-error-ui.show {
display: flex;
}
@@ -0,0 +1,76 @@
@implements IDisposable
@inject NavigationManager NavigationManager
@inject AuthenticationStateProvider AuthenticationStateProvider
<nav class="top-bar">
<div class="top-bar-left">
<a href="/" class="top-bar-brand" title="Gift List">
<i class="bi bi-gift" aria-hidden="true"></i>
<span class="brand-text">Gift List</span>
</a>
</div>
<div class="top-bar-right">
<AuthorizeView>
<Authorized>
<div class="user-info">
<i class="bi bi-person-circle" aria-hidden="true"></i>
<span class="user-email">@context.User.Identity?.Name</span>
</div>
<a href="Account/Manage" class="top-bar-link" title="Account settings">
<i class="bi bi-gear" aria-hidden="true"></i>
</a>
<form action="Account/Logout" method="post" class="logout-form">
<AntiforgeryToken />
<input type="hidden" name="ReturnUrl" value="@currentUrl" />
<button type="submit" class="top-bar-link logout-btn" title="Sign out">
<i class="bi bi-box-arrow-right" aria-hidden="true"></i>
</button>
</form>
</Authorized>
<NotAuthorized>
<a href="Account/Login" class="top-bar-link" title="Sign in">
<i class="bi bi-box-arrow-right" aria-hidden="true"></i>
</a>
</NotAuthorized>
</AuthorizeView>
</div>
</nav>
@code {
private string currentUrl = "";
protected override void OnInitialized()
{
currentUrl = GetRelativePath(NavigationManager.Uri);
NavigationManager.LocationChanged += OnLocationChanged;
}
private void OnLocationChanged(object? sender, LocationChangedEventArgs e)
{
currentUrl = GetRelativePath(e.Location);
}
private string GetRelativePath(string absoluteUri)
{
var relativePath = NavigationManager.ToBaseRelativePath(absoluteUri);
if (string.IsNullOrWhiteSpace(relativePath))
{
return "";
}
// Remove leading slash if present since the logout endpoint adds ~/ prefix
if (relativePath.StartsWith('/'))
{
return relativePath.Substring(1);
}
return relativePath;
}
void IDisposable.Dispose()
{
NavigationManager.LocationChanged -= OnLocationChanged;
}
}
@@ -0,0 +1,100 @@
.top-bar {
display: flex;
justify-content: space-between;
align-items: center;
height: 3.5rem;
background-color: rgba(0, 0, 0, 0.4);
padding: 0 1.5rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.top-bar-left {
display: flex;
align-items: center;
}
.top-bar-brand {
display: flex;
align-items: center;
gap: 0.75rem;
color: white;
text-decoration: none;
font-size: 1.1rem;
font-weight: 500;
transition: opacity 0.2s;
}
.top-bar-brand:hover {
opacity: 0.8;
}
.brand-text {
display: none;
}
@media (min-width: 768px) {
.brand-text {
display: inline;
}
}
.top-bar-right {
display: flex;
align-items: center;
gap: 1.5rem;
}
.user-info {
display: flex;
align-items: center;
gap: 0.5rem;
color: white;
}
.user-email {
font-size: 0.9rem;
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 640px) {
.user-email {
display: none;
}
}
.top-bar-link {
color: white;
background: none;
border: none;
cursor: pointer;
font-size: 1.25rem;
display: flex;
align-items: center;
transition: opacity 0.2s;
padding: 0.5rem;
text-decoration: none;
}
.top-bar-link:hover,
.logout-btn:hover {
opacity: 0.7;
}
.logout-form {
margin: 0;
padding: 0;
}
.logout-btn {
display: flex;
align-items: center;
justify-content: center;
}
.bi {
font-size: 1.25rem;
line-height: 1;
}
+156 -70
View File
@@ -1,86 +1,172 @@
@page "/"
@using BirthList.Web.Features.Registries
<PageTitle>Birth Registry</PageTitle>
<h1>Birth Registry</h1>
<h1>Welcome to Gift List</h1>
<AuthorizeView>
<Authorized Context="authState">
<div class="mb-4">
<h2>Create your registry</h2>
<EditForm Model="Model" OnValidSubmit="CreateRegistryAsync" Context="formContext" FormName="create-registry-form">
<DataAnnotationsValidator />
<div class="row g-3">
<div class="col-md-5">
<label class="form-label">Title</label>
<InputText class="form-control" @bind-Value="Model.Title" />
<div class="registry-sections">
<div class="mb-4">
<div class="section-header">
<h2>Registries you manage</h2>
<button class="btn btn-primary btn-sm" @onclick="() => ShowCreateForm = !ShowCreateForm">
<span class="bi bi-plus"></span> Create new
</button>
</div>
@if (MyRegistries.Count == 0)
{
<p class="text-muted">No registries yet.</p>
}
else
{
<div class="registry-grid">
@foreach (var registry in MyRegistries)
{
<div class="registry-card">
<h3>@registry.Title</h3>
<div class="registry-actions">
<a href="/registry/@registry.PublicLinkCode" class="btn btn-outline-primary btn-sm">View</a>
<a href="/registry/@registry.Id/admin" class="btn btn-outline-secondary btn-sm">Manage</a>
</div>
</div>
}
</div>
<div class="col-md-3">
<label class="form-label">Type</label>
<InputSelect class="form-select" @bind-Value="Model.RegistryType">
<option value="@BirthList.Domain.Entities.RegistryType.Birth">Birth</option>
<option value="@BirthList.Domain.Entities.RegistryType.Wedding">Wedding</option>
<option value="@BirthList.Domain.Entities.RegistryType.Birthday">Birthday</option>
</InputSelect>
}
</div>
<div class="mb-4">
<h2>Visited registries</h2>
@if (VisitedRegistries.Count == 0)
{
<p class="text-muted">No visited registries yet.</p>
}
else
{
<div class="registry-grid">
@foreach (var registry in VisitedRegistries)
{
<div class="registry-card">
<h3>@registry.Title</h3>
<div class="registry-actions">
<a href="/registry/@registry.PublicLinkCode" class="btn btn-outline-primary btn-sm">View</a>
</div>
</div>
}
</div>
<div class="col-md-2">
<label class="form-label">Theme</label>
<InputSelect class="form-select" @bind-Value="Model.ThemeKey">
<option value="default">Default</option>
<option value="soft">Soft</option>
<option value="modern">Modern</option>
</InputSelect>
}
</div>
</div>
@if (ShowCreateForm)
{
<div class="create-registry-modal-overlay" @onclick="() => ShowCreateForm = false">
<div class="create-registry-modal" @onclick:stopPropagation="true">
<div class="modal-header">
<h3 class="modal-title">Create new registry</h3>
<button type="button" class="btn-close" @onclick="() => ShowCreateForm = false"></button>
</div>
<div class="col-md-2 d-flex align-items-end">
<button class="btn btn-primary w-100" type="submit">Create</button>
<div class="modal-body">
<EditForm Model="Model" OnValidSubmit="CreateRegistryAsync" Context="formContext" FormName="create-registry-form">
<DataAnnotationsValidator />
<div class="mb-3">
<label class="form-label">Title</label>
<InputText class="form-control" @bind-Value="Model.Title" />
</div>
<div class="mb-3">
<label class="form-label">Type</label>
<InputSelect class="form-select" @bind-Value="Model.RegistryType">
<option value="@BirthList.Domain.Entities.RegistryType.Birth">Birth</option>
<option value="@BirthList.Domain.Entities.RegistryType.Wedding">Wedding</option>
<option value="@BirthList.Domain.Entities.RegistryType.Birthday">Birthday</option>
</InputSelect>
</div>
<div class="mb-3">
<label class="form-label">Theme</label>
<InputSelect class="form-select" @bind-Value="Model.ThemeKey">
<option value="default">Default</option>
<option value="soft">Soft</option>
<option value="modern">Modern</option>
</InputSelect>
</div>
@if (!string.IsNullOrWhiteSpace(ErrorMessage))
{
<div class="alert alert-danger mb-3">@ErrorMessage</div>
}
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" @onclick="() => ShowCreateForm = false">Cancel</button>
<button class="btn btn-primary" type="submit">Create</button>
</div>
</EditForm>
</div>
</div>
</EditForm>
@if (!string.IsNullOrWhiteSpace(ErrorMessage))
{
<div class="alert alert-danger mt-3">@ErrorMessage</div>
}
</div>
<div class="mb-4">
<h2>Registries you manage</h2>
@if (MyRegistries.Count == 0)
{
<p>No registries yet.</p>
}
else
{
<ul>
@foreach (var registry in MyRegistries)
{
<li>
<a href="/registry/@registry.PublicLinkCode">@registry.Title</a>
&nbsp;|&nbsp;
<a href="/registry/@registry.Id/admin">Admin</a>
</li>
}
</ul>
}
</div>
<div>
<h2>Visited registries</h2>
@if (VisitedRegistries.Count == 0)
{
<p>No visited registries yet.</p>
}
else
{
<ul>
@foreach (var registry in VisitedRegistries)
{
<li><a href="/registry/@registry.PublicLinkCode">@registry.Title</a></li>
}
</ul>
}
</div>
</div>
}
</Authorized>
<NotAuthorized>
<p>Please <a href="Account/Login">log in</a> to create and manage registries.</p>
<div class="alert alert-info mt-4">
<p>Please <a href="Account/Login">log in</a> to create and manage registries.</p>
</div>
</NotAuthorized>
</AuthorizeView>
@code {
[SupplyParameterFromForm(FormName = "create-registry-form")]
protected RegistryCreateModel Model { get; set; } = new();
protected IReadOnlyList<RegistrySummaryViewModel> MyRegistries { get; private set; } = [];
protected IReadOnlyList<RegistrySummaryViewModel> VisitedRegistries { get; private set; } = [];
protected string? ErrorMessage { get; private set; }
protected bool ShowCreateForm { get; private set; }
[Inject] private RegistryService RegistryService { get; set; } = null!;
[Inject] private RegistryUserContext RegistryUserContext { get; set; } = null!;
protected override async Task OnInitializedAsync()
{
await LoadAsync().ConfigureAwait(false);
}
protected async Task CreateRegistryAsync()
{
ErrorMessage = null;
var userId = await RegistryUserContext.GetUserIdAsync(CancellationToken.None).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(userId))
{
ErrorMessage = "You must be logged in to create a registry.";
return;
}
if (string.IsNullOrWhiteSpace(Model.Title))
{
ErrorMessage = "Title is required.";
return;
}
await RegistryService.CreateRegistryAsync(userId, Model, CancellationToken.None).ConfigureAwait(false);
Model = new RegistryCreateModel
{
RegistryType = Model.RegistryType,
ThemeKey = Model.ThemeKey
};
ShowCreateForm = false;
await LoadAsync().ConfigureAwait(false);
}
private async Task LoadAsync()
{
var userId = await RegistryUserContext.GetUserIdAsync(CancellationToken.None).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(userId))
{
MyRegistries = [];
VisitedRegistries = [];
return;
}
MyRegistries = await RegistryService.GetAdminRegistriesAsync(userId, CancellationToken.None).ConfigureAwait(false);
VisitedRegistries = await RegistryService.GetVisitedRegistriesAsync(userId, CancellationToken.None).ConfigureAwait(false);
}
}
@@ -1,62 +0,0 @@
using BirthList.Web.Features.Registries;
using Microsoft.AspNetCore.Components;
namespace BirthList.Web.Components.Pages;
public partial class Home : ComponentBase
{
[Inject] private RegistryService RegistryService { get; set; } = null!;
[Inject] private RegistryUserContext RegistryUserContext { get; set; } = null!;
[SupplyParameterFromForm(FormName = "create-registry-form")]
protected RegistryCreateModel Model { get; set; } = new();
protected IReadOnlyList<RegistrySummaryViewModel> MyRegistries { get; private set; } = [];
protected IReadOnlyList<RegistrySummaryViewModel> VisitedRegistries { get; private set; } = [];
protected string? ErrorMessage { get; private set; }
protected override async Task OnInitializedAsync()
{
await LoadAsync().ConfigureAwait(false);
}
protected async Task CreateRegistryAsync()
{
ErrorMessage = null;
var userId = await RegistryUserContext.GetUserIdAsync(CancellationToken.None).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(userId))
{
ErrorMessage = "You must be logged in to create a registry.";
return;
}
if (string.IsNullOrWhiteSpace(Model.Title))
{
ErrorMessage = "Title is required.";
return;
}
await RegistryService.CreateRegistryAsync(userId, Model, CancellationToken.None).ConfigureAwait(false);
Model = new RegistryCreateModel
{
RegistryType = Model.RegistryType,
ThemeKey = Model.ThemeKey
};
await LoadAsync().ConfigureAwait(false);
}
private async Task LoadAsync()
{
var userId = await RegistryUserContext.GetUserIdAsync(CancellationToken.None).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(userId))
{
MyRegistries = [];
VisitedRegistries = [];
return;
}
MyRegistries = await RegistryService.GetAdminRegistriesAsync(userId, CancellationToken.None).ConfigureAwait(false);
VisitedRegistries = await RegistryService.GetVisitedRegistriesAsync(userId, CancellationToken.None).ConfigureAwait(false);
}
}
@@ -1,7 +1,142 @@
h1 {
margin-bottom: 1rem;
margin-bottom: 2rem;
}
h2 {
margin-bottom: 0.75rem;
margin-bottom: 1.5rem;
}
.registry-sections {
max-width: 1200px;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.section-header h2 {
margin: 0;
}
.registry-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.registry-card {
background: white;
border: 1px solid #dee2e6;
border-radius: 0.375rem;
padding: 1.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
transition: box-shadow 0.2s;
}
.registry-card:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.registry-card h3 {
margin: 0 0 1rem 0;
font-size: 1.25rem;
color: #212529;
word-break: break-word;
}
.registry-actions {
display: flex;
gap: 0.5rem;
}
.registry-actions .btn {
flex: 1;
text-align: center;
}
.text-muted {
color: #6c757d;
font-style: italic;
}
/* Modal overlay */
.create-registry-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1050;
}
.create-registry-modal {
background: white;
border-radius: 0.375rem;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
max-width: 500px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
}
.modal-header {
padding: 1.5rem;
border-bottom: 1px solid #dee2e6;
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-title {
margin: 0;
font-size: 1.25rem;
}
.btn-close {
background: none;
border: none;
cursor: pointer;
font-size: 1.5rem;
color: #6c757d;
padding: 0.25rem;
}
.btn-close:hover {
color: #000;
}
.modal-body {
padding: 1.5rem;
}
.modal-footer {
padding: 1.5rem;
border-top: 1px solid #dee2e6;
display: flex;
justify-content: flex-end;
gap: 0.75rem;
}
@media (max-width: 576px) {
.registry-grid {
grid-template-columns: 1fr;
}
.section-header {
flex-direction: column;
align-items: flex-start;
gap: 1rem;
}
.section-header .btn {
width: 100%;
}
}
@@ -0,0 +1,105 @@
@page "/registry/{RegistryId:guid}/admin/action-log"
@rendermode InteractiveServer
@using BirthList.Web.Features.Registries
@using BirthList.Web.Authorization
<PageTitle>Action Log - Registry Admin</PageTitle>
@if (!IsAuthorized)
{
<p>Access denied.</p>
}
else
{
<h1>Registry Action Log</h1>
<p class="text-muted">This log shows all user actions on this registry: purchases, contributions, and other interactions.</p>
@if (ActionLogs is null)
{
<p>Loading...</p>
}
else if (ActionLogs.Count == 0)
{
<p class="text-muted">No actions recorded yet.</p>
}
else
{
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th>Date/Time</th>
<th>User</th>
<th>Action</th>
<th>Item</th>
<th>Quantity</th>
<th>Amount</th>
<th>Details</th>
</tr>
</thead>
<tbody>
@foreach (var log in ActionLogs)
{
<tr>
<td>@(log.CreatedAtUtc.LocalDateTime.ToString("d")) @(log.CreatedAtUtc.LocalDateTime.ToString("T"))</td>
<td>@log.UserDisplayName</td>
<td>
@switch (log.ActionType)
{
case "RegistryLinkOpened":
<span class="badge bg-info">Registry opened</span>
break;
case "ItemLinkOpened":
<span class="badge bg-info">Item link opened</span>
break;
case "MarkPurchased":
<span class="badge bg-success">Purchase marked</span>
break;
case "UnmarkPurchased":
<span class="badge bg-warning">Purchase unmarked</span>
break;
case "MarkPartialPurchase":
<span class="badge bg-primary">Partial purchase</span>
break;
case "LogContribution":
<span class="badge bg-secondary">Contribution logged</span>
break;
default:
<span class="badge bg-dark">@log.ActionType</span>
break;
}
</td>
<td>@(log.ItemName ?? "-")</td>
<td>@(log.Quantity > 0 ? log.Quantity.ToString() : "-")</td>
<td>@(log.Amount > 0 ? log.Amount.ToString("0.00") : "-")</td>
<td>@(string.IsNullOrWhiteSpace(log.Details) ? "-" : log.Details)</td>
</tr>
}
</tbody>
</table>
</div>
}
}
@code {
[Parameter] public Guid RegistryId { get; set; }
[Inject] private RegistryService RegistryService { get; set; } = null!;
[Inject] private RegistryAuthorizationService AuthorizationService { get; set; } = null!;
[Inject] private RegistryUserContext RegistryUserContext { get; set; } = null!;
private IReadOnlyList<RegistryActionLogViewModel>? ActionLogs { get; set; }
private bool IsAuthorized { get; set; }
protected override async Task OnInitializedAsync()
{
var userId = await RegistryUserContext.GetUserIdAsync(CancellationToken.None).ConfigureAwait(false);
IsAuthorized = await AuthorizationService.IsRegistryAdminAsync(RegistryId, userId, CancellationToken.None).ConfigureAwait(false);
if (IsAuthorized)
{
ActionLogs = await RegistryService.GetRegistryActionLogsAsync(RegistryId, CancellationToken.None).ConfigureAwait(false);
}
}
}
@@ -20,172 +20,289 @@ else
</div>
}
<section class="mb-4">
<h2>Current admins</h2>
@if (Admins.Count == 0)
{
<p>No admins assigned yet.</p>
}
else
{
<ul>
@foreach (var admin in Admins)
{
<li>@admin.DisplayName</li>
}
</ul>
}
</section>
<ul class="nav nav-tabs mb-4" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link @(GetTabClass("items"))"
id="items-tab"
@onclick='() => SetActiveTab("items")'
type="button"
role="tab"
aria-controls="items-content"
aria-selected="@(ActiveTab == "items" ? "true" : "false")">
<span class="bi bi-box-seam"></span> Items
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link @(GetTabClass("settings"))"
id="settings-tab"
@onclick='() => SetActiveTab("settings")'
type="button"
role="tab"
aria-controls="settings-content"
aria-selected="@(ActiveTab == "settings" ? "true" : "false")">
<span class="bi bi-gear"></span> Settings
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link @(GetTabClass("admins"))"
id="admins-tab"
@onclick='() => SetActiveTab("admins")'
type="button"
role="tab"
aria-controls="admins-content"
aria-selected="@(ActiveTab == "admins" ? "true" : "false")">
<span class="bi bi-people"></span> Administrators
</button>
</li>
<li class="nav-item" role="presentation">
<a href="/registry/@RegistryId/admin/action-log" class="nav-link">
<span class="bi bi-clock-history"></span> Action Log
</a>
</li>
</ul>
<section class="mb-4">
<h2>Settings</h2>
<EditForm Model="SettingsModel" OnValidSubmit="SaveSettingsAsync" FormName="registry-settings-form">
<div class="row g-3">
<div class="col-md-4">
<label class="form-label">Baby name</label>
<InputText class="form-control" @bind-Value="SettingsModel.BabyName" />
</div>
<div class="col-md-3">
<label class="form-label">Birth date</label>
<InputDate class="form-control" @bind-Value="SettingsModel.BirthDate" />
</div>
<div class="col-md-2">
<label class="form-label">Currency</label>
<InputText class="form-control" @bind-Value="SettingsModel.CurrencyCode" />
</div>
<div class="col-md-3">
<label class="form-label">Theme</label>
<InputSelect class="form-select" @bind-Value="SettingsModel.ThemeKey">
<option value="default">Default</option>
<option value="soft">Soft</option>
<option value="modern">Modern</option>
</InputSelect>
</div>
</div>
<div class="tab-content">
<!-- Items Tab -->
<div class="tab-pane fade @(GetTabPaneClass("items"))" id="items-content" role="tabpanel" aria-labelledby="items-tab">
<section class="mb-4">
<h2>Add or edit item</h2>
<EditForm Model="ItemModel" OnValidSubmit="SaveItemAsync" FormName="registry-item-form">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Name</label>
<InputText class="form-control" @bind-Value="ItemModel.Name" />
</div>
<div class="col-md-6">
<label class="form-label">Product URL</label>
<div class="input-group">
<InputText class="form-control" @bind-Value="ItemModel.ProductUrl" />
<button type="button" class="btn btn-outline-secondary" @onclick="FetchMetadataAsync">Auto fetch</button>
</div>
</div>
<div class="col-md-6">
<label class="form-label">Picture URL</label>
<InputText class="form-control" @bind-Value="ItemModel.PictureUrl" />
</div>
<div class="col-md-6">
<label class="form-label">Description</label>
<InputTextArea class="form-control" @bind-Value="ItemModel.Description" rows="2" />
</div>
<div class="col-md-2">
<label class="form-label">Price</label>
<InputNumber class="form-control" @bind-Value="ItemModel.PriceAmount" />
</div>
<div class="col-md-2">
<label class="form-label">Currency</label>
<InputText class="form-control" @bind-Value="ItemModel.CurrencyCode" />
</div>
<div class="col-md-2">
<label class="form-label">Desired qty</label>
<InputNumber class="form-control" @bind-Value="ItemModel.DesiredQuantity" />
</div>
<div class="col-md-3 d-flex align-items-center gap-2">
<InputCheckbox @bind-Value="ItemModel.ParticipationAllowed" />
<label>Participation</label>
</div>
<div class="col-md-3">
<label class="form-label">Participation target</label>
<InputNumber class="form-control" @bind-Value="ItemModel.ParticipationTargetAmount" />
</div>
<div class="col-md-3">
<label class="form-label">Second hand preference</label>
<InputSelect class="form-select" @bind-Value="ItemModel.PreferSecondHand">
<option value="">Second hand optional</option>
<option value="true">Prefer second hand</option>
<option value="false">New only</option>
</InputSelect>
</div>
<div class="col-md-3 d-flex align-items-center gap-2">
<InputCheckbox @bind-Value="ItemModel.IsGiven" />
<label>Given</label>
</div>
</div>
<button class="btn btn-primary mt-3" type="submit">Save item</button>
</EditForm>
</section>
<div class="mt-3">
<label class="form-label">Shipping address</label>
<InputTextArea class="form-control" @bind-Value="SettingsModel.ShippingAddress" rows="3" />
</div>
<div class="mt-3">
<label class="form-label">Top content</label>
<BlazoredTextEditor @ref="TextEditor" Placeholder="Welcome text" Theme="snow" />
</div>
<div class="row g-3 mt-2">
<div class="col-md-4">
<label class="form-label">Bank account name</label>
<InputText class="form-control" @bind-Value="SettingsModel.BankAccountDisplayName" />
</div>
<div class="col-md-4">
<label class="form-label">IBAN</label>
<InputText class="form-control" @bind-Value="SettingsModel.BankAccountIban" />
</div>
<div class="col-md-4">
<label class="form-label">BIC</label>
<InputText class="form-control" @bind-Value="SettingsModel.BankAccountBic" />
</div>
</div>
<button class="btn btn-primary mt-3" type="submit">Save settings</button>
</EditForm>
</section>
<section class="mb-4">
<h2>Invite admin</h2>
<div class="row g-2">
<div class="col-md-8">
<InputText class="form-control" @bind-Value="InviteEmail" placeholder="optional email" />
</div>
<div class="col-md-4">
<button class="btn btn-outline-primary w-100" @onclick="CreateInviteAsync">Create invite</button>
</div>
<section>
<h2>Items</h2>
<table class="table table-striped">
<thead>
<tr>
<th>Name</th>
<th>Desired Qty</th>
<th>Participation</th>
<th>Purchased by / Contributed by</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var item in Items)
{
<tr>
<td>@item.Name</td>
<td>@item.DesiredQuantity</td>
<td>@(item.ParticipationAllowed ? "Yes" : "No")</td>
<td>
@if (item.Purchasers.Count > 0)
{
<div class="mb-1">
<small><strong>Purchased:</strong></small>
<div class="small">
@foreach (var purchaser in item.Purchasers)
{
<div>@purchaser.DisplayName (@purchaser.Quantity)</div>
}
</div>
</div>
}
@if (item.Contributors.Count > 0)
{
<div>
<small><strong>Contributed:</strong></small>
<div class="small">
@foreach (var contributor in item.Contributors)
{
<div>@contributor.DisplayName (@contributor.Amount.ToString("0.00"))</div>
}
</div>
</div>
}
</td>
<td>
<button class="btn btn-sm btn-outline-secondary me-2" @onclick="() => EditItem(item)">Edit</button>
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteItemAsync(item.Id)">Delete</button>
</td>
</tr>
}
</tbody>
</table>
</section>
</div>
@if (!string.IsNullOrWhiteSpace(InviteLink))
{
<p class="mt-2">Invite link: <a href="@InviteLink">@InviteLink</a></p>
}
</section>
<section>
<h2>Add or edit item</h2>
<EditForm Model="ItemModel" OnValidSubmit="SaveItemAsync" FormName="registry-item-form">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Name</label>
<InputText class="form-control" @bind-Value="ItemModel.Name" />
</div>
<div class="col-md-6">
<label class="form-label">Product URL</label>
<div class="input-group">
<InputText class="form-control" @bind-Value="ItemModel.ProductUrl" />
<button type="button" class="btn btn-outline-secondary" @onclick="FetchMetadataAsync">Auto fetch</button>
<!-- Settings Tab -->
<div class="tab-pane fade @(GetTabPaneClass("settings"))" id="settings-content" role="tabpanel" aria-labelledby="settings-tab">
<section class="mb-4">
<h2>Registry Settings</h2>
<EditForm Model="SettingsModel" OnValidSubmit="SaveSettingsAsync" FormName="registry-settings-form">
<div class="row g-3">
<div class="col-md-4">
<label class="form-label">Baby name</label>
<InputText class="form-control" @bind-Value="SettingsModel.BabyName" />
</div>
<div class="col-md-3">
<label class="form-label">Birth date</label>
<InputDate class="form-control" @bind-Value="SettingsModel.BirthDate" />
</div>
<div class="col-md-2">
<label class="form-label">Currency</label>
<InputText class="form-control" @bind-Value="SettingsModel.CurrencyCode" />
</div>
<div class="col-md-3">
<label class="form-label">Theme</label>
<InputSelect class="form-select" @bind-Value="SettingsModel.ThemeKey">
<option value="default">Default</option>
<option value="soft">Soft</option>
<option value="modern">Modern</option>
</InputSelect>
</div>
</div>
<div class="mt-3">
<label class="form-label">Shipping address</label>
<InputTextArea class="form-control" @bind-Value="SettingsModel.ShippingAddress" rows="4" />
<small class="form-text text-muted">Line breaks will be preserved</small>
</div>
<div class="mt-3">
<label class="form-label">Top content</label>
<div class="editor-wrapper">
<BlazoredTextEditor @ref="TextEditor" Placeholder="Welcome text" Theme="snow" ToolbarContent="@ToolbarContent" />
</div>
</div>
<div class="mt-3">
<h3>Bank Account Settings</h3>
<div class="row g-3 mt-2">
<div class="col-md-4">
<label class="form-label">Bank account name</label>
<InputText class="form-control" @bind-Value="SettingsModel.BankAccountDisplayName" />
</div>
<div class="col-md-4">
<label class="form-label">IBAN</label>
<InputText class="form-control" @bind-Value="SettingsModel.BankAccountIban" />
</div>
<div class="col-md-4">
<label class="form-label">BIC</label>
<InputText class="form-control" @bind-Value="SettingsModel.BankAccountBic" />
</div>
</div>
<div class="mt-2">
<div class="form-check">
<InputCheckbox class="form-check-input" @bind-Value="SettingsModel.ShowBankAccountName" id="showBankName" />
<label class="form-check-label" for="showBankName">
Display bank account name
</label>
</div>
</div>
</div>
<button class="btn btn-primary mt-4" type="submit">Save settings</button>
</EditForm>
</section>
</div>
<!-- Admins Tab -->
<div class="tab-pane fade @(GetTabPaneClass("admins"))" id="admins-content" role="tabpanel" aria-labelledby="admins-tab">
<section class="mb-4">
<h2>Current administrators</h2>
@if (Admins.Count == 0)
{
<p class="text-muted">No admins assigned yet.</p>
}
else
{
<table class="table table-striped">
<thead>
<tr>
<th>Email / Name</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var admin in Admins)
{
<tr>
<td>@admin.DisplayName</td>
<td class="text-end">
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteAdminAsync(admin.UserId)">Remove</button>
</td>
</tr>
}
</tbody>
</table>
}
</section>
<section>
<h2>Invite administrator</h2>
<div class="row g-2">
<div class="col-md-8">
<InputText class="form-control" @bind-Value="InviteEmail" placeholder="optional email" />
</div>
<div class="col-md-4">
<button class="btn btn-outline-primary w-100" @onclick="CreateInviteAsync">Create invite</button>
</div>
</div>
<div class="col-md-6">
<label class="form-label">Picture URL</label>
<InputText class="form-control" @bind-Value="ItemModel.PictureUrl" />
</div>
<div class="col-md-6">
<label class="form-label">Description</label>
<InputTextArea class="form-control" @bind-Value="ItemModel.Description" rows="2" />
</div>
<div class="col-md-2">
<label class="form-label">Price</label>
<InputNumber class="form-control" @bind-Value="ItemModel.PriceAmount" />
</div>
<div class="col-md-2">
<label class="form-label">Currency</label>
<InputText class="form-control" @bind-Value="ItemModel.CurrencyCode" />
</div>
<div class="col-md-2">
<label class="form-label">Desired qty</label>
<InputNumber class="form-control" @bind-Value="ItemModel.DesiredQuantity" />
</div>
<div class="col-md-3 d-flex align-items-center gap-2">
<InputCheckbox @bind-Value="ItemModel.ParticipationAllowed" />
<label>Participation</label>
</div>
<div class="col-md-3">
<label class="form-label">Participation target</label>
<InputNumber class="form-control" @bind-Value="ItemModel.ParticipationTargetAmount" />
</div>
<div class="col-md-3 d-flex align-items-center gap-2">
<InputCheckbox @bind-Value="ItemModel.CanBeSecondHand" />
<label>Second hand allowed</label>
</div>
<div class="col-md-3 d-flex align-items-center gap-2">
<InputCheckbox @bind-Value="ItemModel.IsGiven" />
<label>Given</label>
</div>
</div>
<button class="btn btn-primary mt-3" type="submit">Save item</button>
</EditForm>
<table class="table table-striped mt-4">
<thead>
<tr>
<th>Name</th>
<th>Desired Qty</th>
<th>Participation</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var item in Items)
@if (!string.IsNullOrWhiteSpace(InviteLink))
{
<tr>
<td>@item.Name</td>
<td>@item.DesiredQuantity</td>
<td>@(item.ParticipationAllowed ? "Yes" : "No")</td>
<td>
<button class="btn btn-sm btn-outline-secondary me-2" @onclick="() => EditItem(item)">Edit</button>
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteItemAsync(item.Id)">Delete</button>
</td>
</tr>
<p class="mt-3">
<strong>Invite link:</strong>
<br />
<a href="@InviteLink" target="_blank">@InviteLink</a>
</p>
}
</tbody>
</table>
</section>
</section>
</div>
</div>
}
@@ -30,6 +30,14 @@ public partial class RegistryAdmin : ComponentBase
protected string? InviteEmail { get; set; }
protected string? InviteLink { get; private set; }
protected BlazoredTextEditor? TextEditor { get; set; }
protected string ActiveTab { get; set; } = "items";
protected RenderFragment ToolbarContent => builder =>
{
builder.AddMarkupContent(0, "<span class='ql-formats'><select class='ql-header'><option selected></option><option value='1'></option><option value='2'></option></select></span>");
builder.AddMarkupContent(1, "<span class='ql-formats'><button class='ql-bold'></button><button class='ql-italic'></button><button class='ql-underline'></button></span>");
builder.AddMarkupContent(2, "<span class='ql-formats'><button class='ql-list' value='ordered'></button><button class='ql-list' value='bullet'></button></span>");
builder.AddMarkupContent(3, "<span class='ql-formats'><button class='ql-link'></button><button class='ql-clean'></button></span>");
};
protected override async Task OnParametersSetAsync()
{
@@ -71,6 +79,21 @@ public partial class RegistryAdmin : ComponentBase
}
}
protected void SetActiveTab(string tab)
{
ActiveTab = tab;
}
protected string GetTabClass(string tab)
{
return ActiveTab == tab ? "active" : "";
}
protected string GetTabPaneClass(string tab)
{
return ActiveTab == tab ? "show active" : "";
}
protected async Task SaveSettingsAsync()
{
if (TextEditor is not null)
@@ -128,7 +151,7 @@ public partial class RegistryAdmin : ComponentBase
DesiredQuantity = model.DesiredQuantity,
ParticipationAllowed = model.ParticipationAllowed,
ParticipationTargetAmount = model.ParticipationTargetAmount,
CanBeSecondHand = model.CanBeSecondHand,
PreferSecondHand = model.PreferSecondHand,
IsGiven = model.IsGiven
};
}
@@ -195,6 +218,17 @@ public partial class RegistryAdmin : ComponentBase
Admins = await RegistryService.GetRegistryAdminsAsync(RegistryId, CancellationToken.None).ConfigureAwait(false);
}
protected async Task DeleteAdminAsync(string? userId)
{
if (string.IsNullOrWhiteSpace(userId))
{
return;
}
await RegistryService.RemoveRegistryAdminAsync(RegistryId, userId, CancellationToken.None).ConfigureAwait(false);
Admins = await RegistryService.GetRegistryAdminsAsync(RegistryId, CancellationToken.None).ConfigureAwait(false);
}
private async Task LoadAsync()
{
var settings = await RegistryService.GetRegistrySettingsAsync(RegistryId, CancellationToken.None).ConfigureAwait(false);
@@ -209,6 +243,7 @@ public partial class RegistryAdmin : ComponentBase
SettingsModel.BankAccountDisplayName = settings.BankAccountDisplayName;
SettingsModel.BankAccountIban = settings.BankAccountIban;
SettingsModel.BankAccountBic = settings.BankAccountBic;
SettingsModel.ShowBankAccountName = settings.ShowBankAccountName;
}
Admins = await RegistryService.GetRegistryAdminsAsync(RegistryId, CancellationToken.None).ConfigureAwait(false);
@@ -1,3 +1,160 @@
section {
margin-bottom: 2rem;
}
h1 {
margin-bottom: 1.5rem;
}
h2 {
margin-bottom: 1rem;
font-size: 1.25rem;
}
h3 {
font-size: 1rem;
font-weight: 600;
margin-bottom: 0.75rem;
}
/* Tabs styling */
.nav-tabs {
border-bottom: 2px solid #dee2e6;
}
.nav-tabs .nav-link {
border: none;
border-bottom: 3px solid transparent;
color: #6c757d;
margin-bottom: -2px;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 0.5rem;
}
.nav-tabs .nav-link:hover {
border-bottom-color: #0d6efd;
color: #0d6efd;
}
.nav-tabs .nav-link.active {
border-bottom-color: #0d6efd;
color: #0d6efd;
background-color: transparent;
}
.tab-content {
animation: fadeIn 0.2s;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.tab-pane {
display: none;
}
.tab-pane.show.active {
display: block;
}
/* Editor styling */
.editor-wrapper {
border: 1px solid #dee2e6;
border-radius: 0.375rem;
overflow: hidden;
background-color: #fff;
}
.editor-wrapper ::deep .ql-container {
font-family: inherit;
font-size: 1rem;
}
.editor-wrapper ::deep .ql-editor {
min-height: 200px;
padding: 15px;
line-height: 1.5;
}
.editor-wrapper ::deep .ql-toolbar {
background-color: #f8f9fa;
border-bottom: 1px solid #dee2e6;
}
.editor-wrapper ::deep .ql-toolbar.ql-snow {
padding: 10px;
}
.editor-wrapper ::deep .ql-snow .ql-picker-label {
color: #495057;
}
.editor-wrapper ::deep .ql-snow .ql-stroke {
stroke: #495057;
}
.editor-wrapper ::deep .ql-snow .ql-fill {
fill: #495057;
}
.editor-wrapper ::deep .ql-snow .ql-picker.ql-expanded .ql-picker-item.selected {
color: #0d6efd;
}
/* Table styling */
.table {
margin-top: 1rem;
}
.table thead th {
background-color: #f8f9fa;
border-top: 2px solid #dee2e6;
font-weight: 600;
}
.table tbody tr:hover {
background-color: #f8f9fa;
}
/* Form styling */
.form-check {
margin-top: 0.75rem;
}
.form-check-input {
margin-right: 0.5rem;
}
.text-muted {
color: #6c757d;
}
/* Alert styling */
.alert {
margin-bottom: 1.5rem;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.nav-tabs .nav-link {
font-size: 0.9rem;
padding: 0.5rem 0.75rem;
}
.table {
font-size: 0.9rem;
}
.btn-sm {
padding: 0.25rem 0.5rem;
font-size: 0.8rem;
}
}
@@ -1,4 +1,5 @@
@page "/registry/{Code}"
@rendermode InteractiveServer
@using BirthList.Web.Features.Registries
@@ -22,11 +23,11 @@ else
{
<div class="alert alert-info">
<strong>Shipping address</strong><br />
@Registry.ShippingAddress
<span class="shipping-address-text">@Registry.ShippingAddress</span>
</div>
}
@if (!string.IsNullOrWhiteSpace(Registry.BankAccountIban))
@if (Registry.ShowBankAccountName && !string.IsNullOrWhiteSpace(Registry.BankAccountIban))
{
<div class="alert alert-secondary">
<strong>Bank transfer participation</strong><br />
@@ -42,16 +43,26 @@ else
<div class="item-grid">
@foreach (var item in Registry.Items)
{
<article class="card item-card">
<article class="card item-card @(item.IsGiven ? "item-given" : "")">
@if (!string.IsNullOrWhiteSpace(item.PictureUrl))
{
<img class="card-img-top item-image" src="@item.PictureUrl" alt="@item.Name" />
}
<div class="card-body">
<h5 class="card-title">@item.Name</h5>
<div class="card-header-badges">
<h5 class="card-title">@item.Name</h5>
@if (item.PreferSecondHand == true)
{
<span class="badge bg-info">Second-hand preferred</span>
}
else if (item.PreferSecondHand == null)
{
<span class="badge bg-warning">Second-hand optional</span>
}
</div>
@if (!string.IsNullOrWhiteSpace(item.Description))
{
<p class="card-text">@item.Description</p>
<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.PriceAmount.HasValue)
@@ -63,6 +74,54 @@ else
<p class="mb-2"><strong>Participation:</strong> @item.MoneyFulfilledAmount.ToString("0.00") / @item.ParticipationTargetAmount.Value.ToString("0.00") @item.CurrencyCode</p>
}
@if (item.Purchasers.Count > 0 || item.Contributors.Count > 0)
{
<div class="contributors-section mt-2 mb-2">
@if (item.Purchasers.Count > 0)
{
<div class="mb-2">
@if (item.CanViewPurchasers && Registry.IsAdmin)
{
<strong class="text-sm">Purchased by:</strong>
<div class="contributor-list">
@foreach (var purchaser in item.Purchasers)
{
<span class="contributor-badge">@purchaser.DisplayName (@purchaser.Quantity)</span>
}
</div>
}
else if (item.CanViewPurchasers && item.CurrentUserPurchasedQuantity > 0)
{
<strong class="text-sm">Purchased by:</strong>
<div class="contributor-list">
<span class="contributor-badge">You (@item.CurrentUserPurchasedQuantity)</span>
@if (item.Purchasers.Count > 1)
{
<span class="contributor-badge">and @(item.Purchasers.Count - 1) other @(item.Purchasers.Count - 1 == 1 ? "person" : "people")</span>
}
</div>
}
else
{
<span class="text-muted small">Purchased</span>
}
</div>
}
@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)
{
<span class="contributor-badge">@contributor.DisplayName (@contributor.Amount.ToString("0.00") @item.CurrencyCode)</span>
}
</div>
</div>
}
</div>
}
@if (!IsAuthenticated)
{
<button class="btn btn-outline-primary btn-sm" @onclick="() => LoginRedirect()">Login to purchase</button>
@@ -70,11 +129,22 @@ else
else
{
<div class="d-flex gap-2 flex-wrap">
@if (!string.IsNullOrWhiteSpace(item.ProductUrl))
@if (!string.IsNullOrWhiteSpace(item.ProductUrl) && item.CurrentUserPurchasedQuantity == 0)
{
<a class="btn btn-primary btn-sm" href="@item.ProductUrl" target="_blank" @onclick="() => BeginPurchase(item.Id)">Purchase</a>
<button class="btn btn-primary btn-sm" @onclick="() => OpenPurchasePrompt(item.Id, openTab: true)">Purchase</button>
}
@if (item.CurrentUserPurchasedQuantity == 0)
{
<button class="btn btn-success btn-sm" @onclick="() => OpenPurchasePrompt(item.Id)">Mark purchased</button>
}
@if (item.CurrentUserPurchasedQuantity > 0 || (Registry.IsAdmin && item.Purchasers.Count > 0))
{
@if (item.CurrentUserPurchasedQuantity > 0)
{
<span class="badge bg-success me-2">You purchased @item.CurrentUserPurchasedQuantity</span>
}
<button class="btn btn-warning btn-sm" @onclick="() => UnmarkPurchaseAsync(item.Id)">Unmark purchase</button>
}
<button class="btn btn-success btn-sm" @onclick="() => OpenPurchasePrompt(item.Id)">Mark purchased</button>
@if (item.ParticipationAllowed)
{
<button class="btn btn-secondary btn-sm" @onclick="() => OpenContributionPrompt(item.Id)">I transferred money</button>
@@ -95,8 +165,16 @@ else
<h3>Mark as purchased</h3>
<p>How many units did you purchase?</p>
<InputNumber class="form-control" @bind-Value="PurchasedQuantity" />
@if (!string.IsNullOrWhiteSpace(PurchaseItemUrl))
{
<div class="mt-3">
<a href="@PurchaseItemUrl" target="_blank" class="btn btn-outline-primary btn-sm w-100">Open product link</a>
</div>
}
<div class="d-flex gap-2 mt-3">
<button class="btn btn-success" @onclick="ConfirmPurchaseAsync">Confirm</button>
<button class="btn btn-success" @onclick="ConfirmPurchaseAsync">Confirm purchase</button>
<button class="btn btn-outline-secondary" @onclick="ClosePurchasePrompt">Cancel</button>
</div>
</div>
@@ -118,3 +196,26 @@ else
</div>
</div>
}
@if (ShowPurchaserSelectionPrompt)
{
<div class="prompt-overlay">
<div class="prompt-card">
<h3>Select purchaser to unmark</h3>
<p>Multiple users have purchased this item. Choose which purchase to unmark:</p>
<div class="purchaser-list">
@foreach (var purchaser in PurchasersToUnmark)
{
<div class="purchaser-item">
<span>@purchaser.DisplayName (@purchaser.Quantity)</span>
<button class="btn btn-warning btn-sm" @onclick="() => UnmarkPurchaserAsync(purchaser.UserId)">Unmark</button>
</div>
}
</div>
<div class="d-flex gap-2 mt-3">
<button class="btn btn-danger" @onclick="UnmarkAllPurchasersAsync">Unmark all</button>
<button class="btn btn-outline-secondary" @onclick="ClosePurchaserSelectionPrompt">Cancel</button>
</div>
</div>
</div>
}
@@ -1,5 +1,7 @@
using BirthList.Domain.Entities;
using BirthList.Web.Features.Registries;
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
namespace BirthList.Web.Components.Pages;
@@ -11,21 +13,30 @@ public partial class RegistryPublic : ComponentBase
[Inject] private RegistryUserContext RegistryUserContext { get; set; } = null!;
[Inject] private RegistryThemeService RegistryThemeService { get; set; } = null!;
[Inject] private NavigationManager NavigationManager { get; set; } = null!;
[Inject] private IJSRuntime JSRuntime { get; set; } = null!;
protected RegistryPublicViewModel? Registry { get; private set; }
protected bool IsAuthenticated { get; private set; }
protected bool ShowPurchasePrompt { get; private set; }
protected bool ShowContributionPrompt { get; private set; }
protected bool ShowPurchaserSelectionPrompt { get; private set; }
protected Guid ActiveItemId { get; private set; }
protected int PurchasedQuantity { get; set; } = 1;
protected decimal ContributionAmount { get; set; }
protected string ContributionMessage { get; set; } = string.Empty;
protected string? PurchaseItemUrl { get; private set; }
protected IReadOnlyList<ItemContributorViewModel> PurchasersToUnmark { get; private set; } = [];
protected override async Task OnParametersSetAsync()
{
var userId = await RegistryUserContext.GetUserIdAsync(CancellationToken.None).ConfigureAwait(false);
IsAuthenticated = !string.IsNullOrWhiteSpace(userId);
Registry = await RegistryService.GetPublicRegistryByCodeAsync(Code, userId, CancellationToken.None).ConfigureAwait(false);
if (Registry is not null && !string.IsNullOrWhiteSpace(userId))
{
await RegistryService.LogUserActionAsync(Registry.Id, userId, UserActionType.RegistryLinkOpened, null, null, CancellationToken.None).ConfigureAwait(false);
}
}
protected void LoginRedirect()
@@ -39,10 +50,23 @@ public partial class RegistryPublic : ComponentBase
PurchasedQuantity = 1;
}
protected void OpenPurchasePrompt(Guid itemId)
protected async Task OpenPurchasePrompt(Guid itemId, bool openTab = false)
{
var item = Registry?.Items.FirstOrDefault(x => x.Id == itemId);
if (item is null)
{
return;
}
ActiveItemId = itemId;
PurchasedQuantity = 1;
PurchaseItemUrl = item.ProductUrl;
if (openTab && !string.IsNullOrWhiteSpace(item.ProductUrl))
{
await JSRuntime.InvokeVoidAsync("open", item.ProductUrl, "_blank");
}
ShowPurchasePrompt = true;
}
@@ -50,6 +74,7 @@ public partial class RegistryPublic : ComponentBase
{
ShowPurchasePrompt = false;
ActiveItemId = Guid.Empty;
PurchaseItemUrl = null;
}
protected async Task ConfirmPurchaseAsync()
@@ -94,4 +119,69 @@ public partial class RegistryPublic : ComponentBase
ShowContributionPrompt = false;
Registry = await RegistryService.GetPublicRegistryByCodeAsync(Code, userId, CancellationToken.None).ConfigureAwait(false);
}
protected async Task UnmarkPurchaseAsync(Guid itemId)
{
var userId = await RegistryUserContext.GetUserIdAsync(CancellationToken.None).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(userId))
{
return;
}
var item = Registry?.Items.FirstOrDefault(x => x.Id == itemId);
if (item is null)
{
return;
}
// If admin with multiple purchasers, show selection modal
if (Registry.IsAdmin)
{
ActiveItemId = itemId;
PurchasersToUnmark = item.Purchasers;
ShowPurchaserSelectionPrompt = true;
return;
}
// Regular user or single purchaser - unmark directly
await RegistryService.UnmarkPurchaseAsync(itemId, userId, CancellationToken.None).ConfigureAwait(false);
Registry = await RegistryService.GetPublicRegistryByCodeAsync(Code, userId, CancellationToken.None).ConfigureAwait(false);
}
protected void ClosePurchaserSelectionPrompt()
{
ShowPurchaserSelectionPrompt = false;
ActiveItemId = Guid.Empty;
PurchasersToUnmark = [];
}
protected async Task UnmarkPurchaserAsync(string purchaserUserId)
{
var userId = await RegistryUserContext.GetUserIdAsync(CancellationToken.None).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(userId))
{
return;
}
await RegistryService.UnmarkPurchaseByAdminAsync(ActiveItemId, purchaserUserId, userId, CancellationToken.None).ConfigureAwait(false);
ShowPurchaserSelectionPrompt = false;
Registry = await RegistryService.GetPublicRegistryByCodeAsync(Code, userId, CancellationToken.None).ConfigureAwait(false);
}
protected async Task UnmarkAllPurchasersAsync()
{
var userId = await RegistryUserContext.GetUserIdAsync(CancellationToken.None).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(userId))
{
return;
}
foreach (var purchaser in PurchasersToUnmark)
{
await RegistryService.UnmarkPurchaseByAdminAsync(ActiveItemId, purchaser.UserId, userId, CancellationToken.None).ConfigureAwait(false);
}
ShowPurchaserSelectionPrompt = false;
Registry = await RegistryService.GetPublicRegistryByCodeAsync(Code, userId, CancellationToken.None).ConfigureAwait(false);
}
}
@@ -11,8 +11,33 @@
}
.item-image {
max-height: 220px;
object-fit: cover;
width: 100%;
aspect-ratio: 1 / 1;
object-fit: contain;
object-position: center;
background-color: #f8f9fa;
}
.item-given {
opacity: 0.75;
background-color: #f0f0f0;
}
.item-given .item-image {
background-color: #e8e8e8;
}
.item-given .card-body {
background-color: #b0b0b0;
}
.item-given button {
opacity: 0.3;
/*background-color: #b0b0b0;*/
}
.item-given .btn-warning {
opacity: 1;
}
.prompt-overlay {
@@ -43,3 +68,76 @@
.theme-birthday-default h1 {
color: #198754;
}
.shipping-address-text,
.item-description-text {
white-space: pre-line;
}
.card-header-badges {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.card-header-badges .card-title {
margin: 0;
flex: 1;
}
.card-header-badges .badge {
white-space: nowrap;
flex-shrink: 0;
}
.contributors-section {
padding: 0.75rem;
background-color: #f8f9fa;
border-radius: 4px;
font-size: 0.875rem;
}
.contributor-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.25rem;
}
.contributor-badge {
display: inline-block;
padding: 0.25rem 0.5rem;
background-color: #e9ecef;
border-radius: 3px;
font-size: 0.8rem;
}
.text-sm {
font-size: 0.875rem;
}
.purchaser-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin: 1rem 0;
}
.purchaser-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem;
background-color: #f8f9fa;
border-radius: 4px;
}
.purchaser-item span {
flex: 1;
}
.purchaser-item .btn {
margin-left: 0.5rem;
}
@@ -92,7 +92,15 @@ internal sealed class RegistryMetadataService(IHttpClientFactory httpClientFacto
{
if (string.IsNullOrWhiteSpace(image) || IsGenericAmazonImage(image))
{
image = ExtractAmazonImage(html) ?? image;
var extractedImage = ExtractAmazonImage(html);
if (!string.IsNullOrWhiteSpace(extractedImage))
{
image = extractedImage;
}
else if (!string.IsNullOrWhiteSpace(image) && IsGenericAmazonImage(image))
{
image = null;
}
}
if (!price.HasValue)
@@ -127,11 +135,15 @@ internal sealed class RegistryMetadataService(IHttpClientFactory httpClientFacto
private static string? ExtractAmazonImage(string html)
{
var patterns = new []
var patterns = new[]
{
"\\\"landingImageUrl\\\"\\s*:\\s*\\\"(?<url>https?:\\\\/\\\\/[^\\\"]+)\\\"",
"\\\"hiRes\\\"\\s*:\\s*\\\"(?<url>https?:\\\\/\\\\/[^\\\"]+)\\\"",
"<img[^>]+id=['\"]landingImage['\"][^>]+src=['\"](?<url>[^'\"]+)['\"]"
"data-old-hires=['\"](?<url>https?://[^'\"]+)['\"]",
"\\\"mainUrl\\\"\\s*:\\s*\\\"(?<url>https?:\\\\/\\\\/[^\\\"]+)\\\"",
"<img[^>]+id=['\"]landingImage['\"][^>]+src=['\"](?<url>[^'\"]+)['\"]",
"<img[^>]+id=['\"]imgBlkFront['\"][^>]+src=['\"](?<url>[^'\"]+)['\"]",
"\\\"image\\\"\\s*:\\s*\\\"(?<url>https?:\\\\/\\\\/[^\\\"]+)\\\""
};
foreach (var pattern in patterns)
@@ -149,7 +161,7 @@ internal sealed class RegistryMetadataService(IHttpClientFactory httpClientFacto
}
value = value.Replace("\\/", "/").Replace("\\u0026", "&");
if (Uri.TryCreate(value, UriKind.Absolute, out _))
if (Uri.TryCreate(value, UriKind.Absolute, out _) && !IsGenericAmazonImage(value))
{
return value;
}
@@ -21,8 +21,18 @@ public sealed class RegistryItemEditModel
public int DesiredQuantity { get; set; } = 1;
public bool ParticipationAllowed { get; set; }
public decimal? ParticipationTargetAmount { get; set; }
public bool CanBeSecondHand { get; set; }
public bool? PreferSecondHand { get; set; }
public bool IsGiven { get; set; }
public IReadOnlyList<ItemContributorViewModel> Purchasers { get; init; } = [];
public IReadOnlyList<ItemContributorViewModel> Contributors { get; init; } = [];
}
public sealed class ItemContributorViewModel
{
public string UserId { get; init; } = string.Empty;
public string DisplayName { get; init; } = string.Empty;
public decimal Amount { get; init; }
public int Quantity { get; init; }
}
public sealed class RegistrySettingsEditModel
@@ -36,6 +46,7 @@ public sealed class RegistrySettingsEditModel
public string? BankAccountIban { get; set; }
public string? BankAccountBic { get; set; }
public string? BankAccountDisplayName { get; set; }
public bool ShowBankAccountName { get; set; }
}
public sealed class RegistrySummaryViewModel
@@ -61,6 +72,9 @@ public sealed class RegistryPublicViewModel
public string? BankAccountIban { get; init; }
public string? BankAccountBic { get; init; }
public string? BankAccountDisplayName { get; init; }
public bool ShowBankAccountName { get; init; }
public string? CurrentUserId { get; init; }
public bool IsAdmin { get; init; }
public IReadOnlyList<RegistryPublicItemViewModel> Items { get; init; } = [];
}
@@ -78,8 +92,12 @@ public sealed class RegistryPublicItemViewModel
public bool ParticipationAllowed { get; init; }
public decimal? ParticipationTargetAmount { get; init; }
public decimal MoneyFulfilledAmount { get; init; }
public bool CanBeSecondHand { get; init; }
public bool? PreferSecondHand { get; init; }
public bool IsGiven { get; init; }
public IReadOnlyList<ItemContributorViewModel> Purchasers { get; init; } = [];
public IReadOnlyList<ItemContributorViewModel> Contributors { get; init; } = [];
public int CurrentUserPurchasedQuantity { get; init; }
public bool CanViewPurchasers { get; init; }
}
public sealed class RegistryAdminDisplayModel
@@ -97,3 +115,15 @@ public sealed class UrlMetadataResult
public decimal? PriceAmount { get; init; }
public string? CurrencyCode { get; init; }
}
public sealed class RegistryActionLogViewModel
{
public Guid Id { get; init; }
public string UserDisplayName { get; init; } = string.Empty;
public string ActionType { get; init; } = string.Empty;
public string? ItemName { get; init; }
public int Quantity { get; init; }
public decimal Amount { get; init; }
public string? Details { get; init; }
public DateTimeOffset CreatedAtUtc { get; init; }
}
@@ -1,12 +1,13 @@
using BirthList.Domain.Entities;
using BirthList.Infrastructure.Persistence;
using BirthList.Web.Data;
using Microsoft.EntityFrameworkCore;
using System.Security.Cryptography;
using System.Text;
namespace BirthList.Web.Features.Registries;
internal sealed class RegistryService(RegistryDbContext registryDbContext)
internal sealed class RegistryService(RegistryDbContext registryDbContext, ApplicationDbContext applicationDbContext)
{
public async Task<Guid> CreateRegistryAsync(string userId, RegistryCreateModel model, CancellationToken cancellationToken)
{
@@ -86,16 +87,28 @@ internal sealed class RegistryService(RegistryDbContext registryDbContext)
public async Task<IReadOnlyList<RegistryAdminDisplayModel>> GetRegistryAdminsAsync(Guid registryId, CancellationToken cancellationToken)
{
return await registryDbContext.RegistryAdmins
var admins = await registryDbContext.RegistryAdmins
.Where(x => x.RegistryId == registryId)
.OrderBy(x => x.AddedAtUtc)
.Select(x => new RegistryAdminDisplayModel
{
UserId = x.UserId,
DisplayName = x.UserId
})
.Select(x => x.UserId)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
var usersById = await applicationDbContext.Users
.Where(x => admins.Contains(x.Id))
.Select(x => new { x.Id, x.Email })
.ToDictionaryAsync(x => x.Id, x => x.Email, cancellationToken)
.ConfigureAwait(false);
return admins
.Select(userId => new RegistryAdminDisplayModel
{
UserId = userId,
DisplayName = usersById.TryGetValue(userId, out var email) && !string.IsNullOrWhiteSpace(email)
? email
: userId
})
.ToList();
}
public async Task<RegistryPublicViewModel?> GetPublicRegistryByCodeAsync(string code, string? userId, CancellationToken cancellationToken)
@@ -144,6 +157,44 @@ internal sealed class RegistryService(RegistryDbContext registryDbContext)
await registryDbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
var itemIds = registry.Items.Select(x => x.Id).ToList();
var userIds = new HashSet<string>();
var purchases = await registryDbContext.ItemPurchases
.Where(x => itemIds.Contains(x.RegistryItemId))
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
var contributions = await registryDbContext.ItemContributions
.Where(x => itemIds.Contains(x.RegistryItemId))
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
foreach (var purchase in purchases)
{
userIds.Add(purchase.UserId);
}
foreach (var contribution in contributions)
{
userIds.Add(contribution.UserId);
}
var usersById = await applicationDbContext.Users
.Where(x => userIds.Contains(x.Id))
.Select(x => new { x.Id, x.Email })
.ToDictionaryAsync(x => x.Id, x => x.Email ?? x.Id, cancellationToken)
.ConfigureAwait(false);
var purchasesByItemId = purchases
.GroupBy(x => x.RegistryItemId)
.ToDictionary(g => g.Key, g => g.ToList());
var contributionsByItemId = contributions
.GroupBy(x => x.RegistryItemId)
.ToDictionary(g => g.Key, g => g.ToList());
var isAdmin = !string.IsNullOrWhiteSpace(userId) && registry.Admins.Any(x => x.UserId == userId);
return new RegistryPublicViewModel
{
Id = registry.Id,
@@ -158,6 +209,9 @@ internal sealed class RegistryService(RegistryDbContext registryDbContext)
BankAccountIban = settings?.BankAccountIban,
BankAccountBic = settings?.BankAccountBic,
BankAccountDisplayName = settings?.BankAccountDisplayName,
ShowBankAccountName = settings?.ShowBankAccountName ?? false,
CurrentUserId = userId,
IsAdmin = isAdmin,
Items = registry.Items
.OrderBy(x => x.Name)
.Select(x => new RegistryPublicItemViewModel
@@ -174,8 +228,36 @@ internal sealed class RegistryService(RegistryDbContext registryDbContext)
ParticipationAllowed = x.ParticipationAllowed,
ParticipationTargetAmount = x.ParticipationTargetAmount,
MoneyFulfilledAmount = x.MoneyFulfilledAmount,
CanBeSecondHand = x.CanBeSecondHand,
IsGiven = x.IsGiven
PreferSecondHand = x.PreferSecondHand,
IsGiven = x.IsGiven,
CanViewPurchasers = isAdmin || (purchasesByItemId.TryGetValue(x.Id, out var itemPurchases) && itemPurchases.Any(p => p.UserId == userId)),
Purchasers = purchasesByItemId.TryGetValue(x.Id, out var itemPurchases2)
? itemPurchases2
.GroupBy(p => p.UserId)
.Select(g => new ItemContributorViewModel
{
UserId = g.Key,
DisplayName = usersById.TryGetValue(g.Key, out var displayName) ? displayName : g.Key,
Quantity = g.Sum(p => p.Quantity)
})
.ToList()
: [],
Contributors = contributionsByItemId.TryGetValue(x.Id, out var itemContributions)
? itemContributions
.GroupBy(c => c.UserId)
.Select(g => new ItemContributorViewModel
{
UserId = g.Key,
DisplayName = usersById.TryGetValue(g.Key, out var displayName) ? displayName : g.Key,
Amount = g.Sum(c => c.Amount)
})
.ToList()
: [],
CurrentUserPurchasedQuantity = purchasesByItemId.TryGetValue(x.Id, out var itemPurchases3)
? itemPurchases3
.Where(p => p.UserId == userId)
.Sum(p => p.Quantity)
: 0
})
.ToList()
};
@@ -206,7 +288,8 @@ internal sealed class RegistryService(RegistryDbContext registryDbContext)
ThemeKey = registry.ThemeKey,
BankAccountIban = settings?.BankAccountIban,
BankAccountBic = settings?.BankAccountBic,
BankAccountDisplayName = settings?.BankAccountDisplayName
BankAccountDisplayName = settings?.BankAccountDisplayName,
ShowBankAccountName = settings?.ShowBankAccountName ?? false
};
}
@@ -241,15 +324,56 @@ internal sealed class RegistryService(RegistryDbContext registryDbContext)
settings.BankAccountIban = string.IsNullOrWhiteSpace(model.BankAccountIban) ? null : model.BankAccountIban.Trim();
settings.BankAccountBic = string.IsNullOrWhiteSpace(model.BankAccountBic) ? null : model.BankAccountBic.Trim();
settings.BankAccountDisplayName = string.IsNullOrWhiteSpace(model.BankAccountDisplayName) ? null : model.BankAccountDisplayName.Trim();
settings.ShowBankAccountName = model.ShowBankAccountName;
await registryDbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<RegistryItemEditModel>> GetRegistryItemsAsync(Guid registryId, CancellationToken cancellationToken)
{
return await registryDbContext.RegistryItems
var items = await registryDbContext.RegistryItems
.Where(x => x.RegistryId == registryId)
.OrderBy(x => x.Name)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
var itemIds = items.Select(x => x.Id).ToList();
var userIds = new HashSet<string>();
var purchases = await registryDbContext.ItemPurchases
.Where(x => itemIds.Contains(x.RegistryItemId))
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
var contributions = await registryDbContext.ItemContributions
.Where(x => itemIds.Contains(x.RegistryItemId))
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
foreach (var purchase in purchases)
{
userIds.Add(purchase.UserId);
}
foreach (var contribution in contributions)
{
userIds.Add(contribution.UserId);
}
var usersById = await applicationDbContext.Users
.Where(x => userIds.Contains(x.Id))
.Select(x => new { x.Id, x.Email })
.ToDictionaryAsync(x => x.Id, x => x.Email ?? x.Id, cancellationToken)
.ConfigureAwait(false);
var purchasesByItemId = purchases
.GroupBy(x => x.RegistryItemId)
.ToDictionary(g => g.Key, g => g.ToList());
var contributionsByItemId = contributions
.GroupBy(x => x.RegistryItemId)
.ToDictionary(g => g.Key, g => g.ToList());
return items
.Select(x => new RegistryItemEditModel
{
Id = x.Id,
@@ -262,34 +386,104 @@ internal sealed class RegistryService(RegistryDbContext registryDbContext)
DesiredQuantity = x.DesiredQuantity,
ParticipationAllowed = x.ParticipationAllowed,
ParticipationTargetAmount = x.ParticipationTargetAmount,
CanBeSecondHand = x.CanBeSecondHand,
IsGiven = x.IsGiven
PreferSecondHand = x.PreferSecondHand,
IsGiven = x.IsGiven,
Purchasers = purchasesByItemId.TryGetValue(x.Id, out var itemPurchases)
? itemPurchases
.GroupBy(p => p.UserId)
.Select(g => new ItemContributorViewModel
{
UserId = g.Key,
DisplayName = usersById.TryGetValue(g.Key, out var displayName) ? displayName : g.Key,
Quantity = g.Sum(p => p.Quantity)
})
.ToList()
: [],
Contributors = contributionsByItemId.TryGetValue(x.Id, out var itemContributions)
? itemContributions
.GroupBy(c => c.UserId)
.Select(g => new ItemContributorViewModel
{
UserId = g.Key,
DisplayName = usersById.TryGetValue(g.Key, out var displayName) ? displayName : g.Key,
Amount = g.Sum(c => c.Amount)
})
.ToList()
: []
})
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
.ToList();
}
public async Task<RegistryItemEditModel?> GetRegistryItemAsync(Guid registryId, Guid itemId, CancellationToken cancellationToken)
{
return await registryDbContext.RegistryItems
.Where(x => x.RegistryId == registryId && x.Id == itemId)
.Select(x => new RegistryItemEditModel
{
Id = x.Id,
Name = x.Name,
PictureUrl = x.PictureUrl,
ProductUrl = x.ProductUrl,
Description = x.Description,
PriceAmount = x.PriceAmount,
CurrencyCode = x.CurrencyCode,
DesiredQuantity = x.DesiredQuantity,
ParticipationAllowed = x.ParticipationAllowed,
ParticipationTargetAmount = x.ParticipationTargetAmount,
CanBeSecondHand = x.CanBeSecondHand,
IsGiven = x.IsGiven
})
.FirstOrDefaultAsync(cancellationToken)
var item = await registryDbContext.RegistryItems
.FirstOrDefaultAsync(x => x.RegistryId == registryId && x.Id == itemId, cancellationToken)
.ConfigureAwait(false);
if (item is null)
{
return null;
}
var purchases = await registryDbContext.ItemPurchases
.Where(x => x.RegistryItemId == itemId)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
var contributions = await registryDbContext.ItemContributions
.Where(x => x.RegistryItemId == itemId)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
var userIds = new HashSet<string>();
foreach (var purchase in purchases)
{
userIds.Add(purchase.UserId);
}
foreach (var contribution in contributions)
{
userIds.Add(contribution.UserId);
}
var usersById = await applicationDbContext.Users
.Where(x => userIds.Contains(x.Id))
.Select(x => new { x.Id, x.Email })
.ToDictionaryAsync(x => x.Id, x => x.Email ?? x.Id, cancellationToken)
.ConfigureAwait(false);
return new RegistryItemEditModel
{
Id = item.Id,
Name = item.Name,
PictureUrl = item.PictureUrl,
ProductUrl = item.ProductUrl,
Description = item.Description,
PriceAmount = item.PriceAmount,
CurrencyCode = item.CurrencyCode,
DesiredQuantity = item.DesiredQuantity,
ParticipationAllowed = item.ParticipationAllowed,
ParticipationTargetAmount = item.ParticipationTargetAmount,
PreferSecondHand = item.PreferSecondHand,
IsGiven = item.IsGiven,
Purchasers = purchases
.GroupBy(p => p.UserId)
.Select(g => new ItemContributorViewModel
{
UserId = g.Key,
DisplayName = usersById.TryGetValue(g.Key, out var displayName) ? displayName : g.Key,
Quantity = g.Sum(p => p.Quantity)
})
.ToList(),
Contributors = contributions
.GroupBy(c => c.UserId)
.Select(g => new ItemContributorViewModel
{
UserId = g.Key,
DisplayName = usersById.TryGetValue(g.Key, out var displayName) ? displayName : g.Key,
Amount = g.Sum(c => c.Amount)
})
.ToList()
};
}
public async Task UpsertRegistryItemAsync(Guid registryId, RegistryItemEditModel model, CancellationToken cancellationToken)
@@ -327,7 +521,7 @@ internal sealed class RegistryService(RegistryDbContext registryDbContext)
entity.DesiredQuantity = model.DesiredQuantity < 1 ? 1 : model.DesiredQuantity;
entity.ParticipationAllowed = model.ParticipationAllowed;
entity.ParticipationTargetAmount = model.ParticipationAllowed ? model.ParticipationTargetAmount : null;
entity.CanBeSecondHand = model.CanBeSecondHand;
entity.PreferSecondHand = model.PreferSecondHand;
entity.IsGiven = model.IsGiven;
await registryDbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
@@ -370,6 +564,17 @@ internal sealed class RegistryService(RegistryDbContext registryDbContext)
item.PurchasedQuantity += normalizedQuantity;
item.IsGiven = item.PurchasedQuantity >= item.DesiredQuantity;
registryDbContext.UserActionLogs.Add(new UserActionLog
{
Id = Guid.NewGuid(),
RegistryId = item.RegistryId,
UserId = userId,
RegistryItemId = itemId,
ActionType = UserActionType.MarkPurchased,
Quantity = normalizedQuantity,
CreatedAtUtc = DateTimeOffset.UtcNow
});
await registryDbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
@@ -397,6 +602,153 @@ internal sealed class RegistryService(RegistryDbContext registryDbContext)
});
item.MoneyFulfilledAmount += amount;
registryDbContext.UserActionLogs.Add(new UserActionLog
{
Id = Guid.NewGuid(),
RegistryId = item.RegistryId,
UserId = userId,
RegistryItemId = itemId,
ActionType = UserActionType.LogContribution,
Amount = amount,
Details = transferMessage,
CreatedAtUtc = DateTimeOffset.UtcNow
});
await registryDbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
public async Task UnmarkPurchaseAsync(Guid itemId, string userId, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(userId);
var item = await registryDbContext.RegistryItems
.FirstOrDefaultAsync(x => x.Id == itemId, cancellationToken)
.ConfigureAwait(false) ?? throw new InvalidOperationException("Item not found.");
var purchase = await registryDbContext.ItemPurchases
.FirstOrDefaultAsync(x => x.RegistryItemId == itemId && x.UserId == userId, cancellationToken)
.ConfigureAwait(false);
if (purchase is null)
{
return;
}
item.PurchasedQuantity -= purchase.Quantity;
item.IsGiven = item.PurchasedQuantity >= item.DesiredQuantity;
registryDbContext.ItemPurchases.Remove(purchase);
registryDbContext.UserActionLogs.Add(new UserActionLog
{
Id = Guid.NewGuid(),
RegistryId = item.RegistryId,
UserId = userId,
RegistryItemId = itemId,
ActionType = UserActionType.UnmarkPurchased,
Quantity = purchase.Quantity,
CreatedAtUtc = DateTimeOffset.UtcNow
});
await registryDbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
public async Task UnmarkPurchaseByAdminAsync(Guid itemId, string purchaserUserId, string adminUserId, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(purchaserUserId);
ArgumentException.ThrowIfNullOrWhiteSpace(adminUserId);
var item = await registryDbContext.RegistryItems
.FirstOrDefaultAsync(x => x.Id == itemId, cancellationToken)
.ConfigureAwait(false) ?? throw new InvalidOperationException("Item not found.");
var purchase = await registryDbContext.ItemPurchases
.FirstOrDefaultAsync(x => x.RegistryItemId == itemId && x.UserId == purchaserUserId, cancellationToken)
.ConfigureAwait(false);
if (purchase is null)
{
return;
}
item.PurchasedQuantity -= purchase.Quantity;
item.IsGiven = item.PurchasedQuantity >= item.DesiredQuantity;
registryDbContext.ItemPurchases.Remove(purchase);
registryDbContext.UserActionLogs.Add(new UserActionLog
{
Id = Guid.NewGuid(),
RegistryId = item.RegistryId,
UserId = adminUserId,
RegistryItemId = itemId,
ActionType = UserActionType.UnmarkPurchased,
Quantity = purchase.Quantity,
Details = $"Unmarked purchase by {purchaserUserId}",
CreatedAtUtc = DateTimeOffset.UtcNow
});
await registryDbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<RegistryActionLogViewModel>> GetRegistryActionLogsAsync(Guid registryId, CancellationToken cancellationToken)
{
var actionLogs = await registryDbContext.UserActionLogs
.Where(x => x.RegistryId == registryId)
.OrderByDescending(x => x.CreatedAtUtc)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
var userIds = actionLogs.Select(x => x.UserId).Distinct().ToList();
var usersById = await applicationDbContext.Users
.Where(x => userIds.Contains(x.Id))
.Select(x => new { x.Id, x.Email })
.ToDictionaryAsync(x => x.Id, x => x.Email ?? x.Id, cancellationToken)
.ConfigureAwait(false);
var itemIds = actionLogs
.Where(x => x.RegistryItemId.HasValue)
.Select(x => x.RegistryItemId!.Value)
.Distinct()
.ToList();
var itemsById = await registryDbContext.RegistryItems
.Where(x => itemIds.Contains(x.Id))
.Select(x => new { x.Id, x.Name })
.ToDictionaryAsync(x => x.Id, x => x.Name, cancellationToken)
.ConfigureAwait(false);
return actionLogs
.Select(log => new RegistryActionLogViewModel
{
Id = log.Id,
UserDisplayName = usersById.TryGetValue(log.UserId, out var displayName) ? displayName : log.UserId,
ActionType = log.ActionType.ToString(),
ItemName = log.RegistryItemId.HasValue && itemsById.TryGetValue(log.RegistryItemId.Value, out var itemName) ? itemName : null,
Quantity = log.Quantity,
Amount = log.Amount,
Details = log.Details,
CreatedAtUtc = log.CreatedAtUtc
})
.ToList();
}
public async Task LogUserActionAsync(Guid registryId, string userId, UserActionType actionType, Guid? itemId, string? details, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(userId);
registryDbContext.UserActionLogs.Add(new UserActionLog
{
Id = Guid.NewGuid(),
RegistryId = registryId,
UserId = userId,
RegistryItemId = itemId,
ActionType = actionType,
Details = details,
CreatedAtUtc = DateTimeOffset.UtcNow
});
await registryDbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
@@ -458,6 +810,21 @@ internal sealed class RegistryService(RegistryDbContext registryDbContext)
return true;
}
public async Task RemoveRegistryAdminAsync(Guid registryId, string userId, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(userId);
var admin = await registryDbContext.RegistryAdmins
.FirstOrDefaultAsync(x => x.RegistryId == registryId && x.UserId == userId, cancellationToken)
.ConfigureAwait(false);
if (admin is not null)
{
registryDbContext.RegistryAdmins.Remove(admin);
await registryDbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
}
private async Task<string> CreateUniquePublicCodeAsync(CancellationToken cancellationToken)
{
for (var attempt = 0; attempt < 10; attempt++)
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB