diff --git a/.dialyzer_ignore.exs b/.dialyzer_ignore.exs index e42bafa06..516b15f61 100644 --- a/.dialyzer_ignore.exs +++ b/.dialyzer_ignore.exs @@ -1,5 +1,6 @@ [ # Mix functions are only available during Mix compilation context + {"lib/mix/tasks/phoenix_kit.gen.migration.ex", :unknown_function}, {"lib/mix/tasks/phoenix_kit.doctor.ex", :unknown_function}, {"lib/mix/tasks/phoenix_kit.install.ex", :unknown_function}, {"lib/mix/tasks/phoenix_kit.update.ex", :unknown_function}, diff --git a/lib/mix/tasks/phoenix_kit.gen.migration.ex b/lib/mix/tasks/phoenix_kit.gen.migration.ex index 7f08121c8..81441b632 100644 --- a/lib/mix/tasks/phoenix_kit.gen.migration.ex +++ b/lib/mix/tasks/phoenix_kit.gen.migration.ex @@ -2,10 +2,10 @@ defmodule Mix.Tasks.PhoenixKit.Gen.Migration do use Mix.Task @moduledoc """ - Generate PhoenixKit migration in parent application. + Generate a PhoenixKit versioned migration for the parent application. - This task generates a new migration file with PhoenixKit tables - that can be customized before running. + Scans existing migrations in `priv/repo/migrations/` to determine the current + PhoenixKit version, then generates a migration that upgrades to the latest version. ## Usage @@ -13,99 +13,139 @@ defmodule Mix.Tasks.PhoenixKit.Gen.Migration do ## Options - * `--table-prefix` - Custom prefix for tables (default: "phoenix_kit") + * `--prefix` - Database schema prefix (default: "public") ## Examples - # Generate with default phoenix_kit_users prefix + # Generate upgrade migration with default prefix mix phoenix_kit.gen.migration - # Generate with custom prefix - mix phoenix_kit.gen.migration --table-prefix users + # Generate with custom schema prefix + mix phoenix_kit.gen.migration --prefix my_schema """ - @shortdoc "Generate PhoenixKit migration" + alias PhoenixKit.Migrations.Postgres, as: PkMigrations + + @shortdoc "Generate PhoenixKit versioned migration" @impl Mix.Task def run(args) do opts = parse_args(args) - table_prefix = opts[:table_prefix] || "phoenix_kit" + prefix = opts[:prefix] || "public" + + from_version = detect_current_version() + to_version = PkMigrations.current_version() + + if from_version >= to_version do + IO.puts("✅ Already at latest PhoenixKit migration version (v#{to_version}). Nothing to do.") + else + generate_migration(from_version, to_version, prefix) + end + end + defp parse_args(args) do + {opts, _, _} = OptionParser.parse(args, switches: [prefix: :string]) + opts + end + + # Scan existing migration files to find the highest PhoenixKit version applied. + # Looks for files matching `*_phoenix_kit_update_v*_to_v*.exs` or `*_create_phoenix_kit_tables.exs`. + defp detect_current_version do + migrations_path = "priv/repo/migrations" + + if File.dir?(migrations_path) do + migrations_path + |> File.ls!() + |> Enum.filter(&String.ends_with?(&1, ".exs")) + |> Enum.flat_map(&extract_phoenix_kit_version/1) + |> Enum.max(fn -> 0 end) + else + 0 + end + end + + # Extract the "to" version from migration filenames like: + # - `20260310_phoenix_kit_update_v78_to_v80.exs` → 80 + # - `20260316_create_phoenix_kit_tables.exs` → 0 (initial install) + defp extract_phoenix_kit_version(filename) do + cond do + # Pattern: phoenix_kit_update_vXX_to_vYY.exs + match = Regex.run(~r/phoenix_kit_update_v\d+_to_v(\d+)/, filename) -> + [_, version_str] = match + [String.to_integer(version_str)] + + # Pattern: create_phoenix_kit_tables.exs (initial install = version 1) + String.contains?(filename, "create_phoenix_kit_tables") -> + [1] + + true -> + [] + end + end + + defp generate_migration(from_version, to_version, prefix) do timestamp = generate_timestamp() - filename = "#{timestamp}_create_#{table_prefix}_tables.exs" + slug = "phoenix_kit_update_v#{from_version}_to_v#{to_version}" + filename = "#{timestamp}_#{slug}.exs" path = Path.join("priv/repo/migrations", filename) File.mkdir_p!("priv/repo/migrations") - migration_content = generate_migration_content(table_prefix) - File.write!(path, migration_content) + app_module = app_module_name() + content = migration_content(app_module, slug, from_version, to_version, prefix) + File.write!(path, content) IO.puts("✅ Generated migration: #{filename}") + IO.puts(" Upgrades PhoenixKit: v#{from_version} → v#{to_version}") IO.puts("Run: mix ecto.migrate") end - defp parse_args(args) do - {opts, _, _} = OptionParser.parse(args, switches: [table_prefix: :string]) - opts - end - - defp generate_timestamp do - {{year, month, day}, {hour, minute, second}} = :calendar.universal_time() + defp app_module_name do + app = Mix.Project.config()[:app] - :io_lib.format( - "~4..0B~2..0B~2..0B~2..0B~2..0B~2..0B", - [year, month, day, hour, minute, second] - ) + app |> to_string() + |> Macro.camelize() end - defp generate_migration_content(table_prefix) do - module_name = Macro.camelize("create_#{table_prefix}_tables") - # For default phoenix_kit prefix, use phoenix_kit_users/phoenix_kit_users_tokens - # For custom prefixes, use prefix/prefix_tokens - {users_table, tokens_table} = - if table_prefix == "phoenix_kit" do - {"phoenix_kit_users", "phoenix_kit_users_tokens"} - else - {table_prefix, "#{table_prefix}_tokens"} - end + defp migration_content(app_module, slug, from_version, to_version, prefix) do + module_name = Macro.camelize(slug) + create_schema = prefix != "public" """ - defmodule #{Mix.Phoenix.context_app()}.Repo.Migrations.#{module_name} do + defmodule #{app_module}.Repo.Migrations.#{module_name} do + @moduledoc false use Ecto.Migration - def change do - execute "CREATE EXTENSION IF NOT EXISTS citext", "" + @disable_ddl_transaction true - create table(:#{users_table}, primary_key: false) do - add :uuid, :binary_id, primary_key: true - add :email, :citext, null: false - add :hashed_password, :string, null: false - add :confirmed_at, :utc_datetime - - timestamps(type: :utc_datetime) - end - - create unique_index(:#{users_table}, [:email]) - - create table(:#{tokens_table}, primary_key: false) do - add :uuid, :binary_id, primary_key: true - add :user_uuid, - references(:#{users_table}, column: :uuid, type: :binary_id, on_delete: :delete_all), - null: false - add :token, :binary, null: false - add :context, :string, null: false - add :sent_to, :string - add :ip_address, :string - add :user_agent_hash, :string - - timestamps(type: :utc_datetime, updated_at: false) - end + def up do + # PhoenixKit Update Migration: V#{from_version} -> V#{to_version} + PhoenixKit.Migrations.up( + prefix: "#{prefix}", + version: #{to_version}, + create_schema: #{create_schema} + ) + end - create index(:#{tokens_table}, [:user_uuid]) - create unique_index(:#{tokens_table}, [:context, :token]) + def down do + # Rollback PhoenixKit to V#{from_version} + PhoenixKit.Migrations.down( + prefix: "#{prefix}", + version: #{from_version} + ) end end """ end + + defp generate_timestamp do + {{year, month, day}, {hour, minute, second}} = :calendar.universal_time() + + :io_lib.format( + "~4..0B~2..0B~2..0B~2..0B~2..0B~2..0B", + [year, month, day, hour, minute, second] + ) + |> to_string() + end end diff --git a/lib/phoenix_kit/dashboard/tab.ex b/lib/phoenix_kit/dashboard/tab.ex index 9e0ed2623..058985a1f 100644 --- a/lib/phoenix_kit/dashboard/tab.ex +++ b/lib/phoenix_kit/dashboard/tab.ex @@ -236,7 +236,7 @@ defmodule PhoenixKit.Dashboard.Tab do redirect_to_first_subtab: get_attr(attrs, :redirect_to_first_subtab) || false, highlight_with_subtabs: get_attr(attrs, :highlight_with_subtabs) || false, match: parse_match(get_attr(attrs, :match) || :prefix), - visible: get_attr(attrs, :visible) || true, + visible: if(is_nil(get_attr(attrs, :visible)), do: true, else: get_attr(attrs, :visible)), badge: badge, tooltip: get_attr(attrs, :tooltip), external: get_attr(attrs, :external) || false, @@ -248,7 +248,13 @@ defmodule PhoenixKit.Dashboard.Tab do } end - defp get_attr(attrs, key), do: attrs[key] || attrs[Atom.to_string(key)] + defp get_attr(attrs, key) do + cond do + Map.has_key?(attrs, key) -> Map.get(attrs, key) + Map.has_key?(attrs, Atom.to_string(key)) -> Map.get(attrs, Atom.to_string(key)) + true -> nil + end + end @doc """ Creates a new Tab struct, raising on error. @@ -288,7 +294,7 @@ defmodule PhoenixKit.Dashboard.Tab do priority: opts[:priority] || 500, group: opts[:group], match: :exact, - visible: opts[:visible] || true, + visible: if(is_nil(opts[:visible]), do: true, else: opts[:visible]), metadata: %{type: :divider} } end diff --git a/lib/phoenix_kit/migrations/postgres.ex b/lib/phoenix_kit/migrations/postgres.ex index b29b6c8a6..38c814293 100644 --- a/lib/phoenix_kit/migrations/postgres.ex +++ b/lib/phoenix_kit/migrations/postgres.ex @@ -529,7 +529,12 @@ defmodule PhoenixKit.Migrations.Postgres do - Replaces unique index with partial index (slug-mode only, WHERE slug IS NOT NULL) - Adds unique index on `(group_uuid, post_date, post_time)` for timestamp-mode posts - ### V83 - Add status to publishing groups ⚡ LATEST + ### V84 - Rename mailing tables to newsletters ⚡ LATEST + - Idempotently renames `phoenix_kit_mailing_*` tables to `phoenix_kit_newsletters_*` + - Fixes databases that ran the old V79 (which created `mailing_*` tables) + - Safe to run multiple times — uses IF EXISTS guards + + ### V83 - Add status to publishing groups - Adds `status` column (varchar(20), default 'active') to `phoenix_kit_publishing_groups` - Supports soft-delete via "trashed" status - Adds index on `(status)` for filtering @@ -645,7 +650,7 @@ defmodule PhoenixKit.Migrations.Postgres do use Ecto.Migration @initial_version 1 - @current_version 83 + @current_version 84 @default_prefix "public" @doc false diff --git a/lib/phoenix_kit/migrations/postgres/v84.ex b/lib/phoenix_kit/migrations/postgres/v84.ex new file mode 100644 index 000000000..7622a5d72 --- /dev/null +++ b/lib/phoenix_kit/migrations/postgres/v84.ex @@ -0,0 +1,96 @@ +defmodule PhoenixKit.Migrations.Postgres.V84 do + @moduledoc """ + V84: Rename mailing tables to newsletters. + + The mailing module was renamed to newsletters. V79 was updated to create + `phoenix_kit_newsletters_*` tables, but databases that already ran the old V79 + (which created `phoenix_kit_mailing_*` tables) were not migrated. + + This migration idempotently renames any remaining `mailing_*` tables to + `newsletters_*`. Safe to run multiple times — uses IF EXISTS guards. + """ + + 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 + + rename_if_needed(schema, p, "phoenix_kit_mailing_lists", "phoenix_kit_newsletters_lists") + + rename_if_needed( + schema, + p, + "phoenix_kit_mailing_list_members", + "phoenix_kit_newsletters_list_members" + ) + + rename_if_needed( + schema, + p, + "phoenix_kit_mailing_broadcasts", + "phoenix_kit_newsletters_broadcasts" + ) + + rename_if_needed( + schema, + p, + "phoenix_kit_mailing_deliveries", + "phoenix_kit_newsletters_deliveries" + ) + + execute("COMMENT ON TABLE #{p}phoenix_kit IS '84'") + end + + def down(opts) do + prefix = Map.get(opts, :prefix, "public") + p = prefix_str(prefix) + schema = if prefix == "public", do: "public", else: prefix + + rename_if_needed( + schema, + p, + "phoenix_kit_newsletters_deliveries", + "phoenix_kit_mailing_deliveries" + ) + + rename_if_needed( + schema, + p, + "phoenix_kit_newsletters_broadcasts", + "phoenix_kit_mailing_broadcasts" + ) + + rename_if_needed( + schema, + p, + "phoenix_kit_newsletters_list_members", + "phoenix_kit_mailing_list_members" + ) + + rename_if_needed(schema, p, "phoenix_kit_newsletters_lists", "phoenix_kit_mailing_lists") + + execute("COMMENT ON TABLE #{p}phoenix_kit IS '83'") + end + + defp rename_if_needed(schema, p, from_table, to_table) do + execute(""" + DO $$ + BEGIN + IF EXISTS ( + SELECT FROM information_schema.tables + WHERE table_schema = '#{schema}' AND table_name = '#{from_table}' + ) AND NOT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_schema = '#{schema}' AND table_name = '#{to_table}' + ) THEN + ALTER TABLE #{p}#{from_table} RENAME TO #{to_table}; + END IF; + END $$; + """) + end + + defp prefix_str("public"), do: "public." + defp prefix_str(prefix), do: "#{prefix}." +end diff --git a/lib/phoenix_kit_web/components/auth_page_wrapper.ex b/lib/phoenix_kit_web/components/auth_page_wrapper.ex index c8522ad18..2ad96854e 100644 --- a/lib/phoenix_kit_web/components/auth_page_wrapper.ex +++ b/lib/phoenix_kit_web/components/auth_page_wrapper.ex @@ -52,7 +52,8 @@ defmodule PhoenixKitWeb.Components.AuthPageWrapper do page_title={@page_title} > {raw(@bg_style_tag)} -
+ <%!-- z-10: above footer (z-auto/0) but below sticky header (z-50) in parent layouts --%> +
<%= if @auth_logo_url != "" do %>