Add 'backend/' from commit '040cfd7a75423d4e6136e58a67b40579af4ee966'

git-subtree-dir: backend
git-subtree-mainline: ab911955ed
git-subtree-split: 040cfd7a75
This commit is contained in:
2025-01-15 15:24:30 -05:00
179 changed files with 14349 additions and 0 deletions

View File

@@ -0,0 +1,21 @@
using System.ComponentModel.DataAnnotations;
namespace Hutopy.Web.Features.Contents.Data;
public class Content
{
public Guid Id { get; init; }
public Guid CreatedBy { get; init; }
public Creator? Creator { get; set; }
public DateTimeOffset CreatedAt { get; init; }
public Guid? DeletedBy { get; set; }
public DateTimeOffset? DeletedAt { get; set; }
[MaxLength(128)] public required string Title { get; set; }
[MaxLength(512)] public string? ThumbnailUrl { get; set; } = "";
[MaxLength(2048)] public string Description { get; set; } = "";
[MaxLength(2048)] public string? HtmlFileUrl { get; set; } = "";
public IList<ContentReaction> Reactions { get; set; } = new List<ContentReaction>();
public string[]? Urls { get; init; }
}

View File

@@ -0,0 +1,68 @@
namespace Hutopy.Web.Features.Contents.Data;
public class ContentDbContext(
DbContextOptions<ContentDbContext> options)
: DbContext(options)
{
public const string SchemaName = "Content";
public DbSet<Content> Contents => Set<Content>();
public DbSet<Creator> Creators => Set<Creator>();
protected override void OnModelCreating(
ModelBuilder modelBuilder)
{
modelBuilder.HasDefaultSchema(SchemaName);
modelBuilder
.Entity<Content>()
.Property(c => c.CreatedAt)
.ValueGeneratedOnAdd()
.HasDefaultValueSql("CURRENT_TIMESTAMP");
modelBuilder
.Entity<Content>()
.HasOne(c => c.Creator)
.WithMany()
.HasForeignKey(c => c.CreatedBy);
modelBuilder
.Entity<Content>()
.OwnsMany(c => c.Reactions)
.ToTable("Reactions");
modelBuilder
.Entity<Content>()
.Property(c => c.ThumbnailUrl);
modelBuilder
.Entity<Creator>()
.Property(x => x.NormalizedName)
.HasComputedColumnSql("LOWER( \"Content\".\"Creators\".\"Name\")", stored: true);
modelBuilder
.Entity<Creator>()
.HasIndex(x => x.NormalizedName)
.IsUnique();
modelBuilder
.Entity<Creator>()
.OwnsOne<Socials>(x => x.Socials)
.ToTable(nameof(Socials));
modelBuilder
.Entity<Creator>()
.OwnsOne<Colors>(x => x.Colors)
.ToTable(nameof(Colors));
modelBuilder
.Entity<Creator>()
.OwnsOne<Images>(x => x.Images)
.ToTable(nameof(Images));
modelBuilder
.Entity<Creator>()
.OwnsOne<PresentationInfos>(x => x.PresentationInfos)
.ToTable(nameof(PresentationInfos));
}
}

View File

@@ -0,0 +1,32 @@
namespace Hutopy.Web.Features.Contents.Data;
public static class InitializerExtensions
{
public static async Task InitialiseContentDbContextAsync(this WebApplication app)
{
using var scope = app.Services.CreateScope();
var initializer = scope.ServiceProvider.GetRequiredService<ContentDbContextInitializer>();
await initializer.InitialiseAsync();
}
}
public class ContentDbContextInitializer(
ILogger<ContentDbContextInitializer> logger,
ContentDbContext context
)
{
public async Task InitialiseAsync()
{
try
{
await context.Database.MigrateAsync();
}
catch (Exception ex)
{
logger.LogError(ex, "An error occurred while initialising the content database.");
throw;
}
}
}

View File

@@ -0,0 +1,11 @@
using System.ComponentModel.DataAnnotations;
using Hutopy.Web.Features.Contents.Data.Enums;
namespace Hutopy.Web.Features.Contents.Data;
public class ContentReaction
{
public required Reaction Reaction { get; set; }
public required Guid UserId { get; set; }
[MaxLength(128)] public required string UserName { get; set; }
}

View File

