Skip to content

fix(contacts): make concurrent first-contact upserts safe - #469

Open
vladbisceanu wants to merge 718 commits into
useplunk:mainfrom
vladbisceanu:upstream/10-first-contact-race
Open

fix(contacts): make concurrent first-contact upserts safe#469
vladbisceanu wants to merge 718 commits into
useplunk:mainfrom
vladbisceanu:upstream/10-first-contact-race

Conversation

@vladbisceanu

Copy link
Copy Markdown

Concurrent first-seen tracking requests can all observe a missing contact, then race on ContactService.upsert. The unique (projectId, email) constraint correctly selects one row, but every losing contact.create currently surfaces Prisma P2002 and turns an otherwise valid track request into a 500.

This change treats P2002 as the election result: losers read and continue with the elected contact. They never replay their stale data over the winning row. An explicit concurrent unsubscribe is preserved with a compare-and-set update, and subscription events are emitted only by the request that actually changes the stored state.

The service regression suite forces eight first-seen upserts through the same missing-row observation, covers both winner orderings, and verifies that stale concurrent unsubscribe observers emit only one state-change event. Existing normalization and cross-project isolation coverage remains in the same suite.

Verification:

  • yarn vitest run apps/api/src/services/__tests__/ContactService.test.ts — 63 passed
  • yarn eslint --config apps/api/eslint.config.mjs apps/api/src/services/ContactService.ts apps/api/src/services/__tests__/ContactService.test.ts
  • yarn workspace api build

This is the portable counterpart to vladbisceanu#10.

driaugand others added 30 commits April 24, 2026 12:54
The raw MIME template in sendRawEmail produced a blank line between Content-Type and List-Unsubscribe whenever no custom headers were passed, because the ternary for custom headers expanded to an empty string surrounded by newlines.
Per RFC 5322 §2.1 a blank line terminates the header section, so List-Unsubscribe ended up as the first line of the body. Lenient clients (Gmail, Outlook) recover; strict clients (Thunderbird) do not, breaking one-click unsubscribe and degrading Gmail/Yahoo bulk-sender deliverability signals.
Collect optional headers (custom + List-Unsubscribe) into an array, filter empties, and append them to the Content-Type line with a single newline separator. No codepath can now produce a blank line inside the header block.
driaugand others added 26 commits August 10, 2026 11:14
The menu covered if/unless/for and stopped there, so the multi-way branch
people reach for most after a plain conditional — case/when — could only be
written from memory, which is the thing this menu exists to avoid.
Added, each still inserting complete and balanced:
- case/when/else, for picking a version per value. Offered for text, number and
date fields but not booleans, where a multi-way branch on two states is noise.
- contains, for matching within a value.
- for/else, which renders the fallback when the list is empty.
- comment, for notes that never send.
- raw, for showing template markup as literal text. The docs already tell
authors to reach for it and the menu did not offer it.
Also a below-a-value comparison for numbers and dates, which only had above.
The block definitions are now checked against the same parser the API validates
with, both filled and left empty. A typo in a closing tag would otherwise ship
as a menu entry that produces an unsaveable template, and nothing in the type
system would catch it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Templates used as workflow steps are edited on the same screen as campaign
templates, and are rendered with the trigger's event alongside contact data
(WorkflowExecutionService). Reading `event.plan` is correct there, but the field
lint only knew about contact fields and reported it as a missing one — a warning
firing on a working template, which is the failure mode this lint most needs to
avoid.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat!: implement more expressive templates via liquidjs
…erve-reply-to
fix(smtp): preserve Reply-To when relaying to /v1/send
…/js-yaml-4.3.1
build(deps): bump js-yaml from 4.3.0 to 4.3.1
…/nanoid-3.3.18
build(deps): bump nanoid from 3.3.11 to 3.3.18
…/dompurify-3.4.13
build(deps): bump dompurify from 3.4.12 to 3.4.13
The copy across the dashboard was written screen by screen, so the same
concept was phrased several different ways and a lot of text explained
things the UI already said. This is a pass over every user-facing string
in apps/web against one standard, applied consistently.
Systemic patterns removed:
- 25 confirmation dialogs opened with "Are you sure you want to...", then
repeated the question the title already asked. Titles now name the
object ("Delete john@acme.com?"), bodies state only the consequence,
and buttons are verb + object. Cancel dialogs previously rendered
[Cancel] [Cancel Campaign]; the dismiss button now reads "Keep sending".
- 42 success toasts ended in "successfully". The toast is already green.
- 87 "Failed to X" strings gave no cause and no next step. Most sites
already surfaced error.message and only used these as a fallback, so
the fallbacks now name what did not happen and what to do.
- ~20 CardDescriptions restated their own CardTitle. Deleted. The ones
carrying an actual rule were kept and sharpened instead.
- BillingConsumption, BillingInvoices and QuickStart each wrote their
header 3-4 times across loading/error/empty branches. Hoisted to one
header per component.
- 243 Title Case UI strings across 190 distinct labels moved to sentence
case. The app previously shipped both "Send To" and "Send to".
- Required-field markers used three different mechanisms. Now one.
- Terminology: "API Credentials" and "API Keys" were the same card under
two names; cancelled/canceled and .../… were both in use; the templates
page title disagreed with its nav item.
Structural changes:
- Removed the analytics "Performance Insights" card. It dispensed advice
the product cannot substantiate ("above industry average" against no
cited benchmark, "add more compelling calls-to-action") in a product
positioned against exactly that tone. Replaced with two counters drawn
from real data: events triggered and workflow runs started.
- Subscription activation copy was ~75 words over two paragraphs. It is
load-bearing (it prevents billing surprises), so it was restructured
rather than cut: one summary line plus a "Why two charges?" disclosure.
- Consolidated the DKIM instructions that appeared both above and below
the DNS record table.
- Trimmed the workflow active-executions notice and the HTML-to-visual
editor warning to their load-bearing lists.
Deliberately untouched:
- packages/shared/src/i18n/locales/* and the public subscribe,
unsubscribe and manage pages. Their copy is translated into 16 locales
and editing English there would desync 15 of them.
- The onboarding flows and the dashboard greeting, whose copy is already
the voice everything else was moved toward.
- console.error strings, which are developer-facing.
PRODUCT.md replaces the legacy .impeccable.md, with the register made
explicit and a terminology section recording that user-facing nouns
(Contact, Event, Campaign, Workflow, Template, Segment) track the API.
Typecheck and production build pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
refactor(web): rewrite UX copy for clarity and consistency
…s--next--components--plunk
chore(next): release 0.14.0
Sign up for freeto 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.

13 participants

@vladbisceanu@driaug@mre@ReylanLugo@taniasanz7@jaschaio@andygrunwald@abehuman@pausan@hanamizuki@cyrilchandelier@tomfri@emanuelefaja