Add and remove reaction from a content

This commit is contained in:
Dominic Villemure
2024-08-24 18:39:09 -04:00
parent 63dc032aa4
commit 588be7941c
17 changed files with 660 additions and 45 deletions

1
EfContentDbContext.ps1 Normal file
View File

@@ -0,0 +1 @@
dotnet ef $args --startup-project ./src/Web/Web.csproj --project ./src/Web/Web.csproj --context ContentDbContext

1
EfMessagingDbContext.ps1 Normal file
View File

@@ -0,0 +1 @@
dotnet ef $args --startup-project ./src/Web/Web.csproj --project ./src/Web/Web.csproj --context MessagingDbContext

View File

@@ -0,0 +1,34 @@
namespace Hutopy.Web.Extensions;
public static class EnumExtensions
{
/// <summary>
/// Converts a string to the specified enum type.
/// </summary>
/// <typeparam name="TEnum">The type of the enum to convert to. Must be an enum.</typeparam>
/// <param name="value">The string value to convert.</param>
/// <param name="ignoreCase">Specifies whether the string comparison should ignore case. Default is true.</param>
/// <returns>
/// The corresponding enum value if the conversion is successful; otherwise, null if the string
/// cannot be converted to the specified enum type.
/// </returns>
public static TEnum? ToEnum<TEnum>(this string value, bool ignoreCase = true) where TEnum : struct
{
if (Enum.TryParse(value, ignoreCase, out TEnum result))
{
return result;
}
return null;
}
/// <summary>
/// Converts an enum value to its string representation.
/// </summary>
/// <typeparam name="TEnum">The type of the enum.</typeparam>
/// <param name="enumValue">The enum value to convert.</param>
/// <returns>The string representation of the enum value.</returns>
public static string FromEnum<TEnum>(this TEnum enumValue) where TEnum : struct, Enum
{
return enumValue.ToString();
}
}

View File

@@ -10,8 +10,8 @@ public class Content
public DateTimeOffset CreatedAt { get; init; }
public Guid? DeletedBy { get; set; }
public DateTimeOffset? DeletedAt { get; set; }
[MaxLength(128)] public required string Title { get; set; }
[MaxLength(2048)] public required string Description { get; set; }
public IList<ContentReaction> Reactions { get; set; } = new List<ContentReaction>();
public string[]? Urls { get; init; }
}

View File