@@ -0,0 +1,72 @@
using System.ComponentModel.DataAnnotations;
namespace Hutopy.Web.Features.Contents.Data;
public class Creator
{
public Guid Id { get; set; }
public Guid CreatedBy { get; set; }
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!;
[MaxLength(255)] public string? Title { get; set; }
public Socials Socials { get; set; } = new();
public Colors Colors { get; set; } = new();
public Images Images { get; set; } = new();
public PresentationInfos PresentationInfos { get; set; } = new();
}
public class Colors
{
[MaxLength(9)] public string Primary { get; set; } = null!;
[MaxLength(9)] public string Secondary { get; set; } = null!;
[MaxLength(9)] public string Background { get; set; } = null!;
[MaxLength(9)] public string Surface { get; set; } = null!;
[MaxLength(9)] public string Error { get; set; } = null!;
[MaxLength(9)] public string OnPrimary { get; set; } = null!;
[MaxLength(9)] public string OnSecondary { get; set; } = null!;
[MaxLength(9)] public string OnBackground { get; set; } = null!;
[MaxLength(9)] public string OnSurface { get; set; } = null!;
[MaxLength(9)] public string OnError { get; set; } = null!;
}
public class Socials
{
[MaxLength(255)] public string? FacebookUrl { get; set; }
[MaxLength(255)] public string? InstagramUrl { get; set; }
[MaxLength(255)] public string? XUrl { get; set; }
[MaxLength(255)] public string? LinkedInUrl { get; set; }
[MaxLength(255)] public string? TikTokUrl { get; set; }
[MaxLength(255)] public string? YoutubeUrl { get; set; }
[MaxLength(255)] public string? RedditUrl { get; set; }
[MaxLength(255)] public string? WebsiteUrl { get; set; }
}
public class Images
{
[MaxLength(255)] public string? Banner { get; set; }
[MaxLength(255)] public string? Logo { get; set; }
}
public class PresentationInfos
{
[MaxLength(255)] public string PhoneNumber { get; set; } = string.Empty;
[MaxLength(255)] public string Email { get; set; } = string.Empty;
[MaxLength(2000)] public string Title { get; set; } = string.Empty;
[MaxLength(2000)] public string MainImageUrl { get; set; } = string.Empty;
[MaxLength(10000)] public string MainImageText { get; set; } = string.Empty;
[MaxLength(10000)] public string MainVideoText { get; set; } = string.Empty;
[MaxLength(2000)] public string ImagesSubtitle { get; set; } = string.Empty;
[MaxLength(2000)] public string Image1Url { get; set; } = string.Empty;
[MaxLength(2000)] public string Image2Url { get; set; } = string.Empty;
[MaxLength(2000)] public string Image3Url { get; set; } = string.Empty;
[MaxLength(2000)] public string Image4Url { get; set; } = string.Empty;
[MaxLength(10000)] public string ImagesText { get; set; } = string.Empty;
[MaxLength(2000)] public string VideoSubtitle { get; set; } = string.Empty;
[MaxLength(2000)] public string VideoSubtitleMain { get; set; } = string.Empty;
[MaxLength(2000)] public string VideoUrlMain { get; set; } = string.Empty;
[MaxLength(2000)] public string VideoUrl { get; set; } = string.Empty;
[MaxLength(10000)] public string VideoText { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,13 @@
namespace Hutopy.Web.Features.Contents.Data.Enums;
public enum Reaction
{
None = 0,
Like = 1,
Dislike = 2,
Love = 3,
Haha = 4,
Wow = 5,
Sad = 6,
Angry = 7
}

View File

@@ -0,0 +1,285 @@
// <auto-generated />
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("20241020202641_Initial")]
partial class Initial
{
/// <inheritdoc />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid>("CreatedBy")
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("DeletedBy")
.HasColumnType("uuid");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("HtmlFileUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string[]>("Urls")
.HasColumnType("text[]");
b.HasKey("Id");
b.HasIndex("CreatedBy");
b.ToTable("Contents", "Content");
});
modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Creator", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("CreatedBy")
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("Title")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.HasKey("Id");
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<Guid>("ContentId")
.HasColumnType("uuid");
b1.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property<int>("Id"));
b1.Property<int>("Reaction")
.HasColumnType("integer");
b1.Property<Guid>("UserId")
.HasColumnType("uuid");
b1.Property<string>("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<Guid>("CreatorId")
.HasColumnType("uuid");
b1.Property<string>("Background")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("Error")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("OnBackground")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("OnError")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("OnPrimary")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("OnSecondary")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("OnSurface")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("Primary")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("Secondary")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("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<Guid>("CreatorId")
.HasColumnType("uuid");
b1.Property<string>("Banner")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("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.Socials", "Socials", b1 =>
{
b1.Property<Guid>("CreatorId")
.HasColumnType("uuid");
b1.Property<string>("FacebookUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("InstagramUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("LinkedInUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("RedditUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("TikTokUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("WebsiteUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("XUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("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("Socials")
.IsRequired();
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,197 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Hutopy.Web.Features.Contents.Data.Migrations
{
/// <inheritdoc />
public partial class Initial : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.EnsureSchema(
name: "Content");
migrationBuilder.CreateTable(
name: "Creators",
schema: "Content",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
CreatedBy = table.Column<Guid>(type: "uuid", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
Name = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
Title = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Creators", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Colors",
schema: "Content",
columns: table => new
{
CreatorId = table.Column<Guid>(type: "uuid", nullable: false),
Primary = table.Column<string>(type: "character varying(9)", maxLength: 9, nullable: false),
Secondary = table.Column<string>(type: "character varying(9)", maxLength: 9, nullable: false),
Background = table.Column<string>(type: "character varying(9)", maxLength: 9, nullable: false),
Surface = table.Column<string>(type: "character varying(9)", maxLength: 9, nullable: false),
Error = table.Column<string>(type: "character varying(9)", maxLength: 9, nullable: false),
OnPrimary = table.Column<string>(type: "character varying(9)", maxLength: 9, nullable: false),
OnSecondary = table.Column<string>(type: "character varying(9)", maxLength: 9, nullable: false),
OnBackground = table.Column<string>(type: "character varying(9)", maxLength: 9, nullable: false),
OnSurface = table.Column<string>(type: "character varying(9)", maxLength: 9, nullable: false),
OnError = table.Column<string>(type: "character varying(9)", maxLength: 9, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Colors", x => x.CreatorId);
table.ForeignKey(
name: "FK_Colors_Creators_CreatorId",
column: x => x.CreatorId,
principalSchema: "Content",
principalTable: "Creators",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Contents",
schema: "Content",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
CreatedBy = table.Column<Guid>(type: "uuid", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"),
DeletedBy = table.Column<Guid>(type: "uuid", nullable: true),
DeletedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
Title = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
Description = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: false),
HtmlFileUrl = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
Urls = table.Column<string[]>(type: "text[]", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Contents", x => x.Id);
table.ForeignKey(
name: "FK_Contents_Creators_CreatedBy",
column: x => x.CreatedBy,
principalSchema: "Content",
principalTable: "Creators",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Images",
schema: "Content",
columns: table => new
{
CreatorId = table.Column<Guid>(type: "uuid", nullable: false),
Banner = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: true),
Logo = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Images", x => x.CreatorId);
table.ForeignKey(
name: "FK_Images_Creators_CreatorId",
column: x => x.CreatorId,
principalSchema: "Content",
principalTable: "Creators",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Socials",
schema: "Content",
columns: table => new
{
CreatorId = table.Column<Guid>(type: "uuid", nullable: false),
FacebookUrl = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: true),
InstagramUrl = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: true),
XUrl = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: true),
LinkedInUrl = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: true),
TikTokUrl = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: true),
YoutubeUrl = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: true),
RedditUrl = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: true),
WebsiteUrl = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Socials", x => x.CreatorId);
table.ForeignKey(
name: "FK_Socials_Creators_CreatorId",
column: x => x.CreatorId,
principalSchema: "Content",
principalTable: "Creators",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Reactions",
schema: "Content",
columns: table => new
{
ContentId = table.Column<Guid>(type: "uuid", nullable: false),
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Reaction = table.Column<int>(type: "integer", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
UserName = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Reactions", x => new { x.ContentId, x.Id });
table.ForeignKey(
name: "FK_Reactions_Contents_ContentId",
column: x => x.ContentId,
principalSchema: "Content",
principalTable: "Contents",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_Contents_CreatedBy",
schema: "Content",
table: "Contents",
column: "CreatedBy");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Colors",
schema: "Content");
migrationBuilder.DropTable(
name: "Images",
schema: "Content");
migrationBuilder.DropTable(
name: "Reactions",
schema: "Content");
migrationBuilder.DropTable(
name: "Socials",
schema: "Content");
migrationBuilder.DropTable(
name: "Contents",
schema: "Content");
migrationBuilder.DropTable(
name: "Creators",
schema: "Content");
}
}
}

View File

@@ -0,0 +1,289 @@
// <auto-generated />
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("20241201173048_AddThumbnailUrl")]
partial class AddThumbnailUrl
{
/// <inheritdoc />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid>("CreatedBy")
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("DeletedBy")
.HasColumnType("uuid");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("HtmlFileUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("ThumbnailUrl")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string[]>("Urls")
.HasColumnType("text[]");
b.HasKey("Id");
b.HasIndex("CreatedBy");
b.ToTable("Contents", "Content");
});
modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Creator", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("CreatedBy")
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("Title")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.HasKey("Id");
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<Guid>("ContentId")
.HasColumnType("uuid");
b1.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property<int>("Id"));
b1.Property<int>("Reaction")
.HasColumnType("integer");
b1.Property<Guid>("UserId")
.HasColumnType("uuid");
b1.Property<string>("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<Guid>("CreatorId")
.HasColumnType("uuid");
b1.Property<string>("Background")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("Error")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("OnBackground")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("OnError")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("OnPrimary")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("OnSecondary")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("OnSurface")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("Primary")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("Secondary")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("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<Guid>("CreatorId")
.HasColumnType("uuid");
b1.Property<string>("Banner")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("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.Socials", "Socials", b1 =>
{
b1.Property<Guid>("CreatorId")
.HasColumnType("uuid");
b1.Property<string>("FacebookUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("InstagramUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("LinkedInUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("RedditUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("TikTokUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("WebsiteUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("XUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("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("Socials")
.IsRequired();
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,31 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Hutopy.Web.Features.Contents.Data.Migrations
{
/// <inheritdoc />
public partial class AddThumbnailUrl : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "ThumbnailUrl",
schema: "Content",
table: "Contents",
type: "character varying(512)",
maxLength: 512,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ThumbnailUrl",
schema: "Content",
table: "Contents");
}
}
}

View File

@@ -0,0 +1,390 @@
// <auto-generated />
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("20241201182352_AddPresentationInfos")]
partial class AddPresentationInfos
{
/// <inheritdoc />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid>("CreatedBy")
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("DeletedBy")
.HasColumnType("uuid");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("HtmlFileUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("ThumbnailUrl")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string[]>("Urls")
.HasColumnType("text[]");
b.HasKey("Id");
b.HasIndex("CreatedBy");
b.ToTable("Contents", "Content");
});
modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Creator", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("CreatedBy")
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("Title")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.HasKey("Id");
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<Guid>("ContentId")
.HasColumnType("uuid");
b1.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property<int>("Id"));
b1.Property<int>("Reaction")
.HasColumnType("integer");
b1.Property<Guid>("UserId")
.HasColumnType("uuid");
b1.Property<string>("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<Guid>("CreatorId")
.HasColumnType("uuid");
b1.Property<string>("Background")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("Error")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("OnBackground")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("OnError")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("OnPrimary")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("OnSecondary")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("OnSurface")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("Primary")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("Secondary")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("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<Guid>("CreatorId")
.HasColumnType("uuid");
b1.Property<string>("Banner")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("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<Guid>("CreatorId")
.HasColumnType("uuid");
b1.Property<string>("Email")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("Image1Url")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("Image2Url")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("Image3Url")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("Image4Url")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("ImagesSubtitle")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("ImagesText")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("MainImageText")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("MainImageUrl")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("MainVideoText")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("PhoneNumber")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("Title")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("VideoSubtitle")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("VideoSubtitleMain")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("VideoText")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("VideoUrl")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("VideoUrlMain")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.HasKey("CreatorId");
b1.ToTable("PresentationInfos", "Content");
b1.WithOwner()
.HasForeignKey("CreatorId");
});
b.OwnsOne("Hutopy.Web.Features.Contents.Data.Socials", "Socials", b1 =>
{
b1.Property<Guid>("CreatorId")
.HasColumnType("uuid");
b1.Property<string>("FacebookUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("InstagramUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("LinkedInUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("RedditUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("TikTokUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("WebsiteUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("XUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("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
}
}
}

View File

@@ -0,0 +1,59 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Hutopy.Web.Features.Contents.Data.Migrations
{
/// <inheritdoc />
public partial class AddPresentationInfos : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "PresentationInfos",
schema: "Content",
columns: table => new
{
CreatorId = table.Column<Guid>(type: "uuid", nullable: false),
PhoneNumber = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
Email = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
Title = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
MainImageUrl = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
MainImageText = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
MainVideoText = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
ImagesSubtitle = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
Image1Url = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
Image2Url = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
Image3Url = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
Image4Url = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
ImagesText = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
VideoSubtitle = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
VideoSubtitleMain = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
VideoUrlMain = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
VideoUrl = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
VideoText = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_PresentationInfos", x => x.CreatorId);
table.ForeignKey(
name: "FK_PresentationInfos_Creators_CreatorId",
column: x => x.CreatorId,
principalSchema: "Content",
principalTable: "Creators",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "PresentationInfos",
schema: "Content");
}
}
}

View File

@@ -0,0 +1,390 @@
// <auto-generated />
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("20241202131957_LongerStringPresentationInfos")]
partial class LongerStringPresentationInfos
{
/// <inheritdoc />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid>("CreatedBy")
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("DeletedBy")
.HasColumnType("uuid");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("HtmlFileUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("ThumbnailUrl")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string[]>("Urls")
.HasColumnType("text[]");
b.HasKey("Id");
b.HasIndex("CreatedBy");
b.ToTable("Contents", "Content");
});
modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Creator", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("CreatedBy")
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("Title")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.HasKey("Id");
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<Guid>("ContentId")
.HasColumnType("uuid");
b1.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property<int>("Id"));
b1.Property<int>("Reaction")
.HasColumnType("integer");
b1.Property<Guid>("UserId")
.HasColumnType("uuid");
b1.Property<string>("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<Guid>("CreatorId")
.HasColumnType("uuid");
b1.Property<string>("Background")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("Error")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("OnBackground")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("OnError")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("OnPrimary")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("OnSecondary")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("OnSurface")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("Primary")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("Secondary")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("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<Guid>("CreatorId")
.HasColumnType("uuid");
b1.Property<string>("Banner")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("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<Guid>("CreatorId")
.HasColumnType("uuid");
b1.Property<string>("Email")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("Image1Url")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("Image2Url")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("Image3Url")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("Image4Url")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("ImagesSubtitle")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("ImagesText")
.IsRequired()
.HasMaxLength(10000)
.HasColumnType("character varying(10000)");
b1.Property<string>("MainImageText")
.IsRequired()
.HasMaxLength(10000)
.HasColumnType("character varying(10000)");
b1.Property<string>("MainImageUrl")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("MainVideoText")
.IsRequired()
.HasMaxLength(10000)
.HasColumnType("character varying(10000)");
b1.Property<string>("PhoneNumber")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("Title")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("VideoSubtitle")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("VideoSubtitleMain")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("VideoText")
.IsRequired()
.HasMaxLength(10000)
.HasColumnType("character varying(10000)");
b1.Property<string>("VideoUrl")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("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<Guid>("CreatorId")
.HasColumnType("uuid");
b1.Property<string>("FacebookUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("InstagramUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("LinkedInUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("RedditUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("TikTokUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("WebsiteUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("XUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("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
}
}
}

View File

@@ -0,0 +1,348 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Hutopy.Web.Features.Contents.Data.Migrations
{
/// <inheritdoc />
public partial class LongerStringPresentationInfos : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "VideoUrlMain",
schema: "Content",
table: "PresentationInfos",
type: "character varying(2000)",
maxLength: 2000,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(255)",
oldMaxLength: 255);
migrationBuilder.AlterColumn<string>(
name: "VideoUrl",
schema: "Content",
table: "PresentationInfos",
type: "character varying(2000)",
maxLength: 2000,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(255)",
oldMaxLength: 255);
migrationBuilder.AlterColumn<string>(
name: "VideoText",
schema: "Content",
table: "PresentationInfos",
type: "character varying(10000)",
maxLength: 10000,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(255)",
oldMaxLength: 255);
migrationBuilder.AlterColumn<string>(
name: "VideoSubtitleMain",
schema: "Content",
table: "PresentationInfos",
type: "character varying(2000)",
maxLength: 2000,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(255)",
oldMaxLength: 255);
migrationBuilder.AlterColumn<string>(
name: "VideoSubtitle",
schema: "Content",
table: "PresentationInfos",
type: "character varying(2000)",
maxLength: 2000,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(255)",
oldMaxLength: 255);
migrationBuilder.AlterColumn<string>(
name: "Title",
schema: "Content",
table: "PresentationInfos",
type: "character varying(2000)",
maxLength: 2000,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(255)",
oldMaxLength: 255);
migrationBuilder.AlterColumn<string>(
name: "MainVideoText",
schema: "Content",
table: "PresentationInfos",
type: "character varying(10000)",
maxLength: 10000,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(255)",
oldMaxLength: 255);
migrationBuilder.AlterColumn<string>(
name: "MainImageUrl",
schema: "Content",
table: "PresentationInfos",
type: "character varying(2000)",
maxLength: 2000,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(255)",
oldMaxLength: 255);
migrationBuilder.AlterColumn<string>(
name: "MainImageText",
schema: "Content",
table: "PresentationInfos",
type: "character varying(10000)",
maxLength: 10000,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(255)",
oldMaxLength: 255);
migrationBuilder.AlterColumn<string>(
name: "ImagesText",
schema: "Content",
table: "PresentationInfos",
type: "character varying(10000)",
maxLength: 10000,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(255)",
oldMaxLength: 255);
migrationBuilder.AlterColumn<string>(
name: "ImagesSubtitle",
schema: "Content",
table: "PresentationInfos",
type: "character varying(2000)",
maxLength: 2000,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(255)",
oldMaxLength: 255);
migrationBuilder.AlterColumn<string>(
name: "Image4Url",
schema: "Content",
table: "PresentationInfos",
type: "character varying(2000)",
maxLength: 2000,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(255)",
oldMaxLength: 255);
migrationBuilder.AlterColumn<string>(
name: "Image3Url",
schema: "Content",
table: "PresentationInfos",
type: "character varying(2000)",
maxLength: 2000,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(255)",
oldMaxLength: 255);
migrationBuilder.AlterColumn<string>(
name: "Image2Url",
schema: "Content",
table: "PresentationInfos",
type: "character varying(2000)",
maxLength: 2000,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(255)",
oldMaxLength: 255);
migrationBuilder.AlterColumn<string>(
name: "Image1Url",
schema: "Content",
table: "PresentationInfos",
type: "character varying(2000)",
maxLength: 2000,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(255)",
oldMaxLength: 255);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "VideoUrlMain",
schema: "Content",
table: "PresentationInfos",
type: "character varying(255)",
maxLength: 255,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(2000)",
oldMaxLength: 2000);
migrationBuilder.AlterColumn<string>(
name: "VideoUrl",
schema: "Content",
table: "PresentationInfos",
type: "character varying(255)",
maxLength: 255,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(2000)",
oldMaxLength: 2000);
migrationBuilder.AlterColumn<string>(
name: "VideoText",
schema: "Content",
table: "PresentationInfos",
type: "character varying(255)",
maxLength: 255,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(10000)",
oldMaxLength: 10000);
migrationBuilder.AlterColumn<string>(
name: "VideoSubtitleMain",
schema: "Content",
table: "PresentationInfos",
type: "character varying(255)",
maxLength: 255,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(2000)",
oldMaxLength: 2000);
migrationBuilder.AlterColumn<string>(
name: "VideoSubtitle",
schema: "Content",
table: "PresentationInfos",
type: "character varying(255)",
maxLength: 255,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(2000)",
oldMaxLength: 2000);
migrationBuilder.AlterColumn<string>(
name: "Title",
schema: "Content",
table: "PresentationInfos",
type: "character varying(255)",
maxLength: 255,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(2000)",
oldMaxLength: 2000);
migrationBuilder.AlterColumn<string>(
name: "MainVideoText",
schema: "Content",
table: "PresentationInfos",
type: "character varying(255)",
maxLength: 255,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(10000)",
oldMaxLength: 10000);
migrationBuilder.AlterColumn<string>(
name: "MainImageUrl",
schema: "Content",
table: "PresentationInfos",
type: "character varying(255)",
maxLength: 255,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(2000)",
oldMaxLength: 2000);
migrationBuilder.AlterColumn<string>(
name: "MainImageText",
schema: "Content",
table: "PresentationInfos",
type: "character varying(255)",
maxLength: 255,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(10000)",
oldMaxLength: 10000);
migrationBuilder.AlterColumn<string>(
name: "ImagesText",
schema: "Content",
table: "PresentationInfos",
type: "character varying(255)",
maxLength: 255,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(10000)",
oldMaxLength: 10000);
migrationBuilder.AlterColumn<string>(
name: "ImagesSubtitle",
schema: "Content",
table: "PresentationInfos",
type: "character varying(255)",
maxLength: 255,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(2000)",
oldMaxLength: 2000);
migrationBuilder.AlterColumn<string>(
name: "Image4Url",
schema: "Content",
table: "PresentationInfos",
type: "character varying(255)",
maxLength: 255,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(2000)",
oldMaxLength: 2000);
migrationBuilder.AlterColumn<string>(
name: "Image3Url",
schema: "Content",
table: "PresentationInfos",
type: "character varying(255)",
maxLength: 255,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(2000)",
oldMaxLength: 2000);
migrationBuilder.AlterColumn<string>(
name: "Image2Url",
schema: "Content",
table: "PresentationInfos",
type: "character varying(255)",
maxLength: 255,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(2000)",
oldMaxLength: 2000);
migrationBuilder.AlterColumn<string>(
name: "Image1Url",
schema: "Content",
table: "PresentationInfos",
type: "character varying(255)",
maxLength: 255,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(2000)",
oldMaxLength: 2000);
}
}
}

View File

@@ -0,0 +1,400 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid>("CreatedBy")
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("DeletedBy")
.HasColumnType("uuid");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("HtmlFileUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("ThumbnailUrl")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string[]>("Urls")
.HasColumnType("text[]");
b.HasKey("Id");
b.HasIndex("CreatedBy");
b.ToTable("Contents", "Content");
});
modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Creator", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("CreatedBy")
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("NormalizedName")
.IsRequired()
.ValueGeneratedOnAddOrUpdate()
.HasMaxLength(255)
.HasColumnType("character varying(255)")
.HasComputedColumnSql("LOWER( \"Content\".\"Creators\".\"Name\")", true);
b.Property<string>("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<Guid>("ContentId")
.HasColumnType("uuid");
b1.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property<int>("Id"));
b1.Property<int>("Reaction")
.HasColumnType("integer");
b1.Property<Guid>("UserId")
.HasColumnType("uuid");
b1.Property<string>("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<Guid>("CreatorId")
.HasColumnType("uuid");
b1.Property<string>("Background")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("Error")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("OnBackground")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("OnError")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("OnPrimary")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("OnSecondary")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("OnSurface")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("Primary")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("Secondary")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("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<Guid>("CreatorId")
.HasColumnType("uuid");
b1.Property<string>("Banner")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("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<Guid>("CreatorId")
.HasColumnType("uuid");
b1.Property<string>("Email")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("Image1Url")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("Image2Url")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("Image3Url")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("Image4Url")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("ImagesSubtitle")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("ImagesText")
.IsRequired()
.HasMaxLength(10000)
.HasColumnType("character varying(10000)");
b1.Property<string>("MainImageText")
.IsRequired()
.HasMaxLength(10000)
.HasColumnType("character varying(10000)");
b1.Property<string>("MainImageUrl")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("MainVideoText")
.IsRequired()
.HasMaxLength(10000)
.HasColumnType("character varying(10000)");
b1.Property<string>("PhoneNumber")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("Title")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("VideoSubtitle")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("VideoSubtitleMain")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("VideoText")
.IsRequired()
.HasMaxLength(10000)
.HasColumnType("character varying(10000)");
b1.Property<string>("VideoUrl")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("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<Guid>("CreatorId")
.HasColumnType("uuid");
b1.Property<string>("FacebookUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("InstagramUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("LinkedInUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("RedditUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("TikTokUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("WebsiteUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("XUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("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
}
}
}

View File

@@ -0,0 +1,45 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Hutopy.Web.Features.Contents.Data.Migrations
{
/// <inheritdoc />
public partial class AddComputedColumnAndIndex_CreatorName : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
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);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Creators_NormalizedName",
schema: "Content",
table: "Creators");
migrationBuilder.DropColumn(
name: "NormalizedName",
schema: "Content",
table: "Creators");
}
}
}

View File

@@ -0,0 +1,403 @@
// <auto-generated />
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("20250108210552_Add_Verified_Creator")]
partial class Add_Verified_Creator
{
/// <inheritdoc />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid>("CreatedBy")
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("DeletedBy")
.HasColumnType("uuid");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("HtmlFileUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("ThumbnailUrl")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string[]>("Urls")
.HasColumnType("text[]");
b.HasKey("Id");
b.HasIndex("CreatedBy");
b.ToTable("Contents", "Content");
});
modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Creator", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("CreatedBy")
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("NormalizedName")
.IsRequired()
.ValueGeneratedOnAddOrUpdate()
.HasMaxLength(255)
.HasColumnType("character varying(255)")
.HasComputedColumnSql("LOWER( \"Content\".\"Creators\".\"Name\")", true);
b.Property<string>("Title")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<bool>("Verified")
.HasColumnType("boolean");
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<Guid>("ContentId")
.HasColumnType("uuid");
b1.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property<int>("Id"));
b1.Property<int>("Reaction")
.HasColumnType("integer");
b1.Property<Guid>("UserId")
.HasColumnType("uuid");
b1.Property<string>("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<Guid>("CreatorId")
.HasColumnType("uuid");
b1.Property<string>("Background")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("Error")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("OnBackground")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("OnError")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("OnPrimary")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("OnSecondary")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("OnSurface")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("Primary")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("Secondary")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("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<Guid>("CreatorId")
.HasColumnType("uuid");
b1.Property<string>("Banner")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("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<Guid>("CreatorId")
.HasColumnType("uuid");
b1.Property<string>("Email")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("Image1Url")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("Image2Url")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("Image3Url")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("Image4Url")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("ImagesSubtitle")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("ImagesText")
.IsRequired()
.HasMaxLength(10000)
.HasColumnType("character varying(10000)");
b1.Property<string>("MainImageText")
.IsRequired()
.HasMaxLength(10000)
.HasColumnType("character varying(10000)");
b1.Property<string>("MainImageUrl")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("MainVideoText")
.IsRequired()
.HasMaxLength(10000)
.HasColumnType("character varying(10000)");
b1.Property<string>("PhoneNumber")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("Title")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("VideoSubtitle")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("VideoSubtitleMain")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("VideoText")
.IsRequired()
.HasMaxLength(10000)
.HasColumnType("character varying(10000)");
b1.Property<string>("VideoUrl")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("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<Guid>("CreatorId")
.HasColumnType("uuid");
b1.Property<string>("FacebookUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("InstagramUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("LinkedInUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("RedditUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("TikTokUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("WebsiteUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("XUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("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
}
}
}

View File

@@ -0,0 +1,31 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Hutopy.Web.Features.Contents.Data.Migrations
{
/// <inheritdoc />
public partial class Add_Verified_Creator : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "Verified",
schema: "Content",
table: "Creators",
type: "boolean",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Verified",
schema: "Content",
table: "Creators");
}
}
}

View File

@@ -0,0 +1,406 @@
// <auto-generated />
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("20250109015556_Adds_AcceptDonation_Creator")]
partial class Adds_AcceptDonation_Creator
{
/// <inheritdoc />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid>("CreatedBy")
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("DeletedBy")
.HasColumnType("uuid");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("HtmlFileUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("ThumbnailUrl")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string[]>("Urls")
.HasColumnType("text[]");
b.HasKey("Id");
b.HasIndex("CreatedBy");
b.ToTable("Contents", "Content");
});
modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Creator", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<bool>("AcceptDonation")
.HasColumnType("boolean");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("CreatedBy")
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("NormalizedName")
.IsRequired()
.ValueGeneratedOnAddOrUpdate()
.HasMaxLength(255)
.HasColumnType("character varying(255)")
.HasComputedColumnSql("LOWER( \"Content\".\"Creators\".\"Name\")", true);
b.Property<string>("Title")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<bool>("Verified")
.HasColumnType("boolean");
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<Guid>("ContentId")
.HasColumnType("uuid");
b1.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property<int>("Id"));
b1.Property<int>("Reaction")
.HasColumnType("integer");
b1.Property<Guid>("UserId")
.HasColumnType("uuid");
b1.Property<string>("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<Guid>("CreatorId")
.HasColumnType("uuid");
b1.Property<string>("Background")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("Error")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("OnBackground")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("OnError")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("OnPrimary")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("OnSecondary")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("OnSurface")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("Primary")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("Secondary")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("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<Guid>("CreatorId")
.HasColumnType("uuid");
b1.Property<string>("Banner")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("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<Guid>("CreatorId")
.HasColumnType("uuid");
b1.Property<string>("Email")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("Image1Url")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("Image2Url")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("Image3Url")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("Image4Url")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("ImagesSubtitle")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("ImagesText")
.IsRequired()
.HasMaxLength(10000)
.HasColumnType("character varying(10000)");
b1.Property<string>("MainImageText")
.IsRequired()
.HasMaxLength(10000)
.HasColumnType("character varying(10000)");
b1.Property<string>("MainImageUrl")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("MainVideoText")
.IsRequired()
.HasMaxLength(10000)
.HasColumnType("character varying(10000)");
b1.Property<string>("PhoneNumber")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("Title")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("VideoSubtitle")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("VideoSubtitleMain")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("VideoText")
.IsRequired()
.HasMaxLength(10000)
.HasColumnType("character varying(10000)");
b1.Property<string>("VideoUrl")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("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<Guid>("CreatorId")
.HasColumnType("uuid");
b1.Property<string>("FacebookUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("InstagramUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("LinkedInUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("RedditUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("TikTokUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("WebsiteUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("XUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("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
}
}
}

View File

@@ -0,0 +1,31 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Hutopy.Web.Features.Contents.Data.Migrations
{
/// <inheritdoc />
public partial class Adds_AcceptDonation_Creator : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "AcceptDonation",
schema: "Content",
table: "Creators",
type: "boolean",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "AcceptDonation",
schema: "Content",
table: "Creators");
}
}
}

View File

@@ -0,0 +1,403 @@
// <auto-generated />
using System;
using Hutopy.Web.Features.Contents.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Hutopy.Web.Features.Contents.Data.Migrations
{
[DbContext(typeof(ContentDbContext))]
partial class ContentDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid>("CreatedBy")
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("DeletedBy")
.HasColumnType("uuid");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("HtmlFileUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("ThumbnailUrl")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string[]>("Urls")
.HasColumnType("text[]");
b.HasKey("Id");
b.HasIndex("CreatedBy");
b.ToTable("Contents", "Content");
});
modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Creator", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<bool>("AcceptDonation")
.HasColumnType("boolean");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("CreatedBy")
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("NormalizedName")
.IsRequired()
.ValueGeneratedOnAddOrUpdate()
.HasMaxLength(255)
.HasColumnType("character varying(255)")
.HasComputedColumnSql("LOWER( \"Content\".\"Creators\".\"Name\")", true);
b.Property<string>("Title")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<bool>("Verified")
.HasColumnType("boolean");
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<Guid>("ContentId")
.HasColumnType("uuid");
b1.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property<int>("Id"));
b1.Property<int>("Reaction")
.HasColumnType("integer");
b1.Property<Guid>("UserId")
.HasColumnType("uuid");
b1.Property<string>("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<Guid>("CreatorId")
.HasColumnType("uuid");
b1.Property<string>("Background")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("Error")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("OnBackground")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("OnError")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("OnPrimary")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("OnSecondary")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("OnSurface")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("Primary")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("Secondary")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("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<Guid>("CreatorId")
.HasColumnType("uuid");
b1.Property<string>("Banner")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("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<Guid>("CreatorId")
.HasColumnType("uuid");
b1.Property<string>("Email")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("Image1Url")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("Image2Url")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("Image3Url")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("Image4Url")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("ImagesSubtitle")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("ImagesText")
.IsRequired()
.HasMaxLength(10000)
.HasColumnType("character varying(10000)");
b1.Property<string>("MainImageText")
.IsRequired()
.HasMaxLength(10000)
.HasColumnType("character varying(10000)");
b1.Property<string>("MainImageUrl")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("MainVideoText")
.IsRequired()
.HasMaxLength(10000)
.HasColumnType("character varying(10000)");
b1.Property<string>("PhoneNumber")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("Title")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("VideoSubtitle")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("VideoSubtitleMain")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("VideoText")
.IsRequired()
.HasMaxLength(10000)
.HasColumnType("character varying(10000)");
b1.Property<string>("VideoUrl")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("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<Guid>("CreatorId")
.HasColumnType("uuid");
b1.Property<string>("FacebookUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("InstagramUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("LinkedInUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("RedditUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("TikTokUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("WebsiteUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("XUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("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
}
}
}

View File

@@ -0,0 +1,16 @@
using Hutopy.Web.Features.Contents.Data;
namespace Hutopy.Web.Features.Contents;
public static class DependencyInjection
{
public static WebApplicationBuilder AddContentModule(
this WebApplicationBuilder builder,
Action<DbContextOptionsBuilder>? configureAction = null)
{
builder.Services.AddDbContext<ContentDbContext>(configureAction);
builder.Services.AddScoped<ContentDbContextInitializer>();
return builder;
}
}

View File

@@ -0,0 +1,43 @@
using Hutopy.Web.Features.Contents.Data;
using Hutopy.Web.Features.Memberships.Events;
namespace Hutopy.Web.Features.Contents.EventHandlers;
[UsedImplicitly]
public class StripeAccountConfiguredHandler(
ILogger<StripeAccountConfiguredHandler> logger,
IServiceScopeFactory scopeFactory)
: IEventHandler<StripeAccountConfigured>
{
public async Task HandleAsync(
StripeAccountConfigured eventModel,
CancellationToken ct)
{
using var scope = scopeFactory.CreateScope();
await using var dbContext = scope.ServiceProvider.GetRequiredService<ContentDbContext>();
var creator = await dbContext.FindAsync<Creator>(
[eventModel.CreatorId],
cancellationToken: ct);
if (creator is null)
{
logger.LogError(
"Creator with id {CreatorId} was not found.",
eventModel.CreatorId);
return;
}
creator.AcceptDonation = true;
var rows = await dbContext.SaveChangesAsync(ct);
if (rows is 0 or > 1)
{
logger.LogError(
"An error occured while updating Creator with id {CreatorId}: rows:{Rows}",
eventModel.CreatorId,
rows);
}
}
}

View File

@@ -0,0 +1,83 @@
using Hutopy.Web.Extensions;
using Hutopy.Web.Features.Contents.Data;
using Hutopy.Web.Features.Contents.Data.Enums;
namespace Hutopy.Web.Features.Contents.Handlers;
[PublicAPI]
public sealed class AddReactionRequest
{
public required Guid ContentId { get; set; }
public required string Reaction { get; set; }
public required Guid UserId { get; set; }
public required string UserName { get; set; }
}
[PublicAPI]
internal sealed class AddReactionRequestValidator
: Validator<AddReactionRequest>
{
public AddReactionRequestValidator()
{
RuleFor(r => r.Reaction)
.NotNull()
.Must(BeAValidReaction)
.WithMessage("'{PropertyValue}' is not a valid reaction.");
}
private bool BeAValidReaction(string reaction)
{
return Enum.TryParse(typeof(Reaction), reaction, true, out _);
}
}
[PublicAPI]
public class AddReaction(
ContentDbContext context)
: Endpoint<AddReactionRequest>
{
public override void Configure()
{
Post("/api/content/reaction");
Options(o => o.WithTags("Contents"));
}
public override async Task HandleAsync(
AddReactionRequest req,
CancellationToken ct)
{
var content = await context.Contents.SingleAsync(x => x.Id == req.ContentId, ct);
var reactionEnum = req.Reaction.ToEnum<Reaction>();
var currentReaction = content.Reactions.SingleOrDefault(x => x.UserId == req.UserId);
// Already reacted or reaction didn't change, do nothing
if (currentReaction != null && currentReaction.Reaction == reactionEnum)
{
return;
}
// User has already reacted, remove the existing reaction
if (currentReaction != null)
{
content.Reactions.Remove(currentReaction);
}
// If the new reaction is valid, add or update the reaction
if (reactionEnum.HasValue)
{
var reaction = new ContentReaction
{
Reaction = reactionEnum.Value,
UserId = req.UserId,
UserName = req.UserName
};
content.Reactions.Add(reaction);
}
await context.SaveChangesAsync(ct);
}
}

View File

@@ -0,0 +1,60 @@
using Hutopy.Web.Common.BlobStorage;
using Hutopy.Web.Features.Contents.Data;
namespace Hutopy.Web.Features.Contents.Handlers;
[PublicAPI]
public record ChangeBannerRequest(
Guid CreatorId,
IFormFile File);
[PublicAPI]
public record ChangeBannerResponse(
string BlobUrl);
[PublicAPI]
public class ChangeBannerHandler(
ContentDbContext context,
AzureBlobStorage blobStorage)
: Endpoint<ChangeBannerRequest, ChangeBannerResponse>
{
public override void Configure()
{
Post("/api/creators/{CreatorId}/banner");
Options(o => o.WithTags("Creators"));
AllowFileUploads();
}
public override async Task HandleAsync(
ChangeBannerRequest request,
CancellationToken ct)
{
var creator = await context
.Creators
.Include(c => c.Images)
.SingleOrDefaultAsync(
c => c.Id == request.CreatorId,
cancellationToken: ct);
if (creator is null)
{
await SendNotFoundAsync(ct);
return;
}
var blobUrl = await blobStorage.UploadFileAsync(
ContainerNames.Creators,
$"{request.CreatorId}/{SubDirectoryNames.Profile}/{CommonFileNames.BannerPicture}",
request.File.OpenReadStream(),
request.File.ContentType,
ct);
creator.Images.Banner = blobUrl;
await context.SaveChangesAsync(ct);
await SendOkAsync(
new ChangeBannerResponse(blobUrl),
ct);
}
}

View File

@@ -0,0 +1,113 @@
using Hutopy.Web.Features.Contents.Data;
namespace Hutopy.Web.Features.Contents.Handlers;
[PublicAPI]
public record ChangeColorsRequest(
Guid CreatorId,
string Primary,
string Secondary,
string Background,
string Surface,
string Error,
string OnPrimary,
string OnSecondary,
string OnBackground,
string OnSurface,
string OnError);
[PublicAPI]
public sealed class ChangeColorsRequestValidator
: Validator<ChangeColorsRequest>
{
public ChangeColorsRequestValidator()
{
RuleFor(x => x.Primary)
.MinimumLength(4).WithMessage("The minimum value should be in the format #444")
.MaximumLength(9).WithMessage("The maximum value should be in the format #11223344")
.Must(x => x.StartsWith('#')).WithMessage("The format should be a valid html color and start with #");
RuleFor(x => x.Secondary)
.MinimumLength(4).WithMessage("The minimum value should be in the format #444")
.MaximumLength(9).WithMessage("The maximum value should be in the format #11223344")
.Must(x => x.StartsWith('#')).WithMessage("The format should be a valid html color and start with #");
RuleFor(x => x.Background)
.MinimumLength(4).WithMessage("The minimum value should be in the format #444")
.MaximumLength(9).WithMessage("The maximum value should be in the format #11223344")
.Must(x => x.StartsWith('#')).WithMessage("The format should be a valid html color and start with #");
RuleFor(x => x.Surface)
.MinimumLength(4).WithMessage("The minimum value should be in the format #444")
.MaximumLength(9).WithMessage("The maximum value should be in the format #11223344")
.Must(x => x.StartsWith('#')).WithMessage("The format should be a valid html color and start with #");
RuleFor(x => x.Error)
.MinimumLength(4).WithMessage("The minimum value should be in the format #444")
.MaximumLength(9).WithMessage("The maximum value should be in the format #11223344")
.Must(x => x.StartsWith('#')).WithMessage("The format should be a valid html color and start with #");
RuleFor(x => x.OnPrimary)
.MinimumLength(4).WithMessage("The minimum value should be in the format #444")
.MaximumLength(9).WithMessage("The maximum value should be in the format #11223344")
.Must(x => x.StartsWith('#')).WithMessage("The format should be a valid html color and start with #");
RuleFor(x => x.OnSecondary)
.MinimumLength(4).WithMessage("The minimum value should be in the format #444")
.MaximumLength(9).WithMessage("The maximum value should be in the format #11223344")
.Must(x => x.StartsWith('#')).WithMessage("The format should be a valid html color and start with #");
RuleFor(x => x.OnBackground)
.MinimumLength(4).WithMessage("The minimum value should be in the format #444")
.MaximumLength(9).WithMessage("The maximum value should be in the format #11223344")
.Must(x => x.StartsWith('#')).WithMessage("The format should be a valid html color and start with #");
RuleFor(x => x.OnSurface)
.MinimumLength(4).WithMessage("The minimum value should be in the format #444")
.MaximumLength(9).WithMessage("The maximum value should be in the format #11223344")
.Must(x => x.StartsWith('#')).WithMessage("The format should be a valid html color and start with #");
RuleFor(x => x.OnError)
.MinimumLength(4).WithMessage("The minimum value should be in the format #444")
.MaximumLength(9).WithMessage("The maximum value should be in the format #11223344")
.Must(x => x.StartsWith('#')).WithMessage("The format should be a valid html color and start with #");
}
}
public class ChangeColorsHandler(
ContentDbContext context)
: Endpoint<ChangeColorsRequest>
{
public override void Configure()
{
Post("/api/creators/{CreatorId}/colors");
Options(o => o.WithTags("Creators"));
}
public override async Task HandleAsync(
ChangeColorsRequest request,
CancellationToken ct)
{
var creator = await context
.Creators
.Include(c => c.Colors)
.SingleAsync(
c => c.Id == request.CreatorId,
cancellationToken: ct);
creator.Colors.Primary = request.Primary;
creator.Colors.Secondary = request.Secondary;
creator.Colors.Background = request.Background;
creator.Colors.Surface = request.Surface;
creator.Colors.Error = request.Error;
creator.Colors.OnPrimary = request.OnPrimary;
creator.Colors.OnSecondary = request.OnSecondary;
creator.Colors.OnBackground = request.OnBackground;
creator.Colors.OnSurface = request.OnSurface;
creator.Colors.OnError = request.OnError;
await context.SaveChangesAsync(ct);
await SendOkAsync(ct);
}
}

View File

@@ -0,0 +1,70 @@
using Hutopy.Web.Common.BlobStorage;
using Hutopy.Web.Features.Contents.Data;
namespace Hutopy.Web.Features.Contents.Handlers;
[PublicAPI]
public record ChangeLogoRequest(
Guid CreatorId,
IFormFile File);
[PublicAPI]
public sealed class ChangeLogoRequestValidator : Validator<ChangeLogoRequest>
{
public ChangeLogoRequestValidator()
{
RuleFor(x => x.CreatorId)
.NotNull()
.NotEmpty();
RuleFor(x => x.File)
.NotNull()
.NotEmpty();
}
}
[PublicAPI]
public class ChangeLogoHandler(
ContentDbContext context,
AzureBlobStorage blobStorage)
: Endpoint<ChangeLogoRequest>
{
public override void Configure()
{
Post("/api/creators/{CreatorId}/logo");
Options(o => o.WithTags("Creators"));
AllowFileUploads();
}
public override async Task HandleAsync(
ChangeLogoRequest request,
CancellationToken ct)
{
var creator = await context
.Creators
.Include(c => c.Images)
.SingleOrDefaultAsync(
c => c.Id == request.CreatorId,
cancellationToken: ct);
if (creator is null)
{
await SendNotFoundAsync(ct);
return;
}
// TODO: this upload should be done to the Creators container
var blobUrl = await blobStorage.UploadFileAsync(
ContainerNames.Creators,
$"{request.CreatorId}/{SubDirectoryNames.Profile}/{CommonFileNames.ProfilePicture}",
request.File.OpenReadStream(),
request.File.ContentType,
ct);
creator.Images.Logo = blobUrl;
await context.SaveChangesAsync(ct);
await SendOkAsync(blobUrl, ct);
}
}

View File

@@ -0,0 +1,115 @@
using Hutopy.Web.Common.BlobStorage;
using Hutopy.Web.Features.Contents.Data;
namespace Hutopy.Web.Features.Contents.Handlers;
[PublicAPI]
public record ChangePresentationInfosRequest(
Guid CreatorId,
string? PhoneNumber,
string? Email,
string? Title,
string? MainImageText,
string? MainVideoText,
string? ImagesSubtitle,
string? ImagesText,
string? VideoSubtitle,
string? VideoSubtitleMain,
string? VideoUrlMain,
string? VideoUrl,
string? VideoText,
string? MainImageUrl,
string? Image1Url,
string? Image2Url,
string? Image3Url,
string? Image4Url,
IFormFile? MainImage,
IFormFile? Image1,
IFormFile? Image2,
IFormFile? Image3,
IFormFile? Image4);
[PublicAPI]
public class ChangePresentationInfosHandler(
ContentDbContext context,
AzureBlobStorage blobStorage)
: Endpoint<ChangePresentationInfosRequest>
{
public override void Configure()
{
Post("/api/creators/{CreatorId}/presentation-infos");
Options(o => o.WithTags("Creators"));
AllowFileUploads();
}
public override async Task HandleAsync(
ChangePresentationInfosRequest request,
CancellationToken ct)
{
var creator = await context
.Creators
.Include(c => c.PresentationInfos)
.SingleOrDefaultAsync(
c => c.Id == request.CreatorId,
cancellationToken: ct);
if (creator is null)
{
await SendNotFoundAsync(ct);
return;
}
async Task<string> UploadFileOrDefaultAsync(
IFormFile? file,
string subDirectory,
string fileName,
string? newUrl)
{
if (newUrl == "")
return "";
if (file != null)
{
return await blobStorage.UploadFileAsync(
ContainerNames.Creators,
$"{request.CreatorId}/{subDirectory}/{fileName}",
file.OpenReadStream(),
file.ContentType,
ct);
}
return newUrl?.Trim() ?? "";
}
creator.PresentationInfos.MainImageUrl = await UploadFileOrDefaultAsync(
request.MainImage, "Profile", "MainImage", request.MainImageUrl);
creator.PresentationInfos.Image1Url = await UploadFileOrDefaultAsync(
request.Image1, "Profile", "Image1", request.Image1Url);
creator.PresentationInfos.Image2Url = await UploadFileOrDefaultAsync(
request.Image2, "Profile", "Image2", request.Image2Url);
creator.PresentationInfos.Image3Url = await UploadFileOrDefaultAsync(
request.Image3, "Profile", "Image3", request.Image3Url);
creator.PresentationInfos.Image4Url = await UploadFileOrDefaultAsync(
request.Image4, "Profile", "Image4", request.Image4Url);
creator.PresentationInfos.PhoneNumber = request.PhoneNumber?.Trim() ?? "";
creator.PresentationInfos.Email = request.Email?.Trim() ?? "";
creator.PresentationInfos.Title = request.Title?.Trim() ?? "";
creator.PresentationInfos.MainImageText = request.MainImageText?.Trim() ?? "";
creator.PresentationInfos.MainVideoText = request.MainVideoText?.Trim() ?? "";
creator.PresentationInfos.ImagesSubtitle = request.ImagesSubtitle?.Trim() ?? "";
creator.PresentationInfos.ImagesText = request.ImagesText?.Trim() ?? "";
creator.PresentationInfos.VideoSubtitle = request.VideoSubtitle?.Trim() ?? "";
creator.PresentationInfos.VideoSubtitleMain = request.VideoSubtitleMain?.Trim() ?? "";
creator.PresentationInfos.VideoUrlMain = request.VideoUrlMain?.Trim() ?? "";
creator.PresentationInfos.VideoUrl = request.VideoUrl?.Trim() ?? "";
creator.PresentationInfos.VideoText = request.VideoText?.Trim() ?? "";
await context.SaveChangesAsync(ct);
await SendOkAsync(ct);
}
}

View File

@@ -0,0 +1,50 @@
using Hutopy.Web.Features.Contents.Data;
namespace Hutopy.Web.Features.Contents.Handlers;
[PublicAPI]
public record ChangeSocialsRequest(
Guid CreatorId,
string? FacebookUrl,
string? InstagramUrl,
string? XUrl,
string? LinkedInUrl,
string? TikTokUrl,
string? YoutubeUrl,
string? RedditUrl,
string? WebsiteUrl);
[PublicAPI]
public class ChangeSocialsHandler(
ContentDbContext context)
: Endpoint<ChangeSocialsRequest>
{
public override void Configure()
{
Post("/api/creators/{CreatorId}/socials");
Options(o => o.WithTags("Creators"));
}
public override async Task HandleAsync(ChangeSocialsRequest request, CancellationToken ct)
{
var creator = await context
.Creators
.Include(c => c.Socials)
.SingleAsync(
c => c.Id == request.CreatorId,
cancellationToken: ct);
creator.Socials.FacebookUrl = request.FacebookUrl;
creator.Socials.InstagramUrl = request.InstagramUrl;
creator.Socials.XUrl = request.XUrl;
creator.Socials.LinkedInUrl = request.LinkedInUrl;
creator.Socials.TikTokUrl = request.TikTokUrl;
creator.Socials.YoutubeUrl = request.YoutubeUrl;
creator.Socials.RedditUrl = request.RedditUrl;
creator.Socials.WebsiteUrl = request.WebsiteUrl;
await context.SaveChangesAsync(ct);
await SendOkAsync(ct);
}
}

View File

@@ -0,0 +1,37 @@
using Hutopy.Web.Features.Contents.Data;
namespace Hutopy.Web.Features.Contents.Handlers;
[PublicAPI]
public record ChangeTitleRequest(
Guid CreatorId,
string? Title);
[PublicAPI]
public class ChangeTitleHandler(
ContentDbContext context)
: Endpoint<ChangeTitleRequest>
{
public override void Configure()
{
Post("/api/creators/{CreatorId}/title");
Options(o => o.WithTags("Creators"));
}
public override async Task HandleAsync(
ChangeTitleRequest request,
CancellationToken ct)
{
var creator = await context
.Creators
.SingleAsync(
c => c.Id == request.CreatorId,
cancellationToken: ct);
creator.Title = request.Title;
await context.SaveChangesAsync(ct);
await SendOkAsync(ct);
}
}

View File

@@ -0,0 +1,156 @@
using System.Collections.Concurrent;
using Hutopy.Web.Common.BlobStorage;
using Hutopy.Web.Common.Security;
using Hutopy.Web.Features.Contents.Data;
namespace Hutopy.Web.Features.Contents.Handlers;
[PublicAPI]
public record PostContentRequest(
Guid Id,
Guid CreatorId,
string Title,
string Description,
IFormFileCollection? Files,
IFormFile? Thumbnail,
string[]? ExternalUrls);
[PublicAPI]
public sealed class PostContentRequestValidator : Validator<PostContentRequest>
{
public PostContentRequestValidator()
{
RuleFor(r => r.Id)
.NotNull().WithMessage("You should specify the Id")
.NotEmpty().WithMessage("You should specify a valid/not empty Id");
RuleFor(r => r.CreatorId)
.NotNull().WithMessage("You should specify the CreatorId")
.NotEmpty().WithMessage("You should specify a valid/not empty CreatorId");
RuleFor(r => r.Title)
.NotNull().WithMessage("You should specify the Title")
.NotEmpty().WithMessage("You should specify a valid/not empty Title");
RuleFor(r => r.Description)
.NotNull().WithMessage("You should specify the Description")
.NotEmpty().WithMessage("You should specify a valid/not empty Description");
RuleForEach(r => r.ExternalUrls)
.Must(url => Uri.IsWellFormedUriString(url, UriKind.Absolute) &&
(url.StartsWith("http://") || url.StartsWith("https://")))
.WithMessage("External URL must be a valid HTTP/HTTPS URL");
RuleFor(r => r.Thumbnail)
.Must(file => file == null || file.ContentType.StartsWith("image/"))
.WithMessage("Thumbnail must be an image");
}
}
public sealed class PostContent(
AzureBlobStorage blobStorage,
ContentDbContext context)
: Endpoint<PostContentRequest>
{
public override void Configure()
{
Post("/api/contents");
Options(o => o.WithTags("Contents"));
AllowFileUploads();
}
public override async Task HandleAsync(PostContentRequest req, CancellationToken ct)
{
var urls = new ConcurrentBag<string>();
string? thumbnailUrl = null;
await using var transaction = await context.Database.BeginTransactionAsync(ct);
try
{
if (req.Files is not null)
{
await Parallel.ForEachAsync(req.Files, ct, async (file, ict) =>
{
try
{
var contentUrl = await SaveFileAsync(req.CreatorId, req.Id, file, ict);
urls.Add(contentUrl);
}
catch (Exception ex)
{
Logger.LogError("Failed to upload file {FileName}: {Message}", file.FileName, ex.Message);
}
});
}
if (req.ExternalUrls is not null)
{
foreach (var externalUrl in req.ExternalUrls.Where(url => !string.IsNullOrWhiteSpace(url)))
{
urls.Add(externalUrl);
}
}
if (req.Thumbnail is not null)
{
try
{
thumbnailUrl = await SaveFileAsync(req.CreatorId, req.Id, req.Thumbnail, ct, isThumbnail: true);
}
catch (Exception ex)
{
Logger.LogError("Error uploading thumbnail: {Message}", ex.Message);
}
}
await context.Contents.AddAsync(new Content
{
Id = req.Id,
CreatedBy = User.GetUserId(),
Title = req.Title,
Description = req.Description,
Urls = urls.IsEmpty ? null : urls.ToArray(),
ThumbnailUrl = thumbnailUrl,
}, ct);
await context.SaveChangesAsync(ct);
await transaction.CommitAsync(ct);
await SendOkAsync(new { Message = "Content published successfully!" }, ct);
}
catch (Exception ex)
{
await transaction.RollbackAsync(ct);
Logger.LogError("Transaction failed: {Message}", ex.Message);
throw;
}
}
private async Task<string> SaveFileAsync(
Guid creatorId,
Guid contentId,
IFormFile file,
CancellationToken ct = default,
bool isThumbnail = false)
{
var blobName = isThumbnail
? $"{creatorId}/{SubDirectoryNames.Contents}/{contentId}/thumbnail-{file.FileName}"
: $"{creatorId}/{SubDirectoryNames.Contents}/{contentId}/{file.FileName}";
return await blobStorage.UploadFileAsync(
ContainerNames.Creators,
blobName,
file.OpenReadStream(),
file.ContentType,
ct: ct);
}
}

View File

@@ -0,0 +1,110 @@
using System.Text;
using Hutopy.Web.Common.BlobStorage;
using Hutopy.Web.Common.Security;
using Hutopy.Web.Features.Contents.Data;
using Hutopy.Web.Features.Contents.Handlers.Models;
namespace Hutopy.Web.Features.Contents.Handlers;
[PublicAPI]
public record PostContentFromHtmlRequest(
Guid Id,
Guid CreatorId,
string Title,
string HtmlContent
);
[PublicAPI]
public sealed class PostContentFromHtmlRequestValidator : Validator<PostContentFromHtmlRequest>
{
public PostContentFromHtmlRequestValidator()
{
RuleFor(r => r.Id)
.NotNull().WithMessage("You should specify the Id")
.NotEmpty().WithMessage("You should specify a valid/not empty Id");
RuleFor(r => r.CreatorId)
.NotNull().WithMessage("You should specify the CreatorId")
.NotEmpty().WithMessage("You should specify a valid/not empty CreatorId");
RuleFor(r => r.Title)
.NotNull().WithMessage("You should specify the Title")
.NotEmpty().WithMessage("You should specify a valid/not empty Title");
}
}
public sealed class PostContentHtml(
AzureBlobStorage blobStorage,
ContentDbContext context)
: Endpoint<PostContentFromHtmlRequest>
{
public override void Configure()
{
Post("/api/contents/html");
Options(o => o.WithTags("Contents"));
AllowFileUploads();
}
public override async Task HandleAsync(
PostContentFromHtmlRequest req,
CancellationToken ct)
{
var htmlFileUrl = await SaveHtmlContentAsHtmlFileAsync(
req.CreatorId,
req.Id,
req.HtmlContent,
ct);
await context.Contents.AddAsync(
new Content { Id = req.Id, CreatedBy = User.GetUserId(), Title = req.Title, HtmlFileUrl = htmlFileUrl },
ct);
await context.SaveChangesAsync(ct);
var content = await context
.Contents
.Select(c => new ContentModel
{
Id = c.Id,
CreatedBy = c.CreatedBy,
CreatedByName = c.Creator!.Name,
CreatedByPortraitUrl = c.Creator.Images.Logo,
CreatedAt = c.CreatedAt,
DeletedBy = c.DeletedBy,
DeletedAt = c.DeletedAt,
Title = c.Title,
Description = c.Description,
Urls = c.Urls,
ThumbnailUrl = c.ThumbnailUrl,
HtmlFileUrl = htmlFileUrl
})
.SingleOrDefaultAsync(
c => c.Id == req.Id,
cancellationToken: ct);
await SendOkAsync(content, ct);
}
private async Task<string> SaveHtmlContentAsHtmlFileAsync(
Guid creatorId,
Guid contentId,
string htmlContent,
CancellationToken ct = default)
{
var fileName = $"{contentId}.html";
var filePath = $"{creatorId}/{SubDirectoryNames.Contents}/{fileName}";
// Convert the HTML string into a stream
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(htmlContent));
// Upload the stream as an HTML file
var url = await blobStorage.UploadFileAsync(
ContainerNames.Creators,
filePath,
stream,
"text/html",
ct: ct);
return url;
}
}

View File

@@ -0,0 +1,91 @@
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;
[PublicAPI]
public record CreateCreatorRequest(
Guid CreatorId,
string Name);
[UsedImplicitly]
public sealed class CreateCreatorRequestValidator : Validator<CreateCreatorRequest>
{
public CreateCreatorRequestValidator()
{
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");
}
}
[PublicAPI]
public sealed class CreateCreatorHandler(
ContentDbContext context)
: Endpoint<CreateCreatorRequest>
{
public override void Configure()
{
Post("/api/creators");
Options(o => o.WithTags("Creators"));
}
public override async Task HandleAsync(
CreateCreatorRequest req,
CancellationToken ct)
{
try
{
await context.Creators.AddAsync(
new Creator
{
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));
}
}
else
{
await SendResultAsync(new ProblemDetails(
[new ValidationFailure(nameof(Creator.Name), e.Message)],
(int)HttpStatusCode.Conflict));
}
}
}
}

View File

@@ -0,0 +1,60 @@
using Hutopy.Web.Common;
using Hutopy.Web.Common.Security;
using Hutopy.Web.Features.Contents.Data;
namespace Hutopy.Web.Features.Contents.Handlers;
[PublicAPI]
public record DeleteContentRequest(
Guid ContentId);
[PublicAPI]
public sealed class DeleteContentRequestValidator : Validator<DeleteContentRequest>
{
public DeleteContentRequestValidator()
{
RuleFor(r => r.ContentId)
.NotNull().WithMessage("You should specify the ContentId")
.NotEmpty().WithMessage("You should specify a valid/not empty ContentId");
}
}
public sealed class DeleteContent(
ContentDbContext context)
: Endpoint<DeleteContentRequest>
{
public override void Configure()
{
Delete("/api/contents/{ContentId}");
Options(o => o.WithTags("Contents"));
}
public override async Task HandleAsync(
DeleteContentRequest req,
CancellationToken ct)
{
var content = await context.Contents.FindAsync(
[req.ContentId],
ct);
if (content is null)
{
await SendNotFoundAsync(ct);
return;
}
var userId = HttpContext.User.GetUserId();
if (content.CreatedBy != userId)
{
await SendForbiddenAsync(ct);
return;
}
content.DeletedAt = DateTimeOffset.UtcNow;
content.DeletedBy = userId;
await context.SaveChangesAsync(ct);
await SendOkAsync(ct);
}
}

View File

@@ -0,0 +1,59 @@
using Hutopy.Web.Extensions;
using Hutopy.Web.Features.Contents.Data;
using Hutopy.Web.Features.Contents.Handlers.Models;
namespace Hutopy.Web.Features.Contents.Handlers;
[PublicAPI]
public sealed class GetContentRequest
{
public Guid ContentId { get; set; }
}
[PublicAPI]
public class GetContent(
ContentDbContext context)
: Endpoint<GetContentRequest, ContentModel>
{
public override void Configure()
{
Get("/api/contents/{ContentId:guid}");
Options(o => o.WithTags("Contents"));
AllowAnonymous();
}
public override async Task HandleAsync(
GetContentRequest req,
CancellationToken ct)
{
var content = await context
.Contents
.Select(c => new ContentModel
{
Id = c.Id,
CreatedBy = c.CreatedBy,
CreatedByName = c.Creator!.Name,
CreatedByPortraitUrl = c.Creator.Images.Logo,
CreatedAt = c.CreatedAt,
DeletedBy = c.DeletedBy,
DeletedAt = c.DeletedAt,
Title = c.Title,
Description = c.Description,
Urls = c.Urls,
ThumbnailUrl = c.ThumbnailUrl,
HtmlFileUrl = c.HtmlFileUrl ?? "",
Reactions = c.Reactions.Select(x => new ReactionModel
{
Reaction = x.Reaction.FromEnum(), UserId = x.UserId, UserName = x.UserName
}).ToList()
})
.SingleOrDefaultAsync(
c => c.Id == req.ContentId,
cancellationToken: ct);
if (content is null)
await SendNotFoundAsync(cancellation: ct);
else
await SendAsync(content, cancellation: ct);
}
}

View File

@@ -0,0 +1,68 @@
using Hutopy.Web.Extensions;
using Hutopy.Web.Features.Contents.Data;
using Hutopy.Web.Features.Contents.Handlers.Models;
namespace Hutopy.Web.Features.Contents.Handlers;
[PublicAPI]
public sealed class GetContentsByCreatorRequest
{
public Guid CreatorId { get; set; }
[BindFrom("page_size")] public int PageSize { get; set; } = 10;
[BindFrom("last_id")] public Guid? LastId { get; set; }
}
[PublicAPI]
public class GetContentsByCreatorHandler(
ContentDbContext context)
: Endpoint<GetContentsByCreatorRequest, List<ContentModel>>
{
public override void Configure()
{
Get("/api/contents/creator/{CreatorId:guid}");
Options(o => o.WithTags("Contents"));
AllowAnonymous();
}
public override async Task HandleAsync(
GetContentsByCreatorRequest req,
CancellationToken ct)
{
var query = context.Contents
.Where(c => c.CreatedBy == req.CreatorId && c.DeletedAt == null)
.OrderByDescending(c => c.CreatedAt);
if (req.LastId.HasValue)
{
query = query.Where(c => c.Id > req.LastId.Value)
.OrderByDescending(c => c.CreatedAt);
}
var content = await query
.Select(c => new ContentModel
{
Id = c.Id,
CreatedBy = c.CreatedBy,
CreatedByName = c.Creator!.Name,
CreatedByPortraitUrl = c.Creator.Images.Logo,
CreatedAt = c.CreatedAt,
DeletedBy = c.DeletedBy,
DeletedAt = c.DeletedAt,
Title = c.Title,
Description = c.Description,
Urls = c.Urls,
ThumbnailUrl = c.ThumbnailUrl,
HtmlFileUrl = c.HtmlFileUrl ?? "",
Reactions = c.Reactions.Select(x => new ReactionModel
{
Reaction = x.Reaction.FromEnum(),
UserId = x.UserId,
UserName = x.UserName
}).ToList()
})
.Take(req.PageSize)
.ToListAsync(ct);
await SendAsync(content, cancellation: ct);
}
}

View File

@@ -0,0 +1,83 @@
using Hutopy.Web.Features.Contents.Data;
using Hutopy.Web.Features.Contents.Handlers.Models;
namespace Hutopy.Web.Features.Contents.Handlers;
[PublicAPI]
public sealed class GetCreatorByAliasRequest
{
public required string Name { get; set; }
}
[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);
[UsedImplicitly]
public sealed class GetCreatorByAliasRequestValidator
: Validator<GetCreatorByAliasRequest>
{
public GetCreatorByAliasRequestValidator()
{
RuleFor(r => r.Name)
.NotNull().WithMessage("You should specify the Name")
.NotEmpty().WithMessage("You should specify a valid/not empty Name");
}
}
[PublicAPI]
public class GetCreatorByAliasHandler(
ContentDbContext context)
: Endpoint<GetCreatorByAliasRequest, GetCreatorByAliasResponse>
{
public override void Configure()
{
Get("/api/creators/@{Name}");
Options((o => o.WithTags("Creators")));
AllowAnonymous();
}
public override async Task HandleAsync(
GetCreatorByAliasRequest req,
CancellationToken ct)
{
var creatorName = req.Name.ToLower();
var creator = await context
.Creators
.Where(c => EF.Functions.ILike(c.Name, creatorName))
.FirstOrDefaultAsync(ct);
if (creator is null)
{
await SendNotFoundAsync(ct);
}
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);
}
}
}

View File

@@ -0,0 +1,48 @@
using Hutopy.Web.Features.Contents.Data;
namespace Hutopy.Web.Features.Contents.Handlers;
[PublicAPI]
public sealed class GetCreatorByIdRequest
{
public required Guid CreatorId { get; set; }
}
[UsedImplicitly]
public sealed class GetCreatorByIdRequestValidator
: Validator<GetCreatorByIdRequest>
{
public GetCreatorByIdRequestValidator()
{
RuleFor(r => r.CreatorId)
.NotNull().WithMessage("You should specify the CreatorId")
.NotEmpty().WithMessage("You should specify a valid/not empty CreatorId");
}
}
[PublicAPI]
public class GetCreatorByIdHandler(
ContentDbContext context)
: Endpoint<GetCreatorByIdRequest, Creator>
{
public override void Configure()
{
Get("/api/creators/{CreatorId}");
Options((o => o.WithTags("Creators")));
AllowAnonymous();
}
public override async Task HandleAsync(
GetCreatorByIdRequest req,
CancellationToken ct)
{
var creator = await context
.Creators
.FindAsync(
[req.CreatorId],
cancellationToken: ct);
if (creator is null) await SendNotFoundAsync(ct);
else await SendAsync(creator, cancellation: ct);
}
}

View File

@@ -0,0 +1,31 @@
using Hutopy.Web.Common;
using Hutopy.Web.Common.Security;
using Hutopy.Web.Features.Contents.Data;
namespace Hutopy.Web.Features.Contents.Handlers;
[PublicAPI]
public class GetCreatorProfileHandler(
ContentDbContext context)
: EndpointWithoutRequest<Creator>
{
public override void Configure()
{
Get("/api/creators/profile");
Options((o => o.WithTags("Creators")));
AllowAnonymous();
}
public override async Task HandleAsync(
CancellationToken ct)
{
var creator = await context
.Creators
.FindAsync(
[HttpContext.User.GetUserId()],
cancellationToken: ct);
if (creator is null) await SendNotFoundAsync(ct);
else await SendAsync(creator, cancellation: ct);
}
}

View File

@@ -0,0 +1,66 @@
using Hutopy.Web.Extensions;
using Hutopy.Web.Features.Contents.Data;
using Hutopy.Web.Features.Contents.Handlers.Models;
namespace Hutopy.Web.Features.Contents.Handlers;
[PublicAPI]
public sealed class GetFeaturedContentsRequest
{
[BindFrom("page_size")] public int PageSize { get; set; } = 10;
[BindFrom("last_id")] public Guid? LastId { get; set; }
}
[PublicAPI]
public class GetFeaturedContentsHandler(
ContentDbContext context)
: Endpoint<GetFeaturedContentsRequest, List<ContentModel>>
{
public override void Configure()
{
Get("/api/contents/featured");
Options(o => o.WithTags("Contents"));
AllowAnonymous();
}
public override async Task HandleAsync(
GetFeaturedContentsRequest req,
CancellationToken ct)
{
var query = context.Contents
.Where(c => c.DeletedAt == null);
if (req.LastId.HasValue)
{
query = query.Where(c => c.Id > req.LastId.Value);
}
query = query.OrderByDescending(x => x.Reactions.Count);
var content = await query
.Select(c => new ContentModel
{
Id = c.Id,
CreatedBy = c.CreatedBy,
CreatedByName = c.Creator!.Name,
CreatedByPortraitUrl = c.Creator.Images.Logo,
CreatedAt = c.CreatedAt,
DeletedBy = c.DeletedBy,
DeletedAt = c.DeletedAt,
Title = c.Title,
Description = c.Description,
Urls = c.Urls,
ThumbnailUrl = c.ThumbnailUrl,
Reactions = c.Reactions.Select(x => new ReactionModel
{
Reaction = x.Reaction.FromEnum(),
UserId = x.UserId,
UserName = x.UserName
}).ToList()
})
.Take(req.PageSize)
.ToListAsync(ct);
await SendAsync(content, cancellation: ct);
}
}

View File

@@ -0,0 +1,86 @@
using Hutopy.Web.Common.BlobStorage;
namespace Hutopy.Web.Features.Contents.Handlers;
[PublicAPI]
public record InsertImagesRequest(
Guid Id,
Guid CreatorId,
IFormFileCollection? Files
);
[PublicAPI]
public sealed class InsertImagesRequestValidator : Validator<InsertImagesRequest>
{
public InsertImagesRequestValidator()
{
RuleFor(r => r.Id)
.NotNull().WithMessage("You should specify the Id")
.NotEmpty().WithMessage("You should specify a valid/not empty Id");
RuleFor(r => r.CreatorId)
.NotNull().WithMessage("You should specify the CreatorId")
.NotEmpty().WithMessage("You should specify a valid/not empty CreatorId");
}
}
public sealed class InsertImages(
AzureBlobStorage blobStorage)
: Endpoint<InsertImagesRequest>
{
public override void Configure()
{
Post("/api/content/insert-image/");
Options(o => o.WithTags("Contents"));
AllowFileUploads();
}
public override async Task HandleAsync(
InsertImagesRequest req,
CancellationToken ct)
{
var urls = new List<string>();
if (req.Files is not null)
{
await Parallel.ForEachAsync(
req.Files,
ct,
async (
file,
ict) =>
{
try
{
var contentUrl = await SaveFileAsync(
req.CreatorId,
req.Id,
file,
ict);
urls.Add(contentUrl);
}
catch (Exception ex)
{
Logger.LogError("{ErrorMessage}", ex.Message);
}
});
}
await SendOkAsync(urls, ct);
}
private async Task<string> SaveFileAsync(
Guid creatorId,
Guid contentId,
IFormFile file,
CancellationToken ct = default)
{
var url = await blobStorage.UploadFileAsync(
ContainerNames.Creators,
$"{creatorId}/{SubDirectoryNames.Contents}/{contentId}/{file.FileName}",
file.OpenReadStream(),
file.ContentType,
ct: ct);
return url;
}
}

View File

@@ -0,0 +1,19 @@
namespace Hutopy.Web.Features.Contents.Handlers.Models;
[PublicAPI]
public class ContentModel
{
public required Guid Id { get; init; }
public required Guid CreatedBy { get; init; }
public required string CreatedByName { get; init; }
public required string? CreatedByPortraitUrl { get; init; }
public required DateTimeOffset CreatedAt { get; init; }
public Guid? DeletedBy { get; init; }
public DateTimeOffset? DeletedAt { get; init; }
public required string Title { get; init; }
public required string Description { get; init; }
public string HtmlFileUrl { get; init; } = "";
public required string[]? Urls { get; init; }
public string? ThumbnailUrl { get; init; }
public IList<ReactionModel>? Reactions { get; set; } = new List<ReactionModel>();
}

View File

@@ -0,0 +1,7 @@
namespace Hutopy.Web.Features.Contents.Handlers.Models;
[PublicAPI]
public record FollowModel(
Guid CreatorId,
string CreatorName,
string? CreatorPortraitUrl);

View File

@@ -0,0 +1,8 @@
namespace Hutopy.Web.Features.Contents.Handlers.Models;
public class ReactionModel
{
public required string Reaction { get; set; }
public required Guid UserId { get; set; }
public required string UserName { get; set; }
}

View File

@@ -0,0 +1,36 @@
using Hutopy.Web.Features.Contents.Data;
namespace Hutopy.Web.Features.Contents.Handlers;
[PublicAPI]
public sealed class RemoveReactionRequest
{
public required Guid ContentId { get; set; }
public required Guid UserId { get; set; }
}
[PublicAPI]
public class RemoveReaction(
ContentDbContext context)
: Endpoint<RemoveReactionRequest>
{
public override void Configure()
{
Post("/api/content/reaction/remove");
Options(o => o.WithTags("Contents"));
}
public override async Task HandleAsync(
RemoveReactionRequest req,
CancellationToken ct)
{
var content = await context.Contents
.SingleAsync(x => x.Id == req.ContentId, ct);
var reaction = content.Reactions.Single(x => x.UserId == req.UserId);
content.Reactions.Remove(reaction);
await context.SaveChangesAsync(ct);
}
}

View File

@@ -0,0 +1,9 @@
namespace Hutopy.Web.Features.Memberships.Data;
public class Creator
{
public Guid Id { get; set; }
public string Name { get; set; }
public string? StripeAccountId { get; set; }
public string PortraitUrl { get; set; }
}

View File

@@ -0,0 +1,58 @@
namespace Hutopy.Web.Features.Memberships.Data;
public sealed class MembershipDbContext(
DbContextOptions<MembershipDbContext> options)
: DbContext(options)
{
public const string SchemaName = "Membership";
public DbSet<Creator> Creators => Set<Creator>();
public DbSet<Subscription> Subscriptions => Set<Subscription>();
public DbSet<Tier> Tiers => Set<Tier>();
public DbSet<Tip> Tips => Set<Tip>();
public DbSet<Transaction> Transactions => Set<Transaction>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasDefaultSchema(SchemaName);
modelBuilder.Entity<Creator>();
modelBuilder
.Entity<Subscription>()
.Property(c => c.CreatedAt)
.ValueGeneratedOnAdd()
.HasDefaultValueSql("CURRENT_TIMESTAMP");
modelBuilder
.Entity<Subscription>()
.HasOne(c => c.Creator)
.WithMany()
.HasForeignKey(c => c.CreatorId);
modelBuilder
.Entity<Tier>()
.HasOne(c => c.Creator)
.WithMany()
.HasForeignKey(c => c.CreatorId);
modelBuilder
.Entity<Tier>()
.Property(c => c.CreatedAt)
.ValueGeneratedOnAdd()
.HasDefaultValueSql("CURRENT_TIMESTAMP");
modelBuilder
.Entity<Tip>()
.Property(c => c.CreatedAt)
.ValueGeneratedOnAdd()
.HasDefaultValueSql("CURRENT_TIMESTAMP");
modelBuilder
.Entity<Transaction>()
.Property(c => c.CreatedAt)
.ValueGeneratedOnAdd()
.HasDefaultValueSql("CURRENT_TIMESTAMP");
}
}

View File

@@ -0,0 +1,34 @@
using Hutopy.Web.Features.Contents.Data;
namespace Hutopy.Web.Features.Memberships.Data;
public static class InitializerExtensions
{
public static async Task InitialiseMembershipDbContextAsync(this WebApplication app)
{
using var scope = app.Services.CreateScope();
var initializer = scope.ServiceProvider.GetRequiredService<MembershipDbContextInitializer>();
await initializer.InitialiseAsync();
}
}
public class MembershipDbContextInitializer(
ILogger<MembershipDbContextInitializer> logger,
MembershipDbContext context
)
{
public async Task InitialiseAsync()
{
try
{
await context.Database.MigrateAsync();
}
catch (Exception ex)
{
logger.LogError(ex, "An error occurred while initialising the membership database.");
throw;
}
}
}

View File

@@ -0,0 +1,288 @@
// <auto-generated />
using System;
using Hutopy.Web.Features.Memberships.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.Memberships.Data.Migrations
{
[DbContext(typeof(MembershipDbContext))]
[Migration("20241022191000_Initial")]
partial class Initial
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("Membership")
.HasAnnotation("ProductVersion", "8.0.10")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Creator", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("StripeAccountId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Creators", "Membership");
});
modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Subscription", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid>("CreatorId")
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("EndDate")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("StartDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("StripeSessionId")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("StripeSubscriptionId")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<Guid>("TierId")
.HasColumnType("uuid");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("CreatorId");
b.HasIndex("TierId");
b.ToTable("Subscriptions", "Membership");
});
modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Tier", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid>("CreatorId")
.HasColumnType("uuid");
b.Property<string>("CurrencyCode")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("character varying(4096)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<decimal>("Price")
.HasColumnType("numeric");
b.Property<string>("StripePriceId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("StripeProductId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.HasKey("Id");
b.HasIndex("CreatorId");
b.ToTable("Tiers", "Membership");
});
modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Tip", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<decimal>("Amount")
.HasColumnType("numeric");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid>("CreatorId")
.HasColumnType("uuid");
b.Property<string>("CreatorName")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Currency")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Message")
.IsRequired()
.HasColumnType("text");
b.Property<string>("StripeSessionId")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("TipperId")
.HasColumnType("uuid");
b.Property<string>("TipperName")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("TransactionId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TransactionId");
b.ToTable("Tips", "Membership");
});
modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Transaction", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<decimal>("Amount")
.HasColumnType("numeric");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("Currency")
.IsRequired()
.HasColumnType("text");
b.Property<string>("StripeInvoiceUrl")
.HasColumnType("text");
b.Property<Guid?>("SubscriptionId")
.HasColumnType("uuid");
b.Property<DateTime>("Timestamp")
.HasColumnType("timestamp with time zone");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("SubscriptionId");
b.ToTable("Transactions", "Membership");
});
modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Subscription", b =>
{
b.HasOne("Hutopy.Web.Features.Memberships.Data.Creator", "Creator")
.WithMany()
.HasForeignKey("CreatorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Hutopy.Web.Features.Memberships.Data.Tier", "Tier")
.WithMany("Subscriptions")
.HasForeignKey("TierId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Creator");
b.Navigation("Tier");
});
modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Tier", b =>
{
b.HasOne("Hutopy.Web.Features.Memberships.Data.Creator", "Creator")
.WithMany()
.HasForeignKey("CreatorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Creator");
});
modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Tip", b =>
{
b.HasOne("Hutopy.Web.Features.Memberships.Data.Transaction", "Transaction")
.WithMany()
.HasForeignKey("TransactionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Transaction");
});
modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Transaction", b =>
{
b.HasOne("Hutopy.Web.Features.Memberships.Data.Subscription", null)
.WithMany("Transactions")
.HasForeignKey("SubscriptionId");
});
modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Subscription", b =>
{
b.Navigation("Transactions");
});
modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Tier", b =>
{
b.Navigation("Subscriptions");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,201 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Hutopy.Web.Features.Memberships.Data.Migrations
{
/// <inheritdoc />
public partial class Initial : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.EnsureSchema(
name: "Membership");
migrationBuilder.CreateTable(
name: "Creators",
schema: "Membership",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "text", nullable: false),
StripeAccountId = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Creators", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Tiers",
schema: "Membership",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"),
CreatorId = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
Description = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: false),
Price = table.Column<decimal>(type: "numeric", nullable: false),
CurrencyCode = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
StripeProductId = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
StripePriceId = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Tiers", x => x.Id);
table.ForeignKey(
name: "FK_Tiers_Creators_CreatorId",
column: x => x.CreatorId,
principalSchema: "Membership",
principalTable: "Creators",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Subscriptions",
schema: "Membership",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
CreatorId = table.Column<Guid>(type: "uuid", nullable: false),
TierId = table.Column<Guid>(type: "uuid", nullable: false),
StartDate = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
EndDate = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
StripeSessionId = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: true),
StripeSubscriptionId = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Subscriptions", x => x.Id);
table.ForeignKey(
name: "FK_Subscriptions_Creators_CreatorId",
column: x => x.CreatorId,
principalSchema: "Membership",
principalTable: "Creators",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_Subscriptions_Tiers_TierId",
column: x => x.TierId,
principalSchema: "Membership",
principalTable: "Tiers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Transactions",
schema: "Membership",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"),
Amount = table.Column<decimal>(type: "numeric", nullable: false),
Currency = table.Column<string>(type: "text", nullable: false),
Type = table.Column<string>(type: "text", nullable: false),
Timestamp = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
StripeInvoiceUrl = table.Column<string>(type: "text", nullable: true),
SubscriptionId = table.Column<Guid>(type: "uuid", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Transactions", x => x.Id);
table.ForeignKey(
name: "FK_Transactions_Subscriptions_SubscriptionId",
column: x => x.SubscriptionId,
principalSchema: "Membership",
principalTable: "Subscriptions",
principalColumn: "Id");
});
migrationBuilder.CreateTable(
name: "Tips",
schema: "Membership",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"),
StripeSessionId = table.Column<string>(type: "text", nullable: false),
TipperId = table.Column<Guid>(type: "uuid", nullable: false),
TipperName = table.Column<string>(type: "text", nullable: false),
CreatorId = table.Column<Guid>(type: "uuid", nullable: false),
CreatorName = table.Column<string>(type: "text", nullable: false),
Amount = table.Column<decimal>(type: "numeric", nullable: false),
Currency = table.Column<string>(type: "text", nullable: false),
Message = table.Column<string>(type: "text", nullable: false),
TransactionId = table.Column<Guid>(type: "uuid", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Tips", x => x.Id);
table.ForeignKey(
name: "FK_Tips_Transactions_TransactionId",
column: x => x.TransactionId,
principalSchema: "Membership",
principalTable: "Transactions",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_Subscriptions_CreatorId",
schema: "Membership",
table: "Subscriptions",
column: "CreatorId");
migrationBuilder.CreateIndex(
name: "IX_Subscriptions_TierId",
schema: "Membership",
table: "Subscriptions",
column: "TierId");
migrationBuilder.CreateIndex(
name: "IX_Tiers_CreatorId",
schema: "Membership",
table: "Tiers",
column: "CreatorId");
migrationBuilder.CreateIndex(
name: "IX_Tips_TransactionId",
schema: "Membership",
table: "Tips",
column: "TransactionId");
migrationBuilder.CreateIndex(
name: "IX_Transactions_SubscriptionId",
schema: "Membership",
table: "Transactions",
column: "SubscriptionId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Tips",
schema: "Membership");
migrationBuilder.DropTable(
name: "Transactions",
schema: "Membership");
migrationBuilder.DropTable(
name: "Subscriptions",
schema: "Membership");
migrationBuilder.DropTable(
name: "Tiers",
schema: "Membership");
migrationBuilder.DropTable(
name: "Creators",
schema: "Membership");
}
}
}

