diff --git a/src/BirthList.Domain/Entities/Registry.cs b/src/BirthList.Domain/Entities/Registry.cs index a598a86..357b8db 100644 --- a/src/BirthList.Domain/Entities/Registry.cs +++ b/src/BirthList.Domain/Entities/Registry.cs @@ -15,6 +15,7 @@ public class Registry public DateTimeOffset CreatedAtUtc { get; set; } public ICollection Admins { get; set; } = []; + public ICollection ItemCategories { get; set; } = []; public ICollection Items { get; set; } = []; public ICollection Visits { get; set; } = []; public ICollection AdminInvites { get; set; } = []; diff --git a/src/BirthList.Domain/Entities/RegistryItem.cs b/src/BirthList.Domain/Entities/RegistryItem.cs index adbc053..a8125de 100644 --- a/src/BirthList.Domain/Entities/RegistryItem.cs +++ b/src/BirthList.Domain/Entities/RegistryItem.cs @@ -4,6 +4,8 @@ public class RegistryItem { public Guid Id { get; set; } public Guid RegistryId { get; set; } + public Guid CategoryId { get; set; } + public int SortOrder { get; set; } public string Name { get; set; } = string.Empty; public string? PictureUrl { get; set; } public string? ProductUrl { get; set; } @@ -20,6 +22,7 @@ public class RegistryItem public DateTimeOffset CreatedAtUtc { get; set; } public Registry Registry { get; set; } = null!; + public RegistryItemCategory Category { get; set; } = null!; public ICollection Purchases { get; set; } = []; public ICollection Contributions { get; set; } = []; } diff --git a/src/BirthList.Domain/Entities/RegistryItemCategory.cs b/src/BirthList.Domain/Entities/RegistryItemCategory.cs new file mode 100644 index 0000000..fd5f193 --- /dev/null +++ b/src/BirthList.Domain/Entities/RegistryItemCategory.cs @@ -0,0 +1,13 @@ +namespace BirthList.Domain.Entities; + +public class RegistryItemCategory +{ + public Guid Id { get; set; } + public Guid RegistryId { get; set; } + public string Name { get; set; } = string.Empty; + public int SortOrder { get; set; } + public DateTimeOffset CreatedAtUtc { get; set; } + + public Registry Registry { get; set; } = null!; + public ICollection Items { get; set; } = []; +} diff --git a/src/BirthList.Infrastructure/BirthList.Infrastructure.csproj b/src/BirthList.Infrastructure/BirthList.Infrastructure.csproj index b7c9e9a..401c2ab 100644 --- a/src/BirthList.Infrastructure/BirthList.Infrastructure.csproj +++ b/src/BirthList.Infrastructure/BirthList.Infrastructure.csproj @@ -15,10 +15,6 @@ - - - - net8.0 enable diff --git a/src/BirthList.Infrastructure/Migrations/20260519123811_AddRegistryItemCategoriesAndOrdering.cs b/src/BirthList.Infrastructure/Migrations/20260519123811_AddRegistryItemCategoriesAndOrdering.cs new file mode 100644 index 0000000..e69de29 diff --git a/src/BirthList.Infrastructure/Migrations/20260519124946_AddRegistryItemCategoriesAndOrdering.Designer.cs b/src/BirthList.Infrastructure/Migrations/20260519124946_AddRegistryItemCategoriesAndOrdering.Designer.cs new file mode 100644 index 0000000..496c15b --- /dev/null +++ b/src/BirthList.Infrastructure/Migrations/20260519124946_AddRegistryItemCategoriesAndOrdering.Designer.cs @@ -0,0 +1,552 @@ +// +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("20260519124946_AddRegistryItemCategoriesAndOrdering")] + partial class AddRegistryItemCategoriesAndOrdering + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("ContributedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("CurrencyCode") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("nvarchar(3)"); + + b.Property("RegistryItemId") + .HasColumnType("uniqueidentifier"); + + b.Property("TransferMessage") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("PurchasedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("Quantity") + .HasColumnType("int"); + + b.Property("RegistryItemId") + .HasColumnType("uniqueidentifier"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AssignedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("BabyName") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("BirthDate") + .HasColumnType("date"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("CurrencyCode") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("nvarchar(3)"); + + b.Property("HeaderContentHtml") + .HasColumnType("nvarchar(max)"); + + b.Property("PublicLinkCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RegistryType") + .HasColumnType("int"); + + b.Property("ShippingAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("ThemeKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("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("RegistryId") + .HasColumnType("uniqueidentifier"); + + b.Property("UserId") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("AddedAtUtc") + .HasColumnType("datetimeoffset"); + + b.HasKey("RegistryId", "UserId"); + + b.ToTable("RegistryAdmins"); + }); + + modelBuilder.Entity("BirthList.Domain.Entities.RegistryAdminInvite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ExpiresAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("RedeemedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("RegistryId") + .HasColumnType("uniqueidentifier"); + + b.Property("SentToEmail") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CategoryId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("CurrencyCode") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("nvarchar(3)"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("DesiredQuantity") + .HasColumnType("int"); + + b.Property("IsGiven") + .HasColumnType("bit"); + + b.Property("MoneyFulfilledAmount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("ParticipationAllowed") + .HasColumnType("bit"); + + b.Property("ParticipationTargetAmount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("PictureUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("PreferSecondHand") + .HasColumnType("bit"); + + b.Property("PriceAmount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("ProductUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("PurchasedQuantity") + .HasColumnType("int"); + + b.Property("RegistryId") + .HasColumnType("uniqueidentifier"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("RegistryId"); + + b.HasIndex("CategoryId", "SortOrder"); + + b.ToTable("RegistryItems"); + }); + + modelBuilder.Entity("BirthList.Domain.Entities.RegistryItemCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RegistryId") + .HasColumnType("uniqueidentifier"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("RegistryId", "Name"); + + b.HasIndex("RegistryId", "SortOrder"); + + b.ToTable("RegistryItemCategories"); + }); + + modelBuilder.Entity("BirthList.Domain.Entities.RegistrySettings", b => + { + b.Property("RegistryId") + .HasColumnType("uniqueidentifier"); + + b.Property("BankAccountBic") + .HasMaxLength(11) + .HasColumnType("nvarchar(11)"); + + b.Property("BankAccountDisplayName") + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("BankAccountIban") + .HasMaxLength(34) + .HasColumnType("nvarchar(34)"); + + b.Property("ContributionAmountQrCodesJson") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ContributionQrCodeUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("ShowBankAccountName") + .HasColumnType("bit"); + + b.HasKey("RegistryId"); + + b.ToTable("RegistrySettings"); + }); + + modelBuilder.Entity("BirthList.Domain.Entities.RegistryVisit", b => + { + b.Property("RegistryId") + .HasColumnType("uniqueidentifier"); + + b.Property("UserId") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("LastVisitedAtUtc") + .HasColumnType("datetimeoffset"); + + b.HasKey("RegistryId", "UserId"); + + b.ToTable("RegistryVisits"); + }); + + modelBuilder.Entity("BirthList.Domain.Entities.UserActionLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ActionType") + .HasColumnType("int"); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("Details") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Quantity") + .HasColumnType("int"); + + b.Property("RegistryId") + .HasColumnType("uniqueidentifier"); + + b.Property("RegistryItemId") + .HasColumnType("uniqueidentifier"); + + b.Property("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.RegistryItemCategory", "Category") + .WithMany("Items") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("BirthList.Domain.Entities.Registry", "Registry") + .WithMany("Items") + .HasForeignKey("RegistryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Category"); + + b.Navigation("Registry"); + }); + + modelBuilder.Entity("BirthList.Domain.Entities.RegistryItemCategory", b => + { + b.HasOne("BirthList.Domain.Entities.Registry", "Registry") + .WithMany("ItemCategories") + .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("ItemCategories"); + + b.Navigation("Items"); + + b.Navigation("Visits"); + }); + + modelBuilder.Entity("BirthList.Domain.Entities.RegistryItem", b => + { + b.Navigation("Contributions"); + + b.Navigation("Purchases"); + }); + + modelBuilder.Entity("BirthList.Domain.Entities.RegistryItemCategory", b => + { + b.Navigation("Items"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/BirthList.Infrastructure/Migrations/20260519124946_AddRegistryItemCategoriesAndOrdering.cs b/src/BirthList.Infrastructure/Migrations/20260519124946_AddRegistryItemCategoriesAndOrdering.cs new file mode 100644 index 0000000..9b79e34 --- /dev/null +++ b/src/BirthList.Infrastructure/Migrations/20260519124946_AddRegistryItemCategoriesAndOrdering.cs @@ -0,0 +1,128 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace BirthList.Infrastructure.Migrations +{ + /// + public partial class AddRegistryItemCategoriesAndOrdering : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "CategoryId", + table: "RegistryItems", + type: "uniqueidentifier", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); + + migrationBuilder.AddColumn( + name: "SortOrder", + table: "RegistryItems", + type: "int", + nullable: false, + defaultValue: 0); + + migrationBuilder.CreateTable( + name: "RegistryItemCategories", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + RegistryId = table.Column(type: "uniqueidentifier", nullable: false), + Name = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), + SortOrder = table.Column(type: "int", nullable: false), + CreatedAtUtc = table.Column(type: "datetimeoffset", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_RegistryItemCategories", x => x.Id); + table.ForeignKey( + name: "FK_RegistryItemCategories_Registries_RegistryId", + column: x => x.RegistryId, + principalTable: "Registries", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.Sql( + """ + INSERT INTO RegistryItemCategories (Id, RegistryId, Name, SortOrder, CreatedAtUtc) + SELECT NEWID(), r.Id, N'General', 0, SYSUTCDATETIME() + FROM Registries r + WHERE NOT EXISTS ( + SELECT 1 + FROM RegistryItemCategories c + WHERE c.RegistryId = r.Id + ); + + ;WITH GeneralCategories AS ( + SELECT c.RegistryId, c.Id AS CategoryId + FROM RegistryItemCategories c + INNER JOIN ( + SELECT RegistryId, MIN(SortOrder) AS MinSortOrder + FROM RegistryItemCategories + GROUP BY RegistryId + ) firstCategory ON firstCategory.RegistryId = c.RegistryId AND firstCategory.MinSortOrder = c.SortOrder + ), OrderedItems AS ( + SELECT i.Id AS ItemId, + gc.CategoryId, + ROW_NUMBER() OVER (PARTITION BY i.RegistryId ORDER BY i.CreatedAtUtc, i.Id) - 1 AS SortOrder + FROM RegistryItems i + INNER JOIN GeneralCategories gc ON gc.RegistryId = i.RegistryId + ) + UPDATE i + SET i.CategoryId = oi.CategoryId, + i.SortOrder = oi.SortOrder + FROM RegistryItems i + INNER JOIN OrderedItems oi ON oi.ItemId = i.Id; + """); + + migrationBuilder.CreateIndex( + name: "IX_RegistryItems_CategoryId_SortOrder", + table: "RegistryItems", + columns: new[] { "CategoryId", "SortOrder" }); + + migrationBuilder.CreateIndex( + name: "IX_RegistryItemCategories_RegistryId_Name", + table: "RegistryItemCategories", + columns: new[] { "RegistryId", "Name" }); + + migrationBuilder.CreateIndex( + name: "IX_RegistryItemCategories_RegistryId_SortOrder", + table: "RegistryItemCategories", + columns: new[] { "RegistryId", "SortOrder" }); + + migrationBuilder.AddForeignKey( + name: "FK_RegistryItems_RegistryItemCategories_CategoryId", + table: "RegistryItems", + column: "CategoryId", + principalTable: "RegistryItemCategories", + principalColumn: "Id"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_RegistryItems_RegistryItemCategories_CategoryId", + table: "RegistryItems"); + + migrationBuilder.DropTable( + name: "RegistryItemCategories"); + + migrationBuilder.DropIndex( + name: "IX_RegistryItems_CategoryId_SortOrder", + table: "RegistryItems"); + + migrationBuilder.DropColumn( + name: "CategoryId", + table: "RegistryItems"); + + migrationBuilder.DropColumn( + name: "SortOrder", + table: "RegistryItems"); + } + } +} diff --git a/src/BirthList.Infrastructure/Migrations/RegistryDbContextModelSnapshot.cs b/src/BirthList.Infrastructure/Migrations/RegistryDbContextModelSnapshot.cs index 14c56ca..2af21f5 100644 --- a/src/BirthList.Infrastructure/Migrations/RegistryDbContextModelSnapshot.cs +++ b/src/BirthList.Infrastructure/Migrations/RegistryDbContextModelSnapshot.cs @@ -221,6 +221,9 @@ namespace BirthList.Infrastructure.Migrations .ValueGeneratedOnAdd() .HasColumnType("uniqueidentifier"); + b.Property("CategoryId") + .HasColumnType("uniqueidentifier"); + b.Property("CreatedAtUtc") .HasColumnType("datetimeoffset"); @@ -275,13 +278,47 @@ namespace BirthList.Infrastructure.Migrations b.Property("RegistryId") .HasColumnType("uniqueidentifier"); + b.Property("SortOrder") + .HasColumnType("int"); + b.HasKey("Id"); b.HasIndex("RegistryId"); + b.HasIndex("CategoryId", "SortOrder"); + b.ToTable("RegistryItems"); }); + modelBuilder.Entity("BirthList.Domain.Entities.RegistryItemCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RegistryId") + .HasColumnType("uniqueidentifier"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("RegistryId", "Name"); + + b.HasIndex("RegistryId", "SortOrder"); + + b.ToTable("RegistryItemCategories"); + }); + modelBuilder.Entity("BirthList.Domain.Entities.RegistrySettings", b => { b.Property("RegistryId") @@ -419,12 +456,31 @@ namespace BirthList.Infrastructure.Migrations modelBuilder.Entity("BirthList.Domain.Entities.RegistryItem", b => { + b.HasOne("BirthList.Domain.Entities.RegistryItemCategory", "Category") + .WithMany("Items") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + b.HasOne("BirthList.Domain.Entities.Registry", "Registry") .WithMany("Items") .HasForeignKey("RegistryId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); + b.Navigation("Category"); + + b.Navigation("Registry"); + }); + + modelBuilder.Entity("BirthList.Domain.Entities.RegistryItemCategory", b => + { + b.HasOne("BirthList.Domain.Entities.Registry", "Registry") + .WithMany("ItemCategories") + .HasForeignKey("RegistryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + b.Navigation("Registry"); }); @@ -469,6 +525,8 @@ namespace BirthList.Infrastructure.Migrations b.Navigation("Admins"); + b.Navigation("ItemCategories"); + b.Navigation("Items"); b.Navigation("Visits"); @@ -480,6 +538,11 @@ namespace BirthList.Infrastructure.Migrations b.Navigation("Purchases"); }); + + modelBuilder.Entity("BirthList.Domain.Entities.RegistryItemCategory", b => + { + b.Navigation("Items"); + }); #pragma warning restore 612, 618 } } diff --git a/src/BirthList.Infrastructure/Persistence/RegistryDbContext.cs b/src/BirthList.Infrastructure/Persistence/RegistryDbContext.cs index d268a23..5546ab9 100644 --- a/src/BirthList.Infrastructure/Persistence/RegistryDbContext.cs +++ b/src/BirthList.Infrastructure/Persistence/RegistryDbContext.cs @@ -9,6 +9,7 @@ public class RegistryDbContext(DbContextOptions options) : Db public DbSet RegistryAdmins => Set(); public DbSet RegistryAdminInvites => Set(); public DbSet RegistrySettings => Set(); + public DbSet RegistryItemCategories => Set(); public DbSet RegistryItems => Set(); public DbSet ItemPurchases => Set(); public DbSet ItemContributions => Set(); @@ -65,6 +66,18 @@ public class RegistryDbContext(DbContextOptions options) : Db .OnDelete(DeleteBehavior.Cascade); }); + modelBuilder.Entity(entity => + { + entity.HasKey(x => x.Id); + entity.Property(x => x.Name).HasMaxLength(100); + entity.HasIndex(x => new { x.RegistryId, x.SortOrder }); + entity.HasIndex(x => new { x.RegistryId, x.Name }); + entity.HasOne(x => x.Registry) + .WithMany(x => x.ItemCategories) + .HasForeignKey(x => x.RegistryId) + .OnDelete(DeleteBehavior.Cascade); + }); + modelBuilder.Entity(entity => { entity.HasKey(x => x.Id); @@ -75,10 +88,15 @@ public class RegistryDbContext(DbContextOptions options) : Db entity.Property(x => x.PriceAmount).HasPrecision(18, 2); entity.Property(x => x.ParticipationTargetAmount).HasPrecision(18, 2); entity.Property(x => x.MoneyFulfilledAmount).HasPrecision(18, 2); + entity.HasIndex(x => new { x.CategoryId, x.SortOrder }); entity.HasOne(x => x.Registry) .WithMany(x => x.Items) .HasForeignKey(x => x.RegistryId) .OnDelete(DeleteBehavior.Cascade); + entity.HasOne(x => x.Category) + .WithMany(x => x.Items) + .HasForeignKey(x => x.CategoryId) + .OnDelete(DeleteBehavior.NoAction); }); modelBuilder.Entity(entity => diff --git a/src/BirthList.Web/BirthList.Web.csproj b/src/BirthList.Web/BirthList.Web.csproj index 74c29be..7674319 100644 --- a/src/BirthList.Web/BirthList.Web.csproj +++ b/src/BirthList.Web/BirthList.Web.csproj @@ -30,4 +30,8 @@ + + + + diff --git a/src/BirthList.Web/Components/Account/Pages/Manage/Index.razor b/src/BirthList.Web/Components/Account/Pages/Manage/Index.razor index 6ac6eb9..70a4bcf 100644 --- a/src/BirthList.Web/Components/Account/Pages/Manage/Index.razor +++ b/src/BirthList.Web/Components/Account/Pages/Manage/Index.razor @@ -73,9 +73,11 @@ private async Task OnValidSubmitAsync() { - if (Input.PhoneNumber != phoneNumber) + var normalizedPhone = string.IsNullOrWhiteSpace(Input.PhoneNumber) ? null : Input.PhoneNumber.Trim(); + + if (normalizedPhone != phoneNumber) { - var setPhoneResult = await UserManager.SetPhoneNumberAsync(user, Input.PhoneNumber); + var setPhoneResult = await UserManager.SetPhoneNumberAsync(user, normalizedPhone); if (!setPhoneResult.Succeeded) { RedirectManager.RedirectToCurrentPageWithStatus("Error: Failed to set phone number.", HttpContext); @@ -110,8 +112,28 @@ [Display(Name = "Address")] public string? Address { get; set; } - [Phone] + [OptionalPhone(ErrorMessage = "The Phone number field is not a valid phone number.")] [Display(Name = "Phone number")] public string? PhoneNumber { get; set; } } + + private sealed class OptionalPhoneAttribute : ValidationAttribute + { + private static readonly PhoneAttribute PhoneValidator = new(); + + public override bool IsValid(object? value) + { + if (value is null) + { + return true; + } + + if (value is string stringValue && string.IsNullOrWhiteSpace(stringValue)) + { + return true; + } + + return PhoneValidator.IsValid(value); + } + } } diff --git a/src/BirthList.Web/Components/App.razor b/src/BirthList.Web/Components/App.razor index efcb884..f42e574 100644 --- a/src/BirthList.Web/Components/App.razor +++ b/src/BirthList.Web/Components/App.razor @@ -20,6 +20,7 @@ + diff --git a/src/BirthList.Web/Components/Pages/RegistryAdmin.razor b/src/BirthList.Web/Components/Pages/RegistryAdmin.razor index 0f8370d..8e53d72 100644 --- a/src/BirthList.Web/Components/Pages/RegistryAdmin.razor +++ b/src/BirthList.Web/Components/Pages/RegistryAdmin.razor @@ -54,6 +54,17 @@ else Administrators +