PhoenixKit.Migrations.Postgres.V152 (phoenix_kit v1.7.208)

Copy Markdown View Source

V152: Newsletters/CRM/Core restructuring — accumulator migration.

Per the "one open migration" rule: while V152 is unreleased, every DDL step of the restructuring plan lands here as its own section rather than opening a new vNNN. Add new work as another up_*/down_* pair, called from up/1/down/1 in application order (down/1 unwinds in reverse). Keep each section's DDL self-contained and idempotent, same as any other migration in this chain.

The accumulator's one sharp edge — read before appending

The chain tracks progress by a single version comment on the phoenix_kit table. A database that has already stamped '152' will never re-enter this module, so a section appended after that stamp silently never applies there. Two hard consequences:

  1. The moment V152 ships in a release, it is CLOSED. The next DDL opens V153 — the one-open-migration rule applies only to the unreleased window. Appending to a released version would strand every already-migrated host with missing DDL and no error.
  2. Dev/staging databases running this branch must re-apply after every appended section: mix ecto.rollback --step 1 (the file migration downs the whole version — data written by earlier sections survives via their own down/up copy cycles), then mix phoenix_kit.update to run the version again with all sections. Skipping this dance leaves the stamp at '152' with only the older sections applied — the exact silent-skip failure this warning exists to prevent.

Section: send profiles move to core Email

Creates phoenix_kit_email_send_profiles — the same shape V145 gave phoenix_kit_newsletters_send_profiles, now owned by core's PhoenixKit.Email namespace instead of the newsletters module (send profiles stop being newsletters-only, so any module can resolve one). Every row is copied across by its existing uuid (the PK on both tables, so nothing is renumbered) and the V145 table is then dropped. idx_nl_send_profiles_integration/idx_nl_send_profiles_default become idx_email_send_profiles_integration/idx_email_send_profiles_default.

Does not touch phoenix_kit_newsletters_broadcasts.send_profile_uuid (added by V145) — it was already a bare UUID with no FK, so it still points at the same row regardless of which table now owns it.

The copy+drop only runs when the V145 table is still present, so up/1 is safe to re-run after it has already completed once (nothing left to copy, no "relation does not exist" on the second pass). Same for down/1 against the V152 table.

Section: CRM contact lists

Two new tables plus three columns on the existing V138 phoenix_kit_crm_contacts, for Stage 3 of the restructuring plan (list-based sending + account import):

  • phoenix_kit_crm_lists — a named, sluggable list (status active/archived, subscribable pre-provisioned for the Stage-4 preference center, subscriber_count a maintained cache, locale a nullable content-language tag the admin UI can bulk-apply to the list's contacts).
  • phoenix_kit_crm_list_members — the list↔contact join, carrying a denormalized email snapshot taken at add-time (so a list survives a later change to the contact's own email) and its own status (subscribed/pending/removed) and source (manual/import/form/api). UNIQUE (list_uuid, contact_uuid) keeps one membership row per contact per list; UNIQUE (list_uuid, email) WHERE email IS NOT NULL (idx_crm_list_members_list_email) is the actual per-list email uniqueness guard — a removed member still holds its email slot, so re-importing the same address cannot silently create a second, resubscribed row under it.
  • phoenix_kit_crm_contacts.locale / .opted_out_at / .consent — opt-out and consent live on the contact (not the membership), so an opt-out applies across every list the contact belongs to; the Stage-4 send path checks membership subscribed AND contact not opted out.

citext (already ensured by V151) backs email here too, for the same case-insensitive matching. All operations are idempotent.

Section: broadcasts can source recipients from a CRM list

Minimal Stage-4 groundwork: a broadcast can now target either its own phoenix_kit_newsletters_lists list (unchanged default behavior) or a phoenix_kit_crm_lists list, tagged by the new source_type column ('newsletters_list' default / 'crm_list') — validated at the Ecto layer only, same as broadcasts.status already is (no DB CHECK on either). crm_list_uuid is a bare UUID with no FK, matching send_profile_uuid's existing soft-reference pattern: newsletters must not hard-depend on the CRM module being installed.

broadcasts.list_uuid and deliveries.user_uuid both drop their NOT NULL — a crm_list broadcast has no newsletters list, and a CRM-sourced delivery generally has no core User row at all (most CRM contacts never log in). deliveries.recipient_email is the new column that makes such a delivery addressable: a CITEXT snapshot of the recipient's email taken when the send is enqueued, mirroring how phoenix_kit_crm_list_members.email already snapshots at add-time.

down/1 deliberately does not restore the two NOT NULLs — by the time this ships, a dev database exercising this feature will have rows with list_uuid/user_uuid NULL, and re-imposing the constraint would make the documented rollback-and-reapply dance (see the warning above) fail on exactly the data this section exists to create. Only the new columns are dropped.

A dropped user_uuid NOT NULL alone would let a delivery row be addressable by neither identifier at all — phoenix_kit_newsletters_deliveries_recipient_check (CHECK (user_uuid IS NOT NULL OR recipient_email IS NOT NULL)) closes that gap; down/1 drops it along with the columns. The Ecto-layer Broadcaster insert path already guards the same invariant before this constraint existed — this is a cheap, redundant DB-level backstop, not a replacement for it.

Summary

Functions

down(opts)

up(opts)