View File

@@ -0,0 +1,292 @@
// <auto-generated />
using System;
using Hutopy.Web.Features.Memberships.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.Memberships.Data.Migrations
{
[DbContext(typeof(MembershipDbContext))]
[Migration("20241022203207_PortraitUrlToCreator")]
partial class PortraitUrlToCreator
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("Membership")
.HasAnnotation("ProductVersion", "8.0.10")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Creator", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("PortraitUrl")
.IsRequired()
.HasColumnType("text");
b.Property<string>("StripeAccountId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Creators", "Membership");
});
modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Subscription", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid>("CreatorId")
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("EndDate")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("StartDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("StripeSessionId")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("StripeSubscriptionId")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<Guid>("TierId")
.HasColumnType("uuid");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("CreatorId");
b.HasIndex("TierId");
b.ToTable("Subscriptions", "Membership");
});
modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Tier", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid>("CreatorId")
.HasColumnType("uuid");
b.Property<string>("CurrencyCode")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("character varying(4096)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<decimal>("Price")
.HasColumnType("numeric");
b.Property<string>("StripePriceId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("StripeProductId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.HasKey("Id");
b.HasIndex("CreatorId");
b.ToTable("Tiers", "Membership");
});
modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Tip", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<decimal>("Amount")
.HasColumnType("numeric");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid>("CreatorId")
.HasColumnType("uuid");
b.Property<string>("CreatorName")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Currency")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Message")
.IsRequired()
.HasColumnType("text");
b.Property<string>("StripeSessionId")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("TipperId")
.HasColumnType("uuid");
b.Property<string>("TipperName")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("TransactionId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TransactionId");
b.ToTable("Tips", "Membership");
});
modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Transaction", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<decimal>("Amount")
.HasColumnType("numeric");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("Currency")
.IsRequired()
.HasColumnType("text");
b.Property<string>("StripeInvoiceUrl")
.HasColumnType("text");
b.Property<Guid?>("SubscriptionId")
.HasColumnType("uuid");
b.Property<DateTime>("Timestamp")
.HasColumnType("timestamp with time zone");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("SubscriptionId");
b.ToTable("Transactions", "Membership");
});
modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Subscription", b =>
{
b.HasOne("Hutopy.Web.Features.Memberships.Data.Creator", "Creator")
.WithMany()
.HasForeignKey("CreatorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Hutopy.Web.Features.Memberships.Data.Tier", "Tier")
.WithMany("Subscriptions")
.HasForeignKey("TierId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Creator");
b.Navigation("Tier");
});
modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Tier", b =>
{
b.HasOne("Hutopy.Web.Features.Memberships.Data.Creator", "Creator")
.WithMany()
.HasForeignKey("CreatorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Creator");
});
modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Tip", b =>
{
b.HasOne("Hutopy.Web.Features.Memberships.Data.Transaction", "Transaction")
.WithMany()
.HasForeignKey("TransactionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Transaction");
});
modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Transaction", b =>
{
b.HasOne("Hutopy.Web.Features.Memberships.Data.Subscription", null)
.WithMany("Transactions")
.HasForeignKey("SubscriptionId");
});
modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Subscription", b =>
{
b.Navigation("Transactions");
});
modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Tier", b =>
{
b.Navigation("Subscriptions");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,31 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Hutopy.Web.Features.Memberships.Data.Migrations
{
/// <inheritdoc />
public partial class PortraitUrlToCreator : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "PortraitUrl",
schema: "Membership",
table: "Creators",
type: "text",
nullable: false,
defaultValue: "");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "PortraitUrl",
schema: "Membership",
table: "Creators");
}
}
}

