Skip to content

Service ownership phase 3: Ticket/Budget/Campaign triangle (#472)#224

Merged
peterdrier merged 2 commits intomainfrom
sprint/2026-04-13/batch-4
Apr 13, 2026
Merged

Service ownership phase 3: Ticket/Budget/Campaign triangle (#472)#224
peterdrier merged 2 commits intomainfrom
sprint/2026-04-13/batch-4

Conversation

@peterdrier
Copy link
Copy Markdown
Owner

Summary

Phase 3 of the service data ownership plan. Closes cross-section write violations between Tickets, Budget, and Campaigns.

  • 3a TicketSyncService no longer writes CampaignGrants directly. New ICampaignService.MarkGrantsRedeemedAsync(IReadOnlyCollection<DiscountCodeRedemption>, ct) accepts aggregated discount-code redemptions from ticket sync and applies them inside CampaignService.
  • 3b TicketingBudgetService no longer writes BudgetLineItems or TicketingProjections. The weekly line-item upsert, projection-from-actuals update, and projected-line materialization moved into BudgetService (SyncTicketingActualsAsync, RefreshTicketingProjectionsAsync, GetTicketingProjectionEntriesAsync, GetActualTicketsSold). TicketingBudgetService is now a thin orchestrator: reads ticket orders (a table it co-owns), aggregates per ISO week into TicketingWeeklyActuals DTOs, and delegates budget mutations to BudgetService.
  • 3c CampaignService no longer creates EmailOutboxMessages rows directly. New IEmailService.SendCampaignCodeAsync(CampaignCodeEmailRequest, ct) on OutboxEmailService handles markdown rendering, unsubscribe headers, and outbox insert. CampaignService persists grants first, then delegates per-grant email enqueue. SmtpEmailService and StubEmailService also implement the new method for dev/inline paths.
  • 3d ProfileService no longer queries ticket tables. New ITicketQueryService.HasCurrentEventTicketAsync(userId, ct) replaces the hold-date computation's direct ticket reads; GetUserTicketExportDataAsync(userId, ct) replaces the GDPR export's ticket reads.

Ticket caches (UserTicketCount, UserIdsWithTickets, TicketEventSummary, TicketDashboardStats) are only accessed from ticket services (TicketQueryService, TicketSyncService, TicketTailorService) and the central MemoryCacheExtensions helpers.

Acceptance Criteria (from nobodies-collective#472)

  • TicketSyncService does not write CampaignGrants directly — grep and reforge confirm.
  • TicketingBudgetService does not write BudgetLineItems or TicketingProjections directly — reforge ownership-violations returns 0 on these tables.
  • CampaignService does not write EmailOutboxMessages directly — only the owning OutboxEmailService touches the table.
  • ProfileService does not query ticket tables — ProfileService.cs no longer references _dbContext.TicketOrders / TicketAttendees / TicketSyncStates.
  • All ticket caches managed by ticket services only — confirmed by grep across src/.
  • Existing tests pass (947 Application + 108 Domain).

Test Plan

  • dotnet build Humans.slnx -v q — 0 errors, 0 warnings.
  • dotnet test Humans.slnx — 947 Application tests pass, 108 Domain tests pass. Integration tests require Docker (Testcontainers/PostgreSQL) which is unavailable in this worker environment; pre-existing, unrelated to these changes.
  • dotnet format Humans.slnx --verify-no-changes — clean.
  • reforge ownership-violations rerun against the four target table groups — 0 in-scope violations remain.
  • QA deploy and smoke test: send a campaign wave (verify grant + outbox row created), run a ticket sync with a discount code (verify RedeemedAt set), refresh ticketing projections, open the profile data export endpoint, verify the guest dashboard shows the hold date.

Files Touched

  • src/Humans.Application/DTOs/TicketingBudgetDtos.cs — new TicketingWeeklyActuals record
  • src/Humans.Application/Interfaces/IBudgetService.cs — +4 ticketing sync methods
  • src/Humans.Application/Interfaces/ICampaignService.cs — +MarkGrantsRedeemedAsync, DiscountCodeRedemption
  • src/Humans.Application/Interfaces/IEmailService.cs — +SendCampaignCodeAsync, CampaignCodeEmailRequest
  • src/Humans.Application/Interfaces/ITicketQueryService.cs — +HasCurrentEventTicketAsync, GetUserTicketExportDataAsync, export DTOs
  • src/Humans.Infrastructure/Services/BudgetService.cs — owns all ticketing-sync mutation logic
  • src/Humans.Infrastructure/Services/CampaignService.cs — delegates email enqueue + grant-redeem
  • src/Humans.Infrastructure/Services/OutboxEmailService.cs — +campaign-code enqueue
  • src/Humans.Infrastructure/Services/SmtpEmailService.cs — +campaign-code inline send
  • src/Humans.Infrastructure/Services/StubEmailService.cs — +campaign-code stub
  • src/Humans.Infrastructure/Services/ProfileService.cs — hold-date + export use ITicketQueryService
  • src/Humans.Infrastructure/Services/TicketQueryService.cs — +hold-status + export reads
  • src/Humans.Infrastructure/Services/TicketSyncService.cs — delegates code matching to campaign service
  • src/Humans.Infrastructure/Services/TicketingBudgetService.cs — thin orchestrator
  • tests/Humans.Application.Tests/Services/CampaignServiceTests.cs — assertions via IEmailService mock
  • tests/Humans.Application.Tests/Services/ProfileServiceTests.cs — new ITicketQueryService substitute
  • tests/Humans.Application.Tests/Services/TicketSyncServiceTests.cs — verifies campaign-service delegation

Executed by /execute-sprint as batch 4 of sprint 2026-04-13.

🤖 Generated with Claude Code

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9f7cb911b4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +402 to +406
await _dbContext.SaveChangesAsync(ct);

// Enqueue campaign emails via the Email service (owner of email_outbox_messages).
foreach (var user in eligibleUsers)
{
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Keep grant and email enqueue atomic in SendWaveAsync

Persisting all CampaignGrant rows (already marked LatestEmailStatus = Queued) before the email loop makes partial failures unrecoverable: if SendCampaignCodeAsync throws mid-loop, some recipients end up with a saved grant but no outbox message. Those users are then excluded by the next SendWaveAsync run via alreadyGrantedSet, and their grants are not in Failed state, so RetryAllFailedAsync also skips them.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Confirmed — fixed in 845e904.

Changed SendWaveAsync to per-iteration save-then-enqueue: each CampaignGrant is persisted individually, then the email is enqueued, and if SendCampaignCodeAsync throws, a local catch flips that single grant to Failed in a follow-up save before the loop continues. A single bad enqueue now only affects one grant (which flows back through RetryAllFailedAsync normally), instead of orphaning the tail of the batch with no outbox row and a stuck Queued status.

Pre-nobodies-collective#472 this was implicitly safe because the grant write and the outbox write happened in the same DbContext.SaveChangesAsync — splitting ownership surfaced the bug. The per-iteration pattern is the minimum change that restores the atomicity guarantee without re-introducing a cross-service transaction.

Comment on lines 538 to 542
grant.LatestEmailStatus = EmailOutboxStatus.Queued;
grant.LatestEmailAt = now;

_metrics.RecordEmailQueued("campaign_code");
}

await _dbContext.SaveChangesAsync(ct);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Do not mark failed grants queued before enqueue succeeds

RetryAllFailedAsync flips all failed grants to Queued and commits that state before sending any retry emails. If one SendCampaignCodeAsync call fails, remaining grants have already left the Failed set without a corresponding outbox row, so subsequent retry runs (which filter on LatestEmailStatus == Failed) will never pick them up.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Confirmed — fixed in 845e904, same approach as SendWaveAsync.

RetryAllFailedAsync now flips each grant from Failed to Queued and enqueues it individually. If SendCampaignCodeAsync throws on a given grant, a local catch flips it straight back to Failed in a follow-up save, so it remains visible to the next retry pass instead of silently leaving the Failed set without a corresponding outbox row. A single bad retry can no longer orphan the tail of the batch.

…collective#472)

Enforce table ownership across the Ticket, Budget, and Campaign sections:
cross-service writes that previously bypassed owning services now go through
service interfaces.

3a. TicketSyncService no longer writes CampaignGrants directly. New
    ICampaignService.MarkGrantsRedeemedAsync accepts a list of
    DiscountCodeRedemption records; TicketSyncService aggregates ticket
    orders with discount codes and delegates the RedeemedAt updates to
    CampaignService, which owns the grant table.

3b. TicketingBudgetService no longer writes BudgetLineItems or
    TicketingProjections directly. The weekly line-item upsert,
    projection-from-actuals update, and projected-line materialization
    logic moved into BudgetService as SyncTicketingActualsAsync,
    RefreshTicketingProjectionsAsync, GetTicketingProjectionEntriesAsync,
    and GetActualTicketsSold. TicketingBudgetService is now a thin
    orchestrator: it reads TicketOrders (a ticket-section table it
    co-owns), aggregates them per ISO week into TicketingWeeklyActuals,
    and delegates the budget mutations to BudgetService.

3c. CampaignService no longer creates EmailOutboxMessages rows directly.
    New IEmailService.SendCampaignCodeAsync takes a CampaignCodeEmailRequest
    describing the campaign's raw template (subject + markdown body +
    {{Code}}/{{Name}} placeholders). OutboxEmailService renders and
    wraps the email; SmtpEmailService sends inline. CampaignService now
    persists grants first, then delegates email enqueuing per grant.

3d. ProfileService no longer queries ticket tables. New methods on
    ITicketQueryService (HasCurrentEventTicketAsync,
    GetUserTicketExportDataAsync) replace ProfileService's direct reads
    of TicketOrders / TicketAttendees / TicketSyncStates used by the
    event-hold date computation and GDPR data export.

Ticket caches (UserTicketCount, UserIdsWithTickets, TicketEventSummary,
TicketDashboardStats) are verified to be accessed only by ticket services
and the central MemoryCacheExtensions helpers.

Tests: 947 Application + 108 Domain tests pass. Existing campaign/ticket-
sync tests were updated to verify delegation via NSubstitute rather than
asserting on the outbox/grant tables directly. Integration tests require
Docker/Testcontainers which is unavailable in this environment.

Closes nobodies-collective#472

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@peterdrier peterdrier force-pushed the sprint/2026-04-13/batch-4 branch from 9f7cb91 to cfda8f9 Compare April 13, 2026 15:27
… review)

Codex flagged two P1 atomicity bugs introduced by the service-boundary
split in nobodies-collective#472: both SendWaveAsync and RetryAllFailedAsync saved the
CampaignGrant rows as Queued in one transaction and then looped over
SendCampaignCodeAsync to enqueue outbox messages. A throw mid-loop
left the tail of grants in a lost state:
- Persisted as Queued
- No outbox row, so the email never goes out
- Not Failed, so RetryAllFailedAsync skips them
- Already granted, so SendWaveAsync's alreadyGrantedSet skips them
  on the next wave
= silent data loss.

Before nobodies-collective#472 these writes happened in a single DbContext save to the
same service boundary, so the atomicity question didn't arise.

Move to per-iteration save-then-enqueue with a local catch: each
grant is saved, the email is enqueued, and if the enqueue throws the
grant is flipped to Failed in a follow-up save. The loop continues
so a single bad email can't orphan the rest of the batch, and the
Failed grants flow back through RetryAllFailedAsync normally.

Same treatment for RetryAllFailedAsync: per-grant flip-and-enqueue
so a retry failure leaves only that one grant in Failed state.
@peterdrier peterdrier merged commit 4682a2d into main Apr 13, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant