diff --git a/src/SelfService.Tests/Application/TestEmailCampaignApplicationService.cs b/src/SelfService.Tests/Application/TestEmailCampaignApplicationService.cs index 07d0523a..a3a873d4 100644 --- a/src/SelfService.Tests/Application/TestEmailCampaignApplicationService.cs +++ b/src/SelfService.Tests/Application/TestEmailCampaignApplicationService.cs @@ -7,6 +7,7 @@ using SelfService.Domain.Models; using SelfService.Domain.Queries; using SelfService.Domain.Services; +using SelfService.Infrastructure.Api.Metrics; using SelfService.Infrastructure.Persistence; using SelfService.Infrastructure.Persistence.Models; using SelfService.Tests.Builders; @@ -321,7 +322,8 @@ private static EmailCampaignApplicationService BuildService( IUserFilterService? userFilter = null, IRbacApplicationService? rbac = null, ITemplateRenderingService? templateRendering = null, - IServiceScopeFactory? scopeFactory = null + IServiceScopeFactory? scopeFactory = null, + AllCapabilitiesCostsCache? costsCache = null ) { return new EmailCampaignApplicationService( @@ -335,7 +337,8 @@ private static EmailCampaignApplicationService BuildService( userFilter ?? Mock.Of(), templateRendering ?? Mock.Of(), rbac ?? Mock.Of(), - scopeFactory ?? Mock.Of() + scopeFactory ?? Mock.Of(), + costsCache ?? new AllCapabilitiesCostsCache() ); } diff --git a/src/SelfService.Tests/Domain/Models/TestCapabilityCosts.cs b/src/SelfService.Tests/Domain/Models/TestCapabilityCosts.cs new file mode 100644 index 00000000..e6447ce6 --- /dev/null +++ b/src/SelfService.Tests/Domain/Models/TestCapabilityCosts.cs @@ -0,0 +1,43 @@ +using System; +using SelfService.Domain.Models; + +namespace SelfService.Tests.Domain.Models; + +public class TestCapabilityCosts +{ + private static CapabilityCosts Costs(params TimeSeries[] points) => + new(CapabilityId.Parse("test-capability-abc12"), points); + + [Fact] + public void SumForLastDays_NoData_ReturnsNull() + { + Assert.Null(Costs().SumForLastDays(7)); + } + + [Fact] + public void SumForLastDays_FewerDaysThanWindow_SumsAll() + { + var costs = Costs( + new TimeSeries(10.00f, new DateTime(2026, 6, 27)), + new TimeSeries(5.50f, new DateTime(2026, 6, 28)) + ); + + Assert.Equal(15.50f, costs.SumForLastDays(7)); + } + + [Fact] + public void SumForLastDays_MoreDaysThanWindow_SumsMostRecentUnordered() + { + // Deliberately unordered input; the 7-day window must pick the 7 most recent timestamps. + var costs = Costs( + new TimeSeries(1f, new DateTime(2026, 6, 20)), // older — excluded by a 3-day window + new TimeSeries(1f, new DateTime(2026, 6, 21)), // older — excluded + new TimeSeries(2f, new DateTime(2026, 6, 28)), // newest + new TimeSeries(4f, new DateTime(2026, 6, 26)), + new TimeSeries(8f, new DateTime(2026, 6, 27)) + ); + + // 3 most recent: 6/28 (2) + 6/27 (8) + 6/26 (4) = 14 + Assert.Equal(14f, costs.SumForLastDays(3)); + } +} diff --git a/src/SelfService.Tests/Infrastructure/Persistence/TestCapabilityFilterService.cs b/src/SelfService.Tests/Infrastructure/Persistence/TestCapabilityFilterService.cs new file mode 100644 index 00000000..8e5449a5 --- /dev/null +++ b/src/SelfService.Tests/Infrastructure/Persistence/TestCapabilityFilterService.cs @@ -0,0 +1,128 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using SelfService.Domain.Models; +using SelfService.Infrastructure.Api.Metrics; +using SelfService.Infrastructure.Persistence; +using SelfService.Tests.Builders; + +namespace SelfService.Tests.Infrastructure.Persistence; + +public class TestCapabilityFilterService +{ + private static string CostFilterAudience(string op, string value) => + JsonSerializer.Serialize( + new + { + mode = "filter", + filters = new[] + { + new + { + field = "cost", + @operator = op, + value = value, + }, + }, + } + ); + + private static CapabilityCosts CostsFor(Capability capability, params float[] dailyValues) + { + // SumForLastDays takes the most recent N timestamps; the absolute dates only need to be ordered. + var baseDate = new DateTime(2026, 6, 1); + var series = dailyValues.Select((v, i) => new TimeSeries(v, baseDate.AddDays(i))).ToArray(); + return new CapabilityCosts(capability.Id, series); + } + + private static async Task CacheWith(params CapabilityCosts[] costs) + { + var cache = new AllCapabilitiesCostsCache(); + await cache.UpdateCache(new AllCapabilitiesCosts(costs.ToList())); + return cache; + } + + [Fact] + [Trait("Category", "InMemoryDatabase")] + public async Task cost_filter_greater_than_excludes_cheaper_and_costless_capabilities() + { + await using var databaseFactory = new InMemoryDatabaseFactory(); + var dbContext = await databaseFactory.CreateSelfServiceDbContext(); + + var expensive = A.Capability.WithId(CapabilityId.CreateFrom("expensive")).Build(); + var cheap = A.Capability.WithId(CapabilityId.CreateFrom("cheap")).Build(); + var noCost = A.Capability.WithId(CapabilityId.CreateFrom("nocost")).Build(); + dbContext.Capabilities.AddRange(expensive, cheap, noCost); + await dbContext.SaveChangesAsync(); + + // noCost is intentionally absent from the cache. + var cache = await CacheWith(CostsFor(expensive, 6f, 4f), CostsFor(cheap, 1f)); + var sut = new CapabilityFilterService(dbContext, cache); + + var result = await sut.ResolveCapabilities(CostFilterAudience("gt", "2")); + + Assert.Equal(new[] { expensive.Id }, result.Select(c => c.Id).ToArray()); + } + + [Fact] + [Trait("Category", "InMemoryDatabase")] + public async Task cost_filter_less_than_or_equal_includes_matching_and_still_excludes_costless() + { + await using var databaseFactory = new InMemoryDatabaseFactory(); + var dbContext = await databaseFactory.CreateSelfServiceDbContext(); + + var expensive = A.Capability.WithId(CapabilityId.CreateFrom("expensive")).Build(); + var cheap = A.Capability.WithId(CapabilityId.CreateFrom("cheap")).Build(); + var noCost = A.Capability.WithId(CapabilityId.CreateFrom("nocost")).Build(); + dbContext.Capabilities.AddRange(expensive, cheap, noCost); + await dbContext.SaveChangesAsync(); + + var cache = await CacheWith(CostsFor(expensive, 10f), CostsFor(cheap, 1f)); + var sut = new CapabilityFilterService(dbContext, cache); + + var result = await sut.ResolveCapabilities(CostFilterAudience("lte", "2")); + + // cheap (1) matches; expensive (10) and noCost (no data) do not. + Assert.Equal(new[] { cheap.Id }, result.Select(c => c.Id).ToArray()); + } + + [Fact] + [Trait("Category", "InMemoryDatabase")] + public async Task cost_filter_with_empty_cache_narrows_audience_to_nothing() + { + await using var databaseFactory = new InMemoryDatabaseFactory(); + var dbContext = await databaseFactory.CreateSelfServiceDbContext(); + + dbContext.Capabilities.AddRange( + A.Capability.WithId(CapabilityId.CreateFrom("alpha")).Build(), + A.Capability.WithId(CapabilityId.CreateFrom("beta")).Build() + ); + await dbContext.SaveChangesAsync(); + + var sut = new CapabilityFilterService(dbContext, new AllCapabilitiesCostsCache()); + + var result = await sut.ResolveCapabilities(CostFilterAudience("gt", "0")); + + Assert.Empty(result); + } + + [Fact] + [Trait("Category", "InMemoryDatabase")] + public async Task no_cost_filter_leaves_costless_capabilities_untouched() + { + await using var databaseFactory = new InMemoryDatabaseFactory(); + var dbContext = await databaseFactory.CreateSelfServiceDbContext(); + + dbContext.Capabilities.AddRange( + A.Capability.WithId(CapabilityId.CreateFrom("alpha")).Build(), + A.Capability.WithId(CapabilityId.CreateFrom("beta")).Build() + ); + await dbContext.SaveChangesAsync(); + + var sut = new CapabilityFilterService(dbContext, new AllCapabilitiesCostsCache()); + + var audience = JsonSerializer.Serialize(new { mode = "all" }); + var result = await sut.ResolveCapabilities(audience); + + Assert.Equal(2, result.Count); + } +} diff --git a/src/SelfService.Tests/Infrastructure/Persistence/TestTemplateRenderingService.cs b/src/SelfService.Tests/Infrastructure/Persistence/TestTemplateRenderingService.cs index b04d7b02..e4faea02 100644 --- a/src/SelfService.Tests/Infrastructure/Persistence/TestTemplateRenderingService.cs +++ b/src/SelfService.Tests/Infrastructure/Persistence/TestTemplateRenderingService.cs @@ -16,7 +16,8 @@ private static TemplateRenderContext CreateContext( AwsAccount? awsAccount = null, List? azureResources = null, List? requirementScores = null, - int pendingMembershipApplicationCount = 0 + int pendingMembershipApplicationCount = 0, + CapabilityCosts? costs = null ) { return new TemplateRenderContext @@ -29,6 +30,7 @@ private static TemplateRenderContext CreateContext( AzureResources = azureResources ?? new List(), RequirementScores = requirementScores ?? new List(), PendingMembershipApplicationCount = pendingMembershipApplicationCount, + Costs = costs, }; } @@ -348,6 +350,83 @@ public void RenderTemplate_PendingMembershipApplicationCount_Zero_RendersZero() Assert.Equal("Pending: 0", result); } + [Fact] + public void RenderTemplate_CapabilityCost_WithData_RendersUsdSumOverWindow() + { + var cap = A.Capability.Build(); + var costs = new CapabilityCosts( + cap.Id, + new[] + { + new TimeSeries(10.00f, new DateTime(2026, 6, 20)), + new TimeSeries(20.50f, new DateTime(2026, 6, 21)), + new TimeSeries(12.95f, new DateTime(2026, 6, 22)), + } + ); + var context = CreateContext(capability: cap, costs: costs); + + var result = _sut.RenderTemplate("{{Capability.Cost.7Days}}", context); + + Assert.Equal("$43.45", 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))); + 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 + ); + + Assert.Equal("$14.00|$28.00|$60.00", result); + } + + [Fact] + public void RenderTemplate_CapabilityCost_NoData_RendersNA() + { + var context = CreateContext(costs: null); + + var result = _sut.RenderTemplate("{{Capability.Cost.30Days}}", context); + + Assert.Equal("N/A", result); + } + + [Fact] + public void RenderTemplate_EachUserCapabilities_CostResolvesPerCapability() + { + var capA = A.Capability.WithName("Cap A").Build(); + var capB = A.Capability.WithName("Cap B").Build(); + var ctx = UserContext( + userCapabilities: new List + { + new() + { + Capability = capA, + Costs = new CapabilityCosts(capA.Id, new[] { new TimeSeries(100.00f, new DateTime(2026, 6, 28)) }), + }, + new() + { + Capability = capB, + Costs = new CapabilityCosts(capB.Id, new[] { new TimeSeries(5.50f, new DateTime(2026, 6, 28)) }), + }, + } + ); + + var result = _sut.RenderTemplate( + "{{#each User.Capabilities}}[{{Capability.Name}}={{Capability.Cost.30Days}}]{{/each}}", + ctx + ); + + Assert.Equal("[Cap A=$100.00][Cap B=$5.50]", result); + } + [Fact] public void RenderTemplate_AllNewVariablesCombined() { @@ -385,6 +464,9 @@ public void GetVariableDefinitions_ReturnsExpectedSet() "Capability.CreatedBy", "Capability.RequirementScore", "Capability.MemberCount", + "Capability.Cost.7Days", + "Capability.Cost.14Days", + "Capability.Cost.30Days", "Member.DisplayName", "Member.Email", "Campaign.Name", @@ -589,24 +671,34 @@ public void RenderTemplate_EachUserCapabilities_OuterTokensStillResolve() } [Fact] - public void GetVariableDefinitions_User_OmitsCapabilityVariables() + public void GetVariableDefinitions_User_IncludesPerCapabilityVariablesWithScope() { var userVars = _sut.GetVariableDefinitions(EmailCampaignTargetType.User); - Assert.Contains(userVars, v => v.Name == "User.Email"); - Assert.Contains(userVars, v => v.Name == "User.CapabilityCount"); - Assert.Contains(userVars, v => v.Name == "Campaign.Name"); - Assert.DoesNotContain(userVars, v => v.Name == "Capability.Name"); - Assert.DoesNotContain(userVars, v => v.Name == "Aws.AccountId"); + // Top-level user/shared variables resolve at the recipient/campaign root. + Assert.Contains(userVars, v => v.Name == "User.Email" && v.Scope == "topLevel"); + Assert.Contains(userVars, v => v.Name == "User.CapabilityCount" && v.Scope == "topLevel"); + Assert.Contains(userVars, v => v.Name == "Campaign.Name" && v.Scope == "topLevel"); + + // Capability-scoped variables now surface for use inside {{#each User.Capabilities}}, + // flagged "perCapability" so the picker can mark them as loop-only. + Assert.Contains(userVars, v => v.Name == "Capability.Name" && v.Scope == "perCapability"); + Assert.Contains(userVars, v => v.Name == "Aws.AccountId" && v.Scope == "perCapability"); + Assert.Contains(userVars, v => v.Name == "Capability.Cost.30Days" && v.Scope == "perCapability"); + + // Member.* is replaced by User.* in user campaigns and must not appear. + Assert.DoesNotContain(userVars, v => v.Name == "Member.Email"); + Assert.DoesNotContain(userVars, v => v.Name == "Member.DisplayName"); } [Fact] - public void GetVariableDefinitions_Capability_OmitsUserVariables() + public void GetVariableDefinitions_Capability_OmitsUserVariables_AndScopesCostPerCapability() { var capVars = _sut.GetVariableDefinitions(EmailCampaignTargetType.Capability); - Assert.Contains(capVars, v => v.Name == "Capability.Name"); - Assert.Contains(capVars, v => v.Name == "Member.Email"); + Assert.Contains(capVars, v => v.Name == "Capability.Name" && v.Scope == "perCapability"); + Assert.Contains(capVars, v => v.Name == "Capability.Cost.7Days" && v.Scope == "perCapability"); + Assert.Contains(capVars, v => v.Name == "Member.Email" && v.Scope == "topLevel"); Assert.DoesNotContain(capVars, v => v.Name == "User.Email"); Assert.DoesNotContain(capVars, v => v.Name == "User.CapabilityCount"); } diff --git a/src/SelfService.Tests/Infrastructure/Persistence/TestUserFilterService.cs b/src/SelfService.Tests/Infrastructure/Persistence/TestUserFilterService.cs new file mode 100644 index 00000000..ac523b99 --- /dev/null +++ b/src/SelfService.Tests/Infrastructure/Persistence/TestUserFilterService.cs @@ -0,0 +1,193 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using SelfService.Domain.Models; +using SelfService.Infrastructure.Api.Metrics; +using SelfService.Infrastructure.Persistence; +using SelfService.Tests.Builders; + +namespace SelfService.Tests.Infrastructure.Persistence; + +public class TestUserFilterService +{ + private static string FilterAudience(params object[] filters) => + JsonSerializer.Serialize(new { mode = "filter", filters }); + + private static CapabilityCosts CostsFor(Capability capability, params float[] dailyValues) + { + var baseDate = new DateTime(2026, 6, 1); + var series = dailyValues.Select((v, i) => new TimeSeries(v, baseDate.AddDays(i))).ToArray(); + return new CapabilityCosts(capability.Id, series); + } + + private static async Task CacheWith(params CapabilityCosts[] costs) + { + var cache = new AllCapabilitiesCostsCache(); + await cache.UpdateCache(new AllCapabilitiesCosts(costs.ToList())); + return cache; + } + + private static UserFilterService Sut(SelfServiceDbContext dbContext, AllCapabilitiesCostsCache cache) => + new(dbContext, new CapabilityFilterService(dbContext, cache)); + + [Fact] + [Trait("Category", "InMemoryDatabase")] + public async Task capability_status_filter_selects_only_members_of_matching_capabilities() + { + await using var databaseFactory = new InMemoryDatabaseFactory(); + var dbContext = await databaseFactory.CreateSelfServiceDbContext(); + + var capActive = A + .Capability.WithId(CapabilityId.CreateFrom("active-cap")) + .WithStatus(CapabilityStatusOptions.Active) + .Build(); + var capPending = A + .Capability.WithId(CapabilityId.CreateFrom("pending-cap")) + .WithStatus(CapabilityStatusOptions.PendingDeletion) + .Build(); + var inActiveCap = A.Member.WithUserId(UserId.Parse("alice")).WithEmail("alice@dfds.com").Build(); + var inPendingCap = A.Member.WithUserId(UserId.Parse("bob")).WithEmail("bob@dfds.com").Build(); + var inNoCap = A.Member.WithUserId(UserId.Parse("carol")).WithEmail("carol@dfds.com").Build(); + + dbContext.Capabilities.AddRange(capActive, capPending); + dbContext.Members.AddRange(inActiveCap, inPendingCap, inNoCap); + dbContext.Memberships.AddRange( + A.Membership.WithCapabilityId(capActive.Id).WithUserId(inActiveCap.Id).Build(), + A.Membership.WithCapabilityId(capPending.Id).WithUserId(inPendingCap.Id).Build() + ); + await dbContext.SaveChangesAsync(); + + var result = await Sut(dbContext, new AllCapabilitiesCostsCache()) + .ResolveUsers( + FilterAudience( + new + { + field = "status", + @operator = "eq", + value = "active", + } + ) + ); + + Assert.Equal(new[] { inActiveCap.Id }, result.Members.Select(m => m.Id).ToArray()); + } + + [Fact] + [Trait("Category", "InMemoryDatabase")] + public async Task capability_cost_filter_selects_members_of_expensive_capabilities() + { + await using var databaseFactory = new InMemoryDatabaseFactory(); + var dbContext = await databaseFactory.CreateSelfServiceDbContext(); + + var expensive = A.Capability.WithId(CapabilityId.CreateFrom("expensive-cap")).Build(); + var cheap = A.Capability.WithId(CapabilityId.CreateFrom("cheap-cap")).Build(); + var onExpensive = A.Member.WithUserId(UserId.Parse("alice")).WithEmail("alice@dfds.com").Build(); + var onCheap = A.Member.WithUserId(UserId.Parse("bob")).WithEmail("bob@dfds.com").Build(); + + dbContext.Capabilities.AddRange(expensive, cheap); + dbContext.Members.AddRange(onExpensive, onCheap); + dbContext.Memberships.AddRange( + A.Membership.WithCapabilityId(expensive.Id).WithUserId(onExpensive.Id).Build(), + A.Membership.WithCapabilityId(cheap.Id).WithUserId(onCheap.Id).Build() + ); + await dbContext.SaveChangesAsync(); + + var cache = await CacheWith(CostsFor(expensive, 6f, 4f), CostsFor(cheap, 1f)); + + var result = await Sut(dbContext, cache) + .ResolveUsers( + FilterAudience( + new + { + field = "cost", + @operator = "gt", + value = "2", + } + ) + ); + + Assert.Equal(new[] { onExpensive.Id }, result.Members.Select(m => m.Id).ToArray()); + } + + [Fact] + [Trait("Category", "InMemoryDatabase")] + public async Task combined_user_and_capability_filters_are_anded() + { + await using var databaseFactory = new InMemoryDatabaseFactory(); + var dbContext = await databaseFactory.CreateSelfServiceDbContext(); + + var expensive = A.Capability.WithId(CapabilityId.CreateFrom("expensive-cap")).Build(); + var cheap = A.Capability.WithId(CapabilityId.CreateFrom("cheap-cap")).Build(); + + // alice: @dfds + expensive -> matches both filters + // bob: @dfds + cheap -> fails cost filter + // carol: @other + expensive -> fails email filter + var alice = A.Member.WithUserId(UserId.Parse("alice")).WithEmail("alice@dfds.com").Build(); + var bob = A.Member.WithUserId(UserId.Parse("bob")).WithEmail("bob@dfds.com").Build(); + var carol = A.Member.WithUserId(UserId.Parse("carol")).WithEmail("carol@other.com").Build(); + + dbContext.Capabilities.AddRange(expensive, cheap); + dbContext.Members.AddRange(alice, bob, carol); + dbContext.Memberships.AddRange( + A.Membership.WithCapabilityId(expensive.Id).WithUserId(alice.Id).Build(), + A.Membership.WithCapabilityId(cheap.Id).WithUserId(bob.Id).Build(), + A.Membership.WithCapabilityId(expensive.Id).WithUserId(carol.Id).Build() + ); + await dbContext.SaveChangesAsync(); + + var cache = await CacheWith(CostsFor(expensive, 10f), CostsFor(cheap, 1f)); + + var result = await Sut(dbContext, cache) + .ResolveUsers( + FilterAudience( + new + { + field = "email", + @operator = "contains", + value = "@dfds", + }, + new + { + field = "cost", + @operator = "gt", + value = "2", + } + ) + ); + + Assert.Equal(new[] { alice.Id }, result.Members.Select(m => m.Id).ToArray()); + } + + // NOTE: the capabilityCostCentre filter (and metadata key/value capability filters) rely on + // EF.Functions.JsonContains (PostgreSQL jsonb @>), which the SQLite-backed InMemoryDatabase + // cannot translate — so they are not unit-tested here (the same limitation applies to the + // CapabilityFilterService metadata filters). That path is unchanged by this work: capabilityCostCentre + // remains in UserScopedFields and is resolved by the existing local handler. + + [Fact] + [Trait("Category", "InMemoryDatabase")] + public async Task user_scoped_filter_only_does_not_narrow_by_membership() + { + await using var databaseFactory = new InMemoryDatabaseFactory(); + var dbContext = await databaseFactory.CreateSelfServiceDbContext(); + + // Neither member has any membership; a pure user-scoped filter must still match them. + var alice = A.Member.WithUserId(UserId.Parse("alice")).WithEmail("alice@dfds.com").Build(); + var bob = A.Member.WithUserId(UserId.Parse("bob")).WithEmail("bob@other.com").Build(); + dbContext.Members.AddRange(alice, bob); + await dbContext.SaveChangesAsync(); + + var result = await Sut(dbContext, new AllCapabilitiesCostsCache()) + .ResolveUsers( + FilterAudience( + new + { + field = "email", + @operator = "contains", + value = "@dfds", + } + ) + ); + + Assert.Equal(new[] { alice.Id }, result.Members.Select(m => m.Id).ToArray()); + } +} diff --git a/src/SelfService/Application/EmailCampaignApplicationService.cs b/src/SelfService/Application/EmailCampaignApplicationService.cs index 57c69895..77a2c3bf 100644 --- a/src/SelfService/Application/EmailCampaignApplicationService.cs +++ b/src/SelfService/Application/EmailCampaignApplicationService.cs @@ -4,6 +4,7 @@ using SelfService.Domain.Models; using SelfService.Domain.Queries; using SelfService.Domain.Services; +using SelfService.Infrastructure.Api.Metrics; using SelfService.Infrastructure.Persistence.Models; namespace SelfService.Application; @@ -21,6 +22,7 @@ public class EmailCampaignApplicationService : IEmailCampaignApplicationService private readonly ITemplateRenderingService _templateRenderingService; private readonly IRbacApplicationService _rbacApplicationService; private readonly IServiceScopeFactory _serviceScopeFactory; + private readonly AllCapabilitiesCostsCache _allCapabilitiesCostsCache; public EmailCampaignApplicationService( IEmailCampaignRepository emailCampaignRepository, @@ -33,7 +35,8 @@ public EmailCampaignApplicationService( IUserFilterService userFilterService, ITemplateRenderingService templateRenderingService, IRbacApplicationService rbacApplicationService, - IServiceScopeFactory serviceScopeFactory + IServiceScopeFactory serviceScopeFactory, + AllCapabilitiesCostsCache allCapabilitiesCostsCache ) { _emailCampaignRepository = emailCampaignRepository; @@ -47,8 +50,21 @@ IServiceScopeFactory serviceScopeFactory _templateRenderingService = templateRenderingService; _rbacApplicationService = rbacApplicationService; _serviceScopeFactory = serviceScopeFactory; + _allCapabilitiesCostsCache = allCapabilitiesCostsCache; } + /// + /// Builds a capability-id → cost lookup from the in-memory cache (refreshed twice daily by + /// ). Reused for all template rendering so a + /// campaign send never triggers a per-capability platform-data-api/Athena query. Empty when + /// the cache has not been populated yet — cost variables then render "N/A". + /// + private Dictionary GetCostsById() => + _allCapabilitiesCostsCache + .GetCachedData() + ?.Costs.GroupBy(c => c.CapabilityId) + .ToDictionary(g => g.Key, g => g.First()) ?? new Dictionary(); + [TransactionalBoundary, Outboxed] public async Task CreateDraft( string name, @@ -631,6 +647,7 @@ IReadOnlyCollection members var requirementScores = await requirementsMetricService.GetRequirementScoresForCapabilitiesAsync( allCapIds.Select(id => id.ToString()).ToList() ); + var costsById = GetCostsById(); var result = new Dictionary>(); foreach (var member in members) @@ -652,6 +669,7 @@ IReadOnlyCollection members RequirementScores = requirementScores.GetValueOrDefault(capId.ToString()) ?? new List(), PendingMembershipApplicationCount = pending.GetValueOrDefault(capId), + Costs = costsById.GetValueOrDefault(capId.ToString()), } ); } @@ -789,6 +807,7 @@ int memberCount AzureResources = await azureResourcesTask, RequirementScores = scores, PendingMembershipApplicationCount = (await pendingAppsTask).Count(), + Costs = GetCostsById().GetValueOrDefault(capabilityId.ToString()), }; } diff --git a/src/SelfService/Domain/Models/CapabilityCosts.cs b/src/SelfService/Domain/Models/CapabilityCosts.cs index 52e54c9d..db77769f 100644 --- a/src/SelfService/Domain/Models/CapabilityCosts.cs +++ b/src/SelfService/Domain/Models/CapabilityCosts.cs @@ -22,6 +22,18 @@ public CapabilityCosts(CapabilityId capabilityId, TimeSeries[] costs) CapabilityId = capabilityId; Costs = costs; } + + /// + /// Sum of the N most recent daily cost datapoints, or null when there is no data. + /// The cached time series already excludes today/yesterday (incomplete days), so this + /// mirrors how the portal computes a rolling window total (sum of daily values). + /// + public float? SumForLastDays(int days) + { + if (Costs is null || Costs.Length == 0) + return null; + return Costs.OrderByDescending(c => c.TimeStamp).Take(days).Sum(c => c.Value); + } } public class MyCapabilityCosts diff --git a/src/SelfService/Domain/Services/TemplateRenderContext.cs b/src/SelfService/Domain/Services/TemplateRenderContext.cs index 559abdf1..5abb55ad 100644 --- a/src/SelfService/Domain/Services/TemplateRenderContext.cs +++ b/src/SelfService/Domain/Services/TemplateRenderContext.cs @@ -19,6 +19,12 @@ public record TemplateRenderContext public List RequirementScores { get; init; } = new(); public int PendingMembershipApplicationCount { get; init; } + /// + /// Cached cost time series for , used by the Capability.Cost.* variables. + /// Null when no cost data is available for the capability. + /// + public CapabilityCosts? Costs { get; init; } + /// /// Capabilities the recipient user belongs to. Used for User-targeted campaigns, /// in particular by the {{#each User.Capabilities}} template block. @@ -34,4 +40,5 @@ public record UserCapabilityRef public List AzureResources { get; init; } = new(); public List RequirementScores { get; init; } = new(); public int PendingMembershipApplicationCount { get; init; } + public CapabilityCosts? Costs { get; init; } } diff --git a/src/SelfService/Domain/Services/TemplateVariable.cs b/src/SelfService/Domain/Services/TemplateVariable.cs index 9a2e4c63..5a203cad 100644 --- a/src/SelfService/Domain/Services/TemplateVariable.cs +++ b/src/SelfService/Domain/Services/TemplateVariable.cs @@ -6,4 +6,10 @@ public class TemplateVariable public string Description { get; set; } = ""; public string Entity { get; set; } = ""; public string Example { get; set; } = ""; + + /// + /// "perCapability" (resolves to a capability — directly in Capability campaigns, only inside + /// {{#each User.Capabilities}} in User campaigns) or "topLevel" (resolves at the recipient/campaign root). + /// + public string Scope { get; set; } = ""; } diff --git a/src/SelfService/Infrastructure/Api/EmailCampaigns/EmailCampaignController.cs b/src/SelfService/Infrastructure/Api/EmailCampaigns/EmailCampaignController.cs index f272618b..8636a3a4 100644 --- a/src/SelfService/Infrastructure/Api/EmailCampaigns/EmailCampaignController.cs +++ b/src/SelfService/Infrastructure/Api/EmailCampaigns/EmailCampaignController.cs @@ -72,6 +72,7 @@ public async Task GetTemplateVariables([FromQuery] string? target Description = v.Description, Entity = v.Entity, Example = v.Example, + Scope = v.Scope, }) ); } diff --git a/src/SelfService/Infrastructure/Api/EmailCampaigns/TemplateVariableApiResource.cs b/src/SelfService/Infrastructure/Api/EmailCampaigns/TemplateVariableApiResource.cs index 5a6dc0c8..8cbfe14e 100644 --- a/src/SelfService/Infrastructure/Api/EmailCampaigns/TemplateVariableApiResource.cs +++ b/src/SelfService/Infrastructure/Api/EmailCampaigns/TemplateVariableApiResource.cs @@ -6,4 +6,5 @@ public class TemplateVariableApiResource public string Description { get; set; } = ""; public string Entity { get; set; } = ""; public string Example { get; set; } = ""; + public string Scope { get; set; } = ""; } diff --git a/src/SelfService/Infrastructure/Persistence/CapabilityFilterService.cs b/src/SelfService/Infrastructure/Persistence/CapabilityFilterService.cs index d975e351..bce9c65d 100644 --- a/src/SelfService/Infrastructure/Persistence/CapabilityFilterService.cs +++ b/src/SelfService/Infrastructure/Persistence/CapabilityFilterService.cs @@ -1,19 +1,27 @@ +using System.Globalization; using System.Text.Json; using Microsoft.EntityFrameworkCore; using SelfService.Domain.Models; using SelfService.Domain.Services; +using SelfService.Infrastructure.Api.Metrics; namespace SelfService.Infrastructure.Persistence; public class CapabilityFilterService : ICapabilityFilterService { + // "Monthly Cost (USD)" — sum of the last 30 daily cost datapoints, matching the + // portal's "Cost (last 30 days)" column / capability cost page. + private const int CostWindowDays = 30; + private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true }; private readonly SelfServiceDbContext _dbContext; + private readonly AllCapabilitiesCostsCache _costsCache; - public CapabilityFilterService(SelfServiceDbContext dbContext) + public CapabilityFilterService(SelfServiceDbContext dbContext, AllCapabilitiesCostsCache costsCache) { _dbContext = dbContext; + _costsCache = costsCache; } public async Task> ResolveCapabilities(string audienceJson) @@ -56,7 +64,59 @@ private async Task> ResolveFiltered(AudienceFilterCondition[] f query = ApplyFilter(query, filter); } - return await query.ToListAsync(); + var capabilities = await query.ToListAsync(); + + // Cost filters can't be translated to SQL — cost data lives in the in-memory + // AllCapabilitiesCostsCache (refreshed twice daily from platform-data-api), not the + // database — so apply them in-memory after the DB-translatable filters have run. + var costFilters = filters.Where(f => f.Field?.ToLowerInvariant() == "cost").ToArray(); + if (costFilters.Length > 0) + capabilities = ApplyCostFilters(capabilities, costFilters); + + return capabilities; + } + + private List ApplyCostFilters(List capabilities, AudienceFilterCondition[] costFilters) + { + var costsById = + _costsCache.GetCachedData()?.Costs.GroupBy(c => c.CapabilityId).ToDictionary(g => g.Key, g => g.First()) + ?? new Dictionary(); + + foreach (var filter in costFilters) + { + if (!float.TryParse(filter.Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var threshold)) + continue; + + var op = filter.Operator; + capabilities = capabilities.Where(c => MatchesCost(costsById, c, op, threshold)).ToList(); + } + + return capabilities; + } + + private static bool MatchesCost( + IReadOnlyDictionary costsById, + Capability capability, + string? op, + float threshold + ) + { + var cost = costsById.GetValueOrDefault(capability.Id.ToString())?.SumForLastDays(CostWindowDays); + + // A capability with no cost data can't satisfy a numeric cost comparison, so exclude it. + // This also means an empty/unpopulated cache narrows a cost-filtered audience to nothing. + if (cost is null) + return false; + + return op switch + { + "eq" => cost == threshold, + "gte" => cost >= threshold, + "lte" => cost <= threshold, + "gt" => cost > threshold, + "lt" => cost < threshold, + _ => true, + }; } private IQueryable ApplyFilter(IQueryable query, AudienceFilterCondition filter) diff --git a/src/SelfService/Infrastructure/Persistence/TemplateRenderingService.cs b/src/SelfService/Infrastructure/Persistence/TemplateRenderingService.cs index 50f14a53..956988fe 100644 --- a/src/SelfService/Infrastructure/Persistence/TemplateRenderingService.cs +++ b/src/SelfService/Infrastructure/Persistence/TemplateRenderingService.cs @@ -26,13 +26,26 @@ private enum AppliesTo Both = Capability | User, } + /// + /// Where a variable resolves. variables describe a capability: + /// they resolve directly in Capability-targeted campaigns, but only inside a + /// {{#each User.Capabilities}} block in User-targeted campaigns. + /// variables resolve at the campaign/recipient root (Member, User, Campaign, Date). + /// + private enum VarScope + { + TopLevel, + PerCapability, + } + private abstract record VariableEntry( string Name, string Description, string Entity, string Example, bool Hidden, - AppliesTo Applies + AppliesTo Applies, + VarScope Scope ); private sealed record StaticVariable( @@ -42,8 +55,9 @@ private sealed record StaticVariable( string Example, Func Resolve, AppliesTo Applies = AppliesTo.Capability, - bool Hidden = false - ) : VariableEntry(Name, Description, Entity, Example, Hidden, Applies); + bool Hidden = false, + VarScope Scope = VarScope.PerCapability + ) : VariableEntry(Name, Description, Entity, Example, Hidden, Applies, Scope); private sealed record PatternVariable( string Name, @@ -54,8 +68,9 @@ private sealed record PatternVariable( Func Resolve, string? FallbackOnMiss = null, AppliesTo Applies = AppliesTo.Capability, - bool Hidden = false - ) : VariableEntry(Name, Description, Entity, Example, Hidden, Applies); + bool Hidden = false, + VarScope Scope = VarScope.PerCapability + ) : VariableEntry(Name, Description, Entity, Example, Hidden, Applies, Scope); private static readonly VariableEntry[] Variables = { @@ -116,19 +131,42 @@ private sealed record PatternVariable( "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]" + 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]" + ctx => ctx.Member?.Email ?? "[Member Email]", + Scope: VarScope.TopLevel ), // --- Shared variables (both target types) --- new StaticVariable( @@ -137,7 +175,8 @@ private sealed record PatternVariable( "Campaign", "Q1 Migration Notice", ctx => ctx.CampaignName, - Applies: AppliesTo.Both + Applies: AppliesTo.Both, + Scope: VarScope.TopLevel ), new StaticVariable( "Date.Today", @@ -145,7 +184,8 @@ private sealed record PatternVariable( "Date", "2024-01-15", _ => DateTime.UtcNow.ToString("yyyy-MM-dd"), - Applies: AppliesTo.Both + Applies: AppliesTo.Both, + Scope: VarScope.TopLevel ), new StaticVariable( "Date.Year", @@ -153,7 +193,8 @@ private sealed record PatternVariable( "Date", "2024", _ => DateTime.UtcNow.Year.ToString(), - Applies: AppliesTo.Both + Applies: AppliesTo.Both, + Scope: VarScope.TopLevel ), new PatternVariable( "Requirement.", @@ -270,7 +311,8 @@ private sealed record PatternVariable( "User", "user@dfds.com", ctx => ctx.Member?.Id.ToString() ?? "[User Id]", - Applies: AppliesTo.User + Applies: AppliesTo.User, + Scope: VarScope.TopLevel ), new StaticVariable( "User.Email", @@ -278,7 +320,8 @@ private sealed record PatternVariable( "User", "jane.doe@dfds.com", ctx => ctx.Member?.Email ?? "[User Email]", - Applies: AppliesTo.User + Applies: AppliesTo.User, + Scope: VarScope.TopLevel ), new StaticVariable( "User.DisplayName", @@ -286,7 +329,8 @@ private sealed record PatternVariable( "User", "Jane Doe", ctx => ctx.Member?.DisplayName ?? "[User Name]", - Applies: AppliesTo.User + Applies: AppliesTo.User, + Scope: VarScope.TopLevel ), new StaticVariable( "User.LastSeen", @@ -294,7 +338,8 @@ private sealed record PatternVariable( "User", "2024-01-15", ctx => ctx.Member?.LastSeen?.ToString("yyyy-MM-dd") ?? "Never", - Applies: AppliesTo.User + Applies: AppliesTo.User, + Scope: VarScope.TopLevel ), new StaticVariable( "User.CapabilityCount", @@ -302,7 +347,8 @@ private sealed record PatternVariable( "User", "3", ctx => ctx.UserCapabilities.Count.ToString(), - Applies: AppliesTo.User + Applies: AppliesTo.User, + Scope: VarScope.TopLevel ), new StaticVariable( "User.CapabilityNames", @@ -310,7 +356,8 @@ private sealed record PatternVariable( "User", "Cap A, Cap B, Cap C", ctx => string.Join(", ", ctx.UserCapabilities.Select(uc => uc.Capability.Name)), - Applies: AppliesTo.User + 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. @@ -320,7 +367,8 @@ private sealed record PatternVariable( "User", "{{#each User.Capabilities}}...{{/each}}", _ => "", - Applies: AppliesTo.User + Applies: AppliesTo.User, + Scope: VarScope.TopLevel ), }; @@ -339,17 +387,26 @@ public string RenderTemplate(string template, TemplateRenderContext context) public IReadOnlyList GetVariableDefinitions(EmailCampaignTargetType? targetType = null) { var resolved = targetType ?? EmailCampaignTargetType.Capability; - var wanted = resolved == EmailCampaignTargetType.User ? AppliesTo.User : AppliesTo.Capability; + var isUser = resolved == EmailCampaignTargetType.User; return Variables .Where(v => !v.Hidden) - .Where(v => (v.Applies & wanted) != 0) + // Capability campaigns: every variable that applies to a capability resolves directly. + // User campaigns: top-level user/shared variables, plus all per-capability variables + // (Capability.*, Requirement.*, Aws.*, Azure.*, MembershipApplications.*, Cost.*) — the + // latter only resolve inside a {{#each User.Capabilities}} block, flagged via Scope. + .Where(v => + isUser + ? v.Scope == VarScope.PerCapability || (v.Applies & AppliesTo.User) != 0 + : (v.Applies & AppliesTo.Capability) != 0 + ) .Select(v => new TemplateVariable { Name = v.Name, Description = v.Description, Entity = v.Entity, Example = v.Example, + Scope = v.Scope == VarScope.PerCapability ? "perCapability" : "topLevel", }) .ToList(); } @@ -379,6 +436,7 @@ private string ExpandUserCapabilitiesBlocks(string template, TemplateRenderConte AzureResources = entry.AzureResources, RequirementScores = entry.RequirementScores, PendingMembershipApplicationCount = entry.PendingMembershipApplicationCount, + Costs = entry.Costs, }; sb.Append(TokenRegex.Replace(body, m => Resolve(m.Groups[1].Value, subContext) ?? m.Value)); } @@ -403,6 +461,9 @@ private string ExpandUserCapabilitiesBlocks(string template, TemplateRenderConte return null; } + // 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"); + private static string? ResolveRequirementScore(TemplateRenderContext ctx, string id) { // Tags are not stored in the requirements DB — they are derived from the capability's metadata, diff --git a/src/SelfService/Infrastructure/Persistence/UserFilterService.cs b/src/SelfService/Infrastructure/Persistence/UserFilterService.cs index cd44ee70..357f4a56 100644 --- a/src/SelfService/Infrastructure/Persistence/UserFilterService.cs +++ b/src/SelfService/Infrastructure/Persistence/UserFilterService.cs @@ -9,11 +9,25 @@ public class UserFilterService : IUserFilterService { private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true }; + // Fields resolved against the Member entity itself. Every other field is treated as + // capability-scoped and resolved via CapabilityFilterService (see ResolveFiltered). + // capabilitycostcentre is capability-scoped semantically but kept here because it's a + // multi-value "is one of" that CapabilityFilterService doesn't model. + private static readonly HashSet UserScopedFields = new(StringComparer.OrdinalIgnoreCase) + { + "email", + "displayname", + "lastseen", + "capabilitycostcentre", + }; + private readonly SelfServiceDbContext _dbContext; + private readonly ICapabilityFilterService _capabilityFilterService; - public UserFilterService(SelfServiceDbContext dbContext) + public UserFilterService(SelfServiceDbContext dbContext, ICapabilityFilterService capabilityFilterService) { _dbContext = dbContext; + _capabilityFilterService = capabilityFilterService; } public async Task ResolveUsers(string audienceJson) @@ -59,11 +73,43 @@ private async Task> ResolveFiltered(UserAudienceFilterCondition[] f { IQueryable query = _dbContext.Members; - foreach (var filter in filters) + // User-scoped filters resolve directly against the Member entity. + var userScoped = filters.Where(f => UserScopedFields.Contains(f.Field ?? "")).ToArray(); + foreach (var filter in userScoped) { query = ApplyFilter(query, filter); } + // Capability-scoped filters (status, cost, requirement score, …) are resolved by the + // CapabilityFilterService into the set of matching capabilities; a user is then included + // if they hold a membership to at least one of them. This is recipient selection only — + // the rendered {{#each User.Capabilities}} loop still iterates all of a user's capabilities. + // Multiple capability-scoped filters AND on the same capability (CapabilityFilterService + // chains them on one capability query), matching the Capability target exactly. An empty + // cost cache resolves cost filters to zero capabilities, hence zero users. + var capabilityScoped = filters.Where(f => !UserScopedFields.Contains(f.Field ?? "")).ToArray(); + if (capabilityScoped.Length > 0) + { + var capJson = JsonSerializer.Serialize( + new + { + mode = "filter", + filters = capabilityScoped.Select(f => new + { + field = f.Field, + @operator = f.Operator, + value = f.Value, + key = f.Key, + }), + } + ); + + var capIds = (await _capabilityFilterService.ResolveCapabilities(capJson)).Select(c => c.Id).ToList(); + query = query.Where(m => + _dbContext.Memberships.Any(ms => ms.UserId == m.Id && capIds.Contains(ms.CapabilityId)) + ); + } + return await query.ToListAsync(); } @@ -163,4 +209,7 @@ internal class UserAudienceFilterCondition public string? Field { get; set; } public string? Operator { get; set; } public string? Value { get; set; } + + // Only used by capability-scoped metadataKeyValue filters; passed through to CapabilityFilterService. + public string? Key { get; set; } }