diff --git a/src/Web/Features/Contents/Data/ContentDbContext.cs b/src/Web/Features/Contents/Data/ContentDbContext.cs index 0ea9bcb..6ef76de 100644 --- a/src/Web/Features/Contents/Data/ContentDbContext.cs +++ b/src/Web/Features/Contents/Data/ContentDbContext.cs @@ -9,7 +9,8 @@ public class ContentDbContext( public DbSet Contents => Set(); public DbSet Creators => Set(); - protected override void OnModelCreating(ModelBuilder modelBuilder) + protected override void OnModelCreating( + ModelBuilder modelBuilder) { modelBuilder.HasDefaultSchema(SchemaName); @@ -34,6 +35,15 @@ public class ContentDbContext( .Entity() .Property(c => c.ThumbnailUrl); + modelBuilder + .Entity() + .Property(x => x.NormalizedName) + .HasComputedColumnSql("LOWER( \"Content\".\"Creators\".\"Name\")", stored: true); + + modelBuilder + .Entity() + .HasIndex(x => x.NormalizedName) + .IsUnique(); modelBuilder .Entity() diff --git a/src/Web/Features/Contents/Data/Creator.cs b/src/Web/Features/Contents/Data/Creator.cs index fd411ed..b8ec61b 100644 --- a/src/Web/Features/Contents/Data/Creator.cs +++ b/src/Web/Features/Contents/Data/Creator.cs @@ -8,6 +8,7 @@ public class Creator public Guid CreatedBy { get; set; } public DateTimeOffset CreatedAt { get; init; } [MaxLength(255)] public string Name { get; set; } = null!; + [MaxLength(255)] public string NormalizedName { get; set; } = null!; [MaxLength(255)] public string? Title { get; set; } public Socials Socials { get; set; } = new(); public Colors Colors { get; set; } = new(); diff --git a/src/Web/Features/Contents/Data/Migrations/20250108022601_AddComputedColumnAndIndex_CreatorName.Designer.cs b/src/Web/Features/Contents/Data/Migrations/20250108022601_AddComputedColumnAndIndex_CreatorName.Designer.cs new file mode 100644 index 0000000..dcd72ef --- /dev/null +++ b/src/Web/Features/Contents/Data/Migrations/20250108022601_AddComputedColumnAndIndex_CreatorName.Designer.cs @@ -0,0 +1,400 @@ +// +using System; +using Hutopy.Web.Features.Contents.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Hutopy.Web.Features.Contents.Data.Migrations +{ + [DbContext(typeof(ContentDbContext))] + [Migration("20250108022601_AddComputedColumnAndIndex_CreatorName")] + partial class AddComputedColumnAndIndex_CreatorName + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("Content") + .HasAnnotation("ProductVersion", "8.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Content", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasColumnType("uuid"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("HtmlFileUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("ThumbnailUrl") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Urls") + .HasColumnType("text[]"); + + b.HasKey("Id"); + + b.HasIndex("CreatedBy"); + + b.ToTable("Contents", "Content"); + }); + + modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Creator", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("NormalizedName") + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasComputedColumnSql("LOWER( \"Content\".\"Creators\".\"Name\")", true); + + b.Property("Title") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique(); + + b.ToTable("Creators", "Content"); + }); + + modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Content", b => + { + b.HasOne("Hutopy.Web.Features.Contents.Data.Creator", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsMany("Hutopy.Web.Features.Contents.Data.ContentReaction", "Reactions", b1 => + { + b1.Property("ContentId") + .HasColumnType("uuid"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("Reaction") + .HasColumnType("integer"); + + b1.Property("UserId") + .HasColumnType("uuid"); + + b1.Property("UserName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b1.HasKey("ContentId", "Id"); + + b1.ToTable("Reactions", "Content"); + + b1.WithOwner() + .HasForeignKey("ContentId"); + }); + + b.Navigation("Creator"); + + b.Navigation("Reactions"); + }); + + modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Creator", b => + { + b.OwnsOne("Hutopy.Web.Features.Contents.Data.Colors", "Colors", b1 => + { + b1.Property("CreatorId") + .HasColumnType("uuid"); + + b1.Property("Background") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character varying(9)"); + + b1.Property("Error") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character varying(9)"); + + b1.Property("OnBackground") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character varying(9)"); + + b1.Property("OnError") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character varying(9)"); + + b1.Property("OnPrimary") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character varying(9)"); + + b1.Property("OnSecondary") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character varying(9)"); + + b1.Property("OnSurface") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character varying(9)"); + + b1.Property("Primary") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character varying(9)"); + + b1.Property("Secondary") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character varying(9)"); + + b1.Property("Surface") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character varying(9)"); + + b1.HasKey("CreatorId"); + + b1.ToTable("Colors", "Content"); + + b1.WithOwner() + .HasForeignKey("CreatorId"); + }); + + b.OwnsOne("Hutopy.Web.Features.Contents.Data.Images", "Images", b1 => + { + b1.Property("CreatorId") + .HasColumnType("uuid"); + + b1.Property("Banner") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b1.Property("Logo") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b1.HasKey("CreatorId"); + + b1.ToTable("Images", "Content"); + + b1.WithOwner() + .HasForeignKey("CreatorId"); + }); + + b.OwnsOne("Hutopy.Web.Features.Contents.Data.PresentationInfos", "PresentationInfos", b1 => + { + b1.Property("CreatorId") + .HasColumnType("uuid"); + + b1.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b1.Property("Image1Url") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b1.Property("Image2Url") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b1.Property("Image3Url") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b1.Property("Image4Url") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b1.Property("ImagesSubtitle") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b1.Property("ImagesText") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)"); + + b1.Property("MainImageText") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)"); + + b1.Property("MainImageUrl") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b1.Property("MainVideoText") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)"); + + b1.Property("PhoneNumber") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b1.Property("Title") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b1.Property("VideoSubtitle") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b1.Property("VideoSubtitleMain") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b1.Property("VideoText") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)"); + + b1.Property("VideoUrl") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b1.Property("VideoUrlMain") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b1.HasKey("CreatorId"); + + b1.ToTable("PresentationInfos", "Content"); + + b1.WithOwner() + .HasForeignKey("CreatorId"); + }); + + b.OwnsOne("Hutopy.Web.Features.Contents.Data.Socials", "Socials", b1 => + { + b1.Property("CreatorId") + .HasColumnType("uuid"); + + b1.Property("FacebookUrl") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b1.Property("InstagramUrl") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b1.Property("LinkedInUrl") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b1.Property("RedditUrl") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b1.Property("TikTokUrl") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b1.Property("WebsiteUrl") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b1.Property("XUrl") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b1.Property("YoutubeUrl") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b1.HasKey("CreatorId"); + + b1.ToTable("Socials", "Content"); + + b1.WithOwner() + .HasForeignKey("CreatorId"); + }); + + b.Navigation("Colors") + .IsRequired(); + + b.Navigation("Images") + .IsRequired(); + + b.Navigation("PresentationInfos") + .IsRequired(); + + b.Navigation("Socials") + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Web/Features/Contents/Data/Migrations/20250108022601_AddComputedColumnAndIndex_CreatorName.cs b/src/Web/Features/Contents/Data/Migrations/20250108022601_AddComputedColumnAndIndex_CreatorName.cs new file mode 100644 index 0000000..ab8bd48 --- /dev/null +++ b/src/Web/Features/Contents/Data/Migrations/20250108022601_AddComputedColumnAndIndex_CreatorName.cs @@ -0,0 +1,45 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Hutopy.Web.Features.Contents.Data.Migrations +{ + /// + public partial class AddComputedColumnAndIndex_CreatorName : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "NormalizedName", + schema: "Content", + table: "Creators", + type: "character varying(255)", + maxLength: 255, + nullable: false, + computedColumnSql: "LOWER( \"Content\".\"Creators\".\"Name\")", + stored: true); + + migrationBuilder.CreateIndex( + name: "IX_Creators_NormalizedName", + schema: "Content", + table: "Creators", + column: "NormalizedName", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Creators_NormalizedName", + schema: "Content", + table: "Creators"); + + migrationBuilder.DropColumn( + name: "NormalizedName", + schema: "Content", + table: "Creators"); + } + } +} diff --git a/src/Web/Features/Contents/Data/Migrations/ContentDbContextModelSnapshot.cs b/src/Web/Features/Contents/Data/Migrations/ContentDbContextModelSnapshot.cs index 2100220..274b951 100644 --- a/src/Web/Features/Contents/Data/Migrations/ContentDbContextModelSnapshot.cs +++ b/src/Web/Features/Contents/Data/Migrations/ContentDbContextModelSnapshot.cs @@ -88,12 +88,22 @@ namespace Hutopy.Web.Features.Contents.Data.Migrations .HasMaxLength(255) .HasColumnType("character varying(255)"); + b.Property("NormalizedName") + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasComputedColumnSql("LOWER( \"Content\".\"Creators\".\"Name\")", true); + b.Property("Title") .HasMaxLength(255) .HasColumnType("character varying(255)"); b.HasKey("Id"); + b.HasIndex("NormalizedName") + .IsUnique(); + b.ToTable("Creators", "Content"); }); diff --git a/src/Web/Features/Contents/Handlers/CreateCreator.cs b/src/Web/Features/Contents/Handlers/CreateCreator.cs index 07b5db3..d52e4fc 100644 --- a/src/Web/Features/Contents/Handlers/CreateCreator.cs +++ b/src/Web/Features/Contents/Handlers/CreateCreator.cs @@ -1,6 +1,8 @@ -using Hutopy.Web.Common; +using System.Net; +using FluentValidation.Results; using Hutopy.Web.Common.Security; using Hutopy.Web.Features.Contents.Data; +using Npgsql; namespace Hutopy.Web.Features.Contents.Handlers; @@ -39,30 +41,51 @@ public sealed class CreateCreatorHandler( CreateCreatorRequest req, CancellationToken ct) { - await context.Creators.AddAsync( - new Creator - { - Id = req.CreatorId, - CreatedBy = User.GetUserId(), - Name = req.Name, - Colors = + try + { + await context.Creators.AddAsync( + new Creator { - Primary = "#6200EE", - OnPrimary = "#FFFFFF", - Secondary = "#03DAC6", - OnSecondary = "#000000", - Surface = "#FFFFFF", - OnSurface = "#000000", - Error = "#B00020", - OnError = "#FFFFFF", - Background = "#FFFFFF", - OnBackground = "#000000", + Id = req.CreatorId, + CreatedBy = User.GetUserId(), + Name = req.Name, + Colors = + { + Primary = "#6200EE", + OnPrimary = "#FFFFFF", + Secondary = "#03DAC6", + OnSecondary = "#000000", + Surface = "#FFFFFF", + OnSurface = "#000000", + Error = "#B00020", + OnError = "#FFFFFF", + Background = "#FFFFFF", + OnBackground = "#000000", + } + }, + ct); + + await context.SaveChangesAsync(ct); + + await SendOkAsync(ct); + } + catch (Exception e) + { + if (e.InnerException is PostgresException innerException) + { + if (innerException.ConstraintName == "IX_Creators_NormalizedName") + { + await SendResultAsync(new ProblemDetails( + [new ValidationFailure(nameof(Creator.Name), "The name is already taken.")], + (int)HttpStatusCode.Conflict)); } - }, - ct); - - await context.SaveChangesAsync(ct); - - await SendOkAsync(ct); + } + else + { + await SendResultAsync(new ProblemDetails( + [new ValidationFailure(nameof(Creator.Name), e.Message)], + (int)HttpStatusCode.Conflict)); + } + } } } diff --git a/src/Web/Web.csproj b/src/Web/Web.csproj index e5bfd9d..68263b5 100644 --- a/src/Web/Web.csproj +++ b/src/Web/Web.csproj @@ -38,6 +38,8 @@ + +