From e11e60e16bb43344a85419050d1012dc5acc2f31 Mon Sep 17 00:00:00 2001 From: sam Date: Sat, 28 Sep 2024 22:28:59 +0200 Subject: [PATCH] feat(backend): add update member endpoint --- .../Controllers/MembersController.cs | 94 +++++++++++++++++++ .../Controllers/UsersController.cs | 16 +++- .../Database/FlagQueryExtensions.cs | 21 +++++ Foxnouns.Backend/Utils/PatchRequest.cs | 3 + 4 files changed, 133 insertions(+), 1 deletion(-) diff --git a/Foxnouns.Backend/Controllers/MembersController.cs b/Foxnouns.Backend/Controllers/MembersController.cs index 8438161..1c4a783 100644 --- a/Foxnouns.Backend/Controllers/MembersController.cs +++ b/Foxnouns.Backend/Controllers/MembersController.cs @@ -92,6 +92,100 @@ public class MembersController( return Ok(memberRenderer.RenderMember(member, CurrentToken)); } + [HttpPatch("/api/v2/users/@me/members/{memberRef}")] + [Authorize("member.update")] + public async Task UpdateMemberAsync(string memberRef, [FromBody] UpdateMemberRequest req) + { + await using var tx = await db.Database.BeginTransactionAsync(); + var member = await db.ResolveMemberAsync(CurrentUser!.Id, memberRef); + var errors = new List<(string, ValidationError?)>(); + + if (req.Name != null) + { + errors.Add(("name", ValidationUtils.ValidateMemberName(req.Name))); + member.Name = req.Name; + } + + if (req.HasProperty(nameof(req.DisplayName))) + { + errors.Add(("display_name", ValidationUtils.ValidateDisplayName(req.DisplayName))); + member.DisplayName = req.DisplayName; + } + + if (req.HasProperty(nameof(req.Bio))) + { + errors.Add(("bio", ValidationUtils.ValidateBio(req.Bio))); + member.Bio = req.Bio; + } + + if (req.HasProperty(nameof(req.Links))) + { + errors.AddRange(ValidationUtils.ValidateLinks(req.Links)); + member.Links = req.Links ?? []; + } + + if (req.Names != null) + { + errors.AddRange(ValidationUtils.ValidateFieldEntries(req.Names, CurrentUser!.CustomPreferences, "names")); + member.Names = req.Names.ToList(); + } + + if (req.Pronouns != null) + { + errors.AddRange(ValidationUtils.ValidatePronouns(req.Pronouns, CurrentUser!.CustomPreferences)); + member.Pronouns = req.Pronouns.ToList(); + } + + if (req.Fields != null) + { + errors.AddRange(ValidationUtils.ValidateFields(req.Fields.ToList(), CurrentUser!.CustomPreferences)); + member.Fields = req.Fields.ToList(); + } + + if (req.Flags != null) + { + var flagError = await db.SetMemberFlagsAsync(CurrentUser!.Id, member.Id, req.Flags); + if (flagError != null) errors.Add(("flags", flagError)); + } + + if (req.HasProperty(nameof(req.Avatar))) + errors.Add(("avatar", ValidationUtils.ValidateAvatar(req.Avatar))); + + ValidationUtils.Validate(errors); + // This is fired off regardless of whether the transaction is committed + // (atomic operations are hard when combined with background jobs) + // so it's in a separate block to the validation above. + if (req.HasProperty(nameof(req.Avatar))) + queue.QueueInvocableWithPayload( + new AvatarUpdatePayload(member.Id, req.Avatar)); + try + { + await db.SaveChangesAsync(); + } + catch (UniqueConstraintException) + { + _logger.Debug("Could not update member {Id} due to name conflict ({CurrentName} / {NewName})", member.Id, + member.Name, req.Name); + throw new ApiError.BadRequest("A member with that name already exists", "name", req.Name!); + } + + await tx.CommitAsync(); + return Ok(memberRenderer.RenderMember(member, CurrentToken)); + } + + public class UpdateMemberRequest : PatchRequest + { + public string? Name { get; init; } + public string? DisplayName { get; init; } + public string? Bio { get; init; } + public string? Avatar { get; init; } + public string[]? Links { get; init; } + public FieldEntry[]? Names { get; init; } + public Pronoun[]? Pronouns { get; init; } + public Field[]? Fields { get; init; } + public Snowflake[]? Flags { get; init; } + } + [HttpDelete("/api/v2/users/@me/members/{memberRef}")] [Authorize("member.update")] public async Task DeleteMemberAsync(string memberRef) diff --git a/Foxnouns.Backend/Controllers/UsersController.cs b/Foxnouns.Backend/Controllers/UsersController.cs index c976183..e292fc3 100644 --- a/Foxnouns.Backend/Controllers/UsersController.cs +++ b/Foxnouns.Backend/Controllers/UsersController.cs @@ -1,5 +1,6 @@ using System.Diagnostics.CodeAnalysis; using Coravel.Queuing.Interfaces; +using EntityFramework.Exceptions.Common; using Foxnouns.Backend.Database; using Foxnouns.Backend.Database.Models; using Foxnouns.Backend.Jobs; @@ -15,11 +16,14 @@ namespace Foxnouns.Backend.Controllers; [Route("/api/v2/users")] public class UsersController( DatabaseContext db, + ILogger logger, UserRendererService userRenderer, ISnowflakeGenerator snowflakeGenerator, IQueue queue, IClock clock) : ApiControllerBase { + private readonly ILogger _logger = logger.ForContext(); + [HttpGet("{userRef}")] [ProducesResponseType(statusCode: StatusCodes.Status200OK)] public async Task GetUserAsync(string userRef, CancellationToken ct = default) @@ -103,7 +107,17 @@ public class UsersController( queue.QueueInvocableWithPayload( new AvatarUpdatePayload(CurrentUser!.Id, req.Avatar)); - await db.SaveChangesAsync(ct); + try + { + await db.SaveChangesAsync(ct); + } + catch (UniqueConstraintException) + { + _logger.Debug("Could not update user {Id} due to name conflict ({CurrentName} / {NewName})", user.Id, + user.Username, req.Username); + throw new ApiError.BadRequest("That username is already taken.", "username", req.Username!); + } + await tx.CommitAsync(ct); return Ok(await userRenderer.RenderUserAsync(user, CurrentUser, renderMembers: false, renderAuthMethods: false, ct: ct)); diff --git a/Foxnouns.Backend/Database/FlagQueryExtensions.cs b/Foxnouns.Backend/Database/FlagQueryExtensions.cs index 39272af..cbdd710 100644 --- a/Foxnouns.Backend/Database/FlagQueryExtensions.cs +++ b/Foxnouns.Backend/Database/FlagQueryExtensions.cs @@ -33,4 +33,25 @@ public static class FlagQueryExtensions return null; } + + public static async Task SetMemberFlagsAsync(this DatabaseContext db, Snowflake userId, + Snowflake memberId, Snowflake[] flagIds) + { + var currentFlags = await db.MemberFlags.Where(f => f.MemberId == memberId).ToListAsync(); + foreach (var flag in currentFlags) + db.MemberFlags.Remove(flag); + + if (flagIds.Length == 0) return null; + if (flagIds.Length > 100) return ValidationError.LengthError("Too many profile flags", 0, 100, flagIds.Length); + + var flags = await db.GetFlagsAsync(userId); + var unknownFlagIds = flagIds.Where(id => flags.All(f => f.Id != id)).ToArray(); + if (unknownFlagIds.Length != 0) + return ValidationError.GenericValidationError("Unknown flag IDs", unknownFlagIds); + + var memberFlags = flagIds.Select(id => new MemberFlag { PrideFlagId = id, MemberId = memberId }); + db.MemberFlags.AddRange(memberFlags); + + return null; + } } \ No newline at end of file diff --git a/Foxnouns.Backend/Utils/PatchRequest.cs b/Foxnouns.Backend/Utils/PatchRequest.cs index da98615..4e3ac1c 100644 --- a/Foxnouns.Backend/Utils/PatchRequest.cs +++ b/Foxnouns.Backend/Utils/PatchRequest.cs @@ -6,6 +6,9 @@ namespace Foxnouns.Backend.Utils; /// /// A base class used for PATCH requests which stores information on whether a key is explicitly set to null or not passed at all. +/// +/// HasProperty() should not be used for properties that cannot be set to null--a null value should be treated +/// as an unset value in those cases. /// public abstract class PatchRequest {