Skip to content

feat(contact): Contact model for public-ticket dedupe (Pattern B)#17

Draft
mpge wants to merge 3 commits intomainfrom
feat/public-tickets-contact-convergence
Draft

feat(contact): Contact model for public-ticket dedupe (Pattern B)#17
mpge wants to merge 3 commits intomainfrom
feat/public-tickets-contact-convergence

Conversation

@mpge
Copy link
Copy Markdown
Member

@mpge mpge commented Apr 24, 2026

Summary

.NET port of Pattern B. Reference implementations:

Rollout strategy: https://github.com/escalated-dev/escalated/blob/feat/public-ticket-system/docs/superpowers/plans/2026-04-24-public-tickets-rollout-status.md

Changes

  • Contact entity (EF Core) — unique email, nullable UserId, JSON metadata
  • Ticket — nullable ContactId + Contact navigation property (on delete SetNull)
  • EscalatedDbContext — registers DbSet, unique email index, ticket→contact relation
  • ContactModelTests — 8 xUnit cases (NormalizeEmail, DecideAction, metadata round-trip, defaults)

Pure-function helpers (`NormalizeEmail`, `DecideAction`) let the branch logic be tested without EF — matching the NestJS `message-id.ts` and Adonis `contact_model.test.js` patterns.

Test plan

  • CI runs 8 new ContactModelTests (local .NET SDK unavailable in author env)
  • EF migration will be generated separately — this PR ships model + DbContext config so ASP.NET Core apps using `synchronize` style auto-create pick it up

Backwards compatibility

Inline GuestName / GuestEmail / GuestToken fields preserved. Follow-up PRs migrate controllers and deprecate inline fields.

.NET port of the Pattern B design shipped in escalated-nestjs PR #17
and companion PRs for Laravel / Rails / Django / Adonis.

Changes:
  - src/Escalated/Models/Contact.cs — EF Core entity mapped to
    escalated_contacts. Unique email (case-normalized before save
    by application code), nullable UserId, JSON Metadata string with
    GetMetadata / SetMetadata helpers.
    Exposes pure static helpers NormalizeEmail and DecideAction so
    FindOrCreateByEmail's branch selection is testable without EF.
  - src/Escalated/Models/Ticket.cs — adds nullable ContactId + Contact
    navigation property (FK on delete SetNull).
  - src/Escalated/Data/EscalatedDbContext.cs — registers DbSet<Contact>
    with unique email index + user_id index; wires ticket.Contact
    relation with SetNull cascade.
  - tests/Escalated.Tests/Models/ContactModelTests.cs — 8 xUnit cases
    covering NormalizeEmail (3), DecideAction (4), Metadata round-trip
    (2), and default construction.

Inline GuestName / GuestEmail / GuestToken fields preserved on Ticket
for backwards compatibility; follow-up PRs will migrate controllers.

Local verification: .NET SDK unavailable in author's environment;
CI on this PR will run the tests.

See escalated-dev/escalated docs/superpowers/plans/2026-04-24-public-tickets-rollout-status.md
Follow-up on the Contact entity added earlier on this branch.
TicketService.CreateAsync now resolves/creates a Contact by email
when a guest email is supplied (and no requesterId was provided),
and sets Ticket.ContactId to link them.

Private FindOrCreateContactAsync uses the Pattern B pure statics
(Contact.NormalizeEmail + Contact.DecideAction) to select between
return-existing, update-name, and create. Matches the reference
impl used in NestJS/Laravel/Rails/Django/Adonis/Symfony/Go.

Inline GuestName / GuestEmail / GuestToken fields preserved for
backwards-compat dual-read.

Tests: 4 new integration-style xUnit cases using the in-memory
EF provider:
  - CreateAsync_WithGuestEmail_CreatesContactAndLinksTicket
  - CreateAsync_RepeatGuestEmail_DedupesOntoSameContact (casing variant)
  - CreateAsync_NoGuestEmail_LeavesContactIdNull (authenticated path)
  - CreateAsync_FillsBlankNameOnExistingContact

Local verification: no .NET SDK in author's env; CI on this PR runs
the tests.
@mpge
Copy link
Copy Markdown
Member Author

mpge commented Apr 24, 2026

Wire-up commit pushed

TicketService.CreateAsync now resolves/creates a Contact by email and sets Ticket.ContactId when a guest email is supplied.

Private FindOrCreateContactAsync uses the Pattern B pure statics (Contact.NormalizeEmail + Contact.DecideAction) — matches the reference impl in NestJS/Laravel/Rails/Django/Adonis/Symfony/Go.

4 new xUnit tests using the in-memory EF provider:

  • creates Contact + links ticket
  • repeat submission with casing variant dedupes
  • no guest email → ContactId stays null
  • existing contact with blank name gets filled

Local .NET SDK unavailable; CI on this PR verifies.

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