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 .config/dotnet-tools.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@
"isRoot": true,
"tools": {
"dotnet-stryker": {
"version": "4.14.1",
"version": "4.14.2",
"commands": [
"dotnet-stryker"
]
}
}
}
}
8 changes: 4 additions & 4 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@
</PropertyGroup>
<ItemGroup>
<!-- AI/LLM -->
<PackageVersion Include="Anthropic" Version="12.11.0" />
<PackageVersion Include="Anthropic" Version="12.22.0" />
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARN — About page not updated after NuGet bumps

memory/process/about-page-license-attribution.md — After any NuGet update, src/Humans.Web/Views/About/Index.cshtml must be updated to reflect the new versions and licenses (AGPL-3.0 attribution requirement).

This PR bumps three production NuGet packages without updating the About page:

  • Anthropic 12.11.0 → 12.22.0
  • Ical.Net 5.2.1 → 5.2.2
  • Google.Apis.CloudIdentity.v1 1.74.0.4030 → 1.74.0.4150

Update Views/About/Index.cshtml to reflect the new versions before merging.

<!-- Core Framework -->
<PackageVersion Include="HtmlSanitizer" Version="9.0.892" />
<PackageVersion Include="Ical.Net" Version="5.2.1" />
<PackageVersion Include="Ical.Net" Version="5.2.2" />
<PackageVersion Include="Markdig" Version="1.2.0" />
<PackageVersion Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.8" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.Google" Version="10.0.8" />
Expand All @@ -35,7 +35,7 @@
<PackageVersion Include="Octokit" Version="14.0.0" />
<!-- Google Workspace APIs -->
<PackageVersion Include="Google.Apis.Admin.Directory.directory_v1" Version="1.74.0.4128" />
<PackageVersion Include="Google.Apis.CloudIdentity.v1" Version="1.74.0.4030" />
<PackageVersion Include="Google.Apis.CloudIdentity.v1" Version="1.74.0.4150" />
<PackageVersion Include="Google.Apis.Drive.v3" Version="1.74.0.4135" />
<PackageVersion Include="Google.Apis.DriveActivity.v2" Version="1.74.0.3880" />
<PackageVersion Include="Google.Apis.Groupssettings.v1" Version="1.74.0.2721" />
Expand Down Expand Up @@ -76,7 +76,7 @@
<PackageVersion Include="Meziantou.Analyzer" Version="3.0.85" />
<PackageVersion Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.14.15" />
<!-- Testing -->
<PackageVersion Include="coverlet.collector" Version="10.0.0" />
<PackageVersion Include="coverlet.collector" Version="10.0.1" />
<PackageVersion Include="AwesomeAssertions" Version="9.4.0" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.5.1" />
<PackageVersion Include="NodaTime.Testing" Version="3.3.2" />
Expand Down
12 changes: 7 additions & 5 deletions docs/sections/Mailer.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@ Orchestrates Humans ↔ MailerLite synchronisation. Inbound import + outbound au
## Concepts

- **MailerLite subscriber** — a row in ML's `subscribers` collection. Has `status ∈ {active, unsubscribed, unconfirmed, bounced, junk}` and `subscribed_at` / `unsubscribed_at` / `opted_in_at` timestamps. Tracks the `groups` the subscriber belongs to.
- **Import plan** — the classified result of pulling every ML subscriber and matching against Humans's user/email/preference state. Built fresh on every preview/commit; never persisted between runs.
- **Apply** — executes an import plan: creates contacts, attaches verified users, deletes unverified UserEmail rows that block contact creation, updates Marketing preferences per the conflict rule, writes one summary audit.
- **Import plan** — the classified result of pulling the MailerLite `Website` group's subscribers and matching against Humans's user/email/preference state, plus a Humans-side pass that flags Marketing opt-ins the prior whole-account import wrongly set (see **Reset**). Built fresh on every preview/commit; never persisted between runs.
- **Apply** — executes an import plan: creates contacts, attaches verified users, deletes unverified UserEmail rows that block contact creation, updates Marketing preferences per the conflict rule, resets (deletes → null) Marketing flags caught by the reset pass, writes one summary audit.
- **Reset (marketing flag)** — a Humans-side cleanup decision: a Marketing opt-in written by the erroneous whole-account import (`UpdateSource = "MailerLiteSync"`, opted-in, no prior consent) on someone **not** in the `Website` group is deleted, reverting the category to "no preference" (null) — never to opt-out. GDPR remediation; cutoff for "prior consent" is hardcoded in `MailerImportService.BadImportCutoff`.
- **Audience** — a code-defined `IMailerAudience` implementation whose `MailerLiteGroupName` starts with `"Humans - "`. Membership is computed from Humans state and synced into the ML group by `MailerAudienceSyncService` (daily Hangfire job + on-demand admin button).

