Skip to content

Commit 1367edf

Browse files
authored
Add soft delete for users with deleted users tab and E2E tests (#815)
### Summary & Motivation Add soft delete functionality for users, allowing Tenant Owners and Admins to delete users with the ability to restore them or permanently purge them. Previously, user deletion was immediate and irreversible. This feature provides a safety net for accidental deletions and compliance with data retention requirements. - Implement soft delete for User aggregate using the `ISoftDeletable` interface from the shared kernel - Add API endpoints for managing deleted users: - `GET /api/users/deleted` - list deleted users with pagination - `POST /api/users/{id}/restore` - restore a soft-deleted user - `DELETE /api/users/{id}/purge` - permanently delete a single user - `POST /api/users/deleted/bulk-purge` - permanently delete multiple users - `POST /api/users/deleted/empty-recycle-bin` - purge all deleted users - Create "Recycle bin" tab on the Users page with restore and purge functionality, accessible only to Owners and Admins - Add `SmartDate` component and `useSmartDate` hook for auto-updating relative time display ("Just now", "5 minutes ago", "2 hours ago"), used in the Users table for Created and Modified columns - Simplify `formatDate` utility to use fixed English month names for consistent formatting across browsers - Add comprehensive backend tests for soft delete, restore, single purge, bulk purge, and empty recycle bin operations - Add test verifying soft-deleted users cannot log in (returns fake login ID and sends unknown user email) - Add E2E tests covering the complete soft delete workflow including tab navigation, restore, and permanent deletion - Rename `ForceHardDelete` to `ForcePurge` and `MarkForHardDelete` to `MarkForPurge` in the `ISoftDeletable` interface for consistency - Improve E2E test reliability and fix back-office tests by dynamically reading internal email domain from platform-settings.jsonc ### Checklist - [x] I have added tests, or done manual regression tests - [x] I have updated the documentation, if necessary
2 parents a6b6d16 + ae342c9 commit 1367edf

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

50 files changed

+2636
-279
lines changed

application/account-management/Api/Endpoints/UserEndpoints.cs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,26 @@ public void MapEndpoints(IEndpointRouteBuilder routes)
4646
=> await mediator.Send(command)
4747
);
4848