@@ -1,4 +1,6 @@
namespace Hutopy.Web.Features.Contents.Data;
using Humanizer;
namespace Hutopy.Web.Features.Contents.Data;
public class ContentDbContext(
DbContextOptions<ContentDbContext> options)
@@ -25,6 +27,11 @@ public class ContentDbContext(
.HasOne(c => c.Creator)
.WithMany()
.HasForeignKey(c => c.CreatedBy);
modelBuilder
.Entity<Content>()
.OwnsMany(c => c.Reactions)
.ToTable(nameof(ContentReaction).Pluralize());
modelBuilder
.Entity<Subscription>()

View File

@@ -0,0 +1,11 @@
using System.ComponentModel.DataAnnotations;
using Hutopy.Web.Features.Contents.Handlers.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,13 @@
namespace Hutopy.Web.Features.Contents.Handlers.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,93 @@
using Hutopy.Web.Extensions;
using Hutopy.Web.Features.Contents.Data;
using Hutopy.Web.Features.Contents.Handlers.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 hasReacted = content.Reactions.Any(x => x.UserId == req.UserId);
if (hasReacted)
{
var currentReaction = content.Reactions.Single(x => x.UserId == req.UserId);
if (currentReaction.Reaction == reactionEnum) return;
if (reactionEnum.HasValue)
{
content.Reactions.Remove(currentReaction);
var reaction = new ContentReaction
{
Reaction = reactionEnum.Value,
UserId = req.UserId,
UserName = req.UserName
};
content.Reactions.Add(reaction);
}
}
else
{
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

@@ -1,4 +1,5 @@
using Hutopy.Web.Features.Contents.Data;
using Hutopy.Web.Extensions;
using Hutopy.Web.Features.Contents.Data;
using Hutopy.Web.Features.Contents.Handlers.Models;
namespace Hutopy.Web.Features.Contents.Handlers;
@@ -41,6 +42,10 @@ public class GetContent(
Title = c.Title,
Description = c.Description,
Urls = c.Urls,
Reactions = c.Reactions.Select(x => new ReactionModel
{
Reaction = x.Reaction.FromEnum(), UserId = x.UserId, UserName = x.UserName
}).ToList()
})
.SingleOrDefaultAsync(
c => c.Id == req.ContentId,

View File

@@ -1,4 +1,4 @@
using System.Text;
using Hutopy.Web.Extensions;
using Hutopy.Web.Features.Contents.Data;
using Hutopy.Web.Features.Contents.Handlers.Models;
@@ -28,45 +28,72 @@ public class GetContentsByCreatorHandler(
GetContentsByCreatorRequest req,
CancellationToken ct)
{
var queryBuilder = new StringBuilder();
queryBuilder.AppendLine($"""
SELECT content."Id",
content."CreatedBy",
creator."Name" as CreatedByName,
i."Logo" as CreatedByPortraitUrl,
c."Menu" as ColorMenu,
c."Accent" as ColorAccent,
content."CreatedAt",
content."DeletedBy",
content."DeletedAt",
content."Title",
content."Description",
content."Urls"
FROM "Content"."Contents" AS content
INNER JOIN "Content"."Creators" AS creator ON content."CreatedBy" = creator."Id"
LEFT JOIN "Content"."Images" AS i ON creator."Id" = i."CreatorId"
LEFT JOIN "Content"."Colors" AS c ON creator."Id" = c."CreatorId"
WHERE content."CreatedBy" = '{req.CreatorId}'
AND content."DeletedBy" IS NULL
""");
// var queryBuilder = new StringBuilder();
// queryBuilder.AppendLine($"""
// SELECT content."Id",
// content."CreatedBy",
// creator."Name" as CreatedByName,
// i."Logo" as CreatedByPortraitUrl,
// c."Menu" as ColorMenu,
// c."Accent" as ColorAccent,
// content."CreatedAt",
// content."DeletedBy",
// content."DeletedAt",
// content."Title",
// content."Description",
// content."Urls",
// FROM "Content"."Contents" AS content
// INNER JOIN "Content"."Creators" AS creator ON content."CreatedBy" = creator."Id"
// LEFT JOIN "Content"."Images" AS i ON creator."Id" = i."CreatorId"
// LEFT JOIN "Content"."Colors" AS c ON creator."Id" = c."CreatorId"
// LEFT JOIN "Content"."ContentReactions" AS cr ON content."Id" = cr."ContentId"
// WHERE content."CreatedBy" = '{req.CreatorId}'
// AND content."DeletedBy" IS NULL
// """);
//
// if (req.LastId.HasValue)
// {
// queryBuilder.AppendLine($"""AND content."Id" > '{req.LastId.Value}'""");
// }
//
// queryBuilder.AppendLine($"""
// ORDER BY content."CreatedAt" DESC
// LIMIT {req.PageSize}
// """);
//
// var query = queryBuilder.ToString();
//
// var results = await context
// .Database
// .SqlQueryRaw<ContentModel>(query)
// .Include(c => c.Reactions)
// .ToListAsync(cancellationToken: ct);
if (req.LastId.HasValue)
{
queryBuilder.AppendLine($"""AND content."Id" > '{req.LastId.Value}'""");
}
queryBuilder.AppendLine($"""
ORDER BY content."CreatedAt" DESC
LIMIT {req.PageSize}
""");
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,
ColorMenu = c.Creator.Colors.Menu,
ColorAccent = c.Creator.Colors.Accent,
DeletedBy = c.DeletedBy,
DeletedAt = c.DeletedAt,
Title = c.Title,
Description = c.Description,
Urls = c.Urls,
Reactions = c.Reactions.Select(x => new ReactionModel
{
Reaction = x.Reaction.FromEnum(), UserId = x.UserId, UserName = x.UserName
}).ToList()
})
.Where(x => x.CreatedBy == req.CreatorId && x.DeletedAt == null)
.ToListAsync(ct);
var query = queryBuilder.ToString();
var results = await context
.Database
.SqlQueryRaw<ContentModel>(query)
.ToListAsync(cancellationToken: ct);
await SendAsync(results, cancellation: ct);
await SendAsync(content, cancellation: ct);
}
}

