Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
using Microsoft.EntityFrameworkCore;
using PlatformPlatform.SharedKernel.Database;

namespace PlatformPlatform.AccountManagement.Database.DataMigrations;

public sealed class PopulateUserLastSeenAt(AccountManagementDbContext dbContext) : IDataMigration
{
public string Id => "20260106160001_PopulateUserLastSeenAt";

public async Task<string> ExecuteAsync(CancellationToken cancellationToken)
{
var rowsAffected = await dbContext.Database.ExecuteSqlRawAsync(
"""
UPDATE Users
SET LastSeenAt = COALESCE(ModifiedAt, CreatedAt)
WHERE EmailConfirmed = 1 AND LastSeenAt IS NULL
""",
cancellationToken
);

await dbContext.SaveChangesAsync(cancellationToken);

return $"Updated {rowsAffected} users with LastSeenAt";
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using PlatformPlatform.AccountManagement.Database;

namespace PlatformPlatform.AccountManagement.Database.Migrations;

[DbContext(typeof(AccountManagementDbContext))]
[Migration("20260106160000_AddUserLastSeenAt")]
public sealed class AddUserLastSeenAt : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTimeOffset>("LastSeenAt", "Users", "datetimeoffset", nullable: true);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,9 @@ public async Task<Result> Handle(CompleteLoginCommand command, CancellationToken
var session = Session.Create(user.TenantId, user.Id, userAgent, ipAddress);
await sessionRepository.AddAsync(session, cancellationToken);

user.UpdateLastSeen(timeProvider.GetUtcNow());
userRepository.Update(user);

var userInfo = await userInfoFactory.CreateUserInfoAsync(user, cancellationToken, session.Id);
authenticationTokenService.CreateAndSetAuthenticationTokens(userInfo, session.Id, session.RefreshTokenJti);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,9 @@ public async Task<Result> Handle(RefreshAuthenticationTokensCommand command, Can
{
session.Refresh();
sessionRepository.Update(session);

user.UpdateLastSeen(now);
userRepository.Update(user);
}

var userInfo = await userInfoFactory.CreateUserInfoAsync(user, cancellationToken, session.Id);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,9 @@ public async Task<Result> Handle(SwitchTenantCommand command, CancellationToken
var session = Session.Create(targetUser.TenantId, targetUser.Id, userAgent, ipAddress);
await sessionRepository.AddAsync(session, cancellationToken);

targetUser.UpdateLastSeen(timeProvider.GetUtcNow());
userRepository.Update(targetUser);

var userInfo = await userInfoFactory.CreateUserInfoAsync(targetUser, cancellationToken, session.Id);
authenticationTokenService.CreateAndSetAuthenticationTokens(userInfo, session.Id, session.RefreshTokenJti, currentSession.ExpiresAt);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ public sealed class CompleteSignupHandler(
IHttpContextAccessor httpContextAccessor,
IExecutionContext executionContext,
IMediator mediator,
ITelemetryEventsCollector events
ITelemetryEventsCollector events,
TimeProvider timeProvider
) : IRequestHandler<CompleteSignupCommand, Result>
{
public async Task<Result> Handle(CompleteSignupCommand command, CancellationToken cancellationToken)
Expand All @@ -55,6 +56,9 @@ public async Task<Result> Handle(CompleteSignupCommand command, CancellationToke
var session = Session.Create(user!.TenantId, user.Id, userAgent, ipAddress);
await sessionRepository.AddAsync(session, cancellationToken);

user.UpdateLastSeen(timeProvider.GetUtcNow());
userRepository.Update(user);

var userInfo = await userInfoFactory.CreateUserInfoAsync(user, cancellationToken, session.Id);
authenticationTokenService.CreateAndSetAuthenticationTokens(userInfo, session.Id, session.RefreshTokenJti);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ public string Email

public bool IsInternalUser => Email.EndsWith(Settings.Current.Identity.InternalEmailDomain, StringComparison.OrdinalIgnoreCase);

public DateTimeOffset? LastSeenAt { get; private set; }

public DateTimeOffset? DeletedAt { get; private set; }

[NotMapped]
Expand Down Expand Up @@ -102,6 +104,11 @@ public void ChangeLocale(string locale)
{
Locale = locale;
}

public void UpdateLastSeen(DateTimeOffset lastSeenAt)
{
LastSeenAt = lastSeenAt;
}
}

public sealed record Avatar(string? Url = null, int Version = 0, bool IsGravatar = false);
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using JetBrains.Annotations;
using Microsoft.EntityFrameworkCore;
using PlatformPlatform.AccountManagement.Database;
using PlatformPlatform.SharedKernel.Domain;
Expand Down Expand Up @@ -115,20 +116,23 @@ public async Task<User[]> GetDeletedByIdsAsync(UserId[] ids, CancellationToken c

public async Task<(int TotalUsers, int ActiveUsers, int PendingUsers)> GetUserSummaryAsync(CancellationToken cancellationToken)
{
var thirtyDaysAgo = timeProvider.GetUtcNow().AddDays(-30);

var summary = await DbSet
.GroupBy(_ => 1) // Group all records into a single group to calculate multiple COUNT aggregates in one query
.Select(g => new
{
TotalUsers = g.Count(),
ActiveUsers = g.Count(u => u.EmailConfirmed && u.ModifiedAt >= thirtyDaysAgo),
PendingUsers = g.Count(u => !u.EmailConfirmed)
}
)
var thirtyDaysAgo = timeProvider.GetUtcNow().AddDays(-30).ToString("O");
var tenantId = executionContext.TenantId!.Value.ToString();

var sql = """
SELECT
COUNT(*) AS TotalUsers,
SUM(CASE WHEN EmailConfirmed = 1 AND LastSeenAt >= {0} THEN 1 ELSE 0 END) AS ActiveUsers,
SUM(CASE WHEN EmailConfirmed = 0 THEN 1 ELSE 0 END) AS PendingUsers
FROM Users
WHERE TenantId = {1} AND DeletedAt IS NULL
""";

var result = await accountManagementDbContext.Database
.SqlQueryRaw<UserSummaryResult>(sql, thirtyDaysAgo, tenantId)
.SingleAsync(cancellationToken);

return (summary.TotalUsers, summary.ActiveUsers, summary.PendingUsers);
return (result.TotalUsers, result.ActiveUsers, result.PendingUsers);
}

public async Task<(User[] Users, int TotalItems, int TotalPages)> Search(
Expand Down Expand Up @@ -182,9 +186,9 @@ CancellationToken cancellationToken
SortableUserProperties.CreatedAt => sortOrder == SortOrder.Ascending
? users.OrderBy(u => u.CreatedAt)
: users.OrderByDescending(u => u.CreatedAt),
SortableUserProperties.ModifiedAt => sortOrder == SortOrder.Ascending
? users.OrderBy(u => u.ModifiedAt)
: users.OrderByDescending(u => u.ModifiedAt),
SortableUserProperties.LastSeenAt => sortOrder == SortOrder.Ascending
? users.OrderBy(u => u.LastSeenAt)
: users.OrderByDescending(u => u.LastSeenAt),
SortableUserProperties.Name => sortOrder == SortOrder.Ascending
? users.OrderBy(u => u.FirstName == null ? 1 : 0)
.ThenBy(u => u.FirstName)
Expand Down Expand Up @@ -239,4 +243,7 @@ public async Task<User[]> GetUsersByEmailUnfilteredAsync(string email, Cancellat
.Where(u => u.Email == email.ToLowerInvariant())
.ToArrayAsync(cancellationToken);
}

[UsedImplicitly]
private sealed record UserSummaryResult(int TotalUsers, int ActiveUsers, int PendingUsers);
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ public enum UserStatus
public enum SortableUserProperties
{
CreatedAt,
ModifiedAt,
LastSeenAt,
Name,
Email,
Role
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ public sealed record UserDetails(
UserId Id,
DateTimeOffset CreatedAt,
DateTimeOffset? ModifiedAt,
DateTimeOffset? LastSeenAt,
string Email,
UserRole Role,
string FirstName,
Expand Down
90 changes: 90 additions & 0 deletions application/account-management/Tests/Users/GetUserSummaryTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
using System.Text.Json;
using FluentAssertions;
using PlatformPlatform.AccountManagement.Database;
using PlatformPlatform.AccountManagement.Features.Users.Domain;
using PlatformPlatform.AccountManagement.Features.Users.Queries;
using PlatformPlatform.SharedKernel.Domain;
using PlatformPlatform.SharedKernel.Tests;
using PlatformPlatform.SharedKernel.Tests.Persistence;
using Xunit;

namespace PlatformPlatform.AccountManagement.Tests.Users;

public sealed class GetUserSummaryTests : EndpointBaseTest<AccountManagementDbContext>
{
[Fact]
public async Task GetUserSummary_WhenUsersHaveVariousLastSeenDates_ShouldCountActiveUsersCorrectly()
{
// Arrange
var now = TimeProvider.GetUtcNow();
var thirtyOneDaysAgo = now.AddDays(-31);

// Set the seeded owner user as active (LastSeenAt within 30 days)
Connection.Update("Users", "Id", DatabaseSeeder.Tenant1Owner.Id.ToString(), [("LastSeenAt", now)]);

// Insert an active user (LastSeenAt within 30 days, confirmed)
Connection.Insert("Users", [
("TenantId", DatabaseSeeder.Tenant1.Id.ToString()),
("Id", UserId.NewId().ToString()),
("CreatedAt", now.AddMinutes(-10)),
("ModifiedAt", null),
("Email", "active@example.com"),
("FirstName", "Active"),
("LastName", "User"),
("Title", null),
("Role", nameof(UserRole.Member)),
("EmailConfirmed", true),
("LastSeenAt", now.AddDays(-5)),
("Avatar", JsonSerializer.Serialize(new Avatar())),
("Locale", "en-US")
]
);

// Insert an inactive user (LastSeenAt older than 30 days, confirmed)
Connection.Insert("Users", [
("TenantId", DatabaseSeeder.Tenant1.Id.ToString()),
("Id", UserId.NewId().ToString()),
("CreatedAt", now.AddDays(-60)),
("ModifiedAt", null),
("Email", "inactive@example.com"),
("FirstName", "Inactive"),
("LastName", "User"),
("Title", null),
("Role", nameof(UserRole.Member)),
("EmailConfirmed", true),
("LastSeenAt", thirtyOneDaysAgo),
("Avatar", JsonSerializer.Serialize(new Avatar())),
("Locale", "en-US")
]
);

// Insert a pending user (not confirmed, no LastSeenAt)
Connection.Insert("Users", [
("TenantId", DatabaseSeeder.Tenant1.Id.ToString()),
("Id", UserId.NewId().ToString()),
("CreatedAt", now.AddMinutes(-5)),
("ModifiedAt", null),
("Email", "pending@example.com"),
("FirstName", null),
("LastName", null),
("Title", null),
("Role", nameof(UserRole.Member)),
("EmailConfirmed", false),
("LastSeenAt", null),
("Avatar", JsonSerializer.Serialize(new Avatar())),
("Locale", "en-US")
]
);

// Act
var response = await AuthenticatedOwnerHttpClient.GetAsync("/api/account-management/users/summary");

// Assert
response.ShouldBeSuccessfulGetRequest();
var summaryResponse = await response.DeserializeResponse<UserSummaryResponse>();
summaryResponse.Should().NotBeNull();
summaryResponse.TotalUsers.Should().Be(5);
summaryResponse.ActiveUsers.Should().Be(2);
summaryResponse.PendingUsers.Should().Be(1);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -119,9 +119,9 @@ function UserProfileContent({
</div>
<div className="flex justify-between">
<Text className="text-sm">
<Trans>Modified</Trans>
<Trans>Last seen</Trans>
</Text>
<Text className="text-sm">{formatDate(user.modifiedAt, true)}</Text>
<Text className="text-sm">{formatDate(user.lastSeenAt, true)}</Text>
</div>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -250,8 +250,8 @@ export function UserTable({
</Column>
)}
{isMediumViewportOrLarger() && (
<Column minWidth={65} defaultWidth={140} allowsSorting={true} id={SortableUserProperties.ModifiedAt}>
<Trans>Modified</Trans>
<Column minWidth={65} defaultWidth={140} allowsSorting={true} id={SortableUserProperties.LastSeenAt}>
<Trans>Last seen</Trans>
</Column>
)}
{isSmallViewportOrLarger() && (
Expand Down Expand Up @@ -304,7 +304,7 @@ export function UserTable({
)}
{isMediumViewportOrLarger() && (
<Cell>
<SmartDate date={user.modifiedAt} className="text-foreground" />
<SmartDate date={user.lastSeenAt} className="text-foreground" />
</Cell>
)}
{isSmallViewportOrLarger() && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1708,6 +1708,11 @@
"format": "date-time",
"nullable": true
},
"lastSeenAt": {
"type": "string",
"format": "date-time",
"nullable": true
},
"email": {
"type": "string"
},
Expand Down Expand Up @@ -1763,14 +1768,14 @@
"description": "",
"x-enumNames": [
"CreatedAt",
"ModifiedAt",
"LastSeenAt",
"Name",
"Email",
"Role"
],
"enum": [
"CreatedAt",
"ModifiedAt",
"LastSeenAt",
"Name",
"Email",
"Role"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,9 @@ msgstr "Sidst aktiv:"
msgid "Last name"
msgstr "Efternavn"

msgid "Last seen"
msgstr "Sidst set"

msgid "Leave"
msgstr "Forlad"

Expand Down Expand Up @@ -508,9 +511,6 @@ msgstr "Microsoft opdaterer og sikrer automatisk al PaaS infrastruktur"
msgid "Mobile"
msgstr "Mobil"

msgid "Modified"
msgstr "Ændret"

msgid "Modified date"
msgstr "Ændret dato"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,9 @@ msgstr "Last active:"
msgid "Last name"
msgstr "Last name"

msgid "Last seen"
msgstr "Last seen"

msgid "Leave"
msgstr "Leave"

Expand Down Expand Up @@ -508,9 +511,6 @@ msgstr "Microsoft patches and secures all PaaS infrastructure automatically"
msgid "Mobile"
msgstr "Mobile"

msgid "Modified"
msgstr "Modified"

msgid "Modified date"
msgstr "Modified date"

Expand Down
Loading
Loading