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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/sections/Mailer.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// "Humans - Marketing no Ticket" — humans who have explicitly opted in to the
/// Marketing communication category (<see cref="UserInfo.MarketingOptedOut"/> == 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.
/// </summary>
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<IReadOnlySet<Guid>> 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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ internal static IServiceCollection AddMailerSection(
services.AddScoped<IMailerAudience, HasShiftAudience>();
services.AddScoped<IMailerAudience, HasTicketAudience>();
services.AddScoped<IMailerAudience, MarketingAudience>();
services.AddScoped<IMailerAudience, MarketingNoTicketAudience>();
services.AddTransient<MailerAudienceSyncJob>();

return services;
Expand Down
Original file line number Diff line number Diff line change
@@ -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<UserInfo> users,
HashSet<Guid> ticketHolders)
{
var userService = Substitute.For<IUserService>();
userService.GetAllUserInfosAsync(Arg.Any<CancellationToken>()).Returns(users);

var ticketService = Substitute.For<ITicketQueryService>();
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);
}
Loading