Uh oh!
There was an error while loading. Please reload this page.
V155: CRM contact id on newsletter deliveries + per-broadcast dedup indexes - #653
Conversation
phoenix_kit_newsletters_deliveries gains a nullable crm_contact_uuid (bare soft reference, no FK — newsletters must not hard-depend on the CRM module, same pattern as broadcasts.crm_list_uuid), plus three partial unique indexes on (broadcast_uuid, user_uuid), (broadcast_uuid, crm_contact_uuid) and (broadcast_uuid, recipient_email). These are the table's first DB-level per-broadcast duplicate guard: insert_all had no ON CONFLICT, and Oban's unique option guards the job, not the row a second enqueue would insert. The address-level index is the only one that also stops one mailbox receiving the same broadcast twice via two contacts sharing it. The recipient CHECK is replaced under the same name: it keeps the original addressability requirement and adds mutual exclusion between user_uuid and crm_contact_uuid. Deliberately not the strict XOR the spec's shorthand describes — existing CRM deliveries carry neither owner (they are addressed by recipient_email alone) and a strict XOR would reject the shape the current send path produces; see the migration moduledoc. Verified against a live database (14 deliveries / 4 broadcasts): no pre-existing duplicate pairs, so all three indexes create cleanly.
timujinne
left a comment
There was a problem hiding this comment.
Code review
This pair of PRs adds DB-level per-broadcast delivery dedup to newsletters. Core #653 (V154) adds a bare crm_contact_uuid soft-ref column to phoenix_kit_newsletters_deliveries, replaces the recipient_check CHECK (same name) with "addressable AND not double-claimed by both a core user and a CRM contact," and creates three partial unique indexes — (broadcast_uuid, user_uuid|crm_contact_uuid|recipient_email) each WHERE … IS NOT NULL — the table's first per-broadcast dedup guarantee. Newsletters #20 threads the contact uuid from CRMSource.sendable_recipients/1 into the insert rows and switches Broadcaster.process_batch/5 to on_conflict: :nothing, counting skipped duplicates, continuing the throttle offset over inserted rows, and correcting total_recipients to the actual inserted count. The design is sound; I verified the riskiest claims (partial-index arbitration under ON CONFLICT DO NOTHING with no target, RETURNING semantics, finalize independence from total_recipients, changeset↔CHECK parity) directly against Postgres and the code. The issues below are MINOR/NOTE — no blocker.
Verdict: APPROVE-WITH-NOTES
Findings
[MINOR]
total_recipientsreports "inserted this pass", not the broadcast's recipient count — misleading on a resend.lib/phoenix_kit/newsletters/broadcaster.ex:122-127. On a partial/fully-duplicate resend,total_recipientsis corrected to the rows inserted this enqueue (0 on a pure resend, "new-only" on a mixed one), while the original enqueue's Oban jobs still send the original N deliveries. The value is rendered as a headline stat (broadcast_details.html.heex:118), so a pure resend shows "Recipients: 0" whilesent_countlater climbs to N (and anysent/totalratio divides by zero). Finalization never readstotal_recipients(see Verified), so this is display-only, but the field name and the UI imply "total recipients of this broadcast." Suggestion: set it to the post-insert delivery count for the broadcast (count(deliveries where broadcast_uuid)), or relabel/document the field as "recipients added in the latest enqueue."[MINOR]
down/1is lossy in practice and the rollback coupling is undocumented.lib/phoenix_kit/migrations/postgres/v154.ex:78-83, 123-129. The moduledoc states "Non-lossy — nothing here is a backfill, so there's no derived data to lose on rollback." That holds only for the schema state V154 starts from; once CRM broadcasts have run, deliveries carry realcrm_contact_uuidvalues thatDROP COLUMNdestroys. Separately, newsletters #20 writescrm_contact_uuidon every CRM send, so rolling V154 back alone breaks the module — a coherent rollback must downgrade both together, which the moduledoc doesn't note. Suggestion: soften the claim to "non-lossy for pre-existing schema; populated contact refs are dropped on rollback" and mention the module dependency.[NOTE] Index/CHECK creation is non-concurrent.
v154.ex:143-150, 168-186. ThreeCREATE UNIQUE INDEXplusADD CONSTRAINT … CHECKrun non-concurrently inside the migration transaction, each taking a strong lock and full-scanningphoenix_kit_newsletters_deliveries. Trivial on the dev table cited in the moduledoc (14 rows), a real write-blocking window on a large production deliveries table. This matches existing chain conventions (andCONCURRENTLYcan't be used inside the DDL transaction), so just flagging it for operator awareness on large installs.
Verified (adversarial focus, came back clean)
on_conflict: :nothingwithout aconflict_targetis well-defined here. Confirmed against Postgres: an exact(broadcast, contact)duplicate and a cross-contact same-email row are both skipped (num_rows: 0) underON CONFLICT DO NOTHINGwith no target — all three partial indexes arbitrate. The shared-mailbox dedup is the documented intent (v154.exmoduledoc §dedup indexes), not an accident. The only other unique indexes on the table are the PK (freshUUIDv7.generate(), collision-proof) and the partialmessage_idindex (NULL atinsert_alltime → inert), so nothing unintended can be silently swallowed. A CHECK/NOT-NULL violation raises rather than skipping, so it isn't masked either.- Duplicate count is correct.
RETURNINGyields zero rows for conflict-skipped inserts (verified), solength(deliveries) - length(inserted)(broadcaster.ex:304) equals exactly the conflict count — every non-inserted row is a genuine duplicate. total_recipientscannot desynchronize finalization across batches.maybe_finalize_broadcast(delivery_worker.ex:516-520) andrepair_stuck_sending_broadcasts(newsletters.ex:434) flipsending→sentpurely onDelivery.non_terminal_broadcast_uuids_query/0, never ontotal_recipients; batched duplicate skips only affect the display value (finding 1).- Changeset matches the DB CHECK exactly.
validate_recipient/1+validate_not_both_owners/1(delivery.ex) reject and accept the same row shapes as the CHECK across all six cases (both-nil, contact-only, user+contact+email reject; user-only, email-only, contact+email accept). up/1is atomic and idempotent. Ecto wraps the version in a DDL transaction (no@disable_ddl_transaction), so a mid-version failure rolls back all index/constraint/column changes;IF NOT EXISTS/ DROP-then-ADD make re-runs safe.down/1restores the V152 CHECK verbatim and drops index→column in reverse order.- Tests are honest.
broadcaster_idempotency_test.exsdrives the realBroadcaster.send/1→insert_allpath and asserts a second enqueue (status reset todraft, re-send) adds no delivery rows and yieldstotal_recipients0 / 1 — not a fresh-broadcast assertion.Oban.insert_all([])on an all-duplicate batch is a safe no-op (Oban 2.23→Repo.insert_all(_, _, [], _)→{0, []}).
…ependency Review feedback on PR BeamLabEU#653: "non-lossy" was accurate only against the V152 schema V154 starts from, not against data written after it ships — once phoenix_kit_newsletters' Broadcaster has sent anything through the crm_list path, crm_contact_uuid holds real contact references that DROP COLUMN destroys same as any other rollback of a populated column. Also documents that V154 and the newsletters release depending on it must be rolled back together: reverting V154 alone while that newsletters version stays deployed breaks its insert_all outright (the column it targets is gone). Doc-only — no DDL, schema, or test changes.
timujinne
commented
Jul 20, 2026
Documentation note addressed in 198991c: the |
Adds `source_params JSONB NOT NULL DEFAULT '{}'` to
`phoenix_kit_newsletters_broadcasts`, per S4-C (spec §1/§7): a third
recipient source, source_type = "user_group", targets core users by
role rather than a CRM list or the newsletters list.
JSONB rather than another scalar soft-ref uuid column (like
crm_list_uuid) because a broadcast can target more than one role — the
newsletters-side resolver reads/writes the shape
`%{"role_names" => [...]}`. Same flexible-bag convention already used
for crm_lists.metadata/crm_list_members.metadata; source_type's own
enum stays Ecto-only, unchanged from V152's convention.
Landed in this open PR per the one-open-migration rule rather than
opening V155, since BeamLabEU#653 hasn't merged yet.
Note: source_params rollback is genuinely lossy (a user_group
broadcast's role selection is destroyed by DROP COLUMN, not merely
orphaned) but it's a UI selection, not delivery-identifying history —
documented in the moduledoc alongside the existing crm_contact_uuid
lossiness note.timujinne
commented
Jul 20, 2026
Delta review of the section added after the first pass ( This delta extends the still-unreleased V154 accumulator with one new section: Verdict: APPROVE-WITH-NOTESFindings
|
…y snapshot Follow-up to delta review on BeamLabEU#653: a role's name is mutable (Roles.update_role/2 doesn't protect it, not even for system roles), so a broadcast that stored names would silently re-target — or empty out — whatever the role gets renamed to, with no signal anywhere. Changed the documented (JSONB, Ecto-only, no DB CHECK — unchanged) shape from `%{"role_names" => [...]}` to `%{"role_uuids" => [...], "role_names_snapshot" => [...]}`: the newsletters-side resolver resolves by the stable uuid; the name snapshot is display-only, same precedent as recipient_email/supplier_name_snapshot elsewhere in this chain, so the UI can still show what a broadcast targeted after a role is later renamed or deleted. Also fixed the source_params default-column test to check type + NOT NULL + "a default exists" rather than pinning the default's textual representation (a Postgres/driver formatting detail, not part of the contract). Doc/test-only — no DDL change (source_params was always a schemaless JSONB bag; this only changes what the newsletters side puts in it).
ddon
commented
Jul 20, 2026
@timujinne conflicts |
# Conflicts: # lib/phoenix_kit/migrations/postgres.ex # lib/phoenix_kit/migrations/postgres/v154.ex
timujinne
commented
Jul 20, 2026
Conflict resolved by merging current main. Two things happened at once here:
Verified locally: V155 + V152 migration suites and the timezone-label suite — 58 tests, 0 failures. GitHub now reports the branch as clean. |
Post-merge review of V155 (CRM contact id on newsletter deliveries + per-broadcast dedup indexes). No bugs found: verified no version collision with PR #650's V154 despite the misleading "V154" branch/commit naming, confirmed up/down statement ordering respects column/constraint dependencies, and cross-checked the widened-not-XOR recipient-check deviation against V152's history. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Bumps version and adds the CHANGELOG entry for the merged-but-unpublished PRs #650 (V154 OpenGraph tables + admin list-UI/breadcrumb/sidebar enhancements), #653 (V155 delivery CRM contact id + per-broadcast dedup), #654 (cheap timezone-label accessor), and #655 (etcher 0.8.2). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Groundwork for the newsletters module sending to CRM contact lists (restructuring spec §7). Three changes to
phoenix_kit_newsletters_deliveries:crm_contact_uuid— nullable, bare UUID, no FK (newsletters must not hard-depend on the CRM module being installed; same soft-reference pattern asbroadcasts.crm_list_uuidfrom V152), with a plain index.Recipient CHECK widened, under the same constraint name — keeps the original "somebody is addressable" clause (
user_uuid IS NOT NULL OR recipient_email IS NOT NULL) and adds mutual exclusion: a row is never claimed by both a core user and a CRM contact.Deliberate deviation: the spec's shorthand calls for a strict XOR between the two owners. That is not enforced here — existing CRM-sourced deliveries carry neither (they are addressed by
recipient_emailalone, and nothing backfills the new column onto historical rows), so a strict XOR would reject exactly the shape the current send path produces. Retrofitting a heuristic email-based backfill was out of scope. Rationale is recorded in the migration's moduledoc.Three partial unique indexes —
(broadcast_uuid, user_uuid),(broadcast_uuid, crm_contact_uuid),(broadcast_uuid, recipient_email), eachWHERE ... IS NOT NULL. This is the table's first DB-level per-broadcast duplicate guard:Broadcaster.process_batch/5'sinsert_allhas noON CONFLICT, and Oban'suniqueoption guards the job, not the row a second enqueue of the same broadcast would insert. The address-level index is the only one that also prevents one mailbox receiving the same broadcast twice through two contacts that share it.down/1unwinds in reverse and restores the V152 CHECK verbatim; nothing here is a backfill, so rollback is non-lossy.Verification
Checked a live database before writing the indexes (14 deliveries across 4 broadcasts): no pre-existing duplicate
(broadcast_uuid, user_uuid)or(broadcast_uuid, recipient_email)pairs, so all three create cleanly with no pre-migration cleanup.V155Test+V152Test→ 44 tests, 0 failures (fresh chain, both prefixed and unprefixed).mix compile --warnings-as-errorsclean.Note: the module-side consumer (threading
crm_contact_uuidthrough the send path and addingon_conflict: :nothing) follows in a phoenix_kit_newsletters PR that requires a release carrying this migration.