diff --git a/Foxnouns.Backend/Controllers/V1/UsersV1Controller.cs b/Foxnouns.Backend/Controllers/V1/UsersV1Controller.cs index e11e490..75fd7b9 100644 --- a/Foxnouns.Backend/Controllers/V1/UsersV1Controller.cs +++ b/Foxnouns.Backend/Controllers/V1/UsersV1Controller.cs @@ -4,13 +4,36 @@ using Microsoft.AspNetCore.Mvc; namespace Foxnouns.Backend.Controllers.V1; -[Route("/api/v1/users")] -public class UsersV1Controller(UsersV1Service usersV1Service) : ApiControllerBase +[Route("/api/v1")] +public class UsersV1Controller(UsersV1Service usersV1Service, MembersV1Service membersV1Service) + : ApiControllerBase { - [HttpGet("{userRef}")] + [HttpGet("users/{userRef}")] public async Task GetUserAsync(string userRef, CancellationToken ct = default) { User user = await usersV1Service.ResolveUserAsync(userRef, CurrentToken, ct); - return Ok(await usersV1Service.RenderUserAsync(user)); + return Ok( + await usersV1Service.RenderUserAsync( + user, + CurrentToken, + renderMembers: true, + renderFlags: true, + ct: ct + ) + ); + } + + [HttpGet("members/{id}")] + public async Task GetMemberAsync(string id, CancellationToken ct = default) + { + Member member = await membersV1Service.ResolveMemberAsync(id, ct); + return Ok( + await membersV1Service.RenderMemberAsync( + member, + CurrentToken, + renderFlags: true, + ct: ct + ) + ); } } diff --git a/Foxnouns.Backend/Dto/V1/Member.cs b/Foxnouns.Backend/Dto/V1/Member.cs new file mode 100644 index 0000000..955e9af --- /dev/null +++ b/Foxnouns.Backend/Dto/V1/Member.cs @@ -0,0 +1,44 @@ +// ReSharper disable NotAccessedPositionalProperty.Global +using Foxnouns.Backend.Database; +using Newtonsoft.Json; + +namespace Foxnouns.Backend.Dto.V1; + +public record PartialMember( + string Id, + Snowflake IdNew, + string Sid, + string Name, + string? DisplayName, + string? Bio, + string? Avatar, + string[] Links, + FieldEntry[] Names, + PronounEntry[] Pronouns +); + +public record MemberResponse( + string Id, + Snowflake IdNew, + string Sid, + string Name, + string? DisplayName, + string? Bio, + string? Avatar, + string[] Links, + FieldEntry[] Names, + PronounEntry[] Pronouns, + ProfileField[] Fields, + PrideFlag[] Flags, + PartialUser User, + [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] bool? Unlisted +); + +public record PartialUser( + string Id, + Snowflake IdNew, + string Name, + string? DisplayName, + string? Avatar, + Dictionary CustomPreferences +); diff --git a/Foxnouns.Backend/Dto/V1/User.cs b/Foxnouns.Backend/Dto/V1/User.cs index eab4c29..11ff066 100644 --- a/Foxnouns.Backend/Dto/V1/User.cs +++ b/Foxnouns.Backend/Dto/V1/User.cs @@ -21,6 +21,8 @@ public record UserResponse( FieldEntry[] Names, PronounEntry[] Pronouns, ProfileField[] Fields, + PrideFlag[] Flags, + PartialMember[] Members, int? UtcOffset, Dictionary CustomPreferences ); @@ -75,3 +77,5 @@ public record PronounEntry(string Pronouns, string? DisplayText, string Status) )) .ToArray(); } + +public record PrideFlag(string Id, Snowflake IdNew, string Hash, string Name, string? Description); diff --git a/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs b/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs index 86b4a82..426ec12 100644 --- a/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs +++ b/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs @@ -130,7 +130,8 @@ public static class WebApplicationExtensions .AddTransient() .AddTransient() // Legacy services - .AddScoped(); + .AddScoped() + .AddScoped(); if (!config.Logging.EnableMetrics) services.AddHostedService(); diff --git a/Foxnouns.Backend/Services/V1/MembersV1Service.cs b/Foxnouns.Backend/Services/V1/MembersV1Service.cs new file mode 100644 index 0000000..521a924 --- /dev/null +++ b/Foxnouns.Backend/Services/V1/MembersV1Service.cs @@ -0,0 +1,72 @@ +using Foxnouns.Backend.Database; +using Foxnouns.Backend.Database.Models; +using Foxnouns.Backend.Dto.V1; +using Microsoft.EntityFrameworkCore; +using FieldEntry = Foxnouns.Backend.Dto.V1.FieldEntry; +using PrideFlag = Foxnouns.Backend.Dto.V1.PrideFlag; + +namespace Foxnouns.Backend.Services.V1; + +public class MembersV1Service(DatabaseContext db) +{ + public async Task ResolveMemberAsync(string id, CancellationToken ct = default) + { + Member? member; + if (Snowflake.TryParse(id, out Snowflake? sf)) + { + member = await db + .Members.Include(m => m.User) + .FirstOrDefaultAsync(m => m.Id == sf && !m.User.Deleted, ct); + if (member != null) + return member; + } + + member = await db + .Members.Include(m => m.User) + .FirstOrDefaultAsync(m => m.LegacyId == id && !m.User.Deleted, ct); + if (member != null) + return member; + + throw new ApiError.NotFound("No member with that ID found.", ErrorCode.MemberNotFound); + } + + public async Task RenderMemberAsync( + Member m, + Token? token = default, + bool renderFlags = true, + CancellationToken ct = default + ) + { + bool renderUnlisted = m.UserId == token?.UserId; + + List flags = renderFlags + ? await db.MemberFlags.Where(f => f.MemberId == m.Id).OrderBy(f => f.Id).ToListAsync(ct) + : []; + + return new MemberResponse( + m.LegacyId, + m.Id, + m.Sid, + m.Name, + m.DisplayName, + m.Bio, + m.Avatar, + m.Links, + Names: FieldEntry.FromEntries(m.Names, m.User.CustomPreferences), + Pronouns: PronounEntry.FromPronouns(m.Pronouns, m.User.CustomPreferences), + Fields: ProfileField.FromFields(m.Fields, m.User.CustomPreferences), + Flags: flags + .Where(f => f.PrideFlag.Hash != null) + .Select(f => new PrideFlag( + f.PrideFlag.LegacyId, + f.PrideFlag.Id, + f.PrideFlag.Hash!, + f.PrideFlag.Name, + f.PrideFlag.Description + )) + .ToArray(), + User: UsersV1Service.RenderPartialUser(m.User), + Unlisted: renderUnlisted ? m.Unlisted : null + ); + } +} diff --git a/Foxnouns.Backend/Services/V1/UsersV1Service.cs b/Foxnouns.Backend/Services/V1/UsersV1Service.cs index 7492256..990812e 100644 --- a/Foxnouns.Backend/Services/V1/UsersV1Service.cs +++ b/Foxnouns.Backend/Services/V1/UsersV1Service.cs @@ -3,6 +3,7 @@ using Foxnouns.Backend.Database.Models; using Foxnouns.Backend.Dto.V1; using Microsoft.EntityFrameworkCore; using FieldEntry = Foxnouns.Backend.Dto.V1.FieldEntry; +using PrideFlag = Foxnouns.Backend.Dto.V1.PrideFlag; namespace Foxnouns.Backend.Services.V1; @@ -49,8 +50,26 @@ public class UsersV1Service(DatabaseContext db) ); } - public async Task RenderUserAsync(User user) + public async Task RenderUserAsync( + User user, + Token? token = null, + bool renderMembers = true, + bool renderFlags = true, + CancellationToken ct = default + ) { + bool isSelfUser = user.Id == token?.UserId; + renderMembers = renderMembers && (isSelfUser || !user.ListHidden); + + // Only fetch members if we're rendering members (duh) + List members = renderMembers + ? await db.Members.Where(m => m.UserId == user.Id).OrderBy(m => m.Name).ToListAsync(ct) + : []; + + List flags = renderFlags + ? await db.UserFlags.Where(f => f.UserId == user.Id).OrderBy(f => f.Id).ToListAsync(ct) + : []; + int? utcOffset = null; if ( user.Timezone != null @@ -70,23 +89,67 @@ public class UsersV1Service(DatabaseContext db) user.MemberTitle, user.Avatar, user.Links, - FieldEntry.FromEntries(user.Names, user.CustomPreferences), - PronounEntry.FromPronouns(user.Pronouns, user.CustomPreferences), - ProfileField.FromFields(user.Fields, user.CustomPreferences), + Names: FieldEntry.FromEntries(user.Names, user.CustomPreferences), + Pronouns: PronounEntry.FromPronouns(user.Pronouns, user.CustomPreferences), + Fields: ProfileField.FromFields(user.Fields, user.CustomPreferences), + Flags: flags + .Where(f => f.PrideFlag.Hash != null) + .Select(f => new PrideFlag( + f.PrideFlag.LegacyId, + f.PrideFlag.Id, + f.PrideFlag.Hash!, + f.PrideFlag.Name, + f.PrideFlag.Description + )) + .ToArray(), + Members: members.Select(m => RenderPartialMember(m, user.CustomPreferences)).ToArray(), utcOffset, - user.CustomPreferences.Select(x => - ( - x.Value.LegacyId, - new CustomPreference( - x.Value.Icon, - x.Value.Tooltip, - x.Value.Size, - x.Value.Muted, - x.Value.Favourite - ) - ) - ) - .ToDictionary() + CustomPreferences: RenderCustomPreferences(user.CustomPreferences) ); } + + private static Dictionary RenderCustomPreferences( + Dictionary customPreferences + ) => + customPreferences + .Select(x => + ( + x.Value.LegacyId, + new CustomPreference( + x.Value.Icon, + x.Value.Tooltip, + x.Value.Size, + x.Value.Muted, + x.Value.Favourite + ) + ) + ) + .ToDictionary(); + + private static PartialMember RenderPartialMember( + Member m, + Dictionary customPreferences + ) => + new( + m.LegacyId, + m.Id, + m.Sid, + m.Name, + m.DisplayName, + m.Bio, + m.Avatar, + m.Links, + Names: FieldEntry.FromEntries(m.Names, customPreferences), + Pronouns: PronounEntry.FromPronouns(m.Pronouns, customPreferences) + ); + + public static PartialUser RenderPartialUser(User user) => + new( + user.LegacyId, + user.Id, + user.Username, + user.DisplayName, + user.Avatar, + CustomPreferences: RenderCustomPreferences(user.CustomPreferences) + ); }