feat(backend): add update member endpoint
This commit is contained in:
parent
8fe8755183
commit
e11e60e16b
4 changed files with 133 additions and 1 deletions
|
@ -92,6 +92,100 @@ public class MembersController(
|
||||||
return Ok(memberRenderer.RenderMember(member, CurrentToken));
|
return Ok(memberRenderer.RenderMember(member, CurrentToken));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPatch("/api/v2/users/@me/members/{memberRef}")]
|
||||||
|
[Authorize("member.update")]
|
||||||
|
public async Task<IActionResult> 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<MemberAvatarUpdateInvocable, AvatarUpdatePayload>(
|
||||||
|
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}")]
|
[HttpDelete("/api/v2/users/@me/members/{memberRef}")]
|
||||||
[Authorize("member.update")]
|
[Authorize("member.update")]
|
||||||
public async Task<IActionResult> DeleteMemberAsync(string memberRef)
|
public async Task<IActionResult> DeleteMemberAsync(string memberRef)
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using Coravel.Queuing.Interfaces;
|
using Coravel.Queuing.Interfaces;
|
||||||
|
using EntityFramework.Exceptions.Common;
|
||||||
using Foxnouns.Backend.Database;
|
using Foxnouns.Backend.Database;
|
||||||
using Foxnouns.Backend.Database.Models;
|
using Foxnouns.Backend.Database.Models;
|
||||||
using Foxnouns.Backend.Jobs;
|
using Foxnouns.Backend.Jobs;
|
||||||
|
@ -15,11 +16,14 @@ namespace Foxnouns.Backend.Controllers;
|
||||||
[Route("/api/v2/users")]
|
[Route("/api/v2/users")]
|
||||||
public class UsersController(
|
public class UsersController(
|
||||||
DatabaseContext db,
|
DatabaseContext db,
|
||||||
|
ILogger logger,
|
||||||
UserRendererService userRenderer,
|
UserRendererService userRenderer,
|
||||||
ISnowflakeGenerator snowflakeGenerator,
|
ISnowflakeGenerator snowflakeGenerator,
|
||||||
IQueue queue,
|
IQueue queue,
|
||||||
IClock clock) : ApiControllerBase
|
IClock clock) : ApiControllerBase
|
||||||
{
|
{
|
||||||
|
private readonly ILogger _logger = logger.ForContext<UsersController>();
|
||||||
|
|
||||||
[HttpGet("{userRef}")]
|
[HttpGet("{userRef}")]
|
||||||
[ProducesResponseType<UserRendererService.UserResponse>(statusCode: StatusCodes.Status200OK)]
|
[ProducesResponseType<UserRendererService.UserResponse>(statusCode: StatusCodes.Status200OK)]
|
||||||
public async Task<IActionResult> GetUserAsync(string userRef, CancellationToken ct = default)
|
public async Task<IActionResult> GetUserAsync(string userRef, CancellationToken ct = default)
|
||||||
|
@ -103,7 +107,17 @@ public class UsersController(
|
||||||
queue.QueueInvocableWithPayload<UserAvatarUpdateInvocable, AvatarUpdatePayload>(
|
queue.QueueInvocableWithPayload<UserAvatarUpdateInvocable, AvatarUpdatePayload>(
|
||||||
new AvatarUpdatePayload(CurrentUser!.Id, req.Avatar));
|
new AvatarUpdatePayload(CurrentUser!.Id, req.Avatar));
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
await db.SaveChangesAsync(ct);
|
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);
|
await tx.CommitAsync(ct);
|
||||||
return Ok(await userRenderer.RenderUserAsync(user, CurrentUser, renderMembers: false,
|
return Ok(await userRenderer.RenderUserAsync(user, CurrentUser, renderMembers: false,
|
||||||
renderAuthMethods: false, ct: ct));
|
renderAuthMethods: false, ct: ct));
|
||||||
|
|
|
@ -33,4 +33,25 @@ public static class FlagQueryExtensions
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static async Task<ValidationError?> 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;
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -6,6 +6,9 @@ namespace Foxnouns.Backend.Utils;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A base class used for PATCH requests which stores information on whether a key is explicitly set to null or not passed at all.
|
/// 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public abstract class PatchRequest
|
public abstract class PatchRequest
|
||||||
{
|
{
|
||||||
|
|
Loading…
Reference in a new issue