diff --git a/backend/src/Web/Features/Contents/Data/ContentDbContext.cs b/backend/src/Web/Features/Contents/Data/ContentDbContext.cs index 6ef76de..767dffd 100644 --- a/backend/src/Web/Features/Contents/Data/ContentDbContext.cs +++ b/backend/src/Web/Features/Contents/Data/ContentDbContext.cs @@ -8,6 +8,7 @@ public class ContentDbContext( public DbSet Contents => Set(); public DbSet Creators => Set(); + public DbSet Slugs => Set(); protected override void OnModelCreating( ModelBuilder modelBuilder) @@ -36,12 +37,12 @@ public class ContentDbContext( .Property(c => c.ThumbnailUrl); modelBuilder - .Entity() + .Entity() .Property(x => x.NormalizedName) - .HasComputedColumnSql("LOWER( \"Content\".\"Creators\".\"Name\")", stored: true); + .HasComputedColumnSql("LOWER( \"Content\".\"Slugs\".\"Name\")", stored: true); modelBuilder - .Entity() + .Entity() .HasIndex(x => x.NormalizedName) .IsUnique(); diff --git a/backend/src/Web/Features/Contents/Data/Creator.cs b/backend/src/Web/Features/Contents/Data/Creator.cs index 496a4dc..f0d6713 100644 --- a/backend/src/Web/Features/Contents/Data/Creator.cs +++ b/backend/src/Web/Features/Contents/Data/Creator.cs @@ -9,8 +9,7 @@ public class Creator public DateTimeOffset CreatedAt { get; init; } public bool AcceptDonation { get; set; } public bool Verified { get; set; } - [MaxLength(255)] public string Name { get; set; } = null!; - [MaxLength(255)] public string NormalizedName { get; set; } = null!; + public Slugs Slugs { 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/backend/src/Web/Features/Contents/Data/Migrations/20250131210849_AddSlug.Designer.cs b/backend/src/Web/Features/Contents/Data/Migrations/20250131210849_AddSlug.Designer.cs new file mode 100644 index 0000000..2577e29 --- /dev/null +++ b/backend/src/Web/Features/Contents/Data/Migrations/20250131210849_AddSlug.Designer.cs @@ -0,0 +1,442 @@ +// +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("20250131210849_AddSlug")] + partial class AddSlug + { + /// + 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("AcceptDonation") + .HasColumnType("boolean"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("SlugsId") + .HasColumnType("uuid"); + + b.Property("Title") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Verified") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("SlugsId"); + + b.ToTable("Creators", "Content"); + }); + + modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Slugs", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Active") + .HasColumnType("boolean"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("NormalizedName") + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComputedColumnSql("LOWER( \"Content\".\"Slugs\".\"Name\")", true); + + b.Property("ReservedUntil") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique(); + + b.ToTable("Slugs", "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.HasOne("Hutopy.Web.Features.Contents.Data.Slugs", "Slugs") + .WithMany() + .HasForeignKey("SlugsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + 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("Slugs"); + + b.Navigation("Socials") + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/Web/Features/Contents/Data/Migrations/20250131210849_AddSlug.cs b/backend/src/Web/Features/Contents/Data/Migrations/20250131210849_AddSlug.cs new file mode 100644 index 0000000..ec5e2b5 --- /dev/null +++ b/backend/src/Web/Features/Contents/Data/Migrations/20250131210849_AddSlug.cs @@ -0,0 +1,159 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Hutopy.Web.Features.Contents.Data.Migrations +{ + /// + public partial class AddSlug : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Creators_NormalizedName", + schema: "Content", + table: "Creators"); + + migrationBuilder.DropColumn( + name: "NormalizedName", + schema: "Content", + table: "Creators"); + + // Add SlugsId column to Creators (temporary nullable to avoid issues while updating) + migrationBuilder.AddColumn( + name: "SlugsId", + schema: "Content", + table: "Creators", + type: "uuid", + nullable: true); + + // Create the Slugs table + migrationBuilder.CreateTable( + name: "Slugs", + schema: "Content", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + CreatedBy = table.Column(type: "uuid", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + Name = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + NormalizedName = table.Column(type: "character varying(128)", maxLength: 128, nullable: false, computedColumnSql: "LOWER( \"Content\".\"Slugs\".\"Name\")", stored: true), + ReservedUntil = table.Column(type: "timestamp with time zone", nullable: false), + Active = table.Column(type: "boolean", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Slugs", x => x.Id); + }); + + // Create Slugs for existing Creators + migrationBuilder.Sql(@" + INSERT INTO ""Content"".""Slugs"" (""Id"", ""Name"", ""CreatedBy"", ""CreatedAt"", ""ReservedUntil"", ""Active"") + SELECT ""Id"", ""Name"", ""CreatedBy"", ""CreatedAt"", ""CreatedAt"", TRUE + FROM ""Content"".""Creators"" + WHERE ""Name"" IS NOT NULL + "); + + // Update Creators to reference Slugs + migrationBuilder.Sql(@" + UPDATE ""Content"".""Creators"" + SET ""SlugsId"" = (SELECT ""Id"" FROM ""Content"".""Slugs"" WHERE ""Content"".""Slugs"".""Name"" = ""Content"".""Creators"".""Name"") + WHERE ""Name"" IS NOT NULL + "); + + // Make SlugsId non-nullable + migrationBuilder.AlterColumn( + name: "SlugsId", + schema: "Content", + table: "Creators", + type: "uuid", + nullable: false, + oldClrType: typeof(Guid), + oldType: "uuid", + oldNullable: true); + + // Create index for SlugsId + migrationBuilder.CreateIndex( + name: "IX_Creators_SlugsId", + schema: "Content", + table: "Creators", + column: "SlugsId"); + + // Create index for NormalizedName in Slugs + migrationBuilder.CreateIndex( + name: "IX_Slugs_NormalizedName", + schema: "Content", + table: "Slugs", + column: "NormalizedName", + unique: true); + + // Add foreign key constraint + migrationBuilder.AddForeignKey( + name: "FK_Creators_Slugs_SlugsId", + schema: "Content", + table: "Creators", + column: "SlugsId", + principalSchema: "Content", + principalTable: "Slugs", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + + // Drop the Name column + migrationBuilder.DropColumn( + name: "Name", + schema: "Content", + table: "Creators"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Creators_Slugs_SlugsId", + schema: "Content", + table: "Creators"); + + migrationBuilder.DropTable( + name: "Slugs", + schema: "Content"); + + migrationBuilder.DropIndex( + name: "IX_Creators_SlugsId", + schema: "Content", + table: "Creators"); + + migrationBuilder.DropColumn( + name: "SlugsId", + schema: "Content", + table: "Creators"); + + migrationBuilder.AddColumn( + name: "Name", + schema: "Content", + table: "Creators", + type: "character varying(255)", + maxLength: 255, + nullable: false, + defaultValue: ""); + + 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); + } + } +} diff --git a/backend/src/Web/Features/Contents/Data/Migrations/ContentDbContextModelSnapshot.cs b/backend/src/Web/Features/Contents/Data/Migrations/ContentDbContextModelSnapshot.cs index 1beecf0..c20bd59 100644 --- a/backend/src/Web/Features/Contents/Data/Migrations/ContentDbContextModelSnapshot.cs +++ b/backend/src/Web/Features/Contents/Data/Migrations/ContentDbContextModelSnapshot.cs @@ -86,17 +86,8 @@ namespace Hutopy.Web.Features.Contents.Data.Migrations 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("SlugsId") + .HasColumnType("uuid"); b.Property("Title") .HasMaxLength(255) @@ -107,10 +98,47 @@ namespace Hutopy.Web.Features.Contents.Data.Migrations b.HasKey("Id"); + b.HasIndex("SlugsId"); + + b.ToTable("Creators", "Content"); + }); + + modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Slugs", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Active") + .HasColumnType("boolean"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("NormalizedName") + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComputedColumnSql("LOWER( \"Content\".\"Slugs\".\"Name\")", true); + + b.Property("ReservedUntil") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + b.HasIndex("NormalizedName") .IsUnique(); - b.ToTable("Creators", "Content"); + b.ToTable("Slugs", "Content"); }); modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Content", b => @@ -158,6 +186,12 @@ namespace Hutopy.Web.Features.Contents.Data.Migrations modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Creator", b => { + b.HasOne("Hutopy.Web.Features.Contents.Data.Slugs", "Slugs") + .WithMany() + .HasForeignKey("SlugsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + b.OwnsOne("Hutopy.Web.Features.Contents.Data.Colors", "Colors", b1 => { b1.Property("CreatorId") @@ -394,6 +428,8 @@ namespace Hutopy.Web.Features.Contents.Data.Migrations b.Navigation("PresentationInfos") .IsRequired(); + b.Navigation("Slugs"); + b.Navigation("Socials") .IsRequired(); }); diff --git a/backend/src/Web/Features/Contents/Data/Slugs.cs b/backend/src/Web/Features/Contents/Data/Slugs.cs new file mode 100644 index 0000000..8925dcf --- /dev/null +++ b/backend/src/Web/Features/Contents/Data/Slugs.cs @@ -0,0 +1,14 @@ +using System.ComponentModel.DataAnnotations; + +namespace Hutopy.Web.Features.Contents.Data; + +public class Slugs +{ + public Guid Id { get; set; } + public Guid CreatedBy { get; set; } + public DateTimeOffset CreatedAt { get; init; } + [MaxLength(128)] public string Name { get; set; } = null!; + [MaxLength(128)] public string NormalizedName { get; set; } = null!; + public DateTimeOffset ReservedUntil { get; set; } + public bool Active { get; set; } +} diff --git a/backend/src/Web/Features/Contents/DependencyInjection.cs b/backend/src/Web/Features/Contents/DependencyInjection.cs index c92c791..6caec7d 100644 --- a/backend/src/Web/Features/Contents/DependencyInjection.cs +++ b/backend/src/Web/Features/Contents/DependencyInjection.cs @@ -1,4 +1,5 @@ using Hutopy.Web.Features.Contents.Data; +using Hutopy.Web.Features.Contents.Handlers; namespace Hutopy.Web.Features.Contents; @@ -10,6 +11,7 @@ public static class DependencyInjection { builder.Services.AddDbContext(configureAction); builder.Services.AddScoped(); + builder.Services.Configure(builder.Configuration.GetSection(ContentOptions.ConfigurationSection)); return builder; } diff --git a/backend/src/Web/Features/Contents/Handlers/ContentOptions.cs b/backend/src/Web/Features/Contents/Handlers/ContentOptions.cs new file mode 100644 index 0000000..bf43610 --- /dev/null +++ b/backend/src/Web/Features/Contents/Handlers/ContentOptions.cs @@ -0,0 +1,8 @@ +namespace Hutopy.Web.Features.Contents.Handlers; + +public class ContentOptions +{ + public const string ConfigurationSection = "Contents"; + + public TimeSpan SlugReservationDuration { get; set; } +} diff --git a/backend/src/Web/Features/Contents/Handlers/CreateContentFromHtml.cs b/backend/src/Web/Features/Contents/Handlers/CreateContentFromHtml.cs index f07ccc2..5b93bd3 100644 --- a/backend/src/Web/Features/Contents/Handlers/CreateContentFromHtml.cs +++ b/backend/src/Web/Features/Contents/Handlers/CreateContentFromHtml.cs @@ -67,7 +67,7 @@ public sealed class PostContentHtml( { Id = c.Id, CreatedBy = c.CreatedBy, - CreatedByName = c.Creator!.Name, + CreatedByName = c.Creator!.Slugs.Name, CreatedByPortraitUrl = c.Creator.Images.Logo, CreatedAt = c.CreatedAt, DeletedBy = c.DeletedBy, diff --git a/backend/src/Web/Features/Contents/Handlers/CreateCreator.cs b/backend/src/Web/Features/Contents/Handlers/CreateCreator.cs index cf64b2c..e760844 100644 --- a/backend/src/Web/Features/Contents/Handlers/CreateCreator.cs +++ b/backend/src/Web/Features/Contents/Handlers/CreateCreator.cs @@ -1,28 +1,27 @@ -using System.Net; -using FluentValidation.Results; -using Hutopy.Web.Common.Security; +using Hutopy.Web.Common.Security; using Hutopy.Web.Features.Contents.Data; -using Npgsql; namespace Hutopy.Web.Features.Contents.Handlers; [PublicAPI] public record CreateCreatorRequest( - Guid CreatorId, - string Name); + Guid SlugReservationId, + Guid CreatorId); [UsedImplicitly] public sealed class CreateCreatorRequestValidator : Validator { public CreateCreatorRequestValidator() { + RuleFor(r => r.SlugReservationId) + .NotNull() + .NotEmpty() + .WithMessage("You should specify a valid Name"); + RuleFor(r => r.CreatorId) - .NotNull().WithMessage("You should specify the CreatorId") - .NotEmpty().WithMessage("You should specify a valid/not empty CreatorId"); - - RuleFor(r => r.Name) - .NotNull().WithMessage("You should specify the Name") - .NotEmpty().WithMessage("You should specify a valid/not empty Name"); + .NotNull() + .NotEmpty() + .WithMessage("You should specify a valid CreatorId"); } } @@ -41,14 +40,30 @@ public sealed class CreateCreatorHandler( CreateCreatorRequest req, CancellationToken ct) { + await using var transaction = await context.Database.BeginTransactionAsync(ct); + try { + var slug = await context + .Slugs + .SingleAsync(s => s.Id == req.SlugReservationId, ct); + + if (slug.Active == false + && slug.ReservedUntil >= DateTime.Now + && slug.CreatedBy == User.GetUserId()) + { + await SendErrorsAsync(500, ct); + return; + } + + slug.Active = true; + await context.Creators.AddAsync( new Creator { Id = req.CreatorId, CreatedBy = User.GetUserId(), - Name = req.Name, + Slugs = slug, Colors = { Primary = "#A30E79", @@ -67,25 +82,13 @@ public sealed class CreateCreatorHandler( await context.SaveChangesAsync(ct); + await transaction.CommitAsync(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)); - } - } - else - { - await SendResultAsync(new ProblemDetails( - [new ValidationFailure(nameof(Creator.Name), e.Message)], - (int)HttpStatusCode.Conflict)); - } + await transaction.RollbackAsync(ct); } } } diff --git a/backend/src/Web/Features/Contents/Handlers/GetContent.cs b/backend/src/Web/Features/Contents/Handlers/GetContent.cs index 7ff4ea7..9479008 100644 --- a/backend/src/Web/Features/Contents/Handlers/GetContent.cs +++ b/backend/src/Web/Features/Contents/Handlers/GetContent.cs @@ -32,7 +32,7 @@ public class GetContent( { Id = c.Id, CreatedBy = c.CreatedBy, - CreatedByName = c.Creator!.Name, + CreatedByName = c.Creator!.Slugs.Name, CreatedByPortraitUrl = c.Creator.Images.Logo, CreatedAt = c.CreatedAt, DeletedBy = c.DeletedBy, diff --git a/backend/src/Web/Features/Contents/Handlers/GetContentsByCreator.cs b/backend/src/Web/Features/Contents/Handlers/GetContentsByCreator.cs index e502e8e..79dfc8f 100644 --- a/backend/src/Web/Features/Contents/Handlers/GetContentsByCreator.cs +++ b/backend/src/Web/Features/Contents/Handlers/GetContentsByCreator.cs @@ -43,7 +43,7 @@ public class GetContentsByCreatorHandler( { Id = c.Id, CreatedBy = c.CreatedBy, - CreatedByName = c.Creator!.Name, + CreatedByName = c.Creator!.Slugs.Name, CreatedByPortraitUrl = c.Creator.Images.Logo, CreatedAt = c.CreatedAt, DeletedBy = c.DeletedBy, diff --git a/backend/src/Web/Features/Contents/Handlers/GetCreatorByAlias.cs b/backend/src/Web/Features/Contents/Handlers/GetCreatorByAlias.cs index 75e0dbc..41c8732 100644 --- a/backend/src/Web/Features/Contents/Handlers/GetCreatorByAlias.cs +++ b/backend/src/Web/Features/Contents/Handlers/GetCreatorByAlias.cs @@ -1,5 +1,4 @@ using Hutopy.Web.Features.Contents.Data; -using Hutopy.Web.Features.Contents.Handlers.Models; namespace Hutopy.Web.Features.Contents.Handlers; @@ -10,18 +9,31 @@ public sealed class GetCreatorByAliasRequest } [PublicAPI] -public record struct GetCreatorByAliasResponse( - Guid Id, - Guid CreatedBy, - DateTimeOffset CreatedAt, - bool Verified, - bool AcceptDonation, - string Name, - string? Title, - Socials Socials, - Colors Colors, - PresentationInfos PresentationInfos, - Images Images); +public class GetCreatorByAliasResponse( + Guid id, + Guid createdBy, + DateTimeOffset createdAt, + bool verified, + bool acceptDonation, + string name, + string? title, + Socials socials, + Colors colors, + PresentationInfos presentationInfos, + Images images) +{ + public Guid Id { get; } = id; + public Guid CreatedBy { get; } = createdBy; + public DateTimeOffset CreatedAt { get; } = createdAt; + public bool Verified { get; } = verified; + public bool AcceptDonation { get; } = acceptDonation; + public string Name { get; } = name; + public string? Title { get; } = title; + public Socials Socials { get; } = socials; + public Colors Colors { get; } = colors; + public PresentationInfos PresentationInfos { get; } = presentationInfos; + public Images Images { get; } = images; +} [UsedImplicitly] public sealed class GetCreatorByAliasRequestValidator @@ -55,8 +67,22 @@ public class GetCreatorByAliasHandler( var creator = await context .Creators - .Where(c => EF.Functions.ILike(c.Name, creatorName)) - .FirstOrDefaultAsync(ct); + .Where(c => EF.Functions.ILike(c.Slugs.Name, creatorName)) + .AsNoTracking() + .Select(c => new GetCreatorByAliasResponse + ( + c.Id, + c.CreatedBy, + c.CreatedAt, + c.Verified, + c.AcceptDonation, + c.Slugs.NormalizedName, + c.Title, + c.Socials, + c.Colors, + c.PresentationInfos, + c.Images)) + .SingleOrDefaultAsync(ct); if (creator is null) { @@ -64,20 +90,7 @@ public class GetCreatorByAliasHandler( } else { - var model = new GetCreatorByAliasResponse( - creator.Id, - creator.CreatedBy, - creator.CreatedAt, - creator.Verified, - creator.AcceptDonation, - creator.Name, - creator.Title, - creator.Socials, - creator.Colors, - creator.PresentationInfos, - creator.Images); - - await SendAsync(model, cancellation: ct); + await SendAsync(creator, cancellation: ct); } } } diff --git a/backend/src/Web/Features/Contents/Handlers/GetCreatorProfile.cs b/backend/src/Web/Features/Contents/Handlers/GetCreatorProfile.cs index cb27ca4..177ebf9 100644 --- a/backend/src/Web/Features/Contents/Handlers/GetCreatorProfile.cs +++ b/backend/src/Web/Features/Contents/Handlers/GetCreatorProfile.cs @@ -4,10 +4,26 @@ using Hutopy.Web.Features.Contents.Data; namespace Hutopy.Web.Features.Contents.Handlers; +[PublicAPI] +public sealed class GetCreatorProfileResponse +{ + public Guid Id { get; set; } + public Guid CreatedBy { get; set; } + public DateTimeOffset CreatedAt { get; set; } + public string Title { get; set; } + public string Name { get; set; } + public bool Verified { get; set; } + public bool AcceptDonation { get; set; } + public Colors Colors { get; set; } + public Images Images { get; set; } + public PresentationInfos PresentationInfos { get; set; } + public Socials Socials { get; set; } +} + [PublicAPI] public class GetCreatorProfileHandler( ContentDbContext context) - : EndpointWithoutRequest + : EndpointWithoutRequest { public override void Configure() { @@ -21,9 +37,23 @@ public class GetCreatorProfileHandler( { var creator = await context .Creators - .FindAsync( - [HttpContext.User.GetUserId()], - cancellationToken: ct); + .Where(c => c.Id == HttpContext.User.GetUserId()) + .AsNoTracking() + .Select(c => new GetCreatorProfileResponse + { + Id = c.Id, + CreatedBy = c.CreatedBy, + CreatedAt = c.CreatedAt, + Title = c.Title, + Name = c.Slugs.NormalizedName, + Verified = c.Verified, + AcceptDonation = c.AcceptDonation, + Colors = c.Colors, + Images = c.Images, + PresentationInfos = c.PresentationInfos, + Socials = c.Socials, + }) + .SingleOrDefaultAsync(ct); if (creator is null) await SendNotFoundAsync(ct); else await SendAsync(creator, cancellation: ct); diff --git a/backend/src/Web/Features/Contents/Handlers/GetFeaturedContents.cs b/backend/src/Web/Features/Contents/Handlers/GetFeaturedContents.cs index 0a32f88..9b0e915 100644 --- a/backend/src/Web/Features/Contents/Handlers/GetFeaturedContents.cs +++ b/backend/src/Web/Features/Contents/Handlers/GetFeaturedContents.cs @@ -42,7 +42,7 @@ public class GetFeaturedContentsHandler( { Id = c.Id, CreatedBy = c.CreatedBy, - CreatedByName = c.Creator!.Name, + CreatedByName = c.Creator!.Slugs.Name, CreatedByPortraitUrl = c.Creator.Images.Logo, CreatedAt = c.CreatedAt, DeletedBy = c.DeletedBy, diff --git a/backend/src/Web/Features/Contents/Handlers/ReserveSlug.cs b/backend/src/Web/Features/Contents/Handlers/ReserveSlug.cs new file mode 100644 index 0000000..7bad1d4 --- /dev/null +++ b/backend/src/Web/Features/Contents/Handlers/ReserveSlug.cs @@ -0,0 +1,95 @@ +using System.Net; +using FluentValidation.Results; +using Hutopy.Web.Common.Security; +using Hutopy.Web.Features.Contents.Data; +using Microsoft.Extensions.Options; +using Npgsql; + +namespace Hutopy.Web.Features.Contents.Handlers; + +[PublicAPI] +public record ReserveSlugRequest +{ + public string Slug { get; set; } = null!; + public required Guid ReservationId { get; set; } +} + +[PublicAPI] +public sealed class ReserveSlugRequestValidator : Validator +{ + public ReserveSlugRequestValidator() + { + RuleFor(r => r.Slug) + .NotEmpty() + .NotNull() + .WithMessage("You should specify a valid Slug"); + } +} + +[PublicAPI] +public sealed class ReserveSlug( + ContentDbContext context, + IOptions opts) + : Endpoint +{ + public override void Configure() + { + Post("/api/creators/@{Slug}/reserve"); + Options(o => o.WithTags("Contents")); + } + + public override async Task HandleAsync( + ReserveSlugRequest req, + CancellationToken ct) + { + await using var transaction = await context.Database.BeginTransactionAsync(ct); + + try + { + await context.Slugs.AddAsync( + new Slugs + { + Id = req.ReservationId, + Active = false, + Name = req.Slug, + ReservedUntil = DateTimeOffset.UtcNow + opts.Value.SlugReservationDuration, + CreatedBy = User.GetUserId(), + }, + cancellationToken: ct); + + await context.SaveChangesAsync(ct); + + await transaction.CommitAsync(ct); + + await SendOkAsync(new { Message = "Slug reserved." }, ct); + } + catch (Exception e) + { + await transaction.RollbackAsync(ct); + + Logger.LogError("Transaction failed: {Message}", e.Message); + + if (e.InnerException is PostgresException innerException) + { + if (innerException.ConstraintName == "IX_Slugs_NormalizedName") + { + await SendResultAsync(new ProblemDetails( + [ + new ValidationFailure(nameof(Slugs.Name), + "The name is already taken.") + ], + (int)HttpStatusCode.Conflict)); + } + } + else + { + await SendResultAsync(new ProblemDetails( + [ + new ValidationFailure(nameof(Slugs.Name), + e.Message) + ], + (int)HttpStatusCode.Conflict)); + } + } + } +} diff --git a/backend/src/Web/appsettings.json b/backend/src/Web/appsettings.json index edd8c66..3ee0362 100644 --- a/backend/src/Web/appsettings.json +++ b/backend/src/Web/appsettings.json @@ -12,5 +12,8 @@ "Jwt": { "Lifetime": "00:30:00" } + }, + "Contents": { + "SlugReservationDuration": "00:05:00" } } \ No newline at end of file diff --git a/frontend/src/router/router.js b/frontend/src/router/router.js index f119a24..ec25bb3 100644 --- a/frontend/src/router/router.js +++ b/frontend/src/router/router.js @@ -25,7 +25,7 @@ import LoginView from '../views/LoginView.vue'; import PaymentCompleted from '../views/PaymentCompleted.vue'; import Home from '../views/main/Home.vue'; import Wallet from '../views/main/Wallet.vue'; -import CreateCreator from "@/views/profile/creators/CreateCreator.vue"; +import CreateCreator from "@/views/creators/CreateCreator.vue"; const routes = [ { diff --git a/frontend/src/stores/authStore.js b/frontend/src/stores/authStore.js index 3da2d35..06a414b 100644 --- a/frontend/src/stores/authStore.js +++ b/frontend/src/stores/authStore.js @@ -24,6 +24,7 @@ export const useAuthStore = defineStore( const refreshToken = useSessionStorage('auth-refreshToken', undefined) const isAuthenticated = computed(() => !!accessToken.value) + const userId = computed(() => { const claims = getClaimsFromToken(accessToken.value) return claims.sub; @@ -98,5 +99,4 @@ export const useAuthStore = defineStore( } return {accessToken, refreshToken, isAuthenticated, userId, login, loginWithGoogle, logout} - }) - + }) \ No newline at end of file diff --git a/frontend/src/stores/brandingStore.js b/frontend/src/stores/brandingStore.js index f929963..c4e2344 100644 --- a/frontend/src/stores/brandingStore.js +++ b/frontend/src/stores/brandingStore.js @@ -41,8 +41,8 @@ export const useBrandingStore = defineStore( if (newCreator !== undefined) { value.value = await fetchCreatorData(newCreator) currentBrand.value = newCreator - colors.value = value.value.colors - presentationInfos.value = value.value.presentationInfos + colors.value = value.value?.colors + presentationInfos.value = value.value?.presentationInfos } else { value.value = {} currentBrand.value = undefined diff --git a/frontend/src/stores/creatorProfileStore.js b/frontend/src/stores/creatorProfileStore.js index 556a141..58bb560 100644 --- a/frontend/src/stores/creatorProfileStore.js +++ b/frontend/src/stores/creatorProfileStore.js @@ -1,67 +1,70 @@ -import { useClient } from '@/plugins/api.js'; -import { useAuthStore } from '@/stores/authStore.js'; -import { useSessionStorage } from '@vueuse/core'; -import { defineStore } from 'pinia'; -import { computed, watch } from 'vue'; -import { useRouter } from 'vue-router'; +import {useClient} from '@/plugins/api.js'; +import {useAuthStore} from '@/stores/authStore.js'; +import {useSessionStorage} from '@vueuse/core'; +import {defineStore} from 'pinia'; +import {computed, watch} from 'vue'; +import {useRouter} from 'vue-router'; -export const useCreatorProfileStore = defineStore('creator-profile', () => { - const router = useRouter(); - - const authStore = useAuthStore(); - - watch( - () => authStore.isAuthenticated, - async (newValue) => { - if (newValue) { - await fetchCurrentCreatorProfile(); - - if (value.value === undefined) { - await router.push('/'); - } else { - await router.push(`/@${value.value.name}`); - } - } else { - value.value = undefined; - } - } - ); - - const value = useSessionStorage( +export const useCreatorProfileStore = defineStore( 'creator-profile', - undefined, - { writeDefaults: false } - ); + () => { + const router = useRouter(); + const authStore = useAuthStore(); + watch( + () => authStore.isAuthenticated, + async (newValue) => { + if (newValue) { + await fetchCurrentCreatorProfile(); + if (value.value === undefined) { + await router.push('/'); + } else { + await router.push(`/@${value.value.name}`); + } + } else { + value.value = undefined; + } + } + ); - const hasCreator = computed( - () => value.value && Object.getOwnPropertyNames(value.value).length >= 1 - ); + const value = useSessionStorage( + 'creator-profile', + {}, + {writeDefaults: false} + ); - const client = useClient(); + const hasCreator = computed( + () => value.value && Object.getOwnPropertyNames(value.value).length >= 1 + ); - async function fetchCurrentCreatorProfile() { - try { - const creatorResponse = await client.get(`/api/creators/profile`); - value.value = creatorResponse.data; - // TODO: no cache-busting ??? - } catch (error) { - value.value = undefined; - } - } + const client = useClient(); - async function ConfigureStripeAccount() { - try { - await client.post(`/api/membership/stripe-account`); - return true; - } catch (error) { - return false; - } - } + async function fetchCurrentCreatorProfile() { + try { + const creatorResponse = await client.get(`/api/creators/profile`); + console.log('creatorProfile'); + console.dir(creatorResponse.data) + value.value = creatorResponse.data; + console.dir(value.value); + // TODO: no cache-busting ??? + } catch (error) { + console.log(`!!!`) + value.value = undefined; + } + } - return { - creator: value, - hasCreator, - fetchCurrentCreatorProfile, - ConfigureStripeAccount, - }; -}); + async function ConfigureStripeAccount() { + try { + await client.post(`/api/membership/stripe-account`); + return true; + } catch (error) { + return false; + } + } + + return { + creator: value, + hasCreator, + fetchCurrentCreatorProfile, + ConfigureStripeAccount, + }; + }); diff --git a/frontend/src/views/creators/ActualBanner.vue b/frontend/src/views/creators/ActualBanner.vue new file mode 100644 index 0000000..9aed883 --- /dev/null +++ b/frontend/src/views/creators/ActualBanner.vue @@ -0,0 +1,65 @@ + + + \ No newline at end of file diff --git a/frontend/src/views/creators/Banner.vue b/frontend/src/views/creators/Banner.vue new file mode 100644 index 0000000..9a9cd2b --- /dev/null +++ b/frontend/src/views/creators/Banner.vue @@ -0,0 +1,25 @@ + + + diff --git a/frontend/src/views/creators/BannerActions.vue b/frontend/src/views/creators/BannerActions.vue index 87dca6f..5d72b41 100644 --- a/frontend/src/views/creators/BannerActions.vue +++ b/frontend/src/views/creators/BannerActions.vue @@ -1,9 +1,10 @@  \ No newline at end of file diff --git a/frontend/src/views/creators/CreatorLayout.vue b/frontend/src/views/creators/CreatorLayout.vue index 3e9162f..a2ba926 100644 --- a/frontend/src/views/creators/CreatorLayout.vue +++ b/frontend/src/views/creators/CreatorLayout.vue @@ -5,7 +5,7 @@
- +
@@ -16,11 +16,11 @@
- + \ No newline at end of file diff --git a/frontend/src/views/profile/creators/LogoPicker.vue b/frontend/src/views/creators/CreatorLogoEditor.vue similarity index 96% rename from frontend/src/views/profile/creators/LogoPicker.vue rename to frontend/src/views/creators/CreatorLogoEditor.vue index 441443c..e44020d 100644 --- a/frontend/src/views/profile/creators/LogoPicker.vue +++ b/frontend/src/views/creators/CreatorLogoEditor.vue @@ -23,12 +23,13 @@ Annuler Enregistrer + + + + + \ No newline at end of file diff --git a/frontend/src/views/creators/NameTitle.vue b/frontend/src/views/creators/NameTitle.vue new file mode 100644 index 0000000..d8afbab --- /dev/null +++ b/frontend/src/views/creators/NameTitle.vue @@ -0,0 +1,79 @@ + + + \ No newline at end of file diff --git a/frontend/src/views/creators/NameTitleEditor.vue b/frontend/src/views/creators/NameTitleEditor.vue new file mode 100644 index 0000000..95c7fe4 --- /dev/null +++ b/frontend/src/views/creators/NameTitleEditor.vue @@ -0,0 +1,76 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/views/profile/account/AccountPage.vue b/frontend/src/views/profile/AccountPage.vue similarity index 59% rename from frontend/src/views/profile/account/AccountPage.vue rename to frontend/src/views/profile/AccountPage.vue index 20d3fd8..8c8dc76 100644 --- a/frontend/src/views/profile/account/AccountPage.vue +++ b/frontend/src/views/profile/AccountPage.vue @@ -1,95 +1,89 @@