View File

@@ -0,0 +1,292 @@
// <auto-generated />
using System;
using Hutopy.Web.Features.Memberships.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.Memberships.Data.Migrations
{
[DbContext(typeof(MembershipDbContext))]
[Migration("20241216215210_UpdateSeedData")]
partial class UpdateSeedData
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("Membership")
.HasAnnotation("ProductVersion", "8.0.10")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Creator", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("PortraitUrl")
.IsRequired()
.HasColumnType("text");
b.Property<string>("StripeAccountId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Creators", "Membership");
});
modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Subscription", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid>("CreatorId")
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("EndDate")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("StartDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("StripeSessionId")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("StripeSubscriptionId")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<Guid>("TierId")
.HasColumnType("uuid");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("CreatorId");
b.HasIndex("TierId");
b.ToTable("Subscriptions", "Membership");
});
modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Tier", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid>("CreatorId")
.HasColumnType("uuid");
b.Property<string>("CurrencyCode")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("character varying(4096)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<decimal>("Price")
.HasColumnType("numeric");
b.Property<string>("StripePriceId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("StripeProductId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.HasKey("Id");
b.HasIndex("CreatorId");
b.ToTable("Tiers", "Membership");
});
modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Tip", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<decimal>("Amount")
.HasColumnType("numeric");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid>("CreatorId")
.HasColumnType("uuid");
b.Property<string>("CreatorName")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Currency")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Message")
.IsRequired()
.HasColumnType("text");
b.Property<string>("StripeSessionId")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("TipperId")
.HasColumnType("uuid");
b.Property<string>("TipperName")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("TransactionId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TransactionId");
b.ToTable("Tips", "Membership");
});
modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Transaction", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<decimal>("Amount")
.HasColumnType("numeric");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("Currency")
.IsRequired()
.HasColumnType("text");
b.Property<string>("StripeInvoiceUrl")
.HasColumnType("text");
b.Property<Guid?>("SubscriptionId")
.HasColumnType("uuid");
b.Property<DateTime>("Timestamp")
.HasColumnType("timestamp with time zone");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("SubscriptionId");
b.ToTable("Transactions", "Membership");
});
modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Subscription", b =>
{
b.HasOne("Hutopy.Web.Features.Memberships.Data.Creator", "Creator")
.WithMany()
.HasForeignKey("CreatorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Hutopy.Web.Features.Memberships.Data.Tier", "Tier")
.WithMany("Subscriptions")
.HasForeignKey("TierId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Creator");
b.Navigation("Tier");
});
modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Tier", b =>
{
b.HasOne("Hutopy.Web.Features.Memberships.Data.Creator", "Creator")
.WithMany()
.HasForeignKey("CreatorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Creator");
});
modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Tip", b =>
{
b.HasOne("Hutopy.Web.Features.Memberships.Data.Transaction", "Transaction")
.WithMany()
.HasForeignKey("TransactionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Transaction");
});
modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Transaction", b =>
{
b.HasOne("Hutopy.Web.Features.Memberships.Data.Subscription", null)
.WithMany("Transactions")
.HasForeignKey("SubscriptionId");
});
modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Subscription", b =>
{
b.Navigation("Transactions");
});
modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Tier", b =>
{
b.Navigation("Subscriptions");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,22 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Hutopy.Web.Features.Memberships.Data.Migrations
{
/// <inheritdoc />
public partial class UpdateSeedData : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}

View File

@@ -0,0 +1,289 @@
// <auto-generated />
using System;
using Hutopy.Web.Features.Memberships.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Hutopy.Web.Features.Memberships.Data.Migrations
{
[DbContext(typeof(MembershipDbContext))]
partial class MembershipDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("Membership")
.HasAnnotation("ProductVersion", "8.0.10")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Creator", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("PortraitUrl")
.IsRequired()
.HasColumnType("text");
b.Property<string>("StripeAccountId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Creators", "Membership");
});
modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Subscription", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid>("CreatorId")
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("EndDate")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("StartDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("StripeSessionId")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("StripeSubscriptionId")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<Guid>("TierId")
.HasColumnType("uuid");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("CreatorId");
b.HasIndex("TierId");
b.ToTable("Subscriptions", "Membership");
});
modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Tier", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid>("CreatorId")
.HasColumnType("uuid");
b.Property<string>("CurrencyCode")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("character varying(4096)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<decimal>("Price")
.HasColumnType("numeric");
b.Property<string>("StripePriceId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("StripeProductId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.HasKey("Id");
b.HasIndex("CreatorId");
b.ToTable("Tiers", "Membership");
});
modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Tip", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<decimal>("Amount")
.HasColumnType("numeric");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid>("CreatorId")
.HasColumnType("uuid");
b.Property<string>("CreatorName")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Currency")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Message")
.IsRequired()
.HasColumnType("text");
b.Property<string>("StripeSessionId")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("TipperId")
.HasColumnType("uuid");
b.Property<string>("TipperName")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("TransactionId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TransactionId");
b.ToTable("Tips", "Membership");
});
modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Transaction", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<decimal>("Amount")
.HasColumnType("numeric");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("Currency")
.IsRequired()
.HasColumnType("text");
b.Property<string>("StripeInvoiceUrl")
.HasColumnType("text");
b.Property<Guid?>("SubscriptionId")
.HasColumnType("uuid");
b.Property<DateTime>("Timestamp")
.HasColumnType("timestamp with time zone");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("SubscriptionId");
b.ToTable("Transactions", "Membership");
});
modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Subscription", b =>
{
b.HasOne("Hutopy.Web.Features.Memberships.Data.Creator", "Creator")
.WithMany()
.HasForeignKey("CreatorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Hutopy.Web.Features.Memberships.Data.Tier", "Tier")
.WithMany("Subscriptions")
.HasForeignKey("TierId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Creator");
b.Navigation("Tier");
});
modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Tier", b =>
{
b.HasOne("Hutopy.Web.Features.Memberships.Data.Creator", "Creator")
.WithMany()
.HasForeignKey("CreatorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Creator");
});
modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Tip", b =>
{
b.HasOne("Hutopy.Web.Features.Memberships.Data.Transaction", "Transaction")
.WithMany()
.HasForeignKey("TransactionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Transaction");
});
modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Transaction", b =>
{
b.HasOne("Hutopy.Web.Features.Memberships.Data.Subscription", null)
.WithMany("Transactions")
.HasForeignKey("SubscriptionId");
});
modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Subscription", b =>
{
b.Navigation("Transactions");
});
modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Tier", b =>
{
b.Navigation("Subscriptions");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,21 @@
using System.ComponentModel.DataAnnotations;
namespace Hutopy.Web.Features.Memberships.Data;
public class Subscription
{
public Guid Id { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public Guid UserId { get; set; }
public Guid CreatorId { get; set; }
public Creator Creator { get; set; }
public Guid TierId { get; set; }
public Tier Tier { get; set; }
public DateTimeOffset StartDate { get; set; }
public DateTimeOffset? EndDate { get; set; }
public bool IsActive => EndDate == null || EndDate > DateTimeOffset.UtcNow;
[MaxLength(255)]public string? StripeSessionId { get; set; }
[MaxLength(255)]public string? StripeSubscriptionId { get; set; }
public ICollection<Transaction> Transactions { get; set; } = [];
}

View File

@@ -0,0 +1,19 @@
using System.ComponentModel.DataAnnotations;
namespace Hutopy.Web.Features.Memberships.Data;
public class Tier
{
public Guid Id { get; set; }
public DateTime CreatedAt { get; set; }
public Guid CreatorId { get; set; }
public Creator Creator { get; set; } = null!;
[MaxLength(128)] public string Name { get; set; } = null!;
[MaxLength(4096)] public string Description { get; set; } = null!;
public decimal Price { get; set; }
[MaxLength(128)] public string CurrencyCode { get; set; } = null!;
[MaxLength(128)] public string StripeProductId { get; set; } = null!;
[MaxLength(128)] public string StripePriceId { get; set; } = null!;
public ICollection<Subscription> Subscriptions { get; set; } = [];
}

View File

@@ -0,0 +1,18 @@
namespace Hutopy.Web.Features.Memberships.Data;
public class Tip
{
public Guid Id { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public string StripeSessionId { get; set; }
public Guid TipperId { get; set; }
public string TipperName { get; set; }
public Guid CreatorId { get; set; }
public string CreatorName { get; set; }
public decimal Amount { get; set; }
public string Currency { get; set; }
public string Message { get; set; }
public Guid TransactionId { get; set; }
public Transaction Transaction { get; set; }
}

View File

@@ -0,0 +1,12 @@
namespace Hutopy.Web.Features.Memberships.Data;
public class Transaction
{
public Guid Id { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public decimal Amount { get; set; }
public string Currency { get; set; }
public string Type { get; set; } // Subscription, Tip
public DateTime Timestamp { get; set; }
public string? StripeInvoiceUrl { get; set; }
}

View File

@@ -0,0 +1,27 @@
using Hutopy.Web.Features.Memberships.Data;
using Hutopy.Web.Features.Memberships.Infrastructure;
namespace Hutopy.Web.Features.Memberships;
public static class DependencyInjection
{
public static WebApplicationBuilder AddMembershipModule(
this WebApplicationBuilder builder,
Action<DbContextOptionsBuilder>? configureAction = null)
{
builder.Services.AddSingleton<PushNotificationService>();
builder.Services.AddDbContext<MembershipDbContext>(configureAction);
builder.Services.AddScoped<MembershipDbContextInitializer>();
builder.Services.AddScoped<StripeService>();
builder.Services
.AddOptions<StripeOptions>()
.Bind(builder.Configuration.GetSection("Stripe"))
.ValidateDataAnnotations()
.ValidateOnStart();
return builder;
}
}

View File

@@ -0,0 +1,5 @@
namespace Hutopy.Web.Features.Memberships.Events;
public record StripeAccountConfigured(
Guid CreatorId,
string StripeAccountId);

View File

@@ -0,0 +1,7 @@
namespace Hutopy.Web.Features.Memberships.Events;
public record struct SubscriptionPaid(
Guid CreatorId,
string CreatorName,
string Tier,
DateTimeOffset Since);

View File

@@ -0,0 +1,8 @@
namespace Hutopy.Web.Features.Memberships.Events;
public record struct TipPaid(
Guid CreatorId,
string CreatorName,
decimal Amount,
string Currency,
string Message);

View File

@@ -0,0 +1,48 @@
using Hutopy.Web.Features.Memberships.Data;
using Hutopy.Web.Features.Memberships.Infrastructure;
namespace Hutopy.Web.Features.Memberships.Handlers;
[PublicAPI]
public class CancelSubscriptionRequest
{
public Guid SubscriptionId { get; set; }
}
public class CancelSubscriptionHandler(
MembershipDbContext dbContext,
StripeService stripeService)
: Endpoint<CancelSubscriptionRequest>
{
public override void Configure()
{
Post("/api/membership/unsubscribe");
Options(o => o.WithTags("Memberships"));
}
public override async Task HandleAsync(
CancelSubscriptionRequest req,
CancellationToken ct)
{
var subscription = await dbContext
.Subscriptions
.FindAsync(
[req.SubscriptionId],
cancellationToken: ct);
if (subscription is not { EndDate: null })
{
await SendNotFoundAsync(ct);
return;
}
// Cancel Stripe subscription
await stripeService.CancelSubscription(subscription.Id);
// Update subscription in the system
subscription.EndDate = DateTime.UtcNow;
await dbContext.SaveChangesAsync(ct);
await SendOkAsync(subscription.Id, ct);
}
}

View File

@@ -0,0 +1,55 @@
using Hutopy.Web.Common.Security;
using Hutopy.Web.Features.Memberships.Data;
using Hutopy.Web.Features.Memberships.Events;
namespace Hutopy.Web.Features.Memberships.Handlers;
[PublicAPI]
public record struct ChangeStripeIdRequest(
string StripeAccountId);
public class ChangeStripeIdHandler(
MembershipDbContext dbContext)
: Endpoint<ChangeStripeIdRequest>
{
public override void Configure()
{
Post("/api/membership/stripe-account");
Options(o => o.WithTags("Memberships"));
}
public override async Task HandleAsync(
ChangeStripeIdRequest req,
CancellationToken ct)
{
var creatorId = HttpContext.User.GetUserId();
var creator = await dbContext
.Creators
.FindAsync(
[creatorId],
cancellationToken: ct);
if (creator is null)
{
creator = new Creator
{
Id = creatorId,
Name = HttpContext.User.GetAlias() ?? creatorId.ToString(),
PortraitUrl = HttpContext.User.GetPortraitUrl() ?? string.Empty
};
await dbContext.AddAsync(creator, ct);
}
creator.StripeAccountId = req.StripeAccountId;
await dbContext.SaveChangesAsync(ct);
await PublishAsync(
new StripeAccountConfigured(creator.Id, creator.StripeAccountId),
cancellation: ct);
await SendOkAsync(creator.Id, ct);
}
}

View File

@@ -0,0 +1,56 @@
using Hutopy.Web.Features.Memberships.Data;
using Hutopy.Web.Features.Memberships.Infrastructure;
namespace Hutopy.Web.Features.Memberships.Handlers;
[PublicAPI]
public record struct CreateMembershipTierRequest(
Guid CreatorId,
string Name,
string Description,
decimal Price,
string Currency = "CAD");
[PublicAPI]
public class CreateMembershipTierEndpoint(
MembershipDbContext dbContext,
StripeService stripe)
: Endpoint<CreateMembershipTierRequest>
{
public override void Configure()
{
Post("/api/membership/tiers");
Options(o => o.WithTags("Memberships"));
}
public override async Task HandleAsync(
CreateMembershipTierRequest req,
CancellationToken ct)
{
var tierId = Guid.NewGuid();
var productId = await stripe.CreateProductAsync(
req.CreatorId,
tierId,
req.Name,
req.Currency,
req.Price);
// Record the new Tier
var tier = new Tier
{
Id = tierId,
CreatorId = req.CreatorId,
Price = req.Price,
Name = req.Name,
Description = req.Description,
StripeProductId = productId,
};
dbContext.Tiers.Add(tier);
await dbContext.SaveChangesAsync(ct);
await SendOkAsync(tier, ct);
}
}

View File

@@ -0,0 +1,44 @@
using Hutopy.Web.Common.Security;
using Hutopy.Web.Features.Memberships.Data;
namespace Hutopy.Web.Features.Memberships.Handlers;
[PublicAPI]
public record struct GetActiveSubscriptionsResponse(
Guid Id,
Guid CreatorId,
string CreatorName,
string CreatorPortraitUrl,
DateTimeOffset StartDate,
DateTimeOffset? EndDate);
[PublicAPI]
public class GetActiveSubscriptionsHandler(
MembershipDbContext dbContext)
: EndpointWithoutRequest<List<GetActiveSubscriptionsResponse>>
{
public override void Configure()
{
Get("/api/membership/active");
Options(o => o.WithTags("Memberships"));
}
public override async Task HandleAsync(
CancellationToken ct)
{
var subscriptions = await dbContext
.Subscriptions
.Where(subscription => subscription.UserId == User.GetUserId())
.Where(subscription => subscription.EndDate == null || subscription.EndDate > DateTimeOffset.UtcNow)
.Select(subscription => new GetActiveSubscriptionsResponse(
subscription.Id,
subscription.Creator.Id,
subscription.Creator.Name,
subscription.Creator.PortraitUrl,
subscription.StartDate,
subscription.EndDate))
.ToListAsync(ct);
await SendOkAsync(subscriptions, ct);
}
}

View File

@@ -0,0 +1,52 @@
using Hutopy.Web.Features.Memberships.Data;
namespace Hutopy.Web.Features.Memberships.Handlers;
[PublicAPI]
public record GetMembershipTierRequest
{
public Guid CreatorId { get; set; }
}
[PublicAPI]
public record struct TierModel(
Guid Id,
DateTime CreatedAt,
string Name,
string Description,
decimal Price,
string CurrencyCode,
string StripeProductId);
[PublicAPI]
public class GetMembershipTierEndpoint(
MembershipDbContext dbContext)
: Endpoint<GetMembershipTierRequest, List<TierModel>>
{
public override void Configure()
{
Get("/api/membership/tiers/{CreatorId:guid}");
Options(o => o.WithTags("Memberships"));
AllowAnonymous();
}
public override async Task HandleAsync(
GetMembershipTierRequest req,
CancellationToken ct)
{
var tiers = await dbContext
.Tiers
.Where(tier => tier.CreatorId == req.CreatorId)
.Select(tier => new TierModel(
tier.Id,
tier.CreatedAt,
tier.Name,
tier.Description,
tier.Price,
tier.CurrencyCode,
tier.StripeProductId))
.ToListAsync(ct);
await SendOkAsync(tiers, ct);
}
}

View File

@@ -0,0 +1,46 @@
using Hutopy.Web.Common;
using Hutopy.Web.Common.Security;
using Hutopy.Web.Features.Memberships.Data;
namespace Hutopy.Web.Features.Memberships.Handlers;
[PublicAPI]
public record struct TipReceivedModel(
Guid Id,
DateTimeOffset CreatedAt,
Guid TipperId,
string TipperName,
decimal Amount,
string Currency,
string Message);
[PublicAPI]
public class GetReceivedTipsHandler(
MembershipDbContext dbContext)
: EndpointWithoutRequest<List<TipReceivedModel>>
{
public override void Configure()
{
Get("/api/tips/received");
Options(o => o.WithTags("Memberships"));
}
public override async Task HandleAsync(
CancellationToken ct)
{
var tipsReceived = await dbContext
.Tips
.Where(tip => tip.CreatorId == User.GetUserId())
.Select(tip => new TipReceivedModel(
tip.Id,
tip.CreatedAt,
tip.TipperId,
tip.TipperName,
tip.Amount,
tip.Currency,
tip.Message))
.ToListAsync(ct);
await SendOkAsync(tipsReceived, ct);
}
}

View File

@@ -0,0 +1,46 @@
using Hutopy.Web.Common;
using Hutopy.Web.Common.Security;
using Hutopy.Web.Features.Memberships.Data;
namespace Hutopy.Web.Features.Memberships.Handlers;
[PublicAPI]
public record struct TipSentModel(
Guid Id,
DateTimeOffset CreatedAt,
Guid CreatorId,
string CreatorName,
decimal Amount,
string Currency,
string Message);
[PublicAPI]
public class GetSentTipsHandler(
MembershipDbContext dbContext)
: EndpointWithoutRequest<List<TipSentModel>>
{
public override void Configure()
{
Get("/api/tips/sent");
Options(o => o.WithTags("Memberships"));
}
public override async Task HandleAsync(
CancellationToken ct)
{
var tips = await dbContext
.Tips
.Where(t => t.TipperId == User.GetUserId())
.Select(tip => new TipSentModel(
tip.Id,
tip.CreatedAt,
tip.CreatorId,
tip.CreatorName,
tip.Amount,
tip.Currency,
tip.Message))
.ToListAsync(ct);
await SendOkAsync(tips, ct);
}
}

View File

@@ -0,0 +1,51 @@
using Hutopy.Web.Features.Memberships.Infrastructure;
using Microsoft.Extensions.Options;
using Stripe;
namespace Hutopy.Web.Features.Memberships.Handlers;
public class StripeWebhookEndpoint(
StripeService stripeService,
IOptions<StripeOptions> options)
: EndpointWithoutRequest
{
public override void Configure()
{
Post("/api/stripe");
AllowAnonymous();
Options(o => o.WithTags("Memberships"));
}
public override async Task HandleAsync(CancellationToken ct)
{
using var streamReader = new StreamReader(HttpContext.Request.Body);
var json = await streamReader.ReadToEndAsync(ct);
var signatureHeader = HttpContext.Request.Headers["Stripe-Signature"];
var stripeEvent = EventUtility.ConstructEvent(json, signatureHeader, options.Value.WebhookSecret);
switch (stripeEvent.Type)
{
case "checkout.session.completed":
await stripeService.HandleCheckoutSessionCompleted(stripeEvent, ct);
break;
case "invoice.payment_succeeded":
await stripeService.HandleInvoicePaymentSucceeded(stripeEvent, ct);
break;
case "invoice.payment_failed":
await stripeService.HandleInvoicePaymentFailed(stripeEvent, ct);
break;
case "customer.subscription.created":
await stripeService.HandleCustomerSubscriptionCreated(stripeEvent, ct);
break;
case "customer.subscription.updated":
await stripeService.HandleCustomerSubscriptionUpdated(stripeEvent, ct);
break;
case "customer.subscription.deleted":
await stripeService.HandleCustomerSubscriptionDeleted(stripeEvent, ct);
break;
}
await SendOkAsync(ct);
}
}

View File

@@ -0,0 +1,87 @@
using Hutopy.Web.Common.Security;
using Hutopy.Web.Features.Memberships.Data;
using Hutopy.Web.Features.Memberships.Infrastructure;
namespace Hutopy.Web.Features.Memberships.Handlers;
[PublicAPI]
public record SendTipRequest(
Guid CreatorId,
decimal Amount,
string Currency,
string Message,
string CheckoutSuccessUrl,
string CheckoutCancelledUrl);
[PublicAPI]
public record SendTipResponse(
string Status,
string StripeCheckoutUrl);
[PublicAPI]
public class SendTipRequestValidator : Validator<SendTipRequest>
{
public SendTipRequestValidator()
{
RuleFor(x => x.Amount)
.GreaterThan(0)
.WithMessage("Tip amount must be greater than 0");
RuleFor(x => x.CreatorId)
.NotEmpty()
.WithMessage("Creator ID is required");
RuleFor(x => x.CheckoutSuccessUrl)
.NotEmpty()
.WithMessage("CheckoutSuccessUrl is required");
RuleFor(x => x.CheckoutCancelledUrl)
.NotEmpty()
.WithMessage("CheckoutCancelledUrl is required");
}
}
[PublicAPI]
public class SendTipHandler(
MembershipDbContext dbContext,
StripeService stripeService)
: Endpoint<SendTipRequest, SendTipResponse>
{
public override void Configure()
{
Post("/api/tips");
Options(o => o.WithTags("Memberships"));
AllowAnonymous();
}
public override async Task HandleAsync(
SendTipRequest req,
CancellationToken ct)
{
var creator = await dbContext.Creators.FindAsync(
[req.CreatorId],
cancellationToken: ct);
if (creator == null)
{
await SendNotFoundAsync(ct);
return;
}
var checkoutSession = await stripeService.CreateTipCheckoutSessionAsync(
creator.Id,
creator.Name,
req.Amount,
req.Currency,
req.Message,
creator.StripeAccountId,
req.CheckoutSuccessUrl,
req.CheckoutCancelledUrl
);
await SendAsync(
new SendTipResponse("Pending", checkoutSession.Url),
cancellation: ct);
}
}

View File

@@ -0,0 +1,72 @@
using Hutopy.Web.Common.Security;
using Hutopy.Web.Features.Memberships.Data;
using Hutopy.Web.Features.Memberships.Infrastructure;
namespace Hutopy.Web.Features.Memberships.Handlers;
[PublicAPI]
public class SubscribeRequest
{
public Guid CreatorId { get; set; }
public Guid TierId { get; set; }
public required string CheckoutSuccessUrl { get; init; }
public required string CheckoutCancelledUrl { get; init; }
}
[PublicAPI]
public record struct SubscriptionResponse(
string StripeCheckoutUrl);
[PublicAPI]
public class SubscribeValidator : Validator<SubscribeRequest>
{
public SubscribeValidator()
{
RuleFor(x => x.TierId).NotEmpty();
}
}
[PublicAPI]
public class SubscribeHandler(
MembershipDbContext dbContext,
StripeService stripeService)
: Endpoint<SubscribeRequest, SubscriptionResponse>
{
public override void Configure()
{
Post("/api/membership/subscribe");
Options(o => o.WithTags("Memberships"));
}
public override async Task HandleAsync(
SubscribeRequest req,
CancellationToken ct)
{
var tier = await dbContext
.Tiers
.Include(tier => tier.Creator) // Include the related table
.Where(tier => tier.Id == req.TierId)
.FirstOrDefaultAsync(ct);
if (tier == null)
{
await SendNotFoundAsync(ct);
return;
}
// Process Stripe subscription
var checkoutSession = await stripeService.CreateSubscriptionCheckoutSession(
User.GetUserId(),
tier.Creator.Id,
tier.Creator.Name,
tier.Creator.StripeAccountId,
tier.Id,
tier.StripePriceId,
req.CheckoutSuccessUrl,
req.CheckoutCancelledUrl);
await SendOkAsync(
new SubscriptionResponse { StripeCheckoutUrl = checkoutSession.Url },
cancellation: ct);
}
}

View File

@@ -0,0 +1,13 @@
namespace Hutopy.Web.Features.Memberships.Infrastructure;
public sealed class PushNotificationService(
ILogger<PushNotificationService> logger)
{
public void NotifyCreator<TEvent>(
Guid tipCreatorId,
TEvent notification)
where TEvent : struct
{
logger.LogInformation("Notifying creator {CreatorId}, {Notification}", tipCreatorId, notification);
}
}

View File

@@ -0,0 +1,428 @@
using System.ComponentModel.DataAnnotations;
using Hutopy.Web.Features.Memberships.Data;
using Hutopy.Web.Features.Memberships.Events;
using Microsoft.Extensions.Options;
using Stripe;
using Stripe.Checkout;
using Subscription = Stripe.Subscription;
namespace Hutopy.Web.Features.Memberships.Infrastructure;
public class StripeOptions
{
[Required] public required string SecretKey { get; init; }
[Required] public required string WebhookSecret { get; init; }
[Required] [Range(0, 1)] public required decimal HutopyRate { get; init; }
}
public sealed class StripeService(
IOptions<StripeOptions> paymentOptions,
MembershipDbContext dbContext,
PushNotificationService notificationService)
{
public async Task<string> CreateProductAsync(
Guid creatorId,
Guid tierId,
string productName,
string currencyCode,
decimal amount)
{
StripeConfiguration.ApiKey = paymentOptions.Value.SecretKey;
// Create the product
var productService = new ProductService();
var product = await productService.CreateAsync(
new ProductCreateOptions
{
Name = productName,
Metadata = { { "creatorId", creatorId.ToString() }, { "tierId", tierId.ToString() } }
});
// Create the price for the product
var priceService = new PriceService();
await priceService.CreateAsync(
new PriceCreateOptions
{
Product = product.Id,
UnitAmountDecimal = amount * 100, // Convert amount to cents
Currency = currencyCode,
Recurring = new PriceRecurringOptions { Interval = "month" }
});
return product.Id;
}
public async Task<Session> CreateTipCheckoutSessionAsync(
Guid creatorId,
string creatorName,
decimal amount,
string currencyCode,
string message,
string creatorAccountId,
string successUrl,
string cancelUrl,
CancellationToken ct = default)
{
StripeConfiguration.ApiKey = paymentOptions.Value.SecretKey;
// Create Stripe customer for the user if not already created
var customerService = new CustomerService();
var customer = await customerService.CreateAsync(
new CustomerCreateOptions{},
cancellationToken: ct);
// Create paymentIntent for the user
var sessionService = new SessionService();
return await sessionService.CreateAsync(
new SessionCreateOptions
{
Customer = customer.Id,
PaymentMethodTypes = ["card"],
LineItems =
[
new SessionLineItemOptions
{
PriceData = new SessionLineItemPriceDataOptions
{
Currency = currencyCode,
UnitAmountDecimal = amount, // Amount in cents
ProductData = new SessionLineItemPriceDataProductDataOptions
{
Name = $"Tip for {creatorName}", // or any descriptive name for the tip
Metadata = new Dictionary<string, string> { { "creatorId", creatorId.ToString() } }
}
},
Quantity = 1
}
],
Mode = "payment",
PaymentIntentData = new SessionPaymentIntentDataOptions
{
ApplicationFeeAmount =
Convert.ToInt64(amount * 100 * paymentOptions.Value.HutopyRate), // Platform fee
TransferData = new SessionPaymentIntentDataTransferDataOptions
{
Destination = creatorAccountId // Creator's Stripe account ID
}
},
SuccessUrl = successUrl, // Redirect after successful payment
CancelUrl = cancelUrl, // Redirect after canceled payment
Metadata = new Dictionary<string, string>
{
{ "creatorId", creatorId.ToString() },
{ "creatorName", creatorName },
{ "message", message },
}
},
cancellationToken: ct);
}
public async Task<Session> CreateSubscriptionCheckoutSession(
Guid userId,
Guid creatorId,
string creatorName,
string creatorAccountId,
Guid tierId,
string priceId,
string successUrl,
string cancelUrl)
{
StripeConfiguration.ApiKey = paymentOptions.Value.SecretKey;
// Create Stripe customer for the user if not already created
var customerService = new CustomerService();
var customer = await customerService.CreateAsync(
new CustomerCreateOptions
{
Metadata = new Dictionary<string, string> { { "userId", userId.ToString() } }
});
// Create Checkout Session for the subscription
var sessionService = new SessionService();
return await sessionService.CreateAsync(
new SessionCreateOptions
{
Customer = customer.Id,
PaymentMethodTypes = ["card"],
LineItems =
[
new SessionLineItemOptions { Price = priceId, Quantity = 1 }
],
Mode = "subscription",
SubscriptionData = new SessionSubscriptionDataOptions
{
ApplicationFeePercent = paymentOptions.Value.HutopyRate,
TransferData = new SessionSubscriptionDataTransferDataOptions { Destination = creatorAccountId }
},
SuccessUrl = successUrl, // Redirect after successful payment
CancelUrl = cancelUrl, // Redirect after canceled payment
Metadata = new Dictionary<string, string>
{
{ "userId", userId.ToString() },
{ "creatorId", creatorId.ToString() },
{ "creatorName", creatorName },
{ "tierId", tierId.ToString() }
}
});
}
public async Task CancelSubscription(
Guid subscriptionId)
{
var subscriptionService = new SubscriptionService();
await subscriptionService.CancelAsync(subscriptionId.ToString());
}
public async Task HandleInvoicePaymentSucceeded(
Event stripeEvent,
CancellationToken ct = default)
{
// Ensure we have an invoice related to a Subscription
if (stripeEvent.Data.Object is not Invoice { Subscription: not null } invoice)
{
return;
}
var subscription = await dbContext
.Subscriptions
.FirstOrDefaultAsync(
subscription => subscription.StripeSubscriptionId == invoice.Subscription.Id,
cancellationToken: ct);
if (subscription == null)
{
return;
}
// Record the Transaction
var transaction = new Transaction
{
Id = Guid.NewGuid(),
CreatedAt = DateTimeOffset.UtcNow,
Amount = invoice.AmountPaid / 100m, // Convert amount from cents to dollars
Currency = invoice.Currency,
Type = "Subscription",
Timestamp = DateTime.UtcNow,
StripeInvoiceUrl = invoice.HostedInvoiceUrl
};
dbContext.Transactions.Add(transaction);
// Link the Transaction to the Subscription
subscription.Transactions.Add(transaction);
await dbContext.SaveChangesAsync(ct);
}
public async Task HandleInvoicePaymentFailed(
Event stripeEvent,
CancellationToken ct = default)
{
if (stripeEvent.Data.Object is not Invoice { Subscription: not null } invoice)
{
return;
}
var subscription = await dbContext
.Subscriptions
.SingleOrDefaultAsync(
subscription => subscription.StripeSubscriptionId == invoice.SubscriptionId,
cancellationToken: ct);
if (subscription != null)
{
subscription.EndDate = DateTimeOffset.UtcNow; // Mark as expired or failed
await dbContext.SaveChangesAsync(ct);
}
}
private async Task HandleTipPayment(
Session session,
CancellationToken ct)
{
// Record the Tip
var tip = new Tip
{
Id = Guid.NewGuid(),
CreatedAt = DateTimeOffset.UtcNow,
StripeSessionId = session.Id,
TipperId = Guid.Parse(session.Metadata["tipperId"]),
TipperName = session.Metadata["tipperName"],
CreatorId = Guid.Parse(session.Metadata["creatorId"]),
CreatorName = session.Metadata["creatorName"],
Amount = session.AmountTotal!.Value / 100m,
Currency = session.Currency,
Message = session.Metadata["message"]
};
dbContext.Tips.Add(tip);
// Record the Transaction
var transaction = new Transaction
{
Id = Guid.NewGuid(),
CreatedAt = DateTimeOffset.UtcNow,
Amount = tip.Amount,
Currency = tip.Currency,
Type = "Tip",
Timestamp = DateTime.UtcNow,
// TODO: __StripeInvoiceUrl = session.Invoice.HostedInvoiceUrl__ How come nor Invoice or InvoiceId are set.
};
dbContext.Transactions.Add(transaction);
// Link the Transaction to the Tip
tip.TransactionId = transaction.Id;
// Save the changes
await dbContext.SaveChangesAsync(ct);
// Notify the Creator
notificationService.NotifyCreator(
tip.CreatorId,
new TipPaid(
tip.CreatorId,
tip.CreatorName,
tip.Amount,
tip.Currency,
tip.Message)
);
}
private async Task HandleSubscriptionPayment(
Session session,
CancellationToken ct)
{
// Record the Subscription
var subscription = new Data.Subscription
{
Id = Guid.NewGuid(),
CreatedAt = DateTimeOffset.UtcNow,
UserId = Guid.Parse(session.Metadata["userId"]),
CreatorId = Guid.Parse(session.Metadata["creatorId"]),
TierId = Guid.Parse(session.Metadata["tierId"]),
StartDate = DateTimeOffset.UtcNow,
StripeSessionId = session.Id,
StripeSubscriptionId = session.SubscriptionId
};
dbContext.Subscriptions.Add(subscription);
// Record the Transaction
var transaction = new Transaction
{
Id = Guid.NewGuid(),
CreatedAt = DateTimeOffset.UtcNow,
Amount = session.AmountTotal!.Value / 100m, // Convert amount from cents to dollars
Currency = session.Currency,
Type = "Subscription",
Timestamp = DateTime.UtcNow,
// TODO: __StripeInvoiceUrl = session.Invoice.HostedInvoiceUrl__ How come nor Invoice or InvoiceId are set.
};
dbContext.Transactions.Add(transaction);
// Link the Transaction to the Subscription
subscription.Transactions.Add(transaction);
// Save the changes
await dbContext.SaveChangesAsync(ct);
// Notify the Creator
notificationService.NotifyCreator(
subscription.CreatorId,
new SubscriptionPaid(
subscription.CreatorId,
session.Metadata["creatorName"],
subscription.TierId.ToString(),
subscription.StartDate)
);
}
public async Task HandleCheckoutSessionCompleted(
Event stripeEvent,
CancellationToken ct = default)
{
if (stripeEvent.Data.Object is not Session session)
{
return;
}
switch (session.Mode)
{
// Check if this is a one-time tip
case "payment" when session.PaymentIntentId != null:
await HandleTipPayment(session, ct);
break;
// Check if this is a subscription
case "subscription" when session.SubscriptionId != null:
await HandleSubscriptionPayment(session, ct);
break;
}
}
public async Task HandleCustomerSubscriptionCreated(
Event stripeEvent,
CancellationToken ct)
{
if (stripeEvent.Data.Object is not Subscription stripeSubscription)
return;
var subscription = await dbContext
.Subscriptions
.SingleOrDefaultAsync(
subscription => subscription.StripeSubscriptionId == stripeSubscription.Id,
cancellationToken: ct);
if (subscription != null)
{
subscription.StartDate = stripeSubscription.CurrentPeriodStart;
subscription.EndDate = null; // Active subscription
await dbContext.SaveChangesAsync(ct);
}
}
public async Task HandleCustomerSubscriptionUpdated(
Event stripeEvent,
CancellationToken ct)
{
if (stripeEvent.Data.Object is Subscription stripeSubscription)
{
var subscription = await dbContext
.Subscriptions
.SingleOrDefaultAsync(
s => s.StripeSubscriptionId == stripeSubscription.Id,
cancellationToken: ct);
if (subscription != null)
{
subscription.StartDate = stripeSubscription.CurrentPeriodStart;
subscription.EndDate = null; // Active subscription
await dbContext.SaveChangesAsync(ct);
}
}
}
public async Task HandleCustomerSubscriptionDeleted(
Event stripeEvent,
CancellationToken ct)
{
var subscription = stripeEvent.Data.Object as Subscription;
var existingSubscription = await dbContext
.Subscriptions
.FirstOrDefaultAsync(x => x.StripeSubscriptionId == subscription!.Id, ct);
if (existingSubscription != null)
{
var today = DateTime.Today;
int lastDay = DateTime.DaysInMonth(today.Year, today.Month);
var lastDayOfMonth = new DateTime(today.Year, today.Month, lastDay);
existingSubscription.EndDate = new DateTimeOffset(lastDayOfMonth);
await dbContext.SaveChangesAsync(ct);
}
}
}

View File

@@ -0,0 +1,15 @@
using System.ComponentModel.DataAnnotations;
namespace Hutopy.Web.Features.Messages.Data;
public class Message
{
public Guid Id { get; set; }
public Guid SubjectId { get; set; }
public Guid CreatedBy { get; set; }
[MaxLength(255)] public required string CreatedByName { get; set; }
[MaxLength(255)] public string? CreatedByPortraitUrl { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public Guid? ParentId { get; set; }
[MaxLength(2048)] public required string Value { get; set; }
}

View File

@@ -0,0 +1,76 @@
using Hutopy.Web.Features.Messages.Handlers.Models;
namespace Hutopy.Web.Features.Messages.Data;
public class MessagingDbContext(
DbContextOptions<MessagingDbContext> options)
: DbContext(options)
{
public const string SchemaName = "Messaging";
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasDefaultSchema(SchemaName);
modelBuilder
.Entity<Message>()
.Property(c => c.CreatedAt)
.ValueGeneratedOnAdd()
.HasDefaultValueSql("CURRENT_TIMESTAMP");
}
public DbSet<Message> Messages { get; set; }
public async Task<List<MessageDto>> GetMessagesAsync(
Guid subjectId,
Guid? parentId,
Guid? lastId,
int pageSize,
CancellationToken ct = default)
{
var query = Messages
.Where(c => c.SubjectId == subjectId)
.Where(c => c.ParentId == parentId);
if (lastId.HasValue)
{
var lastMessage = await Messages
.Where(c => c.Id == lastId.Value)
.Select(c => new { c.CreatedAt, c.Id })
.FirstOrDefaultAsync(cancellationToken: ct);
if (lastMessage != null)
{
query = query
.Where(c => c.CreatedAt < lastMessage.CreatedAt
|| (c.CreatedAt == lastMessage.CreatedAt && c.Id < lastMessage.Id));
}
}
var messages = await query
.OrderByDescending(c => c.CreatedAt)
.ThenByDescending(c => c.Id)
.Take(pageSize)
.Select(message => message.ToDto())
.ToListAsync(cancellationToken: ct);
return messages;
}
public async Task<int> GetMessageCountAsync(
Guid subjectId,
Guid? parentId,
int pageSize,
CancellationToken ct = default)
{
var query = Messages
.Where(c => c.SubjectId == subjectId)
.Where(c => c.ParentId == parentId);
var messageCount = await query
.Take(pageSize)
.CountAsync(ct);
return messageCount;
}
}

View File

@@ -0,0 +1,32 @@
namespace Hutopy.Web.Features.Messages.Data;
public static class InitializerExtensions
{
public static async Task InitialiseMessagingDbContextAsync(this WebApplication app)
{
using var scope = app.Services.CreateScope();
var initializer = scope.ServiceProvider.GetRequiredService<MessagingDbContextInitializer>();
await initializer.InitialiseAsync();
}
}
public class MessagingDbContextInitializer(
ILogger<MessagingDbContextInitializer> logger,
MessagingDbContext context
)
{
public async Task InitialiseAsync()
{
try
{
await context.Database.MigrateAsync();
}
catch (Exception ex)
{
logger.LogError(ex, "An error occurred while initialising the messaging database.");
throw;
}
}
}

View File

@@ -0,0 +1,69 @@
// <auto-generated />
using System;
using Hutopy.Web.Features.Messages.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.Messages.Migrations
{
[DbContext(typeof(MessagingDbContext))]
[Migration("20240805012343_Initial")]
partial class Initial
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("Messaging")
.HasAnnotation("ProductVersion", "8.0.4")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Hutopy.Web.Features.Messages.Data.Message", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid>("CreatedBy")
.HasColumnType("uuid");
b.Property<string>("CreatedByName")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("CreatedByPortraitUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<Guid?>("ParentId")
.HasColumnType("uuid");
b.Property<Guid>("SubjectId")
.HasColumnType("uuid");
b.Property<string>("Value")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Messages", "Messaging");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,45 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Hutopy.Web.Features.Messages.Migrations
{
/// <inheritdoc />
public partial class Initial : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.EnsureSchema(
name: "Messaging");
migrationBuilder.CreateTable(
name: "Messages",
schema: "Messaging",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
SubjectId = table.Column<Guid>(type: "uuid", nullable: false),
CreatedBy = table.Column<Guid>(type: "uuid", nullable: false),
CreatedByName = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
CreatedByPortraitUrl = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"),
ParentId = table.Column<Guid>(type: "uuid", nullable: true),
Value = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Messages", x => x.Id);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Messages",
schema: "Messaging");
}
}
}

View File

@@ -0,0 +1,70 @@
// <auto-generated />
using System;
using Hutopy.Web.Features.Messages.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.Messages.Migrations
{
[DbContext(typeof(MessagingDbContext))]
[Migration("20241217225954_ChangeStripeId")]
partial class ChangeStripeId
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("Messaging")
.HasAnnotation("ProductVersion", "8.0.10")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Hutopy.Web.Features.Messages.Data.Message", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid>("CreatedBy")
.HasColumnType("uuid");
b.Property<string>("CreatedByName")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("CreatedByPortraitUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<Guid?>("ParentId")
.HasColumnType("uuid");
b.Property<Guid>("SubjectId")
.HasColumnType("uuid");
b.Property<string>("Value")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.HasKey("Id");
b.ToTable("Messages", "Messaging");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,38 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Hutopy.Web.Features.Messages.Migrations
{
/// <inheritdoc />
public partial class ChangeStripeId : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "Value",
schema: "Messaging",
table: "Messages",
type: "character varying(2048)",
maxLength: 2048,
nullable: false,
oldClrType: typeof(string),
oldType: "text");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "Value",
schema: "Messaging",
table: "Messages",
type: "text",
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(2048)",
oldMaxLength: 2048);
}
}
}

View File

@@ -0,0 +1,67 @@
// <auto-generated />
using System;
using Hutopy.Web.Features.Messages.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Hutopy.Web.Features.Messages.Migrations
{
[DbContext(typeof(MessagingDbContext))]
partial class MessagingDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("Messaging")
.HasAnnotation("ProductVersion", "8.0.10")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Hutopy.Web.Features.Messages.Data.Message", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid>("CreatedBy")
.HasColumnType("uuid");
b.Property<string>("CreatedByName")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("CreatedByPortraitUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<Guid?>("ParentId")
.HasColumnType("uuid");
b.Property<Guid>("SubjectId")
.HasColumnType("uuid");
b.Property<string>("Value")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.HasKey("Id");
b.ToTable("Messages", "Messaging");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,16 @@
using Hutopy.Web.Features.Messages.Data;
namespace Hutopy.Web.Features.Messages;
public static class DependencyInjection
{
public static WebApplicationBuilder AddMessagingModule(
this WebApplicationBuilder builder,
Action<DbContextOptionsBuilder>? configureAction = null)
{
builder.Services.AddDbContext<MessagingDbContext>(configureAction);
builder.Services.AddScoped<MessagingDbContextInitializer>();
return builder;
}
}

View File

@@ -0,0 +1,57 @@
using Hutopy.Web.Common;
using Hutopy.Web.Common.Security;
using Hutopy.Web.Features.Messages.Data;
namespace Hutopy.Web.Features.Messages.Handlers;
public sealed class AddMessageRequest
{
public Guid? Id { get; set; }
public required Guid SubjectId { get; set; }
public required string Message { get; set; }
}
internal sealed class AddMessageRequestValidator
: Validator<AddMessageRequest>
{
public AddMessageRequestValidator()
{
RuleFor(r => r.SubjectId)
.NotNull().WithMessage("You must specify a SubjectId")
.NotEmpty().WithMessage("You must specify a non-empty SubjectId");
RuleFor(r => r.Message)
.NotNull().WithMessage("You must specify a Message")
.NotEmpty().WithMessage("You must specify a non-empty Message");
}
}
public class AddMessage(
MessagingDbContext context)
: Endpoint<AddMessageRequest>
{
public override void Configure()
{
Post("/api/messages");
Options(o => o.WithTags("Messages"));
}
public override async Task HandleAsync(
AddMessageRequest req,
CancellationToken ct)
{
var message = new Message
{
Id = req.Id ?? GuidHelper.GenerateUuidV7(),
SubjectId = req.SubjectId,
CreatedBy = User.GetUserId(),
CreatedByName = User.GetAlias() ?? $"{User.GetFirstName()} {User.GetLastName()}",
CreatedByPortraitUrl = User.GetPortraitUrl(),
Value = req.Message
};
await context.Messages.AddAsync(message, ct);
await context.SaveChangesAsync(ct);
}
}

View File

@@ -0,0 +1,62 @@
using Hutopy.Web.Common;
using Hutopy.Web.Common.Security;
using Hutopy.Web.Features.Messages.Data;
namespace Hutopy.Web.Features.Messages.Handlers;
public sealed class AddReplyRequest
{
public Guid? Id { get; set; }
public required Guid ParentId { get; set; }
public required Guid SubjectId { get; set; }
public required string Message { get; set; }
}
internal sealed class AddReplyRequestValidator
: Validator<AddReplyRequest>
{
public AddReplyRequestValidator()
{
RuleFor(r => r.ParentId)
.NotNull().WithMessage("You must specify a ParentId")
.NotEmpty().WithMessage("You must specify a non-empty ParentId");
RuleFor(r => r.SubjectId)
.NotNull().WithMessage("You must specify a SubjectId")
.NotEmpty().WithMessage("You must specify a non-empty SubjectId");
RuleFor(r => r.Message)
.NotNull().WithMessage("You must specify a Message")
.NotEmpty().WithMessage("You must specify a non-empty Message");
}
}
internal sealed class AddReply(
MessagingDbContext context)
: Endpoint<AddReplyRequest>
{
public override void Configure()
{
Post("/api/messages/{ParentId:guid}/replies");
Options(o => o.WithTags("Messages"));
}
public override async Task HandleAsync(
AddReplyRequest req,
CancellationToken ct)
{
var message = new Message
{
Id = GuidHelper.GenerateUuidV7(),
SubjectId = req.SubjectId,
ParentId = req.ParentId,
CreatedBy = User.GetUserId(),
CreatedByName = User.GetName(),
Value = req.Message
};
await context.Messages.AddAsync(message, ct);
await context.SaveChangesAsync(ct);
}
}

View File

@@ -0,0 +1,65 @@
using Hutopy.Web.Common;
using Hutopy.Web.Common.Security;
using Hutopy.Web.Features.Messages.Data;
namespace Hutopy.Web.Features.Messages.Handlers;
public sealed class ChangeMessageRequest
{
public Guid? Id { get; set; }
public required Guid SubjectId { get; set; }
public required string Message { get; set; }
}
internal sealed class ChangeMessageRequestValidator
: Validator<ChangeMessageRequest>
{
public ChangeMessageRequestValidator()
{
RuleFor(r => r.SubjectId)
.NotNull().WithMessage("You must specify a SubjectId")
.NotEmpty().WithMessage("You must specify a non-empty SubjectId");
RuleFor(r => r.Message)
.NotNull().WithMessage("You must specify a Message")
.NotEmpty().WithMessage("You must specify a non-empty Message");
}
}
public class ChangeMessage(
MessagingDbContext context)
: Endpoint<ChangeMessageRequest>
{
public override void Configure()
{
Post("/api/messages/update");
Options(o => o.WithTags("Messages"));
}
public override async Task HandleAsync(
ChangeMessageRequest req,
CancellationToken ct)
{
var message = await context.Messages.FirstOrDefaultAsync(x => x.Id == req.Id, ct);
if (message is null)
{
await SendNotFoundAsync(ct);
return;
}
var userId = HttpContext.User.GetUserId();
if (message.CreatedBy != userId)
{
await SendForbiddenAsync(ct);
return;
}
message.SubjectId = req.SubjectId;
message.Value = req.Message;
context.Update(message);
await context.SaveChangesAsync(ct);
}
}

View File

@@ -0,0 +1,53 @@
using Hutopy.Web.Common;
using Hutopy.Web.Common.Security;
using Hutopy.Web.Features.Messages.Data;
namespace Hutopy.Web.Features.Messages.Handlers;
public record DeleteMessageRequest(Guid MessageId);
internal sealed class DeleteMessageRequestValidator
: Validator<DeleteMessageRequest>
{
public DeleteMessageRequestValidator()
{
RuleFor(r => r.MessageId)
.NotNull().WithMessage("You must specify a MessageId")
.NotEmpty().WithMessage("You must specify a non-empty MessageId");
}
}
public class DeleteMessage(
MessagingDbContext context)
: Endpoint<DeleteMessageRequest>
{
public override void Configure()
{
Delete("/api/messages/{MessageId}");
Options(o => o.WithTags("Messages"));
}
public override async Task HandleAsync(
DeleteMessageRequest req,
CancellationToken ct)
{
var message = await context.Messages.FirstOrDefaultAsync(x => x.Id == req.MessageId, ct);
if (message is null)
{
await SendNotFoundAsync(ct);
return;
}
var userId = HttpContext.User.GetUserId();
if (message.CreatedBy != userId)
{
await SendForbiddenAsync(ct);
return;
}
context.Messages.Remove(message);
await context.SaveChangesAsync(ct);
}
}

View File

@@ -0,0 +1,44 @@
using Hutopy.Web.Features.Messages.Data;
namespace Hutopy.Web.Features.Messages.Handlers;
public sealed class GetMessageCountRequest
{
public Guid SubjectId { get; set; }
[BindFrom("page_size")] public int PageSize { get; set; } = 1000;
}
public record struct GetMessageCountResponse
{
public required int Count { get; init; }
}
public class GetMessageCount(
MessagingDbContext context)
: Endpoint<GetMessageCountRequest, GetMessageCountResponse>
{
public override void Configure()
{
Get("/api/message-count/{SubjectId:guid}");
Options(o => o.WithTags("Messages"));
AllowAnonymous();
}
public override async Task HandleAsync(
GetMessageCountRequest req,
CancellationToken ct)
{
var messageCount = await context.GetMessageCountAsync(
req.SubjectId,
null,
req.PageSize,
ct);
await SendAsync(
new()
{
Count = messageCount
},
cancellation: ct);
}
}

View File

@@ -0,0 +1,47 @@
using Hutopy.Web.Features.Messages.Data;
using Hutopy.Web.Features.Messages.Handlers.Models;
namespace Hutopy.Web.Features.Messages.Handlers;
public sealed class GetMessagesRequest
{
public Guid SubjectId { get; set; }
[BindFrom("page_size")] public int PageSize { get; set; } = 10;
[BindFrom("last_id")] public Guid? LastId { get; set; }
}
public record struct GetMessagesResponse
{
public required List<MessageDto> Messages { get; init; }
}
public class GetMessages(
MessagingDbContext context)
: Endpoint<GetMessagesRequest, GetMessagesResponse>
{
public override void Configure()
{
Get("/api/messages/{SubjectId:guid}");
Options(o => o.WithTags("Messages"));
AllowAnonymous();
}
public override async Task HandleAsync(
GetMessagesRequest req,
CancellationToken ct)
{
var messages = await context.GetMessagesAsync(
req.SubjectId,
null,
req.LastId,
req.PageSize,
ct);
await SendAsync(
new()
{
Messages = messages
},
cancellation: ct);
}
}

View File

@@ -0,0 +1,46 @@
using Hutopy.Web.Features.Messages.Data;
using Hutopy.Web.Features.Messages.Handlers.Models;
namespace Hutopy.Web.Features.Messages.Handlers;
public class GetMessagesByUserRequest
{
public Guid UserId { get; set; }
}
public record struct GetMessagesByUserResponse
{
public required List<MessageDto> Messages { get; init; }
}
public class GetMessagesByUser(
MessagingDbContext context)
: Endpoint<GetMessagesByUserRequest, GetMessagesByUserResponse>
{
public override void Configure()
{
Get("/api/messages/user/{UserId:guid}");
Options(o => o.WithTags("Messages"));
AllowAnonymous();
}
public override async Task HandleAsync(
GetMessagesByUserRequest req,
CancellationToken ct)
{
var messages = await context
.Messages
.Where(c => c.CreatedBy == req.UserId)
.Where(c => c.ParentId == null)
.ToListAsync(cancellationToken: ct);
await SendAsync(
new()
{
Messages = messages
.Select(message => message.ToDto())
.ToList()
},
cancellation: ct);
}
}

View File

@@ -0,0 +1,48 @@
using Hutopy.Web.Features.Messages.Data;
using Hutopy.Web.Features.Messages.Handlers.Models;
namespace Hutopy.Web.Features.Messages.Handlers;
public class GetRepliesRequest
{
public Guid SubjectId { get; set; }
public Guid ParentId { get; set; }
[BindFrom("page_size")] public int PageSize { get; set; } = 10;
[BindFrom("last_id")] public Guid? LastId { get; set; }
}
public record struct GetRepliesResponse
{
public required List<MessageDto> Messages { get; init; }
}
public class GetReplies(
MessagingDbContext context)
: Endpoint<GetRepliesRequest, GetRepliesResponse>
{
public override void Configure()
{
Get("/api/messages/{ParentId:guid}/replies");
Options(o => o.WithTags("Messages"));
AllowAnonymous();
}
public override async Task HandleAsync(
GetRepliesRequest req,
CancellationToken ct)
{
var replies = await context.GetMessagesAsync(
req.SubjectId,
req.ParentId,
req.LastId,
req.PageSize,
ct);
await SendAsync(
new()
{
Messages = replies,
},
cancellation: ct);
}
}

View File

@@ -0,0 +1,30 @@
using Hutopy.Web.Features.Messages.Data;
namespace Hutopy.Web.Features.Messages.Handlers.Models;
public record struct MessageDto(
Guid Id,
Guid SubjectId,
Guid CreatedBy,
string CreatedByName,
string? CreatedByPortraitUrl,
DateTimeOffset CreatedAt,
Guid? ParentId,
string Value
);
public static class MessageExtensions
{
public static MessageDto ToDto(this Message message) =>
new()
{
Id = message.Id,
ParentId = message.ParentId,
CreatedAt = message.CreatedAt,
CreatedBy = message.CreatedBy,
CreatedByName = message.CreatedByName,
CreatedByPortraitUrl = message.CreatedByPortraitUrl,
SubjectId = message.SubjectId,
Value = message.Value
};
}

View File

@@ -0,0 +1,19 @@
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
namespace Hutopy.Web.Features.Users.Data
{
public class IdentityDbContext(
DbContextOptions<IdentityDbContext> options)
: IdentityDbContext<IdentityUser, IdentityRole, Guid>(options)
{
public const string SchemaName = "Identity";
protected override void OnModelCreating(ModelBuilder
modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.HasDefaultSchema(SchemaName);
}
}
}

View File

@@ -0,0 +1,64 @@
using Microsoft.AspNetCore.Identity;
namespace Hutopy.Web.Features.Users.Data;
public static class InitializerExtensions
{
public static async Task InitialiseIdentityDatabaseAsync(this WebApplication app)
{
using var scope = app.Services.CreateScope();
var initializer = scope.ServiceProvider.GetRequiredService<IdentityDbContextInitializer>();
await initializer.InitialiseAsync();
await initializer.SeedAsync();
}
}
public class IdentityDbContextInitializer(
ILogger<IdentityDbContextInitializer> logger,
IdentityDbContext context,
RoleManager<IdentityRole> roleManager)
{
public async Task InitialiseAsync()
{
try
{
await context.Database.MigrateAsync();
}
catch (Exception ex)
{
logger.LogError(ex, "An error occurred while initialising the database.");
throw;
}
}
public async Task SeedAsync()
{
try
{
await TrySeedAsync();
}
catch (Exception ex)
{
logger.LogError(ex, "An error occurred while seeding the database.");
throw;
}
}
private async Task TrySeedAsync()
{
var administratorRole = new IdentityRole(KnownRoles.Administrator);
if (roleManager.Roles.All(r => r.Name != administratorRole.Name))
{
await roleManager.CreateAsync(administratorRole);
}
var roleCreator = new IdentityRole(KnownRoles.Creator);
if (roleManager.Roles.All(r => r.Name != roleCreator.Name))
{
await roleManager.CreateAsync(roleCreator);
}
}
}

View File

@@ -0,0 +1,304 @@
// <auto-generated />
using System;
using Hutopy.Web.Features.Users.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.Users.Data.Migrations
{
[DbContext(typeof(IdentityDbContext))]
[Migration("20241020183421_Initial")]
partial class Initial
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("Identity")
.HasAnnotation("ProductVersion", "8.0.10")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Hutopy.Web.Features.Users.IdentityRole", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex");
b.ToTable("AspNetRoles", "Identity");
});
modelBuilder.Entity("Hutopy.Web.Features.Users.IdentityUser", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<int>("AccessFailedCount")
.HasColumnType("integer");
b.Property<string>("Address")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("Alias")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<DateTime?>("BirthDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("boolean");
b.Property<string>("Firstname")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("GoogleId")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("Lastname")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<bool>("LockoutEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("PhoneNumber")
.HasColumnType("text");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("boolean");
b.Property<string>("PortraitUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("SecurityStamp")
.HasColumnType("text");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("boolean");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("AspNetUsers", "Identity");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<Guid>("RoleId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", "Identity");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", "Identity");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("ProviderKey")
.HasColumnType("text");
b.Property<string>("ProviderDisplayName")
.HasColumnType("text");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", "Identity");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<Guid>("RoleId")
.HasColumnType("uuid");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", "Identity");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Value")
.HasColumnType("text");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", "Identity");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
{
b.HasOne("Hutopy.Web.Features.Users.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.HasOne("Hutopy.Web.Features.Users.IdentityUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.HasOne("Hutopy.Web.Features.Users.IdentityUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
{
b.HasOne("Hutopy.Web.Features.Users.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Hutopy.Web.Features.Users.IdentityUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
{
b.HasOne("Hutopy.Web.Features.Users.IdentityUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,260 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Hutopy.Web.Features.Users.Data.Migrations
{
/// <inheritdoc />
public partial class Initial : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.EnsureSchema(
name: "Identity");
migrationBuilder.CreateTable(
name: "AspNetRoles",
schema: "Identity",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
NormalizedName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
ConcurrencyStamp = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetRoles", x => x.Id);
});
migrationBuilder.CreateTable(
name: "AspNetUsers",
schema: "Identity",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Alias = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: true),
Firstname = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: true),
Lastname = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: true),
BirthDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
Address = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: true),
PortraitUrl = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: true),
GoogleId = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: true),
UserName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
NormalizedUserName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
Email = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
NormalizedEmail = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
EmailConfirmed = table.Column<bool>(type: "boolean", nullable: false),
PasswordHash = table.Column<string>(type: "text", nullable: true),
SecurityStamp = table.Column<string>(type: "text", nullable: true),
ConcurrencyStamp = table.Column<string>(type: "text", nullable: true),
PhoneNumber = table.Column<string>(type: "text", nullable: true),
PhoneNumberConfirmed = table.Column<bool>(type: "boolean", nullable: false),
TwoFactorEnabled = table.Column<bool>(type: "boolean", nullable: false),
LockoutEnd = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
LockoutEnabled = table.Column<bool>(type: "boolean", nullable: false),
AccessFailedCount = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUsers", x => x.Id);
});
migrationBuilder.CreateTable(
name: "AspNetRoleClaims",
schema: "Identity",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
RoleId = table.Column<Guid>(type: "uuid", nullable: false),
ClaimType = table.Column<string>(type: "text", nullable: true),
ClaimValue = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetRoleClaims", x => x.Id);
table.ForeignKey(
name: "FK_AspNetRoleClaims_AspNetRoles_RoleId",
column: x => x.RoleId,
principalSchema: "Identity",
principalTable: "AspNetRoles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserClaims",
schema: "Identity",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
ClaimType = table.Column<string>(type: "text", nullable: true),
ClaimValue = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserClaims", x => x.Id);
table.ForeignKey(
name: "FK_AspNetUserClaims_AspNetUsers_UserId",
column: x => x.UserId,
principalSchema: "Identity",
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserLogins",
schema: "Identity",
columns: table => new
{
LoginProvider = table.Column<string>(type: "text", nullable: false),
ProviderKey = table.Column<string>(type: "text", nullable: false),
ProviderDisplayName = table.Column<string>(type: "text", nullable: true),
UserId = table.Column<Guid>(type: "uuid", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserLogins", x => new { x.LoginProvider, x.ProviderKey });
table.ForeignKey(
name: "FK_AspNetUserLogins_AspNetUsers_UserId",
column: x => x.UserId,
principalSchema: "Identity",
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserRoles",
schema: "Identity",
columns: table => new
{
UserId = table.Column<Guid>(type: "uuid", nullable: false),
RoleId = table.Column<Guid>(type: "uuid", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserRoles", x => new { x.UserId, x.RoleId });
table.ForeignKey(
name: "FK_AspNetUserRoles_AspNetRoles_RoleId",
column: x => x.RoleId,
principalSchema: "Identity",
principalTable: "AspNetRoles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_AspNetUserRoles_AspNetUsers_UserId",
column: x => x.UserId,
principalSchema: "Identity",
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserTokens",
schema: "Identity",
columns: table => new
{
UserId = table.Column<Guid>(type: "uuid", nullable: false),
LoginProvider = table.Column<string>(type: "text", nullable: false),
Name = table.Column<string>(type: "text", nullable: false),
Value = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserTokens", x => new { x.UserId, x.LoginProvider, x.Name });
table.ForeignKey(
name: "FK_AspNetUserTokens_AspNetUsers_UserId",
column: x => x.UserId,
principalSchema: "Identity",
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_AspNetRoleClaims_RoleId",
schema: "Identity",
table: "AspNetRoleClaims",
column: "RoleId");
migrationBuilder.CreateIndex(
name: "RoleNameIndex",
schema: "Identity",
table: "AspNetRoles",
column: "NormalizedName",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_AspNetUserClaims_UserId",
schema: "Identity",
table: "AspNetUserClaims",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_AspNetUserLogins_UserId",
schema: "Identity",
table: "AspNetUserLogins",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_AspNetUserRoles_RoleId",
schema: "Identity",
table: "AspNetUserRoles",
column: "RoleId");
migrationBuilder.CreateIndex(
name: "EmailIndex",
schema: "Identity",
table: "AspNetUsers",
column: "NormalizedEmail");
migrationBuilder.CreateIndex(
name: "UserNameIndex",
schema: "Identity",
table: "AspNetUsers",
column: "NormalizedUserName",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AspNetRoleClaims",
schema: "Identity");
migrationBuilder.DropTable(
name: "AspNetUserClaims",
schema: "Identity");
migrationBuilder.DropTable(
name: "AspNetUserLogins",
schema: "Identity");
migrationBuilder.DropTable(
name: "AspNetUserRoles",
schema: "Identity");
migrationBuilder.DropTable(
name: "AspNetUserTokens",
schema: "Identity");
migrationBuilder.DropTable(
name: "AspNetRoles",
schema: "Identity");
migrationBuilder.DropTable(
name: "AspNetUsers",
schema: "Identity");
}
}
}

View File

@@ -0,0 +1,301 @@
// <auto-generated />
using System;
using Hutopy.Web.Features.Users.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Hutopy.Web.Features.Users.Data.Migrations
{
[DbContext(typeof(IdentityDbContext))]
partial class ApplicationDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("Identity")
.HasAnnotation("ProductVersion", "8.0.10")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Hutopy.Web.Features.Users.IdentityRole", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex");
b.ToTable("AspNetRoles", "Identity");
});
modelBuilder.Entity("Hutopy.Web.Features.Users.IdentityUser", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<int>("AccessFailedCount")
.HasColumnType("integer");
b.Property<string>("Address")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("Alias")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<DateTime?>("BirthDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("boolean");
b.Property<string>("Firstname")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("GoogleId")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("Lastname")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<bool>("LockoutEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("PhoneNumber")
.HasColumnType("text");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("boolean");
b.Property<string>("PortraitUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("SecurityStamp")
.HasColumnType("text");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("boolean");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("AspNetUsers", "Identity");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<Guid>("RoleId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", "Identity");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", "Identity");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("ProviderKey")
.HasColumnType("text");
b.Property<string>("ProviderDisplayName")
.HasColumnType("text");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", "Identity");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<Guid>("RoleId")
.HasColumnType("uuid");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", "Identity");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Value")
.HasColumnType("text");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", "Identity");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
{
b.HasOne("Hutopy.Web.Features.Users.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.HasOne("Hutopy.Web.Features.Users.IdentityUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.HasOne("Hutopy.Web.Features.Users.IdentityUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
{
b.HasOne("Hutopy.Web.Features.Users.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Hutopy.Web.Features.Users.IdentityUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
{
b.HasOne("Hutopy.Web.Features.Users.IdentityUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,43 @@
using Hutopy.Web.Common.BlobStorage;
using Hutopy.Web.Features.Messages.Data;
using Hutopy.Web.Features.Users.Data;
using Microsoft.AspNetCore.Identity;
namespace Hutopy.Web.Features.Users;
public static class DependencyInjection
{
public static WebApplicationBuilder AddIdentityModule(
this WebApplicationBuilder builder,
Action<DbContextOptionsBuilder>? configureAction = null)
{
builder.Services.AddDbContext<MessagingDbContext>(configureAction);
builder.Services.AddScoped<MessagingDbContextInitializer>();
builder.Services.AddDbContext<IdentityDbContext>(configureAction);
builder.Services.AddScoped<IdentityDbContextInitializer>();
builder.Services.AddAuthentication()
.AddBearerToken(IdentityConstants.BearerScheme);
builder.Services.AddAuthorizationBuilder();
builder.Services
.AddIdentityCore<IdentityUser>()
.AddUserManager<IdentityUserManager>()
.AddRoles<IdentityRole>()
.AddEntityFrameworkStores<IdentityDbContext>()
.AddApiEndpoints()
.AddSignInManager<SignInManager<IdentityUser>>()
.AddDefaultTokenProviders();
// Singleton services
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddSingleton<AzureBlobStorage>();
// Scoped services
builder.Services.AddScoped<IdentityService>();
return builder;
}
}

View File

@@ -0,0 +1,41 @@
using Hutopy.Web.Common.Security;
namespace Hutopy.Web.Features.Users.Handlers;
[PublicAPI]
public record ChangeAddressRequest(
string? Address);
[PublicAPI]
public class ChangeAddressHandler(
IdentityUserManager userManager)
: Endpoint<ChangeAddressRequest>
{
public override void Configure()
{
Post("/api/users/address");
Options(o => o.WithTags("Users"));
}
public override async Task HandleAsync(
ChangeAddressRequest request,
CancellationToken ct)
{
var user = await userManager.FindByIdAsync(HttpContext.User.GetUserId().ToString());
if (user is null)
{
await SendNotFoundAsync(ct);
return;
}
user.Address = request.Address;
var result = await userManager.UpdateAsync(user);
if (result.Succeeded)
await SendOkAsync(ct);
else
await SendUnauthorizedAsync(ct);
}
}

Some files were not shown because too many files have changed in this diff Show More