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/k8s/deployment.yml b/k8s/deployment.yml index 35ade4f2..dc061682 100644 --- a/k8s/deployment.yml +++ b/k8s/deployment.yml @@ -98,6 +98,8 @@ spec: value: "false" - name: "SS_CONFLUENT_GATEWAY_API_ENDPOINT" value: http://confluent-gateway.selfservice:8080 + - name: "SS_PORTAL_BASE_URL" + value: "https://build.dfds.cloud" envFrom: - secretRef: name: kafka-credentials diff --git a/src/SelfService.Tests/Application/TestEmailCampaignApplicationService.cs b/src/SelfService.Tests/Application/TestEmailCampaignApplicationService.cs index a3a873d4..e0e86d7a 100644 --- a/src/SelfService.Tests/Application/TestEmailCampaignApplicationService.cs +++ b/src/SelfService.Tests/Application/TestEmailCampaignApplicationService.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Threading.Tasks; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Moq; using SelfService.Application; @@ -300,10 +301,13 @@ public async Task user_capabilities_loop_excludes_non_active_capabilities() (typeof(IRequirementsMetricService), metricService.Object) ); + var configMock = new Mock(); + configMock.Setup(c => c["SS_PORTAL_BASE_URL"]).Returns("localhost:3001"); + var sut = BuildService( campaignRepo: campaignRepo.Object, userFilter: userFilter.Object, - templateRendering: new TemplateRenderingService(), + templateRendering: new TemplateRenderingService(configMock.Object), scopeFactory: scopeFactory ); 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.Tests/Infrastructure/Persistence/TestTemplateRenderingService.cs b/src/SelfService.Tests/Infrastructure/Persistence/TestTemplateRenderingService.cs index e4faea02..1162afeb 100644 --- a/src/SelfService.Tests/Infrastructure/Persistence/TestTemplateRenderingService.cs +++ b/src/SelfService.Tests/Infrastructure/Persistence/TestTemplateRenderingService.cs @@ -1,3 +1,5 @@ +using Microsoft.Extensions.Configuration; +using Moq; using SelfService.Domain.Models; using SelfService.Domain.Services; using SelfService.Infrastructure.Persistence; @@ -8,7 +10,15 @@ namespace SelfService.Tests.Infrastructure.Persistence; public class TestTemplateRenderingService { - private readonly TemplateRenderingService _sut = new(); + private readonly TemplateRenderingService _sut; + + public TestTemplateRenderingService() + { + var configMock = new Mock(); + configMock.Setup(c => c["SS_PORTAL_BASE_URL"]).Returns("localhost:3001"); + + _sut = new TemplateRenderingService(configMock.Object); + } private static TemplateRenderContext CreateContext( Capability? capability = null, @@ -367,17 +377,56 @@ public void RenderTemplate_CapabilityCost_WithData_RendersUsdSumOverWindow() var result = _sut.RenderTemplate("{{Capability.Cost.7Days}}", context); - Assert.Equal("$43.45", result); + Assert.Equal("$43.45 (no trend data)", result); } [Fact] public void RenderTemplate_CapabilityCost_WindowsTakeMostRecentDays() { var cap = A.Capability.Build(); - // 30 days of $2/day, plus one much older $1000 day that the 7/14/30-day windows exclude. - var points = new List { new(1000.00f, new DateTime(2026, 1, 1)) }; - for (var d = 0; d < 30; d++) - points.Add(new TimeSeries(2.00f, new DateTime(2026, 6, 28).AddDays(-d))); + // 60 days of data: first 30 days at $1/day, last 30 days at $2/day + var points = new List(); + var now = DateTime.UtcNow; + + // Prior 30-day period (60-30 days ago): $1/day = $30 total + for (var d = 59; d >= 30; d--) + points.Add(new TimeSeries(1.00f, now.AddDays(-d))); + + // Current 30-day period (last 30 days): $2/day = $60 total + for (var d = 29; d >= 0; d--) + points.Add(new TimeSeries(2.00f, now.AddDays(-d))); + + var context = CreateContext(capability: cap, costs: new CapabilityCosts(cap.Id, points.ToArray())); + + var result = _sut.RenderTemplate( + "{{Capability.Cost.7Days}}|{{Capability.Cost.14Days}}|{{Capability.Cost.30Days}}", + context + ); + + // 7-day: current=$14 (days 0-6 at $2), prior=$14 (days 7-13 still at $2), trend=0% + // 14-day: current=$28 (days 0-13 at $2), prior=$28 (days 14-27 still at $2), trend=0% + // 30-day: current=$60 (days 0-29 at $2), prior=$30 (days 30-59 at $1), trend=100% + Assert.Equal("$14.00 (0.0%)|$28.00 (0.0%)|$60.00 (100.0%)", result); + } + + [Fact] + public void RenderTemplate_CapabilityCost_DifferentTrendsPerWindow() + { + var cap = A.Capability.Build(); + // Data fluctuates every 7 days: $300, $200, $150, $100, $80, $50, $40, $30 per day + var points = new List(); + var now = DateTime.UtcNow; + var dailyAmounts = new[] { 300f, 200f, 150f, 100f, 80f, 50f, 40f, 30f }; + + for (var week = 0; week < 8; week++) + { + var dailyAmount = dailyAmounts[week]; + for (var day = 0; day < 7; day++) + { + points.Add(new TimeSeries(dailyAmount, now.AddDays(-(week * 7 + day)))); + } + } + var context = CreateContext(capability: cap, costs: new CapabilityCosts(cap.Id, points.ToArray())); var result = _sut.RenderTemplate( @@ -385,7 +434,87 @@ public void RenderTemplate_CapabilityCost_WindowsTakeMostRecentDays() context ); - Assert.Equal("$14.00|$28.00|$60.00", result); + // 7-day: current=$2100 (7×$300), prior=$1400 (7×$200), trend=50% + // 14-day: current=$3500 (7×$300 + 7×$200), prior=$1750 (7×$150 + 7×$100), trend=100% + // 30-day: current=$5410 (7×$300 + 7×$200 + 7×$150 + 7×$100 + 2×$80), prior=$1240 (5×$80 + 7×$50 + 7×$40 + 7×$30), trend=336.3% + Assert.Equal("$2100.00 (50.0%)|$3500.00 (100.0%)|$5410.00 (336.3%)", result); + } + + [Fact] + public void RenderTemplate_CapabilityCost_NoTrendData_InsufficientPriorPeriod() + { + var cap = A.Capability.Build(); + var now = DateTime.UtcNow; + // Only 30 days of data (current period), no prior period + var points = new List(); + for (var d = 29; d >= 0; d--) + points.Add(new TimeSeries(2.00f, now.AddDays(-d))); + + var context = CreateContext(capability: cap, costs: new CapabilityCosts(cap.Id, points.ToArray())); + var result = _sut.RenderTemplate("{{Capability.Cost.30Days}}", context); + + Assert.Equal("$60.00 (no trend data)", result); + } + + [Fact] + public void RenderTemplate_CapabilityCost_NoTrendData_PreviousPeriodZeroButCurrentPositive() + { + var cap = A.Capability.Build(); + var now = DateTime.UtcNow; + // Prior 30 days: $0, Current 30 days: $60 (undefined growth → no trend data) + var points = new List(); + for (var d = 59; d >= 30; d--) + points.Add(new TimeSeries(0.00f, now.AddDays(-d))); + for (var d = 29; d >= 0; d--) + points.Add(new TimeSeries(2.00f, now.AddDays(-d))); + + var context = CreateContext(capability: cap, costs: new CapabilityCosts(cap.Id, points.ToArray())); + var result = _sut.RenderTemplate("{{Capability.Cost.30Days}}", context); + + Assert.Equal("$60.00 (no trend data)", result); + } + + [Fact] + public void RenderTemplate_CapabilityCost_ZeroTrend_BothPeriodsZero() + { + var cap = A.Capability.Build(); + var now = DateTime.UtcNow; + // Both periods zero: trend should show 0% + var points = new List(); + for (var d = 59; d >= 0; d--) + points.Add(new TimeSeries(0.00f, now.AddDays(-d))); + + var context = CreateContext(capability: cap, costs: new CapabilityCosts(cap.Id, points.ToArray())); + var result = _sut.RenderTemplate("{{Capability.Cost.30Days}}", context); + + Assert.Equal("$0.00 (0.0%)", result); + } + + [Fact] + public void RenderTemplate_CapabilityCost_WithTrendData_CalculatesPercentageChange() + { + var cap = A.Capability.Build(); + var now = DateTime.UtcNow; + + // Create 14 days of continuous data: days 13-0 (from oldest to newest) + // Days 13-7 (prior period): $10/day = $70 total + // Days 6-0 (current period): $20/day = $140 total + // Trend: ($140 - $70) / $70 * 100 = 100% + var points = new List(); + for (var d = 13; d >= 7; d--) + points.Add(new TimeSeries(10.00f, now.AddDays(-d))); + for (var d = 6; d >= 0; d--) + points.Add(new TimeSeries(20.00f, now.AddDays(-d))); + + var costs = new CapabilityCosts(cap.Id, points.ToArray()); + var context = CreateContext(capability: cap, costs: costs); + + var result = _sut.RenderTemplate("{{Capability.Cost.7Days}}", context); + + // Current 7-day: days 0-6 = 7 × $20 = $140 + // Prior 7-day: days 7-13 = 7 × $10 = $70 + // Trend: ($140 - $70) / $70 * 100 = 100% + Assert.Equal("$140.00 (100.0%)", result); } [Fact] @@ -424,7 +553,7 @@ public void RenderTemplate_EachUserCapabilities_CostResolvesPerCapability() ctx ); - Assert.Equal("[Cap A=$100.00][Cap B=$5.50]", result); + Assert.Equal("[Cap A=$100.00 (no trend data)][Cap B=$5.50 (no trend data)]", result); } [Fact] @@ -458,6 +587,7 @@ public void GetVariableDefinitions_ReturnsExpectedSet() { "Capability.Id", "Capability.Name", + "Capability.NameLink", "Capability.Description", "Capability.Status", "Capability.CreatedAt", 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(); diff --git a/src/SelfService/Infrastructure/Persistence/TemplateRenderingService.cs b/src/SelfService/Infrastructure/Persistence/TemplateRenderingService.cs index 956988fe..faf3fa11 100644 --- a/src/SelfService/Infrastructure/Persistence/TemplateRenderingService.cs +++ b/src/SelfService/Infrastructure/Persistence/TemplateRenderingService.cs @@ -1,6 +1,7 @@ using System.Text; using System.Text.Json; using System.Text.RegularExpressions; +using Microsoft.Extensions.Configuration; using SelfService.Domain.Models; using SelfService.Domain.Services; @@ -18,6 +19,11 @@ public class TemplateRenderingService : ITemplateRenderingService RegexOptions.Compiled | RegexOptions.Singleline ); + private readonly string _portalBaseUrl; + private readonly VariableEntry[] _variables; + private readonly Dictionary _byName; + private readonly PatternVariable[] _patterns; + [Flags] private enum AppliesTo { @@ -72,311 +78,333 @@ private sealed record PatternVariable( VarScope Scope = VarScope.PerCapability ) : VariableEntry(Name, Description, Entity, Example, Hidden, Applies, Scope); - private static readonly VariableEntry[] Variables = + public TemplateRenderingService(IConfiguration configuration) + { + _portalBaseUrl = + configuration["SS_PORTAL_BASE_URL"] + ?? throw new InvalidOperationException("SS_PORTAL_BASE_URL configuration is required but not set."); + _variables = InitializeVariables(); + _byName = _variables.OfType().ToDictionary(v => v.Name); + _patterns = _variables.OfType().ToArray(); + } + + private VariableEntry[] InitializeVariables() { - // --- Capability-targeted variables --- - new StaticVariable( - "Capability.Id", - "Capability ID slug", - "Capability", - "my-capability-abc12", - ctx => ctx.Capability?.Id.ToString() ?? "" - ), - new StaticVariable( - "Capability.Name", - "Display name", - "Capability", - "My Capability", - ctx => ctx.Capability?.Name ?? "" - ), - new StaticVariable( - "Capability.Description", - "Description text", - "Capability", - "A description...", - ctx => ctx.Capability?.Description ?? "" - ), - new StaticVariable( - "Capability.Status", - "Active or Pending Deletion", - "Capability", - "Active", - ctx => ctx.Capability?.Status.ToString() ?? "" - ), - new StaticVariable( - "Capability.CreatedAt", - "Creation date (yyyy-MM-dd)", - "Capability", - "2024-01-15", - ctx => ctx.Capability?.CreatedAt.ToString("yyyy-MM-dd") ?? "" - ), - new StaticVariable( - "Capability.CreatedBy", - "Creator user ID", - "Capability", - "user@dfds.com", - ctx => ctx.Capability?.CreatedBy ?? "" - ), - new StaticVariable( - "Capability.RequirementScore", - "Compliance score (0-100)", - "Capability", - "85", - ctx => ctx.Capability?.RequirementScore?.ToString("0") ?? "N/A" - ), - new StaticVariable( - "Capability.MemberCount", - "Number of members", - "Capability", - "12", - ctx => ctx.MemberCount.ToString() - ), - new StaticVariable( - "Capability.Cost.7Days", - "Total cost over the last 7 days (USD)", - "Capability", - "$123.45", - ctx => FormatCost(ctx.Costs?.SumForLastDays(7)) - ), - new StaticVariable( - "Capability.Cost.14Days", - "Total cost over the last 14 days (USD)", - "Capability", - "$246.90", - ctx => FormatCost(ctx.Costs?.SumForLastDays(14)) - ), - new StaticVariable( - "Capability.Cost.30Days", - "Total cost over the last 30 days (USD)", - "Capability", - "$543.21", - ctx => FormatCost(ctx.Costs?.SumForLastDays(30)) - ), - new StaticVariable( - "Member.DisplayName", - "Recipient display name", - "Member", - "Jane Doe", - ctx => ctx.Member?.DisplayName ?? "[Member Name]", - Scope: VarScope.TopLevel - ), - new StaticVariable( - "Member.Email", - "Recipient email address", - "Member", - "jane.doe@dfds.com", - ctx => ctx.Member?.Email ?? "[Member Email]", - Scope: VarScope.TopLevel - ), - // --- Shared variables (both target types) --- - new StaticVariable( - "Campaign.Name", - "Campaign name", - "Campaign", - "Q1 Migration Notice", - ctx => ctx.CampaignName, - Applies: AppliesTo.Both, - Scope: VarScope.TopLevel - ), - new StaticVariable( - "Date.Today", - "Current date (yyyy-MM-dd)", - "Date", - "2024-01-15", - _ => DateTime.UtcNow.ToString("yyyy-MM-dd"), - Applies: AppliesTo.Both, - Scope: VarScope.TopLevel - ), - new StaticVariable( - "Date.Year", - "Current year", - "Date", - "2024", - _ => DateTime.UtcNow.Year.ToString(), - Applies: AppliesTo.Both, - Scope: VarScope.TopLevel - ), - new PatternVariable( - "Requirement.", - "Individual requirement score (0-100). Replace with: mandatory_tags, external_secrets, irsa, k8s-probes, ecr-pull", - "Requirement", - "85", - new Regex(@"^Requirement\.(?[^.]+)$", RegexOptions.Compiled), - (m, ctx) => ResolveRequirementScore(ctx, m.Groups["id"].Value), - FallbackOnMiss: "N/A" - ), - new PatternVariable( - "Requirement..DisplayName", - "Requirement display name", - "Requirement", - "Use of Mandatory Tags", - new Regex(@"^Requirement\.(?[^.]+)\.DisplayName$", RegexOptions.Compiled), - (m, ctx) => ResolveRequirementDisplayName(ctx, m.Groups["id"].Value), - FallbackOnMiss: "N/A" - ), - new PatternVariable( - "Requirement..HelpUrl", - "Requirement help URL", - "Requirement", - "https://wiki.dfds.cloud/...", - new Regex(@"^Requirement\.(?[^.]+)\.HelpUrl$", RegexOptions.Compiled), - (m, ctx) => ResolveRequirementHelpUrl(ctx, m.Groups["id"].Value), - FallbackOnMiss: "N/A" - ), - // Catch-all: preserves the original "any unmatched Requirement.* → N/A" behavior. - new PatternVariable( - "Requirement.", - "", - "Requirement", - "", - new Regex(@"^Requirement\..+$", RegexOptions.Compiled), - (_, _) => null, - FallbackOnMiss: "N/A", - Hidden: true - ), - new StaticVariable( - "Aws.AccountId", - "AWS account number (12-digit)", - "AwsAccount", - "123456789012", - ctx => ctx.AwsAccount?.Registration.AccountId?.ToString() ?? "N/A" - ), - new StaticVariable( - "Aws.Status", - "AWS account status", - "AwsAccount", - "Completed", - ctx => ctx.AwsAccount?.Status.ToString() ?? "N/A" - ), - new StaticVariable( - "Aws.Namespace", - "Kubernetes namespace linked to AWS account", - "AwsAccount", - "my-capability-abc12", - ctx => ctx.AwsAccount?.KubernetesLink.Namespace ?? "N/A" - ), - new StaticVariable( - "Aws.RoleEmail", - "AWS account role email", - "AwsAccount", - "aws.123456789012@dfds.com", - ctx => ctx.AwsAccount?.Registration.RoleEmail ?? "N/A" - ), - new StaticVariable( - "Azure.ResourceCount", - "Number of Azure resource groups", - "AzureResource", - "2", - ctx => ctx.AzureResources.Count.ToString() - ), - new StaticVariable( - "Azure.Environments", - "Comma-separated Azure environments", - "AzureResource", - "dev, prod", - ctx => - ctx.AzureResources.Count > 0 - ? string.Join(", ", ctx.AzureResources.Select(r => r.Environment).OrderBy(e => e)) - : "None" - ), - new PatternVariable( - "Azure..Id", - "Resource group ID for a specific environment (e.g. Azure.dev.Id)", - "AzureResource", - "a1b2c3d4-e5f6-...", - new Regex(@"^Azure\.(?[^.]+)\.Id$", RegexOptions.Compiled), - (m, ctx) => ctx.AzureResources.FirstOrDefault(r => r.Environment == m.Groups["env"].Value)?.Id.ToString() - ), - new StaticVariable( - "MembershipApplications.PendingCount", - "Number of pending membership applications", - "MembershipApplication", - "3", - ctx => ctx.PendingMembershipApplicationCount.ToString() - ), - // Hidden: not advertised by /email-campaigns/variables but supported by the renderer. - new PatternVariable( - "Metadata.", - "", - "Capability", - "", - new Regex(@"^Metadata\.(?.+)$", RegexOptions.Compiled), - (m, ctx) => LookupMetadata(ctx.Capability?.JsonMetadata, m.Groups["key"].Value), - Hidden: true - ), - // --- User-targeted variables --- - new StaticVariable( - "User.Id", - "Recipient user ID", - "User", - "user@dfds.com", - ctx => ctx.Member?.Id.ToString() ?? "[User Id]", - Applies: AppliesTo.User, - Scope: VarScope.TopLevel - ), - new StaticVariable( - "User.Email", - "Recipient email address", - "User", - "jane.doe@dfds.com", - ctx => ctx.Member?.Email ?? "[User Email]", - Applies: AppliesTo.User, - Scope: VarScope.TopLevel - ), - new StaticVariable( - "User.DisplayName", - "Recipient display name", - "User", - "Jane Doe", - ctx => ctx.Member?.DisplayName ?? "[User Name]", - Applies: AppliesTo.User, - Scope: VarScope.TopLevel - ), - new StaticVariable( - "User.LastSeen", - "Date the user was last seen in the portal (yyyy-MM-dd), or 'Never'", - "User", - "2024-01-15", - ctx => ctx.Member?.LastSeen?.ToString("yyyy-MM-dd") ?? "Never", - Applies: AppliesTo.User, - Scope: VarScope.TopLevel - ), - new StaticVariable( - "User.CapabilityCount", - "Number of capabilities the recipient belongs to", - "User", - "3", - ctx => ctx.UserCapabilities.Count.ToString(), - Applies: AppliesTo.User, - Scope: VarScope.TopLevel - ), - new StaticVariable( - "User.CapabilityNames", - "Comma-separated names of capabilities the recipient belongs to", - "User", - "Cap A, Cap B, Cap C", - ctx => string.Join(", ", ctx.UserCapabilities.Select(uc => uc.Capability.Name)), - Applies: AppliesTo.User, - Scope: VarScope.TopLevel - ), - // Documented as a block construct so users discover it via the variable picker. - // Render-side handling lives in the {{#each User.Capabilities}} block pre-pass. - new StaticVariable( - "User.Capabilities", - "Iterable: {{#each User.Capabilities}} ... {{Capability.Name}} ... {{/each}}", - "User", - "{{#each User.Capabilities}}...{{/each}}", - _ => "", - Applies: AppliesTo.User, - Scope: VarScope.TopLevel - ), - }; - - private static readonly Dictionary ByName = Variables - .OfType() - .ToDictionary(v => v.Name); - - private static readonly PatternVariable[] Patterns = Variables.OfType().ToArray(); + return new VariableEntry[] + { + // --- Capability-targeted variables --- + new StaticVariable( + "Capability.Id", + "Capability ID slug", + "Capability", + "my-capability-abc12", + ctx => ctx.Capability?.Id.ToString() ?? "" + ), + new StaticVariable( + "Capability.Name", + "Display name", + "Capability", + "My Capability", + ctx => ctx.Capability?.Name ?? "" + ), + new StaticVariable( + "Capability.NameLink", + "Display name as HTML link to capability", + "Capability", + "My Capability", + ctx => + { + if (ctx.Capability == null) + return ""; + var capId = ctx.Capability.Id.ToString(); + var name = ctx.Capability.Name; + return $"{name}"; + } + ), + new StaticVariable( + "Capability.Description", + "Description text", + "Capability", + "A description...", + ctx => ctx.Capability?.Description ?? "" + ), + new StaticVariable( + "Capability.Status", + "Active or Pending Deletion", + "Capability", + "Active", + ctx => ctx.Capability?.Status.ToString() ?? "" + ), + new StaticVariable( + "Capability.CreatedAt", + "Creation date (yyyy-MM-dd)", + "Capability", + "2024-01-15", + ctx => ctx.Capability?.CreatedAt.ToString("yyyy-MM-dd") ?? "" + ), + new StaticVariable( + "Capability.CreatedBy", + "Creator user ID", + "Capability", + "user@dfds.com", + ctx => ctx.Capability?.CreatedBy ?? "" + ), + new StaticVariable( + "Capability.RequirementScore", + "Compliance score (0-100)", + "Capability", + "85", + ctx => ctx.Capability?.RequirementScore?.ToString("0") ?? "N/A" + ), + new StaticVariable( + "Capability.MemberCount", + "Number of members", + "Capability", + "12", + ctx => ctx.MemberCount.ToString() + ), + new StaticVariable( + "Capability.Cost.7Days", + "Total cost over the last 7 days (USD) with trend", + "Capability", + "$123.45 (-5.2%)", + ctx => FormatCostWithTrend(ctx.Costs, 7) + ), + new StaticVariable( + "Capability.Cost.14Days", + "Total cost over the last 14 days (USD) with trend", + "Capability", + "$246.90 (-5.2%)", + ctx => FormatCostWithTrend(ctx.Costs, 14) + ), + new StaticVariable( + "Capability.Cost.30Days", + "Total cost over the last 30 days (USD) with trend", + "Capability", + "$543.21 (-5.2%)", + ctx => FormatCostWithTrend(ctx.Costs, 30) + ), + new StaticVariable( + "Member.DisplayName", + "Recipient display name", + "Member", + "Jane Doe", + ctx => ctx.Member?.DisplayName ?? "[Member Name]", + Scope: VarScope.TopLevel + ), + new StaticVariable( + "Member.Email", + "Recipient email address", + "Member", + "jane.doe@dfds.com", + ctx => ctx.Member?.Email ?? "[Member Email]", + Scope: VarScope.TopLevel + ), + // --- Shared variables (both target types) --- + new StaticVariable( + "Campaign.Name", + "Campaign name", + "Campaign", + "Q1 Migration Notice", + ctx => ctx.CampaignName, + Applies: AppliesTo.Both, + Scope: VarScope.TopLevel + ), + new StaticVariable( + "Date.Today", + "Current date (yyyy-MM-dd)", + "Date", + "2024-01-15", + _ => DateTime.UtcNow.ToString("yyyy-MM-dd"), + Applies: AppliesTo.Both, + Scope: VarScope.TopLevel + ), + new StaticVariable( + "Date.Year", + "Current year", + "Date", + "2024", + _ => DateTime.UtcNow.Year.ToString(), + Applies: AppliesTo.Both, + Scope: VarScope.TopLevel + ), + new PatternVariable( + "Requirement.", + "Individual requirement score (0-100). Replace with: mandatory_tags, external_secrets, irsa, k8s-probes, ecr-pull", + "Requirement", + "85", + new Regex(@"^Requirement\.(?[^.]+)$", RegexOptions.Compiled), + (m, ctx) => ResolveRequirementScore(ctx, m.Groups["id"].Value), + FallbackOnMiss: "N/A" + ), + new PatternVariable( + "Requirement..DisplayName", + "Requirement display name", + "Requirement", + "Use of Mandatory Tags", + new Regex(@"^Requirement\.(?[^.]+)\.DisplayName$", RegexOptions.Compiled), + (m, ctx) => ResolveRequirementDisplayName(ctx, m.Groups["id"].Value), + FallbackOnMiss: "N/A" + ), + new PatternVariable( + "Requirement..HelpUrl", + "Requirement help URL", + "Requirement", + "https://wiki.dfds.cloud/...", + new Regex(@"^Requirement\.(?[^.]+)\.HelpUrl$", RegexOptions.Compiled), + (m, ctx) => ResolveRequirementHelpUrl(ctx, m.Groups["id"].Value), + FallbackOnMiss: "N/A" + ), + // Catch-all: preserves the original "any unmatched Requirement.* → N/A" behavior. + new PatternVariable( + "Requirement.", + "", + "Requirement", + "", + new Regex(@"^Requirement\..+$", RegexOptions.Compiled), + (_, _) => null, + FallbackOnMiss: "N/A", + Hidden: true + ), + new StaticVariable( + "Aws.AccountId", + "AWS account number (12-digit)", + "AwsAccount", + "123456789012", + ctx => ctx.AwsAccount?.Registration.AccountId?.ToString() ?? "N/A" + ), + new StaticVariable( + "Aws.Status", + "AWS account status", + "AwsAccount", + "Completed", + ctx => ctx.AwsAccount?.Status.ToString() ?? "N/A" + ), + new StaticVariable( + "Aws.Namespace", + "Kubernetes namespace linked to AWS account", + "AwsAccount", + "my-capability-abc12", + ctx => ctx.AwsAccount?.KubernetesLink.Namespace ?? "N/A" + ), + new StaticVariable( + "Aws.RoleEmail", + "AWS account role email", + "AwsAccount", + "aws.123456789012@dfds.com", + ctx => ctx.AwsAccount?.Registration.RoleEmail ?? "N/A" + ), + new StaticVariable( + "Azure.ResourceCount", + "Number of Azure resource groups", + "AzureResource", + "2", + ctx => ctx.AzureResources.Count.ToString() + ), + new StaticVariable( + "Azure.Environments", + "Comma-separated Azure environments", + "AzureResource", + "dev, prod", + ctx => + ctx.AzureResources.Count > 0 + ? string.Join(", ", ctx.AzureResources.Select(r => r.Environment).OrderBy(e => e)) + : "None" + ), + new PatternVariable( + "Azure..Id", + "Resource group ID for a specific environment (e.g. Azure.dev.Id)", + "AzureResource", + "a1b2c3d4-e5f6-...", + new Regex(@"^Azure\.(?[^.]+)\.Id$", RegexOptions.Compiled), + (m, ctx) => + ctx.AzureResources.FirstOrDefault(r => r.Environment == m.Groups["env"].Value)?.Id.ToString() + ), + new StaticVariable( + "MembershipApplications.PendingCount", + "Number of pending membership applications", + "MembershipApplication", + "3", + ctx => ctx.PendingMembershipApplicationCount.ToString() + ), + // Hidden: not advertised by /email-campaigns/variables but supported by the renderer. + new PatternVariable( + "Metadata.", + "", + "Capability", + "", + new Regex(@"^Metadata\.(?.+)$", RegexOptions.Compiled), + (m, ctx) => LookupMetadata(ctx.Capability?.JsonMetadata, m.Groups["key"].Value), + Hidden: true + ), + // --- User-targeted variables --- + new StaticVariable( + "User.Id", + "Recipient user ID", + "User", + "user@dfds.com", + ctx => ctx.Member?.Id.ToString() ?? "[User Id]", + Applies: AppliesTo.User, + Scope: VarScope.TopLevel + ), + new StaticVariable( + "User.Email", + "Recipient email address", + "User", + "jane.doe@dfds.com", + ctx => ctx.Member?.Email ?? "[User Email]", + Applies: AppliesTo.User, + Scope: VarScope.TopLevel + ), + new StaticVariable( + "User.DisplayName", + "Recipient display name", + "User", + "Jane Doe", + ctx => ctx.Member?.DisplayName ?? "[User Name]", + Applies: AppliesTo.User, + Scope: VarScope.TopLevel + ), + new StaticVariable( + "User.LastSeen", + "Date the user was last seen in the portal (yyyy-MM-dd), or 'Never'", + "User", + "2024-01-15", + ctx => ctx.Member?.LastSeen?.ToString("yyyy-MM-dd") ?? "Never", + Applies: AppliesTo.User, + Scope: VarScope.TopLevel + ), + new StaticVariable( + "User.CapabilityCount", + "Number of capabilities the recipient belongs to", + "User", + "3", + ctx => ctx.UserCapabilities.Count.ToString(), + Applies: AppliesTo.User, + Scope: VarScope.TopLevel + ), + new StaticVariable( + "User.CapabilityNames", + "Comma-separated names of capabilities the recipient belongs to", + "User", + "Cap A, Cap B, Cap C", + ctx => string.Join(", ", ctx.UserCapabilities.Select(uc => uc.Capability.Name)), + Applies: AppliesTo.User, + Scope: VarScope.TopLevel + ), + // Documented as a block construct so users discover it via the variable picker. + // Render-side handling lives in the {{#each User.Capabilities}} block pre-pass. + new StaticVariable( + "User.Capabilities", + "Iterable: {{#each User.Capabilities}} ... {{Capability.Name}} ... {{/each}}", + "User", + "{{#each User.Capabilities}}...{{/each}}", + _ => "", + Applies: AppliesTo.User, + Scope: VarScope.TopLevel + ), + }; + } public string RenderTemplate(string template, TemplateRenderContext context) { @@ -389,7 +417,7 @@ public IReadOnlyList GetVariableDefinitions(EmailCampaignTarge var resolved = targetType ?? EmailCampaignTargetType.Capability; var isUser = resolved == EmailCampaignTargetType.User; - return Variables + return _variables .Where(v => !v.Hidden) // Capability campaigns: every variable that applies to a capability resolves directly. // User campaigns: top-level user/shared variables, plus all per-capability variables @@ -445,12 +473,12 @@ private string ExpandUserCapabilitiesBlocks(string template, TemplateRenderConte ); } - private static string? Resolve(string name, TemplateRenderContext context) + private string? Resolve(string name, TemplateRenderContext context) { - if (ByName.TryGetValue(name, out var def)) + if (_byName.TryGetValue(name, out var def)) return def.Resolve(context); - foreach (var p in Patterns) + foreach (var p in _patterns) { var match = p.Regex.Match(name); if (!match.Success) @@ -464,6 +492,63 @@ private string ExpandUserCapabilitiesBlocks(string template, TemplateRenderConte // Matches the portal's cost display currency (USD); "N/A" when no cost data is cached. private static string FormatCost(float? value) => value is null ? "N/A" : "$" + value.Value.ToString("0.00"); + // Format cost with trend: "$543.21 (-5.2%)" or "$543.21 (no trend data)" + private static string FormatCostWithTrend(CapabilityCosts? costs, int days) + { + if (costs?.Costs == null || costs.Costs.Length == 0) + return "N/A"; + + var costValue = costs.SumForLastDays(days); + if (costValue == null) + return "N/A"; + + var trend = CalculateCostTrend(costs.Costs, days); + var costStr = "$" + costValue.Value.ToString("0.00"); + + if (trend == null) + return costStr + " (no trend data)"; + + return costStr + $" ({trend:0.0}%)"; + } + + // Calculate the trend percentage for a given period + // Returns null if insufficient data, otherwise the percentage change + private static decimal? CalculateCostTrend(TimeSeries[] costTimeSeries, int days) + { + if (costTimeSeries == null || costTimeSeries.Length == 0) + return null; + + var now = DateTime.UtcNow; + var currentStart = now.AddDays(-days); + var previousStart = now.AddDays(-days * 2); + + // Current period: past {days} days + var currentPeriod = costTimeSeries.Where(ts => ts.TimeStamp >= currentStart && ts.TimeStamp <= now).ToArray(); + + // Previous period: {days} days before that + var previousPeriod = costTimeSeries + .Where(ts => ts.TimeStamp >= previousStart && ts.TimeStamp < currentStart) + .ToArray(); + + // Need data in both periods + if (currentPeriod.Length == 0 || previousPeriod.Length == 0) + return null; + + var currentSum = (decimal)currentPeriod.Sum(ts => ts.Value); + var previousSum = (decimal)previousPeriod.Sum(ts => ts.Value); + + // Both periods are zero: no change + if (currentSum == 0 && previousSum == 0) + return 0m; + + // Previous was zero but current is positive: undefined growth, return null + if (previousSum == 0 && currentSum > 0) + return null; + + // Calculate percentage change: ((current - previous) / previous) * 100 + return ((currentSum - previousSum) / previousSum) * 100; + } + private static string? ResolveRequirementScore(TemplateRenderContext ctx, string id) { // Tags are not stored in the requirements DB — they are derived from the capability's metadata,