Extract game user admin service
This commit is contained in:
@@ -27,6 +27,7 @@ Backend:
|
|||||||
- `RpgRoller/Services/GameCharacterService.cs`: extracted character creation, transfer, activation, deletion, and owner-scoped listing workflows behind the same facade contract
|
- `RpgRoller/Services/GameCharacterService.cs`: extracted character creation, transfer, activation, deletion, and owner-scoped listing workflows behind the same facade contract
|
||||||
- `RpgRoller/Services/GameSkillService.cs`: extracted skill-group CRUD, skill CRUD, sheet shaping, and ruleset-specific validation orchestration behind the same facade contract
|
- `RpgRoller/Services/GameSkillService.cs`: extracted skill-group CRUD, skill CRUD, sheet shaping, and ruleset-specific validation orchestration behind the same facade contract
|
||||||
- `RpgRoller/Services/GameRollService.cs`: extracted roll execution, log shaping, roll detail visibility, and campaign-state snapshot reads behind the same facade contract
|
- `RpgRoller/Services/GameRollService.cs`: extracted roll execution, log shaping, roll detail visibility, and campaign-state snapshot reads behind the same facade contract
|
||||||
|
- `RpgRoller/Services/GameUserAdministrationService.cs`: extracted username reads, admin user listings, role updates, and account deletion workflows behind the same facade contract
|
||||||
|
|
||||||
Frontend:
|
Frontend:
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ public sealed class ServiceAdminAndCampaignDeletionTests
|
|||||||
var adminSession = ServiceTestSupport.GetValue(service.Login("admin", "Password123")).SessionToken;
|
var adminSession = ServiceTestSupport.GetValue(service.Login("admin", "Password123")).SessionToken;
|
||||||
var memberSession = ServiceTestSupport.GetValue(service.Login("member", "Password123")).SessionToken;
|
var memberSession = ServiceTestSupport.GetValue(service.Login("member", "Password123")).SessionToken;
|
||||||
|
|
||||||
|
var usernames = ServiceTestSupport.GetValue(service.GetUsernames(memberSession));
|
||||||
|
Assert.Equal(["admin", "member"], usernames);
|
||||||
|
|
||||||
var forbiddenList = service.GetUsers(memberSession);
|
var forbiddenList = service.GetUsers(memberSession);
|
||||||
Assert.False(forbiddenList.Succeeded);
|
Assert.False(forbiddenList.Succeeded);
|
||||||
|
|
||||||
@@ -28,6 +31,10 @@ public sealed class ServiceAdminAndCampaignDeletionTests
|
|||||||
var promoted = ServiceTestSupport.GetValue(service.UpdateUserRoles(adminSession, memberUser.Id, [UserRoles.Admin]));
|
var promoted = ServiceTestSupport.GetValue(service.UpdateUserRoles(adminSession, memberUser.Id, [UserRoles.Admin]));
|
||||||
Assert.Contains(promoted.Roles, role => string.Equals(role, UserRoles.Admin, StringComparison.OrdinalIgnoreCase));
|
Assert.Contains(promoted.Roles, role => string.Equals(role, UserRoles.Admin, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
var invalidRole = service.UpdateUserRoles(adminSession, memberUser.Id, [UserRoles.Admin, "gm"]);
|
||||||
|
Assert.False(invalidRole.Succeeded);
|
||||||
|
Assert.Equal("invalid_role", invalidRole.Error?.Code);
|
||||||
|
|
||||||
var selfDemote = service.UpdateUserRoles(adminSession, bootstrapAdmin.Id, Array.Empty<string>());
|
var selfDemote = service.UpdateUserRoles(adminSession, bootstrapAdmin.Id, Array.Empty<string>());
|
||||||
Assert.False(selfDemote.Succeeded);
|
Assert.False(selfDemote.Succeeded);
|
||||||
|
|
||||||
@@ -111,6 +118,9 @@ public sealed class ServiceAdminAndCampaignDeletionTests
|
|||||||
var deleteResult = ServiceTestSupport.GetValue(service.DeleteUser(adminSession, gmUser.Id));
|
var deleteResult = ServiceTestSupport.GetValue(service.DeleteUser(adminSession, gmUser.Id));
|
||||||
Assert.True(deleteResult);
|
Assert.True(deleteResult);
|
||||||
|
|
||||||
|
Assert.Null(service.GetUserBySession(gmSession));
|
||||||
|
Assert.False(service.GetMe(gmSession).Succeeded);
|
||||||
|
Assert.False(service.GetUsernames(gmSession).Succeeded);
|
||||||
Assert.False(service.GetCampaign(adminSession, gmOwnedCampaign.Id).Succeeded);
|
Assert.False(service.GetCampaign(adminSession, gmOwnedCampaign.Id).Succeeded);
|
||||||
|
|
||||||
var playerCharacters = ServiceTestSupport.GetValue(service.GetOwnCharacters(playerSession));
|
var playerCharacters = ServiceTestSupport.GetValue(service.GetOwnCharacters(playerSession));
|
||||||
|
|||||||
@@ -11,22 +11,14 @@ public sealed class GameService : IGameService
|
|||||||
public GameService(IDbContextFactory<RpgRollerDbContext> dbContextFactory, IPasswordHasher<UserAccount> passwordHasher, IDiceRoller diceRoller)
|
public GameService(IDbContextFactory<RpgRollerDbContext> dbContextFactory, IPasswordHasher<UserAccount> passwordHasher, IDiceRoller diceRoller)
|
||||||
{
|
{
|
||||||
m_StateStore = new();
|
m_StateStore = new();
|
||||||
m_CampaignsById = m_StateStore.CampaignsById;
|
|
||||||
m_CampaignStateById = m_StateStore.CampaignStateById;
|
|
||||||
m_CharactersById = m_StateStore.CharactersById;
|
|
||||||
m_Gate = m_StateStore.Gate;
|
m_Gate = m_StateStore.Gate;
|
||||||
m_RollLog = m_StateStore.RollLog;
|
|
||||||
m_SessionsByToken = m_StateStore.SessionsByToken;
|
|
||||||
m_SkillGroupsById = m_StateStore.SkillGroupsById;
|
|
||||||
m_SkillsById = m_StateStore.SkillsById;
|
|
||||||
m_UserIdsByUsername = m_StateStore.UserIdsByUsername;
|
|
||||||
m_UsersById = m_StateStore.UsersById;
|
|
||||||
m_PersistenceService = new(dbContextFactory, m_StateStore);
|
m_PersistenceService = new(dbContextFactory, m_StateStore);
|
||||||
m_AuthService = new(m_StateStore, passwordHasher, m_PersistenceService);
|
m_AuthService = new(m_StateStore, passwordHasher, m_PersistenceService);
|
||||||
m_CampaignService = new(m_StateStore, m_PersistenceService);
|
m_CampaignService = new(m_StateStore, m_PersistenceService);
|
||||||
m_CharacterService = new(m_StateStore, m_PersistenceService);
|
m_CharacterService = new(m_StateStore, m_PersistenceService);
|
||||||
m_RollService = new(m_StateStore, m_PersistenceService, diceRoller);
|
m_RollService = new(m_StateStore, m_PersistenceService, diceRoller);
|
||||||
m_SkillService = new(m_StateStore, m_PersistenceService);
|
m_SkillService = new(m_StateStore, m_PersistenceService);
|
||||||
|
m_UserAdministrationService = new(m_StateStore, m_PersistenceService);
|
||||||
LoadStateFromDatabase();
|
LoadStateFromDatabase();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,109 +79,22 @@ public sealed class GameService : IGameService
|
|||||||
|
|
||||||
public ServiceResult<IReadOnlyList<string>> GetUsernames(string sessionToken)
|
public ServiceResult<IReadOnlyList<string>> GetUsernames(string sessionToken)
|
||||||
{
|
{
|
||||||
lock (m_Gate)
|
return m_UserAdministrationService.GetUsernames(sessionToken);
|
||||||
{
|
|
||||||
var user = ResolveUserLocked(sessionToken);
|
|
||||||
if (user is null)
|
|
||||||
return ServiceResult<IReadOnlyList<string>>.Failure("unauthorized", "You must be logged in.");
|
|
||||||
|
|
||||||
var usernames = m_UsersById.Values.Select(account => account.Username).OrderBy(username => username, StringComparer.OrdinalIgnoreCase).ToArray();
|
|
||||||
return ServiceResult<IReadOnlyList<string>>.Success(usernames);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public ServiceResult<IReadOnlyList<AdminUserSummary>> GetUsers(string sessionToken)
|
public ServiceResult<IReadOnlyList<AdminUserSummary>> GetUsers(string sessionToken)
|
||||||
{
|
{
|
||||||
lock (m_Gate)
|
return m_UserAdministrationService.GetUsers(sessionToken);
|
||||||
{
|
|
||||||
var user = ResolveUserLocked(sessionToken);
|
|
||||||
if (user is null)
|
|
||||||
return ServiceResult<IReadOnlyList<AdminUserSummary>>.Failure("unauthorized", "You must be logged in.");
|
|
||||||
|
|
||||||
if (!UserHasRoleLocked(user, UserRoles.Admin))
|
|
||||||
return ServiceResult<IReadOnlyList<AdminUserSummary>>.Failure("forbidden", "Admin role is required.");
|
|
||||||
|
|
||||||
var users = m_UsersById.Values
|
|
||||||
.OrderBy(account => account.Username, StringComparer.OrdinalIgnoreCase)
|
|
||||||
.Select(ToAdminUserSummary)
|
|
||||||
.ToArray();
|
|
||||||
return ServiceResult<IReadOnlyList<AdminUserSummary>>.Success(users);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public ServiceResult<AdminUserSummary> UpdateUserRoles(string sessionToken, Guid userId, IReadOnlyList<string> roles)
|
public ServiceResult<AdminUserSummary> UpdateUserRoles(string sessionToken, Guid userId, IReadOnlyList<string> roles)
|
||||||
{
|
{
|
||||||
lock (m_Gate)
|
return m_UserAdministrationService.UpdateUserRoles(sessionToken, userId, roles);
|
||||||
{
|
|
||||||
var user = ResolveUserLocked(sessionToken);
|
|
||||||
if (user is null)
|
|
||||||
return ServiceResult<AdminUserSummary>.Failure("unauthorized", "You must be logged in.");
|
|
||||||
|
|
||||||
if (!UserHasRoleLocked(user, UserRoles.Admin))
|
|
||||||
return ServiceResult<AdminUserSummary>.Failure("forbidden", "Admin role is required.");
|
|
||||||
|
|
||||||
if (!m_UsersById.TryGetValue(userId, out var targetUser))
|
|
||||||
return ServiceResult<AdminUserSummary>.Failure("user_not_found", "User was not found.");
|
|
||||||
|
|
||||||
var normalizedRoles = RoleSerializer.Normalize(roles);
|
|
||||||
if (normalizedRoles.Any(role => !string.Equals(role, UserRoles.Admin, StringComparison.Ordinal)))
|
|
||||||
return ServiceResult<AdminUserSummary>.Failure("invalid_role", "Unsupported role.");
|
|
||||||
|
|
||||||
if (user.Id == targetUser.Id && !normalizedRoles.Contains(UserRoles.Admin, StringComparer.Ordinal))
|
|
||||||
return ServiceResult<AdminUserSummary>.Failure("forbidden", "You cannot remove your own admin role.");
|
|
||||||
|
|
||||||
targetUser.Roles = RoleSerializer.Serialize(normalizedRoles);
|
|
||||||
PersistStateLocked();
|
|
||||||
return ServiceResult<AdminUserSummary>.Success(ToAdminUserSummary(targetUser));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public ServiceResult<bool> DeleteUser(string sessionToken, Guid userId)
|
public ServiceResult<bool> DeleteUser(string sessionToken, Guid userId)
|
||||||
{
|
{
|
||||||
lock (m_Gate)
|
return m_UserAdministrationService.DeleteUser(sessionToken, userId);
|
||||||
{
|
|
||||||
var user = ResolveUserLocked(sessionToken);
|
|
||||||
if (user is null)
|
|
||||||
return ServiceResult<bool>.Failure("unauthorized", "You must be logged in.");
|
|
||||||
|
|
||||||
if (!UserHasRoleLocked(user, UserRoles.Admin))
|
|
||||||
return ServiceResult<bool>.Failure("forbidden", "Admin role is required.");
|
|
||||||
|
|
||||||
if (user.Id == userId)
|
|
||||||
return ServiceResult<bool>.Failure("forbidden", "You cannot delete your own account.");
|
|
||||||
|
|
||||||
if (!m_UsersById.TryGetValue(userId, out var targetUser))
|
|
||||||
return ServiceResult<bool>.Failure("user_not_found", "User was not found.");
|
|
||||||
|
|
||||||
var gmCampaignIds = m_CampaignsById.Values.Where(campaign => campaign.GmUserId == targetUser.Id).Select(campaign => campaign.Id).ToArray();
|
|
||||||
var gmCampaignIdSet = gmCampaignIds.ToHashSet();
|
|
||||||
var preservedCharacterIds = m_CharactersById.Values
|
|
||||||
.Where(character => character.CampaignId.HasValue && gmCampaignIdSet.Contains(character.CampaignId.Value))
|
|
||||||
.Select(character => character.Id)
|
|
||||||
.ToHashSet();
|
|
||||||
|
|
||||||
foreach (var campaignId in gmCampaignIds)
|
|
||||||
DeleteCampaignLocked(campaignId);
|
|
||||||
|
|
||||||
var ownedCharacterIds = m_CharactersById.Values
|
|
||||||
.Where(character => character.OwnerUserId == targetUser.Id && !preservedCharacterIds.Contains(character.Id))
|
|
||||||
.Select(character => character.Id)
|
|
||||||
.ToArray();
|
|
||||||
foreach (var characterId in ownedCharacterIds)
|
|
||||||
DeleteCharacterLocked(characterId);
|
|
||||||
|
|
||||||
m_RollLog.RemoveAll(entry => entry.RollerUserId == targetUser.Id);
|
|
||||||
|
|
||||||
var staleSessions = m_SessionsByToken.Values.Where(session => session.UserId == targetUser.Id).Select(session => session.Token).ToArray();
|
|
||||||
foreach (var token in staleSessions)
|
|
||||||
m_SessionsByToken.Remove(token);
|
|
||||||
|
|
||||||
m_UsersById.Remove(targetUser.Id);
|
|
||||||
m_UserIdsByUsername.Remove(targetUser.UsernameNormalized);
|
|
||||||
|
|
||||||
PersistStateLocked();
|
|
||||||
return ServiceResult<bool>.Success(true);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public ServiceResult<CharacterSummary> CreateCharacter(string sessionToken, string name, Guid campaignId)
|
public ServiceResult<CharacterSummary> CreateCharacter(string sessionToken, string name, Guid campaignId)
|
||||||
@@ -282,110 +187,12 @@ public sealed class GameService : IGameService
|
|||||||
return m_RollService.GetCampaignStateSnapshot(sessionToken, campaignId);
|
return m_RollService.GetCampaignStateSnapshot(sessionToken, campaignId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static UserSummary ToUserSummary(UserAccount user)
|
|
||||||
{
|
|
||||||
return new(user.Id, user.Username, user.DisplayName, RoleSerializer.Parse(user.Roles));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static AdminUserSummary ToAdminUserSummary(UserAccount user)
|
|
||||||
{
|
|
||||||
return new(user.Id, user.Username, user.DisplayName, RoleSerializer.Parse(user.Roles));
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool TryGetCurrentCampaignIdLocked(UserAccount user, out Guid campaignId)
|
|
||||||
{
|
|
||||||
campaignId = Guid.Empty;
|
|
||||||
if (user.ActiveCharacterId is not Guid activeCharacterId)
|
|
||||||
return false;
|
|
||||||
|
|
||||||
if (!m_CharactersById.TryGetValue(activeCharacterId, out var character))
|
|
||||||
{
|
|
||||||
user.ActiveCharacterId = null;
|
|
||||||
PersistStateLocked();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!character.CampaignId.HasValue)
|
|
||||||
return false;
|
|
||||||
|
|
||||||
campaignId = character.CampaignId.Value;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool TryResolveCharacterCampaignLocked(Character character, out Campaign campaign, out ServiceError? error)
|
|
||||||
{
|
|
||||||
campaign = default!;
|
|
||||||
if (!character.CampaignId.HasValue || !m_CampaignsById.TryGetValue(character.CampaignId.Value, out var resolvedCampaign) || resolvedCampaign is null)
|
|
||||||
{
|
|
||||||
error = new ServiceError("character_not_in_campaign", "Character is not linked to a campaign.");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
campaign = resolvedCampaign;
|
|
||||||
error = null;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void DeleteCampaignLocked(Guid campaignId)
|
|
||||||
{
|
|
||||||
if (!m_CampaignsById.Remove(campaignId))
|
|
||||||
return;
|
|
||||||
|
|
||||||
var affectedCharacterIds = m_CharactersById.Values.Where(character => character.CampaignId == campaignId).Select(character => character.Id).ToArray();
|
|
||||||
foreach (var characterId in affectedCharacterIds)
|
|
||||||
m_CharactersById[characterId].CampaignId = null;
|
|
||||||
|
|
||||||
m_RollLog.RemoveAll(entry => entry.CampaignId == campaignId);
|
|
||||||
m_CampaignStateById.Remove(campaignId);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void DeleteCharacterLocked(Guid characterId)
|
|
||||||
{
|
|
||||||
if (!m_CharactersById.TryGetValue(characterId, out var character))
|
|
||||||
return;
|
|
||||||
|
|
||||||
var campaignId = character.CampaignId;
|
|
||||||
m_CharactersById.Remove(characterId);
|
|
||||||
|
|
||||||
var skillGroupIds = m_SkillGroupsById.Values.Where(group => group.CharacterId == characterId).Select(group => group.Id).ToHashSet();
|
|
||||||
foreach (var skillGroupId in skillGroupIds)
|
|
||||||
m_SkillGroupsById.Remove(skillGroupId);
|
|
||||||
|
|
||||||
var skillIds = m_SkillsById.Values.Where(skill => skill.CharacterId == characterId).Select(skill => skill.Id).ToHashSet();
|
|
||||||
foreach (var skillId in skillIds)
|
|
||||||
m_SkillsById.Remove(skillId);
|
|
||||||
|
|
||||||
m_RollLog.RemoveAll(entry => entry.CharacterId == characterId || skillIds.Contains(entry.SkillId));
|
|
||||||
|
|
||||||
foreach (var user in m_UsersById.Values.Where(account => account.ActiveCharacterId == characterId))
|
|
||||||
user.ActiveCharacterId = null;
|
|
||||||
|
|
||||||
RemoveCharacterStateLocked(campaignId, characterId);
|
|
||||||
TouchRosterLocked(campaignId);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool UserHasRoleLocked(UserAccount user, string role)
|
|
||||||
{
|
|
||||||
return RoleSerializer.HasRole(user.Roles, role);
|
|
||||||
}
|
|
||||||
|
|
||||||
private UserAccount? ResolveUserLocked(string sessionToken)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(sessionToken))
|
|
||||||
return null;
|
|
||||||
|
|
||||||
if (!m_SessionsByToken.TryGetValue(sessionToken, out var session))
|
|
||||||
return null;
|
|
||||||
|
|
||||||
return m_UsersById.GetValueOrDefault(session.UserId);
|
|
||||||
}
|
|
||||||
|
|
||||||
private GameCampaignStateTracker GetOrCreateCampaignStateLocked(Guid campaignId)
|
private GameCampaignStateTracker GetOrCreateCampaignStateLocked(Guid campaignId)
|
||||||
{
|
{
|
||||||
if (!m_CampaignStateById.TryGetValue(campaignId, out var state))
|
if (!m_StateStore.CampaignStateById.TryGetValue(campaignId, out var state))
|
||||||
{
|
{
|
||||||
state = new GameCampaignStateTracker();
|
state = new GameCampaignStateTracker();
|
||||||
m_CampaignStateById[campaignId] = state;
|
m_StateStore.CampaignStateById[campaignId] = state;
|
||||||
}
|
}
|
||||||
|
|
||||||
return state;
|
return state;
|
||||||
@@ -393,49 +200,21 @@ public sealed class GameService : IGameService
|
|||||||
|
|
||||||
private void AddCharacterStateLocked(Guid? campaignId, Guid characterId)
|
private void AddCharacterStateLocked(Guid? campaignId, Guid characterId)
|
||||||
{
|
{
|
||||||
if (!campaignId.HasValue || !m_CampaignsById.ContainsKey(campaignId.Value))
|
if (!campaignId.HasValue || !m_StateStore.CampaignsById.ContainsKey(campaignId.Value))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
var state = GetOrCreateCampaignStateLocked(campaignId.Value);
|
var state = GetOrCreateCampaignStateLocked(campaignId.Value);
|
||||||
state.CharacterVersions[characterId] = 1;
|
state.CharacterVersions[characterId] = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void RemoveCharacterStateLocked(Guid? campaignId, Guid characterId)
|
|
||||||
{
|
|
||||||
if (!campaignId.HasValue || !m_CampaignStateById.TryGetValue(campaignId.Value, out var state))
|
|
||||||
return;
|
|
||||||
|
|
||||||
state.CharacterVersions.Remove(characterId);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void TouchRosterLocked(Guid? campaignId)
|
|
||||||
{
|
|
||||||
if (!campaignId.HasValue || !m_CampaignsById.ContainsKey(campaignId.Value))
|
|
||||||
return;
|
|
||||||
|
|
||||||
var state = GetOrCreateCampaignStateLocked(campaignId.Value);
|
|
||||||
state.TotalVersion += 1;
|
|
||||||
state.RosterVersion += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void TouchLogLocked(Guid? campaignId)
|
|
||||||
{
|
|
||||||
if (!campaignId.HasValue || !m_CampaignsById.ContainsKey(campaignId.Value))
|
|
||||||
return;
|
|
||||||
|
|
||||||
var state = GetOrCreateCampaignStateLocked(campaignId.Value);
|
|
||||||
state.TotalVersion += 1;
|
|
||||||
state.LogVersion += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void RebuildCampaignStateLocked()
|
private void RebuildCampaignStateLocked()
|
||||||
{
|
{
|
||||||
m_CampaignStateById.Clear();
|
m_StateStore.CampaignStateById.Clear();
|
||||||
|
|
||||||
foreach (var campaignId in m_CampaignsById.Keys)
|
foreach (var campaignId in m_StateStore.CampaignsById.Keys)
|
||||||
m_CampaignStateById[campaignId] = new GameCampaignStateTracker();
|
m_StateStore.CampaignStateById[campaignId] = new GameCampaignStateTracker();
|
||||||
|
|
||||||
foreach (var character in m_CharactersById.Values.Where(character => character.CampaignId.HasValue))
|
foreach (var character in m_StateStore.CharactersById.Values.Where(character => character.CampaignId.HasValue))
|
||||||
AddCharacterStateLocked(character.CampaignId, character.Id);
|
AddCharacterStateLocked(character.CampaignId, character.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -446,26 +225,13 @@ public sealed class GameService : IGameService
|
|||||||
RebuildCampaignStateLocked();
|
RebuildCampaignStateLocked();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void PersistStateLocked()
|
|
||||||
{
|
|
||||||
m_PersistenceService.PersistStateLocked();
|
|
||||||
}
|
|
||||||
|
|
||||||
private readonly Dictionary<Guid, Campaign> m_CampaignsById;
|
|
||||||
private readonly Dictionary<Guid, GameCampaignStateTracker> m_CampaignStateById;
|
|
||||||
private readonly GameCampaignService m_CampaignService;
|
private readonly GameCampaignService m_CampaignService;
|
||||||
private readonly GameCharacterService m_CharacterService;
|
private readonly GameCharacterService m_CharacterService;
|
||||||
private readonly Dictionary<Guid, Character> m_CharactersById;
|
|
||||||
private readonly GameAuthService m_AuthService;
|
private readonly GameAuthService m_AuthService;
|
||||||
private readonly object m_Gate;
|
private readonly object m_Gate;
|
||||||
private readonly GamePersistenceService m_PersistenceService;
|
private readonly GamePersistenceService m_PersistenceService;
|
||||||
private readonly List<RollLogEntry> m_RollLog;
|
|
||||||
private readonly GameRollService m_RollService;
|
private readonly GameRollService m_RollService;
|
||||||
private readonly Dictionary<string, UserSession> m_SessionsByToken;
|
|
||||||
private readonly GameSkillService m_SkillService;
|
private readonly GameSkillService m_SkillService;
|
||||||
private readonly Dictionary<Guid, SkillGroup> m_SkillGroupsById;
|
|
||||||
private readonly Dictionary<Guid, Skill> m_SkillsById;
|
|
||||||
private readonly GameStateStore m_StateStore;
|
private readonly GameStateStore m_StateStore;
|
||||||
private readonly Dictionary<string, Guid> m_UserIdsByUsername;
|
private readonly GameUserAdministrationService m_UserAdministrationService;
|
||||||
private readonly Dictionary<Guid, UserAccount> m_UsersById;
|
|
||||||
}
|
}
|
||||||
|
|||||||
231
RpgRoller/Services/GameUserAdministrationService.cs
Normal file
231
RpgRoller/Services/GameUserAdministrationService.cs
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
using RpgRoller.Contracts;
|
||||||
|
using RpgRoller.Domain;
|
||||||
|
|
||||||
|
namespace RpgRoller.Services;
|
||||||
|
|
||||||
|
public sealed class GameUserAdministrationService
|
||||||
|
{
|
||||||
|
public GameUserAdministrationService(GameStateStore stateStore, GamePersistenceService persistenceService)
|
||||||
|
{
|
||||||
|
m_StateStore = stateStore;
|
||||||
|
m_PersistenceService = persistenceService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ServiceResult<IReadOnlyList<string>> GetUsernames(string sessionToken)
|
||||||
|
{
|
||||||
|
lock (m_StateStore.Gate)
|
||||||
|
{
|
||||||
|
var user = ResolveUserLocked(sessionToken);
|
||||||
|
if (user is null)
|
||||||
|
return ServiceResult<IReadOnlyList<string>>.Failure("unauthorized", "You must be logged in.");
|
||||||
|
|
||||||
|
var usernames = m_StateStore.UsersById.Values
|
||||||
|
.Select(account => account.Username)
|
||||||
|
.OrderBy(username => username, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
return ServiceResult<IReadOnlyList<string>>.Success(usernames);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ServiceResult<IReadOnlyList<AdminUserSummary>> GetUsers(string sessionToken)
|
||||||
|
{
|
||||||
|
lock (m_StateStore.Gate)
|
||||||
|
{
|
||||||
|
var user = ResolveUserLocked(sessionToken);
|
||||||
|
if (user is null)
|
||||||
|
return ServiceResult<IReadOnlyList<AdminUserSummary>>.Failure("unauthorized", "You must be logged in.");
|
||||||
|
|
||||||
|
if (!UserHasRoleLocked(user, UserRoles.Admin))
|
||||||
|
return ServiceResult<IReadOnlyList<AdminUserSummary>>.Failure("forbidden", "Admin role is required.");
|
||||||
|
|
||||||
|
var users = m_StateStore.UsersById.Values
|
||||||
|
.OrderBy(account => account.Username, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Select(ToAdminUserSummary)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
return ServiceResult<IReadOnlyList<AdminUserSummary>>.Success(users);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ServiceResult<AdminUserSummary> UpdateUserRoles(string sessionToken, Guid userId, IReadOnlyList<string> roles)
|
||||||
|
{
|
||||||
|
lock (m_StateStore.Gate)
|
||||||
|
{
|
||||||
|
var user = ResolveUserLocked(sessionToken);
|
||||||
|
if (user is null)
|
||||||
|
return ServiceResult<AdminUserSummary>.Failure("unauthorized", "You must be logged in.");
|
||||||
|
|
||||||
|
if (!UserHasRoleLocked(user, UserRoles.Admin))
|
||||||
|
return ServiceResult<AdminUserSummary>.Failure("forbidden", "Admin role is required.");
|
||||||
|
|
||||||
|
if (!m_StateStore.UsersById.TryGetValue(userId, out var targetUser))
|
||||||
|
return ServiceResult<AdminUserSummary>.Failure("user_not_found", "User was not found.");
|
||||||
|
|
||||||
|
var normalizedRoles = RoleSerializer.Normalize(roles);
|
||||||
|
if (normalizedRoles.Any(role => !string.Equals(role, UserRoles.Admin, StringComparison.Ordinal)))
|
||||||
|
return ServiceResult<AdminUserSummary>.Failure("invalid_role", "Unsupported role.");
|
||||||
|
|
||||||
|
if (user.Id == targetUser.Id && !normalizedRoles.Contains(UserRoles.Admin, StringComparer.Ordinal))
|
||||||
|
return ServiceResult<AdminUserSummary>.Failure("forbidden", "You cannot remove your own admin role.");
|
||||||
|
|
||||||
|
targetUser.Roles = RoleSerializer.Serialize(normalizedRoles);
|
||||||
|
m_PersistenceService.PersistStateLocked();
|
||||||
|
return ServiceResult<AdminUserSummary>.Success(ToAdminUserSummary(targetUser));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ServiceResult<bool> DeleteUser(string sessionToken, Guid userId)
|
||||||
|
{
|
||||||
|
lock (m_StateStore.Gate)
|
||||||
|
{
|
||||||
|
var user = ResolveUserLocked(sessionToken);
|
||||||
|
if (user is null)
|
||||||
|
return ServiceResult<bool>.Failure("unauthorized", "You must be logged in.");
|
||||||
|
|
||||||
|
if (!UserHasRoleLocked(user, UserRoles.Admin))
|
||||||
|
return ServiceResult<bool>.Failure("forbidden", "Admin role is required.");
|
||||||
|
|
||||||
|
if (user.Id == userId)
|
||||||
|
return ServiceResult<bool>.Failure("forbidden", "You cannot delete your own account.");
|
||||||
|
|
||||||
|
if (!m_StateStore.UsersById.TryGetValue(userId, out var targetUser))
|
||||||
|
return ServiceResult<bool>.Failure("user_not_found", "User was not found.");
|
||||||
|
|
||||||
|
var gmCampaignIds = m_StateStore.CampaignsById.Values
|
||||||
|
.Where(campaign => campaign.GmUserId == targetUser.Id)
|
||||||
|
.Select(campaign => campaign.Id)
|
||||||
|
.ToArray();
|
||||||
|
var gmCampaignIdSet = gmCampaignIds.ToHashSet();
|
||||||
|
var preservedCharacterIds = m_StateStore.CharactersById.Values
|
||||||
|
.Where(character => character.CampaignId.HasValue && gmCampaignIdSet.Contains(character.CampaignId.Value))
|
||||||
|
.Select(character => character.Id)
|
||||||
|
.ToHashSet();
|
||||||
|
|
||||||
|
foreach (var campaignId in gmCampaignIds)
|
||||||
|
DeleteCampaignLocked(campaignId);
|
||||||
|
|
||||||
|
var ownedCharacterIds = m_StateStore.CharactersById.Values
|
||||||
|
.Where(character => character.OwnerUserId == targetUser.Id && !preservedCharacterIds.Contains(character.Id))
|
||||||
|
.Select(character => character.Id)
|
||||||
|
.ToArray();
|
||||||
|
foreach (var characterId in ownedCharacterIds)
|
||||||
|
DeleteCharacterLocked(characterId);
|
||||||
|
|
||||||
|
m_StateStore.RollLog.RemoveAll(entry => entry.RollerUserId == targetUser.Id);
|
||||||
|
|
||||||
|
var staleSessions = m_StateStore.SessionsByToken.Values
|
||||||
|
.Where(session => session.UserId == targetUser.Id)
|
||||||
|
.Select(session => session.Token)
|
||||||
|
.ToArray();
|
||||||
|
foreach (var token in staleSessions)
|
||||||
|
m_StateStore.SessionsByToken.Remove(token);
|
||||||
|
|
||||||
|
m_StateStore.UsersById.Remove(targetUser.Id);
|
||||||
|
m_StateStore.UserIdsByUsername.Remove(targetUser.UsernameNormalized);
|
||||||
|
|
||||||
|
m_PersistenceService.PersistStateLocked();
|
||||||
|
return ServiceResult<bool>.Success(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private UserAccount? ResolveUserLocked(string sessionToken)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(sessionToken))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
if (!m_StateStore.SessionsByToken.TryGetValue(sessionToken, out var session))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return m_StateStore.UsersById.GetValueOrDefault(session.UserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool UserHasRoleLocked(UserAccount user, string role)
|
||||||
|
{
|
||||||
|
return RoleSerializer.HasRole(user.Roles, role);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AdminUserSummary ToAdminUserSummary(UserAccount user)
|
||||||
|
{
|
||||||
|
return new(user.Id, user.Username, user.DisplayName, RoleSerializer.Parse(user.Roles));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DeleteCampaignLocked(Guid campaignId)
|
||||||
|
{
|
||||||
|
if (!m_StateStore.CampaignsById.Remove(campaignId))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var affectedCharacterIds = m_StateStore.CharactersById.Values
|
||||||
|
.Where(character => character.CampaignId == campaignId)
|
||||||
|
.Select(character => character.Id)
|
||||||
|
.ToArray();
|
||||||
|
foreach (var characterId in affectedCharacterIds)
|
||||||
|
m_StateStore.CharactersById[characterId].CampaignId = null;
|
||||||
|
|
||||||
|
m_StateStore.RollLog.RemoveAll(entry => entry.CampaignId == campaignId);
|
||||||
|
m_StateStore.CampaignStateById.Remove(campaignId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DeleteCharacterLocked(Guid characterId)
|
||||||
|
{
|
||||||
|
if (!m_StateStore.CharactersById.TryGetValue(characterId, out var character))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var campaignId = character.CampaignId;
|
||||||
|
m_StateStore.CharactersById.Remove(characterId);
|
||||||
|
|
||||||
|
var skillGroupIds = m_StateStore.SkillGroupsById.Values
|
||||||
|
.Where(group => group.CharacterId == characterId)
|
||||||
|
.Select(group => group.Id)
|
||||||
|
.ToHashSet();
|
||||||
|
foreach (var skillGroupId in skillGroupIds)
|
||||||
|
m_StateStore.SkillGroupsById.Remove(skillGroupId);
|
||||||
|
|
||||||
|
var skillIds = m_StateStore.SkillsById.Values
|
||||||
|
.Where(skill => skill.CharacterId == characterId)
|
||||||
|
.Select(skill => skill.Id)
|
||||||
|
.ToHashSet();
|
||||||
|
foreach (var skillId in skillIds)
|
||||||
|
m_StateStore.SkillsById.Remove(skillId);
|
||||||
|
|
||||||
|
m_StateStore.RollLog.RemoveAll(entry => entry.CharacterId == characterId || skillIds.Contains(entry.SkillId));
|
||||||
|
|
||||||
|
foreach (var account in m_StateStore.UsersById.Values.Where(account => account.ActiveCharacterId == characterId))
|
||||||
|
account.ActiveCharacterId = null;
|
||||||
|
|
||||||
|
RemoveCharacterStateLocked(campaignId, characterId);
|
||||||
|
TouchRosterLocked(campaignId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RemoveCharacterStateLocked(Guid? campaignId, Guid characterId)
|
||||||
|
{
|
||||||
|
if (!campaignId.HasValue || !m_StateStore.CampaignStateById.TryGetValue(campaignId.Value, out var state))
|
||||||
|
return;
|
||||||
|
|
||||||
|
state.CharacterVersions.Remove(characterId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void TouchRosterLocked(Guid? campaignId)
|
||||||
|
{
|
||||||
|
if (!campaignId.HasValue || !m_StateStore.CampaignsById.ContainsKey(campaignId.Value))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var state = GetOrCreateCampaignStateLocked(campaignId.Value);
|
||||||
|
state.TotalVersion += 1;
|
||||||
|
state.RosterVersion += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private GameCampaignStateTracker GetOrCreateCampaignStateLocked(Guid campaignId)
|
||||||
|
{
|
||||||
|
if (!m_StateStore.CampaignStateById.TryGetValue(campaignId, out var state))
|
||||||
|
{
|
||||||
|
state = new GameCampaignStateTracker();
|
||||||
|
m_StateStore.CampaignStateById[campaignId] = state;
|
||||||
|
}
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly GamePersistenceService m_PersistenceService;
|
||||||
|
private readonly GameStateStore m_StateStore;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user