## Data Model
Expand Down Expand Up @@ -36,7 +37,8 @@ All routes are `AdminOnly`.
- Every outbound write targets an ML group whose `Name` starts with `"Humans - "`. `MailerLiteClient` runtime-rejects writes against non-`"Humans - "` groups with `InvalidOperationException`. Pinned by `MailerLiteClientWriteGuardTests`.
- All `IMailerAudience` implementations target group names starting with `"Humans - "`. Pinned by `MailerArchitectureTests.AllAudiences_UseHumansPrefix`. Audience keys and group names are unique across registrations (pinned by `AllAudiences_HaveUniqueGroupNamesAndKeys`).
- `MailerImportService` and `MailerAudienceSyncService` live in `Humans.Application` and never reference `Microsoft.EntityFrameworkCore`. Pinned by architecture tests.
- Every write to `CommunicationPreference[Marketing]` goes through `CommunicationPreferenceService.UpdatePreferenceAsync` and produces a `CommunicationPreferenceChanged` audit entry on real state changes (not idempotent confirms).
- The import (`MailerImportService`) ingests **only** the MailerLite group named `Website` (resolved by name; throws if the group is absent) — never the whole account. The reset pass excludes anyone in that group of any status, since the import already owns their pref (active → opt-in, unsubscribed/bounced → opt-out). Pinned by `MailerImportServiceWebsiteScopeTests`.
- Every write to `CommunicationPreference[Marketing]` goes through `CommunicationPreferenceService` — `UpdatePreferenceAsync` for opt state, `ResetPreferenceAsync` to delete the row (→ null) — and produces a `CommunicationPreferenceChanged` audit entry on real state changes (not idempotent confirms).
- `ApplyAsync` is idempotent: a second run against unchanged ML+Humans state writes zero per-row entries and exactly one `MailerLiteReconciliationCompleted` summary entry.
- `SyncAsync` is idempotent: a second run against unchanged audience+ML state writes zero ML mutations and exactly one `MailerLiteAudienceSyncCompleted` summary entry whose counts are all zero.
- Bounced/junk subscribers always set `OptedOut = true` regardless of any Humans-side timestamp. Delivery facts override preferences.
Expand All @@ -62,9 +64,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
2 changes: 2 additions & 0 deletions src/Humans.Application/Interfaces/Mailer/Dtos/ImportPlan.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ public sealed record ImportPlan(
VerifiedFlipToOptIn: Decisions.Count(d => d.Outcome == SubscriberOutcome.VerifiedFlipToOptIn),
VerifiedFlipToOptOut: Decisions.Count(d => d.Outcome == SubscriberOutcome.VerifiedFlipToOptOut),
VerifiedKeepHumansPref: Decisions.Count(d => d.Outcome == SubscriberOutcome.VerifiedKeepHumansPref),
ResetMarketingFlag: Decisions.Count(d => d.Outcome == SubscriberOutcome.ResetMarketingFlag),
AmbiguousMultipleVerified: Decisions.Count(d => d.Outcome == SubscriberOutcome.AmbiguousMultipleVerified),
UnconfirmedSkipped: Decisions.Count(d => d.Outcome == SubscriberOutcome.UnconfirmedSkipped));
}
Expand All @@ -22,5 +23,6 @@ public sealed record ImportPlanCounts(
int VerifiedFlipToOptIn,
int VerifiedFlipToOptOut,
int VerifiedKeepHumansPref,
int ResetMarketingFlag,
int AmbiguousMultipleVerified,
int UnconfirmedSkipped);
2 changes: 2 additions & 0 deletions src/Humans.Application/Interfaces/Mailer/Dtos/ImportResult.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ public sealed record ImportResult(
int PrefsFlippedToOptIn,
int PrefsFlippedToOptOut,
int PrefsKeptByConflict,
int MarketingFlagsReset,
int UnverifiedEmailsReplaced,
int AmbiguousSkipped,
int UnconfirmedSkipped,
Expand All @@ -21,6 +22,7 @@ public string FormatSummary() =>
$"{HumansCreated} humans created, " +
$"{PrefsFlippedToOptIn} flipped to opt-in, {PrefsFlippedToOptOut} flipped to opt-out, " +
$"{PrefsKeptByConflict} kept by conflict-rule, " +
$"{MarketingFlagsReset} marketing flags reset, " +
$"{UnverifiedEmailsReplaced} unverified emails replaced, " +
$"{AmbiguousSkipped} ambiguous skipped, " +
$"{UnconfirmedSkipped} unconfirmed skipped, " +
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,16 @@ public enum SubscriberOutcome

/// <summary>Verified human match, user touched Humans' pref more recently than ML — keep Humans' state.</summary>
VerifiedKeepHumansPref,

/// <summary>
/// Existing human, opted-in to Marketing by the erroneous whole-account
/// MailerLite import (<c>UpdateSource = "MailerLiteSync"</c>), who is not an
/// active member of the Website group and has no genuine prior consent —
/// delete the Marketing pref row to revert to "no preference recorded"
/// (null), not opt-out. GDPR remediation; discovered from the Humans side,
/// not from an ML subscriber.
/// </summary>
ResetMarketingFlag,
}

