Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .dialyzer_ignore.exs
Original file line number Diff line number Diff line change
@@ -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},
Expand Down
164 changes: 102 additions & 62 deletions lib/mix/tasks/phoenix_kit.gen.migration.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2,110 +2,150 @@ 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

mix phoenix_kit.gen.migration

## 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
12 changes: 9 additions & 3 deletions lib/phoenix_kit/dashboard/tab.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down
9 changes: 7 additions & 2 deletions lib/phoenix_kit/migrations/postgres.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
96 changes: 96 additions & 0 deletions lib/phoenix_kit/migrations/postgres/v84.ex
Original file line number Diff line number Diff line change
@@ -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
3 changes: 2 additions & 1 deletion lib/phoenix_kit_web/components/auth_page_wrapper.ex
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@ defmodule PhoenixKitWeb.Components.AuthPageWrapper do
page_title={@page_title}
>
{raw(@bg_style_tag)}
<div class="auth-bg fixed inset-x-0 top-16 bottom-0 flex items-center justify-center px-4 py-8 overflow-auto">
<%!-- z-10: above footer (z-auto/0) but below sticky header (z-50) in parent layouts --%>
<div class="auth-bg fixed inset-x-0 top-16 bottom-0 z-10 flex items-center justify-center px-4 py-8 overflow-auto">
<div class="card bg-base-100 w-full max-w-sm shadow-2xl">
<div class="card-body">
<%= if @auth_logo_url != "" do %>
Expand Down
Loading