View File

@@ -1,4 +1,6 @@
namespace Hutopy.Web.Features.Contents.Handlers.Models;
using System.ComponentModel.DataAnnotations.Schema;
namespace Hutopy.Web.Features.Contents.Handlers.Models;
[PublicAPI]
public class ContentModel
@@ -15,4 +17,5 @@ public class ContentModel
public required string Title { get; init; }
public required string Description { get; init; }
public required string[]? Urls { get; init; }
public IList<ReactionModel>? Reactions { get; set; } = new List<ReactionModel>();
}

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,296 @@
// <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.Migrations
{
[DbContext(typeof(ContentDbContext))]
[Migration("20240824185551_AddReactionToContent")]
partial class AddReactionToContent
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("Content")
.HasAnnotation("ProductVersion", "8.0.4")
.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>("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.HasKey("Id");
b.ToTable("Creators", "Content");
});
modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Subscription", b =>
{
b.Property<Guid>("CreatedBy")
.HasColumnType("uuid");
b.Property<Guid>("CreatorId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("CreatedBy", "CreatorId");
b.HasIndex("CreatorId");
b.ToTable("Subscriptions", "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("ContentReactions", "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.About", "About", b1 =>
{
b1.Property<Guid>("CreatorId")
.HasColumnType("uuid");
b1.Property<string>("Description")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b1.Property<string>("Title")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.HasKey("CreatorId");
b1.ToTable("Creators", "Content");
b1.WithOwner()
.HasForeignKey("CreatorId");
});
b.OwnsOne("Hutopy.Web.Features.Contents.Data.Colors", "Colors", b1 =>
{
b1.Property<Guid>("CreatorId")
.HasColumnType("uuid");
b1.Property<string>("Accent")
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("BannerBottom")
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("BannerTop")
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("Menu")
.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("About")
.IsRequired();
b.Navigation("Colors")
.IsRequired();
b.Navigation("Images")
.IsRequired();
b.Navigation("Socials")
.IsRequired();
});
modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Subscription", b =>
{
b.HasOne("Hutopy.Web.Features.Contents.Data.Creator", "Creator")
.WithMany()
.HasForeignKey("CreatorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Creator");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,48 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Hutopy.Web.Features.Contents.Migrations
{
/// <inheritdoc />
public partial class AddReactionToContent : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ContentReactions",
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_ContentReactions", x => new { x.ContentId, x.Id });
table.ForeignKey(
name: "FK_ContentReactions_Contents_ContentId",
column: x => x.ContentId,
principalSchema: "Content",
principalTable: "Contents",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ContentReactions",
schema: "Content");
}
}
}

View File

@@ -37,12 +37,12 @@ namespace Hutopy.Web.Features.Contents.Migrations
b.Property<Guid>("CreatedBy")
.HasColumnType("uuid");
b.Property<Guid?>("DeletedBy")
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("DeletedBy")
.HasColumnType("uuid");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2048)
@@ -111,7 +111,39 @@ namespace Hutopy.Web.Features.Contents.Migrations
.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("ContentReactions", "Content");
b1.WithOwner()
.HasForeignKey("ContentId");
});
b.Navigation("Creator");
b.Navigation("Reactions");
});
modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Creator", b =>