49+
group.MapGet("/deleted", async Task<ApiResult<DeletedUsersResponse>> ([AsParameters] GetDeletedUsersQuery query, IMediator mediator)
50+
=> await mediator.Send(query)
51+
).Produces<DeletedUsersResponse>();
52+
53+
group.MapPost("/{id}/restore", async Task<ApiResult> (UserId id, IMediator mediator)
54+
=> await mediator.Send(new RestoreUserCommand(id))
55+
);
56+
57+
group.MapDelete("/{id}/purge", async Task<ApiResult> (UserId id, IMediator mediator)
58+
=> await mediator.Send(new PurgeUserCommand(id))
59+
);
60+
61+
group.MapPost("/deleted/bulk-purge", async Task<ApiResult> (BulkPurgeUsersCommand command, IMediator mediator)
62+
=> await mediator.Send(command)
63+
);
64+
65+
group.MapPost("/deleted/empty-recycle-bin", async Task<ApiResult<int>> (IMediator mediator)
66+
=> await mediator.Send(new EmptyRecycleBinCommand())
67+
).Produces<int>();
68+
4969
// The following endpoints are for the current user only
5070
group.MapGet("/me", async Task<ApiResult<CurrentUserResponse>> ([AsParameters] GetUserQuery query, IMediator mediator)
5171
=> await mediator.Send(query)
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
using Microsoft.EntityFrameworkCore.Infrastructure;
2+
using Microsoft.EntityFrameworkCore.Migrations;
3+
4+
namespace PlatformPlatform.AccountManagement.Database.Migrations;
5+
6+
[DbContext(typeof(AccountManagementDbContext))]
7+
[Migration("20260103000000_AddUserSoftDeleteAndEmailConstraint")]
8+
public sealed class AddUserSoftDeleteAndEmailConstraint : Migration
9+
{
10+
protected override void Up(MigrationBuilder migrationBuilder)
11+
{
12+
migrationBuilder.AddColumn<DateTimeOffset>(
13+
name: "DeletedAt",
14+
table: "Users",
15+
type: "datetimeoffset",
16+
nullable: true);
17+
18+
migrationBuilder.CreateIndex(
19+
name: "IX_Users_TenantId_Email",
20+
table: "Users",
21+
columns: ["TenantId", "Email"],
22+
unique: true,
23+
filter: "[DeletedAt] IS NULL");
24+
}
25+
}

application/account-management/Core/Features/TelemetryEvents.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,12 @@ public sealed class UserInvited(UserId userId)
9393
public sealed class UserLocaleChanged(string fromLocale, string toLocale)
9494
: TelemetryEvent(("from_locale", fromLocale), ("to_locale", toLocale));
9595

96+
public sealed class UserPurged(UserId userId, UserPurgeReason reason)
97+
: TelemetryEvent(("user_id", userId), ("reason", reason));
98+
99+
public sealed class UserRestored(UserId userId)
100+
: TelemetryEvent(("user_id", userId));
101+
96102
public sealed class UserRoleChanged(UserId userId, UserRole fromRole, UserRole toRole)
97103
: TelemetryEvent(("user_id", userId), ("from_role", fromRole), ("to_role", toRole));
98104

application/account-management/Core/Features/Users/Commands/BulkDeleteUsers.cs

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,11 +49,25 @@ public async Task<Result> Handle(BulkDeleteUsersCommand command, CancellationTok
4949
return Result.NotFound($"Users with ids '{string.Join(", ", missingUserIds.Select(id => id.ToString()))}' not found.");
5050
}
5151

52-
userRepository.RemoveRange(usersToDelete);
52+
var usersToSoftDelete = usersToDelete.Where(u => u.EmailConfirmed).ToArray();
53+
var usersToHardDelete = usersToDelete.Where(u => !u.EmailConfirmed).ToArray();
5354

54-
foreach (var userId in command.UserIds)
55+
if (usersToSoftDelete.Length > 0)
5556
{
56-
events.CollectEvent(new UserDeleted(userId, true));
57+
userRepository.RemoveRange(usersToSoftDelete);
58+
foreach (var user in usersToSoftDelete)
59+
{
60+
events.CollectEvent(new UserDeleted(user.Id, true));
61+
}
62+
}
63+
64+
if (usersToHardDelete.Length > 0)
65+
{
66+
userRepository.PermanentlyRemoveRange(usersToHardDelete);
67+
foreach (var user in usersToHardDelete)
68+
{
69+
events.CollectEvent(new UserPurged(user.Id, UserPurgeReason.NeverActivated));
70+
}
5771
}
5872

5973
events.CollectEvent(new UsersBulkDeleted(command.UserIds.Length));
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
using FluentValidation;
2+
using JetBrains.Annotations;
3+
using PlatformPlatform.AccountManagement.Features.Users.Domain;
4+
using PlatformPlatform.SharedKernel.Cqrs;
5+
using PlatformPlatform.SharedKernel.Domain;
6+
using PlatformPlatform.SharedKernel.ExecutionContext;
7+
using PlatformPlatform.SharedKernel.Telemetry;
8+
9+
namespace PlatformPlatform.AccountManagement.Features.Users.Commands;
10+
11+
[PublicAPI]
12+
public sealed record BulkPurgeUsersCommand(UserId[] UserIds) : ICommand, IRequest<Result>;
13+
14+
public sealed class BulkPurgeUsersValidator : AbstractValidator<BulkPurgeUsersCommand>
15+
{
16+
public BulkPurgeUsersValidator()
17+
{
18+
RuleFor(x => x.UserIds)
19+
.NotEmpty()
20+
.WithMessage("At least one user must be selected for deletion.")
21+
.Must(ids => ids.Length <= 100)
22+
.WithMessage("Cannot delete more than 100 users at once.");
23+
}
24+
}
25+
26+
public sealed class BulkPurgeUsersHandler(IUserRepository userRepository, IExecutionContext executionContext, ITelemetryEventsCollector events)
27+
: IRequestHandler<BulkPurgeUsersCommand, Result>
28+
{
29+
public async Task<Result> Handle(BulkPurgeUsersCommand command, CancellationToken cancellationToken)
30+
{
31+
if (executionContext.UserInfo.Role != nameof(UserRole.Owner))
32+
{
33+
return Result.Forbidden("Only owners can permanently delete users from the recycle bin.");
34+
}
35+
36+
var deletedUsers = await userRepository.GetDeletedByIdsAsync(command.UserIds, cancellationToken);
37+
38+
var missingUserIds = command.UserIds.Where(id => !deletedUsers.Select(u => u.Id).Contains(id)).ToArray();
39+
if (missingUserIds.Length > 0)
40+
{
41+
return Result.NotFound($"Deleted users with ids '{string.Join(", ", missingUserIds.Select(id => id.ToString()))}' not found.");
42+
}
43+
44+
userRepository.PermanentlyRemoveRange(deletedUsers);
45+
46+
foreach (var user in deletedUsers)
47+
{
48+
events.CollectEvent(new UserPurged(user.Id, UserPurgeReason.BulkUserPurge));
49+
}
50+
51+
return Result.Success();
52+
}
53+
}

application/account-management/Core/Features/Users/Commands/DeclineInvitation.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,10 @@ public async Task<Result> Handle(DeclineInvitationCommand command, CancellationT
3737
// Calculate how long the invitation existed
3838
var inviteExistedTimeInMinutes = (int)(timeProvider.GetUtcNow() - user.CreatedAt).TotalMinutes;
3939

40-
// Delete the user to decline the invitation
41-
userRepository.Remove(user);
40+
userRepository.PermanentlyRemove(user);
4241

4342
events.CollectEvent(new UserInviteDeclined(user.Id, inviteExistedTimeInMinutes));
43+
events.CollectEvent(new UserPurged(user.Id, UserPurgeReason.NeverActivated));
4444

4545
return Result.Success();
4646
}

application/account-management/Core/Features/Users/Commands/DeleteUser.cs

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,16 @@ public async Task<Result> Handle(DeleteUserCommand command, CancellationToken ca
2525
var user = await userRepository.GetByIdAsync(command.Id, cancellationToken);
2626
if (user is null) return Result.NotFound($"User with id '{command.Id}' not found.");
2727

28-
userRepository.Remove(user);
29-
30-
events.CollectEvent(new UserDeleted(user.Id));
28+
if (user.EmailConfirmed)
29+
{
30+
userRepository.Remove(user);
31+
events.CollectEvent(new UserDeleted(user.Id));
32+
}
33+
else
34+
{
35+
userRepository.PermanentlyRemove(user);
36+
events.CollectEvent(new UserPurged(user.Id, UserPurgeReason.NeverActivated));
37+
}
3138

3239
return Result.Success();
3340
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
using JetBrains.Annotations;
2+
using PlatformPlatform.AccountManagement.Features.Users.Domain;
3+
using PlatformPlatform.SharedKernel.Cqrs;
4+
using PlatformPlatform.SharedKernel.ExecutionContext;
5+
using PlatformPlatform.SharedKernel.Telemetry;
6+
7+
namespace PlatformPlatform.AccountManagement.Features.Users.Commands;
8+
9+
[PublicAPI]
10+
public sealed record EmptyRecycleBinCommand : ICommand, IRequest<Result<int>>;
11+
12+
public sealed class EmptyRecycleBinHandler(IUserRepository userRepository, IExecutionContext executionContext, ITelemetryEventsCollector events)
13+
: IRequestHandler<EmptyRecycleBinCommand, Result<int>>
14+
{
15+
public async Task<Result<int>> Handle(EmptyRecycleBinCommand command, CancellationToken cancellationToken)
16+
{
17+
if (executionContext.UserInfo.Role != nameof(UserRole.Owner))
18+
{
19+
return Result<int>.Forbidden("Only owners can empty the deleted users recycle bin.");
20+
}
21+
22+
var deletedUsers = await userRepository.GetAllDeletedAsync(cancellationToken);
23+
24+
if (deletedUsers.Length == 0)
25+
{
26+
return 0;
27+
}
28+
29+
userRepository.PermanentlyRemoveRange(deletedUsers);
30+
31+
foreach (var user in deletedUsers)
32+
{
33+
events.CollectEvent(new UserPurged(user.Id, UserPurgeReason.RecycleBinPurge));
34+
}
35+
36+
return deletedUsers.Length;
37+
}
38+
}

application/account-management/Core/Features/Users/Commands/InviteUser.cs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,13 @@ public async Task<Result> Handle(InviteUserCommand command, CancellationToken ca
4949

5050
if (!await userRepository.IsEmailFreeAsync(command.Email, cancellationToken))
5151
{
52-
return Result.BadRequest($"The user with '{command.Email}' already exists.");
52+
var deletedUser = await userRepository.GetDeletedUserByEmailAsync(command.Email, cancellationToken);
53+
if (deletedUser is not null)
54+
{
55+
return Result.BadRequest($"The user '{command.Email}' was previously deleted. Please restore or permanently delete the user before inviting again.");
56+
}
57+
58+
return Result.BadRequest($"The user '{command.Email}' already exists.");
5359
}
5460

5561
var result = await mediator.Send(
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
using JetBrains.Annotations;
2+
using PlatformPlatform.AccountManagement.Features.Users.Domain;
3+
using PlatformPlatform.SharedKernel.Cqrs;
4+
using PlatformPlatform.SharedKernel.Domain;
5+
using PlatformPlatform.SharedKernel.ExecutionContext;
6+
using PlatformPlatform.SharedKernel.Telemetry;
7+
8+
namespace PlatformPlatform.AccountManagement.Features.Users.Commands;
9+
10+
[PublicAPI]
11+
public sealed record PurgeUserCommand(UserId Id) : ICommand, IRequest<Result>;
12+
13+
public sealed class PurgeUserHandler(IUserRepository userRepository, IExecutionContext executionContext, ITelemetryEventsCollector events)
14+
: IRequestHandler<PurgeUserCommand, Result>
15+
{
16+
public async Task<Result> Handle(PurgeUserCommand command, CancellationToken cancellationToken)
17+
{
18+
if (executionContext.UserInfo.Role is not (nameof(UserRole.Owner) or nameof(UserRole.Admin)))
19+
{
20+
return Result.Forbidden("Only owners and admins can permanently delete users.");
21+
}
22+
23+
var user = await userRepository.GetDeletedByIdAsync(command.Id, cancellationToken);
24+
if (user is null)
25+
{
26+
return Result.NotFound($"Deleted user with id '{command.Id}' not found.");
27+
}
28+
29+
userRepository.PermanentlyRemove(user);
30+
31+
events.CollectEvent(new UserPurged(user.Id, UserPurgeReason.SingleUserPurge));
32+
33+
return Result.Success();
34+
}
35+
}

0 commit comments

Comments
 (0)