Packages
phoenix_kit
1.7.215
1.7.216
1.7.215
1.7.214
1.7.213
1.7.212
1.7.211
1.7.210
1.7.209
1.7.208
1.7.207
1.7.206
1.7.205
1.7.204
1.7.203
1.7.202
1.7.201
1.7.200
1.7.199
1.7.198
1.7.197
1.7.196
1.7.194
1.7.193
1.7.192
1.7.191
1.7.190
1.7.189
1.7.187
1.7.186
1.7.185
1.7.184
1.7.183
1.7.182
1.7.181
1.7.180
1.7.179
1.7.178
1.7.177
1.7.176
1.7.175
1.7.174
1.7.173
1.7.172
1.7.171
1.7.170
1.7.169
1.7.168
1.7.167
1.7.166
1.7.165
1.7.164
1.7.162
1.7.161
1.7.160
1.7.159
1.7.157
1.7.156
1.7.155
1.7.154
1.7.153
1.7.152
1.7.151
1.7.150
1.7.149
1.7.146
1.7.145
1.7.144
1.7.143
1.7.138
1.7.133
1.7.132
1.7.131
1.7.130
1.7.128
1.7.126
1.7.125
1.7.121
1.7.120
1.7.119
1.7.118
1.7.117
1.7.116
1.7.115
1.7.114
1.7.113
1.7.112
1.7.111
1.7.110
1.7.109
1.7.108
1.7.107
1.7.106
1.7.105
1.7.104
1.7.103
1.7.102
1.7.101
1.7.100
1.7.99
1.7.98
1.7.97
1.7.96
1.7.95
1.7.94
1.7.93
1.7.92
1.7.91
1.7.90
1.7.89
1.7.88
1.7.87
1.7.86
1.7.85
1.7.84
1.7.83
1.7.82
1.7.81
1.7.80
1.7.79
1.7.78
1.7.77
1.7.76
1.7.75
1.7.74
1.7.71
1.7.70
1.7.69
1.7.66
1.7.65
1.7.64
1.7.63
1.7.62
1.7.61
1.7.59
1.7.58
1.7.57
1.7.56
1.7.55
1.7.54
1.7.53
1.7.52
1.7.51
1.7.49
1.7.44
1.7.43
1.7.42
1.7.41
1.7.39
1.7.38
1.7.37
1.7.36
1.7.34
1.7.33
1.7.31
1.7.30
1.7.29
1.7.28
1.7.27
1.7.26
1.7.25
1.7.24
1.7.23
1.7.22
1.7.21
1.7.20
1.7.19
1.7.18
1.7.17
1.7.16
1.7.15
1.7.14
1.7.13
1.7.12
1.7.11
1.7.10
1.7.9
1.7.8
1.7.7
1.7.6
1.7.5
1.7.4
1.7.3
1.7.2
1.7.1
1.7.0
1.6.20
1.6.19
1.6.18
1.6.17
1.6.16
1.6.15
1.6.14
1.6.13
1.6.12
1.6.11
1.6.10
1.6.9
1.6.8
1.6.7
1.6.6
1.6.5
1.6.4
1.6.3
1.5.2
1.5.1
1.5.0
1.4.9
1.4.8
1.4.7
1.4.6
1.4.5
1.4.4
1.4.3
1.4.2
1.4.1
1.4.0
1.3.2
1.3.1
1.3.0
1.2.10
1.2.9
1.2.8
1.2.7
1.2.5
1.2.4
1.2.2
1.2.1
1.2.0
1.1.0
1.0.0
A foundation for building Elixir Phoenix apps — SaaS, social networks, ERP systems, marketplaces, and more
Current section
Files
Jump to
Current section
Files
lib/phoenix_kit/migrations/postgres/v152.ex
defmodule PhoenixKit.Migrations.Postgres.V152 do
@moduledoc """
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 {: .warning}
>
> 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 NULL`s — 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.
"""
use Ecto.Migration
alias PhoenixKit.Migrations.Postgres.Helpers
@send_profile_columns """
uuid, name, integration_uuid, provider_kind, from_name, from_email, reply_to,
signature_html, signature_text, rate_per_hour, rate_per_day, pause_seconds,
advanced, enabled, is_default, inserted_at, updated_at
"""
def up(opts) do
prefix = Map.get(opts, :prefix, "public")
p = prefix_str(prefix)
up_send_profiles_to_core_email(opts, prefix, p)
up_crm_contact_lists(opts, prefix, p)
up_broadcast_crm_source(opts, prefix, p)
execute("COMMENT ON TABLE #{p}phoenix_kit IS '152'")
end
def down(opts) do
prefix = Map.get(opts, :prefix, "public")
p = prefix_str(prefix)
# Reverse of up/1: added third, so it unwinds first.
down_broadcast_crm_source(opts, prefix, p)
# CRM contact lists was added second, so it unwinds next.
down_crm_contact_lists(opts, prefix, p)
down_send_profiles_to_core_email(opts, prefix, p)
execute("COMMENT ON TABLE #{p}phoenix_kit IS '151'")
end
# ── Section: send profiles move to core Email ──
defp up_send_profiles_to_core_email(opts, prefix, p) do
execute("""
CREATE TABLE IF NOT EXISTS #{p}phoenix_kit_email_send_profiles (
uuid UUID PRIMARY KEY DEFAULT #{Helpers.uuid_v7_call(prefix)},
name VARCHAR(255) NOT NULL,
integration_uuid UUID NOT NULL,
provider_kind VARCHAR(40) NOT NULL,
from_name VARCHAR(255), from_email VARCHAR(255), reply_to VARCHAR(255),
signature_html TEXT, signature_text TEXT,
rate_per_hour INTEGER, rate_per_day INTEGER, pause_seconds INTEGER DEFAULT 0,
advanced JSONB NOT NULL DEFAULT '{}'::jsonb,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
is_default BOOLEAN NOT NULL DEFAULT FALSE,
inserted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
""")
execute("""
CREATE INDEX IF NOT EXISTS idx_email_send_profiles_integration
ON #{p}phoenix_kit_email_send_profiles(integration_uuid)
""")
execute("""
CREATE UNIQUE INDEX IF NOT EXISTS idx_email_send_profiles_default
ON #{p}phoenix_kit_email_send_profiles(is_default) WHERE is_default = TRUE
""")
if table_exists?(opts, prefix, "phoenix_kit_newsletters_send_profiles") do
execute("""
INSERT INTO #{p}phoenix_kit_email_send_profiles (#{@send_profile_columns})
SELECT #{@send_profile_columns} FROM #{p}phoenix_kit_newsletters_send_profiles
ON CONFLICT (uuid) DO NOTHING
""")
execute("DROP TABLE IF EXISTS #{p}phoenix_kit_newsletters_send_profiles CASCADE")
end
end
defp down_send_profiles_to_core_email(opts, prefix, p) do
execute("""
CREATE TABLE IF NOT EXISTS #{p}phoenix_kit_newsletters_send_profiles (
uuid UUID PRIMARY KEY DEFAULT #{Helpers.uuid_v7_call(prefix)},
name VARCHAR(255) NOT NULL,
integration_uuid UUID NOT NULL,
provider_kind VARCHAR(40) NOT NULL,
from_name VARCHAR(255), from_email VARCHAR(255), reply_to VARCHAR(255),
signature_html TEXT, signature_text TEXT,
rate_per_hour INTEGER, rate_per_day INTEGER, pause_seconds INTEGER DEFAULT 0,
advanced JSONB NOT NULL DEFAULT '{}'::jsonb,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
is_default BOOLEAN NOT NULL DEFAULT FALSE,
inserted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
""")
execute("""
CREATE INDEX IF NOT EXISTS idx_nl_send_profiles_integration
ON #{p}phoenix_kit_newsletters_send_profiles(integration_uuid)
""")
execute("""
CREATE UNIQUE INDEX IF NOT EXISTS idx_nl_send_profiles_default
ON #{p}phoenix_kit_newsletters_send_profiles(is_default) WHERE is_default = TRUE
""")
if table_exists?(opts, prefix, "phoenix_kit_email_send_profiles") do
execute("""
INSERT INTO #{p}phoenix_kit_newsletters_send_profiles (#{@send_profile_columns})
SELECT #{@send_profile_columns} FROM #{p}phoenix_kit_email_send_profiles
ON CONFLICT (uuid) DO NOTHING
""")
execute("DROP TABLE IF EXISTS #{p}phoenix_kit_email_send_profiles CASCADE")
end
end
# ── Section: CRM contact lists ──
defp up_crm_contact_lists(_opts, prefix, p) do
Helpers.ensure_extension!("citext")
execute("""
CREATE TABLE IF NOT EXISTS #{p}phoenix_kit_crm_lists (
uuid UUID PRIMARY KEY DEFAULT #{Helpers.uuid_v7_call(prefix)},
name VARCHAR(255) NOT NULL,
slug VARCHAR(255) NOT NULL,
description TEXT,
status VARCHAR(20) NOT NULL DEFAULT 'active'
CHECK (status IN ('active', 'archived')),
subscribable BOOLEAN NOT NULL DEFAULT FALSE,
subscriber_count INTEGER NOT NULL DEFAULT 0,
locale VARCHAR(10),
metadata JSONB NOT NULL DEFAULT '{}',
inserted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
""")
execute("""
CREATE UNIQUE INDEX IF NOT EXISTS idx_crm_lists_slug
ON #{p}phoenix_kit_crm_lists (slug)
""")
execute("""
CREATE TABLE IF NOT EXISTS #{p}phoenix_kit_crm_list_members (
uuid UUID PRIMARY KEY DEFAULT #{Helpers.uuid_v7_call(prefix)},
list_uuid UUID NOT NULL REFERENCES #{p}phoenix_kit_crm_lists(uuid) ON DELETE CASCADE,
contact_uuid UUID NOT NULL REFERENCES #{p}phoenix_kit_crm_contacts(uuid) ON DELETE CASCADE,
email CITEXT,
status VARCHAR(20) NOT NULL DEFAULT 'subscribed'
CHECK (status IN ('subscribed', 'pending', 'removed')),
subscribed_at TIMESTAMPTZ,
unsubscribed_at TIMESTAMPTZ,
source VARCHAR(20) NOT NULL DEFAULT 'manual'
CHECK (source IN ('manual', 'import', 'form', 'api')),
metadata JSONB NOT NULL DEFAULT '{}',
inserted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
""")
execute("""
CREATE UNIQUE INDEX IF NOT EXISTS idx_crm_list_members_list_contact
ON #{p}phoenix_kit_crm_list_members (list_uuid, contact_uuid)
""")
# Per-list email uniqueness on the denormalized snapshot — a `removed`
# member still holds its email slot, so re-importing the same address
# cannot silently resubscribe it under a second row.
execute("""
CREATE UNIQUE INDEX IF NOT EXISTS idx_crm_list_members_list_email
ON #{p}phoenix_kit_crm_list_members (list_uuid, email)
WHERE email IS NOT NULL
""")
execute("""
CREATE INDEX IF NOT EXISTS idx_crm_list_members_contact
ON #{p}phoenix_kit_crm_list_members (contact_uuid)
""")
execute("""
ALTER TABLE #{p}phoenix_kit_crm_contacts
ADD COLUMN IF NOT EXISTS locale VARCHAR(10)
""")
execute("""
ALTER TABLE #{p}phoenix_kit_crm_contacts
ADD COLUMN IF NOT EXISTS opted_out_at TIMESTAMPTZ
""")
execute("""
ALTER TABLE #{p}phoenix_kit_crm_contacts
ADD COLUMN IF NOT EXISTS consent JSONB NOT NULL DEFAULT '{}'
""")
end
defp down_crm_contact_lists(_opts, _prefix, p) do
execute("ALTER TABLE #{p}phoenix_kit_crm_contacts DROP COLUMN IF EXISTS consent")
execute("ALTER TABLE #{p}phoenix_kit_crm_contacts DROP COLUMN IF EXISTS opted_out_at")
execute("ALTER TABLE #{p}phoenix_kit_crm_contacts DROP COLUMN IF EXISTS locale")
execute("DROP TABLE IF EXISTS #{p}phoenix_kit_crm_list_members CASCADE")
execute("DROP TABLE IF EXISTS #{p}phoenix_kit_crm_lists CASCADE")
end
# ── Section: broadcasts can source recipients from a CRM list ──
defp up_broadcast_crm_source(opts, prefix, p) do
Helpers.ensure_extension!("citext")
# A crm_list broadcast has no newsletters list of its own.
execute("""
ALTER TABLE #{p}phoenix_kit_newsletters_broadcasts
ALTER COLUMN list_uuid DROP NOT NULL
""")
execute("""
ALTER TABLE #{p}phoenix_kit_newsletters_broadcasts
ADD COLUMN IF NOT EXISTS source_type VARCHAR(20) NOT NULL DEFAULT 'newsletters_list'
""")
# Bare UUID, no FK — same soft-reference pattern as send_profile_uuid:
# newsletters must not hard-depend on the CRM module being installed.
execute("""
ALTER TABLE #{p}phoenix_kit_newsletters_broadcasts
ADD COLUMN IF NOT EXISTS crm_list_uuid UUID
""")
execute("""
CREATE INDEX IF NOT EXISTS idx_newsletters_broadcasts_crm_list
ON #{p}phoenix_kit_newsletters_broadcasts (crm_list_uuid)
WHERE crm_list_uuid IS NOT NULL
""")
# A CRM-sourced delivery generally has no core User row at all (most
# CRM contacts never log in) — recipient_email is the address that
# makes such a delivery addressable, snapshotted when the send is
# enqueued (same idea as phoenix_kit_crm_list_members.email).
execute("""
ALTER TABLE #{p}phoenix_kit_newsletters_deliveries
ALTER COLUMN user_uuid DROP NOT NULL
""")
execute("""
ALTER TABLE #{p}phoenix_kit_newsletters_deliveries
ADD COLUMN IF NOT EXISTS recipient_email CITEXT
""")
# Cheap integrity guard: with both NOT NULLs dropped above, a delivery
# row addressable by neither a core User nor a snapshotted email is
# unreachable by any send path — Postgres has no
# `ADD CONSTRAINT IF NOT EXISTS`, so guard on the constraint name (same
# pattern used elsewhere in this migration chain, e.g. V125's
# `add_status_entity_uuid_fk/2`). Deliberately no CHECK on `source_type`
# here — this repo's convention keeps enum-shaped columns Ecto-only
# (see `broadcasts.status`), not DB CHECK-constrained.
escaped_prefix = Map.get(opts, :escaped_prefix, prefix)
execute("""
DO $$
BEGIN
IF NOT EXISTS (
SELECT FROM information_schema.table_constraints
WHERE table_schema = '#{escaped_prefix}'
AND table_name = 'phoenix_kit_newsletters_deliveries'
AND constraint_name = 'phoenix_kit_newsletters_deliveries_recipient_check'
) THEN
ALTER TABLE #{p}phoenix_kit_newsletters_deliveries
ADD CONSTRAINT phoenix_kit_newsletters_deliveries_recipient_check
CHECK (user_uuid IS NOT NULL OR recipient_email IS NOT NULL);
END IF;
END $$;
""")
end
defp down_broadcast_crm_source(_opts, _prefix, p) do
# Deliberately does NOT restore the NOT NULLs — see the module doc's
# "Section: broadcasts can source recipients from a CRM list" note.
execute(
"ALTER TABLE #{p}phoenix_kit_newsletters_deliveries DROP CONSTRAINT IF EXISTS phoenix_kit_newsletters_deliveries_recipient_check"
)
execute("DROP INDEX IF EXISTS #{p}idx_newsletters_broadcasts_crm_list")
execute(
"ALTER TABLE #{p}phoenix_kit_newsletters_broadcasts DROP COLUMN IF EXISTS crm_list_uuid"
)
execute(
"ALTER TABLE #{p}phoenix_kit_newsletters_broadcasts DROP COLUMN IF EXISTS source_type"
)
execute(
"ALTER TABLE #{p}phoenix_kit_newsletters_deliveries DROP COLUMN IF EXISTS recipient_email"
)
end
# ── Shared helpers ──
defp table_exists?(opts, prefix, table_name) do
escaped_prefix = Map.get(opts, :escaped_prefix, prefix)
case repo().query(
"""
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_name = '#{table_name}'
AND table_schema = '#{escaped_prefix}'
)
""",
[],
log: false
) do
{:ok, %{rows: [[true]]}} -> true
_ -> false
end
end
defp prefix_str("public"), do: "public."
defp prefix_str(prefix), do: "#{prefix}."
end