Skip to content

fix(email-campaigns): batch large contact lookups via contacts_view#2859

Merged
malek10xdev merged 1 commit into
mainfrom
fix-email-campaign
Jul 15, 2026
Merged

fix(email-campaigns): batch large contact lookups via contacts_view#2859
malek10xdev merged 1 commit into
mainfrom
fix-email-campaign

Conversation

@malek10xdev

@malek10xdev malek10xdev commented Jul 13, 2026

Copy link
Copy Markdown
Collaborator

Summary

Large email campaigns (>~1k contacts) failed with URI too long because getSelectedContacts (compliance middleware) and getContactsByEmails (/campaigns/preview) used a single .in("email", emails) query. PostgREST serializes that as ?email=in.(...) in the URL, and ~30 chars/email blows past the URL length limit on Supabase / edge functions.

Fix

Query private.contacts_view (which already dedupes by (user_id, COALESCE(email, id::text)) via ROW_NUMBER() OVER (PARTITION BY ...) and aggregates consent_status via (array_agg(... ORDER BY rn) FILTER (...))[1]) in batches of 100. The view's SQL-level dedup makes the JS-side dedup redundant, so the function body in this PR is byte-identical to the equivalent in leadminer-commercial (PR #120). The 2-step getContactsByEmails flow (persons .in() -> RPC by id) is replaced with a single batched query against the view.

Gated Deno.serve() behind import.meta.main so tests can import index.ts helpers without binding a port.

leadminer-commercial parity

The matching change is on leadminer-commercial PR #120 (branch fix/email-campaign-uri-too-long, commit a00e57d). The integration script at deploy time copies commercial's middlewares-mod.ts into the deployed leadminer; without the matching commercial change, the two repos would diverge.

Behavior change

The consent_status winner switches from "most recent updated_at" to "primary source first, then most recent updated_at" (the view's rn ordering). This is more correct for the campaign-check flow (primary source is the source of truth for consent) and matches what the production /campaigns/preview path already does via get_contacts_table_by_ids.

Tests

  • 3 tests in tests/get-selected-contacts.test.ts (batching, small list, empty input) — the previous "cross-batch dedup" test was removed because dedup is now at the SQL view level
  • 3 tests in tests/get-contacts-by-emails.test.ts (unchanged)
  • 6 pre-existing broken tests in tests/campaign-{bill,check}-middleware.test.ts fixed (they imported non-existent campaign-{bill,check}-middleware.ts; the real middlewares are createFinalResponseMiddleware and complianceMiddleware in middlewares-mod.ts)
  • 21/21 Deno tests pass (deno test --allow-all --no-check in supabase/functions/email-campaigns/)

Test plan

  1. cd supabase/functions/email-campaigns && deno test --allow-all --no-check — 21/21 pass
  2. Manual: send a campaign with 1000+ contacts (previously failed, should now succeed)

🤖 Generated with opencode

Co-Authored-By: opencode noreply@opencode.ai

Large email campaigns (>~1k contacts) failed with "URI too long"
because both `getSelectedContacts` (compliance middleware) and
`getContactsByEmails` (/campaigns/preview) used a single
`\.in("email", emails)` query. PostgREST serializes that as
`?email=in.(...)` in the URL, and ~30 chars/email blows past the URL
length limit on Supabase / edge functions.

Fix: query `private.contacts_view` (which already dedupes by
`(user_id, COALESCE(email, id::text))` via `ROW_NUMBER() OVER (...)`
and aggregates `consent_status` via `(array_agg(... ORDER BY
rn))[1]`) in batches of 100. The view's SQL-level dedup makes the
JS-side dedup redundant, so the function body is now byte-identical
to the equivalent in leadminer-commercial.

The 2-step `getContactsByEmails` flow (persons `\.in()` -> RPC by id)
is replaced with a single batched query against the view too.

Gated `Deno.serve()` behind `import.meta.main` so tests can import
`index.ts` helpers without binding a port.

Also fixes 6 pre-existing broken tests in
`tests/campaign-{bill,check}-middleware.test.ts` that imported
non-existent files (`campaign-{bill,check}-middleware.ts`). The real
middlewares live in `middlewares-mod.ts` as
`createFinalResponseMiddleware` and `complianceMiddleware`.

Behavior change: the consent_status winner is now "primary source
first, then most recent" (view's `rn` ordering) instead of "most
recent only". This is more correct for the campaign-check flow
(primary source is the source of truth) and matches what other
contact-fetch paths in the codebase already do.

Mirrors `leadminer-commercial` change on branch
`fix/email-campaign-uri-too-long` (commit
`a00e57d`).

Tests: 21/21 pass (`deno test --allow-all --no-check` in
`supabase/functions/email-campaigns/`).
@malek10xdev malek10xdev changed the title fix(email-campaigns): batch large contact lookups to avoid URI too long fix(email-campaigns): batch large contact lookups via contacts_view Jul 13, 2026
@malek10xdev
malek10xdev merged commit dff7170 into main Jul 15, 2026
12 of 14 checks passed
@malek10xdev
malek10xdev deleted the fix-email-campaign branch July 15, 2026 15:44
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