fix(email-campaigns): batch large contact lookups via contacts_view#2859
Merged
Conversation
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
force-pushed
the
fix-email-campaign
branch
from
July 13, 2026 14:39
de7ac01 to
2ded5c8
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Large email campaigns (>~1k contacts) failed with
URI too longbecausegetSelectedContacts(compliance middleware) andgetContactsByEmails(/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))viaROW_NUMBER() OVER (PARTITION BY ...)and aggregatesconsent_statusvia(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-stepgetContactsByEmailsflow (persons .in()-> RPC by id) is replaced with a single batched query against the view.Gated
Deno.serve()behindimport.meta.mainso tests can importindex.tshelpers without binding a port.leadminer-commercial parity
The matching change is on
leadminer-commercialPR #120 (branchfix/email-campaign-uri-too-long, commita00e57d). The integration script at deploy time copies commercial'smiddlewares-mod.tsinto 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 recentupdated_at" (the view'srnordering). This is more correct for the campaign-check flow (primary source is the source of truth for consent) and matches what the production/campaigns/previewpath already does viaget_contacts_table_by_ids.Tests
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 leveltests/get-contacts-by-emails.test.ts(unchanged)tests/campaign-{bill,check}-middleware.test.tsfixed (they imported non-existentcampaign-{bill,check}-middleware.ts; the real middlewares arecreateFinalResponseMiddlewareandcomplianceMiddlewareinmiddlewares-mod.ts)deno test --allow-all --no-checkinsupabase/functions/email-campaigns/)Test plan
cd supabase/functions/email-campaigns && deno test --allow-all --no-check— 21/21 pass🤖 Generated with opencode
Co-Authored-By: opencode noreply@opencode.ai