From 02c886143c63852328fe5555019f230194d1aebe Mon Sep 17 00:00:00 2001 From: Andreas Frisch Date: Tue, 16 Jun 2026 22:49:20 +0200 Subject: [PATCH] Grace period for membership cleaner --- ...100000_add-missing-member-record-table.sql | 19 ++ ...dMemberCleanerApplicationServiceBuilder.cs | 64 ++++- .../Persistence/TestMemberCleaner.cs | 89 ++++++ ...ctivatedMemberCleanerApplicationService.cs | 266 +++++++++++++++--- src/SelfService/Configuration/Domain.cs | 1 + .../Domain/Events/MemberMarkedAsMissing.cs | 15 + .../Events/MemberRemovedDueToMissingStatus.cs | 15 + .../Domain/Models/IMissingMemberRepository.cs | 9 + .../Domain/Models/MissingMemberRecord.cs | 45 +++ .../Persistence/MissingMemberRepository.cs | 35 +++ .../Persistence/SelfServiceDbContext.cs | 1 + 11 files changed, 521 insertions(+), 38 deletions(-) create mode 100644 db/migrations/20260616100000_add-missing-member-record-table.sql create mode 100644 src/SelfService/Domain/Events/MemberMarkedAsMissing.cs create mode 100644 src/SelfService/Domain/Events/MemberRemovedDueToMissingStatus.cs create mode 100644 src/SelfService/Domain/Models/IMissingMemberRepository.cs create mode 100644 src/SelfService/Domain/Models/MissingMemberRecord.cs create mode 100644 src/SelfService/Infrastructure/Persistence/MissingMemberRepository.cs diff --git a/db/migrations/20260616100000_add-missing-member-record-table.sql b/db/migrations/20260616100000_add-missing-member-record-table.sql new file mode 100644 index 00000000..dbf71ecb --- /dev/null +++ b/db/migrations/20260616100000_add-missing-member-record-table.sql @@ -0,0 +1,19 @@ +-- 2026-06-16 10:00:00 : add-missing-member-record-table + +CREATE TABLE IF NOT EXISTS "MissingMemberRecords" +( + "Id" uuid NOT NULL, + "UserId" varchar(255) NOT NULL, + "Status" varchar(50) NOT NULL, -- NotFound, Deactivated + "FirstSeenMissingAt" timestamp NOT NULL, + "LastCheckedAt" timestamp NOT NULL, + "CreatedAt" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "UpdatedAt" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "MissingMemberRecords_PK" PRIMARY KEY ("Id"), + CONSTRAINT "MissingMemberRecords_UserId_FK" FOREIGN KEY ("UserId") REFERENCES "Member" ("Id") ON DELETE CASCADE, + UNIQUE ("UserId") +); + +CREATE INDEX "MissingMemberRecords_FirstSeenMissingAt_idx" ON "MissingMemberRecords" ("FirstSeenMissingAt" ASC); +CREATE INDEX "MissingMemberRecords_LastCheckedAt_idx" ON "MissingMemberRecords" ("LastCheckedAt" ASC); diff --git a/src/SelfService.Tests/Builders/DeactivatedMemberCleanerApplicationServiceBuilder.cs b/src/SelfService.Tests/Builders/DeactivatedMemberCleanerApplicationServiceBuilder.cs index d035e462..61d25b3f 100644 --- a/src/SelfService.Tests/Builders/DeactivatedMemberCleanerApplicationServiceBuilder.cs +++ b/src/SelfService.Tests/Builders/DeactivatedMemberCleanerApplicationServiceBuilder.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; +using Moq; using SelfService.Application; using SelfService.Domain; using SelfService.Domain.Models; @@ -14,6 +15,10 @@ public class DeactivatedMemberCleanerApplicationServiceBuilder private IMembershipRepository _membershipRepository; private IMemberRepository _memberRepository; private IMembershipApplicationRepository _membershipApplicationRepository; + private IMissingMemberRepository _missingMemberRepository; + private IRbacPermissionGrantRepository _rbacPermissionGrantRepository; + private IRbacRoleGrantRepository _rbacRoleGrantRepository; + private IRbacGroupMemberRepository _rbacGroupMemberRepository; private ILogger _logger; //make correct logger public DeactivatedMemberCleanerApplicationServiceBuilder() @@ -21,6 +26,27 @@ public DeactivatedMemberCleanerApplicationServiceBuilder() _membershipRepository = Dummy.Of(); _memberRepository = Dummy.Of(); _membershipApplicationRepository = Dummy.Of(); + _missingMemberRepository = Dummy.Of(); + + // Configure RBAC repositories to return empty lists instead of null + var permissionGrantMock = new Mock(); + permissionGrantMock + .Setup(x => x.GetAllWithPredicate(It.IsAny>())) + .ReturnsAsync(new List()); + _rbacPermissionGrantRepository = permissionGrantMock.Object; + + var roleGrantMock = new Mock(); + roleGrantMock + .Setup(x => x.GetByAssignedUsers(It.IsAny>())) + .ReturnsAsync(new List()); + _rbacRoleGrantRepository = roleGrantMock.Object; + + var groupMemberMock = new Mock(); + groupMemberMock + .Setup(x => x.GetAllWithPredicate(It.IsAny>())) + .ReturnsAsync(new List()); + _rbacGroupMemberRepository = groupMemberMock.Object; + _logger = Dummy.Of>(); } @@ -46,13 +72,49 @@ IMembershipApplicationRepository membershipApplicationRepository return this; } + public DeactivatedMemberCleanerApplicationServiceBuilder WithMissingMemberRepository( + IMissingMemberRepository missingMemberRepository + ) + { + _missingMemberRepository = missingMemberRepository; + return this; + } + + public DeactivatedMemberCleanerApplicationServiceBuilder WithRbacPermissionGrantRepository( + IRbacPermissionGrantRepository rbacPermissionGrantRepository + ) + { + _rbacPermissionGrantRepository = rbacPermissionGrantRepository; + return this; + } + + public DeactivatedMemberCleanerApplicationServiceBuilder WithRbacRoleGrantRepository( + IRbacRoleGrantRepository rbacRoleGrantRepository + ) + { + _rbacRoleGrantRepository = rbacRoleGrantRepository; + return this; + } + + public DeactivatedMemberCleanerApplicationServiceBuilder WithRbacGroupMemberRepository( + IRbacGroupMemberRepository rbacGroupMemberRepository + ) + { + _rbacGroupMemberRepository = rbacGroupMemberRepository; + return this; + } + public DeactivatedMemberCleanerApplicationService Build() { return new DeactivatedMemberCleanerApplicationService( logger: _logger, membershipRepository: _membershipRepository, memberRepository: _memberRepository, - membershipApplicationRepository: _membershipApplicationRepository + membershipApplicationRepository: _membershipApplicationRepository, + missingMemberRepository: _missingMemberRepository, + rbacPermissionGrantRepository: _rbacPermissionGrantRepository, + rbacRoleGrantRepository: _rbacRoleGrantRepository, + rbacGroupMemberRepository: _rbacGroupMemberRepository ); } diff --git a/src/SelfService.Tests/Infrastructure/Persistence/TestMemberCleaner.cs b/src/SelfService.Tests/Infrastructure/Persistence/TestMemberCleaner.cs index 1c3536c4..a44786a2 100644 --- a/src/SelfService.Tests/Infrastructure/Persistence/TestMemberCleaner.cs +++ b/src/SelfService.Tests/Infrastructure/Persistence/TestMemberCleaner.cs @@ -11,6 +11,80 @@ namespace SelfService.Tests.Infrastructure.Persistence; public class TestMemberCleaner { + [Fact] + [Trait("Category", "InMemoryDatabase")] + public async Task deactivated_member_cleaner_marks_missing_member_without_deleting_on_first_detection() + { + await using var databaseFactory = new InMemoryDatabaseFactory(); + var dbContext = await databaseFactory.CreateSelfServiceDbContext(); + + var memberRepo = new MemberRepository(dbContext); + var membershipRepo = new MembershipRepository(dbContext); + var missingMemberRepo = new MissingMemberRepository(dbContext); + var membershipApplicationRepo = new MembershipApplicationRepository(dbContext, SystemTime.Default); + + var member = A.Member.WithUserId("userdeactivated@dfds.com").Build(); + await memberRepo.Add(member); + await dbContext.SaveChangesAsync(); + + var membershipCleaner = A + .DeactivatedMemberCleanerApplicationService.WithMemberRepository(memberRepo) + .WithMembershipRepository(membershipRepo) + .WithMembershipApplicationRepository(membershipApplicationRepo) + .WithMissingMemberRepository(missingMemberRepo) + .Build(); + + var userStatusChecker = new StubUserStatusChecker().WithDeactivatedUser(member.Id); + + await membershipCleaner.RemoveDeactivatedMemberships(userStatusChecker); + await dbContext.SaveChangesAsync(); + + var memberAfterRun = await memberRepo.FindBy(member.Id); + Assert.NotNull(memberAfterRun); + + var missingRecord = await missingMemberRepo.FindByUser(member.Id.ToString()); + Assert.NotNull(missingRecord); + Assert.Equal(MissingMemberStatus.Deactivated, missingRecord.Status); + } + + [Fact] + [Trait("Category", "InMemoryDatabase")] + public async Task deactivated_member_cleaner_deletes_member_after_grace_period_expires() + { + await using var databaseFactory = new InMemoryDatabaseFactory(); + var dbContext = await databaseFactory.CreateSelfServiceDbContext(); + + var memberRepo = new MemberRepository(dbContext); + var membershipRepo = new MembershipRepository(dbContext); + var missingMemberRepo = new MissingMemberRepository(dbContext); + var membershipApplicationRepo = new MembershipApplicationRepository(dbContext, SystemTime.Default); + + var member = A.Member.WithUserId("userdeactivated@dfds.com").Build(); + await memberRepo.Add(member); + await missingMemberRepo.Add( + new MissingMemberRecord(member.Id.ToString(), MissingMemberStatus.Deactivated, DateTime.UtcNow.AddDays(-8)) + ); + await dbContext.SaveChangesAsync(); + + var membershipCleaner = A + .DeactivatedMemberCleanerApplicationService.WithMemberRepository(memberRepo) + .WithMembershipRepository(membershipRepo) + .WithMembershipApplicationRepository(membershipApplicationRepo) + .WithMissingMemberRepository(missingMemberRepo) + .Build(); + + var userStatusChecker = new StubUserStatusChecker().WithDeactivatedUser(member.Id); + + await membershipCleaner.RemoveDeactivatedMemberships(userStatusChecker); + await dbContext.SaveChangesAsync(); + + var memberAfterRun = await memberRepo.FindBy(member.Id); + Assert.Null(memberAfterRun); + + var missingRecordAfterRun = await missingMemberRepo.FindByUser(member.Id.ToString()); + Assert.Null(missingRecordAfterRun); + } + [Fact] [Trait("Category", "InMemoryDatabase")] public async Task deactivated_member_cleaner_removes_deactivated_users() @@ -24,6 +98,7 @@ public async Task deactivated_member_cleaner_removes_deactivated_users() .DeactivatedMemberCleanerApplicationService.WithMemberRepository(new MemberRepository(dbContext)) .WithMembershipRepository(new MembershipRepository(dbContext)) .WithMembershipApplicationRepository(new MembershipApplicationRepository(dbContext, systemTime)) + .WithMissingMemberRepository(new MissingMemberRepository(dbContext)) .Build(); var capability = A.Capability.Build(); @@ -127,10 +202,22 @@ public async Task deactivated_member_cleaner_respects_referential_integrity() //now the repository is in the right state + var missingMemberRepo = new MissingMemberRepository(dbContext); + // Pre-seed an expired missing record so the grace period is already elapsed + await missingMemberRepo.Add( + new MissingMemberRecord( + deactivatedMember.Id.ToString(), + MissingMemberStatus.Deactivated, + firstSeenAt: DateTime.UtcNow.AddDays(-8) + ) + ); + await dbContext.SaveChangesAsync(); + var membershipCleaner = A .DeactivatedMemberCleanerApplicationService.WithMemberRepository(memberRepo) .WithMembershipRepository(membershipRepo) .WithMembershipApplicationRepository(membershipApplicationRepo) + .WithMissingMemberRepository(missingMemberRepo) .Build(); var userStatusChecker = new StubUserStatusChecker() @@ -144,6 +231,8 @@ public async Task deactivated_member_cleaner_respects_referential_integrity() // CLEAN UP await memberRepo.Remove(approverMember.Id); + await dbContext.SaveChangesAsync(); + dbContext.Capabilities.Remove(capability); await dbContext.SaveChangesAsync(); } diff --git a/src/SelfService/Application/DeactivatedMemberCleanerApplicationService.cs b/src/SelfService/Application/DeactivatedMemberCleanerApplicationService.cs index 0f9fdc6a..d5b9841f 100644 --- a/src/SelfService/Application/DeactivatedMemberCleanerApplicationService.cs +++ b/src/SelfService/Application/DeactivatedMemberCleanerApplicationService.cs @@ -10,21 +10,34 @@ public class DeactivatedMemberCleanerApplicationService : IDeactivatedMemberClea private readonly IMembershipRepository _membershipRepository; private readonly IMemberRepository _memberRepository; private readonly IMembershipApplicationRepository _membershipApplicationRepository; + private readonly IMissingMemberRepository _missingMemberRepository; + private readonly IRbacPermissionGrantRepository _rbacPermissionGrantRepository; + private readonly IRbacRoleGrantRepository _rbacRoleGrantRepository; + private readonly IRbacGroupMemberRepository _rbacGroupMemberRepository; private readonly ILogger _logger; private readonly StringBuilder _sb = new(); + private const int GracePeriodDays = 7; public DeactivatedMemberCleanerApplicationService( ILogger logger, IMembershipRepository membershipRepository, IMemberRepository memberRepository, - IMembershipApplicationRepository membershipApplicationRepository + IMembershipApplicationRepository membershipApplicationRepository, + IMissingMemberRepository missingMemberRepository, + IRbacPermissionGrantRepository rbacPermissionGrantRepository, + IRbacRoleGrantRepository rbacRoleGrantRepository, + IRbacGroupMemberRepository rbacGroupMemberRepository ) { _membershipRepository = membershipRepository; _memberRepository = memberRepository; _membershipApplicationRepository = membershipApplicationRepository; + _missingMemberRepository = missingMemberRepository; + _rbacPermissionGrantRepository = rbacPermissionGrantRepository; + _rbacRoleGrantRepository = rbacRoleGrantRepository; + _rbacGroupMemberRepository = rbacGroupMemberRepository; _logger = logger; } @@ -51,8 +64,10 @@ public async Task RemoveDeactivatedMemberships(IUserStatusChecker userStatusChec } var members = await _memberRepository.GetAll(); - List deactivatedMembers = new List(); - List notFoundMembers = new List(); + List membersToBeDeleted = new List(); + List newlyMissingMembers = new List(); + List reappearedMembers = new List(); + foreach (var member in members) { // Service principals are not represented in Azure AD's /users endpoint; @@ -62,57 +77,123 @@ public async Task RemoveDeactivatedMemberships(IUserStatusChecker userStatusChec var status = await userStatusChecker.CheckUserStatus(member.Id); - if (status == UserStatusCheckerStatus.NotFound) - notFoundMembers.Add(member); - if (status == UserStatusCheckerStatus.Deactivated) - deactivatedMembers.Add(member); - if (status == UserStatusCheckerStatus.NoAuthToken || status == UserStatusCheckerStatus.BadAuthToken) { _logger.LogError("Unable to check status of user {UserID}, no valid auth token found", member.Id); return; } - } - if (notFoundMembers.Count <= 0) - { - _logger.LogDebug("no users were completely unfound in Azure AD (yay)"); - } - else - { - _logger.LogWarning( - "Removing {NotFoundmembersCount} members not found in Azure AD:\n{notfoundMembers}\n", - notFoundMembers.Count, - ToIdStringList(notFoundMembers) - ); + if (status == UserStatusCheckerStatus.Found) + { + // Only interesting if they were previously marked as missing + if (await HandleMemberFound(member)) + reappearedMembers.Add(member); + } + else if (status == UserStatusCheckerStatus.NotFound) + { + await HandleMemberMissing( + member, + MissingMemberStatus.NotFound, + membersToBeDeleted, + newlyMissingMembers + ); + } + else if (status == UserStatusCheckerStatus.Deactivated) + { + await HandleMemberMissing( + member, + MissingMemberStatus.Deactivated, + membersToBeDeleted, + newlyMissingMembers + ); + } } - if (deactivatedMembers.Count <= 0) + LogResults(newlyMissingMembers, reappearedMembers, membersToBeDeleted); + + if (membersToBeDeleted.Count <= 0) + return; + + await DeleteMembers(membersToBeDeleted); + } + + private async Task HandleMemberFound(Member member) + { + var missingRecord = await _missingMemberRepository.FindByUser(member.Id.ToString()); + if (missingRecord == null) + return false; + + await _missingMemberRepository.RemoveByUserId(member.Id.ToString()); + _logger.LogInformation("Member {UserId} reappeared in Azure, removed from missing records", member.Id); + return true; + } + + private async Task HandleMemberMissing( + Member member, + MissingMemberStatus status, + List membersToBeDeleted, + List newlyMissingMembers + ) + { + var existingRecord = await _missingMemberRepository.FindByUser(member.Id.ToString()); + + if (existingRecord == null) { - _logger.LogDebug("Found no members with deactivated/disabled accounts"); + // First time seeing this member as missing - record it + var newRecord = new MissingMemberRecord(member.Id.ToString(), status, DateTime.UtcNow); + await _missingMemberRepository.Add(newRecord); + newlyMissingMembers.Add(member); + _logger.LogInformation( + "Member {UserId} marked as missing with status {Status}. Grace period until {GracePeriodExpiry}", + member.Id, + status, + DateTime.UtcNow.AddDays(GracePeriodDays) + ); } else { - _logger.LogWarning( - "Removing {DeactivatedMembersCount} members with deactivated/disabled accounts in Azure AD:\n{DeactivatedMembers}", - deactivatedMembers.Count, - ToIdStringList(deactivatedMembers) - ); - } - - var membersToBeDeleted = new HashSet(); - deactivatedMembers.ForEach(x => membersToBeDeleted.Add(x)); - notFoundMembers.ForEach(x => membersToBeDeleted.Add(x)); + if (existingRecord.HasGracePeriodExpired(GracePeriodDays)) + { + membersToBeDeleted.Add(member); + _logger.LogWarning( + "Member {UserId} grace period expired (marked missing since {FirstSeen}). Scheduling for deletion", + member.Id, + existingRecord.FirstSeenMissingAt + ); + } + else + { + // Keep heartbeat fresh only while still inside grace period. + existingRecord.UpdateLastChecked(); + await _missingMemberRepository.Update(existingRecord); - if (membersToBeDeleted.Count <= 0) - return; + var daysRemaining = + GracePeriodDays - (int)(DateTime.UtcNow - existingRecord.FirstSeenMissingAt).TotalDays; + _logger.LogInformation( + "Member {UserId} still missing with status {Status}. {DaysRemaining} days until deletion", + member.Id, + status, + daysRemaining + ); + } + } + } + private async Task DeleteMembers(List membersToBeDeleted) + { foreach (var member in membersToBeDeleted) { + // Clean up RBAC permissions + await CleanupRbacPermissions(member.Id.ToString()); + + // Cancel all memberships await _membershipRepository.CancelAllMembershipsWithUserId(member.Id); } - _logger.LogInformation("Successfully cancelled memberships of users with deactivated accounts"); + _logger.LogInformation( + "Successfully cancelled memberships of {Count} users with deactivated/missing accounts", + membersToBeDeleted.Count + ); foreach (var member in membersToBeDeleted) { @@ -120,14 +201,125 @@ public async Task RemoveDeactivatedMemberships(IUserStatusChecker userStatusChec } _logger.LogInformation( - "Successfully removed pending membership applications of users with deactivated accounts" + "Successfully removed pending membership applications of {Count} users", + membersToBeDeleted.Count ); foreach (var member in membersToBeDeleted) { + // Remove missing-member tracking explicitly to keep behavior consistent + // even when DB cascades are not enforced (e.g., some test providers). + await _missingMemberRepository.RemoveByUserId(member.Id.ToString()); + + // Remove the member await _memberRepository.Remove(member.Id); } - _logger.LogInformation("Successfully removed member entries of users with deactivated accounts"); + _logger.LogInformation( + "Successfully removed {Count} member entries of users with deactivated/missing accounts", + membersToBeDeleted.Count + ); + } + + private async Task CleanupRbacPermissions(string userId) + { + try + { + // Remove RBAC permission grants for this user + var permissionGrants = await _rbacPermissionGrantRepository.GetAllWithPredicate(pg => + pg.AssignedEntityId == userId + ); + + foreach (var grant in permissionGrants) + { + await _rbacPermissionGrantRepository.Remove(grant.Id); + } + + if (permissionGrants.Count > 0) + { + _logger.LogInformation( + "Removed {Count} RBAC permission grants for user {UserId}", + permissionGrants.Count, + userId + ); + } + + // Remove RBAC role grants for this user + var roleGrants = await _rbacRoleGrantRepository.GetByAssignedUsers(new[] { userId }); + + foreach (var grant in roleGrants) + { + await _rbacRoleGrantRepository.Remove(grant.Id); + } + + if (roleGrants.Count > 0) + { + _logger.LogInformation("Removed {Count} RBAC role grants for user {UserId}", roleGrants.Count, userId); + } + + // Remove RBAC group memberships for this user + var groupMembers = await _rbacGroupMemberRepository.GetAllWithPredicate(gm => gm.UserId == userId); + + foreach (var member in groupMembers) + { + await _rbacGroupMemberRepository.Remove(member.Id); + } + + if (groupMembers.Count > 0) + { + _logger.LogInformation( + "Removed {Count} RBAC group memberships for user {UserId}", + groupMembers.Count, + userId + ); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error cleaning up RBAC permissions for user {UserId}", userId); + throw; + } + } + + private void LogResults( + List newlyMissingMembers, + List reappearedMembers, + List membersToBeDeleted + ) + { + if (newlyMissingMembers.Count > 0) + { + _logger.LogWarning( + "Found {Count} newly missing members in Azure AD (grace period started):\n{Members}\n", + newlyMissingMembers.Count, + ToIdStringList(newlyMissingMembers) + ); + } + else + { + _logger.LogDebug("No newly missing users found in Azure AD"); + } + + if (reappearedMembers.Count > 0) + { + _logger.LogInformation( + "Found {Count} members that reappeared in Azure AD (cleared from missing records):\n{Members}", + reappearedMembers.Count, + ToIdStringList(reappearedMembers) + ); + } + + if (membersToBeDeleted.Count > 0) + { + _logger.LogWarning( + "Removing {Count} members due to grace period expiration:\n{Members}\n", + membersToBeDeleted.Count, + ToIdStringList(membersToBeDeleted) + ); + } + else + { + _logger.LogDebug("Found no members ready for deletion (grace period not yet expired)"); + } } } diff --git a/src/SelfService/Configuration/Domain.cs b/src/SelfService/Configuration/Domain.cs index d189e5a0..56e8bfdc 100644 --- a/src/SelfService/Configuration/Domain.cs +++ b/src/SelfService/Configuration/Domain.cs @@ -79,6 +79,7 @@ public static void AddDomain(this WebApplicationBuilder builder) builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); + builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); diff --git a/src/SelfService/Domain/Events/MemberMarkedAsMissing.cs b/src/SelfService/Domain/Events/MemberMarkedAsMissing.cs new file mode 100644 index 00000000..31c78097 --- /dev/null +++ b/src/SelfService/Domain/Events/MemberMarkedAsMissing.cs @@ -0,0 +1,15 @@ +using SelfService.Domain.Models; + +namespace SelfService.Domain.Events; + +public class MemberMarkedAsMissing : IDomainEvent +{ + public string UserId { get; set; } + public string Status { get; set; } // NotFound or Deactivated + + public MemberMarkedAsMissing(string userId, string status) + { + UserId = userId; + Status = status; + } +} diff --git a/src/SelfService/Domain/Events/MemberRemovedDueToMissingStatus.cs b/src/SelfService/Domain/Events/MemberRemovedDueToMissingStatus.cs new file mode 100644 index 00000000..fcaed251 --- /dev/null +++ b/src/SelfService/Domain/Events/MemberRemovedDueToMissingStatus.cs @@ -0,0 +1,15 @@ +using SelfService.Domain.Models; + +namespace SelfService.Domain.Events; + +public class MemberRemovedDueToMissingStatus : IDomainEvent +{ + public string UserId { get; set; } + public string Status { get; set; } // NotFound or Deactivated + + public MemberRemovedDueToMissingStatus(string userId, string status) + { + UserId = userId; + Status = status; + } +} diff --git a/src/SelfService/Domain/Models/IMissingMemberRepository.cs b/src/SelfService/Domain/Models/IMissingMemberRepository.cs new file mode 100644 index 00000000..d4af47ee --- /dev/null +++ b/src/SelfService/Domain/Models/IMissingMemberRepository.cs @@ -0,0 +1,9 @@ +namespace SelfService.Domain.Models; + +public interface IMissingMemberRepository +{ + Task FindByUser(string userId); + Task Add(MissingMemberRecord record); + Task Update(MissingMemberRecord record); + Task RemoveByUserId(string userId); +} diff --git a/src/SelfService/Domain/Models/MissingMemberRecord.cs b/src/SelfService/Domain/Models/MissingMemberRecord.cs new file mode 100644 index 00000000..a785e1db --- /dev/null +++ b/src/SelfService/Domain/Models/MissingMemberRecord.cs @@ -0,0 +1,45 @@ +namespace SelfService.Domain.Models; + +public enum MissingMemberStatus +{ + NotFound, + Deactivated, +} + +public class MissingMemberRecord +{ + public Guid Id { get; set; } + public string UserId { get; set; } + public MissingMemberStatus Status { get; set; } + public DateTime FirstSeenMissingAt { get; set; } + public DateTime LastCheckedAt { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } + + private MissingMemberRecord() + { + UserId = string.Empty; + } + + public MissingMemberRecord(string userId, MissingMemberStatus status, DateTime firstSeenAt) + { + Id = Guid.NewGuid(); + UserId = userId; + Status = status; + FirstSeenMissingAt = firstSeenAt; + LastCheckedAt = firstSeenAt; + CreatedAt = DateTime.UtcNow; + UpdatedAt = DateTime.UtcNow; + } + + public bool HasGracePeriodExpired(int gracePeriodDays = 7) + { + return FirstSeenMissingAt.AddDays(gracePeriodDays) <= DateTime.UtcNow; + } + + public void UpdateLastChecked() + { + LastCheckedAt = DateTime.UtcNow; + UpdatedAt = DateTime.UtcNow; + } +} diff --git a/src/SelfService/Infrastructure/Persistence/MissingMemberRepository.cs b/src/SelfService/Infrastructure/Persistence/MissingMemberRepository.cs new file mode 100644 index 00000000..a0ad9d7e --- /dev/null +++ b/src/SelfService/Infrastructure/Persistence/MissingMemberRepository.cs @@ -0,0 +1,35 @@ +using Microsoft.EntityFrameworkCore; +using SelfService.Domain.Models; + +namespace SelfService.Infrastructure.Persistence; + +public class MissingMemberRepository : IMissingMemberRepository +{ + private readonly SelfServiceDbContext _dbContext; + + public MissingMemberRepository(SelfServiceDbContext dbContext) + { + _dbContext = dbContext; + } + + public async Task FindByUser(string userId) + { + return await _dbContext.MissingMemberRecords.FirstOrDefaultAsync(x => x.UserId == userId); + } + + public async Task Add(MissingMemberRecord record) + { + await _dbContext.MissingMemberRecords.AddAsync(record); + } + + public async Task Update(MissingMemberRecord record) + { + _dbContext.MissingMemberRecords.Update(record); + await Task.CompletedTask; + } + + public async Task RemoveByUserId(string userId) + { + await _dbContext.MissingMemberRecords.Where(x => x.UserId == userId).ExecuteDeleteAsync(); + } +} diff --git a/src/SelfService/Infrastructure/Persistence/SelfServiceDbContext.cs b/src/SelfService/Infrastructure/Persistence/SelfServiceDbContext.cs index 1e25502a..cf550cc5 100644 --- a/src/SelfService/Infrastructure/Persistence/SelfServiceDbContext.cs +++ b/src/SelfService/Infrastructure/Persistence/SelfServiceDbContext.cs @@ -45,6 +45,7 @@ public SelfServiceDbContext(DbContextOptions options) public DbSet Capabilities => Set(); public DbSet Members => Set(); public DbSet Memberships => Set(); + public DbSet MissingMemberRecords => Set(); public DbSet Favourites => Set(); public DbSet MembershipApplications => Set();