public sealed record SubscriberDecision(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,17 @@ Task UpdatePreferenceAsync(
Guid userId, MessageCategory category, bool optedOut, bool inboxEnabled, string source,
CancellationToken cancellationToken = default);

/// <summary>
/// Deletes the preference row for a user+category, reverting the category to
/// the "no preference recorded" (null) state — distinct from an explicit
/// opt-out. No-op if no row exists. Logs an audit entry when a row is
/// removed. Used by the Mailer import GDPR remediation to undo Marketing
/// opt-ins the erroneous whole-account import set.
/// </summary>
Task ResetPreferenceAsync(
Guid userId, MessageCategory category, string source,
CancellationToken cancellationToken = default);

/// <summary>
/// Generates a time-limited unsubscribe token encoding userId + category.
/// Token expires after ~90 days.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,15 @@ Task<List<CommunicationPreference>> AddDefaultsOrReloadAsync(
/// </summary>
Task UpdateAsync(CommunicationPreference preference, CancellationToken ct = default);

/// <summary>
/// Deletes the single <c>communication_preferences</c> row for a user+category,
/// returning the category to its "no row recorded" (null) state — distinct
/// from an explicit opt-out. Returns true if a row was deleted, false if none
/// existed. Used by the Mailer import GDPR remediation.
/// </summary>
Task<bool> DeleteByUserAndCategoryAsync(
Guid userId, MessageCategory category, CancellationToken ct = default);

/// <summary>
/// Bulk-moves <c>communication_preferences</c> rows from
/// <paramref name="sourceUserId"/> to <paramref name="targetUserId"/> for the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ public static IReadOnlyList<CalendarOccurrence> Expand(
DtStart = new CalDateTime(dtStartLocal, e.RecurrenceTimezone, hasTime: true),
Duration = Ical.Net.DataTypes.Duration.FromTimeSpanExact(TimeSpan.FromTicks(dur.BclCompatibleTicks)),
};
icalEv.RecurrenceRules.Add(new RecurrencePattern(e.RecurrenceRule!));
icalEv.RecurrenceRule = new RecurrencePattern(e.RecurrenceRule!);

var fromLocal = from.InZone(zone).LocalDateTime.ToDateTimeUnspecified();
var toLocal = to.InZone(zone).LocalDateTime.ToDateTimeUnspecified();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@ private static string CalendarValidationMemberName(ValidationException ex) =>
DtStart = new CalDateTime(dtStartLocal, tz, hasTime: true),
Duration = Ical.Net.DataTypes.Duration.FromTimeSpanExact(TimeSpan.FromTicks(duration.BclCompatibleTicks)),
};
icalEv.RecurrenceRules.Add(new RecurrencePattern(rrule));
icalEv.RecurrenceRule = new RecurrencePattern(rrule);

var startCalDt = new CalDateTime(dtStartLocal, tz, hasTime: true);
var last = icalEv.GetOccurrences(startCalDt, new EvaluationOptions())
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();
}
}
Loading
Loading