Merged PR 112: Add and remove reaction from a content

Add and remove reaction from a content
This commit is contained in:
Dominic Villemure
2024-08-25 14:39:19 +00:00
17 changed files with 656 additions and 42 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,83 @@
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 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

@@ -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,81 @@ 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"
// 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)
// .ToListAsync(cancellationToken: ct);
var query = context.Contents
.Where(c => c.CreatedBy == req.CreatorId && c.DeletedAt == null)
.OrderByDescending(c => c.CreatedAt);
if (req.LastId.HasValue)
{
queryBuilder.AppendLine($"""AND content."Id" > '{req.LastId.Value}'""");
query = query.Where(c => c.Id > req.LastId.Value)
.OrderByDescending(c => c.CreatedAt);
}
queryBuilder.AppendLine($"""
ORDER BY content."CreatedAt" DESC
LIMIT {req.PageSize}
""");
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,
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()
})
.Take(req.PageSize)
.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 =>