diff --git a/docs/sections/Mailer.md b/docs/sections/Mailer.md index 4bdc9b6c4..d095acabc 100644 --- a/docs/sections/Mailer.md +++ b/docs/sections/Mailer.md @@ -62,9 +62,9 @@ All routes are `AdminOnly`. - **Profiles**: reads `IUserEmailService.FindVerifiedEmailWithUserAsync`, `FindAnyUserIdByEmailAsync`, `DeleteEmailAsync`, `GetPrimaryEmailsByUserIdsAsync`; reads/writes `ICommunicationPreferenceService.GetAsync` / `UpdatePreferenceAsync` / `GetCountByCategoryAndStateAsync`. - **Users**: writes via `IAccountProvisioningService.FindOrCreateUserByEmailAsync`; reads `IUserService.GetByIdAsync` (tombstone follow), `IUserService.GetCountByContactSourceAsync`. -- **Tickets**: `ITicketQueryService.GetUserIdsWithTicketsAsync` — audience-side ticket-holder enumeration for `TicketNoShiftsAudience` and `HasTicketAudience`. Scoped to the active vendor event by the cached decorator (see `TicketSyncState.VendorEventId`). +- **Tickets**: `ITicketQueryService.GetUserIdsWithTicketsAsync` — audience-side ticket-holder enumeration for `TicketNoShiftsAudience`, `HasTicketAudience`, and `MarketingNoTicketAudience`. Scoped to the active vendor event by the cached decorator (see `TicketSyncState.VendorEventId`). - **Shifts**: `IShiftView.GetUsersAsync` + `IUserService.GetAllUserInfosAsync` — cached per-user shift signups, used by `TicketNoShiftsAudience` and `HasShiftAudience` (encode Pending/Confirmed-on-active-event via `ShiftUserView.HasShift`). -- **Users**: `IUserService.GetAllUserInfosAsync` — audience-side enumeration of explicit Marketing opt-ins for `MarketingAudience` (`UserInfo.MarketingOptedOut == false`). +- **Users**: `IUserService.GetAllUserInfosAsync` — audience-side enumeration of explicit Marketing opt-ins for `MarketingAudience` and `MarketingNoTicketAudience` (`UserInfo.MarketingOptedOut == false`). - **AuditLog**: writes via `IAuditLogService.LogAsync` (job overload). ## Architecture diff --git a/src/Humans.Application/Services/Mailer/Audiences/MarketingNoTicketAudience.cs b/src/Humans.Application/Services/Mailer/Audiences/MarketingNoTicketAudience.cs new file mode 100644 index 000000000..0f316724b --- /dev/null +++ b/src/Humans.Application/Services/Mailer/Audiences/MarketingNoTicketAudience.cs @@ -0,0 +1,30 @@ +using Humans.Application.Interfaces.Mailer; +using Humans.Application.Interfaces.Tickets; +using Humans.Application.Interfaces.Users; + +namespace Humans.Application.Services.Mailer.Audiences; + +/// +/// "Humans - Marketing no Ticket" — humans who have explicitly opted in to the +/// Marketing communication category ( == false) +/// AND do not currently hold a ticket in the active vendor event. +/// Users with no Marketing preference row (default-off) or who hold a ticket are excluded. +/// +public sealed class MarketingNoTicketAudience( + IUserService users, + ITicketQueryService tickets) : IMailerAudience +{ + public string Key => "marketing-no-ticket"; + public string DisplayName => "Marketing opt-ins without a ticket"; + public string MailerLiteGroupName => "Humans - Marketing no Ticket"; + + public async Task> ComputeMemberUserIdsAsync(CancellationToken ct) + { + var ticketHolders = await tickets.GetUserIdsWithTicketsAsync(); + var allUsers = await users.GetAllUserInfosAsync(ct); + return allUsers + .Where(u => u.MarketingOptedOut == false && !ticketHolders.Contains(u.Id)) + .Select(u => u.Id) + .ToHashSet(); + } +} diff --git a/src/Humans.Web/Extensions/Sections/MailerSectionExtensions.cs b/src/Humans.Web/Extensions/Sections/MailerSectionExtensions.cs index 061fb022b..176a67e50 100644 --- a/src/Humans.Web/Extensions/Sections/MailerSectionExtensions.cs +++ b/src/Humans.Web/Extensions/Sections/MailerSectionExtensions.cs @@ -52,6 +52,7 @@ internal static IServiceCollection AddMailerSection( services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddTransient(); return services; diff --git a/tests/Humans.Application.Tests/Services/Mailer/Audiences/MarketingNoTicketAudienceTests.cs b/tests/Humans.Application.Tests/Services/Mailer/Audiences/MarketingNoTicketAudienceTests.cs new file mode 100644 index 000000000..4b5f34ca8 --- /dev/null +++ b/tests/Humans.Application.Tests/Services/Mailer/Audiences/MarketingNoTicketAudienceTests.cs @@ -0,0 +1,86 @@ +using AwesomeAssertions; +using Humans.Application.Interfaces.Tickets; +using Humans.Application.Interfaces.Users; +using Humans.Application.Services.Mailer.Audiences; +using Humans.Application.Tests.Infrastructure; +using Humans.Domain.Entities; +using Humans.Domain.Enums; +using NodaTime; +using NSubstitute; + +namespace Humans.Application.Tests.Services.Mailer.Audiences; + +public class MarketingNoTicketAudienceTests +{ + [HumansFact] + public async Task ComputeMemberUserIdsAsync_ExcludesTicketHolders_AndNonOptIns() + { + var optInNoTicket = Guid.NewGuid(); // OptedOut=false, no ticket → IN + var optInWithTicket = Guid.NewGuid(); // OptedOut=false, has ticket → OUT + var optedOut = Guid.NewGuid(); // OptedOut=true → OUT + var noPrefRow = Guid.NewGuid(); // no row → OUT + + var audience = NewAudience( + users: new[] + { + UserWithMarketingPref(optInNoTicket, optedOut: false), + UserWithMarketingPref(optInWithTicket, optedOut: false), + UserWithMarketingPref(optedOut, optedOut: true), + UserWithoutMarketingPref(noPrefRow), + }, + ticketHolders: [optInWithTicket]); + + var members = await audience.ComputeMemberUserIdsAsync(CancellationToken.None); + + members.Should().BeEquivalentTo([optInNoTicket]); + } + + [HumansFact] + public async Task ComputeMemberUserIdsAsync_NoUsers_ReturnsEmpty() + { + var audience = NewAudience([], []); + + var members = await audience.ComputeMemberUserIdsAsync(CancellationToken.None); + + members.Should().BeEmpty(); + } + + [HumansFact] + public void Metadata_UsesHumansPrefix() + { + var audience = NewAudience([], []); + audience.Key.Should().Be("marketing-no-ticket"); + audience.MailerLiteGroupName.Should().Be("Humans - Marketing no Ticket"); + audience.MailerLiteGroupName.Should().StartWith("Humans - "); + } + + private static MarketingNoTicketAudience NewAudience( + IReadOnlyList users, + HashSet ticketHolders) + { + var userService = Substitute.For(); + userService.GetAllUserInfosAsync(Arg.Any()).Returns(users); + + var ticketService = Substitute.For(); + ticketService.GetUserIdsWithTicketsAsync().Returns(ticketHolders); + + return new MarketingNoTicketAudience(userService, ticketService); + } + + private static UserInfo UserWithMarketingPref(Guid userId, bool optedOut) => + UserInfo.Create( + new User { Id = userId, DisplayName = "u", PreferredLanguage = "en" }, + [], [], [], profile: null, [], [], [], + [new CommunicationPreference + { + Id = Guid.NewGuid(), + UserId = userId, + Category = MessageCategory.Marketing, + OptedOut = optedOut, + UpdatedAt = Instant.FromUnixTimeSeconds(0), + UpdateSource = "Test", + }]); + + private static UserInfo UserWithoutMarketingPref(Guid userId) => + UserInfoStubHelpers.MakeUserInfo(userId); +}