Merge pull request 'Async migration PR 1: UserRepository, auth pipeline & user controllers' (#194) from feature/webapi/async-user-auth into develop
Reviewed-on: #194
This commit was merged in pull request #194.
This commit is contained in:
@@ -57,7 +57,7 @@ public class RefreshTokenTransformation(
|
||||
/// </summary>
|
||||
public const string ClaimType = "LoginValid";
|
||||
/// <inheritdoc />
|
||||
public Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
|
||||
public async Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
|
||||
{
|
||||
ClaimsIdentity claimsIdentity = new ClaimsIdentity();
|
||||
if (principal.HasClaim(claim => claim.Type == ClaimType)) {
|
||||
@@ -68,12 +68,12 @@ public class RefreshTokenTransformation(
|
||||
}
|
||||
}
|
||||
var userData = authService.GetUserData(principal);
|
||||
if (userData == null) return Task.FromResult(principal);
|
||||
var user = userRepository.Find(userData.Id);
|
||||
if (user == null) return Task.FromResult(principal);
|
||||
if (string.IsNullOrEmpty(user.RefreshToken) || user.RefreshTokenExpires == null || user.RefreshTokenExpires < DateTime.UtcNow) return Task.FromResult(principal);
|
||||
if (userData == null) return principal;
|
||||
var user = await userRepository.FindAsync(userData.Id, CancellationToken.None);
|
||||
if (user == null) return principal;
|
||||
if (string.IsNullOrEmpty(user.RefreshToken) || user.RefreshTokenExpires == null || user.RefreshTokenExpires < DateTime.UtcNow) return principal;
|
||||
claimsIdentity.AddClaim(new Claim(ClaimType, "true", ClaimValueTypes.Boolean));
|
||||
principal.AddIdentity(claimsIdentity);
|
||||
return Task.FromResult(principal);
|
||||
return principal;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -366,16 +366,17 @@ public class AssetController(
|
||||
/// Creates a new asset record.
|
||||
/// </summary>
|
||||
/// <param name="dto">The asset creation data.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>200 with the new asset ID, or an error response.</returns>
|
||||
[NonAction]
|
||||
[HttpPut]
|
||||
[Authorize(Roles = "Curator,Admin")]
|
||||
public ActionResult<Guid> Create([FromBody] AssetCreateDto dto) {
|
||||
public async Task<ActionResult<Guid>> Create([FromBody] AssetCreateDto dto, CancellationToken cancellationToken) {
|
||||
var uid = authService.GetUserData(User)?.Id;
|
||||
var accesslevel = authService.GetUserData(User)!.AccessLevel;
|
||||
|
||||
if (dto.UploadedBy.HasValue) {
|
||||
if (userRepository.Find(dto.UploadedBy.Value) == null) {
|
||||
if (await userRepository.FindAsync(dto.UploadedBy.Value, cancellationToken) == null) {
|
||||
logger.LogWarning($"User {dto.UploadedBy} does not exist in the database");
|
||||
return BadRequest();
|
||||
}
|
||||
|
||||
@@ -35,17 +35,18 @@ public class AuthController(
|
||||
/// Authenticates a user by username/email and password, issuing JWT and refresh tokens.
|
||||
/// </summary>
|
||||
/// <param name="userDto">The login credentials.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>An authentication result with tokens, or an error.</returns>
|
||||
[HttpPost("login")]
|
||||
public ActionResult<AuthResultDto> Login([FromBody] CredentialsDto userDto) {
|
||||
public async Task<ActionResult<AuthResultDto>> Login([FromBody] CredentialsDto userDto, CancellationToken cancellationToken) {
|
||||
User? user;
|
||||
|
||||
if (userDto.Identifier.Contains('@')) {
|
||||
//search for user in database (by email)
|
||||
user = userRepository.FindByEmail(userDto.Identifier);
|
||||
user = await userRepository.FindByEmailAsync(userDto.Identifier, cancellationToken);
|
||||
} else {
|
||||
//search for user in database (by username)
|
||||
user = userRepository.FindByUsername(userDto.Identifier);
|
||||
user = await userRepository.FindByUsernameAsync(userDto.Identifier, cancellationToken);
|
||||
}
|
||||
if (user == null) {
|
||||
return NotFound(new AuthResultDto() {
|
||||
@@ -79,16 +80,16 @@ public class AuthController(
|
||||
case PasswordVerificationResult.Failed: return NotFound(new AuthResultDto() { Success = false, ErrorMessage = "User or Password was wrong"});
|
||||
case PasswordVerificationResult.SuccessRehashNeeded:
|
||||
user.Password = passwordHasher.HashPassword(user, userDto.Password);
|
||||
userRepository.Save();
|
||||
await userRepository.SaveAsync(cancellationToken);
|
||||
break;
|
||||
case PasswordVerificationResult.Success: break;
|
||||
}
|
||||
|
||||
user.LastLogin = DateTime.UtcNow;
|
||||
|
||||
|
||||
var token = authService.GenerateAccessToken(user);
|
||||
var refreshToken = authService.GenerateRefreshToken(user);
|
||||
userRepository.Save();
|
||||
await userRepository.SaveAsync(cancellationToken);
|
||||
return new AuthResultDto() {
|
||||
UserId = user.Id,
|
||||
Token = token,
|
||||
@@ -111,10 +112,11 @@ public class AuthController(
|
||||
/// Registers a new user account.
|
||||
/// </summary>
|
||||
/// <param name="dto">The registration data.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>200 with the new user ID, 403 if registration is disabled, or 409 if the user already exists.</returns>
|
||||
//TODO: Switch Guid reply with Authentication Result (giving a complete reason in case of failure or giving the authentication token)
|
||||
[HttpPost("register")]
|
||||
public ActionResult Register([FromBody] UserRegisterDto dto) {
|
||||
public async Task<ActionResult> Register([FromBody] UserRegisterDto dto, CancellationToken cancellationToken) {
|
||||
var regSetting = settingsRepository.Get(Settings.UserRegistrationEnabled.AsString());
|
||||
if (regSetting?.Value != "true")
|
||||
return StatusCode(403, "Registration is currently disabled on this server");
|
||||
@@ -127,7 +129,8 @@ public class AuthController(
|
||||
"""
|
||||
);
|
||||
|
||||
if (userRepository.FindByEmail(dto.Email) != null || userRepository.FindByUsername(dto.Username) != null) {
|
||||
if (await userRepository.FindByEmailAsync(dto.Email, cancellationToken) != null
|
||||
|| await userRepository.FindByUsernameAsync(dto.Username, cancellationToken) != null) {
|
||||
return Conflict("User already exists");
|
||||
}
|
||||
|
||||
@@ -142,7 +145,7 @@ public class AuthController(
|
||||
user.Password = passwordHasher.HashPassword(user, dto.Password);
|
||||
|
||||
userRepository.Insert(user);
|
||||
userRepository.Save();
|
||||
await userRepository.SaveAsync(cancellationToken);
|
||||
return Ok(user.Id);
|
||||
}
|
||||
|
||||
@@ -152,17 +155,17 @@ public class AuthController(
|
||||
/// <returns>200 on success, 401 if unauthorized.</returns>
|
||||
[Authorize]
|
||||
[HttpPost("logout")]
|
||||
public ActionResult Logout() {
|
||||
public async Task<ActionResult> Logout(CancellationToken cancellationToken) {
|
||||
LactoseAuthenticatedUser? identity = authService.GetUserData(User);
|
||||
|
||||
if (identity == null) { return Unauthorized(); }
|
||||
|
||||
|
||||
//Reset token from the database
|
||||
User? user = userRepository.Find(identity.Id);
|
||||
User? user = await userRepository.FindAsync(identity.Id, cancellationToken);
|
||||
if (user == null) { return Unauthorized(); }
|
||||
user.RefreshToken = string.Empty;
|
||||
user.RefreshTokenExpires = null;
|
||||
userRepository.Save();
|
||||
await userRepository.SaveAsync(cancellationToken);
|
||||
|
||||
return Ok();
|
||||
}
|
||||
@@ -172,10 +175,11 @@ public class AuthController(
|
||||
/// Refreshes an expired JWT access token using a valid refresh token.
|
||||
/// </summary>
|
||||
/// <param name="token">The refresh token request data.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>A new access token, and optionally a new refresh token.</returns>
|
||||
[HttpPost("refresh")]
|
||||
public ActionResult<AuthResultDto> RefreshToken([FromBody] RefreshDto token) {
|
||||
User? user = userRepository.Find(token.UserId);
|
||||
public async Task<ActionResult<AuthResultDto>> RefreshToken([FromBody] RefreshDto token, CancellationToken cancellationToken) {
|
||||
User? user = await userRepository.FindAsync(token.UserId, cancellationToken);
|
||||
|
||||
if (user == null) {
|
||||
return NotFound(
|
||||
@@ -225,7 +229,7 @@ public class AuthController(
|
||||
authDto.RefreshToken = refreshToken;
|
||||
user.RefreshToken = refreshToken;
|
||||
user.RefreshTokenExpires = DateTime.UtcNow.AddMinutes(LactoseAuthService.LongExpirationTime);
|
||||
userRepository.Save();
|
||||
await userRepository.SaveAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return authDto;
|
||||
|
||||
@@ -29,10 +29,11 @@ namespace Lactose.Controllers {
|
||||
/// Creates a new user (admin only).
|
||||
/// </summary>
|
||||
/// <param name="userCreateDto">The user creation data.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>200 with the new user ID, or conflict if the email/username already exists.</returns>
|
||||
[Authorize(Roles = "Admin")]
|
||||
[HttpPut]
|
||||
public ActionResult<UserInfoDto> Create([FromBody] UserCreateDto userCreateDto) {
|
||||
public async Task<ActionResult<UserInfoDto>> Create([FromBody] UserCreateDto userCreateDto, CancellationToken cancellationToken) {
|
||||
LactoseAuthenticatedUser? authenticatedUser = authService.GetUserData(User);
|
||||
|
||||
if (authenticatedUser != null) {
|
||||
@@ -50,8 +51,9 @@ namespace Lactose.Controllers {
|
||||
"""
|
||||
);
|
||||
|
||||
if (userRepository.GetAll().Any(x => x.Email == userCreateDto.Email)) return Conflict("Email already exists");
|
||||
if (userRepository.GetAll().Any(x => x.Username == userCreateDto.Username)) return Conflict("Username already exists");
|
||||
List<User> users = await userRepository.GetAllAsync(cancellationToken);
|
||||
if (users.Any(x => x.Email == userCreateDto.Email)) return Conflict("Email already exists");
|
||||
if (users.Any(x => x.Username == userCreateDto.Username)) return Conflict("Username already exists");
|
||||
|
||||
var user = new User {
|
||||
Username = userCreateDto.Username,
|
||||
@@ -63,7 +65,7 @@ namespace Lactose.Controllers {
|
||||
user.Password = passwordHasher.HashPassword(user, userCreateDto.Password);
|
||||
|
||||
userRepository.Insert(user);
|
||||
userRepository.Save();
|
||||
await userRepository.SaveAsync(cancellationToken);
|
||||
|
||||
return Ok(user.Id);
|
||||
}
|
||||
@@ -73,9 +75,10 @@ namespace Lactose.Controllers {
|
||||
/// Gets a user by their ID.
|
||||
/// </summary>
|
||||
/// <param name="id">The user ID.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>The user info, or 404 if not found.</returns>
|
||||
[HttpGet("{id}")]
|
||||
public ActionResult<UserInfoDto> Get(Guid id) {
|
||||
public async Task<ActionResult<UserInfoDto>> Get(Guid id, CancellationToken cancellationToken) {
|
||||
LactoseAuthenticatedUser? authenticatedUser = authService.GetUserData(User);
|
||||
|
||||
if (authenticatedUser == null) {
|
||||
@@ -85,7 +88,7 @@ namespace Lactose.Controllers {
|
||||
|
||||
logger.LogDebug($"Sending user with id {id}");
|
||||
|
||||
User? user = userRepository.FindWithMaintainedPersons(id);
|
||||
User? user = await userRepository.FindWithMaintainedPersonsAsync(id, cancellationToken);
|
||||
|
||||
if (user is null) return NotFound();
|
||||
|
||||
@@ -98,13 +101,15 @@ namespace Lactose.Controllers {
|
||||
/// <returns>A list of all users.</returns>
|
||||
[Authorize(Roles = "Admin,Curator")]
|
||||
[HttpGet]
|
||||
public ActionResult<List<UserInfoDto>> GetAll() {
|
||||
public async Task<ActionResult<List<UserInfoDto>>> GetAll(CancellationToken cancellationToken) {
|
||||
LactoseAuthenticatedUser? authenticatedUser = authService.GetUserData(User);
|
||||
EAccessLevel viewerAccessLevel = authenticatedUser?.AccessLevel ?? EAccessLevel.User;
|
||||
|
||||
logger.LogDebug("Sending Users list");
|
||||
|
||||
return Ok(userRepository.GetAll().Select(u => u.ToGetUsersDto(viewerAccessLevel, authenticatedUser?.Id)).ToList());
|
||||
List<User> users = await userRepository.GetAllAsync(cancellationToken);
|
||||
|
||||
return Ok(users.Select(u => u.ToGetUsersDto(viewerAccessLevel, authenticatedUser?.Id)).ToList());
|
||||
}
|
||||
|
||||
|
||||
@@ -112,9 +117,10 @@ namespace Lactose.Controllers {
|
||||
/// Updates an existing user's profile.
|
||||
/// </summary>
|
||||
/// <param name="dto">The user update data.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>The updated user info, or 403/404 if unauthorized or not found.</returns>
|
||||
[HttpPost("update")]
|
||||
public ActionResult<UserInfoDto> Update([FromBody] UserUpdateDto dto) {
|
||||
public async Task<ActionResult<UserInfoDto>> Update([FromBody] UserUpdateDto dto, CancellationToken cancellationToken) {
|
||||
LactoseAuthenticatedUser? authenticatedUser = authService.GetUserData(User);
|
||||
|
||||
if (authenticatedUser == null) {
|
||||
@@ -136,7 +142,7 @@ namespace Lactose.Controllers {
|
||||
|
||||
if (authenticatedUser.AccessLevel != EAccessLevel.Admin && authenticatedUser.Id != dto.Id) return Forbid();
|
||||
|
||||
User? user = userRepository.Find(dto.Id);
|
||||
User? user = await userRepository.FindAsync(dto.Id, cancellationToken);
|
||||
if (user == null) return NotFound();
|
||||
|
||||
if (dto.Username != null) { user.Username = dto.Username; }
|
||||
@@ -172,11 +178,11 @@ namespace Lactose.Controllers {
|
||||
if (dto.AccessLevel != null) { user.AccessLevel = dto.AccessLevel.Value; }
|
||||
|
||||
if (dto.MaintainedPersonIds != null) {
|
||||
userRepository.SetMaintainedPersons(user.Id, dto.MaintainedPersonIds);
|
||||
await userRepository.SetMaintainedPersonsAsync(user.Id, dto.MaintainedPersonIds, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
userRepository.Save();
|
||||
await userRepository.SaveAsync(cancellationToken);
|
||||
return Ok(user.ToGetUsersDto(authenticatedUser.AccessLevel, authenticatedUser.Id));
|
||||
}
|
||||
|
||||
@@ -184,10 +190,11 @@ namespace Lactose.Controllers {
|
||||
/// Soft-deletes a user by their ID (admin only).
|
||||
/// </summary>
|
||||
/// <param name="id">The user ID.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>200 with the deleted user ID, or 404 if not found.</returns>
|
||||
[Authorize(Roles = "Admin")]
|
||||
[HttpDelete("{id}")]
|
||||
public ActionResult<Guid> Delete([FromRoute] Guid id) {
|
||||
public async Task<ActionResult<Guid>> Delete([FromRoute] Guid id, CancellationToken cancellationToken) {
|
||||
LactoseAuthenticatedUser? authenticatedUser = authService.GetUserData(User);
|
||||
|
||||
if (authenticatedUser == null) {
|
||||
@@ -197,14 +204,14 @@ namespace Lactose.Controllers {
|
||||
|
||||
logger.LogDebug($"Deleting user: {id}");
|
||||
|
||||
User? user = userRepository.Find(id);
|
||||
User? user = await userRepository.FindAsync(id, cancellationToken);
|
||||
|
||||
if (user == null) return NotFound();
|
||||
|
||||
user.DeletedAt = DateTime.UtcNow;
|
||||
|
||||
//TODO: eventually remove/hide all of the user content if required
|
||||
userRepository.Save();
|
||||
await userRepository.SaveAsync(cancellationToken);
|
||||
return Ok(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,49 +7,55 @@ namespace Lactose.Repositories;
|
||||
/// </summary>
|
||||
public interface IUserRepository : IDisposable {
|
||||
/// <summary>
|
||||
/// Return the list of all users
|
||||
/// Return the list of all users.
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
IEnumerable<User> GetAll();
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>The list of all users.</returns>
|
||||
Task<List<User>> GetAllAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Insert a new uer
|
||||
/// Insert a new user.
|
||||
/// </summary>
|
||||
/// <param name="user"></param>
|
||||
/// <param name="user">The user to insert.</param>
|
||||
void Insert(User user);
|
||||
|
||||
/// <summary>
|
||||
/// Saves all the changes to the Model
|
||||
/// Saves all the changes to the Model.
|
||||
/// </summary>
|
||||
void Save();
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
Task SaveAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Get the user from the database.
|
||||
/// Get the user from the database.
|
||||
/// </summary>
|
||||
/// <param name="id"></param>
|
||||
/// <returns>Returns the user if exist</returns>
|
||||
User? Find(Guid id);
|
||||
/// <param name="id">The user ID.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>Returns the user if exist.</returns>
|
||||
Task<User?> FindAsync(Guid id, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Find a user by email, expects that the email is unique
|
||||
/// Find a user by email, expects that the email is unique.
|
||||
/// </summary>
|
||||
/// <param name="email"></param>
|
||||
/// <returns>Returns the user if found </returns>
|
||||
User? FindByEmail(string email);
|
||||
/// <param name="email">The email address.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>Returns the user if found.</returns>
|
||||
Task<User?> FindByEmailAsync(string email, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Find a user by username, expects that the username is unique
|
||||
/// Find a user by username, expects that the username is unique.
|
||||
/// </summary>
|
||||
/// <param name="username"></param>
|
||||
/// <returns>Returns the user if found </returns>
|
||||
User? FindByUsername(string username);
|
||||
/// <param name="username">The username.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>Returns the user if found.</returns>
|
||||
Task<User?> FindByUsernameAsync(string username, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Get the user with their maintained persons eagerly loaded.
|
||||
/// </summary>
|
||||
/// <param name="id">The user ID.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>Returns the user with maintained persons if found.</returns>
|
||||
User? FindWithMaintainedPersons(Guid id);
|
||||
Task<User?> FindWithMaintainedPersonsAsync(Guid id, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Sets the list of person (cosplayer) IDs that a user maintains.
|
||||
@@ -57,5 +63,6 @@ public interface IUserRepository : IDisposable {
|
||||
/// </summary>
|
||||
/// <param name="userId">The user ID.</param>
|
||||
/// <param name="personIds">The person IDs to assign.</param>
|
||||
void SetMaintainedPersons(Guid userId, IEnumerable<Guid> personIds);
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
Task SetMaintainedPersonsAsync(Guid userId, IEnumerable<Guid> personIds, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Lactose.Context;
|
||||
using Lactose.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Lactose.Repositories;
|
||||
|
||||
@@ -9,30 +10,36 @@ public class UserRepository(LactoseDbContext context) : IUserRepository {
|
||||
public void Dispose() => context.Dispose();
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<User> GetAll() => context.Users.Include(u => u.MaintainedPersons).AsEnumerable();
|
||||
public Task<List<User>> GetAllAsync(CancellationToken cancellationToken) =>
|
||||
context.Users.Include(u => u.MaintainedPersons).ToListAsync(cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Insert(User user) => context.Users.Add(user);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Save() => context.SaveChanges();
|
||||
public Task SaveAsync(CancellationToken cancellationToken) => context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public User? Find(Guid id) => context.Users.Find(id);
|
||||
public async Task<User?> FindAsync(Guid id, CancellationToken cancellationToken) =>
|
||||
await context.Users.FindAsync([id], cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public User? FindByEmail(string email) => context.Users.FirstOrDefault(u => u.Email == email);
|
||||
public Task<User?> FindByEmailAsync(string email, CancellationToken cancellationToken) =>
|
||||
context.Users.FirstOrDefaultAsync(u => u.Email == email, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public User? FindByUsername(string username) => context.Users.FirstOrDefault(u => u.Username == username);
|
||||
public Task<User?> FindByUsernameAsync(string username, CancellationToken cancellationToken) =>
|
||||
context.Users.FirstOrDefaultAsync(u => u.Username == username, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public User? FindWithMaintainedPersons(Guid id) =>
|
||||
context.Users.Include(u => u.MaintainedPersons).FirstOrDefault(u => u.Id == id);
|
||||
public Task<User?> FindWithMaintainedPersonsAsync(Guid id, CancellationToken cancellationToken) =>
|
||||
context.Users.Include(u => u.MaintainedPersons).FirstOrDefaultAsync(u => u.Id == id, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void SetMaintainedPersons(Guid userId, IEnumerable<Guid> personIds) {
|
||||
var existing = context.PersonMaintainers.Where(pm => pm.UserId == userId);
|
||||
public async Task SetMaintainedPersonsAsync(Guid userId, IEnumerable<Guid> personIds, CancellationToken cancellationToken) {
|
||||
var existing = await context.PersonMaintainers
|
||||
.Where(pm => pm.UserId == userId)
|
||||
.ToListAsync(cancellationToken);
|
||||
context.PersonMaintainers.RemoveRange(existing);
|
||||
|
||||
foreach (var personId in personIds) {
|
||||
|
||||
Reference in New Issue
Block a user