Adds user Alias. Make StoredDataUrls optionals.

This commit is contained in:
Jonathan Bourdon
2024-07-22 00:42:27 -04:00
parent 8551398edc
commit 0faf5a9a0e
31 changed files with 1720 additions and 174 deletions

View File

@@ -1,4 +1,5 @@
using System.Security.Claims;
using Hutopy.Infrastructure.Utils;
namespace Hutopy.Web.Common;
@@ -6,25 +7,48 @@ public static class ClaimsPrincipalExtensions
{
public static Guid GetUserId(this ClaimsPrincipal claims)
{
return (Guid)claims.GetFirstValue<Guid>(ClaimTypes.NameIdentifier);
return (Guid)claims.GetRequiredClaim<Guid>(ClaimTypes.NameIdentifier);
}
public static string GetName(this ClaimsPrincipal claims)
{
return (string)claims.GetRequiredClaim<string>(ClaimTypes.Name);
}
public static string? GetAlias(this ClaimsPrincipal claims)
{
return (string?)claims.GetClaim<string?>(KnownClaims.Alias);
}
public static string? GetPortraitUrl(this ClaimsPrincipal claims)
{
return (string?)claims.GetClaim<string?>(KnownClaims.PortraitUrl);
}
public static string GetFirstName(this ClaimsPrincipal claims)
{
return (string)claims.GetFirstValue<string>(ClaimTypes.GivenName);
return (string)claims.GetRequiredClaim<string>(ClaimTypes.GivenName);
}
public static string GetLastName(this ClaimsPrincipal claims)
{
return (string)claims.GetFirstValue<string>(ClaimTypes.Surname);
return (string)claims.GetRequiredClaim<string>(ClaimTypes.Surname);
}
public static string GetEmail(this ClaimsPrincipal claims)
{
return (string)claims.GetFirstValue<string>(ClaimTypes.Email);
return (string)claims.GetRequiredClaim<string>(ClaimTypes.Email);
}
public static object GetFirstValue<TValue>(this ClaimsPrincipal claims, string key)
public static object? GetClaim<TValue>(this ClaimsPrincipal claims, string key)
{
var claim = claims.FindFirst(key);
if (claim is null) return default;
return claims.GetRequiredClaim<TValue>(key);
}
public static object GetRequiredClaim<TValue>(this ClaimsPrincipal claims, string key)
{
var claim = claims.FindFirst(key);

View File

@@ -74,6 +74,7 @@ public class GoogleController(
jwtOptions.Value.Key,
user.Id,
user.Email,
user.Alias,
user.FirstName,
user.LastName,
user.StoredDataUrls.ProfilePictureUrl);

View File

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

View File

@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore;
using Hutopy.Web.Features.Messages.Handlers.Models;
using Microsoft.EntityFrameworkCore;
namespace Hutopy.Web.Features.Messages.Data;
@@ -9,7 +10,7 @@ public class MessagingDbContext(
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasDefaultSchema("Messaging");
modelBuilder
.Entity<Message>()
.Property(c => c.CreatedAt)
@@ -17,5 +18,41 @@ public class MessagingDbContext(
.HasDefaultValueSql("CURRENT_TIMESTAMP");
}
public DbSet<Message> Messages { get; set; }
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;
}
}

View File

@@ -1,14 +1,30 @@
using FastEndpoints;
using FluentValidation;
using Hutopy.Web.Common;
using Hutopy.Web.Features.Messages.Data;
namespace Hutopy.Web.Features.Messages.Handlers;
public class AddMessageRequest
public sealed class AddMessageRequest
{
public Guid? Id { get; set; }
public Guid SubjectId { get; set; }
public string Message { 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(
@@ -30,6 +46,8 @@ public class AddMessage(
Id = req.Id ?? GuidHelper.GenerateUuidV7(),
SubjectId = req.SubjectId,
CreatedBy = User.GetUserId(),
CreatedByName = User.GetAlias() ?? $"{User.GetFirstName()} {User.GetLastName()}",
CreatedByPortraitUrl = User.GetPortraitUrl(),
Value = req.Message
};

View File

@@ -1,16 +1,37 @@
using FastEndpoints;
using FluentValidation;
using Hutopy.Web.Common;
using Hutopy.Web.Features.Messages.Data;
namespace Hutopy.Web.Features.Messages.Handlers;
internal sealed class AddReplyRequest
public sealed class AddReplyRequest
{
public required Guid SubjectId { get; set; }
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>
@@ -28,9 +49,10 @@ internal sealed class AddReply(
var message = new Message
{
Id = GuidHelper.GenerateUuidV7(),
SubjectId = req.SubjectId,
SubjectId = req.SubjectId,
ParentId = req.ParentId,
CreatedBy = User.GetUserId(),
CreatedByName = User.GetName(),
Value = req.Message
};

View File

@@ -1,19 +1,24 @@
using FastEndpoints;
using Hutopy.Web.Features.Messages.Data;
using Microsoft.EntityFrameworkCore;
using Hutopy.Web.Features.Messages.Handlers.Models;
namespace Hutopy.Web.Features.Messages.Handlers;
public class GetMessagesRequest
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, List<Message>>
: Endpoint<GetMessagesRequest, GetMessagesResponse>
{
public override void Configure()
{
@@ -26,34 +31,18 @@ public class GetMessages(
GetMessagesRequest req,
CancellationToken ct)
{
var query = context
.Messages
.Where(c => c.SubjectId == req.SubjectId)
.Where(c => c.ParentId == null);
if (req.LastId.HasValue)
{
var lastMessage = await context
.Messages
.Where(c => c.Id == req.LastId.Value)
.Select(c => new { c.CreatedAt, c.Id })
.FirstOrDefaultAsync(cancellationToken: ct);
if (lastMessage != null)
var messages = await context.GetMessagesAsync(
req.SubjectId,
null,
req.LastId,
req.PageSize,
ct);
await SendAsync(
new()
{
query = query
.Where(c => c.CreatedAt < lastMessage.CreatedAt
|| (c.CreatedAt == lastMessage.CreatedAt && c.Id < lastMessage.Id));
}
}
query = query
.OrderByDescending(c => c.CreatedAt)
.ThenByDescending(c => c.Id)
.Take(req.PageSize);
var messages = await query.ToListAsync(cancellationToken: ct);
await SendAsync(messages, cancellation: ct);
Messages = messages
},
cancellation: ct);
}
}

View File

@@ -1,5 +1,6 @@
using FastEndpoints;
using Hutopy.Web.Features.Messages.Data;
using Hutopy.Web.Features.Messages.Handlers.Models;
using Microsoft.EntityFrameworkCore;
namespace Hutopy.Web.Features.Messages.Handlers;
@@ -9,9 +10,14 @@ 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, List<Message>>
: Endpoint<GetMessagesByUserRequest, GetMessagesByUserResponse>
{
public override void Configure()
{
@@ -24,12 +30,19 @@ public class GetMessagesByUser(
GetMessagesByUserRequest req,
CancellationToken ct)
{
var posts = await context
var messages = await context
.Messages
.Where(c => c.CreatedBy == req.UserId)
.Where(c => c.ParentId == null)
.ToListAsync(cancellationToken: ct);
await SendAsync(posts, cancellation: ct);
await SendAsync(
new()
{
Messages = messages
.Select(message => message.ToDto())
.ToList()
},
cancellation: ct);
}
}

View File

@@ -1,6 +1,6 @@
using FastEndpoints;
using Hutopy.Web.Features.Messages.Data;
using Microsoft.EntityFrameworkCore;
using Hutopy.Web.Features.Messages.Handlers.Models;
namespace Hutopy.Web.Features.Messages.Handlers;
@@ -12,9 +12,14 @@ public class GetRepliesRequest
[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, List<Message>>
: Endpoint<GetRepliesRequest, GetRepliesResponse>
{
public override void Configure()
{
@@ -27,20 +32,18 @@ public class GetReplies(
GetRepliesRequest req,
CancellationToken ct)
{
var query = context
.Messages
.Where(c => c.SubjectId == req.SubjectId)
.Where(c => c.ParentId == req.ParentId);
var replies = await context.GetMessagesAsync(
req.SubjectId,
req.ParentId,
req.LastId,
req.PageSize,
ct);
query = query.OrderByDescending(c => c.CreatedAt);
if (req.LastId is not null)
query = query.Where(c => c.Id < req.LastId.Value);
query = query.Take(req.PageSize);
var replies = await query.ToListAsync(cancellationToken: ct);
await SendAsync(replies, cancellation: 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

@@ -9,10 +9,10 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Hutopy.Web.Messages.Migrations
namespace Hutopy.Web.Features.Messages.Migrations
{
[DbContext(typeof(MessagingDbContext))]
[Migration("20240718173016_Initial")]
[Migration("20240721041322_Initial")]
partial class Initial
{
/// <inheritdoc />
@@ -26,7 +26,7 @@ namespace Hutopy.Web.Messages.Migrations
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Hutopy.Web.Messages.Data.Message", b =>
modelBuilder.Entity("Hutopy.Web.Features.Messages.Data.Message", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
@@ -40,6 +40,13 @@ namespace Hutopy.Web.Messages.Migrations
b.Property<Guid>("CreatedBy")
.HasColumnType("uuid");
b.Property<string>("CreatedByName")
.IsRequired()
.HasColumnType("text");
b.Property<string>("CreatedByPortraitUrl")
.HasColumnType("text");
b.Property<Guid?>("ParentId")
.HasColumnType("uuid");

View File

@@ -3,7 +3,7 @@ using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Hutopy.Web.Messages.Migrations
namespace Hutopy.Web.Features.Messages.Migrations
{
/// <inheritdoc />
public partial class Initial : Migration
@@ -22,6 +22,8 @@ namespace Hutopy.Web.Messages.Migrations
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: "text", nullable: false),
CreatedByPortraitUrl = table.Column<string>(type: "text", 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)

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("20240721064224_ChangedAuthorDefinition")]
partial class ChangedAuthorDefinition
{
/// <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(64)
.HasColumnType("character varying(64)");
b.Property<string>("CreatedByPortraitUrl")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
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,60 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Hutopy.Web.Features.Messages.Migrations
{
/// <inheritdoc />
public partial class ChangedAuthorDefinition : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "CreatedByPortraitUrl",
schema: "Messaging",
table: "Messages",
type: "character varying(256)",
maxLength: 256,
nullable: true,
oldClrType: typeof(string),
oldType: "text",
oldNullable: true);
migrationBuilder.AlterColumn<string>(
name: "CreatedByName",
schema: "Messaging",
table: "Messages",
type: "character varying(64)",
maxLength: 64,
nullable: false,
oldClrType: typeof(string),
oldType: "text");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "CreatedByPortraitUrl",
schema: "Messaging",
table: "Messages",
type: "text",
nullable: true,
oldClrType: typeof(string),
oldType: "character varying(256)",
oldMaxLength: 256,
oldNullable: true);
migrationBuilder.AlterColumn<string>(
name: "CreatedByName",
schema: "Messaging",
table: "Messages",
type: "text",
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(64)",
oldMaxLength: 64);
}
}
}

View File

@@ -8,7 +8,7 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Hutopy.Web.Messages.Migrations
namespace Hutopy.Web.Features.Messages.Migrations
{
[DbContext(typeof(MessagingDbContext))]
partial class MessagingDbContextModelSnapshot : ModelSnapshot
@@ -23,7 +23,7 @@ namespace Hutopy.Web.Messages.Migrations
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Hutopy.Web.Messages.Data.Message", b =>
modelBuilder.Entity("Hutopy.Web.Features.Messages.Data.Message", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
@@ -37,6 +37,15 @@ namespace Hutopy.Web.Messages.Migrations
b.Property<Guid>("CreatedBy")
.HasColumnType("uuid");
b.Property<string>("CreatedByName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("CreatedByPortraitUrl")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<Guid?>("ParentId")
.HasColumnType("uuid");

View File

@@ -35,13 +35,13 @@ internal class TestDataSeeder(
{
if (contentContext.Contents.Any()) return;
_ = await CreateAdministratorAsync("admin");
_ = await CreateUserAsync("userA");
_ = await CreateUserAsync("userB");
_users.Add(await CreateUserAsync("admin", Roles.Administrator));
_users.Add(await CreateUserAsync("userA"));
_users.Add(await CreateUserAsync("userB"));
foreach (var creator in _creators)
{
_ = await CreateCreatorAsync(creator);
_users.Add(await CreateCreatorAsync(creator));
var contents = GenerateContent(creator, 100);
foreach (var content in contents)
@@ -96,15 +96,17 @@ internal class TestDataSeeder(
for (var m = messageCount; m > 0; m--)
{
currentDate = currentDate.AddSeconds(-Random.Shared.Next(100, 100_000));
var author = Random.Shared.GetItems(_creators, 1)[0];
var author = Random.Shared.GetItems(_users.ToArray(), 1).First();
var message = new Message
{
Id = GuidHelper.GenerateUuidV7(),
SubjectId = content.Id,
CreatedBy = Guid.Parse(author.Id),
CreatedAt = currentDate,
Value = $"Message #{m} from {author.UserName} on {content.Title}"
CreatedBy = Guid.Parse(author.Id),
CreatedByName = author.Alias ?? $"{author.FirstName} {author.LastName}",
CreatedByPortraitUrl = author.StoredDataUrls.ProfilePictureUrl,
Value = $"Message #{m} on {content.Title}"
};
messagingContext.Messages.Add(message);
@@ -124,7 +126,7 @@ internal class TestDataSeeder(
{
currentDate = currentDate.AddSeconds(-Random.Shared.Next(100, 100_000));
var author = Random.Shared.GetItems(_creators, 1)[0];
var author = Random.Shared.GetItems(_users.ToArray(), 1).First();
var message = new Message
{
@@ -132,8 +134,10 @@ internal class TestDataSeeder(
SubjectId = content.Id,
ParentId = parent.Id,
CreatedBy = Guid.Parse(author.Id),
CreatedByName = author.Alias ?? $"{author.FirstName} {author.LastName}",
CreatedByPortraitUrl = author.StoredDataUrls.ProfilePictureUrl,
CreatedAt = currentDate,
Value = $"Reply {r} to {parent.Value}"
Value = $"Reply {r} to {parent.Value} on {content.Title}"
};
messagingContext.Messages.Add(message);
@@ -144,31 +148,22 @@ internal class TestDataSeeder(
return replies;
}
private async Task<ApplicationUser> CreateAdministratorAsync(string name)
{
ArgumentException.ThrowIfNullOrWhiteSpace(name);
var administrator = new ApplicationUser { UserName = $"{name}@test", Email = $"{name}@test" };
await userManager.CreateAsync(administrator, DefaultPassword);
await userManager.AddToRolesAsync(administrator, new[] { Roles.Administrator });
return administrator;
}
private async Task<ApplicationUser> CreateUserAsync(string name)
private async Task<ApplicationUser> CreateUserAsync(string name, params string[] roles)
{
var user = new ApplicationUser
{
UserName = $"{name}@test",
Email = $"{name}@test",
EmailConfirmed = true,
Alias = name,
FirstName = $"FirstName of {name}",
LastName = $"LastName of {name}"
};
await userManager.CreateAsync(user, DefaultPassword);
if (roles.Length > 0) await userManager.AddToRolesAsync(user, roles);
return user;
}
@@ -180,12 +175,19 @@ internal class TestDataSeeder(
}
private readonly List<ApplicationUser> _users =
[
];
private readonly static ApplicationUser Hutopy = new()
{
UserName = "hutopy@test",
Email = "hutopy@test",
EmailConfirmed = true,
CreatorAlias = "hutopy",
FirstName = "FirstName of a Brand/Creator",
LastName = "LastName of a Brand/Creator",
About = "Page officielle",
Description = "Site officiel pour Hutopy. Venez-nous-y retrouver avec tous vos fans!",
ProfileColors = new ProfileColors