diff --git a/lib/phoenix_kit/migrations/postgres.ex b/lib/phoenix_kit/migrations/postgres.ex index cd1ee6eb7..a7c6b6dc3 100644 --- a/lib/phoenix_kit/migrations/postgres.ex +++ b/lib/phoenix_kit/migrations/postgres.ex @@ -667,7 +667,7 @@ defmodule PhoenixKit.Migrations.Postgres do use Ecto.Migration @initial_version 1 - @current_version 87 + @current_version 88 @default_prefix "public" @doc false diff --git a/lib/phoenix_kit/migrations/postgres/v88.ex b/lib/phoenix_kit/migrations/postgres/v88.ex new file mode 100644 index 000000000..16111c410 --- /dev/null +++ b/lib/phoenix_kit/migrations/postgres/v88.ex @@ -0,0 +1,520 @@ +defmodule PhoenixKit.Migrations.Postgres.V88 do + @moduledoc """ + V88: Publishing schema V2 — restructure posts/versions/contents. + + Posts become a minimal routing shell. Versions become the source of truth + for published state and metadata. Contents hold per-language title + body. + + Changes: + - Add `active_version_uuid` to posts (FK → versions, nullable) + - Add `trashed_at` to posts (replaces status-based soft delete) + - Add `published_at` to versions (moved from posts) + - Add `title_i18n` and `description_i18n` JSONB to groups (for future i18n) + - Data migration: populate new columns from existing data + - Drop legacy post columns: `scheduled_at`, `status`, `published_at`, `primary_language`, `data` + - Drop obsolete indexes (scheduled, group_status, group_published_at) + - Add new indexes for active_version_uuid, trashed_at, and version published_at + """ + + use Ecto.Migration + + def up(opts) do + prefix = Map.get(opts, :prefix, "public") + p = prefix_str(prefix) + schema = if prefix == "public", do: "public", else: prefix + + # Guard: only run if publishing tables exist + execute(""" + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_schema = '#{schema}' AND table_name = 'phoenix_kit_publishing_posts' + ) THEN + RAISE NOTICE 'Publishing tables not found, skipping V88'; + RETURN; + END IF; + + -- 1. Add active_version_uuid to posts + IF NOT EXISTS ( + SELECT FROM information_schema.columns + WHERE table_schema = '#{schema}' + AND table_name = 'phoenix_kit_publishing_posts' + AND column_name = 'active_version_uuid' + ) THEN + ALTER TABLE #{p}phoenix_kit_publishing_posts + ADD COLUMN active_version_uuid UUID; + + ALTER TABLE #{p}phoenix_kit_publishing_posts + ADD CONSTRAINT fk_publishing_posts_active_version + FOREIGN KEY (active_version_uuid) + REFERENCES #{p}phoenix_kit_publishing_versions(uuid) + ON DELETE SET NULL; + END IF; + + -- 2. Add trashed_at to posts + IF NOT EXISTS ( + SELECT FROM information_schema.columns + WHERE table_schema = '#{schema}' + AND table_name = 'phoenix_kit_publishing_posts' + AND column_name = 'trashed_at' + ) THEN + ALTER TABLE #{p}phoenix_kit_publishing_posts + ADD COLUMN trashed_at TIMESTAMPTZ; + END IF; + + -- 3. Add published_at to versions + IF NOT EXISTS ( + SELECT FROM information_schema.columns + WHERE table_schema = '#{schema}' + AND table_name = 'phoenix_kit_publishing_versions' + AND column_name = 'published_at' + ) THEN + ALTER TABLE #{p}phoenix_kit_publishing_versions + ADD COLUMN published_at TIMESTAMPTZ; + END IF; + + -- 4. Add translatable title/description to groups (for future i18n) + IF NOT EXISTS ( + SELECT FROM information_schema.columns + WHERE table_schema = '#{schema}' + AND table_name = 'phoenix_kit_publishing_groups' + AND column_name = 'title_i18n' + ) THEN + ALTER TABLE #{p}phoenix_kit_publishing_groups + ADD COLUMN title_i18n JSONB NOT NULL DEFAULT '{}'; + END IF; + + IF NOT EXISTS ( + SELECT FROM information_schema.columns + WHERE table_schema = '#{schema}' + AND table_name = 'phoenix_kit_publishing_groups' + AND column_name = 'description_i18n' + ) THEN + ALTER TABLE #{p}phoenix_kit_publishing_groups + ADD COLUMN description_i18n JSONB NOT NULL DEFAULT '{}'; + END IF; + + END $$; + """) + + # ── New indexes ──────────────────────────────────────────────── + + execute(""" + CREATE INDEX IF NOT EXISTS idx_publishing_posts_active_version + ON #{p}phoenix_kit_publishing_posts (active_version_uuid) + WHERE active_version_uuid IS NOT NULL + """) + + execute(""" + CREATE INDEX IF NOT EXISTS idx_publishing_posts_trashed_at + ON #{p}phoenix_kit_publishing_posts (trashed_at) + WHERE trashed_at IS NULL + """) + + execute(""" + CREATE INDEX IF NOT EXISTS idx_publishing_versions_published_at + ON #{p}phoenix_kit_publishing_versions (published_at DESC) + WHERE published_at IS NOT NULL + """) + + # ── Data migration (BEFORE dropping legacy columns) ───────────── + + # Populate trashed_at for trashed posts + migrate_trashed_posts(p, schema) + + # Copy published_at from post to its published version + migrate_published_at(p, schema) + + # Set active_version_uuid for published posts + migrate_active_version(p, schema) + + # Copy metadata from content.data and post.data → version.data + migrate_version_data(p, schema) + + # ── Drop legacy columns (AFTER data migration) ──────────────── + + drop_legacy_post_columns(p, schema) + + # ── Drop obsolete indexes ────────────────────────────────────── + + execute("DROP INDEX IF EXISTS idx_publishing_posts_scheduled") + execute("DROP INDEX IF EXISTS idx_publishing_posts_group_status") + execute("DROP INDEX IF EXISTS idx_publishing_posts_group_published_at") + + execute("COMMENT ON TABLE #{p}phoenix_kit IS '88'") + end + + # WARNING: Rollback restores schema structure but data migrations are irreversible. + # - Legacy post columns are re-added as empty (original values lost) + # - published_at copied to versions is lost when the version column is dropped + # - active_version_uuid linkages are lost + # - Merged JSONB data (tags, seo, featured_image_uuid) cannot be un-merged + def down(opts) do + prefix = Map.get(opts, :prefix, "public") + p = prefix_str(prefix) + schema = if prefix == "public", do: "public", else: prefix + + execute(""" + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_schema = '#{schema}' AND table_name = 'phoenix_kit_publishing_posts' + ) THEN + RETURN; + END IF; + + -- Restore legacy post columns (empty — original values lost) + IF NOT EXISTS ( + SELECT FROM information_schema.columns + WHERE table_schema = '#{schema}' + AND table_name = 'phoenix_kit_publishing_posts' + AND column_name = 'scheduled_at' + ) THEN + ALTER TABLE #{p}phoenix_kit_publishing_posts + ADD COLUMN scheduled_at TIMESTAMPTZ; + END IF; + + IF NOT EXISTS ( + SELECT FROM information_schema.columns + WHERE table_schema = '#{schema}' + AND table_name = 'phoenix_kit_publishing_posts' + AND column_name = 'status' + ) THEN + ALTER TABLE #{p}phoenix_kit_publishing_posts + ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'draft'; + END IF; + + IF NOT EXISTS ( + SELECT FROM information_schema.columns + WHERE table_schema = '#{schema}' + AND table_name = 'phoenix_kit_publishing_posts' + AND column_name = 'published_at' + ) THEN + ALTER TABLE #{p}phoenix_kit_publishing_posts + ADD COLUMN published_at TIMESTAMPTZ; + END IF; + + IF NOT EXISTS ( + SELECT FROM information_schema.columns + WHERE table_schema = '#{schema}' + AND table_name = 'phoenix_kit_publishing_posts' + AND column_name = 'primary_language' + ) THEN + ALTER TABLE #{p}phoenix_kit_publishing_posts + ADD COLUMN primary_language VARCHAR(10) NOT NULL DEFAULT 'en'; + END IF; + + IF NOT EXISTS ( + SELECT FROM information_schema.columns + WHERE table_schema = '#{schema}' + AND table_name = 'phoenix_kit_publishing_posts' + AND column_name = 'data' + ) THEN + ALTER TABLE #{p}phoenix_kit_publishing_posts + ADD COLUMN data JSONB NOT NULL DEFAULT '{}'; + END IF; + + -- Drop new columns + IF EXISTS ( + SELECT FROM information_schema.columns + WHERE table_schema = '#{schema}' + AND table_name = 'phoenix_kit_publishing_posts' + AND column_name = 'active_version_uuid' + ) THEN + ALTER TABLE #{p}phoenix_kit_publishing_posts + DROP COLUMN active_version_uuid; + END IF; + + IF EXISTS ( + SELECT FROM information_schema.columns + WHERE table_schema = '#{schema}' + AND table_name = 'phoenix_kit_publishing_posts' + AND column_name = 'trashed_at' + ) THEN + ALTER TABLE #{p}phoenix_kit_publishing_posts + DROP COLUMN trashed_at; + END IF; + + IF EXISTS ( + SELECT FROM information_schema.columns + WHERE table_schema = '#{schema}' + AND table_name = 'phoenix_kit_publishing_versions' + AND column_name = 'published_at' + ) THEN + ALTER TABLE #{p}phoenix_kit_publishing_versions + DROP COLUMN published_at; + END IF; + + -- Drop new group columns + IF EXISTS ( + SELECT FROM information_schema.columns + WHERE table_schema = '#{schema}' + AND table_name = 'phoenix_kit_publishing_groups' + AND column_name = 'title_i18n' + ) THEN + ALTER TABLE #{p}phoenix_kit_publishing_groups + DROP COLUMN title_i18n; + END IF; + + IF EXISTS ( + SELECT FROM information_schema.columns + WHERE table_schema = '#{schema}' + AND table_name = 'phoenix_kit_publishing_groups' + AND column_name = 'description_i18n' + ) THEN + ALTER TABLE #{p}phoenix_kit_publishing_groups + DROP COLUMN description_i18n; + END IF; + + END $$; + """) + + # Drop new indexes + execute("DROP INDEX IF EXISTS idx_publishing_posts_active_version") + execute("DROP INDEX IF EXISTS idx_publishing_posts_trashed_at") + execute("DROP INDEX IF EXISTS idx_publishing_versions_published_at") + + # Restore old indexes + execute(""" + CREATE INDEX IF NOT EXISTS idx_publishing_posts_scheduled + ON #{p}phoenix_kit_publishing_posts (scheduled_at) + WHERE status = 'scheduled' + """) + + execute(""" + CREATE INDEX IF NOT EXISTS idx_publishing_posts_group_status + ON #{p}phoenix_kit_publishing_posts (group_uuid, status) + """) + + execute(""" + CREATE INDEX IF NOT EXISTS idx_publishing_posts_group_published_at + ON #{p}phoenix_kit_publishing_posts (group_uuid, published_at DESC) + """) + + execute("COMMENT ON TABLE #{p}phoenix_kit IS '87'") + end + + # ── Column cleanup (runs AFTER data migration) ────────────────── + + defp drop_legacy_post_columns(p, schema) do + execute(""" + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_schema = '#{schema}' AND table_name = 'phoenix_kit_publishing_posts' + ) THEN + RETURN; + END IF; + + IF EXISTS ( + SELECT FROM information_schema.columns + WHERE table_schema = '#{schema}' + AND table_name = 'phoenix_kit_publishing_posts' + AND column_name = 'scheduled_at' + ) THEN + ALTER TABLE #{p}phoenix_kit_publishing_posts DROP COLUMN scheduled_at; + END IF; + + IF EXISTS ( + SELECT FROM information_schema.columns + WHERE table_schema = '#{schema}' + AND table_name = 'phoenix_kit_publishing_posts' + AND column_name = 'status' + ) THEN + ALTER TABLE #{p}phoenix_kit_publishing_posts DROP COLUMN status; + END IF; + + IF EXISTS ( + SELECT FROM information_schema.columns + WHERE table_schema = '#{schema}' + AND table_name = 'phoenix_kit_publishing_posts' + AND column_name = 'published_at' + ) THEN + ALTER TABLE #{p}phoenix_kit_publishing_posts DROP COLUMN published_at; + END IF; + + IF EXISTS ( + SELECT FROM information_schema.columns + WHERE table_schema = '#{schema}' + AND table_name = 'phoenix_kit_publishing_posts' + AND column_name = 'primary_language' + ) THEN + ALTER TABLE #{p}phoenix_kit_publishing_posts DROP COLUMN primary_language; + END IF; + + IF EXISTS ( + SELECT FROM information_schema.columns + WHERE table_schema = '#{schema}' + AND table_name = 'phoenix_kit_publishing_posts' + AND column_name = 'data' + ) THEN + ALTER TABLE #{p}phoenix_kit_publishing_posts DROP COLUMN data; + END IF; + END $$; + """) + end + + # ── Data migration helpers ─────────────────────────────────────── + + defp migrate_trashed_posts(p, schema) do + execute(""" + DO $$ + BEGIN + IF EXISTS ( + SELECT FROM information_schema.columns + WHERE table_schema = '#{schema}' + AND table_name = 'phoenix_kit_publishing_posts' + AND column_name = 'status' + ) THEN + UPDATE #{p}phoenix_kit_publishing_posts + SET trashed_at = updated_at + WHERE status = 'trashed' AND trashed_at IS NULL; + END IF; + END $$; + """) + end + + defp migrate_published_at(p, schema) do + execute(""" + DO $$ + BEGIN + IF EXISTS ( + SELECT FROM information_schema.columns + WHERE table_schema = '#{schema}' + AND table_name = 'phoenix_kit_publishing_posts' + AND column_name = 'published_at' + ) THEN + UPDATE #{p}phoenix_kit_publishing_versions v + SET published_at = p.published_at + FROM #{p}phoenix_kit_publishing_posts p + WHERE v.post_uuid = p.uuid + AND v.status = 'published' + AND p.published_at IS NOT NULL + AND v.published_at IS NULL; + END IF; + END $$; + """) + end + + defp migrate_active_version(p, schema) do + execute(""" + DO $$ + BEGIN + IF EXISTS ( + SELECT FROM information_schema.columns + WHERE table_schema = '#{schema}' + AND table_name = 'phoenix_kit_publishing_posts' + AND column_name = 'status' + ) THEN + UPDATE #{p}phoenix_kit_publishing_posts p + SET active_version_uuid = v.uuid + FROM ( + SELECT DISTINCT ON (post_uuid) post_uuid, uuid + FROM #{p}phoenix_kit_publishing_versions + WHERE status = 'published' + ORDER BY post_uuid, version_number DESC + ) v + WHERE p.uuid = v.post_uuid + AND p.status = 'published' + AND p.active_version_uuid IS NULL; + END IF; + END $$; + """) + end + + defp migrate_version_data(p, schema) do + # For each version, find the site-default language content (falling back to + # first by language ASC), and merge its data fields into version.data. + # Also merge post.data fields (allow_version_access, tags, seo). + execute(""" + DO $$ + BEGIN + IF EXISTS ( + SELECT FROM information_schema.columns + WHERE table_schema = '#{schema}' + AND table_name = 'phoenix_kit_publishing_posts' + AND column_name = 'data' + ) THEN + -- Merge post.data fields into version.data + UPDATE #{p}phoenix_kit_publishing_versions v + SET data = v.data || jsonb_build_object( + 'allow_version_access', COALESCE(p.data->'allow_version_access', 'false'::jsonb), + 'tags', COALESCE(p.data->'tags', '[]'::jsonb), + 'seo', COALESCE(p.data->'seo', '{}'::jsonb) + ) + FROM #{p}phoenix_kit_publishing_posts p + WHERE v.post_uuid = p.uuid + AND p.data != '{}'::jsonb + AND NOT v.data ? 'tags'; + + -- Merge content.data fields into version.data (from best content row) + -- Uses primary_language to pick the best content per version if the column + -- still exists; falls back to 'en' on partial re-run after column was dropped + IF EXISTS ( + SELECT FROM information_schema.columns + WHERE table_schema = '#{schema}' + AND table_name = 'phoenix_kit_publishing_posts' + AND column_name = 'primary_language' + ) THEN + EXECUTE ' + WITH best_content AS ( + SELECT DISTINCT ON (c.version_uuid) + c.version_uuid, + c.data + FROM #{p}phoenix_kit_publishing_contents c + JOIN #{p}phoenix_kit_publishing_versions v ON v.uuid = c.version_uuid + JOIN #{p}phoenix_kit_publishing_posts p ON p.uuid = v.post_uuid + WHERE c.data != ''{}''::jsonb + ORDER BY c.version_uuid, + CASE WHEN c.language = COALESCE(p.primary_language, ''en'') THEN 0 ELSE 1 END, + c.language ASC + ) + UPDATE #{p}phoenix_kit_publishing_versions v + SET data = v.data || jsonb_build_object( + ''featured_image_uuid'', bc.data->''featured_image_uuid'', + ''description'', bc.data->''description'', + ''seo_title'', bc.data->''seo_title'', + ''excerpt'', bc.data->''excerpt'' + ) + FROM best_content bc + WHERE v.uuid = bc.version_uuid + AND NOT v.data ? ''featured_image_uuid'' + '; + ELSE + -- primary_language already dropped (partial re-run), fall back to ''en'' + EXECUTE ' + WITH best_content AS ( + SELECT DISTINCT ON (c.version_uuid) + c.version_uuid, + c.data + FROM #{p}phoenix_kit_publishing_contents c + JOIN #{p}phoenix_kit_publishing_versions v ON v.uuid = c.version_uuid + WHERE c.data != ''{}''::jsonb + ORDER BY c.version_uuid, + CASE WHEN c.language = ''en'' THEN 0 ELSE 1 END, + c.language ASC + ) + UPDATE #{p}phoenix_kit_publishing_versions v + SET data = v.data || jsonb_build_object( + ''featured_image_uuid'', bc.data->''featured_image_uuid'', + ''description'', bc.data->''description'', + ''seo_title'', bc.data->''seo_title'', + ''excerpt'', bc.data->''excerpt'' + ) + FROM best_content bc + WHERE v.uuid = bc.version_uuid + AND NOT v.data ? ''featured_image_uuid'' + '; + END IF; + + END IF; + END $$; + """) + end + + defp prefix_str("public"), do: "public." + defp prefix_str(prefix), do: "#{prefix}." +end